KI Implementierung (MVP) auf Übungen #46

Merged
Lars merged 10 commits from develop into main 2026-05-22 10:38:39 +02:00
Showing only changes of commit f9e295bce0 - Show all commits

View File

@ -2735,258 +2735,6 @@ 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}`}
@ -3017,6 +2765,259 @@ function ExerciseFormPageRoot() {
</form>
</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>
)
})()}
<ExercisePickerModal
open={comboStationPickerIx !== null}
onClose={() => setComboStationPickerIx(null)}