Files
triqura-ecd/components/cortex/command-center/command-input.tsx
colinislit 2170b23348 refactor: rename swift → cortex in code and documentation
Complete rename of "swift" terminology to "cortex" across the codebase:

Code Changes:
- Rename directories: lib/swift → lib/cortex, components/swift → components/cortex
- Rename API routes: /api/swift/* → /api/cortex/*
- Rename page routes: /epd/swift → /epd/cortex
- Rename store: swift-store.ts → cortex-store.ts

Type Renames:
- SwiftIntent → CortexIntent
- SwiftStore → CortexStore
- useSwiftStore → useCortexStore
- SwiftContext → CortexContext
- useSwiftVoice → useCortexVoice

Documentation Updates (docs/intent/):
- Safety Net → Nudge (Layer 3 rename)
- SafetySuggestion → NudgeSuggestion
- evaluateSafetyNet → evaluateNudge
- SWIFT_* feature flags → CORTEX_*
- All path references updated to match new structure

Files affected: 47 files, ~700 lines changed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 09:18:06 +01:00

316 lines
9.6 KiB
TypeScript

'use client';
/**
* Command Input
*
* Bottom input bar for text and voice commands.
* Height: 64px (h-16)
*
* Features:
* - Text input with dynamic placeholder
* - Focus state with ring
* - Send button (appears when input has value)
* - Voice input with Deepgram streaming
* - ⌘K shortcut hint
*/
import { forwardRef, useState, useEffect, useRef } from 'react';
import { useCortexStore } from '@/stores/cortex-store';
import { useCortexVoice } from '@/lib/cortex/use-cortex-voice';
import type { BlockType } from '@/lib/cortex/types';
import { Mic, MicOff, Send, Loader2 } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { safeFetch, getErrorInfo } from '@/lib/cortex/error-handler';
import { routeIntentToArtifact } from '@/lib/cortex/action-parser';
export const CommandInput = forwardRef<HTMLInputElement>(function CommandInput(_, ref) {
const {
inputValue,
setInputValue,
clearInput,
activePatient,
activeBlock,
isVoiceActive,
openBlock,
openArtifact,
addRecentAction,
} = useCortexStore();
const { toast } = useToast();
const {
isRecording,
isConnecting,
isConnected,
error: voiceError,
startRecording,
stopRecording,
analyserNode,
isBrowserSupported,
} = useCortexVoice();
const [isProcessing, setIsProcessing] = useState(false);
const waveformRef = useRef<HTMLCanvasElement>(null);
const animationRef = useRef<number | null>(null);
const hasValue = inputValue.trim().length > 0;
// Waveform visualization
useEffect(() => {
if (!analyserNode || !waveformRef.current || !isRecording) {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
animationRef.current = null;
}
return;
}
const canvas = waveformRef.current;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const bufferLength = analyserNode.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
const draw = () => {
if (!isRecording) return;
animationRef.current = requestAnimationFrame(draw);
analyserNode.getByteFrequencyData(dataArray);
ctx.fillStyle = 'rgb(248, 250, 252)'; // slate-50
ctx.fillRect(0, 0, canvas.width, canvas.height);
const barWidth = (canvas.width / bufferLength) * 2.5;
let x = 0;
for (let i = 0; i < bufferLength; i++) {
const barHeight = (dataArray[i] / 255) * canvas.height;
// Gradient from blue to red based on amplitude
const hue = 220 - (dataArray[i] / 255) * 40;
ctx.fillStyle = `hsl(${hue}, 70%, 60%)`;
ctx.fillRect(x, canvas.height - barHeight, barWidth, barHeight);
x += barWidth + 1;
}
};
draw();
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};
}, [analyserNode, isRecording]);
// Dynamic placeholder based on context
const getPlaceholder = () => {
if (isRecording) return 'Luisteren...';
if (isConnecting) return 'Verbinden met spraakherkenning...';
if (activePatient) {
return `Actie voor ${activePatient.name_given[0]}... (bijv. "notitie medicatie")`;
}
return 'Typ of spreek je intentie... (bijv. "notitie jan medicatie")';
};
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault();
if (!hasValue || isProcessing) return;
// Stop recording if active
if (isRecording) {
stopRecording();
}
const inputText = inputValue.trim();
setIsProcessing(true);
try {
// Call intent classification API
const response = await safeFetch(
'/api/intent/classify',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ input: inputText }),
},
{ operation: 'Intent classificeren' }
);
const result = await response.json();
const { intent, confidence, entities } = result;
// Route intent to artifact using Epic 5.S1 routing logic
const artifactConfig = routeIntentToArtifact(intent, entities, confidence);
if (artifactConfig) {
// Open artifact with routing configuration
openArtifact({
type: artifactConfig.type,
title: artifactConfig.title,
prefill: artifactConfig.prefill,
});
// Add to recent actions
addRecentAction({
intent,
label: inputText.slice(0, 50), // Truncate for display
patientName: entities.patientName,
});
clearInput();
} else {
// Low confidence or missing required entities - show FallbackPicker
openBlock('fallback', { content: inputText });
clearInput();
}
} catch (error) {
console.error('Error processing intent:', error);
const statusCode = (error as any)?.statusCode;
const errorInfo = getErrorInfo(error, {
operation: 'Intent classificeren',
statusCode,
});
// Show error toast
toast({
variant: 'destructive',
title: errorInfo.title,
description: errorInfo.description,
});
// On error, show FallbackPicker so user can choose
// This ensures the user's input is not lost
openBlock('fallback', { content: inputText });
clearInput();
} finally {
setIsProcessing(false);
}
};
const handleVoiceToggle = () => {
if (isRecording) {
stopRecording();
} else {
startRecording();
}
};
// Keyboard shortcut: Cmd/Ctrl+Enter to submit
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Cmd/Ctrl+Enter: submit command
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
handleSubmit();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [handleSubmit]);
const isDisabled = isProcessing || activeBlock !== null;
return (
<footer className="h-16 border-t border-slate-200 flex items-center px-4 shrink-0 bg-white">
<form onSubmit={handleSubmit} className="flex-1 flex items-center gap-2">
{/* Input wrapper with optional waveform */}
<div className="relative flex-1">
{/* Waveform canvas (shown when recording) */}
{isRecording && (
<canvas
ref={waveformRef}
width={200}
height={40}
className="absolute left-2 top-1/2 -translate-y-1/2 rounded opacity-60"
/>
)}
<input
ref={ref}
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder={getPlaceholder()}
disabled={isDisabled}
className={`w-full bg-white border rounded-xl py-3 text-slate-900 placeholder:text-slate-400
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
disabled:opacity-50 disabled:cursor-not-allowed
transition-all duration-200
${isRecording ? 'pl-[220px] pr-12 border-red-500/50' : 'pl-4 pr-12 border-slate-300'}`}
autoFocus
/>
{/* Status indicators */}
{!hasValue && !isRecording && !isConnecting && (
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-slate-600 pointer-events-none hidden sm:block">
K
</span>
)}
{isConnecting && (
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-amber-500 flex items-center gap-1">
<Loader2 size={12} className="animate-spin" />
Verbinden
</span>
)}
{voiceError && (
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-red-400 max-w-[150px] truncate">
{voiceError}
</span>
)}
</div>
{/* Send button - appears when has value */}
{hasValue && (
<button
type="submit"
disabled={isProcessing}
className="p-3 rounded-xl bg-blue-600 hover:bg-blue-500 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Verstuur (Enter)"
>
{isProcessing ? (
<Loader2 size={20} className="animate-spin" />
) : (
<Send size={20} />
)}
</button>
)}
{/* Voice button */}
{isBrowserSupported ? (
<button
type="button"
onClick={handleVoiceToggle}
disabled={isDisabled || isConnecting}
className={`p-3 rounded-xl transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed ${
isRecording
? 'bg-red-600 hover:bg-red-500 text-white ring-4 ring-red-600/30'
: 'bg-slate-100 hover:bg-slate-200 text-slate-600'
}`}
title={isRecording ? 'Stop opname' : 'Start voice input'}
>
{isConnecting ? (
<Loader2 size={20} className="animate-spin" />
) : isRecording ? (
<MicOff size={20} />
) : (
<Mic size={20} />
)}
</button>
) : (
<button
type="button"
disabled
className="p-3 rounded-xl bg-slate-100 text-slate-400 cursor-not-allowed"
title="Spraakherkenning niet ondersteund in deze browser"
>
<MicOff size={20} />
</button>
)}
</form>
</footer>
);
});