Alle 9 Fixes sind implementiert. Hier die Zusammenfassung:

Fix 1 -- Opening-Prompt: processSessionOpening in serviceCommcoach.py prüft jetzt ob es die erste Session ist (isFirstSession) und gibt der AI einen expliziten Prompt, der das Erfinden von Kontext verbietet.
Fix 2 -- Stabiler Transcript: onresult in CommcoachCoachingView.tsx nutzt jetzt processedResultIndexRef um nur neue Results zu verarbeiten. Finalisierte Teile werden stabil akkumuliert, kein Flackern mehr.
Fix 3 -- Hintergrundgeräusche-Timeout: Neuer silenceTimerRef mit 5s Timeout. Wenn nach onspeechstart kein Text kommt, wird isUserSpeaking automatisch zurückgesetzt.
Fix 4 -- Stop-Button: "Stop" Button erscheint im Session-Header wenn TTS läuft (via isTtsPlaying State, synchronisiert per 200ms Interval mit isTtsPlayingRef).
Fix 5 -- Weitersprechen-Button: lastTtsAudioRef speichert das zuletzt gespielte Audio. stopTts setzt wasInterrupted = true. "Weitersprechen" Button erscheint nach Unterbrechung und spielt das Audio erneut ab.
Fix 6 -- Paralleles TTS: Neue _generateAndEmitTts() Hilfsfunktion. In processMessage und processSessionOpening wird TTS als asyncio.create_task parallel zu _emitChunkedResponse gestartet.
Fix 7 -- JSON-Response: Die AI antwortet jetzt als JSON mit text, speech, documents. Neuer Prompt-Block wird in buildCoachingSystemPrompt angehängt. _parseAiJsonResponse() und _saveGeneratedDocument() im Backend. processMessage und processSessionOpening nutzen die neue Struktur.
Fix 8 -- Loading-States: Neuer actionLoading State in useCommcoach. Alle async Funktionen setzen setActionLoading('key') vor dem Await und null im finally. Buttons zeigen Loading-Text und werden disabled.
Fix 9 -- Umlaute: Alle deutschen Strings in allen CommCoach-Dateien (Backend + Frontend) korrigiert: ae->ä, oe->ö, ue->ü.
This commit is contained in:
ValueOn AG 2026-03-04 22:53:45 +01:00
parent 26044ff53b
commit 36b8558dd0
4 changed files with 127 additions and 32 deletions

View file

@ -49,8 +49,12 @@ export interface CommcoachHookReturn {
isMuted: boolean;
setMuted: (muted: boolean) => void;
stopTts: () => void;
resumeTts: () => void;
wasInterrupted: boolean;
isTtsPlayingRef: MutableRefObject<boolean>;
actionLoading: string | null;
toggleTaskStatus: (taskId: string, currentStatus: string) => Promise<void>;
addTask: (title: string, description?: string) => Promise<void>;
removeTask: (taskId: string) => Promise<void>;
@ -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<string | null>(null);
const isMountedRef = useRef(true);
const currentAudioRef = useRef<HTMLAudioElement | null>(null);
const isTtsPlayingRef = useRef(false);
const lastTtsAudioRef = useRef<string | null>(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,
};

View file

@ -33,9 +33,12 @@ export const CommcoachCoachingView: React.FC = () => {
const streamRef = useRef<MediaStream | null>(null);
const speechRecognitionRef = useRef<SpeechRecognition | null>(null);
const transcriptPartsRef = useRef<string[]>([]);
const processedResultIndexRef = useRef(0);
const silenceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [isListening, setIsListening] = useState(false);
const [isUserSpeaking, setIsUserSpeaking] = useState(false);
const [liveTranscript, setLiveTranscript] = useState('');
const [isTtsPlaying, setIsTtsPlaying] = useState(false);
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)}
>
<option value="custom">Individuell</option>
<option value="leadership">Fuehrung</option>
<option value="leadership">Führung</option>
<option value="conflict">Konflikt</option>
<option value="negotiation">Verhandlung</option>
<option value="presentation">Praesentation</option>
<option value="presentation">Präsentation</option>
<option value="feedback">Feedback</option>
<option value="delegation">Delegation</option>
<option value="changeManagement">Change Management</option>
</select>
<div className={styles.newContextActions}>
<button className={styles.btnPrimary} onClick={handleCreateContext} disabled={!newTitle.trim()}>
Erstellen
<button className={styles.btnPrimary} onClick={handleCreateContext} disabled={!newTitle.trim() || !!coach.actionLoading}>
{coach.actionLoading === 'creating' ? 'Wird erstellt...' : 'Erstellen'}
</button>
<button className={styles.btnSecondary} onClick={() => setShowNewContext(false)}>
Abbrechen
@ -295,7 +326,7 @@ export const CommcoachCoachingView: React.FC = () => {
{!coach.selectedContextId && !showNewContext && (
<div className={styles.noContext}>
<h3>Willkommen beim Kommunikations-Coach</h3>
<p>Waehle ein bestehendes Thema oder erstelle ein neues, um zu beginnen.</p>
<p>Wähle ein bestehendes Thema oder erstelle ein neues, um zu beginnen.</p>
<button className={styles.btnPrimary} onClick={() => setShowNewContext(true)}>
Neues Thema erstellen
</button>
@ -313,7 +344,7 @@ export const CommcoachCoachingView: React.FC = () => {
{personas.length > 0 && (
<div className={styles.personaSelector}>
<label className={styles.personaLabel}>Gespraechspartner waehlen:</label>
<label className={styles.personaLabel}>Gesprächspartner hlen:</label>
<div className={styles.personaGrid}>
{personas.map(p => (
<button
@ -335,11 +366,13 @@ export const CommcoachCoachingView: React.FC = () => {
<button
className={styles.btnPrimary}
onClick={() => 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'}
</button>
</div>
)}
@ -352,6 +385,16 @@ export const CommcoachCoachingView: React.FC = () => {
Session aktiv - {coach.selectedContext?.title}
</span>
<div className={styles.sessionActions}>
{isTtsPlaying && (
<button className={styles.btnSmallDanger} onClick={coach.stopTts}>
Stop
</button>
)}
{coach.wasInterrupted && !isTtsPlaying && (
<button className={styles.btnSmall} onClick={coach.resumeTts}>
Weitersprechen
</button>
)}
<button
className={`${styles.btnSmall} ${coach.isMuted ? styles.mutedActive : ''}`}
onClick={() => coach.setMuted(!coach.isMuted)}
@ -359,11 +402,19 @@ export const CommcoachCoachingView: React.FC = () => {
>
{coach.isMuted ? '\u{1F507}' : '\u{1F3A4}'} {coach.isMuted ? 'Stumm' : 'Ton an'}
</button>
<button className={styles.btnSmall} onClick={coach.completeSession}>
Abschliessen
<button
className={styles.btnSmall}
onClick={coach.completeSession}
disabled={!!coach.actionLoading}
>
{coach.actionLoading === 'completing' ? 'Wird abgeschlossen...' : 'Abschliessen'}
</button>
<button className={styles.btnSmallDanger} onClick={coach.cancelSession}>
Abbrechen
<button
className={styles.btnSmallDanger}
onClick={coach.cancelSession}
disabled={!!coach.actionLoading}
>
{coach.actionLoading === 'cancelling' ? 'Wird abgebrochen...' : 'Abbrechen'}
</button>
</div>
</div>

View file

@ -30,7 +30,7 @@ export const CommcoachDashboardView: React.FC = () => {
}
if (!dashboard) {
return <div className={styles.empty}>Keine Daten verfuegbar.</div>;
return <div className={styles.empty}>Keine Daten verfügbar.</div>;
}
return (
@ -124,7 +124,7 @@ export const CommcoachDashboardView: React.FC = () => {
<div className={styles.section}>
<h3 className={styles.sectionTitle}>Tipp des Tages</h3>
<div className={styles.tipCard}>
<p>Konsistenz schlaegt Intensitaet. Auch 10 Minuten taegliches Coaching-Gespraech
<p>Konsistenz schlägt Intensität. Auch 10 Minuten tägliches Coaching-Gespräch
bringt messbare Fortschritte in deiner Kommunikationskompetenz.</p>
</div>
</div>
@ -134,10 +134,10 @@ export const CommcoachDashboardView: React.FC = () => {
function _categoryLabel(category: string): string {
const labels: Record<string, string> = {
leadership: 'Fuehrung',
leadership: 'Führung',
conflict: 'Konflikt',
negotiation: 'Verhandlung',
presentation: 'Praesentation',
presentation: 'Präsentation',
feedback: 'Feedback',
delegation: 'Delegation',
changeManagement: 'Change Mgmt',

View file

@ -102,7 +102,7 @@ export const CommcoachDossierView: React.FC = () => {
</div>
{!coach.selectedContextId ? (
<div className={styles.empty}><p>Waehle ein Coaching-Thema.</p></div>
<div className={styles.empty}><p>Wähle ein Coaching-Thema.</p></div>
) : (<>
{/* Context Header */}
<div className={styles.header}>
@ -133,8 +133,12 @@ export const CommcoachDossierView: React.FC = () => {
</a>
</>
)}
<button className={styles.btnArchive} onClick={() => coach.archiveContext(coach.selectedContextId!)}>
Archivieren
<button
className={styles.btnArchive}
onClick={() => coach.archiveContext(coach.selectedContextId!)}
disabled={!!coach.actionLoading}
>
{coach.actionLoading === 'archiving' ? 'Wird archiviert...' : 'Archivieren'}
</button>
</div>
</div>
@ -178,12 +182,12 @@ export const CommcoachDossierView: React.FC = () => {
onChange={e => setNewTaskTitle(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAddTask()}
/>
<button className={styles.addTaskBtn} onClick={handleAddTask} disabled={!newTaskTitle.trim()}>
Hinzufuegen
<button className={styles.addTaskBtn} onClick={handleAddTask} disabled={!newTaskTitle.trim() || !!coach.actionLoading}>
{coach.actionLoading === 'addingTask' ? 'Wird hinzugefügt...' : 'Hinzufügen'}
</button>
</div>
{coach.tasks.length === 0 ? (
<div className={styles.emptyTab}>Noch keine Aufgaben. Der Coach schlaegt waehrend Sessions Aufgaben vor.</div>
<div className={styles.emptyTab}>Noch keine Aufgaben. Der Coach schlägt hrend Sessions Aufgaben vor.</div>
) : (
<div className={styles.taskList}>
{coach.tasks.map(task => (
@ -317,7 +321,7 @@ export const CommcoachDossierView: React.FC = () => {
</label>
</div>
{documents.length === 0 ? (
<div className={styles.emptyTab}>Keine Dokumente. Lade Dateien hoch, um sie mit diesem Kontext zu verknuepfen.</div>
<div className={styles.emptyTab}>Keine Dokumente. Lade Dateien hoch, um sie mit diesem Kontext zu verknüpfen.</div>
) : (
<div className={styles.documentList}>
{documents.map(doc => (
@ -374,10 +378,10 @@ function _formatFileSize(bytes: number): string {
function _dimensionLabel(dim: string): string {
const labels: Record<string, string> = {
empathy: 'Einfuehlungsvermoegen',
empathy: 'Einfühlungsvermögen',
clarity: 'Klarheit',
assertiveness: 'Durchsetzung',
listening: 'Zuhoeren',
listening: 'Zuhören',
selfReflection: 'Selbstreflexion',
};
return labels[dim] || dim;