ui-nyla/src/pages/views/workspace/NeutralizationPanel.tsx
ValueOn AG 0ad9006b94
All checks were successful
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m26s
panel fixes 3
2026-06-11 22:55:09 +02:00

477 lines
17 KiB
TypeScript

// 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<string, unknown>): 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<string, NeutralizationMapping[]>;
unscoped: NeutralizationMapping[];
} {
const byFile: Record<string, NeutralizationMapping[]> = {};
const unscoped: NeutralizationMapping[] = [];
for (const item of rows) {
if (!item || typeof item !== 'object') continue;
const raw = item as Record<string, unknown>;
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<string, string> = {
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<string, NeutralizationMapping>,
): 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(<span key={`t-${lastIdx}`}>{text.slice(lastIdx, match.index)}</span>);
}
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(
<span
key={`ph-${match.index}`}
title={mapping ? `${mapping.originalText} (${phType})` : phType}
style={{
background: color + '18',
color,
border: `1px solid ${color}40`,
borderRadius: 4,
padding: '1px 4px',
fontFamily: 'monospace',
fontSize: '0.78rem',
cursor: 'help',
whiteSpace: 'nowrap',
}}
>
{fullPh}
</span>,
);
lastIdx = match.index + match[0].length;
}
if (lastIdx < text.length) {
parts.push(<span key={`t-${lastIdx}`}>{text.slice(lastIdx)}</span>);
}
return parts;
}
const NeutralizationPanel: React.FC<NeutralizationPanelProps> = ({ instanceId }) => {
const { t } = useLanguage();
const [sources, setSources] = useState<NeutralizationSource[]>([]);
const [selectedSource, setSelectedSource] = useState<string | null>(null);
const [mappings, setMappings] = useState<NeutralizationMapping[]>([]);
const [loading, setLoading] = useState(true);
const [attributeByFile, setAttributeByFile] = useState<Record<string, NeutralizationMapping[]>>({});
const [attributeUnscoped, setAttributeUnscoped] = useState<NeutralizationMapping[]>([]);
const [snapshots, setSnapshots] = useState<NeutralizationSnapshot[]>([]);
const [expandedSnapshot, setExpandedSnapshot] = useState<string | null>(null);
const _mappingLookup = useMemo(() => {
const map = new Map<string, NeutralizationMapping>();
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<string, unknown>) => 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<string, { bg: string; text: string }> = {
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 (
<span style={{ fontSize: '0.75rem', padding: '2px 8px', borderRadius: 10, background: c.bg, color: c.text }}>
{status}
</span>
);
};
if (loading) return <div style={{ padding: 16, textAlign: 'center', color: '#6b7280' }}>{t('Neutralisierungsdaten werden geladen')}</div>;
const _hasAnyData = sources.length > 0 || snapshots.length > 0;
return (
<div style={{ padding: 16, display: 'flex', flexDirection: 'column', gap: 16 }}>
<h3 style={{ margin: 0, fontSize: '1.1rem' }}>{t('Neutralisierung')}</h3>
<p style={{ margin: 0, fontSize: '0.85rem', color: 'var(--text-secondary, #6b7280)' }}>
{t('Neutralisierte Texte mit Platzhaltern und die zugehörigen Mappings (Original &harr; Platzhalter).')}
</p>
{snapshots.length > 0 && (
<Panel
variant="card"
id="neutralization-snapshots"
title={`${t('Neutralisierter Text')} (${snapshots.length})`}
collapsible
collapseKey={`neutralization-snapshots-${instanceId}`}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{snapshots.map((snap) => {
const _isExpanded = expandedSnapshot === snap.id;
return (
<div key={snap.id} style={{ border: '1px solid var(--border-color, #e5e7eb)', borderRadius: 8, overflow: 'hidden' }}>
<div
onClick={() => 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',
}}
>
<span style={{ fontWeight: 500 }}>{snap.sourceLabel}</span>
<span style={{ fontSize: '0.75rem', color: '#9ca3af' }}>
{snap.placeholderCount} {t('Platzhalter')} {_isExpanded ? '\u25BC' : '\u25B6'}
</span>
</div>
{_isExpanded && (
<div
style={{
padding: '10px 12px',
fontSize: '0.82rem',
lineHeight: 1.6,
maxHeight: 400,
overflowY: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
background: 'var(--bg-primary, #fff)',
}}
>
{_renderHighlightedText(snap.neutralizedText, _mappingLookup)}
</div>
)}
</div>
);
})}
</div>
</Panel>
)}
{sources.length > 0 && (
<Panel
variant="card"
id="neutralization-sources"
title={t('Datenquellen')}
collapsible
collapseKey={`neutralization-sources-${instanceId}`}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{sources.map((src) => (
<div
key={src.fileId}
style={{
padding: '10px 14px',
borderRadius: 8,
border:
selectedSource === src.fileId ? '2px solid var(--accent, #4f46e5)' : '1px solid var(--border-color, #e5e7eb)',
cursor: 'pointer',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
onClick={() => setSelectedSource(src.fileId === selectedSource ? null : src.fileId)}
>
<div>
<div style={{ fontWeight: 500, fontSize: '0.9rem' }}>{src.fileName}</div>
<div style={{ fontSize: '0.8rem', color: '#6b7280', marginTop: 2 }}>
{_statusBadge(src.neutralizationStatus)}
{src.mappingCount > 0 && (
<span style={{ marginLeft: 8, color: '#9ca3af' }}>{src.mappingCount} {t('Mapping(s)')}</span>
)}
</div>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
{!src.isVirtual && (
<button
onClick={(e) => {
e.stopPropagation();
_handleRetrigger(src.fileId);
}}
style={{
fontSize: '0.8rem',
padding: '4px 10px',
borderRadius: 6,
border: '1px solid var(--border-color, #d1d5db)',
background: 'transparent',
cursor: 'pointer',
}}
>
{t('Erneut neutralisieren')}
</button>
)}
<span style={{ fontSize: '0.8rem', color: '#9ca3af' }}>{selectedSource === src.fileId ? '\u25BC' : '\u25B6'}</span>
</div>
</div>
))}
</div>
</Panel>
)}
{selectedSource && mappings.length > 0 && (
<Panel
variant="table"
id="neutralization-mappings"
title={`${t('Platzhalter-Mappings')} (${mappings.length})`}
>
<div style={{ maxHeight: 300, overflowY: 'auto' }}>
{mappings.map((m) => (
<div
key={m.id}
style={{
display: 'flex',
alignItems: 'center',
padding: '8px 16px',
borderTop: '1px solid var(--border-color, #f3f4f6)',
fontSize: '0.8rem',
gap: 12,
}}
>
<span style={{ flex: 1, fontFamily: 'monospace', color: _phTypeColors[m.patternType] || '#4f46e5' }}>{m.placeholder}</span>
<span style={{ color: '#9ca3af' }}>{'\u2192'}</span>
<span style={{ flex: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={m.originalText}>
{m.originalText}
</span>
<span style={{ fontSize: '0.7rem', color: '#9ca3af', flexShrink: 0 }}>{m.patternType}</span>
<button
onClick={() => _handleDeleteMapping(m.id)}
style={{ color: '#ef4444', border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '0.9rem', padding: '2px 6px' }}
title={t('Mapping löschen')}
>
{'\u00D7'}
</button>
</div>
))}
</div>
</Panel>
)}
{selectedSource && mappings.length === 0 && (
<div style={{ padding: 16, textAlign: 'center', color: '#9ca3af', fontSize: '0.85rem' }}>
{selectedSource === _chatPromptSourceId
? t('Keine Mappings ohne Dateizuordnung')
: t('Keine gespeicherten Mappings für diese')}
</div>
)}
{!_hasAnyData && (
<div style={{ padding: 24, textAlign: 'center', color: '#9ca3af', fontSize: '0.85rem' }}>
{t('Noch keine Neutralisierungsdaten vorhanden. Sende eine Nachricht mit aktivierter Neutralisierung.')}
</div>
)}
</div>
);
};
export default NeutralizationPanel;