openvibers/openvibe.codes Commits
Use shared upload kit for Codes attachments and imports (#420)
Changes
@@@ -181,44 +181,63 @@model.addEventListener('change', toggle);}});-- // The conductor (Codes C9b): a message with images goes through the JSON API (the form alone sends the words), and-- // the live turn's lines fill the transcript. When the turn ends the page reloads once, so the turn's answer and its-- // state are the server's own words.-- document.querySelectorAll('[data-c4-conductor-form]').forEach((node) => {-- const form = node.closest('form');-- const input = form.querySelector('[data-c4-images]');++ // The conductor shares the upload picker; files use the existing multipart message route.++ document.querySelectorAll('[data-c4-conductor-form]').forEach((form) => {const status = form.querySelector('[data-c4-message-status]');-- if (!form || !input) return;-- const say = (text, bad) => { if (status) { status.textContent = text; status.classList.toggle('is-bad', !!bad); } };-- const asData = (file) => new Promise((resolve) => {-- const reader = new FileReader();-- reader.onload = () => resolve({ name: file.name, mime: file.type, data: String(reader.result || '') });-- reader.onerror = () => resolve(null);-- reader.readAsDataURL(file);++ const root = form.querySelector('[data-c4-upload]');++ const meterRoot = form.querySelector('[data-c4-upload-progress]');++ if (!root || !window.OVUpload) return;++ const files = [];++ const meter = OVUpload.progress(meterRoot, { onRetry: () => form.requestSubmit() });++ const say = (message, bad) => { if (status) { status.textContent = message; status.classList.toggle('is-bad', !!bad); } };++ const blocked = /\.(exe|dll|com|bat|cmd|msi|scr|ps1|sh|so|dylib|app|apk|deb|rpm|jar|bin|elf|vbs|wsf|hta)$/i;++ const picker = OVUpload.picker(root, {++ label: 'Attach files', multiple: true,++ onFile: (file, row) => {++ const total = files.reduce((n, item) => n + item.size, 0);++ const problem = blocked.test(file.name) ? 'Programs cannot be attached.'++ : !file.size ? 'Choose a file that is not empty.'++ : file.size > 20 * 1048576 ? `${file.name} is over 20 MB.`++ : files.length >= 10 ? 'Choose at most 10 files.'++ : total + file.size > 50 * 1048576 ? 'The files are over 50 MB together.' : '';++ if (problem) { row.dispatchEvent(new Event('remove')); row.remove(); say(problem, true); return; }++ files.push(file); say(`${files.length} file${files.length === 1 ? '' : 's'} selected`);++ },++ onRemove: (file) => { const i = files.indexOf(file); if (i >= 0) files.splice(i, 1); say(files.length ? `${files.length} files selected` : ''); }});form.addEventListener('submit', async (event) => {-- const files = Array.from(input.files || []).slice(0, 10);-- if (files.length) return; // the form itself posts the files as multipartevent.preventDefault();const button = form.querySelector('button[type="submit"]');if (button) button.disabled = true;say('Sending…');++ if (files.length) {++ const data = new FormData(form);++ files.forEach((file) => data.append('files', file, file.name));++ const xhr = new XMLHttpRequest();++ xhr.open('POST', form.action);++ xhr.withCredentials = true;++ const total = files.reduce((n, file) => n + file.size, 0);++ meter.update({ state: 'sending', done: 0, total });++ xhr.upload.onprogress = (e) => { if (e.lengthComputable) meter.update({ state: 'sending', done: Math.min(total, e.loaded), total }); };++ xhr.onload = () => {++ if (xhr.status >= 200 && xhr.status < 400) { meter.update({ state: 'done', done: total, total }); location.href = xhr.responseURL || form.action; return; }++ const message = 'The files could not be sent. Check their formats and try again.';++ meter.update({ state: 'failed', message }); say(message, true); if (button) button.disabled = false;++ };++ xhr.onerror = () => { const message = 'The connection stopped. Try again.'; meter.update({ state: 'failed', message }); say(message, true); if (button) button.disabled = false; };++ xhr.send(data);++ return;++ }try {-- const attachments = (await Promise.all(files.map(asData))).filter(Boolean);const text = (form.querySelector('textarea[name="text"]') || {}).value || '';-- const res = await fetch(node.dataset.conductorApi, {-- method: 'POST',-- headers: { 'Content-Type': 'application/json', Accept: 'application/json' },-- credentials: 'same-origin',-- body: JSON.stringify({ text, attachments }),++ const res = await fetch(form.dataset.conductorApi, {++ method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' },++ credentials: 'same-origin', body: JSON.stringify({ text, attachments: [] }),});const doc = await res.json().catch(() => ({}));if (!res.ok) throw new Error(doc.detail || `the message was refused (${res.status})`);window.location.reload();-- } catch (e) {-- say(String((e && e.message) || 'the message could not be sent'), true);-- if (button) button.disabled = false;-- }++ } catch (e) { say(String((e && e.message) || 'the message could not be sent'), true); if (button) button.disabled = false; }});});@@@ -175,106 +175,71 @@if ($('.ovt.is-start', app) && !matchMedia('(pointer: coarse)').matches) text.focus({ preventScroll: true });}if (form) {-- // ── The composer's files: upload before send, with a chip, a thumbnail, a progress bar and a × ──-- // The kit's attach menu renders one input per row (Files, Photos and videos, the camera): every one of-- // them feeds the same queue, and the no-JavaScript post sends them all as multipart.-- const pickers = Array.from(form.querySelectorAll('input[data-ovt-files], input[data-ch-files]'));-- const picker = pickers[0] || null;-- const chips = $('[data-ch-files-list]', form);++ // The shared picker owns drop, keyboard selection, rows and progress. Codes owns its upload transport.++ const uploadRoot = $('[data-ch-upload]', form);const BLOCKED = /\.(exe|dll|com|bat|cmd|msi|scr|ps1|sh|so|dylib|app|apk|deb|rpm|jar|bin|elf|vbs|wsf|hta)$/i;const MAX_FILES = 10, MAX_FILE = 20 * 1024 * 1024, MAX_TOTAL = 50 * 1024 * 1024;const entries = [];-- const sizeWords = (n) => (n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${Math.round(n / 1024)} KB` : `${(n / (1024 * 1024)).toFixed(n < 10 * 1024 * 1024 ? 1 : 0)} MB`);-- const attachIcon = picker && picker.closest('label') && picker.closest('label').querySelector('svg');-- const chipFor = (entry) => {-- const li = document.createElement('span');-- li.className = 'ch-chip ch-file';-- li.setAttribute('role', 'listitem');-- li.dataset.chFile = entry.file.name;-- if (/^image\//i.test(entry.file.type)) {-- const img = document.createElement('img');-- img.width = 24; img.height = 24; img.alt = ''; img.src = entry.thumb;-- li.appendChild(img);-- } else if (attachIcon) {-- li.appendChild(attachIcon.cloneNode(true));++ let picker = null;++ function addFile(file, row) {++ const total = entries.reduce((n, e) => n + e.file.size, 0);++ let problem = '';++ if (BLOCKED.test(file.name)) problem = `“${file.name}” is a program or script, and cannot be attached`;++ else if (!file.size) problem = `“${file.name}” is empty`;++ else if (file.size > MAX_FILE) problem = `“${file.name}” is larger than 20 MB`;++ else if (entries.length >= MAX_FILES) problem = 'A message carries at most 10 files';++ else if (total + file.size > MAX_TOTAL) problem = 'The files of one message are larger than 50 MB together';++ if (problem) { row.dispatchEvent(new Event('remove')); row.remove(); say(problem, true); return; }++ const meterRoot = document.createElement('div');++ row.append(meterRoot);++ const meter = OVUpload.progress(meterRoot, { onRetry: () => sendFile() });++ const entry = { file, row, meter, id: null, uploading: true, xhr: null };++ entries.push(entry);++ function sendFile() {++ entry.uploading = true;++ meter.update({ state: 'sending', done: 0, total: file.size });++ const xhr = entry.xhr = new XMLHttpRequest();++ xhr.open('POST', '/api/v1/attachments');++ xhr.withCredentials = true;++ xhr.upload.onprogress = (e) => { if (e.lengthComputable) meter.update({ state: 'sending', done: e.loaded, total: e.total }); };++ xhr.onload = () => {++ entry.uploading = false;++ let doc = {};++ try { doc = JSON.parse(xhr.responseText || '{}'); } catch { /* response is not JSON */ }++ if (xhr.status === 201 && doc.attachment && doc.attachment.id) {++ entry.id = doc.attachment.id;++ meter.update({ state: 'done', done: file.size, total: file.size });++ } else {++ const message = doc.detail || `“${file.name}” could not be attached`;++ meter.update({ state: 'failed', message }); say(message, true);++ }++ };++ xhr.onerror = () => { entry.uploading = false; const message = `“${file.name}” could not be attached`; meter.update({ state: 'failed', message }); say(message, true); };++ xhr.onabort = () => { entry.uploading = false; };++ const data = new FormData(); data.append('file', file, file.name); xhr.send(data);}-- const name = document.createElement('span'); name.textContent = entry.file.name; li.appendChild(name);-- const size = document.createElement('small'); size.textContent = sizeWords(entry.file.size); li.appendChild(size);-- const bar = document.createElement('progress'); bar.max = 100; bar.value = 0; bar.setAttribute('aria-label', `Uploading ${entry.file.name}`); li.appendChild(bar);-- const remove = document.createElement('button');-- remove.type = 'button'; remove.className = 'ch-icon-btn'; remove.title = `Remove ${entry.file.name}`;-- remove.setAttribute('aria-label', `Remove ${entry.file.name}`); remove.textContent = '×';-- remove.addEventListener('click', () => {-- const i = entries.indexOf(entry);-- if (i >= 0) entries.splice(i, 1);-- if (entry.xhr) entry.xhr.abort();-- if (entry.thumb) URL.revokeObjectURL(entry.thumb);-- li.remove();-- if (entry.id) fetch(`/api/v1/attachments/${encodeURIComponent(entry.id)}`, { method: 'DELETE', credentials: 'same-origin' }).catch(() => {});-- say(entries.length ? `${entries.length} file${entries.length === 1 ? '' : 's'} ready to send` : '');-- });-- entry.bar = bar; entry.li = li;-- li.appendChild(remove);-- if (chips) { chips.hidden = false; chips.appendChild(li); }-- return li;-- };-- const upload = (entry) => {-- entry.uploading = true;-- const xhr = entry.xhr = new XMLHttpRequest();-- xhr.open('POST', '/api/v1/attachments');-- xhr.withCredentials = true;-- xhr.upload.onprogress = (e) => { if (e.lengthComputable && entry.bar) entry.bar.value = Math.round((e.loaded / e.total) * 100); };-- xhr.onload = () => {-- entry.uploading = false;-- let doc = {};-- try { doc = JSON.parse(xhr.responseText || '{}'); } catch { doc = {}; }-- if (xhr.status === 201 && doc.attachment && doc.attachment.id) {-- entry.id = doc.attachment.id;-- if (entry.bar) { entry.bar.value = 100; entry.bar.hidden = true; }-- if (entry.li) entry.li.dataset.uploaded = '1';-- } else {-- if (entry.li) entry.li.classList.add('is-bad');-- say(doc.detail || `“${entry.file.name}” could not be attached`, true);++ sendFile();++ say(`${entries.length} file${entries.length === 1 ? '' : 's'} selected`);++ }++ if (uploadRoot && window.OVUpload) {++ picker = OVUpload.picker(uploadRoot, {++ label: 'Attach files', accept: uploadRoot.dataset.accept, multiple: true,++ onFile: addFile,++ onRemove: (file) => {++ const i = entries.findIndex((entry) => entry.file === file);++ if (i < 0) return;++ const [entry] = entries.splice(i, 1);++ if (entry.xhr) entry.xhr.abort();++ entry.meter.destroy();++ if (entry.id) fetch(`/api/v1/attachments/${encodeURIComponent(entry.id)}`, { method: 'DELETE', credentials: 'same-origin' }).catch(() => {});++ say(entries.length ? `${entries.length} file${entries.length === 1 ? '' : 's'} selected` : '');}-- };-- xhr.onerror = () => { entry.uploading = false; if (entry.li) entry.li.classList.add('is-bad'); say(`“${entry.file.name}” could not be attached`, true); };-- xhr.onabort = () => { entry.uploading = false; };-- const data = new FormData();-- data.append('file', entry.file, entry.file.name);-- xhr.send(data);-- };-- const addFiles = (list) => {-- const incoming = Array.from(list || []);-- if (!incoming.length) return;-- if (entries.length + incoming.length > MAX_FILES) { say(`A message carries at most ${MAX_FILES} files`, true); return; }-- let total = entries.reduce((n, e) => n + e.file.size, 0);-- for (const file of incoming) {-- if (BLOCKED.test(file.name)) { say(`“${file.name}” is a program or script, and cannot be attached`, true); continue; }-- if (!file.size) { say(`“${file.name}” is empty`, true); continue; }-- if (file.size > MAX_FILE) { say(`“${file.name}” is larger than 20 MB`, true); continue; }-- if (total + file.size > MAX_TOTAL) { say('The files of one message are larger than 50 MB together', true); break; }-- total += file.size;-- const entry = { file, id: null, uploading: false, thumb: /^image\//i.test(file.type) ? URL.createObjectURL(file) : null, xhr: null };-- entries.push(entry);-- chipFor(entry);-- upload(entry);-- }-- if (entries.length) say(`${entries.length} file${entries.length === 1 ? '' : 's'} ready to send`);-- };-- for (const one of pickers) {-- one.addEventListener('change', () => { addFiles(one.files); one.value = ''; });++ });++ if (text) text.addEventListener('paste', (e) => {++ const files = e.clipboardData && e.clipboardData.files;++ if (files && files.length) { e.preventDefault(); picker.add(files); }++ });}-- // Paste an image from the clipboard, or drop files on the composer.-- if (text) text.addEventListener('paste', (e) => {-- const files = e.clipboardData && e.clipboardData.files;-- if (files && files.length) { e.preventDefault(); addFiles(files); }-- });-- form.addEventListener('dragover', (e) => { if (e.dataTransfer && Array.from(e.dataTransfer.types || []).includes('Files')) { e.preventDefault(); form.classList.add('is-dragging'); } });-- form.addEventListener('dragleave', () => form.classList.remove('is-dragging'));-- form.addEventListener('drop', (e) => {-- if (!e.dataTransfer || !e.dataTransfer.files || !e.dataTransfer.files.length) return;-- e.preventDefault(); form.classList.remove('is-dragging'); addFiles(e.dataTransfer.files);-- });form.addEventListener('submit', async (e) => {const submitter = e.submitter;if (submitter && submitter.formAction && submitter.formAction !== form.action) return; // Stop: a plain post@@@ -314,7 +279,6 @@}// The files are up: a plain form post names their ids (and sends the words and the pickers as usual).form.enctype = 'application/x-www-form-urlencoded';-- for (const one of pickers) one.value = '';for (const id of ids) {const hidden = document.createElement('input');hidden.type = 'hidden'; hidden.name = 'attachment_ids'; hidden.value = id;@@@ -1,25 +1,65 @@'use strict';--/*-- * Memory M5: choosing a portable file fills the import textarea, so the page works with the browser's own file-- * picker and no inline script. It only copies the file's text; the import itself is a normal form post to Codes,-- * which forwards the document to the store.-- */++// The shared picker keeps portable JSON local; export zips go through the existing preview route.(function () {-- var input = document.getElementById('memory-import-file');-- var text = document.getElementById('memory-import-text');-- if (!input || !text) return;-- input.addEventListener('change', function () {-- var file = input.files && input.files[0];-- if (!file) return;-- if (file.size > 25 * 1024 * 1024) {-- text.value = '';-- input.setCustomValidity('That file is larger than 25 MB.');-- input.reportValidity();-- return;-- }-- input.setCustomValidity('');-- var reader = new FileReader();-- reader.onload = function () { text.value = String(reader.result || ''); };-- reader.readAsText(file);++ if (!window.OVUpload) return;++ const localRoot = document.getElementById('memory-import-picker');++ const text = document.getElementById('memory-import-text');++ const localError = document.getElementById('memory-import-error');++ const maxBytes = 25 * 1024 * 1024;++ if (localRoot && text) {++ const picker = OVUpload.picker(localRoot, {++ label: 'Choose a memory JSON file', accept: '.json,application/json', multiple: false,++ onFile: (file, row) => {++ picker.rows.querySelectorAll('.ov-upload__row').forEach((old) => { if (old !== row) { old.dispatchEvent(new Event('remove')); old.remove(); } });++ if (file.size > maxBytes || !(/\.json$/i.test(file.name) || file.type === 'application/json')) {++ row.dispatchEvent(new Event('remove')); row.remove();++ localError.textContent = file.size > maxBytes ? 'That file is larger than 25 MB.' : 'Choose a JSON file.';++ localError.hidden = false; return;++ }++ localError.hidden = true;++ const reader = new FileReader();++ reader.onload = () => { text.value = String(reader.result || ''); };++ reader.onerror = () => { localError.textContent = 'That file could not be read. Try another JSON file.'; localError.hidden = false; };++ reader.readAsText(file);++ },++ onRemove: () => { text.value = ''; }++ });++ }++ document.querySelectorAll('[data-memory-zip-form]').forEach((form) => {++ const root = form.querySelector('[data-memory-zip-picker]');++ const error = form.querySelector('[data-memory-zip-error]');++ const meter = OVUpload.progress(form.querySelector('[data-memory-zip-progress]'), { onRetry: () => form.requestSubmit() });++ let selected = null;++ const picker = OVUpload.picker(root, {++ label: 'Choose an export ZIP', accept: '.zip,application/zip,application/x-zip-compressed', multiple: false,++ onFile: (file, row) => {++ picker.rows.querySelectorAll('.ov-upload__row').forEach((old) => { if (old !== row) { old.dispatchEvent(new Event('remove')); old.remove(); } });++ if (!/\.zip$/i.test(file.name) || !file.size || file.size > maxBytes) {++ row.dispatchEvent(new Event('remove')); row.remove();++ error.textContent = !/\.zip$/i.test(file.name) ? 'Choose a ZIP export.' : file.size > maxBytes ? 'That export is larger than 25 MB.' : 'That export is empty.';++ error.hidden = false; return;++ }++ selected = file; error.hidden = true;++ meter.update({ state: 'waiting', done: 0, total: file.size });++ },++ onRemove: (file) => { if (selected === file) selected = null; meter.update({ state: 'waiting', done: 0, total: 0 }); }++ });++ form.addEventListener('submit', (event) => {++ event.preventDefault();++ if (!selected) { error.textContent = 'Choose a ZIP export to preview.'; error.hidden = false; return; }++ const button = form.querySelector('button[type="submit"]');++ button.disabled = true; error.hidden = true;++ const data = new FormData(form); data.append('file', selected, selected.name);++ const xhr = new XMLHttpRequest(); xhr.open('POST', form.action); xhr.withCredentials = true;++ meter.update({ state: 'sending', done: 0, total: selected.size });++ xhr.upload.onprogress = (e) => { if (e.lengthComputable) meter.update({ state: 'sending', done: Math.min(selected.size, e.loaded), total: selected.size }); };++ xhr.onload = () => {++ if (xhr.status >= 200 && xhr.status < 400) { meter.update({ state: 'done', done: selected.size, total: selected.size }); location.href = xhr.responseURL || form.action; return; }++ const message = 'That export could not be previewed. Try another ZIP file.';++ meter.update({ state: 'failed', message }); error.textContent = message; error.hidden = false; button.disabled = false;++ };++ xhr.onerror = () => { const message = 'The connection stopped. Try again.'; meter.update({ state: 'failed', message }); error.textContent = message; error.hidden = false; button.disabled = false; };++ xhr.send(data);++ });});}());@@@ -221,7 +221,8 @@ async function createApp(opts = {}) {scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com'],styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],-- imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.media'],++ imgSrc: ["'self'", 'data:', 'blob:', 'https://openvibe.network', 'https://openvibe.media'],++ mediaSrc: ["'self'", 'blob:'],// openvibe.events: release notifications (release-watch's EventSource, openvibe-shared 1.17).// The navbar's Messages count asks Chat (openvibe-shared 4.130.0).connectSrc: ["'self'", 'https://openvibe.chat', 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events'],@@@ -381,7 +381,7 @@ ${pr.problem ? `<p class="c4-hint">${esc(pr.problem.detail)}</p>` : `<dl class="const outcome = row.outcome === 'unfinished' || row.outcome === 'stopped'? `<div class="c4-outcome"><span class="c4-obadge" aria-hidden="true">${icon('<path d="m18 6-12 12M6 6l12 12"/>')}</span><div class="c4-outcome-copy"><b>${row.outcome === 'stopped' ? 'Stopped' : 'The last turn did not finish'}</b><span>${esc(row.outcome_note || row.error || 'Write a message to carry on.')}</span></div></div>` : '';const transcript = `<section class="c4-card c4-transcript" data-c4-conductor-stream="${esc(`${api}/events?last_event_id=${lastSeq}`)}" data-task="${esc(row.id)}" data-started-at="${esc(row.started_at || row.created_at || '')}" data-c4-conductor="${live ? 'live' : 'idle'}"><div class="c4-card-head"><h2>${live ? 'This turn, live' : 'The last turn'}</h2><span class="c4-muted" data-live-label>${live ? 'Connecting…' : 'Nothing is running'}</span></div><pre class="c4-log" data-task-log aria-live="polite" tabindex="0">${esc(lastLines)}</pre></section>`;-- const composer = row.outcome === 'stopped' ? '' : `<section class="c4-card c4-conductor-composer"><form method="post" action="/tasks/${encodeURIComponent(row.id)}/messages" enctype="multipart/form-data" data-c4-conductor-form data-conductor-api="/api/v1/conductor/${encodeURIComponent(row.id)}/messages"><label for="c4-message">${live ? 'Say something: it is the next turn' : 'What next?'}</label><textarea id="c4-message" name="text" rows="3" placeholder="Tell your conductor what to do next, or ask what it is doing"></textarea><div class="c4-conductor-actions"><label class="c4-file">Attach files<input type="file" name="files" multiple data-c4-images></label><button class="c4-button" type="submit">Send</button></div><p class="c4-hint" data-c4-message-status role="status">${live ? 'A message waits until the current turn ends; it is not lost.' : ''}</p></form></section>`;++ const composer = row.outcome === 'stopped' ? '' : `<section class="c4-card c4-conductor-composer"><form method="post" action="/tasks/${encodeURIComponent(row.id)}/messages" enctype="multipart/form-data" data-c4-conductor-form data-conductor-api="/api/v1/conductor/${encodeURIComponent(row.id)}/messages"><label for="c4-message">${live ? 'Say something: it is the next turn' : 'What next?'}</label><textarea id="c4-message" name="text" rows="3" placeholder="Tell your conductor what to do next, or ask what it is doing"></textarea><div class="c4-conductor-actions"><div data-c4-upload></div><noscript><label class="c4-file">Attach files<input type="file" name="files" multiple></label></noscript><button class="c4-button" type="submit">Send</button></div><p class="c4-hint">Up to 10 files, 20 MB each, 50 MB together. Programs cannot be attached.</p><div data-c4-upload-progress></div><p class="c4-hint" data-c4-message-status role="status">${live ? 'A message waits until the current turn ends; it is not lost.' : ''}</p></form></section>`;const content = `${outcome}<section class="c4-card c4-conductor-head"><div class="c4-task-heading"><div class="c4-task-title-wrap"><h1>Conductor</h1></div><span class="c4-status c4-${esc(row.state)}">${esc(conductorState(row))}</span></div><div class="c4-chips" aria-label="Conductor details"><span class="c4-chip">${esc(row.folder || 'a folder')}</span><span class="c4-chip">${esc(row.agent === 'claude-code' ? modelLabel(row.model) : (row.model || 'its default model'))}</span><span class="c4-chip">safety: ${esc(row.safety || 'ask')}</span><span class="c4-chip">${esc(machineLabel || 'your machine')}</span></div><p class="c4-hint">One long session on your own machine, on the agent you chose and your own login. Each message is the next turn of the same conversation; it hands the work to your other agents through the Codes tools.</p></section>${approvals}${usageCard}<section class="c4-card c4-turns"><div class="c4-card-head"><h2>Turns</h2><span class="c4-muted">${(view.messages || []).length} so far</span></div>${turns ? `<ol class="c4-turn-list">${turns}</ol>` : '<div class="c4-emptyline">Its first turn is starting.</div>'}</section>${transcript}${composer}`;page(req, res, 'Conductor', wrap(content), { sharedScripts: ['agent-session.js'] });}@@@ -440,9 +440,9 @@ function createChatRoutes(ctx) {function composer({ action, api = '', placeholder, pickers = '', options = '', examples = [], live = false, stopAction = '', note = '', id = null, where = '', files = false, value = '' }) {return thread.composer({action, id, placeholder, label: placeholder, tools: pickers,-- attach: files ? { menu: true, accept: ATTACH_ACCEPT, library: MEDIA_LIBRARY, name: 'files' } : null,++ attach: null,options: options ? { html: options, label: 'Options' } : null, examples, value,-- foot: `${files ? '<div class="ch-files" data-ch-files-list role="list" aria-live="polite"></div>' : ''}${where}`,++ foot: `${files ? `<div data-ch-upload data-accept="${esc(ATTACH_ACCEPT)}"></div>${MEDIA_LIBRARY ? `<a href="${esc(MEDIA_LIBRARY)}">From your OpenVibe files</a>` : ''}<noscript><label>Attach files<input type="file" name="files" accept="${esc(ATTACH_ACCEPT)}" multiple></label></noscript>` : ''}${where}`,note: files ? (note ? `${note} ${ATTACH_NOTE}` : ATTACH_NOTE) : note,attrs: { ...(api ? { 'data-ch-api': api } : {}), ...(files ? { enctype: 'multipart/form-data' } : {}) },busy: live, stopAction: live && stopAction ? stopAction : null, maxlength: 16000,@@@ -462,7 +462,7 @@ function createChatRoutes(ctx) {cls: page != null ? 'is-page' : '',});const body = `<div class="ch" data-chat${streamUrl ? ` data-ch-stream="${esc(streamUrl)}"` : ''}>${view}</div>`;-- return send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, title: `${title} · Codes`, css: [...css, 'css/chat.css'], styles: ['kit.css', 'thread.css', ...styles], sharedScripts: ['kit-web.js', 'ov-thread.js', ...sharedScripts], scripts: [...scripts, 'js/md.js', 'js/chat.js'], body: raw(body) });++ return send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, title: `${title} · Codes`, css: [...css, 'css/chat.css'], styles: ['kit.css', 'thread.css', 'ov-upload.css', ...styles], sharedScripts: ['kit-web.js', 'ov-thread.js', 'ov-upload.js', ...sharedScripts], scripts: [...scripts, 'js/md.js', 'js/chat.js'], body: raw(body) });}/** The panel: who worked on it, and where the work is. */@@@ -81,7 +81,7 @@ const MEMORY_TABS = [];/** The share sheet and the gate are drawn by openvibe-shared (openvibe-shared 4.123.0); no inline script. */--const SHARE_ASSETS = { styles: ['ov-share.css'], sharedScripts: ['ov-share.js'], scripts: ['js/memory-import.js'] };++const SHARE_ASSETS = { styles: ['ov-share.css', 'ov-upload.css'], sharedScripts: ['ov-share.js', 'ov-upload.js'], scripts: ['js/memory-import.js'] };const SERVICES = ['mem0', 'letta', 'zep'];/** What the store's refusals say to a person. The words are ours: no note text ever reaches a flash. */@@@ -241,9 +241,10 @@ function createMemoryRoutes(ctx, kit) {+ '<p class="c4-hint">' + esc(p.what) + '</p>'+ '<ol class="c4-steps">' + (p.steps || []).map((step) => '<li>' + esc(step) + '</li>').join('') + '</ol>'+ '<p class="c4-hint"><strong>What arrives:</strong> ' + esc(p.arrives) + '<br><strong>How long:</strong> ' + esc(p.time) + '</p>'-- + '<form class="c4-form" method="post" action="/memory/import/preview" enctype="multipart/form-data">'++ + '<form class="c4-form" method="post" action="/memory/import/preview" enctype="multipart/form-data" data-memory-zip-form>'+ '<input type="hidden" name="provider" value="' + esc(p.id) + '">'-- + '<label class="c4-file">Your export .zip <input type="file" name="file" accept=".zip,application/zip,application/x-zip-compressed" required></label>'++ + '<div data-memory-zip-picker></div><noscript><label class="c4-file">Your export .zip <input type="file" name="file" accept=".zip,application/zip,application/x-zip-compressed" required></label></noscript>'++ + '<p class="c4-hint">Choose a .zip export, up to 25 MB.</p><div data-memory-zip-progress></div><p role="alert" data-memory-zip-error hidden></p>'+ '<button class="c4-button" type="submit">Preview ' + esc(p.name) + '</button>'+ '</form></article>';const paste = '<section class="c4-card"><div class="c4-card-head"><h2>Any assistant: ask what it remembers</h2>'@@@ -575,7 +576,7 @@ function createMemoryRoutes(ctx, kit) {+ '<section class="c4-card"><div class="c4-card-head"><h2>Import a portable file</h2></div>'+ '<p class="c4-sub">Choose the file or paste its JSON. Notes that say the same thing as one you already have are merged, not copied.</p>'+ '<form method="post" action="/memory/import"><input type="hidden" name="tab" value="export">'-- + '<label class="c4-field"><span>File</span><input type="file" id="memory-import-file" accept="application/json,.json"></label>'++ + '<div id="memory-import-picker"></div><p role="alert" id="memory-import-error" hidden></p>'+ '<label class="c4-field"><span>Or paste the JSON</span><textarea id="memory-import-text" name="document" rows="8" spellcheck="false"></textarea></label>'+ '<button class="c4-button" type="submit">Import</button></form>'+ '<p class="c4-hint">A file over 25 MB is refused. Nothing is kept until you press Import.</p></section>';@@@ -164,18 +164,18 @@ function multipart(fields, files) {assert.strictEqual((await t.get(`/api/v1/attachments/${rows[0].id}`, { as: ada, method: 'DELETE' })).status, 409);});-- await check('the composers offer files without JavaScript too: a multipart form and the kit\u2019s attach menu', async () => {++ await check('the composers use the shared picker and keep a multipart fallback', async () => {const home = await t.get('/', { as: ada });assert.strictEqual(home.status, 200);assert.match(home.text, /<form class="ovt-composer" id="ch-start"[^>]*enctype="multipart\/form-data"/, 'the start form posts multipart');-- assert.match(home.text, /data-ovt-attach/, 'the kit\u2019s attach menu');-- assert.match(home.text, /<input type="file" name="files"[^>]*multiple data-ovt-files/, 'the Files row takes files');++ assert.match(home.text, /data-ch-upload/, 'the shared picker mount');++ assert.match(home.text, /ov-upload\.js/, 'the shared picker script');++ assert.match(home.text, /<noscript><label>Attach files<input type="file" name="files"[^>]*multiple/, 'a native fallback takes files');assert.match(home.text, /accept="[^"]*image\/\*/, 'images, text, code, PDF and zip');assert.match(home.text, /From your OpenVibe files/, 'the library row');-- assert.match(home.text, /data-ch-files-list/, 'with the chips list the script fills');const task = await t.get(`/tasks/${taskId}`, { as: ada });assert.match(task.text, /enctype="multipart\/form-data"/, 'the reply composer too');-- assert.match(task.text, /data-ovt-files/);++ assert.match(task.text, /data-ch-upload/);});await check('a multipart reply carries its own files on the next turn', async () => {@@@ -56,6 +56,10 @@ const { stepKind } = require('../server/http/chat');assert.ok(text.includes('data-ovt-panel-open aria-controls="ch-side"') && text.includes('<aside class="ovt-panel" id="ch-side"'), 'what it did is the panel');assert.ok(text.includes('1 file changed') && text.includes('<span class="ch-add">+3</span>'), 'how it ended: the files');assert.ok(text.includes(`action="/tasks/${MINE}/continue"`), 'the composer carries it on');++ assert.ok(text.includes('data-ch-upload') && text.includes('ov-upload.js') && text.includes('ov-upload.css'), 'the shared file picker is loaded for the composer');++ assert.match(page.headers.get('content-security-policy'), /img-src[^;]*blob:/, 'the picker can preview image files');++ assert.match(page.headers.get('content-security-policy'), /media-src[^;]*blob:/, 'the picker can preview video files');++ assert.ok(text.includes('20 MB each, 50 MB a message'), 'attachment limits are visible before upload');assert.ok(text.includes('aria-current="page"'), 'the rail marks the open conversation');});@@@ -204,7 +204,7 @@ function assertSummary(s) {await check('the home reads ?prompt=, ?mode= and ?repo= and prefills its composer', async () => {const plain = await t.get('/', { as: ada });assert.strictEqual(plain.status, 200);-- assert.ok(plain.text.includes('data-ovt-attach'), 'the kit\u2019s attach menu');++ assert.ok(plain.text.includes('data-ch-upload'), 'the shared upload picker');assert.ok(plain.text.includes('image/*'), 'images, text, code, PDF and zip');assert.ok(plain.text.includes('https://openvibe.media'), 'the person\u2019s OpenVibe files');assert.ok(plain.text.includes('Programs can\u2019t be attached'), 'the limits in the note');@@@ -162,6 +162,7 @@ await check('the portable file downloads as an attachment, and an import goes baassert.ok(page.text.includes('Download memory.json'), 'the export link');assert.ok(page.text.includes('Import a portable file'), 'the import form');assert.ok(page.text.includes('memory-import.js'), 'the file picker script');++ assert.ok(page.text.includes('id="memory-import-picker"') && page.text.includes('ov-upload.js'), 'portable JSON uses the shared local picker');const file = await h.get('/memory/export', { as: user });assert.strictEqual(file.status, 200);@@@ -344,6 +344,7 @@ await check('the Import tab walks through the three assistants and offers the paassert.ok(res.text.includes('Show the prompt to paste'), 'the extraction prompt');assert.ok(res.text.includes('List everything you remember about me'), 'the prompt text');assert.ok(res.text.includes('name="file"') && res.text.includes('enctype="multipart/form-data"'), 'the zip form');++ assert.ok(res.text.includes('data-memory-zip-picker') && res.text.includes('data-memory-zip-progress'), 'zip preview uses the shared picker and progress');assert.ok(res.text.includes('Preview the answer'), 'the paste form');assert.ok(res.text.includes('nothing is kept until you tick it'), 'the consent line');assert.ok(!res.text.includes('Note number 1'), 'no note text on the import tab');
@@ -181,44 +181,63 @@
@@ -181,44 +181,63 @@
model.addEventListener('change', toggle);
model.addEventListener('change', toggle);
}
}
});
});
- // The conductor (Codes C9b): a message with images goes through the JSON API (the form alone sends the words), and
- // the live turn's lines fill the transcript. When the turn ends the page reloads once, so the turn's answer and its
- // state are the server's own words.
- document.querySelectorAll('[data-c4-conductor-form]').forEach((node) => {
- const form = node.closest('form');
- const input = form.querySelector('[data-c4-images]');
+ // The conductor shares the upload picker; files use the existing multipart message route.
+ document.querySelectorAll('[data-c4-conductor-form]').forEach((form) => {
const status = form.querySelector('[data-c4-message-status]');
const status = form.querySelector('[data-c4-message-status]');
- if (!form || !input) return;
- const say = (text, bad) => { if (status) { status.textContent = text; status.classList.toggle('is-bad', !!bad); } };
- const asData = (file) => new Promise((resolve) => {
- const reader = new FileReader();
- reader.onload = () => resolve({ name: file.name, mime: file.type, data: String(reader.result || '') });
- reader.onerror = () => resolve(null);
- reader.readAsDataURL(file);
+ const root = form.querySelector('[data-c4-upload]');
+ const meterRoot = form.querySelector('[data-c4-upload-progress]');
+ if (!root || !window.OVUpload) return;
+ const files = [];
+ const meter = OVUpload.progress(meterRoot, { onRetry: () => form.requestSubmit() });
+ const say = (message, bad) => { if (status) { status.textContent = message; status.classList.toggle('is-bad', !!bad); } };
+ const blocked = /\.(exe|dll|com|bat|cmd|msi|scr|ps1|sh|so|dylib|app|apk|deb|rpm|jar|bin|elf|vbs|wsf|hta)$/i;
+ const picker = OVUpload.picker(root, {
+ label: 'Attach files', multiple: true,
+ onFile: (file, row) => {
+ const total = files.reduce((n, item) => n + item.size, 0);
+ const problem = blocked.test(file.name) ? 'Programs cannot be attached.'
+ : !file.size ? 'Choose a file that is not empty.'
+ : file.size > 20 * 1048576 ? `${file.name} is over 20 MB.`
+ : files.length >= 10 ? 'Choose at most 10 files.'
+ : total + file.size > 50 * 1048576 ? 'The files are over 50 MB together.' : '';
+ if (problem) { row.dispatchEvent(new Event('remove')); row.remove(); say(problem, true); return; }
+ files.push(file); say(`${files.length} file${files.length === 1 ? '' : 's'} selected`);
+ },
+ onRemove: (file) => { const i = files.indexOf(file); if (i >= 0) files.splice(i, 1); say(files.length ? `${files.length} files selected` : ''); }
});
});
form.addEventListener('submit', async (event) => {
form.addEventListener('submit', async (event) => {
- const files = Array.from(input.files || []).slice(0, 10);
- if (files.length) return; // the form itself posts the files as multipart
event.preventDefault();
event.preventDefault();
const button = form.querySelector('button[type="submit"]');
const button = form.querySelector('button[type="submit"]');
if (button) button.disabled = true;
if (button) button.disabled = true;
say('Sending…');
say('Sending…');
+ if (files.length) {
+ const data = new FormData(form);
+ files.forEach((file) => data.append('files', file, file.name));
+ const xhr = new XMLHttpRequest();
+ xhr.open('POST', form.action);
+ xhr.withCredentials = true;
+ const total = files.reduce((n, file) => n + file.size, 0);
+ meter.update({ state: 'sending', done: 0, total });
+ xhr.upload.onprogress = (e) => { if (e.lengthComputable) meter.update({ state: 'sending', done: Math.min(total, e.loaded), total }); };
+ xhr.onload = () => {
+ if (xhr.status >= 200 && xhr.status < 400) { meter.update({ state: 'done', done: total, total }); location.href = xhr.responseURL || form.action; return; }
+ const message = 'The files could not be sent. Check their formats and try again.';
+ meter.update({ state: 'failed', message }); say(message, true); if (button) button.disabled = false;
+ };
+ xhr.onerror = () => { const message = 'The connection stopped. Try again.'; meter.update({ state: 'failed', message }); say(message, true); if (button) button.disabled = false; };
+ xhr.send(data);
+ return;
+ }
try {
try {
- const attachments = (await Promise.all(files.map(asData))).filter(Boolean);
const text = (form.querySelector('textarea[name="text"]') || {}).value || '';
const text = (form.querySelector('textarea[name="text"]') || {}).value || '';
- const res = await fetch(node.dataset.conductorApi, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
- credentials: 'same-origin',
- body: JSON.stringify({ text, attachments }),
+ const res = await fetch(form.dataset.conductorApi, {
+ method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
+ credentials: 'same-origin', body: JSON.stringify({ text, attachments: [] }),
});
});
const doc = await res.json().catch(() => ({}));
const doc = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(doc.detail || `the message was refused (${res.status})`);
if (!res.ok) throw new Error(doc.detail || `the message was refused (${res.status})`);
window.location.reload();
window.location.reload();
- } catch (e) {
- say(String((e && e.message) || 'the message could not be sent'), true);
- if (button) button.disabled = false;
- }
+ } catch (e) { say(String((e && e.message) || 'the message could not be sent'), true); if (button) button.disabled = false; }
});
});
});
});
@@ -175,106 +175,71 @@
@@ -175,106 +175,71 @@
if ($('.ovt.is-start', app) && !matchMedia('(pointer: coarse)').matches) text.focus({ preventScroll: true });
if ($('.ovt.is-start', app) && !matchMedia('(pointer: coarse)').matches) text.focus({ preventScroll: true });
}
}
if (form) {
if (form) {
- // ── The composer's files: upload before send, with a chip, a thumbnail, a progress bar and a × ──
- // The kit's attach menu renders one input per row (Files, Photos and videos, the camera): every one of
- // them feeds the same queue, and the no-JavaScript post sends them all as multipart.
- const pickers = Array.from(form.querySelectorAll('input[data-ovt-files], input[data-ch-files]'));
- const picker = pickers[0] || null;
- const chips = $('[data-ch-files-list]', form);
+ // The shared picker owns drop, keyboard selection, rows and progress. Codes owns its upload transport.
+ const uploadRoot = $('[data-ch-upload]', form);
const BLOCKED = /\.(exe|dll|com|bat|cmd|msi|scr|ps1|sh|so|dylib|app|apk|deb|rpm|jar|bin|elf|vbs|wsf|hta)$/i;
const BLOCKED = /\.(exe|dll|com|bat|cmd|msi|scr|ps1|sh|so|dylib|app|apk|deb|rpm|jar|bin|elf|vbs|wsf|hta)$/i;
const MAX_FILES = 10, MAX_FILE = 20 * 1024 * 1024, MAX_TOTAL = 50 * 1024 * 1024;
const MAX_FILES = 10, MAX_FILE = 20 * 1024 * 1024, MAX_TOTAL = 50 * 1024 * 1024;
const entries = [];
const entries = [];
- const sizeWords = (n) => (n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${Math.round(n / 1024)} KB` : `${(n / (1024 * 1024)).toFixed(n < 10 * 1024 * 1024 ? 1 : 0)} MB`);
- const attachIcon = picker && picker.closest('label') && picker.closest('label').querySelector('svg');
- const chipFor = (entry) => {
- const li = document.createElement('span');
- li.className = 'ch-chip ch-file';
- li.setAttribute('role', 'listitem');
- li.dataset.chFile = entry.file.name;
- if (/^image\//i.test(entry.file.type)) {
- const img = document.createElement('img');
- img.width = 24; img.height = 24; img.alt = ''; img.src = entry.thumb;
- li.appendChild(img);
- } else if (attachIcon) {
- li.appendChild(attachIcon.cloneNode(true));
+ let picker = null;
+ function addFile(file, row) {
+ const total = entries.reduce((n, e) => n + e.file.size, 0);
+ let problem = '';
+ if (BLOCKED.test(file.name)) problem = `“${file.name}” is a program or script, and cannot be attached`;
+ else if (!file.size) problem = `“${file.name}” is empty`;
+ else if (file.size > MAX_FILE) problem = `“${file.name}” is larger than 20 MB`;
+ else if (entries.length >= MAX_FILES) problem = 'A message carries at most 10 files';
+ else if (total + file.size > MAX_TOTAL) problem = 'The files of one message are larger than 50 MB together';
+ if (problem) { row.dispatchEvent(new Event('remove')); row.remove(); say(problem, true); return; }
+ const meterRoot = document.createElement('div');
+ row.append(meterRoot);
+ const meter = OVUpload.progress(meterRoot, { onRetry: () => sendFile() });
+ const entry = { file, row, meter, id: null, uploading: true, xhr: null };
+ entries.push(entry);
+ function sendFile() {
+ entry.uploading = true;
+ meter.update({ state: 'sending', done: 0, total: file.size });
+ const xhr = entry.xhr = new XMLHttpRequest();
+ xhr.open('POST', '/api/v1/attachments');
+ xhr.withCredentials = true;
+ xhr.upload.onprogress = (e) => { if (e.lengthComputable) meter.update({ state: 'sending', done: e.loaded, total: e.total }); };
+ xhr.onload = () => {
+ entry.uploading = false;
+ let doc = {};
+ try { doc = JSON.parse(xhr.responseText || '{}'); } catch { /* response is not JSON */ }
+ if (xhr.status === 201 && doc.attachment && doc.attachment.id) {
+ entry.id = doc.attachment.id;
+ meter.update({ state: 'done', done: file.size, total: file.size });
+ } else {
+ const message = doc.detail || `“${file.name}” could not be attached`;
+ meter.update({ state: 'failed', message }); say(message, true);
+ }
+ };
+ xhr.onerror = () => { entry.uploading = false; const message = `“${file.name}” could not be attached`; meter.update({ state: 'failed', message }); say(message, true); };
+ xhr.onabort = () => { entry.uploading = false; };
+ const data = new FormData(); data.append('file', file, file.name); xhr.send(data);
}
}
- const name = document.createElement('span'); name.textContent = entry.file.name; li.appendChild(name);
- const size = document.createElement('small'); size.textContent = sizeWords(entry.file.size); li.appendChild(size);
- const bar = document.createElement('progress'); bar.max = 100; bar.value = 0; bar.setAttribute('aria-label', `Uploading ${entry.file.name}`); li.appendChild(bar);
- const remove = document.createElement('button');
- remove.type = 'button'; remove.className = 'ch-icon-btn'; remove.title = `Remove ${entry.file.name}`;
- remove.setAttribute('aria-label', `Remove ${entry.file.name}`); remove.textContent = '×';
- remove.addEventListener('click', () => {
- const i = entries.indexOf(entry);
- if (i >= 0) entries.splice(i, 1);
- if (entry.xhr) entry.xhr.abort();
- if (entry.thumb) URL.revokeObjectURL(entry.thumb);
- li.remove();
- if (entry.id) fetch(`/api/v1/attachments/${encodeURIComponent(entry.id)}`, { method: 'DELETE', credentials: 'same-origin' }).catch(() => {});
- say(entries.length ? `${entries.length} file${entries.length === 1 ? '' : 's'} ready to send` : '');
- });
- entry.bar = bar; entry.li = li;
- li.appendChild(remove);
- if (chips) { chips.hidden = false; chips.appendChild(li); }
- return li;
- };
- const upload = (entry) => {
- entry.uploading = true;
- const xhr = entry.xhr = new XMLHttpRequest();
- xhr.open('POST', '/api/v1/attachments');
- xhr.withCredentials = true;
- xhr.upload.onprogress = (e) => { if (e.lengthComputable && entry.bar) entry.bar.value = Math.round((e.loaded / e.total) * 100); };
- xhr.onload = () => {
- entry.uploading = false;
- let doc = {};
- try { doc = JSON.parse(xhr.responseText || '{}'); } catch { doc = {}; }
- if (xhr.status === 201 && doc.attachment && doc.attachment.id) {
- entry.id = doc.attachment.id;
- if (entry.bar) { entry.bar.value = 100; entry.bar.hidden = true; }
- if (entry.li) entry.li.dataset.uploaded = '1';
- } else {
- if (entry.li) entry.li.classList.add('is-bad');
- say(doc.detail || `“${entry.file.name}” could not be attached`, true);
+ sendFile();
+ say(`${entries.length} file${entries.length === 1 ? '' : 's'} selected`);
+ }
+ if (uploadRoot && window.OVUpload) {
+ picker = OVUpload.picker(uploadRoot, {
+ label: 'Attach files', accept: uploadRoot.dataset.accept, multiple: true,
+ onFile: addFile,
+ onRemove: (file) => {
+ const i = entries.findIndex((entry) => entry.file === file);
+ if (i < 0) return;
+ const [entry] = entries.splice(i, 1);
+ if (entry.xhr) entry.xhr.abort();
+ entry.meter.destroy();
+ if (entry.id) fetch(`/api/v1/attachments/${encodeURIComponent(entry.id)}`, { method: 'DELETE', credentials: 'same-origin' }).catch(() => {});
+ say(entries.length ? `${entries.length} file${entries.length === 1 ? '' : 's'} selected` : '');
}
}
- };
- xhr.onerror = () => { entry.uploading = false; if (entry.li) entry.li.classList.add('is-bad'); say(`“${entry.file.name}” could not be attached`, true); };
- xhr.onabort = () => { entry.uploading = false; };
- const data = new FormData();
- data.append('file', entry.file, entry.file.name);
- xhr.send(data);
- };
- const addFiles = (list) => {
- const incoming = Array.from(list || []);
- if (!incoming.length) return;
- if (entries.length + incoming.length > MAX_FILES) { say(`A message carries at most ${MAX_FILES} files`, true); return; }
- let total = entries.reduce((n, e) => n + e.file.size, 0);
- for (const file of incoming) {
- if (BLOCKED.test(file.name)) { say(`“${file.name}” is a program or script, and cannot be attached`, true); continue; }
- if (!file.size) { say(`“${file.name}” is empty`, true); continue; }
- if (file.size > MAX_FILE) { say(`“${file.name}” is larger than 20 MB`, true); continue; }
- if (total + file.size > MAX_TOTAL) { say('The files of one message are larger than 50 MB together', true); break; }
- total += file.size;
- const entry = { file, id: null, uploading: false, thumb: /^image\//i.test(file.type) ? URL.createObjectURL(file) : null, xhr: null };
- entries.push(entry);
- chipFor(entry);
- upload(entry);
- }
- if (entries.length) say(`${entries.length} file${entries.length === 1 ? '' : 's'} ready to send`);
- };
- for (const one of pickers) {
- one.addEventListener('change', () => { addFiles(one.files); one.value = ''; });
+ });
+ if (text) text.addEventListener('paste', (e) => {
+ const files = e.clipboardData && e.clipboardData.files;
+ if (files && files.length) { e.preventDefault(); picker.add(files); }
+ });
}
}
- // Paste an image from the clipboard, or drop files on the composer.
- if (text) text.addEventListener('paste', (e) => {
- const files = e.clipboardData && e.clipboardData.files;
- if (files && files.length) { e.preventDefault(); addFiles(files); }
- });
- form.addEventListener('dragover', (e) => { if (e.dataTransfer && Array.from(e.dataTransfer.types || []).includes('Files')) { e.preventDefault(); form.classList.add('is-dragging'); } });
- form.addEventListener('dragleave', () => form.classList.remove('is-dragging'));
- form.addEventListener('drop', (e) => {
- if (!e.dataTransfer || !e.dataTransfer.files || !e.dataTransfer.files.length) return;
- e.preventDefault(); form.classList.remove('is-dragging'); addFiles(e.dataTransfer.files);
- });
form.addEventListener('submit', async (e) => {
form.addEventListener('submit', async (e) => {
const submitter = e.submitter;
const submitter = e.submitter;
if (submitter && submitter.formAction && submitter.formAction !== form.action) return; // Stop: a plain post
if (submitter && submitter.formAction && submitter.formAction !== form.action) return; // Stop: a plain post
@@ -314,7 +279,6 @@
@@ -314,7 +279,6 @@
}
}
// The files are up: a plain form post names their ids (and sends the words and the pickers as usual).
// The files are up: a plain form post names their ids (and sends the words and the pickers as usual).
form.enctype = 'application/x-www-form-urlencoded';
form.enctype = 'application/x-www-form-urlencoded';
- for (const one of pickers) one.value = '';
for (const id of ids) {
for (const id of ids) {
const hidden = document.createElement('input');
const hidden = document.createElement('input');
hidden.type = 'hidden'; hidden.name = 'attachment_ids'; hidden.value = id;
hidden.type = 'hidden'; hidden.name = 'attachment_ids'; hidden.value = id;
@@ -1,25 +1,65 @@
@@ -1,25 +1,65 @@
'use strict';
'use strict';
-/*
- * Memory M5: choosing a portable file fills the import textarea, so the page works with the browser's own file
- * picker and no inline script. It only copies the file's text; the import itself is a normal form post to Codes,
- * which forwards the document to the store.
- */
+// The shared picker keeps portable JSON local; export zips go through the existing preview route.
(function () {
(function () {
- var input = document.getElementById('memory-import-file');
- var text = document.getElementById('memory-import-text');
- if (!input || !text) return;
- input.addEventListener('change', function () {
- var file = input.files && input.files[0];
- if (!file) return;
- if (file.size > 25 * 1024 * 1024) {
- text.value = '';
- input.setCustomValidity('That file is larger than 25 MB.');
- input.reportValidity();
- return;
- }
- input.setCustomValidity('');
- var reader = new FileReader();
- reader.onload = function () { text.value = String(reader.result || ''); };
- reader.readAsText(file);
+ if (!window.OVUpload) return;
+ const localRoot = document.getElementById('memory-import-picker');
+ const text = document.getElementById('memory-import-text');
+ const localError = document.getElementById('memory-import-error');
+ const maxBytes = 25 * 1024 * 1024;
+ if (localRoot && text) {
+ const picker = OVUpload.picker(localRoot, {
+ label: 'Choose a memory JSON file', accept: '.json,application/json', multiple: false,
+ onFile: (file, row) => {
+ picker.rows.querySelectorAll('.ov-upload__row').forEach((old) => { if (old !== row) { old.dispatchEvent(new Event('remove')); old.remove(); } });
+ if (file.size > maxBytes || !(/\.json$/i.test(file.name) || file.type === 'application/json')) {
+ row.dispatchEvent(new Event('remove')); row.remove();
+ localError.textContent = file.size > maxBytes ? 'That file is larger than 25 MB.' : 'Choose a JSON file.';
+ localError.hidden = false; return;
+ }
+ localError.hidden = true;
+ const reader = new FileReader();
+ reader.onload = () => { text.value = String(reader.result || ''); };
+ reader.onerror = () => { localError.textContent = 'That file could not be read. Try another JSON file.'; localError.hidden = false; };
+ reader.readAsText(file);
+ },
+ onRemove: () => { text.value = ''; }
+ });
+ }
+ document.querySelectorAll('[data-memory-zip-form]').forEach((form) => {
+ const root = form.querySelector('[data-memory-zip-picker]');
+ const error = form.querySelector('[data-memory-zip-error]');
+ const meter = OVUpload.progress(form.querySelector('[data-memory-zip-progress]'), { onRetry: () => form.requestSubmit() });
+ let selected = null;
+ const picker = OVUpload.picker(root, {
+ label: 'Choose an export ZIP', accept: '.zip,application/zip,application/x-zip-compressed', multiple: false,
+ onFile: (file, row) => {
+ picker.rows.querySelectorAll('.ov-upload__row').forEach((old) => { if (old !== row) { old.dispatchEvent(new Event('remove')); old.remove(); } });
+ if (!/\.zip$/i.test(file.name) || !file.size || file.size > maxBytes) {
+ row.dispatchEvent(new Event('remove')); row.remove();
+ error.textContent = !/\.zip$/i.test(file.name) ? 'Choose a ZIP export.' : file.size > maxBytes ? 'That export is larger than 25 MB.' : 'That export is empty.';
+ error.hidden = false; return;
+ }
+ selected = file; error.hidden = true;
+ meter.update({ state: 'waiting', done: 0, total: file.size });
+ },
+ onRemove: (file) => { if (selected === file) selected = null; meter.update({ state: 'waiting', done: 0, total: 0 }); }
+ });
+ form.addEventListener('submit', (event) => {
+ event.preventDefault();
+ if (!selected) { error.textContent = 'Choose a ZIP export to preview.'; error.hidden = false; return; }
+ const button = form.querySelector('button[type="submit"]');
+ button.disabled = true; error.hidden = true;
+ const data = new FormData(form); data.append('file', selected, selected.name);
+ const xhr = new XMLHttpRequest(); xhr.open('POST', form.action); xhr.withCredentials = true;
+ meter.update({ state: 'sending', done: 0, total: selected.size });
+ xhr.upload.onprogress = (e) => { if (e.lengthComputable) meter.update({ state: 'sending', done: Math.min(selected.size, e.loaded), total: selected.size }); };
+ xhr.onload = () => {
+ if (xhr.status >= 200 && xhr.status < 400) { meter.update({ state: 'done', done: selected.size, total: selected.size }); location.href = xhr.responseURL || form.action; return; }
+ const message = 'That export could not be previewed. Try another ZIP file.';
+ meter.update({ state: 'failed', message }); error.textContent = message; error.hidden = false; button.disabled = false;
+ };
+ xhr.onerror = () => { const message = 'The connection stopped. Try again.'; meter.update({ state: 'failed', message }); error.textContent = message; error.hidden = false; button.disabled = false; };
+ xhr.send(data);
+ });
});
});
}());
}());
@@ -221,7 +221,8 @@ async function createApp(opts = {}) {
@@ -221,7 +221,8 @@ async function createApp(opts = {}) {
scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com'],
scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com'],
styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],
styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],
fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],
fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],
- imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.media'],
+ imgSrc: ["'self'", 'data:', 'blob:', 'https://openvibe.network', 'https://openvibe.media'],
+ mediaSrc: ["'self'", 'blob:'],
// openvibe.events: release notifications (release-watch's EventSource, openvibe-shared 1.17).
// openvibe.events: release notifications (release-watch's EventSource, openvibe-shared 1.17).
// The navbar's Messages count asks Chat (openvibe-shared 4.130.0).
// The navbar's Messages count asks Chat (openvibe-shared 4.130.0).
connectSrc: ["'self'", 'https://openvibe.chat', 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events'],
connectSrc: ["'self'", 'https://openvibe.chat', 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events'],
@@ -381,7 +381,7 @@ ${pr.problem ? `<p class="c4-hint">${esc(pr.problem.detail)}</p>` : `<dl class="
@@ -381,7 +381,7 @@ ${pr.problem ? `<p class="c4-hint">${esc(pr.problem.detail)}</p>` : `<dl class="
const outcome = row.outcome === 'unfinished' || row.outcome === 'stopped'
const outcome = row.outcome === 'unfinished' || row.outcome === 'stopped'
? `<div class="c4-outcome"><span class="c4-obadge" aria-hidden="true">${icon('<path d="m18 6-12 12M6 6l12 12"/>')}</span><div class="c4-outcome-copy"><b>${row.outcome === 'stopped' ? 'Stopped' : 'The last turn did not finish'}</b><span>${esc(row.outcome_note || row.error || 'Write a message to carry on.')}</span></div></div>` : '';
? `<div class="c4-outcome"><span class="c4-obadge" aria-hidden="true">${icon('<path d="m18 6-12 12M6 6l12 12"/>')}</span><div class="c4-outcome-copy"><b>${row.outcome === 'stopped' ? 'Stopped' : 'The last turn did not finish'}</b><span>${esc(row.outcome_note || row.error || 'Write a message to carry on.')}</span></div></div>` : '';
const transcript = `<section class="c4-card c4-transcript" data-c4-conductor-stream="${esc(`${api}/events?last_event_id=${lastSeq}`)}" data-task="${esc(row.id)}" data-started-at="${esc(row.started_at || row.created_at || '')}" data-c4-conductor="${live ? 'live' : 'idle'}"><div class="c4-card-head"><h2>${live ? 'This turn, live' : 'The last turn'}</h2><span class="c4-muted" data-live-label>${live ? 'Connecting…' : 'Nothing is running'}</span></div><pre class="c4-log" data-task-log aria-live="polite" tabindex="0">${esc(lastLines)}</pre></section>`;
const transcript = `<section class="c4-card c4-transcript" data-c4-conductor-stream="${esc(`${api}/events?last_event_id=${lastSeq}`)}" data-task="${esc(row.id)}" data-started-at="${esc(row.started_at || row.created_at || '')}" data-c4-conductor="${live ? 'live' : 'idle'}"><div class="c4-card-head"><h2>${live ? 'This turn, live' : 'The last turn'}</h2><span class="c4-muted" data-live-label>${live ? 'Connecting…' : 'Nothing is running'}</span></div><pre class="c4-log" data-task-log aria-live="polite" tabindex="0">${esc(lastLines)}</pre></section>`;
- const composer = row.outcome === 'stopped' ? '' : `<section class="c4-card c4-conductor-composer"><form method="post" action="/tasks/${encodeURIComponent(row.id)}/messages" enctype="multipart/form-data" data-c4-conductor-form data-conductor-api="/api/v1/conductor/${encodeURIComponent(row.id)}/messages"><label for="c4-message">${live ? 'Say something: it is the next turn' : 'What next?'}</label><textarea id="c4-message" name="text" rows="3" placeholder="Tell your conductor what to do next, or ask what it is doing"></textarea><div class="c4-conductor-actions"><label class="c4-file">Attach files<input type="file" name="files" multiple data-c4-images></label><button class="c4-button" type="submit">Send</button></div><p class="c4-hint" data-c4-message-status role="status">${live ? 'A message waits until the current turn ends; it is not lost.' : ''}</p></form></section>`;
+ const composer = row.outcome === 'stopped' ? '' : `<section class="c4-card c4-conductor-composer"><form method="post" action="/tasks/${encodeURIComponent(row.id)}/messages" enctype="multipart/form-data" data-c4-conductor-form data-conductor-api="/api/v1/conductor/${encodeURIComponent(row.id)}/messages"><label for="c4-message">${live ? 'Say something: it is the next turn' : 'What next?'}</label><textarea id="c4-message" name="text" rows="3" placeholder="Tell your conductor what to do next, or ask what it is doing"></textarea><div class="c4-conductor-actions"><div data-c4-upload></div><noscript><label class="c4-file">Attach files<input type="file" name="files" multiple></label></noscript><button class="c4-button" type="submit">Send</button></div><p class="c4-hint">Up to 10 files, 20 MB each, 50 MB together. Programs cannot be attached.</p><div data-c4-upload-progress></div><p class="c4-hint" data-c4-message-status role="status">${live ? 'A message waits until the current turn ends; it is not lost.' : ''}</p></form></section>`;
const content = `${outcome}<section class="c4-card c4-conductor-head"><div class="c4-task-heading"><div class="c4-task-title-wrap"><h1>Conductor</h1></div><span class="c4-status c4-${esc(row.state)}">${esc(conductorState(row))}</span></div><div class="c4-chips" aria-label="Conductor details"><span class="c4-chip">${esc(row.folder || 'a folder')}</span><span class="c4-chip">${esc(row.agent === 'claude-code' ? modelLabel(row.model) : (row.model || 'its default model'))}</span><span class="c4-chip">safety: ${esc(row.safety || 'ask')}</span><span class="c4-chip">${esc(machineLabel || 'your machine')}</span></div><p class="c4-hint">One long session on your own machine, on the agent you chose and your own login. Each message is the next turn of the same conversation; it hands the work to your other agents through the Codes tools.</p></section>${approvals}${usageCard}<section class="c4-card c4-turns"><div class="c4-card-head"><h2>Turns</h2><span class="c4-muted">${(view.messages || []).length} so far</span></div>${turns ? `<ol class="c4-turn-list">${turns}</ol>` : '<div class="c4-emptyline">Its first turn is starting.</div>'}</section>${transcript}${composer}`;
const content = `${outcome}<section class="c4-card c4-conductor-head"><div class="c4-task-heading"><div class="c4-task-title-wrap"><h1>Conductor</h1></div><span class="c4-status c4-${esc(row.state)}">${esc(conductorState(row))}</span></div><div class="c4-chips" aria-label="Conductor details"><span class="c4-chip">${esc(row.folder || 'a folder')}</span><span class="c4-chip">${esc(row.agent === 'claude-code' ? modelLabel(row.model) : (row.model || 'its default model'))}</span><span class="c4-chip">safety: ${esc(row.safety || 'ask')}</span><span class="c4-chip">${esc(machineLabel || 'your machine')}</span></div><p class="c4-hint">One long session on your own machine, on the agent you chose and your own login. Each message is the next turn of the same conversation; it hands the work to your other agents through the Codes tools.</p></section>${approvals}${usageCard}<section class="c4-card c4-turns"><div class="c4-card-head"><h2>Turns</h2><span class="c4-muted">${(view.messages || []).length} so far</span></div>${turns ? `<ol class="c4-turn-list">${turns}</ol>` : '<div class="c4-emptyline">Its first turn is starting.</div>'}</section>${transcript}${composer}`;
page(req, res, 'Conductor', wrap(content), { sharedScripts: ['agent-session.js'] });
page(req, res, 'Conductor', wrap(content), { sharedScripts: ['agent-session.js'] });
}
}
@@ -440,9 +440,9 @@ function createChatRoutes(ctx) {
@@ -440,9 +440,9 @@ function createChatRoutes(ctx) {
function composer({ action, api = '', placeholder, pickers = '', options = '', examples = [], live = false, stopAction = '', note = '', id = null, where = '', files = false, value = '' }) {
function composer({ action, api = '', placeholder, pickers = '', options = '', examples = [], live = false, stopAction = '', note = '', id = null, where = '', files = false, value = '' }) {
return thread.composer({
return thread.composer({
action, id, placeholder, label: placeholder, tools: pickers,
action, id, placeholder, label: placeholder, tools: pickers,
- attach: files ? { menu: true, accept: ATTACH_ACCEPT, library: MEDIA_LIBRARY, name: 'files' } : null,
+ attach: null,
options: options ? { html: options, label: 'Options' } : null, examples, value,
options: options ? { html: options, label: 'Options' } : null, examples, value,
- foot: `${files ? '<div class="ch-files" data-ch-files-list role="list" aria-live="polite"></div>' : ''}${where}`,
+ foot: `${files ? `<div data-ch-upload data-accept="${esc(ATTACH_ACCEPT)}"></div>${MEDIA_LIBRARY ? `<a href="${esc(MEDIA_LIBRARY)}">From your OpenVibe files</a>` : ''}<noscript><label>Attach files<input type="file" name="files" accept="${esc(ATTACH_ACCEPT)}" multiple></label></noscript>` : ''}${where}`,
note: files ? (note ? `${note} ${ATTACH_NOTE}` : ATTACH_NOTE) : note,
note: files ? (note ? `${note} ${ATTACH_NOTE}` : ATTACH_NOTE) : note,
attrs: { ...(api ? { 'data-ch-api': api } : {}), ...(files ? { enctype: 'multipart/form-data' } : {}) },
attrs: { ...(api ? { 'data-ch-api': api } : {}), ...(files ? { enctype: 'multipart/form-data' } : {}) },
busy: live, stopAction: live && stopAction ? stopAction : null, maxlength: 16000,
busy: live, stopAction: live && stopAction ? stopAction : null, maxlength: 16000,
@@ -462,7 +462,7 @@ function createChatRoutes(ctx) {
@@ -462,7 +462,7 @@ function createChatRoutes(ctx) {
cls: page != null ? 'is-page' : '',
cls: page != null ? 'is-page' : '',
});
});
const body = `<div class="ch" data-chat${streamUrl ? ` data-ch-stream="${esc(streamUrl)}"` : ''}>${view}</div>`;
const body = `<div class="ch" data-chat${streamUrl ? ` data-ch-stream="${esc(streamUrl)}"` : ''}>${view}</div>`;
- return send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, title: `${title} · Codes`, css: [...css, 'css/chat.css'], styles: ['kit.css', 'thread.css', ...styles], sharedScripts: ['kit-web.js', 'ov-thread.js', ...sharedScripts], scripts: [...scripts, 'js/md.js', 'js/chat.js'], body: raw(body) });
+ return send(res, 200, { viewer: req.viewer, config, path: req.originalUrl, title: `${title} · Codes`, css: [...css, 'css/chat.css'], styles: ['kit.css', 'thread.css', 'ov-upload.css', ...styles], sharedScripts: ['kit-web.js', 'ov-thread.js', 'ov-upload.js', ...sharedScripts], scripts: [...scripts, 'js/md.js', 'js/chat.js'], body: raw(body) });
}
}
/** The panel: who worked on it, and where the work is. */
/** The panel: who worked on it, and where the work is. */
@@ -81,7 +81,7 @@ const MEMORY_TABS = [
@@ -81,7 +81,7 @@ const MEMORY_TABS = [
];
];
/** The share sheet and the gate are drawn by openvibe-shared (openvibe-shared 4.123.0); no inline script. */
/** The share sheet and the gate are drawn by openvibe-shared (openvibe-shared 4.123.0); no inline script. */
-const SHARE_ASSETS = { styles: ['ov-share.css'], sharedScripts: ['ov-share.js'], scripts: ['js/memory-import.js'] };
+const SHARE_ASSETS = { styles: ['ov-share.css', 'ov-upload.css'], sharedScripts: ['ov-share.js', 'ov-upload.js'], scripts: ['js/memory-import.js'] };
const SERVICES = ['mem0', 'letta', 'zep'];
const SERVICES = ['mem0', 'letta', 'zep'];
/** What the store's refusals say to a person. The words are ours: no note text ever reaches a flash. */
/** What the store's refusals say to a person. The words are ours: no note text ever reaches a flash. */
@@ -241,9 +241,10 @@ function createMemoryRoutes(ctx, kit) {
@@ -241,9 +241,10 @@ function createMemoryRoutes(ctx, kit) {
+ '<p class="c4-hint">' + esc(p.what) + '</p>'
+ '<p class="c4-hint">' + esc(p.what) + '</p>'
+ '<ol class="c4-steps">' + (p.steps || []).map((step) => '<li>' + esc(step) + '</li>').join('') + '</ol>'
+ '<ol class="c4-steps">' + (p.steps || []).map((step) => '<li>' + esc(step) + '</li>').join('') + '</ol>'
+ '<p class="c4-hint"><strong>What arrives:</strong> ' + esc(p.arrives) + '<br><strong>How long:</strong> ' + esc(p.time) + '</p>'
+ '<p class="c4-hint"><strong>What arrives:</strong> ' + esc(p.arrives) + '<br><strong>How long:</strong> ' + esc(p.time) + '</p>'
- + '<form class="c4-form" method="post" action="/memory/import/preview" enctype="multipart/form-data">'
+ + '<form class="c4-form" method="post" action="/memory/import/preview" enctype="multipart/form-data" data-memory-zip-form>'
+ '<input type="hidden" name="provider" value="' + esc(p.id) + '">'
+ '<input type="hidden" name="provider" value="' + esc(p.id) + '">'
- + '<label class="c4-file">Your export .zip <input type="file" name="file" accept=".zip,application/zip,application/x-zip-compressed" required></label>'
+ + '<div data-memory-zip-picker></div><noscript><label class="c4-file">Your export .zip <input type="file" name="file" accept=".zip,application/zip,application/x-zip-compressed" required></label></noscript>'
+ + '<p class="c4-hint">Choose a .zip export, up to 25 MB.</p><div data-memory-zip-progress></div><p role="alert" data-memory-zip-error hidden></p>'
+ '<button class="c4-button" type="submit">Preview ' + esc(p.name) + '</button>'
+ '<button class="c4-button" type="submit">Preview ' + esc(p.name) + '</button>'
+ '</form></article>';
+ '</form></article>';
const paste = '<section class="c4-card"><div class="c4-card-head"><h2>Any assistant: ask what it remembers</h2>'
const paste = '<section class="c4-card"><div class="c4-card-head"><h2>Any assistant: ask what it remembers</h2>'
@@ -575,7 +576,7 @@ function createMemoryRoutes(ctx, kit) {
@@ -575,7 +576,7 @@ function createMemoryRoutes(ctx, kit) {
+ '<section class="c4-card"><div class="c4-card-head"><h2>Import a portable file</h2></div>'
+ '<section class="c4-card"><div class="c4-card-head"><h2>Import a portable file</h2></div>'
+ '<p class="c4-sub">Choose the file or paste its JSON. Notes that say the same thing as one you already have are merged, not copied.</p>'
+ '<p class="c4-sub">Choose the file or paste its JSON. Notes that say the same thing as one you already have are merged, not copied.</p>'
+ '<form method="post" action="/memory/import"><input type="hidden" name="tab" value="export">'
+ '<form method="post" action="/memory/import"><input type="hidden" name="tab" value="export">'
- + '<label class="c4-field"><span>File</span><input type="file" id="memory-import-file" accept="application/json,.json"></label>'
+ + '<div id="memory-import-picker"></div><p role="alert" id="memory-import-error" hidden></p>'
+ '<label class="c4-field"><span>Or paste the JSON</span><textarea id="memory-import-text" name="document" rows="8" spellcheck="false"></textarea></label>'
+ '<label class="c4-field"><span>Or paste the JSON</span><textarea id="memory-import-text" name="document" rows="8" spellcheck="false"></textarea></label>'
+ '<button class="c4-button" type="submit">Import</button></form>'
+ '<button class="c4-button" type="submit">Import</button></form>'
+ '<p class="c4-hint">A file over 25 MB is refused. Nothing is kept until you press Import.</p></section>';
+ '<p class="c4-hint">A file over 25 MB is refused. Nothing is kept until you press Import.</p></section>';
@@ -164,18 +164,18 @@ function multipart(fields, files) {
@@ -164,18 +164,18 @@ function multipart(fields, files) {
assert.strictEqual((await t.get(`/api/v1/attachments/${rows[0].id}`, { as: ada, method: 'DELETE' })).status, 409);
assert.strictEqual((await t.get(`/api/v1/attachments/${rows[0].id}`, { as: ada, method: 'DELETE' })).status, 409);
});
});
- await check('the composers offer files without JavaScript too: a multipart form and the kit\u2019s attach menu', async () => {
+ await check('the composers use the shared picker and keep a multipart fallback', async () => {
const home = await t.get('/', { as: ada });
const home = await t.get('/', { as: ada });
assert.strictEqual(home.status, 200);
assert.strictEqual(home.status, 200);
assert.match(home.text, /<form class="ovt-composer" id="ch-start"[^>]*enctype="multipart\/form-data"/, 'the start form posts multipart');
assert.match(home.text, /<form class="ovt-composer" id="ch-start"[^>]*enctype="multipart\/form-data"/, 'the start form posts multipart');
- assert.match(home.text, /data-ovt-attach/, 'the kit\u2019s attach menu');
- assert.match(home.text, /<input type="file" name="files"[^>]*multiple data-ovt-files/, 'the Files row takes files');
+ assert.match(home.text, /data-ch-upload/, 'the shared picker mount');
+ assert.match(home.text, /ov-upload\.js/, 'the shared picker script');
+ assert.match(home.text, /<noscript><label>Attach files<input type="file" name="files"[^>]*multiple/, 'a native fallback takes files');
assert.match(home.text, /accept="[^"]*image\/\*/, 'images, text, code, PDF and zip');
assert.match(home.text, /accept="[^"]*image\/\*/, 'images, text, code, PDF and zip');
assert.match(home.text, /From your OpenVibe files/, 'the library row');
assert.match(home.text, /From your OpenVibe files/, 'the library row');
- assert.match(home.text, /data-ch-files-list/, 'with the chips list the script fills');
const task = await t.get(`/tasks/${taskId}`, { as: ada });
const task = await t.get(`/tasks/${taskId}`, { as: ada });
assert.match(task.text, /enctype="multipart\/form-data"/, 'the reply composer too');
assert.match(task.text, /enctype="multipart\/form-data"/, 'the reply composer too');
- assert.match(task.text, /data-ovt-files/);
+ assert.match(task.text, /data-ch-upload/);
});
});
await check('a multipart reply carries its own files on the next turn', async () => {
await check('a multipart reply carries its own files on the next turn', async () => {
@@ -56,6 +56,10 @@ const { stepKind } = require('../server/http/chat');
@@ -56,6 +56,10 @@ const { stepKind } = require('../server/http/chat');
assert.ok(text.includes('data-ovt-panel-open aria-controls="ch-side"') && text.includes('<aside class="ovt-panel" id="ch-side"'), 'what it did is the panel');
assert.ok(text.includes('data-ovt-panel-open aria-controls="ch-side"') && text.includes('<aside class="ovt-panel" id="ch-side"'), 'what it did is the panel');
assert.ok(text.includes('1 file changed') && text.includes('<span class="ch-add">+3</span>'), 'how it ended: the files');
assert.ok(text.includes('1 file changed') && text.includes('<span class="ch-add">+3</span>'), 'how it ended: the files');
assert.ok(text.includes(`action="/tasks/${MINE}/continue"`), 'the composer carries it on');
assert.ok(text.includes(`action="/tasks/${MINE}/continue"`), 'the composer carries it on');
+ assert.ok(text.includes('data-ch-upload') && text.includes('ov-upload.js') && text.includes('ov-upload.css'), 'the shared file picker is loaded for the composer');
+ assert.match(page.headers.get('content-security-policy'), /img-src[^;]*blob:/, 'the picker can preview image files');
+ assert.match(page.headers.get('content-security-policy'), /media-src[^;]*blob:/, 'the picker can preview video files');
+ assert.ok(text.includes('20 MB each, 50 MB a message'), 'attachment limits are visible before upload');
assert.ok(text.includes('aria-current="page"'), 'the rail marks the open conversation');
assert.ok(text.includes('aria-current="page"'), 'the rail marks the open conversation');
});
});
@@ -204,7 +204,7 @@ function assertSummary(s) {
@@ -204,7 +204,7 @@ function assertSummary(s) {
await check('the home reads ?prompt=, ?mode= and ?repo= and prefills its composer', async () => {
await check('the home reads ?prompt=, ?mode= and ?repo= and prefills its composer', async () => {
const plain = await t.get('/', { as: ada });
const plain = await t.get('/', { as: ada });
assert.strictEqual(plain.status, 200);
assert.strictEqual(plain.status, 200);
- assert.ok(plain.text.includes('data-ovt-attach'), 'the kit\u2019s attach menu');
+ assert.ok(plain.text.includes('data-ch-upload'), 'the shared upload picker');
assert.ok(plain.text.includes('image/*'), 'images, text, code, PDF and zip');
assert.ok(plain.text.includes('image/*'), 'images, text, code, PDF and zip');
assert.ok(plain.text.includes('https://openvibe.media'), 'the person\u2019s OpenVibe files');
assert.ok(plain.text.includes('https://openvibe.media'), 'the person\u2019s OpenVibe files');
assert.ok(plain.text.includes('Programs can\u2019t be attached'), 'the limits in the note');
assert.ok(plain.text.includes('Programs can\u2019t be attached'), 'the limits in the note');
@@ -162,6 +162,7 @@ await check('the portable file downloads as an attachment, and an import goes ba
@@ -162,6 +162,7 @@ await check('the portable file downloads as an attachment, and an import goes ba
assert.ok(page.text.includes('Download memory.json'), 'the export link');
assert.ok(page.text.includes('Download memory.json'), 'the export link');
assert.ok(page.text.includes('Import a portable file'), 'the import form');
assert.ok(page.text.includes('Import a portable file'), 'the import form');
assert.ok(page.text.includes('memory-import.js'), 'the file picker script');
assert.ok(page.text.includes('memory-import.js'), 'the file picker script');
+ assert.ok(page.text.includes('id="memory-import-picker"') && page.text.includes('ov-upload.js'), 'portable JSON uses the shared local picker');
const file = await h.get('/memory/export', { as: user });
const file = await h.get('/memory/export', { as: user });
assert.strictEqual(file.status, 200);
assert.strictEqual(file.status, 200);
@@ -344,6 +344,7 @@ await check('the Import tab walks through the three assistants and offers the pa
@@ -344,6 +344,7 @@ await check('the Import tab walks through the three assistants and offers the pa
assert.ok(res.text.includes('Show the prompt to paste'), 'the extraction prompt');
assert.ok(res.text.includes('Show the prompt to paste'), 'the extraction prompt');
assert.ok(res.text.includes('List everything you remember about me'), 'the prompt text');
assert.ok(res.text.includes('List everything you remember about me'), 'the prompt text');
assert.ok(res.text.includes('name="file"') && res.text.includes('enctype="multipart/form-data"'), 'the zip form');
assert.ok(res.text.includes('name="file"') && res.text.includes('enctype="multipart/form-data"'), 'the zip form');
+ assert.ok(res.text.includes('data-memory-zip-picker') && res.text.includes('data-memory-zip-progress'), 'zip preview uses the shared picker and progress');
assert.ok(res.text.includes('Preview the answer'), 'the paste form');
assert.ok(res.text.includes('Preview the answer'), 'the paste form');
assert.ok(res.text.includes('nothing is kept until you tick it'), 'the consent line');
assert.ok(res.text.includes('nothing is kept until you tick it'), 'the consent line');
assert.ok(!res.text.includes('Note number 1'), 'no note text on the import tab');
assert.ok(!res.text.includes('Note number 1'), 'no note text on the import tab');