Virtual-Kimi-2 / kimi-js /kimi-video-controller.js
VirtualKimi's picture
Upload 69 files
9aa357b verified
// ===== 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;