0 directories, 11 files

tinai

Home / testing / ai / tinai
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
import hljs from 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/es/highlight.min.js';
import Api from './api.js';


// AI-COMPLETED: on mobile, when loading, if no conversation is selected, open the conversations panel
class App {

	BREAKPOINT_MOBILE = 780;

	KEY_APP_CONFIG = 'APP_CONFIG';
	KEY_CONFIG_THEME = 'THEME';
	KEY_CONFIG_SELECTED_CONVERSATION_GUID = 'SELECTED_CONVERSATION_GUID'

	KEY_APP_INDEX = 'APP_INDEX';
	KEY_INDEX_DATE_CREATED = 'DATE_CREATED';
	KEY_INDEX_DATE_UPDATED = 'DATE_UPDATED';
	KEY_INDEX_SYNC = 'SYNC';
	KEY_INDEX_GUID = 'GUID';

	KEY_APP_CONVERSATION_PREFIX = 'CONVERSATION_';
	KEY_CONVERSATION_TITLE = 'TITLE';
	KEY_CONVERSATION_SUMMARY = 'SUMMARY';

	#api = new Api();
	
	btn_send;
	prompt;
	div_title;
	div_list;
	div_response;
	select_theme;
	btn_index_new;
	btn_conversations;

	response_object = {};

	constructor() {
		mermaid.initialize({
			securityLevel: 'loose',
			theme: 'dark',
		});
		
		this.init_elements();
		this.init_listeners();
		this.init_interactions();
		this.on_app_config();
		this.on_app_index_updated();

		const config = this.get_app_config();
		if (window.innerWidth <= this.BREAKPOINT_MOBILE && !config[this.KEY_CONFIG_SELECTED_CONVERSATION_GUID]) {
			this.show_conversation_panel(true);
		}
	}

	async updateMermaid(){
		if (typeof mermaid !== 'undefined') {
			// noinspection JSUnresolvedReference
			await mermaid.run({
				querySelector: '.div-diagram-mermaid',
			});
		} else {
			console.log('No mermaid found.');
		}
	}

	init_elements() {
		this.btn_send = document.getElementById('btn-send');
		this.prompt = document.getElementById('id-prompt');
		this.div_title = document.getElementById('id-div-response-title');
		this.div_response = document.getElementById('id-div-response-render');
		this.select_theme = document.getElementById('id-select-theme');
		this.div_list = document.getElementById('id-div-list');
		this.btn_index_new = document.getElementById('id-index-btn-new');
		this.btn_conversations = document.getElementById('id-btn-conversations');
	}

	init_listeners(){
		window.addEventListener('storage', (event) => {
			console.log('Storage changed:', event);
			this.handle_storage_change(event);
		});

		const mediaQuery = window.matchMedia(`screen and (max-width: ${this.BREAKPOINT_MOBILE}px)`);
		const handleMediaChange = (e) => {
			this.show_conversation_panel(!e.matches);
		};
		mediaQuery.addEventListener('change', handleMediaChange);
		// Initialize state
		handleMediaChange(mediaQuery);
	}

	init_interactions(){
		this.btn_send.onclick = this.send.bind(this);
		this.select_theme.onchange = this.update_theme_setting.bind(this);
		this.btn_index_new.onclick = this.index_new.bind(this);
		this.btn_conversations.onclick = this.toggle_conversation_panel.bind(this);
	}

	handle_storage_change(event) {
		if (event.key === this.KEY_APP_CONFIG) {
			this.on_app_config();
		}
		if (event.key === this.KEY_APP_INDEX) {
			this.on_app_index_updated();
		}
		const config = this.get_app_config();
		const selected_guid = config[this.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
		if (event.key === this.KEY_APP_CONVERSATION_PREFIX + selected_guid) {
			this.on_conversation_updated();
		}
	}

	on_app_config(){
		this.apply_theme();
		this.apply_selected_index_class();
		this.on_conversation_updated();
	}

	update_theme_setting() {
		this.update_app_config(this.KEY_CONFIG_THEME, this.select_theme.value);
	}

	update_app_config(key, value){
		const config = this.get_app_config();
		config[key] = value;
		localStorage.setItem(this.KEY_APP_CONFIG, JSON.stringify(config));
		const event = new StorageEvent('storage', {
			key: this.KEY_APP_CONFIG
		});
		window.dispatchEvent( event );
	}

	get_app_config(){
		return JSON.parse(localStorage.getItem(this.KEY_APP_CONFIG) || '{}');
	}
	
	show_conversation_panel(show = true) {
		const bodies = document.querySelectorAll('.div-structure-body');
		bodies.forEach(body => {
			if (show) {
				body.classList.add('div-structure-body-conv-open');
				body.classList.remove('div-structure-body-conv-closed');
			} else {
				body.classList.remove('div-structure-body-conv-open');
				body.classList.add('div-structure-body-conv-closed');
			}
		});
	}

	// AI-COMPLETED: if styles must be loaded, show .page-loader, otherwise hide it when done loading
	apply_theme() {
		const loader = document.querySelector('.page-loader');
		const themes = ['theme_dark', 'theme_light'];
		const config = this.get_app_config();
		let theme = (config && config[this.KEY_CONFIG_THEME]) ? config[this.KEY_CONFIG_THEME] : 'theme_dark';

		if (!themes.includes(theme)) {
			theme = 'theme_dark';
		}

		this.select_theme.value = theme;

		const hljs_theme = theme.includes('light')
			? 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/default.min.css'
			: 'https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/dark.min.css';

		const required_hrefs = [
			hljs_theme,
			'dynamic.php?s=base&t=' + theme,
			'dynamic.php?s=style&t=' + theme
		];

		const existing_links = Array.from(document.querySelectorAll('link[rel="stylesheet"]'));
		const current_hrefs = existing_links.map(link => link.getAttribute('href'));

		const is_already_applied = required_hrefs.every(href => current_hrefs.includes(href));

		if (!is_already_applied) {
			if (loader){
				loader.style.display = 'block';
				loader.style.opacity = '0%';
				// Force reflow to ensure transition triggers
				loader.offsetHeight;
				loader.style.opacity = '100%';
			}
			const old_links = existing_links.filter(link => {
				const href = link.getAttribute('href') || '';
				return href.includes('dynamic.php?s=') || href.includes('highlight.js/11.11.1/styles/');
			});

			let loaded_count = 0;
			const on_link_load = () => {
				loaded_count++;
				if (loaded_count === required_hrefs.length) {
					old_links.forEach(link => link.remove());
					if (loader) {
						loader.style.opacity = '0%';
						loader.addEventListener('transitionend', () => {
							loader.style.display = 'none';
						}, { once: true });
					}
				}
			};

			required_hrefs.forEach(href => {
				let link = document.createElement('link');
				link.rel = 'stylesheet';
				link.href = href;
				link.onload = on_link_load;
				link.onerror = on_link_load;
				document.head.appendChild(link);
			});
		} else {
			if (loader) {
				loader.style.opacity = '0%';
				loader.addEventListener('transitionend', () => {
					loader.style.display = 'none';
				}, { once: true });
			}
		}
	}
	
	toggle_conversation_panel() {
		const bodies = document.querySelectorAll('.div-structure-body');
		bodies.forEach(body => {
			body.classList.toggle('div-structure-body-conv-open');
			body.classList.toggle('div-structure-body-conv-closed');
		});
	}

	update_app_index(guid, sync){
		const index = this.get_app_index();
		const now = new Date().getTime();
		let found = false;

		guid = guid.trim();

		if (guid !== '') {
			for (let i = 0; i < index.length; i++) {
				if (index[i][this.KEY_INDEX_GUID] === guid) {
					index[i][this.KEY_INDEX_DATE_UPDATED] = now;
					index[i][this.KEY_INDEX_SYNC] = sync;
					found = true;
					break;
				}
			}
		} else {
			guid = crypto.randomUUID();
		}

		if (!found) {
			const item = {};
			item[this.KEY_INDEX_GUID] = guid;
			item[this.KEY_INDEX_DATE_CREATED] = now;
			item[this.KEY_INDEX_DATE_UPDATED] = now;
			item[this.KEY_INDEX_SYNC] = sync;
			index.push(item);
		}

		localStorage.setItem(this.KEY_APP_INDEX, JSON.stringify(index));
		const event = new StorageEvent('storage', {
			key: this.KEY_APP_INDEX
		});
		window.dispatchEvent( event );
		return guid;
	}

	index_new() {
		const guid = this.update_app_index('', false);
		this.update_app_config(this.KEY_CONFIG_SELECTED_CONVERSATION_GUID, guid);
	}
	
	index_delete(guid) {
		const config = this.get_app_config();
		if (config[this.KEY_CONFIG_SELECTED_CONVERSATION_GUID] === guid) {
			this.update_app_config(this.KEY_CONFIG_SELECTED_CONVERSATION_GUID, '');
			this.show_conversation_panel(true);
		}
		let index = this.get_app_index();
		index = index.filter(item => item[this.KEY_INDEX_GUID] !== guid);
		localStorage.setItem(this.KEY_APP_INDEX, JSON.stringify(index));
		localStorage.removeItem(this.KEY_APP_CONVERSATION_PREFIX + guid);
		const event = new StorageEvent('storage', {
			key: this.KEY_APP_INDEX
		});
		window.dispatchEvent(event);
	}

	get_app_index(){
		return JSON.parse(localStorage.getItem(this.KEY_APP_INDEX) || '[]');
	}

	get_selected_conversation() {
		const config = this.get_app_config();
		const guid = config[this.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
		if (!guid) return null;
		return JSON.parse(localStorage.getItem(this.KEY_APP_CONVERSATION_PREFIX + guid) || '{}');
	}
	
	on_app_index_updated(){
		const index = this.get_app_index();
		this.div_list.innerHTML = '';
		index.sort((a, b) => b[this.KEY_INDEX_DATE_UPDATED] - a[this.KEY_INDEX_DATE_UPDATED]);
		
		index.forEach(item => {
			const guid = item[this.KEY_INDEX_GUID];
			const conversation = JSON.parse(localStorage.getItem(this.KEY_APP_CONVERSATION_PREFIX + guid) || '{}');
			const title = conversation[this.KEY_CONVERSATION_TITLE] || 'New Conversation';
			
			const div = document.createElement('div');
			div.className = 'div-index-item';
			div.id = 'conversation-' + guid;
			div.style.display = 'flex';
			div.style.justifyContent = 'space-between';
			div.onclick = () => {
				const config = this.get_app_config();
				const current_guid = config[this.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
				const new_guid = (current_guid === guid) ? '' : guid;
				this.update_app_config(this.KEY_CONFIG_SELECTED_CONVERSATION_GUID, new_guid);
			};
			
			const span_title = document.createElement('span');
			span_title.textContent = title;
			
			const btn_del = document.createElement('button');
			btn_del.textContent = 'X';
			btn_del.style.marginLeft = '4pt';
			btn_del.onclick = (e) => {
				e.stopPropagation();
				this.index_delete(guid);
			};

			div.appendChild(span_title);
			div.appendChild(btn_del);
			this.div_list.appendChild(div);
		});
		this.apply_selected_index_class();
	}
	apply_selected_index_class() {
		const config = this.get_app_config();
		const selected_guid = config[this.KEY_CONFIG_SELECTED_CONVERSATION_GUID];

		if (!selected_guid) {
			this.div_title.innerHTML = '<h4>Welcome</h4>';
		} else {
			this.div_title.innerHTML = this.conversation_header();
			document.getElementById('id-btn-close-conv').onclick = this.close_conversation.bind(this);
			if (window.innerWidth <= this.BREAKPOINT_MOBILE) {
				this.show_conversation_panel(false);
			}
		}

		this.div_list.querySelectorAll('.div-index-item.div-index-item-selected').forEach(item => {
			item.classList.remove('div-index-item-selected');
		});

		const selected_item = document.getElementById('conversation-' + selected_guid);
		if (selected_item) {
			selected_item.classList.add('div-index-item-selected');
		}

		const ui_elements = document.querySelectorAll('.div-chat-ui-elements');
		const empty_elements = document.querySelectorAll('.div-chat-empty');

		if (selected_guid) {
			ui_elements.forEach(el => el.style.display = '');
			empty_elements.forEach(el => el.style.display = 'none');
		} else {
			ui_elements.forEach(el => el.style.display = 'none');
			empty_elements.forEach(el => el.style.display = '');
		}
	}

	conversation_header() {
		return '<div style="display: flex; justify-content: space-between; align-items: center;">' +
			'<h4>Options</h4>' +
			'<button id="id-btn-close-conv">X</button>' +
			'</div>';
	}

	close_conversation() {
		this.update_app_config(this.KEY_CONFIG_SELECTED_CONVERSATION_GUID, '');
		this.show_conversation_panel(true);
	}

	send(){
		this.btn_send.disabled = true;
		this.btn_send.textContent = 'Sending...';

		const conversation = this.get_selected_conversation();
		const context = (conversation && conversation['CONTEXT']) ? conversation['CONTEXT'] : [];

		this.#api.post(
			this.#api.format_data(
				this.prompt.value,
				context
			),
			(response) => this.send_success(response),
			(response) => this.send_failure(response)
		).then(() => {console.log('POST successful.')})
	}
	
	format_header(item) {
		return '<h4>' + item.value + '</h4>';
	}

	format_text(item) {
		return '<p>' + item.value + '</p>';
	}

	format_code(item) {
		let escaped = item.value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
		let lang_class = item.language ? ' class="language-' + item.language + '"' : '';
		let html = '';
		if (item.caption) html += '<h7>' + item.caption + '</h7>';
		html += '<code' + lang_class + '><pre>' + escaped + '</pre></code>';
		return html;
	}

	format_quote(item) {
		let html = '';
		if (item.caption) html += '<h7>' + item.caption + '</h7>';
		html += '<blockquote>' + item.value + '</blockquote>';
		return html;
	}

	format_link(item) {
		const link_title = item.source[0]?.title;
		const link_href = item.source[0]?.url;
		if(link_title && link_href){
			return '<a target="_blank" href="' + link_href + '">' + link_title + '↗</a>';
		}
		return '<a>' + item.value + '</a>';
	}

	format_color(item) {
		let html = '';
		if (item.caption) html += '<h7>' + item.caption + '</h7>';
		html += '<span style="color: ' + item.value + '">' + item.value + '</span>';
		return html;
	}

	format_list(item) {
		if(item.ordered){
			return '<ol><li value="' + item.index + '">' + item.value + '</li></ol>';
		}
		return '<ul><li>' + item.value + '</li></ul>';
	}

	format_mermaid(item) {
		let html = '';
		if (item.caption) html += '<h7>' + item.caption + '</h7>';
		html += '<pre class="div-diagram-mermaid">' + item.value + '</pre>';
		return html;
	}

	format_table(item) {
		let html = '';
		if(item.caption) html += '<h7>' + item.caption + '</h7>';
		const has_th = item['has-header'];
		const rows = [...item['table-rows']];
		html += '<table>';
		if(has_th){
			const row = rows.shift();
			html += '<thead><tr>';
			for(const title of row) html += '<th>' + title + '</th>';
			html += '</tr></thead>';
		}
		html += '<tbody>';
		for(const row of rows){
			html += '<tr>';
			for(const cell of row) html += '<td>' + cell + '</td>';
			html += '</tr>';
		}
		html += '</tbody></table>';
		return html;
	}

	format_default(item) {
		return '<pre>' + item.value + '</pre>';
	}

	format_item(item) {
		switch (item.type) {
			case 'header':
				return this.format_header(item);
			case 'text':
				return this.format_text(item);
			case 'code':
				return this.format_code(item);
			case 'quote':
				return this.format_quote(item);
			case 'link':
				return this.format_link(item);
			case 'color-hex':
				return this.format_color(item);
			case 'list-step':
				return this.format_list(item);
			case 'diagram-mermaid':
				return this.format_mermaid(item);
			case 'table':
				return this.format_table(item);
			default:
				return this.format_default(item);
		}
	}
	
	send_success(response){
		this.btn_send.disabled = false;
		this.btn_send.textContent = 'Send';
		
		const config = this.get_app_config();
		const guid = config[this.KEY_CONFIG_SELECTED_CONVERSATION_GUID];
		
		if(!('title' in response) || !guid) return;
		
		const key = this.KEY_APP_CONVERSATION_PREFIX + guid;
		const conversation = JSON.parse(localStorage.getItem(key) || '{}');

		conversation[this.KEY_CONVERSATION_TITLE] = response.title;
		conversation[this.KEY_CONVERSATION_SUMMARY] = response.summary;
		conversation['CONTENT_LATEST'] = response;

		localStorage.setItem(key, JSON.stringify(conversation));
		this.update_app_index(guid, false);

		// AI-COMPLETED: emit a local storage event with this key
		window.dispatchEvent(new StorageEvent('storage', {
			key: key
		}));

		this.prompt.value = '';
	}

	on_conversation_updated() {
		const conversation = this.get_selected_conversation();
		if (!conversation || !conversation['CONTENT_LATEST']) {
			this.div_response.innerHTML = '';
			return;
		}

		const data = conversation['CONTENT_LATEST'];

		let full_html = '';
		let first = true;

		Object.values(data.content).forEach(item => {
			if (!first) {
				full_html += '<hr class="hr-chat-response-divider"/>';
			}
			full_html += this.format_item(item);
			first = false;
		});

		this.div_response.innerHTML = full_html;

		document.querySelectorAll('code[class*="language-"] pre').forEach((el) => {
			// noinspection JSUnresolvedReference
			hljs.highlightElement(el);
		});
		// noinspection JSIgnoredPromiseFromCall
		this.updateMermaid();
	}

	
	send_failure(error){
		this.btn_send.disabled = false;
		this.btn_send.textContent = 'Send';
		console.error('App Error:', error);
	}

}

export default App;
🌐
app.js ×
Type: Web, text/plain
16.36 Kilobytes
Last Modified 2026-03-10 04:20:39
⬇ Download File