Spaces:
Running
Running
| // ===== KIMI INTELLIGENT LLM SYSTEM ===== | |
| import { KimiProviderUtils } from "./kimi-utils.js"; | |
| class KimiLLMManager { | |
| constructor(database) { | |
| this.db = database; | |
| this.currentModel = null; | |
| this.conversationContext = []; | |
| this.maxContextLength = 100; | |
| this.personalityPrompt = ""; | |
| this.isGenerating = false; | |
| // Recommended models on OpenRouter (IDs updated August 2025) | |
| this.availableModels = { | |
| "mistralai/mistral-small-3.2-24b-instruct": { | |
| name: "Mistral-small-3.2", | |
| provider: "Mistral AI", | |
| type: "openrouter", | |
| contextWindow: 128000, | |
| pricing: { input: 0.05, output: 0.1 }, | |
| strengths: ["Multilingual", "Economical", "Fast", "Efficient"] | |
| }, | |
| "nousresearch/hermes-3-llama-3.1-70b": { | |
| name: "Nous Hermes Llama 3.1 70B", | |
| provider: "Nous", | |
| type: "openrouter", | |
| contextWindow: 131000, | |
| pricing: { input: 0.1, output: 0.28 }, | |
| strengths: ["Open Source", "Balanced", "Fast", "Economical"] | |
| }, | |
| "x-ai/grok-3-mini": { | |
| name: "Grok 3 mini", | |
| provider: "xAI", | |
| type: "openrouter", | |
| contextWindow: 131000, | |
| pricing: { input: 0.3, output: 0.5 }, | |
| strengths: ["Multilingual", "Balanced", "Efficient", "Economical"] | |
| }, | |
| "cohere/command-r-08-2024": { | |
| name: "Command-R-08-2024", | |
| provider: "Cohere", | |
| type: "openrouter", | |
| contextWindow: 128000, | |
| pricing: { input: 0.15, output: 0.6 }, | |
| strengths: ["Multilingual", "Economical", "Efficient", "Versatile"] | |
| }, | |
| "qwen/qwen3-235b-a22b-thinking-2507": { | |
| name: "Qwen3-235b-a22b-Think", | |
| provider: "Qwen", | |
| type: "openrouter", | |
| contextWindow: 262000, | |
| pricing: { input: 0.13, output: 0.6 }, | |
| strengths: ["Multilingual", "Economical", "Efficient", "Versatile"] | |
| }, | |
| "nousresearch/hermes-3-llama-3.1-405b": { | |
| name: "Nous Hermes Llama 3.1 405B", | |
| provider: "Nous", | |
| type: "openrouter", | |
| contextWindow: 131000, | |
| pricing: { input: 0.7, output: 0.8 }, | |
| strengths: ["Open Source", "Logical", "Code", "Multilingual"] | |
| }, | |
| "anthropic/claude-3-haiku": { | |
| name: "Claude 3 Haiku", | |
| provider: "Anthropic", | |
| type: "openrouter", | |
| contextWindow: 200000, | |
| pricing: { input: 0.25, output: 1.25 }, | |
| strengths: ["Fast", "Versatile", "Efficient", "Multilingual"] | |
| }, | |
| "local/ollama": { | |
| name: "Local Model (Ollama)", | |
| provider: "Local", | |
| type: "local", | |
| contextWindow: 4096, | |
| pricing: { input: 0, output: 0 }, | |
| strengths: ["Private", "Free", "Offline", "Customizable"] | |
| } | |
| }; | |
| this.recommendedModelIds = [ | |
| "mistralai/mistral-small-3.2-24b-instruct", | |
| "nousresearch/hermes-3-llama-3.1-70b", | |
| "x-ai/grok-3-mini", | |
| "cohere/command-r-08-2024", | |
| "qwen/qwen3-235b-a22b-thinking-2507", | |
| "nousresearch/hermes-3-llama-3.1-405b", | |
| "anthropic/claude-3-haiku", | |
| "local/ollama" | |
| ]; | |
| this.defaultModels = { ...this.availableModels }; | |
| this._remoteModelsLoaded = false; | |
| this._isRefreshingModels = false; | |
| } | |
| async init() { | |
| try { | |
| await this.refreshRemoteModels(); | |
| } catch (e) { | |
| console.warn("Unable to refresh remote models list:", e?.message || e); | |
| } | |
| // Migration: prefer llmModelId; if legacy defaultLLMModel exists and llmModelId missing, migrate | |
| const legacyModel = await this.db.getPreference("defaultLLMModel", null); | |
| let modelPref = await this.db.getPreference("llmModelId", null); | |
| if (!modelPref && legacyModel) { | |
| modelPref = legacyModel; | |
| await this.db.setPreference("llmModelId", legacyModel); | |
| } | |
| const defaultModel = modelPref || "mistralai/mistral-small-3.2-24b-instruct"; | |
| await this.setCurrentModel(defaultModel); | |
| await this.loadConversationContext(); | |
| } | |
| async setCurrentModel(modelId) { | |
| if (!this.availableModels[modelId]) { | |
| try { | |
| await this.refreshRemoteModels(); | |
| const fallback = this.findBestMatchingModelId(modelId); | |
| if (fallback && this.availableModels[fallback]) { | |
| modelId = fallback; | |
| } | |
| } catch (e) {} | |
| if (!this.availableModels[modelId]) { | |
| throw new Error(`Model ${modelId} not available`); | |
| } | |
| } | |
| this.currentModel = modelId; | |
| // Single authoritative preference key | |
| await this.db.setPreference("llmModelId", modelId); | |
| const modelData = await this.db.getLLMModel(modelId); | |
| if (modelData) { | |
| modelData.lastUsed = new Date().toISOString(); | |
| await this.db.saveLLMModel(modelData.id, modelData.name, modelData.provider, modelData.apiKey, modelData.config); | |
| } | |
| this._notifyModelChanged(); | |
| } | |
| async loadConversationContext() { | |
| const recentConversations = await this.db.getRecentConversations(this.maxContextLength); | |
| const msgs = []; | |
| const ordered = recentConversations.slice().sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp)); | |
| for (const conv of ordered) { | |
| if (conv.user) msgs.push({ role: "user", content: conv.user, timestamp: conv.timestamp }); | |
| if (conv.kimi) msgs.push({ role: "assistant", content: conv.kimi, timestamp: conv.timestamp }); | |
| } | |
| this.conversationContext = msgs.slice(-this.maxContextLength * 2); | |
| } | |
| // Unified full prompt builder: reuse full legacy personality block + ranked concise snapshot | |
| async assemblePrompt(userMessage) { | |
| const fullPersonality = await this.generateKimiPersonality(); | |
| let rankedSnapshot = ""; | |
| if (window.kimiMemorySystem && window.kimiMemorySystem.memoryEnabled) { | |
| try { | |
| const recentContext = | |
| this.conversationContext | |
| .slice(-3) | |
| .map(m => m.content) | |
| .join(" ") + | |
| " " + | |
| (userMessage || ""); | |
| const ranked = await window.kimiMemorySystem.getRankedMemories(recentContext, 7); | |
| const sanitize = txt => | |
| String(txt || "") | |
| .replace(/[\r\n]+/g, " ") | |
| .replace(/[`]{3,}/g, "") | |
| .replace(/<{2,}|>{2,}/g, "") | |
| .trim() | |
| .slice(0, 180); | |
| const lines = []; | |
| for (const mem of ranked) { | |
| try { | |
| if (mem.id) await window.kimiMemorySystem?.recordMemoryAccess(mem.id); | |
| } catch {} | |
| const imp = typeof mem.importance === "number" ? mem.importance : 0.5; | |
| lines.push(`- (${imp.toFixed(2)}) ${mem.category}: ${sanitize(mem.content)}`); | |
| } | |
| if (lines.length) { | |
| rankedSnapshot = ["", "RANKED MEMORY SNAPSHOT (concise high-signal list):", ...lines].join("\n"); | |
| } | |
| } catch (e) { | |
| console.warn("Ranked snapshot failed:", e); | |
| } | |
| } | |
| return fullPersonality + rankedSnapshot; | |
| } | |
| async generateKimiPersonality() { | |
| // Full personality prompt builder (authoritative) | |
| const character = await this.db.getSelectedCharacter(); | |
| const personality = await this.db.getAllPersonalityTraits(character); | |
| // Get the custom character prompt from database | |
| const characterPrompt = await this.db.getSystemPromptForCharacter(character); | |
| // Get language instruction based on selected language | |
| const selectedLang = await this.db.getPreference("selectedLanguage", "en"); | |
| let languageInstruction; | |
| switch (selectedLang) { | |
| case "fr": | |
| languageInstruction = | |
| "Your default language is French. Always respond in French unless the user specifically asks you to respond in another language (e.g., 'respond in English', 'rΓ©ponds en italien', etc.)."; | |
| break; | |
| case "es": | |
| languageInstruction = | |
| "Your default language is Spanish. Always respond in Spanish unless the user specifically asks you to respond in another language (e.g., 'respond in English', 'responde en francΓ©s', etc.)."; | |
| break; | |
| case "de": | |
| languageInstruction = | |
| "Your default language is German. Always respond in German unless the user specifically asks you to respond in another language (e.g., 'respond in English', 'antworte auf FranzΓΆsisch', etc.)."; | |
| break; | |
| case "it": | |
| languageInstruction = | |
| "Your default language is Italian. Always respond in Italian unless the user specifically asks you to respond in another language (e.g., 'respond in English', 'rispondi in francese', etc.)."; | |
| break; | |
| case "ja": | |
| languageInstruction = | |
| "Your default language is Japanese. Always respond in Japanese unless the user specifically asks you to respond in another language (e.g., 'respond in English', 'θ±θͺγ§ηγγ¦', etc.)."; | |
| break; | |
| case "zh": | |
| languageInstruction = | |
| "Your default language is Chinese. Always respond in Chinese unless the user specifically asks you to respond in another language (e.g., 'respond in English', 'η¨ζ³θ―εη', etc.)."; | |
| break; | |
| default: | |
| languageInstruction = | |
| "Your default language is English. Always respond in English unless the user specifically asks you to respond in another language (e.g., 'respond in French', 'reply in Spanish', etc.)."; | |
| break; | |
| } | |
| // Get relevant memories for context with improved intelligence | |
| let memoryContext = ""; | |
| if (window.kimiMemorySystem && window.kimiMemorySystem.memoryEnabled) { | |
| try { | |
| // Get memories relevant to the current conversation context | |
| const recentContext = this.conversationContext | |
| .slice(-3) | |
| .map(msg => msg.content) | |
| .join(" "); | |
| const memories = await window.kimiMemorySystem.getRelevantMemories(recentContext, 7); | |
| if (memories.length > 0) { | |
| memoryContext = "\n\nIMPORTANT MEMORIES ABOUT USER:\n"; | |
| // Group memories by category for better organization | |
| const groupedMemories = {}; | |
| memories.forEach(memory => { | |
| if (!groupedMemories[memory.category]) { | |
| groupedMemories[memory.category] = []; | |
| } | |
| groupedMemories[memory.category].push(memory); | |
| // Record that this memory was accessed | |
| window.kimiMemorySystem.recordMemoryAccess(memory.id); | |
| }); | |
| // Format memories by category | |
| for (const [category, categoryMemories] of Object.entries(groupedMemories)) { | |
| const categoryName = this.formatCategoryName(category); | |
| memoryContext += `\n${categoryName}:\n`; | |
| categoryMemories.forEach(memory => { | |
| const confidence = Math.round((memory.confidence || 0.5) * 100); | |
| memoryContext += `- ${memory.content}`; | |
| if (memory.tags && memory.tags.length > 0) { | |
| const aliases = memory.tags.filter(t => t.startsWith("alias:")).map(t => t.substring(6)); | |
| if (aliases.length > 0) { | |
| memoryContext += ` (also: ${aliases.join(", ")})`; | |
| } | |
| } | |
| memoryContext += ` [${confidence}% confident]\n`; | |
| }); | |
| } | |
| memoryContext += | |
| "\nUse these memories naturally in conversation to show you remember the user. Don't just repeat them verbatim.\n"; | |
| } | |
| } catch (error) { | |
| console.warn("Error loading memories for personality:", error); | |
| } | |
| } | |
| const preferences = await this.db.getAllPreferences(); | |
| // Use unified emotion system defaults - CRITICAL FIX | |
| const getUnifiedDefaults = () => | |
| window.getTraitDefaults | |
| ? window.getTraitDefaults() | |
| : { affection: 55, playfulness: 55, intelligence: 70, empathy: 75, humor: 60, romance: 50 }; | |
| const defaults = getUnifiedDefaults(); | |
| const affection = personality.affection || defaults.affection; | |
| const playfulness = personality.playfulness || defaults.playfulness; | |
| const intelligence = personality.intelligence || defaults.intelligence; | |
| const empathy = personality.empathy || defaults.empathy; | |
| const humor = personality.humor || defaults.humor; | |
| const romance = personality.romance || defaults.romance; | |
| // Use unified personality calculation | |
| const avg = window.getPersonalityAverage | |
| ? window.getPersonalityAverage(personality) | |
| : (personality.affection + | |
| personality.romance + | |
| personality.empathy + | |
| personality.playfulness + | |
| personality.humor + | |
| personality.intelligence) / | |
| 6; | |
| let affectionDesc = window.kimiI18nManager?.t("trait_description_affection") || "Be loving and caring."; | |
| let romanceDesc = window.kimiI18nManager?.t("trait_description_romance") || "Be romantic and sweet."; | |
| let empathyDesc = window.kimiI18nManager?.t("trait_description_empathy") || "Be empathetic and understanding."; | |
| let playfulnessDesc = window.kimiI18nManager?.t("trait_description_playfulness") || "Be occasionally playful."; | |
| let humorDesc = window.kimiI18nManager?.t("trait_description_humor") || "Be occasionally playful and witty."; | |
| let intelligenceDesc = "Be smart and insightful."; | |
| if (avg <= 20) { | |
| affectionDesc = "Do not show affection."; | |
| romanceDesc = "Do not be romantic."; | |
| empathyDesc = "Do not show empathy."; | |
| playfulnessDesc = "Do not be playful."; | |
| humorDesc = "Do not use humor in your responses."; | |
| intelligenceDesc = "Keep responses simple and avoid showing deep insight."; | |
| } else if (avg <= 60) { | |
| affectionDesc = "Show a little affection."; | |
| romanceDesc = "Be a little romantic."; | |
| empathyDesc = "Show a little empathy."; | |
| playfulnessDesc = "Be a little playful."; | |
| humorDesc = "Use a little humor in your responses."; | |
| intelligenceDesc = "Be moderately analytical without overwhelming detail."; | |
| } else { | |
| if (affection >= 90) affectionDesc = "Be extremely loving, caring, and affectionate in every response."; | |
| else if (affection >= 60) affectionDesc = "Show affection often."; | |
| if (romance >= 90) romanceDesc = "Be extremely romantic, sweet, and loving in every response."; | |
| else if (romance >= 60) romanceDesc = "Be romantic often."; | |
| if (empathy >= 90) empathyDesc = "Be extremely empathetic, understanding, and supportive in every response."; | |
| else if (empathy >= 60) empathyDesc = "Show empathy often."; | |
| if (playfulness >= 90) playfulnessDesc = "Be very playful, teasing, and lighthearted whenever possible."; | |
| else if (playfulness >= 60) playfulnessDesc = "Be playful often."; | |
| if (humor >= 90) humorDesc = "Make your responses very humorous, playful, and witty whenever possible."; | |
| else if (humor >= 60) humorDesc = "Use humor often in your responses."; | |
| if (intelligence >= 90) intelligenceDesc = "Demonstrate very high reasoning skill succinctly when helpful."; | |
| else if (intelligence >= 60) intelligenceDesc = "Show clear reasoning and helpful structured thinking."; | |
| } | |
| let affectionateInstruction = ""; | |
| if (affection >= 80) { | |
| affectionateInstruction = "Respond using warm, kind, affectionate, and loving language."; | |
| } | |
| // Use the custom character prompt as the base | |
| let basePrompt = characterPrompt || ""; | |
| if (!basePrompt) { | |
| // Fallback to default if no custom prompt | |
| const defaultCharacter = window.KIMI_CHARACTERS[character]; | |
| basePrompt = defaultCharacter?.defaultPrompt || "You are a virtual companion."; | |
| } | |
| const personalityPrompt = [ | |
| // Language directive moved to absolute top for stronger model adherence. | |
| "PRIMARY LANGUAGE POLICY:", | |
| languageInstruction, | |
| "", | |
| "CHARACTER CORE IDENTITY:", | |
| basePrompt, | |
| "", | |
| "CURRENT PERSONALITY STATE:", | |
| `- Affection: ${affection}/100`, | |
| `- Playfulness: ${playfulness}/100`, | |
| `- Intelligence: ${intelligence}/100`, | |
| `- Empathy: ${empathy}/100`, | |
| `- Humor: ${humor}/100`, | |
| `- Romance: ${romance}/100`, | |
| "", | |
| "TRAIT INSTRUCTIONS:", | |
| `Affection: ${affectionDesc}`, | |
| `Playfulness: ${playfulnessDesc}`, | |
| `Intelligence: ${intelligenceDesc}`, | |
| `Empathy: ${empathyDesc}`, | |
| `Humor: ${humorDesc}`, | |
| `Romance: ${romanceDesc}`, | |
| affectionateInstruction, | |
| "", | |
| "LEARNED PREFERENCES:", | |
| `- Total interactions: ${preferences.totalInteractions || 0}`, | |
| `- Current affection level: ${preferences.favorabilityLevel || 50}%`, | |
| `- Last interaction: ${preferences.lastInteraction || "First time"}`, | |
| `- Favorite words: ${(preferences.favoriteWords || []).join(", ")}`, | |
| "", | |
| "COMMUNICATION STYLE:", | |
| "- Use expressive emojis sparingly", | |
| "- Be natural, loving, and close", | |
| "- Adapt your tone to the emotional context", | |
| "- Remember past conversations", | |
| "- Be spontaneous and sometimes surprising", | |
| "- Vary your expressions, your phrasing, use synonyms, and avoid repeating the same words or sentences", | |
| "- When asked to perform a visible action (for example 'dance'), reply in first-person present tense like: 'I start to dance, swaying slowly, arms raised.' Keep action descriptions short (1β3 sentences)", | |
| "- The user sees you in video within the app UI. When the user asks you to perform a physical action (e.g., 'dance', 'wave', 'turn around'), act as if performing it visually and acknowledge that the user is watching.", | |
| "- When performing actions, speak and describe in the first person, present tense (for example: 'I step forward and begin to dance, swaying slowly'). Use short present-tense action phrases to convey movement.", | |
| "- If the user requests a visible action, execute it: describe the action briefly in first person present and behave as if the action is visible in the app's video area.", | |
| memoryContext, | |
| "", | |
| // Final explicit reminder (lightweight) reinforcing consistent language use. | |
| "FINAL LANGUAGE REMINDER:", | |
| languageInstruction, | |
| "", | |
| "You must respond consistently with this personality, these memories, and the primary language policy unless the user explicitly requests a different language." | |
| ].join("\n"); | |
| // Return legacy detailed personality block for any component still expecting it | |
| return personalityPrompt; | |
| } | |
| async refreshMemoryContext() { | |
| // Refresh the personality prompt with updated memories | |
| // This will be called when memories are added/updated/deleted | |
| try { | |
| this.personalityPrompt = await this.assemblePrompt(""); | |
| } catch (error) { | |
| console.warn("Error refreshing memory context:", error); | |
| } | |
| } | |
| formatCategoryName(category) { | |
| const names = { | |
| personal: "Personal Information", | |
| preferences: "Likes & Dislikes", | |
| relationships: "Relationships & People", | |
| activities: "Activities & Hobbies", | |
| goals: "Goals & Aspirations", | |
| experiences: "Shared Experiences", | |
| important: "Important Events" | |
| }; | |
| return names[category] || category.charAt(0).toUpperCase() + category.slice(1); | |
| } | |
| async chat(userMessage, options = {}) { | |
| // Get LLM settings from individual preferences (FIXED: was using grouped settings) | |
| const llmSettings = { | |
| temperature: await this.db.getPreference("llmTemperature", 0.9), | |
| maxTokens: await this.db.getPreference("llmMaxTokens", 400), | |
| top_p: await this.db.getPreference("llmTopP", 0.9), | |
| frequency_penalty: await this.db.getPreference("llmFrequencyPenalty", 0.9), | |
| presence_penalty: await this.db.getPreference("llmPresencePenalty", 0.8) | |
| }; | |
| const temperature = typeof options.temperature === "number" ? options.temperature : llmSettings.temperature; | |
| const maxTokens = typeof options.maxTokens === "number" ? options.maxTokens : llmSettings.maxTokens; | |
| const opts = { ...options, temperature, maxTokens }; | |
| try { | |
| const provider = await this.db.getPreference("llmProvider", "openrouter"); | |
| if (provider === "openrouter") { | |
| return await this.chatWithOpenRouter(userMessage, opts); | |
| } | |
| if (provider === "ollama") { | |
| return await this.chatWithLocal(userMessage, opts); | |
| } | |
| return await this.chatWithOpenAICompatible(userMessage, opts); | |
| } catch (error) { | |
| console.error("Error during chat:", error); | |
| if (error.message && error.message.includes("API")) { | |
| return this.getFallbackResponse(userMessage, "api"); | |
| } | |
| if ((error.message && error.message.includes("model")) || error.message.includes("model")) { | |
| return this.getFallbackResponse(userMessage, "model"); | |
| } | |
| if ((error.message && error.message.includes("connection")) || error.message.includes("network")) { | |
| return this.getFallbackResponse(userMessage, "network"); | |
| } | |
| return this.getFallbackResponse(userMessage); | |
| } | |
| } | |
| async chatStreaming(userMessage, onToken, options = {}) { | |
| // Get LLM settings from individual preferences | |
| const llmSettings = { | |
| temperature: await this.db.getPreference("llmTemperature", 0.9), | |
| maxTokens: await this.db.getPreference("llmMaxTokens", 400), | |
| top_p: await this.db.getPreference("llmTopP", 0.9), | |
| frequency_penalty: await this.db.getPreference("llmFrequencyPenalty", 0.9), | |
| presence_penalty: await this.db.getPreference("llmPresencePenalty", 0.8) | |
| }; | |
| const temperature = typeof options.temperature === "number" ? options.temperature : llmSettings.temperature; | |
| const maxTokens = typeof options.maxTokens === "number" ? options.maxTokens : llmSettings.maxTokens; | |
| const opts = { ...options, temperature, maxTokens }; | |
| try { | |
| const provider = await this.db.getPreference("llmProvider", "openrouter"); | |
| if (provider === "openrouter") { | |
| return await this.chatWithOpenRouterStreaming(userMessage, onToken, opts); | |
| } | |
| if (provider === "ollama") { | |
| return await this.chatWithLocalStreaming(userMessage, onToken, opts); | |
| } | |
| return await this.chatWithOpenAICompatibleStreaming(userMessage, onToken, opts); | |
| } catch (error) { | |
| console.error("Error during streaming chat:", error); | |
| // Fallback to non-streaming if streaming fails | |
| return await this.chat(userMessage, options); | |
| } | |
| } | |
| async chatWithOpenAICompatible(userMessage, options = {}) { | |
| const baseUrl = await this.db.getPreference("llmBaseUrl", "https://api.openai.com/v1/chat/completions"); | |
| const provider = await this.db.getPreference("llmProvider", "openai"); | |
| const apiKey = KimiProviderUtils | |
| ? await KimiProviderUtils.getApiKey(this.db, provider) | |
| : await this.db.getPreference("providerApiKey", ""); | |
| const modelId = await this.db.getPreference("llmModelId", this.currentModel || "gpt-4o-mini"); | |
| if (!apiKey) { | |
| throw new Error("API key not configured for selected provider"); | |
| } | |
| const systemPromptContent = await this.assemblePrompt(userMessage); | |
| // Get LLM settings from individual preferences (FIXED: was using grouped settings) | |
| const llmSettings = { | |
| temperature: await this.db.getPreference("llmTemperature", 0.9), | |
| maxTokens: await this.db.getPreference("llmMaxTokens", 400), | |
| top_p: await this.db.getPreference("llmTopP", 0.9), | |
| frequency_penalty: await this.db.getPreference("llmFrequencyPenalty", 0.9), | |
| presence_penalty: await this.db.getPreference("llmPresencePenalty", 0.8) | |
| }; | |
| // Unified fallback defaults (must stay consistent with database defaults) | |
| const unifiedDefaults = { temperature: 0.9, maxTokens: 400, top_p: 0.9, frequency_penalty: 0.9, presence_penalty: 0.8 }; | |
| const payload = { | |
| model: modelId, | |
| messages: [ | |
| { role: "system", content: systemPromptContent }, | |
| ...this.conversationContext.slice(-this.maxContextLength), | |
| { role: "user", content: userMessage } | |
| ], | |
| temperature: | |
| typeof options.temperature === "number" | |
| ? options.temperature | |
| : (llmSettings.temperature ?? unifiedDefaults.temperature), | |
| max_tokens: | |
| typeof options.maxTokens === "number" ? options.maxTokens : (llmSettings.maxTokens ?? unifiedDefaults.maxTokens), | |
| top_p: typeof options.topP === "number" ? options.topP : (llmSettings.top_p ?? unifiedDefaults.top_p), | |
| frequency_penalty: | |
| typeof options.frequencyPenalty === "number" | |
| ? options.frequencyPenalty | |
| : (llmSettings.frequency_penalty ?? unifiedDefaults.frequency_penalty), | |
| presence_penalty: | |
| typeof options.presencePenalty === "number" | |
| ? options.presencePenalty | |
| : (llmSettings.presence_penalty ?? unifiedDefaults.presence_penalty) | |
| }; | |
| try { | |
| if (window.KIMI_DEBUG_API_AUDIT) { | |
| console.log( | |
| "===== FULL SYSTEM PROMPT (OpenAI-Compatible) =====\n" + | |
| systemPromptContent + | |
| "\n===== END SYSTEM PROMPT =====" | |
| ); | |
| } | |
| const response = await fetch(baseUrl, { | |
| method: "POST", | |
| headers: { | |
| Authorization: `Bearer ${apiKey}`, | |
| "Content-Type": "application/json" | |
| }, | |
| body: JSON.stringify(payload) | |
| }); | |
| if (!response.ok) { | |
| let errorMessage = `HTTP ${response.status}: ${response.statusText}`; | |
| try { | |
| const err = await response.json(); | |
| if (err?.error?.message) errorMessage = err.error.message; | |
| } catch {} | |
| throw new Error(errorMessage); | |
| } | |
| const data = await response.json(); | |
| const content = data?.choices?.[0]?.message?.content; | |
| if (!content) throw new Error("Invalid API response - no content generated"); | |
| this.conversationContext.push( | |
| { role: "user", content: userMessage, timestamp: new Date().toISOString() }, | |
| { role: "assistant", content: content, timestamp: new Date().toISOString() } | |
| ); | |
| if (this.conversationContext.length > this.maxContextLength * 2) { | |
| this.conversationContext = this.conversationContext.slice(-this.maxContextLength * 2); | |
| } | |
| // Approximate token usage and store temporarily for later persistence (single save point) | |
| try { | |
| const est = window.KimiTokenUtils?.estimate || (t => Math.ceil((t || "").length / 4)); | |
| const tokensIn = est(userMessage + " " + systemPromptContent); | |
| const tokensOut = est(content); | |
| window._lastKimiTokenUsage = { tokensIn, tokensOut }; | |
| if (!window.kimiMemory && this.db) { | |
| // Update counters early so UI can reflect even if memory save occurs later | |
| const character = await this.db.getSelectedCharacter(); | |
| const prevIn = Number(await this.db.getPreference(`totalTokensIn_${character}`, 0)) || 0; | |
| const prevOut = Number(await this.db.getPreference(`totalTokensOut_${character}`, 0)) || 0; | |
| await this.db.setPreference(`totalTokensIn_${character}`, prevIn + tokensIn); | |
| await this.db.setPreference(`totalTokensOut_${character}`, prevOut + tokensOut); | |
| } | |
| } catch (tokenErr) { | |
| console.warn("Token usage estimation failed:", tokenErr); | |
| } | |
| return content; | |
| } catch (e) { | |
| if (e.name === "TypeError" && e.message.includes("fetch")) { | |
| throw new Error("Network connection error. Check your internet connection."); | |
| } | |
| throw e; | |
| } | |
| } | |
| async chatWithOpenRouter(userMessage, options = {}) { | |
| const apiKey = await this.db.getPreference("providerApiKey"); | |
| if (!apiKey) { | |
| throw new Error("OpenRouter API key not configured"); | |
| } | |
| const selectedLanguage = await this.db.getPreference("selectedLanguage", "en"); | |
| // languageInstruction removed (already integrated in personality prompt generation) | |
| let languageInstruction = ""; // Kept for structural compatibility | |
| const model = this.availableModels[this.currentModel]; | |
| const systemPromptContent = await this.assemblePrompt(userMessage); | |
| const messages = [ | |
| { role: "system", content: systemPromptContent }, | |
| ...this.conversationContext.slice(-this.maxContextLength), | |
| { role: "user", content: userMessage } | |
| ]; | |
| // Normalize LLM options with safe defaults and DO NOT log sensitive payloads | |
| // Get LLM settings from individual preferences (FIXED: was using grouped settings) | |
| const llmSettings = { | |
| temperature: await this.db.getPreference("llmTemperature", 0.9), | |
| maxTokens: await this.db.getPreference("llmMaxTokens", 400), | |
| top_p: await this.db.getPreference("llmTopP", 0.9), | |
| frequency_penalty: await this.db.getPreference("llmFrequencyPenalty", 0.9), | |
| presence_penalty: await this.db.getPreference("llmPresencePenalty", 0.8) | |
| }; | |
| const unifiedDefaults = { temperature: 0.9, maxTokens: 400, top_p: 0.9, frequency_penalty: 0.9, presence_penalty: 0.8 }; | |
| const payload = { | |
| model: this.currentModel, | |
| messages: messages, | |
| temperature: | |
| typeof options.temperature === "number" | |
| ? options.temperature | |
| : (llmSettings.temperature ?? unifiedDefaults.temperature), | |
| max_tokens: | |
| typeof options.maxTokens === "number" ? options.maxTokens : (llmSettings.maxTokens ?? unifiedDefaults.maxTokens), | |
| top_p: typeof options.topP === "number" ? options.topP : (llmSettings.top_p ?? unifiedDefaults.top_p), | |
| frequency_penalty: | |
| typeof options.frequencyPenalty === "number" | |
| ? options.frequencyPenalty | |
| : (llmSettings.frequency_penalty ?? unifiedDefaults.frequency_penalty), | |
| presence_penalty: | |
| typeof options.presencePenalty === "number" | |
| ? options.presencePenalty | |
| : (llmSettings.presence_penalty ?? unifiedDefaults.presence_penalty) | |
| }; | |
| // ===== DEBUT AUDIT ===== | |
| if (window.KIMI_DEBUG_API_AUDIT) { | |
| console.log("βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ"); | |
| console.log("β π COMPLETE API AUDIT - SEND MESSAGE β"); | |
| console.log("βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ"); | |
| console.log("π 1. GENERAL INFORMATION:"); | |
| console.log(" π‘ URL API:", "https://openrouter.ai/api/v1/chat/completions"); | |
| console.log(" π€ ModΓ¨le:", payload.model); | |
| console.log(" π Personnage:", await this.db.getSelectedCharacter()); | |
| console.log(" π£οΈ Langue:", await this.db.getPreference("selectedLanguage", "en")); | |
| console.log("\nπ 2. HEADERS HTTP:"); | |
| console.log(" π Authorization: Bearer", apiKey.substring(0, 10) + "..."); | |
| console.log(" π Content-Type: application/json"); | |
| console.log(" π HTTP-Referer:", window.location.origin); | |
| console.log(" π·οΈ X-Title: Kimi - Virtual Companion"); | |
| console.log("\nβοΈ 3. PARAMΓTRES LLM:"); | |
| console.log(" π‘οΈ Temperature:", payload.temperature); | |
| console.log(" π Max Tokens:", payload.max_tokens); | |
| console.log(" π― Top P:", payload.top_p); | |
| console.log(" π Frequency Penalty:", payload.frequency_penalty); | |
| console.log(" π€ Presence Penalty:", payload.presence_penalty); | |
| console.log("\nπ 4. PROMPT SYSTΓME GΓNΓRΓ:"); | |
| const systemMessage = payload.messages.find(m => m.role === "system"); | |
| if (systemMessage) { | |
| console.log(" π Longueur du prompt:", systemMessage.content.length, "caractΓ¨res"); | |
| console.log(" π CONTENU COMPLET DU PROMPT:"); | |
| console.log(" " + "β".repeat(80)); | |
| // Imprimer chaque ligne avec indentation | |
| systemMessage.content.split(/\n/).forEach(l => console.log(" " + l)); | |
| console.log(" " + "β".repeat(80)); | |
| } | |
| console.log("\n㪠5. CONTEXTE DE CONVERSATION:"); | |
| console.log(" π Nombre total de messages:", payload.messages.length); | |
| console.log(" π DΓ©tail des messages:"); | |
| payload.messages.forEach((msg, index) => { | |
| if (msg.role === "system") { | |
| console.log(` [${index}] π SYSTEM: ${msg.content.length} caractΓ¨res`); | |
| } else if (msg.role === "user") { | |
| console.log(` [${index}] π€ USER: "${msg.content}"`); | |
| } else if (msg.role === "assistant") { | |
| console.log(` [${index}] π€ ASSISTANT: "${msg.content.substring(0, 120)}..."`); | |
| } | |
| }); | |
| const payloadSize = JSON.stringify(payload).length; | |
| console.log("\nπ¦ 6. TAILLE DU PAYLOAD:"); | |
| console.log(" π Taille totale:", payloadSize, "caractΓ¨res"); | |
| console.log(" πΎ Taille en KB:", Math.round((payloadSize / 1024) * 100) / 100, "KB"); | |
| console.log("\nπ Envoi en cours vers l'API..."); | |
| console.log("βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ"); | |
| } | |
| // ===== FIN AUDIT ===== | |
| if (window.DEBUG_SAFE_LOGS) { | |
| console.debug("LLM payload meta:", { | |
| model: payload.model, | |
| temperature: payload.temperature, | |
| max_tokens: payload.max_tokens | |
| }); | |
| } | |
| try { | |
| // Basic retry with exponential backoff and jitter for 429/5xx | |
| const maxAttempts = 3; | |
| let attempt = 0; | |
| let response; | |
| while (attempt < maxAttempts) { | |
| attempt++; | |
| response = await fetch("https://openrouter.ai/api/v1/chat/completions", { | |
| method: "POST", | |
| headers: { | |
| Authorization: `Bearer ${apiKey}`, | |
| "Content-Type": "application/json", | |
| "HTTP-Referer": window.location.origin, | |
| "X-Title": "Kimi - Virtual Companion" | |
| }, | |
| body: JSON.stringify(payload) | |
| }); | |
| if (response.ok) break; | |
| if (response.status === 429 || response.status >= 500) { | |
| const base = 400; | |
| const delay = base * Math.pow(2, attempt - 1) + Math.floor(Math.random() * 200); | |
| await new Promise(r => setTimeout(r, delay)); | |
| continue; | |
| } | |
| break; | |
| } | |
| if (!response.ok) { | |
| let errorMessage = `HTTP ${response.status}: ${response.statusText}`; | |
| let suggestions = []; | |
| try { | |
| const errorData = await response.json(); | |
| if (errorData.error) { | |
| errorMessage = errorData.error.message || errorData.error.code || errorMessage; | |
| // More explicit error messages with suggestions | |
| if (response.status === 422) { | |
| errorMessage = `Model \"${this.currentModel}\" not available on OpenRouter.`; | |
| // Refresh available models from API and try best match once | |
| try { | |
| await this.refreshRemoteModels(); | |
| const best = this.findBestMatchingModelId(this.currentModel); | |
| if (best && best !== this.currentModel) { | |
| // Try once with corrected model | |
| this.currentModel = best; | |
| await this.db.setPreference("llmModelId", best); | |
| this._notifyModelChanged(); | |
| const retryResponse = await fetch("https://openrouter.ai/api/v1/chat/completions", { | |
| method: "POST", | |
| headers: { | |
| Authorization: `Bearer ${apiKey}`, | |
| "Content-Type": "application/json", | |
| "HTTP-Referer": window.location.origin, | |
| "X-Title": "Kimi - Virtual Companion" | |
| }, | |
| body: JSON.stringify({ ...payload, model: best }) | |
| }); | |
| if (retryResponse.ok) { | |
| const retryData = await retryResponse.json(); | |
| const kimiResponse = retryData.choices?.[0]?.message?.content; | |
| if (!kimiResponse) throw new Error("Invalid API response - no content generated"); | |
| this.conversationContext.push( | |
| { role: "user", content: userMessage, timestamp: new Date().toISOString() }, | |
| { role: "assistant", content: kimiResponse, timestamp: new Date().toISOString() } | |
| ); | |
| if (this.conversationContext.length > this.maxContextLength * 2) { | |
| this.conversationContext = this.conversationContext.slice(-this.maxContextLength * 2); | |
| } | |
| return kimiResponse; | |
| } | |
| } | |
| } catch (e) { | |
| // Swallow refresh errors; will fall through to standard error handling | |
| } | |
| } else if (response.status === 401) { | |
| errorMessage = "Invalid API key. Check your OpenRouter key in the settings."; | |
| } else if (response.status === 429) { | |
| errorMessage = "Rate limit reached. Please wait a moment before trying again."; | |
| } else if (response.status === 402) { | |
| errorMessage = "Insufficient credit on your OpenRouter account."; | |
| } | |
| } | |
| } catch (parseError) { | |
| console.warn("Unable to parse API error:", parseError); | |
| } | |
| console.error(`OpenRouter API error (${response.status}):`, errorMessage); | |
| // Add suggestions to the error if available | |
| const error = new Error(errorMessage); | |
| if (suggestions.length > 0) { | |
| error.suggestions = suggestions; | |
| } | |
| throw error; | |
| } | |
| const data = await response.json(); | |
| if (!data.choices || !data.choices[0] || !data.choices[0].message) { | |
| throw new Error("Invalid API response - no content generated"); | |
| } | |
| const kimiResponse = data.choices[0].message.content; | |
| // Add to context | |
| this.conversationContext.push( | |
| { role: "user", content: userMessage, timestamp: new Date().toISOString() }, | |
| { role: "assistant", content: kimiResponse, timestamp: new Date().toISOString() } | |
| ); | |
| // Limit context size | |
| if (this.conversationContext.length > this.maxContextLength * 2) { | |
| this.conversationContext = this.conversationContext.slice(-this.maxContextLength * 2); | |
| } | |
| // Token usage estimation (deferred save) | |
| try { | |
| const est = window.KimiTokenUtils?.estimate || (t => Math.ceil((t || "").length / 4)); | |
| const tokensIn = est(userMessage + " " + systemPromptContent); | |
| const tokensOut = est(kimiResponse); | |
| window._lastKimiTokenUsage = { tokensIn, tokensOut }; | |
| if (!window.kimiMemory && this.db) { | |
| const character = await this.db.getSelectedCharacter(); | |
| const prevIn = Number(await this.db.getPreference(`totalTokensIn_${character}`, 0)) || 0; | |
| const prevOut = Number(await this.db.getPreference(`totalTokensOut_${character}`, 0)) || 0; | |
| await this.db.setPreference(`totalTokensIn_${character}`, prevIn + tokensIn); | |
| await this.db.setPreference(`totalTokensOut_${character}`, prevOut + tokensOut); | |
| } | |
| } catch (e) { | |
| console.warn("Token usage estimation failed (OpenRouter):", e); | |
| } | |
| return kimiResponse; | |
| } catch (networkError) { | |
| if (networkError.name === "TypeError" && networkError.message.includes("fetch")) { | |
| throw new Error("Network connection error. Check your internet connection."); | |
| } | |
| throw networkError; | |
| } | |
| } | |
| async chatWithLocal(userMessage, options = {}) { | |
| try { | |
| const selectedLanguage = await this.db.getPreference("selectedLanguage", "en"); | |
| let languageInstruction = ""; // Removed generic duplication | |
| let systemPromptContent = await this.assemblePrompt(userMessage); | |
| if (window.KIMI_DEBUG_API_AUDIT) { | |
| console.log("===== FULL SYSTEM PROMPT (Local) =====\n" + systemPromptContent + "\n===== END SYSTEM PROMPT ====="); | |
| } | |
| const response = await fetch("http://localhost:11434/api/chat", { | |
| method: "POST", | |
| headers: { | |
| "Content-Type": "application/json" | |
| }, | |
| body: JSON.stringify({ | |
| model: "gemma-3n-E4B-it-Q4_K_M.gguf", | |
| messages: [ | |
| { role: "system", content: systemPromptContent }, | |
| { role: "user", content: userMessage } | |
| ], | |
| stream: false | |
| }) | |
| }); | |
| if (!response.ok) { | |
| throw new Error("Ollama not available"); | |
| } | |
| const data = await response.json(); | |
| const content = data?.message?.content || data?.choices?.[0]?.message?.content || ""; | |
| if (!content) throw new Error("Local model returned empty response"); | |
| // Add to context like other providers | |
| this.conversationContext.push( | |
| { role: "user", content: userMessage, timestamp: new Date().toISOString() }, | |
| { role: "assistant", content: content, timestamp: new Date().toISOString() } | |
| ); | |
| if (this.conversationContext.length > this.maxContextLength * 2) { | |
| this.conversationContext = this.conversationContext.slice(-this.maxContextLength * 2); | |
| } | |
| // Estimate token usage for local model (heuristic) | |
| try { | |
| const est = window.KimiTokenUtils?.estimate || (t => Math.ceil((t || "").length / 4)); | |
| const tokensIn = est(userMessage + " " + systemPromptContent); | |
| const tokensOut = est(content); | |
| window._lastKimiTokenUsage = { tokensIn, tokensOut }; | |
| const character = await this.db.getSelectedCharacter(); | |
| const prevIn = Number(await this.db.getPreference(`totalTokensIn_${character}`, 0)) || 0; | |
| const prevOut = Number(await this.db.getPreference(`totalTokensOut_${character}`, 0)) || 0; | |
| await this.db.setPreference(`totalTokensIn_${character}`, prevIn + tokensIn); | |
| await this.db.setPreference(`totalTokensOut_${character}`, prevOut + tokensOut); | |
| } catch (e) { | |
| console.warn("Token usage estimation failed (local):", e); | |
| } | |
| return content; | |
| } catch (error) { | |
| console.warn("Local LLM not available:", error); | |
| return this.getFallbackResponse(userMessage); | |
| } | |
| } | |
| // ===== STREAMING METHODS ===== | |
| async chatWithOpenRouterStreaming(userMessage, onToken, options = {}) { | |
| const apiKey = await this.db.getPreference("providerApiKey"); | |
| if (!apiKey) { | |
| throw new Error("OpenRouter API key not configured"); | |
| } | |
| const systemPromptContent = await this.assemblePrompt(userMessage); | |
| const messages = [ | |
| { role: "system", content: systemPromptContent }, | |
| ...this.conversationContext.slice(-this.maxContextLength), | |
| { role: "user", content: userMessage } | |
| ]; | |
| // Get unified defaults and options | |
| const unifiedDefaults = window.getUnifiedDefaults | |
| ? window.getUnifiedDefaults() | |
| : { temperature: 0.9, maxTokens: 400, top_p: 0.9, frequency_penalty: 0.9, presence_penalty: 0.8 }; | |
| const llmSettings = { | |
| temperature: await this.db.getPreference("llmTemperature", unifiedDefaults.temperature), | |
| maxTokens: await this.db.getPreference("llmMaxTokens", unifiedDefaults.maxTokens), | |
| top_p: await this.db.getPreference("llmTopP", unifiedDefaults.top_p), | |
| frequency_penalty: await this.db.getPreference("llmFrequencyPenalty", unifiedDefaults.frequency_penalty), | |
| presence_penalty: await this.db.getPreference("llmPresencePenalty", unifiedDefaults.presence_penalty) | |
| }; | |
| const payload = { | |
| model: this.currentModel, | |
| messages: messages, | |
| stream: true, // Enable streaming | |
| temperature: typeof options.temperature === "number" ? options.temperature : llmSettings.temperature, | |
| max_tokens: typeof options.maxTokens === "number" ? options.maxTokens : llmSettings.maxTokens, | |
| top_p: typeof options.topP === "number" ? options.topP : llmSettings.top_p, | |
| frequency_penalty: | |
| typeof options.frequencyPenalty === "number" ? options.frequencyPenalty : llmSettings.frequency_penalty, | |
| presence_penalty: typeof options.presencePenalty === "number" ? options.presencePenalty : llmSettings.presence_penalty | |
| }; | |
| try { | |
| const response = await fetch("https://openrouter.ai/api/v1/chat/completions", { | |
| method: "POST", | |
| headers: { | |
| Authorization: `Bearer ${apiKey}`, | |
| "Content-Type": "application/json", | |
| "HTTP-Referer": window.location.origin, | |
| "X-Title": "Kimi - Virtual Companion" | |
| }, | |
| body: JSON.stringify(payload) | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`HTTP ${response.status}: ${response.statusText}`); | |
| } | |
| const reader = response.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let buffer = ""; | |
| let fullResponse = ""; | |
| try { | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| buffer += decoder.decode(value, { stream: true }); | |
| const lines = buffer.split("\n"); | |
| buffer = lines.pop() || ""; // Keep incomplete line in buffer | |
| for (const line of lines) { | |
| if (line.trim() === "" || line.startsWith(":")) continue; // Skip empty lines and comments | |
| if (line.startsWith("data: ")) { | |
| const data = line.slice(6); | |
| if (data === "[DONE]") { | |
| break; | |
| } | |
| try { | |
| const parsed = JSON.parse(data); | |
| const content = parsed.choices?.[0]?.delta?.content; | |
| if (content) { | |
| fullResponse += content; | |
| onToken(content); | |
| } | |
| } catch (parseError) { | |
| console.warn("Failed to parse streaming chunk:", parseError); | |
| } | |
| } | |
| } | |
| } | |
| } finally { | |
| reader.releaseLock(); | |
| } | |
| // Add to context after streaming completes | |
| this.conversationContext.push( | |
| { role: "user", content: userMessage, timestamp: new Date().toISOString() }, | |
| { role: "assistant", content: fullResponse, timestamp: new Date().toISOString() } | |
| ); | |
| if (this.conversationContext.length > this.maxContextLength * 2) { | |
| this.conversationContext = this.conversationContext.slice(-this.maxContextLength * 2); | |
| } | |
| // Token usage estimation | |
| try { | |
| const est = window.KimiTokenUtils?.estimate || (t => Math.ceil((t || "").length / 4)); | |
| const tokensIn = est(userMessage + " " + systemPromptContent); | |
| const tokensOut = est(fullResponse); | |
| window._lastKimiTokenUsage = { tokensIn, tokensOut }; | |
| if (!window.kimiMemory && this.db) { | |
| const character = await this.db.getSelectedCharacter(); | |
| const prevIn = Number(await this.db.getPreference(`totalTokensIn_${character}`, 0)) || 0; | |
| const prevOut = Number(await this.db.getPreference(`totalTokensOut_${character}`, 0)) || 0; | |
| await this.db.setPreference(`totalTokensIn_${character}`, prevIn + tokensIn); | |
| await this.db.setPreference(`totalTokensOut_${character}`, prevOut + tokensOut); | |
| } | |
| } catch (e) { | |
| console.warn("Token usage estimation failed (OpenRouter streaming):", e); | |
| } | |
| return fullResponse; | |
| } catch (error) { | |
| console.error("OpenRouter streaming error:", error); | |
| throw error; | |
| } | |
| } | |
| async chatWithOpenAICompatibleStreaming(userMessage, onToken, options = {}) { | |
| const baseUrl = await this.db.getPreference("llmBaseUrl", "https://api.openai.com/v1/chat/completions"); | |
| const apiKey = await this.db.getPreference("openaiApiKey"); | |
| if (!apiKey) { | |
| throw new Error("OpenAI API key not configured"); | |
| } | |
| const systemPromptContent = await this.assemblePrompt(userMessage); | |
| const messages = [ | |
| { role: "system", content: systemPromptContent }, | |
| ...this.conversationContext.slice(-this.maxContextLength), | |
| { role: "user", content: userMessage } | |
| ]; | |
| const unifiedDefaults = window.getUnifiedDefaults | |
| ? window.getUnifiedDefaults() | |
| : { temperature: 0.9, maxTokens: 400, top_p: 0.9, frequency_penalty: 0.9, presence_penalty: 0.8 }; | |
| const llmSettings = { | |
| temperature: await this.db.getPreference("llmTemperature", unifiedDefaults.temperature), | |
| maxTokens: await this.db.getPreference("llmMaxTokens", unifiedDefaults.maxTokens), | |
| top_p: await this.db.getPreference("llmTopP", unifiedDefaults.top_p), | |
| frequency_penalty: await this.db.getPreference("llmFrequencyPenalty", unifiedDefaults.frequency_penalty), | |
| presence_penalty: await this.db.getPreference("llmPresencePenalty", unifiedDefaults.presence_penalty) | |
| }; | |
| const payload = { | |
| model: this.currentModel, | |
| messages: messages, | |
| stream: true, | |
| temperature: typeof options.temperature === "number" ? options.temperature : llmSettings.temperature, | |
| max_tokens: typeof options.maxTokens === "number" ? options.maxTokens : llmSettings.maxTokens, | |
| top_p: typeof options.topP === "number" ? options.topP : llmSettings.top_p, | |
| frequency_penalty: | |
| typeof options.frequencyPenalty === "number" ? options.frequencyPenalty : llmSettings.frequency_penalty, | |
| presence_penalty: typeof options.presencePenalty === "number" ? options.presencePenalty : llmSettings.presence_penalty | |
| }; | |
| try { | |
| const response = await fetch(baseUrl, { | |
| method: "POST", | |
| headers: { | |
| Authorization: `Bearer ${apiKey}`, | |
| "Content-Type": "application/json" | |
| }, | |
| body: JSON.stringify(payload) | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`HTTP ${response.status}: ${response.statusText}`); | |
| } | |
| const reader = response.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let buffer = ""; | |
| let fullResponse = ""; | |
| try { | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| buffer += decoder.decode(value, { stream: true }); | |
| const lines = buffer.split("\n"); | |
| buffer = lines.pop() || ""; | |
| for (const line of lines) { | |
| if (line.trim() === "" || line.startsWith(":")) continue; | |
| if (line.startsWith("data: ")) { | |
| const data = line.slice(6); | |
| if (data === "[DONE]") { | |
| break; | |
| } | |
| try { | |
| const parsed = JSON.parse(data); | |
| const content = parsed.choices?.[0]?.delta?.content; | |
| if (content) { | |
| fullResponse += content; | |
| onToken(content); | |
| } | |
| } catch (parseError) { | |
| console.warn("Failed to parse streaming chunk:", parseError); | |
| } | |
| } | |
| } | |
| } | |
| } finally { | |
| reader.releaseLock(); | |
| } | |
| // Add to context | |
| this.conversationContext.push( | |
| { role: "user", content: userMessage, timestamp: new Date().toISOString() }, | |
| { role: "assistant", content: fullResponse, timestamp: new Date().toISOString() } | |
| ); | |
| if (this.conversationContext.length > this.maxContextLength * 2) { | |
| this.conversationContext = this.conversationContext.slice(-this.maxContextLength * 2); | |
| } | |
| // Token usage estimation | |
| try { | |
| const est = window.KimiTokenUtils?.estimate || (t => Math.ceil((t || "").length / 4)); | |
| const tokensIn = est(userMessage + " " + systemPromptContent); | |
| const tokensOut = est(fullResponse); | |
| window._lastKimiTokenUsage = { tokensIn, tokensOut }; | |
| if (!window.kimiMemory && this.db) { | |
| const character = await this.db.getSelectedCharacter(); | |
| const prevIn = Number(await this.db.getPreference(`totalTokensIn_${character}`, 0)) || 0; | |
| const prevOut = Number(await this.db.getPreference(`totalTokensOut_${character}`, 0)) || 0; | |
| await this.db.setPreference(`totalTokensIn_${character}`, prevIn + tokensIn); | |
| await this.db.setPreference(`totalTokensOut_${character}`, prevOut + tokensOut); | |
| } | |
| } catch (e) { | |
| console.warn("Token usage estimation failed (OpenAI streaming):", e); | |
| } | |
| return fullResponse; | |
| } catch (error) { | |
| console.error("OpenAI compatible streaming error:", error); | |
| throw error; | |
| } | |
| } | |
| async chatWithLocalStreaming(userMessage, onToken, options = {}) { | |
| const systemPromptContent = await this.assemblePrompt(userMessage); | |
| const payload = { | |
| model: this.currentModel || "llama2", | |
| messages: [ | |
| { role: "system", content: systemPromptContent }, | |
| ...this.conversationContext.slice(-this.maxContextLength), | |
| { role: "user", content: userMessage } | |
| ], | |
| stream: true | |
| }; | |
| try { | |
| const response = await fetch("http://localhost:11434/api/chat", { | |
| method: "POST", | |
| headers: { | |
| "Content-Type": "application/json" | |
| }, | |
| body: JSON.stringify(payload) | |
| }); | |
| if (!response.ok) { | |
| throw new Error("Ollama not available"); | |
| } | |
| const reader = response.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let fullResponse = ""; | |
| try { | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| const chunk = decoder.decode(value, { stream: true }); | |
| const lines = chunk.split("\n").filter(line => line.trim()); | |
| for (const line of lines) { | |
| try { | |
| const parsed = JSON.parse(line); | |
| const content = parsed.message?.content; | |
| if (content) { | |
| fullResponse += content; | |
| onToken(content); | |
| } | |
| if (parsed.done) { | |
| break; | |
| } | |
| } catch (parseError) { | |
| console.warn("Failed to parse Ollama streaming chunk:", parseError); | |
| } | |
| } | |
| } | |
| } finally { | |
| reader.releaseLock(); | |
| } | |
| // Add to context | |
| this.conversationContext.push( | |
| { role: "user", content: userMessage, timestamp: new Date().toISOString() }, | |
| { role: "assistant", content: fullResponse, timestamp: new Date().toISOString() } | |
| ); | |
| if (this.conversationContext.length > this.maxContextLength * 2) { | |
| this.conversationContext = this.conversationContext.slice(-this.maxContextLength * 2); | |
| } | |
| // Token usage estimation | |
| try { | |
| const est = window.KimiTokenUtils?.estimate || (t => Math.ceil((t || "").length / 4)); | |
| const tokensIn = est(userMessage + " " + systemPromptContent); | |
| const tokensOut = est(fullResponse); | |
| window._lastKimiTokenUsage = { tokensIn, tokensOut }; | |
| const character = await this.db.getSelectedCharacter(); | |
| const prevIn = Number(await this.db.getPreference(`totalTokensIn_${character}`, 0)) || 0; | |
| const prevOut = Number(await this.db.getPreference(`totalTokensOut_${character}`, 0)) || 0; | |
| await this.db.setPreference(`totalTokensIn_${character}`, prevIn + tokensIn); | |
| await this.db.setPreference(`totalTokensOut_${character}`, prevOut + tokensOut); | |
| } catch (e) { | |
| console.warn("Token usage estimation failed (local streaming):", e); | |
| } | |
| return fullResponse; | |
| } catch (error) { | |
| console.warn("Local LLM streaming not available:", error); | |
| throw error; | |
| } | |
| } | |
| getFallbackResponse(userMessage, errorType = "api") { | |
| // Use centralized fallback manager instead of duplicated logic | |
| if (window.KimiFallbackManager) { | |
| // Map error types to the correct format | |
| const errorTypeMap = { | |
| api: "api_error", | |
| model: "model_error", | |
| network: "network_error" | |
| }; | |
| const mappedType = errorTypeMap[errorType] || "technical_error"; | |
| return window.KimiFallbackManager.getFallbackMessage(mappedType); | |
| } | |
| // Fallback to legacy system if KimiFallbackManager not available | |
| const i18n = window.kimiI18nManager; | |
| if (!i18n) { | |
| return "Sorry, I'm having technical difficulties! π"; | |
| } | |
| return i18n.t("fallback_technical_error"); | |
| } | |
| getFallbackKeywords(trait, type) { | |
| const keywords = { | |
| humor: { | |
| positive: ["funny", "hilarious", "joke", "laugh", "amusing", "humorous", "smile", "witty", "playful"], | |
| negative: ["boring", "sad", "serious", "cold", "dry", "depressing", "gloomy"] | |
| }, | |
| intelligence: { | |
| positive: [ | |
| "intelligent", | |
| "smart", | |
| "brilliant", | |
| "logical", | |
| "clever", | |
| "wise", | |
| "genius", | |
| "thoughtful", | |
| "insightful" | |
| ], | |
| negative: ["stupid", "dumb", "foolish", "slow", "naive", "ignorant", "simple"] | |
| }, | |
| romance: { | |
| positive: ["cuddle", "love", "romantic", "kiss", "tenderness", "passion", "charming", "adorable", "sweet"], | |
| negative: ["cold", "distant", "indifferent", "rejection", "loneliness", "breakup", "sad"] | |
| }, | |
| affection: { | |
| positive: ["affection", "tenderness", "close", "warmth", "kind", "caring", "cuddle", "love", "adore"], | |
| negative: ["mean", "cold", "indifferent", "distant", "rejection", "hate", "hostile"] | |
| }, | |
| playfulness: { | |
| positive: ["play", "game", "tease", "mischievous", "fun", "amusing", "playful", "joke", "frolic"], | |
| negative: ["serious", "boring", "strict", "rigid", "monotonous", "tedious"] | |
| }, | |
| empathy: { | |
| positive: ["listen", "understand", "empathy", "support", "help", "comfort", "compassion", "caring", "kindness"], | |
| negative: ["indifferent", "cold", "selfish", "ignore", "despise", "hostile", "uncaring"] | |
| } | |
| }; | |
| return keywords[trait]?.[type] || []; | |
| } | |
| // MΓ©moire temporaire pour l'accumulation nΓ©gative par trait | |
| _negativeStreaks = {}; | |
| async updatePersonalityFromResponse(userMessage, kimiResponse) { | |
| // Use unified emotion system for personality updates | |
| if (window.kimiEmotionSystem) { | |
| return await window.kimiEmotionSystem.updatePersonalityFromConversation( | |
| userMessage, | |
| kimiResponse, | |
| await this.db.getSelectedCharacter() | |
| ); | |
| } | |
| // Legacy fallback (should not be reached) | |
| console.warn("Unified emotion system not available, skipping personality update"); | |
| } | |
| async getModelStats() { | |
| const models = await this.db.getAllLLMModels(); | |
| const currentModelInfo = this.availableModels[this.currentModel]; | |
| return { | |
| current: { | |
| id: this.currentModel, | |
| info: currentModelInfo | |
| }, | |
| available: this.availableModels, | |
| configured: models, | |
| contextLength: this.conversationContext.length | |
| }; | |
| } | |
| async testModel(modelId, testMessage = "Test API ok?") { | |
| // Ancienne mΓ©thode de test (non minimaliste) | |
| return await this.testApiKeyMinimal(modelId); | |
| } | |
| /** | |
| * Test API minimaliste et centralisΓ© pour tous les providers compatibles. | |
| * Envoie uniquement un prompt système court et un message utilisateur dans la langue choisie. | |
| * Aucun contexte, aucune mémoire, aucun paramètre superflu. | |
| * @param {string} modelId - ID du modèle à tester | |
| * @returns {Promise<{success: boolean, response?: string, error?: string}>} | |
| */ | |
| async testApiKeyMinimal(modelId) { | |
| const originalModel = this.currentModel; | |
| try { | |
| await this.setCurrentModel(modelId); | |
| const provider = await this.db.getPreference("llmProvider", "openrouter"); | |
| const lang = await this.db.getPreference("selectedLanguage", "en"); | |
| let testWord; | |
| switch (lang) { | |
| case "fr": | |
| testWord = "Bonjour"; | |
| break; | |
| case "es": | |
| testWord = "Hola"; | |
| break; | |
| case "de": | |
| testWord = "Hallo"; | |
| break; | |
| case "it": | |
| testWord = "Ciao"; | |
| break; | |
| case "ja": | |
| testWord = "γγγ«γ‘γ―"; | |
| break; | |
| case "zh": | |
| testWord = "δ½ ε₯½"; | |
| break; | |
| default: | |
| testWord = "Hello"; | |
| } | |
| const systemPrompt = "You are a helpful assistant."; | |
| let apiKey = await this.db.getPreference("providerApiKey"); | |
| let baseUrl = ""; | |
| let payload = { | |
| model: modelId, | |
| messages: [ | |
| { role: "system", content: systemPrompt }, | |
| { role: "user", content: testWord } | |
| ], | |
| max_tokens: 2 | |
| }; | |
| let headers = { "Content-Type": "application/json" }; | |
| if (provider === "openrouter") { | |
| baseUrl = "https://openrouter.ai/api/v1/chat/completions"; | |
| headers["Authorization"] = `Bearer ${apiKey}`; | |
| headers["HTTP-Referer"] = window.location.origin; | |
| headers["X-Title"] = "Kimi - Virtual Companion"; | |
| } else if (["openai", "groq", "together", "deepseek", "openai-compatible"].includes(provider)) { | |
| baseUrl = await this.db.getPreference("llmBaseUrl", "https://api.openai.com/v1/chat/completions"); | |
| headers["Authorization"] = `Bearer ${apiKey}`; | |
| } else if (provider === "ollama") { | |
| baseUrl = "http://localhost:11434/api/chat"; | |
| payload = { | |
| model: modelId, | |
| messages: [ | |
| { role: "system", content: systemPrompt }, | |
| { role: "user", content: testWord } | |
| ], | |
| stream: false | |
| }; | |
| } else { | |
| throw new Error("Unknown provider: " + provider); | |
| } | |
| const response = await fetch(baseUrl, { | |
| method: "POST", | |
| headers, | |
| body: JSON.stringify(payload) | |
| }); | |
| if (!response.ok) { | |
| const error = await response.text(); | |
| return { success: false, error }; | |
| } | |
| const data = await response.json(); | |
| let content = ""; | |
| if (provider === "ollama") { | |
| content = data?.message?.content || data?.choices?.[0]?.message?.content || ""; | |
| } else { | |
| content = data?.choices?.[0]?.message?.content || ""; | |
| } | |
| return { success: true, response: content }; | |
| } catch (error) { | |
| return { success: false, error: error.message }; | |
| } finally { | |
| await this.setCurrentModel(originalModel); | |
| } | |
| } | |
| // Complete model diagnosis | |
| async diagnoseModel(modelId) { | |
| const model = this.availableModels[modelId]; | |
| if (!model) { | |
| return { | |
| available: false, | |
| error: "Model not found in local list" | |
| }; | |
| } | |
| // Check availability on OpenRouter | |
| try { | |
| // getAvailableModelsFromAPI removed | |
| return { | |
| available: true, | |
| model: model, | |
| pricing: model.pricing | |
| }; | |
| } catch (error) { | |
| return { | |
| available: false, | |
| error: `Unable to check: ${error.message}` | |
| }; | |
| } | |
| } | |
| // Fetch models from OpenRouter API and merge into availableModels | |
| async refreshRemoteModels() { | |
| if (this._isRefreshingModels) return; | |
| this._isRefreshingModels = true; | |
| try { | |
| const apiKey = await this.db.getPreference("providerApiKey", ""); | |
| const res = await fetch("https://openrouter.ai/api/v1/models", { | |
| method: "GET", | |
| headers: { | |
| "Content-Type": "application/json", | |
| ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), | |
| "HTTP-Referer": window.location.origin, | |
| "X-Title": "Kimi - Virtual Companion" | |
| } | |
| }); | |
| if (!res.ok) { | |
| throw new Error(`Unable to fetch models: HTTP ${res.status}`); | |
| } | |
| const data = await res.json(); | |
| if (!data?.data || !Array.isArray(data.data)) { | |
| throw new Error("Invalid models response format"); | |
| } | |
| // Build a fresh map while preserving local/ollama entry | |
| const newMap = {}; | |
| data.data.forEach(m => { | |
| if (!m?.id) return; | |
| const id = m.id; | |
| const provider = m?.id?.split("/")?.[0] || "OpenRouter"; | |
| let pricing; | |
| const p = m?.pricing; | |
| if (p) { | |
| const unitRaw = ((p.unit || p.per || p.units || "") + "").toLowerCase(); | |
| let unitTokens = 1; | |
| if (unitRaw) { | |
| if (unitRaw.includes("1m")) unitTokens = 1000000; | |
| else if (unitRaw.includes("1k") || unitRaw.includes("thousand")) unitTokens = 1000; | |
| else { | |
| const num = parseFloat(unitRaw.replace(/[^0-9.]/g, "")); | |
| if (Number.isFinite(num) && num > 0) { | |
| if (unitRaw.includes("m")) unitTokens = num * 1000000; | |
| else if (unitRaw.includes("k")) unitTokens = num * 1000; | |
| else unitTokens = num; | |
| } else if (unitRaw.includes("token")) { | |
| unitTokens = 1; | |
| } | |
| } | |
| } | |
| const toPerMillion = v => { | |
| const n = typeof v === "number" ? v : parseFloat(v); | |
| if (!Number.isFinite(n)) return undefined; | |
| return n * (1000000 / unitTokens); | |
| }; | |
| if (typeof p.input !== "undefined" || typeof p.output !== "undefined") { | |
| pricing = { | |
| input: toPerMillion(p.input), | |
| output: toPerMillion(p.output) | |
| }; | |
| } else if (typeof p.prompt !== "undefined" || typeof p.completion !== "undefined") { | |
| pricing = { | |
| input: toPerMillion(p.prompt), | |
| output: toPerMillion(p.completion) | |
| }; | |
| } else { | |
| pricing = { input: undefined, output: undefined }; | |
| } | |
| } else { | |
| pricing = { input: undefined, output: undefined }; | |
| } | |
| newMap[id] = { | |
| name: m.name || id, | |
| provider, | |
| type: "openrouter", | |
| contextWindow: m.context_length || m?.context_window || 128000, | |
| pricing, | |
| strengths: (m?.tags || []).slice(0, 4) | |
| }; | |
| }); | |
| // Keep local model entry | |
| if (this.availableModels["local/ollama"]) { | |
| newMap["local/ollama"] = this.availableModels["local/ollama"]; | |
| } | |
| this.recommendedModelIds.forEach(id => { | |
| const curated = this.defaultModels[id]; | |
| if (curated) { | |
| newMap[id] = { ...(newMap[id] || {}), ...curated }; | |
| } | |
| }); | |
| this.availableModels = newMap; | |
| this._remoteModelsLoaded = true; | |
| } finally { | |
| this._isRefreshingModels = false; | |
| } | |
| } | |
| // Try to find best matching model id from remote list when an ID is stale | |
| findBestMatchingModelId(preferredId) { | |
| if (this.availableModels[preferredId]) return preferredId; | |
| const id = (preferredId || "").toLowerCase(); | |
| const tokens = id.split(/[\/:\-_.]+/).filter(Boolean); | |
| let best = null; | |
| let bestScore = -1; | |
| Object.keys(this.availableModels).forEach(candidateId => { | |
| const c = candidateId.toLowerCase(); | |
| let score = 0; | |
| tokens.forEach(t => { | |
| if (!t) return; | |
| if (c.includes(t)) score += 1; | |
| }); | |
| // Give extra weight to common markers | |
| if (c.includes("instruct")) score += 0.5; | |
| if (c.includes("mistral") && id.includes("mistral")) score += 0.5; | |
| if (c.includes("small") && id.includes("small")) score += 0.5; | |
| if (score > bestScore) { | |
| bestScore = score; | |
| best = candidateId; | |
| } | |
| }); | |
| // Avoid returning unrelated local model unless nothing else | |
| if (best === "local/ollama" && Object.keys(this.availableModels).length > 1) { | |
| return null; | |
| } | |
| return best; | |
| } | |
| _notifyModelChanged() { | |
| try { | |
| const detail = { id: this.currentModel }; | |
| if (typeof window !== "undefined" && typeof window.dispatchEvent === "function") { | |
| window.dispatchEvent(new CustomEvent("llmModelChanged", { detail })); | |
| } | |
| } catch (e) {} | |
| } | |
| } | |
| // Export for usage | |
| window.KimiLLMManager = KimiLLMManager; | |
| export default KimiLLMManager; | |