Artifact

Das Panel neben einem KI-Chat, das zeigt, was das Modell erzeugt hat. Webseiten, SVGs, Dokumente und Code streamen live ein und wechseln dann zu einer Sandbox-Vorschau, mit Versionen zum Vergleichen und Wiederherstellen, einem anpassbaren Split und einem Bottom Sheet auf Smartphones.

Wenn ein Modell etwas Bewahrenswertes erzeugt, etwa eine Webseite, ein Dokument oder eine Datei, gehört es neben den Chat statt hinein. Artifact Workspace stellt deinen Chat und ein Panel nebeneinander. Das Panel öffnet sich von selbst, sobald ein Artifact zu streamen beginnt, gleitet herein, während der Chat Platz macht, und lässt sich in der Größe ändern, auf die volle Breite erweitern oder zuziehen. Auf Smartphones wird es zu einem Bottom Sheet, das sich nur per Tipp auf eine Card öffnet, sodass das Lesen des Chats nie unterbrochen wird.

Während das Modell schreibt, zeigt das Panel hervorgehobenen Code, der neuen Zeilen folgt, mit einem Button Jump to latest, wenn du nach oben scrollst. Webseiten und SVGs wechseln zur Vorschau, wenn das Schreiben endet, nie mitten im Stream, und die Vorschau zeigt bis dahin die zuletzt fertige Version. Dokumente werden beim Streamen als Markdown gerendert. Vorschauen laufen in einem Sandbox-iframe ohne Zugriff auf deine Website, werden erst ausgetauscht, nachdem die neue Version geladen ist, sodass kein weißes Aufblitzen entsteht, und melden Laufzeitfehler mit einer Fix-it-Aktion, die du ans Modell zurückschicken kannst.

Kleine Änderungen schreiben die Datei nicht neu. Ein Update kann edits senden, jeweils ein Suchen und Ersetzen, der Reihe nach auf die zuletzt fertige Version angewendet, wie bei Claudes Artifacts. Der Code bleibt auf dem Bildschirm und scrollt zu jeder Änderung, entfernte Zeilen werden durchgestrichen und falten sich weg, neuer Text wird mit grüner Tönung getippt, und der Rest der Datei bleibt ruhig. Die Card sagt Editing mit der Anzahl der Änderungen. Eine Änderung, deren Text nicht oder mehrfach gefunden wird, lässt diese Version mit einer Meldung fehlschlagen, die sagt, welche Änderung und warum, und die letzte gute Version bleibt aktuell. Nutze edits für lokale Änderungen und den gesamten content, wenn sich der Großteil der Datei oder ihre Struktur ändert.

Jedes Update ist eine neue Version. Der Header listet sie mit den Änderungen auf, Show changes vergleicht eine Version zeilenweise mit der vorherigen in derselben Diff-Ansicht wie Diff Review, und ältere Versionen lassen sich wiederherstellen, ohne etwas zu löschen. Die Zahlen für hinzugefügte und entfernte Zeilen neben dem Schalter und auf der Card sind die Zeilen, die der Diff zeigt. Eine neue Version kommt immer nach vorn. Cards im Chat zeigen die Version, die ihre Nachricht erzeugt hat, sodass ein Klick auf eine alte Card diese Version öffnet. getArtifactsFromMessages baut all das aus AI-SDK-Tool-Aufrufen auf, und Panel, Codeansicht und Vorschau funktionieren auch einzeln.

  1. Die Pro-Registry zu components.json hinzufügen

    components.json
    {
      "registries": {
        "@hextaui-pro": {
          "url": "https://hextaui.com/r/pro/{name}.json",
          "headers": {
            "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
          }
        }
      }
    }
  2. Token hinzufügen

    Erstelle auf deiner Kontoseite einen Token und trage ihn in .env.local als HEXTAUI_PRO_TOKEN ein.

  3. Den Block hinzufügen

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

Mit dem AI SDK

getArtifactsFromMessages macht aus create_artifact- und update_artifact-Tool-Aufrufen von useChat Artifacts mit Versionen. Setze eine ArtifactCard dorthin, wo jeder Aufruf erscheint, und stelle wieder her, indem du mit setMessages einen fertigen update-Aufruf hinzufügst. update_artifact läuft im Browser, sodass applyEdits dem Modell sagen kann, wenn eine Änderung nicht gepasst hat, und es erneut versuchen kann.

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

Die Tools auf deinem Server

Zwei Tools genügen: Eines erstellt ein Artifact mit einer stabilen ID, das andere ändert es. Eine Änderung ist entweder edits, Suchen-und-Ersetzen-Paare für kleine Änderungen, oder der gesamte content für ein Neuschreiben. Beide streamen als Tool-Eingabe, sodass sich das Panel füllt oder die Änderung an Ort und Stelle getippt wird, während das Modell schreibt.

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

Ein Panel allein

Zeige ein gespeichertes Artifact ohne Chat, zum Beispiel auf einer Share-Seite. Das Panel behält seine eigene Version, seinen Tab und seinen Vergleichszustand.

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

Aufbau

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

PartBeschreibung
ArtifactWorkspaceDas Layout: dein Chat als children, das Panel daneben auf breiten Bildschirmen und in einem Bottom Sheet auf schmalen. Es entscheidet, was geöffnet ist, öffnet neue Artifacts und sagt den Fortschritt an.
ArtifactCardDie Card in einer Nachricht, die ein Artifact oder eine seiner Versionen öffnet und anzeigt, ob es gerade geschrieben wird, fehlgeschlagen oder geöffnet ist.
ArtifactPanelDer Header mit Titel, Versionen, Tabs und Aktionen, darunter der Code, die Änderungen, die Vorschau oder das Dokument.
ArtifactCode, ArtifactPreviewDie streamende Code- und Diff-Ansicht sowie die Sandbox-Vorschau mit ihrer Fehlerkarte.
getArtifactsFromMessages, applyEditsLiest create- und update-Tool-Aufrufe aus AI-SDK-Nachrichten und gibt Artifacts mit ihren Versionen zurück. applyEdits wendet Suchen-und-Ersetzen-Änderungen auf dieselbe Weise an, sodass du eine Änderung prüfen kannst, bevor du dem Modell sagst, dass sie funktioniert hat.
useArtifactWorkspaceÖffnet und schließt Artifacts über deine eigenen Steuerelemente, zum Beispiel eine Dateiliste in einer Seitenleiste.
PropTypStandard
artifactsJedes Artifact der Unterhaltung, der Reihe nach. Üblicherweise getArtifactsFromMessages(messages).
Artifact[]–
childrenDer Chat, meist ein ChatThread.
ReactNode–
openIdDas geöffnete Artifact, wenn du es kontrollierst. Eine ID, die nicht in artifacts enthalten ist, gilt als geschlossen.
string | null–
defaultOpenIdDas zuerst geöffnete Artifact, im unkontrollierten Modus.
string | nullnull
onOpenChangeWird aufgerufen, wenn ein Artifact geöffnet oder das Panel geschlossen wird.
(id: string | null) => void–
autoOpenÖffnet auf breiten Bildschirmen ein Artifact, wenn eine Version zu streamen beginnt. Es öffnet sich einmal pro Version, nie, nachdem du es während dieses Streams geschlossen hast, und verschiebt nie den Fokus.
booleantrue
defaultPanelSizeDer Breitenanteil des Panels in Prozent beim Öffnen. Der Chat behält mindestens 320 px und das Panel mindestens 360 px. Das Ziehen des Trenners hat für den Rest der Sitzung Vorrang.
number70
onRestoreZeigt Restore bei älteren Versionen. Fügt den alten Inhalt als neue Version hinzu; nichts wird gelöscht.
(artifact, version) => void–
onFixZeigt Fix it, wenn die Vorschau einen Fehler wirft. error hat message und line.
(artifact, version, error) => void–
actionsZusätzliche Header-Steuerelemente, etwa Publish oder Share.
(artifact) => ReactNode–
PropTypStandard
artifactIdDas Artifact, das geöffnet werden soll. Rendert nichts, wenn es nicht existiert.
string–
versionIdDie Version, für die diese Card steht, meist die Tool-Call-ID. Ein Klick öffnet diese Version, und die Card sagt, welche Version es ist.
string–

ArtifactPanel

Wird für dich innerhalb von ArtifactWorkspace gerendert. Verwende es direkt, um ein Artifact ohne Chat zu zeigen.

PropTypStandard
artifactWas angezeigt werden soll.
Artifact–
versionIdDie angezeigte Version, wenn du sie kontrollierst. null folgt der neuesten Version.
string | null–
onVersionChangeWird aufgerufen, wenn jemand eine Version wählt, mit null für die neueste.
(versionId: string | null) => void–
onCloseZeigt den Schließen-Button und schließt bei Escape.
() => void–
fullscreen, onFullscreenChangeZeigt Expand und Show chat und verlässt bei Escape den erweiterten Modus.
boolean, (fullscreen: boolean) => void–
onRestore, onFix, actionsDasselbe wie beim Workspace.
see ArtifactWorkspace–

Artifact

Die Daten, die die Komponenten lesen.

PropTypStandard
id, titleEine stabile ID und der Titel, der in Header und Card angezeigt wird.
string–
kindhtml und svg erhalten eine Sandbox-Vorschau, markdown wird als Dokument gerendert, code zeigt nur Code.
"html" | "svg" | "markdown" | "code"–
language, filenameHervorhebung und Download-Name für Code.
string–
versionsÄlteste zuerst. status ist streaming, complete, stopped oder error; note sagt, was sich geändert hat. edits listet die Suchen-und-Ersetzen-Paare auf, die ein gezieltes Update angewendet hat; content ist immer das vollständige Ergebnis.
{ id, content, status?, error?, note?, edits?, createdAt?, messageId? }[]–
PropTypStandard
messagesuseChat-Nachrichten. Tool-Aufrufe namens create_artifact und update_artifact oder ihre camelCase-Formen werden zu Versionen.
UIMessage[]–
options.streamingOb die letzte Nachricht noch eintrifft. Ohne sie gelten unfertige Aufrufe als gestoppt.
booleanfalse
options.toolsDeine eigenen Tool-Namen. Create liest id, title, kind, language, filename, description und content. Update liest id, description und entweder content oder edits.
{ create: string[]; update: string[] }–
update inputcontent schreibt das Artifact neu. edits werden der Reihe nach auf die zuletzt fertige Version angewendet; jedes find muss genau einmal passen, sonst schlägt die Version mit error fehl und nennt, welche Änderung und warum. Werden beide gesendet, gewinnt content.
{ content: string } | { edits: { find: string; replace: string }[] }–
TasteAktion
EnterÖffnet auf einer Card ihr Artifact und setzt den Fokus auf den Panel-Titel. Schließt eine geöffnete Card.
EscVerlässt den erweiterten Modus, schließt dann das Panel und gibt den Fokus an die Card zurück.
←→Ändert die Größe des Splits, solange der Trenner den Fokus hat. Unterhalb der kleinsten Größe schließt sich das Panel oder der Chat wird ausgeblendet.
←→Wechselt zwischen Code und Preview, solange ein Tab den Fokus hat.
TabWechselt durch den Header, den Code- oder Dokumentbereich, der mit den Pfeiltasten scrollt, und die Vorschau.
  • Das Panel ist eine beschriftete Region mit einer echten Überschrift. Beim Öffnen über eine Card springt der Fokus auf diese Überschrift, beim Schließen zurück zur Card. Öffnet es sich von selbst, während das Modell schreibt, wird der Fokus nie verschoben, sodass das Tippen im Composer nie unterbrochen wird.
  • Cards sind Buttons mit aria-pressed und aria-controls und sagen, ob das Artifact gerade geschrieben wird, fehlgeschlagen oder gestoppt ist. Ein höflicher Status meldet, wann das Schreiben beginnt und eine Version fertig ist, statt jede Zeile vorzulesen.
  • Das Vorschau-iframe trägt den Titel des Artifacts. Laufzeitfehler erscheinen als Alert mit Meldung und Zeile, ebenso eine Änderung, die nicht angewendet werden konnte. Änderungen werden als hinzugefügt und entfernt angesagt, nicht nur farblich gezeigt.
  • Jeder Icon-Button hat ein Label und einen Tooltip. Öffnen, Schließen und Erweitern gleiten, während der Chat sanft umfließt, und bei reduzierter Bewegung ändert sich das Layout sofort mit kurzem Fade. Änderungen blenden als Ganzes ein, statt getippt zu werden.

Gebaut mit

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

Code

12 Dateien, hinzugefügt zu components/blocks/artifact.