diff --git a/server/index.js b/server/index.js index 8afc7d34..feb7591b 100644 --- a/server/index.js +++ b/server/index.js @@ -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 diff --git a/server/workspace-ki.js b/server/workspace-ki.js new file mode 100644 index 00000000..ab7ef6f8 --- /dev/null +++ b/server/workspace-ki.js @@ -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"); +}); diff --git a/tools/workspace-stempel.mjs b/tools/workspace-stempel.mjs new file mode 100644 index 00000000..db1c0839 --- /dev/null +++ b/tools/workspace-stempel.mjs @@ -0,0 +1,59 @@ +/* ===================================================================== + workspace-stempel.mjs — Versionsstempel an die Workspace-Dateien. + + WARUM ES DAS GIBT: + Cloudflare speichert JS und CSS vier Stunden im Browser -- es + ueberschreibt damit das "no-cache", das der Server korrekt schickt. + Nach einem Deploy sah man deshalb bis zu vier Stunden lang die ALTE + Oberflaeche, obwohl der Server laengst die neue auslieferte. Genau so + ist es passiert: eine neue Kachel war live, aber nicht sichtbar. + + Die HTML-Seiten kommen dagegen immer frisch an (no-cache, DYNAMIC). + Deshalb genuegt es, in der HTML einen Stempel an die Adressen zu + haengen: Neue Seite -> neue Adresse -> Cloudflare kennt sie nicht -> + frische Datei. Ohne Cloudflare-Einstellung, ohne Zutun von aussen. + + Der Stempel ist die Minute des Aufrufs. Er wird bei jedem Lauf neu + gesetzt, auch wenn schon einer dasteht. + + AUFRUF: node tools/workspace-stempel.mjs + Gehoert vor jeden Deploy, der JS oder CSS im Workspace anfasst. + ===================================================================== */ + +import { readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const HIER = dirname(fileURLToPath(import.meta.url)); +const ORDNER = join(HIER, "..", "workspace"); + +const d = new Date(); +const p = (n) => String(n).padStart(2, "0"); +const STEMPEL = `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}${p(d.getHours())}${p(d.getMinutes())}`; + +/* Trifft href="assets/..." und src="assets/..." -- mit oder ohne + vorhandenen Stempel, damit wiederholte Laeufe nicht anhaengen. */ +const MUSTER = /(href|src)="(assets\/(?:css|js)\/[A-Za-z0-9._-]+)(?:\?v=\d+)?"/g; + +let dateien = 0; +let stellen = 0; + +for (const name of readdirSync(ORDNER)) { + if (!name.endsWith(".html")) continue; + const pfad = join(ORDNER, name); + const alt = readFileSync(pfad, "utf8"); + let n = 0; + const neu = alt.replace(MUSTER, (_, was, datei) => { + n += 1; + return `${was}="${datei}?v=${STEMPEL}"`; + }); + if (neu !== alt) { + writeFileSync(pfad, neu); + dateien += 1; + stellen += n; + console.log(` ${name.padEnd(20)} ${n} Verweise`); + } +} + +console.log(`\nStempel ${STEMPEL} auf ${stellen} Verweise in ${dateien} Dateien gesetzt.`); +if (!dateien) console.log("Nichts geaendert -- stimmt das Muster noch?"); diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index c31618dc..7ecdd351 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -7,9 +7,9 @@ - - - + + +
@@ -134,7 +134,7 @@ - - + +