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(); +})();