fixed tools

This commit is contained in:
ValueOn AG 2026-04-14 16:15:33 +02:00
parent af6feec4ca
commit 851b509f9e
5 changed files with 904 additions and 160 deletions

175
src/hooks/useAudioQueue.ts Normal file
View file

@ -0,0 +1,175 @@
/**
* useAudioQueue Playlist-style audio playback queue.
*
* When multiple audio clips arrive (e.g. TTS chunks from the agent), they are
* queued and played sequentially. The next clip only starts once the current
* one finishes (or is skipped). Individual clips can still be paused/resumed.
*/
import { useCallback, useRef, useState } from 'react';
export interface AudioQueueItem {
id: string;
url: string;
language?: string;
charCount?: number;
}
export interface AudioQueueState {
currentId: string | null;
isPlaying: boolean;
isPaused: boolean;
queueLength: number;
}
export interface AudioQueueApi {
state: AudioQueueState;
enqueue: (item: AudioQueueItem) => void;
pause: () => void;
resume: () => void;
skip: () => void;
stopAll: () => void;
isItemActive: (id: string) => boolean;
isItemQueued: (id: string) => boolean;
getProgress: () => number;
getDuration: () => number;
}
export function useAudioQueue(): AudioQueueApi {
const queueRef = useRef<AudioQueueItem[]>([]);
const audioRef = useRef<HTMLAudioElement | null>(null);
const currentIdRef = useRef<string | null>(null);
const playingRef = useRef(false);
const [state, setState] = useState<AudioQueueState>({
currentId: null,
isPlaying: false,
isPaused: false,
queueLength: 0,
});
const _updateState = useCallback((patch: Partial<AudioQueueState>) => {
setState(prev => ({ ...prev, ...patch }));
}, []);
const _playNext = useCallback(() => {
if (playingRef.current) return;
const next = queueRef.current.shift();
if (!next) {
currentIdRef.current = null;
_updateState({ currentId: null, isPlaying: false, isPaused: false, queueLength: 0 });
return;
}
playingRef.current = true;
currentIdRef.current = next.id;
_updateState({
currentId: next.id,
isPlaying: true,
isPaused: false,
queueLength: queueRef.current.length,
});
const audio = new Audio(next.url);
audioRef.current = audio;
audio.addEventListener('ended', () => {
audioRef.current = null;
playingRef.current = false;
currentIdRef.current = null;
_updateState({
currentId: null,
isPlaying: false,
isPaused: false,
queueLength: queueRef.current.length,
});
_playNext();
});
audio.addEventListener('error', () => {
audioRef.current = null;
playingRef.current = false;
currentIdRef.current = null;
_playNext();
});
audio.play().catch(() => {
audioRef.current = null;
playingRef.current = false;
currentIdRef.current = null;
_playNext();
});
}, [_updateState]);
const enqueue = useCallback((item: AudioQueueItem) => {
queueRef.current.push(item);
_updateState({ queueLength: queueRef.current.length });
if (!playingRef.current) {
_playNext();
}
}, [_playNext, _updateState]);
const pause = useCallback(() => {
if (audioRef.current && !audioRef.current.paused) {
audioRef.current.pause();
_updateState({ isPaused: true });
}
}, [_updateState]);
const resume = useCallback(() => {
if (audioRef.current && audioRef.current.paused) {
audioRef.current.play().catch(() => {});
_updateState({ isPaused: false });
}
}, [_updateState]);
const skip = useCallback(() => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.removeAttribute('src');
audioRef.current = null;
}
playingRef.current = false;
currentIdRef.current = null;
_playNext();
}, [_playNext]);
const stopAll = useCallback(() => {
queueRef.current = [];
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.removeAttribute('src');
audioRef.current = null;
}
playingRef.current = false;
currentIdRef.current = null;
_updateState({ currentId: null, isPlaying: false, isPaused: false, queueLength: 0 });
}, [_updateState]);
const isItemActive = useCallback((id: string) => currentIdRef.current === id, []);
const isItemQueued = useCallback((id: string) => queueRef.current.some(q => q.id === id), []);
const getProgress = useCallback(() => {
const a = audioRef.current;
if (!a || !a.duration) return 0;
return a.currentTime / a.duration;
}, []);
const getDuration = useCallback(() => {
return audioRef.current?.duration ?? 0;
}, []);
return {
state,
enqueue,
pause,
resume,
skip,
stopAll,
isItemActive,
isItemQueued,
getProgress,
getDuration,
};
}

View file

@ -3,7 +3,11 @@
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
max-width: 1400px; max-width: 1400px;
padding: 0 0.5rem; padding: 1rem 0.5rem;
box-sizing: border-box;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
} }
.pageTitle { .pageTitle {
@ -11,6 +15,7 @@
font-weight: 700; font-weight: 700;
margin: 0; margin: 0;
color: var(--text-primary, #1a1a1a); color: var(--text-primary, #1a1a1a);
flex-shrink: 0;
} }
.pageDesc { .pageDesc {
@ -18,6 +23,7 @@
color: var(--text-secondary, #666); color: var(--text-secondary, #666);
margin: 0; margin: 0;
line-height: 1.4; line-height: 1.4;
flex-shrink: 0;
} }
/* Mandate selector */ /* Mandate selector */
@ -25,6 +31,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.6rem; gap: 0.6rem;
flex-shrink: 0;
} }
.mandateLabel { .mandateLabel {
@ -49,6 +56,7 @@
display: flex; display: flex;
gap: 0; gap: 0;
border-bottom: 2px solid var(--border-color, #e0e0e0); border-bottom: 2px solid var(--border-color, #e0e0e0);
flex-shrink: 0;
} }
.tab { .tab {
@ -73,10 +81,21 @@
border-bottom-color: var(--accent-color, #1976d2); border-bottom-color: var(--accent-color, #1976d2);
} }
/* Content area */ /* Content area — bounded height so FormGeneratorTable fills available space */
.tabContent { .tabContent {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.tabContentScrollable {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow-y: auto;
gap: 1rem; gap: 1rem;
} }
@ -186,3 +205,139 @@
margin: 0 0 0.6rem; margin: 0 0 0.6rem;
color: var(--text-primary, #1a1a1a); color: var(--text-primary, #1a1a1a);
} }
/* ── Content View Modal ── */
.modalOverlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.modalContainer {
background: var(--bg-primary, #fff);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
width: 100%;
max-width: 900px;
max-height: 85vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.modalHeader {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 1.2rem;
border-bottom: 1px solid var(--border-color, #e0e0e0);
flex-shrink: 0;
position: relative;
}
.modalTitle {
font-size: 1rem;
font-weight: 600;
margin: 0;
color: var(--text-primary, #1a1a1a);
}
.modalMeta {
font-size: 0.78rem;
color: var(--text-secondary, #888);
flex: 1;
}
.modalClose {
position: absolute;
right: 1rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
color: var(--text-secondary, #888);
font-size: 1rem;
padding: 4px;
line-height: 1;
border-radius: 4px;
transition: color 0.15s, background 0.15s;
}
.modalClose:hover {
color: var(--text-primary, #1a1a1a);
background: var(--bg-hover, #f3f4f6);
}
.modalMappingBar {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.5rem 1.2rem;
background: #f5f3ff;
border-bottom: 1px solid #e9e5ff;
flex-shrink: 0;
font-size: 0.8rem;
}
.modalMappingLabel {
font-weight: 600;
color: #7c3aed;
}
.modalMappingHint {
color: #9ca3af;
font-style: italic;
}
.modalTabBar {
display: flex;
gap: 0;
border-bottom: 2px solid var(--border-color, #e0e0e0);
padding: 0 1.2rem;
flex-shrink: 0;
}
.modalTab {
padding: 0.5rem 1rem;
border: none;
background: none;
font-size: 0.85rem;
font-weight: 500;
color: var(--text-secondary, #666);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: color 0.15s, border-color 0.15s;
}
.modalTab:hover {
color: var(--text-primary, #1a1a1a);
}
.modalTabActive {
color: var(--accent-color, #1976d2);
border-bottom-color: var(--accent-color, #1976d2);
}
.modalBody {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 1rem 1.2rem;
}
.modalTextContent {
font-size: 0.82rem;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
color: var(--text-primary, #333);
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
}

View file

@ -1,9 +1,10 @@
/** /**
* ComplianceAuditPage Compliance & AI-Audit dashboard. * ComplianceAuditPage Compliance & AI-Audit dashboard.
* *
* Tab A: AI Data-Flow Log FormGeneratorTable + content download * Tab A: AI Data-Flow Log FormGeneratorTable + content view modal + download
* Tab B: Security / GDPR Audit Log FormGeneratorTable * Tab B: Security / GDPR Audit Log FormGeneratorTable
* Tab C: Aggregated AI-Audit Statistics with charts * Tab C: Aggregated AI-Audit Statistics with charts
* Tab D: Neutralization Mappings FormGeneratorTable + delete
*/ */
import React, { useState, useCallback, useEffect, useMemo } from 'react'; import React, { useState, useCallback, useEffect, useMemo } from 'react';
@ -11,10 +12,11 @@ import {
ResponsiveContainer, LineChart, Line, XAxis, YAxis, CartesianGrid, ResponsiveContainer, LineChart, Line, XAxis, YAxis, CartesianGrid,
Tooltip, BarChart, Bar, PieChart, Pie, Cell, Tooltip, BarChart, Bar, PieChart, Pie, Cell,
} from 'recharts'; } from 'recharts';
import { FaDownload } from 'react-icons/fa'; import { FaDownload, FaEye, FaTrash, FaTimes } from 'react-icons/fa';
import api from '../api'; import api from '../api';
import { useLanguage } from '../providers/language/LanguageContext'; import { useLanguage } from '../providers/language/LanguageContext';
import { useUserMandates } from '../hooks/useUserMandates'; import { useUserMandates } from '../hooks/useUserMandates';
import { useConfirm } from '../hooks/useConfirm';
import { FormGeneratorTable, ColumnConfig } from '../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorTable, ColumnConfig } from '../components/FormGenerator/FormGeneratorTable';
import styles from './ComplianceAuditPage.module.css'; import styles from './ComplianceAuditPage.module.css';
@ -25,17 +27,76 @@ const _CATEGORY_COLORS: Record<string, string> = {
access: '#1565c0', key: '#2e7d32', data: '#00897b', access: '#1565c0', key: '#2e7d32', data: '#00897b',
}; };
type TabId = 'ai-log' | 'audit-log' | 'stats'; type TabId = 'ai-log' | 'audit-log' | 'stats' | 'neutralization';
function _tabLabel(tabId: TabId, t: (k: string) => string): string { function _tabLabel(tabId: TabId, t: (k: string) => string): string {
switch (tabId) { switch (tabId) {
case 'ai-log': return t('AI-Datenfluss'); case 'ai-log': return t('AI-Datenfluss');
case 'audit-log': return t('Audit-Log'); case 'audit-log': return t('Audit-Log');
case 'stats': return t('Statistiken'); case 'stats': return t('Statistiken');
case 'neutralization': return t('Neutralisierung');
default: return tabId; default: return tabId;
} }
} }
// ─── Placeholder highlighting ───
const _PLACEHOLDER_RX = /\[([a-z]+)\.([a-f0-9-]{36})\]/g;
const _PH_TYPE_COLORS: 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',
};
interface NeutMapping { id: string; originalText: string; patternType: string; }
function _renderHighlightedText(
text: string,
lookup: Map<string, NeutMapping>,
): React.ReactNode[] {
const parts: React.ReactNode[] = [];
let lastIdx = 0;
const rx = new RegExp(_PLACEHOLDER_RX.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 = lookup.get(phId);
const color = _PH_TYPE_COLORS[phType] || _PH_TYPE_COLORS.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;
}
// ─── Shared types ─── // ─── Shared types ───
interface AuditStats { interface AuditStats {
@ -51,12 +112,23 @@ interface AuditStats {
interface Mandate { id: string; name?: string; label?: string; } interface Mandate { id: string; name?: string; label?: string; }
interface ContentModalData {
row: any;
contentInputFull?: string;
contentOutputFull?: string;
contentInputPreview?: string;
contentOutputPreview?: string;
neutralizationMappings: NeutMapping[];
}
const _AI_LOG_PAGE_SIZE = 50; const _AI_LOG_PAGE_SIZE = 50;
const _AUDIT_LOG_PAGE_SIZE = 100; const _AUDIT_LOG_PAGE_SIZE = 100;
const _NEUT_PAGE_SIZE = 100;
export const ComplianceAuditPage: React.FC = () => { export const ComplianceAuditPage: React.FC = () => {
const { t } = useLanguage(); const { t } = useLanguage();
const { fetchMandates } = useUserMandates(); const { fetchMandates } = useUserMandates();
const { confirm, ConfirmDialog } = useConfirm();
const [mandates, setMandates] = useState<Mandate[]>([]); const [mandates, setMandates] = useState<Mandate[]>([]);
const [mandatesLoading, setMandatesLoading] = useState(true); const [mandatesLoading, setMandatesLoading] = useState(true);
@ -78,6 +150,16 @@ export const ComplianceAuditPage: React.FC = () => {
const [statsLoading, setStatsLoading] = useState(false); const [statsLoading, setStatsLoading] = useState(false);
const [statsRange, setStatsRange] = useState(30); const [statsRange, setStatsRange] = useState(30);
// ── Tab D: Neutralization Mappings state ──
const [neutEntries, setNeutEntries] = useState<any[]>([]);
const [neutPagination, setNeutPagination] = useState<any>(undefined);
const [neutLoading, setNeutLoading] = useState(false);
// ── Content View Modal state ──
const [contentModal, setContentModal] = useState<ContentModalData | null>(null);
const [contentModalLoading, setContentModalLoading] = useState(false);
const [contentModalTab, setContentModalTab] = useState<'input' | 'output'>('input');
// ── Mandate loader ── // ── Mandate loader ──
useEffect(() => { useEffect(() => {
@ -101,7 +183,7 @@ export const ComplianceAuditPage: React.FC = () => {
return selectedMandateId ? { 'X-Mandate-Id': selectedMandateId } : {}; return selectedMandateId ? { 'X-Mandate-Id': selectedMandateId } : {};
} }
// ── Tab A loader (FormGeneratorTable refetch pattern) ── // ── Tab A loader ──
const _loadAiLog = useCallback(async (paginationParams?: any) => { const _loadAiLog = useCallback(async (paginationParams?: any) => {
if (!selectedMandateId) return; if (!selectedMandateId) return;
@ -170,6 +252,69 @@ export const ComplianceAuditPage: React.FC = () => {
finally { setStatsLoading(false); } finally { setStatsLoading(false); }
}, [selectedMandateId]); // eslint-disable-line react-hooks/exhaustive-deps }, [selectedMandateId]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Tab D loader ──
const _loadNeutMappings = useCallback(async (paginationParams?: any) => {
if (!selectedMandateId) return;
setNeutLoading(true);
try {
const page = paginationParams?.page ?? 1;
const pageSize = paginationParams?.pageSize ?? _NEUT_PAGE_SIZE;
const offset = (page - 1) * pageSize;
const { data } = await api.get('/api/audit/neutralization-mappings', {
params: { limit: pageSize, offset },
headers: _mandateHeaders(),
});
const items: any[] = data?.items ?? [];
const totalItems = data?.totalItems ?? 0;
setNeutEntries(items);
setNeutPagination({
currentPage: page,
pageSize,
totalItems,
totalPages: Math.max(1, Math.ceil(totalItems / pageSize)),
});
} catch { /* */ }
finally { setNeutLoading(false); }
}, [selectedMandateId]); // eslint-disable-line react-hooks/exhaustive-deps
const _handleDeleteMapping = useCallback(async (row: any) => {
if (!selectedMandateId || !row?.id) return;
const ok = await confirm(
t('Soll diese Zuordnung wirklich gelöscht werden?'),
{ confirmLabel: t('Löschen'), variant: 'danger' },
);
if (!ok) return;
try {
await api.delete(`/api/audit/neutralization-mappings/${row.id}`, {
headers: _mandateHeaders(),
});
void _loadNeutMappings();
} catch (err) {
console.error('Delete mapping failed:', err);
}
}, [selectedMandateId, _loadNeutMappings, confirm, t]); // eslint-disable-line react-hooks/exhaustive-deps
const _handleDeleteMappingsBatch = useCallback(async (rows: any[]) => {
if (!selectedMandateId || rows.length === 0) return;
const ok = await confirm(
t('{n} Zuordnungen wirklich löschen?', { n: String(rows.length) }),
{ confirmLabel: t('Alle löschen'), variant: 'danger' },
);
if (!ok) return;
try {
await Promise.all(
rows.map(row => api.delete(`/api/audit/neutralization-mappings/${row.id}`, {
headers: _mandateHeaders(),
}))
);
void _loadNeutMappings();
} catch (err) {
console.error('Batch delete failed:', err);
}
}, [selectedMandateId, _loadNeutMappings, confirm, t]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Auto-load on tab / mandate change ── // ── Auto-load on tab / mandate change ──
useEffect(() => { useEffect(() => {
@ -177,9 +322,36 @@ export const ComplianceAuditPage: React.FC = () => {
if (activeTab === 'ai-log') void _loadAiLog(); if (activeTab === 'ai-log') void _loadAiLog();
else if (activeTab === 'audit-log') void _loadAuditLog(); else if (activeTab === 'audit-log') void _loadAuditLog();
else if (activeTab === 'stats') void _loadStats(statsRange); else if (activeTab === 'stats') void _loadStats(statsRange);
else if (activeTab === 'neutralization') void _loadNeutMappings();
}, [activeTab, selectedMandateId]); // eslint-disable-line react-hooks/exhaustive-deps }, [activeTab, selectedMandateId]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Content download handler (Tab A — Akzeptanzkriterium #3) ── // ── Content view handler (modal) ──
const _handleContentView = useCallback(async (row: any) => {
if (!selectedMandateId || !row?.id) return;
setContentModalLoading(true);
setContentModalTab('input');
setContentModal({ row, neutralizationMappings: [] });
try {
const { data } = await api.get(`/api/audit/ai-log/${row.id}/content`, {
headers: _mandateHeaders(),
});
setContentModal({
row,
contentInputFull: data?.contentInputFull,
contentOutputFull: data?.contentOutputFull,
contentInputPreview: data?.contentInputPreview,
contentOutputPreview: data?.contentOutputPreview,
neutralizationMappings: data?.neutralizationMappings ?? [],
});
} catch (err) {
console.error('Content load failed:', err);
} finally {
setContentModalLoading(false);
}
}, [selectedMandateId]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Content download handler ──
const _handleContentDownload = useCallback(async (row: any) => { const _handleContentDownload = useCallback(async (row: any) => {
if (!selectedMandateId || !row?.id) return; if (!selectedMandateId || !row?.id) return;
@ -187,13 +359,21 @@ export const ComplianceAuditPage: React.FC = () => {
const { data } = await api.get(`/api/audit/ai-log/${row.id}/content`, { const { data } = await api.get(`/api/audit/ai-log/${row.id}/content`, {
headers: _mandateHeaders(), headers: _mandateHeaders(),
}); });
const ts = row.timestamp ? new Date(row.timestamp * 1000).toISOString() : '';
const text = [ const text = [
`=== AI-Audit-Eintrag: ${row.id} ===`, `=== AI-Audit-Eintrag: ${row.id} ===`,
`Zeitpunkt: ${ts}`,
`Benutzer: ${row.username || row.userId || ''}`,
`Provider: ${row.aiProvider || ''}`,
`Modell: ${row.aiModel || ''}`,
`Typ: ${row.operationType || ''}`,
`Neutralisierung: ${row.neutralizationActive ? 'aktiv' : 'inaktiv'}`,
`Status: ${row.success ? 'OK' : 'Fehler'}`,
'', '',
'--- Input ---', '=== Input (was an AI gesendet wurde) ===',
data?.contentInputFull || data?.contentInputPreview || '(kein Input gespeichert)', data?.contentInputFull || data?.contentInputPreview || '(kein Input gespeichert)',
'', '',
'--- Output ---', '=== Output (AI-Antwort) ===',
data?.contentOutputFull || data?.contentOutputPreview || '(kein Output gespeichert)', data?.contentOutputFull || data?.contentOutputPreview || '(kein Output gespeichert)',
].join('\n'); ].join('\n');
@ -209,6 +389,18 @@ export const ComplianceAuditPage: React.FC = () => {
} }
}, [selectedMandateId]); // eslint-disable-line react-hooks/exhaustive-deps }, [selectedMandateId]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Mapping lookup for modal ──
const _modalMappingLookup = useMemo(() => {
const map = new Map<string, NeutMapping>();
if (contentModal?.neutralizationMappings) {
for (const m of contentModal.neutralizationMappings) {
map.set(m.id, m);
}
}
return map;
}, [contentModal?.neutralizationMappings]);
// ── Column definitions ── // ── Column definitions ──
const aiLogColumns: ColumnConfig[] = useMemo(() => [ const aiLogColumns: ColumnConfig[] = useMemo(() => [
@ -222,14 +414,13 @@ export const ComplianceAuditPage: React.FC = () => {
formatter: (val: any, row: any) => row?.instanceLabel || val || '', formatter: (val: any, row: any) => row?.instanceLabel || val || '',
}, },
{ key: 'aiModel', label: t('AI-Modell'), type: 'text' as any, sortable: true, filterable: true, width: 160 }, { key: 'aiModel', label: t('AI-Modell'), type: 'text' as any, sortable: true, filterable: true, width: 160 },
{ key: 'operationType', label: t('Typ'), type: 'text' as any, sortable: true, filterable: true, width: 90 },
{ {
key: 'tokensInput', label: t('Tokens (Input)'), type: 'number' as any, sortable: true, width: 110, key: 'aiProvider', label: t('Provider / Typ'), type: 'text' as any, sortable: true, filterable: true, width: 140,
formatter: (val: any) => val != null ? `${val}` : '', formatter: (val: any, row: any) => {
}, const provider = val || '';
{ const op = row?.operationType;
key: 'tokensOutput', label: t('Tokens (Output)'), type: 'number' as any, sortable: true, width: 110, return op ? `${provider} · ${op}` : provider;
formatter: (val: any) => val != null ? `${val}` : '', },
}, },
{ {
key: 'priceCHF', label: t('Kosten (CHF)'), type: 'number' as any, sortable: true, width: 110, key: 'priceCHF', label: t('Kosten (CHF)'), type: 'number' as any, sortable: true, width: 110,
@ -271,6 +462,24 @@ export const ComplianceAuditPage: React.FC = () => {
{ key: 'ipAddress', label: t('IP'), type: 'text' as any, width: 120 }, { key: 'ipAddress', label: t('IP'), type: 'text' as any, width: 120 },
], [t]); ], [t]);
const neutColumns: ColumnConfig[] = useMemo(() => [
{ key: 'placeholder', label: t('Platzhalter'), type: 'text' as any, sortable: true, searchable: true, width: 220 },
{ key: 'originalText', label: t('Originaltext'), type: 'text' as any, sortable: true, searchable: true, width: 240 },
{ key: 'patternType', label: t('Kategorie'), type: 'text' as any, sortable: true, filterable: true, width: 120 },
{
key: 'userId', label: t('Benutzer'), type: 'text' as any, sortable: true, filterable: true, width: 140,
formatter: (val: any) => val ? String(val).slice(0, 8) + '…' : '',
},
{
key: 'featureInstanceId', label: t('Feature-Instanz'), type: 'text' as any, sortable: true, filterable: true, width: 160,
formatter: (val: any) => val || '',
},
{
key: 'fileId', label: t('Datei'), type: 'text' as any, sortable: true, width: 140,
formatter: (val: any) => val ? String(val).slice(0, 8) + '…' : '',
},
], [t]);
// ── hookData for FormGeneratorTable ── // ── hookData for FormGeneratorTable ──
const aiLogHookData = useMemo(() => ({ const aiLogHookData = useMemo(() => ({
@ -283,9 +492,14 @@ export const ComplianceAuditPage: React.FC = () => {
pagination: auditPagination, pagination: auditPagination,
}), [_loadAuditLog, auditPagination]); }), [_loadAuditLog, auditPagination]);
const neutHookData = useMemo(() => ({
refetch: _loadNeutMappings,
pagination: neutPagination,
}), [_loadNeutMappings, neutPagination]);
// ── Render ── // ── Render ──
const _tabs: TabId[] = ['ai-log', 'audit-log', 'stats']; const _tabs: TabId[] = ['ai-log', 'audit-log', 'stats', 'neutralization'];
return ( return (
<div className={styles.wrap}> <div className={styles.wrap}>
@ -329,7 +543,7 @@ export const ComplianceAuditPage: React.FC = () => {
{/* ── Tab A: AI Data-Flow Log ── */} {/* ── Tab A: AI Data-Flow Log ── */}
{activeTab === 'ai-log' && ( {activeTab === 'ai-log' && (
<div className={styles.tabContent} style={{ minHeight: 400 }}> <div className={styles.tabContent}>
<FormGeneratorTable <FormGeneratorTable
key={`ai-log-${selectedMandateId}`} key={`ai-log-${selectedMandateId}`}
data={aiEntries} data={aiEntries}
@ -345,6 +559,12 @@ export const ComplianceAuditPage: React.FC = () => {
onRefresh={_loadAiLog} onRefresh={_loadAiLog}
hookData={aiLogHookData} hookData={aiLogHookData}
customActions={[ customActions={[
{
id: 'viewContent',
title: t('Input/Output anzeigen'),
icon: <FaEye />,
onClick: _handleContentView,
},
{ {
id: 'downloadContent', id: 'downloadContent',
title: t('Input/Output herunterladen'), title: t('Input/Output herunterladen'),
@ -358,7 +578,7 @@ export const ComplianceAuditPage: React.FC = () => {
{/* ── Tab B: Audit Log ── */} {/* ── Tab B: Audit Log ── */}
{activeTab === 'audit-log' && ( {activeTab === 'audit-log' && (
<div className={styles.tabContent} style={{ minHeight: 400 }}> <div className={styles.tabContent}>
<FormGeneratorTable <FormGeneratorTable
key={`audit-log-${selectedMandateId}`} key={`audit-log-${selectedMandateId}`}
data={auditEntries} data={auditEntries}
@ -379,7 +599,7 @@ export const ComplianceAuditPage: React.FC = () => {
{/* ── Tab C: Statistics ── */} {/* ── Tab C: Statistics ── */}
{activeTab === 'stats' && ( {activeTab === 'stats' && (
<div className={styles.tabContent}> <div className={styles.tabContentScrollable}>
<div className={styles.statsControls}> <div className={styles.statsControls}>
{[7, 30, 90].map(d => ( {[7, 30, 90].map(d => (
<button <button
@ -520,8 +740,126 @@ export const ComplianceAuditPage: React.FC = () => {
)} )}
</div> </div>
)} )}
{/* ── Tab D: Neutralization Mappings ── */}
{activeTab === 'neutralization' && (
<div className={styles.tabContent}>
<FormGeneratorTable
key={`neut-${selectedMandateId}`}
data={neutEntries}
columns={neutColumns}
loading={neutLoading}
pagination={true}
pageSize={_NEUT_PAGE_SIZE}
sortable={true}
filterable={true}
searchable={true}
selectable={true}
emptyMessage={t('Keine Neutralisierungs-Zuordnungen vorhanden.')}
onRefresh={_loadNeutMappings}
hookData={neutHookData}
batchActions={[
{
label: t('Ausgewählte löschen'),
onClick: _handleDeleteMappingsBatch,
},
]}
customActions={[
{
id: 'deleteMapping',
title: t('Zuordnung löschen'),
icon: <FaTrash />,
onClick: _handleDeleteMapping,
},
]}
/>
</div>
)}
</> </>
)} )}
{/* ── Content View Modal ── */}
{contentModal && (
<div className={styles.modalOverlay} onClick={() => setContentModal(null)}>
<div className={styles.modalContainer} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h3 className={styles.modalTitle}>{t('AI-Audit Inhalt')}</h3>
<div className={styles.modalMeta}>
{contentModal.row?.username || contentModal.row?.userId?.slice(0, 8) || ''}
{' · '}
{contentModal.row?.aiModel || ''}
{' · '}
{contentModal.row?.timestamp
? new Date(contentModal.row.timestamp * 1000).toLocaleString()
: ''}
</div>
<button
className={styles.modalClose}
onClick={() => setContentModal(null)}
title={t('Schliessen')}
>
<FaTimes />
</button>
</div>
{contentModal.neutralizationMappings.length > 0 && (
<div className={styles.modalMappingBar}>
<span className={styles.modalMappingLabel}>
{t('{n} Platzhalter aufgelöst', { n: String(contentModal.neutralizationMappings.length) })}
</span>
<span className={styles.modalMappingHint}>
{t('Hover über markierte Platzhalter für Originaltext')}
</span>
</div>
)}
<div className={styles.modalTabBar}>
<button
className={`${styles.modalTab} ${contentModalTab === 'input' ? styles.modalTabActive : ''}`}
onClick={() => setContentModalTab('input')}
>
{t('Input')}
</button>
<button
className={`${styles.modalTab} ${contentModalTab === 'output' ? styles.modalTabActive : ''}`}
onClick={() => setContentModalTab('output')}
>
{t('Output')}
</button>
</div>
<div className={styles.modalBody}>
{contentModalLoading ? (
<p className={styles.loadingText}>{t('Lade Inhalt…')}</p>
) : (
<div className={styles.modalTextContent}>
{contentModalTab === 'input' ? (
(() => {
const text = contentModal.contentInputFull
|| contentModal.contentInputPreview
|| t('(kein Input gespeichert)');
return _modalMappingLookup.size > 0
? _renderHighlightedText(text, _modalMappingLookup)
: text;
})()
) : (
(() => {
const text = contentModal.contentOutputFull
|| contentModal.contentOutputPreview
|| t('(kein Output gespeichert)');
return _modalMappingLookup.size > 0
? _renderHighlightedText(text, _modalMappingLookup)
: text;
})()
)}
</div>
)}
</div>
</div>
</div>
)}
<ConfirmDialog />
</div> </div>
); );
}; };

View file

@ -3,6 +3,9 @@
* *
* Renders messages with full Markdown (GFM tables, code blocks with syntax * Renders messages with full Markdown (GFM tables, code blocks with syntax
* highlighting), agent progress indicators, and file edit proposals. * highlighting), agent progress indicators, and file edit proposals.
*
* Audio playback uses a playlist queue: when the agent sends multiple TTS
* chunks they are queued and played one after the other instead of overlapping.
*/ */
import React, { useRef, useEffect, useCallback, useState } from 'react'; import React, { useRef, useEffect, useCallback, useState } from 'react';
@ -12,6 +15,7 @@ import api from '../../../api';
import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize'; import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
import type { Message, MessageDocument } from '../../../components/UiComponents/Messages/MessagesTypes'; import type { Message, MessageDocument } from '../../../components/UiComponents/Messages/MessagesTypes';
import type { AgentProgress, FileEditProposal } from './useWorkspace'; import type { AgentProgress, FileEditProposal } from './useWorkspace';
import { useAudioQueue, type AudioQueueApi } from '../../../hooks/useAudioQueue';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
@ -33,12 +37,30 @@ export const ChatStream: React.FC<ChatStreamProps> = ({ messages,
onRejectEdit, onRejectEdit,
onOpenEditor, onOpenEditor,
}) => { }) => {
const { t } = useLanguage();
const bottomRef = useRef<HTMLDivElement>(null); const bottomRef = useRef<HTMLDivElement>(null);
const audioQueue = useAudioQueue();
const enqueuedIdsRef = useRef<Set<string>>(new Set());
useEffect(() => { useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, agentProgress]); }, [messages, agentProgress]);
useEffect(() => {
for (const msg of messages) {
const audioUrl = (msg as any)._audioUrl;
if (!audioUrl) continue;
if (enqueuedIdsRef.current.has(msg.id)) continue;
enqueuedIdsRef.current.add(msg.id);
audioQueue.enqueue({
id: msg.id,
url: audioUrl,
language: (msg as any)._audioLang,
charCount: (msg as any)._audioCharCount,
});
}
}, [messages, audioQueue]);
return ( return (
<div style={{ <div style={{
flex: 1, flex: 1,
@ -145,10 +167,11 @@ export const ChatStream: React.FC<ChatStreamProps> = ({ messages,
</div> </div>
)} )}
{(msg as any)._audioUrl && ( {(msg as any)._audioUrl && (
<_AudioPlayer <_QueuedAudioPlayer
msgId={msg.id}
url={(msg as any)._audioUrl} url={(msg as any)._audioUrl}
language={(msg as any)._audioLang} language={(msg as any)._audioLang}
charCount={(msg as any)._audioCharCount} audioQueue={audioQueue}
/> />
)} )}
{msg.role === 'assistant' && msg.documents && msg.documents.length > 0 && ( {msg.role === 'assistant' && msg.documents && msg.documents.length > 0 && (
@ -256,46 +279,62 @@ export const ChatStream: React.FC<ChatStreamProps> = ({ messages,
</div> </div>
)} )}
{/* Agent progress */} {/* Thinking / agent-progress indicator */}
{isProcessing && agentProgress && ( {isProcessing && (
<div style={{ <div style={{
flexShrink: 0, flexShrink: 0,
padding: '8px 14px', borderRadius: 8, fontSize: 12, padding: '10px 16px',
background: 'var(--progress-bg, #e8f5e9)', borderRadius: 12,
border: '1px solid var(--progress-border, #c8e6c9)',
alignSelf: 'flex-start', alignSelf: 'flex-start',
display: 'flex', gap: 12, alignItems: 'center', maxWidth: '85%',
background: 'var(--assistant-bg, #ffffff)',
border: '1px solid var(--border-color, #e0e0e0)',
display: 'flex',
flexDirection: 'column',
gap: 6,
}}> }}>
<span style={{ fontWeight: 600 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
Round {agentProgress.round}{agentProgress.maxRounds ? `/${agentProgress.maxRounds}` : ''} <div style={{ fontSize: 11, color: '#888' }}>Assistant</div>
</span> <div className="workspace-thinking-dots" style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<span>{agentProgress.totalToolCalls} tools</span> <span className="workspace-dot workspace-dot-1" />
<span>{agentProgress.costCHF?.toFixed(4) || '0'} CHF</span> <span className="workspace-dot workspace-dot-2" />
</div> <span className="workspace-dot workspace-dot-3" />
)} </div>
</div>
{isProcessing && !agentProgress && ( {agentProgress ? (
<div style={{ <div style={{ display: 'flex', gap: 10, alignItems: 'center', fontSize: 11, color: '#888' }}>
flexShrink: 0, <span style={{ fontWeight: 600 }}>
padding: '8px 14px', borderRadius: 8, fontSize: 12, Round {agentProgress.round}{agentProgress.maxRounds ? `/${agentProgress.maxRounds}` : ''}
color: '#666', alignSelf: 'flex-start', fontStyle: 'italic', </span>
display: 'flex', alignItems: 'center', gap: 8, <span>{agentProgress.totalToolCalls} tools</span>
}}> <span>{agentProgress.costCHF?.toFixed(4) || '0'} CHF</span>
<span className="workspace-spinner" style={{ </div>
display: 'inline-block', width: 12, height: 12, ) : (
border: '2px solid var(--border-color, #ccc)', borderTopColor: 'var(--primary-color, #F25843)', <div style={{ fontSize: 12, color: '#999' }}>
borderRadius: '50%', animation: 'workspace-spin 0.8s linear infinite', {t('Denkt nach…')}
}} /> </div>
Processing... )}
</div> </div>
)} )}
<div ref={bottomRef} /> <div ref={bottomRef} />
<style>{` <style>{`
@keyframes workspace-spin { @keyframes workspace-dot-bounce {
to { transform: rotate(360deg); } 0%, 80%, 100% { transform: translateY(0); opacity: 0.4; }
40% { transform: translateY(-5px); opacity: 1; }
} }
.workspace-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--primary-color, #F25843);
animation: workspace-dot-bounce 1.4s ease-in-out infinite;
}
.workspace-dot-1 { animation-delay: 0s; }
.workspace-dot-2 { animation-delay: 0.2s; }
.workspace-dot-3 { animation-delay: 0.4s; }
.workspace-markdown p { margin: 4px 0; } .workspace-markdown p { margin: 4px 0; }
.workspace-markdown ul, .workspace-markdown ol { margin: 4px 0; padding-left: 20px; } .workspace-markdown ul, .workspace-markdown ol { margin: 4px 0; padding-left: 20px; }
.workspace-markdown blockquote { .workspace-markdown blockquote {
@ -400,73 +439,158 @@ function _getFileIcon(ext: string): string {
return map[ext] || '\uD83D\uDCC4'; return map[ext] || '\uD83D\uDCC4';
} }
function _AudioPlayer({ url, language }: { url: string; language?: string; charCount?: number }) { /**
const audioRef = useRef<HTMLAudioElement | null>(null); * Queue-aware audio player with replay support.
const [playing, setPlaying] = useState(false); *
const [progress, setProgress] = useState(0); * During the initial queue pass the player shows queue/active/done state.
const [duration, setDuration] = useState(0); * Once an item is done, clicking Play starts an independent local replay
* so every message can be re-listened at any time.
*/
function _QueuedAudioPlayer({
msgId,
url,
language,
audioQueue,
}: {
msgId: string;
url: string;
language?: string;
audioQueue: AudioQueueApi;
}) {
const isActive = audioQueue.state.currentId === msgId;
const isQueued = audioQueue.isItemQueued(msgId);
const queueDone = !isActive && !isQueued;
const isQueuePlaying = isActive && audioQueue.state.isPlaying && !audioQueue.state.isPaused;
const isQueuePaused = isActive && audioQueue.state.isPaused;
const replayRef = useRef<HTMLAudioElement | null>(null);
const [replayPlaying, setReplayPlaying] = useState(false);
const [replayProgress, setReplayProgress] = useState(0);
const [replayDuration, setReplayDuration] = useState(0);
const [queueProgress, setQueueProgress] = useState(0);
const [queueDuration, setQueueDuration] = useState(0);
useEffect(() => { useEffect(() => {
const audio = new Audio(url);
audioRef.current = audio;
audio.addEventListener('loadedmetadata', () => setDuration(audio.duration));
audio.addEventListener('timeupdate', () => {
if (audio.duration) setProgress(audio.currentTime / audio.duration);
});
audio.addEventListener('ended', () => { setPlaying(false); setProgress(0); });
audio.addEventListener('pause', () => setPlaying(false));
audio.addEventListener('play', () => setPlaying(true));
audio.play().catch(() => {});
return () => { return () => {
audio.pause(); if (replayRef.current) { replayRef.current.pause(); replayRef.current = null; }
audio.src = '';
}; };
}, []);
useEffect(() => {
if (!isActive) return;
const interval = setInterval(() => {
setQueueProgress(audioQueue.getProgress());
setQueueDuration(audioQueue.getDuration());
}, 200);
return () => clearInterval(interval);
}, [isActive, audioQueue]);
const _startReplay = useCallback(() => {
if (replayRef.current) { replayRef.current.pause(); replayRef.current = null; }
const audio = new Audio(url);
replayRef.current = audio;
audio.addEventListener('loadedmetadata', () => setReplayDuration(audio.duration));
audio.addEventListener('timeupdate', () => {
if (audio.duration) setReplayProgress(audio.currentTime / audio.duration);
});
audio.addEventListener('ended', () => {
setReplayPlaying(false);
setReplayProgress(0);
replayRef.current = null;
});
audio.play()
.then(() => setReplayPlaying(true))
.catch(() => setReplayPlaying(false));
}, [url]); }, [url]);
const _togglePlay = useCallback(() => { const _toggleReplay = useCallback(() => {
const audio = audioRef.current; const audio = replayRef.current;
if (!audio) return; if (!audio) { _startReplay(); return; }
if (playing) { audio.pause(); } else { audio.play().catch(() => {}); } if (audio.paused) {
}, [playing]); audio.play().then(() => setReplayPlaying(true)).catch(() => {});
} else {
audio.pause();
setReplayPlaying(false);
}
}, [_startReplay]);
const _stop = useCallback(() => { const _stopReplay = useCallback(() => {
const audio = audioRef.current; if (replayRef.current) {
if (!audio) return; replayRef.current.pause();
audio.pause(); replayRef.current.currentTime = 0;
audio.currentTime = 0; replayRef.current = null;
setPlaying(false); }
setProgress(0); setReplayPlaying(false);
setReplayProgress(0);
}, []); }, []);
const _handleMainButton = useCallback(() => {
if (isActive) {
if (isQueuePaused) audioQueue.resume();
else audioQueue.pause();
} else if (queueDone) {
_toggleReplay();
}
}, [isActive, isQueuePaused, queueDone, audioQueue, _toggleReplay]);
const _handleSkip = useCallback(() => {
if (isActive) audioQueue.skip();
}, [isActive, audioQueue]);
const _formatTime = (s: number) => { const _formatTime = (s: number) => {
const m = Math.floor(s / 60); const m = Math.floor(s / 60);
const sec = Math.floor(s % 60); const sec = Math.floor(s % 60);
return `${m}:${sec.toString().padStart(2, '0')}`; return `${m}:${sec.toString().padStart(2, '0')}`;
}; };
const isAnythingPlaying = isQueuePlaying || replayPlaying;
const showProgress = isActive ? queueProgress : replayProgress;
const showDuration = isActive ? queueDuration : replayDuration;
const isWaiting = isQueued && !isActive;
let statusLabel = '';
if (isWaiting) statusLabel = 'in Warteschlange';
let buttonIcon = '\u25B6';
let buttonTitle = 'Abspielen';
if (isAnythingPlaying) { buttonIcon = '\u275A\u275A'; buttonTitle = 'Pause'; }
else if (isQueuePaused) { buttonIcon = '\u25B6'; buttonTitle = 'Weiter'; }
else if (isWaiting) { buttonIcon = '\u25B6'; buttonTitle = 'Warten…'; }
const canInteract = isActive || queueDone;
return ( return (
<div style={{ <div style={{
display: 'flex', alignItems: 'center', gap: 8, display: 'flex', alignItems: 'center', gap: 8,
padding: '8px 12px', borderRadius: 8, padding: '8px 12px', borderRadius: 8,
background: 'var(--audio-player-bg, #f0f4f8)', background: isActive
border: '1px solid var(--border-color, #e0e0e0)', ? 'var(--audio-player-bg-active, #e8f0fe)'
: replayPlaying
? 'var(--audio-player-bg-active, #e8f0fe)'
: 'var(--audio-player-bg, #f0f4f8)',
border: (isActive || replayPlaying)
? '1px solid var(--primary-color, #F25843)'
: '1px solid var(--border-color, #e0e0e0)',
maxWidth: 360, marginTop: 6, maxWidth: 360, marginTop: 6,
transition: 'border-color 0.3s, background 0.3s',
}}> }}>
<button <button
onClick={_togglePlay} onClick={_handleMainButton}
disabled={isWaiting}
style={{ style={{
width: 32, height: 32, borderRadius: '50%', border: 'none', width: 32, height: 32, borderRadius: '50%', border: 'none',
background: 'var(--primary-color, #F25843)', color: '#fff', background: canInteract ? 'var(--primary-color, #F25843)' : '#bbb',
cursor: 'pointer', fontSize: 14, color: '#fff',
cursor: canInteract ? 'pointer' : 'default',
fontSize: 14,
display: 'flex', alignItems: 'center', justifyContent: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0, flexShrink: 0,
}} }}
title={playing ? 'Pause' : 'Play'} title={buttonTitle}
> >
{playing ? '\u275A\u275A' : '\u25B6'} {buttonIcon}
</button> </button>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
@ -478,33 +602,35 @@ function _AudioPlayer({ url, language }: { url: string; language?: string; charC
<div style={{ <div style={{
height: '100%', borderRadius: 2, height: '100%', borderRadius: 2,
background: 'var(--primary-color, #F25843)', background: 'var(--primary-color, #F25843)',
width: `${progress * 100}%`, width: `${showProgress * 100}%`,
transition: 'width 0.2s', transition: 'width 0.2s',
}} /> }} />
</div> </div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 2 }}> <div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 2 }}>
<span style={{ fontSize: 10, color: '#888' }}> <span style={{ fontSize: 10, color: '#888' }}>
{duration > 0 ? _formatTime(progress * duration) : '0:00'} {showDuration > 0 ? _formatTime(showProgress * showDuration) : '0:00'}
</span> </span>
<span style={{ fontSize: 10, color: '#888' }}> <span style={{ fontSize: 10, color: '#888' }}>
{duration > 0 ? _formatTime(duration) : '--:--'} {statusLabel || (showDuration > 0 ? _formatTime(showDuration) : '--:--')}
</span> </span>
</div> </div>
</div> </div>
<button {(isActive || replayPlaying) && (
onClick={_stop} <button
style={{ onClick={isActive ? _handleSkip : _stopReplay}
width: 28, height: 28, borderRadius: '50%', border: '1px solid #ccc', style={{
background: 'transparent', color: '#888', width: 28, height: 28, borderRadius: '50%', border: '1px solid #ccc',
cursor: 'pointer', fontSize: 12, background: 'transparent', color: '#888',
display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', fontSize: 12,
flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
}} flexShrink: 0,
title="Stop" }}
> title={isActive ? 'Skip' : 'Stop'}
&#x25A0; >
</button> {isActive ? '\u23ED' : '\u25A0'}
</button>
)}
{language && ( {language && (
<span style={{ fontSize: 10, color: '#aaa', flexShrink: 0 }}> <span style={{ fontSize: 10, color: '#aaa', flexShrink: 0 }}>

View file

@ -1,22 +1,18 @@
/** /**
* WorkspaceSettingsPage -- Tabbed settings for the AI Workspace. * WorkspaceSettingsPage -- Settings for the AI Workspace.
* *
* Tabs: General settings, Neutralization.
* Voice settings are now in user-level settings (/settings -> "Stimme & Sprache"). * Voice settings are now in user-level settings (/settings -> "Stimme & Sprache").
* Neutralization audit has moved to the Compliance & AI-Audit page.
*/ */
import React, { useState } from 'react'; import React from 'react';
import { useInstanceId } from '../../../hooks/useCurrentInstance'; import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { WorkspaceGeneralSettings } from './WorkspaceGeneralSettings'; import { WorkspaceGeneralSettings } from './WorkspaceGeneralSettings';
import NeutralizationPanel from './NeutralizationPanel';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
type SettingsTab = 'general' | 'neutralization';
export const WorkspaceSettingsPage: React.FC = () => { export const WorkspaceSettingsPage: React.FC = () => {
const { t } = useLanguage(); const { t } = useLanguage();
const instanceId = useInstanceId(); const instanceId = useInstanceId();
const [activeTab, setActiveTab] = useState<SettingsTab>('general');
if (!instanceId) { if (!instanceId) {
return ( return (
@ -28,54 +24,8 @@ export const WorkspaceSettingsPage: React.FC = () => {
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}> <div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<nav style={{
display: 'flex',
gap: 0,
borderBottom: '1px solid var(--border-color, #e0e0e0)',
background: 'var(--bg-secondary, #fafafa)',
flexShrink: 0,
}}>
{([
{ key: 'general' as SettingsTab, label: t('Generelle Einstellungen') },
{ key: 'neutralization' as SettingsTab, label: t('Neutralisierung') },
]).map(tab => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
style={{
padding: '10px 20px',
border: 'none',
borderBottom: activeTab === tab.key
? '2px solid var(--primary-color, #F25843)'
: '2px solid transparent',
background: 'none',
cursor: 'pointer',
fontSize: 14,
fontWeight: activeTab === tab.key ? 600 : 400,
color: activeTab === tab.key
? 'var(--primary-color, #F25843)'
: 'var(--text-secondary, #888)',
}}
>
{tab.label}
</button>
))}
</nav>
<div style={{ flex: 1, overflow: 'auto', padding: '16px 24px' }}> <div style={{ flex: 1, overflow: 'auto', padding: '16px 24px' }}>
{activeTab === 'general' && ( <WorkspaceGeneralSettings instanceId={instanceId} />
<WorkspaceGeneralSettings instanceId={instanceId} />
)}
{activeTab === 'neutralization' && (
<>
<p style={{ margin: '0 0 12px', fontSize: '0.85rem', color: 'var(--text-secondary, #6b7280)' }}>
{t(
'Hier erscheinen die zuletzt an die KI gesendeten neutralisierten Texte und Platzhalter dieser Workspace-Instanz. (Die Benutzer-Einstellungen unter /settings → „Neutralisierung (lokal)“ sind eine andere Seite.)',
)}
</p>
<NeutralizationPanel instanceId={instanceId} />
</>
)}
</div> </div>
</div> </div>
); );