openvibers/openvibe.chat Commits

Vibe: attach any file through the shared upload kit (#457)

OP OpenVibers committed 3 days ago · ff6ca42392 · 17 files, +308 −65

* Vibe composer attaches any file through the shared upload kit

* Keep the file readers on the message author

* Merge remote-tracking branch 'origin/main' into chat-files

# Conflicts:
#	public/web/vibe.css
#	public/web/vibe.js
#	server/web/vibe-pages.js
#	test/vibe-pictures.test.js

* Sequence the file tray migration after server emojis

* Merge remote-tracking branch 'origin/main' into chat-files

* Load the upload kit once after the kit merge

* Sequence the file tray migration after stage channels

* Merge remote-tracking branch 'origin/main' into chat-files

* Merge remote-tracking branch 'origin/main' into chat-files

# Conflicts:
#	server/web/vibe-pages.js

* Sequence the file tray migration after forum channels

* Merge remote-tracking branch 'origin/main' into chat-files

* Merge remote-tracking branch 'origin/main' into chat-files

# Conflicts:
#	package-lock.json
#	package.json
#	public/web/vibe.js

* Merge remote-tracking branch 'origin/main' into chat-files

# Conflicts:
#	package-lock.json
#	package.json

Changes

ddiff --git a/migrations/0060_room_message_files.sql b/migrations/0060_room_message_files.sql
nnew file mode 100644
iindex 0000000..0fcfe1b
---- /dev/null
++++ b/migrations/0060_room_message_files.sql
@@@ -0,0 +1,4 @@
++-- phase: expand
++-- Files attached to a chat message (ai.file@1 references, stored in Media): the Vibe composer's tray
++-- sends them with the message, and Vibe's turn reads them by reference. The bytes never live here.
++ALTER TABLE room_messages ADD COLUMN IF NOT EXISTS files jsonb NOT NULL DEFAULT '[]'::jsonb;
ddiff --git a/package-lock.json b/package-lock.json
iindex 31b4961..2edfd02 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -18,7 +18,7 @@
"iovalkey": "^0.4.0",
"multer": "^2.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.384.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.133.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.135.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.185.0",
"pg": "^8.23.0",
"ws": "^8.18.0"
@@@ -1573,9 +1573,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.133.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.133.0",
-- "integrity": "sha512-qn+y/yQnlYkfm0+tDpKLwUw04L0fZydcyYiEM6OaXzc7iclgp2HAcBB/OkmPXWOCwXGyv2CBQAJeEVesgkWBFA==",
++ "version": "0.135.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.135.0",
++ "integrity": "sha512-kilql6sVqvfqBBqjX0I+uyrNIkBq+ZFY+K1QieX6WzKLGgGRW5NepvyY6Uw+raCaeugcYZ3eeZMiwnZBeQH++Q==",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
ddiff --git a/package.json b/package.json
iindex 947c692..f70c7a3 100644
---- a/package.json
++++ b/package.json
@@@ -30,7 +30,7 @@
"iovalkey": "^0.4.0",
"multer": "^2.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.384.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.133.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.135.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.185.0",
"pg": "^8.23.0",
"ws": "^8.18.0"
ddiff --git a/public/web/vibe.css b/public/web/vibe.css
iindex f20b9df..79cbdca 100644
---- a/public/web/vibe.css
++++ b/public/web/vibe.css
@@@ -273,6 +273,8 @@
@media (min-width: 1180px) { .vbk .vbc-phone { display: none !important; } }
@media (max-width: 1179px) { .vbk .vbc-wide { display: none !important; } }
++/* Files attached to a Vibe message: the shared upload kit's rows sit above the composer (vibe.js). */
++.vb-files { display: grid; gap: 8px; margin: 0 0 8px; }
/* The shared picker in a sheet over the Vibe composer. */
/* Add a file: the kit's sheet (a bottom sheet on a phone), a little narrower than its 560px on a computer. */
.ovk-sheet.vb-upload { width: min(440px, 100vw); }
ddiff --git a/public/web/vibe.js b/public/web/vibe.js
iindex 6892e86..6abf14a 100644
---- a/public/web/vibe.js
++++ b/public/web/vibe.js
@@@ -23,10 +23,12 @@
var latest = Number(cfg.latest) || 0;
var ws = null, delay = 1000, sending = false, waitTimer = null;
var previewEl = document.getElementById('vb-preview');
++ var filesTray = document.getElementById('vb-files');
var progressEl = document.querySelector('[data-vb-progress]');
var uploadDialog = document.querySelector('[data-vb-upload-dialog]');
var uploadRoot = document.querySelector('[data-vb-upload]');
var picture = null; // { file, url }: a picture waiting to go with the next message
++ var attachments = []; // { file, row, xhr, ref }: files waiting to go with the next message (Media references)
var reduce = document.documentElement.getAttribute('data-ov-motion') === 'off';
var chosenModels = Array.isArray(cfg.models) ? cfg.models.slice(0, 3) : [];
function persistModelChoice() {
@@@ -120,8 +122,8 @@
// ── The composer ──
if (input) {
-- // A picture alone may be sent: Send wakes for it even with no words (the kit wakes it only for words).
-- input.addEventListener('input', function () { if (picture && sendBtn) sendBtn.disabled = false; });
++ // A picture or a file alone may be sent: Send wakes for it even with no words (the kit wakes it only for words).
++ input.addEventListener('input', function () { if ((picture || attachments.length) && sendBtn) sendBtn.disabled = false; });
var saved = cfg.signedIn ? store(function (s) { var v = s.getItem(DRAFT); s.removeItem(DRAFT); return v; }) : null;
if (saved && !input.value) { input.value = saved; changed(); }
if (!('ontouchstart' in window)) input.focus();
@@@ -139,31 +141,9 @@
if (input) input.required = true;
changed();
}
-- // A text file goes into the message itself, as a fenced block named after it, cut to what still fits in the box.
-- var TEXT_EXT = /\.(txt|md|markdown|csv|tsv|json|log|xml|ya?ml|toml|ini|html|css|m?js|ts|py|go|rs|java|c|h|cpp|rb|php|sh|sql)$/i;
-- function readText(file) {
-- if (file.size > 2 * 1024 * 1024) { notice('A text file can be up to 2 MB.'); return; }
-- var reader = new FileReader();
-- reader.onload = function () {
-- var text = String(reader.result || '').replace(/\r\n?/g, '\n').replace(/\u0000/g, '').trim();
-- if (!text) { notice('That file is empty.'); return; }
-- var name = String(file.name || 'file').replace(/[\r\n`]/g, '').slice(0, 80);
-- var before = input.value.trim();
-- var head = (before ? before + '\n\n' : '') + name + ':\n```\n';
-- var space = Number(input.getAttribute('maxlength') || 12000) - head.length - 5;
-- if (space < 200) { notice('The box is nearly full. Send what is there first.'); return; }
-- var cut = text.length > space;
-- input.value = head + (cut ? text.slice(0, space - 20) + '\n… (cut to fit)' : text) + '\n```';
-- changed(); input.focus();
-- if (cut) notice('The file was longer than a message: its first part is in the box.');
-- };
-- reader.onerror = function () { notice('That file could not be read.'); };
-- reader.readAsText(file);
-- }
function choosePicture(file) {
if (!file) return;
-- if (!PIC_TYPES.test(file.type) && (/^text\//.test(file.type) || file.type === 'application/json' || TEXT_EXT.test(file.name || ''))) { readText(file); return; }
-- if (!PIC_TYPES.test(file.type) || !uploadRoot || !/image\//.test(uploadRoot.getAttribute('data-accept') || '')) { notice('Send a JPEG, PNG, GIF, WebP or AVIF picture, or a text file.'); return; }
++ if (!PIC_TYPES.test(file.type)) { addAttachmentItem(file); return; }
if (file.size > 20 * 1024 * 1024) { notice('A picture can be up to 20 MB.'); return; }
clearPicture();
picture = { file: file, url: URL.createObjectURL(file) };
@@@ -177,20 +157,98 @@
if (sendBtn) sendBtn.disabled = false;
input.focus();
}
++ // ── Any file with the message, through the shared upload kit: each one is a kit row (name, size, remove)
++ // and uploads to Media as the person (POST /api/chat/vibe/files). The message carries its Media reference;
++ // Vibe's turn reads it (ai.file@1). Pictures pasted or picked as pictures keep the Pics path. ──
++ var MAX_FILES = 10;
++ var MAX_FILE_BYTES = 10 * 1024 * 1024;
++ var picker = null;
++ function filesChanged() {
++ if (filesTray) filesTray.hidden = !attachments.length;
++ if (attachments.length) form.classList.remove('is-empty');
++ changed();
++ if (attachments.length && sendBtn) sendBtn.disabled = false;
++ }
++ function findAttachment(file) {
++ for (var i = 0; i < attachments.length; i++) if (attachments[i].file === file) return attachments[i];
++ return null;
++ }
++ function dropAttachment(item) {
++ if (!item) return;
++ var i = attachments.indexOf(item);
++ if (i < 0) return;
++ attachments.splice(i, 1);
++ if (item.xhr) { try { item.xhr.abort(); } catch (_) {} }
++ if (item.row && item.row.parentNode) item.row.remove();
++ filesChanged();
++ }
++ function uploadAttachment(item) {
++ if (item.xhr) { try { item.xhr.abort(); } catch (_) {} }
++ var xhr = new XMLHttpRequest();
++ item.xhr = xhr;
++ xhr.open('POST', '/api/chat/vibe/files');
++ xhr.withCredentials = true;
++ xhr.setRequestHeader('Content-Type', item.file.type || 'application/octet-stream');
++ try { xhr.setRequestHeader('X-Filename', encodeURIComponent(item.file.name || 'file')); } catch (_) {}
++ function failed(message) {
++ if (item.meter) item.meter.update({ state: 'failed', message: message || 'The upload did not go through.' });
++ notice(message || 'The file did not upload. Try again.');
++ }
++ xhr.upload.onprogress = function (e) { if (e.lengthComputable && item.meter) item.meter.update({ state: 'sending', done: e.loaded, total: e.total }); };
++ xhr.onload = function () {
++ var body = null; try { body = JSON.parse(xhr.responseText || 'null'); } catch (_) { body = null; }
++ if (xhr.status < 200 || xhr.status >= 300 || !body || !body.file) return failed((body && body.error) || 'The file did not upload.');
++ item.ref = body.file;
++ if (item.meter) item.meter.update({ state: 'done', done: item.file.size, total: item.file.size });
++ filesChanged();
++ };
++ xhr.onerror = function () { failed('You seem to be offline. The file did not upload.'); };
++ if (item.meter) item.meter.update({ state: 'sending', done: 0, total: item.file.size });
++ xhr.send(item.file);
++ }
++ function addAttachmentItem(file) {
++ if (!file) return;
++ if (attachments.length >= MAX_FILES) { notice('Up to ' + MAX_FILES + ' files can go with a message.'); return; }
++ if (file.size > MAX_FILE_BYTES) { notice('A file can be up to 10 MB.'); return; }
++ if (!picker) return;
++ picker.add([file]);
++ }
++ function takeRow(file, row) {
++ var item = { file: file, row: row, ref: null, xhr: null, meter: null };
++ row.remove(); filesTray.appendChild(row); filesTray.hidden = false;
++ row.addEventListener('remove', function () { dropAttachment(item); });
++ var meterRoot = el('div', 'ov-upload__meter'); row.appendChild(meterRoot); row.classList.add('has-meter');
++ item.meter = window.OVUpload.progress(meterRoot, { compact: true, onRetry: function () { uploadAttachment(item); } });
++ attachments.push(item);
++ filesChanged();
++ uploadAttachment(item);
++ }
++ function clearAttachments() {
++ attachments.slice().forEach(function (item) { if (item.xhr) { try { item.xhr.abort(); } catch (_) {} } if (item.row && item.row.parentNode) item.row.remove(); });
++ attachments = [];
++ filesChanged();
++ }
++ function pendingFiles() { return attachments.some(function (a) { return !a.ref; }); }
var attachBtn = app.querySelector('[data-vb-attach]');
-- if (attachBtn && uploadDialog && uploadRoot && window.OVUpload) {
++ if (attachBtn && uploadDialog && uploadRoot && filesTray && window.OVUpload) {
var meter = progressEl ? window.OVUpload.progress(progressEl) : null;
-- window.OVUpload.picker(uploadRoot, {
-- label: 'Choose a picture or a text file', accept: uploadRoot.getAttribute('data-accept') || '', multiple: false,
-- onFile: function (file) { uploadDialog.close(); choosePicture(file); }
++ picker = window.OVUpload.picker(uploadRoot, {
++ label: 'Choose a file', accept: '', multiple: true,
++ onFile: function (file, row) {
++ uploadDialog.close();
++ if (PIC_TYPES.test(file.type)) choosePicture(file); else takeRow(file, row);
++ },
++ onRemove: function (file) { var item = findAttachment(file); if (item) dropAttachment(item); }
});
attachBtn.addEventListener('click', function () { uploadDialog.showModal(); });
var closeUpload = uploadDialog.querySelector('[data-vb-upload-close]');
if (closeUpload) closeUpload.addEventListener('click', function () { uploadDialog.close(); });
-- // A picture pasted into the composer goes the same way.
++ // A picture pasted into the composer keeps the picture path; any other pasted file attaches.
input.addEventListener('paste', function (e) {
var items = (e.clipboardData && e.clipboardData.files) || [];
-- if (items.length && PIC_TYPES.test(items[0].type)) { e.preventDefault(); choosePicture(items[0]); }
++ if (!items.length) return;
++ e.preventDefault();
++ if (PIC_TYPES.test(items[0].type)) choosePicture(items[0]); else addAttachmentItem(items[0]);
});
} else if (attachBtn) {
attachBtn.hidden = true;
@@@ -661,7 +719,7 @@
if (form) form.addEventListener('submit', function (e) {
if (e.submitter && e.submitter === stopBtn) { e.preventDefault(); return; }
var text = input.value.trim();
-- if (!text && !picture) { e.preventDefault(); return; }
++ if (!text && !picture && !attachments.length) { e.preventDefault(); return; }
if (form.dataset.signin) {
// Signed out: the draft waits in this browser while they sign in, and comes back in the composer.
e.preventDefault();
@@@ -670,16 +728,20 @@
return;
}
if (!window.fetch || sending) return;
++ var modeEl = form.querySelector('input[name="mode"]:checked'), mode = modeEl ? modeEl.value : 'chat';
++ if (mode !== 'chat' && attachments.length) { e.preventDefault(); notice('Files go with a Chat message. Switch to Chat.'); return; }
++ if (pendingFiles()) { e.preventDefault(); notice('Give the files a moment to finish uploading.'); return; }
++ if (picture && attachments.length) { e.preventDefault(); notice('Send the picture or the files, one at a time.'); return; }
e.preventDefault();
sending = true;
var old = app.querySelector('.vb-live-notice'); if (old) old.remove();
var sendingPicture = picture;
++ var sendingFiles = attachments.map(function (a) { return a.ref; });
var mine = meTurn(text, null, sendingPicture && sendingPicture.url);
threadEl().appendChild(mine);
showWait(); toBottom();
input.value = ''; picture = null; if (previewEl) { previewEl.textContent = ''; previewEl.hidden = true; } input.required = true; changed();
var url, init;
-- var modeEl = form.querySelector('input[name="mode"]:checked'), mode = modeEl ? modeEl.value : 'chat';
if (mode !== 'chat' && !sendingPicture) {
// Research, Agent or Code: an agent task of this chat; Vibe says it is on it, and the result comes back here.
url = room ? '/api/chat/vibe/' + encodeURIComponent(room) + '/ask' : '/api/chat/vibe';
@@@ -690,7 +752,7 @@
init = { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': sendingPicture.file.type, 'X-Caption': encodeURIComponent(text) }, body: sendingPicture.file };
} else {
url = room ? '/api/chat/rooms/' + encodeURIComponent(room) + '/messages' : '/api/chat/vibe';
-- init = { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: text, timezone: timezone() }) };
++ init = { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: text, timezone: timezone(), files: sendingFiles }) };
}
function putBack(msg) { mine.remove(); clearWait(); input.value = text; if (sendingPicture) { choosePicture(sendingPicture.file); if (meter) meter.update({ state: 'failed', message: msg }); } changed(); notice(msg); }
var request;
@@@ -705,6 +767,7 @@
.then(function (x) {
if (!x.ok) { putBack((x.d && x.d.error) || 'That did not send. Try again.'); return; }
if (x.d.message && x.d.message.id) { mine.setAttribute('data-me', String(x.d.message.id)); latest = Math.max(latest, Number(x.d.message.id)); }
++ clearAttachments();
// A Research, Agent or Code task just became the chat's newest: its canvas is the one to draw from now on.
if (x.d && x.d.task && !canvasWatch(x.d.task)) { canvas = null; canvasClear(); }
if (!room && x.d.room) {
ddiff --git a/server/media/client.js b/server/media/client.js
iindex e81867f..82a6d2b 100644
---- a/server/media/client.js
++++ b/server/media/client.js
@@@ -38,6 +38,26 @@ function publicUrl(obj) {
return null;
}
++/**
++ * A person's file for a Vibe turn: an UNLISTED Media object owned by them. Unlisted means not in any
++ * listing, but served by its unguessable Media id, which is what OpenVibe.AI reads a turn's file by
++ * reference (a private object would need a signed URL AI cannot mint). → ai.file@1 metadata only.
++ */
++async function uploadFile(buffer, { filename, mimeType, subject } = {}) {
++ if (!subject) throw new Error('uploadFile: subject (the person who owns the file) is required');
++ const obj = await objects().upload(buffer, { kind: 'file', visibility: 'unlisted', mimeType: mimeType || 'application/octet-stream', filename, subject });
++ return {
++ id: obj.id,
++ name: obj.filename || filename || 'file',
++ media_type: obj.mime_type || mimeType || 'application/octet-stream',
++ size: Number(obj.size_bytes) || buffer.length,
++ storage: { provider: 'media', key: obj.id },
++ access: { readers: [String(subject)] },
++ read: { method: 'not_readable', reason: 'Attached; AI has not read it yet.' },
++ role: 'input',
++ };
++}
++
/**
* Upload image bytes as a public Media object → { id, url }.
* Shaped for the emote routes: `id` is stored as media_asset_id, `url` as media_url.
@@@ -53,4 +73,4 @@ async function deleteObject(id) {
try { return await objects().delete(id); } catch (err) { console.warn(`[Media] delete ${id}: ${err.message}`); return false; }
}
--module.exports = { uploadObject, deleteObject, publicUrl, APP, _setClient(c) { _objects = c; }, _reset() { _objects = null; } };
++module.exports = { uploadObject, uploadFile, deleteObject, publicUrl, APP, _setClient(c) { _objects = c; }, _reset() { _objects = null; } };
ddiff --git a/server/rooms/ai-members.js b/server/rooms/ai-members.js
iindex d79cacd..3189880 100644
---- a/server/rooms/ai-members.js
++++ b/server/rooms/ai-members.js
@@@ -314,7 +314,7 @@ const PLAN_DAY = /\b(plan(?:\s+out)?\s+(?:my|the)\s+(?:day|morning|afternoon|eve
* The answer through chat.member.turn with tools: up to MAX_TOOL_ROUNDS rounds of tool calls run here, then a turn
* with tool_choice none. → the reply text, or null when a turn failed (the caller falls back to chat.member.reply).
*/
--async function answerWithTools({ room, member, context, transcript, askedBy, text, opts, subject = null, images = [], user = null, messageId = null, memory = null, timezone = null, called = new Set(), onText = null, steps = null, results = null, signal = null }) {
++async function answerWithTools({ room, member, context, transcript, askedBy, text, opts, subject = null, images = [], files = [], user = null, messageId = null, memory = null, timezone = null, called = new Set(), onText = null, steps = null, results = null, signal = null }) {
const tools = aiTools.definitions(member, room, { memory: memory !== null });
const vibe = Number(room.assistant) === 1;
const tz = timezoneOf(timezone);
@@@ -337,7 +337,7 @@ async function answerWithTools({ room, member, context, transcript, askedBy, tex
for (let round = 0; round <= MAX_TOOL_ROUNDS; round++) {
if (signal && signal.aborted) return null; // stopped (stop()): the caller keeps what was written
const last = round === MAX_TOOL_ROUNDS;
-- const turn = { messages, tools, tool_choice: last ? 'none' : 'auto', max_tokens: vibe ? 1500 : 600, temperature: 0.7 };
++ const turn = { messages, tools, tool_choice: last ? 'none' : 'auto', max_tokens: vibe ? 1500 : 600, temperature: 0.7, ...(files.length ? { files } : {}) };
const turnOpts = { ...opts, idempotencyKey: `${opts.idempotencyKey}-t${round}`, ...(signal ? { signal } : {}) };
// Vibe's answer streams (each round's text so far, to the chat as it is written); other members answer whole.
const out = onText ? await aiClient.stream('chat.member.turn', turn, turnOpts, onText) : await aiClient.structured('chat.member.turn', turn, turnOpts);
@@@ -427,7 +427,8 @@ async function afterPost(room, message, user, deps = {}) {
if (controller) stoppers.set(room.id, controller);
if (config.ai.memberTools) {
const images = await picturesFor(message, history, user).catch(() => []);
-- try { text = clean(await answerWithTools({ room, member, context, transcript, askedBy, text: String(message.message).slice(0, vibe ? 12000 : 2000), opts, subject: user.subject_id || null, images, user, messageId: message.id, memory, timezone: deps.timezone, called, onText, steps, results, signal: controller && controller.signal }) || '', vibe ? 8000 : 2000); } catch { text = ''; }
++ const files = require('./files').filesOf(message);
++ try { text = clean(await answerWithTools({ room, member, context, transcript, askedBy, text: String(message.message).slice(0, vibe ? 12000 : 2000), opts, subject: user.subject_id || null, images, files, user, messageId: message.id, memory, timezone: deps.timezone, called, onText, steps, results, signal: controller && controller.signal }) || '', vibe ? 8000 : 2000); } catch { text = ''; }
}
if (controller && controller.signal.aborted) {
const sofar = clean(pending || '', 7900);
ddiff --git a/server/rooms/files.js b/server/rooms/files.js
nnew file mode 100644
iindex 0000000..1715ce9
---- /dev/null
++++ b/server/rooms/files.js
@@@ -0,0 +1,55 @@
++'use strict';
++/**
++ * Files on a chat message (ai.file@1 references): the Vibe composer's tray uploads a person's file to
++ * Media (server/media/client.js) and sends the reference with the message; server/rooms/rooms.js stores
++ * the references on the row, and Vibe's turn (server/rooms/ai-members.js) hands them to OpenVibe.AI,
++ * which reads them for the model. The bytes never live in Chat's database.
++ *
++ * A reference is the contract shape: id, name, media_type, size, storage {provider:'media', key},
++ * access {readers}, read {method, reason?}, role 'input'. Only a Media object id (med_…) is accepted,
++ * at most MAX_FILES a message; the file's size and type are Media's own, checked again by AI.
++ */
++const MAX_FILES = 10;
++const MEDIA_ID = /^med_[0-9A-HJKMNP-TV-Z]{26}$/;
++const NAME_MAX = 255;
++
++/** A stored file reference as the message keeps it (bytes are not part of it). */
++function cleanOne(file, index, reader = null) {
++ const { RoomError } = require('./rooms'); // required here: rooms.js requires this module at load
++ const raw = file && typeof file === 'object' ? file : {};
++ const id = String(raw.id || (raw.storage && raw.storage.key) || '');
++ if (!MEDIA_ID.test(id)) throw new RoomError(422, 'rooms.file_invalid', `File ${index + 1} must be a stored Media object`);
++ const name = String(raw.name || '').replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, NAME_MAX) || 'file';
++ const mediaType = String(raw.media_type || raw.type || 'application/octet-stream').split(';')[0].trim().slice(0, NAME_MAX) || 'application/octet-stream';
++ const size = Math.max(0, Math.floor(Number(raw.size) || 0));
++ const readers = Array.isArray(raw.access && raw.access.readers) && raw.access.readers.length
++ ? raw.access.readers.map((r) => String(r).slice(0, 200)).slice(0, 50)
++ : [reader].filter(Boolean);
++ return {
++ id,
++ name,
++ media_type: mediaType,
++ size,
++ storage: { provider: 'media', key: id },
++ ...(readers.length ? { access: { readers } } : {}),
++ read: { method: 'not_readable', reason: 'Attached; AI has not read it yet.' },
++ role: 'input',
++ };
++}
++
++/** The files a message may carry (at most MAX_FILES), or [] when it has none. */
++function cleanFiles(files, reader = null) {
++ if (!Array.isArray(files) || !files.length) return [];
++ if (files.length > MAX_FILES) throw new RoomError(413, 'rooms.files_too_many', `At most ${MAX_FILES} files can go with a message`);
++ return files.map((f, i) => cleanOne(f, i, reader));
++}
++
++/** The files stored on a message row (jsonb comes back as an array; a text column is tolerated too). */
++function filesOf(message) {
++ if (!message) return [];
++ let files = message.files;
++ if (typeof files === 'string') { try { files = JSON.parse(files); } catch { files = []; } }
++ return Array.isArray(files) ? files : [];
++}
++
++module.exports = { cleanFiles, filesOf, MAX_FILES };
ddiff --git a/server/rooms/rooms.js b/server/rooms/rooms.js
iindex ad2f6c1..98ab649 100644
---- a/server/rooms/rooms.js
++++ b/server/rooms/rooms.js
@@@ -1,4 +1,5 @@
'use strict';
++const messageFiles = require('./files');
/**
* Chat rooms (roadmap WS-I task 4): rooms people create on openvibe.chat, beside the global room and
* the stream/channel rooms Live's chat already has. Three kinds:
@@@ -267,7 +268,7 @@ async function announceDeleted(room, ids) {
}
}
--async function post(room, user, text, { now = Date.now(), ai = true, timezone = null } = {}) {
++async function post(room, user, text, { now = Date.now(), ai = true, timezone = null, files = [] } = {}) {
const a = await access(room, user);
if (!user) fail(401, 'rooms.sign_in', 'Sign in to chat');
if (siteBanned(user)) fail(403, 'rooms.banned', 'Your account is banned from chat');
@@@ -298,11 +299,12 @@ async function post(room, user, text, { now = Date.now(), ai = true, timezone =
}
const filtered = wordFilter.check(body);
const message = filtered.safe ? body : filtered.filtered;
++ const attached = messageFiles.cleanFiles(files, user.subject_id || null);
// The message's known OpenVibe link, with a second's budget: the card is stored on the row and arrives with the
// broadcast when it is ready; a slower one fills the row later.
const { card, pending } = await linkCards.forSend(message);
const id = await db.tx(async () => {
-- const r = await db.run('INSERT INTO room_messages (room_id, user_id, subject_id, message, card) VALUES (?, ?, ?, ?, ?)', [room.id, user.id, user.subject_id || null, message, linkCards.toJson(card)]);
++ const r = await db.run('INSERT INTO room_messages (room_id, user_id, subject_id, message, card, files) VALUES (?, ?, ?, ?, ?, ?::jsonb)', [room.id, user.id, user.subject_id || null, message, linkCards.toJson(card), JSON.stringify(attached)]);
await db.run('UPDATE rooms SET message_count = message_count + 1, last_message_at = ov_now() WHERE id = ?', [room.id]);
await db.run('UPDATE room_members SET last_read_id = ?, last_seen_at = ov_now() WHERE room_id = ? AND user_id = ?', [r.lastInsertRowid, room.id, user.id]);
await announceMessage(room, user, Number(r.lastInsertRowid), message, now);
@@@ -340,7 +342,7 @@ async function postAsAi(room, member, text, { now = Date.now(), how = null } = {
}
// A message an AI member wrote (ai_member_id; user_id 0) takes the member's name and handle.
--const MSG_SELECT = `SELECT m.id, m.room_id, m.user_id, m.message, m.card, m.created_at, m.is_deleted, m.ai_member_id, m.agent_stream, m.how, m.results, m.vibe_model,
++const MSG_SELECT = `SELECT m.id, m.room_id, m.user_id, m.message, m.card, m.files, m.created_at, m.is_deleted, m.ai_member_id, m.agent_stream, m.how, m.results, m.vibe_model,
CASE WHEN m.ai_member_id IS NOT NULL THEN a.handle WHEN m.agent_stream THEN 'aiagent' ELSE u.username END AS username,
CASE WHEN m.ai_member_id IS NOT NULL THEN a.name WHEN m.agent_stream THEN 'AI agent' ELSE u.display_name END AS display_name,
m.agent_stream, u.profile_color, u.role AS user_role
ddiff --git a/server/rooms/routes.js b/server/rooms/routes.js
iindex aeba8bd..fae381c 100644
---- a/server/rooms/routes.js
++++ b/server/rooms/routes.js
@@@ -205,7 +205,7 @@ router.get('/:slug/messages', optionalAuth, async (req, res) => {
router.post('/:slug/messages', requireAuth, postLimit, async (req, res) => {
const room = await readable(req, res); if (!room) return;
try {
-- const message = await rooms.post(room, req.user, req.body && req.body.message, { timezone: req.body && req.body.timezone });
++ const message = await rooms.post(room, req.user, req.body && req.body.message, { timezone: req.body && req.body.timezone, files: req.body && req.body.files });
chatServer().broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });
res.status(201).json({ message });
} catch (err) { send(res, err); }
ddiff --git a/server/rooms/vibe-routes.js b/server/rooms/vibe-routes.js
iindex 0a96f66..19f6877 100644
---- a/server/rooms/vibe-routes.js
++++ b/server/rooms/vibe-routes.js
@@@ -32,6 +32,7 @@ const rooms = require('./rooms');
const vibe = require('./vibe');
const how = require('./how');
const memory = require('./vibe-memory');
++const media = require('../media/client');
const router = express.Router();
const startLimit = limits('chat.vibe.start', { minute: 10, hour: 120 });
@@@ -55,11 +56,29 @@ router.get('/', requireAuth, personOnly, async (req, res) => {
} catch (err) { send(res, err); }
});
++// A file for the composer's tray: the body is the file (any Content-Type), its name URI-encoded in
++// X-Filename. It goes to Media as the person (unlisted, theirs), and the message carries its reference.
++const MAX_FILE_BYTES = 10 * 1024 * 1024;
++const fileLimit = limits('chat.vibe.file', { minute: 30, hour: 600 });
++router.post('/files', requireAuth, personOnly, fileLimit, (req, res, next) => express.raw({ type: () => true, limit: MAX_FILE_BYTES + 1 })(req, res, (err) => {
++ if (err && err.type === 'entity.too.large') return res.status(413).json({ error: 'A file can be up to 10 MB', code: 'vibe.file_too_large' });
++ return next(err);
++}), async (req, res) => {
++ try {
++ if (!req.user.subject_id) return res.status(403).json({ error: 'Sign in with an OpenVibe account to attach files', code: 'vibe.file_subject' });
++ if (!Buffer.isBuffer(req.body) || !req.body.length) return res.status(422).json({ error: 'Choose a file first', code: 'vibe.file_empty' });
++ const filename = (() => { try { return decodeURIComponent(String(req.get('x-filename') || '')).slice(0, 255); } catch { return String(req.get('x-filename') || '').slice(0, 255); } })() || 'file';
++ const mimeType = String(req.get('content-type') || 'application/octet-stream').split(';')[0].trim().toLowerCase().slice(0, 255) || 'application/octet-stream';
++ const file = await media.uploadFile(req.body, { filename, mimeType, subject: req.user.subject_id });
++ res.status(201).json({ file });
++ } catch (err) { send(res, err); }
++});
++
const modeOf = (b) => (b && b.mode && b.mode !== 'chat' ? String(b.mode) : null);
router.post('/', requireAuth, personOnly, startLimit, async (req, res) => {
try {
const b = req.body || {};
-- const out = modeOf(b) ? await vibe.startWork(req.user, b.message, modeOf(b)) : await vibe.start(req.user, b.message, { timezone: b.timezone, site: 'openvibe.chat' });
++ const out = modeOf(b) ? await vibe.startWork(req.user, b.message, modeOf(b)) : await vibe.start(req.user, b.message, { timezone: b.timezone, site: 'openvibe.chat', files: b.files });
res.status(201).json(out);
} catch (err) { send(res, err); }
});
ddiff --git a/server/rooms/vibe.js b/server/rooms/vibe.js
iindex 729c9ae..018607b 100644
---- a/server/rooms/vibe.js
++++ b/server/rooms/vibe.js
@@@ -116,13 +116,13 @@ async function createRoom(user, title, { site = null } = {}) {
return await db.get('SELECT * FROM rooms WHERE id = ?', [id]);
}
--async function start(user, text, { timezone = null, site = null } = {}) {
++async function start(user, text, { timezone = null, site = null, files = [] } = {}) {
const body = String(text || '').replace(/\r\n/g, '\n').trim();
if (user && enabled() && !body) fail(422, 'rooms.empty', 'Write something first');
const room = await createRoom(user, body, { site });
const id = room.id;
try {
-- const message = await rooms.post(room, user, body, { timezone });
++ const message = await rooms.post(room, user, body, { timezone, files });
return { room: await rooms.publicRoom(room, { role: 'owner' }), message };
} catch (err) {
await db.tx(() => erase(id)); // a first message the room refuses (spam, too long) leaves no empty chat behind
ddiff --git a/server/web/layout.js b/server/web/layout.js
iindex bd1ddf5..4061509 100644
---- a/server/web/layout.js
++++ b/server/web/layout.js
@@@ -162,15 +162,14 @@ ${INSTALL_LINKS}
<link rel="stylesheet" href="${asset('chat.css')}">
${o.shell ? `<link rel="stylesheet" href="${asset('chat-shell.css')}">` : ''}
<link rel="stylesheet" href="${ovServe.url('items.css')}">
--${o.upload ? `<link rel="stylesheet" href="${ovServe.url('ov-upload.css')}">` : ''}
${o.showcase ? `<link rel="stylesheet" href="${ovServe.url('showcase.css')}">` : ''}
++${o.upload ? `<link rel="stylesheet" href="${ovServe.url('ov-upload.css')}">` : ''}
${(o.inlineStyles || []).map((css) => `<style>${css}</style>`).join('\n')}
${o.kit ? `<link rel="stylesheet" href="${ovServe.url('kit.css')}">\n<link rel="stylesheet" href="${ovServe.url('thread.css')}">` : ''}
${o.app || o.appStyle ? `<link rel="stylesheet" href="${asset(o.appStyle || 'vibe.css')}">` : ''}
${o.feedView ? `<link rel="stylesheet" href="${asset('feed-view.css')}">\n<script src="${asset('feed-view.js')}" defer></script>` : ''}
${o.partyHome ? `<link rel="stylesheet" href="${asset('party-home.css')}">` : ''}
${o.party ? `<link rel="stylesheet" href="${ovServe.url('player.css')}">\n<link rel="stylesheet" href="${asset('party.css')}">\n<script src="${ovServe.url('player.js')}" defer></script>\n<script src="${ovServe.url('ov-sync.js')}" defer></script>\n<script src="${asset('party-providers.js')}" defer></script>\n<script src="${asset('party-provider-sync.js')}" defer></script>\n<script src="${asset('party.js')}" defer></script>` : ''}
--${o.upload ? `<script src="${ovServe.url('ov-upload.js')}" defer></script>` : ''}
${(o.scripts || []).map((n) => `<script src="${asset(n)}" defer></script>`).join('\n')}
<script src="${ovServe.url('theme-loader.js')}" defer></script>
<script src="${ovServe.url('navbar.js')}" defer></script>
@@@ -181,6 +180,7 @@ ${o.callScript ? `<script src="${asset('call.js')}" defer></script>` : ''}
${o.ringScript ? `<script src="${asset('ring.js')}" defer></script>` : ''}
${o.kit ? `<script src="${ovServe.url('kit-web.js')}" defer></script>\n<script src="${ovServe.url('ov-thread.js')}" defer></script>` : ''}
${o.canvas ? `<script src="${ovServe.url('ov-canvas.js')}" defer></script>` : ''}
++${o.upload ? `<script src="${ovServe.url('ov-upload.js')}" defer></script>` : ''}
${o.app ? `<script src="${asset('vibe-md.js')}" defer></script>\n<script src="${asset(o.appScript || 'vibe.js')}" defer></script>` : ''}
</head>
<body class="${[cls, o.app ? 'oc-app-body' : ''].filter(Boolean).join(' ')}"${scale}>
ddiff --git a/server/web/vibe-pages.js b/server/web/vibe-pages.js
iindex 00cb63b..09f138d 100644
---- a/server/web/vibe-pages.js
++++ b/server/web/vibe-pages.js
@@@ -196,18 +196,15 @@ const MODES = [
['code', 'Code', 'OpenVibe.Codes writes it on your computer and opens a pull request', 'What should I build or fix? Name the repository'],
];
--// The text files the attach button takes (read in the browser, vibe.js readText).
--const TEXT_FILES = 'text/plain,text/markdown,text/csv,application/json,.txt,.md,.markdown,.csv,.tsv,.json,.log,.xml,.yaml,.yml,.toml,.ini,.html,.css,.js,.mjs,.ts,.py,.go,.rs,.java,.c,.h,.cpp,.rb,.php,.sh,.sql';
--
function composer({ slug, signedIn, enabled, prefill = '', engine = null, models = [] }) {
const action = !signedIn ? `/auth/login` : slug ? `/vibe/${esc(slug)}` : '/vibe';
-- // A picture (public/web/vibe.js): it goes to OpenVibe.Pics as the person, and Vibe sees it with the question. A text
-- // file (notes, a CSV, code) is read in the browser and goes into the message itself.
++ // A picture (public/web/vibe.js) goes to OpenVibe.Pics; every other file is attached through the shared
++ // upload kit to Media and sent as a reference with the message (server/rooms/vibe-routes.js /files).
const pics = pictures.enabled();
-- const label = pics ? 'Add a picture or a text file' : 'Add a text file';
++ const label = pics ? 'Add a picture or a file' : 'Attach a file';
const attach = signedIn && enabled
? `<button class="ovt-btn vb-attach" type="button" data-vb-attach aria-label="${label}" title="${label}">${ICON.attach}</button>
--<dialog class="ovk-sheet vb-upload" data-vb-upload-dialog aria-labelledby="vb-upload-h"><div class="ovk-sheet-head" data-ov-sheet-head><h2 id="vb-upload-h">${pics ? 'Add a picture or a text file' : 'Add a text file'}</h2><button type="button" class="ovk-icon-btn" data-vb-upload-close aria-label="Close">${thread.icon('close')}</button></div><div class="ovk-sheet-body"><div data-vb-upload data-accept="${pics ? 'image/jpeg,image/png,image/gif,image/webp,image/avif,' : ''}${TEXT_FILES}"></div></div></dialog>`
++<dialog class="ovk-sheet vb-upload" data-vb-upload-dialog aria-labelledby="vb-upload-h"><div class="ovk-sheet-head" data-ov-sheet-head><h2 id="vb-upload-h">${label}</h2><button type="button" class="ovk-icon-btn" data-vb-upload-close aria-label="Close">${thread.icon('close')}</button></div><div class="ovk-sheet-body"><div data-vb-upload data-accept=""></div></div></dialog>`
: '';
const modes = `<fieldset class="vb-modes" aria-label="What Vibe does with it">${MODES.map(([value, label, hint, placeholder], i) => `<label title="${esc(hint)}"><input type="radio" name="mode" value="${value}" data-placeholder="${esc(placeholder)}"${i ? '' : ' checked'}><span>${label}</span></label>`).join('')}</fieldset>`;
const fine = !enabled ? 'Vibe is resting right now. Try again later.' : engine && engine.own ? `Vibe thinks with ${esc(engine.you)} (<a href="/settings#oc-ai-key">change</a>). It can get things wrong: check what matters.` : 'Vibe can get things wrong. Check what matters.';
@@@ -215,7 +212,7 @@ function composer({ slug, signedIn, enabled, prefill = '', engine = null, models
action, method: signedIn ? 'post' : 'get', name: 'message', id: 'vb-composer', attrs: signedIn ? {} : { 'data-signin': '1' },
label: 'Message Vibe', placeholder: signedIn ? 'Message Vibe' : 'Message Vibe (you will sign in first)', value: prefill, maxlength: 12000,
hidden: signedIn ? {} : { next: slug ? `/vibe/${slug}` : (prefill ? `/vibe?ask=${encodeURIComponent(prefill)}` : '/vibe') },
-- top: `<div class="vb-model-picker"><button type="button" class="vb-model-chip" id="vb-model-chip" aria-haspopup="dialog" aria-expanded="false">${models.length > 1 ? `${models.length} models` : models[0] ? esc(models[0]) : 'Auto'} ▾</button><div class="vb-model-sheet" id="vb-model-sheet" role="dialog" aria-label="Choose models" hidden><div class="vb-model-sheet-head"><strong>Choose a model</strong><button type="button" id="vb-model-close" aria-label="Close model list">✕</button></div><label class="vb-model-compare"><input type="checkbox" id="vb-model-compare"> Compare models</label><div id="vb-model-list"><p>Loading models…</p></div><details><summary>Settings</summary><label>System prompt<textarea id="vb-system-prompt" maxlength="4000"></textarea></label><label>Temperature<input id="vb-temperature" type="number" min="0" max="2" step="0.1" value="1"></label></details><p class="vb-model-floor">The floor holds on every model: <a href="https://openvibe.network/rules">the rules</a></p></div></div><div class="vb-preview" id="vb-preview" hidden></div><div class="vb-progress" data-vb-progress hidden></div>`, tools: attach + modes,
++ top: `<div class="vb-files" id="vb-files" data-vb-files hidden></div><div class="vb-model-picker"><button type="button" class="vb-model-chip" id="vb-model-chip" aria-haspopup="dialog" aria-expanded="false">${models.length > 1 ? `${models.length} models` : models[0] ? esc(models[0]) : 'Auto'} ▾</button><div class="vb-model-sheet" id="vb-model-sheet" role="dialog" aria-label="Choose models" hidden><div class="vb-model-sheet-head"><strong>Choose a model</strong><button type="button" id="vb-model-close" aria-label="Close model list">✕</button></div><label class="vb-model-compare"><input type="checkbox" id="vb-model-compare"> Compare models</label><div id="vb-model-list"><p>Loading models…</p></div><details><summary>Settings</summary><label>System prompt<textarea id="vb-system-prompt" maxlength="4000"></textarea></label><label>Temperature<input id="vb-temperature" type="number" min="0" max="2" step="0.1" value="1"></label></details><p class="vb-model-floor">The floor holds on every model: <a href="https://openvibe.network/rules">the rules</a></p></div></div><div class="vb-preview" id="vb-preview" hidden></div><div class="vb-progress" data-vb-progress hidden></div>`, tools: attach + modes,
// Stop is drawn hidden: vibe.js shows it while Vibe writes, and it keeps what was written so far.
stopAction: signedIn ? '' : null,
foot: `<p class="vb-fine">${fine}</p>`,
ddiff --git a/test/vibe-files.test.js b/test/vibe-files.test.js
nnew file mode 100644
iindex 0000000..df86116
---- /dev/null
++++ b/test/vibe-files.test.js
@@@ -0,0 +1,78 @@
++'use strict';
++// The Vibe composer attaches any file: it uploads to Media as the person (unlisted), the message keeps
++// the ai.file@1 reference, and Vibe's turn carries it to OpenVibe.AI (which reads it for the model).
++const assert = require('assert');
++const { ids } = require('openvibe-contracts');
++const { boot, suite } = require('./helpers');
++let aiClient, media, h, ann;
++const turns = [];
++const uploads = [];
++
++const t = suite('vibe files');
++
++t('boot', async () => {
++ h = await boot({ env: { CHAT_WEB_URL: 'https://openvibe.chat' } });
++ aiClient = require('../server/ai/client');
++ media = require('../server/media/client');
++ media._setClient({
++ upload: async (data, opts) => {
++ uploads.push({ data, opts });
++ return { id: 'med_01ARZ3NDEKTSV4RRFFQ69G5FAV', filename: opts.filename, mime_type: opts.mimeType, size_bytes: Buffer.byteLength(data) };
++ },
++ });
++ ann = h.addUser('ann', { subject: ids.newId('user') });
++ await h.ctx.upsertUser(h.live.users.get(ann.id));
++ aiClient._setClient({ runs: { create: async (workflow, input) => {
++ turns.push({ workflow, input });
++ return { run: { status: 'succeeded', output: { choices: [{ message: { content: 'Two rows: Ada 10, Grace 9.' } }] } } };
++ } } });
++});
++
++const rawPost = (path, body, headers) => fetch(`${h.base}${path}`, { method: 'POST', headers: { authorization: `Bearer ${ann.token}`, ...headers }, body })
++ .then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
++
++t('POST /api/chat/vibe/files stores an unlisted Media file as the person and returns ai.file@1', async () => {
++ const r = await rawPost('/api/chat/vibe/files', 'name,score\nAda,10\nGrace,9\n', {
++ 'Content-Type': 'text/csv', 'X-Filename': encodeURIComponent('scores.csv'),
++ });
++ assert.equal(r.status, 201, JSON.stringify(r.body));
++ assert.equal(r.body.file.id, 'med_01ARZ3NDEKTSV4RRFFQ69G5FAV');
++ assert.equal(r.body.file.name, 'scores.csv');
++ assert.equal(r.body.file.media_type, 'text/csv');
++ assert.equal(r.body.file.storage.provider, 'media');
++ assert.deepEqual(r.body.file.access.readers, [ann.subject_id]);
++ assert.equal(r.body.file.read.method, 'not_readable');
++ assert.equal(uploads.length, 1);
++ assert.equal(uploads[0].opts.visibility, 'unlisted', 'AI reads it by its public Media id');
++ assert.equal(uploads[0].opts.subject, ann.subject_id);
++});
++
++t('a message with a file stores the reference and Vibe\'s turn carries it to AI', async () => {
++ const file = (await rawPost('/api/chat/vibe/files', 'a,b\n1,2\n', { 'Content-Type': 'text/csv', 'X-Filename': 'rows.csv' })).body.file;
++ const started = await fetch(`${h.base}/api/chat/vibe`, {
++ method: 'POST', headers: { authorization: `Bearer ${ann.token}`, 'Content-Type': 'application/json' },
++ body: JSON.stringify({ message: 'How many rows?', files: [file] }),
++ }).then(async (r) => ({ status: r.status, body: await r.json() }));
++ assert.equal(started.status, 201, JSON.stringify(started.body));
++ const slug = started.body.room.slug;
++ for (let i = 0; i < 100 && !turns.length; i++) await new Promise((r) => setTimeout(r, 30));
++ assert.ok(turns.length, 'AI was called');
++ assert.equal(turns[0].workflow, 'chat.member.turn');
++ assert.equal(turns[0].input.files.length, 1);
++ assert.equal(turns[0].input.files[0].id, file.id);
++ const history = await h.http('GET', `/api/chat/rooms/${slug}/messages`, { token: ann.token });
++ assert.deepEqual(history.body.messages[0].files.map((f) => f.id), [file.id], 'the message keeps the reference');
++});
++
++t('the composer page carries the attachment tray and the shared upload kit', async () => {
++ const res = await fetch(`${h.base}/vibe`, { headers: { cookie: `ov_token=${ann.token}` } });
++ assert.equal(res.status, 200);
++ const page = await res.text();
++ assert.match(page, /id="vb-files"/);
++ assert.match(page, /data-vb-upload-dialog/);
++ assert.match(page, /data-vb-upload/);
++ assert.match(page, /data-vb-attach/);
++ assert.match(page, /ov-upload\.(?:js|css)/);
++});
++
++t.run(async () => { if (aiClient) aiClient._setClient(null); if (media) media._reset(); if (h && h.close) await h.close(); });
ddiff --git a/test/vibe-long-text.test.js b/test/vibe-long-text.test.js
iindex 97d08b4..e92bcad 100644
---- a/test/vibe-long-text.test.js
++++ b/test/vibe-long-text.test.js
@@@ -46,14 +46,15 @@ t('everywhere else the limits stay: 2000 characters, spam refused', async () =>
assert.strictEqual((await post('look ==============')).status, 422, 'spam still refused in a room');
});
--t('the composer takes 12000 characters and the attach button takes text files', async () => {
++t('the composer takes 12000 characters and any file through the tray', async () => {
const page = await fetch(`${h.base}/vibe`, { headers: { cookie: `ov_token=${ann.token}` } }).then((x) => x.text());
assert.match(page, /<textarea id="ovt-text" name="message" rows="1"[^>]*maxlength="12000"/);
-- assert.match(page, /data-vb-upload data-accept="[^"]*text\/plain[^"]*\.csv[^"]*\.py[^"]*"/);
++ assert.match(page, /data-vb-upload data-accept=""/, 'the picker takes any type');
++ assert.match(page, /id="vb-files"/, 'the attachment tray is there');
assert.match(page, /\/shared\/ov-upload\.js/);
assert.match(page, /\/shared\/ov-upload\.css/);
assert.doesNotMatch(page, /id="vb-file"/, "the hand-rolled file input is gone");
-- assert.match(page, /aria-label="Add (a picture or )?a text file"/);
++ assert.match(page, /aria-label="(Attach a file|Add a picture or a file)"/);
});
t.run(async () => { if (aiClient) aiClient._setClient(null); if (h && h.close) await h.close(); });
ddiff --git a/test/vibe-pictures.test.js b/test/vibe-pictures.test.js
iindex 60b3526..4eaabb7 100644
---- a/test/vibe-pictures.test.js
++++ b/test/vibe-pictures.test.js
@@@ -100,9 +100,10 @@ t('a picture Pics refuses leaves no chat behind; another type is refused before
assert.strictEqual((await vibe.list({ id: ann.id })).length, count, 'no empty chat left');
});
--t('the composer offers the picture button when pictures are on', async () => {
++t('the composer offers the attachment control and the shared upload kit when pictures are on', async () => {
const page = await (await fetch(`${h.base}/vibe`, { headers: { cookie: `ov_token=${ann.token}` } })).text();
-- assert.ok(page.includes('data-vb-attach') && page.includes('data-vb-upload') && page.includes('/shared/ov-upload.js') && page.includes('/shared/ov-upload.css'));
++ assert.ok(page.includes('id="vb-files"') && page.includes('data-vb-attach') && page.includes('data-vb-upload'));
++ assert.ok(page.includes('/shared/ov-upload.js') && page.includes('/shared/ov-upload.css'));
assert.ok(!page.includes('id="vb-file"'), 'the hand-rolled file input is gone');
});