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)
Changes
@@@ -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) };@@@ -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')) {@@@ -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, codeconst 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]);@@@ -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>nnew file mode 100644@@@ -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(); });
@@ -57,9 +57,31 @@
@@ -57,9 +57,31 @@
if (fileInput) fileInput.value = '';
if (fileInput) fileInput.value = '';
if (input) input.required = true;
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) {
function choosePicture(file) {
if (!file) return;
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; }
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) };
@@ -350,7 +350,7 @@ async function afterPost(room, message, user, deps = {}) {
@@ -350,7 +350,7 @@ async function afterPost(room, message, user, deps = {}) {
const context = await contextFor(member);
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}`,
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 } } : {}) };
...(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);
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.
// 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');
const vibeMemory = require('./vibe-memory');
@@ -371,7 +371,7 @@ async function afterPost(room, message, user, deps = {}) {
@@ -371,7 +371,7 @@ async function afterPost(room, message, user, deps = {}) {
} : null;
} : null;
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, 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).
// A plain "remember that …" is kept even when the model only promised it (small models often do).
if (memory !== null && text && !called.has('remember')) {
if (memory !== null && text && !called.has('remember')) {
@@ -71,6 +71,7 @@ const ROLE_ORDER = { owner: 0, mod: 1, speaker: 2, participant: 3, member: 4, vi
@@ -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. */
/** 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 ATTACH_SERVICES = Object.freeze({ community: { resource: /^[a-z0-9][a-z0-9-]{1,39}$/, noun: 'space' } });
const MAX_TEXT = 2000;
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_AI_TEXT_VIBE = 8000;
const MAX_ROOMS_PER_OWNER = 10;
const MAX_ROOMS_PER_OWNER = 10;
const RATE = { max: 6, windowMs: 10_000 };
const RATE = { max: 6, windowMs: 10_000 };
@@ -271,8 +272,11 @@ async function post(room, user, text, { now = Date.now(), ai = true } = {}) {
@@ -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();
const body = String(text || '').replace(/\r\n/g, '\n').trim();
if (!body) fail(422, 'rooms.empty', 'Write something first');
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 (!rateOk(`${room.id}:${user.id}`, now)) fail(429, 'rooms.slow_down', 'Slow down a little');
if (room.slow_seconds > 0 && !a.moderate) {
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]);
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]);
@@ -128,18 +128,24 @@ const MODES = [
@@ -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'],
['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 = '' }) {
function composer({ slug, signedIn, enabled, prefill = '' }) {
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.
- 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>
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"'}>
<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')}">`}
${signedIn ? '' : `<input type="hidden" name="next" value="${slug ? `/vibe/${esc(slug)}` : (prefill ? `/vibe?ask=${encodeURIComponent(prefill)}` : '/vibe')}">`}
${attach}
${attach}
<label class="oc-sr" for="vb-input">Message Vibe</label>
<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>
<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>
<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>
</form>
new file mode 100644
new file mode 100644
@@ -0,0 +1,56 @@
@@ -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(); });