New features: 1. Placeholder chips now visible in pipeline inline templates - Click to insert: weight_data, nutrition_data, activity_data, etc. - Same UX as base prompts 2. Convert to Base Prompt button - New icon (ArrowDownToLine) in actions column - Only visible for 1-stage pipeline prompts - Converts pipeline → base by extracting inline template - Validates: must be 1-stage, 1-prompt, inline source This allows migrated prompts to be properly categorized as base prompts for reuse in other pipelines.
497 lines
16 KiB
JavaScript
497 lines
16 KiB
JavaScript
import { useState, useEffect } from 'react'
|
|
import { api } from '../utils/api'
|
|
import UnifiedPromptModal from '../components/UnifiedPromptModal'
|
|
import { Star, Trash2, Edit, Copy, Filter, ArrowDownToLine } 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() {
|
|
const [prompts, setPrompts] = useState([])
|
|
const [filteredPrompts, setFilteredPrompts] = useState([])
|
|
const [typeFilter, setTypeFilter] = useState('all') // 'all' | 'base' | 'pipeline'
|
|
const [category, setCategory] = useState('all')
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState(null)
|
|
const [editingPrompt, setEditingPrompt] = useState(null)
|
|
const [showNewPrompt, setShowNewPrompt] = useState(false)
|
|
|
|
const categories = [
|
|
{ id: 'all', label: 'Alle Kategorien' },
|
|
{ id: 'körper', label: 'Körper' },
|
|
{ id: 'ernährung', label: 'Ernährung' },
|
|
{ id: 'training', label: 'Training' },
|
|
{ id: 'schlaf', label: 'Schlaf' },
|
|
{ id: 'vitalwerte', label: 'Vitalwerte' },
|
|
{ id: 'ziele', label: 'Ziele' },
|
|
{ id: 'ganzheitlich', label: 'Ganzheitlich' },
|
|
{ id: 'pipeline', label: 'Pipeline' }
|
|
]
|
|
|
|
useEffect(() => {
|
|
loadPrompts()
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
let filtered = prompts
|
|
|
|
// Filter by type
|
|
if (typeFilter === 'base') {
|
|
filtered = filtered.filter(p => p.type === 'base')
|
|
} else if (typeFilter === 'pipeline') {
|
|
filtered = filtered.filter(p => p.type === 'pipeline')
|
|
}
|
|
|
|
// Filter by category
|
|
if (category !== 'all') {
|
|
filtered = filtered.filter(p => p.category === category)
|
|
}
|
|
|
|
setFilteredPrompts(filtered)
|
|
}, [typeFilter, category, prompts])
|
|
|
|
const loadPrompts = async () => {
|
|
try {
|
|
setLoading(true)
|
|
const data = await api.listAdminPrompts()
|
|
setPrompts(data)
|
|
setError(null)
|
|
} catch (e) {
|
|
setError(e.message)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleToggleActive = async (prompt) => {
|
|
try {
|
|
await api.updateUnifiedPrompt(prompt.id, { active: !prompt.active })
|
|
await loadPrompts()
|
|
} catch (e) {
|
|
alert('Fehler: ' + e.message)
|
|
}
|
|
}
|
|
|
|
const handleDelete = async (prompt) => {
|
|
if (!confirm(`Prompt "${prompt.name}" wirklich löschen?`)) return
|
|
|
|
try {
|
|
await api.deletePrompt(prompt.id)
|
|
await loadPrompts()
|
|
} catch (e) {
|
|
alert('Fehler: ' + e.message)
|
|
}
|
|
}
|
|
|
|
const handleDuplicate = async (prompt) => {
|
|
try {
|
|
await api.duplicatePrompt(prompt.id)
|
|
await loadPrompts()
|
|
} catch (e) {
|
|
alert('Fehler: ' + e.message)
|
|
}
|
|
}
|
|
|
|
const handleConvertToBase = async (prompt) => {
|
|
// Convert a 1-stage pipeline to a base prompt
|
|
if (prompt.type !== 'pipeline') {
|
|
alert('Nur Pipeline-Prompts können konvertiert werden')
|
|
return
|
|
}
|
|
|
|
const stages = typeof prompt.stages === 'string'
|
|
? JSON.parse(prompt.stages)
|
|
: prompt.stages
|
|
|
|
if (!stages || stages.length !== 1) {
|
|
alert('Nur 1-stage Pipeline-Prompts können zu Basis-Prompts konvertiert werden')
|
|
return
|
|
}
|
|
|
|
const stage1 = stages[0]
|
|
if (!stage1.prompts || stage1.prompts.length !== 1) {
|
|
alert('Stage muss genau einen Prompt haben')
|
|
return
|
|
}
|
|
|
|
const firstPrompt = stage1.prompts[0]
|
|
if (firstPrompt.source !== 'inline' || !firstPrompt.template) {
|
|
alert('Nur inline Templates können konvertiert werden')
|
|
return
|
|
}
|
|
|
|
if (!confirm(`"${prompt.name}" zu Basis-Prompt konvertieren?`)) return
|
|
|
|
try {
|
|
await api.updateUnifiedPrompt(prompt.id, {
|
|
type: 'base',
|
|
template: firstPrompt.template,
|
|
output_format: firstPrompt.output_format || 'text',
|
|
stages: null
|
|
})
|
|
await loadPrompts()
|
|
} catch (e) {
|
|
alert('Fehler: ' + e.message)
|
|
}
|
|
}
|
|
|
|
const handleSave = async () => {
|
|
setEditingPrompt(null)
|
|
setShowNewPrompt(false)
|
|
await loadPrompts()
|
|
}
|
|
|
|
const getStageCount = (prompt) => {
|
|
if (prompt.type !== 'pipeline' || !prompt.stages) return 0
|
|
try {
|
|
const stages = typeof prompt.stages === 'string'
|
|
? JSON.parse(prompt.stages)
|
|
: prompt.stages
|
|
return stages.length
|
|
} catch (e) {
|
|
return 0
|
|
}
|
|
}
|
|
|
|
const getTypeLabel = (type) => {
|
|
if (type === 'base') return 'Basis'
|
|
if (type === 'pipeline') return 'Pipeline'
|
|
return type || 'Pipeline' // Default for old prompts
|
|
}
|
|
|
|
const getTypeColor = (type) => {
|
|
if (type === 'base') return 'var(--accent)'
|
|
if (type === 'pipeline') return '#6366f1'
|
|
return 'var(--text3)'
|
|
}
|
|
|
|
return (
|
|
<div style={{
|
|
padding: 20,
|
|
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
|
|
className="btn btn-primary"
|
|
onClick={() => setShowNewPrompt(true)}
|
|
>
|
|
+ Neuer Prompt
|
|
</button>
|
|
</div>
|
|
|
|
{error && (
|
|
<div style={{
|
|
padding: 16,
|
|
background: '#fee',
|
|
color: '#c00',
|
|
borderRadius: 8,
|
|
marginBottom: 16
|
|
}}>
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{/* Filters */}
|
|
<div style={{
|
|
display: 'flex',
|
|
gap: 12,
|
|
marginBottom: 24,
|
|
flexWrap: 'wrap',
|
|
alignItems: 'center'
|
|
}}>
|
|
<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
|
|
className="form-select"
|
|
value={category}
|
|
onChange={e => setCategory(e.target.value)}
|
|
style={{ fontSize: 13, padding: '6px 12px' }}
|
|
>
|
|
{categories.map(cat => (
|
|
<option key={cat.id} value={cat.id}>
|
|
{cat.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Prompts Table */}
|
|
{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)',
|
|
overflowX: 'auto'
|
|
}}>
|
|
<table style={{ width: '100%', minWidth: 900, borderCollapse: 'collapse' }}>
|
|
<thead>
|
|
<tr style={{
|
|
background: 'var(--surface2)',
|
|
borderBottom: '1px solid var(--border)'
|
|
}}>
|
|
<th style={{
|
|
padding: 12,
|
|
textAlign: 'left',
|
|
fontSize: 12,
|
|
fontWeight: 600,
|
|
color: 'var(--text3)',
|
|
width: 80
|
|
}}>
|
|
Typ
|
|
</th>
|
|
<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
|
|
</th>
|
|
<th style={{
|
|
padding: 12,
|
|
textAlign: 'center',
|
|
fontSize: 12,
|
|
fontWeight: 600,
|
|
color: 'var(--text3)',
|
|
width: 100
|
|
}}>
|
|
Stages
|
|
</th>
|
|
<th style={{
|
|
padding: 12,
|
|
textAlign: 'center',
|
|
fontSize: 12,
|
|
fontWeight: 600,
|
|
color: 'var(--text3)',
|
|
width: 80
|
|
}}>
|
|
Status
|
|
</th>
|
|
<th style={{
|
|
padding: 12,
|
|
textAlign: 'right',
|
|
fontSize: 12,
|
|
fontWeight: 600,
|
|
color: 'var(--text3)',
|
|
width: 120
|
|
}}>
|
|
Aktionen
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filteredPrompts.length === 0 ? (
|
|
<tr>
|
|
<td colSpan="6" style={{
|
|
padding: 40,
|
|
textAlign: 'center',
|
|
color: 'var(--text3)'
|
|
}}>
|
|
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>
|
|
</td>
|
|
<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}
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td style={{ padding: 12, fontSize: 13 }}>
|
|
{prompt.category || 'ganzheitlich'}
|
|
</td>
|
|
<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
|
|
type="checkbox"
|
|
checked={prompt.active}
|
|
onChange={() => handleToggleActive(prompt)}
|
|
style={{ margin: 0 }}
|
|
/>
|
|
</label>
|
|
</td>
|
|
<td style={{ padding: 12 }}>
|
|
<div style={{
|
|
display: 'flex',
|
|
gap: 6,
|
|
justifyContent: 'flex-end'
|
|
}}>
|
|
<button
|
|
onClick={() => setEditingPrompt(prompt)}
|
|
style={{
|
|
background: 'none',
|
|
border: 'none',
|
|
cursor: 'pointer',
|
|
padding: 4
|
|
}}
|
|
title="Bearbeiten"
|
|
>
|
|
<Edit size={16} color="var(--accent)" />
|
|
</button>
|
|
{/* Show convert button for 1-stage pipelines */}
|
|
{prompt.type === 'pipeline' && getStageCount(prompt) === 1 && (
|
|
<button
|
|
onClick={() => handleConvertToBase(prompt)}
|
|
style={{
|
|
background: 'none',
|
|
border: 'none',
|
|
cursor: 'pointer',
|
|
padding: 4
|
|
}}
|
|
title="Zu Basis-Prompt konvertieren"
|
|
>
|
|
<ArrowDownToLine size={16} color="#6366f1" />
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => handleDuplicate(prompt)}
|
|
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"
|
|
>
|
|
<Trash2 size={16} color="var(--danger)" />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{/* Unified Prompt Modal */}
|
|
{(editingPrompt || showNewPrompt) && (
|
|
<UnifiedPromptModal
|
|
prompt={editingPrompt}
|
|
onSave={handleSave}
|
|
onClose={() => {
|
|
setEditingPrompt(null)
|
|
setShowNewPrompt(false)
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|