feat: unified prompt UI - Phase 3 complete (Issue #28)
Frontend Consolidation: - UnifiedPromptModal: Single editor for base + pipeline prompts - Type selector (base/pipeline) - Base: Template editor with placeholders - Pipeline: Dynamic stage editor - Add/remove stages with drag/drop - Inline or reference prompts per stage - Output key + format per prompt - AdminPromptsPage redesign: - Removed tab switcher (prompts/pipelines) - Added type filter (All/Base/Pipeline) - Type badge in table - Stage count column - Icon-based actions (Edit/Copy/Delete) - Category filter retained Changes: - Completely rewrote AdminPromptsPage (495 → 446 lines) - Single modal for all prompt types - Mobile-ready layout - Simplified state management Next: Phase 4 - Cleanup deprecated endpoints + docs
This commit is contained in:
parent
7be7266477
commit
31e2c24a8a
549
frontend/src/components/UnifiedPromptModal.jsx
Normal file
549
frontend/src/components/UnifiedPromptModal.jsx
Normal file
|
|
@ -0,0 +1,549 @@
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { api } from '../utils/api'
|
||||||
|
import { X, Plus, Trash2, MoveUp, MoveDown } from 'lucide-react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified Prompt Editor Modal (Issue #28 Phase 3)
|
||||||
|
*
|
||||||
|
* Supports both prompt types:
|
||||||
|
* - Base: Single reusable template
|
||||||
|
* - Pipeline: Multi-stage workflow with dynamic stages
|
||||||
|
*/
|
||||||
|
export default function UnifiedPromptModal({ prompt, onSave, onClose }) {
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [slug, setSlug] = useState('')
|
||||||
|
const [displayName, setDisplayName] = useState('')
|
||||||
|
const [description, setDescription] = useState('')
|
||||||
|
const [type, setType] = useState('pipeline') // 'base' or 'pipeline'
|
||||||
|
const [category, setCategory] = useState('ganzheitlich')
|
||||||
|
const [active, setActive] = useState(true)
|
||||||
|
const [sortOrder, setSortOrder] = useState(0)
|
||||||
|
|
||||||
|
// Base prompt fields
|
||||||
|
const [template, setTemplate] = useState('')
|
||||||
|
const [outputFormat, setOutputFormat] = useState('text')
|
||||||
|
|
||||||
|
// Pipeline prompt fields
|
||||||
|
const [stages, setStages] = useState([
|
||||||
|
{
|
||||||
|
stage: 1,
|
||||||
|
prompts: []
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
// Available prompts for reference selection
|
||||||
|
const [availablePrompts, setAvailablePrompts] = useState([])
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadAvailablePrompts()
|
||||||
|
|
||||||
|
if (prompt) {
|
||||||
|
// Edit mode
|
||||||
|
setName(prompt.name || '')
|
||||||
|
setSlug(prompt.slug || '')
|
||||||
|
setDisplayName(prompt.display_name || '')
|
||||||
|
setDescription(prompt.description || '')
|
||||||
|
setType(prompt.type || 'pipeline')
|
||||||
|
setCategory(prompt.category || 'ganzheitlich')
|
||||||
|
setActive(prompt.active ?? true)
|
||||||
|
setSortOrder(prompt.sort_order || 0)
|
||||||
|
setTemplate(prompt.template || '')
|
||||||
|
setOutputFormat(prompt.output_format || 'text')
|
||||||
|
|
||||||
|
// Parse stages if editing pipeline
|
||||||
|
if (prompt.type === 'pipeline' && prompt.stages) {
|
||||||
|
try {
|
||||||
|
const parsedStages = typeof prompt.stages === 'string'
|
||||||
|
? JSON.parse(prompt.stages)
|
||||||
|
: prompt.stages
|
||||||
|
setStages(parsedStages.length > 0 ? parsedStages : [{ stage: 1, prompts: [] }])
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse stages:', e)
|
||||||
|
setStages([{ stage: 1, prompts: [] }])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [prompt])
|
||||||
|
|
||||||
|
const loadAvailablePrompts = async () => {
|
||||||
|
try {
|
||||||
|
const prompts = await api.listAdminPrompts()
|
||||||
|
setAvailablePrompts(prompts)
|
||||||
|
} catch (e) {
|
||||||
|
setError('Fehler beim Laden der Prompts: ' + e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const addStage = () => {
|
||||||
|
const nextStageNum = Math.max(...stages.map(s => s.stage), 0) + 1
|
||||||
|
setStages([...stages, { stage: nextStageNum, prompts: [] }])
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeStage = (stageNum) => {
|
||||||
|
if (stages.length === 1) {
|
||||||
|
setError('Mindestens eine Stage erforderlich')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setStages(stages.filter(s => s.stage !== stageNum))
|
||||||
|
}
|
||||||
|
|
||||||
|
const moveStage = (stageNum, direction) => {
|
||||||
|
const idx = stages.findIndex(s => s.stage === stageNum)
|
||||||
|
if (idx === -1) return
|
||||||
|
|
||||||
|
const newStages = [...stages]
|
||||||
|
if (direction === 'up' && idx > 0) {
|
||||||
|
[newStages[idx], newStages[idx - 1]] = [newStages[idx - 1], newStages[idx]]
|
||||||
|
} else if (direction === 'down' && idx < newStages.length - 1) {
|
||||||
|
[newStages[idx], newStages[idx + 1]] = [newStages[idx + 1], newStages[idx]]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renumber stages
|
||||||
|
newStages.forEach((s, i) => s.stage = i + 1)
|
||||||
|
setStages(newStages)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addPromptToStage = (stageNum) => {
|
||||||
|
setStages(stages.map(s => {
|
||||||
|
if (s.stage === stageNum) {
|
||||||
|
return {
|
||||||
|
...s,
|
||||||
|
prompts: [...s.prompts, {
|
||||||
|
source: 'inline',
|
||||||
|
template: '',
|
||||||
|
output_key: `output_${Date.now()}`,
|
||||||
|
output_format: 'text'
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const removePromptFromStage = (stageNum, promptIdx) => {
|
||||||
|
setStages(stages.map(s => {
|
||||||
|
if (s.stage === stageNum) {
|
||||||
|
return {
|
||||||
|
...s,
|
||||||
|
prompts: s.prompts.filter((_, i) => i !== promptIdx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateStagePrompt = (stageNum, promptIdx, field, value) => {
|
||||||
|
setStages(stages.map(s => {
|
||||||
|
if (s.stage === stageNum) {
|
||||||
|
const newPrompts = [...s.prompts]
|
||||||
|
newPrompts[promptIdx] = { ...newPrompts[promptIdx], [field]: value }
|
||||||
|
return { ...s, prompts: newPrompts }
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
// Validation
|
||||||
|
if (!name.trim() || !slug.trim()) {
|
||||||
|
setError('Name und Slug sind Pflichtfelder')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'base' && !template.trim()) {
|
||||||
|
setError('Basis-Prompts benötigen ein Template')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'pipeline' && stages.length === 0) {
|
||||||
|
setError('Pipeline-Prompts benötigen mindestens eine Stage')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'pipeline') {
|
||||||
|
// Validate all stages have at least one prompt
|
||||||
|
const emptyStages = stages.filter(s => s.prompts.length === 0)
|
||||||
|
if (emptyStages.length > 0) {
|
||||||
|
setError(`Stage ${emptyStages[0].stage} hat keine Prompts`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate all prompts have required fields
|
||||||
|
for (const stage of stages) {
|
||||||
|
for (const p of stage.prompts) {
|
||||||
|
if (!p.output_key) {
|
||||||
|
setError(`Stage ${stage.stage}: Output-Key fehlt`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (p.source === 'inline' && !p.template) {
|
||||||
|
setError(`Stage ${stage.stage}: Inline-Prompt ohne Template`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (p.source === 'reference' && !p.slug) {
|
||||||
|
setError(`Stage ${stage.stage}: Referenz-Prompt ohne Slug`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
display_name: displayName,
|
||||||
|
description,
|
||||||
|
type,
|
||||||
|
category,
|
||||||
|
active,
|
||||||
|
sort_order: sortOrder,
|
||||||
|
output_format: outputFormat
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'base') {
|
||||||
|
data.template = template
|
||||||
|
data.stages = null
|
||||||
|
} else {
|
||||||
|
data.template = null
|
||||||
|
data.stages = stages
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prompt?.id) {
|
||||||
|
await api.updateUnifiedPrompt(prompt.id, data)
|
||||||
|
} else {
|
||||||
|
await api.createUnifiedPrompt(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
onSave()
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
zIndex: 1000, padding: 20, overflow: 'auto'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--bg)', borderRadius: 12, maxWidth: 1000, width: '100%',
|
||||||
|
maxHeight: '90vh', overflow: 'auto', padding: 24
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
||||||
|
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600 }}>
|
||||||
|
{prompt ? 'Prompt bearbeiten' : 'Neuer Prompt'}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4 }}
|
||||||
|
>
|
||||||
|
<X size={24} color="var(--text3)" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{
|
||||||
|
padding: 12, background: '#fee', color: '#c00', borderRadius: 8, marginBottom: 16, fontSize: 13
|
||||||
|
}}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Basic Info */}
|
||||||
|
<div style={{ display: 'grid', gap: 16, marginBottom: 24 }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
||||||
|
<div>
|
||||||
|
<label className="form-label">Name *</label>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
value={name}
|
||||||
|
onChange={e => setName(e.target.value)}
|
||||||
|
placeholder="Interner Name"
|
||||||
|
style={{ width: '100%', textAlign: 'left' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="form-label">Slug *</label>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
value={slug}
|
||||||
|
onChange={e => setSlug(e.target.value)}
|
||||||
|
placeholder="technischer_name"
|
||||||
|
style={{ width: '100%', textAlign: 'left' }}
|
||||||
|
disabled={!!prompt}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="form-label">Anzeigename</label>
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
value={displayName}
|
||||||
|
onChange={e => setDisplayName(e.target.value)}
|
||||||
|
placeholder="Name für Benutzer (optional)"
|
||||||
|
style={{ width: '100%', textAlign: 'left' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="form-label">Beschreibung</label>
|
||||||
|
<textarea
|
||||||
|
className="form-input"
|
||||||
|
value={description}
|
||||||
|
onChange={e => setDescription(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
placeholder="Kurze Beschreibung des Prompts"
|
||||||
|
style={{ width: '100%', textAlign: 'left', resize: 'vertical' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12 }}>
|
||||||
|
<div>
|
||||||
|
<label className="form-label">Typ *</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
value={type}
|
||||||
|
onChange={e => setType(e.target.value)}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
disabled={!!prompt}
|
||||||
|
>
|
||||||
|
<option value="base">Basis-Prompt</option>
|
||||||
|
<option value="pipeline">Pipeline</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="form-label">Kategorie</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
value={category}
|
||||||
|
onChange={e => setCategory(e.target.value)}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
<option value="ganzheitlich">Ganzheitlich</option>
|
||||||
|
<option value="körper">Körper</option>
|
||||||
|
<option value="ernährung">Ernährung</option>
|
||||||
|
<option value="training">Training</option>
|
||||||
|
<option value="schlaf">Schlaf</option>
|
||||||
|
<option value="pipeline">Pipeline</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="form-label">Output-Format</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
value={outputFormat}
|
||||||
|
onChange={e => setOutputFormat(e.target.value)}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
<option value="text">Text</option>
|
||||||
|
<option value="json">JSON</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 16, alignItems: 'center' }}>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={active}
|
||||||
|
onChange={e => setActive(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 13 }}>Aktiv</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<label className="form-label" style={{ margin: 0 }}>Sortierung:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="form-input"
|
||||||
|
value={sortOrder}
|
||||||
|
onChange={e => setSortOrder(parseInt(e.target.value) || 0)}
|
||||||
|
style={{ width: 80, padding: '4px 8px' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Type-specific editor */}
|
||||||
|
{type === 'base' && (
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<h3 style={{ fontSize: 16, fontWeight: 600, marginBottom: 12 }}>Template</h3>
|
||||||
|
<textarea
|
||||||
|
className="form-input"
|
||||||
|
value={template}
|
||||||
|
onChange={e => setTemplate(e.target.value)}
|
||||||
|
rows={12}
|
||||||
|
placeholder="Prompt-Template mit {{placeholders}}..."
|
||||||
|
style={{ width: '100%', textAlign: 'left', resize: 'vertical', fontFamily: 'monospace', fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>
|
||||||
|
Verwende {{'{{'}}platzhalter{{'}}'}} für dynamische Werte (z.B. {{'{{'}}weight_data{{'}}'}}, {{'{{'}}nutrition_data{{'}}'}})
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{type === 'pipeline' && (
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||||
|
<h3 style={{ fontSize: 16, fontWeight: 600, margin: 0 }}>Stages ({stages.length})</h3>
|
||||||
|
<button className="btn" onClick={addStage} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<Plus size={16} /> Stage hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stages.map((stage, sIdx) => (
|
||||||
|
<div key={stage.stage} style={{
|
||||||
|
background: 'var(--surface)', padding: 16, borderRadius: 8,
|
||||||
|
border: '1px solid var(--border)', marginBottom: 12
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||||
|
<h4 style={{ margin: 0, fontSize: 14, fontWeight: 600 }}>Stage {stage.stage}</h4>
|
||||||
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
|
{sIdx > 0 && (
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
onClick={() => moveStage(stage.stage, 'up')}
|
||||||
|
style={{ padding: '4px 8px' }}
|
||||||
|
>
|
||||||
|
<MoveUp size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{sIdx < stages.length - 1 && (
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
onClick={() => moveStage(stage.stage, 'down')}
|
||||||
|
style={{ padding: '4px 8px' }}
|
||||||
|
>
|
||||||
|
<MoveDown size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
onClick={() => removeStage(stage.stage)}
|
||||||
|
style={{ padding: '4px 8px', color: 'var(--danger)' }}
|
||||||
|
disabled={stages.length === 1}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Prompts in this stage */}
|
||||||
|
{stage.prompts.map((p, pIdx) => (
|
||||||
|
<div key={pIdx} style={{
|
||||||
|
background: 'var(--bg)', padding: 12, borderRadius: 6, marginBottom: 8,
|
||||||
|
border: '1px solid var(--border)'
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'grid', gap: 8 }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '120px 1fr 120px auto', gap: 8, alignItems: 'center' }}>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
value={p.source || 'inline'}
|
||||||
|
onChange={e => updateStagePrompt(stage.stage, pIdx, 'source', e.target.value)}
|
||||||
|
style={{ fontSize: 12 }}
|
||||||
|
>
|
||||||
|
<option value="inline">Inline</option>
|
||||||
|
<option value="reference">Referenz</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<input
|
||||||
|
className="form-input"
|
||||||
|
value={p.output_key || ''}
|
||||||
|
onChange={e => updateStagePrompt(stage.stage, pIdx, 'output_key', e.target.value)}
|
||||||
|
placeholder="output_key"
|
||||||
|
style={{ fontSize: 12, textAlign: 'left' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
value={p.output_format || 'text'}
|
||||||
|
onChange={e => updateStagePrompt(stage.stage, pIdx, 'output_format', e.target.value)}
|
||||||
|
style={{ fontSize: 12 }}
|
||||||
|
>
|
||||||
|
<option value="text">Text</option>
|
||||||
|
<option value="json">JSON</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => removePromptFromStage(stage.stage, pIdx)}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4 }}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} color="var(--danger)" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{p.source === 'reference' ? (
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
value={p.slug || ''}
|
||||||
|
onChange={e => updateStagePrompt(stage.stage, pIdx, 'slug', e.target.value)}
|
||||||
|
style={{ fontSize: 12 }}
|
||||||
|
>
|
||||||
|
<option value="">-- Prompt wählen --</option>
|
||||||
|
{availablePrompts
|
||||||
|
.filter(ap => ap.type === 'base' || !ap.type)
|
||||||
|
.map(ap => (
|
||||||
|
<option key={ap.slug} value={ap.slug}>
|
||||||
|
{ap.display_name || ap.name} ({ap.slug})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<textarea
|
||||||
|
className="form-input"
|
||||||
|
value={p.template || ''}
|
||||||
|
onChange={e => updateStagePrompt(stage.stage, pIdx, 'template', e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
placeholder="Inline-Template mit {{placeholders}}..."
|
||||||
|
style={{ fontSize: 12, textAlign: 'left', resize: 'vertical', fontFamily: 'monospace' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn"
|
||||||
|
onClick={() => addPromptToStage(stage.stage)}
|
||||||
|
style={{ fontSize: 12, display: 'flex', alignItems: 'center', gap: 4 }}
|
||||||
|
>
|
||||||
|
<Plus size={14} /> Prompt hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', gap: 12, justifyContent: 'flex-end',
|
||||||
|
paddingTop: 16, borderTop: '1px solid var(--border)'
|
||||||
|
}}>
|
||||||
|
<button className="btn" onClick={onClose}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? 'Speichern...' : 'Speichern'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,27 +1,23 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
import PromptEditModal from '../components/PromptEditModal'
|
import UnifiedPromptModal from '../components/UnifiedPromptModal'
|
||||||
import PipelineConfigModal from '../components/PipelineConfigModal'
|
import { Star, Trash2, Edit, Copy, Filter } from 'lucide-react'
|
||||||
import { Star, Trash2, Edit, Copy } from 'lucide-react'
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin Prompts Page - Unified System (Issue #28 Phase 3)
|
||||||
|
*
|
||||||
|
* Manages both base and pipeline-type prompts in one interface.
|
||||||
|
*/
|
||||||
export default function AdminPromptsPage() {
|
export default function AdminPromptsPage() {
|
||||||
const [activeTab, setActiveTab] = useState('prompts') // 'prompts' | 'pipelines'
|
|
||||||
|
|
||||||
// Prompts state
|
|
||||||
const [prompts, setPrompts] = useState([])
|
const [prompts, setPrompts] = useState([])
|
||||||
const [filteredPrompts, setFilteredPrompts] = useState([])
|
const [filteredPrompts, setFilteredPrompts] = useState([])
|
||||||
|
const [typeFilter, setTypeFilter] = useState('all') // 'all' | 'base' | 'pipeline'
|
||||||
const [category, setCategory] = useState('all')
|
const [category, setCategory] = useState('all')
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const [editingPrompt, setEditingPrompt] = useState(null)
|
const [editingPrompt, setEditingPrompt] = useState(null)
|
||||||
const [showNewPrompt, setShowNewPrompt] = useState(false)
|
const [showNewPrompt, setShowNewPrompt] = useState(false)
|
||||||
|
|
||||||
// Pipeline configs state
|
|
||||||
const [pipelineConfigs, setPipelineConfigs] = useState([])
|
|
||||||
const [pipelineLoading, setPipelineLoading] = useState(false)
|
|
||||||
const [editingPipeline, setEditingPipeline] = useState(null)
|
|
||||||
const [showNewPipeline, setShowNewPipeline] = useState(false)
|
|
||||||
|
|
||||||
const categories = [
|
const categories = [
|
||||||
{ id: 'all', label: 'Alle Kategorien' },
|
{ id: 'all', label: 'Alle Kategorien' },
|
||||||
{ id: 'körper', label: 'Körper' },
|
{ id: 'körper', label: 'Körper' },
|
||||||
|
|
@ -30,21 +26,31 @@ export default function AdminPromptsPage() {
|
||||||
{ id: 'schlaf', label: 'Schlaf' },
|
{ id: 'schlaf', label: 'Schlaf' },
|
||||||
{ id: 'vitalwerte', label: 'Vitalwerte' },
|
{ id: 'vitalwerte', label: 'Vitalwerte' },
|
||||||
{ id: 'ziele', label: 'Ziele' },
|
{ id: 'ziele', label: 'Ziele' },
|
||||||
{ id: 'ganzheitlich', label: 'Ganzheitlich' }
|
{ id: 'ganzheitlich', label: 'Ganzheitlich' },
|
||||||
|
{ id: 'pipeline', label: 'Pipeline' }
|
||||||
]
|
]
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadPrompts()
|
loadPrompts()
|
||||||
loadPipelineConfigs()
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (category === 'all') {
|
let filtered = prompts
|
||||||
setFilteredPrompts(prompts)
|
|
||||||
} else {
|
// Filter by type
|
||||||
setFilteredPrompts(prompts.filter(p => p.category === category))
|
if (typeFilter === 'base') {
|
||||||
|
filtered = filtered.filter(p => p.type === 'base')
|
||||||
|
} else if (typeFilter === 'pipeline') {
|
||||||
|
filtered = filtered.filter(p => p.type === 'pipeline')
|
||||||
}
|
}
|
||||||
}, [category, prompts])
|
|
||||||
|
// Filter by category
|
||||||
|
if (category !== 'all') {
|
||||||
|
filtered = filtered.filter(p => p.category === category)
|
||||||
|
}
|
||||||
|
|
||||||
|
setFilteredPrompts(filtered)
|
||||||
|
}, [typeFilter, category, prompts])
|
||||||
|
|
||||||
const loadPrompts = async () => {
|
const loadPrompts = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -61,7 +67,7 @@ export default function AdminPromptsPage() {
|
||||||
|
|
||||||
const handleToggleActive = async (prompt) => {
|
const handleToggleActive = async (prompt) => {
|
||||||
try {
|
try {
|
||||||
await api.updatePrompt(prompt.id, { active: !prompt.active })
|
await api.updateUnifiedPrompt(prompt.id, { active: !prompt.active })
|
||||||
await loadPrompts()
|
await loadPrompts()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('Fehler: ' + e.message)
|
alert('Fehler: ' + e.message)
|
||||||
|
|
@ -88,408 +94,345 @@ export default function AdminPromptsPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMoveUp = async (prompt) => {
|
const handleSave = async () => {
|
||||||
const idx = filteredPrompts.findIndex(p => p.id === prompt.id)
|
|
||||||
if (idx === 0) return // Already at top
|
|
||||||
|
|
||||||
const above = filteredPrompts[idx - 1]
|
|
||||||
const newOrder = filteredPrompts.map(p => p.id)
|
|
||||||
newOrder[idx] = above.id
|
|
||||||
newOrder[idx - 1] = prompt.id
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api.reorderPrompts(newOrder)
|
|
||||||
await loadPrompts()
|
|
||||||
} catch (e) {
|
|
||||||
alert('Fehler: ' + e.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMoveDown = async (prompt) => {
|
|
||||||
const idx = filteredPrompts.findIndex(p => p.id === prompt.id)
|
|
||||||
if (idx === filteredPrompts.length - 1) return // Already at bottom
|
|
||||||
|
|
||||||
const below = filteredPrompts[idx + 1]
|
|
||||||
const newOrder = filteredPrompts.map(p => p.id)
|
|
||||||
newOrder[idx] = below.id
|
|
||||||
newOrder[idx + 1] = prompt.id
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api.reorderPrompts(newOrder)
|
|
||||||
await loadPrompts()
|
|
||||||
} catch (e) {
|
|
||||||
alert('Fehler: ' + e.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSavePrompt = async () => {
|
|
||||||
await loadPrompts()
|
|
||||||
setEditingPrompt(null)
|
setEditingPrompt(null)
|
||||||
setShowNewPrompt(false)
|
setShowNewPrompt(false)
|
||||||
|
await loadPrompts()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pipeline Config handlers
|
const getStageCount = (prompt) => {
|
||||||
const loadPipelineConfigs = async () => {
|
if (prompt.type !== 'pipeline' || !prompt.stages) return 0
|
||||||
try {
|
try {
|
||||||
setPipelineLoading(true)
|
const stages = typeof prompt.stages === 'string'
|
||||||
const data = await api.listPipelineConfigs()
|
? JSON.parse(prompt.stages)
|
||||||
setPipelineConfigs(data)
|
: prompt.stages
|
||||||
|
return stages.length
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e.message)
|
return 0
|
||||||
} finally {
|
|
||||||
setPipelineLoading(false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeletePipeline = async (config) => {
|
const getTypeLabel = (type) => {
|
||||||
if (!confirm(`Pipeline-Config "${config.name}" wirklich löschen?`)) return
|
if (type === 'base') return 'Basis'
|
||||||
|
if (type === 'pipeline') return 'Pipeline'
|
||||||
try {
|
return type || 'Pipeline' // Default for old prompts
|
||||||
await api.deletePipelineConfig(config.id)
|
|
||||||
await loadPipelineConfigs()
|
|
||||||
} catch (e) {
|
|
||||||
alert('Fehler: ' + e.message)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSetDefaultPipeline = async (config) => {
|
const getTypeColor = (type) => {
|
||||||
try {
|
if (type === 'base') return 'var(--accent)'
|
||||||
await api.setDefaultPipelineConfig(config.id)
|
if (type === 'pipeline') return '#6366f1'
|
||||||
await loadPipelineConfigs()
|
return 'var(--text3)'
|
||||||
} catch (e) {
|
|
||||||
alert('Fehler: ' + e.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSavePipeline = async () => {
|
|
||||||
await loadPipelineConfigs()
|
|
||||||
setEditingPipeline(null)
|
|
||||||
setShowNewPipeline(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="page">
|
|
||||||
<div style={{textAlign:'center', padding:40}}>
|
|
||||||
<div className="spinner"/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div style={{
|
||||||
<div style={{display:'flex', justifyContent:'space-between', alignItems:'center', marginBottom:24}}>
|
padding: 20,
|
||||||
<h1 className="page-title">KI-Prompts & Pipelines</h1>
|
maxWidth: 1400,
|
||||||
|
margin: '0 auto',
|
||||||
|
paddingBottom: 80
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 24
|
||||||
|
}}>
|
||||||
|
<h1 style={{ margin: 0, fontSize: 24, fontWeight: 600 }}>
|
||||||
|
KI-Prompts ({filteredPrompts.length})
|
||||||
|
</h1>
|
||||||
<button
|
<button
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
onClick={() => activeTab === 'prompts' ? setShowNewPrompt(true) : setShowNewPipeline(true)}
|
onClick={() => setShowNewPrompt(true)}
|
||||||
>
|
>
|
||||||
{activeTab === 'prompts' ? '+ Neuer Prompt' : '+ Neue Pipeline'}
|
+ Neuer Prompt
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tab Switcher */}
|
|
||||||
<div style={{display:'flex', gap:12, marginBottom:24, borderBottom:'2px solid var(--border)'}}>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('prompts')}
|
|
||||||
style={{
|
|
||||||
padding:'12px 24px', background:'none', border:'none',
|
|
||||||
borderBottom: activeTab === 'prompts' ? '2px solid var(--accent)' : '2px solid transparent',
|
|
||||||
marginBottom:-2, cursor:'pointer', fontSize:14, fontWeight:activeTab === 'prompts' ? 600 : 400,
|
|
||||||
color: activeTab === 'prompts' ? 'var(--accent)' : 'var(--text2)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Prompts ({prompts.length})
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('pipelines')}
|
|
||||||
style={{
|
|
||||||
padding:'12px 24px', background:'none', border:'none',
|
|
||||||
borderBottom: activeTab === 'pipelines' ? '2px solid var(--accent)' : '2px solid transparent',
|
|
||||||
marginBottom:-2, cursor:'pointer', fontSize:14, fontWeight:activeTab === 'pipelines' ? 600 : 400,
|
|
||||||
color: activeTab === 'pipelines' ? 'var(--accent)' : 'var(--text2)'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Pipeline-Konfigurationen ({pipelineConfigs.length})
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div style={{padding:12, background:'#fee', color:'#c00', borderRadius:8, marginBottom:16}}>
|
<div style={{
|
||||||
|
padding: 16,
|
||||||
|
background: '#fee',
|
||||||
|
color: '#c00',
|
||||||
|
borderRadius: 8,
|
||||||
|
marginBottom: 16
|
||||||
|
}}>
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Prompts Tab */}
|
{/* Filters */}
|
||||||
{activeTab === 'prompts' && (
|
<div style={{
|
||||||
<>
|
display: 'flex',
|
||||||
{/* Category Filter */}
|
gap: 12,
|
||||||
<div style={{marginBottom:24}}>
|
marginBottom: 24,
|
||||||
<label style={{fontSize:13, fontWeight:600, marginBottom:8, display:'block'}}>
|
flexWrap: 'wrap',
|
||||||
Kategorie filtern:
|
alignItems: 'center'
|
||||||
</label>
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<Filter size={16} color="var(--text3)" />
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--text3)' }}>Typ:</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
<button
|
||||||
|
className={typeFilter === 'all' ? 'btn btn-primary' : 'btn'}
|
||||||
|
onClick={() => setTypeFilter('all')}
|
||||||
|
style={{ fontSize: 13, padding: '6px 12px' }}
|
||||||
|
>
|
||||||
|
Alle ({prompts.length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={typeFilter === 'base' ? 'btn btn-primary' : 'btn'}
|
||||||
|
onClick={() => setTypeFilter('base')}
|
||||||
|
style={{ fontSize: 13, padding: '6px 12px' }}
|
||||||
|
>
|
||||||
|
Basis-Prompts ({prompts.filter(p => p.type === 'base').length})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={typeFilter === 'pipeline' ? 'btn btn-primary' : 'btn'}
|
||||||
|
onClick={() => setTypeFilter('pipeline')}
|
||||||
|
style={{ fontSize: 13, padding: '6px 12px' }}
|
||||||
|
>
|
||||||
|
Pipelines ({prompts.filter(p => p.type === 'pipeline' || !p.type).length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
width: 1,
|
||||||
|
height: 24,
|
||||||
|
background: 'var(--border)',
|
||||||
|
margin: '0 8px'
|
||||||
|
}} />
|
||||||
|
|
||||||
<select
|
<select
|
||||||
className="form-select"
|
className="form-select"
|
||||||
value={category}
|
value={category}
|
||||||
onChange={e => setCategory(e.target.value)}
|
onChange={e => setCategory(e.target.value)}
|
||||||
style={{maxWidth:300}}
|
style={{ fontSize: 13, padding: '6px 12px' }}
|
||||||
>
|
>
|
||||||
{categories.map(cat => (
|
{categories.map(cat => (
|
||||||
<option key={cat.id} value={cat.id}>{cat.label}</option>
|
<option key={cat.id} value={cat.id}>
|
||||||
|
{cat.label}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Prompts Table */}
|
{/* Prompts Table */}
|
||||||
<div className="card">
|
{loading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: 40, color: 'var(--text3)' }}>
|
||||||
|
Lädt...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--surface)',
|
||||||
|
borderRadius: 12,
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}>
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{borderBottom:'2px solid var(--border)', textAlign:'left'}}>
|
<tr style={{
|
||||||
<th style={{padding:'12px 8px', fontSize:12, fontWeight:600, color:'var(--text3)'}}>
|
background: 'var(--surface2)',
|
||||||
Titel
|
borderBottom: '1px solid var(--border)'
|
||||||
|
}}>
|
||||||
|
<th style={{
|
||||||
|
padding: 12,
|
||||||
|
textAlign: 'left',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text3)',
|
||||||
|
width: 80
|
||||||
|
}}>
|
||||||
|
Typ
|
||||||
</th>
|
</th>
|
||||||
<th style={{padding:'12px 8px', fontSize:12, fontWeight:600, color:'var(--text3)'}}>
|
<th style={{
|
||||||
|
padding: 12,
|
||||||
|
textAlign: 'left',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text3)'
|
||||||
|
}}>
|
||||||
|
Name
|
||||||
|
</th>
|
||||||
|
<th style={{
|
||||||
|
padding: 12,
|
||||||
|
textAlign: 'left',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text3)',
|
||||||
|
width: 120
|
||||||
|
}}>
|
||||||
Kategorie
|
Kategorie
|
||||||
</th>
|
</th>
|
||||||
<th style={{padding:'12px 8px', fontSize:12, fontWeight:600, color:'var(--text3)'}}>
|
<th style={{
|
||||||
Aktiv
|
padding: 12,
|
||||||
|
textAlign: 'center',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text3)',
|
||||||
|
width: 100
|
||||||
|
}}>
|
||||||
|
Stages
|
||||||
</th>
|
</th>
|
||||||
<th style={{padding:'12px 8px', fontSize:12, fontWeight:600, color:'var(--text3)'}}>
|
<th style={{
|
||||||
Reihenfolge
|
padding: 12,
|
||||||
|
textAlign: 'center',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text3)',
|
||||||
|
width: 80
|
||||||
|
}}>
|
||||||
|
Status
|
||||||
</th>
|
</th>
|
||||||
<th style={{padding:'12px 8px', fontSize:12, fontWeight:600, color:'var(--text3)'}}>
|
<th style={{
|
||||||
|
padding: 12,
|
||||||
|
textAlign: 'right',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text3)',
|
||||||
|
width: 120
|
||||||
|
}}>
|
||||||
Aktionen
|
Aktionen
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filteredPrompts.map((prompt, idx) => (
|
{filteredPrompts.length === 0 ? (
|
||||||
<tr key={prompt.id} style={{borderBottom:'1px solid var(--border)'}}>
|
<tr>
|
||||||
<td style={{padding:'12px 8px'}}>
|
<td colSpan="6" style={{
|
||||||
<div style={{fontWeight:500, fontSize:14}}>{prompt.name}</div>
|
padding: 40,
|
||||||
{prompt.description && (
|
textAlign: 'center',
|
||||||
<div style={{fontSize:11, color:'var(--text3)', marginTop:2}}>
|
color: 'var(--text3)'
|
||||||
{prompt.description}
|
}}>
|
||||||
|
Keine Prompts gefunden
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
filteredPrompts.map(prompt => (
|
||||||
|
<tr
|
||||||
|
key={prompt.id}
|
||||||
|
style={{
|
||||||
|
borderBottom: '1px solid var(--border)',
|
||||||
|
opacity: prompt.active ? 1 : 0.5
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<td style={{ padding: 12 }}>
|
||||||
|
<div style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '2px 8px',
|
||||||
|
background: getTypeColor(prompt.type) + '20',
|
||||||
|
color: getTypeColor(prompt.type),
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 600
|
||||||
|
}}>
|
||||||
|
{getTypeLabel(prompt.type)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</td>
|
||||||
<div style={{fontSize:10, color:'var(--text3)', marginTop:4}}>
|
<td style={{ padding: 12 }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 500 }}>
|
||||||
|
{prompt.display_name || prompt.name}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 2 }}>
|
||||||
{prompt.slug}
|
{prompt.slug}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td style={{padding:'12px 8px'}}>
|
<td style={{ padding: 12, fontSize: 13 }}>
|
||||||
<span style={{
|
|
||||||
padding:'4px 8px', borderRadius:4, fontSize:11, fontWeight:500,
|
|
||||||
background:'var(--surface2)', color:'var(--text2)'
|
|
||||||
}}>
|
|
||||||
{prompt.category || 'ganzheitlich'}
|
{prompt.category || 'ganzheitlich'}
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td style={{padding:'12px 8px'}}>
|
<td style={{ padding: 12, textAlign: 'center', fontSize: 13 }}>
|
||||||
|
{prompt.type === 'pipeline' ? (
|
||||||
|
<span style={{
|
||||||
|
background: 'var(--surface2)',
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: 11
|
||||||
|
}}>
|
||||||
|
{getStageCount(prompt)} Stages
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: 'var(--text3)', fontSize: 11 }}>—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: 12, textAlign: 'center' }}>
|
||||||
|
<label style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={prompt.active}
|
checked={prompt.active}
|
||||||
onChange={() => handleToggleActive(prompt)}
|
onChange={() => handleToggleActive(prompt)}
|
||||||
style={{cursor:'pointer'}}
|
style={{ margin: 0 }}
|
||||||
/>
|
/>
|
||||||
|
</label>
|
||||||
</td>
|
</td>
|
||||||
<td style={{padding:'12px 8px'}}>
|
<td style={{ padding: 12 }}>
|
||||||
<div style={{display:'flex', gap:4}}>
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 6,
|
||||||
|
justifyContent: 'flex-end'
|
||||||
|
}}>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleMoveUp(prompt)}
|
|
||||||
disabled={idx === 0}
|
|
||||||
style={{
|
|
||||||
padding:'4px 8px', fontSize:12, cursor: idx === 0 ? 'not-allowed' : 'pointer',
|
|
||||||
opacity: idx === 0 ? 0.5 : 1
|
|
||||||
}}
|
|
||||||
title="Nach oben"
|
|
||||||
>
|
|
||||||
↑
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleMoveDown(prompt)}
|
|
||||||
disabled={idx === filteredPrompts.length - 1}
|
|
||||||
style={{
|
|
||||||
padding:'4px 8px', fontSize:12,
|
|
||||||
cursor: idx === filteredPrompts.length - 1 ? 'not-allowed' : 'pointer',
|
|
||||||
opacity: idx === filteredPrompts.length - 1 ? 0.5 : 1
|
|
||||||
}}
|
|
||||||
title="Nach unten"
|
|
||||||
>
|
|
||||||
↓
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td style={{padding:'12px 8px'}}>
|
|
||||||
<div style={{display:'flex', gap:6}}>
|
|
||||||
<button
|
|
||||||
className="btn"
|
|
||||||
onClick={() => setEditingPrompt(prompt)}
|
onClick={() => setEditingPrompt(prompt)}
|
||||||
style={{fontSize:12, padding:'6px 12px'}}
|
style={{
|
||||||
>
|
background: 'none',
|
||||||
Bearbeiten
|
border: 'none',
|
||||||
</button>
|
cursor: 'pointer',
|
||||||
<button
|
padding: 4
|
||||||
className="btn"
|
}}
|
||||||
onClick={() => handleDuplicate(prompt)}
|
|
||||||
style={{fontSize:12, padding:'6px 12px'}}
|
|
||||||
>
|
|
||||||
Duplizieren
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn"
|
|
||||||
onClick={() => handleDelete(prompt)}
|
|
||||||
style={{fontSize:12, padding:'6px 12px', color:'#D85A30'}}
|
|
||||||
>
|
|
||||||
Löschen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
{filteredPrompts.length === 0 && (
|
|
||||||
<div style={{padding:40, textAlign:'center', color:'var(--text3)'}}>
|
|
||||||
Keine Prompts in dieser Kategorie
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Pipelines Tab */}
|
|
||||||
{activeTab === 'pipelines' && (
|
|
||||||
<>
|
|
||||||
{pipelineLoading ? (
|
|
||||||
<div style={{textAlign:'center', padding:40}}>
|
|
||||||
<div className="spinner"/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="card">
|
|
||||||
<table style={{width:'100%', borderCollapse:'collapse'}}>
|
|
||||||
<thead>
|
|
||||||
<tr style={{borderBottom:'2px solid var(--border)', textAlign:'left'}}>
|
|
||||||
<th style={{padding:'12px 8px', fontSize:12, fontWeight:600, color:'var(--text3)'}}>Name</th>
|
|
||||||
<th style={{padding:'12px 8px', fontSize:12, fontWeight:600, color:'var(--text3)'}}>Module</th>
|
|
||||||
<th style={{padding:'12px 8px', fontSize:12, fontWeight:600, color:'var(--text3)'}}>Stages</th>
|
|
||||||
<th style={{padding:'12px 8px', fontSize:12, fontWeight:600, color:'var(--text3)'}}>Status</th>
|
|
||||||
<th style={{padding:'12px 8px', fontSize:12, fontWeight:600, color:'var(--text3)'}}>Aktionen</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{pipelineConfigs.map(config => {
|
|
||||||
const activeModules = Object.entries(config.modules || {}).filter(([_, active]) => active)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr key={config.id} style={{borderBottom:'1px solid var(--border)'}}>
|
|
||||||
<td style={{padding:'12px 8px'}}>
|
|
||||||
<div style={{fontWeight:500, fontSize:14}}>{config.name}</div>
|
|
||||||
{config.is_default && (
|
|
||||||
<span style={{
|
|
||||||
fontSize:10, padding:'2px 6px', background:'var(--accent)',
|
|
||||||
color:'white', borderRadius:3, marginTop:4, display:'inline-block'
|
|
||||||
}}>
|
|
||||||
Standard
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{config.description && (
|
|
||||||
<div style={{fontSize:11, color:'var(--text3)', marginTop:2}}>
|
|
||||||
{config.description}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td style={{padding:'12px 8px'}}>
|
|
||||||
<div style={{fontSize:11}}>
|
|
||||||
{activeModules.map(([name]) => name).join(', ')}
|
|
||||||
</div>
|
|
||||||
<div style={{fontSize:10, color:'var(--text3)', marginTop:2}}>
|
|
||||||
{activeModules.length} Module
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td style={{padding:'12px 8px'}}>
|
|
||||||
<div style={{fontSize:11}}>
|
|
||||||
S1: {config.stage1_prompts?.length || 0} Prompts
|
|
||||||
</div>
|
|
||||||
<div style={{fontSize:10, color:'var(--text3)'}}>
|
|
||||||
S2: {config.stage2_prompt ? '✓' : '-'} | S3: {config.stage3_prompt ? '✓' : '-'}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td style={{padding:'12px 8px'}}>
|
|
||||||
<span style={{
|
|
||||||
fontSize:11, padding:'3px 8px', borderRadius:4,
|
|
||||||
background: config.active ? 'var(--accent-light)' : 'var(--surface2)',
|
|
||||||
color: config.active ? 'var(--accent)' : 'var(--text3)'
|
|
||||||
}}>
|
|
||||||
{config.active ? 'Aktiv' : 'Inaktiv'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td style={{padding:'12px 8px'}}>
|
|
||||||
<div style={{display:'flex', gap:6}}>
|
|
||||||
{!config.is_default && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleSetDefaultPipeline(config)}
|
|
||||||
style={{padding:'4px 8px', fontSize:11}}
|
|
||||||
title="Als Standard setzen"
|
|
||||||
>
|
|
||||||
<Star size={14} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => setEditingPipeline(config)}
|
|
||||||
style={{padding:'4px 8px', fontSize:11}}
|
|
||||||
title="Bearbeiten"
|
title="Bearbeiten"
|
||||||
>
|
>
|
||||||
<Edit size={14} />
|
<Edit size={16} color="var(--accent)" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeletePipeline(config)}
|
onClick={() => handleDuplicate(prompt)}
|
||||||
style={{padding:'4px 8px', fontSize:11, color:'var(--danger)'}}
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: 4
|
||||||
|
}}
|
||||||
|
title="Duplizieren"
|
||||||
|
>
|
||||||
|
<Copy size={16} color="var(--text3)" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(prompt)}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: 4
|
||||||
|
}}
|
||||||
title="Löschen"
|
title="Löschen"
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={16} color="var(--danger)" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)
|
))
|
||||||
})}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
{pipelineConfigs.length === 0 && (
|
|
||||||
<div style={{padding:40, textAlign:'center', color:'var(--text3)'}}>
|
|
||||||
Noch keine Pipeline-Konfigurationen vorhanden
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Edit Modals */}
|
{/* Unified Prompt Modal */}
|
||||||
{(editingPrompt || showNewPrompt) && (
|
{(editingPrompt || showNewPrompt) && (
|
||||||
<PromptEditModal
|
<UnifiedPromptModal
|
||||||
prompt={editingPrompt}
|
prompt={editingPrompt}
|
||||||
onSave={handleSavePrompt}
|
onSave={handleSave}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setEditingPrompt(null)
|
setEditingPrompt(null)
|
||||||
setShowNewPrompt(false)
|
setShowNewPrompt(false)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(editingPipeline || showNewPipeline) && (
|
|
||||||
<PipelineConfigModal
|
|
||||||
config={editingPipeline}
|
|
||||||
onSave={handleSavePipeline}
|
|
||||||
onClose={() => {
|
|
||||||
setEditingPipeline(null)
|
|
||||||
setShowNewPipeline(false)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user