/**
* Service class to handle communication with the TinAI backend API.
*/
class Api {
static MODELS = {};
static PRESETS = {};
static UTILITY_MODELS = {};
#endpoint = 'api.php';
static #modelsPromise = null;
#active_requests = 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;
}
/**
* 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) {
return { models: Api.MODELS, presets: Api.PRESETS, utilityModels: Api.UTILITY_MODELS };
}
if (Api.#modelsPromise) {
return Api.#modelsPromise;
}
Api.#modelsPromise = (async () => {
try {
const [modelsResp, presetsResp, utilityResp] = await Promise.all([
fetch('models.json'),
fetch('model-presets.json'),
fetch('utility-models.json')
]);
Api.MODELS = await modelsResp.json();
Api.PRESETS = await presetsResp.json();
Api.UTILITY_MODELS = await utilityResp.json();
return { models: Api.MODELS, presets: Api.PRESETS, utilityModels: Api.UTILITY_MODELS };
} catch (error) {
console.error('Failed to load models.json, model-presets.json, or utility-models.json:', error);
return { models: {}, presets: {}, utilityModels: {} };
} 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;
}
/**
* 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.
* @returns {Promise<void>}
*/
async post(data, on_success, on_failure, function_name = 'chat', on_thinking = null, on_progress = null, guid = '', signal = 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 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.#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 headers = {
'Content-Type': 'application/json'
};
if (typeof localStorage !== 'undefined') {
const token = localStorage.getItem('AUTH_TOKEN');
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const locationId = localStorage.getItem('LOCATION_ID');
if (locationId) {
headers['X-Location-ID'] = locationId;
}
}
const response = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(data),
signal: reqAbortController.signal
});
if (response.status === 401) {
console.warn('Session expired or unauthorized. Clearing auth token.');
if (typeof localStorage !== 'undefined') {
localStorage.removeItem('AUTH_TOKEN');
}
window.location.reload();
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 === 'thinking') {
if (typeof on_thinking === 'function') {
on_thinking(parsed.text, targetGuid);
}
if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function') {
window.dispatchEvent(new CustomEvent('tinai:thinking', {
detail: { text: parsed.text, guid: targetGuid, requestId }
}));
}
} else if (parsed) {
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(new Error(lastResponseObject.error));
} else {
safeSuccess(lastResponseObject);
}
} else {
safeFailure(new Error('No final response received from stream'));
}
} else {
const response_object = await response.json();
if (response_object && response_object.error) {
safeFailure(new Error(response_object.error));
} 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();
}
}
/**
* Retrieves the current user's ID from the backend.
* @returns {Promise<string|null>} The user ID or null on failure.
*/
async get_user_id() {
return new Promise((resolve) => {
this.post({}, (response) => {
if (response && response.user_id) {
resolve(response.user_id);
} else {
console.error('User ID not found in response');
resolve(null);
}
}, (error) => {
console.error('Failed to get user ID:', error);
resolve(null);
}, 'get_user_id');
});
}
/**
* Retrieves version information from the backend.
* @param {number|null} [count=null] - Number of versions to retrieve. Defaults to null (latest version).
* @returns {Promise<Object|null>}
*/
async get_version(count = null) {
const payload = count ? { count } : {};
return new Promise((resolve) => {
this.post(payload, (response) => {
resolve(response);
}, (error) => {
console.error('Failed to get version:', error);
resolve(null);
}, 'version');
});
}
/**
* 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. { google_search: true }).
* @returns {Promise<Object>}
*/
async evaluate_utility(input, utility, utility_model = '', on_thinking = null, on_progress = null, meta_context = {}) {
const payload = {
input: input || '',
utility: utility || '',
utility_model: utility_model || '',
meta_context: meta_context || {}
};
return new Promise((resolve, reject) => {
this.post(payload, (response) => {
resolve(response);
}, (error) => {
console.error('Failed to execute utility evaluation:', error);
reject(error);
}, 'utility', on_thinking, on_progress);
});
}
}
export default Api;