This commit is contained in:
team3
2026-06-10 23:12:17 +02:00
parent 58fd209174
commit 54eaa1c89b
15 changed files with 367 additions and 41 deletions

View File

@@ -53,6 +53,16 @@ PROVIDERS = {
"quick": "minimax/MiniMax-M2.7-highspeed", "quick": "minimax/MiniMax-M2.7-highspeed",
"env_key": "MINIMAX_API_KEY", "env_key": "MINIMAX_API_KEY",
}, },
# Wie "minimax", aber Chat/Elemente (Rolle "fast") laufen auf M3 OHNE Thinking.
# M2.x kann Thinking nicht abschalten — nur M3 respektiert thinking:disabled.
# guide/quick bleiben identisch zur Thinking-Variante.
"minimax-direkt": {
"cli": "opencode",
"guide": "minimax/MiniMax-M3",
"fast": "minimax-direkt/MiniMax-M3",
"quick": "minimax/MiniMax-M2.7-highspeed",
"env_key": "MINIMAX_API_KEY",
},
"lokal": { "lokal": {
"cli": "opencode", "cli": "opencode",
"guide": "ollama/qwen3.6:27b", "guide": "ollama/qwen3.6:27b",

View File

@@ -42,6 +42,8 @@ CREATE TABLE IF NOT EXISTS elements (
description TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '',
examples TEXT NOT NULL DEFAULT '[]', examples TEXT NOT NULL DEFAULT '[]',
hints TEXT NOT NULL DEFAULT '[]', hints TEXT NOT NULL DEFAULT '[]',
aufgabe TEXT NOT NULL DEFAULT '',
loesung TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
) )
@@ -68,6 +70,11 @@ async def init_db():
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:
pass pass
for col in ("aufgabe", "loesung"): # Migration für Elemente ohne Aufgabe/Lösung
try:
await db.execute(f"ALTER TABLE elements ADD COLUMN {col} TEXT NOT NULL DEFAULT ''")
except aiosqlite.OperationalError:
pass
await db.execute( await db.execute(
"UPDATE guides SET status = 'error', progress = NULL, error_msg = 'Server-Neustart' " "UPDATE guides SET status = 'error', progress = NULL, error_msg = 'Server-Neustart' "
"WHERE status IN ('queued', 'generating')" "WHERE status IN ('queued', 'generating')"
@@ -166,8 +173,8 @@ def _element_row(row, cursor) -> dict:
async def create_element(element: dict) -> dict: async def create_element(element: dict) -> dict:
db = await get_db() db = await get_db()
await db.execute( await db.execute(
"""INSERT INTO elements (id, topic, title, description, examples, hints, created_at, updated_at) """INSERT INTO elements (id, topic, title, description, examples, hints, aufgabe, loesung, created_at, updated_at)
VALUES (:id, :topic, :title, :description, :examples, :hints, :created_at, :updated_at)""", VALUES (:id, :topic, :title, :description, :examples, :hints, :aufgabe, :loesung, :created_at, :updated_at)""",
{**element, "examples": json.dumps(element["examples"], ensure_ascii=False), {**element, "examples": json.dumps(element["examples"], ensure_ascii=False),
"hints": json.dumps(element["hints"], ensure_ascii=False)}, "hints": json.dumps(element["hints"], ensure_ascii=False)},
) )

View File

@@ -1396,6 +1396,8 @@ def _element_fields(data: dict) -> dict | None:
"description": str(data.get("description", "")).strip(), "description": str(data.get("description", "")).strip(),
"examples": listen["examples"], "examples": listen["examples"],
"hints": listen["hints"], "hints": listen["hints"],
"aufgabe": str(data.get("aufgabe", "")).strip(),
"loesung": str(data.get("loesung", "")).strip(),
} }
@@ -1418,7 +1420,7 @@ def _topic_context(topic: str, limit: int = 12000) -> str:
async def generate_element(topic: str, hint: str, provider: str = DEFAULT_PROVIDER) -> dict: async def generate_element(topic: str, hint: str, provider: str = DEFAULT_PROVIDER) -> dict:
"""Erstellt Element-Felder per KI. Fallback: nur Titel aus dem Stichwort.""" """Erstellt Element-Felder per KI. Fallback: nur Titel aus dem Stichwort."""
fallback = {"title": hint.strip() or "Neues Element", "description": "", "examples": [], "hints": []} fallback = {"title": hint.strip() or "Neues Element", "description": "", "examples": [], "hints": [], "aufgabe": "", "loesung": ""}
try: try:
prompt = _prompt( prompt = _prompt(
"Element-Create", "Element-Create",
@@ -1454,7 +1456,7 @@ def _parse_suggestions(stdout: str) -> list[dict] | None:
text = str(s.get("text", "")).strip() text = str(s.get("text", "")).strip()
target = s.get("target") target = s.get("target")
content = str(s.get("content", "")).strip() content = str(s.get("content", "")).strip()
if text and content and target in ("description", "examples", "hints"): if text and content and target in ("description", "examples", "hints", "aufgabe", "loesung"):
if target == "examples": if target == "examples":
content = _fence(content) content = _fence(content)
suggestions.append({"text": text, "target": target, "content": content}) suggestions.append({"text": text, "target": target, "content": content})
@@ -1465,7 +1467,7 @@ async def check_element(element: dict, provider: str = DEFAULT_PROVIDER) -> list
"""Zweischrittige Prüfung auf fehlende Infos: Recherche → Verifizieren. None bei Fehler.""" """Zweischrittige Prüfung auf fehlende Infos: Recherche → Verifizieren. None bei Fehler."""
try: try:
element_json = json.dumps( element_json = json.dumps(
{k: element[k] for k in ("title", "description", "examples", "hints")}, {k: element[k] for k in ("title", "description", "examples", "hints", "aufgabe", "loesung")},
ensure_ascii=False, indent=1, ensure_ascii=False, indent=1,
) )
context = _topic_context(element["topic"]) context = _topic_context(element["topic"])
@@ -1502,7 +1504,7 @@ async def check_element(element: dict, provider: str = DEFAULT_PROVIDER) -> list
def _element_json(element: dict) -> str: def _element_json(element: dict) -> str:
return json.dumps( return json.dumps(
{k: element[k] for k in ("title", "description", "examples", "hints")}, {k: element[k] for k in ("title", "description", "examples", "hints", "aufgabe", "loesung")},
ensure_ascii=False, indent=1, ensure_ascii=False, indent=1,
) )
@@ -1518,7 +1520,7 @@ def _validate_change(c, element: dict) -> dict | None:
content = str(c.get("content", "")).strip() content = str(c.get("content", "")).strip()
if not text or action not in ("entfernen", "anpassen", "hinzufuegen"): if not text or action not in ("entfernen", "anpassen", "hinzufuegen"):
return None return None
if target not in ("title", "description", "examples", "hints"): if target not in ("title", "description", "examples", "hints", "aufgabe", "loesung"):
return None return None
if action in ("anpassen", "hinzufuegen") and not content: if action in ("anpassen", "hinzufuegen") and not content:
return None return None

View File

@@ -8,7 +8,7 @@ FormatType = Literal[
"FullGuide", "FullGuide",
] ]
ProviderType = Literal["claude", "minimax", "lokal"] ProviderType = Literal["claude", "minimax", "minimax-direkt", "lokal"]
class GuideCreateRequest(BaseModel): class GuideCreateRequest(BaseModel):
@@ -86,6 +86,8 @@ class ElementResponse(BaseModel):
description: str = "" description: str = ""
examples: list[str] = [] examples: list[str] = []
hints: list[str] = [] hints: list[str] = []
aufgabe: str = ""
loesung: str = ""
created_at: str created_at: str
updated_at: str updated_at: str
@@ -101,6 +103,8 @@ class ElementUpdateRequest(BaseModel):
description: str | None = None description: str | None = None
examples: list[str] | None = None examples: list[str] | None = None
hints: list[str] | None = None hints: list[str] | None = None
aufgabe: str | None = None
loesung: str | None = None
class ElementCheckRequest(BaseModel): class ElementCheckRequest(BaseModel):
@@ -109,7 +113,7 @@ class ElementCheckRequest(BaseModel):
class ElementSuggestion(BaseModel): class ElementSuggestion(BaseModel):
text: str text: str
target: Literal["description", "examples", "hints"] target: Literal["description", "examples", "hints", "aufgabe", "loesung"]
content: str content: str
@@ -120,7 +124,7 @@ class ElementCheckResponse(BaseModel):
class ElementStyleChange(BaseModel): class ElementStyleChange(BaseModel):
text: str text: str
action: Literal["entfernen", "anpassen", "hinzufuegen"] action: Literal["entfernen", "anpassen", "hinzufuegen"]
target: Literal["title", "description", "examples", "hints"] target: Literal["title", "description", "examples", "hints", "aufgabe", "loesung"]
index: int | None = None index: int | None = None
content: str = "" content: str = ""

View File

@@ -11,6 +11,22 @@
} }
} }
}, },
"minimax-direkt": {
"npm": "@ai-sdk/anthropic",
"name": "MiniMax (ohne Thinking)",
"options": {
"baseURL": "https://api.minimax.io/anthropic/v1",
"apiKey": "{env:MINIMAX_API_KEY}"
},
"models": {
"MiniMax-M3": {
"name": "MiniMax M3 (ohne Thinking)",
"options": {
"thinking": { "type": "disabled" }
}
}
}
},
"ollama": { "ollama": {
"npm": "@ai-sdk/openai-compatible", "npm": "@ai-sdk/openai-compatible",
"name": "Ollama (lokal)", "name": "Ollama (lokal)",

View File

@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<link rel="icon" href="/favicon.ico"> <link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content">
<title>Creator</title> <title>Creator</title>
<script> <script>
(function () { (function () {

View File

@@ -259,7 +259,7 @@ function handlePreview(guide) {
function handleOpenElements() { function handleOpenElements() {
if (!selectedTopic.value) return if (!selectedTopic.value) return
elementsView.value = true elementsView.value = true
elementsOpen.value = true // Rechte Sidebar bleibt zu — sie öffnet erst beim Klick auf ein Element.
} }
function handleOpenElementDetail(el) { function handleOpenElementDetail(el) {
@@ -398,6 +398,11 @@ onUnmounted(() => {
<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>
<div
v-if="elementsOpen && selectedTopic"
class="elements-backdrop"
@click="elementsOpen = false"
></div>
<ElementsSidebar <ElementsSidebar
v-if="elementsOpen && selectedTopic" v-if="elementsOpen && selectedTopic"
:topic="selectedTopic" :topic="selectedTopic"
@@ -490,7 +495,7 @@ textarea::placeholder {
.layout { .layout {
display: flex; display: flex;
height: 100vh; height: 100dvh;
overflow: hidden; overflow: hidden;
position: relative; position: relative;
} }
@@ -500,7 +505,7 @@ textarea::placeholder {
left: 0; left: 0;
top: 0; top: 0;
width: 50px; width: 50px;
height: 100vh; height: 100dvh;
z-index: 5; z-index: 5;
cursor: pointer; cursor: pointer;
} }
@@ -518,7 +523,7 @@ textarea::placeholder {
position: fixed; position: fixed;
left: 0; left: 0;
top: 0; top: 0;
height: 100vh; height: 100dvh;
transform: translateX(-100%); transform: translateX(-100%);
transition: transform 0.2s ease; transition: transform 0.2s ease;
z-index: 10; z-index: 10;
@@ -539,4 +544,20 @@ textarea::placeholder {
color: var(--text-muted); color: var(--text-muted);
font-size: 1rem; font-size: 1rem;
} }
/* Nur sichtbar, wenn die Elemente-Sidebar mobil als Overlay liegt.
Tipp daneben schließt sie. */
.elements-backdrop {
display: none;
}
@media (max-width: 768px) {
.elements-backdrop {
display: block;
position: fixed;
inset: 0;
z-index: 29;
background: var(--shadow);
}
}
</style> </style>

View File

@@ -17,6 +17,9 @@ const elements = ref([])
const query = ref('') const query = ref('')
const creating = ref(false) const creating = ref(false)
const selected = ref(null) const selected = ref(null)
const tab = ref('overview') // 'overview' | 'chat' | 'edit'
const edit = ref({ title: '', description: '', examples: [], hints: [], aufgabe: '', loesung: '' })
const savingEdit = ref(false)
watch(() => props.topic, load, { immediate: true }) watch(() => props.topic, load, { immediate: true })
@@ -70,10 +73,10 @@ async function add() {
function select(el) { function select(el) {
selected.value = el selected.value = el
tab.value = 'overview'
messages.value = [] messages.value = []
input.value = '' input.value = ''
resetCheck() resetCheck()
nextTick(() => inputEl.value?.focus())
} }
function back() { function back() {
@@ -82,6 +85,48 @@ function back() {
resetCheck() resetCheck()
} }
// --- Bearbeiten-Tab: Felder direkt editieren ---
function loadEdit() {
if (!selected.value) return
edit.value = {
title: selected.value.title,
description: selected.value.description,
examples: [...selected.value.examples],
hints: [...selected.value.hints],
aufgabe: selected.value.aufgabe || '',
loesung: selected.value.loesung || '',
}
}
function openEdit() {
loadEdit()
tab.value = 'edit'
}
async function saveEdit() {
if (!selected.value || savingEdit.value) return
savingEdit.value = true
try {
const updated = await updateElement(selected.value.id, {
title: edit.value.title,
description: edit.value.description,
examples: edit.value.examples.filter((s) => s.trim()),
hints: edit.value.hints.filter((s) => s.trim()),
aufgabe: edit.value.aufgabe,
loesung: edit.value.loesung,
})
selected.value = updated
const idx = elements.value.findIndex((e) => e.id === updated.id)
if (idx !== -1) elements.value.splice(idx, 1, updated)
emit('changed')
tab.value = 'overview'
} catch (e) {
console.error('Speichern fehlgeschlagen:', e)
} finally {
savingEdit.value = false
}
}
// --- KI-Prüfung auf fehlende Infos (Ergebnisse landen als Inline-Vorschläge) --- // --- KI-Prüfung auf fehlende Infos (Ergebnisse landen als Inline-Vorschläge) ---
const checking = ref(false) const checking = ref(false)
const statusMsg = ref(null) const statusMsg = ref(null)
@@ -205,18 +250,22 @@ async function applyStyleChange(i) {
const c = styleChanges.value[i] const c = styleChanges.value[i]
applyingStyle.value = true applyingStyle.value = true
try { try {
const STRING_TARGETS = ['title', 'description', 'aufgabe', 'loesung']
const fields = { const fields = {
title: selected.value.title, title: selected.value.title,
description: selected.value.description, description: selected.value.description,
examples: [...selected.value.examples], examples: [...selected.value.examples],
hints: [...selected.value.hints], hints: [...selected.value.hints],
aufgabe: selected.value.aufgabe || '',
loesung: selected.value.loesung || '',
} }
if (c.action === 'entfernen') fields[c.target].splice(c.index, 1) if (c.action === 'entfernen') fields[c.target].splice(c.index, 1)
else if (c.action === 'hinzufuegen') { else if (c.action === 'hinzufuegen') {
if (c.target === 'title') fields.title = c.content if (c.target === 'title') fields.title = c.content
else if (c.target === 'description') fields.description += '\n\n' + c.content else if (c.target === 'description' || c.target === 'aufgabe' || c.target === 'loesung')
fields[c.target] = fields[c.target] ? fields[c.target] + '\n\n' + c.content : c.content
else fields[c.target].push(c.content) else fields[c.target].push(c.content)
} else if (c.target === 'title' || c.target === 'description') fields[c.target] = c.content } else if (STRING_TARGETS.includes(c.target)) fields[c.target] = c.content
else fields[c.target][c.index] = c.content else fields[c.target][c.index] = c.content
const updated = await updateElement(selected.value.id, fields) const updated = await updateElement(selected.value.id, fields)
@@ -355,7 +404,12 @@ async function send() {
<!-- Detail-Modus --> <!-- Detail-Modus -->
<template v-else> <template v-else>
<div class="el-detail"> <nav class="el-tabs">
<button :class="{ active: tab === 'overview' }" @click="tab = 'overview'">Übersicht</button>
<button :class="{ active: tab === 'chat' }" @click="tab = 'chat'">Chat</button>
<button :class="{ active: tab === 'edit' }" @click="openEdit">Bearbeiten</button>
</nav>
<div v-show="tab === 'overview'" class="el-detail">
<div v-if="selected.description" class="el-desc markdown" v-html="renderMarkdown(selected.description)"></div> <div v-if="selected.description" class="el-desc markdown" v-html="renderMarkdown(selected.description)"></div>
<ElementSuggestion <ElementSuggestion
v-for="[ci, c] in [...styleAt('title'), ...styleAt('description'), ...styleAdds('description')]" v-for="[ci, c] in [...styleAt('title'), ...styleAt('description'), ...styleAdds('description')]"
@@ -394,13 +448,32 @@ async function send() {
/> />
</div> </div>
<div v-if="selected.aufgabe || styleAt('aufgabe').length || styleAdds('aufgabe').length" class="el-task-block">
<h4>Aufgabe</h4>
<div v-if="selected.aufgabe" class="el-entry markdown" v-html="renderMarkdown(selected.aufgabe)"></div>
<ElementSuggestion
v-for="[ci, c] in [...styleAt('aufgabe'), ...styleAdds('aufgabe')]"
:key="'sga' + ci" :change="c" :busy="suggBusy(ci)"
@apply="applyStyleChange(ci)" @dismiss="dismissStyleChange(ci)" @refine="(t) => refineChange(ci, t)"
/>
</div>
<div v-if="selected.loesung || styleAt('loesung').length || styleAdds('loesung').length" class="el-task-block">
<h4>Lösung</h4>
<div v-if="selected.loesung" class="el-entry markdown" v-html="renderMarkdown(selected.loesung)"></div>
<ElementSuggestion
v-for="[ci, c] in [...styleAt('loesung'), ...styleAdds('loesung')]"
:key="'sgl' + ci" :change="c" :busy="suggBusy(ci)"
@apply="applyStyleChange(ci)" @dismiss="dismissStyleChange(ci)" @refine="(t) => refineChange(ci, t)"
/>
</div>
<div v-if="checking || styling || statusMsg" class="el-check"> <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="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="styling" class="check-empty busy-text">Prüft den Stil</p>
<p v-if="statusMsg && !checking && !styling" class="check-empty">{{ statusMsg }}</p> <p v-if="statusMsg && !checking && !styling" class="check-empty">{{ statusMsg }}</p>
</div> </div>
</div> </div>
<div class="el-chat"> <div v-show="tab === 'chat'" class="el-chat">
<div ref="messagesEl" class="chat-messages"> <div ref="messagesEl" class="chat-messages">
<p v-if="!messages.length" class="chat-hint">Schreib, was am Element geändert werden soll.</p> <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"> <template v-for="(m, i) in messages" :key="i">
@@ -423,6 +496,39 @@ async function send() {
>{{ loading ? '✕' : '➤' }}</button> >{{ loading ? '✕' : '➤' }}</button>
</div> </div>
</div> </div>
<!-- Bearbeiten-Modus: Felder direkt editieren -->
<div v-show="tab === 'edit'" class="el-edit">
<button class="edit-save" :disabled="savingEdit" @click="saveEdit">
{{ savingEdit ? 'Speichert' : 'Speichern' }}
</button>
<label>Titel</label>
<input v-model="edit.title" placeholder="Titel" />
<label>Beschreibung</label>
<textarea v-model="edit.description" placeholder="Beschreibung"></textarea>
<label>Beispiele</label>
<div v-for="(ex, i) in edit.examples" :key="'ex' + i" class="edit-row">
<textarea v-model="edit.examples[i]" placeholder="Beispiel"></textarea>
<button class="edit-del" title="Entfernen" @click="edit.examples.splice(i, 1)">×</button>
</div>
<button class="edit-add" @click="edit.examples.push('')">+ Beispiel</button>
<label>Hinweise</label>
<div v-for="(h, i) in edit.hints" :key="'hi' + i" class="edit-row">
<textarea v-model="edit.hints[i]" placeholder="Hinweis"></textarea>
<button class="edit-del" title="Entfernen" @click="edit.hints.splice(i, 1)">×</button>
</div>
<button class="edit-add" @click="edit.hints.push('')">+ Hinweis</button>
<label>Aufgabenstellung</label>
<textarea v-model="edit.aufgabe" placeholder="Aufgabenstellung"></textarea>
<label>Aufgabenlösung</label>
<textarea v-model="edit.loesung" placeholder="Aufgabenlösung"></textarea>
</div>
</template> </template>
</aside> </aside>
</template> </template>
@@ -431,7 +537,7 @@ async function send() {
.elements-sidebar { .elements-sidebar {
width: 320px; width: 320px;
min-width: 320px; min-width: 320px;
height: 100vh; height: 100dvh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: var(--panel); background: var(--panel);
@@ -441,6 +547,19 @@ async function send() {
z-index: 30; z-index: 30;
} }
/* Mobil/schmal: als Overlay über den Hauptinhalt legen, statt ihn
im Flex-Fluss einzuquetschen. */
@media (max-width: 768px) {
.elements-sidebar {
position: fixed;
top: 0;
right: 0;
width: min(100vw, 380px);
min-width: 0;
box-shadow: -4px 0 16px var(--shadow);
}
}
.el-header { .el-header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -500,6 +619,32 @@ async function send() {
animation: pulse 1.5s ease-in-out infinite; animation: pulse 1.5s ease-in-out infinite;
} }
.el-tabs {
display: flex;
border-bottom: 1px solid var(--border);
}
.el-tabs button {
flex: 1;
padding: 0.5rem 0.25rem;
border: none;
background: none;
color: var(--text-muted);
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
border-bottom: 2px solid transparent;
}
.el-tabs button.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.el-tabs button:hover:not(.active) {
color: var(--text);
}
.el-new { .el-new {
display: flex; display: flex;
gap: 6px; gap: 6px;
@@ -641,11 +786,13 @@ async function send() {
color: var(--text); color: var(--text);
} }
.el-hints-block { .el-hints-block,
.el-task-block {
margin-top: 0.9rem; margin-top: 0.9rem;
} }
.el-hints-block h4 { .el-hints-block h4,
.el-task-block h4 {
margin: 0 0 0.35rem; margin: 0 0 0.35rem;
font-size: 0.72rem; font-size: 0.72rem;
font-weight: 700; font-weight: 700;
@@ -846,13 +993,114 @@ async function send() {
cursor: not-allowed; cursor: not-allowed;
} }
/* Chat unten (Muster: TopicDetail-Chat) */ /* Chat-Tab (Muster: TopicDetail-Chat) */
.el-chat { .el-chat {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 0 0 33%; flex: 1;
min-height: 0; min-height: 0;
border-top: 1px solid var(--border); }
/* --- Bearbeiten-Tab --- */
.el-edit {
flex: 1;
overflow-y: auto;
padding: 0.9rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.el-edit label {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-faint);
margin-top: 0.5rem;
}
.el-edit input,
.el-edit textarea {
width: 100%;
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;
}
.el-edit textarea {
resize: vertical;
min-height: 120px;
overflow: auto;
line-height: 1.4;
}
.el-edit input:focus,
.el-edit textarea:focus {
border-color: var(--accent);
}
.edit-row {
display: flex;
gap: 6px;
align-items: flex-start;
}
.edit-row textarea {
flex: 1;
}
.edit-del {
flex-shrink: 0;
width: 30px;
align-self: stretch;
border: 1px solid var(--border-strong);
border-radius: 8px;
background: none;
color: var(--danger);
font-size: 1rem;
cursor: pointer;
}
.edit-add {
align-self: flex-start;
padding: 5px 10px;
border: 1px dashed var(--border-strong);
border-radius: 8px;
background: none;
color: var(--text-muted);
font-size: 0.78rem;
cursor: pointer;
}
.edit-add:hover {
border-color: var(--accent);
color: var(--accent);
}
.edit-save {
position: sticky;
top: 0;
z-index: 1;
margin-bottom: 0.3rem;
padding: 9px 10px;
border: none;
border-radius: 8px;
background: var(--accent);
color: var(--on-accent);
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
}
.edit-save:disabled {
opacity: 0.5;
cursor: wait;
} }
.chat-messages { .chat-messages {

View File

@@ -146,11 +146,19 @@ async function scrollToBottom() {
if (messagesEl.value) messagesEl.value.scrollTop = messagesEl.value.scrollHeight if (messagesEl.value) messagesEl.value.scrollTop = messagesEl.value.scrollHeight
} }
function autoGrow() {
const el = inputEl.value
if (!el) return
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 140) + 'px'
}
async function send() { async function send() {
const text = input.value.trim() const text = input.value.trim()
if (!text || loading.value || !props.previewGuide) return if (!text || loading.value || !props.previewGuide) return
messages.value.push({ role: 'user', content: text }) messages.value.push({ role: 'user', content: text })
input.value = '' input.value = ''
nextTick(autoGrow)
loading.value = true loading.value = true
scrollToBottom() scrollToBottom()
try { try {
@@ -237,7 +245,9 @@ async function send() {
<textarea <textarea
ref="inputEl" ref="inputEl"
v-model="input" v-model="input"
rows="3"
placeholder="Frage stellen…" placeholder="Frage stellen…"
@input="autoGrow"
@keydown.enter.exact.prevent="send" @keydown.enter.exact.prevent="send"
></textarea> ></textarea>
<button :disabled="!input.trim() || loading" @click="send"></button> <button :disabled="!input.trim() || loading" @click="send"></button>
@@ -252,7 +262,7 @@ async function send() {
/* Flex-Item darf schmaler werden als seine Code-Blöcke — sonst sprengt /* Flex-Item darf schmaler werden als seine Code-Blöcke — sonst sprengt
deren Mindestbreite auf Mobile das Layout */ deren Mindestbreite auf Mobile das Layout */
min-width: 0; min-width: 0;
height: 100vh; height: 100dvh;
position: relative; position: relative;
} }
@@ -269,7 +279,7 @@ async function send() {
margin: 0 auto; margin: 0 auto;
padding: 2rem 2.5rem 5rem; padding: 2rem 2.5rem 5rem;
/* Lese-Zoom nur für den Inhalt — Sidebar/Chat bleiben unverändert */ /* Lese-Zoom nur für den Inhalt — Sidebar/Chat bleiben unverändert */
zoom: 1.2; zoom: 1;
} }
@media (max-width: 600px) { @media (max-width: 600px) {
@@ -623,7 +633,7 @@ async function send() {
bottom: 1.5rem; bottom: 1.5rem;
width: 360px; width: 360px;
height: 500px; height: 500px;
max-height: calc(100vh - 3rem); max-height: calc(100dvh - 3rem);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: var(--panel); background: var(--panel);
@@ -706,6 +716,7 @@ async function send() {
.chat-input { .chat-input {
display: flex; display: flex;
align-items: stretch;
gap: 6px; gap: 6px;
padding: 0.6rem; padding: 0.6rem;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
@@ -714,12 +725,15 @@ async function send() {
.chat-input textarea { .chat-input textarea {
flex: 1; flex: 1;
resize: none; resize: none;
height: 38px; min-height: 72px;
max-height: 200px;
overflow-y: auto;
padding: 8px 10px; padding: 8px 10px;
border: 1px solid var(--border-strong); border: 1px solid var(--border-strong);
border-radius: 8px; border-radius: 8px;
font-size: 0.85rem; font-size: 0.85rem;
font-family: inherit; font-family: inherit;
line-height: 1.4;
outline: none; outline: none;
} }
@@ -729,6 +743,7 @@ async function send() {
.chat-input button { .chat-input button {
width: 38px; width: 38px;
flex-shrink: 0;
border: none; border: none;
border-radius: 8px; border-radius: 8px;
background: var(--accent); background: var(--accent);

View File

@@ -26,7 +26,7 @@ function providerAvailable(id) {
return p ? p.available : true return p ? p.available : true
} }
const PROVIDER_LABELS = { claude: 'Claude', minimax: 'MiniMax', lokal: 'Lokal' } const PROVIDER_LABELS = { claude: 'Claude', minimax: 'MiniMax', 'minimax-direkt': 'MiniMax direkt', lokal: 'Lokal' }
// Tracker oben in der Navigation: Themen gesamt, pro Format erstellt/absolviert // Tracker oben in der Navigation: Themen gesamt, pro Format erstellt/absolviert
const trackerItems = computed(() => { const trackerItems = computed(() => {
@@ -385,7 +385,7 @@ function confirmDeleteProject(name) {
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100vh; height: 100dvh;
} }
.new-topic { .new-topic {

View File

@@ -17,8 +17,8 @@ Umfang: SO LANG WIE NÖTIG und SO KURZ WIE MÖGLICH. Markdown: `inline-code` fü
Jeder Vorschlag: Jeder Vorschlag:
- text: kurz, was geändert wird (max. 12 Wörter, reiner Text) - text: kurz, was geändert wird (max. 12 Wörter, reiner Text)
- action: "entfernen" | "anpassen" | "hinzufuegen" - action: "entfernen" | "anpassen" | "hinzufuegen"
- target: "title" | "description" | "examples" | "hints" - target: "title" | "description" | "examples" | "hints" | "aufgabe" | "loesung"
- index: 0-basierte Position im AKTUELLEN examples- bzw. hints-Array (bei title/description und hinzufuegen: null) - index: 0-basierte Position im AKTUELLEN examples- bzw. hints-Array (bei title/description/aufgabe/loesung und hinzufuegen: null)
- content: der neue vollständige Inhalt (bei entfernen: leer) - content: der neue vollständige Inhalt (bei entfernen: leer)
"entfernen" nur für examples/hints. Nur Vorschläge machen, die die Nutzer-Anweisung verlangt. "entfernen" nur für examples/hints. Nur Vorschläge machen, die die Nutzer-Anweisung verlangt.

View File

@@ -6,12 +6,12 @@ AKTUELLES ELEMENT (JSON):
KONTEXT (Auszüge aus dem Themen-Material): KONTEXT (Auszüge aus dem Themen-Material):
{context} {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. RECHERCHE — sammle breit alle Kandidaten: fehlende Kernaussagen, wichtige Varianten, typische Stolperfallen, Best Practices. Fehlt eine Übungsaufgabe (aufgabe) oder deren Lösung (loesung), schlage sie vor. 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: Jeder Kandidat:
- text: kurze Beschreibung der Lücke (max. 12 Wörter, reiner Text) - text: kurze Beschreibung der Lücke (max. 12 Wörter, reiner Text)
- target: "description" | "examples" | "hints" - target: "description" | "examples" | "hints" | "aufgabe" | "loesung"
- 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. - 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>`."). aufgabe: EINE konkrete, in Minuten lösbare Übungsaufgabe; loesung: die knappe Musterlösung dazu. Tags/Bezeichner im Fließtext IMMER in Backticks.
Gib NUR gültiges JSON aus, ohne Code-Fence, ohne weiteren Text: Gib NUR gültiges JSON aus, ohne Code-Fence, ohne weiteren Text:
{{"suggestions": [{{"text": "...", "target": "hints", "content": "..."}}]}} {{"suggestions": [{{"text": "...", "target": "hints", "content": "..."}}]}}

View File

@@ -11,6 +11,8 @@ Erstelle GENAU EIN Element zum Stichwort:
2. description — was es ist und wozu: MAXIMAL 12 Sätze 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. 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.) 4. hints — IMMER leere Liste. Hinweise ergänzt der Nutzer später selbst. (Falls je gefordert: TELEGRAMMSTIL, max. 10 Wörter.)
5. aufgabe — GENAU EINE kleine Übungsaufgabe zum Konzept: konkret, in wenigen Minuten lösbar, prüft das Verständnis. Markdown, Code als Codeblock mit Sprachangabe.
6. loesung — die Musterlösung zur Aufgabe: knapp, nachvollziehbar, Schritt für Schritt nur wo nötig. Code als Codeblock mit Sprachangabe.
Das Element ist ATOMAR: allein verständlich, ohne dass der Leser etwas anderes gelesen hat. Benutzte Begriffe in einem Halbsatz auflösen. Das Element ist ATOMAR: allein verständlich, ohne dass der Leser etwas anderes gelesen hat. Benutzte Begriffe in einem Halbsatz auflösen.
@@ -21,4 +23,4 @@ Tonalität: klares Deutsch, direkt, praxisorientiert. Fachbegriffe beim ersten A
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. 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: Gib NUR gültiges JSON aus, ohne Code-Fence, ohne weiteren Text:
{{"title": "...", "description": "...", "examples": ["```sprache\n...\n```"], "hints": []}} {{"title": "...", "description": "...", "examples": ["```sprache\n...\n```"], "hints": [], "aufgabe": "...", "loesung": "..."}}

View File

@@ -8,6 +8,7 @@ STIL-REGELN:
2. description — was es ist und wozu: MAXIMAL 12 Sätze 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. 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. 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.
aufgabe — EINE konkrete, in Minuten lösbare Übungsaufgabe, die das Verständnis prüft. loesung — knappe, nachvollziehbare Musterlösung dazu. Beide: Code als Codeblock mit Sprachangabe.
Vorher: "Browser fügen standardmäßig vertikalen Abstand vor und nach `<p>` ein — anpassbar mit `margin`." Vorher: "Browser fügen standardmäßig vertikalen Abstand vor und nach `<p>` ein — anpassbar mit `margin`."
Nachher: "Browser-Abstand um `<p>` per `margin` anpassbar." 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. 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.
@@ -17,8 +18,8 @@ STIL-REGELN:
Schlage für jeden Stil-Verstoß GENAU EINE Änderung vor: Schlage für jeden Stil-Verstoß GENAU EINE Änderung vor:
- text: kurz, was und warum (max. 12 Wörter, reiner Text) - text: kurz, was und warum (max. 12 Wörter, reiner Text)
- action: "entfernen" | "anpassen" | "hinzufuegen" - action: "entfernen" | "anpassen" | "hinzufuegen"
- target: "title" | "description" | "examples" | "hints" - target: "title" | "description" | "examples" | "hints" | "aufgabe" | "loesung"
- index: 0-basierte Position im AKTUELLEN examples- bzw. hints-Array (bei title/description: null; bei hinzufuegen: null) - index: 0-basierte Position im AKTUELLEN examples- bzw. hints-Array (bei title/description/aufgabe/loesung: null; bei hinzufuegen: null)
- content: der neue/vollständige Inhalt (bei entfernen: leer) - 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. "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.

View File

@@ -13,7 +13,7 @@ Prüfe JEDEN Kandidaten kritisch:
1. WICHTIG? Muss ein Lerner das wissen? Nice-to-haves und Nischenwissen ablehnen. 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. 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. 3. KORREKT? Fachlich falsch oder irreführend → ablehnen.
4. PASST das target ("description" | "examples" | "hints")? Sonst korrigieren. 4. PASST das target ("description" | "examples" | "hints" | "aufgabe" | "loesung")? Sonst korrigieren. Höchstens EIN Vorschlag je für "aufgabe" und "loesung".
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). 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).