([]);
@@ -237,7 +239,10 @@ export function useCommcoach(): CommcoachHookReturn {
if (eventData.resumed && Array.isArray(eventData.messages)) {
setMessages(eventData.messages);
}
+ } else if (eventType === 'messageChunk' && eventData) {
+ setStreamingMessage(eventData.accumulated || '');
} else if (eventType === 'message' && eventData) {
+ setStreamingMessage(null);
const msg: CoachingMessage = {
id: eventData.id || `msg-${Date.now()}`,
sessionId: eventData.sessionId || '',
@@ -263,12 +268,14 @@ export function useCommcoach(): CommcoachHookReturn {
if (isMountedRef.current) {
setError(err.message);
setIsStreaming(false);
+ setStreamingMessage(null);
}
},
() => {
if (isMountedRef.current) {
setIsStreaming(false);
setStreamingStatus(null);
+ setStreamingMessage(null);
}
},
);
@@ -309,7 +316,10 @@ export function useCommcoach(): CommcoachHookReturn {
const eventType = event.type;
const eventData = event.data;
- if (eventType === 'message' && eventData) {
+ if (eventType === 'messageChunk' && eventData) {
+ setStreamingMessage(eventData.accumulated || '');
+ } else if (eventType === 'message' && eventData) {
+ setStreamingMessage(null);
const msg: CoachingMessage = {
id: eventData.id || `msg-${Date.now()}`,
sessionId: session.id,
@@ -343,12 +353,14 @@ export function useCommcoach(): CommcoachHookReturn {
if (isMountedRef.current) {
setError(err.message);
setIsStreaming(false);
+ setStreamingMessage(null);
}
},
() => {
if (isMountedRef.current) {
setIsStreaming(false);
setStreamingStatus(null);
+ setStreamingMessage(null);
}
},
);
@@ -485,7 +497,7 @@ export function useCommcoach(): CommcoachHookReturn {
return {
contexts, selectedContextId, selectedContext, loadingContexts,
- session, messages, isStreaming, streamingStatus,
+ session, messages, isStreaming, streamingStatus, streamingMessage,
tasks, scores, sessions,
error, inputValue, setInputValue,
selectContext, createContext, archiveContext,
diff --git a/src/pages/views/commcoach/CommcoachCoachingView.tsx b/src/pages/views/commcoach/CommcoachCoachingView.tsx
index 38bd1a3..be4597a 100644
--- a/src/pages/views/commcoach/CommcoachCoachingView.tsx
+++ b/src/pages/views/commcoach/CommcoachCoachingView.tsx
@@ -341,10 +341,16 @@ export const CommcoachCoachingView: React.FC = () => {
{coach.isStreaming && (
-
- {coach.streamingStatus || 'Coach denkt nach'}
- ...
-
+ {coach.streamingMessage ? (
+
+ {coach.streamingMessage}
+
+ ) : (
+
+ {coach.streamingStatus || 'Coach denkt nach'}
+ ...
+
+ )}
)}
diff --git a/src/pages/views/commcoach/CommcoachDashboardView.tsx b/src/pages/views/commcoach/CommcoachDashboardView.tsx
index 02a2b12..403c764 100644
--- a/src/pages/views/commcoach/CommcoachDashboardView.tsx
+++ b/src/pages/views/commcoach/CommcoachDashboardView.tsx
@@ -55,9 +55,11 @@ export const CommcoachDashboardView: React.FC = () => {
Durchschnitt
-
{dashboard.openTasks}
-
Offene Aufgaben
-
{dashboard.completedTasks} erledigt
+
+ {dashboard.goalProgress != null ? `${dashboard.goalProgress}%` : '--'}
+
+
Zielfortschritt
+
{dashboard.openTasks} offene Aufgaben
@@ -84,6 +86,7 @@ export const CommcoachDashboardView: React.FC = () => {
{_categoryLabel(ctx.category)}
{ctx.sessionCount} Sessions
+ {ctx.goalProgress != null && Ziele: {ctx.goalProgress}%}
{ctx.lastSessionAt && (
From 5186e58e007f2884a137f570a6832f90bf58c081 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Tue, 3 Mar 2026 23:02:49 +0100
Subject: [PATCH 2/7] iteration 2 done
---
src/api/commcoachApi.ts | 124 ++++++++++++++-
src/hooks/useCommcoach.ts | 5 +-
.../views/commcoach/CommcoachCoachingView.tsx | 44 +++++-
.../CommcoachDashboardView.module.css | 26 ++++
.../commcoach/CommcoachDashboardView.tsx | 30 ++++
.../commcoach/CommcoachDossierView.module.css | 113 ++++++++++++++
.../views/commcoach/CommcoachDossierView.tsx | 146 +++++++++++++++++-
7 files changed, 483 insertions(+), 5 deletions(-)
diff --git a/src/api/commcoachApi.ts b/src/api/commcoachApi.ts
index 69c8e40..2462668 100644
--- a/src/api/commcoachApi.ts
+++ b/src/api/commcoachApi.ts
@@ -29,6 +29,7 @@ export interface CoachingSession {
contextId: string;
userId: string;
status: string;
+ personaId?: string;
summary?: string;
durationSeconds: number;
messageCount: number;
@@ -38,6 +39,38 @@ export interface CoachingSession {
endedAt?: string;
}
+export interface CoachingPersona {
+ id: string;
+ userId: string;
+ key: string;
+ label: string;
+ description: string;
+ gender?: string;
+ category: string;
+ isActive: boolean;
+}
+
+export interface CoachingDocument {
+ id: string;
+ contextId: string;
+ fileName: string;
+ mimeType: string;
+ fileSize: number;
+ extractedText?: string;
+ summary?: string;
+ createdAt?: string;
+}
+
+export interface CoachingBadge {
+ id: string;
+ userId: string;
+ badgeKey: string;
+ label?: string;
+ description?: string;
+ icon?: string;
+ awardedAt?: string;
+}
+
export interface CoachingMessage {
id: string;
sessionId: string;
@@ -100,6 +133,8 @@ export interface DashboardData {
openTasks: number;
completedTasks: number;
goalProgress?: number;
+ badges?: CoachingBadge[];
+ level?: { number: number; label: string; totalSessions: number };
contexts: Array<{ id: string; title: string; category: string; sessionCount: number; lastSessionAt?: string; goalProgress?: number }>;
}
@@ -180,10 +215,12 @@ export async function startSessionStreamApi(
onEvent: (event: SSEEvent) => void,
onError?: (error: Error) => void,
onComplete?: () => void,
+ personaId?: string,
): Promise {
try {
const baseURL = api.defaults.baseURL || '';
- const url = `${baseURL}/api/commcoach/${instanceId}/contexts/${contextId}/sessions/start`;
+ const personaParam = personaId ? `?personaId=${encodeURIComponent(personaId)}` : '';
+ const url = `${baseURL}/api/commcoach/${instanceId}/contexts/${contextId}/sessions/start${personaParam}`;
const headers: Record = { 'Content-Type': 'application/json' };
const authToken = localStorage.getItem('authToken');
@@ -474,3 +511,88 @@ export async function testVoiceApi(request: ApiRequestFunction, instanceId: stri
const data = await request({ url: `/api/commcoach/${instanceId}/voice/tts`, method: 'post', data: body });
return data;
}
+
+// ============================================================================
+// Persona API (Iteration 2)
+// ============================================================================
+
+export async function getPersonasApi(request: ApiRequestFunction, instanceId: string): Promise {
+ const data = await request({ url: `/api/commcoach/${instanceId}/personas`, method: 'get' });
+ return data.personas || [];
+}
+
+export async function createPersonaApi(request: ApiRequestFunction, instanceId: string, body: {
+ label: string; description: string; gender?: string; systemPromptOverride?: string;
+}): Promise {
+ const data = await request({ url: `/api/commcoach/${instanceId}/personas`, method: 'post', data: body });
+ return data.persona;
+}
+
+export async function deletePersonaApi(request: ApiRequestFunction, instanceId: string, personaId: string): Promise {
+ await request({ url: `/api/commcoach/${instanceId}/personas/${personaId}`, method: 'delete' });
+}
+
+// ============================================================================
+// Document API (Iteration 2)
+// ============================================================================
+
+export async function getDocumentsApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise {
+ const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/documents`, method: 'get' });
+ return data.documents || [];
+}
+
+export async function uploadDocumentApi(instanceId: string, contextId: string, file: File): Promise {
+ const baseURL = api.defaults.baseURL || '';
+ const url = `${baseURL}/api/commcoach/${instanceId}/contexts/${contextId}/documents`;
+ const formData = new FormData();
+ formData.append('file', file);
+
+ const headers: Record = {};
+ const authToken = localStorage.getItem('authToken');
+ if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
+ if (!getCSRFToken()) generateAndStoreCSRFToken();
+ addCSRFTokenToHeaders(headers);
+
+ const response = await fetch(url, { method: 'POST', headers, body: formData, credentials: 'include' });
+ if (!response.ok) throw new Error(`Upload failed: ${response.status}`);
+ const data = await response.json();
+ return data.document;
+}
+
+export async function deleteDocumentApi(request: ApiRequestFunction, instanceId: string, documentId: string): Promise {
+ await request({ url: `/api/commcoach/${instanceId}/documents/${documentId}`, method: 'delete' });
+}
+
+// ============================================================================
+// Badge API (Iteration 2)
+// ============================================================================
+
+export async function getBadgesApi(request: ApiRequestFunction, instanceId: string): Promise {
+ const data = await request({ url: `/api/commcoach/${instanceId}/badges`, method: 'get' });
+ return data.badges || [];
+}
+
+// ============================================================================
+// Export API (Iteration 2)
+// ============================================================================
+
+export function getDossierExportUrl(instanceId: string, contextId: string, format: string = 'md'): string {
+ const baseURL = api.defaults.baseURL || '';
+ return `${baseURL}/api/commcoach/${instanceId}/contexts/${contextId}/export?format=${format}`;
+}
+
+export function getSessionExportUrl(instanceId: string, sessionId: string, format: string = 'md'): string {
+ const baseURL = api.defaults.baseURL || '';
+ return `${baseURL}/api/commcoach/${instanceId}/sessions/${sessionId}/export?format=${format}`;
+}
+
+// ============================================================================
+// Score History API (Iteration 2)
+// ============================================================================
+
+export async function getScoreHistoryApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise>> {
+ const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/scores/history`, method: 'get' });
+ return data.history || {};
+}
diff --git a/src/hooks/useCommcoach.ts b/src/hooks/useCommcoach.ts
index f2bdbb6..6dc65cc 100644
--- a/src/hooks/useCommcoach.ts
+++ b/src/hooks/useCommcoach.ts
@@ -40,7 +40,7 @@ export interface CommcoachHookReturn {
createContext: (title: string, description?: string, category?: string, goals?: string[]) => Promise;
archiveContext: (contextId: string) => Promise;
- startSession: () => Promise;
+ startSession: (personaId?: string) => Promise;
sendMessage: (content: string) => Promise;
sendAudio: (audioBlob: Blob) => Promise;
completeSession: () => Promise;
@@ -212,7 +212,7 @@ export function useCommcoach(): CommcoachHookReturn {
}
}, [request, instanceId, selectedContextId, refreshContexts]);
- const startSessionCb = useCallback(async () => {
+ const startSessionCb = useCallback(async (personaId?: string) => {
if (!instanceId || !selectedContextId) return;
await _unlockAudioForTts();
setError(null);
@@ -278,6 +278,7 @@ export function useCommcoach(): CommcoachHookReturn {
setStreamingMessage(null);
}
},
+ personaId,
);
} catch (err: any) {
if (isMountedRef.current) {
diff --git a/src/pages/views/commcoach/CommcoachCoachingView.tsx b/src/pages/views/commcoach/CommcoachCoachingView.tsx
index be4597a..d658658 100644
--- a/src/pages/views/commcoach/CommcoachCoachingView.tsx
+++ b/src/pages/views/commcoach/CommcoachCoachingView.tsx
@@ -9,6 +9,9 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useCommcoach } from '../../../hooks/useCommcoach';
+import { useApiRequest } from '../../../hooks/useApi';
+import { useInstanceId } from '../../../hooks/useCurrentInstance';
+import { getPersonasApi, type CoachingPersona } from '../../../api/commcoachApi';
import AutoScroll from '../../../components/UiComponents/AutoScroll/AutoScroll';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
@@ -17,11 +20,15 @@ import styles from './CommcoachCoachingView.module.css';
export const CommcoachCoachingView: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const coach = useCommcoach();
+ const { request } = useApiRequest();
+ const instanceId = useInstanceId();
const [showNewContext, setShowNewContext] = useState(false);
const [newTitle, setNewTitle] = useState('');
const [newDescription, setNewDescription] = useState('');
const [newCategory, setNewCategory] = useState('custom');
const inputRef = useRef(null);
+ const [personas, setPersonas] = useState([]);
+ const [selectedPersonaId, setSelectedPersonaId] = useState(undefined);
const streamRef = useRef(null);
const speechRecognitionRef = useRef(null);
@@ -71,6 +78,13 @@ export const CommcoachCoachingView: React.FC = () => {
}
}, [coach.session]);
+ useEffect(() => {
+ if (!instanceId) return;
+ getPersonasApi(request, instanceId)
+ .then(p => setPersonas(p))
+ .catch(() => {});
+ }, [instanceId, request]);
+
useEffect(() => {
if (!coach.session || coach.isMuted) {
if (speechRecognitionRef.current) {
@@ -284,8 +298,36 @@ export const CommcoachCoachingView: React.FC = () => {
{coach.selectedContext?.title}
{coach.selectedContext?.description || 'Starte eine neue Coaching-Session zu diesem Thema.'}
-
)}
diff --git a/src/pages/views/commcoach/CommcoachDashboardView.module.css b/src/pages/views/commcoach/CommcoachDashboardView.module.css
index bfea6ac..54899b2 100644
--- a/src/pages/views/commcoach/CommcoachDashboardView.module.css
+++ b/src/pages/views/commcoach/CommcoachDashboardView.module.css
@@ -114,6 +114,32 @@
border-radius: 10px;
}
+.badgeGrid {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+}
+
+.badgeCard {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 0.9rem;
+ background: var(--bg-card, #fff);
+ border: 1px solid var(--border-color, #e0e0e0);
+ border-radius: 20px;
+ font-size: 0.85rem;
+}
+
+.badgeIcon {
+ font-size: 1.1rem;
+}
+
+.badgeLabel {
+ font-weight: 500;
+ color: var(--text-primary, #333);
+}
+
.tipCard {
background: var(--bg-card, #fff);
border: 1px solid var(--border-color, #e0e0e0);
diff --git a/src/pages/views/commcoach/CommcoachDashboardView.tsx b/src/pages/views/commcoach/CommcoachDashboardView.tsx
index 403c764..d30b8f3 100644
--- a/src/pages/views/commcoach/CommcoachDashboardView.tsx
+++ b/src/pages/views/commcoach/CommcoachDashboardView.tsx
@@ -99,6 +99,27 @@ export const CommcoachDashboardView: React.FC = () => {
)}
+ {/* Level + Badges */}
+ {(dashboard.level || (dashboard.badges && dashboard.badges.length > 0)) && (
+
+
+ {dashboard.level
+ ? `Level ${dashboard.level.number}: ${dashboard.level.label}`
+ : 'Auszeichnungen'}
+
+ {dashboard.badges && dashboard.badges.length > 0 && (
+
+ {dashboard.badges.map(b => (
+
+
{_badgeIcon(b.icon)}
+
{b.label || b.badgeKey}
+
+ ))}
+
+ )}
+
+ )}
+
{/* Quick Start */}
Tipp des Tages
@@ -125,6 +146,15 @@ function _categoryLabel(category: string): string {
return labels[category] || category;
}
+function _badgeIcon(icon?: string): string {
+ const icons: Record = {
+ star: '\u2605', fire: '\u{1F525}', trophy: '\u{1F3C6}',
+ medal: '\u{1F3C5}', layers: '\u{1F4DA}', theater: '\u{1F3AD}',
+ compass: '\u{1F9ED}', 'check-circle': '\u2714',
+ };
+ return icons[icon || 'star'] || '\u2605';
+}
+
function _formatDate(isoStr: string): string {
try {
const d = new Date(isoStr);
diff --git a/src/pages/views/commcoach/CommcoachDossierView.module.css b/src/pages/views/commcoach/CommcoachDossierView.module.css
index cc79280..8372ef0 100644
--- a/src/pages/views/commcoach/CommcoachDossierView.module.css
+++ b/src/pages/views/commcoach/CommcoachDossierView.module.css
@@ -287,3 +287,116 @@
color: var(--text-secondary, #666);
line-height: 1.4;
}
+
+/* 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 */
+.uploadLabel {
+ padding: 0.5rem 1rem;
+ background: var(--primary-color, #F25843);
+ 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;
+}
+
+.documentItem {
+ display: flex;
+ align-items: flex-start;
+ gap: 0.75rem;
+ padding: 0.75rem;
+ background: var(--bg-card, #fff);
+ border: 1px solid var(--border-color, #e0e0e0);
+ border-radius: 8px;
+}
+
+.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;
+}
diff --git a/src/pages/views/commcoach/CommcoachDossierView.tsx b/src/pages/views/commcoach/CommcoachDossierView.tsx
index d0ac345..1737590 100644
--- a/src/pages/views/commcoach/CommcoachDossierView.tsx
+++ b/src/pages/views/commcoach/CommcoachDossierView.tsx
@@ -6,13 +6,26 @@
import React, { useState, useCallback, useEffect } from 'react';
import { useCommcoach } from '../../../hooks/useCommcoach';
+import { useApiRequest } from '../../../hooks/useApi';
+import { useInstanceId } from '../../../hooks/useCurrentInstance';
+import {
+ getDossierExportUrl, getSessionExportUrl,
+ getDocumentsApi, uploadDocumentApi, deleteDocumentApi,
+ getScoreHistoryApi,
+ type CoachingDocument,
+} from '../../../api/commcoachApi';
import ReactMarkdown from 'react-markdown';
import styles from './CommcoachDossierView.module.css';
export const CommcoachDossierView: React.FC = () => {
const coach = useCommcoach();
+ const { request } = useApiRequest();
+ const instanceId = useInstanceId();
const [newTaskTitle, setNewTaskTitle] = useState('');
- const [activeTab, setActiveTab] = useState<'sessions' | 'tasks' | 'scores'>('tasks');
+ const [activeTab, setActiveTab] = useState<'sessions' | 'tasks' | 'scores' | 'documents'>('tasks');
+ const [documents, setDocuments] = useState([]);
+ const [uploading, setUploading] = useState(false);
+ const [scoreHistory, setScoreHistory] = useState>>({});
useEffect(() => {
if (!coach.selectedContextId && coach.contexts.length > 0) {
@@ -20,6 +33,41 @@ export const CommcoachDossierView: React.FC = () => {
}
}, [coach.contexts, coach.selectedContextId, coach.selectContext]);
+ useEffect(() => {
+ if (!instanceId || !coach.selectedContextId) return;
+ getDocumentsApi(request, instanceId, coach.selectedContextId)
+ .then(d => setDocuments(d))
+ .catch(() => {});
+ getScoreHistoryApi(request, instanceId, coach.selectedContextId)
+ .then(h => setScoreHistory(h))
+ .catch(() => {});
+ }, [instanceId, request, coach.selectedContextId]);
+
+ const handleUpload = useCallback(async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file || !instanceId || !coach.selectedContextId) return;
+ setUploading(true);
+ try {
+ const doc = await uploadDocumentApi(instanceId, coach.selectedContextId, file);
+ setDocuments(prev => [doc, ...prev]);
+ } catch {
+ // upload failed
+ } finally {
+ setUploading(false);
+ e.target.value = '';
+ }
+ }, [instanceId, coach.selectedContextId]);
+
+ const handleDeleteDocument = useCallback(async (docId: string) => {
+ if (!instanceId) return;
+ try {
+ await deleteDocumentApi(request, instanceId, docId);
+ setDocuments(prev => prev.filter(d => d.id !== docId));
+ } catch {
+ // delete failed
+ }
+ }, [instanceId, request]);
+
const handleAddTask = useCallback(async () => {
if (!newTaskTitle.trim()) return;
await coach.addTask(newTaskTitle);
@@ -65,6 +113,26 @@ export const CommcoachDossierView: React.FC = () => {
)}
+ {instanceId && coach.selectedContextId && (
+ <>
+
+ Export MD
+
+
+ Export PDF
+
+ >
+ )}
coach.archiveContext(coach.selectedContextId!)}>
Archivieren
@@ -91,6 +159,12 @@ export const CommcoachDossierView: React.FC = () => {
>
Bewertungen ({coach.scores.length})
+
setActiveTab('documents')}
+ >
+ Dokumente ({documents.length})
+
{/* Tasks Tab */}
@@ -166,6 +240,18 @@ export const CommcoachDossierView: React.FC = () => {
)}
{s.messageCount} Nachrichten | {Math.round(s.durationSeconds / 60)} Min.
+ {s.personaId &&
| Persona}
+ {instanceId && s.status === 'completed' && (
+
e.stopPropagation()}
+ >
+ Export
+
+ )}
))}
@@ -196,6 +282,58 @@ export const CommcoachDossierView: React.FC = () => {
{group.latest.evidence && (
{group.latest.evidence}
)}
+ {scoreHistory[group.dimension] && scoreHistory[group.dimension].length > 1 && (
+
+
Verlauf:
+
+ {scoreHistory[group.dimension].map((entry, i) => (
+
+ {Math.round(entry.score)}
+
+ ))}
+
+
+ )}
+
+ ))}
+
+ )}
+
+ )}
+
+ {/* Documents Tab */}
+ {activeTab === 'documents' && (
+
+
+
+
+ {documents.length === 0 ? (
+
Keine Dokumente. Lade Dateien hoch, um sie mit diesem Kontext zu verknuepfen.
+ ) : (
+
+ {documents.map(doc => (
+
+
+
{doc.fileName}
+
+ {_formatFileSize(doc.fileSize)} | {doc.createdAt ? new Date(doc.createdAt).toLocaleDateString('de-CH') : ''}
+
+ {doc.summary && (
+
{doc.summary}
+ )}
+
+
handleDeleteDocument(doc.id)}>
+ x
+
))}
@@ -228,6 +366,12 @@ function _groupScoresByDimension(scores: any[]): ScoreGroup[] {
return Object.values(groups);
}
+function _formatFileSize(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+}
+
function _dimensionLabel(dim: string): string {
const labels: Record
= {
empathy: 'Einfuehlungsvermoegen',
From d603ee8820d562e03087bbc2169bd5431f2bcdd7 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Tue, 3 Mar 2026 23:07:49 +0100
Subject: [PATCH 3/7] 2 critical fixes: pwd reset and invitation caching ui
---
src/pages/InvitePage.tsx | 33 +++++++++++++++++++++++++++++++++
1 file changed, 33 insertions(+)
diff --git a/src/pages/InvitePage.tsx b/src/pages/InvitePage.tsx
index c0bdfbd..9193e17 100644
--- a/src/pages/InvitePage.tsx
+++ b/src/pages/InvitePage.tsx
@@ -25,6 +25,7 @@ import React, { useState, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { useInvitations, type InvitationValidation } from '../hooks/useInvitations';
import api from '../api';
+import { getUserDataCache } from '../utils/userCache';
import { FaCheckCircle, FaTimesCircle, FaSpinner, FaSignInAlt, FaUserPlus } from 'react-icons/fa';
import styles from './InvitePage.module.css';
@@ -45,6 +46,7 @@ export const InvitePage: React.FC = () => {
const [accepting, setAccepting] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState(null);
+ const [userMismatch, setUserMismatch] = useState(false);
const [userExists, setUserExists] = useState(null);
// Validate token on mount
@@ -84,6 +86,14 @@ export const InvitePage: React.FC = () => {
}
}
+ if (result.valid && isAuthenticated && result.targetUsername) {
+ const cachedUser = getUserDataCache();
+ if (cachedUser?.username && cachedUser.username.toLowerCase() !== result.targetUsername.toLowerCase()) {
+ localStorage.removeItem(PENDING_INVITATION_KEY);
+ setUserMismatch(true);
+ }
+ }
+
setValidating(false);
};
@@ -190,6 +200,29 @@ export const InvitePage: React.FC = () => {
);
}
+ // Authenticated but invitation is for a different user
+ if (userMismatch && validation?.valid) {
+ const cachedUser = getUserDataCache();
+ return (
+
+
+
+
+
Falsche Anmeldung
+
+ Diese Einladung ist für {validation.targetUsername} bestimmt.
+ Sie sind als {cachedUser?.username || 'anderer Benutzer'} angemeldet.
+
+
Bitte melden Sie sich ab und mit dem richtigen Konto wieder an.
+
+ Zum Dashboard
+
+
+
+
+ );
+ }
+
// Already authenticated - show accept button
const isFeatureInvite = !!validation.featureInstanceId;
const introText = isFeatureInvite
From 26044ff53b20a27b35d7bcfdb645a6b72959c694 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Wed, 4 Mar 2026 00:09:10 +0100
Subject: [PATCH 4/7] fixes comm blocking bot and feedback
---
.../views/commcoach/CommcoachCoachingView.tsx | 20 +++++++++++++++----
1 file changed, 16 insertions(+), 4 deletions(-)
diff --git a/src/pages/views/commcoach/CommcoachCoachingView.tsx b/src/pages/views/commcoach/CommcoachCoachingView.tsx
index d658658..4c41154 100644
--- a/src/pages/views/commcoach/CommcoachCoachingView.tsx
+++ b/src/pages/views/commcoach/CommcoachCoachingView.tsx
@@ -136,14 +136,14 @@ export const CommcoachCoachingView: React.FC = () => {
};
recognition.onspeechstart = () => {
- if (cancelled) return;
+ if (cancelled || coach.isTtsPlayingRef.current) return;
setIsUserSpeaking(true);
transcriptPartsRef.current = [];
setLiveTranscript('');
};
recognition.onresult = (event: SpeechRecognitionEvent) => {
- if (cancelled) return;
+ if (cancelled || coach.isTtsPlayingRef.current) return;
const finalized: string[] = [];
let currentInterim = '';
for (let i = 0; i < event.results.length; i++) {
@@ -163,6 +163,12 @@ export const CommcoachCoachingView: React.FC = () => {
recognition.onspeechend = () => {
if (cancelled) return;
+ if (coach.isTtsPlayingRef.current) {
+ transcriptPartsRef.current = [];
+ setLiveTranscript('');
+ setIsUserSpeaking(false);
+ return;
+ }
const fullTranscript = transcriptPartsRef.current.join(' ').trim();
if (fullTranscript) {
const wordCount = fullTranscript.split(/\s+/).filter(Boolean).length;
@@ -175,9 +181,15 @@ export const CommcoachCoachingView: React.FC = () => {
recognition.onend = () => {
if (cancelled) return;
+ setIsUserSpeaking(false);
+ transcriptPartsRef.current = [];
+ setLiveTranscript('');
if (speechRecognitionRef.current === recognition) {
- speechRecognitionRef.current = null;
- setIsUserSpeaking(false);
+ try {
+ recognition.start();
+ } catch {
+ speechRecognitionRef.current = null;
+ }
}
};
From 36b8558dd0e3a11c08b4a0da5a41d18df977eb2c Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Wed, 4 Mar 2026 22:53:45 +0100
Subject: [PATCH 5/7] =?UTF-8?q?Alle=209=20Fixes=20sind=20implementiert.=20?=
=?UTF-8?q?Hier=20die=20Zusammenfassung:=20Fix=201=20--=20Opening-Prompt:?=
=?UTF-8?q?=20processSessionOpening=20in=20serviceCommcoach.py=20pr=C3=BCf?=
=?UTF-8?q?t=20jetzt=20ob=20es=20die=20erste=20Session=20ist=20(isFirstSes?=
=?UTF-8?q?sion)=20und=20gibt=20der=20AI=20einen=20expliziten=20Prompt,=20?=
=?UTF-8?q?der=20das=20Erfinden=20von=20Kontext=20verbietet.=20Fix=202=20-?=
=?UTF-8?q?-=20Stabiler=20Transcript:=20onresult=20in=20CommcoachCoachingV?=
=?UTF-8?q?iew.tsx=20nutzt=20jetzt=20processedResultIndexRef=20um=20nur=20?=
=?UTF-8?q?neue=20Results=20zu=20verarbeiten.=20Finalisierte=20Teile=20wer?=
=?UTF-8?q?den=20stabil=20akkumuliert,=20kein=20Flackern=20mehr.=20Fix=203?=
=?UTF-8?q?=20--=20Hintergrundger=C3=A4usche-Timeout:=20Neuer=20silenceTim?=
=?UTF-8?q?erRef=20mit=205s=20Timeout.=20Wenn=20nach=20onspeechstart=20kei?=
=?UTF-8?q?n=20Text=20kommt,=20wird=20isUserSpeaking=20automatisch=20zur?=
=?UTF-8?q?=C3=BCckgesetzt.=20Fix=204=20--=20Stop-Button:=20"Stop"=20Butto?=
=?UTF-8?q?n=20erscheint=20im=20Session-Header=20wenn=20TTS=20l=C3=A4uft?=
=?UTF-8?q?=20(via=20isTtsPlaying=20State,=20synchronisiert=20per=20200ms?=
=?UTF-8?q?=20Interval=20mit=20isTtsPlayingRef).=20Fix=205=20--=20Weitersp?=
=?UTF-8?q?rechen-Button:=20lastTtsAudioRef=20speichert=20das=20zuletzt=20?=
=?UTF-8?q?gespielte=20Audio.=20stopTts=20setzt=20wasInterrupted=20=3D=20t?=
=?UTF-8?q?rue.=20"Weitersprechen"=20Button=20erscheint=20nach=20Unterbrec?=
=?UTF-8?q?hung=20und=20spielt=20das=20Audio=20erneut=20ab.=20Fix=206=20--?=
=?UTF-8?q?=20Paralleles=20TTS:=20Neue=20=5FgenerateAndEmitTts()=20Hilfsfu?=
=?UTF-8?q?nktion.=20In=20processMessage=20und=20processSessionOpening=20w?=
=?UTF-8?q?ird=20TTS=20als=20asyncio.create=5Ftask=20parallel=20zu=20=5Fem?=
=?UTF-8?q?itChunkedResponse=20gestartet.=20Fix=207=20--=20JSON-Response:?=
=?UTF-8?q?=20Die=20AI=20antwortet=20jetzt=20als=20JSON=20mit=20text,=20sp?=
=?UTF-8?q?eech,=20documents.=20Neuer=20Prompt-Block=20wird=20in=20buildCo?=
=?UTF-8?q?achingSystemPrompt=20angeh=C3=A4ngt.=20=5FparseAiJsonResponse()?=
=?UTF-8?q?=20und=20=5FsaveGeneratedDocument()=20im=20Backend.=20processMe?=
=?UTF-8?q?ssage=20und=20processSessionOpening=20nutzen=20die=20neue=20Str?=
=?UTF-8?q?uktur.=20Fix=208=20--=20Loading-States:=20Neuer=20actionLoading?=
=?UTF-8?q?=20State=20in=20useCommcoach.=20Alle=20async=20Funktionen=20set?=
=?UTF-8?q?zen=20setActionLoading('key')=20vor=20dem=20Await=20und=20null?=
=?UTF-8?q?=20im=20finally.=20Buttons=20zeigen=20Loading-Text=20und=20werd?=
=?UTF-8?q?en=20disabled.=20Fix=209=20--=20Umlaute:=20Alle=20deutschen=20S?=
=?UTF-8?q?trings=20in=20allen=20CommCoach-Dateien=20(Backend=20+=20Fronte?=
=?UTF-8?q?nd)=20korrigiert:=20ae->=C3=A4,=20oe->=C3=B6,=20ue->=C3=BC.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/hooks/useCommcoach.ts | 42 ++++++++-
.../views/commcoach/CommcoachCoachingView.tsx | 87 +++++++++++++++----
.../commcoach/CommcoachDashboardView.tsx | 8 +-
.../views/commcoach/CommcoachDossierView.tsx | 22 +++--
4 files changed, 127 insertions(+), 32 deletions(-)
diff --git a/src/hooks/useCommcoach.ts b/src/hooks/useCommcoach.ts
index 6dc65cc..a7e5eab 100644
--- a/src/hooks/useCommcoach.ts
+++ b/src/hooks/useCommcoach.ts
@@ -49,8 +49,12 @@ export interface CommcoachHookReturn {
isMuted: boolean;
setMuted: (muted: boolean) => void;
stopTts: () => void;
+ resumeTts: () => void;
+ wasInterrupted: boolean;
isTtsPlayingRef: MutableRefObject;
+ actionLoading: string | null;
+
toggleTaskStatus: (taskId: string, currentStatus: string) => Promise;
addTask: (title: string, description?: string) => Promise;
removeTask: (taskId: string) => Promise;
@@ -81,10 +85,13 @@ export function useCommcoach(): CommcoachHookReturn {
const [inputValue, setInputValue] = useState('');
const [isMuted, setIsMuted] = useState(false);
+ const [wasInterrupted, setWasInterrupted] = useState(false);
+ const [actionLoading, setActionLoading] = useState(null);
const isMountedRef = useRef(true);
const currentAudioRef = useRef(null);
const isTtsPlayingRef = useRef(false);
+ const lastTtsAudioRef = useRef(null);
useEffect(() => { isMountedRef.current = true; return () => { isMountedRef.current = false; }; }, []);
@@ -108,6 +115,8 @@ export function useCommcoach(): CommcoachHookReturn {
currentAudioRef.current.pause();
currentAudioRef.current = null;
}
+ lastTtsAudioRef.current = audioB64;
+ setWasInterrupted(false);
isTtsPlayingRef.current = true;
try {
const audio = new Audio(`data:audio/mp3;base64,${audioB64}`);
@@ -127,9 +136,18 @@ export function useCommcoach(): CommcoachHookReturn {
currentAudioRef.current.pause();
currentAudioRef.current = null;
}
+ if (isTtsPlayingRef.current) {
+ setWasInterrupted(true);
+ }
isTtsPlayingRef.current = false;
}, []);
+ const resumeTts = useCallback(() => {
+ if (lastTtsAudioRef.current) {
+ _playTtsAudio(lastTtsAudioRef.current);
+ }
+ }, [_playTtsAudio]);
+
const selectContext = useCallback(async (contextId: string) => {
if (!instanceId) return;
setSelectedContextId(contextId);
@@ -178,6 +196,7 @@ export function useCommcoach(): CommcoachHookReturn {
const createContext = useCallback(async (title: string, description?: string, category?: string, goals?: string[]) => {
if (!instanceId) return;
+ setActionLoading('creating');
try {
const created = await createContextApi(request, instanceId, { title, description, category, goals });
if (isMountedRef.current) {
@@ -192,11 +211,14 @@ export function useCommcoach(): CommcoachHookReturn {
}
} catch (err: any) {
if (isMountedRef.current) setError(err.message || 'Fehler beim Erstellen des Kontexts');
+ } finally {
+ if (isMountedRef.current) setActionLoading(null);
}
}, [request, instanceId, refreshContexts]);
const archiveContext = useCallback(async (contextId: string) => {
if (!instanceId) return;
+ setActionLoading('archiving');
try {
const { archiveContextApi } = await import('../api/commcoachApi');
await archiveContextApi(request, instanceId, contextId);
@@ -209,11 +231,14 @@ export function useCommcoach(): CommcoachHookReturn {
}
} catch (err: any) {
if (isMountedRef.current) setError(err.message || 'Fehler beim Archivieren');
+ } finally {
+ if (isMountedRef.current) setActionLoading(null);
}
}, [request, instanceId, selectedContextId, refreshContexts]);
const startSessionCb = useCallback(async (personaId?: string) => {
if (!instanceId || !selectedContextId) return;
+ setActionLoading('starting');
await _unlockAudioForTts();
setError(null);
setIsMuted(false);
@@ -285,6 +310,8 @@ export function useCommcoach(): CommcoachHookReturn {
setError(err.message || 'Fehler beim Starten der Session');
setIsStreaming(false);
}
+ } finally {
+ if (isMountedRef.current) setActionLoading(null);
}
}, [instanceId, selectedContextId, _playTtsAudio]);
@@ -437,6 +464,7 @@ export function useCommcoach(): CommcoachHookReturn {
const completeSessionCb = useCallback(async () => {
if (!instanceId || !session) return;
+ setActionLoading('completing');
try {
const completed = await completeSessionApi(request, instanceId, session.id);
if (isMountedRef.current) {
@@ -445,11 +473,14 @@ export function useCommcoach(): CommcoachHookReturn {
}
} catch (err: any) {
if (isMountedRef.current) setError(err.message || 'Fehler beim Abschliessen');
+ } finally {
+ if (isMountedRef.current) setActionLoading(null);
}
}, [request, instanceId, session, selectedContextId, selectContext]);
const cancelSessionCb = useCallback(async () => {
if (!instanceId || !session) return;
+ setActionLoading('cancelling');
try {
await cancelSessionApi(request, instanceId, session.id);
if (isMountedRef.current) {
@@ -458,11 +489,14 @@ export function useCommcoach(): CommcoachHookReturn {
}
} catch (err: any) {
if (isMountedRef.current) setError(err.message || 'Fehler beim Abbrechen');
+ } finally {
+ if (isMountedRef.current) setActionLoading(null);
}
}, [request, instanceId, session]);
const toggleTaskStatus = useCallback(async (taskId: string, currentStatus: string) => {
if (!instanceId) return;
+ setActionLoading('togglingTask');
const newStatus = currentStatus === 'done' ? 'open' : 'done';
try {
const updated = await updateTaskStatusApi(request, instanceId, taskId, newStatus);
@@ -471,16 +505,21 @@ export function useCommcoach(): CommcoachHookReturn {
}
} catch (err: any) {
if (isMountedRef.current) setError(err.message);
+ } finally {
+ if (isMountedRef.current) setActionLoading(null);
}
}, [request, instanceId]);
const addTask = useCallback(async (title: string, description?: string) => {
if (!instanceId || !selectedContextId) return;
+ setActionLoading('addingTask');
try {
const created = await createTaskApi(request, instanceId, selectedContextId, { title, description });
if (isMountedRef.current) setTasks(prev => [created, ...prev]);
} catch (err: any) {
if (isMountedRef.current) setError(err.message);
+ } finally {
+ if (isMountedRef.current) setActionLoading(null);
}
}, [request, instanceId, selectedContextId]);
@@ -504,7 +543,8 @@ export function useCommcoach(): CommcoachHookReturn {
selectContext, createContext, archiveContext,
startSession: startSessionCb,
sendMessage, sendAudio, completeSession: completeSessionCb, cancelSession: cancelSessionCb,
- isMuted, setMuted: setIsMuted, stopTts, isTtsPlayingRef,
+ isMuted, setMuted: setIsMuted, stopTts, resumeTts, wasInterrupted, isTtsPlayingRef,
+ actionLoading,
toggleTaskStatus, addTask, removeTask,
refreshContexts,
};
diff --git a/src/pages/views/commcoach/CommcoachCoachingView.tsx b/src/pages/views/commcoach/CommcoachCoachingView.tsx
index 4c41154..9d0db6f 100644
--- a/src/pages/views/commcoach/CommcoachCoachingView.tsx
+++ b/src/pages/views/commcoach/CommcoachCoachingView.tsx
@@ -33,9 +33,12 @@ export const CommcoachCoachingView: React.FC = () => {
const streamRef = useRef(null);
const speechRecognitionRef = useRef(null);
const transcriptPartsRef = useRef([]);
+ const processedResultIndexRef = useRef(0);
+ const silenceTimerRef = useRef | null>(null);
const [isListening, setIsListening] = useState(false);
const [isUserSpeaking, setIsUserSpeaking] = useState(false);
const [liveTranscript, setLiveTranscript] = useState('');
+ const [isTtsPlaying, setIsTtsPlaying] = useState(false);
const handleSend = useCallback(async () => {
if (!coach.inputValue.trim() || coach.isStreaming) return;
@@ -85,6 +88,14 @@ export const CommcoachCoachingView: React.FC = () => {
.catch(() => {});
}, [instanceId, request]);
+ useEffect(() => {
+ if (!coach.session) return;
+ const interval = setInterval(() => {
+ setIsTtsPlaying(coach.isTtsPlayingRef.current);
+ }, 200);
+ return () => clearInterval(interval);
+ }, [coach.session, coach.isTtsPlayingRef]);
+
useEffect(() => {
if (!coach.session || coach.isMuted) {
if (speechRecognitionRef.current) {
@@ -135,36 +146,54 @@ export const CommcoachCoachingView: React.FC = () => {
if (cancelled) return;
};
+ const SILENCE_TIMEOUT_MS = 5000;
+
+ const _resetSilenceTimer = () => {
+ if (silenceTimerRef.current) clearTimeout(silenceTimerRef.current);
+ silenceTimerRef.current = setTimeout(() => {
+ if (cancelled) return;
+ setIsUserSpeaking(false);
+ transcriptPartsRef.current = [];
+ processedResultIndexRef.current = 0;
+ setLiveTranscript('');
+ }, 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 || coach.isTtsPlayingRef.current) return;
- const finalized: string[] = [];
let currentInterim = '';
- for (let i = 0; i < event.results.length; i++) {
+ for (let i = processedResultIndexRef.current; i < event.results.length; i++) {
const r = event.results[i];
if (r.isFinal) {
- finalized.push(r[0].transcript.trim());
+ const text = r[0].transcript.trim();
+ if (text) transcriptPartsRef.current.push(text);
+ processedResultIndexRef.current = i + 1;
} else {
currentInterim = r[0].transcript.trim();
}
}
- transcriptPartsRef.current = finalized.filter(Boolean);
const preview = [...transcriptPartsRef.current, currentInterim].join(' ').trim();
setLiveTranscript(preview);
+ if (preview) _resetSilenceTimer();
const totalWords = preview.split(/\s+/).filter(Boolean).length;
if (totalWords >= 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;
@@ -175,6 +204,7 @@ export const CommcoachCoachingView: React.FC = () => {
if (wordCount >= MIN_WORDS_TO_INTERRUPT) coach.sendMessage(fullTranscript);
}
transcriptPartsRef.current = [];
+ processedResultIndexRef.current = 0;
setLiveTranscript('');
setIsUserSpeaking(false);
};
@@ -208,6 +238,7 @@ export const CommcoachCoachingView: React.FC = () => {
init();
return () => {
cancelled = true;
+ if (silenceTimerRef.current) clearTimeout(silenceTimerRef.current);
if (speechRecognitionRef.current) {
try {
speechRecognitionRef.current.stop();
@@ -272,17 +303,17 @@ export const CommcoachCoachingView: React.FC = () => {
onChange={e => setNewCategory(e.target.value)}
>
-
+
-
+
-
- Erstellen
+
+ {coach.actionLoading === 'creating' ? 'Wird erstellt...' : 'Erstellen'}
setShowNewContext(false)}>
Abbrechen
@@ -295,7 +326,7 @@ export const CommcoachCoachingView: React.FC = () => {
{!coach.selectedContextId && !showNewContext && (
Willkommen beim Kommunikations-Coach
-
Waehle ein bestehendes Thema oder erstelle ein neues, um zu beginnen.
+
Wähle ein bestehendes Thema oder erstelle ein neues, um zu beginnen.
setShowNewContext(true)}>
Neues Thema erstellen
@@ -313,7 +344,7 @@ export const CommcoachCoachingView: React.FC = () => {
{personas.length > 0 && (
-
+
{personas.map(p => (
{
coach.startSession(selectedPersonaId)}
+ disabled={!!coach.actionLoading}
>
- Session starten
- {selectedPersonaId && personas.find(p => p.id === selectedPersonaId)
- ? ` mit ${personas.find(p => p.id === selectedPersonaId)!.label}`
- : ''}
+ {coach.actionLoading === 'starting'
+ ? 'Wird gestartet...'
+ : selectedPersonaId && personas.find(p => p.id === selectedPersonaId)
+ ? `Session starten mit ${personas.find(p => p.id === selectedPersonaId)!.label}`
+ : 'Session starten'}
)}
@@ -352,6 +385,16 @@ export const CommcoachCoachingView: React.FC = () => {
Session aktiv - {coach.selectedContext?.title}
+ {isTtsPlaying && (
+
+ Stop
+
+ )}
+ {coach.wasInterrupted && !isTtsPlaying && (
+
+ Weitersprechen
+
+ )}
coach.setMuted(!coach.isMuted)}
@@ -359,11 +402,19 @@ export const CommcoachCoachingView: React.FC = () => {
>
{coach.isMuted ? '\u{1F507}' : '\u{1F3A4}'} {coach.isMuted ? 'Stumm' : 'Ton an'}
-
- Abschliessen
+
+ {coach.actionLoading === 'completing' ? 'Wird abgeschlossen...' : 'Abschliessen'}
-
- Abbrechen
+
+ {coach.actionLoading === 'cancelling' ? 'Wird abgebrochen...' : 'Abbrechen'}
diff --git a/src/pages/views/commcoach/CommcoachDashboardView.tsx b/src/pages/views/commcoach/CommcoachDashboardView.tsx
index d30b8f3..94e8ead 100644
--- a/src/pages/views/commcoach/CommcoachDashboardView.tsx
+++ b/src/pages/views/commcoach/CommcoachDashboardView.tsx
@@ -30,7 +30,7 @@ export const CommcoachDashboardView: React.FC = () => {
}
if (!dashboard) {
- return
Keine Daten verfuegbar.
;
+ return
Keine Daten verfügbar.
;
}
return (
@@ -124,7 +124,7 @@ export const CommcoachDashboardView: React.FC = () => {
Tipp des Tages
-
Konsistenz schlaegt Intensitaet. Auch 10 Minuten taegliches Coaching-Gespraech
+
Konsistenz schlägt Intensität. Auch 10 Minuten tägliches Coaching-Gespräch
bringt messbare Fortschritte in deiner Kommunikationskompetenz.
@@ -134,10 +134,10 @@ export const CommcoachDashboardView: React.FC = () => {
function _categoryLabel(category: string): string {
const labels: Record
= {
- leadership: 'Fuehrung',
+ leadership: 'Führung',
conflict: 'Konflikt',
negotiation: 'Verhandlung',
- presentation: 'Praesentation',
+ presentation: 'Präsentation',
feedback: 'Feedback',
delegation: 'Delegation',
changeManagement: 'Change Mgmt',
diff --git a/src/pages/views/commcoach/CommcoachDossierView.tsx b/src/pages/views/commcoach/CommcoachDossierView.tsx
index 1737590..52c617e 100644
--- a/src/pages/views/commcoach/CommcoachDossierView.tsx
+++ b/src/pages/views/commcoach/CommcoachDossierView.tsx
@@ -102,7 +102,7 @@ export const CommcoachDossierView: React.FC = () => {
{!coach.selectedContextId ? (
- Waehle ein Coaching-Thema.
+ Wähle ein Coaching-Thema.
) : (<>
{/* Context Header */}
@@ -133,8 +133,12 @@ export const CommcoachDossierView: React.FC = () => {
>
)}
- coach.archiveContext(coach.selectedContextId!)}>
- Archivieren
+ coach.archiveContext(coach.selectedContextId!)}
+ disabled={!!coach.actionLoading}
+ >
+ {coach.actionLoading === 'archiving' ? 'Wird archiviert...' : 'Archivieren'}
@@ -178,12 +182,12 @@ export const CommcoachDossierView: React.FC = () => {
onChange={e => setNewTaskTitle(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAddTask()}
/>
-
- Hinzufuegen
+
+ {coach.actionLoading === 'addingTask' ? 'Wird hinzugefügt...' : 'Hinzufügen'}
{coach.tasks.length === 0 ? (
- Noch keine Aufgaben. Der Coach schlaegt waehrend Sessions Aufgaben vor.
+ Noch keine Aufgaben. Der Coach schlägt während Sessions Aufgaben vor.
) : (
{coach.tasks.map(task => (
@@ -317,7 +321,7 @@ export const CommcoachDossierView: React.FC = () => {
{documents.length === 0 ? (
- Keine Dokumente. Lade Dateien hoch, um sie mit diesem Kontext zu verknuepfen.
+ Keine Dokumente. Lade Dateien hoch, um sie mit diesem Kontext zu verknüpfen.
) : (
{documents.map(doc => (
@@ -374,10 +378,10 @@ function _formatFileSize(bytes: number): string {
function _dimensionLabel(dim: string): string {
const labels: Record
= {
- empathy: 'Einfuehlungsvermoegen',
+ empathy: 'Einfühlungsvermögen',
clarity: 'Klarheit',
assertiveness: 'Durchsetzung',
- listening: 'Zuhoeren',
+ listening: 'Zuhören',
selfReflection: 'Selbstreflexion',
};
return labels[dim] || dim;
From 251a9ca1eab124c7c89119d840809fed925cc72b Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Thu, 5 Mar 2026 23:41:41 +0100
Subject: [PATCH 6/7] fixed stt procedure
---
src/api/commcoachApi.ts | 6 +
src/hooks/useCommcoach.ts | 43 +-
src/pages/FeatureView.tsx | 4 +-
.../views/commcoach/CommcoachCoachingView.tsx | 34 +-
.../commcoach/CommcoachDossierView.module.css | 567 +++++++----
.../views/commcoach/CommcoachDossierView.tsx | 890 +++++++++++++-----
src/pages/views/commcoach/index.ts | 1 -
7 files changed, 1051 insertions(+), 494 deletions(-)
diff --git a/src/api/commcoachApi.ts b/src/api/commcoachApi.ts
index 2462668..9a29be1 100644
--- a/src/api/commcoachApi.ts
+++ b/src/api/commcoachApi.ts
@@ -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 = {};
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);
diff --git a/src/hooks/useCommcoach.ts b/src/hooks/useCommcoach.ts
index a7e5eab..df9ab1d 100644
--- a/src/hooks/useCommcoach.ts
+++ b/src/hooks/useCommcoach.ts
@@ -36,7 +36,7 @@ export interface CommcoachHookReturn {
inputValue: string;
setInputValue: (v: string) => void;
- selectContext: (contextId: string) => Promise;
+ selectContext: (contextId: string, options?: { skipSessionResume?: boolean }) => Promise;
createContext: (title: string, description?: string, category?: string, goals?: string[]) => Promise;
archiveContext: (contextId: string) => Promise;
@@ -59,6 +59,8 @@ export interface CommcoachHookReturn {
addTask: (title: string, description?: string) => Promise;
removeTask: (taskId: string) => Promise;
+ onDocumentCreatedRef: MutableRefObject<((doc: any) => void) | null>;
+
refreshContexts: () => Promise;
}
@@ -92,6 +94,7 @@ export function useCommcoach(): CommcoachHookReturn {
const currentAudioRef = useRef(null);
const isTtsPlayingRef = useRef(false);
const lastTtsAudioRef = useRef(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,
};
}
diff --git a/src/pages/FeatureView.tsx b/src/pages/FeatureView.tsx
index c3251dd..b11384f 100644
--- a/src/pages/FeatureView.tsx
+++ b/src/pages/FeatureView.tsx
@@ -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> = {
},
commcoach: {
dashboard: CommcoachDashboardView,
- coaching: CommcoachCoachingView,
+ coaching: CommcoachDossierView,
dossier: CommcoachDossierView,
settings: CommcoachSettingsView,
},
diff --git a/src/pages/views/commcoach/CommcoachCoachingView.tsx b/src/pages/views/commcoach/CommcoachCoachingView.tsx
index 9d0db6f..ff07215 100644
--- a/src/pages/views/commcoach/CommcoachCoachingView.tsx
+++ b/src/pages/views/commcoach/CommcoachCoachingView.tsx
@@ -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 {
diff --git a/src/pages/views/commcoach/CommcoachDossierView.module.css b/src/pages/views/commcoach/CommcoachDossierView.module.css
index 8372ef0..d078335 100644
--- a/src/pages/views/commcoach/CommcoachDossierView.module.css
+++ b/src/pages/views/commcoach/CommcoachDossierView.module.css
@@ -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; }
diff --git a/src/pages/views/commcoach/CommcoachDossierView.tsx b/src/pages/views/commcoach/CommcoachDossierView.tsx
index 52c617e..1ac75f5 100644
--- a/src/pages/views/commcoach/CommcoachDossierView.tsx
+++ b/src/pages/views/commcoach/CommcoachDossierView.tsx
@@ -1,38 +1,65 @@
/**
- * CommCoach Dossier View
- *
- * Shows context detail: sessions timeline, tasks checklist, scores, insights.
+ * CommCoach Dossier View (Main View)
+ *
+ * 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('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([]);
const [uploading, setUploading] = useState(false);
const [scoreHistory, setScoreHistory] = useState>>({});
+ const [personas, setPersonas] = useState([]);
+ const [selectedPersonaId, setSelectedPersonaId] = useState(undefined);
+ const inputRef = useRef(null);
+ const streamRef = useRef(null);
+ const speechRecognitionRef = useRef(null);
+ const transcriptPartsRef = useRef([]);
+ const processedResultIndexRef = useRef(0);
+ const silenceTimerRef = useRef | 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) => {
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 ;
}
- if (coach.contexts.length === 0) {
- return (
-
-
Noch keine Coaching-Themen vorhanden. Erstelle zuerst eines im Coaching-Tab.
-
- );
- }
-
return (
{/* Context Selector */}
@@ -94,261 +330,403 @@ export const CommcoachDossierView: React.FC = () => {
coach.selectContext(ctx.id)}
+ onClick={() => handleSelectContext(ctx.id)}
>
+ {_categoryIcon(ctx.category)}
{ctx.title}
))}
+ setShowNewContext(!showNewContext)}
+ title="Neues Thema"
+ >
+ +
+
- {!coach.selectedContextId ? (
- Wähle ein Coaching-Thema.
- ) : (<>
- {/* Context Header */}
-
-
-
{coach.selectedContext?.title}
- {coach.selectedContext?.description && (
-
{coach.selectedContext.description}
- )}
+ {/* New Context Form */}
+ {showNewContext && (
+
-
- {instanceId && coach.selectedContextId && (
- <>
-
- Export MD
-
-
- Export PDF
-
- >
- )}
-
coach.archiveContext(coach.selectedContextId!)}
- disabled={!!coach.actionLoading}
- >
- {coach.actionLoading === 'archiving' ? 'Wird archiviert...' : 'Archivieren'}
-
+ )}
+
+ {/* No context selected */}
+ {!coach.selectedContextId && !showNewContext && coach.contexts.length === 0 && (
+
+
Willkommen beim Kommunikations-Coach
+
Erstelle ein Thema, um zu beginnen.
+
setShowNewContext(true)}>Neues Thema erstellen
-
+ )}
- {/* Tab Navigation */}
-
- setActiveTab('tasks')}
- >
- Aufgaben ({coach.tasks.length})
-
- setActiveTab('sessions')}
- >
- Sessions ({coach.sessions.length})
-
- setActiveTab('scores')}
- >
- Bewertungen ({coach.scores.length})
-
- setActiveTab('documents')}
- >
- Dokumente ({documents.length})
-
-
-
- {/* Tasks Tab */}
- {activeTab === 'tasks' && (
-
-
-
setNewTaskTitle(e.target.value)}
- onKeyDown={e => e.key === 'Enter' && handleAddTask()}
- />
-
- {coach.actionLoading === 'addingTask' ? 'Wird hinzugefügt...' : 'Hinzufügen'}
+ {coach.selectedContextId && (<>
+ {/* Context Header */}
+
+
+
{coach.selectedContext?.title}
+ {coach.selectedContext?.description && (
+
{coach.selectedContext.description}
+ )}
+
+
+ {instanceId && (
+ <>
+
Export MD
+
Export PDF
+ >
+ )}
+
coach.archiveContext(coach.selectedContextId!)} disabled={!!coach.actionLoading}>
+ {coach.actionLoading === 'archiving' ? 'Wird archiviert...' : 'Archivieren'}
- {coach.tasks.length === 0 ? (
-
Noch keine Aufgaben. Der Coach schlägt während Sessions Aufgaben vor.
- ) : (
-
- {coach.tasks.map(task => (
-
-
coach.toggleTaskStatus(task.id, task.status)}
- >
- {task.status === 'done' ? '\u2713' : '\u25CB'}
-
-
-
{task.title}
- {task.description &&
{task.description}
}
-
-
-
- {task.priority}
-
-
-
coach.removeTask(task.id)}>
- x
-
-
- ))}
-
- )}
- )}
- {/* Sessions Tab */}
- {activeTab === 'sessions' && (
-
- {coach.sessions.length === 0 ? (
-
Noch keine abgeschlossenen Sessions.
- ) : (
-
- {coach.sessions.map(s => (
-
-
-
- {s.status === 'completed' ? 'Abgeschlossen' : s.status === 'active' ? 'Aktiv' : 'Abgebrochen'}
-
-
- {s.startedAt ? new Date(s.startedAt).toLocaleDateString('de-CH') : ''}
-
- {s.competenceScore != null && (
- Score: {Math.round(s.competenceScore)}
- )}
-
- {s.summary && (
-
-
{s.summary}
+ {/* Tab Navigation */}
+
+ {(['coaching', 'tasks', 'sessions', 'scores', 'documents'] as TabKey[]).map(tab => (
+ setActiveTab(tab)}
+ >
+ {_tabLabel(tab, coach, documents)}
+
+ ))}
+
+
+ {/* ============================================================ */}
+ {/* COACHING TAB */}
+ {/* ============================================================ */}
+ {activeTab === 'coaching' && (
+
+ {!coach.session ? (
+
+
Starte eine neue Coaching-Session zu diesem Thema.
+ {personas.length > 0 && (
+
+
+
+ {personas.map(p => (
+ setSelectedPersonaId(selectedPersonaId === p.id ? undefined : p.id)}
+ title={p.description}
+ >
+ {p.gender === 'f' ? '\u2640' : p.gender === 'm' ? '\u2642' : '\u25CB'}
+ {p.label}
+
+ ))}
- )}
-
- {s.messageCount} Nachrichten | {Math.round(s.durationSeconds / 60)} Min.
- {s.personaId &&
| Persona}
- {instanceId && s.status === 'completed' && (
-
e.stopPropagation()}
- >
- Export
-
+
+ )}
+
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'}
+
+
+ ) : (
+ <>
+ {/* Session Header */}
+
+
Session aktiv
+
+ {isTtsPlaying && (
+ Stop
)}
+ {coach.wasInterrupted && !isTtsPlaying && (
+ Weitersprechen
+ )}
+ coach.setMuted(!coach.isMuted)}
+ title={coach.isMuted ? 'Stummschaltung aufheben' : 'Stummschalten'}
+ >
+ {coach.isMuted ? '\u{1F507} Stumm' : '\u{1F3A4} Ton an'}
+
+
+ {coach.actionLoading === 'completing' ? 'Wird abgeschlossen...' : 'Abschliessen'}
+
+
+ {coach.actionLoading === 'cancelling' ? 'Wird abgebrochen...' : 'Abbrechen'}
+
- ))}
-
- )}
-
- )}
- {/* Scores Tab */}
- {activeTab === 'scores' && (
-
- {coach.scores.length === 0 ? (
-
Noch keine Bewertungen. Schliesse eine Session ab, um Scores zu erhalten.
- ) : (
-
- {_groupScoresByDimension(coach.scores).map(group => (
-
-
- {_dimensionLabel(group.dimension)}
- {Math.round(group.latest.score)}/100
-
- {group.latest.trend === 'improving' ? 'steigend' : group.latest.trend === 'declining' ? 'sinkend' : 'stabil'}
-
-
-
- {group.latest.evidence && (
-
{group.latest.evidence}
- )}
- {scoreHistory[group.dimension] && scoreHistory[group.dimension].length > 1 && (
-
-
Verlauf:
-
- {scoreHistory[group.dimension].map((entry, i) => (
-
- {Math.round(entry.score)}
-
- ))}
+ {/* Messages */}
+
+
+ {coach.messages.map(msg => (
+
+
+ {msg.content}
+
+
+ {msg.createdAt ? new Date(msg.createdAt).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' }) : ''}
+
+
+ ))}
+ {liveTranscript && (
+
+ )}
+ {coach.isStreaming && (
+
+
+ {coach.streamingMessage ? (
+
{coach.streamingMessage}
+ ) : (
+
{coach.streamingStatus || 'Coach denkt nach'}...
+ )}
+
-
- )}
-
- ))}
-
- )}
-
- )}
-
- {/* Documents Tab */}
- {activeTab === 'documents' && (
-
-
-
-
- {documents.length === 0 ? (
-
Keine Dokumente. Lade Dateien hoch, um sie mit diesem Kontext zu verknüpfen.
- ) : (
-
- {documents.map(doc => (
-
-
-
{doc.fileName}
-
- {_formatFileSize(doc.fileSize)} | {doc.createdAt ? new Date(doc.createdAt).toLocaleDateString('de-CH') : ''}
-
- {doc.summary && (
-
{doc.summary}
)}
-
handleDeleteDocument(doc.id)}>
- x
-
+
+
+ {/* Input Area */}
+
+
+
+ {coach.isMuted
+ ? 'Stumm – Mikrofon aus'
+ : coach.isStreaming
+ ? (coach.streamingStatus || 'Coach antwortet...')
+ : isUserSpeaking
+ ? 'Spricht...'
+ : isListening
+ ? 'Mikrofon an – bitte sprechen'
+ : 'Mikrofon wird gestartet...'}
+
+
+
+
- ))}
+ >
+ )}
+ {coach.error &&
{coach.error}
}
+
+ )}
+
+ {/* ============================================================ */}
+ {/* TASKS TAB */}
+ {/* ============================================================ */}
+ {activeTab === 'tasks' && (
+
+
+ setNewTaskTitle(e.target.value)}
+ onKeyDown={e => e.key === 'Enter' && handleAddTask()}
+ />
+
+ {coach.actionLoading === 'addingTask' ? 'Wird hinzugefügt...' : 'Hinzufügen'}
+
- )}
-
- )}
+ {coach.tasks.length === 0 ? (
+
Noch keine Aufgaben. Der Coach schlägt während Sessions Aufgaben vor.
+ ) : (
+
+ {coach.tasks.map(task => (
+
+
coach.toggleTaskStatus(task.id, task.status)}>
+ {task.status === 'done' ? '\u2713' : '\u25CB'}
+
+
+
{task.title}
+ {task.description &&
{task.description}
}
+
+
+ {task.priority}
+
+
coach.removeTask(task.id)}>x
+
+ ))}
+
+ )}
+
+ )}
+
+ {/* ============================================================ */}
+ {/* SESSIONS TAB */}
+ {/* ============================================================ */}
+ {activeTab === 'sessions' && (
+
+ {coach.sessions.length === 0 ? (
+
Noch keine abgeschlossenen Sessions.
+ ) : (
+
+ {coach.sessions.map(s => (
+
+
+
+ {s.status === 'completed' ? 'Abgeschlossen' : s.status === 'active' ? 'Aktiv' : 'Abgebrochen'}
+
+ {s.startedAt ? new Date(s.startedAt).toLocaleDateString('de-CH') : ''}
+ {s.competenceScore != null && Score: {Math.round(s.competenceScore)}}
+
+ {s.summary && (
+
{s.summary}
+ )}
+
+ {s.messageCount} Nachrichten | {Math.round(s.durationSeconds / 60)} Min.
+ {s.personaId &&
| Persona}
+ {instanceId && s.status === 'completed' && (
+
e.stopPropagation()}>Export
+ )}
+
+
+ ))}
+
+ )}
+
+ )}
+
+ {/* ============================================================ */}
+ {/* SCORES TAB */}
+ {/* ============================================================ */}
+ {activeTab === 'scores' && (
+
+ {coach.scores.length === 0 ? (
+
Noch keine Bewertungen. Schliesse eine Session ab, um Scores zu erhalten.
+ ) : (
+
+ {_groupScoresByDimension(coach.scores).map(group => (
+
+
+ {_dimensionLabel(group.dimension)}
+ {Math.round(group.latest.score)}/100
+
+ {group.latest.trend === 'improving' ? 'steigend' : group.latest.trend === 'declining' ? 'sinkend' : 'stabil'}
+
+
+
+ {group.latest.evidence &&
{group.latest.evidence}
}
+ {scoreHistory[group.dimension] && scoreHistory[group.dimension].length > 1 && (
+
+
Verlauf:
+
+ {scoreHistory[group.dimension].map((entry, i) => (
+ {Math.round(entry.score)}
+ ))}
+
+
+ )}
+
+ ))}
+
+ )}
+
+ )}
+
+ {/* ============================================================ */}
+ {/* DOCUMENTS TAB */}
+ {/* ============================================================ */}
+ {activeTab === 'documents' && (
+
+
+
+
+ {documents.length === 0 ? (
+
Keine Dokumente. Lade Dateien hoch oder bitte den Coach, eines zu erstellen.
+ ) : (
+
+ {documents.map(doc => (
+
+
+
{doc.fileName}
+
+ {_formatFileSize(doc.fileSize)} | {doc.createdAt ? new Date(doc.createdAt).toLocaleDateString('de-CH') : ''}
+
+ {doc.summary &&
{doc.summary}
}
+
+
+ handleDownloadDocument(doc)} disabled={!doc.fileRef}>Download
+ handleDeleteDocument(doc.id)}>x
+
+
+ ))}
+
+ )}
+
+ )}
>)}
);
};
+function _categoryIcon(category: string): string {
+ const icons: Record
= {
+ 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 = {};
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 = {
- 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;
diff --git a/src/pages/views/commcoach/index.ts b/src/pages/views/commcoach/index.ts
index 6f4299b..f986d2d 100644
--- a/src/pages/views/commcoach/index.ts
+++ b/src/pages/views/commcoach/index.ts
@@ -1,4 +1,3 @@
export { CommcoachDashboardView } from './CommcoachDashboardView';
-export { CommcoachCoachingView } from './CommcoachCoachingView';
export { CommcoachDossierView } from './CommcoachDossierView';
export { CommcoachSettingsView } from './CommcoachSettingsView';
From cb45ecd52b2f68c0682b89efe6d017d760180367 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Fri, 6 Mar 2026 12:40:06 +0100
Subject: [PATCH 7/7] commcoach: unused totalWords entfernt
Made-with: Cursor
---
src/pages/views/commcoach/CommcoachDossierView.tsx | 1 -
1 file changed, 1 deletion(-)
diff --git a/src/pages/views/commcoach/CommcoachDossierView.tsx b/src/pages/views/commcoach/CommcoachDossierView.tsx
index 1ac75f5..57ea656 100644
--- a/src/pages/views/commcoach/CommcoachDossierView.tsx
+++ b/src/pages/views/commcoach/CommcoachDossierView.tsx
@@ -182,7 +182,6 @@ export const CommcoachDossierView: React.FC = () => {
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();