openvibers/openvibe.chat Commits
Chat: the Global room's AI insight, read and shown (#480)
Changes
nnew file mode 100644@@@ -0,0 +1,10 @@++-- phase: expand++-- A room's AI insight (GET /api/v1/rooms/:id/insight): the same summary row Global already keeps, now++-- with the window's people count and its topics (label + how many messages mentioned it) so one public++-- read answers the shape every room will use. The timeline gets a kind and an optional source message,++-- so a reader can tell a notable moment from a running topic. Both columns are nullable/defaulted:++-- older rows keep working, the job fills them from its next window.++ALTER TABLE chat_ai_summaries ADD COLUMN IF NOT EXISTS people bigint DEFAULT 0;++ALTER TABLE chat_ai_summaries ADD COLUMN IF NOT EXISTS topics_json text COLLATE "C" DEFAULT '[]';++ALTER TABLE chat_timeline_events ADD COLUMN IF NOT EXISTS kind text COLLATE "C" DEFAULT 'moment';++ALTER TABLE chat_timeline_events ADD COLUMN IF NOT EXISTS message_id bigint;@@@ -646,6 +646,16 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }.oc-pop-item { display: flex; align-items: center; gap: 10px; width: 100%; padding: 9px 10px; border-radius: 8px; background: transparent; color: var(--oc-text); font-weight: 600; text-align: left; }.oc-pop-item:hover { background: var(--oc-panel-2); }.oc-pop-item.oc-danger { color: var(--oc-red); }++/* A room's AI insight (server/web/pages.js insightBlock): plain text, topics and a short timeline. Only++ the parts a popover needs to stay readable on a phone; its look is the designer's. */++.oc-insight-pop { width: min(380px, calc(100vw - 32px)); max-height: min(70vh, 560px); overflow-y: auto; }++.oc-insight-pop > p { margin: 6px 8px; }++.oc-insight-topics, .oc-insight-timeline { list-style: none; margin: 8px; padding: 0; font-size: .88rem; }++.oc-insight-topics { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(92px, 100%), 1fr)); gap: 4px; }++.oc-insight-topics li { padding: 3px 8px; border: 1px solid var(--oc-line); border-radius: 999px; }++.oc-insight-timeline { display: grid; grid-template-columns: minmax(0, 1fr); gap: 4px; }++.oc-insight-timeline time { margin-right: 6px; color: var(--oc-muted); font-variant-numeric: tabular-nums; }++.oc-insight-meta { font-size: .78rem; }.oc-row-lock { display: inline-flex; color: var(--oc-muted); flex: none; }.oc-row-lock .oc-i { width: .9em; height: .9em; }@@@ -23,6 +23,7 @@ const config = require('../config');const db = require('../db/database');const aiClient = require('./client');const extractive = require('./extractive');++const consent = require('../net/consent');// ── Tunables (Live's) ────────────────────────────────────────────────────────const TICK_MS = config.ai.tickMs;@@@ -96,7 +97,11 @@ function _stampAdditions(additions, fallbackTs, nowMs) {let ts = fallbackTs;const mins = Number(a && a.mins_ago);if (Number.isFinite(mins) && mins >= 0 && mins <= 43200) ts = _sqlTime(now - mins * 60000);-- out.push({ ts, label, detail: _clip(a.detail || a.description || '', 240) });++ const messageId = a && a.message_id != null && Number.isFinite(Number(a.message_id)) ? Number(a.message_id) : null;++ out.push({++ ts, label, detail: _clip(a.detail || a.description || '', 240),++ kind: _clip(a && a.kind ? a.kind : 'moment', 20), message_id: messageId,++ });}return out;}@@@ -116,6 +121,42 @@ function _cleanTimeline(arr) {.sort((a, b) => _parseSqlTime(a.ts) - _parseSqlTime(b.ts));}++// The words people keep coming back to in the window: a topic with how many messages mentioned it.++// Extractive so the room read has topics even when the model wrote only prose.++const TOPIC_STOP = new Set(('the and for that this with you your are was were have has had not but all can '++ + 'just from they them their there here what when who how why will would could should about into over than then '++ + 'its it\'s i\'m dont don\'t im ive you\'re your\'re hes shes theyre we\'re thats whats lets get got like one '++ + 'now out up off too very really some any more most much many also only even still back well good bad lol lmao '++ + 'yeah yes nope ok okay hi hello hey thanks thank please bro dude guys guy chat room message messages people').split(' '));++function _topics(rows, limit = 8) {++ const counts = new Map();++ for (const r of rows || []) {++ const text = String((r && r.message) || '').toLowerCase()++ .replace(/https?:\/\/\S+/g, ' ')++ .replace(/:[a-z0-9_+-]+:/g, ' ')++ .replace(/@[a-z0-9_.-]+/g, ' ');++ for (const m of text.matchAll(/[a-z][a-z'’-]{2,}/g)) {++ const word = m[0].replace(/['’-]+$/g, '');++ if (word.length < 3 || TOPIC_STOP.has(word)) continue;++ counts.set(word, (counts.get(word) || 0) + 1);++ }++ }++ return [...counts.entries()]++ .filter(([, count]) => count >= 2)++ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))++ .slice(0, limit)++ .map(([label, count]) => ({ label: _clip(label, 40), count }));++}++/** How many different people wrote in the window (one per signed-in author, else per name). */++function _people(rows) {++ const seen = new Set();++ for (const r of rows || []) {++ const key = r && (r.user_id ? `u:${r.user_id}` : r.username ? `n:${String(r.username).toLowerCase()}` : null);++ if (key) seen.add(key);++ }++ return seen.size;++}++// Chat is the only writer of the two summaries tables since the C-04 cutover: no authority gate.// ── GLOBAL ───────────────────────────────────────────────────────────────────@@@ -138,8 +179,16 @@ async function _refreshGlobal() {const windowStart = _sqlTime(startMs);const windowLabel = _windowLabel(now - startMs);-- const rows = await db.getChatMessagesForAi({ sinceTs: windowStart, order: 'desc', limit: MAX_BATCH_MESSAGES });-- if (!rows.length) return false;++ // The room's read includes only people who allowed AI use of their messages (network.consent@1,++ // ai.train on kind chat). Anon and relay authors have no Network subject and are left out; when++ // Network cannot answer, the check fails closed and this window is skipped.++ const batch = await db.getChatMessagesForAi({ sinceTs: windowStart, order: 'desc', limit: MAX_BATCH_MESSAGES });++ if (!batch.length) return false;++ const rows = await consent.filterRows(batch);++ if (!rows.length) {++ console.log('[ChatAI] global window has no consenting messages; nothing summarised');++ return false;++ }const priorMemory = prior ? (prior.memory_json || '') : '';let priorTl = [];try { priorTl = JSON.parse(prior ? (prior.timeline_json || '[]') : '[]'); } catch { priorTl = []; }@@@ -165,8 +214,10 @@ async function _refreshGlobal() {window_label: windowLabel,window_start: windowStart,window_end: nowIso,++ people: _people(rows),++ topics_json: JSON.stringify(_topics(rows)),});-- console.log(`[ChatAI] global refreshed (${newCount} new, window=${windowLabel}, ${rows.length} msgs)`);++ console.log(`[ChatAI] global refreshed (${newCount} new, ${rows.length} consenting, window=${windowLabel})`);return true;}@@@ -438,7 +489,7 @@ async function getAnonInsight(anonId) {}module.exports = {-- start, stop, _tick, _msgData,++ start, stop, _tick, _msgData, _topics, _people,getGlobalInsight, getUserInsight, getRelayUserInsight, getAnonInsight,WORKFLOW_GLOBAL, WORKFLOW_PROFILE,};@@@ -33,7 +33,10 @@ function _timeline(rows, now) {.map((r) => {const label = _oneLine(r.message, 80);if (!label || _PLACEHOLDER.test(label)) return null;-- return { label, detail: _oneLine(r.username || r.channel_username || '', 120), mins_ago: _minsAgo(r.timestamp || r.created_at, now) };++ return {++ label, detail: _oneLine(r.username || r.channel_username || '', 120), mins_ago: _minsAgo(r.timestamp || r.created_at, now),++ kind: r.message_type && r.message_type !== 'chat' ? 'event' : 'message', message_id: r.id == null ? null : Number(r.id),++ };}).filter(Boolean);}@@@ -177,6 +177,8 @@ function createApp({ chatServer, relay, events = null, callServer = null, settin// The chat plays queue of an agent stream's room (Agent Streams S6): what the desk may read of it and// how it records that a request ran (the capabilities chat.play_queue.read and chat.play_queue.write).app.use('/api/v1/rooms', require('./chat/play-queue-routes').service);++ // A room's AI insight (plan 2026-11-10): public, one shape for Global and, in part 2, every room.++ app.use('/api/v1/rooms', require('./chat/room-insight-routes'));app.get('/health', (req, res) => res.json({ ok: true, service: 'chat' }));// Readiness in the openvibe-shared/ready shape (status ready/degraded/not_ready, named checks):// 503 only when the required check fails. `db` is Chat's own database, which it cannot servennew file mode 100644@@@ -0,0 +1,27 @@++'use strict';++/**++ * A room's public AI insight (plan 2026-11-10, part 1): GET /api/v1/rooms/:id/insight.++ *++ * One shape for every room — Global first, then a server's channels and rooms — so a reader never needs++ * to know which kind of room it opened. Global's stable id is `global`; a room not covered by the++ * rollout answers 404 rather than an empty shape, so a client can tell "no such room" from "quiet".++ */++const express = require('express');++const router = express.Router();++const roomInsight = require('./room-insight');++++router.get('/:id/insight', async (req, res) => {++ const id = String(req.params.id || '');++ if (id !== 'global') return res.status(404).json({ error: 'Room insight not found' });++ try {++ const insight = await roomInsight.global();++ // A minute of cache: the job rewrites at most every 30 minutes and the answer is the same for everyone.++ res.set('Cache-Control', 'public, max-age=60');++ return res.json(insight);++ } catch (err) {++ console.warn('[RoomInsight] global read failed:', err && err.message);++ return res.status(500).json({ error: 'Failed to load room insight' });++ }++});++++module.exports = router;nnew file mode 100644@@@ -0,0 +1,71 @@++'use strict';++/**++ * One room's AI insight, as GET /api/v1/rooms/:id/insight answers it (plan 2026-11-10, part 1: Global).++ *++ * The writer is the existing chat-AI job (server/ai/chat-ai.js): the same schedule, the same window and++ * budget rules, only when there were new messages, and only the messages of people who allowed AI use of++ * them. This module is the reader: it shapes the stored summary and the append-only timeline into one++ * answer for every room, so Live and Chat can read the same thing.++ *++ * { overview: { text, topics: [{ label, count }], people, messages, from, to, updated_at },++ * timeline: [{ at, kind, title, text, message_id }],++ * state: 'ready' | 'quiet' | 'off' }++ *++ * Quiet is said as quiet: when the last summary is old (or there is none), the overview text is empty and++ * only the stored timeline remains. The timeline is newest first, which is what someone arriving wants.++ */++const db = require('../db/database');++++const QUIET_AFTER_MS = 6 * 60 * 60 * 1000; // the job refreshes Global at least every 30 min when there is news++const TIMELINE_LIMIT = 40;++++function _time(s) { return s ? new Date(String(s).replace(' ', 'T') + 'Z').getTime() : 0; }++++function _topics(raw) {++ let list = [];++ try { list = JSON.parse(raw || '[]'); } catch { list = []; }++ if (!Array.isArray(list)) return [];++ return list++ .map((t) => ({ label: String((t && t.label) || '').trim().slice(0, 60), count: Math.max(0, Number(t && t.count) || 0) }))++ .filter((t) => t.label)++ .slice(0, 20);++}++++function _timeline(events) {++ return (events || []).map((e) => ({++ at: e.ts || null,++ kind: e.kind || 'moment',++ title: String(e.label || '').slice(0, 140),++ text: String(e.detail || ''),++ message_id: e.message_id == null ? null : Number(e.message_id),++ })).filter((e) => e.at && e.title);++}++++function _shape(row, events) {++ const fresh = !!(row && row.overview && row.window_end && Date.now() - _time(row.window_end) < QUIET_AFTER_MS);++ const state = fresh ? 'ready' : 'quiet';++ return {++ overview: {++ text: fresh ? String(row.overview) : '',++ topics: fresh ? _topics(row.topics_json) : [],++ people: fresh ? (Number(row.people) || 0) : 0,++ messages: fresh ? (Number(row.window_message_count) || 0) : 0,++ from: row ? (row.window_start || null) : null,++ to: row ? (row.window_end || null) : null,++ updated_at: row ? (row.updated_at || null) : null,++ },++ timeline: _timeline(events),++ state,++ };++}++++/** The Global room's insight. Other rooms answer through room-insight in part 2. */++async function global() {++ const [row, events] = await Promise.all([++ db.getChatAiSummary('global', 0, 'global'),++ db.getChatTimelineEvents({ scope: 'global', subjectId: 0, limit: TIMELINE_LIMIT }),++ ]);++ return _shape(row, events);++}++++module.exports = { global, _shape, _topics, QUIET_AFTER_MS };@@@ -1217,13 +1217,14 @@ async function upsertChatAiSummary(sfx) {scope, subject_id = 0, window, overview = '', memory_json = '', timeline_json = '[]',message_count = 0, window_message_count = 0, last_message_id = 0,window_label = '', window_start = null, window_end = null,++ people = 0, topics_json = '[]',} = sfx || {};return await transaction(async () => {const res = await run(`INSERT INTO chat_ai_summaries(scope, subject_id, "window", overview, memory_json, timeline_json, message_count,-- window_message_count, last_message_id, window_label, window_start, window_end, updated_at)-- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ov_now())++ window_message_count, last_message_id, window_label, window_start, window_end, people, topics_json, updated_at)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ov_now())ON CONFLICT(scope, subject_id, "window") DO UPDATE SEToverview = excluded.overview,memory_json = excluded.memory_json,@@@ -1234,9 +1235,11 @@ async function upsertChatAiSummary(sfx) {window_label = excluded.window_label,window_start = excluded.window_start,window_end = excluded.window_end,++ people = excluded.people,++ topics_json = excluded.topics_json,updated_at = ov_now()`,[scope, subject_id || 0, window, overview, memory_json, timeline_json, message_count,-- window_message_count, last_message_id, window_label, window_start, window_end]++ window_message_count, last_message_id, window_label, window_start, window_end, people, topics_json]);return _writeResult(res, 'chat_ai_summaries', [await get('SELECT * FROM chat_ai_summaries WHERE scope = ? AND subject_id = ? AND "window" = ?', [scope, subject_id || 0, window])]);});@@@ -1250,8 +1253,9 @@ async function addChatTimelineEvents(scope, subjectId, events) {for (const e of events) {if (!e || !e.label || !e.ts) continue;try {-- const res = await run('INSERT INTO chat_timeline_events (scope, subject_id, ts, label, detail) VALUES (?, ?, ?, ?, ?) ON CONFLICT DO NOTHING',-- [scope || 'global', subjectId || 0, e.ts, String(e.label).slice(0, 120), String(e.detail || '').slice(0, 400)]);++ const res = await run('INSERT INTO chat_timeline_events (scope, subject_id, ts, label, detail, kind, message_id) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING',++ [scope || 'global', subjectId || 0, e.ts, String(e.label).slice(0, 120), String(e.detail || '').slice(0, 400),++ String(e.kind || 'moment').slice(0, 20), e.message_id == null ? null : Number(e.message_id)]);if (res.changes) ids.push(Number(res.lastInsertRowid));n++; // Live counts every event it tried, the duplicates included} catch { /* */ }@@@ -1285,7 +1289,7 @@ async function getChatTimelineEvents({ scope = 'global', subjectId = 0, before =if (q && String(q).trim()) { const like = '%' + String(q).trim().slice(0, 60) + '%'; conds.push('(label ILIKE ? OR detail ILIKE ?)'); params.push(like, like); }params.push(Math.min(60, Math.max(1, limit)));try {-- return await all(`SELECT id, ts, label, detail FROM chat_timeline_events WHERE ${conds.join(' AND ')} ORDER BY ts DESC, id DESC LIMIT ?`, params) || [];++ return await all(`SELECT id, ts, label, detail, kind, message_id FROM chat_timeline_events WHERE ${conds.join(' AND ')} ORDER BY ts DESC, id DESC LIMIT ?`, params) || [];} catch { return []; }}@@@ -1301,7 +1305,7 @@ async function countChatMessagesSince(afterId, userId = null) {}// Fetch analyzable messages for AI batching (with the channel/broadcaster label).async function getChatMessagesForAi({ afterId = null, sinceTs = null, userId = null, limit = 400, order = 'asc' } = {}) {-- let sql = `SELECT cm.id, cm.user_id, cm.username, cm.message, cm.message_type, cm.timestamp,++ let sql = `SELECT cm.id, cm.user_id, cm.subject_id, cm.username, cm.message, cm.message_type, cm.timestamp,cm.stream_id, cm.channel_user_id, cm.is_global,ch.username AS channel_username, ch.display_name AS channel_displayFROM chat_messages cmnnew file mode 100644@@@ -0,0 +1,88 @@++'use strict';++/**++ * OpenVibe.Network's consent check, as Chat sees it for the room insight (plan L24: data and AI use by++ * consent, openvibe-contracts network.consent@1).++ *++ * allowedSubjects(subjects) POST /internal/consent/check { purpose: 'ai.train', kind: 'chat' }++ * with Chat's own service token (client `chat`, capability++ * network.consent.read). A person who has not allowed AI use of their++ * chat messages is left out of what the summariser reads.++ *++ * Fail-closed, exactly as the consent contract says: no token, an answer that is not ok, a timeout or a++ * Network that is down all mean "nobody allowed". An answer is cached 60 s per person; a failed call is++ * not retried for 30 s, so an outage costs one call, not one per tick. Nothing here logs a subject.++ */++const config = require('../config');++const auth = require('./service-auth');++++const CACHE_TTL_MS = 60_000;++const FAIL_TTL_MS = 30_000;++const MAX_BATCH = 500;++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++++const cache = new Map(); // subject → { at, allowed }++let blockedUntil = 0; // a failed call: nobody is allowed until this passes++let lastLog = '';++++function _log(message) {++ if (message === lastLog) return;++ lastLog = message;++ console.warn(`[Consent] ${message}`);++}++++async function _ask(batch) {++ const headers = await auth.headers('openvibe.network', 'network.consent.read');++ const res = await fetch(`${config.networkInternalUrl}/internal/consent/check`, {++ method: 'POST',++ headers: { ...headers, Accept: 'application/json', 'Content-Type': 'application/json' },++ body: JSON.stringify({ purpose: 'ai.train', kind: 'chat', subjects: batch }),++ signal: AbortSignal.timeout(5000),++ });++ if (!res.ok) throw new Error(`answered ${res.status}`);++ const data = await res.json().catch(() => null);++ return (data && data.allowed) || {};++}++++/**++ * Which of these subjects allowed AI use of their chat messages. Always a Set of subject ids; a subject++ * that was not asked (no Network account, a relay or anon author) is simply not in it.++ */++async function allowedSubjects(subjects) {++ const wanted = [...new Set((subjects || []).map(String).filter((s) => SUBJECT_RE.test(s)))];++ const now = Date.now();++ const allowed = new Set();++ const missing = [];++ for (const s of wanted) {++ const hit = cache.get(s);++ if (hit && now - hit.at < CACHE_TTL_MS) { if (hit.allowed) allowed.add(s); } else missing.push(s);++ }++ if (!missing.length || blockedUntil > now) return allowed;++ for (let i = 0; i < missing.length; i += MAX_BATCH) {++ const batch = missing.slice(i, i + MAX_BATCH);++ try {++ const answers = await _ask(batch);++ const at = Date.now();++ for (const s of batch) {++ const ok = answers[s] === true;++ cache.set(s, { at, allowed: ok });++ if (ok) allowed.add(s);++ else allowed.delete(s);++ }++ lastLog = '';++ } catch (err) {++ blockedUntil = Date.now() + FAIL_TTL_MS;++ _log(`check failed, leaving everyone out: ${(err && err.message) || err}`);++ return allowed; // fail closed; the people already cached as allowed stay++ }++ }++ return allowed;++}++++/** The rows whose author allowed AI use. Rows without a Network subject (anon, relay, deleted) are left out. */++async function filterRows(rows) {++ const list = Array.isArray(rows) ? rows : [];++ const allowed = await allowedSubjects(list.map((r) => r && r.subject_id).filter(Boolean));++ return list.filter((r) => r && r.subject_id && allowed.has(String(r.subject_id)));++}++++module.exports = { allowedSubjects, filterRows, _reset() { cache.clear(); blockedUntil = 0; lastLog = ''; } };@@@ -276,6 +276,10 @@ function createWebRoutes({ config }) {: `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=%2F">Sign in with OpenVibe</a> to chat. Anyone can read along.</p>`;const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';const signedIn = actor.kind === 'user';++ // The room's AI insight (server/chat/room-insight.js): a local read of the stored summary, so the page++ // never waits on OpenVibe.AI. Public like the room: signed out too.++ const insight = await require('../chat/room-insight').global().catch(() => null);++ const insightHtml = insight ? insightBlock(insight) : '';// Around the room: the real activity (server/web/home.js, read once a minute). Someone signed in gets the app: the// room first at the screen's height, Ask Vibe in one line, compact lists, and no introduction or questions (those,// the hero and the full footer, are for people who are new here, and for search).@@@ -318,7 +322,7 @@ function createWebRoutes({ config }) {rail: shell.rail({ global: true, dmUnread: dms.reduce((n, d) => n + d.unread, 0), servers: shell.railServers(mineServers) }),side: shell.sideHome({ dms, friends: contacts.friends, requests: contacts.requests, you: { name: me.display_name || me.username, status: 'Online', avatarUrl: me.avatar_url || '' } }),sideLabel: 'Home',-- main: `<header class="oc-chan-head">${shell.toList}<h1 id="oc-h" class="oc-chan-title">Global chat</h1><span class="oc-muted oc-chan-topic">Everyone on OpenVibe${home.peopleLine(activity)}</span><span class="oc-chan-tools"><span class="oc-live" id="oc-live" hidden>● live</span><a class="oc-icon-btn" href="/vibe" aria-label="Chat with Vibe" title="Chat with Vibe">${IC.spark}</a><a class="oc-icon-btn" href="/settings" aria-label="Chat settings" title="Chat settings">${IC.gear}</a></span></header>++ main: `<header class="oc-chan-head">${shell.toList}<h1 id="oc-h" class="oc-chan-title">Global chat</h1><span class="oc-muted oc-chan-topic">Everyone on OpenVibe${home.peopleLine(activity)}</span><span class="oc-chan-tools"><span class="oc-live" id="oc-live" hidden>● live</span><a class="oc-icon-btn" href="/vibe" aria-label="Chat with Vibe" title="Chat with Vibe">${IC.spark}</a><a class="oc-icon-btn" href="/settings" aria-label="Chat settings" title="Chat settings">${IC.gear}</a></span>${insightHtml}</header>${error}${feedList}${composer}`,@@@ -341,6 +345,7 @@ ${parts.vibe}<section class="oc-room" aria-labelledby="oc-h"><h2 id="oc-h">Global chat</h2><p class="oc-muted">One room for everyone on OpenVibe: streamers, viewers and players, from every site. <span class="oc-live" id="oc-live" hidden>● live</span></p>++${insightHtml}${error}${feedList}${composer}@@@ -367,6 +372,25 @@ ${foldBlock({ id: 'more', head: '<h2 id="oc-h-more">More on OpenVibe</h2>', html// ── Direct messages ──const dmApi = require('../chat/dm-routes');const { ICONS: IC } = require('./icons');++ /**++ * The Global room's AI insight, plain markup behind the head's AI button (plan 2026-11-10, part 1).++ * Server-rendered, so it reads without JavaScript; no JavaScript is needed to open it either (<details>).++ * The look is Fable's: this is only text, topics and the recent timeline in kit classes.++ */++ const insightBlock = (insight) => {++ const o = insight.overview || {};++ const topics = (o.topics || []).slice(0, 8);++ const events = (insight.timeline || []).slice(0, 6);++ const ready = insight.state === 'ready' && !!o.text;++ const when = o.updated_at ? ui.when(o.updated_at) : null;++ const meta = [o.people ? `${o.people} ${o.people === 1 ? 'person' : 'people'}` : '', o.messages ? `${o.messages} messages` : '', when && when.text ? `updated ${when.text}` : ''].filter(Boolean);++ return `<details class="oc-more oc-insight"><summary class="oc-icon-btn" aria-label="AI overview of this room" title="AI overview">${IC.spark}</summary><div class="oc-pop oc-insight-pop">`++ + (ready ? `<p>${esc(o.text)}</p>` : '<p class="oc-muted">Quiet right now: there is nothing new to summarise.</p>')++ + (topics.length ? `<ul class="oc-insight-topics">${topics.map((t) => `<li>${esc(t.label)} <span class="oc-muted">${Number(t.count) || 0}</span></li>`).join('')}</ul>` : '')++ + (events.length ? `<ol class="oc-insight-timeline">${events.map((e) => `<li><time>${esc(ui.when(e.at).text || '')}</time> <strong>${esc(e.title)}</strong>${e.text ? ` <span class="oc-muted">${esc(e.text)}</span>` : ''}</li>`).join('')}</ol>` : '')++ + (meta.length ? `<p class="oc-muted oc-insight-meta">${esc(meta.join(' · '))}</p>` : '')++ + '</div></details>';++ };/** One conversation in a list: initials, name and time on one line, the last message under it, unread marked. */const convRow = ({ href, title, preview, at, unread = 0, badge = '', personPresence = null, avatarUrl = '' }) => {const t = ui.when(at);@@@ -13,12 +13,14 @@ const t = suite('chat-ai');let h, db, ctx, chatAi, aiClient, streamer, alice, bob, channelId, streamId;async function msg(o = {}) {++ const subject = o.subject_id !== undefined ? o.subject_id++ : (o.user_id ? ((h.live.users.get(o.user_id) || {}).subject_id || null) : null);return (await db.run(-- `INSERT INTO chat_messages (stream_id, user_id, username, message, message_type, is_global, channel_user_id, timestamp)-- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,++ `INSERT INTO chat_messages (stream_id, user_id, username, message, message_type, is_global, channel_user_id, timestamp, subject_id)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,[o.stream_id === undefined ? streamId : o.stream_id, o.user_id === undefined ? null : o.user_id,o.username || null, o.message || 'a message', o.message_type || 'chat', o.is_global ? 1 : 0,-- o.channel_user_id === undefined ? streamer.id : o.channel_user_id, o.timestamp || sqliteNow()]++ o.channel_user_id === undefined ? streamer.id : o.channel_user_id, o.timestamp || sqliteNow(), subject])).lastInsertRowid;}@@@ -28,9 +30,12 @@ t('boot', async () => {ctx = require('../server/live-context');chatAi = require('../server/ai/chat-ai');aiClient = require('../server/ai/client');-- streamer = h.addUser('streamer', { role: 'streamer' });-- alice = h.addUser('alice');-- bob = h.addUser('bob');++ streamer = h.addUser('streamer', { role: 'streamer', subject: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A1' });++ alice = h.addUser('alice', { subject: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A2' });++ bob = h.addUser('bob', { subject: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3' });++ h.consent.allowed.add(streamer.subject_id);++ h.consent.allowed.add(alice.subject_id);++ h.consent.allowed.add(bob.subject_id);channelId = h.addChannel(streamer.id);streamId = h.addStream(streamer.id, channelId);await h.ctx.sync();@@@ -170,4 +175,92 @@ t('the six routes answer with Live\'s shapes, publicly', async () => {assert.strictEqual(r.status, 404);});++t('the summariser reads only people who allowed AI use, and stores people and topics', async () => {++ const calls = [];++ aiClient._setClient({++ runs: {++ create: async (workflow, input) => {++ calls.push({ workflow, input });++ return { run: { status: 'succeeded', output: {++ recent_overview: 'consenting talk', memory: 'memory line',++ timeline: [{ label: 'a moment', detail: 'detail', mins_ago: 1 }],++ } } };++ },++ },++ });++ const carol = h.addUser('carol', { subject: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A4' }); // no consent++ await db.run("UPDATE chat_ai_summaries SET updated_at = ? WHERE scope = 'global'", [sqliteNow(-60 * 60 * 1000)]);++ await msg({ user_id: carol.id, username: 'carol', message: 'carol secret line' });++ await msg({ user_id: alice.id, username: 'alice', message: 'zelda talk is good' });++ await msg({ user_id: bob.id, username: 'bob', message: 'zelda again' });++++ await chatAi._tick();++++ const global = calls.find((c) => c.workflow === chatAi.WORKFLOW_GLOBAL);++ assert.ok(global, 'the global workflow was called');++ const authors = global.input.messages.map((m) => m.author);++ assert.ok(!authors.includes('carol'), 'carol is left out');++ assert.ok(authors.includes('alice') && authors.includes('bob'), JSON.stringify(authors));++ const row = await db.getChatAiSummary('global', 0, 'global');++ assert.strictEqual(row.overview, 'consenting talk');++ assert.strictEqual(Number(row.people), 2);++ const topics = JSON.parse(row.topics_json);++ assert.ok(topics.some((x) => x.label === 'zelda' && x.count >= 2), JSON.stringify(topics));++});++++t('a consent check that fails leaves everyone out (fail closed)', async () => {++ const consent = require('../server/net/consent');++ consent._reset();++ h.consent.down = true;++ const before = (await db.getChatAiSummary('global', 0, 'global')).message_count;++ await db.run("UPDATE chat_ai_summaries SET updated_at = ? WHERE scope = 'global'", [sqliteNow(-60 * 60 * 1000)]);++ await msg({ user_id: alice.id, username: 'alice', message: 'not read while Network is down' });++ await chatAi._tick();++ assert.strictEqual((await db.getChatAiSummary('global', 0, 'global')).message_count, before);++ h.consent.down = false;++ consent._reset();++});++++t('GET /api/v1/rooms/global/insight answers the room shape, publicly', async () => {++ await db.upsertChatAiSummary({++ scope: 'global', subject_id: 0, window: 'global', overview: 'arrivals see this', memory_json: 'm',++ timeline_json: JSON.stringify([{ ts: sqliteNow(), label: 'a moment', detail: 'detail' }]),++ message_count: 12, window_message_count: 4, window_label: 'past hour',++ window_start: sqliteNow(-3600e3), window_end: sqliteNow(), people: 3,++ topics_json: JSON.stringify([{ label: 'zelda', count: 2 }]),++ });++ await db.addChatTimelineEvents('global', 0, [{ ts: sqliteNow(), label: 'the moment', detail: 'what happened', kind: 'moment' }]);++++ let r = await h.http('GET', '/api/v1/rooms/global/insight'); // no token: public++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.body.state, 'ready');++ assert.strictEqual(r.body.overview.text, 'arrivals see this');++ assert.deepStrictEqual(r.body.overview.topics, [{ label: 'zelda', count: 2 }]);++ assert.strictEqual(r.body.overview.people, 3);++ assert.strictEqual(r.body.overview.messages, 4);++ assert.ok(r.body.overview.from && r.body.overview.to && r.body.overview.updated_at);++ assert.ok(Array.isArray(r.body.timeline) && r.body.timeline.length >= 1);++ const ev = r.body.timeline.find((e) => e.title === 'the moment');++ assert.ok(ev, JSON.stringify(r.body.timeline.slice(0, 3)));++ assert.deepStrictEqual(Object.keys(ev).sort(), ['at', 'kind', 'message_id', 'text', 'title']);++ assert.strictEqual(ev.text, 'what happened');++++ r = await h.http('GET', '/api/v1/rooms/nope/insight');++ assert.strictEqual(r.status, 404);++++ r = await h.http('GET', '/');++ assert.strictEqual(r.status, 200, r.text);++ assert.ok(r.text.includes('arrivals see this'), 'the Global page shows the overview');++ assert.ok(r.text.includes('AI overview of this room'), 'behind the head button');++});++++t('an old summary is said as quiet, never as today\'s news', async () => {++ await db.run("UPDATE chat_ai_summaries SET window_end = ?, updated_at = ? WHERE scope = 'global'", [sqliteNow(-48 * 3600e3), sqliteNow(-48 * 3600e3)]);++ const r = await h.http('GET', '/api/v1/rooms/global/insight');++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.body.state, 'quiet');++ assert.strictEqual(r.body.overview.text, '');++ assert.deepStrictEqual(r.body.overview.topics, []);++});++t.run(async () => { try { chatAi.stop(); } catch { /* */ } });@@@ -99,6 +99,10 @@ async function boot({ env = {} } = {}) {// Presence for the Network endpoint Chat reads (network.presence.read). Tests set rows here; the// visibility rule itself is Network's and is tested there, so the stub answers the subjects asked for.const netPresence = { rows: new Map(), calls: [] };++ // The consent check (network.consent@1, capability network.consent.read): Chat's room insight asks++ // which authors allowed ai.train on kind chat. Tests put subjects in h.consent.allowed; h.consent.down++ // makes the call fail, which must leave everyone out (fail closed).++ const netConsent = { allowed: new Set(), calls: [], down: false };const network = http.createServer(async (req, res) => {const raw = await new Promise((r) => { let s = ''; req.on('data', (c) => { s += c; }); req.on('end', () => r(s)); });res.setHeader('Content-Type', 'application/json');@@@ -113,6 +117,18 @@ async function boot({ env = {} } = {}) {netPresence.calls.push({ for: forSubject, subjects });return res.end(JSON.stringify({ presence: subjects.map((s) => netPresence.rows.get(s)).filter(Boolean) }));}++ if (req.url === '/internal/consent/check') {++ const auth = String(req.headers.authorization || '');++ const v = auth.startsWith('Bearer ') ? serviceAuth.verifyServiceToken(auth.slice(7), { publicKey: keys.publicKey, issuer: ISS, audience: 'openvibe.network' }) : { ok: false, code: 'token.missing' };++ if (!v.ok) { res.statusCode = 401; return res.end(JSON.stringify({ error: v.code })); }++ if (!(v.claims.cap || []).includes('network.consent.read')) { res.statusCode = 403; return res.end(JSON.stringify({ error: 'capability.denied' })); }++ if (netConsent.down) { req.socket.destroy(); return; }++ let body = {}; try { body = JSON.parse(raw || '{}'); } catch { body = {}; }++ netConsent.calls.push(body);++ const allowed = {};++ for (const s of Array.isArray(body.subjects) ? body.subjects : []) allowed[s] = netConsent.allowed.has(s);++ return res.end(JSON.stringify({ purpose: body.purpose, kind: body.kind, allowed, checked_at: new Date().toISOString() }));++ }const mm = /^\/internal\/modules\/([^/?]+)\/([^/?]+)$/.exec(req.url);if (mm) return modulesRoute(req, res, raw, decodeURIComponent(mm[1]), decodeURIComponent(mm[2]));if (req.url === '/oauth/token') {@@@ -313,6 +329,7 @@ async function boot({ env = {} } = {}) {const h = {tmp, keys, live, network, liveServer, tokenRequests, serviceToken, ISS, netModules, netPresence,++ consent: netConsent,userSeq: 0,/** Add a Live user (projection row) with a browser token; returns { id, token, ...row }. */addUser(username, { role = 'user', subject = null, created_at = sqliteNow(-30 * 86400e3), apiScopes = null, is_owner = 0, display_name = null } = {}) {nnew file mode 100644@@@ -0,0 +1,54 @@++'use strict';++/**++ * A room's AI insight reader (plan 2026-11-10, part 1): the one shape GET /api/v1/rooms/:id/insight++ * answers, quiet said as quiet, and the route module mounted for Global. The job's consent filtering++ * and the public route are exercised end to end in chat-ai.test.js.++ */++const assert = require('assert');++const { suite, sqliteNow } = require('./helpers');++const roomInsight = require('../server/chat/room-insight');++const routes = require('../server/chat/room-insight-routes');++++const t = suite('room-insight');++++t('a fresh summary is ready and keeps its topics, people and timeline', async () => {++ const now = sqliteNow();++ const from = sqliteNow(-3600e3);++ const shaped = roomInsight._shape({++ overview: 'arrivals see this', window_start: from, window_end: now, updated_at: now,++ people: 3, window_message_count: 4, topics_json: JSON.stringify([{ label: 'zelda', count: 2 }, { label: '', count: 9 }]),++ }, [{ ts: now, label: 'a moment', detail: 'what happened', kind: 'event', message_id: 9 }]);++ assert.strictEqual(shaped.state, 'ready');++ assert.strictEqual(shaped.overview.text, 'arrivals see this');++ assert.deepStrictEqual(shaped.overview.topics, [{ label: 'zelda', count: 2 }]);++ assert.strictEqual(shaped.overview.people, 3);++ assert.strictEqual(shaped.overview.messages, 4);++ assert.deepStrictEqual(shaped.timeline, [{ at: now, kind: 'event', title: 'a moment', text: 'what happened', message_id: 9 }]);++ assert.strictEqual(shaped.overview.from, from);++});++++t('an old summary is quiet: no text, no topics, the timeline stays', async () => {++ const old = sqliteNow(-48 * 3600e3);++ const shaped = roomInsight._shape({++ overview: 'two days ago everything happened', window_start: old, window_end: old, updated_at: old,++ people: 7, window_message_count: 30, topics_json: JSON.stringify([{ label: 'zelda', count: 4 }]),++ }, [{ ts: old, label: 'a moment', detail: 'old news' }]);++ assert.strictEqual(shaped.state, 'quiet');++ assert.strictEqual(shaped.overview.text, '');++ assert.deepStrictEqual(shaped.overview.topics, []);++ assert.strictEqual(shaped.overview.people, 0);++ assert.strictEqual(shaped.overview.messages, 0);++ assert.strictEqual(shaped.timeline.length, 1);++ assert.strictEqual(shaped.timeline[0].kind, 'moment');++});++++t('no row at all is quiet, and the route module is a router', async () => {++ const shaped = roomInsight._shape(null, []);++ assert.strictEqual(shaped.state, 'quiet');++ assert.strictEqual(shaped.overview.text, '');++ assert.strictEqual(shaped.overview.updated_at, null);++ assert.deepStrictEqual(shaped.timeline, []);++ assert.strictEqual(typeof routes, 'function');++});++++t.run();@@@ -121,6 +121,7 @@ t('ratchet: every file that makes an outbound request itself is reviewed', () =>'server/chat/account-data.js': 'Network internal API (configured), fixed paths: export parts and deletion confirmations','server/prefs/network-modules.js': 'Network user modules (configured)','server/presence.js': 'Network internal presence (configured internal URL), fixed /internal/presence path, the subject list in the query, a service token, 1.5 s, held 30 s',++ 'server/net/consent.js': 'Network internal consent check (configured internal URL), fixed /internal/consent/check path, up to 500 subject ids in the body (never a URL), a service token, 5 s, fail-closed','server/chat/routes.js': 'GIF providers (fixed hosts)','server/chat/emotes-routes.js': 'FFZ / BTTV / 7TV emote providers (fixed hosts, hardcoded URLs)','server/chat/soundboard-service.js': '101soundboards (host allowlist; audio via safeLookup)',
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+-- phase: expand
+-- A room's AI insight (GET /api/v1/rooms/:id/insight): the same summary row Global already keeps, now
+-- with the window's people count and its topics (label + how many messages mentioned it) so one public
+-- read answers the shape every room will use. The timeline gets a kind and an optional source message,
+-- so a reader can tell a notable moment from a running topic. Both columns are nullable/defaulted:
+-- older rows keep working, the job fills them from its next window.
+ALTER TABLE chat_ai_summaries ADD COLUMN IF NOT EXISTS people bigint DEFAULT 0;
+ALTER TABLE chat_ai_summaries ADD COLUMN IF NOT EXISTS topics_json text COLLATE "C" DEFAULT '[]';
+ALTER TABLE chat_timeline_events ADD COLUMN IF NOT EXISTS kind text COLLATE "C" DEFAULT 'moment';
+ALTER TABLE chat_timeline_events ADD COLUMN IF NOT EXISTS message_id bigint;
@@ -646,6 +646,16 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
@@ -646,6 +646,16 @@ h2 { font-size: 1.15rem; margin: 1.4em 0 .5em; }
.oc-pop-item { display: flex; align-items: center; gap: 10px; width: 100%; padding: 9px 10px; border-radius: 8px; background: transparent; color: var(--oc-text); font-weight: 600; text-align: left; }
.oc-pop-item { display: flex; align-items: center; gap: 10px; width: 100%; padding: 9px 10px; border-radius: 8px; background: transparent; color: var(--oc-text); font-weight: 600; text-align: left; }
.oc-pop-item:hover { background: var(--oc-panel-2); }
.oc-pop-item:hover { background: var(--oc-panel-2); }
.oc-pop-item.oc-danger { color: var(--oc-red); }
.oc-pop-item.oc-danger { color: var(--oc-red); }
+/* A room's AI insight (server/web/pages.js insightBlock): plain text, topics and a short timeline. Only
+ the parts a popover needs to stay readable on a phone; its look is the designer's. */
+.oc-insight-pop { width: min(380px, calc(100vw - 32px)); max-height: min(70vh, 560px); overflow-y: auto; }
+.oc-insight-pop > p { margin: 6px 8px; }
+.oc-insight-topics, .oc-insight-timeline { list-style: none; margin: 8px; padding: 0; font-size: .88rem; }
+.oc-insight-topics { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(92px, 100%), 1fr)); gap: 4px; }
+.oc-insight-topics li { padding: 3px 8px; border: 1px solid var(--oc-line); border-radius: 999px; }
+.oc-insight-timeline { display: grid; grid-template-columns: minmax(0, 1fr); gap: 4px; }
+.oc-insight-timeline time { margin-right: 6px; color: var(--oc-muted); font-variant-numeric: tabular-nums; }
+.oc-insight-meta { font-size: .78rem; }
.oc-row-lock { display: inline-flex; color: var(--oc-muted); flex: none; }
.oc-row-lock { display: inline-flex; color: var(--oc-muted); flex: none; }
.oc-row-lock .oc-i { width: .9em; height: .9em; }
.oc-row-lock .oc-i { width: .9em; height: .9em; }
@@ -23,6 +23,7 @@ const config = require('../config');
@@ -23,6 +23,7 @@ const config = require('../config');
const db = require('../db/database');
const db = require('../db/database');
const aiClient = require('./client');
const aiClient = require('./client');
const extractive = require('./extractive');
const extractive = require('./extractive');
+const consent = require('../net/consent');
// ── Tunables (Live's) ────────────────────────────────────────────────────────
// ── Tunables (Live's) ────────────────────────────────────────────────────────
const TICK_MS = config.ai.tickMs;
const TICK_MS = config.ai.tickMs;
@@ -96,7 +97,11 @@ function _stampAdditions(additions, fallbackTs, nowMs) {
@@ -96,7 +97,11 @@ function _stampAdditions(additions, fallbackTs, nowMs) {
let ts = fallbackTs;
let ts = fallbackTs;
const mins = Number(a && a.mins_ago);
const mins = Number(a && a.mins_ago);
if (Number.isFinite(mins) && mins >= 0 && mins <= 43200) ts = _sqlTime(now - mins * 60000);
if (Number.isFinite(mins) && mins >= 0 && mins <= 43200) ts = _sqlTime(now - mins * 60000);
- out.push({ ts, label, detail: _clip(a.detail || a.description || '', 240) });
+ const messageId = a && a.message_id != null && Number.isFinite(Number(a.message_id)) ? Number(a.message_id) : null;
+ out.push({
+ ts, label, detail: _clip(a.detail || a.description || '', 240),
+ kind: _clip(a && a.kind ? a.kind : 'moment', 20), message_id: messageId,
+ });
}
}
return out;
return out;
}
}
@@ -116,6 +121,42 @@ function _cleanTimeline(arr) {
@@ -116,6 +121,42 @@ function _cleanTimeline(arr) {
.sort((a, b) => _parseSqlTime(a.ts) - _parseSqlTime(b.ts));
.sort((a, b) => _parseSqlTime(a.ts) - _parseSqlTime(b.ts));
}
}
+// The words people keep coming back to in the window: a topic with how many messages mentioned it.
+// Extractive so the room read has topics even when the model wrote only prose.
+const TOPIC_STOP = new Set(('the and for that this with you your are was were have has had not but all can '
+ + 'just from they them their there here what when who how why will would could should about into over than then '
+ + 'its it\'s i\'m dont don\'t im ive you\'re your\'re hes shes theyre we\'re thats whats lets get got like one '
+ + 'now out up off too very really some any more most much many also only even still back well good bad lol lmao '
+ + 'yeah yes nope ok okay hi hello hey thanks thank please bro dude guys guy chat room message messages people').split(' '));
+function _topics(rows, limit = 8) {
+ const counts = new Map();
+ for (const r of rows || []) {
+ const text = String((r && r.message) || '').toLowerCase()
+ .replace(/https?:\/\/\S+/g, ' ')
+ .replace(/:[a-z0-9_+-]+:/g, ' ')
+ .replace(/@[a-z0-9_.-]+/g, ' ');
+ for (const m of text.matchAll(/[a-z][a-z'’-]{2,}/g)) {
+ const word = m[0].replace(/['’-]+$/g, '');
+ if (word.length < 3 || TOPIC_STOP.has(word)) continue;
+ counts.set(word, (counts.get(word) || 0) + 1);
+ }
+ }
+ return [...counts.entries()]
+ .filter(([, count]) => count >= 2)
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
+ .slice(0, limit)
+ .map(([label, count]) => ({ label: _clip(label, 40), count }));
+}
+/** How many different people wrote in the window (one per signed-in author, else per name). */
+function _people(rows) {
+ const seen = new Set();
+ for (const r of rows || []) {
+ const key = r && (r.user_id ? `u:${r.user_id}` : r.username ? `n:${String(r.username).toLowerCase()}` : null);
+ if (key) seen.add(key);
+ }
+ return seen.size;
+}
+
// Chat is the only writer of the two summaries tables since the C-04 cutover: no authority gate.
// Chat is the only writer of the two summaries tables since the C-04 cutover: no authority gate.
// ── GLOBAL ───────────────────────────────────────────────────────────────────
// ── GLOBAL ───────────────────────────────────────────────────────────────────
@@ -138,8 +179,16 @@ async function _refreshGlobal() {
@@ -138,8 +179,16 @@ async function _refreshGlobal() {
const windowStart = _sqlTime(startMs);
const windowStart = _sqlTime(startMs);
const windowLabel = _windowLabel(now - startMs);
const windowLabel = _windowLabel(now - startMs);
- const rows = await db.getChatMessagesForAi({ sinceTs: windowStart, order: 'desc', limit: MAX_BATCH_MESSAGES });
- if (!rows.length) return false;
+ // The room's read includes only people who allowed AI use of their messages (network.consent@1,
+ // ai.train on kind chat). Anon and relay authors have no Network subject and are left out; when
+ // Network cannot answer, the check fails closed and this window is skipped.
+ const batch = await db.getChatMessagesForAi({ sinceTs: windowStart, order: 'desc', limit: MAX_BATCH_MESSAGES });
+ if (!batch.length) return false;
+ const rows = await consent.filterRows(batch);
+ if (!rows.length) {
+ console.log('[ChatAI] global window has no consenting messages; nothing summarised');
+ return false;
+ }
const priorMemory = prior ? (prior.memory_json || '') : '';
const priorMemory = prior ? (prior.memory_json || '') : '';
let priorTl = [];
let priorTl = [];
try { priorTl = JSON.parse(prior ? (prior.timeline_json || '[]') : '[]'); } catch { priorTl = []; }
try { priorTl = JSON.parse(prior ? (prior.timeline_json || '[]') : '[]'); } catch { priorTl = []; }
@@ -165,8 +214,10 @@ async function _refreshGlobal() {
@@ -165,8 +214,10 @@ async function _refreshGlobal() {
window_label: windowLabel,
window_label: windowLabel,
window_start: windowStart,
window_start: windowStart,
window_end: nowIso,
window_end: nowIso,
+ people: _people(rows),
+ topics_json: JSON.stringify(_topics(rows)),
});
});
- console.log(`[ChatAI] global refreshed (${newCount} new, window=${windowLabel}, ${rows.length} msgs)`);
+ console.log(`[ChatAI] global refreshed (${newCount} new, ${rows.length} consenting, window=${windowLabel})`);
return true;
return true;
}
}
@@ -438,7 +489,7 @@ async function getAnonInsight(anonId) {
@@ -438,7 +489,7 @@ async function getAnonInsight(anonId) {
}
}
module.exports = {
module.exports = {
- start, stop, _tick, _msgData,
+ start, stop, _tick, _msgData, _topics, _people,
getGlobalInsight, getUserInsight, getRelayUserInsight, getAnonInsight,
getGlobalInsight, getUserInsight, getRelayUserInsight, getAnonInsight,
WORKFLOW_GLOBAL, WORKFLOW_PROFILE,
WORKFLOW_GLOBAL, WORKFLOW_PROFILE,
};
};
@@ -33,7 +33,10 @@ function _timeline(rows, now) {
@@ -33,7 +33,10 @@ function _timeline(rows, now) {
.map((r) => {
.map((r) => {
const label = _oneLine(r.message, 80);
const label = _oneLine(r.message, 80);
if (!label || _PLACEHOLDER.test(label)) return null;
if (!label || _PLACEHOLDER.test(label)) return null;
- return { label, detail: _oneLine(r.username || r.channel_username || '', 120), mins_ago: _minsAgo(r.timestamp || r.created_at, now) };
+ return {
+ label, detail: _oneLine(r.username || r.channel_username || '', 120), mins_ago: _minsAgo(r.timestamp || r.created_at, now),
+ kind: r.message_type && r.message_type !== 'chat' ? 'event' : 'message', message_id: r.id == null ? null : Number(r.id),
+ };
})
})
.filter(Boolean);
.filter(Boolean);
}
}
@@ -177,6 +177,8 @@ function createApp({ chatServer, relay, events = null, callServer = null, settin
@@ -177,6 +177,8 @@ function createApp({ chatServer, relay, events = null, callServer = null, settin
// The chat plays queue of an agent stream's room (Agent Streams S6): what the desk may read of it and
// The chat plays queue of an agent stream's room (Agent Streams S6): what the desk may read of it and
// how it records that a request ran (the capabilities chat.play_queue.read and chat.play_queue.write).
// how it records that a request ran (the capabilities chat.play_queue.read and chat.play_queue.write).
app.use('/api/v1/rooms', require('./chat/play-queue-routes').service);
app.use('/api/v1/rooms', require('./chat/play-queue-routes').service);
+ // A room's AI insight (plan 2026-11-10): public, one shape for Global and, in part 2, every room.
+ app.use('/api/v1/rooms', require('./chat/room-insight-routes'));
app.get('/health', (req, res) => res.json({ ok: true, service: 'chat' }));
app.get('/health', (req, res) => res.json({ ok: true, service: 'chat' }));
// Readiness in the openvibe-shared/ready shape (status ready/degraded/not_ready, named checks):
// Readiness in the openvibe-shared/ready shape (status ready/degraded/not_ready, named checks):
// 503 only when the required check fails. `db` is Chat's own database, which it cannot serve
// 503 only when the required check fails. `db` is Chat's own database, which it cannot serve
new file mode 100644
new file mode 100644
@@ -0,0 +1,27 @@
@@ -0,0 +1,27 @@
+'use strict';
+/**
+ * A room's public AI insight (plan 2026-11-10, part 1): GET /api/v1/rooms/:id/insight.
+ *
+ * One shape for every room — Global first, then a server's channels and rooms — so a reader never needs
+ * to know which kind of room it opened. Global's stable id is `global`; a room not covered by the
+ * rollout answers 404 rather than an empty shape, so a client can tell "no such room" from "quiet".
+ */
+const express = require('express');
+const router = express.Router();
+const roomInsight = require('./room-insight');
+
+router.get('/:id/insight', async (req, res) => {
+ const id = String(req.params.id || '');
+ if (id !== 'global') return res.status(404).json({ error: 'Room insight not found' });
+ try {
+ const insight = await roomInsight.global();
+ // A minute of cache: the job rewrites at most every 30 minutes and the answer is the same for everyone.
+ res.set('Cache-Control', 'public, max-age=60');
+ return res.json(insight);
+ } catch (err) {
+ console.warn('[RoomInsight] global read failed:', err && err.message);
+ return res.status(500).json({ error: 'Failed to load room insight' });
+ }
+});
+
+module.exports = router;
new file mode 100644
new file mode 100644
@@ -0,0 +1,71 @@
@@ -0,0 +1,71 @@
+'use strict';
+/**
+ * One room's AI insight, as GET /api/v1/rooms/:id/insight answers it (plan 2026-11-10, part 1: Global).
+ *
+ * The writer is the existing chat-AI job (server/ai/chat-ai.js): the same schedule, the same window and
+ * budget rules, only when there were new messages, and only the messages of people who allowed AI use of
+ * them. This module is the reader: it shapes the stored summary and the append-only timeline into one
+ * answer for every room, so Live and Chat can read the same thing.
+ *
+ * { overview: { text, topics: [{ label, count }], people, messages, from, to, updated_at },
+ * timeline: [{ at, kind, title, text, message_id }],
+ * state: 'ready' | 'quiet' | 'off' }
+ *
+ * Quiet is said as quiet: when the last summary is old (or there is none), the overview text is empty and
+ * only the stored timeline remains. The timeline is newest first, which is what someone arriving wants.
+ */
+const db = require('../db/database');
+
+const QUIET_AFTER_MS = 6 * 60 * 60 * 1000; // the job refreshes Global at least every 30 min when there is news
+const TIMELINE_LIMIT = 40;
+
+function _time(s) { return s ? new Date(String(s).replace(' ', 'T') + 'Z').getTime() : 0; }
+
+function _topics(raw) {
+ let list = [];
+ try { list = JSON.parse(raw || '[]'); } catch { list = []; }
+ if (!Array.isArray(list)) return [];
+ return list
+ .map((t) => ({ label: String((t && t.label) || '').trim().slice(0, 60), count: Math.max(0, Number(t && t.count) || 0) }))
+ .filter((t) => t.label)
+ .slice(0, 20);
+}
+
+function _timeline(events) {
+ return (events || []).map((e) => ({
+ at: e.ts || null,
+ kind: e.kind || 'moment',
+ title: String(e.label || '').slice(0, 140),
+ text: String(e.detail || ''),
+ message_id: e.message_id == null ? null : Number(e.message_id),
+ })).filter((e) => e.at && e.title);
+}
+
+function _shape(row, events) {
+ const fresh = !!(row && row.overview && row.window_end && Date.now() - _time(row.window_end) < QUIET_AFTER_MS);
+ const state = fresh ? 'ready' : 'quiet';
+ return {
+ overview: {
+ text: fresh ? String(row.overview) : '',
+ topics: fresh ? _topics(row.topics_json) : [],
+ people: fresh ? (Number(row.people) || 0) : 0,
+ messages: fresh ? (Number(row.window_message_count) || 0) : 0,
+ from: row ? (row.window_start || null) : null,
+ to: row ? (row.window_end || null) : null,
+ updated_at: row ? (row.updated_at || null) : null,
+ },
+ timeline: _timeline(events),
+ state,
+ };
+}
+
+/** The Global room's insight. Other rooms answer through room-insight in part 2. */
+async function global() {
+ const [row, events] = await Promise.all([
+ db.getChatAiSummary('global', 0, 'global'),
+ db.getChatTimelineEvents({ scope: 'global', subjectId: 0, limit: TIMELINE_LIMIT }),
+ ]);
+ return _shape(row, events);
+}
+
+module.exports = { global, _shape, _topics, QUIET_AFTER_MS };
@@ -1217,13 +1217,14 @@ async function upsertChatAiSummary(sfx) {
@@ -1217,13 +1217,14 @@ async function upsertChatAiSummary(sfx) {
scope, subject_id = 0, window, overview = '', memory_json = '', timeline_json = '[]',
scope, subject_id = 0, window, overview = '', memory_json = '', timeline_json = '[]',
message_count = 0, window_message_count = 0, last_message_id = 0,
message_count = 0, window_message_count = 0, last_message_id = 0,
window_label = '', window_start = null, window_end = null,
window_label = '', window_start = null, window_end = null,
+ people = 0, topics_json = '[]',
} = sfx || {};
} = sfx || {};
return await transaction(async () => {
return await transaction(async () => {
const res = await run(
const res = await run(
`INSERT INTO chat_ai_summaries
`INSERT INTO chat_ai_summaries
(scope, subject_id, "window", overview, memory_json, timeline_json, message_count,
(scope, subject_id, "window", overview, memory_json, timeline_json, message_count,
- window_message_count, last_message_id, window_label, window_start, window_end, updated_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ov_now())
+ window_message_count, last_message_id, window_label, window_start, window_end, people, topics_json, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ov_now())
ON CONFLICT(scope, subject_id, "window") DO UPDATE SET
ON CONFLICT(scope, subject_id, "window") DO UPDATE SET
overview = excluded.overview,
overview = excluded.overview,
memory_json = excluded.memory_json,
memory_json = excluded.memory_json,
@@ -1234,9 +1235,11 @@ async function upsertChatAiSummary(sfx) {
@@ -1234,9 +1235,11 @@ async function upsertChatAiSummary(sfx) {
window_label = excluded.window_label,
window_label = excluded.window_label,
window_start = excluded.window_start,
window_start = excluded.window_start,
window_end = excluded.window_end,
window_end = excluded.window_end,
+ people = excluded.people,
+ topics_json = excluded.topics_json,
updated_at = ov_now()`,
updated_at = ov_now()`,
[scope, subject_id || 0, window, overview, memory_json, timeline_json, message_count,
[scope, subject_id || 0, window, overview, memory_json, timeline_json, message_count,
- window_message_count, last_message_id, window_label, window_start, window_end]
+ window_message_count, last_message_id, window_label, window_start, window_end, people, topics_json]
);
);
return _writeResult(res, 'chat_ai_summaries', [await get('SELECT * FROM chat_ai_summaries WHERE scope = ? AND subject_id = ? AND "window" = ?', [scope, subject_id || 0, window])]);
return _writeResult(res, 'chat_ai_summaries', [await get('SELECT * FROM chat_ai_summaries WHERE scope = ? AND subject_id = ? AND "window" = ?', [scope, subject_id || 0, window])]);
});
});
@@ -1250,8 +1253,9 @@ async function addChatTimelineEvents(scope, subjectId, events) {
@@ -1250,8 +1253,9 @@ async function addChatTimelineEvents(scope, subjectId, events) {
for (const e of events) {
for (const e of events) {
if (!e || !e.label || !e.ts) continue;
if (!e || !e.label || !e.ts) continue;
try {
try {
- const res = await run('INSERT INTO chat_timeline_events (scope, subject_id, ts, label, detail) VALUES (?, ?, ?, ?, ?) ON CONFLICT DO NOTHING',
- [scope || 'global', subjectId || 0, e.ts, String(e.label).slice(0, 120), String(e.detail || '').slice(0, 400)]);
+ const res = await run('INSERT INTO chat_timeline_events (scope, subject_id, ts, label, detail, kind, message_id) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING',
+ [scope || 'global', subjectId || 0, e.ts, String(e.label).slice(0, 120), String(e.detail || '').slice(0, 400),
+ String(e.kind || 'moment').slice(0, 20), e.message_id == null ? null : Number(e.message_id)]);
if (res.changes) ids.push(Number(res.lastInsertRowid));
if (res.changes) ids.push(Number(res.lastInsertRowid));
n++; // Live counts every event it tried, the duplicates included
n++; // Live counts every event it tried, the duplicates included
} catch { /* */ }
} catch { /* */ }
@@ -1285,7 +1289,7 @@ async function getChatTimelineEvents({ scope = 'global', subjectId = 0, before =
@@ -1285,7 +1289,7 @@ async function getChatTimelineEvents({ scope = 'global', subjectId = 0, before =
if (q && String(q).trim()) { const like = '%' + String(q).trim().slice(0, 60) + '%'; conds.push('(label ILIKE ? OR detail ILIKE ?)'); params.push(like, like); }
if (q && String(q).trim()) { const like = '%' + String(q).trim().slice(0, 60) + '%'; conds.push('(label ILIKE ? OR detail ILIKE ?)'); params.push(like, like); }
params.push(Math.min(60, Math.max(1, limit)));
params.push(Math.min(60, Math.max(1, limit)));
try {
try {
- return await all(`SELECT id, ts, label, detail FROM chat_timeline_events WHERE ${conds.join(' AND ')} ORDER BY ts DESC, id DESC LIMIT ?`, params) || [];
+ return await all(`SELECT id, ts, label, detail, kind, message_id FROM chat_timeline_events WHERE ${conds.join(' AND ')} ORDER BY ts DESC, id DESC LIMIT ?`, params) || [];
} catch { return []; }
} catch { return []; }
}
}
@@ -1301,7 +1305,7 @@ async function countChatMessagesSince(afterId, userId = null) {
@@ -1301,7 +1305,7 @@ async function countChatMessagesSince(afterId, userId = null) {
}
}
// Fetch analyzable messages for AI batching (with the channel/broadcaster label).
// Fetch analyzable messages for AI batching (with the channel/broadcaster label).
async function getChatMessagesForAi({ afterId = null, sinceTs = null, userId = null, limit = 400, order = 'asc' } = {}) {
async function getChatMessagesForAi({ afterId = null, sinceTs = null, userId = null, limit = 400, order = 'asc' } = {}) {
- let sql = `SELECT cm.id, cm.user_id, cm.username, cm.message, cm.message_type, cm.timestamp,
+ let sql = `SELECT cm.id, cm.user_id, cm.subject_id, cm.username, cm.message, cm.message_type, cm.timestamp,
cm.stream_id, cm.channel_user_id, cm.is_global,
cm.stream_id, cm.channel_user_id, cm.is_global,
ch.username AS channel_username, ch.display_name AS channel_display
ch.username AS channel_username, ch.display_name AS channel_display
FROM chat_messages cm
FROM chat_messages cm
new file mode 100644
new file mode 100644
@@ -0,0 +1,88 @@
@@ -0,0 +1,88 @@
+'use strict';
+/**
+ * OpenVibe.Network's consent check, as Chat sees it for the room insight (plan L24: data and AI use by
+ * consent, openvibe-contracts network.consent@1).
+ *
+ * allowedSubjects(subjects) POST /internal/consent/check { purpose: 'ai.train', kind: 'chat' }
+ * with Chat's own service token (client `chat`, capability
+ * network.consent.read). A person who has not allowed AI use of their
+ * chat messages is left out of what the summariser reads.
+ *
+ * Fail-closed, exactly as the consent contract says: no token, an answer that is not ok, a timeout or a
+ * Network that is down all mean "nobody allowed". An answer is cached 60 s per person; a failed call is
+ * not retried for 30 s, so an outage costs one call, not one per tick. Nothing here logs a subject.
+ */
+const config = require('../config');
+const auth = require('./service-auth');
+
+const CACHE_TTL_MS = 60_000;
+const FAIL_TTL_MS = 30_000;
+const MAX_BATCH = 500;
+const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+const cache = new Map(); // subject → { at, allowed }
+let blockedUntil = 0; // a failed call: nobody is allowed until this passes
+let lastLog = '';
+
+function _log(message) {
+ if (message === lastLog) return;
+ lastLog = message;
+ console.warn(`[Consent] ${message}`);
+}
+
+async function _ask(batch) {
+ const headers = await auth.headers('openvibe.network', 'network.consent.read');
+ const res = await fetch(`${config.networkInternalUrl}/internal/consent/check`, {
+ method: 'POST',
+ headers: { ...headers, Accept: 'application/json', 'Content-Type': 'application/json' },
+ body: JSON.stringify({ purpose: 'ai.train', kind: 'chat', subjects: batch }),
+ signal: AbortSignal.timeout(5000),
+ });
+ if (!res.ok) throw new Error(`answered ${res.status}`);
+ const data = await res.json().catch(() => null);
+ return (data && data.allowed) || {};
+}
+
+/**
+ * Which of these subjects allowed AI use of their chat messages. Always a Set of subject ids; a subject
+ * that was not asked (no Network account, a relay or anon author) is simply not in it.
+ */
+async function allowedSubjects(subjects) {
+ const wanted = [...new Set((subjects || []).map(String).filter((s) => SUBJECT_RE.test(s)))];
+ const now = Date.now();
+ const allowed = new Set();
+ const missing = [];
+ for (const s of wanted) {
+ const hit = cache.get(s);
+ if (hit && now - hit.at < CACHE_TTL_MS) { if (hit.allowed) allowed.add(s); } else missing.push(s);
+ }
+ if (!missing.length || blockedUntil > now) return allowed;
+ for (let i = 0; i < missing.length; i += MAX_BATCH) {
+ const batch = missing.slice(i, i + MAX_BATCH);
+ try {
+ const answers = await _ask(batch);
+ const at = Date.now();
+ for (const s of batch) {
+ const ok = answers[s] === true;
+ cache.set(s, { at, allowed: ok });
+ if (ok) allowed.add(s);
+ else allowed.delete(s);
+ }
+ lastLog = '';
+ } catch (err) {
+ blockedUntil = Date.now() + FAIL_TTL_MS;
+ _log(`check failed, leaving everyone out: ${(err && err.message) || err}`);
+ return allowed; // fail closed; the people already cached as allowed stay
+ }
+ }
+ return allowed;
+}
+
+/** The rows whose author allowed AI use. Rows without a Network subject (anon, relay, deleted) are left out. */
+async function filterRows(rows) {
+ const list = Array.isArray(rows) ? rows : [];
+ const allowed = await allowedSubjects(list.map((r) => r && r.subject_id).filter(Boolean));
+ return list.filter((r) => r && r.subject_id && allowed.has(String(r.subject_id)));
+}
+
+module.exports = { allowedSubjects, filterRows, _reset() { cache.clear(); blockedUntil = 0; lastLog = ''; } };
@@ -276,6 +276,10 @@ function createWebRoutes({ config }) {
@@ -276,6 +276,10 @@ function createWebRoutes({ config }) {
: `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=%2F">Sign in with OpenVibe</a> to chat. Anyone can read along.</p>`;
: `<p class="oc-signin"><a class="oc-button" href="/auth/login?next=%2F">Sign in with OpenVibe</a> to chat. Anyone can read along.</p>`;
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
const signedIn = actor.kind === 'user';
const signedIn = actor.kind === 'user';
+ // The room's AI insight (server/chat/room-insight.js): a local read of the stored summary, so the page
+ // never waits on OpenVibe.AI. Public like the room: signed out too.
+ const insight = await require('../chat/room-insight').global().catch(() => null);
+ const insightHtml = insight ? insightBlock(insight) : '';
// Around the room: the real activity (server/web/home.js, read once a minute). Someone signed in gets the app: the
// Around the room: the real activity (server/web/home.js, read once a minute). Someone signed in gets the app: the
// room first at the screen's height, Ask Vibe in one line, compact lists, and no introduction or questions (those,
// room first at the screen's height, Ask Vibe in one line, compact lists, and no introduction or questions (those,
// the hero and the full footer, are for people who are new here, and for search).
// the hero and the full footer, are for people who are new here, and for search).
@@ -318,7 +322,7 @@ function createWebRoutes({ config }) {
@@ -318,7 +322,7 @@ function createWebRoutes({ config }) {
rail: shell.rail({ global: true, dmUnread: dms.reduce((n, d) => n + d.unread, 0), servers: shell.railServers(mineServers) }),
rail: shell.rail({ global: true, dmUnread: dms.reduce((n, d) => n + d.unread, 0), servers: shell.railServers(mineServers) }),
side: shell.sideHome({ dms, friends: contacts.friends, requests: contacts.requests, you: { name: me.display_name || me.username, status: 'Online', avatarUrl: me.avatar_url || '' } }),
side: shell.sideHome({ dms, friends: contacts.friends, requests: contacts.requests, you: { name: me.display_name || me.username, status: 'Online', avatarUrl: me.avatar_url || '' } }),
sideLabel: 'Home',
sideLabel: 'Home',
- main: `<header class="oc-chan-head">${shell.toList}<h1 id="oc-h" class="oc-chan-title">Global chat</h1><span class="oc-muted oc-chan-topic">Everyone on OpenVibe${home.peopleLine(activity)}</span><span class="oc-chan-tools"><span class="oc-live" id="oc-live" hidden>● live</span><a class="oc-icon-btn" href="/vibe" aria-label="Chat with Vibe" title="Chat with Vibe">${IC.spark}</a><a class="oc-icon-btn" href="/settings" aria-label="Chat settings" title="Chat settings">${IC.gear}</a></span></header>
+ main: `<header class="oc-chan-head">${shell.toList}<h1 id="oc-h" class="oc-chan-title">Global chat</h1><span class="oc-muted oc-chan-topic">Everyone on OpenVibe${home.peopleLine(activity)}</span><span class="oc-chan-tools"><span class="oc-live" id="oc-live" hidden>● live</span><a class="oc-icon-btn" href="/vibe" aria-label="Chat with Vibe" title="Chat with Vibe">${IC.spark}</a><a class="oc-icon-btn" href="/settings" aria-label="Chat settings" title="Chat settings">${IC.gear}</a></span>${insightHtml}</header>
${error}
${error}
${feedList}
${feedList}
${composer}`,
${composer}`,
@@ -341,6 +345,7 @@ ${parts.vibe}
@@ -341,6 +345,7 @@ ${parts.vibe}
<section class="oc-room" aria-labelledby="oc-h">
<section class="oc-room" aria-labelledby="oc-h">
<h2 id="oc-h">Global chat</h2>
<h2 id="oc-h">Global chat</h2>
<p class="oc-muted">One room for everyone on OpenVibe: streamers, viewers and players, from every site. <span class="oc-live" id="oc-live" hidden>● live</span></p>
<p class="oc-muted">One room for everyone on OpenVibe: streamers, viewers and players, from every site. <span class="oc-live" id="oc-live" hidden>● live</span></p>
+${insightHtml}
${error}
${error}
${feedList}
${feedList}
${composer}
${composer}
@@ -367,6 +372,25 @@ ${foldBlock({ id: 'more', head: '<h2 id="oc-h-more">More on OpenVibe</h2>', html
@@ -367,6 +372,25 @@ ${foldBlock({ id: 'more', head: '<h2 id="oc-h-more">More on OpenVibe</h2>', html
// ── Direct messages ──
// ── Direct messages ──
const dmApi = require('../chat/dm-routes');
const dmApi = require('../chat/dm-routes');
const { ICONS: IC } = require('./icons');
const { ICONS: IC } = require('./icons');
+ /**
+ * The Global room's AI insight, plain markup behind the head's AI button (plan 2026-11-10, part 1).
+ * Server-rendered, so it reads without JavaScript; no JavaScript is needed to open it either (<details>).
+ * The look is Fable's: this is only text, topics and the recent timeline in kit classes.
+ */
+ const insightBlock = (insight) => {
+ const o = insight.overview || {};
+ const topics = (o.topics || []).slice(0, 8);
+ const events = (insight.timeline || []).slice(0, 6);
+ const ready = insight.state === 'ready' && !!o.text;
+ const when = o.updated_at ? ui.when(o.updated_at) : null;
+ const meta = [o.people ? `${o.people} ${o.people === 1 ? 'person' : 'people'}` : '', o.messages ? `${o.messages} messages` : '', when && when.text ? `updated ${when.text}` : ''].filter(Boolean);
+ return `<details class="oc-more oc-insight"><summary class="oc-icon-btn" aria-label="AI overview of this room" title="AI overview">${IC.spark}</summary><div class="oc-pop oc-insight-pop">`
+ + (ready ? `<p>${esc(o.text)}</p>` : '<p class="oc-muted">Quiet right now: there is nothing new to summarise.</p>')
+ + (topics.length ? `<ul class="oc-insight-topics">${topics.map((t) => `<li>${esc(t.label)} <span class="oc-muted">${Number(t.count) || 0}</span></li>`).join('')}</ul>` : '')
+ + (events.length ? `<ol class="oc-insight-timeline">${events.map((e) => `<li><time>${esc(ui.when(e.at).text || '')}</time> <strong>${esc(e.title)}</strong>${e.text ? ` <span class="oc-muted">${esc(e.text)}</span>` : ''}</li>`).join('')}</ol>` : '')
+ + (meta.length ? `<p class="oc-muted oc-insight-meta">${esc(meta.join(' · '))}</p>` : '')
+ + '</div></details>';
+ };
/** One conversation in a list: initials, name and time on one line, the last message under it, unread marked. */
/** One conversation in a list: initials, name and time on one line, the last message under it, unread marked. */
const convRow = ({ href, title, preview, at, unread = 0, badge = '', personPresence = null, avatarUrl = '' }) => {
const convRow = ({ href, title, preview, at, unread = 0, badge = '', personPresence = null, avatarUrl = '' }) => {
const t = ui.when(at);
const t = ui.when(at);
@@ -13,12 +13,14 @@ const t = suite('chat-ai');
@@ -13,12 +13,14 @@ const t = suite('chat-ai');
let h, db, ctx, chatAi, aiClient, streamer, alice, bob, channelId, streamId;
let h, db, ctx, chatAi, aiClient, streamer, alice, bob, channelId, streamId;
async function msg(o = {}) {
async function msg(o = {}) {
+ const subject = o.subject_id !== undefined ? o.subject_id
+ : (o.user_id ? ((h.live.users.get(o.user_id) || {}).subject_id || null) : null);
return (await db.run(
return (await db.run(
- `INSERT INTO chat_messages (stream_id, user_id, username, message, message_type, is_global, channel_user_id, timestamp)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
+ `INSERT INTO chat_messages (stream_id, user_id, username, message, message_type, is_global, channel_user_id, timestamp, subject_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[o.stream_id === undefined ? streamId : o.stream_id, o.user_id === undefined ? null : o.user_id,
[o.stream_id === undefined ? streamId : o.stream_id, o.user_id === undefined ? null : o.user_id,
o.username || null, o.message || 'a message', o.message_type || 'chat', o.is_global ? 1 : 0,
o.username || null, o.message || 'a message', o.message_type || 'chat', o.is_global ? 1 : 0,
- o.channel_user_id === undefined ? streamer.id : o.channel_user_id, o.timestamp || sqliteNow()]
+ o.channel_user_id === undefined ? streamer.id : o.channel_user_id, o.timestamp || sqliteNow(), subject]
)).lastInsertRowid;
)).lastInsertRowid;
}
}
@@ -28,9 +30,12 @@ t('boot', async () => {
@@ -28,9 +30,12 @@ t('boot', async () => {
ctx = require('../server/live-context');
ctx = require('../server/live-context');
chatAi = require('../server/ai/chat-ai');
chatAi = require('../server/ai/chat-ai');
aiClient = require('../server/ai/client');
aiClient = require('../server/ai/client');
- streamer = h.addUser('streamer', { role: 'streamer' });
- alice = h.addUser('alice');
- bob = h.addUser('bob');
+ streamer = h.addUser('streamer', { role: 'streamer', subject: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A1' });
+ alice = h.addUser('alice', { subject: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A2' });
+ bob = h.addUser('bob', { subject: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3' });
+ h.consent.allowed.add(streamer.subject_id);
+ h.consent.allowed.add(alice.subject_id);
+ h.consent.allowed.add(bob.subject_id);
channelId = h.addChannel(streamer.id);
channelId = h.addChannel(streamer.id);
streamId = h.addStream(streamer.id, channelId);
streamId = h.addStream(streamer.id, channelId);
await h.ctx.sync();
await h.ctx.sync();
@@ -170,4 +175,92 @@ t('the six routes answer with Live\'s shapes, publicly', async () => {
@@ -170,4 +175,92 @@ t('the six routes answer with Live\'s shapes, publicly', async () => {
assert.strictEqual(r.status, 404);
assert.strictEqual(r.status, 404);
});
});
+t('the summariser reads only people who allowed AI use, and stores people and topics', async () => {
+ const calls = [];
+ aiClient._setClient({
+ runs: {
+ create: async (workflow, input) => {
+ calls.push({ workflow, input });
+ return { run: { status: 'succeeded', output: {
+ recent_overview: 'consenting talk', memory: 'memory line',
+ timeline: [{ label: 'a moment', detail: 'detail', mins_ago: 1 }],
+ } } };
+ },
+ },
+ });
+ const carol = h.addUser('carol', { subject: 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A4' }); // no consent
+ await db.run("UPDATE chat_ai_summaries SET updated_at = ? WHERE scope = 'global'", [sqliteNow(-60 * 60 * 1000)]);
+ await msg({ user_id: carol.id, username: 'carol', message: 'carol secret line' });
+ await msg({ user_id: alice.id, username: 'alice', message: 'zelda talk is good' });
+ await msg({ user_id: bob.id, username: 'bob', message: 'zelda again' });
+
+ await chatAi._tick();
+
+ const global = calls.find((c) => c.workflow === chatAi.WORKFLOW_GLOBAL);
+ assert.ok(global, 'the global workflow was called');
+ const authors = global.input.messages.map((m) => m.author);
+ assert.ok(!authors.includes('carol'), 'carol is left out');
+ assert.ok(authors.includes('alice') && authors.includes('bob'), JSON.stringify(authors));
+ const row = await db.getChatAiSummary('global', 0, 'global');
+ assert.strictEqual(row.overview, 'consenting talk');
+ assert.strictEqual(Number(row.people), 2);
+ const topics = JSON.parse(row.topics_json);
+ assert.ok(topics.some((x) => x.label === 'zelda' && x.count >= 2), JSON.stringify(topics));
+});
+
+t('a consent check that fails leaves everyone out (fail closed)', async () => {
+ const consent = require('../server/net/consent');
+ consent._reset();
+ h.consent.down = true;
+ const before = (await db.getChatAiSummary('global', 0, 'global')).message_count;
+ await db.run("UPDATE chat_ai_summaries SET updated_at = ? WHERE scope = 'global'", [sqliteNow(-60 * 60 * 1000)]);
+ await msg({ user_id: alice.id, username: 'alice', message: 'not read while Network is down' });
+ await chatAi._tick();
+ assert.strictEqual((await db.getChatAiSummary('global', 0, 'global')).message_count, before);
+ h.consent.down = false;
+ consent._reset();
+});
+
+t('GET /api/v1/rooms/global/insight answers the room shape, publicly', async () => {
+ await db.upsertChatAiSummary({
+ scope: 'global', subject_id: 0, window: 'global', overview: 'arrivals see this', memory_json: 'm',
+ timeline_json: JSON.stringify([{ ts: sqliteNow(), label: 'a moment', detail: 'detail' }]),
+ message_count: 12, window_message_count: 4, window_label: 'past hour',
+ window_start: sqliteNow(-3600e3), window_end: sqliteNow(), people: 3,
+ topics_json: JSON.stringify([{ label: 'zelda', count: 2 }]),
+ });
+ await db.addChatTimelineEvents('global', 0, [{ ts: sqliteNow(), label: 'the moment', detail: 'what happened', kind: 'moment' }]);
+
+ let r = await h.http('GET', '/api/v1/rooms/global/insight'); // no token: public
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.body.state, 'ready');
+ assert.strictEqual(r.body.overview.text, 'arrivals see this');
+ assert.deepStrictEqual(r.body.overview.topics, [{ label: 'zelda', count: 2 }]);
+ assert.strictEqual(r.body.overview.people, 3);
+ assert.strictEqual(r.body.overview.messages, 4);
+ assert.ok(r.body.overview.from && r.body.overview.to && r.body.overview.updated_at);
+ assert.ok(Array.isArray(r.body.timeline) && r.body.timeline.length >= 1);
+ const ev = r.body.timeline.find((e) => e.title === 'the moment');
+ assert.ok(ev, JSON.stringify(r.body.timeline.slice(0, 3)));
+ assert.deepStrictEqual(Object.keys(ev).sort(), ['at', 'kind', 'message_id', 'text', 'title']);
+ assert.strictEqual(ev.text, 'what happened');
+
+ r = await h.http('GET', '/api/v1/rooms/nope/insight');
+ assert.strictEqual(r.status, 404);
+
+ r = await h.http('GET', '/');
+ assert.strictEqual(r.status, 200, r.text);
+ assert.ok(r.text.includes('arrivals see this'), 'the Global page shows the overview');
+ assert.ok(r.text.includes('AI overview of this room'), 'behind the head button');
+});
+
+t('an old summary is said as quiet, never as today\'s news', async () => {
+ await db.run("UPDATE chat_ai_summaries SET window_end = ?, updated_at = ? WHERE scope = 'global'", [sqliteNow(-48 * 3600e3), sqliteNow(-48 * 3600e3)]);
+ const r = await h.http('GET', '/api/v1/rooms/global/insight');
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.body.state, 'quiet');
+ assert.strictEqual(r.body.overview.text, '');
+ assert.deepStrictEqual(r.body.overview.topics, []);
+});
+
t.run(async () => { try { chatAi.stop(); } catch { /* */ } });
t.run(async () => { try { chatAi.stop(); } catch { /* */ } });
@@ -99,6 +99,10 @@ async function boot({ env = {} } = {}) {
@@ -99,6 +99,10 @@ async function boot({ env = {} } = {}) {
// Presence for the Network endpoint Chat reads (network.presence.read). Tests set rows here; the
// Presence for the Network endpoint Chat reads (network.presence.read). Tests set rows here; the
// visibility rule itself is Network's and is tested there, so the stub answers the subjects asked for.
// visibility rule itself is Network's and is tested there, so the stub answers the subjects asked for.
const netPresence = { rows: new Map(), calls: [] };
const netPresence = { rows: new Map(), calls: [] };
+ // The consent check (network.consent@1, capability network.consent.read): Chat's room insight asks
+ // which authors allowed ai.train on kind chat. Tests put subjects in h.consent.allowed; h.consent.down
+ // makes the call fail, which must leave everyone out (fail closed).
+ const netConsent = { allowed: new Set(), calls: [], down: false };
const network = http.createServer(async (req, res) => {
const network = http.createServer(async (req, res) => {
const raw = await new Promise((r) => { let s = ''; req.on('data', (c) => { s += c; }); req.on('end', () => r(s)); });
const raw = await new Promise((r) => { let s = ''; req.on('data', (c) => { s += c; }); req.on('end', () => r(s)); });
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Type', 'application/json');
@@ -113,6 +117,18 @@ async function boot({ env = {} } = {}) {
@@ -113,6 +117,18 @@ async function boot({ env = {} } = {}) {
netPresence.calls.push({ for: forSubject, subjects });
netPresence.calls.push({ for: forSubject, subjects });
return res.end(JSON.stringify({ presence: subjects.map((s) => netPresence.rows.get(s)).filter(Boolean) }));
return res.end(JSON.stringify({ presence: subjects.map((s) => netPresence.rows.get(s)).filter(Boolean) }));
}
}
+ if (req.url === '/internal/consent/check') {
+ const auth = String(req.headers.authorization || '');
+ const v = auth.startsWith('Bearer ') ? serviceAuth.verifyServiceToken(auth.slice(7), { publicKey: keys.publicKey, issuer: ISS, audience: 'openvibe.network' }) : { ok: false, code: 'token.missing' };
+ if (!v.ok) { res.statusCode = 401; return res.end(JSON.stringify({ error: v.code })); }
+ if (!(v.claims.cap || []).includes('network.consent.read')) { res.statusCode = 403; return res.end(JSON.stringify({ error: 'capability.denied' })); }
+ if (netConsent.down) { req.socket.destroy(); return; }
+ let body = {}; try { body = JSON.parse(raw || '{}'); } catch { body = {}; }
+ netConsent.calls.push(body);
+ const allowed = {};
+ for (const s of Array.isArray(body.subjects) ? body.subjects : []) allowed[s] = netConsent.allowed.has(s);
+ return res.end(JSON.stringify({ purpose: body.purpose, kind: body.kind, allowed, checked_at: new Date().toISOString() }));
+ }
const mm = /^\/internal\/modules\/([^/?]+)\/([^/?]+)$/.exec(req.url);
const mm = /^\/internal\/modules\/([^/?]+)\/([^/?]+)$/.exec(req.url);
if (mm) return modulesRoute(req, res, raw, decodeURIComponent(mm[1]), decodeURIComponent(mm[2]));
if (mm) return modulesRoute(req, res, raw, decodeURIComponent(mm[1]), decodeURIComponent(mm[2]));
if (req.url === '/oauth/token') {
if (req.url === '/oauth/token') {
@@ -313,6 +329,7 @@ async function boot({ env = {} } = {}) {
@@ -313,6 +329,7 @@ async function boot({ env = {} } = {}) {
const h = {
const h = {
tmp, keys, live, network, liveServer, tokenRequests, serviceToken, ISS, netModules, netPresence,
tmp, keys, live, network, liveServer, tokenRequests, serviceToken, ISS, netModules, netPresence,
+ consent: netConsent,
userSeq: 0,
userSeq: 0,
/** Add a Live user (projection row) with a browser token; returns { id, token, ...row }. */
/** Add a Live user (projection row) with a browser token; returns { id, token, ...row }. */
addUser(username, { role = 'user', subject = null, created_at = sqliteNow(-30 * 86400e3), apiScopes = null, is_owner = 0, display_name = null } = {}) {
addUser(username, { role = 'user', subject = null, created_at = sqliteNow(-30 * 86400e3), apiScopes = null, is_owner = 0, display_name = null } = {}) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,54 @@
@@ -0,0 +1,54 @@
+'use strict';
+/**
+ * A room's AI insight reader (plan 2026-11-10, part 1): the one shape GET /api/v1/rooms/:id/insight
+ * answers, quiet said as quiet, and the route module mounted for Global. The job's consent filtering
+ * and the public route are exercised end to end in chat-ai.test.js.
+ */
+const assert = require('assert');
+const { suite, sqliteNow } = require('./helpers');
+const roomInsight = require('../server/chat/room-insight');
+const routes = require('../server/chat/room-insight-routes');
+
+const t = suite('room-insight');
+
+t('a fresh summary is ready and keeps its topics, people and timeline', async () => {
+ const now = sqliteNow();
+ const from = sqliteNow(-3600e3);
+ const shaped = roomInsight._shape({
+ overview: 'arrivals see this', window_start: from, window_end: now, updated_at: now,
+ people: 3, window_message_count: 4, topics_json: JSON.stringify([{ label: 'zelda', count: 2 }, { label: '', count: 9 }]),
+ }, [{ ts: now, label: 'a moment', detail: 'what happened', kind: 'event', message_id: 9 }]);
+ assert.strictEqual(shaped.state, 'ready');
+ assert.strictEqual(shaped.overview.text, 'arrivals see this');
+ assert.deepStrictEqual(shaped.overview.topics, [{ label: 'zelda', count: 2 }]);
+ assert.strictEqual(shaped.overview.people, 3);
+ assert.strictEqual(shaped.overview.messages, 4);
+ assert.deepStrictEqual(shaped.timeline, [{ at: now, kind: 'event', title: 'a moment', text: 'what happened', message_id: 9 }]);
+ assert.strictEqual(shaped.overview.from, from);
+});
+
+t('an old summary is quiet: no text, no topics, the timeline stays', async () => {
+ const old = sqliteNow(-48 * 3600e3);
+ const shaped = roomInsight._shape({
+ overview: 'two days ago everything happened', window_start: old, window_end: old, updated_at: old,
+ people: 7, window_message_count: 30, topics_json: JSON.stringify([{ label: 'zelda', count: 4 }]),
+ }, [{ ts: old, label: 'a moment', detail: 'old news' }]);
+ assert.strictEqual(shaped.state, 'quiet');
+ assert.strictEqual(shaped.overview.text, '');
+ assert.deepStrictEqual(shaped.overview.topics, []);
+ assert.strictEqual(shaped.overview.people, 0);
+ assert.strictEqual(shaped.overview.messages, 0);
+ assert.strictEqual(shaped.timeline.length, 1);
+ assert.strictEqual(shaped.timeline[0].kind, 'moment');
+});
+
+t('no row at all is quiet, and the route module is a router', async () => {
+ const shaped = roomInsight._shape(null, []);
+ assert.strictEqual(shaped.state, 'quiet');
+ assert.strictEqual(shaped.overview.text, '');
+ assert.strictEqual(shaped.overview.updated_at, null);
+ assert.deepStrictEqual(shaped.timeline, []);
+ assert.strictEqual(typeof routes, 'function');
+});
+
+t.run();
@@ -121,6 +121,7 @@ t('ratchet: every file that makes an outbound request itself is reviewed', () =>
@@ -121,6 +121,7 @@ t('ratchet: every file that makes an outbound request itself is reviewed', () =>
'server/chat/account-data.js': 'Network internal API (configured), fixed paths: export parts and deletion confirmations',
'server/chat/account-data.js': 'Network internal API (configured), fixed paths: export parts and deletion confirmations',
'server/prefs/network-modules.js': 'Network user modules (configured)',
'server/prefs/network-modules.js': 'Network user modules (configured)',
'server/presence.js': 'Network internal presence (configured internal URL), fixed /internal/presence path, the subject list in the query, a service token, 1.5 s, held 30 s',
'server/presence.js': 'Network internal presence (configured internal URL), fixed /internal/presence path, the subject list in the query, a service token, 1.5 s, held 30 s',
+ 'server/net/consent.js': 'Network internal consent check (configured internal URL), fixed /internal/consent/check path, up to 500 subject ids in the body (never a URL), a service token, 5 s, fail-closed',
'server/chat/routes.js': 'GIF providers (fixed hosts)',
'server/chat/routes.js': 'GIF providers (fixed hosts)',
'server/chat/emotes-routes.js': 'FFZ / BTTV / 7TV emote providers (fixed hosts, hardcoded URLs)',
'server/chat/emotes-routes.js': 'FFZ / BTTV / 7TV emote providers (fixed hosts, hardcoded URLs)',
'server/chat/soundboard-service.js': '101soundboards (host allowlist; audio via safeLookup)',
'server/chat/soundboard-service.js': '101soundboards (host allowlist; audio via safeLookup)',