uncloseai.com/src/uncloseai-embed-modal.js

2590 lines
77 KiB
JavaScript

// Main Hermes AI modal functionality - COMPLETE WORKING VERSION
import { marked } from "https://cdn.jsdelivr.net/npm/marked/lib/marked.esm.js";
import hljs from "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/es/highlight.min.js";
import { API_KEY, setSystemMessageAppend, TTS_API_URL } from "./config.js";
import { extractWebpageContent } from "./content.js";
import { detectPageLanguage } from "./language-detection.js";
import {
detectCurrentTheme,
getThemeColors,
initializeChunkFiveFont,
} from "./ui-themes.js";
import {
getUIText,
getUserLanguagePreference,
setUserLanguagePreference,
} from "./ui-translations.js";
import { NATIVE_LANGUAGE_NAMES } from "./translation.js";
// Get USE_CUSTOM_STYLING from window or default
const USE_CUSTOM_STYLING = window.UNCLOSEAI_CUSTOM_STYLING !== false;
// Import required functions dynamically to avoid circular dependencies
async function speakChatText(text, voice, speed) {
const { speakTextDirect } = await import("./tts.js");
return await speakTextDirect(text, voice, speed);
}
async function* sendMessage(message) {
try {
const chatModule = await import("./chat.js");
if (!chatModule.sendMessage) {
console.error("sendMessage not found in chat.js module:", chatModule);
throw new Error("sendMessage function not found in chat.js");
}
yield* chatModule.sendMessage(message);
} catch (error) {
console.error("Error in sendMessage wrapper:", error);
throw error;
}
}
async function* sendMessageWithCustomHistory(history) {
const { sendMessageWithCustomHistory } = await import("./chat.js");
yield* sendMessageWithCustomHistory(history);
}
async function getChatHistory() {
const { getChatHistory } = await import("./chat.js");
return getChatHistory();
}
async function updateChatHistory(history) {
const { updateChatHistory } = await import("./chat.js");
return updateChatHistory(history);
}
async function clearConversationHistory() {
const { clearConversationHistory } = await import("./storage.js");
return clearConversationHistory();
}
async function loadConversationHistory() {
const { loadConversationHistory } = await import("./storage.js");
return loadConversationHistory();
}
async function saveConversationHistory(history) {
const { saveConversationHistory } = await import("./storage.js");
return saveConversationHistory(history);
}
async function fetchModelsFromEndpoints() {
const { fetchModelsFromEndpoints } = await import("./models.js");
return fetchModelsFromEndpoints();
}
async function getSelectedModel() {
const { getSelectedModel } = await import("./models.js");
return getSelectedModel();
}
// Helper function to add copy buttons to code blocks
function addCodeBlockCopyButtons(element) {
const codeBlocks = element.querySelectorAll("pre code");
codeBlocks.forEach((codeBlock) => {
const pre = codeBlock.parentElement;
if (pre.tagName.toLowerCase() === "pre") {
// Make the pre element relative for positioning
pre.style.position = "relative";
// Create copy button
const copyBtn = document.createElement("button");
copyBtn.textContent = "📋";
copyBtn.title = "Copy code";
copyBtn.style.cssText = `
position: absolute;
top: 8px;
right: 8px;
background: rgba(0, 0, 0, 0.7);
color: white;
border: none;
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
font-size: 12px;
opacity: 0.8;
transition: opacity 0.2s;
z-index: 10;
`;
copyBtn.onmouseenter = () => {
copyBtn.style.opacity = "1";
};
copyBtn.onmouseleave = () => {
copyBtn.style.opacity = "0.8";
};
copyBtn.onclick = async () => {
try {
await navigator.clipboard.writeText(codeBlock.textContent);
copyBtn.textContent = "✓";
setTimeout(() => {
copyBtn.textContent = "📋";
}, 2000);
} catch (error) {
console.error("Failed to copy code:", error);
}
};
pre.appendChild(copyBtn);
}
});
}
// Function to refresh modal UI when language changes
function refreshModalUI() {
// Refresh all elements with data-i18n attributes in the modal
const modal = document.getElementById("uncloseai-embedded-modal");
if (!modal) return;
const elementsWithI18n = modal.querySelectorAll("[data-i18n]");
elementsWithI18n.forEach((element) => {
const key = element.getAttribute("data-i18n");
const translatedText = getUIText(key);
if (element.tagName === "INPUT" && element.type === "text") {
element.placeholder = translatedText;
} else if (element.tagName === "BUTTON" || element.tagName === "LABEL") {
// Preserve any icons/emojis at the start
const currentText = element.textContent;
const iconMatch = currentText.match(/^[^\w\s]+\s*/);
const icon = iconMatch ? iconMatch[0] : '';
element.textContent = icon + translatedText;
} else {
element.textContent = translatedText;
}
});
// Update any specific elements that need special handling
const titleElement = modal.querySelector("h1");
if (titleElement) {
titleElement.innerHTML = `<span style="font-family: 'ChunkFiveRegular', monospace;">uncloseai.</span> ${getUIText("hermesTitle")}`;
}
console.log("Modal UI language refreshed");
}
async function openUncloseaiEmbeddedModalNew() {
// Ensure ChunkFive font is loaded and text colors are enforced
initializeChunkFiveFont();
// Get theme colors
const theme = detectCurrentTheme();
const colors = getThemeColors(theme);
const modal = document.createElement("dialog");
modal.id = "uncloseai-embedded-modal";
modal.setAttribute("data-theme", theme);
// Mobile-first: full screen on mobile, centered on desktop
const isMobile = window.innerWidth <= 768;
modal.style.cssText = `
position: fixed;
margin: 0;
padding: 0;
border: none;
background: ${colors.modalBackground};
color: ${colors.primaryText};
z-index: 2000;
${
isMobile
? `
top: 0;
left: 0;
width: 100vw;
height: 100vh;
max-width: 100vw;
max-height: 100vh;
border-radius: 0;
`
: `
width: 90vw;
max-width: 800px;
height: 90vh;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border-radius: 16px;
box-shadow: 0 20px 40px ${colors.shadowColor};
`
}
`;
// Simple container with flex layout
const container = document.createElement("div");
container.style.cssText = `
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
`;
// Header
const header = document.createElement("header");
header.style.cssText = `
flex-shrink: 0;
padding: 16px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 4px ${colors.lightShadow};
`;
const titleContainer = document.createElement("div");
titleContainer.style.cssText =
"flex: 1; display: flex; flex-direction: column; gap: 4px;";
const title = document.createElement("h2");
title.innerHTML =
"🤖 <span style=\"font-family: 'ChunkFiveRegular', monospace; color: white;\">uncloseai.</span>";
title.style.cssText = `
margin: 0;
font-size: ${isMobile ? "18px" : "20px"};
font-weight: 600;
color: white;
`;
const subtitle = document.createElement("div");
const pageTitle = document.title || window.location.hostname;
subtitle.innerHTML = `
<div style="font-size: 12px; opacity: 0.9; font-weight: normal; line-height: 1.3; color: white;">
<span style="font-family: 'ChunkFiveRegular', monospace; color: white;">uncloseai.</span> ${getUIText("hermesIntro")}<br>
<span style="opacity: 0.8;">${getUIText("discussingPage")}: ${pageTitle}</span>
</div>
`;
titleContainer.appendChild(title);
titleContainer.appendChild(subtitle);
const closeBtn = document.createElement("button");
closeBtn.textContent = "✕";
closeBtn.style.cssText = `
background: rgba(255,255,255,0.2);
border: none;
color: white;
font-size: 20px;
width: 36px;
height: 36px;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
`;
closeBtn.onmouseover = () =>
(closeBtn.style.background = "rgba(255,255,255,0.3)");
closeBtn.onmouseout = () =>
(closeBtn.style.background = "rgba(255,255,255,0.2)");
closeBtn.onclick = () => {
modal.close();
document.body.removeChild(modal);
uncloseaiEmbeddedModalOpen = false;
};
// Add settings/menu button
const menuBtn = document.createElement("button");
menuBtn.textContent = "⚙️";
menuBtn.style.cssText = `
background: rgba(255,255,255,0.2);
border: none;
color: white;
font-size: 18px;
width: 36px;
height: 36px;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
margin-right: 8px;
`;
menuBtn.onmouseover = () =>
(menuBtn.style.background = "rgba(255,255,255,0.3)");
menuBtn.onmouseout = () =>
(menuBtn.style.background = "rgba(255,255,255,0.2)");
const headerRight = document.createElement("div");
headerRight.style.cssText = "display: flex; gap: 8px; align-items: center;";
headerRight.appendChild(menuBtn);
headerRight.appendChild(closeBtn);
header.appendChild(titleContainer);
header.appendChild(headerRight);
// Chat area
const chatArea = document.createElement("div");
chatArea.style.cssText = `
flex: 1;
overflow-y: auto;
padding: 16px;
background: ${colors.contentBackground};
-webkit-overflow-scrolling: touch;
`;
const chatBox = document.createElement("div");
chatBox.id = "modal-chat-box";
chatBox.style.cssText = `
max-width: 100%;
display: flex;
flex-direction: column;
gap: 12px;
`;
chatArea.appendChild(chatBox);
// Settings panel (collapsible)
const settingsPanel = document.createElement("div");
settingsPanel.style.cssText = `
flex-shrink: 0;
background: ${colors.panelBackground};
border-top: 1px solid ${colors.dividerColor};
border-bottom: 1px solid ${colors.dividerColor};
padding: 16px;
display: none;
max-height: 40vh;
overflow-y: auto;
`;
// Model selection
const modelSection = document.createElement("div");
modelSection.style.cssText = "margin-bottom: 16px;";
const modelLabel = document.createElement("label");
modelLabel.textContent = getUIText("modelLabel");
modelLabel.style.cssText = `display: block; margin-bottom: 8px; font-weight: 600; color: ${colors.primaryText};`;
const modelSelect = document.createElement("select");
modelSelect.style.cssText = `
width: 100%;
padding: 8px 12px;
border: 1px solid ${colors.borderColor};
border-radius: 6px;
font-size: 14px;
background: ${colors.inputBackground};
color: ${USE_CUSTOM_STYLING ? 'black' : colors.primaryText};
`;
// Store models for selection handler
let loadedModels = [];
// Load models
const loadModels = async () => {
try {
console.log("Loading models for Hermes modal...");
const models = await fetchModelsFromEndpoints();
console.log("Models loaded:", models);
loadedModels = models; // Store for selection handler
modelSelect.innerHTML = "";
if (models && models.length > 0) {
models.forEach((model) => {
const option = document.createElement("option");
option.value = model.uniqueId;
option.textContent = `${model.endpointId} | ${model.modelName}`;
modelSelect.appendChild(option);
});
// Load saved model from localStorage or use current selection
const savedModel = localStorage.getItem("selectedModel");
const savedEndpoint = localStorage.getItem("selectedEndpoint");
const currentModel = getSelectedModel();
if (savedModel && savedEndpoint) {
const savedOption = Array.from(modelSelect.options).find(
(opt) =>
opt.textContent.includes(savedModel) &&
opt.textContent.includes(savedEndpoint),
);
if (savedOption) {
modelSelect.value = savedOption.value;
console.log(
"Restored saved model:",
savedModel,
"from endpoint:",
savedEndpoint,
);
}
} else if (currentModel) {
const currentOption = Array.from(modelSelect.options).find((opt) =>
opt.textContent.includes(currentModel),
);
if (currentOption) {
modelSelect.value = currentOption.value;
}
}
} else {
const option = document.createElement("option");
option.textContent = "No models available";
option.disabled = true;
modelSelect.appendChild(option);
}
} catch (error) {
console.error("Failed to load models:", error);
const option = document.createElement("option");
option.textContent = "Error loading models";
option.disabled = true;
modelSelect.appendChild(option);
}
};
// Handle model selection changes
modelSelect.onchange = () => {
const selectedUniqueId = modelSelect.value;
const selectedModel = loadedModels.find(
(model) => model.uniqueId === selectedUniqueId,
);
if (selectedModel) {
// Update the global model selection
localStorage.setItem("selectedModel", selectedModel.modelName);
localStorage.setItem("selectedEndpoint", selectedModel.endpointId);
console.log("Model changed to:", selectedModel);
}
};
modelSection.appendChild(modelLabel);
modelSection.appendChild(modelSelect);
// Voice selection for TTS
const voiceSection = document.createElement("div");
voiceSection.style.cssText = "margin-bottom: 16px;";
const voiceLabel = document.createElement("label");
voiceLabel.textContent = getUIText("voiceLabel");
voiceLabel.style.cssText = `display: block; margin-bottom: 8px; font-weight: 600; color: ${colors.primaryText};`;
const voiceSelect = document.createElement("select");
voiceSelect.id = "hermes-voice-select";
voiceSelect.style.cssText = `
width: 100%;
padding: 8px 12px;
border: 1px solid ${colors.borderColor};
border-radius: 6px;
font-size: 14px;
background: ${colors.inputBackground};
color: ${USE_CUSTOM_STYLING ? 'black' : colors.primaryText};
`;
const voices = ["alloy", "echo", "fable", "onyx", "nova", "shimmer"];
voices.forEach((voice, index) => {
const option = document.createElement("option");
option.value = voice;
option.textContent = voice.charAt(0).toUpperCase() + voice.slice(1);
voiceSelect.appendChild(option);
});
// Load saved voice from localStorage or default to alloy
const savedVoice = localStorage.getItem("selectedVoice") || "alloy";
voiceSelect.value = savedVoice;
console.log("Voices loaded:", voices.length, "voices, selected:", savedVoice);
// Save voice selection to localStorage when changed
voiceSelect.onchange = () => {
localStorage.setItem("selectedVoice", voiceSelect.value);
console.log("Voice changed to:", voiceSelect.value);
};
voiceSection.appendChild(voiceLabel);
voiceSection.appendChild(voiceSelect);
// Language preference section
const languageSection = document.createElement("div");
languageSection.style.cssText = "margin-bottom: 16px;";
const languageLabel = document.createElement("label");
languageLabel.textContent = getUIText("languageLabel");
languageLabel.style.cssText = `display: block; margin-bottom: 8px; font-weight: 600; color: ${colors.primaryText};`;
// Create language selector
const { SUPPORTED_LANGUAGES, NATIVE_LANGUAGE_NAMES } = await import(
"./translation.js"
);
const currentLang = getUserLanguagePreference();
const languageSelect = document.createElement("select");
languageSelect.id = "hermes-language-select";
languageSelect.style.cssText = `
width: 100%;
padding: 8px 12px;
border: 1px solid ${colors.borderColor};
border-radius: 6px;
font-size: 14px;
background: ${colors.inputBackground};
color: ${USE_CUSTOM_STYLING ? 'black' : colors.primaryText};
`;
// Add options for each supported language
Object.entries(SUPPORTED_LANGUAGES).forEach(([code, englishName]) => {
const option = document.createElement("option");
option.value = code;
const nativeName = NATIVE_LANGUAGE_NAMES[code];
option.textContent = `${nativeName} (${englishName})`;
if (code === currentLang) {
option.selected = true;
}
languageSelect.appendChild(option);
});
// Handle language change
languageSelect.onchange = () => {
const newLang = languageSelect.value;
setUserLanguagePreference(newLang);
// Show confirmation
const selectedOption = languageSelect.options[languageSelect.selectedIndex];
console.log(`Language changed to: ${selectedOption.textContent}`);
// Refresh all UI text in the modal
refreshModalUI();
// Notify user to refresh for full effect
setTimeout(() => {
const langName = SUPPORTED_LANGUAGES[newLang];
alert(getUIText("languageChanged", { lang: langName }));
}, 100);
};
languageSection.appendChild(languageLabel);
languageSection.appendChild(languageSelect);
// Action buttons
const actionsSection = document.createElement("div");
actionsSection.style.cssText = "margin-bottom: 16px;";
const actionsLabel = document.createElement("div");
actionsLabel.textContent = getUIText("quickActions");
actionsLabel.style.cssText =
"margin-bottom: 8px; font-weight: 600; color: #495057;";
const actionsGrid = document.createElement("div");
actionsGrid.style.cssText = `
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 8px;
`;
const actions = [
{ text: getUIText("refreshModels"), action: loadModels },
{
text: getUIText("clearChat"),
action: async () => {
if (confirm(getUIText("clearChatConfirm"))) {
chatBox.innerHTML = "";
await clearConversationHistory();
// Also clear the chat history in the chat.js module
const currentHistory = await getChatHistory();
const systemMsg = currentHistory.find((msg) => msg.role === "system");
const newHistory = systemMsg ? [systemMsg] : [];
await updateChatHistory(newHistory);
console.log("Chat history cleared, reset to system message only");
// Re-add intro message
await addIntroMessage();
}
},
},
];
actions.forEach(({ text, action }) => {
const btn = document.createElement("button");
btn.textContent = text;
btn.style.cssText = `
padding: 8px 12px;
background: ${colors.buttonBackground};
border: 1px solid ${colors.buttonBorder};
border-radius: 6px;
cursor: pointer;
font-size: 13px;
color: ${colors.primaryText};
transition: all 0.2s;
white-space: nowrap;
`;
btn.onmouseover = () => {
btn.style.background = colors.buttonHover;
btn.style.borderColor = colors.buttonBorder;
};
btn.onmouseout = () => {
btn.style.background = colors.buttonBackground;
btn.style.borderColor = colors.buttonBorder;
};
btn.onclick = action;
actionsGrid.appendChild(btn);
});
actionsSection.appendChild(actionsLabel);
actionsSection.appendChild(actionsGrid);
settingsPanel.appendChild(modelSection);
settingsPanel.appendChild(voiceSection);
settingsPanel.appendChild(languageSection);
settingsPanel.appendChild(actionsSection);
// Load and save settings panel state
let settingsOpen = localStorage.getItem("hermesSettingsOpen") === "true";
settingsPanel.style.display = settingsOpen ? "block" : "none";
menuBtn.style.background = settingsOpen
? "rgba(255,255,255,0.3)"
: "rgba(255,255,255,0.2)";
menuBtn.onclick = () => {
settingsOpen = !settingsOpen;
settingsPanel.style.display = settingsOpen ? "block" : "none";
menuBtn.style.background = settingsOpen
? "rgba(255,255,255,0.3)"
: "rgba(255,255,255,0.2)";
localStorage.setItem("hermesSettingsOpen", settingsOpen.toString());
};
// Quick action buttons
const controls = document.createElement("div");
controls.style.cssText = `
flex-shrink: 0;
padding: 8px 16px;
background: ${colors.panelBackground};
border-top: 1px solid ${colors.dividerColor};
border-bottom: 1px solid ${colors.dividerColor};
display: flex;
gap: 8px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
`;
// HTML to Markdown converter function
function htmlToMarkdown(html) {
// Create a temporary element to parse HTML
const temp = document.createElement("div");
temp.innerHTML = html;
// Remove script and style elements
temp
.querySelectorAll("script, style, noscript")
.forEach((el) => el.remove());
const markdown = "";
function processNode(node) {
if (node.nodeType === Node.TEXT_NODE) {
return node.textContent.trim();
}
if (node.nodeType !== Node.ELEMENT_NODE) return "";
const tag = node.tagName.toLowerCase();
const children = Array.from(node.childNodes).map(processNode).join("");
switch (tag) {
case "h1":
return `# ${children}\n\n`;
case "h2":
return `## ${children}\n\n`;
case "h3":
return `### ${children}\n\n`;
case "h4":
return `#### ${children}\n\n`;
case "h5":
return `##### ${children}\n\n`;
case "h6":
return `###### ${children}\n\n`;
case "p":
return `${children}\n\n`;
case "br":
return "\n";
case "strong":
case "b":
return `**${children}**`;
case "em":
case "i":
return `*${children}*`;
case "code":
return `\`${children}\``;
case "pre":
return `\`\`\`\n${children}\n\`\`\`\n\n`;
case "a": {
const href = node.getAttribute("href");
return href ? `[${children}](${href})` : children;
}
case "img": {
const src = node.getAttribute("src");
const alt = node.getAttribute("alt") || "";
return src ? `![${alt}](${src})` : "";
}
case "ul":
case "ol":
return `${children}\n`;
case "li":
return `- ${children}\n`;
case "blockquote":
return `> ${children}\n\n`;
default:
return children;
}
}
return processNode(temp).trim();
}
let readPageAudio = null;
let isReadingPage = false;
let readPageContainer = null;
const controlActions = [
{
text: getUIText("readPage"),
action: async (button) => {
// If already playing, pause
if (readPageAudio && !readPageAudio.paused) {
readPageAudio.pause();
button.textContent = getUIText("readPage");
isReadingPage = false;
return;
}
// If paused, resume
if (
readPageAudio?.paused &&
readPageAudio.currentTime > 0
) {
readPageAudio.play();
button.textContent = "⏸️ Pause";
isReadingPage = true;
return;
}
// Prevent multiple simultaneous requests
if (isReadingPage) return;
// Start new reading
button.textContent = getUIText("processingText");
button.disabled = true;
isReadingPage = true;
try {
// Get page content
const { extractWebpageContent } = await import("./content.js");
const pageContent = extractWebpageContent();
// Generate TTS
const { speakText } = await import("./tts.js");
const result = await speakText(pageContent, "alloy", 1.0);
readPageAudio = result.audio;
// Set up audio event handlers
readPageAudio.onplay = () => {
button.textContent = "⏸️ Pause";
isReadingPage = true;
};
readPageAudio.onpause = () => {
button.textContent = "▶️ Resume";
};
readPageAudio.onended = () => {
button.textContent = getUIText("readPage");
isReadingPage = false;
readPageAudio = null;
// Remove download button container if it exists
if (readPageContainer) {
readPageContainer.remove();
readPageContainer = null;
}
};
// Create download button next to the read button
if (!readPageContainer) {
readPageContainer = document.createElement("div");
readPageContainer.style.cssText = `
display: inline-flex;
gap: 8px;
align-items: center;
`;
// Move the read button into the container
const parent = button.parentElement;
parent.insertBefore(readPageContainer, button);
readPageContainer.appendChild(button);
// Add download button
const downloadBtn = document.createElement("button");
downloadBtn.textContent = "💾";
downloadBtn.title = "Download audio";
downloadBtn.style.cssText = `
padding: 8px 16px;
background: ${colors.buttonBackground};
border: 1px solid ${colors.buttonBorder};
border-radius: 20px;
cursor: pointer;
font-size: 14px;
white-space: nowrap;
color: ${colors.primaryText} !important;
transition: all 0.2s;
`;
downloadBtn.onmouseover = () => {
downloadBtn.style.background = colors.buttonHover;
downloadBtn.style.borderColor = colors.buttonBorder;
};
downloadBtn.onmouseout = () => {
downloadBtn.style.background = colors.buttonBackground;
downloadBtn.style.borderColor = colors.buttonBorder;
};
downloadBtn.onclick = () => {
if (readPageAudio) {
const url = readPageAudio.src;
const a = document.createElement("a");
a.href = url;
a.download = "page-audio.mp3";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
};
readPageContainer.appendChild(downloadBtn);
}
// Auto-play the generated audio
try {
await readPageAudio.play();
} catch (playError) {
button.textContent = getUIText("readPage");
isReadingPage = false;
}
} catch (error) {
alert(getUIText("failedToReadPage", { error: error.message }));
button.textContent = getUIText("readPage");
isReadingPage = false;
} finally {
button.disabled = false;
}
},
},
{
text: getUIText("ttsAnything"),
action: async (btn) => {
if (typeof window.openTTSModal === 'function') {
window.openTTSModal();
} else {
// Fallback: dynamically import and call
try {
const { openTTSModal } = await import("./tts-modal.js");
openTTSModal();
} catch (error) {
console.error("Failed to open TTS modal:", error);
alert("TTS modal is not available at the moment.");
}
}
}
},
{
text: getUIText("translationModal"),
action: async (btn) => {
if (typeof window.openTranslateModal === 'function') {
window.openTranslateModal();
} else {
// Fallback: dynamically import and call
try {
const { openTranslateModal } = await import("./translate-modal.js");
openTranslateModal();
} catch (error) {
console.error("Failed to open translate modal:", error);
alert("Translation modal is not available at the moment.");
}
}
}
},
{
text: getUIText("smartTranslate"),
action: async (btn) => {
// Create dropdown instead of opening modal
if (
btn.nextElementSibling?.classList.contains("translate-dropdown")
) {
// Toggle existing dropdown
btn.nextElementSibling.remove();
return;
}
// Detect page language
btn.textContent = getUIText("detectingLanguage");
btn.disabled = true;
try {
const currentLang = await detectPageLanguage();
btn.textContent = getUIText("smartTranslate");
btn.disabled = false;
// Import supported languages
const { SUPPORTED_LANGUAGES, translateCurrentPage } = await import(
"./translation.js"
);
// Create dropdown
const dropdown = document.createElement("div");
dropdown.className = "translate-dropdown";
dropdown.style.cssText = `
position: absolute;
top: 100%;
left: 0;
background: ${colors.panelBackground};
border: 1px solid ${colors.borderColor};
border-radius: 8px;
box-shadow: 0 4px 12px ${colors.shadowColor};
z-index: 1000;
min-width: 200px;
max-height: 300px;
overflow-y: auto;
margin-top: 4px;
`;
// Add current language indicator
const currentLangDiv = document.createElement("div");
currentLangDiv.style.cssText = `
padding: 8px 12px;
font-size: 12px;
color: ${colors.mutedText};
border-bottom: 1px solid ${colors.dividerColor};
background: ${colors.contentBackground};
`;
const currentLangName = SUPPORTED_LANGUAGES[currentLang] || "Unknown";
currentLangDiv.textContent = `Current: ${currentLangName} (${currentLang})`;
dropdown.appendChild(currentLangDiv);
// Add translation options
Object.entries(SUPPORTED_LANGUAGES).forEach(([code, name]) => {
// Skip current language
if (code === currentLang) return;
const option = document.createElement("div");
option.style.cssText = `
padding: 8px 12px;
cursor: pointer;
transition: background-color 0.2s;
color: ${colors.primaryText};
font-size: 14px;
`;
option.textContent = `${name} (${code})`;
option.onmouseenter = () => {
option.style.background = colors.buttonHover;
};
option.onmouseleave = () => {
option.style.background = "transparent";
};
option.onclick = async () => {
try {
// Show loading
option.textContent = `Translating to ${name}...`;
option.style.pointerEvents = "none";
// Translate the page
const translatedHtml = await translateCurrentPage(code);
// Open in new tab
const newWindow = window.open("", "_blank");
newWindow.document.write(translatedHtml);
newWindow.document.close();
newWindow.document.title = `${document.title} (${name})`;
// Close dropdown
dropdown.remove();
} catch (error) {
alert(`Translation failed: ${error.message}`);
option.textContent = `${name} (${code})`;
option.style.pointerEvents = "auto";
}
};
dropdown.appendChild(option);
});
// Position dropdown relative to button
btn.style.position = "relative";
btn.parentElement.appendChild(dropdown);
// Close dropdown when clicking outside
const closeDropdown = (e) => {
if (!dropdown.contains(e.target) && e.target !== btn) {
dropdown.remove();
document.removeEventListener("click", closeDropdown);
}
};
setTimeout(
() => document.addEventListener("click", closeDropdown),
100,
);
} catch (error) {
btn.textContent = getUIText("smartTranslate");
btn.disabled = false;
alert(getUIText("languageDetectionFailed", { error: error.message }));
}
},
},
{
text: getUIText("fullChatRaw"),
action: async () => {
try {
const { getChatHistory } = await import("./chat.js");
const history = await getChatHistory();
let rawContent = "";
history.forEach((msg) => {
if (msg.role === "user") {
rawContent += `**You:** ${msg.content}\n\n`;
} else if (msg.role === "assistant") {
rawContent += `**AI:** ${msg.content}\n\n`;
}
});
await navigator.clipboard.writeText(rawContent);
alert(getUIText("fullChatCopied"));
} catch (error) {
alert(getUIText("failedToCopy", { error: error.message }));
}
},
},
{
text: getUIText("fullChatHTML"),
action: async () => {
try {
const { getChatHistory } = await import("./chat.js");
const history = await getChatHistory();
let htmlContent =
'<div style="font-family: system-ui, -apple-system, sans-serif;">';
history.forEach((msg) => {
if (msg.role === "user") {
htmlContent += `<div style="margin-bottom: 16px;"><strong>You:</strong><br>${marked.parse(msg.content)}</div>`;
} else if (msg.role === "assistant") {
htmlContent += `<div style="margin-bottom: 16px;"><strong>AI:</strong><br>${marked.parse(msg.content)}</div>`;
}
});
htmlContent += "</div>";
await navigator.clipboard.writeText(htmlContent);
alert(getUIText("fullChatHTMLCopied"));
} catch (error) {
alert(getUIText("failedToCopy", { error: error.message }));
}
},
},
{
text: getUIText("fullPageRaw"),
action: async () => {
try {
// Get the entire page HTML
const pageHtml = document.documentElement.outerHTML;
// Convert to markdown
const markdown = htmlToMarkdown(pageHtml);
await navigator.clipboard.writeText(markdown);
alert(getUIText("fullPageCopied"));
} catch (error) {
alert(getUIText("failedToCopyPage", { error: error.message }));
}
},
},
];
controlActions.forEach(({ text, action }) => {
const btn = document.createElement("button");
btn.textContent = text;
btn.style.cssText = `
padding: 8px 16px;
background: ${colors.buttonBackground};
border: 1px solid ${colors.buttonBorder};
border-radius: 20px;
cursor: pointer;
white-space: nowrap;
font-size: 14px;
color: ${colors.primaryText} !important;
transition: all 0.2s;
`;
btn.onmouseover = () => {
btn.style.background = colors.buttonHover;
btn.style.borderColor = colors.buttonBorder;
};
btn.onmouseout = () => {
btn.style.background = colors.buttonBackground;
btn.style.borderColor = colors.buttonBorder;
};
btn.onclick = () => action(btn);
controls.appendChild(btn);
});
// Assemble modal structure
container.appendChild(header);
container.appendChild(settingsPanel);
container.appendChild(chatArea);
container.appendChild(controls);
// Input area
const inputArea = document.createElement("div");
inputArea.style.cssText = `
flex-shrink: 0;
padding: 16px;
background: ${colors.panelBackground};
display: flex;
gap: 12px;
align-items: flex-end;
box-shadow: 0 -2px 10px ${colors.lightShadow};
`;
const input = document.createElement("textarea");
input.id = "modal-user-input";
input.placeholder = getUIText("typePlaceholder");
input.style.cssText = `
flex: 1;
min-height: 44px;
max-height: 120px;
padding: 12px;
border: 1px solid ${colors.borderColor};
border-radius: 24px;
resize: none;
font-size: 16px;
font-family: inherit;
line-height: 1.4;
outline: none;
background: ${colors.inputBackground};
color: ${USE_CUSTOM_STYLING ? 'black' : colors.primaryText};
transition: border-color 0.2s;
`;
input.onfocus = () => (input.style.borderColor = colors.focusBorder);
input.onblur = () => (input.style.borderColor = colors.borderColor);
// Auto-resize textarea
input.oninput = () => {
input.style.height = "auto";
input.style.height = `${Math.min(input.scrollHeight, 120)}px`;
};
const sendBtn = document.createElement("button");
sendBtn.textContent = "→";
sendBtn.style.cssText = `
width: 44px;
height: 44px;
padding: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 50%;
cursor: pointer;
font-size: 20px;
font-weight: bold;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.2s;
flex-shrink: 0;
`;
sendBtn.onmouseover = () => (sendBtn.style.transform = "scale(1.05)");
sendBtn.onmouseout = () => (sendBtn.style.transform = "scale(1)");
// Handle message sending
const sendMessageHandler = async () => {
const message = input.value.trim();
if (!message) return;
input.value = "";
input.style.height = "44px";
// Add user message
const userMsg = document.createElement("div");
userMsg.className = "user-message";
userMsg.style.cssText = `
align-self: flex-end;
background: ${colors.userMessageBg};
color: ${colors.userMessageText};
padding: 12px 16px;
border-radius: 18px 18px 4px 18px;
max-width: 80%;
word-wrap: break-word;
`;
// Add message text and actions inside the bubble
const messageText = document.createElement("div");
messageText.textContent = message;
messageText.style.cssText = "margin-bottom: 8px;";
const userMessageActions = document.createElement("div");
userMessageActions.style.cssText = `
display: flex;
gap: 8px;
opacity: 0.8;
justify-content: flex-end;
`;
const userTtsBtn = document.createElement("button");
userTtsBtn.textContent = "🔊";
userTtsBtn.title = "Read aloud";
userTtsBtn.style.cssText = `
background: ${colors.actionBg};
border: 1px solid ${colors.actionBorder};
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
font-size: 12px;
color: ${colors.userMessageText};
transition: all 0.2s;
`;
userTtsBtn.onmouseenter = () => {
userTtsBtn.style.background = colors.actionBgHover;
};
userTtsBtn.onmouseleave = () => {
userTtsBtn.style.background = colors.actionBg;
};
// Store audio for pause/resume functionality
let userAudio = null;
let isPlaying = false;
userTtsBtn.onclick = async () => {
const modalVoiceSelect = document.getElementById("hermes-voice-select");
const selectedVoice = modalVoiceSelect?.value || "alloy";
// If audio is already playing, pause it
if (userAudio && !userAudio.paused) {
userAudio.pause();
userTtsBtn.textContent = "🔊";
isPlaying = false;
return;
}
// If audio exists and is paused, resume it
if (userAudio?.paused && userAudio.currentTime > 0) {
userAudio.play();
userTtsBtn.textContent = "⏸️";
isPlaying = true;
return;
}
// Generate new audio
const originalText = userTtsBtn.textContent;
userTtsBtn.textContent = "⏳";
userTtsBtn.disabled = true;
try {
const result = await speakChatText(message, selectedVoice, 1.0);
userAudio = result.audio;
// Add download button if not already present
if (!userMessageActions.querySelector('.download-btn')) {
const downloadBtn = document.createElement("button");
downloadBtn.textContent = "💾";
downloadBtn.title = "Download MP3";
downloadBtn.className = "download-btn";
downloadBtn.style.cssText = `
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
font-size: 12px;
color: ${colors.userMessageText};
transition: all 0.2s;
margin-left: 4px;
`;
downloadBtn.onclick = async () => {
try {
const { generateTitleForTTS } = await import("./tts.js");
const filename = await generateTitleForTTS(message);
const a = document.createElement("a");
a.href = result.blobUrl;
a.download = `${filename}.mp3`;
a.click();
} catch (error) {
console.error("Error generating filename:", error);
const a = document.createElement("a");
a.href = result.blobUrl;
a.download = "user-message.mp3";
a.click();
}
};
// Insert download button right after the play button (before delete button)
const userDeleteBtn = userMessageActions.querySelector('button[title="Delete message"]');
if (userDeleteBtn) {
userMessageActions.insertBefore(downloadBtn, userDeleteBtn);
} else {
userMessageActions.appendChild(downloadBtn);
}
}
// Set up audio event handlers
userAudio.onplay = () => {
userTtsBtn.textContent = "⏸️";
isPlaying = true;
};
userAudio.onpause = () => {
userTtsBtn.textContent = "🔊";
isPlaying = false;
};
userAudio.onended = () => {
userTtsBtn.textContent = "🔊";
isPlaying = false;
userAudio = null;
};
// Auto-play the generated audio
try {
await userAudio.play();
} catch (playError) {
userTtsBtn.textContent = "🔊";
}
} catch (error) {
alert(`User TTS failed: ${error.message}`);
userTtsBtn.textContent = originalText;
} finally {
userTtsBtn.disabled = false;
}
};
const userDeleteBtn = document.createElement("button");
userDeleteBtn.textContent = "🗑️";
userDeleteBtn.title = "Delete message";
userDeleteBtn.style.cssText = `
background: ${colors.actionBg};
border: 1px solid ${colors.actionBorder};
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
font-size: 12px;
color: ${colors.userMessageText};
transition: all 0.2s;
`;
userDeleteBtn.onmouseenter = () => {
userDeleteBtn.style.background = colors.actionBgHover;
};
userDeleteBtn.onmouseleave = () => {
userDeleteBtn.style.background = colors.actionBg;
};
userDeleteBtn.onclick = async () => {
// Remove from chat history and localStorage
const { loadConversationHistory, saveConversationHistory } = await import("./storage.js");
const { updateChatHistory } = await import("./chat.js");
const currentHistory = await loadConversationHistory();
const updatedHistory = currentHistory.filter(
(msg) => !(msg.role === "user" && msg.content === message),
);
// Update both localStorage and chat.js module
await saveConversationHistory(updatedHistory);
await updateChatHistory(updatedHistory);
userMsg.remove();
};
userMessageActions.appendChild(userTtsBtn);
userMessageActions.appendChild(userDeleteBtn);
userMsg.appendChild(messageText);
userMsg.appendChild(userMessageActions);
chatBox.appendChild(userMsg);
// Add AI response placeholder
const aiMsg = document.createElement("div");
aiMsg.style.cssText = `
align-self: flex-start;
background: ${colors.aiMessageBg};
color: ${colors.aiMessageText};
padding: 12px 16px;
border-radius: 18px 18px 18px 4px;
max-width: 80%;
box-shadow: 0 1px 2px ${colors.lightShadow};
`;
aiMsg.innerHTML = `<em style="color: ${colors.mutedText};">Thinking...</em>`;
chatBox.appendChild(aiMsg);
chatArea.scrollTop = chatArea.scrollHeight;
try {
let response = "";
for await (const chunk of sendMessage(message)) {
response += chunk;
aiMsg.innerHTML = marked.parse(response);
addCodeBlockCopyButtons(aiMsg);
}
// Add the AI response to chat history manually since sendMessage generator doesn't do it
const { getChatHistory, updateChatHistory } = await import("./chat.js");
const currentHistory = await getChatHistory();
currentHistory.push({ role: "assistant", content: response });
await updateChatHistory(currentHistory);
// Save conversation history after successful response
await saveConversationHistory(currentHistory);
console.log(
"Conversation history saved:",
currentHistory.length,
"messages",
);
console.log(
"Saved history content:",
currentHistory.map((msg) => ({
role: msg.role,
content: `${msg.content.substring(0, 50)}...`,
})),
);
// Add TTS and delete buttons to AI message
const messageActions = document.createElement("div");
messageActions.style.cssText = `
margin-top: 8px;
display: flex;
gap: 8px;
opacity: 0.7;
`;
const ttsBtn = document.createElement("button");
ttsBtn.textContent = "🔊";
ttsBtn.title = "Read aloud";
ttsBtn.style.cssText = `
background: ${colors.buttonBackground};
border: 1px solid ${colors.borderColor};
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
font-size: 12px;
color: ${colors.primaryText};
transition: all 0.2s;
`;
// Store audio for pause/resume functionality
let currentAudio = null;
let isPlaying = false;
ttsBtn.onclick = async () => {
// Find voice selection from the modal by ID
const modalVoiceSelect = document.getElementById("hermes-voice-select");
const selectedVoice = modalVoiceSelect?.value || "alloy";
// If audio is already playing, pause it
if (currentAudio && !currentAudio.paused) {
currentAudio.pause();
ttsBtn.textContent = "🔊";
isPlaying = false;
console.log("🔊 CHAT TTS: Paused");
return;
}
// If audio exists and is paused, resume it
if (
currentAudio?.paused &&
currentAudio.currentTime > 0
) {
currentAudio.play();
ttsBtn.textContent = "⏸️";
isPlaying = true;
console.log("🔊 CHAT TTS: Resumed");
return;
}
// Generate new audio
console.log("🔊 CHAT TTS: Generating new audio");
const originalText = ttsBtn.textContent;
ttsBtn.textContent = "⏳";
ttsBtn.disabled = true;
try {
const result = await speakChatText(response, selectedVoice, 1.0);
currentAudio = result.audio;
// Add download button if not already present
if (!messageActions.querySelector('.download-btn')) {
const downloadBtn = document.createElement("button");
downloadBtn.textContent = "💾";
downloadBtn.title = "Download MP3";
downloadBtn.className = "download-btn";
downloadBtn.style.cssText = `
background: ${colors.buttonBackground};
border: 1px solid ${colors.borderColor};
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
font-size: 12px;
color: ${colors.primaryText};
transition: all 0.2s;
margin-left: 4px;
`;
downloadBtn.onclick = async () => {
try {
const { generateTitleForTTS } = await import("./tts.js");
const filename = await generateTitleForTTS(response);
const a = document.createElement("a");
a.href = result.blobUrl;
a.download = `${filename}.mp3`;
a.click();
} catch (error) {
console.error("Error generating filename:", error);
const a = document.createElement("a");
a.href = result.blobUrl;
a.download = "ai-response.mp3";
a.click();
}
};
// Insert download button right after the play button (before copy buttons)
const copyRawBtn = messageActions.querySelector('button[title="Copy raw response"]');
if (copyRawBtn) {
messageActions.insertBefore(downloadBtn, copyRawBtn);
} else {
messageActions.appendChild(downloadBtn);
}
}
// Set up audio event handlers
currentAudio.onplay = () => {
ttsBtn.textContent = "⏸️";
isPlaying = true;
};
currentAudio.onpause = () => {
ttsBtn.textContent = "🔊";
isPlaying = false;
};
currentAudio.onended = () => {
ttsBtn.textContent = "🔊";
isPlaying = false;
currentAudio = null;
};
// Auto-play the generated audio
try {
await currentAudio.play();
console.log("🔊 CHAT TTS: Playing new audio");
} catch (playError) {
console.log("🔊 CHAT TTS: Auto-play blocked:", playError.message);
ttsBtn.textContent = "🔊";
}
} catch (error) {
console.error("🔊 CHAT TTS: Failed with error:", error);
alert(`Chat TTS failed: ${error.message}`);
ttsBtn.textContent = originalText;
} finally {
ttsBtn.disabled = false;
}
};
const deleteBtn = document.createElement("button");
deleteBtn.textContent = "🗑️";
deleteBtn.title = "Delete message";
deleteBtn.style.cssText = `
background: ${colors.buttonBackground};
border: 1px solid ${colors.borderColor};
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
font-size: 12px;
color: ${colors.primaryText};
transition: all 0.2s;
`;
deleteBtn.onclick = async () => {
// Remove both user and AI messages from history and localStorage
const { loadConversationHistory, saveConversationHistory } = await import("./storage.js");
const { updateChatHistory } = await import("./chat.js");
const currentHistory = await loadConversationHistory();
const updatedHistory = currentHistory.filter(
(msg) => !(
(msg.role === "user" && msg.content === message) ||
(msg.role === "assistant" && msg.content === response)
)
);
// Update both localStorage and chat.js module
await saveConversationHistory(updatedHistory);
await updateChatHistory(updatedHistory);
userMsg.remove();
aiMsg.remove();
};
// Copy Raw (Markdown) button
const copyRawBtn = document.createElement("button");
copyRawBtn.textContent = "📋";
copyRawBtn.title = "Copy raw (Markdown)";
copyRawBtn.style.cssText = `
background: ${colors.buttonBackground};
border: 1px solid ${colors.borderColor};
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
font-size: 12px;
color: ${colors.primaryText};
transition: all 0.2s;
`;
copyRawBtn.onclick = async () => {
try {
await navigator.clipboard.writeText(response);
const originalText = copyRawBtn.textContent;
copyRawBtn.textContent = "✓";
setTimeout(() => {
copyRawBtn.textContent = originalText;
}, 1000);
} catch (error) {
alert(`Failed to copy: ${error.message}`);
}
};
// Copy HTML button
const copyHtmlBtn = document.createElement("button");
copyHtmlBtn.textContent = "📄";
copyHtmlBtn.title = "Copy HTML";
copyHtmlBtn.style.cssText = `
background: ${colors.buttonBackground};
border: 1px solid ${colors.borderColor};
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
font-size: 12px;
color: ${colors.primaryText};
transition: all 0.2s;
`;
copyHtmlBtn.onclick = async () => {
try {
const htmlContent = marked.parse(response);
await navigator.clipboard.writeText(htmlContent);
const originalText = copyHtmlBtn.textContent;
copyHtmlBtn.textContent = "✓";
setTimeout(() => {
copyHtmlBtn.textContent = originalText;
}, 1000);
} catch (error) {
alert(`Failed to copy: ${error.message}`);
}
};
messageActions.appendChild(ttsBtn);
messageActions.appendChild(copyRawBtn);
messageActions.appendChild(copyHtmlBtn);
messageActions.appendChild(deleteBtn);
aiMsg.appendChild(messageActions);
} catch (error) {
aiMsg.innerHTML = `<span style="color: #dc3545;">Error: ${error.message}</span>`;
}
chatArea.scrollTop = chatArea.scrollHeight;
};
sendBtn.onclick = sendMessageHandler;
input.onkeydown = (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessageHandler();
}
};
inputArea.appendChild(input);
inputArea.appendChild(sendBtn);
container.appendChild(inputArea);
modal.appendChild(container);
document.body.appendChild(modal);
modal.showModal();
modal.addEventListener("click", (e) => {
if (e.target === modal) {
modal.close();
document.body.removeChild(modal);
uncloseaiEmbeddedModalOpen = false;
}
});
// Theme change monitoring
const updateModalTheme = () => {
const newTheme = detectCurrentTheme();
const newColors = getThemeColors(newTheme);
modal.setAttribute("data-theme", newTheme);
// Update modal background and main color
modal.style.background = newColors.modalBackground;
modal.style.color = newColors.primaryText;
// Update chat area background
chatArea.style.background = newColors.contentBackground;
// Update settings panel background
settingsPanel.style.background = newColors.panelBackground;
settingsPanel.style.borderTopColor = newColors.dividerColor;
settingsPanel.style.borderBottomColor = newColors.dividerColor;
// Update input area
inputArea.style.background = newColors.panelBackground;
input.style.background = newColors.inputBackground;
input.style.color = newColors.primaryText;
input.style.borderColor = newColors.borderColor;
// Update controls panel
controls.style.background = newColors.panelBackground;
controls.style.borderTopColor = newColors.dividerColor;
controls.style.borderBottomColor = newColors.dividerColor;
// Update all control buttons
controls.querySelectorAll("button").forEach((btn) => {
btn.style.background = newColors.buttonBackground;
btn.style.borderColor = newColors.buttonBorder;
btn.style.color = `${newColors.primaryText} !important`;
// Re-attach hover handlers with new colors
btn.onmouseover = () => {
btn.style.background = newColors.buttonHover;
btn.style.borderColor = newColors.buttonBorder;
};
btn.onmouseout = () => {
btn.style.background = newColors.buttonBackground;
btn.style.borderColor = newColors.buttonBorder;
};
});
// Update all message bubbles
chatBox
.querySelectorAll('div[style*="align-self: flex-end"]')
.forEach((userMsg) => {
userMsg.style.background = newColors.userMessageBg;
userMsg.style.color = newColors.userMessageText;
});
chatBox
.querySelectorAll('div[style*="align-self: flex-start"]')
.forEach((aiMsg) => {
aiMsg.style.background = newColors.aiMessageBg;
aiMsg.style.color = newColors.aiMessageText;
aiMsg.style.boxShadow = `0 1px 2px ${newColors.lightShadow}`;
});
};
// Set up theme change listeners
const themeObserver = new MutationObserver(updateModalTheme);
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme"],
});
if (window.matchMedia) {
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", updateModalTheme);
}
// Clean up listeners when modal is closed
const originalClose = modal.close.bind(modal);
modal.close = () => {
themeObserver.disconnect();
if (window.matchMedia) {
window
.matchMedia("(prefers-color-scheme: dark)")
.removeEventListener("change", updateModalTheme);
}
originalClose();
};
input.focus();
// Load models on startup
loadModels();
// Load conversation history
const loadHistory = async () => {
try {
const history = await loadConversationHistory();
console.log("Raw history from localStorage:", history);
console.log("History length:", history ? history.length : 0);
if (history && history.length > 0) {
// Set up page context for existing conversation
const pageContent = extractWebpageContent();
const pageTitle = document.title || window.location.hostname;
const conversationContextAppend = `
PAGE CONTEXT FOR THIS CONVERSATION:
You are embedded on the webpage: "${pageTitle}"
URL: ${window.location.href}
FULL PAGE CONTENT:
${pageContent}
You have complete knowledge of this page content and can reference any details, names, topics, or information mentioned on this page. Answer questions about the page content accurately and helpfully.`;
setSystemMessageAppend(conversationContextAppend);
// Sync the loaded history with the chat.js module
const { updateChatHistory } = await import("./chat.js");
const { getSystemMessage } = await import("./config.js");
// Create new history with updated system message and loaded conversation
const newHistory = [
{ role: "system", content: getSystemMessage() },
...history,
];
// Update the chat.js module's history
await updateChatHistory(newHistory);
console.log(
"Chat history synced with loaded data:",
newHistory.length,
"messages",
);
console.log("History to display:", history);
// Display previous conversation
history.forEach((msg, index) => {
console.log(
`Displaying message ${index}:`,
msg.role,
msg.content.substring(0, 50),
);
if (msg.role === "user") {
const userMsg = document.createElement("div");
userMsg.className = "user-message";
userMsg.style.cssText = `
align-self: flex-end;
background: ${colors.userMessageBg};
color: ${colors.userMessageText};
padding: 12px 16px;
border-radius: 18px 18px 4px 18px;
max-width: 80%;
word-wrap: break-word;
`;
// Add message text and actions inside the bubble
const messageText = document.createElement("div");
messageText.textContent = msg.content;
messageText.style.cssText = "margin-bottom: 8px;";
const userMessageActions = document.createElement("div");
userMessageActions.style.cssText = `
display: flex;
gap: 8px;
opacity: 0.8;
justify-content: flex-end;
`;
const userTtsBtn = document.createElement("button");
userTtsBtn.textContent = "🔊";
userTtsBtn.title = "Read aloud";
userTtsBtn.style.cssText = `
background: ${colors.actionBg};
border: 1px solid ${colors.actionBorder};
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
font-size: 12px;
color: ${colors.userMessageText};
transition: all 0.2s;
`;
userTtsBtn.onmouseenter = () => {
userTtsBtn.style.background = "rgba(255, 255, 255, 0.3)";
};
userTtsBtn.onmouseleave = () => {
userTtsBtn.style.background = "rgba(255, 255, 255, 0.2)";
};
// Store audio for pause/resume functionality
let historicalUserAudio = null;
userTtsBtn.onclick = async () => {
const modalVoiceSelect = document.getElementById(
"hermes-voice-select",
);
const selectedVoice = modalVoiceSelect?.value || "alloy";
// If audio is already playing, pause it
if (historicalUserAudio && !historicalUserAudio.paused) {
historicalUserAudio.pause();
userTtsBtn.textContent = "🔊";
return;
}
// If audio exists and is paused, resume it
if (
historicalUserAudio &&
historicalUserAudio.paused &&
historicalUserAudio.currentTime > 0
) {
historicalUserAudio.play();
userTtsBtn.textContent = "⏸️";
return;
}
// Generate new audio
const originalText = userTtsBtn.textContent;
userTtsBtn.textContent = "⏳";
userTtsBtn.disabled = true;
try {
const result = await speakChatText(
msg.content,
selectedVoice,
1.0,
);
historicalUserAudio = result.audio;
// Add download button if not already present
if (!userMessageActions.querySelector('.download-btn')) {
const downloadBtn = document.createElement("button");
downloadBtn.textContent = "💾";
downloadBtn.title = "Download MP3";
downloadBtn.className = "download-btn";
downloadBtn.style.cssText = `
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
font-size: 12px;
color: ${colors.userMessageText};
transition: all 0.2s;
margin-left: 4px;
`;
downloadBtn.onclick = async () => {
try {
const { generateTitleForTTS } = await import("./tts.js");
const filename = await generateTitleForTTS(msg.content);
const a = document.createElement("a");
a.href = result.blobUrl;
a.download = `${filename}.mp3`;
a.click();
} catch (error) {
console.error("Error generating filename:", error);
const a = document.createElement("a");
a.href = result.blobUrl;
a.download = "user-historical-message.mp3";
a.click();
}
};
// Insert download button right after the play button (before delete button)
const userDeleteBtn = userMessageActions.querySelector('button[title="Delete message"]');
if (userDeleteBtn) {
userMessageActions.insertBefore(downloadBtn, userDeleteBtn);
} else {
userMessageActions.appendChild(downloadBtn);
}
}
// Set up audio event handlers
historicalUserAudio.onplay = () => {
userTtsBtn.textContent = "⏸️";
};
historicalUserAudio.onpause = () => {
userTtsBtn.textContent = "🔊";
};
historicalUserAudio.onended = () => {
userTtsBtn.textContent = "🔊";
historicalUserAudio = null;
};
// Auto-play the generated audio
try {
await historicalUserAudio.play();
} catch (playError) {
userTtsBtn.textContent = "🔊";
}
} catch (error) {
alert(`User TTS failed: ${error.message}`);
userTtsBtn.textContent = originalText;
} finally {
userTtsBtn.disabled = false;
}
};
const userDeleteBtn = document.createElement("button");
userDeleteBtn.textContent = "🗑️";
userDeleteBtn.title = "Delete message";
userDeleteBtn.style.cssText = `
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
font-size: 12px;
color: ${colors.userMessageText};
transition: all 0.2s;
`;
userDeleteBtn.onmouseenter = () => {
userDeleteBtn.style.background = "rgba(255, 255, 255, 0.3)";
};
userDeleteBtn.onmouseleave = () => {
userDeleteBtn.style.background = "rgba(255, 255, 255, 0.2)";
};
userDeleteBtn.onclick = async () => {
// Remove from chat history by index
const currentHistory = await loadConversationHistory();
if (index >= 0 && index < currentHistory.length) {
currentHistory.splice(index, 1);
await saveConversationHistory(currentHistory);
// Update the chat.js module history to match localStorage
const { updateChatHistory } = await import("./chat.js");
const { getSystemMessage } = await import("./config.js");
const newHistory = [
{ role: "system", content: getSystemMessage() },
...currentHistory,
];
await updateChatHistory(newHistory);
}
userMsg.remove();
};
userMessageActions.appendChild(userTtsBtn);
userMessageActions.appendChild(userDeleteBtn);
userMsg.appendChild(messageText);
userMsg.appendChild(userMessageActions);
chatBox.appendChild(userMsg);
console.log("Added user message to chatBox");
} else if (msg.role === "assistant") {
const aiMsg = document.createElement("div");
aiMsg.style.cssText = `
align-self: flex-start;
background: ${colors.aiMessageBg};
color: ${colors.aiMessageText};
padding: 12px 16px;
border-radius: 18px 18px 18px 4px;
max-width: 80%;
box-shadow: 0 1px 2px ${colors.lightShadow};
`;
aiMsg.innerHTML = marked.parse(msg.content);
addCodeBlockCopyButtons(aiMsg);
// Add TTS and delete buttons to historical AI messages
const messageActions = document.createElement("div");
messageActions.style.cssText = `
margin-top: 8px;
display: flex;
gap: 8px;
opacity: 0.7;
`;
const ttsBtn = document.createElement("button");
ttsBtn.textContent = "🔊";
ttsBtn.title = "Read aloud";
ttsBtn.style.cssText = `
background: ${colors.buttonBackground};
border: 1px solid ${colors.borderColor};
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
font-size: 12px;
color: ${colors.primaryText};
transition: all 0.2s;
`;
// Store audio for pause/resume functionality
let historicalAudio = null;
ttsBtn.onclick = async () => {
const modalVoiceSelect = document.getElementById(
"hermes-voice-select",
);
const selectedVoice = modalVoiceSelect?.value || "alloy";
// If audio is already playing, pause it
if (historicalAudio && !historicalAudio.paused) {
historicalAudio.pause();
ttsBtn.textContent = "🔊";
console.log("🔊 HISTORICAL TTS: Paused");
return;
}
// If audio exists and is paused, resume it
if (
historicalAudio &&
historicalAudio.paused &&
historicalAudio.currentTime > 0
) {
historicalAudio.play();
ttsBtn.textContent = "⏸️";
console.log("🔊 HISTORICAL TTS: Resumed");
return;
}
// Generate new audio
console.log("🔊 HISTORICAL TTS: Generating new audio");
const originalText = ttsBtn.textContent;
ttsBtn.textContent = "⏳";
ttsBtn.disabled = true;
try {
const result = await speakChatText(
msg.content,
selectedVoice,
1.0,
);
historicalAudio = result.audio;
// Add download button if not already present
if (!messageActions.querySelector('.download-btn')) {
const downloadBtn = document.createElement("button");
downloadBtn.textContent = "💾";
downloadBtn.title = "Download MP3";
downloadBtn.className = "download-btn";
downloadBtn.style.cssText = `
background: none;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s;
margin-left: 4px;
`;
downloadBtn.onclick = async () => {
try {
const { generateTitleForTTS } = await import("./tts.js");
const filename = await generateTitleForTTS(msg.content);
const a = document.createElement("a");
a.href = result.blobUrl;
a.download = `${filename}.mp3`;
a.click();
} catch (error) {
console.error("Error generating filename:", error);
const a = document.createElement("a");
a.href = result.blobUrl;
a.download = "historical-message.mp3";
a.click();
}
};
// Insert download button right after the play button (before copy buttons)
const copyRawBtn = messageActions.querySelector('button[title="Copy raw response"]');
if (copyRawBtn) {
messageActions.insertBefore(downloadBtn, copyRawBtn);
} else {
messageActions.appendChild(downloadBtn);
}
}
// Set up audio event handlers
historicalAudio.onplay = () => {
ttsBtn.textContent = "⏸️";
};
historicalAudio.onpause = () => {
ttsBtn.textContent = "🔊";
};
historicalAudio.onended = () => {
ttsBtn.textContent = "🔊";
historicalAudio = null;
};
// Auto-play the generated audio
try {
await historicalAudio.play();
console.log("🔊 HISTORICAL TTS: Playing new audio");
} catch (playError) {
console.log(
"🔊 HISTORICAL TTS: Auto-play blocked:",
playError.message,
);
ttsBtn.textContent = "🔊";
}
} catch (error) {
console.error("🔊 HISTORICAL TTS: Failed with error:", error);
alert(`Historical TTS failed: ${error.message}`);
ttsBtn.textContent = originalText;
} finally {
ttsBtn.disabled = false;
}
};
const deleteBtn = document.createElement("button");
deleteBtn.textContent = "🗑️";
deleteBtn.title = "Delete message";
deleteBtn.style.cssText = `
background: none;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s;
`;
deleteBtn.onclick = async () => {
// Remove from chat history and localStorage by index
const { loadConversationHistory, saveConversationHistory } = await import("./storage.js");
const { updateChatHistory } = await import("./chat.js");
const currentHistory = await loadConversationHistory();
if (index >= 0 && index < currentHistory.length) {
currentHistory.splice(index, 1);
// Update both localStorage and chat.js module
await saveConversationHistory(currentHistory);
await updateChatHistory(currentHistory);
}
aiMsg.remove();
};
// Copy Raw (Markdown) button
const copyRawBtn = document.createElement("button");
copyRawBtn.textContent = "📋";
copyRawBtn.title = "Copy raw (Markdown)";
copyRawBtn.style.cssText = `
background: none;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s;
`;
copyRawBtn.onclick = async () => {
try {
await navigator.clipboard.writeText(msg.content);
const originalText = copyRawBtn.textContent;
copyRawBtn.textContent = "✓";
setTimeout(() => {
copyRawBtn.textContent = originalText;
}, 1000);
} catch (error) {
alert(`Failed to copy: ${error.message}`);
}
};
// Copy HTML button
const copyHtmlBtn = document.createElement("button");
copyHtmlBtn.textContent = "📄";
copyHtmlBtn.title = "Copy HTML";
copyHtmlBtn.style.cssText = `
background: none;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s;
`;
copyHtmlBtn.onclick = async () => {
try {
const htmlContent = marked.parse(msg.content);
await navigator.clipboard.writeText(htmlContent);
const originalText = copyHtmlBtn.textContent;
copyHtmlBtn.textContent = "✓";
setTimeout(() => {
copyHtmlBtn.textContent = originalText;
}, 1000);
} catch (error) {
alert(`Failed to copy: ${error.message}`);
}
};
messageActions.appendChild(ttsBtn);
messageActions.appendChild(copyRawBtn);
messageActions.appendChild(copyHtmlBtn);
messageActions.appendChild(deleteBtn);
aiMsg.appendChild(messageActions);
chatBox.appendChild(aiMsg);
console.log("Added AI message to chatBox with TTS buttons");
}
});
chatArea.scrollTop = chatArea.scrollHeight;
} else {
// Add intro message if no history
await addIntroMessage();
}
} catch (error) {
console.error("Failed to load conversation history:", error);
await addIntroMessage();
}
};
const addIntroMessage = async () => {
const introMsg = document.createElement("div");
introMsg.style.cssText = `
align-self: flex-start;
background: white;
padding: 12px 16px;
border-radius: 18px 18px 18px 4px;
max-width: 80%;
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
color: #495057;
`;
// Show loading message first
introMsg.innerHTML =
'<em style="color: #6c757d;">Analyzing page and generating welcome message...</em>';
chatBox.appendChild(introMsg);
try {
// Get page content for context
const pageContent = extractWebpageContent();
const pageTitle = document.title || window.location.hostname;
// FIRST: Generate intro message with specialized intro system prompt
// Get user's preferred language and localize the system prompt
const userLang = getUserLanguagePreference();
const languageName = NATIVE_LANGUAGE_NAMES[userLang] || "English";
const introSystemPrompt = `${getUIText("systemPromptIntro")}
PAGE INFORMATION:
Title: "${pageTitle}"
URL: ${window.location.href}
FULL PAGE CONTENT:
${pageContent}
Generate a 3-paragraph introduction that:
${getUIText("systemPromptTask1")}
${getUIText("systemPromptTask2")}
${getUIText("systemPromptTask3")}
${getUIText("systemPromptInstructions")} ${languageName}.`;
const introPrompt = "Generate the welcoming introduction message now.";
// Create temporary chat history for intro generation
const introHistory = [
{ role: "system", content: introSystemPrompt },
{ role: "user", content: introPrompt },
];
// Generate intro with specialized system prompt
let response = "";
for await (const chunk of sendMessageWithCustomHistory(introHistory)) {
response += chunk;
introMsg.innerHTML = marked.parse(response);
addCodeBlockCopyButtons(introMsg);
}
// SECOND: Set up conversation system prompt for follow-up messages
const conversationContextAppend = `
PAGE CONTEXT FOR THIS CONVERSATION:
You are embedded on the webpage: "${pageTitle}"
URL: ${window.location.href}
FULL PAGE CONTENT:
${pageContent}
You have complete knowledge of this page content and can reference any details, names, topics, or information mentioned on this page. Answer questions about the page content accurately and helpfully.`;
setSystemMessageAppend(conversationContextAppend);
// Update the chat.js module with the intro message
const { updateChatHistory, getChatHistory } = await import("./chat.js");
const { getSystemMessage } = await import("./config.js");
// Create new history with system message and intro
const newHistory = [
{ role: "system", content: getSystemMessage() },
{ role: "assistant", content: response },
];
// Update the chat.js module's history
await updateChatHistory(newHistory);
console.log("Added intro message to chat history");
// Add TTS and delete buttons to intro message
const messageActions = document.createElement("div");
messageActions.style.cssText = `
margin-top: 8px;
display: flex;
gap: 8px;
opacity: 0.7;
`;
const ttsBtn = document.createElement("button");
ttsBtn.textContent = "🔊";
ttsBtn.title = "Read aloud";
ttsBtn.style.cssText = `
background: none;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s;
`;
// Store audio for pause/resume functionality
let introAudio = null;
ttsBtn.onclick = async () => {
const modalVoiceSelect = document.getElementById("hermes-voice-select");
const selectedVoice = modalVoiceSelect?.value || "alloy";
// If audio is already playing, pause it
if (introAudio && !introAudio.paused) {
introAudio.pause();
ttsBtn.textContent = "🔊";
console.log("🔊 INTRO TTS: Paused");
return;
}
// If audio exists and is paused, resume it
if (introAudio && introAudio.paused && introAudio.currentTime > 0) {
introAudio.play();
ttsBtn.textContent = "⏸️";
console.log("🔊 INTRO TTS: Resumed");
return;
}
// Generate new audio
console.log("🔊 INTRO TTS: Generating new audio");
const originalText = ttsBtn.textContent;
ttsBtn.textContent = "⏳";
ttsBtn.disabled = true;
try {
const result = await speakChatText(response, selectedVoice, 1.0);
introAudio = result.audio;
// Set up audio event handlers
introAudio.onplay = () => {
ttsBtn.textContent = "⏸️";
};
introAudio.onpause = () => {
ttsBtn.textContent = "🔊";
};
introAudio.onended = () => {
ttsBtn.textContent = "🔊";
introAudio = null;
};
// Auto-play the generated audio
try {
await introAudio.play();
console.log("🔊 INTRO TTS: Playing new audio");
} catch (playError) {
console.log("🔊 INTRO TTS: Auto-play blocked:", playError.message);
ttsBtn.textContent = "🔊";
}
} catch (error) {
console.error("🔊 INTRO TTS: Failed with error:", error);
alert(`Intro TTS failed: ${error.message}`);
ttsBtn.textContent = originalText;
} finally {
ttsBtn.disabled = false;
}
};
const deleteBtn = document.createElement("button");
deleteBtn.textContent = "🗑️";
deleteBtn.title = "Delete message";
deleteBtn.style.cssText = `
background: none;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s;
`;
deleteBtn.onclick = async () => {
// Remove intro message from localStorage
const { loadConversationHistory, saveConversationHistory } = await import("./storage.js");
const { updateChatHistory } = await import("./chat.js");
const currentHistory = await loadConversationHistory();
const updatedHistory = currentHistory.filter(
(msg) => !(msg.role === "assistant" && msg.content === response)
);
// Update both localStorage and chat.js module
await saveConversationHistory(updatedHistory);
await updateChatHistory(updatedHistory);
introMsg.remove();
};
// Copy Raw (Markdown) button
const copyRawBtn = document.createElement("button");
copyRawBtn.textContent = "📋";
copyRawBtn.title = "Copy raw (Markdown)";
copyRawBtn.style.cssText = `
background: ${colors.buttonBackground};
border: 1px solid ${colors.borderColor};
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
font-size: 12px;
color: ${colors.primaryText};
transition: all 0.2s;
`;
copyRawBtn.onclick = async () => {
try {
await navigator.clipboard.writeText(response);
const originalText = copyRawBtn.textContent;
copyRawBtn.textContent = "✓";
setTimeout(() => {
copyRawBtn.textContent = originalText;
}, 1000);
} catch (error) {
alert(`Failed to copy: ${error.message}`);
}
};
// Copy HTML button
const copyHtmlBtn = document.createElement("button");
copyHtmlBtn.textContent = "📄";
copyHtmlBtn.title = "Copy HTML";
copyHtmlBtn.style.cssText = `
background: ${colors.buttonBackground};
border: 1px solid ${colors.borderColor};
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
font-size: 12px;
color: ${colors.primaryText};
transition: all 0.2s;
`;
copyHtmlBtn.onclick = async () => {
try {
const htmlContent = marked.parse(response);
await navigator.clipboard.writeText(htmlContent);
const originalText = copyHtmlBtn.textContent;
copyHtmlBtn.textContent = "✓";
setTimeout(() => {
copyHtmlBtn.textContent = originalText;
}, 1000);
} catch (error) {
alert(`Failed to copy: ${error.message}`);
}
};
messageActions.appendChild(ttsBtn);
messageActions.appendChild(copyRawBtn);
messageActions.appendChild(copyHtmlBtn);
messageActions.appendChild(deleteBtn);
introMsg.appendChild(messageActions);
// Save this intro as part of the conversation
await saveConversationHistory(await getChatHistory());
} catch (error) {
console.error("Failed to generate contextual intro:", error);
// Fallback to basic intro
introMsg.innerHTML = `👋 Hi! I'm Hermes AI. I can help you understand this page, answer questions, or assist with various tasks. How can I help you today?`;
}
};
await loadHistory();
}
// Function to toggle Hermes modal
let uncloseaiEmbeddedModalOpen = false;
async function toggleUncloseaiEmbeddedModal() {
if (uncloseaiEmbeddedModalOpen) {
const existingModal = document.getElementById("uncloseai-embedded-modal");
if (existingModal) {
existingModal.close();
document.body.removeChild(existingModal);
}
uncloseaiEmbeddedModalOpen = false;
} else {
await openUncloseaiEmbeddedModalNew();
uncloseaiEmbeddedModalOpen = true;
}
}
// Export the modal functions
export { toggleUncloseaiEmbeddedModal, openUncloseaiEmbeddedModalNew };
// Export compatibility alias
export const openUncloseaiEmbeddedModal = toggleUncloseaiEmbeddedModal;