streamlined neutralization flow
This commit is contained in:
parent
317e019b18
commit
9d4e5bc90d
3 changed files with 361 additions and 67 deletions
|
|
@ -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 && <div className={styles.errorMessage}>{error}</div>}
|
||||
|
||||
<section className={styles.section}>
|
||||
<h2 className={styles.sectionTitle}>Platzhalter-Mappings</h2>
|
||||
<h2 className={styles.sectionTitle}>Platzhalter-Mappings (lokal)</h2>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '1rem',
|
||||
padding: '0.75rem 1rem',
|
||||
background: 'var(--surface-color, #eff6ff)',
|
||||
border: '1px solid var(--border-color, #bfdbfe)',
|
||||
borderRadius: 8,
|
||||
fontSize: '0.85rem',
|
||||
lineHeight: 1.5,
|
||||
color: 'var(--text-primary, #1e3a5f)',
|
||||
}}
|
||||
>
|
||||
<strong>AI-Workspace:</strong> Neutralisierter Chat-Text, Dokumente und Platzhalter-Mappings finden Sie unter{' '}
|
||||
<strong>Mandant → AI-Workspace-Instanz → Einstellungen → Tab „Neutralisierung“</strong> (nicht auf dieser
|
||||
Seite). Dieser Tab zeigt nur die <strong>lokale</strong> Liste über <code>/api/local/neutralization-mappings</code>.
|
||||
</div>
|
||||
<p className={styles.settingDescription} style={{ marginBottom: '1rem' }}>
|
||||
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.
|
||||
</p>
|
||||
|
||||
{mappings.length === 0 ? (
|
||||
|
|
|
|||
|
|
@ -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<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 [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 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<string, unknown>) => 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<NeutralizationPanelProps> = ({ 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<NeutralizationPanelProps> = ({ 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<NeutralizationPanelProps> = ({ instanceId })
|
|||
|
||||
if (loading) return <div style={{ padding: 16, textAlign: 'center', color: '#6b7280' }}>Lade Neutralisierungsdaten...</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' }}>Neutralisierung</h3>
|
||||
<p style={{ margin: 0, fontSize: '0.85rem', color: 'var(--text-secondary, #6b7280)' }}>
|
||||
Übersicht aller Datenquellen mit Neutralisierung und deren Platzhalter-Mappings.
|
||||
Neutralisierte Texte mit Platzhaltern und die zugehörigen Mappings (Original ↔ Platzhalter).
|
||||
</p>
|
||||
|
||||
{sources.length === 0 ? (
|
||||
<div style={{ padding: 24, textAlign: 'center', color: '#9ca3af', fontSize: '0.85rem' }}>
|
||||
Keine Datenquellen mit aktiver Neutralisierung.
|
||||
</div>
|
||||
) : (
|
||||
{/* ── Snapshots: neutralisierter Text ──────────────────────── */}
|
||||
{snapshots.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-primary, #111827)' }}>
|
||||
Neutralisierter Text ({snapshots.length})
|
||||
</div>
|
||||
{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} 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>
|
||||
)}
|
||||
|
||||
{/* ── Quellen (Dateien + Chat/Prompt) ──────────────────────── */}
|
||||
{sources.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ fontSize: '0.85rem', fontWeight: 600, color: 'var(--text-primary, #111827)' }}>
|
||||
Datenquellen
|
||||
</div>
|
||||
{sources.map((src) => (
|
||||
<div
|
||||
key={src.fileId}
|
||||
style={{
|
||||
padding: '12px 16px', 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',
|
||||
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)}
|
||||
>
|
||||
|
|
@ -137,24 +375,38 @@ const NeutralizationPanel: React.FC<NeutralizationPanelProps> = ({ instanceId })
|
|||
<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} Mapping(s)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<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' }}
|
||||
>
|
||||
Erneut neutralisieren
|
||||
</button>
|
||||
<span style={{ fontSize: '0.8rem', color: '#9ca3af' }}>
|
||||
{selectedSource === src.fileId ? '\u25BC' : '\u25B6'}
|
||||
</span>
|
||||
{!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',
|
||||
}}
|
||||
>
|
||||
Erneut neutralisieren
|
||||
</button>
|
||||
)}
|
||||
<span style={{ fontSize: '0.8rem', color: '#9ca3af' }}>{selectedSource === src.fileId ? '\u25BC' : '\u25B6'}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Mappings für ausgewählte Quelle ──────────────────────── */}
|
||||
{selectedSource && mappings.length > 0 && (
|
||||
<div style={{ border: '1px solid var(--border-color, #e5e7eb)', borderRadius: 8, overflow: 'hidden' }}>
|
||||
<div style={{ padding: '8px 16px', background: 'var(--bg-hover, #f9fafb)', fontSize: '0.85rem', fontWeight: 500 }}>
|
||||
|
|
@ -162,10 +414,22 @@ const NeutralizationPanel: React.FC<NeutralizationPanelProps> = ({ instanceId })
|
|||
</div>
|
||||
<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: '#4f46e5' }}>{m.placeholder}</span>
|
||||
<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' }}>{m.originalText}</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)}
|
||||
|
|
@ -182,7 +446,15 @@ const NeutralizationPanel: React.FC<NeutralizationPanelProps> = ({ instanceId })
|
|||
|
||||
{selectedSource && mappings.length === 0 && (
|
||||
<div style={{ padding: 16, textAlign: 'center', color: '#9ca3af', fontSize: '0.85rem' }}>
|
||||
Keine Mappings für diese Datenquelle.
|
||||
{selectedSource === _chatPromptSourceId
|
||||
? 'Keine Mappings ohne Dateizuordnung.'
|
||||
: 'Keine gespeicherten Mappings für diese Datenquelle.'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!_hasAnyData && (
|
||||
<div style={{ padding: 24, textAlign: 'center', color: '#9ca3af', fontSize: '0.85rem' }}>
|
||||
Noch keine Neutralisierungsdaten vorhanden. Sende eine Nachricht mit aktivierter Neutralisierung.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
|||
<WorkspaceGeneralSettings instanceId={instanceId} />
|
||||
)}
|
||||
{activeTab === 'neutralization' && (
|
||||
<NeutralizationPanel instanceId={instanceId} />
|
||||
<>
|
||||
<p style={{ margin: '0 0 12px', fontSize: '0.85rem', color: 'var(--text-secondary, #6b7280)' }}>
|
||||
Hier erscheinen die zuletzt an die KI gesendeten neutralisierten Texte und Platzhalter dieser
|
||||
Workspace-Instanz. (Die Benutzer-Einstellungen unter <strong>/settings</strong> → „Neutralisierung (lokal)“
|
||||
ist eine andere Seite.)
|
||||
</p>
|
||||
<NeutralizationPanel instanceId={instanceId} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue