1174 lines
36 KiB
HTML
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, '`');
|
|
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|
|
|
|
// ─────────────────────────────────────────────
|
|
// 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>
|