Initial Firmware chat web app

This commit is contained in:
kdusek
2026-01-27 02:01:40 +01:00
commit c716699a66
18 changed files with 4818 additions and 0 deletions

13
web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="light" />
<title>Firmware Chat</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

19
web/package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "firmware-chat-web-ui",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "node -c vite.config.js"
},
"dependencies": {
"dompurify": "^3.1.6",
"marked": "^15.0.7",
"nanoid": "^5.0.7"
},
"devDependencies": {
"vite": "^6.0.0"
}
}

480
web/src/admin.js Normal file
View File

@@ -0,0 +1,480 @@
const app = document.querySelector('#app');
const state = {
authed: false,
token: '',
q: '',
status: '',
model: '',
from: '',
to: '',
logs: [],
offset: 0,
limit: 50,
stats: null,
detail: null,
loading: false,
};
boot();
async function boot() {
render();
// Try fetching stats to see if cookie session already exists.
try {
const res = await fetch('/api/stats/summary');
if (res.ok) {
state.authed = true;
await refreshAll();
}
} catch {
// ignore
}
render();
}
async function login() {
const token = (state.token || '').trim();
if (!token) return;
state.loading = true;
render();
try {
const res = await fetch('/api/admin/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
state.authed = true;
state.token = '';
await refreshAll();
} catch (e) {
alert(`Login failed: ${String(e.message || e)}`);
} finally {
state.loading = false;
render();
}
}
async function logout() {
state.loading = true;
render();
try {
await fetch('/api/admin/logout', { method: 'POST' });
} finally {
state.authed = false;
state.logs = [];
state.stats = null;
state.detail = null;
state.loading = false;
render();
}
}
async function refreshAll() {
await Promise.all([loadStats(), loadLogs({ reset: true })]);
}
function buildLogsUrl({ reset } = { reset: false }) {
const url = new URL('/api/logs', window.location.origin);
url.searchParams.set('limit', String(state.limit));
url.searchParams.set('offset', String(reset ? 0 : state.offset));
if (state.q.trim()) url.searchParams.set('q', state.q.trim());
if (state.status) url.searchParams.set('status', state.status);
if (state.model) url.searchParams.set('model', state.model);
if (state.from) url.searchParams.set('from', state.from);
if (state.to) url.searchParams.set('to', state.to);
return url.toString();
}
async function loadLogs({ reset } = { reset: false }) {
if (!state.authed) return;
state.loading = true;
render();
try {
const res = await fetch(buildLogsUrl({ reset }));
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
state.logs = json.rows || [];
state.offset = json.offset || 0;
} catch (e) {
alert(`Load logs failed: ${String(e.message || e)}`);
} finally {
state.loading = false;
render();
}
}
async function loadStats() {
if (!state.authed) return;
try {
const url = new URL('/api/stats/summary', window.location.origin);
if (state.from) url.searchParams.set('from', state.from);
if (state.to) url.searchParams.set('to', state.to);
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
state.stats = await res.json();
} catch {
// ignore
}
}
async function openDetail(requestId) {
if (!requestId) return;
state.loading = true;
state.detail = null;
render();
try {
const res = await fetch(`/api/logs/${encodeURIComponent(requestId)}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
state.detail = await res.json();
} catch (e) {
alert(`Load detail failed: ${String(e.message || e)}`);
} finally {
state.loading = false;
render();
}
}
function render() {
app.innerHTML = state.authed ? renderAuthed() : renderLogin();
wire();
}
function renderLogin() {
return `
<div class="page">
<div class="card">
<div class="h">Admin</div>
<div class="p">Enter ADMIN_TOKEN to start a browser session (cookie-based).</div>
<div class="row">
<input data-token type="password" placeholder="ADMIN_TOKEN" value="${escapeHtml(state.token)}" />
<button data-login ${state.loading ? 'disabled' : ''}>Login</button>
</div>
<div class="small">This is intended for local-network use.</div>
</div>
</div>
${styles()}
`;
}
function renderAuthed() {
const s = state.stats;
return `
<div class="page">
<header class="top">
<div class="brand">
<div class="mark">A</div>
<div>
<div class="h">Admin</div>
<div class="small">Logs and stats</div>
</div>
</div>
<div class="actions">
<button data-refresh ${state.loading ? 'disabled' : ''}>Refresh</button>
<button data-logout ${state.loading ? 'disabled' : ''}>Logout</button>
</div>
</header>
<div class="grid">
<section class="panel">
<div class="panelH">Filters</div>
<div class="filters">
<input data-q placeholder="Search prompts+answers" value="${escapeHtml(state.q)}" />
<div class="row2">
<input data-from placeholder="from (ms epoch)" value="${escapeHtml(state.from)}" />
<input data-to placeholder="to (ms epoch)" value="${escapeHtml(state.to)}" />
</div>
<div class="row2">
<select data-status>
<option value="" ${state.status === '' ? 'selected' : ''}>any status</option>
<option value="ok" ${state.status === 'ok' ? 'selected' : ''}>ok</option>
<option value="error" ${state.status === 'error' ? 'selected' : ''}>error</option>
<option value="aborted" ${state.status === 'aborted' ? 'selected' : ''}>aborted</option>
<option value="started" ${state.status === 'started' ? 'selected' : ''}>started</option>
</select>
<input data-model placeholder="model" value="${escapeHtml(state.model)}" />
</div>
<div class="row">
<button data-apply ${state.loading ? 'disabled' : ''}>Apply</button>
<button data-clear ${state.loading ? 'disabled' : ''}>Clear</button>
</div>
</div>
<div class="panelH" style="margin-top:12px">Summary</div>
<div class="stats">
<div class="stat"><div class="k">total</div><div class="v">${num(s?.total)}</div></div>
<div class="stat"><div class="k">ok</div><div class="v">${num(s?.ok)}</div></div>
<div class="stat"><div class="k">error</div><div class="v">${num(s?.error)}</div></div>
<div class="stat"><div class="k">aborted</div><div class="v">${num(s?.aborted)}</div></div>
<div class="stat"><div class="k">avg ms</div><div class="v">${num(s?.avg_latency_ms)}</div></div>
<div class="stat"><div class="k">p95 ms</div><div class="v">${num(s?.p95_latency_ms)}</div></div>
</div>
</section>
<section class="panel">
<div class="panelH">Logs</div>
<div class="table">
${state.logs.length ? state.logs.map(renderRow).join('') : '<div class="empty">No results</div>'}
</div>
</section>
<section class="panel">
<div class="panelH">Detail</div>
${state.detail ? renderDetail(state.detail) : '<div class="empty">Select a log row</div>'}
</section>
</div>
</div>
${styles()}
`;
}
function renderRow(r) {
const ts = r.ts_request ? new Date(r.ts_request).toLocaleString() : '';
const prompt = (r.user_preview || '').trim();
const ans = (r.assistant_preview || '').trim();
const latency = r.latency_ms != null ? `${r.latency_ms}ms` : '';
return `
<button class="rowBtn" data-open="${escapeHtml(r.request_id)}">
<div class="rowTop">
<div class="pill ${escapeHtml(r.status || '')}">${escapeHtml(r.status || '')}</div>
<div class="ts">${escapeHtml(ts)}</div>
<div class="mono">${escapeHtml(r.model || '')}</div>
<div class="mono">${escapeHtml(latency)}</div>
</div>
<div class="rowBody">
<div class="label">prompt</div>
<div class="text">${escapeHtml(prompt || '(empty)')}</div>
<div class="label" style="margin-top:8px">answer</div>
<div class="text">${escapeHtml(ans || '(empty)')}</div>
</div>
</button>
`;
}
function renderDetail(d) {
const ts = d.ts_request ? new Date(d.ts_request).toLocaleString() : '';
const latency = d.ts_done != null ? `${d.ts_done - d.ts_request}ms` : '';
let msgs = null;
try { msgs = JSON.parse(d.messages_json); } catch {}
return `
<div class="detail">
<div class="mono">id=${escapeHtml(d.request_id)}</div>
<div class="mono">${escapeHtml(ts)}${escapeHtml(d.model)}${escapeHtml(d.status)}${escapeHtml(latency)}</div>
<div class="hr"></div>
<div class="label">messages</div>
<pre class="pre">${escapeHtml(JSON.stringify(msgs || d.messages_json, null, 2))}</pre>
<div class="label" style="margin-top:10px">assistant_text</div>
<pre class="pre">${escapeHtml(d.assistant_text || '')}</pre>
</div>
`;
}
function wire() {
if (!state.authed) {
const token = app.querySelector('[data-token]');
const loginBtn = app.querySelector('[data-login]');
token?.addEventListener('input', (e) => { state.token = e.target.value; });
token?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') login();
});
loginBtn?.addEventListener('click', login);
return;
}
app.querySelector('[data-logout]')?.addEventListener('click', logout);
app.querySelector('[data-refresh]')?.addEventListener('click', refreshAll);
app.querySelector('[data-q]')?.addEventListener('input', (e) => { state.q = e.target.value; });
app.querySelector('[data-from]')?.addEventListener('input', (e) => { state.from = e.target.value; });
app.querySelector('[data-to]')?.addEventListener('input', (e) => { state.to = e.target.value; });
app.querySelector('[data-model]')?.addEventListener('input', (e) => { state.model = e.target.value; });
app.querySelector('[data-status]')?.addEventListener('change', (e) => { state.status = e.target.value; });
app.querySelector('[data-apply]')?.addEventListener('click', async () => {
await Promise.all([loadStats(), loadLogs({ reset: true })]);
});
app.querySelector('[data-clear]')?.addEventListener('click', async () => {
state.q = '';
state.status = '';
state.model = '';
state.from = '';
state.to = '';
state.detail = null;
render();
await refreshAll();
});
for (const el of app.querySelectorAll('[data-open]')) {
el.addEventListener('click', (e) => {
const id = e.currentTarget.getAttribute('data-open');
openDetail(id);
});
}
}
function styles() {
return `
<style>
:root {
--bg: #0f1a20;
--panel: rgba(255,255,255,0.06);
--panel2: rgba(255,255,255,0.08);
--ink: rgba(255,255,255,0.92);
--muted: rgba(255,255,255,0.65);
--line: rgba(255,255,255,0.12);
--shadow: 0 14px 35px rgba(0,0,0,0.35);
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: var(--sans);
background:
radial-gradient(900px 500px at 15% 10%, rgba(90, 191, 245, 0.18), transparent 60%),
radial-gradient(700px 420px at 80% 30%, rgba(255, 150, 92, 0.16), transparent 60%),
linear-gradient(180deg, #0b1318, var(--bg));
color: var(--ink);
}
.page { min-height: 100dvh; padding: 18px 14px calc(18px + env(safe-area-inset-bottom)); }
.top {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 14px;
border: 1px solid var(--line);
border-radius: 18px;
background: rgba(255,255,255,0.06);
box-shadow: var(--shadow);
backdrop-filter: blur(10px);
}
.brand { display: flex; align-items: center; gap: 12px; }
.mark {
width: 40px; height: 40px; border-radius: 14px;
display: grid; place-items: center;
font-weight: 800;
font-family: var(--mono);
background: linear-gradient(135deg, rgba(90,191,245,0.9), rgba(22,85,120,0.9));
box-shadow: 0 10px 28px rgba(0,0,0,0.35);
}
.h { font-size: 18px; font-weight: 800; letter-spacing: 0.2px; }
.p { margin-top: 8px; color: var(--muted); line-height: 1.4; }
.small { color: var(--muted); font-size: 12px; margin-top: 10px; }
.actions { display: flex; gap: 8px; }
button {
padding: 10px 12px;
border-radius: 14px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.08);
color: var(--ink);
font-weight: 750;
cursor: pointer;
}
button:disabled { opacity: 0.55; cursor: default; }
input, select {
width: 100%;
padding: 10px 12px;
border-radius: 14px;
border: 1px solid var(--line);
background: rgba(0,0,0,0.2);
color: var(--ink);
outline: none;
font-size: 16px;
}
.grid {
display: grid;
grid-template-columns: 1fr;
gap: 12px;
margin-top: 12px;
}
.panel {
border: 1px solid var(--line);
border-radius: 18px;
background: rgba(255,255,255,0.06);
box-shadow: var(--shadow);
padding: 12px;
}
.panelH { font-weight: 800; margin-bottom: 10px; color: rgba(255,255,255,0.88); }
.filters { display: grid; gap: 10px; }
.row { display: flex; gap: 10px; }
.row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.stats { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.stat { padding: 10px; border-radius: 14px; border: 1px solid var(--line); background: rgba(0,0,0,0.18); }
.k { font-size: 12px; color: var(--muted); font-family: var(--mono); }
.v { font-size: 18px; font-weight: 850; margin-top: 3px; }
.table { display: grid; gap: 10px; }
.rowBtn {
text-align: left;
width: 100%;
border: 1px solid var(--line);
border-radius: 18px;
background: rgba(0,0,0,0.16);
padding: 12px;
}
.rowTop { display: grid; grid-template-columns: auto 1fr auto auto; gap: 10px; align-items: center; }
.pill {
font-family: var(--mono);
font-size: 12px;
padding: 4px 8px;
border-radius: 999px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.06);
width: fit-content;
}
.pill.ok { border-color: rgba(74, 222, 128, 0.35); }
.pill.error { border-color: rgba(248, 113, 113, 0.45); }
.pill.aborted { border-color: rgba(251, 191, 36, 0.45); }
.pill.started { border-color: rgba(147, 197, 253, 0.45); }
.ts { color: var(--muted); font-size: 12px; }
.mono { font-family: var(--mono); color: var(--muted); font-size: 12px; }
.rowBody { margin-top: 10px; }
.label { color: var(--muted); font-size: 12px; font-family: var(--mono); }
.text { margin-top: 4px; line-height: 1.4; white-space: pre-wrap; word-break: break-word; }
.empty { color: var(--muted); padding: 10px; }
.detail .hr { height: 1px; background: var(--line); margin: 10px 0; }
.pre {
white-space: pre-wrap;
word-break: break-word;
padding: 10px;
border-radius: 14px;
border: 1px solid var(--line);
background: rgba(0,0,0,0.20);
margin: 6px 0 0;
font-family: var(--mono);
font-size: 12px;
line-height: 1.45;
max-height: 42vh;
overflow: auto;
-webkit-overflow-scrolling: touch;
}
.card {
max-width: 560px;
margin: 12vh auto 0;
border: 1px solid var(--line);
border-radius: 18px;
background: rgba(255,255,255,0.06);
box-shadow: var(--shadow);
padding: 16px;
backdrop-filter: blur(10px);
}
@media (min-width: 1000px) {
.grid { grid-template-columns: 420px 1fr 1fr; align-items: start; }
.panel { min-height: 280px; }
}
</style>
`;
}
function num(x) {
if (x === null || x === undefined) return '-';
if (typeof x === 'number') return String(Math.round(x));
return String(x);
}
function escapeHtml(s) {
return String(s)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}

554
web/src/main.js Normal file
View File

@@ -0,0 +1,554 @@
import { nanoid } from 'nanoid';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
const app = document.querySelector('#app');
if (window.location.pathname.startsWith('/admin')) {
import('./admin.js');
} else {
bootChat();
}
function bootChat() {
const state = {
chatId: loadOrCreateChatId(),
model: 'gpt-5.2',
models: null,
messages: [
{ role: 'assistant', content: 'Xin chao! Ban muon hoi gi?' },
],
streaming: false,
abortController: null,
};
render();
loadModels();
function loadOrCreateChatId() {
const key = 'fw_chat_id';
const existing = localStorage.getItem(key);
if (existing) return existing;
const id = nanoid();
localStorage.setItem(key, id);
return id;
}
async function loadModels() {
try {
const res = await fetch('/api/models');
if (!res.ok) return;
const json = await res.json();
const ids = (json?.data || [])
.map((m) => m?.id)
.filter((x) => typeof x === 'string');
if (ids.length) {
state.models = ids;
if (!ids.includes(state.model)) {
state.model = ids[0];
}
render();
}
} catch {
// ignore
}
}
function setComposerEnabled(enabled) {
const textarea = app.querySelector('textarea');
const btn = app.querySelector('button[data-send]');
if (textarea) textarea.disabled = !enabled;
if (btn) btn.disabled = !enabled;
}
function scrollToBottom({ force } = { force: false }) {
const list = app.querySelector('[data-list]');
if (!list) return;
if (!force) {
const nearBottom = list.scrollHeight - list.scrollTop - list.clientHeight < 120;
if (!nearBottom) return;
}
list.scrollTop = list.scrollHeight;
}
function render() {
app.innerHTML = `
<div class="shell">
<header class="top">
<div class="brand">
<div class="mark">FW</div>
<div class="title">Chat</div>
</div>
<div class="controls">
<select data-model ${state.streaming ? 'disabled' : ''}>
${(state.models || [state.model]).map((id) => {
const sel = id === state.model ? 'selected' : '';
return `<option value="${escapeHtml(id)}" ${sel}>${escapeHtml(id)}</option>`;
}).join('')}
</select>
<button data-new ${state.streaming ? 'disabled' : ''} title="New chat">New</button>
</div>
</header>
<main class="main" data-list>
${state.messages.map((m) => renderMsg(m)).join('')}
</main>
<footer class="composer">
<div class="composerInner">
<textarea data-input rows="1" placeholder="Ask in Vietnamese or English..." inputmode="text"></textarea>
<div class="actions">
<button data-send ${state.streaming ? 'disabled' : ''}>Send</button>
<button data-stop ${state.streaming ? '' : 'disabled'}>Stop</button>
</div>
</div>
<div class="hint">Local network app • Streaming enabled</div>
</footer>
</div>
<style>
:root {
--bg: #f7f2ea;
--panel: #ffffff;
--ink: #1f2328;
--muted: #5b636a;
--line: #e7dccb;
--accent: #0b4f6c;
--accent2: #d87a49;
--shadow: 0 10px 25px rgba(31, 35, 40, 0.08);
--radius: 18px;
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: var(--sans);
color: var(--ink);
background:
radial-gradient(1200px 600px at 15% 0%, rgba(11, 79, 108, 0.10), transparent 60%),
radial-gradient(900px 500px at 90% 20%, rgba(216, 122, 73, 0.13), transparent 60%),
linear-gradient(180deg, #fbf6ef, var(--bg));
}
.shell {
height: 100dvh;
display: grid;
grid-template-rows: auto 1fr auto;
}
.top {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
padding: calc(12px + env(safe-area-inset-top)) 14px 12px;
background: rgba(255, 255, 255, 0.78);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--line);
}
.brand { display: flex; align-items: center; gap: 10px; }
.mark {
width: 36px; height: 36px;
border-radius: 12px;
display: grid; place-items: center;
background: linear-gradient(135deg, var(--accent), #153243);
color: #fff;
font-family: var(--mono);
font-weight: 700;
letter-spacing: 0.5px;
box-shadow: var(--shadow);
}
.title { font-weight: 720; letter-spacing: 0.2px; }
.controls { display: flex; align-items: center; gap: 8px; }
select {
max-width: 52vw;
padding: 8px 10px;
border-radius: 12px;
border: 1px solid var(--line);
background: var(--panel);
color: var(--ink);
font-size: 14px;
}
button {
padding: 9px 12px;
border-radius: 12px;
border: 1px solid var(--line);
background: var(--panel);
color: var(--ink);
font-weight: 650;
font-size: 14px;
}
button:disabled { opacity: 0.55; }
button[data-send] {
border-color: rgba(11,79,108,0.25);
background: linear-gradient(180deg, rgba(11,79,108,0.12), rgba(11,79,108,0.06));
}
button[data-stop] {
border-color: rgba(216,122,73,0.35);
background: linear-gradient(180deg, rgba(216,122,73,0.14), rgba(216,122,73,0.06));
}
.main {
overflow: auto;
-webkit-overflow-scrolling: touch;
padding: 18px 14px 10px;
}
.msg {
display: grid;
grid-template-columns: 42px 1fr;
gap: 10px;
margin: 0 0 14px;
animation: fadeIn 220ms ease-out both;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.avatar {
width: 42px; height: 42px;
border-radius: 16px;
display: grid; place-items: center;
font-family: var(--mono);
font-weight: 700;
color: #fff;
box-shadow: var(--shadow);
}
.avatar.user { background: linear-gradient(135deg, #2d6a4f, #1b4332); }
.avatar.assistant { background: linear-gradient(135deg, var(--accent), #153243); }
.bubble {
background: rgba(255,255,255,0.90);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: 0 12px 26px rgba(0,0,0,0.06);
padding: 12px 14px;
line-height: 1.45;
word-break: break-word;
}
.bubble.md { white-space: normal; }
.bubble.md p { margin: 0 0 10px; }
.bubble.md p:last-child { margin-bottom: 0; }
.bubble.md ul, .bubble.md ol { margin: 8px 0 10px 20px; padding: 0; }
.bubble.md li { margin: 4px 0; }
.bubble.md code {
font-family: var(--mono);
font-size: 0.95em;
background: rgba(231,220,203,0.55);
border: 1px solid rgba(231,220,203,0.95);
padding: 0 6px;
border-radius: 10px;
}
.bubble.md pre {
margin: 10px 0;
padding: 12px;
background: rgba(15, 26, 32, 0.06);
border: 1px solid rgba(231,220,203,0.95);
border-radius: 14px;
overflow: auto;
-webkit-overflow-scrolling: touch;
}
.bubble.md pre code {
background: transparent;
border: none;
padding: 0;
}
.bubble.md a { color: var(--accent); text-decoration: underline; }
.bubble.md blockquote {
margin: 10px 0;
padding: 10px 12px;
border-left: 3px solid rgba(11,79,108,0.35);
background: rgba(11,79,108,0.06);
border-radius: 12px;
}
.meta {
margin-top: 6px;
color: var(--muted);
font-size: 12px;
font-family: var(--mono);
}
.composer {
padding: 10px 12px calc(12px + env(safe-area-inset-bottom));
border-top: 1px solid var(--line);
background: rgba(255, 255, 255, 0.86);
backdrop-filter: blur(12px);
}
.composerInner {
display: grid;
grid-template-columns: 1fr auto;
gap: 10px;
align-items: end;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 16px;
box-shadow: var(--shadow);
padding: 10px;
}
textarea {
border: none;
outline: none;
resize: none;
width: 100%;
font-size: 16px; /* prevent iOS zoom */
line-height: 1.35;
background: transparent;
color: var(--ink);
max-height: 140px;
}
.actions { display: flex; gap: 8px; }
.hint {
margin-top: 8px;
color: var(--muted);
font-size: 12px;
}
@media (min-width: 980px) {
.shell {
max-width: 980px;
margin: 0 auto;
border-left: 1px solid rgba(231,220,203,0.65);
border-right: 1px solid rgba(231,220,203,0.65);
background: rgba(255,255,255,0.35);
}
.main { padding: 22px 18px 12px; }
.top { padding: 14px 18px 12px; }
.composer { padding: 12px 18px 18px; }
}
</style>
`;
wire();
requestAnimationFrame(() => scrollToBottom({ force: true }));
}
function renderMsg(m) {
const who = m.role === 'user' ? 'user' : 'assistant';
const label = who === 'user' ? 'YOU' : 'AI';
const meta = m.meta ? `<div class="meta">${escapeHtml(m.meta)}</div>` : '';
const contentHtml = who === 'assistant'
? renderMarkdownToSafeHtml(m.content || '')
: escapeHtml(m.content || '');
return `
<div class="msg" data-msg-role="${who}">
<div class="avatar ${who}">${label}</div>
<div>
<div class="bubble ${who === 'assistant' ? 'md' : ''}" data-msg-content>${contentHtml}</div>
${meta}
</div>
</div>
`;
}
function wire() {
const textarea = app.querySelector('textarea[data-input]');
const sendBtn = app.querySelector('button[data-send]');
const stopBtn = app.querySelector('button[data-stop]');
const modelSel = app.querySelector('select[data-model]');
const newBtn = app.querySelector('button[data-new]');
modelSel?.addEventListener('change', (e) => {
state.model = e.target.value;
});
newBtn?.addEventListener('click', () => {
if (state.streaming) return;
state.messages = [{ role: 'assistant', content: 'Xin chao! Ban muon hoi gi?' }];
render();
focusComposer();
});
textarea?.addEventListener('input', () => {
autoGrow(textarea);
});
textarea?.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
send();
}
});
sendBtn?.addEventListener('click', send);
stopBtn?.addEventListener('click', stop);
// Initial focus on desktop; on iOS, avoid aggressive auto-focus.
if (!isIos()) {
focusComposer();
}
}
function focusComposer() {
const textarea = app.querySelector('textarea[data-input]');
textarea?.focus();
}
function autoGrow(textarea) {
textarea.style.height = 'auto';
textarea.style.height = `${Math.min(textarea.scrollHeight, 140)}px`;
}
function stop() {
if (state.abortController) {
state.abortController.abort();
}
}
async function send() {
if (state.streaming) return;
const textarea = app.querySelector('textarea[data-input]');
const prompt = (textarea?.value || '').trim();
if (!prompt) return;
textarea.value = '';
autoGrow(textarea);
state.messages.push({ role: 'user', content: prompt });
const aiMsg = { role: 'assistant', content: '' };
state.messages.push(aiMsg);
state.streaming = true;
render();
setComposerEnabled(false);
// After initial render, keep a handle to the last assistant bubble.
const aiEl = getLastAssistantContentEl();
state.abortController = new AbortController();
const startedAt = performance.now();
let requestId = null;
try {
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: state.abortController.signal,
body: JSON.stringify({
chat_id: state.chatId,
model: state.model,
stream: true,
messages: state.messages.filter((m) => m.role === 'user' || m.role === 'assistant' || m.role === 'system'),
}),
});
requestId = res.headers.get('x-request-id');
if (!res.ok || !res.body) {
const t = await res.text();
throw new Error(`HTTP ${res.status}: ${t}`);
}
await readSseStream(res.body, (delta) => {
aiMsg.content += delta;
if (aiEl) {
aiEl.innerHTML = renderMarkdownToSafeHtml(aiMsg.content);
}
scrollToBottom();
});
const ms = Math.round(performance.now() - startedAt);
aiMsg.meta = `model=${state.model}${ms}ms${requestId ? ` • id=${requestId}` : ''}`;
} catch (e) {
const isAbort = state.abortController?.signal?.aborted;
aiMsg.content = aiMsg.content || (isAbort ? '(stopped)' : `Error: ${String(e.message || e)}`);
aiMsg.meta = isAbort ? 'stopped' : 'error';
} finally {
state.streaming = false;
state.abortController = null;
render();
setComposerEnabled(true);
if (!isIos()) focusComposer();
}
}
function getLastAssistantContentEl() {
const nodes = app.querySelectorAll('[data-msg-role="assistant"]');
const last = nodes[nodes.length - 1];
if (!last) return null;
return last.querySelector('[data-msg-content]');
}
function renderMarkdownToSafeHtml(md) {
const html = marked.parse(md || '', {
gfm: true,
breaks: true,
});
return DOMPurify.sanitize(html, {
USE_PROFILES: { html: true },
});
}
async function readSseStream(body, onDelta) {
const reader = body.getReader();
const decoder = new TextDecoder();
let buf = '';
let pending = '';
let raf = 0;
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const parts = buf.split('\n\n');
buf = parts.pop() || '';
for (const part of parts) {
const lines = part.split('\n');
for (const line of lines) {
const t = line.trim();
if (!t.startsWith('data:')) continue;
const payload = t.slice(5).trim();
if (!payload || payload === '[DONE]') continue;
let evt;
try {
evt = JSON.parse(payload);
} catch {
continue;
}
const delta = evt?.choices?.[0]?.delta?.content;
if (typeof delta === 'string' && delta.length) {
pending += delta;
if (!raf) {
raf = requestAnimationFrame(() => {
raf = 0;
const flush = pending;
pending = '';
if (flush) onDelta(flush);
});
}
}
}
}
}
if (raf) {
cancelAnimationFrame(raf);
raf = 0;
}
if (pending) onDelta(pending);
}
function escapeHtml(s) {
return String(s)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function isIos() {
const ua = navigator.userAgent || '';
return /iPad|iPhone|iPod/.test(ua);
}
}

13
web/vite.config.js Normal file
View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'vite';
export default defineConfig({
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8787',
changeOrigin: true,
},
},
},
});