This commit is contained in:
team3
2026-06-07 15:17:50 +02:00
parent 1649a046d2
commit af5c0950ea
16 changed files with 1897 additions and 34 deletions

View File

@@ -1,3 +1,5 @@
import json
import aiosqlite import aiosqlite
from config import DB_PATH from config import DB_PATH
@@ -32,6 +34,19 @@ CREATE TABLE IF NOT EXISTS topics (
) )
""" """
CREATE_ELEMENTS = """
CREATE TABLE IF NOT EXISTS elements (
id TEXT PRIMARY KEY,
topic TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
examples TEXT NOT NULL DEFAULT '[]',
hints TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
_db: aiosqlite.Connection | None = None _db: aiosqlite.Connection | None = None
@@ -48,6 +63,7 @@ async def init_db():
await db.execute(CREATE_GUIDES) await db.execute(CREATE_GUIDES)
await db.execute(CREATE_PROGRESS) await db.execute(CREATE_PROGRESS)
await db.execute(CREATE_TOPICS) await db.execute(CREATE_TOPICS)
await db.execute(CREATE_ELEMENTS)
try: # Migration für Bestands-DBs ohne step-Spalte try: # Migration für Bestands-DBs ohne step-Spalte
await db.execute("ALTER TABLE guides ADD COLUMN step INTEGER") await db.execute("ALTER TABLE guides ADD COLUMN step INTEGER")
except aiosqlite.OperationalError: except aiosqlite.OperationalError:
@@ -138,6 +154,63 @@ async def delete_topic(name: str) -> None:
await db.commit() await db.commit()
# --- Elemente ---
def _element_row(row, cursor) -> dict:
el = _row_to_dict(row, cursor)
el["examples"] = json.loads(el["examples"] or "[]")
el["hints"] = json.loads(el["hints"] or "[]")
return el
async def create_element(element: dict) -> dict:
db = await get_db()
await db.execute(
"""INSERT INTO elements (id, topic, title, description, examples, hints, created_at, updated_at)
VALUES (:id, :topic, :title, :description, :examples, :hints, :created_at, :updated_at)""",
{**element, "examples": json.dumps(element["examples"], ensure_ascii=False),
"hints": json.dumps(element["hints"], ensure_ascii=False)},
)
await db.commit()
return element
async def list_elements(topic: str) -> list[dict]:
db = await get_db()
cursor = await db.execute(
"SELECT * FROM elements WHERE topic = ? ORDER BY updated_at DESC", (topic,)
)
rows = await cursor.fetchall()
return [_element_row(row, cursor) for row in rows]
async def get_element(element_id: str) -> dict | None:
db = await get_db()
cursor = await db.execute("SELECT * FROM elements WHERE id = ?", (element_id,))
row = await cursor.fetchone()
if row is None:
return None
return _element_row(row, cursor)
async def update_element(element_id: str, **fields) -> None:
for key in ("examples", "hints"):
if key in fields:
fields[key] = json.dumps(fields[key], ensure_ascii=False)
sets = ", ".join(f"{k} = :{k}" for k in fields)
fields["id"] = element_id
db = await get_db()
await db.execute(f"UPDATE elements SET {sets} WHERE id = :id", fields)
await db.commit()
async def delete_element(element_id: str) -> bool:
db = await get_db()
cursor = await db.execute("DELETE FROM elements WHERE id = ?", (element_id,))
await db.commit()
return cursor.rowcount > 0
# --- Kapitel-Fortschritt --- # --- Kapitel-Fortschritt ---
async def list_progress(guide_id: str) -> list[str]: async def list_progress(guide_id: str) -> list[str]:

View File

@@ -1350,3 +1350,232 @@ async def chat_with_guide(topic: str, format_name: str, section: str, outline: s
return reply or "Entschuldigung, ich habe keine Antwort erhalten." return reply or "Entschuldigung, ich habe keine Antwort erhalten."
except Exception: except Exception:
return "Entschuldigung, das hat nicht geklappt. Bitte versuche es erneut." return "Entschuldigung, das hat nicht geklappt. Bitte versuche es erneut."
# --- Elemente (persönliche Zusammenfassung) ---
def _parse_json_text(text: str):
"""Parst JSON aus KI-Output (Code-Fences und Drumherum-Text tolerant).
Repariert unescapte Anführungszeichen in Strings (z. B. MiniMax: "Titel „p" geändert"):
das letzte `"` vor der Fehlerstelle escapen und erneut parsen.
"""
text = re.sub(r"^```(?:json)?\s*|\s*```$", "", (text or "").strip())
start, end = text.find("{"), text.rfind("}")
if start == -1 or end <= start:
return None
candidate = text[start:end + 1]
for _ in range(20):
try:
return json.loads(candidate)
except json.JSONDecodeError as e:
if not e.msg.startswith(("Expecting ',' delimiter", "Expecting ':' delimiter")):
return None
q = candidate.rfind('"', 0, e.pos)
if q <= 0:
return None
candidate = candidate[:q] + '\\"' + candidate[q + 1:]
except Exception:
return None
return None
def _element_fields(data: dict) -> dict | None:
"""Validiert KI-Element-JSON und normalisiert auf die DB-Felder."""
if not isinstance(data, dict):
return None
title = str(data.get("title", "")).strip()
if not title:
return None
listen = {}
for key in ("examples", "hints"):
raw = data.get(key, [])
listen[key] = [str(e).strip() for e in raw if str(e).strip()] if isinstance(raw, list) else []
return {
"title": title[:200],
"description": str(data.get("description", "")).strip(),
"examples": listen["examples"][:5],
"hints": listen["hints"][:5],
}
def _topic_context(topic: str, limit: int = 12000) -> str:
"""Bausteine + Guide-Inhalte des Themas als Kontext-Text (gekürzt)."""
parts: list[str] = []
bp = bausteine_path(topic)
if bp.exists():
parts.append(bp.read_text(encoding="utf-8"))
for fmt in ("FullGuide", "Guide", "MiniGuide", "OnePager"):
content = _json_datei(guide_content_path(topic, fmt))
if content:
for ch in content.get("chapters", []):
for sec in ch.get("sections", []):
parts.append(sec if isinstance(sec, str) else json.dumps(sec, ensure_ascii=False))
break # bester verfügbarer Guide reicht
text = "\n\n".join(parts).strip()
return text[:limit] if text else "(kein Material vorhanden)"
async def generate_element(topic: str, hint: str, provider: str = DEFAULT_PROVIDER) -> dict:
"""Erstellt Element-Felder per KI. Fallback: nur Titel aus dem Stichwort."""
fallback = {"title": hint.strip() or "Neues Element", "description": "", "examples": [], "hints": []}
try:
prompt = _prompt(
"Element-Create",
topic=topic, hint=hint.strip() or "(keins — wähle selbst ein Kernkonzept)",
context=_topic_context(topic),
)
returncode, stdout, _ = await run_agent(
"element-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none"
)
if returncode != 0:
return fallback
return _element_fields(_parse_json_text(stdout)) or fallback
except Exception:
return fallback
def _fence(content: str) -> str:
"""Beispiele müssen Codeblöcke sein — fehlende Fences nachrüsten."""
if content.startswith("```"):
return content
return f"```\n{content}\n```"
def _parse_suggestions(stdout: str) -> list[dict] | None:
"""Validiert Vorschlags-JSON aus KI-Output. None bei ungültigem JSON."""
data = _parse_json_text(stdout)
if not isinstance(data, dict):
return None
suggestions = []
for s in data.get("suggestions", []):
if not isinstance(s, dict):
continue
text = str(s.get("text", "")).strip()
target = s.get("target")
content = str(s.get("content", "")).strip()
if text and content and target in ("description", "examples", "hints"):
if target == "examples":
content = _fence(content)
suggestions.append({"text": text, "target": target, "content": content})
return suggestions
async def check_element(element: dict, provider: str = DEFAULT_PROVIDER) -> list[dict] | None:
"""Zweischrittige Prüfung auf fehlende Infos: Recherche → Verifizieren. None bei Fehler."""
try:
element_json = json.dumps(
{k: element[k] for k in ("title", "description", "examples", "hints")},
ensure_ascii=False, indent=1,
)
context = _topic_context(element["topic"])
# Schritt 1: Recherche — breit Kandidaten sammeln
prompt = _prompt("Element-Check", topic=element["topic"], element_json=element_json, context=context)
returncode, stdout, _ = await run_agent(
"element-check-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none"
)
if returncode != 0:
return None
candidates = _parse_suggestions(stdout)
if candidates is None:
return None
if not candidates:
return []
# Schritt 2: Verifizieren — nur Wichtiges, nicht Redundantes durchlassen
prompt = _prompt(
"Element-Verify",
topic=element["topic"], element_json=element_json,
candidates_json=json.dumps({"suggestions": candidates}, ensure_ascii=False, indent=1),
context=context,
)
returncode, stdout, _ = await run_agent(
"element-verify-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none"
)
if returncode != 0:
return None
return _parse_suggestions(stdout)
except Exception:
return None
async def _element_rewrite(template: str, element: dict, provider: str, **extra) -> tuple[str, dict | None]:
"""Gemeinsames Muster: Element + Template → (Antwort, neue Felder|None)."""
fehler = "Entschuldigung, das hat nicht geklappt. Bitte versuche es erneut."
try:
element_json = json.dumps(
{k: element[k] for k in ("title", "description", "examples", "hints")},
ensure_ascii=False, indent=1,
)
prompt = _prompt(template, topic=element["topic"], element_json=element_json, **extra)
returncode, stdout, _ = await run_agent(
f"element-{template.lower()}-" + str(uuid.uuid4()), prompt, 240,
provider=provider, role="fast", capabilities="none",
)
if returncode != 0:
return fehler, None
data = _parse_json_text(stdout)
if not isinstance(data, dict):
return fehler, None
fields = _element_fields(data.get("element"))
reply = str(data.get("reply", "")).strip() or ("Erledigt." if fields else fehler)
return reply, fields
except Exception:
return fehler, None
async def chat_with_element(element: dict, messages: list[dict], provider: str = DEFAULT_PROVIDER) -> tuple[str, dict | None]:
"""Passt ein Element per Chat an. Gibt (Antwort, neue Felder|None) zurück."""
transcript = "\n".join(
f"{'Nutzer' if m.get('role') == 'user' else 'Assistent'}: {m.get('content', '')}"
for m in messages
)
return await _element_rewrite("Element-Chat", element, provider, transcript=transcript)
async def style_element(element: dict, provider: str = DEFAULT_PROVIDER) -> list[dict] | None:
"""Prüft ein Element auf die Stil-Regeln und schlägt Änderungen vor. None bei Fehler."""
try:
element_json = json.dumps(
{k: element[k] for k in ("title", "description", "examples", "hints")},
ensure_ascii=False, indent=1,
)
prompt = _prompt("Element-Stil", topic=element["topic"], element_json=element_json)
returncode, stdout, _ = await run_agent(
"element-stil-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none"
)
if returncode != 0:
return None
data = _parse_json_text(stdout)
if not isinstance(data, dict):
return None
changes = []
for c in data.get("changes", []):
if not isinstance(c, dict):
continue
text = str(c.get("text", "")).strip()
action = c.get("action")
target = c.get("target")
index = c.get("index")
content = str(c.get("content", "")).strip()
if not text or action not in ("entfernen", "anpassen", "hinzufuegen"):
continue
if target not in ("title", "description", "examples", "hints"):
continue
if action in ("anpassen", "hinzufuegen") and not content:
continue
if action == "entfernen" and target not in ("examples", "hints"):
continue
# Index nur für anpassen/entfernen in Listen-Feldern; muss existieren
if target in ("examples", "hints") and action in ("anpassen", "entfernen"):
if not isinstance(index, int) or not (0 <= index < len(element[target])):
continue
else:
index = None
if target == "examples" and action in ("anpassen", "hinzufuegen"):
content = _fence(content)
changes.append({"text": text, "action": action, "target": target, "index": index, "content": content})
return changes
except Exception:
return None

View File

@@ -79,6 +79,66 @@ class GuideChatResponse(BaseModel):
reply: str reply: str
class ElementResponse(BaseModel):
id: str
topic: str
title: str
description: str = ""
examples: list[str] = []
hints: list[str] = []
created_at: str
updated_at: str
class ElementCreateRequest(BaseModel):
topic: str = Field(min_length=1, max_length=100)
hint: str = Field(default="", max_length=500)
provider: ProviderType = "claude"
class ElementUpdateRequest(BaseModel):
title: str | None = Field(default=None, max_length=200)
description: str | None = None
examples: list[str] | None = None
hints: list[str] | None = None
class ElementCheckRequest(BaseModel):
provider: ProviderType = "claude"
class ElementSuggestion(BaseModel):
text: str
target: Literal["description", "examples", "hints"]
content: str
class ElementCheckResponse(BaseModel):
suggestions: list[ElementSuggestion]
class ElementStyleChange(BaseModel):
text: str
action: Literal["entfernen", "anpassen", "hinzufuegen"]
target: Literal["title", "description", "examples", "hints"]
index: int | None = None
content: str = ""
class ElementStyleResponse(BaseModel):
changes: list[ElementStyleChange]
class ElementChatRequest(BaseModel):
messages: list[ChatMessage] = Field(min_length=1)
provider: ProviderType = "claude"
class ElementChatResponse(BaseModel):
reply: str
element: ElementResponse
class ProgressUpdate(BaseModel): class ProgressUpdate(BaseModel):
chapter: str = Field(min_length=1, max_length=100) chapter: str = Field(min_length=1, max_length=100)
done: bool done: bool

View File

@@ -13,16 +13,20 @@ from database import (
create_guide, delete_guide, get_guide, list_guides, create_guide, delete_guide, get_guide, list_guides,
create_topic, list_topics as db_list_topics, delete_topic, create_topic, list_topics as db_list_topics, delete_topic,
list_progress, set_progress, delete_progress, list_progress, set_progress, delete_progress,
create_element, list_elements, get_element, update_element, delete_element,
) )
from generator import ( from generator import (
generate_guide, cancel_guide, chat_with_guide, guide_slot_dateien, generate_guide, cancel_guide, chat_with_guide, guide_slot_dateien,
generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine, generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine,
generate_element, chat_with_element, check_element, style_element,
) )
from models import ( from models import (
GuideCreateRequest, GuideResponse, GuideCreateRequest, GuideResponse,
TopicCreateRequest, TopicCreateRequest,
BausteineCreateRequest, BausteineStatusResponse, BausteineCreateRequest, BausteineStatusResponse,
GuideChatRequest, GuideChatResponse, GuideChatRequest, GuideChatResponse,
ElementCreateRequest, ElementChatRequest, ElementChatResponse, ElementResponse,
ElementUpdateRequest, ElementCheckRequest, ElementCheckResponse, ElementStyleResponse,
ProgressUpdate, ProgressResponse, ProjectResponse, ProviderInfo, ProgressUpdate, ProgressResponse, ProjectResponse, ProviderInfo,
) )
from paths import bausteine_path, bausteine_topics, guide_content_path, project_dir, topic_dir from paths import bausteine_path, bausteine_topics, guide_content_path, project_dir, topic_dir
@@ -263,6 +267,75 @@ async def guide_chat(guide_id: str, req: GuideChatRequest):
return {"reply": reply} return {"reply": reply}
# --- Elemente (persönliche Zusammenfassung) ---
@router.get("/elements", response_model=list[ElementResponse])
async def get_elements(topic: str):
return await list_elements(topic)
@router.post("/elements", response_model=ElementResponse)
async def post_element(req: ElementCreateRequest):
fields = await generate_element(req.topic, req.hint, provider=req.provider)
now = datetime.now(timezone.utc).isoformat()
element = {"id": str(uuid.uuid4()), "topic": req.topic, **fields, "created_at": now, "updated_at": now}
await create_element(element)
return element
@router.post("/elements/{element_id}/chat", response_model=ElementChatResponse)
async def element_chat(element_id: str, req: ElementChatRequest):
element = await get_element(element_id)
if element is None:
raise HTTPException(404, "Element nicht gefunden")
reply, fields = await chat_with_element(element, [m.model_dump() for m in req.messages], provider=req.provider)
if fields:
now = datetime.now(timezone.utc).isoformat()
await update_element(element_id, **fields, updated_at=now)
element = await get_element(element_id)
return {"reply": reply, "element": element}
@router.put("/elements/{element_id}", response_model=ElementResponse)
async def put_element(element_id: str, req: ElementUpdateRequest):
if await get_element(element_id) is None:
raise HTTPException(404, "Element nicht gefunden")
fields = req.model_dump(exclude_unset=True, exclude_none=True)
if fields:
now = datetime.now(timezone.utc).isoformat()
await update_element(element_id, **fields, updated_at=now)
return await get_element(element_id)
@router.post("/elements/{element_id}/style", response_model=ElementStyleResponse)
async def element_style(element_id: str, req: ElementCheckRequest):
element = await get_element(element_id)
if element is None:
raise HTTPException(404, "Element nicht gefunden")
changes = await style_element(element, provider=req.provider)
if changes is None:
raise HTTPException(502, "Stil-Prüfung fehlgeschlagen — bitte erneut versuchen")
return {"changes": changes}
@router.post("/elements/{element_id}/check", response_model=ElementCheckResponse)
async def element_check(element_id: str, req: ElementCheckRequest):
element = await get_element(element_id)
if element is None:
raise HTTPException(404, "Element nicht gefunden")
suggestions = await check_element(element, provider=req.provider)
if suggestions is None:
raise HTTPException(502, "Prüfung fehlgeschlagen — bitte erneut versuchen")
return {"suggestions": suggestions}
@router.delete("/elements/{element_id}")
async def remove_element(element_id: str):
if not await delete_element(element_id):
raise HTTPException(404, "Element nicht gefunden")
return {"ok": True}
@router.post("/guides/{guide_id}/cancel") @router.post("/guides/{guide_id}/cancel")
async def cancel(guide_id: str): async def cancel(guide_id: str):
cancelled = await cancel_guide(guide_id) cancelled = await cancel_guide(guide_id)

View File

@@ -3,6 +3,8 @@ import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { fetchGuides, fetchTopics, createTopic as apiCreateTopic, deleteTopic as apiDeleteTopic, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel, fetchBausteineStatus, fetchActiveBausteine, createBausteine as apiCreateBausteine, cancelBausteine as apiCancelBausteine, deleteBausteine as apiDeleteBausteine, fetchProjects, deleteProject as apiDeleteProject, fetchProviders, fetchStats, fetchTopicFortschritt } from './api.js' import { fetchGuides, fetchTopics, createTopic as apiCreateTopic, deleteTopic as apiDeleteTopic, createGuide as apiCreate, deleteGuide, cancelGuide as apiCancel, fetchBausteineStatus, fetchActiveBausteine, createBausteine as apiCreateBausteine, cancelBausteine as apiCancelBausteine, deleteBausteine as apiDeleteBausteine, fetchProjects, deleteProject as apiDeleteProject, fetchProviders, fetchStats, fetchTopicFortschritt } from './api.js'
import TopicSidebar from './components/TopicSidebar.vue' import TopicSidebar from './components/TopicSidebar.vue'
import TopicDetail from './components/TopicDetail.vue' import TopicDetail from './components/TopicDetail.vue'
import ElementsSidebar from './components/ElementsSidebar.vue'
import ElementsOverview from './components/ElementsOverview.vue'
const guides = ref([]) const guides = ref([])
const projects = ref([]) const projects = ref([])
@@ -23,6 +25,11 @@ const provider = ref(localStorage.getItem('provider') || 'claude')
const providers = ref([]) const providers = ref([])
const stats = ref(null) const stats = ref(null)
const fortschritt = ref({}) const fortschritt = ref({})
const elementsOpen = ref(false) // rechte Sidebar
const elementsView = ref(false) // Übersicht im Hauptbereich
const elementsVersion = ref(0) // Erhöhung = Übersicht neu laden
const elementOpenId = ref(null) // Element aus Übersicht in Sidebar öffnen
const elementOpenTick = ref(0)
async function loadStats() { async function loadStats() {
try { try {
@@ -182,6 +189,9 @@ function selectTopic(topic) {
selectedTopic.value = topic selectedTopic.value = topic
previewGuide.value = null previewGuide.value = null
sidebarSticky.value = false sidebarSticky.value = false
elementsOpen.value = false
elementsView.value = false
elementOpenId.value = null
localStorage.setItem('lastTopic', topic) localStorage.setItem('lastTopic', topic)
loadBausteine() loadBausteine()
nextTick(autoPreview) nextTick(autoPreview)
@@ -243,6 +253,19 @@ async function handleDeleteProject(name) {
function handlePreview(guide) { function handlePreview(guide) {
previewGuide.value = guide previewGuide.value = guide
elementsView.value = false
}
function handleOpenElements() {
if (!selectedTopic.value) return
elementsView.value = true
elementsOpen.value = true
}
function handleOpenElementDetail(el) {
elementOpenId.value = el.id
elementOpenTick.value++
elementsOpen.value = true
} }
async function handleDeleteGuide(guideId, slots = false) { async function handleDeleteGuide(guideId, slots = false) {
@@ -353,19 +376,36 @@ onUnmounted(() => {
@deleteGuide="handleDeleteGuide" @deleteGuide="handleDeleteGuide"
@dismissError="handleDismissError" @dismissError="handleDismissError"
@preview="handlePreview" @preview="handlePreview"
@openElements="handleOpenElements"
@togglePin="toggleSidebarPin" @togglePin="toggleSidebarPin"
@sidebarLeave="onSidebarLeave" @sidebarLeave="onSidebarLeave"
/> />
<ElementsOverview
v-if="selectedTopic && elementsView"
:topic="selectedTopic"
:version="elementsVersion"
@open="handleOpenElementDetail"
/>
<TopicDetail <TopicDetail
v-if="selectedTopic" v-else-if="selectedTopic"
:previewGuide="previewGuide" :previewGuide="previewGuide"
:dark="darkMode" :dark="darkMode"
:provider="provider" :provider="provider"
@progressChanged="loadStats(); loadBausteine()" @progressChanged="loadStats(); loadBausteine()"
@openElements="elementsOpen = true"
/> />
<div v-else class="empty-main"> <div v-else class="empty-main">
<p>Thema in der Sidebar anlegen oder auswählen.</p> <p>Thema in der Sidebar anlegen oder auswählen.</p>
</div> </div>
<ElementsSidebar
v-if="elementsOpen && selectedTopic"
:topic="selectedTopic"
:provider="provider"
:openId="elementOpenId"
:openTick="elementOpenTick"
@close="elementsOpen = false"
@changed="elementsVersion++"
/>
</div> </div>
</template> </template>

View File

@@ -118,3 +118,59 @@ export async function chatGuide(id, { section, outline, messages, provider = 'cl
}) })
return res.json() return res.json()
} }
export async function fetchElements(topic) {
const res = await fetch(`${BASE}/elements?topic=${encodeURIComponent(topic)}`)
return res.json()
}
export async function createElement(topic, hint = '', provider = 'claude') {
const res = await fetch(`${BASE}/elements`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic, hint, provider }),
})
return res.json()
}
export async function chatElement(id, messages, provider = 'claude') {
const res = await fetch(`${BASE}/elements/${id}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages, provider }),
})
return res.json()
}
export async function deleteElement(id) {
await fetch(`${BASE}/elements/${id}`, { method: 'DELETE' })
}
export async function updateElement(id, fields) {
const res = await fetch(`${BASE}/elements/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(fields),
})
return res.json()
}
export async function styleElement(id, provider = 'claude') {
const res = await fetch(`${BASE}/elements/${id}/style`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider }),
})
if (!res.ok) throw new Error(`Stil-Prüfung fehlgeschlagen (${res.status})`)
return res.json()
}
export async function checkElement(id, provider = 'claude') {
const res = await fetch(`${BASE}/elements/${id}/check`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider }),
})
if (!res.ok) throw new Error(`Prüfung fehlgeschlagen (${res.status})`)
return res.json()
}

View File

@@ -0,0 +1,205 @@
<script setup>
import { ref, watch } from 'vue'
import { fetchElements } from '../api.js'
import { renderMarkdown } from '../markdown.js'
const props = defineProps({
topic: { type: String, required: true },
version: { type: Number, default: 0 }, // Erhöhung = Elemente neu laden
})
const emit = defineEmits(['open'])
const elements = ref([])
watch([() => props.topic, () => props.version], load, { immediate: true })
async function load() {
try {
elements.value = await fetchElements(props.topic)
} catch (e) {
console.error('Fehler beim Laden der Elemente:', e)
}
}
function plain(text) {
return (text || '').replace(/```[a-z]*\n?/g, '').replace(/[`*_#]/g, '')
}
</script>
<template>
<div class="elements-overview">
<div class="overview-scroll">
<div class="overview-content">
<header class="overview-head">
<h1>{{ topic }}</h1>
<span class="overview-format">Elemente</span>
</header>
<p v-if="!elements.length" class="overview-empty">
Noch keine Elemente. Rechts in der Sidebar Stichwort eingeben und + klicken.
</p>
<div class="element-grid">
<article
v-for="el in elements"
:key="el.id"
class="element-card"
@click="emit('open', el)"
>
<h3>{{ plain(el.title) }}</h3>
<div class="markdown" v-html="renderMarkdown(el.description)"></div>
<div v-for="(ex, i) in el.examples" :key="i" class="markdown el-example" v-html="renderMarkdown(ex)"></div>
<div v-if="el.hints.length" class="el-hints-block">
<h4>Hinweise</h4>
<ul>
<li v-for="(h, i) in el.hints" :key="i" class="markdown" v-html="renderMarkdown(h)"></li>
</ul>
</div>
</article>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.elements-overview {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
background: var(--bg-preview);
}
.overview-scroll {
flex: 1;
overflow-y: auto;
}
.overview-content {
max-width: 1000px;
margin: 0 auto;
padding: 2.5rem 2rem 4rem;
}
.overview-head {
display: flex;
align-items: baseline;
gap: 0.8rem;
margin-bottom: 1.5rem;
}
.overview-head h1 {
margin: 0;
font-size: 2.2rem;
color: var(--text);
}
.overview-format {
font-size: 1rem;
font-weight: 600;
color: var(--text-faint);
}
.overview-empty {
color: var(--text-muted);
}
.element-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 1rem;
align-items: start;
}
.element-card {
background: var(--panel);
border: 1px solid var(--border);
border-top: 3px solid var(--accent);
border-radius: 10px;
padding: 1rem 1.1rem;
cursor: pointer;
transition: box-shadow 0.15s, transform 0.15s;
}
.element-card:hover {
box-shadow: 0 4px 16px var(--shadow);
transform: translateY(-1px);
}
.element-card h3 {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: var(--text);
}
.el-example {
margin-top: 0.5rem;
}
.el-hints-block {
margin-top: 0.7rem;
}
.el-hints-block h4 {
margin: 0 0 0.3rem;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-faint);
}
.el-hints-block ul {
margin: 0;
padding-left: 1.1rem;
}
.el-hints-block li {
font-size: 0.85rem;
line-height: 1.5;
color: var(--text);
margin-bottom: 0.2rem;
}
/* Markdown im Guide-Stil */
.markdown {
font-size: 0.9rem;
line-height: 1.55;
color: var(--text);
}
.markdown :deep(p) {
margin: 0 0 0.5em;
}
.markdown :deep(p:last-child) {
margin-bottom: 0;
}
.markdown :deep(code) {
background: var(--border);
padding: 1px 4px;
border-radius: 4px;
font-family: "SF Mono", Consolas, monospace;
font-size: 0.85em;
overflow-wrap: anywhere;
}
.markdown :deep(pre) {
background: var(--code-bg, #1e2330);
color: var(--code-fg, #e6e8ee);
padding: 10px 12px;
border-radius: 8px;
/* Umbrechen statt horizontal scrollen — Scrollbar verdeckt sonst die Code-Zeile */
white-space: pre-wrap;
overflow-wrap: anywhere;
margin: 0.4em 0;
}
.markdown :deep(pre code) {
background: none;
padding: 0;
color: inherit;
font-size: 0.85em;
}
</style>

View File

@@ -0,0 +1,986 @@
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
import { fetchElements, createElement, chatElement, deleteElement, updateElement, checkElement, styleElement } from '../api.js'
import { renderMarkdown } from '../markdown.js'
const props = defineProps({
topic: { type: String, required: true },
provider: { type: String, default: 'claude' },
openId: { type: String, default: null }, // Element-ID, die geöffnet werden soll
openTick: { type: Number, default: 0 }, // Erhöhung = openId (erneut) öffnen
})
const emit = defineEmits(['close', 'changed'])
const elements = ref([])
const query = ref('')
const creating = ref(false)
const selected = ref(null)
watch(() => props.topic, load, { immediate: true })
async function load() {
selected.value = null
query.value = ''
try {
elements.value = await fetchElements(props.topic)
} catch (e) {
console.error('Fehler beim Laden der Elemente:', e)
}
openFromProp()
}
// Aus der Übersicht im Hauptbereich angeklicktes Element öffnen
watch(() => props.openTick, openFromProp)
function openFromProp() {
if (!props.openId) return
const el = elements.value.find((e) => e.id === props.openId)
if (el) select(el)
}
// Markdown-Zeichen für Titel und Listen-Vorschau entfernen
function plain(text) {
return (text || '').replace(/```[a-z]*\n?/g, '').replace(/[`*_#]/g, '')
}
const filtered = computed(() => {
const q = query.value.trim().toLowerCase()
if (!q) return elements.value
return elements.value.filter(
(el) => el.title.toLowerCase().includes(q) || el.description.toLowerCase().includes(q),
)
})
async function add() {
if (creating.value) return
creating.value = true
try {
const el = await createElement(props.topic, query.value.trim(), props.provider)
elements.value.unshift(el)
query.value = ''
emit('changed')
} catch (e) {
console.error('Fehler beim Erstellen des Elements:', e)
} finally {
creating.value = false
}
}
function select(el) {
selected.value = el
messages.value = []
input.value = ''
resetCheck()
nextTick(() => inputEl.value?.focus())
}
function back() {
selected.value = null
messages.value = []
resetCheck()
}
// --- KI-Prüfung auf fehlende Infos (Ergebnisse landen als Inline-Vorschläge) ---
const checking = ref(false)
const statusMsg = ref(null)
function resetCheck() {
checking.value = false
statusMsg.value = null
resetStyle()
}
let checkRun = 0 // laufende Prüfung identifizieren; Abbruch ignoriert ihr Ergebnis
async function runCheck() {
if (!selected.value) return
if (checking.value) { // zweiter Klick = abbrechen
checkRun++
checking.value = false
return
}
const run = ++checkRun
checking.value = true
statusMsg.value = null
try {
const res = await checkElement(selected.value.id, props.provider)
if (run !== checkRun) return // abgebrochen oder neue Prüfung gestartet
const mapped = res.suggestions.map((s) => ({
text: s.text, action: 'hinzufuegen', target: s.target, index: null, content: s.content,
}))
if (mapped.length) styleChanges.value = [...(styleChanges.value || []), ...mapped]
else statusMsg.value = 'Keine wichtigen Lücken gefunden.'
} catch (e) {
if (run !== checkRun) return
console.error('Prüfung fehlgeschlagen:', e)
statusMsg.value = 'Prüfung fehlgeschlagen — bitte erneut versuchen.'
} finally {
if (run === checkRun) checking.value = false
}
}
// --- Stil-Prüfung: KI schlägt Änderungen vor, Nutzer bestätigt ---
const styleChanges = ref(null) // null = noch nicht geprüft
const styling = ref(false)
const applyingStyle = ref(false)
const ACTION_LABELS = { entfernen: 'Entfernen:', anpassen: 'Anpassen:', hinzufuegen: 'Hinzufügen:' }
let styleRun = 0
function resetStyle() {
styleChanges.value = null
styling.value = false
applyingStyle.value = false
}
async function runStyle() {
if (!selected.value) return
if (styling.value) { // zweiter Klick = abbrechen
styleRun++
styling.value = false
return
}
const run = ++styleRun
styling.value = true
statusMsg.value = null
try {
const res = await styleElement(selected.value.id, props.provider)
if (run !== styleRun) return
if (res.changes.length) styleChanges.value = [...(styleChanges.value || []), ...res.changes]
else statusMsg.value = 'Stil passt bereits.'
} catch (e) {
if (run !== styleRun) return
console.error('Stil-Prüfung fehlgeschlagen:', e)
statusMsg.value = 'Stil-Prüfung fehlgeschlagen — bitte erneut versuchen.'
} finally {
if (run === styleRun) styling.value = false
}
}
// Vorschläge am Ziel-Ort anzeigen: anpassen/entfernen beim betroffenen Eintrag …
function styleAt(target, index = null) {
if (!styleChanges.value) return []
return styleChanges.value
.map((c, i) => [i, c])
.filter(([, c]) => c.target === target && c.index === index && c.action !== 'hinzufuegen')
}
// … Ergänzungen am Ende der jeweiligen Sektion
function styleAdds(target) {
if (!styleChanges.value) return []
return styleChanges.value
.map((c, i) => [i, c])
.filter(([, c]) => c.target === target && c.action === 'hinzufuegen')
}
function dismissStyleChange(i) {
styleChanges.value = styleChanges.value.filter((_, j) => j !== i)
}
async function applyStyleChange(i) {
if (applyingStyle.value || !selected.value) return
const c = styleChanges.value[i]
applyingStyle.value = true
try {
const fields = {
title: selected.value.title,
description: selected.value.description,
examples: [...selected.value.examples],
hints: [...selected.value.hints],
}
if (c.action === 'entfernen') fields[c.target].splice(c.index, 1)
else if (c.action === 'hinzufuegen') {
if (c.target === 'title') fields.title = c.content
else if (c.target === 'description') fields.description += '\n\n' + c.content
else fields[c.target].push(c.content)
} else if (c.target === 'title' || c.target === 'description') fields[c.target] = c.content
else fields[c.target][c.index] = c.content
const updated = await updateElement(selected.value.id, fields)
selected.value = updated
const idx = elements.value.findIndex((e) => e.id === updated.id)
if (idx !== -1) elements.value.splice(idx, 1, updated)
// Rest-Vorschläge behalten; Indizes hinter einer Entfernung rücken auf
const rest = styleChanges.value.filter((_, j) => j !== i)
if (c.action === 'entfernen') {
for (const r of rest) {
if (r.target === c.target && r.index !== null && r.index > c.index) r.index--
}
}
styleChanges.value = rest
emit('changed')
} catch (e) {
console.error('Übernehmen fehlgeschlagen:', e)
} finally {
applyingStyle.value = false
}
}
// Inline-Bestätigung wie in TopicSidebar: erster Klick „Sicher?", zweiter löscht
const pendingDelete = ref(null)
let deleteTimer = null
async function confirmDelete(el) {
clearTimeout(deleteTimer)
if (pendingDelete.value === el.id) {
pendingDelete.value = null
await deleteElement(el.id)
elements.value = elements.value.filter((e) => e.id !== el.id)
if (selected.value?.id === el.id) back()
emit('changed')
} else {
pendingDelete.value = el.id
deleteTimer = setTimeout(() => { pendingDelete.value = null }, 3000)
}
}
// --- Chat zum Anpassen des Elements ---
const messages = ref([])
const input = ref('')
const loading = ref(false)
const messagesEl = ref(null)
const inputEl = ref(null)
async function scrollToBottom() {
await nextTick()
if (messagesEl.value) messagesEl.value.scrollTop = messagesEl.value.scrollHeight
}
async function send() {
const text = input.value.trim()
if (!text || loading.value || !selected.value) return
messages.value.push({ role: 'user', content: text })
input.value = ''
loading.value = true
scrollToBottom()
try {
const res = await chatElement(selected.value.id, messages.value, props.provider)
messages.value.push({ role: 'assistant', content: res.reply || '…' })
if (res.element) {
selected.value = res.element
const i = elements.value.findIndex((e) => e.id === res.element.id)
if (i !== -1) elements.value.splice(i, 1, res.element)
emit('changed')
}
} catch {
messages.value.push({ role: 'assistant', content: 'Fehler bei der Anfrage.' })
} finally {
loading.value = false
scrollToBottom()
nextTick(() => inputEl.value?.focus())
}
}
</script>
<template>
<aside class="elements-sidebar">
<header class="el-header">
<button v-if="selected" class="el-back" title="Zur Liste" @click="back"></button>
<span class="el-title">{{ selected ? plain(selected.title) : 'Elemente' }}</span>
<button
v-if="selected" class="el-tool" :class="{ busy: checking }"
:title="checking ? 'Prüfung abbrechen' : 'Auf fehlende Infos prüfen'" @click="runCheck"
>🔍</button>
<button
v-if="selected" class="el-tool" :class="{ busy: styling }"
:title="styling ? 'Prüfung abbrechen' : 'Stil prüfen & anpassen'" @click="runStyle"
></button>
<button class="el-close" title="Schließen" @click="emit('close')">×</button>
</header>
<!-- Listen-Modus -->
<template v-if="!selected">
<div class="el-new">
<input
v-model="query"
placeholder="Suchen oder Stichwort…"
:disabled="creating"
@keyup.enter="add"
/>
<button :disabled="creating" title="Element per KI erstellen" @click="add">+</button>
</div>
<div v-if="creating" class="el-creating">KI erstellt Element</div>
<ul class="el-list">
<li v-for="el in filtered" :key="el.id" @click="select(el)">
<div class="el-item-main">
<span class="el-item-title">{{ plain(el.title) }}</span>
<span class="el-item-desc">{{ plain(el.description) }}</span>
</div>
<button
class="el-delete"
:class="{ armed: pendingDelete === el.id }"
title="Element löschen"
@click.stop="confirmDelete(el)"
>{{ pendingDelete === el.id ? 'Sicher?' : '×' }}</button>
</li>
<li v-if="!filtered.length && !creating" class="el-empty">
{{ elements.length ? 'Keine Treffer.' : 'Noch keine Elemente. Stichwort eingeben und + klicken.' }}
</li>
</ul>
</template>
<!-- Detail-Modus -->
<template v-else>
<div class="el-detail">
<div v-if="selected.description" class="el-desc markdown" v-html="renderMarkdown(selected.description)"></div>
<div
v-for="[ci, c] in [...styleAt('title'), ...styleAt('description'), ...styleAdds('description')]"
:key="'sgd' + ci" class="style-sugg"
>
<div class="style-sugg-text"><strong>{{ ACTION_LABELS[c.action] }}</strong> {{ c.text }}</div>
<div v-if="c.content" class="style-sugg-preview markdown" v-html="renderMarkdown(c.content)"></div>
<div class="style-sugg-actions">
<button class="sugg-ok" :disabled="applyingStyle" @click="applyStyleChange(ci)">Bestätigen</button>
<button class="sugg-no" :disabled="applyingStyle" @click="dismissStyleChange(ci)">Ablehnen</button>
</div>
</div>
<template v-for="(ex, i) in selected.examples" :key="i">
<div class="el-entry markdown" v-html="renderMarkdown(ex)"></div>
<div v-for="[ci, c] in styleAt('examples', i)" :key="'sge' + ci" class="style-sugg">
<div class="style-sugg-text"><strong>{{ ACTION_LABELS[c.action] }}</strong> {{ c.text }}</div>
<div v-if="c.content" class="style-sugg-preview markdown" v-html="renderMarkdown(c.content)"></div>
<div class="style-sugg-actions">
<button class="sugg-ok" :disabled="applyingStyle" @click="applyStyleChange(ci)">Bestätigen</button>
<button class="sugg-no" :disabled="applyingStyle" @click="dismissStyleChange(ci)">Ablehnen</button>
</div>
</div>
</template>
<div v-for="[ci, c] in styleAdds('examples')" :key="'sgea' + ci" class="style-sugg">
<div class="style-sugg-text"><strong>{{ ACTION_LABELS[c.action] }}</strong> {{ c.text }}</div>
<div v-if="c.content" class="style-sugg-preview markdown" v-html="renderMarkdown(c.content)"></div>
<div class="style-sugg-actions">
<button class="sugg-ok" :disabled="applyingStyle" @click="applyStyleChange(ci)">Bestätigen</button>
<button class="sugg-no" :disabled="applyingStyle" @click="dismissStyleChange(ci)">Ablehnen</button>
</div>
</div>
<div v-if="selected.hints.length || styleAdds('hints').length" class="el-hints-block">
<h4>Hinweise</h4>
<ul class="el-hints">
<li v-for="(h, i) in selected.hints" :key="i">
<span class="markdown" v-html="renderMarkdown(h)"></span>
<div v-for="[ci, c] in styleAt('hints', i)" :key="'sgh' + ci" class="style-sugg">
<div class="style-sugg-text"><strong>{{ ACTION_LABELS[c.action] }}</strong> {{ c.text }}</div>
<div v-if="c.content" class="style-sugg-preview markdown" v-html="renderMarkdown(c.content)"></div>
<div class="style-sugg-actions">
<button class="sugg-ok" :disabled="applyingStyle" @click="applyStyleChange(ci)">Bestätigen</button>
<button class="sugg-no" :disabled="applyingStyle" @click="dismissStyleChange(ci)">Ablehnen</button>
</div>
</div>
</li>
</ul>
<div v-for="[ci, c] in styleAdds('hints')" :key="'sgha' + ci" class="style-sugg">
<div class="style-sugg-text"><strong>{{ ACTION_LABELS[c.action] }}</strong> {{ c.text }}</div>
<div v-if="c.content" class="style-sugg-preview markdown" v-html="renderMarkdown(c.content)"></div>
<div class="style-sugg-actions">
<button class="sugg-ok" :disabled="applyingStyle" @click="applyStyleChange(ci)">Bestätigen</button>
<button class="sugg-no" :disabled="applyingStyle" @click="dismissStyleChange(ci)">Ablehnen</button>
</div>
</div>
</div>
<div v-if="checking || styling || statusMsg" class="el-check">
<p v-if="checking" class="check-empty busy-text">Prüft auf fehlende Infos</p>
<p v-if="styling" class="check-empty busy-text">Prüft den Stil</p>
<p v-if="statusMsg && !checking && !styling" class="check-empty">{{ statusMsg }}</p>
</div>
</div>
<div class="el-chat">
<div ref="messagesEl" class="chat-messages">
<p v-if="!messages.length" class="chat-hint">Schreib, was am Element geändert werden soll.</p>
<template v-for="(m, i) in messages" :key="i">
<div :class="['chat-msg', m.role]">{{ m.content }}</div>
</template>
<div v-if="loading" class="chat-msg assistant chat-typing">Passt an</div>
</div>
<div class="chat-input">
<textarea
ref="inputEl"
v-model="input"
placeholder="Element anpassen…"
@keydown.enter.exact.prevent="send"
></textarea>
<button :disabled="!input.trim() || loading" @click="send"></button>
</div>
</div>
</template>
</aside>
</template>
<style scoped>
.elements-sidebar {
width: 320px;
min-width: 320px;
height: 100vh;
display: flex;
flex-direction: column;
background: var(--panel);
border-left: 1px solid var(--border);
/* Über dem Guide-Chat (FAB/Panel: z-index 20) */
position: relative;
z-index: 30;
}
.el-header {
display: flex;
align-items: center;
gap: 6px;
padding: 0.6rem 0.9rem;
border-bottom: 1px solid var(--border);
}
.el-title {
flex: 1;
font-weight: 600;
font-size: 0.9rem;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.el-back,
.el-close {
border: none;
background: none;
color: var(--text-faint);
font-size: 1.2rem;
line-height: 1;
cursor: pointer;
padding: 0 4px;
}
.el-back:hover,
.el-close:hover {
color: var(--text);
}
.el-tool {
border: none;
background: none;
font-size: 0.95rem;
line-height: 1;
cursor: pointer;
padding: 2px 3px;
border-radius: 6px;
filter: grayscale(0.4);
}
.el-tool:hover {
background: var(--panel-soft);
filter: none;
}
.el-tool.busy {
filter: none;
animation: pulse 1.5s ease-in-out infinite;
}
.busy-text {
animation: pulse 1.5s ease-in-out infinite;
}
.el-new {
display: flex;
gap: 6px;
padding: 0.6rem 0.75rem;
}
.el-new input {
flex: 1;
padding: 8px 10px;
border: 1px solid var(--border-strong);
border-radius: 8px;
font-size: 0.85rem;
background: var(--panel);
color: var(--text);
outline: none;
}
.el-new input:focus {
border-color: var(--accent);
}
.el-new button {
width: 38px;
border: none;
border-radius: 8px;
background: var(--accent);
color: var(--on-accent);
font-size: 1.1rem;
cursor: pointer;
}
.el-new button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.el-creating {
padding: 0.4rem 0.75rem;
font-size: 0.78rem;
color: var(--warning);
background: var(--warning-soft);
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
50% { opacity: 0.35; }
}
.el-list {
flex: 1;
overflow-y: auto;
list-style: none;
margin: 0;
padding: 0.25rem 0;
}
.el-list li {
display: flex;
align-items: center;
gap: 6px;
padding: 0.5rem 0.75rem;
cursor: pointer;
transition: background 0.15s;
}
.el-list li:hover {
background: var(--panel-soft);
}
.el-item-main {
flex: 1;
min-width: 0;
}
.el-item-title {
display: block;
font-size: 0.85rem;
font-weight: 600;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.el-item-desc {
display: block;
font-size: 0.75rem;
color: var(--text-faint);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.el-delete {
border: none;
background: none;
color: var(--danger);
font-size: 1rem;
line-height: 1;
cursor: pointer;
padding: 0 2px;
visibility: hidden;
}
.el-list li:hover .el-delete {
visibility: visible;
}
.el-delete.armed {
visibility: visible;
font-size: 0.7rem;
font-weight: 700;
background: var(--danger);
color: #fff;
border-radius: 4px;
padding: 2px 6px;
}
.el-empty {
cursor: default !important;
color: var(--text-faint);
font-size: 0.8rem;
}
.el-empty:hover {
background: none !important;
}
.el-detail {
flex: 1;
overflow-y: auto;
padding: 0.9rem;
}
.el-desc {
margin: 0 0 0.9rem;
font-size: 0.85rem;
line-height: 1.6;
color: var(--text);
}
.el-hints-block {
margin-top: 0.9rem;
}
.el-hints-block h4 {
margin: 0 0 0.35rem;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-faint);
}
.el-entry {
font-size: 0.82rem;
line-height: 1.5;
color: var(--text);
margin-bottom: 0.4rem;
}
.el-entry:last-child {
margin-bottom: 0;
}
.el-hints {
margin: 0;
padding-left: 1.1rem;
}
.el-hints li {
font-size: 0.82rem;
line-height: 1.5;
color: var(--text);
margin-bottom: 0.25rem;
}
/* Hinweis-Text inline neben dem Bullet halten (p ist sonst block) */
.el-hints li > .markdown {
display: inline;
}
.el-hints li > .markdown :deep(p) {
display: inline;
margin: 0;
}
/* --- Markdown im Guide-Stil (Muster: TopicDetail) --- */
.markdown :deep(p) {
margin: 0 0 0.5em;
}
.markdown :deep(p:last-child) {
margin-bottom: 0;
}
.markdown :deep(ul),
.markdown :deep(ol) {
margin: 0.3em 0;
padding-left: 1.2em;
}
.markdown :deep(code) {
background: var(--border);
padding: 1px 4px;
border-radius: 4px;
font-family: "SF Mono", Consolas, monospace;
font-size: 0.85em;
overflow-wrap: anywhere;
}
.markdown :deep(pre) {
background: var(--code-bg, #1e2330);
color: var(--code-fg, #e6e8ee);
padding: 8px 10px;
border-radius: 8px;
/* Schmale Sidebar: Code umbrechen statt horizontal scrollen */
white-space: pre-wrap;
overflow-wrap: anywhere;
margin: 0.4em 0;
}
.markdown :deep(pre code) {
background: none;
padding: 0;
color: inherit;
font-size: 0.85em;
}
/* --- Stil-Vorschläge inline am Ziel --- */
.style-sugg {
margin: 0.3rem 0 0.6rem;
padding: 0.5rem 0.6rem;
border: 1px dashed var(--accent);
border-radius: 8px;
background: var(--panel-soft);
}
.style-sugg-text {
font-size: 0.76rem;
line-height: 1.4;
color: var(--text);
}
.style-sugg-text strong {
color: var(--accent);
}
.style-sugg-preview {
margin-top: 0.35rem;
font-size: 0.76rem;
line-height: 1.45;
color: var(--text-muted);
}
.style-sugg-actions {
display: flex;
gap: 6px;
margin-top: 0.45rem;
}
.sugg-ok,
.sugg-no {
padding: 4px 10px;
border-radius: 6px;
font-size: 0.74rem;
font-weight: 600;
cursor: pointer;
}
.sugg-ok {
border: none;
background: var(--accent);
color: var(--on-accent);
}
.sugg-no {
border: 1px solid var(--border-strong);
background: none;
color: var(--text-muted);
}
.sugg-no:hover {
border-color: var(--danger);
color: var(--danger);
}
.sugg-ok:disabled,
.sugg-no:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* --- KI-Prüfung --- */
.el-check {
margin-top: 1rem;
padding-top: 0.8rem;
border-top: 1px dashed var(--border-strong);
}
.check-btn {
width: 100%;
margin-bottom: 0.4rem;
padding: 7px 10px;
border: 1.5px dashed var(--border-strong);
border-radius: 8px;
background: var(--panel-soft);
color: var(--text-muted);
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: all 0.12s;
}
.check-btn:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
}
.check-btn:disabled {
opacity: 0.6;
cursor: wait;
}
.check-empty {
margin: 0.6rem 0 0;
font-size: 0.78rem;
color: var(--text-faint);
text-align: center;
}
.sugg-list {
list-style: none;
margin: 0.6rem 0 0;
padding: 0;
}
.sugg-list li {
display: flex;
align-items: flex-start;
gap: 7px;
padding: 0.4rem 0.2rem;
font-size: 0.8rem;
line-height: 1.4;
color: var(--text);
cursor: pointer;
border-radius: 6px;
}
.sugg-list li:hover {
background: var(--panel-soft);
}
.sugg-list input {
margin-top: 2px;
accent-color: var(--accent);
cursor: pointer;
}
.sugg-text em {
color: var(--text-faint);
font-style: normal;
font-size: 0.72rem;
}
.check-btn.busy {
border-style: solid;
border-color: var(--warning);
color: var(--warning);
animation: pulse 1.5s ease-in-out infinite;
}
.sugg-actions {
display: flex;
gap: 6px;
margin-top: 0.5rem;
}
.dismiss-btn {
padding: 7px 10px;
border: 1px solid var(--border-strong);
border-radius: 8px;
background: none;
color: var(--text-muted);
font-size: 0.8rem;
cursor: pointer;
}
.dismiss-btn:hover {
border-color: var(--danger);
color: var(--danger);
}
.apply-btn {
flex: 1;
padding: 7px 10px;
border: none;
border-radius: 8px;
background: var(--accent);
color: var(--on-accent);
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
}
.apply-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* Chat unten (Muster: TopicDetail-Chat) */
.el-chat {
display: flex;
flex-direction: column;
flex: 0 0 33%;
min-height: 0;
border-top: 1px solid var(--border);
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 0.6rem 0.75rem;
display: flex;
flex-direction: column;
gap: 8px;
}
.chat-hint {
color: var(--text-faint);
font-size: 0.78rem;
text-align: center;
margin-top: 0.5rem;
}
.chat-msg {
max-width: 85%;
padding: 6px 10px;
border-radius: 12px;
font-size: 0.82rem;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-word;
}
.chat-msg.user {
align-self: flex-end;
background: var(--accent);
color: var(--on-accent);
border-bottom-right-radius: 3px;
}
.chat-msg.assistant {
align-self: flex-start;
background: var(--panel-soft);
color: var(--text);
border-bottom-left-radius: 3px;
}
.chat-typing {
color: var(--text-faint);
font-style: italic;
}
.chat-input {
display: flex;
gap: 6px;
padding: 0.6rem;
border-top: 1px solid var(--border);
}
.chat-input textarea {
flex: 1;
resize: none;
height: 72px;
padding: 8px 10px;
border: 1px solid var(--border-strong);
border-radius: 8px;
font-size: 0.85rem;
font-family: inherit;
background: var(--panel);
color: var(--text);
outline: none;
}
.chat-input textarea:focus {
border-color: var(--accent);
}
.chat-input button {
width: 38px;
border: none;
border-radius: 8px;
background: var(--accent);
color: var(--on-accent);
font-size: 1rem;
cursor: pointer;
}
.chat-input button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>

View File

@@ -1,37 +1,7 @@
<script setup> <script setup>
import { computed, ref, watch, nextTick, onMounted, onUnmounted } from 'vue' import { computed, ref, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { marked } from 'marked'
import { markedHighlight } from 'marked-highlight'
import hljs from 'highlight.js'
import 'highlight.js/styles/github-dark.css'
import DOMPurify from 'dompurify'
import { fetchGuideContent, chatGuide, fetchProgress, setProgress } from '../api.js' import { fetchGuideContent, chatGuide, fetchProgress, setProgress } from '../api.js'
import { renderMarkdown } from '../markdown.js'
marked.use(markedHighlight({
langPrefix: 'hljs language-',
highlight(code, lang) {
if (lang && hljs.getLanguage(lang)) {
return hljs.highlight(code, { language: lang }).value
}
return hljs.highlightAuto(code).value
},
}))
marked.setOptions({ breaks: true, gfm: true })
// Rohes HTML im Markdown (z. B. <p>, <img> ohne Backticks aus Agenten-Output)
// als Text anzeigen statt rendern — sonst verschluckt der Browser den Inhalt.
marked.use({
renderer: {
html(token) {
const text = typeof token === 'string' ? token : token.text
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
},
},
})
function renderMarkdown(text) {
return DOMPurify.sanitize(marked.parse(text || ''))
}
const props = defineProps({ const props = defineProps({
previewGuide: { type: Object, default: null }, previewGuide: { type: Object, default: null },
@@ -39,7 +9,7 @@ const props = defineProps({
provider: { type: String, default: 'claude' }, provider: { type: String, default: 'claude' },
}) })
const emit = defineEmits(['progressChanged']) const emit = defineEmits(['progressChanged', 'openElements'])
const isOnePager = computed(() => props.previewGuide?.format === 'OnePager') const isOnePager = computed(() => props.previewGuide?.format === 'OnePager')
@@ -247,6 +217,7 @@ async function send() {
</div> </div>
<button v-if="previewGuide && !chatOpen" class="chat-fab" title="Fragen zum Guide" @click="openChat">💬</button> <button v-if="previewGuide && !chatOpen" class="chat-fab" title="Fragen zum Guide" @click="openChat">💬</button>
<button v-if="previewGuide && !chatOpen" class="chat-fab elements-fab" title="Elemente öffnen" @click="emit('openElements')">🗂</button>
<div v-if="previewGuide && chatOpen" ref="panelEl" class="chat-panel"> <div v-if="previewGuide && chatOpen" ref="panelEl" class="chat-panel">
<header class="chat-header"> <header class="chat-header">
@@ -632,6 +603,10 @@ async function send() {
background: var(--accent-hover); background: var(--accent-hover);
} }
.elements-fab {
right: 5.25rem;
}
.chat-panel { .chat-panel {
position: fixed; position: fixed;
right: 1.5rem; right: 1.5rem;

View File

@@ -19,7 +19,7 @@ const props = defineProps({
providers: { type: Array, default: () => [] }, providers: { type: Array, default: () => [] },
}) })
const emit = defineEmits(['select', 'create', 'formatClick', 'bausteineClick', 'cancelBausteine', 'resetBausteine', 'deleteTopic', 'deleteProject', 'cancelGuide', 'deleteGuide', 'dismissError', 'preview', 'togglePin', 'sidebarLeave', 'toggleDark', 'setProvider']) const emit = defineEmits(['select', 'create', 'formatClick', 'bausteineClick', 'cancelBausteine', 'resetBausteine', 'deleteTopic', 'deleteProject', 'cancelGuide', 'deleteGuide', 'dismissError', 'preview', 'openElements', 'togglePin', 'sidebarLeave', 'toggleDark', 'setProvider'])
function providerAvailable(id) { function providerAvailable(id) {
const p = props.providers.find((x) => x.id === id) const p = props.providers.find((x) => x.id === id)
@@ -334,6 +334,11 @@ function confirmDeleteProject(name) {
<button class="format-error-x" title="Ausblenden" @click="dismissError(f.key)">×</button> <button class="format-error-x" title="Ausblenden" @click="dismissError(f.key)">×</button>
</div> </div>
</div> </div>
<div class="format-row ord-elemente">
<button class="format-name elements-btn" @click="emit('openElements')">
<span class="format-label">Elemente</span>
</button>
</div>
</div> </div>
<ul class="topic-list"> <ul class="topic-list">
@@ -639,6 +644,19 @@ function confirmDeleteProject(name) {
order: 2; order: 2;
} }
.ord-elemente {
order: 4;
}
.elements-btn {
cursor: pointer;
color: var(--text);
}
.elements-btn:hover {
background: var(--panel-soft);
}
.progress-info { .progress-info {
padding: 0.4rem 0.75rem; padding: 0.4rem 0.75rem;
font-size: 0.75rem; font-size: 0.75rem;

31
frontend/src/markdown.js Normal file
View File

@@ -0,0 +1,31 @@
import { marked } from 'marked'
import { markedHighlight } from 'marked-highlight'
import hljs from 'highlight.js'
import 'highlight.js/styles/github-dark.css'
import DOMPurify from 'dompurify'
marked.use(markedHighlight({
langPrefix: 'hljs language-',
highlight(code, lang) {
if (lang && hljs.getLanguage(lang)) {
return hljs.highlight(code, { language: lang }).value
}
return hljs.highlightAuto(code).value
},
}))
marked.setOptions({ breaks: true, gfm: true })
// Rohes HTML im Markdown (z. B. <p>, <img> ohne Backticks aus Agenten-Output)
// als Text anzeigen statt rendern — sonst verschluckt der Browser den Inhalt.
marked.use({
renderer: {
html(token) {
const text = typeof token === 'string' ? token : token.text
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
},
},
})
export function renderMarkdown(text) {
return DOMPurify.sanitize(marked.parse(text || ''))
}

View File

@@ -0,0 +1,22 @@
Du hilfst, ein Lern-Element einer persönlichen Zusammenfassung zum Thema "{topic}" anzupassen.
AKTUELLES ELEMENT (JSON):
{element_json}
BISHERIGER CHAT-VERLAUF:
{transcript}
Setze die letzte Nutzer-Anweisung am Element um. Halte die Element-Regeln ein:
1. title — prägnanter Titel (max. 8 Wörter, reiner Text ohne Markdown/Backticks)
2. description — was es ist und wozu: MAXIMAL 12 Sätze
3. examples — KURZ und SIMPEL: wenige Zeilen Code, das Minimalbeispiel, keine Realwelt-Komplexität. Ein Beispiel pro relevanter Variante, geordnet vom Üblichen zum Speziellen. Jedes beginnt mit einem kurzen Kommentar in der Code-Syntax (z. B. `<!-- Einzelner Absatz -->`), der die Variante benennt.
4. hints — jeder Hinweis muss WICHTIG oder NÜTZLICH sein: Stolperfalle, Merksatz oder Best Practice mit echtem Praxiswert. Telegrammstil: nur die Kernaussage. Beispiel: "Keine Blockelemente in `<p>`."
Umfang: SO LANG WIE NÖTIG und SO KURZ WIE MÖGLICH — gilt für description, examples und hints. Jedes Wort muss seinen Platz verdienen: Füllwörter, Nebensätze ohne Informationswert und Selbstverständliches streichen. Aber: Kürze nie auf Kosten der Verständlichkeit.
Tonalität: klares Deutsch, direkt, praxisorientiert. Keine Füllsätze.
Markdown in description, examples und hints: `inline-code` für Bezeichner, Codeblöcke mit Sprachangabe (```sprache), **fett** sparsam. Keine Überschriften. Code-Beispiele IMMER als Codeblock, nie als Inline-Code. Bezeichner, Tags und Befehle (z. B. `<p>`, `git add`) im Fließtext IMMER in Backticks — nie nackt.
Gib NUR gültiges JSON aus, ohne Code-Fence, ohne weiteren Text:
{{"reply": "kurze Antwort an den Nutzer (12 Sätze)", "element": {{"title": "...", "description": "...", "examples": ["```sprache\n...\n```"], "hints": ["..."]}}}}

View File

@@ -0,0 +1,19 @@
Du recherchierst, welche Informationen in einem Lern-Element einer persönlichen Zusammenfassung zum Thema "{topic}" noch fehlen könnten.
AKTUELLES ELEMENT (JSON):
{element_json}
KONTEXT (Auszüge aus dem Themen-Material):
{context}
RECHERCHE — sammle breit alle Kandidaten: fehlende Kernaussagen, wichtige Varianten, typische Stolperfallen, Best Practices. Lieber einen Kandidaten zu viel als einen zu wenig — die Bewertung passiert in einem zweiten Schritt. Nichts vorschlagen, was das Element schon enthält.
Jeder Kandidat:
- text: kurze Beschreibung der Lücke (max. 12 Wörter, reiner Text)
- target: "description" | "examples" | "hints"
- content: fertiger Inhalt zum Einfügen. SO KURZ WIE MÖGLICH, so lang wie nötig. Markdown: `inline-code` für Bezeichner, examples als Codeblock mit Sprachangabe (```sprache), beginnend mit kurzem Kommentar zur Variante (z. B. `<!-- Einzelner Absatz -->`), hints nur wenn WICHTIG oder NÜTZLICH, im Telegrammstil (nur Kernaussage, z. B. "Keine Blockelemente in `<p>`."). Tags/Bezeichner im Fließtext IMMER in Backticks.
Gib NUR gültiges JSON aus, ohne Code-Fence, ohne weiteren Text:
{{"suggestions": [{{"text": "...", "target": "hints", "content": "..."}}]}}
Keine Kandidaten → {{"suggestions": []}}

View File

@@ -0,0 +1,24 @@
Du erstellst ein kurzes Lern-Element für eine persönliche Zusammenfassung zum Thema "{topic}".
STICHWORT VOM NUTZER:
{hint}
KONTEXT (Auszüge aus dem Themen-Material):
{context}
Erstelle GENAU EIN Element zum Stichwort:
1. title — prägnanter Titel (max. 8 Wörter, reiner Text ohne Markdown/Backticks)
2. description — was es ist und wozu: MAXIMAL 12 Sätze
3. examples — GENAU EIN Beispiel: KURZ und SIMPEL, wenige Zeilen Code, das Minimalbeispiel, keine Realwelt-Komplexität. Beginnt mit einem kurzen Kommentar in der Code-Syntax (z. B. `<!-- Einzelner Absatz -->`, `// Mit Default-Wert`), der die Variante benennt.
4. hints — IMMER leere Liste. Hinweise ergänzt der Nutzer später selbst. (Falls je gefordert: TELEGRAMMSTIL, max. 10 Wörter.)
Das Element ist ATOMAR: allein verständlich, ohne dass der Leser etwas anderes gelesen hat. Benutzte Begriffe in einem Halbsatz auflösen.
Umfang: SO KURZ WIE MÖGLICH, so lang wie nötig — gilt für description, examples und hints. Jedes Wort muss seinen Platz verdienen: Füllwörter, Nebensätze ohne Informationswert und Selbstverständliches streichen. Die Länge kommt aus der ZAHL der Beispiele (Varianten), nie aus langen Texten.
Tonalität: klares Deutsch, direkt, praxisorientiert. Fachbegriffe beim ersten Auftreten kurz erklären. Keine Füllsätze, keine Einleitungsfloskeln.
Markdown in description und examples: normale Absätze, `inline-code` für Bezeichner, Codeblöcke mit Sprachangabe (```sprache), **fett** sparsam für Kernaussagen. Keine Überschriften. Code-Beispiele IMMER als Codeblock, nie als Inline-Code. Bezeichner, Tags und Befehle (z. B. `<p>`, `git add`) im Fließtext IMMER in Backticks — nie nackt.
Gib NUR gültiges JSON aus, ohne Code-Fence, ohne weiteren Text:
{{"title": "...", "description": "...", "examples": ["```sprache\n...\n```"], "hints": []}}

View File

@@ -0,0 +1,29 @@
Du prüfst ein Lern-Element einer persönlichen Zusammenfassung zum Thema "{topic}" auf Stil-Regeln und schlägst Änderungen vor. Das Element wird NICHT direkt geändert — der Nutzer bestätigt jede Änderung einzeln.
AKTUELLES ELEMENT (JSON):
{element_json}
STIL-REGELN:
1. title — prägnant, max. 8 Wörter, reiner Text ohne Markdown/Backticks
2. description — was es ist und wozu: MAXIMAL 12 Sätze
3. examples — KURZ und SIMPEL: wenige Zeilen Code, Minimalbeispiel, keine Realwelt-Komplexität. Ein Beispiel pro relevanter Variante, geordnet vom Üblichen zum Speziellen. Als Codeblock mit Sprachangabe (```sprache), nie als Inline-Code. Jedes Beispiel beginnt mit einem kurzen Kommentar in der Code-Syntax (z. B. `<!-- Einzelner Absatz -->`), der die Variante benennt.
4. hints — jeder Hinweis muss WICHTIG oder NÜTZLICH sein: Stolperfalle, Merksatz oder Best Practice mit echtem Praxiswert. Selbstverständliches, Nischenwissen und Redundantes zum Element entfernen. Telegrammstil: nur die Kernaussage, Füllverben und Herleitungen streichen.
Vorher: "Browser fügen standardmäßig vertikalen Abstand vor und nach `<p>` ein — anpassbar mit `margin`."
Nachher: "Browser-Abstand um `<p>` per `margin` anpassbar."
5. Umfang: SO LANG WIE NÖTIG und SO KURZ WIE MÖGLICH. Jedes Wort muss seinen Platz verdienen — Füllwörter, Nebensätze ohne Informationswert und Selbstverständliches streichen. Aber: Kürze nie auf Kosten der Verständlichkeit oder Korrektheit.
6. Markdown: `inline-code` für Bezeichner, Tags und Befehle im Fließtext (z. B. `<p>`, `git add`) — IMMER in Backticks, nie nackt. **fett** sparsam. Keine Überschriften.
7. Tonalität: klares Deutsch, direkt, praxisorientiert. Keine Füllsätze.
Schlage für jeden Stil-Verstoß GENAU EINE Änderung vor:
- text: kurz, was und warum (max. 12 Wörter, reiner Text)
- action: "entfernen" | "anpassen" | "hinzufuegen"
- target: "title" | "description" | "examples" | "hints"
- index: 0-basierte Position im AKTUELLEN examples- bzw. hints-Array (bei title/description: null; bei hinzufuegen: null)
- content: der neue/vollständige Inhalt (bei entfernen: leer)
"entfernen" nur für examples/hints. "hinzufuegen" sparsam — nur wenn eine Stil-Regel es verlangt (z. B. fehlender Varianten-Kommentar gehört zu "anpassen", nicht "hinzufuegen"). Erfüllt etwas die Regeln schon: NICHT anfassen.
Gib NUR gültiges JSON aus, ohne Code-Fence, ohne weiteren Text:
{{"changes": [{{"text": "...", "action": "anpassen", "target": "hints", "index": 0, "content": "..."}}]}}
Kein Stil-Verstoß → {{"changes": []}}

View File

@@ -0,0 +1,23 @@
Du verifizierst Ergänzungs-Kandidaten für ein Lern-Element einer persönlichen Zusammenfassung zum Thema "{topic}". Das Element soll KURZ bleiben — nur wirklich Wichtiges kommt durch.
AKTUELLES ELEMENT (JSON):
{element_json}
KANDIDATEN (JSON):
{candidates_json}
KONTEXT (Auszüge aus dem Themen-Material):
{context}
Prüfe JEDEN Kandidaten kritisch:
1. WICHTIG? Muss ein Lerner das wissen? Nice-to-haves und Nischenwissen ablehnen.
2. REDUNDANT? Steckt die Info schon im Element oder in einem anderen Kandidaten? Ablehnen bzw. Duplikate zusammenführen.
3. KORREKT? Fachlich falsch oder irreführend → ablehnen.
4. PASST das target ("description" | "examples" | "hints")? Sonst korrigieren.
Behalte nur Kandidaten, die alle Prüfungen bestehen. Verbessere dabei content auf die Stil-Regeln: SO LANG WIE NÖTIG und SO KURZ WIE MÖGLICH; `inline-code` für Bezeichner; examples als Codeblock mit Sprachangabe und kurzem Varianten-Kommentar; hints im Telegrammstil (nur Kernaussage, Kürze nie auf Kosten der Verständlichkeit).
Gib NUR gültiges JSON aus, ohne Code-Fence, ohne weiteren Text:
{{"suggestions": [{{"text": "...", "target": "hints", "content": "..."}}]}}
Kein Kandidat besteht → {{"suggestions": []}}