610 lines
28 KiB
TypeScript
610 lines
28 KiB
TypeScript
/**
|
|
* CommCoach Session View (Refactored)
|
|
*
|
|
* Shows ONLY the active coaching session: Chat, Voice, TTS, Agent activity.
|
|
* Three states:
|
|
* 1. No module selected -> hint with links to Assistant / Modules
|
|
* 2. Module selected, no session -> persona picker + "Start session"
|
|
* 3. Session active -> full chat/voice/TTS interface
|
|
*
|
|
* Reachable via Assistant wizard or Modules page ("Session starten" button).
|
|
* KeepAlive-wrapped — voice sessions persist across tab switches.
|
|
*/
|
|
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
|
import { useSearchParams, useNavigate, useLocation } from 'react-router-dom';
|
|
import { useCommcoach } from '../../../hooks/useCommcoach';
|
|
import { type TtsEvent } from '../../../hooks/useTtsPlayback';
|
|
import { useApiRequest } from '../../../hooks/useApi';
|
|
import { useInstanceId, useMandateId } from '../../../hooks/useCurrentInstance';
|
|
import {
|
|
getPersonasApi, getModulePersonasApi,
|
|
type CoachingPersona,
|
|
type SendMessageOptions,
|
|
} from '../../../api/commcoachApi';
|
|
import api from '../../../api';
|
|
import AutoScroll from '../../../components/UiComponents/AutoScroll/AutoScroll';
|
|
import ReactMarkdown from 'react-markdown';
|
|
import remarkGfm from 'remark-gfm';
|
|
import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
|
|
import type { UdbContext, UdbTab, AddToChat_FileItem } from '../../../components/UnifiedDataBar';
|
|
import { _defaultProviderSelection } from '../../../components/ProviderSelector';
|
|
import type { ProviderSelection } from '../../../components/ProviderSelector';
|
|
import { getPageIcon } from '../../../config/pageRegistry';
|
|
import styles from './CommcoachDossierView.module.css';
|
|
import sessionStyles from './Commcoach.module.css';
|
|
import { useVoiceController } from './useVoiceController';
|
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
|
|
|
interface WorkspaceFileInfo { id: string; fileName: string; mimeType: string; fileSize: number; }
|
|
interface DataSourceInfo { id: string; connectionId: string; sourceType: string; path: string; label: string; }
|
|
interface FeatureDataSourceInfo { id: string; featureInstanceId: string; featureCode: string; tableName: string; label: string; }
|
|
|
|
function _formatToolPayload(payload: Record<string, unknown>): string {
|
|
try {
|
|
const s = JSON.stringify(payload, null, 0);
|
|
return s.length > 120 ? s.substring(0, 120) + '...' : s;
|
|
} catch { return '[unlesbar]'; }
|
|
}
|
|
|
|
export const CommcoachSessionView: React.FC = () => {
|
|
const { t } = useLanguage();
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const instanceId = useInstanceId();
|
|
const mandateId = useMandateId();
|
|
const coach = useCommcoach(instanceId);
|
|
const { request } = useApiRequest();
|
|
const [searchParams] = useSearchParams();
|
|
const moduleId = searchParams.get('moduleId');
|
|
|
|
const isSessionRoute = /\/commcoach\/[^/]+\/session/.test(location.pathname);
|
|
|
|
const [personas, setPersonas] = useState<CoachingPersona[]>([]);
|
|
const [modulePersonaIds, setModulePersonaIds] = useState<string[] | null>(null);
|
|
const [selectedPersonaId, setSelectedPersonaId] = useState<string | undefined>(undefined);
|
|
const [showAgentActivity, setShowAgentActivity] = useState(true);
|
|
|
|
const [udbCollapsed, setUdbCollapsed] = useState(false);
|
|
const [udbTab, setUdbTab] = useState<UdbTab>('files');
|
|
const [udbWidth, setUdbWidth] = useState(280);
|
|
const udbResizing = useRef(false);
|
|
|
|
const [wsFiles, setWsFiles] = useState<WorkspaceFileInfo[]>([]);
|
|
const [wsDataSources, setWsDataSources] = useState<DataSourceInfo[]>([]);
|
|
const [wsFeatureDataSources, setWsFeatureDataSources] = useState<FeatureDataSourceInfo[]>([]);
|
|
const [attachedFileIds, setAttachedFileIds] = useState<string[]>([]);
|
|
const [attachedDsIds, setAttachedDsIds] = useState<string[]>([]);
|
|
const [attachedFdsIds, setAttachedFdsIds] = useState<string[]>([]);
|
|
const [providerSelection, _setProviderSelection] = useState<ProviderSelection>(_defaultProviderSelection);
|
|
const [_showFilePicker, setShowFilePicker] = useState(false);
|
|
const [_showSourcePicker, setShowSourcePicker] = useState(false);
|
|
|
|
const _udbContext: UdbContext | null = instanceId
|
|
? { instanceId, mandateId: mandateId || undefined, featureInstanceId: instanceId }
|
|
: null;
|
|
|
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
const sendMessageRef = useRef(coach.sendMessage);
|
|
sendMessageRef.current = coach.sendMessage;
|
|
const attachedFileIdsRef = useRef(attachedFileIds);
|
|
attachedFileIdsRef.current = attachedFileIds;
|
|
const attachedDsIdsRef = useRef(attachedDsIds);
|
|
attachedDsIdsRef.current = attachedDsIds;
|
|
const attachedFdsIdsRef = useRef(attachedFdsIds);
|
|
attachedFdsIdsRef.current = attachedFdsIds;
|
|
const providerSelRef = useRef(providerSelection);
|
|
providerSelRef.current = providerSelection;
|
|
|
|
const voice = useVoiceController({
|
|
onFinalText: (text) => {
|
|
const opts: SendMessageOptions = {};
|
|
if (attachedFileIdsRef.current.length) opts.fileIds = attachedFileIdsRef.current;
|
|
if (attachedDsIdsRef.current.length) opts.dataSourceIds = attachedDsIdsRef.current;
|
|
if (attachedFdsIdsRef.current.length) opts.featureDataSourceIds = attachedFdsIdsRef.current;
|
|
const allowed = providerSelRef.current.include.length > 0 ? providerSelRef.current.include : undefined;
|
|
if (allowed) opts.allowedProviders = allowed;
|
|
sendMessageRef.current(text, Object.keys(opts).length ? opts : undefined);
|
|
},
|
|
});
|
|
|
|
useEffect(() => {
|
|
coach.onTtsEventRef.current = (event: TtsEvent) => {
|
|
if (event === 'playing') voice.ttsPlaying();
|
|
else if (event === 'ended') voice.ttsEnded();
|
|
else if (event === 'paused') voice.ttsPaused();
|
|
else if (event === 'error') voice.ttsEnded();
|
|
};
|
|
return () => { coach.onTtsEventRef.current = null; };
|
|
}, [coach.onTtsEventRef, voice.ttsPlaying, voice.ttsEnded, voice.ttsPaused]);
|
|
|
|
// Auto-select module from URL param
|
|
useEffect(() => {
|
|
if (moduleId && coach.contexts.length > 0 && coach.selectedContextId !== moduleId) {
|
|
const found = coach.contexts.find(c => c.id === moduleId);
|
|
if (found) coach.selectContext(moduleId);
|
|
} else if (!moduleId && !coach.selectedContextId && coach.contexts.length > 0) {
|
|
coach.selectContext(coach.contexts[0].id, { skipSessionResume: true });
|
|
}
|
|
}, [moduleId, coach.contexts, coach.selectedContextId, coach.selectContext]);
|
|
|
|
useEffect(() => {
|
|
if (!instanceId || !isSessionRoute) return;
|
|
getPersonasApi(request, instanceId).then(p => setPersonas(p)).catch(() => {});
|
|
}, [instanceId, request, isSessionRoute]);
|
|
|
|
useEffect(() => {
|
|
if (!instanceId || !coach.selectedContextId || !isSessionRoute) { setModulePersonaIds(null); return; }
|
|
getModulePersonasApi(request, instanceId, coach.selectedContextId)
|
|
.then(ids => setModulePersonaIds(ids.length > 0 ? ids : null))
|
|
.catch(() => setModulePersonaIds(null));
|
|
}, [instanceId, request, coach.selectedContextId, isSessionRoute]);
|
|
|
|
const _refreshWorkspaceAssets = useCallback(() => {
|
|
if (!instanceId) return;
|
|
api.get(`/api/workspace/${instanceId}/files`).then(r => setWsFiles(r.data.files || [])).catch(() => {});
|
|
api.get(`/api/workspace/${instanceId}/datasources`).then(r => setWsDataSources(r.data.dataSources || [])).catch(() => {});
|
|
api.get(`/api/workspace/${instanceId}/feature-datasources`).then(r => setWsFeatureDataSources(r.data.featureDataSources || [])).catch(() => {});
|
|
}, [instanceId]);
|
|
|
|
useEffect(() => { _refreshWorkspaceAssets(); }, [_refreshWorkspaceAssets]);
|
|
useEffect(() => {
|
|
const h = () => _refreshWorkspaceAssets();
|
|
window.addEventListener('fileUploaded', h);
|
|
return () => window.removeEventListener('fileUploaded', h);
|
|
}, [_refreshWorkspaceAssets]);
|
|
|
|
useEffect(() => {
|
|
if (!coach.session) {
|
|
voice.deactivate();
|
|
} else if (voice.state === 'idle') {
|
|
voice.activate();
|
|
}
|
|
}, [coach.session?.id, voice]);
|
|
|
|
useEffect(() => {
|
|
coach.onDocumentCreatedRef.current = () => {
|
|
window.dispatchEvent(new CustomEvent('fileUploaded', { detail: { source: 'commcoachDocument' } }));
|
|
};
|
|
return () => { coach.onDocumentCreatedRef.current = null; };
|
|
}, [coach, _refreshWorkspaceAssets]);
|
|
|
|
useEffect(() => {
|
|
if (coach.agentToolCalls.length > 0) setShowAgentActivity(true);
|
|
}, [coach.agentToolCalls.length]);
|
|
|
|
const handleStopTts = useCallback(() => { coach.stopTts(); voice.ttsStopped(); }, [coach, voice]);
|
|
const handlePauseTts = useCallback(() => coach.pauseTts(), [coach]);
|
|
const handleResumeTts = useCallback(() => coach.resumeTts(), [coach]);
|
|
|
|
const handleSend = useCallback(async () => {
|
|
if (!coach.inputValue.trim() || coach.isStreaming) return;
|
|
const opts: SendMessageOptions = {};
|
|
if (attachedFileIds.length) opts.fileIds = attachedFileIds;
|
|
if (attachedDsIds.length) opts.dataSourceIds = attachedDsIds;
|
|
if (attachedFdsIds.length) opts.featureDataSourceIds = attachedFdsIds;
|
|
const allowed = providerSelection.include.length > 0 ? providerSelection.include : undefined;
|
|
if (allowed) opts.allowedProviders = allowed;
|
|
await coach.sendMessage(coach.inputValue, Object.keys(opts).length ? opts : undefined);
|
|
setAttachedFileIds([]);
|
|
setShowSourcePicker(false);
|
|
setShowFilePicker(false);
|
|
}, [coach, attachedFileIds, attachedDsIds, attachedFdsIds, providerSelection]);
|
|
|
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); }
|
|
}, [handleSend]);
|
|
|
|
const _toggleFile = useCallback((fileId: string) => {
|
|
setAttachedFileIds(prev => prev.includes(fileId) ? prev.filter(id => id !== fileId) : [...prev, fileId]);
|
|
}, []);
|
|
const _toggleDs = useCallback((dsId: string) => {
|
|
setAttachedDsIds(prev => prev.includes(dsId) ? prev.filter(id => id !== dsId) : [...prev, dsId]);
|
|
}, []);
|
|
const _toggleFds = useCallback((fdsId: string) => {
|
|
setAttachedFdsIds(prev => prev.includes(fdsId) ? prev.filter(id => id !== fdsId) : [...prev, fdsId]);
|
|
}, []);
|
|
|
|
const attachedFileNamesRef = useRef<Record<string, string>>({});
|
|
|
|
const _handleUdbFileSelect = useCallback((fileId: string, fileName?: string) => {
|
|
if (fileName) attachedFileNamesRef.current[fileId] = fileName;
|
|
setAttachedFileIds(prev => prev.includes(fileId) ? prev : [...prev, fileId]);
|
|
}, []);
|
|
|
|
const _handleUdbSendToChat = useCallback((items: AddToChat_FileItem[]) => {
|
|
for (const item of items) {
|
|
if (item.name) attachedFileNamesRef.current[item.id] = item.name;
|
|
}
|
|
const fileIds = items.filter(i => i.type === 'file').map(i => i.id);
|
|
setAttachedFileIds(prev => {
|
|
const merged = [...prev];
|
|
for (const fId of fileIds) {
|
|
if (!merged.includes(fId)) merged.push(fId);
|
|
}
|
|
return merged;
|
|
});
|
|
}, []);
|
|
|
|
const _isTreeDrag = useCallback((e: React.DragEvent) => {
|
|
return e.dataTransfer.types.includes('application/tree-items') ||
|
|
e.dataTransfer.types.includes('application/file-id') ||
|
|
e.dataTransfer.types.includes('application/file-ids');
|
|
}, []);
|
|
|
|
const _handleInputDragOver = useCallback((e: React.DragEvent) => {
|
|
if (_isTreeDrag(e)) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
e.dataTransfer.dropEffect = 'copy';
|
|
}
|
|
}, [_isTreeDrag]);
|
|
|
|
const _handleInputDrop = useCallback((e: React.DragEvent) => {
|
|
const treeJson = e.dataTransfer.getData('application/tree-items');
|
|
if (treeJson) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
try {
|
|
const items: AddToChat_FileItem[] = JSON.parse(treeJson);
|
|
for (const item of items) {
|
|
if (item.name) attachedFileNamesRef.current[item.id] = item.name;
|
|
}
|
|
const fileIds = items.filter(i => i.type === 'file').map(i => i.id);
|
|
setAttachedFileIds(prev => {
|
|
const merged = [...prev];
|
|
for (const fId of fileIds) {
|
|
if (!merged.includes(fId)) merged.push(fId);
|
|
}
|
|
return merged;
|
|
});
|
|
} catch {}
|
|
return;
|
|
}
|
|
const fileId = e.dataTransfer.getData('application/file-id');
|
|
if (fileId) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const name = e.dataTransfer.getData('text/plain');
|
|
if (name) attachedFileNamesRef.current[fileId] = name;
|
|
setAttachedFileIds(prev => prev.includes(fileId) ? prev : [...prev, fileId]);
|
|
}
|
|
}, []);
|
|
|
|
const _toolPayloadForDisplay = (payload: Record<string, unknown>): string => {
|
|
const formatted = _formatToolPayload(payload);
|
|
return formatted === '[unlesbar]' ? t('[unlesbar]') : formatted;
|
|
};
|
|
|
|
if (coach.loadingContexts) {
|
|
return <div className={sessionStyles.loading}>{t('Laden...')}</div>;
|
|
}
|
|
|
|
// ========== STATE 1: No module selected ==========
|
|
if (!coach.selectedContextId && coach.contexts.length === 0) {
|
|
return (
|
|
<div className={sessionStyles.modulesContainer} style={{ alignItems: 'center', justifyContent: 'center' }}>
|
|
<h3>{t('Keine aktive Session')}</h3>
|
|
<p style={{ color: 'var(--text-secondary, #666)', textAlign: 'center', maxWidth: 400 }}>
|
|
{t('Erstelle zuerst ein Modul ueber den Assistenten oder starte eine Session ueber die Module-Seite.')}
|
|
</p>
|
|
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '1rem' }}>
|
|
<button className={sessionStyles.btnPrimary}
|
|
onClick={() => navigate(`/mandates/${mandateId}/commcoach/${instanceId}/assistant`)}
|
|
>{t('Zum Assistenten')}</button>
|
|
<button className={sessionStyles.btnSecondary}
|
|
onClick={() => navigate(`/mandates/${mandateId}/commcoach/${instanceId}/modules`)}
|
|
>{t('Zu den Modulen')}</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ========== STATE 2: Module selected, no active session ==========
|
|
if (coach.selectedContextId && !coach.session) {
|
|
return (
|
|
<div className={sessionStyles.modulesContainer}>
|
|
<div style={{ marginBottom: '1rem' }}>
|
|
<h3>{coach.selectedContext?.title || t('Modul')}</h3>
|
|
{coach.selectedContext?.description && (
|
|
<p style={{ color: 'var(--text-secondary, #666)', marginTop: '0.25rem' }}>{coach.selectedContext.description}</p>
|
|
)}
|
|
</div>
|
|
|
|
{personas.length > 0 && (() => {
|
|
const availablePersonas = modulePersonaIds
|
|
? personas.filter(p => modulePersonaIds.includes(p.id))
|
|
: personas;
|
|
return availablePersonas.length > 0 ? (
|
|
<div style={{ marginBottom: '1rem' }}>
|
|
<label style={{ display: 'block', fontWeight: 500, marginBottom: '0.5rem' }}>{t('Gespraechspartner waehlen')}</label>
|
|
{modulePersonaIds && (
|
|
<p style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #888)', margin: '0 0 0.5rem' }}>
|
|
{t('Fuer dieses Modul sind bestimmte Gespraechspartner konfiguriert.')}
|
|
</p>
|
|
)}
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
|
{availablePersonas.map(p => (
|
|
<button
|
|
key={p.id}
|
|
className={`${sessionStyles.btnSmall} ${selectedPersonaId === p.id ? sessionStyles.btnSmallActive : ''}`}
|
|
onClick={() => setSelectedPersonaId(selectedPersonaId === p.id ? undefined : p.id)}
|
|
title={p.description}
|
|
>
|
|
<span>{p.gender === 'f' ? '\u2640' : p.gender === 'm' ? '\u2642' : '\u25CB'}</span>
|
|
{' '}{p.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : null;
|
|
})()}
|
|
|
|
<button
|
|
className={sessionStyles.btnPrimary}
|
|
onClick={() => coach.startSession(selectedPersonaId)}
|
|
disabled={!!coach.actionLoading}
|
|
style={{ alignSelf: 'flex-start' }}
|
|
>
|
|
{coach.actionLoading === 'starting'
|
|
? t('Wird gestartet...')
|
|
: selectedPersonaId && personas.find(p => p.id === selectedPersonaId)
|
|
? `${t('Session starten mit')} ${personas.find(p => p.id === selectedPersonaId)!.label}`
|
|
: t('Session starten')}
|
|
</button>
|
|
|
|
{coach.error && <div className={sessionStyles.errorBanner}>{coach.error}</div>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ========== STATE 3: Active session ==========
|
|
return (
|
|
<div className={styles.dossierLayout}>
|
|
{/* UDB Sidebar */}
|
|
{_udbContext && (
|
|
<div
|
|
className={`${styles.udbSidebar} ${udbCollapsed ? styles.udbSidebarCollapsed : ''}`}
|
|
style={udbCollapsed ? undefined : { width: udbWidth, minWidth: 180 }}
|
|
>
|
|
<button
|
|
className={styles.udbToggle}
|
|
onClick={() => setUdbCollapsed(v => !v)}
|
|
title={udbCollapsed ? t('Seitenleiste einblenden') : t('Seitenleiste ausblenden')}
|
|
>
|
|
{udbCollapsed ? '\u25B6' : '\u25C0'}
|
|
</button>
|
|
{!udbCollapsed && (
|
|
<UnifiedDataBar
|
|
context={_udbContext}
|
|
activeTab={udbTab}
|
|
onTabChange={setUdbTab}
|
|
hideTabs={['chats']}
|
|
onFileSelect={_handleUdbFileSelect}
|
|
onSendToChat_Files={_handleUdbSendToChat}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
{_udbContext && !udbCollapsed && (
|
|
<div
|
|
className={styles.udbResizeHandle}
|
|
onMouseDown={(e) => {
|
|
e.preventDefault();
|
|
udbResizing.current = true;
|
|
const startX = e.clientX;
|
|
const startW = udbWidth;
|
|
const onMove = (ev: MouseEvent) => {
|
|
if (!udbResizing.current) return;
|
|
const newW = Math.max(180, Math.min(600, startW + (ev.clientX - startX)));
|
|
setUdbWidth(newW);
|
|
};
|
|
const onUp = () => {
|
|
udbResizing.current = false;
|
|
document.removeEventListener('mousemove', onMove);
|
|
document.removeEventListener('mouseup', onUp);
|
|
};
|
|
document.addEventListener('mousemove', onMove);
|
|
document.addEventListener('mouseup', onUp);
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Main Session Content */}
|
|
<div className={styles.dossier}>
|
|
{/* Session Header */}
|
|
<div className={styles.sessionHeader}>
|
|
<span className={styles.sessionLabel}>
|
|
{coach.selectedContext?.title ? `${coach.selectedContext.title} — ` : ''}
|
|
{t('Session aktiv')}
|
|
</span>
|
|
<div className={styles.sessionActions}>
|
|
{voice.state === 'botSpeaking' && (
|
|
<>
|
|
<button className={styles.btnSmall} onClick={handlePauseTts}>Pause</button>
|
|
<button className={styles.btnSmallDanger} onClick={handleStopTts}>Stop</button>
|
|
</>
|
|
)}
|
|
{voice.state === 'interrupted' && coach.hasAudioToResume() && (
|
|
<button className={styles.btnSmall} onClick={handleResumeTts}>{t('Weitersprechen')}</button>
|
|
)}
|
|
<button
|
|
className={`${styles.btnSmall} ${voice.muted ? styles.mutedActive : ''}`}
|
|
onClick={voice.toggleMute}
|
|
title={voice.muted ? t('Stummschaltung aufheben') : t('stummschalten')}
|
|
>
|
|
{voice.muted ? t('Stumm') : t('Ton an')}
|
|
</button>
|
|
<button className={styles.btnSmall} onClick={coach.completeSession} disabled={!!coach.actionLoading}>
|
|
{coach.actionLoading === 'completing' ? t('wird abgeschlossen') : t('abschliessen')}
|
|
</button>
|
|
<button className={styles.btnSmallDanger} onClick={coach.cancelSession} disabled={!!coach.actionLoading}>
|
|
{coach.actionLoading === 'cancelling' ? t('wird abgebrochen') : t('Abbrechen')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{coach.error && <div className={styles.errorBanner || sessionStyles.errorBanner}>{coach.error}</div>}
|
|
|
|
{/* Messages */}
|
|
<AutoScroll scrollDependency={coach.messages.length + (coach.isStreaming ? 1 : 0) + voice.liveTranscript.length}>
|
|
<div className={styles.messages}>
|
|
{coach.messages.map(msg => (
|
|
<div key={msg.id} className={`${styles.message} ${msg.role === 'user' ? styles.messageUser : styles.messageAssistant}`}>
|
|
<div className={styles.messageBubble}>
|
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown>
|
|
</div>
|
|
<div className={styles.messageTime}>
|
|
{msg.createdAt ? new Date(msg.createdAt).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' }) : ''}
|
|
</div>
|
|
</div>
|
|
))}
|
|
{voice.liveTranscript && (
|
|
<div className={`${styles.message} ${styles.messageUser}`}>
|
|
<div className={`${styles.messageBubble} ${styles.messageLive}`}>{voice.liveTranscript}</div>
|
|
</div>
|
|
)}
|
|
{coach.isStreaming && (
|
|
<div className={`${styles.message} ${styles.messageAssistant}`}>
|
|
<div className={styles.messageBubble}>
|
|
{coach.streamingMessage ? (
|
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{coach.streamingMessage}</ReactMarkdown>
|
|
) : (
|
|
<div className={styles.typing}>{coach.streamingStatus || t('Coach denkt nach')}<span className={styles.typingDots}>...</span></div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</AutoScroll>
|
|
|
|
{/* Agent Activity Panel */}
|
|
{(coach.isStreaming || coach.agentToolCalls.length > 0) && (
|
|
<div className={styles.agentActivityPanel}>
|
|
<button
|
|
className={styles.agentActivityHeader}
|
|
onClick={() => setShowAgentActivity(prev => !prev)}
|
|
type="button"
|
|
>
|
|
<span className={styles.agentActivityTitle}>
|
|
{t('Agent-Aktivitaet')}
|
|
{coach.agentToolCalls.length > 0 ? ` (${coach.agentToolCalls.length})` : ''}
|
|
</span>
|
|
<span className={styles.agentActivityStatus}>
|
|
{coach.streamingStatus || (coach.agentToolCalls.length > 0 ? t('Toolaufrufe vorhanden') : t('Warte auf Agent'))}
|
|
</span>
|
|
<span className={styles.agentActivityChevron}>{showAgentActivity ? '\u25BE' : '\u25B8'}</span>
|
|
</button>
|
|
{showAgentActivity && (
|
|
<div className={styles.agentActivityBody}>
|
|
{coach.agentToolCalls.length === 0 ? (
|
|
<div className={styles.agentActivityEmpty}>{t('Noch keine Tool-Aufrufe in dieser Antwort.')}</div>
|
|
) : (
|
|
coach.agentToolCalls.map((toolCall, idx) => (
|
|
<div key={`${toolCall.toolName}-${idx}`} className={styles.agentActivityItem}>
|
|
<div className={styles.agentActivityItemHeader}>
|
|
<span className={styles.agentActivityToolName}>{toolCall.toolName}</span>
|
|
<span className={`${styles.agentActivityBadge} ${
|
|
toolCall.success === true ? styles.agentActivityBadgeSuccess
|
|
: toolCall.success === false ? styles.agentActivityBadgeError
|
|
: styles.agentActivityBadgeRunning
|
|
}`}>
|
|
{toolCall.success === true ? t('fertig') : toolCall.success === false ? t('fehler') : t('laeuft')}
|
|
</span>
|
|
</div>
|
|
{toolCall.args && (
|
|
<div className={styles.agentActivityMeta}>
|
|
<strong>{t('Argumente:')}</strong> {_toolPayloadForDisplay(toolCall.args)}
|
|
</div>
|
|
)}
|
|
{toolCall.result && (
|
|
<div className={styles.agentActivityMeta}>
|
|
<strong>{t('Ergebnis:')}</strong> {toolCall.result}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Input Area */}
|
|
<div className={styles.inputArea} onDragOver={_handleInputDragOver} onDrop={_handleInputDrop}>
|
|
<div className={styles.voiceStatus}>
|
|
<span className={`${styles.voiceIndicator} ${voice.state === 'listening' ? styles.voiceActive : ''}`}>
|
|
{voice.muted
|
|
? t('Stumm - Mikrofon aus')
|
|
: voice.state === 'botSpeaking'
|
|
? (coach.streamingStatus || t('Coach spricht...'))
|
|
: coach.isStreaming
|
|
? (coach.streamingStatus || t('Coach denkt nach...'))
|
|
: voice.state === 'interrupted'
|
|
? t('Unterbrochen - Mikrofon an')
|
|
: voice.state === 'listening'
|
|
? (voice.liveTranscript ? t('Spricht...') : t('Mikrofon an - bitte sprechen'))
|
|
: t('Mikrofon wird gestartet...')}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Attachment Chips */}
|
|
{(attachedFileIds.length > 0 || attachedDsIds.length > 0 || attachedFdsIds.length > 0) && (
|
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', paddingBottom: 4 }}>
|
|
{attachedFileIds.map(fId => {
|
|
const file = wsFiles.find(f => f.id === fId);
|
|
const displayName = file?.fileName || attachedFileNamesRef.current[fId] || fId;
|
|
return (
|
|
<span key={fId} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '2px 8px', borderRadius: 12, fontSize: 11, background: '#e3f2fd', color: '#1565c0', fontWeight: 500 }}>
|
|
{displayName}
|
|
<button onClick={() => _toggleFile(fId)} style={{ border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#1565c0', padding: 0, lineHeight: 1 }}>x</button>
|
|
</span>
|
|
);
|
|
})}
|
|
{attachedDsIds.map(dsId => {
|
|
const ds = wsDataSources.find(d => d.id === dsId);
|
|
return (
|
|
<span key={dsId} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '2px 8px', borderRadius: 12, fontSize: 11, background: '#e8f5e9', color: '#2e7d32', fontWeight: 500 }}>
|
|
{ds?.label || ds?.path || dsId}
|
|
<button onClick={() => _toggleDs(dsId)} style={{ border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#2e7d32', padding: 0, lineHeight: 1 }}>x</button>
|
|
</span>
|
|
);
|
|
})}
|
|
{attachedFdsIds.map(fdsId => {
|
|
const fds = wsFeatureDataSources.find(d => d.id === fdsId);
|
|
return (
|
|
<span key={fdsId} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '2px 8px', borderRadius: 12, fontSize: 11, background: '#f3e5f5', color: '#7b1fa2', fontWeight: 500 }}>
|
|
<span style={{ fontSize: 12 }}>{fds ? getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F' : '\uD83D\uDDC3\uFE0F'}</span>
|
|
{fds?.label || fdsId}
|
|
<button onClick={() => _toggleFds(fdsId)} style={{ border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#7b1fa2', padding: 0, lineHeight: 1 }}>x</button>
|
|
</span>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
<div className={styles.textInputRow}>
|
|
<textarea
|
|
ref={inputRef}
|
|
className={styles.textInput}
|
|
placeholder={t('Nachricht eingeben')}
|
|
value={coach.inputValue}
|
|
onChange={e => coach.setInputValue(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
onDragOver={_handleInputDragOver}
|
|
onDrop={_handleInputDrop}
|
|
rows={1}
|
|
disabled={coach.isStreaming}
|
|
/>
|
|
<button
|
|
className={styles.sendBtn}
|
|
onClick={handleSend}
|
|
disabled={coach.isStreaming || !coach.inputValue.trim()}
|
|
>
|
|
{t('Senden')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|