From d3d054b1326941741bd0eccc6e50d943257dcbfa Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Wed, 1 Apr 2026 22:04:02 +0200 Subject: [PATCH] commcoach agent integration: keep-alive persistence, input bar, voice controller fixes Made-with: Cursor --- src/api/commcoachApi.ts | 16 +- src/hooks/useCommcoach.ts | 38 +- src/layouts/MainLayout.tsx | 10 +- src/pages/FeatureView.tsx | 5 + .../commcoach/CommcoachDossierView.module.css | 109 +++++ .../views/commcoach/CommcoachDossierView.tsx | 417 ++++++++++++++++-- .../views/commcoach/CommcoachKeepAlive.tsx | 55 +++ .../views/commcoach/useVoiceController.ts | 14 + 8 files changed, 617 insertions(+), 47 deletions(-) create mode 100644 src/pages/views/commcoach/CommcoachKeepAlive.tsx diff --git a/src/api/commcoachApi.ts b/src/api/commcoachApi.ts index 47f5665..df0ed6c 100644 --- a/src/api/commcoachApi.ts +++ b/src/api/commcoachApi.ts @@ -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 { try { const baseURL = api.defaults.baseURL || ''; @@ -304,10 +312,16 @@ export async function sendMessageStreamApi( if (!getCSRFToken()) generateAndStoreCSRFToken(); addCSRFTokenToHeaders(headers); + const body: Record = { 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, }); diff --git a/src/hooks/useCommcoach.ts b/src/hooks/useCommcoach.ts index 1e14ed1..62ec296 100644 --- a/src/hooks/useCommcoach.ts +++ b/src/hooks/useCommcoach.ts @@ -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; result?: string; success?: boolean }>; + selectContext: (contextId: string, options?: { skipSessionResume?: boolean }) => Promise; createContext: (title: string, description?: string, category?: string, goals?: string[]) => Promise; archiveContext: (contextId: string) => Promise; startSession: (personaId?: string) => Promise; - sendMessage: (content: string) => Promise; + sendMessage: (content: string, options?: SendMessageOptions) => Promise; sendAudio: (audioBlob: Blob) => Promise; completeSession: () => Promise; cancelSession: () => Promise; @@ -67,9 +70,10 @@ export interface CommcoachHookReturn { refreshContexts: () => Promise; } -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([]); const [selectedContextId, setSelectedContextId] = useState(null); @@ -88,6 +92,7 @@ export function useCommcoach(): CommcoachHookReturn { const [error, setError] = useState(null); const [inputValue, setInputValue] = useState(''); + const [agentToolCalls, setAgentToolCalls] = useState; result?: string; success?: boolean }>>([]); const [actionLoading, setActionLoading] = useState(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, diff --git a/src/layouts/MainLayout.tsx b/src/layouts/MainLayout.tsx index dd1cd70..579ab64 100644 --- a/src/layouts/MainLayout.tsx +++ b/src/layouts/MainLayout.tsx @@ -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 = () => { /> - + +
diff --git a/src/pages/FeatureView.tsx b/src/pages/FeatureView.tsx index e3ebc03..03567d6 100644 --- a/src/pages/FeatureView.tsx +++ b/src/pages/FeatureView.tsx @@ -219,6 +219,11 @@ export const FeatureViewPage: React.FC = ({ 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) { diff --git a/src/pages/views/commcoach/CommcoachDossierView.module.css b/src/pages/views/commcoach/CommcoachDossierView.module.css index 006680c..459578d 100644 --- a/src/pages/views/commcoach/CommcoachDossierView.module.css +++ b/src/pages/views/commcoach/CommcoachDossierView.module.css @@ -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; diff --git a/src/pages/views/commcoach/CommcoachDossierView.tsx b/src/pages/views/commcoach/CommcoachDossierView.tsx index 3582bf5..c584a04 100644 --- a/src/pages/views/commcoach/CommcoachDossierView.tsx +++ b/src/pages/views/commcoach/CommcoachDossierView.tsx @@ -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 = ({ + 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('coaching'); const [showNewContext, setShowNewContext] = useState(false); @@ -45,6 +81,17 @@ export const CommcoachDossierView: React.FC = () => { const [personas, setPersonas] = useState([]); const [selectedPersonaId, setSelectedPersonaId] = useState(undefined); + const [wsFiles, setWsFiles] = useState([]); + const [wsDataSources, setWsDataSources] = useState([]); + const [wsFeatureDataSources, setWsFeatureDataSources] = useState([]); + const [attachedFileIds, setAttachedFileIds] = useState([]); + const [attachedDsIds, setAttachedDsIds] = useState([]); + const [attachedFdsIds, setAttachedFdsIds] = useState([]); + const [providerSelection, setProviderSelection] = useState(_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([]); - const [debugVisible, setDebugVisible] = useState(false); - const [debugSnapshot, setDebugSnapshot] = useState([]); - 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 = () => { + {(coach.isStreaming || coach.agentToolCalls.length > 0) && ( +
+ + {showAgentActivity && ( +
+ {coach.agentToolCalls.length === 0 ? ( +
+ Noch keine Tool-Aufrufe in dieser Antwort. +
+ ) : ( + coach.agentToolCalls.map((toolCall, idx) => ( +
+
+ {toolCall.toolName} + + {toolCall.success === true ? 'fertig' : toolCall.success === false ? 'fehler' : 'läuft'} + +
+ {toolCall.args && ( +
+ Args: {_formatToolPayload(toolCall.args)} +
+ )} + {toolCall.result && ( +
+ Result: {toolCall.result} +
+ )} +
+ )) + )} +
+ )} +
+ )} + {/* Input Area */}
@@ -396,6 +548,53 @@ export const CommcoachDossierView: React.FC = () => { : 'Mikrofon wird gestartet...'}
+ + {/* Attachment Chips */} + {(attachedFileIds.length > 0 || attachedDsIds.length > 0 || attachedFdsIds.length > 0) && ( +
+ {attachedFileIds.map(fId => { + const file = wsFiles.find(f => f.id === fId); + return ( + + {file?.fileName || fId} + + + ); + })} + {attachedDsIds.map(dsId => { + const ds = wsDataSources.find(d => d.id === dsId); + return ( + + {ds?.label || ds?.path || dsId} + + + ); + })} + {attachedFdsIds.map(fdsId => { + const fds = wsFeatureDataSources.find(d => d.id === fdsId); + return ( + + {fds ? getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F' : '\uD83D\uDDC3\uFE0F'} + {fds?.label || fdsId} + + + ); + })} +
+ )} +