// 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; }, validateRange(value, key) { const bounds = { voiceRate: { min: 0.5, max: 2, def: 1.1 }, voicePitch: { min: 0, max: 2, def: 1.0 }, 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) { // Centralized: always use 'providerApiKey' for all providers except Ollama return provider === "ollama" ? null : "providerApiKey"; }, async getApiKey(db, provider) { if (!db) return null; if (provider === "ollama") return "__local__"; return await db.getPreference("providerApiKey"); }, 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; export { KimiProviderUtils }; // 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"; const supportedLangs = ["en", "fr", "es", "de", "it", "ja", "zh"]; 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 (/[\u3040-\u309f\u30a0-\u30ff\u4e00-\u9faf]/i.test(text)) return "ja"; if (/[\u4e00-\u9fff]/i.test(text)) return "zh"; return "en"; } }; // 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, "'"); 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)); } } // Utility class for centralized video management class KimiVideoManager { constructor(video1, video2, characterName = "kimi") { this.characterName = characterName; this.video1 = video1; this.video2 = video2; this.activeVideo = video1; this.inactiveVideo = video2; this.currentContext = "neutral"; this.currentEmotion = "neutral"; this.lastSwitchTime = Date.now(); this.pendingSwitch = null; this.autoTransitionDuration = 9900; this.transitionDuration = 300; this._prefetchCache = new Map(); this._prefetchInFlight = new Set(); this._maxPrefetch = 3; this._loadTimeout = null; this.updateVideoCategories(); this.emotionToCategory = { listening: "listening", positive: "speakingPositive", negative: "speakingNegative", neutral: "neutral", surprise: "speakingPositive", laughing: "speakingPositive", shy: "neutral", confident: "speakingPositive", romantic: "speakingPositive", flirtatious: "speakingPositive", goodbye: "neutral", kiss: "speakingPositive", dancing: "dancing", speaking: "speakingPositive", speakingPositive: "speakingPositive", speakingNegative: "speakingNegative" }; this.positiveVideos = this.videoCategories.speakingPositive; this.negativeVideos = this.videoCategories.speakingNegative; this.neutralVideos = this.videoCategories.neutral; // Anti-repetition and scoring - Adaptive history based on available videos this.playHistory = { listening: [], speakingPositive: [], speakingNegative: [], neutral: [], dancing: [] }; this.maxHistoryPerCategory = 5; // Will be dynamically adjusted per category this.emotionHistory = []; this.maxEmotionHistory = 5; this._neutralLock = false; this.isEmotionVideoPlaying = false; this.currentEmotionContext = null; this._switchInProgress = false; this._loadingInProgress = false; this._currentLoadHandler = null; this._currentErrorHandler = null; this._stickyContext = null; this._stickyUntil = 0; this._pendingSwitches = []; this._debug = false; // Adaptive timeout refinements (A+B+C) this._maxTimeout = 6000; // Reduced upper bound (was 10000) for 10s clips this._timeoutExtension = 1200; // Extension when metadata only this._timeoutCapRatio = 0.7; // Cap total wait <= 70% clip length // Initialize adaptive loading metrics and failure tracking this._avgLoadTime = null; this._loadTimeSamples = []; this._maxSamples = 10; this._minTimeout = 3000; this._recentFailures = new Map(); this._failureCooldown = 5000; this._consecutiveErrorCount = 0; } //Centralized crossfade transition between two videos. static crossfadeVideos(fromVideo, toVideo, duration = 300, onComplete) { // Resolve duration from CSS variable if present try { const cssDur = getComputedStyle(document.documentElement).getPropertyValue("--video-fade-duration").trim(); if (cssDur) { // Convert CSS time to ms number if needed (e.g., '300ms' or '0.3s') if (cssDur.endsWith("ms")) duration = parseFloat(cssDur); else if (cssDur.endsWith("s")) duration = Math.round(parseFloat(cssDur) * 1000); } } catch {} // Preload and strict synchronization const easing = "ease-in-out"; fromVideo.style.transition = `opacity ${duration}ms ${easing}`; toVideo.style.transition = `opacity ${duration}ms ${easing}`; // Prepare target video (opacity 0, top z-index) toVideo.style.opacity = "0"; toVideo.style.zIndex = "2"; fromVideo.style.zIndex = "1"; // Start target video slightly before the crossfade const startTarget = () => { if (toVideo.paused) toVideo.play().catch(() => {}); // Lance le fondu croisé setTimeout(() => { fromVideo.style.opacity = "0"; toVideo.style.opacity = "1"; }, 20); // After transition, adjust z-index and call the callback setTimeout(() => { fromVideo.style.zIndex = "1"; toVideo.style.zIndex = "2"; if (onComplete) onComplete(); }, duration + 30); }; // If target video is not ready, wait for canplay if (toVideo.readyState < 3) { toVideo.addEventListener("canplay", startTarget, { once: true }); toVideo.load(); } else { startTarget(); } // Ensure source video is playing if (fromVideo.paused) fromVideo.play().catch(() => {}); } //Centralized video element creation utility. static createVideoElement(id, className = "bg-video") { const video = document.createElement("video"); video.id = id; video.className = className; video.autoplay = true; video.muted = true; video.playsinline = true; video.preload = "auto"; video.style.opacity = "0"; video.innerHTML = 'Your browser does not support the video tag.'; return video; } //Centralized video selection utility. static getVideoElement(selector) { if (typeof selector === "string") { if (selector.startsWith("#")) { return document.getElementById(selector.slice(1)); } return document.querySelector(selector); } return selector; } setDebug(enabled) { this._debug = !!enabled; } _logDebug(message, payload = null) { if (!this._debug) return; if (payload) console.log("🎬 VideoManager:", message, payload); else console.log("🎬 VideoManager:", message); } _logSelection(category, selectedSrc, candidates = []) { if (!this._debug) return; const recent = (this.playHistory && this.playHistory[category]) || []; const adaptive = typeof this.getAdaptiveHistorySize === "function" ? this.getAdaptiveHistorySize(category) : null; console.log("🎬 VideoManager: selection", { category, selected: selectedSrc, candidatesCount: Array.isArray(candidates) ? candidates.length : 0, adaptiveHistorySize: adaptive, recentHistory: recent }); } debugPrintHistory(category = null) { if (!this._debug) return; if (!this.playHistory) { console.log("🎬 VideoManager: no play history yet"); return; } if (category) { const recent = this.playHistory[category] || []; console.log("🎬 VideoManager: history", { category, recent }); return; } const summary = Object.keys(this.playHistory).reduce((acc, key) => { acc[key] = this.playHistory[key]; return acc; }, {}); console.log("🎬 VideoManager: history summary", summary); } _priorityWeight(context) { if (context === "speaking" || context === "speakingPositive" || context === "speakingNegative") return 3; if (context === "dancing" || context === "listening") return 2; return 1; } _enqueuePendingSwitch(req) { // Keep small bounded list; prefer newest higher-priority const maxSize = 5; this._pendingSwitches.push(req); if (this._pendingSwitches.length > maxSize) { this._pendingSwitches = this._pendingSwitches.slice(-maxSize); } } _takeNextPendingSwitch() { if (!this._pendingSwitches.length) return null; let bestIdx = 0; let best = this._pendingSwitches[0]; for (let i = 1; i < this._pendingSwitches.length; i++) { const cand = this._pendingSwitches[i]; if (cand.priorityWeight > best.priorityWeight) { best = cand; bestIdx = i; } else if (cand.priorityWeight === best.priorityWeight && cand.requestedAt > best.requestedAt) { best = cand; bestIdx = i; } } this._pendingSwitches.splice(bestIdx, 1); return best; } _processPendingSwitches() { if (this._stickyContext === "dancing") return false; const next = this._takeNextPendingSwitch(); if (!next) return false; this._logDebug("Processing pending switch", next); this.switchToContext(next.context, next.emotion, next.specificVideo, next.traits, next.affection); return true; } setCharacter(characterName) { this.characterName = characterName; // Nettoyer les handlers en cours lors du changement de personnage this._cleanupLoadingHandlers(); // Reset per-character fallback pool so it will be rebuilt for the new character this._fallbackPool = null; this._fallbackIndex = 0; this._fallbackPoolCharacter = null; this.updateVideoCategories(); } updateVideoCategories() { const folder = getCharacterInfo(this.characterName).videoFolder; this.videoCategories = { listening: [ `${folder}listening/listening-gentle-sway.mp4`, `${folder}listening/listening-magnetic-eye-gaze.mp4`, `${folder}listening/listening-silky-caressing-hairplay.mp4`, `${folder}listening/listening-softly-velvet-glance.mp4`, `${folder}listening/listening-surprise-sweet-shiver.mp4`, `${folder}listening/listening-whispered-attention.mp4`, `${folder}listening/listening-hand-gesture.mp4`, `${folder}listening/listening-hair-touch.mp4`, `${folder}listening/listening-full-spin.mp4`, `${folder}listening/listening-teasing-smile.mp4`, `${folder}listening/listening-dreamy-gaze-romantic.mp4` ], speakingPositive: [ `${folder}speaking-positive/speaking-happy-gestures.mp4`, `${folder}speaking-positive/speaking-positive-heartfelt-shine.mp4`, `${folder}speaking-positive/speaking-positive-joyful-flutter.mp4`, `${folder}speaking-positive/speaking-positive-mischief-touch.mp4`, `${folder}speaking-positive/speaking-positive-sparkling-tease.mp4`, `${folder}speaking-positive/speaking-playful-wink.mp4`, `${folder}speaking-positive/speaking-excited-clapping.mp4`, `${folder}speaking-positive/speaking-heart-gesture.mp4`, `${folder}speaking-positive/speaking-surprise-graceful-gasp.mp4`, `${folder}speaking-positive/speaking-laughing-melodious.mp4`, `${folder}speaking-positive/speaking-gentle-smile.mp4`, `${folder}speaking-positive/speaking-graceful-arms.mp4`, `${folder}speaking-positive/speaking-flirtatious-tease.mp4` ], speakingNegative: [ `${folder}speaking-negative/speaking-negative-anxious-caress.mp4`, `${folder}speaking-negative/speaking-negative-frosted-glance.mp4`, `${folder}speaking-negative/speaking-negative-muted-longing.mp4`, `${folder}speaking-negative/speaking-negative-shadowed-sigh.mp4`, `${folder}speaking-negative/speaking-sad-elegant.mp4`, `${folder}speaking-negative/speaking-frustrated-graceful.mp4`, `${folder}speaking-negative/speaking-worried-tender.mp4`, `${folder}speaking-negative/speaking-disappointed-elegant.mp4`, `${folder}speaking-negative/speaking-gentle-wave-goodbye.mp4` ], neutral: [ `${folder}neutral/neutral-thinking-pose.mp4`, `${folder}neutral/neutral-shy-blush-adorable.mp4`, `${folder}neutral/neutral-confident-chic-flair.mp4`, `${folder}neutral/neutral-dreamy-soft-reverie.mp4`, `${folder}neutral/neutral-flirt-wink-whisper.mp4`, `${folder}neutral/neutral-goodbye-tender-wave.mp4`, `${folder}neutral/neutral-hair-twirl.mp4`, `${folder}neutral/neutral-kiss-air-caress.mp4`, `${folder}neutral/neutral-poised-shift.mp4`, `${folder}neutral/neutral-shy-blush-glow.mp4`, `${folder}neutral/neutral-speaking-dreamy-flow.mp4`, `${folder}neutral/neutral-gentle-breathing.mp4`, `${folder}neutral/neutral-hair-adjustment.mp4`, `${folder}neutral/neutral-arms-crossed-elegant.mp4`, `${folder}neutral/neutral-seductive-slow-gaze.mp4`, `${folder}neutral/neutral-confident-pose-alluring.mp4`, `${folder}neutral/neutral-affectionate-kiss-blow.mp4` ], dancing: [ `${folder}dancing/dancing-chin-hand.mp4`, `${folder}dancing/dancing-bow-promise.mp4`, `${folder}dancing/dancing-enchanting-flow.mp4`, `${folder}dancing/dancing-magnetic-spin.mp4`, `${folder}dancing/dancing-playful-glimmer.mp4`, `${folder}dancing/dancing-silken-undulation.mp4`, `${folder}dancing/dancing-full-spin.mp4`, `${folder}dancing/dancing-seductive-dance-undulation.mp4`, `${folder}dancing/dancing-slow-seductive.mp4`, `${folder}dancing/dancing-spinning-elegance-twirl.mp4` ] }; this.positiveVideos = this.videoCategories.speakingPositive; this.negativeVideos = this.videoCategories.speakingNegative; this.neutralVideos = this.videoCategories.neutral; const neutrals = this.neutralVideos || []; // Progressive warm-up phase: start with only 2 neutrals (adaptive on network), others scheduled later let neutralPrefetchCount = 2; try { const conn = navigator.connection || navigator.webkitConnection || navigator.mozConnection; if (conn && conn.effectiveType) { // Reduce on slower connections if (/2g/i.test(conn.effectiveType)) neutralPrefetchCount = 1; else if (/3g/i.test(conn.effectiveType)) neutralPrefetchCount = 2; } } catch {} neutrals.slice(0, neutralPrefetchCount).forEach(src => this._prefetch(src)); // Schedule warm-up step 2: after 5s prefetch the 3rd neutral if not already cached if (!this._warmupTimer) { this._warmupTimer = setTimeout(() => { try { const target = neutrals[2]; if (target && !this._prefetchCache.has(target)) this._prefetch(target); } catch {} }, 5000); } // Mark waiting for first interaction to fetch 4th neutral later this._awaitingFirstInteraction = true; } async init(database = null) { // Attach lightweight visibility guard if (!this._visibilityHandler) { this._visibilityHandler = this.onVisibilityChange.bind(this); document.addEventListener("visibilitychange", this._visibilityHandler); } // Hook basic user interaction (first click / keypress) to advance warm-up if (!this._firstInteractionHandler) { this._firstInteractionHandler = () => { if (this._awaitingFirstInteraction) { this._awaitingFirstInteraction = false; try { const neutrals = this.neutralVideos || []; const fourth = neutrals[3]; if (fourth && !this._prefetchCache.has(fourth)) this._prefetch(fourth); } catch {} } }; window.addEventListener("click", this._firstInteractionHandler, { once: true }); window.addEventListener("keydown", this._firstInteractionHandler, { once: true }); } } onVisibilityChange() { if (document.visibilityState !== "visible") return; const v = this.activeVideo; if (!v) return; try { if (v.ended) { if (typeof this.returnToNeutral === "function") this.returnToNeutral(); } else if (v.paused) { v.play().catch(() => { if (typeof this.returnToNeutral === "function") this.returnToNeutral(); }); } } catch {} } // Intelligent contextual management switchToContext(context, emotion = "neutral", specificVideo = null, traits = null, affection = null) { // Respect sticky context (avoid overrides while dancing is requested/playing) if (this._stickyContext === "dancing" && context !== "dancing") { const categoryForPriority = this.determineCategory(context, emotion, traits); const priorityWeight = this._priorityWeight( categoryForPriority === "speakingPositive" || categoryForPriority === "speakingNegative" ? "speaking" : context ); if (Date.now() < (this._stickyUntil || 0)) { this._enqueuePendingSwitch({ context, emotion, specificVideo, traits, affection, requestedAt: Date.now(), priorityWeight }); this._logDebug("Queued during dancing (sticky)", { context, emotion, priorityWeight }); return; } this._stickyContext = null; this._stickyUntil = 0; // Do not reset adaptive loading metrics here; preserve rolling stats across sticky context release } // While an emotion video is playing (speaking), block non-speaking context switches if ( this.isEmotionVideoPlaying && (this.currentContext === "speaking" || this.currentContext === "speakingPositive" || this.currentContext === "speakingNegative") && !(context === "speaking" || context === "speakingPositive" || context === "speakingNegative") ) { // Queue the request with appropriate priority to be processed after current clip const categoryForPriority = this.determineCategory(context, emotion, traits); const priorityWeight = this._priorityWeight( categoryForPriority === "speakingPositive" || categoryForPriority === "speakingNegative" ? "speaking" : context ); this._enqueuePendingSwitch({ context, emotion, specificVideo, traits, affection, requestedAt: Date.now(), priorityWeight }); this._logDebug("Queued non-speaking during speaking emotion", { context, emotion, priorityWeight }); return; } // While speaking emotion video is playing, also queue speaking→speaking changes (avoid mid-clip replacement) if ( this.isEmotionVideoPlaying && (this.currentContext === "speaking" || this.currentContext === "speakingPositive" || this.currentContext === "speakingNegative") && (context === "speaking" || context === "speakingPositive" || context === "speakingNegative") && this.currentEmotionContext && this.currentEmotionContext !== emotion ) { const priorityWeight = this._priorityWeight("speaking"); this._enqueuePendingSwitch({ context, emotion, specificVideo, traits, affection, requestedAt: Date.now(), priorityWeight }); this._logDebug("Queued speaking→speaking during active emotion", { from: this.currentEmotionContext, to: emotion }); return; } if (context === "neutral" && this._neutralLock) return; if ( (context === "speaking" || context === "speakingPositive" || context === "speakingNegative") && this.isEmotionVideoPlaying && this.currentEmotionContext === emotion ) return; if (this.currentContext === context && this.currentEmotion === emotion && !specificVideo) { const category = this.determineCategory(context, emotion, traits); const currentVideoSrc = this.activeVideo.querySelector("source").getAttribute("src"); const availableVideos = this.videoCategories[category] || this.videoCategories.neutral; const differentVideos = availableVideos.filter(v => v !== currentVideoSrc); if (differentVideos.length > 0) { const nextVideo = typeof this._pickScoredVideo === "function" ? this._pickScoredVideo(category, differentVideos, traits) : differentVideos[Math.floor(Math.random() * differentVideos.length)]; this.loadAndSwitchVideo(nextVideo, "normal"); // Track play history to avoid immediate repeats if (typeof this.updatePlayHistory === "function") this.updatePlayHistory(category, nextVideo); this._logSelection(category, nextVideo, differentVideos); this.lastSwitchTime = Date.now(); } return; } // Determine the category FIRST to ensure correct video selection const category = this.determineCategory(context, emotion, traits); // Déterminer la priorité selon le contexte let priority = "normal"; if (context === "speaking" || context === "speakingPositive" || context === "speakingNegative") { priority = "speaking"; } else if (context === "dancing" || context === "listening") { priority = "high"; } // Set sticky lock for dancing to avoid being interrupted by emotion/neutral updates if (context === "dancing") { this._stickyContext = "dancing"; // Lock roughly for one clip duration; will also be cleared on end/neutral this._stickyUntil = Date.now() + 9500; } // Chemin optimisé lorsque TTS parle/écoute (évite clignotements) if ( window.voiceManager && window.voiceManager.isSpeaking && (context === "speaking" || context === "speakingPositive" || context === "speakingNegative") ) { const speakingPath = this.selectOptimalVideo(category, specificVideo, traits, affection, emotion); const speakingCurrent = this.activeVideo.querySelector("source").getAttribute("src"); if (speakingCurrent !== speakingPath || this.activeVideo.ended) { this.loadAndSwitchVideo(speakingPath, priority); } // IMPORTANT: normalize to the resolved category (e.g., speakingPositive/Negative) this.currentContext = category; this.currentEmotion = emotion; this.lastSwitchTime = Date.now(); return; } if (window.voiceManager && window.voiceManager.isListening && context === "listening") { const listeningPath = this.selectOptimalVideo(category, specificVideo, traits, affection, emotion); const listeningCurrent = this.activeVideo.querySelector("source").getAttribute("src"); if (listeningCurrent !== listeningPath || this.activeVideo.ended) { this.loadAndSwitchVideo(listeningPath, priority); } // Normalize to category for consistency this.currentContext = category; this.currentEmotion = emotion; this.lastSwitchTime = Date.now(); return; } // Sélection standard let videoPath = this.selectOptimalVideo(category, specificVideo, traits, affection, emotion); const currentVideoSrc = this.activeVideo.querySelector("source").getAttribute("src"); // Anti-répétition si plusieurs vidéos disponibles if (videoPath === currentVideoSrc && (this.videoCategories[category] || []).length > 1) { const alternatives = this.videoCategories[category].filter(v => v !== currentVideoSrc); if (alternatives.length > 0) { videoPath = typeof this._pickScoredVideo === "function" ? this._pickScoredVideo(category, alternatives, traits) : alternatives[Math.floor(Math.random() * alternatives.length)]; } } // Adaptive transition timing based on context and priority let minTransitionDelay = 300; const now = Date.now(); const timeSinceLastSwitch = now - (this.lastSwitchTime || 0); // Context-specific timing adjustments if (priority === "speaking") { minTransitionDelay = 200; } else if (context === "listening") { minTransitionDelay = 250; } else if (context === "dancing") { minTransitionDelay = 600; } else if (context === "neutral") { minTransitionDelay = 1200; } // Prevent rapid switching only if not critical if ( this.currentContext === context && this.currentEmotion === emotion && currentVideoSrc === videoPath && !this.activeVideo.paused && !this.activeVideo.ended && timeSinceLastSwitch < minTransitionDelay && priority !== "speaking" // Always allow speech to interrupt ) { return; } this._prefetchLikely(category); this.loadAndSwitchVideo(videoPath, priority); // Always store normalized category as currentContext so event bindings match speakingPositive/Negative this.currentContext = category; this.currentEmotion = emotion; this.lastSwitchTime = now; } setupEventListenersForContext(context) { // Clean previous if (this._globalEndedHandler) { this.activeVideo.removeEventListener("ended", this._globalEndedHandler); this.inactiveVideo.removeEventListener("ended", this._globalEndedHandler); } // Defensive: ensure helpers exist if (!this.playHistory) this.playHistory = {}; if (!this.maxHistoryPerCategory) this.maxHistoryPerCategory = 8; // For dancing: auto-return to neutral after video ends to avoid freeze if (context === "dancing") { this._globalEndedHandler = () => { this._stickyContext = null; this._stickyUntil = 0; if (!this._processPendingSwitches()) { this.returnToNeutral(); } }; this.activeVideo.addEventListener("ended", this._globalEndedHandler, { once: true }); // Safety timer if (typeof this.scheduleAutoTransition === "function") { this.scheduleAutoTransition(this.autoTransitionDuration || 10000); } return; } if (context === "speakingPositive" || context === "speakingNegative") { this._globalEndedHandler = () => { // If TTS is still speaking, keep the speaking flow by chaining another speaking clip if (window.voiceManager && window.voiceManager.isSpeaking) { const emotion = this.currentEmotion || this.currentEmotionContext || "positive"; // Preserve speaking context while chaining const category = emotion === "negative" ? "speakingNegative" : "speakingPositive"; const next = this.selectOptimalVideo(category, null, null, null, emotion); if (next) { this.loadAndSwitchVideo(next, "speaking"); this.currentContext = category; this.currentEmotion = emotion; this.isEmotionVideoPlaying = true; this.currentEmotionContext = emotion; this.lastSwitchTime = Date.now(); return; } } // Otherwise, allow pending high-priority switch or return to neutral this.isEmotionVideoPlaying = false; this.currentEmotionContext = null; this._neutralLock = false; if (!this._processPendingSwitches()) { this.returnToNeutral(); } }; this.activeVideo.addEventListener("ended", this._globalEndedHandler, { once: true }); return; } if (context === "listening") { this._globalEndedHandler = () => { this.switchToContext("listening", "listening"); }; this.activeVideo.addEventListener("ended", this._globalEndedHandler, { once: true }); return; } // Neutral: on end, pick another neutral to avoid static last frame if (context === "neutral") { this._globalEndedHandler = () => this.returnToNeutral(); this.activeVideo.addEventListener("ended", this._globalEndedHandler, { once: true }); } } // keep only the augmented determineCategory above (with traits) selectOptimalVideo(category, specificVideo = null, traits = null, affection = null, emotion = null) { const availableVideos = this.videoCategories[category] || this.videoCategories.neutral; if (specificVideo && availableVideos.includes(specificVideo)) { if (typeof this.updatePlayHistory === "function") this.updatePlayHistory(category, specificVideo); this._logSelection(category, specificVideo, availableVideos); return specificVideo; } const currentVideoSrc = this.activeVideo.querySelector("source").getAttribute("src"); // Filter out recently played videos using adaptive history const recentlyPlayed = this.playHistory[category] || []; let candidateVideos = availableVideos.filter(video => video !== currentVideoSrc && !recentlyPlayed.includes(video)); // If no fresh videos, allow recently played but not current if (candidateVideos.length === 0) { candidateVideos = availableVideos.filter(video => video !== currentVideoSrc); } // Ultimate fallback if (candidateVideos.length === 0) { candidateVideos = availableVideos; } // Ensure we're not falling back to wrong category if (candidateVideos.length === 0) { candidateVideos = this.videoCategories.neutral; } // If traits and affection are provided, weight the selection more subtly if (traits && typeof affection === "number") { let weights = candidateVideos.map(video => { if (category === "speakingPositive") { // More positive videos when affection is high, but not extreme // Also bias within positive towards romance/humor contexts when emotion suggests it const base = 1 + (affection / 100) * 0.3; let bonus = 0; const rom = typeof traits.romance === "number" ? traits.romance : 50; const hum = typeof traits.humor === "number" ? traits.humor : 50; if (emotion === "romantic") bonus += (rom / 100) * 0.2; if (emotion === "laughing") bonus += (hum / 100) * 0.2; return base + bonus; } if (category === "speakingNegative") { // More negative/shy videos when affection is low return 1 + ((100 - affection) / 100) * 0.4; } if (category === "neutral") { // Neutral videos when affection is moderate (peak at ~50, lower at extremes) const distance = Math.abs(50 - affection) / 50; // 0 at 50, 1 at 0 or 100 return 1 + (1 - Math.min(1, distance)) * 0.2; } if (category === "dancing") { // Dancing strongly influenced by playfulness but capped return 1 + Math.min(0.6, (traits.playfulness / 100) * 0.7); } if (category === "listening") { // Listening influenced by empathy and attention const empathyWeight = (traits.empathy || 50) / 100; // Slightly consider affection too (more patient listening at higher affection) return 1 + empathyWeight * 0.2 + (affection / 100) * 0.05; } return 1; }); const total = weights.reduce((a, b) => a + b, 0); let r = Math.random() * total; for (let i = 0; i < candidateVideos.length; i++) { if (r < weights[i]) { const chosen = candidateVideos[i]; if (typeof this.updatePlayHistory === "function") this.updatePlayHistory(category, chosen); this._logSelection(category, chosen, candidateVideos); return chosen; } r -= weights[i]; } const selectedVideo = candidateVideos[0]; if (typeof this.updatePlayHistory === "function") this.updatePlayHistory(category, selectedVideo); this._logSelection(category, selectedVideo, candidateVideos); return selectedVideo; } // No traits weighting: random pick if (candidateVideos.length === 0) { return availableVideos && availableVideos[0] ? availableVideos[0] : null; } const selectedVideo = candidateVideos[Math.floor(Math.random() * candidateVideos.length)]; if (typeof this.updatePlayHistory === "function") this.updatePlayHistory(category, selectedVideo); this._logSelection(category, selectedVideo, candidateVideos); return selectedVideo; } // Get adaptive history size based on available videos getAdaptiveHistorySize(category) { const availableVideos = this.videoCategories[category] || []; const videoCount = availableVideos.length; // Adaptive history: keep 40-60% of available videos in history // Minimum 2, maximum 8 to prevent extreme cases if (videoCount <= 3) return Math.max(1, videoCount - 1); if (videoCount <= 6) return Math.max(2, Math.floor(videoCount * 0.5)); return Math.min(8, Math.floor(videoCount * 0.6)); } // Update history with adaptive sizing updatePlayHistory(category, videoPath) { if (!this.playHistory[category]) { this.playHistory[category] = []; } const adaptiveSize = this.getAdaptiveHistorySize(category); this.playHistory[category].push(videoPath); // Trim to adaptive size if (this.playHistory[category].length > adaptiveSize) { this.playHistory[category] = this.playHistory[category].slice(-adaptiveSize); } } // Ensure determineCategory exists as a class method (used at line ~494 and ~537) determineCategory(context, emotion = "neutral", traits = null) { // Prefer explicit context mapping if provided (e.g., 'listening','dancing') if (this.emotionToCategory && this.emotionToCategory[context]) { return this.emotionToCategory[context]; } // Normalize generic 'speaking' by emotion polarity if (context === "speaking") { if (emotion === "positive") return "speakingPositive"; if (emotion === "negative") return "speakingNegative"; return "neutral"; } // Map by emotion label when possible if (this.emotionToCategory && this.emotionToCategory[emotion]) { return this.emotionToCategory[emotion]; } return "neutral"; } // SPECIALIZED METHODS FOR EACH CONTEXT async startListening(traits = null, affection = null) { // If already listening and playing, avoid redundant switch if (this.currentContext === "listening" && !this.activeVideo.paused && !this.activeVideo.ended) { return; } // Immediate switch to keep UI responsive this.switchToContext("listening"); // Add a short grace window to prevent immediate switch to speaking before TTS starts clearTimeout(this._listeningGraceTimer); this._listeningGraceTimer = setTimeout(() => { // No-op; used as a time marker to let LLM prepare the answer }, 1500); // If caller did not provide traits, try to fetch and refine selection try { if (!traits && window.kimiDB && typeof window.kimiDB.getAllPersonalityTraits === "function") { const selectedCharacter = await window.kimiDB.getSelectedCharacter(); const allTraits = await window.kimiDB.getAllPersonalityTraits(selectedCharacter); if (allTraits && typeof allTraits === "object") { const aff = typeof allTraits.affection === "number" ? allTraits.affection : undefined; // Re-issue context switch with weighting parameters to better pick listening videos this.switchToContext("listening", "listening", null, allTraits, aff); } } else if (traits) { this.switchToContext("listening", "listening", null, traits, affection); } } catch (e) { // Non-fatal: keep basic listening behavior console.warn("Listening refinement skipped due to error:", e); } } respondWithEmotion(emotion, traits = null, affection = null) { // Ignore neutral emotion to avoid unintended overrides (use returnToNeutral when appropriate) if (emotion === "neutral") { if (this._stickyContext === "dancing" || this.currentContext === "dancing") return; this.returnToNeutral(); return; } // Do not override dancing while sticky if (this._stickyContext === "dancing" || this.currentContext === "dancing") return; // If we are already playing the same emotion video, do nothing if (this.isEmotionVideoPlaying && this.currentEmotionContext === emotion) return; // If we just entered listening and TTS isn’t started yet, wait a bit to avoid desync const now = Date.now(); const stillInGrace = this._listeningGraceTimer != null; const ttsNotStarted = !(window.voiceManager && window.voiceManager.isSpeaking); if (this.currentContext === "listening" && stillInGrace && ttsNotStarted) { clearTimeout(this._pendingSpeakSwitch); this._pendingSpeakSwitch = setTimeout(() => { // Re-check speaking state; only switch when we have an actual emotion to play alongside TTS if (window.voiceManager && window.voiceManager.isSpeaking) { this.switchToContext("speaking", emotion, null, traits, affection); this.isEmotionVideoPlaying = true; this.currentEmotionContext = emotion; } }, 900); return; } // First switch context (so internal guards don't see the new flags yet) this.switchToContext("speaking", emotion, null, traits, affection); // Then mark the emotion video as playing for override protection this.isEmotionVideoPlaying = true; this.currentEmotionContext = emotion; } returnToNeutral() { // Always ensure we resume playback with a fresh neutral video to avoid freeze if (this._neutralLock) return; this._neutralLock = true; setTimeout(() => { this._neutralLock = false; }, 1000); this._stickyContext = null; this._stickyUntil = 0; this.isEmotionVideoPlaying = false; this.currentEmotionContext = null; // Si la voix est encore en cours, relancer une vidéo neutre en boucle const category = "neutral"; const currentVideoSrc = this.activeVideo.querySelector("source").getAttribute("src"); const available = this.videoCategories[category] || []; let nextSrc = null; if (available.length > 0) { const candidates = available.filter(v => v !== currentVideoSrc); nextSrc = candidates.length > 0 ? candidates[Math.floor(Math.random() * candidates.length)] : available[Math.floor(Math.random() * available.length)]; } if (nextSrc) { this.loadAndSwitchVideo(nextSrc, "normal"); if (typeof this.updatePlayHistory === "function") this.updatePlayHistory(category, nextSrc); this.currentContext = "neutral"; this.currentEmotion = "neutral"; this.lastSwitchTime = Date.now(); // Si la voix est encore en cours, s'assurer qu'on relance une vidéo neutre à la fin if (window.voiceManager && window.voiceManager.isSpeaking) { this.activeVideo.addEventListener( "ended", () => { if (window.voiceManager && window.voiceManager.isSpeaking) { this.returnToNeutral(); } }, { once: true } ); } } else { // Fallback to existing path if list empty this.switchToContext("neutral"); } } // ADVANCED CONTEXTUAL ANALYSIS async analyzeAndSelectVideo(userMessage, kimiResponse, emotionAnalysis, traits = null, affection = null, lang = null) { // Do not analyze-switch away while dancing is sticky/playing if (this._stickyContext === "dancing" || this.currentContext === "dancing") { return; // let dancing finish } // Auto-detect language if not specified let userLang = lang; if (!userLang && window.kimiDB && window.kimiDB.getPreference) { userLang = await window.KimiLanguageUtils.getLanguage(); } // Use existing emotion analysis instead of creating new system let detectedEmotion = "neutral"; if (window.kimiAnalyzeEmotion) { // Analyze combined user message and Kimi response using existing function const combinedText = [userMessage, kimiResponse].filter(Boolean).join(" "); detectedEmotion = window.kimiAnalyzeEmotion(combinedText, userLang); console.log(`🎭 Emotion detected: "${detectedEmotion}" from text: "${combinedText.substring(0, 50)}..."`); } else if (emotionAnalysis && emotionAnalysis.reaction) { // Fallback to provided emotion analysis detectedEmotion = emotionAnalysis.reaction; } // Special case: Auto-dancing if playfulness very high if (traits && typeof traits.playfulness === "number" && traits.playfulness >= 85) { this.switchToContext("dancing", "dancing", null, traits, affection); return; } // Add to emotion history this.emotionHistory.push(detectedEmotion); if (this.emotionHistory.length > this.maxEmotionHistory) { this.emotionHistory.shift(); } // Analyze emotion trend - support all possible emotions const counts = { positive: 0, negative: 0, neutral: 0, dancing: 0, listening: 0, romantic: 0, laughing: 0, surprise: 0, confident: 0, shy: 0, flirtatious: 0, kiss: 0, goodbye: 0 }; for (let i = 0; i < this.emotionHistory.length; i++) { const emo = this.emotionHistory[i]; if (counts[emo] !== undefined) counts[emo]++; } // Find dominant emotion let dominant = null; let max = 0; for (const key in counts) { if (counts[key] > max) { max = counts[key]; dominant = key; } } // Switch to appropriate context based on dominant emotion if (max >= 1 && dominant) { // Map emotion to context using our emotion mapping const targetCategory = this.emotionToCategory[dominant]; if (targetCategory) { this.switchToContext(targetCategory, dominant, null, traits, affection); return; } // Fallback for unmapped emotions if (dominant === "dancing") { this.switchToContext("dancing", "dancing", null, traits, affection); return; } if (dominant === "positive") { this.switchToContext("speakingPositive", "positive", null, traits, affection); return; } if (dominant === "negative") { this.switchToContext("speakingNegative", "negative", null, traits, affection); return; } if (dominant === "listening") { this.switchToContext("listening", "listening", null, traits, affection); return; } } // Default to neutral context, with a very subtle positive bias at very high affection if (traits && typeof traits.affection === "number" && traits.affection >= 95) { const chance = Math.random(); if (chance < 0.25) { this.switchToContext("speakingPositive", "positive", null, traits, affection); return; } } // Avoid neutral override if a transient state should persist (handled elsewhere) this.switchToContext("neutral", "neutral", null, traits, affection); } // AUTOMATIC TRANSITION TO NEUTRAL scheduleAutoTransition(delayMs) { clearTimeout(this.autoTransitionTimer); // Ne pas programmer d'auto-transition pour les contextes de base if (this.currentContext === "neutral" || this.currentContext === "listening") { return; } // Durées adaptées selon le contexte (toutes les vidéos font 10s) let duration; if (typeof delayMs === "number") { duration = delayMs; } else { switch (this.currentContext) { case "dancing": duration = 10000; // 10 secondes pour dancing (durée réelle des vidéos) break; case "speakingPositive": case "speakingNegative": duration = 10000; // 10 secondes pour speaking (durée réelle des vidéos) break; case "neutral": // Pas d'auto-transition pour neutral (état par défaut, boucle en continu) return; case "listening": // Pas d'auto-transition pour listening (personnage écoute l'utilisateur) return; default: duration = this.autoTransitionDuration; // 10 secondes par défaut } } console.log(`Auto-transition scheduled in ${duration / 1000}s (${this.currentContext} → neutral)`); this.autoTransitionTimer = setTimeout(() => { if (this.currentContext !== "neutral" && this.currentContext !== "listening") { if (!this._processPendingSwitches()) { this.returnToNeutral(); } } }, duration); } // COMPATIBILITY WITH THE OLD SYSTEM switchVideo(emotion = null) { if (emotion) { this.switchToContext("speaking", emotion); } else { this.switchToContext("neutral"); } } autoSwitchToNeutral() { this._neutralLock = false; this.isEmotionVideoPlaying = false; this.currentEmotionContext = null; this.switchToContext("neutral"); } getNextVideo(emotion, currentSrc) { // Adapt the old method for compatibility const category = this.determineCategory("speaking", emotion); return this.selectOptimalVideo(category); } loadAndSwitchVideo(videoSrc, priority = "normal") { const startTs = performance.now(); // Guard: ignore if recently failed and still in cooldown const lastFail = this._recentFailures.get(videoSrc); if (lastFail && performance.now() - lastFail < this._failureCooldown) { // Pick an alternative neutral as quick substitution const neutralList = (this.videoCategories && this.videoCategories.neutral) || []; const alt = neutralList.find(v => v !== videoSrc) || neutralList[0]; if (alt && alt !== videoSrc) { console.warn(`Skipping recently failed video (cooldown): ${videoSrc} -> trying alt: ${alt}`); return this.loadAndSwitchVideo(alt, priority); } } // Avoid redundant loading if the requested source is already active or currently loading in inactive element const activeSrc = this.activeVideo?.querySelector("source")?.getAttribute("src"); const inactiveSrc = this.inactiveVideo?.querySelector("source")?.getAttribute("src"); if (videoSrc && (videoSrc === activeSrc || (this._loadingInProgress && videoSrc === inactiveSrc))) { if (priority !== "high" && priority !== "speaking") { return; // no need to reload same video } } // Only log high priority or error cases to reduce noise if (priority === "speaking" || priority === "high") { console.log(`🎬 Loading video: ${videoSrc} (priority: ${priority})`); } // Si une vidéo haute priorité arrive, on peut interrompre le chargement en cours if (this._loadingInProgress) { if (priority === "high" || priority === "speaking") { this._loadingInProgress = false; // Nettoyer les event listeners en cours sur la vidéo inactive this.inactiveVideo.removeEventListener("canplay", this._currentLoadHandler); this.inactiveVideo.removeEventListener("loadeddata", this._currentLoadHandler); this.inactiveVideo.removeEventListener("error", this._currentErrorHandler); if (this._loadTimeout) { clearTimeout(this._loadTimeout); this._loadTimeout = null; } } else { return; } } this._loadingInProgress = true; // Nettoyer tous les timers en cours clearTimeout(this.autoTransitionTimer); if (this._loadTimeout) { clearTimeout(this._loadTimeout); this._loadTimeout = null; } const pref = this._prefetchCache.get(videoSrc); if (pref && (pref.readyState >= 2 || pref.buffered.length > 0)) { const source = this.inactiveVideo.querySelector("source"); source.setAttribute("src", videoSrc); try { this.inactiveVideo.currentTime = 0; } catch {} this.inactiveVideo.load(); } else { this.inactiveVideo.querySelector("source").setAttribute("src", videoSrc); this.inactiveVideo.load(); } // Stocker les références aux handlers pour pouvoir les nettoyer let fired = false; const onReady = () => { if (fired) return; fired = true; this._loadingInProgress = false; if (this._loadTimeout) { clearTimeout(this._loadTimeout); this._loadTimeout = null; } this.inactiveVideo.removeEventListener("canplay", this._currentLoadHandler); this.inactiveVideo.removeEventListener("loadeddata", this._currentLoadHandler); this.inactiveVideo.removeEventListener("error", this._currentErrorHandler); // Update rolling average load time const duration = performance.now() - startTs; this._loadTimeSamples.push(duration); if (this._loadTimeSamples.length > this._maxSamples) this._loadTimeSamples.shift(); const sum = this._loadTimeSamples.reduce((a, b) => a + b, 0); this._avgLoadTime = sum / this._loadTimeSamples.length; this._consecutiveErrorCount = 0; // reset on success this.performSwitch(); }; this._currentLoadHandler = onReady; const folder = getCharacterInfo(this.characterName).videoFolder; // Rotating fallback pool (stable neutrals first positions) // Build or rebuild fallback pool when absent or when character changed if (!this._fallbackPool || this._fallbackPoolCharacter !== this.characterName) { const neutralList = (this.videoCategories && this.videoCategories.neutral) || []; // Choose first 3 as core reliable set; if less than 3 available, take all this._fallbackPool = neutralList.slice(0, 3); this._fallbackIndex = 0; this._fallbackPoolCharacter = this.characterName; } const fallbackVideo = this._fallbackPool[this._fallbackIndex % this._fallbackPool.length]; this._currentErrorHandler = e => { const mediaEl = this.inactiveVideo; const readyState = mediaEl ? mediaEl.readyState : -1; const networkState = mediaEl ? mediaEl.networkState : -1; let mediaErrorCode = null; if (mediaEl && mediaEl.error) mediaErrorCode = mediaEl.error.code; console.warn( `Error loading video: ${videoSrc} (readyState=${readyState} networkState=${networkState} mediaError=${mediaErrorCode}) falling back to: ${fallbackVideo}` ); this._loadingInProgress = false; if (this._loadTimeout) { clearTimeout(this._loadTimeout); this._loadTimeout = null; } this._recentFailures.set(videoSrc, performance.now()); this._consecutiveErrorCount++; // Stop runaway fallback loop: pause if too many sequential errors relative to pool size if (this._fallbackPool && this._consecutiveErrorCount >= this._fallbackPool.length * 2) { console.error("Temporarily pausing fallback loop after repeated failures. Retrying in 2s."); setTimeout(() => { this._consecutiveErrorCount = 0; this.loadAndSwitchVideo(fallbackVideo, "high"); }, 2000); return; } if (videoSrc !== fallbackVideo) { // Try fallback video this._fallbackIndex = (this._fallbackIndex + 1) % this._fallbackPool.length; // advance for next time this.loadAndSwitchVideo(fallbackVideo, "high"); } else { // Ultimate fallback: try any neutral video console.error(`Fallback video also failed: ${fallbackVideo}. Trying ultimate fallback.`); const neutralVideos = this.videoCategories.neutral || []; if (neutralVideos.length > 0) { // Try a different neutral video const ultimateFallback = neutralVideos.find(video => video !== fallbackVideo); if (ultimateFallback) { this.loadAndSwitchVideo(ultimateFallback, "high"); } else { // Last resort: try first neutral video anyway this.loadAndSwitchVideo(neutralVideos[0], "high"); } } else { // Critical error: no neutral videos available console.error("CRITICAL: No neutral videos available!"); this._switchInProgress = false; } } // Escalate diagnostics if many consecutive errors if (this._consecutiveErrorCount >= 3) { console.info( `Diagnostics: avgLoadTime=${this._avgLoadTime?.toFixed(1) || "n/a"}ms samples=${this._loadTimeSamples.length} prefetchCache=${this._prefetchCache.size}` ); } }; this.inactiveVideo.addEventListener("loadeddata", this._currentLoadHandler, { once: true }); this.inactiveVideo.addEventListener("canplay", this._currentLoadHandler, { once: true }); this.inactiveVideo.addEventListener("error", this._currentErrorHandler, { once: true }); if (this.inactiveVideo.readyState >= 2) { queueMicrotask(() => onReady()); } // Dynamic timeout: refined formula avg*1.5 + buffer, bounded let adaptiveTimeout = this._minTimeout; if (this._avgLoadTime) { adaptiveTimeout = Math.min(this._maxTimeout, Math.max(this._minTimeout, this._avgLoadTime * 1.5 + 400)); } // Cap by clip length ratio if we know (assume 10000ms default when metadata absent) const currentClipMs = 10000; // All clips are 10s adaptiveTimeout = Math.min(adaptiveTimeout, Math.floor(currentClipMs * this._timeoutCapRatio)); this._loadTimeout = setTimeout(() => { if (!fired) { // If metadata is there but not canplay yet, extend once if (this.inactiveVideo.readyState >= 1 && this.inactiveVideo.readyState < 2) { console.debug( `Extending timeout for ${videoSrc} (readyState=${this.inactiveVideo.readyState}) by ${this._timeoutExtension}ms` ); this._loadTimeout = setTimeout(() => { if (!fired) { if (this.inactiveVideo.readyState >= 2) onReady(); else this._currentErrorHandler(); } }, this._timeoutExtension); return; } // Grace retry: still fetching over network (networkState=2) with no data (readyState=0) if ( this.inactiveVideo.networkState === 2 && this.inactiveVideo.readyState === 0 && (this._graceRetryCounts?.[videoSrc] || 0) < 1 ) { if (!this._graceRetryCounts) this._graceRetryCounts = {}; this._graceRetryCounts[videoSrc] = (this._graceRetryCounts[videoSrc] || 0) + 1; const extra = this._timeoutExtension + 600; console.debug(`Grace retry for ${videoSrc} (network loading). Extending by ${extra}ms`); this._loadTimeout = setTimeout(() => { if (!fired) { if (this.inactiveVideo.readyState >= 2) onReady(); else this._currentErrorHandler(); } }, extra); return; } if (this.inactiveVideo.readyState >= 2) { onReady(); } else { this._currentErrorHandler(); } } }, adaptiveTimeout); } usePreloadedVideo(preloadedVideo, videoSrc) { const source = this.inactiveVideo.querySelector("source"); source.setAttribute("src", videoSrc); this.inactiveVideo.currentTime = 0; this.inactiveVideo.load(); this._currentLoadHandler = () => { this._loadingInProgress = false; this.performSwitch(); }; this.inactiveVideo.addEventListener("canplay", this._currentLoadHandler, { once: true }); } performSwitch() { // Prevent rapid double toggles if (this._switchInProgress) return; this._switchInProgress = true; const fromVideo = this.activeVideo; const toVideo = this.inactiveVideo; // Perform a JS-managed crossfade for smoother transitions // Let crossfadeVideos resolve duration from CSS variable (--video-fade-duration) this.constructor.crossfadeVideos(fromVideo, toVideo, undefined, () => { // After crossfade completion, finalize state and classes fromVideo.classList.remove("active"); toVideo.classList.add("active"); // Swap references const prevActive = this.activeVideo; const prevInactive = this.inactiveVideo; this.activeVideo = prevInactive; this.inactiveVideo = prevActive; const playPromise = this.activeVideo.play(); if (playPromise && typeof playPromise.then === "function") { playPromise .then(() => { try { const src = this.activeVideo?.querySelector("source")?.getAttribute("src"); const info = { context: this.currentContext, emotion: this.currentEmotion }; console.log("🎬 VideoManager: Now playing:", src, info); // Recompute autoTransitionDuration from actual duration if available (C) try { const d = this.activeVideo.duration; if (!isNaN(d) && d > 0.5) { // Keep 1s headroom before natural end for auto scheduling const target = Math.max(1000, d * 1000 - 1100); this.autoTransitionDuration = target; } else { this.autoTransitionDuration = 9900; // fallback for 10s clips } // Dynamic neutral prefetch to widen diversity without burst this._prefetchNeutralDynamic(); } catch {} } catch {} this._switchInProgress = false; this.setupEventListenersForContext(this.currentContext); }) .catch(error => { console.warn("Failed to play video:", error); // Revert to previous video to avoid frozen state toVideo.classList.remove("active"); fromVideo.classList.add("active"); this.activeVideo = fromVideo; this.inactiveVideo = toVideo; try { this.activeVideo.play().catch(() => {}); } catch {} this._switchInProgress = false; this.setupEventListenersForContext(this.currentContext); }); } else { // Non-promise play fallback this._switchInProgress = false; try { const d = this.activeVideo.duration; if (!isNaN(d) && d > 0.5) { const target = Math.max(1000, d * 1000 - 1100); this.autoTransitionDuration = target; } else { this.autoTransitionDuration = 9900; } this._prefetchNeutralDynamic(); } catch {} this.setupEventListenersForContext(this.currentContext); } }); } _prefetchNeutralDynamic() { try { const neutrals = (this.videoCategories && this.videoCategories.neutral) || []; if (!neutrals.length) return; // Build a set of already cached or in-flight const cached = new Set( [...this._prefetchCache.keys(), ...this._prefetchInFlight.values()].map(v => (typeof v === "string" ? v : v?.src)) ); // defensive const current = this.activeVideo?.querySelector("source")?.getAttribute("src"); // Choose up to 2 unseen neutral videos different from current const candidates = neutrals.filter(s => s && s !== current && !cached.has(s)); if (!candidates.length) return; let limit = 2; // Network-aware limiting try { const conn = navigator.connection || navigator.webkitConnection || navigator.mozConnection; if (conn && conn.effectiveType) { if (/2g/i.test(conn.effectiveType)) limit = 0; else if (/3g/i.test(conn.effectiveType)) limit = 1; } } catch {} if (limit <= 0) return; candidates.slice(0, limit).forEach(src => this._prefetch(src)); } catch {} } _prefetch(src) { if (!src || this._prefetchCache.has(src) || this._prefetchInFlight.has(src)) return; if (this._prefetchCache.size + this._prefetchInFlight.size >= this._maxPrefetch) return; this._prefetchInFlight.add(src); const v = document.createElement("video"); v.preload = "auto"; v.muted = true; v.playsInline = true; v.src = src; const cleanup = () => { v.oncanplaythrough = null; v.oncanplay = null; v.onerror = null; this._prefetchInFlight.delete(src); }; v.oncanplay = () => { this._prefetchCache.set(src, v); this._trimPrefetchCacheIfNeeded(); cleanup(); }; v.oncanplaythrough = () => { this._prefetchCache.set(src, v); this._trimPrefetchCacheIfNeeded(); cleanup(); }; v.onerror = () => { cleanup(); }; try { v.load(); } catch {} } _trimPrefetchCacheIfNeeded() { try { // Only apply LRU trimming to neutral videos; cap at 6 neutrals cached const MAX_NEUTRAL = 6; const entries = [...this._prefetchCache.entries()]; const neutralEntries = entries.filter(([src]) => /\/neutral\//.test(src)); if (neutralEntries.length <= MAX_NEUTRAL) return; // LRU heuristic: older insertion first (Map preserves insertion order) const excess = neutralEntries.length - MAX_NEUTRAL; let removed = 0; for (const [src, vid] of neutralEntries) { if (removed >= excess) break; // Avoid removing currently active or about to be used const current = this.activeVideo?.querySelector("source")?.getAttribute("src"); if (src === current) continue; this._prefetchCache.delete(src); try { vid.removeAttribute("src"); vid.load(); } catch {} removed++; } } catch {} } _prefetchLikely(category) { const list = this.videoCategories[category] || []; // Prefetch 1-2 next likely videos different from current const current = this.activeVideo?.querySelector("source")?.getAttribute("src") || null; const candidates = list.filter(s => s && s !== current).slice(0, 2); candidates.forEach(src => this._prefetch(src)); } // DIAGNOSTIC AND DEBUG METHODS getCurrentVideoInfo() { const currentSrc = this.activeVideo.querySelector("source").getAttribute("src"); return { currentVideo: currentSrc, context: this.currentContext, emotion: this.currentEmotion, category: this.determineCategory(this.currentContext, this.currentEmotion) }; } // METHODS TO ANALYZE EMOTIONS FROM TEXT analyzeTextEmotion(text) { // Use unified emotion system return window.kimiAnalyzeEmotion ? window.kimiAnalyzeEmotion(text, "auto") : "neutral"; } // CLEANUP destroy() { clearTimeout(this.autoTransitionTimer); this.autoTransitionTimer = null; if (this._visibilityHandler) { document.removeEventListener("visibilitychange", this._visibilityHandler); this._visibilityHandler = null; } } // Utilitaire pour déterminer la catégorie vidéo selon la moyenne des traits setMoodByPersonality(traits) { if (this._stickyContext === "dancing" || this.currentContext === "dancing") return; const category = getMoodCategoryFromPersonality(traits); // Normalize emotion so validation uses base emotion labels let emotion = category; if (category === "speakingPositive") emotion = "positive"; else if (category === "speakingNegative") emotion = "negative"; // For other categories (neutral, listening, dancing) emotion can equal category this.switchToContext(category, emotion, null, traits, traits.affection); } _cleanupLoadingHandlers() { if (this._currentLoadHandler) { this.inactiveVideo.removeEventListener("canplay", this._currentLoadHandler); this.inactiveVideo.removeEventListener("loadeddata", this._currentLoadHandler); this._currentLoadHandler = null; } if (this._currentErrorHandler) { this.inactiveVideo.removeEventListener("error", this._currentErrorHandler); this._currentErrorHandler = null; } if (this._loadTimeout) { clearTimeout(this._loadTimeout); this._loadTimeout = null; } this._loadingInProgress = false; this._switchInProgress = false; } } function getMoodCategoryFromPersonality(traits) { // Use unified emotion system if (window.kimiEmotionSystem) { return window.kimiEmotionSystem.getMoodCategoryFromPersonality(traits); } // Fallback (should not be reached) const keys = ["affection", "romance", "empathy", "playfulness", "humor"]; 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"; } // 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); } setFavorability(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}%`; } setTranscript(text) { this.state.transcript = text; window.KimiDOMUtils.setText("#transcript", text); } 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; window.KimiVideoManager = KimiVideoManager; window.KimiSecurityUtils = KimiSecurityUtils; window.KimiCacheManager = new KimiCacheManager(); // Create global instance 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); } };