Chat Thread

コンポーザーを中心にした会話全体です。回答のストリーミング中も質問は上部に固定され、サイドのチェックポイントでメッセージ間を移動でき、すべての返信をコピー、編集、再試行、評価し、バージョン間で切り替えられます。返信はコードブロック、テーブル、引用を含むMarkdownとして描画され、思考、ツール呼び出し、Prompt Inputもそのまま組み込めます。

Chat Threadは、コンポーザーを中心とした会話です。自分のメッセージ、モデルの返信、それらに対してできることを扱います。最初は空の状態とコンポーザーが中央に表示され、メッセージができるとコンポーザーを再マウントせずに下部へドッキングするため、フォーカスと下書きのテキストはそのまま残ります。

送信したばかりのメッセージは、その回答が下にストリーミングされる間、上部に移動し、ビューはスクロールして離れるまで新しいテキストに追従します。右側のチェックポイントのレールでは、ホバー時のプレビューつきで自分のメッセージ間をジャンプできます。古い履歴は、ビューが動くことなく上から読み込まれます。

メッセージにホバーすると、コピー、編集、再試行ができます。編集と再試行はバージョンとなり、切り替えられるため、何も失われません。失敗した返信にはTry againが表示され、停止した返信はその旨が示され、サムズアップとサムズダウンはワンクリックです。各返信の内部には、他のブロックのReasoning、ToolCalls、Markdownがそのまま組み込めます。

  1. Proレジストリをcomponents.jsonに追加する

    components.json
    {
      "registries": {
        "@hextaui-pro": {
          "url": "https://hextaui.com/r/pro/{name}.json",
          "headers": {
            "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
          }
        }
      }
    }
  2. トークンを追加する

    アカウントページでトークンを作成し、.env.local に HEXTAUI_PRO_TOKEN として設定してください。

  3. ブロックを追加する

    pnpm dlx shadcn@latest add @hextaui-pro/chat-thread

AI SDKと使う

useChatのメッセージをスレッドに対応付けます。ReasoningパーツはReasoningに、ツールパーツはToolCallsに、テキストはMarkdownに渡します。最新のユーザーメッセージをピン留めし、Try againにはregenerateを使い、編集はその時点から再送信します。

"use client"

import { useChat } from "@ai-sdk/react"
import { getToolName, isToolUIPart, type UIMessage } from "ai"

import { PromptInput, PromptInputActions, PromptInputBody, PromptInputSubmit, PromptInputTextarea } from "../prompt-input/prompt-input"
import { Reasoning } from "../thinking/reasoning"
import { ToolCalls } from "../tool-calls/tool-group"
import { getToolPartStatus } from "../tool-calls/tool-part"
import { Markdown } from "../markdown/markdown"
import { ChatAssistantMessage, ChatThread, ChatUserMessage } from "@/components/blocks/chat-thread/chat-thread"

function textOf(message: UIMessage) {
  return message.parts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("")
}

export function Chat() {
  const { messages, status, sendMessage, regenerate, stop, setMessages } = useChat()
  const busy = status === "submitted" || status === "streaming"
  const lastUser = messages.findLastIndex((message) => message.role === "user")

  return (
    <ChatThread
      busy={busy}
      composer={
        <PromptInput status={status} onStop={stop} onSubmit={({ text }) => sendMessage({ text })}>
          <PromptInputBody>
            <PromptInputTextarea />
            <PromptInputActions>
              <PromptInputSubmit />
            </PromptInputActions>
          </PromptInputBody>
        </PromptInput>
      }
    >
      {messages.map((message, index) => {
        const last = index === messages.length - 1
        if (message.role === "user") {
          return (
            <ChatUserMessage
              key={message.id}
              id={message.id}
              text={textOf(message)}
              pin={index === lastUser}
              onEdit={(text) => {
                setMessages(messages.slice(0, index))
                sendMessage({ text })
              }}
            />
          )
        }
        const reasoning = message.parts.flatMap((part) => (part.type === "reasoning" ? [part.text] : [])).join("\n\n")
        const tools = message.parts.filter(isToolUIPart)
        const streaming = last && busy
        return (
          <ChatAssistantMessage
            key={message.id}
            id={message.id}
            text={textOf(message)}
            streaming={streaming}
            last={last}
            error={last && status === "error" ? "Something went wrong." : null}
            onRetry={last ? () => regenerate() : undefined}
          >
            {reasoning ? <Reasoning text={reasoning} streaming={streaming && !textOf(message)} /> : null}
            {tools.length > 0 ? (
              <ToolCalls
                calls={tools.map((part) => ({
                  id: part.toolCallId,
                  name: getToolName(part),
                  status: getToolPartStatus(part, { stopped: !streaming }),
                }))}
              />
            ) : null}
            <Markdown
              text={textOf(message)}
              streaming={streaming}
              sources={message.parts.flatMap((part) =>
                part.type === "source-url"
                  ? [{ id: part.sourceId, url: part.url, title: part.title ?? part.url }]
                  : []
              )}
            />
          </ChatAssistantMessage>
        )
      })}
    </ChatThread>
  )
}

構造

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

パーツ説明
ChatThreadフレーム。スクローラー、チェックポイント、空の状態、コンポーザーのスロット。
ChatUserMessage自分のメッセージ。添付ファイル、バブル、時刻、バージョン、コピー、編集。
ChatAssistantMessage返信。内側にReasoning、ToolCalls、Markdownを置きます。アクション、エラー、停止の状態が追加されます。
ChatDividerメッセージのグループの間にある、ラベル付きの線。
ChatAttachments, ChatAction, ChatCopy, ChatBranchSwitch, ChatEditorメッセージが使う部品。カスタムレイアウト向けにエクスポートされています。
プロパティ型デフォルト
childrenメッセージと区切り線を、順番に。
ReactNode–
composer通常はPrompt Input。空のときは中央に、それ以外は下部にドッキングされます。
ReactNode–
emptyメッセージがないとき、コンポーザーの上に表示されます。
ReactNode–
busy返信のストリーミング中、支援技術に対して会話をビジー状態として示します。
booleanfalse
hasOlder先頭に「Load earlier messages」を表示します。
booleanfalse
loadingOlderそのボタンにスピナーを表示します。falseになるとスクロール位置が復元されます。
booleanfalse
onLoadOlder古いメッセージはここに先頭追加します。
() => void–
プロパティ型デフォルト
id安定したid。ビューが跳ねないよう、バージョンをまたいで同じ値にしてください。
string–
textメッセージ。チェックポイントのプレビューにも使われます。
string–
pinこのメッセージが表示されたとき、先頭までスクロールします。送信したばかりのメッセージに設定してください。
booleanfalse
files{ id, name, size?, type?, url? }。urlを持つ画像はサムネイルとして表示されます。
ChatFile[]–
timeホバー時に、閲覧者のロケールで表示されます。
Date–
branchバージョンの矢印用の { index, count, onChange }。
ChatBranch–
onEditEditを表示します。内容が変更されたとき、新しいテキストとともに呼ばれます。
(text: string) => void–
attachmentsバブルの上のカスタムコンテンツ。
ReactNode–
プロパティ型デフォルト
id安定したid。
string–
childrenReasoning、ToolCalls、Markdown、またはその他の任意の内容。
ReactNode–
textCopy用のプレーンテキスト。
string–
streaming返信が完了するまでアクションを非表示にします。
booleanfalse
lastアクションをホバー時ではなく、常に表示します。
booleanfalse
metaアクション行の右側。モデルや推論量など。
ReactNode–
branch再試行用のバージョンの矢印。
ChatBranch–
feedback現在の評価。
"up" | "down" | nullnull
onFeedbackサムズを表示します。評価が取り消されたときは、nullとともに呼ばれます。
(feedback) => void–
onRetryTry againを表示します。
() => void–
errorアクションの代わりに、Try again付きでメッセージを表示します。
string | null–
stopped途中で停止された返信を示します。
booleanfalse
プロパティ型デフォルト
childrenTodayや「Switched to Nova 3 Max」などのラベル。
ReactNode–
キーアクション
Tabメッセージのアクション間を移動します。ホバー時だけでなく、フォーカス時にも表示されます。
Enter編集したメッセージを送信します。
ShiftEnter編集中に行を追加します。
Esc編集をキャンセルします。
↑↓PgUpPgDnフォーカスがあるとき、会話をスクロールします。
  • 会話は、logを保持するラベル付きのリージョンです。新しいメッセージは届くたびに読み上げられ、返信のストリーミング中はリージョンがビジー状態として示されます。
  • すべてのアイコンアクションにはラベルとツールチップがあります。サムズアップとサムズダウンはaria-pressedを使い、Copyとフィードバックはpoliteなライブリージョンで確認され、エラーにはrole=alertが使われます。
  • チェックポイントは「Message 2: …」という名前のボタンからなるナビゲーションのランドマークで、現在のものにはaria-currentが付きます。
  • バージョンの矢印は「Version 1 of 2」という名前のグループです。
  • エディターは自動でフォーカスし、カーソルは末尾に置かれ、Cancelでメッセージに戻ります。入力メソッドで変換中は、Enterが待機します。
  • フォーカスリングがスクローラーで切り取られることはなく、タッチ画面ではアクションが常に表示されます。

使用技術

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

コード

2 個のファイルを components/blocks/chat-thread に追加しました。