A browser extension that lets you summarize any webpage and ask questions using AI.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add streaming support for API responses

- Implement real-time streaming for both summary and chat responses
- Add pulsing dots placeholder while waiting for first token
- Match streaming content structure with final output to prevent layout shifts
- Include chat suggestions feature for quick follow-up questions

+540 -75
+67 -11
popup/popup.css
··· 350 350 font-weight: 600; 351 351 } 352 352 353 - /* Streaming plain text */ 353 + /* Streaming content - match .result styling for consistency */ 354 354 .streaming-content { 355 - white-space: pre-wrap; 356 - word-wrap: break-word; 357 355 font-size: 14px; 358 356 line-height: 1.7; 359 357 color: var(--text-secondary); 360 358 } 361 359 362 - .streaming-content.streaming::after { 363 - content: '▋'; 364 - color: var(--text-muted); 365 - animation: blink-cursor 0.6s step-end infinite; 366 - margin-left: 1px; 360 + .streaming-content > * + * { 361 + margin-top: 10px; 362 + } 363 + 364 + .streaming-content > *:first-child { 365 + margin-top: 0; 366 + } 367 + 368 + /* Loading placeholder before first token */ 369 + .streaming-placeholder { 370 + display: flex; 371 + align-items: center; 372 + gap: 4px; 373 + padding: 4px 0; 374 + } 375 + 376 + .streaming-placeholder .pulse-dot { 377 + width: 6px; 378 + height: 6px; 379 + background: var(--text-muted); 380 + border-radius: 50%; 381 + animation: pulse-dot 1.4s ease-in-out infinite; 382 + } 383 + 384 + .streaming-placeholder .pulse-dot:nth-child(2) { 385 + animation-delay: 0.2s; 386 + } 387 + 388 + .streaming-placeholder .pulse-dot:nth-child(3) { 389 + animation-delay: 0.4s; 367 390 } 368 391 369 - @keyframes blink-cursor { 370 - 0%, 100% { opacity: 1; } 371 - 50% { opacity: 0; } 392 + @keyframes pulse-dot { 393 + 0%, 80%, 100% { 394 + transform: scale(0.6); 395 + opacity: 0.4; 396 + } 397 + 40% { 398 + transform: scale(1); 399 + opacity: 1; 400 + } 372 401 } 373 402 374 403 /* ── Footer ── */ ··· 449 478 450 479 .chat-container.hidden { 451 480 display: none; 481 + } 482 + 483 + /* Chat suggestions */ 484 + .chat-suggestions { 485 + display: flex; 486 + flex-wrap: wrap; 487 + gap: 6px; 488 + margin-bottom: 10px; 489 + } 490 + 491 + .suggestion-btn { 492 + padding: 5px 10px; 493 + border-radius: 14px; 494 + border: 1px solid var(--border); 495 + background: var(--bg-subtle); 496 + color: var(--text-muted); 497 + font-family: inherit; 498 + font-size: 11px; 499 + cursor: pointer; 500 + transition: all 0.1s; 501 + white-space: nowrap; 502 + } 503 + 504 + .suggestion-btn:hover { 505 + border-color: var(--border-hover); 506 + color: var(--text); 507 + background: var(--bg); 452 508 } 453 509 454 510 /* Chat messages are rendered inside .result, sharing the same scroll */
+251 -64
popup/popup.js
··· 11 11 let isChatLoading = false; 12 12 let contentWasTruncated = false; // Track if content was truncated during extraction 13 13 14 + // Streaming state 15 + let streamingChatContent = ""; 16 + let streamingSummaryContent = ""; 17 + let currentStreamTarget = null; // "chat" or "summary" 18 + let currentStreamElement = null; 19 + 14 20 const resultContainer = document.getElementById("result"); 15 21 const initialState = document.getElementById("initial-state"); 16 22 const summarizeBtn = document.getElementById("summarize-btn"); ··· 21 27 const chatInput = document.getElementById("chat-input"); 22 28 const chatSendBtn = document.getElementById("chat-send"); 23 29 const footer = document.getElementById("footer"); 30 + 31 + const SUGGESTIONS = [ 32 + "Why would this be worth reading?", 33 + "What are some key quotes?", 34 + "Explain this simply", 35 + ]; 24 36 25 37 // Cache key prefix for session storage 26 38 const QUICK_SUMMARY_CACHE_PREFIX = "quick_summary_cache_"; ··· 92 104 summarizeLabel.textContent = text; 93 105 } 94 106 107 + // Listen for streaming messages from background script 108 + chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { 109 + if (message.action === "streamChunk") { 110 + handleStreamChunk(message.chunk, message.done); 111 + return true; 112 + } 113 + if (message.action === "streamDone") { 114 + handleStreamDone(message.error); 115 + return true; 116 + } 117 + return false; 118 + }); 119 + 120 + function handleStreamChunk(chunk, done) { 121 + if (!currentStreamTarget || !currentStreamElement) return; 122 + 123 + if (currentStreamTarget === "chat") { 124 + streamingChatContent += chunk; 125 + updateStreamingChatMessage(streamingChatContent); 126 + } else if (currentStreamTarget === "summary") { 127 + streamingSummaryContent += chunk; 128 + updateStreamingSummary(streamingSummaryContent); 129 + } 130 + } 131 + 132 + function handleStreamDone(error) { 133 + const streamTarget = currentStreamTarget; 134 + const streamElement = currentStreamElement; 135 + currentStreamTarget = null; 136 + currentStreamElement = null; 137 + 138 + if (!streamTarget) return; // Nothing was streaming 139 + 140 + if (error) { 141 + showToast("Error: " + error); 142 + if (streamElement) { 143 + streamElement.innerHTML = `<div class="error-message">${escapeHtml(error)}</div>`; 144 + } 145 + // Still finalize to reset states 146 + if (streamTarget === "chat") { 147 + finalizeChatStream(error); 148 + } else if (streamTarget === "summary") { 149 + finalizeSummaryStream(error); 150 + } 151 + return; 152 + } 153 + 154 + if (streamTarget === "chat") { 155 + finalizeChatStream(null); 156 + } else if (streamTarget === "summary") { 157 + finalizeSummaryStream(null); 158 + } 159 + } 160 + 161 + function updateStreamingChatMessage(content) { 162 + const chatMessages = document.querySelectorAll(".chat-message.assistant"); 163 + const lastAssistantMsg = chatMessages[chatMessages.length - 1]; 164 + if (lastAssistantMsg) { 165 + // Remove placeholder if we have content 166 + const placeholder = lastAssistantMsg.querySelector('.streaming-placeholder'); 167 + if (placeholder && content.trim()) { 168 + placeholder.remove(); 169 + } 170 + // Render markdown during streaming for consistent formatting 171 + lastAssistantMsg.innerHTML = renderMarkdown(content); 172 + // Scroll to bottom 173 + const contentContainer = document.querySelector(".content-container"); 174 + contentContainer.scrollTop = contentContainer.scrollHeight; 175 + } 176 + } 177 + 178 + function updateStreamingSummary(content) { 179 + if (currentStreamElement) { 180 + // Remove placeholder if we have content 181 + const placeholder = currentStreamElement.querySelector('.streaming-placeholder'); 182 + if (placeholder && content.trim()) { 183 + placeholder.remove(); 184 + } 185 + 186 + // Build same structure as showSummary for consistent spacing 187 + let htmlContent = ""; 188 + 189 + // Add truncation warning if applicable (same as showSummary) 190 + if (contentWasTruncated) { 191 + htmlContent += ` 192 + <div class="truncation-warning"> 193 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 194 + <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path> 195 + <line x1="12" y1="9" x2="12" y2="13"></line> 196 + <line x1="12" y1="17" x2="12.01" y2="17"></line> 197 + </svg> 198 + <span>Partial summary based on first part of article</span> 199 + </div> 200 + `; 201 + } 202 + 203 + // Render markdown content 204 + htmlContent += renderMarkdown(content); 205 + currentStreamElement.innerHTML = htmlContent; 206 + 207 + // Scroll to show new content 208 + const contentContainer = document.querySelector(".content-container"); 209 + contentContainer.scrollTop = contentContainer.scrollHeight; 210 + } 211 + } 212 + 95 213 document.addEventListener("DOMContentLoaded", async () => { 96 214 // Load saved theme before rendering anything 97 215 currentTheme = (await chrome.storage.sync.get("theme")).theme || "system"; ··· 420 538 421 539 resultContainer.appendChild(buttonContainer); 422 540 541 + // Add suggestions below summary actions 542 + renderSuggestions(); 543 + 423 544 // Hide footer after summary is shown 424 545 footer.classList.add("hidden"); 425 546 ··· 429 550 430 551 function showChat() { 431 552 chatContainer.classList.remove("hidden"); 553 + } 554 + 555 + function renderSuggestions() { 556 + if (chatHistory.length > 0) { 557 + const existing = resultContainer.querySelector(".chat-suggestions"); 558 + if (existing) existing.remove(); 559 + return; 560 + } 561 + const existing = resultContainer.querySelector(".chat-suggestions"); 562 + if (existing) return; 563 + const suggestionsEl = document.createElement("div"); 564 + suggestionsEl.className = "chat-suggestions"; 565 + suggestionsEl.innerHTML = SUGGESTIONS.map((s) => 566 + `<button class="suggestion-btn">${escapeHtml(s)}</button>` 567 + ).join(""); 568 + suggestionsEl.querySelectorAll(".suggestion-btn").forEach((btn) => { 569 + btn.addEventListener("click", () => { 570 + chatInput.value = btn.textContent; 571 + sendChatMessage(); 572 + }); 573 + }); 574 + resultContainer.appendChild(suggestionsEl); 432 575 } 433 576 434 577 function renderChatMessages() { ··· 525 668 { role: "user", content: message }, 526 669 ]; 527 670 528 - const response = await chrome.runtime.sendMessage({ 529 - action: "chat", 671 + // Set up streaming state 672 + streamingChatContent = ""; 673 + currentStreamTarget = "chat"; 674 + currentStreamElement = loadingEl; 675 + 676 + // Replace loading spinner with streaming placeholder 677 + loadingEl.classList.remove("loading"); 678 + loadingEl.innerHTML = ` 679 + <div class="streaming-placeholder"> 680 + <div class="pulse-dot"></div> 681 + <div class="pulse-dot"></div> 682 + <div class="pulse-dot"></div> 683 + </div> 684 + `; 685 + 686 + // Start streaming request 687 + chrome.runtime.sendMessage({ 688 + action: "streamChat", 689 + tabId: currentTabId, 530 690 data: { 531 691 apiBaseUrl: settings.apiBaseUrl, 532 692 model: settings.model, ··· 537 697 }, 538 698 }); 539 699 540 - // Remove loading indicator 541 - loadingEl.remove(); 542 - 543 - if (!response || !response.success) { 544 - throw new Error(response?.error || "No response from extension"); 545 - } 546 - 547 - const answer = 548 - response.data.choices?.[0]?.message?.content || 549 - response.data.response || 550 - response.data.message?.content || 551 - "No response received"; 552 - 553 - // Add assistant response to history 554 - chatHistory.push({ role: "assistant", content: answer }); 555 - renderChatMessages(); 700 + // The response will come via onMessage listener 556 701 557 - // Cache chat history 558 - if (currentTabId) { 559 - await chrome.storage.session.set({ 560 - [CHAT_CACHE_PREFIX + currentTabId]: { 561 - messages: chatHistory, 562 - url: currentTabUrl, 563 - timestamp: Date.now(), 564 - }, 565 - }); 566 - } 567 702 } catch (error) { 568 703 // Remove loading indicator 569 704 loadingEl.remove(); ··· 578 713 // Scroll to show error 579 714 const contentContainer = document.querySelector(".content-container"); 580 715 contentContainer.scrollTop = contentContainer.scrollHeight; 581 - } finally { 716 + 582 717 isChatLoading = false; 583 718 chatSendBtn.disabled = false; 584 719 chatInput.focus(); 720 + currentStreamTarget = null; 721 + currentStreamElement = null; 722 + } 723 + } 724 + 725 + // Called when streaming is done (success or error) 726 + function finalizeChatStream(error) { 727 + isChatLoading = false; 728 + chatSendBtn.disabled = false; 729 + chatInput.focus(); 730 + 731 + if (error) { 732 + showToast("Error: " + error); 733 + return; 734 + } 735 + 736 + // Add assistant response to history 737 + chatHistory.push({ role: "assistant", content: streamingChatContent }); 738 + renderChatMessages(); 739 + 740 + // Cache chat history 741 + if (currentTabId) { 742 + chrome.storage.session.set({ 743 + [CHAT_CACHE_PREFIX + currentTabId]: { 744 + messages: chatHistory, 745 + url: currentTabUrl, 746 + timestamp: Date.now(), 747 + }, 748 + }); 585 749 } 586 750 } 587 751 ··· 632 796 633 797 await chrome.runtime.sendMessage({ action: "ping" }).catch(() => {}); 634 798 635 - const response = await chrome.runtime.sendMessage({ 636 - action: "chat", 799 + // Set up streaming state 800 + streamingSummaryContent = ""; 801 + currentStreamTarget = "summary"; 802 + currentStreamElement = null; 803 + 804 + // Show streaming placeholder with loading animation 805 + const streamingEl = document.createElement("div"); 806 + streamingEl.className = "streaming-content"; 807 + streamingEl.innerHTML = ` 808 + <div class="streaming-placeholder"> 809 + <div class="pulse-dot"></div> 810 + <div class="pulse-dot"></div> 811 + <div class="pulse-dot"></div> 812 + </div> 813 + `; 814 + resultContainer.innerHTML = ""; 815 + resultContainer.appendChild(streamingEl); 816 + currentStreamElement = streamingEl; 817 + 818 + // Start streaming request 819 + chrome.runtime.sendMessage({ 820 + action: "streamChat", 821 + tabId: currentTabId, 637 822 data: { 638 823 apiBaseUrl: settings.apiBaseUrl, 639 824 model: settings.model, ··· 644 829 }, 645 830 }); 646 831 647 - if (!response || !response.success) { 648 - throw new Error(response?.error || "No response from extension"); 649 - } 650 - 651 - const summary = 652 - response.data.choices?.[0]?.message?.content || 653 - response.data.response || 654 - response.data.message?.content || 655 - "No response received"; 832 + // The response will come via postMessage listener 656 833 657 - quickSummary = summary; 658 - detailedSummary = ""; // Reset detailed summary when regenerating quick summary 659 - showSummary(quickSummary); 660 - setSummarizeLabel("Regenerate"); 661 - 662 - // Cache the quick summary for this tab 663 - if (currentTabId) { 664 - await chrome.storage.session.set({ 665 - [QUICK_SUMMARY_CACHE_PREFIX + currentTabId]: { 666 - summary: quickSummary, 667 - url: currentTabUrl, 668 - timestamp: Date.now(), 669 - }, 670 - [CONTENT_CACHE_PREFIX + currentTabId]: { 671 - content: currentPageContent, 672 - url: currentTabUrl, 673 - }, 674 - }); 675 - // Clear any old detailed summary cache since we're regenerating 676 - await chrome.storage.session.remove([ 677 - DETAILED_SUMMARY_CACHE_PREFIX + currentTabId, 678 - ]); 679 - } 680 834 } catch (error) { 681 835 console.error("API Error:", error); 682 836 resultContainer.innerHTML = `<div class="error-message">${escapeHtml(error.message)}</div>`; 683 - } finally { 684 837 setLoading(false); 838 + } 839 + } 840 + 841 + function finalizeSummaryStream(error) { 842 + if (error) { 843 + setLoading(false); 844 + if (currentStreamElement) { 845 + currentStreamElement.innerHTML = `<div class="error-message">${escapeHtml(error)}</div>`; 846 + } 847 + return; 848 + } 849 + 850 + quickSummary = streamingSummaryContent; 851 + detailedSummary = ""; 852 + showSummary(quickSummary); 853 + setLoading(false); // Set this after showSummary so label is correct 854 + 855 + // Cache the quick summary for this tab 856 + if (currentTabId) { 857 + chrome.storage.session.set({ 858 + [QUICK_SUMMARY_CACHE_PREFIX + currentTabId]: { 859 + summary: quickSummary, 860 + url: currentTabUrl, 861 + timestamp: Date.now(), 862 + }, 863 + [CONTENT_CACHE_PREFIX + currentTabId]: { 864 + content: currentPageContent, 865 + url: currentTabUrl, 866 + }, 867 + }); 868 + // Clear any old detailed summary cache since we're regenerating 869 + chrome.storage.session.remove([ 870 + DETAILED_SUMMARY_CACHE_PREFIX + currentTabId, 871 + ]); 685 872 } 686 873 } 687 874
+222
scripts/background.js
··· 120 120 return true; // Keep channel open for async 121 121 } 122 122 123 + if (request.action === "streamChat") { 124 + const { tabId } = request; 125 + handleStreamChatRequest(request.data, tabId) 126 + .catch((error) => { 127 + console.error("Stream chat error:", error); 128 + chrome.tabs.sendMessage(tabId, { 129 + action: "streamDone", 130 + error: error.message, 131 + }); 132 + }); 133 + return false; // We handle the response ourselves via sendMessage to tab 134 + } 135 + 123 136 if (request.action === "testOllama") { 124 137 testOllamaConnection() 125 138 .then(() => sendResponse({ success: true })) ··· 146 159 return await callOllamaNative(apiBaseUrl, model, messages, disableThinking); 147 160 } else { 148 161 return await callOpenAICompatible(apiBaseUrl, model, apiKey, messages); 162 + } 163 + } 164 + 165 + async function handleStreamChatRequest(data, tabId) { 166 + const { apiBaseUrl, model, apiKey, messages, apiMode, disableThinking } = data; 167 + 168 + let useNativeOllama = apiMode === "ollama"; 169 + 170 + if (useNativeOllama) { 171 + await callOllamaNativeStream(apiBaseUrl, model, messages, disableThinking, tabId); 172 + } else { 173 + await callOpenAICompatibleStream(apiBaseUrl, model, apiKey, messages, tabId); 149 174 } 150 175 } 151 176 ··· 282 307 283 308 return await response.json(); 284 309 } 310 + 311 + async function callOllamaNativeStream(baseUrl, model, messages, disableThinking, tabId) { 312 + const systemMsgs = messages.filter((m) => m.role === "system"); 313 + const systemContent = systemMsgs.map((m) => m.content).join("\n\n"); 314 + const otherMessages = messages.filter((m) => m.role !== "system"); 315 + const lastUserMsg = otherMessages.filter((m) => m.role === "user").pop(); 316 + 317 + let prompt; 318 + if (otherMessages.length > 1) { 319 + const context = otherMessages 320 + .slice(0, -1) 321 + .map((m) => `${m.role}: ${m.content}`) 322 + .join("\n"); 323 + prompt = OLLAMA_CONTEXT_TEMPLATE 324 + .replace("${context}", context) 325 + .replace("${userMessage}", lastUserMsg?.content || ""); 326 + } else { 327 + prompt = lastUserMsg?.content || ""; 328 + } 329 + 330 + const url = baseUrl.replace(/\/$/, "") + "/api/generate"; 331 + 332 + const requestBody = { 333 + model: model, 334 + prompt: prompt, 335 + system: systemContent, 336 + stream: true, 337 + options: { 338 + temperature: 0.7, 339 + num_predict: 2048, 340 + }, 341 + }; 342 + 343 + if (disableThinking === true) { 344 + requestBody.think = false; 345 + } 346 + 347 + try { 348 + const response = await fetch(url, { 349 + method: "POST", 350 + headers: { 351 + "Content-Type": "application/json", 352 + }, 353 + body: JSON.stringify(requestBody), 354 + }); 355 + 356 + if (!response.ok) { 357 + const text = await response.text(); 358 + let errorMsg = `HTTP ${response.status}`; 359 + if (response.status === 403) { 360 + errorMsg = "403 Forbidden. Ollama is rejecting the request origin. Fix: restart Ollama with OLLAMA_ORIGINS=* (e.g. OLLAMA_ORIGINS=* ollama serve)."; 361 + } else { 362 + try { 363 + const err = JSON.parse(text); 364 + errorMsg = err.error || err.message || errorMsg; 365 + } catch (e) { 366 + errorMsg = text || errorMsg; 367 + } 368 + } 369 + throw new Error(errorMsg); 370 + } 371 + 372 + const reader = response.body.getReader(); 373 + const decoder = new TextDecoder(); 374 + let buffer = ""; 375 + 376 + while (true) { 377 + const { done, value } = await reader.read(); 378 + if (done) break; 379 + 380 + buffer += decoder.decode(value, { stream: true }); 381 + const lines = buffer.split("\n"); 382 + buffer = lines.pop() || ""; 383 + 384 + for (const line of lines) { 385 + if (line.trim()) { 386 + try { 387 + const json = JSON.parse(line); 388 + if (json.response) { 389 + chrome.runtime.sendMessage({ 390 + action: "streamChunk", 391 + chunk: json.response, 392 + done: false, 393 + }).catch(() => {}); 394 + } 395 + } catch (e) { 396 + // Skip invalid JSON lines 397 + } 398 + } 399 + } 400 + } 401 + 402 + // Streaming complete - send done message 403 + chrome.runtime.sendMessage({ 404 + action: "streamDone", 405 + }).catch(() => {}); 406 + 407 + } catch (error) { 408 + chrome.runtime.sendMessage({ 409 + action: "streamDone", 410 + error: error.message, 411 + }).catch(() => {}); 412 + } 413 + } 414 + 415 + async function callOpenAICompatibleStream(baseUrl, model, apiKey, messages, tabId) { 416 + let url = baseUrl.replace(/\/$/, ""); 417 + 418 + if (!url.includes("/v1")) { 419 + url = url + "/v1"; 420 + } 421 + 422 + url = url + "/chat/completions"; 423 + 424 + try { 425 + const response = await fetch(url, { 426 + method: "POST", 427 + headers: { 428 + "Content-Type": "application/json", 429 + ...(apiKey && { Authorization: `Bearer ${apiKey}` }), 430 + }, 431 + body: JSON.stringify({ 432 + model: model, 433 + messages: messages, 434 + stream: true, 435 + max_tokens: 2048, 436 + }), 437 + }); 438 + 439 + if (!response.ok) { 440 + const text = await response.text(); 441 + let errorMsg = `HTTP ${response.status}`; 442 + 443 + if (response.status === 403) { 444 + if (url.includes("/v1")) { 445 + errorMsg = "403 Forbidden. This often means: invalid API key, API key lacks permissions, or the server rejected the request origin."; 446 + } else { 447 + errorMsg = "403 Forbidden. If using Ollama, ensure it's running with: ollama serve"; 448 + } 449 + } else if (response.status === 405) { 450 + errorMsg = "405 Method not allowed. Check if the API URL is correct for your API mode (Native vs OpenAI-compatible)."; 451 + } else { 452 + try { 453 + const err = JSON.parse(text); 454 + errorMsg = err.error?.message || err.message || errorMsg; 455 + } catch (e) { 456 + errorMsg = text || errorMsg; 457 + } 458 + } 459 + throw new Error(errorMsg); 460 + } 461 + 462 + const reader = response.body.getReader(); 463 + const decoder = new TextDecoder(); 464 + let buffer = ""; 465 + 466 + while (true) { 467 + const { done, value } = await reader.read(); 468 + if (done) break; 469 + 470 + buffer += decoder.decode(value, { stream: true }); 471 + const lines = buffer.split("\n"); 472 + buffer = lines.pop() || ""; 473 + 474 + for (const line of lines) { 475 + if (line.trim() && line.startsWith("data: ")) { 476 + const data = line.slice(6); 477 + if (data === "[DONE]") continue; 478 + try { 479 + const json = JSON.parse(data); 480 + const content = json.choices?.[0]?.delta?.content; 481 + if (content) { 482 + chrome.runtime.sendMessage({ 483 + action: "streamChunk", 484 + chunk: content, 485 + done: false, 486 + }).catch(() => {}); 487 + } 488 + } catch (e) { 489 + // Skip invalid JSON lines 490 + } 491 + } 492 + } 493 + } 494 + 495 + // Streaming complete - send done message 496 + chrome.runtime.sendMessage({ 497 + action: "streamDone", 498 + }).catch(() => {}); 499 + 500 + } catch (error) { 501 + chrome.runtime.sendMessage({ 502 + action: "streamDone", 503 + error: error.message, 504 + }).catch(() => {}); 505 + } 506 + }