S01Nour
feat(chat): implement detect stance and extract topic tools with enhanced UI and message formatting
5a8c8b7
| import React, { useState, useRef, useEffect } from 'react'; | |
| import { Plus, ArrowUp, Settings2, Mic, X, Check, Loader2, Search, Sparkles, Play, Pause } from 'lucide-react'; | |
| import { useMCPTools } from '../../hooks/useMCPTools.ts'; | |
| import type { MCPTool } from '../../types/index.ts'; | |
| type ChatInputProps = { | |
| onSubmit?: (message: string, selectedTool?: string | null, stance?: 'positive' | 'negative') => void; | |
| onAudioSubmit?: (audioBlob: Blob, selectedTool?: string | null, stance?: 'positive' | 'negative') => void; | |
| placeholder?: string; | |
| }; | |
| // Type guard to ensure tool type safety (used for runtime validation if needed) | |
| const isMCPTool = (value: any): value is MCPTool => { | |
| return value && typeof value === 'object' && typeof value.name === 'string'; | |
| }; | |
| // Allowed tools - only these 3 will be shown | |
| const ALLOWED_TOOLS = ['detect stance', 'generate argument', 'extract topic']; | |
| // Helper function to check if a tool name matches one of the allowed tools | |
| const isAllowedTool = (toolName: string): boolean => { | |
| const toolLower = toolName.toLowerCase(); | |
| return ALLOWED_TOOLS.some(allowed => | |
| toolLower.includes(allowed.split(' ')[0]) && | |
| toolLower.includes(allowed.split(' ')[1]) | |
| ); | |
| }; | |
| // Helper function to normalize tool name to standard format | |
| const normalizeToolName = (toolName: string): string => { | |
| const toolLower = toolName.toLowerCase(); | |
| if (toolLower.includes('detect') && toolLower.includes('stance')) { | |
| return 'detect stance'; | |
| } | |
| if (toolLower.includes('generate') && toolLower.includes('argument')) { | |
| return 'generate argument'; | |
| } | |
| if (toolLower.includes('extract') && toolLower.includes('topic')) { | |
| return 'extract topic'; | |
| } | |
| return toolName; | |
| }; | |
| // Helper function to get dynamic placeholder based on selected tool | |
| const getPlaceholder = (selectedTool: string | null, defaultPlaceholder: string): string => { | |
| if (!selectedTool) { | |
| return defaultPlaceholder; | |
| } | |
| const normalizedTool = normalizeToolName(selectedTool); | |
| switch (normalizedTool) { | |
| case 'detect stance': | |
| return ''; // Will use separate fields, no placeholder needed | |
| case 'generate argument': | |
| return 'Enter a debate topic to generate an argument (e.g., "cannabis legalization")...'; | |
| case 'extract topic': | |
| return 'Enter text to extract the topic (e.g., "Should we legalize assisted suicide?")...'; | |
| default: | |
| return defaultPlaceholder; | |
| } | |
| }; | |
| const ChatInput = ({ onSubmit, onAudioSubmit, placeholder = 'Ask a follow-up...' }: ChatInputProps) => { | |
| const [input, setInput] = useState(''); | |
| const [isRecording, setIsRecording] = useState(false); | |
| const [showToolsDropdown, setShowToolsDropdown] = useState(false); | |
| const [selectedTool, setSelectedTool] = useState<string | null>(null); | |
| const [selectedStance, setSelectedStance] = useState<'positive' | 'negative' | null>(null); | |
| const [searchQuery, setSearchQuery] = useState(''); | |
| // Separate inputs for detect stance tool | |
| const [detectStanceTopic, setDetectStanceTopic] = useState(''); | |
| const [detectStanceArgument, setDetectStanceArgument] = useState(''); | |
| const [focusedIndex, setFocusedIndex] = useState(-1); | |
| const [dropdownPosition, setDropdownPosition] = useState<'below' | 'above'>('below'); | |
| const [dropdownMaxHeight, setDropdownMaxHeight] = useState(320); | |
| const [audioBlob, setAudioBlob] = useState<Blob | null>(null); | |
| const [audioUrl, setAudioUrl] = useState<string | null>(null); | |
| const [isPlaying, setIsPlaying] = useState(false); | |
| const [recordingTime, setRecordingTime] = useState(0); | |
| const dropdownRef = useRef<HTMLDivElement>(null); | |
| const dropdownContentRef = useRef<HTMLDivElement>(null); | |
| const searchInputRef = useRef<HTMLInputElement>(null); | |
| const mediaRecorderRef = useRef<MediaRecorder | null>(null); | |
| const audioChunksRef = useRef<Blob[]>([]); | |
| const audioRef = useRef<HTMLAudioElement | null>(null); | |
| const recordingTimerRef = useRef<NodeJS.Timeout | null>(null); | |
| const { tools, loading, error, refetch } = useMCPTools(); | |
| const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| const normalizedTool = selectedTool ? normalizeToolName(selectedTool) : null; | |
| // Handle detect stance tool with two fields | |
| if (normalizedTool === 'detect stance') { | |
| if (detectStanceTopic.trim() && detectStanceArgument.trim()) { | |
| // Format as JSON string for detect stance: topic and argument | |
| const detectStanceInput = JSON.stringify({ | |
| topic: detectStanceTopic.trim(), | |
| argument: detectStanceArgument.trim(), | |
| }); | |
| if (onSubmit) { | |
| onSubmit(detectStanceInput, selectedTool); | |
| } | |
| setDetectStanceTopic(''); | |
| setDetectStanceArgument(''); | |
| } | |
| } else if (input.trim()) { | |
| if (onSubmit) { | |
| onSubmit(input, selectedTool, selectedStance || undefined); | |
| } | |
| console.log('Submitted:', input); | |
| setInput(''); | |
| // Reset stance after submit if generate argument tool | |
| if (normalizedTool === 'generate argument') { | |
| setSelectedStance(null); | |
| } | |
| } | |
| return false; | |
| }; | |
| const handleMicClick = async () => { | |
| try { | |
| const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); | |
| // Try to use a supported mime type | |
| let options: MediaRecorderOptions = {}; | |
| if (MediaRecorder.isTypeSupported('audio/webm;codecs=opus')) { | |
| options = { mimeType: 'audio/webm;codecs=opus' }; | |
| } else if (MediaRecorder.isTypeSupported('audio/webm')) { | |
| options = { mimeType: 'audio/webm' }; | |
| } else if (MediaRecorder.isTypeSupported('audio/mp4')) { | |
| options = { mimeType: 'audio/mp4' }; | |
| } | |
| const mediaRecorder = new MediaRecorder(stream, options); | |
| mediaRecorderRef.current = mediaRecorder; | |
| audioChunksRef.current = []; | |
| mediaRecorder.ondataavailable = (event) => { | |
| if (event.data.size > 0) { | |
| audioChunksRef.current.push(event.data); | |
| } | |
| }; | |
| mediaRecorder.onstop = () => { | |
| const mimeType = mediaRecorder.mimeType || 'audio/webm'; | |
| const blob = new Blob(audioChunksRef.current, { type: mimeType }); | |
| setAudioBlob(blob); | |
| const url = URL.createObjectURL(blob); | |
| setAudioUrl(url); | |
| // Clean up old audio element | |
| if (audioRef.current) { | |
| audioRef.current.pause(); | |
| audioRef.current = null; | |
| } | |
| // Stop all tracks to release microphone | |
| stream.getTracks().forEach(track => track.stop()); | |
| }; | |
| mediaRecorder.start(); | |
| setIsRecording(true); | |
| setRecordingTime(0); | |
| // Start recording timer | |
| recordingTimerRef.current = setInterval(() => { | |
| setRecordingTime(prev => prev + 1); | |
| }, 1000); | |
| } catch (error) { | |
| console.error('Error accessing microphone:', error); | |
| alert('Could not access microphone. Please check your permissions.'); | |
| } | |
| }; | |
| const handleCancelRecording = () => { | |
| if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') { | |
| mediaRecorderRef.current.stop(); | |
| } | |
| if (recordingTimerRef.current) { | |
| clearInterval(recordingTimerRef.current); | |
| recordingTimerRef.current = null; | |
| } | |
| if (audioRef.current) { | |
| audioRef.current.pause(); | |
| audioRef.current = null; | |
| } | |
| setIsRecording(false); | |
| setIsPlaying(false); | |
| setRecordingTime(0); | |
| setAudioBlob(null); | |
| if (audioUrl) { | |
| URL.revokeObjectURL(audioUrl); | |
| setAudioUrl(null); | |
| } | |
| audioChunksRef.current = []; | |
| }; | |
| const handleConfirmRecording = () => { | |
| if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') { | |
| // Set up a temporary onstop handler specifically for submission | |
| const originalOnStop = mediaRecorderRef.current.onstop; | |
| mediaRecorderRef.current.onstop = () => { | |
| // Call the original onStop to ensure all state is properly set | |
| if (originalOnStop) { | |
| originalOnStop.call(mediaRecorderRef.current, new Event('stop')); | |
| } | |
| // Create the blob directly from audioChunksRef to ensure we have the data | |
| const mimeType = mediaRecorderRef.current?.mimeType || 'audio/webm'; | |
| const blob = new Blob(audioChunksRef.current, { type: mimeType }); | |
| // Submit the audio immediately | |
| if (blob && onAudioSubmit) { | |
| onAudioSubmit(blob, selectedTool); | |
| } else { | |
| console.error('Audio blob not available for submission', { blob: blob.size > 0, onAudioSubmit }); | |
| } | |
| }; | |
| mediaRecorderRef.current.stop(); | |
| } | |
| if (recordingTimerRef.current) { | |
| clearInterval(recordingTimerRef.current); | |
| recordingTimerRef.current = null; | |
| } | |
| setIsRecording(false); | |
| }; | |
| const handlePlayPause = async () => { | |
| if (!audioUrl) { | |
| console.error('Audio URL not available'); | |
| return; | |
| } | |
| // Ensure audio element exists | |
| if (!audioRef.current) { | |
| console.error('Audio element not initialized'); | |
| return; | |
| } | |
| try { | |
| if (isPlaying) { | |
| audioRef.current.pause(); | |
| setIsPlaying(false); | |
| } else { | |
| // Reset to beginning if needed | |
| if (audioRef.current.ended) { | |
| audioRef.current.currentTime = 0; | |
| } | |
| // Play the audio | |
| const playPromise = audioRef.current.play(); | |
| if (playPromise !== undefined) { | |
| await playPromise; | |
| setIsPlaying(true); | |
| } else { | |
| setIsPlaying(true); | |
| } | |
| } | |
| } catch (error) { | |
| console.error('Error playing audio:', error); | |
| setIsPlaying(false); | |
| // Check if it's an autoplay policy issue | |
| if (error instanceof Error && error.name === 'NotAllowedError') { | |
| alert('Please interact with the page first, then try playing again.'); | |
| } else { | |
| alert('Could not play audio. Please try again.'); | |
| } | |
| } | |
| }; | |
| const handleSendRecording = () => { | |
| // Stop audio playback if playing | |
| if (audioRef.current && isPlaying) { | |
| audioRef.current.pause(); | |
| setIsPlaying(false); | |
| } | |
| // Submit the recorded audio if available | |
| if (audioBlob && onAudioSubmit) { | |
| onAudioSubmit(audioBlob, selectedTool, selectedStance || undefined); | |
| // Reset stance after submit if generate argument tool | |
| if (selectedTool && normalizeToolName(selectedTool) === 'generate argument') { | |
| setSelectedStance(null); | |
| } | |
| } | |
| // Clean up and return to normal chat mode | |
| handleCancelRecording(); | |
| // Focus back to text input | |
| setTimeout(() => { | |
| const textarea = document.querySelector('textarea'); | |
| if (textarea) textarea.focus(); | |
| }, 100); | |
| }; | |
| // Initialize audio element when audioUrl is available | |
| useEffect(() => { | |
| if (audioUrl) { | |
| // Clean up old audio element if URL changed | |
| if (audioRef.current && audioRef.current.src !== audioUrl) { | |
| audioRef.current.pause(); | |
| audioRef.current = null; | |
| } | |
| // Create new audio element if it doesn't exist | |
| if (!audioRef.current) { | |
| const audio = new Audio(audioUrl); | |
| audioRef.current = audio; | |
| audio.volume = 1.0; | |
| audio.onended = () => { | |
| setIsPlaying(false); | |
| }; | |
| audio.onerror = (error) => { | |
| console.error('Audio initialization error:', error); | |
| setIsPlaying(false); | |
| }; | |
| audio.onloadeddata = () => { | |
| console.log('Audio loaded and ready'); | |
| }; | |
| audio.oncanplay = () => { | |
| console.log('Audio can play'); | |
| }; | |
| } | |
| } | |
| return () => { | |
| // Don't clean up audio element here - let it persist for playback | |
| }; | |
| }, [audioUrl]); | |
| // Cleanup on unmount | |
| useEffect(() => { | |
| return () => { | |
| if (recordingTimerRef.current) { | |
| clearInterval(recordingTimerRef.current); | |
| } | |
| if (audioRef.current) { | |
| audioRef.current.pause(); | |
| audioRef.current = null; | |
| } | |
| if (audioUrl) { | |
| URL.revokeObjectURL(audioUrl); | |
| } | |
| }; | |
| }, [audioUrl]); | |
| const WaveAnimation = () => { | |
| const [animationKey, setAnimationKey] = useState(0); | |
| useEffect(() => { | |
| const interval = setInterval(() => { | |
| setAnimationKey((prev) => prev + 1); | |
| }, 100); | |
| return () => clearInterval(interval); | |
| }, []); | |
| const bars = Array.from({ length: 50 }, (_, i) => { | |
| const height = Math.random() * 20 + 4; | |
| const delay = Math.random() * 2; | |
| return ( | |
| <div | |
| key={`${i}-${animationKey}`} | |
| className="bg-zinc-400 dark:bg-gray-400 rounded-sm animate-pulse" | |
| style={{ | |
| width: '2px', | |
| height: `${height}px`, | |
| animationDelay: `${delay}s`, | |
| animationDuration: '1s', | |
| }} | |
| /> | |
| ); | |
| }); | |
| return ( | |
| <div className="flex items-center w-full gap-1"> | |
| <div className="flex-1 border-t-2 border-dotted border-zinc-400 dark:border-gray-500"></div> | |
| <div className="flex items-center gap-0.5 justify-center px-8">{bars}</div> | |
| <div className="flex-1 border-t-2 border-dotted border-zinc-400 dark:border-gray-500"></div> | |
| </div> | |
| ); | |
| }; | |
| // Close dropdown when clicking outside | |
| useEffect(() => { | |
| const handleClickOutside = (event: MouseEvent) => { | |
| if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { | |
| setShowToolsDropdown(false); | |
| setFocusedIndex(-1); | |
| } | |
| }; | |
| document.addEventListener('mousedown', handleClickOutside); | |
| return () => { | |
| document.removeEventListener('mousedown', handleClickOutside); | |
| }; | |
| }, []); | |
| // Handle keyboard navigation | |
| useEffect(() => { | |
| const handleKeyDown = (event: KeyboardEvent) => { | |
| if (!showToolsDropdown) return; | |
| const filteredTools = tools.filter(isMCPTool).filter(tool => | |
| tool.name.toLowerCase().includes(searchQuery.toLowerCase()) || | |
| (tool.description && tool.description.toLowerCase().includes(searchQuery.toLowerCase())) | |
| ); | |
| switch (event.key) { | |
| case 'ArrowDown': | |
| event.preventDefault(); | |
| setFocusedIndex(prev => (prev + 1) % filteredTools.length); | |
| break; | |
| case 'ArrowUp': | |
| event.preventDefault(); | |
| setFocusedIndex(prev => prev <= 0 ? filteredTools.length - 1 : prev - 1); | |
| break; | |
| case 'Enter': | |
| event.preventDefault(); | |
| if (focusedIndex >= 0 && filteredTools[focusedIndex]) { | |
| setSelectedTool(filteredTools[focusedIndex].name); | |
| setShowToolsDropdown(false); | |
| setFocusedIndex(-1); | |
| } | |
| break; | |
| case 'Escape': | |
| event.preventDefault(); | |
| setShowToolsDropdown(false); | |
| setFocusedIndex(-1); | |
| break; | |
| } | |
| }; | |
| document.addEventListener('keydown', handleKeyDown); | |
| return () => { | |
| document.removeEventListener('keydown', handleKeyDown); | |
| }; | |
| }, [showToolsDropdown, focusedIndex, tools, searchQuery]); | |
| // Calculate dropdown position and max height based on viewport | |
| useEffect(() => { | |
| if (showToolsDropdown && dropdownRef.current) { | |
| const calculatePosition = () => { | |
| const buttonElement = dropdownRef.current?.querySelector('button'); | |
| if (!buttonElement) return; | |
| const buttonRect = buttonElement.getBoundingClientRect(); | |
| const viewportHeight = window.innerHeight; | |
| const viewportWidth = window.innerWidth; | |
| const spaceBelow = viewportHeight - buttonRect.bottom; | |
| const spaceAbove = buttonRect.top; | |
| const dropdownHeight = 500; // Approximate max height (increased from 400) | |
| const minSpace = 20; // Minimum space from viewport edge | |
| // Determine if dropdown should be above or below | |
| if (spaceBelow < dropdownHeight + minSpace && spaceAbove > spaceBelow) { | |
| setDropdownPosition('above'); | |
| // Calculate max height based on available space above | |
| const maxHeight = Math.min(450, spaceAbove - minSpace - 60); // Increased from 320 to 450 | |
| setDropdownMaxHeight(Math.max(250, maxHeight)); // Increased minimum from 200 to 250 | |
| } else { | |
| setDropdownPosition('below'); | |
| // Calculate max height based on available space below | |
| const maxHeight = Math.min(450, spaceBelow - minSpace - 60); // Increased from 320 to 450 | |
| setDropdownMaxHeight(Math.max(250, maxHeight)); // Increased minimum from 200 to 250 | |
| } | |
| // Adjust horizontal position if dropdown would overflow | |
| const dropdownWidth = 384; // w-96 = 384px (increased from w-80) | |
| const dropdownElement = dropdownRef.current?.querySelector('[data-dropdown-content]') as HTMLElement; | |
| if (dropdownElement) { | |
| if (buttonRect.left + dropdownWidth > viewportWidth - minSpace) { | |
| // Would overflow on the right, align to right edge | |
| dropdownElement.style.right = '0'; | |
| dropdownElement.style.left = 'auto'; | |
| } else { | |
| // Reset to left alignment | |
| dropdownElement.style.right = 'auto'; | |
| dropdownElement.style.left = '0'; | |
| } | |
| } | |
| }; | |
| calculatePosition(); | |
| // Recalculate on window resize or scroll | |
| window.addEventListener('resize', calculatePosition); | |
| window.addEventListener('scroll', calculatePosition, true); | |
| return () => { | |
| window.removeEventListener('resize', calculatePosition); | |
| window.removeEventListener('scroll', calculatePosition, true); | |
| }; | |
| } | |
| }, [showToolsDropdown]); | |
| // Focus search input when dropdown opens | |
| useEffect(() => { | |
| if (showToolsDropdown && searchInputRef.current) { | |
| setTimeout(() => searchInputRef.current?.focus(), 100); | |
| } | |
| }, [showToolsDropdown]); | |
| const toggleToolsDropdown = () => { | |
| const nextState = !showToolsDropdown; | |
| setShowToolsDropdown(nextState); | |
| setSearchQuery(''); | |
| setFocusedIndex(-1); | |
| if (nextState) { | |
| refetch(); | |
| } | |
| }; | |
| return ( | |
| <div className="relative"> | |
| <form onSubmit={handleSubmit} className="relative"> | |
| <div | |
| className="border border-zinc-300 dark:border-zinc-700 rounded-2xl p-4 relative transition-all duration-500 ease-in-out overflow-visible bg-zinc-100 dark:bg-[#141415]" | |
| > | |
| {isRecording ? ( | |
| <div className="flex items-center justify-between h-12 animate-fade-in w-full"> | |
| <div className="flex items-center gap-3 flex-1"> | |
| <WaveAnimation /> | |
| <span className="text-sm text-zinc-600 dark:text-zinc-400 whitespace-nowrap"> | |
| {Math.floor(recordingTime / 60)}:{(recordingTime % 60).toString().padStart(2, '0')} | |
| </span> | |
| </div> | |
| <div className="flex items-center gap-2 ml-4"> | |
| <button | |
| type="button" | |
| onClick={handleCancelRecording} | |
| className="h-8 w-8 p-0 text-zinc-800 dark:text-white hover:text-zinc-900 dark:hover:text-white hover:bg-zinc-200 dark:hover:bg-zinc-700 rounded-lg transition-all duration-200 hover:scale-110 flex items-center justify-center" | |
| > | |
| <X className="h-5 w-5" /> | |
| </button> | |
| <button | |
| type="button" | |
| onClick={handleConfirmRecording} | |
| className="h-8 w-8 p-0 rounded-lg transition-all duration-200 hover:scale-110 flex items-center justify-center bg-teal-400 dark:bg-[#2DD4BF] text-teal-900 dark:text-[#032827]" | |
| > | |
| <Check className="h-5 w-5" /> | |
| </button> | |
| </div> | |
| </div> | |
| ) : audioBlob && audioUrl ? ( | |
| <div className="flex items-center justify-between h-12 animate-fade-in w-full"> | |
| <div className="flex items-center gap-3 flex-1"> | |
| <button | |
| type="button" | |
| onClick={handlePlayPause} | |
| className="h-8 w-8 p-0 text-zinc-800 dark:text-white hover:text-zinc-900 dark:hover:text-white hover:bg-zinc-200 dark:hover:bg-zinc-700 rounded-lg transition-all duration-200 hover:scale-110 flex items-center justify-center" | |
| > | |
| {isPlaying ? ( | |
| <Pause className="h-5 w-5" /> | |
| ) : ( | |
| <Play className="h-5 w-5" /> | |
| )} | |
| </button> | |
| <span className="text-sm text-zinc-600 dark:text-zinc-400"> | |
| {isPlaying ? 'Playing...' : 'Tap to replay'} | |
| </span> | |
| </div> | |
| <div className="flex items-center gap-2 ml-4"> | |
| <button | |
| type="button" | |
| onClick={handleCancelRecording} | |
| className="h-8 w-8 p-0 text-zinc-800 dark:text-white hover:text-zinc-900 dark:hover:text-white hover:bg-zinc-200 dark:hover:bg-zinc-700 rounded-lg transition-all duration-200 hover:scale-110 flex items-center justify-center" | |
| > | |
| <X className="h-5 w-5" /> | |
| </button> | |
| <button | |
| type="button" | |
| onClick={handleSendRecording} | |
| className="h-8 w-8 p-0 rounded-lg transition-all duration-200 hover:scale-110 flex items-center justify-center bg-teal-400 dark:bg-[#2DD4BF] text-teal-900 dark:text-[#032827]" | |
| > | |
| <ArrowUp className="h-5 w-5" /> | |
| </button> | |
| </div> | |
| </div> | |
| ) : ( | |
| <div className="animate-fade-in"> | |
| {/* Two input fields for detect stance tool */} | |
| {selectedTool && normalizeToolName(selectedTool) === 'detect stance' ? ( | |
| <div className="space-y-3"> | |
| <div> | |
| <label className="block text-xs font-medium text-zinc-600 dark:text-zinc-400 mb-1.5"> | |
| Topic | |
| </label> | |
| <input | |
| type="text" | |
| value={detectStanceTopic} | |
| onChange={(e) => setDetectStanceTopic(e.target.value)} | |
| placeholder="Enter the debate topic (e.g., Climate change is real)" | |
| className="w-full bg-transparent text-zinc-800 dark:text-gray-300 placeholder-zinc-400 dark:placeholder-gray-500 border border-zinc-300 dark:border-zinc-600 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 dark:focus:ring-teal-400" | |
| /> | |
| </div> | |
| <div> | |
| <label className="block text-xs font-medium text-zinc-600 dark:text-zinc-400 mb-1.5"> | |
| Argument | |
| </label> | |
| <textarea | |
| value={detectStanceArgument} | |
| onChange={(e) => setDetectStanceArgument(e.target.value)} | |
| placeholder="Enter the argument to analyze (e.g., Rising global temperatures prove it)" | |
| className="w-full bg-transparent text-zinc-800 dark:text-gray-300 placeholder-zinc-400 dark:placeholder-gray-500 border border-zinc-300 dark:border-zinc-600 rounded-lg px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-teal-500 dark:focus:ring-teal-400 min-h-[60px]" | |
| rows={2} | |
| onKeyDown={(e) => { | |
| if (e.key === 'Enter' && !e.shiftKey && detectStanceTopic.trim() && detectStanceArgument.trim()) { | |
| e.preventDefault(); | |
| handleSubmit(e as any); | |
| } | |
| }} | |
| /> | |
| </div> | |
| </div> | |
| ) : ( | |
| <textarea | |
| value={input} | |
| onChange={(e) => setInput(e.target.value)} | |
| onKeyDown={(e) => { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| if (input.trim()) { | |
| if (onSubmit) { | |
| onSubmit(input, selectedTool, selectedStance || undefined); | |
| } | |
| setInput(''); | |
| // Reset stance after submit if generate argument tool | |
| if (selectedTool && normalizeToolName(selectedTool) === 'generate argument') { | |
| setSelectedStance(null); | |
| } | |
| } | |
| } | |
| }} | |
| placeholder={getPlaceholder(selectedTool, placeholder)} | |
| className="w-full bg-transparent text-zinc-800 dark:text-gray-300 placeholder-zinc-400 dark:placeholder-gray-500 resize-none border-none outline-none text-base leading-relaxed min-h-[24px] max-h-32 transition-all duration-200" | |
| rows={1} | |
| onInput={(e) => { | |
| const target = e.target as HTMLTextAreaElement; | |
| target.style.height = 'auto'; | |
| target.style.height = target.scrollHeight + 'px'; | |
| }} | |
| /> | |
| )} | |
| {/* Stance selection buttons for generate argument tool */} | |
| {selectedTool && normalizeToolName(selectedTool) === 'generate argument' && ( | |
| <div className="mt-3 flex items-center gap-2"> | |
| <span className="text-xs font-medium text-zinc-600 dark:text-zinc-400">Select stance:</span> | |
| <button | |
| type="button" | |
| onClick={() => setSelectedStance(selectedStance === 'positive' ? null : 'positive')} | |
| className={`px-3 py-1.5 text-xs rounded-full font-medium transition-all ${ | |
| selectedStance === 'positive' | |
| ? 'bg-emerald-500 text-white shadow-md' | |
| : 'bg-emerald-100 text-emerald-800 hover:bg-emerald-200 dark:bg-emerald-900/40 dark:text-emerald-200 dark:hover:bg-emerald-900/60' | |
| }`} | |
| > | |
| Positive | |
| </button> | |
| <button | |
| type="button" | |
| onClick={() => setSelectedStance(selectedStance === 'negative' ? null : 'negative')} | |
| className={`px-3 py-1.5 text-xs rounded-full font-medium transition-all ${ | |
| selectedStance === 'negative' | |
| ? 'bg-rose-500 text-white shadow-md' | |
| : 'bg-rose-100 text-rose-800 hover:bg-rose-200 dark:bg-rose-900/40 dark:text-rose-200 dark:hover:bg-rose-900/60' | |
| }`} | |
| > | |
| Negative | |
| </button> | |
| </div> | |
| )} | |
| <div className="flex items-center justify-between mt-8"> | |
| <div className="flex items-center gap-2"> | |
| <button | |
| type="button" | |
| className="h-8 w-8 p-0 text-zinc-800 dark:text-white hover:text-zinc-900 dark:hover:text-white hover:bg-zinc-200 dark:hover:bg-zinc-700 rounded-lg transition-all duration-200 hover:scale-110 flex items-center justify-center" | |
| > | |
| <Plus className="h-5 w-5" /> | |
| </button> | |
| <div className="relative" ref={dropdownRef}> | |
| <button | |
| type="button" | |
| onClick={toggleToolsDropdown} | |
| className={`h-8 w-8 p-0 rounded-lg transition-all duration-200 hover:scale-110 flex items-center justify-center relative ${selectedTool ? 'bg-teal-500/15 text-teal-600 dark:bg-teal-500/20 dark:text-teal-300 hover:bg-teal-500/25 dark:hover:bg-teal-500/30' : 'text-zinc-800 dark:text-white hover:text-zinc-900 dark:hover:text-white hover:bg-zinc-200 dark:hover:bg-zinc-700'}`} | |
| aria-expanded={showToolsDropdown} | |
| aria-haspopup="true" | |
| > | |
| <Settings2 className="h-5 w-5" /> | |
| {selectedTool && ( | |
| <div className="absolute -top-1 -right-1 h-2 w-2 bg-teal-500 rounded-full animate-pulse"></div> | |
| )} | |
| </button> | |
| {showToolsDropdown && ( | |
| <div | |
| ref={dropdownContentRef} | |
| data-dropdown-content | |
| className={`absolute left-0 w-96 rounded-2xl border border-zinc-200/70 dark:border-zinc-700/60 bg-white/95 dark:bg-zinc-900/95 backdrop-blur-xl shadow-[0_20px_45px_-20px_rgba(12,12,12,0.75)] z-50 overflow-hidden animate-fade-in flex flex-col ${ | |
| dropdownPosition === 'above' | |
| ? 'bottom-[calc(100%+0.6rem)]' | |
| : 'top-[calc(100%+0.6rem)]' | |
| }`} | |
| style={{ maxHeight: `${dropdownMaxHeight}px` }} | |
| > | |
| {/* Header */} | |
| <div className="px-4 py-3 border-b border-zinc-200/50 dark:border-zinc-700/50 flex-shrink-0"> | |
| <div className="flex items-center gap-2 mb-3"> | |
| <Sparkles className="h-4 w-4 text-teal-500 dark:text-teal-400" /> | |
| <h3 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">Available Tools</h3> | |
| <div className="ml-auto"> | |
| <span className="text-xs text-zinc-500 dark:text-zinc-400 bg-zinc-100 dark:bg-zinc-800 px-2 py-1 rounded-full"> | |
| {tools.filter(isMCPTool).length} tools | |
| </span> | |
| </div> | |
| </div> | |
| {/* Search input */} | |
| <div className="relative"> | |
| <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-zinc-400 dark:text-zinc-500" /> | |
| <input | |
| ref={searchInputRef} | |
| type="text" | |
| placeholder="Search tools..." | |
| value={searchQuery} | |
| onChange={(e) => { | |
| setSearchQuery(e.target.value); | |
| setFocusedIndex(-1); | |
| }} | |
| className="w-full pl-10 pr-4 py-2 text-sm bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg text-zinc-900 dark:text-zinc-100 placeholder-zinc-500 dark:placeholder-zinc-400 focus:outline-none focus:ring-2 focus:ring-teal-500/50 focus:border-teal-500/50 transition-all duration-200" | |
| /> | |
| </div> | |
| </div> | |
| {/* Tools list */} | |
| <div | |
| className="overflow-y-auto scrollbar-hide flex-1" | |
| style={{ maxHeight: `${dropdownMaxHeight - 140}px` }} | |
| > | |
| {loading ? ( | |
| <div className="flex items-center justify-center gap-3 px-4 py-8"> | |
| <Loader2 className="h-5 w-5 animate-spin text-teal-500 dark:text-teal-400" /> | |
| <span className="text-sm text-zinc-600 dark:text-zinc-300">Loading tools…</span> | |
| </div> | |
| ) : error ? ( | |
| <div className="px-4 py-6"> | |
| <div className="flex flex-col items-center gap-3 text-center"> | |
| <div className="h-12 w-12 rounded-full bg-red-100 dark:bg-red-900/20 flex items-center justify-center"> | |
| <X className="h-6 w-6 text-red-500 dark:text-red-400" /> | |
| </div> | |
| <div> | |
| <p className="text-sm font-medium text-red-600 dark:text-red-400">Failed to load tools</p> | |
| <p className="text-xs text-red-500 dark:text-red-500 mt-1">Please try again later</p> | |
| </div> | |
| </div> | |
| </div> | |
| ) : tools.filter(isMCPTool).filter(tool => isAllowedTool(tool.name)).length === 0 ? ( | |
| <div className="px-4 py-6"> | |
| <div className="flex flex-col items-center gap-3 text-center"> | |
| <div className="h-12 w-12 rounded-full bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center"> | |
| <Settings2 className="h-6 w-6 text-zinc-400 dark:text-zinc-500" /> | |
| </div> | |
| <div> | |
| <p className="text-sm font-medium text-zinc-600 dark:text-zinc-300">No tools available</p> | |
| <p className="text-xs text-zinc-500 dark:text-zinc-400 mt-1">Check back later for updates</p> | |
| </div> | |
| </div> | |
| </div> | |
| ) : ( | |
| <div className="p-2"> | |
| {tools | |
| .filter(isMCPTool) | |
| .filter(tool => isAllowedTool(tool.name)) | |
| .filter(tool => | |
| tool.name.toLowerCase().includes(searchQuery.toLowerCase()) || | |
| (tool.description && tool.description.toLowerCase().includes(searchQuery.toLowerCase())) | |
| ) | |
| .map((tool, index) => ( | |
| <button | |
| key={tool.name || index} | |
| type="button" | |
| className={`w-full rounded-xl px-4 py-3 text-left transition-all duration-200 group ${ | |
| focusedIndex === index | |
| ? 'bg-teal-50 dark:bg-teal-900/20 border border-teal-300/80 dark:border-teal-400/60' | |
| : selectedTool === tool.name | |
| ? 'bg-teal-50/50 dark:bg-teal-900/10 border border-teal-200/60 dark:border-teal-400/40' | |
| : 'hover:bg-zinc-50 dark:hover:bg-zinc-800/50 border border-transparent' | |
| }`} | |
| onClick={() => { | |
| const newTool = tool.name === selectedTool ? null : tool.name; | |
| setSelectedTool(newTool); | |
| // Reset stance when tool changes | |
| if (newTool && normalizeToolName(newTool) !== 'generate argument') { | |
| setSelectedStance(null); | |
| } | |
| setShowToolsDropdown(false); | |
| setFocusedIndex(-1); | |
| }} | |
| onMouseEnter={() => setFocusedIndex(index)} | |
| > | |
| <div className="flex items-start justify-between gap-3"> | |
| <div className="flex-1 min-w-0"> | |
| <div className="flex items-center gap-2 mb-1"> | |
| <h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100 truncate"> | |
| {tool.name} | |
| </h4> | |
| {selectedTool === tool.name && ( | |
| <Check className="h-3.5 w-3.5 text-teal-500 dark:text-teal-400 flex-shrink-0" /> | |
| )} | |
| </div> | |
| {tool.description && ( | |
| <p className="text-xs text-zinc-600 dark:text-zinc-400 line-clamp-2 leading-relaxed"> | |
| {tool.description} | |
| </p> | |
| )} | |
| </div> | |
| </div> | |
| </button> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| {/* Footer */} | |
| <div className="px-4 py-2 border-t border-zinc-200/50 dark:border-zinc-700/50 bg-zinc-50/50 dark:bg-zinc-800/30 flex-shrink-0"> | |
| <div className="flex items-center justify-between"> | |
| <p className="text-xs text-zinc-500 dark:text-zinc-400"> | |
| Use ↑↓ to navigate, Enter to select | |
| </p> | |
| <button | |
| type="button" | |
| onClick={() => { | |
| setSelectedTool(null); | |
| setSelectedStance(null); | |
| setShowToolsDropdown(false); | |
| setFocusedIndex(-1); | |
| }} | |
| className="text-xs text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200 transition-colors duration-200" | |
| > | |
| Clear selection | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| <button | |
| type="button" | |
| onClick={handleMicClick} | |
| className="h-8 w-8 p-0 text-zinc-800 dark:text-white hover:text-zinc-900 dark:hover:text-white hover:bg-zinc-200 dark:hover:bg-zinc-700 rounded-lg transition-all duration-200 hover:scale-110 active:scale-95 active:bg-red-600/20 active:text-red-400 flex items-center justify-center" | |
| > | |
| <Mic className="h-5 w-5 transition-transform duration-200" /> | |
| </button> | |
| <button | |
| type="button" | |
| className="h-8 px-3 rounded-lg text-sm font-medium hover:opacity-90 transition-all duration-200 hover:scale-105 flex items-center justify-center bg-teal-900 dark:bg-[#032827] text-teal-300 dark:text-[#2DD4BF] disabled:opacity-50 disabled:cursor-not-allowed" | |
| > | |
| LLama 4 | |
| </button> | |
| </div> | |
| <button | |
| type="submit" | |
| disabled={ | |
| selectedTool && normalizeToolName(selectedTool) === 'detect stance' | |
| ? !detectStanceTopic.trim() || !detectStanceArgument.trim() | |
| : !input.trim() | |
| } | |
| className="h-8 w-8 p-0 bg-zinc-300 dark:bg-zinc-700 hover:bg-zinc-400 dark:hover:bg-zinc-600 disabled:bg-zinc-200 dark:disabled:bg-zinc-800 disabled:text-zinc-400 dark:disabled:text-zinc-500 text-zinc-800 dark:text-white rounded-lg transition-all duration-200 hover:scale-110 disabled:hover:scale-100 flex items-center justify-center disabled:cursor-not-allowed" | |
| > | |
| <ArrowUp className="h-5 w-5" /> | |
| </button> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </form> | |
| </div> | |
| ); | |
| }; | |
| export default ChatInput; | |