feat(rmeets): add Meeting Intelligence page, space-scoped rooms, toolbar buttons
- Add /meeting-intelligence route with aggregate knowledge (action items, decisions, topics) and space-scoped meeting cards - Add Meeting Intelligence link to hub page and in-room MI dropdown - Prefix Jitsi room names with space slug for conference_id scoping - Add shareaudio and meetingintelligence to embed toolbar buttons - Recordings route now filters by conference_prefix Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
fe605a33e2
commit
8d859b87bf
|
|
@ -182,7 +182,8 @@ routes.get("/meet", (c) => {
|
|||
routes.get("/recordings", async (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const base = `/${escapeHtml(space)}/rmeets`;
|
||||
const result = await miApiFetch("/meetings?limit=50&sort=-created_at");
|
||||
const prefix = encodeURIComponent(space + "_");
|
||||
const result = await miApiFetch(`/meetings?limit=50&sort=-created_at&conference_prefix=${prefix}`);
|
||||
|
||||
let body: string;
|
||||
if (!result.ok) {
|
||||
|
|
@ -490,6 +491,13 @@ routes.get("/", (c) => {
|
|||
<p>Open the full Jitsi Meet interface directly</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="${base}/meeting-intelligence">
|
||||
<span class="nav-icon">🧠</span>
|
||||
<div class="nav-body">
|
||||
<h3>Meeting Intelligence</h3>
|
||||
<p>Knowledge objects: action items, decisions, and insights across all meetings</p>
|
||||
</div>
|
||||
</a>
|
||||
<a href="${base}/recordings">
|
||||
<span class="nav-icon">🎥</span>
|
||||
<div class="nav-body">
|
||||
|
|
@ -509,6 +517,123 @@ routes.get("/", (c) => {
|
|||
}));
|
||||
});
|
||||
|
||||
// ── Meeting Intelligence knowledge page ──
|
||||
|
||||
routes.get("/meeting-intelligence", async (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
const base = `/${escapeHtml(space)}/rmeets`;
|
||||
const prefix = encodeURIComponent(space + "_");
|
||||
|
||||
// Fetch space-scoped meetings from MI API
|
||||
const result = await miApiFetch(`/meetings?limit=50&sort=-created_at&conference_prefix=${prefix}`);
|
||||
|
||||
let meetingCards = "";
|
||||
let aggregateHtml = "";
|
||||
|
||||
if (!result.ok) {
|
||||
meetingCards = miUnavailableHtml();
|
||||
} else {
|
||||
const meetings = Array.isArray(result.data) ? result.data : (result.data?.meetings ?? result.data?.items ?? []);
|
||||
|
||||
if (meetings.length === 0) {
|
||||
meetingCards = `<p style="color:var(--rs-text-secondary)">No meetings recorded yet for this space. Start a meeting and enable recording to see it here.</p>`;
|
||||
} else {
|
||||
// Build meeting cards
|
||||
meetingCards = `<div class="mi-cards">${meetings.map((m: any) => {
|
||||
const date = m.created_at ? new Date(m.created_at).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) : "";
|
||||
const duration = m.duration_seconds ? `${Math.round(m.duration_seconds / 60)}min` : "";
|
||||
const status = (m.status || "completed").toLowerCase();
|
||||
const badgeClass = `mi-badge--${status}`;
|
||||
const hasTranscript = m.has_transcript ?? (m.segment_count != null && m.segment_count > 0);
|
||||
const hasSummary = m.has_summary ?? m.summary_status === "completed";
|
||||
return `<a class="mi-card" href="${base}/recordings/${escapeHtml(String(m.id))}">
|
||||
<h3>${escapeHtml(m.title || m.room_name || m.conference_name || `Meeting ${m.id}`)}</h3>
|
||||
<div class="mi-meta">${[date, duration].filter(Boolean).join(" · ")}</div>
|
||||
<span class="mi-badge ${badgeClass}">${escapeHtml(status)}</span>
|
||||
<div class="mi-chips">
|
||||
${hasTranscript ? `<span class="mi-chip mi-chip--ok">Transcript</span>` : `<span class="mi-chip mi-chip--pending">No transcript</span>`}
|
||||
${hasSummary ? `<span class="mi-chip mi-chip--ok">Summary</span>` : `<span class="mi-chip mi-chip--pending">No summary</span>`}
|
||||
</div>
|
||||
</a>`;
|
||||
}).join("\n")}</div>`;
|
||||
|
||||
// Aggregate knowledge from completed meetings with summaries
|
||||
const readyMeetings = meetings.filter((m: any) => (m.status === "completed" || m.has_summary));
|
||||
if (readyMeetings.length > 0) {
|
||||
// Fetch summaries for up to 10 completed meetings
|
||||
const summaryPromises = readyMeetings.slice(0, 10).map((m: any) =>
|
||||
miApiFetch(`/meetings/${m.id}/summary`).then(r => r.ok ? r.data : null)
|
||||
);
|
||||
const summaries = (await Promise.all(summaryPromises)).filter(Boolean);
|
||||
|
||||
const allActionItems: string[] = [];
|
||||
const allDecisions: string[] = [];
|
||||
const allTopics: string[] = [];
|
||||
|
||||
for (const s of summaries) {
|
||||
const actions = s.action_items ?? s.actionItems ?? [];
|
||||
for (const a of actions) {
|
||||
const text = typeof a === "string" ? a : (a.task || a.text || a.description || "");
|
||||
if (text) allActionItems.push(text);
|
||||
}
|
||||
const decisions = s.decisions ?? [];
|
||||
for (const d of decisions) {
|
||||
const text = typeof d === "string" ? d : (d.text || d.description || "");
|
||||
if (text) allDecisions.push(text);
|
||||
}
|
||||
const topics = s.topics ?? [];
|
||||
for (const t of topics) {
|
||||
const text = typeof t === "string" ? t : (t.name || t.topic || t.text || "");
|
||||
if (text) allTopics.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
if (allActionItems.length > 0 || allDecisions.length > 0 || allTopics.length > 0) {
|
||||
aggregateHtml = `<div class="mi-aggregate">`;
|
||||
if (allActionItems.length > 0) {
|
||||
aggregateHtml += `<h2>Action Items</h2><ul>${allActionItems.map(a => `<li>${escapeHtml(a)}</li>`).join("")}</ul>`;
|
||||
}
|
||||
if (allDecisions.length > 0) {
|
||||
aggregateHtml += `<h2>Decisions</h2><ul>${allDecisions.map(d => `<li>${escapeHtml(d)}</li>`).join("")}</ul>`;
|
||||
}
|
||||
if (allTopics.length > 0) {
|
||||
aggregateHtml += `<h2>Topics</h2><div class="mi-chips">${allTopics.map(t => `<span class="mi-chip mi-chip--ok">${escapeHtml(t)}</span>`).join("")}</div>`;
|
||||
}
|
||||
aggregateHtml += `</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const extraStyles = `<style>
|
||||
.mi-aggregate{margin-bottom:2rem;padding:1.5rem;border-radius:12px;background:var(--rs-bg-surface);border:1px solid var(--rs-border)}
|
||||
.mi-aggregate h2{font-size:1.1rem;margin:1rem 0 .5rem;color:var(--rs-text-primary)}
|
||||
.mi-aggregate h2:first-child{margin-top:0}
|
||||
.mi-aggregate ul{padding-left:1.5rem;margin:.5rem 0}
|
||||
.mi-aggregate li{margin:.25rem 0;color:var(--rs-text-primary);line-height:1.5}
|
||||
.mi-section-title{font-size:1.2rem;margin:2rem 0 1rem;color:var(--rs-text-primary)}
|
||||
</style>`;
|
||||
|
||||
return c.html(renderShell({
|
||||
title: `Meeting Intelligence — rMeets | rSpace`,
|
||||
moduleId: "rmeets",
|
||||
spaceSlug: space,
|
||||
modules: getModuleInfoList(),
|
||||
styles: MI_STYLES + extraStyles,
|
||||
body: `<div class="mi-page">
|
||||
<h1>Meeting Intelligence</h1>
|
||||
<p class="mi-subtitle">Knowledge objects, action items, and insights across all meetings</p>
|
||||
<form class="mi-search-form" method="get" action="${base}/search">
|
||||
<input type="text" name="q" placeholder="Search across all meetings...">
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
${aggregateHtml ? `<h2 class="mi-section-title">Aggregate Knowledge</h2>${aggregateHtml}` : ""}
|
||||
<h2 class="mi-section-title">Meetings</h2>
|
||||
${meetingCards}
|
||||
</div>`,
|
||||
}));
|
||||
});
|
||||
|
||||
// ── Room embed (catch-all — must be LAST so /meet, /recordings, /search match first) ──
|
||||
|
||||
routes.get("/:room", (c) => {
|
||||
|
|
@ -543,7 +668,7 @@ routes.get("/:room", (c) => {
|
|||
}
|
||||
|
||||
// Default: clean full-screen Jitsi — no rSpace shell, mobile-friendly
|
||||
const jitsiRoom = encodeURIComponent(room);
|
||||
const jitsiRoom = encodeURIComponent(space + "_" + room);
|
||||
const meetsBase = `/${escapeHtml(space)}/rmeets`;
|
||||
return c.html(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
|
@ -585,6 +710,7 @@ routes.get("/:room", (c) => {
|
|||
<div class="mi-fab">
|
||||
<button class="mi-fab-btn" id="mi-fab-toggle" title="Meeting Intelligence">🧠</button>
|
||||
<div class="mi-dropdown" id="mi-dropdown">
|
||||
<a href="${meetsBase}/meeting-intelligence"><span class="mi-icon">🧠</span> Meeting Intelligence</a>
|
||||
<a href="${meetsBase}/recordings"><span class="mi-icon">🎥</span> Recordings</a>
|
||||
<a href="${meetsBase}/search"><span class="mi-icon">🔍</span> Search Transcripts</a>
|
||||
<div class="mi-sep"></div>
|
||||
|
|
@ -629,7 +755,7 @@ routes.get("/:room", (c) => {
|
|||
"raisehand","tileview","toggle-camera",
|
||||
"fullscreen","chat","settings",
|
||||
"participants-pane","select-background",
|
||||
"sharedvideo",
|
||||
"sharedvideo","shareaudio","meetingintelligence",
|
||||
],
|
||||
},
|
||||
interfaceConfigOverwrite: {
|
||||
|
|
|
|||
Loading…
Reference in New Issue