update
This commit is contained in:
@@ -1500,48 +1500,69 @@ async def check_element(element: dict, provider: str = DEFAULT_PROVIDER) -> list
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def _element_rewrite(template: str, element: dict, provider: str, **extra) -> tuple[str, dict | None]:
|
def _element_json(element: dict) -> str:
|
||||||
"""Gemeinsames Muster: Element + Template → (Antwort, neue Felder|None)."""
|
return json.dumps(
|
||||||
|
{k: element[k] for k in ("title", "description", "examples", "hints")},
|
||||||
|
ensure_ascii=False, indent=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_change(c, element: dict) -> dict | None:
|
||||||
|
"""Validiert einen Änderungs-Vorschlag aus KI-Output gegen das Element."""
|
||||||
|
if not isinstance(c, dict):
|
||||||
|
return None
|
||||||
|
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"):
|
||||||
|
return None
|
||||||
|
if target not in ("title", "description", "examples", "hints"):
|
||||||
|
return None
|
||||||
|
if action in ("anpassen", "hinzufuegen") and not content:
|
||||||
|
return None
|
||||||
|
if action == "entfernen" and target not in ("examples", "hints"):
|
||||||
|
return None
|
||||||
|
# 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])):
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
index = None
|
||||||
|
if target == "examples" and action in ("anpassen", "hinzufuegen"):
|
||||||
|
content = _fence(content)
|
||||||
|
return {"text": text, "action": action, "target": target, "index": index, "content": content}
|
||||||
|
|
||||||
|
|
||||||
|
async def chat_with_element(element: dict, messages: list[dict], provider: str = DEFAULT_PROVIDER) -> tuple[str, list[dict]]:
|
||||||
|
"""Chat zum Element. Gibt (Antwort, Änderungs-Vorschläge) zurück — ändert nichts direkt."""
|
||||||
fehler = "Entschuldigung, das hat nicht geklappt. Bitte versuche es erneut."
|
fehler = "Entschuldigung, das hat nicht geklappt. Bitte versuche es erneut."
|
||||||
try:
|
try:
|
||||||
element_json = json.dumps(
|
transcript = "\n".join(
|
||||||
{k: element[k] for k in ("title", "description", "examples", "hints")},
|
f"{'Nutzer' if m.get('role') == 'user' else 'Assistent'}: {m.get('content', '')}"
|
||||||
ensure_ascii=False, indent=1,
|
for m in messages
|
||||||
)
|
)
|
||||||
prompt = _prompt(template, topic=element["topic"], element_json=element_json, **extra)
|
prompt = _prompt("Element-Chat", topic=element["topic"], element_json=_element_json(element), transcript=transcript)
|
||||||
returncode, stdout, _ = await run_agent(
|
returncode, stdout, _ = await run_agent(
|
||||||
f"element-{template.lower()}-" + str(uuid.uuid4()), prompt, 240,
|
"element-chat-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none"
|
||||||
provider=provider, role="fast", capabilities="none",
|
|
||||||
)
|
)
|
||||||
if returncode != 0:
|
if returncode != 0:
|
||||||
return fehler, None
|
return fehler, []
|
||||||
data = _parse_json_text(stdout)
|
data = _parse_json_text(stdout)
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
return fehler, None
|
return fehler, []
|
||||||
fields = _element_fields(data.get("element"))
|
changes = [v for c in data.get("changes", []) if (v := _validate_change(c, element))]
|
||||||
reply = str(data.get("reply", "")).strip() or ("Erledigt." if fields else fehler)
|
reply = str(data.get("reply", "")).strip() or ("Vorschläge erstellt." if changes else fehler)
|
||||||
return reply, fields
|
return reply, changes
|
||||||
except Exception:
|
except Exception:
|
||||||
return fehler, None
|
return fehler, []
|
||||||
|
|
||||||
|
|
||||||
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:
|
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."""
|
"""Prüft ein Element auf die Stil-Regeln und schlägt Änderungen vor. None bei Fehler."""
|
||||||
try:
|
try:
|
||||||
element_json = json.dumps(
|
prompt = _prompt("Element-Stil", topic=element["topic"], element_json=_element_json(element))
|
||||||
{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(
|
returncode, stdout, _ = await run_agent(
|
||||||
"element-stil-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none"
|
"element-stil-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none"
|
||||||
)
|
)
|
||||||
@@ -1550,32 +1571,28 @@ async def style_element(element: dict, provider: str = DEFAULT_PROVIDER) -> list
|
|||||||
data = _parse_json_text(stdout)
|
data = _parse_json_text(stdout)
|
||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
return None
|
return None
|
||||||
changes = []
|
return [v for c in data.get("changes", []) if (v := _validate_change(c, element))]
|
||||||
for c in data.get("changes", []):
|
except Exception:
|
||||||
if not isinstance(c, dict):
|
return None
|
||||||
continue
|
|
||||||
text = str(c.get("text", "")).strip()
|
|
||||||
action = c.get("action")
|
async def refine_suggestion(element: dict, suggestion: dict, instruction: str, provider: str = DEFAULT_PROVIDER) -> dict | None:
|
||||||
target = c.get("target")
|
"""Überarbeitet einen einzelnen Vorschlag nach Nutzer-Anweisung. None bei Fehler."""
|
||||||
index = c.get("index")
|
try:
|
||||||
content = str(c.get("content", "")).strip()
|
prompt = _prompt(
|
||||||
if not text or action not in ("entfernen", "anpassen", "hinzufuegen"):
|
"Element-Refine",
|
||||||
continue
|
topic=element["topic"], element_json=_element_json(element),
|
||||||
if target not in ("title", "description", "examples", "hints"):
|
suggestion_json=json.dumps(suggestion, ensure_ascii=False, indent=1),
|
||||||
continue
|
instruction=instruction,
|
||||||
if action in ("anpassen", "hinzufuegen") and not content:
|
)
|
||||||
continue
|
returncode, stdout, _ = await run_agent(
|
||||||
if action == "entfernen" and target not in ("examples", "hints"):
|
"element-refine-" + str(uuid.uuid4()), prompt, 240, provider=provider, role="fast", capabilities="none"
|
||||||
continue
|
)
|
||||||
# Index nur für anpassen/entfernen in Listen-Feldern; muss existieren
|
if returncode != 0:
|
||||||
if target in ("examples", "hints") and action in ("anpassen", "entfernen"):
|
return None
|
||||||
if not isinstance(index, int) or not (0 <= index < len(element[target])):
|
data = _parse_json_text(stdout)
|
||||||
continue
|
if not isinstance(data, dict):
|
||||||
else:
|
return None
|
||||||
index = None
|
return _validate_change(data.get("change"), element)
|
||||||
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:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -136,7 +136,17 @@ class ElementChatRequest(BaseModel):
|
|||||||
|
|
||||||
class ElementChatResponse(BaseModel):
|
class ElementChatResponse(BaseModel):
|
||||||
reply: str
|
reply: str
|
||||||
element: ElementResponse
|
changes: list[ElementStyleChange] = []
|
||||||
|
|
||||||
|
|
||||||
|
class ElementRefineRequest(BaseModel):
|
||||||
|
suggestion: ElementStyleChange
|
||||||
|
instruction: str = Field(min_length=1, max_length=2000)
|
||||||
|
provider: ProviderType = "claude"
|
||||||
|
|
||||||
|
|
||||||
|
class ElementRefineResponse(BaseModel):
|
||||||
|
change: ElementStyleChange
|
||||||
|
|
||||||
|
|
||||||
class ProgressUpdate(BaseModel):
|
class ProgressUpdate(BaseModel):
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from database import (
|
|||||||
from generator import (
|
from generator import (
|
||||||
generate_guide, cancel_guide, chat_with_guide, guide_slot_dateien,
|
generate_guide, cancel_guide, chat_with_guide, guide_slot_dateien,
|
||||||
generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine,
|
generate_bausteine, cancel_bausteine, bausteine_status, active_bausteine, reset_bausteine,
|
||||||
generate_element, chat_with_element, check_element, style_element,
|
generate_element, chat_with_element, check_element, style_element, refine_suggestion,
|
||||||
)
|
)
|
||||||
from models import (
|
from models import (
|
||||||
GuideCreateRequest, GuideResponse,
|
GuideCreateRequest, GuideResponse,
|
||||||
@@ -27,6 +27,7 @@ from models import (
|
|||||||
GuideChatRequest, GuideChatResponse,
|
GuideChatRequest, GuideChatResponse,
|
||||||
ElementCreateRequest, ElementChatRequest, ElementChatResponse, ElementResponse,
|
ElementCreateRequest, ElementChatRequest, ElementChatResponse, ElementResponse,
|
||||||
ElementUpdateRequest, ElementCheckRequest, ElementCheckResponse, ElementStyleResponse,
|
ElementUpdateRequest, ElementCheckRequest, ElementCheckResponse, ElementStyleResponse,
|
||||||
|
ElementRefineRequest, ElementRefineResponse,
|
||||||
ProgressUpdate, ProgressResponse, ProjectResponse, ProviderInfo,
|
ProgressUpdate, ProgressResponse, ProjectResponse, ProviderInfo,
|
||||||
)
|
)
|
||||||
from paths import bausteine_path, bausteine_topics, guide_content_path, project_dir, topic_dir
|
from paths import bausteine_path, bausteine_topics, guide_content_path, project_dir, topic_dir
|
||||||
@@ -288,12 +289,19 @@ async def element_chat(element_id: str, req: ElementChatRequest):
|
|||||||
element = await get_element(element_id)
|
element = await get_element(element_id)
|
||||||
if element is None:
|
if element is None:
|
||||||
raise HTTPException(404, "Element nicht gefunden")
|
raise HTTPException(404, "Element nicht gefunden")
|
||||||
reply, fields = await chat_with_element(element, [m.model_dump() for m in req.messages], provider=req.provider)
|
reply, changes = await chat_with_element(element, [m.model_dump() for m in req.messages], provider=req.provider)
|
||||||
if fields:
|
return {"reply": reply, "changes": changes}
|
||||||
now = datetime.now(timezone.utc).isoformat()
|
|
||||||
await update_element(element_id, **fields, updated_at=now)
|
|
||||||
element = await get_element(element_id)
|
@router.post("/elements/{element_id}/refine", response_model=ElementRefineResponse)
|
||||||
return {"reply": reply, "element": element}
|
async def element_refine(element_id: str, req: ElementRefineRequest):
|
||||||
|
element = await get_element(element_id)
|
||||||
|
if element is None:
|
||||||
|
raise HTTPException(404, "Element nicht gefunden")
|
||||||
|
change = await refine_suggestion(element, req.suggestion.model_dump(), req.instruction, provider=req.provider)
|
||||||
|
if change is None:
|
||||||
|
raise HTTPException(502, "Überarbeitung fehlgeschlagen — bitte erneut versuchen")
|
||||||
|
return {"change": change}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/elements/{element_id}", response_model=ElementResponse)
|
@router.put("/elements/{element_id}", response_model=ElementResponse)
|
||||||
|
|||||||
@@ -165,6 +165,16 @@ export async function styleElement(id, provider = 'claude') {
|
|||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function refineSuggestion(id, suggestion, instruction, provider = 'claude') {
|
||||||
|
const res = await fetch(`${BASE}/elements/${id}/refine`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ suggestion, instruction, provider }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`Überarbeitung fehlgeschlagen (${res.status})`)
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
export async function checkElement(id, provider = 'claude') {
|
export async function checkElement(id, provider = 'claude') {
|
||||||
const res = await fetch(`${BASE}/elements/${id}/check`, {
|
const res = await fetch(`${BASE}/elements/${id}/check`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
212
frontend/src/components/ElementSuggestion.vue
Normal file
212
frontend/src/components/ElementSuggestion.vue
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, nextTick } from 'vue'
|
||||||
|
import { renderMarkdown } from '../markdown.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
change: { type: Object, required: true },
|
||||||
|
busy: { type: Boolean, default: false },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['apply', 'dismiss', 'refine'])
|
||||||
|
|
||||||
|
const ACTION_LABELS = { entfernen: 'Entfernen:', anpassen: 'Anpassen:', hinzufuegen: 'Hinzufügen:' }
|
||||||
|
|
||||||
|
const editing = ref(false)
|
||||||
|
const instruction = ref('')
|
||||||
|
const inputEl = ref(null)
|
||||||
|
|
||||||
|
function toggleEdit() {
|
||||||
|
editing.value = !editing.value
|
||||||
|
if (editing.value) nextTick(() => inputEl.value?.focus())
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
const text = instruction.value.trim()
|
||||||
|
if (!text || props.busy) return
|
||||||
|
emit('refine', text)
|
||||||
|
instruction.value = ''
|
||||||
|
editing.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="style-sugg" :class="{ busy }">
|
||||||
|
<div class="style-sugg-text"><strong>{{ ACTION_LABELS[change.action] }}</strong> {{ change.text }}</div>
|
||||||
|
<div v-if="change.content" class="style-sugg-preview markdown" v-html="renderMarkdown(change.content)"></div>
|
||||||
|
<div class="style-sugg-actions">
|
||||||
|
<button class="sugg-ok" :disabled="busy" @click="emit('apply')">Bestätigen</button>
|
||||||
|
<button class="sugg-no" :disabled="busy" @click="emit('dismiss')">Ablehnen</button>
|
||||||
|
<button class="sugg-edit" :disabled="busy" title="Vorschlag per Anweisung anpassen" @click="toggleEdit">✏️</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="editing" class="sugg-edit-row">
|
||||||
|
<input
|
||||||
|
ref="inputEl"
|
||||||
|
v-model="instruction"
|
||||||
|
placeholder="Anweisung zum Vorschlag…"
|
||||||
|
@keyup.enter="submit"
|
||||||
|
/>
|
||||||
|
<button :disabled="!instruction.trim() || busy" @click="submit">➤</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.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.busy {
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
50% { opacity: 0.45; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
align-items: center;
|
||||||
|
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-edit {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 6px;
|
||||||
|
filter: grayscale(0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sugg-edit:hover {
|
||||||
|
background: var(--border);
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sugg-ok:disabled,
|
||||||
|
.sugg-no:disabled,
|
||||||
|
.sugg-edit:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sugg-edit-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sugg-edit-row input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--text);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sugg-edit-row input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sugg-edit-row button {
|
||||||
|
width: 30px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--on-accent);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sugg-edit-row button:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Markdown in der Vorschau */
|
||||||
|
.markdown :deep(p) {
|
||||||
|
margin: 0 0 0.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: 6px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
margin: 0.3em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown :deep(pre code) {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
color: inherit;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, nextTick } from 'vue'
|
import { ref, computed, watch, nextTick } from 'vue'
|
||||||
import { fetchElements, createElement, chatElement, deleteElement, updateElement, checkElement, styleElement } from '../api.js'
|
import { fetchElements, createElement, chatElement, deleteElement, updateElement, checkElement, styleElement, refineSuggestion } from '../api.js'
|
||||||
import { renderMarkdown } from '../markdown.js'
|
import { renderMarkdown } from '../markdown.js'
|
||||||
|
import ElementSuggestion from './ElementSuggestion.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
topic: { type: String, required: true },
|
topic: { type: String, required: true },
|
||||||
@@ -124,13 +125,35 @@ async function runCheck() {
|
|||||||
const styleChanges = ref(null) // null = noch nicht geprüft
|
const styleChanges = ref(null) // null = noch nicht geprüft
|
||||||
const styling = ref(false)
|
const styling = ref(false)
|
||||||
const applyingStyle = ref(false)
|
const applyingStyle = ref(false)
|
||||||
const ACTION_LABELS = { entfernen: 'Entfernen:', anpassen: 'Anpassen:', hinzufuegen: 'Hinzufügen:' }
|
const refiningIdx = ref(null)
|
||||||
let styleRun = 0
|
let styleRun = 0
|
||||||
|
|
||||||
function resetStyle() {
|
function resetStyle() {
|
||||||
styleChanges.value = null
|
styleChanges.value = null
|
||||||
styling.value = false
|
styling.value = false
|
||||||
applyingStyle.value = false
|
applyingStyle.value = false
|
||||||
|
refiningIdx.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function suggBusy(i) {
|
||||||
|
return applyingStyle.value || refiningIdx.value === i
|
||||||
|
}
|
||||||
|
|
||||||
|
// Einzelnen Vorschlag per Anweisung überarbeiten (Stift-Icon)
|
||||||
|
async function refineChange(i, instruction) {
|
||||||
|
if (refiningIdx.value !== null || applyingStyle.value || !selected.value) return
|
||||||
|
refiningIdx.value = i
|
||||||
|
try {
|
||||||
|
const res = await refineSuggestion(selected.value.id, styleChanges.value[i], instruction, props.provider)
|
||||||
|
const next = [...styleChanges.value]
|
||||||
|
next[i] = res.change
|
||||||
|
styleChanges.value = next
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Überarbeitung fehlgeschlagen:', e)
|
||||||
|
statusMsg.value = 'Überarbeitung fehlgeschlagen — bitte erneut versuchen.'
|
||||||
|
} finally {
|
||||||
|
refiningIdx.value = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runStyle() {
|
async function runStyle() {
|
||||||
@@ -247,28 +270,38 @@ async function scrollToBottom() {
|
|||||||
if (messagesEl.value) messagesEl.value.scrollTop = messagesEl.value.scrollHeight
|
if (messagesEl.value) messagesEl.value.scrollTop = messagesEl.value.scrollHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let chatRun = 0 // laufende Anfrage identifizieren; Abbruch ignoriert ihr Ergebnis
|
||||||
|
|
||||||
async function send() {
|
async function send() {
|
||||||
|
if (loading.value) { // zweiter Klick = abbrechen
|
||||||
|
chatRun++
|
||||||
|
loading.value = false
|
||||||
|
messages.value.push({ role: 'assistant', content: 'Abgebrochen.' })
|
||||||
|
return
|
||||||
|
}
|
||||||
const text = input.value.trim()
|
const text = input.value.trim()
|
||||||
if (!text || loading.value || !selected.value) return
|
if (!text || !selected.value) return
|
||||||
|
const run = ++chatRun
|
||||||
messages.value.push({ role: 'user', content: text })
|
messages.value.push({ role: 'user', content: text })
|
||||||
input.value = ''
|
input.value = ''
|
||||||
loading.value = true
|
loading.value = true
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
try {
|
try {
|
||||||
const res = await chatElement(selected.value.id, messages.value, props.provider)
|
const res = await chatElement(selected.value.id, messages.value, props.provider)
|
||||||
|
if (run !== chatRun) return
|
||||||
messages.value.push({ role: 'assistant', content: res.reply || '…' })
|
messages.value.push({ role: 'assistant', content: res.reply || '…' })
|
||||||
if (res.element) {
|
if (res.changes?.length) {
|
||||||
selected.value = res.element
|
styleChanges.value = [...(styleChanges.value || []), ...res.changes]
|
||||||
const i = elements.value.findIndex((e) => e.id === res.element.id)
|
|
||||||
if (i !== -1) elements.value.splice(i, 1, res.element)
|
|
||||||
emit('changed')
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
if (run !== chatRun) return
|
||||||
messages.value.push({ role: 'assistant', content: 'Fehler bei der Anfrage.' })
|
messages.value.push({ role: 'assistant', content: 'Fehler bei der Anfrage.' })
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (run === chatRun) {
|
||||||
scrollToBottom()
|
loading.value = false
|
||||||
nextTick(() => inputEl.value?.focus())
|
scrollToBottom()
|
||||||
|
nextTick(() => inputEl.value?.focus())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -324,59 +357,41 @@ async function send() {
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="el-detail">
|
<div 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>
|
||||||
<div
|
<ElementSuggestion
|
||||||
v-for="[ci, c] in [...styleAt('title'), ...styleAt('description'), ...styleAdds('description')]"
|
v-for="[ci, c] in [...styleAt('title'), ...styleAt('description'), ...styleAdds('description')]"
|
||||||
:key="'sgd' + ci" class="style-sugg"
|
:key="'sgd' + ci" :change="c" :busy="suggBusy(ci)"
|
||||||
>
|
@apply="applyStyleChange(ci)" @dismiss="dismissStyleChange(ci)" @refine="(t) => refineChange(ci, t)"
|
||||||
<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">
|
<template v-for="(ex, i) in selected.examples" :key="i">
|
||||||
<div class="el-entry markdown" v-html="renderMarkdown(ex)"></div>
|
<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">
|
<ElementSuggestion
|
||||||
<div class="style-sugg-text"><strong>{{ ACTION_LABELS[c.action] }}</strong> {{ c.text }}</div>
|
v-for="[ci, c] in styleAt('examples', i)"
|
||||||
<div v-if="c.content" class="style-sugg-preview markdown" v-html="renderMarkdown(c.content)"></div>
|
:key="'sge' + ci" :change="c" :busy="suggBusy(ci)"
|
||||||
<div class="style-sugg-actions">
|
@apply="applyStyleChange(ci)" @dismiss="dismissStyleChange(ci)" @refine="(t) => refineChange(ci, t)"
|
||||||
<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>
|
</template>
|
||||||
<div v-for="[ci, c] in styleAdds('examples')" :key="'sgea' + ci" class="style-sugg">
|
<ElementSuggestion
|
||||||
<div class="style-sugg-text"><strong>{{ ACTION_LABELS[c.action] }}</strong> {{ c.text }}</div>
|
v-for="[ci, c] in styleAdds('examples')"
|
||||||
<div v-if="c.content" class="style-sugg-preview markdown" v-html="renderMarkdown(c.content)"></div>
|
:key="'sgea' + ci" :change="c" :busy="suggBusy(ci)"
|
||||||
<div class="style-sugg-actions">
|
@apply="applyStyleChange(ci)" @dismiss="dismissStyleChange(ci)" @refine="(t) => refineChange(ci, t)"
|
||||||
<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">
|
<div v-if="selected.hints.length || styleAdds('hints').length" class="el-hints-block">
|
||||||
<h4>Hinweise</h4>
|
<h4>Hinweise</h4>
|
||||||
<ul class="el-hints">
|
<ul class="el-hints">
|
||||||
<li v-for="(h, i) in selected.hints" :key="i">
|
<li v-for="(h, i) in selected.hints" :key="i">
|
||||||
<span class="markdown" v-html="renderMarkdown(h)"></span>
|
<span class="markdown" v-html="renderMarkdown(h)"></span>
|
||||||
<div v-for="[ci, c] in styleAt('hints', i)" :key="'sgh' + ci" class="style-sugg">
|
<ElementSuggestion
|
||||||
<div class="style-sugg-text"><strong>{{ ACTION_LABELS[c.action] }}</strong> {{ c.text }}</div>
|
v-for="[ci, c] in styleAt('hints', i)"
|
||||||
<div v-if="c.content" class="style-sugg-preview markdown" v-html="renderMarkdown(c.content)"></div>
|
:key="'sgh' + ci" :change="c" :busy="suggBusy(ci)"
|
||||||
<div class="style-sugg-actions">
|
@apply="applyStyleChange(ci)" @dismiss="dismissStyleChange(ci)" @refine="(t) => refineChange(ci, t)"
|
||||||
<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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div v-for="[ci, c] in styleAdds('hints')" :key="'sgha' + ci" class="style-sugg">
|
<ElementSuggestion
|
||||||
<div class="style-sugg-text"><strong>{{ ACTION_LABELS[c.action] }}</strong> {{ c.text }}</div>
|
v-for="[ci, c] in styleAdds('hints')"
|
||||||
<div v-if="c.content" class="style-sugg-preview markdown" v-html="renderMarkdown(c.content)"></div>
|
:key="'sgha' + ci" :change="c" :busy="suggBusy(ci)"
|
||||||
<div class="style-sugg-actions">
|
@apply="applyStyleChange(ci)" @dismiss="dismissStyleChange(ci)" @refine="(t) => refineChange(ci, t)"
|
||||||
<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>
|
||||||
|
|
||||||
<div v-if="checking || styling || statusMsg" class="el-check">
|
<div v-if="checking || styling || statusMsg" class="el-check">
|
||||||
@@ -400,7 +415,12 @@ async function send() {
|
|||||||
placeholder="Element anpassen…"
|
placeholder="Element anpassen…"
|
||||||
@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"
|
||||||
|
:class="{ cancel: loading }"
|
||||||
|
:title="loading ? 'Abbrechen' : 'Senden'"
|
||||||
|
@click="send"
|
||||||
|
>{{ loading ? '✕' : '➤' }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -709,70 +729,6 @@ async function send() {
|
|||||||
font-size: 0.85em;
|
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 --- */
|
/* --- KI-Prüfung --- */
|
||||||
.el-check {
|
.el-check {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
@@ -983,4 +939,8 @@ async function send() {
|
|||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-input button.cancel {
|
||||||
|
background: var(--danger);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
Du hilfst, ein Lern-Element einer persönlichen Zusammenfassung zum Thema "{topic}" anzupassen.
|
Du hilfst, ein Lern-Element einer persönlichen Zusammenfassung zum Thema "{topic}" anzupassen. Du änderst NICHTS direkt — du schlägst Änderungen vor, der Nutzer bestätigt jede einzeln.
|
||||||
|
|
||||||
AKTUELLES ELEMENT (JSON):
|
AKTUELLES ELEMENT (JSON):
|
||||||
{element_json}
|
{element_json}
|
||||||
@@ -6,17 +6,24 @@ AKTUELLES ELEMENT (JSON):
|
|||||||
BISHERIGER CHAT-VERLAUF:
|
BISHERIGER CHAT-VERLAUF:
|
||||||
{transcript}
|
{transcript}
|
||||||
|
|
||||||
Setze die letzte Nutzer-Anweisung am Element um. Halte die Element-Regeln ein:
|
Setze die letzte Nutzer-Anweisung in Änderungs-Vorschläge um. Halte die Element-Regeln ein:
|
||||||
1. title — prägnanter Titel (max. 8 Wörter, reiner Text ohne Markdown/Backticks)
|
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
|
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.
|
3. examples — KURZ und SIMPEL: wenige Zeilen Code, das Minimalbeispiel. Jedes beginnt mit einem kurzen Kommentar in der Code-Syntax (z. B. `<!-- Einzelner Absatz -->`), der die Variante benennt. Als Codeblock mit Sprachangabe (```sprache).
|
||||||
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>`."
|
4. hints — jeder Hinweis muss WICHTIG oder NÜTZLICH sein. 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.
|
Umfang: SO LANG WIE NÖTIG und SO KURZ WIE MÖGLICH. Markdown: `inline-code` für Bezeichner, Tags und Befehle — IMMER in Backticks. Tonalität: klares Deutsch, direkt, keine Füllsätze.
|
||||||
|
|
||||||
Tonalität: klares Deutsch, direkt, praxisorientiert. Keine Füllsätze.
|
Jeder Vorschlag:
|
||||||
|
- text: kurz, was geändert wird (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 und hinzufuegen: null)
|
||||||
|
- content: der neue vollständige Inhalt (bei entfernen: leer)
|
||||||
|
|
||||||
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.
|
"entfernen" nur für examples/hints. Nur Vorschläge machen, die die Nutzer-Anweisung verlangt.
|
||||||
|
|
||||||
Gib NUR gültiges JSON aus, ohne Code-Fence, ohne weiteren Text:
|
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": ["..."]}}}}
|
{{"reply": "kurze Antwort an den Nutzer (1–2 Sätze)", "changes": [{{"text": "...", "action": "anpassen", "target": "hints", "index": 0, "content": "..."}}]}}
|
||||||
|
|
||||||
|
Reine Frage ohne Änderungswunsch → beantworte sie in reply, "changes": []
|
||||||
|
|||||||
24
templates/Prompt/Element-Refine.md
Normal file
24
templates/Prompt/Element-Refine.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
Du überarbeitest GENAU EINEN Änderungs-Vorschlag für ein Lern-Element zum Thema "{topic}" nach einer Nutzer-Anweisung.
|
||||||
|
|
||||||
|
AKTUELLES ELEMENT (JSON):
|
||||||
|
{element_json}
|
||||||
|
|
||||||
|
AKTUELLER VORSCHLAG (JSON):
|
||||||
|
{suggestion_json}
|
||||||
|
|
||||||
|
ANWEISUNG DES NUTZERS:
|
||||||
|
{instruction}
|
||||||
|
|
||||||
|
Passe den Vorschlag gemäß der Anweisung an. Behalte action/target/index bei, außer die Anweisung verlangt anderes.
|
||||||
|
|
||||||
|
Stil-Regeln für content: SO LANG WIE NÖTIG und SO KURZ WIE MÖGLICH. `inline-code` für Bezeichner, Tags und Befehle — IMMER in Backticks. examples als Codeblock mit Sprachangabe und kurzem Varianten-Kommentar (z. B. `<!-- Einzelner Absatz -->`). hints im Telegrammstil: nur die Kernaussage.
|
||||||
|
|
||||||
|
Felder:
|
||||||
|
- text: kurz, was geändert wird (max. 12 Wörter, reiner Text)
|
||||||
|
- action: "entfernen" | "anpassen" | "hinzufuegen"
|
||||||
|
- target: "title" | "description" | "examples" | "hints"
|
||||||
|
- index: 0-basierte Position im examples-/hints-Array (sonst null)
|
||||||
|
- content: der neue vollständige Inhalt (bei entfernen: leer)
|
||||||
|
|
||||||
|
Gib NUR gültiges JSON aus, ohne Code-Fence, ohne weiteren Text:
|
||||||
|
{{"change": {{"text": "...", "action": "anpassen", "target": "hints", "index": 0, "content": "..."}}}}
|
||||||
Reference in New Issue
Block a user