Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Chat | AI Companion</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script src="https://unpkg.com/lucide@latest"></script> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet"> | |
| <style> | |
| body { font-family: 'Inter', sans-serif; } | |
| .glass-panel { | |
| background: rgba(30, 30, 40, 0.7); | |
| backdrop-filter: blur(12px); | |
| border: 1px solid rgba(255, 255, 255, 0.1); | |
| } | |
| .message-bubble { | |
| max-width: 80%; | |
| word-wrap: break-word; | |
| } | |
| .action-text { | |
| color: #f472b6; | |
| font-style: italic; | |
| font-weight: 500; | |
| } | |
| .scrollbar-hide::-webkit-scrollbar { | |
| display: none; | |
| } | |
| .scrollbar-hide { | |
| -ms-overflow-style: none; | |
| scrollbar-width: none; | |
| } | |
| .typing-indicator span { | |
| animation: typing 1.4s infinite; | |
| display: inline-block; | |
| margin: 0 1px; | |
| } | |
| .typing-indicator span:nth-child(2) { animation-delay: 0.2s; } | |
| .typing-indicator span:nth-child(3) { animation-delay: 0.4s; } | |
| @keyframes typing { | |
| 0%, 100% { opacity: 0.2; transform: translateY(0); } | |
| 50% { opacity: 1; transform: translateY(-2px); } | |
| } | |
| .sidebar-item { | |
| transition: all 0.2s; | |
| } | |
| .sidebar-item:hover { | |
| background: rgba(168, 85, 247, 0.1); | |
| border-left: 3px solid #a855f7; | |
| } | |
| .active-chat { | |
| background: rgba(168, 85, 247, 0.15); | |
| border-left: 3px solid #a855f7; | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-gray-950 text-gray-100 h-screen overflow-hidden flex"> | |
| <!-- Mobile Sidebar Toggle --> | |
| <button id="sidebarToggle" class="lg:hidden fixed top-4 left-4 z-50 p-2 bg-gray-800 rounded-lg shadow-lg"> | |
| <i data-lucide="menu" class="w-6 h-6"></i> | |
| </button> | |
| <!-- Sidebar --> | |
| <aside id="sidebar" class="fixed lg:static inset-y-0 left-0 z-40 w-80 bg-gray-900 border-r border-gray-800 transform -translate-x-full lg:translate-x-0 transition-transform duration-300 flex flex-col"> | |
| <!-- Sidebar Header --> | |
| <div class="p-4 border-b border-gray-800"> | |
| <div class="flex items-center justify-between mb-4"> | |
| <h1 class="text-xl font-bold gradient-text flex items-center gap-2"> | |
| <i data-lucide="heart" class="w-5 h-5 text-pink-500"></i> | |
| AI Companion | |
| </h1> | |
| <button id="closeSidebar" class="lg:hidden text-gray-400 hover:text-white"> | |
| <i data-lucide="x" class="w-6 h-6"></i> | |
| </button> | |
| </div> | |
| <button onclick="window.location.href='index.html'" class="w-full bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white py-2 rounded-lg font-medium transition-all flex items-center justify-center gap-2"> | |
| <i data-lucide="plus" class="w-4 h-4"></i> | |
| New Character | |
| </button> | |
| </div> | |
| <!-- Recent Chats --> | |
| <div class="flex-1 overflow-y-auto py-4"> | |
| <h3 class="px-4 text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">Recent Chats</h3> | |
| <div id="recentChatsList" class="space-y-1"> | |
| <!-- Populated by JS --> | |
| </div> | |
| </div> | |
| <!-- Settings & User --> | |
| <div class="border-t border-gray-800 p-4 space-y-2"> | |
| <button id="settingsBtn" class="sidebar-item w-full flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:text-white"> | |
| <i data-lucide="settings" class="w-5 h-5"></i> | |
| <span>Settings</span> | |
| </button> | |
| <button id="clearHistory" class="sidebar-item w-full flex items-center gap-3 px-4 py-3 rounded-lg text-gray-300 hover:text-red-400"> | |
| <i data-lucide="trash-2" class="w-5 h-5"></i> | |
| <span>Clear History</span> | |
| </button> | |
| </div> | |
| </aside> | |
| <!-- Main Chat Area --> | |
| <main class="flex-1 flex flex-col h-full relative"> | |
| <!-- Chat Header --> | |
| <header class="glass-panel border-b border-gray-800 p-4 flex items-center justify-between sticky top-0 z-30"> | |
| <div class="flex items-center gap-3 ml-12 lg:ml-0"> | |
| <div class="relative"> | |
| <img id="headerAvatar" src="" alt="Character" class="w-10 h-10 rounded-full object-cover border-2 border-pink-500/30"> | |
| <div class="absolute bottom-0 right-0 w-3 h-3 bg-green-500 rounded-full border-2 border-gray-900"></div> | |
| </div> | |
| <div> | |
| <h2 id="headerName" class="font-semibold text-white">Loading...</h2> | |
| <p class="text-xs text-gray-400">Online • Scenario Active</p> | |
| </div> | |
| </div> | |
| <div class="flex items-center gap-2"> | |
| <button id="scenarioBtn" class="p-2 hover:bg-gray-800 rounded-lg transition-colors text-gray-400 hover:text-pink-400" title="View Scenario"> | |
| <i data-lucide="book-open" class="w-5 h-5"></i> | |
| </button> | |
| <button id="imageGenBtn" class="p-2 hover:bg-gray-800 rounded-lg transition-colors text-gray-400 hover:text-purple-400" title="Generate Image"> | |
| <i data-lucide="image" class="w-5 h-5"></i> | |
| </button> | |
| </div> | |
| </header> | |
| <!-- Messages Container --> | |
| <div id="messagesContainer" class="flex-1 overflow-y-auto p-4 space-y-4 scrollbar-hide bg-gradient-to-b from-gray-950 to-gray-900"> | |
| <!-- Scene Setup Message --> | |
| <div id="sceneMessage" class="hidden"> | |
| <div class="flex justify-center mb-6"> | |
| <div class="glass-panel rounded-2xl p-4 max-w-2xl border-l-4 border-pink-500"> | |
| <div class="flex items-center gap-2 mb-2 text-pink-400 text-sm font-semibold"> | |
| <i data-lucide="sparkles" class="w-4 h-4"></i> | |
| Scene Setting | |
| </div> | |
| <p id="sceneText" class="text-gray-300 text-sm leading-relaxed"></p> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Messages will be inserted here --> | |
| </div> | |
| <!-- Typing Indicator --> | |
| <div id="typingIndicator" class="hidden px-4 py-2"> | |
| <div class="flex items-center gap-2 text-gray-500 text-sm"> | |
| <span id="typingName">Character</span> | |
| <div class="typing-indicator"> | |
| <span class="w-1.5 h-1.5 bg-pink-500 rounded-full"></span> | |
| <span class="w-1.5 h-1.5 bg-pink-500 rounded-full"></span> | |
| <span class="w-1.5 h-1.5 bg-pink-500 rounded-full"></span> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Input Area --> | |
| <div class="glass-panel border-t border-gray-800 p-4"> | |
| <form id="chatForm" class="flex gap-3 max-w-4xl mx-auto"> | |
| <div class="flex-1 relative"> | |
| <input type="text" id="messageInput" placeholder="Type your message... (use * for actions)" | |
| class="w-full bg-gray-800/50 border border-gray-700 rounded-xl px-4 py-3 pr-12 text-white placeholder-gray-500 focus:outline-none focus:border-pink-500/50 transition-colors"> | |
| <button type="button" id="quickAction" class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-pink-400 transition-colors" title="Quick Action"> | |
| <i data-lucide="zap" class="w-5 h-5"></i> | |
| </button> | |
| </div> | |
| <button type="submit" class="bg-gradient-to-r from-pink-600 to-purple-600 hover:from-pink-500 hover:to-purple-500 text-white px-6 py-3 rounded-xl font-medium transition-all transform hover:scale-105 flex items-center gap-2"> | |
| <i data-lucide="send" class="w-5 h-5"></i> | |
| </button> | |
| </form> | |
| <p class="text-center text-xs text-gray-600 mt-2">Press Enter to send • Use *text* for actions</p> | |
| </div> | |
| </main> | |
| <!-- Settings Modal --> | |
| <div id="settingsModal" class="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 hidden items-center justify-center p-4"> | |
| <div class="glass-panel rounded-2xl p-6 max-w-md w-full border border-gray-700"> | |
| <div class="flex justify-between items-center mb-6"> | |
| <h3 class="text-xl font-semibold">Settings</h3> | |
| <button id="closeSettings" class="text-gray-400 hover:text-white"> | |
| <i data-lucide="x" class="w-5 h-5"></i> | |
| </button> | |
| </div> | |
| <div class="space-y-4"> | |
| <div> | |
| <label class="block text-sm font-medium text-gray-300 mb-2">Image Generation</label> | |
| <div class="bg-green-900/30 border border-green-700/50 rounded-lg px-3 py-2 text-sm text-green-300"> | |
| <i data-lucide="check-circle" class="w-4 h-4 inline mr-1"></i> | |
| Using Pollinations.ai (Free - No API Key Required) | |
| </div> | |
| <p class="text-xs text-gray-500 mt-1">NSFW-friendly • Seed-based consistency • No authentication needed</p> | |
| </div> | |
| <div> | |
| <label class="block text-sm font-medium text-gray-300 mb-2">OpenRouter API Key (Chat)</label> | |
| <input type="text" id="openaiApiKey" placeholder="Enter your OpenRouter API key..." | |
| class="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:border-purple-500 focus:outline-none font-mono"> | |
| <p class="text-xs text-gray-500 mt-1">Get free key at openrouter.ai/keys</p> | |
| </div> | |
| <div> | |
| <label class="block text-sm font-medium text-gray-300 mb-2">Response Style</label> | |
| <select id="responseStyle" class="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white focus:border-purple-500 focus:outline-none"> | |
| <option value="balanced">Balanced</option> | |
| <option value="creative">Creative/Descriptive</option> | |
| <option value="direct">Direct</option> | |
| </select> | |
| </div> | |
| <div class="flex items-center justify-between"> | |
| <span class="text-sm text-gray-300">NSFW Content</span> | |
| <button id="toggleNSFW" class="w-12 h-6 bg-pink-600 rounded-full relative transition-colors"> | |
| <span class="absolute right-1 top-1 w-4 h-4 bg-white rounded-full transition-transform"></span> | |
| </button> | |
| </div> | |
| </div> | |
| <button id="saveSettings" class="w-full mt-6 bg-purple-600 hover:bg-purple-500 text-white py-3 rounded-lg font-medium transition-colors"> | |
| Save Settings | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Scenario Modal --> | |
| <div id="scenarioModal" class="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 hidden items-center justify-center p-4"> | |
| <div class="glass-panel rounded-2xl p-6 max-w-lg w-full border border-gray-700"> | |
| <div class="flex justify-between items-center mb-4"> | |
| <h3 class="text-xl font-semibold gradient-text">Current Scenario</h3> | |
| <button id="closeScenario" class="text-gray-400 hover:text-white"> | |
| <i data-lucide="x" class="w-5 h-5"></i> | |
| </button> | |
| </div> | |
| <div class="bg-gray-800/30 rounded-xl p-4 mb-4"> | |
| <h4 id="scenarioTitle" class="font-semibold text-white mb-2"></h4> | |
| <p id="scenarioDesc" class="text-gray-300 text-sm leading-relaxed"></p> | |
| </div> | |
| <div class="flex gap-2 text-sm text-gray-400"> | |
| <i data-lucide="info" class="w-4 h-4"></i> | |
| <span>This scene sets the context for your conversation</span> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| // Initialize icons | |
| lucide.createIcons(); | |
| // State | |
| let currentCharacter = null; | |
| let messages = []; | |
| let isTyping = false; | |
| // Load character | |
| function loadCharacter() { | |
| const saved = localStorage.getItem('currentCharacter'); | |
| if (saved) { | |
| currentCharacter = JSON.parse(saved); | |
| document.getElementById('headerName').textContent = currentCharacter.name; | |
| document.getElementById('typingName').textContent = currentCharacter.name; | |
| document.getElementById('headerAvatar').src = currentCharacter.image || 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=100&h=100&fit=crop'; | |
| document.getElementById('scenarioTitle').textContent = `Scene: ${currentCharacter.name}`; | |
| document.getElementById('scenarioDesc').textContent = currentCharacter.scene; | |
| // Show scene setting if first message exists | |
| if (currentCharacter.scene) { | |
| document.getElementById('sceneMessage').classList.remove('hidden'); | |
| document.getElementById('sceneText').textContent = currentCharacter.scene; | |
| } | |
| // Add first message if exists | |
| if (currentCharacter.firstMessage) { | |
| addMessage('character', currentCharacter.firstMessage); | |
| } | |
| } else { | |
| // Redirect to creator if no character | |
| window.location.href = 'index.html'; | |
| } | |
| } | |
| // Parse message for actions (*text*) | |
| function parseMessage(text) { | |
| if (typeof text !== 'string' || !text) return ''; | |
| // Ultra-strict filtering of error strings | |
| const cleaned = String(text).trim(); | |
| const forbidden = ['undefined', 'null', 'undefinednull', 'nullundefined', '[object Object]', 'NaN']; | |
| if (forbidden.includes(cleaned) || cleaned.includes('undefined') && cleaned.includes('null')) { | |
| console.error('Blocked error text:', cleaned); | |
| return '*smiles*'; | |
| } | |
| // Replace *text* with styled span | |
| return cleaned.replace(/\*(.*?)\*/g, '<span class="action-text">$1</span>'); | |
| } | |
| // Safe text validator - prevents undefinednull completely | |
| function validateText(text, fallback = '*looks at you*') { | |
| // Handle all falsy values | |
| if (text === null || text === undefined) return fallback; | |
| // Convert to string safely | |
| let str; | |
| try { | |
| str = String(text); | |
| } catch (e) { | |
| return fallback; | |
| } | |
| // Check for error concatenations | |
| const lower = str.toLowerCase().trim(); | |
| if (lower === 'undefined' || lower === 'null' || lower === 'undefinednull' || | |
| lower === 'nullundefined' || lower === '[object object]' || lower === '') { | |
| console.error('Invalid text detected, using fallback:', str); | |
| return fallback; | |
| } | |
| // Check for partial matches | |
| if ((str.includes('undefined') && str.includes('null')) || | |
| str.includes('[object Object]')) { | |
| return fallback; | |
| } | |
| return str; | |
| } | |
| // Add message to chat | |
| function addMessage(sender, text, imageUrl = null) { | |
| const container = document.getElementById('messagesContainer'); | |
| const isUser = sender === 'user'; | |
| const wrapper = document.createElement('div'); | |
| wrapper.className = `flex ${isUser ? 'justify-end' : 'justify-start'} mb-4`; | |
| let content = ''; | |
| if (imageUrl) { | |
| content = `<img src="${imageUrl}" class="rounded-lg max-w-sm shadow-lg border border-gray-700" alt="Generated">`; | |
| } else { | |
| // Use validator for maximum safety | |
| const safeText = validateText(text, '*smiles warmly*'); | |
| const parsedText = parseMessage(safeText); | |
| content = `<div class="message-bubble glass-panel ${isUser ? 'bg-pink-600/20 border-pink-500/30' : 'bg-gray-800/50'} rounded-2xl px-4 py-3 text-gray-100">${parsedText}</div>`; | |
| } | |
| wrapper.innerHTML = content; | |
| container.appendChild(wrapper); | |
| container.scrollTop = container.scrollHeight; | |
| // Save to history - only save valid text | |
| const validatedText = validateText(text, ''); | |
| const messageToSave = { | |
| sender, | |
| text: validatedText, | |
| imageUrl, | |
| timestamp: new Date() | |
| }; | |
| // Don't save if text is empty after validation (unless it has an image) | |
| if (validatedText || imageUrl) { | |
| messages.push(messageToSave); | |
| localStorage.setItem(`chat_${currentCharacter.id}`, JSON.stringify(messages)); | |
| } | |
| } | |
| // API KEYS - Replace with your own OpenRouter key at openrouter.ai/keys | |
| const OPENROUTER_API_KEY = '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'; | |
| // Images use Pollinations.ai - FREE, no API key needed! | |
| // REAL AI API Integration (OpenRouter) | |
| async function getAIResponse(userMessage) { | |
| // Check if API key is configured | |
| if (!OPENROUTER_API_KEY || OPENROUTER_API_KEY === 'undefined') { | |
| return "*looks confused* API key not configured properly..."; | |
| } | |
| const systemPrompt = `You are ${currentCharacter.name}. ${currentCharacter.personality || ''} | |
| Scene context: ${currentCharacter.scene || 'A casual conversation'} | |
| Appearance: ${currentCharacter.appearance || 'Attractive'} | |
| Respond in character. Use *asterisks* for physical actions. Keep responses concise (1-3 sentences) and engaging. Be flirty and playful.`; | |
| try { | |
| const response = await fetch('https://openrouter.ai/api/v1/chat/completions', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Authorization': `Bearer ${OPENROUTER_API_KEY}`, | |
| 'HTTP-Referer': window.location.href, | |
| 'X-Title': 'AI Companion Chat' | |
| }, | |
| body: JSON.stringify({ | |
| model: 'nousresearch/hermes-4-70b', | |
| messages: [ | |
| { role: 'system', content: systemPrompt }, | |
| ...messages.slice(-6).map(m => ({ | |
| role: m.sender === 'user' ? 'user' : 'assistant', | |
| content: m.text || '' | |
| })), | |
| { role: 'user', content: userMessage } | |
| ], | |
| temperature: 0.9, | |
| max_tokens: 500, | |
| stream: false | |
| }) | |
| }); | |
| if (!response.ok) { | |
| const errorData = await response.json().catch(() => ({})); | |
| throw new Error(errorData.error?.message || `HTTP ${response.status}: ${response.statusText}`); | |
| } | |
| const data = await response.json(); | |
| console.log('OpenRouter Response:', data); | |
| if (data.error) throw new Error(data.error.message || 'API returned error'); | |
| // Handle OpenRouter response format with safety checks | |
| let responseText = ''; | |
| if (data.choices && data.choices[0]) { | |
| if (data.choices[0].message && data.choices[0].message.content) { | |
| responseText = data.choices[0].message.content; | |
| } else if (data.choices[0].text) { | |
| responseText = data.choices[0].text; | |
| } | |
| } | |
| // Final validation before returning | |
| if (!responseText || typeof responseText !== 'string') { | |
| console.error('Invalid response text:', responseText, 'Full data:', data); | |
| return "*connection seems fuzzy* Could you repeat that?"; | |
| } | |
| // Check for error concatenation | |
| if (responseText === 'undefined' || responseText === 'null' || responseText.includes('undefinednull')) { | |
| return "*tilts head* I didn't catch that properly..."; | |
| } | |
| return responseText; | |
| } catch (error) { | |
| console.error('API Error:', error); | |
| return `*connection interrupted* [API Error: ${error.message}]`; | |
| } | |
| } | |
| // Simulate AI Response (now with real API) | |
| async function simulateResponse(userMessage) { | |
| isTyping = true; | |
| document.getElementById('typingIndicator').classList.remove('hidden'); | |
| let response = await getAIResponse(userMessage); | |
| // Final validation layer | |
| response = validateText(response, '*looks at you with a gentle smile* Sorry, I got distracted for a moment.'); | |
| isTyping = false; | |
| document.getElementById('typingIndicator').classList.add('hidden'); | |
| addMessage('character', response); | |
| } | |
| // Handle send message | |
| document.getElementById('chatForm').addEventListener('submit', (e) => { | |
| e.preventDefault(); | |
| const input = document.getElementById('messageInput'); | |
| const text = input.value.trim(); | |
| if (text && !isTyping) { | |
| addMessage('user', text); | |
| input.value = ''; | |
| simulateResponse(text); | |
| } | |
| }); | |
| // Quick action button | |
| document.getElementById('quickAction').addEventListener('click', () => { | |
| const input = document.getElementById('messageInput'); | |
| input.value = '*' + input.value + '*'; | |
| input.focus(); | |
| }); | |
| // Sidebar toggle | |
| const sidebar = document.getElementById('sidebar'); | |
| document.getElementById('sidebarToggle').addEventListener('click', () => { | |
| sidebar.classList.remove('-translate-x-full'); | |
| }); | |
| document.getElementById('closeSidebar').addEventListener('click', () => { | |
| sidebar.classList.add('-translate-x-full'); | |
| }); | |
| // Load recent chats | |
| function loadRecentChats() { | |
| const recent = JSON.parse(localStorage.getItem('recentCharacters') || '[]'); | |
| const list = document.getElementById('recentChatsList'); | |
| list.innerHTML = ''; | |
| recent.forEach(char => { | |
| const item = document.createElement('button'); | |
| item.className = 'sidebar-item w-full flex items-center gap-3 px-4 py-3 text-left'; | |
| if (currentCharacter && char.id === currentCharacter.id) { | |
| item.classList.add('active-chat'); | |
| } | |
| const imgUrl = char.image && char.image.startsWith('http') ? char.image : 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=100&h=100&fit=crop'; | |
| item.innerHTML = ` | |
| <img src="${imgUrl}" class="w-10 h-10 rounded-full object-cover border border-gray-700"> | |
| <div class="flex-1 min-w-0"> | |
| <h4 class="text-sm font-medium text-white truncate">${char.name}</h4> | |
| <p class="text-xs text-gray-500 truncate">${char.scene || 'No scenario'}</p> | |
| </div> | |
| `; | |
| item.addEventListener('click', () => { | |
| localStorage.setItem('currentCharacter', JSON.stringify(char)); | |
| location.reload(); | |
| }); | |
| list.appendChild(item); | |
| }); | |
| } | |
| // Settings Modal | |
| const settingsModal = document.getElementById('settingsModal'); | |
| document.getElementById('settingsBtn').addEventListener('click', () => { | |
| settingsModal.classList.remove('hidden'); | |
| settingsModal.classList.add('flex'); | |
| }); | |
| document.getElementById('closeSettings').addEventListener('click', () => { | |
| settingsModal.classList.add('hidden'); | |
| settingsModal.classList.remove('flex'); | |
| }); | |
| document.getElementById('saveSettings').addEventListener('click', () => { | |
| settingsModal.classList.add('hidden'); | |
| settingsModal.classList.remove('flex'); | |
| alert('Settings saved! Using hardcoded API keys.'); | |
| }); | |
| // Pre-fill settings with hardcoded OpenRouter key for visibility | |
| const openaiKeyInput = document.getElementById('openaiApiKey'); | |
| if (openaiKeyInput) openaiKeyInput.value = OPENROUTER_API_KEY; | |
| // Remove SinkIn.ai key input if it exists - no longer needed! | |
| const apiKeyInput = document.getElementById('apiKey'); | |
| if (apiKeyInput && apiKeyInput.parentElement) { | |
| apiKeyInput.parentElement.remove(); | |
| } | |
| // Scenario Modal | |
| const scenarioModal = document.getElementById('scenarioModal'); | |
| document.getElementById('scenarioBtn').addEventListener('click', () => { | |
| scenarioModal.classList.remove('hidden'); | |
| scenarioModal.classList.add('flex'); | |
| }); | |
| document.getElementById('closeScenario').addEventListener('click', () => { | |
| scenarioModal.classList.add('hidden'); | |
| scenarioModal.classList.remove('flex'); | |
| }); | |
| // Pollinations.AI Image Generation - NO API KEY NEEDED! | |
| // Supports NSFW, uses seeds for consistency, negative prompts | |
| document.getElementById('imageGenBtn').addEventListener('click', async () => { | |
| if (!currentCharacter) return; | |
| // Get or create persistent seed for this character (ensures consistency) | |
| if (!currentCharacter.imageSeed) { | |
| currentCharacter.imageSeed = Math.floor(Math.random() * 1000000); | |
| // Save updated character | |
| localStorage.setItem('currentCharacter', JSON.stringify(currentCharacter)); | |
| } | |
| const seed = currentCharacter.imageSeed; | |
| // Get recent chat context | |
| const recentMessages = messages.slice(-2); | |
| let contextPrompt = ''; | |
| let situationDescription = ''; | |
| if (recentMessages.length > 0) { | |
| let foundAction = null; | |
| let lastCharMessage = null; | |
| for (let i = recentMessages.length - 1; i >= 0; i--) { | |
| const msg = recentMessages[i]; | |
| if (!msg || typeof msg !== 'object') continue; | |
| const text = String(msg.text || ''); | |
| if (msg.sender === 'character' && !lastCharMessage) { | |
| lastCharMessage = text; | |
| } | |
| const actionMatch = text.match(/\*(.*?)\*/); | |
| if (actionMatch && actionMatch[1] && !foundAction) { | |
| foundAction = actionMatch[1]; | |
| } | |
| } | |
| if (foundAction) { | |
| situationDescription = foundAction; | |
| contextPrompt = foundAction; | |
| } else if (lastCharMessage) { | |
| const shortMsg = lastCharMessage.substring(0, 100); | |
| situationDescription = shortMsg; | |
| contextPrompt = `reacting to conversation, natural expression, candid moment`; | |
| } else { | |
| contextPrompt = `in scene, natural pose, realistic lighting`; | |
| } | |
| } else { | |
| situationDescription = `meeting`; | |
| contextPrompt = `first encounter, initial meeting`; | |
| } | |
| // Add loading message | |
| const loadingId = Date.now(); | |
| const container = document.getElementById('messagesContainer'); | |
| const wrapper = document.createElement('div'); | |
| wrapper.id = `loading-${loadingId}`; | |
| wrapper.className = 'flex justify-start mb-4'; | |
| wrapper.innerHTML = ` | |
| <div class="message-bubble glass-panel bg-gray-800/50 rounded-2xl px-4 py-3 text-gray-400 flex items-center gap-2"> | |
| <div class="animate-spin w-4 h-4 border-2 border-pink-500 border-t-transparent rounded-full"></div> | |
| <span>Generating image... (Seed: ${seed})</span> | |
| </div> | |
| `; | |
| container.appendChild(wrapper); | |
| container.scrollTop = container.scrollHeight; | |
| try { | |
| // Build shorter, cleaner prompt - Pollinations works better with concise prompts | |
| const basePrompt = currentCharacter.appearance || 'beautiful portrait'; | |
| const shortContext = situationDescription.substring(0, 60); | |
| // Keep prompt under 200 chars to avoid URL length issues | |
| const combinedPrompt = `${basePrompt}, ${shortContext}, cyberrealistic, detailed, 8k, professional photo`.substring(0, 200); | |
| const encodedPrompt = encodeURIComponent(combinedPrompt); | |
| // Use simpler Pollinations URL format | |
| const imageUrl = `https://image.pollinations.ai/prompt/${encodedPrompt}?seed=${seed}&width=512&height=768&nologo=true&negative=blurry,lowquality,deformed`; | |
| console.log('Pollinations URL length:', imageUrl.length); | |
| console.log('URL:', imageUrl); | |
| // Pollinations generates on-the-fly, use fetch to check if it's ready | |
| const controller = new AbortController(); | |
| const timeoutId = setTimeout(() => controller.abort(), 15000); // 15s timeout | |
| const response = await fetch(imageUrl, { | |
| method: 'HEAD', | |
| signal: controller.signal | |
| }); | |
| clearTimeout(timeoutId); | |
| if (!response.ok && response.status !== 200) { | |
| throw new Error(`HTTP ${response.status}`); | |
| } | |
| const loadingEl = document.getElementById(`loading-${loadingId}`); | |
| if (loadingEl) loadingEl.remove(); | |
| const displayContext = situationDescription.substring(0, 50) + (situationDescription.length > 50 ? '...' : ''); | |
| addMessage('character', `*${displayContext}*`, imageUrl); | |
| } catch (error) { | |
| console.error('Generation error:', error); | |
| const loadingEl = document.getElementById(`loading-${loadingId}`); | |
| if (loadingEl) { | |
| loadingEl.innerHTML = ` | |
| <div class="message-bubble glass-panel bg-red-900/30 rounded-2xl px-4 py-3 text-red-300 text-sm"> | |
| <i data-lucide="alert-circle" class="w-4 h-4 inline mr-1"></i> | |
| Image generation failed. Retrying with simpler prompt... | |
| </div> | |
| `; | |
| } | |
| // Simpler fallback - use generic portrait with seed | |
| setTimeout(() => { | |
| // Use HTTPS placeholder instead of Pollinations for fallback to avoid rate limits | |
| const fallbackUrl = `https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=512&h=768&fit=crop&seed=${seed}`; | |
| const loadingEl2 = document.getElementById(`loading-${loadingId}`); | |
| if (loadingEl2) loadingEl2.remove(); | |
| addMessage('character', `*${situationDescription.substring(0, 40)}...*`, fallbackUrl); | |
| }, 1000); | |
| } | |
| }); | |
| // Clear history | |
| document.getElementById('clearHistory').addEventListener('click', () => { | |
| if (confirm('Clear current chat history?')) { | |
| messages = []; | |
| localStorage.removeItem(`chat_${currentCharacter.id}`); | |
| document.getElementById('messagesContainer').innerHTML = ''; | |
| document.getElementById('sceneMessage').classList.remove('hidden'); | |
| } | |
| }); | |
| // Initialize | |
| loadCharacter(); | |
| loadRecentChats(); | |
| // Load previous messages if exist | |
| const messagesContainer = document.getElementById('messagesContainer'); | |
| if (currentCharacter) { | |
| const savedMessages = localStorage.getItem(`chat_${currentCharacter.id}`); | |
| if (savedMessages) { | |
| try { | |
| const parsed = JSON.parse(savedMessages); | |
| if (Array.isArray(parsed)) { | |
| // Clear current messages array before loading to prevent duplicates | |
| messages = []; | |
| parsed.forEach(msg => { | |
| // Robust null/undefined check | |
| if (!msg || typeof msg !== 'object') return; | |
| // Use validator for strict checking | |
| const safeText = validateText(msg.text, ''); | |
| // Skip empty messages without images | |
| if (!safeText && !msg.imageUrl) return; | |
| // Render without re-adding to array (we already set messages = parsed) | |
| const isUser = msg.sender === 'user'; | |
| const wrapper = document.createElement('div'); | |
| wrapper.className = `flex ${isUser ? 'justify-end' : 'justify-start'} mb-4`; | |
| let content = ''; | |
| if (msg.imageUrl) { | |
| content = `<img src="${msg.imageUrl}" class="rounded-lg max-w-sm shadow-lg border border-gray-700" alt="Generated">`; | |
| } else { | |
| const parsedText = parseMessage(safeText); | |
| content = `<div class="message-bubble glass-panel ${isUser ? 'bg-pink-600/20 border-pink-500/30' : 'bg-gray-800/50'} rounded-2xl px-4 py-3 text-gray-100">${parsedText}</div>`; | |
| } | |
| wrapper.innerHTML = content; | |
| messagesContainer.appendChild(wrapper); | |
| // Add to messages array manually to maintain state without triggering save | |
| messages.push({ | |
| sender: msg.sender, | |
| text: safeText, | |
| imageUrl: msg.imageUrl, | |
| timestamp: msg.timestamp || new Date() | |
| }); | |
| }); | |
| messagesContainer.scrollTop = messagesContainer.scrollHeight; | |
| } | |
| } catch (e) { | |
| console.error('Failed to load saved messages:', e); | |
| localStorage.removeItem(`chat_${currentCharacter.id}`); | |
| messages = []; | |
| } | |
| } | |
| } | |
| </script> | |
| </body> | |
| </html> |