feat: add advanced chat attachment and saved work controls
This commit is contained in:
1 parent
2261208a1b
commit
bba522bc26
2 files changed
+334
No files matched your search
@@ -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 <img src=x onerror=alert(1)>',kind:'markdown',revision:2,content:'<script>window.injected=true</script>'};
|
||||||
|
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('<html><body><aside id="chatSidebar"></aside><form id="primaryComposer"><div id="featureActions"></div><div id="attachmentPreviews"></div></form></body></html>')
|
||||||
|
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('<img src=x onerror=alert(1)> changed')
|
||||||
|
self.assertEqual(self.page.locator('pre').inner_text(), '<img src=x onerror=alert(1)> 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()
|
||||||
+147
@@ -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();
|
||||||
|
})();
|
||||||
Reference in new issue
Block a user