Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions chrome_extension/README.md
Original file line number Diff line number Diff line change
@@ -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.
137 changes: 137 additions & 0 deletions chrome_extension/background.js
Original file line number Diff line number Diff line change
@@ -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) {}
}
});
}
});
163 changes: 163 additions & 0 deletions chrome_extension/content_script.js
Original file line number Diff line number Diff line change
@@ -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 = `
<div class="ai-reader-header">
<div class="ai-reader-title-wrap">
<span id="ai-reader-title">AI Reader</span>
</div>
<button id="ai-reader-close" title="Close">×</button>
</div>
<div id="ai-reader-body" class="ai-reader-body" role="region" aria-live="polite"></div>
<div class="ai-reader-footer"><small>AI Reader Assistant</small></div>
`;

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 = `<div class="ai-reader-item ai-reader-loading">Requesting ${escapeHtml(analysisType)}...</div>`;
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 = `<div class="ai-reader-selected">Selected: <em>${escapeHtml(selected)}</em></div>`;
const mdHtml = renderMarkdown(response || '');
const respHtml = `<div class="ai-reader-item ai-reader-markdown">${mdHtml}</div>`;
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 = `<div class="ai-reader-item ai-reader-error">Error: ${escapeHtml(message)}</div>`;
panel.querySelector('#ai-reader-title').textContent = `AI Reader — error`;
}

function escapeHtml(s) {
if (!s) return '';
return s
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}

// 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 '<pre><code>' + escapeHtml(code) + '</code></pre>';
});

// Inline code
text = text.replace(/`([^`]+)`/g, (m, code) => '<code>' + escapeHtml(code) + '</code>');

// Headings
text = text.replace(/^###### (.*)$/gm, '<h6>$1</h6>');
text = text.replace(/^##### (.*)$/gm, '<h5>$1</h5>');
text = text.replace(/^#### (.*)$/gm, '<h4>$1</h4>');
text = text.replace(/^### (.*)$/gm, '<h3>$1</h3>');
text = text.replace(/^## (.*)$/gm, '<h2>$1</h2>');
text = text.replace(/^# (.*)$/gm, '<h1>$1</h1>');

// Bold and italics
text = text.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
text = text.replace(/\*(.*?)\*/g, '<em>$1</em>');

// Links [text](url)
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');

// Unordered lists
// Convert lines starting with - or * into <li>
if (/^[-*] /m.test(text)) {
text = text.replace(/(^|\n)([-*] .+(?:\n[-*] .+)*)/g, (m, pre, block) => {
const items = block.split(/\n/).map(l => '<li>' + l.replace(/^[-*] /, '') + '</li>').join('');
return pre + '<ul>' + items + '</ul>';
});
}

// 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] = '<p>' + line + '</p>';
}
}
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 = `<div class="ai-reader-item ai-reader-loading">Sending request to backend...</div>`;
body.innerHTML = info;
return;
}
if (msg.type === 'request_finished') {
const panel = ensurePanel();
const body = panel.querySelector('#ai-reader-body');
const info = `<div class="ai-reader-item">Request finished (status: ${msg.status})</div>`;
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) {}

})();
1 change: 1 addition & 0 deletions chrome_extension/icons/icon48.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
22 changes: 22 additions & 0 deletions chrome_extension/manifest.json
Original file line number Diff line number Diff line change
@@ -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": ["<all_urls>"],
"js": ["content_script.js"],
"css": ["style.css"],
"run_at": "document_idle"
}
],
"icons": {
"48": "icons/icon48.png"
}
}
Loading