Files
ForgeLM/ui/chat.html
T

1174 lines
36 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ForgeLM Chat</title>
<!-- Markdown + Syntax Highlighting + Sanitization -->
<script src="https://cdn.jsdelivr.net/npm/marked@12/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11/lib/core.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11/lib/languages/javascript.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11/lib/languages/typescript.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11/lib/languages/python.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11/lib/languages/rust.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11/lib/languages/bash.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11/lib/languages/json.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11/lib/languages/sql.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11/lib/languages/css.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11/lib/languages/xml.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
<!-- sql.js for SQLite in browser -->
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/sql-wasm.js"></script>
<!-- highlight.js theme -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11/styles/github-dark.min.css" />
<style>
:root {
--bg: #0f0f0f;
--bg2: #1a1a1a;
--bg3: #222;
--bg4: #2a2a2a;
--border: #2a2a2a;
--text: #e0e0e0;
--text2: #999;
--accent: #2563eb;
--accent-hover: #1d4ed8;
--user-bg: #1e3a5f;
--assistant-bg: #1a1a1a;
--danger: #ef4444;
--success: #22c55e;
--code-bg: #161b22;
--radius: 8px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
height: 100dvh;
display: flex;
overflow: hidden;
}
/* ── Sidebar ── */
#sidebar {
width: 260px;
min-width: 260px;
background: var(--bg2);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
}
#sidebar-header {
padding: 14px 16px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 8px;
}
#sidebar-header h2 { font-size: 0.9rem; font-weight: 600; flex: 1; }
#new-chat-btn {
background: var(--accent);
color: #fff;
border: none;
border-radius: 6px;
padding: 5px 10px;
font-size: 0.78rem;
cursor: pointer;
white-space: nowrap;
}
#new-chat-btn:hover { background: var(--accent-hover); }
#chat-list {
flex: 1;
overflow-y: auto;
padding: 8px;
display: flex;
flex-direction: column;
gap: 2px;
}
.chat-item {
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
font-size: 0.82rem;
color: var(--text2);
display: flex;
align-items: center;
gap: 6px;
transition: background 0.1s;
position: relative;
}
.chat-item:hover { background: var(--bg3); color: var(--text); }
.chat-item.active { background: var(--bg4); color: var(--text); }
.chat-item-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-item-del {
opacity: 0;
background: none;
border: none;
color: var(--danger);
cursor: pointer;
font-size: 0.9rem;
padding: 2px 4px;
border-radius: 4px;
line-height: 1;
}
.chat-item:hover .chat-item-del { opacity: 1; }
#sidebar-footer {
padding: 10px 16px;
border-top: 1px solid var(--border);
font-size: 0.72rem;
color: var(--text2);
}
/* ── Main ── */
#main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ── Header ── */
#header {
padding: 10px 20px;
background: var(--bg2);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 12px;
}
#header h1 { font-size: 0.95rem; font-weight: 600; flex: 1; }
#status-dot {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--text2);
flex-shrink: 0;
}
#status-dot.ok { background: var(--success); }
#status-dot.err { background: var(--danger); }
#model-select {
background: var(--bg3);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 4px 8px;
font-size: 0.78rem;
}
#server-input {
background: var(--bg3);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 4px 8px;
font-size: 0.78rem;
width: 200px;
}
#tps-badge {
font-size: 0.72rem;
background: var(--bg3);
padding: 3px 8px;
border-radius: 4px;
color: var(--text2);
}
/* ── Messages ── */
#messages {
flex: 1;
overflow-y: auto;
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
.msg-wrapper {
display: flex;
flex-direction: column;
max-width: 800px;
width: 100%;
}
.msg-wrapper.user { align-self: flex-end; align-items: flex-end; }
.msg-wrapper.assistant { align-self: flex-start; align-items: flex-start; }
.msg-bubble {
padding: 12px 16px;
border-radius: var(--radius);
font-size: 0.9rem;
line-height: 1.65;
position: relative;
}
.msg-wrapper.user .msg-bubble {
background: var(--user-bg);
border-bottom-right-radius: 2px;
}
.msg-wrapper.assistant .msg-bubble {
background: var(--assistant-bg);
border: 1px solid var(--border);
border-bottom-left-radius: 2px;
}
/* Markdown styles inside bubbles */
.msg-bubble h1, .msg-bubble h2, .msg-bubble h3,
.msg-bubble h4, .msg-bubble h5, .msg-bubble h6 {
margin: 0.8em 0 0.4em;
line-height: 1.3;
}
.msg-bubble h1 { font-size: 1.3em; }
.msg-bubble h2 { font-size: 1.15em; }
.msg-bubble h3 { font-size: 1.05em; }
.msg-bubble p { margin: 0.5em 0; }
.msg-bubble p:first-child { margin-top: 0; }
.msg-bubble p:last-child { margin-bottom: 0; }
.msg-bubble ul, .msg-bubble ol { padding-left: 1.4em; margin: 0.5em 0; }
.msg-bubble li { margin: 0.2em 0; }
.msg-bubble blockquote {
border-left: 3px solid var(--accent);
padding-left: 12px;
margin: 0.5em 0;
color: var(--text2);
}
.msg-bubble table {
border-collapse: collapse;
width: 100%;
margin: 0.5em 0;
font-size: 0.85em;
}
.msg-bubble th, .msg-bubble td {
border: 1px solid var(--border);
padding: 6px 10px;
text-align: left;
}
.msg-bubble th { background: var(--bg3); }
.msg-bubble a { color: #60a5fa; text-decoration: underline; }
.msg-bubble hr { border: none; border-top: 1px solid var(--border); margin: 0.8em 0; }
.msg-bubble strong { font-weight: 600; }
.msg-bubble em { font-style: italic; }
/* Inline code */
.msg-bubble code:not(.hljs) {
background: var(--code-bg);
padding: 1px 5px;
border-radius: 4px;
font-family: 'Fira Code', 'Cascadia Code', monospace;
font-size: 0.85em;
}
/* Code blocks */
.code-block-wrapper {
position: relative;
margin: 0.6em 0;
border-radius: 6px;
overflow: hidden;
border: 1px solid var(--border);
}
.code-block-header {
display: flex;
align-items: center;
justify-content: space-between;
background: #0d1117;
padding: 5px 12px;
font-size: 0.72rem;
color: var(--text2);
}
.code-block-header .lang-label { font-family: monospace; }
.copy-code-btn {
background: none;
border: 1px solid var(--border);
color: var(--text2);
border-radius: 4px;
padding: 2px 8px;
font-size: 0.7rem;
cursor: pointer;
transition: all 0.15s;
}
.copy-code-btn:hover { background: var(--bg3); color: var(--text); }
.copy-code-btn.copied { color: var(--success); border-color: var(--success); }
.code-block-wrapper pre {
margin: 0;
overflow-x: auto;
}
.code-block-wrapper pre code {
display: block;
padding: 12px 16px;
font-family: 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
font-size: 0.82rem;
line-height: 1.5;
}
/* Message actions */
.msg-actions {
display: flex;
gap: 4px;
margin-top: 4px;
opacity: 0;
transition: opacity 0.15s;
}
.msg-wrapper:hover .msg-actions { opacity: 1; }
.msg-action-btn {
background: none;
border: 1px solid var(--border);
color: var(--text2);
border-radius: 4px;
padding: 3px 8px;
font-size: 0.72rem;
cursor: pointer;
transition: all 0.15s;
display: flex;
align-items: center;
gap: 4px;
}
.msg-action-btn:hover { background: var(--bg3); color: var(--text); }
.msg-action-btn.danger:hover { background: #3f1212; color: var(--danger); border-color: var(--danger); }
/* Edit mode */
.msg-edit-area {
width: 100%;
background: var(--bg3);
color: var(--text);
border: 1px solid var(--accent);
border-radius: 6px;
padding: 10px 12px;
font-size: 0.9rem;
font-family: inherit;
resize: vertical;
min-height: 80px;
outline: none;
}
.msg-edit-actions {
display: flex;
gap: 6px;
margin-top: 6px;
}
.btn-primary {
background: var(--accent);
color: #fff;
border: none;
border-radius: 6px;
padding: 5px 14px;
font-size: 0.8rem;
cursor: pointer;
}
.btn-primary:hover { background: var(--accent-hover); }
.btn-secondary {
background: var(--bg3);
color: var(--text2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 5px 14px;
font-size: 0.8rem;
cursor: pointer;
}
.btn-secondary:hover { background: var(--bg4); color: var(--text); }
/* Thinking indicator */
.thinking {
display: flex;
gap: 4px;
padding: 12px 16px;
background: var(--assistant-bg);
border: 1px solid var(--border);
border-radius: var(--radius);
width: fit-content;
}
.thinking span {
width: 6px; height: 6px;
background: var(--text2);
border-radius: 50%;
animation: bounce 1.2s infinite;
}
.thinking span:nth-child(2) { animation-delay: 0.2s; }
.thinking span:nth-child(3) { animation-delay: 0.4s; }
@keyframes bounce {
0%, 80%, 100% { transform: translateY(0); opacity: 0.4; }
40% { transform: translateY(-6px); opacity: 1; }
}
/* ── Input area ── */
#input-area {
padding: 12px 20px;
background: var(--bg2);
border-top: 1px solid var(--border);
}
#input-row {
display: flex;
gap: 8px;
align-items: flex-end;
}
#prompt {
flex: 1;
background: var(--bg3);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 14px;
font-size: 0.9rem;
font-family: inherit;
resize: none;
min-height: 44px;
max-height: 200px;
outline: none;
transition: border-color 0.15s;
line-height: 1.5;
}
#prompt:focus { border-color: var(--accent); }
#send-btn {
background: var(--accent);
color: #fff;
border: none;
border-radius: 8px;
padding: 10px 18px;
font-size: 0.9rem;
cursor: pointer;
height: 44px;
white-space: nowrap;
transition: background 0.15s;
}
#send-btn:hover:not(:disabled) { background: var(--accent-hover); }
#send-btn:disabled { opacity: 0.4; cursor: not-allowed; }
#input-meta {
display: flex;
align-items: center;
gap: 12px;
margin-top: 6px;
font-size: 0.72rem;
color: var(--text2);
}
#stream-toggle { display: flex; align-items: center; gap: 5px; cursor: pointer; }
#stream-toggle input { cursor: pointer; }
#memory-toggle { display: flex; align-items: center; gap: 5px; cursor: pointer; }
#memory-toggle input { cursor: pointer; }
#char-count { margin-left: auto; }
/* ── Toast ── */
#toast {
position: fixed;
bottom: 80px;
left: 50%;
transform: translateX(-50%) translateY(20px);
background: var(--bg3);
border: 1px solid var(--border);
color: var(--text);
padding: 8px 16px;
border-radius: 6px;
font-size: 0.82rem;
opacity: 0;
transition: all 0.2s;
pointer-events: none;
z-index: 100;
}
#toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
/* ── Scrollbar ── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--bg4); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #444; }
/* ── Empty state ── */
#empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--text2);
}
#empty-state h2 { font-size: 1.4rem; color: var(--text); }
#empty-state p { font-size: 0.85rem; max-width: 360px; text-align: center; }
</style>
</head>
<body>
<!-- Sidebar -->
<div id="sidebar">
<div id="sidebar-header">
<h2>Chats</h2>
<button id="new-chat-btn">+ New</button>
</div>
<div id="chat-list"></div>
<div id="sidebar-footer">
<span id="db-status">SQLite: initializing…</span>
</div>
</div>
<!-- Main -->
<div id="main">
<div id="header">
<div id="status-dot"></div>
<h1 id="chat-title">ForgeLM Chat</h1>
<input id="server-input" type="text" value="http://127.0.0.1:8080" placeholder="Server URL" />
<select id="model-select"><option value="">Loading…</option></select>
<span id="tps-badge">— tok/s</span>
</div>
<div id="messages">
<div id="empty-state">
<h2>ForgeLM Chat</h2>
<p>Start a conversation or select a chat from the sidebar. Supports full Markdown, code highlighting, and persistent history.</p>
</div>
</div>
<div id="input-area">
<div id="input-row">
<textarea id="prompt" placeholder="Message… (Shift+Enter for new line, Enter to send)" rows="1"></textarea>
<button id="send-btn">Send</button>
</div>
<div id="input-meta">
<label id="stream-toggle">
<input type="checkbox" id="stream-cb" checked /> Streaming
</label>
<label id="memory-toggle">
<input type="checkbox" id="memory-cb" checked /> Memory
</label>
<span id="char-count">0 chars</span>
</div>
</div>
</div>
<div id="toast"></div>
<script>
// ─────────────────────────────────────────────
// Config
// ─────────────────────────────────────────────
const MAX_MEMORY_MESSAGES = 20; // how many messages to include as context
// ─────────────────────────────────────────────
// SQLite via sql.js
// ─────────────────────────────────────────────
let db = null;
async function initDB() {
const SQL = await initSqlJs({
locateFile: file => `https://cdn.jsdelivr.net/npm/[email protected]/dist/${file}`
});
// Try to load persisted DB from localStorage
const saved = localStorage.getItem('forgelm_db');
if (saved) {
const buf = Uint8Array.from(atob(saved), c => c.charCodeAt(0));
db = new SQL.Database(buf);
} else {
db = new SQL.Database();
}
db.run(`
CREATE TABLE IF NOT EXISTS chats (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL DEFAULT 'New Chat',
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_id INTEGER NOT NULL REFERENCES chats(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK(role IN ('user','assistant','system')),
content TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
CREATE INDEX IF NOT EXISTS idx_messages_chat ON messages(chat_id, created_at);
`);
persistDB();
document.getElementById('db-status').textContent = 'SQLite: ready';
}
function persistDB() {
if (!db) return;
const data = db.export();
const b64 = btoa(String.fromCharCode(...data));
try { localStorage.setItem('forgelm_db', b64); } catch(e) {}
}
function dbRun(sql, params = []) {
db.run(sql, params);
persistDB();
}
function dbQuery(sql, params = []) {
const stmt = db.prepare(sql);
stmt.bind(params);
const rows = [];
while (stmt.step()) rows.push(stmt.getAsObject());
stmt.free();
return rows;
}
function dbGet(sql, params = []) {
return dbQuery(sql, params)[0] || null;
}
// ─────────────────────────────────────────────
// State
// ─────────────────────────────────────────────
let currentChatId = null;
let isGenerating = false;
let abortController = null;
// ─────────────────────────────────────────────
// Markdown renderer
// ─────────────────────────────────────────────
const renderer = new marked.Renderer();
renderer.code = function(code, lang) {
const language = lang && hljs.getLanguage(lang) ? lang : 'plaintext';
let highlighted;
try {
highlighted = language !== 'plaintext'
? hljs.highlight(code, { language }).value
: hljs.highlightAuto(code).value;
} catch { highlighted = code; }
const escapedCode = code.replace(/`/g, '&#96;');
return `<div class="code-block-wrapper">
<div class="code-block-header">
<span class="lang-label">${language}</span>
<button class="copy-code-btn" onclick="copyCode(this, \`${escapedCode.replace(/\\/g,'\\\\').replace(/`/g,'\\`')}\`)">Copy</button>
</div>
<pre><code class="hljs language-${language}">${highlighted}</code></pre>
</div>`;
};
marked.setOptions({
renderer,
breaks: true,
gfm: true,
});
function renderMarkdown(text) {
const html = marked.parse(text || '');
return DOMPurify.sanitize(html, {
ADD_TAGS: ['div', 'span', 'button'],
ADD_ATTR: ['class', 'onclick', 'data-code'],
FORCE_BODY: false,
});
}
// ─────────────────────────────────────────────
// Toast
// ─────────────────────────────────────────────
let toastTimer = null;
function showToast(msg, duration = 2000) {
const el = document.getElementById('toast');
el.textContent = msg;
el.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.classList.remove('show'), duration);
}
// ─────────────────────────────────────────────
// Copy helpers
// ─────────────────────────────────────────────
function copyCode(btn, code) {
navigator.clipboard.writeText(code).then(() => {
btn.textContent = 'Copied!';
btn.classList.add('copied');
setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('copied'); }, 2000);
});
}
function copyMessage(content) {
navigator.clipboard.writeText(content).then(() => showToast('Copied!'));
}
// ─────────────────────────────────────────────
// Chat list (sidebar)
// ─────────────────────────────────────────────
function loadChatList() {
const chats = dbQuery('SELECT * FROM chats ORDER BY updated_at DESC');
const list = document.getElementById('chat-list');
list.innerHTML = '';
chats.forEach(chat => {
const item = document.createElement('div');
item.className = 'chat-item' + (chat.id === currentChatId ? ' active' : '');
item.dataset.id = chat.id;
item.innerHTML = `
<span class="chat-item-title">${escapeHtml(chat.title)}</span>
<button class="chat-item-del" title="Delete" onclick="deleteChat(${chat.id}, event)">✕</button>
`;
item.addEventListener('click', () => openChat(chat.id));
list.appendChild(item);
});
}
function escapeHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ─────────────────────────────────────────────
// Chat CRUD
// ─────────────────────────────────────────────
function newChat() {
dbRun(`INSERT INTO chats (title) VALUES ('New Chat')`);
const row = dbGet('SELECT last_insert_rowid() as id');
currentChatId = row.id;
loadChatList();
renderMessages();
document.getElementById('chat-title').textContent = 'New Chat';
document.getElementById('prompt').focus();
}
function openChat(id) {
currentChatId = id;
const chat = dbGet('SELECT * FROM chats WHERE id = ?', [id]);
document.getElementById('chat-title').textContent = chat?.title || 'Chat';
loadChatList();
renderMessages();
}
function deleteChat(id, e) {
e.stopPropagation();
if (!confirm('Delete this chat?')) return;
dbRun('DELETE FROM chats WHERE id = ?', [id]);
if (currentChatId === id) {
currentChatId = null;
document.getElementById('messages').innerHTML = document.getElementById('empty-state')?.outerHTML || '';
document.getElementById('chat-title').textContent = 'ForgeLM Chat';
}
loadChatList();
}
function updateChatTitle(chatId, firstUserMsg) {
const title = firstUserMsg.slice(0, 50) + (firstUserMsg.length > 50 ? '…' : '');
dbRun('UPDATE chats SET title = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?', [title, chatId]);
document.getElementById('chat-title').textContent = title;
loadChatList();
}
// ─────────────────────────────────────────────
// Message rendering
// ─────────────────────────────────────────────
function renderMessages() {
const container = document.getElementById('messages');
if (!currentChatId) {
container.innerHTML = `<div id="empty-state">
<h2>ForgeLM Chat</h2>
<p>Start a conversation or select a chat from the sidebar.</p>
</div>`;
return;
}
const msgs = dbQuery('SELECT * FROM messages WHERE chat_id = ? ORDER BY created_at ASC', [currentChatId]);
container.innerHTML = '';
msgs.forEach(msg => {
container.appendChild(buildMessageEl(msg));
});
scrollToBottom();
}
function buildMessageEl(msg) {
const wrapper = document.createElement('div');
wrapper.className = `msg-wrapper ${msg.role}`;
wrapper.dataset.msgId = msg.id;
const bubble = document.createElement('div');
bubble.className = 'msg-bubble';
if (msg.role === 'assistant') {
bubble.innerHTML = renderMarkdown(msg.content);
} else {
bubble.textContent = msg.content;
}
const actions = document.createElement('div');
actions.className = 'msg-actions';
// Copy full message
const copyBtn = document.createElement('button');
copyBtn.className = 'msg-action-btn';
copyBtn.innerHTML = '⎘ Copy';
copyBtn.onclick = () => copyMessage(msg.content);
actions.appendChild(copyBtn);
// Edit button
const editBtn = document.createElement('button');
editBtn.className = 'msg-action-btn';
editBtn.innerHTML = '✎ Edit';
editBtn.onclick = () => startEdit(wrapper, msg);
actions.appendChild(editBtn);
// Delete button
const delBtn = document.createElement('button');
delBtn.className = 'msg-action-btn danger';
delBtn.innerHTML = '✕ Delete';
delBtn.onclick = () => deleteMessage(msg.id, wrapper);
actions.appendChild(delBtn);
wrapper.appendChild(bubble);
wrapper.appendChild(actions);
return wrapper;
}
function deleteMessage(msgId, wrapper) {
dbRun('DELETE FROM messages WHERE id = ?', [msgId]);
wrapper.remove();
}
// ─────────────────────────────────────────────
// Edit message
// ─────────────────────────────────────────────
function startEdit(wrapper, msg) {
const bubble = wrapper.querySelector('.msg-bubble');
const actions = wrapper.querySelector('.msg-actions');
bubble.style.display = 'none';
actions.style.display = 'none';
const textarea = document.createElement('textarea');
textarea.className = 'msg-edit-area';
textarea.value = msg.content;
autoResize(textarea);
textarea.addEventListener('input', () => autoResize(textarea));
const editActions = document.createElement('div');
editActions.className = 'msg-edit-actions';
const saveBtn = document.createElement('button');
saveBtn.className = 'btn-primary';
saveBtn.textContent = 'Save & Resend';
saveBtn.onclick = () => saveEdit(wrapper, msg, textarea.value, editActions, textarea, true);
const saveOnlyBtn = document.createElement('button');
saveOnlyBtn.className = 'btn-secondary';
saveOnlyBtn.textContent = 'Save only';
saveOnlyBtn.onclick = () => saveEdit(wrapper, msg, textarea.value, editActions, textarea, false);
const cancelBtn = document.createElement('button');
cancelBtn.className = 'btn-secondary';
cancelBtn.textContent = 'Cancel';
cancelBtn.onclick = () => {
textarea.remove();
editActions.remove();
bubble.style.display = '';
actions.style.display = '';
};
editActions.appendChild(saveBtn);
editActions.appendChild(saveOnlyBtn);
editActions.appendChild(cancelBtn);
wrapper.appendChild(textarea);
wrapper.appendChild(editActions);
textarea.focus();
}
function saveEdit(wrapper, msg, newContent, editActions, textarea, resend) {
if (!newContent.trim()) return;
dbRun('UPDATE messages SET content = ? WHERE id = ?', [newContent, msg.id]);
textarea.remove();
editActions.remove();
const bubble = wrapper.querySelector('.msg-bubble');
const actions = wrapper.querySelector('.msg-actions');
if (msg.role === 'assistant') {
bubble.innerHTML = renderMarkdown(newContent);
} else {
bubble.textContent = newContent;
}
bubble.style.display = '';
actions.style.display = '';
if (resend && msg.role === 'user') {
// Delete all messages after this one and regenerate
dbRun('DELETE FROM messages WHERE chat_id = ? AND id > ?', [currentChatId, msg.id]);
// Remove DOM elements after this wrapper
let next = wrapper.nextSibling;
while (next) {
const toRemove = next;
next = next.nextSibling;
toRemove.remove();
}
sendToAPI(newContent, false);
}
}
// ─────────────────────────────────────────────
// API communication
// ─────────────────────────────────────────────
function getServer() {
return document.getElementById('server-input').value.replace(/\/$/, '');
}
function getModel() {
return document.getElementById('model-select').value;
}
async function loadModels() {
const server = getServer();
try {
const res = await fetch(`${server}/v1/models`);
const data = await res.json();
const sel = document.getElementById('model-select');
sel.innerHTML = '';
(data.data || []).forEach(m => {
const opt = document.createElement('option');
opt.value = m.id;
opt.textContent = m.id;
sel.appendChild(opt);
});
document.getElementById('status-dot').className = 'ok';
} catch {
document.getElementById('status-dot').className = 'err';
}
}
async function checkHealth() {
const server = getServer();
try {
const res = await fetch(`${server}/health`);
const data = await res.json();
document.getElementById('status-dot').className = data.model_loaded ? 'ok' : 'err';
} catch {
document.getElementById('status-dot').className = 'err';
}
}
function buildMessages() {
if (!currentChatId) return [];
const useMemory = document.getElementById('memory-cb').checked;
const limit = useMemory ? MAX_MEMORY_MESSAGES : 1;
const msgs = dbQuery(
'SELECT role, content FROM messages WHERE chat_id = ? ORDER BY created_at DESC LIMIT ?',
[currentChatId, limit]
).reverse();
return msgs.map(m => ({ role: m.role, content: m.content }));
}
async function sendToAPI(userContent, saveUser = true) {
if (isGenerating) return;
if (!currentChatId) newChat();
const model = getModel();
if (!model) { showToast('No model selected'); return; }
isGenerating = true;
document.getElementById('send-btn').disabled = true;
abortController = new AbortController();
// Save user message
if (saveUser) {
dbRun('INSERT INTO messages (chat_id, role, content) VALUES (?, \'user\', ?)', [currentChatId, userContent]);
// Update chat title from first user message
const count = dbGet('SELECT COUNT(*) as c FROM messages WHERE chat_id = ?', [currentChatId]);
if (count?.c === 1) updateChatTitle(currentChatId, userContent);
// Render user message
const userMsg = dbGet('SELECT * FROM messages WHERE chat_id = ? ORDER BY id DESC LIMIT 1', [currentChatId]);
document.getElementById('messages').appendChild(buildMessageEl(userMsg));
}
// Show thinking indicator
const thinkingEl = document.createElement('div');
thinkingEl.className = 'msg-wrapper assistant';
thinkingEl.innerHTML = `<div class="thinking"><span></span><span></span><span></span></div>`;
document.getElementById('messages').appendChild(thinkingEl);
scrollToBottom();
const useStream = document.getElementById('stream-cb').checked;
const messages = buildMessages();
try {
const res = await fetch(`${getServer()}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: abortController.signal,
body: JSON.stringify({
model,
messages,
stream: useStream,
stream_options: useStream ? { include_usage: true } : undefined,
max_tokens: 2048,
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err?.error?.message || `HTTP ${res.status}`);
}
let fullContent = '';
let usage = null;
if (useStream) {
// Replace thinking with live bubble
thinkingEl.innerHTML = `<div class="msg-bubble"></div>`;
const liveBubble = thinkingEl.querySelector('.msg-bubble');
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6).trim();
if (data === '[DONE]') continue;
try {
const chunk = JSON.parse(data);
const delta = chunk.choices?.[0]?.delta?.content;
if (delta) {
fullContent += delta;
liveBubble.innerHTML = renderMarkdown(fullContent);
scrollToBottom();
}
if (chunk.usage) usage = chunk.usage;
} catch {}
}
}
} else {
const data = await res.json();
fullContent = data.choices?.[0]?.message?.content || '';
usage = data.usage;
thinkingEl.remove();
}
// Save assistant message
dbRun('INSERT INTO messages (chat_id, role, content) VALUES (?, \'assistant\', ?)', [currentChatId, fullContent]);
dbRun('UPDATE chats SET updated_at = strftime(\'%s\',\'now\') WHERE id = ?', [currentChatId]);
// Replace live element with proper message element
const savedMsg = dbGet('SELECT * FROM messages WHERE chat_id = ? ORDER BY id DESC LIMIT 1', [currentChatId]);
const msgEl = buildMessageEl(savedMsg);
if (useStream) {
thinkingEl.replaceWith(msgEl);
} else {
document.getElementById('messages').appendChild(msgEl);
}
// Update TPS badge
if (usage?.tokens_per_second) {
document.getElementById('tps-badge').textContent = `${usage.tokens_per_second.toFixed(1)} tok/s`;
}
loadChatList();
scrollToBottom();
} catch (err) {
thinkingEl.remove();
if (err.name !== 'AbortError') {
showToast(`Error: ${err.message}`, 4000);
}
} finally {
isGenerating = false;
document.getElementById('send-btn').disabled = false;
abortController = null;
}
}
// ─────────────────────────────────────────────
// UI helpers
// ─────────────────────────────────────────────
function scrollToBottom() {
const el = document.getElementById('messages');
el.scrollTop = el.scrollHeight;
}
function autoResize(el) {
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 200) + 'px';
}
// ─────────────────────────────────────────────
// Event listeners
// ─────────────────────────────────────────────
document.getElementById('new-chat-btn').addEventListener('click', newChat);
document.getElementById('send-btn').addEventListener('click', () => {
const prompt = document.getElementById('prompt');
const text = prompt.value.trim();
if (!text || isGenerating) return;
prompt.value = '';
autoResize(prompt);
document.getElementById('char-count').textContent = '0 chars';
sendToAPI(text);
});
document.getElementById('prompt').addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
document.getElementById('send-btn').click();
}
});
document.getElementById('prompt').addEventListener('input', function() {
autoResize(this);
document.getElementById('char-count').textContent = `${this.value.length} chars`;
});
document.getElementById('server-input').addEventListener('change', () => {
loadModels();
checkHealth();
});
// ─────────────────────────────────────────────
// Init
// ─────────────────────────────────────────────
(async () => {
await initDB();
loadChatList();
await loadModels();
await checkHealth();
// Auto-refresh health every 30s
setInterval(checkHealth, 30000);
})();
</script>
</body>
</html>