This commit is contained in:
2026-04-05 00:43:23 +05:30
commit 8be37d3e92
425 changed files with 101853 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
/**
* ThirdEye Meet — Service Worker (Manifest V3 background)
* Minimal: just keeps the extension alive and relays messages.
*/
chrome.runtime.onInstalled.addListener(() => {
console.log("[ThirdEye] Meet extension installed.");
});

View File

@@ -0,0 +1,255 @@
/**
* ThirdEye Meet Recorder — Content Script
* Injected into meet.google.com pages.
* Uses Web Speech API (Chrome built-in) for live transcription.
* Buffers transcript and POSTs chunks to ThirdEye backend every CHUNK_INTERVAL_MS.
*/
const CHUNK_INTERVAL_MS = 30000; // Send a chunk every 30 seconds
const MAX_BUFFER_CHARS = 8000; // ~2000 tokens — safe for LLM processing
let recognition = null;
let isRecording = false;
let transcriptBuffer = "";
let meetingId = null;
let backendUrl = "http://localhost:8000";
let ingestSecret = "thirdeye_meet_secret_change_me";
let groupId = "meet_sessions";
let chunkTimer = null;
let chunkCount = 0;
// --- Helpers ---
function getMeetingId() {
// Extract meeting code from URL: meet.google.com/abc-defg-hij
const match = window.location.pathname.match(/\/([a-z]{3}-[a-z]{4}-[a-z]{3})/i);
if (match) return match[1];
// Fallback: use timestamp-based ID
return `meet_${Date.now()}`;
}
function getParticipantName() {
// Try to find the user's own name from the Meet DOM
const nameEl = document.querySelector('[data-self-name]');
if (nameEl) return nameEl.getAttribute('data-self-name');
const profileEl = document.querySelector('img[data-iml]');
if (profileEl && profileEl.alt) return profileEl.alt;
return "Unknown";
}
// --- Transport ---
async function sendChunkToBackend(text, isFinal = false) {
if (!text || text.trim().length < 10) return; // Don't send near-empty chunks
const payload = {
meeting_id: meetingId,
group_id: groupId,
chunk_index: chunkCount++,
text: text.trim(),
speaker: getParticipantName(),
timestamp: new Date().toISOString(),
is_final: isFinal,
};
try {
const res = await fetch(`${backendUrl}/api/meet/ingest`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-ThirdEye-Secret": ingestSecret,
},
body: JSON.stringify(payload),
});
if (!res.ok) {
console.warn(`[ThirdEye] Backend rejected chunk: ${res.status}`);
} else {
console.log(`[ThirdEye] Chunk ${payload.chunk_index} sent (${text.length} chars)`);
chrome.runtime.sendMessage({
type: "CHUNK_SENT",
chunkIndex: payload.chunk_index,
charCount: text.length,
meetingId: meetingId,
});
}
} catch (err) {
console.warn(`[ThirdEye] Failed to send chunk: ${err.message}`);
// Buffer is NOT cleared on failure — next flush will include this text
transcriptBuffer = text + "\n" + transcriptBuffer;
}
}
// --- Periodic flush ---
function flushBuffer() {
if (transcriptBuffer.trim().length > 0) {
sendChunkToBackend(transcriptBuffer);
transcriptBuffer = "";
}
}
function startChunkTimer() {
if (chunkTimer) clearInterval(chunkTimer);
chunkTimer = setInterval(flushBuffer, CHUNK_INTERVAL_MS);
}
function stopChunkTimer() {
if (chunkTimer) {
clearInterval(chunkTimer);
chunkTimer = null;
}
}
// --- Web Speech API ---
function initSpeechRecognition() {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognition) {
console.error("[ThirdEye] Web Speech API not available in this browser.");
chrome.runtime.sendMessage({ type: "ERROR", message: "Web Speech API not supported." });
return null;
}
const rec = new SpeechRecognition();
rec.continuous = true; // Don't stop after first pause
rec.interimResults = true; // Get partial results while speaking
rec.lang = "en-US"; // Change if needed
rec.maxAlternatives = 1;
rec.onstart = () => {
console.log("[ThirdEye] Speech recognition started.");
chrome.runtime.sendMessage({ type: "STATUS", status: "recording", meetingId });
};
rec.onresult = (event) => {
let newText = "";
for (let i = event.resultIndex; i < event.results.length; i++) {
const result = event.results[i];
if (result.isFinal) {
newText += result[0].transcript + " ";
}
// We only accumulate FINAL results to avoid duplicates from interim results
}
if (newText.trim()) {
transcriptBuffer += newText;
// Guard: if buffer is getting huge, flush early
if (transcriptBuffer.length > MAX_BUFFER_CHARS) {
flushBuffer();
}
}
};
rec.onerror = (event) => {
console.warn(`[ThirdEye] Speech recognition error: ${event.error}`);
if (event.error === "not-allowed") {
chrome.runtime.sendMessage({
type: "ERROR",
message: "Microphone permission denied. Allow mic access in Chrome settings.",
});
} else if (event.error !== "no-speech") {
// Restart on non-fatal errors
setTimeout(() => { if (isRecording) rec.start(); }, 1000);
}
};
rec.onend = () => {
console.log("[ThirdEye] Speech recognition ended.");
if (isRecording) {
// Auto-restart: Chrome's Web Speech API stops after ~60s of silence
setTimeout(() => { if (isRecording) rec.start(); }, 250);
}
};
return rec;
}
// --- Public controls (called from popup via chrome.tabs.sendMessage) ---
async function startRecording(config = {}) {
if (isRecording) return;
// Load config from storage or use provided values
const stored = await chrome.storage.sync.get(["backendUrl", "ingestSecret", "groupId"]);
backendUrl = config.backendUrl || stored.backendUrl || "http://localhost:8000";
ingestSecret = config.ingestSecret || stored.ingestSecret || "thirdeye_meet_secret_change_me";
groupId = config.groupId || stored.groupId || "meet_sessions";
meetingId = getMeetingId();
chunkCount = 0;
transcriptBuffer = "";
isRecording = true;
recognition = initSpeechRecognition();
if (!recognition) {
isRecording = false;
return;
}
recognition.start();
startChunkTimer();
// Notify backend that a new meeting has started
try {
await fetch(`${backendUrl}/api/meet/start`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-ThirdEye-Secret": ingestSecret,
},
body: JSON.stringify({
meeting_id: meetingId,
group_id: groupId,
started_at: new Date().toISOString(),
speaker: getParticipantName(),
}),
});
} catch (err) {
console.warn("[ThirdEye] Could not notify backend of meeting start:", err.message);
}
}
async function stopRecording() {
if (!isRecording) return;
isRecording = false;
stopChunkTimer();
if (recognition) {
recognition.stop();
recognition = null;
}
// Send the final buffered chunk marked as final
await sendChunkToBackend(transcriptBuffer, true);
transcriptBuffer = "";
chrome.runtime.sendMessage({ type: "STATUS", status: "stopped", meetingId });
console.log("[ThirdEye] Recording stopped.");
}
// --- Message listener (from popup) ---
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === "START_RECORDING") {
startRecording(msg.config || {}).then(() => sendResponse({ ok: true }));
return true; // async
}
if (msg.type === "STOP_RECORDING") {
stopRecording().then(() => sendResponse({ ok: true }));
return true;
}
if (msg.type === "GET_STATUS") {
sendResponse({
isRecording,
meetingId,
bufferLength: transcriptBuffer.length,
chunkCount,
});
return true;
}
});
console.log("[ThirdEye] Content script loaded on", window.location.href);

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

View File

@@ -0,0 +1,33 @@
{
"manifest_version": 3,
"name": "ThirdEye Meet Recorder",
"version": "1.0.0",
"description": "Captures Google Meet transcripts and sends them to your ThirdEye knowledge base.",
"permissions": [
"activeTab",
"storage",
"scripting"
],
"host_permissions": [
"https://meet.google.com/*"
],
"background": {
"service_worker": "background.js"
},
"content_scripts": [
{
"matches": ["https://meet.google.com/*"],
"js": ["content.js"],
"run_at": "document_idle"
}
],
"action": {
"default_popup": "popup.html",
"default_title": "ThirdEye Meet Recorder"
},
"icons": {
"16": "icon16.png",
"48": "icon48.png",
"128": "icon128.png"
}
}

View File

@@ -0,0 +1,144 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ThirdEye Meet</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
width: 320px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 13px;
background: #0f172a;
color: #e2e8f0;
}
.header {
background: #1e293b;
padding: 12px 16px;
display: flex;
align-items: center;
gap: 8px;
border-bottom: 1px solid #334155;
}
.logo { font-weight: 700; font-size: 15px; color: #38bdf8; }
.badge {
font-size: 10px;
background: #0ea5e9;
color: white;
padding: 2px 6px;
border-radius: 9999px;
font-weight: 600;
}
.section { padding: 12px 16px; border-bottom: 1px solid #1e293b; }
.status-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.status-dot {
width: 10px; height: 10px;
border-radius: 50%;
display: inline-block;
margin-right: 6px;
}
.status-dot.idle { background: #475569; }
.status-dot.recording { background: #ef4444; animation: pulse 1.2s infinite; }
@keyframes pulse {
0%, 100% { opacity: 1; } 50% { opacity: 0.4; }
}
.meeting-id {
font-size: 11px;
color: #94a3b8;
font-family: monospace;
margin-top: 4px;
}
.stat { font-size: 11px; color: #64748b; }
.btn {
width: 100%;
padding: 9px;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
}
.btn:hover { opacity: 0.85; }
.btn-start { background: #0ea5e9; color: white; }
.btn-stop { background: #ef4444; color: white; }
.btn-disabled { background: #334155; color: #64748b; cursor: not-allowed; }
label { display: block; font-size: 11px; color: #94a3b8; margin-bottom: 3px; }
input {
width: 100%;
background: #1e293b;
border: 1px solid #334155;
color: #e2e8f0;
border-radius: 5px;
padding: 6px 8px;
font-size: 12px;
margin-bottom: 8px;
}
input:focus { outline: none; border-color: #0ea5e9; }
.save-btn {
background: #1e293b;
border: 1px solid #334155;
color: #94a3b8;
border-radius: 5px;
padding: 5px 10px;
font-size: 11px;
cursor: pointer;
width: 100%;
}
.save-btn:hover { border-color: #0ea5e9; color: #0ea5e9; }
.not-meet {
padding: 24px 16px;
text-align: center;
color: #64748b;
font-size: 12px;
}
.not-meet strong { display: block; color: #94a3b8; margin-bottom: 6px; }
</style>
</head>
<body>
<div class="header">
<span class="logo">👁 ThirdEye</span>
<span class="badge">Meet</span>
</div>
<div id="not-meet" class="not-meet" style="display:none;">
<strong>Not on Google Meet</strong>
Open a meeting at meet.google.com to start recording.
</div>
<div id="meet-ui" style="display:none;">
<div class="section">
<div class="status-row">
<div>
<span class="status-dot idle" id="status-dot"></span>
<span id="status-label">Idle</span>
</div>
<span class="stat" id="chunks-label">0 chunks sent</span>
</div>
<div class="meeting-id" id="meeting-id-label">Meeting ID: —</div>
</div>
<div class="section">
<button class="btn btn-start" id="main-btn">▶ Start Recording</button>
</div>
<div class="section">
<label>Backend URL</label>
<input type="text" id="backend-url" placeholder="http://localhost:8000" />
<label>Group ID</label>
<input type="text" id="group-id" placeholder="meet_sessions" />
<label>Ingest Secret</label>
<input type="password" id="ingest-secret" placeholder="thirdeye_meet_secret_change_me" />
<button class="save-btn" id="save-btn">Save Settings</button>
</div>
</div>
<script src="popup.js"></script>
</body>
</html>

View File

@@ -0,0 +1,109 @@
let currentTab = null;
let isRecording = false;
async function getActiveTab() {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
return tabs[0] || null;
}
function isMeetTab(tab) {
return tab && tab.url && tab.url.includes("meet.google.com");
}
async function loadSettings() {
const stored = await chrome.storage.sync.get(["backendUrl", "ingestSecret", "groupId"]);
document.getElementById("backend-url").value = stored.backendUrl || "http://localhost:8000";
document.getElementById("ingest-secret").value = stored.ingestSecret || "";
document.getElementById("group-id").value = stored.groupId || "meet_sessions";
}
async function saveSettings() {
const backendUrl = document.getElementById("backend-url").value.trim();
const ingestSecret = document.getElementById("ingest-secret").value.trim();
const groupId = document.getElementById("group-id").value.trim();
await chrome.storage.sync.set({ backendUrl, ingestSecret, groupId });
const btn = document.getElementById("save-btn");
btn.textContent = "✓ Saved";
setTimeout(() => { btn.textContent = "Save Settings"; }, 1500);
}
function setRecordingUI(recording, meetingId, chunks) {
isRecording = recording;
const dot = document.getElementById("status-dot");
const label = document.getElementById("status-label");
const btn = document.getElementById("main-btn");
const meetLabel = document.getElementById("meeting-id-label");
const chunksLabel = document.getElementById("chunks-label");
dot.className = "status-dot " + (recording ? "recording" : "idle");
label.textContent = recording ? "Recording…" : "Idle";
btn.className = "btn " + (recording ? "btn-stop" : "btn-start");
btn.textContent = recording ? "■ Stop Recording" : "▶ Start Recording";
meetLabel.textContent = meetingId ? `Meeting ID: ${meetingId}` : "Meeting ID: —";
chunksLabel.textContent = `${chunks || 0} chunks sent`;
}
async function getTabStatus() {
if (!currentTab) return;
try {
const status = await chrome.tabs.sendMessage(currentTab.id, { type: "GET_STATUS" });
setRecordingUI(status.isRecording, status.meetingId, status.chunkCount);
} catch {
setRecordingUI(false, null, 0);
}
}
async function handleMainBtn() {
if (!currentTab) return;
const stored = await chrome.storage.sync.get(["backendUrl", "ingestSecret", "groupId"]);
if (!isRecording) {
await chrome.tabs.sendMessage(currentTab.id, {
type: "START_RECORDING",
config: {
backendUrl: stored.backendUrl || "http://localhost:8000",
ingestSecret: stored.ingestSecret || "thirdeye_meet_secret_change_me",
groupId: stored.groupId || "meet_sessions",
},
});
} else {
await chrome.tabs.sendMessage(currentTab.id, { type: "STOP_RECORDING" });
}
// Poll for updated status
setTimeout(getTabStatus, 500);
}
// Listen for status updates from content script
chrome.runtime.onMessage.addListener((msg) => {
if (msg.type === "STATUS") {
setRecordingUI(msg.status === "recording", msg.meetingId, null);
}
if (msg.type === "CHUNK_SENT") {
document.getElementById("chunks-label").textContent = `${msg.chunkIndex + 1} chunks sent`;
document.getElementById("meeting-id-label").textContent = `Meeting ID: ${msg.meetingId}`;
}
});
// Init
document.addEventListener("DOMContentLoaded", async () => {
currentTab = await getActiveTab();
if (!isMeetTab(currentTab)) {
document.getElementById("not-meet").style.display = "block";
document.getElementById("meet-ui").style.display = "none";
return;
}
document.getElementById("meet-ui").style.display = "block";
document.getElementById("not-meet").style.display = "none";
await loadSettings();
await getTabStatus();
document.getElementById("main-btn").addEventListener("click", handleMainBtn);
document.getElementById("save-btn").addEventListener("click", saveSettings);
// Poll status every 5s while popup is open
setInterval(getTabStatus, 5000);
});

View File

@@ -0,0 +1,11 @@
import base64, os
# Minimal 1x1 white PNG
PNG_1x1 = base64.b64decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI6QAAAABJRU5ErkJggg=='
)
for size in [16, 48, 128]:
with open(f'meet_extension/icon{size}.png', 'wb') as f:
f.write(PNG_1x1)
print(f' Created icon{size}.png (placeholder)')