+
{[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 && (