"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Node } from '@/types/database'; import AsciiBanner from './AsciiBanner'; import TerminalMessage from './TerminalMessage'; import TerminalInput from './TerminalInput'; import { Zap, Flame } from 'lucide-react'; import { useSSEChat, ChatMessage, MessageRole } from './hooks/useSSEChat'; import { useQuotaHandler } from '@/hooks/useQuotaHandler'; import { apiKeyService } from '@/services/storage/apiKeys'; // Stub type for delegation (delegation system removed in rah-light) type AgentDelegation = { id: number; sessionId: string; task: string; status: 'queued' | 'in_progress' | 'completed' | 'failed'; summary?: string | null; agentType: string; createdAt: string; updatedAt: string; }; // Stub DelegationIndicator component (delegation system removed) function DelegationIndicator({ delegations }: { delegations: AgentDelegation[] }) { return null; } // Stub voice hooks (voice system will be removed in story 2) function useVoiceSession() { return { isActive: false, amplitude: 0, startSession: () => {}, stopSession: () => {}, resetTranscript: () => {}, setStatus: (_status: string) => {}, setAmplitude: (_amp: number) => {}, setInterimTranscript: (_text: string) => {}, appendFinalTranscript: (_text: string) => {}, }; } function useAssistantTTS(_options: { onSpeechStart?: () => void; onSpeechComplete?: () => void; onError?: (e: Error) => void }) { return { speak: (_text: string, _options?: { flush?: boolean; metadata?: Record }) => {}, stop: () => {}, status: 'idle' as const, }; } function useVoiceInterruption(_options: { amplitude: number; isVoiceActive: boolean; ttsStatus: string; onInterruption: () => void }) {} function useRealtimeVoiceClient(_handlers: { onStatusChange?: (status: string) => void; onInterimTranscript?: (text: string) => void; onFinalTranscript?: (text: string) => void; onAmplitude?: (amp: number) => void; onError?: (e: Error) => void }, _options: { getAuthToken: () => string | null }) { return { connect: () => {}, disconnect: () => {}, start: () => {}, stop: () => {}, }; } const createSessionId = () => `session_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const createVoiceRequestId = () => typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' ? crypto.randomUUID() : `voice_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; interface HighlightedPassage { selectedText: string; nodeId: number; nodeTitle: string; } interface RAHChatProps { openTabsData: Node[]; activeTabId: number | null; activeDimension?: string | null; onClearDimension?: () => void; onNodeClick?: (nodeId: number) => void; delegations?: AgentDelegation[]; messages?: ChatMessage[]; setMessages?: (updater: (prev: ChatMessage[]) => ChatMessage[]) => void; mode?: 'easy' | 'hard'; delegationMode?: boolean; delegationSessionId?: string; onQuickAdd?: () => void; highlightedPassage?: HighlightedPassage | null; onClearPassage?: () => void; } export default function RAHChat({ openTabsData, activeTabId, activeDimension, onClearDimension: _onClearDimension, onNodeClick, delegations = [], messages: externalMessages, setMessages: externalSetMessages, mode = 'easy', delegationMode = false, delegationSessionId, onQuickAdd: _onQuickAdd, highlightedPassage: _highlightedPassage, onClearPassage: _onClearPassage, }: RAHChatProps) { // Use external state if provided (lifted state), otherwise use local state const [internalMessages, internalSetMessages] = useState([]); const messages = externalMessages !== undefined ? externalMessages : internalMessages; const setMessages = externalSetMessages || internalSetMessages; const [sessionId, setSessionId] = useState(() => createSessionId()); const messagesEndRef = useRef(null); const chatMode = mode === 'hard' ? 'hard' : 'easy'; const helperKey = chatMode === 'hard' ? 'ra-h' : 'ra-h-easy'; const helperDisplayName = helperKey === 'ra-h' ? 'ra-h (hard)' : 'ra-h (easy)'; const { quotaError, handleAPIError: handleQuotaApiError, checkQuotaBeforeRequest, refetchUsage, isQuotaExceeded, } = useQuotaHandler(); const streamCompleteHandlerRef = useRef<() => Promise | void>(async () => { await refetchUsage(); }); const setMessagesRef = useRef(setMessages); const voice = useVoiceSession(); const { isActive: isVoiceActive, amplitude: voiceAmplitude, startSession: startVoice, stopSession: stopVoice, resetTranscript: resetVoiceTranscript, setStatus: setVoiceStatus, setAmplitude: setVoiceAmplitude, setInterimTranscript, appendFinalTranscript, } = voice; const pendingVoiceQueueRef = useRef<{ text: string; queuedAt: number }[]>([]); const assistantSpeechMapRef = useRef>(new Map()); const [voiceError, setVoiceError] = useState(null); const voiceErrorHandledRef = useRef(false); const voiceStartTimestampRef = useRef(null); const handleVoiceError = useCallback((error: Error) => { console.error('[RAHChat] Voice error:', error); setVoiceError(error.message); if (isVoiceActive) { stopVoice(); } resetVoiceTranscript(); setVoiceAmplitude(0); setVoiceStatus('idle'); pendingVoiceQueueRef.current = []; assistantSpeechMapRef.current.clear(); }, [isVoiceActive, resetVoiceTranscript, setVoiceAmplitude, setVoiceStatus, stopVoice]); const { speak: speakAssistantResponse, stop: stopAssistantTTS, status: ttsStatus } = useAssistantTTS({ onSpeechStart: () => { if (isVoiceActive) { setVoiceStatus('speaking'); } }, onSpeechComplete: () => { setVoiceStatus(isVoiceActive ? 'listening' : 'idle'); }, onError: handleVoiceError, }); const handleVoiceInterruption = useCallback(() => { stopAssistantTTS(); setVoiceStatus('listening'); }, [setVoiceStatus, stopAssistantTTS]); const sse = useSSEChat('/api/rah/chat', setMessages, { getAuthToken: () => null, beforeRequest: checkQuotaBeforeRequest, onRequestError: handleQuotaApiError, onStreamComplete: async () => { const handler = streamCompleteHandlerRef.current; if (handler) { await handler(); } }, }); useVoiceInterruption({ amplitude: voiceAmplitude, isVoiceActive, ttsStatus, onInterruption: handleVoiceInterruption, }); const sendMessage = useCallback(async (text: string) => { if (delegationMode) return; // Delegation chats are read-only if (isQuotaExceeded) { checkQuotaBeforeRequest(); return; } await sse.send({ text, history: messages, openTabs: openTabsData, activeTabId, sessionId, mode: chatMode }); }, [activeTabId, chatMode, checkQuotaBeforeRequest, delegationMode, isQuotaExceeded, messages, openTabsData, sse, sessionId]); const handleVoiceFinalTranscript = useCallback( (raw: string) => { const normalized = raw.trim(); console.info('[RAHVoice] Final transcript received:', normalized || '(empty)'); setInterimTranscript(''); if (!normalized) { console.info('[RAHVoice] Ignoring empty transcript'); return; } appendFinalTranscript(normalized); if (sse.isLoading) { pendingVoiceQueueRef.current.push({ text: normalized, queuedAt: Date.now() }); console.info('[RAHVoice] SSE busy, queueing transcript', { queuedCount: pendingVoiceQueueRef.current.length, }); setVoiceStatus('thinking'); return; } setVoiceStatus('thinking'); console.info('[RAHVoice] Dispatching transcript to /api/rah/chat'); void sendMessage(normalized); }, [appendFinalTranscript, sendMessage, setInterimTranscript, setVoiceStatus, sse.isLoading] ); const voiceRealtime = useRealtimeVoiceClient( { onStatusChange: (status) => { if (!isVoiceActive && status !== 'idle') return; if (status === 'listening' && (sse.isLoading || pendingVoiceQueueRef.current.length > 0)) { setVoiceStatus('thinking'); return; } setVoiceStatus(status); }, onInterimTranscript: setInterimTranscript, onFinalTranscript: handleVoiceFinalTranscript, onAmplitude: setVoiceAmplitude, onError: handleVoiceError, }, { getAuthToken: () => null, } ); const handleStreamComplete = useCallback(async () => { if (pendingVoiceQueueRef.current.length > 0) { console.info('[RAHVoice] SSE stream complete, draining queued transcripts', { queued: pendingVoiceQueueRef.current.length, }); } while (pendingVoiceQueueRef.current.length > 0) { const nextQueued = pendingVoiceQueueRef.current.shift(); if (!nextQueued) { break; } const queueLatency = Date.now() - nextQueued.queuedAt; setVoiceStatus('thinking'); console.info('[RAHVoice] Dispatching queued transcript to /api/rah/chat', { queuedMs: queueLatency, }); await sendMessage(nextQueued.text); } await refetchUsage(); }, [sendMessage, setVoiceStatus, refetchUsage]); useEffect(() => { streamCompleteHandlerRef.current = handleStreamComplete; }, [handleStreamComplete]); useEffect(() => { setMessagesRef.current = setMessages; }, [setMessages]); useEffect(() => { if (!voiceError) { voiceErrorHandledRef.current = false; return; } if (voiceErrorHandledRef.current) return; voiceErrorHandledRef.current = true; voiceRealtime.stop(); stopAssistantTTS(); }, [voiceError, voiceRealtime, stopAssistantTTS]); const focusSummary = useMemo(() => { if (!openTabsData.length) return null; const titles = openTabsData.map((node) => node?.title || 'Untitled'); const activeNode = openTabsData.find((node) => node.id === activeTabId) || openTabsData[0]; const truncate = (value: string, limit = 64) => { if (value.length <= limit) return value; return `${value.slice(0, limit - 1)}…`; }; return { id: activeNode?.id ?? null, title: truncate(activeNode?.title || 'Untitled'), total: titles.length, }; }, [openTabsData, activeTabId]); useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); useEffect(() => { if (!isVoiceActive) { assistantSpeechMapRef.current.clear(); stopAssistantTTS(); return; } if (sse.isLoading) return; const assistantMessages = messages.filter((m) => m.role === MessageRole.ASSISTANT); if (!assistantMessages.length) return; const latest = assistantMessages[assistantMessages.length - 1]; const spokenContent = assistantSpeechMapRef.current.get(latest.id); if (!latest.content.trim() || spokenContent === latest.content) return; assistantSpeechMapRef.current.set(latest.id, latest.content); const voiceRequestId = createVoiceRequestId(); speakAssistantResponse(latest.content, { flush: true, metadata: { sessionId, helper: helperKey, requestId: voiceRequestId, messageId: latest.id, }, }); }, [helperKey, isVoiceActive, messages, sessionId, sse.isLoading, speakAssistantResponse, stopAssistantTTS]); const handleNewChat = () => { if (delegationMode) return; sse.abort(); setMessages((_prev) => []); setSessionId(createSessionId()); if (isVoiceActive) { stopVoice(); resetVoiceTranscript(); } }; // Subscribe to delegation stream if in delegation mode useEffect(() => { if (!delegationMode || !delegationSessionId) return; const eventSource = new EventSource(`/api/rah/delegations/stream?sessionId=${delegationSessionId}`); eventSource.onmessage = (event) => { try { const data = JSON.parse(event.data); if (data.type === 'text-delta' && data.delta) { setMessagesRef.current((prev) => { const lastMsg = prev[prev.length - 1]; if (lastMsg && lastMsg.role === MessageRole.ASSISTANT) { const updated = [...prev]; updated[updated.length - 1] = { ...lastMsg, content: lastMsg.content + data.delta }; return updated; } return [...prev, { id: crypto.randomUUID(), role: MessageRole.ASSISTANT, content: data.delta, timestamp: new Date() }]; }); } if (data.type === 'tool-input-start' || data.type === 'tool-call') { const toolMessage: ChatMessage = { id: `tool-${data.toolCallId || crypto.randomUUID()}`, role: MessageRole.TOOL, content: data.toolName, timestamp: new Date(), toolName: data.toolName, status: (data.status as ChatMessage['status']) || 'running', toolArgs: data.input ?? data.args ?? data.parameters }; setMessagesRef.current((prev) => [...prev, toolMessage]); } if (data.type === 'tool-output-available' || data.type === 'tool-result') { setMessagesRef.current((prev) => prev.map((m) => m.id === `tool-${data.toolCallId}` ? { ...m, content: `${m.toolName} ${data.status === 'error' ? '✗' : '✓'}`, status: (data.status as ChatMessage['status']) || (data.error ? 'error' : 'complete'), toolResult: data.result ?? data.output ?? (data.summary ? { summary: data.summary } : undefined), } : m ) ); } if (data.type === 'assistant-message') { setMessagesRef.current((prev) => [...prev, { id: crypto.randomUUID(), role: MessageRole.ASSISTANT, content: '', timestamp: new Date() }]); } } catch (error) { console.error('[RAHChat] Failed to parse delegation stream event:', error); } }; eventSource.onerror = () => { console.error('[RAHChat] Delegation stream connection error'); }; return () => { eventSource.close(); }; }, [delegationMode, delegationSessionId]); useEffect(() => { if (delegationMode && isVoiceActive) { voiceRealtime.stop(); stopVoice(); resetVoiceTranscript(); stopAssistantTTS(); } }, [delegationMode, isVoiceActive, resetVoiceTranscript, stopAssistantTTS, stopVoice, voiceRealtime]); const handleVoiceToggle = useCallback(async () => { if (isVoiceActive) { voiceRealtime.stop(); stopVoice(); resetVoiceTranscript(); setVoiceAmplitude(0); setVoiceStatus('idle'); assistantSpeechMapRef.current.clear(); pendingVoiceQueueRef.current = []; stopAssistantTTS(); voiceStartTimestampRef.current = null; return; } setVoiceError(null); try { voiceStartTimestampRef.current = performance.now(); console.info('[RAHVoice] Voice session starting'); await voiceRealtime.start(); startVoice(); setVoiceStatus('listening'); } catch (error) { voiceStartTimestampRef.current = null; handleVoiceError(error instanceof Error ? error : new Error(String(error))); } }, [ handleVoiceError, isVoiceActive, resetVoiceTranscript, setVoiceAmplitude, setVoiceStatus, setVoiceError, startVoice, stopAssistantTTS, stopVoice, voiceRealtime, ]); return (
{focusSummary && (
{/* Focused node info */}
Focused Node ({focusSummary.total}) #{focusSummary.id} {focusSummary.title}
)}
{messages.length === 0 ? ( ) : ( <> {messages.map((message) => ( ))} )} {/* Voice transcript preview removed for streamlined UI */}
{!delegationMode && (
{(quotaError || isQuotaExceeded) && (
{quotaError?.message ?? 'Rate limit reached. Please wait a moment and try again.'}
)}
)}
); } interface ModelSelectorProps { chatMode: 'easy' | 'hard'; } function ModelSelector({ chatMode }: ModelSelectorProps) { const [dropdownOpen, setDropdownOpen] = useState(false); const currentModel = chatMode === 'easy' ? 'Easy (GPT)' : 'Hard (Claude)'; const Icon = chatMode === 'easy' ? Zap : Flame; const activeColor = chatMode === 'easy' ? '#22c55e' : '#f97316'; const options = [ { id: 'easy', label: 'Easy (GPT)', icon: Zap, color: '#22c55e' }, { id: 'hard', label: 'Hard (Claude)', icon: Flame, color: '#f97316' }, { id: 'soon', label: 'Ra-h (Soon)', icon: null, color: '#666', disabled: true } ]; return (
{dropdownOpen && (
{options.map((option) => { const OptionIcon = option.icon; const isActive = (option.id === 'easy' && chatMode === 'easy') || (option.id === 'hard' && chatMode === 'hard'); return ( ); })}
)}
); }