ZWEI MELDUNGEN.
1. "DAS SIEHT JA MAL RICHTIG SCHEISSE AUS"
Die Rollenauswahl im Formular "Neue Person" lief unten aus dem
Formular heraus und legte sich ueber die Personenliste darunter.
Die Ursache war keine schlampige Gestaltung, sondern eine Regel, die
fuer etwas anderes gemacht ist: `.neu__raster > *` (aufgaben.css) gibt
JEDEM direkten Kind ein Raster mit fester Zeilenhoehe von 44 px, damit
Beschriftung und Eingabefeld in allen Spalten auf einer Linie sitzen.
Fuer ein einzeiliges Feld ist das genau richtig. Die Rollenauswahl ist
aber kein Feld, sondern ein Block aus vier hohen Karten -- die passten
nicht in 44 px, und was nicht hineinpasst, steht eben daneben.
Das Formular ist jetzt aus dem Spaltenraster geloest und liest sich
von oben nach unten: NAME (schmal, ein Name braucht keine 1200 px),
ROLLE (volle Breite, vier gleich breite Karten), Knoepfe. Das ist
nicht nur reparierter Ueberlauf, sondern auch die Reihenfolge, in der
man die Sache tatsaechlich entscheidet.
Dazu zwei Kleinigkeiten, die den Unterschied machen: Das Namensfeld
war ohne Rasterzeile 60 px hoch geworden (`height: 100%` einer Zeile,
die es nicht mehr gibt) -- hoeher als die Knoepfe daneben, was nach
Versehen aussieht, weil es eins war. Und die gewaehlte Rolle traegt
jetzt ein HAEKCHEN, nicht nur einen etwas helleren Rahmen: Genau die
Art Unterschied, die im Kontrastmodus verschwindet und fuer
farbunsichere Augen nie existiert hat.
2. "DIE MANAGER SOLLEN DIESE KATEGORIEN GARNICHT SEHEN"
"Personen & Zugaenge" und "Automationen" gehoeren ab sofort allein
DogFather.
Bei den Personen war es ohnehin ein Widerspruch im eigenen Haus: In
der Rollenauswahl steht seit jeher "Manager -- dieselben Rechte wie
DogFather, AUSSER der Personenverwaltung". Die Kachel stand trotzdem
bei ihm, und die Schranke liess ihn hinein: Codes erneuern, sperren,
Zuteilungen aendern. Eine Beschreibung, die etwas anderes sagt als die
Software, ist schlimmer als beides einzeln -- man weiss danach nicht
mehr, welcher von beiden man glauben soll.
Umgesetzt auf DREI Ebenen, weil Wegnehmen was man SIEHT kein
Rechteentzug ist:
1. die Kachel erscheint nicht mehr
2. die Seite leitet zur Startseite zurueck (GESCHUETZT)
3. die Schnittstellen antworten mit 404 -- Personenverwaltung,
Systemzustand, KI-Schalter
FOLGE, die bewusst in Kauf genommen wird: Die Betreuungs-Zuteilung
liegt auf der Personenseite. Ab jetzt kann also NUR DogFather
festlegen, wer welchen Creator betreut.
GEPRUEFT: server/pruef-personen-formular.mjs, 20 Pruefungen. Das Aussehen
wird gemessen, nicht angesehen: Endet die Rollenauswahl innerhalb des
Formulars? Ragt eine Karte seitlich hinaus? Liegt etwas ueber der Liste?
Sind alle vier Karten gleich breit (272-272 px)? Traegt die gewaehlte ein
Haekchen? Dazu zwei Gegenproben: DogFather kommt weiterhin an beide
Bereiche (sonst waere jede Sperr-Zeile auch bei einem fuer ALLE kaputten
Bereich gruen), und die Managerin arbeitet ansonsten unveraendert weiter.
Bestehende Laeufe gruen: Rollen 97, Handy 50, Sicherung 41, Personenliste
33, Team 30, Formulare 19, Code 17, Grosscheck 15.
Co-Authored-By: Claude Opus 5 <[email protected]>
275 lines
12 KiB
JavaScript
275 lines
12 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, einstellung, einstellungSetzen, istLeitung, istDogFather,
|
||
} 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;
|
||
|
||
/* Der Schalter aus der Automationen-Seite. Steht bewusst hier oben, vor
|
||
der ersten Verwendung -- als const weiter unten waere es zwar zur
|
||
Laufzeit gutgegangen, aber beim Lesen eine Stolperfalle. */
|
||
const ANGESCHALTET = () => einstellung("ki_an", "1") !== "0";
|
||
|
||
/* 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) {
|
||
/* Der Schalter wird HIER geprueft, nicht nur beim Anzeigen der
|
||
Knoepfe. Eine Oberflaeche, die etwas versteckt, ist keine Sperre. */
|
||
if (!ANGESCHALTET()) {
|
||
return res.status(403).json({ fehler: "Die KI-Vorschläge sind abgeschaltet." });
|
||
}
|
||
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) => {
|
||
/* Abgeschaltet heisst abgeschaltet -- dann wird der Dienst gar nicht
|
||
erst gefragt, und die Knoepfe erscheinen nirgends. */
|
||
if (!ANGESCHALTET()) {
|
||
return res.json({ da: false, angeschaltet: false, erreichbar: null, beschaeftigt: false });
|
||
}
|
||
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, angeschaltet: true, erreichbar: a.ok, beschaeftigt: laeuft });
|
||
} catch {
|
||
res.json({ da: false, angeschaltet: true, erreichbar: false, beschaeftigt: false });
|
||
} finally {
|
||
clearTimeout(uhr);
|
||
}
|
||
});
|
||
|
||
/* Ein- und ausschalten. NUR DogFather (02.09.2026).
|
||
|
||
Vorher stand hier istLeitung. Der Schalter sitzt aber auf der Seite
|
||
"Automationen", und die sieht seit heute nur noch DogFather -- ein
|
||
Weg, der offen bleibt, waehrend die Seite dazu verschwindet, ist
|
||
genau die Art Rest, die man spaeter nicht mehr findet. */
|
||
kiRouter.put("/workspace/api/ki/schalter", gleicheHerkunft, express.json(), (req, res) => {
|
||
try {
|
||
if (!istDogFather(req.person)) return res.status(403).json({ fehler: "nicht_erlaubt" });
|
||
const an = req.body?.an === true || req.body?.an === "1";
|
||
einstellungSetzen("ki_an", an ? "1" : "0", { ...req.person, ip: echteIp(req) });
|
||
res.json({ ok: true, angeschaltet: an });
|
||
} catch (fehler) {
|
||
console.error("[workspace] KI-Schalter:", fehler?.message);
|
||
res.status(503).json({ fehler: "nicht_verfuegbar" });
|
||
}
|
||
});
|
||
|
||
/* ---------- 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);
|
||
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.",
|
||
/* Der Bereichsname wird bewusst NICHT mitgegeben. Mit ihm kam
|
||
"LIVE-Struktur auf 18 und 21 Uhr festlegen" zurueck -- das
|
||
Modell baut ihn in den Titel ein, statt ihn als Zusammenhang zu
|
||
behandeln. Ohne ihn: "Zeitpunkt der Sendung festlegen". Der
|
||
Befund allein reicht, er steht ja im richtigen Bereich. */
|
||
`Befund: ${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");
|
||
});
|