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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
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;