diff --git a/chrome_extension/README.md b/chrome_extension/README.md new file mode 100644 index 00000000..63d42d8f --- /dev/null +++ b/chrome_extension/README.md @@ -0,0 +1,18 @@ +# AI Reader Chrome Extension + +This extension adds two context-menu commands to the browser when you select text: `Fact-check` and `Discuss`. + +What it does: +- Sends the selected text to the backend endpoint at `http://localhost:8123/api/ai/analyze`. +- Displays the AI response in a right-side chatbot panel on the current page. + +Installation (developer / unpacked mode): + +1. Start the ai-reader backend (e.g., `uvicorn server:app --reload --port 8123`). +2. In Chrome, go to `chrome://extensions/` and enable "Developer mode". +3. Click "Load unpacked" and select the `chrome_extension/` directory in this repo. +4. Visit any page, select some text, right-click and choose the AI Reader command. + +Notes: +- The extension's background worker sends requests to `http://localhost:8123`. If your backend runs on a different port or host, update `background.js` `API_BASE` constant. +- The extension fetches in the background (service worker) so it doesn't rely on page CORS. diff --git a/chrome_extension/background.js b/chrome_extension/background.js new file mode 100644 index 00000000..1fd70d41 --- /dev/null +++ b/chrome_extension/background.js @@ -0,0 +1,137 @@ +// Background service worker: create context menu items and call backend +// Default API base — keep in sync with `manifest.json` host_permissions +const API_BASE = 'http://localhost:8123'; + +function createContextMenus() { + try { + chrome.contextMenus.removeAll(() => { + // ignore errors + chrome.contextMenus.create({ + id: 'fact_check', + title: 'Fact-check', + contexts: ['selection'] + }); + + chrome.contextMenus.create({ + id: 'discuss', + title: 'Discuss', + contexts: ['selection'] + }); + }); + } catch (e) { + console.error('Failed to create context menus', e); + } +} + +// Ensure menus are created when the service worker starts +createContextMenus(); + +chrome.runtime.onInstalled.addListener(() => createContextMenus()); +chrome.runtime.onStartup && chrome.runtime.onStartup.addListener(() => createContextMenus()); + +async function ensureContentScriptInjected(tabId) { + return new Promise((resolve, reject) => { + // Try sending a ping message to see if content script is present + chrome.tabs.sendMessage(tabId, { type: 'ping' }, (resp) => { + const err = chrome.runtime.lastError; + if (!err && resp && resp.pong) return resolve(true); + + // Not present — inject content script and css + chrome.scripting.executeScript( + { target: { tabId }, files: ['content_script.js'] }, + () => { + const cssErr = chrome.runtime.lastError; + // inject CSS too (best-effort) + chrome.scripting.insertCSS({ target: { tabId }, files: ['style.css'] }, () => { + // ignore errors + const err2 = chrome.runtime.lastError; + if (cssErr || err2) { + console.warn('Injected content script but got css/script error', cssErr || err2); + } + resolve(true); + }); + } + ); + }); + }); +} + +chrome.contextMenus.onClicked.addListener(async (info, tab) => { + if (!info.selectionText || !tab || !tab.id) return; + + const selected = info.selectionText.trim(); + const analysisType = info.menuItemId === 'fact_check' ? 'fact_check' : 'discussion'; + + try { + // Ensure content script exists in the tab so messages are received + await ensureContentScriptInjected(tab.id); + + // Tell the content script to show a loading panel + chrome.tabs.sendMessage(tab.id, { + type: 'show_loading', + analysisType, + selected + }, (resp) => { + if (chrome.runtime.lastError) { + console.warn('show_loading sendMessage error:', chrome.runtime.lastError.message); + } + }); + + // Notify content script that request is starting (helps debugging) + chrome.tabs.sendMessage(tab.id, { type: 'request_started', analysisType, selected }, () => {}); + + console.log('AI Reader: calling backend', `${API_BASE}/api/ai/analyze`); + + const resp = await fetch(`${API_BASE}/api/ai/analyze`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + highlight_id: -1, + analysis_type: analysisType, + selected_text: selected, + context: '' + }) + }); + + if (!resp.ok) { + const txt = await resp.text(); + throw new Error(`Backend error: ${resp.status} ${txt}`); + } + + const data = await resp.json(); + + // Notify content script that request finished + chrome.tabs.sendMessage(tab.id, { type: 'request_finished', status: resp.status }, () => {}); + + chrome.tabs.sendMessage(tab.id, { + type: 'show_response', + analysisType, + selected, + response: data.response || data.result || JSON.stringify(data) + }, (r) => { + if (chrome.runtime.lastError) { + console.warn('show_response sendMessage error:', chrome.runtime.lastError.message); + } + }); + + } catch (err) { + console.error('AI analyze error', err); + chrome.tabs.sendMessage(tab.id, { + type: 'show_error', + message: err.message || String(err) + }, (r) => { + if (chrome.runtime.lastError) { + // If we can't message the tab at all, show a notification as fallback + console.warn('Failed to send error to content script:', chrome.runtime.lastError.message); + try { + chrome.notifications && chrome.notifications.create({ + type: 'basic', + iconUrl: 'icons/icon48.png', + title: 'AI Reader', + message: err.message || String(err) + }); + } catch (e) {} + } + }); + } +}); diff --git a/chrome_extension/content_script.js b/chrome_extension/content_script.js new file mode 100644 index 00000000..7c8d9ae6 --- /dev/null +++ b/chrome_extension/content_script.js @@ -0,0 +1,163 @@ +// Content script: creates a right-side chat panel and listens for messages +(function () { + const PANEL_ID = 'ai-reader-panel-v1'; + + function ensurePanel() { + let panel = document.getElementById(PANEL_ID); + if (panel) return panel; + // Create a fixed right-side sidebar panel + panel = document.createElement('aside'); + panel.id = PANEL_ID; + panel.className = 'ai-reader-panel'; + + panel.innerHTML = ` +
+
+ AI Reader +
+ +
+
+ + `; + + document.documentElement.appendChild(panel); + + // Close/hide the sidebar + document.getElementById('ai-reader-close').addEventListener('click', () => { + panel.style.display = 'none'; + }); + + return panel; + } + + function showLoading(analysisType, selected) { + const panel = ensurePanel(); + panel.style.display = 'block'; + const body = panel.querySelector('#ai-reader-body'); + body.innerHTML = `
Requesting ${escapeHtml(analysisType)}...
`; + panel.querySelector('#ai-reader-title').textContent = `AI Reader — ${analysisType.replace('_',' ')}`; + // scroll to top for a new request + body.scrollTop = 0; + } + + function showResponse(analysisType, selected, response) { + const panel = ensurePanel(); + panel.style.display = 'block'; + const body = panel.querySelector('#ai-reader-body'); + + const headerHtml = `
Selected: ${escapeHtml(selected)}
`; + const mdHtml = renderMarkdown(response || ''); + const respHtml = `
${mdHtml}
`; + body.innerHTML = headerHtml + respHtml; + panel.querySelector('#ai-reader-title').textContent = `AI Reader — ${analysisType.replace('_',' ')}`; + // Scroll to bottom if content is long + setTimeout(() => { body.scrollTop = body.scrollHeight; }, 50); + } + + function showError(message) { + const panel = ensurePanel(); + panel.style.display = 'block'; + const body = panel.querySelector('#ai-reader-body'); + body.innerHTML = `
Error: ${escapeHtml(message)}
`; + panel.querySelector('#ai-reader-title').textContent = `AI Reader — error`; + } + + function escapeHtml(s) { + if (!s) return ''; + return s + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + } + + // Minimal Markdown renderer: supports code fences, inline code, headings, bold, italics, links, lists + function renderMarkdown(md) { + if (!md) return ''; + // Escape first, then restore code blocks + let text = md.replaceAll('\r\n', '\n'); + + // Code fences + text = text.replace(/```([\s\S]*?)```/g, (m, code) => { + return '
' + escapeHtml(code) + '
'; + }); + + // Inline code + text = text.replace(/`([^`]+)`/g, (m, code) => '' + escapeHtml(code) + ''); + + // Headings + text = text.replace(/^###### (.*)$/gm, '
$1
'); + text = text.replace(/^##### (.*)$/gm, '
$1
'); + text = text.replace(/^#### (.*)$/gm, '

$1

'); + text = text.replace(/^### (.*)$/gm, '

$1

'); + text = text.replace(/^## (.*)$/gm, '

$1

'); + text = text.replace(/^# (.*)$/gm, '

$1

'); + + // Bold and italics + text = text.replace(/\*\*(.*?)\*\*/g, '$1'); + text = text.replace(/\*(.*?)\*/g, '$1'); + + // Links [text](url) + text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); + + // Unordered lists + // Convert lines starting with - or * into
  • + if (/^[-*] /m.test(text)) { + text = text.replace(/(^|\n)([-*] .+(?:\n[-*] .+)*)/g, (m, pre, block) => { + const items = block.split(/\n/).map(l => '
  • ' + l.replace(/^[-*] /, '') + '
  • ').join(''); + return pre + ''; + }); + } + + // Paragraphs: wrap remaining lines + const lines = text.split('\n'); + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line) continue; + if (!/^<(h[1-6]|ul|li|pre|code|a|strong|em)/.test(line)) { + lines[i] = '

    ' + line + '

    '; + } + } + text = lines.join(''); + + return text; + } + + // Listen for messages from background + chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { + if (!msg || !msg.type) return; + if (msg.type === 'ping') { + // Reply so background knows content script is present + sendResponse({ pong: true }); + return; + } + if (msg.type === 'request_started') { + const panel = ensurePanel(); + panel.style.display = 'block'; + const body = panel.querySelector('#ai-reader-body'); + const info = `
    Sending request to backend...
    `; + body.innerHTML = info; + return; + } + if (msg.type === 'request_finished') { + const panel = ensurePanel(); + const body = panel.querySelector('#ai-reader-body'); + const info = `
    Request finished (status: ${msg.status})
    `; + body.insertAdjacentHTML('beforeend', info); + return; + } + if (msg.type === 'show_loading') { + showLoading(msg.analysisType || 'analysis', msg.selected || ''); + } else if (msg.type === 'show_response') { + showResponse(msg.analysisType || 'analysis', msg.selected || '', msg.response || '(no response)'); + } else if (msg.type === 'show_error') { + showError(msg.message || 'Unknown error'); + } + }); + + // Create panel on load (hidden) + try { ensurePanel(); document.getElementById(PANEL_ID).style.display = 'none'; } catch(e) {} + +})(); diff --git a/chrome_extension/icons/icon48.png b/chrome_extension/icons/icon48.png new file mode 100644 index 00000000..99c8e0c9 --- /dev/null +++ b/chrome_extension/icons/icon48.png @@ -0,0 +1 @@ +iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABg3Am/AAAAF0lEQVR42u3BMQEAAADCoPVPbQhPoAAAAAAAAAB4GwYAAQjvD0kAAAAASUVORK5CYII= \ No newline at end of file diff --git a/chrome_extension/manifest.json b/chrome_extension/manifest.json new file mode 100644 index 00000000..4d623631 --- /dev/null +++ b/chrome_extension/manifest.json @@ -0,0 +1,22 @@ +{ + "manifest_version": 3, + "name": "AI Reader Assistant", + "version": "0.1", + "description": "Adds Fact-check and Discuss context menu items that call the ai-reader backend and show a right-side chat panel.", + "permissions": ["contextMenus", "tabs", "scripting", "storage", "activeTab"], + "host_permissions": ["http://localhost:8123/*"], + "background": { + "service_worker": "background.js" + }, + "content_scripts": [ + { + "matches": [""], + "js": ["content_script.js"], + "css": ["style.css"], + "run_at": "document_idle" + } + ], + "icons": { + "48": "icons/icon48.png" + } +} diff --git a/chrome_extension/style.css b/chrome_extension/style.css new file mode 100644 index 00000000..fd3464eb --- /dev/null +++ b/chrome_extension/style.css @@ -0,0 +1,71 @@ +/* Minimal styles for the right-side AI Reader panel */ + +.ai-reader-panel { + box-sizing: border-box; + position: fixed; + top: 0; + right: 0; + width: 420px; + height: 100vh; + background: #fff; + color: #111; + border-left: 1px solid rgba(0,0,0,0.08); + font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial; + display: flex; + flex-direction: column; + z-index: 2147483647; + overflow: hidden; +} + +.ai-reader-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + background: linear-gradient(90deg, #f7fafc, #ffffff); + border-bottom: 1px solid rgba(0,0,0,0.06); +} + +#ai-reader-title { font-weight: 700; font-size: 14px; } + +#ai-reader-close { + border: none; + background: transparent; + font-size: 18px; + line-height: 1; + cursor: pointer; +} + +.ai-reader-body { + flex: 1 1 auto; + padding: 14px; + overflow-y: auto; + height: 100%; + max-height: calc(100vh - 48px); + -webkit-overflow-scrolling: touch; +} + +.ai-reader-footer { + padding: 8px 12px; + border-top: 1px solid rgba(0,0,0,0.04); + text-align: right; + font-size: 11px; + color: #666; +} + +.ai-reader-item { margin-bottom: 10px; word-break: break-word; } +.ai-reader-loading { color: #666; font-style: italic; } +.ai-reader-error { color: #a33; } +.ai-reader-selected { margin-bottom: 8px; font-size: 12px; color: #333; } + +.ai-reader-markdown { + font-size: 13px; + line-height: 1.6; +} +.ai-reader-markdown p { margin: 0 0 10px 0; } +.ai-reader-markdown pre { background: #f6f8fa; padding: 8px; border-radius: 6px; overflow: auto; font-size: 12px; } +.ai-reader-markdown code { background: #f6f8fa; padding: 2px 4px; border-radius: 4px; font-size: 12px; } +.ai-reader-markdown h1, .ai-reader-markdown h2, .ai-reader-markdown h3 { margin: 6px 0; font-size: 1em; } +.ai-reader-markdown ul { margin: 6px 0 12px 18px; } +.ai-reader-markdown a { color: #0366d6; text-decoration: none; } +.ai-reader-markdown a:hover { text-decoration: underline; } diff --git a/server.py b/server.py index a10953c8..210e342a 100644 --- a/server.py +++ b/server.py @@ -256,7 +256,7 @@ async def analyze_text(req: AIRequest): response = await service.discuss(req.selected_text, req.context) else: raise HTTPException(status_code=400, detail="Invalid analysis type") - + return { "response": response, "status": "success"