Initial Firmware chat web app
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user