Refines chat panel, nudge messages and artifact rendering, reworks deepgram token/streaming handling, and adds test tooling deps (playwright, cypress) to package.json. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
621 lines
23 KiB
TypeScript
621 lines
23 KiB
TypeScript
'use client';
|
|
|
|
/**
|
|
* Chat Panel (v4.0)
|
|
*
|
|
* Chat interface met scrollable message list, auto-scroll, en scroll-lock detection.
|
|
* V2 integratie: ActionChainCard, ClarificationCard, ProcessingIndicator
|
|
*
|
|
* Epic: E2 (Chat Panel & Messages), E3 (UI Components)
|
|
* Story: E2.S3 (ChatPanel component - scrolling), E3 Integration
|
|
*/
|
|
|
|
import { useRef, useEffect, useState, useCallback } from 'react';
|
|
import { ArrowDown } from 'lucide-react';
|
|
import { AnimatePresence } from 'framer-motion';
|
|
import { ChatMessage } from './chat-message';
|
|
import { NudgeChatMessage } from './nudge-chat-message';
|
|
import { ChatInput, ChatInputHandle } from './chat-input';
|
|
import { ChatSuggestions } from './chat-suggestions';
|
|
import { ChatEmptyState } from './chat-empty-state';
|
|
import { ActionChainCard } from './action-chain-card';
|
|
import { ClarificationCard } from './clarification-card';
|
|
import { ProcessingIndicator } from './processing-indicator';
|
|
import { useCortexStore, type ChatMessage as ChatMessageType, type NoShowFlowState } from '@/stores/cortex-store';
|
|
import type { NudgeSuggestion } from '@/lib/cortex/types';
|
|
import { sendChatMessage } from '@/lib/cortex/chat-api';
|
|
import { parseActionFromResponse, shouldOpenArtifact, routeIntentToArtifact, getDefaultConfirmationMessage } from '@/lib/cortex/action-parser';
|
|
import { evaluateNudge } from '@/lib/cortex/nudge';
|
|
import { isFeatureEnabled } from '@/lib/config/feature-flags';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
export function ChatPanel() {
|
|
// Chat state
|
|
const chatMessages = useCortexStore((s) => s.chatMessages);
|
|
const addChatMessage = useCortexStore((s) => s.addChatMessage);
|
|
const updateLastMessage = useCortexStore((s) => s.updateLastMessage);
|
|
const setStreaming = useCortexStore((s) => s.setStreaming);
|
|
const isStreaming = useCortexStore((s) => s.isStreaming);
|
|
const setPendingAction = useCortexStore((s) => s.setPendingAction);
|
|
const activePatient = useCortexStore((s) => s.activePatient);
|
|
const shift = useCortexStore((s) => s.shift);
|
|
|
|
// V2 Chain state
|
|
const activeChain = useCortexStore((s) => s.activeChain);
|
|
const updateActionStatus = useCortexStore((s) => s.updateActionStatus);
|
|
const completeChain = useCortexStore((s) => s.completeChain);
|
|
|
|
// V2 Clarification state
|
|
const pendingClarification = useCortexStore((s) => s.pendingClarification);
|
|
const setPendingClarification = useCortexStore((s) => s.setPendingClarification);
|
|
const resolveClarification = useCortexStore((s) => s.resolveClarification);
|
|
|
|
// Artifact state (E5.S2)
|
|
const openArtifact = useCortexStore((s) => s.openArtifact);
|
|
|
|
// Nudge state (chat-based nudges)
|
|
const acceptSuggestion = useCortexStore((s) => s.acceptSuggestion);
|
|
const dismissSuggestion = useCortexStore((s) => s.dismissSuggestion);
|
|
|
|
// No-show flow state
|
|
const setNoShowStep = useCortexStore((s) => s.setNoShowStep);
|
|
const setNoShowContext = useCortexStore((s) => s.setNoShowContext);
|
|
const setNoShowProcessing = useCortexStore((s) => s.setNoShowProcessing);
|
|
const resetNoShowFlow = useCortexStore((s) => s.resetNoShowFlow);
|
|
|
|
// Refs for scrolling
|
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Ref for chat input (for keyboard shortcuts)
|
|
const chatInputRef = useRef<ChatInputHandle>(null);
|
|
|
|
// Scroll-lock state
|
|
const [isScrolledUp, setIsScrolledUp] = useState(false);
|
|
const [showScrollButton, setShowScrollButton] = useState(false);
|
|
|
|
// Intent Helper state: minimize suggestions after first message
|
|
const [isSuggestionsMinimized, setIsSuggestionsMinimized] = useState(false);
|
|
const hasAutoMinimizedRef = useRef(false);
|
|
|
|
const hasMessages = chatMessages.length > 0;
|
|
|
|
// Get active patient name for suggestion placeholders
|
|
const activePatientName = activePatient
|
|
? `${activePatient.name_given?.[0] || ''} ${activePatient.name_family || ''}`.trim()
|
|
: undefined;
|
|
|
|
// Scroll to bottom function
|
|
const scrollToBottom = useCallback((behavior: ScrollBehavior = 'smooth') => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior });
|
|
setIsScrolledUp(false);
|
|
setShowScrollButton(false);
|
|
}, []);
|
|
|
|
// Detect scroll position (scroll-lock detection)
|
|
const handleScroll = useCallback(() => {
|
|
if (!scrollContainerRef.current) return;
|
|
|
|
const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current;
|
|
const isNearBottom = scrollHeight - scrollTop - clientHeight < 100; // 100px threshold
|
|
|
|
setIsScrolledUp(!isNearBottom);
|
|
|
|
// Show scroll button only if scrolled up AND there are messages
|
|
setShowScrollButton(!isNearBottom && hasMessages);
|
|
}, [hasMessages]);
|
|
|
|
// Auto-scroll to latest message when new message arrives (unless user scrolled up)
|
|
useEffect(() => {
|
|
if (!isScrolledUp && hasMessages) {
|
|
scrollToBottom('smooth');
|
|
}
|
|
}, [chatMessages.length, isScrolledUp, hasMessages, scrollToBottom]);
|
|
|
|
// Initial scroll to bottom on mount
|
|
useEffect(() => {
|
|
scrollToBottom('auto');
|
|
}, [scrollToBottom]);
|
|
|
|
// Auto-minimize suggestions after first message is sent (only once)
|
|
useEffect(() => {
|
|
if (hasMessages && !hasAutoMinimizedRef.current) {
|
|
hasAutoMinimizedRef.current = true;
|
|
setIsSuggestionsMinimized(true);
|
|
}
|
|
}, [hasMessages]);
|
|
|
|
// Cleanup no-show flow bij unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
const { noShowFlow } = useCortexStore.getState();
|
|
if (noShowFlow.step !== 'idle' && noShowFlow.step !== 'done') {
|
|
resetNoShowFlow();
|
|
}
|
|
};
|
|
}, [resetNoShowFlow]);
|
|
|
|
// Handle suggestion selection - fill input with selected text
|
|
const handleSelectSuggestion = useCallback((text: string) => {
|
|
chatInputRef.current?.setValue(text);
|
|
}, []);
|
|
|
|
// Global keyboard shortcuts
|
|
useEffect(() => {
|
|
const handleGlobalKeyDown = (e: KeyboardEvent) => {
|
|
// ⌘K or Ctrl+K to focus chat input
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
|
e.preventDefault();
|
|
chatInputRef.current?.focus();
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handleGlobalKeyDown);
|
|
return () => window.removeEventListener('keydown', handleGlobalKeyDown);
|
|
}, []);
|
|
|
|
// No-show flow: stap 2→3 — annuleer afspraak + check concept brief
|
|
const handleNoShowCancelStep = useCallback(async (_suggestion: NudgeSuggestion) => {
|
|
const { isNoShowProcessing } = useCortexStore.getState();
|
|
if (isNoShowProcessing) return;
|
|
|
|
setNoShowProcessing(true);
|
|
setNoShowStep('waiting_cancel');
|
|
addChatMessage({ type: 'assistant', content: 'Bezig met annuleren...' });
|
|
|
|
try {
|
|
const patientId = activePatient?.id ?? 'demo-patient-001';
|
|
|
|
// Stap 1: annuleer de afspraak
|
|
const cancelRes = await fetch('/api/cortex/noshow/cancel', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ appointmentId: 'mock-appt-noshow-001', patientId }),
|
|
});
|
|
if (!cancelRes.ok) throw new Error('Annuleren mislukt');
|
|
|
|
// Stap 2: check op concept brief
|
|
const ctxRes = await fetch(`/api/cortex/noshow/context?patientId=${patientId}`);
|
|
const ctx = await ctxRes.json();
|
|
|
|
if (ctx.hasConceptBrief) {
|
|
setNoShowContext({ documentId: ctx.document.id, originalContent: ctx.document.content });
|
|
setNoShowStep('waiting_brief');
|
|
|
|
// Construeer nudge 2 handmatig
|
|
const briefNudge: NudgeSuggestion = {
|
|
id: `nudge-noshow-brief-${Date.now()}`,
|
|
trigger: { actionId: 'noshow-cancel-done', intent: 'cancel_appointment', entities: {} },
|
|
suggestion: {
|
|
intent: 'register_no_show',
|
|
entities: {},
|
|
message: 'Afspraak geannuleerd. Er staat nog een concept huisartsbrief klaar. Zal ik daar de No Show in verwerken?',
|
|
rationale: 'noshow-brief-check',
|
|
},
|
|
status: 'pending',
|
|
priority: 'high',
|
|
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
|
|
createdAt: new Date(),
|
|
};
|
|
addChatMessage({ type: 'nudge', content: briefNudge.suggestion.message, nudge: briefNudge });
|
|
} else {
|
|
setNoShowStep('done');
|
|
addChatMessage({
|
|
type: 'assistant',
|
|
content: 'Afspraak geannuleerd als No Show. Er zijn geen openstaande conceptbrieven gevonden.',
|
|
});
|
|
}
|
|
} catch {
|
|
setNoShowStep('idle');
|
|
addChatMessage({ type: 'error', content: 'Er is iets misgegaan bij het annuleren. Probeer het opnieuw.' });
|
|
} finally {
|
|
setNoShowProcessing(false);
|
|
}
|
|
}, [activePatient, setNoShowStep, setNoShowContext, setNoShowProcessing, addChatMessage]);
|
|
|
|
// No-show flow: stap 4→5 — rescript brief + open artifact
|
|
const handleNoShowRescriptStep = useCallback(async () => {
|
|
const { isNoShowProcessing, noShowFlow } = useCortexStore.getState();
|
|
if (isNoShowProcessing) return;
|
|
|
|
setNoShowProcessing(true);
|
|
setNoShowStep('brief_open');
|
|
addChatMessage({ type: 'assistant', content: 'Huisartsbrief aanpassen...' });
|
|
|
|
try {
|
|
const res = await fetch('/api/cortex/noshow/rescript', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
documentId: noShowFlow.documentId ?? 'mock-brief-noshow-001',
|
|
originalContent: noShowFlow.originalContent ?? '',
|
|
patientId: activePatient?.id ?? 'demo-patient-001',
|
|
}),
|
|
});
|
|
|
|
const result = await res.json();
|
|
|
|
openArtifact({
|
|
type: 'register_no_show',
|
|
title: 'Huisartsbrief n.a.v. intake',
|
|
prefill: {
|
|
documentId: result.documentId,
|
|
content: result.rescriptedContent,
|
|
title: 'Huisartsbrief n.a.v. intake',
|
|
originalContent: result.originalContent,
|
|
rescriptWarning: result.warning,
|
|
},
|
|
});
|
|
|
|
addChatMessage({
|
|
type: 'assistant',
|
|
content: 'Huisartsbrief is aangepast. Kijk of je hiermee akkoord bent.',
|
|
});
|
|
} catch {
|
|
setNoShowStep('waiting_brief');
|
|
addChatMessage({ type: 'error', content: 'Herschrijven mislukt. Probeer het opnieuw.' });
|
|
} finally {
|
|
setNoShowProcessing(false);
|
|
}
|
|
}, [activePatient, setNoShowStep, setNoShowProcessing, addChatMessage, openArtifact]);
|
|
|
|
// V2 Chain action handlers (E5.S2)
|
|
const handleConfirmAction = useCallback((actionId: string) => {
|
|
// Find the action in the active chain
|
|
const action = activeChain?.actions.find((a) => a.id === actionId);
|
|
if (!action || !activeChain) {
|
|
console.error('[ChatPanel] Action not found:', actionId);
|
|
return;
|
|
}
|
|
|
|
console.log('[ChatPanel] Confirming action:', actionId, action.intent);
|
|
updateActionStatus(actionId, 'executing');
|
|
|
|
// Route to artifact (uses existing artifact system)
|
|
const artifact = routeIntentToArtifact(
|
|
action.intent,
|
|
action.entities,
|
|
action.confidence
|
|
);
|
|
|
|
if (artifact) {
|
|
console.log('[ChatPanel] Opening artifact:', artifact.type);
|
|
openArtifact({
|
|
type: artifact.type,
|
|
prefill: artifact.prefill,
|
|
title: artifact.title,
|
|
});
|
|
}
|
|
|
|
// Mark as success (artifact is now open for user to complete)
|
|
updateActionStatus(actionId, 'success');
|
|
|
|
// E5.S2: Trigger nudge evaluation after successful action
|
|
// Now adds nudges as chat messages instead of toast
|
|
if (isFeatureEnabled('CORTEX_NUDGE')) {
|
|
const suggestions = evaluateNudge({
|
|
intent: action.intent,
|
|
actionId,
|
|
entities: action.entities,
|
|
content: action.entities.content,
|
|
});
|
|
|
|
if (suggestions.length > 0) {
|
|
console.log('[ChatPanel] Nudge suggestions (chat-based):', suggestions.length);
|
|
suggestions.forEach((suggestion) => {
|
|
// Add as chat message with nudge type
|
|
addChatMessage({
|
|
type: 'nudge',
|
|
content: suggestion.suggestion.message,
|
|
nudge: suggestion,
|
|
});
|
|
});
|
|
}
|
|
}
|
|
}, [activeChain, updateActionStatus, openArtifact, addChatMessage]);
|
|
|
|
const handleSkipAction = useCallback((actionId: string) => {
|
|
console.log('[ChatPanel] Skipping action:', actionId);
|
|
updateActionStatus(actionId, 'skipped');
|
|
}, [updateActionStatus]);
|
|
|
|
const handleRetryAction = useCallback((actionId: string) => {
|
|
console.log('[ChatPanel] Retrying action:', actionId);
|
|
updateActionStatus(actionId, 'pending');
|
|
// Re-trigger confirmation flow
|
|
setTimeout(() => {
|
|
updateActionStatus(actionId, 'confirming');
|
|
}, 100);
|
|
}, [updateActionStatus]);
|
|
|
|
const handleDismissChain = useCallback(() => {
|
|
console.log('[ChatPanel] Dismissing chain');
|
|
completeChain();
|
|
}, [completeChain]);
|
|
|
|
const handleSelectClarification = useCallback((option: string) => {
|
|
console.log('[ChatPanel] Clarification selected:', option);
|
|
resolveClarification(option);
|
|
// TODO: E5 - Re-process with selected option
|
|
}, [resolveClarification]);
|
|
|
|
const handleDismissClarification = useCallback(() => {
|
|
console.log('[ChatPanel] Clarification dismissed');
|
|
setPendingClarification(null);
|
|
}, [setPendingClarification]);
|
|
|
|
// Nudge handlers (chat-based nudges)
|
|
const handleAcceptNudge = useCallback(async (suggestionId: string, suggestion: ChatMessageType['nudge']) => {
|
|
console.log('[ChatPanel] Nudge accepted:', suggestionId);
|
|
acceptSuggestion(suggestionId);
|
|
|
|
if (!suggestion) return;
|
|
|
|
// No-show flow stap 2→3: declarabiliteitscheck geaccepteerd
|
|
if (suggestion.trigger.intent === 'register_no_show') {
|
|
await handleNoShowCancelStep(suggestion);
|
|
return;
|
|
}
|
|
|
|
// No-show flow stap 4→5: brief-check geaccepteerd
|
|
if (suggestion.suggestion.rationale === 'noshow-brief-check') {
|
|
await handleNoShowRescriptStep();
|
|
return;
|
|
}
|
|
|
|
// Generieke flow voor alle andere nudges
|
|
const artifact = routeIntentToArtifact(
|
|
suggestion.suggestion.intent,
|
|
suggestion.suggestion.entities,
|
|
0.9
|
|
);
|
|
|
|
if (artifact) {
|
|
console.log('[ChatPanel] Opening artifact from nudge:', artifact.type);
|
|
openArtifact({
|
|
type: artifact.type,
|
|
prefill: artifact.prefill,
|
|
title: artifact.title,
|
|
});
|
|
}
|
|
}, [acceptSuggestion, openArtifact, handleNoShowCancelStep, handleNoShowRescriptStep]);
|
|
|
|
const handleDismissNudge = useCallback((suggestionId: string) => {
|
|
console.log('[ChatPanel] Nudge dismissed:', suggestionId);
|
|
dismissSuggestion(suggestionId);
|
|
}, [dismissSuggestion]);
|
|
|
|
// E5.S2: Sequential chain execution - auto-advance to next action
|
|
useEffect(() => {
|
|
if (!activeChain) return;
|
|
|
|
const actions = activeChain.actions;
|
|
const completedStatuses = ['success', 'skipped', 'failed'];
|
|
|
|
// Count completed actions
|
|
const completedCount = actions.filter((a) =>
|
|
completedStatuses.includes(a.status)
|
|
).length;
|
|
|
|
// Find next pending action
|
|
const nextPending = actions.find((a) => a.status === 'pending');
|
|
|
|
// If there's a completed action and a pending one, auto-advance
|
|
if (completedCount > 0 && nextPending) {
|
|
// Small delay for UI feedback before advancing
|
|
const timer = setTimeout(() => {
|
|
updateActionStatus(nextPending.id, 'confirming');
|
|
}, 300);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
|
|
// If all actions are complete, finish the chain
|
|
if (completedCount === actions.length && actions.length > 0) {
|
|
const timer = setTimeout(() => {
|
|
console.log('[ChatPanel] All actions complete, finishing chain');
|
|
completeChain();
|
|
}, 500);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [activeChain, updateActionStatus, completeChain]);
|
|
|
|
// Check if we should show multi-intent UI
|
|
const showActionChain = activeChain && activeChain.actions.length > 1;
|
|
|
|
return (
|
|
<div className="h-full flex flex-col bg-white">
|
|
{/* Chat messages area - scrollable */}
|
|
<div
|
|
ref={scrollContainerRef}
|
|
onScroll={handleScroll}
|
|
className="flex-1 overflow-y-auto p-6 relative"
|
|
>
|
|
{hasMessages ? (
|
|
<div className="flex flex-col space-y-3">
|
|
{chatMessages.map((message) => (
|
|
message.type === 'nudge' && message.nudge ? (
|
|
<NudgeChatMessage
|
|
key={message.id}
|
|
suggestion={message.nudge}
|
|
onAccept={(id) => handleAcceptNudge(id, message.nudge)}
|
|
onDismiss={handleDismissNudge}
|
|
/>
|
|
) : (
|
|
<ChatMessage key={message.id} message={message} showTimestamp />
|
|
)
|
|
))}
|
|
|
|
{/* V2: Processing indicator while AI is thinking */}
|
|
{isStreaming && (
|
|
<div className="self-start">
|
|
<ProcessingIndicator message="Even nadenken..." />
|
|
</div>
|
|
)}
|
|
|
|
{/* V2: Multi-intent action chain (feature flagged) */}
|
|
{isFeatureEnabled('CORTEX_MULTI_INTENT') && (
|
|
<AnimatePresence mode="wait">
|
|
{showActionChain && (
|
|
<ActionChainCard
|
|
key={activeChain.id}
|
|
chain={activeChain}
|
|
onConfirmAction={handleConfirmAction}
|
|
onSkipAction={handleSkipAction}
|
|
onRetryAction={handleRetryAction}
|
|
onDismissChain={handleDismissChain}
|
|
/>
|
|
)}
|
|
</AnimatePresence>
|
|
)}
|
|
|
|
{/* V2: Clarification card for ambiguous input (feature flagged) */}
|
|
{isFeatureEnabled('CORTEX_V2_ENABLED') && (
|
|
<AnimatePresence mode="wait">
|
|
{pendingClarification && (
|
|
<ClarificationCard
|
|
key="clarification"
|
|
question={pendingClarification.question}
|
|
options={pendingClarification.options}
|
|
originalInput={pendingClarification.originalInput}
|
|
onSelectOption={handleSelectClarification}
|
|
onDismiss={handleDismissClarification}
|
|
/>
|
|
)}
|
|
</AnimatePresence>
|
|
)}
|
|
|
|
{/* Invisible element to scroll to */}
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
) : (
|
|
<ChatEmptyState
|
|
onSelectAction={handleSelectSuggestion}
|
|
activePatientName={activePatientName}
|
|
/>
|
|
)}
|
|
|
|
{/* Scroll to bottom button */}
|
|
{showScrollButton && (
|
|
<button
|
|
onClick={() => scrollToBottom('smooth')}
|
|
className={cn(
|
|
'absolute bottom-4 right-4 z-10',
|
|
'bg-white border border-slate-300 rounded-full p-2',
|
|
'shadow-lg hover:shadow-xl',
|
|
'transition-all duration-200',
|
|
'hover:bg-slate-50 active:scale-95',
|
|
'flex items-center gap-2 text-sm text-slate-700 font-medium px-3 py-2'
|
|
)}
|
|
aria-label="Scroll naar laatste bericht"
|
|
>
|
|
<ArrowDown className="w-4 h-4" />
|
|
<span>Scroll naar beneden</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Suggestion strip above input */}
|
|
<ChatSuggestions
|
|
isMinimized={isSuggestionsMinimized}
|
|
onToggleMinimize={() => setIsSuggestionsMinimized(!isSuggestionsMinimized)}
|
|
onSelectSuggestion={handleSelectSuggestion}
|
|
activePatientName={activePatientName}
|
|
/>
|
|
|
|
{/* Chat input */}
|
|
<ChatInput
|
|
ref={chatInputRef}
|
|
disabled={isStreaming}
|
|
onSend={async (message) => {
|
|
// E3.S1: Test streaming API with mock response
|
|
setStreaming(true);
|
|
|
|
// Add empty assistant message that will be filled by streaming
|
|
addChatMessage({
|
|
type: 'assistant',
|
|
content: '',
|
|
});
|
|
|
|
let accumulatedContent = '';
|
|
|
|
await sendChatMessage(
|
|
message,
|
|
chatMessages,
|
|
{
|
|
activePatient: activePatient ? {
|
|
id: activePatient.id,
|
|
first_name: activePatient.name_given?.[0] || '',
|
|
last_name: activePatient.name_family || '',
|
|
} : null,
|
|
shift,
|
|
},
|
|
(chunk) => {
|
|
// On each chunk, append to accumulated content
|
|
accumulatedContent += chunk;
|
|
|
|
// Filter out JSON blocks during streaming - users don't need to see the raw JSON
|
|
// Show "Verwerken..." when JSON is being generated but no readable text yet
|
|
const displayContent = accumulatedContent
|
|
.replace(/```json[\s\S]*?```/g, '') // Remove complete JSON blocks
|
|
.replace(/```json[\s\S]*$/g, '') // Remove incomplete JSON block at end
|
|
.trim();
|
|
|
|
// If we have displayable content, show it; otherwise show processing message
|
|
updateLastMessage(displayContent || 'Verwerken...');
|
|
},
|
|
() => {
|
|
// On done - parse action from complete response
|
|
setStreaming(false);
|
|
|
|
// E3.S4: Parse action object from AI response
|
|
const parsed = parseActionFromResponse(accumulatedContent);
|
|
|
|
if (parsed.action) {
|
|
console.log('[ChatPanel] Action detected:', parsed.action);
|
|
|
|
// If textContent is empty but we have an action, generate a default confirmation message
|
|
const displayContent = parsed.textContent.trim() ||
|
|
getDefaultConfirmationMessage(parsed.action.intent, parsed.action.entities);
|
|
|
|
// Update last message with text content (or default) and action
|
|
updateLastMessage(displayContent, parsed.action);
|
|
|
|
// Store action in pendingAction for artifact opening (E3.S6)
|
|
if (shouldOpenArtifact(parsed.action.confidence)) {
|
|
setPendingAction(parsed.action);
|
|
console.log('[ChatPanel] Pending action set (confidence:', parsed.action.confidence, ')');
|
|
} else {
|
|
console.log('[ChatPanel] Action confidence too low:', parsed.action.confidence);
|
|
}
|
|
|
|
// No-show nudge trigger na register_no_show classificatie
|
|
if (parsed.action.intent === 'register_no_show' && isFeatureEnabled('CORTEX_NUDGE')) {
|
|
const suggestions = evaluateNudge({
|
|
intent: 'register_no_show',
|
|
actionId: crypto.randomUUID(),
|
|
entities: {}, // register_no_show heeft geen entiteiten nodig
|
|
content: message,
|
|
});
|
|
suggestions.forEach((suggestion) => {
|
|
addChatMessage({ type: 'nudge', content: suggestion.suggestion.message, nudge: suggestion });
|
|
});
|
|
}
|
|
} else {
|
|
console.log('[ChatPanel] No action detected in response');
|
|
}
|
|
},
|
|
(error) => {
|
|
// On error
|
|
setStreaming(false);
|
|
addChatMessage({
|
|
type: 'error',
|
|
content: `Er ging iets mis: ${error}`,
|
|
});
|
|
}
|
|
);
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|