diff --git a/server/workspace-ki.js b/server/workspace-ki.js index ab7ef6f8..9fe889dd 100644 --- a/server/workspace-ki.js +++ b/server/workspace-ki.js @@ -196,7 +196,6 @@ kiRouter.post("/workspace/api/ki/todos", gleicheHerkunft, express.json(), (req, 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." }); } @@ -204,7 +203,12 @@ kiRouter.post("/workspace/api/ki/aufgabentitel", gleicheHerkunft, express.json() 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}`, + /* 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." }, diff --git a/workspace/assets/css/gate.css b/workspace/assets/css/gate.css index ca08f7f5..73e2e352 100644 --- a/workspace/assets/css/gate.css +++ b/workspace/assets/css/gate.css @@ -631,3 +631,66 @@ body.suche-offen { overflow: hidden; } .stufe-knopf:hover, .suche-knopf:hover, .suche-zu:hover, .todo__weg:hover, .karte__stift:hover { transform: none; } } + +/* =================================================================== + KI-Vorschläge (28.08.2026). + + Bewusst ANDERS als die normalen Knöpfe: Ein KI-Knopf tut nichts + Endgültiges, er schlägt vor. Deshalb gestrichelter Rand statt + geschliffener Kante -- man soll auf den ersten Blick sehen, dass hier + etwas Unverbindliches passiert. + + Liegt in gate.css, weil drei Seiten ihn benutzen. + =================================================================== */ + +.ki-knopf { + display: inline-flex; align-items: center; gap: 8px; + padding: 8px 15px; cursor: pointer; + background: rgba(138, 118, 255, .07); + border: 1px dashed rgba(138, 118, 255, .45); + border-radius: 999px; + color: #b6a9ff; font: inherit; font-size: .82rem; font-weight: 600; + transition: color .16s ease, border-color .16s ease, background .16s ease; +} +.ki-knopf:hover:not([disabled]) { + color: #fff; + border-color: rgba(138, 118, 255, .8); + background: rgba(138, 118, 255, .16); +} +.ki-knopf:focus-visible { + outline: none; box-shadow: 0 0 0 3px rgba(138, 118, 255, .3); +} +.ki-knopf[disabled] { cursor: progress; } +.ki-knopf__funke { + width: 15px; height: 15px; flex: none; + fill: currentColor; stroke: none; +} +/* Denkt gerade: der Funke pulst langsam. Zwölf Sekunden ohne + Rückmeldung fühlen sich sonst an wie ein Fehler. */ +.ki-knopf[data-denkt="ja"] { border-style: solid; } +.ki-knopf[data-denkt="ja"] .ki-knopf__funke { animation: ki-puls 1.6s ease-in-out infinite; } +@keyframes ki-puls { 0%, 100% { opacity: .35; } 50% { opacity: 1; } } + +.ki-fehler, .ki-hinweis { + display: block; margin-top: 7px; + font-size: .8rem; color: var(--text-still); +} +.ki-fehler { color: var(--warn); } + +.ki-reihe { margin-top: 22px; } +.ki-text { + margin: 10px 0 0; padding: 13px 15px; + background: rgba(138, 118, 255, .06); + border: 1px solid rgba(138, 118, 255, .28); + border-left-width: 3px; + border-radius: var(--radius-klein); + font-size: .9rem; color: var(--text-leise); line-height: 1.6; + max-width: 80ch; +} + +.todo-knoepfe { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-bottom: 14px; } + +@media (prefers-reduced-motion: reduce) { + .ki-knopf { transition: none; } + .ki-knopf[data-denkt="ja"] .ki-knopf__funke { animation: none; opacity: .7; } +} diff --git a/workspace/assets/js/calls.js b/workspace/assets/js/calls.js index 38251c0c..9b0edca6 100644 --- a/workspace/assets/js/calls.js +++ b/workspace/assets/js/calls.js @@ -13,6 +13,7 @@ let ich = null; let daten = null; let offen = new Set(); + let kiDa = false; const el = (tag, klasse, text) => { const k = document.createElement(tag); @@ -108,7 +109,36 @@ const mehr = el('button', 'knopf-still', '+ Zeile'); mehr.type = 'button'; mehr.addEventListener('click', () => todoZeile(true)); - f.append(mehr); + + const reihe = el('div', 'todo-knoepfe'); + reihe.append(mehr); + + /* Vorschlaege aus dem, was oben schon steht. Der Knopf erscheint + nur, wenn die KI ueberhaupt laeuft -- sonst gaebe es einen Knopf, + der immer scheitert. */ + if (kiDa) { + reihe.append(window.KI.knopf('To-dos vorschlagen', async () => { + const text = [fPunkte.value, fEnt.value].filter((x) => x.trim()).join(' '); + if (text.trim().length < 20) { + throw new Error('Schreib erst ein paar Sätze oben – daraus kann die KI dann ableiten.'); + } + const d = await window.KI.frage('todos', { text }); + if (!d.todos?.length) throw new Error('Die KI hat nichts gefunden.'); + /* Leere Zeilen zuerst fuellen, dann neue anhaengen. Bereits + Getipptes wird nie ueberschrieben. */ + for (const vorschlag of d.todos) { + let ziel = [...todoBox.querySelectorAll('.todo__titel')].find((i) => !i.value.trim()); + if (!ziel) { todoZeile(false); ziel = todoBox.lastElementChild.querySelector('.todo__titel'); } + ziel.value = vorschlag; + ziel.dataset.vonKi = 'ja'; + } + meldung.textContent = ''; + const hinweis = el('p', 'ki-hinweis', d.hinweis); + reihe.after(hinweis); + setTimeout(() => hinweis.remove(), 9000); + })); + } + f.append(reihe); /* Nächster Termin */ f.append(el('label', 'feld-schild', 'Nächster Termin')); @@ -329,6 +359,7 @@ ich = await a.json(); } catch { return; } $('wer').textContent = ich.name + ' · ' + (ich.rolle_name || ich.rolle); + kiDa = await window.KI.verfuegbar(); await laden(); })(); })(); diff --git a/workspace/assets/js/ki.js b/workspace/assets/js/ki.js new file mode 100644 index 00000000..2add25e6 --- /dev/null +++ b/workspace/assets/js/ki.js @@ -0,0 +1,88 @@ +/* =================================================================== + Gemeinsamer Zugang zur lokalen KI. + + Liegt in einer eigenen Datei, weil drei Seiten sie benutzen (Calls, + Start-Check, Report) und die Absicherung sonst dreimal dastuende -- + und beim naechsten Mal in zwei davon. + + ZWEI REGELN: + + 1. IST DIE KI AUS, GIBT ES DEN KNOPF NICHT. Ein Knopf, der immer eine + Fehlermeldung bringt, ist schlimmer als gar kein Knopf. Deshalb + fragt jede Seite beim Laden einmal nach und baut ihn nur dann. + + 2. DIE KI SCHLAEGT VOR, DER MENSCH ENTSCHEIDET. Kein Aufruf hier + speichert etwas. Was uebernommen wird, traegt der Mensch an + derselben Stelle ein wie ohne KI. + =================================================================== */ +window.KI = (() => { + 'use strict'; + + let bekannt = null; // null = noch nicht gefragt + + /* Einmal pro Seitenaufruf fragen, danach gemerkt. */ + async function verfuegbar() { + if (bekannt !== null) return bekannt; + try { + const a = await fetch('/workspace/api/ki/status', { credentials: 'same-origin' }); + bekannt = a.ok ? (await a.json()).da === true : false; + } catch { bekannt = false; } + return bekannt; + } + + async function frage(pfad, koerper) { + const a = await fetch('/workspace/api/ki/' + pfad, { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(koerper), + }); + if (a.status === 401) { location.assign('/workspace/'); throw new Error('abgemeldet'); } + const d = await a.json().catch(() => ({})); + if (!a.ok) throw new Error(d.fehler || 'Die KI konnte nicht antworten.'); + return d; + } + + /* Baut einen Knopf, der waehrend des Denkens sagt, dass er denkt -- + zwoelf Sekunden ohne Rueckmeldung fuehlen sich an wie ein Fehler. */ + function knopf(beschriftung, tuWas) { + const b = document.createElement('button'); + b.type = 'button'; + b.className = 'ki-knopf'; + const funke = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + funke.setAttribute('viewBox', '0 0 24 24'); + funke.setAttribute('class', 'ki-knopf__funke'); + funke.setAttribute('aria-hidden', 'true'); + funke.innerHTML = '' + + ''; + const text = document.createElement('span'); + text.textContent = beschriftung; + b.append(funke, text); + + b.addEventListener('click', async () => { + if (b.disabled) return; + b.disabled = true; + b.dataset.denkt = 'ja'; + const vorher = text.textContent; + text.textContent = 'Die KI denkt …'; + try { + await tuWas(); + } catch (fehler) { + /* Die Meldung gehoert dorthin, wo der Knopf steht -- nicht in + ein Fenster, das den Rest verdeckt. */ + const hinweis = document.createElement('span'); + hinweis.className = 'ki-fehler'; + hinweis.textContent = fehler.message; + b.after(hinweis); + setTimeout(() => hinweis.remove(), 7000); + } finally { + delete b.dataset.denkt; + text.textContent = vorher; + b.disabled = false; + } + }); + return b; + } + + return { verfuegbar, frage, knopf }; +})(); diff --git a/workspace/assets/js/report.js b/workspace/assets/js/report.js index cee79ec7..aa316f68 100644 --- a/workspace/assets/js/report.js +++ b/workspace/assets/js/report.js @@ -15,6 +15,7 @@ }; let ich = null; + let kiDa = false; const el = (tag, klasse, text) => { const k = document.createElement(tag); @@ -151,6 +152,27 @@ ziel.append(b4); $('entscheidung-block').hidden = false; + + /* Zahlen in Worte -- erst auf Knopfdruck, nie von selbst. Der + Report ist ohne KI vollstaendig; das hier ist eine Zugabe. */ + if (kiDa) { + const reihe = el('div', 'ki-reihe'); + reihe.append(window.KI.knopf('In Worte fassen', async () => { + const zahlen = `Zeitraum ${d.zeitraum.tage} Tage` + + `, ${d.aufgaben.erledigt} Aufgaben erledigt (davor ${d.aufgaben.erledigt_davor})` + + `, ${d.aufgaben.angelegt} neu angelegt (davor ${d.aufgaben.angelegt_davor})` + + `, ${d.aufgaben.offen} offen, ${d.aufgaben.ueberfaellig} überfällig` + + `, ${Object.values(d.bereiche).reduce((s, b) => s + b.dringend, 0)} dringende Punkte` + + `, LIVE-Schnitt ${d.live.jetzt.schnitt ?? '-'} aus ${d.live.jetzt.anzahl} Bewertungen` + + `, ${d.termine.gewesen} Termine gewesen, ${d.termine.kommend} stehen an.`; + const r = await window.KI.frage('reporttext', { zahlen }); + const alt = reihe.querySelector('.ki-text'); + if (alt) alt.remove(); + const text = el('p', 'ki-text', r.text); + reihe.append(text); + })); + ziel.append(reihe); + } } async function laden() { @@ -201,6 +223,7 @@ ich = await a.json(); } catch { return; } $('wer').textContent = ich.name + ' · ' + (ich.rolle_name || ich.rolle); + kiDa = await window.KI.verfuegbar(); try { const { creator } = await (await hole('/workspace/api/report/creator')).json(); diff --git a/workspace/assets/js/startcheck.js b/workspace/assets/js/startcheck.js index daa2ce06..9792b2ed 100644 --- a/workspace/assets/js/startcheck.js +++ b/workspace/assets/js/startcheck.js @@ -22,6 +22,7 @@ let ich = null; let daten = null; let aktuell = null; + let kiDa = false; const el = (tag, klasse, text) => { const k = document.createElement(tag); @@ -156,6 +157,18 @@ titel.select(); }); + /* Die KI formuliert daraus eine Handlung. Der einfache Vorschlag + oben bleibt bestehen -- er funktioniert auch ohne KI. */ + if (kiDa && p.notiz) { + form.append(window.KI.knopf('Besser formulieren', async () => { + const d = await window.KI.frage('aufgabentitel', + { befund: p.notiz, bereich: feld.name }); + if (!d.titel) throw new Error('Die KI hat nichts geliefert.'); + titel.value = d.titel; + titel.focus(); + })); + } + form.addEventListener('submit', async (e) => { e.preventDefault(); ok.disabled = true; @@ -255,6 +268,7 @@ ich = await a.json(); } catch { return; } $('wer').textContent = ich.name + ' · ' + (ich.rolle_name || ich.rolle); + kiDa = await window.KI.verfuegbar(); let liste; try { liste = await (await hole('/workspace/api/startcheck/creator')).json(); } catch { return; } diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index ea1f4cce..9b4a8438 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -7,9 +7,9 @@ - - - + + + @@ -134,7 +134,7 @@ - - + + diff --git a/workspace/bereich.html b/workspace/bereich.html index e84ac3d4..e57a678c 100644 --- a/workspace/bereich.html +++ b/workspace/bereich.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -98,7 +98,7 @@ - - + + diff --git a/workspace/calls.html b/workspace/calls.html index 7622bb7f..eae5d518 100644 --- a/workspace/calls.html +++ b/workspace/calls.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -49,7 +49,8 @@ - - + + + diff --git a/workspace/dateien.html b/workspace/dateien.html index 241611c1..d17a08b4 100644 --- a/workspace/dateien.html +++ b/workspace/dateien.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -88,7 +88,7 @@ - - + + diff --git a/workspace/index.html b/workspace/index.html index 402eb106..fd8fd211 100644 --- a/workspace/index.html +++ b/workspace/index.html @@ -25,7 +25,7 @@ type="image/webp" media="(max-width: 899px)" /> - + @@ -130,6 +130,6 @@

- + diff --git a/workspace/kalender.html b/workspace/kalender.html index cb87db37..b8f29569 100644 --- a/workspace/kalender.html +++ b/workspace/kalender.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -94,7 +94,7 @@ - - + + diff --git a/workspace/personen.html b/workspace/personen.html index 231f5e0a..57bff049 100644 --- a/workspace/personen.html +++ b/workspace/personen.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -87,7 +87,7 @@ - - + + diff --git a/workspace/profil.html b/workspace/profil.html index 21a4f993..c09cbbb2 100644 --- a/workspace/profil.html +++ b/workspace/profil.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -127,7 +127,7 @@ - - + + diff --git a/workspace/report.html b/workspace/report.html index 7ca5da71..fa99f895 100644 --- a/workspace/report.html +++ b/workspace/report.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -86,7 +86,8 @@ - - + + + diff --git a/workspace/scouting.html b/workspace/scouting.html index d9611ada..4be04520 100644 --- a/workspace/scouting.html +++ b/workspace/scouting.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -124,7 +124,7 @@ - - + + diff --git a/workspace/start.html b/workspace/start.html index 77fcd6a7..a024140b 100644 --- a/workspace/start.html +++ b/workspace/start.html @@ -7,8 +7,8 @@ - - + + @@ -56,7 +56,7 @@

- - + + diff --git a/workspace/startcheck.html b/workspace/startcheck.html index 606ee080..a3d714ad 100644 --- a/workspace/startcheck.html +++ b/workspace/startcheck.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -65,7 +65,8 @@ - - + + + diff --git a/workspace/wissen.html b/workspace/wissen.html index 968af94f..d490d749 100644 --- a/workspace/wissen.html +++ b/workspace/wissen.html @@ -7,10 +7,10 @@ - - - - + + + + @@ -165,7 +165,7 @@ - - + +