Merge pull request #34 from valueonag/int
Some checks are pending
Deploy Nyla Frontend to Production / build-and-deploy (push) Waiting to run
Some checks are pending
Deploy Nyla Frontend to Production / build-and-deploy (push) Waiting to run
Int
This commit is contained in:
commit
0178de9650
5 changed files with 904 additions and 160 deletions
175
src/hooks/useAudioQueue.ts
Normal file
175
src/hooks/useAudioQueue.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: '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: 'tokensOutput', label: t('Tokens (Output)'), type: 'number' as any, sortable: true, width: 110,
|
||||
formatter: (val: any) => val != null ? `${val}` : '–',
|
||||
},
|
||||
{
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}}>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
<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 _stop = useCallback(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
const _toggleReplay = useCallback(() => {
|
||||
const audio = replayRef.current;
|
||||
if (!audio) { _startReplay(); return; }
|
||||
if (audio.paused) {
|
||||
audio.play().then(() => setReplayPlaying(true)).catch(() => {});
|
||||
} else {
|
||||
audio.pause();
|
||||
audio.currentTime = 0;
|
||||
setPlaying(false);
|
||||
setProgress(0);
|
||||
setReplayPlaying(false);
|
||||
}
|
||||
}, [_startReplay]);
|
||||
|
||||
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,22 +602,23 @@ 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>
|
||||
|
||||
{(isActive || replayPlaying) && (
|
||||
<button
|
||||
onClick={_stop}
|
||||
onClick={isActive ? _handleSkip : _stopReplay}
|
||||
style={{
|
||||
width: 28, height: 28, borderRadius: '50%', border: '1px solid #ccc',
|
||||
background: 'transparent', color: '#888',
|
||||
|
|
@ -501,10 +626,11 @@ function _AudioPlayer({ url, language }: { url: string; language?: string; charC
|
|||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
title="Stop"
|
||||
title={isActive ? 'Skip' : 'Stop'}
|
||||
>
|
||||
■
|
||||
{isActive ? '\u23ED' : '\u25A0'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{language && (
|
||||
<span style={{ fontSize: 10, color: '#aaa', flexShrink: 0 }}>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue