diff --git a/chrome-extension/manifest.json b/chrome-extension/manifest.json index b44b25e..34bc51c 100644 --- a/chrome-extension/manifest.json +++ b/chrome-extension/manifest.json @@ -2,12 +2,13 @@ "manifest_version": 3, "name": "__MSG_extensionName__", "default_locale": "en", - "version": "1.5.19", + "version": "1.5.21", "description": "__MSG_extensionDescription__", "permissions": [ "tabs", "activeTab", - "storage" + "storage", + "https://api.openai.com/*" ], "background": { "service_worker": "background.js" diff --git a/chrome-extension/popup.html b/chrome-extension/popup.html index c6a8070..7252a5c 100644 --- a/chrome-extension/popup.html +++ b/chrome-extension/popup.html @@ -21,6 +21,7 @@

AI Summary Helper

+
@@ -66,7 +67,7 @@

AI Summary Helper

@@ -103,7 +104,6 @@

AI Summary Helper

-
@@ -114,7 +114,15 @@

AI Summary Helper

diff --git a/chrome-extension/popup.js b/chrome-extension/popup.js index e0bc733..d662a5f 100644 --- a/chrome-extension/popup.js +++ b/chrome-extension/popup.js @@ -19,6 +19,11 @@ function init() { chrome.storage.sync.get(null, (data) => { console.log('All settings:', data); }); + + // show get podcasts from local storage + chrome.storage.local.get({ podcasts: [] }, (data) => { + console.log('Podcasts:', data.podcasts); + }); } fetch(chrome.runtime.getURL('manifest.json')) @@ -48,6 +53,7 @@ document.addEventListener('DOMContentLoaded', () => { const localEndpointInput = document.getElementById('localEndpoint'); // Input for local endpoint const modelIdentifierInput = document.getElementById('modelIdentifier'); + const podcastButton = document.getElementById('podcastButton'); const historyButton = document.getElementById('historyButton'); const backButton = document.getElementById('backButton'); const historyScreen = document.getElementById('historyScreen'); @@ -62,6 +68,11 @@ document.addEventListener('DOMContentLoaded', () => { const titleElement = document.querySelector('.logoheader h2'); + loadPodcasts(); + + // Call loadPodcasts on page load to display existing podcasts + + // Define a configuration object for screens const screenConfig = { mainScreen: { @@ -75,7 +86,7 @@ document.addEventListener('DOMContentLoaded', () => { titleText: 'Settings' }, historyScreen: { - show: ['historyScreen', 'backButton'], + show: ['historyScreen', 'backButton', 'podcastButton'], toggleButtonText: '', titleText: 'History (Beta)' } @@ -98,7 +109,7 @@ document.addEventListener('DOMContentLoaded', () => { function hideAllScreens() { screenList.forEach(screen => screen.style.display = 'none'); - ['historyButton', 'backButton', 'toggleScreenButton', 'appsButton'].forEach(id => { + ['historyButton', 'backButton', 'toggleScreenButton', 'appsButton', 'podcastButton'].forEach(id => { document.getElementById(id).style.display = 'none'; }); } @@ -304,19 +315,34 @@ document.addEventListener('DOMContentLoaded', () => { if (shareButton) { shareButton.addEventListener('click', () => { if (navigator.share) { - const summaryText = article.summary.replace(/<[^>]*>/g, '').trim(); - const shareData = { - title: `πŸ“„ ${article.title || 'No title available'}`, - url: article.url, - text: `πŸͺ„${summaryText || 'No summary available'}` - }; - navigator.share(shareData) - .then(() => { - console.log('Article shared successfully'); - }) - .catch((error) => { - console.error('Error sharing article:', error); + const podcast = podcasts[0]; // Assuming you want to share the first podcast + const podcastUrl = podcast.audio; // Base64-encoded data URL + + if (podcastUrl && podcastUrl.startsWith('data:')) { + // Convert base64 to Blob + const byteString = atob(podcastUrl.split(',')[1]); + const mimeString = podcastUrl.split(',')[0].split(':')[1].split(';')[0]; + const ab = new ArrayBuffer(byteString.length); + const ia = new Uint8Array(ab); + for (let i = 0; i < byteString.length; i++) { + ia[i] = byteString.charCodeAt(i); + } + const blob = new Blob([ab], { type: mimeString }); + const blobUrl = URL.createObjectURL(blob); + + navigator.share({ + title: podcast.title, + url: blobUrl + }).then(() => { + console.log('Podcast shared successfully.'); + URL.revokeObjectURL(blobUrl); // Clean up the Blob URL + }).catch((error) => { + console.error('Error sharing podcast:', error); }); + } else { + console.error('Invalid data URL for sharing:', podcastUrl); + alert('Cannot share podcast: Invalid data URL.'); + } } else { console.error('Web Share API not supported in this browser'); } @@ -347,10 +373,298 @@ document.addEventListener('DOMContentLoaded', () => { } }); }); + + // ======= Toggle Play Podcast Button Visibility ======= + togglePlayPodcastButton(sortedArticles); } }); } + /** + * Toggles the visibility of the Play Podcast button based on selected model and API key. + * @param {Array} sortedArticles - The list of sorted articles. + * + * + */ + function togglePlayPodcastButton(sortedArticles) { + chrome.storage.sync.get(['model', 'apiKey', 'selectedLanguage'], (data) => { + const playButton = document.getElementById('playPodcastButton'); + const audioPlayer = document.getElementById('podcastAudioPlayer'); + + console.log('πŸ” Selected Model:', data.model); + console.log('πŸ”‘ API Key Present:', data.apiKey ? 'Yes' : 'No'); + + if (data.model === 'openai' && data.apiKey) { + console.log('βœ… Conditions met: Displaying Play Podcast Button.'); + playButton.style.display = 'block'; + audioPlayer.style.display = 'none'; + audioPlayer.src = ''; + + // Attach event listener if not already attached + if (!playButton.dataset.listenerAttached) { + playButton.addEventListener('click', () => handlePlayPodcast(sortedArticles, data.apiKey, audioPlayer, playButton, data.selectedLanguage)); + playButton.dataset.listenerAttached = 'true'; // Prevent multiple attachments + console.log('πŸ”— Play Podcast button event listener attached.'); + } + } else { + if (data.model !== 'openai') { + console.warn('⚠️ Selected model is not OpenAI. Play Podcast Button will be hidden.'); + } + if (!data.apiKey) { + console.warn('⚠️ API Key is not set. Play Podcast Button will be hidden.'); + } + playButton.style.display = 'none'; + } + }); + } + + /** + * Handles the Play Podcast button click event. + * @param {Array} sortedArticles - The list of sorted articles. + * @param {string} apiKey - The OpenAI API key. + * @param {HTMLElement} audioPlayer - The audio player element. + * @param {HTMLElement} playButton - The Play Podcast button element. + * @param {string} selectedLanguage - The selected language. + */ + async function handlePlayPodcast(sortedArticles, apiKey, audioPlayer, playButton, selectedLanguage) { + selectedLanguage = document.getElementById('languageSelect').value; + console.log('️ Play Podcast button clicked.'); + playButton.disabled = true; + playButton.textContent = 'πŸŽ™οΈ Generating Podcast...'; + + // 40000 tokens max, 1 token = 4 characters + const MAX_INPUT_LENGTH = 10000; // Maximum allowed characters for OpenAI API + + try { + // Step 1: Combine Articles + const { combinedInput, includedCount } = createCombinedInput(sortedArticles, MAX_INPUT_LENGTH); + console.log(`βœ… Included ${includedCount} article(s) in the podcast.`); + console.log('πŸ“ Combined Input:', combinedInput); + + if (!combinedInput.trim()) { + alert('⚠️ No summaries available to generate a podcast.'); + console.warn('❌ Combined input is empty.'); + resetPlayButton(playButton); + return; + } + + // Step 2: Create Chat Completion for Engaging Script + console.log('πŸ”„ Creating chat completion for the podcast script.'); + const podcastScript = await createChatCompletion(combinedInput, apiKey, selectedLanguage); + console.log('πŸ“ Generated Podcast Script:', podcastScript); + + if (!podcastScript.trim()) { + alert('⚠️ Failed to generate a podcast script.'); + console.warn('❌ Podcast script is empty.'); + resetPlayButton(playButton); + return; + } + + // Step 3: Generate Audio from Podcast Script + console.log('πŸ”„ Generating audio from the podcast script.'); + const audioBlob = await generateAudioFromText(podcastScript, apiKey); + + if (audioBlob) { + console.log('βœ… Audio blob successfully generated.'); + const audioURL = URL.createObjectURL(audioBlob); + audioPlayer.src = audioURL; + audioPlayer.style.display = 'block'; + audioPlayer.play() + .then(() => console.log('▢️ Audio is playing.')) + .catch(err => console.error('❌ Error playing audio:', err)); + + // Save the podcast + const podcastTitle = `Podcast - ${new Date().toLocaleString()}`; + console.log('πŸ“₯ Saving podcast:', podcastTitle); + savePodcast(podcastTitle, audioBlob); + } else { + alert('⚠️ Failed to generate audio.'); + console.error('❌ Audio blob is null.'); + } + } catch (error) { + console.error('πŸ›‘ Error in handlePlayPodcast:', error); + alert(`❌ An error occurred while generating the podcast: ${error.message}`); + } finally { + resetPlayButton(playButton); + } + } + + /** + * Resets the Play Podcast button to its default state. + * @param {HTMLElement} playButton - The Play Podcast button element. + */ + function resetPlayButton(playButton) { + playButton.disabled = false; + playButton.textContent = 'πŸŽ™οΈ Play Podcast'; + console.log('πŸ”„ Play Podcast button re-enabled.'); + } + + /** + * Creates a combined input string from article summaries, titles, and URLs without exceeding the max length. + * Counts only the characters in the summaries to determine inclusion. + * @param {Array} articles - The list of sorted articles. + * @param {number} maxLength - The maximum allowed character length for summaries. + * @returns {Object} - An object containing the combined input and the count of included articles. + */ + function createCombinedInput(articles, maxLength) { + let combinedInput = ''; + let includedCount = 0; + let currentSummaryLength = 0; + + for (let article of articles) { + // Strip HTML from the summary + const plainSummary = stripHtml(article.summary); + + // Calculate the length of the summary + const summaryLength = plainSummary.length; + + // Check if adding this summary would exceed the maxLength + if ((currentSummaryLength + summaryLength) > maxLength * 0.9) { + console.log('πŸ“ Maximum summary length reached. Stopping further additions.'); + break; // Stop adding more articles + } + + // Construct a plain text block for each article + const articleText = `Title: ${article.title}\nSummary: ${plainSummary}\nURL: ${article.url}\n\n`; + + combinedInput += articleText; + includedCount += 1; + currentSummaryLength += summaryLength; + } + + return { combinedInput, includedCount }; + } + + /** + * Creates a chat completion using OpenAI's Chat API to generate an engaging podcast script. + * @param {string} inputText - The combined input text from articles. + * @param {string} apiKey - The OpenAI API key. + * @returns {Promise} - The generated podcast script. + */ + async function createChatCompletion(inputText, apiKey, selectedLanguage) { + const apiUrl = 'https://api.openai.com/v1/chat/completions'; + const model = 'gpt-4o'; // Ensure this is the correct model as per OpenAI documentation + + // Define the conversation messages + const messages = [ + { + role: 'system', + content: `In language code"${selectedLanguage}" language, you are a creative and engaging podcast host. Transform the provided article summaries into an exciting 1 minute podcast script that captivates the audience. use language ${selectedLanguage}. Include the source of the article in the script.` + }, + { + role: 'user', + content: inputText + } + ]; + + // Define the request payload + const requestBody = { + model: model, + messages: messages, + max_tokens: 2500, // Adjust based on desired script length + temperature: 0.7, // Adjust for creativity + top_p: 1, + n: 1, + stream: false, + stop: null + }; + + console.log('πŸ“€ Sending chat completion request to OpenAI:', requestBody); + + try { + const response = await fetch(apiUrl, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(requestBody) + }); + + console.log('πŸ“₯ Received response from Chat Completion API:', response); + + if (!response.ok) { + let errorMessage = `Status: ${response.status}`; + try { + const errorData = await response.json(); + if (errorData.error) { + errorMessage += ` - ${errorData.error.message}`; + console.error('πŸ›‘ OpenAI Chat Completion API Error:', errorData.error); + } else { + console.error('πŸ›‘ Unexpected error structure:', errorData); + } + } catch (parseError) { + const errorText = await response.text(); + console.error('πŸ›‘ Error parsing OpenAI Chat Completion API error response:', parseError); + console.error('πŸ›‘ Raw Error Response:', errorText); + } + throw new Error(errorMessage); + } + + const data = await response.json(); + const podcastScript = data.choices[0].message.content.trim(); + return podcastScript; + } catch (error) { + console.error('πŸ›‘ Error in createChatCompletion:', error); + throw error; // Propagate the error to be handled by the caller + } + } + + /** + * Generates audio from the provided text using OpenAI's speech API. + * @param {string} text - The text to convert to speech. + * @param {string} apiKey - The OpenAI API key. + * @returns {Promise} - The audio blob or null if failed. + */ + async function generateAudioFromText(text, apiKey) { + const apiUrl = 'https://api.openai.com/v1/audio/speech'; // Verify the correct endpoint + const requestBody = { + model: "tts-1", // Ensure this is the correct model as per OpenAI documentation + input: text, + voice: "alloy" // Ensure 'alloy' is a valid voice option + }; + + console.log('πŸ“€ Sending audio generation request to OpenAI:', requestBody); + + try { + const response = await fetch(apiUrl, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(requestBody) + }); + + console.log('πŸ“₯ Received response from Audio API:', response); + + if (!response.ok) { + let errorMessage = `Status: ${response.status}`; + try { + const errorData = await response.json(); + if (errorData.error) { + errorMessage += ` - ${errorData.error.message}`; + console.error('πŸ›‘ OpenAI Audio API Error:', errorData.error); + } else { + console.error('πŸ›‘ Unexpected error structure:', errorData); + } + } catch (parseError) { + const errorText = await response.text(); + console.error('πŸ›‘ Error parsing OpenAI Audio API error response:', parseError); + console.error('πŸ›‘ Raw Error Response:', errorText); + } + throw new Error(errorMessage); + } + + const audioBlob = await response.blob(); + console.log('🎧 Audio blob received:', audioBlob); + return audioBlob; + } catch (error) { + console.error('πŸ›‘ Error in generateAudioFromText:', error); + throw error; // Propagate the error to be handled by the caller + } + } + // Function to toggle visibility of API key and endpoint inputs function toggleInputVisibility() { if (modelInput.value === 'ollama') { @@ -387,6 +701,7 @@ document.addEventListener('DOMContentLoaded', () => { togglePromptInputVisibility(); // Ensure visibility is set correctly on load }); + // Load stored settings from Chrome storage chrome.storage.sync.get(['apiKey', 'prompt', 'model', 'localEndpoint', 'modelIdentifier', 'selectedLanguage', 'promptType', 'presetPrompt'], (data) => { console.log('Loaded settings from local storage:', data); @@ -447,7 +762,7 @@ document.addEventListener('DOMContentLoaded', () => { // Change button text temporarily to indicate success const originalText = event.submitter.textContent; const originalBackground = event.submitter.style.background; - event.submitter.textContent = 'Saved! πŸŽ‰'; + event.submitter.textContent = 'Saved! '; event.submitter.style.background = 'linear-gradient(135deg, #4CAF50, #388E3C)'; // Darker green gradient setTimeout(() => { event.submitter.textContent = originalText; @@ -745,7 +1060,7 @@ document.addEventListener('DOMContentLoaded', () => { input.addEventListener('input', triggerSave); }); - // Add debounced event listener for textarea elements + // Add debounced event listener for textarea and input elements const textareas = settingsForm.querySelectorAll('textarea, input'); const debouncedSave = debounce(triggerSave, 1000); textareas.forEach(textarea => { @@ -794,5 +1109,130 @@ document.addEventListener('DOMContentLoaded', () => { // Call the first-time setup function firstTimeSetup(); + /** + * Estimates the number of tokens in a given string. + * Note: This is a simple approximation. For exact counts, use OpenAI's Tokenizer. + * @param {string} text - The text to tokenize. + * @returns {number} - Estimated number of tokens. + */ + function estimateTokenCount(text) { + return Math.ceil(text.length / 4); + } + + /** + * Creates a combined input string from article summaries, titles, and URLs without exceeding the max token limit. + * @param {Array} articles - The list of sorted articles. + * @param {number} maxTokens - The maximum allowed token count. + * @returns {Object} - An object containing the combined input and the count of included articles. + */ + function createCombinedInputByTokens(articles, maxTokens) { + let combinedInput = ''; + let includedCount = 0; + let currentTokenCount = 0; + + for (let article of articles) { + // Strip HTML from the summary + const plainSummary = stripHtml(article.summary); + + // Construct a plain text block for each article + const articleText = `Title: ${article.title}\nSummary: ${plainSummary}\nURL: ${article.url}\n\n`; + const articleTokenCount = estimateTokenCount(articleText); + + // Check if adding this article would exceed the maxTokens + if ((currentTokenCount + articleTokenCount) > maxTokens) { + console.log('πŸ“ Maximum token limit reached. Stopping further additions.'); + break; // Stop adding more articles + } + + combinedInput += articleText; + includedCount += 1; + currentTokenCount += articleTokenCount; + } + + return { combinedInput, includedCount }; + } + + /** + * Strips HTML tags from a given string. + * @param {string} html - The HTML string to be stripped. + * @returns {string} - The plain text string without HTML tags. + */ + function stripHtml(html) { + const tmp = document.createElement('DIV'); + tmp.innerHTML = html; + return tmp.textContent || tmp.innerText || ''; + } + + // Function to save a podcast to local storage + function savePodcast(title, audioBlob) { + const reader = new FileReader(); + reader.onloadend = function () { + const base64data = reader.result; + chrome.storage.local.get({ podcasts: [] }, (data) => { + const podcasts = data.podcasts; + podcasts.push({ title, audio: base64data, timestamp: Date.now() }); + chrome.storage.local.set({ podcasts }, () => { + if (chrome.runtime.lastError) { + console.error('Error saving podcast:', chrome.runtime.lastError); + } else { + console.log('Podcast saved successfully.'); + loadPodcasts(); // Refresh the list + } + }); + }); + }; + reader.readAsDataURL(audioBlob); + } + + + // Function to load podcasts from local storage + function loadPodcasts() { + const podcastList = document.getElementById('podcastList'); + podcastList.innerHTML = ''; // Clear existing podcasts + + // Add explanatory card + const explanatoryCard = document.createElement('div'); + explanatoryCard.classList.add('explanatory-card'); + explanatoryCard.innerHTML = 'πŸ’  Transform your articles into podcasts and share them with your friends'; + podcastList.appendChild(explanatoryCard); + + // Add play podcast button + const playPodcastButton = document.createElement('button'); + playPodcastButton.id = 'playPodcastButton'; + playPodcastButton.classList.add('button-primary'); + playPodcastButton.textContent = 'πŸŽ™οΈ Create'; + playPodcastButton.style.display = 'none'; + explanatoryCard.appendChild(playPodcastButton); + + chrome.storage.local.get({ podcasts: [] }, (data) => { + data.podcasts.forEach((podcast, index) => { + const listItem = document.createElement('div'); + listItem.classList.add('podcast-card'); + listItem.innerHTML = ` +
+

${podcast.title}

+ + +
+ `; + podcastList.appendChild(listItem); + + // Add event listeners + const deleteButton = listItem.querySelector('.delete-podcast-button'); + const shareButton = listItem.querySelector('.share-podcast-button'); + + deleteButton.addEventListener('click', () => { + if (confirm('Are you sure you want to delete this podcast?')) { + data.podcasts.splice(index, 1); + chrome.storage.local.set({ podcasts: data.podcasts }, () => { + console.log('Podcast deleted successfully.'); + loadPodcasts(); // Refresh the list + }); + } + }); + + }); + }); + } }); \ No newline at end of file diff --git a/chrome-extension/styles.css b/chrome-extension/styles.css index a6b8355..9914144 100644 --- a/chrome-extension/styles.css +++ b/chrome-extension/styles.css @@ -629,4 +629,129 @@ ul#articleList { opacity: 1; transform: translateY(0); } +} + +/* Styles for Play Podcast Button */ +#playPodcastButton { + background-color: #4CAF50; + /* Green */ + border: none; + color: white; + padding: 10px 20px; + text-align: center; + text-decoration: none; + display: inline-block; + +} + +#playPodcastButton:hover { + background-color: #45a049; +} + +/* Styles for Audio Player */ +#podcastAudioPlayer { + margin-top: 10px; + width: 100%; +} + +.search-and-create { + display: flex; + align-items: center; + gap: 10px; + /* Adjust the gap as needed */ +} + +#searchInput { + flex-grow: 1; + /* Allows the input to take up available space */ +} + +#playPodcastButton { + margin-top: 0; + /* Remove any top margin to align with the input */ +} + +#podcastList { + display: flex; + overflow-x: auto; + padding: 12px 0px 12px 20px; + margin: 0; + gap: var(--spacing-s-4); +} + +.podcast-card { + flex: 0 0 auto; + width: 200px; + margin-right: 10px; + padding: 10px; + border-radius: var(--spacing-s-4); + background-color: var(--color-neutral-light); + box-shadow: var(--box-shadow); + transition: transform 0.3s, box-shadow 0.3s; + display: flex; + flex-direction: column; + justify-content: space-between; + height: auto; +} + +.podcast-card-content { + display: flex; + flex-direction: column; + flex-grow: 1; + justify-content: space-between; + gap: var(--spacing-s-2); +} + +.podcast-card audio { + width: 100%; +} + +.podcast-card h3, +.podcast-card p { + margin: 0; +} + +.podcast-card .play-button, +.podcast-card .delete-button, +.podcast-card .share-button { + margin-top: auto; + color: #007bff; + text-decoration: none; + align-self: flex-start; +} + +.podcast-card .play-button:hover, +.podcast-card .delete-button:hover, +.podcast-card .share-button:hover { + text-decoration: underline; +} + +.podcast-menu { + position: absolute; + top: 60px; + right: 10px; + background-color: var(--color-surface-white); + border-radius: var(--spacing-s-2); + box-shadow: var(--box-shadow); + padding: var(--spacing-s-4); + width: 300px; + z-index: 1000; +} + +.podcast-menu button { + width: 100%; + margin-bottom: var(--spacing-s-2); +} + +#podcastList { + display: flex; + overflow-x: auto; + padding: 12px 0px; + margin: 0; + gap: var(--spacing-s-4); +} + +.compatible-tools { + display: none; + /* Initially hidden */ } \ No newline at end of file diff --git a/og-share.jpeg b/og-share.jpeg index 8a1ad15..99ef848 100644 Binary files a/og-share.jpeg and b/og-share.jpeg differ