// Copyright (c) 2026 PowerOn AG // All rights reserved. import React, { useState, useEffect, useCallback, useMemo } from 'react'; import api from '../../../api'; import { useLanguage } from '../../../providers/language/LanguageContext'; import { Panel } from '../../../components/Layout/Panel'; const _chatPromptSourceId = '__chat_prompt__'; const _placeholderRx = /\[([a-z]+)\.([a-f0-9-]{36})\]/g; interface NeutralizationMapping { id: string; originalText: string; placeholder: string; patternType: string; fileId?: string; fileName?: string; createdAt?: string; } interface NeutralizationSnapshot { id: string; sourceLabel: string; neutralizedText: string; placeholderCount: number; } interface NeutralizationSource { fileId: string; fileName: string; neutralizationStatus: string; mappingCount: number; isVirtual?: boolean; } interface NeutralizationPanelProps { instanceId: string; } function _normalizeApiRow(raw: Record): NeutralizationMapping { const id = String(raw.id ?? ''); const patternType = String(raw.patternType ?? 'unknown'); const existingPh = raw.placeholder; const placeholder = typeof existingPh === 'string' && existingPh ? existingPh : id ? `[${patternType}.${id}]` : ''; return { id, originalText: String(raw.originalText ?? ''), placeholder, patternType, fileId: raw.fileId != null && raw.fileId !== '' ? String(raw.fileId) : undefined, fileName: raw.fileName != null ? String(raw.fileName) : undefined, createdAt: raw.createdAt != null ? String(raw.createdAt) : raw.sysCreatedAt != null ? String(raw.sysCreatedAt) : undefined, }; } function _partitionAttributes(rows: unknown[]): { byFile: Record; unscoped: NeutralizationMapping[]; } { const byFile: Record = {}; const unscoped: NeutralizationMapping[] = []; for (const item of rows) { if (!item || typeof item !== 'object') continue; const raw = item as Record; const m = _normalizeApiRow(raw); const fid = raw.fileId; if (fid == null || fid === '') { unscoped.push(m); } else { const key = String(fid); if (!byFile[key]) byFile[key] = []; byFile[key].push(m); } } return { byFile, unscoped }; } const _phTypeColors: Record = { name: '#7c3aed', email: '#2563eb', phone: '#0891b2', address: '#059669', financial: '#d97706', id: '#dc2626', logic: '#be185d', company: '#4f46e5', product: '#7c3aed', location: '#059669', other: '#6b7280', }; function _renderHighlightedText( text: string, mappingLookup: Map, ): React.ReactNode[] { const parts: React.ReactNode[] = []; let lastIdx = 0; const rx = new RegExp(_placeholderRx.source, 'g'); let match: RegExpExecArray | null; while ((match = rx.exec(text)) !== null) { if (match.index > lastIdx) { parts.push({text.slice(lastIdx, match.index)}); } const phType = match[1]; const phId = match[2]; const fullPh = match[0]; const mapping = mappingLookup.get(phId); const color = _phTypeColors[phType] || _phTypeColors.other; parts.push( {fullPh} , ); lastIdx = match.index + match[0].length; } if (lastIdx < text.length) { parts.push({text.slice(lastIdx)}); } return parts; } const NeutralizationPanel: React.FC = ({ instanceId }) => { const { t } = useLanguage(); const [sources, setSources] = useState([]); const [selectedSource, setSelectedSource] = useState(null); const [mappings, setMappings] = useState([]); const [loading, setLoading] = useState(true); const [attributeByFile, setAttributeByFile] = useState>({}); const [attributeUnscoped, setAttributeUnscoped] = useState([]); const [snapshots, setSnapshots] = useState([]); const [expandedSnapshot, setExpandedSnapshot] = useState(null); const _mappingLookup = useMemo(() => { const map = new Map(); for (const m of attributeUnscoped) map.set(m.id, m); for (const arr of Object.values(attributeByFile)) { for (const m of arr) map.set(m.id, m); } return map; }, [attributeUnscoped, attributeByFile]); const _loadSources = useCallback(async () => { setLoading(true); try { const headers = instanceId ? { 'X-Instance-Id': instanceId } : undefined; const [filesResponse, attrResponse] = await Promise.all([ api.get(`/api/workspace/${instanceId}/files`, { headers }), api.get('/api/neutralization/attributes', { headers }), ]); let snapAxios: { data: unknown } = { data: [] }; try { const _enc = encodeURIComponent(instanceId); snapAxios = await api.get(`/api/neutralization/${_enc}/snapshots`, { headers }); } catch (_snapErr) { console.warn('NeutralizationPanel: scoped snapshots failed, trying unscoped:', _snapErr); try { snapAxios = await api.get('/api/neutralization/snapshots', { headers }); } catch (_snapErr2) { console.error('NeutralizationPanel: snapshots could not be loaded:', _snapErr2); snapAxios = { data: [] }; } } const rawFiles = filesResponse.data; const files = Array.isArray(rawFiles) ? rawFiles : (rawFiles?.files || rawFiles?.data || []); const fileList = Array.isArray(files) ? files : []; const attrPayload = attrResponse.data?.data ?? attrResponse.data ?? []; const attrRows = Array.isArray(attrPayload) ? attrPayload : []; const { byFile, unscoped } = _partitionAttributes(attrRows); setAttributeByFile(byFile); setAttributeUnscoped(unscoped); const _snapBody = snapAxios.data as { data?: unknown } | unknown[] | null | undefined; const snapPayload = Array.isArray(_snapBody) ? _snapBody : (_snapBody && typeof _snapBody === 'object' && 'data' in _snapBody ? ( _snapBody as { data: unknown }).data : _snapBody) ?? []; const snapList: NeutralizationSnapshot[] = Array.isArray(snapPayload) ? (snapPayload as NeutralizationSnapshot[]) : []; setSnapshots(snapList); if (snapList.length > 0 && snapList[0].id) { setExpandedSnapshot(snapList[0].id); } else { setExpandedSnapshot(null); } const neutralizedFiles = fileList.filter((f: Record) => f.neutralize); const nextSources: NeutralizationSource[] = []; if (unscoped.length > 0) { nextSources.push({ fileId: _chatPromptSourceId, fileName: t('Chat, Prompt & Kontext'), neutralizationStatus: 'completed', mappingCount: unscoped.length, isVirtual: true, }); } for (const f of neutralizedFiles) { const fid = String(f.id ?? ''); if (!fid) continue; nextSources.push({ fileId: fid, fileName: String(f.fileName ?? f.name ?? 'unknown'), neutralizationStatus: String(f.neutralizationStatus ?? f.status ?? 'unknown'), mappingCount: byFile[fid]?.length ?? 0, }); } setSources(nextSources); } catch (err) { console.error('Failed to load neutralization sources:', err); } finally { setLoading(false); } }, [instanceId, t]); useEffect(() => { _loadSources(); }, [_loadSources]); useEffect(() => { if (!selectedSource) { setMappings([]); return; } if (selectedSource === _chatPromptSourceId) { setMappings(attributeUnscoped); return; } setMappings(attributeByFile[selectedSource] ?? []); }, [selectedSource, attributeByFile, attributeUnscoped]); const _handleDeleteMapping = async (mappingId: string) => { try { await api.delete(`/api/neutralization/attributes/single/${mappingId}`, { headers: instanceId ? { 'X-Instance-Id': instanceId } : undefined, }); await _loadSources(); } catch (err) { console.error('Failed to delete mapping:', err); } }; const _handleRetrigger = async (fileId: string) => { try { await api.post( '/api/neutralization/retrigger', { fileId }, { headers: instanceId ? { 'X-Instance-Id': instanceId } : undefined }, ); await _loadSources(); } catch (err) { console.error('Failed to retrigger neutralization:', err); } }; const _statusBadge = (status: string) => { const colors: Record = { completed: { bg: '#dcfce7', text: '#166534' }, pending: { bg: '#fef3c7', text: '#92400e' }, failed: { bg: '#fef2f2', text: '#991b1b' }, not_required: { bg: '#f3f4f6', text: '#6b7280' }, }; const c = colors[status] || colors.not_required; return ( {status} ); }; if (loading) return
{t('Neutralisierungsdaten werden geladen')}
; const _hasAnyData = sources.length > 0 || snapshots.length > 0; return (

{t('Neutralisierung')}

{t('Neutralisierte Texte mit Platzhaltern und die zugehörigen Mappings (Original ↔ Platzhalter).')}

{snapshots.length > 0 && (
{snapshots.map((snap) => { const _isExpanded = expandedSnapshot === snap.id; return (
setExpandedSnapshot(_isExpanded ? null : snap.id)} style={{ padding: '8px 12px', background: 'var(--bg-hover, #f9fafb)', cursor: 'pointer', display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '0.85rem', }} > {snap.sourceLabel} {snap.placeholderCount} {t('Platzhalter')} {_isExpanded ? '\u25BC' : '\u25B6'}
{_isExpanded && (
{_renderHighlightedText(snap.neutralizedText, _mappingLookup)}
)}
); })}
)} {sources.length > 0 && (
{sources.map((src) => (
setSelectedSource(src.fileId === selectedSource ? null : src.fileId)} >
{src.fileName}
{_statusBadge(src.neutralizationStatus)} {src.mappingCount > 0 && ( {src.mappingCount} {t('Mapping(s)')} )}
{!src.isVirtual && ( )} {selectedSource === src.fileId ? '\u25BC' : '\u25B6'}
))}
)} {selectedSource && mappings.length > 0 && (
{mappings.map((m) => (
{m.placeholder} {'\u2192'} {m.originalText} {m.patternType}
))}
)} {selectedSource && mappings.length === 0 && (
{selectedSource === _chatPromptSourceId ? t('Keine Mappings ohne Dateizuordnung') : t('Keine gespeicherten Mappings für diese')}
)} {!_hasAnyData && (
{t('Noch keine Neutralisierungsdaten vorhanden. Sende eine Nachricht mit aktivierter Neutralisierung.')}
)}
); }; export default NeutralizationPanel;