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]>
236 lines
9.6 KiB
JavaScript
236 lines
9.6 KiB
JavaScript
/* =====================================================================
|
||
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");
|
||
});
|