Spaces:
Running
Running
// ===== 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; | |
}; | |