Virtual-Kimi / kimi-js /kimi-emotion-system.js
VirtualKimi's picture
Upload 34 files
bcbb712 verified
// ===== KIMI UNIFIED EMOTION SYSTEM =====
// Centralizes all emotion analysis, personality updates, and validation
class KimiEmotionSystem {
constructor(database = null) {
this.db = database;
this.negativeStreaks = {};
// Debouncing system for personality updates
this._personalityUpdateQueue = {};
this._personalityUpdateTimer = null;
this._personalityUpdateDelay = 300; // ms
// Unified emotion mappings
this.EMOTIONS = {
// Base emotions
POSITIVE: "positive",
NEGATIVE: "negative",
NEUTRAL: "neutral",
// Specific emotions
ROMANTIC: "romantic",
DANCING: "dancing",
LISTENING: "listening",
LAUGHING: "laughing",
SURPRISE: "surprise",
CONFIDENT: "confident",
SHY: "shy",
FLIRTATIOUS: "flirtatious",
KISS: "kiss",
GOODBYE: "goodbye"
};
// Unified video context mapping - CENTRALIZED SOURCE OF TRUTH
this.emotionToVideoCategory = {
// Base emotional states
positive: "speakingPositive",
negative: "speakingNegative",
neutral: "neutral",
// Special contexts (always take priority)
dancing: "dancing",
listening: "listening",
// Specific emotions mapped to appropriate categories
romantic: "speakingPositive",
laughing: "speakingPositive",
surprise: "speakingPositive",
confident: "speakingPositive",
flirtatious: "speakingPositive",
kiss: "speakingPositive",
// Neutral/subdued emotions
shy: "neutral",
goodbye: "neutral",
// Explicit context mappings (for compatibility)
speaking: "speakingPositive", // Generic speaking defaults to positive
speakingPositive: "speakingPositive",
speakingNegative: "speakingNegative"
};
// Emotion priority weights for conflict resolution
this.emotionPriorities = {
dancing: 10, // Maximum priority - immersive experience
kiss: 9, // Very high - intimate moment
romantic: 8, // High - emotional connection
listening: 7, // High - active interaction
flirtatious: 6, // Medium-high - playful interaction
laughing: 6, // Medium-high - positive expression
surprise: 5, // Medium - reaction
confident: 5, // Medium - personality expression
speaking: 4, // Medium-low - generic speaking context
positive: 4, // Medium-low - general positive
negative: 4, // Medium-low - general negative
neutral: 3, // Low - default state
shy: 3, // Low - subdued state
goodbye: 2, // Very low - transitional
speakingPositive: 4, // Medium-low - for consistency
speakingNegative: 4 // Medium-low - for consistency
};
// Context/emotion validation system for system integrity
this.validContexts = ["dancing", "listening", "speaking", "speakingPositive", "speakingNegative", "neutral"];
this.validEmotions = Object.values(this.EMOTIONS);
// Unified trait defaults - Balanced for progressive experience
this.TRAIT_DEFAULTS = {
affection: 55, // Baseline neutral affection
playfulness: 55, // Moderately playful baseline
intelligence: 70, // Competent baseline intellect
empathy: 75, // Warm & caring baseline
humor: 60, // Mild sense of humor baseline
romance: 50 // Neutral romance baseline (earned over time)
};
// Central emotion -> trait base deltas (pre global multipliers & gainCfg scaling)
// Positive numbers increase trait, negative decrease.
// Keep values small; final effect passes through adjustUp/adjustDown and global multipliers.
this.EMOTION_TRAIT_EFFECTS = {
positive: { affection: 0.45, empathy: 0.2, playfulness: 0.25, humor: 0.25 },
negative: { affection: -0.7, empathy: 0.3 },
romantic: { romance: 0.7, affection: 0.55, empathy: 0.15 },
flirtatious: { romance: 0.55, playfulness: 0.45, affection: 0.25 },
laughing: { humor: 0.85, playfulness: 0.5, affection: 0.25 },
dancing: { playfulness: 1.1, affection: 0.45 },
surprise: { intelligence: 0.12, empathy: 0.12 },
shy: { romance: -0.3, affection: -0.12 },
confident: { intelligence: 0.15, affection: 0.55 },
listening: { empathy: 0.6, intelligence: 0.25 },
kiss: { romance: 0.85, affection: 0.7 },
goodbye: { affection: -0.15, empathy: 0.1 }
};
// Trait keyword scaling model for conversation analysis (per-message delta shaping)
this.TRAIT_KEYWORD_MODEL = {
affection: { posFactor: 0.5, negFactor: 0.65, streakPenaltyAfter: 3, maxStep: 2 },
romance: { posFactor: 0.55, negFactor: 0.75, streakPenaltyAfter: 2, maxStep: 1.8 },
empathy: { posFactor: 0.4, negFactor: 0.5, streakPenaltyAfter: 3, maxStep: 1.5 },
playfulness: { posFactor: 0.45, negFactor: 0.4, streakPenaltyAfter: 4, maxStep: 1.4 },
humor: { posFactor: 0.55, negFactor: 0.45, streakPenaltyAfter: 4, maxStep: 1.6 },
intelligence: { posFactor: 0.35, negFactor: 0.55, streakPenaltyAfter: 2, maxStep: 1.2 }
};
}
// ===== DEBOUNCED PERSONALITY UPDATE SYSTEM =====
_debouncedPersonalityUpdate(updates, character) {
// Merge with existing queued updates for this character
if (!this._personalityUpdateQueue[character]) {
this._personalityUpdateQueue[character] = {};
}
Object.assign(this._personalityUpdateQueue[character], updates);
// Clear existing timer and set new one
if (this._personalityUpdateTimer) {
clearTimeout(this._personalityUpdateTimer);
}
this._personalityUpdateTimer = setTimeout(async () => {
try {
const allUpdates = { ...this._personalityUpdateQueue };
this._personalityUpdateQueue = {};
this._personalityUpdateTimer = null;
// Process all queued updates
for (const [char, traits] of Object.entries(allUpdates)) {
if (Object.keys(traits).length > 0) {
await this.db.setPersonalityBatch(traits, char);
// Emit unified personality update event
if (typeof window !== "undefined" && window.dispatchEvent) {
window.dispatchEvent(
new CustomEvent("personality:updated", {
detail: { character: char, traits: traits }
})
);
}
}
}
} catch (error) {
console.error("Error in debounced personality update:", error);
}
}, this._personalityUpdateDelay);
}
// ===== CENTRALIZED VALIDATION SYSTEM =====
validateContext(context) {
if (!context || typeof context !== "string") return "neutral";
const normalized = context.toLowerCase().trim();
// Check if it's a valid context
if (this.validContexts.includes(normalized)) return normalized;
// Check if it's a valid emotion that can be mapped to context
if (this.emotionToVideoCategory[normalized]) return normalized;
return "neutral"; // Safe fallback
}
validateEmotion(emotion) {
if (!emotion || typeof emotion !== "string") return "neutral";
const normalized = emotion.toLowerCase().trim();
// Check if it's a valid emotion
if (this.validEmotions.includes(normalized)) return normalized;
// Check common aliases
const aliases = {
happy: "positive",
sad: "negative",
mad: "negative",
angry: "negative",
excited: "positive",
calm: "neutral",
romance: "romantic",
laugh: "laughing",
dance: "dancing",
// Speaking contexts as emotion aliases
speaking: "positive", // Generic speaking defaults to positive
speakingpositive: "positive",
speakingnegative: "negative"
};
if (aliases[normalized]) return aliases[normalized];
return "neutral"; // Safe fallback
}
validateVideoCategory(category) {
const validCategories = ["dancing", "listening", "speakingPositive", "speakingNegative", "neutral"];
if (!category || typeof category !== "string") return "neutral";
const normalized = category.toLowerCase().trim();
return validCategories.includes(normalized) ? normalized : "neutral";
}
// Enhanced emotion analysis with validation
analyzeEmotionValidated(text, lang = "auto") {
const rawEmotion = this.analyzeEmotion(text, lang);
return this.validateEmotion(rawEmotion);
}
// ===== UTILITY METHODS FOR SYSTEM INTEGRATION =====
// Centralized method to get video category for any emotion/context combination
getVideoCategory(emotionOrContext, traits = null) {
// Handle the case where we get both context and emotion (e.g., from determineCategory calls)
// Priority: Specific contexts > Specific emotions > Generic fallbacks
// Try context validation first for immediate context matches
let validated = this.validateContext(emotionOrContext);
if (validated !== "neutral" || emotionOrContext === "neutral") {
// Valid context found or explicitly neutral
const category = this.emotionToVideoCategory[validated] || "neutral";
return this.validateVideoCategory(category);
}
// If no valid context, try as emotion
validated = this.validateEmotion(emotionOrContext);
const category = this.emotionToVideoCategory[validated] || "neutral";
return this.validateVideoCategory(category);
} // Get priority weight for any emotion/context
getPriorityWeight(emotionOrContext) {
// Try context validation first, then emotion validation
let validated = this.validateContext(emotionOrContext);
if (validated === "neutral" && emotionOrContext !== "neutral") {
// If context validation gave neutral but input wasn't neutral, try as emotion
validated = this.validateEmotion(emotionOrContext);
}
return this.emotionPriorities[validated] || 3; // Default medium-low priority
}
// Check if an emotion/context should override current state
shouldOverride(newEmotion, currentEmotion, currentContext = null) {
const newPriority = this.getPriorityWeight(newEmotion);
const currentPriority = Math.max(this.getPriorityWeight(currentEmotion), this.getPriorityWeight(currentContext));
return newPriority > currentPriority;
}
// Utility to normalize and validate a complete emotion/context request
normalizeEmotionRequest(context, emotion, traits = null) {
return {
context: this.validateContext(context),
emotion: this.validateEmotion(emotion),
category: this.getVideoCategory(emotion || context, traits),
priority: this.getPriorityWeight(emotion || context)
};
}
// ===== UNIFIED EMOTION ANALYSIS =====
analyzeEmotion(text, lang = "auto") {
if (!text || typeof text !== "string") return this.EMOTIONS.NEUTRAL;
const lowerText = this.normalizeText(text);
// Auto-detect language
let detectedLang = this._detectLanguage(text, lang);
// Get language-specific keywords
const positiveWords = window.KIMI_CONTEXT_POSITIVE?.[detectedLang] ||
window.KIMI_CONTEXT_POSITIVE?.en || ["happy", "good", "great", "love"];
const negativeWords = window.KIMI_CONTEXT_NEGATIVE?.[detectedLang] ||
window.KIMI_CONTEXT_NEGATIVE?.en || ["sad", "bad", "angry", "hate"];
const emotionKeywords = window.KIMI_CONTEXT_KEYWORDS?.[detectedLang] || window.KIMI_CONTEXT_KEYWORDS?.en || {};
// Priority order for emotion detection - reordered for better logic
const emotionChecks = [
// High-impact emotions first
{ emotion: this.EMOTIONS.KISS, keywords: emotionKeywords.kiss || ["kiss", "embrace"] },
{ emotion: this.EMOTIONS.DANCING, keywords: emotionKeywords.dancing || ["dance", "dancing"] },
{ emotion: this.EMOTIONS.ROMANTIC, keywords: emotionKeywords.romantic || ["love", "romantic"] },
{ emotion: this.EMOTIONS.FLIRTATIOUS, keywords: emotionKeywords.flirtatious || ["flirt", "tease"] },
{ emotion: this.EMOTIONS.LAUGHING, keywords: emotionKeywords.laughing || ["laugh", "funny"] },
{ emotion: this.EMOTIONS.SURPRISE, keywords: emotionKeywords.surprise || ["wow", "surprise"] },
{ emotion: this.EMOTIONS.CONFIDENT, keywords: emotionKeywords.confident || ["confident", "strong"] },
{ emotion: this.EMOTIONS.SHY, keywords: emotionKeywords.shy || ["shy", "embarrassed"] },
{ emotion: this.EMOTIONS.GOODBYE, keywords: emotionKeywords.goodbye || ["goodbye", "bye"] },
// Listening intent (lower priority to not mask other emotions)
{
emotion: this.EMOTIONS.LISTENING,
keywords: emotionKeywords.listening || [
"listen carefully",
"I'm listening",
"listening to you",
"hear me out",
"pay attention"
]
}
];
// Check for specific emotions first, applying sensitivity weights per language
const sensitivity = (window.KIMI_EMOTION_SENSITIVITY &&
(window.KIMI_EMOTION_SENSITIVITY[detectedLang] || window.KIMI_EMOTION_SENSITIVITY.default)) || {
listening: 1,
dancing: 1,
romantic: 1,
laughing: 1,
surprise: 1,
confident: 1,
shy: 1,
flirtatious: 1,
kiss: 1,
goodbye: 1,
positive: 1,
negative: 1
};
// Normalize keyword lists to handle accents/contractions
const normalizeList = arr => (Array.isArray(arr) ? arr.map(x => this.normalizeText(String(x))).filter(Boolean) : []);
const normalizedPositiveWords = normalizeList(positiveWords);
const normalizedNegativeWords = normalizeList(negativeWords);
const normalizedChecks = emotionChecks.map(ch => ({
emotion: ch.emotion,
keywords: normalizeList(ch.keywords)
}));
let bestEmotion = null;
let bestScore = 0;
for (const check of normalizedChecks) {
const hits = check.keywords.reduce((acc, word) => acc + (this.countTokenMatches(lowerText, String(word)) ? 1 : 0), 0);
if (hits > 0) {
const key = check.emotion;
const weight = sensitivity[key] != null ? sensitivity[key] : 1;
const score = hits * weight;
if (score > bestScore) {
bestScore = score;
bestEmotion = check.emotion;
}
}
}
if (bestEmotion) return bestEmotion;
// Fall back to positive/negative analysis (use normalized lists)
const hasPositive = normalizedPositiveWords.some(word => this.countTokenMatches(lowerText, String(word)) > 0);
const hasNegative = normalizedNegativeWords.some(word => this.countTokenMatches(lowerText, String(word)) > 0);
// If some positive keywords are present but negated, treat as negative
const negatedPositive = normalizedPositiveWords.some(word => this.isTokenNegated(lowerText, String(word)));
if (hasPositive && !hasNegative) {
if (negatedPositive) {
return this.EMOTIONS.NEGATIVE;
}
// Apply sensitivity for base polarity
if ((sensitivity.positive || 1) >= (sensitivity.negative || 1)) return this.EMOTIONS.POSITIVE;
// If negative is favored, still fall back to positive since no negative hit
return this.EMOTIONS.POSITIVE;
}
if (hasNegative && !hasPositive) {
if ((sensitivity.negative || 1) >= (sensitivity.positive || 1)) return this.EMOTIONS.NEGATIVE;
return this.EMOTIONS.NEGATIVE;
}
return this.EMOTIONS.NEUTRAL;
}
// ===== UNIFIED PERSONALITY SYSTEM =====
async updatePersonalityFromEmotion(emotion, text, character = null) {
if (!this.db) {
console.warn("Database not available for personality updates");
return;
}
const selectedCharacter = character || (await this.db.getSelectedCharacter());
const traits = await this.db.getAllPersonalityTraits(selectedCharacter);
const safe = (v, def) => (typeof v === "number" && isFinite(v) ? v : def);
let affection = safe(traits?.affection, this.TRAIT_DEFAULTS.affection);
let romance = safe(traits?.romance, this.TRAIT_DEFAULTS.romance);
let empathy = safe(traits?.empathy, this.TRAIT_DEFAULTS.empathy);
let playfulness = safe(traits?.playfulness, this.TRAIT_DEFAULTS.playfulness);
let humor = safe(traits?.humor, this.TRAIT_DEFAULTS.humor);
let intelligence = safe(traits?.intelligence, this.TRAIT_DEFAULTS.intelligence);
// Unified adjustment functions - More balanced progression for better user experience
const adjustUp = (val, amount) => {
// Gradual slowdown only at very high levels to allow natural progression
if (val >= 95) return val + amount * 0.2; // Slow near max to preserve challenge
if (val >= 88) return val + amount * 0.5; // Moderate slowdown at very high levels
if (val >= 80) return val + amount * 0.7; // Slight slowdown at high levels
if (val >= 60) return val + amount * 0.9; // Nearly normal progression in mid-high range
return val + amount; // Normal progression below 60%
};
const adjustDown = (val, amount) => {
// Faster decline at higher values - easier to lose than to gain
if (val >= 80) return val - amount * 1.2; // Faster loss at high levels
if (val >= 60) return val - amount; // Normal loss at medium levels
if (val >= 40) return val - amount * 0.8; // Slower loss at low-medium levels
if (val <= 20) return val - amount * 0.4; // Very slow loss at low levels
return val - amount * 0.6; // Moderate loss between 20-40
};
// Unified emotion-based adjustments - More balanced and realistic progression
const gainCfg = window.KIMI_TRAIT_ADJUSTMENT || {
globalGain: 1,
globalLoss: 1,
emotionGain: {},
traitGain: {},
traitLoss: {}
};
const emoGain = emotion && gainCfg.emotionGain ? gainCfg.emotionGain[emotion] || 1 : 1;
const GGAIN = (gainCfg.globalGain || 1) * emoGain;
const GLOSS = gainCfg.globalLoss || 1;
// Helpers to apply trait-specific scaling
const scaleGain = (traitName, baseDelta) => {
const t = gainCfg.traitGain && (gainCfg.traitGain[traitName] || 1);
return baseDelta * GGAIN * t;
};
const scaleLoss = (traitName, baseDelta) => {
const t = gainCfg.traitLoss && (gainCfg.traitLoss[traitName] || 1);
return baseDelta * GLOSS * t;
};
// Apply emotion deltas from centralized map (if defined)
const map = this.EMOTION_TRAIT_EFFECTS?.[emotion];
if (map) {
for (const [traitName, baseDelta] of Object.entries(map)) {
const delta = baseDelta; // base delta -> will be scaled below
if (delta === 0) continue;
switch (traitName) {
case "affection":
affection =
delta > 0
? Math.min(100, adjustUp(affection, scaleGain("affection", delta)))
: Math.max(0, adjustDown(affection, scaleLoss("affection", Math.abs(delta))));
break;
case "romance":
romance =
delta > 0
? Math.min(100, adjustUp(romance, scaleGain("romance", delta)))
: Math.max(0, adjustDown(romance, scaleLoss("romance", Math.abs(delta))));
break;
case "empathy":
empathy =
delta > 0
? Math.min(100, adjustUp(empathy, scaleGain("empathy", delta)))
: Math.max(0, adjustDown(empathy, scaleLoss("empathy", Math.abs(delta))));
break;
case "playfulness":
playfulness =
delta > 0
? Math.min(100, adjustUp(playfulness, scaleGain("playfulness", delta)))
: Math.max(0, adjustDown(playfulness, scaleLoss("playfulness", Math.abs(delta))));
break;
case "humor":
humor =
delta > 0
? Math.min(100, adjustUp(humor, scaleGain("humor", delta)))
: Math.max(0, adjustDown(humor, scaleLoss("humor", Math.abs(delta))));
break;
case "intelligence":
intelligence =
delta > 0
? Math.min(100, adjustUp(intelligence, scaleGain("intelligence", delta)))
: Math.max(0, adjustDown(intelligence, scaleLoss("intelligence", Math.abs(delta))));
break;
}
}
}
// Cross-trait interactions - traits influence each other for more realistic personality development
// High empathy should boost affection over time
if (empathy >= 75 && affection < empathy - 5) {
affection = Math.min(100, adjustUp(affection, scaleGain("affection", 0.1)));
}
// High intelligence should slightly boost empathy (understanding others)
if (intelligence >= 80 && empathy < intelligence - 10) {
empathy = Math.min(100, adjustUp(empathy, scaleGain("empathy", 0.05)));
}
// Humor and playfulness should reinforce each other
if (humor >= 70 && playfulness < humor - 10) {
playfulness = Math.min(100, adjustUp(playfulness, scaleGain("playfulness", 0.05)));
}
if (playfulness >= 70 && humor < playfulness - 10) {
humor = Math.min(100, adjustUp(humor, scaleGain("humor", 0.05)));
}
// Content-based adjustments (unified)
await this._analyzeTextContent(
text,
traits => {
if (typeof traits.romance !== "undefined") romance = traits.romance;
if (typeof traits.affection !== "undefined") affection = traits.affection;
if (typeof traits.humor !== "undefined") humor = traits.humor;
if (typeof traits.playfulness !== "undefined") playfulness = traits.playfulness;
},
adjustUp
);
// Cross-trait modifiers (applied after primary emotion & content changes)
({ affection, romance, empathy, playfulness, humor, intelligence } = this._applyCrossTraitModifiers({
affection,
romance,
empathy,
playfulness,
humor,
intelligence,
adjustUp,
adjustDown,
scaleGain,
scaleLoss
}));
// Preserve fractional progress to allow gradual visible changes
const to2 = v => Number(Number(v).toFixed(2));
const clamp = v => Math.max(0, Math.min(100, v));
const updatedTraits = {
affection: to2(clamp(affection)),
romance: to2(clamp(romance)),
empathy: to2(clamp(empathy)),
playfulness: to2(clamp(playfulness)),
humor: to2(clamp(humor)),
intelligence: to2(clamp(intelligence))
};
// Prepare persistence with smoothing / threshold to avoid tiny writes
const toPersist = {};
for (const [trait, candValue] of Object.entries(updatedTraits)) {
const current = typeof traits?.[trait] === "number" ? traits[trait] : this.TRAIT_DEFAULTS[trait];
const prep = this._preparePersistTrait(trait, current, candValue, selectedCharacter);
if (prep.shouldPersist) toPersist[trait] = prep.value;
}
// Use debounced update instead of immediate DB write
if (Object.keys(toPersist).length > 0) {
this._debouncedPersonalityUpdate(toPersist, selectedCharacter);
}
return updatedTraits;
}
// Apply cross-trait synergy & balancing rules.
_applyCrossTraitModifiers(ctx) {
let { affection, romance, empathy, playfulness, humor, intelligence, adjustUp, adjustDown, scaleGain } = ctx;
// High empathy soft-boost affection if still lagging
if (empathy >= 80 && affection < empathy - 8) {
affection = Math.min(100, adjustUp(affection, scaleGain("affection", 0.08)));
}
// High romance amplifies affection gains subtlely
if (romance >= 80 && affection < romance - 5) {
affection = Math.min(100, adjustUp(affection, scaleGain("affection", 0.06)));
}
// High affection but lower romance triggers slight romance catch-up
if (affection >= 90 && romance < 70) {
romance = Math.min(100, adjustUp(romance, scaleGain("romance", 0.05)));
}
// Intelligence supports empathy & humor small growth
if (intelligence >= 85) {
if (empathy < intelligence - 12) {
empathy = Math.min(100, adjustUp(empathy, scaleGain("empathy", 0.04)));
}
if (humor < 75) {
humor = Math.min(100, adjustUp(humor, scaleGain("humor", 0.04)));
}
}
// Humor/playfulness mutual reinforcement (retain existing logic but guarded)
if (humor >= 70 && playfulness < humor - 10) {
playfulness = Math.min(100, adjustUp(playfulness, scaleGain("playfulness", 0.05)));
}
if (playfulness >= 70 && humor < playfulness - 10) {
humor = Math.min(100, adjustUp(humor, scaleGain("humor", 0.05)));
}
return { affection, romance, empathy, playfulness, humor, intelligence };
}
// ===== UNIFIED LLM PERSONALITY ANALYSIS =====
async updatePersonalityFromConversation(userMessage, kimiResponse, character = null) {
if (!this.db) return;
const lowerUser = this.normalizeText(userMessage || "");
const lowerKimi = this.normalizeText(kimiResponse || "");
const traits = (await this.db.getAllPersonalityTraits(character)) || {};
const selectedLanguage = await this.db.getPreference("selectedLanguage", "en");
// Use unified keyword system
const getPersonalityWords = (trait, type) => {
if (window.KIMI_PERSONALITY_KEYWORDS && window.KIMI_PERSONALITY_KEYWORDS[selectedLanguage]) {
return window.KIMI_PERSONALITY_KEYWORDS[selectedLanguage][trait]?.[type] || [];
}
return this._getFallbackKeywords(trait, type);
};
const pendingUpdates = {};
for (const trait of ["humor", "intelligence", "romance", "affection", "playfulness", "empathy"]) {
const posWords = getPersonalityWords(trait, "positive");
const negWords = getPersonalityWords(trait, "negative");
let currentVal =
typeof traits[trait] === "number" && isFinite(traits[trait]) ? traits[trait] : this.TRAIT_DEFAULTS[trait];
const model = this.TRAIT_KEYWORD_MODEL[trait];
const posFactor = model.posFactor;
const negFactor = model.negFactor;
const maxStep = model.maxStep;
const streakLimit = model.streakPenaltyAfter;
let posScore = 0;
let negScore = 0;
for (const w of posWords) {
posScore += this.countTokenMatches(lowerUser, String(w)) * 1.0;
posScore += this.countTokenMatches(lowerKimi, String(w)) * 0.5;
}
for (const w of negWords) {
negScore += this.countTokenMatches(lowerUser, String(w)) * 1.0;
negScore += this.countTokenMatches(lowerKimi, String(w)) * 0.5;
}
let rawDelta = posScore * posFactor - negScore * negFactor;
// Track negative streaks per trait (only when net negative & no positives)
if (!this.negativeStreaks[trait]) this.negativeStreaks[trait] = 0;
if (negScore > 0 && posScore === 0) {
this.negativeStreaks[trait]++;
} else if (posScore > 0) {
this.negativeStreaks[trait] = 0;
}
if (rawDelta < 0 && this.negativeStreaks[trait] >= streakLimit) {
rawDelta *= 1.15; // escalate sustained negativity
}
// Clamp magnitude
if (rawDelta > maxStep) rawDelta = maxStep;
if (rawDelta < -maxStep) rawDelta = -maxStep;
if (rawDelta !== 0) {
let newVal = currentVal + rawDelta;
if (rawDelta > 0) {
newVal = Math.min(100, newVal);
} else {
newVal = Math.max(0, newVal);
}
pendingUpdates[trait] = newVal;
}
}
// Flush pending updates in a single batch write to avoid overwrites
if (Object.keys(pendingUpdates).length > 0) {
// Apply smoothing/threshold per trait (read current values)
const toPersist = {};
for (const [trait, candValue] of Object.entries(pendingUpdates)) {
const current = typeof traits?.[trait] === "number" ? traits[trait] : this.TRAIT_DEFAULTS[trait];
const prep = this._preparePersistTrait(trait, current, candValue, character);
if (prep.shouldPersist) toPersist[trait] = prep.value;
}
if (Object.keys(toPersist).length > 0) {
await this.db.setPersonalityBatch(toPersist, character);
}
}
}
validatePersonalityTrait(trait, value) {
if (typeof value !== "number" || value < 0 || value > 100) {
console.warn(`Invalid trait value for ${trait}: ${value}, using default`);
return this.TRAIT_DEFAULTS[trait] || 50;
}
return value;
}
// ===== NORMALIZATION & MATCH HELPERS =====
// Normalize text for robust matching (NFD -> remove diacritics, normalize quotes, lower, collapse spaces)
normalizeText(s) {
if (!s || typeof s !== "string") return "";
// Convert various apostrophes to ASCII, normalize NFD and remove diacritics
let out = s.replace(/[\u2018\u2019\u201A\u201B\u2032\u2035]/g, "'");
out = out.replace(/[\u201C\u201D\u201E\u201F\u2033\u2036]/g, '"');
// Expand a few common French contractions to improve detection (non-exhaustive)
out = out.replace(/\bj'/gi, "je ");
// expand negation contraction n' -> ne
out = out.replace(/\bn'/gi, "ne ");
out = out.replace(/\bt'/gi, "te ");
out = out.replace(/\bc'/gi, "ce ");
out = out.replace(/\bd'/gi, "de ");
out = out.replace(/\bl'/gi, "le ");
// Unicode normalize and strip combining marks
out = out.normalize("NFD").replace(/\p{Diacritic}/gu, "");
// Lowercase and collapse whitespace
out = out.toLowerCase().replace(/\s+/g, " ").trim();
return out;
}
// Count non-overlapping occurrences of needle in haystack
countOccurrences(haystack, needle) {
if (!haystack || !needle) return 0;
let count = 0;
let pos = 0;
while (true) {
const idx = haystack.indexOf(needle, pos);
if (idx === -1) break;
count++;
pos = idx + needle.length;
}
return count;
}
// Tokenize normalized text into words (strip punctuation)
tokenizeText(s) {
if (!s || typeof s !== "string") return [];
// split on whitespace, remove surrounding non-alphanum, keep ascii letters/numbers
return s
.split(/\s+/)
.map(t => t.replace(/^[^a-z0-9]+|[^a-z0-9]+$/gi, ""))
.filter(t => t.length > 0);
}
// Check for simple negators in a window before a token index
hasNegationWindow(tokens, index, window = 3) {
if (!Array.isArray(tokens) || tokens.length === 0) return false;
// Respect runtime-configured negators if available
const globalNegators = (window.KIMI_NEGATORS && window.KIMI_NEGATORS.common) || [];
// Try selected language list if set
const lang = (window.KIMI_SELECTED_LANG && String(window.KIMI_SELECTED_LANG)) || null;
const langNegators = (lang && window.KIMI_NEGATORS && window.KIMI_NEGATORS[lang]) || [];
const merged = new Set([
...(Array.isArray(langNegators) ? langNegators : []),
...(Array.isArray(globalNegators) ? globalNegators : [])
]);
// Always include a minimal english/french set as fallback
["no", "not", "never", "none", "nobody", "nothing", "ne", "n", "pas", "jamais", "plus", "aucun", "rien", "non"].forEach(
x => merged.add(x)
);
const win = Number(window.KIMI_NEGATION_WINDOW) || window;
const start = Math.max(0, index - win);
for (let i = start; i < index; i++) {
if (merged.has(tokens[i])) return true;
}
return false;
}
// Count token-based matches (exact word or phrase) with negation handling
countTokenMatches(haystack, needle) {
if (!haystack || !needle) return 0;
const normNeedle = this.normalizeText(String(needle));
if (normNeedle.length === 0) return 0;
const needleTokens = this.tokenizeText(normNeedle);
if (needleTokens.length === 0) return 0;
const normHay = this.normalizeText(String(haystack));
const tokens = this.tokenizeText(normHay);
if (tokens.length === 0) return 0;
let count = 0;
for (let i = 0; i <= tokens.length - needleTokens.length; i++) {
let match = true;
for (let j = 0; j < needleTokens.length; j++) {
if (tokens[i + j] !== needleTokens[j]) {
match = false;
break;
}
}
if (match) {
// skip if a negation is in window before the match
if (!this.hasNegationWindow(tokens, i)) {
count++;
}
i += needleTokens.length - 1; // advance to avoid overlapping
}
}
return count;
}
// Return true if any occurrence of needle in haystack is negated (within negation window)
isTokenNegated(haystack, needle) {
if (!haystack || !needle) return false;
const normNeedle = this.normalizeText(String(needle));
const needleTokens = this.tokenizeText(normNeedle);
if (needleTokens.length === 0) return false;
const normHay = this.normalizeText(String(haystack));
const tokens = this.tokenizeText(normHay);
for (let i = 0; i <= tokens.length - needleTokens.length; i++) {
let match = true;
for (let j = 0; j < needleTokens.length; j++) {
if (tokens[i + j] !== needleTokens[j]) {
match = false;
break;
}
}
if (match) {
if (this.hasNegationWindow(tokens, i)) return true;
i += needleTokens.length - 1;
}
}
return false;
}
// ===== SMOOTHING / PERSISTENCE HELPERS =====
// Apply EMA smoothing between current and candidate value. alpha in (0..1).
_applyEMA(current, candidate, alpha) {
alpha = typeof alpha === "number" && isFinite(alpha) ? alpha : 0.3;
return current * (1 - alpha) + candidate * alpha;
}
// Decide whether to persist based on absolute change threshold. Returns {shouldPersist, value}
_preparePersistTrait(trait, currentValue, candidateValue, character = null) {
// Configurable via globals
const alpha = (window.KIMI_SMOOTHING_ALPHA && Number(window.KIMI_SMOOTHING_ALPHA)) || 0.3;
const threshold = (window.KIMI_PERSIST_THRESHOLD && Number(window.KIMI_PERSIST_THRESHOLD)) || 0.25; // percent absolute
const smoothed = this._applyEMA(currentValue, candidateValue, alpha);
const absDelta = Math.abs(smoothed - currentValue);
if (absDelta < threshold) {
return { shouldPersist: false, value: currentValue };
}
return { shouldPersist: true, value: Number(Number(smoothed).toFixed(2)) };
}
// ===== UTILITY METHODS =====
_detectLanguage(text, lang) {
if (lang !== "auto") return lang;
if (/[àâäéèêëîïôöùûüÿç]/i.test(text)) return "fr";
else if (/[äöüß]/i.test(text)) return "de";
else if (/[ñáéíóúü]/i.test(text)) return "es";
else if (/[àèìòù]/i.test(text)) return "it";
else if (/[\u3040-\u309f\u30a0-\u30ff\u4e00-\u9faf]/i.test(text)) return "ja";
else if (/[\u4e00-\u9fff]/i.test(text)) return "zh";
return "en";
}
async _analyzeTextContent(text, callback, adjustUp) {
if (!this.db) return;
const selectedLanguage = await this.db.getPreference("selectedLanguage", "en");
const romanticWords = window.KIMI_CONTEXT_KEYWORDS?.[selectedLanguage]?.romantic ||
window.KIMI_CONTEXT_KEYWORDS?.en?.romantic || ["love", "romantic", "kiss"];
const humorWords = window.KIMI_CONTEXT_KEYWORDS?.[selectedLanguage]?.laughing ||
window.KIMI_CONTEXT_KEYWORDS?.en?.laughing || ["joke", "funny", "lol"];
const romanticPattern = new RegExp(`(${romanticWords.join("|")})`, "i");
const humorPattern = new RegExp(`(${humorWords.join("|")})`, "i");
const traits = {};
if (text.match(romanticPattern)) {
traits.romance = adjustUp(traits.romance || this.TRAIT_DEFAULTS.romance, 0.5);
traits.affection = adjustUp(traits.affection || this.TRAIT_DEFAULTS.affection, 0.5);
}
if (text.match(humorPattern)) {
traits.humor = adjustUp(traits.humor || this.TRAIT_DEFAULTS.humor, 2);
traits.playfulness = adjustUp(traits.playfulness || this.TRAIT_DEFAULTS.playfulness, 1);
}
callback(traits);
}
_getFallbackKeywords(trait, type) {
const fallbackKeywords = {
humor: {
positive: ["funny", "hilarious", "joke", "laugh", "amusing"],
negative: ["boring", "sad", "serious", "cold", "dry"]
},
intelligence: {
positive: ["intelligent", "smart", "brilliant", "logical", "clever"],
negative: ["stupid", "dumb", "foolish", "slow", "naive"]
},
romance: {
positive: ["cuddle", "love", "romantic", "kiss", "tenderness"],
negative: ["cold", "distant", "indifferent", "rejection"]
},
affection: {
positive: ["affection", "tenderness", "close", "warmth", "kind"],
negative: ["mean", "cold", "indifferent", "distant", "rejection"]
},
playfulness: {
positive: ["play", "game", "tease", "mischievous", "fun"],
negative: ["serious", "boring", "strict", "rigid"]
},
empathy: {
positive: ["listen", "understand", "empathy", "support", "help"],
negative: ["indifferent", "cold", "selfish", "ignore"]
}
};
return fallbackKeywords[trait]?.[type] || [];
}
// ===== PERSONALITY CALCULATION =====
calculatePersonalityAverage(traits) {
const keys = ["affection", "romance", "empathy", "playfulness", "humor", "intelligence"];
let sum = 0;
let count = 0;
keys.forEach(key => {
if (typeof traits[key] === "number") {
sum += traits[key];
count++;
}
});
return count > 0 ? sum / count : 50;
}
getMoodCategoryFromPersonality(traits) {
const avg = this.calculatePersonalityAverage(traits);
if (avg >= 80) return "speakingPositive";
if (avg >= 60) return "neutral";
if (avg >= 40) return "neutral";
if (avg >= 20) return "speakingNegative";
return "speakingNegative";
}
}
window.KimiEmotionSystem = KimiEmotionSystem;
// Expose centralized tuning maps for debugging / live adjustments
Object.defineProperty(window, "KIMI_EMOTION_TRAIT_EFFECTS", {
get() {
return window.kimiEmotionSystem ? window.kimiEmotionSystem.EMOTION_TRAIT_EFFECTS : null;
}
});
Object.defineProperty(window, "KIMI_TRAIT_KEYWORD_MODEL", {
get() {
return window.kimiEmotionSystem ? window.kimiEmotionSystem.TRAIT_KEYWORD_MODEL : null;
}
});
// Debug/tuning helpers
window.setEmotionDelta = function (emotion, trait, value) {
if (!window.kimiEmotionSystem) return false;
const map = window.kimiEmotionSystem.EMOTION_TRAIT_EFFECTS;
if (!map[emotion]) map[emotion] = {};
map[emotion][trait] = Number(value);
return true;
};
window.resetEmotionDeltas = function () {
if (!window.kimiEmotionSystem) return false;
// No stored original snapshot; advise page reload for full reset.
console.warn("For full reset reload the page (original deltas are not snapshotted).");
};
window.setTraitKeywordScaling = function (trait, cfg) {
if (!window.kimiEmotionSystem) return false;
const model = window.kimiEmotionSystem.TRAIT_KEYWORD_MODEL;
if (!model[trait]) return false;
Object.assign(model[trait], cfg);
return true;
};
// Force recompute + UI refresh for personality average
window.refreshPersonalityAverageUI = async function (characterKey = null) {
try {
if (window.updateGlobalPersonalityUI) {
await window.updateGlobalPersonalityUI(characterKey);
} else if (window.getPersonalityAverage && window.kimiDB) {
const charKey = characterKey || (await window.kimiDB.getSelectedCharacter());
const traits = await window.kimiDB.getAllPersonalityTraits(charKey);
const avg = window.getPersonalityAverage(traits);
const bar = document.getElementById("favorability-bar");
const text = document.getElementById("favorability-text");
if (bar) bar.style.width = `${avg}%`;
if (text) text.textContent = `${avg.toFixed(2)}%`;
}
} catch (err) {
console.warn("refreshPersonalityAverageUI failed", err);
}
};
export default KimiEmotionSystem;
// ===== BACKWARD COMPATIBILITY LAYER =====
// Ensure single instance of KimiEmotionSystem (Singleton pattern)
function getKimiEmotionSystemInstance() {
if (!window.kimiEmotionSystem) {
window.kimiEmotionSystem = new KimiEmotionSystem(window.kimiDB);
}
return window.kimiEmotionSystem;
}
// Replace the old kimiAnalyzeEmotion function
window.kimiAnalyzeEmotion = function (text, lang = "auto") {
return getKimiEmotionSystemInstance().analyzeEmotion(text, lang);
};
// Replace the old updatePersonalityTraitsFromEmotion function
window.updatePersonalityTraitsFromEmotion = async function (emotion, text) {
const updatedTraits = await getKimiEmotionSystemInstance().updatePersonalityFromEmotion(emotion, text);
return updatedTraits;
};
// Replace getPersonalityAverage function
window.getPersonalityAverage = function (traits) {
return getKimiEmotionSystemInstance().calculatePersonalityAverage(traits);
};
// Unified trait defaults accessor
window.getTraitDefaults = function () {
return getKimiEmotionSystemInstance().TRAIT_DEFAULTS;
};