openvibers/openvibe.codes Commits

Use shared upload kit for Codes attachments and imports (#420)

OP OpenVibers committed 1 days ago · 1d6842de98 · 12 files, +187 −156

* Use shared upload kit for Codes attachments and imports

* Make shared upload retry controls resubmit failed files

* Keep media library link and update shared picker coverage

* Allow shared picker media previews under Codes CSP

Changes

ddiff --git a/public/js/c4.js b/public/js/c4.js
iindex 89dd9a4..b2023f5 100644
---- a/public/js/c4.js
++++ b/public/js/c4.js
@@@ -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 multipart
event.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; }
});
});
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex 2cd3ec0..08870dd 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -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;
ddiff --git a/public/js/memory-import.js b/public/js/memory-import.js
iindex 4b69403..1413041 100644
---- a/public/js/memory-import.js
++++ b/public/js/memory-import.js
@@@ -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);
++ });
});
}());
ddiff --git a/server/app.js b/server/app.js
iindex b87a409..9069841 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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'],
ddiff --git a/server/http/c4.js b/server/http/c4.js
iindex ed078c7..357e910 100644
---- a/server/http/c4.js
++++ b/server/http/c4.js
@@@ -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'] });
}
ddiff --git a/server/http/chat.js b/server/http/chat.js
iindex 31eaed7..6a9b5a5 100644
---- a/server/http/chat.js
++++ b/server/http/chat.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. */
ddiff --git a/server/http/memory.js b/server/http/memory.js
iindex 0e42602..e51911a 100644
---- a/server/http/memory.js
++++ b/server/http/memory.js
@@@ -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>';
ddiff --git a/test/attachments.test.js b/test/attachments.test.js
iindex f84b7df..cd5d022 100644
---- a/test/attachments.test.js
++++ b/test/attachments.test.js
@@@ -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 () => {
ddiff --git a/test/chat.test.js b/test/chat.test.js
iindex 688bbe1..5cd857a 100644
---- a/test/chat.test.js
++++ b/test/chat.test.js
@@@ -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');
});
ddiff --git a/test/dock-tasks.test.js b/test/dock-tasks.test.js
iindex 7778f92..20e0864 100644
---- a/test/dock-tasks.test.js
++++ b/test/dock-tasks.test.js
@@@ -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');
ddiff --git a/test/memory-m5.test.js b/test/memory-m5.test.js
iindex 2bde75c..8b89cd6 100644
---- a/test/memory-m5.test.js
++++ b/test/memory-m5.test.js
@@@ -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('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);
ddiff --git a/test/memory.test.js b/test/memory.test.js
iindex 0e774e6..d0bc244 100644
---- a/test/memory.test.js
++++ b/test/memory.test.js
@@@ -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('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');