openvibers/openvibe.live Commits

Group channel AI chatter timeline by day (#801)

OP OpenVibers committed 2 days ago · c9466cae1f · 3 files, +26 −6

Changes

ddiff --git a/public/js/app-channel.js b/public/js/app-channel.js
iindex 3fab03a7..115d390c 100644
---- a/public/js/app-channel.js
++++ b/public/js/app-channel.js
@@@ -2888,8 +2888,16 @@ async function loadChannelAiTimeline(username) {
const chat = data.chatInsight;
const chatOverall = chat && (chat.overview_alltime || chat.overview_24h);
const chatMoments = (chat && Array.isArray(chat.timeline)) ? chat.timeline.slice().reverse() : [];
++ const compactDays = (data.chatMemory && Array.isArray(data.chatMemory.entries)) ? data.chatMemory.entries : [];
++ const groupedMoments = Object.values(chatMoments.reduce((days, m) => {
++ const day = String(m.ts || '').slice(0, 10);
++ if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) return days;
++ (days[day] ||= { period_start: day, summary: '', highlights: [], message_count: 0 }).highlights.push({ detail: m.detail || m.label, at: m.ts });
++ return days;
++ }, {})).sort((a, b) => b.period_start.localeCompare(a.period_start));
++ const chatDays = compactDays.length ? compactDays : groupedMoments;
const hasStreamer = !!(sessions.length || streamerOv);
-- const hasChatter = !!(chatOverall || chatMoments.length);
++ const hasChatter = !!(chatOverall || chatDays.length);
const combined = data.combinedOverview;
if (!combined && !hasStreamer && !hasChatter) {
wrap.innerHTML = `<div class="ai-tl-empty"><i class="fa-solid fa-brain"></i><p>No AI timeline yet.</p><p class="muted">As ${dn} streams and chats, the AI builds an overview here — with links straight to the VOD moments.</p></div>`;
@@@ -2924,12 +2932,21 @@ async function loadChannelAiTimeline(username) {
// ── As a chatter (inner content) ──
let chatterInner = '';
if (hasChatter) {
-- const momentsHTML = chatMoments.length ? `<div class="ai-tl-track">${chatMoments.map(t => `
++ const momentsHTML = chatDays.length ? `<div class="ai-tl-track">${chatDays.map(t => `
<div class="ai-tl-session">
<div class="ai-tl-session-head"><div class="ai-tl-node"></div>
-- <div class="ai-tl-session-title">${esc(t.label || 'Moment')}</div>
-- <div class="ai-tl-session-meta"><i class="fa-solid fa-clock"></i> ${_aiTimeAgo(t.ts)}</div></div>
-- ${t.detail ? `<div class="ai-tl-session-overview">${esc(t.detail)}</div>` : ''}
++ <div class="ai-tl-session-title">${esc(t.period_start)}</div>
++ <div class="ai-tl-session-meta">${t.message_count ? `${_fmtCount(t.message_count)} messages` : `${t.highlights.length} moments`}</div></div>
++ ${t.summary ? `<div class="ai-tl-session-overview">${esc(t.summary)}</div>` : ''}
++ ${(t.highlights || []).slice(0, 5).map(h => {
++ const session = [...sessions, ...(data.index || [])].find(s => Number(s.id) === Number(h.stream_id));
++ const parseTime = value => value ? Date.parse(String(value).replace(' ', 'T').replace(/Z?$/, 'Z')) : NaN;
++ const started = parseTime(session && (session.started_at || session.created_at || session.when));
++ const at = parseTime(h.at);
++ const offset = Number.isFinite(started) && Number.isFinite(at) ? Math.max(0, Math.floor((at - started) / 1000)) : 0;
++ const href = session?.vod_id ? videoHref('vod', session.vod_id, offset) : '';
++ return `<div class="ai-tl-session-overview">${href ? `<a href="${esc(href)}">${esc(h.detail || '')}</a>` : esc(h.detail || '')}</div>`;
++ }).join('')}
</div>`).join('')}</div>` : '';
chatterInner = (chatOverall ? `<div class="ai-tl-overview-card"><div class="ai-tl-overview-label"><i class="fa-solid fa-comments"></i> As a chatter</div>${_collapsibleOverview(chatOverall)}${chat.message_count ? `<p class="ai-tl-summary" style="margin:8px 0 0">~${chat.message_count} messages analyzed</p>` : ''}</div>` : '')
+ momentsHTML;
ddiff --git a/server/ai/chat-ai-routes.js b/server/ai/chat-ai-routes.js
iindex 387418ff..03cf84c0 100644
---- a/server/ai/chat-ai-routes.js
++++ b/server/ai/chat-ai-routes.js
@@@ -155,10 +155,11 @@ router.get('/timeline/:username', async (req, res) => {
// First page also carries the CHATTER side (how they behave in chat) + a combined
// "whole person" overview fusing streamer + chatter. Both are best-effort. The channel's chat
// insight is the same one shape every Chat room answers (Chat's /api/v1/rooms/channel:<id>/insight).
-- let chatInsight, combinedOverview, channelInsight;
++ let chatInsight, combinedOverview, channelInsight, chatMemory;
if (first) {
chatInsight = await insights.getUser(user.id);
channelInsight = await insights.getChannel(user.id);
++ chatMemory = await insights.getPersonMemory(user.username);
try { combinedOverview = await _combinedOverview(user.id, timeline.overview, chatInsight); } catch { combinedOverview = null; }
try { await _ensureSessionTitles(user.id); } catch { /* background titling is best-effort */ }
}
@@@ -168,6 +169,7 @@ router.get('/timeline/:username', async (req, res) => {
display_name: user.display_name || user.username,
overview: first ? timeline.overview : undefined,
chatInsight,
++ chatMemory,
channelInsight,
combinedOverview,
sessionCount: timeline.sessionCount,
ddiff --git a/server/chat/insight-client.js b/server/chat/insight-client.js
iindex f54286ac..9bdefdb7 100644
---- a/server/chat/insight-client.js
++++ b/server/chat/insight-client.js
@@@ -113,6 +113,7 @@ module.exports = {
getRelay: (platform, username) => (platform && username ? read(`r:${platform}:${username}`, relayPath(platform, username)) : Promise.resolve(null)),
/** A Live channel's chat insight, the same shape a Chat room answers (channel:<broadcaster user id>). */
getChannel: (id) => (validUser(id) ? readRoom(`c:${Number(id)}`, channelPath(id)) : Promise.resolve(null)),
++ getPersonMemory: (username) => username ? readRoom(`pm:${username}`, `/api/v1/people/${encodeURIComponent(String(username))}/memory?channel=${encodeURIComponent(String(username))}&level=day`) : Promise.resolve(null),
peekGlobal: () => peek('g', '/global'),
peekUser: (id) => (validUser(id) ? peek(`u:${Number(id)}`, userPath(id)) : null),
peekAnon: (id) => (validAnon(id) ? peek(`a:${id}`, anonPath(id)) : null),