Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>NexusChat - Advanced AI Client</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/styles/github.min.css"> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/highlight.min.js"></script> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> | |
| <script> | |
| tailwind.config = { | |
| theme: { | |
| extend: { | |
| colors: { | |
| primary: { | |
| 50: '#f0f9ff', | |
| 100: '#e0f2fe', | |
| 200: '#bae6fd', | |
| 300: '#7dd3fc', | |
| 400: '#38bdf8', | |
| 500: '#0ea5e9', | |
| 600: '#0284c7', | |
| 700: '#0369a1', | |
| 800: '#075985', | |
| 900: '#0c4a6e', | |
| }, | |
| dark: { | |
| 800: '#1e293b', | |
| 900: '#0f172a', | |
| } | |
| } | |
| } | |
| } | |
| } | |
| </script> | |
| <style> | |
| /* Custom scrollbar */ | |
| ::-webkit-scrollbar { | |
| width: 6px; | |
| } | |
| ::-webkit-scrollbar-track { | |
| background: #f1f1f1; | |
| } | |
| ::-webkit-scrollbar-thumb { | |
| background: #888; | |
| border-radius: 3px; | |
| } | |
| ::-webkit-scrollbar-thumb:hover { | |
| background: #555; | |
| } | |
| /* Dark mode scrollbar */ | |
| .dark ::-webkit-scrollbar-track { | |
| background: #1e293b; | |
| } | |
| .dark ::-webkit-scrollbar-thumb { | |
| background: #64748b; | |
| } | |
| /* Chat message typing animation */ | |
| @keyframes typing { | |
| from { width: 0 } | |
| to { width: 100% } | |
| } | |
| .typing-animation { | |
| overflow: hidden; | |
| white-space: nowrap; | |
| animation: typing 2s steps(40, end); | |
| } | |
| /* Smooth transitions */ | |
| .transition-all { | |
| transition: all 0.3s ease; | |
| } | |
| /* Markdown styling */ | |
| .prose pre { | |
| background-color: rgba(0,0,0,0.05); | |
| padding: 1em; | |
| border-radius: 0.5em; | |
| overflow-x: auto; | |
| margin: 1em 0; | |
| } | |
| .dark .prose pre { | |
| background-color: rgba(255,255,255,0.05); | |
| } | |
| .prose code { | |
| background-color: rgba(0,0,0,0.1); | |
| padding: 0.2em 0.4em; | |
| border-radius: 0.25em; | |
| font-family: monospace; | |
| } | |
| .dark .prose code { | |
| background-color: rgba(255,255,255,0.1); | |
| } | |
| .prose ul, .prose ol { | |
| padding-left: 1.5em; | |
| margin: 0.5em 0; | |
| } | |
| .prose li { | |
| margin: 0.25em 0; | |
| } | |
| .prose table { | |
| border-collapse: collapse; | |
| width: 100%; | |
| margin: 1em 0; | |
| } | |
| .prose th, .prose td { | |
| border: 1px solid rgba(0,0,0,0.1); | |
| padding: 0.5em; | |
| } | |
| .dark .prose th, .dark .prose td { | |
| border-color: rgba(255,255,255,0.1); | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-gray-50 dark:bg-dark-900 text-gray-800 dark:text-gray-200 min-h-screen"> | |
| <div class="flex h-screen overflow-hidden"> | |
| <!-- Sidebar --> | |
| <div class="w-64 bg-white dark:bg-dark-800 border-r border-gray-200 dark:border-gray-700 flex flex-col"> | |
| <div class="p-4 border-b border-gray-200 dark:border-gray-700"> | |
| <button id="newChatBtn" class="w-full flex items-center justify-center gap-2 bg-primary-500 hover:bg-primary-600 text-white py-2 px-4 rounded-lg transition-all"> | |
| <i class="fas fa-plus"></i> | |
| <span>New Chat</span> | |
| </button> | |
| </div> | |
| <div class="flex-1 overflow-y-auto p-2" id="chatList"> | |
| <!-- Chat items will be added here dynamically --> | |
| </div> | |
| <div class="p-4 border-t border-gray-200 dark:border-gray-700"> | |
| <div class="flex items-center gap-3 mb-4 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 p-2 rounded-lg transition-all"> | |
| <div class="w-8 h-8 rounded-full bg-primary-500 flex items-center justify-center text-white"> | |
| <i class="fas fa-user"></i> | |
| </div> | |
| <span class="font-medium">User Account</span> | |
| </div> | |
| <div id="settingsBtn" class="flex items-center gap-3 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 p-2 rounded-lg transition-all"> | |
| <div class="w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-600 flex items-center justify-center"> | |
| <i class="fas fa-cog"></i> | |
| </div> | |
| <span>Settings</span> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Main Content --> | |
| <div class="flex-1 flex flex-col overflow-hidden"> | |
| <!-- Header --> | |
| <header class="bg-white dark:bg-dark-800 border-b border-gray-200 dark:border-gray-700 p-4 flex items-center justify-between"> | |
| <div class="flex items-center gap-2"> | |
| <button id="sidebarToggle" class="md:hidden text-gray-500 dark:text-gray-400"> | |
| <i class="fas fa-bars text-xl"></i> | |
| </button> | |
| <h1 class="text-xl font-bold">NexusChat</h1> | |
| </div> | |
| <div class="flex items-center gap-4"> | |
| <div class="relative"> | |
| <button id="modelDropdownBtn" class="flex items-center gap-2 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 py-2 px-3 rounded-lg transition-all"> | |
| <span id="currentModel">GPT-4</span> | |
| <i class="fas fa-chevron-down text-xs"></i> | |
| </button> | |
| </div> | |
| <div class="relative"> | |
| <button id="chatSettingsBtn" class="flex items-center gap-2 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 py-2 px-3 rounded-lg transition-all"> | |
| <i class="fas fa-cog"></i> | |
| </button> | |
| <div id="modelDropdown" class="hidden absolute right-0 mt-2 w-48 bg-white dark:bg-dark-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-10"> | |
| <div class="p-2"> | |
| <div class="text-xs uppercase text-gray-500 dark:text-gray-400 px-2 py-1">Default Models</div> | |
| <div class="model-option cursor-pointer px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" data-model="gpt-3.5">GPT-3.5</div> | |
| <div class="model-option cursor-pointer px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded" data-model="gpt-4">GPT-4</div> | |
| <div class="text-xs uppercase text-gray-500 dark:text-gray-400 px-2 py-1 mt-2">Custom Models</div> | |
| <div id="customModelsList"> | |
| <!-- Custom models will be added here --> | |
| </div> | |
| <div class="border-t border-gray-200 dark:border-gray-700 mt-2 pt-2"> | |
| <div id="addModelBtn" class="cursor-pointer px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2 text-primary-500"> | |
| <i class="fas fa-plus"></i> | |
| <span>Add Custom Model</span> | |
| </div> | |
| </div> | |
| <div class="text-xs uppercase text-gray-500 dark:text-gray-400 px-2 py-1 mt-2">Custom Providers</div> | |
| <div id="customProvidersList"> | |
| <!-- Custom providers will be added here --> | |
| </div> | |
| <div class="border-t border-gray-200 dark:border-gray-700 mt-2 pt-2"> | |
| <div id="addProviderBtn" class="cursor-pointer px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center gap-2 text-primary-500"> | |
| <i class="fas fa-plus"></i> | |
| <span>Add Custom Provider</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <button id="darkModeToggle" class="text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"> | |
| <i class="fas fa-moon dark:hidden"></i> | |
| <i class="fas fa-sun hidden dark:block"></i> | |
| </button> | |
| </div> | |
| </header> | |
| <!-- Chat Area --> | |
| <div class="flex-1 overflow-y-auto p-4 bg-gray-50 dark:bg-dark-900" id="chatArea"> | |
| <div class="max-w-3xl mx-auto"> | |
| <!-- Welcome message - shown when no chat or empty chat --> | |
| <div id="welcomeMessage" class="text-center py-10"> | |
| <div class="w-16 h-16 mx-auto mb-4 bg-primary-500 rounded-full flex items-center justify-center text-white"> | |
| <i class="fas fa-robot text-2xl"></i> | |
| </div> | |
| <h2 class="text-2xl font-bold mb-2">Welcome to NexusChat</h2> | |
| <p class="text-gray-600 dark:text-gray-400 mb-6">Start a new conversation or select one from your history</p> | |
| <div class="grid grid-cols-1 md:grid-cols-2 gap-4 max-w-2xl mx-auto mb-8"> | |
| <div class="p-4 bg-white dark:bg-gray-800 rounded-lg shadow-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 transition-all example-prompt"> | |
| <h3 class="font-medium mb-1">Explain quantum computing</h3> | |
| <p class="text-sm text-gray-500 dark:text-gray-400">In simple terms</p> | |
| </div> | |
| <div class="p-4 bg-white dark:bg-gray-800 rounded-lg shadow-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 transition-all example-prompt"> | |
| <h3 class="font-medium mb-1">Write a poem</h3> | |
| <p class="text-sm text-gray-500 dark:text-gray-400">About artificial intelligence</p> | |
| </div> | |
| <div class="p-4 bg-white dark:bg-gray-800 rounded-lg shadow-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 transition-all example-prompt"> | |
| <h3 class="font-medium mb-1">Plan a trip</h3> | |
| <p class="text-sm text-gray-500 dark:text-gray-400">To Japan for 2 weeks</p> | |
| </div> | |
| <div class="p-4 bg-white dark:bg-gray-800 rounded-lg shadow-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 transition-all example-prompt"> | |
| <h3 class="font-medium mb-1">Help me debug</h3> | |
| <p class="text-sm text-gray-500 dark:text-gray-400">This JavaScript code</p> | |
| </div> | |
| </div> | |
| <button id="quickStartBtn" class="bg-primary-500 hover:bg-primary-600 text-white py-2 px-6 rounded-lg transition-all"> | |
| Quick Start | |
| </button> | |
| </div> | |
| <!-- Chat messages will be added here dynamically --> | |
| </div> | |
| </div> | |
| <!-- Input Area --> | |
| <div class="p-4 border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-dark-800"> | |
| <div class="max-w-3xl mx-auto"> | |
| <form id="messageForm" class="relative"> | |
| <textarea id="messageInput" rows="1" class="w-full p-4 pr-16 bg-gray-100 dark:bg-gray-700 rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-primary-500" placeholder="Type your message here..."></textarea> | |
| <button type="submit" class="absolute right-4 bottom-4 w-8 h-8 bg-primary-500 hover:bg-primary-600 text-white rounded-full flex items-center justify-center transition-all"> | |
| <i class="fas fa-paper-plane"></i> | |
| </button> | |
| </form> | |
| <div class="text-xs text-gray-500 dark:text-gray-400 mt-2 text-center"> | |
| NexusChat may produce inaccurate information about people, places, or facts. | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Add Model Modal --> | |
| <div id="addModelModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden"> | |
| <div class="bg-white dark:bg-dark-800 rounded-lg shadow-xl w-full max-w-md p-6"> | |
| <div class="flex justify-between items-center mb-4"> | |
| <h3 class="text-lg font-bold">Add Custom Model</h3> | |
| <button id="closeModelModal" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"> | |
| <i class="fas fa-times"></i> | |
| </button> | |
| </div> | |
| <form id="addModelForm"> | |
| <div class="mb-4"> | |
| <label for="modelName" class="block text-sm font-medium mb-1">Model Name</label> | |
| <input type="text" id="modelName" class="w-full p-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:ring-primary-500 focus:border-primary-500" placeholder="e.g. My Custom Model" required> | |
| </div> | |
| <div class="mb-4"> | |
| <label for="modelEndpoint" class="block text-sm font-medium mb-1">API Endpoint</label> | |
| <input type="text" id="modelEndpoint" class="w-full p-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:ring-primary-500 focus:border-primary-500" placeholder="https://api.example.com/v1/chat" required> | |
| </div> | |
| <div class="mb-4"> | |
| <label for="modelKey" class="block text-sm font-medium mb-1">API Key (optional)</label> | |
| <input type="password" id="modelKey" class="w-full p-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:ring-primary-500 focus:border-primary-500" placeholder="sk-..."> | |
| </div> | |
| <div class="mb-4 flex items-center"> | |
| <input type="checkbox" id="modelStreaming" class="w-4 h-4 text-primary-600 rounded border-gray-300 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700"> | |
| <label for="modelStreaming" class="ml-2 text-sm font-medium text-gray-700 dark:text-gray-300">Supports Streaming</label> | |
| </div> | |
| <input type="hidden" id="editModelId" value=""> | |
| <input type="hidden" id="editProviderId" value=""> | |
| <div class="flex justify-end gap-2"> | |
| <button type="button" id="cancelAddModel" class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-100 dark:hover:bg-gray-700 transition-all"> | |
| Cancel | |
| </button> | |
| <button type="submit" class="px-4 py-2 bg-primary-500 hover:bg-primary-600 text-white rounded transition-all"> | |
| Add Model | |
| </button> | |
| </div> | |
| </form> | |
| </div> | |
| </div> | |
| <!-- API Key Modal --> | |
| <div id="apiKeyModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden"> | |
| <div class="bg-white dark:bg-dark-800 rounded-lg shadow-xl w-full max-w-md p-6"> | |
| <div class="flex justify-between items-center mb-4"> | |
| <h3 class="text-lg font-bold">OpenAI API Key Required</h3> | |
| <button id="closeApiKeyModal" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"> | |
| <i class="fas fa-times"></i> | |
| </button> | |
| </div> | |
| <p class="mb-4">To use OpenAI models, please enter your API key:</p> | |
| <form id="apiKeyForm"> | |
| <div class="mb-4"> | |
| <input type="password" id="apiKeyInput" class="w-full p-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:ring-primary-500 focus:border-primary-500" placeholder="sk-..." required> | |
| </div> | |
| <div class="flex justify-end gap-2"> | |
| <button type="button" id="cancelApiKey" class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-100 dark:hover:bg-gray-700 transition-all"> | |
| Cancel | |
| </button> | |
| <button type="submit" class="px-4 py-2 bg-primary-500 hover:bg-primary-600 text-white rounded transition-all"> | |
| Save Key | |
| </button> | |
| </div> | |
| </form> | |
| </div> | |
| </div> | |
| <!-- Settings Page --> | |
| <div id="settingsPage" class="hidden fixed inset-0 bg-gray-50 dark:bg-dark-900 z-40 flex flex-col"> | |
| <div class="max-w-3xl mx-auto w-full p-6 overflow-y-auto"> | |
| <div class="sticky top-0 bg-gray-50 dark:bg-dark-900 pt-6 pb-4 z-10"> | |
| <div class="flex items-center gap-4 mb-2"> | |
| <button id="backToChatBtn" class="text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"> | |
| <i class="fas fa-arrow-left"></i> | |
| </button> | |
| <h2 class="text-2xl font-bold">Settings</h2> | |
| </div> | |
| </div> | |
| <div class="bg-white dark:bg-dark-800 rounded-lg shadow-sm p-6 mb-6"> | |
| <h3 class="text-lg font-semibold mb-4">API Configuration</h3> | |
| <form id="apiKeySettingsForm"> | |
| <div class="mb-4"> | |
| <label for="providerUrl" class="block text-sm font-medium mb-1">Custom Provider URL</label> | |
| <input type="text" id="providerUrl" class="w-full p-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:ring-primary-500 focus:border-primary-500" placeholder="https://example.com/v1" value=""> | |
| <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Base URL for your custom provider</p> | |
| </div> | |
| <div class="mb-4"> | |
| <label for="settingsApiKey" class="block text-sm font-medium mb-1">OpenAI API Key</label> | |
| <div class="relative"> | |
| <input type="password" id="settingsApiKey" class="w-full p-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:ring-primary-500 focus:border-primary-500" placeholder="sk-..." value=""> | |
| <button type="button" id="toggleApiKeyVisibility" class="absolute right-2 top-2 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"> | |
| <i class="fas fa-eye"></i> | |
| </button> | |
| </div> | |
| <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Your API key is stored locally in your browser</p> | |
| </div> | |
| <button type="submit" class="px-4 py-2 bg-primary-500 hover:bg-primary-600 text-white rounded transition-all"> | |
| Save API Key | |
| </button> | |
| </form> | |
| </div> | |
| <div class="bg-white dark:bg-dark-800 rounded-lg shadow-sm p-6 mb-6"> | |
| <h3 class="text-lg font-semibold mb-4">Appearance</h3> | |
| <div class="flex items-center justify-between mb-2"> | |
| <span>Dark Mode</span> | |
| <label class="relative inline-flex items-center cursor-pointer"> | |
| <input type="checkbox" id="darkModeToggleSetting" class="sr-only peer"> | |
| <div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 dark:peer-focus:ring-primary-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-primary-500"></div> | |
| </label> | |
| </div> | |
| </div> | |
| <div class="bg-white dark:bg-dark-800 rounded-lg shadow-sm p-6 mb-6"> | |
| <h3 class="text-lg font-semibold mb-4">Chat Defaults</h3> | |
| <form id="defaultChatSettingsForm"> | |
| <div class="mb-4"> | |
| <label for="defaultTemperature" class="block text-sm font-medium mb-1">Default Temperature (0-1)</label> | |
| <input type="number" id="defaultTemperature" min="0" max="1" step="0.1" | |
| class="w-full p-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:ring-primary-500 focus:border-primary-500" | |
| placeholder="0.7" required> | |
| </div> | |
| <div class="mb-4"> | |
| <label for="defaultHistoryCount" class="block text-sm font-medium mb-1">Default Message History Count</label> | |
| <input type="number" id="defaultHistoryCount" min="1" max="20" | |
| class="w-full p-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:ring-primary-500 focus:border-primary-500" | |
| placeholder="5" required> | |
| </div> | |
| <div class="mb-4"> | |
| <label for="defaultSystemMessage" class="block text-sm font-medium mb-1">Default System Message</label> | |
| <textarea id="defaultSystemMessage" rows="3" | |
| class="w-full p-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:ring-primary-500 focus:border-primary-500" | |
| placeholder="You are a helpful assistant..."></textarea> | |
| </div> | |
| <button type="submit" class="px-4 py-2 bg-primary-500 hover:bg-primary-600 text-white rounded transition-all"> | |
| Save Defaults | |
| </button> | |
| </form> | |
| </div> | |
| <div class="bg-white dark:bg-dark-800 rounded-lg shadow-sm p-6"> | |
| <h3 class="text-lg font-semibold mb-4">About</h3> | |
| <p class="text-sm text-gray-600 dark:text-gray-300 mb-2">NexusChat v1.0.0</p> | |
| <p class="text-sm text-gray-600 dark:text-gray-300">An advanced AI client for modern workflows</p> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Add Provider Modal --> | |
| <div id="addProviderModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden"> | |
| <div class="bg-white dark:bg-dark-800 rounded-lg shadow-xl w-full max-w-md p-6"> | |
| <div class="flex justify-between items-center mb-4"> | |
| <h3 class="text-lg font-bold">Add Custom Provider</h3> | |
| <button id="closeProviderModal" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"> | |
| <i class="fas fa-times"></i> | |
| </button> | |
| </div> | |
| <form id="addProviderForm"> | |
| <div class="mb-4"> | |
| <label for="providerName" class="block text-sm font-medium mb-1">Provider Name</label> | |
| <input type="text" id="providerName" class="w-full p-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:ring-primary-500 focus:border-primary-500" placeholder="e.g. My AI Provider" required> | |
| </div> | |
| <div class="mb-4"> | |
| <label for="providerBaseUrl" class="block text-sm font-medium mb-1">Base URL</label> | |
| <input type="text" id="providerBaseUrl" class="w-full p-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:ring-primary-500 focus:border-primary-500" placeholder="https://api.example.com/v1" required> | |
| </div> | |
| <div class="mb-4"> | |
| <label for="providerApiKey" class="block text-sm font-medium mb-1">API Key (optional)</label> | |
| <input type="password" id="providerApiKey" class="w-full p-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:ring-primary-500 focus:border-primary-500" placeholder="sk-..."> | |
| </div> | |
| <div class="mb-4 flex items-center"> | |
| <input type="checkbox" id="providerStreaming" class="w-4 h-4 text-primary-600 rounded border-gray-300 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-700"> | |
| <label for="providerStreaming" class="ml-2 text-sm font-medium text-gray-700 dark:text-gray-300">Supports Streaming</label> | |
| </div> | |
| <div class="flex justify-end gap-2"> | |
| <button type="button" id="cancelAddProvider" class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-100 dark:hover:bg-gray-700 transition-all"> | |
| Cancel | |
| </button> | |
| <button type="submit" class="px-4 py-2 bg-primary-500 hover:bg-primary-600 text-white rounded transition-all"> | |
| Add Provider | |
| </button> | |
| </div> | |
| </form> | |
| </div> | |
| </div> | |
| <!-- Provider Models Modal --> | |
| <div id="providerModelsModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden"> | |
| <div class="bg-white dark:bg-dark-800 rounded-lg shadow-xl w-full max-w-md p-6 max-h-[80vh] overflow-y-auto"> | |
| <div class="flex justify-between items-center mb-4"> | |
| <h3 class="text-lg font-bold" id="providerModelsTitle">Provider Models</h3> | |
| <button id="closeProviderModelsModal" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"> | |
| <i class="fas fa-times"></i> | |
| </button> | |
| </div> | |
| <div id="providerModelsList" class="space-y-2"> | |
| <!-- Models will be loaded here --> | |
| </div> | |
| <div class="flex justify-end mt-4"> | |
| <button type="button" id="cancelProviderModels" class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-100 dark:hover:bg-gray-700 transition-all"> | |
| Close | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Chat Settings Modal --> | |
| <div id="chatSettingsModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden"> | |
| <div class="bg-white dark:bg-dark-800 rounded-lg shadow-xl w-full max-w-md p-6"> | |
| <div class="flex justify-between items-center mb-4"> | |
| <h3 class="text-lg font-bold">Chat Settings</h3> | |
| <button id="closeChatSettingsModal" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"> | |
| <i class="fas fa-times"></i> | |
| </button> | |
| </div> | |
| <form id="chatSettingsForm"> | |
| <div class="mb-4"> | |
| <label for="chatTemperature" class="block text-sm font-medium mb-1">Temperature (0-1)</label> | |
| <input type="number" id="chatTemperature" min="0" max="1" step="0.1" | |
| class="w-full p-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:ring-primary-500 focus:border-primary-500" | |
| placeholder="0.7" required> | |
| </div> | |
| <div class="mb-4"> | |
| <label for="chatHistoryCount" class="block text-sm font-medium mb-1">Message History Count</label> | |
| <input type="number" id="chatHistoryCount" min="1" max="20" | |
| class="w-full p-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:ring-primary-500 focus:border-primary-500" | |
| placeholder="5" required> | |
| </div> | |
| <div class="mb-4"> | |
| <label for="chatSystemMessage" class="block text-sm font-medium mb-1">System Message</label> | |
| <textarea id="chatSystemMessage" rows="3" | |
| class="w-full p-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 focus:ring-primary-500 focus:border-primary-500" | |
| placeholder="You are a helpful assistant..."></textarea> | |
| </div> | |
| <div class="flex justify-end gap-2"> | |
| <button type="button" id="cancelChatSettings" class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-100 dark:hover:bg-gray-700 transition-all"> | |
| Cancel | |
| </button> | |
| <button type="submit" class="px-4 py-2 bg-primary-500 hover:bg-primary-600 text-white rounded transition-all"> | |
| Save | |
| </button> | |
| </div> | |
| </form> | |
| </div> | |
| </div> | |
| <!-- Delete Chat Confirmation Modal --> | |
| <div id="deleteChatModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden"> | |
| <div class="bg-white dark:bg-dark-800 rounded-lg shadow-xl w-full max-w-md p-6"> | |
| <div class="flex justify-between items-center mb-4"> | |
| <h3 class="text-lg font-bold">Delete Chat</h3> | |
| <button id="closeDeleteModal" class="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"> | |
| <i class="fas fa-times"></i> | |
| </button> | |
| </div> | |
| <p class="mb-6">Are you sure you want to delete this chat? This action cannot be undone.</p> | |
| <div class="flex justify-end gap-2"> | |
| <button type="button" id="cancelDelete" class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-100 dark:hover:bg-gray-700 transition-all"> | |
| Cancel | |
| </button> | |
| <button type="button" id="confirmDelete" class="px-4 py-2 bg-red-500 hover:bg-red-600 text-white rounded transition-all"> | |
| Delete | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| // DOM Elements | |
| const apiKeyModal = document.getElementById('apiKeyModal'); | |
| const closeApiKeyModal = document.getElementById('closeApiKeyModal'); | |
| const cancelApiKey = document.getElementById('cancelApiKey'); | |
| const apiKeyForm = document.getElementById('apiKeyForm'); | |
| const apiKeyInput = document.getElementById('apiKeyInput'); | |
| const sidebarToggle = document.getElementById('sidebarToggle'); | |
| const darkModeToggle = document.getElementById('darkModeToggle'); | |
| const newChatBtn = document.getElementById('newChatBtn'); | |
| const chatList = document.getElementById('chatList'); | |
| const chatArea = document.getElementById('chatArea'); | |
| const welcomeMessage = document.getElementById('welcomeMessage'); | |
| const messageForm = document.getElementById('messageForm'); | |
| const messageInput = document.getElementById('messageInput'); | |
| const quickStartBtn = document.getElementById('quickStartBtn'); | |
| const modelDropdownBtn = document.getElementById('modelDropdownBtn'); | |
| const modelDropdown = document.getElementById('modelDropdown'); | |
| const currentModel = document.getElementById('currentModel'); | |
| const customModelsList = document.getElementById('customModelsList'); | |
| const addModelBtn = document.getElementById('addModelBtn'); | |
| const addModelModal = document.getElementById('addModelModal'); | |
| const closeModelModal = document.getElementById('closeModelModal'); | |
| const cancelAddModel = document.getElementById('cancelAddModel'); | |
| const addModelForm = document.getElementById('addModelForm'); | |
| const deleteChatModal = document.getElementById('deleteChatModal'); | |
| const closeDeleteModal = document.getElementById('closeDeleteModal'); | |
| const cancelDelete = document.getElementById('cancelDelete'); | |
| const confirmDelete = document.getElementById('confirmDelete'); | |
| // State | |
| let chats = JSON.parse(localStorage.getItem('chats')) || []; | |
| let currentChatId = null; | |
| let defaultChatSettings = { | |
| temperature: 0.7, | |
| historyCount: 5, | |
| systemMessage: 'You are a helpful assistant.' | |
| }; | |
| let models = [ | |
| { name: 'GPT-3.5', id: 'gpt-3.5', type: 'default' }, | |
| { name: 'GPT-4', id: 'gpt-4', type: 'default' } | |
| ]; | |
| let customModels = JSON.parse(localStorage.getItem('customModels')) || []; | |
| let customProviders = JSON.parse(localStorage.getItem('customProviders')) || []; | |
| let selectedModel = 'gpt-4'; | |
| let chatToDelete = null; | |
| let currentProvider = null; | |
| // Initialize | |
| function init() { | |
| // Load default chat settings | |
| const savedDefaults = localStorage.getItem('defaultChatSettings'); | |
| if (savedDefaults) { | |
| defaultChatSettings = JSON.parse(savedDefaults); | |
| } | |
| // Load custom models and providers from localStorage | |
| const savedCustomModels = localStorage.getItem('customModels'); | |
| if (savedCustomModels) { | |
| customModels = JSON.parse(savedCustomModels); | |
| customModels.forEach(model => { | |
| models.push({ | |
| id: model.id, | |
| name: model.name, | |
| endpoint: model.endpoint, | |
| key: model.key, | |
| streaming: model.streaming || false, | |
| type: 'custom' | |
| }); | |
| }); | |
| } | |
| // Load custom providers | |
| const savedCustomProviders = localStorage.getItem('customProviders'); | |
| if (savedCustomProviders) { | |
| customProviders = JSON.parse(savedCustomProviders); | |
| customProviders.forEach(provider => { | |
| models.push({ | |
| id: provider.id, | |
| name: provider.name, | |
| type: 'provider', | |
| baseUrl: provider.baseUrl, | |
| apiKey: provider.apiKey, | |
| streaming: provider.streaming || false | |
| }); | |
| }); | |
| } | |
| // Configure marked.js | |
| marked.setOptions({ | |
| breaks: true, | |
| gfm: true, | |
| highlight: function(code, lang) { | |
| const language = hljs.getLanguage(lang) ? lang : 'plaintext'; | |
| return hljs.highlight(code, { language }).value; | |
| } | |
| }); | |
| // Load custom models | |
| customModels.forEach(model => { | |
| models.push({ ...model, type: 'custom' }); | |
| }); | |
| // Render chat list | |
| renderChatList(); | |
| // Set dark mode if preferred | |
| if (localStorage.getItem('darkMode') === 'true' || | |
| (!localStorage.getItem('darkMode') && window.matchMedia('(prefers-color-scheme: dark)').matches)) { | |
| document.documentElement.classList.add('dark'); | |
| } | |
| // Set current model | |
| const savedModel = localStorage.getItem('selectedModel'); | |
| if (savedModel) { | |
| try { | |
| const modelData = JSON.parse(savedModel); | |
| const model = models.find(m => m.id === modelData.id); | |
| if (model) { | |
| selectedModel = modelData.id; | |
| currentModel.textContent = modelData.name; | |
| // For provider models, ensure the provider is still available | |
| if (modelData.type === 'provider-model') { | |
| const providerId = modelData.id.split('-')[0]; | |
| const providerExists = customProviders.some(p => p.id === providerId); | |
| if (!providerExists) { | |
| // Fall back to default if provider is gone | |
| selectModel('gpt-4'); | |
| } | |
| } | |
| } | |
| } catch (e) { | |
| // Fallback for old string format | |
| const model = models.find(m => m.id === savedModel); | |
| if (model) { | |
| selectedModel = savedModel; | |
| currentModel.textContent = model.name; | |
| // Migrate old chats to new modelData format | |
| chats.forEach(chat => { | |
| if (!chat.modelData && chat.model) { | |
| chat.modelData = { | |
| id: chat.model, | |
| name: models.find(m => m.id === chat.model)?.name || 'GPT-4' | |
| }; | |
| } | |
| }); | |
| saveChats(); | |
| } | |
| } | |
| } | |
| // Render custom models and providers in dropdown | |
| renderCustomModels(); | |
| renderCustomProviders(); | |
| } | |
| // Render chat list | |
| function renderChatList() { | |
| chatList.innerHTML = ''; | |
| if (chats.length === 0) { | |
| chatList.innerHTML = ` | |
| <div class="p-4 text-center text-gray-500 dark:text-gray-400"> | |
| No chats yet | |
| </div> | |
| `; | |
| return; | |
| } | |
| chats.forEach(chat => { | |
| // Function to handle edit mode | |
| const setupEditMode = (chatElement, chat) => { | |
| const titleElement = chatElement.querySelector('.chat-title'); | |
| const originalTitle = titleElement.textContent; | |
| const input = document.createElement('input'); | |
| input.type = 'text'; | |
| input.value = originalTitle; | |
| input.className = 'w-full bg-transparent border-b border-blue-500 focus:outline-none'; | |
| titleElement.replaceWith(input); | |
| input.focus(); | |
| const handleKeyDown = (e) => { | |
| if (e.key === 'Enter') { | |
| finishEdit(); | |
| } else if (e.key === 'Escape') { | |
| input.value = originalTitle; | |
| finishEdit(); | |
| } | |
| }; | |
| const finishEdit = () => { | |
| const newTitle = input.value.trim() || 'New Chat'; | |
| chat.title = newTitle; | |
| saveChats(); | |
| titleElement.textContent = newTitle; | |
| input.replaceWith(titleElement); | |
| input.removeEventListener('blur', finishEdit); | |
| input.removeEventListener('keydown', handleKeyDown); | |
| }; | |
| input.addEventListener('blur', finishEdit); | |
| input.addEventListener('keydown', handleKeyDown); | |
| }; | |
| const chatElement = document.createElement('div'); | |
| chatElement.className = `group relative flex items-center gap-3 p-2 rounded-lg cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700 transition-all ${currentChatId === chat.id ? 'bg-gray-100 dark:bg-gray-700' : ''}`; | |
| chatElement.dataset.id = chat.id; | |
| chatElement.innerHTML = ` | |
| <div class="w-8 h-8 rounded-full bg-primary-100 dark:bg-primary-900 text-primary-500 dark:text-primary-300 flex items-center justify-center"> | |
| <i class="fas fa-comment"></i> | |
| </div> | |
| <div class="flex-1 min-w-0"> | |
| <div class="flex items-center gap-1"> | |
| <p class="truncate font-medium chat-title">${chat.title || 'New Chat'}</p> | |
| <button class="edit-chat opacity-0 group-hover:opacity-100 text-gray-400 hover:text-blue-500 transition-all p-1"> | |
| <i class="fas fa-edit text-xs"></i> | |
| </button> | |
| </div> | |
| <p class="text-xs text-gray-500 dark:text-gray-400 truncate">${chat.lastMessage || ''}</p> | |
| </div> | |
| <button class="delete-chat opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-500 transition-all p-1"> | |
| <i class="fas fa-trash text-sm"></i> | |
| </button> | |
| `; | |
| chatElement.addEventListener('click', () => loadChat(chat.id)); | |
| const deleteBtn = chatElement.querySelector('.delete-chat'); | |
| deleteBtn.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| showDeleteModal(chat.id); | |
| }); | |
| chatList.appendChild(chatElement); | |
| // Add edit button event listener | |
| const editBtn = chatElement.querySelector('.edit-chat'); | |
| editBtn.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| setupEditMode(chatElement, chat); | |
| }); | |
| }); | |
| } | |
| // Render custom providers in dropdown | |
| function renderCustomProviders() { | |
| customProvidersList.innerHTML = ''; | |
| if (customProviders.length === 0) { | |
| customProvidersList.innerHTML = ` | |
| <div class="text-center text-gray-500 dark:text-gray-400 py-2 text-sm"> | |
| No custom providers added | |
| </div> | |
| `; | |
| return; | |
| } | |
| customProviders.forEach(provider => { | |
| const providerElement = document.createElement('div'); | |
| providerElement.className = 'provider-option cursor-pointer px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex justify-between items-center'; | |
| providerElement.dataset.provider = provider.id; | |
| providerElement.innerHTML = ` | |
| <span>${provider.name}</span> | |
| <div class="flex gap-1"> | |
| <button class="edit-provider text-gray-400 hover:text-blue-500 transition-all p-1" title="Edit"> | |
| <i class="fas fa-edit text-xs"></i> | |
| </button> | |
| <button class="view-provider-models text-gray-400 hover:text-blue-500 transition-all p-1" title="View Models"> | |
| <i class="fas fa-list text-xs"></i> | |
| </button> | |
| <button class="delete-provider text-gray-400 hover:text-red-500 transition-all p-1"> | |
| <i class="fas fa-trash text-xs"></i> | |
| </button> | |
| </div> | |
| `; | |
| const deleteBtn = providerElement.querySelector('.delete-provider'); | |
| deleteBtn.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| deleteCustomProvider(provider.id); | |
| }); | |
| const viewModelsBtn = providerElement.querySelector('.view-provider-models'); | |
| viewModelsBtn.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| showProviderModels(provider); | |
| }); | |
| const editBtn = providerElement.querySelector('.edit-provider'); | |
| editBtn.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| showAddProviderModal(provider); | |
| }); | |
| customProvidersList.appendChild(providerElement); | |
| }); | |
| } | |
| // Render custom models in dropdown | |
| function renderCustomModels() { | |
| customModelsList.innerHTML = ''; | |
| // Reload custom models from localStorage to ensure we have latest | |
| const savedCustomModels = localStorage.getItem('customModels'); | |
| if (savedCustomModels) { | |
| customModels = JSON.parse(savedCustomModels); | |
| } | |
| if (customModels.length === 0) { | |
| customModelsList.innerHTML = ` | |
| <div class="text-center text-gray-500 dark:text-gray-400 py-2 text-sm"> | |
| No custom models added | |
| </div> | |
| `; | |
| return; | |
| } | |
| customModels.forEach(model => { | |
| const modelElement = document.createElement('div'); | |
| modelElement.className = 'model-option cursor-pointer px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex justify-between items-center'; | |
| modelElement.dataset.model = model.id; | |
| modelElement.innerHTML = ` | |
| <span>${model.name}</span> | |
| <div class="flex gap-1"> | |
| <button class="edit-model text-gray-400 hover:text-blue-500 transition-all p-1"> | |
| <i class="fas fa-edit text-xs"></i> | |
| </button> | |
| <button class="delete-model text-gray-400 hover:text-red-500 transition-all p-1"> | |
| <i class="fas fa-trash text-xs"></i> | |
| </button> | |
| </div> | |
| `; | |
| modelElement.addEventListener('click', (e) => { | |
| if (!e.target.classList.contains('delete-model')) { | |
| selectModel(model.id); | |
| } | |
| }); | |
| const deleteBtn = modelElement.querySelector('.delete-model'); | |
| deleteBtn.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| deleteCustomModel(model.id); | |
| }); | |
| const editBtn = modelElement.querySelector('.edit-model'); | |
| editBtn.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| showEditModelModal(model); | |
| }); | |
| customModelsList.appendChild(modelElement); | |
| }); | |
| } | |
| // Show chat settings modal | |
| function showChatSettingsModal() { | |
| if (!currentChatId) return; | |
| const chat = chats.find(c => c.id === currentChatId); | |
| if (!chat) return; | |
| // Initialize settings if they don't exist | |
| if (!chat.settings) { | |
| chat.settings = { ...defaultChatSettings }; | |
| } | |
| document.getElementById('chatTemperature').value = chat.settings.temperature; | |
| document.getElementById('chatHistoryCount').value = chat.settings.historyCount; | |
| document.getElementById('chatSystemMessage').value = chat.settings.systemMessage; | |
| chatSettingsModal.classList.remove('hidden'); | |
| } | |
| // Hide chat settings modal | |
| function hideChatSettingsModal() { | |
| chatSettingsModal.classList.add('hidden'); | |
| } | |
| // Save chat settings | |
| function saveChatSettings() { | |
| if (!currentChatId) return; | |
| const chat = chats.find(c => c.id === currentChatId); | |
| if (!chat) return; | |
| chat.settings = { | |
| temperature: parseFloat(document.getElementById('chatTemperature').value), | |
| historyCount: parseInt(document.getElementById('chatHistoryCount').value), | |
| systemMessage: document.getElementById('chatSystemMessage').value | |
| }; | |
| saveChats(); | |
| hideChatSettingsModal(); | |
| } | |
| // Save default chat settings | |
| function saveDefaultChatSettings() { | |
| defaultChatSettings = { | |
| temperature: parseFloat(document.getElementById('defaultTemperature').value), | |
| historyCount: parseInt(document.getElementById('defaultHistoryCount').value), | |
| systemMessage: document.getElementById('defaultSystemMessage').value | |
| }; | |
| localStorage.setItem('defaultChatSettings', JSON.stringify(defaultChatSettings)); | |
| // Show success feedback | |
| const submitBtn = document.querySelector('#defaultChatSettingsForm button[type="submit"]'); | |
| const originalText = submitBtn.textContent; | |
| submitBtn.textContent = 'Saved!'; | |
| submitBtn.classList.remove('bg-primary-500', 'hover:bg-primary-600'); | |
| submitBtn.classList.add('bg-green-500', 'hover:bg-green-600'); | |
| setTimeout(() => { | |
| submitBtn.textContent = originalText; | |
| submitBtn.classList.remove('bg-green-500', 'hover:bg-green-600'); | |
| submitBtn.classList.add('bg-primary-500', 'hover:bg-primary-600'); | |
| }, 2000); | |
| } | |
| // Create new chat | |
| function createNewChat() { | |
| const newChat = { | |
| id: Date.now().toString(), | |
| title: 'New Chat', | |
| model: selectedModel, | |
| messages: [], | |
| lastMessage: '', | |
| createdAt: new Date().toISOString(), | |
| modelData: { | |
| id: selectedModel, | |
| name: models.find(m => m.id === selectedModel)?.name || 'GPT-4' | |
| }, | |
| settings: { ...defaultChatSettings } | |
| }; | |
| chats.unshift(newChat); | |
| saveChats(); | |
| loadChat(newChat.id); | |
| renderChatList(); | |
| // Welcome message will be shown automatically since messages array is empty | |
| } | |
| // Load chat | |
| function loadChat(chatId) { | |
| hideSettingsPage(); | |
| currentChatId = chatId; | |
| const chat = chats.find(c => c.id === chatId); | |
| if (!chat) { | |
| createNewChat(); | |
| return; | |
| } | |
| // Update UI and model selection | |
| document.querySelectorAll('[data-id]').forEach(el => { | |
| el.classList.toggle('bg-gray-100', el.dataset.id === chatId); | |
| el.classList.toggle('dark:bg-gray-700', el.dataset.id === chatId); | |
| }); | |
| // Update model selection to match this chat's model | |
| if (chat.modelData) { | |
| selectedModel = chat.modelData.id; | |
| currentModel.textContent = chat.modelData.name; | |
| localStorage.setItem('selectedModel', JSON.stringify(chat.modelData)); | |
| } | |
| // Render messages | |
| renderMessages(chat.messages); | |
| // Show welcome message if chat is empty | |
| welcomeMessage.classList.toggle('hidden', chat.messages.length > 0); | |
| } | |
| // Render messages | |
| function renderMessages(messages) { | |
| chatArea.innerHTML = ''; | |
| if (messages.length === 0) { | |
| welcomeMessage.classList.remove('hidden'); | |
| return; | |
| } | |
| const messagesContainer = document.createElement('div'); | |
| messagesContainer.className = 'space-y-6'; | |
| messages.forEach((msg, index) => { | |
| const messageElement = document.createElement('div'); | |
| messageElement.className = `flex gap-4 ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`; | |
| if (msg.role === 'assistant') { | |
| messageElement.innerHTML = ` | |
| <div class="flex-shrink-0 w-8 h-8 rounded-full bg-primary-500 text-white flex items-center justify-center"> | |
| <i class="fas fa-robot"></i> | |
| </div> | |
| <div class="max-w-[80%] bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm relative group"> | |
| <div class="prose dark:prose-invert max-w-none break-words">${marked.parse(msg.content)}</div> | |
| <div class="absolute right-2 top-2 opacity-0 group-hover:opacity-100 transition-opacity flex gap-1"> | |
| <button class="message-action bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 p-1 rounded text-gray-700 dark:text-gray-300" data-action="regenerate" title="Regenerate"> | |
| <i class="fas fa-sync-alt text-xs"></i> | |
| </button> | |
| <button class="message-action bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 p-1 rounded text-gray-700 dark:text-gray-300" data-action="edit" title="Edit"> | |
| <i class="fas fa-edit text-xs"></i> | |
| </button> | |
| </div> | |
| </div> | |
| `; | |
| } else { | |
| messageElement.innerHTML = ` | |
| <div class="max-w-[80%] bg-primary-500 text-white rounded-lg p-4 shadow-sm relative group"> | |
| <div class="prose prose-white whitespace-pre-wrap">${msg.content.replace(/\n/g, '<br>')}</div> | |
| <div class="absolute right-2 top-2 opacity-0 group-hover:opacity-100 transition-opacity"> | |
| <button class="message-action bg-primary-600 hover:bg-primary-700 p-1 rounded text-white" data-action="edit" title="Edit"> | |
| <i class="fas fa-edit text-xs"></i> | |
| </button> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| messagesContainer.appendChild(messageElement); | |
| }); | |
| chatArea.appendChild(messagesContainer); | |
| chatArea.scrollTop = chatArea.scrollHeight; | |
| } | |
| // Send message | |
| async function sendMessage() { | |
| const message = messageInput.value.trim(); | |
| if (!message) return; | |
| // If no chat is selected, create a new one | |
| if (!currentChatId) { | |
| createNewChat(); | |
| // Wait a moment for the chat to be created | |
| await new Promise(resolve => setTimeout(resolve, 50)); | |
| } | |
| // Get current chat | |
| let chatIndex = chats.findIndex(c => c.id === currentChatId); | |
| if (chatIndex === -1) return; | |
| const chat = chats[chatIndex]; | |
| const model = models.find(m => m.id === chat.model); | |
| if (!model) return; | |
| if (model.type === 'default' && !localStorage.getItem('openaiApiKey')) { | |
| showApiKeyModal(); | |
| return; | |
| } | |
| // Add user message to chat | |
| const userMessage = { | |
| role: 'user', | |
| content: message, | |
| timestamp: new Date().toISOString() | |
| }; | |
| // Ensure we have the latest chat reference | |
| chatIndex = chats.findIndex(c => c.id === currentChatId); | |
| if (chatIndex === -1) return; | |
| let currentChat = chats[chatIndex]; | |
| currentChat.messages.push(userMessage); | |
| currentChat.lastMessage = message.length > 30 ? message.substring(0, 30) + '...' : message; | |
| // Save chat immediately after adding user message | |
| chats[chatIndex] = currentChat; | |
| saveChats(); | |
| // Render all messages including the new user message | |
| renderMessages(chat.messages); | |
| messageInput.value = ''; | |
| chatArea.scrollTop = chatArea.scrollHeight; | |
| // Create assistant message element for streaming/response | |
| const assistantMessageElement = document.createElement('div'); | |
| assistantMessageElement.className = 'flex gap-4 justify-start'; | |
| assistantMessageElement.innerHTML = ` | |
| <div class="flex-shrink-0 w-8 h-8 rounded-full bg-primary-500 text-white flex items-center justify-center"> | |
| <i class="fas fa-robot"></i> | |
| </div> | |
| <div class="max-w-[80%] bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm relative group"> | |
| <div class="flex gap-2" id="loading-indicator"> | |
| <div class="w-2 h-2 rounded-full bg-gray-300 animate-pulse"></div> | |
| <div class="w-2 h-2 rounded-full bg-gray-300 animate-pulse delay-75"></div> | |
| <div class="w-2 h-2 rounded-full bg-gray-300 animate-pulse delay-150"></div> | |
| </div> | |
| <div class="prose dark:prose-invert hidden" id="streaming-content"></div> | |
| </div> | |
| `; | |
| chatArea.appendChild(assistantMessageElement); | |
| const contentElement = assistantMessageElement.querySelector('#streaming-content'); | |
| const loadingIndicator = assistantMessageElement.querySelector('#loading-indicator'); | |
| try { | |
| // Ensure we have the latest chat reference first | |
| chatIndex = chats.findIndex(c => c.id === currentChatId); | |
| if (chatIndex === -1) throw new Error('Chat not found'); | |
| currentChat = chats[chatIndex]; | |
| // Get the current model | |
| const model = models.find(m => m.id === currentChat.model); | |
| if (!model) throw new Error('Model not found'); | |
| // Prepare messages for API | |
| const messages = currentChat.messages.map(msg => ({ | |
| role: msg.role, | |
| content: msg.content | |
| })); | |
| let response; | |
| response = ''; | |
| const onStream = (content) => { | |
| response = content; | |
| contentElement.innerHTML = marked.parse(response); | |
| chatArea.scrollTop = chatArea.scrollHeight; | |
| }; | |
| if (model.type === 'default') { | |
| // Call OpenAI API | |
| response = await callOpenAIAPI(messages, model.name, null, onStream); | |
| } else if (model.type === 'provider-model') { | |
| // Call provider model API | |
| response = await callProviderModelAPI(messages, model, onStream); | |
| } else { | |
| // Call custom model API | |
| response = await callCustomModelAPI(messages, model, onStream); | |
| } | |
| // Hide loading indicator and show content | |
| loadingIndicator.classList.add('hidden'); | |
| contentElement.classList.remove('hidden'); | |
| // Ensure we have the latest chat reference | |
| chatIndex = chats.findIndex(c => c.id === currentChatId); | |
| if (chatIndex === -1) return; | |
| currentChat = chats[chatIndex]; | |
| // Update the assistant message in the chat | |
| const assistantMessage = { | |
| role: 'assistant', | |
| content: response, | |
| timestamp: new Date().toISOString() | |
| }; | |
| // Replace the last message (which was the loading one) with the actual response | |
| currentChat.messages.push(assistantMessage); | |
| saveChats(); | |
| // Update chat list | |
| renderChatList(); | |
| // Force re-render of messages to ensure UI is in sync | |
| renderMessages(currentChat.messages); | |
| } catch (error) { | |
| console.error('API Error:', error); | |
| // Remove the message element if there was an error | |
| messageElement.remove(); | |
| // Show error message | |
| const errorElement = document.createElement('div'); | |
| errorElement.className = 'flex gap-4 justify-start'; | |
| errorElement.innerHTML = ` | |
| <div class="flex-shrink-0 w-8 h-8 rounded-full bg-red-500 text-white flex items-center justify-center"> | |
| <i class="fas fa-exclamation-triangle"></i> | |
| </div> | |
| <div class="max-w-[80%] bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm"> | |
| <div class="text-red-500">Error: ${error.message}</div> | |
| </div> | |
| `; | |
| chatArea.appendChild(errorElement); | |
| chatArea.scrollTop = chatArea.scrollHeight; | |
| } | |
| } | |
| // Call OpenAI API | |
| async function callOpenAIAPI(messages, model, apiKey, onStream) { | |
| const isStreaming = model.streaming || false; | |
| // Get current chat settings | |
| const chat = chats.find(c => c.id === currentChatId); | |
| const settings = chat?.settings || defaultChatSettings; | |
| // Limit messages to history count | |
| const limitedMessages = messages.slice(-settings.historyCount); | |
| // Add system message if provided | |
| if (settings.systemMessage) { | |
| limitedMessages.unshift({ | |
| role: 'system', | |
| content: settings.systemMessage | |
| }); | |
| } | |
| const response = await fetch('https://api.openai.com/v1/chat/completions', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Authorization': `Bearer ${apiKey || localStorage.getItem('openaiApiKey')}` | |
| }, | |
| body: JSON.stringify({ | |
| model: model === 'gpt-3.5' ? 'gpt-3.5-turbo' : 'gpt-4', | |
| messages: limitedMessages, | |
| temperature: settings.temperature, | |
| stream: isStreaming | |
| }) | |
| }); | |
| if (!response.ok) { | |
| const errorData = await response.json(); | |
| throw new Error(errorData.error?.message || 'Failed to call OpenAI API'); | |
| } | |
| if (isStreaming && onStream) { | |
| const reader = response.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let result = ''; | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| const chunk = decoder.decode(value); | |
| const lines = chunk.split('\n').filter(line => line.trim() !== ''); | |
| for (const line of lines) { | |
| if (line.startsWith('data: ')) { | |
| const data = line.substring(6); | |
| if (data === '[DONE]') continue; | |
| try { | |
| const parsed = JSON.parse(data); | |
| const content = parsed.choices?.[0]?.delta?.content || ''; | |
| result += content; | |
| onStream(result); | |
| } catch (e) { | |
| console.error('Error parsing stream data:', e); | |
| } | |
| } | |
| } | |
| } | |
| return result; | |
| } else { | |
| const data = await response.json(); | |
| return data.choices[0]?.message?.content || 'No response from model'; | |
| } | |
| } | |
| // Call provider model API | |
| async function callProviderModelAPI(messages, model, onStream) { | |
| const endpoint = `${model.baseUrl}/chat/completions`; | |
| if (model.streaming) { | |
| const response = await fetch(endpoint, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| ...(model.apiKey ? { 'Authorization': `Bearer ${model.apiKey}` } : {}) | |
| }, | |
| body: JSON.stringify({ | |
| model: model.modelName, | |
| messages, | |
| temperature: 0.7, | |
| stream: true | |
| }) | |
| }); | |
| if (!response.ok) { | |
| const errorText = await response.text(); | |
| throw new Error(errorText || 'Failed to call provider model API'); | |
| } | |
| const reader = response.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let result = ''; | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| const chunk = decoder.decode(value); | |
| const lines = chunk.split('\n').filter(line => line.trim() !== ''); | |
| for (const line of lines) { | |
| if (line.startsWith('data: ')) { | |
| const data = line.substring(6); | |
| if (data === '[DONE]') continue; | |
| try { | |
| const parsed = JSON.parse(data); | |
| const content = parsed.choices?.[0]?.delta?.content || ''; | |
| result += content; | |
| if (onStream) onStream(result); | |
| } catch (e) { | |
| console.error('Error parsing stream data:', e); | |
| } | |
| } | |
| } | |
| } | |
| return result; | |
| } else { | |
| const response = await fetch(endpoint, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| ...(model.apiKey ? { 'Authorization': `Bearer ${model.apiKey}` } : {}) | |
| }, | |
| body: JSON.stringify({ | |
| model: model.modelName, | |
| messages, | |
| temperature: 0.7 | |
| }) | |
| }); | |
| if (!response.ok) { | |
| const errorText = await response.text(); | |
| throw new Error(errorText || 'Failed to call provider model API'); | |
| } | |
| const data = await response.json(); | |
| return data.choices?.[0]?.message?.content || data.response || 'No response from model'; | |
| } | |
| } | |
| // Call custom model API | |
| async function callCustomModelAPI(messages, model) { | |
| if (model.streaming) { | |
| // Handle streaming response | |
| const response = await fetch(model.endpoint, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| ...(model.key ? { 'Authorization': `Bearer ${model.key}` } : {}) | |
| }, | |
| body: JSON.stringify({ | |
| model: model.name, | |
| messages, | |
| temperature: 0.7, | |
| stream: true | |
| }) | |
| }); | |
| if (!response.ok) { | |
| const errorText = await response.text(); | |
| throw new Error(errorText || 'Failed to call custom model API'); | |
| } | |
| const reader = response.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let result = ''; | |
| // Create message element for streaming | |
| const messageElement = document.createElement('div'); | |
| messageElement.className = 'flex gap-4 justify-start'; | |
| messageElement.innerHTML = ` | |
| <div class="flex-shrink-0 w-8 h-8 rounded-full bg-primary-500 text-white flex items-center justify-center"> | |
| <i class="fas fa-robot"></i> | |
| </div> | |
| <div class="max-w-[80%] bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm relative group"> | |
| <div class="prose dark:prose-invert" id="streaming-content"></div> | |
| </div> | |
| `; | |
| chatArea.appendChild(messageElement); | |
| const contentElement = messageElement.querySelector('#streaming-content'); | |
| try { | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| const chunk = decoder.decode(value); | |
| const lines = chunk.split('\n').filter(line => line.trim() !== ''); | |
| for (const line of lines) { | |
| if (line.startsWith('data: ')) { | |
| const data = line.substring(6); | |
| if (data === '[DONE]') continue; | |
| try { | |
| const parsed = JSON.parse(data); | |
| const content = parsed.choices?.[0]?.delta?.content || ''; | |
| result += content; | |
| // Process markdown in streaming response | |
| contentElement.innerHTML = marked.parse(result); | |
| chatArea.scrollTop = chatArea.scrollHeight; | |
| } catch (e) { | |
| console.error('Error parsing stream data:', e); | |
| } | |
| } | |
| } | |
| } | |
| } catch (e) { | |
| console.error('Stream error:', e); | |
| throw e; | |
| } | |
| return result; | |
| } else { | |
| // Handle regular response | |
| const response = await fetch(model.endpoint, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| ...(model.key ? { 'Authorization': `Bearer ${model.key}` } : {}) | |
| }, | |
| body: JSON.stringify({ | |
| model: model.name, | |
| messages, | |
| temperature: 0.7, | |
| stream: false | |
| }) | |
| }); | |
| if (!response.ok) { | |
| try { | |
| const errorData = await response.json(); | |
| throw new Error(errorData.error?.message || 'Failed to call custom model API'); | |
| } catch (e) { | |
| const errorText = await response.text(); | |
| throw new Error(errorText || 'Failed to call custom model API'); | |
| } | |
| } | |
| try { | |
| const data = await response.json(); | |
| return data.choices[0]?.message?.content || data.response || 'No response from model'; | |
| } catch (e) { | |
| const text = await response.text(); | |
| return text || 'No response from model'; | |
| } | |
| } | |
| } | |
| // Select model | |
| function selectModel(modelId) { | |
| // Ensure models array is up to date | |
| const savedCustomModels = localStorage.getItem('customModels'); | |
| if (savedCustomModels) { | |
| customModels = JSON.parse(savedCustomModels); | |
| // Update models array with latest custom models | |
| models = models.filter(m => m.type !== 'custom'); | |
| customModels.forEach(model => { | |
| models.push({ | |
| id: model.id, | |
| name: model.name, | |
| endpoint: model.endpoint, | |
| key: model.key, | |
| streaming: model.streaming || false, | |
| type: 'custom' | |
| }); | |
| }); | |
| } | |
| const model = models.find(m => m.id === modelId); | |
| if (!model) return; | |
| selectedModel = modelId; | |
| currentModel.textContent = model.name; | |
| modelDropdown.classList.add('hidden'); | |
| // Save to localStorage - include both model ID and name for provider models | |
| const modelData = { | |
| id: modelId, | |
| name: model.name, | |
| type: model.type | |
| }; | |
| localStorage.setItem('selectedModel', JSON.stringify(modelData)); | |
| // Update current chat's model if one is active | |
| if (currentChatId) { | |
| const chat = chats.find(c => c.id === currentChatId); | |
| if (chat) { | |
| chat.model = modelId; | |
| chat.modelData = { | |
| id: modelId, | |
| name: model.name | |
| }; | |
| saveChats(); | |
| } | |
| } | |
| } | |
| // Add custom model | |
| function addCustomModel(name, endpoint, key, streaming) { | |
| const newModel = { | |
| id: `custom-${Date.now()}`, | |
| name, | |
| endpoint, | |
| key, | |
| streaming: streaming || false, | |
| type: 'custom' | |
| }; | |
| customModels.push(newModel); | |
| models.push(newModel); | |
| // Save to localStorage | |
| localStorage.setItem('customModels', JSON.stringify(customModels)); | |
| // Update UI | |
| renderCustomModels(); | |
| hideAddModelModal(); | |
| } | |
| // Edit custom model | |
| function editCustomModel(modelId, name, endpoint, key, streaming) { | |
| const modelIndex = customModels.findIndex(m => m.id === modelId); | |
| if (modelIndex === -1) return; | |
| customModels[modelIndex] = { | |
| ...customModels[modelIndex], | |
| name, | |
| endpoint, | |
| key, | |
| streaming: streaming || false | |
| }; | |
| // Update in models array | |
| const globalModelIndex = models.findIndex(m => m.id === modelId); | |
| if (globalModelIndex !== -1) { | |
| models[globalModelIndex] = { | |
| ...models[globalModelIndex], | |
| name, | |
| endpoint, | |
| key | |
| }; | |
| } | |
| // Save to localStorage | |
| localStorage.setItem('customModels', JSON.stringify(customModels)); | |
| // Update UI | |
| renderCustomModels(); | |
| hideAddModelModal(); | |
| } | |
| // Edit custom provider | |
| function editCustomProvider(providerId, name, baseUrl, apiKey, streaming) { | |
| const providerIndex = customProviders.findIndex(p => p.id === providerId); | |
| if (providerIndex === -1) return; | |
| const updatedProvider = { | |
| ...customProviders[providerIndex], | |
| name, | |
| baseUrl, | |
| apiKey, | |
| streaming: streaming || false | |
| }; | |
| customProviders[providerIndex] = updatedProvider; | |
| // Update in models array | |
| const globalModelIndex = models.findIndex(m => m.id === providerId); | |
| if (globalModelIndex !== -1) { | |
| models[globalModelIndex] = { | |
| ...models[globalModelIndex], | |
| name: updatedProvider.name, | |
| baseUrl: updatedProvider.baseUrl, | |
| apiKey: updatedProvider.apiKey, | |
| streaming: updatedProvider.streaming | |
| }; | |
| } | |
| // Also update any provider models that use this provider | |
| models.forEach((model, index) => { | |
| if (model.type === 'provider-model' && model.id.startsWith(providerId)) { | |
| models[index] = { | |
| ...model, | |
| baseUrl: updatedProvider.baseUrl, | |
| apiKey: updatedProvider.apiKey, | |
| streaming: updatedProvider.streaming | |
| }; | |
| } | |
| }); | |
| localStorage.setItem('customProviders', JSON.stringify(customProviders)); | |
| localStorage.setItem('models', JSON.stringify(models)); | |
| renderCustomProviders(); | |
| hideAddProviderModal(); | |
| } | |
| // Add custom provider | |
| function addCustomProvider(name, baseUrl, apiKey, streaming) { | |
| const newProvider = { | |
| id: `provider-${Date.now()}`, | |
| name, | |
| baseUrl, | |
| apiKey, | |
| streaming: streaming || false | |
| }; | |
| customProviders.push(newProvider); | |
| models.push({ | |
| id: newProvider.id, | |
| name: newProvider.name, | |
| type: 'provider', | |
| baseUrl: newProvider.baseUrl, | |
| apiKey: newProvider.apiKey, | |
| streaming: newProvider.streaming | |
| }); | |
| localStorage.setItem('customProviders', JSON.stringify(customProviders)); | |
| localStorage.setItem('models', JSON.stringify(models)); | |
| renderCustomProviders(); | |
| } | |
| // Delete custom provider | |
| function deleteCustomProvider(providerId) { | |
| customProviders = customProviders.filter(p => p.id !== providerId); | |
| models = models.filter(m => m.id !== providerId); | |
| localStorage.setItem('customProviders', JSON.stringify(customProviders)); | |
| renderCustomProviders(); | |
| if (selectedModel === providerId) { | |
| selectModel('gpt-4'); | |
| } | |
| } | |
| // Show provider models | |
| async function showProviderModels(provider) { | |
| currentProvider = provider; | |
| document.getElementById('providerModelsTitle').textContent = `${provider.name} Models`; | |
| document.getElementById('providerModelsList').innerHTML = '<div class="text-center py-4">Loading models...</div>'; | |
| providerModelsModal.classList.remove('hidden'); | |
| try { | |
| const response = await fetch(`${provider.baseUrl}/models`, { | |
| headers: { | |
| 'Authorization': `Bearer ${provider.apiKey || ''}` | |
| } | |
| }); | |
| if (!response.ok) { | |
| throw new Error('Failed to fetch models'); | |
| } | |
| const data = await response.json(); | |
| const modelsList = document.getElementById('providerModelsList'); | |
| modelsList.innerHTML = ''; | |
| // OpenAI models endpoint returns { data: [ ... ] } | |
| if (data.data && data.data.length > 0) { | |
| data.data.forEach(modelObj => { | |
| const modelElement = document.createElement('div'); | |
| modelElement.className = 'model-option cursor-pointer px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded'; | |
| modelElement.textContent = modelObj.id; | |
| modelElement.addEventListener('click', () => { | |
| // Create a model entry for this provider model | |
| const providerModel = { | |
| id: `${currentProvider.id}-${modelObj.id}`, | |
| name: `${currentProvider.name} - ${modelObj.id}`, | |
| type: 'provider-model', | |
| baseUrl: currentProvider.baseUrl, | |
| apiKey: currentProvider.apiKey, | |
| modelName: modelObj.id, | |
| streaming: currentProvider.streaming || false | |
| }; | |
| // Add or update in models array | |
| const existingIndex = models.findIndex(m => m.id === providerModel.id); | |
| if (existingIndex !== -1) { | |
| models[existingIndex] = providerModel; | |
| } else { | |
| models.push(providerModel); | |
| } | |
| selectModel(providerModel.id); | |
| providerModelsModal.classList.add('hidden'); | |
| }); | |
| modelsList.appendChild(modelElement); | |
| }); | |
| } else { | |
| modelsList.innerHTML = '<div class="text-center py-4 text-gray-500">No models available</div>'; | |
| } | |
| } catch (error) { | |
| document.getElementById('providerModelsList').innerHTML = ` | |
| <div class="text-center py-4 text-red-500"> | |
| Error loading models: ${error.message} | |
| </div> | |
| `; | |
| } | |
| } | |
| // Delete custom model | |
| function deleteCustomModel(modelId) { | |
| customModels = customModels.filter(m => m.id !== modelId); | |
| models = models.filter(m => m.id !== modelId); | |
| // Save to localStorage | |
| localStorage.setItem('customModels', JSON.stringify(customModels)); | |
| // Update UI | |
| renderCustomModels(); | |
| // If the deleted model was selected, switch to default | |
| if (selectedModel === modelId) { | |
| selectModel('gpt-4'); | |
| } | |
| } | |
| // Delete chat | |
| function deleteChat(chatId) { | |
| chats = chats.filter(c => c.id !== chatId); | |
| saveChats(); | |
| if (currentChatId === chatId) { | |
| currentChatId = null; | |
| chatArea.innerHTML = ''; | |
| welcomeMessage.classList.remove('hidden'); | |
| } | |
| renderChatList(); | |
| hideDeleteModal(); | |
| } | |
| // Show delete modal | |
| function showDeleteModal(chatId) { | |
| chatToDelete = chatId; | |
| deleteChatModal.classList.remove('hidden'); | |
| } | |
| // Hide delete modal | |
| function hideDeleteModal() { | |
| deleteChatModal.classList.add('hidden'); | |
| chatToDelete = null; | |
| } | |
| // Show add model modal | |
| function showAddModelModal() { | |
| addModelModal.classList.remove('hidden'); | |
| } | |
| // Show edit model modal | |
| function showEditModelModal(model) { | |
| document.getElementById('modelName').value = model.name; | |
| document.getElementById('modelEndpoint').value = model.endpoint; | |
| document.getElementById('modelKey').value = model.key || ''; | |
| document.getElementById('modelStreaming').checked = model.streaming || false; | |
| document.getElementById('editModelId').value = model.id; | |
| // Change modal title and submit button text | |
| document.querySelector('#addModelModal h3').textContent = 'Edit Custom Model'; | |
| document.querySelector('#addModelForm button[type="submit"]').textContent = 'Save Changes'; | |
| showAddModelModal(); | |
| } | |
| // Hide add model modal | |
| function hideAddModelModal() { | |
| addModelModal.classList.add('hidden'); | |
| addModelForm.reset(); | |
| // Reset modal title and submit button text | |
| document.querySelector('#addModelModal h3').textContent = 'Add Custom Model'; | |
| document.querySelector('#addModelForm button[type="submit"]').textContent = 'Add Model'; | |
| } | |
| // Save chats to localStorage | |
| function saveChats() { | |
| localStorage.setItem('chats', JSON.stringify(chats)); | |
| } | |
| // Toggle dark mode | |
| function toggleDarkMode() { | |
| const isDark = document.documentElement.classList.toggle('dark'); | |
| localStorage.setItem('darkMode', isDark); | |
| // Sync settings toggle if it exists | |
| const darkModeToggle = document.getElementById('darkModeToggleSetting'); | |
| if (darkModeToggle) { | |
| darkModeToggle.checked = isDark; | |
| } | |
| } | |
| // Toggle sidebar on mobile | |
| function toggleSidebar() { | |
| document.querySelector('.w-64').classList.toggle('hidden'); | |
| document.querySelector('.w-64').classList.toggle('block'); | |
| } | |
| // Show API key modal | |
| function showApiKeyModal() { | |
| apiKeyModal.classList.remove('hidden'); | |
| } | |
| // Hide API key modal | |
| function hideApiKeyModal() { | |
| apiKeyModal.classList.add('hidden'); | |
| apiKeyInput.value = ''; | |
| } | |
| // Save API key | |
| function saveApiKey(key) { | |
| localStorage.setItem('openaiApiKey', key); | |
| hideApiKeyModal(); | |
| } | |
| // Show add provider modal | |
| function showAddProviderModal(provider = null) { | |
| // Reset form first | |
| const form = document.getElementById('addProviderForm'); | |
| form.reset(); | |
| document.getElementById('editProviderId').value = ''; | |
| if (provider) { | |
| // Editing existing provider | |
| document.getElementById('providerName').value = provider.name; | |
| document.getElementById('providerBaseUrl').value = provider.baseUrl; | |
| document.getElementById('providerApiKey').value = provider.apiKey || ''; | |
| document.getElementById('providerStreaming').checked = provider.streaming || false; | |
| document.getElementById('editProviderId').value = provider.id; | |
| // Update UI for edit mode | |
| document.querySelector('#addProviderModal h3').textContent = 'Edit Provider'; | |
| document.querySelector('#addProviderForm button[type="submit"]').textContent = 'Save Changes'; | |
| } else { | |
| // Creating new provider | |
| document.querySelector('#addProviderModal h3').textContent = 'Add Custom Provider'; | |
| document.querySelector('#addProviderForm button[type="submit"]').textContent = 'Add Provider'; | |
| document.getElementById('editProviderId').value = ''; | |
| } | |
| addProviderModal.classList.remove('hidden'); | |
| } | |
| // Hide add provider modal | |
| function hideAddProviderModal() { | |
| addProviderModal.classList.add('hidden'); | |
| // Don't reset form here - let the submit handler or showAddProviderModal handle it | |
| } | |
| // Event Listeners | |
| closeApiKeyModal.addEventListener('click', hideApiKeyModal); | |
| // Chat settings events | |
| document.getElementById('chatSettingsBtn').addEventListener('click', showChatSettingsModal); | |
| document.getElementById('closeChatSettingsModal').addEventListener('click', hideChatSettingsModal); | |
| document.getElementById('cancelChatSettings').addEventListener('click', hideChatSettingsModal); | |
| document.getElementById('chatSettingsForm').addEventListener('submit', (e) => { | |
| e.preventDefault(); | |
| saveChatSettings(); | |
| }); | |
| // Default chat settings events | |
| document.getElementById('defaultChatSettingsForm').addEventListener('submit', (e) => { | |
| e.preventDefault(); | |
| saveDefaultChatSettings(); | |
| }); | |
| // Initialize default settings form | |
| document.getElementById('defaultTemperature').value = defaultChatSettings.temperature; | |
| document.getElementById('defaultHistoryCount').value = defaultChatSettings.historyCount; | |
| document.getElementById('defaultSystemMessage').value = defaultChatSettings.systemMessage; | |
| // Provider modal events | |
| document.getElementById('addProviderBtn').addEventListener('click', () => showAddProviderModal(null)); | |
| document.getElementById('closeProviderModal').addEventListener('click', hideAddProviderModal); | |
| document.getElementById('cancelAddProvider').addEventListener('click', hideAddProviderModal); | |
| document.getElementById('addProviderForm').addEventListener('submit', (e) => { | |
| e.preventDefault(); | |
| const name = document.getElementById('providerName').value.trim(); | |
| const baseUrl = document.getElementById('providerBaseUrl').value.trim(); | |
| const apiKey = document.getElementById('providerApiKey').value.trim(); | |
| const streaming = document.getElementById('providerStreaming').checked; | |
| const editProviderId = document.getElementById('editProviderId').value; | |
| if (!name || !baseUrl) { | |
| alert('Provider name and base URL are required'); | |
| return; | |
| } | |
| // Clear any previous error state | |
| const errorElements = document.querySelectorAll('.provider-error'); | |
| errorElements.forEach(el => el.remove()); | |
| // Validate base URL format | |
| try { | |
| new URL(baseUrl); | |
| } catch (e) { | |
| const baseUrlInput = document.getElementById('providerBaseUrl'); | |
| const errorElement = document.createElement('p'); | |
| errorElement.className = 'text-red-500 text-xs mt-1 provider-error'; | |
| errorElement.textContent = 'Please enter a valid URL (e.g. https://api.example.com)'; | |
| baseUrlInput.parentNode.appendChild(errorElement); | |
| return; | |
| } | |
| if (editProviderId) { | |
| editCustomProvider(editProviderId, name, baseUrl, apiKey, streaming); | |
| } else { | |
| addCustomProvider(name, baseUrl, apiKey, streaming); | |
| } | |
| }); | |
| // Provider models modal events | |
| document.getElementById('closeProviderModelsModal').addEventListener('click', () => { | |
| providerModelsModal.classList.add('hidden'); | |
| }); | |
| document.getElementById('cancelProviderModels').addEventListener('click', () => { | |
| providerModelsModal.classList.add('hidden'); | |
| }); | |
| cancelApiKey.addEventListener('click', hideApiKeyModal); | |
| apiKeyForm.addEventListener('submit', (e) => { | |
| e.preventDefault(); | |
| const key = apiKeyInput.value.trim(); | |
| if (key) { | |
| saveApiKey(key); | |
| // Retry sending the message | |
| setTimeout(() => { | |
| sendMessage(); | |
| }, 100); | |
| } | |
| }); | |
| sidebarToggle.addEventListener('click', toggleSidebar); | |
| darkModeToggle.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| toggleDarkMode(); | |
| }); | |
| newChatBtn.addEventListener('click', (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| hideSettingsPage(); | |
| createNewChat(); | |
| // Hide welcome message when new chat is created | |
| welcomeMessage.classList.add('hidden'); | |
| // Close sidebar on mobile after creating new chat | |
| if (window.innerWidth < 768) { | |
| document.querySelector('.w-64').classList.add('hidden'); | |
| } | |
| }); | |
| quickStartBtn.addEventListener('click', (e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| hideSettingsPage(); | |
| createNewChat(); | |
| // Hide welcome message when quick start is clicked | |
| welcomeMessage.classList.add('hidden'); | |
| // Close sidebar on mobile after quick start | |
| if (window.innerWidth < 768) { | |
| document.querySelector('.w-64').classList.add('hidden'); | |
| } | |
| }); | |
| messageForm.addEventListener('submit', (e) => { | |
| e.preventDefault(); | |
| sendMessage(); | |
| }); | |
| messageInput.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter') { | |
| // Check for Ctrl+Enter or Command+Enter (Mac) | |
| if (e.ctrlKey || e.metaKey) { | |
| // Insert new line | |
| const start = messageInput.selectionStart; | |
| const end = messageInput.selectionEnd; | |
| messageInput.value = messageInput.value.substring(0, start) + '\n' + messageInput.value.substring(end); | |
| messageInput.selectionStart = messageInput.selectionEnd = start + 1; | |
| // Prevent default to avoid submitting | |
| e.preventDefault(); | |
| } else if (!e.shiftKey) { | |
| // Regular Enter - submit form | |
| e.preventDefault(); | |
| sendMessage(); | |
| } | |
| } | |
| }); | |
| // Auto-resize textarea | |
| messageInput.addEventListener('input', () => { | |
| messageInput.style.height = 'auto'; | |
| messageInput.style.height = `${messageInput.scrollHeight}px`; | |
| }); | |
| // Model dropdown | |
| modelDropdownBtn.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| modelDropdown.classList.toggle('hidden'); | |
| }); | |
| // Close dropdown when clicking outside | |
| document.addEventListener('click', (e) => { | |
| if (!modelDropdown.contains(e.target) && e.target !== modelDropdownBtn && !e.target.closest('.model-option')) { | |
| modelDropdown.classList.add('hidden'); | |
| } | |
| }); | |
| // Handle model selection - delegated event listener | |
| document.addEventListener('click', (e) => { | |
| const modelOption = e.target.closest('.model-option'); | |
| if (modelOption && !e.target.classList.contains('delete-model')) { | |
| const modelId = modelOption.dataset.model; | |
| selectModel(modelId); | |
| } | |
| }); | |
| // Add model | |
| addModelBtn.addEventListener('click', showAddModelModal); | |
| closeModelModal.addEventListener('click', hideAddModelModal); | |
| cancelAddModel.addEventListener('click', hideAddModelModal); | |
| addModelForm.addEventListener('submit', (e) => { | |
| e.preventDefault(); | |
| const name = document.getElementById('modelName').value; | |
| const endpoint = document.getElementById('modelEndpoint').value; | |
| const key = document.getElementById('modelKey').value; | |
| const streaming = document.getElementById('modelStreaming').checked; | |
| const editModelId = document.getElementById('editModelId').value; | |
| if (editModelId) { | |
| editCustomModel(editModelId, name, endpoint, key, streaming); | |
| } else { | |
| addCustomModel(name, endpoint, key); | |
| } | |
| }); | |
| // Delete chat | |
| closeDeleteModal.addEventListener('click', hideDeleteModal); | |
| cancelDelete.addEventListener('click', hideDeleteModal); | |
| confirmDelete.addEventListener('click', () => { | |
| if (chatToDelete) { | |
| deleteChat(chatToDelete); | |
| } | |
| }); | |
| // Settings button | |
| document.getElementById('settingsBtn').addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| showSettingsPage(); | |
| }); | |
| // Back to chat button | |
| document.getElementById('backToChatBtn').addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| hideSettingsPage(); | |
| }); | |
| // Show settings page | |
| function showSettingsPage() { | |
| document.getElementById('settingsPage').classList.remove('hidden'); | |
| document.body.classList.add('overflow-hidden'); | |
| // Load current settings | |
| const apiKey = localStorage.getItem('openaiApiKey') || ''; | |
| document.getElementById('settingsApiKey').value = apiKey; | |
| // Set dark mode toggle state | |
| const isDark = document.documentElement.classList.contains('dark'); | |
| document.getElementById('darkModeToggleSetting').checked = isDark; | |
| // Update state to remember we're in settings | |
| localStorage.setItem('inSettings', 'true'); | |
| } | |
| // Hide settings page | |
| function hideSettingsPage() { | |
| document.getElementById('settingsPage').classList.add('hidden'); | |
| document.body.classList.remove('overflow-hidden'); | |
| localStorage.removeItem('inSettings'); | |
| } | |
| // Check if we should show settings on load | |
| if (localStorage.getItem('inSettings') === 'true') { | |
| showSettingsPage(); | |
| } | |
| // Toggle API key visibility | |
| document.getElementById('toggleApiKeyVisibility').addEventListener('click', function() { | |
| const input = document.getElementById('settingsApiKey'); | |
| const icon = this.querySelector('i'); | |
| if (input.type === 'password') { | |
| input.type = 'text'; | |
| icon.classList.replace('fa-eye', 'fa-eye-slash'); | |
| } else { | |
| input.type = 'password'; | |
| icon.classList.replace('fa-eye-slash', 'fa-eye'); | |
| } | |
| }); | |
| // Save API key from settings | |
| document.getElementById('apiKeySettingsForm').addEventListener('submit', function(e) { | |
| e.preventDefault(); | |
| const key = document.getElementById('settingsApiKey').value.trim(); | |
| saveApiKey(key); | |
| // Show success feedback | |
| const submitBtn = this.querySelector('button[type="submit"]'); | |
| const originalText = submitBtn.textContent; | |
| submitBtn.textContent = 'Saved!'; | |
| submitBtn.classList.remove('bg-primary-500', 'hover:bg-primary-600'); | |
| submitBtn.classList.add('bg-green-500', 'hover:bg-green-600'); | |
| setTimeout(() => { | |
| submitBtn.textContent = originalText; | |
| submitBtn.classList.remove('bg-green-500', 'hover:bg-green-600'); | |
| submitBtn.classList.add('bg-primary-500', 'hover:bg-primary-600'); | |
| }, 2000); | |
| }); | |
| // Dark mode toggle in settings | |
| document.getElementById('darkModeToggleSetting').addEventListener('change', function() { | |
| toggleDarkMode(); | |
| }); | |
| // Handle message actions | |
| function handleMessageAction(action, messageIndex) { | |
| if (!currentChatId) return; | |
| const chat = chats.find(c => c.id === currentChatId); | |
| if (!chat || !chat.messages[messageIndex]) return; | |
| if (action === 'regenerate') { | |
| // Only regenerate if this is the last assistant message | |
| if (messageIndex === chat.messages.length - 1 && | |
| chat.messages[messageIndex].role === 'assistant') { | |
| chat.messages.splice(messageIndex, 1); | |
| renderMessages(chat.messages); | |
| // Resend last user message | |
| const lastUserMessage = chat.messages[chat.messages.length - 1]; | |
| if (lastUserMessage && lastUserMessage.role === 'user') { | |
| sendMessage(lastUserMessage.content); | |
| } | |
| } | |
| } else if (action === 'edit') { | |
| // TODO: Implement edit functionality | |
| alert('Edit functionality will be implemented here'); | |
| } | |
| } | |
| // Initialize the app | |
| init(); | |
| // Add delegated event listener for message actions | |
| document.addEventListener('click', (e) => { | |
| const actionBtn = e.target.closest('.message-action'); | |
| if (actionBtn) { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| const messageElement = actionBtn.closest('.flex.gap-4'); | |
| const messageIndex = Array.from(messageElement.parentNode.children).indexOf(messageElement); | |
| const action = actionBtn.dataset.action; | |
| handleMessageAction(action, messageIndex); | |
| } | |
| }); | |
| </script> | |
| <p style="border-radius: 8px; text-align: center; font-size: 12px; color: #fff; margin-top: 16px;position: fixed; left: 8px; bottom: 8px; z-index: 10; background: rgba(0, 0, 0, 0.8); padding: 4px 8px;">Made with <img src="https://enzostvs-deepsite.hf.space/logo.svg" alt="DeepSite Logo" style="width: 16px; height: 16px; vertical-align: middle;display:inline-block;margin-right:3px;filter:brightness(0) invert(1);"><a href="https://enzostvs-deepsite.hf.space" style="color: #fff;text-decoration: underline;" target="_blank" >DeepSite</a> - 🧬 <a href="https://enzostvs-deepsite.hf.space?remix=kokofixcomputers/nexuschat" style="color: #fff;text-decoration: underline;" target="_blank" >Remix</a></p></body> | |
| </html> |