Files
dogfather-universe/server/workspace-ki.js
T
DogFatherGit eb87e4a484 Workspace: keine Kachel mehr ohne Ziel -- Automationen bekommen ihren Ort
Filipe: "da sind aber noch zwei kategorien zu". Er hat recht, und es
waren zwei VERSCHIEDENE Fehler.

1) "DASHBOARD" war eine Kachel, die man nicht oeffnen kann -- man steht
   ja schon darauf. Sie stand seit dem ersten Tag als Platzhalter da.
   Ein Eintrag, der nirgendwohin fuehrt, sieht aus wie etwas Unfertiges.
   Entfernt.

2) "AUTOMATIONEN" trug noch "Phase 4", obwohl die Automationen laengst
   liefen -- sie waren nur verteilt (Hinweise auf der Uebersicht, Suche
   ueberall, KI-Vorschlaege an drei Stellen) und hatten keinen Ort.
   Jetzt haben sie einen.

DIE SEITE FUEHRT BEWUSST NICHTS EIGENES. Was dort steht, kommt aus den
Bereichen selbst -- eine Seite, die Automationen doppelt abbildet, waere
die naechste Stelle, die irgendwann etwas anderes behauptet als die
Wirklichkeit. Vier Abschnitte:

- Die lokale KI mit Zustand, Modell, Ort, Kosten und Grenzen -- und dem
  einzigen Schalter der Seite. Sie ist die einzige Automation, die
  Rechenzeit kostet, die der Website gehoert.
- "Was gerade anliegt": dieselben Hinweise wie auf der Uebersicht, hier
  vollstaendig statt gekuerzt.
- "Laeuft dauerhaft im Hintergrund": sieben Regeln im Klartext. Jede
  beschreibt etwas, das tatsaechlich im Code steht.
- "Zuletzt automatisch passiert": aus dem Protokoll, gefiltert auf das,
  was ohne Zutun geschah.

DER KI-SCHALTER WIRD AN ZWEI STELLEN GEPRUEFT: beim Anzeigen der
Knoepfe UND in jedem Endpunkt. Eine Oberflaeche, die etwas versteckt,
ist keine Sperre. Geprueft: abgeschaltet liefert der Endpunkt 403, ein
Creator darf gar nicht schalten (403), die Einstellung ueberdauert einen
Neustart (eigene Tabelle, damit sie dieselbe Sicherung bekommt wie alles
andere).

NEBENBEI DERSELBE FEHLER WIE FRUEHER: Der Schalter-Baustein lag in
wissen.css und fehlte prompt auf der neuen Seite -- dort erschien er als
nacktes Kaestchen. Wie damals bei .knopf-still. Liegt jetzt in gate.css.

Geprueft: 17 Kacheln, KEINE mehr ohne Ziel. Creator wird von der Seite
weggeleitet (302). Handy ohne Ueberlauf, 0 Konsolenfehler.
2026-08-31 12:46:22 +02:00

271 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =====================================================================
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,
} 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 die Leitung -- es ist eine Entscheidung
ueber Rechenzeit, die der Website gehoert. */
kiRouter.put("/workspace/api/ki/schalter", gleicheHerkunft, express.json(), (req, res) => {
try {
if (!istLeitung(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");
});