From 851b509f9e6bbf9981c7c89a2c6c87bec254661c Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Tue, 14 Apr 2026 16:15:33 +0200 Subject: [PATCH] fixed tools --- src/hooks/useAudioQueue.ts | 175 ++++++++ src/pages/ComplianceAuditPage.module.css | 159 +++++++- src/pages/ComplianceAuditPage.tsx | 374 +++++++++++++++++- src/pages/views/workspace/ChatStream.tsx | 298 ++++++++++---- .../views/workspace/WorkspaceSettingsPage.tsx | 58 +-- 5 files changed, 904 insertions(+), 160 deletions(-) create mode 100644 src/hooks/useAudioQueue.ts diff --git a/src/hooks/useAudioQueue.ts b/src/hooks/useAudioQueue.ts new file mode 100644 index 0000000..1cd4fb1 --- /dev/null +++ b/src/hooks/useAudioQueue.ts @@ -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([]); + const audioRef = useRef(null); + const currentIdRef = useRef(null); + const playingRef = useRef(false); + + const [state, setState] = useState({ + currentId: null, + isPlaying: false, + isPaused: false, + queueLength: 0, + }); + + const _updateState = useCallback((patch: Partial) => { + 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, + }; +} diff --git a/src/pages/ComplianceAuditPage.module.css b/src/pages/ComplianceAuditPage.module.css index 776f300..e3dddbc 100644 --- a/src/pages/ComplianceAuditPage.module.css +++ b/src/pages/ComplianceAuditPage.module.css @@ -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; +} diff --git a/src/pages/ComplianceAuditPage.tsx b/src/pages/ComplianceAuditPage.tsx index 83940ca..3d18ac9 100644 --- a/src/pages/ComplianceAuditPage.tsx +++ b/src/pages/ComplianceAuditPage.tsx @@ -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 = { 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 = { + 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, +): 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({text.slice(lastIdx, match.index)}); + } + 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( + + {fullPh} + , + ); + lastIdx = match.index + match[0].length; + } + if (lastIdx < text.length) { + parts.push({text.slice(lastIdx)}); + } + 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([]); 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([]); + const [neutPagination, setNeutPagination] = useState(undefined); + const [neutLoading, setNeutLoading] = useState(false); + + // ── Content View Modal state ── + const [contentModal, setContentModal] = useState(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(); + if (contentModal?.neutralizationMappings) { + for (const m of contentModal.neutralizationMappings) { + map.set(m.id, m); + } + } + return map; + }, [contentModal?.neutralizationMappings]); + // ── Column definitions ── const aiLogColumns: ColumnConfig[] = useMemo(() => [ @@ -222,14 +414,13 @@ export const ComplianceAuditPage: React.FC = () => { formatter: (val: any, row: any) => row?.instanceLabel || val || '–', }, { key: 'aiModel', label: t('AI-Modell'), type: 'text' as any, sortable: true, filterable: true, width: 160 }, - { key: 'operationType', label: t('Typ'), type: 'text' as any, sortable: true, filterable: true, width: 90 }, { - key: 'tokensInput', label: t('Tokens (Input)'), type: 'number' as any, sortable: true, width: 110, - formatter: (val: any) => val != null ? `${val}` : '–', - }, - { - key: 'tokensOutput', label: t('Tokens (Output)'), type: 'number' as any, sortable: true, width: 110, - formatter: (val: any) => val != null ? `${val}` : '–', + key: 'aiProvider', label: t('Provider / Typ'), type: 'text' as any, sortable: true, filterable: true, width: 140, + formatter: (val: any, row: any) => { + const provider = val || '–'; + const op = row?.operationType; + return op ? `${provider} · ${op}` : provider; + }, }, { key: 'priceCHF', label: t('Kosten (CHF)'), type: 'number' as any, sortable: true, width: 110, @@ -271,6 +462,24 @@ export const ComplianceAuditPage: React.FC = () => { { key: 'ipAddress', label: t('IP'), type: 'text' as any, width: 120 }, ], [t]); + const neutColumns: ColumnConfig[] = useMemo(() => [ + { key: 'placeholder', label: t('Platzhalter'), type: 'text' as any, sortable: true, searchable: true, width: 220 }, + { key: 'originalText', label: t('Originaltext'), type: 'text' as any, sortable: true, searchable: true, width: 240 }, + { key: 'patternType', label: t('Kategorie'), type: 'text' as any, sortable: true, filterable: true, width: 120 }, + { + key: 'userId', label: t('Benutzer'), type: 'text' as any, sortable: true, filterable: true, width: 140, + formatter: (val: any) => val ? String(val).slice(0, 8) + '…' : '–', + }, + { + key: 'featureInstanceId', label: t('Feature-Instanz'), type: 'text' as any, sortable: true, filterable: true, width: 160, + formatter: (val: any) => val || '–', + }, + { + key: 'fileId', label: t('Datei'), type: 'text' as any, sortable: true, width: 140, + formatter: (val: any) => val ? String(val).slice(0, 8) + '…' : '–', + }, + ], [t]); + // ── hookData for FormGeneratorTable ── const aiLogHookData = useMemo(() => ({ @@ -283,9 +492,14 @@ export const ComplianceAuditPage: React.FC = () => { pagination: auditPagination, }), [_loadAuditLog, auditPagination]); + const neutHookData = useMemo(() => ({ + refetch: _loadNeutMappings, + pagination: neutPagination, + }), [_loadNeutMappings, neutPagination]); + // ── Render ── - const _tabs: TabId[] = ['ai-log', 'audit-log', 'stats']; + const _tabs: TabId[] = ['ai-log', 'audit-log', 'stats', 'neutralization']; return (
@@ -329,7 +543,7 @@ export const ComplianceAuditPage: React.FC = () => { {/* ── Tab A: AI Data-Flow Log ── */} {activeTab === 'ai-log' && ( -
+
{ onRefresh={_loadAiLog} hookData={aiLogHookData} customActions={[ + { + id: 'viewContent', + title: t('Input/Output anzeigen'), + icon: , + 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' && ( -
+
{ {/* ── Tab C: Statistics ── */} {activeTab === 'stats' && ( -
+
{[7, 30, 90].map(d => (
)} + + {/* ── Tab D: Neutralization Mappings ── */} + {activeTab === 'neutralization' && ( +
+ , + onClick: _handleDeleteMapping, + }, + ]} + /> +
+ )} )} + + {/* ── Content View Modal ── */} + {contentModal && ( +
setContentModal(null)}> +
e.stopPropagation()}> +
+

{t('AI-Audit Inhalt')}

+
+ {contentModal.row?.username || contentModal.row?.userId?.slice(0, 8) || '–'} + {' · '} + {contentModal.row?.aiModel || '–'} + {' · '} + {contentModal.row?.timestamp + ? new Date(contentModal.row.timestamp * 1000).toLocaleString() + : '–'} +
+ +
+ + {contentModal.neutralizationMappings.length > 0 && ( +
+ + {t('{n} Platzhalter aufgelöst', { n: String(contentModal.neutralizationMappings.length) })} + + + {t('Hover über markierte Platzhalter für Originaltext')} + +
+ )} + +
+ + +
+ +
+ {contentModalLoading ? ( +

{t('Lade Inhalt…')}

+ ) : ( +
+ {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; + })() + )} +
+ )} +
+
+
+ )} + +
); }; diff --git a/src/pages/views/workspace/ChatStream.tsx b/src/pages/views/workspace/ChatStream.tsx index 23d8a1a..070fdd4 100644 --- a/src/pages/views/workspace/ChatStream.tsx +++ b/src/pages/views/workspace/ChatStream.tsx @@ -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 = ({ messages, onRejectEdit, onOpenEditor, }) => { + const { t } = useLanguage(); const bottomRef = useRef(null); + const audioQueue = useAudioQueue(); + const enqueuedIdsRef = useRef>(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 (
= ({ messages,
)} {(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 = ({ messages,
)} - {/* Agent progress */} - {isProcessing && agentProgress && ( + {/* Thinking / agent-progress indicator */} + {isProcessing && (
- - Round {agentProgress.round}{agentProgress.maxRounds ? `/${agentProgress.maxRounds}` : ''} - - {agentProgress.totalToolCalls} tools - {agentProgress.costCHF?.toFixed(4) || '0'} CHF -
- )} - - {isProcessing && !agentProgress && ( -
- - Processing... +
+
Assistant
+
+ + + +
+
+ {agentProgress ? ( +
+ + Round {agentProgress.round}{agentProgress.maxRounds ? `/${agentProgress.maxRounds}` : ''} + + {agentProgress.totalToolCalls} tools + {agentProgress.costCHF?.toFixed(4) || '0'} CHF +
+ ) : ( +
+ {t('Denkt nach…')} +
+ )}
)}