Prompt Input

Ein Chat-Composer, der als ruhige einzelne Zeile beginnt, beim Schreiben zu einer Karte wächst und nach unten wandert, sobald die Unterhaltung beginnt. Enter sendet, sicher bei japanischer und chinesischer Eingabe. Dateien per Einfügen, Ablegen oder Auswählen hinzufügen, mit Vorschau, Fortschritt und Wiederholen. @ fügt Dateien hinzu und / führt Befehle aus einem Menü an der Einfügemarke aus. Ein Modellwähler mit Zifferntasten, ein Effort-Slider, der bei Max lebendig wird, ein Kontextring, Diktat mit Live-Wellenform, Tool-Chips, eine Warteschlange für Nachrichten, die während einer streamenden Antwort getippt werden, und Entwürfe, die einen Reload überstehen.

Der Composer ist der Teil eines Chats, den Leute am meisten berühren, deshalb muss sich jeder Tastenanschlag richtig anfühlen. Prompt Input beginnt als ruhige einzelne Zeile mit dem + links und Senden rechts, wächst zu einer Karte, sobald der Text umbricht oder Dateien hinzukommen, und setzt sich zurück, wenn er geleert wird.

Enter sendet und Shift+Enter fügt eine Zeile hinzu, und Eingabemethoden für Japanisch, Chinesisch und Koreanisch werden nie unterbrochen. Auf Touchscreens fügt Enter eine Zeile hinzu, da die Tastatur kein Shift hat. Einfügungen laufen über den eigenen Undo-Stack des Browsers, sodass ⌘Z immer funktioniert, und lange eingefügte Texte werden zu einem Anhang, den du in der Vorschau ansehen oder wieder inline einsetzen kannst.

Alles ist aus Parts zusammengesetzt, die einen Kontext teilen. Nutze nur das Feld und den Senden-Button, oder füge Datei-Uploads mit Fortschritt und Wiederholen, @- und /-Menüs an der Einfügemarke, einen Modellwähler, einen Effort-Slider, einen Kontextring, Diktat, Tool-Chips, eine Warteschlange für Nachrichten, die während einer streamenden Antwort gesendet werden, und Entwürfe, die einen Reload überstehen, hinzu.

Prompt Input ist kostenlos. Installiere es ohne Konto oder Token und nutze es unter der Pro-Lizenz in beliebig vielen Projekten.

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

Mit dem AI SDK

Übergib status und stop von useChat. Dateien kommen als FileUIPart mit einer Data-URL an, und eine Nachricht, die während einer streamenden Antwort getippt wird, wird in die Warteschlange gestellt und gesendet, wenn die Antwort fertig ist.

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

Modell, Effort und Diktat

Setze Modellwähler, Effort-Slider und Kontextring mit PromptInputMeta unter den Composer. Aktiviere Tools über das +-Menü und zeige sie als Chips. Übergib transcribe, um Aufnahmen an dein eigenes Sprachmodell zu schicken, und autoSend, um zu senden, sobald der Text da ist.

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

Erwähnungen und Befehle

Füge pro Auslösezeichen ein Menü hinzu. @ macht aus einer Auswahl einen Chip über dem Text, und / fügt den Befehl ein. Pfeiltasten bewegen, Enter oder Tab wählt und Escape schließt, alles ohne den Text zu verlassen.

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

Dateien hochladen

Gib upload eine Funktion, die den Fortschritt meldet und das Abbruchsignal beachtet. Wirf UploadError mit retryable: false bei Fehlern, die ein erneuter Versuch nicht behebt, etwa einer Datei, die für den Server zu groß ist.

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

Agent-Harness

Umschließe den Body mit PromptInputInset und füge ein PromptInputFooter hinzu. Nutze PromptInputSelect für den Modus, den Ausführungsort und den Ordner, in dem gearbeitet wird.

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

Aufbau

Die Teile, die du zusammensetzt, von außen nach innen.

PartBeschreibung
PromptInputDas Formular und der gemeinsame State: Text, Dateien, Erwähnungen, Warteschlange und Status.
PromptInputBodyDie abgerundete Fläche, die zwischen kompakt und erweitert wechselt.
PromptInputTextareaDas Feld. Die gesamte Tastaturbehandlung liegt hier.
PromptInputToolsLinke Seite: das +-Menü und Tool-Chips.
PromptInputActionsRechte Seite: Diktat und Senden.
PromptInputSubmitSenden, Stopp oder Warteschlange, je nach Status und Text.
PromptInputAttachmentsDatei- und Erwähnungs-Chips über dem Text.
PromptInputMenuEin Menü an der Einfügemarke für ein Auslösezeichen.
PromptInputMetaDie Zeile unter dem Composer für die Steuerelemente von Modell, Effort und Kontext.
PromptInputModelEin Steuerelement für Modell und Effort, mit dem Thinking-Switch.
PromptInputInsetEine gedämpfte Hülle um den Body, die eine Fußzeile enthält, für Agent-Harnesses.
PromptInputFooterDie Zeile innerhalb der eingelassenen Hülle für Ort-, Ordner- und Sitzungs-Steuerelemente.
PromptInputSelectEin kompakter Picker für Modi, Ausführungsort, Ordner oder Repositories.
PromptInputQueue, PromptInputSuggestions, PromptInputMeterNachrichten in der Warteschlange, Starter-Prompts und ein Zeichenzähler nahe maxLength.

PromptInput

Akzeptiert auch alle form-Props außer onSubmit und defaultValue.

PropTypStandard
onSubmitErhält { text, files, mentions }. Gib ein Promise zurück, um den Text zu behalten, bis es aufgelöst ist.
(message: PromptMessage) => void | Promise<unknown>–
statusÜbergib status von useChat. Solange beschäftigt, wird Senden zu Stopp oder Warteschlange.
"ready" | "submitted" | "streaming" | "error""ready"
onStopWird vom Stopp-Button oder Escape aufgerufen.
() => void–
onEditLastGib die letzte Nachricht zurück, um sie mit ↑ in einem leeren Feld zu laden.
() => string | void–
valueKontrollierter Text.
string–
defaultValueAnfangstext.
string""
onValueChangeWird aufgerufen, wenn sich der Text ändert.
(value: string) => void–
submitOnWelche Taste sendet. ⌘↵ sendet immer.
"enter" | "mod+enter""enter"
pasteLimitZeichen, ab denen ein Einfügen zu einem Anhang wird.
number | false5000
maxLengthHartes Limit, von PromptInputMeter angezeigt.
number–
draftKeySpeichert ungesendeten Text unter diesem Schlüssel im Local Storage.
string–
globalDropAkzeptiert Dateien, die irgendwo auf der Seite abgelegt werden.
booleanfalse
focusOnTypeTippen, solange nichts anderes den Fokus hat, beginnt hier zu schreiben.
booleanfalse
layoutErzwingt ein Layout, statt umzuschalten.
"auto" | "compact" | "expanded""auto"
acceptAkzeptierte Dateitypen, wie das input-Attribut.
string–
maxFilesHöchstzahl gleichzeitiger Dateien.
number–
maxFileSizeGrößte Datei in Bytes.
number–
uploadLädt jede Datei hoch. Wirf UploadError mit retryable: false bei dauerhaften Fehlern.
(file, { signal, onProgress }) => Promise<unknown>–
onRejectWird für Dateien über einem Limit oder vom falschen Typ aufgerufen.
(file: File, reason: string) => void–
disabledDeaktiviert den gesamten Composer.
booleanfalse
PropTypStandard
triggerDas Zeichen, das es öffnet, etwa @ oder /.
string–
items{ value, label, description?, icon?, hint?, keywords?, insert? }
PromptMenuItem[]–
modeErwähnungen fügen einen Chip hinzu; Text fügt insert ?? label ein.
"mention" | "text"–
labelAbschnittslabel oben im Menü.
string–
limitHöchstzahl angezeigter Zeilen.
number–
onSelectWird nach einer Auswahl aufgerufen.
(item: PromptMenuItem) => void–

PromptInputModel

Modellwähler und Effort-Slider in einem Steuerelement. Nimmt auch alle PromptInputEffort-Props außer value und onValueChange.

PropTypStandard
modelsDie ersten neun erhalten die Kürzel 1–9.
{ value, label }[]–
modelGewähltes Modell.
string–
onModelChangeWird bei der Auswahl aufgerufen.
(value: string) => void–
levelsEffort-Stufen, von der niedrigsten zur höchsten.
{ value, label }[]–
effortGewählter Effort.
string–
onEffortChangeWird aufgerufen, während sich der Slider bewegt.
(value: string) => void–
PropTypStandard
modelsDie ersten neun erhalten die Kürzel 1–9.
{ value, label }[]–
valueGewähltes Modell.
string–
onValueChangeWird bei der Auswahl aufgerufen.
(value: string) => void–
PropTypStandard
levelsVon der niedrigsten zur höchsten.
{ value, label }[]–
valueGewählte Stufe.
string–
onValueChangeWird aufgerufen, während sich der Slider bewegt.
(value: string) => void–
recommendedMarkiert eine Stufe als empfohlen.
string–
thinkingZeigt den Switch für Extended Thinking, wenn gesetzt.
boolean–
onThinkingChangeWird vom Switch aufgerufen.
(thinking: boolean) => void–
thinkingLabelSwitch-Label.
string–
descriptionHilfetext im Tooltip.
string–
PropTypStandard
usedVerbrauchte Tokens.
number–
limitGröße des Kontextfensters.
number–
limitsZusätzliche Zeilen, etwa Planverbrauch.
{ label, used, detail? }[]–
limitsLabelÜberschrift für diese Zeilen.
string–
PropTypStandard
transcribeSendet die Aufnahme an dein Sprachmodell. Ohne dies wird die Spracherkennung des Browsers verwendet.
(audio: Blob) => Promise<string>–
shortcutStartet und beendet eine Aufnahme von überall.
string | false"ctrl+shift+d"
autoSendSendet, sobald das Transkript da ist.
booleanfalse
PropTypStandard
iconWird im Chip angezeigt und beim Hover durch × ersetzt.
ReactNode–
childrenTool-Name.
string–
toneChip-Farbe.
"blue" | "green" | "amber" | "violet" | "pink""blue"
onRemoveWird aufgerufen, wenn auf den Chip geklickt wird.
() => void–
PropTypStandard
optionsDie Auswahlmöglichkeiten. Beschreibungen erscheinen unter jedem Label.
{ value, label, icon?, description? }[]–
valueGewählte Option. Lass es ungesetzt, um den Platzhalter zu zeigen.
string–
onValueChangeWird bei der Auswahl aufgerufen.
(value: string) => void–
labelZugänglicher Name und Tooltip, etwa Mode oder Run on.
string–
placeholderWird angezeigt, wenn nichts gewählt ist.
string–
iconWird angezeigt, wenn die gewählte Option kein Icon hat.
ReactNode–
headingLabel oben im Menü.
string–
shortcutSchaltet von überall durch die Optionen, etwa mit alt+m.
string–
childrenZusätzliche Menüpunkte unter den Optionen, etwa Open folder.
ReactNode–
TasteAktion
EnterSendet. Auf Touchscreens fügt es stattdessen eine Zeile hinzu.
ShiftEnterFügt eine Zeile hinzu.
⌘EnterSendet immer, auch wenn submitOn mod+enter ist.
EscStoppt eine streamende Antwort, schließt ein Menü oder bricht eine Aufnahme ab.
↑Holt in einem leeren Feld deine letzte Nachricht zurück, wenn onEditLast gesetzt ist.
↑↓Wechselt durch ein geöffnetes @- oder /-Menü.
EnterTabWählt die hervorgehobene Menüzeile.
1–9Wählt ein Modell, solange das Modellmenü geöffnet ist.
←→Bewegt den Effort-Slider. Pos1 und Ende springen zu den Enden.
CtrlShiftDStartet das Diktat und fügt das Transkript ein, wenn erneut gedrückt.
AltMSchaltet ein PromptInputSelect durch, das shortcut="alt+m" hat, etwa den Agent-Modus.
  • Das Feld hat einen sichtbaren Platzhalter und einen zugänglichen Namen, und jeder Icon-Button hat ein Label und einen Tooltip mit seinem Tastenkürzel.
  • Das Menü an der Einfügemarke ist eine Combobox mit aria-activedescendant, sodass der Fokus beim Navigieren nie den Text verlässt.
  • Uploads, Ablehnungen, Änderungen an der Warteschlange und Diktat werden über eine höfliche Live-Region angesagt.
  • Das Effort-Steuerelement ist ein Slider mit Werttext und funktioniert weiterhin mit Pfeiltasten sowie Pos1 und Ende.
  • Inputs haben auf Touchscreens mindestens 16 px, damit iOS beim Fokus nie hineinzoomt.
  • Bei reduzierter Bewegung setzen sich Höhenänderungen, die Wellenform und die Effort-Füllung ohne Animation.

Gebaut mit

Die kostenlosen HextaUI-Komponenten, aus denen Prompt Input besteht. Jede lässt sich einzeln installieren.

Code

10 Dateien, hinzugefügt zu components/blocks/prompt-input.