MiniChat / index.html
Francisco2025's picture
Update index.html
91e1b93 verified
<!DOCTYPE html>
<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 !important;
}
.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"
>&#9881;</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>