uncloseai.com/src/ui.js
Russell Ballestrini eee33986cf feat(ui): add comprehensive language localization system
- Add complete UI translations for all 19 supported languages
- Add language preference dropdown in modal settings
- Store language preference in localStorage
- Inject language preference into Hermes system prompts
- Add smart translation dropdown with AI-powered language detection
- Keep both original translation modal and new smart dropdown
- Remove non-functional upload button from modal
- Add biome.json config to ignore third-party CSS files
2025-07-03 11:09:10 -04:00

5562 lines
173 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// UI creation and management functionality
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 { speakText } from "./tts.js";
// Initialize ChunkFive font with absolute path
function initializeChunkFiveFont() {
const fontFaceStyle = document.createElement("style");
fontFaceStyle.textContent = `
@font-face {
font-family: "ChunkFiveRegular";
src: url("https://uncloseai.com/css/chunkfive/chunkfive-regular-webfont.woff2") format("woff2"),
url("https://uncloseai.com/css/chunkfive/chunkfive-regular-webfont.woff") format("woff");
font-weight: normal;
font-style: normal;
font-display: swap;
}
`;
document.head.appendChild(fontFaceStyle);
}
// Fast AI-powered language detection using Hermes
async function detectPageLanguage() {
try {
// Get page content as markdown
const pageHtml = document.documentElement.outerHTML;
const pageMarkdown = htmlToMarkdown(pageHtml);
// Take a sample (first 1000 characters) for speed
const sample = pageMarkdown.substring(0, 1000);
// Import required modules
const { getSelectedModel, getSelectedModelEndpoint } = await import(
"./models.js"
);
const apiUrl = `${getSelectedModelEndpoint()}/chat/completions`;
const response = await fetch(apiUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
},
body: JSON.stringify({
model: getSelectedModel(),
messages: [
{
role: "system",
content:
"You are a language classifier. Respond with ONLY the 2-letter ISO language code (en, es, fr, de, etc.) for the detected language. No explanations, just the code.",
},
{
role: "user",
content: `Detect the language of this text:\n\n${sample}`,
},
],
temperature: 0.1,
max_tokens: 10,
stream: false,
}),
});
if (!response.ok) {
console.warn(
"Language detection failed, falling back to browser language",
);
return fallbackLanguageDetection();
}
const data = await response.json();
const detectedLang = data.choices[0].message.content.trim().toLowerCase();
// Validate it's a reasonable language code (2-3 chars)
if (/^[a-z]{2,3}$/.test(detectedLang)) {
console.log("AI detected language:", detectedLang);
return detectedLang;
} else {
console.warn("Invalid language code from AI:", detectedLang);
return fallbackLanguageDetection();
}
} catch (error) {
console.warn("Language detection error:", error);
return fallbackLanguageDetection();
}
}
// Fallback language detection using DOM attributes
function fallbackLanguageDetection() {
// Check html lang attribute first
const htmlLang = document.documentElement.lang;
if (htmlLang) {
const langCode = htmlLang.split("-")[0].toLowerCase();
return langCode;
}
// Fallback to browser language
const browserLang = navigator.language || navigator.userLanguage;
if (browserLang) {
const langCode = browserLang.split("-")[0].toLowerCase();
return langCode;
}
// Default to English
return "en";
}
// Complete UI Translations for all supported languages
const UI_TRANSLATIONS = {
en: {
// Main buttons
readPage: "📖 Read Page",
ttsAnything: "🔊 TTS Anything",
translate: "🌐 Translate",
smartTranslate: "🤖 Smart Translate",
uploadFile: "📁 Upload File",
// Settings
modelLabel: "AI Model:",
voiceLabel: "TTS Voice:",
languageLabel: "Interface Language:",
quickActions: "Quick Actions:",
// Actions
refreshModels: "🔄 Refresh Models",
clearChat: "🗑️ Clear Chat",
fullChatRaw: "📋 Full Chat Raw",
downloadChat: "💾 Download Chat",
// Chat UI
sendButton: "Send",
typePlaceholder: "Type your message...",
clearChatConfirm: "Clear all conversation history?",
// System messages
pageContentPrefix: "Here's the content of the webpage:",
additionalContext:
"You are embedded on this page. When answering questions, consider the page content and context.",
// Notifications
languageChanged:
"Language set to {lang}. Refresh the page to see all interface elements in your preferred language.",
translationFailed: "Translation failed: {error}",
ttsFailed: "TTS failed: {error}",
// Dropdown labels
currentPage: "Current page: {lang}",
translatingTo: "Translating...",
detectingLanguage: "🔍 Detecting...",
},
// Chinese (Simplified)
zh: {
readPage: "📖 阅读页面",
ttsAnything: "🔊 语音合成",
translate: "🌐 翻译",
smartTranslate: "🤖 智能翻译",
uploadFile: "📁 上传文件",
modelLabel: "AI 模型:",
voiceLabel: "语音类型:",
languageLabel: "界面语言:",
quickActions: "快捷操作:",
refreshModels: "🔄 刷新模型",
clearChat: "🗑️ 清除聊天",
fullChatRaw: "📋 完整聊天记录",
downloadChat: "💾 下载聊天",
sendButton: "发送",
typePlaceholder: "输入您的消息...",
clearChatConfirm: "清除所有对话历史记录?",
pageContentPrefix: "以下是网页内容:",
additionalContext: "您已嵌入此页面。回答问题时,请考虑页面内容和上下文。",
languageChanged:
"语言设置为 {lang}。刷新页面以查看所有界面元素的首选语言。",
translationFailed: "翻译失败:{error}",
ttsFailed: "语音合成失败:{error}",
currentPage: "当前页面:{lang}",
translatingTo: "翻译中...",
detectingLanguage: "🔍 检测中...",
},
// Hindi
hi: {
readPage: "📖 पृष्ठ पढ़ें",
ttsAnything: "🔊 TTS कुछ भी",
translate: "🌐 अनुवाद करें",
smartTranslate: "🤖 स्मार्ट अनुवाद",
uploadFile: "📁 फ़ाइल अपलोड करें",
modelLabel: "AI मॉडल:",
voiceLabel: "TTS आवाज़:",
languageLabel: "इंटरफ़ेस भाषा:",
quickActions: "त्वरित क्रियाएं:",
refreshModels: "🔄 मॉडल रीफ्रेश करें",
clearChat: "🗑️ चैट साफ़ करें",
fullChatRaw: "📋 पूर्ण चैट रॉ",
downloadChat: "💾 चैट डाउनलोड करें",
sendButton: "भेजें",
typePlaceholder: "अपना संदेश टाइप करें...",
clearChatConfirm: "सभी वार्तालाप इतिहास साफ़ करें?",
pageContentPrefix: "यहाँ वेबपेज की सामग्री है:",
additionalContext:
"आप इस पृष्ठ पर एम्बेडेड हैं। प्रश्नों का उत्तर देते समय, पृष्ठ सामग्री और संदर्भ पर विचार करें।",
languageChanged:
"भाषा {lang} पर सेट की गई। अपनी पसंदीदा भाषा में सभी इंटरफ़ेस तत्वों को देखने के लिए पृष्ठ को रीफ्रेश करें।",
translationFailed: "अनुवाद विफल: {error}",
ttsFailed: "TTS विफल: {error}",
currentPage: "वर्तमान पृष्ठ: {lang}",
translatingTo: "अनुवाद हो रहा है...",
detectingLanguage: "🔍 पता लगाया जा रहा है...",
},
// Spanish
es: {
readPage: "📖 Leer Página",
ttsAnything: "🔊 TTS Todo",
translate: "🌐 Traducir",
smartTranslate: "🤖 Traducción Inteligente",
uploadFile: "📁 Subir Archivo",
modelLabel: "Modelo de IA:",
voiceLabel: "Voz TTS:",
languageLabel: "Idioma de la Interfaz:",
quickActions: "Acciones Rápidas:",
refreshModels: "🔄 Actualizar Modelos",
clearChat: "🗑️ Borrar Chat",
fullChatRaw: "📋 Chat Completo Raw",
downloadChat: "💾 Descargar Chat",
sendButton: "Enviar",
typePlaceholder: "Escribe tu mensaje...",
clearChatConfirm: "¿Borrar todo el historial de conversación?",
pageContentPrefix: "Aquí está el contenido de la página web:",
additionalContext:
"Estás integrado en esta página. Al responder preguntas, considera el contenido y contexto de la página.",
languageChanged:
"Idioma establecido en {lang}. Actualiza la página para ver todos los elementos en tu idioma preferido.",
translationFailed: "Error en traducción: {error}",
ttsFailed: "Error en TTS: {error}",
currentPage: "Página actual: {lang}",
translatingTo: "Traduciendo...",
detectingLanguage: "🔍 Detectando...",
},
// French
fr: {
readPage: "📖 Lire la Page",
ttsAnything: "🔊 TTS Tout",
translate: "🌐 Traduire",
smartTranslate: "🤖 Traduction Intelligente",
uploadFile: "📁 Télécharger Fichier",
modelLabel: "Modèle IA :",
voiceLabel: "Voix TTS :",
languageLabel: "Langue de l'Interface :",
quickActions: "Actions Rapides :",
refreshModels: "🔄 Actualiser les Modèles",
clearChat: "🗑️ Effacer le Chat",
fullChatRaw: "📋 Chat Complet Brut",
downloadChat: "💾 Télécharger le Chat",
sendButton: "Envoyer",
typePlaceholder: "Tapez votre message...",
clearChatConfirm: "Effacer tout l'historique de conversation ?",
pageContentPrefix: "Voici le contenu de la page web :",
additionalContext:
"Vous êtes intégré sur cette page. Lors de la réponse aux questions, considérez le contenu et le contexte de la page.",
languageChanged:
"Langue définie sur {lang}. Actualisez la page pour voir tous les éléments de l'interface dans votre langue préférée.",
translationFailed: "Échec de la traduction : {error}",
ttsFailed: "Échec du TTS : {error}",
currentPage: "Page actuelle : {lang}",
translatingTo: "Traduction en cours...",
detectingLanguage: "🔍 Détection...",
},
// Arabic
ar: {
readPage: "📖 قراءة الصفحة",
ttsAnything: "🔊 تحويل النص إلى كلام",
translate: "🌐 ترجمة",
smartTranslate: "🤖 ترجمة ذكية",
uploadFile: "📁 رفع ملف",
modelLabel: "نموذج الذكاء الاصطناعي:",
voiceLabel: "صوت TTS:",
languageLabel: "لغة الواجهة:",
quickActions: "إجراءات سريعة:",
refreshModels: "🔄 تحديث النماذج",
clearChat: "🗑️ مسح المحادثة",
fullChatRaw: "📋 المحادثة الكاملة",
downloadChat: "💾 تحميل المحادثة",
sendButton: "إرسال",
typePlaceholder: "اكتب رسالتك...",
clearChatConfirm: "مسح كل سجل المحادثة؟",
pageContentPrefix: "هنا محتوى صفحة الويب:",
additionalContext:
"أنت مضمن في هذه الصفحة. عند الإجابة على الأسئلة، ضع في اعتبارك محتوى الصفحة والسياق.",
languageChanged:
"تم تعيين اللغة إلى {lang}. قم بتحديث الصفحة لرؤية جميع عناصر الواجهة بلغتك المفضلة.",
translationFailed: "فشلت الترجمة: {error}",
ttsFailed: "فشل TTS: {error}",
currentPage: "الصفحة الحالية: {lang}",
translatingTo: "جارٍ الترجمة...",
detectingLanguage: "🔍 جارٍ الكشف...",
},
// Bengali
bn: {
readPage: "📖 পৃষ্ঠা পড়ুন",
ttsAnything: "🔊 TTS সবকিছু",
translate: "🌐 অনুবাদ",
smartTranslate: "🤖 স্মার্ট অনুবাদ",
uploadFile: "📁 ফাইল আপলোড",
modelLabel: "AI মডেল:",
voiceLabel: "TTS ভয়েস:",
languageLabel: "ইন্টারফেস ভাষা:",
quickActions: "দ্রুত ক্রিয়া:",
refreshModels: "🔄 মডেল রিফ্রেশ",
clearChat: "🗑️ চ্যাট মুছুন",
fullChatRaw: "📋 সম্পূর্ণ চ্যাট র",
downloadChat: "💾 চ্যাট ডাউনলোড",
sendButton: "পাঠান",
typePlaceholder: "আপনার বার্তা টাইপ করুন...",
clearChatConfirm: "সমস্ত কথোপকথনের ইতিহাস মুছে ফেলবেন?",
pageContentPrefix: "এখানে ওয়েবপেজের বিষয়বস্তু:",
additionalContext:
"আপনি এই পৃষ্ঠায় এম্বেড করা আছেন। প্রশ্নের উত্তর দেওয়ার সময়, পৃষ্ঠার বিষয়বস্তু এবং প্রসঙ্গ বিবেচনা করুন।",
languageChanged:
"ভাষা {lang} সেট করা হয়েছে। আপনার পছন্দের ভাষায় সমস্ত ইন্টারফেস উপাদান দেখতে পৃষ্ঠাটি রিফ্রেশ করুন।",
translationFailed: "অনুবাদ ব্যর্থ: {error}",
ttsFailed: "TTS ব্যর্থ: {error}",
currentPage: "বর্তমান পৃষ্ঠা: {lang}",
translatingTo: "অনুবাদ হচ্ছে...",
detectingLanguage: "🔍 সনাক্ত করা হচ্ছে...",
},
// Russian
ru: {
readPage: "📖 Читать Страницу",
ttsAnything: "🔊 TTS Всё",
translate: "🌐 Перевести",
smartTranslate: "🤖 Умный Перевод",
uploadFile: "📁 Загрузить Файл",
modelLabel: "Модель ИИ:",
voiceLabel: "Голос TTS:",
languageLabel: "Язык Интерфейса:",
quickActions: "Быстрые Действия:",
refreshModels: "🔄 Обновить Модели",
clearChat: "🗑️ Очистить Чат",
fullChatRaw: "📋 Полный Чат Raw",
downloadChat: "💾 Скачать Чат",
sendButton: "Отправить",
typePlaceholder: "Введите ваше сообщение...",
clearChatConfirm: "Очистить всю историю разговора?",
pageContentPrefix: "Вот содержимое веб-страницы:",
additionalContext:
"Вы встроены на эту страницу. При ответе на вопросы учитывайте содержание и контекст страницы.",
languageChanged:
"Язык установлен на {lang}. Обновите страницу, чтобы увидеть все элементы интерфейса на предпочитаемом языке.",
translationFailed: "Ошибка перевода: {error}",
ttsFailed: "Ошибка TTS: {error}",
currentPage: "Текущая страница: {lang}",
translatingTo: "Перевод...",
detectingLanguage: "🔍 Определение...",
},
// Portuguese
pt: {
readPage: "📖 Ler Página",
ttsAnything: "🔊 TTS Tudo",
translate: "🌐 Traduzir",
smartTranslate: "🤖 Tradução Inteligente",
uploadFile: "📁 Enviar Arquivo",
modelLabel: "Modelo de IA:",
voiceLabel: "Voz TTS:",
languageLabel: "Idioma da Interface:",
quickActions: "Ações Rápidas:",
refreshModels: "🔄 Atualizar Modelos",
clearChat: "🗑️ Limpar Chat",
fullChatRaw: "📋 Chat Completo Raw",
downloadChat: "💾 Baixar Chat",
sendButton: "Enviar",
typePlaceholder: "Digite sua mensagem...",
clearChatConfirm: "Limpar todo o histórico de conversas?",
pageContentPrefix: "Aqui está o conteúdo da página web:",
additionalContext:
"Você está incorporado nesta página. Ao responder perguntas, considere o conteúdo e contexto da página.",
languageChanged:
"Idioma definido para {lang}. Atualize a página para ver todos os elementos da interface no seu idioma preferido.",
translationFailed: "Falha na tradução: {error}",
ttsFailed: "Falha no TTS: {error}",
currentPage: "Página atual: {lang}",
translatingTo: "Traduzindo...",
detectingLanguage: "🔍 Detectando...",
},
// Urdu
ur: {
readPage: "📖 صفحہ پڑھیں",
ttsAnything: "🔊 TTS سب کچھ",
translate: "🌐 ترجمہ",
smartTranslate: "🤖 سمارٹ ترجمہ",
uploadFile: "📁 فائل اپ لوڈ",
modelLabel: "AI ماڈل:",
voiceLabel: "TTS آواز:",
languageLabel: "انٹرفیس زبان:",
quickActions: "فوری اعمال:",
refreshModels: "🔄 ماڈلز ریفریش",
clearChat: "🗑️ چیٹ صاف کریں",
fullChatRaw: "📋 مکمل چیٹ Raw",
downloadChat: "💾 چیٹ ڈاؤن لوڈ",
sendButton: "بھیجیں",
typePlaceholder: "اپنا پیغام ٹائپ کریں...",
clearChatConfirm: "تمام گفتگو کی تاریخ صاف کریں؟",
pageContentPrefix: "یہاں ویب پیج کا مواد ہے:",
additionalContext:
"آپ اس صفحے پر ایمبیڈ ہیں۔ سوالات کا جواب دیتے وقت، صفحے کے مواد اور سیاق و سباق پر غور کریں۔",
languageChanged:
"زبان {lang} پر سیٹ کی گئی۔ اپنی پسندیدہ زبان میں تمام انٹرفیس عناصر دیکھنے کے لیے صفحہ ریفریش کریں۔",
translationFailed: "ترجمہ ناکام: {error}",
ttsFailed: "TTS ناکام: {error}",
currentPage: "موجودہ صفحہ: {lang}",
translatingTo: "ترجمہ ہو رہا ہے...",
detectingLanguage: "🔍 تلاش کر رہے ہیں...",
},
// Indonesian
id: {
readPage: "📖 Baca Halaman",
ttsAnything: "🔊 TTS Semua",
translate: "🌐 Terjemahkan",
smartTranslate: "🤖 Terjemahan Cerdas",
uploadFile: "📁 Unggah Berkas",
modelLabel: "Model AI:",
voiceLabel: "Suara TTS:",
languageLabel: "Bahasa Antarmuka:",
quickActions: "Aksi Cepat:",
refreshModels: "🔄 Segarkan Model",
clearChat: "🗑️ Hapus Obrolan",
fullChatRaw: "📋 Obrolan Lengkap Raw",
downloadChat: "💾 Unduh Obrolan",
sendButton: "Kirim",
typePlaceholder: "Ketik pesan Anda...",
clearChatConfirm: "Hapus semua riwayat percakapan?",
pageContentPrefix: "Berikut adalah konten halaman web:",
additionalContext:
"Anda tertanam di halaman ini. Saat menjawab pertanyaan, pertimbangkan konten dan konteks halaman.",
languageChanged:
"Bahasa diatur ke {lang}. Segarkan halaman untuk melihat semua elemen antarmuka dalam bahasa pilihan Anda.",
translationFailed: "Terjemahan gagal: {error}",
ttsFailed: "TTS gagal: {error}",
currentPage: "Halaman saat ini: {lang}",
translatingTo: "Menerjemahkan...",
detectingLanguage: "🔍 Mendeteksi...",
},
// German
de: {
readPage: "📖 Seite Lesen",
ttsAnything: "🔊 TTS Alles",
translate: "🌐 Übersetzen",
smartTranslate: "🤖 Intelligente Übersetzung",
uploadFile: "📁 Datei Hochladen",
modelLabel: "KI-Modell:",
voiceLabel: "TTS-Stimme:",
languageLabel: "Oberflächensprache:",
quickActions: "Schnellaktionen:",
refreshModels: "🔄 Modelle Aktualisieren",
clearChat: "🗑️ Chat Löschen",
fullChatRaw: "📋 Vollständiger Chat Raw",
downloadChat: "💾 Chat Herunterladen",
sendButton: "Senden",
typePlaceholder: "Geben Sie Ihre Nachricht ein...",
clearChatConfirm: "Gesamten Gesprächsverlauf löschen?",
pageContentPrefix: "Hier ist der Inhalt der Webseite:",
additionalContext:
"Sie sind auf dieser Seite eingebettet. Berücksichtigen Sie bei der Beantwortung von Fragen den Inhalt und Kontext der Seite.",
languageChanged:
"Sprache auf {lang} eingestellt. Aktualisieren Sie die Seite, um alle Oberflächenelemente in Ihrer bevorzugten Sprache zu sehen.",
translationFailed: "Übersetzung fehlgeschlagen: {error}",
ttsFailed: "TTS fehlgeschlagen: {error}",
currentPage: "Aktuelle Seite: {lang}",
translatingTo: "Übersetze...",
detectingLanguage: "🔍 Erkenne...",
},
// Japanese
ja: {
readPage: "📖 ページを読む",
ttsAnything: "🔊 TTS すべて",
translate: "🌐 翻訳",
smartTranslate: "🤖 スマート翻訳",
uploadFile: "📁 ファイルをアップロード",
modelLabel: "AIモデル",
voiceLabel: "TTS音声",
languageLabel: "インターフェース言語:",
quickActions: "クイックアクション:",
refreshModels: "🔄 モデルを更新",
clearChat: "🗑️ チャットをクリア",
fullChatRaw: "📋 完全なチャットRaw",
downloadChat: "💾 チャットをダウンロード",
sendButton: "送信",
typePlaceholder: "メッセージを入力してください...",
clearChatConfirm: "すべての会話履歴をクリアしますか?",
pageContentPrefix: "ウェブページのコンテンツは次のとおりです:",
additionalContext:
"このページに埋め込まれています。質問に答える際は、ページのコンテンツとコンテキストを考慮してください。",
languageChanged:
"言語を{lang}に設定しました。すべてのインターフェース要素を希望の言語で表示するには、ページを更新してください。",
translationFailed: "翻訳失敗:{error}",
ttsFailed: "TTS失敗{error}",
currentPage: "現在のページ:{lang}",
translatingTo: "翻訳中...",
detectingLanguage: "🔍 検出中...",
},
// Swahili
sw: {
readPage: "📖 Soma Ukurasa",
ttsAnything: "🔊 TTS Yote",
translate: "🌐 Tafsiri",
smartTranslate: "🤖 Tafsiri ya Akili",
uploadFile: "📁 Pakia Faili",
modelLabel: "Mfano wa AI:",
voiceLabel: "Sauti ya TTS:",
languageLabel: "Lugha ya Kiolesura:",
quickActions: "Vitendo vya Haraka:",
refreshModels: "🔄 Onyesha Upya Mifano",
clearChat: "🗑️ Futa Mazungumzo",
fullChatRaw: "📋 Mazungumzo Kamili Raw",
downloadChat: "💾 Pakua Mazungumzo",
sendButton: "Tuma",
typePlaceholder: "Andika ujumbe wako...",
clearChatConfirm: "Futa historia yote ya mazungumzo?",
pageContentPrefix: "Hapa kuna maudhui ya ukurasa wa wavuti:",
additionalContext:
"Umepachikwa kwenye ukurasa huu. Unapojibu maswali, zingatia maudhui na muktadha wa ukurasa.",
languageChanged:
"Lugha imewekwa kuwa {lang}. Onyesha upya ukurasa ili kuona vipengele vyote vya kiolesura katika lugha unayopendelea.",
translationFailed: "Tafsiri imeshindwa: {error}",
ttsFailed: "TTS imeshindwa: {error}",
currentPage: "Ukurasa wa sasa: {lang}",
translatingTo: "Inatafsiri...",
detectingLanguage: "🔍 Inatambua...",
},
// Marathi
mr: {
readPage: "📖 पृष्ठ वाचा",
ttsAnything: "🔊 TTS सर्व",
translate: "🌐 भाषांतर",
smartTranslate: "🤖 स्मार्ट भाषांतर",
uploadFile: "📁 फाईल अपलोड",
modelLabel: "AI मॉडेल:",
voiceLabel: "TTS आवाज:",
languageLabel: "इंटरफेस भाषा:",
quickActions: "जलद क्रिया:",
refreshModels: "🔄 मॉडेल रीफ्रेश",
clearChat: "🗑️ चॅट साफ करा",
fullChatRaw: "📋 संपूर्ण चॅट Raw",
downloadChat: "💾 चॅट डाउनलोड",
sendButton: "पाठवा",
typePlaceholder: "तुमचा संदेश टाइप करा...",
clearChatConfirm: "सर्व संभाषण इतिहास साफ करायचा?",
pageContentPrefix: "येथे वेबपेजची सामग्री आहे:",
additionalContext:
"तुम्ही या पृष्ठावर एम्बेड केलेले आहात. प्रश्नांची उत्तरे देताना, पृष्ठाची सामग्री आणि संदर्भ विचारात घ्या.",
languageChanged:
"भाषा {lang} वर सेट केली. तुमच्या पसंतीच्या भाषेत सर्व इंटरफेस घटक पाहण्यासाठी पृष्ठ रीफ्रेश करा.",
translationFailed: "भाषांतर अयशस्वी: {error}",
ttsFailed: "TTS अयशस्वी: {error}",
currentPage: "वर्तमान पृष्ठ: {lang}",
translatingTo: "भाषांतर करत आहे...",
detectingLanguage: "🔍 शोधत आहे...",
},
// Telugu
te: {
readPage: "📖 పేజీ చదవండి",
ttsAnything: "🔊 TTS అన్నీ",
translate: "🌐 అనువదించు",
smartTranslate: "🤖 స్మార్ట్ అనువాదం",
uploadFile: "📁 ఫైల్ అప్‌లోడ్",
modelLabel: "AI మోడల్:",
voiceLabel: "TTS వాయిస్:",
languageLabel: "ఇంటర్ఫేస్ భాష:",
quickActions: "త్వరిత చర్యలు:",
refreshModels: "🔄 మోడల్స్ రిఫ్రెష్",
clearChat: "🗑️ చాట్ క్లియర్",
fullChatRaw: "📋 పూర్తి చాట్ Raw",
downloadChat: "💾 చాట్ డౌన్‌లోడ్",
sendButton: "పంపు",
typePlaceholder: "మీ సందేశం టైప్ చేయండి...",
clearChatConfirm: "అన్ని సంభాషణ చరిత్రను క్లియర్ చేయాలా?",
pageContentPrefix: "వెబ్‌పేజీ కంటెంట్ ఇక్కడ ఉంది:",
additionalContext:
"మీరు ఈ పేజీలో ఎంబెడ్ చేయబడ్డారు. ప్రశ్నలకు సమాధానం ఇచ్చేటప్పుడు, పేజీ కంటెంట్ మరియు సందర్భాన్ని పరిగణించండి.",
languageChanged:
"భాష {lang}కి సెట్ చేయబడింది. మీ ప్రాధాన్య భాషలో అన్ని ఇంటర్ఫేస్ అంశాలను చూడటానికి పేజీని రిఫ్రెష్ చేయండి.",
translationFailed: "అనువాదం విఫలమైంది: {error}",
ttsFailed: "TTS విఫలమైంది: {error}",
currentPage: "ప్రస్తుత పేజీ: {lang}",
translatingTo: "అనువదిస్తోంది...",
detectingLanguage: "🔍 గుర్తిస్తోంది...",
},
// Turkish
tr: {
readPage: "📖 Sayfayı Oku",
ttsAnything: "🔊 TTS Her Şey",
translate: "🌐 Çevir",
smartTranslate: "🤖 Akıllı Çeviri",
uploadFile: "📁 Dosya Yükle",
modelLabel: "AI Modeli:",
voiceLabel: "TTS Sesi:",
languageLabel: "Arayüz Dili:",
quickActions: "Hızlı İşlemler:",
refreshModels: "🔄 Modelleri Yenile",
clearChat: "🗑️ Sohbeti Temizle",
fullChatRaw: "📋 Tam Sohbet Raw",
downloadChat: "💾 Sohbeti İndir",
sendButton: "Gönder",
typePlaceholder: "Mesajınızı yazın...",
clearChatConfirm: "Tüm konuşma geçmişi silinsin mi?",
pageContentPrefix: "Web sayfasının içeriği burada:",
additionalContext:
"Bu sayfaya gömülüsünüz. Soruları yanıtlarken, sayfa içeriğini ve bağlamını göz önünde bulundurun.",
languageChanged:
"Dil {lang} olarak ayarlandı. Tüm arayüz öğelerini tercih ettiğiniz dilde görmek için sayfayı yenileyin.",
translationFailed: "Çeviri başarısız: {error}",
ttsFailed: "TTS başarısız: {error}",
currentPage: "Geçerli sayfa: {lang}",
translatingTo: "Çevriliyor...",
detectingLanguage: "🔍 Algılanıyor...",
},
// Chinese (Traditional)
"zh-tw": {
readPage: "📖 閱讀頁面",
ttsAnything: "🔊 語音合成",
translate: "🌐 翻譯",
smartTranslate: "🤖 智慧翻譯",
uploadFile: "📁 上傳檔案",
modelLabel: "AI 模型:",
voiceLabel: "語音類型:",
languageLabel: "介面語言:",
quickActions: "快速操作:",
refreshModels: "🔄 重新整理模型",
clearChat: "🗑️ 清除聊天",
fullChatRaw: "📋 完整聊天記錄",
downloadChat: "💾 下載聊天",
sendButton: "傳送",
typePlaceholder: "輸入您的訊息...",
clearChatConfirm: "清除所有對話歷史記錄?",
pageContentPrefix: "以下是網頁內容:",
additionalContext: "您已嵌入此頁面。回答問題時,請考慮頁面內容和上下文。",
languageChanged:
"語言設定為 {lang}。重新整理頁面以查看所有介面元素的首選語言。",
translationFailed: "翻譯失敗:{error}",
ttsFailed: "語音合成失敗:{error}",
currentPage: "目前頁面:{lang}",
translatingTo: "翻譯中...",
detectingLanguage: "🔍 偵測中...",
},
// Korean
ko: {
readPage: "📖 페이지 읽기",
ttsAnything: "🔊 TTS 모두",
translate: "🌐 번역",
smartTranslate: "🤖 스마트 번역",
uploadFile: "📁 파일 업로드",
modelLabel: "AI 모델:",
voiceLabel: "TTS 음성:",
languageLabel: "인터페이스 언어:",
quickActions: "빠른 작업:",
refreshModels: "🔄 모델 새로고침",
clearChat: "🗑️ 채팅 지우기",
fullChatRaw: "📋 전체 채팅 Raw",
downloadChat: "💾 채팅 다운로드",
sendButton: "보내기",
typePlaceholder: "메시지를 입력하세요...",
clearChatConfirm: "모든 대화 기록을 지우시겠습니까?",
pageContentPrefix: "웹페이지 내용은 다음과 같습니다:",
additionalContext:
"이 페이지에 포함되어 있습니다. 질문에 답할 때 페이지 내용과 맥락을 고려하세요.",
languageChanged:
"언어가 {lang}(으)로 설정되었습니다. 모든 인터페이스 요소를 선호하는 언어로 보려면 페이지를 새로고침하세요.",
translationFailed: "번역 실패: {error}",
ttsFailed: "TTS 실패: {error}",
currentPage: "현재 페이지: {lang}",
translatingTo: "번역 중...",
detectingLanguage: "🔍 감지 중...",
},
};
// Get UI text in user's preferred language
function getUIText(key, params = {}) {
const lang = getUserLanguagePreference();
const translations = UI_TRANSLATIONS[lang] || UI_TRANSLATIONS.en;
let text = translations[key] || UI_TRANSLATIONS.en[key] || key;
// Replace parameters like {lang} with actual values
Object.entries(params).forEach(([param, value]) => {
text = text.replace(`{${param}}`, value);
});
return text;
}
// Language preference management
function getUserLanguagePreference() {
try {
return localStorage.getItem("uncloseai_language") || "en";
} catch (error) {
console.warn("Failed to read language preference:", error);
return "en";
}
}
function setUserLanguagePreference(langCode) {
try {
localStorage.setItem("uncloseai_language", langCode);
console.log("Language preference saved:", langCode);
// Trigger UI refresh when language changes
refreshUILanguage();
} catch (error) {
console.warn("Failed to save language preference:", error);
}
}
// Translation function for UI text
async function translateUIText(text, targetLang) {
if (targetLang === "en" || !text) return text;
try {
const { translateText } = await import("./translation.js");
const translated = await translateText(text, targetLang);
return translated || text;
} catch (error) {
console.warn("UI translation failed:", error);
return text;
}
}
// Refresh UI elements when language preference changes
function refreshUILanguage() {
console.log("UI language refresh triggered");
// Update existing button texts if modal is open
const readBtn = document.querySelector('[onclick*="readPageWithHermes"]');
if (readBtn) readBtn.textContent = getUIText("readPage");
const ttsBtn = document.querySelector('[onclick*="openTTSModal"]');
if (ttsBtn) ttsBtn.textContent = getUIText("ttsAnything");
const translateBtn = document.querySelector(
'[onclick*="openTranslateModal"]',
);
if (translateBtn) translateBtn.textContent = getUIText("translate");
// Update smart translate buttons
const smartTranslateBtns = document.querySelectorAll(
'[onclick*="handleSmartTranslate"]',
);
smartTranslateBtns.forEach(
(btn) => (btn.textContent = getUIText("smartTranslate")),
);
// Update labels if settings panel is open
const modelLabel = document.querySelector(
"#hermes-model-select",
)?.previousElementSibling;
if (modelLabel) modelLabel.textContent = getUIText("modelLabel");
const voiceLabel = document.querySelector(
"#hermes-voice-select",
)?.previousElementSibling;
if (voiceLabel) voiceLabel.textContent = getUIText("voiceLabel");
const langLabel = document.querySelector(
"#hermes-language-select",
)?.previousElementSibling;
if (langLabel) langLabel.textContent = getUIText("languageLabel");
}
// Theme detection and styling functions
function detectCurrentTheme() {
const isDark =
document.documentElement.getAttribute("data-theme") === "dark" ||
(window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches &&
!document.documentElement.getAttribute("data-theme"));
return isDark ? "dark" : "light";
}
function getThemeColors(theme = detectCurrentTheme()) {
if (theme === "dark") {
return {
// Main backgrounds
modalBackground: "#1a1a1a",
contentBackground: "#2d2d2d",
panelBackground: "#262626",
inputBackground: "#363636",
// Text colors
primaryText: "#ffffff",
secondaryText: "#b3b3b3",
mutedText: "#808080",
// Borders and dividers
borderColor: "#404040",
dividerColor: "#333333",
// Interactive elements
buttonBackground: "#404040",
buttonHover: "#4a4a4a",
buttonBorder: "#555555",
// Message bubbles
userMessageBg: "#4a5568",
userMessageText: "#ffffff",
aiMessageBg: "#363636",
aiMessageText: "#ffffff",
// Input focus
focusBorder: "#667eea",
// Shadows (adjusted for dark theme)
shadowColor: "rgba(0,0,0,0.5)",
lightShadow: "rgba(0,0,0,0.3)",
// Action buttons
actionBg: "rgba(255, 255, 255, 0.1)",
actionBgHover: "rgba(255, 255, 255, 0.2)",
actionBorder: "rgba(255, 255, 255, 0.2)",
};
} else {
return {
// Main backgrounds
modalBackground: "#ffffff",
contentBackground: "#f8f9fa",
panelBackground: "#ffffff",
inputBackground: "#ffffff",
// Text colors
primaryText: "#212529",
secondaryText: "#495057",
mutedText: "#6c757d",
// Borders and dividers
borderColor: "#dee2e6",
dividerColor: "#e9ecef",
// Interactive elements
buttonBackground: "#f8f9fa",
buttonHover: "#e9ecef",
buttonBorder: "#dee2e6",
// Message bubbles
userMessageBg: "#667eea",
userMessageText: "#ffffff",
aiMessageBg: "#ffffff",
aiMessageText: "#212529",
// Input focus
focusBorder: "#667eea",
// Shadows
shadowColor: "rgba(0,0,0,0.3)",
lightShadow: "rgba(0,0,0,0.1)",
// Action buttons
actionBg: "rgba(255, 255, 255, 0.2)",
actionBgHover: "rgba(255, 255, 255, 0.3)",
actionBorder: "rgba(255, 255, 255, 0.3)",
};
}
}
// Ensure font is loaded when module is imported
if (typeof document !== "undefined") {
initializeChunkFiveFont();
}
// 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 (e) => {
e.stopPropagation();
try {
const codeText = codeBlock.textContent;
await navigator.clipboard.writeText(codeText);
const originalText = copyBtn.textContent;
copyBtn.textContent = "✓";
setTimeout(() => {
copyBtn.textContent = originalText;
}, 1000);
} catch (error) {
alert("Failed to copy code: " + error.message);
}
};
pre.appendChild(copyBtn);
}
});
}
// Send message with custom history (for intro generation)
async function* sendMessageWithCustomHistory(messageHistory) {
const { getSelectedModel, getSelectedModelEndpoint } = await import(
"./models.js"
);
const apiUrl = `${getSelectedModelEndpoint()}/chat/completions`;
const response = await fetch(apiUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
},
body: JSON.stringify({
model: getSelectedModel(),
messages: messageHistory,
temperature: 0.3,
max_tokens: 8192,
stream: true,
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
for (let i = 0; i < lines.length - 1; i++) {
const line = lines[i].trim();
if (line.startsWith("data: ")) {
const jsonData = line.slice(6);
if (jsonData === "[DONE]") continue;
try {
const parsedData = JSON.parse(jsonData);
const content = parsedData.choices[0].delta.content;
if (content) {
yield content;
}
} catch (error) {
console.error("Error parsing JSON:", error);
}
}
}
buffer = lines[lines.length - 1];
}
}
// Simple TTS function for chat messages (no preprocessing needed)
async function speakChatText(text, voice = "alloy", rate = 1.0) {
try {
const response = await fetch(TTS_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
},
body: JSON.stringify({
model: "tts-1",
voice: voice,
input: text,
}),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const audioBlob = await response.blob();
const audioUrl = URL.createObjectURL(audioBlob);
const audio = new Audio(audioUrl);
audio.playbackRate = rate;
return { audio, blob: audioBlob };
} catch (error) {
console.error("Error in chat TTS:", error);
throw error;
}
}
import { chatHistory, handleUserInput, sendMessage } from "./chat.js";
import {
handleFileUpload,
hideProgressIndicator,
showProgressIndicator,
uploadFile,
} from "./file-upload.js";
import {
fetchModelsFromEndpoints,
getSelectedModel,
getSelectedModelEndpoint,
modelRegistry,
} from "./models.js";
import { readPageWithHermes } from "./page-reader.js";
import {
clearConversationHistory,
getPageSpecificKey,
loadConversationHistory,
saveConversationHistory,
} from "./storage.js";
import {
SUPPORTED_LANGUAGES,
translateCurrentPage,
translateText,
} from "./translation.js";
// Configuration flags
const USE_CUSTOM_STYLING = window.UNCLOSEAI_CUSTOM_STYLING !== false;
const SHOW_FLOATING_BUTTON = window.UNCLOSEAI_FLOATING_BUTTON !== false;
// Global variable to track modal state
let uncloseaiEmbeddedModalOpen = false;
// Initialize uncloseai elements based on class
export function initializeUncloseaiElements() {
const uncloseaiElements = document.querySelectorAll(".uncloseai");
uncloseaiElements.forEach((element) => {
const features = element.dataset.features || "full";
const type = element.dataset.type || "standard";
// Create container for this uncloseai instance
const container = document.createElement("div");
container.className = "uncloseai-container";
container.style.cssText = "width: 100%; margin: 10px 0;";
if (features === "full" || type === "full") {
createFullInterface(container);
} else {
createCustomInterface(container, features.split(","));
}
element.appendChild(container);
});
}
// Create full chat interface
export function createFullInterface(container) {
// Chat area
const chatContainer = document.createElement("div");
chatContainer.innerHTML = `
<div id="chat-box" style="min-height: 200px; border: 1px solid #ccc; padding: 10px; margin-bottom: 10px; overflow-y: auto; border-radius: 4px;"></div>
<div style="display: flex; gap: 10px; margin-bottom: 10px;">
<input type="text" id="user-input" placeholder="Ask about this page..." style="flex: 1; padding: 8px; border: 1px solid #ccc; border-radius: 4px;">
<button onclick="handleUserInput()" style="padding: 8px 16px; background: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer;">Send</button>
</div>
`;
// Control buttons
const controlsDiv = document.createElement("div");
controlsDiv.style.cssText =
"display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 8px; margin-bottom: 10px;";
const readBtn = createButton(getUIText("readPage"), () =>
readPageWithHermes(),
);
const ttsBtn = createButton(getUIText("ttsAnything"), () => openTTSModal());
const translateBtn = createButton(getUIText("translate"), () =>
openTranslateModal(),
);
controlsDiv.appendChild(readBtn);
controlsDiv.appendChild(ttsBtn);
controlsDiv.appendChild(translateBtn);
// Hidden file input
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.setAttribute("data-uncloseai-file-input", "");
fileInput.style.display = "none";
fileInput.onchange = handleFileUpload;
container.appendChild(chatContainer);
container.appendChild(controlsDiv);
container.appendChild(fileInput);
}
// Create custom interface with specific features
export function createCustomInterface(container, features) {
const div = document.createElement("div");
div.style.cssText =
"display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; padding: 15px; border: 1px solid #ddd; border-radius: 8px;";
features.forEach((feature) => {
switch (feature.trim()) {
case "chat":
createChatFeature(div);
break;
case "tts":
createTTSFeature(div);
break;
case "translate":
createTranslateFeature(div);
break;
case "smart-translate":
createSmartTranslateFeature(div);
break;
case "upload":
createUploadFeature(div);
break;
case "read":
createReadFeature(div);
break;
}
});
container.appendChild(div);
}
// Individual feature creators
export function createChatFeature(container) {
const chatDiv = document.createElement("div");
chatDiv.innerHTML = `
<h4>AI Chat</h4>
<div style="border: 1px solid #ccc; height: 150px; padding: 8px; margin: 5px 0; overflow-y: auto;" data-chat-box></div>
<input type="text" placeholder="Ask anything..." style="width: 100%; margin: 2px 0;" data-chat-input>
<button onclick="handleCustomChat(this)" style="width: 100%; padding: 6px;">Send</button>
`;
container.appendChild(chatDiv);
}
export function createTTSFeature(container) {
const ttsDiv = document.createElement("div");
ttsDiv.innerHTML = `
<h4>Text to Speech</h4>
<textarea placeholder="Enter text to speak..." style="width: 100%; height: 80px; margin: 5px 0;" data-tts-input></textarea>
<button onclick="handleTTSFromElement(this)" style="width: 100%; padding: 6px;">🔊 Convert to Speech</button>
<div data-tts-result style="margin: 5px 0;"></div>
`;
container.appendChild(ttsDiv);
}
export function createUploadFeature(container) {
const uploadDiv = document.createElement("div");
uploadDiv.innerHTML = `
<h4>File Upload</h4>
<input type="file" style="width: 100%; margin: 5px 0;" data-upload-input>
<button onclick="handleUploadFromElement(this)" style="width: 100%; padding: 6px;">📁 Upload & Analyze</button>
<div data-upload-result style="margin: 5px 0; display: none;"></div>
`;
container.appendChild(uploadDiv);
}
export function createTranslateFeature(container) {
const translateDiv = document.createElement("div");
translateDiv.innerHTML = `
<h4>Translation Modal</h4>
<button onclick="openTranslateModal()" style="width: 100%; padding: 6px;">🌐 Translation Modal</button>
`;
container.appendChild(translateDiv);
}
export function createSmartTranslateFeature(container) {
const smartTranslateDiv = document.createElement("div");
const button = document.createElement("button");
button.textContent = getUIText("smartTranslate");
button.style.cssText = "width: 100%; padding: 6px;";
button.onclick = () => handleSmartTranslate(button);
const heading = document.createElement("h4");
heading.textContent = "Smart Translate";
smartTranslateDiv.appendChild(heading);
smartTranslateDiv.appendChild(button);
container.appendChild(smartTranslateDiv);
}
export function createReadFeature(container) {
const readDiv = document.createElement("div");
readDiv.innerHTML = `
<h4>Page Reader</h4>
<p style="font-size: 0.9em; margin: 5px 0;">Read this page with AI voice</p>
<button onclick="readPageWithHermes()" style="width: 100%; padding: 8px 16px; background: #6f42c1; color: white; border: none; border-radius: 4px; cursor: pointer;">📖 Read Page with AI</button>
`;
container.appendChild(readDiv);
}
// Helper functions for custom features
export function createButton(text, onclick) {
const btn = document.createElement("button");
btn.textContent = text;
btn.onclick = onclick;
btn.style.cssText =
"padding: 8px 12px; border: 1px solid #ccc; border-radius: 4px; background: white; cursor: pointer;";
return btn;
}
export async function handleTTSFromElement(button) {
const container =
button.closest("[data-tts-result]")?.parentElement || button.parentElement;
const textarea = container.querySelector("[data-tts-input]");
const resultDiv = container.querySelector("[data-tts-result]");
const text = textarea?.value?.trim();
if (!text) {
alert("Please enter some text first!");
return;
}
button.disabled = true;
button.textContent = "Converting...";
resultDiv.innerHTML = "<em>Converting to speech...</em>";
try {
const result = await speakText(text, "alloy", 0.9);
const audioControls = document.createElement("div");
audioControls.style.cssText = "margin: 10px 0;";
const playButton = document.createElement("button");
playButton.textContent = "▶️ Play";
playButton.style.cssText = "margin: 2px; padding: 4px 8px;";
playButton.onclick = () => result.audio.play();
const pauseButton = document.createElement("button");
pauseButton.textContent = "⏸️ Pause";
pauseButton.style.cssText = "margin: 2px; padding: 4px 8px;";
pauseButton.onclick = () => result.audio.pause();
audioControls.appendChild(playButton);
audioControls.appendChild(pauseButton);
resultDiv.innerHTML = "";
resultDiv.appendChild(result.audio);
resultDiv.appendChild(audioControls);
} catch (error) {
resultDiv.innerHTML = "<strong>Error:</strong> " + error.message;
} finally {
button.disabled = false;
button.textContent = "🔊 Convert to Speech";
}
}
export async function handleUploadFromElement(button) {
const container = button.parentElement;
const fileInput = container.querySelector("[data-upload-input]");
const resultDiv = container.querySelector("[data-upload-result]");
if (!fileInput.files[0]) {
alert("Please select a file first!");
return;
}
button.disabled = true;
button.textContent = "Processing...";
resultDiv.style.display = "block";
resultDiv.innerHTML = "<em>Uploading and analyzing file...</em>";
try {
showProgressIndicator("Processing file...");
const response = await uploadFile(fileInput.files[0]);
hideProgressIndicator();
resultDiv.innerHTML = `<strong>Analysis Result:</strong><br>${response}`;
fileInput.value = "";
} catch (error) {
hideProgressIndicator();
resultDiv.innerHTML = "<strong>Error:</strong> " + error.message;
} finally {
button.disabled = false;
button.textContent = "📁 Upload & Analyze";
}
}
export async function handleSmartTranslate(button) {
console.log("🤖 Smart translate clicked!", button);
// Check if dropdown already exists
const existingDropdown = button.parentElement.querySelector(
".translate-dropdown",
);
if (existingDropdown) {
existingDropdown.remove();
return;
}
// Detect page language
button.textContent = getUIText("detectingLanguage");
button.disabled = true;
try {
const currentLang = await detectPageLanguage();
button.textContent = getUIText("smartTranslate");
button.disabled = false;
// Import supported languages and native names
const { SUPPORTED_LANGUAGES, NATIVE_LANGUAGE_NAMES, translateCurrentPage } =
await import("./translation.js");
const colors = getThemeColors();
// 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 =
NATIVE_LANGUAGE_NAMES[currentLang] ||
SUPPORTED_LANGUAGES[currentLang] ||
"Unknown";
currentLangDiv.textContent = getUIText("currentPage", {
lang: currentLangName,
});
dropdown.appendChild(currentLangDiv);
// Add translation options with code, English, and native names
Object.entries(NATIVE_LANGUAGE_NAMES).forEach(([code, nativeName]) => {
// Skip current language
if (code === currentLang) return;
const englishName = SUPPORTED_LANGUAGES[code];
const option = document.createElement("div");
option.style.cssText = `
padding: 8px 12px;
cursor: pointer;
transition: background-color 0.2s;
color: ${colors.primaryText};
font-size: 14px;
line-height: 1.4;
`;
// Format: "es • Spanish • Español"
option.innerHTML = `
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-family: monospace; color: ${colors.mutedText}; font-size: 12px; min-width: 24px;">${code}</span>
<span style="color: ${colors.mutedText};">•</span>
<span style="color: ${colors.secondaryText};">${englishName}</span>
<span style="color: ${colors.mutedText};">•</span>
<span style="font-weight: 500;">${nativeName}</span>
</div>
`;
option.onmouseenter = () => {
option.style.background = colors.buttonHover;
};
option.onmouseleave = () => {
option.style.background = "transparent";
};
option.onclick = async () => {
try {
// Show loading
option.innerHTML = `
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-family: monospace; color: ${colors.mutedText}; font-size: 12px; min-width: 24px;">${code}</span>
<span style="color: ${colors.mutedText};">•</span>
<span style="color: ${colors.secondaryText};">${getUIText("translatingTo")}</span>
</div>
`;
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}`);
// Restore original format
option.innerHTML = `
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-family: monospace; color: ${colors.mutedText}; font-size: 12px; min-width: 24px;">${code}</span>
<span style="color: ${colors.mutedText};">•</span>
<span style="color: ${colors.secondaryText};">${englishName}</span>
<span style="color: ${colors.mutedText};">•</span>
<span style="font-weight: 500;">${nativeName}</span>
</div>
`;
option.style.pointerEvents = "auto";
}
};
dropdown.appendChild(option);
});
// Position dropdown relative to button
button.style.position = "relative";
button.parentElement.appendChild(dropdown);
// Close dropdown when clicking outside
const closeDropdown = (e) => {
if (!dropdown.contains(e.target) && e.target !== button) {
dropdown.remove();
document.removeEventListener("click", closeDropdown);
}
};
setTimeout(() => document.addEventListener("click", closeDropdown), 100);
} catch (error) {
button.textContent = getUIText("smartTranslate");
button.disabled = false;
alert(`Language detection failed: ${error.message}`);
}
}
// Initialize the legacy chat interface (for backward compatibility)
export function initializeChatInterface() {
// Only initialize if there are legacy elements (chat-container, user-input, etc.)
const legacyElements = document.querySelector(
"#chat-container, #user-input, #chat-box",
);
if (!legacyElements) return;
const pageContent = extractWebpageContent();
chatHistory.push({
role: "system",
content: `Here's the content of the webpage: ${pageContent}`,
});
marked.setOptions({
highlight: (code, lang) => {
const language = hljs.getLanguage(lang) ? lang : "plaintext";
return hljs.highlight(code, { language }).value;
},
});
// Add the Read Page button
addReadPageButton();
// Add the File Upload button and picker
addFileUploadButton();
// Event listeners
document
.getElementById("user-input")
?.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
handleUserInput();
}
});
}
// Add button to page
export function addReadPageButton() {
const button = document.createElement("button");
button.textContent = "Read Page";
button.onclick = readPageWithHermes;
button.style.margin = "10px";
const ttsButton = document.createElement("button");
ttsButton.textContent = "TTS Anything";
ttsButton.onclick = openTTSModal;
ttsButton.style.margin = "10px";
document.body.appendChild(button);
document.body.appendChild(ttsButton);
}
// Add file upload button
export function addFileUploadButton() {
const button = document.createElement("button");
button.textContent = "Upload File";
button.style.margin = "10px";
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.id = "file-input";
fileInput.style.display = "none";
button.onclick = () => fileInput.click();
fileInput.onchange = handleFileUpload;
document.body.appendChild(button);
document.body.appendChild(fileInput);
}
// Function to create floating AI button
export function createFloatingAIButton() {
console.log("uncloseai.js: createFloatingAIButton() called.");
// Ensure ChunkFive font is loaded
initializeChunkFiveFont();
// Remove any existing floating button first
const existingButton = document.getElementById("floating-ai-button");
if (existingButton) {
console.log("uncloseai.js: Found existing floating button, removing it.");
existingButton.remove();
}
// Create the main floating button
const floatingButton = document.createElement("button");
floatingButton.id = "floating-ai-button";
floatingButton.textContent = "uncloseai.";
console.log("uncloseai.js: Created new floating button element.");
// Function to update button theme
function updateButtonTheme() {
const isDark =
document.documentElement.getAttribute("data-theme") === "dark" ||
(window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches &&
!document.documentElement.getAttribute("data-theme"));
floatingButton.style.cssText = `
position: fixed;
bottom: 20px;
right: 10px;
width: 110px;
height: 55px;
border-radius: 22px;
background: ${isDark ? "#ffffff" : "#000000"};
border: 2px solid ${isDark ? "#000000" : "#ffffff"};
color: ${isDark ? "#000000" : "#ffffff"};
font-family: 'ChunkFiveRegular', monospace;
font-size: 13px;
cursor: pointer;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
z-index: 1000;
transition: all 0.3s ease;
max-width: calc(100vw - 20px);
box-sizing: border-box;
`;
}
// Initial theme setup
updateButtonTheme();
// Watch for theme changes
const observer = new MutationObserver(updateButtonTheme);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme"],
});
// Watch for system theme changes
if (window.matchMedia) {
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", updateButtonTheme);
}
// Hover effects
floatingButton.onmouseenter = () => {
floatingButton.style.transform = "scale(1.1)";
floatingButton.style.boxShadow = "0 6px 16px rgba(0,0,0,0.4)";
};
floatingButton.onmouseleave = () => {
floatingButton.style.transform = "scale(1)";
floatingButton.style.boxShadow = "0 4px 12px rgba(0,0,0,0.3)";
};
// Set up self-contained click handler
setupFloatingButtonHandler(floatingButton);
document.body.appendChild(floatingButton);
console.log("uncloseai.js: Appended floating button to document.body.");
}
// Set up click handler for floating button (self-contained)
function setupFloatingButtonHandler(floatingButton) {
floatingButton.onclick = async () => {
console.log("Floating button clicked!");
// Show loading state
const originalText = floatingButton.textContent;
floatingButton.textContent = "loading...";
floatingButton.disabled = true;
floatingButton.style.opacity = "0.7";
floatingButton.style.cursor = "wait";
try {
console.log("Trying to toggle Hermes modal...");
// Try multiple ways to access the modal function
let modalFunction = null;
if (typeof toggleUncloseaiEmbeddedModal === "function") {
console.log("Found toggleUncloseaiEmbeddedModal in local scope");
modalFunction = toggleUncloseaiEmbeddedModal;
} else if (typeof window.toggleUncloseaiEmbeddedModal === "function") {
console.log("Found toggleUncloseaiEmbeddedModal on window");
modalFunction = window.toggleUncloseaiEmbeddedModal;
} else {
console.log(
"toggleUncloseaiEmbeddedModal not found, trying dynamic import",
);
// Try to import it dynamically as a fallback
try {
const uiModule = await import("./ui.js");
if (uiModule.toggleUncloseaiEmbeddedModal) {
console.log("Successfully imported toggleUncloseaiEmbeddedModal");
modalFunction = uiModule.toggleUncloseaiEmbeddedModal;
}
} catch (importError) {
console.error(
"Could not import toggleUncloseaiEmbeddedModal:",
importError,
);
}
}
if (modalFunction) {
console.log("Calling modal function");
await modalFunction();
} else {
console.error(
"toggleUncloseaiEmbeddedModal function not available in any form",
);
alert("AI modal not available in this context");
}
} catch (error) {
console.error("Error opening modal:", error);
alert("Error opening AI modal: " + error.message);
} finally {
// Restore button state
floatingButton.textContent = originalText;
floatingButton.disabled = false;
floatingButton.style.opacity = "1";
floatingButton.style.cursor = "pointer";
}
};
}
// New mobile-first Hermes modal
async function openUncloseaiEmbeddedModalNew() {
// Ensure ChunkFive font is loaded
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;\">uncloseai.</span>";
title.style.cssText = `
margin: 0;
font-size: ${isMobile ? "18px" : "20px"};
font-weight: 600;
`;
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;">
<span style="font-family: 'ChunkFiveRegular', monospace;">uncloseai.</span> presents nous research's <a href="https://nousresearch.com/hermes3/" target="_blank" style="color: rgba(255,255,255,0.9); text-decoration: underline;">hermes</a> large language model<br>
<span style="opacity: 0.8;">You are discussing: ${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: ${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: ${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: ${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}`);
// 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 = "";
clearConversationHistory();
// Also clear the chat history in the chat.js module
const systemMsg = chatHistory.find((msg) => msg.role === "system");
chatHistory.length = 0; // Clear array
if (systemMsg) chatHistory.push(systemMsg);
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: "📖 Read Page",
action: async (button) => {
// If already playing, pause
if (readPageAudio && !readPageAudio.paused) {
readPageAudio.pause();
button.textContent = "📖 Read Page";
isReadingPage = false;
return;
}
// If paused, resume
if (
readPageAudio &&
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 = "⏳ Processing...";
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 = "📖 Read Page";
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 = "📖 Read Page";
isReadingPage = false;
}
} catch (error) {
alert("Failed to read page: " + error.message);
button.textContent = "📖 Read Page";
isReadingPage = false;
} finally {
button.disabled = false;
}
},
},
{ text: "🔊 TTS Anything", action: (btn) => window.openTTSModal() },
{
text: "🌐 Translation Modal",
action: (btn) => window.openTranslateModal(),
},
{
text: "🤖 Smart Translate",
action: async (btn) => {
// Create dropdown instead of opening modal
if (
btn.nextElementSibling &&
btn.nextElementSibling.classList.contains("translate-dropdown")
) {
// Toggle existing dropdown
btn.nextElementSibling.remove();
return;
}
// Detect page language
btn.textContent = "🔍 Detecting...";
btn.disabled = true;
try {
const currentLang = await detectPageLanguage();
btn.textContent = "🤖 Smart Translate";
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 = "🤖 Smart Translate";
btn.disabled = false;
alert(`Language detection failed: ${error.message}`);
}
},
},
{
text: "📋 Full Chat Raw",
action: async () => {
try {
const { getChatHistory } = await import("./chat.js");
const history = 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("Full chat copied as raw markdown!");
} catch (error) {
alert("Failed to copy: " + error.message);
}
},
},
{
text: "📄 Full Chat HTML",
action: async () => {
try {
const { getChatHistory } = await import("./chat.js");
const history = 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("Full chat copied as HTML!");
} catch (error) {
alert("Failed to copy: " + error.message);
}
},
},
{
text: "📝 Full Page Raw",
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("Full page copied as markdown!");
} catch (error) {
alert("Failed to copy page: " + 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 = "Ask about this page...";
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: ${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.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 && 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;
// 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 = () => {
// Remove from chat history
const currentHistory = getChatHistory();
const updatedHistory = currentHistory.filter(
(msg) => !(msg.role === "user" && msg.content === message),
);
updateChatHistory(updatedHistory);
saveConversationHistory(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 = getChatHistory();
currentHistory.push({ role: "assistant", content: response });
updateChatHistory(currentHistory);
// Save conversation history after successful response
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 &&
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;
// 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 = () => {
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 = 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
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.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;
// 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 = loadConversationHistory();
if (index >= 0 && index < currentHistory.length) {
currentHistory.splice(index, 1);
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,
];
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;
// 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 = () => {
aiMsg.remove();
// TODO: Remove from chat history
};
// 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
const introSystemPrompt = `You are Hermes AI, powered by Nous Research, embedded on a webpage. Your task is to generate a warm, welcoming 3-paragraph introduction that shows you understand the specific page the user is viewing.
PAGE INFORMATION:
Title: "${pageTitle}"
URL: ${window.location.href}
FULL PAGE CONTENT:
${pageContent}
Generate a 3-paragraph introduction that:
1. Introduces yourself as Hermes AI and acknowledges the specific page/content
2. Explains how you can help with this page content and related topics
3. Invites questions and explains your capabilities
Reference specific details from the page content to show understanding.`;
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
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 = () => {
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
saveConversationHistory(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
export async function toggleUncloseaiEmbeddedModal() {
const existingModal = document.getElementById("uncloseai-embedded-modal");
if (existingModal) {
document.body.removeChild(existingModal);
uncloseaiEmbeddedModalOpen = false;
} else {
await openUncloseaiEmbeddedModalNew();
uncloseaiEmbeddedModalOpen = true;
}
}
// Function to open Hermes modal - Mobile-first design
export async function openUncloseaiEmbeddedModal() {
const modal = document.createElement("dialog");
modal.id = "uncloseai-embedded-modal";
// Mobile-first: full screen on mobile, centered on desktop
const isMobile = window.innerWidth <= 768;
if (isMobile) {
modal.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
border: none;
border-radius: 0;
background: white;
margin: 0;
padding: 0;
z-index: 2000;
overflow: hidden;
`;
} else {
modal.style.cssText = `
position: fixed;
width: 90vw;
max-width: 800px;
height: 90vh;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border: none;
border-radius: 16px;
box-shadow: 0 20px 40px rgba(0,0,0,0.3);
background: white;
margin: 0;
padding: 0;
z-index: 2000;
overflow: hidden;
`;
}
const article = document.createElement("article");
if (USE_CUSTOM_STYLING) {
article.style.cssText = `
width: 100%;
max-width: 100%;
box-sizing: border-box;
height: 100%;
display: grid;
grid-template-rows: auto auto 1fr auto;
margin: 0;
padding: 0;
`;
} else {
// Responsive scaling based on screen width
const screenWidth = window.innerWidth;
const scale = screenWidth < 480 ? 0.9 : screenWidth < 768 ? 0.8 : 0.75;
article.style.cssText = `
height: 100%;
display: grid;
grid-template-rows: auto auto 1fr auto;
transform: scale(${scale});
transform-origin: top center;
margin: 0;
padding: 0;
min-width: 0;
max-width: none;
box-sizing: border-box;
`;
}
modal.appendChild(article);
// Handle viewport changes (onscreen keyboard)
if (!USE_CUSTOM_STYLING) {
const handleViewportChange = () => {
// Use dvh (dynamic viewport height) for better mobile keyboard handling
modal.style.height = "100dvh";
// Fallback for browsers that don't support dvh
if (window.visualViewport) {
modal.style.height = `${window.visualViewport.height}px`;
}
};
// Listen for visual viewport changes (keyboard open/close)
if (window.visualViewport) {
window.visualViewport.addEventListener("resize", handleViewportChange);
}
// Also listen for window resize as fallback
window.addEventListener("resize", handleViewportChange);
// Initial call
handleViewportChange();
}
// Create modal header
const header = document.createElement("div");
if (USE_CUSTOM_STYLING) {
header.style.cssText = `
background: linear-gradient(45deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 16px 20px;
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
`;
} else {
header.style.cssText = `
padding: 16px 20px;
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
border-bottom: 1px solid #ccc;
`;
}
const titleContainer = document.createElement("div");
const title = document.createElement("h2");
title.innerHTML =
'<span style="font-family: \'ChunkFiveRegular\', monospace;">uncloseai.</span> presents nous research\'s <a href="https://nousresearch.com/hermes3/" target="_blank" style="color: inherit; text-decoration: underline;">hermes</a> large language model';
if (USE_CUSTOM_STYLING) {
title.style.cssText = `
margin: 0;
font-family: 'ChunkFiveRegular', monospace;
font-size: 16px;
line-height: 1.2;
`;
} else {
title.style.cssText = `
margin: 0;
font-size: 0.9em;
line-height: 1.2;
`;
}
const pageTitle = document.createElement("div");
pageTitle.textContent = `You are discussing: ${document.title}`;
if (USE_CUSTOM_STYLING) {
pageTitle.style.cssText = `
font-size: 12px;
opacity: 0.8;
margin-top: 4px;
`;
} else {
pageTitle.style.cssText = `
font-size: 0.75em;
opacity: 0.7;
margin-top: 4px;
`;
}
titleContainer.appendChild(title);
titleContainer.appendChild(pageTitle);
const closeButton = document.createElement("button");
closeButton.textContent = "×";
if (USE_CUSTOM_STYLING) {
closeButton.style.cssText = `
background: none;
border: none;
color: white;
font-size: 24px;
cursor: pointer;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
`;
} else {
closeButton.style.cssText = `
background: var(--background-color);
border: 1px solid var(--border-color);
color: var(--color);
font-size: 18px;
cursor: pointer;
padding: 4px;
width: 28px;
height: 28px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.2s;
`;
closeButton.onmouseenter = () => (closeButton.style.opacity = "0.7");
closeButton.onmouseleave = () => (closeButton.style.opacity = "1");
}
closeButton.onclick = () => {
document.body.removeChild(modal);
uncloseaiEmbeddedModalOpen = false;
};
header.appendChild(titleContainer);
header.appendChild(closeButton);
// Create controls section
const controls = document.createElement("div");
if (USE_CUSTOM_STYLING) {
controls.style.cssText = `
padding: 16px 20px;
border-bottom: 1px solid #e0e0e0;
display: grid;
grid-template-columns: auto 1fr auto 1fr;
gap: 12px;
align-items: center;
`;
} else {
controls.className = "uncloseai-controls";
controls.style.cssText = `
display: grid;
grid-template-columns: auto 1fr auto 1fr;
gap: 12px;
padding: 0.5rem;
align-items: center;
`;
}
// Add model selection dropdown
const modelLabel = document.createElement("label");
modelLabel.textContent = "Model: ";
if (USE_CUSTOM_STYLING) {
modelLabel.style.fontWeight = "bold";
}
const modelSelect = document.createElement("select");
modelSelect.id = "modal-model-selection";
if (USE_CUSTOM_STYLING) {
modelSelect.style.cssText = `
padding: 6px 12px;
border: 1px solid #ccc;
border-radius: 4px;
background: white;
`;
}
// Add loading placeholder
const loadingOption = document.createElement("option");
loadingOption.textContent = "Loading models...";
loadingOption.disabled = true;
modelSelect.appendChild(loadingOption);
// Populate model dropdown dynamically (async)
fetchModelsFromEndpoints()
.then((models) => {
modelSelect.innerHTML = ""; // Clear loading option
models.forEach((model) => {
const option = document.createElement("option");
option.value = model.uniqueId;
option.textContent = `${model.endpointId} | ${model.modelName}`;
modelSelect.appendChild(option);
});
// Restore saved model selection after loading
const savedModel = localStorage.getItem("hermes-selected-model");
if (
savedModel &&
modelSelect.querySelector(`option[value="${savedModel}"]`)
) {
modelSelect.value = savedModel;
}
})
.catch((error) => {
console.error("Error loading models:", error);
modelSelect.innerHTML = "";
const errorOption = document.createElement("option");
errorOption.textContent = "Error loading models";
errorOption.disabled = true;
modelSelect.appendChild(errorOption);
});
// Save model selection on change
modelSelect.addEventListener("change", () => {
localStorage.setItem("hermes-selected-model", modelSelect.value);
});
// Add voice selection with newline
const voiceBreak = document.createElement("div");
voiceBreak.style.width = "100%";
const voiceLabel = document.createElement("label");
voiceLabel.textContent = "Voice: ";
if (USE_CUSTOM_STYLING) {
voiceLabel.style.fontWeight = "bold";
}
const voiceSelect = document.createElement("select");
voiceSelect.id = "modal-voice-selection";
if (USE_CUSTOM_STYLING) {
voiceSelect.style.cssText = `
padding: 6px 12px;
border: 1px solid #ccc;
border-radius: 4px;
background: white;
`;
}
const voices = ["alloy", "echo", "fable", "onyx", "nova", "shimmer"];
voices.forEach((voice) => {
const option = document.createElement("option");
option.value = voice;
option.textContent = voice;
if (voice === "alloy") option.selected = true;
voiceSelect.appendChild(option);
});
// Restore saved voice selection
const savedVoice = localStorage.getItem("hermes-selected-voice");
if (savedVoice && voices.includes(savedVoice)) {
voiceSelect.value = savedVoice;
}
// Save voice selection on change
voiceSelect.addEventListener("change", () => {
localStorage.setItem("hermes-selected-voice", voiceSelect.value);
});
// Add action buttons
const actionButtons = document.createElement("div");
if (USE_CUSTOM_STYLING) {
actionButtons.style.cssText = `
display: grid;
grid-template-columns: repeat(auto-fit, minmax(60px, 1fr));
gap: 4px;
`;
} else {
actionButtons.className = "uncloseai-button-group";
actionButtons.style.cssText = `
display: grid;
grid-template-columns: repeat(auto-fit, minmax(60px, 1fr));
gap: 4px;
`;
}
const readPageBtn = document.createElement("button");
readPageBtn.textContent = "📖 Read Page";
if (USE_CUSTOM_STYLING) {
readPageBtn.style.cssText =
"padding: 6px 12px; border: 1px solid #ccc; border-radius: 4px; background: white; cursor: pointer;";
}
readPageBtn.onclick = readPageWithHermes;
const ttsBtn = document.createElement("button");
ttsBtn.textContent = "🔊 TTS Anything";
if (USE_CUSTOM_STYLING) {
ttsBtn.style.cssText =
"padding: 6px 12px; border: 1px solid #ccc; border-radius: 4px; background: white; cursor: pointer;";
}
ttsBtn.onclick = openTTSModal;
const refreshBtn = document.createElement("button");
refreshBtn.textContent = "🔄 Refresh";
if (USE_CUSTOM_STYLING) {
refreshBtn.style.cssText =
"padding: 6px 12px; border: 1px solid #ccc; border-radius: 4px; background: white; cursor: pointer;";
}
refreshBtn.onclick = async () => {
// Clear cache and refresh models
localStorage.removeItem("modelRegistryCache");
localStorage.removeItem("vllmEndpointsHash");
const models = await fetchModelsFromEndpoints();
// Update modal dropdown
modelSelect.innerHTML = "";
models.forEach((model) => {
const option = document.createElement("option");
option.value = model.uniqueId;
option.textContent = `${model.endpointId} | ${model.modelName}`;
modelSelect.appendChild(option);
});
// Update main page dropdown if it exists
const mainDropdown = document.getElementById("model-selection");
if (mainDropdown) {
mainDropdown.innerHTML = "";
models.forEach((model) => {
const option = document.createElement("option");
option.value = model.uniqueId;
option.textContent = `${model.endpointId} | ${model.modelName}`;
mainDropdown.appendChild(option);
});
}
};
const clearBtn = document.createElement("button");
clearBtn.textContent = "🗑️ Clear";
if (USE_CUSTOM_STYLING) {
clearBtn.style.cssText =
"padding: 6px 12px; border: 1px solid #ccc; border-radius: 4px; background: white; cursor: pointer;";
}
clearBtn.onclick = async () => {
if (confirm("Clear all conversation history?")) {
clearConversationHistory();
chatBox.innerHTML = "";
await addHermesIntroduction(); // Call addHermesIntroduction after clearing
}
};
actionButtons.appendChild(readPageBtn);
actionButtons.appendChild(ttsBtn);
actionButtons.appendChild(refreshBtn);
actionButtons.appendChild(clearBtn);
// Make action buttons span all columns
actionButtons.style.gridColumn = "span 4";
controls.appendChild(actionButtons);
controls.appendChild(modelLabel);
controls.appendChild(modelSelect);
controls.appendChild(voiceLabel);
controls.appendChild(voiceSelect);
// Create chat area
const chatArea = document.createElement("div");
if (USE_CUSTOM_STYLING) {
chatArea.style.cssText = `
grid-row: 3;
padding: 20px;
overflow-y: auto;
border-bottom: 1px solid #e0e0e0;
`;
} else {
chatArea.style.cssText = `
grid-row: 3;
padding: 1rem;
overflow-y: auto;
border-bottom: 1px solid #e0e0e0;
min-height: 200px;
`;
}
const chatBox = document.createElement("div");
chatBox.id = "modal-chat-box";
if (USE_CUSTOM_STYLING) {
chatBox.style.cssText = `
height: 100%;
overflow-y: auto;
`;
} else {
chatBox.style.cssText = `
min-height: 150px;
overflow-y: auto;
`;
}
chatArea.appendChild(chatBox);
// Import necessary functions from other modules
const {
loadConversationHistory,
saveConversationHistory,
clearConversationHistory,
} = await import("./storage.js");
const { extractWebpageContent } = await import("./content.js");
const { sendMessage } = await import("./chat.js");
const { getSystemMessage } = await import("./config.js");
// Restore conversation history in modal
function restoreConversationHistory() {
const history = loadConversationHistory();
chatBox.innerHTML = "";
// Filter out system messages before displaying
const displayHistory = history.filter((msg) => msg.role !== "system");
displayHistory.forEach((msg, index) => {
const messageDiv = document.createElement("div");
messageDiv.style.cssText =
"position: relative; margin-bottom: 10px; padding: 5px; border-radius: 5px; background: rgba(0,0,0,0.05);";
const deleteBtn = document.createElement("button");
deleteBtn.textContent = "×";
deleteBtn.style.cssText =
"position: absolute; top: 2px; right: 2px; width: 20px; height: 20px; font-size: 12px; background: rgba(255,0,0,0.7); color: white; border: none; border-radius: 50%; cursor: pointer;";
deleteBtn.onclick = ((messageIndex) => {
return () => {
const savedHistory = loadConversationHistory();
if (messageIndex >= 0 && messageIndex < savedHistory.length) {
savedHistory.splice(messageIndex, 1);
localStorage.setItem(
getPageSpecificKey("hermes-conversation-history"),
JSON.stringify(savedHistory),
);
// Update global chat history
chatHistory = [
{
role: "system",
content: getSystemMessage(),
},
...savedHistory,
];
restoreConversationHistory();
}
messageDiv.remove();
};
})(index);
if (msg.role === "user") {
messageDiv.innerHTML = `<p><strong>You:</strong> ${msg.content}</p>`;
} else if (msg.role === "assistant") {
const parsedContent = marked.parse(msg.content);
messageDiv.innerHTML = `<p><strong>AI:</strong> ${parsedContent}</p>`;
addCodeBlockCopyButtons(messageDiv);
// Add TTS button to AI messages in history
addTTSButtonToMessage(messageDiv, msg.content);
}
messageDiv.appendChild(deleteBtn);
chatBox.appendChild(messageDiv);
});
chatBox.scrollTop = chatBox.scrollHeight;
}
restoreConversationHistory();
// Generate dynamic Hermes introduction using LLM
async function addHermesIntroduction() {
const history = loadConversationHistory();
if (history.length === 0) {
const pageContent = extractWebpageContent();
const pageTitle = document.title;
const pageUrl = window.location.href;
// Create cache key based on page content hash (handle Unicode safely)
const contentForHash = pageContent.substring(0, 1000);
let contentHash;
try {
contentHash = btoa(unescape(encodeURIComponent(contentForHash)))
.replace(/[^a-zA-Z0-9]/g, "")
.substring(0, 32);
} catch (e) {
// Fallback: use simple string hash if btoa fails
let hash = 0;
for (let i = 0; i < contentForHash.length; i++) {
const char = contentForHash.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash; // Convert to 32bit integer
}
contentHash = Math.abs(hash).toString(36).substring(0, 32);
}
const cacheKey = `hermes-intro-${contentHash}`;
// Check if we have a cached introduction for this page content
const cachedIntro = localStorage.getItem(cacheKey);
if (cachedIntro) {
displayIntroduction(cachedIntro);
// Add cached intro to chat history
chatHistory.push({ role: "assistant", content: cachedIntro });
saveConversationHistory(chatHistory);
return;
}
// Generate new introduction using LLM
const introDiv = document.createElement("div");
introDiv.style.cssText =
"position: relative; margin-bottom: 10px; padding: 10px; border-radius: 5px; background: rgba(0,100,200,0.1); border-left: 4px solid #0066cc;";
introDiv.innerHTML =
"<p><strong>🤖 Hermes:</strong> <em>✨ Analyzing this page and crafting a personalized introduction... This may take a moment.</em></p>";
chatBox.appendChild(introDiv);
chatBox.scrollTop = chatBox.scrollHeight;
try {
const prompt = `You are Hermes, a large language model from Nous Research. Write a friendly 3-paragraph introduction for yourself when embedded on this webpage. Be specific about this page's content and identify 2-3 key takeaways. Keep it conversational and helpful.
Page Title: ${pageTitle}
Page URL: ${pageUrl}
Page Content: ${pageContent.substring(0, 2000)}
Format: Start with "Greetings! I'm Hermes..." and make it sound natural and engaging. Write 3 full paragraphs that showcase your capabilities and how you can help with THIS specific page.`;
let generatedIntro = "";
for await (const chunk of sendMessage(prompt)) {
generatedIntro += chunk;
// Update display in real-time
introDiv.innerHTML = `<p><strong>🤖 Hermes:</strong> ${generatedIntro}</p>`;
// Scroll to bottom as content updates
chatBox.scrollTop = chatBox.scrollHeight;
}
// Add to chat history and save
chatHistory.push({ role: "assistant", content: generatedIntro });
saveConversationHistory(chatHistory);
// Cache the generated introduction
localStorage.setItem(cacheKey, generatedIntro);
// Add TTS button to the generated introduction
addTTSButtonToMessage(introDiv, generatedIntro);
chatBox.scrollTop = chatBox.scrollHeight;
} catch (error) {
console.error("Error generating introduction:", error);
const fallbackIntro =
"Greetings! I'm Hermes, a large language model from Nous Research. I'm here to help you understand this page and assist with any questions, coding, or creative tasks you might have. Feel free to ask me anything!";
introDiv.innerHTML = `<p><strong>🤖 Hermes:</strong> ${fallbackIntro}</p>`;
// Add fallback to chat history too
chatHistory.push({ role: "assistant", content: fallbackIntro });
saveConversationHistory(chatHistory);
}
}
}
function displayIntroduction(introText) {
const introDiv = document.createElement("div");
introDiv.style.cssText =
"position: relative; margin-bottom: 10px; padding: 10px; border-radius: 5px; background: rgba(0,100,200,0.1); border-left: 4px solid #0066cc;";
introDiv.innerHTML = `<p><strong>🤖 Hermes:</strong> ${introText}</p>`;
chatBox.appendChild(introDiv);
// Add TTS button to introduction
addTTSButtonToMessage(introDiv, introText);
chatBox.scrollTop = chatBox.scrollHeight;
}
// Add TTS button to any message
function addTTSButtonToMessage(messageDiv, messageText) {
const ttsContainer = document.createElement("div");
ttsContainer.style.cssText =
"display: flex; gap: 8px; margin: 8px 0; align-items: center;";
const ttsBtn = document.createElement("button");
ttsBtn.textContent = "🔊 Play Response";
if (USE_CUSTOM_STYLING) {
ttsBtn.style.cssText =
"padding: 4px 8px; border: 1px solid #0066cc; border-radius: 4px; background: #f0f8ff; cursor: pointer; color: #0066cc; font-size: 12px;";
} else {
ttsBtn.style.cssText =
"padding: 4px 8px; border-radius: 4px; cursor: pointer; font-size: 12px;";
}
const downloadBtn = document.createElement("button");
downloadBtn.textContent = "💾 Download";
downloadBtn.style.display = "none";
if (USE_CUSTOM_STYLING) {
downloadBtn.style.cssText =
"padding: 4px 8px; border: 1px solid #0066cc; border-radius: 4px; background: #f0f8ff; cursor: pointer; color: #0066cc; font-size: 12px;";
} else {
downloadBtn.style.cssText =
"padding: 4px 8px; border-radius: 4px; cursor: pointer; font-size: 12px;";
}
let messageAudio = null;
let messageBlob = null;
ttsBtn.onclick = async () => {
if (!messageAudio) {
ttsBtn.textContent = "Processing...";
ttsBtn.disabled = true;
try {
const selectedVoice = voiceSelect.value;
const result = await speakText(messageText, selectedVoice, 0.9);
messageAudio = result.audio;
messageBlob = result.blob;
ttsBtn.textContent = "⏸️ Pause";
ttsBtn.disabled = false;
downloadBtn.style.display = "inline-block";
messageAudio.play();
messageAudio.onended = () => {
ttsBtn.textContent = "🔊 Play Response";
};
} catch (error) {
console.error("Error generating TTS:", error);
ttsBtn.textContent = "🔊 Play Response";
ttsBtn.disabled = false;
}
} else {
if (messageAudio.paused) {
messageAudio.play();
ttsBtn.textContent = "⏸️ Pause";
} else {
messageAudio.pause();
ttsBtn.textContent = "▶️ Resume";
}
}
};
downloadBtn.onclick = () => {
if (messageBlob) {
const a = document.createElement("a");
a.href = URL.createObjectURL(messageBlob);
a.download = `hermes-response-${Date.now()}.mp3`;
a.click();
}
};
ttsContainer.appendChild(ttsBtn);
ttsContainer.appendChild(downloadBtn);
messageDiv.appendChild(ttsContainer);
}
// Show the modal first, then load intro asynchronously
document.body.appendChild(modal);
// Use showModal() for proper mobile support and backdrop
modal.showModal();
// Close modal when clicking backdrop
modal.addEventListener("click", (e) => {
if (e.target === modal) {
modal.close();
document.body.removeChild(modal);
uncloseaiEmbeddedModalOpen = false;
}
});
// Load Hermes introduction asynchronously after modal is shown
addHermesIntroduction().catch((error) => {
console.error("Error generating Hermes introduction:", error);
});
// Create input area
const inputArea = document.createElement("div");
if (USE_CUSTOM_STYLING) {
inputArea.style.cssText = `
grid-row: 4;
padding: 20px;
display: flex;
gap: 12px;
align-items: flex-end;
`;
} else {
inputArea.style.cssText = `
grid-row: 4;
padding: 1rem;
display: flex;
gap: 12px;
align-items: flex-end;
`;
}
const userInput = document.createElement("textarea");
userInput.id = "modal-user-input";
userInput.placeholder = "Ask about this page...";
if (USE_CUSTOM_STYLING) {
userInput.style.cssText = `
flex: 1;
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-family: inherit;
font-size: 14px;
resize: vertical;
min-height: 44px;
max-height: 120px;
`;
} else {
userInput.style.cssText = `
flex: 1;
padding: 0.5rem;
min-height: 44px;
max-height: 120px;
resize: vertical;
`;
}
const sendButton = document.createElement("button");
sendButton.textContent = "Send";
if (USE_CUSTOM_STYLING) {
sendButton.style.cssText = `
padding: 12px 24px;
background: linear-gradient(45deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: bold;
min-height: 44px;
`;
} else {
sendButton.style.cssText = `
padding: 0.75rem 1.5rem;
min-height: 44px;
`;
}
// Handle message sending
const handleModalInput = async () => {
const message = userInput.value.trim();
if (!message) return;
userInput.value = "";
userInput.style.height = "auto"; // Reset height
// Add user message to chat
const userDiv = document.createElement("div");
userDiv.style.cssText =
"position: relative; margin-bottom: 10px; padding: 5px; border-radius: 5px; background: rgba(0,0,0,0.05);";
userDiv.innerHTML = `<p><strong>You:</strong> ${message}</p>`;
chatBox.appendChild(userDiv);
// Add AI response placeholder
const aiDiv = document.createElement("div");
aiDiv.style.cssText =
"position: relative; margin-bottom: 10px; padding: 5px; border-radius: 5px; background: rgba(0,0,0,0.05);";
aiDiv.innerHTML = "<p><strong>AI:</strong> <em>thinking...</em></p>";
chatBox.appendChild(aiDiv);
chatBox.scrollTop = chatBox.scrollHeight;
try {
let response = "";
for await (const chunk of sendMessage(message)) {
response += chunk;
const parsedResponse = marked.parse(response);
aiDiv.innerHTML = `<p><strong>AI:</strong> ${parsedResponse}</p>`;
addCodeBlockCopyButtons(aiDiv);
chatBox.scrollTop = chatBox.scrollHeight;
}
// Add AI response to chat history and save
const { getChatHistory } = await import("./chat.js");
const currentHistory = getChatHistory();
currentHistory.push({ role: "assistant", content: response });
saveConversationHistory(currentHistory);
// Add delete button and TTS button to messages
[userDiv, aiDiv].forEach((div, index) => {
const deleteBtn = document.createElement("button");
deleteBtn.textContent = "×";
deleteBtn.style.cssText =
"position: absolute; top: 2px; right: 2px; width: 20px; height: 20px; font-size: 12px; background: rgba(255,0,0,0.7); color: white; border: none; border-radius: 50%; cursor: pointer;";
deleteBtn.onclick = () => {
div.remove();
// Update stored history
restoreConversationHistory();
};
div.appendChild(deleteBtn);
// Add TTS button to AI response
if (index === 1) {
// aiDiv
addTTSButtonToMessage(div, response);
}
});
} catch (error) {
aiDiv.innerHTML = `<p><strong>Error:</strong> ${error.message}</p>`;
}
};
sendButton.onclick = handleModalInput;
// Handle Enter key (Shift+Enter for new line)
userInput.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleModalInput();
}
});
inputArea.appendChild(userInput);
inputArea.appendChild(sendButton);
article.appendChild(header);
article.appendChild(controls);
article.appendChild(chatArea);
article.appendChild(inputArea);
}
// TTS Modal functionality
export function openTTSModal() {
// Ensure ChunkFive font is loaded
initializeChunkFiveFont();
// Check if Hermes modal is open to set appropriate z-index
const hermesModal = document.getElementById("uncloseai-embedded-modal");
const zIndex = hermesModal ? "2001" : "1001";
const modal = document.createElement("dialog");
modal.style.zIndex = zIndex;
modal.style.position = "fixed";
if (USE_CUSTOM_STYLING) {
modal.style.cssText += `
width: 95vw;
max-width: 800px;
max-height: 95vh;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border: none;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
background: white;
margin: 0;
overflow: auto;
`;
} else {
modal.style.cssText += `
width: 95vw;
max-width: 800px;
max-height: 95vh;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
margin: 0;
overflow: auto;
`;
}
const article = document.createElement("article");
modal.appendChild(article);
const header = document.createElement("header");
if (USE_CUSTOM_STYLING) {
header.style.cssText = `
background: linear-gradient(45deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 16px 20px;
margin: -1em -1em 1em -1em;
display: flex;
justify-content: space-between;
align-items: center;
`;
} else {
header.style.cssText = `
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
margin: -1em -1em 1em -1em;
`;
}
article.appendChild(header);
const h1 = document.createElement("h1");
h1.innerHTML =
"<span style=\"font-family: 'ChunkFiveRegular', monospace;\">uncloseai.</span> TTS Anything!";
if (USE_CUSTOM_STYLING) {
h1.style.cssText = `
margin: 0;
font-size: 20px;
`;
}
header.appendChild(h1);
const closeButton = document.createElement("button");
closeButton.textContent = "X";
if (USE_CUSTOM_STYLING) {
closeButton.style.cssText = `
background: none;
border: none;
color: white;
font-size: 24px;
cursor: pointer;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
`;
} else {
closeButton.style.cssText = `
background: var(--background-color);
border: 1px solid var(--border-color);
color: var(--color);
font-size: 18px;
cursor: pointer;
padding: 4px;
width: 28px;
height: 28px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.2s;
`;
closeButton.onmouseenter = () => (closeButton.style.opacity = "0.7");
closeButton.onmouseleave = () => (closeButton.style.opacity = "1");
}
closeButton.onclick = () => {
modal.close();
document.body.removeChild(modal);
};
header.appendChild(closeButton);
const textArea = document.createElement("textarea");
textArea.style.width = "100%";
textArea.style.height = "240px";
textArea.placeholder =
"text-to-speech: write any message & have the artificial intelligence speak it!";
article.appendChild(textArea);
// Voice selection
const voices = ["alloy", "echo", "fable", "onyx", "nova", "shimmer"];
const voiceSelection = document.createElement("div");
if (USE_CUSTOM_STYLING) {
voiceSelection.style.cssText = `
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 16px 0;
`;
} else {
voiceSelection.style.cssText = `
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 16px 0;
`;
}
voices.forEach((voice) => {
const label = document.createElement("label");
if (USE_CUSTOM_STYLING) {
label.style.cssText = `
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
`;
} else {
label.style.display = "inline-block";
label.style.marginRight = "10px";
}
const radio = document.createElement("input");
radio.type = "radio";
radio.name = "tts-voice";
radio.value = voice;
if (voice === "alloy") radio.checked = true;
label.appendChild(radio);
label.appendChild(document.createTextNode(voice));
voiceSelection.appendChild(label);
});
article.appendChild(voiceSelection);
// Speed selection
const speedLabel = document.createElement("label");
const speedValue = document.createElement("span");
speedValue.textContent = "0.9";
speedLabel.textContent = `Speed: ${speedValue.textContent}`;
const speedSlider = document.createElement("input");
speedSlider.type = "range";
speedSlider.min = "0.25";
speedSlider.max = "4.0";
speedSlider.step = "0.05";
speedSlider.value = "0.9";
speedSlider.oninput = () => {
speedValue.textContent = speedSlider.value;
speedLabel.textContent = `Speed: ${speedSlider.value}`;
};
article.appendChild(speedLabel);
article.appendChild(speedSlider);
const playButton = document.createElement("button");
playButton.textContent = "Play Text";
if (USE_CUSTOM_STYLING) {
playButton.style.cssText = `
padding: 12px 24px;
background: linear-gradient(45deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: bold;
margin: 16px 10px 0 0;
`;
}
let ttsAudio = null;
let ttsBlob = null;
let lastTTSInput = "";
playButton.onclick = async () => {
const currentText = textArea.value.trim();
const selectedVoice = document.querySelector(
'input[name="tts-voice"]:checked',
).value;
const selectedSpeed = parseFloat(speedSlider.value);
if (!currentText) return;
if (!ttsAudio || lastTTSInput !== currentText) {
if (ttsAudio) ttsAudio.pause();
playButton.textContent = "Processing...";
playButton.disabled = true;
lastTTSInput = currentText;
const result = await speakText(currentText, selectedVoice, selectedSpeed);
ttsAudio = result.audio;
ttsBlob = result.blob;
playButton.disabled = false;
// Auto-play the audio (handle browser auto-play policies)
console.log("TTS MP3 ready, attempting auto-play...");
try {
const playPromise = ttsAudio.play();
await playPromise;
console.log("✅ TTS audio auto-playing successfully");
playButton.textContent = "Pause Text";
} catch (error) {
console.log("❌ Auto-play prevented by browser policy:", error.message);
console.log("User will need to manually click play");
playButton.textContent = "Play Text";
// Don't throw error, just log it - user can manually click play
}
// Add download button if not exists
let downloadButton = article.querySelector(".download-btn");
if (!downloadButton) {
downloadButton = document.createElement("button");
downloadButton.className = "download-btn";
downloadButton.textContent = "Download MP3";
if (USE_CUSTOM_STYLING) {
downloadButton.style.cssText = `
padding: 12px 24px;
background: #f5f5f5;
border: 1px solid #ccc;
border-radius: 8px;
cursor: pointer;
font-weight: bold;
margin: 16px 0 0 10px;
`;
}
downloadButton.onclick = () => {
const a = document.createElement("a");
a.href = URL.createObjectURL(ttsBlob);
a.download = `tts-${Date.now()}.mp3`;
a.click();
};
article.appendChild(downloadButton);
}
} else {
// Audio already exists, toggle play/pause
if (ttsAudio.paused) {
ttsAudio.play();
playButton.textContent = "Pause Text";
} else {
ttsAudio.pause();
playButton.textContent = "Play Text";
}
}
ttsAudio.onended = () => {
playButton.textContent = "Play Text";
};
};
article.appendChild(playButton);
document.body.appendChild(modal);
// Use showModal() for proper mobile support and backdrop
modal.showModal();
// Close modal when clicking backdrop
modal.addEventListener("click", (e) => {
if (e.target === modal) {
modal.close();
document.body.removeChild(modal);
}
});
}
// Open translation modal
export function openTranslateModal() {
// Ensure ChunkFive font is loaded
initializeChunkFiveFont();
// Check if Hermes modal is open to set appropriate z-index
const hermesModal = document.getElementById("uncloseai-embedded-modal");
const zIndex = hermesModal ? "2001" : "1001";
const modal = document.createElement("dialog");
modal.style.zIndex = zIndex;
modal.style.position = "fixed";
if (USE_CUSTOM_STYLING) {
modal.style.cssText += `
width: 95vw;
max-width: 800px;
max-height: 95vh;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
border: none;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
background: white;
margin: 0;
overflow: auto;
`;
} else {
modal.style.cssText += `
width: 95vw;
max-width: 800px;
max-height: 95vh;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
margin: 0;
overflow: auto;
`;
}
const article = document.createElement("article");
modal.appendChild(article);
const header = document.createElement("header");
if (USE_CUSTOM_STYLING) {
header.style.cssText = `
background: linear-gradient(45deg, #4CAF50 0%, #45a049 100%);
color: white;
padding: 16px 20px;
margin: -1em -1em 1em -1em;
display: flex;
justify-content: space-between;
align-items: center;
`;
} else {
header.style.cssText = `
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
margin: -1em -1em 1em -1em;
`;
}
article.appendChild(header);
const h1 = document.createElement("h1");
h1.innerHTML =
"<span style=\"font-family: 'ChunkFiveRegular', monospace;\">uncloseai.</span> 🌐 Translation";
if (USE_CUSTOM_STYLING) {
h1.style.cssText = `
margin: 0;
font-size: 20px;
`;
}
header.appendChild(h1);
const closeButton = document.createElement("button");
closeButton.textContent = "X";
if (USE_CUSTOM_STYLING) {
closeButton.style.cssText = `
background: none;
border: none;
color: white;
font-size: 24px;
cursor: pointer;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
touch-action: manipulation;
`;
} else {
closeButton.style.cssText = `
background: var(--background-color);
border: 1px solid var(--border-color);
color: var(--color);
font-size: 18px;
cursor: pointer;
padding: 4px;
width: 28px;
height: 28px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.2s;
touch-action: manipulation;
`;
closeButton.onmouseenter = () => (closeButton.style.opacity = "0.7");
closeButton.onmouseleave = () => (closeButton.style.opacity = "1");
}
closeButton.onclick = () => {
modal.close();
document.body.removeChild(modal);
};
header.appendChild(closeButton);
// Translation mode tabs
const modeContainer = document.createElement("div");
modeContainer.style.cssText =
"display: flex; gap: 10px; margin-bottom: 20px;";
const pageTab = document.createElement("button");
pageTab.textContent = "📄 Translate Page";
pageTab.style.cssText =
"flex: 1; padding: 10px; border: 2px solid #4CAF50; border-radius: 6px; background: #4CAF50; color: white; cursor: pointer;";
const customTab = document.createElement("button");
customTab.textContent = "✏️ Custom Text";
customTab.style.cssText =
"flex: 1; padding: 10px; border: 2px solid #4CAF50; border-radius: 6px; background: white; color: #4CAF50; cursor: pointer;";
modeContainer.appendChild(pageTab);
modeContainer.appendChild(customTab);
article.appendChild(modeContainer);
// Content areas
const pageContent = document.createElement("div");
const customContent = document.createElement("div");
customContent.style.display = "none";
// Page translation content
const pageInfo = document.createElement("p");
pageInfo.textContent =
"Translate the current page content into your chosen language:";
pageContent.appendChild(pageInfo);
// Custom text content
const textArea = document.createElement("textarea");
textArea.style.width = "100%";
textArea.style.height = "150px";
textArea.placeholder = "Enter any text to translate...";
customContent.appendChild(textArea);
article.appendChild(pageContent);
article.appendChild(customContent);
// Language selection
const languageContainer = document.createElement("div");
languageContainer.style.cssText = "margin: 20px 0;";
const languageLabel = document.createElement("label");
languageLabel.textContent = "Translate to: ";
languageLabel.style.display = "block";
languageLabel.style.marginBottom = "8px";
const languageSelect = document.createElement("select");
languageSelect.style.cssText =
"width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 4px;";
Object.entries(SUPPORTED_LANGUAGES).forEach(([code, name]) => {
const option = document.createElement("option");
option.value = code;
option.textContent = name;
languageSelect.appendChild(option);
});
languageContainer.appendChild(languageLabel);
languageContainer.appendChild(languageSelect);
article.appendChild(languageContainer);
// Translate button
const translateButton = document.createElement("button");
translateButton.textContent = "🌐 Translate";
if (USE_CUSTOM_STYLING) {
translateButton.style.cssText = `
padding: 12px 24px;
background: linear-gradient(45deg, #4CAF50 0%, #45a049 100%);
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: bold;
margin: 16px 10px 0 0;
width: 100%;
`;
} else {
translateButton.style.cssText =
"width: 100%; padding: 12px; margin: 16px 0;";
}
// Result area
const resultContainer = document.createElement("div");
resultContainer.style.cssText = "margin-top: 20px; display: none;";
const resultLabel = document.createElement("h3");
resultLabel.textContent = "Translation:";
const resultArea = document.createElement("div");
resultArea.style.cssText =
"border: 1px solid #ccc; border-radius: 4px; padding: 15px; max-height: 400px; overflow-y: auto; background: #f9f9f9; white-space: pre-wrap;";
resultContainer.appendChild(resultLabel);
resultContainer.appendChild(resultArea);
article.appendChild(resultContainer);
article.appendChild(translateButton);
// Tab switching
let isPageMode = true;
pageTab.onclick = () => {
if (!isPageMode) {
isPageMode = true;
pageTab.style.background = "#4CAF50";
pageTab.style.color = "white";
customTab.style.background = "white";
customTab.style.color = "#4CAF50";
pageContent.style.display = "block";
customContent.style.display = "none";
translateButton.textContent = "🌐 Translate Page";
}
};
customTab.onclick = () => {
if (isPageMode) {
isPageMode = false;
customTab.style.background = "#4CAF50";
customTab.style.color = "white";
pageTab.style.background = "white";
pageTab.style.color = "#4CAF50";
customContent.style.display = "block";
pageContent.style.display = "none";
translateButton.textContent = "🌐 Translate Text";
}
};
// Translation logic
translateButton.onclick = async () => {
const targetLanguage = languageSelect.value;
const targetLanguageName = SUPPORTED_LANGUAGES[targetLanguage];
try {
translateButton.textContent = "Processing...";
translateButton.disabled = true;
resultContainer.style.display = "none";
// Get token count and estimated time for progress
const { countTokens } = await import("./token_estimator.js");
const { estimateProcessingTime } = await import("./translation.js");
const textToAnalyze = isPageMode
? document.documentElement.outerHTML
: textArea.value.trim();
const tokenInfo = countTokens(textToAnalyze);
const estimatedSeconds = estimateProcessingTime(tokenInfo.totalTokens);
// Create progress indicator
const progressContainer = document.createElement("div");
progressContainer.style.cssText = `
margin: 20px 0;
padding: 20px;
border: 2px solid #4CAF50;
border-radius: 8px;
background: #f0fff0;
text-align: center;
`;
const progressText = document.createElement("div");
progressText.style.cssText =
"font-weight: bold; color: #4CAF50; margin-bottom: 10px;";
progressText.textContent = `🤖 Processing ${tokenInfo.totalTokens} tokens...`;
const etaText = document.createElement("div");
etaText.style.cssText = "color: #666; margin-bottom: 15px;";
etaText.textContent = `Estimated time: ${estimatedSeconds} seconds`;
const progressBar = document.createElement("div");
progressBar.style.cssText = `
width: 100%;
height: 20px;
background: #e0e0e0;
border-radius: 10px;
overflow: hidden;
margin-bottom: 10px;
`;
const progressFill = document.createElement("div");
progressFill.style.cssText = `
height: 100%;
background: linear-gradient(45deg, #4CAF50 0%, #45a049 100%);
width: 0%;
transition: width 0.5s ease;
border-radius: 10px;
`;
progressBar.appendChild(progressFill);
const statusText = document.createElement("div");
statusText.style.cssText = "color: #777; font-size: 0.9em;";
statusText.textContent = "Initializing translation...";
progressContainer.appendChild(progressText);
progressContainer.appendChild(etaText);
progressContainer.appendChild(progressBar);
progressContainer.appendChild(statusText);
// Insert progress before result container
resultContainer.parentNode.insertBefore(
progressContainer,
resultContainer,
);
// Start progress animation
const startTime = Date.now();
const progressInterval = setInterval(() => {
const elapsed = Date.now() - startTime;
const progress = Math.min(
(elapsed / (estimatedSeconds * 1000)) * 100,
95,
);
progressFill.style.width = `${progress}%`;
const remainingTime = Math.max(
0,
estimatedSeconds - Math.floor(elapsed / 1000),
);
etaText.textContent =
remainingTime > 0
? `Time remaining: ${remainingTime}s`
: "Almost done...";
if (elapsed < 3000) statusText.textContent = "Analyzing content...";
else if (elapsed < estimatedSeconds * 500)
statusText.textContent = "Translating with Hermes AI...";
else statusText.textContent = "Finalizing translation...";
}, 500);
let translatedText;
if (isPageMode) {
translatedText = await translateCurrentPage(targetLanguage);
} else {
const customText = textArea.value.trim();
if (!customText) {
alert("Please enter some text to translate!");
return;
}
translatedText = await translateText(customText, targetLanguage);
}
resultLabel.textContent = `Translation (${targetLanguageName}):`;
// Check if the translated content looks like HTML
const isHTML =
translatedText.includes("<") &&
translatedText.includes(">") &&
(translatedText.includes("<h") ||
translatedText.includes("<p") ||
translatedText.includes("<div") ||
translatedText.includes("<section"));
if (isHTML) {
// Auto-open preview in new tab
const previewWindow = window.open("", "_blank");
if (previewWindow) {
// Inject base URL for relative resources to work
const baseUrl =
window.location.origin +
window.location.pathname.substring(
0,
window.location.pathname.lastIndexOf("/") + 1,
);
let htmlWithBase;
const scriptTag = `
<script>
window.UNCLOSEAI_SKIP_INIT = true;
</script>
<script src="https://uncloseai.com/uncloseai.js" type="module"></script>`;
if (translatedText.includes("<!DOCTYPE")) {
// Already has DOCTYPE, just add base and script
htmlWithBase = translatedText.includes("<head>")
? translatedText.replace(
"<head>",
`<head><base href="${baseUrl}">${scriptTag}`,
)
: translatedText.replace(
"<html>",
`<html><head><base href="${baseUrl}">${scriptTag}</head>`,
);
} else if (translatedText.includes("<head>")) {
// Has head but no DOCTYPE
htmlWithBase = `<!DOCTYPE html>\n${translatedText.replace("<head>", `<head><base href="${baseUrl}">${scriptTag}`)}`;
} else {
// No DOCTYPE or head
htmlWithBase = `<!DOCTYPE html>\n<html><head><base href="${baseUrl}">${scriptTag}</head><body>${translatedText}</body></html>`;
}
previewWindow.document.write(htmlWithBase);
previewWindow.document.close();
previewWindow.document.title = `Translation Preview (${targetLanguageName}) - AI Enabled`;
// Add a notice about AI functionality only in preview window (wait for body to be available)
const addNotice = () => {
if (
previewWindow &&
previewWindow.document &&
previewWindow.document.body
) {
console.log("Adding AI notice to preview window");
const notice = previewWindow.document.createElement("div");
notice.style.cssText =
"position: fixed; top: 10px; right: 10px; background: #4CAF50; color: white; padding: 8px 12px; border-radius: 4px; font-size: 12px; z-index: 10000; box-shadow: 0 2px 8px rgba(0,0,0,0.2);";
notice.textContent = `🤖 AI features should work in ${targetLanguageName}!`;
previewWindow.document.body.appendChild(notice);
// Auto-hide notice after 5 seconds
setTimeout(() => {
if (notice.parentNode) notice.parentNode.removeChild(notice);
}, 5000);
} else if (previewWindow) {
// Wait a bit and try again
setTimeout(addNotice, 100);
}
};
// Only add notice if we have a valid preview window
if (previewWindow) {
addNotice();
}
}
// Show raw HTML toggle in modal
resultArea.innerHTML = `
<div style="margin-bottom: 15px;">
<button onclick="this.nextElementSibling.style.display = this.nextElementSibling.style.display === 'none' ? 'block' : 'none'; this.textContent = this.textContent.includes('Raw') ? 'Show Raw HTML' : 'Hide Raw HTML'" style="padding: 8px 16px; background: #666; color: white; border: none; border-radius: 4px; cursor: pointer;">Show Raw HTML</button>
<div style="display: none; margin: 10px 0; padding: 15px; background: #f0f0f0; border-radius: 4px; white-space: pre-wrap; font-family: monospace; font-size: 0.85em; max-height: 300px; overflow-y: auto; border: 1px solid #ddd;">${translatedText.replace(/</g, "&lt;").replace(/>/g, "&gt;")}</div>
</div>
<div style="border: 2px solid #4CAF50; border-radius: 8px; background: #f0fff0; padding: 15px; text-align: center;">
<div style="color: #4CAF50; font-weight: bold; margin-bottom: 10px;">✅ Preview Opened in New Window</div>
<p style="margin: 0; color: #666;">The translated HTML page has opened in a new window with full AI functionality!</p>
${previewWindow ? "" : '<p style="margin: 10px 0 0 0; color: #d32f2f;"><strong>Note:</strong> Please allow popups for preview window</p>'}
</div>
`;
resultArea.style.whiteSpace = "normal";
} else {
resultArea.textContent = translatedText;
resultArea.style.whiteSpace = "pre-wrap";
}
resultContainer.style.display = "block";
// Complete progress bar
clearInterval(progressInterval);
progressFill.style.width = "100%";
etaText.textContent = "Translation completed!";
statusText.textContent = "✅ Ready to preview";
// Hide progress after a moment
setTimeout(() => {
if (progressContainer.parentNode) {
progressContainer.parentNode.removeChild(progressContainer);
}
}, 2000);
// Scroll to result
resultContainer.scrollIntoView({ behavior: "smooth", block: "nearest" });
} catch (error) {
// Clean up progress on error
if (typeof progressInterval !== "undefined") {
clearInterval(progressInterval);
}
const progressContainer = document.querySelector(
'[style*="border: 2px solid #4CAF50"]',
);
if (progressContainer && progressContainer.parentNode) {
progressContainer.parentNode.removeChild(progressContainer);
}
alert(`Translation failed: ${error.message}`);
} finally {
translateButton.textContent = isPageMode
? "🌐 Translate Page"
: "🌐 Translate Text";
translateButton.disabled = false;
}
};
document.body.appendChild(modal);
// Use showModal() for proper mobile support and backdrop
modal.showModal();
// Close modal when clicking backdrop
modal.addEventListener("click", (e) => {
if (e.target === modal) {
modal.close();
document.body.removeChild(modal);
}
});
}
// Initialize the system
export function initializeSystem() {
// Check for skip init flag - allow partial initialization for preview windows
if (window.UNCLOSEAI_SKIP_INIT === true) {
console.log("uncloseai.js: Partial initialization for preview window");
// Only create floating button in preview windows if not explicitly disabled
if (window.UNCLOSEAI_FLOATING_BUTTON !== false) {
console.log("uncloseai.js: Creating floating button in preview window");
// Ensure DOM is ready before creating button
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", createFloatingAIButton);
} else {
createFloatingAIButton();
}
}
return;
}
console.log("uncloseai.js: Full initialization");
initializeChatInterface();
// Only create floating button if not disabled
if (SHOW_FLOATING_BUTTON) {
console.log(
"uncloseai.js: SHOW_FLOATING_BUTTON is true, calling createFloatingAIButton()",
);
createFloatingAIButton();
} else {
console.log(
"uncloseai.js: SHOW_FLOATING_BUTTON is false, not creating floating button.",
);
}
// Initialize class-based elements
initializeUncloseaiElements();
}