openvibers/openvibe.live Commits
Group channel AI chatter timeline by day (#801)
Changes
@@@ -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;@@@ -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,@@@ -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),
@@ -2888,8 +2888,16 @@ async function loadChannelAiTimeline(username) {
@@ -2888,8 +2888,16 @@ async function loadChannelAiTimeline(username) {
const chat = data.chatInsight;
const chat = data.chatInsight;
const chatOverall = chat && (chat.overview_alltime || chat.overview_24h);
const chatOverall = chat && (chat.overview_alltime || chat.overview_24h);
const chatMoments = (chat && Array.isArray(chat.timeline)) ? chat.timeline.slice().reverse() : [];
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 hasStreamer = !!(sessions.length || streamerOv);
- const hasChatter = !!(chatOverall || chatMoments.length);
+ const hasChatter = !!(chatOverall || chatDays.length);
const combined = data.combinedOverview;
const combined = data.combinedOverview;
if (!combined && !hasStreamer && !hasChatter) {
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>`;
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) {
@@ -2924,12 +2932,21 @@ async function loadChannelAiTimeline(username) {
// ── As a chatter (inner content) ──
// ── As a chatter (inner content) ──
let chatterInner = '';
let chatterInner = '';
if (hasChatter) {
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">
<div class="ai-tl-session-head"><div class="ai-tl-node"></div>
<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>` : '';
</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>` : '')
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;
+ momentsHTML;
@@ -155,10 +155,11 @@ router.get('/timeline/:username', async (req, res) => {
@@ -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
// 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
// "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).
// 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) {
if (first) {
chatInsight = await insights.getUser(user.id);
chatInsight = await insights.getUser(user.id);
channelInsight = await insights.getChannel(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 { combinedOverview = await _combinedOverview(user.id, timeline.overview, chatInsight); } catch { combinedOverview = null; }
try { await _ensureSessionTitles(user.id); } catch { /* background titling is best-effort */ }
try { await _ensureSessionTitles(user.id); } catch { /* background titling is best-effort */ }
}
}
@@ -168,6 +169,7 @@ router.get('/timeline/:username', async (req, res) => {
@@ -168,6 +169,7 @@ router.get('/timeline/:username', async (req, res) => {
display_name: user.display_name || user.username,
display_name: user.display_name || user.username,
overview: first ? timeline.overview : undefined,
overview: first ? timeline.overview : undefined,
chatInsight,
chatInsight,
+ chatMemory,
channelInsight,
channelInsight,
combinedOverview,
combinedOverview,
sessionCount: timeline.sessionCount,
sessionCount: timeline.sessionCount,
@@ -113,6 +113,7 @@ module.exports = {
@@ -113,6 +113,7 @@ module.exports = {
getRelay: (platform, username) => (platform && username ? read(`r:${platform}:${username}`, relayPath(platform, username)) : Promise.resolve(null)),
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>). */
/** 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)),
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'),
peekGlobal: () => peek('g', '/global'),
peekUser: (id) => (validUser(id) ? peek(`u:${Number(id)}`, userPath(id)) : null),
peekUser: (id) => (validUser(id) ? peek(`u:${Number(id)}`, userPath(id)) : null),
peekAnon: (id) => (validAnon(id) ? peek(`a:${id}`, anonPath(id)) : null),
peekAnon: (id) => (validAnon(id) ? peek(`a:${id}`, anonPath(id)) : null),