KI Implementierung (MVP) auf Übungen #46
|
|
@ -25,7 +25,7 @@
|
||||||
| **S1** | Migration `ai_prompts` + Defaults `exercise_summary`, `exercise_skill_suggestions`; `exercises.summary_ai_generated` | Migrierte DB, App startet |
|
| **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 |
|
| **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 |
|
| **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 |
|
| **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 |
|
| **S6** | (später) Zielausbau, Anleitung-only, Varianten, Admin-Masse laut Vision | Separate Epics |
|
||||||
|
|
||||||
|
|
@ -47,6 +47,7 @@
|
||||||
|
|
||||||
- **2026-05-22:** Initial; S1–S4 als erster Umsetzungspfad.
|
- **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:** 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`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -54,5 +55,7 @@
|
||||||
|
|
||||||
**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.
|
**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,
|
buildExerciseMediaDragPayload,
|
||||||
} from '../../utils/exerciseInlineMediaRefs'
|
} from '../../utils/exerciseInlineMediaRefs'
|
||||||
import { autoScrollForDragNearEdges } from '../../utils/dragAutoScroll'
|
import { autoScrollForDragNearEdges } from '../../utils/dragAutoScroll'
|
||||||
import { normalizeSkillLevelSlug } from '../../constants/skillLevels'
|
import { normalizeSkillLevelSlug, formatSkillLevelSlug } from '../../constants/skillLevels'
|
||||||
import { stripHtmlToText } from '../../utils/htmlUtils'
|
import { stripHtmlToText } from '../../utils/htmlUtils'
|
||||||
import ExerciseCatalogAssocEditor from './ExerciseCatalogAssocEditor'
|
import ExerciseCatalogAssocEditor from './ExerciseCatalogAssocEditor'
|
||||||
import ExerciseSkillsEditor from './ExerciseSkillsEditor'
|
import ExerciseSkillsEditor from './ExerciseSkillsEditor'
|
||||||
|
|
@ -44,6 +44,7 @@ import { useBeforeUnloadWhen, useUnsavedChangesBlocker } from '../../hooks/useUn
|
||||||
import {
|
import {
|
||||||
EXERCISE_SKILL_INTENSITY_DEFAULT,
|
EXERCISE_SKILL_INTENSITY_DEFAULT,
|
||||||
normalizeExerciseSkillIntensity,
|
normalizeExerciseSkillIntensity,
|
||||||
|
formatExerciseSkillIntensityLabel,
|
||||||
} from '../../constants/exerciseSkillIntensity'
|
} from '../../constants/exerciseSkillIntensity'
|
||||||
import {
|
import {
|
||||||
EXERCISE_VISIBILITY_CLUB_FIELD_LABEL,
|
EXERCISE_VISIBILITY_CLUB_FIELD_LABEL,
|
||||||
|
|
@ -89,6 +90,85 @@ function aiPlainSummaryToMinimalHtml(text) {
|
||||||
return paras.map((p) => `<p>${escapeHtmlText(p)}</p>`).join('')
|
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() {
|
function emptyComboSlotRow() {
|
||||||
return {
|
return {
|
||||||
title: '',
|
title: '',
|
||||||
|
|
@ -524,6 +604,7 @@ function ExerciseFormPageRoot() {
|
||||||
const [variantSavingId, setVariantSavingId] = useState(null)
|
const [variantSavingId, setVariantSavingId] = useState(null)
|
||||||
const [variantBusy, setVariantBusy] = useState(false)
|
const [variantBusy, setVariantBusy] = useState(false)
|
||||||
const [aiSuggestBusy, setAiSuggestBusy] = useState(false)
|
const [aiSuggestBusy, setAiSuggestBusy] = useState(false)
|
||||||
|
const [aiSuggestionPreview, setAiSuggestionPreview] = useState(null)
|
||||||
const [variantEditSelection, setVariantEditSelection] = useState(null)
|
const [variantEditSelection, setVariantEditSelection] = useState(null)
|
||||||
const [activeFormTab, setActiveFormTab] = useState('stammdaten')
|
const [activeFormTab, setActiveFormTab] = useState('stammdaten')
|
||||||
const variantsSavedSnapshotRef = useRef({})
|
const variantsSavedSnapshotRef = useRef({})
|
||||||
|
|
@ -896,6 +977,9 @@ function ExerciseFormPageRoot() {
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(', ')
|
.join(', ')
|
||||||
|
|
||||||
|
const snapshotSummaryHtml = formData.summary || ''
|
||||||
|
const snapshotSkills = cloneExerciseSkillRows(formData.skills)
|
||||||
|
|
||||||
setAiSuggestBusy(true)
|
setAiSuggestBusy(true)
|
||||||
try {
|
try {
|
||||||
const res = await api.suggestExerciseAi({
|
const res = await api.suggestExerciseAi({
|
||||||
|
|
@ -907,45 +991,20 @@ function ExerciseFormPageRoot() {
|
||||||
include_skills: skillsOn,
|
include_skills: skillsOn,
|
||||||
})
|
})
|
||||||
|
|
||||||
let applied = false
|
const preview = buildExerciseAiSuggestionPreview({
|
||||||
|
mode,
|
||||||
if (summaryOn && res.summary?.text) {
|
snapshotSummaryHtml,
|
||||||
updateFormField('summary', aiPlainSummaryToMinimalHtml(res.summary.text))
|
snapshotSkills,
|
||||||
applied = true
|
apiRes: res,
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
|
const hasSomething = preview.hasSummaryProposal || preview.hasSkillChoices
|
||||||
|
if (!hasSomething) {
|
||||||
|
toast.info('Die KI lieferte keinen verwertbaren Vorschlag für die gewählten Bereiche.')
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!applied) {
|
setAiSuggestionPreview(preview)
|
||||||
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.')
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err?.message || String(err))
|
toast.error(err?.message || String(err))
|
||||||
} finally {
|
} 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 () => {
|
const refreshVariants = useCallback(async () => {
|
||||||
if (!exerciseId) return
|
if (!exerciseId) return
|
||||||
const ex = await api.getExercise(exerciseId)
|
const ex = await api.getExercise(exerciseId)
|
||||||
|
|
@ -1423,7 +1528,7 @@ function ExerciseFormPageRoot() {
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
style={{ fontSize: '12px' }}
|
style={{ fontSize: '12px' }}
|
||||||
disabled={aiSuggestBusy}
|
disabled={aiSuggestBusy || !!aiSuggestionPreview}
|
||||||
onClick={() => runExerciseAiSuggestion('summary')}
|
onClick={() => runExerciseAiSuggestion('summary')}
|
||||||
>
|
>
|
||||||
KI: Kurzfassung
|
KI: Kurzfassung
|
||||||
|
|
@ -2098,7 +2203,7 @@ function ExerciseFormPageRoot() {
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
style={{ fontSize: '12px' }}
|
style={{ fontSize: '12px' }}
|
||||||
disabled={aiSuggestBusy}
|
disabled={aiSuggestBusy || !!aiSuggestionPreview}
|
||||||
onClick={() => runExerciseAiSuggestion('skills')}
|
onClick={() => runExerciseAiSuggestion('skills')}
|
||||||
>
|
>
|
||||||
KI: Fähigkeiten
|
KI: Fähigkeiten
|
||||||
|
|
@ -2107,7 +2212,7 @@ function ExerciseFormPageRoot() {
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
style={{ fontSize: '12px' }}
|
style={{ fontSize: '12px' }}
|
||||||
disabled={aiSuggestBusy}
|
disabled={aiSuggestBusy || !!aiSuggestionPreview}
|
||||||
onClick={() => runExerciseAiSuggestion('both')}
|
onClick={() => runExerciseAiSuggestion('both')}
|
||||||
>
|
>
|
||||||
KI: Kurzfassung und Fähigkeiten
|
KI: Kurzfassung und Fähigkeiten
|
||||||
|
|
@ -2117,7 +2222,7 @@ function ExerciseFormPageRoot() {
|
||||||
<button type="button" className="exercise-form-inline-tab-link" onClick={() => setActiveFormTab('anleitung')}>
|
<button type="button" className="exercise-form-inline-tab-link" onClick={() => setActiveFormTab('anleitung')}>
|
||||||
Anleitung
|
Anleitung
|
||||||
</button>
|
</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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -2615,6 +2720,258 @@ function ExerciseFormPageRoot() {
|
||||||
</div>
|
</div>
|
||||||
</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 && (
|
{mediaPreview && (
|
||||||
<MediaPreviewModal
|
<MediaPreviewModal
|
||||||
title={(mediaPreview.title || '').trim() || mediaPreview.original_filename || `Medium #${mediaPreview.id}`}
|
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' }}>
|
<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
|
<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.
|
wie gewohnt.
|
||||||
</p>
|
</p>
|
||||||
<UnsavedChangesPrompt
|
<UnsavedChangesPrompt
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user