Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <meta name="twitter:card" content="player"/> | |
| <meta name="twitter:site" content=""/> | |
| <meta name="twitter:player" content="https://broadfield-dev-or-chatbot.static.hf.space/index.html"/> | |
| <meta name="twitter:player:stream" content="https://broadfield-dev-or-chatbot.static.hf.space/index.html"/> | |
| <meta name="twitter:player:width" content="100%"/> | |
| <meta name="twitter:player:height" content="100%"/> | |
| <meta property="og:title" content="Single-file HTML Chatbot"/> | |
| <meta property="og:description" content="Interactive X App"/> | |
| <meta property="og:image" content="https://huggingface.co/spaces/Space-Share/iLearn/resolve/main/card_im_crop.png"/> | |
| <title>OpenRouter Chatbot</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/font-awesome/6.6.0/css/all.min.css"> | |
| <style> | |
| body { font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } | |
| .user-msg { background: #3b82f6; border-radius: 18px 18px 4px 18px; } | |
| .assistant-msg { background: #1f2937; border-radius: 18px 18px 18px 4px; } | |
| .prose { max-width: none; } | |
| .prose pre { background: #111827; padding: 1rem; border-radius: 8px; overflow-x: auto; } | |
| .prose code { font-size: 0.9em; background: #374151; padding: 2px 4px; border-radius: 4px; } | |
| </style> | |
| </head> | |
| <body class="bg-gray-950 text-gray-100 h-screen flex"> | |
| <!-- Sidebar --> | |
| <div class="w-80 bg-gray-900 border-r border-gray-700 flex flex-col"> | |
| <div class="p-4 border-b border-gray-700"> | |
| <h1 class="text-2xl font-bold flex items-center gap-2"> | |
| <i class="fas fa-robot text-blue-500"></i> OR Chat | |
| </h1> | |
| </div> | |
| <div class="p-4"> | |
| <button onclick="newChat()" | |
| class="w-full bg-blue-600 hover:bg-blue-700 py-3 rounded-2xl font-medium flex items-center justify-center gap-2"> | |
| <i class="fas fa-plus"></i> New Chat | |
| </button> | |
| </div> | |
| <div class="px-4 text-xs uppercase tracking-widest text-gray-500 mb-2">Conversations</div> | |
| <div id="chatHistory" class="flex-1 overflow-auto px-2 space-y-1"></div> | |
| <div class="p-4 border-t border-gray-700 space-y-4"> | |
| <div> | |
| <label class="text-sm block mb-1">OpenRouter API Key</label> | |
| <input id="apiKey" type="password" class="w-full bg-gray-800 border border-gray-600 rounded-xl px-4 py-2 text-sm" placeholder="disabled for demo" disabled> | |
| <button onclick="saveApiKey()" class="mt-2 w-full bg-gray-700 hover:bg-gray-600 py-2 rounded-xl text-sm">Save Key</button> | |
| </div> | |
| <div> | |
| <div class="flex justify-between items-center mb-1"> | |
| <label class="text-sm">Model</label> | |
| <button onclick="fetchModels()" class="text-blue-400 hover:text-blue-300 text-xs"> | |
| <i class="fas fa-sync"></i> Refresh | |
| </button> | |
| </div> | |
| <select id="modelSelect" onchange="handleModelChange()" | |
| class="w-full bg-gray-800 border border-gray-600 rounded-xl px-4 py-2 text-sm"> | |
| </select> | |
| </div> | |
| <div> | |
| <div class="flex justify-between text-sm mb-2"> | |
| <span>Tools</span> | |
| <button onclick="document.getElementById('toolFileInput').click()" | |
| class="text-violet-400 hover:text-violet-300 text-xs" disabled>+ Load MCP</button> | |
| </div> | |
| <input type="file" id="toolFileInput" multiple accept=".json" class="hidden" onchange="handleToolFiles(event)"> | |
| <div id="toolsList" class="text-xs bg-gray-800 rounded-xl p-3 max-h-40 overflow-auto"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Main Chat --> | |
| <div class="flex-1 flex flex-col"> | |
| <div class="h-14 bg-gray-900 border-b border-gray-700 flex items-center px-6 justify-between"> | |
| <div id="currentModelHeader" class="font-medium text-gray-200"></div> | |
| <div class="flex gap-4 text-gray-400"> | |
| <button onclick="exportChat()" title="Export Chat"><i class="fas fa-download"></i></button> | |
| <button onclick="deleteCurrentChat()" title="Delete Chat"><i class="fas fa-trash"></i></button> | |
| </div> | |
| </div> | |
| <div id="messages" class="flex-1 overflow-y-auto p-6 space-y-6 bg-gray-950"></div> | |
| <div class="p-6 bg-gray-900 border-t border-gray-700"> | |
| <div class="flex gap-3"> | |
| <textarea id="userInput" rows="3" | |
| class="flex-1 bg-gray-800 border border-gray-600 rounded-3xl px-5 py-4 focus:outline-none focus:border-blue-500 resize-y" | |
| placeholder="Send a message..."></textarea> | |
| <button onclick="sendMessage()" | |
| class="bg-blue-600 hover:bg-blue-700 w-12 h-12 rounded-3xl flex items-center justify-center self-end"> | |
| <i class="fas fa-paper-plane"></i> | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="toast" class="hidden fixed bottom-6 right-6 bg-gray-800 border border-gray-600 rounded-2xl px-6 py-3 shadow-2xl"></div> | |
| <script> | |
| let API_KEY = localStorage.getItem('or_api_key') || ''; | |
| let conversations = JSON.parse(localStorage.getItem('or_conversations') || '[]'); | |
| let currentChatId = null; | |
| let AVAILABLE_MODELS = []; | |
| let AVAILABLE_TOOLS = JSON.parse(localStorage.getItem('or_tools') || '[]'); | |
| async function init() { | |
| document.getElementById('apiKey').value = API_KEY; | |
| renderChatHistory(); | |
| if (conversations.length === 0) newChat(); | |
| else loadChat(conversations[0].id); | |
| await fetchModels(); | |
| } | |
| function saveApiKey() { | |
| API_KEY = document.getElementById('apiKey').value.trim(); | |
| localStorage.setItem('or_api_key', API_KEY); | |
| showToast('API Key saved'); | |
| } | |
| async function fetchModels() { | |
| if (!API_KEY) return showToast('Enter API Key first'); | |
| try { | |
| const res = await fetch('https://openrouter.ai/api/v1/models', { | |
| headers: { Authorization: `Bearer ${API_KEY}` } | |
| }); | |
| const data = await res.json(); | |
| AVAILABLE_MODELS = data.data || []; | |
| const select = document.getElementById('modelSelect'); | |
| select.innerHTML = AVAILABLE_MODELS | |
| .sort((a,b) => a.name.localeCompare(b.name)) | |
| .map(m => `<option value="${m.id}">${m.name} (${Math.round((m.context_length||0)/1000)}k)</option>`).join(''); | |
| if (currentChatId) { | |
| const chat = conversations.find(c => c.id === currentChatId); | |
| if (chat?.model) select.value = chat.model; | |
| } | |
| updateModelHeader(); | |
| } catch (e) { | |
| showToast('Failed to load models'); | |
| } | |
| } | |
| function handleModelChange() { | |
| updateModelHeader(); | |
| if (currentChatId) { | |
| const chat = conversations.find(c => c.id === currentChatId); | |
| if (chat) chat.model = document.getElementById('modelSelect').value; | |
| saveConversations(); | |
| } | |
| } | |
| function updateModelHeader() { | |
| const modelId = document.getElementById('modelSelect').value; | |
| const model = AVAILABLE_MODELS.find(m => m.id === modelId); | |
| document.getElementById('currentModelHeader').textContent = model ? model.name : modelId || 'No model selected'; | |
| } | |
| function newChat() { | |
| const chat = { | |
| id: 'chat_' + Date.now(), | |
| title: 'New Chat', | |
| model: document.getElementById('modelSelect').value || 'openai/gpt-4o-mini', | |
| messages: [], | |
| createdAt: new Date().toISOString() | |
| }; | |
| conversations.unshift(chat); | |
| saveConversations(); | |
| loadChat(chat.id); | |
| } | |
| function loadChat(id) { | |
| currentChatId = id; | |
| const chat = conversations.find(c => c.id === id); | |
| if (!chat) return; | |
| document.getElementById('modelSelect').value = chat.model || ''; | |
| updateModelHeader(); | |
| renderMessages(chat.messages); | |
| renderChatHistory(); | |
| } | |
| function renderChatHistory() { | |
| const container = document.getElementById('chatHistory'); | |
| container.innerHTML = conversations.map(chat => ` | |
| <div onclick="loadChat('${chat.id}')" | |
| class="px-4 py-3 rounded-2xl cursor-pointer hover:bg-gray-800 ${chat.id === currentChatId ? 'bg-gray-800' : ''}"> | |
| <div class="line-clamp-1">${chat.title}</div> | |
| <div class="text-xs text-gray-500">${new Date(chat.createdAt).toLocaleDateString()}</div> | |
| </div> | |
| `).join(''); | |
| } | |
| function renderMessages(messages) { | |
| const container = document.getElementById('messages'); | |
| container.innerHTML = messages.map(msg => ` | |
| <div class="flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}"> | |
| <div class="${msg.role === 'user' ? 'user-msg text-white' : 'assistant-msg'} max-w-[75%] px-6 py-4 prose prose-invert"> | |
| ${msg.role === 'user' ? msg.content : marked.parse(msg.content || '')} | |
| </div> | |
| </div> | |
| `).join(''); | |
| container.scrollTop = container.scrollHeight; | |
| } | |
| async function sendMessage() { | |
| const input = document.getElementById('userInput'); | |
| const text = input.value.trim(); | |
| if (!text || !currentChatId) return; | |
| const chat = conversations.find(c => c.id === currentChatId); | |
| chat.messages.push({ role: "user", content: text }); | |
| if (chat.messages.length === 2) { | |
| chat.title = text.slice(0, 40) + (text.length > 40 ? '...' : ''); | |
| } | |
| input.value = ''; | |
| renderMessages(chat.messages); | |
| renderChatHistory(); | |
| const model = document.getElementById('modelSelect').value; | |
| // Create assistant message container for streaming | |
| const messagesDiv = document.getElementById('messages'); | |
| const assistantWrapper = document.createElement('div'); | |
| assistantWrapper.className = 'flex justify-start'; | |
| assistantWrapper.innerHTML = ` | |
| <div class="assistant-msg max-w-[75%] px-6 py-4 prose prose-invert"> | |
| <span class="animate-pulse">Thinking...</span> | |
| </div> | |
| `; | |
| messagesDiv.appendChild(assistantWrapper); | |
| messagesDiv.scrollTop = messagesDiv.scrollHeight; | |
| let fullResponse = ''; | |
| try { | |
| const res = await fetch('https://openrouter.ai/api/v1/chat/completions', { | |
| method: 'POST', | |
| headers: { | |
| 'Authorization': `Bearer ${API_KEY}`, | |
| 'Content-Type': 'application/json', | |
| 'HTTP-Referer': window.location.href, | |
| 'X-Title': 'OR Chatbot' | |
| }, | |
| body: JSON.stringify({ | |
| model: model, | |
| messages: chat.messages, | |
| tools: AVAILABLE_TOOLS.length > 0 ? AVAILABLE_TOOLS : undefined, | |
| stream: true | |
| }) | |
| }); | |
| const reader = res.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| const chunk = decoder.decode(value); | |
| const lines = chunk.split('\n'); | |
| for (const line of lines) { | |
| if (line.startsWith('data: ') && !line.includes('[DONE]')) { | |
| try { | |
| const json = JSON.parse(line.slice(6)); | |
| const delta = json.choices[0]?.delta?.content || ''; | |
| if (delta) { | |
| fullResponse += delta; | |
| assistantWrapper.querySelector('.assistant-msg').innerHTML = marked.parse(fullResponse); | |
| messagesDiv.scrollTop = messagesDiv.scrollHeight; | |
| } | |
| } catch (e) {} | |
| } | |
| } | |
| } | |
| // Save final message | |
| chat.messages.push({ role: "assistant", content: fullResponse }); | |
| saveConversations(); | |
| } catch (err) { | |
| console.error(err); | |
| showToast('Request failed'); | |
| } | |
| } | |
| function saveConversations() { | |
| localStorage.setItem('or_conversations', JSON.stringify(conversations)); | |
| } | |
| function deleteCurrentChat() { | |
| if (!currentChatId || !confirm('Delete this conversation?')) return; | |
| conversations = conversations.filter(c => c.id !== currentChatId); | |
| saveConversations(); | |
| currentChatId = null; | |
| if (conversations.length) loadChat(conversations[0].id); | |
| else newChat(); | |
| } | |
| function exportChat() { | |
| const chat = conversations.find(c => c.id === currentChatId); | |
| if (!chat) return; | |
| const dataStr = JSON.stringify(chat, null, 2); | |
| const dataUri = 'data:application/json;charset=utf-8,' + encodeURIComponent(dataStr); | |
| const link = document.createElement('a'); | |
| link.href = dataUri; | |
| link.download = `${chat.title.replace(/[^a-z0-9]/gi, '_')}.json`; | |
| link.click(); | |
| } | |
| function handleToolFiles(e) { | |
| const files = Array.from(e.target.files); | |
| let added = 0; | |
| files.forEach(file => { | |
| const reader = new FileReader(); | |
| reader.onload = (ev) => { | |
| try { | |
| let tools = JSON.parse(ev.target.result); | |
| if (!Array.isArray(tools)) tools = [tools]; | |
| tools.forEach(tool => { | |
| if (tool.type === "function" && !AVAILABLE_TOOLS.some(t => t.function.name === tool.function.name)) { | |
| AVAILABLE_TOOLS.push(tool); | |
| added++; | |
| } | |
| }); | |
| localStorage.setItem('or_tools', JSON.stringify(AVAILABLE_TOOLS)); | |
| renderTools(); | |
| showToast(`Added ${added} tool(s)`); | |
| } catch (err) { | |
| showToast('Invalid tool file'); | |
| } | |
| }; | |
| reader.readAsText(file); | |
| }); | |
| } | |
| function renderTools() { | |
| document.getElementById('toolsList').innerHTML = AVAILABLE_TOOLS.map(t => | |
| `<div class="bg-gray-700 px-3 py-1.5 rounded-lg">${t.function.name}</div>` | |
| ).join(''); | |
| } | |
| function showToast(message) { | |
| const toast = document.getElementById('toast'); | |
| toast.textContent = message; | |
| toast.classList.remove('hidden'); | |
| setTimeout(() => toast.classList.add('hidden'), 2500); | |
| } | |
| // Enter key support | |
| document.getElementById('userInput').addEventListener('keydown', e => { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| sendMessage(); | |
| } | |
| }); | |
| window.onload = init; | |
| </script> | |
| </body> | |
| </html> |