Merge pull request #34 from valueonag/int
Some checks are pending
Deploy Nyla Frontend to Production / build-and-deploy (push) Waiting to run

Int
This commit is contained in:
Patrick Motsch 2026-04-14 16:28:18 +02:00 committed by GitHub
commit 0178de9650
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
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;
gap: 1rem;
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 {
@ -11,6 +15,7 @@
font-weight: 700;
margin: 0;
color: var(--text-primary, #1a1a1a);
flex-shrink: 0;
}
.pageDesc {
@ -18,6 +23,7 @@
color: var(--text-secondary, #666);
margin: 0;
line-height: 1.4;
flex-shrink: 0;
}
/* Mandate selector */
@ -25,6 +31,7 @@
display: flex;
align-items: center;
gap: 0.6rem;
flex-shrink: 0;
}
.mandateLabel {
@ -49,6 +56,7 @@
display: flex;
gap: 0;
border-bottom: 2px solid var(--border-color, #e0e0e0);
flex-shrink: 0;
}
.tab {
@ -73,10 +81,21 @@
border-bottom-color: var(--accent-color, #1976d2);
}
/* Content area */
/* Content area — bounded height so FormGeneratorTable fills available space */
.tabContent {
display: flex;
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;
}
@ -186,3 +205,139 @@
margin: 0 0 0.6rem;
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.
*
* 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 C: Aggregated AI-Audit Statistics with charts
* Tab D: Neutralization Mappings FormGeneratorTable + delete
*/
import React, { useState, useCallback, useEffect, useMemo } from 'react';
@ -11,10 +12,11 @@ import {
ResponsiveContainer, LineChart, Line, XAxis, YAxis, CartesianGrid,
Tooltip, BarChart, Bar, PieChart, Pie, Cell,
} from 'recharts';
import { FaDownload } from 'react-icons/fa';
import { FaDownload, FaEye, FaTrash, FaTimes } from 'react-icons/fa';
import api from '../api';
import { useLanguage } from '../providers/language/LanguageContext';
import { useUserMandates } from '../hooks/useUserMandates';
import { useConfirm } from '../hooks/useConfirm';
import { FormGeneratorTable, ColumnConfig } from '../components/FormGenerator/FormGeneratorTable';
import styles from './ComplianceAuditPage.module.css';
@ -25,17 +27,76 @@ const _CATEGORY_COLORS: Record<string, string> = {
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 {
switch (tabId) {
case 'ai-log': return t('AI-Datenfluss');
case 'audit-log': return t('Audit-Log');
case 'stats': return t('Statistiken');
case 'neutralization': return t('Neutralisierung');
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 ───
interface AuditStats {
@ -51,12 +112,23 @@ interface AuditStats {
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 _AUDIT_LOG_PAGE_SIZE = 100;
const _NEUT_PAGE_SIZE = 100;
export const ComplianceAuditPage: React.FC = () => {
const { t } = useLanguage();
const { fetchMandates } = useUserMandates();
const { confirm, ConfirmDialog } = useConfirm();
const [mandates, setMandates] = useState<Mandate[]>([]);
const [mandatesLoading, setMandatesLoading] = useState(true);
@ -78,6 +150,16 @@ export const ComplianceAuditPage: React.FC = () => {
const [statsLoading, setStatsLoading] = useState(false);
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 ──
useEffect(() => {
@ -101,7 +183,7 @@ export const ComplianceAuditPage: React.FC = () => {
return selectedMandateId ? { 'X-Mandate-Id': selectedMandateId } : {};
}
// ── Tab A loader (FormGeneratorTable refetch pattern) ──
// ── Tab A loader ──
const _loadAiLog = useCallback(async (paginationParams?: any) => {
if (!selectedMandateId) return;
@ -170,6 +252,69 @@ export const ComplianceAuditPage: React.FC = () => {
finally { setStatsLoading(false); }
}, [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 ──
useEffect(() => {
@ -177,9 +322,36 @@ export const ComplianceAuditPage: React.FC = () => {
if (activeTab === 'ai-log') void _loadAiLog();
else if (activeTab === 'audit-log') void _loadAuditLog();
else if (activeTab === 'stats') void _loadStats(statsRange);
else if (activeTab === 'neutralization') void _loadNeutMappings();
}, [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) => {
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`, {
headers: _mandateHeaders(),
});
const ts = row.timestamp ? new Date(row.timestamp * 1000).toISOString() : '';
const text = [
`=== 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)',
'',
'--- Output ---',
'=== Output (AI-Antwort) ===',
data?.contentOutputFull || data?.contentOutputPreview || '(kein Output gespeichert)',
].join('\n');
@ -209,6 +389,18 @@ export const ComplianceAuditPage: React.FC = () => {
}
}, [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 ──
const aiLogColumns: ColumnConfig[] = useMemo(() => [
@ -222,14 +414,13 @@ export const ComplianceAuditPage: React.FC = () => {
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: '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,
formatter: (val: any) => val != null ? `${val}` : '',
},
{
key: 'tokensOutput', label: t('Tokens (Output)'), type: 'number' as any, sortable: true, width: 110,
formatter: (val: any) => val != null ? `${val}` : '',
key: 'aiProvider', label: t('Provider / Typ'), type: 'text' as any, sortable: true, filterable: true, width: 140,
formatter: (val: any, row: any) => {
const provider = val || '';
const op = row?.operationType;
return op ? `${provider} · ${op}` : provider;
},
},
{
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 },
], [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 ──
const aiLogHookData = useMemo(() => ({
@ -283,9 +492,14 @@ export const ComplianceAuditPage: React.FC = () => {
pagination: auditPagination,
}), [_loadAuditLog, auditPagination]);
const neutHookData = useMemo(() => ({
refetch: _loadNeutMappings,
pagination: neutPagination,
}), [_loadNeutMappings, neutPagination]);
// ── Render ──
const _tabs: TabId[] = ['ai-log', 'audit-log', 'stats'];
const _tabs: TabId[] = ['ai-log', 'audit-log', 'stats', 'neutralization'];
return (
<div className={styles.wrap}>
@ -329,7 +543,7 @@ export const ComplianceAuditPage: React.FC = () => {
{/* ── Tab A: AI Data-Flow Log ── */}
{activeTab === 'ai-log' && (
<div className={styles.tabContent} style={{ minHeight: 400 }}>
<div className={styles.tabContent}>
<FormGeneratorTable
key={`ai-log-${selectedMandateId}`}
data={aiEntries}
@ -345,6 +559,12 @@ export const ComplianceAuditPage: React.FC = () => {
onRefresh={_loadAiLog}
hookData={aiLogHookData}
customActions={[
{
id: 'viewContent',
title: t('Input/Output anzeigen'),
icon: <FaEye />,
onClick: _handleContentView,
},
{
id: 'downloadContent',
title: t('Input/Output herunterladen'),
@ -358,7 +578,7 @@ export const ComplianceAuditPage: React.FC = () => {
{/* ── Tab B: Audit Log ── */}
{activeTab === 'audit-log' && (
<div className={styles.tabContent} style={{ minHeight: 400 }}>
<div className={styles.tabContent}>
<FormGeneratorTable
key={`audit-log-${selectedMandateId}`}
data={auditEntries}
@ -379,7 +599,7 @@ export const ComplianceAuditPage: React.FC = () => {
{/* ── Tab C: Statistics ── */}
{activeTab === 'stats' && (
<div className={styles.tabContent}>
<div className={styles.tabContentScrollable}>
<div className={styles.statsControls}>
{[7, 30, 90].map(d => (
<button
@ -520,8 +740,126 @@ export const ComplianceAuditPage: React.FC = () => {
)}
</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>
);
};

View file

@ -3,6 +3,9 @@
*
* Renders messages with full Markdown (GFM tables, code blocks with syntax
* 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';
@ -12,6 +15,7 @@ import api from '../../../api';
import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize';
import type { Message, MessageDocument } from '../../../components/UiComponents/Messages/MessagesTypes';
import type { AgentProgress, FileEditProposal } from './useWorkspace';
import { useAudioQueue, type AudioQueueApi } from '../../../hooks/useAudioQueue';
import { useLanguage } from '../../../providers/language/LanguageContext';
@ -33,12 +37,30 @@ export const ChatStream: React.FC<ChatStreamProps> = ({ messages,
onRejectEdit,
onOpenEditor,
}) => {
const { t } = useLanguage();
const bottomRef = useRef<HTMLDivElement>(null);
const audioQueue = useAudioQueue();
const enqueuedIdsRef = useRef<Set<string>>(new Set());
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [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 (
<div style={{
flex: 1,
@ -145,10 +167,11 @@ export const ChatStream: React.FC<ChatStreamProps> = ({ messages,
</div>
)}
{(msg as any)._audioUrl && (
<_AudioPlayer
<_QueuedAudioPlayer
msgId={msg.id}
url={(msg as any)._audioUrl}
language={(msg as any)._audioLang}
charCount={(msg as any)._audioCharCount}
audioQueue={audioQueue}
/>
)}
{msg.role === 'assistant' && msg.documents && msg.documents.length > 0 && (
@ -256,46 +279,62 @@ export const ChatStream: React.FC<ChatStreamProps> = ({ messages,
</div>
)}
{/* Agent progress */}
{isProcessing && agentProgress && (
{/* Thinking / agent-progress indicator */}
{isProcessing && (
<div style={{
flexShrink: 0,
padding: '8px 14px', borderRadius: 8, fontSize: 12,
background: 'var(--progress-bg, #e8f5e9)',
border: '1px solid var(--progress-border, #c8e6c9)',
padding: '10px 16px',
borderRadius: 12,
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 }}>
Round {agentProgress.round}{agentProgress.maxRounds ? `/${agentProgress.maxRounds}` : ''}
</span>
<span>{agentProgress.totalToolCalls} tools</span>
<span>{agentProgress.costCHF?.toFixed(4) || '0'} CHF</span>
</div>
)}
{isProcessing && !agentProgress && (
<div style={{
flexShrink: 0,
padding: '8px 14px', borderRadius: 8, fontSize: 12,
color: '#666', alignSelf: 'flex-start', fontStyle: 'italic',
display: 'flex', alignItems: 'center', gap: 8,
}}>
<span className="workspace-spinner" style={{
display: 'inline-block', width: 12, height: 12,
border: '2px solid var(--border-color, #ccc)', borderTopColor: 'var(--primary-color, #F25843)',
borderRadius: '50%', animation: 'workspace-spin 0.8s linear infinite',
}} />
Processing...
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ fontSize: 11, color: '#888' }}>Assistant</div>
<div className="workspace-thinking-dots" style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<span className="workspace-dot workspace-dot-1" />
<span className="workspace-dot workspace-dot-2" />
<span className="workspace-dot workspace-dot-3" />
</div>
</div>
{agentProgress ? (
<div style={{ display: 'flex', gap: 10, alignItems: 'center', fontSize: 11, color: '#888' }}>
<span style={{ fontWeight: 600 }}>
Round {agentProgress.round}{agentProgress.maxRounds ? `/${agentProgress.maxRounds}` : ''}
</span>
<span>{agentProgress.totalToolCalls} tools</span>
<span>{agentProgress.costCHF?.toFixed(4) || '0'} CHF</span>
</div>
) : (
<div style={{ fontSize: 12, color: '#999' }}>
{t('Denkt nach…')}
</div>
)}
</div>
)}
<div ref={bottomRef} />
<style>{`
@keyframes workspace-spin {
to { transform: rotate(360deg); }
@keyframes workspace-dot-bounce {
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 ul, .workspace-markdown ol { margin: 4px 0; padding-left: 20px; }
.workspace-markdown blockquote {
@ -400,73 +439,158 @@ function _getFileIcon(ext: string): string {
return map[ext] || '\uD83D\uDCC4';
}
function _AudioPlayer({ url, language }: { url: string; language?: string; charCount?: number }) {
const audioRef = useRef<HTMLAudioElement | null>(null);
const [playing, setPlaying] = useState(false);
const [progress, setProgress] = useState(0);
const [duration, setDuration] = useState(0);
/**
* Queue-aware audio player with replay support.
*
* During the initial queue pass the player shows queue/active/done state.
* 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(() => {
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 () => {
audio.pause();
audio.src = '';
if (replayRef.current) { replayRef.current.pause(); replayRef.current = null; }
};
}, []);
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]);
const _togglePlay = useCallback(() => {
const audio = audioRef.current;
if (!audio) return;
if (playing) { audio.pause(); } else { audio.play().catch(() => {}); }
}, [playing]);
const _toggleReplay = useCallback(() => {
const audio = replayRef.current;
if (!audio) { _startReplay(); return; }
if (audio.paused) {
audio.play().then(() => setReplayPlaying(true)).catch(() => {});
} else {
audio.pause();
setReplayPlaying(false);
}
}, [_startReplay]);
const _stop = useCallback(() => {
const audio = audioRef.current;
if (!audio) return;
audio.pause();
audio.currentTime = 0;
setPlaying(false);
setProgress(0);
const _stopReplay = useCallback(() => {
if (replayRef.current) {
replayRef.current.pause();
replayRef.current.currentTime = 0;
replayRef.current = null;
}
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 m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
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 (
<div style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '8px 12px', borderRadius: 8,
background: 'var(--audio-player-bg, #f0f4f8)',
border: '1px solid var(--border-color, #e0e0e0)',
background: isActive
? '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,
transition: 'border-color 0.3s, background 0.3s',
}}>
<button
onClick={_togglePlay}
onClick={_handleMainButton}
disabled={isWaiting}
style={{
width: 32, height: 32, borderRadius: '50%', border: 'none',
background: 'var(--primary-color, #F25843)', color: '#fff',
cursor: 'pointer', fontSize: 14,
background: canInteract ? 'var(--primary-color, #F25843)' : '#bbb',
color: '#fff',
cursor: canInteract ? 'pointer' : 'default',
fontSize: 14,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}
title={playing ? 'Pause' : 'Play'}
title={buttonTitle}
>
{playing ? '\u275A\u275A' : '\u25B6'}
{buttonIcon}
</button>
<div style={{ flex: 1, minWidth: 0 }}>
@ -478,33 +602,35 @@ function _AudioPlayer({ url, language }: { url: string; language?: string; charC
<div style={{
height: '100%', borderRadius: 2,
background: 'var(--primary-color, #F25843)',
width: `${progress * 100}%`,
width: `${showProgress * 100}%`,
transition: 'width 0.2s',
}} />
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 2 }}>
<span style={{ fontSize: 10, color: '#888' }}>
{duration > 0 ? _formatTime(progress * duration) : '0:00'}
{showDuration > 0 ? _formatTime(showProgress * showDuration) : '0:00'}
</span>
<span style={{ fontSize: 10, color: '#888' }}>
{duration > 0 ? _formatTime(duration) : '--:--'}
{statusLabel || (showDuration > 0 ? _formatTime(showDuration) : '--:--')}
</span>
</div>
</div>
<button
onClick={_stop}
style={{
width: 28, height: 28, borderRadius: '50%', border: '1px solid #ccc',
background: 'transparent', color: '#888',
cursor: 'pointer', fontSize: 12,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}
title="Stop"
>
&#x25A0;
</button>
{(isActive || replayPlaying) && (
<button
onClick={isActive ? _handleSkip : _stopReplay}
style={{
width: 28, height: 28, borderRadius: '50%', border: '1px solid #ccc',
background: 'transparent', color: '#888',
cursor: 'pointer', fontSize: 12,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}
title={isActive ? 'Skip' : 'Stop'}
>
{isActive ? '\u23ED' : '\u25A0'}
</button>
)}
{language && (
<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").
* 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 { WorkspaceGeneralSettings } from './WorkspaceGeneralSettings';
import NeutralizationPanel from './NeutralizationPanel';
import { useLanguage } from '../../../providers/language/LanguageContext';
type SettingsTab = 'general' | 'neutralization';
export const WorkspaceSettingsPage: React.FC = () => {
const { t } = useLanguage();
const instanceId = useInstanceId();
const [activeTab, setActiveTab] = useState<SettingsTab>('general');
if (!instanceId) {
return (
@ -28,54 +24,8 @@ export const WorkspaceSettingsPage: React.FC = () => {
return (
<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' }}>
{activeTab === 'general' && (
<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} />
</>
)}
<WorkspaceGeneralSettings instanceId={instanceId} />
</div>
</div>
);