Merge branch 'dev'

This commit is contained in:
Jeff Emmett 2026-03-22 18:04:29 -07:00
commit dc3bff9f70
2 changed files with 375 additions and 14 deletions

View File

@ -102,14 +102,13 @@ export function renderLanding(): string {
<!-- Roadmap -->
<section class="rl-section rl-section--alt">
<div class="rl-container">
<h2 class="rl-heading" style="text-align:center">On the Horizon</h2>
<p class="rl-subtext" style="text-align:center">Features in development for the rMeets roadmap.</p>
<h2 class="rl-heading" style="text-align:center">Built-in Intelligence</h2>
<p class="rl-subtext" style="text-align:center">Every meeting automatically recorded, transcribed, and summarized &mdash; all on your own infrastructure.</p>
<div class="rl-grid-3">
<div class="rl-card rl-card--center">
<div class="rl-icon-box">&#127908;</div>
<h3>Local Transcription</h3>
<span class="rl-badge">Coming Soon</span>
<p>On-device speech-to-text using Whisper. Transcripts generated locally &mdash; audio never sent to external APIs.</p>
<p>Speech-to-text powered by Whisper running on your server. Diarized transcripts with speaker identification &mdash; audio never leaves your infrastructure.</p>
</div>
<div class="rl-card rl-card--center">
<div class="rl-icon-box">&#128421;</div>
@ -118,10 +117,9 @@ export function renderLanding(): string {
<p>Point rMeets at any Jitsi instance. Run your own hardware, choose your jurisdiction, scale on your terms.</p>
</div>
<div class="rl-card rl-card--center">
<div class="rl-icon-box">&#128268;</div>
<h3>Data Integrations</h3>
<span class="rl-badge">Coming Soon</span>
<p>Auto-link recordings to documents, push summaries to rNotes, and sync action items to rTasks task boards.</p>
<div class="rl-icon-box">&#129504;</div>
<h3>AI Meeting Intelligence</h3>
<p>Automatic summaries, key decisions, and action items extracted from every meeting. Full-text search across all your transcripts.</p>
</div>
</div>
</div>

View File

@ -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 `<div style="max-width:600px;margin:3rem auto;padding:2rem;text-align:center;color:var(--rs-text-secondary)">
<div style="font-size:2.5rem;margin-bottom:1rem">&#128268;</div>
<p>${escapeHtml(message)}</p>
</div>`;
}
// ── Shared styles for MI pages ──
const MI_STYLES = `<style>
.mi-page{max-width:900px;margin:2rem auto;padding:0 1.5rem}
.mi-page h1{font-size:1.8rem;margin-bottom:.25rem;color:var(--rs-text-primary)}
.mi-page .mi-subtitle{color:var(--rs-text-secondary);margin-bottom:2rem}
.mi-cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:1rem}
.mi-card{padding:1.25rem;border-radius:12px;background:var(--rs-bg-surface);border:1px solid var(--rs-border);text-decoration:none;color:inherit;transition:border-color .15s,background .15s;display:block}
.mi-card:hover{border-color:var(--rs-accent);background:var(--rs-bg-hover)}
.mi-card h3{margin:0 0 .5rem;font-size:1.05rem;color:var(--rs-text-primary)}
.mi-card .mi-meta{font-size:.8rem;color:var(--rs-text-secondary);margin-bottom:.5rem}
.mi-chips{display:flex;gap:.5rem;flex-wrap:wrap;margin-top:.75rem}
.mi-chip{display:inline-block;padding:.15rem .6rem;border-radius:99px;font-size:.75rem;font-weight:500}
.mi-chip--ok{background:var(--rs-accent);color:#fff;opacity:.8}
.mi-chip--pending{background:var(--rs-border);color:var(--rs-text-secondary)}
.mi-badge{display:inline-block;padding:.2rem .6rem;border-radius:6px;font-size:.75rem;font-weight:600;text-transform:uppercase}
.mi-badge--completed{background:#22c55e22;color:#22c55e}
.mi-badge--processing{background:#eab30822;color:#eab308}
.mi-badge--recording{background:#ef444422;color:#ef4444}
.mi-badge--failed{background:#ef444422;color:#ef4444}
.mi-tabs{display:flex;gap:0;border-bottom:1px solid var(--rs-border);margin-bottom:1.5rem}
.mi-tab{padding:.75rem 1.25rem;text-decoration:none;color:var(--rs-text-secondary);font-size:.9rem;font-weight:500;border-bottom:2px solid transparent;transition:color .15s,border-color .15s}
.mi-tab:hover{color:var(--rs-text-primary)}
.mi-tab--active{color:var(--rs-accent);border-bottom-color:var(--rs-accent)}
.mi-detail-header{margin-bottom:1.5rem}
.mi-detail-header h1{margin-bottom:.5rem}
.mi-detail-meta{display:flex;gap:1.5rem;flex-wrap:wrap;font-size:.85rem;color:var(--rs-text-secondary)}
.mi-transcript{display:flex;flex-direction:column;gap:1rem}
.mi-segment{padding:.75rem 1rem;border-left:3px solid var(--rs-accent);background:var(--rs-bg-surface);border-radius:0 8px 8px 0}
.mi-segment .mi-speaker{font-weight:600;color:var(--rs-accent);font-size:.85rem}
.mi-segment .mi-timestamp{font-size:.75rem;color:var(--rs-text-secondary);margin-left:.75rem}
.mi-segment .mi-text{margin-top:.35rem;color:var(--rs-text-primary);line-height:1.5}
.mi-summary{color:var(--rs-text-primary);line-height:1.6}
.mi-summary h2{font-size:1.2rem;margin:1.5rem 0 .5rem;color:var(--rs-text-primary)}
.mi-summary h2:first-child{margin-top:0}
.mi-summary ul{padding-left:1.5rem;margin:.5rem 0}
.mi-summary li{margin:.25rem 0}
.mi-speakers{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:1rem}
.mi-speaker-card{padding:1rem;border-radius:10px;background:var(--rs-bg-surface);border:1px solid var(--rs-border)}
.mi-speaker-card h4{margin:0 0 .25rem;color:var(--rs-text-primary)}
.mi-speaker-card p{margin:0;font-size:.85rem;color:var(--rs-text-secondary)}
.mi-search-form{display:flex;gap:.75rem;margin-bottom:2rem}
.mi-search-form input{flex:1;padding:.65rem 1rem;border-radius:8px;border:1px solid var(--rs-input-border);background:var(--rs-input-bg);color:var(--rs-input-text);font-size:.95rem}
.mi-search-form input:focus{outline:none;border-color:var(--rs-accent)}
.mi-search-form button{padding:.65rem 1.5rem;border-radius:8px;border:none;background:var(--rs-accent);color:#fff;font-weight:600;cursor:pointer;font-size:.95rem}
.mi-search-form button:hover{background:var(--rs-accent-hover)}
.mi-result{padding:1rem 1.25rem;border-radius:10px;background:var(--rs-bg-surface);border:1px solid var(--rs-border);margin-bottom:.75rem;display:block;text-decoration:none;color:inherit;transition:border-color .15s}
.mi-result:hover{border-color:var(--rs-accent)}
.mi-result h3{margin:0 0 .25rem;font-size:1rem;color:var(--rs-text-primary)}
.mi-result .mi-excerpt{font-size:.85rem;color:var(--rs-text-secondary);line-height:1.4}
.mi-result mark{background:var(--rs-accent);color:#fff;padding:0 .2rem;border-radius:3px}
@media(max-width:600px){.mi-page{margin:1rem auto;padding:0 .75rem}.mi-cards{grid-template-columns:1fr}}
</style>`;
// ── 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 = `<div class="mi-page"><h1>Recordings</h1><p class="mi-subtitle">No recorded meetings yet. Start a meeting and enable recording to see it here.</p></div>`;
} 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 `<a class="mi-card" href="${base}/recordings/${escapeHtml(String(m.id))}">
<h3>${escapeHtml(m.title || m.room_name || `Meeting ${m.id}`)}</h3>
<div class="mi-meta">${[date, duration, participants ? `${participants} participants` : ""].filter(Boolean).join(" &middot; ")}</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");
body = `<div class="mi-page"><h1>Recordings</h1><p class="mi-subtitle">Past meetings with transcripts and AI summaries</p><div class="mi-cards">${cards}</div></div>`;
}
}
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 `<a class="${cls}" href="${base}/recordings/${escapeHtml(String(id))}/${t}">${label}</a>`;
}).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
? `<div class="mi-speakers">${speakers.map((s: any) => {
const time = s.speaking_time_seconds ? `${Math.round(s.speaking_time_seconds / 60)}min speaking` : "";
return `<div class="mi-speaker-card"><h4>${escapeHtml(s.name || s.label || `Speaker ${s.id}`)}</h4><p>${escapeHtml(time)}</p></div>`;
}).join("")}</div>`
: `<p style="color:var(--rs-text-secondary)">No speaker data available.</p>`;
tabContent = `<div class="mi-detail-meta">
${date ? `<span>${escapeHtml(date)}</span>` : ""}
${duration ? `<span>${escapeHtml(duration)}</span>` : ""}
${participants ? `<span>${participants} participants</span>` : ""}
</div>
<h2 style="font-size:1.1rem;margin:1.5rem 0 .75rem;color:var(--rs-text-primary)">Speakers</h2>
${speakerList}`;
} else if (activeTab === "transcript") {
if (!transcriptRes.ok) {
tabContent = `<p style="color:var(--rs-text-secondary)">Transcript not available yet.</p>`;
} else {
const segments = Array.isArray(transcriptRes.data) ? transcriptRes.data : (transcriptRes.data?.segments ?? transcriptRes.data?.transcript ?? []);
if (segments.length === 0) {
tabContent = `<p style="color:var(--rs-text-secondary)">Transcript is empty.</p>`;
} else {
tabContent = `<div class="mi-transcript">${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 `<div class="mi-segment">
<span class="mi-speaker">${escapeHtml(speaker)}</span><span class="mi-timestamp">${escapeHtml(ts)}</span>
<div class="mi-text">${escapeHtml(text)}</div>
</div>`;
}).join("")}</div>`;
}
}
} else if (activeTab === "summary") {
if (!summaryRes.ok) {
tabContent = `<p style="color:var(--rs-text-secondary)">Summary not available yet.</p>`;
} 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 = `<div class="mi-summary">`;
if (overview) html += `<h2>Overview</h2><p>${escapeHtml(overview)}</p>`;
if (keyPoints.length > 0) html += `<h2>Key Points</h2><ul>${keyPoints.map((p: string) => `<li>${escapeHtml(p)}</li>`).join("")}</ul>`;
if (actionItems.length > 0) html += `<h2>Action Items</h2><ul>${actionItems.map((a: any) => `<li>${escapeHtml(typeof a === "string" ? a : a.text || a.description || "")}</li>`).join("")}</ul>`;
if (decisions.length > 0) html += `<h2>Decisions</h2><ul>${decisions.map((d: any) => `<li>${escapeHtml(typeof d === "string" ? d : d.text || d.description || "")}</li>`).join("")}</ul>`;
if (!overview && keyPoints.length === 0 && actionItems.length === 0) html += `<p style="color:var(--rs-text-secondary)">Summary content is empty.</p>`;
html += `</div>`;
tabContent = html;
}
} else if (activeTab === "speakers") {
const speakers = speakersRes.ok ? (Array.isArray(speakersRes.data) ? speakersRes.data : speakersRes.data?.speakers ?? []) : [];
if (speakers.length === 0) {
tabContent = `<p style="color:var(--rs-text-secondary)">No speaker data available.</p>`;
} else {
tabContent = `<div class="mi-speakers">${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 `<div class="mi-speaker-card"><h4>${escapeHtml(s.name || s.label || `Speaker ${s.id}`)}</h4><p>${[time, segments ? `${segments} segments` : ""].filter(Boolean).join(" &middot; ")}</p></div>`;
}).join("")}</div>`;
}
} else {
tabContent = "";
}
return c.html(renderShell({
title: `${title} — rMeets | rSpace`,
moduleId: "rmeets",
spaceSlug: space,
modules: getModuleInfoList(),
styles: MI_STYLES,
body: `<div class="mi-page">
<div class="mi-detail-header"><h1>${escapeHtml(title)}</h1></div>
<div class="mi-tabs">${tabs}</div>
${tabContent}
</div>`,
}));
});
// ── 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 = `<p style="color:var(--rs-text-secondary)">No results found for "${escapeHtml(q)}".</p>`;
} 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 `<a class="mi-result" href="${base}/recordings/${escapeHtml(String(meetingId))}/transcript">
<h3>${escapeHtml(title)}</h3>
<div class="mi-excerpt">${excerpt}</div>
</a>`;
}).join("");
}
}
}
return c.html(renderShell({
title: `Search — rMeets | rSpace`,
moduleId: "rmeets",
spaceSlug: space,
modules: getModuleInfoList(),
styles: MI_STYLES,
body: `<div class="mi-page">
<h1>Search Transcripts</h1>
<p class="mi-subtitle">Full-text search across all meeting transcripts</p>
<form class="mi-search-form" method="get" action="${base}/search">
<input type="text" name="q" placeholder="Search meetings..." value="${escapeHtml(q)}">
<button type="submit">Search</button>
</form>
${resultsHtml}
</div>`,
}));
});
// ── 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) => {
<p>Video meetings powered by Jitsi no account required</p>
<nav class="rs-nav">
<a href="${base}/room/${randomId}">
<span class="nav-icon">🚀</span>
<span class="nav-icon">&#128640;</span>
<div class="nav-body">
<h3>Quick Meet</h3>
<p>Start an instant meeting with a random room name</p>
</div>
</a>
<form class="room-form" onsubmit="event.preventDefault();var n=this.querySelector('input').value.trim();if(n)location.href='${base}/room/'+encodeURIComponent(n)">
<span class="nav-icon">🔗</span>
<span class="nav-icon">&#128279;</span>
<div class="nav-body">
<h3>Join a Room</h3>
<p>Enter a room name to join or create a meeting</p>
@ -110,17 +446,41 @@ routes.get("/", (c) => {
<button type="submit">Join</button>
</form>
<a href="${base}/meet">
<span class="nav-icon">📹</span>
<span class="nav-icon">&#128249;</span>
<div class="nav-body">
<h3>Jitsi Lobby</h3>
<p>Open the full Jitsi Meet interface directly</p>
</div>
</a>
<a href="${base}/recordings">
<span class="nav-icon">&#127909;</span>
<div class="nav-body">
<h3>Recordings</h3>
<p>Browse past meetings, transcripts and AI summaries</p>
</div>
</a>
<a href="${base}/search">
<span class="nav-icon">&#128269;</span>
<div class="nav-body">
<h3>Search</h3>
<p>Full-text search across all meeting transcripts</p>
</div>
</a>
</nav>
</div>`,
}));
});
// ── 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" },
],
};