9 directories, 58 files

tinai

Home / tinai
/**
 * 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;
🌐
api.js ×
Type: Web, text/x-c++
22.21 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File