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
|
||||
// ============================================================================
|
||||
|
||||
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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}}
|
||||
>
|
||||
🔗
|
||||
{(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;
|
||||
|
|
|
|||
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;
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue