Compare commits

...

32 Commits
beta ... master

Author SHA1 Message Date
tcsenpai
ba230d04ed added experimental youtube support and logging view 2025-03-17 12:09:20 +01:00
tcsenpai
0d60dbceb1 updated screenshot and readme 2025-03-11 12:56:09 +01:00
tcsenpai
f868a40d1b bump 2025-03-11 12:47:32 +01:00
tcsenpai
28be327e91 Merge remote-tracking branch 'refs/remotes/origin/master' 2025-03-11 12:46:31 +01:00
tcsenpai
bcfbfab42f better system prompt improves accuracy a lot 2025-03-11 12:46:21 +01:00
TheCookingSenpai
fad943d216
Merge pull request #11 from tcsenpai/nice_graphics
Nice graphics
2025-03-11 12:26:38 +01:00
tcsenpai
a98379f9ee better pagination and backgrounds 2025-03-11 12:26:07 +01:00
tcsenpai
3477478cfd background added 2025-03-11 12:19:32 +01:00
tcsenpai
96a1882323 updated token limits and model to be more modern 2025-03-11 12:06:09 +01:00
TheCookingSenpai
ad363a946a
Merge pull request #10 from tcsenpai/increased_timeout
added a timeout
2025-03-11 11:32:36 +01:00
tcsenpai
71278d18b3 added a timeout 2025-03-11 11:31:42 +01:00
TheCookingSenpai
050a74adad
Update README.md 2024-11-04 21:53:08 +01:00
TheCookingSenpai
91ad5375b0
Merge pull request #4 from jks-liu/bugfix_pass-token-limit
Fix token size passing issue
2024-10-18 15:20:11 +02:00
Jks Liu
c4deab61d9 1. Add missing argument of tokcn size for summarizeChunk.
2. Make sure `num_ctx` is from `options` instead of calculated value which is not stable.
3. Simplify function of `recursiveSummarize`
2024-10-17 21:18:52 +08:00
tcsenpai
7ea7dc0cdf Merge branch 'master' of https://github.com/tcsenpai/spacellama 2024-10-15 21:48:58 +02:00
tcsenpai
da5ef7a6fe early support for further fact checking (not active) 2024-10-15 21:48:56 +02:00
TheCookingSenpai
9f98d7a5cf
Merge pull request #2 from maglore9900/master
added chrome capability
2024-10-15 21:47:12 +02:00
maglore9900
877143acce Update manifest_ch.json 2024-10-15 10:46:33 -04:00
maglore9900
49b881bb30 added chrome capability 2024-10-15 10:39:22 -04:00
tcsenpai
7ab58c548f release 2024-10-14 11:59:01 +02:00
tcsenpai
e8435556a3 bumped version 2024-10-14 11:52:54 +02:00
tcsenpai
c0b77bb24e improved ollama reaction to long contexts and variable context size 2024-10-14 11:51:19 +02:00
tcsenpai
57a28a117f updated xpi version 2024-10-13 18:50:23 +02:00
tcsenpai
08394baa54 auto model token limits 2024-10-13 18:41:23 +02:00
tcsenpai
6a01bb6024 updated instructions 2024-10-13 18:22:22 +02:00
tcsenpai
083da7f71a chunking and recursive summarizing support 2024-10-13 18:09:32 +02:00
tcsenpai
cd20d4f5c0 token count and limits support 2024-10-13 17:58:06 +02:00
tcsenpai
f90b62070d customizable system prompt 2024-10-13 17:54:38 +02:00
TheCookingSenpai
0c7ed8afff
Update README.md 2024-10-11 17:44:07 +02:00
tcsenpai
f6cb0eeca4 forgot to upload the screen lol 2024-10-11 17:37:09 +02:00
tcsenpai
710a9595ac added screenshot 2024-10-11 17:36:15 +02:00
tcsenpai
c4a969044a Added prepacked version 2024-10-11 17:34:11 +02:00
17 changed files with 1847 additions and 298 deletions

4
.gitignore vendored
View File

@ -1,3 +1,7 @@
extension
pack_extension.sh
SpaceLLama.zip
yarn.lock
dist/*.xpi
dist/*.zip
node_modules

View File

@ -1,7 +1,13 @@
# SpaceLLama
[![justforfunnoreally.dev badge](https://img.shields.io/badge/justforfunnoreally-dev-9ff)](https://justforfunnoreally.dev)
SpaceLLama is a powerful browser extension that leverages OLLAMA to provide quick and efficient web page summarization. It offers a seamless way to distill the essence of any web content, saving you time and enhancing your browsing experience.
![SpaceLLama](./dist/spacellama.png)
**[Download it from the Mozilla Extensions store!](https://addons.mozilla.org/en-US/firefox/addon/spacellama/)**
## Features
- **One-Click Summarization**: Quickly summarize any web page with a single click.
@ -9,6 +15,8 @@ SpaceLLama is a powerful browser extension that leverages OLLAMA to provide quic
- **Customizable OLLAMA Settings**: Easily configure the OLLAMA endpoint and model through the options page.
- **Markdown Rendering**: Summaries are rendered in Markdown for better readability and formatting.
- **Error Handling**: Robust error handling with informative messages for troubleshooting.
- **Token Limit Handling**: Ability to set a token limit for the summary.
- **Recursive Summarization with Context Chunking**: Recursively summarizes content that exceeds the token limit by breaking it into smaller chunks, summarizing each chunk, and then combining the summaries to provide a more comprehensive summary (only if the token limit is exceeded).
## How It Works
@ -24,8 +32,18 @@ You can customize SpaceLLama's behavior through the options page:
1. Click the "Open Settings" button in the sidebar.
2. Set your preferred OLLAMA endpoint (default is `http://localhost:11434`).
3. Choose the OLLAMA model you want to use (default is `llama2`).
4. Save your settings.
3. Choose the OLLAMA model you want to use (default is `llama3.1:latest`).
4. Set the token limit for the summary (default is `16384`).
5. Set the system prompt for the summary (default is `You are a helpful AI assistant. Summarize the given text concisely, without leaving out informations. You should aim to give a summary that is highly factual, useful and rich but still shorter than the original content, while not being too short.`).
6. Save your settings.
## Manual Installation
1. Clone the repository.
2. Install `npm install -g web-ext`
3. Run `chmod +x build_xpi_webext.sh` to make the script executable.
4. Run `./build_xpi_webext.sh` to build the extension.
5. Install the extension in your browser through `about:debugging`
## Technical Details
@ -42,6 +60,10 @@ The extension uses the `marked` library to render Markdown content in the summar
SpaceLLama processes web page content locally through your configured OLLAMA endpoint. No data is sent to external servers beyond what you configure. Always ensure you're using a trusted OLLAMA setup, especially if using a remote endpoint.
## FAQ
- If you get a 403 error, you probably need to set the environment variable `OLLAMA_ORIGINS` to "\*" on your ollama server. On Windows, you will have to set the environment variable in the `SYSTEM` environment, not just the `USER` environment.
## Contributing
Contributions to SpaceLLama are welcome! Please feel free to submit issues, feature requests, or pull requests to help improve the extension.
@ -51,6 +73,10 @@ Contributions to SpaceLLama are welcome! Please feel free to submit issues, feat
Licensed under the [Do What The Fuck You Want To Public License](LICENSE.md).
See [LICENSE.md](LICENSE.md) for more details.
## Credits
- [Background Image](https://www.pexels.com/) - I could not find the right image; if you know the author, please let me know so I can give them the credits they deserve.
---
SpaceLLama: Bringing the power of OLLAMA to your browser for effortless web page summarization.

BIN
SpaceLlama.xpi Normal file

Binary file not shown.

View File

@ -1,55 +1,326 @@
console.log("Background script loaded");
browser.browserAction.onClicked.addListener(() => {
browser.sidebarAction.toggle();
});
let isFirefox = typeof InstallTrigger !== "undefined"; // Firefox has `InstallTrigger`
let browser = isFirefox ? window.browser : chrome;
// Check if chrome.action or browser.action is available
if (isFirefox && browser.browserAction) {
// Firefox specific: Use browserAction
browser.browserAction.onClicked.addListener(() => {
console.log("Firefox: Toggling sidebar");
browser.sidebarAction.toggle();
});
} else if (browser.action) {
// Chrome specific: Use action and inject the sidebar iframe
browser.action.onClicked.addListener((tab) => {
console.log("Injecting sidebar iframe into the page");
// Use the tab object properly here
browser.scripting.executeScript(
{
target: { tabId: tab.id }, // Pass the tab ID correctly
function: injectSidebar,
},
() => {
if (browser.runtime.lastError) {
console.error(
"Error injecting sidebar:",
browser.runtime.lastError.message
);
} else {
console.log("Sidebar injected successfully.");
}
}
);
});
}
// Function to inject the sidebar as an iframe in browsers like Chrome
function injectSidebar() {
// Check if the sidebar iframe is already injected
if (document.getElementById("sidebar-frame")) {
console.log("Sidebar is already injected.");
return;
}
// Create an iframe for the sidebar
const sidebarFrame = document.createElement("iframe");
sidebarFrame.id = "sidebar-frame"; // Add an ID to prevent multiple injections
sidebarFrame.src = chrome.runtime.getURL("sidebar/sidebar.html"); // Use the sidebar.html
sidebarFrame.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 300px;
height: 100%;
border: none;
z-index: 9999;
background-color: white;
`;
// Append the sidebar iframe to the body of the active webpage
document.body.appendChild(sidebarFrame);
}
// Background script listens for the 'summarize' action
browser.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.action === "summarize") {
summarizeContent(request.content)
.then(summary => {
sendResponse({ summary });
console.log("Summarization request received in background script.");
const tokenCount = estimateTokenCount(request.content);
summarizeContent(request.content, request.systemPrompt)
.then((summary) => {
sendResponse({ summary, tokenCount });
})
.catch(error => {
console.error('Error in summarizeContent:', error);
sendResponse({ error: error.toString(), details: error.details });
.catch((error) => {
console.error("Error in summarizeContent:", error);
sendResponse({
error: error.toString(),
details: error.details,
tokenCount,
});
});
return true; // Indicates that we will send a response asynchronously
}
});
async function summarizeContent(content) {
const settings = await browser.storage.local.get(['ollamaEndpoint', 'ollamaModel']);
const endpoint = `${settings.ollamaEndpoint || 'http://localhost:11434'}/api/generate`;
const model = settings.ollamaModel || 'llama2';
async function summarizeContent(content, systemPrompt) {
const settings = await browser.storage.local.get([
"ollamaEndpoint",
"ollamaModel",
"tokenLimit",
]);
const endpoint = `${
settings.ollamaEndpoint || "http://localhost:11434"
}/api/generate`;
const model = settings.ollamaModel || "llama3.1:8b";
const tokenLimit = settings.tokenLimit || 4096;
console.log(`Starting summarization process. Token limit: ${tokenLimit}`);
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
prompt: `Summarize the following text:\n\n${content}`,
model: model,
stream: false
}),
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP error! status: ${response.status}, message: ${errorText}`);
}
const data = await response.json();
return data.response;
let { summary, chunkCount, recursionDepth } = await recursiveSummarize(
content,
systemPrompt,
tokenLimit,
endpoint,
model
);
console.log("Final summary completed.");
return {
summary:
typeof summary === "string" ? summary.trim() : JSON.stringify(summary),
// NOTE Chunk count and recursion depth are disabled if not needed
//chunkCount,
//recursionDepth,
};
} catch (error) {
console.error('Error details:', error);
console.error("Error in summarizeContent:", error);
error.details = {
endpoint: endpoint,
model: model,
message: error.message
message: error.message,
};
throw error;
}
}
async function recursiveSummarize(
content,
systemPrompt,
tokenLimit,
endpoint,
model,
depth = 0
) {
console.log(`Recursive summarization depth: ${depth}`);
const chunks = splitContentIntoChunks(content, tokenLimit, systemPrompt);
console.log(`Split content into ${chunks.length} chunks`);
let summaries = [];
for (let i = 0; i < chunks.length; i++) {
console.log(`Summarizing chunk ${i + 1} of ${chunks.length}`);
const chunkSummary = await summarizeChunk(
chunks[i],
systemPrompt,
endpoint,
model,
tokenLimit
);
summaries.push(chunkSummary);
}
const combinedSummaries = summaries.join("\n\n");
if (chunks.length <= 1) {
console.log("Single chunk, summarizing directly");
return {
summary: combinedSummaries,
chunkCount: chunks.length,
recursionDepth: depth,
};
} else {
console.log("Multiple chunks, summarizing recursively");
const result = await recursiveSummarize(
combinedSummaries,
systemPrompt,
tokenLimit,
endpoint,
model,
depth + 1
);
return {
...result,
chunkCount: chunks.length + result.chunkCount,
};
}
}
async function summarizeChunk(
chunk,
systemPrompt,
endpoint,
model,
tokenLimit
) {
let response;
let maxRetries = 3;
let retryCount = 0;
let retryDelay = 1000;
// We will retry the request if it fails (three times)
// Each time we will wait longer before retrying (1, 2, 4 seconds)
// Each request will timeout after 25 * retryDelay
while (retryCount < maxRetries) {
try {
response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
prompt: `${systemPrompt}\n\nFollow the above instructions and summarize the following text:\n\n${chunk}`,
model: model,
stream: false,
num_ctx: tokenLimit,
}),
signal: AbortSignal.timeout(25 * retryDelay),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
break; // Success - exit the retry loop
} catch (error) {
console.error("Error in summarizeChunk:", error);
retryCount++;
if (retryCount >= maxRetries) {
throw new Error(
`Failed to summarize chunk after ${maxRetries} retries: ${error.message}`
);
}
console.log(`Retry ${retryCount}/${maxRetries} after ${retryDelay}ms`);
await new Promise((resolve) => setTimeout(resolve, retryDelay));
retryDelay *= 2;
}
}
// TODO Add bespoke-minicheck validation here
// LINK https://ollama.com/library/bespoke-minicheck
let factCheck = false;
if (factCheck) {
let bespokeResponse = await bespokeMinicheck(chunk, summary);
console.log(bespokeResponse);
}
const data = await response.json();
return data.response;
}
function estimateTokenCount(text) {
return Math.ceil(text.length / 4);
}
function splitContentIntoChunks(content, tokenLimit, systemPrompt) {
const maxTokens = tokenLimit - estimateTokenCount(systemPrompt) - 100; // Reserve 100 tokens for safety
const chunks = [];
const words = content.split(/\s+/);
let currentChunk = "";
for (const word of words) {
if (estimateTokenCount(currentChunk + " " + word) > maxTokens) {
chunks.push(currentChunk.trim());
currentChunk = word;
} else {
currentChunk += (currentChunk ? " " : "") + word;
}
}
if (currentChunk) {
chunks.push(currentChunk.trim());
}
return chunks;
}
async function bespokeMinicheck(chunk, summary) {
let bespoke_prompt = `
Document: ${chunk}
Claim: This is a correct summary of the document:\n\n ${summary},
`;
let bespoke_body = {
prompt: bespoke_prompt,
model: "bespoke-minicheck:latest",
stream: false,
num_ctx: 30000, // Model is 32k but we want to leave some buffer
options: {
temperature: 0.0,
num_predict: 2,
},
};
let bespoke_response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(bespoke_body),
});
// TODO Error handling
let response_text = await bespoke_response.text();
return response_text;
}
// Add this to your background.js
let extensionLogs = [];
const MAX_LOGS = 1000;
browser.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.action === "log") {
// Store log
extensionLogs.push({
message: request.message,
data: request.data,
timestamp: request.timestamp,
url: request.url,
tabId: sender.tab ? sender.tab.id : "unknown",
});
// Also log to the console
console.log("[Content.js log]", request.message, request.data);
// Trim logs if they get too large
if (extensionLogs.length > MAX_LOGS) {
extensionLogs = extensionLogs.slice(-MAX_LOGS);
}
return true;
}
if (request.action === "getLogs") {
sendResponse({ logs: extensionLogs });
return true;
}
// Handle other messages...
});

26
build_xpi.sh Executable file
View File

@ -0,0 +1,26 @@
#!/bin/bash
# Set extension name
EXTENSION_NAME="SpaceLLama"
# Create a temporary directory for building
BUILD_DIR="./build"
mkdir -p $BUILD_DIR
# Copy all necessary files to the build directory
echo "Copying files to build directory..."
cp -r background.js content_scripts icon.png manifest.json options sidebar model_tokens.json $BUILD_DIR
# Navigate to the build directory
cd $BUILD_DIR
# Create the XPI file (which is just a ZIP file with .xpi extension)
echo "Creating XPI file..."
zip -r ../${EXTENSION_NAME}.xpi *
# Clean up
cd ..
echo "Cleaning up build directory..."
rm -rf $BUILD_DIR
echo "XPI file created: ${EXTENSION_NAME}.xpi"

6
build_xpi_webext.sh Executable file
View File

@ -0,0 +1,6 @@
#!/bin/bash
# Build the extension using web-ext
web-ext build --source-dir ./ --artifacts-dir ./dist --overwrite-dest
echo "XPI file created in ./dist directory"

View File

@ -1,16 +1,683 @@
function getPageContent() {
console.log("getPageContent called");
return document.body.innerText;
extensionLog("Content script loading...");
let browser =
typeof chrome !== "undefined"
? chrome
: typeof browser !== "undefined"
? browser
: null;
// Function to log messages to both console and background script
function extensionLog(message, data = null) {
// Log to console
if (data) {
console.log(`[SpaceLLama] ${message}`, data);
} else {
console.log(`[SpaceLLama] ${message}`);
}
browser.runtime.onMessage.addListener((request, sender, sendResponse) => {
console.log("Content script received message:", request);
if (request.action === "getContent") {
const content = getPageContent();
console.log("Sending content (first 100 chars):", content.substring(0, 100));
sendResponse({ content: content });
// Send to background script
try {
browser.runtime
.sendMessage({
action: "log",
message: message,
data: data,
timestamp: new Date().toISOString(),
url: window.location.href,
})
.catch((err) => console.error("Error sending log:", err));
} catch (e) {
console.error("Error in extensionLog:", e);
}
return true; // Indicate that we will send a response asynchronously
}
// YouTube subtitle handler for SpaceLLama
extensionLog("YouTube handler functionality initializing...");
// Function to check if the current page is a YouTube video
function isYouTubeVideo(url) {
return (
url.includes("youtube.com/watch") ||
url.includes("youtu.be/") ||
url.includes("/watch?v=")
);
}
// Extract video ID from YouTube URL
function extractVideoId(url) {
let videoId = "";
if (url.includes("youtube.com/watch")) {
const urlParams = new URLSearchParams(new URL(url).search);
videoId = urlParams.get("v");
} else if (url.includes("youtu.be/")) {
videoId = url.split("youtu.be/")[1].split("?")[0];
} else if (url.includes("/watch?v=")) {
// Adding youtube.com to the URL if it's missing (e.g. invidious)
url = "https://youtube.com/watch?v=" + url.split("/watch?v=")[1];
const urlParams = new URLSearchParams(new URL(url).search);
videoId = urlParams.get("v");
}
return videoId;
}
// Add this function to fetch subtitles using YouTube API
async function fetchSubtitlesWithApi(videoId) {
try {
// Get the API key from storage
const result = await browser.storage.sync.get({ youtubeApiKey: "" });
const apiKey = result.youtubeApiKey;
if (!apiKey) {
extensionLog("No YouTube API key provided in settings");
return null;
}
extensionLog("Attempting to fetch captions with YouTube API");
// First, get the caption tracks available for this video
const captionListUrl = `https://www.googleapis.com/youtube/v3/captions?part=snippet&videoId=${videoId}&key=${apiKey}`;
const response = await fetch(captionListUrl);
if (!response.ok) {
extensionLog("YouTube API request failed:", response.status);
return null;
}
const data = await response.json();
if (!data.items || data.items.length === 0) {
extensionLog("No caption tracks found via API");
return null;
}
// Find Eglish captions or use the first available
const englishCaption =
data.items.find(
(item) =>
item.snippet.language === "en" ||
item.snippet.language === "en-US" ||
(item.snippet.name &&
item.snippet.name.toLowerCase().includes("english"))
) || data.items[0];
// Get the caption content
const captionId = englishCaption.id;
const captionUrl = `https://www.googleapis.com/youtube/v3/captions/${captionId}?key=${apiKey}`;
// Note: This might require OAuth2 authentication which is beyond the scope of a simple extension
// If this fails, we'll need to fall back to other methods
const captionResponse = await fetch(captionUrl);
if (!captionResponse.ok) {
extensionLog("Failed to fetch caption content:", captionResponse.status);
return null;
}
const captionData = await captionResponse.json();
return captionData.text;
} catch (error) {
extensionLog("Error fetching subtitles with API:", error);
return null;
}
}
// Update the fetchYouTubeSubtitles function to try the API first
async function fetchYouTubeSubtitles(videoId) {
extensionLog("Attempting to fetch subtitles for video ID:", videoId);
try {
// First try: Use YouTube API if key is provided
const apiSubtitles = await fetchSubtitlesWithApi(videoId);
if (apiSubtitles) {
extensionLog("Successfully fetched subtitles using YouTube API");
return apiSubtitles;
}
// Method 1: Try to get subtitles directly from the page
const subtitlesFromPage = getSubtitlesFromPage();
if (subtitlesFromPage) {
extensionLog("Found subtitles in page");
return subtitlesFromPage;
}
// If all methods fail, don't use fallbacks anymore
extensionLog("No subtitles found, will use description only");
return null;
} catch (error) {
extensionLog("Error fetching YouTube subtitles:", error);
return null;
}
}
// Extract player response data from page
function getPlayerResponseData() {
try {
// YouTube stores player data in a script tag or window variable
for (const script of document.querySelectorAll("script")) {
if (script.textContent.includes("ytInitialPlayerResponse")) {
const match = script.textContent.match(
/ytInitialPlayerResponse\s*=\s*({.+?});/
);
if (match && match[1]) {
return JSON.parse(match[1]);
}
}
}
// Try window variable if available
if (typeof window.ytInitialPlayerResponse !== "undefined") {
return window.ytInitialPlayerResponse;
}
return null;
} catch (error) {
extensionLog("Error getting player response data:", error);
return null;
}
}
// Get initial player response from window variable
function getInitialPlayerResponse() {
try {
// Look for the data in various possible locations
if (typeof window.ytInitialPlayerResponse !== "undefined") {
return window.ytInitialPlayerResponse;
}
// Try to find it in script tags
for (const script of document.querySelectorAll("script:not([src])")) {
if (script.textContent.includes("ytInitialPlayerResponse")) {
const match = script.textContent.match(
/ytInitialPlayerResponse\s*=\s*({.+?});/
);
if (match && match[1]) {
try {
return JSON.parse(match[1]);
} catch (e) {
extensionLog("Error parsing ytInitialPlayerResponse:", e);
}
}
}
}
return null;
} catch (error) {
extensionLog("Error getting initial player response:", error);
return null;
}
}
// Extract subtitles from player response data
function extractSubtitlesFromPlayerResponse(playerResponse) {
try {
if (!playerResponse || !playerResponse.captions) {
return null;
}
const captionTracks =
playerResponse.captions?.playerCaptionsTracklistRenderer?.captionTracks;
if (!captionTracks || captionTracks.length === 0) {
return null;
}
// Find English subtitles or use the first available
const englishTrack =
captionTracks.find(
(track) =>
track.languageCode === "en" ||
(track.name &&
track.name.simpleText &&
track.name.simpleText.includes("English"))
) || captionTracks[0];
if (englishTrack && englishTrack.baseUrl) {
// We found a subtitle track URL, but direct fetch might be restricted
// For now, we'll extract what we can from the page
return null;
}
return null;
} catch (error) {
extensionLog("Error extracting subtitles from player response:", error);
return null;
}
}
// Extract subtitles from initial player response
function extractSubtitlesFromInitialResponse(initialResponse) {
try {
if (!initialResponse) return null;
// Navigate through the complex structure to find captions
const captionTracks =
initialResponse.captions?.playerCaptionsTracklistRenderer?.captionTracks;
if (!captionTracks || captionTracks.length === 0) {
return null;
}
// Find English track or use first available
const englishTrack =
captionTracks.find(
(track) =>
track.languageCode === "en" ||
(track.name?.simpleText && track.name.simpleText.includes("English"))
) || captionTracks[0];
if (!englishTrack || !englishTrack.baseUrl) {
return null;
}
// We have a URL but can't directly fetch it due to CORS
// Instead, extract what we can from the visible transcript on the page
return null;
} catch (error) {
extensionLog("Error extracting subtitles from initial response:", error);
return null;
}
}
// Get alternative text from video description or comments
function getAlternativeText() {
try {
// Try to get the video description
const description =
document.querySelector("#description-inline-expander, #description-text")
?.textContent || "";
// Try to get the transcript panel content if it's open
const transcriptPanel = document.querySelector(".ytd-transcript-renderer");
if (transcriptPanel) {
const transcriptItems = transcriptPanel.querySelectorAll(
".ytd-transcript-segment-renderer"
);
if (transcriptItems && transcriptItems.length > 0) {
let transcript = "";
transcriptItems.forEach((item) => {
transcript += item.textContent + " ";
});
return transcript.trim();
}
}
// If description is substantial, use it
if (description.length > 200) {
extensionLog(
"FALLBACK: Using video description as alternative to transcript",
{ descriptionLength: description.length }
);
return (
"[FALLBACK: Using video description as alternative to transcript]\n\n" +
description
);
}
return null;
} catch (error) {
extensionLog("Error getting alternative text:", error);
return null;
}
}
// Try to find auto-generated captions
function findAutoGeneratedCaptions() {
try {
// Check if the transcript button is available
const transcriptButton = document.querySelector(
'button[aria-label*="transcript"], button[aria-label*="Transcript"]'
);
if (transcriptButton) {
// We can't click it programmatically due to security restrictions
// But we can inform the user that transcripts are available
return "Auto-generated captions may be available. Please click the transcript button in the YouTube player to view them.";
}
// Look for any visible caption elements
const visibleCaptions = document.querySelector(".ytp-caption-segment");
if (visibleCaptions) {
return "Captions are enabled for this video. Please ensure captions are turned on in the YouTube player to see them.";
}
return null;
} catch (error) {
extensionLog("Error finding auto-generated captions:", error);
return null;
}
}
// Extract text from the visible transcript panel if it's open
function getVisibleTranscript() {
try {
// This targets the transcript panel that appears when you click "Show Transcript"
const transcriptItems = document.querySelectorAll(
"ytd-transcript-segment-renderer"
);
if (transcriptItems && transcriptItems.length > 0) {
let transcript = "";
transcriptItems.forEach((item) => {
// Each segment has text and timestamp
const text = item.querySelector("#text")?.textContent || "";
transcript += text + " ";
});
return transcript.trim();
}
return null;
} catch (error) {
extensionLog("Error getting visible transcript:", error);
return null;
}
}
// Parse subtitles XML into plain text
function parseSubtitlesXml(xmlText) {
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlText, "text/xml");
const textElements = xmlDoc.getElementsByTagName("text");
let subtitles = "";
for (let i = 0; i < textElements.length; i++) {
subtitles += textElements[i].textContent + " ";
}
return subtitles.trim();
}
// Try to get subtitles directly from the YouTube page
function getSubtitlesFromPage() {
extensionLog("Attempting to get subtitles from page");
// First try: Check for transcript panel if it's open
const visibleTranscript = getVisibleTranscript();
if (visibleTranscript) {
extensionLog("Found visible transcript panel");
return visibleTranscript;
}
// Second try: YouTube stores caption data in a script tag
try {
const scriptTags = document.querySelectorAll("script");
let captionData = null;
let captionTrackData = null;
// Look for caption tracks in script tags
for (const script of scriptTags) {
const content = script.textContent;
// Try different patterns to find caption data
if (content.includes('"captionTracks"')) {
const match = content.match(/"captionTracks":(\[.*?\])/);
if (match && match[1]) {
try {
captionData = JSON.parse(match[1]);
extensionLog("Found caption tracks in script tag");
break;
} catch (e) {
extensionLog("Error parsing caption data:", e);
}
}
}
// Try another pattern
if (content.includes('{"captionTracks":')) {
const regex = /{"captionTracks":(\[.*?\])}/g;
const match = regex.exec(content);
if (match && match[1]) {
try {
captionTrackData = JSON.parse(match[1]);
extensionLog("Found caption track data in script tag");
break;
} catch (e) {
extensionLog("Error parsing caption track data:", e);
}
}
}
}
// Process caption data if found
const tracks = captionData || captionTrackData;
if (tracks && tracks.length > 0) {
// Find English subtitles or use the first available
const englishTrack =
tracks.find(
(track) =>
track.languageCode === "en" ||
(track.name &&
track.name.simpleText &&
track.name.simpleText.includes("English"))
) || tracks[0];
if (englishTrack && englishTrack.baseUrl) {
extensionLog("Found subtitle track URL, attempting to fetch");
// Try to fetch the subtitles directly (may fail due to CORS)
try {
return fetch(englishTrack.baseUrl)
.then((response) => response.text())
.then((xmlText) => {
extensionLog("Successfully fetched subtitle XML");
return parseSubtitlesXml(xmlText);
})
.catch((error) => {
extensionLog("Error fetching subtitle XML:", error);
return null;
});
} catch (error) {
extensionLog("Error attempting to fetch subtitles:", error);
}
}
}
// Third try: Look for transcript in the page DOM
const transcriptContent = document.querySelector("#transcript-scrollbox");
if (transcriptContent) {
extensionLog("Found transcript scrollbox");
let transcript = "";
const segments = transcriptContent.querySelectorAll(".segment");
segments.forEach((segment) => {
transcript += segment.textContent.trim() + " ";
});
console.log("Content script loaded");
if (transcript.length > 100) {
return transcript;
}
}
return null;
} catch (error) {
extensionLog("Error getting subtitles from page:", error);
return null;
}
}
// Get video metadata (title, description, etc.)
function getVideoMetadata() {
const title =
document.querySelector('meta[property="og:title"]')?.content ||
document.querySelector("title")?.textContent ||
"";
const description =
document.querySelector('meta[property="og:description"]')?.content ||
document.querySelector('meta[name="description"]')?.content ||
"";
const author =
document.querySelector('link[itemprop="name"]')?.content ||
document.querySelector(".ytd-channel-name a")?.textContent ||
"";
return {
title,
description,
author,
};
}
// Update getYouTubeContent to clearly indicate when using description
async function getYouTubeContent() {
extensionLog("getYouTubeContent called");
const url = window.location.href;
extensionLog("Current URL in getYouTubeContent:", url);
const videoId = extractVideoId(url);
extensionLog("Extracted video ID:", videoId);
if (!videoId) {
extensionLog("No video ID found");
return null;
}
extensionLog("Getting metadata and subtitles");
const metadata = getVideoMetadata();
extensionLog("Metadata retrieved:", metadata.title);
const subtitles = await fetchYouTubeSubtitles(videoId);
extensionLog("Subtitles retrieved:", subtitles ? "yes" : "no");
if (!subtitles) {
extensionLog("No subtitles available");
// Get as much context as possible
const videoLength = getVideoLength();
const viewCount = getViewCount();
const uploadDate = getUploadDate();
return {
isYouTube: true,
hasSubtitles: false,
content:
`Title: ${metadata.title}\n\n` +
`Description: ${metadata.description}\n\n` +
`Author: ${metadata.author}\n\n` +
`Video Length: ${videoLength}\n` +
`Views: ${viewCount}\n` +
`Upload Date: ${uploadDate}\n\n` +
`⚠️ NO TRANSCRIPT AVAILABLE: This summary is based only on the video metadata and description.`,
};
}
extensionLog("Returning YouTube content with subtitles");
return {
isYouTube: true,
hasSubtitles: true,
content: `Title: ${metadata.title}\n\nDescription: ${metadata.description}\n\nAuthor: ${metadata.author}\n\nTranscript:\n${subtitles}`,
};
}
// Helper functions to get additional metadata
function getVideoLength() {
try {
return (
document.querySelector(".ytp-time-duration")?.textContent ||
document.querySelector('span[itemprop="duration"]')?.textContent ||
"Unknown"
);
} catch (e) {
return "Unknown";
}
}
function getViewCount() {
try {
return (
document.querySelector(".view-count")?.textContent ||
document.querySelector('span[itemprop="interactionCount"]')
?.textContent ||
"Unknown"
);
} catch (e) {
return "Unknown";
}
}
function getUploadDate() {
try {
return (
document.querySelector("#info-strings yt-formatted-string")
?.textContent ||
document.querySelector('span[itemprop="datePublished"]')?.textContent ||
"Unknown"
);
} catch (e) {
return "Unknown";
}
}
// Main content script functionality
function getPageContent() {
extensionLog("getPageContent called");
// Check if we're on a YouTube video page
const url = window.location.href;
extensionLog("Current URL in getPageContent:", url);
if (isYouTubeVideo(url)) {
extensionLog("YouTube video detected, fetching subtitles...");
return getYouTubeContent()
.then((youtubeContent) => {
if (youtubeContent) {
extensionLog(
"YouTube content retrieved:",
youtubeContent.hasSubtitles ? "with subtitles" : "without subtitles"
);
return youtubeContent.content;
} else {
// Fallback to regular page content if YouTube handler fails
extensionLog(
"YouTube handler failed, falling back to regular content"
);
return document.body.innerText;
}
})
.catch((error) => {
extensionLog("Error getting YouTube content:", error);
return document.body.innerText;
});
}
// Regular page content for non-YouTube pages
extensionLog("Not a YouTube video, returning regular page content");
return document.body.innerText;
}
// Set up message listener
browser.runtime.onMessage.addListener((request, sender, sendResponse) => {
extensionLog("Content script received message:", request);
if (request.action === "getContent") {
extensionLog("Getting page content...");
// Handle the case where getPageContent might return a Promise
const contentResult = getPageContent();
if (contentResult instanceof Promise) {
contentResult
.then((content) => {
extensionLog(
"Sending content (first 100 chars):",
content.substring(0, 100)
);
sendResponse({ content: content });
})
.catch((error) => {
extensionLog("Error getting page content:", error);
sendResponse({
content: "Error retrieving content: " + error.message,
});
});
return true; // Indicate that we will send a response asynchronously
} else {
// Handle synchronous result
const content = contentResult;
extensionLog(
"Sending content (first 100 chars):",
content.substring(0, 100)
);
sendResponse({ content: content });
return true; // Still need to return true for Firefox compatibility
}
}
return true; // Always return true to indicate we're handling the message
});
extensionLog("Content script fully loaded with YouTube handler functionality");

BIN
dist/spacellama.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 552 KiB

View File

@ -1,14 +1,9 @@
{
"manifest_version": 2,
"name": "SpaceLLama",
"version": "1.0",
"description": "Summarize web pages using OLLAMA",
"permissions": [
"activeTab",
"storage",
"<all_urls>",
"tabs"
],
"version": "1.7",
"description": "Summarize web pages using Ollama. Supports custom models, token limits, system prompts, chunking, and more. See https://github.com/tcsenpai/spacellama for more information.",
"permissions": ["activeTab", "storage", "<all_urls>", "tabs"],
"browser_action": {
"default_title": "SpaceLLama",
"default_icon": "icon.png"
@ -32,7 +27,5 @@
"page": "options/options.html",
"open_in_tab": true
},
"web_accessible_resources": [
"sidebar/marked.min.js"
]
}
"web_accessible_resources": ["sidebar/marked.min.js", "model_tokens.json"]
}

30
manifest_ch.json Normal file
View File

@ -0,0 +1,30 @@
{
"manifest_version": 3,
"name": "SpaceLLama",
"version": "1.21",
"description": "Summarize web pages using Ollama. Supports custom models, token limits, system prompts, chunking, and more. See https://github.com/tcsenpai/spacellama for more information.",
"permissions": ["activeTab", "storage", "tabs", "scripting"],
"action": {
"default_title": "SpaceLLama",
"default_icon": "icon.png"
},
"background": {
"service_worker": "background.js"
},
"content_scripts": [
{
"matches": ["http://*/*", "https://*/*"],
"js": ["content_scripts/youtube_handler.js", "content_scripts/content.js"]
}
],
"options_ui": {
"page": "options/options.html",
"open_in_tab": true
},
"web_accessible_resources": [
{
"resources": ["sidebar/sidebar.html", "sidebar/sidebar.js", "sidebar/sidebar.css", "sidebar/marked.min.js", "model_tokens.json"],
"matches": ["http://*/*", "https://*/*"]
}
]
}

26
model_tokens.json Normal file
View File

@ -0,0 +1,26 @@
{
"llama2": 4096,
"llama2:13b": 4096,
"llama2:70b": 4096,
"codellama": 16384,
"codellama:13b": 16384,
"codellama:34b": 16384,
"mistral": 8192,
"mixtral": 32768,
"phi": 2048,
"qwen": 8192,
"qwen:14b": 8192,
"qwen:72b": 8192,
"stablelm": 4096,
"stablelm-zephyr": 4096,
"neural-chat": 8192,
"openhermes": 8192,
"starling-lm": 8192,
"orca2": 4096,
"vicuna": 8192,
"wizardcoder": 16384,
"wizardcoder:python": 16384,
"wizardmath": 8192,
"llama3.1:8b": 128000,
"llama3.1:70b": 128000
}

View File

@ -1,11 +1,47 @@
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
margin: 0;
padding: 0;
background-image: url("https://static.pexels.com/photos/414171/pexels-photo-414171.jpeg");
background-size: cover;
-webkit-animation: slidein 100s;
animation: slidein 100s;
-webkit-animation-fill-mode: forwards;
animation-fill-mode: forwards;
-webkit-animation-iteration-count: infinite;
animation-iteration-count: infinite;
-webkit-animation-direction: alternate;
animation-direction: alternate;
background-color: #f5f5f5;
color: #333;
}
@-webkit-keyframes slidein {
from {
background-position: top;
background-size: 3000px;
}
to {
background-position: -100px 0px;
background-size: 2750px;
}
}
@keyframes slidein {
from {
background-position: top;
background-size: 3000px;
}
to {
background-position: -100px 0px;
background-size: 2750px;
}
}
.container {
max-width: 600px;
margin: 0 auto;
@ -88,3 +124,17 @@ input[type="text"]:focus {
background-color: #e74c3c;
color: white;
}
textarea {
width: 100%;
padding: 10px;
font-size: 16px;
border: 1px solid #bdc3c7;
border-radius: 5px;
transition: border-color 0.3s ease;
resize: vertical;
}
textarea:focus {
outline: none;
border-color: #3498db;
}

View File

@ -1,30 +1,64 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OLLAMA Summarizer Settings</title>
<link rel="stylesheet" type="text/css" href="options.css">
</head>
<body>
<link rel="stylesheet" type="text/css" href="options.css" />
</head>
<body>
<div class="container">
<h1>OLLAMA Summarizer Settings</h1>
<form id="settings-form">
<div class="form-group">
<label for="endpoint">OLLAMA Endpoint:</label>
<div class="input-group">
<input type="text" id="endpoint" placeholder="http://localhost:11434">
<input
type="text"
id="endpoint"
placeholder="http://localhost:11434"
/>
<span id="endpoint-status" class="status-indicator"></span>
</div>
</div>
<div class="form-group">
<label for="model">OLLAMA Model:</label>
<input type="text" id="model" placeholder="llama2">
<input type="text" id="model" placeholder="llama3.1:latest" />
</div>
<div class="form-group">
<label for="token-limit">Token Limit:</label>
<input
type="number"
id="token-limit"
min="1024"
placeholder="16384"
/>
</div>
<div class="option-group">
<label for="youtube-api-key">YouTube API Key (optional):</label>
<input
type="text"
id="youtube-api-key"
name="youtube-api-key"
placeholder="Enter your YouTube API key"
/>
<p class="help-text">
Used to fetch YouTube video transcripts.
<a
href="https://developers.google.com/youtube/v3/getting-started"
target="_blank"
>Get a key</a
>
</p>
</div>
<button type="submit" class="btn btn-primary">Save Settings</button>
<div class="form-group">
<label for="system-prompt">System Prompt:</label>
<textarea id="system-prompt" rows="3" class="form-control"></textarea>
</div>
</form>
<div id="status" class="status-message"></div>
</div>
<script src="options.js"></script>
</body>
</body>
</html>

View File

@ -1,60 +1,124 @@
let browser =
typeof chrome !== "undefined"
? chrome
: typeof browser !== "undefined"
? browser
: null;
async function validateEndpoint(endpoint) {
try {
const response = await fetch(`${endpoint}/api/tags`);
return response.ok;
} catch (error) {
console.error('Error validating endpoint:', error);
console.error("Error validating endpoint:", error);
return false;
}
}
function updateEndpointStatus(isValid) {
const statusElement = document.getElementById('endpoint-status');
statusElement.textContent = isValid ? '✅' : '❌';
statusElement.title = isValid ? 'Endpoint is valid' : 'Endpoint is invalid';
const statusElement = document.getElementById("endpoint-status");
statusElement.textContent = isValid ? "✅" : "❌";
statusElement.title = isValid ? "Endpoint is valid" : "Endpoint is invalid";
}
async function updateTokenLimit() {
try {
const modelTokens = await loadModelTokens();
const model = document.getElementById("model").value;
const tokenLimitInput = document.getElementById("token-limit");
if (model in modelTokens) {
tokenLimitInput.value = modelTokens[model];
} else {
tokenLimitInput.value = 16384; // Default value
}
} catch (error) {
console.error("Error updating token limit:", error.message || error);
}
}
async function loadModelTokens() {
try {
const response = await fetch(browser.runtime.getURL("model_tokens.json"));
return await response.json();
} catch (error) {
console.error("Error loading model tokens:", error.message || error);
}
}
async function saveOptions(e) {
e.preventDefault();
const endpoint = document.getElementById('endpoint').value;
const model = document.getElementById('model').value;
const status = document.getElementById('status');
const endpoint = document.getElementById("endpoint").value;
const model = document.getElementById("model").value;
const systemPrompt = document.getElementById("system-prompt").value;
const status = document.getElementById("status");
const tokenLimit = document.getElementById("token-limit").value || 16384;
const youtubeApiKey = document.getElementById("youtube-api-key").value;
// Ensure the endpoint doesn't end with /api/generate
const cleanEndpoint = endpoint.replace(/\/api\/generate\/?$/, '');
status.textContent = 'Validating endpoint...';
const cleanEndpoint = endpoint.replace(/\/api\/generate\/?$/, "");
status.textContent = "Validating endpoint...";
try {
const isValid = await validateEndpoint(cleanEndpoint);
updateEndpointStatus(isValid);
if (isValid) {
browser.storage.local.set({
await browser.storage.local.set({
ollamaEndpoint: cleanEndpoint,
ollamaModel: model
}).then(() => {
status.textContent = 'Options saved and endpoint validated.';
setTimeout(() => {
status.textContent = '';
}, 2000);
ollamaModel: model,
systemPrompt: systemPrompt,
tokenLimit: parseInt(tokenLimit),
youtubeApiKey: youtubeApiKey,
});
status.textContent = "Options saved and endpoint validated.";
setTimeout(() => {
status.textContent = "";
}, 2000);
} else {
status.textContent = 'Invalid endpoint. Please check the URL and try again.';
status.textContent =
"Invalid endpoint. Please check the URL and try again.";
}
} catch (error) {
console.error("Error saving options:", error.message || error);
status.textContent = "Error saving options.";
}
}
async function restoreOptions() {
const result = await browser.storage.local.get(['ollamaEndpoint', 'ollamaModel']);
const endpoint = result.ollamaEndpoint || 'http://localhost:11434';
document.getElementById('endpoint').value = endpoint;
document.getElementById('model').value = result.ollamaModel || 'llama2';
function restoreOptions() {
browser.storage.local.get(
{
ollamaEndpoint: "http://localhost:11434",
ollamaModel: "llama3.1:latest",
systemPrompt:
"You are a helpful AI assistant. Summarize the given text concisely, without leaving out informations. You should aim to give a summary that is highly factual, useful and rich but still shorter than the original content, while not being too short.",
tokenLimit: 16384,
youtubeApiKey: "",
},
function (result) {
document.getElementById("endpoint").value =
result.ollamaEndpoint || "http://localhost:11434";
document.getElementById("model").value =
result.ollamaModel || "llama3.1:latest";
document.getElementById("system-prompt").value =
result.systemPrompt ||
"You are a helpful AI assistant. Summarize the given text concisely.";
document.getElementById("youtube-api-key").value = result.youtubeApiKey;
const isValid = await validateEndpoint(endpoint);
// Call to updateTokenLimit remains async
updateTokenLimit().then(() => {
validateEndpoint(result.ollamaEndpoint).then((isValid) => {
updateEndpointStatus(isValid);
});
});
}
);
}
document.addEventListener('DOMContentLoaded', restoreOptions);
document.getElementById('settings-form').addEventListener('submit', saveOptions);
document.getElementById('endpoint').addEventListener('blur', async (e) => {
document.addEventListener("DOMContentLoaded", restoreOptions);
document
.getElementById("settings-form")
.addEventListener("submit", saveOptions);
document.getElementById("endpoint").addEventListener("blur", async (e) => {
const isValid = await validateEndpoint(e.target.value);
updateEndpointStatus(isValid);
document.getElementById("model").addEventListener("change", updateTokenLimit);
});

View File

@ -1,11 +1,48 @@
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
margin: 0;
padding: 0;
background-image: url("https://static.pexels.com/photos/414171/pexels-photo-414171.jpeg");
background-size: cover;
-webkit-animation: slidein 100s;
animation: slidein 100s;
-webkit-animation-fill-mode: forwards;
animation-fill-mode: forwards;
-webkit-animation-iteration-count: infinite;
animation-iteration-count: infinite;
-webkit-animation-direction: alternate;
animation-direction: alternate;
background-color: #f5f5f5;
color: #333;
}
@-webkit-keyframes slidein {
from {
background-position: top;
background-size: 3000px;
}
to {
background-position: -100px 0px;
background-size: 2750px;
}
}
@keyframes slidein {
from {
background-position: top;
background-size: 3000px;
}
to {
background-position: -100px 0px;
background-size: 2750px;
}
}
.container {
max-width: 600px;
margin: 0 auto;
@ -21,60 +58,105 @@ h1 {
.btn {
display: inline-block;
padding: 10px 20px;
font-size: 16px;
font-size: 14px;
border: none;
border-radius: 5px;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.3s ease;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
font-weight: 500;
position: relative;
overflow: hidden;
text-transform: uppercase;
letter-spacing: 1px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
}
.btn::before {
content: "";
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.2),
transparent
);
transition: 0.5s;
}
.btn:hover::before {
left: 100%;
}
.btn-primary {
background-color: #3498db;
color: white;
background: linear-gradient(135deg, #0b132b 0%, #3a506b 100%);
color: #5bc0be;
border: 1px solid #5bc0be;
}
.btn-primary:hover {
background-color: #2980b9;
background: linear-gradient(135deg, #0b132b 0%, #3a506b 100%);
box-shadow: 0 5px 15px rgba(91, 192, 190, 0.4),
0 0 5px rgba(91, 192, 190, 0.4);
color: #6fffe9;
border-color: #6fffe9;
}
.btn-secondary {
background-color: #95a5a6;
color: white;
background: rgba(30, 30, 30, 0.9);
color: #c5c6c7;
border: 1px solid #45a29e;
}
.btn-secondary:hover {
background-color: #7f8c8d;
background: rgba(30, 30, 30, 0.9);
box-shadow: 0 5px 15px rgba(69, 162, 158, 0.3),
0 0 5px rgba(69, 162, 158, 0.3);
color: #ffffff;
border-color: #66fcf1;
}
.summary-container {
margin-top: 20px;
padding: 15px;
margin-top: 10px;
padding: 10px;
background-color: white;
border-radius: 5px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
#summary h1, #summary h2, #summary h3 {
margin-top: 15px;
margin-bottom: 10px;
#summary h1,
#summary h2,
#summary h3 {
margin-top: 0px;
margin-bottom: 0px;
color: #2c3e50;
}
#summary p {
margin-top: 10px;
margin-bottom: 10px;
line-height: 1.6;
}
#summary ul, #summary ol {
#summary ul,
#summary ol {
padding-left: 20px;
margin-bottom: 10px;
margin-top: 10px;
}
#summary li {
margin-bottom: 10px;
}
#summary code {
background-color: #f0f0f0;
padding: 2px 4px;
border-radius: 3px;
font-family: 'Courier New', Courier, monospace;
font-family: "Courier New", Courier, monospace;
}
#summary pre {
@ -82,9 +164,45 @@ h1 {
padding: 10px;
border-radius: 3px;
overflow-x: auto;
font-family: 'Courier New', Courier, monospace;
font-family: "Courier New", Courier, monospace;
}
#open-options {
margin-top: 20px;
}
.form-group {
margin-top: 10px;
}
label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
textarea {
width: 100%;
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
resize: vertical;
}
#save-prompt {
margin-top: 10px;
margin-bottom: 10px;
}
#summary,
#system-prompt {
user-select: text;
-webkit-user-select: text;
-moz-user-select: text;
-ms-user-select: text;
}
#summary {
/* white-space: pre-wrap; */
word-wrap: break-word;
}

View File

@ -1,18 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" type="text/css" href="sidebar.css">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" type="text/css" href="sidebar.css" />
<script src="marked.min.js"></script>
</head>
<body>
</head>
<body>
<div class="container">
<h1>OLLAMA Summarizer</h1>
<button id="summarize" class="btn btn-primary">Summarize</button>
<div id="summary" class="summary-container"></div>
<div class="form-group">
<label for="system-prompt">System Prompt:</label>
<textarea id="system-prompt" rows="3" class="form-control"></textarea>
</div>
<button id="save-prompt" class="btn btn-secondary">Save Prompt</button>
<button id="open-options" class="btn btn-secondary">Open Settings</button>
<div class="button-container">
<button id="view-logs" class="button">View Debug Logs</button>
</div>
</div>
<script src="sidebar.js"></script>
</body>
</body>
</html>

View File

@ -1,33 +1,156 @@
document.addEventListener('DOMContentLoaded', () => {
const summarizeButton = document.getElementById('summarize');
const summaryDiv = document.getElementById('summary');
const openOptionsButton = document.getElementById('open-options');
let isFirefox = typeof InstallTrigger !== "undefined"; // Firefox has `InstallTrigger`
let browser = isFirefox ? window.browser : chrome;
summarizeButton.addEventListener('click', () => {
summaryDiv.innerHTML = '<p>Summarizing...</p>';
document.addEventListener("DOMContentLoaded", () => {
const summarizeButton = document.getElementById("summarize");
const summaryDiv = document.getElementById("summary");
const openOptionsButton = document.getElementById("open-options");
const tokenCountDiv = document.createElement("div");
tokenCountDiv.id = "token-count";
tokenCountDiv.style.marginTop = "10px";
tokenCountDiv.style.fontStyle = "italic";
summarizeButton.parentNode.insertBefore(
tokenCountDiv,
summarizeButton.nextSibling
);
// Correctly define systemPromptTextarea
const systemPromptTextarea = document.getElementById("system-prompt");
summarizeButton.addEventListener("click", () => {
summaryDiv.innerHTML = "<p>Summarizing...</p>";
console.log("Summarizing...");
tokenCountDiv.textContent = "";
summarizeButton.disabled = true;
// Get the current tab content
browser.tabs.query({ active: true, currentWindow: true }, (tabs) => {
browser.tabs.sendMessage(tabs[0].id, { action: "getContent" }, (response) => {
// First check if the current URL is a YouTube video
const currentUrl = tabs[0].url;
console.log("Current URL:", currentUrl);
const isYouTubeUrl =
currentUrl.includes("youtube.com/watch") ||
currentUrl.includes("youtu.be/") ||
currentUrl.includes("/watch?v=");
console.log("Is YouTube URL:", isYouTubeUrl);
if (isYouTubeUrl) {
// Show a notification that we're processing a YouTube video
summaryDiv.innerHTML = `
<div style="background-color: #1a73e8; color: white; padding: 10px; margin-bottom: 10px; border-radius: 4px;">
<strong>YouTube Video Detected!</strong><br>
Fetching and processing video transcript...
</div>
<p>Summarizing video content...</p>
`;
}
browser.tabs.sendMessage(
tabs[0].id,
{ action: "getContent" },
(response) => {
console.log("Response:", response);
if (browser.runtime.lastError) {
handleError('Error getting page content: ' + browser.runtime.lastError.message);
handleError(
"Error getting page content: " + browser.runtime.lastError.message
);
return;
}
if (response && response.content) {
const systemPrompt = systemPromptTextarea.value;
var failedToFetchSubtitles = false;
// Check if the content appears to be from YouTube
var isYouTubeContent =
response.content.includes("Title:") &&
response.content.includes("Transcript:") &&
(tabs[0].url.includes("youtube.com") ||
tabs[0].url.includes("youtu.be") ||
tabs[0].url.includes("/watch?v="));
console.log("Is YouTube Content:", isYouTubeContent);
if (response.content.includes("NO TRANSCRIPT AVAILABLE")) {
console.log(
"Warning: No subtitles available for this video: setting isYouTubeContent to false"
);
failedToFetchSubtitles = true;
isYouTubeContent = false;
}
// Customize the prompt for YouTube videos
const customizedPrompt = isYouTubeContent
? `${systemPrompt}\n\nThis is a YouTube video transcript. Please summarize the key points discussed in the video.`
: systemPrompt;
console.log("System prompt:", customizedPrompt);
// Send message to background script for summarization
browser.runtime.sendMessage(
{ action: "summarize", content: response.content },
{
action: "summarize",
content: response.content,
systemPrompt: customizedPrompt,
},
(response) => {
if (browser.runtime.lastError) {
handleError('Error during summarization: ' + browser.runtime.lastError.message);
handleError(
"Error during summarization: " +
browser.runtime.lastError.message
);
return;
}
if (response && response.summary) {
// Render the Markdown content
summaryDiv.innerHTML = marked.parse(response.summary);
let warningHtml = "";
if (response.chunkCount > 1) {
warningHtml = `
<div class="warning" style="background-color: #fff3cd; color: #856404; padding: 10px; margin-bottom: 10px; border-radius: 4px;">
<strong>Warning:</strong> The content was split into ${response.chunkCount} chunks for summarization.
Recursive summarization depth: ${response.recursionDepth}.
This may affect the quality and coherence of the summary, and might result in slower performance.
</div>
`;
}
if (failedToFetchSubtitles) {
warningHtml =
`
<div class="warning" style="background-color: #fff3cd; color: #856404; padding: 10px; margin-bottom: 10px; border-radius: 4px;">
<strong>Warning:</strong> Failed to fetch subtitles for this video: using video description and metadata instead.
</div>
` + warningHtml;
}
// Add YouTube notification if it's a YouTube video
if (isYouTubeContent) {
warningHtml =
`
<div style="background-color: #1a73e8; color: white; padding: 10px; margin-bottom: 10px; border-radius: 4px;">
<strong>YouTube Video Summary</strong><br>
This summary was generated from the video transcript.
</div>
` + warningHtml;
}
let summaryText;
if (typeof response.summary === "string") {
summaryText = response.summary;
} else if (typeof response.summary === "object") {
// Convert JSON to Markdown
summaryText = Object.entries(response.summary)
.map(([key, value]) => `## ${key}\n\n${value}`)
.join("\n\n");
} else {
summaryText = JSON.stringify(response.summary);
}
// Render the Markdown content with warning if applicable
summaryDiv.innerHTML =
warningHtml + marked.parse(summaryText);
// NOTE Token count is disabled if not needed
//tokenCountDiv.textContent = `Token count: ${response.tokenCount}`;
} else if (response && response.error) {
handleError(response.error, response.details);
if (response.tokenCount) {
tokenCountDiv.textContent = `Token count: ${response.tokenCount}`;
}
} else {
handleError("Unexpected response from summarization");
}
@ -35,13 +158,14 @@ document.addEventListener('DOMContentLoaded', () => {
}
);
} else {
handleError('Error: Could not retrieve page content.');
handleError("Error: Could not retrieve page content.");
}
});
}
);
});
});
openOptionsButton.addEventListener('click', () => {
openOptionsButton.addEventListener("click", () => {
browser.runtime.openOptionsPage();
});
@ -53,4 +177,106 @@ document.addEventListener('DOMContentLoaded', () => {
}
summarizeButton.disabled = false;
}
const viewLogsButton = document.getElementById("view-logs");
// Only add the event listener if the button exists
if (viewLogsButton) {
// Add the same CSS class as other buttons
viewLogsButton.className = "button";
let logsVisible = false;
viewLogsButton.addEventListener("click", () => {
const logsDiv = document.getElementById("logs-container");
// Toggle logs visibility
if (logsVisible && logsDiv) {
// Hide logs if they're currently visible
logsDiv.remove();
logsVisible = false;
viewLogsButton.textContent = "View Debug Logs";
return;
}
// Show logs
browser.runtime.sendMessage({ action: "getLogs" }, (response) => {
if (response && response.logs) {
// Remove existing logs container if it exists
if (logsDiv) {
logsDiv.remove();
}
// Create new logs container
const newLogsDiv = document.createElement("div");
newLogsDiv.id = "logs-container";
newLogsDiv.style.marginTop = "20px";
newLogsDiv.style.borderTop = "1px solid #ccc";
newLogsDiv.innerHTML = "<h3>Extension Logs</h3>";
const logList = document.createElement("pre");
logList.style.maxHeight = "400px";
logList.style.overflow = "auto";
logList.style.whiteSpace = "pre-wrap";
logList.style.fontSize = "12px";
logList.style.backgroundColor = "#f5f5f5";
logList.style.padding = "10px";
logList.style.borderRadius = "4px";
// Add logs in reverse chronological order (newest first)
response.logs
.slice()
.reverse()
.forEach((log) => {
const logEntry = document.createElement("div");
logEntry.style.marginBottom = "5px";
logEntry.style.borderBottom = "1px dotted #ddd";
logEntry.style.paddingBottom = "5px";
// Highlight important logs
if (
log.message.includes("No subtitles") ||
log.message.includes("Using alternative text") ||
log.message.includes("Error")
) {
logEntry.style.color = log.message.includes("Error")
? "#d32f2f"
: "#ff9800";
logEntry.style.fontWeight = "bold";
}
logEntry.textContent = `[${log.timestamp}] ${log.message}`;
if (log.data) {
logEntry.textContent += ` ${JSON.stringify(log.data)}`;
}
logList.appendChild(logEntry);
});
newLogsDiv.appendChild(logList);
document.getElementById("summary").appendChild(newLogsDiv);
logsVisible = true;
viewLogsButton.textContent = "Hide Debug Logs";
}
});
});
} else {
console.warn("View logs button not found in the sidebar");
}
});
const systemPromptTextarea = document.getElementById("system-prompt");
const savePromptButton = document.getElementById("save-prompt");
// Load saved system prompt
browser.storage.local.get("systemPrompt").then((result) => {
const defaultSystemPrompt =
"You are a helpful AI assistant. Summarize the given text concisely.";
systemPromptTextarea.value = result.systemPrompt || defaultSystemPrompt;
});
savePromptButton.addEventListener("click", () => {
const systemPrompt = systemPromptTextarea.value;
browser.storage.local.set({ systemPrompt }).then(() => {
alert("System prompt saved successfully!");
});
});