commcoach agent integration: keep-alive persistence, input bar, voice controller fixes

Made-with: Cursor
This commit is contained in:
ValueOn AG 2026-04-01 22:04:02 +02:00
parent 2509fbdcf2
commit d3d054b132
8 changed files with 617 additions and 47 deletions

View file

@ -285,6 +285,13 @@ export async function cancelSessionApi(request: ApiRequestFunction, instanceId:
// Streaming Chat API
// ============================================================================
export interface SendMessageOptions {
fileIds?: string[];
dataSourceIds?: string[];
featureDataSourceIds?: string[];
allowedProviders?: string[];
}
export async function sendMessageStreamApi(
instanceId: string,
sessionId: string,
@ -293,6 +300,7 @@ export async function sendMessageStreamApi(
onError?: (error: Error) => void,
onComplete?: () => void,
signal?: AbortSignal,
options?: SendMessageOptions,
): Promise<void> {
try {
const baseURL = api.defaults.baseURL || '';
@ -304,10 +312,16 @@ export async function sendMessageStreamApi(
if (!getCSRFToken()) generateAndStoreCSRFToken();
addCSRFTokenToHeaders(headers);
const body: Record<string, unknown> = { content };
if (options?.fileIds?.length) body.fileIds = options.fileIds;
if (options?.dataSourceIds?.length) body.dataSourceIds = options.dataSourceIds;
if (options?.featureDataSourceIds?.length) body.featureDataSourceIds = options.featureDataSourceIds;
if (options?.allowedProviders?.length) body.allowedProviders = options.allowedProviders;
const response = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify({ content }),
body: JSON.stringify(body),
credentials: 'include',
signal,
});

View file

@ -14,6 +14,7 @@ import {
createTaskApi, updateTaskStatusApi, deleteTaskApi,
type CoachingContext, type CoachingSession, type CoachingMessage,
type CoachingTask, type CoachingScore, type SSEEvent,
type SendMessageOptions,
} from '../api/commcoachApi';
import { useTtsPlayback, type TtsEvent } from './useTtsPlayback';
@ -37,12 +38,14 @@ export interface CommcoachHookReturn {
inputValue: string;
setInputValue: (v: string) => void;
agentToolCalls: Array<{ toolName: string; args?: Record<string, unknown>; result?: string; success?: boolean }>;
selectContext: (contextId: string, options?: { skipSessionResume?: boolean }) => Promise<void>;
createContext: (title: string, description?: string, category?: string, goals?: string[]) => Promise<void>;
archiveContext: (contextId: string) => Promise<void>;
startSession: (personaId?: string) => Promise<void>;
sendMessage: (content: string) => Promise<void>;
sendMessage: (content: string, options?: SendMessageOptions) => Promise<void>;
sendAudio: (audioBlob: Blob) => Promise<void>;
completeSession: () => Promise<void>;
cancelSession: () => Promise<void>;
@ -67,9 +70,10 @@ export interface CommcoachHookReturn {
refreshContexts: () => Promise<void>;
}
export function useCommcoach(): CommcoachHookReturn {
export function useCommcoach(instanceIdOverride?: string): CommcoachHookReturn {
const { request } = useApiRequest();
const instanceId = useInstanceId();
const routeInstanceId = useInstanceId();
const instanceId = instanceIdOverride || routeInstanceId;
const [contexts, setContexts] = useState<CoachingContext[]>([]);
const [selectedContextId, setSelectedContextId] = useState<string | null>(null);
@ -88,6 +92,7 @@ export function useCommcoach(): CommcoachHookReturn {
const [error, setError] = useState<string | null>(null);
const [inputValue, setInputValue] = useState('');
const [agentToolCalls, setAgentToolCalls] = useState<Array<{ toolName: string; args?: Record<string, unknown>; result?: string; success?: boolean }>>([]);
const [actionLoading, setActionLoading] = useState<string | null>(null);
@ -239,6 +244,7 @@ export function useCommcoach(): CommcoachHookReturn {
setError(null);
setIsStreaming(true);
setStreamingStatus(null);
setStreamingMessage(null);
setMessages([]);
setSession(null);
try {
@ -259,7 +265,7 @@ export function useCommcoach(): CommcoachHookReturn {
setMessages(eventData.messages);
}
} else if (eventType === 'messageChunk' && eventData) {
setStreamingMessage(eventData.accumulated || '');
setStreamingStatus(prev => prev || 'Coach formuliert Antwort...');
} else if (eventType === 'message' && eventData) {
setStreamingMessage(null);
const msg: CoachingMessage = {
@ -313,7 +319,7 @@ export function useCommcoach(): CommcoachHookReturn {
}
}, [instanceId, selectedContextId, ttsPlayback.play]);
const sendMessage = useCallback(async (content: string) => {
const sendMessage = useCallback(async (content: string, options?: SendMessageOptions) => {
const normalizedContent = content.trim();
if (!normalizedContent || !instanceId || !session) return;
@ -326,6 +332,8 @@ export function useCommcoach(): CommcoachHookReturn {
setError(null);
setIsStreaming(true);
setStreamingStatus(null);
setStreamingMessage(null);
setAgentToolCalls([]);
const tempMsg: CoachingMessage = {
id: `temp-${Date.now()}`,
@ -350,7 +358,7 @@ export function useCommcoach(): CommcoachHookReturn {
const eventData = event.data;
if (eventType === 'messageChunk' && eventData) {
setStreamingMessage(eventData.accumulated || '');
setStreamingStatus(prev => prev || 'Coach formuliert Antwort...');
} else if (eventType === 'message' && eventData) {
setStreamingMessage(null);
const msg: CoachingMessage = {
@ -374,6 +382,17 @@ export function useCommcoach(): CommcoachHookReturn {
ttsPlayback.play(eventData.audio);
} else if (eventType === 'status' && eventData) {
setStreamingStatus(eventData.label || null);
} else if (eventType === 'toolCall' && eventData) {
setAgentToolCalls(prev => [...prev, { toolName: eventData.toolName, args: eventData.args }]);
setStreamingStatus(`Tool: ${eventData.toolName}...`);
} else if (eventType === 'toolResult' && eventData) {
setAgentToolCalls(prev => prev.map((tc, idx) =>
idx === prev.length - 1
? { ...tc, result: eventData.data?.slice(0, 200), success: eventData.success }
: tc
));
} else if (eventType === 'agentProgress' && eventData) {
setStreamingStatus(`Runde ${eventData.round}/${eventData.maxRounds}...`);
} else if (eventType === 'taskCreated' && eventData) {
setTasks(prev => [eventData, ...prev]);
} else if (eventType === 'documentCreated' && eventData) {
@ -400,6 +419,7 @@ export function useCommcoach(): CommcoachHookReturn {
}
},
ac.signal,
options,
);
} catch (err: any) {
if (err.name === 'AbortError') return;
@ -417,6 +437,7 @@ export function useCommcoach(): CommcoachHookReturn {
setError(null);
setIsStreaming(true);
setStreamingStatus(null);
setStreamingMessage(null);
try {
await sendAudioStreamApi(
instanceId,
@ -427,7 +448,9 @@ export function useCommcoach(): CommcoachHookReturn {
const eventType = event.type;
const eventData = event.data;
if (eventType === 'status' && eventData) {
if (eventType === 'messageChunk' && eventData) {
setStreamingStatus(prev => prev || 'Coach formuliert Antwort...');
} else if (eventType === 'status' && eventData) {
setStreamingStatus(eventData.label || null);
} else if (eventType === 'message' && eventData) {
if (eventData.role === 'assistant') setError(null);
@ -555,6 +578,7 @@ export function useCommcoach(): CommcoachHookReturn {
session, messages, isStreaming, streamingStatus, streamingMessage,
tasks, scores, sessions,
error, inputValue, setInputValue,
agentToolCalls,
selectContext, createContext, archiveContext,
startSession: startSessionCb,
sendMessage, sendAudio,

View file

@ -11,9 +11,11 @@ import { FeatureProvider, useFeatureStore } from '../stores/featureStore';
import { MandateNavigation } from '../components/Navigation/MandateNavigation';
import { UserSection } from '../components/Navigation/UserSection';
import { WorkspaceKeepAlive } from '../pages/views/workspace/WorkspaceKeepAlive';
import { CommcoachKeepAlive } from '../pages/views/commcoach/CommcoachKeepAlive';
import styles from './MainLayout.module.css';
const _WORKSPACE_ROUTE_RE = /\/mandates\/[^/]+\/workspace\/[^/]+\/dashboard/;
const _COMMCOACH_ROUTE_RE = /\/mandates\/[^/]+\/commcoach\/[^/]+\/(?:coaching|dossier)/;
// =============================================================================
// INNER LAYOUT (mit Zugriff auf Store)
@ -23,6 +25,9 @@ const MainLayoutInner: React.FC = () => {
const { loadFeatures, initialized, loading, error } = useFeatureStore();
const location = useLocation();
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
const isWorkspaceKeepAliveVisible = _WORKSPACE_ROUTE_RE.test(location.pathname);
const isCommcoachKeepAliveVisible = _COMMCOACH_ROUTE_RE.test(location.pathname);
const hideOutletShell = isWorkspaceKeepAliveVisible || isCommcoachKeepAliveVisible;
// Features laden beim Mount
useEffect(() => {
@ -105,11 +110,12 @@ const MainLayoutInner: React.FC = () => {
/>
</div>
<WorkspaceKeepAlive isVisible={_WORKSPACE_ROUTE_RE.test(location.pathname)} />
<WorkspaceKeepAlive isVisible={isWorkspaceKeepAliveVisible} />
<CommcoachKeepAlive isVisible={isCommcoachKeepAliveVisible} />
<div
className={styles.outletShell}
style={{ display: _WORKSPACE_ROUTE_RE.test(location.pathname) ? 'none' : undefined }}
style={{ display: hideOutletShell ? 'none' : undefined }}
>
<Outlet />
</div>

View file

@ -219,6 +219,11 @@ export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
return null;
}
// CommCoach coaching/dossier is rendered persistently by CommcoachKeepAlive at MainLayout level.
if (featureCode === 'commcoach' && (view === 'coaching' || view === 'dossier')) {
return null;
}
// View-Komponente finden
const featureViews = VIEW_COMPONENTS[featureCode];
if (!featureViews) {

View file

@ -406,6 +406,115 @@
.typingDots { animation: blink 1.4s infinite both; }
@keyframes blink { 0%, 80%, 100% { opacity: 0; } 40% { opacity: 1; } }
.agentActivityPanel {
margin: 0 1rem 0.75rem;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 10px;
background: var(--bg-card, #fff);
overflow: hidden;
flex-shrink: 0;
}
.agentActivityHeader {
width: 100%;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.65rem 0.9rem;
background: var(--bg-hover, #f8f8f8);
border: none;
cursor: pointer;
text-align: left;
color: var(--text-primary, #333);
}
.agentActivityTitle {
font-size: 0.85rem;
font-weight: 600;
}
.agentActivityStatus {
flex: 1;
min-width: 0;
font-size: 0.78rem;
color: var(--text-secondary, #777);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.agentActivityChevron {
font-size: 0.8rem;
color: var(--text-secondary, #777);
}
.agentActivityBody {
display: flex;
flex-direction: column;
gap: 0.6rem;
padding: 0.8rem 0.9rem;
max-height: 220px;
overflow-y: auto;
}
.agentActivityEmpty {
font-size: 0.8rem;
color: var(--text-secondary, #777);
}
.agentActivityItem {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.65rem 0.75rem;
border: 1px solid var(--border-color, #ededed);
border-radius: 8px;
background: var(--bg-secondary, #fafafa);
}
.agentActivityItemHeader {
display: flex;
align-items: center;
gap: 0.5rem;
}
.agentActivityToolName {
font-size: 0.82rem;
font-weight: 600;
color: var(--text-primary, #333);
}
.agentActivityBadge {
padding: 0.12rem 0.42rem;
border-radius: 999px;
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.agentActivityBadgeRunning {
background: #e3f2fd;
color: #1565c0;
}
.agentActivityBadgeSuccess {
background: #e8f5e9;
color: #2e7d32;
}
.agentActivityBadgeError {
background: #fde8e8;
color: #c62828;
}
.agentActivityMeta {
font-size: 0.76rem;
color: var(--text-secondary, #666);
line-height: 1.45;
word-break: break-word;
}
/* Input Area */
.inputArea {
display: flex;

View file

@ -15,22 +15,58 @@ import {
getDossierExportUrl, getSessionExportUrl,
getScoreHistoryApi, getPersonasApi,
type CoachingPersona,
type SendMessageOptions,
} from '../../../api/commcoachApi';
import api from '../../../api';
import AutoScroll from '../../../components/UiComponents/AutoScroll/AutoScroll';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar';
import { ProviderMultiSelect, _defaultProviderSelection } from '../../../components/ProviderSelector';
import type { ProviderSelection } from '../../../components/ProviderSelector';
import { getPageIcon } from '../../../config/pageRegistry';
import styles from './CommcoachDossierView.module.css';
import { useVoiceController } from './useVoiceController';
interface WorkspaceFileInfo {
id: string;
fileName: string;
mimeType: string;
fileSize: number;
}
interface DataSourceInfo {
id: string;
connectionId: string;
sourceType: string;
path: string;
label: string;
}
interface FeatureDataSourceInfo {
id: string;
featureInstanceId: string;
featureCode: string;
tableName: string;
label: string;
}
type TabKey = 'coaching' | 'tasks' | 'sessions' | 'scores';
export const CommcoachDossierView: React.FC = () => {
const coach = useCommcoach();
interface CommcoachDossierViewProps {
persistentInstanceId?: string;
persistentMandateId?: string;
}
export const CommcoachDossierView: React.FC<CommcoachDossierViewProps> = ({
persistentInstanceId,
persistentMandateId,
}) => {
const routeInstanceId = useInstanceId();
const routeMandateId = useMandateId();
const instanceId = persistentInstanceId || routeInstanceId;
const mandateId = persistentMandateId || routeMandateId;
const coach = useCommcoach(instanceId);
const { request } = useApiRequest();
const instanceId = useInstanceId();
const mandateId = useMandateId();
const [activeTab, setActiveTab] = useState<TabKey>('coaching');
const [showNewContext, setShowNewContext] = useState(false);
@ -45,6 +81,17 @@ export const CommcoachDossierView: React.FC = () => {
const [personas, setPersonas] = useState<CoachingPersona[]>([]);
const [selectedPersonaId, setSelectedPersonaId] = useState<string | undefined>(undefined);
const [wsFiles, setWsFiles] = useState<WorkspaceFileInfo[]>([]);
const [wsDataSources, setWsDataSources] = useState<DataSourceInfo[]>([]);
const [wsFeatureDataSources, setWsFeatureDataSources] = useState<FeatureDataSourceInfo[]>([]);
const [attachedFileIds, setAttachedFileIds] = useState<string[]>([]);
const [attachedDsIds, setAttachedDsIds] = useState<string[]>([]);
const [attachedFdsIds, setAttachedFdsIds] = useState<string[]>([]);
const [providerSelection, setProviderSelection] = useState<ProviderSelection>(_defaultProviderSelection);
const [showSourcePicker, setShowSourcePicker] = useState(false);
const [showFilePicker, setShowFilePicker] = useState(false);
const [showAgentActivity, setShowAgentActivity] = useState(true);
const _udbContext: UdbContext | null = instanceId
? { instanceId, mandateId: mandateId || undefined, featureInstanceId: instanceId }
: null;
@ -53,23 +100,26 @@ export const CommcoachDossierView: React.FC = () => {
const sendMessageRef = useRef(coach.sendMessage);
sendMessageRef.current = coach.sendMessage;
const voice = useVoiceController({
onFinalText: (text) => sendMessageRef.current(text),
});
const attachedFileIdsRef = useRef(attachedFileIds);
attachedFileIdsRef.current = attachedFileIds;
const attachedDsIdsRef = useRef(attachedDsIds);
attachedDsIdsRef.current = attachedDsIds;
const attachedFdsIdsRef = useRef(attachedFdsIds);
attachedFdsIdsRef.current = attachedFdsIds;
const providerSelRef = useRef(providerSelection);
providerSelRef.current = providerSelection;
// #region agent log
const debugLogsRef = useRef<string[]>([]);
const [debugVisible, setDebugVisible] = useState(false);
const [debugSnapshot, setDebugSnapshot] = useState<string[]>([]);
const _dlog = useCallback((tag: string, info?: string) => {
const t = new Date();
const ts = `${t.getMinutes()}:${String(t.getSeconds()).padStart(2,'0')}.${String(t.getMilliseconds()).padStart(3,'0')}`;
const entry = `[${ts}] ${tag}${info ? ' ' + info : ''}`;
debugLogsRef.current.push(entry);
if (debugLogsRef.current.length > 80) debugLogsRef.current.shift();
}, []);
useEffect(() => { (window as any).__dlog = _dlog; return () => { delete (window as any).__dlog; }; }, [_dlog]);
// #endregion
const voice = useVoiceController({
onFinalText: (text) => {
const opts: SendMessageOptions = {};
if (attachedFileIdsRef.current.length) opts.fileIds = attachedFileIdsRef.current;
if (attachedDsIdsRef.current.length) opts.dataSourceIds = attachedDsIdsRef.current;
if (attachedFdsIdsRef.current.length) opts.featureDataSourceIds = attachedFdsIdsRef.current;
const allowed = providerSelRef.current.include.length > 0 ? providerSelRef.current.include : undefined;
if (allowed) opts.allowedProviders = allowed;
sendMessageRef.current(text, Object.keys(opts).length ? opts : undefined);
},
});
useEffect(() => {
coach.onTtsEventRef.current = (event: TtsEvent) => {
@ -103,6 +153,23 @@ export const CommcoachDossierView: React.FC = () => {
.catch(() => {});
}, [instanceId, request]);
const _refreshWorkspaceAssets = useCallback(() => {
if (!instanceId) return;
api.get(`/api/workspace/${instanceId}/files`).then(r => setWsFiles(r.data.files || [])).catch(() => {});
api.get(`/api/workspace/${instanceId}/datasources`).then(r => setWsDataSources(r.data.dataSources || [])).catch(() => {});
api.get(`/api/workspace/${instanceId}/feature-datasources`).then(r => setWsFeatureDataSources(r.data.featureDataSources || [])).catch(() => {});
}, [instanceId]);
useEffect(() => {
_refreshWorkspaceAssets();
}, [_refreshWorkspaceAssets]);
useEffect(() => {
const _handleFileUploaded = () => _refreshWorkspaceAssets();
window.addEventListener('fileUploaded', _handleFileUploaded);
return () => window.removeEventListener('fileUploaded', _handleFileUploaded);
}, [_refreshWorkspaceAssets]);
useEffect(() => {
if (activeTab !== 'coaching' || !coach.session) {
voice.deactivate();
@ -118,16 +185,44 @@ export const CommcoachDossierView: React.FC = () => {
return () => {
coach.onDocumentCreatedRef.current = null;
};
}, [coach]);
}, [coach, _refreshWorkspaceAssets]);
const handleStopTts = useCallback(() => coach.stopTts(), [coach]);
useEffect(() => {
if (coach.agentToolCalls.length > 0) {
setShowAgentActivity(true);
}
}, [coach.agentToolCalls.length]);
const handleStopTts = useCallback(() => {
coach.stopTts();
voice.ttsStopped();
}, [coach, voice]);
const handlePauseTts = useCallback(() => coach.pauseTts(), [coach]);
const handleResumeTts = useCallback(() => coach.resumeTts(), [coach]);
const handleSend = useCallback(async () => {
if (!coach.inputValue.trim() || coach.isStreaming) return;
await coach.sendMessage(coach.inputValue);
}, [coach]);
const opts: SendMessageOptions = {};
if (attachedFileIds.length) opts.fileIds = attachedFileIds;
if (attachedDsIds.length) opts.dataSourceIds = attachedDsIds;
if (attachedFdsIds.length) opts.featureDataSourceIds = attachedFdsIds;
const allowed = providerSelection.include.length > 0 ? providerSelection.include : undefined;
if (allowed) opts.allowedProviders = allowed;
await coach.sendMessage(coach.inputValue, Object.keys(opts).length ? opts : undefined);
setAttachedFileIds([]);
setShowSourcePicker(false);
setShowFilePicker(false);
}, [coach, attachedFileIds, attachedDsIds, attachedFdsIds, providerSelection]);
const _toggleFile = useCallback((fileId: string) => {
setAttachedFileIds(prev => prev.includes(fileId) ? prev.filter(id => id !== fileId) : [...prev, fileId]);
}, []);
const _toggleDs = useCallback((dsId: string) => {
setAttachedDsIds(prev => prev.includes(dsId) ? prev.filter(id => id !== dsId) : [...prev, dsId]);
}, []);
const _toggleFds = useCallback((fdsId: string) => {
setAttachedFdsIds(prev => prev.includes(fdsId) ? prev.filter(id => id !== fdsId) : [...prev, fdsId]);
}, []);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); }
@ -379,6 +474,63 @@ export const CommcoachDossierView: React.FC = () => {
</div>
</AutoScroll>
{(coach.isStreaming || coach.agentToolCalls.length > 0) && (
<div className={styles.agentActivityPanel}>
<button
className={styles.agentActivityHeader}
onClick={() => setShowAgentActivity(prev => !prev)}
type="button"
>
<span className={styles.agentActivityTitle}>
Agent-Aktivität
{coach.agentToolCalls.length > 0 ? ` (${coach.agentToolCalls.length})` : ''}
</span>
<span className={styles.agentActivityStatus}>
{coach.streamingStatus || (coach.agentToolCalls.length > 0 ? 'Tool-Aufrufe vorhanden' : 'Warte auf Agent')}
</span>
<span className={styles.agentActivityChevron}>{showAgentActivity ? '▾' : '▸'}</span>
</button>
{showAgentActivity && (
<div className={styles.agentActivityBody}>
{coach.agentToolCalls.length === 0 ? (
<div className={styles.agentActivityEmpty}>
Noch keine Tool-Aufrufe in dieser Antwort.
</div>
) : (
coach.agentToolCalls.map((toolCall, idx) => (
<div key={`${toolCall.toolName}-${idx}`} className={styles.agentActivityItem}>
<div className={styles.agentActivityItemHeader}>
<span className={styles.agentActivityToolName}>{toolCall.toolName}</span>
<span
className={`${styles.agentActivityBadge} ${
toolCall.success === true
? styles.agentActivityBadgeSuccess
: toolCall.success === false
? styles.agentActivityBadgeError
: styles.agentActivityBadgeRunning
}`}
>
{toolCall.success === true ? 'fertig' : toolCall.success === false ? 'fehler' : 'läuft'}
</span>
</div>
{toolCall.args && (
<div className={styles.agentActivityMeta}>
<strong>Args:</strong> {_formatToolPayload(toolCall.args)}
</div>
)}
{toolCall.result && (
<div className={styles.agentActivityMeta}>
<strong>Result:</strong> {toolCall.result}
</div>
)}
</div>
))
)}
</div>
)}
</div>
)}
{/* Input Area */}
<div className={styles.inputArea}>
<div className={styles.voiceStatus}>
@ -396,6 +548,53 @@ export const CommcoachDossierView: React.FC = () => {
: 'Mikrofon wird gestartet...'}
</span>
</div>
{/* Attachment Chips */}
{(attachedFileIds.length > 0 || attachedDsIds.length > 0 || attachedFdsIds.length > 0) && (
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', paddingBottom: 4 }}>
{attachedFileIds.map(fId => {
const file = wsFiles.find(f => f.id === fId);
return (
<span key={fId} style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '2px 8px', borderRadius: 12, fontSize: 11,
background: '#e3f2fd', color: '#1565c0', fontWeight: 500,
}}>
{file?.fileName || fId}
<button onClick={() => _toggleFile(fId)} style={{ border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#1565c0', padding: 0, lineHeight: 1 }}>×</button>
</span>
);
})}
{attachedDsIds.map(dsId => {
const ds = wsDataSources.find(d => d.id === dsId);
return (
<span key={dsId} style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '2px 8px', borderRadius: 12, fontSize: 11,
background: '#e8f5e9', color: '#2e7d32', fontWeight: 500,
}}>
{ds?.label || ds?.path || dsId}
<button onClick={() => _toggleDs(dsId)} style={{ border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#2e7d32', padding: 0, lineHeight: 1 }}>×</button>
</span>
);
})}
{attachedFdsIds.map(fdsId => {
const fds = wsFeatureDataSources.find(d => d.id === fdsId);
return (
<span key={fdsId} style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '2px 8px', borderRadius: 12, fontSize: 11,
background: '#f3e5f5', color: '#7b1fa2', fontWeight: 500,
}}>
<span style={{ fontSize: 12 }}>{fds ? getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F' : '\uD83D\uDDC3\uFE0F'}</span>
{fds?.label || fdsId}
<button onClick={() => _toggleFds(fdsId)} style={{ border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#7b1fa2', padding: 0, lineHeight: 1 }}>×</button>
</span>
);
})}
</div>
)}
<div className={styles.textInputRow}>
<textarea
ref={inputRef}
@ -407,6 +606,153 @@ export const CommcoachDossierView: React.FC = () => {
rows={1}
disabled={coach.isStreaming}
/>
{/* File Picker */}
{wsFiles.length > 0 && (
<div style={{ position: 'relative' }}>
<button
onClick={() => { setShowFilePicker(v => !v); setShowSourcePicker(false); }}
disabled={coach.isStreaming}
title="Datei anhängen"
style={{
width: 36, height: 36, borderRadius: 8,
border: `1px solid ${attachedFileIds.length ? '#1565c0' : 'var(--border-color, #ddd)'}`,
background: attachedFileIds.length ? '#e3f2fd' : 'var(--secondary-bg, #f5f5f5)',
color: attachedFileIds.length ? '#1565c0' : '#666',
cursor: coach.isStreaming ? 'not-allowed' : 'pointer',
fontSize: 15, display: 'flex', alignItems: 'center', justifyContent: 'center',
opacity: coach.isStreaming ? 0.5 : 1, position: 'relative',
}}
>
+
{attachedFileIds.length > 0 && (
<span style={{ position: 'absolute', top: -4, right: -4, background: '#1565c0', color: '#fff', fontSize: 9, fontWeight: 700, borderRadius: '50%', width: 15, height: 15, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{attachedFileIds.length}
</span>
)}
</button>
{showFilePicker && (
<div style={{
position: 'absolute', bottom: '100%', right: 0, marginBottom: 4,
background: 'var(--bg-card, #fff)', border: '1px solid var(--border-color, #e0e0e0)',
borderRadius: 8, boxShadow: '0 -2px 8px rgba(0,0,0,0.1)', zIndex: 20,
minWidth: 220, maxHeight: 240, overflowY: 'auto',
}}>
<div style={{ padding: '6px 12px', fontSize: 11, color: '#999', fontWeight: 600, borderBottom: '1px solid #f0f0f0' }}>Dateien anhängen</div>
{wsFiles.map(f => {
const sel = attachedFileIds.includes(f.id);
return (
<div key={f.id} onClick={() => _toggleFile(f.id)} style={{
padding: '6px 12px', cursor: 'pointer', fontSize: 12,
display: 'flex', alignItems: 'center', gap: 8,
background: sel ? '#e3f2fd' : 'transparent',
}}
onMouseEnter={e => { if (!sel) e.currentTarget.style.background = '#f5f5f5'; }}
onMouseLeave={e => { if (!sel) e.currentTarget.style.background = ''; }}
>
<span style={{ width: 14, height: 14, borderRadius: 3, border: sel ? '2px solid #1565c0' : '2px solid #ccc', background: sel ? '#1565c0' : 'transparent', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontSize: 9, fontWeight: 700, flexShrink: 0 }}>
{sel ? '✓' : ''}
</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.fileName}</span>
</div>
);
})}
</div>
)}
</div>
)}
{/* Source Picker */}
{(wsDataSources.length > 0 || wsFeatureDataSources.length > 0) && (
<div style={{ position: 'relative' }}>
<button
onClick={() => { setShowSourcePicker(v => !v); setShowFilePicker(false); }}
disabled={coach.isStreaming}
title="Datenquellen anhängen"
style={{
width: 36, height: 36, borderRadius: 8,
border: `1px solid ${(attachedDsIds.length + attachedFdsIds.length) ? '#2e7d32' : 'var(--border-color, #ddd)'}`,
background: (attachedDsIds.length + attachedFdsIds.length) ? '#e8f5e9' : 'var(--secondary-bg, #f5f5f5)',
color: (attachedDsIds.length + attachedFdsIds.length) ? '#2e7d32' : '#666',
cursor: coach.isStreaming ? 'not-allowed' : 'pointer',
fontSize: 14, display: 'flex', alignItems: 'center', justifyContent: 'center',
opacity: coach.isStreaming ? 0.5 : 1, position: 'relative',
}}
>
&#128279;
{(attachedDsIds.length + attachedFdsIds.length) > 0 && (
<span style={{ position: 'absolute', top: -4, right: -4, background: '#2e7d32', color: '#fff', fontSize: 9, fontWeight: 700, borderRadius: '50%', width: 15, height: 15, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{attachedDsIds.length + attachedFdsIds.length}
</span>
)}
</button>
{showSourcePicker && (
<div style={{
position: 'absolute', bottom: '100%', right: 0, marginBottom: 4,
background: 'var(--bg-card, #fff)', border: '1px solid var(--border-color, #e0e0e0)',
borderRadius: 8, boxShadow: '0 -2px 8px rgba(0,0,0,0.1)', zIndex: 20,
minWidth: 240, maxHeight: 260, overflowY: 'auto',
}}>
{wsDataSources.length > 0 && (
<>
<div style={{ padding: '6px 12px', fontSize: 11, color: '#999', fontWeight: 600, borderBottom: '1px solid #f0f0f0' }}>Persönliche Quellen</div>
{wsDataSources.map(ds => {
const sel = attachedDsIds.includes(ds.id);
return (
<div key={ds.id} onClick={() => _toggleDs(ds.id)} style={{
padding: '6px 12px', cursor: 'pointer', fontSize: 12,
display: 'flex', alignItems: 'center', gap: 8,
background: sel ? '#e8f5e9' : 'transparent',
}}
onMouseEnter={e => { if (!sel) e.currentTarget.style.background = '#f5f5f5'; }}
onMouseLeave={e => { if (!sel) e.currentTarget.style.background = ''; }}
>
<span style={{ width: 14, height: 14, borderRadius: 3, border: sel ? '2px solid #2e7d32' : '2px solid #ccc', background: sel ? '#2e7d32' : 'transparent', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontSize: 9, fontWeight: 700, flexShrink: 0 }}>
{sel ? '✓' : ''}
</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{ds.label || ds.path}</span>
</div>
);
})}
</>
)}
{wsFeatureDataSources.length > 0 && (
<>
<div style={{ padding: '6px 12px', fontSize: 11, color: '#999', fontWeight: 600, borderTop: wsDataSources.length ? '1px solid #f0f0f0' : 'none', borderBottom: '1px solid #f0f0f0' }}>Feature-Datenquellen</div>
{wsFeatureDataSources.map(fds => {
const sel = attachedFdsIds.includes(fds.id);
return (
<div key={fds.id} onClick={() => _toggleFds(fds.id)} style={{
padding: '6px 12px', cursor: 'pointer', fontSize: 12,
display: 'flex', alignItems: 'center', gap: 8,
background: sel ? '#f3e5f5' : 'transparent',
}}
onMouseEnter={e => { if (!sel) e.currentTarget.style.background = '#f5f5f5'; }}
onMouseLeave={e => { if (!sel) e.currentTarget.style.background = ''; }}
>
<span style={{ width: 14, height: 14, borderRadius: 3, border: sel ? '2px solid #7b1fa2' : '2px solid #ccc', background: sel ? '#7b1fa2' : 'transparent', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontSize: 9, fontWeight: 700, flexShrink: 0 }}>
{sel ? '✓' : ''}
</span>
<span style={{ display: 'flex', alignItems: 'center', fontSize: 12, color: '#7b1fa2', flexShrink: 0 }}>{getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F'}</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{fds.label} {fds.tableName}</span>
</div>
);
})}
</>
)}
</div>
)}
</div>
)}
{/* Provider Selector */}
<ProviderMultiSelect
selection={providerSelection}
onChange={setProviderSelection}
showLabel={false}
disabled={coach.isStreaming}
/>
<button className={styles.sendBtn} onClick={handleSend} disabled={!coach.inputValue.trim() || coach.isStreaming}>
Senden
</button>
@ -534,19 +880,6 @@ export const CommcoachDossierView: React.FC = () => {
</>)}
{/* #region agent log */}
<div style={{position:'fixed',bottom:0,right:0,zIndex:9999}}>
<button
onClick={() => { setDebugSnapshot([...debugLogsRef.current]); setDebugVisible(v => !v); }}
style={{background:'#333',color:'#0f0',border:'none',padding:'4px 8px',fontSize:'10px',borderRadius:'4px 0 0 0'}}
>DBG ({debugLogsRef.current.length})</button>
{debugVisible && (
<div style={{background:'rgba(0,0,0,0.9)',color:'#0f0',fontSize:'9px',maxHeight:'40vh',overflow:'auto',padding:'4px',fontFamily:'monospace',whiteSpace:'pre-wrap',width:'100vw'}}>
{debugSnapshot.map((l,i) => <div key={i}>{l}</div>)}
</div>
)}
</div>
{/* #endregion */}
</div>
</div>
);
@ -596,4 +929,14 @@ function _dimensionLabel(dim: string): string {
return labels[dim] || dim;
}
function _formatToolPayload(payload: Record<string, unknown>): string {
try {
const serialized = JSON.stringify(payload);
if (!serialized) return '';
return serialized.length > 180 ? `${serialized.slice(0, 177)}...` : serialized;
} catch {
return '[unlesbar]';
}
}
export default CommcoachDossierView;

View file

@ -0,0 +1,55 @@
/**
* CommcoachKeepAlive
*
* Keeps the CommCoach dossier/coaching page mounted across route changes.
* Visibility is toggled via CSS so session state, messages, and input state
* stay alive when the user leaves and later returns.
*/
import React, { useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { CommcoachDossierView } from './CommcoachDossierView';
const _COMMCOACH_ROUTE_RE = /\/mandates\/([^/]+)\/commcoach\/([^/]+)\/(?:coaching|dossier)/;
interface CommcoachKeepAliveProps {
isVisible: boolean;
}
export const CommcoachKeepAlive: React.FC<CommcoachKeepAliveProps> = ({ isVisible }) => {
const location = useLocation();
const cachedMandateIdRef = useRef<string>('');
const cachedInstanceIdRef = useRef<string>('');
const match = location.pathname.match(_COMMCOACH_ROUTE_RE);
if (match?.[1] && match?.[2]) {
cachedMandateIdRef.current = match[1];
cachedInstanceIdRef.current = match[2];
}
const mandateId = cachedMandateIdRef.current;
const instanceId = cachedInstanceIdRef.current;
if (!mandateId || !instanceId) return null;
return (
<div
style={{
display: isVisible ? 'flex' : 'none',
flexDirection: 'column',
position: 'absolute',
top: 'var(--mobile-topbar-height, 0px)',
left: 0,
right: 0,
bottom: 0,
overflow: 'hidden',
}}
>
<CommcoachDossierView
persistentInstanceId={instanceId}
persistentMandateId={mandateId}
/>
</div>
);
};
export default CommcoachKeepAlive;

View file

@ -24,6 +24,7 @@ export interface VoiceControllerApi {
ttsPlaying: () => void;
ttsPaused: () => void;
ttsEnded: () => void;
ttsStopped: () => void;
toggleMute: () => void;
}
@ -124,6 +125,18 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo
_startStream().catch((err) => _dlog('MIC-ERR', String(err)));
}, [_setState, _startStream, _dlog]);
const ttsStopped = useCallback(() => {
const cur = stateRef.current;
if (cur !== 'botSpeaking' && cur !== 'interrupted') return;
voiceStream.stop();
if (mutedRef.current) {
_setState('interrupted');
return;
}
_setState('listening');
_startStream().catch((err) => _dlog('MIC-ERR', String(err)));
}, [_setState, _startStream, _dlog, voiceStream]);
const toggleMute = useCallback(() => {
const cur = stateRef.current;
if (cur === 'idle') return;
@ -147,6 +160,7 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo
ttsPlaying,
ttsPaused,
ttsEnded,
ttsStopped,
toggleMute,
};
}