Spaces:
Running
Running
| // ===== KIMI VIDEO CONTROLLER ===== | |
| class KimiVideoController { | |
| constructor(videoManager) { | |
| this.videoManager = videoManager; | |
| this._lastNegativeAt = 0; | |
| this._negativeCooldownMs = 8000; // avoid spamming negative videos | |
| this._negativeStickUntil = 0; // timestamp until which negative stays sticky | |
| this._baseNegativeStickMs = 3000; // minimal stickiness | |
| this._positiveDebounceMs = 5000; // block positive too soon after negative | |
| this._lastCategory = "neutral"; | |
| this._lastSwitchAt = 0; | |
| this._suppressPositiveUntil = 0; // timestamp blocking positive transitions | |
| // Dynamic hostility series tracking | |
| this._hostileTimestamps = []; // epoch ms of negative triggers | |
| this._maxSeriesWindowMs = 60000; // 60s sliding window | |
| this._minCooldownMs = 3000; // lower bound | |
| this._maxCooldownMs = 15000; // upper bound | |
| } | |
| // ===== SINGLE DECISION FUNCTION ===== | |
| playVideo(trigger, text = "") { | |
| // 1. DECISION BASE | |
| let category = "neutral"; | |
| const now = Date.now(); | |
| if (this._isDancing(text)) { | |
| category = "dancing"; | |
| } else if (trigger === "user") { | |
| // Analyze user message directly for immediate negative reaction | |
| let userEmo = null; | |
| try { | |
| userEmo = window.kimiEmotionSystem?.analyzeEmotionValidated?.(text) || null; | |
| } catch {} | |
| if (userEmo === "negative") { | |
| const severity = this._computeHostileSeverity(text); | |
| if (now - this._lastNegativeAt > this._negativeCooldownMs) { | |
| category = "speakingNegative"; | |
| this._lastNegativeAt = now; | |
| this._negativeStickUntil = now + this._stickDurationForSeverity(severity); | |
| this._registerNegative(now, severity); | |
| } else if (now < this._negativeStickUntil) { | |
| category = "speakingNegative"; // still sticky | |
| } | |
| } | |
| } else if (trigger === "tts") { | |
| // Prefer centralized emotion analysis if available | |
| let emo = null; | |
| try { | |
| if (window.kimiEmotionSystem?.analyzeEmotionValidated) { | |
| emo = window.kimiEmotionSystem.analyzeEmotionValidated(text || ""); | |
| } else if (window.kimiEmotionSystem?.analyzeEmotion) { | |
| emo = window.kimiEmotionSystem.analyzeEmotion(text || ""); | |
| } | |
| } catch {} | |
| if (emo === "negative") { | |
| const severity = this._computeHostileSeverity(text); | |
| if (now - this._lastNegativeAt > this._negativeCooldownMs) { | |
| category = "speakingNegative"; | |
| this._lastNegativeAt = now; | |
| this._negativeStickUntil = now + this._stickDurationForSeverity(severity); | |
| this._registerNegative(now, severity); | |
| } else if (now < this._negativeStickUntil) { | |
| category = "speakingNegative"; | |
| } else { | |
| // cooldown active; degrade to neutral | |
| category = "neutral"; | |
| } | |
| } else { | |
| // Block positive if still inside debounce window after last negative | |
| if (now < this._suppressPositiveUntil && now < this._negativeStickUntil) { | |
| category = "speakingNegative"; // keep negative sticky | |
| } else if (now < this._suppressPositiveUntil) { | |
| category = "neutral"; // soft neutral instead of immediate positive | |
| } else { | |
| category = "speakingPositive"; | |
| } | |
| } | |
| } else if (trigger === "listening") { | |
| category = "listening"; | |
| } | |
| // Sticky guard: if trying to leave negative before stick time -> stay | |
| if (this._lastCategory === "speakingNegative" && category !== "speakingNegative" && Date.now() < this._negativeStickUntil) { | |
| category = "speakingNegative"; | |
| } | |
| // Neutral dedupe & cascade suppression (< 500ms repeated neutrals) | |
| if (category === "neutral" && this._lastCategory === "neutral") { | |
| const since = now - this._lastSwitchAt; | |
| if (since < 500) { | |
| if (window.KIMI_DEBUG_EMOTION) console.debug("[EMO] Skip rapid neutral cascade", { since }); | |
| return; | |
| } | |
| } | |
| this._commitCategory(category, now); | |
| if (window.KIMI_DEBUG_EMOTION) { | |
| console.debug("[EMO] category=", category, { | |
| lastNegativeAt: this._lastNegativeAt, | |
| stickUntil: this._negativeStickUntil, | |
| now, | |
| debounceRemaining: Math.max(0, this._positiveDebounceMs - (now - this._lastNegativeAt)), | |
| suppressPositiveMs: Math.max(0, this._suppressPositiveUntil - now) | |
| }); | |
| } | |
| } | |
| // ===== SIMPLE HELPERS ===== | |
| _isDancing(text) { | |
| if (!text) return false; | |
| if (window.hasKeywordCategory && window.hasKeywordCategory("dancing", text)) return true; | |
| // Fallback minimal legacy list (should rarely be used) | |
| const words = ["dance", "dancing"]; | |
| return words.some(w => text.toLowerCase().includes(w)); | |
| } | |
| // _isNegative deprecated: centralized emotion system handles polarity | |
| // Severity = proportion of hostile keywords length found relative to text tokens | |
| _computeHostileSeverity(text) { | |
| if (!text) return 0; | |
| try { | |
| const lang = window.KIMI_LAST_LANG || "en"; | |
| const raw = text.toLowerCase(); | |
| const tokens = raw.split(/\s+/).filter(Boolean); | |
| if (!tokens.length) return 0; | |
| const hostile = (window.KIMI_CONTEXT_KEYWORDS?.[lang]?.hostile || []).concat(window.KIMI_CONTEXT_KEYWORDS?.en?.hostile || []); | |
| // Prebuild boundary regex list once per call | |
| const patterns = hostile.map(h => { | |
| const esc = String(h) | |
| .trim() | |
| .toLowerCase() | |
| .replace(/[-/\\^$*+?.()|[\]{}]/g, r => "\\" + r); | |
| return new RegExp(`\\b${esc}\\b`, "i"); | |
| }); | |
| let hits = 0; | |
| for (const p of patterns) { | |
| if (p.test(raw)) hits++; | |
| } | |
| const severity = hits / tokens.length; | |
| return severity > 1 ? 1 : severity; | |
| } catch { | |
| return 0; | |
| } | |
| } | |
| _commitCategory(category, now) { | |
| if (this.videoManager?.switchToContext) { | |
| this.videoManager.switchToContext(category, category, null, null, null, false); | |
| } | |
| this._lastCategory = category; | |
| this._lastSwitchAt = now; | |
| } | |
| _stickDurationForSeverity(sev) { | |
| if (sev >= 0.4) return 6000; | |
| if (sev >= 0.25) return 4500; | |
| if (sev >= 0.15) return 3800; | |
| return this._baseNegativeStickMs; | |
| } | |
| _registerNegative(now, severity) { | |
| try { | |
| this._hostileTimestamps.push({ t: now, s: severity }); | |
| // purge old | |
| const cutoff = now - this._maxSeriesWindowMs; | |
| this._hostileTimestamps = this._hostileTimestamps.filter(e => e.t >= cutoff); | |
| this._updateDynamicCooldown(now); | |
| } catch {} | |
| } | |
| _updateDynamicCooldown(now) { | |
| const entries = this._hostileTimestamps; | |
| if (!entries.length) return; | |
| // Compute weighted intensity: sum(severity * freshnessWeight) | |
| // freshnessWeight = 1 - age/maxWindow | |
| const cutoff = now - this._maxSeriesWindowMs; | |
| let weighted = 0; | |
| for (const e of entries) { | |
| const age = Math.min(this._maxSeriesWindowMs, Math.max(0, now - e.t)); | |
| const w = 1 - age / this._maxSeriesWindowMs; | |
| weighted += e.s * w; | |
| } | |
| // Normalize to rough 0..N scale. If user spams many insults severity .3 → weighted ~ >1 | |
| // Map weighted to cooldown via inverse relation: plus d'hostilité récente => cooldown plus long & debounce plus long | |
| // Clamp weighted to 0..2 for mapping | |
| const clamped = Math.min(2, weighted); | |
| const ratio = clamped / 2; // 0..1 | |
| // Interpolate cooldown | |
| const newCooldown = Math.round(this._minCooldownMs + (this._maxCooldownMs - this._minCooldownMs) * ratio); | |
| this._negativeCooldownMs = newCooldown; | |
| // Optionally also scale positive debounce (bounded 3000..8000) | |
| this._positiveDebounceMs = 3000 + Math.round(5000 * ratio); | |
| if (window.KIMI_DEBUG_EMOTION) { | |
| console.debug("[EMO] dynamicCooldown", { weighted, ratio, newCooldown, positiveDebounce: this._positiveDebounceMs, series: entries.length }); | |
| } | |
| } | |
| } | |
| // ===== SIMPLE API ===== | |
| function initializeVideoController(videoManager) { | |
| const controller = new KimiVideoController(videoManager); | |
| return controller; | |
| } | |
| // ES6 exports | |
| export { KimiVideoController, initializeVideoController }; | |
| // Global exposure | |
| window.KimiVideoController = KimiVideoController; | |
| window.initializeVideoController = initializeVideoController; | |