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; | |
| }; | |