This commit is contained in:
team3
2026-06-18 00:00:24 +02:00
parent 3c839861e7
commit 229880b7e6
15 changed files with 374 additions and 42 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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/<topic> → 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:

113
backend/crawl.py Normal file
View File

@@ -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

View File

@@ -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):

View File

@@ -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):

View File

@@ -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"

View File

@@ -1,3 +1,4 @@
fastapi
uvicorn[standard]
aiosqlite
playwright

View File

@@ -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}

View File

@@ -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"

View File

@@ -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)
}

View File

@@ -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 ? '☀' : '🌙' }}</button>
<button class="new-topic-toggle" :class="{ active: dlg }" title="Thema erstellen" @click="toggleErstellen">+</button>
</div>
<!-- Erstellen: inline aufklappbar (kein Modal) -->
<div v-if="dlg" class="thema-panel">
<input class="dlg-input" v-model="form.name" placeholder="Thema-Name…" @keyup.enter="createThema" autofocus />
<textarea class="dlg-textarea" v-model="form.instructions" rows="3" placeholder="Weitere Informationen / Spezifikation (optional)…"></textarea>
<div class="dlg-sources">
<button :class="{ active: form.sourceType === 'thema' }" @click="form.sourceType = 'thema'; form.sourceOrt = ''">Thema</button>
<button :class="{ active: form.sourceType === 'link' }" @click="form.sourceType = 'link'; form.sourceOrt = ''">Link</button>
<button :class="{ active: form.sourceType === 'projekt' }" @click="form.sourceType = 'projekt'; form.sourceOrt = ''">Projekt</button>
<button :class="{ active: form.sourceType === 'uni' }" @click="form.sourceType = 'uni'; form.sourceOrt = ''">Uni</button>
</div>
<input
v-model="newTopic"
placeholder="Neues Thema…"
@keyup.enter="submit"
v-if="form.sourceType === 'link'"
class="dlg-input" v-model="form.sourceOrt"
placeholder="https://…" @keyup.enter="createThema"
/>
<button @click="submit" :disabled="!newTopic.trim()">+</button>
<input
v-else-if="form.sourceType === 'projekt' || form.sourceType === 'uni'"
class="dlg-input" v-model="form.sourceOrt"
placeholder="Ordner ab ./ (z.B. projects/meinprojekt)" @keyup.enter="createThema"
/>
<p class="dlg-hint">{{ sourceHint }}</p>
<div class="dlg-actions">
<button class="dlg-cancel" @click="dlg = false">Abbrechen</button>
<button class="dlg-create" :disabled="!canCreate" @click="createThema">Erstellen</button>
</div>
</div>
<div class="provider-toggle" v-if="providers.length">
<button
@@ -853,4 +898,68 @@ function confirmDeleteProject(name) {
50% { opacity: 0.65; }
}
/* Erstellen — inline aufklappbar (kein Modal) */
.new-topic-toggle {
flex: 1;
padding: 6px 8px;
border: 1px solid transparent;
border-radius: 6px;
background: var(--accent);
color: var(--on-accent);
cursor: pointer;
font: inherit;
font-size: 1.1rem;
line-height: 1;
text-align: center;
}
.new-topic-toggle:hover { background: var(--accent-hover); }
.new-topic-toggle.active { background: var(--accent-hover); }
.thema-panel {
margin: 0.5rem 0;
padding: 0.75rem;
background: var(--panel-soft);
border: 1px solid var(--border);
border-radius: 10px;
display: flex;
flex-direction: column;
gap: 0.55rem;
font-size: 0.85rem;
}
.dlg-input, .dlg-textarea {
width: 100%;
box-sizing: border-box;
padding: 0.5rem 0.6rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--panel);
color: var(--text);
font: inherit;
}
.dlg-input:focus, .dlg-textarea:focus { outline: none; border-color: var(--accent); }
.dlg-textarea { resize: vertical; min-height: 3rem; }
.dlg-sources { display: flex; gap: 0.4rem; }
.dlg-sources button {
flex: 1;
padding: 0.45rem 0.2rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--panel-soft);
color: var(--text);
cursor: pointer;
font-size: 0.82rem;
}
.dlg-sources button.active { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
.dlg-hint { margin: 0; font-size: 0.75rem; color: var(--text-faint); }
.dlg-actions { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 0.3rem; }
.dlg-actions button {
padding: 0.45rem 0.9rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--panel-soft);
color: var(--text);
cursor: pointer;
}
.dlg-create { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
.dlg-create:disabled { opacity: 0.45; cursor: default; }
</style>

View File

@@ -0,0 +1 @@
Die Quelle wurde von einer Website gecrawlt und liegt als Text-Dateien im Ordner {project} (Seiten als .txt, PDFs als gleichnamige .txt — lies IMMER die .txt). Jede Seiten-Datei beginnt mit einer `QUELLE:`-Zeile (Ursprungs-URL). Verschaffe dir mit Bash (ls/find) einen Überblick und lies die Dateien mit dem Read-Tool. ZIEL: Klausur-Vorbereitung. Erfasse die PRÜFUNGSRELEVANTEN Bausteine: Definitionen, Kernkonzepte, Verfahren/Algorithmen, Formeln, typische Aufgaben. Nur was in den Dateien steht — nichts Erfundenes, kein externes Wissen dazu.

View File

@@ -1 +1 @@
Das Thema ist das Projekt unter {project}. Verschaffe dir mit Bash (ls/find) einen Überblick und lies README, Doku-Ordner und den relevanten Quellcode mit dem Read-Tool. PDFs liegen als gleichnamige .txt-Dateien vor — lies IMMER die .txt, nie das PDF. Die Bausteine müssen das echte Projekt widerspiegeln, nichts Erfundenes.
Das Thema ist das Projekt unter {project}. Verschaffe dir mit Bash (ls/find) einen Überblick und lies README, Doku-Ordner und den relevanten Quellcode mit dem Read-Tool. PDFs liegen als gleichnamige .txt-Dateien vor — lies IMMER die .txt, nie das PDF. ZIEL: das Projekt VERSTEHEN, nicht nachprogrammieren. Erfasse die Bausteine, die erklären, wie es funktioniert: die Features/Funktionen, die Architektur und Komponenten, die wichtigen Abläufe/Flows (z.B. Request→Response, Datenfluss), die zentralen Konzepte und Entscheidungen. Nicht Zeile-für-Zeile-Code, sondern das Verständnis-Gerüst. Die Bausteine müssen das echte Projekt widerspiegeln, nichts Erfundenes.

View File

@@ -0,0 +1 @@
Das Lernmaterial liegt im Ordner {project} (z.B. Vorlesungsfolien, Skripte, Übungsblätter; PDFs liegen als gleichnamige .txt-Dateien vor — lies IMMER die .txt, nie das PDF). Verschaffe dir mit Bash (ls/find) einen Überblick und lies die Dateien mit dem Read-Tool. ZIEL: Klausur-Vorbereitung. Erfasse die PRÜFUNGSRELEVANTEN Bausteine: Definitionen, Kernkonzepte, Verfahren/Algorithmen, Formeln und Sätze, typische Aufgaben- und Fragetypen. Die Bausteine müssen das echte Material widerspiegeln — nur was darin vorkommt, nichts Erfundenes, kein externes Lehrbuchwissen dazuerfinden.