Prompt Input

最初は静かな1行で、入力に合わせてカードへ広がり、会話が始まると下へ移動するチャット入力欄です。Enterで送信でき、日本語や中国語の入力でも安全です。ファイルは貼り付け、ドロップ、選択でき、プレビュー、進捗、再試行に対応します。@でファイルを追加し、/でコマンドをカーソル位置のメニューから実行できます。数字キーで選べるモデルピッカー、Maxで動き出す推論量スライダー、コンテキストリング、リアルタイム波形つきの音声入力、ツールチップ、返信のストリーミング中に入力したメッセージのキュー、再読み込みしても残る下書きを備えています。

コンポーザーは、チャットの中でもっとも頻繁に触れられる部分なので、すべてのキー入力の感触が正しくなければなりません。Prompt Inputは、左に+、右に送信を備えた静かな1行で始まり、テキストが折り返されるかファイルが追加されるとカードへ広がり、クリアすると元に戻ります。

Enterで送信、Shift+Enterで改行となり、日本語、中国語、韓国語の入力メソッドが中断されることはありません。タッチ画面ではキーボードにShiftがないため、Enterは改行になります。挿入はブラウザー自身のundoスタックを通るため、⌘Zが常に動作し、長い貼り付けはプレビューしたりインラインに戻したりできる添付ファイルになります。

すべては、1つのコンテキストを共有するパーツから組み立てられます。フィールドと送信ボタンだけを使うことも、進捗と再試行つきのファイルアップロード、キャレット位置の@と/のメニュー、モデルピッカー、推論量スライダー、コンテキストリング、音声入力、ツールチップ、返信のストリーミング中に送ったメッセージのキュー、再読み込みしても残る下書きを追加することもできます。

Prompt Inputは無料です。アカウントやトークンなしでインストールでき、Proライセンスのもと、好きなだけのプロジェクトで使えます。

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

AI SDKと使う

useChatのstatusとstopを渡します。ファイルはデータURLを持つFileUIPartとして届き、返信のストリーミング中に入力されたメッセージはキューに入り、完了したときに送信されます。

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

モデル、推論量、音声入力

PromptInputMetaを使って、モデルピッカー、推論量スライダー、コンテキストリングをコンポーザーの下に置きます。+メニューからツールをオンにして、チップとして表示します。録音を独自の音声モデルに送るにはtranscribeを、テキストが入力された時点ですぐに送信するにはautoSendを渡します。

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

メンションとコマンド

トリガーごとにメニューを追加します。@は選択をテキストの上のチップにし、/はコマンドを挿入します。矢印キーで移動し、EnterまたはTabで選択し、Escapeで閉じます。すべてテキストから離れずに操作できます。

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

ファイルのアップロード

進捗を報告し、中断シグナルに従う関数をuploadに渡してください。サーバーに対して大きすぎるファイルなど、再試行では直せないエラーには、retryable: falseでUploadErrorを投げます。

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

エージェントハーネス

本体をPromptInputInsetで包み、PromptInputFooterを追加します。モード、実行場所、作業するフォルダーにはPromptInputSelectを使います。

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

構造

外側から内側へ組み合わせるパーツ。

パーツ説明
PromptInputフォームと共有される状態。テキスト、ファイル、メンション、キュー、ステータス。
PromptInputBodyコンパクトと展開の間で変化する、角丸のサーフェス。
PromptInputTextareaフィールド。すべてのキーボード操作はここで処理されます。
PromptInputTools左側。+メニューとツールチップ。
PromptInputActions右側。音声入力と送信。
PromptInputSubmitstatusとテキストに応じて、送信、停止、またはキュー追加を行います。
PromptInputAttachmentsテキストの上に表示されるファイルとメンションのチップ。
PromptInputMenu1つのトリガー文字に対する、キャレット位置のメニュー。
PromptInputMetaモデル、推論量、コンテキストのコントロールのための、コンポーザーの下の行。
PromptInputModelモデルと推論量をまとめた1つのコントロールで、シンキングのスイッチ付きです。
PromptInputInsetエージェントハーネス向けに、フッター行を保持する、本体を囲む控えめなシェル。
PromptInputFooter場所、フォルダー、セッションのコントロールのための、インセットシェル内の行。
PromptInputSelectモード、実行場所、フォルダー、リポジトリ用のコンパクトなピッカー。
PromptInputQueue, PromptInputSuggestions, PromptInputMeterキューに入ったメッセージ、スタータープロンプト、maxLength付近での文字数メーター。

PromptInput

onSubmitとdefaultValueを除くすべてのformプロップも受け付けます。

プロパティ型デフォルト
onSubmit{ text, files, mentions } を受け取ります。Promiseを返すと、resolveされるまでテキストが保持されます。
(message: PromptMessage) => void | Promise<unknown>–
statususeChatのstatusを渡します。ビジー中は、送信が停止またはキューに変わります。
"ready" | "submitted" | "streaming" | "error""ready"
onStop停止ボタンまたはEscapeから呼ばれます。
() => void–
onEditLast空のフィールドで↑を押したときに読み込めるよう、最後のメッセージを返します。
() => string | void–
value制御されたテキスト。
string–
defaultValue初期テキスト。
string""
onValueChangeテキストが変わるたびに呼ばれます。
(value: string) => void–
submitOn送信に使うキー。⌘↵は常に送信します。
"enter" | "mod+enter""enter"
pasteLimit貼り付けが添付ファイルになるまでの文字数。
number | false5000
maxLength厳密な上限。PromptInputMeterに表示されます。
number–
draftKey未送信のテキストを、このキーでローカルストレージに保存します。
string–
globalDropページ上のどこにドロップされたファイルも受け付けます。
booleanfalse
focusOnType他の要素にフォーカスがない状態で入力すると、ここへの入力が始まります。
booleanfalse
layout切り替えずに、レイアウトを固定します。
"auto" | "compact" | "expanded""auto"
accept受け付けるファイルの種類。input属性と同様です。
string–
maxFiles一度に扱える最大のファイル数。
number–
maxFileSizeバイト単位での最大ファイルサイズ。
number–
upload各ファイルをアップロードします。恒久的な失敗には、retryable: falseでUploadErrorを投げます。
(file, { signal, onProgress }) => Promise<unknown>–
onReject上限を超えたファイル、または種類が違うファイルに対して呼ばれます。
(file: File, reason: string) => void–
disabledコンポーザー全体を無効にします。
booleanfalse
プロパティ型デフォルト
trigger開くための文字。@や/など。
string–
items{ value, label, description?, icon?, hint?, keywords?, insert? }
PromptMenuItem[]–
modeメンションはチップを追加し、テキストは「insert ?? label」を挿入します。
"mention" | "text"–
labelメニューの上部にあるセクションのラベル。
string–
limit一度に表示できる最大の行数。
number–
onSelect選択後に呼ばれます。
(item: PromptMenuItem) => void–

PromptInputModel

モデルピッカーと推論量スライダーを1つにまとめたコントロール。valueとonValueChangeを除く、PromptInputEffortのすべてのプロップも受け付けます。

プロパティ型デフォルト
models最初の9個には、1から9のショートカットが割り当てられます。
{ value, label }[]–
model選択されたモデル。
string–
onModelChange選択時に呼ばれます。
(value: string) => void–
levels推論量のレベル。低いものから高いものの順です。
{ value, label }[]–
effort選択された推論量。
string–
onEffortChangeスライダーの移動に合わせて呼ばれます。
(value: string) => void–
プロパティ型デフォルト
models最初の9個には、1から9のショートカットが割り当てられます。
{ value, label }[]–
value選択されたモデル。
string–
onValueChange選択時に呼ばれます。
(value: string) => void–
プロパティ型デフォルト
levels低いものから高いものの順。
{ value, label }[]–
value選択されたレベル。
string–
onValueChangeスライダーの移動に合わせて呼ばれます。
(value: string) => void–
recommended1つのレベルを推奨としてマークします。
string–
thinking設定すると、拡張シンキングのスイッチを表示します。
boolean–
onThinkingChangeスイッチから呼ばれます。
(thinking: boolean) => void–
thinkingLabelスイッチのラベル。
string–
descriptionツールチップ内のヘルプテキスト。
string–
プロパティ型デフォルト
used使用したトークン数。
number–
limitコンテキストウィンドウのサイズ。
number–
limitsプランの使用状況などの追加の行。
{ label, used, detail? }[]–
limitsLabelそれらの行の見出し。
string–
プロパティ型デフォルト
transcribe録音を独自の音声モデルに送ります。指定しない場合は、ブラウザーの音声認識が使われます。
(audio: Blob) => Promise<string>–
shortcutどこからでも録音を開始・終了します。
string | false"ctrl+shift+d"
autoSend文字起こしが入力された時点ですぐに送信します。
booleanfalse
プロパティ型デフォルト
iconチップ内に表示され、ホバー時には×に置き換わります。
ReactNode–
childrenツール名。
string–
toneチップの色。
"blue" | "green" | "amber" | "violet" | "pink""blue"
onRemoveチップがクリックされたときに呼ばれます。
() => void–
プロパティ型デフォルト
options選択肢。説明は各ラベルの下に表示されます。
{ value, label, icon?, description? }[]–
value選択されたオプション。プレースホルダーを表示するには、未設定のままにします。
string–
onValueChange選択時に呼ばれます。
(value: string) => void–
labelアクセシブルな名前とツールチップ。ModeやRun onなど。
string–
placeholder何も選択されていないときに表示されます。
string–
icon選択されたオプションにアイコンがないときに表示されます。
ReactNode–
headingメニューの上部のラベル。
string–
shortcutalt+mのように、どこからでもオプションを順に切り替えます。
string–
childrenオプションの下に表示される、Open folderなどの追加のメニュー項目。
ReactNode–
キーアクション
Enter送信します。タッチ画面では、代わりに改行します。
ShiftEnter行を追加します。
⌘EntersubmitOnがmod+enterの場合でも、常に送信します。
Escストリーミング中の返信を停止するか、メニューを閉じるか、録音をキャンセルします。
↑空のフィールドで、onEditLastが設定されていると、直前のメッセージを呼び戻します。
↑↓開いている@または/のメニュー内を移動します。
EnterTabハイライトされたメニューの行を選択します。
1–9モデルメニューが開いているとき、モデルを選択します。
←→推論量スライダーを動かします。HomeとEndで両端にジャンプします。
CtrlShiftD音声入力を開始し、もう一度押すと文字起こしを挿入します。
AltMエージェントモードのように、shortcut="alt+m" を持つPromptInputSelectを順に切り替えます。
  • フィールドには見えるプレースホルダーとアクセシブルな名前があり、すべてのアイコンボタンにはラベルと、ショートカットを示すツールチップがあります。
  • キャレット位置のメニューはaria-activedescendantを持つコンボボックスなので、移動中もフォーカスがテキストから離れることはありません。
  • アップロード、拒否、キューの変更、音声入力は、politeなライブリージョンで読み上げられます。
  • 推論量のコントロールは値のテキストを持つスライダーで、矢印キーとHome、Endでも引き続き操作できます。
  • タッチ画面では入力欄が16px以上になるため、フォーカス時にiOSがズームすることはありません。
  • モーション軽減時は、高さの変化、波形、推論量の塗りは、アニメーションせずに落ち着きます。

使用技術

Prompt Input を構成する無料のHextaUIコンポーネントです。それぞれ単独でインストールできます。

コード

10 個のファイルを components/blocks/prompt-input に追加しました。