Prompt Input

Un compositor de chat que empieza como una línea tranquila, crece hasta convertirse en una tarjeta mientras escribes y baja cuando empieza la conversación. Enter envía, de forma segura con entrada en japonés y chino. Pega, suelta o elige archivos con vistas previas, progreso y reintento. @ añade archivos y / ejecuta comandos desde un menú junto al cursor. Un selector de modelo con teclas numéricas, un control deslizante de esfuerzo que cobra vida en Max, un anillo de contexto, dictado con forma de onda en vivo, chips de herramientas, una cola para los mensajes escritos mientras se transmite una respuesta y borradores que sobreviven a una recarga.

El compositor es la parte del chat que más toca la gente, así que cada pulsación tiene que sentirse bien. Prompt Input empieza como una línea tranquila con el + a la izquierda y enviar a la derecha, crece hasta convertirse en una tarjeta cuando el texto se ajusta o se añaden archivos, y vuelve a su tamaño al vaciarse.

Enter envía y Shift+Enter añade una línea, y los métodos de entrada de japonés, chino y coreano nunca se interrumpen. En pantallas táctiles Enter añade una línea, ya que el teclado no tiene Shift. Las inserciones pasan por la pila de deshacer propia del navegador, así que ⌘Z siempre funciona, y los pegados largos se convierten en un adjunto que puedes previsualizar o devolver al texto.

Todo se compone a partir de partes que comparten un contexto. Usa solo el campo y el botón de enviar, o añade subida de archivos con progreso y reintento, menús @ y / junto al cursor, un selector de modelo, un control deslizante de esfuerzo, un anillo de contexto, dictado, chips de herramientas, una cola para los mensajes enviados mientras se transmite una respuesta y borradores que sobreviven a una recarga.

Prompt Input es gratis. Instálalo sin cuenta ni token, y úsalo bajo la Licencia Pro en tantos proyectos como quieras.

pnpm dlx shadcn@latest add https://hextaui.com/r/pro/prompt-input.json

Con AI SDK

Pasa el status y el stop de useChat. Los archivos llegan como FileUIPart con una URL de datos, y un mensaje escrito mientras se transmite una respuesta se pone en cola y se envía cuando termina.

"use client"

import { useChat } from "@ai-sdk/react"
import type { FileUIPart } from "ai"

import { PromptInputAddMenu } from "@/components/blocks/prompt-input/prompt-controls"
import {
  PromptInput,
  PromptInputAttachments,
  PromptInputBody,
  PromptInputActions,
  PromptInputQueue,
  PromptInputSubmit,
  PromptInputTextarea,
  PromptInputTools,
  type PromptMessage,
} from "@/components/blocks/prompt-input/prompt-input"

function toDataUrl(file: File) {
  return new Promise<string>((resolve, reject) => {
    const reader = new FileReader()
    reader.onload = () => resolve(String(reader.result))
    reader.onerror = () => reject(reader.error)
    reader.readAsDataURL(file)
  })
}

export function Composer({ chatId }: { chatId: string }) {
  const { sendMessage, status, stop } = useChat({ id: chatId })

  const send = async ({ text, files }: PromptMessage) => {
    const parts: FileUIPart[] = await Promise.all(
      files.map(async ({ file }) => ({
        type: "file" as const,
        mediaType: file.type,
        filename: file.name,
        url: await toDataUrl(file),
      }))
    )
    await sendMessage({ text, files: parts })
  }

  return (
    <PromptInput
      status={status}
      onStop={stop}
      onSubmit={send}
      accept="image/*,.pdf"
      maxFileSize={20 * 1024 * 1024}
      draftKey={chatId}
    >
      <PromptInputQueue />
      <PromptInputBody>
        <PromptInputAttachments />
        <PromptInputTools>
          <PromptInputAddMenu />
        </PromptInputTools>
        <PromptInputTextarea />
        <PromptInputActions>
          <PromptInputSubmit />
        </PromptInputActions>
      </PromptInputBody>
    </PromptInput>
  )
}

Modelo, esfuerzo y dictado

Coloca el selector de modelo, el control deslizante de esfuerzo y el anillo de contexto bajo el compositor con PromptInputMeta. Activa herramientas desde el menú + y muéstralas como chips. Pasa transcribe para enviar las grabaciones a tu propio modelo de voz, y autoSend para enviar en cuanto llegue el texto.

"use client"

import * as React from "react"
import { IconWorld } from "@tabler/icons-react"

import { DropdownMenuCheckboxItem } from "@/components/ui/dropdown-menu"

import {
  PromptInputAddMenu,
  PromptInputContextUsage,
  PromptInputDictation,
  PromptInputEffort,
  PromptInputMeta,
  PromptInputModelSelect,
  PromptInputTool,
} from "@/components/blocks/prompt-input/prompt-controls"
import {
  PromptInput,
  PromptInputActions,
  PromptInputAttachments,
  PromptInputBody,
  PromptInputSubmit,
  PromptInputTextarea,
  PromptInputTools,
  type PromptMessage,
} from "@/components/blocks/prompt-input/prompt-input"

const models = [
  { value: "large", label: "Large" },
  { value: "small", label: "Small" },
]

const efforts = [
  { value: "low", label: "Low" },
  { value: "medium", label: "Medium" },
  { value: "high", label: "High" },
  { value: "max", label: "Max" },
]

export function Composer({
  onSend,
  contextUsed,
  transcribe,
}: {
  onSend: (message: PromptMessage) => void
  contextUsed: number
  transcribe: (audio: Blob) => Promise<string>
}) {
  const [model, setModel] = React.useState("large")
  const [effort, setEffort] = React.useState("medium")
  const [thinking, setThinking] = React.useState(true)
  const [search, setSearch] = React.useState(false)

  return (
    <PromptInput onSubmit={onSend} focusOnType>
      <PromptInputBody>
        <PromptInputAttachments />
        <PromptInputTools>
          <PromptInputAddMenu>
            <DropdownMenuCheckboxItem
              indicator="end"
              checked={search}
              onCheckedChange={setSearch}
            >
              <IconWorld />
              Web search
            </DropdownMenuCheckboxItem>
          </PromptInputAddMenu>
          {search ? (
            <PromptInputTool
              icon={<IconWorld />}
              tone="blue"
              onRemove={() => setSearch(false)}
            >
              Search
            </PromptInputTool>
          ) : null}
        </PromptInputTools>
        <PromptInputTextarea autoFocus />
        <PromptInputActions>
          <PromptInputDictation transcribe={transcribe} autoSend />
          <PromptInputSubmit />
        </PromptInputActions>
      </PromptInputBody>
      <PromptInputMeta>
        <PromptInputModelSelect
          models={models}
          value={model}
          onValueChange={setModel}
        />
        <PromptInputEffort
          levels={efforts}
          value={effort}
          onValueChange={setEffort}
          recommended="medium"
          thinking={thinking}
          onThinkingChange={setThinking}
        />
        <PromptInputContextUsage used={contextUsed} limit={200000} />
      </PromptInputMeta>
    </PromptInput>
  )
}

Menciones y comandos

Añade un menú por disparador. @ convierte una selección en un chip sobre el texto, y / inserta el comando. Las teclas de flecha se mueven, Enter o Tab eligen y Escape cierra, todo sin salir del texto.

"use client"

import { IconBrandTypescript, IconTrash } from "@tabler/icons-react"

import {
  PromptInput,
  PromptInputAttachments,
  PromptInputBody,
  PromptInputActions,
  PromptInputSubmit,
  PromptInputTextarea,
  type PromptMessage,
} from "@/components/blocks/prompt-input/prompt-input"
import { PromptInputMenu, type PromptMenuItem } from "@/components/blocks/prompt-input/prompt-menu"

const files: PromptMenuItem[] = [
  {
    value: "app/page.tsx",
    label: "page.tsx",
    description: "app",
    icon: <IconBrandTypescript />,
  },
  {
    value: "lib/utils.ts",
    label: "utils.ts",
    description: "lib",
    icon: <IconBrandTypescript />,
  },
]

const commands: PromptMenuItem[] = [
  { value: "explain", label: "explain", description: "Explain code step by step" },
  { value: "test", label: "test", description: "Write tests for a file" },
  {
    value: "clear",
    label: "clear",
    description: "Start a new conversation",
    icon: <IconTrash />,
    insert: "",
  },
]

export function Composer({
  onSend,
  onClear,
}: {
  onSend: (message: PromptMessage) => void
  onClear: () => void
}) {
  return (
    <PromptInput onSubmit={onSend}>
      <PromptInputBody>
        <PromptInputAttachments />
        <PromptInputTextarea placeholder="Ask anything, @ to add files, / for commands" />
        <PromptInputActions>
          <PromptInputSubmit />
        </PromptInputActions>
      </PromptInputBody>
      <PromptInputMenu trigger="@" items={files} />
      <PromptInputMenu
        trigger="/"
        items={commands}
        onSelect={(item) => {
          if (item.value === "clear") onClear()
        }}
      />
    </PromptInput>
  )
}

Subir archivos

Dale a upload una función que informe del progreso y respete la señal de aborto. Lanza UploadError con retryable: false para los errores que un reintento no puede arreglar, como un archivo demasiado grande para el servidor.

"use client"

import {
  PromptInput,
  PromptInputAttachments,
  PromptInputBody,
  PromptInputActions,
  PromptInputSubmit,
  PromptInputTextarea,
  PromptInputTools,
  type PromptMessage,
} from "@/components/blocks/prompt-input/prompt-input"
import { PromptInputAddMenu } from "@/components/blocks/prompt-input/prompt-controls"
import { UploadError, type PromptUpload } from "@/components/blocks/prompt-input/use-prompt-files"

const upload: PromptUpload = (file, { signal, onProgress }) =>
  new Promise((resolve, reject) => {
    const request = new XMLHttpRequest()
    request.open("POST", "/api/upload")
    request.upload.onprogress = (event) => {
      if (event.lengthComputable) onProgress((event.loaded / event.total) * 100)
    }
    request.onload = () => {
      if (request.status < 300) {
        resolve(JSON.parse(request.responseText))
        return
      }
      reject(
        new UploadError(
          request.status === 413 ? "Too large for the server" : "Upload failed",
          { retryable: request.status === 429 || request.status >= 500 }
        )
      )
    }
    request.onerror = () => reject(new UploadError("Network error. Try again."))
    signal.addEventListener("abort", () => request.abort())
    const body = new FormData()
    body.append("file", file)
    request.send(body)
  })

export function Composer({ onSend }: { onSend: (message: PromptMessage) => void }) {
  return (
    <PromptInput
      onSubmit={onSend}
      upload={upload}
      accept="image/*,.pdf,.csv"
      maxFiles={5}
      maxFileSize={25 * 1024 * 1024}
      globalDrop
    >
      <PromptInputBody>
        <PromptInputAttachments />
        <PromptInputTools>
          <PromptInputAddMenu />
        </PromptInputTools>
        <PromptInputTextarea />
        <PromptInputActions>
          <PromptInputSubmit />
        </PromptInputActions>
      </PromptInputBody>
    </PromptInput>
  )
}

Arnés de agente

Envuelve el cuerpo en PromptInputInset y añade un PromptInputFooter. Usa PromptInputSelect para el modo, dónde se ejecuta y en qué carpeta trabaja.

"use client"

import * as React from "react"
import {
  IconCloud,
  IconCode,
  IconDeviceLaptop,
  IconFolder,
  IconFolderOpen,
  IconListCheck,
} from "@tabler/icons-react"

import {
  PromptInputAddMenu,
  PromptInputFooter,
  PromptInputInset,
  PromptInputModel,
  PromptInputSelect,
} from "@/components/blocks/prompt-input/prompt-controls"
import {
  PromptInput,
  PromptInputActions,
  PromptInputAttachments,
  PromptInputBody,
  PromptInputSubmit,
  PromptInputTextarea,
  PromptInputTools,
  type PromptMessage,
} from "@/components/blocks/prompt-input/prompt-input"

const modes = [
  { value: "code", label: "Code", icon: <IconCode /> },
  { value: "plan", label: "Plan", icon: <IconListCheck /> },
]

const locations = [
  { value: "local", label: "Local", icon: <IconDeviceLaptop /> },
  { value: "cloud", label: "Cloud", icon: <IconCloud /> },
]

const folders = [
  { value: "~/code/app", label: "app", icon: <IconFolder /> },
]

const models = [
  { value: "large", label: "Large" },
  { value: "small", label: "Small" },
]

const efforts = [
  { value: "low", label: "Low" },
  { value: "medium", label: "Medium" },
  { value: "high", label: "High" },
]

export function AgentComposer({
  onSend,
}: {
  onSend: (message: PromptMessage & { mode: string }) => void
}) {
  const [mode, setMode] = React.useState("code")
  const [location, setLocation] = React.useState("local")
  const [folder, setFolder] = React.useState<string>()
  const [model, setModel] = React.useState("large")
  const [effort, setEffort] = React.useState("medium")

  return (
    <PromptInput
      layout="expanded"
      onSubmit={(message) => onSend({ ...message, mode })}
    >
      <PromptInputInset>
        <PromptInputBody>
          <PromptInputAttachments />
          <PromptInputTools>
            <PromptInputAddMenu />
            <PromptInputSelect
              label="Mode"
              options={modes}
              value={mode}
              onValueChange={setMode}
              shortcut="alt+m"
            />
          </PromptInputTools>
          <PromptInputTextarea className="min-h-16" />
          <PromptInputActions>
            <PromptInputModel
              models={models}
              model={model}
              onModelChange={setModel}
              levels={efforts}
              effort={effort}
              onEffortChange={setEffort}
            />
            <PromptInputSubmit />
          </PromptInputActions>
        </PromptInputBody>
        <PromptInputFooter>
          <PromptInputSelect
            label="Run on"
            options={locations}
            value={location}
            onValueChange={setLocation}
          />
          <PromptInputSelect
            label="Folder"
            options={folders}
            value={folder}
            onValueChange={setFolder}
            placeholder="Choose folder"
            icon={<IconFolderOpen />}
          />
        </PromptInputFooter>
      </PromptInputInset>
    </PromptInput>
  )
}

Anatomía

Las partes que compones, de fuera hacia dentro.

ParteDescripción
PromptInputEl formulario y el estado compartido: texto, archivos, menciones, cola y estado.
PromptInputBodyLa superficie redondeada que cambia entre compacta y expandida.
PromptInputTextareaEl campo. Aquí reside toda la gestión del teclado.
PromptInputToolsLado izquierdo: el menú + y los chips de herramientas.
PromptInputActionsLado derecho: dictado y enviar.
PromptInputSubmitEnviar, detener o poner en cola, según el estado y el texto.
PromptInputAttachmentsChips de archivos y menciones sobre el texto.
PromptInputMenuUn menú junto al cursor para un carácter disparador.
PromptInputMetaLa fila bajo el compositor para los controles de modelo, esfuerzo y contexto.
PromptInputModelUn solo control para el modelo y el esfuerzo, con el switch de razonamiento.
PromptInputInsetUna carcasa atenuada alrededor del cuerpo que contiene una fila de pie, para arneses de agentes.
PromptInputFooterLa fila dentro de la carcasa para los controles de ubicación, carpeta y sesión.
PromptInputSelectUn selector compacto para modos, ubicación de ejecución, carpetas o repositorios.
PromptInputQueue, PromptInputSuggestions, PromptInputMeterMensajes en cola, prompts iniciales y un medidor de caracteres cerca de maxLength.

PromptInput

También acepta todas las props de form excepto onSubmit y defaultValue.

PropTipoPredeterminado
onSubmitRecibe { text, files, mentions }. Devuelve una promesa para conservar el texto hasta que se resuelva.
(message: PromptMessage) => void | Promise<unknown>–
statusPasa el status de useChat. Mientras está ocupado, enviar se convierte en detener o en cola.
"ready" | "submitted" | "streaming" | "error""ready"
onStopSe llama desde el botón de detener o Escape.
() => void–
onEditLastDevuelve el último mensaje para cargarlo con ↑ en un campo vacío.
() => string | void–
valueTexto controlado.
string–
defaultValueTexto inicial.
string""
onValueChangeSe llama cuando cambia el texto.
(value: string) => void–
submitOnQué tecla envía. ⌘↵ siempre envía.
"enter" | "mod+enter""enter"
pasteLimitCaracteres antes de que un pegado se convierta en un adjunto.
number | false5000
maxLengthLímite estricto, mostrado por PromptInputMeter.
number–
draftKeyGuarda el texto sin enviar en el almacenamiento local bajo esta clave.
string–
globalDropAcepta archivos soltados en cualquier parte de la página.
booleanfalse
focusOnTypeEscribir mientras nada más tiene el foco empieza a escribir aquí.
booleanfalse
layoutFuerza un diseño en lugar de alternar.
"auto" | "compact" | "expanded""auto"
acceptTipos de archivo aceptados, como el atributo del input.
string–
maxFilesMáximo de archivos a la vez.
number–
maxFileSizeArchivo más grande en bytes.
number–
uploadSube cada archivo. Lanza UploadError con retryable: false para fallos permanentes.
(file, { signal, onProgress }) => Promise<unknown>–
onRejectSe llama para archivos que superan un límite o son de tipo incorrecto.
(file: File, reason: string) => void–
disabledDeshabilita todo el compositor.
booleanfalse
PropTipoPredeterminado
triggerEl carácter que lo abre, como @ o /.
string–
items{ value, label, description?, icon?, hint?, keywords?, insert? }
PromptMenuItem[]–
modeLas menciones añaden un chip; el texto inserta insert ?? label.
"mention" | "text"–
labelEtiqueta de sección en la parte superior del menú.
string–
limitMáximo de filas mostradas.
number–
onSelectSe llama tras una selección.
(item: PromptMenuItem) => void–

PromptInputModel

El selector de modelo y el control deslizante de esfuerzo en un solo control. También acepta todas las props de PromptInputEffort excepto value y onValueChange.

PropTipoPredeterminado
modelsLos nueve primeros obtienen atajos 1–9.
{ value, label }[]–
modelModelo seleccionado.
string–
onModelChangeSe llama al elegir.
(value: string) => void–
levelsNiveles de esfuerzo, de menos a más.
{ value, label }[]–
effortEsfuerzo seleccionado.
string–
onEffortChangeSe llama mientras se mueve el control deslizante.
(value: string) => void–
PropTipoPredeterminado
modelsLos nueve primeros obtienen atajos 1–9.
{ value, label }[]–
valueModelo seleccionado.
string–
onValueChangeSe llama al elegir.
(value: string) => void–
PropTipoPredeterminado
levelsDe menos a más.
{ value, label }[]–
valueNivel seleccionado.
string–
onValueChangeSe llama mientras se mueve el control deslizante.
(value: string) => void–
recommendedMarca un nivel como recomendado.
string–
thinkingMuestra el switch de razonamiento extendido cuando está definido.
boolean–
onThinkingChangeSe llama desde el switch.
(thinking: boolean) => void–
thinkingLabelEtiqueta del switch.
string–
descriptionTexto de ayuda en el tooltip.
string–
PropTipoPredeterminado
usedTokens usados.
number–
limitTamaño de la ventana de contexto.
number–
limitsFilas adicionales, como el uso del plan.
{ label, used, detail? }[]–
limitsLabelEncabezado de esas filas.
string–
PropTipoPredeterminado
transcribeEnvía la grabación a tu modelo de voz. Sin esto, se usa el reconocimiento de voz del navegador.
(audio: Blob) => Promise<string>–
shortcutInicia y termina una grabación desde cualquier parte.
string | false"ctrl+shift+d"
autoSendEnvía en cuanto llega la transcripción.
booleanfalse
PropTipoPredeterminado
iconSe muestra en el chip, y se sustituye por × al pasar el cursor.
ReactNode–
childrenNombre de la herramienta.
string–
toneColor del chip.
"blue" | "green" | "amber" | "violet" | "pink""blue"
onRemoveSe llama al hacer clic en el chip.
() => void–
PropTipoPredeterminado
optionsLas opciones. Las descripciones se muestran bajo cada etiqueta.
{ value, label, icon?, description? }[]–
valueOpción seleccionada. Déjala sin definir para mostrar el placeholder.
string–
onValueChangeSe llama al elegir.
(value: string) => void–
labelNombre accesible y tooltip, como Mode o Run on.
string–
placeholderSe muestra cuando no hay nada seleccionado.
string–
iconSe muestra cuando la opción seleccionada no tiene icono.
ReactNode–
headingEtiqueta en la parte superior del menú.
string–
shortcutRecorre las opciones desde cualquier parte, como alt+m.
string–
childrenElementos de menú adicionales bajo las opciones, como Open folder.
ReactNode–
KeyAcción
EnterEnvía. En pantallas táctiles añade una línea en su lugar.
ShiftEnterAñade una línea.
⌘EnterSiempre envía, incluso cuando submitOn es mod+enter.
EscDetiene una respuesta en streaming, cierra un menú o cancela una grabación.
↑En un campo vacío, recupera tu último mensaje cuando onEditLast está definido.
↑↓Recorre un menú @ o / abierto.
EnterTabElige la fila resaltada del menú.
1–9Elige un modelo mientras el menú de modelos está abierto.
←→Mueve el control deslizante de esfuerzo. Home y End saltan a los extremos.
CtrlShiftDInicia el dictado, e inserta la transcripción al pulsar de nuevo.
AltMRecorre un PromptInputSelect que tiene shortcut="alt+m", como el modo de agente.
  • El campo tiene un placeholder visible y un nombre accesible, y cada botón de icono tiene una etiqueta y un tooltip con su atajo.
  • El menú junto al cursor es un combobox con aria-activedescendant, así que el foco nunca sale del texto mientras te mueves por él.
  • Las subidas, los rechazos, los cambios de cola y el dictado se anuncian mediante una región live polite.
  • El control de esfuerzo es un slider con un texto de valor, y sigue funcionando con las teclas de flecha y Home y End.
  • Los inputs miden al menos 16px en pantallas táctiles, para que iOS nunca haga zoom al enfocar.
  • Con movimiento reducido, los cambios de altura, la forma de onda y el relleno de esfuerzo se asientan sin animarse.

Construido con

Los componentes gratuitos de HextaUI con los que está hecho Prompt Input. Cada uno se instala por separado.

Código

10 archivos, añadidos a components/blocks/prompt-input.