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 = `
+
+
+
+ `;
+
+ 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"