commit 498fe2220a507cfba8df997a041bc1ed0c93961d Author: Team3 Date: Sun Sep 27 21:36:33 2026 +0200 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..757393e --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.venv/ +storage/ +frontend/node_modules/ +frontend/dist/ +__pycache__/ +.env diff --git a/API.md b/API.md new file mode 100644 index 0000000..08e732a --- /dev/null +++ b/API.md @@ -0,0 +1,81 @@ +# buchwerk – Schnittstelle Backend ↔ Frontend + +Backend: FastAPI auf Port 8020, liefert `frontend/dist/` aus. Alle Pfade unter `/api`. + +## Themen + +- `GET /api/topics` → `[{name, titel, status, laeuft, stufe}]` + - `status`: `neu | laeuft | pausiert | fertig | fehler` +- `POST /api/topics` `{name, hinweise}` → Topic (name = Thema, z. B. „Godot 4“; hinweise optional, z. B. „für Entwickler, ab Version 4.6“) +- `DELETE /api/topics/{name}` +- `POST /api/topics/{name}/start` – startet oder setzt fort (resümiert an der Stufe, die noch offen ist) +- `POST /api/topics/{name}/pause` – bricht laufende Agenten ab; Neustart mit `start` +- `POST /api/topics/{name}/reset` – löscht alles außer `thema.json` + +## Zustand eines Laufs + +`GET /api/topics/{name}/state` → + +```json +{ + "name": "Godot 4", "titel": "Godot 4", "hinweise": "…", + "status": "laeuft", "grund": "", "stufe": "schreiben", + "deutung": {"titel": "Godot 4", "untertitel": "…", "deutung": "…", "buchtyp": "werkzeug|wissen", "zielgruppe": "…"}, + "stufen": [ + {"id": "recherche", "titel": "Thema deuten und Quellen beschaffen", "status": "fertig", "fertig": 1, "gesamt": 1, + "kosten_usd": 1.2, "tokens": 250000, "gestartet": "2026-09-28T10:00:00Z", "beendet": "…"}, + {"id": "gliederung", "titel": "Gliederung", …}, + {"id": "schreiben", "titel": "Kapitel schreiben", "status": "laeuft", "fertig": 3, "gesamt": 18, …}, + {"id": "pruefung", "titel": "Kapitel prüfen", …}, + {"id": "konsistenz", "titel": "Querverweise und Vorwort", …}, + {"id": "montage", "titel": "Buch zusammensetzen", …} + ], + "kosten_usd": 12.3, "tokens": 4100000, + "agenten": [ + {"id": "schreiben-03", "stufe": "schreiben", "name": "Kapitel 3 schreiben", "status": "laeuft|fertig|fehler|abgebrochen", + "kosten_usd": 0.9, "tokens": 180000, "werkzeuge": 41, "gestartet": "…", "beendet": "…", "bericht": ""} + ], + "ereignisse": [{"zeit": "…", "agent": "schreiben-03", "art": "tool|text|start|ende|fehler", "text": "Write buch/03-dal.md"}] +} +``` + +`stufen[].status`: `offen | laeuft | fertig | fehler`. `ereignisse` sind die letzten 100. + +## Buch + +`GET /api/topics/{name}/guide` → + +```json +{ + "titel": "Godot 4", "untertitel": "Architektur, Konzepte und Praxis der Engine", "status": "fertig", + "vorwort": "", + "kapitel": [ + {"id": "03", "ord": 3, "titel": "GDScript", "teil": "Teil I – Grundlagen", "datei": "buch/03-gdscript.md", + "status": "fehlt|geschrieben|geprueft|konsistent", "intro": "", + "abschnitte": [{"id": "03-1", "nr": "3.1", "titel": "Sprache und Syntax", "text": "", "woerter": 1400, "minuten": 8}], + "woerter": 7000, "minuten": 39, + "berichte": {"autor": "", "pruefer": ""}} + ], + "woerter": 130000, "minuten": 720, + "gliederung": "", "quellen": "", + "pdf": true +} +``` + +- `GET /api/topics/{name}/guide.md` – ganzes Buch als Markdown +- `GET /api/topics/{name}/buch.pdf` – PDF (404, solange nicht gebaut) + +Solange ein Lauf läuft, enthält `guide` die bereits geschriebenen Kapitel (Status `geschrieben` bis der Prüfer fertig ist). + +## Dateien im Speicher + +``` +storage// + thema.json {name, titel, hinweise, angelegt} + state.json Zustand wie oben (ohne guide) + deutung.json vom Recherche-Agenten + quellen/ QUELLEN.md + geklonte/geladene Quellen + buch/ STIL.md, GLIEDERUNG.md, 00-vorwort.md, NN-*.md, README.md, buch.md, buch.pdf + berichte/ .md (Abschlussberichte) + log.jsonl Ereignisse +``` diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2dcb9fb --- /dev/null +++ b/Makefile @@ -0,0 +1,15 @@ +PY := .venv/bin/python + +install: + python3 -m venv .venv + .venv/bin/pip install -r backend/requirements.txt + cd frontend && npm install + +build: + cd frontend && npm run build + +run: + $(PY) -m uvicorn backend.main:app --port 8020 + +dev: + $(PY) -m uvicorn backend.main:app --reload --port 8020 & cd frontend && npm run dev diff --git a/README.md b/README.md new file mode 100644 index 0000000..313fc69 --- /dev/null +++ b/README.md @@ -0,0 +1,19 @@ +# buchwerk + +Aus einem Thema (plus optionalen Hinweisen) entsteht ein deutschsprachiges Sachbuch. Ein Python-Backend +steuert Agenten des Claude Agent SDK (Opus 5.5): Recherche und Deutung, Gliederung, Kapitel parallel +schreiben, jedes Kapitel durch einen unabhängigen Agenten prüfen, Querverweise abgleichen, Vorwort, +Montage (Markdown, README, PDF). Das Buch wird im Browser als Leseansicht gezeigt. + +```bash +make install # venv + npm +make build # Frontend bauen +make run # http://localhost:8020 +``` + +Auth: der Agent SDK nutzt den Claude-Code-Login des Rechners (oder ANTHROPIC_API_KEY, falls gesetzt). + +Ablauf (backend/pipeline.py): Stufen seriell, innerhalb einer Stufe bis zu `PARALLEL` Agenten gleichzeitig. +Jeder Agent arbeitet im Themenverzeichnis `storage//`; sein Abschlussbericht landet unter `berichte/` +und dient als Marker: existiert der Bericht, gilt die Aufgabe als erledigt. Pause bricht laufende Agenten +ab, „Fortsetzen“ wiederholt nur, was noch fehlt. Prompts liegen in `prompts/`, Schnittstelle in `API.md`. diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/agenten.py b/backend/agenten.py new file mode 100644 index 0000000..46e73e8 --- /dev/null +++ b/backend/agenten.py @@ -0,0 +1,74 @@ +"""Ein Agent = eine Claude-Agent-SDK-Query mit dem Themenverzeichnis als Arbeitsverzeichnis.""" +import asyncio +import logging + +from claude_agent_sdk import (AssistantMessage, ClaudeAgentOptions, ResultMessage, TextBlock, ToolUseBlock, query) + +from . import config, state + +log = logging.getLogger("buchwerk") + +SYSTEM_ZUSATZ = ( + "Du bist ein Agent in einer Pipeline, die ein deutschsprachiges Sachbuch erstellt. Das aktuelle Arbeitsverzeichnis " + "ist das Buchprojekt; verlasse es nicht (außer für Klone und Downloads, die du unter quellen/ ablegst). " + "Es gibt keinen menschlichen Ansprechpartner: Rückfragen sind nicht möglich, entscheide selbst. " + "Arbeite gründlich und bis zum Ende der Aufgabe. Antworte auf Deutsch. Deine letzte Nachricht ist der Abschlussbericht." +) + + +def _kurz(block: ToolUseBlock) -> str: + i = block.input or {} + for k in ("file_path", "command", "pattern", "query", "url", "description"): + if k in i: + return f"{block.name} {str(i[k])[:160]}" + return block.name + + +async def laufen(name: str, agent_id: str, stufe_id: str, titel: str, prompt: str, *, + effort: str = config.EFFORT, max_turns: int = config.MAX_TURNS) -> dict: + """Führt einen Agenten aus; schreibt Ereignisse und Bericht. → {"status", "bericht", "kosten", "tokens"}""" + a = state.agent_eintrag(name, agent_id, stufe_id, titel) + state.ereignis(name, agent_id, "start", titel) + options = ClaudeAgentOptions( + model=config.MODELL, effort=effort, cwd=str(state.verzeichnis(name)), + permission_mode="bypassPermissions", max_turns=max_turns, + system_prompt={"type": "preset", "preset": "claude_code", "append": SYSTEM_ZUSATZ}, + ) + letzter_text, kosten, tokens, status, fehler = "", 0.0, 0, "fehler", "" + try: + async for m in query(prompt=prompt, options=options): + if isinstance(m, AssistantMessage): + for b in m.content: + if isinstance(b, ToolUseBlock): + a["werkzeuge"] += 1 + if a["werkzeuge"] % 5 == 1: + state.ereignis(name, agent_id, "tool", _kurz(b)) + elif isinstance(b, TextBlock) and b.text.strip(): + letzter_text = b.text + elif isinstance(m, ResultMessage): + kosten = m.total_cost_usd or 0.0 + u = m.usage or {} + tokens = sum(int(u.get(k) or 0) for k in + ("input_tokens", "output_tokens", "cache_creation_input_tokens", "cache_read_input_tokens")) + if m.result and m.result.strip(): + letzter_text = m.result + if m.is_error or m.subtype != "success": + fehler = f"{m.subtype}: " + "; ".join(str(e) for e in (m.errors or [])[:3]) + else: + status = "fertig" + except asyncio.CancelledError: + state.agent_fertig(name, a, "abgebrochen", letzter_text, kosten, tokens) + state.ereignis(name, agent_id, "ende", "abgebrochen") + raise + except Exception as e: # ProcessError, CLIConnectionError, … + fehler = f"{type(e).__name__}: {e}" + log.exception("Agent %s", agent_id) + bericht = letzter_text.strip() + if status != "fertig": + bericht = f"**Fehler:** {fehler}\n\n{bericht}".strip() + state.agent_fertig(name, a, status, bericht, kosten, tokens) + if status == "fertig": + state.bericht_schreiben(name, agent_id, bericht) + state.ereignis(name, agent_id, "ende" if status == "fertig" else "fehler", + f"{status} · {kosten:.2f} USD · {a['werkzeuge']} Werkzeugaufrufe" + (f" · {fehler}" if fehler else "")) + return {"status": status, "bericht": bericht, "kosten": kosten, "tokens": tokens} diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..33675d9 --- /dev/null +++ b/backend/config.py @@ -0,0 +1,23 @@ +from pathlib import Path + +WURZEL = Path(__file__).resolve().parent.parent +STORAGE = WURZEL / "storage" +PROMPTS = WURZEL / "prompts" +FRONTEND_DIST = WURZEL / "frontend" / "dist" + +PORT = 8020 +MODELL = "claude-opus-5-5" +EFFORT = "high" +PARALLEL = 5 # gleichzeitige Kapitel-Agenten +MAX_TURNS = 800 +KONSISTENZ_GRUPPE = 5 # Kapitel je Querverweis-Agent +LESE_WPM = 180 + +STUFEN = [ + ("recherche", "Thema deuten und Quellen beschaffen"), + ("gliederung", "Gliederung"), + ("schreiben", "Kapitel schreiben"), + ("pruefung", "Kapitel prüfen"), + ("konsistenz", "Querverweise und Vorwort"), + ("montage", "Buch zusammensetzen"), +] diff --git a/backend/gliederung.py b/backend/gliederung.py new file mode 100644 index 0000000..aea515c --- /dev/null +++ b/backend/gliederung.py @@ -0,0 +1,52 @@ +"""Parser für buch/GLIEDERUNG.md (Format siehe prompts/gliederung.md).""" +import re + +KAPITEL_RE = re.compile(r"^\*\*Kapitel (\d+) – (.+?)\*\* \(`([0-9]{2}-[a-z0-9-]+\.md)`\)\s*$") +TEIL_RE = re.compile(r"^## (Teil .+?)\s*$") +TITEL_RE = re.compile(r"^# Gliederung – (.+?)(?:\. (.+))?$") + + +def parsen(text: str) -> dict: + """→ {"titel", "untertitel", "kapitel": [{"n","titel","datei","teil","abschnitte":[{"nr","titel"}]}], "fehler": [...]}""" + aus = {"titel": "", "untertitel": "", "kapitel": [], "fehler": []} + teil = "" + zeilen = text.splitlines() + i = 0 + while i < len(zeilen): + z = zeilen[i].rstrip() + m = TITEL_RE.match(z) + if m and not aus["titel"]: + aus["titel"], aus["untertitel"] = m.group(1).strip(), (m.group(2) or "").strip() + elif TEIL_RE.match(z): + teil = TEIL_RE.match(z).group(1) + elif KAPITEL_RE.match(z): + m = KAPITEL_RE.match(z) + n = int(m.group(1)) + k = {"n": n, "titel": m.group(2).strip(), "datei": m.group(3), "teil": teil, "abschnitte": []} + # nächste nicht-leere Zeile: Abschnitte + j = i + 1 + while j < len(zeilen) and not zeilen[j].strip(): + j += 1 + if j < len(zeilen): + for teilstueck in re.split(r"\s+·\s+", zeilen[j].strip()): + am = re.match(r"^(\d+)\.(\d+)\s+(.+)$", teilstueck.strip()) + if am and int(am.group(1)) == n: + k["abschnitte"].append({"nr": f"{am.group(1)}.{am.group(2)}", "titel": am.group(3).strip()}) + else: + aus["fehler"].append(f"Kapitel {n}: Abschnittszeile unlesbar: „{teilstueck.strip()[:60]}“") + i = j + if not k["datei"].startswith(f"{n:02d}-"): + aus["fehler"].append(f"Kapitel {n}: Dateiname muss mit {n:02d}- beginnen ({k['datei']})") + aus["kapitel"].append(k) + elif z.startswith("**Kapitel"): + aus["fehler"].append(f"Kapitelzeile nicht im Format: „{z[:80]}“") + i += 1 + for k in aus["kapitel"]: + if not 3 <= len(k["abschnitte"]) <= 6: + aus["fehler"].append(f"Kapitel {k['n']} hat {len(k['abschnitte'])} Abschnitte (3–6 verlangt)") + nummern = [k["n"] for k in aus["kapitel"]] + if nummern != list(range(1, len(nummern) + 1)): + aus["fehler"].append(f"Kapitelnummern nicht fortlaufend: {nummern}") + if not aus["kapitel"]: + aus["fehler"].append("Keine Kapitelzeile gefunden") + return aus diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..6f4b1c3 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,123 @@ +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI, HTTPException +from fastapi.responses import FileResponse, PlainTextResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel + +from . import config, montage, pipeline, state + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + config.STORAGE.mkdir(exist_ok=True) + yield + for name in list(pipeline._laeufe): + await pipeline.pausieren(name) + + +app = FastAPI(title="buchwerk", lifespan=lifespan) + + +class ThemaNeu(BaseModel): + name: str + hinweise: str = "" + + +def _pruefen(name: str) -> dict: + if not state.name_ok(name) or not (t := state.thema(name)): + raise HTTPException(404, "Thema unbekannt") + return t + + +@app.get("/api/topics") +async def topics(): + aus = [] + for t in state.themen(): + st = state.zustand(t["name"]) + aus.append({"name": t["name"], "titel": t["titel"], "status": st["status"], + "laeuft": pipeline.laeuft(t["name"]), "stufe": st["stufe"]}) + return aus + + +@app.post("/api/topics") +async def topic_anlegen(body: ThemaNeu): + name = body.name.strip() + if not state.name_ok(name): + raise HTTPException(400, "Ungültiger Name") + try: + return state.anlegen(name, body.hinweise) + except ValueError as e: + raise HTTPException(409, str(e)) + + +@app.delete("/api/topics/{name}") +async def topic_loeschen(name: str): + _pruefen(name) + await pipeline.pausieren(name) + state.loeschen(name) + return {"ok": True} + + +@app.post("/api/topics/{name}/start") +async def start(name: str): + _pruefen(name) + if state.zustand(name)["status"] == "fertig": + raise HTTPException(400, "Lauf ist fertig – erst zurücksetzen") + pipeline.starten(name) + return {"ok": True} + + +@app.post("/api/topics/{name}/pause") +async def pause(name: str): + _pruefen(name) + await pipeline.pausieren(name) + return {"ok": True} + + +@app.post("/api/topics/{name}/reset") +async def reset(name: str): + _pruefen(name) + await pipeline.pausieren(name) + state.zuruecksetzen(name) + return {"ok": True} + + +@app.get("/api/topics/{name}/state") +async def zustand(name: str): + t = _pruefen(name) + st = state.zustand(name) + return {"name": name, "titel": t["titel"], "hinweise": t.get("hinweise", ""), **st, + "deutung": st.get("deutung") or state.deutung(name), "ereignisse": st["ereignisse"][-100:]} + + +@app.get("/api/topics/{name}/guide") +async def guide(name: str): + _pruefen(name) + return montage.guide(name) + + +@app.get("/api/topics/{name}/guide.md", response_class=PlainTextResponse) +async def guide_md(name: str): + _pruefen(name) + return montage.markdown(name) + + +@app.get("/api/topics/{name}/buch.pdf") +async def buch_pdf(name: str): + _pruefen(name) + p = state.verzeichnis(name) / "buch" / "buch.pdf" + if not p.exists(): + raise HTTPException(404, "PDF noch nicht gebaut") + return FileResponse(p, media_type="application/pdf", filename=f"{name}.pdf") + + +if config.FRONTEND_DIST.exists(): + app.mount("/assets", StaticFiles(directory=config.FRONTEND_DIST / "assets"), name="assets") + + @app.get("/{pfad:path}") + async def spa(pfad: str): + return FileResponse(config.FRONTEND_DIST / "index.html") diff --git a/backend/montage.py b/backend/montage.py new file mode 100644 index 0000000..379d9cb --- /dev/null +++ b/backend/montage.py @@ -0,0 +1,186 @@ +"""Buch aus den Kapiteldateien zusammensetzen: Guide-JSON, README, Gesamt-Markdown, PDF.""" +import html +import logging +import re +import shutil +import subprocess + +from . import config, gliederung, state + +log = logging.getLogger("buchwerk") +ABSCHNITT_RE = re.compile(r"^## (\d+\.\d+) (.+?)\s*$", re.M) + + +def _woerter(text: str) -> int: + return len(re.sub(r"```.*?```", "", text, flags=re.S).split()) + + +def _kapitel_lesen(v, k: dict) -> dict: + p = v / "buch" / k["datei"] + aus = {"id": f"{k['n']:02d}", "ord": k["n"], "titel": k["titel"], "teil": k["teil"], "datei": f"buch/{k['datei']}", + "status": "fehlt", "intro": "", "abschnitte": [], "woerter": 0, "minuten": 0, + "berichte": {"autor": state.bericht_lesen(v.name, f"kapitel-{k['n']:02d}"), + "pruefer": state.bericht_lesen(v.name, f"pruefung-{k['n']:02d}")}} + if not p.exists(): + return aus + text = p.read_text() + text = re.sub(r"^# .*\n", "", text, count=1) + treffer = list(ABSCHNITT_RE.finditer(text)) + aus["intro"] = text[:treffer[0].start()].strip() if treffer else text.strip() + for i, m in enumerate(treffer): + ende = treffer[i + 1].start() if i + 1 < len(treffer) else len(text) + inhalt = text[m.end():ende].strip() + w = _woerter(inhalt) + aus["abschnitte"].append({"id": f"{k['n']:02d}-{m.group(1).split('.')[1]}", "nr": m.group(1), + "titel": m.group(2), "text": inhalt, "woerter": w, + "minuten": round(w / config.LESE_WPM)}) + aus["woerter"] = _woerter(text) + aus["minuten"] = round(aus["woerter"] / config.LESE_WPM) + st = state.zustand(v.name) + if state.stufe(v.name, "konsistenz")["status"] == "fertig": + aus["status"] = "konsistent" + elif aus["berichte"]["pruefer"]: + aus["status"] = "geprueft" + else: + aus["status"] = "geschrieben" + return aus + + +def guide(name: str) -> dict: + v = state.verzeichnis(name) + t = state.thema(name) or {} + d = state.deutung(name) or {} + gp = v / "buch" / "GLIEDERUNG.md" + g = gliederung.parsen(gp.read_text()) if gp.exists() else {"titel": "", "untertitel": "", "kapitel": []} + kapitel = [_kapitel_lesen(v, k) for k in g["kapitel"]] + vorwort = "" + vp = v / "buch" / "00-vorwort.md" + if vp.exists(): + vorwort = re.sub(r"^# .*\n", "", vp.read_text(), count=1).strip() + qp = v / "quellen" / "QUELLEN.md" + woerter = sum(k["woerter"] for k in kapitel) + return {"titel": d.get("titel") or g["titel"] or t.get("titel") or name, + "untertitel": d.get("untertitel") or g["untertitel"] or "", + "status": state.zustand(name)["status"], "vorwort": vorwort, "kapitel": kapitel, + "woerter": woerter, "minuten": round(woerter / config.LESE_WPM), + "gliederung": gp.read_text() if gp.exists() else "", + "quellen": qp.read_text() if qp.exists() else "", + "pdf": (v / "buch" / "buch.pdf").exists()} + + +def markdown(name: str) -> str: + g = guide(name) + v = state.verzeichnis(name) + zeilen = [f"# {g['titel']}", ""] + if g["untertitel"]: + zeilen += [f"*{g['untertitel']}*", ""] + if g["vorwort"]: + zeilen += ["## Vorwort", "", g["vorwort"], ""] + teil = None + for k in g["kapitel"]: + p = v / "buch" / k["datei"].split("/", 1)[1] + if not p.exists(): + continue + if k["teil"] and k["teil"] != teil: + teil = k["teil"] + zeilen += [f"# {teil}", ""] + zeilen += [p.read_text().strip(), ""] + return "\n".join(zeilen) + + +def readme(name: str) -> str: + g = guide(name) + out = [f"# {g['titel']}", ""] + if g["untertitel"]: + out += [f"*{g['untertitel']}*", ""] + out += ["PDF: [buch.pdf](buch.pdf) · Gesamttext: [buch.md](buch.md)", "", "## Inhalt", "", "[Vorwort](00-vorwort.md)", ""] + teil = None + for k in g["kapitel"]: + if k["teil"] and k["teil"] != teil: + teil = k["teil"] + out += [f"### {teil}", ""] + d = k["datei"].split("/", 1)[1] + out += [f"**[Kapitel {k['ord']} – {k['titel']}]({d})** ", + " · ".join(f"{a['nr']} {a['titel']}" for a in k["abschnitte"]), ""] + out += [f"*{len(g['kapitel'])} Kapitel, rund {round(g['woerter'], -3):,} Wörter.*".replace(",", "."), ""] + return "\n".join(out) + + +# ---- PDF ---------------------------------------------------------------------- + +CSS = """ +@page{size:A5;margin:17mm 14mm 19mm 14mm;@bottom-center{content:counter(page);font:9pt "DejaVu Serif"}} +@page:first{@bottom-center{content:none}} +body{font-family:"DejaVu Serif",serif;font-size:9.3pt;line-height:1.42;text-align:left;hyphens:auto} +h1.chap{font-size:16pt;margin:0 0 9mm;page-break-before:always} +h2{font-size:11.8pt;margin:7mm 0 2mm;page-break-after:avoid} +h3{font-size:10pt;margin:4mm 0 1mm;page-break-after:avoid} +p{margin:0 0 2.2mm;orphans:2;widows:2} +code{font-family:"DejaVu Sans Mono",monospace;font-size:7.9pt;hyphens:none} +.codehilite,pre{background:#f5f5f2!important;border:0.4pt solid #ccc;padding:2mm;margin:2mm 0 3mm;border-radius:1mm} +pre{white-space:pre-wrap;word-wrap:break-word;font-size:7.3pt;line-height:1.3;margin:0;border:none;padding:0} +table{border-collapse:collapse;font-size:8pt;margin:3mm 0;width:100%} +td,th{border:0.4pt solid #888;padding:1.2mm;vertical-align:top;text-align:left} +ul,ol{padding-left:5mm} li{margin-bottom:1.2mm} +.title{page-break-after:always;text-align:center;padding-top:50mm} +.title h1{font-size:28pt;margin:0}.title p{font-size:12pt} +.part{page-break-before:always;page-break-after:always;text-align:center;padding-top:60mm} +.parttitle{font-size:19pt} +.toc{page-break-before:always}.toc ul{list-style:none;padding:0} +.tpart{font-weight:bold;margin-top:4mm}.tch a{color:black;text-decoration:none} +.tch a::after{content:leader(".") target-counter(attr(href),page)} +a{color:black} +""" + + +def pdf(name: str) -> bool: + exe = shutil.which("weasyprint") + if not exe: + log.warning("weasyprint fehlt – kein PDF") + return False + import markdown as md_mod + g = guide(name) + v = state.verzeichnis(name) + md = md_mod.Markdown(extensions=["tables", "sane_lists", "fenced_code", "codehilite"], + extension_configs={"codehilite": {"noclasses": True, "pygments_style": "friendly", "guess_lang": False}}) + body, toc, teil = [], [], None + if g["vorwort"]: + body.append(f'

Vorwort

{md.convert(g["vorwort"])}
') + toc.append('
  • Vorwort
  • ') + md.reset() + for k in g["kapitel"]: + p = v / "buch" / k["datei"].split("/", 1)[1] + if not p.exists(): + continue + if k["teil"] and k["teil"] != teil: + teil = k["teil"] + tt = html.escape(teil).replace(" – ", "
    ", 1) + body.append(f'

    {tt}

    ') + toc.append(f'
  • {html.escape(teil)}
  • ') + aid = f"k{k['ord']}" + t = f"Kapitel {k['ord']} – {k['titel']}" + toc.append(f'
  • {html.escape(t)}
  • ') + h = md.convert(p.read_text()).replace("

    ", f'

    ', 1) + h = h.replace("border: 1px solid #FF0000", "border: none") + body.append(f'
    {h}
    ') + md.reset() + doc = (f'{html.escape(g["titel"])}' + f'

    {html.escape(g["titel"])}

    ' + f'

    {html.escape(g["untertitel"])}

    Inhalt

    ' + f'
      {"".join(toc)}
    {"".join(body)}') + hp, pp = v / "arbeit" / "buch.html", v / "buch" / "buch.pdf" + hp.parent.mkdir(exist_ok=True) + hp.write_text(doc) + r = subprocess.run([exe, str(hp), str(pp)], capture_output=True, text=True, timeout=900) + if r.returncode != 0: + log.error("weasyprint: %s", r.stderr[-500:]) + return False + return True + + +def bauen(name: str) -> bool: + """README, Gesamt-Markdown, PDF. Läuft in einem Thread – keine Zustandsänderungen hier.""" + v = state.verzeichnis(name) + (v / "buch" / "README.md").write_text(readme(name)) + (v / "buch" / "buch.md").write_text(markdown(name)) + return pdf(name) diff --git a/backend/pipeline.py b/backend/pipeline.py new file mode 100644 index 0000000..401d0ef --- /dev/null +++ b/backend/pipeline.py @@ -0,0 +1,214 @@ +"""Lauf-Orchestrierung: Stufen seriell, innerhalb einer Stufe parallele Agenten; jede Stufe resümierbar über +Dateimarker (Bericht existiert = Agent fertig).""" +import asyncio +import logging + +from . import agenten, config, gliederung, montage, prompts, state + +log = logging.getLogger("buchwerk") +_laeufe: dict[str, asyncio.Task] = {} + + +class Abbruch(Exception): + pass + + +def laeuft(name: str) -> bool: + t = _laeufe.get(name) + return bool(t and not t.done()) + + +def starten(name: str) -> None: + if laeuft(name): + return + st = state.zustand(name) + st.update(status="laeuft", grund="") + state.speichern(name) + _laeufe[name] = asyncio.create_task(_lauf(name)) + + +async def pausieren(name: str) -> None: + t = _laeufe.get(name) + if t and not t.done(): + t.cancel() + try: + await t + except (asyncio.CancelledError, Exception): + pass + + +async def _lauf(name: str) -> None: + st = state.zustand(name) + try: + for sid, _ in config.STUFEN: + s = state.stufe(name, sid) + if s["status"] == "fertig": + continue + s.update(status="laeuft", gestartet=s["gestartet"] or state.jetzt(), beendet="") + st["stufe"] = sid + state.speichern(name) + await STUFEN_FN[sid](name) + s.update(status="fertig", beendet=state.jetzt()) + state.speichern(name) + st.update(status="fertig", grund="", stufe="") + except asyncio.CancelledError: + st.update(status="pausiert", grund="Pausiert") + _offen(name) + state.speichern(name) + raise + except Exception as e: + log.exception("Lauf %s", name) + st.update(status="fehler", grund=f"{type(e).__name__}: {e}"[:500]) + for s in st["stufen"]: + if s["status"] == "laeuft": + s["status"] = "fehler" + finally: + state.speichern(name) + + +def _offen(name: str) -> None: + for s in state.zustand(name)["stufen"]: + if s["status"] == "laeuft": + s["status"] = "offen" + + +def _thema(name: str) -> tuple[str, str]: + t = state.thema(name) or {} + return t.get("titel") or name, t.get("hinweise", "") + + +def _kapitel(name: str) -> list[dict]: + p = state.verzeichnis(name) / "buch" / "GLIEDERUNG.md" + g = gliederung.parsen(p.read_text()) + if g["fehler"]: + raise Abbruch("Gliederung unlesbar: " + "; ".join(g["fehler"][:3])) + return g["kapitel"] + + +async def _parallel(name: str, sid: str, aufgaben: list) -> None: + """aufgaben: [(agent_id, titel, prompt)], schon fertige sind vorher entfernt.""" + sem = asyncio.Semaphore(config.PARALLEL) + s = state.stufe(name, sid) + fehler = [] + + async def eins(agent_id, titel, prompt): + async with sem: + r = await agenten.laufen(name, agent_id, sid, titel, prompt) + if r["status"] == "fertig": + s["fertig"] += 1 + else: + fehler.append(f"{titel}: {r['bericht'][:120]}") + state.speichern(name) + + await asyncio.gather(*(eins(*a) for a in aufgaben)) + if fehler: + raise Abbruch(f"{len(fehler)} Agent(en) gescheitert – „Fortsetzen“ wiederholt nur die offenen: " + " | ".join(fehler[:3])) + + +# ---- Stufen ------------------------------------------------------------------- + +async def st_recherche(name: str) -> None: + thema, hinweise = _thema(name) + s = state.stufe(name, "recherche") + s.update(gesamt=1, fertig=0) + v = state.verzeichnis(name) + if not state.bericht_lesen(name, "recherche"): + p = prompts.laden("recherche", thema=thema, hinweise_block=prompts.hinweise_block(hinweise), + stil_basis=(config.PROMPTS / "stil_basis.md").read_text()) + r = await agenten.laufen(name, "recherche", "recherche", "Thema deuten und Quellen beschaffen", p) + if r["status"] != "fertig": + raise Abbruch("Recherche gescheitert: " + r["bericht"][:200]) + fehlend = [f for f in ("deutung.json", "buch/STIL.md", "quellen/QUELLEN.md") if not (v / f).exists()] + if fehlend: + (v / "berichte" / "recherche.md").unlink(missing_ok=True) + raise Abbruch("Recherche unvollständig, es fehlt: " + ", ".join(fehlend)) + d = state.deutung(name) + state.zustand(name)["deutung"] = d + if d and d.get("titel"): + t = state.thema(name) + t["titel"] = d["titel"] + state._schreiben(v / "thema.json", t) + s["fertig"] = 1 + + +async def st_gliederung(name: str) -> None: + thema, hinweise = _thema(name) + s = state.stufe(name, "gliederung") + s.update(gesamt=1, fertig=0) + p = state.verzeichnis(name) / "buch" / "GLIEDERUNG.md" + hinweis = "" + for versuch in (1, 2): + if p.exists() and not gliederung.parsen(p.read_text())["fehler"] and state.bericht_lesen(name, "gliederung"): + break + pr = prompts.laden("gliederung", thema=thema, hinweise_block=prompts.hinweise_block(hinweise)) + hinweis + r = await agenten.laufen(name, "gliederung", "gliederung", "Gliederung entwerfen", pr) + if r["status"] != "fertig": + raise Abbruch("Gliederung gescheitert: " + r["bericht"][:200]) + g = gliederung.parsen(p.read_text()) if p.exists() else {"fehler": ["Datei fehlt"]} + if not g["fehler"]: + break + hinweis = ("\n\nFORMATFEHLER im vorigen Versuch – bitte buch/GLIEDERUNG.md exakt im vorgegebenen Format neu schreiben:\n- " + + "\n- ".join(g["fehler"][:10])) + state.ereignis(name, "gliederung", "fehler", "Format ungültig, zweiter Versuch") + else: + raise Abbruch("Gliederung auch im zweiten Versuch nicht parsebar") + s["fertig"] = 1 + + +async def st_schreiben(name: str) -> None: + thema, _ = _thema(name) + kap = _kapitel(name) + s = state.stufe(name, "schreiben") + offen = [k for k in kap if not state.bericht_lesen(name, f"kapitel-{k['n']:02d}")] + s.update(gesamt=len(kap), fertig=len(kap) - len(offen)) + aufgaben = [] + for k in offen: + p = prompts.laden("kapitel", thema=thema, n=str(k["n"]), kapitel_titel=k["titel"], datei=k["datei"]) + if (state.verzeichnis(name) / "buch" / k["datei"]).exists(): + p += "\n\nHinweis: `buch/" + k["datei"] + "` existiert bereits aus einem abgebrochenen Lauf – prüfe sie und vervollständige oder ersetze sie." + aufgaben.append((f"kapitel-{k['n']:02d}", f"Kapitel {k['n']} schreiben", p)) + await _parallel(name, "schreiben", aufgaben) + + +async def st_pruefung(name: str) -> None: + thema, _ = _thema(name) + kap = _kapitel(name) + s = state.stufe(name, "pruefung") + offen = [k for k in kap if not state.bericht_lesen(name, f"pruefung-{k['n']:02d}")] + s.update(gesamt=len(kap), fertig=len(kap) - len(offen)) + aufgaben = [] + for k in offen: + autor = state.bericht_lesen(name, f"kapitel-{k['n']:02d}") or "(kein Autorenbericht vorhanden)" + p = prompts.laden("pruefung", thema=thema, n=str(k["n"]), kapitel_titel=k["titel"], datei=k["datei"], autor_bericht=autor) + aufgaben.append((f"pruefung-{k['n']:02d}", f"Kapitel {k['n']} prüfen", p)) + await _parallel(name, "pruefung", aufgaben) + + +async def st_konsistenz(name: str) -> None: + thema, _ = _thema(name) + kap = _kapitel(name) + gruppen = [kap[i:i + config.KONSISTENZ_GRUPPE] for i in range(0, len(kap), config.KONSISTENZ_GRUPPE)] + s = state.stufe(name, "konsistenz") + aufgaben = [] + for gi, g in enumerate(gruppen, 1): + if state.bericht_lesen(name, f"konsistenz-{gi}"): + continue + dateien = ", ".join(f"`buch/{k['datei']}` (Kapitel {k['n']})" for k in g) + aufgaben.append((f"konsistenz-{gi}", f"Querverweise Kapitel {g[0]['n']}–{g[-1]['n']}", + prompts.laden("konsistenz", thema=thema, dateien=dateien, gruppe=str(gi)))) + if not state.bericht_lesen(name, "vorwort"): + aufgaben.append(("vorwort", "Vorwort schreiben", prompts.laden("vorwort", thema=thema))) + s.update(gesamt=len(gruppen) + 1, fertig=len(gruppen) + 1 - len(aufgaben)) + await _parallel(name, "konsistenz", aufgaben) + + +async def st_montage(name: str) -> None: + s = state.stufe(name, "montage") + s.update(gesamt=1, fertig=0) + ok = await asyncio.to_thread(montage.bauen, name) + state.ereignis(name, "montage", "text" if ok else "fehler", "README, buch.md und PDF erzeugt" if ok else "README und buch.md erzeugt, PDF fehlgeschlagen") + s["fertig"] = 1 + + +STUFEN_FN = {"recherche": st_recherche, "gliederung": st_gliederung, "schreiben": st_schreiben, + "pruefung": st_pruefung, "konsistenz": st_konsistenz, "montage": st_montage} diff --git a/backend/prompts.py b/backend/prompts.py new file mode 100644 index 0000000..4e8a058 --- /dev/null +++ b/backend/prompts.py @@ -0,0 +1,13 @@ +"""Prompts liegen als Markdown in prompts/, Platzhalter {{name}}.""" +from . import config + + +def laden(name: str, **werte: str) -> str: + text = (config.PROMPTS / f"{name}.md").read_text() + for k, v in werte.items(): + text = text.replace("{{" + k + "}}", v) + return text + + +def hinweise_block(hinweise: str) -> str: + return f"Hinweise des Auftraggebers (verbindlich): {hinweise}\n" if hinweise else "" diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..208daf5 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,5 @@ +claude-agent-sdk +fastapi +uvicorn[standard] +markdown +pygments diff --git a/backend/state.py b/backend/state.py new file mode 100644 index 0000000..e79897a --- /dev/null +++ b/backend/state.py @@ -0,0 +1,169 @@ +"""Zustand je Thema: storage//{thema.json,state.json,log.jsonl}. Alles im Speicher, bei Änderung auf Platte.""" +import json +import re +import shutil +from datetime import datetime, timezone +from pathlib import Path + +from . import config + +_cache: dict[str, dict] = {} + + +def jetzt() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def name_ok(name: str) -> bool: + return bool(name) and name.strip() == name and "/" not in name and ".." not in name and len(name) < 120 + + +def verzeichnis(name: str) -> Path: + return config.STORAGE / name + + +def _lesen(pfad: Path, standard): + try: + return json.loads(pfad.read_text()) + except (OSError, ValueError): + return standard + + +def _schreiben(pfad: Path, daten) -> None: + pfad.parent.mkdir(parents=True, exist_ok=True) + tmp = pfad.with_suffix(pfad.suffix + ".tmp") + tmp.write_text(json.dumps(daten, ensure_ascii=False, indent=1)) + tmp.replace(pfad) + + +def leerer_zustand() -> dict: + return {"status": "neu", "grund": "", "stufe": "", "deutung": None, + "stufen": [{"id": s, "titel": t, "status": "offen", "fertig": 0, "gesamt": 0, + "kosten_usd": 0.0, "tokens": 0, "gestartet": "", "beendet": ""} for s, t in config.STUFEN], + "kosten_usd": 0.0, "tokens": 0, "agenten": [], "ereignisse": []} + + +def themen() -> list[dict]: + aus = [] + for d in sorted(config.STORAGE.glob("*/thema.json")): + t = _lesen(d, None) + if t: + aus.append(t) + return aus + + +def anlegen(name: str, hinweise: str) -> dict: + v = verzeichnis(name) + if v.exists(): + raise ValueError("Thema existiert schon") + for u in ("quellen", "buch", "berichte", "arbeit"): + (v / u).mkdir(parents=True) + t = {"name": name, "titel": name, "hinweise": hinweise.strip(), "angelegt": jetzt()} + _schreiben(v / "thema.json", t) + _schreiben(v / "state.json", leerer_zustand()) + return t + + +def loeschen(name: str) -> None: + _cache.pop(name, None) + shutil.rmtree(verzeichnis(name), ignore_errors=True) + + +def zuruecksetzen(name: str) -> None: + v = verzeichnis(name) + t = _lesen(v / "thema.json", None) + _cache.pop(name, None) + for u in ("quellen", "buch", "berichte", "arbeit"): + shutil.rmtree(v / u, ignore_errors=True) + (v / u).mkdir() + for f in ("deutung.json", "log.jsonl", "state.json"): + (v / f).unlink(missing_ok=True) + _schreiben(v / "state.json", leerer_zustand()) + if t: + _schreiben(v / "thema.json", t) + + +def thema(name: str) -> dict | None: + return _lesen(verzeichnis(name) / "thema.json", None) + + +def zustand(name: str) -> dict: + if name not in _cache: + st = _lesen(verzeichnis(name) / "state.json", None) or leerer_zustand() + # Ein Lauf, der beim Serverstart noch als „laeuft“ gespeichert ist, ist tot. + if st.get("status") == "laeuft": + st["status"] = "pausiert" + st["grund"] = "Server wurde beendet – mit „Fortsetzen“ weitermachen" + for s in st["stufen"]: + if s["status"] == "laeuft": + s["status"] = "offen" + for a in st["agenten"]: + if a["status"] == "laeuft": + a["status"] = "abgebrochen" + _cache[name] = st + return _cache[name] + + +def speichern(name: str) -> None: + if name in _cache: + _schreiben(verzeichnis(name) / "state.json", _cache[name]) + + +def deutung(name: str) -> dict | None: + return _lesen(verzeichnis(name) / "deutung.json", None) + + +def stufe(name: str, sid: str) -> dict: + return next(s for s in zustand(name)["stufen"] if s["id"] == sid) + + +def ereignis(name: str, agent: str, art: str, text: str) -> None: + st = zustand(name) + e = {"zeit": jetzt(), "agent": agent, "art": art, "text": text[:300]} + st["ereignisse"].append(e) + del st["ereignisse"][:-200] + try: + with open(verzeichnis(name) / "log.jsonl", "a") as f: + f.write(json.dumps(e, ensure_ascii=False) + "\n") + except OSError: + pass + + +def agent_eintrag(name: str, agent_id: str, stufe_id: str, titel: str) -> dict: + st = zustand(name) + st["agenten"] = [a for a in st["agenten"] if a["id"] != agent_id] + a = {"id": agent_id, "stufe": stufe_id, "name": titel, "status": "laeuft", "kosten_usd": 0.0, "tokens": 0, + "werkzeuge": 0, "gestartet": jetzt(), "beendet": "", "bericht": ""} + st["agenten"].append(a) + speichern(name) + return a + + +def agent_fertig(name: str, a: dict, status: str, bericht: str, kosten: float, tokens: int) -> None: + st = zustand(name) + a.update(status=status, bericht=bericht, kosten_usd=round(kosten, 4), tokens=tokens, beendet=jetzt()) + s = stufe(name, a["stufe"]) + s["kosten_usd"] = round(s["kosten_usd"] + kosten, 4) + s["tokens"] += tokens + st["kosten_usd"] = round(st["kosten_usd"] + kosten, 4) + st["tokens"] += tokens + speichern(name) + + +def bericht_schreiben(name: str, agent_id: str, text: str) -> None: + p = verzeichnis(name) / "berichte" / f"{agent_id}.md" + p.parent.mkdir(exist_ok=True) + p.write_text(text.strip() + "\n") + + +def bericht_lesen(name: str, agent_id: str) -> str: + p = verzeichnis(name) / "berichte" / f"{agent_id}.md" + return p.read_text() if p.exists() else "" + + +def slug(text: str) -> str: + t = text.lower() + for a, b in (("ä", "ae"), ("ö", "oe"), ("ü", "ue"), ("ß", "ss")): + t = t.replace(a, b) + t = re.sub(r"[^a-z0-9]+", "-", t).strip("-") + return t[:60] or "kapitel" diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..68275cd --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + buchwerk + + + +
    + + + diff --git a/frontend/mock/server.js b/frontend/mock/server.js new file mode 100644 index 0000000..ea31fdc --- /dev/null +++ b/frontend/mock/server.js @@ -0,0 +1,133 @@ +// Kleiner statischer Mock des Backends laut API.md für den Sichttest: node mock/server.js (Port 8020, PORT überschreibt) +import http from 'node:http' + +const PORT = Number(process.env.PORT || 8020) +const jetzt = Date.now() +const iso = (minVor) => new Date(jetzt - minVor * 60000).toISOString() + +const topics = [ + { name: 'Godot 4', titel: 'Godot 4', status: 'laeuft', laeuft: true, stufe: 'schreiben' }, + { name: 'Symfony 7', titel: 'Symfony 7', status: 'neu', laeuft: false, stufe: '' }, +] + +const bericht = `## Ergebnis + +Kapitel 3 geschrieben, 4 Abschnitte, 6 900 Wörter. + +- Quellen: \`quellen/godot-docs/tutorials/scripting/gdscript/\` +- Offen: Abschnitt 3.4 braucht ein Beispiel zu \`await\` + +| Abschnitt | Wörter | +|---|---| +| 3.1 | 1400 | +| 3.2 | 1900 | +` + +const state = { + name: 'Godot 4', titel: 'Godot 4', hinweise: 'für Entwickler, ab Version 4.6', + status: 'laeuft', grund: '', stufe: 'schreiben', + deutung: { titel: 'Godot 4', untertitel: 'Architektur, Konzepte und Praxis der Engine', buchtyp: 'werkzeug', + zielgruppe: 'Entwickler mit Programmiererfahrung, neu in Godot', referenz: 'Godot 4.6, offizielle Dokumentation', + deutung: 'Ein Werkzeugbuch: erklärt die Engine entlang ihrer Konzepte (Szenenbaum, Signale, GDScript) und zeigt jeweils die Praxis.' }, + stufen: [ + { id: 'recherche', titel: 'Thema deuten und Quellen beschaffen', status: 'fertig', fertig: 1, gesamt: 1, kosten_usd: 1.2, tokens: 250000, gestartet: iso(60), beendet: iso(52) }, + { id: 'gliederung', titel: 'Gliederung', status: 'fertig', fertig: 1, gesamt: 1, kosten_usd: 0.8, tokens: 120000, gestartet: iso(52), beendet: iso(45) }, + { id: 'schreiben', titel: 'Kapitel schreiben', status: 'laeuft', fertig: 3, gesamt: 18, kosten_usd: 4.1, tokens: 1100000, gestartet: iso(45), beendet: '' }, + { id: 'pruefung', titel: 'Kapitel prüfen', status: 'laeuft', fertig: 1, gesamt: 18, kosten_usd: 0.9, tokens: 300000, gestartet: iso(30), beendet: '' }, + { id: 'konsistenz', titel: 'Querverweise und Vorwort', status: 'offen', fertig: 0, gesamt: 1, kosten_usd: 0, tokens: 0, gestartet: '', beendet: '' }, + { id: 'montage', titel: 'Buch zusammensetzen', status: 'offen', fertig: 0, gesamt: 1, kosten_usd: 0, tokens: 0, gestartet: '', beendet: '' }, + ], + kosten_usd: 7.0, tokens: 1770000, + agenten: [ + { id: 'recherche', stufe: 'recherche', name: 'Recherche', status: 'fertig', kosten_usd: 1.2, tokens: 250000, werkzeuge: 58, gestartet: iso(60), beendet: iso(52), bericht: '## Quellen\n\n12 Quellen geladen, davon 9 brauchbar.' }, + { id: 'gliederung', stufe: 'gliederung', name: 'Gliederung', status: 'fertig', kosten_usd: 0.8, tokens: 120000, werkzeuge: 14, gestartet: iso(52), beendet: iso(45), bericht: 'Gliederung mit 18 Kapiteln in 4 Teilen.' }, + { id: 'schreiben-01', stufe: 'schreiben', name: 'Kapitel 1 schreiben', status: 'fertig', kosten_usd: 0.9, tokens: 180000, werkzeuge: 41, gestartet: iso(45), beendet: iso(38), bericht }, + { id: 'schreiben-02', stufe: 'schreiben', name: 'Kapitel 2 schreiben', status: 'fehler', kosten_usd: 0.3, tokens: 60000, werkzeuge: 9, gestartet: iso(45), beendet: iso(42), bericht: '' }, + { id: 'schreiben-03', stufe: 'schreiben', name: 'Kapitel 3 schreiben', status: 'fertig', kosten_usd: 1.1, tokens: 220000, werkzeuge: 52, gestartet: iso(38), beendet: iso(30), bericht }, + { id: 'schreiben-04', stufe: 'schreiben', name: 'Kapitel 4 schreiben', status: 'laeuft', kosten_usd: 0.4, tokens: 90000, werkzeuge: 17, gestartet: iso(8), beendet: '', bericht: '' }, + { id: 'pruefung-01', stufe: 'pruefung', name: 'Kapitel 1 prüfen', status: 'fertig', kosten_usd: 0.5, tokens: 150000, werkzeuge: 22, gestartet: iso(30), beendet: iso(20), bericht: '## Prüfung\n\nAlle Aussagen belegt. Zwei Formulierungen korrigiert.' }, + { id: 'pruefung-03', stufe: 'pruefung', name: 'Kapitel 3 prüfen', status: 'laeuft', kosten_usd: 0.4, tokens: 150000, werkzeuge: 30, gestartet: iso(12), beendet: '', bericht: '' }, + ], + ereignisse: Array.from({ length: 100 }, (_, i) => ({ + zeit: iso(100 - i), agent: i % 3 ? 'schreiben-04' : 'pruefung-03', + art: ['tool', 'text', 'start', 'ende', 'fehler'][i % 5], + text: i % 5 === 0 ? 'Write buch/04-szenen.md' : i % 5 === 1 ? 'Ich lese jetzt die Dokumentation zum Szenenbaum und gleiche die Signale ab.' : `Ereignis ${i}`, + })), +} + +const text = (n) => Array.from({ length: n }, (_, i) => `Absatz ${i + 1}: Der Szenenbaum ist die zentrale Datenstruktur von Godot. Jeder Knoten hat genau einen Elternknoten, und Signale verbinden Knoten lose miteinander.`).join('\n\n') +const code = '\n\n```gdscript\nextends Node2D\n\nsignal getroffen(schaden: int)\n\nfunc _ready() -> void:\n\tgetroffen.connect(_on_getroffen)\n\nfunc _on_getroffen(schaden: int) -> void:\n\tprint("Schaden: %d" % schaden)\n```\n\n| Knoten | Zweck |\n|---|---|\n| Node2D | 2D-Transform |\n| Control | UI |\n\n' +const kap = (ord, titel, teil, status, n) => ({ + id: String(ord).padStart(2, '0'), ord, titel, teil, datei: `buch/${String(ord).padStart(2, '0')}.md`, status, + intro: status === 'fehlt' ? '' : `Worum es in Kapitel ${ord} geht: ${titel}. Dieses Kapitel führt in die Grundlagen ein.`, + abschnitte: status === 'fehlt' ? [] : Array.from({ length: n }, (_, i) => ({ + id: `${String(ord).padStart(2, '0')}-${i + 1}`, nr: `${ord}.${i + 1}`, titel: `Abschnitt ${i + 1} von ${titel}`, + text: text(4) + (i === 0 ? code : ''), woerter: 1400, minuten: 8 })), + woerter: status === 'fehlt' ? 0 : n * 1400, minuten: status === 'fehlt' ? 0 : n * 8, + berichte: status === 'fehlt' ? { autor: '', pruefer: '' } : { autor: bericht, pruefer: status === 'geschrieben' ? '' : '## Prüfung\n\n- 3.1: belegt\n- 3.2: eine Zahl korrigiert' }, +}) +const guide = { + titel: 'Godot 4', untertitel: 'Architektur, Konzepte und Praxis der Engine', status: 'laeuft', + vorwort: 'Dieses Buch erklärt Godot 4 entlang seiner Konzepte.\n\nEs richtet sich an Entwickler, die die Engine **verstehen** wollen, nicht nur bedienen.', + kapitel: [ + kap(1, 'Die Engine im Überblick', 'Teil I – Grundlagen', 'konsistent', 3), + kap(2, 'Szenen und Knoten', 'Teil I – Grundlagen', 'fehlt', 0), + kap(3, 'GDScript', 'Teil I – Grundlagen', 'geprueft', 4), + kap(4, 'Signale', 'Teil II – Praxis', 'geschrieben', 2), + ], + woerter: 12600, minuten: 72, + gliederung: '# Gliederung\n\n## Teil I – Grundlagen\n\n1. Die Engine im Überblick\n2. Szenen und Knoten\n3. GDScript\n\n## Teil II – Praxis\n\n4. Signale', + quellen: '# Quellen\n\n- Godot-Dokumentation 4.6 (docs.godotengine.org)\n- Engine-Quelltext, Tag 4.6-stable\n\n## Prüfmethode\n\nJedes Kapitel wird von einem unabhängigen Agenten gegen die Quellen geprüft.', + pdf: false, +} + +function json(res, code, body) { + res.writeHead(code, { 'content-type': 'application/json' }) + res.end(JSON.stringify(body)) +} +http.createServer((req, res) => { + const url = new URL(req.url, 'http://x') + const p = url.pathname + let m + if (req.method === 'GET' && p === '/api/topics') return json(res, 200, topics) + if (req.method === 'POST' && p === '/api/topics') { + let body = '' + req.on('data', (c) => body += c) + return req.on('end', () => { + const { name } = JSON.parse(body || '{}') + const t = { name, titel: name, status: 'neu', laeuft: false, stufe: '' } + topics.push(t); json(res, 200, t) + }) + } + if ((m = p.match(/^\/api\/topics\/([^/]+)$/)) && req.method === 'DELETE') { + const i = topics.findIndex((t) => t.name === decodeURIComponent(m[1])) + if (i >= 0) topics.splice(i, 1) + return json(res, 200, { ok: true }) + } + if ((m = p.match(/^\/api\/topics\/([^/]+)\/(start|pause|reset)$/)) && req.method === 'POST') { + const t = topics.find((t) => t.name === decodeURIComponent(m[1])) + if (!t) return json(res, 404, { detail: 'unbekanntes Thema' }) + t.status = m[2] === 'start' ? 'laeuft' : m[2] === 'pause' ? 'pausiert' : 'neu'; t.laeuft = t.status === 'laeuft' + if (t.name === state.name) state.status = t.status + return json(res, 200, { ok: true }) + } + if ((m = p.match(/^\/api\/topics\/([^/]+)\/state$/))) { + const name = decodeURIComponent(m[1]) + const t = topics.find((t) => t.name === name) + if (!t) return json(res, 404, { detail: 'unbekanntes Thema' }) + if (name === state.name) return json(res, 200, state) + return json(res, 200, { name, titel: t.titel, hinweise: '', status: t.status, grund: '', stufe: '', deutung: null, + stufen: state.stufen.map((s) => ({ ...s, status: 'offen', fertig: 0, kosten_usd: 0, tokens: 0, gestartet: '', beendet: '' })), + kosten_usd: 0, tokens: 0, agenten: [], ereignisse: [] }) + } + if ((m = p.match(/^\/api\/topics\/([^/]+)\/guide$/))) { + const name = decodeURIComponent(m[1]) + if (name === state.name) return json(res, 200, guide) + return json(res, 200, { titel: name, untertitel: '', status: 'neu', vorwort: '', kapitel: [], woerter: 0, minuten: 0, gliederung: '', quellen: '', pdf: false }) + } + if ((m = p.match(/^\/api\/topics\/([^/]+)\/guide\.md$/))) { + res.writeHead(200, { 'content-type': 'text/markdown; charset=utf-8' }); return res.end('# ' + guide.titel + '\n') + } + if ((m = p.match(/^\/api\/topics\/([^/]+)\/buch\.pdf$/))) return json(res, 404, { detail: 'PDF noch nicht gebaut' }) + json(res, 404, { detail: 'nicht gefunden: ' + p }) +}).listen(PORT, () => console.log(`Mock-Backend auf http://localhost:${PORT}`)) diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..9a84675 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1461 @@ +{ + "name": "primer-frontend", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "primer-frontend", + "dependencies": { + "dompurify": "^3.2.0", + "highlight.js": "^11.11.0", + "markdown-it": "^14.1.0", + "vue": "^3.5.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^6.0.0", + "vite": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.4.tgz", + "integrity": "sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.4.tgz", + "integrity": "sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.4.tgz", + "integrity": "sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.4.tgz", + "integrity": "sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.4.tgz", + "integrity": "sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.4.tgz", + "integrity": "sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.4.tgz", + "integrity": "sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.4.tgz", + "integrity": "sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.4.tgz", + "integrity": "sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.4.tgz", + "integrity": "sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.4.tgz", + "integrity": "sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.4.tgz", + "integrity": "sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.4.tgz", + "integrity": "sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.4.tgz", + "integrity": "sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.4.tgz", + "integrity": "sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.4.tgz", + "integrity": "sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.4.tgz", + "integrity": "sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.4.tgz", + "integrity": "sha512-791ET7W17NnScOZM7h4dX5hYspxE28htPFsb1awY/NRR8+PRNkS53e475rDdxXXDrP+kwnCcNWg9CX5ztn/Aqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.4.tgz", + "integrity": "sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.4.tgz", + "integrity": "sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.4.tgz", + "integrity": "sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.4.tgz", + "integrity": "sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.4.tgz", + "integrity": "sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.4.tgz", + "integrity": "sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.4.tgz", + "integrity": "sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.9", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.9.tgz", + "integrity": "sha512-rD/MORlhaZMlXWW0rEn4FB4wMinWC0z/D7Ye160S5+Rs1mC8ZDAdcDp4SjKfUhG5t8q1ktcPVw4xuTkPRlzrSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.43.tgz", + "integrity": "sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.43", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-core/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.43.tgz", + "integrity": "sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.43.tgz", + "integrity": "sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.43", + "@vue/compiler-dom": "3.5.43", + "@vue/compiler-ssr": "3.5.43", + "@vue/shared": "3.5.43", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.28", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.43.tgz", + "integrity": "sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.43.tgz", + "integrity": "sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.43.tgz", + "integrity": "sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.43.tgz", + "integrity": "sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.43", + "@vue/runtime-core": "3.5.43", + "@vue/shared": "3.5.43", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.43.tgz", + "integrity": "sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.43", + "@vue/runtime-dom": "3.5.43", + "@vue/shared": "3.5.43" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.43.tgz", + "integrity": "sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dompurify": { + "version": "3.4.16", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz", + "integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/highlight.js": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.12.0.tgz", + "integrity": "sha512-nbfWpyRMcMrPMmDwJB+dhX/eiaPKtc2RB+0QZskqJ3WjRA/FDS0e9hZrx8EC/lbEv8gXy98FcDbNa/dspAaJMg==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/linkify-it": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/markdown-it": { + "version": "14.3.2", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.2.tgz", + "integrity": "sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.5.0", + "linkify-it": "^5.0.2", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/mdurl": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz", + "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/rollup": { + "version": "4.63.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.4.tgz", + "integrity": "sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.4", + "@rollup/rollup-android-arm64": "4.63.4", + "@rollup/rollup-darwin-arm64": "4.63.4", + "@rollup/rollup-darwin-x64": "4.63.4", + "@rollup/rollup-freebsd-arm64": "4.63.4", + "@rollup/rollup-freebsd-x64": "4.63.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.4", + "@rollup/rollup-linux-arm-musleabihf": "4.63.4", + "@rollup/rollup-linux-arm64-gnu": "4.63.4", + "@rollup/rollup-linux-arm64-musl": "4.63.4", + "@rollup/rollup-linux-loong64-gnu": "4.63.4", + "@rollup/rollup-linux-loong64-musl": "4.63.4", + "@rollup/rollup-linux-ppc64-gnu": "4.63.4", + "@rollup/rollup-linux-ppc64-musl": "4.63.4", + "@rollup/rollup-linux-riscv64-gnu": "4.63.4", + "@rollup/rollup-linux-riscv64-musl": "4.63.4", + "@rollup/rollup-linux-s390x-gnu": "4.63.4", + "@rollup/rollup-linux-x64-gnu": "4.63.4", + "@rollup/rollup-linux-x64-musl": "4.63.4", + "@rollup/rollup-openbsd-x64": "4.63.4", + "@rollup/rollup-openharmony-arm64": "4.63.4", + "@rollup/rollup-win32-arm64-msvc": "4.63.4", + "@rollup/rollup-win32-ia32-msvc": "4.63.4", + "@rollup/rollup-win32-x64-gnu": "4.63.4", + "@rollup/rollup-win32-x64-msvc": "4.63.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.43", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.43.tgz", + "integrity": "sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.43", + "@vue/compiler-sfc": "3.5.43", + "@vue/runtime-dom": "3.5.43", + "@vue/server-renderer": "3.5.43", + "@vue/shared": "3.5.43" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..856ed1a --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,19 @@ +{ + "name": "buchwerk-frontend", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port 5190", + "build": "vite build" + }, + "dependencies": { + "dompurify": "^3.2.0", + "highlight.js": "^11.11.0", + "markdown-it": "^14.1.0", + "vue": "^3.5.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^6.0.0", + "vite": "^7.0.0" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..b2a5495 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,153 @@ + + + diff --git a/frontend/src/api.js b/frontend/src/api.js new file mode 100644 index 0000000..d124df6 --- /dev/null +++ b/frontend/src/api.js @@ -0,0 +1,28 @@ +async function req(pfad, opts = {}) { + const r = await fetch('/api' + pfad, { + headers: { 'content-type': 'application/json' }, + ...opts, + body: opts.body ? JSON.stringify(opts.body) : undefined, + }) + if (!r.ok) { + let detail = r.statusText + try { detail = (await r.json()).detail || detail } catch { /* leer */ } + throw new Error(detail) + } + const ct = r.headers.get('content-type') || '' + return ct.includes('json') ? r.json() : r.text() +} + +const e = encodeURIComponent +export const api = { + topics: () => req('/topics'), + anlegen: (name, hinweise = '') => req('/topics', { method: 'POST', body: { name, hinweise } }), + loeschen: (t) => req(`/topics/${e(t)}`, { method: 'DELETE' }), + start: (t) => req(`/topics/${e(t)}/start`, { method: 'POST' }), + pause: (t) => req(`/topics/${e(t)}/pause`, { method: 'POST' }), + reset: (t) => req(`/topics/${e(t)}/reset`, { method: 'POST' }), + state: (t) => req(`/topics/${e(t)}/state`), + guide: (t) => req(`/topics/${e(t)}/guide`), + guideMdUrl: (t) => `/api/topics/${e(t)}/guide.md`, + pdfUrl: (t) => `/api/topics/${e(t)}/buch.pdf`, +} diff --git a/frontend/src/components/Guide.vue b/frontend/src/components/Guide.vue new file mode 100644 index 0000000..ea816bb --- /dev/null +++ b/frontend/src/components/Guide.vue @@ -0,0 +1,174 @@ + + + diff --git a/frontend/src/components/Lauf.vue b/frontend/src/components/Lauf.vue new file mode 100644 index 0000000..ce7fc58 --- /dev/null +++ b/frontend/src/components/Lauf.vue @@ -0,0 +1,105 @@ + + + diff --git a/frontend/src/components/Lesemodus.vue b/frontend/src/components/Lesemodus.vue new file mode 100644 index 0000000..e1f5331 --- /dev/null +++ b/frontend/src/components/Lesemodus.vue @@ -0,0 +1,107 @@ + + + diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..fe5bae3 --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import App from './App.vue' +import './style.css' + +createApp(App).mount('#app') diff --git a/frontend/src/markdown.js b/frontend/src/markdown.js new file mode 100644 index 0000000..1b3e342 --- /dev/null +++ b/frontend/src/markdown.js @@ -0,0 +1,48 @@ +import MarkdownIt from 'markdown-it' +import DOMPurify from 'dompurify' +import hljs from 'highlight.js/lib/core' +import python from 'highlight.js/lib/languages/python' +import php from 'highlight.js/lib/languages/php' +import javascript from 'highlight.js/lib/languages/javascript' +import typescript from 'highlight.js/lib/languages/typescript' +import json from 'highlight.js/lib/languages/json' +import yaml from 'highlight.js/lib/languages/yaml' +import bash from 'highlight.js/lib/languages/bash' +import xml from 'highlight.js/lib/languages/xml' +import css from 'highlight.js/lib/languages/css' +import csharp from 'highlight.js/lib/languages/csharp' +import cpp from 'highlight.js/lib/languages/cpp' +import c from 'highlight.js/lib/languages/c' +import glsl from 'highlight.js/lib/languages/glsl' +import ini from 'highlight.js/lib/languages/ini' +import sql from 'highlight.js/lib/languages/sql' +import rust from 'highlight.js/lib/languages/rust' +import go from 'highlight.js/lib/languages/go' +import java from 'highlight.js/lib/languages/java' +import twig from 'highlight.js/lib/languages/twig' +import diff from 'highlight.js/lib/languages/diff' +for (const [n, l] of Object.entries({ python, php, javascript, typescript, json, yaml, bash, xml, css, csharp, cpp, c, glsl, ini, sql, rust, go, java, twig, diff })) hljs.registerLanguage(n, l) +hljs.registerAliases(['js'], { languageName: 'javascript' }); hljs.registerAliases(['ts'], { languageName: 'typescript' }) +hljs.registerAliases(['sh', 'shell', 'console', 'zsh'], { languageName: 'bash' }); hljs.registerAliases(['html', 'svg', 'tscn', 'tres'], { languageName: 'xml' }) +hljs.registerAliases(['yml'], { languageName: 'yaml' }); hljs.registerAliases(['toml', 'cfg', 'godot'], { languageName: 'ini' }); hljs.registerAliases(['cs'], { languageName: 'csharp' }) +hljs.registerAliases(['gdscript', 'gd'], { languageName: 'python' }) // nah genug für lesbare Farben + +const md = new MarkdownIt({ + html: true, + linkify: false, + typographer: false, + highlight(code, lang) { + const l = lang && hljs.getLanguage(lang) ? lang : null + const out = l ? hljs.highlight(code, { language: l }).value : md.utils.escapeHtml(code) + return `
    ${out}
    ` + }, +}) + +export function render(text) { + return DOMPurify.sanitize(md.render(text || '')) +} + +export function zeit(min) { + const m = Math.max(1, Math.round(min)) + return m < 60 ? `~${m} min` : `~${Math.floor(m / 60)} h ${m % 60} min` +} diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..80f26d1 --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,227 @@ +/* Themes über Custom Properties: dunkel (Standard), hell, papier. */ +:root { + --bg: #12161c; --panel: #181e27; --karte: #202834; --rand: #2d3746; + --text: #dbe2ec; --dim: #8d99aa; --akzent: #4f9cf7; --gruen: #43c17f; --gelb: #e6c04a; --rot: #e36b6b; + --lese-bg: #14181f; --lese-text: #c9d1dc; --lese-dim: #98a3b3; --lese-rand: #2a3340; + --code-bg: #0e1218; + --code-fg: #d8dee9; --c-kw: #c792ea; --c-str: #a5d6a7; --c-com: #7b8794; --c-num: #f6c177; --c-fn: #82aaff; --c-type: #ffcb6b; +} +:root[data-theme="light"] { + --bg: #f4f6f9; --panel: #ffffff; --karte: #eef1f5; --rand: #d5dbe3; + --text: #1d2531; --dim: #5f6b7c; --akzent: #1b6fd6; --gruen: #1f8a57; --gelb: #9a7a12; --rot: #c43d3d; + --lese-bg: #ffffff; --lese-text: #2b3440; --lese-dim: #5c6674; --lese-rand: #dfe4ea; + --code-bg: #f1f3f6; + --code-fg: #1f2933; --c-kw: #7c3aed; --c-str: #1a7f37; --c-com: #6b7280; --c-num: #b45309; --c-fn: #1d4ed8; --c-type: #0e7490; +} +:root[data-theme="sepia"] { + --bg: #efe6d2; --panel: #f8f1de; --karte: #eee3c8; --rand: #d8caa6; + --text: #43392a; --dim: #82745a; --akzent: #7a5b2c; --gruen: #4f7a42; --gelb: #8f6b14; --rot: #a6442e; + --lese-bg: #f6efdd; --lese-text: #3f3527; --lese-dim: #6f6249; --lese-rand: #d9cba8; + --code-bg: #ece2c7; + --code-fg: #3b3224; --c-kw: #7a3e9d; --c-str: #2f6b2f; --c-com: #8a7c62; --c-num: #9a5b12; --c-fn: #2b5aa6; --c-type: #1c6f74; +} +* { box-sizing: border-box; } +html, body, #app { margin: 0; height: 100%; } +body { background: var(--bg); color: var(--text); font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; } +button, a.knopf { background: var(--akzent); color: #fff; border: 0; border-radius: 6px; padding: 6px 14px; cursor: pointer; font: inherit; } +button.sekundaer, a.knopf.sekundaer { background: var(--karte); color: var(--text); border: 1px solid var(--rand); } +button.rot { background: var(--rot); } +button.klein, a.knopf.klein { padding: 3px 9px; font-size: 12px; } +button:disabled { opacity: 0.45; cursor: default; } +input, select { background: var(--karte); color: var(--text); border: 1px solid var(--rand); border-radius: 6px; padding: 6px 10px; font: inherit; } +a { color: var(--akzent); } +a.knopf { display: inline-block; text-decoration: none; line-height: normal; } + +.app { height: 100%; display: flex; } +.sidebar { width: 230px; min-width: 230px; background: var(--panel); border-right: 1px solid var(--rand); display: flex; flex-direction: column; } +.sidebar .topics { flex: 1; overflow-y: auto; padding: 8px; } +.topic-eintrag { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 6px; cursor: pointer; color: var(--dim); } +.topic-eintrag.aktiv { background: var(--karte); color: var(--text); } +.topic-eintrag .punkt { width: 8px; height: 8px; border-radius: 50%; background: var(--dim); flex: none; } +.topic-eintrag .punkt.fertig { background: var(--gruen); } +.topic-eintrag .punkt.laeuft { background: var(--akzent); animation: puls 1.2s infinite; } +.topic-eintrag .punkt.pausiert { background: var(--gelb); } +.topic-eintrag .punkt.fehler { background: var(--rot); } +@keyframes puls { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } } +.sidebar .neu { padding: 10px; border-top: 1px solid var(--rand); display: grid; gap: 6px; } +/* Eingeklappte Themenliste (Tab „Buch“ und schmale Bildschirme): ☰ öffnet sie als Overlay */ +.schliessen { display: none; } +.sidebar.offen { display: flex; position: fixed; inset: 0 auto 0 0; z-index: 25; box-shadow: 0 0 30px rgba(0,0,0,0.4); } +.sidebar.offen .schliessen { display: inline-block; margin: 10px 10px 4px; align-self: flex-start; } +.burger { display: none; } +.app.tab-buch .burger { display: inline-block; } +.app.tab-buch .sidebar:not(.offen) { display: none; } +.backdrop { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.35); z-index: 24; } + +.haupt { flex: 1; display: flex; flex-direction: column; min-width: 0; } +.topbar { display: flex; gap: 10px; align-items: center; padding: 8px 16px; background: var(--panel); border-bottom: 1px solid var(--rand); flex-wrap: wrap; } +.topbar .titel { font-weight: 600; margin-right: 6px; } +.tabs { display: flex; gap: 4px; } +.tabs button { background: transparent; color: var(--dim); } +.tabs button.aktiv { background: var(--karte); color: var(--text); } +.spacer { flex: 1; } +.badge { display: inline-block; padding: 2px 9px; border-radius: 10px; font-size: 11.5px; background: var(--karte); color: var(--dim); } +.badge.gruen { color: var(--gruen); } .badge.gelb { color: var(--gelb); } .badge.rot { color: var(--rot); } .badge.blau { color: var(--akzent); } +.inhalt { flex: 1; overflow: hidden; display: flex; flex-direction: column; min-height: 0; } +.fehler { margin: 10px 16px; padding: 8px 12px; border: 1px solid var(--rot); border-radius: 6px; color: var(--rot); cursor: pointer; } + +/* Lauf-Ansicht */ +.lauf { padding: 18px 22px; overflow-y: auto; } +.lauf .aktionen { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 14px; } +.lauf .grund { margin: 8px 0 14px; padding: 8px 12px; background: var(--karte); border-left: 3px solid var(--gelb); border-radius: 4px; } +.deutung { max-width: 760px; margin: 0 0 16px; padding: 12px 14px; background: var(--panel); border: 1px solid var(--rand); border-radius: 8px; } +.deutung .titel { font-weight: 600; font-size: 15px; } +.deutung .sub { color: var(--dim); font-size: 12.5px; } +.deutung p { margin: 8px 0; } +.deutung .fakten { display: flex; gap: 14px; flex-wrap: wrap; font-size: 12.5px; color: var(--dim); } +.deutung .fakten b { font-weight: 600; color: var(--text); margin-right: 4px; } +.stufen { display: grid; gap: 6px; max-width: 760px; } +.stufe { display: grid; grid-template-columns: 26px 1fr auto auto; gap: 10px; align-items: center; padding: 9px 12px; background: var(--panel); border: 1px solid var(--rand); border-radius: 8px; } +.stufe .kreis { width: 18px; height: 18px; border-radius: 50%; border: 2px solid var(--rand); display: grid; place-items: center; font-size: 11px; } +.stufe.fertig .kreis { background: var(--gruen); border-color: var(--gruen); color: #fff; } +.stufe.laeuft .kreis { border-color: var(--akzent); border-top-color: transparent; animation: dreh 0.9s linear infinite; } +.stufe.pausiert .kreis { border-color: var(--gelb); } .stufe.fehler .kreis { background: var(--rot); border-color: var(--rot); } +@keyframes dreh { to { transform: rotate(360deg); } } +.stufe .name { font-weight: 600; } +.stufe .sub { color: var(--dim); font-size: 12px; } +.stufe .balken { height: 4px; background: var(--karte); border-radius: 2px; margin-top: 5px; overflow: hidden; } +.stufe .balken i { display: block; height: 100%; background: var(--akzent); } +.stufe.fertig .balken i { background: var(--gruen); } +/* Agenten: Zeile aufklappen zeigt den Abschlussbericht */ +.agenten { max-width: 760px; margin-top: 18px; } +.agenten h3, .ereignisse summary { margin: 18px 0 8px; font-size: 13px; font-weight: 600; color: var(--dim); text-transform: uppercase; letter-spacing: 0.05em; } +.agent { background: var(--panel); border: 1px solid var(--rand); border-radius: 8px; margin-bottom: 6px; } +.agent.laeuft { border-color: var(--akzent); } +.agent.fehler { border-color: var(--rot); } +.agent summary { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; padding: 8px 12px; cursor: pointer; list-style: none; } +.agent summary::-webkit-details-marker { display: none; } +.agent summary .name { font-weight: 600; flex: 1; min-width: 160px; } +.agent summary .sub { color: var(--dim); font-size: 12px; white-space: nowrap; } +.agent .bericht { padding: 6px 12px 12px; border-top: 1px solid var(--rand); } +.agent .bericht.sub { color: var(--dim); font-size: 12.5px; } +.ereignisse { max-width: 760px; margin-top: 18px; } +.ereignisse summary { cursor: pointer; } +.ereignis { display: grid; grid-template-columns: 64px 130px 1fr; gap: 10px; padding: 2px 0; font: 12px/1.5 "SF Mono", Consolas, Menlo, monospace; border-bottom: 1px solid var(--rand); } +.ereignis .zeit, .ereignis .wer { color: var(--dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.ereignis .text { overflow-wrap: anywhere; } +.ereignis.fehler .text { color: var(--rot); } +.ereignis.start .text, .ereignis.ende .text { color: var(--akzent); } + +/* Gerendertes Markdown außerhalb des Buchtexts (Berichte, Prüfung, Quellen, Gliederung) */ +.md { font: 13px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif; } +.md h1, .md h2, .md h3, .md h4 { font-size: 13.5px; margin: 12px 0 4px; line-height: 1.4; } +.md p, .md ul, .md ol { margin: 0 0 0.7em; } +.md ul, .md ol { padding-left: 1.4em; } +.md li { margin: 0.15em 0; } +.md code { font-family: "SF Mono", Consolas, Menlo, monospace; font-size: 0.9em; background: var(--code-bg); padding: 1px 5px; border-radius: 4px; overflow-wrap: anywhere; } +.md pre { background: var(--code-bg); color: var(--code-fg); padding: 10px 12px; border-radius: 6px; overflow-x: auto; font-size: 0.9em; line-height: 1.5; } +.md pre code { background: none; padding: 0; } +.md table { border-collapse: collapse; margin: 0 0 0.8em; display: block; max-width: 100%; overflow-x: auto; } +.md th, .md td { border: 1px solid var(--rand); padding: 3px 8px; text-align: left; } + +/* Buch-Ansicht */ +.guide { flex: 1; display: flex; min-height: 0; } +.guide-nav { width: 270px; min-width: 240px; overflow-y: auto; padding: 14px 10px; border-right: 1px solid var(--rand); background: var(--panel); font-size: 13px; } +.guide-nav .werkzeuge { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; } +.guide-nav .meta { color: var(--dim); font-size: 12px; margin-bottom: 10px; } +.guide-nav a.kap { display: flex; align-items: baseline; gap: 6px; padding: 6px 8px; border-radius: 6px; cursor: pointer; color: var(--text); text-decoration: none; font-weight: 600; margin-top: 6px; } +.guide-nav a.kap .pfeil { color: var(--dim); font-size: 10px; width: 10px; flex: none; } +.guide-nav a.kap .t { flex: 1; } +.guide-nav a.abs { display: flex; justify-content: space-between; gap: 6px; padding: 3px 8px 3px 24px; border-radius: 6px; cursor: pointer; color: var(--dim); text-decoration: none; } +.guide-nav a.aktiv { background: var(--karte); color: var(--text); } +.guide-nav .zeit { color: var(--dim); font-size: 11px; white-space: nowrap; } +.guide-nav .fortschritt { height: 4px; background: var(--karte); border-radius: 2px; margin: 8px 0 4px; overflow: hidden; } +.guide-nav .fortschritt i { display: block; height: 100%; background: var(--gruen); } +.guide-nav .leer { color: var(--dim); padding: 8px; } +.guide-nav .teil { margin: 14px 0 2px; font: 600 11px system-ui, sans-serif; letter-spacing: 0.08em; text-transform: uppercase; color: var(--dim); } + +.lese { flex: 1; overflow-y: auto; background: var(--lese-bg); color: var(--lese-text); } +.buch { max-width: 68ch; margin: 0 auto; padding: 36px 28px 120px; font-family: "Charter", "Bitstream Charter", "Iowan Old Style", "Palatino Linotype", Georgia, serif; font-size: 1.125rem; line-height: 1.65; hyphens: auto; hyphenate-limit-chars: 8 4 4; } +.buch h1 { font-size: 2rem; line-height: 1.2; margin: 0 0 10px; } +.buch .untertitel { color: var(--lese-text); font-style: italic; font-size: 1.1rem; margin-bottom: 6px; } +.buch .meta { color: var(--lese-dim); margin-bottom: 26px; font-size: 0.95rem; } +.buch .vorwort { margin-top: 8px; } +.buch .vorwort h2 { font-size: 1.3rem; margin-bottom: 14px; } +.buch .kapitel { margin-top: 56px; padding-top: 18px; border-top: 1px solid var(--lese-rand); } +.buch .kapitel:first-of-type { border-top: 0; margin-top: 8px; } +.buch h2 { font-size: 1.6rem; line-height: 1.25; margin: 0 0 6px; } +.buch h2 .badge { font: 11.5px system-ui, sans-serif; vertical-align: middle; margin-left: 10px; } +.buch .kap-meta { color: var(--lese-dim); font-size: 0.85rem; margin-bottom: 14px; } +.buch .kap-intro { color: var(--lese-text); margin-bottom: 24px; } +.buch h3 { font-size: 1.22rem; margin: 34px 0 4px; line-height: 1.3; scroll-margin-top: 16px; } +.buch p { margin: 0 0 1em; } +.buch ul, .buch ol { padding-left: 1.4em; margin: 0 0 1em; } +.buch li { margin: 0.25em 0; } +.buch code { font-family: "SF Mono", Consolas, Menlo, monospace; font-size: 0.86em; background: var(--code-bg); padding: 1px 5px; border-radius: 4px; overflow-wrap: anywhere; } +.buch p, .buch li { overflow-wrap: anywhere; } +.buch pre { background: var(--code-bg); color: var(--code-fg); padding: 12px 14px; border-radius: 8px; overflow-x: auto; font-size: 0.86em; line-height: 1.5; } +.buch pre code { background: none; padding: 0; } +.buch table { border-collapse: collapse; margin: 0 0 1em; font-size: 0.92em; display: block; max-width: 100%; overflow-x: auto; } +.buch th, .buch td { border: 1px solid var(--lese-rand); padding: 4px 9px; text-align: left; } +.buch .hinweis { background: var(--karte); border-radius: 6px; padding: 8px 12px; font-size: 0.85rem; color: var(--dim); font-family: system-ui, sans-serif; margin: 0 0 14px; } +.buch .teil-titel { font: 600 0.78rem system-ui, sans-serif; letter-spacing: 0.1em; text-transform: uppercase; color: var(--lese-dim); margin: 40px 0 6px; } +/* Prüfung je Kapitel (Autoren- und Prüfbericht), eingeklappt */ +.buch .pruefung { margin-top: 28px; border: 1px solid var(--lese-rand); border-radius: 8px; font-family: system-ui, sans-serif; } +.buch .pruefung summary { padding: 8px 12px; cursor: pointer; color: var(--lese-dim); font-size: 0.85rem; font-weight: 600; } +.buch .pruefung h4 { margin: 10px 12px 4px; font-size: 0.78rem; color: var(--lese-dim); text-transform: uppercase; letter-spacing: 0.06em; } +.buch .pruefung .md { padding: 0 12px 10px; } +.buch .ende { margin-top: 60px; border-top: 1px solid var(--lese-rand); padding-top: 16px; font-family: system-ui, sans-serif; } +.buch .ende details { margin-bottom: 8px; } +.buch .ende summary { cursor: pointer; font-weight: 600; font-size: 0.9rem; padding: 6px 0; } +.buch .ende .md { padding: 4px 0 8px; } +.mobil-leiste { display: none; } + +/* Lesemodus (Overlay, Bildschirm für Bildschirm) */ +.lesemodus { position: fixed; inset: 0; z-index: 40; background: var(--lese-bg); color: var(--lese-text); display: flex; flex-direction: column; } +.lm-kopf { display: flex; align-items: center; gap: 12px; padding: 10px 18px; border-bottom: 1px solid var(--lese-rand); font: 12.5px system-ui, sans-serif; color: var(--lese-dim); } +.lm-kopf .balken { flex: 1; height: 4px; background: var(--karte); border-radius: 2px; overflow: hidden; } +.lm-kopf .balken i { display: block; height: 100%; background: var(--gruen); } +.lm-kopf button { background: transparent; color: var(--lese-dim); font-size: 18px; padding: 2px 8px; } +.lm-buehne { flex: 1; overflow-y: auto; } +.lm-screen { max-width: 66ch; margin: 0 auto; padding: 40px 28px 80px; font-family: "Charter", "Bitstream Charter", "Iowan Old Style", "Palatino Linotype", Georgia, serif; font-size: 1.15rem; line-height: 1.65; hyphens: auto; hyphenate-limit-chars: 8 4 4; } +.lm-label { font: 600 11.5px system-ui, sans-serif; letter-spacing: 0.08em; text-transform: uppercase; color: var(--lese-dim); margin-bottom: 8px; } +.lm-screen h1 { font-size: 2.1rem; line-height: 1.2; margin: 0 0 12px; } +.lm-screen h2 { font-size: 1.6rem; line-height: 1.25; margin: 0 0 6px; } +.lm-screen .intro { color: var(--lese-text); } +.lm-screen .stats { color: var(--lese-dim); font: 13px system-ui, sans-serif; margin-top: 14px; } +.lm-screen p { margin: 0 0 1em; } +.lm-screen code { font-family: "SF Mono", Consolas, monospace; font-size: 0.86em; background: var(--code-bg); padding: 1px 5px; border-radius: 4px; } +.lm-screen pre { background: var(--code-bg); color: var(--code-fg); padding: 12px 14px; border-radius: 8px; overflow-x: auto; font-size: 0.86em; } +.lm-screen pre code { background: none; padding: 0; } +.lm-screen ul, .lm-screen ol { padding-left: 1.4em; } +.lm-screen table { border-collapse: collapse; margin: 0 0 1em; font-size: 0.92em; display: block; max-width: 100%; overflow-x: auto; } +.lm-screen th, .lm-screen td { border: 1px solid var(--lese-rand); padding: 4px 9px; text-align: left; } +.lm-screen.ende { text-align: center; padding-top: 120px; } +.lm-fuss { display: flex; justify-content: space-between; padding: 10px 18px; border-top: 1px solid var(--lese-rand); font-family: system-ui, sans-serif; } +.lm-fuss button { background: var(--karte); color: var(--text); border: 1px solid var(--rand); } +.lm-fuss button.weiter { background: var(--akzent); color: #fff; border: 0; } +kbd { font: 11px system-ui, sans-serif; border: 1px solid var(--rand); border-radius: 4px; padding: 0 5px; margin-left: 6px; color: var(--dim); } + +@media (max-width: 800px) { + .topbar { gap: 8px; padding: 8px 10px; flex-wrap: nowrap; } + .topbar .badge { display: none; } + .topbar .titel { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } + .mobil-leiste { display: flex; gap: 8px; padding: 10px 16px 0; align-items: center; } + .guide-nav.offen .schliessen { display: inline-block; margin: 10px 10px 4px; } + .lese { overflow-x: hidden; } + .sidebar:not(.offen) { display: none; } + .burger { display: inline-block; } + .guide-nav { display: none; } + .guide-nav.offen { display: block; position: fixed; inset: 0 auto 0 0; z-index: 25; width: 82vw; } + .buch { padding: 22px 16px 100px; font-size: 1.02rem; } + .lm-screen { padding: 24px 16px 60px; font-size: 1.05rem; } + .lm-kopf, .lm-fuss { padding: 8px 12px; } + .stufe { grid-template-columns: 26px 1fr; } + .stufe .rechts { grid-column: 2; } + .ereignis { grid-template-columns: 64px 1fr; } + .ereignis .text { grid-column: 1 / -1; } +} + +/* Code-Farben je Theme (statt einer festen highlight.js-Vorlage) */ +pre.hljs, .hljs { background: var(--code-bg); color: var(--code-fg); } +.hljs-keyword, .hljs-selector-tag, .hljs-literal, .hljs-meta { color: var(--c-kw); } +.hljs-string, .hljs-regexp, .hljs-addition { color: var(--c-str); } +.hljs-comment, .hljs-quote { color: var(--c-com); font-style: italic; } +.hljs-number, .hljs-symbol, .hljs-bullet { color: var(--c-num); } +.hljs-title, .hljs-function, .hljs-section, .hljs-name { color: var(--c-fn); } +.hljs-built_in, .hljs-type, .hljs-class, .hljs-attr, .hljs-variable { color: var(--c-type); } diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..536ae4b --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + server: { proxy: { '/api': 'http://localhost:8020' } }, +}) diff --git a/prompts/gliederung.md b/prompts/gliederung.md new file mode 100644 index 0000000..a79be7a --- /dev/null +++ b/prompts/gliederung.md @@ -0,0 +1,30 @@ +Du entwirfst die Gliederung eines deutschsprachigen Sachbuchs zum Thema „{{thema}}“. +{{hinweise_block}} +Lies zuerst vollständig: `buch/STIL.md`, `quellen/QUELLEN.md` und `deutung.json`. Verschaffe dir dann anhand der Quellen unter `quellen/` (Inhaltsverzeichnisse der Doku, Klassenlisten, Titel der Studien) einen Überblick, was das Thema umfasst – die Gliederung muss aus dem Stoff kommen, nicht aus dem Gedächtnis. + +Anforderungen: +- Breite Übersicht über alles Wichtige; keine feste Kapitelzahl, typisch 10–25 Kapitel, ab 8 Kapiteln in Teile gruppiert. +- Je Kapitel 3–6 Abschnitte. Reihenfolge nach dem, was man zuerst wissen muss. +- Jedes Thema hat genau ein zuständiges Kapitel; Überschneidungen vermeiden (später wird per Querverweis verwiesen). +- Titel deutsch, konkret, keine Marketingsprache. + +Schreibe `buch/GLIEDERUNG.md` GENAU in diesem Format (Parser liest es): + +``` +# Gliederung – . + +## Teil I – + +**Kapitel 1 – ** (`01-.md`) +1.1 · 1.2 · 1.3 + +**Kapitel 2 – ** (`02-.md`) +2.1 · 2.2 · 2.3 · 2.4 + +## Teil II – +… +``` + +Regeln für das Format: Dateiname zweistellig nummeriert, slug nur Kleinbuchstaben, Ziffern und Bindestriche (ae/oe/ue/ss statt Umlauten); Abschnitte in einer Zeile, getrennt durch „ · “; Nummerierung fortlaufend; Teile mit `## Teil – Name`; bei weniger als 8 Kapiteln keine Teile-Zeilen. Keine weiteren Zeilen außer Leerzeilen. + +Antworte am Ende kurz (max. 6 Zeilen): Anzahl Teile/Kapitel/Abschnitte und was bewusst weggelassen wurde. diff --git a/prompts/kapitel.md b/prompts/kapitel.md new file mode 100644 index 0000000..526c4b0 --- /dev/null +++ b/prompts/kapitel.md @@ -0,0 +1,15 @@ +Du schreibst ein Kapitel für ein deutschsprachiges Sachbuch zum Thema „{{thema}}“. + +Lies zuerst vollständig: `buch/STIL.md`, `buch/GLIEDERUNG.md`, `quellen/QUELLEN.md`. + +Aufgabe: **Kapitel {{n}} – {{kapitel_titel}}**, Datei `buch/{{datei}}`, mit genau den Abschnitten aus der Gliederung. + +Arbeite in diesem Loop: +1. RECHERCHE in den lokalen Quellen (siehe QUELLEN.md): Verstehe die Sache anhand von Quellcode, Referenz, Doku bzw. Abstracts, bevor du schreibst. Online nur, wenn lokal nicht klärbar und QUELLEN.md es erlaubt. +2. SCHREIBEN mit Write (bei Länge in mehreren Schritten, dann anhängen). +3. VERIFIZIEREN: Lies das fertige Kapitel vollständig. Prüfe JEDE Tatsachenangabe (Bezeichner, Signaturen, Versionsangaben, Namen, Jahre, Zahlen, Studienergebnisse) gegen die Quellen; führe Codebeispiele aus, wo STIL.md das verlangt. Korrigiere oder entschärfe alles Unbelegte. +4. PRÜFEN gegen STIL.md: Überschriften exakt wie in der Gliederung, keine Übungen/Merkkästen/Zusammenfassungen/Literaturlisten, Prosa statt Listenwüste, Querverweise statt Redundanz zu anderen Kapiteln, Umfang, Umlaute. Überarbeite, bis alles passt. + +Arbeitsdateien (Testskripte, Notizen) nur unter `arbeit/kapitel-{{n}}/` anlegen, nicht in `buch/` oder `quellen/`. Keine persönlichen Daten an externe Dienste senden. + +Antworte am Ende mit einem kurzen Bericht (max. 10 Zeilen, Markdown): Wortzahl; was du per Quelle abgesichert oder korrigiert hast; eine Liste der Aussagen, die du NICHT belegen konntest (diese Liste bekommt der unabhängige Prüfer). diff --git a/prompts/konsistenz.md b/prompts/konsistenz.md new file mode 100644 index 0000000..a89ba3b --- /dev/null +++ b/prompts/konsistenz.md @@ -0,0 +1,16 @@ +Du prüfst Querverweise und Doppelungen in einem deutschsprachigen Sachbuch zum Thema „{{thema}}“. Alle Kapitel sind geschrieben und einzeln geprüft; sie liegen unter `buch/` (Gliederung: `buch/GLIEDERUNG.md`). + +Deine Kapitel: {{dateien}} + +Für jeden Querverweis der Form „(→ Kapitel N)“, „(→ Abschnitt N.M)“ oder Formulierungen wie „wird in Kapitel N behandelt“ in deinen Kapiteln: +1. Stelle fest, auf welches Thema verwiesen wird. +2. Prüfe per grep/Read in der Zieldatei, ob das Thema dort tatsächlich behandelt wird – nicht nur laut Gliederung, im Text. Bei „Abschnitt N.M“: steht es in genau diesem Abschnitt? +3. Wenn nicht: Korrigiere den Verweis auf die Stelle, wo das Thema wirklich steht (grep über alle Kapitel), oder entferne den Verweis bzw. formuliere den Satz um, falls das Thema nirgends behandelt wird. Behauptungen wie „wird in Kapitel X ausführlich behandelt“ nur stehen lassen, wenn das zutrifft. + +Doppelungen: Prüfe, ob dieselbe Studie, dasselbe Konzept oder dieselbe Mechanik in mehreren Kapiteln AUSFÜHRLICH beschrieben wird (grep über alle Kapitel). Die Hauptstelle ist das Kapitel, zu dessen Thema es laut Gliederung gehört. Kürze in DEINEN Kapiteln Nebenstellen auf ein bis zwei Sätze mit Verweis auf die Hauptstelle. Ändere Kapitel anderer Gruppen nicht; melde stattdessen, was dort zu kürzen wäre. + +Widersprüche: Widersprechen sich Angaben zum selben Sachverhalt zwischen deinen Kapiteln und dem Zielkapitel (andere Zahl, anderes Jahr, andere Versionsangabe, anderes Verhalten)? Kläre per Quelle (siehe `quellen/QUELLEN.md`) und gleiche in deinen Kapiteln an; melde, wenn das andere Kapitel falsch ist. + +Ändere nur deine Kapitel, minimal-invasiv mit Edit. Arbeitsdateien nur unter `arbeit/konsistenz-{{gruppe}}/`. + +Antworte am Ende kurz (max. 8 Zeilen, Markdown): Anzahl geprüfter Verweise, Korrekturen, gefundene Widersprüche und Doppelungen in fremden Kapiteln (Datei, Stelle, was zu tun ist). diff --git a/prompts/pruefung.md b/prompts/pruefung.md new file mode 100644 index 0000000..2969e5d --- /dev/null +++ b/prompts/pruefung.md @@ -0,0 +1,19 @@ +Du bist unabhängiger Fachlektor und Faktenprüfer für ein deutschsprachiges Sachbuch zum Thema „{{thema}}“. Du hast das Kapitel nicht geschrieben und traust ihm nicht. + +Lies zuerst: `buch/STIL.md`, `buch/GLIEDERUNG.md`, `quellen/QUELLEN.md`. + +Prüfe vollständig: `buch/{{datei}}` (Kapitel {{n}} – {{kapitel_titel}}). + +Der Autor hat diese Punkte als nicht belegt oder unsicher gemeldet – prüfe sie vorrangig: +{{autor_bericht}} + +Loop, bis nichts mehr zu korrigieren ist: +1. Lies das Kapitel komplett. Extrahiere alle überprüfbaren Behauptungen: Bezeichner und Signaturen, Versionsangaben, Studien (Autoren, Jahr, Design, Stichprobe, Ergebnis, Effektgröße), Zahlen, Zitate, Verhaltensbeschreibungen („was passiert wann“), Zuschreibungen („X hat Y eingeführt“). +2. Verifiziere möglichst ALLE gegen die lokalen Quellen (QUELLEN.md). Priorisiere nach Fehlerwahrscheinlichkeit. Führe Codebeispiele aus bzw. teste beschriebenes Verhalten mit kleinen Skripten, wo STIL.md das vorsieht. Versionsangaben gegen Tags/Changelogs prüfen. Achte besonders auf: falsche Zuschreibungen, verdrehte Effektrichtungen, überzogene Kausalaussagen aus Korrelationsstudien, erfundene Stichprobengrößen, veraltete oder erfundene Bezeichner, Doku-Aussagen, die dem Quellcode widersprechen (dann gilt der Quellcode). +3. Korrigiere Fehler direkt in der Datei mit Edit (minimal-invasiv, Stil beibehalten). Unbelegbares vorsichtiger formulieren oder streichen. +4. Prüfe Form: Überschriften exakt wie GLIEDERUNG.md; keine Übungen/Merkkästen/Zusammenfassungen/Literaturlisten; Querverweise zeigen auf Kapitel, die das Thema laut Gliederung (und, falls die Datei schon existiert, laut Text) wirklich behandeln; keine größeren Doppelungen mit Themen, die in andere Kapitel gehören (dann kürzen und verweisen); Rechtschreibung, Umlaute, Markdown. +5. Wiederhole, bis du keine Fehler mehr findest. + +Ändere nur `buch/{{datei}}`. Arbeitsdateien nur unter `arbeit/pruefung-{{n}}/`. Keine persönlichen Daten an externe Dienste senden. + +Antworte am Ende mit einem kurzen Bericht (max. 10 Zeilen, Markdown): Anzahl geprüfter Behauptungen und ausgeführter Tests; konkrete Korrekturen; was weiterhin unbelegt ist; Widersprüche zu anderen Kapiteln, die dir aufgefallen sind (Datei und Stelle). diff --git a/prompts/recherche.md b/prompts/recherche.md new file mode 100644 index 0000000..376c6ff --- /dev/null +++ b/prompts/recherche.md @@ -0,0 +1,26 @@ +Du bereitest ein deutschsprachiges Sachbuch zum Thema „{{thema}}“ vor. +{{hinweise_block}} +Du arbeitest im Verzeichnis dieses Buchprojekts (aktuelles Arbeitsverzeichnis). Unterordner: `quellen/` (Rohmaterial), `buch/` (Buchdateien), `berichte/`. + +Deine Aufgaben, in dieser Reihenfolge: + +1. **Deutung.** Kläre, was mit dem Thema gemeint ist. Bei mehrdeutigen Namen wähle die wahrscheinlichste Deutung selbst (keine Rückfragen möglich). Lege den Buchtyp fest: `werkzeug` (Software, Framework, Engine, Sprache – der Leser soll die Sache verstehen und richtig einsetzen) oder `wissen` (Wissenschaft, Fachgebiet, Phänomen – der Leser soll den Forschungsstand verstehen). Bestimme Zielgruppe und Anspruch; beachte die Hinweise oben. + +2. **Quellen beschaffen – lokal, prüfbar.** Lege alles unter `quellen/` ab, damit die späteren Autoren und Prüfer ohne Websuche arbeiten können (das Websuch-Kontingent je Agent ist begrenzt): + - Software: Quellcode und offizielle Dokumentation der aktuellen stabilen Version per `git clone --depth 1` (Tag der aktuellen Version ermitteln), ggf. auch der Vorversion für Vergleiche; Klassenreferenzen; wenn eine ausführbare Version frei erhältlich ist (z. B. ein Engine-Binary, ein CLI), herunterladen und einen kurzen Testlauf machen, damit Beispiele später ausgeführt werden können; Upgrade-/Changelog-Dateien. + - Wissenschaft: einen Korpus von Studien mit Abstracts, z. B. per Europe PMC REST (`https://www.ebi.ac.uk/europepmc/webservices/rest/search?query=…&resultType=core&pageSize=1000&format=json&cursorMark=*`, alle Seiten), als JSON unter `quellen/`; dazu die zentralen Metaanalysen und Übersichtsarbeiten benennen; Crossref (`https://api.crossref.org/works?query.bibliographic=…`) für Metadaten. + - Sonstiges: Wikipedia-Artikel (de/en) als Text, offizielle Seiten, Standards, Primärquellen – als Dateien speichern. + - Nutze WebSearch sparsam (Kontingent), WebFetch und `curl` gezielt. Keine persönlichen Daten an Dienste senden. + +3. **`quellen/QUELLEN.md` schreiben:** Was liegt wo (Pfade), Version/Stand, wie man darin prüft (konkrete grep-/curl-/Skript-Beispiele), was verlässlicher ist (z. B. Quellcode vor Doku), welche Online-Quellen bei Bedarf erlaubt sind. Diese Datei ist die Arbeitsanweisung für alle späteren Agenten. + +4. **`buch/STIL.md` schreiben:** Beginne mit dem Leitfaden unten unverändert, ergänze dann den Abschnitt „## Quellen und Prüfung“ mit den themenspezifischen Regeln (Referenzversion, welche Prüfschritte Pflicht sind – z. B. Bezeichner gegen Klassenreferenz, Codebeispiele headless ausführen, Studienangaben gegen Abstracts – und Verweise auf QUELLEN.md) und den Abschnitt „## Buchtyp und Leser“ (Zielgruppe, vorausgesetztes Wissen, Hauptsprache der Beispiele). + +5. **`deutung.json` schreiben** (im Arbeitsverzeichnis): `{"titel": "…", "untertitel": "…", "deutung": "<1–3 Sätze, was das Buch behandelt>", "buchtyp": "werkzeug|wissen", "zielgruppe": "…", "referenz": ""}`. + +Antworte am Ende kurz (max. 8 Zeilen): Deutung, Buchtyp, was unter `quellen/` liegt, was fehlt oder unsicher ist. + +--- +LEITFADEN (unverändert als Anfang von buch/STIL.md übernehmen, mit der Überschrift „# Stil- und Qualitätsleitfaden“): + +{{stil_basis}} diff --git a/prompts/stil_basis.md b/prompts/stil_basis.md new file mode 100644 index 0000000..a5d46ec --- /dev/null +++ b/prompts/stil_basis.md @@ -0,0 +1,30 @@ +## Ziel +Ein deutschsprachiges Sachbuch, das **Verständnis** vermittelt: Wie ist die Sache aufgebaut, warum ist sie so, welche Befunde, Argumente oder Mechanismen stehen dahinter, was ist gesichert, was umstritten? Ziel ist eine breite Übersicht über alles Wichtige, nicht Vollständigkeit bis ins letzte Detail. Kein Tutorial, keine Referenz, kein Ratgeber. + +## Form +- Sprache: Deutsch, korrekte Umlaute und ß, gut lesbare Sachbuchprosa. Keine direkte Anrede als Stilmittel; „wir“ sparsam. +- **Keine** Übungen, Selbsttests, Lernziele, Merkkästen, Checklisten, „Praxistipps“, Schritt-für-Schritt-Anleitungen als Selbstzweck, Zusammenfassungen am Kapitelende, Literaturlisten. +- Überwiegend Fließtext. Listen und Tabellen nur, wo sie echte Klarheit bringen – sparsam. +- Fachbegriffe bei der ersten Nennung kurz erklären; englische Fachbegriffe nennen, wo sie üblich sind. +- Konkrete Belege im Text: Studien mit Autor und Jahr („(Curran & Hill, 2019)“), Klassen und Befehle in `Code`-Formatierung, Versionsangaben („seit 4.3“) nur, wenn geprüft. +- Querverweise „(→ Kapitel 8)“ bzw. „(→ Abschnitt 8.2)“ statt Wiederholung. Die Gliederung zeigt, was wo steht; Themen anderer Kapitel nur kurz streifen und verweisen. +- Codebeispiele (falls das Thema Software ist): kurz (typisch 5–30 Zeilen), nur wo sie ein Konzept erklären, mit Sprachangabe im Codeblock, im Text erklärt. + +## Aufbau jeder Kapiteldatei (Markdown) +``` +# Kapitel N – Titel + +Einleitung: 2–4 Absätze, die ins Thema führen und die Leitfrage stellen. + +## N.1 Titel des Abschnitts +Fließtext, ggf. mit ### Unterabschnitten. + +## N.2 … +``` +Überschriften exakt wie in GLIEDERUNG.md (Nummerierung beibehalten). Umfang je Abschnitt ca. 1.000–2.000 Wörter, je Kapitel ca. 5.000–10.000 Wörter. Lieber substanziell als aufgebläht. + +## Korrektheit +- Jede Tatsachenangabe (Name, Jahr, Zahl, Bezeichner, Signatur, Versionsangabe, Studienergebnis) muss gegen die im Abschnitt „Quellen und Prüfung“ genannten Quellen geprüft sein. Nichts aus dem Gedächtnis übernehmen, ohne es zu prüfen; was sich nicht belegen lässt, vorsichtiger formulieren oder weglassen. +- Keine erfundenen Studien, Zitate, Zahlen, Klassen oder Optionen. +- Kontroversen und Replikationsprobleme fair darstellen. +- Keine persönlichen Daten an externe Dienste senden (keine E-Mail-Adressen als Parameter). diff --git a/prompts/vorwort.md b/prompts/vorwort.md new file mode 100644 index 0000000..4d5f422 --- /dev/null +++ b/prompts/vorwort.md @@ -0,0 +1,12 @@ +Du schreibst das Vorwort für ein deutschsprachiges Sachbuch zum Thema „{{thema}}“. + +Lies: `deutung.json`, `buch/STIL.md`, `buch/GLIEDERUNG.md`, die Einleitungen (Text vor dem ersten `## `) aller Kapitel unter `buch/` sowie die Prüfberichte unter `berichte/` (Dateien `pruefung-*.md`), um zu wissen, was geprüft wurde und wo Grenzen liegen. + +Schreibe `buch/00-vorwort.md` (Markdown, Überschrift `# Vorwort`, 350–600 Wörter): +- Worum es geht und für wen das Buch ist; was es nicht ist (kein Tutorial, kein Ratgeber, keine Referenz). +- Wie das Buch aufgebaut ist (die Teile in einem Absatz, keine Liste). +- Woran sich die Darstellung orientiert (Referenzversion bzw. Literaturstand) und wie die Angaben geprüft wurden – ehrlich, ohne Übertreibung; welche Grenzen bleiben. +- Hinweis auf Querverweise „(→ Abschnitt N.M)“. +- Bei Gesundheitsthemen: ein Satz, dass das Buch informiert und nicht berät, mit Hinweis auf Hilfsangebote (Deutschland: 116 117, Telefonseelsorge 0800 111 0 111 / 0800 111 0 222). + +Sachbuchprosa, keine Anrede des Lesers, keine Werbung. Antworte am Ende mit einer Zeile.