9 directories, 58 files

tinai

Home / tinai
/**
 * Read-only viewer for shared conversations (shared.php). Fetches the copied
 * conversation from the public share endpoint, renders it by conversation
 * type with the app's renderers in read-only mode, and provides the page's
 * own zoom, color theme and show-costs settings plus export. No session and
 * no cost-incurring calls: the code overlay's Analyze action is absent and
 * read-aloud controls are not rendered.
 */

import './util/scrollbars.js';
import Conversation from './conversation.js';
import ScratchpadConversation from './scratchpad-conversation.js';
import Document from './document.js';
import RankingsView from './rankings-view.js';
import ChoicesEditor from './choices-editor.js';
import StickyNotesEditor from './sticky-notes-editor.js';
import { compute_list_markers } from './document-model.js';
import {
	getEl, update_range_fill
} from './util/dom-utils.js';
import {
	highlightCodeBlocks, typesetMathjax, extractFormattedContent,
	build_export_html, rasterize_diagrams
} from './util/format-utils.js';
import { copyToClipboard } from './util/async-utils.js';
import {
	applyTheme, fetchThemeIndex, populateThemeFamilyDropdown,
	populateThemeDropdown, getThemeFamilyForTheme, fadeOutLoader
} from './util/theme-utils.js';
import { render_mermaid_in_container } from './util/mermaid-render.js';
import { init_code_overlay, attach_code_expand_buttons } from './util/code-overlay.js';
import { init_table_overlay, attach_table_expand_buttons } from './util/table-overlay.js';
import { init_diagram_overlay } from './util/diagram-overlay.js';
import { export_content } from './util/export-utils.js';
import { exportDocumentPdf } from './util/pdf-export.js';
import { customAlert } from './util/confirm-dialog.js';

const THEME_KEY = 'SHARED_VIEWER_THEME';
const ZOOM_KEY = 'SHARED_VIEWER_ZOOM';
const COSTS_KEY = 'SHARED_VIEWER_COSTS';
const THINKING_KEY = 'SHARED_VIEWER_THINKING';

let conversation = null;

/**
 * Fetches the shared copy for the ?g= guid from the public endpoint.
 * @returns {Promise<Object>} The conversation object.
 */
async function fetch_shared_conversation() {
	const guid = new URLSearchParams(location.search).get('g') || '';
	const response = await fetch('api.php?function=share&action=get', {
		method: 'POST',
		headers: { 'Content-Type': 'application/json' },
		body: JSON.stringify({ action: 'get', g: guid })
	});
	const data = await response.json();
	if (!response.ok || data.error) throw new Error(data.error || 'Failed to load the shared conversation.');
	return data.conversation;
}

/**
 * Shows a status message in place of the content (load errors).
 * @param {string} message - The message text.
 */
function show_status(message) {
	const status = getEl('id-div-shared-status');
	status.textContent = message;
	status.style.display = 'block';
	getEl('id-div-shared-chat').style.display = 'none';
}

/**
 * Returns the stored settings from localStorage with defaults applied.
 * @returns {Object} { theme, zoom, costs, thinking }.
 */
function load_settings() {
	const zoom = parseInt(localStorage.getItem(ZOOM_KEY), 10);
	return {
		theme: localStorage.getItem(THEME_KEY) || 'theme_dark',
		zoom: Number.isFinite(zoom) && zoom >= 70 && zoom <= 200 ? zoom : 100,
		costs: localStorage.getItem(COSTS_KEY) === '1',
		thinking: localStorage.getItem(THINKING_KEY) === '1'
	};
}

/**
 * Applies the stored zoom percentage to the root element, like the app's UI zoom.
 * @param {number} zoom - Zoom percentage (70-200).
 */
function apply_zoom(zoom) {
	document.documentElement.style.zoom = zoom === 100 ? '' : String(zoom / 100);
}

/**
 * Applies or removes the body flag that hides the per-turn cost tables.
 * @param {boolean} show - Whether costs are shown.
 */
function apply_costs(show) {
	document.body.classList.toggle('show-costs', show);
}

/**
 * Applies or removes the body flag that hides the thought-process blocks.
 * @param {boolean} show - Whether thinking is shown.
 */
function apply_thinking(show) {
	document.body.classList.toggle('show-thinking', show);
}

/**
 * Wires the settings bar: theme selects, zoom slider and show-costs toggle.
 */
async function init_settings() {
	const settings = load_settings();

	const family_select = getEl('id-select-shared-theme-family');
	const theme_select = getEl('id-select-shared-theme');
	const theme_index = await fetchThemeIndex();
	const family = getThemeFamilyForTheme(settings.theme, theme_index);
	populateThemeFamilyDropdown(family_select, theme_index, family);
	populateThemeDropdown(theme_select, theme_index, family, settings.theme);
	family_select.addEventListener('change', () => {
		populateThemeDropdown(theme_select, theme_index, family_select.value, settings.theme);
	});
	theme_select.addEventListener('change', () => {
		localStorage.setItem(THEME_KEY, theme_select.value);
		settings.theme = theme_select.value;
		void applyTheme(settings.theme);
	});
	await applyTheme(settings.theme);

	const zoom_input = getEl('id-input-shared-zoom');
	const zoom_value = getEl('id-span-shared-zoom-value');
	zoom_input.value = String(settings.zoom);
	zoom_value.textContent = settings.zoom + '%';
	update_range_fill(zoom_input);
	apply_zoom(settings.zoom);
	zoom_input.addEventListener('input', () => {
		const zoom = parseInt(zoom_input.value, 10);
		localStorage.setItem(ZOOM_KEY, String(zoom));
		zoom_value.textContent = zoom + '%';
		apply_zoom(zoom);
		update_range_fill(zoom_input);
	});

	const costs_checkbox = getEl('id-checkbox-shared-costs');
	costs_checkbox.checked = settings.costs;
	apply_costs(settings.costs);
	costs_checkbox.addEventListener('change', () => {
		localStorage.setItem(COSTS_KEY, costs_checkbox.checked ? '1' : '0');
		apply_costs(costs_checkbox.checked);
	});

	const thinking_checkbox = getEl('id-checkbox-shared-thinking');
	thinking_checkbox.checked = settings.thinking;
	apply_thinking(settings.thinking);
	thinking_checkbox.addEventListener('change', () => {
		localStorage.setItem(THINKING_KEY, thinking_checkbox.checked ? '1' : '0');
		apply_thinking(thinking_checkbox.checked);
	});
}

/**
 * Renders the conversation by its type into the right container and returns
 * the element holding the rendered content.
 * @param {Object} conversation - The shared conversation copy.
 * @returns {HTMLElement} The content root.
 */
function render_conversation(conversation) {
	const type = conversation.TYPE || 'CHAT';
	const chat_scroll = getEl('id-div-shared-chat');
	const document_container = getEl('div-shared-document-container');
	const content = getEl('id-div-shared-content');
	const settings = {
		read_only: true,
		show_suggested: conversation.SHOW_SUGGESTED_QUERIES === true,
		show_related: conversation.SHOW_RELATED_QUERIES === true
	};

	if (type === 'DOCUMENT') {
		chat_scroll.style.display = 'none';
		document_container.style.display = 'block';
		const items = Array.isArray(conversation.DOCUMENT) ? conversation.DOCUMENT : [];
		document_container.querySelector('.div-document-items').innerHTML = new Document().render(items);
		return document_container.querySelector('.div-document-items');
	}

	let html = '';
	if (type === 'SCRATCHPAD') {
		html = new ScratchpadConversation().render(conversation, settings);
	} else if (type === 'RANKING') {
		html = new RankingsView().render(Array.isArray(conversation.RANKINGS) ? conversation.RANKINGS : [], settings);
	} else if (type === 'COLORS') {
		html = new ChoicesEditor().render(Array.isArray(conversation.CHOICES) ? conversation.CHOICES : []);
	} else if (type === 'STICKY_NOTES') {
		html = new StickyNotesEditor().render(Array.isArray(conversation.STICKIES) ? conversation.STICKIES : [], Array.isArray(conversation.TAGS) ? conversation.TAGS : []);
	} else {
		html = new Conversation().render(conversation, settings);
	}
	content.innerHTML = html;
	return content;
}

/**
 * Makes a rendered root read-only: strips contenteditable and disables every
 * control except the per-turn copy buttons.
 * @param {HTMLElement} root - The rendered content root.
 */
function make_read_only(root) {
	root.querySelectorAll('[contenteditable]').forEach(el => el.removeAttribute('contenteditable'));
	root.querySelectorAll('button, input, select, textarea').forEach(el => {
		if (!el.classList.contains('btn-copy-response') && !el.classList.contains('btn-copy-scratchpad')) {
			el.disabled = true;
		}
	});
}

/**
 * Moves each per-turn copy button into the response content it copies, so
 * it can hover in that content's top-right corner (shared-view.css), and
 * removes the now-empty toolbar rows.
 * @param {HTMLElement} root - The rendered content root.
 */
function relocate_copy_buttons(root) {
	root.querySelectorAll('.btn-copy-response, .btn-copy-scratchpad').forEach(btn => {
		const response_div = root.querySelector(`.div-response-content[data-index="${btn.getAttribute('data-index')}"]`);
		if (response_div) {
			response_div.appendChild(btn);
		}
	});
	root.querySelectorAll('.div-chat-response-buttons').forEach(el => el.remove());
}

/**
 * Binds the interactive behaviour the viewer keeps: per-turn copy, thinking
 * block toggles, and code copy buttons rendered inside code blocks.
 * @param {HTMLElement} root - The rendered content root.
 */
function bind_viewer_controls(root) {
	root.querySelectorAll('.btn-copy-response, .btn-copy-scratchpad').forEach(btn => {
		btn.onclick = () => {
			const response_div = root.querySelector(`.div-response-content[data-index="${btn.getAttribute('data-index')}"]`);
			if (response_div) {
				void copyToClipboard(response_div, btn);
			}
		};
	});
	root.querySelectorAll('.p-thinking-header').forEach(header => {
		header.onclick = () => {
			const container = header.closest('.div-thinking-content');
			if (!container) return;
			const is_expanded = container.classList.contains('thinking-expanded');
			const arrow = header.querySelector('.span-thinking-arrow');
			const text_div = container.querySelector('.div-thinking-text');
			container.classList.toggle('thinking-expanded', !is_expanded);
			container.classList.toggle('thinking-collapsed', is_expanded);
			if (arrow) arrow.textContent = is_expanded ? '\u25b6' : '\u25bc';
			if (text_div) text_div.style.display = is_expanded ? 'none' : 'block';
		};
	});
	root.querySelectorAll('.copyable-code, .copyable-table').forEach(btn => {
		btn.onclick = () => {
			const data = btn.getAttribute('data-code') || btn.getAttribute('data-table');
			void copyToClipboard(data || '', btn);
		};
	});
}

/**
 * Waits briefly for the async MathJax script so equations typeset on first
 * render even when the library is still loading.
 * @returns {Promise<void>}
 */
async function wait_mathjax(timeout_ms = 5000) {
	const start = Date.now();
	while (!globalThis.MathJax?.typesetPromise) {
		if (Date.now() - start > timeout_ms) return;
		await new Promise(resolve => setTimeout(resolve, 100));
	}
}

/**
 * Runs the post-render pass over a rendered root: highlighting, diagrams,
 * overlay buttons, equations, read-only enforcement and viewer controls.
 * @param {HTMLElement} root - The rendered content root.
 */
async function post_render(root) {
	make_read_only(root);
	relocate_copy_buttons(root);
	highlightCodeBlocks(root);
	await render_mermaid_in_container(root);
	attach_code_expand_buttons(root);
	attach_table_expand_buttons(root);
	await wait_mathjax();
	await typesetMathjax(root);
	bind_viewer_controls(root);
}

/**
 * Returns the conversation title for the header and file names.
 * @param {Object} conversation - The shared conversation copy.
 * @returns {string} The title.
 */
function conversation_title(conversation) {
	return String(conversation?.TITLE || '').trim() || 'Shared Conversation';
}

/**
 * Builds a detached clone of the rendered content prepared for export:
 * copy buttons, expand buttons and the sticky controls removed.
 * @param {HTMLElement} root - The rendered content root.
 * @returns {HTMLElement} The prepared clone.
 */
function prepared_export_clone(root) {
	const clone = root.cloneNode(true);
	clone.querySelectorAll('.btn-copy-response, .btn-copy-scratchpad, .div-chat-response-buttons, .div-code-expand-btn, .div-diagram-expand-btn, .div-notes-sticky, .div-notes-note-actions').forEach(el => el.remove());
	return clone;
}

/**
 * Resolves the typed document/scratchpad items for the PDF export, or null
 * for types exported from the rendered DOM only.
 * @returns {Array|null} The content items.
 */
function pdf_items() {
	const type = conversation?.TYPE;
	if (type === 'DOCUMENT') {
		return Array.isArray(conversation.DOCUMENT) ? conversation.DOCUMENT : [];
	}
	if (type === 'SCRATCHPAD') {
		const history = Array.isArray(conversation.HISTORY) ? conversation.HISTORY : [];
		const revision = history[history.length - 1];
		return Array.isArray(revision?.items) ? revision.items : [];
	}
	return null;
}

/**
 * Wires the export dropdown: HTML variants and markdown are built from the
 * rendered DOM; PDF is offered for document and scratchpad types, whose
 * typed items the PDF exporter consumes. Sticky notes and rankings have no
 * export: the dropdown is hidden for them.
 * @param {HTMLElement} root - The rendered content root.
 */
function init_export(root) {
	const select = getEl('id-select-shared-export');
	const type = conversation?.TYPE;
	if (type === 'STICKY_NOTES' || type === 'RANKING') {
		select.style.display = 'none';
		return;
	}
	const items = pdf_items();
	if (!items) {
		select.querySelector('.option-shared-pdf')?.remove();
	}
	select.addEventListener('change', async () => {
		const format = select.value;
		select.value = '';
		if (!format) return;
		const title = conversation_title(conversation);
		try {
			const images = await rasterize_diagrams(root, items || []);
			await export_content(format, title, {
				pdf: () => exportDocumentPdf(items, compute_list_markers(items), title, images),
				html: async (mobile) => {
					const { html } = await extractFormattedContent(prepared_export_clone(root), images);
					return build_export_html(title, html, mobile);
				},
				markdown: async () => {
					const { text } = await extractFormattedContent(prepared_export_clone(root), images);
					return text + '\n';
				}
			});
		} catch (error) {
			void customAlert('Export Failed', String(error?.message || error));
		}
	});
}

/**
 * Boots the viewer: fetch, render, settings, overlays and export.
 */
async function init() {
	init_diagram_overlay();
	init_table_overlay();
	init_code_overlay({
		load_analysis: () => null,
		save_analysis: () => {},
		single_column_breakpoint: 780
	});
	try {
		conversation = await fetch_shared_conversation();
	} catch (error) {
		show_status(String(error?.message || error));
		fadeOutLoader();
		return;
	}

	document.title = conversation_title(conversation) + ' - TinAI';
	getEl('id-span-shared-title').textContent = conversation_title(conversation);

	await init_settings();
	const root = render_conversation(conversation);
	await post_render(root);
	init_export(root);
	fadeOutLoader();
}

init();
🌐
shared.js ×
Type: Web, text/x-java
14.63 Kilobytes
Last Modified 2026-11-04 09:48:28
⬇ Download File