spb/zyquo-cloud-web Public MIT
Zyquo Cloud Web — every cloud model, one beautiful chat, entirely in your browser.
TypeScript 81.9%
CSS 8.9%
JavaScript 7.5%
Shell 1.1%
HTML 0.6%
1/*2 * InputBar.tsx3 * Zyquo Cloud Web4 *5 * Author: Simon-Pierre Boucher6 * Mail: contact@spboucher.ai7 *8 * Floating input card: multiline auto-grow editor, image attach/paste for9 * vision models, text-file attach, drag-and-drop, params popover trigger,10 * circular accent send (⌘/Ctrl+Enter) that becomes Stop while streaming,11 * token/cost HUD with context-window usage bar.12 */1314import { useEffect, useMemo, useRef, useState } from 'react'15import { estimateTokens, findModel } from '../features/catalogHelpers'16import { useStore } from '../state/store'17import type { Attachment, Conversation } from '../types'18import { uid } from '../state/store'19import SlashMenu from './SlashMenu'20import { IconMic, IconPaperclip, IconSend, IconSliders, IconStop, IconX } from './icons'2122interface SpeechRecognitionLike {23 lang: string24 interimResults: boolean25 continuous: boolean26 onresult: ((event: { results: ArrayLike<ArrayLike<{ transcript: string }>> }) => void) | null27 onend: (() => void) | null28 onerror: (() => void) | null29 start: () => void30 stop: () => void31}3233function speechRecognition(): SpeechRecognitionLike | null {34 const w = window as unknown as Record<string, unknown>35 const Ctor = (w['SpeechRecognition'] ?? w['webkitSpeechRecognition']) as36 | (new () => SpeechRecognitionLike)37 | undefined38 return Ctor ? new Ctor() : null39}4041const IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/gif']42const MAX_TEXT_FILE = 512 * 10244344export default function InputBar({45 conversation,46 draft,47 setDraft,48 onOpenParams,49}: {50 conversation: Conversation51 draft: string52 setDraft: (text: string) => void53 onOpenParams: () => void54}) {55 const send = useStore((s) => s.send)56 const stop = useStore((s) => s.stop)57 const streaming = useStore((s) => s.streaming[conversation.id])58 const toast = useStore((s) => s.toast)59 const [attachments, setAttachments] = useState<Attachment[]>([])60 const [dragging, setDragging] = useState(false)61 const [dictating, setDictating] = useState(false)62 const editorRef = useRef<HTMLTextAreaElement>(null)63 const fileRef = useRef<HTMLInputElement>(null)64 const recognitionRef = useRef<SpeechRecognitionLike | null>(null)6566 // Slash command: draft starts with "/" → menu with query (word after slash)67 // and input (rest of the draft after the first whitespace).68 const slashMatch = draft.startsWith('/') ? /^\/(\S*)\s?([\s\S]*)$/.exec(draft) : null6970 const toggleDictation = () => {71 if (dictating) {72 recognitionRef.current?.stop()73 return74 }75 const recognition = speechRecognition()76 if (!recognition) {77 toast('Speech recognition not available in this browser', 'error')78 return79 }80 recognition.lang = navigator.language || 'en-US'81 recognition.interimResults = false82 recognition.continuous = true83 recognition.onresult = (event) => {84 const last = event.results[event.results.length - 1]85 const transcript = last?.[0]?.transcript ?? ''86 if (transcript !== '') setDraft(`${draft}${draft === '' ? '' : ' '}${transcript}`)87 }88 recognition.onend = () => setDictating(false)89 recognition.onerror = () => setDictating(false)90 recognitionRef.current = recognition91 setDictating(true)92 recognition.start()93 }9495 const model = findModel(conversation.provider, conversation.modelID)96 const vision = model?.capabilities.vision ?? false9798 useEffect(() => {99 const editor = editorRef.current100 if (!editor) return101 editor.style.height = 'auto'102 editor.style.height = `${Math.min(editor.scrollHeight, 200)}px`103 }, [draft])104105 const contextUsage = useMemo(() => {106 if (!model) return 0107 let chars = (conversation.systemPrompt ?? '').length + draft.length108 for (const message of conversation.messages) chars += message.text.length109 return Math.min(1, estimateTokens('x'.repeat(chars)) / model.contextWindow)110 }, [conversation, draft, model])111112 const addFiles = async (files: FileList | File[]) => {113 for (const file of files) {114 if (IMAGE_TYPES.includes(file.type)) {115 if (!vision) {116 toast(`${model?.displayName ?? 'This model'} doesn't support images`, 'error')117 continue118 }119 const data = await fileToBase64(file)120 setAttachments((prev) => [121 ...prev,122 { id: uid(), kind: 'image', fileName: file.name, mimeType: file.type, data },123 ])124 } else if (file.size <= MAX_TEXT_FILE) {125 const text = await file.text()126 setAttachments((prev) => [127 ...prev,128 {129 id: uid(),130 kind: 'textFile',131 fileName: file.name,132 mimeType: file.type || 'text/plain',133 data: text,134 },135 ])136 } else {137 toast(`${file.name} is too large (max 512 KB for text files)`, 'error')138 }139 }140 }141142 const doSend = () => {143 const text = draft.trim()144 if (text === '' && attachments.length === 0) return145 setDraft('')146 const toSend = attachments147 setAttachments([])148 void send(conversation.id, text, toSend)149 }150151 const onKeyDown = (e: React.KeyboardEvent) => {152 if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {153 e.preventDefault()154 doSend()155 }156 }157158 const onPaste = (e: React.ClipboardEvent) => {159 const files = [...e.clipboardData.files]160 if (files.length > 0) {161 e.preventDefault()162 void addFiles(files)163 }164 }165166 const totalTokens = useMemo(() => {167 let tokens = 0168 let cost = 0169 for (const message of conversation.messages) {170 tokens += (message.usage?.inputTokens ?? 0) + (message.usage?.outputTokens ?? 0)171 cost += message.estimatedCost ?? 0172 }173 return { tokens, cost }174 }, [conversation])175176 return (177 <div className="input-dock">178 <div179 className={`input-bar${dragging ? ' drag-target' : ''}`}180 onDragOver={(e) => {181 e.preventDefault()182 setDragging(true)183 }}184 onDragLeave={() => setDragging(false)}185 onDrop={(e) => {186 e.preventDefault()187 setDragging(false)188 void addFiles(e.dataTransfer.files)189 }}190 >191 {attachments.length > 0 && (192 <div className="attachment-strip">193 {attachments.map((attachment) => (194 <div key={attachment.id} className="attachment-thumb">195 {attachment.kind === 'image' ? (196 <img197 src={`data:${attachment.mimeType};base64,${attachment.data}`}198 alt={attachment.fileName}199 />200 ) : (201 <span>{attachment.fileName}</span>202 )}203 <button204 className="attachment-remove"205 onClick={() =>206 setAttachments((prev) => prev.filter((a) => a.id !== attachment.id))207 }208 >209 <IconX size={8} />210 </button>211 </div>212 ))}213 </div>214 )}215 <div className="input-row" style={{ position: 'relative' }}>216 {slashMatch && (217 <SlashMenu218 query={slashMatch[1] ?? ''}219 input={slashMatch[2] ?? ''}220 conversationID={conversation.id}221 onApply={(text) => {222 setDraft(text)223 editorRef.current?.focus()224 }}225 onClose={() => setDraft(draft.replace(/^\//, ''))}226 />227 )}228 <button229 className="icon-button"230 title={vision ? 'Attach image or file' : 'Attach text file'}231 onClick={() => fileRef.current?.click()}232 >233 <IconPaperclip />234 </button>235 <input236 ref={fileRef}237 type="file"238 multiple239 hidden240 onChange={(e) => {241 if (e.target.files) void addFiles(e.target.files)242 e.target.value = ''243 }}244 />245 <textarea246 ref={editorRef}247 className="input-editor"248 placeholder="Message…"249 rows={1}250 value={draft}251 data-input-editor252 onChange={(e) => setDraft(e.target.value)}253 onKeyDown={onKeyDown}254 onPaste={onPaste}255 />256 <button257 className="icon-button"258 title={dictating ? 'Stop dictation' : 'Dictate'}259 style={dictating ? { color: 'var(--z-danger)' } : {}}260 onClick={toggleDictation}261 >262 <IconMic />263 </button>264 <button className="icon-button" title="Parameters" onClick={onOpenParams}>265 <IconSliders />266 </button>267 {streaming ? (268 <button269 className="send-button stop"270 title="Stop (⌘.)"271 onClick={() => stop(conversation.id)}272 >273 <IconStop />274 </button>275 ) : (276 <button277 className="send-button"278 title="Send (⌘↩)"279 disabled={draft.trim() === '' && attachments.length === 0}280 onClick={doSend}281 >282 <IconSend />283 </button>284 )}285 </div>286 <div className="input-hud">287 {model && (288 <>289 <div className="context-bar" title="Context window usage">290 <div291 className={`context-bar-fill${contextUsage > 0.8 ? ' warn' : ''}`}292 style={{ width: `${Math.max(2, contextUsage * 100)}%` }}293 />294 </div>295 <span>{Math.round(contextUsage * 100)}% ctx</span>296 </>297 )}298 {totalTokens.tokens > 0 && (299 <span>300 {totalTokens.tokens.toLocaleString()} tok301 {totalTokens.cost > 0 ? ` · ~$${totalTokens.cost.toFixed(4)}` : ''}302 </span>303 )}304 </div>305 </div>306 </div>307 )308}309310function fileToBase64(file: File): Promise<string> {311 return new Promise((resolve, reject) => {312 const reader = new FileReader()313 reader.onload = () => {314 const result = reader.result as string315 resolve(result.slice(result.indexOf(',') + 1))316 }317 reader.onerror = () => reject(reader.error ?? new Error('read failed'))318 reader.readAsDataURL(file)319 })320}321