update
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import json
|
||||
|
||||
import aiosqlite
|
||||
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
|
||||
|
||||
|
||||
@@ -48,6 +63,7 @@ async def init_db():
|
||||
await db.execute(CREATE_GUIDES)
|
||||
await db.execute(CREATE_PROGRESS)
|
||||
await db.execute(CREATE_TOPICS)
|
||||
await db.execute(CREATE_ELEMENTS)
|
||||
try: # Migration für Bestands-DBs ohne step-Spalte
|
||||
await db.execute("ALTER TABLE guides ADD COLUMN step INTEGER")
|
||||
except aiosqlite.OperationalError:
|
||||
@@ -138,6 +154,63 @@ async def delete_topic(name: str) -> None:
|
||||
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 ---
|
||||
|
||||
async def list_progress(guide_id: str) -> list[str]:
|
||||
|
||||
@@ -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."
|
||||
except Exception:
|
||||
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
|
||||
|
||||
@@ -79,6 +79,66 @@ class GuideChatResponse(BaseModel):
|
||||
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):
|
||||
chapter: str = Field(min_length=1, max_length=100)
|
||||
done: bool
|
||||
|
||||
@@ -13,16 +13,20 @@ from database import (
|
||||
create_guide, delete_guide, get_guide, list_guides,
|
||||
create_topic, list_topics as db_list_topics, delete_topic,
|
||||
list_progress, set_progress, delete_progress,
|
||||
create_element, list_elements, get_element, update_element, delete_element,
|
||||
)
|
||||
from generator import (
|
||||
generate_guide, cancel_guide, chat_with_guide, guide_slot_dateien,
|
||||
generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine,
|
||||
generate_element, chat_with_element, check_element, style_element,
|
||||
)
|
||||
from models import (
|
||||
GuideCreateRequest, GuideResponse,
|
||||
TopicCreateRequest,
|
||||
BausteineCreateRequest, BausteineStatusResponse,
|
||||
GuideChatRequest, GuideChatResponse,
|
||||
ElementCreateRequest, ElementChatRequest, ElementChatResponse, ElementResponse,
|
||||
ElementUpdateRequest, ElementCheckRequest, ElementCheckResponse, ElementStyleResponse,
|
||||
ProgressUpdate, ProgressResponse, ProjectResponse, ProviderInfo,
|
||||
)
|
||||
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}
|
||||
|
||||
|
||||
# --- 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")
|
||||
async def cancel(guide_id: str):
|
||||
cancelled = await cancel_guide(guide_id)
|
||||
|
||||
@@ -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 TopicSidebar from './components/TopicSidebar.vue'
|
||||
import TopicDetail from './components/TopicDetail.vue'
|
||||
import ElementsSidebar from './components/ElementsSidebar.vue'
|
||||
import ElementsOverview from './components/ElementsOverview.vue'
|
||||
|
||||
const guides = ref([])
|
||||
const projects = ref([])
|
||||
@@ -23,6 +25,11 @@ const provider = ref(localStorage.getItem('provider') || 'claude')
|
||||
const providers = ref([])
|
||||
const stats = ref(null)
|
||||
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() {
|
||||
try {
|
||||
@@ -182,6 +189,9 @@ function selectTopic(topic) {
|
||||
selectedTopic.value = topic
|
||||
previewGuide.value = null
|
||||
sidebarSticky.value = false
|
||||
elementsOpen.value = false
|
||||
elementsView.value = false
|
||||
elementOpenId.value = null
|
||||
localStorage.setItem('lastTopic', topic)
|
||||
loadBausteine()
|
||||
nextTick(autoPreview)
|
||||
@@ -243,6 +253,19 @@ async function handleDeleteProject(name) {
|
||||
|
||||
function handlePreview(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) {
|
||||
@@ -353,19 +376,36 @@ onUnmounted(() => {
|
||||
@deleteGuide="handleDeleteGuide"
|
||||
@dismissError="handleDismissError"
|
||||
@preview="handlePreview"
|
||||
@openElements="handleOpenElements"
|
||||
@togglePin="toggleSidebarPin"
|
||||
@sidebarLeave="onSidebarLeave"
|
||||
/>
|
||||
<ElementsOverview
|
||||
v-if="selectedTopic && elementsView"
|
||||
:topic="selectedTopic"
|
||||
:version="elementsVersion"
|
||||
@open="handleOpenElementDetail"
|
||||
/>
|
||||
<TopicDetail
|
||||
v-if="selectedTopic"
|
||||
v-else-if="selectedTopic"
|
||||
:previewGuide="previewGuide"
|
||||
:dark="darkMode"
|
||||
:provider="provider"
|
||||
@progressChanged="loadStats(); loadBausteine()"
|
||||
@openElements="elementsOpen = true"
|
||||
/>
|
||||
<div v-else class="empty-main">
|
||||
<p>Thema in der Sidebar anlegen oder auswählen.</p>
|
||||
</div>
|
||||
<ElementsSidebar
|
||||
v-if="elementsOpen && selectedTopic"
|
||||
:topic="selectedTopic"
|
||||
:provider="provider"
|
||||
:openId="elementOpenId"
|
||||
:openTick="elementOpenTick"
|
||||
@close="elementsOpen = false"
|
||||
@changed="elementsVersion++"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -118,3 +118,59 @@ export async function chatGuide(id, { section, outline, messages, provider = 'cl
|
||||
})
|
||||
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()
|
||||
}
|
||||
|
||||
205
frontend/src/components/ElementsOverview.vue
Normal file
205
frontend/src/components/ElementsOverview.vue
Normal 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>
|
||||
986
frontend/src/components/ElementsSidebar.vue
Normal file
986
frontend/src/components/ElementsSidebar.vue
Normal 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>
|
||||
@@ -1,37 +1,7 @@
|
||||
<script setup>
|
||||
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'
|
||||
|
||||
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
function renderMarkdown(text) {
|
||||
return DOMPurify.sanitize(marked.parse(text || ''))
|
||||
}
|
||||
import { renderMarkdown } from '../markdown.js'
|
||||
|
||||
const props = defineProps({
|
||||
previewGuide: { type: Object, default: null },
|
||||
@@ -39,7 +9,7 @@ const props = defineProps({
|
||||
provider: { type: String, default: 'claude' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['progressChanged'])
|
||||
const emit = defineEmits(['progressChanged', 'openElements'])
|
||||
|
||||
const isOnePager = computed(() => props.previewGuide?.format === 'OnePager')
|
||||
|
||||
@@ -247,6 +217,7 @@ async function send() {
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<header class="chat-header">
|
||||
@@ -632,6 +603,10 @@ async function send() {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.elements-fab {
|
||||
right: 5.25rem;
|
||||
}
|
||||
|
||||
.chat-panel {
|
||||
position: fixed;
|
||||
right: 1.5rem;
|
||||
|
||||
@@ -19,7 +19,7 @@ const props = defineProps({
|
||||
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) {
|
||||
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>
|
||||
</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>
|
||||
|
||||
<ul class="topic-list">
|
||||
@@ -639,6 +644,19 @@ function confirmDeleteProject(name) {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.ord-elemente {
|
||||
order: 4;
|
||||
}
|
||||
|
||||
.elements-btn {
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.elements-btn:hover {
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.progress-info {
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
|
||||
31
frontend/src/markdown.js
Normal file
31
frontend/src/markdown.js
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export function renderMarkdown(text) {
|
||||
return DOMPurify.sanitize(marked.parse(text || ''))
|
||||
}
|
||||
22
templates/Prompt/Element-Chat.md
Normal file
22
templates/Prompt/Element-Chat.md
Normal 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 1–2 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 (1–2 Sätze)", "element": {{"title": "...", "description": "...", "examples": ["```sprache\n...\n```"], "hints": ["..."]}}}}
|
||||
19
templates/Prompt/Element-Check.md
Normal file
19
templates/Prompt/Element-Check.md
Normal 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": []}}
|
||||
24
templates/Prompt/Element-Create.md
Normal file
24
templates/Prompt/Element-Create.md
Normal 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 1–2 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": []}}
|
||||
29
templates/Prompt/Element-Stil.md
Normal file
29
templates/Prompt/Element-Stil.md
Normal 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 1–2 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": []}}
|
||||
23
templates/Prompt/Element-Verify.md
Normal file
23
templates/Prompt/Element-Verify.md
Normal 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": []}}
|
||||
Reference in New Issue
Block a user