Enhance AI Exercise Suggestion Functionality and UX
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 38s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 38s
Test Suite / playwright-tests (push) Successful in 1m21s
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 38s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 38s
Test Suite / playwright-tests (push) Successful in 1m21s
- Updated the AI Exercise Implementation Plan to include a detailed description of the new suggestion dialog for AI proposals, allowing users to preview and selectively adopt AI-generated summaries and skills. - Implemented a new preview feature in the ExerciseFormPageRoot component, enabling users to review AI suggestions before applying them to the form. - Enhanced the skill management process by normalizing AI-suggested skills and integrating them into the exercise form, improving user interaction and data handling.
This commit is contained in:
parent
4d36bbf634
commit
e5291256d0
|
|
@ -25,7 +25,7 @@
|
|||
| **S1** | Migration `ai_prompts` + Defaults `exercise_summary`, `exercise_skill_suggestions`; `exercises.summary_ai_generated` | Migrierte DB, App startet |
|
||||
| **S2** | `httpx`-Client OpenRouter; Modul lädt Prompt, ersetzt Platzhalter, parst Antwort | Unit-/Smoke: 503 ohne Key |
|
||||
| **S3** | `POST /api/exercises/ai/suggest`, `POST /api/exercises/{id}/ai/regenerate` | OpenAPI/Handtest mit Key |
|
||||
| **S4** | Frontend: KI-Vorschlag, Teilübernahme Summary + Skills | Manuelle UX-Prüfung |
|
||||
| **S4** | Frontend: KI-Vorschlag, **Änderungsdialog** (Vorschau, Kurzfassung wählbar, Fähigkeiten pro Zeile an-/abwählbar), dann Übernahme ins Formular | Manuelle UX-Prüfung |
|
||||
| **S5** | (später) Auto-Fallback beim Speichern laut `KI_FEATURES_SPEC` §7 | Feature-Flag / Config |
|
||||
| **S6** | (später) Zielausbau, Anleitung-only, Varianten, Admin-Masse laut Vision | Separate Epics |
|
||||
|
||||
|
|
@ -47,12 +47,15 @@
|
|||
|
||||
- **2026-05-22:** Initial; S1–S4 als erster Umsetzungspfad.
|
||||
- **2026-05-22:** S1–S4 im Code umgesetzt (Migration 067, `exercise_ai` + Router, Übungsformular); S5 weiter offen.
|
||||
- **2026-05-22:** UX: Übernahmedialog für KI-Vorschläge (Vorschau, selektive Übernahme) im Übungsformular (`ExerciseFormPageRoot`).
|
||||
|
||||
---
|
||||
|
||||
## 5. Umsetzungsstand (Zwischencheckpoint)
|
||||
|
||||
**Erledigt (2026-05-22):** Migration **`067_ai_prompts_exercise_assistant`**, **`openrouter_chat`**, **`exercise_ai`**, **`POST /api/exercises/ai/suggest`** und **`POST /api/exercises/{id}/ai/regenerate`**, Formular-Schaltflächen (Kurzfassung / Fähigkeiten / kombiniert).
|
||||
**Erledigt (2026-05-22):** Migration **`067_ai_prompts_exercise_assistant`**, **`openrouter_chat`**, **`exercise_ai`**, **`POST /api/exercises/ai/suggest`** und **`POST /api/exercises/{id}/ai/regenerate`**, Formular-Schaltflächen (Kurzfassung / Fähigkeiten / kombiniert).
|
||||
|
||||
**Nacharbeit S4 UX:** Übernahmedialog **`ExerciseFormPageRoot`**: keine sofortige Überschreibung; Kurzfassung mit Vergleich + Checkbox; Fähigkeiten mit Neu/Aktualisierung, Checkboxen, „Alle auswählen/abwählen“; **`Escape`** schließt; KI-Schaltflächen blockiert solange Dialog offen.
|
||||
|
||||
**Bewusst noch nicht:** automatische KI beim Speichern (**S5**), Setzen von `summary_ai_generated` bei manuellen UI-Änderungen, Prompt-Admin-UI, Rate-Limits.
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import {
|
|||
buildExerciseMediaDragPayload,
|
||||
} from '../../utils/exerciseInlineMediaRefs'
|
||||
import { autoScrollForDragNearEdges } from '../../utils/dragAutoScroll'
|
||||
import { normalizeSkillLevelSlug } from '../../constants/skillLevels'
|
||||
import { normalizeSkillLevelSlug, formatSkillLevelSlug } from '../../constants/skillLevels'
|
||||
import { stripHtmlToText } from '../../utils/htmlUtils'
|
||||
import ExerciseCatalogAssocEditor from './ExerciseCatalogAssocEditor'
|
||||
import ExerciseSkillsEditor from './ExerciseSkillsEditor'
|
||||
|
|
@ -44,6 +44,7 @@ import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../../hooks/useUn
|
|||
import {
|
||||
EXERCISE_SKILL_INTENSITY_DEFAULT,
|
||||
normalizeExerciseSkillIntensity,
|
||||
formatExerciseSkillIntensityLabel,
|
||||
} from '../../constants/exerciseSkillIntensity'
|
||||
import {
|
||||
EXERCISE_VISIBILITY_CLUB_FIELD_LABEL,
|
||||
|
|
@ -89,6 +90,85 @@ function aiPlainSummaryToMinimalHtml(text) {
|
|||
return paras.map((p) => `<p>${escapeHtmlText(p)}</p>`).join('')
|
||||
}
|
||||
|
||||
function cloneExerciseSkillRows(rows) {
|
||||
return Array.isArray(rows) ? rows.map((s) => ({ ...s })) : []
|
||||
}
|
||||
|
||||
function buildNormalizedAiSkillRowFromApi(sug) {
|
||||
const sid = Number(sug.skill_id)
|
||||
if (!Number.isFinite(sid)) return null
|
||||
return {
|
||||
skill_id: sid,
|
||||
intensity: normalizeExerciseSkillIntensity(sug.intensity),
|
||||
required_level: normalizeSkillLevelSlug(sug.required_level) || 'grundlagen',
|
||||
target_level:
|
||||
normalizeSkillLevelSlug(sug.target_level) ||
|
||||
normalizeSkillLevelSlug(sug.required_level) ||
|
||||
'grundlagen',
|
||||
is_primary: !!sug.is_primary,
|
||||
ai_suggested: true,
|
||||
}
|
||||
}
|
||||
|
||||
function buildExerciseAiSuggestionPreview({ mode, snapshotSummaryHtml, snapshotSkills, apiRes }) {
|
||||
const summaryRequested = mode !== 'skills'
|
||||
const skillsRequested = mode !== 'summary'
|
||||
|
||||
let summaryAfterHtml = null
|
||||
let summaryAfterPlain = ''
|
||||
if (summaryRequested && apiRes.summary?.text) {
|
||||
summaryAfterPlain = String(apiRes.summary.text).trim()
|
||||
if (summaryAfterPlain) {
|
||||
summaryAfterHtml = aiPlainSummaryToMinimalHtml(apiRes.summary.text)
|
||||
}
|
||||
}
|
||||
|
||||
const skillChoices = []
|
||||
if (skillsRequested && Array.isArray(apiRes.skills)) {
|
||||
for (const sug of apiRes.skills) {
|
||||
const after = buildNormalizedAiSkillRowFromApi(sug)
|
||||
if (!after) continue
|
||||
const ix = snapshotSkills.findIndex((s) => Number(s.skill_id) === after.skill_id)
|
||||
const before = ix >= 0 ? { ...snapshotSkills[ix] } : null
|
||||
skillChoices.push({
|
||||
key: String(after.skill_id),
|
||||
skill_id: after.skill_id,
|
||||
kind: before ? 'update' : 'add',
|
||||
before,
|
||||
after,
|
||||
include: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const hasSummaryProposal = !!(summaryRequested && summaryAfterHtml)
|
||||
const hasSkillChoices = skillChoices.length > 0
|
||||
|
||||
return {
|
||||
mode,
|
||||
applySummary: hasSummaryProposal,
|
||||
summaryBeforePlain: stripHtmlToText(snapshotSummaryHtml || '').trim(),
|
||||
summaryAfterPlain,
|
||||
summaryAfterHtml,
|
||||
skillChoices,
|
||||
hasSummaryProposal,
|
||||
hasSkillChoices,
|
||||
summaryRequested,
|
||||
skillsRequested,
|
||||
}
|
||||
}
|
||||
|
||||
function describeExerciseSkillRowForPreview(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}`
|
||||
}
|
||||
|
||||
function emptyComboSlotRow() {
|
||||
return {
|
||||
title: '',
|
||||
|
|
@ -524,6 +604,7 @@ function ExerciseFormPageRoot() {
|
|||
const [variantSavingId, setVariantSavingId] = useState(null)
|
||||
const [variantBusy, setVariantBusy] = useState(false)
|
||||
const [aiSuggestBusy, setAiSuggestBusy] = useState(false)
|
||||
const [aiSuggestionPreview, setAiSuggestionPreview] = useState(null)
|
||||
const [variantEditSelection, setVariantEditSelection] = useState(null)
|
||||
const [activeFormTab, setActiveFormTab] = useState('stammdaten')
|
||||
const variantsSavedSnapshotRef = useRef({})
|
||||
|
|
@ -896,6 +977,9 @@ function ExerciseFormPageRoot() {
|
|||
.filter(Boolean)
|
||||
.join(', ')
|
||||
|
||||
const snapshotSummaryHtml = formData.summary || ''
|
||||
const snapshotSkills = cloneExerciseSkillRows(formData.skills)
|
||||
|
||||
setAiSuggestBusy(true)
|
||||
try {
|
||||
const res = await api.suggestExerciseAi({
|
||||
|
|
@ -907,45 +991,20 @@ function ExerciseFormPageRoot() {
|
|||
include_skills: skillsOn,
|
||||
})
|
||||
|
||||
let applied = false
|
||||
const preview = buildExerciseAiSuggestionPreview({
|
||||
mode,
|
||||
snapshotSummaryHtml,
|
||||
snapshotSkills,
|
||||
apiRes: res,
|
||||
})
|
||||
|
||||
if (summaryOn && res.summary?.text) {
|
||||
updateFormField('summary', aiPlainSummaryToMinimalHtml(res.summary.text))
|
||||
applied = true
|
||||
}
|
||||
|
||||
if (skillsOn && Array.isArray(res.skills) && res.skills.length) {
|
||||
setFormDirty(true)
|
||||
setFormData((prev) => {
|
||||
const next = [...(prev.skills || [])]
|
||||
for (const sug of res.skills) {
|
||||
const sid = Number(sug.skill_id)
|
||||
if (!Number.isFinite(sid)) continue
|
||||
const row = {
|
||||
skill_id: sid,
|
||||
intensity: normalizeExerciseSkillIntensity(sug.intensity),
|
||||
required_level: normalizeSkillLevelSlug(sug.required_level) || 'grundlagen',
|
||||
target_level:
|
||||
normalizeSkillLevelSlug(sug.target_level) ||
|
||||
normalizeSkillLevelSlug(sug.required_level) ||
|
||||
'grundlagen',
|
||||
is_primary: !!sug.is_primary,
|
||||
ai_suggested: true,
|
||||
}
|
||||
const ix = next.findIndex((s) => Number(s.skill_id) === sid)
|
||||
if (ix >= 0) next[ix] = { ...next[ix], ...row }
|
||||
else next.push(row)
|
||||
}
|
||||
return { ...prev, skills: next }
|
||||
})
|
||||
applied = true
|
||||
}
|
||||
|
||||
if (!applied) {
|
||||
const hasSomething = preview.hasSummaryProposal || preview.hasSkillChoices
|
||||
if (!hasSomething) {
|
||||
toast.info('Die KI lieferte keinen verwertbaren Vorschlag für die gewählten Bereiche.')
|
||||
} else {
|
||||
toast.success('KI-Vorschlag ins Formular übernommen — bitte prüfen und speichern.')
|
||||
return
|
||||
}
|
||||
|
||||
setAiSuggestionPreview(preview)
|
||||
} catch (err) {
|
||||
toast.error(err?.message || String(err))
|
||||
} finally {
|
||||
|
|
@ -953,6 +1012,52 @@ function ExerciseFormPageRoot() {
|
|||
}
|
||||
}
|
||||
|
||||
const applyExerciseAiSuggestionPreview = () => {
|
||||
const p = aiSuggestionPreview
|
||||
if (!p) return
|
||||
const takeSummary = !!(p.applySummary && p.summaryAfterHtml)
|
||||
const skillsToMerge = p.skillChoices.filter((c) => c.include).map((c) => c.after)
|
||||
|
||||
if (!takeSummary && skillsToMerge.length === 0) {
|
||||
toast.error('Bitte mindestens eine Kurzfassung oder eine Fähigkeit zur Übernahme auswählen.')
|
||||
return
|
||||
}
|
||||
|
||||
if (takeSummary) {
|
||||
updateFormField('summary', p.summaryAfterHtml)
|
||||
}
|
||||
if (skillsToMerge.length > 0) {
|
||||
setFormDirty(true)
|
||||
setFormData((prev) => {
|
||||
const next = [...(prev.skills || [])]
|
||||
for (const row of skillsToMerge) {
|
||||
const sid = Number(row.skill_id)
|
||||
const ix = next.findIndex((s) => Number(s.skill_id) === sid)
|
||||
if (ix >= 0) next[ix] = { ...next[ix], ...row }
|
||||
else next.push(row)
|
||||
}
|
||||
return { ...prev, skills: next }
|
||||
})
|
||||
}
|
||||
|
||||
toast.success('Ausgewählte KI-Vorschläge übernommen — bitte prüfen und speichern.')
|
||||
setAiSuggestionPreview(null)
|
||||
}
|
||||
|
||||
const discardExerciseAiSuggestionPreview = () => setAiSuggestionPreview(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!aiSuggestionPreview) return undefined
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
setAiSuggestionPreview(null)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [aiSuggestionPreview])
|
||||
|
||||
const refreshVariants = useCallback(async () => {
|
||||
if (!exerciseId) return
|
||||
const ex = await api.getExercise(exerciseId)
|
||||
|
|
@ -1423,7 +1528,7 @@ function ExerciseFormPageRoot() {
|
|||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '12px' }}
|
||||
disabled={aiSuggestBusy}
|
||||
disabled={aiSuggestBusy || !!aiSuggestionPreview}
|
||||
onClick={() => runExerciseAiSuggestion('summary')}
|
||||
>
|
||||
KI: Kurzfassung
|
||||
|
|
@ -2098,7 +2203,7 @@ function ExerciseFormPageRoot() {
|
|||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '12px' }}
|
||||
disabled={aiSuggestBusy}
|
||||
disabled={aiSuggestBusy || !!aiSuggestionPreview}
|
||||
onClick={() => runExerciseAiSuggestion('skills')}
|
||||
>
|
||||
KI: Fähigkeiten
|
||||
|
|
@ -2107,7 +2212,7 @@ function ExerciseFormPageRoot() {
|
|||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '12px' }}
|
||||
disabled={aiSuggestBusy}
|
||||
disabled={aiSuggestBusy || !!aiSuggestionPreview}
|
||||
onClick={() => runExerciseAiSuggestion('both')}
|
||||
>
|
||||
KI: Kurzfassung und Fähigkeiten
|
||||
|
|
@ -2117,7 +2222,7 @@ function ExerciseFormPageRoot() {
|
|||
<button type="button" className="exercise-form-inline-tab-link" onClick={() => setActiveFormTab('anleitung')}>
|
||||
Anleitung
|
||||
</button>
|
||||
· Vorschläge werden ins Formular übernommen und nicht automatisch gespeichert.
|
||||
· Es öffnet ein Dialogfeld mit Vorschau; Übernahme wählweise pro Teil. Speichern nur über die Aktionsleiste.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -2615,6 +2720,258 @@ function ExerciseFormPageRoot() {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
{aiSuggestionPreview &&
|
||||
(() => {
|
||||
const p = aiSuggestionPreview
|
||||
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',
|
||||
}
|
||||
const canApplySomething =
|
||||
(p.applySummary && p.summaryAfterHtml) || p.skillChoices.some((c) => c.include)
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="KI-Vorschlag prüfen"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
zIndex: 1001,
|
||||
overflow: 'auto',
|
||||
padding: '16px',
|
||||
}}
|
||||
onClick={() => discardExerciseAiSuggestionPreview()}
|
||||
onKeyDown={(e) => e.key === 'Escape' && discardExerciseAiSuggestionPreview()}
|
||||
>
|
||||
<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' }}>KI-Vorschlag übernehmen</h3>
|
||||
<p style={{ fontSize: '13px', color: 'var(--text3)', marginTop: 0, marginBottom: '16px' }}>
|
||||
Vergleichen und nur die gewünschten Teile übernehmen. Es werden keine Daten automatisch gespeichert.
|
||||
</p>
|
||||
|
||||
{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) =>
|
||||
setAiSuggestionPreview((prev) =>
|
||||
prev ? { ...prev, applySummary: e.target.checked } : prev,
|
||||
)
|
||||
}
|
||||
/>
|
||||
Kurzfassung durch Vorschlag ersetzen (bestehende Kurzbeschreibung wird überschrieben)
|
||||
</label>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(0,1fr) minmax(0,1fr)',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--text3)', marginBottom: '4px' }}>
|
||||
Aktuell (ohne Formatierung)
|
||||
</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 ? (
|
||||
<section aria-labelledby="ai-preview-skills-heading">
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '8px',
|
||||
marginBottom: '10px',
|
||||
}}
|
||||
>
|
||||
<div id="ai-preview-skills-heading" style={{ fontWeight: 600, fontSize: '0.95rem' }}>
|
||||
Fähigkeiten ({p.skillChoices.length}
|
||||
{p.skillChoices.length === 1 ? ' Vorschlag' : ' Vorschläge'})
|
||||
</div>
|
||||
{p.skillChoices.length > 0 ? (
|
||||
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '11px', padding: '4px 8px' }}
|
||||
onClick={() =>
|
||||
setAiSuggestionPreview((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
skillChoices: prev.skillChoices.map((x) => ({ ...x, include: true })),
|
||||
}
|
||||
: prev,
|
||||
)
|
||||
}
|
||||
>
|
||||
Alle auswählen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '11px', padding: '4px 8px' }}
|
||||
onClick={() =>
|
||||
setAiSuggestionPreview((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
skillChoices: prev.skillChoices.map((x) => ({ ...x, include: false })),
|
||||
}
|
||||
: prev,
|
||||
)
|
||||
}
|
||||
>
|
||||
Alle abwählen
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{p.skillChoices.length === 0 ? (
|
||||
<p style={{ fontSize: '13px', color: 'var(--text3)', margin: 0 }}>
|
||||
Keine passenden Fähigkeiten — der Katalog-Vorschlag war leer oder enthielt nur ungültige IDs.
|
||||
</p>
|
||||
) : (
|
||||
<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',
|
||||
gap: '10px',
|
||||
alignItems: 'flex-start',
|
||||
cursor: 'pointer',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={c.include}
|
||||
onChange={() =>
|
||||
setAiSuggestionPreview((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
skillChoices: prev.skillChoices.map((x) =>
|
||||
x.skill_id === c.skill_id ? { ...x, include: !x.include } : x,
|
||||
),
|
||||
}
|
||||
: prev,
|
||||
)
|
||||
}
|
||||
style={{ marginTop: '4px' }}
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: '13px', marginBottom: '6px' }}>
|
||||
{c.kind === 'add' ? 'Neu hinzufügen' : 'Bestehende Zeile aktualisieren'}
|
||||
</div>
|
||||
{c.kind === 'update' && c.before ? (
|
||||
<div style={{ fontSize: '12px', lineHeight: 1.5 }}>
|
||||
<div style={{ color: 'var(--text3)', marginBottom: '2px' }}>Bisher</div>
|
||||
<div style={{ marginBottom: '8px' }}>
|
||||
{describeExerciseSkillRowForPreview(c.before, skillsCatalog)}
|
||||
</div>
|
||||
<div style={{ color: 'var(--text3)', marginBottom: '2px' }}>Nach KI-Vorschlag</div>
|
||||
<div>{describeExerciseSkillRowForPreview(c.after, skillsCatalog)}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: '13px', lineHeight: 1.5 }}>
|
||||
{describeExerciseSkillRowForPreview(c.after, skillsCatalog)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</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={discardExerciseAiSuggestionPreview}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={!canApplySomething}
|
||||
onClick={() => applyExerciseAiSuggestionPreview()}
|
||||
>
|
||||
Ausgewähltes übernehmen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{mediaPreview && (
|
||||
<MediaPreviewModal
|
||||
title={(mediaPreview.title || '').trim() || mediaPreview.original_filename || `Medium #${mediaPreview.id}`}
|
||||
|
|
@ -2660,7 +3017,7 @@ function ExerciseFormPageRoot() {
|
|||
|
||||
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: '16px' }}>
|
||||
<strong>KI-Unterstützung:</strong> OpenRouter gestützte Vorschläge für Kurzfassung und Fähigkeitenzuordnung
|
||||
(<code>suggestExerciseAi</code> / <code>regenerateExerciseAi</code>). Übernahme nur im Formular; Speichern
|
||||
(<code>suggestExerciseAi</code> / <code>regenerateExerciseAi</code>). Übernahme im Dialog ins Formular; Speichern
|
||||
wie gewohnt.
|
||||
</p>
|
||||
<UnsavedChangesPrompt
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user