diff --git a/tests/unit/test_chat_features_web.py b/tests/unit/test_chat_features_web.py
new file mode 100644
index 0000000..f9fbc5a
--- /dev/null
+++ b/tests/unit/test_chat_features_web.py
@@ -0,0 +1,187 @@
+"""Offline Chromium qualification for advanced chat controls; no service calls."""
+from pathlib import Path
+import unittest
+
+ROOT = Path(__file__).resolve().parents[2]
+
+MOCK = r'''() => {
+ window.calls = []; window.listeners = []; window.pendingUpload = false; window.conflict = false;
+ window.capabilities = {attachments:true, artifacts:true, scheduled_tasks:true, memory_controls:true, image_input:true,
+ attachment_mimes:['text/plain','text/markdown','application/pdf','image/png'], upload_bytes:8388608};
+ window.artifact = {id:'a'.repeat(32),title:'Draft
',kind:'markdown',revision:2,content:''};
+ window.task = {id:'b'.repeat(12),name:'Daily note',schedule:'0 9 * * *',prompt:'Review notes',enabled:true};
+ window.HopeAuth = {user:{id:'owner-a',status:'ready'},onChange(fn){listeners.push(fn);fn(this.user,true);},
+ async request(path,options={}) {
+ calls.push({path,method:options.method||'GET',body:typeof options.body==='string'?JSON.parse(options.body):null,
+ binary:options.body instanceof File,headers:options.headers});
+ if (path==='/api/capabilities') return {features:capabilities};
+ if (path==='/api/attachments') {
+ if(pendingUpload) await new Promise((resolve,reject)=>options.signal.addEventListener('abort',()=>reject(new DOMException('Stopped','AbortError'))));
+ return {attachment:{id:'c'.repeat(32),filename:options.body.name,extraction_status:'ready'}};
+ }
+ if(path==='/api/artifacts') return options.method==='POST'?{artifact:{...artifact,...JSON.parse(options.body),revision:1}}:{artifacts:[artifact]};
+ if(path.startsWith('/api/artifacts/')) {
+ if(options.method==='PATCH') {if(conflict) throw new Error('Artifact revision conflict');artifact={...artifact,...JSON.parse(options.body),revision:artifact.revision+1};}
+ return {artifact};
+ }
+ if(path==='/api/tasks') return options.method==='POST'?{task}:{tasks:[task]};
+ if(path.startsWith('/api/tasks/')) return {task};
+ if(path==='/api/memory') return {sections:[{section:'memory',content:'Saved facts',revision:'m1'},{section:'user',content:'Profile',revision:'u1'}]};
+ if(path.startsWith('/api/memory/')) {if(conflict) throw new Error('Memory revision conflict');return {...JSON.parse(options.body),revision:'m2'};}
+ return {ok:true};
+ }};
+ window.changeAccount = user => {HopeAuth.user=user;listeners.forEach(fn=>fn(user,true));};
+ window.createdURLs=[];window.revokedURLs=[];
+ URL.createObjectURL=()=>{const url='blob:preview-'+createdURLs.length;createdURLs.push(url);return url;};
+ URL.revokeObjectURL=url=>revokedURLs.push(url);
+}'''
+
+
+class ChatFeaturesWebTests(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ try:
+ from playwright.sync_api import sync_playwright
+ except ImportError:
+ raise unittest.SkipTest('Playwright required for offline browser qualification')
+ cls.playwright = sync_playwright().start()
+ cls.browser = cls.playwright.chromium.launch(headless=True)
+
+ @classmethod
+ def tearDownClass(cls):
+ cls.browser.close()
+ cls.playwright.stop()
+
+ def setUp(self):
+ self.page = self.browser.new_page()
+ self.page.set_content('
')
+ self.page.evaluate(MOCK)
+ self.page.add_script_tag(path=str(ROOT / 'web/features.js'))
+ self.page.wait_for_function('document.querySelector("[data-attach]").disabled === false')
+
+ def tearDown(self):
+ self.page.close()
+
+ def open_panel(self, name):
+ self.page.get_by_text(name, exact=True).click()
+
+ def test_upload_raw_bytes_and_ack_only_clear(self):
+ self.page.locator('input[type=file]').set_input_files({'name':'notes.txt','mimeType':'text/plain','buffer':b'Hello'})
+ self.page.wait_for_function('HopeFeatures.attachmentIDs().length===1')
+ self.assertFalse(self.page.evaluate('HopeFeatures.hasPendingUploads()'))
+ call = self.page.evaluate('calls.find(c=>c.path==="/api/attachments")')
+ self.assertTrue(call['binary'])
+ self.assertEqual(call['headers'], {'Content-Type':'text/plain','X-Filename':'notes.txt'})
+ self.page.evaluate('window.dispatchEvent(new CustomEvent("hope:conversation-selected",{detail:{conversation_id:"first"}}))')
+ self.assertEqual(len(self.page.evaluate('HopeFeatures.attachmentIDs()')), 1)
+ self.page.evaluate('HopeFeatures.clearAttachmentsAfterSend([])')
+ self.assertEqual(len(self.page.evaluate('HopeFeatures.attachmentIDs()')), 1)
+ self.page.evaluate('HopeFeatures.clearAttachmentsAfterSend()')
+ self.assertEqual(self.page.evaluate('HopeFeatures.attachmentIDs()'), [])
+
+ def test_cancel_retry_and_conversation_change(self):
+ self.page.evaluate('pendingUpload=true')
+ self.page.locator('input[type=file]').set_input_files({'name':'notes.txt','mimeType':'text/plain','buffer':b'Hello'})
+ self.page.get_by_role('button',name='Cancel upload').click()
+ self.page.get_by_role('button',name='Retry upload').wait_for()
+ self.assertTrue(self.page.evaluate('HopeFeatures.hasPendingUploads()'))
+ self.page.evaluate('pendingUpload=false')
+ self.page.get_by_role('button',name='Retry upload').click()
+ self.page.wait_for_function('HopeFeatures.attachmentIDs().length===1')
+ self.page.evaluate('window.dispatchEvent(new CustomEvent("hope:conversation-selected",{detail:{conversation_id:"one"}}));window.dispatchEvent(new CustomEvent("hope:conversation-selected",{detail:{conversation_id:"two"}}))')
+ self.assertEqual(self.page.evaluate('HopeFeatures.attachmentIDs()'), [])
+
+ def test_capabilities_validation_and_logout_cleanup(self):
+ self.page.evaluate('capabilities.image_input=false;HopeFeatures.refresh()')
+ self.page.wait_for_function('document.getElementById("featureAvailability").textContent.includes("Image input")')
+ self.page.evaluate('HopeFeatures.addFiles([new File(["x"],"image.png",{type:"image/png"})])')
+ self.assertEqual(self.page.evaluate('calls.filter(c=>c.path==="/api/attachments").length'), 0)
+ self.page.evaluate('capabilities.image_input=true;HopeFeatures.refresh()')
+ self.page.wait_for_function('document.querySelector("input[type=file]").accept.includes("image/png")')
+ self.page.locator('input[type=file]').set_input_files({'name':'image.png','mimeType':'image/png','buffer':b'fake image for mocked endpoint'})
+ self.page.wait_for_function('HopeFeatures.attachmentIDs().length===1')
+ self.page.evaluate('changeAccount(null)')
+ self.assertEqual(self.page.evaluate('HopeFeatures.attachmentIDs()'), [])
+ self.assertEqual(self.page.evaluate('createdURLs'), self.page.evaluate('revokedURLs'))
+ self.assertTrue(self.page.locator('#hopeFeatures').is_hidden())
+
+ def test_artifact_safe_preview_versions_conflict_and_export(self):
+ self.open_panel('Artifacts')
+ self.page.get_by_role('button',name='Edit Draft',exact=False).click()
+ self.page.get_by_label('Content',exact=True).fill('
changed')
+ self.assertEqual(self.page.locator('pre').inner_text(), '
changed')
+ self.assertEqual(self.page.locator('#hopeFeatures script, #hopeFeatures img').count(), 0)
+ self.page.evaluate('conflict=true')
+ self.page.get_by_role('button',name='Save new version').click()
+ self.page.wait_for_function('document.getElementById("featureStatus").textContent.includes("retained")')
+ self.assertIn('changed', self.page.get_by_label('Content',exact=True).input_value())
+ self.page.evaluate('conflict=false')
+ self.page.get_by_role('button',name='Save new version').click()
+ self.page.wait_for_function('document.getElementById("featureStatus").textContent.includes("version 3")')
+ self.page.get_by_label('Saved version').select_option('1')
+ self.page.get_by_role('button',name='View saved version').click()
+ self.page.wait_for_function('calls.some(c=>c.path.endsWith("?revision=1"))')
+ self.assertIn('/export', self.page.get_by_role('link',name='Export latest saved version').get_attribute('href'))
+ self.assertTrue(self.page.get_by_role('link',name='Export selected saved version').get_attribute('href').endswith('/export?revision=1'))
+ patch = self.page.evaluate('calls.find(c=>c.method==="PATCH")')
+ self.assertEqual(patch['body']['revision'], 2)
+
+ def test_task_crud_action_shapes(self):
+ self.open_panel('Scheduled tasks')
+ self.page.get_by_role('button',name='Pause',exact=True).click()
+ self.page.wait_for_function('calls.some(c=>c.path.endsWith("/pause"))')
+ self.page.get_by_role('button',name='Run now').click()
+ self.page.wait_for_function('calls.some(c=>c.path.endsWith("/run"))')
+ self.page.get_by_role('button',name='New scheduled task').click()
+ self.page.get_by_label('Task name').fill('Reminder')
+ self.page.get_by_label('Schedule',exact=True).fill('0 9 * * *')
+ self.page.get_by_label('Prompt',exact=True).fill('Summarize notes')
+ self.page.get_by_role('button',name='Create task',exact=True).click()
+ self.page.wait_for_function('calls.some(c=>c.path==="/api/tasks"&&c.method==="POST")')
+ data = self.page.evaluate('calls.find(c=>c.path==="/api/tasks"&&c.method==="POST").body')
+ self.assertEqual(data, {'name':'Reminder','schedule':'0 9 * * *','prompt':'Summarize notes','paused':True})
+
+ def test_drag_drop_validation_and_disabled_feature_panels(self):
+ self.page.evaluate('''() => {const transfer=new DataTransfer();transfer.items.add(new File(['notes'],'drop.txt',{type:'text/plain'}));document.getElementById('primaryComposer').dispatchEvent(new DragEvent('drop',{dataTransfer:transfer,bubbles:true,cancelable:true}));}''')
+ self.page.wait_for_function('HopeFeatures.attachmentIDs().length===1')
+ self.page.evaluate('HopeFeatures.addFiles([new File(["x"],"bad.svg",{type:"image/svg+xml"}),new File([],"empty.txt",{type:"text/plain"})])')
+ self.assertEqual(self.page.evaluate('calls.filter(c=>c.path==="/api/attachments").length'), 1)
+ self.page.evaluate('capabilities.scheduled_tasks=false;capabilities.memory_controls=false;HopeFeatures.refresh()')
+ self.page.wait_for_function('document.getElementById("featureAvailability").textContent.includes("Scheduled tasks")')
+ self.assertTrue(self.page.get_by_text('Scheduled tasks',exact=True).is_hidden())
+ self.assertTrue(self.page.get_by_text('Memory',exact=True).is_hidden())
+
+ def test_task_edit_resume_delete(self):
+ self.page.evaluate('task.enabled=false')
+ self.open_panel('Scheduled tasks')
+ self.page.get_by_role('button',name='Resume',exact=True).click()
+ self.page.wait_for_function('calls.some(c=>c.path.endsWith("/resume"))')
+ self.page.get_by_role('button',name='Edit task',exact=True).click()
+ self.page.get_by_label('Task name').fill('Renamed task')
+ self.page.get_by_role('button',name='Save task',exact=True).click()
+ self.page.wait_for_function('calls.some(c=>c.path.startsWith("/api/tasks/")&&c.method==="PATCH")')
+ self.assertEqual(self.page.evaluate('calls.find(c=>c.method==="PATCH").body.name'), 'Renamed task')
+ self.page.get_by_text('Delete task',exact=True).click()
+ self.page.get_by_role('button',name='Confirm delete task').click()
+ self.page.wait_for_function('calls.some(c=>c.path.startsWith("/api/tasks/")&&c.method==="DELETE")')
+
+ def test_memory_revision_conflict_retains_edits_and_explicit_clear(self):
+ self.open_panel('Memory')
+ content = self.page.get_by_label('Assistant memory',exact=True)
+ content.fill('Updated facts')
+ self.page.evaluate('conflict=true')
+ self.page.get_by_role('button',name='Save assistant memory').click()
+ self.page.wait_for_function('document.getElementById("featureStatus").textContent.includes("retained")')
+ self.assertEqual(content.input_value(), 'Updated facts')
+ self.page.evaluate('conflict=false')
+ self.page.get_by_role('button',name='Save assistant memory').click()
+ self.page.wait_for_function('document.getElementById("featureStatus").textContent.includes("saved")')
+ self.page.get_by_text('Clear assistant memory',exact=True).click()
+ self.page.get_by_role('button',name='Confirm clear assistant memory').click()
+ self.page.wait_for_function('document.getElementById("featureStatus").textContent.includes("cleared")')
+ last = self.page.evaluate('calls.filter(c=>c.path==="/api/memory/memory").at(-1).body')
+ self.assertEqual(last, {'content':'','revision':'m2'})
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/web/features.js b/web/features.js
new file mode 100644
index 0000000..79078a8
--- /dev/null
+++ b/web/features.js
@@ -0,0 +1,147 @@
+// 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 enc = encodeURIComponent, $ = id => document.getElementById(id);
+ let options = {}, toolbar, previews, panel, status, 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;};
+ 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);
+ try {
+ const result = await (options.request || HopeAuth.request)(path, {method, ...(body === undefined ? {} : {body: JSON.stringify(body)}), ...extra, signal: extra.signal || controller.signal});
+ if (epoch !== state.epoch) throw new DOMException('Account changed', 'AbortError');
+ return result;
+ } finally {state.controllers.delete(controller);}
+ }
+ 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 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();}
+ function mime(file) {return file.type || ({txt: 'text/plain', md: 'text/markdown', pdf: 'application/pdf', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp'}[file.name.split('.').pop().toLowerCase()] || '');}
+ async function upload(item) {
+ const epoch = state.epoch; item.status = 'uploading'; item.error = ''; item.controller = new AbortController(); changed();
+ try {
+ const data = await request('/api/attachments', 'POST', undefined, {body: item.file, headers: {'Content-Type': mime(item.file), 'X-Filename': item.file.name}, signal: item.controller.signal});
+ if (epoch !== state.epoch || !state.attachments.includes(item)) return;
+ item.attachment = data.attachment;
+ const extraction = data.attachment.extraction_status;
+ if (['failed', 'unavailable', 'encrypted'].includes(extraction)) {item.status = 'blocked'; item.error = 'Text could not be extracted. Remove this file or use a UTF-8 text file.';}
+ else item.status = 'ready';
+ } catch (error) {if (epoch !== state.epoch || !state.attachments.includes(item)) return; item.status = 'failed'; item.error = error.name === 'AbortError' ? 'Upload cancelled. Retry or remove the file.' : error.message;}
+ 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;}
+ 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;}
+ 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);
+ }
+ changed();
+ }
+ function renderAttachments() {
+ if (!previews) return; previews.replaceChildren();
+ for (const item of state.attachments) {
+ const row = node('div', undefined, 'attachment-preview');
+ 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));
+ 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)));
+ row.append(button('Remove ' + item.file.name, () => {dispose(item); state.attachments = state.attachments.filter(candidate => candidate !== item); changed();}));
+ 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;}
+ const artifactSection = {value: null}, taskSection = {value: null}, memorySection = {value: null};
+ 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);}
+ }
+ function editArtifact(artifact) {
+ artifactEditor.replaceChildren(); const form = node('form'), heading = node('h3', artifact ? 'Edit artifact' : 'New artifact');
+ 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()));
+ 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);
+ }
+ formSubmit(form, async () => {const data = {title: title.value.trim(), kind: kind.value, content: content.value}; if (new TextEncoder().encode(data.content).length > (state.capabilities.artifact_bytes || 512 * 1024)) throw new Error('Artifact exceeds the content limit.'); const saved = await request('/api/artifacts' + (artifact ? '/' + enc(artifact.id) : ''), artifact ? 'PATCH' : 'POST', {...data, ...(artifact ? {revision: artifact.revision} : {})}); message('Artifact saved as version ' + saved.artifact.revision + '.'); await loadArtifacts(); editArtifact(saved.artifact);});
+ 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);}
+ }
+ 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 || '');
+ 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;
+ 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.'));
+ 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;
+ 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.');});
+ 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;
+ 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;
+ 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(' ');
+ }
+ function initialize(config = {}) {
+ options = {...options, ...config}; if (state.initialized) return; state.initialized = true;
+ toolbar = options.toolbar || $('featureActions'); previews = options.previews || $('attachmentPreviews');
+ 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');
+ 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'); ($('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);
+ 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);
+ for (const [area, load] of [[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);});});
+ 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;});
+ 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 [artifactSection.value, taskSection.value, memorySection.value]) {area.detail.open = false; area.body.replaceChildren(); area.detail.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);});
+ });
+ }
+ window.HopeFeatures = {initialize, attachmentIDs, hasPendingUploads, clearAttachmentsAfterSend, clearAfterSend: clearAttachmentsAfterSend, addFiles, refresh};
+ initialize();
+})();