feat: Enhance StartNode and Workflow Editor with analysis metadata
All checks were successful
Deploy Development / deploy (push) Successful in 54s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s

- Updated StartNode to display a trimmed analysis title if available, falling back to the label or 'Start'.
- Refactored WorkflowEditorPage to include analysis metadata (title, description, category) in the start node configuration.
- Improved serialization and deserialization of workflow graphs to handle new analysis fields.
- Enhanced user interface to allow setting and displaying analysis metadata for better clarity in the workflow editor.

These changes improve the user experience by providing clearer metadata handling in workflows and ensuring consistent display in analysis components.
This commit is contained in:
Lars 2026-04-11 12:19:06 +02:00
parent d803f39de3
commit 0ce98e8973
5 changed files with 201 additions and 33 deletions

View File

@ -8,10 +8,11 @@ import { Handle, Position } from 'reactflow'
* - selected: Boolean (Node ist ausgewählt) * - selected: Boolean (Node ist ausgewählt)
*/ */
export function StartNode({ data, selected }) { export function StartNode({ data, selected }) {
const title = (data.analysis_title || '').trim()
return ( return (
<div className={`workflow-node start-node ${selected ? 'selected' : ''}`}> <div className={`workflow-node start-node ${selected ? 'selected' : ''}`}>
<div className="node-icon">🚀</div> <div className="node-icon">🚀</div>
<div className="node-label">{data.label || 'Start'}</div> <div className="node-label">{title || data.label || 'Start'}</div>
{/* Nur Source Handle (kein Target, da Einstiegspunkt) */} {/* Nur Source Handle (kein Target, da Einstiegspunkt) */}
<Handle <Handle

View File

@ -0,0 +1,21 @@
/** DB `ai_prompts.category` Reihenfolge der Gruppen in der KI-Analyse-Navigation */
export const ANALYSIS_CATEGORY_ORDER = [
'körper',
'ernährung',
'training',
'schlaf',
'vitalwerte',
'ziele',
'ganzheitlich',
]
export const ANALYSIS_CATEGORY_LABELS = {
körper: 'Körper',
ernährung: 'Ernährung',
training: 'Training',
schlaf: 'Schlaf',
vitalwerte: 'Vitalwerte',
ziele: 'Ziele',
ganzheitlich: 'Ganzheitlich',
}

View File

@ -3,6 +3,10 @@ import { Brain, Trash2, ChevronDown, ChevronUp } from 'lucide-react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { api } from '../utils/api' import { api } from '../utils/api'
import { getWorkflowDisplayContent } from '../utils/workflowDisplay' import { getWorkflowDisplayContent } from '../utils/workflowDisplay'
import {
ANALYSIS_CATEGORY_ORDER,
ANALYSIS_CATEGORY_LABELS,
} from '../config/analysisCategories'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import Markdown from '../utils/Markdown' import Markdown from '../utils/Markdown'
import UsageBadge from '../components/UsageBadge' import UsageBadge from '../components/UsageBadge'
@ -15,19 +19,6 @@ const SLUG_LABELS = {
pipeline: '🔬 Mehrstufige Gesamtanalyse' pipeline: '🔬 Mehrstufige Gesamtanalyse'
} }
/** DB `ai_prompts.category` Reihenfolge der Gruppen in der Analyse-Navigation */
const ANALYSIS_CATEGORY_ORDER = ['körper', 'ernährung', 'training', 'schlaf', 'vitalwerte', 'ziele', 'ganzheitlich']
const ANALYSIS_CATEGORY_LABELS = {
körper: 'Körper',
ernährung: 'Ernährung',
training: 'Training',
schlaf: 'Schlaf',
vitalwerte: 'Vitalwerte',
ziele: 'Ziele',
ganzheitlich: 'Ganzheitlich',
}
function sortAnalysisCategoryKeys(keys) { function sortAnalysisCategoryKeys(keys) {
return [...keys].sort((a, b) => { return [...keys].sort((a, b) => {
const na = String(a).toLowerCase() const na = String(a).toLowerCase()
@ -87,7 +78,8 @@ function InsightCard({ ins, onDelete, defaultOpen=false, prompts=[] }) {
// Find matching prompt to get display_name // Find matching prompt to get display_name
const prompt = prompts.find(p => p.slug === ins.scope) const prompt = prompts.find(p => p.slug === ins.scope)
const displayName = prompt?.display_name || SLUG_LABELS[ins.scope] || ins.scope const displayName =
prompt?.display_name || prompt?.name || SLUG_LABELS[ins.scope] || ins.scope
// Use already-parsed metadata // Use already-parsed metadata
const metadata = metadataRaw const metadata = metadataRaw
@ -546,9 +538,10 @@ export default function Analysis() {
{canUseAI && pipelinePrompts.length > 0 && ( {canUseAI && pipelinePrompts.length > 0 && (
<> <>
<p style={{fontSize:13,color:'var(--text2)',marginBottom:14,lineHeight:1.6}}> <p style={{fontSize:13,color:'var(--text2)',marginBottom:14,lineHeight:1.6}}>
Zuerst die <strong>Kategorie</strong> wählen (Chip-Leiste bzw. Seitenleiste). Alle Pipeline-Analysen Zuerst die <strong>Kategorie</strong> wählen (Chip-Leiste bzw. Seitenleiste). Alle{' '}
dieser Kategorie erscheinen im Detailbereich (rechts auf Desktop, darunter auf Mobil). <strong>Pipeline- und Workflow-Auswertungen</strong> dieser Kategorie erscheinen im Detailbereich
Kategorien kommen aus dem Feld Kategorie beim jeweiligen Prompt im Admin. (rechts auf Desktop, darunter auf Mobil). Bei Workflows legst du Kategorie, Titel und Kurztext in der{' '}
<strong>Start-Node</strong> des Workflow-Editors fest; bei Pipelines im Admin unter KI-Prompts.
</p> </p>
<div className="analysis-split"> <div className="analysis-split">
<div className="analysis-split__nav-wrap"> <div className="analysis-split__nav-wrap">
@ -649,9 +642,9 @@ export default function Analysis() {
{canUseAI && pipelinePrompts.length === 0 && ( {canUseAI && pipelinePrompts.length === 0 && (
<div className="empty-state"> <div className="empty-state">
<p>Keine aktiven Pipeline-Prompts verfügbar.</p> <p>Keine aktiven Pipeline- oder Workflow-Auswertungen verfügbar.</p>
<p style={{fontSize:12,color:'var(--text3)',marginTop:8}}> <p style={{fontSize:12,color:'var(--text3)',marginTop:8}}>
Erstelle Pipeline-Prompts im Admin-Bereich unter Admin KI-Prompts. Pipelines und Workflows werden im Admin unter KI-Prompts bzw. Workflow-Editor angelegt.
</p> </p>
</div> </div>
)} )}
@ -675,7 +668,10 @@ export default function Analysis() {
onClick={() => setHistoryScopePick(scope)} onClick={() => setHistoryScopePick(scope)}
aria-current={activeHistoryScope === scope ? 'page' : undefined} aria-current={activeHistoryScope === scope ? 'page' : undefined}
> >
{prompts.find(pr => pr.slug === scope)?.display_name || SLUG_LABELS[scope] || scope} {(() => {
const pr = prompts.find((p) => p.slug === scope)
return pr?.display_name || pr?.name || SLUG_LABELS[scope] || scope
})()}
<span className="muted" style={{ fontSize: 12 }}> ({grouped[scope].length})</span> <span className="muted" style={{ fontSize: 12 }}> ({grouped[scope].length})</span>
</button> </button>
))} ))}

View File

@ -22,6 +22,28 @@ import { InlineTemplateEditor } from '../components/workflow/panels/InlineTempla
import { Toast } from '../components/Toast' import { Toast } from '../components/Toast'
import { ConfirmDialog } from '../components/ConfirmDialog' import { ConfirmDialog } from '../components/ConfirmDialog'
import '../styles/workflowEditor.css' import '../styles/workflowEditor.css'
import {
ANALYSIS_CATEGORY_ORDER,
ANALYSIS_CATEGORY_LABELS,
} from '../config/analysisCategories'
/** Aus Start-Node → Felder für Analyse-UI / ai_prompts (display_name, description, category) */
function getWorkflowAnalysisPublishFields(nodes, fallbackName) {
const start = nodes.find((n) => n.type === 'start')
const d = start?.data || {}
const title =
String(d.analysis_title ?? '').trim() ||
String(fallbackName ?? '').trim() ||
'Workflow'
const description = String(d.analysis_description ?? '').trim()
const category =
String(d.analysis_category ?? 'ganzheitlich').toLowerCase().trim() || 'ganzheitlich'
return {
display_name: title,
description: description || null,
category,
}
}
// Node-Type Mapping // Node-Type Mapping
const nodeTypes = { const nodeTypes = {
@ -45,7 +67,6 @@ export default function WorkflowEditorPage() {
const selectedNode = selectedNodeId ? nodes.find(n => n.id === selectedNodeId) : null const selectedNode = selectedNodeId ? nodes.find(n => n.id === selectedNodeId) : null
const [currentPrompt, setCurrentPrompt] = useState(null) const [currentPrompt, setCurrentPrompt] = useState(null)
const [workflowName, setWorkflowName] = useState('Neuer Workflow') const [workflowName, setWorkflowName] = useState('Neuer Workflow')
const [workflowDescription, setWorkflowDescription] = useState('')
const [validationErrors, setValidationErrors] = useState([]) const [validationErrors, setValidationErrors] = useState([])
const [validationWarnings, setValidationWarnings] = useState([]) const [validationWarnings, setValidationWarnings] = useState([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@ -103,16 +124,26 @@ export default function WorkflowEditorPage() {
}, []) }, [])
const handleAddNode = (nodeType) => { const handleAddNode = (nodeType) => {
const newNode = { const base = {
id: `node_${nodeIdCounter++}`, id: `node_${nodeIdCounter++}`,
type: nodeType, type: nodeType,
position: { x: 250, y: 100 + nodes.length * 100 }, position: { x: 250, y: 100 + nodes.length * 100 },
data: { data: {
label: `${nodeType.charAt(0).toUpperCase() + nodeType.slice(1)} ${nodeIdCounter - 1}` label: `${nodeType.charAt(0).toUpperCase() + nodeType.slice(1)} ${nodeIdCounter - 1}`,
},
}
if (nodeType === 'start') {
base.data = {
label: 'Start',
analysis_title:
workflowName && workflowName !== 'Neuer Workflow'
? workflowName
: '',
analysis_description: '',
analysis_category: 'ganzheitlich',
} }
} }
setNodes((nds) => [...nds, base])
setNodes((nds) => [...nds, newNode])
} }
const handleNodeUpdate = (nodeId, updates) => { const handleNodeUpdate = (nodeId, updates) => {
@ -152,13 +183,20 @@ export default function WorkflowEditorPage() {
}) })
console.log('📊 Serialized graph_data:', graph_data) console.log('📊 Serialized graph_data:', graph_data)
const { display_name, description, category } = getWorkflowAnalysisPublishFields(
nodes,
workflowName
)
if (currentPrompt) { if (currentPrompt) {
// Update existing // Update existing
console.log('📝 Updating existing workflow:', currentPrompt.id) console.log('📝 Updating existing workflow:', currentPrompt.id)
await api.updateUnifiedPrompt(currentPrompt.id, { await api.updateUnifiedPrompt(currentPrompt.id, {
type: 'workflow', type: 'workflow',
name: workflowName, name: workflowName,
description: workflowDescription, display_name,
description: description ?? undefined,
category,
graph_data graph_data
}) })
setToast({ message: '✅ Workflow gespeichert!', type: 'success' }) setToast({ message: '✅ Workflow gespeichert!', type: 'success' })
@ -168,7 +206,9 @@ export default function WorkflowEditorPage() {
const result = await api.createUnifiedPrompt({ const result = await api.createUnifiedPrompt({
type: 'workflow', type: 'workflow',
name: workflowName, name: workflowName,
description: workflowDescription, display_name,
description: description ?? undefined,
category,
graph_data graph_data
}) })
console.log('✅ Workflow created:', result) console.log('✅ Workflow created:', result)
@ -203,11 +243,30 @@ export default function WorkflowEditorPage() {
const { nodes: loadedNodes, edges: loadedEdges } = deserializeFromWorkflowGraph(prompt.graph_data) const { nodes: loadedNodes, edges: loadedEdges } = deserializeFromWorkflowGraph(prompt.graph_data)
console.log('🎯 Deserialized:', { nodes: loadedNodes, edges: loadedEdges }) console.log('🎯 Deserialized:', { nodes: loadedNodes, edges: loadedEdges })
setNodes(loadedNodes) const mergedNodes = loadedNodes.map((n) => {
if (n.type !== 'start') return n
const hasStoredTitle = Object.prototype.hasOwnProperty.call(n.data || {}, 'analysis_title')
return {
...n,
data: {
label: n.data?.label || 'Start',
...n.data,
analysis_title: hasStoredTitle
? n.data.analysis_title
: (prompt.display_name || prompt.name || ''),
analysis_description: Object.prototype.hasOwnProperty.call(n.data || {}, 'analysis_description')
? n.data.analysis_description
: (prompt.description || ''),
analysis_category:
(n.data?.analysis_category || prompt.category || 'ganzheitlich').toLowerCase(),
},
}
})
setNodes(mergedNodes)
setEdges(loadedEdges) setEdges(loadedEdges)
setCurrentPrompt(prompt) setCurrentPrompt(prompt)
setWorkflowName(prompt.name) setWorkflowName(prompt.name)
setWorkflowDescription(prompt.description || '')
// nodeIdCounter aktualisieren // nodeIdCounter aktualisieren
const maxId = Math.max( const maxId = Math.max(
@ -242,7 +301,6 @@ export default function WorkflowEditorPage() {
setEdges([]) setEdges([])
setCurrentPrompt(null) setCurrentPrompt(null)
setWorkflowName('Neuer Workflow') setWorkflowName('Neuer Workflow')
setWorkflowDescription('')
setSelectedNodeId(null) setSelectedNodeId(null)
navigate('/workflow-editor/new') navigate('/workflow-editor/new')
} }
@ -342,7 +400,8 @@ export default function WorkflowEditorPage() {
type="text" type="text"
value={workflowName} value={workflowName}
onChange={(e) => setWorkflowName(e.target.value)} onChange={(e) => setWorkflowName(e.target.value)}
placeholder="Workflow-Name" placeholder="Interner Workflow-Name (Slug-Basis)"
title="Technischer Name in der Datenbank. Den sichtbaren Titel für die KI-Analyse setzt du in der Start-Node."
style={{ flex: 1, padding: '8px', borderRadius: '4px', border: '1px solid var(--border)' }} style={{ flex: 1, padding: '8px', borderRadius: '4px', border: '1px solid var(--border)' }}
/> />
@ -495,9 +554,88 @@ export default function WorkflowEditorPage() {
</div> </div>
)} )}
{/* Start-Node: Metadaten für KI-Analyse (wie Pipeline: display_name, description, category) */}
{selectedNode.type === 'start' && (
<div className="config-section" style={{ marginBottom: 20 }}>
<h3 style={{ margin: '0 0 10px', fontSize: 15 }}>Anzeige in KI-Analyse</h3>
<p style={{ fontSize: 12, color: 'var(--text3)', lineHeight: 1.5, marginBottom: 12 }}>
Titel, Kurzbeschreibung und Kategorie erscheinen auf der Analyse-Seite, im Verlauf und in der
Kategorie-Navigation analog zu Pipeline-Prompts im Admin.
</p>
<label style={{ display: 'block', marginBottom: 6, fontWeight: 600, fontSize: 13 }}>
Titel (sichtbar für Nutzer)
</label>
<input
type="text"
value={selectedNode.data.analysis_title ?? ''}
onChange={(e) =>
handleNodeUpdate(selectedNode.id, { analysis_title: e.target.value })
}
placeholder="z. B. Ganzheitliche Auswertung"
style={{
width: '100%',
padding: '8px',
borderRadius: '4px',
border: '1px solid var(--border)',
background: 'var(--surface)',
color: 'var(--text1)',
fontSize: '14px',
marginBottom: 12,
}}
/>
<label style={{ display: 'block', marginBottom: 6, fontWeight: 600, fontSize: 13 }}>
Kurzbeschreibung (optional)
</label>
<textarea
value={selectedNode.data.analysis_description ?? ''}
onChange={(e) =>
handleNodeUpdate(selectedNode.id, { analysis_description: e.target.value })
}
placeholder="Ein Satz, worum es bei dieser Auswertung geht …"
rows={4}
style={{
width: '100%',
padding: '8px',
borderRadius: '4px',
border: '1px solid var(--border)',
background: 'var(--surface)',
color: 'var(--text1)',
fontSize: '13px',
lineHeight: 1.5,
marginBottom: 12,
resize: 'vertical',
}}
/>
<label style={{ display: 'block', marginBottom: 6, fontWeight: 600, fontSize: 13 }}>
Kategorie
</label>
<select
value={selectedNode.data.analysis_category || 'ganzheitlich'}
onChange={(e) =>
handleNodeUpdate(selectedNode.id, { analysis_category: e.target.value })
}
style={{
width: '100%',
padding: '8px',
borderRadius: '4px',
border: '1px solid var(--border)',
background: 'var(--surface)',
color: 'var(--text1)',
fontSize: '14px',
}}
>
{ANALYSIS_CATEGORY_ORDER.map((key) => (
<option key={key} value={key}>
{ANALYSIS_CATEGORY_LABELS[key] || key}
</option>
))}
</select>
</div>
)}
{/* Basis-Konfiguration */} {/* Basis-Konfiguration */}
<div className="config-section"> <div className="config-section">
<label>Node-Name</label> <label>Node-Name {selectedNode.type === 'start' && '(auf dem Canvas)'}</label>
<input <input
type="text" type="text"
value={selectedNode.data.label || ''} value={selectedNode.data.label || ''}

View File

@ -42,6 +42,12 @@ export function serializeToWorkflowGraph(nodes, edges, metadata = {}) {
...(node.type === 'end' && { ...(node.type === 'end' && {
output_mode: node.data.output_mode || 'auto', output_mode: node.data.output_mode || 'auto',
template: node.data.template || null template: node.data.template || null
}),
...(node.type === 'start' && {
analysis_title: node.data.analysis_title || '',
analysis_description: node.data.analysis_description || '',
analysis_category: node.data.analysis_category || 'ganzheitlich',
}) })
})) }))
@ -105,6 +111,12 @@ export function deserializeFromWorkflowGraph(jsonbData) {
...(node.type === 'end' && { ...(node.type === 'end' && {
output_mode: node.output_mode || 'auto', output_mode: node.output_mode || 'auto',
template: node.template || null template: node.template || null
}),
...(node.type === 'start' && {
analysis_title: node.analysis_title || '',
analysis_description: node.analysis_description || '',
analysis_category: node.analysis_category || 'ganzheitlich',
}) })
} }
})) }))