fixed tools
This commit is contained in:
parent
af6feec4ca
commit
851b509f9e
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;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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'}
|
||||||
■
|
>
|
||||||
</button>
|
{isActive ? '\u23ED' : '\u25A0'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{language && (
|
{language && (
|
||||||
<span style={{ fontSize: 10, color: '#aaa', flexShrink: 0 }}>
|
<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").
|
* 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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue