uncloseai.com/src/ui.js

557 lines
16 KiB
JavaScript

// 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, getAPIConfig } from "./config.js";
import { extractWebpageContent } from "./content.js";
import { detectPageLanguage } from "./language-detection.js";
import { speakText } from "./tts.js";
import {
detectCurrentTheme,
getThemeColors,
initializeChunkFiveFont,
} from "./ui-themes.js";
import {
getUIText,
getUserLanguagePreference,
setUserLanguagePreference,
} from "./ui-translations.js";
import { NATIVE_LANGUAGE_NAMES } from "./translation.js";
import { openTTSModal } from "./tts-modal.js";
import { openTranslateModal } from "./translate-modal.js";
import {
createFullInterface,
createCustomInterface,
createButton,
handleTTSFromElement,
handleUploadFromElement,
handleSmartTranslate,
} from "./widget-library.js";
import {
openUncloseaiEmbeddedModal,
openUncloseaiEmbeddedModalNew,
} from "./uncloseai-embed-modal.js";
// 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");
}
// 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.className = "uncloseai-code-copy-btn";
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(getUIText("failedToCopyCode", { error: error.message }));
}
};
pre.appendChild(copyBtn);
}
});
}
// Send message with custom history (for intro generation)
async function* sendMessageWithCustomHistory(messageHistory) {
// Get API configuration (custom or default)
const apiConfig = await getAPIConfig();
let apiUrl, headers, model;
if (apiConfig.isCustom) {
// Use custom API configuration
apiUrl = `${apiConfig.endpoint}/chat/completions`;
headers = apiConfig.headers;
model = apiConfig.model;
} else {
// Use default Hermes configuration
const { getSelectedModel, getSelectedModelEndpoint } = await import(
"./models.js"
);
apiUrl = `${getSelectedModelEndpoint()}/chat/completions`;
headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
};
model = getSelectedModel();
}
// Import getSelectedModelMaxTokens to get dynamic max tokens
const { getSelectedModelMaxTokens } = await import("./models.js");
const maxTokens = getSelectedModelMaxTokens();
console.log("UI (intro generation) using max_tokens:", maxTokens);
const response = await fetch(apiUrl, {
method: "POST",
headers: headers,
body: JSON.stringify({
model: model,
messages: messageHistory,
temperature: 0.3,
max_tokens: maxTokens,
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 {
addFileUploadButton,
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 uncloseai-ui-container";
if (features === "full" || type === "full") {
createFullInterface(container);
} else {
createCustomInterface(container, features.split(","));
}
element.appendChild(container);
});
}
// Widget creation functions imported from widget-library.js
// 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 = (event) => readPageWithHermes(event.target);
button.className = "uncloseai-ui-button-margin";
const ttsButton = document.createElement("button");
ttsButton.textContent = "TTS Anything";
ttsButton.onclick = openTTSModal;
ttsButton.className = "uncloseai-ui-button-margin";
document.body.appendChild(button);
document.body.appendChild(ttsButton);
}
// File upload functionality moved to file-upload.js
// Helper function to create and append the floating button
function createAndAppendFloatingButton() {
console.log("uncloseai.js: createAndAppendFloatingButton() 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?.("(prefers-color-scheme: dark)").matches &&
!document.documentElement.getAttribute("data-theme"));
// Set floating button theme colors via CSS custom properties
const root = document.documentElement;
root.style.setProperty('--floating-button-bg', isDark ? "#ffffff" : "#000000");
root.style.setProperty('--floating-button-border', isDark ? "#000000" : "#ffffff");
root.style.setProperty('--floating-button-text', isDark ? "#000000" : "#ffffff");
floatingButton.className = "uncloseai-floating-button";
}
// 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 handled by CSS
// Set up self-contained click handler
setupFloatingButtonHandler(floatingButton);
document.body.appendChild(floatingButton);
console.log("uncloseai.js: Appended floating button to document.body.");
}
// Function to create floating AI button
export function createFloatingAIButton() {
console.log("uncloseai.js: createFloatingAIButton() called.");
// Load CSS for floating button if not already loaded
const hasPicoCSS = document.querySelector('link[href*="pico"]') !== null;
const cssFile = hasPicoCSS ? 'uncloseai-modal-pico.css' : 'uncloseai-modal-builtin.css';
if (!document.querySelector(`link[href*="${cssFile}"]`)) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = `https://uncloseai.com/src/${cssFile}`;
// Create button only after CSS loads
link.onload = () => {
console.log(`uncloseai.js: ${cssFile} loaded, creating floating button.`);
createAndAppendFloatingButton();
};
link.onerror = () => {
console.error(`uncloseai.js: Failed to load ${cssFile}, creating button anyway.`);
createAndAppendFloatingButton();
};
document.head.appendChild(link);
return; // Exit early, button will be created after CSS loads
}
// CSS already loaded, create button immediately
createAndAppendFloatingButton();
}
// 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.classList.add("loading");
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 {
// Fallback: directly open the modal
console.log("Using fallback to directly open modal");
await openUncloseaiEmbeddedModal();
}
} catch (error) {
console.error("Error opening modal:", error);
alert(getUIText("errorOpeningModal", { error: error.message }));
} finally {
// Restore button state
floatingButton.textContent = originalText;
floatingButton.disabled = false;
floatingButton.classList.remove("loading");
}
};
}
// New mobile-first Hermes modal
// Modal functions extracted to uncloseai-embed-modal.js
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();
}
// Toggle function for floating button
export async function toggleUncloseaiEmbeddedModal() {
console.log("toggleUncloseaiEmbeddedModal called");
// Check if modal is already open
const existingModal = document.getElementById("uncloseai-embedded-modal");
if (existingModal && window.uncloseaiEmbeddedModalOpen) {
console.log("Modal is open, closing it");
existingModal.close();
document.body.removeChild(existingModal);
window.uncloseaiEmbeddedModalOpen = false;
} else {
console.log("Modal is closed, opening it");
await openUncloseaiEmbeddedModal();
}
}
// Export modal functions globally for onclick handlers
window.openTTSModal = openTTSModal;
window.openTranslateModal = openTranslateModal;
window.toggleUncloseaiEmbeddedModal = toggleUncloseaiEmbeddedModal;