openvibers/openvibe.chat Commits
Vibe: attach any file through the shared upload kit (#457)
Changes
nnew file mode 100644@@@ -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;@@@ -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"@@@ -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"@@@ -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); }@@@ -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) {@@@ -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; } };@@@ -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, texfor (let round = 0; round <= MAX_TOOL_ROUNDS; round++) {if (signal && signal.aborted) return null; // stopped (stop()): the caller keeps what was writtenconst 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);nnew file mode 100644@@@ -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 };@@@ -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@@@ -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); }@@@ -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); }});@@@ -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@@@ -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}>@@@ -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, modelsaction, 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>`,nnew file mode 100644@@@ -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(); });@@@ -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(); });@@@ -100,9 +100,10 @@ t('a picture Pics refuses leaves no chat behind; another type is refused beforeassert.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');});
new file mode 100644
new file mode 100644
@@ -0,0 +1,4 @@
@@ -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;
@@ -18,7 +18,7 @@
@@ -18,7 +18,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^2.0.2",
"multer": "^2.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.384.0",
"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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.185.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
"ws": "^8.18.0"
"ws": "^8.18.0"
@@ -1573,9 +1573,9 @@
@@ -1573,9 +1573,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"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",
"license": "MIT",
"bin": {
"bin": {
"openvibe-download": "bin/openvibe-download.js"
"openvibe-download": "bin/openvibe-download.js"
@@ -30,7 +30,7 @@
@@ -30,7 +30,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^2.0.2",
"multer": "^2.0.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.384.0",
"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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.185.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
"ws": "^8.18.0"
"ws": "^8.18.0"
@@ -273,6 +273,8 @@
@@ -273,6 +273,8 @@
@media (min-width: 1180px) { .vbk .vbc-phone { display: none !important; } }
@media (min-width: 1180px) { .vbk .vbc-phone { display: none !important; } }
@media (max-width: 1179px) { .vbk .vbc-wide { 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. */
/* 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. */
/* 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); }
.ovk-sheet.vb-upload { width: min(440px, 100vw); }
@@ -23,10 +23,12 @@
@@ -23,10 +23,12 @@
var latest = Number(cfg.latest) || 0;
var latest = Number(cfg.latest) || 0;
var ws = null, delay = 1000, sending = false, waitTimer = null;
var ws = null, delay = 1000, sending = false, waitTimer = null;
var previewEl = document.getElementById('vb-preview');
var previewEl = document.getElementById('vb-preview');
+ var filesTray = document.getElementById('vb-files');
var progressEl = document.querySelector('[data-vb-progress]');
var progressEl = document.querySelector('[data-vb-progress]');
var uploadDialog = document.querySelector('[data-vb-upload-dialog]');
var uploadDialog = document.querySelector('[data-vb-upload-dialog]');
var uploadRoot = document.querySelector('[data-vb-upload]');
var uploadRoot = document.querySelector('[data-vb-upload]');
var picture = null; // { file, url }: a picture waiting to go with the next message
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 reduce = document.documentElement.getAttribute('data-ov-motion') === 'off';
var chosenModels = Array.isArray(cfg.models) ? cfg.models.slice(0, 3) : [];
var chosenModels = Array.isArray(cfg.models) ? cfg.models.slice(0, 3) : [];
function persistModelChoice() {
function persistModelChoice() {
@@ -120,8 +122,8 @@
@@ -120,8 +122,8 @@
// ── The composer ──
// ── The composer ──
if (input) {
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;
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 (saved && !input.value) { input.value = saved; changed(); }
if (!('ontouchstart' in window)) input.focus();
if (!('ontouchstart' in window)) input.focus();
@@ -139,31 +141,9 @@
@@ -139,31 +141,9 @@
if (input) input.required = true;
if (input) input.required = true;
changed();
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) {
function choosePicture(file) {
if (!file) return;
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; }
if (file.size > 20 * 1024 * 1024) { notice('A picture can be up to 20 MB.'); return; }
clearPicture();
clearPicture();
picture = { file: file, url: URL.createObjectURL(file) };
picture = { file: file, url: URL.createObjectURL(file) };
@@ -177,20 +157,98 @@
@@ -177,20 +157,98 @@
if (sendBtn) sendBtn.disabled = false;
if (sendBtn) sendBtn.disabled = false;
input.focus();
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]');
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;
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(); });
attachBtn.addEventListener('click', function () { uploadDialog.showModal(); });
var closeUpload = uploadDialog.querySelector('[data-vb-upload-close]');
var closeUpload = uploadDialog.querySelector('[data-vb-upload-close]');
if (closeUpload) closeUpload.addEventListener('click', function () { uploadDialog.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) {
input.addEventListener('paste', function (e) {
var items = (e.clipboardData && e.clipboardData.files) || [];
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) {
} else if (attachBtn) {
attachBtn.hidden = true;
attachBtn.hidden = true;
@@ -661,7 +719,7 @@
@@ -661,7 +719,7 @@
if (form) form.addEventListener('submit', function (e) {
if (form) form.addEventListener('submit', function (e) {
if (e.submitter && e.submitter === stopBtn) { e.preventDefault(); return; }
if (e.submitter && e.submitter === stopBtn) { e.preventDefault(); return; }
var text = input.value.trim();
var text = input.value.trim();
- if (!text && !picture) { e.preventDefault(); return; }
+ if (!text && !picture && !attachments.length) { e.preventDefault(); return; }
if (form.dataset.signin) {
if (form.dataset.signin) {
// Signed out: the draft waits in this browser while they sign in, and comes back in the composer.
// Signed out: the draft waits in this browser while they sign in, and comes back in the composer.
e.preventDefault();
e.preventDefault();
@@ -670,16 +728,20 @@
@@ -670,16 +728,20 @@
return;
return;
}
}
if (!window.fetch || sending) 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();
e.preventDefault();
sending = true;
sending = true;
var old = app.querySelector('.vb-live-notice'); if (old) old.remove();
var old = app.querySelector('.vb-live-notice'); if (old) old.remove();
var sendingPicture = picture;
var sendingPicture = picture;
+ var sendingFiles = attachments.map(function (a) { return a.ref; });
var mine = meTurn(text, null, sendingPicture && sendingPicture.url);
var mine = meTurn(text, null, sendingPicture && sendingPicture.url);
threadEl().appendChild(mine);
threadEl().appendChild(mine);
showWait(); toBottom();
showWait(); toBottom();
input.value = ''; picture = null; if (previewEl) { previewEl.textContent = ''; previewEl.hidden = true; } input.required = true; changed();
input.value = ''; picture = null; if (previewEl) { previewEl.textContent = ''; previewEl.hidden = true; } input.required = true; changed();
var url, init;
var url, init;
- var modeEl = form.querySelector('input[name="mode"]:checked'), mode = modeEl ? modeEl.value : 'chat';
if (mode !== 'chat' && !sendingPicture) {
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.
// 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';
url = room ? '/api/chat/vibe/' + encodeURIComponent(room) + '/ask' : '/api/chat/vibe';
@@ -690,7 +752,7 @@
@@ -690,7 +752,7 @@
init = { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': sendingPicture.file.type, 'X-Caption': encodeURIComponent(text) }, body: sendingPicture.file };
init = { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': sendingPicture.file.type, 'X-Caption': encodeURIComponent(text) }, body: sendingPicture.file };
} else {
} else {
url = room ? '/api/chat/rooms/' + encodeURIComponent(room) + '/messages' : '/api/chat/vibe';
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); }
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;
var request;
@@ -705,6 +767,7 @@
@@ -705,6 +767,7 @@
.then(function (x) {
.then(function (x) {
if (!x.ok) { putBack((x.d && x.d.error) || 'That did not send. Try again.'); return; }
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)); }
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.
// 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 (x.d && x.d.task && !canvasWatch(x.d.task)) { canvas = null; canvasClear(); }
if (!room && x.d.room) {
if (!room && x.d.room) {
@@ -38,6 +38,26 @@ function publicUrl(obj) {
@@ -38,6 +38,26 @@ function publicUrl(obj) {
return null;
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 }.
* 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.
* Shaped for the emote routes: `id` is stored as media_asset_id, `url` as media_url.
@@ -53,4 +73,4 @@ async function deleteObject(id) {
@@ -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; }
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; } };
@@ -314,7 +314,7 @@ const PLAN_DAY = /\b(plan(?:\s+out)?\s+(?:my|the)\s+(?:day|morning|afternoon|eve
@@ -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
* 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).
* 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 tools = aiTools.definitions(member, room, { memory: memory !== null });
const vibe = Number(room.assistant) === 1;
const vibe = Number(room.assistant) === 1;
const tz = timezoneOf(timezone);
const tz = timezoneOf(timezone);
@@ -337,7 +337,7 @@ async function answerWithTools({ room, member, context, transcript, askedBy, tex
@@ -337,7 +337,7 @@ async function answerWithTools({ room, member, context, transcript, askedBy, tex
for (let round = 0; round <= MAX_TOOL_ROUNDS; round++) {
for (let round = 0; round <= MAX_TOOL_ROUNDS; round++) {
if (signal && signal.aborted) return null; // stopped (stop()): the caller keeps what was written
if (signal && signal.aborted) return null; // stopped (stop()): the caller keeps what was written
const last = round === MAX_TOOL_ROUNDS;
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 } : {}) };
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.
// 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);
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 = {}) {
@@ -427,7 +427,8 @@ async function afterPost(room, message, user, deps = {}) {
if (controller) stoppers.set(room.id, controller);
if (controller) stoppers.set(room.id, controller);
if (config.ai.memberTools) {
if (config.ai.memberTools) {
const images = await picturesFor(message, history, user).catch(() => []);
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) {
if (controller && controller.signal.aborted) {
const sofar = clean(pending || '', 7900);
const sofar = clean(pending || '', 7900);
new file mode 100644
new file mode 100644
@@ -0,0 +1,55 @@
@@ -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 };
@@ -1,4 +1,5 @@
@@ -1,4 +1,5 @@
'use strict';
'use strict';
+const messageFiles = require('./files');
/**
/**
* Chat rooms (roadmap WS-I task 4): rooms people create on openvibe.chat, beside the global room and
* 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:
* the stream/channel rooms Live's chat already has. Three kinds:
@@ -267,7 +268,7 @@ async function announceDeleted(room, ids) {
@@ -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);
const a = await access(room, user);
if (!user) fail(401, 'rooms.sign_in', 'Sign in to chat');
if (!user) fail(401, 'rooms.sign_in', 'Sign in to chat');
if (siteBanned(user)) fail(403, 'rooms.banned', 'Your account is banned from 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 =
@@ -298,11 +299,12 @@ async function post(room, user, text, { now = Date.now(), ai = true, timezone =
}
}
const filtered = wordFilter.check(body);
const filtered = wordFilter.check(body);
const message = filtered.safe ? body : filtered.filtered;
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
// 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.
// broadcast when it is ready; a slower one fills the row later.
const { card, pending } = await linkCards.forSend(message);
const { card, pending } = await linkCards.forSend(message);
const id = await db.tx(async () => {
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 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 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);
await announceMessage(room, user, Number(r.lastInsertRowid), message, now);
@@ -340,7 +342,7 @@ async function postAsAi(room, member, text, { now = Date.now(), how = null } = {
@@ -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.
// 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.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,
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
m.agent_stream, u.profile_color, u.role AS user_role
@@ -205,7 +205,7 @@ router.get('/:slug/messages', optionalAuth, async (req, res) => {
@@ -205,7 +205,7 @@ router.get('/:slug/messages', optionalAuth, async (req, res) => {
router.post('/:slug/messages', requireAuth, postLimit, async (req, res) => {
router.post('/:slug/messages', requireAuth, postLimit, async (req, res) => {
const room = await readable(req, res); if (!room) return;
const room = await readable(req, res); if (!room) return;
try {
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 });
chatServer().broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message });
res.status(201).json({ message });
res.status(201).json({ message });
} catch (err) { send(res, err); }
} catch (err) { send(res, err); }
@@ -32,6 +32,7 @@ const rooms = require('./rooms');
@@ -32,6 +32,7 @@ const rooms = require('./rooms');
const vibe = require('./vibe');
const vibe = require('./vibe');
const how = require('./how');
const how = require('./how');
const memory = require('./vibe-memory');
const memory = require('./vibe-memory');
+const media = require('../media/client');
const router = express.Router();
const router = express.Router();
const startLimit = limits('chat.vibe.start', { minute: 10, hour: 120 });
const startLimit = limits('chat.vibe.start', { minute: 10, hour: 120 });
@@ -55,11 +56,29 @@ router.get('/', requireAuth, personOnly, async (req, res) => {
@@ -55,11 +56,29 @@ router.get('/', requireAuth, personOnly, async (req, res) => {
} catch (err) { send(res, err); }
} 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);
const modeOf = (b) => (b && b.mode && b.mode !== 'chat' ? String(b.mode) : null);
router.post('/', requireAuth, personOnly, startLimit, async (req, res) => {
router.post('/', requireAuth, personOnly, startLimit, async (req, res) => {
try {
try {
const b = req.body || {};
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);
res.status(201).json(out);
} catch (err) { send(res, err); }
} catch (err) { send(res, err); }
});
});
@@ -116,13 +116,13 @@ async function createRoom(user, title, { site = null } = {}) {
@@ -116,13 +116,13 @@ async function createRoom(user, title, { site = null } = {}) {
return await db.get('SELECT * FROM rooms WHERE id = ?', [id]);
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();
const body = String(text || '').replace(/\r\n/g, '\n').trim();
if (user && enabled() && !body) fail(422, 'rooms.empty', 'Write something first');
if (user && enabled() && !body) fail(422, 'rooms.empty', 'Write something first');
const room = await createRoom(user, body, { site });
const room = await createRoom(user, body, { site });
const id = room.id;
const id = room.id;
try {
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 };
return { room: await rooms.publicRoom(room, { role: 'owner' }), message };
} catch (err) {
} catch (err) {
await db.tx(() => erase(id)); // a first message the room refuses (spam, too long) leaves no empty chat behind
await db.tx(() => erase(id)); // a first message the room refuses (spam, too long) leaves no empty chat behind
@@ -162,15 +162,14 @@ ${INSTALL_LINKS}
@@ -162,15 +162,14 @@ ${INSTALL_LINKS}
<link rel="stylesheet" href="${asset('chat.css')}">
<link rel="stylesheet" href="${asset('chat.css')}">
${o.shell ? `<link rel="stylesheet" href="${asset('chat-shell.css')}">` : ''}
${o.shell ? `<link rel="stylesheet" href="${asset('chat-shell.css')}">` : ''}
<link rel="stylesheet" href="${ovServe.url('items.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.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.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.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.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.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.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.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')}
${(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('theme-loader.js')}" defer></script>
<script src="${ovServe.url('navbar.js')}" defer></script>
<script src="${ovServe.url('navbar.js')}" defer></script>
@@ -181,6 +180,7 @@ ${o.callScript ? `<script src="${asset('call.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.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.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.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>` : ''}
${o.app ? `<script src="${asset('vibe-md.js')}" defer></script>\n<script src="${asset(o.appScript || 'vibe.js')}" defer></script>` : ''}
</head>
</head>
<body class="${[cls, o.app ? 'oc-app-body' : ''].filter(Boolean).join(' ')}"${scale}>
<body class="${[cls, o.app ? 'oc-app-body' : ''].filter(Boolean).join(' ')}"${scale}>
@@ -196,18 +196,15 @@ const MODES = [
@@ -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'],
['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 = [] }) {
function composer({ slug, signedIn, enabled, prefill = '', engine = null, models = [] }) {
const action = !signedIn ? `/auth/login` : slug ? `/vibe/${esc(slug)}` : '/vibe';
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 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
const attach = signedIn && enabled
? `<button class="ovt-btn vb-attach" type="button" data-vb-attach aria-label="${label}" title="${label}">${ICON.attach}</button>
? `<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 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.';
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
@@ -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' },
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,
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') },
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.
// Stop is drawn hidden: vibe.js shows it while Vibe writes, and it keeps what was written so far.
stopAction: signedIn ? '' : null,
stopAction: signedIn ? '' : null,
foot: `<p class="vb-fine">${fine}</p>`,
foot: `<p class="vb-fine">${fine}</p>`,
new file mode 100644
new file mode 100644
@@ -0,0 +1,78 @@
@@ -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(); });
@@ -46,14 +46,15 @@ t('everywhere else the limits stay: 2000 characters, spam refused', async () =>
@@ -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');
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());
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, /<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\.js/);
assert.match(page, /\/shared\/ov-upload\.css/);
assert.match(page, /\/shared\/ov-upload\.css/);
assert.doesNotMatch(page, /id="vb-file"/, "the hand-rolled file input is gone");
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(); });
t.run(async () => { if (aiClient) aiClient._setClient(null); if (h && h.close) await h.close(); });
@@ -100,9 +100,10 @@ t('a picture Pics refuses leaves no chat behind; another type is refused before
@@ -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');
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();
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');
assert.ok(!page.includes('id="vb-file"'), 'the hand-rolled file input is gone');
});
});