From 229880b7e67a491707fb2d8bfbfb0e152b7375bc Mon Sep 17 00:00:00 2001 From: team3 Date: Thu, 18 Jun 2026 00:00:24 +0200 Subject: [PATCH] update --- Dockerfile | 5 + Makefile | 4 +- backend/bausteine.py | 92 +++++++++---- backend/crawl.py | 113 ++++++++++++++++ backend/guide.py | 4 +- backend/models.py | 4 + backend/paths.py | 24 +++- backend/requirements.txt | 1 + backend/routes.py | 16 ++- frontend/src/App.vue | 14 ++ frontend/src/api.js | 4 +- frontend/src/components/TopicSidebar.vue | 131 +++++++++++++++++-- templates/Prompt/Bausteine-Quelle-Link.md | 1 + templates/Prompt/Bausteine-Quelle-Projekt.md | 2 +- templates/Prompt/Bausteine-Quelle-Uni.md | 1 + 15 files changed, 374 insertions(+), 42 deletions(-) create mode 100644 backend/crawl.py create mode 100644 templates/Prompt/Bausteine-Quelle-Link.md create mode 100644 templates/Prompt/Bausteine-Quelle-Uni.md diff --git a/Dockerfile b/Dockerfile index 1ad6a6e..0f1420a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,6 +25,11 @@ RUN useradd -m -u 1000 app COPY backend/requirements.txt /app/backend/requirements.txt RUN pip install --no-cache-dir -r /app/backend/requirements.txt +# Chromium + OS-Libs für Playwright (als root) in ein gemeinsames, welt-lesbares Verzeichnis. +ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright +RUN python3 -m playwright install --with-deps chromium \ + && chmod -R a+rX /ms-playwright + COPY --chown=app:app backend/ /app/backend/ COPY --chown=app:app templates/ /app/templates/ COPY --chown=app:app --from=frontend /build/dist /app/frontend/dist diff --git a/Makefile b/Makefile index 5015b12..f8198ca 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,9 @@ auth: @echo "Verzeichnisse angelegt und auf uid 1000 chowned." install: - pip install --break-system-packages fastapi uvicorn[standard] aiosqlite uv + pip install --break-system-packages fastapi uvicorn[standard] aiosqlite uv playwright + python3 -m playwright install chromium + @echo "Falls Chromium OS-Libs fehlen: 'sudo python3 -m playwright install-deps chromium' einmalig ausführen." @which pdftotext >/dev/null 2>&1 || sudo apt-get install -y poppler-utils cd frontend && npm install npm install -g opencode-ai diff --git a/backend/bausteine.py b/backend/bausteine.py index f7e05fb..002d7d1 100644 --- a/backend/bausteine.py +++ b/backend/bausteine.py @@ -21,7 +21,8 @@ from agents import kill_process from config import KONSENS_GRACE, KONSENS_MAX_RUNDEN, DEFAULT_PROVIDER from fsutil import atomic_write_text, atomic_write_json from jsonio import read_json_file as _json_datei -from paths import arbeit_dir, bausteine_path, project_dir, subbausteine_path +from paths import arbeit_dir, bausteine_path, project_dir, subbausteine_path, quelle_path, quelle_crawl_dir, safe_ordner +from crawl import crawl from pipeline import ( CANCELLED, FAILED, GenContext, _extra, _log, _prompt, _race, _rest_schema, _runde_schema, _semaphore, _str_liste, _stufen_schema, _timeout, run_single_slot, @@ -52,20 +53,45 @@ BAUSTEINE_STEPS = ( ) +def lade_quelle(topic: str) -> dict: + """Persistierte Quellen-Wahl lesen. Fallback (Alt-Themen ohne quelle.json): + existiert projects/ → projekt, sonst thema.""" + q = _json_datei(quelle_path(topic)) + if isinstance(q, dict) and q.get("type") in ("thema", "projekt", "uni", "link"): + return q + if project_dir(topic).is_dir(): + return {"type": "projekt", "ort": f"projects/{topic}", "spec": ""} + return {"type": "thema", "ort": "", "spec": ""} + + +def quelle_ordner(topic: str) -> Path | None: + """Ordner-Quelle (projekt/uni → Pfad, link → Crawl-Ordner) — sonst None (thema).""" + q = lade_quelle(topic) + if q["type"] == "link": + return quelle_crawl_dir(topic) + if q["type"] in ("projekt", "uni"): + return safe_ordner(q.get("ort", "")) + return None + + +def _crawl_fertig(topic: str) -> bool: + return (quelle_crawl_dir(topic) / ".done").exists() # Marker erst bei sauberem Abschluss + + def _bausteine_steps(topic: str) -> tuple: - """Projekte haben einen zusätzlichen Schritt (Ergänzung), nach der Klärung eingefügt. + """Schritte je Quelle: link bekommt vorne „Quelle laden", projekt zusätzlich „Ergänzung". Subbausteine + Stufen sind je drei Phasen (Finden, Wählen, Klären). Pro Phase laufen alle Pakete parallel; der Schritt bleibt, bis das letzte Paket fertig ist. """ + q = lade_quelle(topic) base = ("Recherche", "Konsolidierung", "Klärung") rest = ( "Subbausteine finden", "Subbausteine wählen", "Subbausteine klären", "Stufen finden", "Stufen wählen", "Stufen klären", ) - if project_dir(topic).is_dir(): - return base + ("Ergänzung",) + rest - return base + rest + mitte = base + (("Ergänzung",) if q["type"] == "projekt" else ()) + rest + return (("Quelle laden",) if q["type"] == "link" else ()) + mitte def _step_idx(topic: str, name: str) -> int: @@ -111,18 +137,21 @@ def cancel_bausteine(topic: str) -> bool: def _resume_step(topic: str) -> int: """Erster noch offener Schritt anhand der persistierten Zwischendateien.""" files = _bausteine_files(topic) + q = lade_quelle(topic) + if q["type"] == "link" and not _crawl_fertig(topic): + return _step_idx(topic, "Quelle laden") if sum(p.exists() for p in files["recherche"]) < 3: - return 0 + return _step_idx(topic, "Recherche") if not files["recherche_mapping"].exists(): - return 1 + return _step_idx(topic, "Konsolidierung") mapping = _mapping_schema(_json_datei(files["recherche_mapping"])) geklaert = mapping is not None and ( not mapping[1] # kein strittiger Rest or any((r := _runde_schema(_json_datei(p))) is not None and not r[1] for p in files["mapping"].values()) ) if not geklaert: - return 2 - if project_dir(topic).is_dir() and not files["ergaenzung"].exists(): + return _step_idx(topic, "Klärung") + if q["type"] == "projekt" and not files["ergaenzung"].exists(): return _step_idx(topic, "Ergänzung") if _sidecar_schema(_json_datei(files["sidecar"])) is not None: return len(_bausteine_steps(topic)) @@ -166,6 +195,8 @@ def reset_bausteine(topic: str) -> None: files = _bausteine_files(topic) files["final"].unlink(missing_ok=True) files["sidecar"].unlink(missing_ok=True) # liegt im Themen-Root, nicht in arbeit/ + quelle_path(topic).unlink(missing_ok=True) + shutil.rmtree(quelle_crawl_dir(topic), ignore_errors=True) # gecrawlte Link-Quelle shutil.rmtree(files["arbeit"], ignore_errors=True) _bausteine_errors.pop(topic, None) @@ -209,9 +240,12 @@ def _pdfs_konvertieren(project: Path) -> None: raise RuntimeError(f"PDF-Konvertierung fehlgeschlagen ({pdf.name}): {e}") from e -def _build_recherche_prompt(topic: str, out_path: Path, instructions: str = "", project: Path | None = None) -> str: - if project: - source = _prompt("Bausteine-Quelle-Projekt", project=project) +_QUELLE_TEMPLATE = {"projekt": "Bausteine-Quelle-Projekt", "uni": "Bausteine-Quelle-Uni", "link": "Bausteine-Quelle-Link"} + + +def _build_recherche_prompt(topic: str, out_path: Path, instructions: str, typ: str, ordner: Path | None) -> str: + if typ in _QUELLE_TEMPLATE: + source = _prompt(_QUELLE_TEMPLATE[typ], project=ordner) else: source = _prompt("Bausteine-Quelle-Thema", topic=topic) return _prompt( @@ -337,7 +371,7 @@ async def _subbausteine_block(ctx: GenContext, set_p, files: dict, entries: dict topic, provider, is_cancelled = ctx.topic, ctx.provider, ctx.is_cancelled arbeit = files["arbeit"] idx = _titel_index(entries) - caps = "files" if project_dir(topic).is_dir() else "full" + caps = "files" if quelle_ordner(topic) else "full" nums = list(entries) chunks = _chunk_nums(nums, _n_chunks(len(nums))) n = len(chunks) @@ -548,7 +582,9 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str = files = _bausteine_files(topic) final_path = files["final"] - project = project_dir(topic) if project_dir(topic).is_dir() else None + q = lade_quelle(topic) + ordner = quelle_ordner(topic) # projekt/uni/link → Ordner, thema → None + instructions = q.get("spec") or instructions # persistierte Spezifikation bevorzugen (auch bei Resume) def set_p(msg: str, step: int | None = None) -> None: _bausteine_progress[topic] = msg @@ -566,8 +602,18 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str = try: async with _semaphore: files["arbeit"].mkdir(parents=True, exist_ok=True) - if project: - await asyncio.to_thread(_pdfs_konvertieren, project) + # Link-Quelle: erst crawlen (gleiche Domain, begrenzt) → wird zur Ordner-Quelle. + if q["type"] == "link" and not _crawl_fertig(topic): + set_p("Quelle laden (Crawl)…", step=_step_idx(topic, "Quelle laden")) + n = await asyncio.to_thread(crawl, q["ort"], ordner, cancelled=is_cancelled) + if is_cancelled(): + abgebrochen() + return + if not n: + _bausteine_errors[topic] = "Crawl ergab keine Inhalte — Link/Domain prüfen" + return + if ordner: + await asyncio.to_thread(_pdfs_konvertieren, ordner) # „Neu erstellen": NUR wenn wirklich alles fertig ist (bausteine.md UND # Sidecar) → kompletter Frischstart. Liegt bausteine.md ohne Sidecar vor, # ist das ein Teil-Stand (Block B/C offen) → Resume, nicht wischen. @@ -587,13 +633,13 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str = else: offen.append((i, path)) vorhanden = len(recherchen) - set_p(f"Recherche läuft ({vorhanden} gültig, min. 3)…", step=0) + set_p(f"Recherche läuft ({vorhanden} gültig, min. 3)…", step=_step_idx(topic, "Recherche")) if vorhanden < 3: - caps = "files" if project else "full" + caps = "files" if ordner else "full" slots = [ { "key": f"bausteine-{topic}-recherche-{i}", - "prompt": _build_recherche_prompt(topic, path, instructions, project), + "prompt": _build_recherche_prompt(topic, path, instructions, q["type"], ordner), "role": "quick", "capabilities": caps, "payload": (lambda result, p=path: _file_payload(p)), } @@ -616,7 +662,7 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str = # für semantische Dubletten und Konsens/Rest-Teilung (fatal) mapping = _mapping_schema(_json_datei(files["recherche_mapping"])) if mapping is None: - set_p("Konsolidiere Recherche…", step=1) + set_p("Konsolidiere Recherche…", step=_step_idx(topic, "Konsolidierung")) files["recherche_mapping"].unlink(missing_ok=True) gemergt = _vormerge([_parse_auswahl(t) for t in recherchen]) eintraege = "\n".join(f"{i}. {text} ({n}× genannt)" for i, (text, n) in enumerate(gemergt, 1)) @@ -648,7 +694,7 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str = while rest and runde < KONSENS_MAX_RUNDEN: runde += 1 final_runde = runde == KONSENS_MAX_RUNDEN - set_p(f"Klärung läuft (Runde {runde}/{KONSENS_MAX_RUNDEN})…", step=2) + set_p(f"Klärung läuft (Runde {runde}/{KONSENS_MAX_RUNDEN})…", step=_step_idx(topic, "Klärung")) mapping_path = files["mapping"][runde] # Resume: fertiges Runden-Mapping wird direkt übernommen @@ -732,8 +778,8 @@ async def generate_bausteine(topic: str, instructions: str = "", provider: str = # Nur Projekte: Themenfeld-Ergänzung — Skript/Projekt ist ein Ausschnitt, # ein Web-Agent ergänzt kanonisch fehlende Bausteine, markiert mit [Ergänzung]. - if project: - set_p("Ergänze Themenfeld…", step=3) + if q["type"] == "projekt": + set_p("Ergänze Themenfeld…", step=_step_idx(topic, "Ergänzung")) erg_path = files["ergaenzung"] ergaenzungen = _ergaenzung_schema(_json_datei(erg_path)) if ergaenzungen is None: diff --git a/backend/crawl.py b/backend/crawl.py new file mode 100644 index 0000000..cc9be61 --- /dev/null +++ b/backend/crawl.py @@ -0,0 +1,113 @@ +"""Geboundeter Domain-Crawler für Link-Quellen — rendert JS via Playwright (Chromium). + +Lädt ab einer Start-URL Seiten + PDFs — NUR dieselbe Domain, begrenzte Tiefe und +Seitenzahl. HTML-Seiten werden im Headless-Browser gerendert (für SPAs nötig), dann +Links + Text aus dem fertigen DOM gezogen. PDFs werden direkt als Bytes geladen. +Deterministisch, gebounded; läuft via asyncio.to_thread (Sync-API, kein Event-Loop). +""" + +import hashlib +import logging +import re +from pathlib import Path +from urllib.parse import urldefrag, urlparse +from urllib.request import Request, urlopen + +from fsutil import atomic_write_text + +log = logging.getLogger("creator.crawl") + +MAX_TIEFE = 3 +MAX_SEITEN = 500 +SEITE_TIMEOUT = 30 # Sekunden pro Seite (Render bzw. PDF-Download) +MAX_BYTES = 10_000_000 # 10 MB Deckel pro PDF +_UA = "Mozilla/5.0 (creator-lernbot)" + + +def _fetch_bytes(url: str) -> bytes | None: + """PDF-Bytes per urllib laden (kein Rendering nötig). None bei Fehler/zu groß.""" + try: + req = Request(url, headers={"User-Agent": _UA}) + with urlopen(req, timeout=SEITE_TIMEOUT) as resp: + data = resp.read(MAX_BYTES + 1) + return None if len(data) > MAX_BYTES else data + except Exception as e: + log.debug("crawl: PDF-Fetch fehlgeschlagen %s: %s", url, e) + return None + + +def _name(url: str, ext: str) -> str: + h = hashlib.md5(url.encode("utf-8")).hexdigest()[:8] + slug = re.sub(r"[^a-zA-Z0-9]+", "-", urlparse(url).path).strip("-")[:60] or "index" + return f"{h}-{slug}{ext}" + + +def _is_pdf(url: str) -> bool: + return url.lower().split("?")[0].rstrip("/").endswith(".pdf") + + +def crawl(start_url: str, ziel: Path, *, max_tiefe: int = MAX_TIEFE, max_seiten: int = MAX_SEITEN, cancelled=None) -> int: + """Crawlt ab start_url (nur gleiche Domain), rendert JS und legt Seiten/PDFs in `ziel` ab. + + BFS bis `max_tiefe` / `max_seiten`. Fehler einzelner Seiten werden übersprungen. + Schreibt am ENDE einen `.done`-Marker; ein Abbruch (`cancelled()` → True) lässt ihn weg, + sodass ein Neustart neu crawlt. Gibt die Zahl gespeicherter Quellen zurück. + """ + # Lazy: so startet das Backend auch ohne installiertes Playwright; nur das Crawlen schlägt dann fehl. + from playwright.sync_api import sync_playwright + + ziel.mkdir(parents=True, exist_ok=True) + domain = urlparse(start_url).netloc + gesehen: set[str] = set() + queue: list[tuple[str, int]] = [(urldefrag(start_url)[0], 0)] + gespeichert = 0 + + with sync_playwright() as pw: + browser = pw.chromium.launch(args=["--no-sandbox"]) # non-root (Docker user app) + page = browser.new_page(user_agent=_UA) + try: + while queue and gespeichert < max_seiten: + if cancelled and cancelled(): + return gespeichert # Abbruch → KEIN .done-Marker → Neustart crawlt neu + url, tiefe = queue.pop(0) + if url in gesehen: + continue + gesehen.add(url) + + # PDFs brauchen kein Rendering — direkt laden. + if _is_pdf(url): + data = _fetch_bytes(url) + if data: + p = ziel / _name(url, ".pdf") + if not p.exists(): + p.write_bytes(data) + gespeichert += 1 + continue + + try: + page.goto(url, wait_until="networkidle", timeout=SEITE_TIMEOUT * 1000) + except Exception as e: + log.debug("crawl: goto unvollständig %s: %s", url, e) # trotzdem versuchen, Inhalt zu lesen + try: + text = page.inner_text("body").strip() + except Exception: + text = "" + if text: + atomic_write_text(ziel / _name(url, ".txt"), f"QUELLE: {url}\n\n{text}") + gespeichert += 1 + if tiefe < max_tiefe: + try: + hrefs = page.eval_on_selector_all("a[href]", "els => els.map(e => e.href)") + except Exception: + hrefs = [] + for href in hrefs: + nxt = urldefrag(href)[0] + if (nxt.startswith(("http://", "https://")) + and urlparse(nxt).netloc == domain and nxt not in gesehen): + queue.append((nxt, tiefe + 1)) + finally: + browser.close() + + (ziel / ".done").write_text("ok", encoding="utf-8") # sauber durchgelaufen + log.info("crawl %s → %d Quellen in %s", start_url, gespeichert, ziel) + return gespeichert diff --git a/backend/guide.py b/backend/guide.py index a4c29a5..4add19a 100644 --- a/backend/guide.py +++ b/backend/guide.py @@ -15,7 +15,7 @@ from datetime import datetime, timezone from pathlib import Path from agents import run_agent -from bausteine import _pdfs_konvertieren +from bausteine import _pdfs_konvertieren, quelle_ordner from config import ( DEFAULT_PROVIDER, FORMAT_ANTEIL, KONSENS_GRACE, KONSENS_MAX_RUNDEN, TEMPLATES_DIR, @@ -771,7 +771,7 @@ async def generate_guide(guide_id: str, topic: str, format_name: str, instructio content_path = guide_content_path(topic, format_name) content_path.parent.mkdir(parents=True, exist_ok=True) - project = project_dir(topic) if project_dir(topic).is_dir() else None + project = quelle_ordner(topic) # Ordner-Quelle (projekt/uni/link) → Pfad, sonst None try: if is_guide_cancelled(guide_id): diff --git a/backend/models.py b/backend/models.py index 9dd74ba..b2ebe65 100644 --- a/backend/models.py +++ b/backend/models.py @@ -10,6 +10,8 @@ FormatType = Literal[ ProviderType = Literal["claude", "minimax", "lokal"] +SourceType = Literal["thema", "projekt", "uni", "link"] + class GuideCreateRequest(BaseModel): topic: str = Field(min_length=1, max_length=100) @@ -26,6 +28,8 @@ class BausteineCreateRequest(BaseModel): topic: str = Field(min_length=1, max_length=100) instructions: str = Field(default="", max_length=2000) provider: ProviderType = "claude" + source_type: SourceType = "thema" + source_ort: str = Field(default="", max_length=2000) class BausteineStep(BaseModel): diff --git a/backend/paths.py b/backend/paths.py index 36668a1..24624bd 100644 --- a/backend/paths.py +++ b/backend/paths.py @@ -1,6 +1,6 @@ from pathlib import Path -from config import STORAGE_DIR, PROJECTS_DIR +from config import STORAGE_DIR, PROJECTS_DIR, PROJECT_ROOT THEMEN_DIR = STORAGE_DIR / "themen" @@ -26,6 +26,28 @@ def subbausteine_path(topic: str) -> Path: return topic_dir(topic) / "subbausteine.json" +def quelle_path(topic: str) -> Path: + """Persistierte Quellen-Wahl pro Thema: {type, ort, spec}.""" + return topic_dir(topic) / "quelle.json" + + +def quelle_crawl_dir(topic: str) -> Path: + """Zielordner für gecrawlte Link-Quellen (Seiten + PDF-.txt).""" + return topic_dir(topic) / "quelle" + + +def safe_ordner(ort: str) -> Path | None: + """Ordnerpfad relativ zum Repo-Root, gesandboxt. None bei leer/Ausbruch (../, absolut außerhalb).""" + if not ort or not ort.strip(): + return None + p = (PROJECT_ROOT / ort.strip()).resolve() + try: + p.relative_to(PROJECT_ROOT) + except ValueError: + return None + return p + + def guide_content_path(topic: str, format_name: str) -> Path: return topic_dir(topic) / "guides" / f"{format_name}.json" diff --git a/backend/requirements.txt b/backend/requirements.txt index 231d37c..8d201bc 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,3 +1,4 @@ fastapi uvicorn[standard] aiosqlite +playwright diff --git a/backend/routes.py b/backend/routes.py index 5487f1a..0fb4f6e 100644 --- a/backend/routes.py +++ b/backend/routes.py @@ -35,7 +35,8 @@ from models import ( BausteinChatRequest, BausteinChatResponse, BausteinPruefungRequest, BausteinPruefungResponse, BausteinLernstandResponse, ) -from paths import bausteine_topics, guide_content_path, project_dir, topic_dir +from paths import bausteine_topics, guide_content_path, project_dir, topic_dir, quelle_path, safe_ordner +from fsutil import atomic_write_json router = APIRouter(prefix="/api") @@ -130,6 +131,19 @@ async def create_bausteine(req: BausteineCreateRequest): if bausteine_status(topic)["generating"]: return {"ok": True, "status": "already_generating"} await create_topic(topic) + qp = quelle_path(topic) + # Quelle nur beim ERSTEN Mal festschreiben; ▶/Resume erhält die bestehende Wahl. + if not qp.exists(): + typ, ort = req.source_type, req.source_ort.strip() + if typ in ("projekt", "uni"): + ordner = safe_ordner(ort) + if ordner is None or not ordner.is_dir(): + raise HTTPException(400, "Ordner ungültig oder nicht gefunden (Pfad relativ zum Projekt-Root, kein ../).") + elif typ == "link": + if not ort.lower().startswith(("http://", "https://")): + raise HTTPException(400, "Link muss mit http:// oder https:// beginnen.") + qp.parent.mkdir(parents=True, exist_ok=True) + atomic_write_json(qp, {"type": typ, "ort": ort, "spec": req.instructions.strip()}) asyncio.create_task(generate_bausteine(topic, req.instructions.strip(), req.provider)) return {"ok": True} diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 5c6ad7e..5a3585d 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -245,6 +245,19 @@ async function handleBausteineClick({ instructions }) { startPolling() } +async function handleCreateThema({ topic, instructions, sourceType, sourceOrt }) { + uiError.value = null + try { + await apiCreateBausteine(topic, instructions, provider.value, sourceType, sourceOrt) + } catch (e) { + uiError.value = e.message + return + } + await loadTopics() + selectTopic(topic) + startPolling() +} + async function handleFormatClick({ format, instructions }) { if (!selectedTopic.value) return // Kein Duplikat-Start: läuft für Thema+Format schon eine Generierung, ignorieren @@ -365,6 +378,7 @@ onMounted(async () => { @toggleDark="toggleDark" @select="selectTopic" @create="createTopic" + @createThema="handleCreateThema" @formatClick="handleFormatClick" @bausteineClick="handleBausteineClick" @cancelBausteine="handleCancelBausteine" diff --git a/frontend/src/api.js b/frontend/src/api.js index 0914cfc..3f91c28 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -42,11 +42,11 @@ export async function fetchBausteineStatus(topic) { return res.json() } -export async function createBausteine(topic, instructions = '', provider = 'claude') { +export async function createBausteine(topic, instructions = '', provider = 'claude', sourceType = 'thema', sourceOrt = '') { const res = await fetch(`${BASE}/bausteine`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ topic, instructions, provider }), + body: JSON.stringify({ topic, instructions, provider, source_type: sourceType, source_ort: sourceOrt }), }) return jsonOrThrow(res) } diff --git a/frontend/src/components/TopicSidebar.vue b/frontend/src/components/TopicSidebar.vue index d9c1b3b..39b9868 100644 --- a/frontend/src/components/TopicSidebar.vue +++ b/frontend/src/components/TopicSidebar.vue @@ -22,7 +22,7 @@ const props = defineProps({ providers: { type: Array, default: () => [] }, }) -const emit = defineEmits(['select', 'create', 'formatClick', 'bausteineClick', 'cancelBausteine', 'resetBausteine', 'deleteTopic', 'deleteProject', 'cancelGuide', 'deleteGuide', 'dismissError', 'dismissUiError', 'preview', 'openElements', 'togglePin', 'sidebarLeave', 'toggleDark', 'setProvider']) +const emit = defineEmits(['select', 'create', 'createThema', 'formatClick', 'bausteineClick', 'cancelBausteine', 'resetBausteine', 'deleteTopic', 'deleteProject', 'cancelGuide', 'deleteGuide', 'dismissError', 'dismissUiError', 'preview', 'openElements', 'togglePin', 'sidebarLeave', 'toggleDark', 'setProvider']) function providerAvailable(id) { const p = props.providers.find((x) => x.id === id) @@ -176,13 +176,36 @@ function handleDelete(format) { }) } -const newTopic = ref('') +// Erstellen-Bereich: inline aufklappbar (Name + weitere Infos + Quellen-Typ). +const dlg = ref(false) +const form = ref({ name: '', instructions: '', sourceType: 'thema', sourceOrt: '' }) +const SOURCE_HINTS = { + thema: 'Web-Recherche zum Thema.', + link: 'Seite wird gecrawlt (gleiche Domain, begrenzt) — Klausur-Fokus.', + projekt: 'Ordner wird gelesen — Architektur/Features verstehen.', + uni: 'Ordner wird gelesen — Klausur-Vorbereitung.', +} +const sourceHint = computed(() => SOURCE_HINTS[form.value.sourceType]) +const canCreate = computed(() => { + if (!form.value.name.trim()) return false + if (['link', 'projekt', 'uni'].includes(form.value.sourceType)) return !!form.value.sourceOrt.trim() + return true +}) -function submit() { - const t = newTopic.value.trim() - if (!t) return - emit('create', t) - newTopic.value = '' +function toggleErstellen() { + if (!dlg.value) form.value = { name: '', instructions: '', sourceType: 'thema', sourceOrt: '' } + dlg.value = !dlg.value +} + +function createThema() { + if (!canCreate.value) return + emit('createThema', { + topic: form.value.name.trim(), + instructions: form.value.instructions.trim(), + sourceType: form.value.sourceType, + sourceOrt: form.value.sourceOrt.trim(), + }) + dlg.value = false } function confirmDeleteTopic(topic) { @@ -213,12 +236,34 @@ function confirmDeleteProject(name) { :title="dark ? 'Hellmodus' : 'Dunkelmodus'" @click="emit('toggleDark')" >{{ dark ? '☀' : '🌙' }} + + + + +
+ + +
+ + + + +
- + +

{{ sourceHint }}

+
+ + +