diff --git a/integrations/chrome-extension/README.md b/integrations/chrome-extension/README.md new file mode 100644 index 00000000..e00721dc --- /dev/null +++ b/integrations/chrome-extension/README.md @@ -0,0 +1,129 @@ +# Chrome Extension — Browser Capture & Search + +Save and search your Open Brain second brain directly from your browser. Capture thoughts from any webpage with automatic source tracking, search semantically across all your thoughts, and use keyboard shortcuts for fast access. + +## Features + +- **Save thoughts** from any webpage — source URL and page title are attached automatically +- **Semantic search** across all your thoughts (browser, Telegram, Slack, Claude) +- **Auto-capture** — select text on a page, open the extension, and it's pre-filled +- **Right-click menu** — save or search selected text without opening the popup +- **Omnibox** — type `brain ` in the address bar for instant search +- **Related thoughts** — after saving, see similar thoughts you captured before +- **Source filter** — filter search results by origin (browser, Telegram, Slack, Claude) +- **Click to copy** — click any search result to copy it to clipboard +- **Delete and complete** — remove thoughts or mark tasks as done directly from search results +- **Keyboard shortcut** — `Ctrl+Shift+B` (or `Cmd+Shift+B` on Mac) to open +- **Stats bar** — see total thoughts, today's count, and this week's count at a glance + +## Prerequisites + +- A working [Open Brain](https://github.com/NateBJones-Projects/OB1) setup with: + - The `thoughts` table and `match_thoughts` function in Supabase + - An [OpenRouter](https://openrouter.ai) API key (for embeddings and metadata extraction) +- Chrome or any Chromium-based browser (Edge, Brave, Arc, etc.) +- Supabase CLI installed (`npm install -g supabase`) + +## Step-by-Step Setup + +### 1. Deploy the brain-api Edge Function + +The extension communicates with your Open Brain through a lightweight REST API (included in `supabase-function/`). + +**Set your secrets** (one-time): + +```bash +supabase secrets set OPENROUTER_API_KEY=your-openrouter-key --project-ref YOUR_PROJECT_REF +supabase secrets set BRAIN_API_KEY=$(openssl rand -hex 32) --project-ref YOUR_PROJECT_REF +``` + +Write down the `BRAIN_API_KEY` you generated — you will need it in step 3. + +**Deploy the function:** + +```bash +cd supabase-function +supabase functions deploy brain-api --project-ref YOUR_PROJECT_REF --no-verify-jwt +``` + +**Verify it works:** + +```bash +curl -X POST https://YOUR_PROJECT_REF.supabase.co/functions/v1/brain-api \ + -H "Content-Type: application/json" \ + -H "x-brain-key: YOUR_BRAIN_API_KEY" \ + -d '{"action": "stats"}' +``` + +You should see a JSON response with `total`, `today`, and `this_week` counts. + +### 2. Install the Chrome Extension + +1. Open `chrome://extensions/` in your browser +2. Enable **Developer mode** (toggle in the top right) +3. Click **Load unpacked** +4. Navigate to the `chrome-extension/` folder inside this contribution and select it +5. The Open Brain icon (brain emoji) should now appear in your toolbar + +### 3. Configure the Extension + +1. Click the Open Brain icon in your toolbar (or press `Ctrl+Shift+B`) +2. The settings panel opens automatically on first use +3. Enter: + - **API URL**: `https://YOUR_PROJECT_REF.supabase.co/functions/v1/brain-api` + - **API Key**: the `BRAIN_API_KEY` you generated in step 1 +4. Click **Einstellungen speichern** + +The stats bar should now show your thought counts. + +## Expected Outcome + +After setup, you should be able to: + +1. **Open the popup** with `Ctrl+Shift+B` and see your thought stats (total, today, this week) +2. **Type a thought** in the text area and press `Ctrl+Enter` to save it — you should see "Gedanke gespeichert!" and related thoughts appear below +3. **Search** by typing a query in the search field — results appear with source badges, dates, and similarity scores +4. **Right-click** selected text on any webpage and see "Im Brain speichern" and "Im Brain suchen" in the context menu +5. **Type `brain` in the address bar**, press Tab, and type a query to get instant search suggestions + +## Usage + +| Action | How | +| --- | --- | +| Save a thought | Open popup, type, press `Ctrl+Enter` | +| Save selected text | Select text on page, right-click, "Im Brain speichern" | +| Search | Open popup, type in search field, press Enter | +| Search selected text | Select text, right-click, "Im Brain suchen" | +| Omnibox search | Type `brain` in address bar, press Tab, type query | +| Copy a result | Click on the result text | +| Delete a thought | Hover over result, click X, confirm | +| Complete a task | Hover over a task result, click "Erledigt" | +| Filter by source | Use the dropdown next to the search field | + +## Troubleshooting + +**"API nicht konfiguriert" error** +Open the extension, expand Settings at the bottom, and enter your API URL and API Key. Make sure there are no trailing spaces. + +**Stats show "--" after configuration** +Your brain-api function may not be deployed or the API key is wrong. Test with the curl command from step 1 above. + +**Right-click menu doesn't appear** +Go to `chrome://extensions/`, find Open Brain, and click the reload button. The context menu is registered on install — reloading the extension re-triggers it. + +**"Suche laeuft..." hangs forever** +Check that your Supabase project is active (not paused). Free-tier projects pause after 7 days of inactivity. Go to your Supabase dashboard and restore it if needed. + +**Extension doesn't capture selected text automatically** +Auto-capture doesn't work on `chrome://` pages, PDF viewers, or pages with strict Content Security Policy. This is a Chrome security restriction. + +## Tech Stack + +- Chrome Extension Manifest V3 +- Supabase Edge Functions (Deno/TypeScript) +- pgvector for semantic search +- OpenRouter API (text-embedding-3-small for embeddings, gpt-4o-mini for metadata extraction) + +## License + +MIT diff --git a/integrations/chrome-extension/chrome-extension/background.js b/integrations/chrome-extension/chrome-extension/background.js new file mode 100644 index 00000000..6b6ef938 --- /dev/null +++ b/integrations/chrome-extension/chrome-extension/background.js @@ -0,0 +1,203 @@ +// --- Context Menu Setup --- + +chrome.runtime.onInstalled.addListener(() => { + // Context menu: save selected text to Brain + chrome.contextMenus.create({ + id: "save-to-brain", + title: "Save to Brain", + contexts: ["selection"], + }); + + // Context menu: search selected text in Brain + chrome.contextMenus.create({ + id: "search-in-brain", + title: "Search in Brain", + contexts: ["selection"], + }); +}); + +// --- Context Menu Handler --- + +chrome.contextMenus.onClicked.addListener(async (info, tab) => { + const selectedText = info.selectionText; + if (!selectedText) return; + + // --- "Search in Brain" --- + if (info.menuItemId === "search-in-brain") { + // Store the search query so the popup can pick it up + await chrome.storage.local.set({ pendingSearch: selectedText }); + + // Try to open the popup programmatically (requires Chrome 99+) + try { + await chrome.action.openPopup(); + } catch (err) { + // openPopup() not available or failed -- the popup will pick up + // pendingSearch on next manual open + console.log("[Open Brain] openPopup not available, pendingSearch set:", err); + } + return; + } + + // --- "Save to Brain" --- + if (info.menuItemId !== "save-to-brain") return; + + const pageUrl = tab?.url || "unknown"; + const pageTitle = tab?.title || "Unknown page"; + + // Format: content + source context + const content = `${selectedText}\n\n(Source: ${pageTitle} \u2014 ${pageUrl})`; + + try { + const { apiUrl, apiKey } = await chrome.storage.sync.get([ + "apiUrl", + "apiKey", + ]); + + if (!apiUrl || !apiKey) { + showNotification( + "Configuration missing", + "Please configure the API URL and API Key in settings.", + false + ); + return; + } + + const response = await fetch(apiUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-brain-key": apiKey, + }, + body: JSON.stringify({ + action: "save", + content: content, + metadata: { + source: "browser", + url: pageUrl, + title: pageTitle, + }, + }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`API error (${response.status}): ${text}`); + } + + showNotification( + "Thought saved", + `"${truncate(selectedText, 80)}" has been saved to Brain.`, + true + ); + } catch (err) { + console.error("Open Brain - Error saving:", err); + showNotification( + "Error saving", + err.message || "Unknown error", + false + ); + } +}); + +// --- Omnibox (address bar: "brain ") --- + +chrome.omnibox.onInputStarted.addListener(() => { + chrome.omnibox.setDefaultSuggestion({ + description: "Search Brain: %s", + }); +}); + +chrome.omnibox.onInputChanged.addListener(async (text, suggest) => { + const query = text.trim(); + if (!query || query.length < 2) { + suggest([]); + return; + } + + try { + const { apiUrl, apiKey } = await chrome.storage.sync.get(["apiUrl", "apiKey"]); + if (!apiUrl || !apiKey) { + suggest([]); + return; + } + + const response = await fetch(apiUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-brain-key": apiKey, + }, + body: JSON.stringify({ action: "search", query: query }), + }); + + if (!response.ok) { + suggest([]); + return; + } + + const data = await response.json(); + const results = data.results || data.thoughts || []; + + const suggestions = results.slice(0, 5).map((item) => { + const content = item.content || item.thought || item.text || ""; + // Omnibox description supports XML: escape special chars + const desc = escapeXml(truncate(content, 200)); + // content field is used when suggestion is selected + return { + content: content, + description: desc, + }; + }); + + suggest(suggestions); + } catch (err) { + console.error("[Open Brain] Omnibox search failed:", err); + suggest([]); + } +}); + +chrome.omnibox.onInputEntered.addListener(async (text, disposition) => { + // text is either the typed query (default suggestion) or the selected suggestion content + // Store as pendingSearch so popup can pick it up, then try to open popup + await chrome.storage.local.set({ pendingSearch: text }); + + try { + await chrome.action.openPopup(); + } catch (err) { + // If openPopup fails, copy to clipboard via offscreen or just log + console.log("[Open Brain] openPopup not available after omnibox selection:", err); + } +}); + +// --- Notification Helper --- + +function showNotification(title, message, success) { + // Use the badge as a quick visual indicator + const badgeText = success ? "OK" : "ERR"; + const badgeColor = success ? "#4caf50" : "#ef5350"; + + chrome.action.setBadgeText({ text: badgeText }); + chrome.action.setBadgeBackgroundColor({ color: badgeColor }); + + // Clear badge after 3 seconds + setTimeout(() => { + chrome.action.setBadgeText({ text: "" }); + }, 3000); + + // Log for debugging + console.log(`[Open Brain] ${title}: ${message}`); +} + +function truncate(str, maxLen) { + if (str.length <= maxLen) return str; + return str.substring(0, maxLen - 3) + "..."; +} + +function escapeXml(str) { + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/integrations/chrome-extension/chrome-extension/icons/icon128.png b/integrations/chrome-extension/chrome-extension/icons/icon128.png new file mode 100644 index 00000000..d2c78dd6 Binary files /dev/null and b/integrations/chrome-extension/chrome-extension/icons/icon128.png differ diff --git a/integrations/chrome-extension/chrome-extension/icons/icon16.png b/integrations/chrome-extension/chrome-extension/icons/icon16.png new file mode 100644 index 00000000..8e6b11e9 Binary files /dev/null and b/integrations/chrome-extension/chrome-extension/icons/icon16.png differ diff --git a/integrations/chrome-extension/chrome-extension/icons/icon32.png b/integrations/chrome-extension/chrome-extension/icons/icon32.png new file mode 100644 index 00000000..9afbb489 Binary files /dev/null and b/integrations/chrome-extension/chrome-extension/icons/icon32.png differ diff --git a/integrations/chrome-extension/chrome-extension/icons/icon48.png b/integrations/chrome-extension/chrome-extension/icons/icon48.png new file mode 100644 index 00000000..7b5a275f Binary files /dev/null and b/integrations/chrome-extension/chrome-extension/icons/icon48.png differ diff --git a/integrations/chrome-extension/chrome-extension/manifest.json b/integrations/chrome-extension/chrome-extension/manifest.json new file mode 100644 index 00000000..c75bdb8d --- /dev/null +++ b/integrations/chrome-extension/chrome-extension/manifest.json @@ -0,0 +1,46 @@ +{ + "manifest_version": 3, + "name": "Open Brain", + "version": "1.0.0", + "description": "Save & search your second brain from any webpage", + "permissions": [ + "contextMenus", + "storage", + "activeTab", + "scripting" + ], + "host_permissions": [ + "https://*.supabase.co/*" + ], + "action": { + "default_popup": "popup.html", + "default_icon": { + "16": "icons/icon16.png", + "32": "icons/icon32.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + }, + "default_title": "Open Brain" + }, + "icons": { + "16": "icons/icon16.png", + "32": "icons/icon32.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + }, + "background": { + "service_worker": "background.js" + }, + "omnibox": { + "keyword": "brain" + }, + "commands": { + "_execute_action": { + "suggested_key": { + "default": "Ctrl+Shift+B", + "mac": "Command+Shift+B" + }, + "description": "Open Brain Popup" + } + } +} diff --git a/integrations/chrome-extension/chrome-extension/popup.html b/integrations/chrome-extension/chrome-extension/popup.html new file mode 100644 index 00000000..3df9e1fa --- /dev/null +++ b/integrations/chrome-extension/chrome-extension/popup.html @@ -0,0 +1,711 @@ + + + + + + + + +
+ +

Open Brain

+
+ + + + + +
+
Thoughts: --
+
Today: --
+
This week: --
+
+ + +
+ + +
+
+ Ctrl+Enter to save + +
+
+ +
+ +
+ + +
+ +
+ + + +
+
+
+ +
+ + +
+
+ + +
+
+ + + + +
+ +
+
+
+
+ + + + diff --git a/integrations/chrome-extension/chrome-extension/popup.js b/integrations/chrome-extension/chrome-extension/popup.js new file mode 100644 index 00000000..a1eb0b16 --- /dev/null +++ b/integrations/chrome-extension/chrome-extension/popup.js @@ -0,0 +1,629 @@ +// --- DOM Elements --- +const saveInput = document.getElementById("save-input"); +const saveBtn = document.getElementById("save-btn"); +const saveStatus = document.getElementById("save-status"); +const contextHint = document.getElementById("context-hint"); +const relatedThoughts = document.getElementById("related-thoughts"); + +const searchInput = document.getElementById("search-input"); +const searchBtn = document.getElementById("search-btn"); +const searchResults = document.getElementById("search-results"); +const sourceFilter = document.getElementById("source-filter"); + +const statTotal = document.getElementById("stat-total"); +const statToday = document.getElementById("stat-today"); +const statWeek = document.getElementById("stat-week"); + +const settingsToggle = document.getElementById("settings-toggle"); +const settingsArrow = document.getElementById("settings-arrow"); +const settingsContent = document.getElementById("settings-content"); +const settingApiUrl = document.getElementById("setting-api-url"); +const settingApiKey = document.getElementById("setting-api-key"); +const settingsSaveBtn = document.getElementById("settings-save-btn"); +const settingsStatus = document.getElementById("settings-status"); + +const unconfiguredHint = document.getElementById("unconfigured-hint"); + +// --- State --- +let apiUrl = ""; +let apiKey = ""; +let currentPageUrl = ""; +let currentPageTitle = ""; + +// --- Helpers --- + +function setStatus(el, message, type) { + el.textContent = message; + el.className = ""; + if (type === "success") el.classList.add("status-success"); + else if (type === "error") el.classList.add("status-error"); + else if (type === "loading") el.classList.add("status-loading"); +} + +function clearStatus(el, delay = 3000) { + setTimeout(() => { + el.textContent = ""; + el.className = ""; + }, delay); +} + +async function apiCall(payload) { + if (!apiUrl || !apiKey) { + throw new Error("API not configured. Please check settings."); + } + + const response = await fetch(apiUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-brain-key": apiKey, + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`API error (${response.status}): ${text}`); + } + + return response.json(); +} + +function formatDate(dateStr) { + if (!dateStr) return ""; + try { + const d = new Date(dateStr); + return d.toLocaleDateString("en-US", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + } catch { + return dateStr; + } +} + +function escapeHtml(str) { + const div = document.createElement("div"); + div.textContent = str; + return div.innerHTML.replace(/"/g, """).replace(/'/g, "'"); +} + +function truncateText(str, maxLen) { + if (!str) return ""; + if (str.length <= maxLen) return str; + return str.substring(0, maxLen - 3) + "..."; +} + +function extractSourceUrl(text) { + if (!text) return null; + const match = text.match(/\(Source:.*?—\s*(https?:\/\/[^\s)]+)\)/); + return match ? match[1] : null; +} + +function linkifyUrls(escapedHtml) { + return escapedHtml.replace( + /(https?:\/\/[^\s<)]+)/g, + '$1' + ); +} + +function getSourceBadgeClass(source) { + if (!source) return "source-unknown"; + const s = source.toLowerCase(); + if (s.includes("browser") || s.includes("chrome") || s.includes("extension")) return "source-browser"; + if (s.includes("telegram")) return "source-telegram"; + if (s.includes("slack")) return "source-slack"; + if (s.includes("claude") || s.includes("mcp")) return "source-mcp"; + return "source-unknown"; +} + +function getSourceLabel(source) { + if (!source) return ""; + const s = source.toLowerCase(); + if (s.includes("browser") || s.includes("chrome") || s.includes("extension")) return "browser"; + if (s.includes("telegram")) return "telegram"; + if (s.includes("slack")) return "slack"; + if (s.includes("claude")) return "claude"; + if (s.includes("mcp")) return "mcp"; + return source; +} + +// --- Page Context --- + +async function getPageContext() { + try { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (tab) { + currentPageUrl = tab.url || ""; + currentPageTitle = tab.title || ""; + + // Show context hint + if (currentPageTitle && currentPageUrl && !currentPageUrl.startsWith("chrome://")) { + const displayTitle = truncateText(currentPageTitle, 50); + contextHint.innerHTML = `🔗 Source: ${escapeHtml(displayTitle)}`; + } else { + contextHint.textContent = ""; + } + } + } catch (err) { + console.log("Could not load page context:", err); + } +} + +function buildContentWithContext(rawContent) { + // Append source URL and title if available and not a chrome:// page + if (currentPageTitle && currentPageUrl && !currentPageUrl.startsWith("chrome://")) { + return `${rawContent}\n\n(Source: ${currentPageTitle} \u2014 ${currentPageUrl})`; + } + return rawContent; +} + +// --- Auto-Capture: fill selected text into save textarea --- + +async function autoCaptureSelectedText() { + try { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!tab || !tab.id || tab.url?.startsWith("chrome://")) return; + + const results = await chrome.scripting.executeScript({ + target: { tabId: tab.id }, + func: () => window.getSelection().toString(), + }); + + if (results && results[0] && results[0].result) { + const selectedText = results[0].result.trim(); + if (selectedText) { + saveInput.value = selectedText; + } + } + } catch (err) { + // Silently ignore -- script injection may fail on restricted pages + console.log("Auto-capture: Could not read selected text:", err); + } +} + +// --- Pending Search (from context menu / omnibox) --- + +async function checkPendingSearch() { + try { + const data = await chrome.storage.local.get("pendingSearch"); + if (data.pendingSearch) { + const query = data.pendingSearch; + // Clear it immediately so it doesn't trigger again + await chrome.storage.local.remove("pendingSearch"); + // Put the text in the search input and auto-trigger search + searchInput.value = query; + performSearch(); + } + } catch (err) { + console.log("Error checking pendingSearch:", err); + } +} + +// --- Settings --- + +function toggleSettings(forceOpen) { + const isOpen = + forceOpen !== undefined + ? forceOpen + : !settingsContent.classList.contains("open"); + + if (isOpen) { + settingsContent.classList.add("open"); + settingsArrow.classList.add("open"); + } else { + settingsContent.classList.remove("open"); + settingsArrow.classList.remove("open"); + } +} + +settingsToggle.addEventListener("click", () => toggleSettings()); + +settingsSaveBtn.addEventListener("click", async () => { + const newUrl = settingApiUrl.value.trim(); + const newKey = settingApiKey.value.trim(); + + if (!newUrl || !newKey) { + setStatus(settingsStatus, "Please fill in both fields.", "error"); + clearStatus(settingsStatus); + return; + } + + try { + await chrome.storage.sync.set({ apiUrl: newUrl, apiKey: newKey }); + apiUrl = newUrl; + apiKey = newKey; + + setStatus(settingsStatus, "Settings saved!", "success"); + clearStatus(settingsStatus); + + unconfiguredHint.classList.add("hidden"); + loadStats(); + } catch (err) { + setStatus(settingsStatus, "Error saving: " + err.message, "error"); + clearStatus(settingsStatus, 5000); + } +}); + +// --- Load Settings on startup --- + +async function loadSettings() { + try { + const data = await chrome.storage.sync.get(["apiUrl", "apiKey"]); + apiUrl = data.apiUrl || ""; + apiKey = data.apiKey || ""; + + settingApiUrl.value = apiUrl; + settingApiKey.value = apiKey; + + if (!apiUrl || !apiKey) { + unconfiguredHint.classList.remove("hidden"); + toggleSettings(true); + } else { + unconfiguredHint.classList.add("hidden"); + loadStats(); + } + } catch (err) { + console.error("Error loading settings:", err); + unconfiguredHint.classList.remove("hidden"); + toggleSettings(true); + } +} + +// --- Save --- + +saveBtn.addEventListener("click", async () => { + const rawText = saveInput.value.trim(); + if (!rawText) { + setStatus(saveStatus, "Please enter a thought.", "error"); + clearStatus(saveStatus); + return; + } + + // Build content with source context + const content = buildContentWithContext(rawText); + + saveBtn.disabled = true; + setStatus(saveStatus, "Saving...", "loading"); + + // Hide previous related thoughts + relatedThoughts.classList.add("hidden"); + relatedThoughts.classList.remove("visible"); + + try { + await apiCall({ + action: "save", + content: content, + metadata: { + source: "browser", + url: currentPageUrl || undefined, + title: currentPageTitle || undefined, + }, + }); + + // Save confirmation animation + const saveArea = saveBtn.closest(".save-area"); + if (saveArea) { + saveArea.classList.add("save-flash"); + setTimeout(() => saveArea.classList.remove("save-flash"), 1000); + } + + setStatus(saveStatus, "Thought saved!", "success"); + clearStatus(saveStatus); + loadStats(); + + // Search for related thoughts using the raw text (before context was appended) + fetchRelatedThoughts(rawText); + + saveInput.value = ""; + } catch (err) { + setStatus(saveStatus, err.message, "error"); + clearStatus(saveStatus, 5000); + } finally { + saveBtn.disabled = false; + } +}); + +// Save with Ctrl+Enter +saveInput.addEventListener("keydown", (e) => { + if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + saveBtn.click(); + } +}); + +// --- Related Thoughts --- + +async function fetchRelatedThoughts(query) { + try { + const data = await apiCall({ action: "search", query: query }); + const results = data.results || data.thoughts || []; + + // Show max 3 related thoughts + const topResults = results.slice(0, 3); + if (topResults.length === 0) return; + + let html = ''; + topResults.forEach((item) => { + const text = item.content || item.thought || item.text || ""; + const score = item.similarity != null ? item.similarity : item.score; + const date = item.created_at || item.date || ""; + + html += '"; + }); + + relatedThoughts.innerHTML = html; + relatedThoughts.classList.remove("hidden"); + + // Trigger animation after a brief delay for DOM update + requestAnimationFrame(() => { + relatedThoughts.classList.add("visible"); + }); + } catch (err) { + // Silently ignore -- related thoughts are a nice-to-have + console.log("Could not load related thoughts:", err); + } +} + +// --- Search --- + +searchBtn.addEventListener("click", () => performSearch()); + +searchInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + performSearch(); + } +}); + +async function performSearch() { + const query = searchInput.value.trim(); + if (!query) { + searchResults.innerHTML = + '
Please enter a search term.
'; + return; + } + + searchBtn.disabled = true; + searchResults.innerHTML = + '
Searching...
'; + + try { + // Build search payload with optional source filter + const payload = { action: "search", query: query }; + const selectedSource = sourceFilter.value; + if (selectedSource) { + payload.source = selectedSource; + } + + const data = await apiCall(payload); + const results = data.results || data.thoughts || []; + + if (results.length === 0) { + searchResults.innerHTML = + '
No results found.
'; + return; + } + + searchResults.innerHTML = results + .map((item) => { + const text = item.content || item.thought || item.text || ""; + const score = item.similarity != null ? item.similarity : item.score; + const date = item.created_at || item.date || ""; + const source = item.source || item.metadata?.source || ""; + + const sourceUrl = extractSourceUrl(text); + const itemId = item.id || ""; + const isTask = item.metadata?.type === "task"; + const taskStatus = item.metadata?.status || "open"; + + let html = `
`; + // Delete button (X) + html += ``; + // Done button (only for open tasks) + if (isTask && taskStatus !== "done" && itemId) { + html += ``; + } + // Result text (click to copy) + html += `
${linkifyUrls(escapeHtml(text))}
`; + html += '
'; + if (source) { + const badgeClass = getSourceBadgeClass(source); + const label = getSourceLabel(source); + html += `${escapeHtml(label)}`; + } + if (sourceUrl) { + html += `Source`; + } + if (score != null) { + const pct = (score * 100).toFixed(0); + html += `${pct}%`; + } + if (date) { + html += `${formatDate(date)}`; + } + html += "
"; + html += "
"; + return html; + }) + .join(""); + + // Attach click-to-copy and delete handlers + attachResultHandlers(); + } catch (err) { + searchResults.innerHTML = `
${escapeHtml(err.message)}
`; + } finally { + searchBtn.disabled = false; + } +} + +// --- Click to Copy --- + +function attachResultHandlers() { + // Click to copy on result text + searchResults.querySelectorAll(".result-text").forEach((el) => { + el.addEventListener("click", (e) => { + // Don't copy if user clicked a link inside the text + if (e.target.closest("a")) return; + + const fullText = el.getAttribute("data-full-text") || el.textContent; + navigator.clipboard.writeText(fullText).then(() => { + showCopyToast(el); + }).catch((err) => { + console.error("Copy failed:", err); + }); + }); + }); + + // Delete button handlers + searchResults.querySelectorAll(".result-delete-btn").forEach((btn) => { + btn.addEventListener("click", (e) => { + e.stopPropagation(); + const resultItem = btn.closest(".result-item"); + showDeleteConfirm(resultItem); + }); + }); + + // Done button handlers (for tasks) + searchResults.querySelectorAll(".result-done-btn").forEach((btn) => { + btn.addEventListener("click", async (e) => { + e.stopPropagation(); + const id = btn.getAttribute("data-id"); + if (!id) return; + + try { + await apiCall({ action: "update_status", id, status: "done" }); + btn.textContent = "Done!"; + btn.style.opacity = "1"; + btn.style.borderColor = "#4caf50"; + btn.style.background = "rgba(76, 175, 80, 0.2)"; + btn.disabled = true; + setTimeout(() => { + const resultItem = btn.closest(".result-item"); + if (resultItem) { + resultItem.classList.add("fade-out"); + resultItem.addEventListener("animationend", () => resultItem.remove()); + } + }, 800); + } catch (err) { + console.error("Status update failed:", err); + } + }); + }); +} + +function showCopyToast(textEl) { + const resultItem = textEl.closest(".result-item"); + if (!resultItem) return; + + // Remove any existing toast + const existing = resultItem.querySelector(".copy-toast"); + if (existing) existing.remove(); + + const toast = document.createElement("span"); + toast.className = "copy-toast"; + toast.textContent = "Copied!"; + resultItem.appendChild(toast); + + // Remove after animation + setTimeout(() => toast.remove(), 1300); +} + +// --- Delete Thoughts --- + +function showDeleteConfirm(resultItem) { + // Don't show if already showing + if (resultItem.querySelector(".delete-confirm")) return; + + const thoughtId = resultItem.getAttribute("data-id"); + + const confirm = document.createElement("div"); + confirm.className = "delete-confirm"; + confirm.innerHTML = ` + Delete this thought? + + + `; + + resultItem.appendChild(confirm); + + // "Yes" -- delete + confirm.querySelector(".confirm-yes").addEventListener("click", async () => { + try { + if (thoughtId) { + await apiCall({ action: "delete", id: thoughtId }); + } else { + // Fallback: use content if no id available + const textEl = resultItem.querySelector(".result-text"); + const content = textEl ? (textEl.getAttribute("data-full-text") || textEl.textContent) : ""; + await apiCall({ action: "delete", content: content }); + } + } catch (err) { + console.error("Delete failed:", err); + // Still remove from UI even if API call fails (API may not support delete yet) + } + + // Fade out and remove + resultItem.classList.add("fade-out"); + resultItem.addEventListener("animationend", () => { + resultItem.remove(); + }); + }); + + // "No" -- cancel + confirm.querySelector(".confirm-no").addEventListener("click", () => { + confirm.remove(); + }); +} + +// --- Stats --- + +async function loadStats() { + try { + const data = await apiCall({ action: "stats" }); + statTotal.textContent = data.total ?? data.total_thoughts ?? "--"; + statToday.textContent = data.today ?? data.thoughts_today ?? "--"; + statWeek.textContent = data.this_week ?? data.thoughts_this_week ?? "--"; + } catch { + statTotal.textContent = "--"; + statToday.textContent = "--"; + statWeek.textContent = "--"; + } +} + +// --- Init --- + +async function init() { + // Load settings first + await loadSettings(); + + // Get page context (URL + title) + await getPageContext(); + + // Auto-Capture: fill selected text from the page into save textarea + await autoCaptureSelectedText(); + + // Check for pending search from context menu or omnibox + await checkPendingSearch(); + + // Auto-focus the save textarea (unless a pending search moved focus) + if (!searchInput.value) { + saveInput.focus(); + } +} + +init(); diff --git a/integrations/chrome-extension/metadata.json b/integrations/chrome-extension/metadata.json new file mode 100644 index 00000000..a7fde070 --- /dev/null +++ b/integrations/chrome-extension/metadata.json @@ -0,0 +1,20 @@ +{ + "name": "Chrome Extension — Browser Capture & Search", + "description": "Save thoughts from any webpage and search your entire Open Brain directly from your browser. Includes right-click context menu, omnibox search, auto-capture of selected text, source tracking, and keyboard shortcuts.", + "category": "integrations", + "author": { + "name": "Karin Gutenbrunner", + "github": "konepone" + }, + "version": "1.0.0", + "requires": { + "open_brain": true, + "services": ["OpenRouter API"], + "tools": ["Chrome or Chromium-based browser"] + }, + "tags": ["chrome", "browser", "extension", "capture", "search", "semantic-search"], + "difficulty": "beginner", + "estimated_time": "15 minutes", + "created": "2026-03-15", + "updated": "2026-03-15" +} diff --git a/integrations/chrome-extension/supabase-function/deno.json b/integrations/chrome-extension/supabase-function/deno.json new file mode 100644 index 00000000..5f87fd0c --- /dev/null +++ b/integrations/chrome-extension/supabase-function/deno.json @@ -0,0 +1,5 @@ +{ + "imports": { + "@supabase/supabase-js": "npm:@supabase/supabase-js@2.47.10" + } +} diff --git a/integrations/chrome-extension/supabase-function/index.ts b/integrations/chrome-extension/supabase-function/index.ts new file mode 100644 index 00000000..4c237844 --- /dev/null +++ b/integrations/chrome-extension/supabase-function/index.ts @@ -0,0 +1,279 @@ +import "jsr:@supabase/functions-js/edge-runtime.d.ts"; +import { createClient } from "@supabase/supabase-js"; + +const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!; +const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; +const OPENROUTER_API_KEY = Deno.env.get("OPENROUTER_API_KEY")!; +const BRAIN_API_KEY = Deno.env.get("BRAIN_API_KEY")!; + +const OPENROUTER_BASE = "https://openrouter.ai/api/v1"; +const supabase = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY); + +// --- CORS headers (for Chrome Extension access) --- + +const CORS_HEADERS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, x-brain-key", +}; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + }); +} + +function errorResponse(message: string, status = 400): Response { + return jsonResponse({ error: message }, status); +} + +// --- Shared helpers --- + +async function getEmbedding(text: string): Promise { + const r = await fetch(`${OPENROUTER_BASE}/embeddings`, { + method: "POST", + headers: { + Authorization: `Bearer ${OPENROUTER_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ model: "openai/text-embedding-3-small", input: text }), + }); + if (!r.ok) throw new Error(`Embedding failed: ${r.status}`); + const d = await r.json(); + return d.data[0].embedding; +} + +async function extractMetadata(text: string): Promise> { + const r = await fetch(`${OPENROUTER_BASE}/chat/completions`, { + method: "POST", + headers: { + Authorization: `Bearer ${OPENROUTER_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "openai/gpt-4o-mini", + response_format: { type: "json_object" }, + messages: [ + { + role: "system", + content: `Extract metadata from the user's captured thought. Return JSON with: +- "people": array of people mentioned (empty if none) +- "action_items": array of implied to-dos (empty if none) +- "dates_mentioned": array of dates YYYY-MM-DD (empty if none) +- "topics": array of 1-3 short topic tags (always at least one) +- "type": one of "observation", "task", "idea", "reference", "person_note" +Only extract what's explicitly there.`, + }, + { role: "user", content: text }, + ], + }), + }); + const d = await r.json(); + try { + return JSON.parse(d.choices[0].message.content); + } catch { + return { topics: ["uncategorized"], type: "observation" }; + } +} + +// --- Action handlers --- + +async function handleSave( + content: string, + clientMetadata?: Record +): Promise { + if (!content || !content.trim()) { + return errorResponse("Missing 'content' field"); + } + + const [embedding, aiMetadata] = await Promise.all([ + getEmbedding(content), + extractMetadata(content), + ]); + + const metadata = { + ...aiMetadata, + source: clientMetadata?.source || "browser", + ...(clientMetadata?.url ? { url: clientMetadata.url } : {}), + ...(clientMetadata?.title ? { title: clientMetadata.title } : {}), + }; + + const { error } = await supabase.from("thoughts").insert({ + content, + embedding, + metadata, + }); + + if (error) { + return errorResponse(`Supabase insert failed: ${error.message}`, 500); + } + + return jsonResponse({ ok: true, message: "Thought saved", metadata }); +} + +async function handleSearch(query: string, source?: string): Promise { + if (!query || !query.trim()) { + return errorResponse("Missing 'query' field"); + } + + const queryEmbedding = await getEmbedding(query); + + const { data, error } = await supabase.rpc("match_thoughts", { + query_embedding: queryEmbedding, + match_threshold: 0.3, + match_count: 10, + filter: source ? { source } : {}, + }); + + if (error) { + return errorResponse(`Search failed: ${error.message}`, 500); + } + + const results = (data || []).map( + (t: { + id: string; + content: string; + metadata: Record; + similarity: number; + created_at: string; + }) => ({ + id: t.id, + content: t.content, + metadata: t.metadata, + similarity: t.similarity, + created_at: t.created_at, + source: (t.metadata as Record)?.source || "", + }) + ); + + return jsonResponse({ ok: true, results }); +} + +async function handleStats(): Promise { + const { count } = await supabase + .from("thoughts") + .select("*", { count: "exact", head: true }); + + const todayStart = new Date(); + todayStart.setHours(0, 0, 0, 0); + const { count: todayCount } = await supabase + .from("thoughts") + .select("*", { count: "exact", head: true }) + .gte("created_at", todayStart.toISOString()); + + const weekStart = new Date(); + weekStart.setDate(weekStart.getDate() - weekStart.getDay()); + weekStart.setHours(0, 0, 0, 0); + const { count: weekCount } = await supabase + .from("thoughts") + .select("*", { count: "exact", head: true }) + .gte("created_at", weekStart.toISOString()); + + return jsonResponse({ + ok: true, + total: count || 0, + today: todayCount || 0, + this_week: weekCount || 0, + }); +} + +async function handleDelete(id?: string, content?: string): Promise { + if (id) { + const { error } = await supabase.from("thoughts").delete().eq("id", id); + if (error) return errorResponse(`Delete failed: ${error.message}`, 500); + return jsonResponse({ ok: true, message: "Thought deleted" }); + } + + if (!content || !content.trim()) { + return errorResponse("Missing 'id' or 'content' field"); + } + + const { data, error: selectError } = await supabase + .from("thoughts") + .select("id") + .eq("content", content) + .limit(1) + .maybeSingle(); + + if (selectError) return errorResponse(`Lookup failed: ${selectError.message}`, 500); + if (!data) return jsonResponse({ ok: false, error: "Thought not found" }, 404); + + const { error: deleteError } = await supabase + .from("thoughts") + .delete() + .eq("id", data.id); + + if (deleteError) return errorResponse(`Delete failed: ${deleteError.message}`, 500); + return jsonResponse({ ok: true, message: "Thought deleted" }); +} + +async function handleUpdateStatus(id: string, status: string): Promise { + if (!id) return errorResponse("Missing 'id' field"); + if (!status) return errorResponse("Missing 'status' field"); + + const { data: existing, error: fetchErr } = await supabase + .from("thoughts") + .select("metadata") + .eq("id", id) + .single(); + + if (fetchErr || !existing) { + return errorResponse("Thought not found", 404); + } + + const updatedMetadata = { + ...(existing.metadata as Record), + status, + }; + + const { error } = await supabase + .from("thoughts") + .update({ metadata: updatedMetadata }) + .eq("id", id); + + if (error) return errorResponse(`Update failed: ${error.message}`, 500); + return jsonResponse({ ok: true, message: `Status updated to "${status}"` }); +} + +// --- Main handler --- + +Deno.serve(async (req: Request): Promise => { + if (req.method === "OPTIONS") { + return new Response(null, { status: 204, headers: CORS_HEADERS }); + } + + if (req.method !== "POST") { + return errorResponse("Method not allowed", 405); + } + + const apiKey = req.headers.get("x-brain-key"); + if (!apiKey || apiKey !== BRAIN_API_KEY) { + return errorResponse("Unauthorized", 401); + } + + try { + const body = await req.json(); + const { action } = body; + + switch (action) { + case "save": + return await handleSave(body.content, body.metadata); + case "search": + return await handleSearch(body.query, body.source); + case "stats": + return await handleStats(); + case "delete": + return await handleDelete(body.id, body.content); + case "update_status": + return await handleUpdateStatus(body.id, body.status); + default: + return errorResponse( + `Unknown action: ${action}. Valid: save, search, stats, delete, update_status` + ); + } + } catch (err) { + console.error("brain-api error:", err); + return errorResponse("Internal server error", 500); + } +});