- 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.
724 lines
31 KiB
JavaScript
724 lines
31 KiB
JavaScript
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>
|
||
)
|
||
}
|