/* * InputBar.tsx * Zyquo Cloud Web * * Author: Simon-Pierre Boucher * Mail: contact@spboucher.ai * * Floating input card: multiline auto-grow editor, image attach/paste for * vision models, text-file attach, drag-and-drop, params popover trigger, * circular accent send (⌘/Ctrl+Enter) that becomes Stop while streaming, * token/cost HUD with context-window usage bar. */ import { useEffect, useMemo, useRef, useState } from 'react' import { estimateTokens, findModel } from '../features/catalogHelpers' import { useStore } from '../state/store' import type { Attachment, Conversation } from '../types' import { uid } from '../state/store' import SlashMenu from './SlashMenu' import { IconMic, IconPaperclip, IconSend, IconSliders, IconStop, IconX } from './icons' interface SpeechRecognitionLike { lang: string interimResults: boolean continuous: boolean onresult: ((event: { results: ArrayLike> }) => void) | null onend: (() => void) | null onerror: (() => void) | null start: () => void stop: () => void } function speechRecognition(): SpeechRecognitionLike | null { const w = window as unknown as Record const Ctor = (w['SpeechRecognition'] ?? w['webkitSpeechRecognition']) as | (new () => SpeechRecognitionLike) | undefined return Ctor ? new Ctor() : null } const IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif'] const MAX_TEXT_FILE = 512 * 1024 export default function InputBar({ conversation, draft, setDraft, onOpenParams, }: { conversation: Conversation draft: string setDraft: (text: string) => void onOpenParams: () => void }) { const send = useStore((s) => s.send) const stop = useStore((s) => s.stop) const streaming = useStore((s) => s.streaming[conversation.id]) const toast = useStore((s) => s.toast) const [attachments, setAttachments] = useState([]) const [dragging, setDragging] = useState(false) const [dictating, setDictating] = useState(false) const editorRef = useRef(null) const fileRef = useRef(null) const recognitionRef = useRef(null) // Slash command: draft starts with "/" → menu with query (word after slash) // and input (rest of the draft after the first whitespace). const slashMatch = draft.startsWith('/') ? /^\/(\S*)\s?([\s\S]*)$/.exec(draft) : null const toggleDictation = () => { if (dictating) { recognitionRef.current?.stop() return } const recognition = speechRecognition() if (!recognition) { toast('Speech recognition not available in this browser', 'error') return } recognition.lang = navigator.language || 'en-US' recognition.interimResults = false recognition.continuous = true recognition.onresult = (event) => { const last = event.results[event.results.length - 1] const transcript = last?.[0]?.transcript ?? '' if (transcript !== '') setDraft(`${draft}${draft === '' ? '' : ' '}${transcript}`) } recognition.onend = () => setDictating(false) recognition.onerror = () => setDictating(false) recognitionRef.current = recognition setDictating(true) recognition.start() } const model = findModel(conversation.provider, conversation.modelID) const vision = model?.capabilities.vision ?? false useEffect(() => { const editor = editorRef.current if (!editor) return editor.style.height = 'auto' editor.style.height = `${Math.min(editor.scrollHeight, 200)}px` }, [draft]) const contextUsage = useMemo(() => { if (!model) return 0 let chars = (conversation.systemPrompt ?? '').length + draft.length for (const message of conversation.messages) chars += message.text.length return Math.min(1, estimateTokens('x'.repeat(chars)) / model.contextWindow) }, [conversation, draft, model]) const addFiles = async (files: FileList | File[]) => { for (const file of files) { if (IMAGE_TYPES.includes(file.type)) { if (!vision) { toast(`${model?.displayName ?? 'This model'} doesn't support images`, 'error') continue } const data = await fileToBase64(file) setAttachments((prev) => [ ...prev, { id: uid(), kind: 'image', fileName: file.name, mimeType: file.type, data }, ]) } else if (file.size <= MAX_TEXT_FILE) { const text = await file.text() setAttachments((prev) => [ ...prev, { id: uid(), kind: 'textFile', fileName: file.name, mimeType: file.type || 'text/plain', data: text, }, ]) } else { toast(`${file.name} is too large (max 512 KB for text files)`, 'error') } } } const doSend = () => { const text = draft.trim() if (text === '' && attachments.length === 0) return setDraft('') const toSend = attachments setAttachments([]) void send(conversation.id, text, toSend) } const onKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault() doSend() } } const onPaste = (e: React.ClipboardEvent) => { const files = [...e.clipboardData.files] if (files.length > 0) { e.preventDefault() void addFiles(files) } } const totalTokens = useMemo(() => { let tokens = 0 let cost = 0 for (const message of conversation.messages) { tokens += (message.usage?.inputTokens ?? 0) + (message.usage?.outputTokens ?? 0) cost += message.estimatedCost ?? 0 } return { tokens, cost } }, [conversation]) return (
{ e.preventDefault() setDragging(true) }} onDragLeave={() => setDragging(false)} onDrop={(e) => { e.preventDefault() setDragging(false) void addFiles(e.dataTransfer.files) }} > {attachments.length > 0 && (
{attachments.map((attachment) => (
{attachment.kind === 'image' ? ( {attachment.fileName} ) : ( {attachment.fileName} )}
))}
)}
{slashMatch && ( { setDraft(text) editorRef.current?.focus() }} onClose={() => setDraft(draft.replace(/^\//, ''))} /> )} { if (e.target.files) void addFiles(e.target.files) e.target.value = '' }} />