Enhance AI Quick Create Functionality in ExercisePickerModal
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 40s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m20s
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 40s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m20s
- Updated the quick create process to include a preview feature for AI-generated exercises, allowing users to review goals, execution, preparation, and trainer notes. - Introduced new constants for instruction fields and refactored the payload building function to utilize the preview data. - Improved error handling to ensure at least one of the goal or execution fields is populated. - Deprecated the previous payload building function in favor of the new preview-based approach, streamlining the exercise creation workflow.
This commit is contained in:
parent
4725eaa90b
commit
675cfa85f0
|
|
@ -1,6 +1,6 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.164"
|
APP_VERSION = "0.8.165"
|
||||||
BUILD_DATE = "2026-05-31"
|
BUILD_DATE = "2026-05-31"
|
||||||
DB_SCHEMA_VERSION = "20260531071"
|
DB_SCHEMA_VERSION = "20260531071"
|
||||||
|
|
||||||
|
|
@ -42,6 +42,13 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
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",
|
"version": "0.8.164",
|
||||||
"date": "2026-05-31",
|
"date": "2026-05-31",
|
||||||
|
|
|
||||||
294
frontend/src/components/ExerciseAiSuggestPreviewModal.jsx
Normal file
294
frontend/src/components/ExerciseAiSuggestPreviewModal.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={dialogTitle}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
inset: 0,
|
||||||
|
background: 'rgba(0,0,0,0.55)',
|
||||||
|
zIndex,
|
||||||
|
overflow: 'auto',
|
||||||
|
padding: '16px',
|
||||||
|
}}
|
||||||
|
onClick={() => onDiscard()}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="card"
|
||||||
|
style={{
|
||||||
|
maxWidth: 760,
|
||||||
|
margin: '3vh auto',
|
||||||
|
maxHeight: '92vh',
|
||||||
|
overflow: 'auto',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 style={{ marginTop: 0, fontSize: '1.1rem', marginBottom: '6px' }}>{dialogTitle}</h3>
|
||||||
|
<p style={{ fontSize: '13px', color: 'var(--text3)', marginTop: 0, marginBottom: '16px' }}>{hint}</p>
|
||||||
|
|
||||||
|
{p.hasInstructionChoices ? (
|
||||||
|
<section style={{ marginBottom: '20px' }} aria-labelledby="ai-preview-instructions-heading">
|
||||||
|
<div
|
||||||
|
id="ai-preview-instructions-heading"
|
||||||
|
style={{ fontWeight: 600, fontSize: '0.95rem', marginBottom: '10px' }}
|
||||||
|
>
|
||||||
|
Anleitung ({p.instructionChoices.length}{' '}
|
||||||
|
{p.instructionChoices.length === 1 ? 'Feld' : 'Felder'})
|
||||||
|
</div>
|
||||||
|
{p.instructionChoices.map((c) => (
|
||||||
|
<div
|
||||||
|
key={c.key}
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '12px',
|
||||||
|
marginBottom: '12px',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
marginBottom: '10px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={c.include}
|
||||||
|
onChange={(e) =>
|
||||||
|
onPreviewChange((prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
instructionChoices: prev.instructionChoices.map((x) =>
|
||||||
|
x.key === c.key ? { ...x, include: e.target.checked } : x,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: prev,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{c.label} übernehmen
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'minmax(0,1fr) minmax(0,1fr)',
|
||||||
|
gap: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '12px', color: 'var(--text3)', marginBottom: '4px' }}>
|
||||||
|
Ausgang (Plaintext)
|
||||||
|
</div>
|
||||||
|
<div style={summaryBoxSx}>{c.beforePlain || '(leer)'}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '12px', color: 'var(--text3)', marginBottom: '4px' }}>KI-Vorschlag</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...summaryBoxSx,
|
||||||
|
borderColor: 'var(--accent-dark, rgba(29,158,117,0.45))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{c.afterPlain || '(leer)'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{p.hasSummaryProposal ? (
|
||||||
|
<section style={{ marginBottom: '20px' }} aria-labelledby="ai-preview-summary-heading">
|
||||||
|
<div
|
||||||
|
id="ai-preview-summary-heading"
|
||||||
|
style={{ fontWeight: 600, fontSize: '0.95rem', marginBottom: '10px' }}
|
||||||
|
>
|
||||||
|
Kurzfassung
|
||||||
|
</div>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
marginBottom: '10px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={p.applySummary}
|
||||||
|
onChange={(e) =>
|
||||||
|
onPreviewChange((prev) => (prev ? { ...prev, applySummary: e.target.checked } : prev))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Kurzfassung übernehmen
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'minmax(0,1fr) minmax(0,1fr)',
|
||||||
|
gap: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '12px', color: 'var(--text3)', marginBottom: '4px' }}>Ausgang</div>
|
||||||
|
<div style={summaryBoxSx}>{p.summaryBeforePlain || '(leer)'}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '12px', color: 'var(--text3)', marginBottom: '4px' }}>KI-Vorschlag</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
...summaryBoxSx,
|
||||||
|
borderColor: 'var(--accent-dark, rgba(29,158,117,0.45))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{p.summaryAfterPlain || '(leer)'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{p.skillsRequested !== false && p.hasSkillChoices ? (
|
||||||
|
<section aria-labelledby="ai-preview-skills-heading">
|
||||||
|
<div
|
||||||
|
id="ai-preview-skills-heading"
|
||||||
|
style={{ fontWeight: 600, fontSize: '0.95rem', marginBottom: '10px' }}
|
||||||
|
>
|
||||||
|
Fähigkeiten ({p.skillChoices.length})
|
||||||
|
</div>
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
|
||||||
|
{p.skillChoices.map((c) => (
|
||||||
|
<li
|
||||||
|
key={c.key}
|
||||||
|
style={{
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '10px 12px',
|
||||||
|
marginBottom: '10px',
|
||||||
|
background: 'var(--surface)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: '8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={c.include}
|
||||||
|
style={{ marginTop: 3 }}
|
||||||
|
onChange={(e) =>
|
||||||
|
onPreviewChange((prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
skillChoices: prev.skillChoices.map((x) =>
|
||||||
|
x.key === c.key ? { ...x, include: e.target.checked } : x,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: prev,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>{describeAiSkillRowForPreview(c.after, skillsCatalog)}</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: '20px',
|
||||||
|
paddingTop: '14px',
|
||||||
|
borderTop: '1px solid var(--border)',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '10px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={() => onDiscard()}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={!canApplySomething}
|
||||||
|
onClick={() => onApply()}
|
||||||
|
>
|
||||||
|
{applyLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -17,7 +17,11 @@ import {
|
||||||
import SkillTreeMultiSelect from './SkillTreeMultiSelect'
|
import SkillTreeMultiSelect from './SkillTreeMultiSelect'
|
||||||
import ExerciseFocusRulePicker from './ExerciseFocusRulePicker'
|
import ExerciseFocusRulePicker from './ExerciseFocusRulePicker'
|
||||||
import CatalogRulePicker from './CatalogRulePicker'
|
import CatalogRulePicker from './CatalogRulePicker'
|
||||||
import { buildQuickCreateExercisePayload } from '../utils/exerciseAiQuickCreate'
|
import ExerciseAiSuggestPreviewModal from './ExerciseAiSuggestPreviewModal'
|
||||||
|
import {
|
||||||
|
buildQuickCreateAiPreview,
|
||||||
|
buildQuickCreateExercisePayloadFromPreview,
|
||||||
|
} from '../utils/exerciseAiQuickCreate'
|
||||||
|
|
||||||
const PAGE_SIZE = 100
|
const PAGE_SIZE = 100
|
||||||
const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
|
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 [quickFocusAreaId, setQuickFocusAreaId] = useState('')
|
||||||
const [quickSaving, setQuickSaving] = useState(false)
|
const [quickSaving, setQuickSaving] = useState(false)
|
||||||
const [quickAiError, setQuickAiError] = useState('')
|
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 pickerScrollRef = useRef(null)
|
||||||
|
|
||||||
const toggleMultiPick = (ex) => {
|
const toggleMultiPick = (ex) => {
|
||||||
|
|
@ -78,6 +86,48 @@ export default function ExercisePickerModal({
|
||||||
return () => clearTimeout(t)
|
return () => clearTimeout(t)
|
||||||
}, [aiSearchInput])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
@ -127,6 +177,9 @@ export default function ExercisePickerModal({
|
||||||
setQuickFocusAreaId('')
|
setQuickFocusAreaId('')
|
||||||
setQuickSaving(false)
|
setQuickSaving(false)
|
||||||
setQuickAiError('')
|
setQuickAiError('')
|
||||||
|
setQuickCreatePreview(null)
|
||||||
|
setQuickLibraryHits([])
|
||||||
|
setDebouncedQuickLibraryQ('')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setFilters(mergeExerciseListPrefsFromApi(user?.exercise_list_prefs))
|
setFilters(mergeExerciseListPrefsFromApi(user?.exercise_list_prefs))
|
||||||
|
|
@ -286,7 +339,17 @@ export default function ExercisePickerModal({
|
||||||
|
|
||||||
const resetFilters = () => setFilters({ ...INITIAL_FILTERS })
|
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()
|
const title = (quickTitle || '').trim()
|
||||||
if (title.length < 3) {
|
if (title.length < 3) {
|
||||||
alert('Titel: mindestens 3 Zeichen.')
|
alert('Titel: mindestens 3 Zeichen.')
|
||||||
|
|
@ -294,12 +357,12 @@ export default function ExercisePickerModal({
|
||||||
}
|
}
|
||||||
const sketch = (quickSketch || '').trim()
|
const sketch = (quickSketch || '').trim()
|
||||||
if (!sketch) {
|
if (!sketch) {
|
||||||
alert('Bitte eine kurze Skizze / Idee eingeben — die KI erzeugt daraus die Anleitung.')
|
alert('Bitte eine kurze Skizze / Idee eingeben.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const focusId = parseInt(String(quickFocusAreaId).trim(), 10)
|
const focusId = parseInt(String(quickFocusAreaId).trim(), 10)
|
||||||
if (!Number.isFinite(focusId) || focusId < 1) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -307,6 +370,7 @@ export default function ExercisePickerModal({
|
||||||
const focusHint = (focusRow?.name || '').trim()
|
const focusHint = (focusRow?.name || '').trim()
|
||||||
|
|
||||||
setQuickAiError('')
|
setQuickAiError('')
|
||||||
|
setQuickCreatePreview(null)
|
||||||
setQuickSaving(true)
|
setQuickSaving(true)
|
||||||
try {
|
try {
|
||||||
const aiRes = await api.suggestExerciseAi({
|
const aiRes = await api.suggestExerciseAi({
|
||||||
|
|
@ -322,23 +386,38 @@ export default function ExercisePickerModal({
|
||||||
include_instructions: true,
|
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,
|
title,
|
||||||
focusAreaId: focusId,
|
focusAreaId: focusId,
|
||||||
sketchPlain: sketch,
|
sketchPlain: sketch,
|
||||||
apiRes: aiRes,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const created = await api.createExercise(payload)
|
const created = await api.createExercise(payload)
|
||||||
if (!created?.id) {
|
if (!created?.id) throw new Error('Anlegen fehlgeschlagen')
|
||||||
throw new Error('Anlegen fehlgeschlagen')
|
await adoptExistingExercise(created)
|
||||||
}
|
|
||||||
if (multiSelect && typeof onSelectExercises === 'function') {
|
|
||||||
await Promise.resolve(onSelectExercises([created]))
|
|
||||||
} else if (typeof onSelectExercise === 'function') {
|
|
||||||
await Promise.resolve(onSelectExercise(created))
|
|
||||||
}
|
|
||||||
onClose()
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
const msg = e?.message || String(e)
|
const msg = e?.message || String(e)
|
||||||
|
|
@ -391,14 +470,14 @@ export default function ExercisePickerModal({
|
||||||
onClick={() => setQuickOpen((v) => !v)}
|
onClick={() => setQuickOpen((v) => !v)}
|
||||||
aria-expanded={quickOpen}
|
aria-expanded={quickOpen}
|
||||||
>
|
>
|
||||||
{quickOpen ? 'Neue Übung ausblenden' : 'Neue Übung mit KI anlegen'}
|
{quickOpen ? 'Neue Übung ausblenden' : 'Bibliothek prüfen / neu anlegen'}
|
||||||
</button>
|
</button>
|
||||||
{quickOpen ? (
|
{quickOpen ? (
|
||||||
<div style={{ marginTop: '12px', display: 'grid', gap: '10px' }}>
|
<div style={{ marginTop: '12px', display: 'grid', gap: '10px' }}>
|
||||||
<p style={{ margin: 0, fontSize: '13px', color: 'var(--text2)', lineHeight: 1.45 }}>
|
<p style={{ margin: 0, fontSize: '13px', color: 'var(--text2)', lineHeight: 1.45 }}>
|
||||||
Die KI erzeugt aus Titel und Skizze <strong>Anleitung</strong> (Ziel, Durchführung, Vorbereitung,
|
Zuerst prüft die Suche die <strong>Bibliothek</strong> (Titel + Skizze). Passt eine Übung, direkt
|
||||||
Trainer-Hinweise), eine <strong>Kurzbeschreibung</strong> und <strong>Fähigkeiten</strong>. Gespeichert
|
übernehmen. Sonst erzeugt die KI einen Vorschlag — Anleitung, Kurzbeschreibung, Fähigkeiten — den du
|
||||||
als Entwurf (privat) und direkt in den Ablauf übernommen. Benötigt OpenRouter auf dem Server.
|
im Dialog prüfst, bevor gespeichert wird.
|
||||||
</p>
|
</p>
|
||||||
<div>
|
<div>
|
||||||
<label className="form-label" htmlFor="ex-picker-quick-title">
|
<label className="form-label" htmlFor="ex-picker-quick-title">
|
||||||
|
|
@ -448,6 +527,54 @@ export default function ExercisePickerModal({
|
||||||
placeholder="Kurze Beschreibung: Ablauf, Material, Ziel der Übung …"
|
placeholder="Kurze Beschreibung: Ablauf, Material, Ziel der Übung …"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderTop: '1px solid var(--border)',
|
||||||
|
paddingTop: '12px',
|
||||||
|
marginTop: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<strong style={{ fontSize: '13px' }}>Passende Übungen in der Bibliothek</strong>
|
||||||
|
{quickLibraryQuery.length < 3 ? (
|
||||||
|
<p style={{ margin: '8px 0 0', fontSize: '12px', color: 'var(--text3)' }}>
|
||||||
|
Titel oder Skizze (mind. 3 Zeichen) — dann erscheinen Treffer.
|
||||||
|
</p>
|
||||||
|
) : quickLibraryLoading ? (
|
||||||
|
<p style={{ margin: '8px 0 0', fontSize: '12px', color: 'var(--text3)' }}>Suche läuft…</p>
|
||||||
|
) : quickLibraryHits.length === 0 ? (
|
||||||
|
<p style={{ margin: '8px 0 0', fontSize: '12px', color: 'var(--text3)' }}>
|
||||||
|
Keine Treffer — unten KI-Vorschlag erzeugen.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0, margin: '10px 0 0' }}>
|
||||||
|
{quickLibraryHits.map((ex) => (
|
||||||
|
<li key={ex.id} style={{ marginBottom: '8px' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
textAlign: 'left',
|
||||||
|
padding: '10px 12px',
|
||||||
|
display: 'block',
|
||||||
|
}}
|
||||||
|
onClick={() => adoptExistingExercise(ex)}
|
||||||
|
>
|
||||||
|
<strong style={{ display: 'block' }}>{ex.title}</strong>
|
||||||
|
{(ex.summary || '').trim() ? (
|
||||||
|
<span style={{ fontSize: '12px', color: 'var(--text2)' }}>
|
||||||
|
{(ex.summary || '').trim().slice(0, 100)}
|
||||||
|
{(ex.summary || '').trim().length > 100 ? '…' : ''}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{quickAiError ? (
|
{quickAiError ? (
|
||||||
<p style={{ margin: 0, fontSize: '13px', color: 'var(--danger)' }}>{quickAiError}</p>
|
<p style={{ margin: 0, fontSize: '13px', color: 'var(--danger)' }}>{quickAiError}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -461,9 +588,9 @@ export default function ExercisePickerModal({
|
||||||
!(quickSketch || '').trim() ||
|
!(quickSketch || '').trim() ||
|
||||||
!quickFocusAreaId
|
!quickFocusAreaId
|
||||||
}
|
}
|
||||||
onClick={submitQuickCreate}
|
onClick={runQuickCreateAiSuggest}
|
||||||
>
|
>
|
||||||
{quickSaving ? 'KI erzeugt Übung…' : 'Mit KI anlegen und übernehmen'}
|
{quickSaving ? 'KI erzeugt Vorschlag…' : 'KI-Vorschlag erzeugen'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -819,6 +946,19 @@ export default function ExercisePickerModal({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ExerciseAiSuggestPreviewModal
|
||||||
|
preview={quickCreatePreview}
|
||||||
|
onPreviewChange={setQuickCreatePreview}
|
||||||
|
onDiscard={() => 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}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,21 @@
|
||||||
/**
|
/**
|
||||||
* KI-gestützte Schnellanlage einer Übung (Planung / ExercisePickerModal).
|
* KI-gestützte Schnellanlage / Vorschau (Planung / ExercisePickerModal).
|
||||||
*/
|
*/
|
||||||
import { stripHtmlToText } from './htmlUtils'
|
import { stripHtmlToText } from './htmlUtils'
|
||||||
import { normalizeSkillLevelSlug } from '../constants/skillLevels'
|
import { normalizeSkillLevelSlug, formatSkillLevelSlug } from '../constants/skillLevels'
|
||||||
import {
|
import {
|
||||||
EXERCISE_SKILL_INTENSITY_DEFAULT,
|
EXERCISE_SKILL_INTENSITY_DEFAULT,
|
||||||
normalizeExerciseSkillIntensity,
|
normalizeExerciseSkillIntensity,
|
||||||
|
formatExerciseSkillIntensityLabel,
|
||||||
} from '../constants/exerciseSkillIntensity'
|
} 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) {
|
function escapeHtmlText(s) {
|
||||||
return String(s)
|
return String(s)
|
||||||
.replace(/&/g, '&')
|
.replace(/&/g, '&')
|
||||||
|
|
@ -41,38 +49,118 @@ export function normalizeAiSkillRowFromApi(sug) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Vorschau für Schnellanlage: Kurzfassung + Anleitung + Fähigkeiten. */
|
||||||
* Baut createExercise-Payload aus suggestExerciseAi-Antwort.
|
export function buildQuickCreateAiPreview(apiRes, { sketchPlain = '' } = {}) {
|
||||||
* @throws {Error} wenn Ziel/Durchführung nach KI leer wären
|
|
||||||
*/
|
|
||||||
export function buildQuickCreateExercisePayload({ title, focusAreaId, sketchPlain, apiRes }) {
|
|
||||||
const sketchHtml = aiPlainTextToMinimalHtml(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 summaryAfterHtml = null
|
||||||
let execution = (fields.execution || '').trim()
|
let summaryAfterPlain = ''
|
||||||
const prep = (fields.preparation || '').trim() || null
|
if (apiRes?.summary?.text) {
|
||||||
const trainerNotes = (fields.trainer_notes || '').trim() || null
|
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()) {
|
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(goal).trim()) goal = execution
|
||||||
if (!stripHtmlToText(execution).trim()) execution = goal
|
if (!stripHtmlToText(execution).trim()) execution = goal
|
||||||
|
|
||||||
let summary = null
|
let summary = null
|
||||||
if (apiRes?.summary?.text) {
|
if (preview?.applySummary && preview?.summaryAfterHtml) {
|
||||||
const sumHtml = aiPlainTextToMinimalHtml(apiRes.summary.text)
|
summary = preview.summaryAfterHtml
|
||||||
if (sumHtml) summary = sumHtml
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const skills = []
|
const skills = (preview?.skillChoices || []).filter((c) => c.include).map((c) => c.after)
|
||||||
if (Array.isArray(apiRes?.skills)) {
|
|
||||||
for (const sug of apiRes.skills) {
|
|
||||||
const row = normalizeAiSkillRowFromApi(sug)
|
|
||||||
if (row) skills.push(row)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fid = Number(focusAreaId)
|
const fid = Number(focusAreaId)
|
||||||
if (!Number.isFinite(fid) || fid < 1) {
|
if (!Number.isFinite(fid) || fid < 1) {
|
||||||
|
|
@ -98,3 +186,9 @@ export function buildQuickCreateExercisePayload({ title, focusAreaId, sketchPlai
|
||||||
club_id: null,
|
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 })
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user