├── README.md ├── background.js ├── content.js ├── hello.html ├── llm.js ├── manifest.json ├── sidepanel.html ├── sidepanel.js ├── summarize.png ├── summarize_all.png └── summarize_hn.png /README.md: -------------------------------------------------------------------------------- 1 | # FastDigest 2 | 3 | ## Description 4 | 5 | FastDigest is a Chrome extension designed to summarize Hacker News comments using OpenAI's GPT-4 model. The extension provides a "Summarize" button for individual comments and a "Summarize All" button to summarize all comments on a page. The summaries are generated in real-time and displayed in the Chrome side panel. 6 | 7 | ## Features 8 | 9 | - Adds a "Summarize" button to each comment on Hacker News. 10 |  11 | - Adds a "Summarize All" button to summarize all comments on a page. 12 |  13 | - Supports both OpenAI and Ollama. 14 | - Displays summaries in the Chrome side panel. 15 | 16 | ## Installation 17 | 18 | 1. **Clone the repository**: 19 | 20 | ```sh 21 | git clone https://github.com/yourusername/fastdigest.git 22 | cd fastdigest 23 | ``` 24 | 25 | 2. **Configure LLM Provider** 26 | 27 | - Go to background.js. At the top, you will see a variable to set your OpenAI API Key. 28 | - FastDigest also supports Ollama(llama3.1) for using local LLMs. To use Ollama, you will need to do the following: 29 | - shut down Ollama if it is currently running 30 | - run `launchctl setenv OLLAMA_ORIGINS "*"` 31 | - restart Ollama 32 | 33 | 3. **Load the extension in Chrome**: 34 | - Open Chrome and navigate to chrome://extensions/. 35 | - Enable "Developer mode" by toggling the switch in the top right corner. 36 | - Click on "Load unpacked" and select the directory where you cloned the repository. 37 | 38 | ## Usage 39 | 40 | Visit any Hacker News item page (e.g., https://news.ycombinator.com/item?id=123456). 41 | The extension will add "Summarize" and "Summarize All" buttons to the comments. 42 | Click the "Summarize" button: 43 | 44 | Click the "Summarize" button next to a comment to generate a summary for that comment. 45 | Click the "Summarize All" button: 46 | 47 | Click the "Summarize All" button to generate summaries for all comments on the page. 48 | 49 | ## Contributing 50 | 51 | Contributions are welcome! Please open an issue or submit a pull request for any improvements or bug fixes. 52 | 53 | ## License 54 | 55 | This project is licensed under the MIT License. See the LICENSE file for details. 56 | -------------------------------------------------------------------------------- /background.js: -------------------------------------------------------------------------------- 1 | import { OpenAIProvider } from "./llm.js"; 2 | 3 | const OPENAI_API_KEY = "YOUR_OPEN_AI_API_KEY"; 4 | // Configure the LLMProvider of your choice, see llm.js for more details 5 | // OpenAI: const llmProvider = new OpenAIProvider(OPENAI_API_KEY); 6 | // Ollama: const llmProvider = new OllamaProvider(); 7 | const llmProvider = new OpenAIProvider(OPENAI_API_KEY); 8 | const ALLOWED_ORIGIN = "https://news.ycombinator.com"; 9 | 10 | var isSummarizing = false; 11 | var sidePanelReady = false; 12 | 13 | // Allows users to open the side panel by clicking on the action toolbar icon 14 | chrome.sidePanel 15 | .setPanelBehavior({ openPanelOnActionClick: true }) 16 | .catch((error) => console.error(error)); 17 | 18 | chrome.tabs.onUpdated.addListener(async (tabId, info, tab) => { 19 | if (!tab.url) return; 20 | const url = new URL(tab.url); 21 | 22 | // Enables the side panel on google.com 23 | if (url.origin === ALLOWED_ORIGIN) { 24 | await chrome.sidePanel.setOptions({ 25 | tabId, 26 | path: "sidepanel.html", 27 | enabled: true, 28 | }); 29 | } else { 30 | // Disables the side panel on all other sites 31 | await chrome.sidePanel.setOptions({ 32 | tabId, 33 | enabled: false, 34 | }); 35 | } 36 | }); 37 | 38 | // background.js 39 | chrome.runtime.onMessage.addListener(async (message, sender, sendResponse) => { 40 | if (message.action === "summarize_comments") { 41 | if (isSummarizing) { 42 | chrome.tabs.sendMessage(sender.tab.id, { 43 | action: "alert", 44 | message: "Summarization in progress, you can summarize after", 45 | }); 46 | return; 47 | } 48 | isSummarizing = true; 49 | await chrome.sidePanel.open({ tabId: sender.tab.id }); 50 | if (sidePanelReady) { 51 | await chrome.runtime.sendMessage({ action: "start_summary" }); 52 | } 53 | await streamSummarizedComments(message.commentsChunks, sender.tab.id); 54 | } else if (message.action === "sidepanel_ready") { 55 | sidePanelReady = true; 56 | if (isSummarizing) { 57 | await chrome.runtime.sendMessage({ action: "start_summary" }); 58 | } 59 | } else if (message.action === "sidepanel_closed") { 60 | sidePanelReady = false; 61 | } 62 | }); 63 | 64 | async function fetchFinalSummary(combinedSummaries, tabId) { 65 | const finalPrompt = `Summarize the following summaries, be concise and focus on the recurring thoughts and overall sentiment. Have a section with the key points made in the discussions. Don't include a title.: ${combinedSummaries}`; 66 | 67 | // Use streaming summarization for the final summary 68 | return await llmProvider.summarizeStream(finalPrompt, (chunkText) => { 69 | chrome.runtime.sendMessage({ 70 | action: "stream_chunk", 71 | chunk: chunkText, 72 | tabId, 73 | }); 74 | }); 75 | } 76 | 77 | async function summarizeAndStreamChunk(chunk, tabId) { 78 | const prompt = `Summarize the following Hacker News comments focusing on the key points and recurring thoughts: ${JSON.stringify( 79 | chunk 80 | )}`; 81 | 82 | // Use streaming summarization via provider 83 | return await llmProvider.summarizeStream(prompt, (chunkText) => { 84 | chrome.runtime.sendMessage({ 85 | action: "stream_chunk", 86 | chunk: chunkText, 87 | tabId, 88 | }); 89 | }); 90 | } 91 | 92 | async function streamSummarizedComments(commentsChunks, tabId) { 93 | if (commentsChunks.length === 1) { 94 | const summary = await summarizeAndStreamChunk(commentsChunks[0], tabId); 95 | isSummarizing = false; 96 | chrome.runtime.sendMessage({ 97 | action: "stream_complete", 98 | summary, 99 | tabId, 100 | }); 101 | return; 102 | } 103 | try { 104 | // Create an array of promises to call the LLM provider for each chunk 105 | const summaries = await Promise.all( 106 | commentsChunks.map(async (chunk) => { 107 | const prompt = `Summarize the following Hacker News comments focusing on the key points and recurring thoughts: ${JSON.stringify( 108 | chunk 109 | )}`; 110 | 111 | // Use provider summarization for each chunk 112 | return await llmProvider.summarize(prompt); 113 | }) 114 | ); 115 | 116 | isSummarizing = false; 117 | 118 | // Combine the summaries for the final summarization 119 | const combinedSummaries = summaries.join("\n"); 120 | const finalSummary = await fetchFinalSummary(combinedSummaries, tabId); 121 | 122 | chrome.runtime.sendMessage({ 123 | action: "stream_complete", 124 | summary: finalSummary, 125 | tabId, 126 | }); 127 | } catch (error) { 128 | console.error("Error in streamSummarizedComments:", error.message); 129 | chrome.runtime.sendMessage({ 130 | action: "stream_error", 131 | error: error.message, 132 | tabId, 133 | }); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /content.js: -------------------------------------------------------------------------------- 1 | // Function to add the summarize button 2 | function addSummarizeButtons() { 3 | // get the subline from hacker news submission 4 | const sublines = document.querySelectorAll(".subline"); 5 | // add a summarize all link to the subline 6 | sublines.forEach((subline) => { 7 | if (!subline.querySelector(".summarize-all-btn")) { 8 | const summarizeAllLink = document.createElement("a"); 9 | summarizeAllLink.classList.add("summarize-all-btn"); 10 | summarizeAllLink.href = "#"; 11 | summarizeAllLink.innerText = "summarize all"; 12 | // add a text node with a pipe character 13 | const pipe = document.createTextNode(" | "); 14 | subline.appendChild(pipe); 15 | subline.appendChild(summarizeAllLink); 16 | 17 | // Add click event listener to the summarize all button 18 | summarizeAllLink.addEventListener("click", async (event) => { 19 | event.preventDefault(); 20 | await summarizeAllComments(); 21 | }); 22 | } 23 | }); 24 | 25 | // get all hacker news comment elements 26 | const comments = document.querySelectorAll(".comtr"); 27 | comments.forEach((comment) => { 28 | // get hacker news comment header 29 | const header = comment.querySelector(".comhead"); 30 | if (!header.querySelector(".summarize-btn")) { 31 | // get the navs element with class navs 32 | const navs = header.querySelector(".navs"); 33 | 34 | if (!navs) { 35 | return; 36 | } 37 | 38 | // create a link for summarizing and add it to the existing links 39 | const summarizeLink = document.createElement("a"); 40 | summarizeLink.classList.add("summarize-btn"); 41 | summarizeLink.href = "#"; 42 | summarizeLink.innerText = "summarize"; 43 | navs.appendChild(summarizeLink); 44 | 45 | // Add click event listener to the summarize button 46 | summarizeLink.addEventListener("click", async (event) => { 47 | event.preventDefault(); 48 | await summarizeComment(comment); 49 | }); 50 | } 51 | }); 52 | } 53 | 54 | async function summarizeComment(commentElement) { 55 | let nextElement = commentElement.nextElementSibling; 56 | const currentIndentLevel = parseInt( 57 | commentElement.querySelector(".ind").getAttribute("indent"), 58 | 10 59 | ); 60 | 61 | const commentsArray = []; 62 | let currentChunk = []; 63 | let currentChunkLength = 0; 64 | const maxChunkSize = 50000; 65 | 66 | // Extract the original parent comment 67 | const parentCommentTextElement = commentElement.querySelector(".commtext"); 68 | const parentCommentText = parentCommentTextElement 69 | ? parentCommentTextElement.innerText 70 | : ""; 71 | const parentAuthorElement = commentElement.querySelector(".hnuser"); 72 | const parentAuthor = parentAuthorElement 73 | ? parentAuthorElement.innerText 74 | : "Unknown"; 75 | const parentId = commentElement.id; 76 | 77 | const parentComment = { 78 | id: parentId, 79 | author: parentAuthor, 80 | text: parentCommentText, 81 | parent: null, // Parent has no parent 82 | }; 83 | 84 | // Function to add a comment to the current chunk or create a new one if necessary 85 | function addCommentToChunk(comment) { 86 | const commentLength = comment.text.length; 87 | 88 | // Check if adding the current comment would exceed the chunk size limit 89 | if (currentChunkLength + commentLength > maxChunkSize) { 90 | // Push the current chunk (with the parent comment) to commentsArray 91 | commentsArray.push([parentComment, ...currentChunk]); 92 | // Start a new chunk 93 | currentChunk = []; 94 | currentChunkLength = 0; 95 | } 96 | 97 | // Add the comment to the current chunk 98 | currentChunk.push(comment); 99 | currentChunkLength += commentLength; 100 | } 101 | 102 | // Iterate over the sibling comments until the indent level changes or comments run out 103 | while (nextElement) { 104 | const commentTextElement = nextElement.querySelector(".commtext"); 105 | const commentText = commentTextElement ? commentTextElement.innerText : ""; 106 | 107 | const authorElement = nextElement.querySelector(".hnuser"); 108 | const author = authorElement ? authorElement.innerText : "Unknown"; 109 | 110 | const commentId = nextElement.id; 111 | 112 | // Check for the parent (reply) by looking for 'par' class 113 | const parentElement = nextElement.querySelector(".par a"); 114 | const parentId = parentElement 115 | ? parentElement.getAttribute("href").replace("#", "") 116 | : null; 117 | 118 | if (commentText) { 119 | const comment = { 120 | id: commentId, 121 | author: author, 122 | text: commentText, 123 | parent: parentId, // The parent comment ID, or null if it's a top-level comment 124 | }; 125 | 126 | // Add the comment to the chunk 127 | addCommentToChunk(comment); 128 | } 129 | 130 | // Check the indent level of the next comment 131 | const indentElement = nextElement.querySelector(".ind"); 132 | if (indentElement) { 133 | const nextIndentLevel = parseInt( 134 | indentElement.getAttribute("indent"), 135 | 10 136 | ); 137 | if (nextIndentLevel <= currentIndentLevel) { 138 | break; 139 | } 140 | } 141 | 142 | nextElement = nextElement.nextElementSibling; 143 | } 144 | 145 | // If there are any remaining comments in the current chunk, add them to the array 146 | if (currentChunk.length > 0) { 147 | commentsArray.push([parentComment, ...currentChunk]); 148 | } 149 | 150 | // Send the array of chunks to the background script in one message 151 | try { 152 | await chrome.runtime.sendMessage({ 153 | action: "summarize_comments", 154 | commentsChunks: commentsArray, 155 | }); 156 | } catch (error) { 157 | console.error("Error sending comments for summarization:", error); 158 | } 159 | } 160 | 161 | // Function to summarize all comments (stub for now) 162 | async function summarizeAllComments() { 163 | const topLevelComments = document.querySelectorAll(".comtr"); 164 | 165 | const commentsArray = []; 166 | let currentChunk = []; 167 | let currentChunkLength = 0; 168 | const maxChunkSize = 50000; 169 | 170 | // Function to add a comment to the current chunk or create a new one if necessary 171 | function addCommentToChunk(comment) { 172 | const commentLength = comment.text.length; 173 | 174 | // Check if adding the current comment would exceed the chunk size limit 175 | if (currentChunkLength + commentLength > maxChunkSize) { 176 | // Push the current chunk to commentsArray 177 | commentsArray.push([...currentChunk]); 178 | // Start a new chunk 179 | currentChunk = []; 180 | currentChunkLength = 0; 181 | } 182 | 183 | // Add the comment to the current chunk 184 | currentChunk.push(comment); 185 | currentChunkLength += commentLength; 186 | } 187 | 188 | // Process each top-level comment and its replies 189 | topLevelComments.forEach((commentElement) => { 190 | const indentLevel = parseInt( 191 | commentElement.querySelector(".ind").getAttribute("indent"), 192 | 10 193 | ); 194 | 195 | // Skip comments that aren't top-level (indent level 0) 196 | if (indentLevel !== 0) return; 197 | 198 | let nextElement = commentElement.nextElementSibling; 199 | const currentIndentLevel = parseInt( 200 | commentElement.querySelector(".ind").getAttribute("indent"), 201 | 10 202 | ); 203 | 204 | const parentCommentTextElement = commentElement.querySelector(".commtext"); 205 | const parentCommentText = parentCommentTextElement 206 | ? parentCommentTextElement.innerText 207 | : ""; 208 | const parentAuthorElement = commentElement.querySelector(".hnuser"); 209 | const parentAuthor = parentAuthorElement 210 | ? parentAuthorElement.innerText 211 | : "Unknown"; 212 | const parentId = commentElement.id; 213 | 214 | const parentComment = { 215 | id: parentId, 216 | author: parentAuthor, 217 | text: parentCommentText, 218 | parent: null, // Parent has no parent 219 | }; 220 | 221 | addCommentToChunk(parentComment); 222 | 223 | // Process replies (immediate children, indent level 1) 224 | while (nextElement) { 225 | const commentTextElement = nextElement.querySelector(".commtext"); 226 | const commentText = commentTextElement 227 | ? commentTextElement.innerText 228 | : ""; 229 | 230 | const authorElement = nextElement.querySelector(".hnuser"); 231 | const author = authorElement ? authorElement.innerText : "Unknown"; 232 | 233 | const commentId = nextElement.id; 234 | 235 | const indentElement = nextElement.querySelector(".ind"); 236 | if (indentElement) { 237 | const nextIndentLevel = parseInt( 238 | indentElement.getAttribute("indent"), 239 | 10 240 | ); 241 | if (nextIndentLevel <= currentIndentLevel) { 242 | break; // Stop if the next comment is not a direct reply 243 | } else if (nextIndentLevel === currentIndentLevel + 1) { 244 | // Process immediate replies (indent level 1) 245 | const replyComment = { 246 | id: commentId, 247 | author: author, 248 | text: commentText, 249 | parent: parentId, 250 | }; 251 | addCommentToChunk(replyComment); 252 | } 253 | } 254 | 255 | nextElement = nextElement.nextElementSibling; 256 | } 257 | }); 258 | 259 | // If there are any remaining comments in the current chunk, add them to the array 260 | if (currentChunk.length > 0) { 261 | commentsArray.push([...currentChunk]); 262 | } 263 | 264 | // Send the array of chunks to the background script 265 | try { 266 | await chrome.runtime.sendMessage({ 267 | action: "summarize_comments", 268 | commentsChunks: commentsArray, 269 | }); 270 | } catch (error) { 271 | console.error("Error sending comments for summarization:", error); 272 | } 273 | } 274 | 275 | // Function to send the request to OpenAI 276 | async function sendSummarizationRequest(commentsText, accumulatedSummary) { 277 | return new Promise((resolve, reject) => { 278 | chrome.runtime.sendMessage( 279 | { 280 | action: "summarize_comments", 281 | commentsText, 282 | accumulatedSummary, 283 | }, 284 | (response) => { 285 | if (response && response.summary) { 286 | resolve(response.summary); // Return the new summary 287 | } else { 288 | reject("Summarization failed"); 289 | } 290 | } 291 | ); 292 | }); 293 | } 294 | 295 | // Run the function when the page loads 296 | window.addEventListener("load", () => { 297 | addSummarizeButtons(); 298 | 299 | // listen for alert messages from the background script 300 | chrome.runtime.onMessage.addListener((message) => { 301 | if (message.action === "alert") { 302 | alert(message.message); 303 | } 304 | }); 305 | }); 306 | -------------------------------------------------------------------------------- /hello.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |Summarized comments will appear here.
55 |Summarizing, please wait...
59 |") // Convert multiple newlines to paragraphs 24 | .replace(/\s+/g, " ") // Ensure there are no extra spaces between words 25 | .trim(); // Remove leading and trailing whitespace 26 | return `
${formattedText}
`; 27 | } 28 | 29 | // Add message listener 30 | chrome.runtime.onMessage.addListener((message) => { 31 | console.log("Received message:", message); 32 | 33 | if (message.action === "stream_chunk") { 34 | // Show loading when the first chunk is received 35 | showLoading(); 36 | 37 | summaryContent += message.chunk; 38 | summaryDiv.innerHTML = formatText(summaryContent); 39 | } else if (message.action === "stream_complete") { 40 | console.log("Stream complete, final summary:", message.summary); 41 | summaryContent = message.summary; 42 | summaryDiv.innerHTML = `Summary Complete: ${formatText( 43 | summaryContent 44 | )}
`; 45 | hideLoading(); // Hide loading once streaming is complete 46 | } else if (message.action === "stream_error") { 47 | summaryDiv.innerHTML = 48 | "Error summarizing comments.
"; 49 | hideLoading(); // Hide loading if an error occurs 50 | } else if (message.action === "start_summary") { 51 | console.log("received start summary"); 52 | summaryContent = ""; // Reset the summary content 53 | summaryDiv.innerHTML = ""; // Clear the summary div 54 | showLoading(); 55 | } 56 | }); 57 | 58 | // Ensure the listener is set up before sending messages from the background 59 | console.log("Side panel script loaded and listener set up."); 60 | 61 | chrome.runtime.sendMessage({ action: "sidepanel_ready" }); 62 | -------------------------------------------------------------------------------- /summarize.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/built-by-as/FastDigest/af03e66d6ccf39dfc73b44cc31a3eef24f036f5d/summarize.png -------------------------------------------------------------------------------- /summarize_all.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/built-by-as/FastDigest/af03e66d6ccf39dfc73b44cc31a3eef24f036f5d/summarize_all.png -------------------------------------------------------------------------------- /summarize_hn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/built-by-as/FastDigest/af03e66d6ccf39dfc73b44cc31a3eef24f036f5d/summarize_hn.png --------------------------------------------------------------------------------