Files
hejyou_content_creation/frontend/src/pages/GenerateIt.tsx
admin 0360bcd1e6 Generate it / Publish it: Claude Haiku integration + Generate page redesign
- GenerateIt page: objects sidebar, readOnly canvas, collapsible prompt bar
- Generate it: calls Claude Haiku, saves questions/words to Directus as draft
- Publish it: promotes draft questions/words to published
- Deduplication: links existing words/questions instead of duplicating
- GenerateObjectsList: tree view with user_notes labels
- DrawCanvas: readOnly prop to disable mouse interaction
- api.ts: generateQuestions + publishQuestions endpoints
- requirements.txt: anthropic==0.40.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 10:16:28 +02:00

411 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef } from 'react'
import DrawCanvas, { type DrawCanvasHandle } from '../components/DrawCanvas'
import GenerateObjectsList from '../components/GenerateObjectsList'
import Topbar from '../components/Topbar'
import {
getDirectusPictures,
directusAssetUrl,
type DirectusPicture,
getDirectusObjects,
generateQuestions,
publishQuestions,
type GenerateStats,
} from '../api'
import { useAuth } from '../context/AuthContext'
import type { DirectusObject, CanvasObject } from '../types'
const ChevronLeftIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
)
const ChevronRightIcon = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
)
const GenerateIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2l2.4 7.4H22l-6.2 4.5 2.4 7.4L12 17l-6.2 4.3 2.4-7.4L2 9.4h7.6z" />
</svg>
)
const PublishIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 19V5M5 12l7-7 7 7" />
</svg>
)
const SaveIcon = () => (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
<polyline points="17 21 17 13 7 13 7 21" />
<polyline points="7 3 7 8 15 8" />
</svg>
)
// ── Prompt layout system ──────────────────────────────────────────────────────
const DEFAULT_PROMPT = `Du bist ein erfahrener Sprachlernexperte. Du erhältst die Beschreibung eines Objekts aus einem Bild (Titel, Position, Zustand, Aktion) sowie ggf. dessen Elternobjekt als Kontext. Deine Aufgabe: Erstelle ausschließlich für das genannte Objekt (nicht für das Elternobjekt) Sprachlernfragen auf 10 Niveaustufen (110):
* Stufe 12 (Anfänger): Einfachste Erkennungs- oder Ja/Nein-Fragen, z.B. „Kannst du den Hund sehen?"
* Stufe 35 (Grundstufe): Beschreibende Fragen zu Farbe, Form, Position
* Stufe 68 (Mittelstufe): Fragen zu Funktion, Vergleich oder Kontext
* Stufe 910 (Fortgeschritten): Sprachlich anspruchsvolle, kreative oder erklärende Fragen die Komplexität liegt in Grammatik, Wortschatz und Satzbau, nicht im abstrakten Denken
Regeln:
* Jede Frage muss sich direkt auf das Objekt beziehen
* \`words\`: Enthält alle einzigartigen Tokens aus Frage UND Antwort zusammen (Satzzeichen ausgenommen, keine Duplikate)
* \`short_answer\`: Ein einzelnes treffendes Wort als Kurzantwort (z.B. „Ja", „schwarz", „wendig")
* \`distractor_words\`: Genau 5 Wörter, die thematisch passen, aber NICHT in Frage oder Antwort vorkommen und NICHT die Antwort sind
* Gib ausschließlich valides JSON aus kein Text, kein Markdown
Ausgabeformat:
{
"levels": [
{
"level": 1,
"question": "Kannst du den Hund sehen?",
"answer": "Ja, ich kann den Hund sehen.",
"short_answer": "Ja",
"words": ["Kannst", "du", "den", "Hund", "sehen", "Ja", "ich", "kann"],
"distractor_words": ["Nein", "vielleicht", "Katze", "hören", "groß"]
}
]
}
Informationen: {user-notes_object}
Elternobjekt: {user-notes_parentobject}`
interface PromptLayout {
name: string
prompt: string
}
const STORAGE_KEY = 'cm_prompt_layouts'
function loadLayouts(): PromptLayout[] {
const defaultLayout: PromptLayout = { name: 'Standard', prompt: DEFAULT_PROMPT }
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (!stored) return [defaultLayout]
const parsed = JSON.parse(stored) as PromptLayout[]
const hasDefault = parsed.some(l => l.name === 'Standard')
return hasDefault ? parsed : [defaultLayout, ...parsed]
} catch {
return [defaultLayout]
}
}
function persistLayouts(layouts: PromptLayout[]): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(layouts))
}
// ── Component ─────────────────────────────────────────────────────────────────
export default function GenerateIt() {
const { token } = useAuth()
const canvasRef = useRef<DrawCanvasHandle>(null)
const [pictureList, setPictureList] = useState<DirectusPicture[]>([])
const [currentIndex, setCurrentIndex] = useState(-1)
const [directusObjects, setDirectusObjects] = useState<DirectusObject[]>([])
const [selectedObjId, setSelectedObjId] = useState<string | null>(null)
const [isGenerating, setIsGenerating] = useState(false)
const [isPublishing, setIsPublishing] = useState(false)
const [generateResult, setGenerateResult] = useState<GenerateStats | null>(null)
const [generateError, setGenerateError] = useState<string | null>(null)
const [publishResult, setPublishResult] = useState<{ q: number; w: number } | null>(null)
// Prompt layouts
const [layouts, setLayouts] = useState<PromptLayout[]>(loadLayouts)
const [selectedLayoutName, setSelectedLayoutName] = useState('Standard')
const [promptText, setPromptText] = useState(() => loadLayouts()[0]?.prompt ?? DEFAULT_PROMPT)
const [showSaveDialog, setShowSaveDialog] = useState(false)
const [newLayoutName, setNewLayoutName] = useState('')
const [promptOpen, setPromptOpen] = useState(false)
const currentPicture: DirectusPicture | null =
currentIndex >= 0 && currentIndex < pictureList.length ? pictureList[currentIndex] : null
const canvasObjects: CanvasObject[] = directusObjects.map((obj, i) => ({
id: obj.id,
visible: true,
selections: obj.selections,
index: i + 1,
hierarchy: 1,
}))
useEffect(() => {
if (!token) return
getDirectusPictures(token, 'drawing_created')
.then(pics => { setPictureList(pics); setCurrentIndex(pics.length > 0 ? 0 : -1) })
.catch(console.error)
}, [token])
useEffect(() => {
if (!currentPicture || !token) {
setDirectusObjects([]); setSelectedObjId(null)
setGenerateResult(null); setPublishResult(null); setGenerateError(null)
return
}
getDirectusObjects(currentPicture.id, token)
.then(objs => {
setDirectusObjects(objs)
if (objs.length > 0) setSelectedObjId(objs[0].id)
else setSelectedObjId(null)
})
.catch(console.error)
}, [currentPicture?.id, token])
const handleGenerate = async () => {
if (!selectedObjId || !token) return
setIsGenerating(true)
setGenerateResult(null)
setGenerateError(null)
setPublishResult(null)
try {
const res = await generateQuestions(selectedObjId, promptText, token)
setGenerateResult(res.stats)
} catch (e) {
setGenerateError(e instanceof Error ? e.message : 'Fehler beim Generieren')
} finally {
setIsGenerating(false)
}
}
const handlePublish = async () => {
if (!selectedObjId || !token) return
setIsPublishing(true)
setPublishResult(null)
try {
const res = await publishQuestions(selectedObjId, token)
setPublishResult({ q: res.published_questions, w: res.published_words })
} catch (e) {
setGenerateError(e instanceof Error ? e.message : 'Fehler beim Veröffentlichen')
} finally {
setIsPublishing(false)
}
}
// Layout handlers
const handleSelectLayout = (name: string) => {
const layout = layouts.find(l => l.name === name)
if (layout) { setSelectedLayoutName(name); setPromptText(layout.prompt) }
}
const handleSaveLayout = () => {
const trimmed = newLayoutName.trim()
if (!trimmed) return
const newLayout: PromptLayout = { name: trimmed, prompt: promptText }
const updated = [...layouts.filter(l => l.name !== trimmed), newLayout]
setLayouts(updated)
persistLayouts(updated)
setSelectedLayoutName(trimmed)
setNewLayoutName('')
setShowSaveDialog(false)
}
const handleDeleteLayout = (name: string) => {
if (name === 'Standard') return
const updated = layouts.filter(l => l.name !== name)
setLayouts(updated)
persistLayouts(updated)
if (selectedLayoutName === name) {
const first = updated[0]
setSelectedLayoutName(first.name)
setPromptText(first.prompt)
}
}
const imageNav = (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div className="image-nav">
<button className="btn-icon" onClick={() => setCurrentIndex(i => i - 1)} disabled={currentIndex <= 0}>
<ChevronLeftIcon />
</button>
<span className="image-counter">
{pictureList.length > 0 ? (
<>
<span className="image-counter-num">{currentIndex + 1}</span>
<span className="image-counter-sep">/</span>
<span className="image-counter-total">{pictureList.length}</span>
</>
) : (
<span className="image-counter-empty">Keine Bilder</span>
)}
</span>
<button className="btn-icon" onClick={() => setCurrentIndex(i => i + 1)} disabled={currentIndex >= pictureList.length - 1}>
<ChevronRightIcon />
</button>
</div>
<div style={{ width: 1, height: 24, background: 'var(--border)' }} />
<button
className="btn-ghost btn-sm"
onClick={handleGenerate}
disabled={isGenerating || isPublishing || !selectedObjId}
style={isGenerating ? { opacity: 0.7 } : undefined}
>
<GenerateIcon />
{isGenerating ? 'Generiere…' : 'Generate it'}
</button>
<button
className="btn-ghost btn-sm"
onClick={handlePublish}
disabled={isPublishing || isGenerating || !selectedObjId || !generateResult}
title={!generateResult ? 'Erst Generate it ausführen' : undefined}
>
<PublishIcon />
{isPublishing ? 'Veröffentliche…' : 'Publish it'}
</button>
{generateResult && !isGenerating && (
<span style={{ fontSize: 11, color: 'var(--success)', background: 'var(--success-bg)', border: '1px solid var(--success)', borderRadius: 'var(--r-full)', padding: '2px 8px' }}>
{generateResult.questions_created}F +{generateResult.words_created}W neu
</span>
)}
{publishResult && (
<span style={{ fontSize: 11, color: 'var(--primary-muted-fg)', background: 'var(--primary-muted)', border: '1px solid var(--primary)', borderRadius: 'var(--r-full)', padding: '2px 8px' }}>
{publishResult.q}F {publishResult.w}W
</span>
)}
{generateError && (
<span style={{ fontSize: 11, color: 'var(--danger)', background: 'var(--danger-bg)', border: '1px solid var(--danger)', borderRadius: 'var(--r-full)', padding: '2px 8px', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{generateError}
</span>
)}
</div>
)
return (
<div className="app-shell">
<Topbar page="generate" center={imageNav} />
<div className="workspace">
{/* Left: Objects */}
<aside className="sidebar">
<div className="sidebar-panel" style={{ flex: 1 }}>
<h3 className="sidebar-heading">
Objekte
{directusObjects.length > 0 && <span className="badge">{directusObjects.length}</span>}
</h3>
<GenerateObjectsList
objects={directusObjects}
selectedId={selectedObjId}
onSelect={id => setSelectedObjId(id)}
/>
</div>
</aside>
{/* Center: Canvas + Prompt Bar */}
<main className="canvas-area canvas-area--relative">
<div className="canvas-frame">
<DrawCanvas
ref={canvasRef}
imageSrc={currentPicture && token ? directusAssetUrl(currentPicture.media, token) : null}
objects={canvasObjects}
selectedObjectId={selectedObjId}
mode="rect"
onHasSelection={() => {}}
readOnly
/>
</div>
{/* Collapsible Prompt Bar */}
<div className={`prompt-bar${promptOpen ? ' prompt-bar--open' : ''}`}>
<div className="prompt-bar-header" onClick={() => setPromptOpen(o => !o)}>
<span className="prompt-bar-chevron">{promptOpen ? '▾' : '▸'}</span>
<span className="prompt-bar-title">Prompt</span>
<span className="prompt-bar-layout-name">{selectedLayoutName}</span>
<div className="prompt-bar-actions" onClick={e => e.stopPropagation()}>
<select
className="prompt-layout-select"
value={selectedLayoutName}
onChange={e => handleSelectLayout(e.target.value)}
>
{layouts.map(l => (
<option key={l.name} value={l.name}>{l.name}</option>
))}
</select>
{selectedLayoutName !== 'Standard' && (
<button
className="btn-ghost btn-sm btn-danger"
onClick={() => handleDeleteLayout(selectedLayoutName)}
title="Layout löschen"
></button>
)}
<button
className="btn-ghost btn-sm"
onClick={() => { setNewLayoutName(''); setShowSaveDialog(s => !s) }}
>
<SaveIcon />
Speichern
</button>
</div>
</div>
{promptOpen && (
<div className="prompt-bar-body">
{showSaveDialog && (
<div className="prompt-save-dialog">
<input
type="text"
placeholder="Layout-Name"
value={newLayoutName}
onChange={e => setNewLayoutName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleSaveLayout(); if (e.key === 'Escape') setShowSaveDialog(false) }}
autoFocus
/>
<button className="btn-ghost btn-sm" onClick={handleSaveLayout} disabled={!newLayoutName.trim()}>
Speichern
</button>
<button className="btn-ghost btn-sm" onClick={() => setShowSaveDialog(false)}>
Abbrechen
</button>
</div>
)}
<textarea
className="prompt-textarea"
value={promptText}
onChange={e => setPromptText(e.target.value)}
placeholder="Prompt eingeben…"
/>
</div>
)}
</div>
</main>
{/* Right: Generate results */}
<aside className="sidebar sidebar--right">
<div className="sidebar-panel" style={{ flex: 1 }}>
<h3 className="sidebar-heading">Ergebnis</h3>
{generateResult ? (
<div style={{ padding: '8px 12px', fontSize: 13, display: 'flex', flexDirection: 'column', gap: 6 }}>
<div><strong>{generateResult.questions_created}</strong> Fragen erstellt</div>
<div><strong>{generateResult.questions_linked}</strong> Fragen verknüpft</div>
<div><strong>{generateResult.words_created}</strong> Wörter erstellt</div>
<div><strong>{generateResult.words_linked}</strong> Wörter verknüpft</div>
</div>
) : (
<div className="empty-state">Klicke Generate it".</div>
)}
{publishResult && (
<div style={{ padding: '0 12px 8px', fontSize: 13, display: 'flex', flexDirection: 'column', gap: 6, color: 'var(--primary)' }}>
<div> {publishResult.q} Fragen veröffentlicht</div>
<div> {publishResult.w} Wörter veröffentlicht</div>
</div>
)}
</div>
</aside>
</div>
</div>
)
}