mattrguerra/cortexmainPrivate on GitHub, published here in full

Composer.tsx

Language TypeScript6.4 KB193 lines

src/components/Composer.tsx

  1. import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'
  2. import type { Estimate } from '../ipc/gen/Estimate'
  3. import { IMAGE_EXT, isVision, tokens, usd } from '../lib/format'
  4. import { useCurrentModel, useStore } from '../state/store'
  5. import { AttachmentChip } from './Attachment'
  6. export interface ComposerHandle {
  7. setText(t: string): void
  8. focus(): void
  9. }
  10. /** "~$0.0012", but "<$0.0001" rather than "~<$0.0001". */
  11. const approx = (v: string) => (v.startsWith('<') ? v : `~${v}`)
  12. function isImage(name: string, type: string) {
  13. return type.startsWith('image/') || IMAGE_EXT.test(name)
  14. }
  15. export const Composer = forwardRef<ComposerHandle>(function Composer(_, ref) {
  16. const [text, setText] = useState('')
  17. const [estimate, setEstimate] = useState<Estimate | null>(null)
  18. const [dragging, setDragging] = useState(false)
  19. const area = useRef<HTMLTextAreaElement>(null)
  20. const send = useStore((s) => s.send)
  21. const stop = useStore((s) => s.stop)
  22. const streaming = useStore((s) => s.streaming)
  23. const drafts = useStore((s) => s.drafts)
  24. const addDrafts = useStore((s) => s.addDrafts)
  25. const removeDraft = useStore((s) => s.removeDraft)
  26. const client = useStore((s) => s.client)
  27. const settings = useStore((s) => s.settings)
  28. const view = useStore((s) => s.view)
  29. const toast = useStore((s) => s.toast)
  30. const current = useCurrentModel()
  31. const isLocal = current.info?.tags.includes('local') ?? false
  32. const vision =
  33. !!current.info && (isVision(current.info) || (isLocal && !!settings?.vision_models.includes(current.modelId)))
  34. useImperativeHandle(ref, () => ({
  35. setText: (t) => {
  36. setText(t)
  37. area.current?.focus()
  38. },
  39. focus: () => area.current?.focus(),
  40. }))
  41. // Grow with the text, up to a limit.
  42. useEffect(() => {
  43. const el = area.current
  44. if (!el) return
  45. el.style.height = 'auto'
  46. el.style.height = `${Math.min(el.scrollHeight, 240)}px`
  47. }, [text])
  48. // What this prompt would cost to send (debounced).
  49. useEffect(() => {
  50. if (!client || !current.modelId) return
  51. const t = setTimeout(() => {
  52. client
  53. .estimatePrompt({
  54. conversationId: view?.conversation.id ?? null,
  55. providerId: current.providerId,
  56. modelId: current.modelId,
  57. text,
  58. imageCount: drafts.filter((d) => d.kind === 'image').length,
  59. })
  60. .then(setEstimate)
  61. .catch(() => setEstimate(null))
  62. }, 250)
  63. return () => clearTimeout(t)
  64. }, [client, text, drafts, current.providerId, current.modelId, view?.conversation.id, view?.messages.length])
  65. const submit = async () => {
  66. if (streaming) return
  67. const t = text
  68. setText('')
  69. const ok = await send(t)
  70. if (!ok) setText(t)
  71. }
  72. const ingest = async (files: File[]) => {
  73. if (!client) return
  74. for (const f of files) {
  75. if (isImage(f.name, f.type) && !vision) {
  76. toast(`${current.modelId || 'This model'} cannot read images. Pick a vision model first.`, 'error')
  77. continue
  78. }
  79. try {
  80. const bytes = new Uint8Array(await f.arrayBuffer())
  81. const name = f.name || (f.type === 'image/png' ? 'pasted.png' : 'pasted.txt')
  82. addDrafts([await client.attachBytes(name, bytes)])
  83. } catch (err) {
  84. toast(String(err), 'error')
  85. }
  86. }
  87. }
  88. const over = estimate && estimate.context_length && estimate.prompt_tokens > estimate.context_length
  89. return (
  90. <div
  91. className={`composer glass ${dragging ? 'dragging' : ''}`}
  92. onDragOver={(e) => {
  93. e.preventDefault()
  94. setDragging(true)
  95. }}
  96. onDragLeave={() => setDragging(false)}
  97. onDrop={(e) => {
  98. e.preventDefault()
  99. setDragging(false)
  100. void ingest([...e.dataTransfer.files])
  101. }}
  102. >
  103. {drafts.length > 0 && (
  104. <div className="chips">
  105. {drafts.map((d) => (
  106. <AttachmentChip key={d.id} a={d} onRemove={() => void removeDraft(d.id)} />
  107. ))}
  108. </div>
  109. )}
  110. <textarea
  111. ref={area}
  112. data-testid="composer-input"
  113. aria-label="Message"
  114. placeholder={vision ? 'Ask anything, or drop an image or file' : 'Ask anything, or drop a text file'}
  115. rows={1}
  116. value={text}
  117. onChange={(e) => setText(e.target.value)}
  118. onPaste={(e) => {
  119. const files = [...e.clipboardData.files]
  120. if (files.length) {
  121. e.preventDefault()
  122. void ingest(files)
  123. }
  124. }}
  125. onKeyDown={(e) => {
  126. const sendKey = settings?.send_on_enter ?? true
  127. if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing && (sendKey || e.metaKey || e.ctrlKey)) {
  128. e.preventDefault()
  129. void submit()
  130. } else if (e.key === 'Escape' && streaming) {
  131. e.preventDefault()
  132. void stop()
  133. }
  134. }}
  135. />
  136. <div className="composer-bar">
  137. <button
  138. className="ghost icon"
  139. aria-label="Attach files"
  140. title={vision ? 'Attach images or text files' : 'Attach text files'}
  141. onClick={async () => {
  142. if (!client) return
  143. try {
  144. addDrafts(await client.pickAttachments(vision))
  145. } catch (err) {
  146. toast(String(err), 'error')
  147. }
  148. }}
  149. >
  150. <svg viewBox="0 0 20 20" width="16" height="16" aria-hidden>
  151. <path
  152. d="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"
  153. fill="none"
  154. stroke="currentColor"
  155. strokeWidth="1.6"
  156. strokeLinecap="round"
  157. />
  158. </svg>
  159. </button>
  160. <span className={`estimate ${over ? 'over' : ''}`} data-testid="estimate">
  161. {estimate && estimate.prompt_tokens > 4
  162. ? `~${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' : ''}`
  163. : ''}
  164. </span>
  165. {streaming ? (
  166. <button className="stop" onClick={() => void stop()} data-testid="stop">
  167. Stop
  168. </button>
  169. ) : (
  170. <button
  171. className="primary send"
  172. data-testid="send"
  173. disabled={!text.trim() && !drafts.length}
  174. onClick={() => void submit()}
  175. >
  176. Send
  177. </button>
  178. )}
  179. </div>
  180. </div>
  181. )
  182. })