openvibers/openvibe.actor Commits
Use shared upload kit for Actor files (#440)
Changes
@@@ -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; }@@@ -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();@@@ -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';@@@ -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);@@@ -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)),@@@ -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');@@@ -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);
@@ -108,18 +108,6 @@ body:has(.page-app) > footer, body:has(.page-app) #ov-footer, body:has(.page-app
@@ -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; }
.ct-screen:not(.is-live) .ct-screen-frame { max-width: 420px; }
/* Attached files: chips in the composer before sending, and above the bubble after. */
/* 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. */
/* The model chip (plan L55 C3): what your tasks run on, beside the mode. Its menu opens above the composer. */
.cc-model { position: relative; }
.cc-model { position: relative; }
@@ -137,39 +137,42 @@
@@ -137,39 +137,42 @@
// this file changes the words.
// this file changes the words.
function grow() { if (text) text.dispatchEvent(new Event('input')); }
function grow() { if (text) text.dispatchEvent(new Event('input')); }
if (text) text.addEventListener('input', function () { syncSend(); });
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 = [];
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) {
function readAll(files) {
return Promise.all(files.map(function (f) {
return Promise.all(files.map(function (f) {
@@ -192,7 +195,7 @@
@@ -192,7 +195,7 @@
form.setAttribute('action', w ? '/chat/' + w.getAttribute('data-task') + '/steer' : '/chat');
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');
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());
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();
syncSend();
@@ -209,6 +212,34 @@
@@ -209,6 +212,34 @@
if (text) text.placeholder = 'Reply to Actor';
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 () {
if (stopBtn) stopBtn.addEventListener('click', function () {
var w = working();
var w = working();
if (!w) return;
if (!w) return;
@@ -248,9 +279,8 @@
@@ -248,9 +279,8 @@
(picked.length ? readAll(picked) : Promise.resolve([]))
(picked.length ? readAll(picked) : Promise.resolve([]))
.then(function (files) {
.then(function (files) {
if (files.length) fields.files = 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) {
.then(function (res) {
if (!res.ok || !res.j.turn) { problem(res.j.error || 'That could not be sent. Try again.'); syncSend(); return; }
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]');
var thread = chat.querySelector('[data-ovt-thread]');
@@ -261,7 +291,7 @@
@@ -261,7 +291,7 @@
}
}
var turn = window.OVThread ? window.OVThread.add(res.j.turn) : null;
var turn = window.OVThread ? window.OVThread.add(res.j.turn) : null;
text.value = ''; grow();
text.value = ''; grow();
- picked = []; drawChips();
+ clearPicked();
setParent(res.j.id);
setParent(res.j.id);
if (turn) follow(turn);
if (turn) follow(turn);
syncSend();
syncSend();
@@ -27,17 +27,33 @@
@@ -27,17 +27,33 @@
finally { busy = false; }
finally { busy = false; }
};
};
root.querySelector('[data-decline]').addEventListener('click', () => work(() => finish({ decision: 'decline' })));
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 () => {
root.querySelectorAll('[data-location]').forEach((button) => button.addEventListener('click', () => work(async () => {
if (!navigator.geolocation) throw new Error('This browser cannot read your location.');
if (!navigator.geolocation) throw new Error('This browser cannot read your location.');
const precise = button.dataset.location === 'precise';
const precise = button.dataset.location === 'precise';
@@ -61,7 +61,7 @@ function createAgentPages(ctx) {
@@ -61,7 +61,7 @@ function createAgentPages(ctx) {
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
const signed = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
const signed = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
const owner = (req) => `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 login = (req, res) => res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
const write = (req, res) => {
const write = (req, res) => {
if (!signed(req)) { login(req, res); return false; }
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
@@ -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.');
: (a.state === 'declined' ? 'You declined this.' : 'Nobody answered this one in time.');
const buttons = a.state === 'pending'
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'}">
? 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>` : ''}`}
: 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>
<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>`
<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
@@ -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>
: 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>` : ''}` : ''}
${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>`)}
<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) => {
r.get('/me/approvals/:id', async (req, res) => {
if (!signed(req)) return login(req, res);
if (!signed(req)) return login(req, res);
@@ -548,11 +548,9 @@ ${shown.length ? html`<div class="ct-trig-runs">${shown}</div>` : ''}`;
@@ -548,11 +548,9 @@ ${shown.length ? html`<div class="ct-trig-runs">${shown}</div>` : ''}`;
</select></label>`;
</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';
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 } = {}) {
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>
<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>`;
<span class="cc-note" data-cc-note></span>`;
return raw(thread.composer({
return raw(thread.composer({
@@ -565,7 +563,7 @@ ${modelChip(model)}
@@ -565,7 +563,7 @@ ${modelChip(model)}
busy,
busy,
stopAction: working ? `/tasks/${working}/cancel` : '',
stopAction: working ? `/tasks/${working}/cancel` : '',
hidden: { parent },
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),
tools: String(tools),
note: `Actor checks each answer with a second model.${allowance ? ` ${allowance}` : ''}`,
note: `Actor checks each answer with a second model.${allowance ? ` ${allowance}` : ''}`,
}));
}));
@@ -612,8 +610,8 @@ ${modelChip(model)}
@@ -612,8 +610,8 @@ ${modelChip(model)}
return send(res, status, {
return send(res, status, {
viewer: req.viewer, config, path: req.originalUrl, title, app: true, noReferrer: true,
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).
// 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>') : ''}
body: html`${busy && !shared ? raw('<noscript><meta http-equiv="refresh" content="5"></noscript>') : ''}
<div class="chat" data-chat="${first ? first.id : ''}">${raw(thread.page({
<div class="chat" data-chat="${first ? first.id : ''}">${raw(thread.page({
sidebar: shared ? '' : String(sidebar(chats, first ? first.id : null)),
sidebar: shared ? '' : String(sidebar(chats, first ? first.id : null)),
@@ -62,6 +62,9 @@ const LOC = /^\/chat\/(tsk_[0-9A-HJKMNP-TV-Z]{26})#t-\1$/;
@@ -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, /<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, /<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, /<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, /Your chats show here\./);
assert.match(r.text, /<main id="main" class="page page-app">/, 'the app layout: the whole width, no crumbs');
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');
assert.ok(!/name="parent"/.test(r.text), 'a new chat follows nothing');
@@ -87,6 +87,10 @@ const PHOTO = 'med_01JAB2C3D4E5F6G7H8J9K0MNPS';
@@ -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 () => {
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 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 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 path = `/api/v1/me/approvals/${row.id}`;
const answer = { decision: 'answer', photo: { media_id: PHOTO } };
const answer = { decision: 'answer', photo: { media_id: PHOTO } };
assert.strictEqual((await t.get(`${path}/answer`, { as: ada, headers: SAME, json: answer })).status, 422);
assert.strictEqual((await t.get(`${path}/answer`, { as: ada, headers: SAME, json: answer })).status, 422);