openvibers/openvibe.chat Commits
The agent's canvas beside a Vibe chat (HO9 step 2) (#406)
Changes
@@@ -206,3 +206,24 @@.vbk .ovt-answer.vb-streaming { opacity: .92; }/* Memory: a plain page in the frame; on a wide screen its head held only New chat, which the chats column has. */@media (min-width: 761px) { .vbk .ovt.is-page .ovt-head { display: none; } }++++/* ── The canvas (plan HO9, actor.canvas@1) ───────────────────────────────────────────────────────────────────────++ The agent's data panel beside a Vibe chat: the thread's third column from 1180 px (thread.css's own sticky panel),++ and a closed, counted fold directly above the composer below that. Both hold the same document, drawn read-only by++ openvibe-shared's ov-canvas.js; the one link opens the Actor thread where the person can tick or press. */++.vbk .vbc { min-width: 0; }++.vbk .vbc-count { flex: none; font-size: 12.5px; color: var(--text-muted, #7386a3); white-space: nowrap; }++.vbk .vbc-body, .vbk .vbc-host { min-width: 0; }++.vbk .vbc-body { display: grid; gap: 8px; }++.vbk .vbc-link { justify-self: start; font-size: 12.5px; font-weight: 650; color: var(--accent-light, var(--ovt-accent, #60a5fa)); text-decoration: none; }++.vbk .vbc-link:hover, .vbk .vbc-link:focus-visible { text-decoration: underline; }++.vbk .vbc-phone { max-width: var(--vb-col); margin: 0 auto 8px; border: 1px solid var(--ovt-line); border-radius: 14px; background: var(--bg-card, #131c2e); overflow: hidden; contain: layout paint; }++.vbk .vbc-phone > summary { display: flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 14px; cursor: pointer; font-size: 14px; font-weight: 650; list-style: none; color: var(--text-primary, #e6edf7); }++.vbk .vbc-phone > summary::-webkit-details-marker { display: none; }++.vbk .vbc-phone > summary::marker { content: ''; }++.vbk .vbc-phone .vbc-updated { flex: none; font-size: 12.5px; font-weight: 650; color: var(--ovt-accent, #60a5fa); }++.vbk .vbc-phone .vbc-chev { margin-left: auto; color: var(--text-muted, #7386a3); transition: transform .2s; }++.vbk .vbc-phone[open] .vbc-chev { transform: rotate(180deg); }++.vbk .vbc-phone .vbc-body { max-height: min(45vh, 420px); overflow: auto; overscroll-behavior: contain; padding: 0 10px 10px; }++@media (min-width: 1180px) { .vbk .vbc-phone { display: none !important; } }++@media (max-width: 1179px) { .vbk .vbc-wide { display: none !important; } }@@@ -341,6 +341,141 @@if (stick) toBottom();}++ // ── The canvas (plan HO9): the agent's data panel beside the chat ─────────────────────────────────────────────++ // Only the newest task in the chat, and only when OpenVibe.Actor holds it (the task's actor_task is the tsk_…++ // thread). The document is read in the browser with the person's own Network token — the Actor API allows that one++ // read from openvibe.chat and refuses cookies — and drawn by openvibe-shared's ov-canvas.js. It is read-only here:++ // checklist boxes disabled, buttons not drawn, and one link to the Actor thread where the person can tick or press.++ // The wide third column and the closed fold above the composer share the same document, as on Actor.++ var ACTOR = 'https://openvibe.actor';++ var CANVAS_THREAD = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;++ var canvas = cfg.canvas && CANVAS_THREAD.test(String(cfg.canvas.thread || '')) ? cfg.canvas : null;++ var canvasVersion = 0, canvasTimer = 0, canvasBusy = false, canvasUpdatedTimer = 0;++ function canvasRoot() { return app.querySelector('.ovt[data-ovt]'); }++ function canvasIsOpen() { return !!(canvas && (canvas.status === 'queued' || canvas.status === 'running')); }++ function canvasItems(n) { return n + (n === 1 ? ' item' : ' items'); }++ function canvasCountSpan() { var span = el('span', 'vbc-count'); span.setAttribute('data-vb-canvas-count', ''); return span; }++ function canvasLink() {++ var a = el('a', 'vbc-link', 'Open in Actor to tick or press');++ a.href = ACTOR + '/chat/' + encodeURIComponent(canvas.thread);++ a.target = '_blank'; a.rel = 'noopener';++ return a;++ }++ function canvasBody() {++ var body = el('div', 'vbc-body');++ var host = el('div', 'vbc-host'); host.setAttribute('data-vb-canvas-host', '');++ body.appendChild(host); body.appendChild(canvasLink());++ return body;++ }++ function canvasWide() {++ var root = canvasRoot(); if (!root || !canvas) return null;++ var box = root.querySelector('.vbc-wide');++ if (box) return box;++ box = el('aside', 'ovt-panel vbc vbc-wide');++ box.setAttribute('aria-label', 'Canvas'); box.setAttribute('data-vb-canvas', ''); box.setAttribute('data-thread', canvas.thread);++ var head = el('div', 'ovt-panel-head');++ head.appendChild(el('h2', null, 'Canvas')); head.appendChild(canvasCountSpan());++ box.appendChild(head); box.appendChild(canvasBody());++ root.appendChild(box); root.classList.add('has-panel');++ return box;++ }++ function canvasPhone() {++ var root = canvasRoot(); if (!root || !canvas) return null;++ var box = root.querySelector('.vbc-phone');++ if (box) return box;++ var dock = root.querySelector('.ovt-dock'); if (!dock) return null;++ box = el('details', 'vbc vbc-phone');++ box.setAttribute('data-vb-canvas', ''); box.setAttribute('data-thread', canvas.thread);++ var sum = el('summary');++ sum.appendChild(el('span', 'vbc-label', 'Canvas'));++ var sep = el('span', 'vbc-sep', '·'); sep.setAttribute('aria-hidden', 'true'); sum.appendChild(sep);++ sum.appendChild(canvasCountSpan());++ var up = el('span', 'vbc-updated', 'updated');++ up.setAttribute('data-vb-canvas-updated', ''); up.setAttribute('aria-live', 'polite'); up.hidden = true; sum.appendChild(up);++ var chev = el('span', 'vbc-chev', '▾'); chev.setAttribute('aria-hidden', 'true'); sum.appendChild(chev);++ box.appendChild(sum); box.appendChild(canvasBody());++ dock.insertBefore(box, dock.firstChild);++ // Closed first; the person's choice lasts for this visit, as on Actor.++ var key = 'ov-canvas-open:' + canvas.thread;++ try { if (window.sessionStorage && sessionStorage.getItem(key) === '1') box.open = true; } catch (e) { /* private mode */ }++ box.addEventListener('toggle', function () {++ try { if (window.sessionStorage) sessionStorage.setItem(key, box.open ? '1' : '0'); } catch (e) { /* private mode */ }++ });++ return box;++ }++ function canvasPaint(doc) {++ if (!canvas || !doc || !window.OVCanvas) return;++ // A canvas never acts here: the buttons live on the Actor thread the link opens. The checklist draws disabled++ // because no onCheck is given, which is the renderer's own read-only mode.++ var shown = {++ canvas: doc.canvas, version: doc.version, title: doc.title, updated_at: doc.updated_at,++ blocks: (Array.isArray(doc.blocks) ? doc.blocks : []).filter(function (b) { return b && b.type !== 'button'; }),++ };++ if (!window.OVCanvas.valid(shown)) return;++ [canvasWide(), canvasPhone()].forEach(function (box) {++ var host = box && box.querySelector('[data-vb-canvas-host]');++ if (host) window.OVCanvas.render(host, shown, {});++ });++ [].forEach.call(app.querySelectorAll('[data-vb-canvas-count]'), function (node) { node.textContent = canvasItems(shown.blocks.length); });++ canvasVersion = Number(doc.version) || canvasVersion;++ }++ function canvasClear() {++ [].forEach.call(app.querySelectorAll('[data-vb-canvas]'), function (box) { box.remove(); });++ var root = canvasRoot(); if (root) root.classList.remove('has-panel');++ canvasVersion = 0;++ }++ function canvasUpdated() {++ var node = app.querySelector('.vbc-phone [data-vb-canvas-updated]');++ if (!node) return;++ node.hidden = false;++ if (canvasUpdatedTimer) clearTimeout(canvasUpdatedTimer);++ canvasUpdatedTimer = setTimeout(function () { node.hidden = true; canvasUpdatedTimer = 0; }, 4000);++ }++ function canvasSchedule() {++ if (canvasTimer) { clearTimeout(canvasTimer); canvasTimer = 0; }++ if (!canvasIsOpen()) return;++ canvasTimer = setTimeout(function () { canvasTimer = 0; canvasFetch(); }, 30000);++ }++ function canvasFetch() {++ if (!canvas || !cfg.signedIn || canvasBusy || !CANVAS_THREAD.test(String(canvas.thread || ''))) return;++ var tok = token(); if (!tok) return;++ canvasBusy = true;++ fetch(ACTOR + '/api/v1/me/canvases?thread=' + encodeURIComponent(canvas.thread), {++ // The person's own Bearer, no credentials mode: Actor refuses a cookie from another site.++ credentials: 'omit', headers: { authorization: 'Bearer ' + tok, accept: 'application/json' },++ }).then(function (r) {++ if (r.status === 401 || r.status === 403) return { stop: true };++ return r.ok ? r.json() : null;++ }).then(function (j) {++ if (j && j.stop) { canvas = null; canvasClear(); return; }++ var doc = j && Array.isArray(j.canvases) ? j.canvases[0] : null;++ if (!doc) { canvasClear(); return; }++ if (canvasVersion && Number(doc.version) > canvasVersion) canvasUpdated();++ canvasPaint(doc);++ }).catch(function () { /* offline: the panel stays as it was, and asks again while the task runs */ })++ .then(function () { canvasBusy = false; canvasSchedule(); });++ }++ function canvasRefreshNow() {++ if (canvasTimer) { clearTimeout(canvasTimer); canvasTimer = 0; }++ canvasFetch();++ }++ function canvasWatch(task) {++ var thread = task && task.actor_task ? String(task.actor_task) : '';++ if (!CANVAS_THREAD.test(thread)) return false;++ canvas = { thread: thread, task: task.id, status: task.status };++ canvasClear();++ canvasRefreshNow();++ return true;++ }++ function canvasTaskStatus(task) {++ if (!canvas || !task || Number(task.id) !== Number(canvas.task)) return;++ var status = String(task.status || canvas.status);++ if (status === canvas.status) return;++ canvas.status = status;++ canvasRefreshNow();++ }++ function canvasInit() { if (canvas) canvasFetch(); }++// ── Live ──function catchUp() {if (!room) return Promise.resolve();@@@ -365,6 +500,7 @@else if (m.type === 'room_ai_typing' && m.room === room) { showWait(); if (nearBottom()) toBottom(); }else if (m.type === 'room_ai_delta' && m.room === room) streamText(m.text);else if (m.type === 'room_message_deleted' && m.room === room) removeMessage(m.id);++ else if (m.type === 'room_task' && m.room === room) canvasTaskStatus(m.task);else if (m.type === 'auth_revoked') notice('You were signed out. Sign in again to keep chatting.');};ws.onclose = function () { ws = null; if (room) { setTimeout(connect, delay); delay = Math.min(delay * 2, 30000); } };@@@ -461,6 +597,8 @@.then(function (x) {if (!x.ok) { putBack((x.d && x.d.error) || 'That did not send. Try again.'); return; }if (x.d.message && x.d.message.id) { mine.setAttribute('data-me', String(x.d.message.id)); latest = Math.max(latest, Number(x.d.message.id)); }++ // A Research, Agent or Code task just became the chat's newest: its canvas is the one to draw from now on.++ if (x.d && x.d.task && !canvasWatch(x.d.task)) { canvas = null; canvasClear(); }if (!room && x.d.room) {room = x.d.room.slug;history.replaceState(null, '', '/vibe/' + room);@@@ -491,6 +629,7 @@.catch(function () { notice('You seem to be offline.'); });});if (cfg.waiting) showWait();++ canvasInit();connect();window.addEventListener('pagehide', function () { var w = ws; room = null; if (w) w.close(); });})();@@@ -30,6 +30,7 @@ const LIMITS = { title: 200, details: 6000, result: 20000, error: 500, url: 2000const PER_PERSON_HOUR = 20;const PER_ROOM_DAY = 300;const POLL_MS = 10_000;++const ACTOR_TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;class WorkError extends Error { constructor(status, code, message) { super(message); this.status = status; this.code = code; } }const fail = (status, code, message) => { throw new WorkError(status, code, message); };@@@ -44,7 +45,7 @@ function shape(t, room) {try { sources = JSON.parse(t.sources || '[]'); } catch { sources = []; }return {id: Number(t.id), title: t.title, details: t.details || '', kind: t.kind, status: t.status, via: t.via,-- quick: QUICK.has(t.kind),++ quick: QUICK.has(t.kind), actor_task: t.actor_task || null,requested_by: { id: Number(t.requested_by), username: t.requester_username || null, name: t.requester_name || t.requester_username || 'someone' },runner: t.runner || null, result: t.result || null, result_url: t.result_url || null, sources: Array.isArray(sources) ? sources : [],error: t.error || null, cost_usd: t.cost_usd == null ? null : Number(t.cost_usd), how: t.how || null,@@@ -83,6 +84,18 @@ async function get(room, user, id) {return shape(t, room);}++/**++ * The newest task in a chat when OpenVibe.Actor holds it: its thread is the conversation whose canvas Chat draws++ * beside a Vibe chat (plan HO9). A newer task a worker took, or one Actor refused, has no thread, so there is nothing++ * to draw beside it even when an older Actor task had a canvas.++ */++async function newestActor(room, user) {++ await mustRead(room, user);++ const t = await db.get(`${TASK_SELECT} WHERE t.room_id = ? ORDER BY t.id DESC LIMIT 1`, [room.id]);++ if (!t || !t.actor_task || !ACTOR_TASK.test(String(t.actor_task))) return null;++ return { id: Number(t.id), thread: String(t.actor_task), status: t.status };++}++/** The agent's step for this task (design 2026-11-07): a title, an outcome and a link, never the person's words. */async function announceStep(room, task, { topic = null, status = null, ...rest } = {}) {if (!task) return null;@@@ -369,4 +382,4 @@ function stop() { if (timer) clearInterval(timer); timer = null; }function _setTokens(t) { tokens = t; }--module.exports = { KINDS, QUICK, WorkError, list, get, add, cancel, claim, report, runOnComputer, poll, announce, start, stop, shape, _setTokens };++module.exports = { KINDS, QUICK, WorkError, list, get, newestActor, add, cancel, claim, report, runOnComputer, poll, announce, start, stop, shape, _setTokens };@@@ -89,7 +89,9 @@ function contentSecurityPolicy(nonce, wsOrigin = 'wss://openvibe.chat') {"font-src 'self' data:",// https://openvibe.chat: on Chat's other host (openvibe.party) the shared navbar's Messages count and the Dock// still call Chat's DM API there (openvibe.party is on the credentialed CORS list, server/app.js).-- `connect-src 'self' ${wsOrigin} https://openvibe.chat https://openvibe.network wss://openvibe.network https://openvibe.events wss://openvibe.events https://cloudflareinsights.com`,++ // https://openvibe.actor: a Vibe chat draws the agent's canvas beside it (plan HO9), only the two canvas++ // GETs and only with the person's own Bearer (the Actor API's CORS route).++ `connect-src 'self' ${wsOrigin} https://openvibe.chat https://openvibe.actor https://openvibe.network wss://openvibe.network https://openvibe.events wss://openvibe.events https://cloudflareinsights.com`,// A workspace room's shared screen: a screen share page from OpenVibe.Bot (rooms.screen_url)."frame-src 'self' https://openvibe.bot","frame-ancestors 'self'",@@@ -106,7 +108,8 @@ function contentSecurityPolicy(nonce, wsOrigin = 'wss://openvibe.chat') {* rooms/messages for the navigation), script / callScript / ringScript (public/web/chat.js, call.js, ring.js: a call* coming in, on every page of a signed-in person while calls are on), app (Vibe's full-window app, server/web/vibe-pages.js:* vibe.css + vibe-md.js + vibe.js, and no footer), kit (the network's conversation UI, openvibe-shared/thread: kit.css,-- * thread.css, kit-web.js and ov-thread.js; Vibe's pages and the support inbox), appScript and appStyle (the app's own++ * thread.css, kit-web.js and ov-thread.js; Vibe's pages and the support inbox), canvas (openvibe-shared's ov-canvas.js++ * on Vibe's chat pages: the agent's panel beside the chat, plan HO9), appScript and appStyle (the app's own* script and stylesheet instead of vibe.js and vibe.css: inbox.js and inbox.css), feedView* (a feed's card on the page: feed-view.css + feed-view.js, server/feeds/), scripts (more of* public/web's scripts by name, e.g. support.js), footer ('full', the default, on front pages, directories and docs;@@@ -168,6 +171,7 @@ ${o.script ? `<script src="${asset('chat.js')}" defer></script>` : ''}${o.callScript ? `<script src="${asset('call.js')}" defer></script>` : ''}${o.ringScript ? `<script src="${asset('ring.js')}" defer></script>` : ''}${o.kit ? `<script src="${ovServe.url('kit-web.js')}" defer></script>\n<script src="${ovServe.url('ov-thread.js')}" defer></script>` : ''}++${o.canvas ? `<script src="${ovServe.url('ov-canvas.js')}" defer></script>` : ''}${o.app ? `<script src="${asset('vibe-md.js')}" defer></script>\n<script src="${asset(o.appScript || 'vibe.js')}" defer></script>` : ''}</head><body class="${[cls, o.app ? 'oc-app-body' : ''].filter(Boolean).join(' ')}"${scale}>@@@ -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 workspace = require('../rooms/workspace');const how = require('../rooms/how');const memory = require('../rooms/vibe-memory');const { esc } = require('./layout');@@@ -254,6 +255,11 @@ function mountVibePages(router, { page, viewerOf, needUser, sameSite, formLimit,const allowance = user ? await allowanceOf(user) : null;const messages = room ? await rooms.history(room, { limit: 200 }) : [];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++ // the person's own token (OpenVibe.Actor allows that one read from openvibe.chat), so a signed-out or refused++ // read simply draws nothing.++ 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;@@@ -283,7 +289,7 @@ ${MARK}center: !room, label: room ? 'Your chat with Vibe' : 'A new chat with Vibe',})}</div>`;await page(req, res, status, {-- actor, app: true, kit: true, active: 'vibe', path: room ? `/vibe/${room.slug}` : '/vibe',++ actor, app: true, kit: true, canvas: !!room, active: 'vibe', path: room ? `/vibe/${room.slug}` : '/vibe',title: room ? `${room.name} · Vibe` : 'Vibe, OpenVibe\'s assistant',fullTitle: room ? undefined : 'Vibe: chat with OpenVibe\'s assistant · OpenVibe.Chat',description: 'Chat with Vibe, OpenVibe\'s free assistant: it searches news, deals, jobs, rentals and videos across OpenVibe, reads pages you link and helps you think.',@@@ -292,6 +298,7 @@ ${MARK}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,++ canvas: canvas ? { thread: canvas.thread, task: canvas.id, status: canvas.status } : null,},});}nnew file mode 100644@@@ -0,0 +1,73 @@++'use strict';++/**++ * The agent's canvas beside a Vibe chat (plan HO9): the page names the newest Actor task's thread and loads++ * openvibe-shared's ov-canvas.js only on Vibe's chat pages; the browser reads the document with the person's own++ * token, and a newer non-Actor task (or no task) means there is nothing to draw. The client itself is read-only:++ * it asks OVCanvas for a drawn canvas without onCheck/onAsk, and filters buttons out before drawing.++ */++const assert = require('assert');++const { ids } = require('openvibe-contracts');++const { boot, suite } = require('./helpers');++++const t = suite('vibe canvas');++const TASK = 'tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2AB';++let h, ann, workspace, room, slug, task;++++const pageJson = (html) => JSON.parse((/window\.__OV_PAGE = (\{.*?\});\n/.exec(html) || [])[1].replace(/\\u003c/g, '<')).chat;++++t('boot: a Vibe chat whose newest task is Actor\'s', async () => {++ h = await boot();++ workspace = require('../server/rooms/workspace');++ workspace._setTokens({ getToken: async () => 'chat-actor-token' });++ ann = h.addUser('mira', { subject: ids.newId('user') });++ await h.ctx.upsertUser(h.live.users.get(ann.id));++ const realFetch = globalThis.fetch;++ globalThis.fetch = async (url, o = {}) => {++ if (!String(url).startsWith('http://127.0.0.1:4950')) return realFetch(url, o);++ return { ok: true, status: (o.method || 'GET') === 'POST' ? 201 : 200, json: async () => ({ id: TASK, state: 'running' }) };++ };++ const r = await h.http('POST', '/api/chat/vibe', { token: ann.token, body: { message: 'Plan three days in Lisbon for two', mode: 'research' } });++ assert.strictEqual(r.status, 201, r.text);++ slug = r.body.room.slug;++ task = r.body.task;++ assert.strictEqual(task.actor_task, TASK);++ room = await require('../server/rooms/rooms').bySlug(slug);++});++++t('the Vibe page names the Actor thread, loads ov-canvas.js, and allows the Actor origin', async () => {++ const p = await h.http('GET', `/vibe/${slug}`, { headers: { cookie: `ov_token=${ann.token}` } });++ assert.strictEqual(p.status, 200, p.text.slice(0, 300));++ const cfg = pageJson(p.text);++ assert.deepStrictEqual(cfg.canvas, { thread: TASK, task: task.id, status: 'running' });++ assert.match(p.text, /shared\/ov-canvas\.js/, 'the shared renderer is on the page');++ assert.match(String(p.headers.get('content-security-policy') || ''), /connect-src[^;]*https:\/\/openvibe\.actor/, 'the browser may read the canvas from Actor');++++ const home = await h.http('GET', '/', { headers: { cookie: `ov_token=${ann.token}` } });++ assert.strictEqual(home.status, 200);++ assert.ok(!/shared\/ov-canvas\.js/.test(home.text), 'ov-canvas.js is only on Vibe pages');++});++++t('a newer task that is not Actor\'s leaves nothing to draw', async () => {++ const user = h.live.users.get(ann.id);++ await h.db.getDb().prepare(`INSERT INTO room_tasks (room_id, title, details, kind, requested_by, requested_subject, via, status)++ VALUES (?, ?, '', 'code', ?, ?, 'person', 'queued')`).run(room.id, 'A task a person takes', ann.id, user.subject_id);++ const newest = await workspace.newestActor(room, user);++ assert.strictEqual(newest, null, 'the newest task has no Actor thread');++ const p = await h.http('GET', `/vibe/${slug}`, { headers: { cookie: `ov_token=${ann.token}` } });++ assert.strictEqual(p.status, 200);++ assert.strictEqual(pageJson(p.text).canvas, null);++ assert.match(p.text, /shared\/ov-canvas\.js/, 'the Vibe chat page still has the renderer for the next task');++});++++t('the client draws it read-only and refreshes only while the task runs', async () => {++ const src = require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'web', 'vibe.js'), 'utf8');++ assert.match(src, /Open in Actor to tick or press/);++ assert.match(src, /ACTOR \+ '\/chat\/' \+ encodeURIComponent\(canvas\.thread\)/);++ assert.match(src, /filter\(function \(b\) \{ return b && b\.type !== 'button'; \}\)/, 'buttons are not drawn');++ assert.match(src, /window\.OVCanvas\.render\(host, shown, \{\}\)/, 'no onCheck or onAsk: the canvas only shows');++ assert.match(src, /credentials: 'omit'/, 'the person\'s Bearer, no cookies');++ assert.match(src, /\}, 30000\);/, 'at most every 30 s while the task runs');++ assert.match(src, /m\.type === 'room_task'[\s\S]{0,120}canvasTaskStatus/, 'a task status change refreshes it');++});++++t.run();
@@ -206,3 +206,24 @@
@@ -206,3 +206,24 @@
.vbk .ovt-answer.vb-streaming { opacity: .92; }
.vbk .ovt-answer.vb-streaming { opacity: .92; }
/* Memory: a plain page in the frame; on a wide screen its head held only New chat, which the chats column has. */
/* Memory: a plain page in the frame; on a wide screen its head held only New chat, which the chats column has. */
@media (min-width: 761px) { .vbk .ovt.is-page .ovt-head { display: none; } }
@media (min-width: 761px) { .vbk .ovt.is-page .ovt-head { display: none; } }
+
+/* ── The canvas (plan HO9, actor.canvas@1) ───────────────────────────────────────────────────────────────────────
+ The agent's data panel beside a Vibe chat: the thread's third column from 1180 px (thread.css's own sticky panel),
+ and a closed, counted fold directly above the composer below that. Both hold the same document, drawn read-only by
+ openvibe-shared's ov-canvas.js; the one link opens the Actor thread where the person can tick or press. */
+.vbk .vbc { min-width: 0; }
+.vbk .vbc-count { flex: none; font-size: 12.5px; color: var(--text-muted, #7386a3); white-space: nowrap; }
+.vbk .vbc-body, .vbk .vbc-host { min-width: 0; }
+.vbk .vbc-body { display: grid; gap: 8px; }
+.vbk .vbc-link { justify-self: start; font-size: 12.5px; font-weight: 650; color: var(--accent-light, var(--ovt-accent, #60a5fa)); text-decoration: none; }
+.vbk .vbc-link:hover, .vbk .vbc-link:focus-visible { text-decoration: underline; }
+.vbk .vbc-phone { max-width: var(--vb-col); margin: 0 auto 8px; border: 1px solid var(--ovt-line); border-radius: 14px; background: var(--bg-card, #131c2e); overflow: hidden; contain: layout paint; }
+.vbk .vbc-phone > summary { display: flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 14px; cursor: pointer; font-size: 14px; font-weight: 650; list-style: none; color: var(--text-primary, #e6edf7); }
+.vbk .vbc-phone > summary::-webkit-details-marker { display: none; }
+.vbk .vbc-phone > summary::marker { content: ''; }
+.vbk .vbc-phone .vbc-updated { flex: none; font-size: 12.5px; font-weight: 650; color: var(--ovt-accent, #60a5fa); }
+.vbk .vbc-phone .vbc-chev { margin-left: auto; color: var(--text-muted, #7386a3); transition: transform .2s; }
+.vbk .vbc-phone[open] .vbc-chev { transform: rotate(180deg); }
+.vbk .vbc-phone .vbc-body { max-height: min(45vh, 420px); overflow: auto; overscroll-behavior: contain; padding: 0 10px 10px; }
+@media (min-width: 1180px) { .vbk .vbc-phone { display: none !important; } }
+@media (max-width: 1179px) { .vbk .vbc-wide { display: none !important; } }
@@ -341,6 +341,141 @@
@@ -341,6 +341,141 @@
if (stick) toBottom();
if (stick) toBottom();
}
}
+ // ── The canvas (plan HO9): the agent's data panel beside the chat ─────────────────────────────────────────────
+ // Only the newest task in the chat, and only when OpenVibe.Actor holds it (the task's actor_task is the tsk_…
+ // thread). The document is read in the browser with the person's own Network token — the Actor API allows that one
+ // read from openvibe.chat and refuses cookies — and drawn by openvibe-shared's ov-canvas.js. It is read-only here:
+ // checklist boxes disabled, buttons not drawn, and one link to the Actor thread where the person can tick or press.
+ // The wide third column and the closed fold above the composer share the same document, as on Actor.
+ var ACTOR = 'https://openvibe.actor';
+ var CANVAS_THREAD = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
+ var canvas = cfg.canvas && CANVAS_THREAD.test(String(cfg.canvas.thread || '')) ? cfg.canvas : null;
+ var canvasVersion = 0, canvasTimer = 0, canvasBusy = false, canvasUpdatedTimer = 0;
+ function canvasRoot() { return app.querySelector('.ovt[data-ovt]'); }
+ function canvasIsOpen() { return !!(canvas && (canvas.status === 'queued' || canvas.status === 'running')); }
+ function canvasItems(n) { return n + (n === 1 ? ' item' : ' items'); }
+ function canvasCountSpan() { var span = el('span', 'vbc-count'); span.setAttribute('data-vb-canvas-count', ''); return span; }
+ function canvasLink() {
+ var a = el('a', 'vbc-link', 'Open in Actor to tick or press');
+ a.href = ACTOR + '/chat/' + encodeURIComponent(canvas.thread);
+ a.target = '_blank'; a.rel = 'noopener';
+ return a;
+ }
+ function canvasBody() {
+ var body = el('div', 'vbc-body');
+ var host = el('div', 'vbc-host'); host.setAttribute('data-vb-canvas-host', '');
+ body.appendChild(host); body.appendChild(canvasLink());
+ return body;
+ }
+ function canvasWide() {
+ var root = canvasRoot(); if (!root || !canvas) return null;
+ var box = root.querySelector('.vbc-wide');
+ if (box) return box;
+ box = el('aside', 'ovt-panel vbc vbc-wide');
+ box.setAttribute('aria-label', 'Canvas'); box.setAttribute('data-vb-canvas', ''); box.setAttribute('data-thread', canvas.thread);
+ var head = el('div', 'ovt-panel-head');
+ head.appendChild(el('h2', null, 'Canvas')); head.appendChild(canvasCountSpan());
+ box.appendChild(head); box.appendChild(canvasBody());
+ root.appendChild(box); root.classList.add('has-panel');
+ return box;
+ }
+ function canvasPhone() {
+ var root = canvasRoot(); if (!root || !canvas) return null;
+ var box = root.querySelector('.vbc-phone');
+ if (box) return box;
+ var dock = root.querySelector('.ovt-dock'); if (!dock) return null;
+ box = el('details', 'vbc vbc-phone');
+ box.setAttribute('data-vb-canvas', ''); box.setAttribute('data-thread', canvas.thread);
+ var sum = el('summary');
+ sum.appendChild(el('span', 'vbc-label', 'Canvas'));
+ var sep = el('span', 'vbc-sep', '·'); sep.setAttribute('aria-hidden', 'true'); sum.appendChild(sep);
+ sum.appendChild(canvasCountSpan());
+ var up = el('span', 'vbc-updated', 'updated');
+ up.setAttribute('data-vb-canvas-updated', ''); up.setAttribute('aria-live', 'polite'); up.hidden = true; sum.appendChild(up);
+ var chev = el('span', 'vbc-chev', '▾'); chev.setAttribute('aria-hidden', 'true'); sum.appendChild(chev);
+ box.appendChild(sum); box.appendChild(canvasBody());
+ dock.insertBefore(box, dock.firstChild);
+ // Closed first; the person's choice lasts for this visit, as on Actor.
+ var key = 'ov-canvas-open:' + canvas.thread;
+ try { if (window.sessionStorage && sessionStorage.getItem(key) === '1') box.open = true; } catch (e) { /* private mode */ }
+ box.addEventListener('toggle', function () {
+ try { if (window.sessionStorage) sessionStorage.setItem(key, box.open ? '1' : '0'); } catch (e) { /* private mode */ }
+ });
+ return box;
+ }
+ function canvasPaint(doc) {
+ if (!canvas || !doc || !window.OVCanvas) return;
+ // A canvas never acts here: the buttons live on the Actor thread the link opens. The checklist draws disabled
+ // because no onCheck is given, which is the renderer's own read-only mode.
+ var shown = {
+ canvas: doc.canvas, version: doc.version, title: doc.title, updated_at: doc.updated_at,
+ blocks: (Array.isArray(doc.blocks) ? doc.blocks : []).filter(function (b) { return b && b.type !== 'button'; }),
+ };
+ if (!window.OVCanvas.valid(shown)) return;
+ [canvasWide(), canvasPhone()].forEach(function (box) {
+ var host = box && box.querySelector('[data-vb-canvas-host]');
+ if (host) window.OVCanvas.render(host, shown, {});
+ });
+ [].forEach.call(app.querySelectorAll('[data-vb-canvas-count]'), function (node) { node.textContent = canvasItems(shown.blocks.length); });
+ canvasVersion = Number(doc.version) || canvasVersion;
+ }
+ function canvasClear() {
+ [].forEach.call(app.querySelectorAll('[data-vb-canvas]'), function (box) { box.remove(); });
+ var root = canvasRoot(); if (root) root.classList.remove('has-panel');
+ canvasVersion = 0;
+ }
+ function canvasUpdated() {
+ var node = app.querySelector('.vbc-phone [data-vb-canvas-updated]');
+ if (!node) return;
+ node.hidden = false;
+ if (canvasUpdatedTimer) clearTimeout(canvasUpdatedTimer);
+ canvasUpdatedTimer = setTimeout(function () { node.hidden = true; canvasUpdatedTimer = 0; }, 4000);
+ }
+ function canvasSchedule() {
+ if (canvasTimer) { clearTimeout(canvasTimer); canvasTimer = 0; }
+ if (!canvasIsOpen()) return;
+ canvasTimer = setTimeout(function () { canvasTimer = 0; canvasFetch(); }, 30000);
+ }
+ function canvasFetch() {
+ if (!canvas || !cfg.signedIn || canvasBusy || !CANVAS_THREAD.test(String(canvas.thread || ''))) return;
+ var tok = token(); if (!tok) return;
+ canvasBusy = true;
+ fetch(ACTOR + '/api/v1/me/canvases?thread=' + encodeURIComponent(canvas.thread), {
+ // The person's own Bearer, no credentials mode: Actor refuses a cookie from another site.
+ credentials: 'omit', headers: { authorization: 'Bearer ' + tok, accept: 'application/json' },
+ }).then(function (r) {
+ if (r.status === 401 || r.status === 403) return { stop: true };
+ return r.ok ? r.json() : null;
+ }).then(function (j) {
+ if (j && j.stop) { canvas = null; canvasClear(); return; }
+ var doc = j && Array.isArray(j.canvases) ? j.canvases[0] : null;
+ if (!doc) { canvasClear(); return; }
+ if (canvasVersion && Number(doc.version) > canvasVersion) canvasUpdated();
+ canvasPaint(doc);
+ }).catch(function () { /* offline: the panel stays as it was, and asks again while the task runs */ })
+ .then(function () { canvasBusy = false; canvasSchedule(); });
+ }
+ function canvasRefreshNow() {
+ if (canvasTimer) { clearTimeout(canvasTimer); canvasTimer = 0; }
+ canvasFetch();
+ }
+ function canvasWatch(task) {
+ var thread = task && task.actor_task ? String(task.actor_task) : '';
+ if (!CANVAS_THREAD.test(thread)) return false;
+ canvas = { thread: thread, task: task.id, status: task.status };
+ canvasClear();
+ canvasRefreshNow();
+ return true;
+ }
+ function canvasTaskStatus(task) {
+ if (!canvas || !task || Number(task.id) !== Number(canvas.task)) return;
+ var status = String(task.status || canvas.status);
+ if (status === canvas.status) return;
+ canvas.status = status;
+ canvasRefreshNow();
+ }
+ function canvasInit() { if (canvas) canvasFetch(); }
+
// ── Live ──
// ── Live ──
function catchUp() {
function catchUp() {
if (!room) return Promise.resolve();
if (!room) return Promise.resolve();
@@ -365,6 +500,7 @@
@@ -365,6 +500,7 @@
else if (m.type === 'room_ai_typing' && m.room === room) { showWait(); if (nearBottom()) toBottom(); }
else if (m.type === 'room_ai_typing' && m.room === room) { showWait(); if (nearBottom()) toBottom(); }
else if (m.type === 'room_ai_delta' && m.room === room) streamText(m.text);
else if (m.type === 'room_ai_delta' && m.room === room) streamText(m.text);
else if (m.type === 'room_message_deleted' && m.room === room) removeMessage(m.id);
else if (m.type === 'room_message_deleted' && m.room === room) removeMessage(m.id);
+ else if (m.type === 'room_task' && m.room === room) canvasTaskStatus(m.task);
else if (m.type === 'auth_revoked') notice('You were signed out. Sign in again to keep chatting.');
else if (m.type === 'auth_revoked') notice('You were signed out. Sign in again to keep chatting.');
};
};
ws.onclose = function () { ws = null; if (room) { setTimeout(connect, delay); delay = Math.min(delay * 2, 30000); } };
ws.onclose = function () { ws = null; if (room) { setTimeout(connect, delay); delay = Math.min(delay * 2, 30000); } };
@@ -461,6 +597,8 @@
@@ -461,6 +597,8 @@
.then(function (x) {
.then(function (x) {
if (!x.ok) { putBack((x.d && x.d.error) || 'That did not send. Try again.'); return; }
if (!x.ok) { putBack((x.d && x.d.error) || 'That did not send. Try again.'); return; }
if (x.d.message && x.d.message.id) { mine.setAttribute('data-me', String(x.d.message.id)); latest = Math.max(latest, Number(x.d.message.id)); }
if (x.d.message && x.d.message.id) { mine.setAttribute('data-me', String(x.d.message.id)); latest = Math.max(latest, Number(x.d.message.id)); }
+ // A Research, Agent or Code task just became the chat's newest: its canvas is the one to draw from now on.
+ if (x.d && x.d.task && !canvasWatch(x.d.task)) { canvas = null; canvasClear(); }
if (!room && x.d.room) {
if (!room && x.d.room) {
room = x.d.room.slug;
room = x.d.room.slug;
history.replaceState(null, '', '/vibe/' + room);
history.replaceState(null, '', '/vibe/' + room);
@@ -491,6 +629,7 @@
@@ -491,6 +629,7 @@
.catch(function () { notice('You seem to be offline.'); });
.catch(function () { notice('You seem to be offline.'); });
});
});
if (cfg.waiting) showWait();
if (cfg.waiting) showWait();
+ 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(); });
})();
})();
@@ -30,6 +30,7 @@ const LIMITS = { title: 200, details: 6000, result: 20000, error: 500, url: 2000
@@ -30,6 +30,7 @@ const LIMITS = { title: 200, details: 6000, result: 20000, error: 500, url: 2000
const PER_PERSON_HOUR = 20;
const PER_PERSON_HOUR = 20;
const PER_ROOM_DAY = 300;
const PER_ROOM_DAY = 300;
const POLL_MS = 10_000;
const POLL_MS = 10_000;
+const ACTOR_TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
class WorkError extends Error { constructor(status, code, message) { super(message); this.status = status; this.code = code; } }
class WorkError extends Error { constructor(status, code, message) { super(message); this.status = status; this.code = code; } }
const fail = (status, code, message) => { throw new WorkError(status, code, message); };
const fail = (status, code, message) => { throw new WorkError(status, code, message); };
@@ -44,7 +45,7 @@ function shape(t, room) {
@@ -44,7 +45,7 @@ function shape(t, room) {
try { sources = JSON.parse(t.sources || '[]'); } catch { sources = []; }
try { sources = JSON.parse(t.sources || '[]'); } catch { sources = []; }
return {
return {
id: Number(t.id), title: t.title, details: t.details || '', kind: t.kind, status: t.status, via: t.via,
id: Number(t.id), title: t.title, details: t.details || '', kind: t.kind, status: t.status, via: t.via,
- quick: QUICK.has(t.kind),
+ quick: QUICK.has(t.kind), actor_task: t.actor_task || null,
requested_by: { id: Number(t.requested_by), username: t.requester_username || null, name: t.requester_name || t.requester_username || 'someone' },
requested_by: { id: Number(t.requested_by), username: t.requester_username || null, name: t.requester_name || t.requester_username || 'someone' },
runner: t.runner || null, result: t.result || null, result_url: t.result_url || null, sources: Array.isArray(sources) ? sources : [],
runner: t.runner || null, result: t.result || null, result_url: t.result_url || null, sources: Array.isArray(sources) ? sources : [],
error: t.error || null, cost_usd: t.cost_usd == null ? null : Number(t.cost_usd), how: t.how || null,
error: t.error || null, cost_usd: t.cost_usd == null ? null : Number(t.cost_usd), how: t.how || null,
@@ -83,6 +84,18 @@ async function get(room, user, id) {
@@ -83,6 +84,18 @@ async function get(room, user, id) {
return shape(t, room);
return shape(t, room);
}
}
+/**
+ * The newest task in a chat when OpenVibe.Actor holds it: its thread is the conversation whose canvas Chat draws
+ * beside a Vibe chat (plan HO9). A newer task a worker took, or one Actor refused, has no thread, so there is nothing
+ * to draw beside it even when an older Actor task had a canvas.
+ */
+async function newestActor(room, user) {
+ await mustRead(room, user);
+ const t = await db.get(`${TASK_SELECT} WHERE t.room_id = ? ORDER BY t.id DESC LIMIT 1`, [room.id]);
+ if (!t || !t.actor_task || !ACTOR_TASK.test(String(t.actor_task))) return null;
+ return { id: Number(t.id), thread: String(t.actor_task), status: t.status };
+}
+
/** The agent's step for this task (design 2026-11-07): a title, an outcome and a link, never the person's words. */
/** The agent's step for this task (design 2026-11-07): a title, an outcome and a link, never the person's words. */
async function announceStep(room, task, { topic = null, status = null, ...rest } = {}) {
async function announceStep(room, task, { topic = null, status = null, ...rest } = {}) {
if (!task) return null;
if (!task) return null;
@@ -369,4 +382,4 @@ function stop() { if (timer) clearInterval(timer); timer = null; }
@@ -369,4 +382,4 @@ function stop() { if (timer) clearInterval(timer); timer = null; }
function _setTokens(t) { tokens = t; }
function _setTokens(t) { tokens = t; }
-module.exports = { KINDS, QUICK, WorkError, list, get, add, cancel, claim, report, runOnComputer, poll, announce, start, stop, shape, _setTokens };
+module.exports = { KINDS, QUICK, WorkError, list, get, newestActor, add, cancel, claim, report, runOnComputer, poll, announce, start, stop, shape, _setTokens };
@@ -89,7 +89,9 @@ function contentSecurityPolicy(nonce, wsOrigin = 'wss://openvibe.chat') {
@@ -89,7 +89,9 @@ function contentSecurityPolicy(nonce, wsOrigin = 'wss://openvibe.chat') {
"font-src 'self' data:",
"font-src 'self' data:",
// https://openvibe.chat: on Chat's other host (openvibe.party) the shared navbar's Messages count and the Dock
// https://openvibe.chat: on Chat's other host (openvibe.party) the shared navbar's Messages count and the Dock
// still call Chat's DM API there (openvibe.party is on the credentialed CORS list, server/app.js).
// still call Chat's DM API there (openvibe.party is on the credentialed CORS list, server/app.js).
- `connect-src 'self' ${wsOrigin} https://openvibe.chat https://openvibe.network wss://openvibe.network https://openvibe.events wss://openvibe.events https://cloudflareinsights.com`,
+ // https://openvibe.actor: a Vibe chat draws the agent's canvas beside it (plan HO9), only the two canvas
+ // GETs and only with the person's own Bearer (the Actor API's CORS route).
+ `connect-src 'self' ${wsOrigin} https://openvibe.chat https://openvibe.actor https://openvibe.network wss://openvibe.network https://openvibe.events wss://openvibe.events https://cloudflareinsights.com`,
// A workspace room's shared screen: a screen share page from OpenVibe.Bot (rooms.screen_url).
// A workspace room's shared screen: a screen share page from OpenVibe.Bot (rooms.screen_url).
"frame-src 'self' https://openvibe.bot",
"frame-src 'self' https://openvibe.bot",
"frame-ancestors 'self'",
"frame-ancestors 'self'",
@@ -106,7 +108,8 @@ function contentSecurityPolicy(nonce, wsOrigin = 'wss://openvibe.chat') {
@@ -106,7 +108,8 @@ function contentSecurityPolicy(nonce, wsOrigin = 'wss://openvibe.chat') {
* rooms/messages for the navigation), script / callScript / ringScript (public/web/chat.js, call.js, ring.js: a call
* rooms/messages for the navigation), script / callScript / ringScript (public/web/chat.js, call.js, ring.js: a call
* coming in, on every page of a signed-in person while calls are on), app (Vibe's full-window app, server/web/vibe-pages.js:
* coming in, on every page of a signed-in person while calls are on), app (Vibe's full-window app, server/web/vibe-pages.js:
* vibe.css + vibe-md.js + vibe.js, and no footer), kit (the network's conversation UI, openvibe-shared/thread: kit.css,
* vibe.css + vibe-md.js + vibe.js, and no footer), kit (the network's conversation UI, openvibe-shared/thread: kit.css,
- * thread.css, kit-web.js and ov-thread.js; Vibe's pages and the support inbox), appScript and appStyle (the app's own
+ * thread.css, kit-web.js and ov-thread.js; Vibe's pages and the support inbox), canvas (openvibe-shared's ov-canvas.js
+ * on Vibe's chat pages: the agent's panel beside the chat, plan HO9), appScript and appStyle (the app's own
* script and stylesheet instead of vibe.js and vibe.css: inbox.js and inbox.css), feedView
* script and stylesheet instead of vibe.js and vibe.css: inbox.js and inbox.css), feedView
* (a feed's card on the page: feed-view.css + feed-view.js, server/feeds/), scripts (more of
* (a feed's card on the page: feed-view.css + feed-view.js, server/feeds/), scripts (more of
* public/web's scripts by name, e.g. support.js), footer ('full', the default, on front pages, directories and docs;
* public/web's scripts by name, e.g. support.js), footer ('full', the default, on front pages, directories and docs;
@@ -168,6 +171,7 @@ ${o.script ? `<script src="${asset('chat.js')}" defer></script>` : ''}
@@ -168,6 +171,7 @@ ${o.script ? `<script src="${asset('chat.js')}" defer></script>` : ''}
${o.callScript ? `<script src="${asset('call.js')}" defer></script>` : ''}
${o.callScript ? `<script src="${asset('call.js')}" defer></script>` : ''}
${o.ringScript ? `<script src="${asset('ring.js')}" defer></script>` : ''}
${o.ringScript ? `<script src="${asset('ring.js')}" defer></script>` : ''}
${o.kit ? `<script src="${ovServe.url('kit-web.js')}" defer></script>\n<script src="${ovServe.url('ov-thread.js')}" defer></script>` : ''}
${o.kit ? `<script src="${ovServe.url('kit-web.js')}" defer></script>\n<script src="${ovServe.url('ov-thread.js')}" defer></script>` : ''}
+${o.canvas ? `<script src="${ovServe.url('ov-canvas.js')}" defer></script>` : ''}
${o.app ? `<script src="${asset('vibe-md.js')}" defer></script>\n<script src="${asset(o.appScript || 'vibe.js')}" defer></script>` : ''}
${o.app ? `<script src="${asset('vibe-md.js')}" defer></script>\n<script src="${asset(o.appScript || 'vibe.js')}" defer></script>` : ''}
</head>
</head>
<body class="${[cls, o.app ? 'oc-app-body' : ''].filter(Boolean).join(' ')}"${scale}>
<body class="${[cls, o.app ? 'oc-app-body' : ''].filter(Boolean).join(' ')}"${scale}>
@@ -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 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');
const { esc } = require('./layout');
const { esc } = require('./layout');
@@ -254,6 +255,11 @@ function mountVibePages(router, { page, viewerOf, needUser, sameSite, formLimit,
@@ -254,6 +255,11 @@ function mountVibePages(router, { page, viewerOf, needUser, sameSite, formLimit,
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 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
+ // that task's thread is the conversation the panel belongs to. The browser reads the document itself with
+ // the person's own token (OpenVibe.Actor allows that one read from openvibe.chat), so a signed-out or refused
+ // read simply draws nothing.
+ 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;
@@ -283,7 +289,7 @@ ${MARK}
@@ -283,7 +289,7 @@ ${MARK}
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, {
- actor, app: true, kit: true, active: 'vibe', path: room ? `/vibe/${room.slug}` : '/vibe',
+ actor, app: true, kit: true, canvas: !!room, active: 'vibe', path: room ? `/vibe/${room.slug}` : '/vibe',
title: room ? `${room.name} · Vibe` : 'Vibe, OpenVibe\'s assistant',
title: room ? `${room.name} · Vibe` : 'Vibe, OpenVibe\'s assistant',
fullTitle: room ? undefined : 'Vibe: chat with OpenVibe\'s assistant · OpenVibe.Chat',
fullTitle: room ? undefined : 'Vibe: chat with OpenVibe\'s assistant · OpenVibe.Chat',
description: 'Chat with Vibe, OpenVibe\'s free assistant: it searches news, deals, jobs, rentals and videos across OpenVibe, reads pages you link and helps you think.',
description: 'Chat with Vibe, OpenVibe\'s free assistant: it searches news, deals, jobs, rentals and videos across OpenVibe, reads pages you link and helps you think.',
@@ -292,6 +298,7 @@ ${MARK}
@@ -292,6 +298,7 @@ ${MARK}
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,
+ canvas: canvas ? { thread: canvas.thread, task: canvas.id, status: canvas.status } : null,
},
},
});
});
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,73 @@
@@ -0,0 +1,73 @@
+'use strict';
+/**
+ * The agent's canvas beside a Vibe chat (plan HO9): the page names the newest Actor task's thread and loads
+ * openvibe-shared's ov-canvas.js only on Vibe's chat pages; the browser reads the document with the person's own
+ * token, and a newer non-Actor task (or no task) means there is nothing to draw. The client itself is read-only:
+ * it asks OVCanvas for a drawn canvas without onCheck/onAsk, and filters buttons out before drawing.
+ */
+const assert = require('assert');
+const { ids } = require('openvibe-contracts');
+const { boot, suite } = require('./helpers');
+
+const t = suite('vibe canvas');
+const TASK = 'tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2AB';
+let h, ann, workspace, room, slug, task;
+
+const pageJson = (html) => JSON.parse((/window\.__OV_PAGE = (\{.*?\});\n/.exec(html) || [])[1].replace(/\\u003c/g, '<')).chat;
+
+t('boot: a Vibe chat whose newest task is Actor\'s', async () => {
+ h = await boot();
+ workspace = require('../server/rooms/workspace');
+ workspace._setTokens({ getToken: async () => 'chat-actor-token' });
+ ann = h.addUser('mira', { subject: ids.newId('user') });
+ await h.ctx.upsertUser(h.live.users.get(ann.id));
+ const realFetch = globalThis.fetch;
+ globalThis.fetch = async (url, o = {}) => {
+ if (!String(url).startsWith('http://127.0.0.1:4950')) return realFetch(url, o);
+ return { ok: true, status: (o.method || 'GET') === 'POST' ? 201 : 200, json: async () => ({ id: TASK, state: 'running' }) };
+ };
+ const r = await h.http('POST', '/api/chat/vibe', { token: ann.token, body: { message: 'Plan three days in Lisbon for two', mode: 'research' } });
+ assert.strictEqual(r.status, 201, r.text);
+ slug = r.body.room.slug;
+ task = r.body.task;
+ assert.strictEqual(task.actor_task, TASK);
+ room = await require('../server/rooms/rooms').bySlug(slug);
+});
+
+t('the Vibe page names the Actor thread, loads ov-canvas.js, and allows the Actor origin', async () => {
+ const p = await h.http('GET', `/vibe/${slug}`, { headers: { cookie: `ov_token=${ann.token}` } });
+ assert.strictEqual(p.status, 200, p.text.slice(0, 300));
+ const cfg = pageJson(p.text);
+ assert.deepStrictEqual(cfg.canvas, { thread: TASK, task: task.id, status: 'running' });
+ assert.match(p.text, /shared\/ov-canvas\.js/, 'the shared renderer is on the page');
+ assert.match(String(p.headers.get('content-security-policy') || ''), /connect-src[^;]*https:\/\/openvibe\.actor/, 'the browser may read the canvas from Actor');
+
+ const home = await h.http('GET', '/', { headers: { cookie: `ov_token=${ann.token}` } });
+ assert.strictEqual(home.status, 200);
+ assert.ok(!/shared\/ov-canvas\.js/.test(home.text), 'ov-canvas.js is only on Vibe pages');
+});
+
+t('a newer task that is not Actor\'s leaves nothing to draw', async () => {
+ const user = h.live.users.get(ann.id);
+ await h.db.getDb().prepare(`INSERT INTO room_tasks (room_id, title, details, kind, requested_by, requested_subject, via, status)
+ VALUES (?, ?, '', 'code', ?, ?, 'person', 'queued')`).run(room.id, 'A task a person takes', ann.id, user.subject_id);
+ const newest = await workspace.newestActor(room, user);
+ assert.strictEqual(newest, null, 'the newest task has no Actor thread');
+ const p = await h.http('GET', `/vibe/${slug}`, { headers: { cookie: `ov_token=${ann.token}` } });
+ assert.strictEqual(p.status, 200);
+ assert.strictEqual(pageJson(p.text).canvas, null);
+ assert.match(p.text, /shared\/ov-canvas\.js/, 'the Vibe chat page still has the renderer for the next task');
+});
+
+t('the client draws it read-only and refreshes only while the task runs', async () => {
+ const src = require('fs').readFileSync(require('path').join(__dirname, '..', 'public', 'web', 'vibe.js'), 'utf8');
+ assert.match(src, /Open in Actor to tick or press/);
+ assert.match(src, /ACTOR \+ '\/chat\/' \+ encodeURIComponent\(canvas\.thread\)/);
+ assert.match(src, /filter\(function \(b\) \{ return b && b\.type !== 'button'; \}\)/, 'buttons are not drawn');
+ assert.match(src, /window\.OVCanvas\.render\(host, shown, \{\}\)/, 'no onCheck or onAsk: the canvas only shows');
+ assert.match(src, /credentials: 'omit'/, 'the person\'s Bearer, no cookies');
+ assert.match(src, /\}, 30000\);/, 'at most every 30 s while the task runs');
+ assert.match(src, /m\.type === 'room_task'[\s\S]{0,120}canvasTaskStatus/, 'a task status change refreshes it');
+});
+
+t.run();