Spaces:
Running
Running
| // CENTRALIZED KIMI UTILITIES | |
| // Input validation and sanitization utilities | |
| window.KimiValidationUtils = { | |
| validateMessage(message) { | |
| if (!message || typeof message !== "string") { | |
| return { valid: false, error: "Message must be a non-empty string" }; | |
| } | |
| const trimmed = message.trim(); | |
| if (!trimmed) return { valid: false, error: "Message cannot be empty" }; | |
| const MAX = (window.KIMI_SECURITY_CONFIG && window.KIMI_SECURITY_CONFIG.MAX_MESSAGE_LENGTH) || 5000; | |
| if (trimmed.length > MAX) { | |
| return { valid: false, error: `Message too long (max ${MAX} characters)` }; | |
| } | |
| return { valid: true, sanitized: this.escapeHtml(trimmed) }; | |
| }, | |
| escapeHtml(text) { | |
| const div = document.createElement("div"); | |
| div.textContent = text; | |
| return div.innerHTML; | |
| }, | |
| /** | |
| * Format chat text with simple markdown-like syntax (secure) | |
| * Supports: **bold**, *italic*, and preserves line breaks | |
| * Security: All text is escaped first, then selective formatting is applied | |
| */ | |
| formatChatText(text) { | |
| if (!text || typeof text !== "string") return ""; | |
| // First: Escape all HTML to prevent XSS | |
| let escaped = this.escapeHtml(text); | |
| // Optional: Replace em-dash with regular dash if preferred | |
| escaped = escaped.replace(/—/g, "-"); | |
| // Second: Apply simple formatting (only on escaped text) | |
| // **bold** -> <strong>bold</strong> | |
| escaped = escaped.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>"); | |
| // *italic* -> <em>italic</em> (but not if already inside **) | |
| escaped = escaped.replace(/(?<!\*)\*([^*]+?)\*(?!\*)/g, "<em>$1</em>"); | |
| // Smart paragraph handling: only create <p> for double line breaks or real paragraphs | |
| // Split by double line breaks (\n\n) to identify real paragraphs | |
| const realParagraphs = escaped.split(/\n\s*\n/).filter(para => para.trim().length > 0); | |
| if (realParagraphs.length > 1) { | |
| // Multiple paragraphs found - wrap each in <p> | |
| escaped = realParagraphs.map(p => `<p>${p.trim().replace(/\n/g, " ")}</p>`).join(""); | |
| } else { | |
| // Single paragraph - just convert single \n to spaces (natural text flow) | |
| escaped = escaped.replace(/\n/g, " "); | |
| } | |
| return escaped; | |
| }, | |
| validateRange(value, key) { | |
| const bounds = { | |
| voiceRate: { min: 0.5, max: 2, def: 1.1 }, | |
| voicePitch: { min: 0.5, max: 2, def: 1.1 }, | |
| voiceVolume: { min: 0, max: 1, def: 0.8 }, | |
| llmTemperature: { min: 0, max: 1, def: 0.9 }, | |
| llmMaxTokens: { min: 1, max: 8192, def: 400 }, | |
| llmTopP: { min: 0, max: 1, def: 0.9 }, | |
| llmFrequencyPenalty: { min: 0, max: 2, def: 0.9 }, | |
| llmPresencePenalty: { min: 0, max: 2, def: 0.8 }, | |
| interfaceOpacity: { min: 0.1, max: 1, def: 0.8 } | |
| }; | |
| const b = bounds[key] || { min: 0, max: 100, def: 0 }; | |
| const v = window.KimiSecurityUtils | |
| ? window.KimiSecurityUtils.validateRange(value, b.min, b.max, b.def) | |
| : isNaN(parseFloat(value)) | |
| ? b.def | |
| : Math.max(b.min, Math.min(b.max, parseFloat(value))); | |
| return { value: v, clamped: v !== parseFloat(value) }; | |
| } | |
| }; | |
| // Provider utilities used across the app | |
| const KimiProviderUtils = { | |
| getKeyPrefForProvider(provider) { | |
| // Each provider should have its own separate API key storage | |
| const providerKeys = { | |
| openrouter: "openrouterApiKey", | |
| openai: "openaiApiKey", | |
| groq: "groqApiKey", | |
| together: "togetherApiKey", | |
| deepseek: "deepseekApiKey", | |
| "openai-compatible": "customApiKey", | |
| ollama: null | |
| }; | |
| return providerKeys[provider] || "providerApiKey"; | |
| }, | |
| async getApiKey(db, provider) { | |
| if (!db) return null; | |
| if (provider === "ollama") return "__local__"; | |
| const keyPref = this.getKeyPrefForProvider(provider); | |
| return await db.getPreference(keyPref); | |
| }, | |
| getLabelForProvider(provider) { | |
| const labels = { | |
| openrouter: "OpenRouter API Key", | |
| openai: "OpenAI API Key", | |
| groq: "Groq API Key", | |
| together: "Together API Key", | |
| deepseek: "DeepSeek API Key", | |
| custom: "Custom API Key", | |
| "openai-compatible": "API Key", | |
| ollama: "API Key" | |
| }; | |
| return labels[provider] || "API Key"; | |
| } | |
| }; | |
| window.KimiProviderUtils = KimiProviderUtils; | |
| // Shared provider placeholders used by UI and LLM manager. Keep in window for backward compatibility. | |
| const KimiProviderPlaceholders = { | |
| openrouter: "https://openrouter.ai/api/v1/chat/completions", | |
| openai: "https://api.openai.com/v1/chat/completions", | |
| groq: "https://api.groq.com/openai/v1/chat/completions", | |
| together: "https://api.together.xyz/v1/chat/completions", | |
| deepseek: "https://api.deepseek.com/chat/completions", | |
| "openai-compatible": "", | |
| ollama: "http://localhost:11434/api/chat" | |
| }; | |
| window.KimiProviderPlaceholders = KimiProviderPlaceholders; | |
| export { KimiProviderUtils, KimiProviderPlaceholders }; | |
| // Performance utility functions for debouncing and throttling | |
| window.KimiPerformanceUtils = { | |
| debounce: function (func, wait, immediate = false, context = null) { | |
| let timeout; | |
| let result; | |
| return function executedFunction(...args) { | |
| const later = () => { | |
| timeout = null; | |
| if (!immediate) { | |
| result = func.apply(context || this, args); | |
| } | |
| }; | |
| const callNow = immediate && !timeout; | |
| clearTimeout(timeout); | |
| timeout = setTimeout(later, wait); | |
| if (callNow) { | |
| result = func.apply(context || this, args); | |
| } | |
| return result; | |
| }; | |
| }, | |
| throttle: function (func, limit, options = {}) { | |
| const { leading = true, trailing = true } = options; | |
| let inThrottle; | |
| let lastFunc; | |
| let lastRan; | |
| return function (...args) { | |
| if (!inThrottle) { | |
| if (leading) { | |
| func.apply(this, args); | |
| } | |
| lastRan = Date.now(); | |
| inThrottle = true; | |
| } else { | |
| clearTimeout(lastFunc); | |
| lastFunc = setTimeout( | |
| () => { | |
| if (trailing && Date.now() - lastRan >= limit) { | |
| func.apply(this, args); | |
| lastRan = Date.now(); | |
| } | |
| }, | |
| limit - (Date.now() - lastRan) | |
| ); | |
| } | |
| setTimeout(() => (inThrottle = false), limit); | |
| }; | |
| } | |
| }; | |
| // Language management utilities | |
| window.KimiLanguageUtils = { | |
| // Default language priority: auto -> user preference -> browser -> fr | |
| async getLanguage() { | |
| if (window.kimiDB && window.kimiDB.getPreference) { | |
| const userLang = await window.kimiDB.getPreference("selectedLanguage", null); | |
| if (userLang && userLang !== "auto") { | |
| return userLang; | |
| } | |
| } | |
| // Auto-detect from browser | |
| const browserLang = navigator.language?.split("-")[0] || "en"; | |
| // Handle Brazilian Portuguese specifically | |
| if (navigator.language?.toLowerCase().startsWith("pt")) { | |
| return "pt-br"; | |
| } | |
| const supportedLangs = ["en", "fr", "es", "de", "it", "ja", "zh", "pt-br"]; | |
| return supportedLangs.includes(browserLang) ? browserLang : "en"; | |
| }, | |
| // Auto-detect language from text content | |
| detectLanguage(text) { | |
| if (!text) return "en"; | |
| if (/[àâäéèêëîïôöùûüÿç]/i.test(text)) return "fr"; | |
| if (/[äöüß]/i.test(text)) return "de"; | |
| if (/[ñáéíóúü]/i.test(text)) return "es"; | |
| if (/[àèìòù]/i.test(text)) return "it"; | |
| if (/[ãõâêôçáéíóú]/i.test(text)) return "pt-br"; | |
| if (/[\u3040-\u309f\u30a0-\u30ff\u4e00-\u9faf]/i.test(text)) return "ja"; | |
| if (/[\u4e00-\u9fff]/i.test(text)) return "zh"; | |
| return "en"; | |
| }, | |
| // Normalize language codes to a primary subtag (e.g. 'en-US' -> 'en', 'us:en' -> 'en') | |
| normalizeLanguageCode(raw) { | |
| if (!raw) return ""; | |
| try { | |
| let norm = String(raw).toLowerCase(); | |
| if (norm.includes(":")) { | |
| const parts = norm.split(":"); | |
| norm = parts[parts.length - 1]; | |
| } | |
| norm = norm.replace("_", "-"); | |
| // Special case for Brazilian Portuguese | |
| if (norm === "pt-br" || norm === "pt_br" || norm.startsWith("pt-br") || norm.startsWith("pt_br")) { | |
| return "pt-br"; | |
| } | |
| if (norm.includes("-")) norm = norm.split("-")[0]; | |
| // Map 'pt' to 'pt-br' by default | |
| if (norm === "pt") return "pt-br"; | |
| return norm; | |
| } catch (e) { | |
| return ""; | |
| } | |
| } | |
| }; | |
| // Security and validation utilities | |
| class KimiSecurityUtils { | |
| static sanitizeInput(input, type = "text") { | |
| if (typeof input !== "string") return ""; | |
| switch (type) { | |
| case "html": | |
| return input | |
| .replace(/&/g, "&") | |
| .replace(/</g, "<") | |
| .replace(/>/g, ">") | |
| .replace(/"/g, """) | |
| .replace(/'/g, "'"); | |
| case "number": | |
| const num = parseFloat(input); | |
| return isNaN(num) ? 0 : num; | |
| case "integer": | |
| const int = parseInt(input, 10); | |
| return isNaN(int) ? 0 : int; | |
| case "url": | |
| try { | |
| new URL(input); | |
| return input; | |
| } catch { | |
| return ""; | |
| } | |
| default: | |
| return input.trim(); | |
| } | |
| } | |
| static validateRange(value, min, max, defaultValue = 0) { | |
| const num = parseFloat(value); | |
| if (isNaN(num)) return defaultValue; | |
| return Math.max(min, Math.min(max, num)); | |
| } | |
| static validateApiKey(key) { | |
| if (!key || typeof key !== "string") return false; | |
| if (window.KIMI_VALIDATORS && typeof window.KIMI_VALIDATORS.validateApiKey === "function") { | |
| return !!window.KIMI_VALIDATORS.validateApiKey(key.trim()); | |
| } | |
| return key.trim().length > 10 && (key.startsWith("sk-") || key.startsWith("sk-or-")); | |
| } | |
| } | |
| // Cache management for better performance | |
| class KimiCacheManager { | |
| constructor(maxAge = 300000) { | |
| // 5 minutes default | |
| this.cache = new Map(); | |
| this.maxAge = maxAge; | |
| } | |
| set(key, value, customMaxAge = null) { | |
| const maxAge = customMaxAge || this.maxAge; | |
| this.cache.set(key, { | |
| value, | |
| timestamp: Date.now(), | |
| maxAge | |
| }); | |
| // Clean old entries periodically | |
| if (this.cache.size > 100) { | |
| this.cleanup(); | |
| } | |
| } | |
| get(key) { | |
| const entry = this.cache.get(key); | |
| if (!entry) return null; | |
| const now = Date.now(); | |
| if (now - entry.timestamp > entry.maxAge) { | |
| this.cache.delete(key); | |
| return null; | |
| } | |
| return entry.value; | |
| } | |
| has(key) { | |
| return this.get(key) !== null; | |
| } | |
| delete(key) { | |
| return this.cache.delete(key); | |
| } | |
| clear() { | |
| this.cache.clear(); | |
| } | |
| cleanup() { | |
| const now = Date.now(); | |
| for (const [key, entry] of this.cache.entries()) { | |
| if (now - entry.timestamp > entry.maxAge) { | |
| this.cache.delete(key); | |
| } | |
| } | |
| } | |
| getStats() { | |
| return { | |
| size: this.cache.size, | |
| keys: Array.from(this.cache.keys()) | |
| }; | |
| } | |
| } | |
| class KimiBaseManager { | |
| constructor() { | |
| // Common base for all managers | |
| } | |
| // Utility method to format file size | |
| formatFileSize(bytes) { | |
| if (bytes === 0) return "0 Bytes"; | |
| const k = 1024; | |
| const sizes = ["Bytes", "KB", "MB", "GB", "TB"]; | |
| const i = Math.floor(Math.log(bytes) / Math.log(k)); | |
| return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; | |
| } | |
| // Utility method for error handling | |
| handleError(error, context = "Operation") { | |
| console.error(`Error in ${context}:`, error); | |
| } | |
| // Utility method to wait | |
| async delay(ms) { | |
| return new Promise(resolve => setTimeout(resolve, ms)); | |
| } | |
| } | |
| // KimiVideoManager implementation moved to ./kimi-videos.js | |
| // Ensure the video manager module is evaluated so it registers itself on window | |
| import "./kimi-videos.js"; | |
| function getMoodCategoryFromPersonality(traits) { | |
| // Use unified emotion system | |
| if (window.kimiEmotionSystem) { | |
| return window.kimiEmotionSystem.getMoodCategoryFromPersonality(traits); | |
| } | |
| // Fallback (should not be reached) - must match emotion system calculation | |
| const keys = ["affection", "romance", "empathy", "playfulness", "humor", "intelligence"]; | |
| let sum = 0; | |
| let count = 0; | |
| keys.forEach(key => { | |
| if (typeof traits[key] === "number") { | |
| sum += traits[key]; | |
| count++; | |
| } | |
| }); | |
| const avg = count > 0 ? sum / count : 50; | |
| if (avg >= 80) return "speakingPositive"; | |
| if (avg >= 60) return "neutral"; | |
| if (avg >= 40) return "neutral"; | |
| if (avg >= 20) return "speakingNegative"; | |
| return "speakingNegative"; | |
| } | |
| // Expose personality → mood helper for video manager | |
| window.getMoodCategoryFromPersonality = getMoodCategoryFromPersonality; | |
| // Centralized initialization manager | |
| class KimiInitManager { | |
| constructor() { | |
| this.managers = new Map(); | |
| this.initOrder = []; | |
| this.isInitialized = false; | |
| } | |
| register(name, managerFactory, dependencies = [], delay = 0) { | |
| this.managers.set(name, { | |
| factory: managerFactory, | |
| dependencies, | |
| delay, | |
| instance: null, | |
| initialized: false | |
| }); | |
| } | |
| async initializeAll() { | |
| if (this.isInitialized) return; | |
| // Sort by dependencies and delays | |
| const sortedManagers = this.topologicalSort(); | |
| for (const managerName of sortedManagers) { | |
| await this.initializeManager(managerName); | |
| } | |
| this.isInitialized = true; | |
| } | |
| async initializeManager(name) { | |
| const manager = this.managers.get(name); | |
| if (!manager || manager.initialized) return; | |
| // Wait for dependencies | |
| for (const dep of manager.dependencies) { | |
| await this.initializeManager(dep); | |
| } | |
| // Apply delay if necessary | |
| if (manager.delay > 0) { | |
| await new Promise(resolve => setTimeout(resolve, manager.delay)); | |
| } | |
| try { | |
| manager.instance = await manager.factory(); | |
| manager.initialized = true; | |
| } catch (error) { | |
| console.error(`Error during initialization of ${name}:`, error); | |
| throw error; | |
| } | |
| } | |
| topologicalSort() { | |
| // Simple implementation of topological sort | |
| const sorted = []; | |
| const visited = new Set(); | |
| const temp = new Set(); | |
| const visit = name => { | |
| if (temp.has(name)) { | |
| throw new Error(`Circular dependency detected: ${name}`); | |
| } | |
| if (visited.has(name)) return; | |
| temp.add(name); | |
| const manager = this.managers.get(name); | |
| for (const dep of manager.dependencies) { | |
| visit(dep); | |
| } | |
| temp.delete(name); | |
| visited.add(name); | |
| sorted.push(name); | |
| }; | |
| for (const name of this.managers.keys()) { | |
| visit(name); | |
| } | |
| return sorted; | |
| } | |
| getInstance(name) { | |
| const manager = this.managers.get(name); | |
| return manager ? manager.instance : null; | |
| } | |
| } | |
| // Utility class for DOM manipulations | |
| class KimiDOMUtils { | |
| static get(selector) { | |
| return document.querySelector(selector); | |
| } | |
| static getAll(selector) { | |
| return document.querySelectorAll(selector); | |
| } | |
| static setText(selector, text) { | |
| const el = this.get(selector); | |
| if (el) el.textContent = text; | |
| } | |
| static setValue(selector, value) { | |
| const el = this.get(selector); | |
| if (el) el.value = value; | |
| } | |
| static show(selector) { | |
| const el = this.get(selector); | |
| if (el) el.style.display = ""; | |
| } | |
| static hide(selector) { | |
| const el = this.get(selector); | |
| if (el) el.style.display = "none"; | |
| } | |
| static toggle(selector) { | |
| const el = this.get(selector); | |
| if (el) el.style.display = el.style.display === "none" ? "" : "none"; | |
| } | |
| static addClass(selector, className) { | |
| const el = this.get(selector); | |
| if (el) el.classList.add(className); | |
| } | |
| static removeClass(selector, className) { | |
| const el = this.get(selector); | |
| if (el) el.classList.remove(className); | |
| } | |
| static transition(selector, property, value, duration = 300) { | |
| const el = this.get(selector); | |
| if (el) { | |
| el.style.transition = property + " " + duration + "ms"; | |
| el.style[property] = value; | |
| } | |
| } | |
| } | |
| // Déclaration complète de la classe KimiOverlayManager | |
| class KimiOverlayManager { | |
| constructor() { | |
| this.overlays = {}; | |
| this._initAll(); | |
| } | |
| _initAll() { | |
| const overlayIds = ["chat-container", "settings-overlay", "help-overlay"]; | |
| overlayIds.forEach(id => { | |
| const el = document.getElementById(id); | |
| if (el) { | |
| this.overlays[id] = el; | |
| if (id !== "chat-container") { | |
| el.addEventListener("click", e => { | |
| if (e.target === el) { | |
| this.close(id); | |
| } | |
| }); | |
| } | |
| } | |
| }); | |
| } | |
| open(name) { | |
| const el = this.overlays[name]; | |
| if (el) el.classList.add("visible"); | |
| } | |
| close(name) { | |
| const el = this.overlays[name]; | |
| if (el) el.classList.remove("visible"); | |
| // Ensure background video resumes after closing any overlay | |
| const kv = window.kimiVideo; | |
| if (kv && kv.activeVideo) { | |
| try { | |
| const v = kv.activeVideo; | |
| if (v.ended) { | |
| if (typeof kv.returnToNeutral === "function") kv.returnToNeutral(); | |
| } else if (v.paused) { | |
| v.play().catch(() => { | |
| if (typeof kv.returnToNeutral === "function") kv.returnToNeutral(); | |
| }); | |
| } | |
| } catch {} | |
| } | |
| } | |
| toggle(name) { | |
| const el = this.overlays[name]; | |
| if (el) el.classList.toggle("visible"); | |
| } | |
| isOpen(name) { | |
| const el = this.overlays[name]; | |
| return el ? el.classList.contains("visible") : false; | |
| } | |
| } | |
| function getCharacterInfo(characterName) { | |
| return window.KIMI_CHARACTERS[characterName] || window.KIMI_CHARACTERS.kimi; | |
| } | |
| // Restauration de la classe KimiTabManager | |
| class KimiTabManager { | |
| constructor(options = {}) { | |
| this.settingsOverlay = document.getElementById("settings-overlay"); | |
| this.settingsTabs = document.querySelectorAll(".settings-tab"); | |
| this.tabContents = document.querySelectorAll(".tab-content"); | |
| this.settingsContent = document.querySelector(".settings-content"); | |
| this.onTabChange = options.onTabChange || null; | |
| this.resizeObserver = null; | |
| // Guard flag to batch ResizeObserver callbacks within a frame | |
| this._resizeRafScheduled = false; | |
| this.init(); | |
| } | |
| init() { | |
| this.settingsTabs.forEach(tab => { | |
| tab.addEventListener("click", () => { | |
| this.activateTab(tab.dataset.tab); | |
| }); | |
| }); | |
| const activeTab = document.querySelector(".settings-tab.active"); | |
| if (activeTab) this.activateTab(activeTab.dataset.tab); | |
| this.setupResizeObserver(); | |
| this.setupModalObserver(); | |
| } | |
| activateTab(tabName) { | |
| this.settingsTabs.forEach(tab => { | |
| if (tab.dataset.tab === tabName) tab.classList.add("active"); | |
| else tab.classList.remove("active"); | |
| }); | |
| this.tabContents.forEach(content => { | |
| if (content.dataset.tab === tabName) content.classList.add("active"); | |
| else content.classList.remove("active"); | |
| }); | |
| // Ensure the content scroll resets to the top when changing tabs | |
| if (this.settingsContent) { | |
| this.settingsContent.scrollTop = 0; | |
| // Defer once to handle layout updates after class toggles | |
| window.requestAnimationFrame(() => { | |
| this.settingsContent.scrollTop = 0; | |
| }); | |
| } | |
| if (this.onTabChange) this.onTabChange(tabName); | |
| setTimeout(() => this.adjustTabsForScrollbar(), 100); | |
| if (window.innerWidth <= 768) { | |
| const tab = Array.from(this.settingsTabs).find(t => t.dataset.tab === tabName); | |
| if (tab) tab.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" }); | |
| } | |
| } | |
| setupResizeObserver() { | |
| if ("ResizeObserver" in window && this.settingsContent) { | |
| this.resizeObserver = new ResizeObserver(() => { | |
| // Defer to next animation frame to avoid ResizeObserver loop warnings | |
| if (this._resizeRafScheduled) return; | |
| this._resizeRafScheduled = true; | |
| window.requestAnimationFrame(() => { | |
| this._resizeRafScheduled = false; | |
| this.adjustTabsForScrollbar(); | |
| }); | |
| }); | |
| this.resizeObserver.observe(this.settingsContent); | |
| } | |
| } | |
| setupModalObserver() { | |
| if (!this.settingsOverlay) return; | |
| const observer = new MutationObserver(mutations => { | |
| mutations.forEach(mutation => { | |
| if (mutation.type === "attributes" && mutation.attributeName === "class") { | |
| if (this.settingsOverlay.classList.contains("visible")) { | |
| // Reset scroll to top when the settings modal opens | |
| if (this.settingsContent) { | |
| this.settingsContent.scrollTop = 0; | |
| window.requestAnimationFrame(() => { | |
| this.settingsContent.scrollTop = 0; | |
| }); | |
| } | |
| } | |
| } | |
| }); | |
| }); | |
| observer.observe(this.settingsOverlay, { attributes: true, attributeFilter: ["class"] }); | |
| } | |
| adjustTabsForScrollbar() { | |
| if (!this.settingsContent || !this.settingsTabs.length) return; | |
| const tabsContainer = document.querySelector(".settings-tabs"); | |
| const hasVerticalScrollbar = this.settingsContent.scrollHeight > this.settingsContent.clientHeight; | |
| if (hasVerticalScrollbar) { | |
| const scrollbarWidth = this.settingsContent.offsetWidth - this.settingsContent.clientWidth; | |
| tabsContainer.classList.add("compressed"); | |
| tabsContainer.style.paddingRight = `${Math.max(scrollbarWidth, 8)}px`; | |
| tabsContainer.style.boxSizing = "border-box"; | |
| const tabs = tabsContainer.querySelectorAll(".settings-tab"); | |
| const availableWidth = tabsContainer.clientWidth - scrollbarWidth; | |
| const tabCount = tabs.length; | |
| const idealTabWidth = availableWidth / tabCount; | |
| tabs.forEach(tab => { | |
| if (idealTabWidth < 140) { | |
| tab.style.fontSize = "0.85rem"; | |
| tab.style.padding = "14px 10px"; | |
| } else if (idealTabWidth < 160) { | |
| tab.style.fontSize = "0.95rem"; | |
| tab.style.padding = "15px 12px"; | |
| } else { | |
| tab.style.fontSize = "1rem"; | |
| tab.style.padding = "16px 16px"; | |
| } | |
| }); | |
| } else { | |
| tabsContainer.classList.remove("compressed"); | |
| tabsContainer.style.paddingRight = ""; | |
| tabsContainer.style.boxSizing = ""; | |
| const tabs = tabsContainer.querySelectorAll(".settings-tab"); | |
| tabs.forEach(tab => { | |
| tab.style.fontSize = ""; | |
| tab.style.padding = ""; | |
| }); | |
| } | |
| } | |
| } | |
| class KimiUIEventManager { | |
| constructor() { | |
| this.events = []; | |
| } | |
| addEvent(target, type, handler, options) { | |
| target.addEventListener(type, handler, options); | |
| this.events.push({ target, type, handler, options }); | |
| } | |
| removeAll() { | |
| for (const { target, type, handler, options } of this.events) { | |
| target.removeEventListener(type, handler, options); | |
| } | |
| this.events = []; | |
| } | |
| } | |
| class KimiFormManager { | |
| constructor(options = {}) { | |
| this.db = options.db || null; | |
| this.memory = options.memory || null; | |
| this._autoInit = options.autoInit === true; | |
| if (this._autoInit) { | |
| this._initSliders(); | |
| } | |
| } | |
| init() { | |
| this._initSliders(); | |
| } | |
| _initSliders() { | |
| document.querySelectorAll(".kimi-slider").forEach(slider => { | |
| const valueSpan = document.getElementById(slider.id + "-value"); | |
| if (valueSpan) valueSpan.textContent = slider.value; | |
| // Only update visible value; side-effects handled by specialized listeners | |
| slider.addEventListener("input", () => { | |
| if (valueSpan) valueSpan.textContent = slider.value; | |
| }); | |
| }); | |
| } | |
| } | |
| class KimiUIStateManager { | |
| constructor() { | |
| this.state = { | |
| overlays: {}, | |
| activeTab: null, | |
| favorability: 65, | |
| transcript: "", | |
| chatOpen: false, | |
| settingsOpen: false, | |
| micActive: false, | |
| sliders: {} | |
| }; | |
| this.overlayManager = window.kimiOverlayManager || null; | |
| this.tabManager = window.kimiTabManager || null; | |
| this.formManager = window.kimiFormManager || null; | |
| } | |
| setOverlay(name, visible) { | |
| this.state.overlays[name] = visible; | |
| if (this.overlayManager) { | |
| if (visible) this.overlayManager.open(name); | |
| else this.overlayManager.close(name); | |
| } | |
| } | |
| setActiveTab(tabName) { | |
| this.state.activeTab = tabName; | |
| if (this.tabManager) this.tabManager.activateTab(tabName); | |
| } | |
| /** | |
| * @deprecated Prefer calling updateGlobalPersonalityUI() after updating traits. | |
| * This direct setter will be removed in a future cleanup. | |
| */ | |
| setPersonalityAverage(value) { | |
| const v = Number(value) || 0; | |
| const clamped = Math.max(0, Math.min(100, v)); | |
| this.state.favorability = clamped; | |
| window.KimiDOMUtils.setText("#favorability-text", `${clamped.toFixed(2)}%`); | |
| window.KimiDOMUtils.get("#favorability-bar").style.width = `${clamped}%`; | |
| } | |
| /** | |
| * @deprecated Use setPersonalityAverage() (itself deprecated) or updateGlobalPersonalityUI(). | |
| */ | |
| setFavorability(value) { | |
| this.setPersonalityAverage(value); | |
| } | |
| async setTranscript(text) { | |
| this.state.transcript = text; | |
| // Always use the proper transcript management via VoiceManager | |
| if (window.kimiVoiceManager && window.kimiVoiceManager.updateTranscriptVisibility) { | |
| await window.kimiVoiceManager.updateTranscriptVisibility(!!text, text); | |
| } else { | |
| console.warn("VoiceManager not available - transcript display may not work properly"); | |
| } | |
| } | |
| setChatOpen(open) { | |
| this.state.chatOpen = open; | |
| this.setOverlay("chat-container", open); | |
| } | |
| setSettingsOpen(open) { | |
| this.state.settingsOpen = open; | |
| this.setOverlay("settings-overlay", open); | |
| } | |
| setMicActive(active) { | |
| this.state.micActive = active; | |
| window.KimiDOMUtils.get("#mic-button").classList.toggle("active", active); | |
| } | |
| setSlider(id, value) { | |
| this.state.sliders[id] = value; | |
| if (this.formManager) { | |
| const slider = document.getElementById(id); | |
| if (slider) slider.value = value; | |
| const valueSpan = document.getElementById(id + "-value"); | |
| if (valueSpan) valueSpan.textContent = value; | |
| } | |
| } | |
| getState() { | |
| return { ...this.state }; | |
| } | |
| } | |
| // SIMPLE Fallback management - BASIC ONLY | |
| window.KimiFallbackManager = { | |
| getFallbackMessage: function (errorType, customMessage = null) { | |
| const i18n = window.kimiI18nManager; | |
| // If i18n is available, try to get translated message | |
| if (i18n && typeof i18n.t === "function") { | |
| if (customMessage) { | |
| const translated = i18n.t(customMessage); | |
| if (translated && translated !== customMessage) { | |
| return translated; | |
| } | |
| } | |
| const translationKey = `fallback_${errorType}`; | |
| const translated = i18n.t(translationKey); | |
| if (translated && translated !== translationKey) { | |
| return translated; | |
| } | |
| } | |
| // Fallback to hardcoded messages in multiple languages | |
| const fallbacks = { | |
| api_missing: { | |
| fr: "Pour discuter avec moi, ajoute ta clé API du provider choisi dans les paramètres ! 💕", | |
| en: "To chat with me, add your selected provider API key in settings! 💕", | |
| es: "Para chatear conmigo, agrega la clave API de tu proveedor en configuración! 💕", | |
| de: "Um mit mir zu chatten, füge deinen Anbieter-API-Schlüssel in den Einstellungen hinzu! 💕", | |
| it: "Per chattare con me, aggiungi la chiave API del provider nelle impostazioni! 💕" | |
| }, | |
| api_error: { | |
| fr: "Désolée, le service IA est temporairement indisponible. Veuillez réessayer plus tard.", | |
| en: "Sorry, the AI service is temporarily unavailable. Please try again later.", | |
| es: "Lo siento, el servicio de IA no está disponible temporalmente. Inténtalo de nuevo más tarde.", | |
| de: "Entschuldigung, der KI-Service ist vorübergehend nicht verfügbar. Bitte versuchen Sie es später erneut.", | |
| it: "Spiacente, il servizio IA è temporaneamente non disponibile. Riprova più tardi." | |
| }, | |
| model_error: { | |
| fr: "Désolée, le modèle sélectionné n'est pas disponible. Veuillez choisir un autre modèle.", | |
| en: "Sorry, the selected model is not available. Please choose another model.", | |
| es: "Lo siento, el modelo seleccionado no está disponible. Elige otro modelo.", | |
| de: "Entschuldigung, das ausgewählte Modell ist nicht verfügbar. Bitte wählen Sie ein anderes Modell.", | |
| it: "Spiacente, il modello selezionato non è disponibile. Scegli un altro modello." | |
| } | |
| }; | |
| // Detect current language (fallback detection) | |
| const currentLang = this.detectCurrentLanguage(); | |
| if (fallbacks[errorType] && fallbacks[errorType][currentLang]) { | |
| return fallbacks[errorType][currentLang]; | |
| } | |
| // Ultimate fallback to English | |
| if (fallbacks[errorType] && fallbacks[errorType].en) { | |
| return fallbacks[errorType].en; | |
| } | |
| switch (errorType) { | |
| case "api_missing": | |
| return "To chat with me, add your API key in settings! 💕"; | |
| case "api_error": | |
| case "api": | |
| return "Sorry, the AI service is temporarily unavailable. Please try again later."; | |
| case "model_error": | |
| case "model": | |
| return "Sorry, the selected model is not available. Please choose another model or check your configuration."; | |
| case "network_error": | |
| case "network": | |
| return "Sorry, I cannot respond because there is no internet connection."; | |
| case "technical_error": | |
| case "technical": | |
| return "Sorry, I am unable to answer due to a technical issue."; | |
| case "general_error": | |
| default: | |
| return "Sorry my love, I am having a little technical issue! 💕"; | |
| } | |
| }, | |
| detectCurrentLanguage: function () { | |
| // Try to get language from various sources | |
| // 1. Try from language selector if available | |
| const langSelect = document.getElementById("language-selection"); | |
| if (langSelect && langSelect.value) { | |
| return langSelect.value; | |
| } | |
| // 2. Try from HTML lang attribute | |
| const htmlLang = document.documentElement.lang; | |
| if (htmlLang) { | |
| return htmlLang.split("-")[0]; // Get just the language part | |
| } | |
| // 3. Try from browser language | |
| const browserLang = navigator.language || navigator.userLanguage; | |
| if (browserLang) { | |
| return browserLang.split("-")[0]; | |
| } | |
| // 4. Default to English (as seems to be the default for this app) | |
| return "en"; | |
| }, | |
| showFallbackResponse: async function (errorType, customMessage = null) { | |
| const message = this.getFallbackMessage(errorType, customMessage); | |
| // Add to chat | |
| if (window.addMessageToChat) { | |
| window.addMessageToChat("kimi", message); | |
| } | |
| // Speak if available | |
| if (window.voiceManager && window.voiceManager.speak) { | |
| window.voiceManager.speak(message); | |
| } | |
| // SIMPLE: Always show neutral videos in fallback mode | |
| if (window.kimiVideo && window.kimiVideo.switchToContext) { | |
| window.kimiVideo.switchToContext("neutral", "neutral"); | |
| } | |
| return message; | |
| } | |
| }; | |
| window.KimiBaseManager = KimiBaseManager; | |
| // KimiVideoManager is provided by the separate module `kimi-videos.js` which sets | |
| // `window.KimiVideoManager` when executed. Do not reference the symbol here to | |
| // avoid ReferenceError during module evaluation. | |
| window.KimiSecurityUtils = KimiSecurityUtils; | |
| window.KimiCacheManager = new KimiCacheManager(); // Create global instance | |
| // Expose helper used by the video manager | |
| window.getCharacterInfo = getCharacterInfo; | |
| window.KimiInitManager = KimiInitManager; | |
| window.KimiDOMUtils = KimiDOMUtils; | |
| window.KimiOverlayManager = KimiOverlayManager; | |
| window.KimiTabManager = KimiTabManager; | |
| window.KimiUIEventManager = KimiUIEventManager; | |
| window.KimiFormManager = KimiFormManager; | |
| window.KimiUIStateManager = KimiUIStateManager; | |
| window.KimiTokenUtils = { | |
| // Approximate token estimation (heuristic): | |
| // Base: 1 token ~ 4 chars (English average). We refine by word count and punctuation density. | |
| estimate(text) { | |
| if (!text || typeof text !== "string") return 0; | |
| const trimmed = text.trim(); | |
| if (!trimmed) return 0; | |
| const charLen = trimmed.length; | |
| const words = trimmed.split(/\s+/).length; | |
| // Base estimates | |
| let estimateByChars = Math.ceil(charLen / 4); | |
| const estimateByWords = Math.ceil(words * 1.3); // average 1.3 tokens per word | |
| // Blend and adjust for punctuation heavy content | |
| const punctCount = (trimmed.match(/[.,!?;:]/g) || []).length; | |
| const punctFactor = 1 + Math.min(punctCount / Math.max(words, 1) / 5, 0.15); // cap at +15% | |
| const blended = Math.round((estimateByChars * 0.55 + estimateByWords * 0.45) * punctFactor); | |
| return Math.max(1, blended); | |
| } | |
| }; | |