openvibers/openvibe.actor Commits

Use shared upload kit for Actor files (#440)

OP OpenVibers committed 1 days ago · 8663fa1b3a · 7 files, +106 −67

Changes

ddiff --git a/public/css/chat.css b/public/css/chat.css
iindex 6be94c4..7f98852 100644
---- a/public/css/chat.css
++++ b/public/css/chat.css
@@@ -108,18 +108,6 @@ body:has(.page-app) > footer, body:has(.page-app) #ov-footer, body:has(.page-app
.ct-screen:not(.is-live) .ct-screen-frame { max-width: 420px; }
/* Attached files: chips in the composer before sending, and above the bubble after. */
--.cc-attach { flex: none; display: grid; place-items: center; width: 34px; height: 34px; border-radius: 50%; border: 1px solid var(--line); background: none; color: var(--text-secondary, #b4c0d4); }
--.cc-attach:hover { color: var(--text-primary, #e6edf7); border-color: var(--accent, #f97316); }
--.cc-attach[hidden] { display: none; }
--.cc-attach:disabled { opacity: .4; }
--.cc-attach svg { width: 17px; height: 17px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
--.cc-files { list-style: none; display: flex; flex-wrap: wrap; gap: 6px; margin: 2px 0 6px; padding: 0; }
--.cc-files[hidden] { display: none; }
--.cc-files li { display: inline-flex; align-items: center; gap: 6px; max-width: 220px; padding: 3px 4px 3px 6px; border-radius: 10px; background: var(--bg-secondary, #0f1729); border: 1px solid var(--line); font-size: 13px; }
--.cc-files img { width: 28px; height: 28px; object-fit: cover; border-radius: 6px; }
--.cc-files span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
--.cc-files button { flex: none; width: 22px; height: 22px; border-radius: 50%; border: 0; background: none; color: var(--text-muted, #7386a3); font-size: 16px; line-height: 1; }
--.cc-files button:hover { background: var(--bg-hover, color-mix(in srgb, var(--text-primary, #fff) 8%, transparent)); color: var(--text-primary, #e6edf7); }
/* The model chip (plan L55 C3): what your tasks run on, beside the mode. Its menu opens above the composer. */
.cc-model { position: relative; }
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex 35dceae..2b3e467 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -137,39 +137,42 @@
// this file changes the words.
function grow() { if (text) text.dispatchEvent(new Event('input')); }
if (text) text.addEventListener('input', function () { syncSend(); });
-- // ── Files: the paperclip opens the picker; each file is a chip until it is sent (at most 4, 6 MB each).
-- var attachBtn = chat.querySelector('[data-cc-attach]');
-- var fileInput = chat.querySelector('[data-cc-files]');
-- var chips = chat.querySelector('[data-cc-chips]');
++ // ── Files: the shared picker owns the drop zone, chips and removal.
++ var uploadRoot = chat.querySelector('[data-cc-upload]');
var picked = [];
-- function drawChips() {
-- if (!chips) return;
-- chips.textContent = '';
-- chips.hidden = !picked.length;
-- picked.forEach(function (f, i) {
-- var li = document.createElement('li');
-- if (/^image\//.test(f.type)) { // a data: URL: the page's CSP allows data: images, not blob:
-- var im = document.createElement('img'); im.alt = ''; li.appendChild(im);
-- var rd = new FileReader(); rd.onload = function () { im.src = String(rd.result); }; rd.readAsDataURL(f);
++ var meters = new Map();
++ var picker = uploadRoot && window.OVUpload && OVUpload.picker(uploadRoot, {
++ label: 'Attach files', accept: uploadRoot.dataset.accept, multiple: true,
++ onFile: function (file, row) {
++ var allowed = /^text\//.test(file.type) || /^application\/(pdf|json|xml|x-yaml|yaml|javascript|sql)$/.test(file.type) ||
++ /^image\/(png|jpeg|webp|gif)$/.test(file.type) ||
++ /\.(txt|md|markdown|csv|tsv|json|jsonl|ya?ml|toml|xml|html?|css|js|mjs|cjs|ts|tsx|jsx|py|rb|go|rs|java|kt|c|h|cpp|hpp|cs|php|sh|sql|log|ini|env\.example|srt|vtt|pdf)$/i.test(file.name);
++ var total = picked.reduce(function (sum, item) { return sum + item.size; }, 0) + file.size;
++ if (!allowed || picked.length >= 4 || file.size > 6 * 1048576 || total > 12 * 1048576) {
++ row.dispatchEvent(new Event('remove')); row.remove();
++ problem(!allowed ? 'Actor reads text, code, CSV, JSON, PDFs and PNG, JPEG, WebP or GIF images.' :
++ picked.length >= 4 ? 'Attach at most 4 files to one message.' :
++ file.size > 6 * 1048576 ? file.name + ' is over 6 MB.' : 'The files together are over 12 MB.');
++ return;
}
-- var name = document.createElement('span'); name.textContent = f.name; li.appendChild(name);
-- var x = document.createElement('button'); x.type = 'button'; x.setAttribute('aria-label', 'Remove ' + f.name); x.textContent = '\u00d7';
-- x.addEventListener('click', function () { picked.splice(i, 1); drawChips(); syncSend(); });
-- li.appendChild(x); chips.appendChild(li);
-- });
-- }
-- if (attachBtn && fileInput && window.FileReader) {
-- attachBtn.hidden = false;
-- attachBtn.addEventListener('click', function () { fileInput.click(); });
-- fileInput.addEventListener('change', function () {
-- Array.prototype.forEach.call(fileInput.files || [], function (f) {
-- if (picked.length >= 4) { problem('Attach at most 4 files to one message.'); return; }
-- if (f.size > 6 * 1048576) { problem(f.name + ' is over 6 MB.'); return; }
-- picked.push(f);
-- });
-- fileInput.value = '';
-- drawChips(); syncSend();
-- });
++ problem(null);
++ picked.push(file);
++ var meterRoot = document.createElement('div'); row.append(meterRoot);
++ var meter = OVUpload.progress(meterRoot, { compact: true, onRetry: function () { form.requestSubmit(); } });
++ meter.update({ state: 'waiting', total: file.size });
++ meters.set(file, meter);
++ syncSend();
++ },
++ onRemove: function (file) {
++ picked = picked.filter(function (item) { return item !== file; });
++ if (meters.has(file)) { meters.get(file).destroy(); meters.delete(file); }
++ syncSend();
++ }
++ });
++ function clearPicked() {
++ picked = [];
++ meters.forEach(function (meter) { meter.destroy(); }); meters.clear();
++ if (picker) picker.rows.replaceChildren();
}
function readAll(files) {
return Promise.all(files.map(function (f) {
@@@ -192,7 +195,7 @@
form.setAttribute('action', w ? '/chat/' + w.getAttribute('data-task') + '/steer' : '/chat');
if (text) text.placeholder = w ? 'Tell Actor something while it works' : (form.querySelector('input[name=parent]') ? 'Reply to Actor' : 'Ask Actor to do something');
send.disabled = !(text && text.value.trim());
-- if (attachBtn) attachBtn.disabled = !!w;
++ if (picker) { picker.input.disabled = !!w; uploadRoot.querySelector('.ov-upload__picker').disabled = !!w; }
}
syncSend();
@@@ -209,6 +212,34 @@
if (text) text.placeholder = 'Reply to Actor';
}
++ function sendChat(fields) {
++ return new Promise(function (resolve, reject) {
++ var xhr = new XMLHttpRequest();
++ xhr.open('POST', '/chat');
++ xhr.setRequestHeader('accept', 'application/json');
++ xhr.setRequestHeader('content-type', 'application/json');
++ xhr.upload.onprogress = function (event) {
++ if (!event.lengthComputable) return;
++ var fraction = event.total ? event.loaded / event.total : 0;
++ meters.forEach(function (meter, file) {
++ meter.update({ state: 'sending', done: Math.round(file.size * fraction), total: file.size });
++ });
++ };
++ xhr.upload.onload = function () {
++ meters.forEach(function (meter, file) { meter.update({ state: 'processing', done: file.size, total: file.size, message: 'Actor is reading the file' }); });
++ };
++ xhr.onload = function () {
++ var data;
++ try { data = JSON.parse(xhr.responseText); } catch (e) { data = { error: 'That could not be sent. Try again.' }; }
++ if (xhr.status >= 200 && xhr.status < 300) meters.forEach(function (meter, file) { meter.update({ state: 'done', done: file.size, total: file.size }); });
++ else meters.forEach(function (meter) { meter.update({ state: 'failed', message: data.error || 'That could not be sent. Try again.' }); });
++ resolve({ ok: xhr.status >= 200 && xhr.status < 300, j: data });
++ };
++ xhr.onerror = function () { meters.forEach(function (meter) { meter.update({ state: 'failed', message: 'No connection. Try again.' }); }); reject(new Error('No connection')); };
++ xhr.send(JSON.stringify(fields));
++ });
++ }
++
if (stopBtn) stopBtn.addEventListener('click', function () {
var w = working();
if (!w) return;
@@@ -248,9 +279,8 @@
(picked.length ? readAll(picked) : Promise.resolve([]))
.then(function (files) {
if (files.length) fields.files = files;
-- return fetch('/chat', { method: 'POST', credentials: 'same-origin', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify(fields) });
++ return sendChat(fields);
})
-- .then(function (r) { return r.json().catch(function () { return { error: 'That could not be sent. Try again.' }; }).then(function (j) { return { ok: r.ok, j: j }; }); })
.then(function (res) {
if (!res.ok || !res.j.turn) { problem(res.j.error || 'That could not be sent. Try again.'); syncSend(); return; }
var thread = chat.querySelector('[data-ovt-thread]');
@@@ -261,7 +291,7 @@
}
var turn = window.OVThread ? window.OVThread.add(res.j.turn) : null;
text.value = ''; grow();
-- picked = []; drawChips();
++ clearPicked();
setParent(res.j.id);
if (turn) follow(turn);
syncSend();
ddiff --git a/public/js/device-approval.js b/public/js/device-approval.js
iindex 53fda2f..a0b4018 100644
---- a/public/js/device-approval.js
++++ b/public/js/device-approval.js
@@@ -27,17 +27,33 @@
finally { busy = false; }
};
root.querySelector('[data-decline]').addEventListener('click', () => work(() => finish({ decision: 'decline' })));
-- const photo = root.querySelector('[data-photo]');
-- if (photo) photo.addEventListener('change', () => work(async () => {
-- const file = photo.files && photo.files[0];
-- if (!file) return;
-- if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type) || file.size > 10 * 1024 * 1024) {
-- throw new Error('Choose one JPEG, PNG or WebP photo of at most 10 MB.');
-- }
-- say('Sending your photo…');
-- const uploaded = await send('/photo', file, file.type);
-- await finish({ decision: 'answer', photo: { media_id: uploaded.media_id } });
-- }));
++ const photo = root.querySelector('[data-photo-picker]');
++ if (photo && window.OVUpload) {
++ const queue = OVUpload.queue(photo, {
++ label: 'Take or choose a photo', accept: 'image/jpeg,image/png,image/webp', multiple: false,
++ upload: (file, { onProgress, signal }) => new Promise((resolve, reject) => {
++ if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type) || file.size > 10 * 1024 * 1024) {
++ reject(new Error('Choose one JPEG, PNG or WebP photo of at most 10 MB.'));
++ return;
++ }
++ const xhr = new XMLHttpRequest();
++ xhr.open('POST', `${base}/photo`);
++ xhr.setRequestHeader('Content-Type', file.type);
++ xhr.upload.onprogress = (event) => { if (event.lengthComputable) onProgress({ uploadedBytes: event.loaded }); };
++ xhr.onload = () => {
++ let data;
++ try { data = JSON.parse(xhr.responseText); } catch (_) { data = {}; }
++ if (xhr.status >= 200 && xhr.status < 300) resolve(data);
++ else reject(new Error(data.detail || 'The photo could not be sent. Try again.'));
++ };
++ xhr.onerror = () => reject(new Error('The connection stopped. Try again.'));
++ signal.addEventListener('abort', () => xhr.abort(), { once: true });
++ xhr.send(file);
++ }),
++ onDone: (_file, uploaded) => work(() => finish({ decision: 'answer', photo: { media_id: uploaded.media_id } })),
++ });
++ queue.picker.input.setAttribute('capture', 'environment');
++ }
root.querySelectorAll('[data-location]').forEach((button) => button.addEventListener('click', () => work(async () => {
if (!navigator.geolocation) throw new Error('This browser cannot read your location.');
const precise = button.dataset.location === 'precise';
ddiff --git a/server/http/agent-pages.js b/server/http/agent-pages.js
iindex 012a5a9..9271f5e 100644
---- a/server/http/agent-pages.js
++++ b/server/http/agent-pages.js
@@@ -61,7 +61,7 @@ function createAgentPages(ctx) {
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
const signed = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
const owner = (req) => `user:${req.viewer.subject}`;
-- const page = (req, res, title, body, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, title, body, css: ['css/agents.css', 'css/skill-draft.css'] });
++ const page = (req, res, title, body, status = 200, extra = {}) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, title, body, css: ['css/agents.css', 'css/skill-draft.css'], ...extra });
const login = (req, res) => res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
const write = (req, res) => {
if (!signed(req)) { login(req, res); return false; }
@@@ -208,7 +208,7 @@ ${rows.length ? html`<ul class="pa-cards">${rows.map((a) => html`<li class="pa-c
: (a.state === 'declined' ? 'You declined this.' : 'Nobody answered this one in time.');
const buttons = a.state === 'pending'
? device ? html`<div class="pa-actions" data-device-approval="${a.id}" data-kind="${a.kind}" data-precise="${p && p.precise === true ? 'true' : 'false'}">
--${a.kind === 'device.camera' ? html`<label class="pa-btn pa-primary">Take a photo <input type="file" accept="image/jpeg,image/png,image/webp" capture="environment" data-photo hidden></label>`
++${a.kind === 'device.camera' ? html`<div data-photo-picker></div><p class="pa-hint">JPEG, PNG or WebP · up to 10 MB.</p><p role="alert" data-photo-error hidden></p>`
: html`<button type="button" class="pa-btn pa-primary" data-location="coarse">Share where I am once</button>${p && p.precise === true ? html`<button type="button" class="pa-btn" data-location="precise">Share precise location once</button>` : ''}`}
<button type="button" class="pa-btn" data-decline>Decline</button><span role="status" data-status></span></div>
<p class="pa-hint">${a.kind === 'device.camera' ? 'The photo is taken only when you tap and choose it. It is stripped of metadata.' : 'Your location is read once when you tap. Approximate sharing rounds it to two decimal places.'} This request can never be always allowed.</p><script src="/js/device-approval.js" defer></script>`
@@@ -229,7 +229,7 @@ ${money ? moneyCard : (coding
: html`<section class="pa-panel"><h2>${device ? 'Why your agent asks' : 'What it would do'}</h2><p>${device ? (p && p.why || a.summary) : a.summary}</p>
${cmd ? html`<p class="pa-cmd-where">${where ? html`On <b>${where}</b>, it runs:` : 'It runs:'}</p><pre class="pa-pre pa-cmd">${cmd}</pre>${p && p.cwd ? html`<p class="pa-hint">In ${String(p.cwd).slice(0, 300)}</p>` : ''}` : ''}
<h3 class="pa-sub">The exact request</h3><pre class="pa-pre">${JSON.stringify(p, null, 2).slice(0, 8000)}</pre></section>`)}
--${buttons}`, status);
++${buttons}`, status, a.state === 'pending' && a.kind === 'device.camera' ? { styles: ['ov-upload.css'], sharedScripts: ['ov-upload.js'] } : {});
};
r.get('/me/approvals/:id', async (req, res) => {
if (!signed(req)) return login(req, res);
ddiff --git a/server/http/chat-pages.js b/server/http/chat-pages.js
iindex 1c98305..aed19d0 100644
---- a/server/http/chat-pages.js
++++ b/server/http/chat-pages.js
@@@ -548,11 +548,9 @@ ${shown.length ? html`<div class="ct-trig-runs">${shown}</div>` : ''}`;
</select></label>`;
}
-- const ATTACH_ICON = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 11.5 12.4 19a5 5 0 0 1-7.1-7.1l8-8a3.4 3.4 0 0 1 4.8 4.8l-7.9 7.9a1.7 1.7 0 0 1-2.4-2.4L15 7"/></svg>';
const ATTACH_ACCEPT = '.txt,.md,.csv,.tsv,.json,.jsonl,.yaml,.yml,.toml,.xml,.html,.css,.js,.ts,.py,.rb,.go,.rs,.java,.c,.cpp,.cs,.php,.sh,.sql,.log,.srt,.vtt,text/*,.pdf,application/pdf,image/png,image/jpeg,image/webp,image/gif';
function composer({ parent = null, mode = 'balanced', busy = false, working = null, problem = null, text = '', model = null, allowance = null } = {}) {
-- const tools = html`<button type="button" class="cc-attach" data-cc-attach aria-label="Attach files" title="Attach text, code, CSV, JSON, PDFs or images" hidden>${raw(ATTACH_ICON)}</button><input type="file" multiple hidden data-cc-files accept="${ATTACH_ACCEPT}">
--${modelChip(model)}
++ const tools = html`${modelChip(model)}
<label class="cc-mode" title="How Actor picks the agent"><span class="sr-only">Mode</span><select name="mode" data-cc-mode>${MODES.map((m) => html`<option value="${m}" title="${MODE_HINT[m] || ''}"${m === mode ? raw(' selected') : ''}>${MODE_LABEL[m] || m}</option>`)}</select></label>
<span class="cc-note" data-cc-note></span>`;
return raw(thread.composer({
@@@ -565,7 +563,7 @@ ${modelChip(model)}
busy,
stopAction: working ? `/tasks/${working}/cancel` : '',
hidden: { parent },
-- top: String(html`${problem ? html`<p class="cc-problem" role="alert">${problem}</p>` : ''}<ul class="cc-files" data-cc-chips hidden></ul>`),
++ top: String(html`${problem ? html`<p class="cc-problem" role="alert">${problem}</p>` : ''}<div data-cc-upload data-accept="${ATTACH_ACCEPT}"></div><p class="cc-note">Text, code, CSV, JSON, PDF or PNG, JPEG, WebP, GIF images · up to 4 files, 6 MB each, 12 MB total.</p>`),
tools: String(tools),
note: `Actor checks each answer with a second model.${allowance ? ` ${allowance}` : ''}`,
}));
@@@ -612,8 +610,8 @@ ${modelChip(model)}
return send(res, status, {
viewer: req.viewer, config, path: req.originalUrl, title, app: true, noReferrer: true,
// The network's sidebar (kit.css, kit-web.js) and its conversation UI (thread.css, ov-thread.js).
-- css: ['css/chat.css', 'css/skill-draft.css'], scripts: ['js/chat.js'], styles: ['kit.css', 'thread.css', 'ov-share.css'],
-- sharedScripts: ['kit-web.js', 'ov-thread.js', 'ov-share.js', 'ov-canvas.js'],
++ css: ['css/chat.css', 'css/skill-draft.css'], scripts: ['js/chat.js'], styles: ['kit.css', 'thread.css', 'ov-share.css', 'ov-upload.css'],
++ sharedScripts: ['kit-web.js', 'ov-thread.js', 'ov-share.js', 'ov-canvas.js', 'ov-upload.js'],
body: html`${busy && !shared ? raw('<noscript><meta http-equiv="refresh" content="5"></noscript>') : ''}
<div class="chat" data-chat="${first ? first.id : ''}">${raw(thread.page({
sidebar: shared ? '' : String(sidebar(chats, first ? first.id : null)),
ddiff --git a/test/chat.test.js b/test/chat.test.js
iindex 448eacc..45fb000 100644
---- a/test/chat.test.js
++++ b/test/chat.test.js
@@@ -62,6 +62,9 @@ const LOC = /^\/chat\/(tsk_[0-9A-HJKMNP-TV-Z]{26})#t-\1$/;
assert.match(r.text, /<form class="ovt-composer" method="post" action="\/chat" data-ovt-composer>/);
assert.match(r.text, /<link rel="stylesheet" href="\/shared\/thread\.css(\?v=[0-9a-f]+)?">/, 'the network\'s conversation UI (L55 C4)');
assert.match(r.text, /<script src="\/shared\/ov-thread\.js(\?v=[0-9a-f]+)?" defer><\/script>/);
++ assert.match(r.text, /\/shared\/ov-upload\.css/);
++ assert.match(r.text, /\/shared\/ov-upload\.js/);
++ assert.match(r.text, /data-cc-upload data-accept=/);
assert.match(r.text, /Your chats show here\./);
assert.match(r.text, /<main id="main" class="page page-app">/, 'the app layout: the whole width, no crumbs');
assert.ok(!/name="parent"/.test(r.text), 'a new chat follows nothing');
ddiff --git a/test/device-approvals.test.js b/test/device-approvals.test.js
iindex 42f0a74..017dde6 100644
---- a/test/device-approvals.test.js
++++ b/test/device-approvals.test.js
@@@ -87,6 +87,10 @@ const PHOTO = 'med_01JAB2C3D4E5F6G7H8J9K0MNPS';
await check('a photo must be uploaded for its own approval before its media id is answered', async () => {
const asked = await device.ask(run, 'device.camera', { why: 'Read the street sign' });
const row = await approvals.get(t.ctx.s, owner, asked.data.approval_id);
++ const page = await t.get(`/me/approvals/${row.id}`, { as: ada });
++ assert.match(page.text, /data-photo-picker/);
++ assert.match(page.text, /ov-upload\.css/);
++ assert.match(page.text, /ov-upload\.js/);
const path = `/api/v1/me/approvals/${row.id}`;
const answer = { decision: 'answer', photo: { media_id: PHOTO } };
assert.strictEqual((await t.get(`${path}/answer`, { as: ada, headers: SAME, json: answer })).status, 422);