commcoach agent integration: keep-alive persistence, input bar, voice controller fixes
Made-with: Cursor
This commit is contained in:
parent
2509fbdcf2
commit
d3d054b132
8 changed files with 617 additions and 47 deletions
|
|
@ -285,6 +285,13 @@ export async function cancelSessionApi(request: ApiRequestFunction, instanceId:
|
||||||
// Streaming Chat API
|
// Streaming Chat API
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface SendMessageOptions {
|
||||||
|
fileIds?: string[];
|
||||||
|
dataSourceIds?: string[];
|
||||||
|
featureDataSourceIds?: string[];
|
||||||
|
allowedProviders?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export async function sendMessageStreamApi(
|
export async function sendMessageStreamApi(
|
||||||
instanceId: string,
|
instanceId: string,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
|
|
@ -293,6 +300,7 @@ export async function sendMessageStreamApi(
|
||||||
onError?: (error: Error) => void,
|
onError?: (error: Error) => void,
|
||||||
onComplete?: () => void,
|
onComplete?: () => void,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
|
options?: SendMessageOptions,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const baseURL = api.defaults.baseURL || '';
|
const baseURL = api.defaults.baseURL || '';
|
||||||
|
|
@ -304,10 +312,16 @@ export async function sendMessageStreamApi(
|
||||||
if (!getCSRFToken()) generateAndStoreCSRFToken();
|
if (!getCSRFToken()) generateAndStoreCSRFToken();
|
||||||
addCSRFTokenToHeaders(headers);
|
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, {
|
const response = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
body: JSON.stringify({ content }),
|
body: JSON.stringify(body),
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
createTaskApi, updateTaskStatusApi, deleteTaskApi,
|
createTaskApi, updateTaskStatusApi, deleteTaskApi,
|
||||||
type CoachingContext, type CoachingSession, type CoachingMessage,
|
type CoachingContext, type CoachingSession, type CoachingMessage,
|
||||||
type CoachingTask, type CoachingScore, type SSEEvent,
|
type CoachingTask, type CoachingScore, type SSEEvent,
|
||||||
|
type SendMessageOptions,
|
||||||
} from '../api/commcoachApi';
|
} from '../api/commcoachApi';
|
||||||
import { useTtsPlayback, type TtsEvent } from './useTtsPlayback';
|
import { useTtsPlayback, type TtsEvent } from './useTtsPlayback';
|
||||||
|
|
||||||
|
|
@ -37,12 +38,14 @@ export interface CommcoachHookReturn {
|
||||||
inputValue: string;
|
inputValue: string;
|
||||||
setInputValue: (v: string) => void;
|
setInputValue: (v: string) => void;
|
||||||
|
|
||||||
|
agentToolCalls: Array<{ toolName: string; args?: Record<string, unknown>; result?: string; success?: boolean }>;
|
||||||
|
|
||||||
selectContext: (contextId: string, options?: { skipSessionResume?: boolean }) => Promise<void>;
|
selectContext: (contextId: string, options?: { skipSessionResume?: boolean }) => Promise<void>;
|
||||||
createContext: (title: string, description?: string, category?: string, goals?: string[]) => Promise<void>;
|
createContext: (title: string, description?: string, category?: string, goals?: string[]) => Promise<void>;
|
||||||
archiveContext: (contextId: string) => Promise<void>;
|
archiveContext: (contextId: string) => Promise<void>;
|
||||||
|
|
||||||
startSession: (personaId?: 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>;
|
sendAudio: (audioBlob: Blob) => Promise<void>;
|
||||||
completeSession: () => Promise<void>;
|
completeSession: () => Promise<void>;
|
||||||
cancelSession: () => Promise<void>;
|
cancelSession: () => Promise<void>;
|
||||||
|
|
@ -67,9 +70,10 @@ export interface CommcoachHookReturn {
|
||||||
refreshContexts: () => Promise<void>;
|
refreshContexts: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCommcoach(): CommcoachHookReturn {
|
export function useCommcoach(instanceIdOverride?: string): CommcoachHookReturn {
|
||||||
const { request } = useApiRequest();
|
const { request } = useApiRequest();
|
||||||
const instanceId = useInstanceId();
|
const routeInstanceId = useInstanceId();
|
||||||
|
const instanceId = instanceIdOverride || routeInstanceId;
|
||||||
|
|
||||||
const [contexts, setContexts] = useState<CoachingContext[]>([]);
|
const [contexts, setContexts] = useState<CoachingContext[]>([]);
|
||||||
const [selectedContextId, setSelectedContextId] = useState<string | null>(null);
|
const [selectedContextId, setSelectedContextId] = useState<string | null>(null);
|
||||||
|
|
@ -88,6 +92,7 @@ export function useCommcoach(): CommcoachHookReturn {
|
||||||
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [inputValue, setInputValue] = useState('');
|
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);
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|
@ -239,6 +244,7 @@ export function useCommcoach(): CommcoachHookReturn {
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsStreaming(true);
|
setIsStreaming(true);
|
||||||
setStreamingStatus(null);
|
setStreamingStatus(null);
|
||||||
|
setStreamingMessage(null);
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setSession(null);
|
setSession(null);
|
||||||
try {
|
try {
|
||||||
|
|
@ -259,7 +265,7 @@ export function useCommcoach(): CommcoachHookReturn {
|
||||||
setMessages(eventData.messages);
|
setMessages(eventData.messages);
|
||||||
}
|
}
|
||||||
} else if (eventType === 'messageChunk' && eventData) {
|
} else if (eventType === 'messageChunk' && eventData) {
|
||||||
setStreamingMessage(eventData.accumulated || '');
|
setStreamingStatus(prev => prev || 'Coach formuliert Antwort...');
|
||||||
} else if (eventType === 'message' && eventData) {
|
} else if (eventType === 'message' && eventData) {
|
||||||
setStreamingMessage(null);
|
setStreamingMessage(null);
|
||||||
const msg: CoachingMessage = {
|
const msg: CoachingMessage = {
|
||||||
|
|
@ -313,7 +319,7 @@ export function useCommcoach(): CommcoachHookReturn {
|
||||||
}
|
}
|
||||||
}, [instanceId, selectedContextId, ttsPlayback.play]);
|
}, [instanceId, selectedContextId, ttsPlayback.play]);
|
||||||
|
|
||||||
const sendMessage = useCallback(async (content: string) => {
|
const sendMessage = useCallback(async (content: string, options?: SendMessageOptions) => {
|
||||||
const normalizedContent = content.trim();
|
const normalizedContent = content.trim();
|
||||||
if (!normalizedContent || !instanceId || !session) return;
|
if (!normalizedContent || !instanceId || !session) return;
|
||||||
|
|
||||||
|
|
@ -326,6 +332,8 @@ export function useCommcoach(): CommcoachHookReturn {
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsStreaming(true);
|
setIsStreaming(true);
|
||||||
setStreamingStatus(null);
|
setStreamingStatus(null);
|
||||||
|
setStreamingMessage(null);
|
||||||
|
setAgentToolCalls([]);
|
||||||
|
|
||||||
const tempMsg: CoachingMessage = {
|
const tempMsg: CoachingMessage = {
|
||||||
id: `temp-${Date.now()}`,
|
id: `temp-${Date.now()}`,
|
||||||
|
|
@ -350,7 +358,7 @@ export function useCommcoach(): CommcoachHookReturn {
|
||||||
const eventData = event.data;
|
const eventData = event.data;
|
||||||
|
|
||||||
if (eventType === 'messageChunk' && eventData) {
|
if (eventType === 'messageChunk' && eventData) {
|
||||||
setStreamingMessage(eventData.accumulated || '');
|
setStreamingStatus(prev => prev || 'Coach formuliert Antwort...');
|
||||||
} else if (eventType === 'message' && eventData) {
|
} else if (eventType === 'message' && eventData) {
|
||||||
setStreamingMessage(null);
|
setStreamingMessage(null);
|
||||||
const msg: CoachingMessage = {
|
const msg: CoachingMessage = {
|
||||||
|
|
@ -374,6 +382,17 @@ export function useCommcoach(): CommcoachHookReturn {
|
||||||
ttsPlayback.play(eventData.audio);
|
ttsPlayback.play(eventData.audio);
|
||||||
} else if (eventType === 'status' && eventData) {
|
} else if (eventType === 'status' && eventData) {
|
||||||
setStreamingStatus(eventData.label || null);
|
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) {
|
} else if (eventType === 'taskCreated' && eventData) {
|
||||||
setTasks(prev => [eventData, ...prev]);
|
setTasks(prev => [eventData, ...prev]);
|
||||||
} else if (eventType === 'documentCreated' && eventData) {
|
} else if (eventType === 'documentCreated' && eventData) {
|
||||||
|
|
@ -400,6 +419,7 @@ export function useCommcoach(): CommcoachHookReturn {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
ac.signal,
|
ac.signal,
|
||||||
|
options,
|
||||||
);
|
);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.name === 'AbortError') return;
|
if (err.name === 'AbortError') return;
|
||||||
|
|
@ -417,6 +437,7 @@ export function useCommcoach(): CommcoachHookReturn {
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsStreaming(true);
|
setIsStreaming(true);
|
||||||
setStreamingStatus(null);
|
setStreamingStatus(null);
|
||||||
|
setStreamingMessage(null);
|
||||||
try {
|
try {
|
||||||
await sendAudioStreamApi(
|
await sendAudioStreamApi(
|
||||||
instanceId,
|
instanceId,
|
||||||
|
|
@ -427,7 +448,9 @@ export function useCommcoach(): CommcoachHookReturn {
|
||||||
const eventType = event.type;
|
const eventType = event.type;
|
||||||
const eventData = event.data;
|
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);
|
setStreamingStatus(eventData.label || null);
|
||||||
} else if (eventType === 'message' && eventData) {
|
} else if (eventType === 'message' && eventData) {
|
||||||
if (eventData.role === 'assistant') setError(null);
|
if (eventData.role === 'assistant') setError(null);
|
||||||
|
|
@ -555,6 +578,7 @@ export function useCommcoach(): CommcoachHookReturn {
|
||||||
session, messages, isStreaming, streamingStatus, streamingMessage,
|
session, messages, isStreaming, streamingStatus, streamingMessage,
|
||||||
tasks, scores, sessions,
|
tasks, scores, sessions,
|
||||||
error, inputValue, setInputValue,
|
error, inputValue, setInputValue,
|
||||||
|
agentToolCalls,
|
||||||
selectContext, createContext, archiveContext,
|
selectContext, createContext, archiveContext,
|
||||||
startSession: startSessionCb,
|
startSession: startSessionCb,
|
||||||
sendMessage, sendAudio,
|
sendMessage, sendAudio,
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,11 @@ import { FeatureProvider, useFeatureStore } from '../stores/featureStore';
|
||||||
import { MandateNavigation } from '../components/Navigation/MandateNavigation';
|
import { MandateNavigation } from '../components/Navigation/MandateNavigation';
|
||||||
import { UserSection } from '../components/Navigation/UserSection';
|
import { UserSection } from '../components/Navigation/UserSection';
|
||||||
import { WorkspaceKeepAlive } from '../pages/views/workspace/WorkspaceKeepAlive';
|
import { WorkspaceKeepAlive } from '../pages/views/workspace/WorkspaceKeepAlive';
|
||||||
|
import { CommcoachKeepAlive } from '../pages/views/commcoach/CommcoachKeepAlive';
|
||||||
import styles from './MainLayout.module.css';
|
import styles from './MainLayout.module.css';
|
||||||
|
|
||||||
const _WORKSPACE_ROUTE_RE = /\/mandates\/[^/]+\/workspace\/[^/]+\/dashboard/;
|
const _WORKSPACE_ROUTE_RE = /\/mandates\/[^/]+\/workspace\/[^/]+\/dashboard/;
|
||||||
|
const _COMMCOACH_ROUTE_RE = /\/mandates\/[^/]+\/commcoach\/[^/]+\/(?:coaching|dossier)/;
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// INNER LAYOUT (mit Zugriff auf Store)
|
// INNER LAYOUT (mit Zugriff auf Store)
|
||||||
|
|
@ -23,6 +25,9 @@ const MainLayoutInner: React.FC = () => {
|
||||||
const { loadFeatures, initialized, loading, error } = useFeatureStore();
|
const { loadFeatures, initialized, loading, error } = useFeatureStore();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
|
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
|
// Features laden beim Mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -105,11 +110,12 @@ const MainLayoutInner: React.FC = () => {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<WorkspaceKeepAlive isVisible={_WORKSPACE_ROUTE_RE.test(location.pathname)} />
|
<WorkspaceKeepAlive isVisible={isWorkspaceKeepAliveVisible} />
|
||||||
|
<CommcoachKeepAlive isVisible={isCommcoachKeepAliveVisible} />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={styles.outletShell}
|
className={styles.outletShell}
|
||||||
style={{ display: _WORKSPACE_ROUTE_RE.test(location.pathname) ? 'none' : undefined }}
|
style={{ display: hideOutletShell ? 'none' : undefined }}
|
||||||
>
|
>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -219,6 +219,11 @@ export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
|
||||||
return null;
|
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
|
// View-Komponente finden
|
||||||
const featureViews = VIEW_COMPONENTS[featureCode];
|
const featureViews = VIEW_COMPONENTS[featureCode];
|
||||||
if (!featureViews) {
|
if (!featureViews) {
|
||||||
|
|
|
||||||
|
|
@ -406,6 +406,115 @@
|
||||||
.typingDots { animation: blink 1.4s infinite both; }
|
.typingDots { animation: blink 1.4s infinite both; }
|
||||||
@keyframes blink { 0%, 80%, 100% { opacity: 0; } 40% { opacity: 1; } }
|
@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 */
|
/* Input Area */
|
||||||
.inputArea {
|
.inputArea {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -15,22 +15,58 @@ import {
|
||||||
getDossierExportUrl, getSessionExportUrl,
|
getDossierExportUrl, getSessionExportUrl,
|
||||||
getScoreHistoryApi, getPersonasApi,
|
getScoreHistoryApi, getPersonasApi,
|
||||||
type CoachingPersona,
|
type CoachingPersona,
|
||||||
|
type SendMessageOptions,
|
||||||
} from '../../../api/commcoachApi';
|
} from '../../../api/commcoachApi';
|
||||||
|
import api from '../../../api';
|
||||||
import AutoScroll from '../../../components/UiComponents/AutoScroll/AutoScroll';
|
import AutoScroll from '../../../components/UiComponents/AutoScroll/AutoScroll';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
|
import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
|
||||||
import type { UdbContext, UdbTab } 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 styles from './CommcoachDossierView.module.css';
|
||||||
import { useVoiceController } from './useVoiceController';
|
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';
|
type TabKey = 'coaching' | 'tasks' | 'sessions' | 'scores';
|
||||||
|
|
||||||
export const CommcoachDossierView: React.FC = () => {
|
interface CommcoachDossierViewProps {
|
||||||
const coach = useCommcoach();
|
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 { request } = useApiRequest();
|
||||||
const instanceId = useInstanceId();
|
|
||||||
const mandateId = useMandateId();
|
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<TabKey>('coaching');
|
const [activeTab, setActiveTab] = useState<TabKey>('coaching');
|
||||||
const [showNewContext, setShowNewContext] = useState(false);
|
const [showNewContext, setShowNewContext] = useState(false);
|
||||||
|
|
@ -45,6 +81,17 @@ export const CommcoachDossierView: React.FC = () => {
|
||||||
const [personas, setPersonas] = useState<CoachingPersona[]>([]);
|
const [personas, setPersonas] = useState<CoachingPersona[]>([]);
|
||||||
const [selectedPersonaId, setSelectedPersonaId] = useState<string | undefined>(undefined);
|
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
|
const _udbContext: UdbContext | null = instanceId
|
||||||
? { instanceId, mandateId: mandateId || undefined, featureInstanceId: instanceId }
|
? { instanceId, mandateId: mandateId || undefined, featureInstanceId: instanceId }
|
||||||
: null;
|
: null;
|
||||||
|
|
@ -53,23 +100,26 @@ export const CommcoachDossierView: React.FC = () => {
|
||||||
const sendMessageRef = useRef(coach.sendMessage);
|
const sendMessageRef = useRef(coach.sendMessage);
|
||||||
sendMessageRef.current = coach.sendMessage;
|
sendMessageRef.current = coach.sendMessage;
|
||||||
|
|
||||||
const voice = useVoiceController({
|
const attachedFileIdsRef = useRef(attachedFileIds);
|
||||||
onFinalText: (text) => sendMessageRef.current(text),
|
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 voice = useVoiceController({
|
||||||
const debugLogsRef = useRef<string[]>([]);
|
onFinalText: (text) => {
|
||||||
const [debugVisible, setDebugVisible] = useState(false);
|
const opts: SendMessageOptions = {};
|
||||||
const [debugSnapshot, setDebugSnapshot] = useState<string[]>([]);
|
if (attachedFileIdsRef.current.length) opts.fileIds = attachedFileIdsRef.current;
|
||||||
const _dlog = useCallback((tag: string, info?: string) => {
|
if (attachedDsIdsRef.current.length) opts.dataSourceIds = attachedDsIdsRef.current;
|
||||||
const t = new Date();
|
if (attachedFdsIdsRef.current.length) opts.featureDataSourceIds = attachedFdsIdsRef.current;
|
||||||
const ts = `${t.getMinutes()}:${String(t.getSeconds()).padStart(2,'0')}.${String(t.getMilliseconds()).padStart(3,'0')}`;
|
const allowed = providerSelRef.current.include.length > 0 ? providerSelRef.current.include : undefined;
|
||||||
const entry = `[${ts}] ${tag}${info ? ' ' + info : ''}`;
|
if (allowed) opts.allowedProviders = allowed;
|
||||||
debugLogsRef.current.push(entry);
|
sendMessageRef.current(text, Object.keys(opts).length ? opts : undefined);
|
||||||
if (debugLogsRef.current.length > 80) debugLogsRef.current.shift();
|
},
|
||||||
}, []);
|
});
|
||||||
useEffect(() => { (window as any).__dlog = _dlog; return () => { delete (window as any).__dlog; }; }, [_dlog]);
|
|
||||||
// #endregion
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
coach.onTtsEventRef.current = (event: TtsEvent) => {
|
coach.onTtsEventRef.current = (event: TtsEvent) => {
|
||||||
|
|
@ -103,6 +153,23 @@ export const CommcoachDossierView: React.FC = () => {
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [instanceId, request]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (activeTab !== 'coaching' || !coach.session) {
|
if (activeTab !== 'coaching' || !coach.session) {
|
||||||
voice.deactivate();
|
voice.deactivate();
|
||||||
|
|
@ -118,16 +185,44 @@ export const CommcoachDossierView: React.FC = () => {
|
||||||
return () => {
|
return () => {
|
||||||
coach.onDocumentCreatedRef.current = null;
|
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 handlePauseTts = useCallback(() => coach.pauseTts(), [coach]);
|
||||||
const handleResumeTts = useCallback(() => coach.resumeTts(), [coach]);
|
const handleResumeTts = useCallback(() => coach.resumeTts(), [coach]);
|
||||||
|
|
||||||
const handleSend = useCallback(async () => {
|
const handleSend = useCallback(async () => {
|
||||||
if (!coach.inputValue.trim() || coach.isStreaming) return;
|
if (!coach.inputValue.trim() || coach.isStreaming) return;
|
||||||
await coach.sendMessage(coach.inputValue);
|
const opts: SendMessageOptions = {};
|
||||||
}, [coach]);
|
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) => {
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); }
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); }
|
||||||
|
|
@ -379,6 +474,63 @@ export const CommcoachDossierView: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</AutoScroll>
|
</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 */}
|
{/* Input Area */}
|
||||||
<div className={styles.inputArea}>
|
<div className={styles.inputArea}>
|
||||||
<div className={styles.voiceStatus}>
|
<div className={styles.voiceStatus}>
|
||||||
|
|
@ -396,6 +548,53 @@ export const CommcoachDossierView: React.FC = () => {
|
||||||
: 'Mikrofon wird gestartet...'}
|
: 'Mikrofon wird gestartet...'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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}>
|
<div className={styles.textInputRow}>
|
||||||
<textarea
|
<textarea
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
|
|
@ -407,6 +606,153 @@ export const CommcoachDossierView: React.FC = () => {
|
||||||
rows={1}
|
rows={1}
|
||||||
disabled={coach.isStreaming}
|
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',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🔗
|
||||||
|
{(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}>
|
<button className={styles.sendBtn} onClick={handleSend} disabled={!coach.inputValue.trim() || coach.isStreaming}>
|
||||||
Senden
|
Senden
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -596,4 +929,14 @@ function _dimensionLabel(dim: string): string {
|
||||||
return labels[dim] || dim;
|
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;
|
export default CommcoachDossierView;
|
||||||
|
|
|
||||||
55
src/pages/views/commcoach/CommcoachKeepAlive.tsx
Normal file
55
src/pages/views/commcoach/CommcoachKeepAlive.tsx
Normal 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;
|
||||||
|
|
@ -24,6 +24,7 @@ export interface VoiceControllerApi {
|
||||||
ttsPlaying: () => void;
|
ttsPlaying: () => void;
|
||||||
ttsPaused: () => void;
|
ttsPaused: () => void;
|
||||||
ttsEnded: () => void;
|
ttsEnded: () => void;
|
||||||
|
ttsStopped: () => void;
|
||||||
toggleMute: () => void;
|
toggleMute: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -124,6 +125,18 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo
|
||||||
_startStream().catch((err) => _dlog('MIC-ERR', String(err)));
|
_startStream().catch((err) => _dlog('MIC-ERR', String(err)));
|
||||||
}, [_setState, _startStream, _dlog]);
|
}, [_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 toggleMute = useCallback(() => {
|
||||||
const cur = stateRef.current;
|
const cur = stateRef.current;
|
||||||
if (cur === 'idle') return;
|
if (cur === 'idle') return;
|
||||||
|
|
@ -147,6 +160,7 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo
|
||||||
ttsPlaying,
|
ttsPlaying,
|
||||||
ttsPaused,
|
ttsPaused,
|
||||||
ttsEnded,
|
ttsEnded,
|
||||||
|
ttsStopped,
|
||||||
toggleMute,
|
toggleMute,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue