fixed stt procedure

This commit is contained in:
ValueOn AG 2026-03-05 23:41:41 +01:00
parent 36b8558dd0
commit 251a9ca1ea
7 changed files with 1051 additions and 494 deletions

View file

@ -58,6 +58,7 @@ export interface CoachingDocument {
fileSize: number;
extractedText?: string;
summary?: string;
fileRef?: string;
createdAt?: string;
}
@ -550,6 +551,11 @@ export async function uploadDocumentApi(instanceId: string, contextId: string, f
const headers: Record<string, string> = {};
const authToken = localStorage.getItem('authToken');
if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
const pathMatch = window.location.pathname.match(/^\/mandates\/([^/]+)\/([^/]+)\/([^/]+)/);
if (pathMatch) {
headers['X-Mandate-Id'] = pathMatch[1];
headers['X-Instance-Id'] = pathMatch[3];
}
if (!getCSRFToken()) generateAndStoreCSRFToken();
addCSRFTokenToHeaders(headers);

View file

@ -36,7 +36,7 @@ export interface CommcoachHookReturn {
inputValue: string;
setInputValue: (v: string) => void;
selectContext: (contextId: string) => Promise<void>;
selectContext: (contextId: string, options?: { skipSessionResume?: boolean }) => Promise<void>;
createContext: (title: string, description?: string, category?: string, goals?: string[]) => Promise<void>;
archiveContext: (contextId: string) => Promise<void>;
@ -59,6 +59,8 @@ export interface CommcoachHookReturn {
addTask: (title: string, description?: string) => Promise<void>;
removeTask: (taskId: string) => Promise<void>;
onDocumentCreatedRef: MutableRefObject<((doc: any) => void) | null>;
refreshContexts: () => Promise<void>;
}
@ -92,6 +94,7 @@ export function useCommcoach(): CommcoachHookReturn {
const currentAudioRef = useRef<HTMLAudioElement | null>(null);
const isTtsPlayingRef = useRef(false);
const lastTtsAudioRef = useRef<string | null>(null);
const onDocumentCreatedRef = useRef<((doc: any) => void) | null>(null);
useEffect(() => { isMountedRef.current = true; return () => { isMountedRef.current = false; }; }, []);
@ -134,7 +137,6 @@ export function useCommcoach(): CommcoachHookReturn {
const stopTts = useCallback(() => {
if (currentAudioRef.current) {
currentAudioRef.current.pause();
currentAudioRef.current = null;
}
if (isTtsPlayingRef.current) {
setWasInterrupted(true);
@ -143,12 +145,14 @@ export function useCommcoach(): CommcoachHookReturn {
}, []);
const resumeTts = useCallback(() => {
if (lastTtsAudioRef.current) {
_playTtsAudio(lastTtsAudioRef.current);
if (currentAudioRef.current && currentAudioRef.current.paused) {
isTtsPlayingRef.current = true;
setWasInterrupted(false);
currentAudioRef.current.play().catch(() => { isTtsPlayingRef.current = false; });
}
}, [_playTtsAudio]);
}, []);
const selectContext = useCallback(async (contextId: string) => {
const selectContext = useCallback(async (contextId: string, options?: { skipSessionResume?: boolean }) => {
if (!instanceId) return;
setSelectedContextId(contextId);
setError(null);
@ -160,6 +164,12 @@ export function useCommcoach(): CommcoachHookReturn {
setScores(detail.scores || []);
setSessions(detail.sessions || []);
if (options?.skipSessionResume) {
setSession(null);
setMessages([]);
return;
}
const activeSession = detail.sessions?.find((s: CoachingSession) => s.status === 'active');
if (activeSession) {
await _unlockAudioForTts();
@ -285,6 +295,10 @@ export function useCommcoach(): CommcoachHookReturn {
_playTtsAudio(eventData.audio);
} else if (eventType === 'status' && eventData) {
setStreamingStatus(eventData.label || null);
} else if (eventType === 'taskCreated' && eventData) {
setTasks(prev => [eventData, ...prev]);
} else if (eventType === 'documentCreated' && eventData) {
onDocumentCreatedRef.current?.(eventData);
} else if (eventType === 'error' && eventData) {
setError(eventData.message || 'Stream-Fehler');
}
@ -316,7 +330,9 @@ export function useCommcoach(): CommcoachHookReturn {
}, [instanceId, selectedContextId, _playTtsAudio]);
const sendMessage = useCallback(async (content: string) => {
if (!content.trim() || isStreaming || !instanceId || !session) return;
const normalizedContent = content.trim();
if (!normalizedContent || !instanceId || !session) return;
stopTts();
await _unlockAudioForTts();
setError(null);
setIsStreaming(true);
@ -327,7 +343,7 @@ export function useCommcoach(): CommcoachHookReturn {
sessionId: session.id,
contextId: session.contextId,
role: 'user',
content: content.trim(),
content: normalizedContent,
contentType: 'text',
createdAt: new Date().toISOString(),
};
@ -338,7 +354,7 @@ export function useCommcoach(): CommcoachHookReturn {
await sendMessageStreamApi(
instanceId,
session.id,
content,
normalizedContent,
(event: SSEEvent) => {
if (!isMountedRef.current) return;
const eventType = event.type;
@ -371,6 +387,8 @@ export function useCommcoach(): CommcoachHookReturn {
setStreamingStatus(eventData.label || null);
} else if (eventType === 'taskCreated' && eventData) {
setTasks(prev => [eventData, ...prev]);
} else if (eventType === 'documentCreated' && eventData) {
onDocumentCreatedRef.current?.(eventData);
} else if (eventType === 'scoreUpdate') {
// Will refresh on complete
} else if (eventType === 'error' && eventData) {
@ -398,7 +416,7 @@ export function useCommcoach(): CommcoachHookReturn {
setIsStreaming(false);
}
}
}, [isStreaming, instanceId, session, _playTtsAudio]);
}, [instanceId, session, _playTtsAudio, stopTts]);
const sendAudio = useCallback(async (audioBlob: Blob) => {
if (!instanceId || !session) return;
@ -437,6 +455,10 @@ export function useCommcoach(): CommcoachHookReturn {
} else if (eventType === 'ttsAudio' && eventData?.audio) {
setError(null);
_playTtsAudio(eventData.audio);
} else if (eventType === 'taskCreated' && eventData) {
setTasks(prev => [eventData, ...prev]);
} else if (eventType === 'documentCreated' && eventData) {
onDocumentCreatedRef.current?.(eventData);
} else if (eventType === 'error' && eventData) {
setError(eventData.message || 'Audio-Fehler');
}
@ -546,6 +568,7 @@ export function useCommcoach(): CommcoachHookReturn {
isMuted, setMuted: setIsMuted, stopTts, resumeTts, wasInterrupted, isTtsPlayingRef,
actionLoading,
toggleTaskStatus, addTask, removeTask,
onDocumentCreatedRef,
refreshContexts,
};
}

View file

@ -45,7 +45,7 @@ import { TeamsbotSettingsView } from './views/teamsbot/TeamsbotSettingsView';
import { NeutralizationView } from './views/neutralization';
// CommCoach Views
import { CommcoachDashboardView, CommcoachCoachingView, CommcoachDossierView, CommcoachSettingsView } from './views/commcoach';
import { CommcoachDashboardView, CommcoachDossierView, CommcoachSettingsView } from './views/commcoach';
import styles from './FeatureView.module.css';
@ -148,7 +148,7 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
},
commcoach: {
dashboard: CommcoachDashboardView,
coaching: CommcoachCoachingView,
coaching: CommcoachDossierView,
dossier: CommcoachDossierView,
settings: CommcoachSettingsView,
},

View file

@ -148,14 +148,23 @@ export const CommcoachCoachingView: React.FC = () => {
const SILENCE_TIMEOUT_MS = 5000;
const _sendAndClearTranscript = () => {
const fullTranscript = transcriptPartsRef.current.join(' ').trim();
if (fullTranscript) {
const wordCount = fullTranscript.split(/\s+/).filter(Boolean).length;
if (wordCount >= MIN_WORDS_TO_INTERRUPT) coach.sendMessage(fullTranscript);
}
transcriptPartsRef.current = [];
processedResultIndexRef.current = 0;
setLiveTranscript('');
setIsUserSpeaking(false);
};
const _resetSilenceTimer = () => {
if (silenceTimerRef.current) clearTimeout(silenceTimerRef.current);
silenceTimerRef.current = setTimeout(() => {
if (cancelled) return;
setIsUserSpeaking(false);
transcriptPartsRef.current = [];
processedResultIndexRef.current = 0;
setLiveTranscript('');
_sendAndClearTranscript();
}, SILENCE_TIMEOUT_MS);
};
@ -170,7 +179,7 @@ export const CommcoachCoachingView: React.FC = () => {
recognition.onresult = (event: SpeechRecognitionEvent) => {
if (cancelled || coach.isTtsPlayingRef.current) return;
let currentInterim = '';
const interimParts: string[] = [];
for (let i = processedResultIndexRef.current; i < event.results.length; i++) {
const r = event.results[i];
if (r.isFinal) {
@ -178,9 +187,11 @@ export const CommcoachCoachingView: React.FC = () => {
if (text) transcriptPartsRef.current.push(text);
processedResultIndexRef.current = i + 1;
} else {
currentInterim = r[0].transcript.trim();
const text = r[0].transcript.trim();
if (text) interimParts.push(text);
}
}
const currentInterim = interimParts.join(' ');
const preview = [...transcriptPartsRef.current, currentInterim].join(' ').trim();
setLiveTranscript(preview);
if (preview) _resetSilenceTimer();
@ -198,15 +209,7 @@ export const CommcoachCoachingView: React.FC = () => {
setIsUserSpeaking(false);
return;
}
const fullTranscript = transcriptPartsRef.current.join(' ').trim();
if (fullTranscript) {
const wordCount = fullTranscript.split(/\s+/).filter(Boolean).length;
if (wordCount >= MIN_WORDS_TO_INTERRUPT) coach.sendMessage(fullTranscript);
}
transcriptPartsRef.current = [];
processedResultIndexRef.current = 0;
setLiveTranscript('');
setIsUserSpeaking(false);
_sendAndClearTranscript();
};
recognition.onend = () => {
@ -238,6 +241,7 @@ export const CommcoachCoachingView: React.FC = () => {
init();
return () => {
cancelled = true;
coach.stopTts();
if (silenceTimerRef.current) clearTimeout(silenceTimerRef.current);
if (speechRecognitionRef.current) {
try {

View file

@ -1,16 +1,25 @@
.dossier {
padding: 1rem;
max-width: 900px;
display: flex;
flex-direction: column;
height: calc(100vh - 140px);
overflow: hidden;
}
/* Context Selector */
.contextSelector {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1.25rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color, #e0e0e0);
flex-shrink: 0;
align-items: center;
}
.contextChip {
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.4rem 0.9rem;
background: var(--bg-card, #fff);
border: 1px solid var(--border-color, #ddd);
@ -19,6 +28,7 @@
font-size: 0.85rem;
color: var(--text-primary, #333);
transition: all 0.15s;
white-space: nowrap;
}
.contextChip:hover {
@ -32,21 +42,79 @@
border-color: var(--primary-color, #F25843);
}
.contextChipActive:hover {
color: #fff;
.contextChipActive:hover { color: #fff; }
.contextChipIcon {
font-weight: 700;
font-size: 0.75rem;
}
.contextChipNew {
width: 32px;
height: 32px;
border: 1px dashed var(--border-color, #ccc);
border-radius: 50%;
background: transparent;
cursor: pointer;
font-size: 1.2rem;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary, #888);
flex-shrink: 0;
}
.contextChipNew:hover {
background: var(--bg-hover, #f5f5f5);
color: var(--primary-color, #F25843);
}
/* New Context Form */
.newContextForm {
padding: 1rem;
background: var(--bg-card, #fff);
border-bottom: 1px solid var(--border-color, #e0e0e0);
display: flex;
flex-direction: column;
gap: 0.5rem;
flex-shrink: 0;
}
.newContextInput {
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-color, #ddd);
border-radius: 6px;
font-size: 0.9rem;
background: var(--bg-input, #fff);
color: var(--text-primary, #333);
}
.newContextActions {
display: flex;
gap: 0.5rem;
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
padding: 3rem;
text-align: center;
color: var(--text-secondary, #666);
}
.empty h3 { color: var(--text-primary, #333); margin-bottom: 0.5rem; }
.empty p { margin-bottom: 1rem; }
/* Header */
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1.5rem;
padding: 0.75rem 1rem;
flex-shrink: 0;
}
.title {
@ -62,6 +130,53 @@
margin: 0;
}
.headerActions {
display: flex;
gap: 0.5rem;
align-items: center;
}
/* Buttons */
.btnPrimary {
padding: 0.5rem 1.25rem;
background: var(--primary-color, #F25843);
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
font-weight: 500;
}
.btnPrimary:hover:not(:disabled) { filter: brightness(1.08); }
.btnPrimary:disabled { background: var(--color-medium-gray, #ccc); color: var(--text-secondary, #888); cursor: not-allowed; opacity: 0.8; }
.btnSecondary {
padding: 0.5rem 1.25rem;
background: transparent;
color: var(--text-primary, #333);
border: 1px solid var(--border-color, #ddd);
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
}
.btnSecondary:hover:not(:disabled) { background: var(--hover-bg, #f5f5f5); border-color: var(--primary-color, #F25843); color: var(--primary-color, #F25843); }
.btnExport {
padding: 0.4rem 0.75rem;
background: transparent;
border: 1px solid var(--border-color, #ddd);
border-radius: 6px;
cursor: pointer;
font-size: 0.8rem;
color: var(--text-primary, #333);
text-decoration: none;
display: inline-block;
}
.btnExport:hover { border-color: var(--primary-color, #F25843); color: var(--primary-color, #F25843); }
.btnArchive {
padding: 0.4rem 0.75rem;
background: transparent;
@ -72,17 +187,41 @@
color: var(--text-primary, #333);
}
.btnArchive:hover:not(:disabled) {
color: var(--error-color, #dc2626);
border-color: var(--error-color, #dc2626);
.btnArchive:hover:not(:disabled) { color: var(--error-color, #dc2626); border-color: var(--error-color, #dc2626); }
.btnSmall {
padding: 0.3rem 0.75rem;
background: var(--primary-color, #F25843);
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.8rem;
}
.btnSmall:hover:not(:disabled) { filter: brightness(1.08); }
.btnSmallDanger {
padding: 0.3rem 0.75rem;
background: transparent;
color: var(--error-color, #dc2626);
border: 1px solid var(--error-color, #dc2626);
border-radius: 4px;
cursor: pointer;
font-size: 0.8rem;
}
.btnSmallDanger:hover:not(:disabled) { background: var(--error-color, #dc2626); color: #fff; }
.mutedActive { background: var(--color-medium-gray, #999); color: #fff; border-color: var(--color-medium-gray, #999); }
/* Tabs */
.tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border-color, #e0e0e0);
margin-bottom: 1rem;
flex-shrink: 0;
padding: 0 1rem;
}
.tab {
@ -104,22 +243,177 @@
border-bottom-color: var(--primary-color, #F25843);
}
.tabContent { min-height: 200px; }
.emptyTab { text-align: center; padding: 2rem; color: var(--text-secondary, #888); }
/* Tasks */
.addTaskRow {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
.tabContent {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
.addTaskInput {
.emptyTab { text-align: center; padding: 2rem; color: var(--text-secondary, #888); }
/* ============================================================ */
/* COACHING TAB */
/* ============================================================ */
.coachingTab {
flex: 1;
padding: 0.5rem 0.75rem;
display: flex;
flex-direction: column;
overflow: hidden;
}
.sessionStart {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
text-align: center;
padding: 2rem;
}
.sessionStart p { color: var(--text-secondary, #666); margin-bottom: 1rem; }
.personaSelector { margin-bottom: 1rem; }
.personaLabel { font-size: 0.85rem; font-weight: 500; color: var(--text-primary, #333); display: block; margin-bottom: 0.5rem; }
.personaGrid { display: flex; flex-wrap: wrap; gap: 0.5rem; justify-content: center; }
.personaChip {
display: flex; align-items: center; gap: 0.3rem;
padding: 0.4rem 0.8rem;
border: 1px solid var(--border-color, #ddd);
border-radius: 6px;
border-radius: 20px; background: var(--bg-card, #fff);
cursor: pointer; font-size: 0.8rem;
color: var(--text-primary, #333); transition: all 0.15s;
}
.personaChip:hover { border-color: var(--primary-color, #F25843); color: var(--primary-color, #F25843); }
.personaChipActive { background: var(--primary-color, #F25843); color: #fff; border-color: var(--primary-color, #F25843); }
.personaGender { font-size: 1rem; }
.sessionHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 1rem;
background: var(--bg-card, #fff);
border-bottom: 1px solid var(--border-color, #e0e0e0);
flex-shrink: 0;
}
.sessionLabel { font-size: 0.85rem; font-weight: 500; color: var(--text-primary, #333); }
.sessionActions { display: flex; gap: 0.5rem; }
/* Messages */
.messages {
flex: 1;
padding: 1rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.message { max-width: 80%; }
.messageUser { align-self: flex-end; }
.messageAssistant { align-self: flex-start; }
.messageBubble {
padding: 0.75rem 1rem;
border-radius: 12px;
font-size: 0.9rem;
line-height: 1.5;
}
.messageUser .messageBubble {
background: var(--primary-color, #F25843);
color: #fff;
border-bottom-right-radius: 4px;
}
.messageLive {
opacity: 0.7;
font-style: italic;
border: 1px dashed rgba(255, 255, 255, 0.4);
}
.messageAssistant .messageBubble {
background: var(--bg-card, #f5f5f5);
color: var(--text-primary, #333);
border: 1px solid var(--border-color, #e0e0e0);
border-bottom-left-radius: 4px;
}
.messageBubble p { margin: 0 0 0.5rem; }
.messageBubble p:last-child { margin-bottom: 0; }
.messageTime {
font-size: 0.7rem;
color: var(--text-secondary, #999);
margin-top: 0.2rem;
padding: 0 0.25rem;
}
.messageUser .messageTime { text-align: right; }
.typing { color: var(--text-secondary, #888); font-style: italic; }
.typingDots { animation: blink 1.4s infinite both; }
@keyframes blink { 0%, 80%, 100% { opacity: 0; } 40% { opacity: 1; } }
/* Input Area */
.inputArea {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-top: 1px solid var(--border-color, #e0e0e0);
background: var(--bg-card, #fff);
flex-shrink: 0;
}
.textInputRow { display: flex; gap: 0.5rem; align-items: flex-end; }
.textInput {
flex: 1; min-width: 0;
padding: 0.6rem 0.75rem;
border: 1px solid var(--border-color, #ddd);
border-radius: 8px; resize: none;
font-size: 0.9rem; font-family: inherit;
min-height: 40px; max-height: 120px;
background: var(--bg-input, #fff);
color: var(--text-primary, #333);
}
.sendBtn {
padding: 0.6rem 1.25rem;
background: var(--primary-color, #F25843);
color: #fff; border: none; border-radius: 8px;
cursor: pointer; font-size: 0.85rem; font-weight: 500;
align-self: flex-end;
}
.sendBtn:hover:not(:disabled) { filter: brightness(1.08); }
.sendBtn:disabled { background: var(--color-medium-gray, #ccc); color: var(--text-secondary, #888); cursor: not-allowed; opacity: 0.8; }
.voiceStatus { display: flex; align-items: center; padding: 0.25rem 0; min-height: 1.5rem; }
.voiceIndicator { font-size: 0.9rem; color: var(--text-secondary, #888); }
.voiceIndicator.voiceActive { color: var(--primary-color, #F25843); font-weight: 500; }
.errorBanner {
padding: 0.5rem 1rem;
background: #fde8e8;
color: var(--color-error, #d32f2f);
font-size: 0.85rem;
text-align: center;
flex-shrink: 0;
}
/* ============================================================ */
/* TASKS */
/* ============================================================ */
.addTaskRow { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
.addTaskInput {
flex: 1; padding: 0.5rem 0.75rem;
border: 1px solid var(--border-color, #ddd);
border-radius: 6px; font-size: 0.9rem;
background: var(--bg-input, #fff);
color: var(--text-primary, #333);
}
@ -127,27 +421,17 @@
.addTaskBtn {
padding: 0.5rem 1rem;
background: var(--primary-color, #F25843);
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
color: #fff; border: none; border-radius: 6px;
cursor: pointer; font-size: 0.85rem;
}
.addTaskBtn:hover:not(:disabled) { filter: brightness(1.08); }
.addTaskBtn:disabled {
background: var(--color-medium-gray, #ccc);
color: var(--text-secondary, #888);
cursor: not-allowed;
opacity: 0.8;
}
.addTaskBtn:disabled { background: var(--color-medium-gray, #ccc); color: var(--text-secondary, #888); cursor: not-allowed; opacity: 0.8; }
.taskList { display: flex; flex-direction: column; gap: 0.5rem; }
.taskItem {
display: flex;
align-items: flex-start;
gap: 0.75rem;
display: flex; align-items: flex-start; gap: 0.75rem;
padding: 0.75rem;
background: var(--bg-card, #fff);
border: 1px solid var(--border-color, #e0e0e0);
@ -158,72 +442,44 @@
.taskDone .taskTitle { text-decoration: line-through; }
.taskCheck {
width: 28px;
height: 28px;
width: 28px; height: 28px;
border: 2px solid var(--border-color, #ccc);
border-radius: 50%;
background: transparent;
cursor: pointer;
font-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--primary-color, #F25843);
border-radius: 50%; background: transparent;
cursor: pointer; font-size: 1rem;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0; color: var(--primary-color, #F25843);
}
.taskContent { flex: 1; }
.taskTitle { font-size: 0.9rem; font-weight: 500; color: var(--text-primary, #333); }
.taskDesc { font-size: 0.8rem; color: var(--text-secondary, #666); margin-top: 0.2rem; }
.taskMeta { font-size: 0.75rem; }
.taskPriority {
padding: 0.15rem 0.4rem;
border-radius: 4px;
font-size: 0.7rem;
text-transform: uppercase;
}
.taskPriority { padding: 0.15rem 0.4rem; border-radius: 4px; font-size: 0.7rem; text-transform: uppercase; }
.priority_high { background: #fde8e8; color: #c62828; }
.priority_medium { background: #fff3e0; color: #e65100; }
.priority_low { background: #e8f5e9; color: #2e7d32; }
.taskDelete {
background: transparent;
border: none;
cursor: pointer;
color: var(--text-secondary, #aaa);
font-size: 0.9rem;
padding: 0.2rem;
background: transparent; border: none; cursor: pointer;
color: var(--text-secondary, #aaa); font-size: 0.9rem; padding: 0.2rem;
}
.taskDelete:hover { color: var(--error-color, #dc2626); }
/* Sessions */
/* ============================================================ */
/* SESSIONS */
/* ============================================================ */
.sessionTimeline { display: flex; flex-direction: column; gap: 1rem; }
.sessionItem {
background: var(--bg-card, #fff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
padding: 1rem;
border-radius: 8px; padding: 1rem;
}
.sessionItemHeader {
display: flex;
gap: 0.75rem;
align-items: center;
margin-bottom: 0.5rem;
}
.sessionStatus {
padding: 0.15rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
}
.sessionItemHeader { display: flex; gap: 0.75rem; align-items: center; margin-bottom: 0.5rem; }
.sessionStatus { padding: 0.15rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: 500; }
.status_completed { background: #e8f5e9; color: #2e7d32; }
.status_active { background: #e3f2fd; color: #1565c0; }
.status_cancelled { background: #fde8e8; color: #c62828; }
@ -231,34 +487,26 @@
.sessionDate { font-size: 0.8rem; color: var(--text-secondary, #666); }
.sessionScore { font-size: 0.8rem; font-weight: 600; color: var(--primary-color, #F25843); }
.sessionSummary {
font-size: 0.85rem;
line-height: 1.5;
color: var(--text-primary, #333);
margin-bottom: 0.5rem;
}
.sessionSummary { font-size: 0.85rem; line-height: 1.5; color: var(--text-primary, #333); margin-bottom: 0.5rem; }
.sessionSummary p { margin: 0 0 0.4rem; }
.sessionMeta { font-size: 0.75rem; color: var(--text-secondary, #888); }
/* Scores */
.sessionExport { margin-left: 0.5rem; font-size: 0.75rem; color: var(--primary-color, #F25843); text-decoration: none; }
.sessionExport:hover { text-decoration: underline; }
/* ============================================================ */
/* SCORES */
/* ============================================================ */
.scoreList { display: flex; flex-direction: column; gap: 1rem; }
.scoreGroup {
background: var(--bg-card, #fff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
padding: 1rem;
}
.scoreDimension {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.5rem;
border-radius: 8px; padding: 1rem;
}
.scoreDimension { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.5rem; }
.scoreDimensionLabel { font-weight: 600; font-size: 0.9rem; flex: 1; }
.scoreLatest { font-weight: 700; font-size: 1rem; color: var(--primary-color, #F25843); }
@ -267,113 +515,33 @@
.trend_stable { color: #e65100; }
.trend_declining { color: #c62828; }
.scoreBar {
height: 6px;
background: var(--bg-hover, #eee);
border-radius: 3px;
overflow: hidden;
margin-bottom: 0.5rem;
}
.scoreBar { height: 6px; background: var(--bg-hover, #eee); border-radius: 3px; overflow: hidden; margin-bottom: 0.5rem; }
.scoreBarFill { height: 100%; background: var(--primary-color, #F25843); border-radius: 3px; transition: width 0.3s; }
.scoreBarFill {
height: 100%;
background: var(--primary-color, #F25843);
border-radius: 3px;
transition: width 0.3s;
}
.scoreEvidence { font-size: 0.8rem; color: var(--text-secondary, #666); line-height: 1.4; }
.scoreEvidence {
font-size: 0.8rem;
color: var(--text-secondary, #666);
line-height: 1.4;
}
.scoreHistory { margin-top: 0.5rem; display: flex; align-items: center; gap: 0.5rem; }
.scoreHistoryLabel { font-size: 0.75rem; color: var(--text-secondary, #888); }
.scoreHistoryPoints { display: flex; gap: 0.4rem; flex-wrap: wrap; }
.scoreHistoryPoint { padding: 0.15rem 0.4rem; background: var(--bg-hover, #f0f0f0); border-radius: 4px; font-size: 0.7rem; color: var(--text-secondary, #666); }
/* Score History */
.scoreHistory {
margin-top: 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.scoreHistoryLabel {
font-size: 0.75rem;
color: var(--text-secondary, #888);
}
.scoreHistoryPoints {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.scoreHistoryPoint {
padding: 0.15rem 0.4rem;
background: var(--bg-hover, #f0f0f0);
border-radius: 4px;
font-size: 0.7rem;
color: var(--text-secondary, #666);
}
/* Export Button */
.btnExport {
padding: 0.4rem 0.75rem;
background: transparent;
border: 1px solid var(--border-color, #ddd);
border-radius: 6px;
cursor: pointer;
font-size: 0.8rem;
color: var(--text-primary, #333);
text-decoration: none;
display: inline-block;
}
.btnExport:hover {
border-color: var(--primary-color, #F25843);
color: var(--primary-color, #F25843);
}
.headerActions {
display: flex;
gap: 0.5rem;
align-items: center;
}
/* Session Export */
.sessionExport {
margin-left: 0.5rem;
font-size: 0.75rem;
color: var(--primary-color, #F25843);
text-decoration: none;
}
.sessionExport:hover {
text-decoration: underline;
}
/* Documents */
/* ============================================================ */
/* DOCUMENTS */
/* ============================================================ */
.uploadLabel {
padding: 0.5rem 1rem;
background: var(--primary-color, #F25843);
color: #fff;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
color: #fff; border-radius: 6px;
cursor: pointer; font-size: 0.85rem;
display: inline-block;
}
.uploadLabel:hover { filter: brightness(1.08); }
.documentList {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.documentList { display: flex; flex-direction: column; gap: 0.5rem; }
.documentItem {
display: flex;
align-items: flex-start;
gap: 0.75rem;
display: flex; align-items: flex-start; gap: 0.75rem;
padding: 0.75rem;
background: var(--bg-card, #fff);
border: 1px solid var(--border-color, #e0e0e0);
@ -381,22 +549,7 @@
}
.documentInfo { flex: 1; }
.documentName {
font-size: 0.9rem;
font-weight: 500;
color: var(--text-primary, #333);
}
.documentMeta {
font-size: 0.75rem;
color: var(--text-secondary, #888);
margin-top: 0.2rem;
}
.documentSummary {
font-size: 0.8rem;
color: var(--text-secondary, #666);
margin-top: 0.4rem;
line-height: 1.4;
}
.documentName { font-size: 0.9rem; font-weight: 500; color: var(--text-primary, #333); }
.documentMeta { font-size: 0.75rem; color: var(--text-secondary, #888); margin-top: 0.2rem; }
.documentSummary { font-size: 0.8rem; color: var(--text-secondary, #666); margin-top: 0.4rem; line-height: 1.4; }
.documentActions { display: flex; gap: 0.5rem; align-items: center; flex-shrink: 0; }

View file

@ -1,38 +1,65 @@
/**
* CommCoach Dossier View
* CommCoach Dossier View (Main View)
*
* Shows context detail: sessions timeline, tasks checklist, scores, insights.
* Unified view per context: Coaching session, Tasks, Sessions history, Scores, Documents.
* Voice first, always with text fallback.
*/
import React, { useState, useCallback, useEffect } from 'react';
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { useCommcoach } from '../../../hooks/useCommcoach';
import { useApiRequest } from '../../../hooks/useApi';
import { useInstanceId } from '../../../hooks/useCurrentInstance';
import api from '../../../api';
import {
getDossierExportUrl, getSessionExportUrl,
getDocumentsApi, uploadDocumentApi, deleteDocumentApi,
getScoreHistoryApi,
type CoachingDocument,
getScoreHistoryApi, getPersonasApi,
type CoachingDocument, type CoachingPersona,
} from '../../../api/commcoachApi';
import AutoScroll from '../../../components/UiComponents/AutoScroll/AutoScroll';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import styles from './CommcoachDossierView.module.css';
type TabKey = 'coaching' | 'tasks' | 'sessions' | 'scores' | 'documents';
export const CommcoachDossierView: React.FC = () => {
const coach = useCommcoach();
const { request } = useApiRequest();
const instanceId = useInstanceId();
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 [newTaskTitle, setNewTaskTitle] = useState('');
const [activeTab, setActiveTab] = useState<'sessions' | 'tasks' | 'scores' | 'documents'>('tasks');
const [documents, setDocuments] = useState<CoachingDocument[]>([]);
const [uploading, setUploading] = useState(false);
const [scoreHistory, setScoreHistory] = useState<Record<string, Array<{ score: number; createdAt?: string }>>>({});
const [personas, setPersonas] = useState<CoachingPersona[]>([]);
const [selectedPersonaId, setSelectedPersonaId] = useState<string | undefined>(undefined);
const inputRef = useRef<HTMLTextAreaElement>(null);
const streamRef = useRef<MediaStream | null>(null);
const speechRecognitionRef = useRef<SpeechRecognition | null>(null);
const transcriptPartsRef = useRef<string[]>([]);
const processedResultIndexRef = useRef(0);
const silenceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [isListening, setIsListening] = useState(false);
const [isUserSpeaking, setIsUserSpeaking] = useState(false);
const [liveTranscript, setLiveTranscript] = useState('');
const [isTtsPlaying, setIsTtsPlaying] = useState(false);
// Auto-select first context
useEffect(() => {
if (!coach.selectedContextId && coach.contexts.length > 0) {
coach.selectContext(coach.contexts[0].id);
coach.selectContext(coach.contexts[0].id, { skipSessionResume: true });
}
}, [coach.contexts, coach.selectedContextId, coach.selectContext]);
// Load documents, scores, personas when context changes
useEffect(() => {
if (!instanceId || !coach.selectedContextId) return;
getDocumentsApi(request, instanceId, coach.selectedContextId)
@ -43,6 +70,208 @@ export const CommcoachDossierView: React.FC = () => {
.catch(() => {});
}, [instanceId, request, coach.selectedContextId]);
useEffect(() => {
coach.onDocumentCreatedRef.current = (doc) => {
setDocuments(prev => {
if (prev.some(d => d.id === doc.id)) return prev;
return [doc, ...prev];
});
};
return () => { coach.onDocumentCreatedRef.current = null; };
}, [coach.onDocumentCreatedRef]);
useEffect(() => {
if (!instanceId) return;
getPersonasApi(request, instanceId)
.then(p => setPersonas(p))
.catch(() => {});
}, [instanceId, request]);
// TTS playing state sync
useEffect(() => {
if (!coach.session) return;
const interval = setInterval(() => {
setIsTtsPlaying(coach.isTtsPlayingRef.current);
}, 200);
return () => clearInterval(interval);
}, [coach.session, coach.isTtsPlayingRef]);
// Speech Recognition (only when coaching tab active + session running + not muted)
useEffect(() => {
if (activeTab !== 'coaching' || !coach.session || coach.isMuted) {
if (speechRecognitionRef.current) {
try { speechRecognitionRef.current.stop(); } catch { /* ignore */ }
speechRecognitionRef.current = null;
}
if (streamRef.current) {
streamRef.current.getTracks().forEach(t => t.stop());
streamRef.current = null;
}
setIsListening(false);
setIsUserSpeaking(false);
return;
}
const SpeechRecognitionApi = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
if (!SpeechRecognitionApi) return;
let cancelled = false;
const MIN_WORDS_TO_INTERRUPT = 4;
const init = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true },
});
if (cancelled) { stream.getTracks().forEach(t => t.stop()); return; }
streamRef.current = stream;
setIsListening(true);
const recognition = new SpeechRecognitionApi();
recognition.continuous = true;
recognition.interimResults = true;
recognition.lang = 'de-DE';
const SILENCE_TIMEOUT_MS = 1500;
const _sendAndClearTranscript = () => {
const fullTranscript = transcriptPartsRef.current.join(' ').trim();
if (fullTranscript) {
const wordCount = fullTranscript.split(/\s+/).filter(Boolean).length;
if (wordCount >= MIN_WORDS_TO_INTERRUPT) coach.sendMessage(fullTranscript);
}
transcriptPartsRef.current = [];
processedResultIndexRef.current = 0;
setLiveTranscript('');
setIsUserSpeaking(false);
};
const _resetSilenceTimer = () => {
if (silenceTimerRef.current) clearTimeout(silenceTimerRef.current);
silenceTimerRef.current = setTimeout(() => {
if (cancelled) return;
_sendAndClearTranscript();
}, SILENCE_TIMEOUT_MS);
};
recognition.onspeechstart = () => {
if (cancelled || coach.isTtsPlayingRef.current) return;
setIsUserSpeaking(true);
transcriptPartsRef.current = [];
processedResultIndexRef.current = 0;
setLiveTranscript('');
_resetSilenceTimer();
};
recognition.onresult = (event: SpeechRecognitionEvent) => {
if (cancelled) return;
const interimParts: string[] = [];
for (let i = processedResultIndexRef.current; i < event.results.length; i++) {
const r = event.results[i];
if (r.isFinal) {
const text = r[0].transcript.trim();
if (text) transcriptPartsRef.current.push(text);
processedResultIndexRef.current = i + 1;
} else {
if (coach.isTtsPlayingRef.current) continue;
const text = r[0].transcript.trim();
if (text) interimParts.push(text);
}
}
const currentInterim = interimParts.join(' ');
const preview = [...transcriptPartsRef.current, currentInterim].join(' ').trim();
setLiveTranscript(preview);
if (preview) _resetSilenceTimer();
const totalWords = preview.split(/\s+/).filter(Boolean).length;
const finalizedWords = transcriptPartsRef.current.join(' ').split(/\s+/).filter(Boolean).length;
if (coach.isTtsPlayingRef.current && finalizedWords >= MIN_WORDS_TO_INTERRUPT) {
coach.stopTts();
}
};
recognition.onspeechend = () => {
if (cancelled) return;
if (silenceTimerRef.current) clearTimeout(silenceTimerRef.current);
if (coach.isTtsPlayingRef.current) {
transcriptPartsRef.current = [];
processedResultIndexRef.current = 0;
setLiveTranscript('');
setIsUserSpeaking(false);
return;
}
_sendAndClearTranscript();
};
recognition.onend = () => {
if (cancelled) return;
setIsUserSpeaking(false);
transcriptPartsRef.current = [];
setLiveTranscript('');
if (speechRecognitionRef.current === recognition) {
try { recognition.start(); } catch { speechRecognitionRef.current = null; }
}
};
recognition.onerror = (event: any) => {
if (event.error === 'no-speech' || event.error === 'aborted') return;
console.warn('SpeechRecognition error:', event.error);
};
speechRecognitionRef.current = recognition;
recognition.start();
} catch (err) {
console.warn('Mic access failed:', err);
}
};
init();
return () => {
cancelled = true;
coach.stopTts();
if (silenceTimerRef.current) clearTimeout(silenceTimerRef.current);
if (speechRecognitionRef.current) {
try { speechRecognitionRef.current.stop(); } catch { /* ignore */ }
speechRecognitionRef.current = null;
}
if (streamRef.current) {
streamRef.current.getTracks().forEach(t => t.stop());
streamRef.current = null;
}
};
}, [activeTab, coach.session?.id, coach.isMuted]);
// Reset mute when session ends
useEffect(() => {
if (!coach.session) coach.setMuted(false);
}, [coach.session]);
// Focus input on session start
useEffect(() => {
if (coach.session && inputRef.current) inputRef.current.focus();
}, [coach.session]);
const handleSend = useCallback(async () => {
if (!coach.inputValue.trim() || coach.isStreaming) return;
await coach.sendMessage(coach.inputValue);
}, [coach]);
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 handleUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !instanceId || !coach.selectedContextId) return;
@ -50,9 +279,7 @@ export const CommcoachDossierView: React.FC = () => {
try {
const doc = await uploadDocumentApi(instanceId, coach.selectedContextId, file);
setDocuments(prev => [doc, ...prev]);
} catch {
// upload failed
} finally {
} catch { /* upload failed */ } finally {
setUploading(false);
e.target.value = '';
}
@ -63,11 +290,28 @@ export const CommcoachDossierView: React.FC = () => {
try {
await deleteDocumentApi(request, instanceId, docId);
setDocuments(prev => prev.filter(d => d.id !== docId));
} catch {
// delete failed
}
} catch { /* delete failed */ }
}, [instanceId, request]);
const handleDownloadDocument = useCallback(async (doc: CoachingDocument) => {
if (!doc.fileRef) return;
try {
const response = await api.get(`/api/files/${doc.fileRef}/download`, {
responseType: 'blob',
});
const url = window.URL.createObjectURL(response.data);
const a = document.createElement('a');
a.href = url;
a.download = doc.fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} catch (err) {
console.error('Download failed:', err);
}
}, []);
const handleAddTask = useCallback(async () => {
if (!newTaskTitle.trim()) return;
await coach.addTask(newTaskTitle);
@ -78,14 +322,6 @@ export const CommcoachDossierView: React.FC = () => {
return <div className={styles.empty}><p>Lade...</p></div>;
}
if (coach.contexts.length === 0) {
return (
<div className={styles.empty}>
<p>Noch keine Coaching-Themen vorhanden. Erstelle zuerst eines im Coaching-Tab.</p>
</div>
);
}
return (
<div className={styles.dossier}>
{/* Context Selector */}
@ -94,261 +330,403 @@ export const CommcoachDossierView: React.FC = () => {
<button
key={ctx.id}
className={`${styles.contextChip} ${ctx.id === coach.selectedContextId ? styles.contextChipActive : ''}`}
onClick={() => coach.selectContext(ctx.id)}
onClick={() => handleSelectContext(ctx.id)}
>
<span className={styles.contextChipIcon}>{_categoryIcon(ctx.category)}</span>
{ctx.title}
</button>
))}
<button
className={styles.contextChipNew}
onClick={() => setShowNewContext(!showNewContext)}
title="Neues Thema"
>
+
</button>
</div>
{!coach.selectedContextId ? (
<div className={styles.empty}><p>Wähle ein Coaching-Thema.</p></div>
) : (<>
{/* 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>
)}
{/* New Context Form */}
{showNewContext && (
<div className={styles.newContextForm}>
<input
className={styles.newContextInput}
placeholder="Thema / Titel..."
value={newTitle}
onChange={e => setNewTitle(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleCreateContext()}
autoFocus
/>
<input
className={styles.newContextInput}
placeholder="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">Führung</option>
<option value="conflict">Konflikt</option>
<option value="negotiation">Verhandlung</option>
<option value="presentation">Präsentation</option>
<option value="feedback">Feedback</option>
<option value="delegation">Delegation</option>
<option value="changeManagement">Change Management</option>
</select>
<div className={styles.newContextActions}>
<button className={styles.btnPrimary} onClick={handleCreateContext} disabled={!newTitle.trim() || !!coach.actionLoading}>
{coach.actionLoading === 'creating' ? 'Wird erstellt...' : 'Erstellen'}
</button>
<button className={styles.btnSecondary} onClick={() => setShowNewContext(false)}>Abbrechen</button>
</div>
</div>
<div className={styles.headerActions}>
{instanceId && coach.selectedContextId && (
<>
<a
className={styles.btnExport}
href={getDossierExportUrl(instanceId, coach.selectedContextId, 'md')}
target="_blank"
rel="noopener noreferrer"
>
Export MD
</a>
<a
className={styles.btnExport}
href={getDossierExportUrl(instanceId, coach.selectedContextId, 'pdf')}
target="_blank"
rel="noopener noreferrer"
>
Export PDF
</a>
</>
)}
<button
className={styles.btnArchive}
onClick={() => coach.archiveContext(coach.selectedContextId!)}
disabled={!!coach.actionLoading}
>
{coach.actionLoading === 'archiving' ? 'Wird archiviert...' : 'Archivieren'}
</button>
)}
{/* No context selected */}
{!coach.selectedContextId && !showNewContext && coach.contexts.length === 0 && (
<div className={styles.empty}>
<h3>Willkommen beim Kommunikations-Coach</h3>
<p>Erstelle ein Thema, um zu beginnen.</p>
<button className={styles.btnPrimary} onClick={() => setShowNewContext(true)}>Neues Thema erstellen</button>
</div>
</div>
)}
{/* Tab Navigation */}
<div className={styles.tabs}>
<button
className={`${styles.tab} ${activeTab === 'tasks' ? styles.tabActive : ''}`}
onClick={() => setActiveTab('tasks')}
>
Aufgaben ({coach.tasks.length})
</button>
<button
className={`${styles.tab} ${activeTab === 'sessions' ? styles.tabActive : ''}`}
onClick={() => setActiveTab('sessions')}
>
Sessions ({coach.sessions.length})
</button>
<button
className={`${styles.tab} ${activeTab === 'scores' ? styles.tabActive : ''}`}
onClick={() => setActiveTab('scores')}
>
Bewertungen ({coach.scores.length})
</button>
<button
className={`${styles.tab} ${activeTab === 'documents' ? styles.tabActive : ''}`}
onClick={() => setActiveTab('documents')}
>
Dokumente ({documents.length})
</button>
</div>
{/* Tasks Tab */}
{activeTab === 'tasks' && (
<div className={styles.tabContent}>
<div className={styles.addTaskRow}>
<input
className={styles.addTaskInput}
placeholder="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' ? 'Wird hinzugefügt...' : 'Hinzufügen'}
{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">Export MD</a>
<a className={styles.btnExport} href={getDossierExportUrl(instanceId, coach.selectedContextId, 'pdf')} target="_blank" rel="noopener noreferrer">Export PDF</a>
</>
)}
<button className={styles.btnArchive} onClick={() => coach.archiveContext(coach.selectedContextId!)} disabled={!!coach.actionLoading}>
{coach.actionLoading === 'archiving' ? 'Wird archiviert...' : 'Archivieren'}
</button>
</div>
{coach.tasks.length === 0 ? (
<div className={styles.emptyTab}>Noch keine Aufgaben. Der Coach schlägt während Sessions Aufgaben vor.</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}>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' ? 'Aktiv' : '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>
{/* Tab Navigation */}
<div className={styles.tabs}>
{(['coaching', 'tasks', 'sessions', 'scores', 'documents'] as TabKey[]).map(tab => (
<button
key={tab}
className={`${styles.tab} ${activeTab === tab ? styles.tabActive : ''}`}
onClick={() => setActiveTab(tab)}
>
{_tabLabel(tab, coach, documents)}
</button>
))}
</div>
{/* ============================================================ */}
{/* COACHING TAB */}
{/* ============================================================ */}
{activeTab === 'coaching' && (
<div className={styles.coachingTab}>
{!coach.session ? (
<div className={styles.sessionStart}>
<p>Starte eine neue Coaching-Session zu diesem Thema.</p>
{personas.length > 0 && (
<div className={styles.personaSelector}>
<label className={styles.personaLabel}>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 className={styles.sessionMeta}>
{s.messageCount} Nachrichten | {Math.round(s.durationSeconds / 60)} Min.
{s.personaId && <span> | 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>
)}
<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}>Session aktiv</span>
<div className={styles.sessionActions}>
{isTtsPlaying && (
<button className={styles.btnSmallDanger} onClick={coach.stopTts}>Stop</button>
)}
{coach.wasInterrupted && !isTtsPlaying && (
<button className={styles.btnSmall} onClick={coach.resumeTts}>Weitersprechen</button>
)}
<button
className={`${styles.btnSmall} ${coach.isMuted ? styles.mutedActive : ''}`}
onClick={() => coach.setMuted(!coach.isMuted)}
title={coach.isMuted ? 'Stummschaltung aufheben' : 'Stummschalten'}
>
{coach.isMuted ? '\u{1F507} Stumm' : '\u{1F3A4} Ton an'}
</button>
<button className={styles.btnSmall} onClick={coach.completeSession} disabled={!!coach.actionLoading}>
{coach.actionLoading === 'completing' ? 'Wird abgeschlossen...' : 'Abschliessen'}
</button>
<button className={styles.btnSmallDanger} onClick={coach.cancelSession} disabled={!!coach.actionLoading}>
{coach.actionLoading === 'cancelling' ? 'Wird abgebrochen...' : 'Abbrechen'}
</button>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* Scores Tab */}
{activeTab === 'scores' && (
<div className={styles.tabContent}>
{coach.scores.length === 0 ? (
<div className={styles.emptyTab}>Noch keine Bewertungen. Schliesse eine Session ab, um Scores zu erhalten.</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>
))}
{/* Messages */}
<AutoScroll scrollDependency={coach.messages.length + (coach.isStreaming ? 1 : 0) + 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>
))}
{liveTranscript && (
<div className={`${styles.message} ${styles.messageUser}`}>
<div className={`${styles.messageBubble} ${styles.messageLive}`}>{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>
)}
</div>
))}
</div>
)}
</div>
)}
{/* Documents Tab */}
{activeTab === 'documents' && (
<div className={styles.tabContent}>
<div className={styles.addTaskRow}>
<label className={styles.uploadLabel}>
{uploading ? 'Wird hochgeladen...' : 'Dokument hochladen'}
<input
type="file"
accept=".txt,.md,.pdf,.doc,.docx"
onChange={handleUpload}
disabled={uploading}
style={{ display: 'none' }}
/>
</label>
</div>
{documents.length === 0 ? (
<div className={styles.emptyTab}>Keine Dokumente. Lade Dateien hoch, um sie mit diesem Kontext zu verknüpfen.</div>
) : (
<div className={styles.documentList}>
{documents.map(doc => (
<div key={doc.id} className={styles.documentItem}>
<div className={styles.documentInfo}>
<div className={styles.documentName}>{doc.fileName}</div>
<div className={styles.documentMeta}>
{_formatFileSize(doc.fileSize)} | {doc.createdAt ? new Date(doc.createdAt).toLocaleDateString('de-CH') : ''}
</div>
{doc.summary && (
<div className={styles.documentSummary}>{doc.summary}</div>
)}
</div>
<button className={styles.taskDelete} onClick={() => handleDeleteDocument(doc.id)}>
x
</button>
</AutoScroll>
{/* Input Area */}
<div className={styles.inputArea}>
<div className={styles.voiceStatus}>
<span className={`${styles.voiceIndicator} ${isListening && !coach.isMuted ? styles.voiceActive : ''}`}>
{coach.isMuted
? 'Stumm Mikrofon aus'
: coach.isStreaming
? (coach.streamingStatus || 'Coach antwortet...')
: isUserSpeaking
? 'Spricht...'
: isListening
? 'Mikrofon an bitte sprechen'
: 'Mikrofon wird gestartet...'}
</span>
</div>
<div className={styles.textInputRow}>
<textarea
ref={inputRef}
className={styles.textInput}
placeholder="Nachricht eingeben..."
value={coach.inputValue}
onChange={e => coach.setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
rows={1}
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="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' ? 'Wird hinzugefügt...' : 'Hinzufügen'}
</button>
</div>
)}
</div>
)}
{coach.tasks.length === 0 ? (
<div className={styles.emptyTab}>Noch keine Aufgaben. Der Coach schlägt während Sessions Aufgaben vor.</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}>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' ? 'Aktiv' : '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> | 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}>Noch keine Bewertungen. Schliesse eine Session ab, um Scores zu erhalten.</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>
)}
{/* ============================================================ */}
{/* DOCUMENTS TAB */}
{/* ============================================================ */}
{activeTab === 'documents' && (
<div className={styles.tabContent}>
<div className={styles.addTaskRow}>
<label className={styles.uploadLabel}>
{uploading ? 'Wird hochgeladen...' : 'Dokument hochladen'}
<input type="file" accept=".txt,.md,.pdf,.doc,.docx" onChange={handleUpload} disabled={uploading} style={{ display: 'none' }} />
</label>
</div>
{documents.length === 0 ? (
<div className={styles.emptyTab}>Keine Dokumente. Lade Dateien hoch oder bitte den Coach, eines zu erstellen.</div>
) : (
<div className={styles.documentList}>
{documents.map(doc => (
<div key={doc.id} className={styles.documentItem}>
<div className={styles.documentInfo}>
<div className={styles.documentName}>{doc.fileName}</div>
<div className={styles.documentMeta}>
{_formatFileSize(doc.fileSize)} | {doc.createdAt ? new Date(doc.createdAt).toLocaleDateString('de-CH') : ''}
</div>
{doc.summary && <div className={styles.documentSummary}>{doc.summary}</div>}
</div>
<div className={styles.documentActions}>
<button className={styles.btnExport} onClick={() => handleDownloadDocument(doc)} disabled={!doc.fileRef}>Download</button>
<button className={styles.taskDelete} onClick={() => handleDeleteDocument(doc.id)}>x</button>
</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, documents: CoachingDocument[]): string {
switch (tab) {
case 'coaching': return coach.session ? 'Coaching (aktiv)' : 'Coaching';
case 'tasks': return `Aufgaben (${coach.tasks.length})`;
case 'sessions': return `Sessions (${coach.sessions.length})`;
case 'scores': return `Bewertungen (${coach.scores.length})`;
case 'documents': return `Dokumente (${documents.length})`;
}
}
interface ScoreGroup {
dimension: string;
latest: { score: number; trend: string; evidence?: string; createdAt?: string };
@ -359,13 +737,9 @@ 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: [] };
}
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;
}
if (s.createdAt > (groups[dim].latest.createdAt || '')) groups[dim].latest = s;
}
return Object.values(groups);
}
@ -378,10 +752,8 @@ function _formatFileSize(bytes: number): string {
function _dimensionLabel(dim: string): string {
const labels: Record<string, string> = {
empathy: 'Einfühlungsvermögen',
clarity: 'Klarheit',
assertiveness: 'Durchsetzung',
listening: 'Zuhören',
empathy: 'Einfühlungsvermögen', clarity: 'Klarheit',
assertiveness: 'Durchsetzung', listening: 'Zuhören',
selfReflection: 'Selbstreflexion',
};
return labels[dim] || dim;

View file

@ -1,4 +1,3 @@
export { CommcoachDashboardView } from './CommcoachDashboardView';
export { CommcoachCoachingView } from './CommcoachCoachingView';
export { CommcoachDossierView } from './CommcoachDossierView';
export { CommcoachSettingsView } from './CommcoachSettingsView';