Prompt Input

Un champ de discussion qui démarre sur une seule ligne sobre, grandit en carte à mesure que vous écrivez et descend une fois la conversation lancée. Entrée envoie, sans risque avec la saisie japonaise et chinoise. Collez, déposez ou choisissez des fichiers avec aperçus, progression et nouvel essai. @ ajoute des fichiers et / lance des commandes depuis un menu au niveau du curseur. Un sélecteur de modèle avec touches numériques, un curseur d’effort qui s’anime à Max, un anneau de contexte, la dictée avec forme d’onde en direct, des chips d’outils, une file pour les messages saisis pendant qu’une réponse arrive en streaming, et des brouillons qui survivent à un rechargement.

Le champ de saisie est la partie d’un chat que l’on touche le plus, donc chaque frappe doit être juste. Prompt Input démarre sur une seule ligne sobre avec le + à gauche et l’envoi à droite, grandit en carte quand le texte passe à la ligne ou qu’on ajoute des fichiers, et se replie une fois vidé.

Enter envoie et Shift+Enter ajoute une ligne, et les méthodes de saisie pour le japonais, le chinois et le coréen ne sont jamais interrompues. Sur les écrans tactiles, Enter ajoute une ligne, puisque le clavier n’a pas de Shift. Les insertions passent par la pile d’annulation propre au navigateur, donc ⌘Z fonctionne toujours, et les longs collages deviennent une pièce jointe que vous pouvez prévisualiser ou remettre en ligne.

Tout est composé de parties qui partagent un seul contexte. Utilisez seulement le champ et le bouton d’envoi, ou ajoutez des envois de fichiers avec progression et nouvel essai, des menus @ et / au curseur, un sélecteur de modèle, un curseur d’effort, un anneau de contexte, la dictée, des chips d’outils, une file pour les messages envoyés pendant qu’une réponse arrive en streaming, et des brouillons qui survivent à un rechargement.

Prompt Input est gratuit. Installez-le sans compte ni token, et utilisez-le sous la Licence Pro dans autant de projets que vous voulez.

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

Avec AI SDK

Passez le status et stop de useChat. Les fichiers arrivent sous forme de FileUIPart avec une URL de données, et un message saisi pendant qu’une réponse arrive en streaming est mis en file et envoyé à la fin de celle-ci.

"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>
  )
}

Modèle, effort et dictée

Placez le sélecteur de modèle, le curseur d’effort et l’anneau de contexte sous le champ de saisie avec PromptInputMeta. Activez des outils depuis le menu + et affichez-les en chips. Passez transcribe pour envoyer les enregistrements à votre propre modèle vocal, et autoSend pour envoyer dès que le texte arrive.

"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>
  )
}

Mentions et commandes

Ajoutez un menu par déclencheur. @ transforme un choix en chip au-dessus du texte, et / insère la commande. Les flèches déplacent, Enter ou Tab choisit et Escape ferme, sans quitter le texte.

"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>
  )
}

Envoi de fichiers

Donnez à upload une fonction qui rapporte la progression et respecte le signal d’abandon. Levez UploadError avec retryable: false pour les erreurs qu’un nouvel essai ne peut pas corriger, comme un fichier trop volumineux pour le serveur.

"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>
  )
}

Harnais d’agent

Enveloppez le corps dans PromptInputInset et ajoutez un PromptInputFooter. Utilisez PromptInputSelect pour le mode, le lieu d’exécution et le dossier de travail.

"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>
  )
}

Anatomie

Les parties à composer, de l’extérieur vers l’intérieur.

PartieDescription
PromptInputLe formulaire et l’état partagé : texte, fichiers, mentions, file et statut.
PromptInputBodyLa surface arrondie qui passe du mode compact au mode étendu.
PromptInputTextareaLe champ. Toute la gestion du clavier se trouve ici.
PromptInputToolsCôté gauche : le menu + et les chips d’outils.
PromptInputActionsCôté droit : dictée et envoi.
PromptInputSubmitEnvoyer, arrêter ou mettre en file, selon le status et le texte.
PromptInputAttachmentsChips de fichiers et de mentions au-dessus du texte.
PromptInputMenuUn menu au curseur pour un caractère déclencheur.
PromptInputMetaLa rangée sous le champ de saisie pour les contrôles de modèle, d’effort et de contexte.
PromptInputModelUn seul contrôle pour le modèle et l’effort, avec le switch de réflexion.
PromptInputInsetUne coque discrète autour du corps qui contient une rangée de pied de page, pour les harnais d’agents.
PromptInputFooterLa rangée à l’intérieur de la coque inset pour les contrôles de lieu, de dossier et de session.
PromptInputSelectUn sélecteur compact pour les modes, le lieu d’exécution, les dossiers ou les dépôts.
PromptInputQueue, PromptInputSuggestions, PromptInputMeterMessages en file, prompts de départ et compteur de caractères près de maxLength.

PromptInput

Accepte aussi toutes les props de form sauf onSubmit et defaultValue.

PropTypePar défaut
onSubmitReçoit { text, files, mentions }. Retournez une promesse pour conserver le texte jusqu’à sa résolution.
(message: PromptMessage) => void | Promise<unknown>–
statusPassez le status de useChat. Tant que c’est occupé, l’envoi devient arrêt ou mise en file.
"ready" | "submitted" | "streaming" | "error""ready"
onStopAppelé depuis le bouton d’arrêt ou Escape.
() => void–
onEditLastRetournez le dernier message pour le charger avec ↑ dans un champ vide.
() => string | void–
valueTexte contrôlé.
string–
defaultValueTexte initial.
string""
onValueChangeAppelé à chaque changement du texte.
(value: string) => void–
submitOnQuelle touche envoie. ⌘↵ envoie toujours.
"enter" | "mod+enter""enter"
pasteLimitNombre de caractères avant qu’un collage devienne une pièce jointe.
number | false5000
maxLengthLimite stricte, affichée par PromptInputMeter.
number–
draftKeyEnregistre le texte non envoyé dans le stockage local sous cette clé.
string–
globalDropAccepte les fichiers déposés n’importe où sur la page.
booleanfalse
focusOnTypeTaper alors que rien d’autre n’a le focus commence la saisie ici.
booleanfalse
layoutForce une disposition au lieu de basculer.
"auto" | "compact" | "expanded""auto"
acceptTypes de fichiers acceptés, comme l’attribut d’input.
string–
maxFilesNombre maximal de fichiers à la fois.
number–
maxFileSizePlus gros fichier en octets.
number–
uploadEnvoie chaque fichier. Levez UploadError avec retryable: false pour les échecs définitifs.
(file, { signal, onProgress }) => Promise<unknown>–
onRejectAppelé pour les fichiers dépassant une limite ou d’un mauvais type.
(file: File, reason: string) => void–
disabledDésactive tout le champ de saisie.
booleanfalse
PropTypePar défaut
triggerLe caractère qui l’ouvre, comme @ ou /.
string–
items{ value, label, description?, icon?, hint?, keywords?, insert? }
PromptMenuItem[]–
modeLes mentions ajoutent une chip ; le texte insère insert ?? label.
"mention" | "text"–
labelLibellé de section en haut du menu.
string–
limitNombre maximal de lignes affichées.
number–
onSelectAppelé après un choix.
(item: PromptMenuItem) => void–

PromptInputModel

Le sélecteur de modèle et le curseur d’effort en un seul contrôle. Accepte aussi toutes les props de PromptInputEffort sauf value et onValueChange.

PropTypePar défaut
modelsLes neuf premiers reçoivent les raccourcis 1 à 9.
{ value, label }[]–
modelModèle sélectionné.
string–
onModelChangeAppelé au choix.
(value: string) => void–
levelsNiveaux d’effort, du plus faible au plus élevé.
{ value, label }[]–
effortEffort sélectionné.
string–
onEffortChangeAppelé pendant le déplacement du curseur.
(value: string) => void–
PropTypePar défaut
modelsLes neuf premiers reçoivent les raccourcis 1 à 9.
{ value, label }[]–
valueModèle sélectionné.
string–
onValueChangeAppelé au choix.
(value: string) => void–
PropTypePar défaut
levelsDu plus faible au plus élevé.
{ value, label }[]–
valueNiveau sélectionné.
string–
onValueChangeAppelé pendant le déplacement du curseur.
(value: string) => void–
recommendedMarque un niveau comme recommandé.
string–
thinkingAffiche le switch de réflexion étendue lorsqu’il est défini.
boolean–
onThinkingChangeAppelé depuis le switch.
(thinking: boolean) => void–
thinkingLabelLibellé du switch.
string–
descriptionTexte d’aide dans l’infobulle.
string–
PropTypePar défaut
usedTokens utilisés.
number–
limitTaille de la fenêtre de contexte.
number–
limitsLignes supplémentaires, comme l’utilisation du forfait.
{ label, used, detail? }[]–
limitsLabelTitre de ces lignes.
string–
PropTypePar défaut
transcribeEnvoie l’enregistrement à votre modèle vocal. Sans cela, la reconnaissance vocale du navigateur est utilisée.
(audio: Blob) => Promise<string>–
shortcutDémarre et termine un enregistrement de n’importe où.
string | false"ctrl+shift+d"
autoSendEnvoie dès que la transcription arrive.
booleanfalse
PropTypePar défaut
iconAffiché dans la chip, et remplacé par × au survol.
ReactNode–
childrenNom de l’outil.
string–
toneCouleur de la chip.
"blue" | "green" | "amber" | "violet" | "pink""blue"
onRemoveAppelé au clic sur la chip.
() => void–
PropTypePar défaut
optionsLes choix. Les descriptions s’affichent sous chaque libellé.
{ value, label, icon?, description? }[]–
valueOption sélectionnée. Laissez-la indéfinie pour afficher le placeholder.
string–
onValueChangeAppelé au choix.
(value: string) => void–
labelNom accessible et infobulle, comme Mode ou Run on.
string–
placeholderAffiché quand rien n’est sélectionné.
string–
iconAffiché quand l’option sélectionnée n’a pas d’icône.
ReactNode–
headingLibellé en haut du menu.
string–
shortcutParcourt les options de n’importe où, comme alt+m.
string–
childrenItems de menu supplémentaires sous les options, comme Open folder.
ReactNode–
ToucheAction
EnterEnvoie. Sur les écrans tactiles, il ajoute une ligne à la place.
ShiftEnterAjoute une ligne.
⌘EnterEnvoie toujours, même quand submitOn vaut mod+enter.
EscArrête une réponse en streaming, ferme un menu ou annule un enregistrement.
↑Dans un champ vide, rappelle votre dernier message lorsque onEditLast est défini.
↑↓Parcourt un menu @ ou / ouvert.
EnterTabChoisit la ligne de menu en surbrillance.
1–9Choisit un modèle tant que le menu des modèles est ouvert.
←→Déplace le curseur d’effort. Home et End sautent aux extrémités.
CtrlShiftDDémarre la dictée, et insère la transcription à la deuxième pression.
AltMFait défiler un PromptInputSelect qui a shortcut="alt+m", comme le mode de l’agent.
  • Le champ a un placeholder visible et un nom accessible, et chaque bouton d’icône a un libellé et une infobulle avec son raccourci.
  • Le menu au curseur est une combobox avec aria-activedescendant, donc le focus ne quitte jamais le texte pendant que vous le parcourez.
  • Les envois, rejets, changements de file et la dictée sont annoncés via une région live polie.
  • Le contrôle d’effort est un curseur avec un texte de valeur, et il continue de fonctionner avec les flèches, Home et End.
  • Les champs de saisie font au moins 16px sur les écrans tactiles, donc iOS ne zoome jamais au focus.
  • Avec réduction des animations, les changements de hauteur, la forme d’onde et le remplissage de l’effort se stabilisent sans animation.

Construit avec

Les composants HextaUI gratuits dont Prompt Input est constitué. Chacun s’installe séparément.

Code

10 fichiers, ajoutés à components/blocks/prompt-input.