feat: Frontend im Produktionsbetrieb ausliefern, TTFT bei OpenRouter

npm start baute web/dist, aber niemand lieferte es aus — der Server stellte
nur die API bereit. Jetzt registriert er @fastify/static, wenn ein Build
vorliegt, und die App läuft komplett unter :8788. Ohne web/dist bleibt alles
wie bisher (Vite übernimmt im Dev-Betrieb); der Start sagt, welcher Fall
vorliegt. /api und /ws behalten Vorrang, unbekannte /api-Pfade antworten
weiterhin mit JSON statt mit index.html.

OpenRouter, beim Verifizieren gegen ein Gratis-Modell gefunden:
- ttftMs wurde nie gesetzt — evalMs wurde aus firstTokenAt gerechnet, das
  Feld selbst blieb null
- die Zeitmessung startete erst nach dem fetch und ließ damit die Wartezeit
  auf den Anbieter aus. Startet jetzt davor, wie bei Ollama

Geprüft im Produktionsmodus: /, /assets/*.js, SPA-Fallback, JSON-404 auf
/api, WebSocket aus Origin :8788 und :5174 verbunden, fremde Origin 403.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015AUP4R3rgq4XwVs4bVf7uh
This commit is contained in:
Jeuner 2026-08-28 14:59:35 +02:00
parent ed3c01303b
commit da1d8be426
5 changed files with 309 additions and 9 deletions

View file

@ -11,6 +11,7 @@
},
"dependencies": {
"@fastify/cors": "^10.0.1",
"@fastify/static": "^10.1.3",
"defuddle": "^0.19.3",
"fastify": "^5.2.1",
"linkedom": "^0.18.13",

View file

@ -1,9 +1,10 @@
import Fastify from "fastify";
import cors from "@fastify/cors";
import fastifyStatic from "@fastify/static";
import { WebSocketServer, WebSocket } from "ws";
import type { IncomingMessage } from "node:http";
import { randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import * as dbmod from "./db.js";
import * as mem from "./memory.js";
@ -309,6 +310,25 @@ app.get("/api/ollama/models", async () => {
}
});
// --- Gebautes Frontend ---
//
// Nur wenn web/dist existiert: im Entwicklungsbetrieb liefert der
// Vite-Server das Frontend aus, dann soll hier nichts danebenstehen.
// Registrierung nach den API-Routen, damit /api und /ws Vorrang behalten.
const WEB_DIST = path.join(import.meta.dirname, "..", "..", "web", "dist");
const hasBuild = existsSync(path.join(WEB_DIST, "index.html"));
if (hasBuild) {
await app.register(fastifyStatic, { root: WEB_DIST });
app.setNotFoundHandler((req, reply) => {
// API-Fehler bleiben JSON; alles andere bekommt die App.
if (req.url.startsWith("/api")) {
return reply.code(404).send({ error: "not found" });
}
return reply.sendFile("index.html");
});
}
// --- WebSocket ---
interface ChatOptionsPayload {
think: boolean;
@ -677,4 +697,9 @@ server.on("upgrade", (req, socket, head) => {
app.listen({ port: PORT, host: "127.0.0.1" }, () => {
console.log(`[agenttwo-tools] Server läuft auf http://127.0.0.1:${PORT}`);
console.log(
hasBuild
? "[agenttwo-tools] Frontend aus web/dist wird mit ausgeliefert"
: "[agenttwo-tools] Kein web/dist — Frontend über 'npm run dev:web' (:5174)",
);
});

View file

@ -76,6 +76,10 @@ export async function streamOpenRouter(
cb: StreamCallbacks,
signal: AbortSignal,
): Promise<void> {
// Vor dem fetch: TTFT soll die Wartezeit auf den Anbieter enthalten, nicht
// erst ab dem Eintreffen der Antwort-Header zählen (so misst es auch Ollama).
const startedAt = Date.now();
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
method: "POST",
headers: {
@ -108,7 +112,6 @@ export async function streamOpenRouter(
const decoder = new TextDecoder();
let buffer = "";
const startedAt = Date.now();
let firstTokenAt: number | null = null;
const stats: ChatStats = {
promptTokens: 0,
@ -125,6 +128,7 @@ export async function streamOpenRouter(
*/
function finish(): void | Promise<void> {
stats.totalMs = Date.now() - startedAt;
stats.ttftMs = firstTokenAt === null ? null : firstTokenAt - startedAt;
stats.evalMs = firstTokenAt === null ? 0 : Date.now() - firstTokenAt;
return cb.onStats?.(stats);
}