Artifact

モデルが作成したものを表示する、AIチャットの横のパネルです。Webページ、SVG、ドキュメント、コードがリアルタイムでストリーミングされ、その後サンドボックス化されたプレビューに切り替わります。比較して復元できるバージョン、サイズを変更できる分割、スマートフォンでのボトムシートを備えています。

Webページ、ドキュメント、ファイルなど、モデルが残す価値のあるものを作ったとき、それはチャットの中ではなく、その横にあるべきです。Artifact Workspaceはチャットとパネルを並べて配置します。アーティファクトのストリーミングが始まるとパネルが自動で開き、チャットが場所を空けながらスライドインし、サイズ変更、全幅への展開、ドラッグで閉じることもできます。スマートフォンではボトムシートになり、カードをタップしたときだけ開くので、チャットを読む操作が中断されることはありません。

モデルが書いている間、パネルには新しい行に追従するハイライト付きのコードが表示され、上にスクロールするとJump to latestボタンが出ます。WebページとSVGは、書き込みが終わったときにプレビューへ切り替わり、ストリーミングの途中では切り替わらず、それまでは最後に完了したバージョンのプレビューを表示し続けます。ドキュメントはストリーミング中もMarkdownとして描画されます。プレビューはサイトにアクセスできないサンドボックス化されたiframeで動作し、新しいバージョンが読み込まれてから入れ替わるため白いちらつきがなく、実行時エラーはモデルに送り返せるFix itアクションとともに報告されます。

小さな変更でファイル全体は書き換えません。更新ではeditsを送信でき、それぞれがClaudeのアーティファクトのように、最後に完了したバージョンに順番に適用される検索と置換です。コードは画面に残ったまま各編集へスクロールし、削除された行は取り消し線が引かれて折りたたまれ、新しいテキストは緑の色合いでタイプされ、ファイルの残りは動きません。カードには変更数とともにEditingと表示されます。テキストが見つからない、または複数見つかった編集は、そのバージョンを失敗させ、どの編集がなぜ失敗したかを示すメッセージが表示され、最後に成功したバージョンが現在のまま残ります。局所的な変更にはedits、ファイルの大部分や構造が変わる場合は全体のcontentを使ってください。

すべての更新は新しいバージョンになります。ヘッダーには変更内容とともにバージョンが並び、Show changesはDiff Reviewと同じ差分ビューで、バージョンを1つ前のものと行ごとに比較します。古いバージョンは何も削除せずに復元できます。トグルの横とカードに表示される追加数と削除数は、差分に表示される行です。新しいバージョンは常に前面に来ます。チャット内のカードはそのメッセージが作成したバージョンを示すので、古いカードをクリックするとそのバージョンが開きます。getArtifactsFromMessagesは、これらすべてをAI SDKのツール呼び出しから構築し、パネル、コードビュー、プレビューは単独でも使えます。

  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/artifact

AI SDKと使う

getArtifactsFromMessagesは、useChatのcreate_artifactとupdate_artifactのツール呼び出しを、バージョン付きのアーティファクトに変換します。各呼び出しが現れる場所にArtifactCardを置き、完了したupdate呼び出しをsetMessagesで追加して復元します。update_artifactはブラウザーで実行されるため、applyEditsは編集が一致しなかったときにモデルへ伝え、モデルが再試行できます。

"use client"

import * as React from "react"
import { useChat } from "@ai-sdk/react"
import { isToolUIPart, lastAssistantMessageIsCompleteWithToolCalls, type UIMessage } from "ai"

import { ChatAssistantMessage, ChatThread, ChatUserMessage } from "../chat-thread/chat-thread"
import { Markdown } from "../markdown/markdown"
import { PromptInput, PromptInputActions, PromptInputBody, PromptInputSubmit, PromptInputTextarea } from "../prompt-input/prompt-input"
import { ArtifactCard, ArtifactWorkspace } from "@/components/blocks/artifact/artifact"
import {
  applyEdits,
  findVersion,
  getArtifactsFromMessages,
  lastCompleteVersion,
  type ArtifactEdit,
} from "@/components/blocks/artifact/artifacts"

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

export function Assistant() {
  const latest = React.useRef<UIMessage[]>([])
  const { messages, setMessages, sendMessage, status, stop, addToolOutput } = useChat({
    sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
    onToolCall({ toolCall }) {
      if (toolCall.dynamic || toolCall.toolName !== "update_artifact") return
      const input = toolCall.input as { id?: string; content?: string; edits?: ArtifactEdit[] }
      const artifact = getArtifactsFromMessages(latest.current).find((item) => item.id === input.id)
      const earlier = artifact?.versions.filter((version) => version.id !== toolCall.toolCallId) ?? []
      const result =
        input.content === undefined
          ? applyEdits(lastCompleteVersion({ versions: earlier })?.content ?? "", input.edits ?? [])
          : null
      if (result?.error) {
        addToolOutput({ tool: "update_artifact", toolCallId: toolCall.toolCallId, state: "output-error", errorText: result.error })
      } else {
        addToolOutput({ tool: "update_artifact", toolCallId: toolCall.toolCallId, output: { ok: true } })
      }
    },
  })
  React.useLayoutEffect(() => {
    latest.current = messages
  })
  const busy = status === "submitted" || status === "streaming"
  const artifacts = React.useMemo(
    () => getArtifactsFromMessages(messages, { streaming: busy }),
    [messages, busy]
  )

  return (
    <ArtifactWorkspace
      artifacts={artifacts}
      onFix={(artifact, _version, error) =>
        sendMessage({ text: `The preview of ${artifact.title} shows an error: ${error.message}. Please fix it.` })
      }
      onRestore={(artifact, version) =>
        setMessages((current) => [
          ...current,
          {
            id: crypto.randomUUID(),
            role: "assistant",
            parts: [
              {
                type: "tool-update_artifact",
                toolCallId: crypto.randomUUID(),
                state: "output-available",
                input: {
                  id: artifact.id,
                  description: `Restored from version ${artifact.versions.indexOf(version) + 1}`,
                  content: version.content,
                },
                output: null,
              },
            ],
          },
        ])
      }
    >
      <ChatThread
        busy={busy}
        composer={
          <PromptInput status={status} onStop={stop} onSubmit={({ text }) => sendMessage({ text })}>
            <PromptInputBody>
              <PromptInputTextarea />
              <PromptInputActions>
                <PromptInputSubmit />
              </PromptInputActions>
            </PromptInputBody>
          </PromptInput>
        }
      >
        {messages.map((message, index) => {
          if (message.role === "user") {
            return <ChatUserMessage key={message.id} id={message.id} text={textOf(message)} />
          }
          const streaming = busy && index === messages.length - 1
          return (
            <ChatAssistantMessage key={message.id} id={message.id} text={textOf(message)} streaming={streaming}>
              {message.parts.map((part, position) => {
                if (part.type === "text") {
                  return <Markdown key={position} text={part.text} streaming={part.state === "streaming"} />
                }
                if (!isToolUIPart(part)) return null
                const found = findVersion(artifacts, part.toolCallId)
                return found ? (
                  <ArtifactCard key={part.toolCallId} artifactId={found.artifact.id} versionId={part.toolCallId} />
                ) : null
              })}
            </ChatAssistantMessage>
          )
        })}
      </ChatThread>
    </ArtifactWorkspace>
  )
}

サーバー上のツール

ツールは2つで十分です。1つは安定したidを持つアーティファクトを作成し、もう1つはそれを変更します。変更は、小さな変更向けの検索と置換のペアであるedits、または書き換え向けの全体のcontentのいずれかです。どちらもツール入力としてストリーミングされるため、モデルが書いている間にパネルが埋まり、または編集がその場でタイプされます。

import { streamText, tool, type ModelMessage } from "ai"
import { z } from "zod"

export const artifactTools = {
  create_artifact: tool({
    description:
      "Create a standalone artifact the user will want to keep, edit or run: a web page, an SVG, a document or a code file. Write the complete content.",
    inputSchema: z.object({
      id: z.string().describe("A short, stable kebab-case id, used to update it later"),
      title: z.string(),
      kind: z.enum(["html", "svg", "markdown", "code"]),
      language: z.string().optional().describe("For code, such as ts or python"),
      content: z.string(),
    }),
    execute: async () => ({ ok: true }),
  }),
  update_artifact: tool({
    description: [
      "Change an existing artifact. Send either edits or content, never both.",
      "Use edits for small, local changes: each find is copied exactly from the current version, including whitespace, and must appear in it exactly once. Include a few surrounding lines when a snippet could repeat. Edits apply in order, each to the result of the one before, so a later edit can find text an earlier one wrote.",
      "Use content to rewrite the whole artifact when most of it or its structure changes.",
      "If an edit fails, the result says which one and why. Retry with a longer, unique find, or send the whole content.",
    ].join("\n"),
    inputSchema: z.object({
      id: z.string(),
      description: z.string().describe("What changed, in a few words"),
      edits: z
        .array(
          z.object({
            find: z.string().describe("Text copied exactly from the current version, unique within it"),
            replace: z.string().describe("The new text. Empty to delete."),
          })
        )
        .optional(),
      content: z.string().optional().describe("The whole new content, for a rewrite"),
    }),
  }),
}

export function respond(model: Parameters<typeof streamText>[0]["model"], messages: ModelMessage[]) {
  return streamText({ model, messages, tools: artifactTools })
}

単独のパネル

共有ページなどで、チャットなしで保存済みのアーティファクトを表示します。パネルは独自にバージョン、タブ、比較の状態を保持します。

"use client"

import { ArtifactPanel } from "@/components/blocks/artifact/artifact"
import type { Artifact } from "@/components/blocks/artifact/artifacts"

export function SharedArtifact({ artifact }: { artifact: Artifact }) {
  return (
    <div className="h-dvh">
      <ArtifactPanel artifact={artifact} />
    </div>
  )
}

構造

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

パーツ説明
ArtifactWorkspaceレイアウト。チャットを子要素として配置し、広い画面ではその横に、狭い画面ではボトムシートにパネルを置きます。何を開くかを決め、新しいアーティファクトを開き、進捗を通知します。
ArtifactCardアーティファクト、またはそのバージョンの1つを開くメッセージ内のカードで、書き込み中、失敗、開いている状態を表示します。
ArtifactPanelタイトル、バージョン、タブ、アクションを備えたヘッダーと、その下のコード、変更、プレビュー、ドキュメント。
ArtifactCode, ArtifactPreviewストリーミングされるコードと差分のビュー、およびエラーカードを備えたサンドボックス化されたプレビュー。
getArtifactsFromMessages, applyEditsAI SDKのメッセージから作成と更新のツール呼び出しを読み取り、バージョン付きのアーティファクトを返します。applyEditsは同じ方法で検索と置換の編集を適用するため、モデルに成功したと伝える前に編集を確認できます。
useArtifactWorkspaceサイドバーのファイル一覧など、独自のコントロールからアーティファクトを開閉します。
プロパティ型デフォルト
artifacts会話内のすべてのアーティファクトを、順番に。通常は getArtifactsFromMessages(messages) です。
Artifact[]–
childrenチャット。通常はChatThreadです。
ReactNode–
openId制御する場合に開いているアーティファクト。artifactsにないidは閉じているものとして扱われます。
string | null–
defaultOpenId非制御の場合に、最初に開いているアーティファクト。
string | nullnull
onOpenChangeアーティファクトが開いたとき、またはパネルが閉じたときに呼ばれます。
(id: string | null) => void–
autoOpenバージョンのストリーミングが始まったとき、広い画面でアーティファクトを開きます。バージョンごとに一度だけ開き、そのストリーム中に閉じた場合は再び開かず、フォーカスも動かしません。
booleantrue
defaultPanelSize開いたときにパネルが占める幅の割合(パーセント)。チャットは最低320px、パネルは最低360pxを保ちます。区切り線をドラッグした場合は、そのセッションの間ドラッグした幅が優先されます。
number70
onRestore古いバージョンにRestoreを表示します。古い内容を新しいバージョンとして追加し、何も削除しません。
(artifact, version) => void–
onFixプレビューがエラーを投げたときに、Fix itを表示します。errorはmessageとlineを持ちます。
(artifact, version, error) => void–
actionsPublishやShareなどの、追加のヘッダーコントロール。
(artifact) => ReactNode–
プロパティ型デフォルト
artifactId開くアーティファクト。存在しない場合は何も描画しません。
string–
versionIdこのカードが表すバージョン。通常はツール呼び出しのidです。クリックするとそのバージョンが開き、カードにはどのバージョンかが表示されます。
string–

ArtifactPanel

ArtifactWorkspaceの内部で描画されます。チャットなしでアーティファクトを表示するには、直接使用してください。

プロパティ型デフォルト
artifact表示する内容。
Artifact–
versionId制御する場合に表示するバージョン。nullは最新バージョンに追従します。
string | null–
onVersionChangeバージョンが選ばれたときに呼ばれます。最新の場合はnullが渡されます。
(versionId: string | null) => void–
onClose閉じるボタンを表示し、Escapeで閉じます。
() => void–
fullscreen, onFullscreenChangeExpandとShow chatを表示し、Escapeで展開モードを終了します。
boolean, (fullscreen: boolean) => void–
onRestore, onFix, actionsワークスペースと同じです。
see ArtifactWorkspace–

Artifact

コンポーネントが読み取るデータ。

プロパティ型デフォルト
id, title安定したidと、ヘッダーとカードに表示されるタイトル。
string–
kindhtmlとsvgにはサンドボックス化されたプレビュー、markdownにはドキュメントとしての描画、codeにはコードのみの表示が使われます。
"html" | "svg" | "markdown" | "code"–
language, filenameコードのハイライトとダウンロード名。
string–
versions古いものが先頭です。statusはstreaming、complete、stopped、errorのいずれかで、noteは変更内容を示します。editsは、ターゲットを絞った更新で適用された検索と置換のペアを列挙し、contentは常に完全な結果です。
{ id, content, status?, error?, note?, edits?, createdAt?, messageId? }[]–
プロパティ型デフォルト
messagesuseChatのメッセージ。create_artifactとupdate_artifact、またはそのキャメルケース形式の名前のツール呼び出しが、バージョンになります。
UIMessage[]–
options.streaming最後のメッセージがまだ届いている途中かどうか。指定しない場合、未完了の呼び出しは停止したものとして扱われます。
booleanfalse
options.tools独自のツール名。Createはid、title、kind、language、filename、description、contentを読み取ります。Updateはid、description、およびcontentまたはeditsのいずれかを読み取ります。
{ create: string[]; update: string[] }–
update inputcontentはアーティファクトを書き換えます。editsは最後に完了したバージョンに順番に適用され、各findは必ず1回だけ一致する必要があり、そうでなければそのバージョンはどの編集がなぜ失敗したかを示すerrorとともに失敗します。両方が送られた場合は、contentが優先されます。
{ content: string } | { edits: { find: string; replace: string }[] }–
キーアクション
Enterカードでは、そのアーティファクトを開き、フォーカスをパネルのタイトルに移します。開いているカードでは、閉じます。
Esc展開モードを終了し、パネルを閉じて、フォーカスをカードに戻します。
←→区切り線にフォーカスがある間、分割のサイズを変更します。最小サイズを超えると、パネルが閉じるか、チャットが非表示になります。
←→タブにフォーカスがある間、CodeとPreviewを切り替えます。
Tabヘッダー、矢印キーでスクロールするコードまたはドキュメント領域、プレビューの順に移動します。
  • パネルは、実際の見出しを持つラベル付きのリージョンです。カードから開くとフォーカスがその見出しに移り、閉じるとフォーカスがカードに戻ります。モデルの書き込み中に自動で開く場合はフォーカスを動かさないため、コンポーザーへの入力が中断されることはありません。
  • カードはaria-pressedとaria-controlsを持つボタンで、アーティファクトが書き込み中か、失敗したか、停止したかを伝えます。1行ずつ読み上げる代わりに、書き込みの開始時とバージョンの準備完了時に、politeなステータスで通知します。
  • プレビューのiframeには、アーティファクトにちなんだタイトルが付きます。実行時エラーは、メッセージと行番号つきのalertとして表示され、適用できなかった編集も同様です。変更は色で示されるだけでなく、追加と削除として読み上げられます。
  • すべてのアイコンボタンにはラベルとツールチップがあります。開く、閉じる、展開する動作はスライドし、チャットは滑らかにリフローします。モーション軽減時は、レイアウトが短いフェードとともに即座に切り替わります。編集は、タイプされずにまとめてフェードインします。

使用技術

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

コード

12 個のファイルを components/blocks/artifact に追加しました。