Composer.tsx
Language TypeScript6.4 KB193 lines
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'import type { Estimate } from '../ipc/gen/Estimate'import { IMAGE_EXT, isVision, tokens, usd } from '../lib/format'import { useCurrentModel, useStore } from '../state/store'import { AttachmentChip } from './Attachment'export interface ComposerHandle {setText(t: string): voidfocus(): void}/** "~$0.0012", but "<$0.0001" rather than "~<$0.0001". */const approx = (v: string) => (v.startsWith('<') ? v : `~${v}`)function isImage(name: string, type: string) {return type.startsWith('image/') || IMAGE_EXT.test(name)}export const Composer = forwardRef<ComposerHandle>(function Composer(_, ref) {const [text, setText] = useState('')const [estimate, setEstimate] = useState<Estimate | null>(null)const [dragging, setDragging] = useState(false)const area = useRef<HTMLTextAreaElement>(null)const send = useStore((s) => s.send)const stop = useStore((s) => s.stop)const streaming = useStore((s) => s.streaming)const drafts = useStore((s) => s.drafts)const addDrafts = useStore((s) => s.addDrafts)const removeDraft = useStore((s) => s.removeDraft)const client = useStore((s) => s.client)const settings = useStore((s) => s.settings)const view = useStore((s) => s.view)const toast = useStore((s) => s.toast)const current = useCurrentModel()const isLocal = current.info?.tags.includes('local') ?? falseconst vision =!!current.info && (isVision(current.info) || (isLocal && !!settings?.vision_models.includes(current.modelId)))useImperativeHandle(ref, () => ({setText: (t) => {setText(t)area.current?.focus()},focus: () => area.current?.focus(),}))// Grow with the text, up to a limit.useEffect(() => {const el = area.currentif (!el) returnel.style.height = 'auto'el.style.height = `${Math.min(el.scrollHeight, 240)}px`}, [text])// What this prompt would cost to send (debounced).useEffect(() => {if (!client || !current.modelId) returnconst t = setTimeout(() => {client.estimatePrompt({conversationId: view?.conversation.id ?? null,providerId: current.providerId,modelId: current.modelId,text,imageCount: drafts.filter((d) => d.kind === 'image').length,}).then(setEstimate).catch(() => setEstimate(null))}, 250)return () => clearTimeout(t)}, [client, text, drafts, current.providerId, current.modelId, view?.conversation.id, view?.messages.length])const submit = async () => {if (streaming) returnconst t = textsetText('')const ok = await send(t)if (!ok) setText(t)}const ingest = async (files: File[]) => {if (!client) returnfor (const f of files) {if (isImage(f.name, f.type) && !vision) {toast(`${current.modelId || 'This model'} cannot read images. Pick a vision model first.`, 'error')continue}try {const bytes = new Uint8Array(await f.arrayBuffer())const name = f.name || (f.type === 'image/png' ? 'pasted.png' : 'pasted.txt')addDrafts([await client.attachBytes(name, bytes)])} catch (err) {toast(String(err), 'error')}}}const over = estimate && estimate.context_length && estimate.prompt_tokens > estimate.context_lengthreturn (<divclassName={`composer glass ${dragging ? 'dragging' : ''}`}onDragOver={(e) => {e.preventDefault()setDragging(true)}}onDragLeave={() => setDragging(false)}onDrop={(e) => {e.preventDefault()setDragging(false)void ingest([...e.dataTransfer.files])}}>{drafts.length > 0 && (<div className="chips">{drafts.map((d) => (<AttachmentChip key={d.id} a={d} onRemove={() => void removeDraft(d.id)} />))}</div>)}<textarearef={area}data-testid="composer-input"aria-label="Message"placeholder={vision ? 'Ask anything, or drop an image or file' : 'Ask anything, or drop a text file'}rows={1}value={text}onChange={(e) => setText(e.target.value)}onPaste={(e) => {const files = [...e.clipboardData.files]if (files.length) {e.preventDefault()void ingest(files)}}}onKeyDown={(e) => {const sendKey = settings?.send_on_enter ?? trueif (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing && (sendKey || e.metaKey || e.ctrlKey)) {e.preventDefault()void submit()} else if (e.key === 'Escape' && streaming) {e.preventDefault()void stop()}}}/><div className="composer-bar"><buttonclassName="ghost icon"aria-label="Attach files"title={vision ? 'Attach images or text files' : 'Attach text files'}onClick={async () => {if (!client) returntry {addDrafts(await client.pickAttachments(vision))} catch (err) {toast(String(err), 'error')}}}><svg viewBox="0 0 20 20" width="16" height="16" aria-hidden><pathd="M13.5 6.5l-6 6a1.8 1.8 0 002.5 2.5l6.5-6.5a3.2 3.2 0 00-4.5-4.5L5.5 10.5a4.6 4.6 0 006.5 6.5l5-5"fill="none"stroke="currentColor"strokeWidth="1.6"strokeLinecap="round"/></svg></button><span className={`estimate ${over ? 'over' : ''}`} data-testid="estimate">{estimate && estimate.prompt_tokens > 4? `~${tokens(estimate.prompt_tokens)} tokens in${current.info && current.info.prices.input_per_m > 0 ? `, ${approx(usd(estimate.prompt_cost_nanousd))}` : ''}${over ? ', over the context window' : ''}`: ''}</span>{streaming ? (<button className="stop" onClick={() => void stop()} data-testid="stop">Stop</button>) : (<buttonclassName="primary send"data-testid="send"disabled={!text.trim() && !drafts.length}onClick={() => void submit()}>Send</button>)}</div></div>)})