Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <title>OpenAI Chat Streaming - Markdown</title> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <!-- Bootstrap (latest) --> | |
| <link | |
| href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" | |
| rel="stylesheet" | |
| /> | |
| <!-- Marked.js for Markdown support --> | |
| <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> | |
| <!-- Optional: Enable if you need sanitization | |
| <script src="https://cdn.jsdelivr.net/npm/dompurify@3.0.2/dist/purify.min.js"></script> | |
| --> | |
| <style> | |
| html, | |
| body { | |
| height: 100%; | |
| width: 100vw; | |
| margin: 0; | |
| padding: 0; | |
| } | |
| body { | |
| background: #f8f9fa; | |
| min-height: 100vh; | |
| width: 100vw; | |
| } | |
| .chat-window { | |
| width: 100vw; | |
| height: 100vh; | |
| display: flex; | |
| flex-direction: column; | |
| background: #fff; | |
| } | |
| .chat-messages { | |
| flex: 1; | |
| overflow-y: auto; | |
| padding: 16px; | |
| border-bottom: 1px solid #eee; | |
| background: #fff; | |
| } | |
| .message.user { | |
| text-align: right; | |
| } | |
| .message.openai { | |
| text-align: left; | |
| } | |
| .message span { | |
| display: inline-block; | |
| padding: 8px 12px; | |
| border-radius: 16px; | |
| margin: 6px 0; | |
| max-width: 80%; | |
| word-break: break-word; | |
| } | |
| .message.user span { | |
| background: #0d6efd; | |
| color: #fff; | |
| } | |
| .message.openai span { | |
| background: #e9ecef; | |
| color: #333; | |
| } | |
| .settings-toggle { | |
| cursor: pointer; | |
| color: #0d6efd; | |
| font-size: 1.1em; | |
| float: right; | |
| } | |
| .settings-panel { | |
| display: none; | |
| border-bottom: 1px solid #eee; | |
| padding: 16px; | |
| background: #f7f7f7; | |
| } | |
| .settings-panel.show { | |
| display: block; | |
| } | |
| .new-chat-btn { | |
| margin-right: 10px; | |
| background: #198754; | |
| color: #fff; | |
| border: none; | |
| padding: 5px 16px; | |
| border-radius: 20px; | |
| font-size: 1em; | |
| cursor: pointer; | |
| } | |
| .new-chat-btn:hover { | |
| background: #157347; | |
| } | |
| .chat-id-tag { | |
| font-size: 0.75em; | |
| color: #888; | |
| font-family: monospace; | |
| background: #eee; | |
| padding: 1px 7px; | |
| border-radius: 12px; | |
| margin-left: 8px; | |
| } | |
| .speed-indicator { | |
| font-size: 0.85em; | |
| color: #888; | |
| padding-left: 1em; | |
| } | |
| @media (max-width: 600px) { | |
| .chat-window { | |
| width: 100vw; | |
| height: 100vh; | |
| border-radius: 0; | |
| margin: 0; | |
| } | |
| .chat-messages { | |
| padding: 8px; | |
| } | |
| .settings-panel, | |
| .p-3 { | |
| padding: 8px ; | |
| } | |
| .message span { | |
| max-width: 100%; | |
| font-size: 1em; | |
| } | |
| } | |
| code, | |
| pre { | |
| font-family: 'Fira Mono', 'Consolas', monospace; | |
| background: #f1f3f5; | |
| border-radius: 4px; | |
| padding: 2px 6px; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="chat-window"> | |
| <header | |
| class="p-3 border-bottom d-flex align-items-center justify-content-between" | |
| > | |
| <span class="fw-bold"> | |
| Chat OpenAI <small class="text-secondary">Streaming Markdown</small> | |
| <span | |
| id="chatIdTag" | |
| class="chat-id-tag" | |
| title="Conversation ID" | |
| ></span> | |
| </span> | |
| <div> | |
| <button class="new-chat-btn" id="newChatBtn" title="New Chat"> | |
| New chat | |
| </button> | |
| <button | |
| class="new-chat-btn" | |
| id="generateTitleBtn" | |
| title="Generate Title" | |
| > | |
| Generate title | |
| </button> | |
| <span class="settings-toggle" id="settingsToggle" title="Settings" | |
| >⚙</span | |
| > | |
| </div> | |
| </header> | |
| <section class="settings-panel" id="settingsPanel"> | |
| <form id="settingsForm" autocomplete="on"> | |
| <div class="mb-2"> | |
| <label class="form-label">Base URL (up to /v1)</label> | |
| <input type="text" class="form-control" id="urlBase" required /> | |
| </div> | |
| <div class="mb-2"> | |
| <label class="form-label">API Key</label> | |
| <input type="text" class="form-control" id="apiKey" required /> | |
| </div> | |
| <div class="mb-2"> | |
| <label class="form-label">Model</label> | |
| <input type="text" class="form-control" id="model" required /> | |
| </div> | |
| <div class="mb-2"> | |
| <label class="form-label">System prompt</label> | |
| <textarea | |
| class="form-control" | |
| id="systemPrompt" | |
| rows="2" | |
| placeholder="Example: You are a helpful and concise assistant." | |
| ></textarea> | |
| <small class="text-secondary">Controls AI behavior.</small> | |
| </div> | |
| <button type="submit" class="btn btn-primary btn-sm w-100"> | |
| Save settings | |
| </button> | |
| </form> | |
| </section> | |
| <main class="chat-messages" id="chatMessages"></main> | |
| <form class="d-flex p-3 gap-2" id="chatForm" autocomplete="off"> | |
| <input | |
| type="text" | |
| class="form-control flex-grow-1" | |
| id="userInput" | |
| placeholder="Type your message..." | |
| required | |
| /> | |
| <button class="btn btn-primary flex-shrink-0" type="submit"> | |
| Send | |
| </button> | |
| </form> | |
| </div> | |
| <script type="module"> | |
| import { openDB } from 'https://cdn.jsdelivr.net/npm/idb@7/+esm'; | |
| // ----------- IndexedDB helpers ----------- | |
| const DB_NAME = 'chatdb'; | |
| const STORE = 'messages'; | |
| const dbPromise = openDB(DB_NAME, 1, { | |
| upgrade(db) { | |
| if (!db.objectStoreNames.contains(STORE)) | |
| db.createObjectStore(STORE, { keyPath: 'id', autoIncrement: true }); | |
| }, | |
| }); | |
| const saveMessage = async (chatId, msg) => { | |
| const db = await dbPromise; | |
| await db.add(STORE, { chatId, ...msg }); | |
| }; | |
| const getMessages = async chatId => { | |
| const db = await dbPromise; | |
| return (await db.getAll(STORE)).filter(m => m.chatId === chatId); | |
| }; | |
| const clearMessages = async chatId => { | |
| const db = await dbPromise; | |
| const tx = db.transaction(STORE, 'readwrite'); | |
| const store = tx.objectStore(STORE); | |
| const all = await store.getAll(); | |
| for (const msg of all) | |
| if (msg.chatId === chatId) await store.delete(msg.id); | |
| await tx.done; | |
| }; | |
| // ----------- localStorage helpers -------- | |
| const save = (key, value) => | |
| localStorage.setItem(key, JSON.stringify(value)); | |
| const load = key => JSON.parse(localStorage.getItem(key)); | |
| // ----------- Chat ID management ---------- | |
| const generateChatId = () => | |
| globalThis.crypto?.randomUUID?.() || | |
| Date.now().toString(36) + Math.random().toString(36).slice(2, 8); | |
| const getOrCreateChatId = () => { | |
| const params = new URLSearchParams(location.search); | |
| let chatId = params.get('chat'); | |
| if (!chatId) { | |
| chatId = generateChatId(); | |
| params.set('chat', chatId); | |
| history.replaceState(null, '', '?' + params.toString()); | |
| } | |
| return chatId; | |
| }; | |
| const chatId = getOrCreateChatId(); | |
| document.getElementById('chatIdTag').textContent = chatId; | |
| // ----------- DOM elements --------------- | |
| const settingsPanel = document.getElementById('settingsPanel'); | |
| const settingsToggle = document.getElementById('settingsToggle'); | |
| const settingsForm = document.getElementById('settingsForm'); | |
| const urlBaseInput = document.getElementById('urlBase'); | |
| const apiKeyInput = document.getElementById('apiKey'); | |
| const modelInput = document.getElementById('model'); | |
| const systemPromptInput = document.getElementById('systemPrompt'); | |
| const chatMessages = document.getElementById('chatMessages'); | |
| const chatForm = document.getElementById('chatForm'); | |
| const userInput = document.getElementById('userInput'); | |
| const newChatBtn = document.getElementById('newChatBtn'); | |
| const generateTitleBtn = document.getElementById('generateTitleBtn'); | |
| // ----------- Config management ---------- | |
| const configKey = `chatConfig-${chatId}`; | |
| // Cargar configuración del chat actual, o valores por defecto | |
| const loadConfig = () => { | |
| const config = load(configKey) ?? {}; | |
| urlBaseInput.value = config.urlBase ?? 'https://api.openai.com/v1'; | |
| apiKeyInput.value = config.apiKey ?? ''; | |
| modelInput.value = config.model ?? 'gpt-3.5-turbo'; | |
| systemPromptInput.value = config.systemPrompt ?? ''; | |
| }; | |
| loadConfig(); | |
| settingsToggle.addEventListener('click', () => | |
| settingsPanel.classList.toggle('show'), | |
| ); | |
| settingsForm.addEventListener('submit', e => { | |
| e.preventDefault(); | |
| save(configKey, { | |
| urlBase: urlBaseInput.value.trim(), | |
| apiKey: apiKeyInput.value.trim(), | |
| model: modelInput.value.trim(), | |
| systemPrompt: systemPromptInput.value.trim(), | |
| }); | |
| settingsPanel.classList.remove('show'); | |
| }); | |
| // ----------- Conversation history -------- | |
| let autosaveEnabled = false; | |
| let conversation = []; | |
| const saveConversation = async () => { | |
| if (autosaveEnabled) { | |
| const msg = conversation.at(-1); | |
| if (msg) await saveMessage(chatId, msg); | |
| } | |
| }; | |
| const restoreHistory = async () => { | |
| autosaveEnabled = false; | |
| let history = await getMessages(chatId); | |
| chatMessages.innerHTML = ''; | |
| if (history.length && history.at(-1).role === 'user') history.pop(); | |
| history = history.filter( | |
| msg => !(msg.role === 'assistant' && !msg.content), | |
| ); | |
| conversation = history.map(({ role, content }) => ({ role, content })); | |
| conversation.forEach(msg => | |
| addMessage(msg.content, msg.role === 'user' ? 'user' : 'openai'), | |
| ); | |
| autosaveEnabled = true; | |
| }; | |
| // ----------- Markdown & rendering ------- | |
| const renderMarkdown = text => marked.parse(text); | |
| const addMessage = (content, sender) => { | |
| const div = document.createElement('div'); | |
| div.className = `message ${sender}`; | |
| div.innerHTML = `<span>${ | |
| sender === 'openai' ? renderMarkdown(content) : content | |
| }</span>`; | |
| chatMessages.appendChild(div); | |
| // Use the same approach as original working version | |
| div.scrollIntoView({ block: 'end' }); | |
| }; | |
| // ----------- OpenAI streaming ----------- | |
| const streamOpenAI = async ({ | |
| urlBase, | |
| apiKey, | |
| model, | |
| conversation, | |
| onChunk, | |
| onDone, | |
| onError, | |
| }) => { | |
| const headers = { | |
| 'Content-Type': 'application/json', | |
| Authorization: `Bearer ${apiKey}`, | |
| }; | |
| const startTime = performance.now(); | |
| let lastText = '', | |
| tokenCount = 0; | |
| const response = await fetch(`${urlBase}/chat/completions`, { | |
| method: 'POST', | |
| headers, | |
| body: JSON.stringify({ model, messages: conversation, stream: true }), | |
| }); | |
| const reader = response.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let buffer = '', | |
| done = false; | |
| while (!done) { | |
| const { value, done: doneReading } = await reader.read(); | |
| if (doneReading) break; | |
| buffer += decoder.decode(value, { stream: true }); | |
| const lines = buffer.split('\n'); | |
| buffer = lines.pop(); | |
| for (let line of lines) { | |
| const ltrim = line.trim(); | |
| if (!ltrim.startsWith('data:')) continue; | |
| const jsonStr = ltrim.slice(5).trim(); | |
| if (jsonStr === '[DONE]') { | |
| done = true; | |
| break; | |
| } | |
| const parsed = JSON.parse(jsonStr); | |
| const delta = parsed.choices[0]?.delta?.content ?? ''; | |
| if (delta) { | |
| lastText += delta; | |
| tokenCount++; | |
| onChunk?.( | |
| lastText, | |
| tokenCount, | |
| (performance.now() - startTime) / 1000, | |
| ); | |
| } | |
| } | |
| } | |
| onDone?.(lastText, tokenCount, (performance.now() - startTime) / 1000); | |
| }; | |
| // ----------- Chat submission/stream ------- | |
| await restoreHistory(); | |
| let messageCounter = 0; | |
| const handleChatSubmit = async e => { | |
| e.preventDefault(); | |
| const text = userInput.value.trim(); | |
| if (!text) return; | |
| addMessage(text, 'user'); | |
| userInput.value = ''; | |
| // Usar configuración solo del chat actual | |
| const config = load(configKey) ?? {}; | |
| const sysPrompt = config.systemPrompt || ''; | |
| conversation.push({ role: 'user', content: text }); | |
| await saveConversation(); | |
| let convSend = [...conversation]; | |
| if (sysPrompt && !(convSend.length && convSend[0].role === 'system')) | |
| convSend = [{ role: 'system', content: sysPrompt }, ...convSend]; | |
| messageCounter++; | |
| const replyId = `reply-${messageCounter}`; | |
| const speedId = `speed-${messageCounter}`; | |
| addMessage( | |
| `<span id="${replyId}"></span><br><small id="${speedId}" class="speed-indicator"></small>`, | |
| 'openai', | |
| ); | |
| const replyElem = document.getElementById(replyId); | |
| const speedElem = document.getElementById(speedId); | |
| const msgDiv = chatMessages.lastChild; | |
| await streamOpenAI({ | |
| urlBase: config.urlBase ?? 'https://api.openai.com/v1', | |
| apiKey: config.apiKey ?? '', | |
| model: config.model ?? 'gpt-3.5-turbo', | |
| conversation: convSend, | |
| onChunk: (content, tokens, secs) => { | |
| if (tokens % 10 === 0 || tokens === 1) | |
| replyElem.innerHTML = renderMarkdown(content); | |
| if (tokens > 0) | |
| speedElem.innerText = `Tokens: ${tokens} • Speed: ${( | |
| tokens / (secs || 1) | |
| ).toFixed(2)} tks/sec`; | |
| msgDiv.scrollIntoView({ block: 'end' }); | |
| }, | |
| onDone: async (content, tokens, secs) => { | |
| if (content) { | |
| conversation.push({ role: 'assistant', content }); | |
| await saveConversation(); | |
| } | |
| replyElem.innerHTML = renderMarkdown(content); | |
| speedElem.innerText = `Done. Tokens: ${tokens}, Max speed: ${( | |
| tokens / (secs || 1) | |
| ).toFixed(2)} tks/sec`; | |
| }, | |
| onError: () => { | |
| chatMessages.lastChild.remove(); | |
| addMessage('Connection error (streaming failed).', 'openai'); | |
| }, | |
| }); | |
| }; | |
| chatForm.addEventListener('submit', handleChatSubmit); | |
| // ----------- Start new chat ------------- | |
| newChatBtn.addEventListener('click', () => { | |
| const newId = generateChatId(); | |
| const thisConfig = { | |
| urlBase: urlBaseInput.value.trim(), | |
| apiKey: apiKeyInput.value.trim(), | |
| model: modelInput.value.trim(), | |
| systemPrompt: systemPromptInput.value.trim(), | |
| }; | |
| save(`chatConfig-${newId}`, thisConfig); | |
| window.open(`?chat=${newId}`, '_blank'); | |
| }); | |
| // ----------- Generate title ------------- | |
| const generateTitle = async () => { | |
| if (conversation.length === 0) return; | |
| const config = load(configKey) ?? {}; | |
| const headers = { | |
| 'Content-Type': 'application/json', | |
| Authorization: `Bearer ${config.apiKey}`, | |
| }; | |
| const titleConversation = [ | |
| ...conversation, | |
| { | |
| role: 'user', | |
| content: | |
| 'Generate a very short (max 5 words) and concise title for this conversation. Respond only with the title, no other text.', | |
| }, | |
| ]; | |
| const response = await fetch(`${config.urlBase}/chat/completions`, { | |
| method: 'POST', | |
| headers, | |
| body: JSON.stringify({ | |
| model: config.model, | |
| messages: titleConversation, | |
| stream: false, | |
| }), | |
| }); | |
| const data = await response.json(); | |
| const title = data.choices?.[0]?.message?.content; | |
| if (title) { | |
| const trimmedTitle = title.trim().substring(0, 50); | |
| document.title = trimmedTitle; | |
| localStorage.setItem(`chatTitle-${chatId}`, trimmedTitle); | |
| const headerTitle = document.querySelector('header span.fw-bold'); | |
| if (headerTitle) | |
| headerTitle.innerHTML = `${trimmedTitle} <small class="text-secondary">Streaming Markdown</small><span id="chatIdTag" class="chat-id-tag" title="Conversation ID"></span>`; | |
| } | |
| }; | |
| generateTitleBtn.addEventListener('click', generateTitle); | |
| // Load saved title if exists | |
| const savedTitle = localStorage.getItem(`chatTitle-${chatId}`); | |
| if (savedTitle) { | |
| document.title = savedTitle; | |
| const headerTitle = document.querySelector('header span.fw-bold'); | |
| if (headerTitle) | |
| headerTitle.innerHTML = `${savedTitle} <small class="text-secondary">Streaming Markdown</small><span id="chatIdTag" class="chat-id-tag" title="Conversation ID"></span>`; | |
| } | |
| </script> | |
| </body> | |
| </html> |