openvibers/openvibe.chat Commits
Support inboxes: translation both ways, and pictures from the team (#152)
Changes
@@@ -469,6 +469,16 @@ for people and for companies, with "direct live support with an agent", "fully mwaiting (once a conversation every 10 minutes) or rates. Delivery tries three times (now, 30 s, 5 min); eachwebhook shows its last answer, and 20 failures in a row switch it off. `GET/POST /api/chat/inboxes/:id/webhooks{ url }`, `POST /:id/webhooks/:hookId/test`, `DELETE /:id/webhooks/:hookId`.++- **Pictures:** one of the inbox's people sends a picture from the reply box (`POST++ /api/chat/inboxes/:id/conversations/:cid/picture`, the body is the picture, a line in `X-Caption`); it goes to++ OpenVibe.Pics as them, like a room's picture, and the message carries its link, which the visitor's panel and the++ inbox show inline (only openvibe.pics, at most four a message). Visitors cannot upload yet: anonymous uploads wait++ for picture scanning.++- **Translation** (`server/inbox/translate.js`): "Translate" under a visitor's message puts it in the reader's++ language (`POST /api/chat/inboxes/:id/conversations/:cid/messages/:mid/translate { to }`, kept per message and++ language, so the next reader pays nothing), and "Translate my reply into…" turns the draft into the visitor's++ (`POST /:id/translate { text, to }`). OpenVibe.AI translates; `CHAT_INBOX_TRANSLATIONS_PER_DAY` (300) new ones an++ inbox a UTC day, 30 a minute a person.- **Saved replies:** the team's ready answers (`GET/POST /api/chat/inboxes/:id/replies { shortcut, body }`, `DELETE/:id/replies/:replyId`, or the settings page); typing `/` in the reply box lists them by shortcut (arrows, Enter orTab). **Vibe knows the page** the visitor wrote from, and a verified visitor's name.nnew file mode 100644@@@ -0,0 +1,11 @@++-- phase: expand++-- Translation for an inbox's people (server/inbox/translate.js): a message's translation into a language is kept, so a++-- second reader of it pays nothing; translations are counted against the inbox's day like Vibe's answers.++CREATE TABLE IF NOT EXISTS inbox_translations (++ message_id bigint NOT NULL,++ lang text COLLATE "C" NOT NULL,++ text text NOT NULL,++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),++ PRIMARY KEY (message_id, lang)++);++ALTER TABLE inbox_ai_usage ADD COLUMN IF NOT EXISTS translations integer NOT NULL DEFAULT 0;@@@ -67,3 +67,6 @@ a { color: var(--accent); }.re-signin:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }.re-signin[disabled] { opacity: .65; cursor: default; }.em-head a.em-x { color: inherit; text-decoration: none; }++.em-pics { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }++.em-pic { display: block; line-height: 0; border-radius: 12px; }++.em-pic img { display: block; max-width: min(240px, 100%); max-height: 200px; border-radius: 12px; border: 1px solid var(--line); object-fit: cover; }@@@ -19,6 +19,19 @@var token = null, conv = null, latest = 0, ws = null, delay = 1000, aiOn = root.getAttribute('data-ai') === '1', typingEl = null;var people = root.getAttribute('data-people') !== '0', rateEl = null;++ // A person's reply may hold OpenVibe.Pics pictures (sent from the inbox): shown inline, only that host, at most four.++ var picsBase = root.getAttribute('data-pics') || 'https://openvibe.pics';++ var PICS = new RegExp(picsBase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '/i/([2-9a-km-zA-HJ-NP-Z]{8})\\.(?:jpg|png|gif|webp|avif)(?![\\w/.-])', 'g');++ function picsOf(text) {++ var box = null, m, n = 0; PICS.lastIndex = 0;++ while ((m = PICS.exec(String(text || ''))) && n < 4) {++ if (!box) box = el('div', 'em-pics');++ var a = el('a', 'em-pic'); a.href = picsBase + '/i/' + m[1]; a.target = '_blank'; a.rel = 'noopener';++ var img = el('img'); img.src = picsBase + '/i/' + m[1] + '/thumb.webp'; img.alt = 'A picture'; img.loading = 'lazy';++ a.appendChild(img); box.appendChild(a); n++;++ }++ return box;++ }function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }function store(fn) { try { return fn(window.localStorage); } catch (e) { return null; } }function toParent(msg) { try { window.parent.postMessage(Object.assign({ ov: 'chat' }, msg), '*'); } catch (e) { /* not embedded */ } }@@@ -82,6 +95,7 @@var b = el('div', 'em-bubble');if (m.author === 'ai' && window.VibeMarkdown) b.innerHTML = window.VibeMarkdown.render(m.body); else b.textContent = m.body;li.appendChild(b);++ if (m.author === 'agent') { var pics = picsOf(m.body); if (pics) li.appendChild(pics); }if (!mine) { clearTyping(); toParent({ type: 'message', author: m.author }); }}if (m.id) li.dataset.id = String(m.id);@@@ -18,6 +18,18 @@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 chip(s) { var c = el('span', 'vb-st ' + (STATUS[s] ? STATUS[s][1] : ''), STATUS[s] ? STATUS[s][0] : s); return c; }++ // OpenVibe.Pics direct links in a person's reply show as pictures (only that host, at most four), like in rooms.++ var PICS = /https:\/\/openvibe\.pics\/i\/([2-9a-km-zA-HJ-NP-Z]{8})\.(?:jpg|png|gif|webp|avif)(?![\w\/.-])/g;++ function picsOf(text) {++ var box = null, m, n = 0; PICS.lastIndex = 0;++ while ((m = PICS.exec(String(text || ''))) && n < 4) {++ if (!box) box = el('span', 'oc-pics');++ var a = el('a', 'oc-pic'); a.href = 'https://openvibe.pics/i/' + m[1]; a.target = '_blank'; a.rel = 'noopener';++ var img = el('img'); img.src = 'https://openvibe.pics/i/' + m[1] + '/thumb.webp'; img.alt = 'A picture on OpenVibe.Pics'; img.loading = 'lazy';++ a.appendChild(img); box.appendChild(a); n++;++ }++ return box;++ }function plain(t) { return String(t || '').replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/[*_`~]+|^#+\s*|^>\s*/gm, '').replace(/\s+/g, ' ').trim(); }function bottom() { if (scroll) scroll.scrollTop = scroll.scrollHeight; }function clearTyping() { if (typingEl) { typingEl.remove(); typingEl = null; } }@@@ -36,7 +48,9 @@b.appendChild(el('p', 'ib-who', m.author === 'ai' ? 'Vibe (AI)' : (m.author_name || 'Agent')));var text = el('div', 'vb-md');if (m.author === 'ai' && window.VibeMarkdown) text.innerHTML = window.VibeMarkdown.render(m.body); else text.textContent = m.body;-- b.appendChild(text); li.appendChild(b);++ b.appendChild(text);++ var pics = m.author === 'agent' ? picsOf(m.body) : null; if (pics) b.appendChild(pics);++ li.appendChild(b);}li.dataset.id = String(m.id);return li;@@@ -47,7 +61,7 @@if (m.author === 'agent') { var p = thread.querySelector('.vb-me:not([data-id])'); if (p) { p.dataset.id = String(m.id); latest = Math.max(latest, Number(m.id)); return; } }clearTyping();var stick = scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight < 160;-- thread.appendChild(turn(m));++ var li = turn(m); thread.appendChild(li); addTranslate(li);latest = Math.max(latest, Number(m.id));if (stick) bottom();}@@@ -97,6 +111,56 @@ws.onclose = function () { setTimeout(connect, delay); delay = Math.min(delay * 2, 30000); };}++ // Translation: a visitor's message into the reader's own language, and the reply draft into the visitor's.++ var myLang = String((navigator.language || 'en')).toLowerCase();++ function post(path, body) {++ return fetch('/api/chat/inboxes/' + cfg.inbox + path, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })++ .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); });++ }++ function addTranslate(li) {++ if (!li || !li.classList.contains('ib-visitor') || !li.dataset.id || li.querySelector('.ib-tr-btn')) return;++ var body = li.querySelector('.vb-body'); if (!body) return;++ var b = el('button', 'ib-tr-btn', 'Translate'); b.type = 'button';++ b.addEventListener('click', function () {++ b.disabled = true; b.textContent = 'Translating…';++ post('/conversations/' + cfg.conversation + '/messages/' + li.dataset.id + '/translate', { to: myLang }).then(function (x) {++ if (!x.ok) { b.disabled = false; b.textContent = (x.d && x.d.error) || 'Try again'; return; }++ var p = el('p', 'ib-tr', x.d.text); p.lang = x.d.lang; b.replaceWith(p);++ }).catch(function () { b.disabled = false; b.textContent = 'Try again'; });++ });++ body.appendChild(b);++ }++ if (thread) Array.prototype.forEach.call(thread.querySelectorAll('.ib-visitor'), addTranslate);++ var picIn = document.getElementById('ib-pic');++ if (picIn && input) {++ picIn.addEventListener('change', function () {++ var f = picIn.files && picIn.files[0]; picIn.value = '';++ if (!f) return;++ var caption = input.value.trim();++ fetch('/api/chat/inboxes/' + cfg.inbox + '/conversations/' + cfg.conversation + '/picture', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': f.type, 'X-Caption': encodeURIComponent(caption) }, body: f })++ .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })++ .then(function (x) {++ if (!x.ok) { window.alert((x.d && x.d.error) || 'That picture did not send.'); return; }++ input.value = ''; input.dispatchEvent(new Event('input'));++ appendToThread(x.d.message); touchList(cfg.conversation, x.d.message.body, 'open');++ }).catch(function () { window.alert('You seem to be offline.'); });++ });++ }++ var trTo = document.getElementById('ib-tr-to');++ if (trTo && input) {++ trTo.closest('.ib-tr-pick').hidden = false;++ trTo.addEventListener('change', function () {++ var to = trTo.value, text = input.value.trim(); trTo.value = '';++ if (!to || !text) return;++ var was = input.value; input.disabled = true;++ post('/translate', { text: text, to: to }).then(function (x) {++ input.disabled = false;++ if (!x.ok) { window.alert((x.d && x.d.error) || 'That did not translate.'); return; }++ input.value = x.d.text; input.dataset.original = was; input.dispatchEvent(new Event('input')); input.focus();++ }).catch(function () { input.disabled = false; });++ });++ }++// Saved replies: "/" at the start of the reply box lists them by shortcut; Enter, Tab or a click puts one in.var saved = Array.isArray(cfg.saved) ? cfg.saved : [], pick = null, picked = 0;function closePick() { if (pick) { pick.remove(); pick = null; } input && input.removeAttribute('aria-activedescendant'); }@@@ -218,6 +218,14 @@ body.oc-app-body { overflow: hidden; }.ib-row .vb-st { margin-left: 4px; }.ib-rate { font-size: .85rem; line-height: 1; }.ib-ok { color: var(--oc-ok, #22c55e); font-weight: 800; margin-left: 4px; font-size: .9em; }++.ib-pic-btn { display: grid; place-items: center; width: 36px; height: 36px; border-radius: 50%; cursor: pointer; color: var(--oc-muted); flex: none; font-size: 1.1rem; }++.ib-pic-btn:hover { color: var(--oc-accent); background: color-mix(in srgb, var(--oc-accent) 12%, transparent); }++.ib-pic-btn:focus-within { outline: 2px solid var(--oc-accent); }++.ib-tr-btn { all: unset; cursor: pointer; font-size: .78rem; color: var(--oc-muted); margin-top: 2px; border-radius: 6px; padding: 1px 4px; }++.ib-tr-btn:hover { color: var(--oc-accent); }++.ib-tr-btn:focus-visible { outline: 2px solid var(--oc-accent); }++.ib-tr { margin: 4px 0 0; padding-left: 10px; border-left: 2px solid var(--oc-line); color: var(--oc-muted); }++.ib-tr-pick select { font-size: .8rem; padding: 2px 6px; margin-left: 4px; }.ib-pick { list-style: none; margin: 0 0 6px; padding: 4px; border: 1px solid var(--oc-line); border-radius: 12px; background: var(--oc-panel); max-height: 260px; overflow-y: auto; }.ib-pick li { display: flex; gap: 8px; align-items: baseline; padding: 6px 10px; border-radius: 8px; cursor: pointer; min-width: 0; }.ib-pick li span { color: var(--oc-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }@@@ -107,6 +107,7 @@ const ERASE_AFTER_ROOMS = [['vibe_usage', 'DELETE FROM vibe_usage WHERE user_id = ANY(@ids)'],// Support inboxes (server/inbox/inbox.js): the inboxes they own go with every conversation in them; in others,// their replies stay for the visitor without their name, and their place on the team goes.++ [null, 'DELETE FROM inbox_translations WHERE message_id IN (SELECT m.id FROM inbox_messages m JOIN inbox_conversations c ON c.id = m.conversation_id JOIN inboxes i ON i.id = c.inbox_id WHERE i.owner_subject = ANY(@s) OR i.owner_id = ANY(@ids))'],['inbox_messages', 'DELETE FROM inbox_messages WHERE conversation_id IN (SELECT c.id FROM inbox_conversations c JOIN inboxes i ON i.id = c.inbox_id WHERE i.owner_subject = ANY(@s) OR i.owner_id = ANY(@ids))'],['inbox_conversations', 'DELETE FROM inbox_conversations WHERE inbox_id IN (SELECT id FROM inboxes WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids))'],['inbox_members', 'DELETE FROM inbox_members WHERE user_id = ANY(@ids) OR inbox_id IN (SELECT id FROM inboxes WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids))'],@@@ -76,7 +76,8 @@ module.exports = {// A verified visitor's token is derived with CHAT_INBOX_IDENTITY_KEY (else Chat's OAuth secret): rotating it moves// verified visitors to new conversations.inbox: { aiRepliesPerDay: Math.max(1, int(process.env.CHAT_INBOX_AI_REPLIES_PER_DAY, 500)), notify: process.env.CHAT_INBOX_NOTIFY !== '0', identityKey: process.env.CHAT_INBOX_IDENTITY_KEY || '',-- postsPerMinute: Math.max(1, int(process.env.CHAT_INBOX_POSTS_PER_MINUTE, 20)) }, // a visitor's address, widget writes++ postsPerMinute: Math.max(1, int(process.env.CHAT_INBOX_POSTS_PER_MINUTE, 20)), // a visitor's address, widget writes++ translationsPerDay: Math.max(0, int(process.env.CHAT_INBOX_TRANSLATIONS_PER_DAY, 300)) }, // new translations an inbox a UTC day// OpenVibe.Actor, for Vibe's web search (server/rooms/web-search.js): tasks for the person, with Chat's own token.actor: { internalUrl: strip(process.env.CHAT_ACTOR_INTERNAL_URL || 'http://127.0.0.1:4950') },@@@ -19,6 +19,9 @@* POST /:id/members { username } (owner) DELETE /:id/members/:userId (owner)* POST /:id/identity { action: on | rotate | off } (owner) → { identity_secret }: verified visitors* GET /:id/replies { replies }: saved replies POST /:id/replies { shortcut, body } DELETE /:id/replies/:replyId++ * POST /:id/conversations/:cid/messages/:mid/translate { to } → { text, lang, cached }: a message in the reader's language++ * POST /:id/translate { text, to } → { text, lang }: a reply draft in the visitor's language++ * POST /:id/conversations/:cid/picture the body is a picture, an optional line in X-Caption → 201 { message, picture }* GET /:id/webhooks (owner) { webhooks } POST /:id/webhooks { url } → 201 { webhook, secret }* POST /:id/webhooks/:hookId/test → { ok, status, error } DELETE /:id/webhooks/:hookId* GET /:id/conversations ?status=active|bot|waiting|open|resolved@@@ -35,9 +38,10 @@ const { requireAuth } = require('../auth/auth');const { limits } = require('../net/actor-limits');const inboxes = require('./inbox');const webhooks = require('./webhooks');++const translate = require('./translate');function send(res, err) {-- if (err instanceof inboxes.InboxError || err instanceof webhooks.WebhookError) return res.status(err.status).json({ error: err.message, code: err.code });++ if (err instanceof inboxes.InboxError || err instanceof webhooks.WebhookError || err instanceof translate.TranslateError) return res.status(err.status).json({ error: err.message, code: err.code });console.error('[Inbox]', err && err.message);return res.status(500).json({ error: 'Something went wrong' });}@@@ -144,6 +148,32 @@ agents.delete('/:id/replies/:replyId', requireAuth, manageLimit, async (req, resconst inbox = await agentInbox(req, res); if (!inbox) return;try { res.json({ replies: await inboxes.deleteReply(inbox, req.user, req.params.replyId) }); } catch (err) { send(res, err); }});++// A picture from one of the inbox's people: it goes to OpenVibe.Pics as them (their own sign-in, like a room's picture),++// and the conversation gets a message with its link, which the visitor's panel shows inline.++const bearerOf = (req) => { const m = /^Bearer\s+(.+)$/i.exec(String(req.get('authorization') || '')); return m ? m[1].trim() : (req.cookies && req.cookies.ov_token) || null; };++const pictureLimit = rateLimit({ windowMs: 60_000, max: 10, standardHeaders: true, legacyHeaders: false, message: { error: 'Slow down a little', code: 'inbox.slow_down' } });++agents.post('/:id/conversations/:cid/picture', requireAuth, pictureLimit, (req, res, next) => {++ if (req.authSource === 'api_token') return res.status(403).json({ error: 'Send pictures with your own sign-in', code: 'inbox.person_only' });++ return express.raw({ type: () => true, limit: (config.pics.maxBytes || 20 * 1024 * 1024) + 1 })(req, res, (err) => {++ if (err && err.type === 'entity.too.large') return res.status(413).json({ error: 'That picture is too large', code: 'pictures.too_large' });++ return next(err);++ });++}, async (req, res) => {++ const conv = await agentConversation(req, res); if (!conv) return;++ const caption = (() => { try { return decodeURIComponent(String(req.get('x-caption') || '')).slice(0, 500); } catch { return ''; } })();++ const out = await require('../rooms/pictures').upload({ token: bearerOf(req), buffer: Buffer.isBuffer(req.body) ? req.body : null, contentType: req.get('content-type'), title: caption, ip: req.ip });++ if (!out.ok) return res.status(out.status || 502).json({ error: out.detail || 'That picture did not send', code: out.code || 'pictures.failed' });++ try { res.status(201).json({ message: await inboxes.agentPost(req.inbox, conv, req.user, `${caption ? `${caption}\n` : ''}${out.picture.direct}`), picture: out.picture }); } catch (err) { send(res, err); }++});++const translateLimit = rateLimit({ windowMs: 60_000, max: 30, standardHeaders: true, legacyHeaders: false, message: { error: 'Slow down a little', code: 'inbox.slow_down' } });++agents.post('/:id/conversations/:cid/messages/:mid/translate', requireAuth, translateLimit, async (req, res) => {++ const conv = await agentConversation(req, res); if (!conv) return;++ try { noStore(res).json(await translate.message(req.inbox, conv, req.params.mid, req.body && req.body.to)); } catch (err) { send(res, err); }++});++agents.post('/:id/translate', requireAuth, translateLimit, async (req, res) => {++ const inbox = await agentInbox(req, res); if (!inbox) return;++ try { noStore(res).json(await translate.draft(inbox, req.body && req.body.text, req.body && req.body.to)); } catch (err) { send(res, err); }++});agents.get('/:id/webhooks', requireAuth, async (req, res) => {const inbox = await agentInbox(req, res, { owner: true }); if (!inbox) return;noStore(res).json({ webhooks: await webhooks.list(inbox) });nnew file mode 100644@@@ -0,0 +1,69 @@++'use strict';++/**++ * Translation for an inbox's people (server/inbox/inbox.js): a visitor's message in their reader's language, on request,++ * and a reply draft in the visitor's. OpenVibe.AI translates (chat.member.turn, Chat's token); each message's++ * translation into a language is kept (inbox_translations), so a second reader pays nothing. At most++ * CHAT_INBOX_TRANSLATIONS_PER_DAY (300) new translations an inbox a UTC day.++ */++const db = require('../db/database');++const config = require('../config');++++const LANG_RE = /^[a-z]{2,3}(?:-[A-Za-z]{2,4})?$/;++const NAMES = { en: 'English', es: 'Spanish', pt: 'Portuguese', fr: 'French', de: 'German', it: 'Italian', nl: 'Dutch', ja: 'Japanese', ko: 'Korean', zh: 'Chinese', ru: 'Russian', uk: 'Ukrainian', pl: 'Polish', tr: 'Turkish', ar: 'Arabic', hi: 'Hindi', id: 'Indonesian', vi: 'Vietnamese', th: 'Thai', sv: 'Swedish' };++const today = () => new Date().toISOString().slice(0, 10);++++class TranslateError extends Error { constructor(status, code, message) { super(message); this.status = status; this.code = code; } }++const fail = (status, code, message) => { throw new TranslateError(status, code, message); };++++function langOf(to) {++ const l = String(to || '').trim();++ if (!LANG_RE.test(l)) fail(422, 'inbox.lang', 'A language is a code like en or pt-BR');++ return l.toLowerCase();++}++const nameOf = (l) => NAMES[l.split('-')[0]] || l;++++async function spend(inboxId) {++ const used = Number((await db.get('SELECT translations FROM inbox_ai_usage WHERE inbox_id = ? AND day = ?', [inboxId, today()]) || {}).translations) || 0;++ if (used >= config.inbox.translationsPerDay) fail(429, 'inbox.translations', 'This inbox has translated a lot today; try again tomorrow');++ await db.run(`INSERT INTO inbox_ai_usage (inbox_id, day, replies, translations) VALUES (?, ?, 0, 1)++ ON CONFLICT (inbox_id, day) DO UPDATE SET translations = inbox_ai_usage.translations + 1`, [inboxId, today()]);++}++++async function ask(inbox, text, to, deps = {}) {++ const aiClient = deps.aiClient || require('../ai/client');++ const out = await aiClient.structured('chat.member.turn', {++ messages: [++ { role: 'system', content: `Translate the user's message into ${nameOf(to)}. Answer with the translation only: no quotes, no notes. Keep names, numbers, links and order numbers as they are. If it is already in ${nameOf(to)}, answer it unchanged.` },++ { role: 'user', content: String(text).slice(0, 4000) },++ ],++ max_tokens: 900, temperature: 0,++ }, { force: true, attribution: { service: 'chat', type: 'inbox-translation', id: inbox.id, label: inbox.name.slice(0, 200) } });++ const msg = out && Array.isArray(out.choices) && out.choices[0] && out.choices[0].message;++ const t = msg && typeof msg.content === 'string' ? msg.content.trim() : '';++ if (!t) fail(503, 'inbox.translate_unavailable', 'Translation is not available right now');++ return t.slice(0, 6000);++}++++/** A message of the conversation in `to`: { text, cached }. */++async function message(inbox, conv, messageId, to, deps = {}) {++ const lang = langOf(to);++ const m = await db.get('SELECT id, body FROM inbox_messages WHERE id = ? AND conversation_id = ?', [Number(messageId) || 0, conv.id]);++ if (!m) fail(404, 'inbox.no_message', 'No such message');++ const kept = await db.get('SELECT text FROM inbox_translations WHERE message_id = ? AND lang = ?', [m.id, lang]);++ if (kept) return { text: kept.text, lang, cached: true };++ await spend(inbox.id);++ const text = await ask(inbox, m.body, lang, deps);++ await db.run('INSERT INTO inbox_translations (message_id, lang, text) VALUES (?, ?, ?) ON CONFLICT (message_id, lang) DO NOTHING', [m.id, lang, text]);++ return { text, lang, cached: false };++}++++/** A reply draft in `to` (not kept): { text }. */++async function draft(inbox, text, to, deps = {}) {++ const lang = langOf(to);++ const body = String(text || '').trim().slice(0, 4000);++ if (!body) fail(422, 'inbox.empty', 'Write the reply first');++ await spend(inbox.id);++ return { text: await ask(inbox, body, lang, deps), lang };++}++++module.exports = { TranslateError, message, draft, langOf };@@@ -16,10 +16,12 @@ const path = require('path');const md = require('../../public/web/vibe-md.js');const inboxes = require('../inbox/inbox');const webhooks = require('../inbox/webhooks');++const pictures = require('../rooms/pictures');const { embedOf } = require('../inbox/routes');const { esc, asset } = require('./layout');const WIDGET = path.join(__dirname, '..', '..', 'public', 'web', 'widget.js');++const TR_LANGS = [['en', 'English'], ['es', 'Spanish'], ['pt', 'Portuguese'], ['fr', 'French'], ['de', 'German'], ['it', 'Italian'], ['nl', 'Dutch'], ['pl', 'Polish'], ['tr', 'Turkish'], ['ru', 'Russian'], ['uk', 'Ukrainian'], ['ar', 'Arabic'], ['hi', 'Hindi'], ['id', 'Indonesian'], ['vi', 'Vietnamese'], ['th', 'Thai'], ['ja', 'Japanese'], ['ko', 'Korean'], ['zh', 'Chinese'], ['sv', 'Swedish']];const STATUS = { bot: ['Vibe', 'vb-st-bot'], waiting: ['Waiting', 'vb-st-wait'], open: ['Open', 'vb-st-open'], resolved: ['Resolved', 'vb-st-done'] };const json = (v, d) => { if (Array.isArray(v)) return v; try { return JSON.parse(v || 'null') || d; } catch { return d; } };const ago = (ts) => {@@@ -42,7 +44,7 @@ function embedHtml(inbox) {<script src="${asset('embed.js')}" defer></script></head><body style="--accent:${esc(inbox.color)}">--<div class="em" id="em" data-inbox="${esc(inbox.id)}" data-ai="${Number(inbox.ai_enabled) ? '1' : '0'}" data-people="${inboxes.peopleAnswer(inbox) ? '1' : '0'}">++<div class="em" id="em" data-inbox="${esc(inbox.id)}" data-ai="${Number(inbox.ai_enabled) ? '1' : '0'}" data-people="${inboxes.peopleAnswer(inbox) ? '1' : '0'}" data-pics="${esc(pictures.publicUrl())}"><header class="em-head"><span class="em-mark" aria-hidden="true">${esc(inbox.name.slice(0, 1).toUpperCase())}</span><div><b>${esc(inbox.name)}</b><span id="em-state">${!Number(inbox.ai_enabled) ? 'We usually reply within a few hours.' : inboxes.peopleAnswer(inbox) ? 'Vibe answers right away. Ask for a person any time.' : 'Vibe, the assistant, answers right away.'}</span></div><button class="em-x" type="button" data-close aria-label="Close the chat">×</button></header>@@@ -130,7 +132,7 @@ ${list}const turn = (m) => {if (m.author === 'system') return `<li class="ib-sys" data-id="${m.id}">${esc(m.body)}</li>`;if (m.author === 'visitor') return `<li class="vb-turn vb-ai ib-visitor" data-id="${m.id}"><span class="ib-av" aria-hidden="true">${esc((current.visitor_name || 'V').slice(0, 1).toUpperCase())}</span><div class="vb-body"><p class="ib-who">${esc(current.visitor_name || 'Visitor')}</p><div class="vb-md">${esc(m.body).replace(/\n/g, '<br>')}</div></div></li>`;-- return `<li class="vb-turn vb-me" data-id="${m.id}"><div class="vb-bubble${m.author === 'ai' ? ' ib-ai' : ''}"><p class="ib-who">${m.author === 'ai' ? 'Vibe (AI)' : esc(m.author_name || 'Agent')}</p>${m.author === 'ai' ? `<div class="vb-md">${md.render(m.body)}</div>` : esc(m.body).replace(/\n/g, '<br>')}</div></li>`;++ return `<li class="vb-turn vb-me" data-id="${m.id}"><div class="vb-bubble${m.author === 'ai' ? ' ib-ai' : ''}"><p class="ib-who">${m.author === 'ai' ? 'Vibe (AI)' : esc(m.author_name || 'Agent')}</p>${m.author === 'ai' ? `<div class="vb-md">${md.render(m.body)}</div>` : esc(m.body).replace(/\n/g, '<br>')}${m.author === 'agent' ? pictures.pageHtml(m.body) : ''}</div></li>`;};const tabs = [['active', 'Active'], ['waiting', 'Waiting'], ['resolved', 'Resolved']].map(([k, l]) => `<a href="/inbox/${esc(inbox.id)}?f=${k}"${k === filter ? ' aria-current="page"' : ''}>${l}</a>`).join('');const actions = current ? `<form method="post" action="/inbox/${esc(inbox.id)}/c/${esc(current.id)}/status" class="ib-actions">@@@ -154,7 +156,7 @@ ${current ? `${Number(current.verified) ? `<p class="ib-page">Verified by your s</div>${current ? `<div class="vb-dock"><form class="vb-composer" id="vb-composer" method="post" action="/inbox/${esc(inbox.id)}/c/${esc(current.id)}"><label class="oc-sr" for="vb-input">Reply</label><textarea id="vb-input" name="message" rows="1" maxlength="4000" placeholder="Reply as ${esc(actor.user.display_name || actor.user.username)}${saved.length ? ' · / for saved replies' : ''}" required aria-autocomplete="list"></textarea>--<button class="vb-send" type="submit" aria-label="Send">↑</button></form><p class="vb-fine">Replying takes the conversation from Vibe.</p></div>` : ''}++${pictures.enabled() ? '<label class="ib-pic-btn" title="Send a picture"><input type="file" id="ib-pic" accept="image/jpeg,image/png,image/gif,image/webp,image/avif" hidden><span aria-hidden="true">📎</span><span class="oc-sr">Send a picture</span></label>' : ''}<button class="vb-send" type="submit" aria-label="Send">↑</button></form><p class="vb-fine">Replying takes the conversation from Vibe. <label class="ib-tr-pick" hidden>Translate my reply into <select id="ib-tr-to"><option value="">a language…</option>${TR_LANGS.map(([v, l]) => `<option value="${v}">${l}</option>`).join('')}</select></label></p></div>` : ''}</section></div>`;await page(req, res, 200, {@@@ -26,8 +26,23 @@ async function until(fn, label) {}const visitorMessages = async () => (await widget('GET', `/messages?token=${token}`)).body.messages;++// A stand-in OpenVibe.Pics that takes any picture and answers its links.++let picsServer = null;++function startPics() {++ let n = 0;++ picsServer = require('http').createServer((req, res) => {++ req.resume();++ req.on('end', () => {++ const slug = `Pk${String(++n).padStart(6, '2')}`.replace(/[01]/g, '3');++ res.writeHead(201, { 'content-type': 'application/json' });++ res.end(JSON.stringify({ slug, links: { direct: `https://openvibe.pics/i/${slug}.png`, page: `https://openvibe.pics/i/${slug}` } }));++ });++ });++ return new Promise((r) => picsServer.listen(0, '127.0.0.1', () => r(`http://127.0.0.1:${picsServer.address().port}`)));++}++t('boot', async () => {-- h = await boot({ env: { CHAT_WEB_URL: SITE, CHAT_INBOX_POSTS_PER_MINUTE: '500' } });++ h = await boot({ env: { CHAT_WEB_URL: SITE, CHAT_INBOX_POSTS_PER_MINUTE: '500', CHAT_PICS_INTERNAL_URL: await startPics() } });aiClient = require('../server/ai/client');inboxes = require('../server/inbox/inbox');ann = h.addUser('ann', { subject: ids.newId('user') });@@@ -270,6 +285,49 @@ t('webhooks: signed JSON for every event, a line for Slack when a visitor waits;} finally { webhooks._setSend(null); }});++t('translation: a visitor\'s message into the reader\'s language (kept for the next reader), a reply draft into theirs', async () => {++ reply = 'Hola, ¿en qué te ayudo?';++ const v = (await widget('POST', '/visit', {})).body.token;++ await widget('POST', '/messages', { token: v, message: 'Mi lámpara parpadea' });++ const seen = await until(async () => { const m = (await widget('GET', `/messages?token=${v}`)).body; return m.messages.some((x) => x.author === 'ai') && m; }, 'Vibe answers');++ const cid = seen.conversation.id;++ const mid = seen.messages.find((x) => x.author === 'visitor').id;++ reply = 'My lamp flickers';++ turns.length = 0;++ const r = await agentsApi('POST', `/${inbox.id}/conversations/${cid}/messages/${mid}/translate`, bob, { to: 'en' });++ assert.deepStrictEqual([r.status, r.body.text, r.body.cached], [200, 'My lamp flickers', false]);++ assert.ok(turns[0].input.messages[0].content.includes('into English'));++ const again = await agentsApi('POST', `/${inbox.id}/conversations/${cid}/messages/${mid}/translate`, ann, { to: 'en' });++ assert.ok(again.body.cached && turns.length === 1, 'the next reader pays nothing');++ assert.strictEqual((await agentsApi('POST', `/${inbox.id}/conversations/${cid}/messages/${mid}/translate`, bob, { to: 'not a language' })).status, 422);++ const outsider = h.addUser('eve', { subject: ids.newId('user') });++ assert.strictEqual((await agentsApi('POST', `/${inbox.id}/conversations/${cid}/messages/${mid}/translate`, outsider, { to: 'en' })).status, 404);++ reply = 'Hola, te mando una nueva hoy.';++ const d = await agentsApi('POST', `/${inbox.id}/translate`, bob, { text: 'Hi, I will send you a new one today.', to: 'es' });++ assert.deepStrictEqual([d.status, d.body.text, d.body.lang], [200, 'Hola, te mando una nueva hoy.', 'es']);++ const app = await fetch(`${h.base}/inbox/${inbox.id}?c=${cid}`, { headers: { cookie: `ov_token=${bob.token}` } }).then((x) => x.text());++ assert.ok(app.includes('id="ib-tr-to"') && app.includes('Translate my reply into'));++});++++t('pictures: one of the people sends a picture (through OpenVibe.Pics as them); the visitor and the people see it', async () => {++ const v = (await widget('POST', '/visit', {})).body.token;++ const cid = (await widget('POST', '/messages', { token: v, message: 'Here is the box it came in' })).body.conversation.id;++ const PNG = Buffer.from('89504e470d0a1a0a0000000d4948445200000001000000010806000000', 'hex');++ const send = (u, headers) => fetch(`${h.base}/api/chat/inboxes/${inbox.id}/conversations/${cid}/picture`, { method: 'POST', headers: { authorization: `Bearer ${u.token}`, 'content-type': 'image/png', ...headers }, body: PNG })++ .then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));++ const r = await send(bob, { 'x-caption': encodeURIComponent('Like this one?') });++ assert.strictEqual(r.status, 201, JSON.stringify(r.body));++ assert.ok(/^Like this one\?\nhttps:\/\/openvibe\.pics\/i\/[2-9a-km-zA-HJ-NP-Z]{8}\.png$/.test(r.body.message.body), r.body.message.body);++ assert.strictEqual(r.body.message.author, 'agent');++ assert.ok((await widget('GET', `/messages?token=${v}`)).body.messages.some((m) => m.id === r.body.message.id), 'the visitor gets it');++ const outsider = h.addUser('fay', { subject: ids.newId('user') });++ assert.strictEqual((await send(outsider)).status, 404);++ const app = await fetch(`${h.base}/inbox/${inbox.id}?c=${cid}`, { headers: { cookie: `ov_token=${bob.token}` } }).then((x) => x.text());++ assert.ok(app.includes('class="oc-pic"') && app.includes('/thumb.webp') && app.includes('id="ib-pic"'), 'the people see it inline, and the reply box has a picture button');++ const panel = await fetch(`${h.base}/embed/inbox/${inbox.id}`).then((x) => x.text());++ assert.ok(panel.includes('data-pics="https://openvibe.pics"'), 'the visitor\'s panel knows where pictures come from');++});++t('the embed page and the loader: frame-ancestors follow the allowed sites', async () => {let r = await fetch(`${h.base}/embed/inbox/${inbox.id}`);assert.strictEqual(r.status, 200);@@@ -302,4 +360,4 @@ t('the people\'s pages: the list, a conversation and the settings with the snippassert.strictEqual((await page(`/inbox/${inbox.id}`, h.addUser('cat', { subject: ids.newId('user') }))).status, 404);});--t.run(async () => { if (aiClient) aiClient._setClient(null); if (h && h.close) await h.close(); });++t.run(async () => { if (aiClient) aiClient._setClient(null); if (h && h.close) await h.close(); if (picsServer) picsServer.close(); });@@@ -27,7 +27,7 @@ const TABLES = ['channel_moderators', 'channel_moderation_settings', 'emotes', 'user_tags', 'chat_ai_summaries', 'chat_timeline_events','ctx_users', 'ctx_streams', 'ctx_managed_streams', 'ctx_channels', 'ctx_sync', 'events_outbox', 'service_outbox','chat_ingress_applied', 'audio_requests', 'import_hold', 'import_runs', 'chat_meta', 'deploy_releases',-- 'chat_event_inbox', 'calls', 'rooms', 'room_members', 'room_messages', 'room_attachments', 'room_ai_members', 'room_ai_usage', 'vibe_usage', 'inboxes', 'inbox_members', 'inbox_conversations', 'inbox_messages', 'inbox_ai_usage', 'inbox_saved_replies', 'inbox_webhooks', 'feeds', 'feed_sessions', 'feed_events', 'chat_servers', 'chat_server_members', 'chat_server_invites', 'token_revocations', 'chat_control_log',++ 'chat_event_inbox', 'calls', 'rooms', 'room_members', 'room_messages', 'room_attachments', 'room_ai_members', 'room_ai_usage', 'vibe_usage', 'inboxes', 'inbox_members', 'inbox_conversations', 'inbox_messages', 'inbox_ai_usage', 'inbox_saved_replies', 'inbox_webhooks', 'inbox_translations', 'feeds', 'feed_sessions', 'feed_events', 'chat_servers', 'chat_server_members', 'chat_server_invites', 'token_revocations', 'chat_control_log','network_blocks', 'account_data_events', 'ticket_conversations', 'ticket_messages', 'telemetry_samples',];const INDEXES = [
@@ -469,6 +469,16 @@ for people and for companies, with "direct live support with an agent", "fully m
@@ -469,6 +469,16 @@ for people and for companies, with "direct live support with an agent", "fully m
waiting (once a conversation every 10 minutes) or rates. Delivery tries three times (now, 30 s, 5 min); each
waiting (once a conversation every 10 minutes) or rates. Delivery tries three times (now, 30 s, 5 min); each
webhook shows its last answer, and 20 failures in a row switch it off. `GET/POST /api/chat/inboxes/:id/webhooks
webhook shows its last answer, and 20 failures in a row switch it off. `GET/POST /api/chat/inboxes/:id/webhooks
{ url }`, `POST /:id/webhooks/:hookId/test`, `DELETE /:id/webhooks/:hookId`.
{ url }`, `POST /:id/webhooks/:hookId/test`, `DELETE /:id/webhooks/:hookId`.
+- **Pictures:** one of the inbox's people sends a picture from the reply box (`POST
+ /api/chat/inboxes/:id/conversations/:cid/picture`, the body is the picture, a line in `X-Caption`); it goes to
+ OpenVibe.Pics as them, like a room's picture, and the message carries its link, which the visitor's panel and the
+ inbox show inline (only openvibe.pics, at most four a message). Visitors cannot upload yet: anonymous uploads wait
+ for picture scanning.
+- **Translation** (`server/inbox/translate.js`): "Translate" under a visitor's message puts it in the reader's
+ language (`POST /api/chat/inboxes/:id/conversations/:cid/messages/:mid/translate { to }`, kept per message and
+ language, so the next reader pays nothing), and "Translate my reply into…" turns the draft into the visitor's
+ (`POST /:id/translate { text, to }`). OpenVibe.AI translates; `CHAT_INBOX_TRANSLATIONS_PER_DAY` (300) new ones an
+ inbox a UTC day, 30 a minute a person.
- **Saved replies:** the team's ready answers (`GET/POST /api/chat/inboxes/:id/replies { shortcut, body }`, `DELETE
- **Saved replies:** the team's ready answers (`GET/POST /api/chat/inboxes/:id/replies { shortcut, body }`, `DELETE
/:id/replies/:replyId`, or the settings page); typing `/` in the reply box lists them by shortcut (arrows, Enter or
/:id/replies/:replyId`, or the settings page); typing `/` in the reply box lists them by shortcut (arrows, Enter or
Tab). **Vibe knows the page** the visitor wrote from, and a verified visitor's name.
Tab). **Vibe knows the page** the visitor wrote from, and a verified visitor's name.
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+-- phase: expand
+-- Translation for an inbox's people (server/inbox/translate.js): a message's translation into a language is kept, so a
+-- second reader of it pays nothing; translations are counted against the inbox's day like Vibe's answers.
+CREATE TABLE IF NOT EXISTS inbox_translations (
+ message_id bigint NOT NULL,
+ lang text COLLATE "C" NOT NULL,
+ text text NOT NULL,
+ created_at text COLLATE "C" NOT NULL DEFAULT ov_now(),
+ PRIMARY KEY (message_id, lang)
+);
+ALTER TABLE inbox_ai_usage ADD COLUMN IF NOT EXISTS translations integer NOT NULL DEFAULT 0;
@@ -67,3 +67,6 @@ a { color: var(--accent); }
@@ -67,3 +67,6 @@ a { color: var(--accent); }
.re-signin:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.re-signin:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.re-signin[disabled] { opacity: .65; cursor: default; }
.re-signin[disabled] { opacity: .65; cursor: default; }
.em-head a.em-x { color: inherit; text-decoration: none; }
.em-head a.em-x { color: inherit; text-decoration: none; }
+.em-pics { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
+.em-pic { display: block; line-height: 0; border-radius: 12px; }
+.em-pic img { display: block; max-width: min(240px, 100%); max-height: 200px; border-radius: 12px; border: 1px solid var(--line); object-fit: cover; }
@@ -19,6 +19,19 @@
@@ -19,6 +19,19 @@
var token = null, conv = null, latest = 0, ws = null, delay = 1000, aiOn = root.getAttribute('data-ai') === '1', typingEl = null;
var token = null, conv = null, latest = 0, ws = null, delay = 1000, aiOn = root.getAttribute('data-ai') === '1', typingEl = null;
var people = root.getAttribute('data-people') !== '0', rateEl = null;
var people = root.getAttribute('data-people') !== '0', rateEl = null;
+ // A person's reply may hold OpenVibe.Pics pictures (sent from the inbox): shown inline, only that host, at most four.
+ var picsBase = root.getAttribute('data-pics') || 'https://openvibe.pics';
+ var PICS = new RegExp(picsBase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '/i/([2-9a-km-zA-HJ-NP-Z]{8})\\.(?:jpg|png|gif|webp|avif)(?![\\w/.-])', 'g');
+ function picsOf(text) {
+ var box = null, m, n = 0; PICS.lastIndex = 0;
+ while ((m = PICS.exec(String(text || ''))) && n < 4) {
+ if (!box) box = el('div', 'em-pics');
+ var a = el('a', 'em-pic'); a.href = picsBase + '/i/' + m[1]; a.target = '_blank'; a.rel = 'noopener';
+ var img = el('img'); img.src = picsBase + '/i/' + m[1] + '/thumb.webp'; img.alt = 'A picture'; img.loading = 'lazy';
+ a.appendChild(img); box.appendChild(a); n++;
+ }
+ return box;
+ }
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 store(fn) { try { return fn(window.localStorage); } catch (e) { return null; } }
function store(fn) { try { return fn(window.localStorage); } catch (e) { return null; } }
function toParent(msg) { try { window.parent.postMessage(Object.assign({ ov: 'chat' }, msg), '*'); } catch (e) { /* not embedded */ } }
function toParent(msg) { try { window.parent.postMessage(Object.assign({ ov: 'chat' }, msg), '*'); } catch (e) { /* not embedded */ } }
@@ -82,6 +95,7 @@
@@ -82,6 +95,7 @@
var b = el('div', 'em-bubble');
var b = el('div', 'em-bubble');
if (m.author === 'ai' && window.VibeMarkdown) b.innerHTML = window.VibeMarkdown.render(m.body); else b.textContent = m.body;
if (m.author === 'ai' && window.VibeMarkdown) b.innerHTML = window.VibeMarkdown.render(m.body); else b.textContent = m.body;
li.appendChild(b);
li.appendChild(b);
+ if (m.author === 'agent') { var pics = picsOf(m.body); if (pics) li.appendChild(pics); }
if (!mine) { clearTyping(); toParent({ type: 'message', author: m.author }); }
if (!mine) { clearTyping(); toParent({ type: 'message', author: m.author }); }
}
}
if (m.id) li.dataset.id = String(m.id);
if (m.id) li.dataset.id = String(m.id);
@@ -18,6 +18,18 @@
@@ -18,6 +18,18 @@
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; }
function chip(s) { var c = el('span', 'vb-st ' + (STATUS[s] ? STATUS[s][1] : ''), STATUS[s] ? STATUS[s][0] : s); return c; }
function chip(s) { var c = el('span', 'vb-st ' + (STATUS[s] ? STATUS[s][1] : ''), STATUS[s] ? STATUS[s][0] : s); return c; }
+ // OpenVibe.Pics direct links in a person's reply show as pictures (only that host, at most four), like in rooms.
+ var PICS = /https:\/\/openvibe\.pics\/i\/([2-9a-km-zA-HJ-NP-Z]{8})\.(?:jpg|png|gif|webp|avif)(?![\w\/.-])/g;
+ function picsOf(text) {
+ var box = null, m, n = 0; PICS.lastIndex = 0;
+ while ((m = PICS.exec(String(text || ''))) && n < 4) {
+ if (!box) box = el('span', 'oc-pics');
+ var a = el('a', 'oc-pic'); a.href = 'https://openvibe.pics/i/' + m[1]; a.target = '_blank'; a.rel = 'noopener';
+ var img = el('img'); img.src = 'https://openvibe.pics/i/' + m[1] + '/thumb.webp'; img.alt = 'A picture on OpenVibe.Pics'; img.loading = 'lazy';
+ a.appendChild(img); box.appendChild(a); n++;
+ }
+ return box;
+ }
function plain(t) { return String(t || '').replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/[*_`~]+|^#+\s*|^>\s*/gm, '').replace(/\s+/g, ' ').trim(); }
function plain(t) { return String(t || '').replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/[*_`~]+|^#+\s*|^>\s*/gm, '').replace(/\s+/g, ' ').trim(); }
function bottom() { if (scroll) scroll.scrollTop = scroll.scrollHeight; }
function bottom() { if (scroll) scroll.scrollTop = scroll.scrollHeight; }
function clearTyping() { if (typingEl) { typingEl.remove(); typingEl = null; } }
function clearTyping() { if (typingEl) { typingEl.remove(); typingEl = null; } }
@@ -36,7 +48,9 @@
@@ -36,7 +48,9 @@
b.appendChild(el('p', 'ib-who', m.author === 'ai' ? 'Vibe (AI)' : (m.author_name || 'Agent')));
b.appendChild(el('p', 'ib-who', m.author === 'ai' ? 'Vibe (AI)' : (m.author_name || 'Agent')));
var text = el('div', 'vb-md');
var text = el('div', 'vb-md');
if (m.author === 'ai' && window.VibeMarkdown) text.innerHTML = window.VibeMarkdown.render(m.body); else text.textContent = m.body;
if (m.author === 'ai' && window.VibeMarkdown) text.innerHTML = window.VibeMarkdown.render(m.body); else text.textContent = m.body;
- b.appendChild(text); li.appendChild(b);
+ b.appendChild(text);
+ var pics = m.author === 'agent' ? picsOf(m.body) : null; if (pics) b.appendChild(pics);
+ li.appendChild(b);
}
}
li.dataset.id = String(m.id);
li.dataset.id = String(m.id);
return li;
return li;
@@ -47,7 +61,7 @@
@@ -47,7 +61,7 @@
if (m.author === 'agent') { var p = thread.querySelector('.vb-me:not([data-id])'); if (p) { p.dataset.id = String(m.id); latest = Math.max(latest, Number(m.id)); return; } }
if (m.author === 'agent') { var p = thread.querySelector('.vb-me:not([data-id])'); if (p) { p.dataset.id = String(m.id); latest = Math.max(latest, Number(m.id)); return; } }
clearTyping();
clearTyping();
var stick = scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight < 160;
var stick = scroll.scrollHeight - scroll.scrollTop - scroll.clientHeight < 160;
- thread.appendChild(turn(m));
+ var li = turn(m); thread.appendChild(li); addTranslate(li);
latest = Math.max(latest, Number(m.id));
latest = Math.max(latest, Number(m.id));
if (stick) bottom();
if (stick) bottom();
}
}
@@ -97,6 +111,56 @@
@@ -97,6 +111,56 @@
ws.onclose = function () { setTimeout(connect, delay); delay = Math.min(delay * 2, 30000); };
ws.onclose = function () { setTimeout(connect, delay); delay = Math.min(delay * 2, 30000); };
}
}
+ // Translation: a visitor's message into the reader's own language, and the reply draft into the visitor's.
+ var myLang = String((navigator.language || 'en')).toLowerCase();
+ function post(path, body) {
+ return fetch('/api/chat/inboxes/' + cfg.inbox + path, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
+ .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); });
+ }
+ function addTranslate(li) {
+ if (!li || !li.classList.contains('ib-visitor') || !li.dataset.id || li.querySelector('.ib-tr-btn')) return;
+ var body = li.querySelector('.vb-body'); if (!body) return;
+ var b = el('button', 'ib-tr-btn', 'Translate'); b.type = 'button';
+ b.addEventListener('click', function () {
+ b.disabled = true; b.textContent = 'Translating…';
+ post('/conversations/' + cfg.conversation + '/messages/' + li.dataset.id + '/translate', { to: myLang }).then(function (x) {
+ if (!x.ok) { b.disabled = false; b.textContent = (x.d && x.d.error) || 'Try again'; return; }
+ var p = el('p', 'ib-tr', x.d.text); p.lang = x.d.lang; b.replaceWith(p);
+ }).catch(function () { b.disabled = false; b.textContent = 'Try again'; });
+ });
+ body.appendChild(b);
+ }
+ if (thread) Array.prototype.forEach.call(thread.querySelectorAll('.ib-visitor'), addTranslate);
+ var picIn = document.getElementById('ib-pic');
+ if (picIn && input) {
+ picIn.addEventListener('change', function () {
+ var f = picIn.files && picIn.files[0]; picIn.value = '';
+ if (!f) return;
+ var caption = input.value.trim();
+ fetch('/api/chat/inboxes/' + cfg.inbox + '/conversations/' + cfg.conversation + '/picture', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': f.type, 'X-Caption': encodeURIComponent(caption) }, body: f })
+ .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); })
+ .then(function (x) {
+ if (!x.ok) { window.alert((x.d && x.d.error) || 'That picture did not send.'); return; }
+ input.value = ''; input.dispatchEvent(new Event('input'));
+ appendToThread(x.d.message); touchList(cfg.conversation, x.d.message.body, 'open');
+ }).catch(function () { window.alert('You seem to be offline.'); });
+ });
+ }
+ var trTo = document.getElementById('ib-tr-to');
+ if (trTo && input) {
+ trTo.closest('.ib-tr-pick').hidden = false;
+ trTo.addEventListener('change', function () {
+ var to = trTo.value, text = input.value.trim(); trTo.value = '';
+ if (!to || !text) return;
+ var was = input.value; input.disabled = true;
+ post('/translate', { text: text, to: to }).then(function (x) {
+ input.disabled = false;
+ if (!x.ok) { window.alert((x.d && x.d.error) || 'That did not translate.'); return; }
+ input.value = x.d.text; input.dataset.original = was; input.dispatchEvent(new Event('input')); input.focus();
+ }).catch(function () { input.disabled = false; });
+ });
+ }
+
// Saved replies: "/" at the start of the reply box lists them by shortcut; Enter, Tab or a click puts one in.
// Saved replies: "/" at the start of the reply box lists them by shortcut; Enter, Tab or a click puts one in.
var saved = Array.isArray(cfg.saved) ? cfg.saved : [], pick = null, picked = 0;
var saved = Array.isArray(cfg.saved) ? cfg.saved : [], pick = null, picked = 0;
function closePick() { if (pick) { pick.remove(); pick = null; } input && input.removeAttribute('aria-activedescendant'); }
function closePick() { if (pick) { pick.remove(); pick = null; } input && input.removeAttribute('aria-activedescendant'); }
@@ -218,6 +218,14 @@ body.oc-app-body { overflow: hidden; }
@@ -218,6 +218,14 @@ body.oc-app-body { overflow: hidden; }
.ib-row .vb-st { margin-left: 4px; }
.ib-row .vb-st { margin-left: 4px; }
.ib-rate { font-size: .85rem; line-height: 1; }
.ib-rate { font-size: .85rem; line-height: 1; }
.ib-ok { color: var(--oc-ok, #22c55e); font-weight: 800; margin-left: 4px; font-size: .9em; }
.ib-ok { color: var(--oc-ok, #22c55e); font-weight: 800; margin-left: 4px; font-size: .9em; }
+.ib-pic-btn { display: grid; place-items: center; width: 36px; height: 36px; border-radius: 50%; cursor: pointer; color: var(--oc-muted); flex: none; font-size: 1.1rem; }
+.ib-pic-btn:hover { color: var(--oc-accent); background: color-mix(in srgb, var(--oc-accent) 12%, transparent); }
+.ib-pic-btn:focus-within { outline: 2px solid var(--oc-accent); }
+.ib-tr-btn { all: unset; cursor: pointer; font-size: .78rem; color: var(--oc-muted); margin-top: 2px; border-radius: 6px; padding: 1px 4px; }
+.ib-tr-btn:hover { color: var(--oc-accent); }
+.ib-tr-btn:focus-visible { outline: 2px solid var(--oc-accent); }
+.ib-tr { margin: 4px 0 0; padding-left: 10px; border-left: 2px solid var(--oc-line); color: var(--oc-muted); }
+.ib-tr-pick select { font-size: .8rem; padding: 2px 6px; margin-left: 4px; }
.ib-pick { list-style: none; margin: 0 0 6px; padding: 4px; border: 1px solid var(--oc-line); border-radius: 12px; background: var(--oc-panel); max-height: 260px; overflow-y: auto; }
.ib-pick { list-style: none; margin: 0 0 6px; padding: 4px; border: 1px solid var(--oc-line); border-radius: 12px; background: var(--oc-panel); max-height: 260px; overflow-y: auto; }
.ib-pick li { display: flex; gap: 8px; align-items: baseline; padding: 6px 10px; border-radius: 8px; cursor: pointer; min-width: 0; }
.ib-pick li { display: flex; gap: 8px; align-items: baseline; padding: 6px 10px; border-radius: 8px; cursor: pointer; min-width: 0; }
.ib-pick li span { color: var(--oc-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.ib-pick li span { color: var(--oc-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
@@ -107,6 +107,7 @@ const ERASE_AFTER_ROOMS = [
@@ -107,6 +107,7 @@ const ERASE_AFTER_ROOMS = [
['vibe_usage', 'DELETE FROM vibe_usage WHERE user_id = ANY(@ids)'],
['vibe_usage', 'DELETE FROM vibe_usage WHERE user_id = ANY(@ids)'],
// Support inboxes (server/inbox/inbox.js): the inboxes they own go with every conversation in them; in others,
// Support inboxes (server/inbox/inbox.js): the inboxes they own go with every conversation in them; in others,
// their replies stay for the visitor without their name, and their place on the team goes.
// their replies stay for the visitor without their name, and their place on the team goes.
+ [null, 'DELETE FROM inbox_translations WHERE message_id IN (SELECT m.id FROM inbox_messages m JOIN inbox_conversations c ON c.id = m.conversation_id JOIN inboxes i ON i.id = c.inbox_id WHERE i.owner_subject = ANY(@s) OR i.owner_id = ANY(@ids))'],
['inbox_messages', 'DELETE FROM inbox_messages WHERE conversation_id IN (SELECT c.id FROM inbox_conversations c JOIN inboxes i ON i.id = c.inbox_id WHERE i.owner_subject = ANY(@s) OR i.owner_id = ANY(@ids))'],
['inbox_messages', 'DELETE FROM inbox_messages WHERE conversation_id IN (SELECT c.id FROM inbox_conversations c JOIN inboxes i ON i.id = c.inbox_id WHERE i.owner_subject = ANY(@s) OR i.owner_id = ANY(@ids))'],
['inbox_conversations', 'DELETE FROM inbox_conversations WHERE inbox_id IN (SELECT id FROM inboxes WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids))'],
['inbox_conversations', 'DELETE FROM inbox_conversations WHERE inbox_id IN (SELECT id FROM inboxes WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids))'],
['inbox_members', 'DELETE FROM inbox_members WHERE user_id = ANY(@ids) OR inbox_id IN (SELECT id FROM inboxes WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids))'],
['inbox_members', 'DELETE FROM inbox_members WHERE user_id = ANY(@ids) OR inbox_id IN (SELECT id FROM inboxes WHERE owner_subject = ANY(@s) OR owner_id = ANY(@ids))'],
@@ -76,7 +76,8 @@ module.exports = {
@@ -76,7 +76,8 @@ module.exports = {
// A verified visitor's token is derived with CHAT_INBOX_IDENTITY_KEY (else Chat's OAuth secret): rotating it moves
// A verified visitor's token is derived with CHAT_INBOX_IDENTITY_KEY (else Chat's OAuth secret): rotating it moves
// verified visitors to new conversations.
// verified visitors to new conversations.
inbox: { aiRepliesPerDay: Math.max(1, int(process.env.CHAT_INBOX_AI_REPLIES_PER_DAY, 500)), notify: process.env.CHAT_INBOX_NOTIFY !== '0', identityKey: process.env.CHAT_INBOX_IDENTITY_KEY || '',
inbox: { aiRepliesPerDay: Math.max(1, int(process.env.CHAT_INBOX_AI_REPLIES_PER_DAY, 500)), notify: process.env.CHAT_INBOX_NOTIFY !== '0', identityKey: process.env.CHAT_INBOX_IDENTITY_KEY || '',
- postsPerMinute: Math.max(1, int(process.env.CHAT_INBOX_POSTS_PER_MINUTE, 20)) }, // a visitor's address, widget writes
+ postsPerMinute: Math.max(1, int(process.env.CHAT_INBOX_POSTS_PER_MINUTE, 20)), // a visitor's address, widget writes
+ translationsPerDay: Math.max(0, int(process.env.CHAT_INBOX_TRANSLATIONS_PER_DAY, 300)) }, // new translations an inbox a UTC day
// OpenVibe.Actor, for Vibe's web search (server/rooms/web-search.js): tasks for the person, with Chat's own token.
// OpenVibe.Actor, for Vibe's web search (server/rooms/web-search.js): tasks for the person, with Chat's own token.
actor: { internalUrl: strip(process.env.CHAT_ACTOR_INTERNAL_URL || 'http://127.0.0.1:4950') },
actor: { internalUrl: strip(process.env.CHAT_ACTOR_INTERNAL_URL || 'http://127.0.0.1:4950') },
@@ -19,6 +19,9 @@
@@ -19,6 +19,9 @@
* POST /:id/members { username } (owner) DELETE /:id/members/:userId (owner)
* POST /:id/members { username } (owner) DELETE /:id/members/:userId (owner)
* POST /:id/identity { action: on | rotate | off } (owner) → { identity_secret }: verified visitors
* POST /:id/identity { action: on | rotate | off } (owner) → { identity_secret }: verified visitors
* GET /:id/replies { replies }: saved replies POST /:id/replies { shortcut, body } DELETE /:id/replies/:replyId
* GET /:id/replies { replies }: saved replies POST /:id/replies { shortcut, body } DELETE /:id/replies/:replyId
+ * POST /:id/conversations/:cid/messages/:mid/translate { to } → { text, lang, cached }: a message in the reader's language
+ * POST /:id/translate { text, to } → { text, lang }: a reply draft in the visitor's language
+ * POST /:id/conversations/:cid/picture the body is a picture, an optional line in X-Caption → 201 { message, picture }
* GET /:id/webhooks (owner) { webhooks } POST /:id/webhooks { url } → 201 { webhook, secret }
* GET /:id/webhooks (owner) { webhooks } POST /:id/webhooks { url } → 201 { webhook, secret }
* POST /:id/webhooks/:hookId/test → { ok, status, error } DELETE /:id/webhooks/:hookId
* POST /:id/webhooks/:hookId/test → { ok, status, error } DELETE /:id/webhooks/:hookId
* GET /:id/conversations ?status=active|bot|waiting|open|resolved
* GET /:id/conversations ?status=active|bot|waiting|open|resolved
@@ -35,9 +38,10 @@ const { requireAuth } = require('../auth/auth');
@@ -35,9 +38,10 @@ const { requireAuth } = require('../auth/auth');
const { limits } = require('../net/actor-limits');
const { limits } = require('../net/actor-limits');
const inboxes = require('./inbox');
const inboxes = require('./inbox');
const webhooks = require('./webhooks');
const webhooks = require('./webhooks');
+const translate = require('./translate');
function send(res, err) {
function send(res, err) {
- if (err instanceof inboxes.InboxError || err instanceof webhooks.WebhookError) return res.status(err.status).json({ error: err.message, code: err.code });
+ if (err instanceof inboxes.InboxError || err instanceof webhooks.WebhookError || err instanceof translate.TranslateError) return res.status(err.status).json({ error: err.message, code: err.code });
console.error('[Inbox]', err && err.message);
console.error('[Inbox]', err && err.message);
return res.status(500).json({ error: 'Something went wrong' });
return res.status(500).json({ error: 'Something went wrong' });
}
}
@@ -144,6 +148,32 @@ agents.delete('/:id/replies/:replyId', requireAuth, manageLimit, async (req, res
@@ -144,6 +148,32 @@ agents.delete('/:id/replies/:replyId', requireAuth, manageLimit, async (req, res
const inbox = await agentInbox(req, res); if (!inbox) return;
const inbox = await agentInbox(req, res); if (!inbox) return;
try { res.json({ replies: await inboxes.deleteReply(inbox, req.user, req.params.replyId) }); } catch (err) { send(res, err); }
try { res.json({ replies: await inboxes.deleteReply(inbox, req.user, req.params.replyId) }); } catch (err) { send(res, err); }
});
});
+// A picture from one of the inbox's people: it goes to OpenVibe.Pics as them (their own sign-in, like a room's picture),
+// and the conversation gets a message with its link, which the visitor's panel shows inline.
+const bearerOf = (req) => { const m = /^Bearer\s+(.+)$/i.exec(String(req.get('authorization') || '')); return m ? m[1].trim() : (req.cookies && req.cookies.ov_token) || null; };
+const pictureLimit = rateLimit({ windowMs: 60_000, max: 10, standardHeaders: true, legacyHeaders: false, message: { error: 'Slow down a little', code: 'inbox.slow_down' } });
+agents.post('/:id/conversations/:cid/picture', requireAuth, pictureLimit, (req, res, next) => {
+ if (req.authSource === 'api_token') return res.status(403).json({ error: 'Send pictures with your own sign-in', code: 'inbox.person_only' });
+ return express.raw({ type: () => true, limit: (config.pics.maxBytes || 20 * 1024 * 1024) + 1 })(req, res, (err) => {
+ if (err && err.type === 'entity.too.large') return res.status(413).json({ error: 'That picture is too large', code: 'pictures.too_large' });
+ return next(err);
+ });
+}, async (req, res) => {
+ const conv = await agentConversation(req, res); if (!conv) return;
+ const caption = (() => { try { return decodeURIComponent(String(req.get('x-caption') || '')).slice(0, 500); } catch { return ''; } })();
+ const out = await require('../rooms/pictures').upload({ token: bearerOf(req), buffer: Buffer.isBuffer(req.body) ? req.body : null, contentType: req.get('content-type'), title: caption, ip: req.ip });
+ if (!out.ok) return res.status(out.status || 502).json({ error: out.detail || 'That picture did not send', code: out.code || 'pictures.failed' });
+ try { res.status(201).json({ message: await inboxes.agentPost(req.inbox, conv, req.user, `${caption ? `${caption}\n` : ''}${out.picture.direct}`), picture: out.picture }); } catch (err) { send(res, err); }
+});
+const translateLimit = rateLimit({ windowMs: 60_000, max: 30, standardHeaders: true, legacyHeaders: false, message: { error: 'Slow down a little', code: 'inbox.slow_down' } });
+agents.post('/:id/conversations/:cid/messages/:mid/translate', requireAuth, translateLimit, async (req, res) => {
+ const conv = await agentConversation(req, res); if (!conv) return;
+ try { noStore(res).json(await translate.message(req.inbox, conv, req.params.mid, req.body && req.body.to)); } catch (err) { send(res, err); }
+});
+agents.post('/:id/translate', requireAuth, translateLimit, async (req, res) => {
+ const inbox = await agentInbox(req, res); if (!inbox) return;
+ try { noStore(res).json(await translate.draft(inbox, req.body && req.body.text, req.body && req.body.to)); } catch (err) { send(res, err); }
+});
agents.get('/:id/webhooks', requireAuth, async (req, res) => {
agents.get('/:id/webhooks', requireAuth, async (req, res) => {
const inbox = await agentInbox(req, res, { owner: true }); if (!inbox) return;
const inbox = await agentInbox(req, res, { owner: true }); if (!inbox) return;
noStore(res).json({ webhooks: await webhooks.list(inbox) });
noStore(res).json({ webhooks: await webhooks.list(inbox) });
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -0,0 +1,69 @@
+'use strict';
+/**
+ * Translation for an inbox's people (server/inbox/inbox.js): a visitor's message in their reader's language, on request,
+ * and a reply draft in the visitor's. OpenVibe.AI translates (chat.member.turn, Chat's token); each message's
+ * translation into a language is kept (inbox_translations), so a second reader pays nothing. At most
+ * CHAT_INBOX_TRANSLATIONS_PER_DAY (300) new translations an inbox a UTC day.
+ */
+const db = require('../db/database');
+const config = require('../config');
+
+const LANG_RE = /^[a-z]{2,3}(?:-[A-Za-z]{2,4})?$/;
+const NAMES = { en: 'English', es: 'Spanish', pt: 'Portuguese', fr: 'French', de: 'German', it: 'Italian', nl: 'Dutch', ja: 'Japanese', ko: 'Korean', zh: 'Chinese', ru: 'Russian', uk: 'Ukrainian', pl: 'Polish', tr: 'Turkish', ar: 'Arabic', hi: 'Hindi', id: 'Indonesian', vi: 'Vietnamese', th: 'Thai', sv: 'Swedish' };
+const today = () => new Date().toISOString().slice(0, 10);
+
+class TranslateError extends Error { constructor(status, code, message) { super(message); this.status = status; this.code = code; } }
+const fail = (status, code, message) => { throw new TranslateError(status, code, message); };
+
+function langOf(to) {
+ const l = String(to || '').trim();
+ if (!LANG_RE.test(l)) fail(422, 'inbox.lang', 'A language is a code like en or pt-BR');
+ return l.toLowerCase();
+}
+const nameOf = (l) => NAMES[l.split('-')[0]] || l;
+
+async function spend(inboxId) {
+ const used = Number((await db.get('SELECT translations FROM inbox_ai_usage WHERE inbox_id = ? AND day = ?', [inboxId, today()]) || {}).translations) || 0;
+ if (used >= config.inbox.translationsPerDay) fail(429, 'inbox.translations', 'This inbox has translated a lot today; try again tomorrow');
+ await db.run(`INSERT INTO inbox_ai_usage (inbox_id, day, replies, translations) VALUES (?, ?, 0, 1)
+ ON CONFLICT (inbox_id, day) DO UPDATE SET translations = inbox_ai_usage.translations + 1`, [inboxId, today()]);
+}
+
+async function ask(inbox, text, to, deps = {}) {
+ const aiClient = deps.aiClient || require('../ai/client');
+ const out = await aiClient.structured('chat.member.turn', {
+ messages: [
+ { role: 'system', content: `Translate the user's message into ${nameOf(to)}. Answer with the translation only: no quotes, no notes. Keep names, numbers, links and order numbers as they are. If it is already in ${nameOf(to)}, answer it unchanged.` },
+ { role: 'user', content: String(text).slice(0, 4000) },
+ ],
+ max_tokens: 900, temperature: 0,
+ }, { force: true, attribution: { service: 'chat', type: 'inbox-translation', id: inbox.id, label: inbox.name.slice(0, 200) } });
+ const msg = out && Array.isArray(out.choices) && out.choices[0] && out.choices[0].message;
+ const t = msg && typeof msg.content === 'string' ? msg.content.trim() : '';
+ if (!t) fail(503, 'inbox.translate_unavailable', 'Translation is not available right now');
+ return t.slice(0, 6000);
+}
+
+/** A message of the conversation in `to`: { text, cached }. */
+async function message(inbox, conv, messageId, to, deps = {}) {
+ const lang = langOf(to);
+ const m = await db.get('SELECT id, body FROM inbox_messages WHERE id = ? AND conversation_id = ?', [Number(messageId) || 0, conv.id]);
+ if (!m) fail(404, 'inbox.no_message', 'No such message');
+ const kept = await db.get('SELECT text FROM inbox_translations WHERE message_id = ? AND lang = ?', [m.id, lang]);
+ if (kept) return { text: kept.text, lang, cached: true };
+ await spend(inbox.id);
+ const text = await ask(inbox, m.body, lang, deps);
+ await db.run('INSERT INTO inbox_translations (message_id, lang, text) VALUES (?, ?, ?) ON CONFLICT (message_id, lang) DO NOTHING', [m.id, lang, text]);
+ return { text, lang, cached: false };
+}
+
+/** A reply draft in `to` (not kept): { text }. */
+async function draft(inbox, text, to, deps = {}) {
+ const lang = langOf(to);
+ const body = String(text || '').trim().slice(0, 4000);
+ if (!body) fail(422, 'inbox.empty', 'Write the reply first');
+ await spend(inbox.id);
+ return { text: await ask(inbox, body, lang, deps), lang };
+}
+
+module.exports = { TranslateError, message, draft, langOf };
@@ -16,10 +16,12 @@ const path = require('path');
@@ -16,10 +16,12 @@ const path = require('path');
const md = require('../../public/web/vibe-md.js');
const md = require('../../public/web/vibe-md.js');
const inboxes = require('../inbox/inbox');
const inboxes = require('../inbox/inbox');
const webhooks = require('../inbox/webhooks');
const webhooks = require('../inbox/webhooks');
+const pictures = require('../rooms/pictures');
const { embedOf } = require('../inbox/routes');
const { embedOf } = require('../inbox/routes');
const { esc, asset } = require('./layout');
const { esc, asset } = require('./layout');
const WIDGET = path.join(__dirname, '..', '..', 'public', 'web', 'widget.js');
const WIDGET = path.join(__dirname, '..', '..', 'public', 'web', 'widget.js');
+const TR_LANGS = [['en', 'English'], ['es', 'Spanish'], ['pt', 'Portuguese'], ['fr', 'French'], ['de', 'German'], ['it', 'Italian'], ['nl', 'Dutch'], ['pl', 'Polish'], ['tr', 'Turkish'], ['ru', 'Russian'], ['uk', 'Ukrainian'], ['ar', 'Arabic'], ['hi', 'Hindi'], ['id', 'Indonesian'], ['vi', 'Vietnamese'], ['th', 'Thai'], ['ja', 'Japanese'], ['ko', 'Korean'], ['zh', 'Chinese'], ['sv', 'Swedish']];
const STATUS = { bot: ['Vibe', 'vb-st-bot'], waiting: ['Waiting', 'vb-st-wait'], open: ['Open', 'vb-st-open'], resolved: ['Resolved', 'vb-st-done'] };
const STATUS = { bot: ['Vibe', 'vb-st-bot'], waiting: ['Waiting', 'vb-st-wait'], open: ['Open', 'vb-st-open'], resolved: ['Resolved', 'vb-st-done'] };
const json = (v, d) => { if (Array.isArray(v)) return v; try { return JSON.parse(v || 'null') || d; } catch { return d; } };
const json = (v, d) => { if (Array.isArray(v)) return v; try { return JSON.parse(v || 'null') || d; } catch { return d; } };
const ago = (ts) => {
const ago = (ts) => {
@@ -42,7 +44,7 @@ function embedHtml(inbox) {
@@ -42,7 +44,7 @@ function embedHtml(inbox) {
<script src="${asset('embed.js')}" defer></script>
<script src="${asset('embed.js')}" defer></script>
</head>
</head>
<body style="--accent:${esc(inbox.color)}">
<body style="--accent:${esc(inbox.color)}">
-<div class="em" id="em" data-inbox="${esc(inbox.id)}" data-ai="${Number(inbox.ai_enabled) ? '1' : '0'}" data-people="${inboxes.peopleAnswer(inbox) ? '1' : '0'}">
+<div class="em" id="em" data-inbox="${esc(inbox.id)}" data-ai="${Number(inbox.ai_enabled) ? '1' : '0'}" data-people="${inboxes.peopleAnswer(inbox) ? '1' : '0'}" data-pics="${esc(pictures.publicUrl())}">
<header class="em-head"><span class="em-mark" aria-hidden="true">${esc(inbox.name.slice(0, 1).toUpperCase())}</span>
<header class="em-head"><span class="em-mark" aria-hidden="true">${esc(inbox.name.slice(0, 1).toUpperCase())}</span>
<div><b>${esc(inbox.name)}</b><span id="em-state">${!Number(inbox.ai_enabled) ? 'We usually reply within a few hours.' : inboxes.peopleAnswer(inbox) ? 'Vibe answers right away. Ask for a person any time.' : 'Vibe, the assistant, answers right away.'}</span></div>
<div><b>${esc(inbox.name)}</b><span id="em-state">${!Number(inbox.ai_enabled) ? 'We usually reply within a few hours.' : inboxes.peopleAnswer(inbox) ? 'Vibe answers right away. Ask for a person any time.' : 'Vibe, the assistant, answers right away.'}</span></div>
<button class="em-x" type="button" data-close aria-label="Close the chat">×</button></header>
<button class="em-x" type="button" data-close aria-label="Close the chat">×</button></header>
@@ -130,7 +132,7 @@ ${list}
@@ -130,7 +132,7 @@ ${list}
const turn = (m) => {
const turn = (m) => {
if (m.author === 'system') return `<li class="ib-sys" data-id="${m.id}">${esc(m.body)}</li>`;
if (m.author === 'system') return `<li class="ib-sys" data-id="${m.id}">${esc(m.body)}</li>`;
if (m.author === 'visitor') return `<li class="vb-turn vb-ai ib-visitor" data-id="${m.id}"><span class="ib-av" aria-hidden="true">${esc((current.visitor_name || 'V').slice(0, 1).toUpperCase())}</span><div class="vb-body"><p class="ib-who">${esc(current.visitor_name || 'Visitor')}</p><div class="vb-md">${esc(m.body).replace(/\n/g, '<br>')}</div></div></li>`;
if (m.author === 'visitor') return `<li class="vb-turn vb-ai ib-visitor" data-id="${m.id}"><span class="ib-av" aria-hidden="true">${esc((current.visitor_name || 'V').slice(0, 1).toUpperCase())}</span><div class="vb-body"><p class="ib-who">${esc(current.visitor_name || 'Visitor')}</p><div class="vb-md">${esc(m.body).replace(/\n/g, '<br>')}</div></div></li>`;
- return `<li class="vb-turn vb-me" data-id="${m.id}"><div class="vb-bubble${m.author === 'ai' ? ' ib-ai' : ''}"><p class="ib-who">${m.author === 'ai' ? 'Vibe (AI)' : esc(m.author_name || 'Agent')}</p>${m.author === 'ai' ? `<div class="vb-md">${md.render(m.body)}</div>` : esc(m.body).replace(/\n/g, '<br>')}</div></li>`;
+ return `<li class="vb-turn vb-me" data-id="${m.id}"><div class="vb-bubble${m.author === 'ai' ? ' ib-ai' : ''}"><p class="ib-who">${m.author === 'ai' ? 'Vibe (AI)' : esc(m.author_name || 'Agent')}</p>${m.author === 'ai' ? `<div class="vb-md">${md.render(m.body)}</div>` : esc(m.body).replace(/\n/g, '<br>')}${m.author === 'agent' ? pictures.pageHtml(m.body) : ''}</div></li>`;
};
};
const tabs = [['active', 'Active'], ['waiting', 'Waiting'], ['resolved', 'Resolved']].map(([k, l]) => `<a href="/inbox/${esc(inbox.id)}?f=${k}"${k === filter ? ' aria-current="page"' : ''}>${l}</a>`).join('');
const tabs = [['active', 'Active'], ['waiting', 'Waiting'], ['resolved', 'Resolved']].map(([k, l]) => `<a href="/inbox/${esc(inbox.id)}?f=${k}"${k === filter ? ' aria-current="page"' : ''}>${l}</a>`).join('');
const actions = current ? `<form method="post" action="/inbox/${esc(inbox.id)}/c/${esc(current.id)}/status" class="ib-actions">
const actions = current ? `<form method="post" action="/inbox/${esc(inbox.id)}/c/${esc(current.id)}/status" class="ib-actions">
@@ -154,7 +156,7 @@ ${current ? `${Number(current.verified) ? `<p class="ib-page">Verified by your s
@@ -154,7 +156,7 @@ ${current ? `${Number(current.verified) ? `<p class="ib-page">Verified by your s
</div>
</div>
${current ? `<div class="vb-dock"><form class="vb-composer" id="vb-composer" method="post" action="/inbox/${esc(inbox.id)}/c/${esc(current.id)}">
${current ? `<div class="vb-dock"><form class="vb-composer" id="vb-composer" method="post" action="/inbox/${esc(inbox.id)}/c/${esc(current.id)}">
<label class="oc-sr" for="vb-input">Reply</label><textarea id="vb-input" name="message" rows="1" maxlength="4000" placeholder="Reply as ${esc(actor.user.display_name || actor.user.username)}${saved.length ? ' · / for saved replies' : ''}" required aria-autocomplete="list"></textarea>
<label class="oc-sr" for="vb-input">Reply</label><textarea id="vb-input" name="message" rows="1" maxlength="4000" placeholder="Reply as ${esc(actor.user.display_name || actor.user.username)}${saved.length ? ' · / for saved replies' : ''}" required aria-autocomplete="list"></textarea>
-<button class="vb-send" type="submit" aria-label="Send">↑</button></form><p class="vb-fine">Replying takes the conversation from Vibe.</p></div>` : ''}
+${pictures.enabled() ? '<label class="ib-pic-btn" title="Send a picture"><input type="file" id="ib-pic" accept="image/jpeg,image/png,image/gif,image/webp,image/avif" hidden><span aria-hidden="true">📎</span><span class="oc-sr">Send a picture</span></label>' : ''}<button class="vb-send" type="submit" aria-label="Send">↑</button></form><p class="vb-fine">Replying takes the conversation from Vibe. <label class="ib-tr-pick" hidden>Translate my reply into <select id="ib-tr-to"><option value="">a language…</option>${TR_LANGS.map(([v, l]) => `<option value="${v}">${l}</option>`).join('')}</select></label></p></div>` : ''}
</section>
</section>
</div>`;
</div>`;
await page(req, res, 200, {
await page(req, res, 200, {
@@ -26,8 +26,23 @@ async function until(fn, label) {
@@ -26,8 +26,23 @@ async function until(fn, label) {
}
}
const visitorMessages = async () => (await widget('GET', `/messages?token=${token}`)).body.messages;
const visitorMessages = async () => (await widget('GET', `/messages?token=${token}`)).body.messages;
+// A stand-in OpenVibe.Pics that takes any picture and answers its links.
+let picsServer = null;
+function startPics() {
+ let n = 0;
+ picsServer = require('http').createServer((req, res) => {
+ req.resume();
+ req.on('end', () => {
+ const slug = `Pk${String(++n).padStart(6, '2')}`.replace(/[01]/g, '3');
+ res.writeHead(201, { 'content-type': 'application/json' });
+ res.end(JSON.stringify({ slug, links: { direct: `https://openvibe.pics/i/${slug}.png`, page: `https://openvibe.pics/i/${slug}` } }));
+ });
+ });
+ return new Promise((r) => picsServer.listen(0, '127.0.0.1', () => r(`http://127.0.0.1:${picsServer.address().port}`)));
+}
+
t('boot', async () => {
t('boot', async () => {
- h = await boot({ env: { CHAT_WEB_URL: SITE, CHAT_INBOX_POSTS_PER_MINUTE: '500' } });
+ h = await boot({ env: { CHAT_WEB_URL: SITE, CHAT_INBOX_POSTS_PER_MINUTE: '500', CHAT_PICS_INTERNAL_URL: await startPics() } });
aiClient = require('../server/ai/client');
aiClient = require('../server/ai/client');
inboxes = require('../server/inbox/inbox');
inboxes = require('../server/inbox/inbox');
ann = h.addUser('ann', { subject: ids.newId('user') });
ann = h.addUser('ann', { subject: ids.newId('user') });
@@ -270,6 +285,49 @@ t('webhooks: signed JSON for every event, a line for Slack when a visitor waits;
@@ -270,6 +285,49 @@ t('webhooks: signed JSON for every event, a line for Slack when a visitor waits;
} finally { webhooks._setSend(null); }
} finally { webhooks._setSend(null); }
});
});
+t('translation: a visitor\'s message into the reader\'s language (kept for the next reader), a reply draft into theirs', async () => {
+ reply = 'Hola, ¿en qué te ayudo?';
+ const v = (await widget('POST', '/visit', {})).body.token;
+ await widget('POST', '/messages', { token: v, message: 'Mi lámpara parpadea' });
+ const seen = await until(async () => { const m = (await widget('GET', `/messages?token=${v}`)).body; return m.messages.some((x) => x.author === 'ai') && m; }, 'Vibe answers');
+ const cid = seen.conversation.id;
+ const mid = seen.messages.find((x) => x.author === 'visitor').id;
+ reply = 'My lamp flickers';
+ turns.length = 0;
+ const r = await agentsApi('POST', `/${inbox.id}/conversations/${cid}/messages/${mid}/translate`, bob, { to: 'en' });
+ assert.deepStrictEqual([r.status, r.body.text, r.body.cached], [200, 'My lamp flickers', false]);
+ assert.ok(turns[0].input.messages[0].content.includes('into English'));
+ const again = await agentsApi('POST', `/${inbox.id}/conversations/${cid}/messages/${mid}/translate`, ann, { to: 'en' });
+ assert.ok(again.body.cached && turns.length === 1, 'the next reader pays nothing');
+ assert.strictEqual((await agentsApi('POST', `/${inbox.id}/conversations/${cid}/messages/${mid}/translate`, bob, { to: 'not a language' })).status, 422);
+ const outsider = h.addUser('eve', { subject: ids.newId('user') });
+ assert.strictEqual((await agentsApi('POST', `/${inbox.id}/conversations/${cid}/messages/${mid}/translate`, outsider, { to: 'en' })).status, 404);
+ reply = 'Hola, te mando una nueva hoy.';
+ const d = await agentsApi('POST', `/${inbox.id}/translate`, bob, { text: 'Hi, I will send you a new one today.', to: 'es' });
+ assert.deepStrictEqual([d.status, d.body.text, d.body.lang], [200, 'Hola, te mando una nueva hoy.', 'es']);
+ const app = await fetch(`${h.base}/inbox/${inbox.id}?c=${cid}`, { headers: { cookie: `ov_token=${bob.token}` } }).then((x) => x.text());
+ assert.ok(app.includes('id="ib-tr-to"') && app.includes('Translate my reply into'));
+});
+
+t('pictures: one of the people sends a picture (through OpenVibe.Pics as them); the visitor and the people see it', async () => {
+ const v = (await widget('POST', '/visit', {})).body.token;
+ const cid = (await widget('POST', '/messages', { token: v, message: 'Here is the box it came in' })).body.conversation.id;
+ const PNG = Buffer.from('89504e470d0a1a0a0000000d4948445200000001000000010806000000', 'hex');
+ const send = (u, headers) => fetch(`${h.base}/api/chat/inboxes/${inbox.id}/conversations/${cid}/picture`, { method: 'POST', headers: { authorization: `Bearer ${u.token}`, 'content-type': 'image/png', ...headers }, body: PNG })
+ .then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
+ const r = await send(bob, { 'x-caption': encodeURIComponent('Like this one?') });
+ assert.strictEqual(r.status, 201, JSON.stringify(r.body));
+ assert.ok(/^Like this one\?\nhttps:\/\/openvibe\.pics\/i\/[2-9a-km-zA-HJ-NP-Z]{8}\.png$/.test(r.body.message.body), r.body.message.body);
+ assert.strictEqual(r.body.message.author, 'agent');
+ assert.ok((await widget('GET', `/messages?token=${v}`)).body.messages.some((m) => m.id === r.body.message.id), 'the visitor gets it');
+ const outsider = h.addUser('fay', { subject: ids.newId('user') });
+ assert.strictEqual((await send(outsider)).status, 404);
+ const app = await fetch(`${h.base}/inbox/${inbox.id}?c=${cid}`, { headers: { cookie: `ov_token=${bob.token}` } }).then((x) => x.text());
+ assert.ok(app.includes('class="oc-pic"') && app.includes('/thumb.webp') && app.includes('id="ib-pic"'), 'the people see it inline, and the reply box has a picture button');
+ const panel = await fetch(`${h.base}/embed/inbox/${inbox.id}`).then((x) => x.text());
+ assert.ok(panel.includes('data-pics="https://openvibe.pics"'), 'the visitor\'s panel knows where pictures come from');
+});
+
t('the embed page and the loader: frame-ancestors follow the allowed sites', async () => {
t('the embed page and the loader: frame-ancestors follow the allowed sites', async () => {
let r = await fetch(`${h.base}/embed/inbox/${inbox.id}`);
let r = await fetch(`${h.base}/embed/inbox/${inbox.id}`);
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
@@ -302,4 +360,4 @@ t('the people\'s pages: the list, a conversation and the settings with the snipp
@@ -302,4 +360,4 @@ t('the people\'s pages: the list, a conversation and the settings with the snipp
assert.strictEqual((await page(`/inbox/${inbox.id}`, h.addUser('cat', { subject: ids.newId('user') }))).status, 404);
assert.strictEqual((await page(`/inbox/${inbox.id}`, h.addUser('cat', { subject: ids.newId('user') }))).status, 404);
});
});
-t.run(async () => { if (aiClient) aiClient._setClient(null); if (h && h.close) await h.close(); });
+t.run(async () => { if (aiClient) aiClient._setClient(null); if (h && h.close) await h.close(); if (picsServer) picsServer.close(); });
@@ -27,7 +27,7 @@ const TABLES = [
@@ -27,7 +27,7 @@ const TABLES = [
'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',
'ctx_users', 'ctx_streams', 'ctx_managed_streams', 'ctx_channels', 'ctx_sync', 'events_outbox', 'service_outbox',
'ctx_users', 'ctx_streams', 'ctx_managed_streams', 'ctx_channels', 'ctx_sync', 'events_outbox', 'service_outbox',
'chat_ingress_applied', 'audio_requests', 'import_hold', 'import_runs', 'chat_meta', 'deploy_releases',
'chat_ingress_applied', 'audio_requests', 'import_hold', 'import_runs', 'chat_meta', 'deploy_releases',
- 'chat_event_inbox', 'calls', 'rooms', 'room_members', 'room_messages', 'room_attachments', 'room_ai_members', 'room_ai_usage', 'vibe_usage', 'inboxes', 'inbox_members', 'inbox_conversations', 'inbox_messages', 'inbox_ai_usage', 'inbox_saved_replies', 'inbox_webhooks', 'feeds', 'feed_sessions', 'feed_events', 'chat_servers', 'chat_server_members', 'chat_server_invites', 'token_revocations', 'chat_control_log',
+ 'chat_event_inbox', 'calls', 'rooms', 'room_members', 'room_messages', 'room_attachments', 'room_ai_members', 'room_ai_usage', 'vibe_usage', 'inboxes', 'inbox_members', 'inbox_conversations', 'inbox_messages', 'inbox_ai_usage', 'inbox_saved_replies', 'inbox_webhooks', 'inbox_translations', 'feeds', 'feed_sessions', 'feed_events', 'chat_servers', 'chat_server_members', 'chat_server_invites', 'token_revocations', 'chat_control_log',
'network_blocks', 'account_data_events', 'ticket_conversations', 'ticket_messages', 'telemetry_samples',
'network_blocks', 'account_data_events', 'ticket_conversations', 'ticket_messages', 'telemetry_samples',
];
];
const INDEXES = [
const INDEXES = [