diff --git a/backend/config.py b/backend/config.py index f5af700..fe4c7be 100644 --- a/backend/config.py +++ b/backend/config.py @@ -30,3 +30,4 @@ CLAUDE_CLI = "claude" MODEL_GUIDE = "claude-opus-4-8" MODEL_BAUSTEIN_GEN = "claude-sonnet-4-6" MODEL_BAUSTEIN_REWORK = "claude-sonnet-4-6" +MODEL_CHAT = "claude-sonnet-4-6" diff --git a/backend/generator.py b/backend/generator.py index 42fb82a..9a04ed1 100644 --- a/backend/generator.py +++ b/backend/generator.py @@ -16,6 +16,7 @@ from config import ( MODEL_GUIDE, MODEL_BAUSTEIN_GEN, MODEL_BAUSTEIN_REWORK, + MODEL_CHAT, STORAGE_DIR, ) from database import ( @@ -556,6 +557,48 @@ def _build_topic_suggest_prompt(problem: str, existing_topics: list[str]) -> str return template.replace("{problem}", problem).replace("{existing}", existing) +def _build_guide_chat_prompt(topic: str, format_name: str, section: str, outline: str, messages: list[dict]) -> str: + transcript = "\n".join( + f"{'Nutzer' if m.get('role') == 'user' else 'Assistent'}: {m.get('content', '')}" + for m in messages + ) + outline_block = outline.strip() or "(keine)" + section_block = section.strip() or "(kein Abschnitt erkannt)" + return f"""Du bist ein hilfreicher Tutor zum Lern-Guide "{topic}" (Format: {format_name}). Ein Leser stellt dir Fragen, während er den Guide liest. + +GLIEDERUNG DES GUIDES: +{outline_block} + +AKTUELLER ABSCHNITT, DEN DER LESER GERADE LIEST: +{section_block} + +BISHERIGER CHAT-VERLAUF: +{transcript} + +Antworte als Assistent auf die letzte Nutzer-Nachricht. + +WICHTIG – Antwortstil: +- KURZ und EINFACH: 1–3 Sätze, klare Sprache. +- Keine Einleitung, keine Wiederholung der Frage, kein Markdown-Drumherum. +- Beantworte nur die Frage; nutze den Abschnitt und die Gliederung als Kontext. + +Gib NUR die Antwort aus, kein Präfix wie "Assistent:".""" + + +async def chat_with_guide(topic: str, format_name: str, section: str, outline: str, messages: list[dict]) -> str: + try: + prompt = _build_guide_chat_prompt(topic, format_name, section, outline, messages) + returncode, stdout, stderr = await _run_claude( + "chat-" + str(uuid.uuid4()), prompt, 120, tools=None, model=MODEL_CHAT + ) + if returncode != 0: + return "Entschuldigung, das hat nicht geklappt. Bitte versuche es erneut." + reply = stdout.strip() + return reply or "Entschuldigung, ich habe keine Antwort erhalten." + except Exception: + return "Entschuldigung, das hat nicht geklappt. Bitte versuche es erneut." + + async def suggest_topics(problem: str, existing_topics: list[str] | None = None) -> list[dict]: try: prompt = _build_topic_suggest_prompt(problem, existing_topics or []) diff --git a/backend/models.py b/backend/models.py index 95bfab6..6b79d3a 100644 --- a/backend/models.py +++ b/backend/models.py @@ -75,3 +75,18 @@ class TopicSuggestRequest(BaseModel): class TopicSuggestion(BaseModel): title: str reason: str + + +class ChatMessage(BaseModel): + role: Literal["user", "assistant"] + content: str = Field(min_length=1, max_length=8000) + + +class GuideChatRequest(BaseModel): + section: str = Field(default="", max_length=20000) + outline: str = Field(default="", max_length=8000) + messages: list[ChatMessage] = Field(min_length=1) + + +class GuideChatResponse(BaseModel): + reply: str diff --git a/backend/routes.py b/backend/routes.py index 08bb6de..e37878f 100644 --- a/backend/routes.py +++ b/backend/routes.py @@ -11,11 +11,12 @@ from database import ( create_baustein as db_create_baustein, list_bausteine, get_baustein, delete_baustein as db_delete_baustein, list_suggestions, get_suggestion, update_suggestion, delete_suggestion, ) -from generator import generate_guide, rework_guide, cancel_guide, generate_suggestions, generate_baustein_detail, rework_baustein, sort_bausteine, suggest_topics, is_suggestions_generating, is_sorting +from generator import generate_guide, rework_guide, cancel_guide, generate_suggestions, generate_baustein_detail, rework_baustein, sort_bausteine, suggest_topics, chat_with_guide, is_suggestions_generating, is_sorting from models import ( GuideCreateRequest, GuideReworkRequest, GuideResponse, BausteinCreateRequest, BausteinReworkRequest, BausteinSortRequest, BausteinResponse, SuggestionResponse, TopicSuggestRequest, TopicSuggestion, + GuideChatRequest, GuideChatResponse, ) from paths import final_paths @@ -89,6 +90,18 @@ async def rework(guide_id: str, req: GuideReworkRequest): return {"ok": True} +@router.post("/guides/{guide_id}/chat", response_model=GuideChatResponse) +async def guide_chat(guide_id: str, req: GuideChatRequest): + guide = await get_guide(guide_id) + if guide is None: + raise HTTPException(404, "Guide nicht gefunden") + reply = await chat_with_guide( + guide["topic"], guide["format"], req.section, req.outline, + [m.model_dump() for m in req.messages], + ) + return {"reply": reply} + + @router.post("/guides/{guide_id}/cancel") async def cancel(guide_id: str): cancelled = await cancel_guide(guide_id) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 26820c2..15b48b7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,8 @@ "name": "frontend", "version": "0.0.0", "dependencies": { + "dompurify": "^3.4.7", + "marked": "^18.0.4", "vue": "^3.5.32" }, "devDependencies": { @@ -868,6 +870,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@vitejs/plugin-vue": { "version": "6.0.7", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz", @@ -1260,6 +1269,15 @@ "node": ">=8" } }, + "node_modules/dompurify": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.7.tgz", + "integrity": "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.361", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz", @@ -1726,6 +1744,18 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/marked": { + "version": "18.0.4", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.4.tgz", + "integrity": "sha512-c/BTaKzg0G6ezQx97DAkYU7k0HM6ys0FqYeKBL6hlBByZwy+ycA1+f0vDdjMHKKeEjdgkx0GOv9Il6D+85cOqA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index ed0a264..7a89248 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,8 @@ "preview": "vite preview" }, "dependencies": { + "dompurify": "^3.4.7", + "marked": "^18.0.4", "vue": "^3.5.32" }, "devDependencies": { diff --git a/frontend/src/api.js b/frontend/src/api.js index a811a02..a033adf 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -44,6 +44,15 @@ export function htmlUrl(id) { return `${BASE}/guides/${id}/html` } +export async function chatGuide(id, { section, outline, messages }) { + const res = await fetch(`${BASE}/guides/${id}/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ section, outline, messages }), + }) + return res.json() +} + export async function suggestTopics(problem) { const res = await fetch(`${BASE}/topic-suggestions`, { method: 'POST', diff --git a/frontend/src/components/TopicDetail.vue b/frontend/src/components/TopicDetail.vue index 6351d07..37fb1f1 100644 --- a/frontend/src/components/TopicDetail.vue +++ b/frontend/src/components/TopicDetail.vue @@ -1,6 +1,14 @@ @@ -42,10 +167,13 @@ function injectPadding(e) { display: flex; flex-direction: column; align-items: center; - padding: 2rem; background: #f0f1f4; } +.preview.landscape { + padding: 2rem; +} + .preview-header { display: flex; justify-content: space-between; @@ -76,15 +204,14 @@ function injectPadding(e) { .preview-frame { width: 100%; - max-width: 900px; flex: 1; border: none; background: #fff; - box-shadow: 0 1px 8px rgba(0, 0, 0, 0.08); } .preview-frame.landscape { max-width: 1180px; + box-shadow: 0 1px 8px rgba(0, 0, 0, 0.08); } .empty-preview { @@ -94,4 +221,209 @@ function injectPadding(e) { height: 100%; color: #5a6470; } + +.chat-fab { + position: fixed; + right: 1.5rem; + bottom: 1.5rem; + width: 52px; + height: 52px; + border: none; + border-radius: 50%; + background: #6366f1; + color: #fff; + font-size: 1.4rem; + cursor: pointer; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25); + z-index: 20; +} + +.chat-fab:hover { + background: #4f46e5; +} + +.chat-panel { + position: fixed; + right: 1.5rem; + bottom: 1.5rem; + width: 360px; + height: 500px; + max-height: calc(100vh - 3rem); + display: flex; + flex-direction: column; + background: #fff; + border: 1px solid #e2e5e9; + border-radius: 12px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.18); + z-index: 20; + overflow: hidden; +} + +.chat-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.6rem 0.9rem; + background: #6366f1; + color: #fff; + font-weight: 600; + font-size: 0.9rem; +} + +.chat-close { + border: none; + background: none; + color: #fff; + font-size: 1.4rem; + line-height: 1; + cursor: pointer; + padding: 0 4px; +} + +.chat-messages { + flex: 1; + overflow-y: auto; + padding: 0.9rem; + display: flex; + flex-direction: column; + gap: 8px; +} + +.chat-hint { + color: #9aa3af; + font-size: 0.82rem; + text-align: center; + margin-top: 1rem; +} + +.chat-msg { + max-width: 85%; + padding: 7px 11px; + border-radius: 12px; + font-size: 0.85rem; + line-height: 1.4; + white-space: pre-wrap; + word-break: break-word; +} + +.chat-msg.user { + align-self: flex-end; + background: #6366f1; + color: #fff; + border-bottom-right-radius: 3px; +} + +.chat-msg.assistant { + align-self: flex-start; + background: #f1f3f7; + color: #1a1a1a; + border-bottom-left-radius: 3px; +} + +.chat-typing { + color: #9aa3af; + font-style: italic; +} + +.chat-msg.markdown :deep(p) { + margin: 0 0 0.5em; +} + +.chat-msg.markdown :deep(p:last-child) { + margin-bottom: 0; +} + +.chat-msg.markdown :deep(ul), +.chat-msg.markdown :deep(ol) { + margin: 0.3em 0; + padding-left: 1.2em; +} + +.chat-msg.markdown :deep(li) { + margin: 0.15em 0; +} + +.chat-msg.markdown :deep(code) { + background: #e4e7ee; + padding: 1px 4px; + border-radius: 4px; + font-family: "SF Mono", Consolas, monospace; + font-size: 0.8em; +} + +.chat-msg.markdown :deep(pre) { + background: #1e2330; + color: #e6e8ee; + padding: 8px 10px; + border-radius: 8px; + overflow-x: auto; + margin: 0.5em 0; +} + +.chat-msg.markdown :deep(pre code) { + background: none; + padding: 0; + color: inherit; + font-size: 0.78em; +} + +.chat-msg.markdown :deep(h1), +.chat-msg.markdown :deep(h2), +.chat-msg.markdown :deep(h3) { + font-size: 0.95em; + margin: 0.4em 0 0.2em; +} + +.chat-msg.markdown :deep(a) { + color: #4f46e5; +} + +.chat-msg.markdown :deep(table) { + border-collapse: collapse; + font-size: 0.95em; +} + +.chat-msg.markdown :deep(th), +.chat-msg.markdown :deep(td) { + border: 1px solid #d8dde3; + padding: 2px 6px; +} + +.chat-input { + display: flex; + gap: 6px; + padding: 0.6rem; + border-top: 1px solid #e2e5e9; +} + +.chat-input textarea { + flex: 1; + resize: none; + height: 38px; + padding: 8px 10px; + border: 1px solid #d8dde3; + border-radius: 8px; + font-size: 0.85rem; + font-family: inherit; + outline: none; +} + +.chat-input textarea:focus { + border-color: #6366f1; +} + +.chat-input button { + width: 38px; + border: none; + border-radius: 8px; + background: #6366f1; + color: #fff; + font-size: 1rem; + cursor: pointer; +} + +.chat-input button:disabled { + opacity: 0.4; + cursor: not-allowed; +}