/**
* A model entry within a model family, loaded from models.json.
* @typedef {Object} ModelEntry
* @property {string} model - API model identifier.
* @property {string} suffix - Display suffix.
* @property {string} short_suffix - Short display suffix.
* @property {string} description - Model description.
* @property {string[]} [thinking_modes] - Supported thinking levels.
* @property {boolean} [tools] - Whether tools are supported.
*/
/**
* A model family entry, loaded from models.json.
* @typedef {Object} ModelFamily
* @property {string} family - Family display name.
* @property {string} provider - Provider key (e.g. 'gemini').
* @property {ModelEntry[]} models - Models in the family.
*/
/**
* A preset entry, loaded from model-presets.json / utility-models.json.
* @typedef {Object} ModelPreset
* @property {string} name - Display name.
* @property {string} description - Preset description.
* @property {string} model - API model identifier.
* @property {string|null} [thinking] - Fixed thinking level, if any.
* @property {string} provider - Provider key.
*/
/**
* A voice model entry within a voice family, loaded from voice-models.json.
* @typedef {Object} VoiceModelEntry
* @property {string} key - API model identifier.
* @property {string} short_name - Short display name.
* @property {string} name - Full display name.
* @property {string} description - Model description.
* @property {Object} [rates] - Per-million-token rates (in/out).
*/
/**
* A voice entry within a voice family, loaded from voice-models.json.
* @typedef {Object} VoiceEntry
* @property {string} id - Voice identifier used for lookup.
* @property {string} name - Display name.
* @property {string} code - Provider voice code.
* @property {string[]} providers - Model keys the voice is available on.
*/
/**
* A voice family entry, loaded from voice-models.json.
* @typedef {Object} VoiceFamily
* @property {string} family - Family display name.
* @property {VoiceModelEntry[]} models - Voice models in the family.
* @property {VoiceEntry[]} voices - Voices applicable to the family's models.
*/
/**
* Utility (e.g. instant answer) API response.
* @typedef {Object} UtilityResponse
* @property {boolean} [evaluation] - Whether the utility evaluation succeeded.
* @property {string} [explanation] - The utility answer (HTML).
* @property {string|string[]} [thinking] - Thought lines or newline-separated thoughts.
* @property {Array<Object>} [annotations] - Search grounding annotations.
* @property {string} [error] - Error message when the request failed.
*/
import { get_auth_storage_key } from './util/auth-storage.js';
/**
* Service class to handle communication with the TinAI backend API.
*/
class Api {
/** @type {Record<string, ModelFamily>} */
static MODELS = {};
/** @type {Record<string, ModelPreset>} */
static PRESETS = {};
/** @type {Record<string, ModelPreset>} */
static UTILITY_MODELS = {};
/** @type {Record<string, VoiceFamily>} */
static VOICE_MODELS = {};
#endpoint = 'api.php';
static #modelsPromise = null;
#active_requests = new Map();
/** @type {Map<string, string[]>} Raw streamed thinking tokens per conversation guid. */
#token_buffers = new Map();
/**
* Initializes the Api service and triggers loading of model definitions and presets.
*/
constructor() {
void this.load_models();
}
/**
* Retrieves an array of currently active request metadata objects.
* @returns {Array<Object>} Active request metadata entries.
*/
get_active_requests() {
return Array.from(this.#active_requests.values()).map(req => ({
id: req.id,
guid: req.guid,
function_name: req.function_name,
startTime: req.startTime
}));
}
/**
* Checks whether any request (or a request for a specific GUID) is currently in progress.
* @param {string|null} [guid] - Optional conversation GUID to check.
* @returns {boolean} True if an active request matches the criteria.
*/
is_request_active(guid = null) {
if (guid !== undefined && guid !== null) {
return Array.from(this.#active_requests.values()).some(req => req.guid === guid);
}
return this.#active_requests.size > 0;
}
/**
* Returns the raw thinking tokens streamed so far for a conversation's active request.
* @param {string} guid - Conversation GUID.
* @returns {string[]} Tokens received for the current request; empty when none.
*/
get_tokens(guid) {
return this.#token_buffers.get(guid) || [];
}
/**
* Aborts a specific request by ID or all active requests matching a conversation GUID.
* @param {string} requestIdOrGuid - The request ID or conversation GUID.
*/
abort_request(requestIdOrGuid) {
if (!requestIdOrGuid) return;
if (this.#active_requests.has(requestIdOrGuid)) {
this.#active_requests.get(requestIdOrGuid).abortController.abort();
return;
}
for (const req of this.#active_requests.values()) {
if (req.guid === requestIdOrGuid) {
req.abortController.abort();
}
}
}
/**
* Aborts all currently in-flight API requests across the application.
*/
abort_all() {
for (const req of this.#active_requests.values()) {
req.abortController.abort();
}
}
/**
* Loads available model definitions from models.json and presets from model-presets.json.
* @returns {Promise<Object>}
*/
async load_models() {
if (Object.keys(Api.MODELS).length > 0 && Object.keys(Api.PRESETS).length > 0 && Object.keys(Api.UTILITY_MODELS).length > 0 && Object.keys(Api.VOICE_MODELS).length > 0) {
return { models: Api.MODELS, presets: Api.PRESETS, utilityModels: Api.UTILITY_MODELS, voiceModels: Api.VOICE_MODELS };
}
if (Api.#modelsPromise) {
return Api.#modelsPromise;
}
Api.#modelsPromise = (async () => {
try {
const [modelsResp, presetsResp, utilityResp, voiceModelsResp] = await Promise.all([
fetch('models.json'),
fetch('model-presets.json'),
fetch('utility-models.json'),
fetch('voice-models.json')
]);
Api.MODELS = await modelsResp.json();
Api.PRESETS = await presetsResp.json();
Api.UTILITY_MODELS = await utilityResp.json();
Api.VOICE_MODELS = await voiceModelsResp.json();
return { models: Api.MODELS, presets: Api.PRESETS, utilityModels: Api.UTILITY_MODELS, voiceModels: Api.VOICE_MODELS };
} catch (error) {
console.error('Failed to load models.json, model-presets.json, utility-models.json, or voice-models.json:', error);
return { models: {}, presets: {}, utilityModels: {}, voiceModels: {} };
} finally {
Api.#modelsPromise = null;
}
})();
return Api.#modelsPromise;
}
/**
* Formats the query and context into a data object for the API request.
* @param {string} [query=''] - The user's input string.
* @param {Array} [context=[]] - The conversational history/context.
* @param {string} [verbosity='standard'] - The level of verbosity requested.
* @param {string} [model_key='basic'] - The key for the selected model or preset.
* @param {object} [meta_context={}] - Meta context object.
* @param {boolean} [debug=false] - Whether to enable debug mode.
* @param {object} [model_info={}] - Optional detailed model selection (family, model, thinking).
* @returns {Object} The formatted data object.
*/
format_data(query = '', context = [], verbosity = 'standard', model_key = '', meta_context = {}, debug = false, model_info = {}) {
const resolvedModelKey = model_key || (model_info?.model || 'basic');
const payload = {
context: Array.isArray(context) ? context : [],
debug: !!debug,
query: query || '',
verbosity: verbosity || 'standard',
model_key: resolvedModelKey,
meta_context: meta_context || {}
};
if (model_info && typeof model_info === 'object') {
if (model_info.family) payload.family = model_info.family;
if (model_info.model) payload.model = model_info.model;
if (model_info.thinking !== undefined) payload.thinking = model_info.thinking;
}
return payload;
}
/**
* Builds the request headers, including the auth token and location id
* when present.
* @returns {Object} The request headers.
*/
#auth_headers() {
const headers = {
'Content-Type': 'application/json'
};
if (typeof localStorage !== 'undefined') {
const token = localStorage.getItem(get_auth_storage_key('AUTH_TOKEN'));
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const locationId = localStorage.getItem(get_auth_storage_key('LOCATION_ID'));
if (locationId) {
headers['X-Location-ID'] = locationId;
}
}
return headers;
}
/**
* Handles an unauthorized response by clearing the auth token and reloading.
* @returns {void}
*/
#handle_unauthorized() {
console.warn('Session expired or unauthorized. Clearing auth token.');
if (typeof localStorage !== 'undefined') {
localStorage.removeItem(get_auth_storage_key('AUTH_TOKEN'));
}
window.location.reload();
}
/**
* Sends a JSON POST request and resolves with the response object, or
* rejects on transport and API errors. For the simple request/response
* endpoints; NDJSON streaming responses need post().
* @param {Object} data - The payload to send.
* @param {string} [function_name='chat'] - The API function to call.
* @param {Function|null} [on_thinking=null] - Callback executed on each thinking chunk: on_thinking(text, guid).
* @param {Function|null} [on_progress=null] - Callback executed with bytes received: on_progress(bytes, guid).
* @returns {Promise<Object>} The response object.
*/
request(data, function_name = 'chat', on_thinking = null, on_progress = null) {
return new Promise((resolve, reject) => {
this.post(data, resolve, reject, function_name, on_thinking, on_progress);
});
}
/**
* Sends a POST request to the API endpoint with optional NDJSON streaming, abort support, and active request tracking.
* @param {Object} data - The payload to send.
* @param {Function} on_success - Callback executed on successful response: on_success(response, guid).
* @param {Function} on_failure - Callback executed on request or parsing error: on_failure(error, guid).
* @param {string} [function_name='chat'] - The API function to call.
* @param {Function|null} [on_thinking=null] - Callback executed on each thinking chunk: on_thinking(text, guid).
* @param {Function|null} [on_progress=null] - Callback executed with bytes received: on_progress(bytes, guid).
* @param {string|AbortSignal|null} [guid=''] - Conversation GUID associated with this request or AbortSignal.
* @param {AbortSignal|string|null} [signal=null] - Optional external AbortSignal to cancel the request.
* @param {Function|null} [on_token=null] - Callback executed on each raw streamed token: on_token(text, guid).
* @returns {Promise<void>}
*/
async post(data, on_success, on_failure, function_name = 'chat', on_thinking = null, on_progress = null, guid = '', signal = null, on_token = null) {
let targetGuid = '';
let targetSignal = null;
if (typeof guid === 'string') {
targetGuid = guid;
targetSignal = signal instanceof AbortSignal ? signal : null;
} else if (guid instanceof AbortSignal) {
targetSignal = guid;
targetGuid = typeof signal === 'string' ? signal : '';
} else if (typeof signal === 'string') {
targetGuid = signal;
}
if (!targetGuid && data && typeof data === 'object' && typeof data.guid === 'string') {
targetGuid = data.guid;
}
const localThinkingOnly = !!(data && typeof data === 'object' && data.local_thinking);
const reqAbortController = new AbortController();
if (targetSignal) {
if (targetSignal.aborted) {
reqAbortController.abort(targetSignal.reason);
} else {
targetSignal.addEventListener('abort', () => reqAbortController.abort(targetSignal.reason), { once: true });
}
}
const requestId = 'req_' + Date.now() + '_' + Math.random().toString(36).substring(2, 9);
const requestRecord = {
id: requestId,
guid: targetGuid,
function_name,
abortController: reqAbortController,
startTime: Date.now()
};
this.#token_buffers.delete(targetGuid);
this.#active_requests.set(requestId, requestRecord);
if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function') {
window.dispatchEvent(new CustomEvent('tinai:request-start', {
detail: { requestId, guid: targetGuid, function_name }
}));
}
const cleanupRequest = () => {
if (this.#active_requests.has(requestId)) {
this.#active_requests.delete(requestId);
if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function') {
window.dispatchEvent(new CustomEvent('tinai:request-stop', {
detail: { requestId, guid: targetGuid, function_name }
}));
}
}
};
const safeSuccess = (resp) => {
cleanupRequest();
if (typeof on_success === 'function') {
on_success(resp, targetGuid);
}
};
const safeFailure = (err) => {
cleanupRequest();
if (typeof on_failure === 'function') {
on_failure(err, targetGuid);
} else {
console.error(err);
}
};
try {
const url = `${this.#endpoint}?function=${function_name}`;
const response = await fetch(url, {
method: 'POST',
headers: this.#auth_headers(),
body: JSON.stringify(data),
signal: reqAbortController.signal
});
if (response.status === 401) {
this.#handle_unauthorized();
safeFailure({ error: 'Session expired' });
return;
}
const contentType = response.headers.get('content-type') || '';
if (contentType.includes('application/x-ndjson')) {
if (!response.body || typeof response.body.getReader !== 'function') {
safeFailure(new Error('ReadableStream not supported by browser environment.'));
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let lastResponseObject = null;
let bytesReceived = 0;
const processChunk = (chunkText) => {
if (!chunkText || !chunkText.trim()) return;
try {
const parsed = JSON.parse(chunkText);
if (parsed && parsed.type === 'token') {
let tokens = this.#token_buffers.get(targetGuid);
if (!tokens) {
tokens = [];
this.#token_buffers.set(targetGuid, tokens);
}
tokens.push(parsed.text);
if (typeof on_token === 'function') {
on_token(parsed.text, targetGuid);
}
if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function') {
window.dispatchEvent(new CustomEvent('tinai:tokens', {
detail: { guid: targetGuid, tokens }
}));
}
} else if (parsed && parsed.type === 'thinking') {
if (typeof on_thinking === 'function') {
on_thinking(parsed.text, targetGuid);
}
if (!localThinkingOnly && typeof window !== 'undefined' && typeof window.dispatchEvent === 'function') {
window.dispatchEvent(new CustomEvent('tinai:thinking', {
detail: { text: parsed.text, guid: targetGuid, requestId }
}));
}
} else if (parsed && parsed.type !== 'ping') {
lastResponseObject = parsed;
}
} catch (e) {
console.error('Failed to parse stream chunk:', chunkText, e);
}
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
bytesReceived += value.length;
if (typeof on_progress === 'function') {
on_progress(bytesReceived, targetGuid);
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop(); // Keep the last incomplete line
for (const line of lines) {
processChunk(line);
}
}
processChunk(buffer);
if (lastResponseObject) {
if (lastResponseObject.error) {
safeFailure(lastResponseObject);
} else {
safeSuccess(lastResponseObject);
}
} else {
safeFailure({
error: 'The response stream ended before a final answer was received.',
error_type: 'tinai_timeout'
});
}
} else {
const response_object = await response.json();
if (response_object && response_object.error) {
safeFailure(response_object);
} else {
safeSuccess(response_object);
}
}
} catch (error) {
if (error.name === 'AbortError' || reqAbortController.signal.aborted) {
console.log(`Request ${requestId} (${targetGuid || function_name}) aborted.`);
safeFailure({ status: 'aborted', error: 'Request aborted' });
} else {
console.error('API Error:', error);
safeFailure(error);
}
} finally {
cleanupRequest();
}
}
/**
* Sends a POST request expecting a raw audio byte stream and delivers each
* chunk to on_chunk as it arrives. A JSON error response (sent before any
* audio) rejects the returned promise.
* @param {Object} data - The payload to send.
* @param {Function} on_chunk - Called with each Uint8Array chunk.
* @param {string} [function_name='tts'] - The API function to call.
* @param {AbortSignal|null} [signal=null] - Optional signal to cancel the request.
* @returns {Promise<void>} Resolves when the stream ends.
*/
async post_stream(data, on_chunk, function_name = 'tts', signal = null) {
const reqAbortController = new AbortController();
if (signal) {
if (signal.aborted) {
reqAbortController.abort(signal.reason);
} else {
signal.addEventListener('abort', () => reqAbortController.abort(signal.reason), { once: true });
}
}
const response = await fetch(`${this.#endpoint}?function=${function_name}`, {
method: 'POST',
headers: this.#auth_headers(),
body: JSON.stringify(data),
signal: reqAbortController.signal
});
if (response.status === 401) {
this.#handle_unauthorized();
throw new Error('Session expired');
}
const contentType = response.headers.get('content-type') || '';
if (!response.ok || contentType.includes('application/json')) {
let message = `Request failed (${response.status})`;
try {
const errorObject = await response.json();
if (errorObject && errorObject.error) {
message = errorObject.error;
}
} catch (e) {
// keep the generic message when the body is not JSON
}
throw new Error(message);
}
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
on_chunk(value);
}
}
/**
* Retrieves the current user's ID from the backend.
* @returns {Promise<string|null>} The user ID or null on failure.
*/
async get_user_id() {
try {
const response = await this.request({}, 'get_user_id');
if (response?.user_id) return response.user_id;
console.error('User ID not found in response');
} catch (error) {
console.error('Failed to get user ID:', error);
}
return null;
}
/**
* Retrieves version information from the backend.
* @param {number|null} [count=null] - Number of versions to retrieve. Defaults to null (latest version).
* @returns {Promise<{versions?: Array<Object>, error?: string}|null>}
*/
async get_version(count = null) {
try {
return await this.request(count ? { count } : {}, 'version');
} catch (error) {
console.error('Failed to get version:', error);
return null;
}
}
/**
* Retrieves the server-delivered experimental feature definitions.
* @returns {Promise<{experimental_allowed?: boolean, features?: Array<Object>, error?: string}|null>}
*/
async get_features() {
try {
return await this.request({}, 'features');
} catch (error) {
console.error('Failed to get features:', error);
return null;
}
}
/**
* Retrieves the tool metadata and the current session tool configuration.
* @returns {Promise<{tools?: Array<Object>, config?: Object, error?: string}|null>}
*/
async get_tool_config() {
try {
return await this.request({ action: 'get' }, 'toolconfig');
} catch (error) {
console.error('Failed to get tool config:', error);
return null;
}
}
/**
* Saves the enabled tools for the current session.
* @param {Object} tools - Map of tool name to enabled boolean.
* @returns {Promise<{tools?: Object, error?: string}|null>}
*/
async save_tool_config(tools) {
try {
return await this.request({ action: 'save', tools }, 'toolconfig');
} catch (error) {
console.error('Failed to save tool config:', error);
return null;
}
}
/**
* Evaluates an input string against a utility function using the configured utility model.
* @param {string} input - The input string to evaluate.
* @param {string} utility - The utility function name (e.g. 'location', 'instant_answer').
* @param {string} [utility_model=''] - Optional utility model key override.
* @param {Function|null} [on_thinking=null] - Optional callback for streaming thought lines.
* @param {Function|null} [on_progress=null] - Optional callback for streaming progress bytes.
* @param {object} [meta_context={}] - Optional meta context (e.g. { search: true }).
* @param {boolean} [readable=false] - Whether the response should be optimized for reading aloud.
* @returns {Promise<UtilityResponse>}
*/
async evaluate_utility(input, utility, utility_model = '', on_thinking = null, on_progress = null, meta_context = {}, readable = false) {
const payload = {
input: input || '',
utility: utility || '',
utility_model: utility_model || '',
meta_context: meta_context || {}
};
if (readable) {
payload.readable = true;
}
try {
return await this.request(payload, 'utility', on_thinking, on_progress);
} catch (error) {
console.error('Failed to execute utility evaluation:', error);
throw error;
}
}
/**
* Analyzes a code block with the conversation's model.
* @param {string} code - The raw code text.
* @param {string} language - The highlight.js language name, may be empty.
* @param {string} model_key - The conversation's model key.
* @param {object} [meta_context={}] - Optional meta context (e.g. { search: true }).
* @param {Function|null} [on_thinking=null] - Optional callback for streaming thought lines.
* @returns {Promise<{items?: Array<{lineStart: number, lineEnd: number, type: string, title: string, analysis: string}>, annotations?: Array<Object>, thinking?: string|string[], error?: string}>}
*/
async analyze_code(code, language, model_key = '', meta_context = {}, on_thinking = null) {
const payload = {
code: code || '',
language: language || '',
model_key: model_key || '',
meta_context: meta_context || {}
};
try {
return await this.request(payload, 'analyze', on_thinking);
} catch (error) {
console.error('Failed to analyze code:', error);
throw error;
}
}
}
export default Api;