Files
oh-my-pi/packages/stats/src/server.ts
T
can1357 770f175795 feat(stats): added user-message behavior metrics parsing and storage
- Added user-message behavior metric extraction and parsing for yelling/profanity/drama counts.
- Added behavior persistence and aggregate reads by extending syncSessionFile, DB schema, and query APIs.
- Added behavior dashboard support with new shared types, /api/stats/behavior route, and client views.
- Changed cost summary/chart rendering by removing per-chart 14/30/90-day filters and trend card behavior.
- Added tests for computeUserMessageMetrics edge cases like empty text, case, thresholds, and punctuation.
- Moved tailwindcss from devDependencies to dependencies for shared client styling usage.
2026-05-11 03:41:02 +02:00

325 lines
9.3 KiB
TypeScript

import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { $ } from "bun";
import {
getBehaviorDashboardStats,
getCostDashboardStats,
getDashboardStats,
getModelDashboardStats,
getOverviewStats,
getRecentErrors,
getRecentRequests,
getRequestDetails,
getTotalMessageCount,
syncAllSessions,
} from "./aggregator";
import embeddedClientArchiveTxt from "./embedded-client.generated.txt";
const getEmbeddedClientArchive = (() => {
const txt = embeddedClientArchiveTxt.replaceAll(/[\s\r\n]/g, "").trim();
if (!txt) return null;
return () => Buffer.from(txt, "base64");
})();
const CLIENT_DIR = path.join(import.meta.dir, "client");
const STATIC_DIR = path.join(import.meta.dir, "..", "dist", "client");
const IS_BUN_COMPILED =
Bun.env.PI_COMPILED ||
import.meta.url.includes("$bunfs") ||
import.meta.url.includes("~BUN") ||
import.meta.url.includes("%7EBUN");
const COMPILED_CLIENT_DIR_ROOT = path.join(os.tmpdir(), "omp-stats-client");
let compiledClientDirPromise: Promise<string> | null = null;
function sanitizeArchivePath(archivePath: string): string | null {
const normalized = archivePath.replaceAll("\\", "/").replace(/^\.\//, "");
if (!normalized || normalized === ".") return null;
if (normalized.includes("..") || path.isAbsolute(normalized)) return null;
return normalized;
}
async function extractEmbeddedClientArchive(archiveBytes: Buffer, outputDir: string): Promise<void> {
const archive = new Bun.Archive(archiveBytes);
const files = await archive.files();
const extractRoot = path.resolve(outputDir);
for (const [archivePath, file] of files) {
const sanitizedPath = sanitizeArchivePath(archivePath);
if (!sanitizedPath) continue;
const destinationPath = path.resolve(extractRoot, sanitizedPath);
if (!destinationPath.startsWith(extractRoot + path.sep)) {
throw new Error(`Archive entry escapes extraction directory: ${archivePath}`);
}
await Bun.write(destinationPath, file);
}
}
async function getCompiledClientDir(): Promise<string> {
if (!IS_BUN_COMPILED) return STATIC_DIR;
if (compiledClientDirPromise) return compiledClientDirPromise;
const archiveBytes = getEmbeddedClientArchive?.();
if (!archiveBytes) {
throw new Error("Compiled stats client bundle missing. Rebuild binary with embedded stats assets.");
}
compiledClientDirPromise = (async () => {
const bundleHash = Bun.hash(archiveBytes).toString(16);
const outputDir = path.join(COMPILED_CLIENT_DIR_ROOT, bundleHash);
const markerPath = path.join(outputDir, "index.html");
try {
const marker = await fs.stat(markerPath);
if (marker.isFile()) return outputDir;
} catch {}
await fs.rm(outputDir, { recursive: true, force: true });
await fs.mkdir(outputDir, { recursive: true });
await extractEmbeddedClientArchive(archiveBytes, outputDir);
return outputDir;
})();
return compiledClientDirPromise;
}
async function getLatestMtime(dir: string): Promise<number> {
const entries = await fs.readdir(dir, { withFileTypes: true });
const promises = [];
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
promises.push(getLatestMtime(fullPath));
} else if (entry.isFile()) {
promises.push(fs.stat(fullPath).then(stats => stats.mtimeMs));
}
}
let latest = 0;
await Promise.allSettled(promises).then(results => {
for (const result of results) {
if (result.status === "fulfilled") {
latest = Math.max(latest, result.value);
}
}
});
return latest;
}
const ensureClientBuild = async () => {
if (IS_BUN_COMPILED) return;
const indexPath = path.join(STATIC_DIR, "index.html");
const cssPath = path.join(STATIC_DIR, "styles.css");
const clientSourceMtime = await getLatestMtime(CLIENT_DIR);
const tailwindConfigPath = path.join(import.meta.dir, "..", "tailwind.config.js");
let tailwindConfigMtime = 0;
try {
const tailwindConfigStats = await fs.stat(tailwindConfigPath);
tailwindConfigMtime = tailwindConfigStats.mtimeMs;
} catch {}
const sourceMtime = Math.max(clientSourceMtime, tailwindConfigMtime);
let shouldBuild = true;
try {
const [indexStats, cssStats] = await Promise.all([fs.stat(indexPath), fs.stat(cssPath)]);
if (
indexStats.isFile() &&
cssStats.isFile() &&
indexStats.mtimeMs >= sourceMtime &&
cssStats.mtimeMs >= sourceMtime
) {
shouldBuild = false;
}
} catch {
shouldBuild = true;
}
if (!shouldBuild) return;
await fs.rm(STATIC_DIR, { recursive: true, force: true });
console.log("Building stats client...");
const packageRoot = path.join(import.meta.dir, "..");
const buildResult = await $`bun run build.ts`.cwd(packageRoot).quiet().nothrow();
if (buildResult.exitCode !== 0) {
const output = buildResult.text().trim();
const details = output ? `\n${output}` : "";
throw new Error(`Failed to build stats client (exit ${buildResult.exitCode})${details}`);
}
const indexHtml = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Usage Statistics</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div id="root"></div>
<script src="index.js" type="module"></script>
</body>
</html>`;
await Bun.write(path.join(STATIC_DIR, "index.html"), indexHtml);
};
/**
* Handle API requests.
*/
async function handleApi(req: Request): Promise<Response> {
const url = new URL(req.url);
const path = url.pathname;
// Stats reads are DB-only; explicit /api/sync does the expensive session scan.
const range = url.searchParams.get("range");
if (path === "/api/stats") {
const stats = await getDashboardStats(range);
return Response.json(stats);
}
if (path === "/api/stats/overview") {
const stats = await getOverviewStats(range);
return Response.json(stats);
}
if (path === "/api/stats/model-dashboard") {
const stats = await getModelDashboardStats(range);
return Response.json(stats);
}
if (path === "/api/stats/costs") {
const stats = await getCostDashboardStats(range);
return Response.json(stats);
}
if (path === "/api/stats/behavior") {
const stats = await getBehaviorDashboardStats(range);
return Response.json(stats);
}
if (path === "/api/stats/recent") {
const limit = url.searchParams.get("limit");
const stats = await getRecentRequests(limit ? parseInt(limit, 10) : undefined);
return Response.json(stats);
}
if (path === "/api/stats/errors") {
const limit = url.searchParams.get("limit");
const stats = await getRecentErrors(limit ? parseInt(limit, 10) : undefined);
return Response.json(stats);
}
if (path === "/api/stats/models") {
const stats = await getDashboardStats(range);
return Response.json(stats.byModel);
}
if (path === "/api/stats/folders") {
const stats = await getDashboardStats(range);
return Response.json(stats.byFolder);
}
if (path === "/api/stats/timeseries") {
const stats = await getDashboardStats(range);
return Response.json(stats.timeSeries);
}
if (path.startsWith("/api/request/")) {
const id = path.split("/").pop();
if (!id) return new Response("Bad Request", { status: 400 });
const details = await getRequestDetails(parseInt(id, 10));
if (!details) return new Response("Not Found", { status: 404 });
return Response.json(details);
}
if (path === "/api/sync") {
const result = await syncAllSessions();
const count = await getTotalMessageCount();
return Response.json({ ...result, totalMessages: count });
}
return new Response("Not Found", { status: 404 });
}
/**
* Handle static file requests.
*/
async function handleStatic(requestPath: string): Promise<Response> {
const staticDir = IS_BUN_COMPILED ? await getCompiledClientDir() : STATIC_DIR;
const filePath = requestPath === "/" ? "/index.html" : requestPath;
const fullPath = path.join(staticDir, filePath);
const file = Bun.file(fullPath);
if (await file.exists()) {
return new Response(file);
}
// SPA fallback
const index = Bun.file(path.join(staticDir, "index.html"));
if (await index.exists()) {
return new Response(index);
}
return new Response("Not Found", { status: 404 });
}
/**
* Start the HTTP server.
*/
export async function startServer(port = 3847): Promise<{ port: number; stop: () => void }> {
await ensureClientBuild();
const server = Bun.serve({
port,
async fetch(req) {
const url = new URL(req.url);
const path = url.pathname;
// CORS headers for local development
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
};
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
try {
let response: Response;
if (path.startsWith("/api/")) {
response = await handleApi(req);
} else {
response = await handleStatic(path);
}
// Add CORS headers to all responses
const headers = new Headers(response.headers);
for (const [key, value] of Object.entries(corsHeaders)) {
headers.set(key, value);
}
return new Response(response.body, {
status: response.status,
headers,
});
} catch (error) {
console.error("Server error:", error);
return Response.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500, headers: corsHeaders },
);
}
},
});
return {
port: server.port ?? port,
stop: () => server.stop(),
};
}