Workspace: Versionsstempel gegen Cloudflares 4-Stunden-Cache
Filipe sah die neue Kachel nicht, obwohl sie live war. Ursache gemessen, nicht vermutet: Node liefert : Cache-Control: no-cache (richtig) Cloudflare macht: Cache-Control: max-age=14400 (ueberschreibt es) cf-cache-status: REVALIDATED, Server: cloudflare Cloudflares voreingestellte Browser-Cache-Zeit von vier Stunden setzt sich ueber das no-cache des Servers hinweg -- aber NUR bei JS und CSS. HTML kommt mit DYNAMIC und no-cache immer frisch durch (geprueft an /creator.html und /workspace/). Genau deshalb genuegt ein Stempel in der HTML: neue Seite -> neue Adresse -> Cloudflare kennt sie nicht -> frische Datei. Keine Cloudflare-Einstellung noetig, nichts, worauf ich warten muss. tools/workspace-stempel.mjs setzt den Stempel auf alle 71 Verweise in 13 Dateien. Der Lauf ist wiederholbar: ein vorhandener Stempel wird ersetzt, nicht angehaengt (geprueft). Gehoert ab jetzt vor jeden Deploy, der JS oder CSS im Workspace anfasst. Ich verlasse mich damit nicht mehr aufs Erinnern -- das war der eigentliche Fehler. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -27,6 +27,7 @@ import { hinweisRouter } from "./workspace-hinweise.js";
|
||||
import { sucheRouter } from "./workspace-suche.js";
|
||||
import { startcheckRouter } from "./workspace-startcheck.js";
|
||||
import { wissenRouter } from "./workspace-wissen.js";
|
||||
import { kiRouter } from "./workspace-ki.js";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const SITE_DIR = join(__dirname, "..");
|
||||
@@ -94,6 +95,7 @@ app.use(hinweisRouter);
|
||||
app.use(sucheRouter);
|
||||
app.use(startcheckRouter);
|
||||
app.use(wissenRouter);
|
||||
app.use(kiRouter);
|
||||
|
||||
/* Sicherheits-Fix 20.08.2026 (Audit vor dem geplanten öffentlichen Start morgen).
|
||||
SITE_DIR ist der GESAMTE Repo-Ordner (join(__dirname, "..")) — express.static liefert
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
/* =====================================================================
|
||||
workspace-ki.js — lokale KI-Assistenz (Konzept, Phase 4: "optionale
|
||||
KI-Assistenz").
|
||||
|
||||
Laeuft auf DIESEM Server, in llama.cpp mit Qwen3.5-4B (Apache 2.0).
|
||||
Nichts verlaesst die Maschine, es entstehen keine Kosten, und wenn der
|
||||
Dienst aus ist, funktioniert der ganze Workspace unveraendert weiter.
|
||||
|
||||
VIER REGELN, die hier ueber allem stehen:
|
||||
|
||||
1. DIE KI SCHLAEGT VOR, SIE ENTSCHEIDET NIE. Kein Endpunkt dieser
|
||||
Datei schreibt in die Datenbank. Was uebernommen wird, bestaetigt
|
||||
ein Mensch -- an genau derselben Stelle wie bisher.
|
||||
|
||||
2. SIE DARF DEN WORKSPACE NIE MITREISSEN. Der Dienst kann aus sein,
|
||||
haengen oder Unsinn liefern. Jede Antwort hat eine harte Zeitgrenze,
|
||||
jeder Fehler endet in einer ruhigen Absage -- nie in einem 500er.
|
||||
|
||||
3. NUR EINE ANFRAGE GLEICHZEITIG. Der Server hat vier Kerne, zwei
|
||||
davon darf die KI benutzen. Zwei parallele Anfragen wuerden beide
|
||||
langsam und niemandem helfen. Wer wartet, bekommt das gesagt.
|
||||
|
||||
4. DENKEN IST ABGESCHALTET. Qwen3.5 ist ein Denk-Modell; ohne diese
|
||||
Einstellung verbraucht es die ganze Antwortlaenge fuer
|
||||
Selbstgespraeche und liefert am Ende NICHTS. Beim ersten Test
|
||||
genau so passiert.
|
||||
===================================================================== */
|
||||
|
||||
import express from "express";
|
||||
import { sitzungLesen, protokolliere, echteIp } from "./workspace.js";
|
||||
|
||||
export const kiRouter = express.Router();
|
||||
|
||||
const KI_URL = process.env.KI_URL || "http://127.0.0.1:8771";
|
||||
const ZEITGRENZE_MS = 45_000;
|
||||
const TEXT_MAX = 4000;
|
||||
|
||||
/* Nur eine Anfrage zur Zeit -- siehe Regel 3. */
|
||||
let laeuft = false;
|
||||
|
||||
/* Einfache Bremse je Person: fuenf Anfragen in zehn Minuten. Die KI ist
|
||||
billig im Geld, aber teuer in Rechenzeit, die der Website gehoert. */
|
||||
const VERBRAUCH = new Map();
|
||||
const LIMIT = 5;
|
||||
const FENSTER_MS = 10 * 60_000;
|
||||
|
||||
function zuVielGefragt(id) {
|
||||
const jetzt = Date.now();
|
||||
const liste = (VERBRAUCH.get(id) || []).filter((t) => jetzt - t < FENSTER_MS);
|
||||
VERBRAUCH.set(id, liste);
|
||||
return liste.length >= LIMIT;
|
||||
}
|
||||
function merkeAnfrage(id) {
|
||||
const liste = VERBRAUCH.get(id) || [];
|
||||
liste.push(Date.now());
|
||||
VERBRAUCH.set(id, liste);
|
||||
}
|
||||
|
||||
function angemeldet(req, res, next) {
|
||||
const person = sitzungLesen(req);
|
||||
if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
|
||||
req.person = person;
|
||||
next();
|
||||
}
|
||||
function gleicheHerkunft(req, res, next) {
|
||||
const herkunft = req.get("origin");
|
||||
if (!herkunft) return next();
|
||||
let erlaubt;
|
||||
try { erlaubt = new URL(herkunft).host === req.get("host"); } catch { erlaubt = false; }
|
||||
if (!erlaubt) return res.status(403).json({ fehler: "fremde_herkunft" });
|
||||
next();
|
||||
}
|
||||
|
||||
kiRouter.use("/workspace/api/ki", angemeldet);
|
||||
|
||||
/* ---------- Die eine Stelle, an der gefragt wird ------------------------- */
|
||||
|
||||
/* Ein kleines Modell folgt einer BESCHREIBUNG schlecht und einem
|
||||
BEISPIEL gut. Ohne Beispiel kam auf einen Start-Check-Befund
|
||||
"Klar machen wir feste Sendetermine fuer den Start-Check" zurueck --
|
||||
Geplauder statt Aufgabe. Mit einem einzigen Beispiel:
|
||||
"Zeitpunkt der Sendung festlegen". Deshalb hat jede Aufgabe hier ihr
|
||||
Beispielpaar. */
|
||||
async function frage(anweisung, text, maxWorte, beispiele = []) {
|
||||
const abbruch = new AbortController();
|
||||
const uhr = setTimeout(() => abbruch.abort(), ZEITGRENZE_MS);
|
||||
try {
|
||||
const antwort = await fetch(KI_URL + "/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
signal: abbruch.signal,
|
||||
body: JSON.stringify({
|
||||
messages: [
|
||||
{ role: "system", content: anweisung },
|
||||
...beispiele,
|
||||
{ role: "user", content: text },
|
||||
],
|
||||
/* Ohne diese Zeile denkt das Modell laut und liefert nichts. */
|
||||
chat_template_kwargs: { enable_thinking: false },
|
||||
max_tokens: maxWorte,
|
||||
temperature: 0.3,
|
||||
stream: false,
|
||||
}),
|
||||
});
|
||||
if (!antwort.ok) throw new Error("HTTP " + antwort.status);
|
||||
const d = await antwort.json();
|
||||
return String(d?.choices?.[0]?.message?.content ?? "").trim();
|
||||
} finally {
|
||||
clearTimeout(uhr);
|
||||
}
|
||||
}
|
||||
|
||||
/* Gemeinsamer Rahmen: Bremse, Sperre, Fehler abfangen. So steht die
|
||||
Absicherung einmal da und nicht in jedem Endpunkt neu. */
|
||||
async function mitSchutz(req, res, aufgabe, was) {
|
||||
if (zuVielGefragt(req.person.id)) {
|
||||
return res.status(429).json({
|
||||
fehler: `Höchstens ${LIMIT} Vorschläge in ${FENSTER_MS / 60000} Minuten – die KI teilt sich den Server mit der Website.`,
|
||||
});
|
||||
}
|
||||
if (laeuft) {
|
||||
return res.status(409).json({ fehler: "Die KI denkt gerade über etwas anderes nach. Gleich nochmal probieren." });
|
||||
}
|
||||
laeuft = true;
|
||||
const start = Date.now();
|
||||
try {
|
||||
const ergebnis = await aufgabe();
|
||||
merkeAnfrage(req.person.id);
|
||||
protokolliere("ki_gefragt", {
|
||||
personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req),
|
||||
detail: `${was}, ${Math.round((Date.now() - start) / 1000)} s`.slice(0, 120),
|
||||
});
|
||||
res.json(ergebnis);
|
||||
} catch (fehler) {
|
||||
const weg = fehler?.name === "AbortError";
|
||||
console.error("[workspace] KI:", fehler?.message);
|
||||
res.status(503).json({
|
||||
fehler: weg
|
||||
? "Die KI hat zu lange gebraucht. Schreib es diesmal selbst – es geht auch ohne."
|
||||
: "Die KI ist gerade nicht erreichbar. Alles andere funktioniert normal weiter.",
|
||||
});
|
||||
} finally {
|
||||
laeuft = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Ist die KI ueberhaupt da? -----------------------------------
|
||||
Die Oberflaeche fragt das beim Laden und blendet die Knoepfe sonst
|
||||
aus. Ein Knopf, der immer eine Fehlermeldung bringt, ist schlimmer
|
||||
als gar kein Knopf. */
|
||||
|
||||
kiRouter.get("/workspace/api/ki/status", async (req, res) => {
|
||||
const abbruch = new AbortController();
|
||||
const uhr = setTimeout(() => abbruch.abort(), 2500);
|
||||
try {
|
||||
const a = await fetch(KI_URL + "/health", { signal: abbruch.signal });
|
||||
res.json({ da: a.ok, beschaeftigt: laeuft });
|
||||
} catch {
|
||||
res.json({ da: false, beschaeftigt: false });
|
||||
} finally {
|
||||
clearTimeout(uhr);
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------- Aus einem Gespraech To-dos vorschlagen ----------------------- */
|
||||
|
||||
kiRouter.post("/workspace/api/ki/todos", gleicheHerkunft, express.json(), (req, res) => {
|
||||
const text = String(req.body?.text ?? "").trim().slice(0, TEXT_MAX);
|
||||
if (text.length < 20) {
|
||||
return res.status(400).json({ fehler: "Schreib erst ein paar Sätze – daraus kann die KI dann To-dos ableiten." });
|
||||
}
|
||||
return mitSchutz(req, res, async () => {
|
||||
const roh = await frage(
|
||||
"Du leitest aus Gespraechsnotizen To-dos ab. Nur die Liste, eine Zeile je To-do, "
|
||||
+ "jede mit Bindestrich, hoechstens 10 Woerter, Deutsch, Verb am Anfang, keine Vorrede.",
|
||||
text,
|
||||
200,
|
||||
[
|
||||
{ role: "user", content: "Kamera ist zu dunkel, wir wollten ein Ringlicht kaufen. Overlay ist noch vom letzten Jahr." },
|
||||
{ role: "assistant", content: "- Ringlicht besorgen und ausrichten\n- Overlay neu gestalten" },
|
||||
]);
|
||||
|
||||
/* Die Zeilen aufraeumen: Aufzaehlungszeichen weg, Leeres raus,
|
||||
hoechstens acht. Was das Modell an Vorrede doch liefert, faellt
|
||||
hier durch das Raster. */
|
||||
const todos = roh.split("\n")
|
||||
.map((z) => z.replace(/^\s*[-*•\d.)\]]+\s*/, "").trim())
|
||||
.filter((z) => z.length >= 4 && z.length <= 160)
|
||||
.slice(0, 8);
|
||||
|
||||
return { todos, hinweis: "Vorschlag der KI – prüfe ihn, bevor du ihn übernimmst." };
|
||||
}, "todos");
|
||||
});
|
||||
|
||||
/* ---------- Aus einem Befund einen Aufgabentitel ------------------------- */
|
||||
|
||||
kiRouter.post("/workspace/api/ki/aufgabentitel", gleicheHerkunft, express.json(), (req, res) => {
|
||||
const befund = String(req.body?.befund ?? "").trim().slice(0, 600);
|
||||
const bereich = String(req.body?.bereich ?? "").trim().slice(0, 120);
|
||||
if (befund.length < 8) {
|
||||
return res.status(400).json({ fehler: "Schreib erst eine Notiz zu dem Punkt." });
|
||||
}
|
||||
return mitSchutz(req, res, async () => {
|
||||
const roh = await frage(
|
||||
"Du formulierst kurze Aufgabentitel. Nur der Titel, hoechstens 8 Woerter, "
|
||||
+ "Deutsch, Verb am Anfang, kein Punkt, keine Anfuehrungszeichen.",
|
||||
`Befund${bereich ? " (" + bereich + ")" : ""}: ${befund}`,
|
||||
40,
|
||||
[
|
||||
{ role: "user", content: "Befund: Profilbild ist unscharf und alt." },
|
||||
{ role: "assistant", content: "Neues Profilbild aufnehmen und hochladen" },
|
||||
]);
|
||||
const titel = roh.split("\n")[0].replace(/^["'„»\s]+|["'“«.\s]+$/g, "").slice(0, 160);
|
||||
return { titel, hinweis: "Vorschlag der KI – du kannst ihn vor dem Anlegen ändern." };
|
||||
}, "aufgabentitel");
|
||||
});
|
||||
|
||||
/* ---------- Zahlen in Worte ---------------------------------------------- */
|
||||
|
||||
kiRouter.post("/workspace/api/ki/reporttext", gleicheHerkunft, express.json(), (req, res) => {
|
||||
const zahlen = String(req.body?.zahlen ?? "").trim().slice(0, 1500);
|
||||
if (zahlen.length < 10) return res.status(400).json({ fehler: "Keine Zahlen zum Zusammenfassen." });
|
||||
return mitSchutz(req, res, async () => {
|
||||
const roh = await frage(
|
||||
"Du fasst Kennzahlen sachlich zusammen. Hoechstens drei Saetze auf Deutsch, "
|
||||
+ "ohne Vorrede, ohne Aufzaehlung. Nenne nur, was in den Zahlen steht.",
|
||||
zahlen, 140,
|
||||
[
|
||||
{ role: "user", content: "30 Tage: 6 Aufgaben erledigt (davor 2), 0 ueberfaellig, 1 dringender Punkt." },
|
||||
{ role: "assistant", content: "Die Erledigungen haben sich gegenueber dem Vormonat verdreifacht, von zwei auf sechs. Ueberfaellig ist nichts, offen bleibt ein dringender Punkt." },
|
||||
]);
|
||||
return { text: roh.split("\n").filter(Boolean).join(" ").slice(0, 700),
|
||||
hinweis: "Vorschlag der KI – geprüft wird an den Zahlen darüber." };
|
||||
}, "reporttext");
|
||||
});
|
||||
Reference in New Issue
Block a user