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>
This commit is contained in:
2026-04-26 10:16:28 +02:00
parent 99a8d7e0aa
commit 0360bcd1e6
5 changed files with 436 additions and 80 deletions

View File

@@ -195,3 +195,38 @@ export async function generateSentence(
if (!res.ok) throw new Error(data.error || 'Fehler bei KI-Sentence')
return data
}
export interface GenerateStats {
words_created: number
words_linked: number
questions_created: number
questions_linked: number
}
export async function generateQuestions(
objId: string,
prompt: string,
token: string
): Promise<{ ok: boolean; stats: GenerateStats }> {
const res = await fetch(`/api/object/${encodeURIComponent(objId)}/generate_questions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ prompt }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Fehler bei Generate')
return data
}
export async function publishQuestions(
objId: string,
token: string
): Promise<{ ok: boolean; published_questions: number; published_words: number }> {
const res = await fetch(`/api/object/${encodeURIComponent(objId)}/publish_questions`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || 'Fehler bei Publish')
return data
}

View File

@@ -1,20 +1,19 @@
import { useState, useEffect, useRef } from 'react'
import DrawCanvas, { type DrawCanvasHandle } from '../components/DrawCanvas'
import GenerateObjectsList from '../components/GenerateObjectsList'
import SentencesList from '../components/SentencesList'
import Topbar from '../components/Topbar'
import {
getDirectusPictures,
directusAssetUrl,
type DirectusPicture,
getDirectusObjects,
generateDetails,
generateSentence,
getSentences,
generateQuestions,
publishQuestions,
type GenerateStats,
} from '../api'
import { useAuth } from '../context/AuthContext'
import type { DirectusObject, CanvasObject } from '../types'
import type { Sentence } 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">
@@ -28,15 +27,15 @@ const ChevronRightIcon = () => (
</svg>
)
const SparkleIcon = () => (
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 ChatIcon = () => (
const PublishIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
<path d="M12 19V5M5 12l7-7 7 7" />
</svg>
)
@@ -106,18 +105,6 @@ function persistLayouts(layouts: PromptLayout[]): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(layouts))
}
// ── Word extraction ───────────────────────────────────────────────────────────
function extractWords(sentences: Sentence[]): string[] {
const allText = sentences
.flatMap(s => [s.question_simple_en, s.answer_simple_en, s.question_advanced_en, s.answer_advanced_en])
.join(' ')
const words = allText
.split(/\s+/)
.map(w => w.replace(/[^a-zA-ZäöüÄÖÜßéèêàâùûîô'-]/g, ''))
.filter(w => w.length > 1)
return [...new Set(words)].sort((a, b) => a.localeCompare(b))
}
// ── Component ─────────────────────────────────────────────────────────────────
@@ -129,9 +116,11 @@ export default function GenerateIt() {
const [currentIndex, setCurrentIndex] = useState(-1)
const [directusObjects, setDirectusObjects] = useState<DirectusObject[]>([])
const [selectedObjId, setSelectedObjId] = useState<string | null>(null)
const [sentences, setSentences] = useState<Sentence[]>([])
const [isGeneratingDetails, setIsGeneratingDetails] = useState(false)
const [isGeneratingSentence, setIsGeneratingSentence] = useState(false)
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)
@@ -144,8 +133,6 @@ export default function GenerateIt() {
const currentPicture: DirectusPicture | null =
currentIndex >= 0 && currentIndex < pictureList.length ? pictureList[currentIndex] : null
const words = extractWords(sentences)
const canvasObjects: CanvasObject[] = directusObjects.map((obj, i) => ({
id: obj.id,
visible: true,
@@ -163,44 +150,46 @@ export default function GenerateIt() {
useEffect(() => {
if (!currentPicture || !token) {
setDirectusObjects([]); setSelectedObjId(null); setSentences([])
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); loadSentences(objs[0].id) }
else { setSelectedObjId(null); setSentences([]) }
if (objs.length > 0) setSelectedObjId(objs[0].id)
else setSelectedObjId(null)
})
.catch(console.error)
}, [currentPicture?.id, token])
const loadSentences = async (objId: string) => {
try { setSentences(await getSentences(objId)) } catch (e) { console.error(e) }
}
const handleGenerateDetails = async () => {
if (!selectedObjId) return
setIsGeneratingDetails(true)
const handleGenerate = async () => {
if (!selectedObjId || !token) return
setIsGenerating(true)
setGenerateResult(null)
setGenerateError(null)
setPublishResult(null)
try {
await generateDetails(selectedObjId)
const res = await generateQuestions(selectedObjId, promptText, token)
setGenerateResult(res.stats)
} catch (e) {
alert(e instanceof Error ? e.message : 'Fehler bei KI-Details')
setGenerateError(e instanceof Error ? e.message : 'Fehler beim Generieren')
} finally {
setIsGeneratingDetails(false)
setIsGenerating(false)
}
}
const handleGenerateSentence = async () => {
if (!selectedObjId) return
setIsGeneratingSentence(true)
const handlePublish = async () => {
if (!selectedObjId || !token) return
setIsPublishing(true)
setPublishResult(null)
try {
const data = await generateSentence(selectedObjId)
setSentences(prev => [...prev, data.sentence])
const res = await publishQuestions(selectedObjId, token)
setPublishResult({ q: res.published_questions, w: res.published_words })
} catch (e) {
alert(e instanceof Error ? e.message : 'Fehler bei KI-Sentence')
setGenerateError(e instanceof Error ? e.message : 'Fehler beim Veröffentlichen')
} finally {
setIsGeneratingSentence(false)
setIsPublishing(false)
}
}
@@ -258,14 +247,40 @@ export default function GenerateIt() {
<div style={{ width: 1, height: 24, background: 'var(--border)' }} />
<button className="btn-ghost btn-sm" onClick={handleGenerateDetails} disabled={isGeneratingDetails || !selectedObjId}>
<SparkleIcon />
{isGeneratingDetails ? 'Generiere…' : 'KI-Details'}
<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={handleGenerateSentence} disabled={isGeneratingSentence || !selectedObjId}>
<ChatIcon />
{isGeneratingSentence ? 'Generiere…' : 'KI-Sentence'}
<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>
)
@@ -284,7 +299,7 @@ export default function GenerateIt() {
<GenerateObjectsList
objects={directusObjects}
selectedId={selectedObjId}
onSelect={id => { setSelectedObjId(id); loadSentences(id) }}
onSelect={id => setSelectedObjId(id)}
/>
</div>
</aside>
@@ -367,33 +382,26 @@ export default function GenerateIt() {
</div>
</main>
{/* Words */}
<aside className="sidebar sidebar--words">
<div className="sidebar-panel" style={{ flex: 1 }}>
<h3 className="sidebar-heading">
Wörter
{words.length > 0 && <span className="badge">{words.length}</span>}
</h3>
{words.length === 0 ? (
<div className="empty-state">Noch keine Wörter.</div>
) : (
<div className="words-cloud">
{words.map(w => (
<span key={w} className="word-chip">{w}</span>
))}
</div>
)}
</div>
</aside>
{/* Right: Sentences */}
{/* Right: Generate results */}
<aside className="sidebar sidebar--right">
<div className="sidebar-panel" style={{ flex: 1 }}>
<h3 className="sidebar-heading">
Sätze
{sentences.length > 0 && <span className="badge">{sentences.length}</span>}
</h3>
<SentencesList sentences={sentences} />
<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>