openvibers/openvibe.chat Commits
Room summaries wait for slow answers; the fallback timeline shows each event once (#564)
Changes
@@@ -62,6 +62,9 @@ async function bearer() {* transport error all answer null — exactly Live's ai-service.structured() contract. `force` runs it even with* the insight job switched off (CHAT_AI_ENABLED): a room's AI members have their own switch (CHAT_AI_MEMBERS).*/++// The workflows Chat runs in the background (the room insight job), which may take longer than a person waits.++const BACKGROUND = new Set(['chat.global', 'chat.profile']);++async function structured(workflow, input, opts = {}) {if (!(opts.force ? config.oauth.clientSecret : enabled())) return null;_lastProblem = null;@@@ -77,11 +80,17 @@ async function structured(workflow, input, opts = {}) {...(opts.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : { idempotencyKey: false }),}): await runs.create(workflow, input, {-- wait: config.ai.waitMs,++ // A summary is background work: ask for a short wait (inside the HTTP client's 30 s), then poll the++ // run to its end (2026-11-13: chat.global took 12-76 s and Chat gave up at 30 s, so the room's line++ // was the extractive fallback while AI's answer arrived after it stopped listening).++ wait: BACKGROUND.has(workflow) ? 20000 : config.ai.waitMs,...(opts.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : {}),...(opts.attribution !== undefined ? { attribution: opts.attribution } : {}),});-- const run = body && (body.run || body);++ let run = body && (body.run || body);++ if (run && run.id && (run.status === 'queued' || run.status === 'running') && BACKGROUND.has(workflow) && typeof runs.waitFor === 'function') {++ run = await runs.waitFor(run.id, { intervalMs: 2000, timeoutMs: 150000 });++ }_lastProblem = problemOf(run);// Meter completed failed runs too: providers can bill even when no output is returned.if (run && !run.synthetic && opts.onRun) await opts.onRun(run);@@@ -26,19 +26,31 @@ function _minsAgo(ts, now) {const _PLACEHOLDER = /^(short title|one sentence|label|title|detail|\.\.\.)$/i;/** Notable moments: non-chat activity (donations, clips, sound events), else the latest messages. */++// The window's moments, newest last: each event once (a sound played three times is one line, "×3", at its latest),++// and the latest messages beside them, so a window of repeated sound plays still shows what was said today (the owner,++// 11-13: "played !africa" three times, and nothing from today).function _timeline(rows, now) {-- const notable = rows.filter((r) => r.message_type && r.message_type !== 'chat' && r.message_type !== 'tts');-- const picked = (notable.length ? notable : rows.slice(-5)).slice(-10);-- return picked-- .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),-- kind: r.message_type && r.message_type !== 'chat' ? 'event' : 'message', message_id: r.id == null ? null : Number(r.id),-- };-- })-- .filter(Boolean);++ const isEvent = (r) => !!(r.message_type && r.message_type !== 'chat' && r.message_type !== 'tts');++ const byLabel = new Map();++ for (const r of rows) {++ if (!isEvent(r)) continue;++ const label = _oneLine(r.message, 80);++ if (!label || _PLACEHOLDER.test(label)) continue;++ const key = label.toLowerCase();++ const was = byLabel.get(key);++ byLabel.set(key, { r, label, n: was ? was.n + 1 : 1 });++ }++ const events = [...byLabel.values()].slice(-6);++ const said = rows.filter((r) => !isEvent(r) && r.message_type !== 'tts').slice(-Math.max(3, 8 - events.length));++ const at = (r) => Date.parse(r.timestamp || r.created_at || '') || 0;++ const picked = [...events.map((e) => ({ ...e, kind: 'event' })), ...said.map((r) => ({ r, label: _oneLine(r.message, 80), n: 1, kind: 'message' }))]++ .filter((e) => e.label && !_PLACEHOLDER.test(e.label))++ .sort((x, y) => at(x.r) - at(y.r))++ .slice(-10);++ return picked.map(({ r, label, n, kind }) => ({++ label: n > 1 ? `${label} ×${n}` : label, detail: _oneLine(r.username || r.channel_username || '', 120),++ mins_ago: _minsAgo(r.timestamp || r.created_at, now), kind, message_id: r.id == null ? null : Number(r.id),++ }));}// The words the window keeps coming back to (the same stop-list rule as the job's topics).nnew file mode 100644@@@ -0,0 +1,31 @@++'use strict';++// The fallback timeline (server/ai/extractive.js): each event once with its count, and the latest messages beside++// the events, newest last (the owner, 11-13: "played !africa" three times, and nothing from today).++const assert = require('assert');++const { _timeline } = require('../server/ai/extractive');++++const now = Date.parse('2026-11-13T20:00:00Z');++const at = (min) => new Date(now - min * 60000).toISOString();++const rows = [++ { id: 1, message: 'played !africa', message_type: 'sound', username: 'a', timestamp: at(300) },++ { id: 2, message: 'played !africa', message_type: 'sound', username: 'b', timestamp: at(200) },++ { id: 3, message: 'played !africa', message_type: 'sound', username: 'c', timestamp: at(100) },++ { id: 4, message: 'good morning everyone', message_type: 'chat', username: 'd', timestamp: at(30) },++ { id: 5, message: 'who is live today?', message_type: 'chat', username: 'e', timestamp: at(10) },++ { id: 6, message: 'spoken words', message_type: 'tts', username: 'f', timestamp: at(5) },++];++const t = _timeline(rows, now);++const labels = t.map((x) => x.label);++assert.deepStrictEqual(labels, ['played !africa ×3', 'good morning everyone', 'who is live today?'], 'one line per event, with today\'s messages');++assert.strictEqual(t[0].message_id, 3, 'the event line points at its latest play');++assert.strictEqual(t[0].kind, 'event');++assert.strictEqual(t[2].kind, 'message');++assert.ok(!labels.includes('spoken words'), 'TTS lines are not moments');++++// Only messages: the latest few, in order.++const chat = Array.from({ length: 12 }, (_, i) => ({ id: 10 + i, message: `line ${i}`, message_type: 'chat', username: 'x', timestamp: at(60 - i) }));++const c = _timeline(chat, now);++assert.ok(c.length <= 10 && c.length >= 3, 'a few messages');++assert.strictEqual(c[c.length - 1].label, 'line 11', 'newest last');++++console.log('extractive-timeline: events once with a count, today\'s messages beside them ok');
@@ -62,6 +62,9 @@ async function bearer() {
@@ -62,6 +62,9 @@ async function bearer() {
* transport error all answer null — exactly Live's ai-service.structured() contract. `force` runs it even with
* transport error all answer null — exactly Live's ai-service.structured() contract. `force` runs it even with
* the insight job switched off (CHAT_AI_ENABLED): a room's AI members have their own switch (CHAT_AI_MEMBERS).
* the insight job switched off (CHAT_AI_ENABLED): a room's AI members have their own switch (CHAT_AI_MEMBERS).
*/
*/
+// The workflows Chat runs in the background (the room insight job), which may take longer than a person waits.
+const BACKGROUND = new Set(['chat.global', 'chat.profile']);
+
async function structured(workflow, input, opts = {}) {
async function structured(workflow, input, opts = {}) {
if (!(opts.force ? config.oauth.clientSecret : enabled())) return null;
if (!(opts.force ? config.oauth.clientSecret : enabled())) return null;
_lastProblem = null;
_lastProblem = null;
@@ -77,11 +80,17 @@ async function structured(workflow, input, opts = {}) {
@@ -77,11 +80,17 @@ async function structured(workflow, input, opts = {}) {
...(opts.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : { idempotencyKey: false }),
...(opts.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : { idempotencyKey: false }),
})
})
: await runs.create(workflow, input, {
: await runs.create(workflow, input, {
- wait: config.ai.waitMs,
+ // A summary is background work: ask for a short wait (inside the HTTP client's 30 s), then poll the
+ // run to its end (2026-11-13: chat.global took 12-76 s and Chat gave up at 30 s, so the room's line
+ // was the extractive fallback while AI's answer arrived after it stopped listening).
+ wait: BACKGROUND.has(workflow) ? 20000 : config.ai.waitMs,
...(opts.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : {}),
...(opts.idempotencyKey ? { idempotencyKey: opts.idempotencyKey } : {}),
...(opts.attribution !== undefined ? { attribution: opts.attribution } : {}),
...(opts.attribution !== undefined ? { attribution: opts.attribution } : {}),
});
});
- const run = body && (body.run || body);
+ let run = body && (body.run || body);
+ if (run && run.id && (run.status === 'queued' || run.status === 'running') && BACKGROUND.has(workflow) && typeof runs.waitFor === 'function') {
+ run = await runs.waitFor(run.id, { intervalMs: 2000, timeoutMs: 150000 });
+ }
_lastProblem = problemOf(run);
_lastProblem = problemOf(run);
// Meter completed failed runs too: providers can bill even when no output is returned.
// Meter completed failed runs too: providers can bill even when no output is returned.
if (run && !run.synthetic && opts.onRun) await opts.onRun(run);
if (run && !run.synthetic && opts.onRun) await opts.onRun(run);
@@ -26,19 +26,31 @@ function _minsAgo(ts, now) {
@@ -26,19 +26,31 @@ function _minsAgo(ts, now) {
const _PLACEHOLDER = /^(short title|one sentence|label|title|detail|\.\.\.)$/i;
const _PLACEHOLDER = /^(short title|one sentence|label|title|detail|\.\.\.)$/i;
/** Notable moments: non-chat activity (donations, clips, sound events), else the latest messages. */
/** Notable moments: non-chat activity (donations, clips, sound events), else the latest messages. */
+// The window's moments, newest last: each event once (a sound played three times is one line, "×3", at its latest),
+// and the latest messages beside them, so a window of repeated sound plays still shows what was said today (the owner,
+// 11-13: "played !africa" three times, and nothing from today).
function _timeline(rows, now) {
function _timeline(rows, now) {
- const notable = rows.filter((r) => r.message_type && r.message_type !== 'chat' && r.message_type !== 'tts');
- const picked = (notable.length ? notable : rows.slice(-5)).slice(-10);
- return picked
- .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),
- kind: r.message_type && r.message_type !== 'chat' ? 'event' : 'message', message_id: r.id == null ? null : Number(r.id),
- };
- })
- .filter(Boolean);
+ const isEvent = (r) => !!(r.message_type && r.message_type !== 'chat' && r.message_type !== 'tts');
+ const byLabel = new Map();
+ for (const r of rows) {
+ if (!isEvent(r)) continue;
+ const label = _oneLine(r.message, 80);
+ if (!label || _PLACEHOLDER.test(label)) continue;
+ const key = label.toLowerCase();
+ const was = byLabel.get(key);
+ byLabel.set(key, { r, label, n: was ? was.n + 1 : 1 });
+ }
+ const events = [...byLabel.values()].slice(-6);
+ const said = rows.filter((r) => !isEvent(r) && r.message_type !== 'tts').slice(-Math.max(3, 8 - events.length));
+ const at = (r) => Date.parse(r.timestamp || r.created_at || '') || 0;
+ const picked = [...events.map((e) => ({ ...e, kind: 'event' })), ...said.map((r) => ({ r, label: _oneLine(r.message, 80), n: 1, kind: 'message' }))]
+ .filter((e) => e.label && !_PLACEHOLDER.test(e.label))
+ .sort((x, y) => at(x.r) - at(y.r))
+ .slice(-10);
+ return picked.map(({ r, label, n, kind }) => ({
+ label: n > 1 ? `${label} ×${n}` : label, detail: _oneLine(r.username || r.channel_username || '', 120),
+ mins_ago: _minsAgo(r.timestamp || r.created_at, now), kind, message_id: r.id == null ? null : Number(r.id),
+ }));
}
}
// The words the window keeps coming back to (the same stop-list rule as the job's topics).
// The words the window keeps coming back to (the same stop-list rule as the job's topics).
new file mode 100644
new file mode 100644
@@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
+'use strict';
+// The fallback timeline (server/ai/extractive.js): each event once with its count, and the latest messages beside
+// the events, newest last (the owner, 11-13: "played !africa" three times, and nothing from today).
+const assert = require('assert');
+const { _timeline } = require('../server/ai/extractive');
+
+const now = Date.parse('2026-11-13T20:00:00Z');
+const at = (min) => new Date(now - min * 60000).toISOString();
+const rows = [
+ { id: 1, message: 'played !africa', message_type: 'sound', username: 'a', timestamp: at(300) },
+ { id: 2, message: 'played !africa', message_type: 'sound', username: 'b', timestamp: at(200) },
+ { id: 3, message: 'played !africa', message_type: 'sound', username: 'c', timestamp: at(100) },
+ { id: 4, message: 'good morning everyone', message_type: 'chat', username: 'd', timestamp: at(30) },
+ { id: 5, message: 'who is live today?', message_type: 'chat', username: 'e', timestamp: at(10) },
+ { id: 6, message: 'spoken words', message_type: 'tts', username: 'f', timestamp: at(5) },
+];
+const t = _timeline(rows, now);
+const labels = t.map((x) => x.label);
+assert.deepStrictEqual(labels, ['played !africa ×3', 'good morning everyone', 'who is live today?'], 'one line per event, with today\'s messages');
+assert.strictEqual(t[0].message_id, 3, 'the event line points at its latest play');
+assert.strictEqual(t[0].kind, 'event');
+assert.strictEqual(t[2].kind, 'message');
+assert.ok(!labels.includes('spoken words'), 'TTS lines are not moments');
+
+// Only messages: the latest few, in order.
+const chat = Array.from({ length: 12 }, (_, i) => ({ id: 10 + i, message: `line ${i}`, message_type: 'chat', username: 'x', timestamp: at(60 - i) }));
+const c = _timeline(chat, now);
+assert.ok(c.length <= 10 && c.length >= 3, 'a few messages');
+assert.strictEqual(c[c.length - 1].label, 'line 11', 'newest last');
+
+console.log('extractive-timeline: events once with a count, today\'s messages beside them ok');