diff --git a/src/components/agents/RAHChat.tsx b/src/components/agents/RAHChat.tsx deleted file mode 100644 index c6e7456..0000000 --- a/src/components/agents/RAHChat.tsx +++ /dev/null @@ -1,501 +0,0 @@ -"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'; - -// 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; -} - -const createSessionId = () => `session_${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 sse = useSSEChat('/api/rah/chat', setMessages, { - getAuthToken: () => null, - beforeRequest: checkQuotaBeforeRequest, - onRequestError: handleQuotaApiError, - onStreamComplete: async () => { - const handler = streamCompleteHandlerRef.current; - if (handler) { - await handler(); - } - }, - }); - - 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 handleStreamComplete = useCallback(async () => { - await refetchUsage(); - }, [refetchUsage]); - - useEffect(() => { - streamCompleteHandlerRef.current = handleStreamComplete; - }, [handleStreamComplete]); - - useEffect(() => { - setMessagesRef.current = setMessages; - }, [setMessages]); - - 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]); - - const handleNewChat = () => { - if (delegationMode) return; - sse.abort(); - setMessages((_prev) => []); - setSessionId(createSessionId()); - }; - - // 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]); - - return ( -
- {focusSummary && ( -
- {/* Focused node info */} -
- - Focused Node ({focusSummary.total}) - - #{focusSummary.id} - - {focusSummary.title} - -
- -
- )} - -
- {messages.length === 0 ? ( - - ) : ( - <> - {messages.map((message) => ( - - ))} - - )} -
-
- - {!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 ( - - ); - })} -
- )} -
- ); -} diff --git a/src/components/agents/TerminalInput.tsx b/src/components/agents/TerminalInput.tsx deleted file mode 100644 index d4529cc..0000000 --- a/src/components/agents/TerminalInput.tsx +++ /dev/null @@ -1,399 +0,0 @@ -"use client"; - -import { useState, useRef, useEffect, type DragEvent } from 'react'; - -interface TerminalInputProps { - onSubmit: (text: string) => void; - isProcessing: boolean; - placeholder?: string; - helperId?: number; - disabledExternally?: boolean; - disabledMessage?: string; -} - -export default function TerminalInput({ - onSubmit, - isProcessing, - placeholder, - helperId, - disabledExternally = false, - disabledMessage, -}: TerminalInputProps) { - const [input, setInput] = useState(''); - const [rows, setRows] = useState(1); - const textareaRef = useRef(null); - const [prompts, setPrompts] = useState>([]); - const [showSlashMenu, setShowSlashMenu] = useState(false); - const [activeIndex, setActiveIndex] = useState(0); - const [isDragOver, setIsDragOver] = useState(false); - - useEffect(() => { - const load = async () => { - if (!helperId) return; - try { - const resp = await fetch(`/api/helpers/${helperId}/prompts`); - const data = await resp.json(); - if (resp.ok && data.success) setPrompts(Array.isArray(data.data?.prompts) ? data.data.prompts : []); - } catch (e) { - console.error('Failed to load prompts:', e); - } - }; - load(); - }, [helperId]); - - // Auto-resize textarea - only resize when needed to avoid jumps - useEffect(() => { - const textarea = textareaRef.current; - if (!textarea) return; - - const lineHeight = 24; // line-height * font-size approximately - const minHeight = 32; - const maxHeight = 120; - - // Check if content overflows current height (need to grow) - const needsGrow = textarea.scrollHeight > textarea.clientHeight; - - // Check if we cleared content significantly (need to shrink) - const lineCount = (input.match(/\n/g) || []).length + 1; - const estimatedHeight = Math.max(lineCount * lineHeight, minHeight); - const needsShrink = !input.trim() || (textarea.clientHeight > estimatedHeight + lineHeight); - - if (needsGrow || needsShrink) { - // Only recalculate when necessary - textarea.style.height = 'auto'; - const scrollHeight = textarea.scrollHeight; - const newHeight = Math.min(Math.max(scrollHeight, minHeight), maxHeight); - textarea.style.height = `${newHeight}px`; - - const newRows = Math.min(Math.max(1, Math.floor(newHeight / lineHeight)), 5); - setRows(newRows); - } - }, [input]); - - const handleSubmit = () => { - if (input.trim() && !isProcessing && !disabledExternally) { - // Numeric slash expansion: only when input is exactly /N - const m = input.trim().match(/^\/(\d{1,2})$/); - if (m) { - const n = parseInt(m[1], 10); - const idx = n - 1; - if (!isNaN(idx) && idx >= 0 && idx < prompts.length) { - const content = String(prompts[idx]?.content || '').trim(); - if (content) { - onSubmit(content); - setInput(''); - setRows(1); - setShowSlashMenu(false); - return; - } - } - } - onSubmit(input.trim()); - setInput(''); - setRows(1); - } - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - // Slash menu navigation - if (showSlashMenu) { - if (e.key === 'ArrowDown') { e.preventDefault(); setActiveIndex(i => Math.min(i + 1, prompts.length - 1)); return; } - if (e.key === 'ArrowUp') { e.preventDefault(); setActiveIndex(i => Math.max(i - 1, 0)); return; } - if (e.key === 'Tab') { e.preventDefault(); setActiveIndex(i => (i + 1) % Math.max(prompts.length, 1)); return; } - if (e.key === 'Enter') { - e.preventDefault(); - const p = prompts[activeIndex]; - if (p) { setInput(p.content); setShowSlashMenu(false); } - return; - } - if (e.key === 'Escape') { setShowSlashMenu(false); } - } - if (e.key === 'Enter' && !e.shiftKey && !disabledExternally) { - e.preventDefault(); - handleSubmit(); - } - }; - - useEffect(() => { - // Toggle slash menu when typing starting '/' - const trimmed = input.trimStart(); - if (trimmed.startsWith('/')) { - setShowSlashMenu(true); - setActiveIndex(0); - } else { - setShowSlashMenu(false); - } - }, [input]); - - const trimmedInput = input.trim(); - const buttonIsDisabled = !trimmedInput || isProcessing || disabledExternally; - - // Handle node drag over chat input - const handleDragOver = (e: DragEvent) => { - // Check if it's a node being dragged (either custom MIME or text/plain fallback) - if (e.dataTransfer.types.includes('application/x-rah-node') || - e.dataTransfer.types.includes('application/node-info') || - e.dataTransfer.types.includes('text/plain')) { - e.preventDefault(); - e.dataTransfer.dropEffect = 'copy'; - setIsDragOver(true); - } - }; - - const handleDragLeave = (e: DragEvent) => { - // Only reset if actually leaving the textarea (not entering a child) - if (e.currentTarget === e.target) { - setIsDragOver(false); - } - }; - - const handleDrop = (e: DragEvent) => { - e.preventDefault(); - setIsDragOver(false); - - // Try application/x-rah-node first (structured data with id + title) - let nodeData = e.dataTransfer.getData('application/x-rah-node'); - if (nodeData) { - try { - const { id, title } = JSON.parse(nodeData); - const token = `[NODE:${id}:"${title}"]`; - insertAtCursor(token); - return; - } catch (err) { - console.error('Failed to parse x-rah-node data:', err); - } - } - - // Fallback: try application/node-info (from NodesPanel) - nodeData = e.dataTransfer.getData('application/node-info'); - if (nodeData) { - try { - const { id, title } = JSON.parse(nodeData); - const token = `[NODE:${id}:"${title || 'Untitled'}"]`; - insertAtCursor(token); - return; - } catch (err) { - console.error('Failed to parse node-info data:', err); - } - } - - // Last resort: use text/plain (might already be formatted as [NODE:id:"title"]) - const plainText = e.dataTransfer.getData('text/plain'); - if (plainText && plainText.startsWith('[NODE:')) { - insertAtCursor(plainText); - } - }; - - const insertAtCursor = (text: string) => { - const textarea = textareaRef.current; - if (!textarea) { - setInput(prev => prev + text + ' '); - return; - } - - const start = textarea.selectionStart || 0; - const end = textarea.selectionEnd || 0; - const before = input.slice(0, start); - const after = input.slice(end); - - // Add space before if there's text and it doesn't end with space - const needsSpaceBefore = before.length > 0 && !before.endsWith(' ') && !before.endsWith('\n'); - // Add space after - const newText = (needsSpaceBefore ? ' ' : '') + text + ' '; - - setInput(before + newText + after); - - // Set cursor position after the inserted text - setTimeout(() => { - const newPos = start + newText.length; - textarea.setSelectionRange(newPos, newPos); - textarea.focus(); - }, 0); - }; - - - return ( - <> - -
- {/* Terminal Prompt Symbol */} - - {'>'} - - - {/* Input Wrapper */} -
- {/* Input Row with Textarea and Button */} -
-