Files
hope-voice-api/web/chat.js
T

218 lines
32 KiB
JavaScript

// Durable HTTP chat. Deliberately independent of the LiveKit composer.
(() => {
'use strict';
const $ = id => document.getElementById(id), api = (path, method = 'GET', body, signal) => HopeAuth.request(path, {method, ...(body === undefined ? {} : {body: JSON.stringify(body)}), signal});
const state = {conversation: null, messages: [], run: null, workspaces: [], preferences: null, epoch: 0, stream: null, cursor: null, messageCursor: null, workspace: null, busy: false, voiceActive: false, archived: false, syncCursor: 0, research: false, pending: null};
let searchTimer, syncTimer, listVersion = 0, action, syncBusy = false;
const enc = encodeURIComponent, terminal = run => !run || ['completed', 'failed', 'cancelled'].includes(run.status);
const notice = text => { $('chatNotice').textContent = text; };
const button = (text, fn, cls = 'btn btn-ghost') => { const b = document.createElement('button'); b.type = 'button'; b.className = cls; b.textContent = text; b.addEventListener('click', () => Promise.resolve().then(fn).catch(error => notice(error.message))); return b; };
function controls() {
const ready = HopeAuth.user?.status === 'ready', running = !terminal(state.run) || !!state.stream || state.voiceActive;
$('primaryInput').disabled = !ready || state.busy || !terminal(state.run) || !!state.stream; $('chatTimeline').setAttribute('aria-busy', String(!terminal(state.run))); $('primaryComposer').setAttribute('aria-busy', String(state.busy));
$('primarySend').disabled = !ready || state.busy || running || window.HopeFeatures?.hasPendingUploads() || !$('primaryInput').value.trim();
$('cancelRun').hidden = terminal(state.run); $('researchMode').disabled = !ready || running || state.busy;
for (const id of ['pinConversation', 'renameConversation', 'moveConversation', 'exportMarkdown', 'exportJSON', 'archiveConversation', 'deleteConversation']) $(id).disabled = !state.conversation || state.busy || !!state.stream || !terminal(state.run);
$('talkToHope').disabled = !ready || state.busy || !!state.stream || !terminal(state.run); $('conversationMenu').hidden = !state.conversation;
$('archiveConversation').textContent = state.conversation?.archived ? 'Unarchive' : 'Archive';
$('pinConversation').textContent = state.conversation?.pinned ? 'Unpin' : 'Pin';
$('chatTitle').textContent = state.conversation?.title || 'New conversation';
}
function selected(conversation) {
state.conversation = conversation;
window.dispatchEvent(new CustomEvent('hope:conversation-selected', {detail: {conversation_id: conversation?.id || null}}));
controls();
}
window.addEventListener('hope:voice-state', event => {const wasActive = state.voiceActive; state.voiceActive = !!event.detail?.active; controls(); if (state.voiceActive) notice('Voice is active. End the voice conversation before sending text.'); else {if (wasActive) notice(''); if (wasActive && HopeAuth.user && state.conversation) refreshSelected().then(() => {notice('Voice conversation saved.'); list();}).catch(error => notice(error.message));}});
window.addEventListener('hope:attachments-changed', controls);
window.HopeChat = {get conversationId() {return state.conversation?.id || null;}, get running() {return !terminal(state.run);}};
function stopStream() { state.stream?.abort(); state.stream = null; }
function drawer(open) {
$('chatSidebar').classList.toggle('drawer-open', open); $('drawerBackdrop').hidden = !open;
$('drawerOpen').setAttribute('aria-expanded', String(open));
if (matchMedia('(max-width: 760px)').matches) { $('primaryChat').inert = open; $('legacyVoice').inert = open; $('chatSidebar').inert = !open; }
(open ? $('drawerClose') : $('drawerOpen')).focus();
}
$('drawerOpen').onclick = () => drawer(true); $('drawerClose').onclick = $('drawerBackdrop').onclick = () => drawer(false);
document.addEventListener('keydown', event => { if (event.key === 'Escape' && $('chatSidebar').classList.contains('drawer-open')) drawer(false); if ((event.metaKey || event.ctrlKey) && event.key === 'k') {event.preventDefault(); drawer(true); $('conversationSearch').focus();} });
const mobile = matchMedia('(max-width: 760px)');
function adapt() { $('chatSidebar').inert = mobile.matches && !$('chatSidebar').classList.contains('drawer-open'); if (!mobile.matches) { $('primaryChat').inert = false; $('legacyVoice').inert = false; $('drawerBackdrop').hidden = true; } }
mobile.addEventListener('change', adapt); adapt();
async function startVoice() {
if (state.voiceActive) { $('legacyVoice').hidden = false; $('muteBtn').focus(); return; }
if (state.busy || state.stream || !terminal(state.run) || HopeAuth.user?.status !== 'ready') return;
const epoch = state.epoch; state.busy = true; controls();
try {
if (!state.conversation) {const data = await api('/api/conversations', 'POST', {title: 'Voice conversation', ...(state.workspace ? {workspace_id: state.workspace} : {})}); if (epoch !== state.epoch) return; selected(data.conversation); await list();}
if (epoch !== state.epoch) return;
$('legacyVoice').hidden = false;
window.dispatchEvent(new CustomEvent('hope:start-voice', {detail: {conversation_id: state.conversation.id}}));
} catch (error) {if (epoch === state.epoch) notice(error.message);} finally {if (epoch === state.epoch) {state.busy = false; controls();}}
}
$('talkToHope').onclick = startVoice;
$('openVoice').onclick = startVoice;
$('backToChat').onclick = () => $('primaryInput').focus();
document.querySelectorAll('.lightwell-symbol').forEach(el => {for (let i=0;i<25;i++) el.append(document.createElement('i'));});
$('openLibrary').onclick = () => {if (mobile.matches) drawer(false); $('libraryDialog').showModal();};
$('closeLibrary').onclick = () => $('libraryDialog').close();
// Markdown uses DOM text nodes only: HTML is always literal; unsafe URLs never become links.
function inline(parent, text) {
const expression = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|\[[^\]]+\]\([^\s)]+\))/g;
let last = 0;
for (const match of text.matchAll(expression)) {
parent.append(document.createTextNode(text.slice(last, match.index))); const token = match[0]; let node;
if (token.startsWith('`')) {node = document.createElement('code'); node.textContent = token.slice(1, -1);}
else if (token.startsWith('**')) {node = document.createElement('strong'); node.textContent = token.slice(2, -2);}
else if (token.startsWith('*')) {node = document.createElement('em'); node.textContent = token.slice(1, -1);}
else {const parts = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(token); let url; try {url = new URL(parts[2]);} catch {} if (url && ['https:', 'http:', 'mailto:'].includes(url.protocol)) {node = document.createElement('a'); node.href = url.href; node.rel = 'noopener noreferrer'; node.textContent = parts[1];} else node = document.createTextNode(token);}
parent.append(node); last = match.index + token.length;
}
parent.append(document.createTextNode(text.slice(last)));
}
function markdown(text) {
const root = document.createElement('div'); root.className = 'markdown'; let code = null, list = null;
const lines = String(text || '').split('\n');
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { const line = lines[lineIndex];
if (line.startsWith('```')) { if (code) {code = null;} else {const pre = document.createElement('pre'); code = document.createElement('code'); pre.append(code); root.append(pre);} list = null; continue; }
if (code) {code.append(document.createTextNode(line + '\n')); continue;}
if (line.includes('|') && /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(lines[lineIndex + 1] || '')) {const table = document.createElement('table'), head = document.createElement('thead'), body = document.createElement('tbody'), wrap = document.createElement('div'); wrap.className = 'markdown-table'; const cells = value => value.trim().replace(/^\||\|$/g, '').split('|'); const addRow = (value, parent, header) => {const row = document.createElement('tr'); for (const cell of cells(value)) {const node = document.createElement(header ? 'th' : 'td'); if (header) node.scope = 'col'; inline(node, cell.trim()); row.append(node);} parent.append(row);}; addRow(line, head, true); lineIndex += 1; while (lines[lineIndex + 1]?.includes('|') && lines[lineIndex + 1].trim()) {lineIndex++; addRow(lines[lineIndex], body, false);} table.append(head, body); wrap.append(table); root.append(wrap); list = null; continue;}
const item = /^(?:[-*] |\d+\. )(.*)$/.exec(line);
if (item) {const type = /^\d/.test(line) ? 'ol' : 'ul'; if (!list || list.tagName.toLowerCase() !== type) {list = document.createElement(type); root.append(list);} const li = document.createElement('li'); inline(li, item[1]); list.append(li); continue;}
list = null; if (!line.trim()) continue;
const heading = /^(#{1,6}) (.*)$/.exec(line); const node = document.createElement(heading ? 'h' + Math.min(heading[1].length + 1, 6) : line.startsWith('> ') ? 'blockquote' : 'p'); inline(node, heading ? heading[2] : line.replace(/^> /, '')); root.append(node);
}
return root;
}
function render() {
const timeline = $('chatTimeline'), atBottom = timeline.scrollHeight - timeline.scrollTop - timeline.clientHeight < 100, scroll = timeline.scrollTop;
timeline.replaceChildren();
if (state.messageCursor) timeline.append(button('Load earlier messages', olderMessages));
if (!state.messages.length) { const welcome = document.createElement('div'); welcome.className = 'chat-welcome'; const mark = document.createElement('span'); mark.className = 'hope-mark'; mark.setAttribute('aria-hidden','true'); const h = document.createElement('h2'); h.textContent = 'A little space for you.'; const p = document.createElement('p'); p.textContent = 'Think out loud, work something through, or make something new. You can type, or talk to Hope.'; welcome.append(mark, h, p); timeline.append(welcome); }
for (const message of state.messages) {
const row = document.createElement('article'); row.className = `chat-message ${message.role}`; row.dataset.messageId = message.id;
const name = document.createElement('h2'); name.textContent = message.role === 'user' ? 'You' : 'Hope'; row.append(name, markdown(message.content));
const actions = document.createElement('div'); actions.className = 'message-actions'; actions.setAttribute('aria-label', (message.role === 'user' ? 'Your message' : 'Hope response') + ' actions');
actions.append(button('Copy', () => navigator.clipboard.writeText(message.content)));
if (terminal(state.run)) {
if (message.role === 'user') actions.append(button('Edit in new branch', () => edit(message)));
else {actions.append(button('Branch from here', () => branch(message))); const index = state.messages.indexOf(message); const prior = state.messages.slice(0, index).reverse().find(m => m.role === 'user'); if (prior) actions.append(button('Regenerate', () => branch(prior, prior.content, true)));}
}
if (['failed', 'cancelled'].includes(message.status)) {const status = document.createElement('span'); status.textContent = message.status === 'failed' ? 'Response failed' : 'Response stopped'; actions.append(status);}
row.append(actions); timeline.append(row);
}
timeline.scrollTop = atBottom ? timeline.scrollHeight : scroll; controls();
}
async function list(append = false) {
const version = ++listVersion, epoch = state.epoch;
const params = new URLSearchParams({q: $('conversationSearch').value, archived: String(state.archived), limit: '50'});
if (state.workspace) params.set('workspace_id', state.workspace); if (append && state.cursor) params.set('cursor', state.cursor);
const data = await api('/api/conversations?' + params); if (version !== listVersion || epoch !== state.epoch) return;
if (!append) { $('pinnedConversations').replaceChildren(); $('recentConversations').replaceChildren(); }
for (const conversation of data.conversations) {const b = button(conversation.title || 'Untitled chat', () => open(conversation.id), 'conversation-link'); b.setAttribute('aria-current', conversation.id === state.conversation?.id ? 'page' : 'false'); $(conversation.pinned ? 'pinnedConversations' : 'recentConversations').append(b);}
state.cursor = data.next_cursor; $('loadMoreConversations').hidden = !state.cursor;
}
async function open(id) {
if (state.voiceActive && id !== state.conversation?.id) {notice('End voice before opening another conversation. Your current thread stays here.'); return;}
stopStream(); const epoch = ++state.epoch; state.run = null; state.messages = []; state.pending = null; notice('Loading conversation…'); controls();
const data = await api('/api/conversations/' + enc(id)); if (epoch !== state.epoch) return;
selected(data.conversation); state.messages = data.messages || []; state.messageCursor = data.next_cursor; state.run = (data.runs || []).find(r => !terminal(r)) || null; render(); notice(''); if (mobile.matches) drawer(false); if (state.run) stream(state.run); await list();
}
async function olderMessages() {const epoch = state.epoch, timeline = $('chatTimeline'), height = timeline.scrollHeight; const data = await api('/api/conversations/' + enc(state.conversation.id) + '?' + new URLSearchParams({cursor: state.messageCursor, limit: '50'})); if (epoch !== state.epoch) return; const ids = new Set(state.messages.map(m => m.id)); state.messages = (data.messages || []).filter(m => !ids.has(m.id)).concat(state.messages); state.messageCursor = data.next_cursor; render(); timeline.scrollTop += timeline.scrollHeight - height;}
function reset() { stopStream(); ++state.epoch; state.messages = []; state.messageCursor = null; state.run = null; state.pending = null; state.busy = false; selected(null); render(); notice(''); $('primaryInput').value = ''; controls(); }
$('newConversation').onclick = () => {if (state.voiceActive) {notice('End voice before starting a new conversation.'); return;} reset(); if (mobile.matches) drawer(false); $('primaryInput').focus();};
$('conversationSearch').oninput = () => { clearTimeout(searchTimer); searchTimer = setTimeout(() => list().catch(e => notice(e.message)), 200); };
$('showArchived').onclick = () => {state.archived = !state.archived; $('showArchived').setAttribute('aria-pressed', String(state.archived)); $('recentHeading').textContent = state.archived ? 'Archived' : 'Recent'; list().catch(e => notice(e.message));};
$('loadMoreConversations').onclick = () => list(true).catch(e => notice(e.message));
async function update(patch) {const epoch = state.epoch; let data; try {data = await api('/api/conversations/' + enc(state.conversation.id), 'PATCH', {revision: state.conversation.revision, ...patch});} catch (error) {await refreshSelected(false); await list(); throw new Error(error.message + ' Conversation refreshed. Review the latest version and try again.');} if (epoch !== state.epoch) return; selected(data.conversation); await list();}
$('pinConversation').onclick = () => update({pinned: !state.conversation.pinned}).catch(e => notice(e.message));
function dialog(title, value, fn, description = '', needsValue = true) {
$('chatWorkspaceSelect').hidden = true; $('chatWorkspaceLabel').hidden = true; if ($('conversationActions').matches(':popover-open')) $('conversationActions').hidePopover(); action = fn; $('chatActionTitle').textContent = title; $('chatActionDescription').textContent = description; $('chatActionValue').value = value; $('chatActionValue').hidden = !needsValue; $('chatActionLabel').hidden = !needsValue; $('chatActionValue').required = needsValue; $('chatActionError').textContent = ''; $('chatActionDialog').showModal();
}
$('chatActionCancel').onclick = () => $('chatActionDialog').close();
$('chatActionForm').onsubmit = async event => {event.preventDefault(); $('chatActionSave').disabled = true; try {await action($('chatActionValue').value.trim()); $('chatActionDialog').close();} catch (e) {$('chatActionError').textContent = e.message;} finally {$('chatActionSave').disabled = false;} };
$('renameConversation').onclick = () => dialog('Rename conversation', state.conversation.title, title => update({title}));
$('archiveConversation').onclick = () => dialog(state.conversation.archived ? 'Unarchive conversation?' : 'Archive conversation?', '', async () => {await update({archived: !state.conversation.archived}); reset(); await list();}, 'The conversation is retained, but leaves Recent.', false);
async function exportChat(format) {const epoch = state.epoch, cid = state.conversation.id; const response = await fetch('/api/conversations/' + enc(cid) + '/export?format=' + format, {credentials:'same-origin'}); if (response.status === 401) {HopeAuth.signOut(); return;} if (!response.ok) throw new Error('Download failed (' + response.status + ').'); const blob = await response.blob(); if (epoch !== state.epoch) return; const url = URL.createObjectURL(blob), anchor = document.createElement('a'); anchor.href = url; anchor.download = 'hope-conversation-' + cid.replace(/[^a-zA-Z0-9_-]/g,'') + (format === 'json' ? '.json' : '.md'); anchor.click(); setTimeout(() => URL.revokeObjectURL(url),1000); notice('Conversation downloaded.');}
$('exportMarkdown').onclick = () => exportChat('markdown').catch(e => notice(e.message)); $('exportJSON').onclick = () => exportChat('json').catch(e => notice(e.message));
$('moveConversation').onclick = () => {dialog('Move conversation', '', () => update({workspace_id: $('chatWorkspaceSelect').value || null}), 'Keep the same history in a different workspace.', false); const select = $('chatWorkspaceSelect'); select.replaceChildren(); for (const workspace of [{id:'',name:'No workspace'}, ...state.workspaces]) {const option = document.createElement('option'); option.value = workspace.id; option.textContent = workspace.name; select.append(option);} select.value = state.conversation.workspace_id || ''; select.hidden = false; $('chatWorkspaceLabel').hidden = false;};
$('deleteConversation').onclick = () => dialog('Delete conversation?', '', async () => {await api('/api/conversations/' + enc(state.conversation.id), 'DELETE'); reset(); await list();}, 'This deletes the conversation, not your account memory.', false);
async function branch(message, content, send = false) {const data = await api('/api/conversations/' + enc(state.conversation.id) + '/branch', 'POST', {message_id: message.id, ...(content === undefined ? {} : {content})}); await open(data.conversation.id); if (send) {$('primaryInput').value = content; await submit();} }
function edit(message) {dialog('Edit in a new branch', message.content, content => branch(message, content, true), 'Your original conversation stays unchanged.');}
function upsert(message) {const index = state.messages.findIndex(m => m.id === message.id); if (index < 0) state.messages.push(message); else state.messages[index] = message;}
async function submit() {
const content = $('primaryInput').value.trim(); if (!content || window.HopeFeatures?.hasPendingUploads() || state.voiceActive || state.busy || !terminal(state.run) || HopeAuth.user?.status !== 'ready') return;
state.busy = true; controls(); const epoch = state.epoch;
try {
if (!state.conversation) {const data = await api('/api/conversations', 'POST', {title: content.slice(0, 80), ...(state.workspace ? {workspace_id: state.workspace} : {})}); if (epoch !== state.epoch) return; selected(data.conversation);}
// Retry an uncertain POST with the same key rather than creating another turn.
const attachmentIDs = window.HopeFeatures?.attachmentIDs() || [], mode = state.research ? $('researchMode').value : 'normal';
if (!state.pending || state.pending.content !== content || state.pending.mode !== mode || JSON.stringify(state.pending.attachment_ids) !== JSON.stringify(attachmentIDs)) state.pending = {content, mode, idempotency_key: crypto.randomUUID(), attachment_ids: attachmentIDs};
const data = await api('/api/conversations/' + enc(state.conversation.id) + '/messages', 'POST', state.pending); if (epoch !== state.epoch) return;
window.HopeFeatures?.clearAfterSend(state.pending.attachment_ids); state.pending = null; $('primaryInput').value = ''; upsert(data.message); state.run = data.run; render(); notice(''); stream(data.run); await list();
} catch (e) {if (epoch === state.epoch) notice(e.message + ' You can retry your message.');} finally {state.busy = false; controls();}
}
$('primaryComposer').onsubmit = event => {event.preventDefault(); submit();}; $('primaryInput').oninput = controls;
$('primaryInput').onkeydown = event => {if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {event.preventDefault(); submit();}};
async function stream(run) {
stopStream(); const controller = new AbortController(); state.stream = controller; const epoch = state.epoch; let after = 0, retries = 0;
try {
while (!controller.signal.aborted && epoch === state.epoch) {
try {
const response = await fetch('/api/runs/' + enc(run.id) + '/events?after=' + after, {credentials: 'same-origin', signal: controller.signal, headers: {Accept: 'text/event-stream'}});
if (response.status === 401) {HopeAuth.signOut(); return;} if (!response.ok) throw new Error('Response stream unavailable (' + response.status + ').');
const reader = response.body.getReader(), decoder = new TextDecoder(); let buffer = '', done = false;
while (!done) {const part = await reader.read(); buffer += decoder.decode(part.value || new Uint8Array(), {stream: !part.done}).replace(/\r\n/g, '\n'); let boundary;
while ((boundary = buffer.indexOf('\n\n')) >= 0) {const block = buffer.slice(0, boundary); buffer = buffer.slice(boundary + 2); let type = 'message', id = 0, raw = [];
for (const line of block.split('\n')) {if (line.startsWith('event:')) type = line.slice(6).trim(); else if (line.startsWith('id:')) id = Number(line.slice(3)); else if (line.startsWith('data:')) raw.push(line.slice(5).trimStart());}
if (!raw.length || (id && id <= after)) continue; const data = JSON.parse(raw.join('\n')); if (controller.signal.aborted || epoch !== state.epoch) return; if (id) after = id;
if (type === 'message') {upsert(data.message || data); render();}
if (type === 'run' || type === 'done') {state.run = data.run || data; controls();}
if (type === 'progress') $('runProgress').textContent = data.message || '';
if (type === 'approval') approval(data, run.id);
if (type === 'done') {notice(state.run?.status === 'cancelled' ? 'Response stopped.' : state.run?.status === 'failed' ? 'Response failed.' : 'Response ready.'); done = true; await reader.cancel(); break;}
}
if (part.done) done = true;
}
if (terminal(state.run)) break;
retries = 0;
} catch (error) {if (controller.signal.aborted) return; if (++retries >= 3) {notice('Connection interrupted. Reopen this conversation to resume the response.'); break;}}
await new Promise(resolve => {const timer = setTimeout(resolve, 1000); controller.signal.addEventListener('abort', () => {clearTimeout(timer); resolve();}, {once: true});});
}
} finally {if (state.stream === controller) {if (terminal(state.run) && epoch === state.epoch && !controller.signal.aborted) {try {await refreshSelected(false); await list();} catch (error) {if (epoch === state.epoch) notice('Response saved, but conversation details could not refresh. Reopen the conversation before changing it.');}} if (state.stream !== controller || epoch !== state.epoch) return; state.stream = null; $('runProgress').replaceChildren(); if (state.run?.error) notice(state.run.error); controls(); render();}}
}
function approval(data, runId) {const area = $('runProgress'); area.replaceChildren(); const text = document.createElement('p'); text.textContent = data.prompt || 'Hope needs your permission to continue.'; area.append(text); for (const decision of ['approve', 'deny']) area.append(button(decision === 'approve' ? 'Approve' : 'Deny', async () => {const result = await api('/api/runs/' + enc(runId) + '/approval', 'POST', {approval_id: data.approval_id, decision}); state.run = result.run; area.replaceChildren(); controls();}));}
$('cancelRun').onclick = async () => {try {const data = await api('/api/runs/' + enc(state.run.id) + '/cancel', 'POST'); state.run = data.run; controls();} catch (e) {notice(e.message);} };
function workspaceRows() {
$('workspaceList').replaceChildren(button('All conversations', () => {state.workspace = null; list();}, 'conversation-link'));
for (const workspace of state.workspaces) {
const row = document.createElement('div'); row.className = 'workspace-row';
row.append(button(workspace.name, () => {state.workspace = workspace.id; notice('Workspace: ' + workspace.name + '. New chats will be created here.'); list();}, 'conversation-link'));
const menu = document.createElement('details'), summary = document.createElement('summary'), tools = document.createElement('div'); menu.className = 'workspace-menu'; summary.textContent = '•••'; summary.setAttribute('aria-label', workspace.name + ' workspace actions'); tools.className = 'workspace-tools';
tools.append(button('Instructions', () => {menu.open = false; workspaceInstructions(workspace);}), button('Rename', () => {menu.open = false; dialog('Rename workspace', workspace.name, async name => {await api('/api/workspaces/' + enc(workspace.id), 'PATCH', {revision: workspace.revision, name}); await workspaces();});}), button('Remove', () => {menu.open = false; dialog('Remove workspace?', '', async () => {await api('/api/workspaces/' + enc(workspace.id), 'DELETE'); if (state.workspace === workspace.id) state.workspace = null; await workspaces(); await list();}, 'Its conversations are kept outside the workspace.', false);}));
menu.append(summary, tools); row.append(menu); $('workspaceList').append(row);
}
}
async function workspaces() {const epoch = state.epoch, data = await api('/api/workspaces'); if (epoch !== state.epoch) return; state.workspaces = data.workspaces; workspaceRows();}
function workspaceInstructions(workspace) {dialog('Workspace instructions', workspace.instructions || '', async instructions => {await api('/api/workspaces/' + enc(workspace.id), 'PATCH', {revision: workspace.revision, instructions}); await workspaces();}, 'These instructions guide chats in this workspace. They do not isolate tool access.');}
$('newWorkspace').onclick = () => dialog('Create workspace', '', async name => {await api('/api/workspaces', 'POST', {name}); await workspaces();}, 'Group related chats. A workspace is not a separate tool sandbox.');
function theme(preferences) {document.documentElement.dataset.palette = preferences.palette; document.documentElement.dataset.appearance = preferences.appearance; $('paletteSelect').value = preferences.palette; $('appearanceSelect').value = preferences.appearance;}
window.HopeAppearance = {load: async () => {
const epoch = state.epoch, user = HopeAuth.user?.id, fields = [...$('preferencesForm').querySelectorAll('select, button[type=submit], button:not([type])')];
fields.forEach(field => field.disabled = true);
try {const data = await api('/api/preferences'); if (epoch !== state.epoch || user !== HopeAuth.user?.id) return; state.preferences = data.preferences; theme(data.preferences); $('preferencesMessage').textContent = '';}
finally {if (epoch === state.epoch && user === HopeAuth.user?.id) fields.forEach(field => field.disabled = false);}
}};
$('openPreferences').onclick = async () => {try {if (window.HopeSettings) {await HopeSettings.open('appearance'); return;} await HopeAppearance.load(); if (!HopeAuth.user) return; $('preferencesDialog').showModal();} catch(e) {notice(e.message);} };
$('closePreferences').onclick = () => $('preferencesDialog').close();
$('preferencesForm').onsubmit = async event => {event.preventDefault(); const epoch = state.epoch, user = HopeAuth.user?.id; try {if (!state.preferences || !user) return; const data = await api('/api/preferences', 'PUT', {revision: state.preferences.revision, palette: $('paletteSelect').value, appearance: $('appearanceSelect').value}); if (epoch !== state.epoch || user !== HopeAuth.user?.id) return; state.preferences = data.preferences; theme(data.preferences); $('preferencesMessage').textContent = 'Appearance saved.';} catch(e) {if (epoch === state.epoch && user === HopeAuth.user?.id) $('preferencesMessage').textContent = e.message;} };
async function refreshSelected(messages = true) {if (!state.conversation) return; const epoch = state.epoch, cid = state.conversation.id; const data = await api('/api/conversations/' + enc(cid)); if (epoch !== state.epoch || cid !== state.conversation?.id) return; selected(data.conversation); if (messages && terminal(state.run)) {const timeline = $('chatTimeline'), scroll = timeline.scrollTop; const incoming = new Map((data.messages || []).map(m => [m.id,m])); state.messages = state.messages.map(m => incoming.get(m.id) || m); for (const m of incoming.values()) if (!state.messages.some(old => old.id === m.id)) state.messages.push(m); render(); timeline.scrollTop = scroll;} }
async function sync() {if (syncBusy || !HopeAuth.user || document.hidden) return; syncBusy = true; const epoch = state.epoch; try {const data = await api('/api/sync?after=' + state.syncCursor); if (epoch !== state.epoch) return; state.syncCursor = data.cursor; if (!data.changes?.length) return; for (const change of data.changes) {if (change.kind === 'preferences' && change.data && !$('preferencesDialog').open && !(window.HopeSettings?.isOpen && HopeSettings.section === 'appearance')) {state.preferences = change.data; theme(change.data);} if (change.kind === 'conversation' && change.id === state.conversation?.id && change.deleted) {reset(); notice('This conversation was deleted on another device.');}} await Promise.all([list(), workspaces(), api('/api/research').then(data => {if (epoch !== state.epoch) return; state.research = data.available === true && data.mode === 'hermes_tools'; $('researchMode').hidden = !state.research; $('researchModeLabel').hidden = !state.research;}), refreshSelected()]);} catch (error) {/* A subsequent foreground poll retries; never discard the draft. */} finally {syncBusy = false;}}
document.addEventListener('visibilitychange', () => {if (!document.hidden) sync();});
HopeAuth.onChange((user, changed) => {
if (changed || !user) {clearInterval(syncTimer); state.syncCursor = 0; state.archived = false; state.research = false; $('researchMode').hidden = true; $('researchModeLabel').hidden = true; $('researchMode').value = 'normal'; reset(); state.workspace = null; state.workspaces = []; state.preferences = null; $('pinnedConversations').replaceChildren(); $('recentConversations').replaceChildren(); $('workspaceList').replaceChildren(); $('legacyVoice').hidden = true; $('primaryChat').hidden = false; $('preferencesDialog').close(); $('chatActionDialog').close(); $('libraryDialog').close(); theme({palette: 'opal', appearance: 'system'});}
controls(); if (!user || !changed) return;
const epoch = state.epoch; syncTimer = setInterval(sync, 5000);
Promise.all([list(), workspaces(), api('/api/research').then(data => {if (epoch !== state.epoch) return; state.research = data.available === true && data.mode === 'hermes_tools'; $('researchMode').hidden = !state.research; $('researchModeLabel').hidden = !state.research;}), api('/api/preferences').then(data => {if (epoch === state.epoch) {state.preferences = data.preferences; theme(data.preferences);}})]).catch(e => {if (epoch === state.epoch) notice(e.message);});
});
})();