557 lines
16 KiB
JavaScript
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;
|