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

164 lines
22 KiB
JavaScript

// 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 filesSection = {value: null}, artifactSection = {value: null}, taskSection = {value: null}, memorySection = {value: null};
async function loadFiles() {
const data = await request('/api/attachments'), body = filesSection.value.body; body.replaceChildren(button('Refresh files', loadFiles));
if (!data.attachments?.length) body.append(node('p', 'Uploaded files stay in your private account until you delete them.'));
for (const file of data.attachments || []) {const row = node('div'); row.append(node('span', `${file.filename} · ${Math.ceil(file.size / 1024)} KB · ${file.extraction_status}`), download('/api/attachments/' + enc(file.id) + '/content', 'Download ' + file.filename)); confirmAction(row, 'Delete ' + file.filename, "Deleting removes file bytes; old chat attachment references won't work.", async () => {await request('/api/attachments/' + enc(file.id), 'DELETE'); state.attachments = state.attachments.filter(item => {if (item.attachment?.id === file.id) {dispose(item); return false;} return true;}); changed(); await loadFiles(); message('File deleted.');}); body.append(row);}
}
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;
filesSection.value.detail.hidden = state.capabilities.attachments !== true; artifactSection.value.detail.hidden = state.capabilities.artifacts !== true; taskSection.value.detail.hidden = state.capabilities.scheduled_tasks !== true; memorySection.value.detail.hidden = state.capabilities.memory_controls !== true;
panel.hidden = !state.user;
const availability = $('featureAvailability'); availability.textContent = [state.capabilities.scheduled_tasks !== true ? 'Scheduled tasks are unavailable.' : '', state.capabilities.memory_controls !== true ? 'Memory controls are unavailable.' : '', state.capabilities.image_input !== true ? 'Image input is unavailable for this model.' : ''].filter(Boolean).join(' ');
}
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'); ($('libraryContent') || $('chatSidebar') || document.body).append(panel);
status = node('p'); status.id = 'featureStatus'; status.setAttribute('role', 'status'); const availability = node('p'); availability.id = 'featureAvailability'; const availabilityDetails = node('details'); availabilityDetails.append(node('summary', 'Feature availability'), availability); panel.append(status, availabilityDetails);
filesSection.value = section('Files'); artifactSection.value = section('Artifacts'); artifactEditor = node('div'); artifactSection.value.detail.append(artifactEditor);
taskSection.value = section('Scheduled tasks'); taskEditor = node('div'); taskSection.value.detail.append(taskEditor);
memorySection.value = section('Memory'); memoryEditor = node('div'); memorySection.value.detail.append(memoryEditor);
if ($('activityContent')) $('activityContent').append(taskSection.value.detail);
for (const [area, load] of [[filesSection.value, loadFiles], [artifactSection.value, loadArtifacts], [taskSection.value, loadTasks], [memorySection.value, loadMemory]]) area.detail.addEventListener('toggle', () => {if (area.detail.open && state.user) load().catch(error => {if (error.name !== 'AbortError') message(error.message);});});
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 [filesSection.value, artifactSection.value, taskSection.value, memorySection.value]) {area.detail.open = false; area.body.replaceChildren(); area.detail.hidden = true;} artifactEditor.replaceChildren(); taskEditor.replaceChildren(); memoryEditor.replaceChildren(); message('');}
state.user = user; toolbar.hidden = panel.hidden = !user; if (user && (accountChanged || !Object.keys(state.capabilities).length)) refresh().catch(error => {if (error.name !== 'AbortError') message(error.message);});
});
}
function mountSection(name, target) {
const area = name === 'memory' ? memorySection.value : name === 'tasks' ? taskSection.value : name === 'files' ? filesSection.value : name === 'artifacts' ? artifactSection.value : null;
if (area && target) target.append(area.detail);
return area?.detail || null;
}
function openSection(name) {
const area = name === 'memory' ? memorySection.value : name === 'tasks' ? taskSection.value : name === 'files' ? filesSection.value : name === 'artifacts' ? artifactSection.value : null;
if (area) area.detail.open = true;
}
window.HopeFeatures = {initialize, attachmentIDs, hasPendingUploads, clearAttachmentsAfterSend, clearAfterSend: clearAttachmentsAfterSend, addFiles, refresh,
mountMemory: target => mountSection('memory', target), mountTasks: target => mountSection('tasks', target), openSection};
initialize();
})();