diff --git a/modules/rmeets/landing.ts b/modules/rmeets/landing.ts index 78e13b6..4a15718 100644 --- a/modules/rmeets/landing.ts +++ b/modules/rmeets/landing.ts @@ -102,14 +102,13 @@ export function renderLanding(): string {
-

On the Horizon

-

Features in development for the rMeets roadmap.

+

Built-in Intelligence

+

Every meeting automatically recorded, transcribed, and summarized — all on your own infrastructure.

🎤

Local Transcription

- Coming Soon -

On-device speech-to-text using Whisper. Transcripts generated locally — audio never sent to external APIs.

+

Speech-to-text powered by Whisper running on your server. Diarized transcripts with speaker identification — audio never leaves your infrastructure.

🖥
@@ -118,10 +117,9 @@ export function renderLanding(): string {

Point rMeets at any Jitsi instance. Run your own hardware, choose your jurisdiction, scale on your terms.

-
🔌
-

Data Integrations

- Coming Soon -

Auto-link recordings to documents, push summaries to rNotes, and sync action items to rTasks task boards.

+
🧠
+

AI Meeting Intelligence

+

Automatic summaries, key decisions, and action items extracted from every meeting. Full-text search across all your transcripts.

diff --git a/modules/rmeets/mod.ts b/modules/rmeets/mod.ts index d8cdc6b..4ec74bb 100644 --- a/modules/rmeets/mod.ts +++ b/modules/rmeets/mod.ts @@ -1,8 +1,9 @@ /** - * rMeets module — video meetings powered by Jitsi. + * rMeets module — video meetings powered by Jitsi + Meeting Intelligence. * * Hub page with Quick Meet + room name input, - * room pages embed Jitsi via renderExternalAppShell. + * room pages embed Jitsi via renderExternalAppShell, + * recordings/search pages pull from the Meeting Intelligence API. */ import { Hono } from "hono"; @@ -12,8 +13,85 @@ import type { RSpaceModule } from "../../shared/module"; import { renderLanding } from "./landing"; const JITSI_URL = process.env.JITSI_URL || "https://jeffsi.localvibe.live"; +const MI_API_URL = process.env.MEETING_INTELLIGENCE_API_URL || "http://meeting-intelligence-api:8000"; const routes = new Hono(); +// ── Meeting Intelligence API helper ── + +async function miApiFetch(path: string): Promise<{ ok: boolean; data?: any; error?: string }> { + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 8000); + const res = await fetch(`${MI_API_URL}${path}`, { signal: controller.signal }); + clearTimeout(timeout); + if (!res.ok) return { ok: false, error: `API returned ${res.status}` }; + return { ok: true, data: await res.json() }; + } catch (e: any) { + return { ok: false, error: e?.message || "Connection failed" }; + } +} + +function miUnavailableHtml(message = "Meeting intelligence is temporarily unavailable. Please try again later."): string { + return `
+
🔌
+

${escapeHtml(message)}

+
`; +} + +// ── Shared styles for MI pages ── + +const MI_STYLES = ``; + // ── Room embed ── routes.get("/room/:room", (c) => { @@ -61,6 +139,264 @@ routes.get("/meet", (c) => { })); }); +// ── Recordings list ── + +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"); + + let body: string; + if (!result.ok) { + body = miUnavailableHtml(); + } else { + const meetings = Array.isArray(result.data) ? result.data : (result.data?.meetings ?? result.data?.items ?? []); + if (meetings.length === 0) { + body = `

Recordings

No recorded meetings yet. Start a meeting and enable recording to see it here.

`; + } else { + const 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 participants = m.participant_count ?? m.participants?.length ?? ""; + const status = (m.status || "completed").toLowerCase(); + const badgeClass = `mi-badge--${status}`; + const hasTranscript = m.has_transcript ?? m.transcript_status === "completed"; + const hasSummary = m.has_summary ?? m.summary_status === "completed"; + return ` +

${escapeHtml(m.title || m.room_name || `Meeting ${m.id}`)}

+
${[date, duration, participants ? `${participants} participants` : ""].filter(Boolean).join(" · ")}
+ ${escapeHtml(status)} +
+ ${hasTranscript ? `Transcript` : `No transcript`} + ${hasSummary ? `Summary` : `No summary`} +
+
`; + }).join("\n"); + body = `

Recordings

Past meetings with transcripts and AI summaries

${cards}
`; + } + } + + return c.html(renderShell({ + title: `Recordings — rMeets | rSpace`, + moduleId: "rmeets", + spaceSlug: space, + modules: getModuleInfoList(), + styles: MI_STYLES, + body, + })); +}); + +// ── Recording detail redirect ── + +routes.get("/recordings/:id", (c) => { + const space = c.req.param("space") || "demo"; + const id = c.req.param("id"); + return c.redirect(`/${space}/rmeets/recordings/${id}/overview`); +}); + +// ── Recording detail with tabs ── + +routes.get("/recordings/:id/:tab", async (c) => { + const space = c.req.param("space") || "demo"; + const base = `/${escapeHtml(space)}/rmeets`; + const id = c.req.param("id"); + const tab = c.req.param("tab") || "overview"; + const validTabs = ["overview", "transcript", "summary", "speakers"]; + const activeTab = validTabs.includes(tab) ? tab : "overview"; + + const skip = { ok: false as const, data: undefined, error: "skipped" }; + const [meetingRes, transcriptRes, summaryRes, speakersRes] = await Promise.all([ + miApiFetch(`/meetings/${id}`), + activeTab === "transcript" ? miApiFetch(`/meetings/${id}/transcript`) : Promise.resolve(skip), + activeTab === "summary" ? miApiFetch(`/meetings/${id}/summary`) : Promise.resolve(skip), + (activeTab === "speakers" || activeTab === "overview") ? miApiFetch(`/meetings/${id}/speakers`) : Promise.resolve(skip), + ]); + + if (!meetingRes.ok) { + return c.html(renderShell({ + title: `Recording — rMeets | rSpace`, + moduleId: "rmeets", + spaceSlug: space, + modules: getModuleInfoList(), + styles: MI_STYLES, + body: miUnavailableHtml("Could not load this recording. The meeting intelligence service may be temporarily unavailable."), + })); + } + + const m = meetingRes.data; + const title = m.title || m.room_name || `Meeting ${id}`; + const date = m.created_at ? new Date(m.created_at).toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric", year: "numeric" }) : ""; + const duration = m.duration_seconds ? `${Math.round(m.duration_seconds / 60)} minutes` : ""; + const participants = m.participant_count ?? m.participants?.length ?? ""; + + const tabs = validTabs.map(t => { + const label = t.charAt(0).toUpperCase() + t.slice(1); + const cls = t === activeTab ? "mi-tab mi-tab--active" : "mi-tab"; + return `${label}`; + }).join(""); + + let tabContent: string; + + if (activeTab === "overview") { + const speakers = speakersRes.ok ? (Array.isArray(speakersRes.data) ? speakersRes.data : speakersRes.data?.speakers ?? []) : []; + const speakerList = speakers.length > 0 + ? `
${speakers.map((s: any) => { + const time = s.speaking_time_seconds ? `${Math.round(s.speaking_time_seconds / 60)}min speaking` : ""; + return `

${escapeHtml(s.name || s.label || `Speaker ${s.id}`)}

${escapeHtml(time)}

`; + }).join("")}
` + : `

No speaker data available.

`; + tabContent = `
+ ${date ? `${escapeHtml(date)}` : ""} + ${duration ? `${escapeHtml(duration)}` : ""} + ${participants ? `${participants} participants` : ""} +
+

Speakers

+${speakerList}`; + } else if (activeTab === "transcript") { + if (!transcriptRes.ok) { + tabContent = `

Transcript not available yet.

`; + } else { + const segments = Array.isArray(transcriptRes.data) ? transcriptRes.data : (transcriptRes.data?.segments ?? transcriptRes.data?.transcript ?? []); + if (segments.length === 0) { + tabContent = `

Transcript is empty.

`; + } else { + tabContent = `
${segments.map((seg: any) => { + const speaker = seg.speaker || seg.speaker_name || seg.label || "Unknown"; + const ts = seg.start_time != null ? formatTimestamp(seg.start_time) : ""; + const text = seg.text || seg.content || ""; + return `
+ ${escapeHtml(speaker)}${escapeHtml(ts)} +
${escapeHtml(text)}
+
`; + }).join("")}
`; + } + } + } else if (activeTab === "summary") { + if (!summaryRes.ok) { + tabContent = `

Summary not available yet.

`; + } else { + const summary = summaryRes.data; + const overview = summary.overview || summary.summary || summary.text || ""; + const keyPoints = summary.key_points ?? summary.keyPoints ?? []; + const actionItems = summary.action_items ?? summary.actionItems ?? []; + const decisions = summary.decisions ?? []; + + let html = `
`; + if (overview) html += `

Overview

${escapeHtml(overview)}

`; + if (keyPoints.length > 0) html += `

Key Points

`; + if (actionItems.length > 0) html += `

Action Items

`; + if (decisions.length > 0) html += `

Decisions

`; + if (!overview && keyPoints.length === 0 && actionItems.length === 0) html += `

Summary content is empty.

`; + html += `
`; + tabContent = html; + } + } else if (activeTab === "speakers") { + const speakers = speakersRes.ok ? (Array.isArray(speakersRes.data) ? speakersRes.data : speakersRes.data?.speakers ?? []) : []; + if (speakers.length === 0) { + tabContent = `

No speaker data available.

`; + } else { + tabContent = `
${speakers.map((s: any) => { + const time = s.speaking_time_seconds ? `${Math.round(s.speaking_time_seconds / 60)}min speaking` : ""; + const segments = s.segment_count ?? s.num_segments ?? ""; + return `

${escapeHtml(s.name || s.label || `Speaker ${s.id}`)}

${[time, segments ? `${segments} segments` : ""].filter(Boolean).join(" · ")}

`; + }).join("")}
`; + } + } else { + tabContent = ""; + } + + return c.html(renderShell({ + title: `${title} — rMeets | rSpace`, + moduleId: "rmeets", + spaceSlug: space, + modules: getModuleInfoList(), + styles: MI_STYLES, + body: `
+

${escapeHtml(title)}

+
${tabs}
+ ${tabContent} +
`, + })); +}); + +// ── Search ── + +routes.get("/search", async (c) => { + const space = c.req.param("space") || "demo"; + const base = `/${escapeHtml(space)}/rmeets`; + const q = c.req.query("q") || ""; + + let resultsHtml = ""; + if (q) { + const result = await miApiFetch(`/search?q=${encodeURIComponent(q)}&limit=30`); + if (!result.ok) { + resultsHtml = miUnavailableHtml(); + } else { + const hits = Array.isArray(result.data) ? result.data : (result.data?.results ?? result.data?.hits ?? []); + if (hits.length === 0) { + resultsHtml = `

No results found for "${escapeHtml(q)}".

`; + } else { + resultsHtml = hits.map((h: any) => { + const meetingId = h.meeting_id ?? h.meetingId ?? h.id; + const title = h.meeting_title || h.title || `Meeting ${meetingId}`; + const excerpt = h.highlight || h.excerpt || h.text || ""; + return ` +

${escapeHtml(title)}

+
${excerpt}
+
`; + }).join(""); + } + } + } + + return c.html(renderShell({ + title: `Search — rMeets | rSpace`, + moduleId: "rmeets", + spaceSlug: space, + modules: getModuleInfoList(), + styles: MI_STYLES, + body: `
+

Search Transcripts

+

Full-text search across all meeting transcripts

+
+ + +
+ ${resultsHtml} +
`, + })); +}); + +// ── MI API proxy (for client-side JS, avoids CORS) ── + +routes.all("/api/mi-proxy/*", async (c) => { + const proxyPath = c.req.path.replace(/^\/[^/]+\/rmeets\/api\/mi-proxy/, ""); + const url = new URL(`${MI_API_URL}${proxyPath}`); + // Forward query params + const query = c.req.query(); + for (const [k, v] of Object.entries(query)) { + if (typeof v === "string") url.searchParams.set(k, v); + } + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 8000); + const upstream = await fetch(url.toString(), { + method: c.req.method, + headers: { "Content-Type": "application/json" }, + body: c.req.method !== "GET" && c.req.method !== "HEAD" ? await c.req.text() : undefined, + signal: controller.signal, + }); + clearTimeout(timeout); + const body = await upstream.text(); + return new Response(body, { + status: upstream.status, + headers: { "Content-Type": upstream.headers.get("Content-Type") || "application/json" }, + }); + } catch { + return c.json({ error: "Meeting intelligence service unavailable" }, 502); + } +}); + // ── Hub page ── routes.get("/", (c) => { @@ -94,14 +430,14 @@ routes.get("/", (c) => {

Video meetings powered by Jitsi — no account required

`, })); }); +// ── Helpers ── + +function formatTimestamp(seconds: number): string { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = Math.floor(seconds % 60); + if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`; + return `${m}:${String(s).padStart(2, "0")}`; +} + // ── Module export ── export const meetsModule: RSpaceModule = { @@ -135,6 +495,9 @@ export const meetsModule: RSpaceModule = { outputPaths: [ { path: "meet", name: "Create Call", icon: "🚀", description: "Start a new video meeting" }, { path: "rooms", name: "Rooms", icon: "🚪", description: "Meeting rooms and video calls" }, - { path: "recordings", name: "Recordings", icon: "🎥", description: "Meeting recordings" }, + ], + onboardingActions: [ + { label: "Start a Meeting", icon: "🚀", description: "Jump into an instant video call", type: "create", href: "/{space}/rmeets/room/quick" }, + { label: "View Recordings", icon: "🎥", description: "Browse past meetings with transcripts and AI summaries", type: "link", href: "/{space}/rmeets/recordings" }, ], };