944 lines
47 KiB
TypeScript
944 lines
47 KiB
TypeScript
/**
|
||
* CommCoach Dossier View (Main View)
|
||
*
|
||
* Unified view per context: Coaching session, Tasks, Sessions history, Scores.
|
||
* Voice first, always with text fallback.
|
||
* Files & Sources are provided via the shared UnifiedDataBar sidebar.
|
||
*/
|
||
|
||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||
import { useCommcoach } from '../../../hooks/useCommcoach';
|
||
import { type TtsEvent } from '../../../hooks/useTtsPlayback';
|
||
import { useApiRequest } from '../../../hooks/useApi';
|
||
import { useInstanceId, useMandateId } from '../../../hooks/useCurrentInstance';
|
||
import {
|
||
getDossierExportUrl, getSessionExportUrl,
|
||
getScoreHistoryApi, getPersonasApi,
|
||
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 } from '../../../components/UnifiedDataBar';
|
||
import { ProviderMultiSelect, _defaultProviderSelection } from '../../../components/ProviderSelector';
|
||
import type { ProviderSelection } from '../../../components/ProviderSelector';
|
||
import { getPageIcon } from '../../../config/pageRegistry';
|
||
import styles from './CommcoachDossierView.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;
|
||
}
|
||
|
||
type TabKey = 'coaching' | 'tasks' | 'sessions' | 'scores';
|
||
|
||
interface CommcoachDossierViewProps {
|
||
persistentInstanceId?: string;
|
||
persistentMandateId?: string;
|
||
}
|
||
|
||
export const CommcoachDossierView: React.FC<CommcoachDossierViewProps> = ({ persistentInstanceId,
|
||
persistentMandateId,
|
||
}) => {
|
||
const { t } = useLanguage();
|
||
const routeInstanceId = useInstanceId();
|
||
const routeMandateId = useMandateId();
|
||
const instanceId = persistentInstanceId || routeInstanceId;
|
||
const mandateId = persistentMandateId || routeMandateId;
|
||
const coach = useCommcoach(instanceId);
|
||
const { request } = useApiRequest();
|
||
|
||
const [activeTab, setActiveTab] = useState<TabKey>('coaching');
|
||
const [showNewContext, setShowNewContext] = useState(false);
|
||
const [newTitle, setNewTitle] = useState('');
|
||
const [newDescription, setNewDescription] = useState('');
|
||
const [newCategory, setNewCategory] = useState('custom');
|
||
const [udbCollapsed, setUdbCollapsed] = useState(false);
|
||
const [udbTab, setUdbTab] = useState<UdbTab>('files');
|
||
|
||
const [newTaskTitle, setNewTaskTitle] = useState('');
|
||
const [scoreHistory, setScoreHistory] = useState<Record<string, Array<{ score: number; createdAt?: string }>>>({});
|
||
const [personas, setPersonas] = useState<CoachingPersona[]>([]);
|
||
const [selectedPersonaId, setSelectedPersonaId] = useState<string | undefined>(undefined);
|
||
|
||
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 [showSourcePicker, setShowSourcePicker] = useState(false);
|
||
const [showFilePicker, setShowFilePicker] = useState(false);
|
||
const [showAgentActivity, setShowAgentActivity] = useState(true);
|
||
|
||
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 first context
|
||
useEffect(() => {
|
||
if (!coach.selectedContextId && coach.contexts.length > 0) {
|
||
coach.selectContext(coach.contexts[0].id, { skipSessionResume: true });
|
||
}
|
||
}, [coach.contexts, coach.selectedContextId, coach.selectContext]);
|
||
|
||
// Load scores, personas when context changes
|
||
useEffect(() => {
|
||
if (!instanceId || !coach.selectedContextId) return;
|
||
getScoreHistoryApi(request, instanceId, coach.selectedContextId)
|
||
.then(h => setScoreHistory(h))
|
||
.catch(() => {});
|
||
}, [instanceId, request, coach.selectedContextId]);
|
||
|
||
useEffect(() => {
|
||
if (!instanceId) return;
|
||
getPersonasApi(request, instanceId)
|
||
.then(p => setPersonas(p))
|
||
.catch(() => {});
|
||
}, [instanceId, request]);
|
||
|
||
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 _handleFileUploaded = () => _refreshWorkspaceAssets();
|
||
window.addEventListener('fileUploaded', _handleFileUploaded);
|
||
return () => window.removeEventListener('fileUploaded', _handleFileUploaded);
|
||
}, [_refreshWorkspaceAssets]);
|
||
|
||
useEffect(() => {
|
||
if (activeTab !== 'coaching' || !coach.session) {
|
||
voice.deactivate();
|
||
} else if (voice.state === 'idle') {
|
||
voice.activate();
|
||
}
|
||
}, [activeTab, 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 _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 handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); }
|
||
}, [handleSend]);
|
||
|
||
const handleCreateContext = useCallback(async () => {
|
||
if (!newTitle.trim()) return;
|
||
await coach.createContext(newTitle, newDescription || undefined, newCategory);
|
||
setNewTitle('');
|
||
setNewDescription('');
|
||
setNewCategory('custom');
|
||
setShowNewContext(false);
|
||
}, [newTitle, newDescription, newCategory, coach]);
|
||
|
||
const handleSelectContext = useCallback((contextId: string) => {
|
||
coach.selectContext(contextId, { skipSessionResume: true });
|
||
}, [coach]);
|
||
|
||
const handleAddTask = useCallback(async () => {
|
||
if (!newTaskTitle.trim()) return;
|
||
await coach.addTask(newTaskTitle);
|
||
setNewTaskTitle('');
|
||
}, [newTaskTitle, coach]);
|
||
|
||
if (coach.loadingContexts) {
|
||
return <div className={styles.empty}><p>{t('lade')}</p></div>;
|
||
}
|
||
|
||
return (
|
||
<div className={styles.dossierLayout}>
|
||
{/* UDB Sidebar */}
|
||
{_udbContext && (
|
||
<div className={`${styles.udbSidebar} ${udbCollapsed ? styles.udbSidebarCollapsed : ''}`}>
|
||
<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']}
|
||
/>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Main Content */}
|
||
<div className={styles.dossier}>
|
||
{/* Context Selector */}
|
||
<div className={styles.contextSelector}>
|
||
{coach.contexts.map(ctx => (
|
||
<button
|
||
key={ctx.id}
|
||
className={`${styles.contextChip} ${ctx.id === coach.selectedContextId ? styles.contextChipActive : ''}`}
|
||
onClick={() => handleSelectContext(ctx.id)}
|
||
>
|
||
<span className={styles.contextChipIcon}>{_categoryIcon(ctx.category)}</span>
|
||
{ctx.title}
|
||
</button>
|
||
))}
|
||
<button
|
||
className={styles.contextChipNew}
|
||
onClick={() => setShowNewContext(!showNewContext)}
|
||
title={t('Neues Thema')}
|
||
>
|
||
+
|
||
</button>
|
||
</div>
|
||
|
||
{/* New Context Form */}
|
||
{showNewContext && (
|
||
<div className={styles.newContextForm}>
|
||
<input
|
||
className={styles.newContextInput}
|
||
placeholder={t('Thema Titel')}
|
||
value={newTitle}
|
||
onChange={e => setNewTitle(e.target.value)}
|
||
onKeyDown={e => e.key === 'Enter' && handleCreateContext()}
|
||
autoFocus
|
||
/>
|
||
<input
|
||
className={styles.newContextInput}
|
||
placeholder={t('Beschreibung (optional)')}
|
||
value={newDescription}
|
||
onChange={e => setNewDescription(e.target.value)}
|
||
/>
|
||
<select className={styles.newContextInput} value={newCategory} onChange={e => setNewCategory(e.target.value)}>
|
||
<option value="custom">Individuell</option>
|
||
<option value="leadership">{t('Führung')}</option>
|
||
<option value="conflict">Konflikt</option>
|
||
<option value="negotiation">Verhandlung</option>
|
||
<option value="presentation">{t('Präsentation')}</option>
|
||
<option value="feedback">Feedback</option>
|
||
<option value="delegation">Delegation</option>
|
||
<option value="changeManagement">{t('Change Management')}</option>
|
||
</select>
|
||
<div className={styles.newContextActions}>
|
||
<button className={styles.btnPrimary} onClick={handleCreateContext} disabled={!newTitle.trim() || !!coach.actionLoading}>
|
||
{coach.actionLoading === 'creating' ? t('wird erstellt') : t('erstellen')}
|
||
</button>
|
||
<button className={styles.btnSecondary} onClick={() => setShowNewContext(false)}>{t('Abbrechen')}</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* No context selected */}
|
||
{!coach.selectedContextId && !showNewContext && coach.contexts.length === 0 && (
|
||
<div className={styles.empty}>
|
||
<h3>{t('Willkommen beim Kommunikationscoach')}</h3>
|
||
<p>{t('Erstelle ein Thema, um zu')}</p>
|
||
<button className={styles.btnPrimary} onClick={() => setShowNewContext(true)}>{t('Neues Thema erstellen')}</button>
|
||
</div>
|
||
)}
|
||
|
||
{coach.selectedContextId && (<>
|
||
{/* Context Header */}
|
||
<div className={styles.header}>
|
||
<div>
|
||
<h2 className={styles.title}>{coach.selectedContext?.title}</h2>
|
||
{coach.selectedContext?.description && (
|
||
<p className={styles.description}>{coach.selectedContext.description}</p>
|
||
)}
|
||
</div>
|
||
<div className={styles.headerActions}>
|
||
{instanceId && (
|
||
<>
|
||
<a className={styles.btnExport} href={getDossierExportUrl(instanceId, coach.selectedContextId, 'md')} target="_blank" rel="noopener noreferrer">{t('Export MD')}</a>
|
||
<a className={styles.btnExport} href={getDossierExportUrl(instanceId, coach.selectedContextId, 'pdf')} target="_blank" rel="noopener noreferrer">{t('Export PDF')}</a>
|
||
</>
|
||
)}
|
||
<button className={styles.btnArchive} onClick={() => coach.archiveContext(coach.selectedContextId!)} disabled={!!coach.actionLoading}>
|
||
{coach.actionLoading === 'archiving' ? t('wird archiviert') : t('archivieren')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tab Navigation */}
|
||
<div className={styles.tabs}>
|
||
{(['coaching', 'tasks', 'sessions', 'scores'] as TabKey[]).map(tab => (
|
||
<button
|
||
key={tab}
|
||
className={`${styles.tab} ${activeTab === tab ? styles.tabActive : ''}`}
|
||
onClick={() => setActiveTab(tab)}
|
||
>
|
||
{_tabLabel(tab, coach, t)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* ============================================================ */}
|
||
{/* COACHING TAB */}
|
||
{/* ============================================================ */}
|
||
{activeTab === 'coaching' && (
|
||
<div className={styles.coachingTab}>
|
||
{!coach.session ? (
|
||
<div className={styles.sessionStart}>
|
||
<p>{t('Starte eine neue Coachingsession zu')}</p>
|
||
{personas.length > 0 && (
|
||
<div className={styles.personaSelector}>
|
||
<label className={styles.personaLabel}>{t('Gesprächspartner wählen')}</label>
|
||
<div className={styles.personaGrid}>
|
||
{personas.map(p => (
|
||
<button
|
||
key={p.id}
|
||
className={`${styles.personaChip} ${selectedPersonaId === p.id ? styles.personaChipActive : ''}`}
|
||
onClick={() => setSelectedPersonaId(selectedPersonaId === p.id ? undefined : p.id)}
|
||
title={p.description}
|
||
>
|
||
<span className={styles.personaGender}>{p.gender === 'f' ? '\u2640' : p.gender === 'm' ? '\u2642' : '\u25CB'}</span>
|
||
<span>{p.label}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
<button className={styles.btnPrimary} onClick={() => coach.startSession(selectedPersonaId)} disabled={!!coach.actionLoading}>
|
||
{coach.actionLoading === 'starting'
|
||
? 'Wird gestartet...'
|
||
: selectedPersonaId && personas.find(p => p.id === selectedPersonaId)
|
||
? `Session starten mit ${personas.find(p => p.id === selectedPersonaId)!.label}`
|
||
: 'Session starten'}
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<>
|
||
{/* Session Header */}
|
||
<div className={styles.sessionHeader}>
|
||
<span className={styles.sessionLabel}>{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}>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('abschließen')}
|
||
</button>
|
||
<button className={styles.btnSmallDanger} onClick={coach.cancelSession} disabled={!!coach.actionLoading}>
|
||
{coach.actionLoading === 'cancelling' ? t('wird abgebrochen') : t('Abbrechen')}
|
||
</button>
|
||
</div>
|
||
</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 || 'Coach denkt nach'}<span className={styles.typingDots}>...</span></div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</AutoScroll>
|
||
|
||
{(coach.isStreaming || coach.agentToolCalls.length > 0) && (
|
||
<div className={styles.agentActivityPanel}>
|
||
<button
|
||
className={styles.agentActivityHeader}
|
||
onClick={() => setShowAgentActivity(prev => !prev)}
|
||
type="button"
|
||
>
|
||
<span className={styles.agentActivityTitle}>
|
||
Agent-Aktivität
|
||
{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 ? '▾' : '▸'}</span>
|
||
</button>
|
||
{showAgentActivity && (
|
||
<div className={styles.agentActivityBody}>
|
||
{coach.agentToolCalls.length === 0 ? (
|
||
<div className={styles.agentActivityEmpty}>
|
||
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 ? 'fertig' : toolCall.success === false ? 'fehler' : 'läuft'}
|
||
</span>
|
||
</div>
|
||
{toolCall.args && (
|
||
<div className={styles.agentActivityMeta}>
|
||
<strong>Args:</strong> {_formatToolPayload(toolCall.args)}
|
||
</div>
|
||
)}
|
||
{toolCall.result && (
|
||
<div className={styles.agentActivityMeta}>
|
||
<strong>Result:</strong> {toolCall.result}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Input Area */}
|
||
<div className={styles.inputArea}>
|
||
<div className={styles.voiceStatus}>
|
||
<span className={`${styles.voiceIndicator} ${voice.state === 'listening' ? styles.voiceActive : ''}`}>
|
||
{voice.muted
|
||
? 'Stumm – Mikrofon aus'
|
||
: voice.state === 'botSpeaking'
|
||
? (coach.streamingStatus || 'Coach spricht...')
|
||
: coach.isStreaming
|
||
? (coach.streamingStatus || 'Coach denkt nach...')
|
||
: voice.state === 'interrupted'
|
||
? 'Unterbrochen – Mikrofon an'
|
||
: voice.state === 'listening'
|
||
? (voice.liveTranscript ? 'Spricht...' : 'Mikrofon an – bitte sprechen')
|
||
: '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);
|
||
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,
|
||
}}>
|
||
{file?.fileName || fId}
|
||
<button onClick={() => _toggleFile(fId)} style={{ border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#1565c0', padding: 0, lineHeight: 1 }}>×</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 }}>×</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 }}>×</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}
|
||
rows={1}
|
||
disabled={coach.isStreaming}
|
||
/>
|
||
|
||
{/* File Picker */}
|
||
{wsFiles.length > 0 && (
|
||
<div style={{ position: 'relative' }}>
|
||
<button
|
||
onClick={() => { setShowFilePicker(v => !v); setShowSourcePicker(false); }}
|
||
disabled={coach.isStreaming}
|
||
title={t('Datei anhängen')}
|
||
style={{
|
||
width: 36, height: 36, borderRadius: 8,
|
||
border: `1px solid ${attachedFileIds.length ? '#1565c0' : 'var(--border-color, #ddd)'}`,
|
||
background: attachedFileIds.length ? '#e3f2fd' : 'var(--secondary-bg, #f5f5f5)',
|
||
color: attachedFileIds.length ? '#1565c0' : '#666',
|
||
cursor: coach.isStreaming ? 'not-allowed' : 'pointer',
|
||
fontSize: 15, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
opacity: coach.isStreaming ? 0.5 : 1, position: 'relative',
|
||
}}
|
||
>
|
||
+
|
||
{attachedFileIds.length > 0 && (
|
||
<span style={{ position: 'absolute', top: -4, right: -4, background: '#1565c0', color: '#fff', fontSize: 9, fontWeight: 700, borderRadius: '50%', width: 15, height: 15, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||
{attachedFileIds.length}
|
||
</span>
|
||
)}
|
||
</button>
|
||
{showFilePicker && (
|
||
<div style={{
|
||
position: 'absolute', bottom: '100%', right: 0, marginBottom: 4,
|
||
background: 'var(--bg-card, #fff)', border: '1px solid var(--border-color, #e0e0e0)',
|
||
borderRadius: 8, boxShadow: '0 -2px 8px rgba(0,0,0,0.1)', zIndex: 20,
|
||
minWidth: 220, maxHeight: 240, overflowY: 'auto',
|
||
}}>
|
||
<div style={{ padding: '6px 12px', fontSize: 11, color: '#999', fontWeight: 600, borderBottom: '1px solid #f0f0f0' }}>{t('Dateien anhängen')}</div>
|
||
{wsFiles.map(f => {
|
||
const sel = attachedFileIds.includes(f.id);
|
||
return (
|
||
<div key={f.id} onClick={() => _toggleFile(f.id)} style={{
|
||
padding: '6px 12px', cursor: 'pointer', fontSize: 12,
|
||
display: 'flex', alignItems: 'center', gap: 8,
|
||
background: sel ? '#e3f2fd' : 'transparent',
|
||
}}
|
||
onMouseEnter={e => { if (!sel) e.currentTarget.style.background = '#f5f5f5'; }}
|
||
onMouseLeave={e => { if (!sel) e.currentTarget.style.background = ''; }}
|
||
>
|
||
<span style={{ width: 14, height: 14, borderRadius: 3, border: sel ? '2px solid #1565c0' : '2px solid #ccc', background: sel ? '#1565c0' : 'transparent', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontSize: 9, fontWeight: 700, flexShrink: 0 }}>
|
||
{sel ? '✓' : ''}
|
||
</span>
|
||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.fileName}</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Source Picker */}
|
||
{(wsDataSources.length > 0 || wsFeatureDataSources.length > 0) && (
|
||
<div style={{ position: 'relative' }}>
|
||
<button
|
||
onClick={() => { setShowSourcePicker(v => !v); setShowFilePicker(false); }}
|
||
disabled={coach.isStreaming}
|
||
title={t('Datenquellen anhängen')}
|
||
style={{
|
||
width: 36, height: 36, borderRadius: 8,
|
||
border: `1px solid ${(attachedDsIds.length + attachedFdsIds.length) ? '#2e7d32' : 'var(--border-color, #ddd)'}`,
|
||
background: (attachedDsIds.length + attachedFdsIds.length) ? '#e8f5e9' : 'var(--secondary-bg, #f5f5f5)',
|
||
color: (attachedDsIds.length + attachedFdsIds.length) ? '#2e7d32' : '#666',
|
||
cursor: coach.isStreaming ? 'not-allowed' : 'pointer',
|
||
fontSize: 14, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||
opacity: coach.isStreaming ? 0.5 : 1, position: 'relative',
|
||
}}
|
||
>
|
||
🔗
|
||
{(attachedDsIds.length + attachedFdsIds.length) > 0 && (
|
||
<span style={{ position: 'absolute', top: -4, right: -4, background: '#2e7d32', color: '#fff', fontSize: 9, fontWeight: 700, borderRadius: '50%', width: 15, height: 15, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||
{attachedDsIds.length + attachedFdsIds.length}
|
||
</span>
|
||
)}
|
||
</button>
|
||
{showSourcePicker && (
|
||
<div style={{
|
||
position: 'absolute', bottom: '100%', right: 0, marginBottom: 4,
|
||
background: 'var(--bg-card, #fff)', border: '1px solid var(--border-color, #e0e0e0)',
|
||
borderRadius: 8, boxShadow: '0 -2px 8px rgba(0,0,0,0.1)', zIndex: 20,
|
||
minWidth: 240, maxHeight: 260, overflowY: 'auto',
|
||
}}>
|
||
{wsDataSources.length > 0 && (
|
||
<>
|
||
<div style={{ padding: '6px 12px', fontSize: 11, color: '#999', fontWeight: 600, borderBottom: '1px solid #f0f0f0' }}>{t('Persönliche Quellen')}</div>
|
||
{wsDataSources.map(ds => {
|
||
const sel = attachedDsIds.includes(ds.id);
|
||
return (
|
||
<div key={ds.id} onClick={() => _toggleDs(ds.id)} style={{
|
||
padding: '6px 12px', cursor: 'pointer', fontSize: 12,
|
||
display: 'flex', alignItems: 'center', gap: 8,
|
||
background: sel ? '#e8f5e9' : 'transparent',
|
||
}}
|
||
onMouseEnter={e => { if (!sel) e.currentTarget.style.background = '#f5f5f5'; }}
|
||
onMouseLeave={e => { if (!sel) e.currentTarget.style.background = ''; }}
|
||
>
|
||
<span style={{ width: 14, height: 14, borderRadius: 3, border: sel ? '2px solid #2e7d32' : '2px solid #ccc', background: sel ? '#2e7d32' : 'transparent', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontSize: 9, fontWeight: 700, flexShrink: 0 }}>
|
||
{sel ? '✓' : ''}
|
||
</span>
|
||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{ds.label || ds.path}</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</>
|
||
)}
|
||
{wsFeatureDataSources.length > 0 && (
|
||
<>
|
||
<div style={{ padding: '6px 12px', fontSize: 11, color: '#999', fontWeight: 600, borderTop: wsDataSources.length ? '1px solid #f0f0f0' : 'none', borderBottom: '1px solid #f0f0f0' }}>Feature-Datenquellen</div>
|
||
{wsFeatureDataSources.map(fds => {
|
||
const sel = attachedFdsIds.includes(fds.id);
|
||
return (
|
||
<div key={fds.id} onClick={() => _toggleFds(fds.id)} style={{
|
||
padding: '6px 12px', cursor: 'pointer', fontSize: 12,
|
||
display: 'flex', alignItems: 'center', gap: 8,
|
||
background: sel ? '#f3e5f5' : 'transparent',
|
||
}}
|
||
onMouseEnter={e => { if (!sel) e.currentTarget.style.background = '#f5f5f5'; }}
|
||
onMouseLeave={e => { if (!sel) e.currentTarget.style.background = ''; }}
|
||
>
|
||
<span style={{ width: 14, height: 14, borderRadius: 3, border: sel ? '2px solid #7b1fa2' : '2px solid #ccc', background: sel ? '#7b1fa2' : 'transparent', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontSize: 9, fontWeight: 700, flexShrink: 0 }}>
|
||
{sel ? '✓' : ''}
|
||
</span>
|
||
<span style={{ display: 'flex', alignItems: 'center', fontSize: 12, color: '#7b1fa2', flexShrink: 0 }}>{getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F'}</span>
|
||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{fds.label} – {fds.tableName}</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Provider Selector */}
|
||
<ProviderMultiSelect
|
||
selection={providerSelection}
|
||
onChange={setProviderSelection}
|
||
showLabel={false}
|
||
disabled={coach.isStreaming}
|
||
/>
|
||
|
||
<button className={styles.sendBtn} onClick={handleSend} disabled={!coach.inputValue.trim() || coach.isStreaming}>
|
||
Senden
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
{coach.error && <div className={styles.errorBanner}>{coach.error}</div>}
|
||
</div>
|
||
)}
|
||
|
||
{/* ============================================================ */}
|
||
{/* TASKS TAB */}
|
||
{/* ============================================================ */}
|
||
{activeTab === 'tasks' && (
|
||
<div className={styles.tabContent}>
|
||
<div className={styles.addTaskRow}>
|
||
<input
|
||
className={styles.addTaskInput}
|
||
placeholder={t('Neue Aufgabe')}
|
||
value={newTaskTitle}
|
||
onChange={e => setNewTaskTitle(e.target.value)}
|
||
onKeyDown={e => e.key === 'Enter' && handleAddTask()}
|
||
/>
|
||
<button className={styles.addTaskBtn} onClick={handleAddTask} disabled={!newTaskTitle.trim() || !!coach.actionLoading}>
|
||
{coach.actionLoading === 'addingTask' ? t('wird hinzugefügt') : t('hinzufügen')}
|
||
</button>
|
||
</div>
|
||
{coach.tasks.length === 0 ? (
|
||
<div className={styles.emptyTab}>{t('Noch keine Aufgaben der Coach')}</div>
|
||
) : (
|
||
<div className={styles.taskList}>
|
||
{coach.tasks.map(task => (
|
||
<div key={task.id} className={`${styles.taskItem} ${task.status === 'done' ? styles.taskDone : ''}`}>
|
||
<button className={styles.taskCheck} onClick={() => coach.toggleTaskStatus(task.id, task.status)}>
|
||
{task.status === 'done' ? '\u2713' : '\u25CB'}
|
||
</button>
|
||
<div className={styles.taskContent}>
|
||
<div className={styles.taskTitle}>{task.title}</div>
|
||
{task.description && <div className={styles.taskDesc}>{task.description}</div>}
|
||
</div>
|
||
<div className={styles.taskMeta}>
|
||
<span className={`${styles.taskPriority} ${styles[`priority_${task.priority}`]}`}>{task.priority}</span>
|
||
</div>
|
||
<button className={styles.taskDelete} onClick={() => coach.removeTask(task.id)}>x</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* ============================================================ */}
|
||
{/* SESSIONS TAB */}
|
||
{/* ============================================================ */}
|
||
{activeTab === 'sessions' && (
|
||
<div className={styles.tabContent}>
|
||
{coach.sessions.length === 0 ? (
|
||
<div className={styles.emptyTab}>{t('Noch keine abgeschlossenen Sessions')}</div>
|
||
) : (
|
||
<div className={styles.sessionTimeline}>
|
||
{coach.sessions.map(s => (
|
||
<div key={s.id} className={styles.sessionItem}>
|
||
<div className={styles.sessionItemHeader}>
|
||
<span className={`${styles.sessionStatus} ${styles[`status_${s.status}`]}`}>
|
||
{s.status === 'completed' ? 'Abgeschlossen' : s.status === 'active' ? t('aktiv') : t('Abgebrochen')}
|
||
</span>
|
||
<span className={styles.sessionDate}>{s.startedAt ? new Date(s.startedAt).toLocaleDateString('de-CH') : ''}</span>
|
||
{s.competenceScore != null && <span className={styles.sessionScore}>Score: {Math.round(s.competenceScore)}</span>}
|
||
</div>
|
||
{s.summary && (
|
||
<div className={styles.sessionSummary}><ReactMarkdown>{s.summary}</ReactMarkdown></div>
|
||
)}
|
||
<div className={styles.sessionMeta}>
|
||
{s.messageCount} Nachrichten | {Math.round(s.durationSeconds / 60)} Min.
|
||
{s.personaId && <span> {t('Persona')}</span>}
|
||
{instanceId && s.status === 'completed' && (
|
||
<a className={styles.sessionExport} href={getSessionExportUrl(instanceId, s.id, 'md')} target="_blank" rel="noopener noreferrer" onClick={e => e.stopPropagation()}>Export</a>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* ============================================================ */}
|
||
{/* SCORES TAB */}
|
||
{/* ============================================================ */}
|
||
{activeTab === 'scores' && (
|
||
<div className={styles.tabContent}>
|
||
{coach.scores.length === 0 ? (
|
||
<div className={styles.emptyTab}>{t('Noch keine Bewertungen, schließe eine')}</div>
|
||
) : (
|
||
<div className={styles.scoreList}>
|
||
{_groupScoresByDimension(coach.scores).map(group => (
|
||
<div key={group.dimension} className={styles.scoreGroup}>
|
||
<div className={styles.scoreDimension}>
|
||
<span className={styles.scoreDimensionLabel}>{_dimensionLabel(group.dimension)}</span>
|
||
<span className={styles.scoreLatest}>{Math.round(group.latest.score)}/100</span>
|
||
<span className={`${styles.scoreTrend} ${styles[`trend_${group.latest.trend}`]}`}>
|
||
{group.latest.trend === 'improving' ? 'steigend' : group.latest.trend === 'declining' ? 'sinkend' : 'stabil'}
|
||
</span>
|
||
</div>
|
||
<div className={styles.scoreBar}><div className={styles.scoreBarFill} style={{ width: `${group.latest.score}%` }} /></div>
|
||
{group.latest.evidence && <div className={styles.scoreEvidence}>{group.latest.evidence}</div>}
|
||
{scoreHistory[group.dimension] && scoreHistory[group.dimension].length > 1 && (
|
||
<div className={styles.scoreHistory}>
|
||
<div className={styles.scoreHistoryLabel}>Verlauf:</div>
|
||
<div className={styles.scoreHistoryPoints}>
|
||
{scoreHistory[group.dimension].map((entry, i) => (
|
||
<span key={i} className={styles.scoreHistoryPoint} title={entry.createdAt || ''}>{Math.round(entry.score)}</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
</>)}
|
||
|
||
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
function _categoryIcon(category: string): string {
|
||
const icons: Record<string, string> = {
|
||
leadership: 'L', conflict: 'K', negotiation: 'V',
|
||
presentation: 'P', feedback: 'F', delegation: 'D',
|
||
changeManagement: 'C', custom: '*',
|
||
};
|
||
return icons[category] || '*';
|
||
}
|
||
|
||
function _tabLabel(tab: TabKey, coach: any, t: (key: string) => string): string {
|
||
switch (tab) {
|
||
case 'coaching': return coach.session ? t('Coaching aktiv') : t('Coaching');
|
||
case 'tasks': return `${t('Aufgaben')} (${coach.tasks.length})`;
|
||
case 'sessions': return `Sessions (${coach.sessions.length})`;
|
||
case 'scores': return `${t('Bewertungen')} (${coach.scores.length})`;
|
||
}
|
||
}
|
||
|
||
interface ScoreGroup {
|
||
dimension: string;
|
||
latest: { score: number; trend: string; evidence?: string; createdAt?: string };
|
||
history: Array<{ score: number; createdAt?: string }>;
|
||
}
|
||
|
||
function _groupScoresByDimension(scores: any[]): ScoreGroup[] {
|
||
const groups: Record<string, ScoreGroup> = {};
|
||
for (const s of scores) {
|
||
const dim = s.dimension;
|
||
if (!groups[dim]) groups[dim] = { dimension: dim, latest: s, history: [] };
|
||
groups[dim].history.push({ score: s.score, createdAt: s.createdAt });
|
||
if (s.createdAt > (groups[dim].latest.createdAt || '')) groups[dim].latest = s;
|
||
}
|
||
return Object.values(groups);
|
||
}
|
||
|
||
function _dimensionLabel(dim: string): string {
|
||
const labels: Record<string, string> = {
|
||
empathy: 'Einfühlungsvermögen', clarity: 'Klarheit',
|
||
assertiveness: 'Durchsetzung', listening: 'Zuhören',
|
||
selfReflection: 'Selbstreflexion',
|
||
};
|
||
return labels[dim] || dim;
|
||
}
|
||
|
||
function _formatToolPayload(payload: Record<string, unknown>): string {
|
||
try {
|
||
const serialized = JSON.stringify(payload);
|
||
if (!serialized) return '';
|
||
return serialized.length > 180 ? `${serialized.slice(0, 177)}...` : serialized;
|
||
} catch {
|
||
return '[unlesbar]';
|
||
}
|
||
}
|
||
|
||
export default CommcoachDossierView;
|