All checks were successful
Deploy Nyla Frontend to Integration / deploy (push) Successful in 1m26s
477 lines
17 KiB
TypeScript
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 ↔ 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;
|