feat: clarify web library memory and conversation management
Rebuild & Deploy / rebuild (push) Failing after 17s

This commit is contained in:
snowspeeder committed 2026-10-04 07:58:16 -04:00
1 parent 3183ebaea7
commit 41ca590744
11 files changed
+767 -70

No files matched your search

+25 -2
View File
@@ -95,7 +95,13 @@ function setTasksOpen(open) {
tasksPanel.hidden = !open; tasksPanel.inert = !open;
tasksPanel.classList.toggle("open", open);
tasksBtn.setAttribute("aria-expanded", String(open));
if (open) closeTasksBtn.focus(); else if (wasOpen) tasksBtn.focus();
if (open) {
if (document.getElementById('chatSidebar')?.classList.contains('drawer-open')) document.getElementById('drawerClose')?.click();
closeTasksBtn.focus();
} else if (wasOpen) {
const target = tasksBtn.getClientRects().length && !tasksBtn.closest('[inert]') ? tasksBtn : document.getElementById('drawerOpen');
target?.focus();
}
}
tasksBtn.setAttribute("aria-expanded", "false");
tasksBtn.addEventListener("click", () => setTasksOpen(!tasksPanel.classList.contains("open")));
@@ -727,7 +733,18 @@ modelSelect.addEventListener("change", () => {
thinkingLevelSelect.addEventListener("change", () => {
updateSettingsControls(); setSettingsStatus("Unsaved changes. Save to apply them to your account.");
});
retryModelSettingsBtn.addEventListener("click", loadModelSettings);
retryModelSettingsBtn.addEventListener("click", async () => {
await loadModelSettings();
if (!window.HopeAuth?.user) return;
const account = accountIdentity(), generation = settingsGeneration;
try {await window.HopeFeatures?.refresh();}
catch (error) {
if (settingsResponseIsCurrent(account, generation)) {
retryModelSettingsBtn.hidden = false;
setSettingsStatus(`File support could not be checked: ${error.message}. Retry to check again. Image attachments remain blocked.`, 'error');
}
}
});
modelSettingsForm.addEventListener("submit", async event => {
event.preventDefault(); updateSettingsControls();
if (saveModelSettingsBtn.disabled) return;
@@ -740,6 +757,12 @@ modelSettingsForm.addEventListener("submit", async event => {
const data = await HopeAuth.request("/api/settings/model", { method: "PUT", body: JSON.stringify(settings), signal: settingsAbort.signal });
if (!settingsResponseIsCurrent(account, generation)) return;
savedModelSettings = readSavedSettings(data); renderSavedModelSettings();
window.HopeFeatures?.refresh().catch(error => {
if (settingsResponseIsCurrent(account, generation)) {
retryModelSettingsBtn.hidden = false;
setSettingsStatus(`Model saved, but file support could not be checked: ${error.message}. Use Retry to check again. Image attachments remain blocked.`, 'error');
}
});
setSettingsStatus(data.message || "Saved. Your next request will use these account settings; your conversation continues uninterrupted.", "success");
} catch (error) {
if (!settingsResponseIsCurrent(account, generation)) return;
+60 -9
View File
@@ -3,10 +3,13 @@
'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;
let searchTimer, syncTimer, listVersion = 0, action, syncBusy = false, accountEpoch = 0, rowMenu;
const sidebarWrites = new Set();
const selectedLocked = id => id === state.conversation?.id && (state.busy || !!state.stream || !terminal(state.run) || state.voiceActive);
function closeRowMenu() {if (rowMenu?.matches(':popover-open')) rowMenu.hidePopover();}
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; };
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', () => {const epoch = accountEpoch; Promise.resolve().then(fn).catch(error => {if (epoch === accountEpoch) 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));
@@ -34,7 +37,7 @@
(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();} });
document.addEventListener('keydown', event => { if (event.key === 'Escape' && rowMenu?.matches(':popover-open')) {closeRowMenu(); return;} 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();
@@ -108,10 +111,58 @@
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);}
closeRowMenu();
for (const conversation of data.conversations) {
const row = document.createElement('div'); row.className = 'conversation-row'; row.dataset.conversationId = conversation.id;
const b = button(conversation.title || 'Untitled chat', () => open(conversation.id), 'conversation-link'); b.setAttribute('aria-current', conversation.id === state.conversation?.id ? 'page' : 'false');
const actions = button('•••', () => showRowMenu(conversation, actions), 'conversation-row-actions'); actions.setAttribute('aria-label', 'Actions for ' + (conversation.title || 'Untitled chat')); actions.setAttribute('aria-haspopup', 'dialog'); actions.setAttribute('aria-expanded', 'false');
row.append(b, actions); $(conversation.pinned ? 'pinnedConversations' : 'recentConversations').append(row);
}
state.cursor = data.next_cursor; $('loadMoreConversations').hidden = !state.cursor;
}
function showRowMenu(conversation, trigger) {
closeRowMenu(); rowMenu?.remove();
const menu = document.createElement('div'); rowMenu = menu; menu.id = 'conversationRowActions'; trigger.setAttribute('aria-controls', menu.id); menu.className = 'conversation-row-menu'; menu.popover = 'auto'; menu.setAttribute('role', 'dialog'); menu.setAttribute('aria-label', 'Actions for ' + (conversation.title || 'Untitled chat'));
const epoch = accountEpoch, user = HopeAuth.user?.id;
const current = () => epoch === accountEpoch && user === HopeAuth.user?.id;
const pin = button(conversation.pinned ? 'Unpin' : 'Pin', async () => {closeRowMenu(); if (current()) await updateConversation(conversation, {pinned: !conversation.pinned});});
const rename = button('Rename', () => {closeRowMenu(); if (current()) dialog('Rename conversation', conversation.title, title => updateConversation(conversation, {title}));});
const archive = button(conversation.archived ? 'Unarchive' : 'Archive', () => {closeRowMenu(); if (current()) dialog(conversation.archived ? 'Unarchive conversation?' : 'Archive conversation?', '', () => updateConversation(conversation, {archived: !conversation.archived}), 'The conversation is retained. Only its archive status changes.', false);});
for (const b of [pin, rename, archive]) b.disabled = selectedLocked(conversation.id) || sidebarWrites.has(conversation.id);
menu.append(pin, rename, archive); $('chatSidebar').append(menu);
menu.addEventListener('toggle', () => {const expanded = menu.matches(':popover-open'); trigger.setAttribute('aria-expanded', String(expanded)); if (!expanded && trigger.isConnected && menu.contains(document.activeElement)) trigger.focus();});
menu.showPopover(); trigger.setAttribute('aria-expanded', 'true');
// The native top layer escapes the scrolling sidebar; clamp the menu to the viewport.
const rect = trigger.getBoundingClientRect(); menu.style.position = 'fixed'; menu.style.margin = '0';
menu.style.left = Math.max(8, Math.min(rect.right - menu.offsetWidth, innerWidth - menu.offsetWidth - 8)) + 'px';
menu.style.top = Math.max(8, Math.min(rect.bottom, innerHeight - menu.offsetHeight - 8)) + 'px';
menu.querySelector('button:not(:disabled)')?.focus();
}
async function updateConversation(conversation, patch) {
const epoch = accountEpoch, selectionEpoch = state.epoch, user = HopeAuth.user?.id, cid = conversation.id;
const current = () => epoch === accountEpoch && user === HopeAuth.user?.id;
if (!user || sidebarWrites.has(cid)) return;
if (selectedLocked(cid)) throw new Error('Wait until this conversation is idle before changing it.');
sidebarWrites.add(cid);
try {
let data;
try {data = await api('/api/conversations/' + enc(cid), 'PATCH', {revision: conversation.revision, ...patch});}
catch (error) {
if (!current()) return;
let refreshed = false;
try {const latest = await api('/api/conversations/' + enc(cid)); if (!current()) return; Object.assign(conversation, latest.conversation); if (selectionEpoch === state.epoch && cid === state.conversation?.id) selected(latest.conversation); await list(); refreshed = true;} catch {/* Keep the original mutation failure visible even if refresh also fails. */}
if (!current()) return;
throw new Error(error.message + (refreshed ? ' Conversation refreshed. Review the latest version and try again.' : ' Could not refresh the conversation. Retry when the connection is available.'));
}
if (!current()) return;
Object.assign(conversation, data.conversation);
if (selectionEpoch === state.epoch && cid === state.conversation?.id) {if ('archived' in patch && !selectedLocked(cid)) reset(); else selected(data.conversation);}
// Restart the current search/workspace/archive page: writes can change ordering and cursors.
await list();
} finally {if (current()) sidebarWrites.delete(cid);}
}
async function open(id) {
closeRowMenu();
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;
@@ -123,15 +174,15 @@
$('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();}
async function update(patch) {if (state.conversation) await updateConversation(state.conversation, patch);}
$('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();
$('chatWorkspaceSelect').hidden = true; $('chatWorkspaceLabel').hidden = true; if ($('conversationActions').matches(':popover-open')) $('conversationActions').hidePopover(); const epoch = accountEpoch; action = value => epoch === accountEpoch ? fn(value) : undefined; $('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;} };
$('chatActionForm').onsubmit = async event => {event.preventDefault(); const epoch = accountEpoch; $('chatActionSave').disabled = true; try {await action($('chatActionValue').value.trim()); if (epoch === accountEpoch) $('chatActionDialog').close();} catch (e) {if (epoch === accountEpoch) $('chatActionError').textContent = e.message;} finally {if (epoch === accountEpoch) $('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);
$('archiveConversation').onclick = () => dialog(state.conversation.archived ? 'Unarchive conversation?' : 'Archive conversation?', '', () => update({archived: !state.conversation.archived}), '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;};
@@ -209,7 +260,7 @@
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'});}
if (changed || !user) {++accountEpoch; closeRowMenu(); rowMenu?.remove(); rowMenu = null; sidebarWrites.clear(); $('chatActionSave').disabled = false; clearTimeout(searchTimer); 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);});
+119 -46
View File
@@ -1,11 +1,40 @@
// Account-scoped advanced chat controls. Server capability flags are authoritative.
(() => {
'use strict';
const state = {epoch: 0, user: null, capabilities: {}, attachments: [], controllers: new Set(), urls: new Set(), initialized: false};
const state = {epoch: 0, draftEpoch: 0, capabilityGeneration: 0, user: null, capabilities: {}, attachments: [], controllers: new Set(), urls: new Set(), initialized: false};
const enc = encodeURIComponent, $ = id => document.getElementById(id);
let options = {}, toolbar, previews, panel, status, artifactEditor, taskEditor, memoryEditor;
let options = {}, toolbar, previews, panel, status, composerStatus, artifactEditor, taskEditor, memoryEditor;
const node = (tag, text, cls) => {const el = document.createElement(tag); if (text !== undefined) el.textContent = text; if (cls) el.className = cls; return el;};
const message = text => {if (status) status.textContent = text;};
let statusTarget;
const message = (text, context = 'section') => {if (status) status.textContent = text; if (context === 'composer') {if (composerStatus) composerStatus.textContent = text;} else if (statusTarget) statusTarget.textContent = text; if (!text) {document.querySelectorAll('.feature-status').forEach(el => {el.textContent = '';}); if (composerStatus) composerStatus.textContent = '';}};
let fieldSequence = 0;
const extractionBlocked = file => ['failed', 'unavailable', 'encrypted'].includes(file.extraction_status);
const imageFile = file => (file.mime || '').startsWith('image/') || file.extraction_status === 'image';
function attachmentProblem(file) {
if (!state.user || state.capabilities.attachments !== true) return 'Attachments are unavailable for this account.';
if (extractionBlocked(file)) return `Text could not be extracted from ${file.filename}. Use a UTF-8 text file instead.`;
if (imageFile(file) && state.capabilities.image_input !== true) return 'Image input is not available for the selected model.';
if (file.mime && !(state.capabilities.attachment_mimes || []).includes(file.mime)) return 'Unsupported file type: ' + file.filename;
return '';
}
function focusComposer() {
$('libraryDialog')?.close();
(options.composerInput || $('primaryInput') || $('chatInput'))?.focus();
}
async function useFile(file) {
const problem = attachmentProblem(file); if (problem) throw new Error(problem);
if (state.attachments.some(item => item.attachment?.id === file.id)) {message(file.filename + ' is already queued. Send your message when ready.', 'composer'); focusComposer(); return;}
const maximum = state.capabilities.attachments_per_message || 8;
if (state.attachments.length >= maximum) throw new Error(`Attach at most ${maximum} files per message.`);
const draftEpoch = state.draftEpoch;
const current = (await request('/api/attachments/' + enc(file.id))).attachment;
if (draftEpoch !== state.draftEpoch) return;
const currentProblem = attachmentProblem(current); if (currentProblem) throw new Error(currentProblem);
if (state.attachments.some(item => item.attachment?.id === current.id)) return;
if (state.attachments.length >= maximum) throw new Error(`Attach at most ${maximum} files per message.`);
state.attachments.push({file: {name: current.filename, size: current.size}, attachment: current, status: 'ready', error: ''});
changed(); message(current.filename + ' queued. Send your message when ready.', 'composer'); focusComposer();
}
function button(label, fn) {const el = node('button', label, 'btn btn-ghost'); el.type = 'button'; el.addEventListener('click', async () => {el.disabled = true; try {await fn();} catch (error) {if (error.name !== 'AbortError') message(error.message);} finally {el.disabled = false;}}); return el;}
async function request(path, method = 'GET', body, extra = {}) {
const epoch = state.epoch, controller = new AbortController(); state.controllers.add(controller);
@@ -17,7 +46,7 @@
}
function changed() {renderAttachments(); window.dispatchEvent(new CustomEvent('hope:attachments-changed', {detail: {ids: attachmentIDs(), pending: hasPendingUploads()}}));}
function attachmentIDs() {return state.attachments.filter(item => item.attachment && item.status === 'ready').map(item => item.attachment.id);}
function hasPendingUploads() {return state.attachments.some(item => item.status !== 'ready');}
function hasPendingUploads() {return state.attachments.some(item => item.status !== 'ready' || !!attachmentProblem(item.attachment || {filename: item.file.name, mime: mime(item.file)}));}
function dispose(item) {item.controller?.abort(); if (item.url) {URL.revokeObjectURL(item.url); state.urls.delete(item.url);} }
function clearAttachmentsAfterSend(ids = attachmentIDs()) {const selected = new Set(ids); state.attachments = state.attachments.filter(item => {if (selected.has(item.attachment?.id)) {dispose(item); return false;} return true;}); changed();}
function clearAll() {for (const item of state.attachments) dispose(item); state.attachments = []; changed();}
@@ -35,27 +64,30 @@
finally {if (epoch === state.epoch) changed();}
}
async function addFiles(files) {
if (!state.user || state.capabilities.attachments !== true) {message('Attachments are unavailable for this account.'); return;}
const context = $('libraryDialog')?.open ? 'section' : 'composer', accepted = [], errors = [];
if (!state.user || state.capabilities.attachments !== true) {message('Attachments are unavailable for this account.', context); return;}
for (const file of Array.from(files)) {
const maximum = state.capabilities.attachments_per_message || 8;
if (state.attachments.length >= maximum) {message(`Attach at most ${maximum} files per message.`); break;}
if (!(state.capabilities.attachment_mimes || []).includes(mime(file))) {message('Unsupported file type: ' + file.name); continue;}
if (mime(file).startsWith('image/') && state.capabilities.image_input !== true) {message('Image input is not available for the selected model.'); continue;}
if (!file.size || file.size > (state.capabilities.upload_bytes || 8 * 1024 * 1024)) {message('File is empty or exceeds the upload limit: ' + file.name); continue;}
if (file.name.length > 160 || /[\\/\r\n\x00-\x1F]/.test(file.name)) {message('File name is not supported. Rename the file and retry.'); continue;}
if (state.attachments.length >= maximum) {errors.push(`Attach at most ${maximum} files per message.`); break;}
if (!(state.capabilities.attachment_mimes || []).includes(mime(file))) {errors.push('Unsupported file type: ' + file.name); continue;}
if (mime(file).startsWith('image/') && state.capabilities.image_input !== true) {errors.push('Image input is not available for the selected model.'); continue;}
if (!file.size || file.size > (state.capabilities.upload_bytes || 8 * 1024 * 1024)) {errors.push('File is empty or exceeds the upload limit: ' + file.name); continue;}
if (file.name.length > 160 || /[\\/\r\n\x00-\x1F]/.test(file.name)) {errors.push('File name is not supported. Rename the file and retry.'); continue;}
const item = {file, status: 'uploading', attachment: null, error: ''};
if (mime(file).startsWith('image/')) {item.url = URL.createObjectURL(file); state.urls.add(item.url);}
state.attachments.push(item); upload(item);
state.attachments.push(item); accepted.push(file.name); upload(item);
}
changed();
if (accepted.length) {focusComposer(); message('Uploading ' + accepted.join(', ') + '. Send your message after uploads finish.' + (errors.length ? ' ' + errors.join(' ') : ''), 'composer');}
else if (errors.length) message(errors.join(' '), context);
}
function renderAttachments() {
if (!previews) return; previews.replaceChildren();
for (const item of state.attachments) {
const row = node('div', undefined, 'attachment-preview');
const row = node('div', undefined, 'attachment-preview feature-row');
if (item.url) {const image = node('img'); image.src = item.url; image.alt = 'Preview of ' + item.file.name; image.width = 64; image.height = 64; row.append(image);}
row.append(node('span', `${item.file.name} · ${Math.ceil(item.file.size / 1024)} KB · ${item.status}`));
if (item.error) row.append(node('p', item.error));
const problem = item.error || (item.status === 'ready' ? attachmentProblem(item.attachment) : ''); if (problem) row.append(node('p', problem));
if (item.attachment?.extraction_status === 'truncated') row.append(node('p', 'Only the extracted text limit will be sent.'));
if (item.status === 'uploading') row.append(button('Cancel upload', () => item.controller.abort()));
if (item.status === 'failed') row.append(button('Retry upload', () => upload(item)));
@@ -63,30 +95,51 @@
previews.append(row);
}
}
function field(form, label, kind = 'input', value = '') {const wrap = node('label', label), input = node(kind); input.value = value; wrap.append(input); form.append(wrap); return input;}
function section(title) {const detail = node('details'), summary = node('summary', title), body = node('div'); detail.append(summary, body); panel.append(detail); return {detail, body};}
function formSubmit(form, fn) {form.addEventListener('submit', async event => {event.preventDefault(); const submit = form.querySelector('[type="submit"]'); submit.disabled = true; try {await fn();} catch (error) {if (error.name !== 'AbortError') message(error.message + ' Your edits are retained.');} finally {submit.disabled = false;}});}
function submitButton(form, label) {const el = node('button', label, 'btn btn-primary'); el.type = 'submit'; form.append(el); return el;}
function confirmAction(container, label, description, fn) {const details = node('details'), summary = node('summary', label); details.append(summary, node('p', description), button('Confirm ' + label.toLowerCase(), fn)); container.append(details);}
function download(path, label) {const link = node('a', label, 'btn btn-ghost'); link.href = path; link.setAttribute('download', ''); return link;}
function field(form, label, kind = 'input', value = '', hint = '') {
const wrap = node('div', undefined, 'feature-field'), input = node(kind), id = 'feature-field-' + ++fieldSequence;
input.id = id; input.value = value; const caption = node('label', label); caption.htmlFor = id; wrap.append(caption, input);
if (hint) {const help = node('p', hint, 'feature-hint'); help.id = id + '-hint'; input.setAttribute('aria-describedby', help.id); wrap.append(help);}
form.append(wrap); return input;
}
function section(title) {
const detail = node('details', undefined, 'feature-section'), summary = node('summary', title), body = node('div', undefined, 'feature-section-body');
const localStatus = node('p', undefined, 'feature-status'); localStatus.setAttribute('role', 'status');
detail.addEventListener('click', () => {statusTarget = localStatus;}, true); detail.addEventListener('submit', () => {statusTarget = localStatus;}, true);
const unavailable = node('p', title + ' are unavailable for this account.', 'feature-empty'); unavailable.hidden = true;
detail.append(summary, localStatus, body); panel.append(detail, unavailable); return {detail, body, unavailable};
}
function rowParts() {const row = node('div', undefined, 'feature-row'), main = node('div', undefined, 'feature-row-main'), actions = node('div', undefined, 'feature-row-actions'); row.append(main, actions); return {row, main, actions};}
function listBody(area, intro, actions, empty) {
const body = area.body, tools = node('div', undefined, 'feature-toolbar'), list = node('div', undefined, 'feature-list'); tools.append(...actions);
body.replaceChildren(node('p', intro, 'feature-intro'), tools, list); if (empty) list.append(node('p', empty, 'feature-empty')); return list;
}
function formSubmit(form, fn) {form.addEventListener('submit', async event => {event.preventDefault(); const submit = form.querySelector('[type="submit"]'); submit.disabled = true; try {await fn();} catch (error) {if (error.name !== 'AbortError') message(error.message + ' Your edits are retained.' + (form.dataset.memory === 'true' && (error.status === 409 || /conflict/i.test(error.message)) ? ' Compare latest saved memory, review its content, then explicitly accept the reviewed revision before saving again.' : ''));} finally {submit.disabled = false;}});}
function submitButton(form, label) {const el = node('button', label, 'btn btn-primary'); el.type = 'submit'; const actions = node('div', undefined, 'feature-form-actions'); actions.append(el); form.append(actions); return el;}
function confirmAction(container, label, description, fn) {const details = node('details', undefined, 'feature-confirm'), summary = node('summary', label.startsWith('Delete ') && label !== 'Delete task' && label !== 'Delete draft' ? 'Delete' : label); summary.setAttribute('aria-label', label); details.append(summary, node('p', description), button('Confirm ' + label.toLowerCase(), fn)); container.append(details);}
function download(path, label) {const link = node('a', label.startsWith('Download ') ? 'Download' : label, 'btn btn-ghost'); if (label.startsWith('Download ')) link.setAttribute('aria-label', label); link.href = path; link.setAttribute('download', ''); return link;}
const filesSection = {value: null}, artifactSection = {value: null}, taskSection = {value: null}, memorySection = {value: null};
async function loadFiles() {
const data = await request('/api/attachments'), body = filesSection.value.body; body.replaceChildren(button('Refresh files', loadFiles));
if (!data.attachments?.length) body.append(node('p', 'Uploaded files stay in your private account until you delete them.'));
for (const file of data.attachments || []) {const row = node('div'); row.append(node('span', `${file.filename} · ${Math.ceil(file.size / 1024)} KB · ${file.extraction_status}`), download('/api/attachments/' + enc(file.id) + '/content', 'Download ' + file.filename)); confirmAction(row, 'Delete ' + file.filename, "Deleting removes file bytes; old chat attachment references won't work.", async () => {await request('/api/attachments/' + enc(file.id), 'DELETE'); state.attachments = state.attachments.filter(item => {if (item.attachment?.id === file.id) {dispose(item); return false;} return true;}); changed(); await loadFiles(); message('File deleted.');}); body.append(row);}
const data = await request('/api/attachments');
const list = listBody(filesSection.value, 'Files are private account uploads you can reuse in conversations. Uploading or choosing a file only queues it; nothing is sent until you send a message.', [button('Upload files', () => {if (state.capabilities.attachments !== true) throw new Error('Attachments are unavailable for this account.'); toolbar.querySelector('input[type="file"]').click();}), button('Refresh files', loadFiles)], !data.attachments?.length ? 'No uploaded files yet. Upload a document or supported image to use it in a conversation. Files stay in your account until you delete them.' : '');
for (const file of data.attachments || []) {
const {row, main, actions} = rowParts(); main.append(node('h3', file.filename), node('p', `${Math.ceil(file.size / 1024)} KB · ${file.extraction_status}`));
const problem = attachmentProblem(file); if (problem) main.append(node('p', problem));
actions.append(button('Use in conversation', () => useFile(file)), download('/api/attachments/' + enc(file.id) + '/content', 'Download ' + file.filename));
confirmAction(actions, 'Delete ' + file.filename, `Delete “${file.filename}” from your account? Deleting removes file bytes; old chat attachment references won't work. Your conversations are not deleted.`, async () => {await request('/api/attachments/' + enc(file.id), 'DELETE'); state.attachments = state.attachments.filter(item => {if (item.attachment?.id === file.id) {dispose(item); return false;} return true;}); changed(); await loadFiles(); message(file.filename + ' deleted.');}); list.append(row);
}
}
async function loadArtifacts() {
const data = await request('/api/artifacts'); const body = artifactSection.value.body; body.replaceChildren(button('New artifact', () => editArtifact(null)), button('Refresh artifacts', loadArtifacts));
if (!data.artifacts?.length) body.append(node('p', 'Save a draft, note or code snippet to keep versions across devices.'));
for (const artifact of data.artifacts || []) {const row = node('div'); row.append(node('span', artifact.title), button('Edit ' + artifact.title, async () => editArtifact((await request('/api/artifacts/' + enc(artifact.id))).artifact)), download('/api/artifacts/' + enc(artifact.id) + '/export', 'Export')); confirmAction(row, 'Delete artifact', 'This removes the artifact and its saved versions.', async () => {await request('/api/artifacts/' + enc(artifact.id), 'DELETE'); await loadArtifacts(); message('Artifact deleted.');}); body.append(row);}
const data = await request('/api/artifacts');
const list = listBody(artifactSection.value, 'Saved drafts (artifacts) are editable notes, writing and code with saved versions. They are separate from uploaded files and are not automatically attached to a conversation.', [button('New draft', () => editArtifact(null)), button('Refresh drafts', loadArtifacts)], !data.artifacts?.length ? 'No saved drafts yet. Create a draft, note or code snippet to keep versions across devices.' : '');
for (const artifact of data.artifacts || []) {const {row, main, actions} = rowParts(); main.append(node('h3', artifact.title)); actions.append(button('Edit ' + artifact.title, async () => editArtifact((await request('/api/artifacts/' + enc(artifact.id))).artifact)), download('/api/artifacts/' + enc(artifact.id) + '/export', 'Export')); confirmAction(actions, 'Delete draft', `Delete “${artifact.title}”? This removes this saved draft and its versions, not conversations or uploaded files.`, async () => {await request('/api/artifacts/' + enc(artifact.id), 'DELETE'); await loadArtifacts(); message(artifact.title + ' deleted.');}); list.append(row);}
}
function editArtifact(artifact) {
artifactEditor.replaceChildren(); const form = node('form'), heading = node('h3', artifact ? 'Edit artifact' : 'New artifact');
artifactEditor.replaceChildren(); const form = node('form', undefined, 'feature-form'), heading = node('h3', artifact ? 'Edit draft' : 'New draft');
const title = field(form, 'Title', 'input', artifact?.title || ''); title.required = true; title.maxLength = 160;
const kind = field(form, 'Format', 'select'); for (const format of ['markdown', 'text', 'code']) {const option = node('option', format); option.value = format; kind.append(option);} kind.value = artifact?.kind || 'markdown';
const content = field(form, 'Content', 'textarea', artifact?.content || ''); content.rows = 12;
const preview = node('pre', artifact?.content || ''); content.addEventListener('input', () => {preview.textContent = content.value;});
submitButton(form, artifact ? 'Save new version' : 'Create artifact'); form.append(button('Close editor', () => artifactEditor.replaceChildren()));
submitButton(form, artifact ? 'Save new version' : 'Create draft'); form.append(button('Close editor', () => artifactEditor.replaceChildren()));
if (artifact) {
const versions = field(form, 'Saved version', 'select'); for (let revision = artifact.revision; revision >= 1; revision--) {const option = node('option', 'Version ' + revision); option.value = revision; versions.append(option);}
const versionExport = download('/api/artifacts/' + enc(artifact.id) + '/export?revision=' + enc(versions.value), 'Export selected saved version'); versions.addEventListener('change', () => {versionExport.href = '/api/artifacts/' + enc(artifact.id) + '/export?revision=' + enc(versions.value);}); form.append(button('View saved version', async () => {const saved = (await request('/api/artifacts/' + enc(artifact.id) + '?revision=' + enc(versions.value))).artifact; preview.textContent = saved.content; message('Viewing version ' + saved.revision + '. Your current edits are unchanged.');}), download('/api/artifacts/' + enc(artifact.id) + '/export', 'Export latest saved version'), versionExport);
@@ -95,34 +148,52 @@
artifactEditor.append(heading, form, node('h4', 'Safe text preview'), preview); title.focus();
}
async function loadTasks() {
const data = await request('/api/tasks'), body = taskSection.value.body; body.replaceChildren(button('New scheduled task', () => editTask(null)), button('Refresh tasks', loadTasks));
if (!data.tasks?.length) body.append(node('p', 'Schedule a prompt for your private assistant. Results stay local.'));
for (const task of data.tasks || []) {const row = node('div'); row.append(node('h3', task.name), node('p', String(task.schedule || 'Schedule not supplied') + ' · ' + (task.enabled === true ? 'Enabled' : task.enabled === false ? 'Paused' : 'Status not supplied')), node('p', task.prompt), button('Edit task', () => editTask(task)), button(task.enabled === true ? 'Pause' : task.enabled === false ? 'Resume' : 'Refresh task status', async () => {if (typeof task.enabled !== 'boolean') {await loadTasks(); return;} await request('/api/tasks/' + enc(task.id) + '/' + (task.enabled ? 'pause' : 'resume'), 'POST'); await loadTasks(); message(task.enabled ? 'Task paused.' : 'Task resumed.');}), button('Run now', async () => {await request('/api/tasks/' + enc(task.id) + '/run', 'POST'); await loadTasks(); message('Task run requested. Refresh tasks to check its state.');})); confirmAction(row, 'Delete task', 'This removes the scheduled task, not your conversations.', async () => {await request('/api/tasks/' + enc(task.id), 'DELETE'); await loadTasks(); message('Task deleted.');}); body.append(row);}
const data = await request('/api/tasks');
const list = listBody(taskSection.value, 'Scheduled tasks run a prompt with your private assistant. Delivery stays local; no external recipients or notifications are configured here.', [button('New scheduled task', () => editTask(null)), button('Refresh tasks', loadTasks)], !data.tasks?.length ? 'No scheduled tasks yet. Create one paused to review its prompt and schedule before enabling it.' : '');
for (const task of data.tasks || []) {
const {row, main, actions} = rowParts(); main.append(node('h3', task.name || 'Scheduled task'), node('p', String(task.schedule || 'Schedule not supplied') + ' · ' + (task.enabled === true ? 'Enabled' : task.enabled === false ? 'Paused' : 'Status not supplied')), node('p', task.prompt || 'Prompt not supplied'));
actions.append(button('Edit task', () => editTask(task)), button(task.enabled === true ? 'Pause' : task.enabled === false ? 'Resume' : 'Refresh task status', async () => {if (typeof task.enabled !== 'boolean') {await loadTasks(); return;} await request('/api/tasks/' + enc(task.id) + '/' + (task.enabled ? 'pause' : 'resume'), 'POST'); await loadTasks(); message(task.enabled ? 'Task paused.' : 'Task resumed.');}), button('Run now', async () => {await request('/api/tasks/' + enc(task.id) + '/run', 'POST'); await loadTasks(); message('Task run requested. Refresh tasks to check its state.');}));
confirmAction(actions, 'Delete task', `Delete “${task.name || 'Scheduled task'}”? This removes the scheduled task, not your conversations.`, async () => {await request('/api/tasks/' + enc(task.id), 'DELETE'); await loadTasks(); message('Task deleted.');}); list.append(row);
}
}
function editTask(task) {
taskEditor.replaceChildren(); const form = node('form'), title = field(form, 'Task name', 'input', task?.name || ''), schedule = field(form, 'Schedule', 'input', task?.schedule || ''), prompt = field(form, 'Prompt', 'textarea', task?.prompt || ''), repeat = field(form, 'Repeat count (optional)', 'input', task?.repeat || '');
taskEditor.replaceChildren(); const form = node('form', undefined, 'feature-form');
const title = field(form, 'Task name', 'input', task?.name || '', 'A short name to recognize this task, for example “Review my notes”.');
const schedule = field(form, 'Schedule', 'input', task?.schedule || '', 'Enter a schedule accepted by your assistant, for example cron: 0 9 * * *. The server validates the schedule; timezone and next run are not inferred here.');
const prompt = field(form, 'Prompt', 'textarea', task?.prompt || '', 'What should the assistant do when this task runs? For example: “Summarize the priorities in my saved notes.”'); prompt.rows = 5;
const repeat = field(form, 'Repeat count (optional)', 'input', task?.repeat ?? '', 'Optional positive whole-number repeat value passed to the scheduler. Leave blank to omit it; no unlimited-run behavior is assumed.');
title.maxLength = 200; schedule.maxLength = 200; prompt.maxLength = 5000; title.required = schedule.required = prompt.required = true; repeat.type = 'number'; repeat.min = 1; repeat.step = 1;
form.append(node('p', 'Use a schedule accepted by your assistant, such as a cron expression. Invalid schedules are rejected by the server. Delivery stays local.'));
const paused = field(form, task ? 'Enabled' : 'Create paused'); paused.type = 'checkbox'; paused.checked = task ? !!task.enabled : true;
const paused = field(form, task ? 'Enabled' : 'Create paused', 'input', '', task ? 'Uncheck to pause this task.' : 'Checked by default: review the task before choosing Resume. Uncheck to create it enabled.'); paused.type = 'checkbox'; paused.checked = task ? task.enabled === true : true; paused.parentElement.classList.add('feature-checkbox');
submitButton(form, task ? 'Save task' : 'Create task'); form.append(button('Close task editor', () => taskEditor.replaceChildren()));
formSubmit(form, async () => {const data = {name: title.value.trim(), schedule: schedule.value.trim(), prompt: prompt.value.trim(), ...(task ? {enabled: paused.checked} : {paused: paused.checked})}; if (repeat.value) data.repeat = Number(repeat.value); await request('/api/tasks' + (task ? '/' + enc(task.id) : ''), task ? 'PATCH' : 'POST', data); await loadTasks(); taskEditor.replaceChildren(); message(task ? 'Task saved.' : 'Task created.');}); taskEditor.append(node('h3', task ? 'Edit scheduled task' : 'New scheduled task'), form); title.focus();
}
async function loadMemory() {
const data = await request('/api/memory'); memoryEditor.replaceChildren(node('p', 'Saved memory changes may take effect in a later assistant session. Clearing a section does not delete your conversations.'));
const data = await request('/api/memory'); memoryEditor.replaceChildren(node('p', 'Memory is saved account context, not conversation history. Changes may take effect in a later assistant session. Clearing a section does not delete your conversations or other account data.', 'feature-intro'));
for (const section of data.sections || []) {
const form = node('form'), label = section.section === 'user' ? 'About you' : 'Assistant memory', content = field(form, label, 'textarea', section.content); content.rows = 10;
const form = node('form', undefined, 'feature-form'), label = section.section === 'user' ? 'About you' : 'Assistant memory', content = field(form, label, 'textarea', section.content, section.section === 'user' ? 'Tell the assistant about your preferences and context. For example: “Call me Sam. I prefer concise answers and work in product design.”' : 'Review durable facts and guidance the assistant keeps between sessions. For example: “Our project is called Atlas; use metric units in project notes.”'); content.rows = 10; form.dataset.memory = 'true';
submitButton(form, 'Save ' + label.toLowerCase()); confirmAction(form, 'Clear ' + label.toLowerCase(), 'This clears this saved section. Conversations and the other section stay unchanged.', async () => {const saved = await request('/api/memory/' + enc(section.section), 'PUT', {content: '', revision: section.revision}); section.revision = saved.revision; content.value = ''; message(label + ' cleared.');});
const compare = node('div', undefined, 'feature-editor'); compare.hidden = true;
form.append(button('Compare latest saved memory', async () => {
const latest = (await request('/api/memory')).sections.find(item => item.section === section.section);
if (!latest) throw new Error('Saved memory section is unavailable. Your edits are retained.');
compare.hidden = false; compare.replaceChildren(node('h4', 'Latest saved ' + label.toLowerCase()), node('p', 'Your draft above is unchanged. Review the saved text before accepting its revision. Saving after accepting will replace this saved text with your draft.'), node('pre', latest.content), button('Use reviewed revision for next save', () => {section.revision = latest.revision; compare.hidden = true; message('Reviewed revision accepted. Your draft is unchanged; choose Save to replace the saved section.');}));
}), compare);
formSubmit(form, async () => {if (new TextEncoder().encode(content.value).length > 65536) throw new Error('Memory section exceeds 64 KB.'); const saved = await request('/api/memory/' + enc(section.section), 'PUT', {content: content.value, revision: section.revision}); section.revision = saved.revision; message(label + ' saved.');}); memoryEditor.append(form);
}
}
async function refresh() {
if (!state.user) return;
const result = await request('/api/capabilities'); state.capabilities = result.features || result.capabilities || result;
const generation = ++state.capabilityGeneration;
state.capabilities = {...state.capabilities, image_input: false}; changed();
const result = await request('/api/capabilities');
if (generation !== state.capabilityGeneration) return;
state.capabilities = result.features || result.capabilities || result;
toolbar.hidden = !state.user; const input = toolbar.querySelector('input[type="file"]'); input.accept = (state.capabilities.attachment_mimes || []).filter(type => !type.startsWith('image/') || state.capabilities.image_input === true).join(','); input.disabled = state.capabilities.attachments !== true;
toolbar.querySelector('[data-attach]').disabled = input.disabled;
filesSection.value.detail.hidden = state.capabilities.attachments !== true; artifactSection.value.detail.hidden = state.capabilities.artifacts !== true; taskSection.value.detail.hidden = state.capabilities.scheduled_tasks !== true; memorySection.value.detail.hidden = state.capabilities.memory_controls !== true;
panel.hidden = !state.user;
const availability = $('featureAvailability'); availability.textContent = [state.capabilities.scheduled_tasks !== true ? 'Scheduled tasks are unavailable.' : '', state.capabilities.memory_controls !== true ? 'Memory controls are unavailable.' : '', state.capabilities.image_input !== true ? 'Image input is unavailable for this model.' : ''].filter(Boolean).join(' ');
for (const area of [filesSection.value, artifactSection.value, taskSection.value, memorySection.value]) area.unavailable.hidden = !area.detail.hidden;
panel.hidden = !state.user; changed();
const availability = $('featureAvailability'); availability.textContent = [state.capabilities.attachments !== true ? 'Files are unavailable.' : '', state.capabilities.artifacts !== true ? 'Saved drafts are unavailable.' : '', state.capabilities.scheduled_tasks !== true ? 'Scheduled tasks are unavailable.' : '', state.capabilities.memory_controls !== true ? 'Memory controls are unavailable.' : '', state.capabilities.image_input !== true ? 'Image input is unavailable for this model.' : ''].filter(Boolean).join(' ');
}
function initialize(config = {}) {
options = {...options, ...config}; if (state.initialized) return; state.initialized = true;
@@ -130,27 +201,29 @@
if (!toolbar) {toolbar = node('div'); toolbar.id = 'featureActions'; ($('primaryComposer') || document.body).append(toolbar);}
if (!previews) {previews = node('div'); previews.id = 'attachmentPreviews'; toolbar.after(previews);}
previews.setAttribute('aria-live', 'polite');
composerStatus = $('chatNotice'); if (!composerStatus) {composerStatus = node('p'); composerStatus.id = 'featureComposerStatus'; composerStatus.setAttribute('role', 'status'); previews.after(composerStatus);}
const input = node('input'); input.type = 'file'; input.multiple = true; input.hidden = true; input.setAttribute('aria-label', 'Choose attachments');
const attach = button('Attach files', () => input.click()); attach.dataset.attach = ''; input.addEventListener('change', () => {addFiles(input.files); input.value = '';}); toolbar.append(attach, input);
panel = node('section'); panel.id = 'hopeFeatures'; panel.setAttribute('aria-label', 'Saved work and assistant controls'); ($('libraryContent') || $('chatSidebar') || document.body).append(panel);
status = node('p'); status.id = 'featureStatus'; status.setAttribute('role', 'status'); const availability = node('p'); availability.id = 'featureAvailability'; const availabilityDetails = node('details'); availabilityDetails.append(node('summary', 'Feature availability'), availability); panel.append(status, availabilityDetails);
filesSection.value = section('Files'); artifactSection.value = section('Artifacts'); artifactEditor = node('div'); artifactSection.value.detail.append(artifactEditor);
taskSection.value = section('Scheduled tasks'); taskEditor = node('div'); taskSection.value.detail.append(taskEditor);
memorySection.value = section('Memory'); memoryEditor = node('div'); memorySection.value.detail.append(memoryEditor);
status = node('p'); status.id = 'featureStatus'; status.setAttribute('role', 'status'); const availability = node('p'); availability.id = 'featureAvailability'; const availabilityDetails = node('details', undefined, 'feature-section'); availabilityDetails.append(node('summary', 'Feature availability'), availability); panel.append(status, availabilityDetails);
filesSection.value = section('Files'); artifactSection.value = section('Saved drafts'); artifactEditor = node('div', undefined, 'feature-editor'); artifactSection.value.detail.append(artifactEditor);
taskSection.value = section('Scheduled tasks'); taskEditor = node('div', undefined, 'feature-editor'); taskSection.value.detail.append(taskEditor);
memorySection.value = section('Memory'); memoryEditor = node('div', undefined, 'feature-editor'); memorySection.value.detail.append(memoryEditor);
$('openLibrary')?.addEventListener('click', () => openSection('files'));
if ($('activityContent')) $('activityContent').append(taskSection.value.detail);
for (const [area, load] of [[filesSection.value, loadFiles], [artifactSection.value, loadArtifacts], [taskSection.value, loadTasks], [memorySection.value, loadMemory]]) area.detail.addEventListener('toggle', () => {if (area.detail.open && state.user) load().catch(error => {if (error.name !== 'AbortError') message(error.message);});});
for (const [area, load] of [[filesSection.value, loadFiles], [artifactSection.value, loadArtifacts], [taskSection.value, loadTasks], [memorySection.value, loadMemory]]) area.detail.addEventListener('toggle', () => {if (area.detail.open && state.user) load().catch(error => {if (error.name !== 'AbortError') {area.detail.querySelector('.feature-status').textContent = error.message; if (status) status.textContent = error.message;}});});
const drop = options.dropTarget || $('primaryComposer') || toolbar;
drop.addEventListener('dragover', event => {if (event.dataTransfer?.types.includes('Files')) event.preventDefault();});
drop.addEventListener('drop', event => {if (event.dataTransfer?.files.length) {event.preventDefault(); addFiles(event.dataTransfer.files);}});
window.addEventListener('hope:conversation-selected', event => {const next = event.detail?.conversation_id || null; if (state.conversation && next !== state.conversation) clearAll(); state.conversation = next;});
window.addEventListener('hope:conversation-selected', event => {state.draftEpoch++; const next = event.detail?.conversation_id || null; if (state.conversation && next !== state.conversation) clearAll(); state.conversation = next;});
HopeAuth.onChange((user, accountChanged) => {
if (accountChanged || !user) {state.epoch++; for (const controller of state.controllers) controller.abort(); state.controllers.clear(); clearAll(); state.capabilities = {}; for (const area of [filesSection.value, artifactSection.value, taskSection.value, memorySection.value]) {area.detail.open = false; area.body.replaceChildren(); area.detail.hidden = true;} artifactEditor.replaceChildren(); taskEditor.replaceChildren(); memoryEditor.replaceChildren(); message('');}
if (accountChanged || !user) {state.epoch++; state.draftEpoch++; for (const controller of state.controllers) controller.abort(); state.controllers.clear(); clearAll(); state.capabilities = {}; state.conversation = null; for (const area of [filesSection.value, artifactSection.value, taskSection.value, memorySection.value]) {area.detail.open = false; area.body.replaceChildren(); area.detail.hidden = true; area.unavailable.hidden = true;} artifactEditor.replaceChildren(); taskEditor.replaceChildren(); memoryEditor.replaceChildren(); message('');}
state.user = user; toolbar.hidden = panel.hidden = !user; if (user && (accountChanged || !Object.keys(state.capabilities).length)) refresh().catch(error => {if (error.name !== 'AbortError') message(error.message);});
});
}
function mountSection(name, target) {
const area = name === 'memory' ? memorySection.value : name === 'tasks' ? taskSection.value : name === 'files' ? filesSection.value : name === 'artifacts' ? artifactSection.value : null;
if (area && target) target.append(area.detail);
if (area && target) target.append(area.detail, area.unavailable);
return area?.detail || null;
}
function openSection(name) {
+3 -3
View File
@@ -77,8 +77,8 @@
<button id="retryVoices" type="button" class="btn btn-secondary" hidden>Retry voice catalog</button>
<div class="voice-list" id="voiceList" role="radiogroup" aria-label="English voices"></div>
</div></div></section>
<section data-settings-panel="privacy" hidden><h3>Memory &amp; data</h3><p id="privacyIdentity"></p><p>Memory controls are available only when supported by your private assistant. Conversation deletion does not erase account memory.</p><p id="settingsMemoryStatus" role="status"></p><div id="privacyMemory"></div><button id="settingsOpenLibrary" class="btn btn-secondary">Open Library</button></section>
<section data-settings-panel="security" hidden><h3>Security</h3><p>Hope uses a secure cookie session. Sign out when you leave a shared device.</p><p>Password resets are managed by an administrator and revoke existing sessions.</p></section>
<section data-settings-panel="privacy" hidden><h3>Memory &amp; data</h3><p class="settings-intro">Choose what Hope keeps for future conversations. Saved memory is separate from your chat history and uploaded files.</p><p id="privacyIdentity"></p><p id="settingsMemoryStatus" role="status"></p><div id="privacyMemory"></div><section class="data-guide" aria-labelledby="dataGuideTitle"><h4 id="dataGuideTitle">Manage your other saved data</h4><dl><div><dt>Conversations</dt><dd>Use the ••• menu beside a chat to archive it. To delete a chat, open it and use Conversation actions at the top right. Archiving keeps the chat; deleting removes it.</dd></div><div><dt>Files &amp; saved drafts</dt><dd>Review downloads and delete individual items in Library. Deleting a file also makes it unavailable to old chats that reference it.</dd></div></dl><button id="settingsOpenLibrary" class="btn btn-secondary">Manage files &amp; drafts</button></section></section>
<section data-settings-panel="security" hidden><h3>Security</h3><p class="settings-intro">Your current connection and account access—not another set of switches to configure.</p><dl class="security-facts"><div><dt>Connected server</dt><dd id="securityServer"></dd></div><div><dt>Connection</dt><dd id="securityConnection"></dd></div><div><dt>Signed-in account</dt><dd id="securityIdentity"></dd></div><div><dt>Session access</dt><dd>Hope uses a server-managed cookie session. Signing out ends this browser’s current session.</dd></div></dl><section class="security-action"><h4>Leaving a shared device?</h4><p>Sign out so the next person cannot open your conversations or files.</p><button id="securitySignOut" data-settings-signout class="btn btn-secondary" type="button">Sign out of this browser</button></section><section class="security-action"><h4>Password or account concerns?</h4><p>Contact your Hope administrator for a password reset. A reset revokes existing sessions. Session lists, self-service password changes and two-factor settings are not available here.</p></section></section>
<section data-settings-panel="admin" hidden> <section id="adminSection" class="admin-section" hidden aria-labelledby="adminHeading">
<div class="admin-header"><div><h2 id="adminHeading" tabindex="-1">Accounts</h2><p>Create a private assistant for each person.</p></div><button id="refreshUsers" class="btn btn-secondary">Refresh status</button></div>
<p id="adminMessage" role="status">Loading accounts…</p>
@@ -96,7 +96,7 @@
</section>
</div></div></dialog>
<div id="sheetBackdrop" class="sheet-backdrop" hidden></div>
<dialog id="libraryDialog" aria-labelledby="libraryTitle"><div class="dialog-head"><h2 id="libraryTitle">Library</h2><button id="closeLibrary" class="btn btn-icon" aria-label="Close Library">×</button></div><p>Files and saved work, private to your account.</p><div id="libraryContent"></div></dialog>
<dialog id="libraryDialog" class="library-window" aria-labelledby="libraryTitle"><div class="dialog-head"><div><h2 id="libraryTitle">Library</h2><p class="settings-intro">Your reference files and saved drafts.</p></div><button id="closeLibrary" class="btn btn-icon" aria-label="Close Library">×</button></div><p class="library-guide">Upload a file, choose <strong>Use in conversation</strong>, then ask Hope about it. Files are only included when you send a message—not automatically in every chat.</p><div id="libraryContent"></div></dialog>
<div class="tasks-panel" id="tasksPanel" hidden inert aria-label="Assistant activity"><div class="tasks-header"><div><h2>Activity</h2><p>Progress from your private assistant.</p></div><button id="closeTasksBtn" class="btn btn-icon" aria-label="Close activity">×</button></div><div class="tasks-list" id="tasksList"><div class="tasks-empty"><strong>No activity yet</strong><span>Progress appears here while Hope works. Replies stay in your conversation.</span></div></div><div id="activityContent"></div></div>
<div hidden aria-hidden="true"><button id="voiceModeBtn" type="button">Voice</button><button id="textModeBtn" type="button">Text</button><button id="clearBtn" type="button">Clear</button><button id="openVoice" type="button">Voice</button><button id="backToChat" type="button">Back to chat</button><form id="chatComposer"><textarea id="chatInput"></textarea><button id="sendChatBtn" disabled>Send</button></form></div>
<dialog id="accountDialog" aria-labelledby="accountDialogTitle" aria-describedby="accountDialogDescription">
+11 -1
View File
@@ -19,7 +19,7 @@
function updateMemoryStatus() {
text('settingsMemoryStatus', !memory || memory.hidden
? 'Memory controls are unavailable for this account. No memory has been changed.'
: 'Review or edit your saved memory below. Clearing memory does not delete conversations.');
: '');
}
function identity(user, changed) {
text('settingsAccountName', user?.display_name || user?.username || '');
@@ -51,6 +51,10 @@
button.classList.toggle('selected', active);
if (active) button.setAttribute('aria-current', 'page'); else button.removeAttribute('aria-current');
});
if (window.matchMedia('(max-width: 600px)').matches) {
const selected = dialog.querySelector('[data-settings-target].selected');
selected?.scrollIntoView({block: 'nearest', inline: 'nearest', behavior: 'instant'});
}
// auth.js keeps its polling and provisioning handlers; never copy them.
if ($('adminSection')) $('adminSection').hidden = section !== 'admin';
$('adminBtn')?.setAttribute('aria-expanded', String(section === 'admin'));
@@ -64,6 +68,12 @@
if (typeof loadModelSettings === 'function') loadModelSettings();
}
}
if (section === 'security') {
text('securityServer', window.location.host);
text('securityConnection', window.location.protocol === 'https:' ? 'HTTPS · connection encrypted in transit' : 'Not HTTPS · use your secure Hope address');
const user = window.HopeAuth?.user;
text('securityIdentity', user ? `${user.display_name || user.username} (@${user.username})` : 'Not signed in');
}
if (section === 'privacy') {
if (!memory) memory = window.HopeFeatures?.mountMemory?.($('privacyMemory') || panel('privacy')) || null;
const status = $('featureStatus');
+86
View File
@@ -150,7 +150,10 @@ input::placeholder,textarea::placeholder{
opacity:1}
.chat-sidebar nav{
flex:1;
min-height:0;
overflow-y:auto;
margin-top:18px}
.sidebar-brand,.chat-sidebar>.new-chat,.chat-sidebar>input,.sidebar-footer{flex-shrink:0}
.chat-sidebar h2{
font-size:12px;
font-weight:600;
@@ -840,6 +843,11 @@ dialog p{
border-left:1px solid var(--line);
padding:22px}
.tasks-header{
position:sticky;
top:-22px;
background:var(--canvas);
z-index:1;
padding-top:12px;
display:flex;
align-items:flex-start;
justify-content:space-between;
@@ -894,6 +902,84 @@ dialog p{
#hopeFeatures details{
padding:14px 0;
border-bottom:1px solid var(--line)}
/* Feature sections retain their layout when mounted in Library, Activity or Settings. */
.feature-section{min-width:0;margin-block:20px;border-bottom:1px solid var(--line);padding-bottom:20px}
.feature-section>summary{font-size:1rem;font-weight:600;cursor:pointer;min-height:44px;align-content:center}
.feature-section-body{min-width:0;padding-top:12px}
.feature-intro,.feature-empty,.feature-status{line-height:1.6;max-width:68ch;overflow-wrap:anywhere}
.feature-intro{color:var(--ink-soft);margin-bottom:16px}
.feature-status:empty{display:none}
.feature-status{margin-block:12px}
.feature-toolbar,.feature-row-actions,.feature-form-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-block:12px}
.feature-list{display:flex;flex-direction:column;min-width:0}
.feature-row{display:flex;flex-direction:column;gap:12px;align-items:stretch;padding-block:18px;border-top:1px solid var(--line);min-width:0}
.feature-row-main{flex:1;min-width:0;overflow-wrap:anywhere}
.feature-row-main h3{font-size:1rem;margin-bottom:6px}
.feature-row-main p{line-height:1.6;color:var(--ink-soft);margin-block:6px}
.feature-row-actions{flex:0 1 auto;margin:0;max-width:100%}
.feature-form{display:flex;flex-direction:column;gap:18px;min-width:0;margin-top:16px}
.feature-field{display:flex;flex-direction:column;gap:8px;min-width:0;font-weight:500;line-height:1.5}
.feature-field input,.feature-field textarea,.feature-field select{display:block;width:100%;min-width:0;max-width:100%;padding:12px;border:1px solid var(--hope-control-border);border-radius:8px;background:var(--canvas);font-weight:400;min-height:44px}
.feature-field textarea{resize:vertical;line-height:1.6;min-height:120px}
.feature-field small{font-weight:400;color:var(--ink-soft);line-height:1.5}
.feature-checkbox{display:grid;grid-template-columns:20px minmax(0,1fr);gap:8px 10px;align-items:start;line-height:1.5}
.feature-checkbox>input{grid-column:1;grid-row:1}
.feature-checkbox>label{grid-column:2;grid-row:1}
.feature-checkbox>.feature-hint{grid-column:2}
.feature-checkbox input[type=checkbox]{width:20px!important;min-width:20px;min-height:20px;margin:2px 0;padding:0;flex-shrink:0;accent-color:var(--accent)}
.feature-editor{min-width:0;border-top:1px solid var(--line);padding-top:20px;margin-top:16px}
.feature-editor:empty{display:none}
.feature-editor pre{white-space:pre-wrap;overflow-wrap:anywhere;max-width:100%;line-height:1.6}
.feature-empty{padding:20px 0;color:var(--ink-soft)}
.feature-row details{width:100%;padding:0!important;margin:0;border-bottom:0!important}
.feature-section-body>.feature-intro{margin:0 0 14px!important}
.feature-section-body .feature-toolbar{margin:8px 0 12px}
.feature-row-main p{margin:4px 0!important}
.feature-row-actions{margin:0!important}
.feature-row .feature-row-actions details{flex-basis:100%}
.feature-row .feature-row-actions summary{font-size:13px;font-weight:400}
#hopeFeatures>.feature-section{padding-block:12px;margin-block:12px}
#hopeFeatures>.feature-section:first-of-type{margin-block:4px;padding-block:8px;order:3}
#hopeFeatures{display:flex;flex-direction:column}
#hopeFeatures>.feature-section{order:1}
#hopeFeatures>.feature-empty{order:1}
.library-guide{margin-block:12px 16px}
.feature-section>summary{min-height:40px}
#privacyMemory .feature-section{margin-top:12px}
#privacyMemory .feature-section-body{padding-top:0}
.feature-toolbar .btn:first-child{background:var(--hope-selected-wash);color:var(--accent);font-weight:600}
#activityContent .feature-row+.feature-editor{scroll-margin-top:20px}
.feature-row summary{min-height:44px;align-content:center;cursor:pointer}
.library-window{width:min(820px,calc(100vw - 32px));max-width:820px}
#activityContent{min-width:0;flex-shrink:0}
.tasks-list{flex-shrink:0}
.settings-window>.dialog-head{position:sticky;top:0;background:var(--canvas);z-index:1}
.library-window>.dialog-head{position:sticky;top:-24px;background:var(--canvas);z-index:1;padding-top:8px}
.library-guide{line-height:1.65;max-width:68ch;margin-block:16px 24px;color:var(--ink-soft)}
.settings-intro{line-height:1.65;max-width:65ch;color:var(--ink-soft);margin-top:8px}
.feature-hint{font-size:13px;font-weight:400;color:var(--ink-soft);line-height:1.5;margin:0!important}
#settingsMemoryStatus:empty{display:none}
@media(max-width:600px){.settings-nav{display:flex!important;flex-wrap:nowrap!important;overflow-x:auto;gap:4px}.settings-nav .btn{width:auto!important;flex:0 0 auto;white-space:nowrap;margin:0}.settings-nav .admin-link{border-top:0;margin-top:0;padding-top:10px;min-height:44px}}
.data-guide,.security-action{padding-block:24px;border-top:1px solid var(--line);margin-top:24px}
.data-guide h4,.security-action h4{font-size:1rem;margin-bottom:10px}
.data-guide p,.security-action p{line-height:1.65;max-width:65ch;margin-bottom:16px}
.data-guide dl,.security-facts{margin:20px 0;display:flex;flex-direction:column;gap:20px}
.data-guide dt,.security-facts dt{font-weight:600;margin-bottom:6px}
.data-guide dd,.security-facts dd{margin:0;line-height:1.6;color:var(--ink-soft);overflow-wrap:anywhere}
.conversation-row{display:flex;align-items:center;gap:4px;min-width:0}
.conversation-row .conversation-link{min-width:0;flex:1}
.conversation-row-actions{border:0;background:transparent;border-radius:8px;min-width:44px;min-height:44px;color:var(--ink-soft);flex-shrink:0}
.conversation-row-actions:hover,.conversation-row-actions:focus-visible{background:var(--surface-raised);color:var(--ink)}
.conversation-row-menu{position:fixed;margin:0;min-width:180px;max-width:calc(100vw - 24px);padding:8px;border:1px solid var(--line);border-radius:10px;background:var(--surface);color:var(--ink)}
.conversation-row-menu .btn{display:block;width:100%;text-align:left;min-height:44px}
@media(max-width:600px){
.feature-row{flex-direction:column;gap:10px}
.feature-row-actions{width:100%}
.feature-row-actions .btn{max-width:100%;white-space:normal;overflow-wrap:anywhere}
.feature-form-actions .btn{flex:1 1 auto}
.library-window{width:calc(100vw - 20px);padding:18px}
}
#hopeFeatures summary{
cursor:pointer;
min-height:44px;