Initial Firmware chat web app
This commit is contained in:
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.DS_Store
|
||||
.env
|
||||
.env.*
|
||||
data/
|
||||
*.sqlite
|
||||
*.sqlite-wal
|
||||
*.sqlite-shm
|
||||
13
CHECKLIST.md
Normal file
13
CHECKLIST.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# Checklist
|
||||
|
||||
## Web admin
|
||||
|
||||
- [x] Browser-based admin UI (`/admin`) for logs + stats
|
||||
|
||||
## Android admin app
|
||||
|
||||
- [ ] Kotlin + Compose app
|
||||
- [ ] Store `ADMIN_TOKEN` securely (EncryptedSharedPreferences/Keystore)
|
||||
- [ ] Logs list/search/detail using `/api/logs` + `/api/logs/:request_id`
|
||||
- [ ] Stats views using `/api/stats/*`
|
||||
- [ ] Base URL config for local network host (e.g. `http://192.168.88.2:8787`)
|
||||
84
README.md
Normal file
84
README.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# Firmware Chat Web
|
||||
|
||||
ChatGPT-like web app backed by Firmware (https://docs.firmware.ai/) with SQLite logging and admin read-only APIs.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js 22+
|
||||
- `FIRMWARE_API_KEY` must be exported in your shell
|
||||
- `ADMIN_TOKEN` (protects admin endpoints)
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env`:
|
||||
|
||||
```bash
|
||||
ADMIN_TOKEN=...
|
||||
```
|
||||
|
||||
Export your Firmware key in the same shell you run the app:
|
||||
|
||||
```bash
|
||||
export FIRMWARE_API_KEY=...
|
||||
```
|
||||
|
||||
Note: `.env` is loaded by `./run.sh` for non-secret config (like `ADMIN_TOKEN`, `PORT`).
|
||||
|
||||
## Run (dev)
|
||||
|
||||
```bash
|
||||
./run.sh dev
|
||||
```
|
||||
|
||||
- Web: http://localhost:5173
|
||||
- API: http://localhost:8787
|
||||
|
||||
## Run on LAN (example: 192.168.88.2)
|
||||
|
||||
Set in `.env`:
|
||||
|
||||
```bash
|
||||
HOST=0.0.0.0
|
||||
PORT=8787
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
./run.sh build
|
||||
./run.sh server
|
||||
```
|
||||
|
||||
Open from another device:
|
||||
|
||||
- http://192.168.88.2:8787
|
||||
|
||||
## Run (prod-ish)
|
||||
|
||||
```bash
|
||||
./run.sh build
|
||||
./run.sh server
|
||||
```
|
||||
|
||||
## Admin APIs
|
||||
|
||||
Send `x-admin-token: $ADMIN_TOKEN`:
|
||||
|
||||
- `GET /api/logs`
|
||||
- `GET /api/logs/:request_id`
|
||||
- `GET /api/stats/summary`
|
||||
- `GET /api/stats/models`
|
||||
- `GET /api/stats/timeseries`
|
||||
|
||||
## Admin UI (browser)
|
||||
|
||||
Open:
|
||||
|
||||
- `/admin`
|
||||
|
||||
Enter `ADMIN_TOKEN` once to start a cookie-based session.
|
||||
2774
package-lock.json
generated
Normal file
2774
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
package.json
Normal file
17
package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "firmware-chat-web",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"server",
|
||||
"web"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "concurrently -n server,web -c blue,green \"npm -w server run dev\" \"npm -w web run dev\"",
|
||||
"build": "npm -w web run build",
|
||||
"start": "npm -w server run start",
|
||||
"lint": "npm -w server run lint && npm -w web run lint"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.0.0"
|
||||
}
|
||||
}
|
||||
78
run.sh
Executable file
78
run.sh
Executable file
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cmd="${1:-dev}"
|
||||
|
||||
if [[ ! -f ".env" ]]; then
|
||||
if [[ -f ".env.example" ]]; then
|
||||
cp .env.example .env
|
||||
echo "Created .env from .env.example. Please edit it (FIRMWARE_API_KEY, ADMIN_TOKEN)." >&2
|
||||
else
|
||||
echo "Missing .env (and .env.example)." >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -f ".env" ]]; then
|
||||
# Load .env without overriding already-exported vars.
|
||||
# Intentionally skip FIRMWARE_API_KEY (should come from shell env).
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
[[ -z "$line" ]] && continue
|
||||
[[ "$line" =~ ^[[:space:]]*# ]] && continue
|
||||
if [[ "$line" =~ ^[[:space:]]*([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
|
||||
key="${BASH_REMATCH[1]}"
|
||||
val="${BASH_REMATCH[2]}"
|
||||
|
||||
if [[ "$key" == "FIRMWARE_API_KEY" ]]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Strip optional surrounding quotes
|
||||
if [[ "$val" =~ ^\"(.*)\"$ ]]; then
|
||||
val="${BASH_REMATCH[1]}"
|
||||
elif [[ "$val" =~ ^\'(.*)\'$ ]]; then
|
||||
val="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
|
||||
if [[ -z "${!key:-}" ]]; then
|
||||
export "$key=$val"
|
||||
fi
|
||||
fi
|
||||
done < .env
|
||||
fi
|
||||
|
||||
if [[ -z "${FIRMWARE_API_KEY:-}" ]]; then
|
||||
echo "FIRMWARE_API_KEY must be exported in your shell." >&2
|
||||
echo "Example: export FIRMWARE_API_KEY=..." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -d "node_modules" ]]; then
|
||||
npm install
|
||||
fi
|
||||
|
||||
case "$cmd" in
|
||||
build)
|
||||
echo "Building web..." >&2
|
||||
npm -w web run build
|
||||
;;
|
||||
dev)
|
||||
echo "Starting dev (web + server)..." >&2
|
||||
echo "Web: http://localhost:5173" >&2
|
||||
echo "API: http://localhost:${PORT:-8787}" >&2
|
||||
echo "Admin: add header x-admin-token: \$ADMIN_TOKEN" >&2
|
||||
npm run dev
|
||||
;;
|
||||
server)
|
||||
echo "Starting server only..." >&2
|
||||
echo "Binding: http://${HOST:-0.0.0.0}:${PORT:-8787}" >&2
|
||||
npm -w server run start
|
||||
;;
|
||||
web)
|
||||
echo "Starting web only..." >&2
|
||||
npm -w web run dev
|
||||
;;
|
||||
*)
|
||||
echo "Usage: ./run.sh [dev|server|web|build]" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
21
scripts/admin-demo.sh
Executable file
21
scripts/admin-demo.sh
Executable file
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ -z "${ADMIN_TOKEN:-}" ]]; then
|
||||
echo "ADMIN_TOKEN env var required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BASE="${BASE_URL:-http://localhost:8787}"
|
||||
|
||||
echo "== health"
|
||||
curl -sS "$BASE/api/health" | head -c 300 || true
|
||||
echo
|
||||
|
||||
echo "== latest logs"
|
||||
curl -sS -H "x-admin-token: $ADMIN_TOKEN" "$BASE/api/logs?limit=5" | head -c 2000 || true
|
||||
echo
|
||||
|
||||
echo "== stats summary"
|
||||
curl -sS -H "x-admin-token: $ADMIN_TOKEN" "$BASE/api/stats/summary" | head -c 500 || true
|
||||
echo
|
||||
18
server/package.json
Normal file
18
server/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "firmware-chat-server",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"dev": "node --watch src/index.js",
|
||||
"start": "node src/index.js",
|
||||
"lint": "node -c src/index.js && node -c src/db.js && node -c src/admin.js && node -c src/firmware.js && node -c src/util.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^11.6.0",
|
||||
"cors": "^2.8.5",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"express": "^4.19.2",
|
||||
"nanoid": "^5.0.7"
|
||||
}
|
||||
}
|
||||
270
server/src/admin.js
Normal file
270
server/src/admin.js
Normal file
@@ -0,0 +1,270 @@
|
||||
import { clampInt } from './util.js';
|
||||
|
||||
export function requireAdmin(req, res, next) {
|
||||
const want = process.env.ADMIN_TOKEN || '';
|
||||
const got = req.header('x-admin-token') || '';
|
||||
if (!want || got !== want) {
|
||||
res.status(401).json({ error: 'unauthorized' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
export function requireAdminOrCookie(req, res, next) {
|
||||
const want = process.env.ADMIN_TOKEN || '';
|
||||
const got = req.header('x-admin-token') || '';
|
||||
const cookieToken = req.cookies?.admin_token || '';
|
||||
if (!want || (got !== want && cookieToken !== want)) {
|
||||
res.status(401).json({ error: 'unauthorized' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
export function registerAdminRoutes(app, db) {
|
||||
app.get('/api/logs', requireAdminOrCookie, (req, res) => {
|
||||
const limit = clampInt(req.query.limit ?? '50', { min: 1, max: 200, fallback: 50 });
|
||||
const offset = clampInt(req.query.offset ?? '0', { min: 0, max: 1_000_000, fallback: 0 });
|
||||
|
||||
const from = req.query.from ? Number(req.query.from) : null;
|
||||
const to = req.query.to ? Number(req.query.to) : null;
|
||||
const chatId = req.query.chat_id ? String(req.query.chat_id) : null;
|
||||
const model = req.query.model ? String(req.query.model) : null;
|
||||
const status = req.query.status ? String(req.query.status) : null;
|
||||
const q = req.query.q ? String(req.query.q) : null;
|
||||
|
||||
const where = [];
|
||||
const params = {};
|
||||
if (from && Number.isFinite(from)) {
|
||||
where.push('l.ts_request >= @from');
|
||||
params.from = from;
|
||||
}
|
||||
if (to && Number.isFinite(to)) {
|
||||
where.push('l.ts_request <= @to');
|
||||
params.to = to;
|
||||
}
|
||||
if (chatId) {
|
||||
where.push('l.chat_id = @chat_id');
|
||||
params.chat_id = chatId;
|
||||
}
|
||||
if (model) {
|
||||
where.push('l.model = @model');
|
||||
params.model = model;
|
||||
}
|
||||
if (status) {
|
||||
where.push('l.status = @status');
|
||||
params.status = status;
|
||||
}
|
||||
|
||||
let sql;
|
||||
if (q) {
|
||||
// FTS5 search across prompt+answer
|
||||
// Note: exact syntax is SQLite FTS5; we pass user string through as-is.
|
||||
sql = `
|
||||
SELECT
|
||||
l.request_id,
|
||||
l.chat_id,
|
||||
l.ts_request,
|
||||
l.ts_first_token,
|
||||
l.ts_done,
|
||||
l.model,
|
||||
l.status,
|
||||
substr(coalesce(l.user_text,''), 1, 300) AS user_preview,
|
||||
substr(coalesce(l.assistant_text,''), 1, 800) AS assistant_preview,
|
||||
l.total_tokens,
|
||||
(coalesce(l.ts_done, l.ts_request) - l.ts_request) AS latency_ms
|
||||
FROM chat_log_fts f
|
||||
JOIN chat_log l ON l.id = f.rowid
|
||||
${where.length ? `WHERE ${where.join(' AND ')} AND chat_log_fts MATCH @q` : 'WHERE chat_log_fts MATCH @q'}
|
||||
ORDER BY l.ts_request DESC
|
||||
LIMIT @limit OFFSET @offset
|
||||
`;
|
||||
params.q = q;
|
||||
} else {
|
||||
sql = `
|
||||
SELECT
|
||||
request_id,
|
||||
chat_id,
|
||||
ts_request,
|
||||
ts_first_token,
|
||||
ts_done,
|
||||
model,
|
||||
status,
|
||||
substr(coalesce(user_text,''), 1, 300) AS user_preview,
|
||||
substr(coalesce(assistant_text,''), 1, 800) AS assistant_preview,
|
||||
total_tokens,
|
||||
(coalesce(ts_done, ts_request) - ts_request) AS latency_ms
|
||||
FROM chat_log
|
||||
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
||||
ORDER BY ts_request DESC
|
||||
LIMIT @limit OFFSET @offset
|
||||
`;
|
||||
}
|
||||
|
||||
params.limit = limit;
|
||||
params.offset = offset;
|
||||
try {
|
||||
const rows = db.prepare(sql).all(params);
|
||||
res.json({ limit, offset, rows });
|
||||
} catch (e) {
|
||||
res.status(400).json({ error: 'bad_query', detail: String(e.message || e) });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/logs/:request_id', requireAdminOrCookie, (req, res) => {
|
||||
const row = db.prepare('SELECT * FROM chat_log WHERE request_id = ?').get(req.params.request_id);
|
||||
if (!row) {
|
||||
res.status(404).json({ error: 'not_found' });
|
||||
return;
|
||||
}
|
||||
res.json(row);
|
||||
});
|
||||
|
||||
app.get('/api/stats/summary', requireAdminOrCookie, (req, res) => {
|
||||
const from = req.query.from ? Number(req.query.from) : null;
|
||||
const to = req.query.to ? Number(req.query.to) : null;
|
||||
const where = [];
|
||||
const params = {};
|
||||
if (from && Number.isFinite(from)) {
|
||||
where.push('ts_request >= @from');
|
||||
params.from = from;
|
||||
}
|
||||
if (to && Number.isFinite(to)) {
|
||||
where.push('ts_request <= @to');
|
||||
params.to = to;
|
||||
}
|
||||
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
||||
|
||||
const row = db.prepare(`
|
||||
SELECT
|
||||
count(*) AS total,
|
||||
sum(CASE WHEN status = 'ok' THEN 1 ELSE 0 END) AS ok,
|
||||
sum(CASE WHEN status = 'error' THEN 1 ELSE 0 END) AS error,
|
||||
sum(CASE WHEN status = 'aborted' THEN 1 ELSE 0 END) AS aborted,
|
||||
avg(CASE WHEN ts_done IS NOT NULL THEN (ts_done - ts_request) END) AS avg_latency_ms,
|
||||
sum(coalesce(prompt_tokens, 0)) AS prompt_tokens,
|
||||
sum(coalesce(completion_tokens, 0)) AS completion_tokens,
|
||||
sum(coalesce(total_tokens, 0)) AS total_tokens
|
||||
FROM chat_log
|
||||
${whereSql}
|
||||
`).get(params);
|
||||
|
||||
const countRow = db.prepare(`
|
||||
SELECT count(*) AS n
|
||||
FROM chat_log
|
||||
${whereSql}${whereSql ? ' AND' : ' WHERE'} ts_done IS NOT NULL
|
||||
`).get(params);
|
||||
const n = Number(countRow?.n || 0);
|
||||
let p95_latency_ms = null;
|
||||
if (n > 0) {
|
||||
const idx = Math.max(0, Math.floor(0.95 * (n - 1)));
|
||||
const p = { ...params, idx };
|
||||
const p95 = db.prepare(`
|
||||
SELECT (ts_done - ts_request) AS latency_ms
|
||||
FROM chat_log
|
||||
${whereSql}${whereSql ? ' AND' : ' WHERE'} ts_done IS NOT NULL
|
||||
ORDER BY (ts_done - ts_request) ASC
|
||||
LIMIT 1 OFFSET @idx
|
||||
`).get(p);
|
||||
p95_latency_ms = p95?.latency_ms ?? null;
|
||||
}
|
||||
|
||||
res.json({
|
||||
...row,
|
||||
p95_latency_ms,
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/stats/models', requireAdminOrCookie, (req, res) => {
|
||||
const from = req.query.from ? Number(req.query.from) : null;
|
||||
const to = req.query.to ? Number(req.query.to) : null;
|
||||
const where = [];
|
||||
const params = {};
|
||||
if (from && Number.isFinite(from)) {
|
||||
where.push('ts_request >= @from');
|
||||
params.from = from;
|
||||
}
|
||||
if (to && Number.isFinite(to)) {
|
||||
where.push('ts_request <= @to');
|
||||
params.to = to;
|
||||
}
|
||||
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT
|
||||
model,
|
||||
count(*) AS total,
|
||||
sum(CASE WHEN status = 'ok' THEN 1 ELSE 0 END) AS ok,
|
||||
sum(CASE WHEN status = 'error' THEN 1 ELSE 0 END) AS error,
|
||||
avg(CASE WHEN ts_done IS NOT NULL THEN (ts_done - ts_request) END) AS avg_latency_ms,
|
||||
sum(coalesce(total_tokens, 0)) AS total_tokens
|
||||
FROM chat_log
|
||||
${whereSql}
|
||||
GROUP BY model
|
||||
ORDER BY total DESC
|
||||
`).all(params);
|
||||
res.json({ rows });
|
||||
});
|
||||
|
||||
app.get('/api/stats/timeseries', requireAdminOrCookie, (req, res) => {
|
||||
const from = req.query.from ? Number(req.query.from) : null;
|
||||
const to = req.query.to ? Number(req.query.to) : null;
|
||||
const bucket = req.query.bucket === 'day' ? 'day' : 'hour';
|
||||
|
||||
const where = [];
|
||||
const params = {};
|
||||
if (from && Number.isFinite(from)) {
|
||||
where.push('ts_request >= @from');
|
||||
params.from = from;
|
||||
}
|
||||
if (to && Number.isFinite(to)) {
|
||||
where.push('ts_request <= @to');
|
||||
params.to = to;
|
||||
}
|
||||
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
||||
|
||||
// Bucket by hour/day using Unix epoch seconds then scale.
|
||||
const bucketExpr = bucket === 'day'
|
||||
? "strftime('%Y-%m-%d', ts_request/1000, 'unixepoch')"
|
||||
: "strftime('%Y-%m-%d %H:00', ts_request/1000, 'unixepoch')";
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT
|
||||
${bucketExpr} AS bucket,
|
||||
count(*) AS total,
|
||||
sum(CASE WHEN status = 'ok' THEN 1 ELSE 0 END) AS ok,
|
||||
sum(CASE WHEN status = 'error' THEN 1 ELSE 0 END) AS error,
|
||||
avg(CASE WHEN ts_done IS NOT NULL THEN (ts_done - ts_request) END) AS avg_latency_ms
|
||||
FROM chat_log
|
||||
${whereSql}
|
||||
GROUP BY bucket
|
||||
ORDER BY bucket ASC
|
||||
`).all(params);
|
||||
|
||||
res.json({ bucket, rows });
|
||||
});
|
||||
|
||||
// Browser-based admin session helper (same origin).
|
||||
// Set a cookie so the admin UI can call admin APIs without custom headers.
|
||||
app.post('/api/admin/session', (req, res) => {
|
||||
const want = process.env.ADMIN_TOKEN || '';
|
||||
const got = req.body?.token ? String(req.body.token) : '';
|
||||
if (!want || got !== want) {
|
||||
res.status(401).json({ error: 'unauthorized' });
|
||||
return;
|
||||
}
|
||||
res.cookie('admin_token', got, {
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
secure: false,
|
||||
maxAge: 1000 * 60 * 60 * 12,
|
||||
path: '/',
|
||||
});
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/api/admin/logout', (req, res) => {
|
||||
res.clearCookie('admin_token', { path: '/' });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
}
|
||||
113
server/src/db.js
Normal file
113
server/src/db.js
Normal file
@@ -0,0 +1,113 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import Database from 'better-sqlite3';
|
||||
|
||||
export function openDb(sqlitePath) {
|
||||
const dir = path.dirname(sqlitePath);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const db = new Database(sqlitePath);
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('synchronous = NORMAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
migrate(db);
|
||||
return db;
|
||||
}
|
||||
|
||||
function migrate(db) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS chat_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
request_id TEXT NOT NULL UNIQUE,
|
||||
chat_id TEXT,
|
||||
ts_request INTEGER NOT NULL,
|
||||
ts_first_token INTEGER,
|
||||
ts_done INTEGER,
|
||||
model TEXT NOT NULL,
|
||||
messages_json TEXT NOT NULL,
|
||||
user_text TEXT,
|
||||
assistant_text TEXT,
|
||||
prompt_tokens INTEGER,
|
||||
completion_tokens INTEGER,
|
||||
total_tokens INTEGER,
|
||||
status TEXT NOT NULL,
|
||||
error TEXT,
|
||||
ip TEXT,
|
||||
user_agent TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_log_ts_request ON chat_log(ts_request DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_log_chat_ts ON chat_log(chat_id, ts_request DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_log_model_ts ON chat_log(model, ts_request DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_log_status_ts ON chat_log(status, ts_request DESC);
|
||||
`);
|
||||
|
||||
// FTS5 (prompt+answer search)
|
||||
// content_rowid = chat_log.id
|
||||
db.exec(`
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS chat_log_fts USING fts5(
|
||||
user_text,
|
||||
assistant_text,
|
||||
content='chat_log',
|
||||
content_rowid='id'
|
||||
);
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TRIGGER IF NOT EXISTS chat_log_ai AFTER INSERT ON chat_log BEGIN
|
||||
INSERT INTO chat_log_fts(rowid, user_text, assistant_text)
|
||||
VALUES (new.id, coalesce(new.user_text,''), coalesce(new.assistant_text,''));
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS chat_log_ad AFTER DELETE ON chat_log BEGIN
|
||||
INSERT INTO chat_log_fts(chat_log_fts, rowid, user_text, assistant_text)
|
||||
VALUES('delete', old.id, old.user_text, old.assistant_text);
|
||||
END;
|
||||
CREATE TRIGGER IF NOT EXISTS chat_log_au AFTER UPDATE ON chat_log BEGIN
|
||||
INSERT INTO chat_log_fts(chat_log_fts, rowid, user_text, assistant_text)
|
||||
VALUES('delete', old.id, old.user_text, old.assistant_text);
|
||||
INSERT INTO chat_log_fts(rowid, user_text, assistant_text)
|
||||
VALUES (new.id, coalesce(new.user_text,''), coalesce(new.assistant_text,''));
|
||||
END;
|
||||
`);
|
||||
}
|
||||
|
||||
export function prepareQueries(db) {
|
||||
const insertStart = db.prepare(`
|
||||
INSERT INTO chat_log (
|
||||
request_id, chat_id, ts_request, model, messages_json, user_text,
|
||||
status, ip, user_agent
|
||||
) VALUES (
|
||||
@request_id, @chat_id, @ts_request, @model, @messages_json, @user_text,
|
||||
@status, @ip, @user_agent
|
||||
)
|
||||
`);
|
||||
|
||||
const markFirstToken = db.prepare(`
|
||||
UPDATE chat_log
|
||||
SET ts_first_token = coalesce(ts_first_token, @ts_first_token)
|
||||
WHERE request_id = @request_id
|
||||
`);
|
||||
|
||||
const finish = db.prepare(`
|
||||
UPDATE chat_log
|
||||
SET
|
||||
ts_done = @ts_done,
|
||||
assistant_text = @assistant_text,
|
||||
prompt_tokens = @prompt_tokens,
|
||||
completion_tokens = @completion_tokens,
|
||||
total_tokens = @total_tokens,
|
||||
status = @status,
|
||||
error = @error
|
||||
WHERE request_id = @request_id
|
||||
`);
|
||||
|
||||
const getByRequestId = db.prepare(`
|
||||
SELECT * FROM chat_log WHERE request_id = ?
|
||||
`);
|
||||
|
||||
return {
|
||||
insertStart,
|
||||
markFirstToken,
|
||||
finish,
|
||||
getByRequestId,
|
||||
};
|
||||
}
|
||||
35
server/src/firmware.js
Normal file
35
server/src/firmware.js
Normal file
@@ -0,0 +1,35 @@
|
||||
export const FIRMWARE_BASE_URL = 'https://app.firmware.ai/api/v1';
|
||||
|
||||
export function firmwareHeaders() {
|
||||
const apiKey = process.env.FIRMWARE_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error('FIRMWARE_API_KEY is required');
|
||||
}
|
||||
return {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
export async function firmwareListModels() {
|
||||
const res = await fetch(`${FIRMWARE_BASE_URL}/models`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${process.env.FIRMWARE_API_KEY}`,
|
||||
},
|
||||
});
|
||||
const text = await res.text();
|
||||
let json;
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
json = { raw: text };
|
||||
}
|
||||
if (!res.ok) {
|
||||
const err = new Error(`Firmware models error: ${res.status}`);
|
||||
err.status = res.status;
|
||||
err.body = json;
|
||||
throw err;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
282
server/src/index.js
Normal file
282
server/src/index.js
Normal file
@@ -0,0 +1,282 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { nanoid } from 'nanoid';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { openDb, prepareQueries } from './db.js';
|
||||
import { nowMs, getClientIp, safeJsonParse } from './util.js';
|
||||
import { firmwareHeaders, firmwareListModels, FIRMWARE_BASE_URL } from './firmware.js';
|
||||
import { registerAdminRoutes } from './admin.js';
|
||||
|
||||
const PORT = Number(process.env.PORT || 8787);
|
||||
const HOST = process.env.HOST || '0.0.0.0';
|
||||
const SQLITE_PATH = process.env.SQLITE_PATH || './data/chatlog.sqlite';
|
||||
const DEFAULT_MODEL = process.env.DEFAULT_MODEL || 'gpt-5.2';
|
||||
const MAX_BODY_BYTES = Number(process.env.MAX_BODY_BYTES || 1_048_576);
|
||||
|
||||
const db = openDb(SQLITE_PATH);
|
||||
const q = prepareQueries(db);
|
||||
|
||||
const app = express();
|
||||
app.disable('x-powered-by');
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: MAX_BODY_BYTES }));
|
||||
app.use(cookieParser());
|
||||
|
||||
registerAdminRoutes(app, db);
|
||||
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get('/api/models', async (req, res) => {
|
||||
try {
|
||||
const json = await firmwareListModels();
|
||||
res.json(json);
|
||||
} catch (e) {
|
||||
res.status(e.status || 500).json({ error: 'models_error', detail: e.body || String(e.message || e) });
|
||||
}
|
||||
});
|
||||
|
||||
// Serve built web UI (optional). Run `./run.sh build` first.
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const webDistDir = path.resolve(__dirname, '../../web/dist');
|
||||
const webIndexPath = path.join(webDistDir, 'index.html');
|
||||
const hasWebDist = fs.existsSync(webIndexPath);
|
||||
if (hasWebDist) {
|
||||
app.use(express.static(webDistDir, { index: false }));
|
||||
app.get(/^(?!\/api\/).*/, (req, res) => {
|
||||
res.sendFile(webIndexPath);
|
||||
});
|
||||
}
|
||||
|
||||
function extractLastUserText(messages) {
|
||||
if (!Array.isArray(messages)) return null;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i];
|
||||
if (m && m.role === 'user' && typeof m.content === 'string') return m.content;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
app.post('/api/chat', async (req, res) => {
|
||||
const requestId = nanoid();
|
||||
const tsRequest = nowMs();
|
||||
const ip = getClientIp(req);
|
||||
const userAgent = req.header('user-agent') || null;
|
||||
|
||||
const body = req.body || {};
|
||||
const messages = body.messages;
|
||||
const chatId = body.chat_id ? String(body.chat_id) : null;
|
||||
const model = body.model ? String(body.model) : DEFAULT_MODEL;
|
||||
const stream = body.stream === true;
|
||||
|
||||
if (!Array.isArray(messages) || messages.length === 0) {
|
||||
res.status(400).json({ error: 'bad_request', detail: 'messages[] is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const messagesJson = JSON.stringify(messages);
|
||||
const userText = extractLastUserText(messages);
|
||||
|
||||
q.insertStart.run({
|
||||
request_id: requestId,
|
||||
chat_id: chatId,
|
||||
ts_request: tsRequest,
|
||||
model,
|
||||
messages_json: messagesJson,
|
||||
user_text: userText,
|
||||
status: 'started',
|
||||
ip,
|
||||
user_agent: userAgent,
|
||||
});
|
||||
|
||||
const fwReq = {
|
||||
model,
|
||||
messages,
|
||||
stream,
|
||||
};
|
||||
|
||||
// Pass-through common knobs when present
|
||||
if (typeof body.temperature === 'number') fwReq.temperature = body.temperature;
|
||||
if (typeof body.max_tokens === 'number') fwReq.max_tokens = body.max_tokens;
|
||||
if (typeof body.reasoning_effort === 'string') fwReq.reasoning_effort = body.reasoning_effort;
|
||||
|
||||
let fwRes;
|
||||
try {
|
||||
fwRes = await fetch(`${FIRMWARE_BASE_URL}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: firmwareHeaders(),
|
||||
body: JSON.stringify(fwReq),
|
||||
signal: AbortSignal.timeout(120_000),
|
||||
});
|
||||
} catch (e) {
|
||||
q.finish.run({
|
||||
request_id: requestId,
|
||||
ts_done: nowMs(),
|
||||
assistant_text: null,
|
||||
prompt_tokens: null,
|
||||
completion_tokens: null,
|
||||
total_tokens: null,
|
||||
status: 'error',
|
||||
error: String(e.message || e),
|
||||
});
|
||||
res.status(502).json({ error: 'upstream_error', request_id: requestId });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stream) {
|
||||
const text = await fwRes.text();
|
||||
const json = safeJsonParse(text);
|
||||
if (!fwRes.ok) {
|
||||
q.finish.run({
|
||||
request_id: requestId,
|
||||
ts_done: nowMs(),
|
||||
assistant_text: null,
|
||||
prompt_tokens: null,
|
||||
completion_tokens: null,
|
||||
total_tokens: null,
|
||||
status: 'error',
|
||||
error: json ? JSON.stringify(json) : text,
|
||||
});
|
||||
res.status(fwRes.status).json({ error: 'firmware_error', request_id: requestId, detail: json || text });
|
||||
return;
|
||||
}
|
||||
|
||||
const assistantText = json?.choices?.[0]?.message?.content ?? null;
|
||||
const usage = json?.usage || {};
|
||||
q.finish.run({
|
||||
request_id: requestId,
|
||||
ts_done: nowMs(),
|
||||
assistant_text: assistantText,
|
||||
prompt_tokens: usage.prompt_tokens ?? null,
|
||||
completion_tokens: usage.completion_tokens ?? null,
|
||||
total_tokens: usage.total_tokens ?? null,
|
||||
status: 'ok',
|
||||
error: null,
|
||||
});
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.json({ request_id: requestId, upstream: json });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fwRes.ok || !fwRes.body) {
|
||||
const text = await fwRes.text();
|
||||
q.finish.run({
|
||||
request_id: requestId,
|
||||
ts_done: nowMs(),
|
||||
assistant_text: null,
|
||||
prompt_tokens: null,
|
||||
completion_tokens: null,
|
||||
total_tokens: null,
|
||||
status: 'error',
|
||||
error: text,
|
||||
});
|
||||
res.status(fwRes.status || 502).json({ error: 'firmware_error', request_id: requestId, detail: text });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(200);
|
||||
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-cache, no-transform');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('X-Request-Id', requestId);
|
||||
res.flushHeaders?.();
|
||||
|
||||
let sawFirstToken = false;
|
||||
let assistant = '';
|
||||
let upstreamUsage = null;
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
const reader = fwRes.body.getReader();
|
||||
let buffer = '';
|
||||
let aborted = false;
|
||||
|
||||
const onClose = () => {
|
||||
aborted = true;
|
||||
try { reader.cancel(); } catch {}
|
||||
};
|
||||
req.on('close', onClose);
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
// Forward upstream chunk directly
|
||||
res.write(chunk);
|
||||
|
||||
buffer += chunk;
|
||||
// Parse SSE lines for content accumulation
|
||||
// Split on double-newline boundaries but also handle partial frames.
|
||||
const parts = buffer.split('\n\n');
|
||||
buffer = parts.pop() || '';
|
||||
for (const part of parts) {
|
||||
const lines = part.split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith('data:')) continue;
|
||||
const data = trimmed.slice(5).trim();
|
||||
if (!data) continue;
|
||||
if (data === '[DONE]') {
|
||||
continue;
|
||||
}
|
||||
const evt = safeJsonParse(data);
|
||||
const delta = evt?.choices?.[0]?.delta?.content;
|
||||
if (typeof delta === 'string' && delta.length) {
|
||||
if (!sawFirstToken) {
|
||||
sawFirstToken = true;
|
||||
q.markFirstToken.run({ request_id: requestId, ts_first_token: nowMs() });
|
||||
}
|
||||
assistant += delta;
|
||||
}
|
||||
if (evt?.usage) {
|
||||
upstreamUsage = evt.usage;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
q.finish.run({
|
||||
request_id: requestId,
|
||||
ts_done: nowMs(),
|
||||
assistant_text: assistant || null,
|
||||
prompt_tokens: null,
|
||||
completion_tokens: null,
|
||||
total_tokens: null,
|
||||
status: aborted ? 'aborted' : 'error',
|
||||
error: aborted ? null : String(e.message || e),
|
||||
});
|
||||
try { res.end(); } catch {}
|
||||
return;
|
||||
} finally {
|
||||
req.off('close', onClose);
|
||||
}
|
||||
|
||||
// Ensure last partial frame processed (best-effort)
|
||||
// (Do not attempt to parse unless it includes complete lines)
|
||||
// Also: upstream usage typically comes in final non-delta message; may be null.
|
||||
q.finish.run({
|
||||
request_id: requestId,
|
||||
ts_done: nowMs(),
|
||||
assistant_text: assistant || null,
|
||||
prompt_tokens: upstreamUsage?.prompt_tokens ?? null,
|
||||
completion_tokens: upstreamUsage?.completion_tokens ?? null,
|
||||
total_tokens: upstreamUsage?.total_tokens ?? null,
|
||||
status: aborted ? 'aborted' : 'ok',
|
||||
error: null,
|
||||
});
|
||||
|
||||
res.end();
|
||||
});
|
||||
|
||||
app.listen(PORT, HOST, () => {
|
||||
console.log(`[server] listening on http://${HOST}:${PORT}`);
|
||||
console.log(`[server] sqlite: ${SQLITE_PATH}`);
|
||||
console.log(`[server] default model: ${DEFAULT_MODEL}`);
|
||||
console.log(`[server] web ui: ${hasWebDist ? 'enabled (web/dist)' : 'disabled (run ./run.sh build)'}`);
|
||||
});
|
||||
25
server/src/util.js
Normal file
25
server/src/util.js
Normal file
@@ -0,0 +1,25 @@
|
||||
export function nowMs() {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
export function getClientIp(req) {
|
||||
// In local-network deployments, trust direct socket address.
|
||||
// If you later add a proxy, set app.set('trust proxy', true).
|
||||
return req.socket?.remoteAddress || null;
|
||||
}
|
||||
|
||||
export function clampInt(value, { min, max, fallback }) {
|
||||
const n = Number.parseInt(String(value), 10);
|
||||
if (!Number.isFinite(n)) return fallback;
|
||||
if (n < min) return min;
|
||||
if (n > max) return max;
|
||||
return n;
|
||||
}
|
||||
|
||||
export function safeJsonParse(s) {
|
||||
try {
|
||||
return JSON.parse(s);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
13
web/index.html
Normal file
13
web/index.html
Normal 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
19
web/package.json
Normal 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
480
web/src/admin.js
Normal 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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
554
web/src/main.js
Normal file
554
web/src/main.js
Normal 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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function isIos() {
|
||||
const ua = navigator.userAgent || '';
|
||||
return /iPad|iPhone|iPod/.test(ua);
|
||||
}
|
||||
|
||||
}
|
||||
13
web/vite.config.js
Normal file
13
web/vite.config.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8787',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user