or-chatbot / index.html
broadfield-dev's picture
Update index.html
d3be8b8 verified
Raw
History Blame Contribute Delete
16.6 kB
<!DOCTYPE html>
<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>