mitai-jinkendo/frontend/src/pages/Analysis.jsx
Lars a515a5d563
All checks were successful
Deploy Development / deploy (push) Successful in 56s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s
feat: Add WorkflowDebugPanel component to display per-node debug information
- Created WorkflowDebugPanel.jsx: Collapsible panel showing debug info for each workflow node
  - Shows prompt sent to AI
  - Shows raw AI response
  - Shows parsed results
  - Shows normalized signals
  - Color-coded status (executed/failed/skipped)
  - Expandable/collapsible per node

- Updated Analysis.jsx:
  - Added WorkflowDebugPanel import
  - Store node_states in newResult for debugging
  - Display WorkflowDebugPanel below InsightCard (both locations)

This makes it easy to debug workflow issues by seeing exactly what happened at each node.
2026-04-13 12:41:12 +02:00

724 lines
31 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 React, { useState, useEffect, useMemo } from 'react'
import { Brain, Trash2, ChevronDown, ChevronUp } from 'lucide-react'
import { Link } from 'react-router-dom'
import { api } from '../utils/api'
import { getWorkflowDisplayContent } from '../utils/workflowDisplay'
import {
ANALYSIS_CATEGORY_ORDER,
ANALYSIS_CATEGORY_LABELS,
} from '../config/analysisCategories'
import { useAuth } from '../context/AuthContext'
import Markdown from '../utils/Markdown'
import UsageBadge from '../components/UsageBadge'
import WorkflowDebugPanel from '../components/WorkflowDebugPanel'
import dayjs from 'dayjs'
import 'dayjs/locale/de'
dayjs.locale('de')
// Legacy fallback labels (display_name takes precedence)
const SLUG_LABELS = {
pipeline: '🔬 Mehrstufige Gesamtanalyse'
}
function sortAnalysisCategoryKeys(keys) {
return [...keys].sort((a, b) => {
const na = String(a).toLowerCase()
const nb = String(b).toLowerCase()
const ia = ANALYSIS_CATEGORY_ORDER.indexOf(na)
const ib = ANALYSIS_CATEGORY_ORDER.indexOf(nb)
if (ia === -1 && ib === -1) return String(a).localeCompare(String(b), 'de')
if (ia === -1) return 1
if (ib === -1) return -1
return ia - ib
})
}
function analysisCategoryLabel(key) {
const k = String(key).toLowerCase()
return ANALYSIS_CATEGORY_LABELS[k] || String(key)
}
/** Analyse-Angebote: klassische Pipelines + graphbasierte KI-Workflows (`type === 'workflow'`) */
function isAnalysisOfferPrompt(p) {
return p.active && (p.type === 'pipeline' || p.type === 'workflow')
}
/** Pipeline-Prompts nach `category` gruppieren (Backend-Feld), innerhalb Gruppe nach sort_order */
function buildPipelineGroups(pipelinePrompts) {
const m = new Map()
for (const p of pipelinePrompts) {
const raw =
p.category != null && String(p.category).trim() !== ''
? String(p.category).trim()
: 'ganzheitlich'
if (!m.has(raw)) m.set(raw, [])
m.get(raw).push(p)
}
for (const arr of m.values()) {
arr.sort((a, b) => (Number(a.sort_order) || 0) - (Number(b.sort_order) || 0))
}
return sortAnalysisCategoryKeys([...m.keys()]).map(cat => ({
categoryKey: cat,
label: analysisCategoryLabel(cat),
prompts: m.get(cat),
}))
}
function InsightCard({ ins, onDelete, defaultOpen=false, prompts=[] }) {
const [open, setOpen] = useState(defaultOpen)
// Parse metadata early to determine showOnlyValues
const metadataRaw = ins.metadata ? (typeof ins.metadata === 'string' ? JSON.parse(ins.metadata) : ins.metadata) : null
const isBasePrompt = metadataRaw?.prompt_type === 'base'
const isJsonOutput = ins.content && (ins.content.trim().startsWith('{') || ins.content.trim().startsWith('['))
const placeholdersRaw = metadataRaw?.placeholders || {}
const showOnlyValues = isBasePrompt && isJsonOutput && Object.keys(placeholdersRaw).length > 0
const [showValues, setShowValues] = useState(showOnlyValues) // Auto-expand for base prompts with JSON
const [expertMode, setExpertMode] = useState(false) // Show empty/technical placeholders
// Find matching prompt to get display_name
const prompt = prompts.find(p => p.slug === ins.scope)
const displayName =
prompt?.display_name || prompt?.name || SLUG_LABELS[ins.scope] || ins.scope
// Use already-parsed metadata
const metadata = metadataRaw
const allPlaceholders = placeholdersRaw
// Filter placeholders: In normal mode, hide empty values and raw stage outputs
const placeholders = expertMode
? allPlaceholders
: Object.fromEntries(
Object.entries(allPlaceholders).filter(([key, data]) => {
// Hide raw stage outputs (JSON) in normal mode
if (data.is_stage_raw) return false
// Hide empty values
const val = data.value || ''
return val.trim() !== '' && val !== 'nicht verfügbar' && val !== '[Nicht verfügbar]'
})
)
const placeholderCount = Object.keys(placeholders).length
const hiddenCount = Object.keys(allPlaceholders).length - placeholderCount
// Group placeholders by category
const groupedPlaceholders = Object.entries(placeholders).reduce((acc, [key, data]) => {
const category = data.category || 'Sonstiges'
if (!acc[category]) acc[category] = []
acc[category].push([key, data])
return acc
}, {})
// Sort categories: Regular categories first, then Stage outputs, then Rohdaten
const sortedCategories = Object.keys(groupedPlaceholders).sort((a, b) => {
const aIsStage = a.startsWith('Stage')
const bIsStage = b.startsWith('Stage')
const aIsRohdaten = a.includes('Rohdaten')
const bIsRohdaten = b.includes('Rohdaten')
// Rohdaten last
if (aIsRohdaten && !bIsRohdaten) return 1
if (!aIsRohdaten && bIsRohdaten) return -1
// Stage outputs after regular categories
if (!aIsStage && bIsStage) return -1
if (aIsStage && !bIsStage) return 1
// Otherwise alphabetical
return a.localeCompare(b)
})
return (
<div className="card section-gap" style={{borderLeft:`3px solid var(--accent)`}}>
<div style={{display:'flex',alignItems:'center',gap:8,marginBottom:open?12:0,cursor:'pointer'}}
onClick={()=>setOpen(o=>!o)}>
<div style={{flex:1}}>
<div style={{fontSize:13,fontWeight:600}}>
{displayName}
</div>
<div style={{fontSize:11,color:'var(--text3)'}}>
{dayjs(ins.created).format('DD. MMMM YYYY, HH:mm')}
</div>
</div>
<button style={{background:'none',border:'none',cursor:'pointer',color:'var(--text3)',padding:4}}
onClick={e=>{e.stopPropagation();onDelete(ins.id)}}>
<Trash2 size={13}/>
</button>
{open ? <ChevronUp size={16} color="var(--text3)"/> : <ChevronDown size={16} color="var(--text3)"/>}
</div>
{open && (
<>
{/* For base prompts with JSON: Only show value table */}
{showOnlyValues && (
<div style={{ padding: '12px 16px', background: 'var(--surface)', borderRadius: 8, marginBottom: 12 }}>
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: 8 }}>
Basis-Prompt Rohdaten (JSON-Struktur für technische Nutzung)
</div>
<details style={{ fontSize: 11, color: 'var(--text3)' }}>
<summary style={{ cursor: 'pointer' }}>Technische Daten anzeigen</summary>
<pre style={{
marginTop: 8,
padding: 8,
background: 'var(--bg)',
borderRadius: 4,
overflow: 'auto',
fontSize: 10,
fontFamily: 'monospace'
}}>
{ins.content}
</pre>
</details>
</div>
)}
{/* For other prompts: Show full content */}
{!showOnlyValues && <Markdown text={ins.content}/>}
{/* Value Table */}
{placeholderCount > 0 && (
<div style={{ marginTop: 16, borderTop: '1px solid var(--border)', paddingTop: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div
onClick={() => setShowValues(!showValues)}
style={{
cursor: 'pointer',
fontSize: 12,
color: 'var(--text2)',
fontWeight: 600,
display: 'flex',
alignItems: 'center',
gap: 6
}}
>
{showValues ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
📊 Verwendete Werte ({placeholderCount})
{hiddenCount > 0 && !expertMode && (
<span style={{ fontSize: 10, color: 'var(--text3)', fontWeight: 400 }}>
(+{hiddenCount} ausgeblendet)
</span>
)}
</div>
{showValues && Object.keys(allPlaceholders).length > 0 && (
<button
onClick={(e) => {
e.stopPropagation()
setExpertMode(!expertMode)
}}
className="btn"
style={{
fontSize: 10,
padding: '4px 8px',
background: expertMode ? 'var(--accent)' : 'var(--surface)',
color: expertMode ? 'white' : 'var(--text2)'
}}
>
🔬 Experten-Modus
</button>
)}
</div>
{showValues && (
<div style={{ marginTop: 12, fontSize: 11 }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--border)', textAlign: 'left' }}>
<th style={{ padding: '6px 8px', color: 'var(--text3)', fontWeight: 600 }}>Platzhalter</th>
<th style={{ padding: '6px 8px', color: 'var(--text3)', fontWeight: 600 }}>Wert</th>
<th style={{ padding: '6px 8px', color: 'var(--text3)', fontWeight: 600 }}>Beschreibung</th>
</tr>
</thead>
<tbody>
{sortedCategories.map(category => (
<React.Fragment key={category}>
{/* Category Header */}
<tr style={{ background: 'var(--surface2)', borderTop: '2px solid var(--border)' }}>
<td colSpan="3" style={{
padding: '8px',
fontWeight: 600,
fontSize: 11,
color: 'var(--text2)',
letterSpacing: '0.5px'
}}>
{category}
</td>
</tr>
{/* Category Values */}
{groupedPlaceholders[category].map(([key, data]) => {
const isExtracted = data.is_extracted
const isStageRaw = data.is_stage_raw
return (
<tr key={key} style={{
borderBottom: '1px solid var(--border)',
background: isStageRaw && expertMode ? 'var(--surface)' : 'transparent'
}}>
<td style={{
padding: '6px 8px',
fontFamily: 'monospace',
color: isStageRaw ? 'var(--text3)' : (isExtracted ? '#6B8E23' : 'var(--accent)'),
whiteSpace: 'nowrap',
verticalAlign: 'top',
fontSize: isStageRaw ? 10 : 11
}}>
{isExtracted && '↳ '}
{isStageRaw && '🔬 '}
{key}
</td>
<td style={{
padding: '6px 8px',
fontFamily: 'monospace',
wordBreak: 'break-word',
maxWidth: '400px',
verticalAlign: 'top',
fontSize: isStageRaw ? 9 : 11,
color: isStageRaw ? 'var(--text3)' : 'var(--text1)'
}}>
{isStageRaw ? (
<details style={{ cursor: 'pointer' }}>
<summary style={{
fontWeight: 600,
color: 'var(--accent)',
fontSize: 10,
marginBottom: '4px'
}}>
JSON anzeigen
</summary>
<pre style={{
background: 'var(--surface2)',
padding: '8px',
borderRadius: '4px',
fontSize: 9,
overflow: 'auto',
maxHeight: '300px',
margin: 0
}}>
{data.value}
</pre>
</details>
) : data.value}
</td>
<td style={{
padding: '6px 8px',
color: 'var(--text3)',
fontSize: 10,
verticalAlign: 'top',
fontStyle: isExtracted ? 'italic' : 'normal'
}}>
{data.description || '—'}
</td>
</tr>
)
})}
</React.Fragment>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</>
)}
</div>
)
}
export default function Analysis() {
const { canUseAI } = useAuth()
const [prompts, setPrompts] = useState([])
const [allInsights, setAllInsights] = useState([])
const [loading, setLoading] = useState(null)
const [error, setError] = useState(null)
const [tab, setTab] = useState('run')
const [newResult, setNewResult] = useState(null)
const [aiUsage, setAiUsage] = useState(null)
/** Kategorie-Schlüssel aus `buildPipelineGroups` (Navigation); Detail = alle Pipelines dieser Kategorie */
const [activeCategoryKey, setActiveCategoryKey] = useState(null)
const [historyScopePick, setHistoryScopePick] = useState(null)
// NEW: Progress tracking for SSE workflows
const [progress, setProgress] = useState(null) // { total_nodes, completed_nodes, current_node_label }
const loadAll = async () => {
const [p, i] = await Promise.all([
api.listPrompts(),
api.listInsights()
])
setPrompts(Array.isArray(p)?p:[])
setAllInsights(Array.isArray(i)?i:[])
}
useEffect(()=>{
loadAll()
// Load feature usage for badges
api.getFeatureUsage().then(features => {
const aiFeature = features.find(f => f.feature_id === 'ai_calls')
setAiUsage(aiFeature)
}).catch(err => console.error('Failed to load usage:', err))
},[])
useEffect(() => {
const list = prompts.filter(isAnalysisOfferPrompt)
setActiveCategoryKey(prev => {
if (!list.length) return null
const groups = buildPipelineGroups(list)
const keys = groups.map(g => g.categoryKey)
if (prev && keys.includes(prev)) return prev
return groups[0]?.categoryKey ?? null
})
}, [prompts])
useEffect(() => {
if (!newResult?.scope) return
const list = prompts.filter(isAnalysisOfferPrompt)
const groups = buildPipelineGroups(list)
const g = groups.find(gg => gg.prompts.some(p => p.slug === newResult.scope))
if (g) setActiveCategoryKey(g.categoryKey)
}, [newResult?.scope, prompts])
const runPrompt = async (slug) => {
setLoading(slug); setError(null); setNewResult(null); setProgress(null)
try {
// Use SSE-based executor for long-running workflows
const result = await api.executeUnifiedPromptStream(slug, null, null, true, true, (event) => {
// Progress callback: update UI in real-time
if (event.type === 'execution_started') {
setProgress({ total_nodes: 0, completed_nodes: 0, current_node_label: 'Starte...' })
} else if (event.type === 'node_complete') {
setProgress({
total_nodes: event.total_nodes || 0,
completed_nodes: event.completed_nodes || 0,
current_node_label: event.node_label || `Node ${event.node_id}`
})
}
})
// Transform result to match old format for InsightCard
let content = ''
if (result.type === 'pipeline') {
// For pipeline, extract final output
const finalOutput = result.output || {}
if (typeof finalOutput === 'object' && Object.keys(finalOutput).length === 1) {
content = Object.values(finalOutput)[0]
} else {
content = JSON.stringify(finalOutput, null, 2)
}
} else if (result.type === 'workflow') {
content = getWorkflowDisplayContent(result.aggregated_result)
} else {
// For base prompts, use output directly
content = typeof result.output === 'string' ? result.output : JSON.stringify(result.output, null, 2)
}
// Build metadata from debug info (same logic as backend)
let metadata = null
if (result.debug && result.debug.resolved_placeholders) {
const placeholders = {}
const resolved = result.debug.resolved_placeholders
// For pipeline, collect from all stages (Workflow: kein gleiches debug-Schema)
if (result.type === 'pipeline' && result.debug.stages) {
for (const stage of result.debug.stages) {
for (const promptDebug of (stage.prompts || [])) {
const stageResolved = promptDebug.resolved_placeholders || promptDebug.ref_debug?.resolved_placeholders || {}
for (const [key, value] of Object.entries(stageResolved)) {
if (!placeholders[key]) {
placeholders[key] = { value, description: '' }
}
}
}
}
} else {
// For base prompts
for (const [key, value] of Object.entries(resolved)) {
placeholders[key] = { value, description: '' }
}
}
if (Object.keys(placeholders).length > 0) {
metadata = { prompt_type: result.type, placeholders }
}
}
setNewResult({
scope: slug,
content,
metadata,
node_states: result.node_states, // For workflow debug panel
result_type: result.type,
aggregated_result: result.aggregated_result
})
await loadAll()
setTab('run')
} catch(e) {
setError('Fehler: ' + e.message)
} finally {
setLoading(null)
setProgress(null) // Clear progress
}
}
const deleteInsight = async (id) => {
if (!confirm('Analyse löschen?')) return
try {
await api.deleteInsight(id)
if (newResult?.id === id) setNewResult(null)
await loadAll()
} catch (e) {
setError('Löschen fehlgeschlagen: ' + e.message)
}
}
// Group insights by scope for history view
const grouped = {}
allInsights.forEach(ins => {
const key = ins.scope || 'sonstige'
grouped[key] = grouped[key] || []
grouped[key].push(ins)
})
// Aktive Pipeline- + Workflow-Prompts (nach DB-Kategorie gruppiert)
const { pipelinePrompts, pipelineGroups } = useMemo(() => {
const list = prompts.filter(isAnalysisOfferPrompt)
return { pipelinePrompts: list, pipelineGroups: buildPipelineGroups(list) }
}, [prompts])
const historyScopeKeys = Object.keys(grouped).sort((a, b) => a.localeCompare(b))
const activeHistoryScope =
historyScopeKeys.length === 0
? null
: historyScopeKeys.includes(historyScopePick)
? historyScopePick
: historyScopeKeys[0]
return (
<div className="analysis-page">
<div className="analysis-page__header">
<div>
<h1 className="page-title" style={{ margin: 0 }}>KI-Analyse</h1>
<p style={{ fontSize: 12, color: 'var(--text3)', margin: '8px 0 0', lineHeight: 1.5, maxWidth: 520 }}>
Ziele und Fokusbereiche steuern den Kontext der Auswertungen {' '}
<Link to="/goals" style={{ color: 'var(--accent)', fontWeight: 600 }}>unter Ziele konfigurieren</Link>
{' '}(auch über die untere Navigation).
</p>
</div>
</div>
<div className="tabs">
<button type="button" className={'tab'+(tab==='run'?' active':'')} onClick={()=>setTab('run')}>Analysen starten</button>
<button type="button" className={'tab'+(tab==='history'?' active':'')} onClick={()=>setTab('history')}>
Verlauf
{allInsights.length>0 && <span style={{marginLeft:4,fontSize:10,background:'var(--accent)',
color:'white',padding:'1px 5px',borderRadius:8}}>{allInsights.length}</span>}
</button>
</div>
{error && (
<div style={{padding:'10px 14px',background:'#FCEBEB',borderRadius:8,fontSize:13,
color:'#D85A30',marginBottom:12,lineHeight:1.5}}>
{error.includes('nicht aktiviert') || error.includes('Limit')
? <>🔒 <strong>KI-Zugang eingeschränkt</strong><br/>
Dein Profil hat keinen Zugang zu KI-Analysen oder das Tageslimit wurde erreicht.
Bitte den Admin kontaktieren.</>
: error}
</div>
)}
{/* ── Analysen starten ── */}
{tab==='run' && (
<div>
{/* Fallback: Ergebnis oben nur wenn keine Pipeline-Split-Ansicht (z. B. keine Prompts) */}
{newResult && !(canUseAI && pipelinePrompts.length > 0) && (
<div style={{marginBottom:16}}>
<div style={{fontSize:12,fontWeight:600,color:'var(--accent)',marginBottom:8}}>
Neue Analyse erstellt:
</div>
<InsightCard
ins={{...newResult, created: new Date().toISOString()}}
onDelete={deleteInsight}
defaultOpen={true}
prompts={prompts}
/>
{newResult.node_states && (
<WorkflowDebugPanel nodeStates={newResult.node_states} />
)}
</div>
)}
{!canUseAI && (
<div style={{padding:'14px 16px',background:'#FCEBEB',borderRadius:10,
border:'1px solid #D85A3033',marginBottom:16}}>
<div style={{fontSize:14,fontWeight:600,color:'#D85A30',marginBottom:4}}>
🔒 KI-Analysen nicht freigeschaltet
</div>
<div style={{fontSize:13,color:'var(--text2)',lineHeight:1.5}}>
Dein Profil hat keinen Zugang zu KI-Analysen.
Bitte den Admin bitten, KI für dein Profil zu aktivieren
(Einstellungen Admin Profil bearbeiten).
</div>
</div>
)}
{canUseAI && pipelinePrompts.length > 0 && (
<>
<p style={{fontSize:13,color:'var(--text2)',marginBottom:14,lineHeight:1.6}}>
Zuerst die <strong>Kategorie</strong> wählen (Chip-Leiste bzw. Seitenleiste). Alle{' '}
<strong>Pipeline- und Workflow-Auswertungen</strong> dieser Kategorie erscheinen im Detailbereich
(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>
<div className="analysis-split">
<div className="analysis-split__nav-wrap">
<nav className="analysis-split__nav" aria-label="KI-Analyse-Kategorien">
{pipelineGroups.map(({ categoryKey, label, prompts: inGroup }) => (
<button
key={categoryKey}
type="button"
className={
'analysis-split__nav-item' +
(activeCategoryKey === categoryKey ? ' analysis-split__nav-item--active' : '')
}
onClick={() => setActiveCategoryKey(categoryKey)}
aria-current={activeCategoryKey === categoryKey ? 'page' : undefined}
>
{label}
<span className="analysis-split__nav-cat-count">({inGroup.length})</span>
</button>
))}
</nav>
</div>
<div className="analysis-split__main">
{newResult && (
<div style={{ marginBottom: 20 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--accent)', marginBottom: 8 }}>
Neue Analyse erstellt:
</div>
<InsightCard
ins={{ ...newResult, created: new Date().toISOString() }}
onDelete={deleteInsight}
defaultOpen={true}
prompts={prompts}
/>
{newResult.node_states && (
<WorkflowDebugPanel nodeStates={newResult.node_states} />
)}
</div>
)}
{activeCategoryKey && (() => {
const group = pipelineGroups.find(g => g.categoryKey === activeCategoryKey)
if (!group?.prompts?.length) return null
return (
<>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text3)', marginBottom: 12 }}>
{group.label} · {group.prompts.length} {group.prompts.length === 1 ? 'Analyse' : 'Analysen'}
</div>
{group.prompts.map(p => {
const existing = allInsights.find(i => i.scope === p.slug)
return (
<div key={p.id} className="card" style={{marginBottom:16,borderColor:'var(--accent)',borderWidth:2}}>
<div style={{display:'flex',alignItems:'flex-start',gap:12,flexWrap:'wrap'}}>
<div style={{flex:1,minWidth:0}}>
<div className="badge-container-right" style={{fontWeight:700,fontSize:15,color:'var(--accent)'}}>
<span>{p.display_name || SLUG_LABELS[p.slug] || p.name}</span>
{aiUsage && <UsageBadge {...aiUsage} />}
</div>
{p.description && (
<div style={{fontSize:12,color:'var(--text2)',marginTop:3,lineHeight:1.5}}>
{p.description}
</div>
)}
{existing && (
<div style={{fontSize:11,color:'var(--text3)',marginTop:3}}>
Letzte Analyse: {dayjs(existing.created).format('DD.MM.YYYY, HH:mm')}
</div>
)}
</div>
<div
title={aiUsage && !aiUsage.allowed ? `Limit erreicht (${aiUsage.used}/${aiUsage.limit}). Kontaktiere den Admin oder warte bis zum nächsten Reset.` : ''}
style={{display:'inline-block'}}
>
<button
type="button"
className="btn btn-primary"
style={{flexShrink:0,minWidth:100, cursor: (aiUsage && !aiUsage.allowed) ? 'not-allowed' : 'pointer'}}
onClick={() => runPrompt(p.slug)}
disabled={!!loading||!canUseAI||(aiUsage && !aiUsage.allowed)}
>
{loading===p.slug
? (progress
? <><div className="spinner" style={{width:13,height:13}}/> {progress.completed_nodes}/{progress.total_nodes} Nodes</>
: <><div className="spinner" style={{width:13,height:13}}/> Läuft</>)
: (aiUsage && !aiUsage.allowed) ? '🔒 Limit'
: <><Brain size={13}/> Starten</>}
</button>
</div>
</div>
{existing && newResult?.id !== existing.id && (
<div style={{marginTop:8,borderTop:'1px solid var(--border)',paddingTop:8}}>
<InsightCard ins={existing} onDelete={deleteInsight} defaultOpen={false} prompts={prompts}/>
</div>
)}
</div>
)
})}
</>
)
})()}
</div>
</div>
</>
)}
{canUseAI && pipelinePrompts.length === 0 && (
<div className="empty-state">
<p>Keine aktiven Pipeline- oder Workflow-Auswertungen verfügbar.</p>
<p style={{fontSize:12,color:'var(--text3)',marginTop:8}}>
Pipelines und Workflows werden im Admin unter KI-Prompts bzw. Workflow-Editor angelegt.
</p>
</div>
)}
</div>
)}
{/* ── Verlauf gruppiert ── */}
{tab==='history' && (
<div>
{allInsights.length===0 ? (
<div className="empty-state"><h3>Noch keine Analysen</h3></div>
) : (
<div className="analysis-split">
<div className="analysis-split__nav-wrap">
<nav className="analysis-split__nav" aria-label="Gespeicherte Analysen">
{historyScopeKeys.map(scope => (
<button
key={scope}
type="button"
className={'analysis-split__nav-item' + (activeHistoryScope === scope ? ' analysis-split__nav-item--active' : '')}
onClick={() => setHistoryScopePick(scope)}
aria-current={activeHistoryScope === scope ? 'page' : undefined}
>
{(() => {
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>
</button>
))}
</nav>
</div>
<div className="analysis-split__main">
{activeHistoryScope && grouped[activeHistoryScope]?.map(i => (
<InsightCard key={i.id} ins={i} onDelete={deleteInsight} prompts={prompts}/>
))}
</div>
</div>
)}
</div>
)}
</div>
)
}