SPB Git

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%
10.2 KB · 321 lines tsx
Raw Blame History
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