diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx
index 84691a2..bfe6de9 100644
--- a/src/pages/Settings.tsx
+++ b/src/pages/Settings.tsx
@@ -23,7 +23,7 @@ const _TABS: { key: SettingsTab; label: string }[] = [
{ key: 'profile', label: 'Profil' },
{ key: 'appearance', label: 'Darstellung' },
{ key: 'voice', label: 'Stimme & Sprache' },
- { key: 'neutralization', label: 'Datenneutralisierung' },
+ { key: 'neutralization', label: 'Neutralisierung (lokal)' },
{ key: 'privacy', label: 'Datenschutz' },
];
@@ -358,12 +358,27 @@ const NeutralizationMappingsTab: React.FC = () => {
{error &&
{error}
}
- Platzhalter-Mappings
+ Platzhalter-Mappings (lokal)
+
+ AI-Workspace: Neutralisierter Chat-Text, Dokumente und Platzhalter-Mappings finden Sie unter{' '}
+ Mandant → AI-Workspace-Instanz → Einstellungen → Tab „Neutralisierung“ (nicht auf dieser
+ Seite). Dieser Tab zeigt nur die lokale Liste über /api/local/neutralization-mappings.
+
Bei der Datenneutralisierung werden personenbezogene Daten durch Platzhalter ersetzt, bevor Text an KI-Modelle
geht; die Antwort wird anschliessend wieder mit Ihren Originalbegriffen angereichert (zentrale Pipeline ueber
- den AI-Service). Diese Liste betrifft nur Ihre gespeicherten Platzhalter-Zuordnungen — hier einsehbar und
- loeschbar.
+ den AI-Service). Die Tabelle unten betrifft nur lokale Entwickler-/Test-Mappings — hier einsehbar und loeschbar.
{mappings.length === 0 ? (
diff --git a/src/pages/views/workspace/NeutralizationPanel.tsx b/src/pages/views/workspace/NeutralizationPanel.tsx
index 22a5812..c8e1179 100644
--- a/src/pages/views/workspace/NeutralizationPanel.tsx
+++ b/src/pages/views/workspace/NeutralizationPanel.tsx
@@ -1,6 +1,9 @@
-import React, { useState, useEffect, useCallback } from 'react';
+import React, { useState, useEffect, useCallback, useMemo } from 'react';
import api from '../../../api';
+const _chatPromptSourceId = '__chat_prompt__';
+const _placeholderRx = /\[([a-z]+)\.([a-f0-9-]{36})\]/g;
+
interface NeutralizationMapping {
id: string;
originalText: string;
@@ -11,38 +14,220 @@ interface NeutralizationMapping {
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 [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 response = await api.get(`/api/workspace/${instanceId}/files`);
- const raw = response.data;
- const files = Array.isArray(raw) ? raw : (raw?.files || raw?.data || []);
- const neutralized = (Array.isArray(files) ? files : [])
- .filter((f: any) => f.neutralize)
- .map((f: any) => ({
- fileId: f.id,
- fileName: f.fileName || f.name || 'unknown',
- neutralizationStatus: f.neutralizationStatus || f.status || 'unknown',
- mappingCount: 0,
- }));
- setSources(neutralized);
+ 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: '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 {
@@ -50,35 +235,28 @@ const NeutralizationPanel: React.FC = ({ instanceId })
}
}, [instanceId]);
- const _loadMappings = useCallback(async (fileId: string) => {
- try {
- const response = await api.get(`/api/neutralization/${instanceId}/attributes`, { params: { fileId } });
- const data = response.data?.data || response.data || [];
- setMappings(data.map((m: any) => ({
- id: m.id,
- originalText: m.originalText || '',
- placeholder: m.placeholder || m.id,
- patternType: m.patternType || 'unknown',
- fileId: m.fileId,
- fileName: m.fileName,
- createdAt: m.createdAt || m.sysCreatedAt,
- })));
- } catch (err) {
- console.error('Failed to load mappings:', err);
- setMappings([]);
- }
- }, [instanceId]);
-
- useEffect(() => { _loadSources(); }, [_loadSources]);
+ useEffect(() => {
+ _loadSources();
+ }, [_loadSources]);
useEffect(() => {
- if (selectedSource) _loadMappings(selectedSource);
- }, [selectedSource, _loadMappings]);
+ 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/${instanceId}/attributes/single/${mappingId}`);
- setMappings(prev => prev.filter(m => m.id !== mappingId));
+ 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);
}
@@ -86,8 +264,12 @@ const NeutralizationPanel: React.FC = ({ instanceId })
const _handleRetrigger = async (fileId: string) => {
try {
- await api.post(`/api/neutralization/${instanceId}/retrigger`, { fileId });
- _loadSources();
+ 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);
}
@@ -110,26 +292,82 @@ const NeutralizationPanel: React.FC = ({ instanceId })
if (loading) return Lade Neutralisierungsdaten...
;
+ const _hasAnyData = sources.length > 0 || snapshots.length > 0;
+
return (
Neutralisierung
- Übersicht aller Datenquellen mit Neutralisierung und deren Platzhalter-Mappings.
+ Neutralisierte Texte mit Platzhaltern und die zugehörigen Mappings (Original ↔ Platzhalter).
- {sources.length === 0 ? (
-
- Keine Datenquellen mit aktiver Neutralisierung.
-
- ) : (
+ {/* ── Snapshots: neutralisierter Text ──────────────────────── */}
+ {snapshots.length > 0 && (
+
+ Neutralisierter Text ({snapshots.length})
+
+ {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} Platzhalter {_isExpanded ? '\u25BC' : '\u25B6'}
+
+
+ {_isExpanded && (
+
+ {_renderHighlightedText(snap.neutralizedText, _mappingLookup)}
+
+ )}
+
+ );
+ })}
+
+ )}
+
+ {/* ── Quellen (Dateien + Chat/Prompt) ──────────────────────── */}
+ {sources.length > 0 && (
+
+
+ Datenquellen
+
{sources.map((src) => (
setSelectedSource(src.fileId === selectedSource ? null : src.fileId)}
>
@@ -137,24 +375,38 @@ const NeutralizationPanel: React.FC
= ({ instanceId })
{src.fileName}
{_statusBadge(src.neutralizationStatus)}
+ {src.mappingCount > 0 && (
+ {src.mappingCount} Mapping(s)
+ )}
- { 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' }}
- >
- Erneut neutralisieren
-
-
- {selectedSource === src.fileId ? '\u25BC' : '\u25B6'}
-
+ {!src.isVirtual && (
+ {
+ 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',
+ }}
+ >
+ Erneut neutralisieren
+
+ )}
+ {selectedSource === src.fileId ? '\u25BC' : '\u25B6'}
))}
)}
+ {/* ── Mappings für ausgewählte Quelle ──────────────────────── */}
{selectedSource && mappings.length > 0 && (
@@ -162,10 +414,22 @@ const NeutralizationPanel: React.FC = ({ instanceId })
{mappings.map((m) => (
-
-
{m.placeholder}
+
+
{m.placeholder}
{'\u2192'}
-
{m.originalText}
+
+ {m.originalText}
+
{m.patternType}
_handleDeleteMapping(m.id)}
@@ -182,7 +446,15 @@ const NeutralizationPanel: React.FC = ({ instanceId })
{selectedSource && mappings.length === 0 && (
- Keine Mappings für diese Datenquelle.
+ {selectedSource === _chatPromptSourceId
+ ? 'Keine Mappings ohne Dateizuordnung.'
+ : 'Keine gespeicherten Mappings für diese Datenquelle.'}
+
+ )}
+
+ {!_hasAnyData && (
+
+ Noch keine Neutralisierungsdaten vorhanden. Sende eine Nachricht mit aktivierter Neutralisierung.
)}
diff --git a/src/pages/views/workspace/WorkspaceSettingsPage.tsx b/src/pages/views/workspace/WorkspaceSettingsPage.tsx
index 8f25088..644f253 100644
--- a/src/pages/views/workspace/WorkspaceSettingsPage.tsx
+++ b/src/pages/views/workspace/WorkspaceSettingsPage.tsx
@@ -14,7 +14,7 @@ type SettingsTab = 'general' | 'neutralization';
const _TABS: { key: SettingsTab; label: string }[] = [
{ key: 'general', label: 'Generelle Einstellungen' },
- { key: 'neutralization', label: 'Neutralisierung' },
+ { key: 'neutralization', label: 'Neutralisierung (Workspace)' },
];
export const WorkspaceSettingsPage: React.FC = () => {
@@ -67,7 +67,14 @@ export const WorkspaceSettingsPage: React.FC = () => {
)}
{activeTab === 'neutralization' && (
-
+ <>
+
+ Hier erscheinen die zuletzt an die KI gesendeten neutralisierten Texte und Platzhalter dieser
+ Workspace-Instanz. (Die Benutzer-Einstellungen unter /settings → „Neutralisierung (lokal)“
+ ist eine andere Seite.)
+
+
+ >
)}