6 directories, 29 files

tinai

Home / tinai
/**
 * 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;
🌐
api.js ×
Type: Web, text/plain
12.79 Kilobytes
Last Modified 2026-09-23 10:29:56
⬇ Download File