Artifact

Le panneau à côté d’un chat IA qui montre ce que le modèle a produit. Pages web, SVG, documents et code arrivent en direct, puis passent à un aperçu isolé en sandbox, avec des versions à comparer et à restaurer, une séparation redimensionnable et une bottom sheet sur mobile.

Quand un modèle produit quelque chose qui mérite d’être conservé, comme une page web, un document ou un fichier, cela a sa place à côté du chat plutôt qu’à l’intérieur. Artifact Workspace place votre chat et un panneau côte à côte. Le panneau s’ouvre tout seul quand un artifact commence à arriver en streaming, glisse pendant que le chat lui fait de la place, et peut être redimensionné, agrandi sur toute la largeur ou fermé en le faisant glisser. Sur mobile, il devient une bottom sheet qui ne s’ouvre qu’au toucher d’une carte, donc la lecture du chat n’est jamais interrompue.

Pendant que le modèle écrit, le panneau affiche du code coloré qui suit les nouvelles lignes, avec un bouton Jump to latest si vous remontez. Les pages web et les SVG passent à leur aperçu à la fin de l’écriture, jamais en cours de streaming, et l’aperçu continue d’afficher la dernière version terminée d’ici là. Les documents s’affichent en Markdown pendant le streaming. Les aperçus s’exécutent dans une iframe en sandbox sans accès à votre site, ne s’échangent qu’une fois la nouvelle version chargée pour éviter tout flash blanc, et signalent les erreurs d’exécution avec une action Fix it que vous pouvez renvoyer au modèle.

Les petits changements ne réécrivent pas le fichier. Une mise à jour peut envoyer des edits, chacun étant un rechercher-remplacer appliqué dans l’ordre à la dernière version terminée, comme les artifacts de Claude. Le code reste à l’écran et défile jusqu’à chaque modification, les lignes supprimées sont barrées puis se replient, le nouveau texte se tape avec une teinte verte, et le reste du fichier ne bouge pas. La carte indique Editing avec le nombre de changements. Une modification dont le texte est introuvable, ou trouvé plusieurs fois, fait échouer cette version avec un message indiquant quelle modification et pourquoi, et la dernière bonne version reste courante. Utilisez edits pour les changements locaux et le content complet quand la majeure partie du fichier ou sa structure change.

Chaque mise à jour est une nouvelle version. L’en-tête les liste avec ce qui a changé, Show changes compare une version à la précédente ligne par ligne avec la même vue de diff que Diff Review, et les anciennes versions peuvent être restaurées sans rien supprimer. Les compteurs d’ajouts et de suppressions à côté du bouton et sur la carte correspondent aux lignes que montre le diff. Une nouvelle version passe toujours au premier plan. Les cartes du chat affichent la version produite par leur message, donc cliquer sur une ancienne carte ouvre cette version. getArtifactsFromMessages construit tout cela à partir des appels d’outils d’AI SDK, et le panneau, la vue de code et l’aperçu fonctionnent aussi seuls.

  1. Ajouter le registre Pro à components.json

    components.json
    {
      "registries": {
        "@hextaui-pro": {
          "url": "https://hextaui.com/r/pro/{name}.json",
          "headers": {
            "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
          }
        }
      }
    }
  2. Ajouter votre token

    Créez un token sur votre page de compte et placez-le dans .env.local sous le nom HEXTAUI_PRO_TOKEN.

  3. Ajouter le block

    pnpm dlx shadcn@latest add @hextaui-pro/artifact

Avec AI SDK

getArtifactsFromMessages transforme les appels d’outils create_artifact et update_artifact de useChat en artifacts avec versions. Placez un ArtifactCard là où chaque appel apparaît, et restaurez en ajoutant un appel update terminé avec setMessages. update_artifact s’exécute dans le navigateur, donc applyEdits peut signaler au modèle qu’une modification ne correspondait pas pour qu’il réessaie.

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

Les outils sur votre serveur

Deux outils suffisent : l’un crée un artifact avec un id stable, l’autre le modifie. Une modification est soit des edits, des paires rechercher-remplacer pour les petits changements, soit le content entier pour une réécriture. Les deux arrivent en streaming comme entrée d’outil, si bien que le panneau se remplit, ou que la modification se tape sur place, pendant que le modèle écrit.

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

Un panneau seul

Affiche un artifact enregistré sans chat, par exemple sur une page de partage. Le panneau garde sa propre version, son onglet et son état de comparaison.

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

Anatomie

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

PartieDescription
ArtifactWorkspaceLa mise en page : votre chat en children, le panneau à côté sur grand écran et dans une bottom sheet sur petit écran. Elle décide de ce qui est ouvert, ouvre les nouveaux artifacts et annonce la progression.
ArtifactCardLa carte d’un message qui ouvre un artifact, ou l’une de ses versions, et qui indique s’il est en cours d’écriture, en échec ou ouvert.
ArtifactPanelL’en-tête avec titre, versions, onglets et actions, et en dessous le code, les changements, l’aperçu ou le document.
ArtifactCode, ArtifactPreviewLa vue de code et de diff en streaming, et l’aperçu en sandbox avec sa carte d’erreur.
getArtifactsFromMessages, applyEditsLit les appels d’outils de création et de mise à jour dans les messages AI SDK et renvoie les artifacts avec leurs versions. applyEdits applique de la même façon les modifications rechercher-remplacer, ce qui permet de vérifier une modification avant de dire au modèle qu’elle a fonctionné.
useArtifactWorkspaceOuvre et ferme des artifacts depuis vos propres contrôles, par exemple une liste de fichiers dans une barre latérale.
PropTypePar défaut
artifactsChaque artifact de la conversation, dans l’ordre. En général getArtifactsFromMessages(messages).
Artifact[]–
childrenLe chat, généralement un ChatThread.
ReactNode–
openIdL’artifact ouvert, lorsque vous le contrôlez. Un id absent de artifacts compte comme fermé.
string | null–
defaultOpenIdL’artifact ouvert au départ, en mode non contrôlé.
string | nullnull
onOpenChangeAppelé à l’ouverture d’un artifact ou à la fermeture du panneau.
(id: string | null) => void–
autoOpenOuvre un artifact sur grand écran quand une version commence à arriver en streaming. Il s’ouvre une fois par version, jamais après que vous l’avez fermé pendant ce streaming, et ne déplace jamais le focus.
booleantrue
defaultPanelSizePart de la largeur occupée par le panneau, en pourcentage, à son ouverture. Le chat garde au moins 320px et le panneau au moins 360px. Faire glisser le séparateur l’emporte pour le reste de la session.
number70
onRestoreAffiche Restore sur les anciennes versions. Ajoute l’ancien contenu comme nouvelle version ; rien n’est supprimé.
(artifact, version) => void–
onFixAffiche Fix it quand l’aperçu lève une erreur. error contient message et line.
(artifact, version, error) => void–
actionsContrôles d’en-tête supplémentaires, comme Publish ou Share.
(artifact) => ReactNode–
PropTypePar défaut
artifactIdL’artifact à ouvrir. N’affiche rien s’il n’existe pas.
string–
versionIdLa version que représente cette carte, généralement l’id de l’appel d’outil. Un clic ouvre cette version, et la carte indique de laquelle il s’agit.
string–

ArtifactPanel

Rendu pour vous dans ArtifactWorkspace. Utilisez-le directement pour afficher un artifact sans chat.

PropTypePar défaut
artifactCe qu’il faut afficher.
Artifact–
versionIdLa version affichée, lorsque vous la contrôlez. null suit la version la plus récente.
string | null–
onVersionChangeAppelé quand quelqu’un choisit une version, avec null pour la plus récente.
(versionId: string | null) => void–
onCloseAffiche le bouton de fermeture et ferme avec Escape.
() => void–
fullscreen, onFullscreenChangeAffiche Expand et Show chat, et quitte le mode agrandi avec Escape.
boolean, (fullscreen: boolean) => void–
onRestore, onFix, actionsIdentique à celui du workspace.
see ArtifactWorkspace–

Artifact

Les données lues par les composants.

PropTypePar défaut
id, titleUn identifiant stable et le titre affiché dans l’en-tête et la carte.
string–
kindhtml et svg obtiennent un aperçu en sandbox, markdown s’affiche comme un document, code n’affiche que le code.
"html" | "svg" | "markdown" | "code"–
language, filenameColoration et nom de téléchargement pour le code.
string–
versionsLes plus anciennes d’abord. status vaut streaming, complete, stopped ou error ; note indique ce qui a changé. edits liste les paires rechercher-remplacer qu’une mise à jour ciblée a appliquées ; content est toujours le résultat complet.
{ id, content, status?, error?, note?, edits?, createdAt?, messageId? }[]–
PropTypePar défaut
messagesMessages de useChat. Les appels d’outils nommés create_artifact et update_artifact, ou leurs formes en camelCase, deviennent des versions.
UIMessage[]–
options.streamingIndique si le dernier message est encore en cours d’arrivée. Sans cela, les appels inachevés comptent comme interrompus.
booleanfalse
options.toolsLes noms de vos propres outils. Create lit id, title, kind, language, filename, description et content. Update lit id, description et soit content, soit edits.
{ create: string[]; update: string[] }–
update inputcontent réécrit l’artifact. edits s’appliquent dans l’ordre à la dernière version terminée ; chaque find doit correspondre exactement une fois, sinon la version échoue avec error indiquant quelle modification et pourquoi. Si les deux sont envoyés, content l’emporte.
{ content: string } | { edits: { find: string; replace: string }[] }–
ToucheAction
EnterSur une carte, ouvre son artifact et place le focus sur le titre du panneau. Sur une carte ouverte, la ferme.
EscQuitte le mode agrandi, puis ferme le panneau et rend le focus à la carte.
←→Redimensionne la séparation tant que le séparateur a le focus. Au-delà de la plus petite taille, le panneau se ferme ou le chat se masque.
←→Bascule entre Code et Preview quand un onglet a le focus.
TabParcourt l’en-tête, la zone de code ou de document, qui défile avec les flèches du clavier, et l’aperçu.
  • Le panneau est une région libellée avec un vrai titre. L’ouvrir depuis une carte place le focus sur ce titre, et le fermer rend le focus à la carte. Quand il s’ouvre tout seul pendant que le modèle écrit, il ne déplace jamais le focus, donc la saisie dans le champ de message n’est jamais interrompue.
  • Les cartes sont des boutons avec aria-pressed et aria-controls, et indiquent si l’artifact est en cours d’écriture, en échec ou interrompu. Un statut poli annonce le début de l’écriture et la disponibilité d’une version, au lieu de lire chaque ligne.
  • L’iframe d’aperçu porte le titre de l’artifact. Les erreurs d’exécution apparaissent sous forme d’alerte avec le message et la ligne, comme une modification qui n’a pas pu être appliquée. Les changements sont annoncés comme ajoutés et supprimés, pas seulement montrés en couleur.
  • Chaque bouton d’icône a un libellé et une infobulle. L’ouverture, la fermeture et l’agrandissement glissent pendant que le chat se réagence en douceur ; avec réduction des animations, la mise en page change d’un coup avec un court fondu. Les modifications apparaissent en fondu d’un bloc au lieu d’être tapées.

Construit avec

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

Code

12 fichiers, ajoutés à components/blocks/artifact.