/**
* Service class to handle communication with the TinAI backend API.
*/
class Api {
static MODELS = {};
#endpoint = 'api.php';
static #modelsPromise = null;
/**
* Initializes the Api service and triggers loading of model definitions.
*/
constructor() {
void this.load_models();
}
/**
* Loads available model definitions from models.json.
* @returns {Promise<Object>}
*/
async load_models() {
if (Object.keys(Api.MODELS).length > 0) {
return Api.MODELS;
}
if (Api.#modelsPromise) {
return Api.#modelsPromise;
}
Api.#modelsPromise = (async () => {
try {
const response = await fetch('models.json');
Api.MODELS = await response.json();
return Api.MODELS;
} catch (error) {
console.error('Failed to load models.json:', error);
return {};
} 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.
* @param {object} [meta_context={}] - Meta context object.
* @param {boolean} [debug=false] - Whether to enable debug mode.
* @returns {Object} The formatted data object.
*/
format_data(query = '', context = [], verbosity = 'standard', model_key = 'basic', meta_context = {}, debug = false) {
return {
context: Array.isArray(context) ? context : [],
debug: !!debug,
query: query || '',
verbosity: verbosity || 'standard',
model_key: model_key || 'basic',
meta_context: meta_context || {}
};
}
/**
* Sends a POST request to the API endpoint with optional NDJSON streaming and abort support.
* @param {Object} data - The payload to send.
* @param {Function} on_success - Callback executed on successful response.
* @param {Function} on_failure - Callback executed on request or parsing error.
* @param {string} [function_name='chat'] - The API function to call.
* @param {Function|null} [on_thinking=null] - Callback executed on each thinking chunk.
* @param {Function|null} [on_progress=null] - Callback executed with bytes received.
* @param {AbortSignal|null} [signal=null] - Abort signal to cancel the request.
* @returns {Promise<void>}
*/
async post(data, on_success, on_failure, function_name = 'chat', on_thinking = null, on_progress = null, signal = null) {
const safeSuccess = typeof on_success === 'function' ? on_success : () => {};
const safeFailure = typeof on_failure === 'function' ? on_failure : (err) => console.error(err);
try {
const url = `${this.#endpoint}?function=${function_name}`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data),
signal: signal
});
if (response.status === 401) {
console.warn('Session expired. Redirecting to login page.');
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;
while (true) {
const { done, value } = await reader.read();
if (done) break;
bytesReceived += value.length;
if (typeof on_progress === 'function') {
on_progress(bytesReceived);
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop(); // Keep the last incomplete line
for (const line of lines) {
if (line.trim()) {
try {
const parsed = JSON.parse(line);
if (parsed && parsed.type === 'thinking') {
if (typeof on_thinking === 'function') {
on_thinking(parsed.text);
}
} else {
lastResponseObject = parsed;
}
} catch (e) {
console.error('Failed to parse stream line:', line, e);
}
}
}
}
if (buffer.trim()) {
try {
const parsed = JSON.parse(buffer);
if (parsed && parsed.type === 'thinking') {
if (typeof on_thinking === 'function') {
on_thinking(parsed.text);
}
} else {
lastResponseObject = parsed;
}
} catch (e) {
console.error('Failed to parse final stream buffer:', buffer, e);
}
}
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') {
console.log('Request aborted.');
} else {
console.error('API Error:', error);
safeFailure(error);
}
}
}
/**
* 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');
});
}
}
export default Api;