openvibers/openvibe.chat Commits
Add multi-model Vibe chats and Exchange history import (#419)
Changes
@@@ -28,6 +28,10 @@ TRUST_PROXY=2# Identity — OpenVibe.Network (user JWTs and service tokens are RS256 from here)OV_NETWORK_URL=https://openvibe.networkOV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000++# Vibe model comparison: AI's catalogue and runs, Exchange's old chat export, and optional Bench preferences.++#OV_AI_INTERNAL_URL=http://127.0.0.1:4700++#OV_EXCHANGE_INTERNAL_URL=http://127.0.0.1:5150++#OV_BENCH_URL=https://bench.openvibe.services# PEM with the Network public key; unset = fetched from ${OV_NETWORK_INTERNAL_URL}/api/.well-known/jwks#OV_NETWORK_PUBLIC_KEY=/opt/openvibe.live/data/keys/openvibe-tools-public.pem@@@ -499,6 +499,13 @@ socket, sends on Enter and keeps a draft typed while signed out through the sign- **API:** `GET /api/chat/vibe` (chats, allowance), `POST /api/chat/vibe { message }` → 201 `{ room, message }`,`PATCH /api/chat/vibe/:slug { name }`, `DELETE /api/chat/vibe/:slug`; then the rooms API for messages. People only(an API token gets 403).++- **Model comparison:** `POST /api/vibe/ask { message, models: [id, ...] }` runs one to three AI catalogue models in++ parallel on the person's own AI allowance and keys (`on_behalf_of`). `GET /api/vibe/chat` groups their answers under++ the question with model, provider, cost, elapsed time and run id. `POST /api/vibe/pick` keeps one model for follow-ups;++ `POST /api/vibe/prefer` submits a pairwise preference to Bench. `GET /api/vibe/models` fills the model sheet. Auto++ still uses Vibe's existing engine and tools. `GET|POST /api/vibe/import` brings unsealed Exchange chats once through++ its scoped service export; `POST /api/vibe/import/dismiss` hides the notice. The import needs the++ `exchange.chat.export` grant to Chat's service client.### Support inboxes and the website widget (openvibe.chat/inbox)nnew file mode 100644@@@ -0,0 +1,45 @@++-- phase: expand++-- Explicit model answers belong to the person's existing Vibe room and question.++ALTER TABLE room_messages ADD COLUMN IF NOT EXISTS vibe_model text COLLATE "C";++CREATE TABLE IF NOT EXISTS vibe_model_turns (++ message_id bigint NOT NULL REFERENCES room_messages(id) ON DELETE CASCADE,++ room_id bigint NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,++ model text COLLATE "C" NOT NULL,++ position smallint NOT NULL DEFAULT 0,++ status text COLLATE "C" NOT NULL DEFAULT 'writing',++ text text COLLATE "C",++ provider text COLLATE "C",++ cost_usd numeric,++ ms integer,++ run_id text COLLATE "C",++ error text COLLATE "C",++ created_at text COLLATE "C" DEFAULT ov_now(),++ PRIMARY KEY (message_id, model),++ CONSTRAINT vibe_model_turn_status CHECK (status IN ('writing', 'finished', 'failed'))++);++CREATE INDEX IF NOT EXISTS vibe_model_turns_room ON vibe_model_turns(room_id, message_id);++++CREATE TABLE IF NOT EXISTS vibe_chat_settings (++ room_id bigint PRIMARY KEY REFERENCES rooms(id) ON DELETE CASCADE,++ models jsonb NOT NULL DEFAULT '[]'::jsonb,++ system_prompt text COLLATE "C",++ temperature numeric,++ exchange_id text COLLATE "C" UNIQUE,++ CONSTRAINT vibe_chat_models_array CHECK (jsonb_typeof(models) = 'array'),++ CONSTRAINT vibe_chat_temperature CHECK (temperature IS NULL OR (temperature >= 0 AND temperature <= 2))++);++++CREATE TABLE IF NOT EXISTS vibe_model_preferences (++ message_id bigint NOT NULL REFERENCES room_messages(id) ON DELETE CASCADE,++ user_id bigint NOT NULL,++ winner text COLLATE "C" NOT NULL,++ bench_round_id text COLLATE "C",++ created_at text COLLATE "C" DEFAULT ov_now(),++ PRIMARY KEY (message_id, user_id)++);++++CREATE TABLE IF NOT EXISTS vibe_import_notices (++ user_id bigint PRIMARY KEY,++ dismissed_at text COLLATE "C",++ imported_at text COLLATE "C"++);@@@ -91,6 +91,51 @@.vb-chip:focus-visible { outline: 2px solid var(--oc-accent); outline-offset: 2px; }.vb-chip b { font-weight: 650; font-size: .95rem; color: var(--oc-text); }.vb-chip span { color: var(--oc-muted); font-size: .88rem; }++.vb-import[hidden] { display: none; }++.vb-import { width: 100%; display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 10px; padding: 12px; margin-bottom: 18px; border: 1px solid var(--oc-accent); border-radius: 12px; background: var(--oc-panel); }++.vb-import button { min-height: 44px; border: 1px solid var(--oc-line); border-radius: 8px; padding: 6px 12px; background: var(--oc-bg); color: var(--oc-text); cursor: pointer; }++.vb-import-model { color: var(--oc-muted); font-size: .8rem; margin: 0 0 8px; }++++/* A model choice stays near the composer. Every option can be reached with a 44 px target. */++.vb-model-picker { position: relative; margin-bottom: 6px; }++.vb-model-chip, .vb-model-sheet button, .vb-model-sheet summary, .vb-compare button { min-height: 44px; cursor: pointer; font: inherit; }++.vb-model-chip { padding: 6px 14px; border: 1px solid var(--oc-line); border-radius: 999px; background: var(--oc-panel); color: var(--oc-text); }++.vb-model-sheet[hidden] { display: none; }++.vb-model-sheet { position: absolute; z-index: 35; bottom: calc(100% + 8px); left: 0; width: min(440px, calc(100vw - 32px)); max-height: min(65vh, 600px); overflow-y: auto; padding: 16px; border: 1px solid var(--oc-line); border-radius: 18px; background: var(--oc-panel); color: var(--oc-text); box-shadow: 0 20px 60px #0005; }++.vb-model-sheet-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }++.vb-model-sheet-head button { border: 0; background: transparent; color: inherit; width: 44px; }++.vb-model-compare { display: flex; align-items: center; gap: 10px; min-height: 44px; }++.vb-model-compare input { width: 20px; height: 20px; }++#vb-model-list h3 { margin: 12px 0 4px; font-size: .78rem; color: var(--oc-muted); }++.vb-model-row { display: flex; flex-wrap: wrap; align-items: center; gap: 3px 12px; width: 100%; padding: 5px 0; border: 0; border-bottom: 1px solid var(--oc-line); background: transparent; color: inherit; text-align: left; }++.vb-model-choice { flex: 1 1 100%; border: 0; background: transparent; color: inherit; text-align: left; padding: 0; }++.vb-model-row small { color: var(--oc-muted); flex: 1 1 auto; }++.vb-model-row a, .vb-model-floor a { color: var(--oc-accent); }++.vb-model-sheet details { padding: 8px 0; }++.vb-model-sheet details label { display: grid; gap: 5px; margin: 8px 0; }++.vb-model-sheet textarea, .vb-model-sheet input[type=number] { min-height: 44px; width: 100%; border: 1px solid var(--oc-line); border-radius: 8px; background: var(--oc-bg); color: inherit; padding: 9px; font: inherit; }++.vb-model-floor { margin: 12px 0 0; padding-top: 10px; border-top: 1px solid var(--oc-line); color: var(--oc-muted); font-size: .8rem; }++.vb-compare { min-width: 0; width: 100%; }++.vb-compare-tabs { display: none; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }++.vb-compare-tab { border: 1px solid var(--oc-line); border-radius: 999px; padding: 5px 10px; background: var(--oc-panel); color: var(--oc-text); }++.vb-compare-tab.is-active { border-color: var(--oc-accent); }++.vb-compare-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; min-width: 0; }++.vb-compare-answer { min-width: 0; border: 1px solid var(--oc-line); border-radius: 16px; padding: 14px; background: var(--oc-panel); overflow-wrap: anywhere; }++.vb-compare-answer header { display: grid; gap: 3px; padding-bottom: 10px; border-bottom: 1px solid var(--oc-line); }++.vb-compare-answer header small { color: var(--oc-muted); }++.vb-compare-text { line-height: 1.55; padding: 14px 0; }++.vb-compare-actions { display: flex; flex-wrap: wrap; gap: 4px 8px; }++.vb-compare-actions button { border: 0; border-radius: 8px; padding: 6px 8px; background: transparent; color: var(--oc-accent); }++.vb-pulse { display: inline-block; width: 8px; height: 8px; margin-right: 8px; border-radius: 50%; background: var(--oc-accent); animation: vb-pulse 1.4s ease-in-out infinite; }++@keyframes vb-pulse { 50% { opacity: .3; } }++@media (max-width: 899px) {++ .vb-model-sheet { position: fixed; top: auto; bottom: 12px; left: 12px; right: 12px; width: auto; max-height: min(80dvh, 640px); }++ .vb-compare-tabs { display: flex; }++ .vb-compare-grid { display: block; }++ .vb-compare-answer { display: none; }++ .vb-compare-answer.is-active { display: block; }++ .vb-suggest { grid-template-columns: 1fr; }++}/* ── Memory (/vibe/memory): what Vibe remembers about the person ──────────── */.vb-page { max-width: 640px; margin: 0 auto; padding: 18px 0 8px; display: grid; gap: 16px; }@@@ -26,6 +26,83 @@var previewEl = document.getElementById('vb-preview');var picture = null; // { file, url }: a picture waiting to go with the next messagevar reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;++ var chosenModels = Array.isArray(cfg.models) ? cfg.models.slice(0, 3) : [];++ function persistModelChoice() {++ if (!room) return;++ fetch('/api/vibe/settings', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },++ body: JSON.stringify({ slug: room, models: chosenModels }) }).catch(function () { notice('Model choice could not be saved.'); });++ }++ try { var requested = new URLSearchParams(location.search).get('models'); if (requested) chosenModels = requested.split(',').filter(Boolean).slice(0, 3); } catch (_) {}++ var modelChip = document.getElementById('vb-model-chip');++ var modelSheet = document.getElementById('vb-model-sheet');++ var modelList = document.getElementById('vb-model-list');++ var compareSwitch = document.getElementById('vb-model-compare');++ var modelCatalog = [];++ function modelLabel(id) { var m = modelCatalog.find(function (x) { return x.slug === id; }); return m ? m.name : id; }++ function syncModelChip() {++ if (modelChip) modelChip.textContent = (chosenModels.length > 1 ? chosenModels.length + ' models' : chosenModels.length ? modelLabel(chosenModels[0]) : 'Auto') + ' ▾';++ if (compareSwitch) compareSwitch.checked = chosenModels.length > 1 || compareSwitch.checked;++ }++ function paintModelList() {++ if (!modelList) return;++ modelList.replaceChildren();++ var auto = document.createElement('button'); auto.type = 'button'; auto.className = 'vb-model-row'; auto.textContent = 'Auto · OpenVibe picks';++ auto.addEventListener('click', function () { chosenModels = []; syncModelChip(); paintModelList(); modelSheet.hidden = true; persistModelChoice(); }); modelList.appendChild(auto);++ var groups = new Map(); modelCatalog.forEach(function (m) { var maker = m.family || 'Other'; if (!groups.has(maker)) groups.set(maker, []); groups.get(maker).push(m); });++ groups.forEach(function (models, maker) {++ var h = document.createElement('h3'); h.textContent = '✦ ' + maker; modelList.appendChild(h);++ models.forEach(function (m) {++ var row = document.createElement('div'); row.className = 'vb-model-row';++ var choice = document.createElement('button'); choice.type = 'button'; choice.className = 'vb-model-choice';++ choice.textContent = (chosenModels.includes(m.slug) ? '✓ ' : '') + m.name;++ choice.addEventListener('click', function () {++ if (compareSwitch && compareSwitch.checked) {++ if (chosenModels.includes(m.slug)) chosenModels = chosenModels.filter(function (id) { return id !== m.slug; });++ else if (chosenModels.length < 3) chosenModels.push(m.slug);++ } else { chosenModels = [m.slug]; modelSheet.hidden = true; }++ syncModelChip(); paintModelList(); persistModelChoice();++ }); row.appendChild(choice);++ var offer = (m.offers || []).find(function (x) { return x.price; });++ var facts = document.createElement('small');++ facts.textContent = offer ? '$' + Number(offer.price.in).toFixed(2) + ' in · $' + Number(offer.price.out).toFixed(2) + ' out / million · ' + (offer.speed && offer.speed.p50_ms ? (offer.speed.p50_ms / 1000).toFixed(1) + 's' : 'speed varies') : 'Price and speed vary';++ row.appendChild(facts);++ var ranks = document.createElement('a'); ranks.href = 'https://openvibe.report/ai/models/' + encodeURIComponent(m.slug); ranks.textContent = 'How it ranks'; row.appendChild(ranks);++ modelList.appendChild(row);++ });++ });++ }++ if (modelChip && modelSheet) {++ modelChip.addEventListener('click', function () {++ modelSheet.hidden = !modelSheet.hidden; modelChip.setAttribute('aria-expanded', String(!modelSheet.hidden));++ if (!modelSheet.hidden && !modelCatalog.length) fetch('/api/vibe/models', { credentials: 'same-origin' }).then(function (r) { return r.ok ? r.json() : { models: [] }; }).then(function (d) { modelCatalog = (d.models || []).filter(function (m) { return m && m.slug && m.name; }); paintModelList(); syncModelChip(); }).catch(function () { modelList.textContent = 'Models are unavailable right now.'; });++ });++ document.getElementById('vb-model-close').addEventListener('click', function () { modelSheet.hidden = true; modelChip.setAttribute('aria-expanded', 'false'); });++ compareSwitch.addEventListener('change', function () { if (!compareSwitch.checked && chosenModels.length > 1) chosenModels = chosenModels.slice(0, 1); syncModelChip(); paintModelList(); persistModelChoice(); });++ document.getElementById('vb-system-prompt').value = cfg.system_prompt || '';++ document.getElementById('vb-temperature').value = cfg.temperature == null ? 1 : cfg.temperature;++ syncModelChip();++ }++ var importNotice = document.getElementById('vb-import');++ if (importNotice) fetch('/api/vibe/import', { credentials: 'same-origin' }).then(function (r) { return r.ok ? r.json() : null; }).then(function (d) {++ if (!d || !d.show) return;++ importNotice.hidden = false;++ var title = document.createElement('span'); title.textContent = 'Bring your ' + d.count + ' chats from Exchange'; importNotice.appendChild(title);++ var bring = document.createElement('button'); bring.type = 'button'; bring.textContent = 'Bring chats'; importNotice.appendChild(bring);++ var dismiss = document.createElement('button'); dismiss.type = 'button'; dismiss.textContent = 'Dismiss'; importNotice.appendChild(dismiss);++ bring.addEventListener('click', function () { bring.disabled = true; fetch('/api/vibe/import', { method: 'POST', credentials: 'same-origin' }).then(function (r) { if (!r.ok) throw new Error('Import could not finish'); location.reload(); }).catch(function () { bring.disabled = false; notice('Import could not finish. Try again.'); }); });++ dismiss.addEventListener('click', function () { fetch('/api/vibe/import/dismiss', { method: 'POST', credentials: 'same-origin' }).then(function () { importNotice.hidden = true; }); });++ }).catch(function () {});++ if (form) form.addEventListener('submit', function (e) {++ if (!chosenModels.length || (e.submitter && e.submitter === stopBtn) || form.dataset.signin) return;++ var message = input.value.trim(); if (!message || sending) { e.preventDefault(); return; }++ e.preventDefault(); e.stopImmediatePropagation(); sending = true;++ fetch('/api/vibe/ask', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },++ body: JSON.stringify({ message: message, slug: room, models: chosenModels, system_prompt: document.getElementById('vb-system-prompt').value,++ temperature: Number(document.getElementById('vb-temperature').value) }) })++ .then(function (r) { return r.json().then(function (d) { if (!r.ok) throw new Error(d.error || 'Could not send'); return d; }); })++ .then(function (d) { location.href = '/vibe/' + encodeURIComponent(d.chat.slug); })++ .catch(function (err) { sending = false; notice(err.message); });++ });function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }function token() { var m = /(?:^|;\s*)ov_token=([^;]+)/.exec(document.cookie || ''); return m ? decodeURIComponent(m[1]) : undefined; }@@@ -629,6 +706,47 @@.catch(function () { notice('You seem to be offline.'); });});if (cfg.waiting) showWait();++ var compareRows = app.querySelectorAll('[data-vb-question]');++ if (compareRows.length) {++ app.addEventListener('click', function (e) {++ var tab = e.target.closest('[data-vb-tab]');++ if (tab) {++ var wrap = tab.closest('[data-vb-question]'), index = tab.getAttribute('data-vb-tab');++ wrap.querySelectorAll('[data-vb-tab]').forEach(function (x) { x.classList.toggle('is-active', x === tab); x.setAttribute('aria-selected', String(x === tab)); });++ wrap.querySelectorAll('[data-vb-answer]').forEach(function (x) { x.classList.toggle('is-active', x.getAttribute('data-vb-answer') === index); });++ return;++ }++ var copy = e.target.closest('[data-vb-copy]');++ if (copy) { var section = copy.closest('[data-vb-answer]'); if (navigator.clipboard) navigator.clipboard.writeText(section.querySelector('[data-vb-text]').textContent); return; }++ var pick = e.target.closest('[data-vb-pick]'), prefer = e.target.closest('[data-vb-prefer]');++ if (!pick && !prefer) return;++ var button = pick || prefer, model = button.getAttribute(pick ? 'data-vb-pick' : 'data-vb-prefer');++ fetch(pick ? '/api/vibe/pick' : '/api/vibe/prefer', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },++ body: JSON.stringify({ message_id: Number(button.getAttribute('data-vb-message')), model: model }) })++ .then(function (r) { return r.json().then(function (d) { if (!r.ok) throw new Error(d.error || 'That did not work'); return d; }); })++ .then(function () { if (pick) { chosenModels = [model]; syncModelChip(); } else { button.textContent = 'Preference recorded'; button.disabled = true; } })++ .catch(function (err) { notice(err.message); });++ });++ function refreshCompare() {++ if (!room) return;++ fetch('/api/vibe/chat?slug=' + encodeURIComponent(room), { credentials: 'same-origin' }).then(function (r) { return r.ok ? r.json() : null; }).then(function (d) {++ if (!d) return;++ (d.messages || []).forEach(function (m) {++ if (!m.answers) return;++ var wrap = app.querySelector('[data-vb-question="' + m.id + '"]'); if (!wrap) return;++ m.answers.forEach(function (a, i) {++ var panel = wrap.querySelector('[data-vb-answer="' + i + '"]'); if (!panel) return;++ var body = panel.querySelector('[data-vb-text]');++ if (a.status === 'finished') body.innerHTML = MD.render(a.text);++ else if (a.status === 'failed') body.textContent = a.error || 'Could not answer';++ var head = panel.querySelector('small'); if (head && a.status === 'finished') head.textContent = [a.provider, a.cost_usd == null ? null : '$' + Number(a.cost_usd).toFixed(4), a.ms == null ? null : (a.ms / 1000).toFixed(1) + 's'].filter(Boolean).join(' · ');++ var dot = wrap.querySelector('[data-vb-tab="' + i + '"] span'); if (dot) dot.textContent = a.status === 'finished' ? '●' : '○';++ });++ });++ }).catch(function () {});++ }++ refreshCompare(); setInterval(refreshCompare, 1500);++ }canvasInit();connect();window.addEventListener('pagehide', function () { var w = ws; room = null; if (w) w.close(); });@@@ -13,6 +13,7 @@ const config = require('../config');let _ai = null;let _http = null;let _tokens = null;++let _complete = null;// Why the last run answered nothing, when the caller can show it: `ai.resting` today. Cleared by a run that// succeeds. A product asks lastProblem() instead of turning "no answer" into an error.let _lastProblem = null;@@@ -144,5 +145,36 @@ async function stream(workflow, input, opts = {}, onText = () => {}) {}}--module.exports = { structured, stream, enabled, http, lastProblem, _setClient(c) { _ai = c; }, _setHttp(h) { _http = h; }, _setTokens(t) { _tokens = t; },-- _setProblem(p) { _lastProblem = p; }, _reset() { _ai = null; _http = null; _tokens = null; _lastProblem = null; } };++/** An explicit catalogue model, billed by AI to the person named in on_behalf_of. */++async function complete({ model, messages, subject, temperature = null }) {++ if (_complete) return _complete({ model, messages, subject, temperature });++ if (!subject || !model) throw new Error('A person and model are required');++ if (!_tokens) ai();++ const token = await _tokens.getToken();++ const res = await fetch(`${config.ai.internalUrl}/api/v1/runs?wait=${Math.max(0, Math.floor(config.ai.waitMs))}`, {++ method: 'POST',++ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Accept: 'application/json' },++ body: JSON.stringify({ workflow: 'ai.complete', input: { model, messages, max_tokens: 2048,++ ...(temperature == null ? {} : { temperature }) }, on_behalf_of: { type: 'user', id: subject }, options: { cache: 'none' } }),++ signal: AbortSignal.timeout(config.ai.waitMs + 10000),++ });++ const body = await res.json();++ const run = body && (body.run || body);++ if (!res.ok || !run || !['succeeded', 'cached'].includes(run.status)) throw new Error((run && run.error && run.error.code) || `AI answered ${res.status}`);++ const choice = run.output && run.output.choices && run.output.choices[0];++ return { text: String((choice && choice.message && choice.message.content) || ''),++ model: (run.output && run.output.model) || (run.provenance && run.provenance.model) || model,++ provider: run.provenance && run.provenance.provider || null,++ cost_usd: run.usage && run.usage.cost_usd != null ? Number(run.usage.cost_usd) : null,++ ms: run.started_at && run.finished_at ? Math.max(0, Date.parse(run.finished_at) - Date.parse(run.started_at)) : null,++ run_id: run.id || null };++}++++async function catalog() {++ const res = await fetch(`${config.ai.internalUrl}/api/v1/exchange/catalog`, { signal: AbortSignal.timeout(8000) });++ if (!res.ok) throw new Error(`AI catalogue answered ${res.status}`);++ return res.json();++}++++module.exports = { structured, stream, complete, catalog, enabled, http, lastProblem, _setClient(c) { _ai = c; }, _setComplete(c) { _complete = c; }, _setHttp(h) { _http = h; }, _setTokens(t) { _tokens = t; },++ _setProblem(p) { _lastProblem = p; }, _reset() { _ai = null; _http = null; _tokens = null; _complete = null; _lastProblem = null; } };@@@ -68,6 +68,8 @@ const EXPORTS = [['vibe_memory.json', `SELECT id, text, source, created_at FROM vibe_memory WHERE user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],['vibe_memory_off.json', `SELECT since FROM vibe_memory_off WHERE user_id = ANY(@ids) LIMIT ${LIMIT}`],['vibe_own_engine.json', `SELECT since FROM vibe_own_engine WHERE user_id = ANY(@ids) LIMIT ${LIMIT}`],++ ['vibe_model_preferences.json', `SELECT message_id, winner, bench_round_id, created_at FROM vibe_model_preferences WHERE user_id = ANY(@ids) LIMIT ${LIMIT}`],++ ['vibe_import_notices.json', `SELECT dismissed_at, imported_at FROM vibe_import_notices WHERE user_id = ANY(@ids) LIMIT ${LIMIT}`],// What they asked Vibe by voice on their machines, the last 7 days (server/chat/internal-vibe.js).// Which of their agents' runs were posted into their Vibe chats (server/chat/internal-vibe.js).['vibe_deliveries.json', `SELECT room_id, message_id, created_at FROM vibe_deliveries WHERE user_id = ANY(@ids) ORDER BY created_at DESC LIMIT ${LIMIT}`],@@@ -130,6 +132,8 @@ const ERASE_AFTER_ROOMS = [['vibe_memory', 'DELETE FROM vibe_memory WHERE user_id = ANY(@ids)'],['vibe_memory_off', 'DELETE FROM vibe_memory_off WHERE user_id = ANY(@ids)'],['vibe_own_engine', 'DELETE FROM vibe_own_engine WHERE user_id = ANY(@ids)'],++ ['vibe_model_preferences', 'DELETE FROM vibe_model_preferences WHERE user_id = ANY(@ids)'],++ ['vibe_import_notices', 'DELETE FROM vibe_import_notices WHERE user_id = ANY(@ids)'],['vibe_asks', 'DELETE FROM vibe_asks WHERE user_id = ANY(@ids)'],['agent_room_votes', 'DELETE FROM agent_room_poll_votes WHERE user_id = ANY(@ids)'],['room_reactions', 'DELETE FROM room_message_reactions WHERE user_id = ANY(@ids)'],@@@ -226,6 +226,12 @@ module.exports = {vibe: process.env.CHAT_VIBE !== '0',vibeRepliesPerDay: Math.max(1, int(process.env.CHAT_VIBE_REPLIES_PER_DAY, 100)),},++ bench: {++ url: strip(process.env.OV_BENCH_URL || 'https://bench.openvibe.services'),++ audience: 'openvibe.bench',++ scope: 'bench.round.create bench.judgement.cast',++ },++ exchange: { internalUrl: strip(process.env.OV_EXCHANGE_INTERNAL_URL || 'http://127.0.0.1:5150') },// The chat apps (Telegram, Discord, Slack): your OpenVibe agent in each of them (the agent plan of// 2026-10-30, stage A4). Each app is OFF until its own token and its own secret are here: the owner's@@@ -340,7 +340,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,++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,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@@@ -29,6 +29,8 @@ const rooms = require('./rooms');const vibe = require('./vibe');const vibeResults = require('./vibe-results');const aiClient = require('../ai/client');++const compare = require('./vibe-compare');++const importer = require('./vibe-import');const router = express.Router();router.use(express.json({ limit: '32kb' }));@@@ -36,6 +38,20 @@ router.use(requireAuth);const SIX_HOURS = 6 * 3600 * 1000;const asked = limits('chat.vibe.ask', { minute: 12, hour: 300 });++// The limiter counts calls; charge a compare once for each answer before any message is written.++const answerLimit = async (req, res, next) => {++ let count = Array.isArray(req.body && req.body.models) ? Math.min(3, req.body.models.length) : 1;++ if (!Array.isArray(req.body && req.body.models) && !(req.body && req.body.fresh)) {++ try {++ const slug = req.body && req.body.slug || await vibe.current(req.user);++ const room = slug && await vibe.ownChat(slug, req.user);++ if (room) count = Math.max(1, (await compare.settings(room)).models.length);++ } catch { /* the route below reports a storage error */ }++ }++ let remaining = Math.max(1, count);++ const charge = () => remaining-- > 0 ? asked(req, res, charge) : next();++ return charge();++};const undone = limits('chat.vibe.undo', { minute: 12, hour: 300 }); // like ask: undoing is as rare as askingconst listened = limits('chat.vibe.listen', { minute: 20, hour: 200 });const AUDIO_MAX = 1024 * 1024;@@@ -47,7 +63,8 @@ const at = (ts) => { const d = new Date(String(ts || '').includes('T') ? ts : `$// A Vibe message: its text, and what Vibe really did for the person (the chips; server/rooms/vibe-results.js).const wire = (m) => {const results = vibeResults.list(m.results);-- return { id: Number(m.id), from: m.ai_member_id ? 'vibe' : 'you', text: String(m.message || ''), at: at(m.created_at), ...(results.length ? { results } : {}) };++ return { id: Number(m.id), from: m.ai_member_id ? 'vibe' : 'you', text: String(m.message || ''), at: at(m.created_at),++ ...(m.vibe_model ? { model: m.vibe_model } : {}), ...(results.length ? { results } : {}) };};const chatOf = (room) => (room ? { slug: room.slug, name: room.name || 'Vibe', url: `https://openvibe.chat/vibe/${encodeURIComponent(room.slug)}` } : null);@@@ -62,7 +79,34 @@ router.get('/chat', personOnly, async (req, res) => {if (!room) return res.set('Cache-Control', 'private, no-store').json({ chat: null, messages: [] });const after = Number(req.query.after) || null;const rows = await rooms.history(room, after ? { after, limit: 50 } : { limit: 40 });-- return res.set('Cache-Control', 'private, no-store').json({ chat: chatOf(room), messages: rows.filter((m) => !m.is_deleted).map(wire) });++ const by = await compare.answers(room, rows.filter((m) => !m.ai_member_id).map((m) => Number(m.id)));++ return res.set('Cache-Control', 'private, no-store').json({ chat: { ...chatOf(room), ...(await compare.settings(room)) },++ messages: rows.filter((m) => !m.is_deleted).map((m) => ({ ...wire(m), ...(by.has(Number(m.id)) ? { answers: by.get(Number(m.id)) } : {}) })) });++});++++router.get('/models', personOnly, async (_req, res) => {++ try { return res.set('Cache-Control', 'private, max-age=120').json(await aiClient.catalog()); }++ catch { return res.status(503).json({ error: 'Models are unavailable right now' }); }++});++router.post('/settings', personOnly, asked, async (req, res) => {++ try {++ const room = await vibe.ownChat(String(req.body && req.body.slug || ''), req.user);++ if (!room) return res.status(404).json({ error: 'No such chat' });++ return res.json(await compare.saveSettings(room, { ...(await compare.settings(room)), ...req.body }));++ } catch (err) { return res.status(err.status || 500).json({ error: err.status ? err.message : 'Could not save settings' }); }++});++++router.get('/import', personOnly, async (req, res) => {++ try { return res.set('Cache-Control', 'private, no-store').json(await importer.status(req.user)); }++ catch { return res.status(503).json({ error: 'Exchange chat import is unavailable' }); }++});++router.post('/import', personOnly, asked, async (req, res) => {++ try { return res.set('Cache-Control', 'private, no-store').json(await importer.bring(req.user)); }++ catch { return res.status(503).json({ error: 'Exchange chat import could not finish' }); }++});++router.post('/import/dismiss', personOnly, async (req, res) => {++ try { return res.json(await importer.dismiss(req.user)); }++ catch { return res.status(500).json({ error: 'Could not dismiss this notice' }); }});/**@@@ -128,11 +172,13 @@ router.post('/listen', personOnly, listened, audioBody, async (req, res) => {return res.set('Cache-Control', 'private, no-store').json({ text });});--router.post('/ask', personOnly, asked, async (req, res) => {++router.post('/ask', personOnly, (req, res, next) => (req.body && req.body.models !== undefined && !compare.modelIds(req.body.models)++ ? res.status(422).json({ error: 'Choose one to three distinct models' }) : next()), answerLimit, async (req, res) => {const b = req.body && typeof req.body === 'object' ? req.body : {};const message = String(b.message || '').replace(/\r\n/g, '\n').trim();if (!message) return res.status(422).json({ error: 'Write something first' });if (message.length > 8000) return res.status(422).json({ error: 'That is longer than Vibe reads in one message' });++ if (b.models !== undefined && !compare.modelIds(b.models)) return res.status(422).json({ error: 'Choose one to three distinct models' });const timezone = zone(b.timezone);try {let room = b.slug ? await vibe.ownChat(String(b.slug), req.user) : null;@@@ -141,12 +187,22 @@ router.post('/ask', personOnly, asked, async (req, res) => {const moved = last ? Date.parse(at(last.updated_at || last.last_message_at || last.created_at) || 0) : 0;if (last && Date.now() - moved < SIX_HOURS) room = last;}++ if (!room && b.models) {++ room = await vibe.createRoom(req.user, message);++ try {++ const posted = await compare.ask(room, req.user, message, b.models, { system_prompt: b.system_prompt, temperature: b.temperature });++ return res.status(201).json({ chat: chatOf(room), message: wire(posted) });++ } catch (err) { await vibe.erase(room.id); throw err; }++ }if (!room) {const out = await vibe.start(req.user, message, { timezone });const full = await vibe.ownChat(out.room.slug, req.user);return res.status(201).json({ chat: chatOf(full || out.room), message: wire(out.message) });}-- const posted = await rooms.post(room, req.user, message, { timezone });++ const selected = b.models || (await compare.settings(room)).models;++ const posted = selected.length ? await compare.ask(room, req.user, message, selected,++ { ...(b.system_prompt === undefined ? {} : { system_prompt: b.system_prompt }), ...(b.temperature === undefined ? {} : { temperature: b.temperature }) })++ : await rooms.post(room, req.user, message, { timezone });try { require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message: posted }); } catch { /* the socket is optional */ }return res.status(201).json({ chat: chatOf(room), message: wire(posted) });} catch (err) {@@@ -154,4 +210,13 @@ router.post('/ask', personOnly, asked, async (req, res) => {}});++router.post('/pick', personOnly, asked, async (req, res) => {++ try { return res.json(await compare.pick(req.user, Number(req.body && req.body.message_id), String(req.body && req.body.model || ''))); }++ catch (err) { return res.status(err.status || 500).json({ error: err.status ? err.message : 'Could not pick that answer' }); }++});++router.post('/prefer', personOnly, asked, async (req, res) => {++ try { return res.json(await compare.prefer(req.user, Number(req.body && req.body.message_id), String(req.body && req.body.model || ''))); }++ catch (err) { return res.status(err.status || 500).json({ error: err.status ? err.message : 'Could not record that preference' }); }++});++module.exports = router;nnew file mode 100644@@@ -0,0 +1,130 @@++'use strict';++/** Explicit model turns in a person's existing Vibe room. */++const db = require('../db/database');++const ai = require('../ai/client');++const rooms = require('./rooms');++const config = require('../config');++let benchOverride = null;++++const modelIds = (value) => Array.isArray(value) && value.length >= 1 && value.length <= 3++ && value.every((id) => typeof id === 'string' && /^[a-z0-9][a-z0-9._/@:-]{1,255}$/i.test(id))++ && new Set(value).size === value.length;++++async function settings(room) {++ const row = await db.get('SELECT models, system_prompt, temperature FROM vibe_chat_settings WHERE room_id = ?', [room.id]);++ if (!row) return { models: [], system_prompt: null, temperature: null };++ return { ...row, models: Array.isArray(row.models) ? row.models : JSON.parse(row.models || '[]') };++}++++async function saveSettings(room, { models, system_prompt, temperature }) {++ if (!modelIds(models) && !(Array.isArray(models) && !models.length)) throw Object.assign(new Error('Choose up to three distinct models'), { status: 422 });++ if (system_prompt != null && (typeof system_prompt !== 'string' || system_prompt.length > 4000)) throw Object.assign(new Error('The system prompt is too long'), { status: 422 });++ if (temperature != null && (!Number.isFinite(Number(temperature)) || Number(temperature) < 0 || Number(temperature) > 2)) throw Object.assign(new Error('Temperature must be from 0 to 2'), { status: 422 });++ await db.run(`INSERT INTO vibe_chat_settings (room_id, models, system_prompt, temperature) VALUES (?, ?::jsonb, ?, ?)++ ON CONFLICT (room_id) DO UPDATE SET models = excluded.models, system_prompt = excluded.system_prompt, temperature = excluded.temperature`,++ [room.id, JSON.stringify(models), system_prompt || null, temperature == null ? null : Number(temperature)]);++ return settings(room);++}++++async function answers(room, messageIds) {++ if (!messageIds.length) return new Map();++ const rows = await db.all('SELECT * FROM vibe_model_turns WHERE room_id = ? AND message_id = ANY(?::bigint[]) ORDER BY message_id, position', [room.id, messageIds]);++ const by = new Map();++ for (const r of rows) {++ if (!by.has(Number(r.message_id))) by.set(Number(r.message_id), []);++ by.get(Number(r.message_id)).push({ model: r.model, status: r.status, text: r.text || '', provider: r.provider || null,++ cost_usd: r.cost_usd == null ? null : Number(r.cost_usd), ms: r.ms == null ? null : Number(r.ms), run_id: r.run_id || null,++ ...(r.error ? { error: r.error } : {}) });++ }++ return by;++}++++async function run(room, user, asked, models) {++ const history = await rooms.history(room, { before: asked.id, limit: 24 });++ const earlier = await answers(room, history.filter((m) => !m.ai_member_id).map((m) => Number(m.id)));++ const prefs = await settings(room);++ await Promise.allSettled(models.map(async (model) => {++ try {++ const previous = [];++ for (const m of history.filter((m) => !m.is_deleted)) {++ previous.push({ role: m.ai_member_id ? 'assistant' : 'user', content: m.message });++ const group = earlier.get(Number(m.id));++ if (group) {++ const answer = group.find((a) => a.model === model && a.status === 'finished') || group.find((a) => a.status === 'finished');++ if (answer) previous.push({ role: 'assistant', content: answer.text });++ }++ }++ const messages = [...(prefs.system_prompt ? [{ role: 'system', content: prefs.system_prompt }] : []), ...previous, { role: 'user', content: asked.message }];++ const result = await ai.complete({ model, messages, subject: user.subject_id, temperature: prefs.temperature });++ await db.run(`UPDATE vibe_model_turns SET status = 'finished', text = ?, provider = ?, cost_usd = ?, ms = ?, run_id = ?++ WHERE message_id = ? AND model = ?`, [result.text.slice(0, 8000), result.provider, result.cost_usd, result.ms, result.run_id, asked.id, model]);++ } catch (err) {++ await db.run("UPDATE vibe_model_turns SET status = 'failed', error = ? WHERE message_id = ? AND model = ?",++ [String(err.message || 'The model could not answer').slice(0, 250), asked.id, model]);++ }++ }));++}++++async function ask(room, user, message, models, options = {}) {++ if (!modelIds(models)) throw Object.assign(new Error('Choose one to three distinct models'), { status: 422 });++ const posted = await rooms.post(room, user, message, { ai: false });++ await db.tx(async () => {++ for (const [position, model] of models.entries()) await db.run('INSERT INTO vibe_model_turns (message_id, room_id, model, position) VALUES (?, ?, ?, ?)', [posted.id, room.id, model, position]);++ });++ const prefs = await settings(room);++ await saveSettings(room, { ...prefs, ...options, models });++ setImmediate(() => run(room, user, posted, models).catch((err) => console.error('[Vibe compare]', err.message)));++ return posted;++}++++async function pick(user, messageId, model) {++ const row = await db.get(`SELECT t.room_id FROM vibe_model_turns t JOIN rooms r ON r.id = t.room_id++ WHERE t.message_id = ? AND t.model = ? AND t.status = 'finished' AND r.owner_id = ? AND r.assistant = 1`, [messageId, model, user.id]);++ if (!row) throw Object.assign(new Error('That answer is not in your chat'), { status: 404 });++ const room = await db.get('SELECT * FROM rooms WHERE id = ?', [row.room_id]);++ const prefs = await settings(room);++ await saveSettings(room, { ...prefs, models: [model] });++ return { chat: { slug: room.slug }, model };++}++++async function prefer(user, messageId, model) {++ const row = await db.get(`SELECT t.room_id, m.message FROM vibe_model_turns t JOIN rooms r ON r.id = t.room_id++ JOIN room_messages m ON m.id = t.message_id WHERE t.message_id = ? AND t.model = ? AND t.status = 'finished'++ AND r.owner_id = ? AND r.assistant = 1`, [messageId, model, user.id]);++ if (!row) throw Object.assign(new Error('That answer is not in your chat'), { status: 404 });++ const all = (await answers({ id: row.room_id }, [messageId])).get(messageId) || [];++ const rival = all.find((a) => a.model !== model && a.status === 'finished');++ if (!rival) throw Object.assign(new Error('Two finished answers are needed to compare'), { status: 422 });++ if (await db.get('SELECT message_id FROM vibe_model_preferences WHERE message_id = ? AND user_id = ?', [messageId, user.id]))++ throw Object.assign(new Error('You already chose an answer'), { status: 409 });++ if (!config.oauth.clientSecret && !benchOverride) throw Object.assign(new Error('Preference voting is unavailable'), { status: 503 });++ let token = null;++ if (!benchOverride) {++ const { createServiceTokenClient } = require('openvibe-sdk/auth');++ const tokens = createServiceTokenClient({ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret, audience: config.bench.audience, scope: config.bench.scope });++ token = await tokens.getToken();++ }++ async function call(path, body, subject = false) {++ if (benchOverride) return path.endsWith('/votes') ? benchOverride.vote({ path, body, subject: user.subject_id }) : benchOverride.createRound(body);++ const res = await fetch(`${config.bench.url}${path}`, { method: 'POST', headers: { Authorization: `Bearer ${token}`,++ 'Content-Type': 'application/json', ...(subject ? { 'x-ov-subject': user.subject_id } : {}) },++ body: JSON.stringify(body), signal: AbortSignal.timeout(15000) });++ const out = await res.json().catch(() => null);++ if (!res.ok) throw Object.assign(new Error('Bench could not record the preference'), { status: 503 });++ return out;++ }++ const entries = [all.find((a) => a.model === model), rival];++ const built = await call('/api/v1/arena/rounds', { prompt: row.message.slice(0, 4000), category: 'chat', source: 'chat',++ entries: entries.map((a) => ({ provider: a.provider || 'openvibe', model: a.model, answer: a.text,++ cost_usd: a.cost_usd, ms: a.ms, run_id: a.run_id })) });++ const sides = built && built.entries || [];++ if (!built || !built.round || sides.length < 2) throw Object.assign(new Error('Bench did not create the comparison'), { status: 502 });++ await call(`/api/v1/arena/rounds/${encodeURIComponent(built.round.id)}/votes`,++ { outcome: 'left', pair: { left: sides[0].id, right: sides[1].id } }, true);++ await db.run('INSERT INTO vibe_model_preferences (message_id, user_id, winner, bench_round_id) VALUES (?, ?, ?, ?)',++ [messageId, user.id, model, built.round.id]);++ return { recorded: true, model };++}++++module.exports = { modelIds, settings, saveSettings, answers, ask, pick, prefer, _setBench(client) { benchOverride = client; } };nnew file mode 100644@@@ -0,0 +1,75 @@++'use strict';++const config = require('../config');++const db = require('../db/database');++const vibe = require('./vibe');++++let tokens = null;++let exportOverride = null;++async function exportFor(subject) {++ if (exportOverride) return exportOverride(subject);++ if (!config.oauth.clientSecret) throw Object.assign(new Error('Import is unavailable'), { status: 503 });++ if (!tokens) {++ const { createServiceTokenClient } = require('openvibe-sdk/auth');++ tokens = createServiceTokenClient({ tokenUrl: `${config.networkInternalUrl}/oauth/token`,++ clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,++ audience: 'openvibe.exchange', scope: 'exchange.chat.export' });++ }++ const token = await tokens.getToken();++ const res = await fetch(`${config.exchange.internalUrl}/internal/exchange/chat/export`, {++ headers: { Authorization: `Bearer ${token}`, 'x-ov-subject': subject, Accept: 'application/json' },++ signal: AbortSignal.timeout(15000),++ });++ if (!res.ok) throw Object.assign(new Error('Exchange could not export your chats'), { status: 503 });++ return res.json();++}++++async function status(user) {++ const notice = await db.get('SELECT dismissed_at, imported_at FROM vibe_import_notices WHERE user_id = ?', [user.id]);++ if (notice && (notice.dismissed_at || notice.imported_at)) return { count: 0, show: false };++ const body = await exportFor(user.subject_id);++ const ids = (body.chats || []).map((c) => c.id);++ if (!ids.length) return { count: 0, show: false };++ const existing = await db.all('SELECT exchange_id FROM vibe_chat_settings WHERE exchange_id = ANY(?::text[])', [ids]);++ const imported = new Set(existing.map((r) => r.exchange_id));++ const count = ids.filter((id) => !imported.has(id)).length;++ return { count, show: count > 0 };++}++++async function dismiss(user) {++ await db.run(`INSERT INTO vibe_import_notices (user_id, dismissed_at) VALUES (?, ov_now())++ ON CONFLICT (user_id) DO UPDATE SET dismissed_at = ov_now()`, [user.id]);++ return { show: false };++}++++async function bring(user) {++ const body = await exportFor(user.subject_id);++ let imported = 0;++ for (const c of body.chats || []) {++ if (!/^exc_[0-9A-Za-z]+$/.test(String(c.id)) || !Array.isArray(c.messages)) continue;++ if (await db.get('SELECT room_id FROM vibe_chat_settings WHERE exchange_id = ?', [c.id])) continue;++ const room = await vibe.createRoom(user, c.title || 'Imported chat');++ try {++ const member = await db.get('SELECT id FROM room_ai_members WHERE room_id = ?', [room.id]);++ await db.tx(async () => {++ await db.run('UPDATE rooms SET name = ?, created_at = ?, last_message_at = ? WHERE id = ?', [String(c.title || 'Imported chat').slice(0, 120), c.created_at || c.updated_at, c.updated_at || c.created_at, room.id]);++ await db.run('INSERT INTO vibe_chat_settings (room_id, models, exchange_id) VALUES (?, ?::jsonb, ?)',++ [room.id, JSON.stringify((c.models || []).filter((x) => typeof x === 'string').slice(0, 3)), c.id]);++ for (const m of c.messages.slice(0, 1000)) {++ if (!['user', 'assistant', 'system'].includes(m.role) || typeof m.content !== 'string') continue;++ await db.run(`INSERT INTO room_messages (room_id, user_id, subject_id, ai_member_id, message, created_at, vibe_model)++ VALUES (?, ?, ?, ?, ?, ?, ?)`, [room.id, m.role === 'assistant' ? 0 : user.id,++ m.role === 'assistant' ? null : user.subject_id, m.role === 'assistant' ? member.id : null,++ m.content, m.created_at || c.created_at || c.updated_at, m.role === 'assistant' ? m.model || null : null]);++ }++ const count = await db.get('SELECT COUNT(*) AS n FROM room_messages WHERE room_id = ?', [room.id]);++ await db.run('UPDATE rooms SET message_count = ? WHERE id = ?', [Number(count.n), room.id]);++ });++ imported += 1;++ } catch (err) { await vibe.erase(room.id); throw err; }++ }++ await db.run(`INSERT INTO vibe_import_notices (user_id, imported_at) VALUES (?, ov_now())++ ON CONFLICT (user_id) DO UPDATE SET imported_at = ov_now()`, [user.id]);++ return { imported };++}++++module.exports = { exportFor, status, dismiss, bring, _setExport(fn) { exportOverride = fn; } };@@@ -20,6 +20,7 @@ const md = require('../../public/web/vibe-md.js');const pictures = require('../rooms/pictures');const rooms = require('../rooms/rooms');const vibe = require('../rooms/vibe');++const compare = require('../rooms/vibe-compare');const workspace = require('../rooms/workspace');const how = require('../rooms/how');const memory = require('../rooms/vibe-memory');@@@ -28,10 +29,9 @@ const kit = require('openvibe-shared/kit');const thread = require('openvibe-shared/thread');const SUGGESTIONS = [-- { title: 'Catch me up', text: 'on today\'s tech news', prompt: 'What happened in tech news today? Give me the highlights with links.' },-- { title: 'Find a deal', text: 'on a mechanical keyboard', prompt: 'Find me a good deal on a mechanical keyboard on OpenVibe Deals.' },-- { title: 'Look for jobs', text: 'remote video editing work', prompt: 'Find remote video editing jobs posted recently.' },-- { title: 'Explain a page', text: 'how browser go-live works', prompt: 'Read https://openvibe.live/docs/go-live-in-your-browser and explain how going live from a browser works, in plain words.' },++ { title: 'Explain a concept', prompt: 'Explain a concept I am curious about in plain English.' },++ { title: 'Help me write', prompt: 'Help me write a clear, friendly message.' },++ { title: 'Compare ideas', prompt: 'Give me two ways to approach this idea and their tradeoffs.' },];const DAY = 86_400_000;@@@ -161,7 +161,10 @@ function youOf(m, me) {/** Vibe's answer: the rendered Markdown, what it did (reminders, timers), its sources and how, then Copy and Try again. */function answerOf(m, hows, retrySlug, slug) {const info = hows.get(Number(m.id));-- return `<p class="oc-sr">Vibe said:</p>${thread.answer({ html: md.render(m.message), copy: false })}${resultChips(m, slug)}${how.chips(info, m.id)}${how.html(info, m.id)}${acts(retrySlug)}`;++ return `<p class="oc-sr">Vibe said:</p>${m.vibe_model ? `<p class="vb-import-model">${esc(m.vibe_model)}</p>` : ''}${thread.answer({ html: md.render(m.message), copy: false })}${resultChips(m, slug)}${how.chips(info, m.id)}${how.html(info, m.id)}${acts(retrySlug)}`;++}++function compareOf(asked, answers) {++ return `<div class="vb-compare" data-vb-question="${Number(asked.id)}"><div class="vb-compare-tabs" role="tablist" aria-label="Models">${answers.map((a, i) => `<button type="button" role="tab" class="vb-compare-tab${i ? '' : ' is-active'}" data-vb-tab="${i}" aria-selected="${i ? 'false' : 'true'}">${esc(a.model)} <span aria-hidden="true">${a.status === 'finished' ? '●' : '○'}</span></button>`).join('')}</div><div class="vb-compare-grid">${answers.map((a, i) => `<section class="vb-compare-answer${i ? '' : ' is-active'}" data-vb-answer="${i}"><header><strong>${esc(a.model)}</strong><small>${a.provider ? `${esc(a.provider)} · ` : ''}${a.cost_usd == null ? '' : `$${Number(a.cost_usd).toFixed(4)} · `}${a.ms == null ? '' : `${(Number(a.ms) / 1000).toFixed(1)}s`}</small></header><div class="vb-compare-text" data-vb-text>${a.status === 'writing' ? '<span class="vb-pulse" aria-hidden="true"></span>Writing…' : a.status === 'failed' ? esc(a.error || 'Could not answer') : md.render(a.text)}</div><div class="vb-compare-actions"><button type="button" data-vb-copy="${i}">Copy</button><button type="button" data-vb-pick="${esc(a.model)}" data-vb-message="${Number(asked.id)}">Continue with this one</button><button type="button" data-vb-prefer="${esc(a.model)}" data-vb-message="${Number(asked.id)}">This one was better</button></div></section>`).join('')}</div></div>`;}/** Vibe writing: the live line, open (ov-thread.js and vibe.js follow it). */const WRITING = thread.work({ open: true, live: 'Vibe is writing' });@@@ -178,7 +181,7 @@ function turnsOf(messages, me, hows, { retrySlug = null, slug = null, waiting =const open = specs[specs.length - 1];if (open && open.reply == null) { open.reply = reply; open.ai = m.id; }else specs.push({ reply, ai: m.id });-- } else specs.push({ you: youOf(m, me), me: m.id, reply: null });++ } else specs.push({ you: youOf(m, me), me: m.id, reply: m.compareAnswers ? compareOf(m, m.compareAnswers) : null });}const tail = specs[specs.length - 1];if (waiting && tail && tail.reply == null) { tail.reply = WRITING; tail.open = true; }@@@ -196,7 +199,7 @@ const MODES = [// 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 }) {++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.@@@ -211,7 +214,7 @@ function composer({ slug, signedIn, enabled, prefill = '', engine = null }) {action, method: signedIn ? 'post' : 'get', name: 'message', id: 'vb-composer', attrs: signedIn ? {} : { 'data-signin': '1' },label: 'Message Vibe', placeholder: signedIn ? 'Message Vibe' : 'Message Vibe (you will sign in first)', value: prefill, maxlength: 12000,hidden: signedIn ? {} : { next: slug ? `/vibe/${slug}` : (prefill ? `/vibe?ask=${encodeURIComponent(prefill)}` : '/vibe') },-- top: '<div class="vb-preview" id="vb-preview" hidden></div>', tools: attach + modes,++ 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>`, 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>`,@@@ -254,6 +257,9 @@ function mountVibePages(router, { page, viewerOf, needUser, sameSite, formLimit,const chats = user ? (searching ? await vibe.search(user, query) : await vibe.list(user)) : [];const allowance = user ? await allowanceOf(user) : null;const messages = room ? await rooms.history(room, { limit: 200 }) : [];++ const compareAnswers = room ? await compare.answers(room, messages.filter((m) => !m.ai_member_id).map((m) => Number(m.id))) : new Map();++ for (const m of messages) if (compareAnswers.has(Number(m.id))) m.compareAnswers = compareAnswers.get(Number(m.id));++ const modelSettings = room ? await compare.settings(room) : { models: [] };const hows = room ? await how.forRoom(room) : new Map(); // "How Vibe did this" under agent tasks' answers// The agent's canvas beside the chat (plan HO9): only when the newest task in the chat is Actor's, because// that task's thread is the conversation the panel belongs to. The browser reads the document itself with@@@ -262,14 +268,15 @@ function mountVibePages(router, { page, viewerOf, needUser, sameSite, formLimit,const canvas = room && user ? await workspace.newestActor(room, user) : null;const enabled = vibe.enabled();const title = room ? room.name : 'Vibe';-- const waiting = room && messages.length && !messages[messages.length - 1].ai_member_id;++ const waiting = room && messages.length && !messages[messages.length - 1].ai_member_id && !messages[messages.length - 1].compareAnswers;// Try again goes under the last answer when it answers the person's last message.const retryable = room && messages.length > 1 && !!messages[messages.length - 1].ai_member_id && !messages[messages.length - 2].ai_member_id && user && Number(messages[messages.length - 2].user_id) === Number(user.id);const hello = `<div class="vb-hello">++${user ? '<div class="vb-import" id="vb-import" hidden></div>' : ''}${MARK}--<h1>${user ? `What can I help with${user.display_name || user.username ? `, ${esc(String(user.display_name || user.username).split(' ')[0])}` : ''}?` : 'Hi, I\'m Vibe. What can I help with?'}</h1>--<p class="vb-sub">OpenVibe's assistant. I search the web and all of OpenVibe (news, deals, jobs, rentals, videos), read the pages and pictures you share, remember what you ask me to, and set up work that runs every day for you.</p>--<ul class="vb-suggest">${SUGGESTIONS.map((s) => `<li><button type="button" class="vb-chip" data-prompt="${esc(s.prompt)}"><b>${esc(s.title)}</b><span>${esc(s.text)}</span></button></li>`).join('')}</ul>++<h1>Ask anything</h1>++<p class="vb-sub">One model, or up to three side by side</p>++<ul class="vb-suggest">${SUGGESTIONS.map((s) => `<li><button type="button" class="vb-chip" data-prompt="${esc(s.prompt)}"><b>${esc(s.title)}</b></button></li>`).join('')}</ul></div>`;const head = thread.head({title: room ? title : '', sidebar: 'vb-rail', sidebarLabel: 'Show your chats',@@@ -285,7 +292,7 @@ ${MARK}head,turns: problem && room ? [thread.turn({ avatar: false, reply: problem }), ...turns] : turns,empty: problem + hello,-- composer: composer({ slug: room && room.slug, signedIn: !!user, enabled, prefill: room ? '' : prefillOf(req.query || {}), engine: allowance && allowance.engine }),++ composer: composer({ slug: room && room.slug, signedIn: !!user, enabled, prefill: room ? '' : prefillOf(req.query || {}), engine: allowance && allowance.engine, models: modelSettings.models }),center: !room, label: room ? 'Your chat with Vibe' : 'A new chat with Vibe',})}</div>`;await page(req, res, status, {@@@ -297,7 +304,8 @@ ${MARK}body,page: {view: 'vibe', room: room ? room.slug : null, me: user ? user.id : null, latest: messages.length ? messages[messages.length - 1].id : 0,-- waiting: !!waiting, enabled, signedIn: !!user,++ waiting: !!waiting, enabled, signedIn: !!user, models: modelSettings.models,++ system_prompt: modelSettings.system_prompt, temperature: modelSettings.temperature,canvas: canvas ? { thread: canvas.thread, task: canvas.id, status: canvas.status } : null,},});@@@ -36,7 +36,7 @@ t('export, then erase and confirm', async () => {taskKeyed: ['calls', 'channel_moderators', 'channel_sounds', 'chat_app_callbacks', 'chat_app_link_tokens', 'chat_messages', 'chat_server_invites', 'chat_server_members','ctx_channels', 'ctx_managed_streams', 'ctx_streams', 'dm_conversations', 'dm_participants', 'emotes', 'inbox_actions','inbox_members', 'inbox_saved_replies', 'inbox_webhooks', 'pending_ip_messages', 'room_hooks', 'room_members', 'room_messages', 'agent_room_poll_votes', 'room_message_reactions',-- 'user_tags', 'vibe_asks', 'vibe_deliveries', 'vibe_memory', 'vibe_memory_off', 'vibe_own_engine', 'vibe_usage'],++ 'user_tags', 'vibe_asks', 'vibe_deliveries', 'vibe_memory', 'vibe_memory_off', 'vibe_own_engine', 'vibe_usage', 'vibe_model_preferences', 'vibe_import_notices'],exempt: {account_data_events: 'the ledger of requests itself: which export and deletion Chat applied',config_snapshots: 'D11 owner settings: created_by/activated_by is the acting administrator (usr_...) for the config audit trail; the values are network configuration, not the person\'s account data, and are kept for rollback/history',@@@ -22,6 +22,7 @@ async function get(sql, params) { return await db.prepare(sql).get(...(params ||// Every table Chat owns, including the Chat ingress retry ledger and runtime-created modules.const TABLES = [++ 'vibe_model_turns', 'vibe_chat_settings', 'vibe_model_preferences', 'vibe_import_notices', // 0048: multi-model Vibe and the Exchange import'chat_messages', 'dm_conversations', 'dm_participants', 'dm_messages', 'dm_blocks', 'tts_voice_overrides','channel_sounds', 'relay_users', 'hidden_relay_users', 'pending_ip_messages', 'stream_first_chats', 'moderation_actions','channel_moderators', 'channel_moderation_settings', 'emotes', 'user_tags', 'chat_ai_summaries', 'chat_timeline_events',@@@ -113,6 +113,9 @@ t('DNS rebinding: public at the check, loopback at the download — refused whert('ratchet: every file that makes an outbound request itself is reviewed', () => {const REVIEWED = {++ 'server/ai/client.js': 'AI internal API (configured), fixed paths: runs and the Exchange catalogue',++ 'server/rooms/vibe-import.js': 'Exchange internal API (configured), a fixed path: the signed-in person\'s chat export',++ 'server/rooms/vibe-compare.js': 'Bench (configured), fixed paths: the compare round and its vote','server/net/service-auth.js': 'Network JWKS (configured)','server/chat/account-data.js': 'Network internal API (configured), fixed paths: export parts and deletion confirmations','server/prefs/network-modules.js': 'Network user modules (configured)',@@@ -67,6 +67,85 @@ t('a chat is its person\'s alone, and an empty message is refused', async () =>assert.strictEqual((await h.http('POST', '/api/vibe/ask', { token: ann.token, body: { message: ' ' } })).status, 422);});--t('done', async () => { aiClient._setClient(null); await h.close(); });++t('two and three explicit models run in parallel, keep receipts, and isolate a failure', async () => {++ require('../server/net/actor-limits').limits.reset();++ const seen = [];++ aiClient._setComplete(async ({ model, subject }) => {++ seen.push({ model, subject, at: Date.now() });++ await wait(40);++ if (model.includes('broken')) throw new Error('provider unavailable');++ return { text: `From ${model}`, provider: 'test', cost_usd: 0.001, ms: 40, run_id: `run_${model}` };++ });++ const models = ['maker/one', 'maker/two'];++ const first = await h.http('POST', '/api/vibe/ask', { token: ann.token, body: { message: 'Compare this', models, fresh: true } });++ assert.strictEqual(first.status, 201, first.text);++ const slug = first.body.chat.slug;++ let group;++ for (let i = 0; i < 40; i++) {++ const chat = await h.http('GET', `/api/vibe/chat?slug=${slug}`, { token: ann.token });++ group = chat.body.messages.find((m) => m.id === first.body.message.id);++ if (group && group.answers && group.answers.every((a) => a.status !== 'writing')) break;++ await wait(50);++ }++ assert.deepStrictEqual(group.answers.map((a) => a.status), ['finished', 'finished']);++ assert.ok(group.answers.every((a) => a.provider === 'test' && a.cost_usd === 0.001 && a.ms === 40 && a.run_id));++ assert.ok(Math.abs(seen[0].at - seen[1].at) < 30, 'the calls start together');++ assert.ok(seen.every((x) => x.subject === ann.subject_id));++ const compare = require('../server/rooms/vibe-compare');++ const benchCalls = [];++ compare._setBench({ createRound: async (body) => {++ benchCalls.push({ kind: 'round', body });++ return { round: { id: 'brd_TEST' }, entries: [{ id: 'bte_LEFT' }, { id: 'bte_RIGHT' }] };++ }, vote: async (body) => { benchCalls.push({ kind: 'vote', body }); return { judgement: { id: 'bju_TEST' } }; } });++ const preferred = await h.http('POST', '/api/vibe/prefer', { token: ann.token, body: { message_id: first.body.message.id, model: 'maker/two' } });++ assert.strictEqual(preferred.status, 200, preferred.text);++ assert.deepStrictEqual(benchCalls.map((c) => c.kind), ['round', 'vote']);++ assert.strictEqual(benchCalls[0].body.source, 'chat');++ assert.strictEqual(benchCalls[1].body.subject, ann.subject_id);++ assert.strictEqual((await h.http('POST', '/api/vibe/prefer', { token: ann.token, body: { message_id: first.body.message.id, model: 'maker/two' } })).status, 409);++ compare._setBench(null);++ const picked = await h.http('POST', '/api/vibe/pick', { token: ann.token, body: { message_id: first.body.message.id, model: 'maker/two' } });++ assert.strictEqual(picked.status, 200, picked.text);++ const next = await h.http('POST', '/api/vibe/ask', { token: ann.token, body: { message: 'Three now', slug, models: ['maker/one', 'maker/two', 'maker/broken'] } });++ assert.strictEqual(next.status, 201, next.text);++ let answers;++ for (let i = 0; i < 40; i++) {++ const chat = await h.http('GET', `/api/vibe/chat?slug=${slug}`, { token: ann.token });++ answers = chat.body.messages.find((m) => m.id === next.body.message.id).answers;++ if (answers.every((a) => a.status !== 'writing')) break;++ await wait(50);++ }++ assert.strictEqual(answers.filter((a) => a.status === 'finished').length, 2);++ assert.strictEqual(answers.filter((a) => a.status === 'failed').length, 1);++ assert.strictEqual((await h.http('POST', '/api/vibe/pick', { token: bob.token, body: { message_id: first.body.message.id, model: 'maker/one' } })).status, 404);++ assert.strictEqual((await h.http('POST', '/api/vibe/ask', { token: ann.token, body: { message: 'too many', fresh: true, models: ['a/one', 'b/two', 'c/three', 'd/four'] } })).status, 422);++});++++t('Exchange import is scoped to the person and repeating it makes no second chat', async () => {++ require('../server/net/actor-limits').limits.reset();++ const importer = require('../server/rooms/vibe-import');++ const dates = ['2026-10-01T10:00:00.000Z', '2026-10-01T10:01:00.000Z'];++ importer._setExport(async (subject) => ({ chats: [{ id: subject === ann.subject_id ? 'exc_ANN1' : 'exc_BOB1',++ title: subject === ann.subject_id ? 'Alice old chat' : 'Bob old chat', models: ['maker/one'], created_at: dates[0], updated_at: dates[1],++ messages: [{ role: 'user', content: 'Old question', created_at: dates[0] },++ { role: 'assistant', model: 'maker/one', content: 'Old answer', created_at: dates[1] }] }] }));++ const status = await h.http('GET', '/api/vibe/import', { token: ann.token });++ assert.deepStrictEqual(status.body, { count: 1, show: true });++ const first = await h.http('POST', '/api/vibe/import', { token: ann.token, body: {} });++ assert.strictEqual(first.status, 200, first.text);++ assert.strictEqual(first.body.imported, 1);++ const again = await h.http('POST', '/api/vibe/import', { token: ann.token, body: {} });++ assert.strictEqual(again.body.imported, 0);++ const bobImport = await h.http('POST', '/api/vibe/import', { token: bob.token, body: {} });++ assert.strictEqual(bobImport.body.imported, 1);++ const aliceRoom = await h.db.get("SELECT id, name, created_at FROM rooms WHERE owner_id = ? AND name = 'Alice old chat'", [ann.id]);++ assert.ok(aliceRoom && String(aliceRoom.created_at).startsWith('2026-10-01'));++ const messages = await h.db.all('SELECT message FROM room_messages WHERE room_id = ? ORDER BY id', [aliceRoom.id]);++ assert.deepStrictEqual(messages.map((m) => m.message), ['Old question', 'Old answer']);++ assert.strictEqual(await h.db.get("SELECT id FROM rooms WHERE owner_id = ? AND name = 'Bob old chat'", [ann.id]), undefined);++ importer._setExport(null);++});++++t('done', async () => { aiClient._setComplete(null); aiClient._setClient(null); await h.close(); });t.run();nnew file mode 100644@@@ -0,0 +1,34 @@++'use strict';++/** A phone can choose three models without a sideways page or a fourth selection. */++const assert = require('node:assert/strict');++const fs = require('node:fs');++const { ids } = require('openvibe-contracts');++const { boot, suite } = require('./helpers');++let chromium;++try { ({ chromium } = require('/home/workstation/openvibe/work/OpenVibe.Games/node_modules/playwright')); } catch { /* optional browser harness */ }++const t = suite('vibe compare Chrome');++if (!chromium || !fs.existsSync('/usr/bin/google-chrome')) { console.log('vibe compare Chrome: skipped (Chrome unavailable)'); process.exit(0); }++let h, browser, user;++t('boot', async () => {++ h = await boot({ env: { CHAT_WEB_URL: 'https://openvibe.chat' } });++ user = h.addUser('browser', { subject: ids.newId('user') });++ await h.ctx.upsertUser(h.live.users.get(user.id));++ browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome' });++});++t('the sheet caps Compare at three and a 390 px page does not scroll sideways', async () => {++ const page = await browser.newPage({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });++ await page.context().addCookies([{ name: 'ov_token', value: user.token, url: h.base }]);++ await page.route('**/api/vibe/models', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ models: [++ { slug: 'maker/one', name: 'One', family: 'Maker' }, { slug: 'maker/two', name: 'Two', family: 'Maker' },++ { slug: 'maker/three', name: 'Three', family: 'Maker' }, { slug: 'maker/four', name: 'Four', family: 'Maker' },++ ] }) }));++ await page.goto(h.base + '/vibe', { waitUntil: 'load' });++ await page.locator('#vb-model-chip').click();++ await page.locator('#vb-model-compare').check();++ for (const name of ['One', 'Two', 'Three', 'Four']) await page.getByRole('button', { name, exact: true }).click();++ assert.equal(await page.locator('#vb-model-chip').innerText(), '3 models ▾');++ assert.equal(await page.evaluate(() => document.documentElement.scrollWidth), 390);++ await page.close();++});++t('done', async () => { await browser.close(); await h.close(); });++t.run();@@@ -142,11 +142,11 @@ t('rename, then delete: the chat and its messages go, the day\'s count stays', at('the app page: signed out it invites a sign-in, signed in it lists the chats and starts one from a form', async () => {const out = await web('GET', '/vibe');assert.strictEqual(out.status, 200);-- assert.ok(out.text.includes('Hi, I'm Vibe. What can I help with?') || out.text.includes('Hi, I\'m Vibe. What can I help with?'), 'the greeting');++ assert.ok(out.text.includes('Ask anything'), 'the greeting');assert.ok(out.text.includes('data-signin="1"') && out.text.includes('Sign in to keep your chats'));assert.ok(!out.text.includes('id="ov-footer"'), 'the app has no footer');const signed = await web('GET', '/vibe', ann);-- assert.ok(signed.text.includes('What can I help with, Ann?'));++ assert.ok(signed.text.includes('One model, or up to three side by side'));assert.strictEqual((signed.text.match(/<h1[\s>]/g) || []).length, 1, 'one h1 on a new chat: the greeting (the header names it in a <p>)');assert.ok(signed.text.includes('class="ovk-sidebar-item" href="/vibe/v-'), 'the rail lists their chats (the network\'s one sidebar, kit.sidebar)');// The rail leads to the rest of the person's agent: their tasks, their computers and robots, team workspaces.
@@ -28,6 +28,10 @@ TRUST_PROXY=2
@@ -28,6 +28,10 @@ TRUST_PROXY=2
# Identity — OpenVibe.Network (user JWTs and service tokens are RS256 from here)
# Identity — OpenVibe.Network (user JWTs and service tokens are RS256 from here)
OV_NETWORK_URL=https://openvibe.network
OV_NETWORK_URL=https://openvibe.network
OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
+# Vibe model comparison: AI's catalogue and runs, Exchange's old chat export, and optional Bench preferences.
+#OV_AI_INTERNAL_URL=http://127.0.0.1:4700
+#OV_EXCHANGE_INTERNAL_URL=http://127.0.0.1:5150
+#OV_BENCH_URL=https://bench.openvibe.services
# PEM with the Network public key; unset = fetched from ${OV_NETWORK_INTERNAL_URL}/api/.well-known/jwks
# PEM with the Network public key; unset = fetched from ${OV_NETWORK_INTERNAL_URL}/api/.well-known/jwks
#OV_NETWORK_PUBLIC_KEY=/opt/openvibe.live/data/keys/openvibe-tools-public.pem
#OV_NETWORK_PUBLIC_KEY=/opt/openvibe.live/data/keys/openvibe-tools-public.pem
@@ -499,6 +499,13 @@ socket, sends on Enter and keeps a draft typed while signed out through the sign
@@ -499,6 +499,13 @@ socket, sends on Enter and keeps a draft typed while signed out through the sign
- **API:** `GET /api/chat/vibe` (chats, allowance), `POST /api/chat/vibe { message }` → 201 `{ room, message }`,
- **API:** `GET /api/chat/vibe` (chats, allowance), `POST /api/chat/vibe { message }` → 201 `{ room, message }`,
`PATCH /api/chat/vibe/:slug { name }`, `DELETE /api/chat/vibe/:slug`; then the rooms API for messages. People only
`PATCH /api/chat/vibe/:slug { name }`, `DELETE /api/chat/vibe/:slug`; then the rooms API for messages. People only
(an API token gets 403).
(an API token gets 403).
+- **Model comparison:** `POST /api/vibe/ask { message, models: [id, ...] }` runs one to three AI catalogue models in
+ parallel on the person's own AI allowance and keys (`on_behalf_of`). `GET /api/vibe/chat` groups their answers under
+ the question with model, provider, cost, elapsed time and run id. `POST /api/vibe/pick` keeps one model for follow-ups;
+ `POST /api/vibe/prefer` submits a pairwise preference to Bench. `GET /api/vibe/models` fills the model sheet. Auto
+ still uses Vibe's existing engine and tools. `GET|POST /api/vibe/import` brings unsealed Exchange chats once through
+ its scoped service export; `POST /api/vibe/import/dismiss` hides the notice. The import needs the
+ `exchange.chat.export` grant to Chat's service client.
### Support inboxes and the website widget (openvibe.chat/inbox)
### Support inboxes and the website widget (openvibe.chat/inbox)
new file mode 100644
new file mode 100644
@@ -0,0 +1,45 @@
@@ -0,0 +1,45 @@
+-- phase: expand
+-- Explicit model answers belong to the person's existing Vibe room and question.
+ALTER TABLE room_messages ADD COLUMN IF NOT EXISTS vibe_model text COLLATE "C";
+CREATE TABLE IF NOT EXISTS vibe_model_turns (
+ message_id bigint NOT NULL REFERENCES room_messages(id) ON DELETE CASCADE,
+ room_id bigint NOT NULL REFERENCES rooms(id) ON DELETE CASCADE,
+ model text COLLATE "C" NOT NULL,
+ position smallint NOT NULL DEFAULT 0,
+ status text COLLATE "C" NOT NULL DEFAULT 'writing',
+ text text COLLATE "C",
+ provider text COLLATE "C",
+ cost_usd numeric,
+ ms integer,
+ run_id text COLLATE "C",
+ error text COLLATE "C",
+ created_at text COLLATE "C" DEFAULT ov_now(),
+ PRIMARY KEY (message_id, model),
+ CONSTRAINT vibe_model_turn_status CHECK (status IN ('writing', 'finished', 'failed'))
+);
+CREATE INDEX IF NOT EXISTS vibe_model_turns_room ON vibe_model_turns(room_id, message_id);
+
+CREATE TABLE IF NOT EXISTS vibe_chat_settings (
+ room_id bigint PRIMARY KEY REFERENCES rooms(id) ON DELETE CASCADE,
+ models jsonb NOT NULL DEFAULT '[]'::jsonb,
+ system_prompt text COLLATE "C",
+ temperature numeric,
+ exchange_id text COLLATE "C" UNIQUE,
+ CONSTRAINT vibe_chat_models_array CHECK (jsonb_typeof(models) = 'array'),
+ CONSTRAINT vibe_chat_temperature CHECK (temperature IS NULL OR (temperature >= 0 AND temperature <= 2))
+);
+
+CREATE TABLE IF NOT EXISTS vibe_model_preferences (
+ message_id bigint NOT NULL REFERENCES room_messages(id) ON DELETE CASCADE,
+ user_id bigint NOT NULL,
+ winner text COLLATE "C" NOT NULL,
+ bench_round_id text COLLATE "C",
+ created_at text COLLATE "C" DEFAULT ov_now(),
+ PRIMARY KEY (message_id, user_id)
+);
+
+CREATE TABLE IF NOT EXISTS vibe_import_notices (
+ user_id bigint PRIMARY KEY,
+ dismissed_at text COLLATE "C",
+ imported_at text COLLATE "C"
+);
@@ -91,6 +91,51 @@
@@ -91,6 +91,51 @@
.vb-chip:focus-visible { outline: 2px solid var(--oc-accent); outline-offset: 2px; }
.vb-chip:focus-visible { outline: 2px solid var(--oc-accent); outline-offset: 2px; }
.vb-chip b { font-weight: 650; font-size: .95rem; color: var(--oc-text); }
.vb-chip b { font-weight: 650; font-size: .95rem; color: var(--oc-text); }
.vb-chip span { color: var(--oc-muted); font-size: .88rem; }
.vb-chip span { color: var(--oc-muted); font-size: .88rem; }
+.vb-import[hidden] { display: none; }
+.vb-import { width: 100%; display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 10px; padding: 12px; margin-bottom: 18px; border: 1px solid var(--oc-accent); border-radius: 12px; background: var(--oc-panel); }
+.vb-import button { min-height: 44px; border: 1px solid var(--oc-line); border-radius: 8px; padding: 6px 12px; background: var(--oc-bg); color: var(--oc-text); cursor: pointer; }
+.vb-import-model { color: var(--oc-muted); font-size: .8rem; margin: 0 0 8px; }
+
+/* A model choice stays near the composer. Every option can be reached with a 44 px target. */
+.vb-model-picker { position: relative; margin-bottom: 6px; }
+.vb-model-chip, .vb-model-sheet button, .vb-model-sheet summary, .vb-compare button { min-height: 44px; cursor: pointer; font: inherit; }
+.vb-model-chip { padding: 6px 14px; border: 1px solid var(--oc-line); border-radius: 999px; background: var(--oc-panel); color: var(--oc-text); }
+.vb-model-sheet[hidden] { display: none; }
+.vb-model-sheet { position: absolute; z-index: 35; bottom: calc(100% + 8px); left: 0; width: min(440px, calc(100vw - 32px)); max-height: min(65vh, 600px); overflow-y: auto; padding: 16px; border: 1px solid var(--oc-line); border-radius: 18px; background: var(--oc-panel); color: var(--oc-text); box-shadow: 0 20px 60px #0005; }
+.vb-model-sheet-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
+.vb-model-sheet-head button { border: 0; background: transparent; color: inherit; width: 44px; }
+.vb-model-compare { display: flex; align-items: center; gap: 10px; min-height: 44px; }
+.vb-model-compare input { width: 20px; height: 20px; }
+#vb-model-list h3 { margin: 12px 0 4px; font-size: .78rem; color: var(--oc-muted); }
+.vb-model-row { display: flex; flex-wrap: wrap; align-items: center; gap: 3px 12px; width: 100%; padding: 5px 0; border: 0; border-bottom: 1px solid var(--oc-line); background: transparent; color: inherit; text-align: left; }
+.vb-model-choice { flex: 1 1 100%; border: 0; background: transparent; color: inherit; text-align: left; padding: 0; }
+.vb-model-row small { color: var(--oc-muted); flex: 1 1 auto; }
+.vb-model-row a, .vb-model-floor a { color: var(--oc-accent); }
+.vb-model-sheet details { padding: 8px 0; }
+.vb-model-sheet details label { display: grid; gap: 5px; margin: 8px 0; }
+.vb-model-sheet textarea, .vb-model-sheet input[type=number] { min-height: 44px; width: 100%; border: 1px solid var(--oc-line); border-radius: 8px; background: var(--oc-bg); color: inherit; padding: 9px; font: inherit; }
+.vb-model-floor { margin: 12px 0 0; padding-top: 10px; border-top: 1px solid var(--oc-line); color: var(--oc-muted); font-size: .8rem; }
+.vb-compare { min-width: 0; width: 100%; }
+.vb-compare-tabs { display: none; gap: 6px; flex-wrap: wrap; margin-bottom: 8px; }
+.vb-compare-tab { border: 1px solid var(--oc-line); border-radius: 999px; padding: 5px 10px; background: var(--oc-panel); color: var(--oc-text); }
+.vb-compare-tab.is-active { border-color: var(--oc-accent); }
+.vb-compare-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; min-width: 0; }
+.vb-compare-answer { min-width: 0; border: 1px solid var(--oc-line); border-radius: 16px; padding: 14px; background: var(--oc-panel); overflow-wrap: anywhere; }
+.vb-compare-answer header { display: grid; gap: 3px; padding-bottom: 10px; border-bottom: 1px solid var(--oc-line); }
+.vb-compare-answer header small { color: var(--oc-muted); }
+.vb-compare-text { line-height: 1.55; padding: 14px 0; }
+.vb-compare-actions { display: flex; flex-wrap: wrap; gap: 4px 8px; }
+.vb-compare-actions button { border: 0; border-radius: 8px; padding: 6px 8px; background: transparent; color: var(--oc-accent); }
+.vb-pulse { display: inline-block; width: 8px; height: 8px; margin-right: 8px; border-radius: 50%; background: var(--oc-accent); animation: vb-pulse 1.4s ease-in-out infinite; }
+@keyframes vb-pulse { 50% { opacity: .3; } }
+@media (max-width: 899px) {
+ .vb-model-sheet { position: fixed; top: auto; bottom: 12px; left: 12px; right: 12px; width: auto; max-height: min(80dvh, 640px); }
+ .vb-compare-tabs { display: flex; }
+ .vb-compare-grid { display: block; }
+ .vb-compare-answer { display: none; }
+ .vb-compare-answer.is-active { display: block; }
+ .vb-suggest { grid-template-columns: 1fr; }
+}
/* ── Memory (/vibe/memory): what Vibe remembers about the person ──────────── */
/* ── Memory (/vibe/memory): what Vibe remembers about the person ──────────── */
.vb-page { max-width: 640px; margin: 0 auto; padding: 18px 0 8px; display: grid; gap: 16px; }
.vb-page { max-width: 640px; margin: 0 auto; padding: 18px 0 8px; display: grid; gap: 16px; }
@@ -26,6 +26,83 @@
@@ -26,6 +26,83 @@
var previewEl = document.getElementById('vb-preview');
var previewEl = document.getElementById('vb-preview');
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 reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+ var chosenModels = Array.isArray(cfg.models) ? cfg.models.slice(0, 3) : [];
+ function persistModelChoice() {
+ if (!room) return;
+ fetch('/api/vibe/settings', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ slug: room, models: chosenModels }) }).catch(function () { notice('Model choice could not be saved.'); });
+ }
+ try { var requested = new URLSearchParams(location.search).get('models'); if (requested) chosenModels = requested.split(',').filter(Boolean).slice(0, 3); } catch (_) {}
+ var modelChip = document.getElementById('vb-model-chip');
+ var modelSheet = document.getElementById('vb-model-sheet');
+ var modelList = document.getElementById('vb-model-list');
+ var compareSwitch = document.getElementById('vb-model-compare');
+ var modelCatalog = [];
+ function modelLabel(id) { var m = modelCatalog.find(function (x) { return x.slug === id; }); return m ? m.name : id; }
+ function syncModelChip() {
+ if (modelChip) modelChip.textContent = (chosenModels.length > 1 ? chosenModels.length + ' models' : chosenModels.length ? modelLabel(chosenModels[0]) : 'Auto') + ' ▾';
+ if (compareSwitch) compareSwitch.checked = chosenModels.length > 1 || compareSwitch.checked;
+ }
+ function paintModelList() {
+ if (!modelList) return;
+ modelList.replaceChildren();
+ var auto = document.createElement('button'); auto.type = 'button'; auto.className = 'vb-model-row'; auto.textContent = 'Auto · OpenVibe picks';
+ auto.addEventListener('click', function () { chosenModels = []; syncModelChip(); paintModelList(); modelSheet.hidden = true; persistModelChoice(); }); modelList.appendChild(auto);
+ var groups = new Map(); modelCatalog.forEach(function (m) { var maker = m.family || 'Other'; if (!groups.has(maker)) groups.set(maker, []); groups.get(maker).push(m); });
+ groups.forEach(function (models, maker) {
+ var h = document.createElement('h3'); h.textContent = '✦ ' + maker; modelList.appendChild(h);
+ models.forEach(function (m) {
+ var row = document.createElement('div'); row.className = 'vb-model-row';
+ var choice = document.createElement('button'); choice.type = 'button'; choice.className = 'vb-model-choice';
+ choice.textContent = (chosenModels.includes(m.slug) ? '✓ ' : '') + m.name;
+ choice.addEventListener('click', function () {
+ if (compareSwitch && compareSwitch.checked) {
+ if (chosenModels.includes(m.slug)) chosenModels = chosenModels.filter(function (id) { return id !== m.slug; });
+ else if (chosenModels.length < 3) chosenModels.push(m.slug);
+ } else { chosenModels = [m.slug]; modelSheet.hidden = true; }
+ syncModelChip(); paintModelList(); persistModelChoice();
+ }); row.appendChild(choice);
+ var offer = (m.offers || []).find(function (x) { return x.price; });
+ var facts = document.createElement('small');
+ facts.textContent = offer ? '$' + Number(offer.price.in).toFixed(2) + ' in · $' + Number(offer.price.out).toFixed(2) + ' out / million · ' + (offer.speed && offer.speed.p50_ms ? (offer.speed.p50_ms / 1000).toFixed(1) + 's' : 'speed varies') : 'Price and speed vary';
+ row.appendChild(facts);
+ var ranks = document.createElement('a'); ranks.href = 'https://openvibe.report/ai/models/' + encodeURIComponent(m.slug); ranks.textContent = 'How it ranks'; row.appendChild(ranks);
+ modelList.appendChild(row);
+ });
+ });
+ }
+ if (modelChip && modelSheet) {
+ modelChip.addEventListener('click', function () {
+ modelSheet.hidden = !modelSheet.hidden; modelChip.setAttribute('aria-expanded', String(!modelSheet.hidden));
+ if (!modelSheet.hidden && !modelCatalog.length) fetch('/api/vibe/models', { credentials: 'same-origin' }).then(function (r) { return r.ok ? r.json() : { models: [] }; }).then(function (d) { modelCatalog = (d.models || []).filter(function (m) { return m && m.slug && m.name; }); paintModelList(); syncModelChip(); }).catch(function () { modelList.textContent = 'Models are unavailable right now.'; });
+ });
+ document.getElementById('vb-model-close').addEventListener('click', function () { modelSheet.hidden = true; modelChip.setAttribute('aria-expanded', 'false'); });
+ compareSwitch.addEventListener('change', function () { if (!compareSwitch.checked && chosenModels.length > 1) chosenModels = chosenModels.slice(0, 1); syncModelChip(); paintModelList(); persistModelChoice(); });
+ document.getElementById('vb-system-prompt').value = cfg.system_prompt || '';
+ document.getElementById('vb-temperature').value = cfg.temperature == null ? 1 : cfg.temperature;
+ syncModelChip();
+ }
+ var importNotice = document.getElementById('vb-import');
+ if (importNotice) fetch('/api/vibe/import', { credentials: 'same-origin' }).then(function (r) { return r.ok ? r.json() : null; }).then(function (d) {
+ if (!d || !d.show) return;
+ importNotice.hidden = false;
+ var title = document.createElement('span'); title.textContent = 'Bring your ' + d.count + ' chats from Exchange'; importNotice.appendChild(title);
+ var bring = document.createElement('button'); bring.type = 'button'; bring.textContent = 'Bring chats'; importNotice.appendChild(bring);
+ var dismiss = document.createElement('button'); dismiss.type = 'button'; dismiss.textContent = 'Dismiss'; importNotice.appendChild(dismiss);
+ bring.addEventListener('click', function () { bring.disabled = true; fetch('/api/vibe/import', { method: 'POST', credentials: 'same-origin' }).then(function (r) { if (!r.ok) throw new Error('Import could not finish'); location.reload(); }).catch(function () { bring.disabled = false; notice('Import could not finish. Try again.'); }); });
+ dismiss.addEventListener('click', function () { fetch('/api/vibe/import/dismiss', { method: 'POST', credentials: 'same-origin' }).then(function () { importNotice.hidden = true; }); });
+ }).catch(function () {});
+ if (form) form.addEventListener('submit', function (e) {
+ if (!chosenModels.length || (e.submitter && e.submitter === stopBtn) || form.dataset.signin) return;
+ var message = input.value.trim(); if (!message || sending) { e.preventDefault(); return; }
+ e.preventDefault(); e.stopImmediatePropagation(); sending = true;
+ fetch('/api/vibe/ask', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ message: message, slug: room, models: chosenModels, system_prompt: document.getElementById('vb-system-prompt').value,
+ temperature: Number(document.getElementById('vb-temperature').value) }) })
+ .then(function (r) { return r.json().then(function (d) { if (!r.ok) throw new Error(d.error || 'Could not send'); return d; }); })
+ .then(function (d) { location.href = '/vibe/' + encodeURIComponent(d.chat.slug); })
+ .catch(function (err) { sending = false; notice(err.message); });
+ });
function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
function token() { var m = /(?:^|;\s*)ov_token=([^;]+)/.exec(document.cookie || ''); return m ? decodeURIComponent(m[1]) : undefined; }
function token() { var m = /(?:^|;\s*)ov_token=([^;]+)/.exec(document.cookie || ''); return m ? decodeURIComponent(m[1]) : undefined; }
@@ -629,6 +706,47 @@
@@ -629,6 +706,47 @@
.catch(function () { notice('You seem to be offline.'); });
.catch(function () { notice('You seem to be offline.'); });
});
});
if (cfg.waiting) showWait();
if (cfg.waiting) showWait();
+ var compareRows = app.querySelectorAll('[data-vb-question]');
+ if (compareRows.length) {
+ app.addEventListener('click', function (e) {
+ var tab = e.target.closest('[data-vb-tab]');
+ if (tab) {
+ var wrap = tab.closest('[data-vb-question]'), index = tab.getAttribute('data-vb-tab');
+ wrap.querySelectorAll('[data-vb-tab]').forEach(function (x) { x.classList.toggle('is-active', x === tab); x.setAttribute('aria-selected', String(x === tab)); });
+ wrap.querySelectorAll('[data-vb-answer]').forEach(function (x) { x.classList.toggle('is-active', x.getAttribute('data-vb-answer') === index); });
+ return;
+ }
+ var copy = e.target.closest('[data-vb-copy]');
+ if (copy) { var section = copy.closest('[data-vb-answer]'); if (navigator.clipboard) navigator.clipboard.writeText(section.querySelector('[data-vb-text]').textContent); return; }
+ var pick = e.target.closest('[data-vb-pick]'), prefer = e.target.closest('[data-vb-prefer]');
+ if (!pick && !prefer) return;
+ var button = pick || prefer, model = button.getAttribute(pick ? 'data-vb-pick' : 'data-vb-prefer');
+ fetch(pick ? '/api/vibe/pick' : '/api/vibe/prefer', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ message_id: Number(button.getAttribute('data-vb-message')), model: model }) })
+ .then(function (r) { return r.json().then(function (d) { if (!r.ok) throw new Error(d.error || 'That did not work'); return d; }); })
+ .then(function () { if (pick) { chosenModels = [model]; syncModelChip(); } else { button.textContent = 'Preference recorded'; button.disabled = true; } })
+ .catch(function (err) { notice(err.message); });
+ });
+ function refreshCompare() {
+ if (!room) return;
+ fetch('/api/vibe/chat?slug=' + encodeURIComponent(room), { credentials: 'same-origin' }).then(function (r) { return r.ok ? r.json() : null; }).then(function (d) {
+ if (!d) return;
+ (d.messages || []).forEach(function (m) {
+ if (!m.answers) return;
+ var wrap = app.querySelector('[data-vb-question="' + m.id + '"]'); if (!wrap) return;
+ m.answers.forEach(function (a, i) {
+ var panel = wrap.querySelector('[data-vb-answer="' + i + '"]'); if (!panel) return;
+ var body = panel.querySelector('[data-vb-text]');
+ if (a.status === 'finished') body.innerHTML = MD.render(a.text);
+ else if (a.status === 'failed') body.textContent = a.error || 'Could not answer';
+ var head = panel.querySelector('small'); if (head && a.status === 'finished') head.textContent = [a.provider, a.cost_usd == null ? null : '$' + Number(a.cost_usd).toFixed(4), a.ms == null ? null : (a.ms / 1000).toFixed(1) + 's'].filter(Boolean).join(' · ');
+ var dot = wrap.querySelector('[data-vb-tab="' + i + '"] span'); if (dot) dot.textContent = a.status === 'finished' ? '●' : '○';
+ });
+ });
+ }).catch(function () {});
+ }
+ refreshCompare(); setInterval(refreshCompare, 1500);
+ }
canvasInit();
canvasInit();
connect();
connect();
window.addEventListener('pagehide', function () { var w = ws; room = null; if (w) w.close(); });
window.addEventListener('pagehide', function () { var w = ws; room = null; if (w) w.close(); });
@@ -13,6 +13,7 @@ const config = require('../config');
@@ -13,6 +13,7 @@ const config = require('../config');
let _ai = null;
let _ai = null;
let _http = null;
let _http = null;
let _tokens = null;
let _tokens = null;
+let _complete = null;
// Why the last run answered nothing, when the caller can show it: `ai.resting` today. Cleared by a run that
// Why the last run answered nothing, when the caller can show it: `ai.resting` today. Cleared by a run that
// succeeds. A product asks lastProblem() instead of turning "no answer" into an error.
// succeeds. A product asks lastProblem() instead of turning "no answer" into an error.
let _lastProblem = null;
let _lastProblem = null;
@@ -144,5 +145,36 @@ async function stream(workflow, input, opts = {}, onText = () => {}) {
@@ -144,5 +145,36 @@ async function stream(workflow, input, opts = {}, onText = () => {}) {
}
}
}
}
-module.exports = { structured, stream, enabled, http, lastProblem, _setClient(c) { _ai = c; }, _setHttp(h) { _http = h; }, _setTokens(t) { _tokens = t; },
- _setProblem(p) { _lastProblem = p; }, _reset() { _ai = null; _http = null; _tokens = null; _lastProblem = null; } };
+/** An explicit catalogue model, billed by AI to the person named in on_behalf_of. */
+async function complete({ model, messages, subject, temperature = null }) {
+ if (_complete) return _complete({ model, messages, subject, temperature });
+ if (!subject || !model) throw new Error('A person and model are required');
+ if (!_tokens) ai();
+ const token = await _tokens.getToken();
+ const res = await fetch(`${config.ai.internalUrl}/api/v1/runs?wait=${Math.max(0, Math.floor(config.ai.waitMs))}`, {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Accept: 'application/json' },
+ body: JSON.stringify({ workflow: 'ai.complete', input: { model, messages, max_tokens: 2048,
+ ...(temperature == null ? {} : { temperature }) }, on_behalf_of: { type: 'user', id: subject }, options: { cache: 'none' } }),
+ signal: AbortSignal.timeout(config.ai.waitMs + 10000),
+ });
+ const body = await res.json();
+ const run = body && (body.run || body);
+ if (!res.ok || !run || !['succeeded', 'cached'].includes(run.status)) throw new Error((run && run.error && run.error.code) || `AI answered ${res.status}`);
+ const choice = run.output && run.output.choices && run.output.choices[0];
+ return { text: String((choice && choice.message && choice.message.content) || ''),
+ model: (run.output && run.output.model) || (run.provenance && run.provenance.model) || model,
+ provider: run.provenance && run.provenance.provider || null,
+ cost_usd: run.usage && run.usage.cost_usd != null ? Number(run.usage.cost_usd) : null,
+ ms: run.started_at && run.finished_at ? Math.max(0, Date.parse(run.finished_at) - Date.parse(run.started_at)) : null,
+ run_id: run.id || null };
+}
+
+async function catalog() {
+ const res = await fetch(`${config.ai.internalUrl}/api/v1/exchange/catalog`, { signal: AbortSignal.timeout(8000) });
+ if (!res.ok) throw new Error(`AI catalogue answered ${res.status}`);
+ return res.json();
+}
+
+module.exports = { structured, stream, complete, catalog, enabled, http, lastProblem, _setClient(c) { _ai = c; }, _setComplete(c) { _complete = c; }, _setHttp(h) { _http = h; }, _setTokens(t) { _tokens = t; },
+ _setProblem(p) { _lastProblem = p; }, _reset() { _ai = null; _http = null; _tokens = null; _complete = null; _lastProblem = null; } };
@@ -68,6 +68,8 @@ const EXPORTS = [
@@ -68,6 +68,8 @@ const EXPORTS = [
['vibe_memory.json', `SELECT id, text, source, created_at FROM vibe_memory WHERE user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
['vibe_memory.json', `SELECT id, text, source, created_at FROM vibe_memory WHERE user_id = ANY(@ids) ORDER BY id DESC LIMIT ${LIMIT}`],
['vibe_memory_off.json', `SELECT since FROM vibe_memory_off WHERE user_id = ANY(@ids) LIMIT ${LIMIT}`],
['vibe_memory_off.json', `SELECT since FROM vibe_memory_off WHERE user_id = ANY(@ids) LIMIT ${LIMIT}`],
['vibe_own_engine.json', `SELECT since FROM vibe_own_engine WHERE user_id = ANY(@ids) LIMIT ${LIMIT}`],
['vibe_own_engine.json', `SELECT since FROM vibe_own_engine WHERE user_id = ANY(@ids) LIMIT ${LIMIT}`],
+ ['vibe_model_preferences.json', `SELECT message_id, winner, bench_round_id, created_at FROM vibe_model_preferences WHERE user_id = ANY(@ids) LIMIT ${LIMIT}`],
+ ['vibe_import_notices.json', `SELECT dismissed_at, imported_at FROM vibe_import_notices WHERE user_id = ANY(@ids) LIMIT ${LIMIT}`],
// What they asked Vibe by voice on their machines, the last 7 days (server/chat/internal-vibe.js).
// What they asked Vibe by voice on their machines, the last 7 days (server/chat/internal-vibe.js).
// Which of their agents' runs were posted into their Vibe chats (server/chat/internal-vibe.js).
// Which of their agents' runs were posted into their Vibe chats (server/chat/internal-vibe.js).
['vibe_deliveries.json', `SELECT room_id, message_id, created_at FROM vibe_deliveries WHERE user_id = ANY(@ids) ORDER BY created_at DESC LIMIT ${LIMIT}`],
['vibe_deliveries.json', `SELECT room_id, message_id, created_at FROM vibe_deliveries WHERE user_id = ANY(@ids) ORDER BY created_at DESC LIMIT ${LIMIT}`],
@@ -130,6 +132,8 @@ const ERASE_AFTER_ROOMS = [
@@ -130,6 +132,8 @@ const ERASE_AFTER_ROOMS = [
['vibe_memory', 'DELETE FROM vibe_memory WHERE user_id = ANY(@ids)'],
['vibe_memory', 'DELETE FROM vibe_memory WHERE user_id = ANY(@ids)'],
['vibe_memory_off', 'DELETE FROM vibe_memory_off WHERE user_id = ANY(@ids)'],
['vibe_memory_off', 'DELETE FROM vibe_memory_off WHERE user_id = ANY(@ids)'],
['vibe_own_engine', 'DELETE FROM vibe_own_engine WHERE user_id = ANY(@ids)'],
['vibe_own_engine', 'DELETE FROM vibe_own_engine WHERE user_id = ANY(@ids)'],
+ ['vibe_model_preferences', 'DELETE FROM vibe_model_preferences WHERE user_id = ANY(@ids)'],
+ ['vibe_import_notices', 'DELETE FROM vibe_import_notices WHERE user_id = ANY(@ids)'],
['vibe_asks', 'DELETE FROM vibe_asks WHERE user_id = ANY(@ids)'],
['vibe_asks', 'DELETE FROM vibe_asks WHERE user_id = ANY(@ids)'],
['agent_room_votes', 'DELETE FROM agent_room_poll_votes WHERE user_id = ANY(@ids)'],
['agent_room_votes', 'DELETE FROM agent_room_poll_votes WHERE user_id = ANY(@ids)'],
['room_reactions', 'DELETE FROM room_message_reactions WHERE user_id = ANY(@ids)'],
['room_reactions', 'DELETE FROM room_message_reactions WHERE user_id = ANY(@ids)'],
@@ -226,6 +226,12 @@ module.exports = {
@@ -226,6 +226,12 @@ module.exports = {
vibe: process.env.CHAT_VIBE !== '0',
vibe: process.env.CHAT_VIBE !== '0',
vibeRepliesPerDay: Math.max(1, int(process.env.CHAT_VIBE_REPLIES_PER_DAY, 100)),
vibeRepliesPerDay: Math.max(1, int(process.env.CHAT_VIBE_REPLIES_PER_DAY, 100)),
},
},
+ bench: {
+ url: strip(process.env.OV_BENCH_URL || 'https://bench.openvibe.services'),
+ audience: 'openvibe.bench',
+ scope: 'bench.round.create bench.judgement.cast',
+ },
+ exchange: { internalUrl: strip(process.env.OV_EXCHANGE_INTERNAL_URL || 'http://127.0.0.1:5150') },
// The chat apps (Telegram, Discord, Slack): your OpenVibe agent in each of them (the agent plan of
// The chat apps (Telegram, Discord, Slack): your OpenVibe agent in each of them (the agent plan of
// 2026-10-30, stage A4). Each app is OFF until its own token and its own secret are here: the owner's
// 2026-10-30, stage A4). Each app is OFF until its own token and its own secret are here: the owner's
@@ -340,7 +340,7 @@ async function postAsAi(room, member, text, { now = Date.now(), how = null } = {
@@ -340,7 +340,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,
+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,
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
@@ -29,6 +29,8 @@ const rooms = require('./rooms');
@@ -29,6 +29,8 @@ const rooms = require('./rooms');
const vibe = require('./vibe');
const vibe = require('./vibe');
const vibeResults = require('./vibe-results');
const vibeResults = require('./vibe-results');
const aiClient = require('../ai/client');
const aiClient = require('../ai/client');
+const compare = require('./vibe-compare');
+const importer = require('./vibe-import');
const router = express.Router();
const router = express.Router();
router.use(express.json({ limit: '32kb' }));
router.use(express.json({ limit: '32kb' }));
@@ -36,6 +38,20 @@ router.use(requireAuth);
@@ -36,6 +38,20 @@ router.use(requireAuth);
const SIX_HOURS = 6 * 3600 * 1000;
const SIX_HOURS = 6 * 3600 * 1000;
const asked = limits('chat.vibe.ask', { minute: 12, hour: 300 });
const asked = limits('chat.vibe.ask', { minute: 12, hour: 300 });
+// The limiter counts calls; charge a compare once for each answer before any message is written.
+const answerLimit = async (req, res, next) => {
+ let count = Array.isArray(req.body && req.body.models) ? Math.min(3, req.body.models.length) : 1;
+ if (!Array.isArray(req.body && req.body.models) && !(req.body && req.body.fresh)) {
+ try {
+ const slug = req.body && req.body.slug || await vibe.current(req.user);
+ const room = slug && await vibe.ownChat(slug, req.user);
+ if (room) count = Math.max(1, (await compare.settings(room)).models.length);
+ } catch { /* the route below reports a storage error */ }
+ }
+ let remaining = Math.max(1, count);
+ const charge = () => remaining-- > 0 ? asked(req, res, charge) : next();
+ return charge();
+};
const undone = limits('chat.vibe.undo', { minute: 12, hour: 300 }); // like ask: undoing is as rare as asking
const undone = limits('chat.vibe.undo', { minute: 12, hour: 300 }); // like ask: undoing is as rare as asking
const listened = limits('chat.vibe.listen', { minute: 20, hour: 200 });
const listened = limits('chat.vibe.listen', { minute: 20, hour: 200 });
const AUDIO_MAX = 1024 * 1024;
const AUDIO_MAX = 1024 * 1024;
@@ -47,7 +63,8 @@ const at = (ts) => { const d = new Date(String(ts || '').includes('T') ? ts : `$
@@ -47,7 +63,8 @@ const at = (ts) => { const d = new Date(String(ts || '').includes('T') ? ts : `$
// A Vibe message: its text, and what Vibe really did for the person (the chips; server/rooms/vibe-results.js).
// A Vibe message: its text, and what Vibe really did for the person (the chips; server/rooms/vibe-results.js).
const wire = (m) => {
const wire = (m) => {
const results = vibeResults.list(m.results);
const results = vibeResults.list(m.results);
- return { id: Number(m.id), from: m.ai_member_id ? 'vibe' : 'you', text: String(m.message || ''), at: at(m.created_at), ...(results.length ? { results } : {}) };
+ return { id: Number(m.id), from: m.ai_member_id ? 'vibe' : 'you', text: String(m.message || ''), at: at(m.created_at),
+ ...(m.vibe_model ? { model: m.vibe_model } : {}), ...(results.length ? { results } : {}) };
};
};
const chatOf = (room) => (room ? { slug: room.slug, name: room.name || 'Vibe', url: `https://openvibe.chat/vibe/${encodeURIComponent(room.slug)}` } : null);
const chatOf = (room) => (room ? { slug: room.slug, name: room.name || 'Vibe', url: `https://openvibe.chat/vibe/${encodeURIComponent(room.slug)}` } : null);
@@ -62,7 +79,34 @@ router.get('/chat', personOnly, async (req, res) => {
@@ -62,7 +79,34 @@ router.get('/chat', personOnly, async (req, res) => {
if (!room) return res.set('Cache-Control', 'private, no-store').json({ chat: null, messages: [] });
if (!room) return res.set('Cache-Control', 'private, no-store').json({ chat: null, messages: [] });
const after = Number(req.query.after) || null;
const after = Number(req.query.after) || null;
const rows = await rooms.history(room, after ? { after, limit: 50 } : { limit: 40 });
const rows = await rooms.history(room, after ? { after, limit: 50 } : { limit: 40 });
- return res.set('Cache-Control', 'private, no-store').json({ chat: chatOf(room), messages: rows.filter((m) => !m.is_deleted).map(wire) });
+ const by = await compare.answers(room, rows.filter((m) => !m.ai_member_id).map((m) => Number(m.id)));
+ return res.set('Cache-Control', 'private, no-store').json({ chat: { ...chatOf(room), ...(await compare.settings(room)) },
+ messages: rows.filter((m) => !m.is_deleted).map((m) => ({ ...wire(m), ...(by.has(Number(m.id)) ? { answers: by.get(Number(m.id)) } : {}) })) });
+});
+
+router.get('/models', personOnly, async (_req, res) => {
+ try { return res.set('Cache-Control', 'private, max-age=120').json(await aiClient.catalog()); }
+ catch { return res.status(503).json({ error: 'Models are unavailable right now' }); }
+});
+router.post('/settings', personOnly, asked, async (req, res) => {
+ try {
+ const room = await vibe.ownChat(String(req.body && req.body.slug || ''), req.user);
+ if (!room) return res.status(404).json({ error: 'No such chat' });
+ return res.json(await compare.saveSettings(room, { ...(await compare.settings(room)), ...req.body }));
+ } catch (err) { return res.status(err.status || 500).json({ error: err.status ? err.message : 'Could not save settings' }); }
+});
+
+router.get('/import', personOnly, async (req, res) => {
+ try { return res.set('Cache-Control', 'private, no-store').json(await importer.status(req.user)); }
+ catch { return res.status(503).json({ error: 'Exchange chat import is unavailable' }); }
+});
+router.post('/import', personOnly, asked, async (req, res) => {
+ try { return res.set('Cache-Control', 'private, no-store').json(await importer.bring(req.user)); }
+ catch { return res.status(503).json({ error: 'Exchange chat import could not finish' }); }
+});
+router.post('/import/dismiss', personOnly, async (req, res) => {
+ try { return res.json(await importer.dismiss(req.user)); }
+ catch { return res.status(500).json({ error: 'Could not dismiss this notice' }); }
});
});
/**
/**
@@ -128,11 +172,13 @@ router.post('/listen', personOnly, listened, audioBody, async (req, res) => {
@@ -128,11 +172,13 @@ router.post('/listen', personOnly, listened, audioBody, async (req, res) => {
return res.set('Cache-Control', 'private, no-store').json({ text });
return res.set('Cache-Control', 'private, no-store').json({ text });
});
});
-router.post('/ask', personOnly, asked, async (req, res) => {
+router.post('/ask', personOnly, (req, res, next) => (req.body && req.body.models !== undefined && !compare.modelIds(req.body.models)
+ ? res.status(422).json({ error: 'Choose one to three distinct models' }) : next()), answerLimit, async (req, res) => {
const b = req.body && typeof req.body === 'object' ? req.body : {};
const b = req.body && typeof req.body === 'object' ? req.body : {};
const message = String(b.message || '').replace(/\r\n/g, '\n').trim();
const message = String(b.message || '').replace(/\r\n/g, '\n').trim();
if (!message) return res.status(422).json({ error: 'Write something first' });
if (!message) return res.status(422).json({ error: 'Write something first' });
if (message.length > 8000) return res.status(422).json({ error: 'That is longer than Vibe reads in one message' });
if (message.length > 8000) return res.status(422).json({ error: 'That is longer than Vibe reads in one message' });
+ if (b.models !== undefined && !compare.modelIds(b.models)) return res.status(422).json({ error: 'Choose one to three distinct models' });
const timezone = zone(b.timezone);
const timezone = zone(b.timezone);
try {
try {
let room = b.slug ? await vibe.ownChat(String(b.slug), req.user) : null;
let room = b.slug ? await vibe.ownChat(String(b.slug), req.user) : null;
@@ -141,12 +187,22 @@ router.post('/ask', personOnly, asked, async (req, res) => {
@@ -141,12 +187,22 @@ router.post('/ask', personOnly, asked, async (req, res) => {
const moved = last ? Date.parse(at(last.updated_at || last.last_message_at || last.created_at) || 0) : 0;
const moved = last ? Date.parse(at(last.updated_at || last.last_message_at || last.created_at) || 0) : 0;
if (last && Date.now() - moved < SIX_HOURS) room = last;
if (last && Date.now() - moved < SIX_HOURS) room = last;
}
}
+ if (!room && b.models) {
+ room = await vibe.createRoom(req.user, message);
+ try {
+ const posted = await compare.ask(room, req.user, message, b.models, { system_prompt: b.system_prompt, temperature: b.temperature });
+ return res.status(201).json({ chat: chatOf(room), message: wire(posted) });
+ } catch (err) { await vibe.erase(room.id); throw err; }
+ }
if (!room) {
if (!room) {
const out = await vibe.start(req.user, message, { timezone });
const out = await vibe.start(req.user, message, { timezone });
const full = await vibe.ownChat(out.room.slug, req.user);
const full = await vibe.ownChat(out.room.slug, req.user);
return res.status(201).json({ chat: chatOf(full || out.room), message: wire(out.message) });
return res.status(201).json({ chat: chatOf(full || out.room), message: wire(out.message) });
}
}
- const posted = await rooms.post(room, req.user, message, { timezone });
+ const selected = b.models || (await compare.settings(room)).models;
+ const posted = selected.length ? await compare.ask(room, req.user, message, selected,
+ { ...(b.system_prompt === undefined ? {} : { system_prompt: b.system_prompt }), ...(b.temperature === undefined ? {} : { temperature: b.temperature }) })
+ : await rooms.post(room, req.user, message, { timezone });
try { require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message: posted }); } catch { /* the socket is optional */ }
try { require('../chat/chat-server').broadcastToRoom(room.id, { type: 'room_message', room: room.slug, message: posted }); } catch { /* the socket is optional */ }
return res.status(201).json({ chat: chatOf(room), message: wire(posted) });
return res.status(201).json({ chat: chatOf(room), message: wire(posted) });
} catch (err) {
} catch (err) {
@@ -154,4 +210,13 @@ router.post('/ask', personOnly, asked, async (req, res) => {
@@ -154,4 +210,13 @@ router.post('/ask', personOnly, asked, async (req, res) => {
}
}
});
});
+router.post('/pick', personOnly, asked, async (req, res) => {
+ try { return res.json(await compare.pick(req.user, Number(req.body && req.body.message_id), String(req.body && req.body.model || ''))); }
+ catch (err) { return res.status(err.status || 500).json({ error: err.status ? err.message : 'Could not pick that answer' }); }
+});
+router.post('/prefer', personOnly, asked, async (req, res) => {
+ try { return res.json(await compare.prefer(req.user, Number(req.body && req.body.message_id), String(req.body && req.body.model || ''))); }
+ catch (err) { return res.status(err.status || 500).json({ error: err.status ? err.message : 'Could not record that preference' }); }
+});
+
module.exports = router;
module.exports = router;
new file mode 100644
new file mode 100644
@@ -0,0 +1,130 @@
@@ -0,0 +1,130 @@
+'use strict';
+/** Explicit model turns in a person's existing Vibe room. */
+const db = require('../db/database');
+const ai = require('../ai/client');
+const rooms = require('./rooms');
+const config = require('../config');
+let benchOverride = null;
+
+const modelIds = (value) => Array.isArray(value) && value.length >= 1 && value.length <= 3
+ && value.every((id) => typeof id === 'string' && /^[a-z0-9][a-z0-9._/@:-]{1,255}$/i.test(id))
+ && new Set(value).size === value.length;
+
+async function settings(room) {
+ const row = await db.get('SELECT models, system_prompt, temperature FROM vibe_chat_settings WHERE room_id = ?', [room.id]);
+ if (!row) return { models: [], system_prompt: null, temperature: null };
+ return { ...row, models: Array.isArray(row.models) ? row.models : JSON.parse(row.models || '[]') };
+}
+
+async function saveSettings(room, { models, system_prompt, temperature }) {
+ if (!modelIds(models) && !(Array.isArray(models) && !models.length)) throw Object.assign(new Error('Choose up to three distinct models'), { status: 422 });
+ if (system_prompt != null && (typeof system_prompt !== 'string' || system_prompt.length > 4000)) throw Object.assign(new Error('The system prompt is too long'), { status: 422 });
+ if (temperature != null && (!Number.isFinite(Number(temperature)) || Number(temperature) < 0 || Number(temperature) > 2)) throw Object.assign(new Error('Temperature must be from 0 to 2'), { status: 422 });
+ await db.run(`INSERT INTO vibe_chat_settings (room_id, models, system_prompt, temperature) VALUES (?, ?::jsonb, ?, ?)
+ ON CONFLICT (room_id) DO UPDATE SET models = excluded.models, system_prompt = excluded.system_prompt, temperature = excluded.temperature`,
+ [room.id, JSON.stringify(models), system_prompt || null, temperature == null ? null : Number(temperature)]);
+ return settings(room);
+}
+
+async function answers(room, messageIds) {
+ if (!messageIds.length) return new Map();
+ const rows = await db.all('SELECT * FROM vibe_model_turns WHERE room_id = ? AND message_id = ANY(?::bigint[]) ORDER BY message_id, position', [room.id, messageIds]);
+ const by = new Map();
+ for (const r of rows) {
+ if (!by.has(Number(r.message_id))) by.set(Number(r.message_id), []);
+ by.get(Number(r.message_id)).push({ model: r.model, status: r.status, text: r.text || '', provider: r.provider || null,
+ cost_usd: r.cost_usd == null ? null : Number(r.cost_usd), ms: r.ms == null ? null : Number(r.ms), run_id: r.run_id || null,
+ ...(r.error ? { error: r.error } : {}) });
+ }
+ return by;
+}
+
+async function run(room, user, asked, models) {
+ const history = await rooms.history(room, { before: asked.id, limit: 24 });
+ const earlier = await answers(room, history.filter((m) => !m.ai_member_id).map((m) => Number(m.id)));
+ const prefs = await settings(room);
+ await Promise.allSettled(models.map(async (model) => {
+ try {
+ const previous = [];
+ for (const m of history.filter((m) => !m.is_deleted)) {
+ previous.push({ role: m.ai_member_id ? 'assistant' : 'user', content: m.message });
+ const group = earlier.get(Number(m.id));
+ if (group) {
+ const answer = group.find((a) => a.model === model && a.status === 'finished') || group.find((a) => a.status === 'finished');
+ if (answer) previous.push({ role: 'assistant', content: answer.text });
+ }
+ }
+ const messages = [...(prefs.system_prompt ? [{ role: 'system', content: prefs.system_prompt }] : []), ...previous, { role: 'user', content: asked.message }];
+ const result = await ai.complete({ model, messages, subject: user.subject_id, temperature: prefs.temperature });
+ await db.run(`UPDATE vibe_model_turns SET status = 'finished', text = ?, provider = ?, cost_usd = ?, ms = ?, run_id = ?
+ WHERE message_id = ? AND model = ?`, [result.text.slice(0, 8000), result.provider, result.cost_usd, result.ms, result.run_id, asked.id, model]);
+ } catch (err) {
+ await db.run("UPDATE vibe_model_turns SET status = 'failed', error = ? WHERE message_id = ? AND model = ?",
+ [String(err.message || 'The model could not answer').slice(0, 250), asked.id, model]);
+ }
+ }));
+}
+
+async function ask(room, user, message, models, options = {}) {
+ if (!modelIds(models)) throw Object.assign(new Error('Choose one to three distinct models'), { status: 422 });
+ const posted = await rooms.post(room, user, message, { ai: false });
+ await db.tx(async () => {
+ for (const [position, model] of models.entries()) await db.run('INSERT INTO vibe_model_turns (message_id, room_id, model, position) VALUES (?, ?, ?, ?)', [posted.id, room.id, model, position]);
+ });
+ const prefs = await settings(room);
+ await saveSettings(room, { ...prefs, ...options, models });
+ setImmediate(() => run(room, user, posted, models).catch((err) => console.error('[Vibe compare]', err.message)));
+ return posted;
+}
+
+async function pick(user, messageId, model) {
+ const row = await db.get(`SELECT t.room_id FROM vibe_model_turns t JOIN rooms r ON r.id = t.room_id
+ WHERE t.message_id = ? AND t.model = ? AND t.status = 'finished' AND r.owner_id = ? AND r.assistant = 1`, [messageId, model, user.id]);
+ if (!row) throw Object.assign(new Error('That answer is not in your chat'), { status: 404 });
+ const room = await db.get('SELECT * FROM rooms WHERE id = ?', [row.room_id]);
+ const prefs = await settings(room);
+ await saveSettings(room, { ...prefs, models: [model] });
+ return { chat: { slug: room.slug }, model };
+}
+
+async function prefer(user, messageId, model) {
+ const row = await db.get(`SELECT t.room_id, m.message FROM vibe_model_turns t JOIN rooms r ON r.id = t.room_id
+ JOIN room_messages m ON m.id = t.message_id WHERE t.message_id = ? AND t.model = ? AND t.status = 'finished'
+ AND r.owner_id = ? AND r.assistant = 1`, [messageId, model, user.id]);
+ if (!row) throw Object.assign(new Error('That answer is not in your chat'), { status: 404 });
+ const all = (await answers({ id: row.room_id }, [messageId])).get(messageId) || [];
+ const rival = all.find((a) => a.model !== model && a.status === 'finished');
+ if (!rival) throw Object.assign(new Error('Two finished answers are needed to compare'), { status: 422 });
+ if (await db.get('SELECT message_id FROM vibe_model_preferences WHERE message_id = ? AND user_id = ?', [messageId, user.id]))
+ throw Object.assign(new Error('You already chose an answer'), { status: 409 });
+ if (!config.oauth.clientSecret && !benchOverride) throw Object.assign(new Error('Preference voting is unavailable'), { status: 503 });
+ let token = null;
+ if (!benchOverride) {
+ const { createServiceTokenClient } = require('openvibe-sdk/auth');
+ const tokens = createServiceTokenClient({ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret, audience: config.bench.audience, scope: config.bench.scope });
+ token = await tokens.getToken();
+ }
+ async function call(path, body, subject = false) {
+ if (benchOverride) return path.endsWith('/votes') ? benchOverride.vote({ path, body, subject: user.subject_id }) : benchOverride.createRound(body);
+ const res = await fetch(`${config.bench.url}${path}`, { method: 'POST', headers: { Authorization: `Bearer ${token}`,
+ 'Content-Type': 'application/json', ...(subject ? { 'x-ov-subject': user.subject_id } : {}) },
+ body: JSON.stringify(body), signal: AbortSignal.timeout(15000) });
+ const out = await res.json().catch(() => null);
+ if (!res.ok) throw Object.assign(new Error('Bench could not record the preference'), { status: 503 });
+ return out;
+ }
+ const entries = [all.find((a) => a.model === model), rival];
+ const built = await call('/api/v1/arena/rounds', { prompt: row.message.slice(0, 4000), category: 'chat', source: 'chat',
+ entries: entries.map((a) => ({ provider: a.provider || 'openvibe', model: a.model, answer: a.text,
+ cost_usd: a.cost_usd, ms: a.ms, run_id: a.run_id })) });
+ const sides = built && built.entries || [];
+ if (!built || !built.round || sides.length < 2) throw Object.assign(new Error('Bench did not create the comparison'), { status: 502 });
+ await call(`/api/v1/arena/rounds/${encodeURIComponent(built.round.id)}/votes`,
+ { outcome: 'left', pair: { left: sides[0].id, right: sides[1].id } }, true);
+ await db.run('INSERT INTO vibe_model_preferences (message_id, user_id, winner, bench_round_id) VALUES (?, ?, ?, ?)',
+ [messageId, user.id, model, built.round.id]);
+ return { recorded: true, model };
+}
+
+module.exports = { modelIds, settings, saveSettings, answers, ask, pick, prefer, _setBench(client) { benchOverride = client; } };
new file mode 100644
new file mode 100644
@@ -0,0 +1,75 @@
@@ -0,0 +1,75 @@
+'use strict';
+const config = require('../config');
+const db = require('../db/database');
+const vibe = require('./vibe');
+
+let tokens = null;
+let exportOverride = null;
+async function exportFor(subject) {
+ if (exportOverride) return exportOverride(subject);
+ if (!config.oauth.clientSecret) throw Object.assign(new Error('Import is unavailable'), { status: 503 });
+ if (!tokens) {
+ const { createServiceTokenClient } = require('openvibe-sdk/auth');
+ tokens = createServiceTokenClient({ tokenUrl: `${config.networkInternalUrl}/oauth/token`,
+ clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
+ audience: 'openvibe.exchange', scope: 'exchange.chat.export' });
+ }
+ const token = await tokens.getToken();
+ const res = await fetch(`${config.exchange.internalUrl}/internal/exchange/chat/export`, {
+ headers: { Authorization: `Bearer ${token}`, 'x-ov-subject': subject, Accept: 'application/json' },
+ signal: AbortSignal.timeout(15000),
+ });
+ if (!res.ok) throw Object.assign(new Error('Exchange could not export your chats'), { status: 503 });
+ return res.json();
+}
+
+async function status(user) {
+ const notice = await db.get('SELECT dismissed_at, imported_at FROM vibe_import_notices WHERE user_id = ?', [user.id]);
+ if (notice && (notice.dismissed_at || notice.imported_at)) return { count: 0, show: false };
+ const body = await exportFor(user.subject_id);
+ const ids = (body.chats || []).map((c) => c.id);
+ if (!ids.length) return { count: 0, show: false };
+ const existing = await db.all('SELECT exchange_id FROM vibe_chat_settings WHERE exchange_id = ANY(?::text[])', [ids]);
+ const imported = new Set(existing.map((r) => r.exchange_id));
+ const count = ids.filter((id) => !imported.has(id)).length;
+ return { count, show: count > 0 };
+}
+
+async function dismiss(user) {
+ await db.run(`INSERT INTO vibe_import_notices (user_id, dismissed_at) VALUES (?, ov_now())
+ ON CONFLICT (user_id) DO UPDATE SET dismissed_at = ov_now()`, [user.id]);
+ return { show: false };
+}
+
+async function bring(user) {
+ const body = await exportFor(user.subject_id);
+ let imported = 0;
+ for (const c of body.chats || []) {
+ if (!/^exc_[0-9A-Za-z]+$/.test(String(c.id)) || !Array.isArray(c.messages)) continue;
+ if (await db.get('SELECT room_id FROM vibe_chat_settings WHERE exchange_id = ?', [c.id])) continue;
+ const room = await vibe.createRoom(user, c.title || 'Imported chat');
+ try {
+ const member = await db.get('SELECT id FROM room_ai_members WHERE room_id = ?', [room.id]);
+ await db.tx(async () => {
+ await db.run('UPDATE rooms SET name = ?, created_at = ?, last_message_at = ? WHERE id = ?', [String(c.title || 'Imported chat').slice(0, 120), c.created_at || c.updated_at, c.updated_at || c.created_at, room.id]);
+ await db.run('INSERT INTO vibe_chat_settings (room_id, models, exchange_id) VALUES (?, ?::jsonb, ?)',
+ [room.id, JSON.stringify((c.models || []).filter((x) => typeof x === 'string').slice(0, 3)), c.id]);
+ for (const m of c.messages.slice(0, 1000)) {
+ if (!['user', 'assistant', 'system'].includes(m.role) || typeof m.content !== 'string') continue;
+ await db.run(`INSERT INTO room_messages (room_id, user_id, subject_id, ai_member_id, message, created_at, vibe_model)
+ VALUES (?, ?, ?, ?, ?, ?, ?)`, [room.id, m.role === 'assistant' ? 0 : user.id,
+ m.role === 'assistant' ? null : user.subject_id, m.role === 'assistant' ? member.id : null,
+ m.content, m.created_at || c.created_at || c.updated_at, m.role === 'assistant' ? m.model || null : null]);
+ }
+ const count = await db.get('SELECT COUNT(*) AS n FROM room_messages WHERE room_id = ?', [room.id]);
+ await db.run('UPDATE rooms SET message_count = ? WHERE id = ?', [Number(count.n), room.id]);
+ });
+ imported += 1;
+ } catch (err) { await vibe.erase(room.id); throw err; }
+ }
+ await db.run(`INSERT INTO vibe_import_notices (user_id, imported_at) VALUES (?, ov_now())
+ ON CONFLICT (user_id) DO UPDATE SET imported_at = ov_now()`, [user.id]);
+ return { imported };
+}
+
+module.exports = { exportFor, status, dismiss, bring, _setExport(fn) { exportOverride = fn; } };
@@ -20,6 +20,7 @@ const md = require('../../public/web/vibe-md.js');
@@ -20,6 +20,7 @@ const md = require('../../public/web/vibe-md.js');
const pictures = require('../rooms/pictures');
const pictures = require('../rooms/pictures');
const rooms = require('../rooms/rooms');
const rooms = require('../rooms/rooms');
const vibe = require('../rooms/vibe');
const vibe = require('../rooms/vibe');
+const compare = require('../rooms/vibe-compare');
const workspace = require('../rooms/workspace');
const workspace = require('../rooms/workspace');
const how = require('../rooms/how');
const how = require('../rooms/how');
const memory = require('../rooms/vibe-memory');
const memory = require('../rooms/vibe-memory');
@@ -28,10 +29,9 @@ const kit = require('openvibe-shared/kit');
@@ -28,10 +29,9 @@ const kit = require('openvibe-shared/kit');
const thread = require('openvibe-shared/thread');
const thread = require('openvibe-shared/thread');
const SUGGESTIONS = [
const SUGGESTIONS = [
- { title: 'Catch me up', text: 'on today\'s tech news', prompt: 'What happened in tech news today? Give me the highlights with links.' },
- { title: 'Find a deal', text: 'on a mechanical keyboard', prompt: 'Find me a good deal on a mechanical keyboard on OpenVibe Deals.' },
- { title: 'Look for jobs', text: 'remote video editing work', prompt: 'Find remote video editing jobs posted recently.' },
- { title: 'Explain a page', text: 'how browser go-live works', prompt: 'Read https://openvibe.live/docs/go-live-in-your-browser and explain how going live from a browser works, in plain words.' },
+ { title: 'Explain a concept', prompt: 'Explain a concept I am curious about in plain English.' },
+ { title: 'Help me write', prompt: 'Help me write a clear, friendly message.' },
+ { title: 'Compare ideas', prompt: 'Give me two ways to approach this idea and their tradeoffs.' },
];
];
const DAY = 86_400_000;
const DAY = 86_400_000;
@@ -161,7 +161,10 @@ function youOf(m, me) {
@@ -161,7 +161,10 @@ function youOf(m, me) {
/** Vibe's answer: the rendered Markdown, what it did (reminders, timers), its sources and how, then Copy and Try again. */
/** Vibe's answer: the rendered Markdown, what it did (reminders, timers), its sources and how, then Copy and Try again. */
function answerOf(m, hows, retrySlug, slug) {
function answerOf(m, hows, retrySlug, slug) {
const info = hows.get(Number(m.id));
const info = hows.get(Number(m.id));
- return `<p class="oc-sr">Vibe said:</p>${thread.answer({ html: md.render(m.message), copy: false })}${resultChips(m, slug)}${how.chips(info, m.id)}${how.html(info, m.id)}${acts(retrySlug)}`;
+ return `<p class="oc-sr">Vibe said:</p>${m.vibe_model ? `<p class="vb-import-model">${esc(m.vibe_model)}</p>` : ''}${thread.answer({ html: md.render(m.message), copy: false })}${resultChips(m, slug)}${how.chips(info, m.id)}${how.html(info, m.id)}${acts(retrySlug)}`;
+}
+function compareOf(asked, answers) {
+ return `<div class="vb-compare" data-vb-question="${Number(asked.id)}"><div class="vb-compare-tabs" role="tablist" aria-label="Models">${answers.map((a, i) => `<button type="button" role="tab" class="vb-compare-tab${i ? '' : ' is-active'}" data-vb-tab="${i}" aria-selected="${i ? 'false' : 'true'}">${esc(a.model)} <span aria-hidden="true">${a.status === 'finished' ? '●' : '○'}</span></button>`).join('')}</div><div class="vb-compare-grid">${answers.map((a, i) => `<section class="vb-compare-answer${i ? '' : ' is-active'}" data-vb-answer="${i}"><header><strong>${esc(a.model)}</strong><small>${a.provider ? `${esc(a.provider)} · ` : ''}${a.cost_usd == null ? '' : `$${Number(a.cost_usd).toFixed(4)} · `}${a.ms == null ? '' : `${(Number(a.ms) / 1000).toFixed(1)}s`}</small></header><div class="vb-compare-text" data-vb-text>${a.status === 'writing' ? '<span class="vb-pulse" aria-hidden="true"></span>Writing…' : a.status === 'failed' ? esc(a.error || 'Could not answer') : md.render(a.text)}</div><div class="vb-compare-actions"><button type="button" data-vb-copy="${i}">Copy</button><button type="button" data-vb-pick="${esc(a.model)}" data-vb-message="${Number(asked.id)}">Continue with this one</button><button type="button" data-vb-prefer="${esc(a.model)}" data-vb-message="${Number(asked.id)}">This one was better</button></div></section>`).join('')}</div></div>`;
}
}
/** Vibe writing: the live line, open (ov-thread.js and vibe.js follow it). */
/** Vibe writing: the live line, open (ov-thread.js and vibe.js follow it). */
const WRITING = thread.work({ open: true, live: 'Vibe is writing' });
const WRITING = thread.work({ open: true, live: 'Vibe is writing' });
@@ -178,7 +181,7 @@ function turnsOf(messages, me, hows, { retrySlug = null, slug = null, waiting =
@@ -178,7 +181,7 @@ function turnsOf(messages, me, hows, { retrySlug = null, slug = null, waiting =
const open = specs[specs.length - 1];
const open = specs[specs.length - 1];
if (open && open.reply == null) { open.reply = reply; open.ai = m.id; }
if (open && open.reply == null) { open.reply = reply; open.ai = m.id; }
else specs.push({ reply, ai: m.id });
else specs.push({ reply, ai: m.id });
- } else specs.push({ you: youOf(m, me), me: m.id, reply: null });
+ } else specs.push({ you: youOf(m, me), me: m.id, reply: m.compareAnswers ? compareOf(m, m.compareAnswers) : null });
}
}
const tail = specs[specs.length - 1];
const tail = specs[specs.length - 1];
if (waiting && tail && tail.reply == null) { tail.reply = WRITING; tail.open = true; }
if (waiting && tail && tail.reply == null) { tail.reply = WRITING; tail.open = true; }
@@ -196,7 +199,7 @@ const MODES = [
@@ -196,7 +199,7 @@ const MODES = [
// The text files the attach button takes (read in the browser, vibe.js readText).
// 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';
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 }) {
+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
// 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.
// file (notes, a CSV, code) is read in the browser and goes into the message itself.
@@ -211,7 +214,7 @@ function composer({ slug, signedIn, enabled, prefill = '', engine = null }) {
@@ -211,7 +214,7 @@ function composer({ slug, signedIn, enabled, prefill = '', engine = null }) {
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-preview" id="vb-preview" hidden></div>', tools: attach + modes,
+ 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>`, 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>`,
@@ -254,6 +257,9 @@ function mountVibePages(router, { page, viewerOf, needUser, sameSite, formLimit,
@@ -254,6 +257,9 @@ function mountVibePages(router, { page, viewerOf, needUser, sameSite, formLimit,
const chats = user ? (searching ? await vibe.search(user, query) : await vibe.list(user)) : [];
const chats = user ? (searching ? await vibe.search(user, query) : await vibe.list(user)) : [];
const allowance = user ? await allowanceOf(user) : null;
const allowance = user ? await allowanceOf(user) : null;
const messages = room ? await rooms.history(room, { limit: 200 }) : [];
const messages = room ? await rooms.history(room, { limit: 200 }) : [];
+ const compareAnswers = room ? await compare.answers(room, messages.filter((m) => !m.ai_member_id).map((m) => Number(m.id))) : new Map();
+ for (const m of messages) if (compareAnswers.has(Number(m.id))) m.compareAnswers = compareAnswers.get(Number(m.id));
+ const modelSettings = room ? await compare.settings(room) : { models: [] };
const hows = room ? await how.forRoom(room) : new Map(); // "How Vibe did this" under agent tasks' answers
const hows = room ? await how.forRoom(room) : new Map(); // "How Vibe did this" under agent tasks' answers
// The agent's canvas beside the chat (plan HO9): only when the newest task in the chat is Actor's, because
// The agent's canvas beside the chat (plan HO9): only when the newest task in the chat is Actor's, because
// that task's thread is the conversation the panel belongs to. The browser reads the document itself with
// that task's thread is the conversation the panel belongs to. The browser reads the document itself with
@@ -262,14 +268,15 @@ function mountVibePages(router, { page, viewerOf, needUser, sameSite, formLimit,
@@ -262,14 +268,15 @@ function mountVibePages(router, { page, viewerOf, needUser, sameSite, formLimit,
const canvas = room && user ? await workspace.newestActor(room, user) : null;
const canvas = room && user ? await workspace.newestActor(room, user) : null;
const enabled = vibe.enabled();
const enabled = vibe.enabled();
const title = room ? room.name : 'Vibe';
const title = room ? room.name : 'Vibe';
- const waiting = room && messages.length && !messages[messages.length - 1].ai_member_id;
+ const waiting = room && messages.length && !messages[messages.length - 1].ai_member_id && !messages[messages.length - 1].compareAnswers;
// Try again goes under the last answer when it answers the person's last message.
// Try again goes under the last answer when it answers the person's last message.
const retryable = room && messages.length > 1 && !!messages[messages.length - 1].ai_member_id && !messages[messages.length - 2].ai_member_id && user && Number(messages[messages.length - 2].user_id) === Number(user.id);
const retryable = room && messages.length > 1 && !!messages[messages.length - 1].ai_member_id && !messages[messages.length - 2].ai_member_id && user && Number(messages[messages.length - 2].user_id) === Number(user.id);
const hello = `<div class="vb-hello">
const hello = `<div class="vb-hello">
+${user ? '<div class="vb-import" id="vb-import" hidden></div>' : ''}
${MARK}
${MARK}
-<h1>${user ? `What can I help with${user.display_name || user.username ? `, ${esc(String(user.display_name || user.username).split(' ')[0])}` : ''}?` : 'Hi, I\'m Vibe. What can I help with?'}</h1>
-<p class="vb-sub">OpenVibe's assistant. I search the web and all of OpenVibe (news, deals, jobs, rentals, videos), read the pages and pictures you share, remember what you ask me to, and set up work that runs every day for you.</p>
-<ul class="vb-suggest">${SUGGESTIONS.map((s) => `<li><button type="button" class="vb-chip" data-prompt="${esc(s.prompt)}"><b>${esc(s.title)}</b><span>${esc(s.text)}</span></button></li>`).join('')}</ul>
+<h1>Ask anything</h1>
+<p class="vb-sub">One model, or up to three side by side</p>
+<ul class="vb-suggest">${SUGGESTIONS.map((s) => `<li><button type="button" class="vb-chip" data-prompt="${esc(s.prompt)}"><b>${esc(s.title)}</b></button></li>`).join('')}</ul>
</div>`;
</div>`;
const head = thread.head({
const head = thread.head({
title: room ? title : '', sidebar: 'vb-rail', sidebarLabel: 'Show your chats',
title: room ? title : '', sidebar: 'vb-rail', sidebarLabel: 'Show your chats',
@@ -285,7 +292,7 @@ ${MARK}
@@ -285,7 +292,7 @@ ${MARK}
head,
head,
turns: problem && room ? [thread.turn({ avatar: false, reply: problem }), ...turns] : turns,
turns: problem && room ? [thread.turn({ avatar: false, reply: problem }), ...turns] : turns,
empty: problem + hello,
empty: problem + hello,
- composer: composer({ slug: room && room.slug, signedIn: !!user, enabled, prefill: room ? '' : prefillOf(req.query || {}), engine: allowance && allowance.engine }),
+ composer: composer({ slug: room && room.slug, signedIn: !!user, enabled, prefill: room ? '' : prefillOf(req.query || {}), engine: allowance && allowance.engine, models: modelSettings.models }),
center: !room, label: room ? 'Your chat with Vibe' : 'A new chat with Vibe',
center: !room, label: room ? 'Your chat with Vibe' : 'A new chat with Vibe',
})}</div>`;
})}</div>`;
await page(req, res, status, {
await page(req, res, status, {
@@ -297,7 +304,8 @@ ${MARK}
@@ -297,7 +304,8 @@ ${MARK}
body,
body,
page: {
page: {
view: 'vibe', room: room ? room.slug : null, me: user ? user.id : null, latest: messages.length ? messages[messages.length - 1].id : 0,
view: 'vibe', room: room ? room.slug : null, me: user ? user.id : null, latest: messages.length ? messages[messages.length - 1].id : 0,
- waiting: !!waiting, enabled, signedIn: !!user,
+ waiting: !!waiting, enabled, signedIn: !!user, models: modelSettings.models,
+ system_prompt: modelSettings.system_prompt, temperature: modelSettings.temperature,
canvas: canvas ? { thread: canvas.thread, task: canvas.id, status: canvas.status } : null,
canvas: canvas ? { thread: canvas.thread, task: canvas.id, status: canvas.status } : null,
},
},
});
});
@@ -36,7 +36,7 @@ t('export, then erase and confirm', async () => {
@@ -36,7 +36,7 @@ t('export, then erase and confirm', async () => {
taskKeyed: ['calls', 'channel_moderators', 'channel_sounds', 'chat_app_callbacks', 'chat_app_link_tokens', 'chat_messages', 'chat_server_invites', 'chat_server_members',
taskKeyed: ['calls', 'channel_moderators', 'channel_sounds', 'chat_app_callbacks', 'chat_app_link_tokens', 'chat_messages', 'chat_server_invites', 'chat_server_members',
'ctx_channels', 'ctx_managed_streams', 'ctx_streams', 'dm_conversations', 'dm_participants', 'emotes', 'inbox_actions',
'ctx_channels', 'ctx_managed_streams', 'ctx_streams', 'dm_conversations', 'dm_participants', 'emotes', 'inbox_actions',
'inbox_members', 'inbox_saved_replies', 'inbox_webhooks', 'pending_ip_messages', 'room_hooks', 'room_members', 'room_messages', 'agent_room_poll_votes', 'room_message_reactions',
'inbox_members', 'inbox_saved_replies', 'inbox_webhooks', 'pending_ip_messages', 'room_hooks', 'room_members', 'room_messages', 'agent_room_poll_votes', 'room_message_reactions',
- 'user_tags', 'vibe_asks', 'vibe_deliveries', 'vibe_memory', 'vibe_memory_off', 'vibe_own_engine', 'vibe_usage'],
+ 'user_tags', 'vibe_asks', 'vibe_deliveries', 'vibe_memory', 'vibe_memory_off', 'vibe_own_engine', 'vibe_usage', 'vibe_model_preferences', 'vibe_import_notices'],
exempt: {
exempt: {
account_data_events: 'the ledger of requests itself: which export and deletion Chat applied',
account_data_events: 'the ledger of requests itself: which export and deletion Chat applied',
config_snapshots: 'D11 owner settings: created_by/activated_by is the acting administrator (usr_...) for the config audit trail; the values are network configuration, not the person\'s account data, and are kept for rollback/history',
config_snapshots: 'D11 owner settings: created_by/activated_by is the acting administrator (usr_...) for the config audit trail; the values are network configuration, not the person\'s account data, and are kept for rollback/history',
@@ -22,6 +22,7 @@ async function get(sql, params) { return await db.prepare(sql).get(...(params ||
@@ -22,6 +22,7 @@ async function get(sql, params) { return await db.prepare(sql).get(...(params ||
// Every table Chat owns, including the Chat ingress retry ledger and runtime-created modules.
// Every table Chat owns, including the Chat ingress retry ledger and runtime-created modules.
const TABLES = [
const TABLES = [
+ 'vibe_model_turns', 'vibe_chat_settings', 'vibe_model_preferences', 'vibe_import_notices', // 0048: multi-model Vibe and the Exchange import
'chat_messages', 'dm_conversations', 'dm_participants', 'dm_messages', 'dm_blocks', 'tts_voice_overrides',
'chat_messages', 'dm_conversations', 'dm_participants', 'dm_messages', 'dm_blocks', 'tts_voice_overrides',
'channel_sounds', 'relay_users', 'hidden_relay_users', 'pending_ip_messages', 'stream_first_chats', 'moderation_actions',
'channel_sounds', 'relay_users', 'hidden_relay_users', 'pending_ip_messages', 'stream_first_chats', 'moderation_actions',
'channel_moderators', 'channel_moderation_settings', 'emotes', 'user_tags', 'chat_ai_summaries', 'chat_timeline_events',
'channel_moderators', 'channel_moderation_settings', 'emotes', 'user_tags', 'chat_ai_summaries', 'chat_timeline_events',
@@ -113,6 +113,9 @@ t('DNS rebinding: public at the check, loopback at the download — refused wher
@@ -113,6 +113,9 @@ t('DNS rebinding: public at the check, loopback at the download — refused wher
t('ratchet: every file that makes an outbound request itself is reviewed', () => {
t('ratchet: every file that makes an outbound request itself is reviewed', () => {
const REVIEWED = {
const REVIEWED = {
+ 'server/ai/client.js': 'AI internal API (configured), fixed paths: runs and the Exchange catalogue',
+ 'server/rooms/vibe-import.js': 'Exchange internal API (configured), a fixed path: the signed-in person\'s chat export',
+ 'server/rooms/vibe-compare.js': 'Bench (configured), fixed paths: the compare round and its vote',
'server/net/service-auth.js': 'Network JWKS (configured)',
'server/net/service-auth.js': 'Network JWKS (configured)',
'server/chat/account-data.js': 'Network internal API (configured), fixed paths: export parts and deletion confirmations',
'server/chat/account-data.js': 'Network internal API (configured), fixed paths: export parts and deletion confirmations',
'server/prefs/network-modules.js': 'Network user modules (configured)',
'server/prefs/network-modules.js': 'Network user modules (configured)',
@@ -67,6 +67,85 @@ t('a chat is its person\'s alone, and an empty message is refused', async () =>
@@ -67,6 +67,85 @@ t('a chat is its person\'s alone, and an empty message is refused', async () =>
assert.strictEqual((await h.http('POST', '/api/vibe/ask', { token: ann.token, body: { message: ' ' } })).status, 422);
assert.strictEqual((await h.http('POST', '/api/vibe/ask', { token: ann.token, body: { message: ' ' } })).status, 422);
});
});
-t('done', async () => { aiClient._setClient(null); await h.close(); });
+t('two and three explicit models run in parallel, keep receipts, and isolate a failure', async () => {
+ require('../server/net/actor-limits').limits.reset();
+ const seen = [];
+ aiClient._setComplete(async ({ model, subject }) => {
+ seen.push({ model, subject, at: Date.now() });
+ await wait(40);
+ if (model.includes('broken')) throw new Error('provider unavailable');
+ return { text: `From ${model}`, provider: 'test', cost_usd: 0.001, ms: 40, run_id: `run_${model}` };
+ });
+ const models = ['maker/one', 'maker/two'];
+ const first = await h.http('POST', '/api/vibe/ask', { token: ann.token, body: { message: 'Compare this', models, fresh: true } });
+ assert.strictEqual(first.status, 201, first.text);
+ const slug = first.body.chat.slug;
+ let group;
+ for (let i = 0; i < 40; i++) {
+ const chat = await h.http('GET', `/api/vibe/chat?slug=${slug}`, { token: ann.token });
+ group = chat.body.messages.find((m) => m.id === first.body.message.id);
+ if (group && group.answers && group.answers.every((a) => a.status !== 'writing')) break;
+ await wait(50);
+ }
+ assert.deepStrictEqual(group.answers.map((a) => a.status), ['finished', 'finished']);
+ assert.ok(group.answers.every((a) => a.provider === 'test' && a.cost_usd === 0.001 && a.ms === 40 && a.run_id));
+ assert.ok(Math.abs(seen[0].at - seen[1].at) < 30, 'the calls start together');
+ assert.ok(seen.every((x) => x.subject === ann.subject_id));
+ const compare = require('../server/rooms/vibe-compare');
+ const benchCalls = [];
+ compare._setBench({ createRound: async (body) => {
+ benchCalls.push({ kind: 'round', body });
+ return { round: { id: 'brd_TEST' }, entries: [{ id: 'bte_LEFT' }, { id: 'bte_RIGHT' }] };
+ }, vote: async (body) => { benchCalls.push({ kind: 'vote', body }); return { judgement: { id: 'bju_TEST' } }; } });
+ const preferred = await h.http('POST', '/api/vibe/prefer', { token: ann.token, body: { message_id: first.body.message.id, model: 'maker/two' } });
+ assert.strictEqual(preferred.status, 200, preferred.text);
+ assert.deepStrictEqual(benchCalls.map((c) => c.kind), ['round', 'vote']);
+ assert.strictEqual(benchCalls[0].body.source, 'chat');
+ assert.strictEqual(benchCalls[1].body.subject, ann.subject_id);
+ assert.strictEqual((await h.http('POST', '/api/vibe/prefer', { token: ann.token, body: { message_id: first.body.message.id, model: 'maker/two' } })).status, 409);
+ compare._setBench(null);
+ const picked = await h.http('POST', '/api/vibe/pick', { token: ann.token, body: { message_id: first.body.message.id, model: 'maker/two' } });
+ assert.strictEqual(picked.status, 200, picked.text);
+ const next = await h.http('POST', '/api/vibe/ask', { token: ann.token, body: { message: 'Three now', slug, models: ['maker/one', 'maker/two', 'maker/broken'] } });
+ assert.strictEqual(next.status, 201, next.text);
+ let answers;
+ for (let i = 0; i < 40; i++) {
+ const chat = await h.http('GET', `/api/vibe/chat?slug=${slug}`, { token: ann.token });
+ answers = chat.body.messages.find((m) => m.id === next.body.message.id).answers;
+ if (answers.every((a) => a.status !== 'writing')) break;
+ await wait(50);
+ }
+ assert.strictEqual(answers.filter((a) => a.status === 'finished').length, 2);
+ assert.strictEqual(answers.filter((a) => a.status === 'failed').length, 1);
+ assert.strictEqual((await h.http('POST', '/api/vibe/pick', { token: bob.token, body: { message_id: first.body.message.id, model: 'maker/one' } })).status, 404);
+ assert.strictEqual((await h.http('POST', '/api/vibe/ask', { token: ann.token, body: { message: 'too many', fresh: true, models: ['a/one', 'b/two', 'c/three', 'd/four'] } })).status, 422);
+});
+
+t('Exchange import is scoped to the person and repeating it makes no second chat', async () => {
+ require('../server/net/actor-limits').limits.reset();
+ const importer = require('../server/rooms/vibe-import');
+ const dates = ['2026-10-01T10:00:00.000Z', '2026-10-01T10:01:00.000Z'];
+ importer._setExport(async (subject) => ({ chats: [{ id: subject === ann.subject_id ? 'exc_ANN1' : 'exc_BOB1',
+ title: subject === ann.subject_id ? 'Alice old chat' : 'Bob old chat', models: ['maker/one'], created_at: dates[0], updated_at: dates[1],
+ messages: [{ role: 'user', content: 'Old question', created_at: dates[0] },
+ { role: 'assistant', model: 'maker/one', content: 'Old answer', created_at: dates[1] }] }] }));
+ const status = await h.http('GET', '/api/vibe/import', { token: ann.token });
+ assert.deepStrictEqual(status.body, { count: 1, show: true });
+ const first = await h.http('POST', '/api/vibe/import', { token: ann.token, body: {} });
+ assert.strictEqual(first.status, 200, first.text);
+ assert.strictEqual(first.body.imported, 1);
+ const again = await h.http('POST', '/api/vibe/import', { token: ann.token, body: {} });
+ assert.strictEqual(again.body.imported, 0);
+ const bobImport = await h.http('POST', '/api/vibe/import', { token: bob.token, body: {} });
+ assert.strictEqual(bobImport.body.imported, 1);
+ const aliceRoom = await h.db.get("SELECT id, name, created_at FROM rooms WHERE owner_id = ? AND name = 'Alice old chat'", [ann.id]);
+ assert.ok(aliceRoom && String(aliceRoom.created_at).startsWith('2026-10-01'));
+ const messages = await h.db.all('SELECT message FROM room_messages WHERE room_id = ? ORDER BY id', [aliceRoom.id]);
+ assert.deepStrictEqual(messages.map((m) => m.message), ['Old question', 'Old answer']);
+ assert.strictEqual(await h.db.get("SELECT id FROM rooms WHERE owner_id = ? AND name = 'Bob old chat'", [ann.id]), undefined);
+ importer._setExport(null);
+});
+
+t('done', async () => { aiClient._setComplete(null); aiClient._setClient(null); await h.close(); });
t.run();
t.run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,34 @@
@@ -0,0 +1,34 @@
+'use strict';
+/** A phone can choose three models without a sideways page or a fourth selection. */
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const { ids } = require('openvibe-contracts');
+const { boot, suite } = require('./helpers');
+let chromium;
+try { ({ chromium } = require('/home/workstation/openvibe/work/OpenVibe.Games/node_modules/playwright')); } catch { /* optional browser harness */ }
+const t = suite('vibe compare Chrome');
+if (!chromium || !fs.existsSync('/usr/bin/google-chrome')) { console.log('vibe compare Chrome: skipped (Chrome unavailable)'); process.exit(0); }
+let h, browser, user;
+t('boot', async () => {
+ h = await boot({ env: { CHAT_WEB_URL: 'https://openvibe.chat' } });
+ user = h.addUser('browser', { subject: ids.newId('user') });
+ await h.ctx.upsertUser(h.live.users.get(user.id));
+ browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome' });
+});
+t('the sheet caps Compare at three and a 390 px page does not scroll sideways', async () => {
+ const page = await browser.newPage({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
+ await page.context().addCookies([{ name: 'ov_token', value: user.token, url: h.base }]);
+ await page.route('**/api/vibe/models', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ models: [
+ { slug: 'maker/one', name: 'One', family: 'Maker' }, { slug: 'maker/two', name: 'Two', family: 'Maker' },
+ { slug: 'maker/three', name: 'Three', family: 'Maker' }, { slug: 'maker/four', name: 'Four', family: 'Maker' },
+ ] }) }));
+ await page.goto(h.base + '/vibe', { waitUntil: 'load' });
+ await page.locator('#vb-model-chip').click();
+ await page.locator('#vb-model-compare').check();
+ for (const name of ['One', 'Two', 'Three', 'Four']) await page.getByRole('button', { name, exact: true }).click();
+ assert.equal(await page.locator('#vb-model-chip').innerText(), '3 models ▾');
+ assert.equal(await page.evaluate(() => document.documentElement.scrollWidth), 390);
+ await page.close();
+});
+t('done', async () => { await browser.close(); await h.close(); });
+t.run();
@@ -142,11 +142,11 @@ t('rename, then delete: the chat and its messages go, the day\'s count stays', a
@@ -142,11 +142,11 @@ t('rename, then delete: the chat and its messages go, the day\'s count stays', a
t('the app page: signed out it invites a sign-in, signed in it lists the chats and starts one from a form', async () => {
t('the app page: signed out it invites a sign-in, signed in it lists the chats and starts one from a form', async () => {
const out = await web('GET', '/vibe');
const out = await web('GET', '/vibe');
assert.strictEqual(out.status, 200);
assert.strictEqual(out.status, 200);
- assert.ok(out.text.includes('Hi, I'm Vibe. What can I help with?') || out.text.includes('Hi, I\'m Vibe. What can I help with?'), 'the greeting');
+ assert.ok(out.text.includes('Ask anything'), 'the greeting');
assert.ok(out.text.includes('data-signin="1"') && out.text.includes('Sign in to keep your chats'));
assert.ok(out.text.includes('data-signin="1"') && out.text.includes('Sign in to keep your chats'));
assert.ok(!out.text.includes('id="ov-footer"'), 'the app has no footer');
assert.ok(!out.text.includes('id="ov-footer"'), 'the app has no footer');
const signed = await web('GET', '/vibe', ann);
const signed = await web('GET', '/vibe', ann);
- assert.ok(signed.text.includes('What can I help with, Ann?'));
+ assert.ok(signed.text.includes('One model, or up to three side by side'));
assert.strictEqual((signed.text.match(/<h1[\s>]/g) || []).length, 1, 'one h1 on a new chat: the greeting (the header names it in a <p>)');
assert.strictEqual((signed.text.match(/<h1[\s>]/g) || []).length, 1, 'one h1 on a new chat: the greeting (the header names it in a <p>)');
assert.ok(signed.text.includes('class="ovk-sidebar-item" href="/vibe/v-'), 'the rail lists their chats (the network\'s one sidebar, kit.sidebar)');
assert.ok(signed.text.includes('class="ovk-sidebar-item" href="/vibe/v-'), 'the rail lists their chats (the network\'s one sidebar, kit.sidebar)');
// The rail leads to the rest of the person's agent: their tasks, their computers and robots, team workspaces.
// The rail leads to the rest of the person's agent: their tasks, their computers and robots, team workspaces.