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) => (
+
+
+
+ onPreviewChange((prev) =>
+ prev
+ ? {
+ ...prev,
+ instructionChoices: prev.instructionChoices.map((x) =>
+ x.key === c.key ? { ...x, include: e.target.checked } : x,
+ ),
+ }
+ : prev,
+ )
+ }
+ />
+ {c.label} übernehmen
+
+
+
+
+ Ausgang (Plaintext)
+
+
{c.beforePlain || '(leer)'}
+
+
+
KI-Vorschlag
+
+ {c.afterPlain || '(leer)'}
+
+
+
+
+ ))}
+
+ ) : null}
+
+ {p.hasSummaryProposal ? (
+
+
+ Kurzfassung
+
+
+
+ onPreviewChange((prev) => (prev ? { ...prev, applySummary: e.target.checked } : prev))
+ }
+ />
+ Kurzfassung übernehmen
+
+
+
+
Ausgang
+
{p.summaryBeforePlain || '(leer)'}
+
+
+
KI-Vorschlag
+
+ {p.summaryAfterPlain || '(leer)'}
+
+
+
+
+ ) : null}
+
+ {p.skillsRequested !== false && p.hasSkillChoices ? (
+
+
+ Fähigkeiten ({p.skillChoices.length})
+
+
+
+ ) : null}
+
+
+ onDiscard()}>
+ Abbrechen
+
+ onApply()}
+ >
+ {applyLabel}
+
+
+
+
+ )
+}
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.
@@ -448,6 +527,54 @@ export default function ExercisePickerModal({
placeholder="Kurze Beschreibung: Ablauf, Material, Ziel der Übung …"
/>
+
+
+
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.
+
+ ) : (
+
+ {quickLibraryHits.map((ex) => (
+
+ adoptExistingExercise(ex)}
+ >
+ {ex.title}
+ {(ex.summary || '').trim() ? (
+
+ {(ex.summary || '').trim().slice(0, 100)}
+ {(ex.summary || '').trim().length > 100 ? '…' : ''}
+
+ ) : null}
+
+
+ ))}
+
+ )}
+
+
{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 })
+}