openvibers/openvibe.chat Commits

Vibe takes long text: up to 12000 characters in your own chat (a pasted page, a file, code), read whole, with no spam guess there; the attach button takes text files, read in the browser into the box (#184)

OP OpenVibers committed 20 days ago · 1c8a689a69 · 5 files, +97 −9

Changes

ddiff --git a/public/web/vibe.js b/public/web/vibe.js
iindex d25d7a5..698bd61 100644
---- a/public/web/vibe.js
++++ b/public/web/vibe.js
@@@ -57,9 +57,31 @@
if (fileInput) fileInput.value = '';
if (input) input.required = true;
}
++ // 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```';
++ grow(); 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)) { notice('Send a JPEG, PNG, GIF, WebP or AVIF picture.'); return; }
++ if (!PIC_TYPES.test(file.type) && (/^text\//.test(file.type) || file.type === 'application/json' || TEXT_EXT.test(file.name || ''))) { readText(file); if (fileInput) fileInput.value = ''; return; }
++ if (!PIC_TYPES.test(file.type) || !fileInput || !/image\//.test(fileInput.accept)) { notice('Send a JPEG, PNG, GIF, WebP or AVIF picture, or a text 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) };
ddiff --git a/server/rooms/ai-members.js b/server/rooms/ai-members.js
iindex 2b3dbf3..7c14a77 100644
---- a/server/rooms/ai-members.js
++++ b/server/rooms/ai-members.js
@@@ -350,7 +350,7 @@ async function afterPost(room, message, user, deps = {}) {
const context = await contextFor(member);
const opts = { force: true, attribution: { service: 'chat', type: 'room', id: String(room.id), label: String(room.name || room.slug).slice(0, 200) }, idempotencyKey: `chat-ai-${room.id}-${message.id}`,
...(own ? { credential: { subject: member.engine_subject } } : {}) };
-- const transcript = transcriptOf(history, vibe ? { each: 2000, total: 16000 } : {}) || '(no earlier messages)';
++ const transcript = transcriptOf(history, vibe ? { each: 6000, total: 24000 } : {}) || '(no earlier messages)';
const askedBy = (user.display_name || user.username || 'someone').slice(0, 80);
// Vibe's memory of the person (server/rooms/vibe-memory.js): only in their own chat, and only while it is on.
const vibeMemory = require('./vibe-memory');
@@@ -371,7 +371,7 @@ async function afterPost(room, message, user, deps = {}) {
} : null;
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, 2000), opts, subject: user.subject_id || null, images, user, messageId: message.id, memory, called, onText, steps }) || '', vibe ? 8000 : 2000); } catch { text = ''; }
++ 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, called, onText, steps }) || '', vibe ? 8000 : 2000); } catch { text = ''; }
}
// A plain "remember that …" is kept even when the model only promised it (small models often do).
if (memory !== null && text && !called.has('remember')) {
ddiff --git a/server/rooms/rooms.js b/server/rooms/rooms.js
iindex 83181df..e246761 100644
---- a/server/rooms/rooms.js
++++ b/server/rooms/rooms.js
@@@ -71,6 +71,7 @@ const ROLE_ORDER = { owner: 0, mod: 1, speaker: 2, participant: 3, member: 4, vi
/** Services a room can be attached to, and what names the resource there. */
const ATTACH_SERVICES = Object.freeze({ community: { resource: /^[a-z0-9][a-z0-9-]{1,39}$/, noun: 'space' } });
const MAX_TEXT = 2000;
++const MAX_TEXT_VIBE = 12000; // a person's message in their own Vibe chat: room for a pasted page, a file's text, code
const MAX_AI_TEXT_VIBE = 8000;
const MAX_ROOMS_PER_OWNER = 10;
const RATE = { max: 6, windowMs: 10_000 };
@@@ -271,8 +272,11 @@ async function post(room, user, text, { now = Date.now(), ai = true } = {}) {
}
const body = String(text || '').replace(/\r\n/g, '\n').trim();
if (!body) fail(422, 'rooms.empty', 'Write something first');
-- if (body.length > MAX_TEXT) fail(422, 'rooms.too_long', `Messages are at most ${MAX_TEXT} characters`);
-- if (wordFilter.isSpam(body)) fail(422, 'rooms.spam', 'That looks like spam');
++ // A person's own Vibe chat has no other reader: longer messages, and no spam guess (pasted text has rows of = and links).
++ const ownVibe = Number(room.assistant) === 1 && Number(room.owner_id) === Number(user.id);
++ const max = ownVibe ? MAX_TEXT_VIBE : MAX_TEXT;
++ if (body.length > max) fail(422, 'rooms.too_long', `Messages are at most ${max} characters`);
++ if (!ownVibe && wordFilter.isSpam(body)) fail(422, 'rooms.spam', 'That looks like spam');
if (!rateOk(`${room.id}:${user.id}`, now)) fail(429, 'rooms.slow_down', 'Slow down a little');
if (room.slow_seconds > 0 && !a.moderate) {
const last = await db.get('SELECT created_at FROM room_messages WHERE room_id = ? AND user_id = ? ORDER BY id DESC LIMIT 1', [room.id, user.id]);
ddiff --git a/server/web/vibe-pages.js b/server/web/vibe-pages.js
iindex 2054547..9d34506 100644
---- a/server/web/vibe-pages.js
++++ b/server/web/vibe-pages.js
@@@ -128,18 +128,24 @@ 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 = '' }) {
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.
-- const attach = signedIn && enabled && pictures.enabled()
-- ? `<input type="file" id="vb-file" accept="image/jpeg,image/png,image/gif,image/webp,image/avif" hidden><button class="vb-icon vb-attach" type="button" data-vb-attach aria-label="Add a picture" title="Add a picture">${ICON.attach}</button>`
++ // 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.
++ const pics = pictures.enabled();
++ const label = pics ? 'Add a picture or a text file' : 'Add a text file';
++ const attach = signedIn && enabled
++ ? `<input type="file" id="vb-file" accept="${pics ? 'image/jpeg,image/png,image/gif,image/webp,image/avif,' : ''}${TEXT_FILES}" hidden><button class="vb-icon vb-attach" type="button" data-vb-attach aria-label="${label}" title="${label}">${ICON.attach}</button>`
: '';
return `<div class="vb-preview" id="vb-preview" hidden></div>
<form class="vb-composer" id="vb-composer" method="${signedIn ? 'post' : 'get'}" action="${action}"${signedIn ? '' : ' data-signin="1"'}>
${signedIn ? '' : `<input type="hidden" name="next" value="${slug ? `/vibe/${esc(slug)}` : (prefill ? `/vibe?ask=${encodeURIComponent(prefill)}` : '/vibe')}">`}
${attach}
<label class="oc-sr" for="vb-input">Message Vibe</label>
--<textarea id="vb-input" name="message" rows="${prefill ? 3 : 1}" maxlength="2000" placeholder="${signedIn ? 'Message Vibe' : 'Message Vibe (you will sign in first)'}"${enabled ? '' : ' disabled'}${prefill ? ' autofocus' : ''} required>${esc(prefill)}</textarea>
++<textarea id="vb-input" name="message" rows="${prefill ? 3 : 1}" maxlength="12000" placeholder="${signedIn ? 'Message Vibe' : 'Message Vibe (you will sign in first)'}"${enabled ? '' : ' disabled'}${prefill ? ' autofocus' : ''} required>${esc(prefill)}</textarea>
<button class="vb-send" type="submit" aria-label="Send"${enabled ? '' : ' disabled'}>${ICON.send}</button>
<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>
</form>
ddiff --git a/test/vibe-long-text.test.js b/test/vibe-long-text.test.js
nnew file mode 100644
iindex 0000000..32d127f
---- /dev/null
++++ b/test/vibe-long-text.test.js
@@@ -0,0 +1,56 @@
++'use strict';
++/**
++ * Long text in a person's own Vibe chat (server/rooms/rooms.js post, ai-members.js, web/vibe-pages.js): a message may
++ * hold 12000 characters there (a pasted page, a file's text, code) and Vibe reads all of it; the spam guess does not
++ * apply (rows of = and links are normal in pasted text); everywhere else the limits stay (2000, spam refused). The
++ * composer takes 12000 and its attach button takes text files.
++ */
++const assert = require('assert');
++const { ids } = require('openvibe-contracts');
++const { boot, suite } = require('./helpers');
++
++const t = suite('vibe long text');
++let h, ann, aiClient;
++const turns = [];
++const vibeApi = (method, path, body) => h.http(method, `/api/chat/vibe${path}`, { token: ann.token, body });
++
++t('boot', async () => {
++ h = await boot({ env: { CHAT_WEB_URL: 'https://openvibe.chat' } });
++ aiClient = require('../server/ai/client');
++ 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: 'Read it.' } }] } } }; } } });
++});
++
++t('a 9000-character message in your own Vibe chat is taken, and Vibe reads all of it; 12001 is refused', async () => {
++ const long = `Summarise this:\n${'Lorem ipsum dolor sit amet. '.repeat(330)}THE END MARKER`;
++ assert.ok(long.length > 9000 && long.length < 12000, String(long.length));
++ const r = await vibeApi('POST', '', { message: long });
++ assert.strictEqual(r.status, 201, r.text);
++ for (let i = 0; i < 80 && !turns.length; i++) await new Promise((res) => setTimeout(res, 25));
++ assert.ok(JSON.stringify(turns[turns.length - 1].input).includes('THE END MARKER'), 'the end of the text reached Vibe');
++ const tooLong = await vibeApi('POST', '', { message: 'x '.repeat(6001) });
++ assert.deepStrictEqual([tooLong.status, /at most 12000/.test(tooLong.body.error)], [422, true]);
++});
++
++t('pasted text with rows of = and many links is not spam in your own chat', async () => {
++ const pasted = `Notes\n==============\n${['a', 'b', 'c', 'd', 'e'].map((x) => `https://example.com/${x}`).join('\n')}`;
++ assert.strictEqual((await vibeApi('POST', '', { message: pasted })).status, 201);
++});
++
++t('everywhere else the limits stay: 2000 characters, spam refused', async () => {
++ assert.strictEqual((await h.http('POST', '/api/chat/rooms', { token: ann.token, body: { name: 'Long Crew', visibility: 'private' } })).status, 201);
++ const post = (message) => h.http('POST', '/api/chat/rooms/long-crew/messages', { token: ann.token, body: { message } });
++ const long = await post('y'.repeat(2001));
++ assert.deepStrictEqual([long.status, /at most 2000/.test(long.body.error)], [422, true]);
++ 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 () => {
++ const page = await fetch(`${h.base}/vibe`, { headers: { cookie: `ov_token=${ann.token}` } }).then((x) => x.text());
++ assert.match(page, /<textarea id="vb-input" name="message" rows="1" maxlength="12000"/);
++ assert.match(page, /<input type="file" id="vb-file" accept="[^"]*text\/plain[^"]*\.csv[^"]*\.py[^"]*" hidden>/);
++ assert.match(page, /aria-label="Add (a picture or )?a text file"/);
++});
++
++t.run(async () => { if (aiClient) aiClient._setClient(null); if (h && h.close) await h.close(); });