diff --git a/backend/version.py b/backend/version.py index cbdf9da..2dfe63c 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.164" +APP_VERSION = "0.8.165" BUILD_DATE = "2026-05-31" DB_SCHEMA_VERSION = "20260531071" @@ -42,6 +42,13 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.165", + "date": "2026-05-31", + "changes": [ + "Übungspicker Schnellanlage: KI-Vorschau-Dialog vor Speichern; Live-Bibliothekssuche (Titel+Skizze) mit Übernahme bestehender Übung.", + ], + }, { "version": "0.8.164", "date": "2026-05-31", diff --git a/frontend/src/components/ExerciseAiSuggestPreviewModal.jsx b/frontend/src/components/ExerciseAiSuggestPreviewModal.jsx new file mode 100644 index 0000000..0a21914 --- /dev/null +++ b/frontend/src/components/ExerciseAiSuggestPreviewModal.jsx @@ -0,0 +1,294 @@ +import React, { useEffect } from 'react' +import { describeAiSkillRowForPreview } from '../utils/exerciseAiQuickCreate' + +const summaryBoxSx = { + padding: '10px 12px', + borderRadius: '8px', + border: '1px solid var(--border)', + background: 'var(--surface2)', + fontSize: '13px', + lineHeight: 1.45, + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + minHeight: '72px', +} + +/** + * Modal: KI-Vorschlag prüfen (Formular + Schnellanlage). + */ +export default function ExerciseAiSuggestPreviewModal({ + preview, + onPreviewChange, + onDiscard, + onApply, + skillsCatalog = [], + dialogTitle = 'KI-Vorschlag prüfen', + hint = 'Vergleichen und nur die gewünschten Teile übernehmen.', + applyLabel = 'Ausgewähltes übernehmen', + applyDisabled = false, + zIndex = 2000, +}) { + useEffect(() => { + if (!preview) return undefined + const onKey = (e) => { + if (e.key === 'Escape') { + e.preventDefault() + e.stopPropagation() + onDiscard() + } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [preview, onDiscard]) + + if (!preview) return null + + const p = preview + const canApplySomething = + !applyDisabled && + ((p.applySummary && p.summaryAfterHtml) || + (p.skillChoices || []).some((c) => c.include) || + (p.instructionChoices || []).some((c) => c.include && c.afterHtml)) + + return ( +
onDiscard()} + > +
e.stopPropagation()} + > +

{dialogTitle}

+

{hint}

+ + {p.hasInstructionChoices ? ( +
+
+ Anleitung ({p.instructionChoices.length}{' '} + {p.instructionChoices.length === 1 ? 'Feld' : 'Felder'}) +
+ {p.instructionChoices.map((c) => ( +
+ +
+
+
+ Ausgang (Plaintext) +
+
{c.beforePlain || '(leer)'}
+
+
+
KI-Vorschlag
+
+ {c.afterPlain || '(leer)'} +
+
+
+
+ ))} +
+ ) : null} + + {p.hasSummaryProposal ? ( +
+
+ Kurzfassung +
+ +
+
+
Ausgang
+
{p.summaryBeforePlain || '(leer)'}
+
+
+
KI-Vorschlag
+
+ {p.summaryAfterPlain || '(leer)'} +
+
+
+
+ ) : null} + + {p.skillsRequested !== false && p.hasSkillChoices ? ( +
+
+ Fähigkeiten ({p.skillChoices.length}) +
+
    + {p.skillChoices.map((c) => ( +
  • + +
  • + ))} +
+
+ ) : null} + +
+ + +
+
+
+ ) +} diff --git a/frontend/src/components/ExercisePickerModal.jsx b/frontend/src/components/ExercisePickerModal.jsx index b9c5589..d7f8e51 100644 --- a/frontend/src/components/ExercisePickerModal.jsx +++ b/frontend/src/components/ExercisePickerModal.jsx @@ -17,7 +17,11 @@ import { import SkillTreeMultiSelect from './SkillTreeMultiSelect' import ExerciseFocusRulePicker from './ExerciseFocusRulePicker' import CatalogRulePicker from './CatalogRulePicker' -import { buildQuickCreateExercisePayload } from '../utils/exerciseAiQuickCreate' +import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal' +import { + buildQuickCreateAiPreview, + buildQuickCreateExercisePayloadFromPreview, +} from '../utils/exerciseAiQuickCreate' const PAGE_SIZE = 100 const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null) @@ -60,6 +64,10 @@ export default function ExercisePickerModal({ const [quickFocusAreaId, setQuickFocusAreaId] = useState('') const [quickSaving, setQuickSaving] = useState(false) const [quickAiError, setQuickAiError] = useState('') + const [quickCreatePreview, setQuickCreatePreview] = useState(null) + const [quickLibraryHits, setQuickLibraryHits] = useState([]) + const [quickLibraryLoading, setQuickLibraryLoading] = useState(false) + const [debouncedQuickLibraryQ, setDebouncedQuickLibraryQ] = useState('') const pickerScrollRef = useRef(null) const toggleMultiPick = (ex) => { @@ -78,6 +86,48 @@ export default function ExercisePickerModal({ return () => clearTimeout(t) }, [aiSearchInput]) + const quickLibraryQuery = useMemo(() => { + const t = (quickTitle || '').trim() + const s = (quickSketch || '').trim() + return [t, s].filter(Boolean).join(' ').trim() + }, [quickTitle, quickSketch]) + + useEffect(() => { + const t = setTimeout(() => setDebouncedQuickLibraryQ(quickLibraryQuery), 400) + return () => clearTimeout(t) + }, [quickLibraryQuery]) + + useEffect(() => { + if (!open || !quickOpen || !catalogsReady) { + setQuickLibraryHits([]) + return + } + if (debouncedQuickLibraryQ.length < 3) { + setQuickLibraryHits([]) + return + } + let cancelled = false + ;(async () => { + setQuickLibraryLoading(true) + try { + const q = { search: debouncedQuickLibraryQ, include_archived: true, limit: 8, offset: 0 } + if (Array.isArray(exerciseKindAny) && exerciseKindAny.length > 0) { + q.exercise_kind_any = exerciseKindAny + } + const batch = await api.listExercises(q) + if (!cancelled) setQuickLibraryHits(Array.isArray(batch) ? batch : []) + } catch (e) { + console.error(e) + if (!cancelled) setQuickLibraryHits([]) + } finally { + if (!cancelled) setQuickLibraryLoading(false) + } + })() + return () => { + cancelled = true + } + }, [open, quickOpen, catalogsReady, debouncedQuickLibraryQ, exerciseKindAny]) + useEffect(() => { if (!open) return let cancelled = false @@ -127,6 +177,9 @@ export default function ExercisePickerModal({ setQuickFocusAreaId('') setQuickSaving(false) setQuickAiError('') + setQuickCreatePreview(null) + setQuickLibraryHits([]) + setDebouncedQuickLibraryQ('') return } setFilters(mergeExerciseListPrefsFromApi(user?.exercise_list_prefs)) @@ -286,7 +339,17 @@ export default function ExercisePickerModal({ const resetFilters = () => setFilters({ ...INITIAL_FILTERS }) - const submitQuickCreate = async () => { + const adoptExistingExercise = async (ex) => { + if (!ex?.id) return + if (multiSelect && typeof onSelectExercises === 'function') { + await Promise.resolve(onSelectExercises([ex])) + } else if (typeof onSelectExercise === 'function') { + await Promise.resolve(onSelectExercise(ex)) + } + onClose() + } + + const runQuickCreateAiSuggest = async () => { const title = (quickTitle || '').trim() if (title.length < 3) { alert('Titel: mindestens 3 Zeichen.') @@ -294,12 +357,12 @@ export default function ExercisePickerModal({ } const sketch = (quickSketch || '').trim() if (!sketch) { - alert('Bitte eine kurze Skizze / Idee eingeben — die KI erzeugt daraus die Anleitung.') + alert('Bitte eine kurze Skizze / Idee eingeben.') return } const focusId = parseInt(String(quickFocusAreaId).trim(), 10) if (!Number.isFinite(focusId) || focusId < 1) { - alert('Bitte einen Fokusbereich wählen (für Fähigkeiten-Vorschläge).') + alert('Bitte einen Fokusbereich wählen.') return } @@ -307,6 +370,7 @@ export default function ExercisePickerModal({ const focusHint = (focusRow?.name || '').trim() setQuickAiError('') + setQuickCreatePreview(null) setQuickSaving(true) try { const aiRes = await api.suggestExerciseAi({ @@ -322,23 +386,38 @@ export default function ExercisePickerModal({ include_instructions: true, }) - const payload = buildQuickCreateExercisePayload({ + const preview = buildQuickCreateAiPreview(aiRes, { sketchPlain: sketch }) + if (!preview.hasSummaryProposal && !preview.hasInstructionChoices && !preview.hasSkillChoices) { + throw new Error('Die KI lieferte keinen verwertbaren Vorschlag.') + } + setQuickCreatePreview(preview) + } catch (e) { + console.error(e) + const msg = e?.message || String(e) + setQuickAiError(msg) + alert(msg || 'KI-Vorschlag fehlgeschlagen') + } finally { + setQuickSaving(false) + } + } + + const applyQuickCreatePreview = async () => { + const title = (quickTitle || '').trim() + const sketch = (quickSketch || '').trim() + const focusId = parseInt(String(quickFocusAreaId).trim(), 10) + if (!quickCreatePreview) return + + setQuickSaving(true) + setQuickAiError('') + try { + const payload = buildQuickCreateExercisePayloadFromPreview(quickCreatePreview, { title, focusAreaId: focusId, sketchPlain: sketch, - apiRes: aiRes, }) - const created = await api.createExercise(payload) - if (!created?.id) { - throw new Error('Anlegen fehlgeschlagen') - } - if (multiSelect && typeof onSelectExercises === 'function') { - await Promise.resolve(onSelectExercises([created])) - } else if (typeof onSelectExercise === 'function') { - await Promise.resolve(onSelectExercise(created)) - } - onClose() + if (!created?.id) throw new Error('Anlegen fehlgeschlagen') + await adoptExistingExercise(created) } catch (e) { console.error(e) const msg = e?.message || String(e) @@ -391,14 +470,14 @@ export default function ExercisePickerModal({ onClick={() => setQuickOpen((v) => !v)} aria-expanded={quickOpen} > - {quickOpen ? 'Neue Übung ausblenden' : 'Neue Übung mit KI anlegen'} + {quickOpen ? 'Neue Übung ausblenden' : 'Bibliothek prüfen / neu anlegen'} {quickOpen ? (

- Die KI erzeugt aus Titel und Skizze Anleitung (Ziel, Durchführung, Vorbereitung, - Trainer-Hinweise), eine Kurzbeschreibung und Fähigkeiten. Gespeichert - als Entwurf (privat) und direkt in den Ablauf übernommen. Benötigt OpenRouter auf dem Server. + Zuerst prüft die Suche die Bibliothek (Titel + Skizze). Passt eine Übung, direkt + übernehmen. Sonst erzeugt die KI einen Vorschlag — Anleitung, Kurzbeschreibung, Fähigkeiten — den du + im Dialog prüfst, bevor gespeichert wird.

+ +
+ Passende Übungen in der Bibliothek + {quickLibraryQuery.length < 3 ? ( +

+ Titel oder Skizze (mind. 3 Zeichen) — dann erscheinen Treffer. +

+ ) : quickLibraryLoading ? ( +

Suche läuft…

+ ) : quickLibraryHits.length === 0 ? ( +

+ Keine Treffer — unten KI-Vorschlag erzeugen. +

+ ) : ( + + )} +
+ {quickAiError ? (

{quickAiError}

) : null} @@ -461,9 +588,9 @@ export default function ExercisePickerModal({ !(quickSketch || '').trim() || !quickFocusAreaId } - onClick={submitQuickCreate} + onClick={runQuickCreateAiSuggest} > - {quickSaving ? 'KI erzeugt Übung…' : 'Mit KI anlegen und übernehmen'} + {quickSaving ? 'KI erzeugt Vorschlag…' : 'KI-Vorschlag erzeugen'}
@@ -819,6 +946,19 @@ export default function ExercisePickerModal({ )} + + setQuickCreatePreview(null)} + onApply={applyQuickCreatePreview} + skillsCatalog={catalogs.skills} + dialogTitle="Neue Übung — KI-Vorschlag prüfen" + hint="Felder und Fähigkeiten per Checkbox wählen. Erst „Anlegen und übernehmen“ speichert die Übung (Entwurf, privat) und übernimmt sie in den Ablauf." + applyLabel={quickSaving ? 'Wird angelegt…' : 'Anlegen und übernehmen'} + applyDisabled={quickSaving} + zIndex={2100} + /> ) } diff --git a/frontend/src/utils/exerciseAiQuickCreate.js b/frontend/src/utils/exerciseAiQuickCreate.js index a1ae1d1..29091bf 100644 --- a/frontend/src/utils/exerciseAiQuickCreate.js +++ b/frontend/src/utils/exerciseAiQuickCreate.js @@ -1,13 +1,21 @@ /** - * KI-gestützte Schnellanlage einer Übung (Planung / ExercisePickerModal). + * KI-gestützte Schnellanlage / Vorschau (Planung / ExercisePickerModal). */ import { stripHtmlToText } from './htmlUtils' -import { normalizeSkillLevelSlug } from '../constants/skillLevels' +import { normalizeSkillLevelSlug, formatSkillLevelSlug } from '../constants/skillLevels' import { EXERCISE_SKILL_INTENSITY_DEFAULT, normalizeExerciseSkillIntensity, + formatExerciseSkillIntensityLabel, } from '../constants/exerciseSkillIntensity' +export const INSTRUCTION_AI_FIELD_DEFS = [ + { key: 'goal', label: 'Ziel' }, + { key: 'execution', label: 'Durchführung' }, + { key: 'preparation', label: 'Vorbereitung / Aufbau' }, + { key: 'trainer_notes', label: 'Hinweise für Trainer' }, +] + function escapeHtmlText(s) { return String(s) .replace(/&/g, '&') @@ -41,38 +49,118 @@ export function normalizeAiSkillRowFromApi(sug) { } } -/** - * Baut createExercise-Payload aus suggestExerciseAi-Antwort. - * @throws {Error} wenn Ziel/Durchführung nach KI leer wären - */ -export function buildQuickCreateExercisePayload({ title, focusAreaId, sketchPlain, apiRes }) { +/** Vorschau für Schnellanlage: Kurzfassung + Anleitung + Fähigkeiten. */ +export function buildQuickCreateAiPreview(apiRes, { sketchPlain = '' } = {}) { const sketchHtml = aiPlainTextToMinimalHtml(sketchPlain) - const fields = apiRes?.instructions?.fields || {} + const snapshotInstructions = { + goal: sketchHtml, + execution: '', + preparation: '', + trainer_notes: '', + } - let goal = (fields.goal || '').trim() || sketchHtml - let execution = (fields.execution || '').trim() - const prep = (fields.preparation || '').trim() || null - const trainerNotes = (fields.trainer_notes || '').trim() || null + let summaryAfterHtml = null + let summaryAfterPlain = '' + if (apiRes?.summary?.text) { + summaryAfterPlain = String(apiRes.summary.text).trim() + if (summaryAfterPlain) { + summaryAfterHtml = aiPlainTextToMinimalHtml(apiRes.summary.text) + } + } + + const skillChoices = [] + if (Array.isArray(apiRes?.skills)) { + for (const sug of apiRes.skills) { + const after = normalizeAiSkillRowFromApi(sug) + if (!after) continue + skillChoices.push({ + key: String(after.skill_id), + skill_id: after.skill_id, + kind: 'add', + before: null, + after, + include: true, + }) + } + } + + const instructionChoices = [] + const fields = apiRes?.instructions?.fields || {} + for (const def of INSTRUCTION_AI_FIELD_DEFS) { + const afterHtml = fields[def.key] + if (!afterHtml || !String(afterHtml).trim()) continue + const beforeHtml = snapshotInstructions[def.key] || '' + instructionChoices.push({ + key: def.key, + field: def.key, + label: def.label, + beforePlain: stripHtmlToText(beforeHtml).trim(), + afterHtml: String(afterHtml), + afterPlain: stripHtmlToText(afterHtml).trim(), + include: true, + }) + } + + const hasSummaryProposal = !!summaryAfterHtml + const hasSkillChoices = skillChoices.length > 0 + const hasInstructionChoices = instructionChoices.length > 0 + + return { + mode: 'quick_create', + applySummary: hasSummaryProposal, + summaryBeforePlain: stripHtmlToText(sketchPlain).trim(), + summaryAfterPlain, + summaryAfterHtml, + skillChoices, + instructionChoices, + hasSummaryProposal, + hasSkillChoices, + hasInstructionChoices, + summaryRequested: true, + skillsRequested: true, + instructionsRequested: true, + } +} + +export function describeAiSkillRowForPreview(row, skillsCatalog) { + if (!row) return '' + const sk = (skillsCatalog || []).find((x) => Number(x.id) === Number(row.skill_id)) + const name = sk?.name || `Fähigkeit #${row.skill_id}` + const int = formatExerciseSkillIntensityLabel(row.intensity) + const from = formatSkillLevelSlug(row.required_level) || '—' + const to = formatSkillLevelSlug(row.target_level) || '—' + const prim = row.is_primary ? ' · Primär' : '' + return `${name}: Intensität ${int}, Niveau ${from} → ${to}${prim}` +} + +/** + * createExercise-Payload aus bestätigter Vorschau. + * @throws {Error} + */ +export function buildQuickCreateExercisePayloadFromPreview(preview, { title, focusAreaId, sketchPlain }) { + const sketchHtml = aiPlainTextToMinimalHtml(sketchPlain) + const fieldMap = {} + for (const c of preview?.instructionChoices || []) { + if (c.include && c.afterHtml) fieldMap[c.field] = c.afterHtml + } + + let goal = (fieldMap.goal || '').trim() || sketchHtml + let execution = (fieldMap.execution || '').trim() + const prep = (fieldMap.preparation || '').trim() || null + const trainerNotes = (fieldMap.trainer_notes || '').trim() || null if (!stripHtmlToText(goal).trim() && !stripHtmlToText(execution).trim()) { - throw new Error('KI lieferte keine verwertbare Anleitung (Ziel/Durchführung leer).') + throw new Error('Mindestens Ziel oder Durchführung muss übernommen werden.') } if (!stripHtmlToText(goal).trim()) goal = execution if (!stripHtmlToText(execution).trim()) execution = goal let summary = null - if (apiRes?.summary?.text) { - const sumHtml = aiPlainTextToMinimalHtml(apiRes.summary.text) - if (sumHtml) summary = sumHtml + if (preview?.applySummary && preview?.summaryAfterHtml) { + summary = preview.summaryAfterHtml } - const skills = [] - if (Array.isArray(apiRes?.skills)) { - for (const sug of apiRes.skills) { - const row = normalizeAiSkillRowFromApi(sug) - if (row) skills.push(row) - } - } + const skills = (preview?.skillChoices || []).filter((c) => c.include).map((c) => c.after) const fid = Number(focusAreaId) if (!Number.isFinite(fid) || fid < 1) { @@ -98,3 +186,9 @@ export function buildQuickCreateExercisePayload({ title, focusAreaId, sketchPlai club_id: null, } } + +/** @deprecated Direkt aus API — nutze Preview + buildQuickCreateExercisePayloadFromPreview */ +export function buildQuickCreateExercisePayload({ title, focusAreaId, sketchPlain, apiRes }) { + const preview = buildQuickCreateAiPreview(apiRes, { sketchPlain }) + return buildQuickCreateExercisePayloadFromPreview(preview, { title, focusAreaId, sketchPlain }) +}