🌿

masta + assistant-uiでclaude artifact風の機能を作成する方法(わかる人向け)

に公開

MastraとAssistant-uiでClaudeのArtifact風の機能を作ると起動しているかという部分での解説をしてほしいという要望があったので、かなりざっくり書いています。

assistant-uiとは

Assistant-uiは、AIチャットUI構築用のオープンソースTypeScript/Reactライブラリです。
簡単にチャット風の画面が作れるものになります。

Vercel AI SDK、LangGraph、Mastraと連携可能で、shadcn/ui風のcomposableなprimitiveでストリーミングやアクセシビリティを標準サポートしています。

こう言ったものが、ある程度簡単に作れるUIライブラリです。

assistant-ui公式のサンプルコード

以下に掲載されています。

https://github.com/assistant-ui/assistant-ui/tree/15e0e3372499fba90ac0c709c0ad05c7a086ced8/apps/docs/components/example/artifacts

大まかな流れ

  1. artifact用のツールをmastraおよびLLMを呼び出すbackend側に用意しておきます。

  2. ユーザーのメッセージをMastra経由でLLMに送信し、LLMが「HTMLを表示すべき」と判断するとartifactsツールを呼び出します。

  3. フロントエンドはストリーム応答内のtool-callを検出し、ToolUIコンポーネントでボタンを表示。

tool-uiはこんな感じ

tool-callの結果を表示するUIコンポーネントは、assistant-uiでは 「Tool UI」 と呼ばれています。
MCPとかtoolの呼び出しをしたときにその結果をビジュアルに表示してくれるやつ。

これを使ってArtifact風のデザインをします

  1. ユーザーがクリックするとカスタムイベント(show-artifact)が発火し、ArtifactsViewがiframeでHTMLをレンダリングする。ソース表示時はsugar-highでハイライト表示。

という風な形にしてます。

フロントエンド ↔ Mastra 間のシーケンス

┌─────────────┐     ┌─────────────────┐     ┌──────────────┐     ┌─────────────┐
│   ユーザー   │     │   Frontend      │     │   Mastra     │     │   LLM API   │
│             │     │ (React/Next.js) │     │              │     │ (OpenAI等)  │
│             │     │                 │     │              │     │             │
└──────┬──────┘     └────────┬────────┘     └──────┬───────┘     └──────┬──────┘
       │                     │                     │                    │
       │ 1. メッセージ入力    │                     │                    │
       │────────────────────>│                     │                    │
       │                     │                     │                    │
       │                     │ 2. メッセージ変換    │                    │
       │                     │ convertMessages     │                    │
       │                     │ ToMastraFormat()    │                    │
       │                     │                     │                    │
       │                     │ 3. POST /api/agents │                    │
       │                     │    /{agentId}/stream│                    │
       │                     │────────────────────>│                    │
       │                     │                     │                    │
       │                     │                     │ 4. agent.stream()  │
       │                     │                     │───────────────────>│
       │                     │                     │                    │
       │                     │                     │ 5. ストリーム応答   │
       │                     │                     │<───────────────────│
       │                     │                     │  (tool_call含む)   │
       │                     │                     │                    │
       │                     │ 6. DataStreamDecoder│                    │
       │                     │<────────────────────│                    │
       │                     │  ストリームをパース  │                    │
       │                     │                     │                    │
       │                     │ 7. tool-call検出    │                    │
       │                     │  toolName='artifacts'                    │
       │                     │                     │                    │
       │ 8. ToolUI表示       │                     │                    │
       │<────────────────────│                     │                    │
       │  「クリックして表示」 │                     │                    │
       │                     │                     │                    │
       │ 9. ボタンクリック    │                     │                    │
       │────────────────────>│                     │                    │
       │                     │                     │                    │
       │                     │ 10. CustomEvent     │                    │
       │                     │ 'show-artifact'発火 │                    │
       │                     │                     │                    │
       │ 11. ArtifactsView   │                     │                    │
       │<────────────────────│                     │                    │
       │   でHTML表示(iframe)│                     │                    │
       │                     │                     │                    │

処理の詳細

1. メッセージ送信(Frontend)

ファイル: app/assistant.tsx 内の MyCustomAdapter

const MyCustomAdapter: ChatModelAdapter = {
  async *run({ messages, abortSignal }) {
    // 最新メッセージをMastra形式に変換
    const convertedMessages = convertMessagesToMastraFormat([latestMessage]);
    
    // Mastraへストリームリクエスト
    const response = await fetch(
      `http://mastraのURL:port番号/api/agents/${agentId}/stream`,
      {
        method: 'POST',
        body: JSON.stringify({
          messages: convertedMessages,
          threadId,
          resourceId,
          maxSteps
        })
      }
    );
    
    // ストリームをパースして順次yield
    const stream = response.body
      .pipeThrough(new DataStreamDecoder())
      .pipeThrough(new AssistantMessageAccumulator());
    // ...
  }
};

2. Mastra側のエージェント処理

ファイル: server/src/mastra/agents/index.ts

class CustomAgent extends Agent {
  async stream(messages, options) {
    // LLMへリクエスト(tool定義含む)
    return super.stream(messages, {
      ...options,
      experimental_generateMessageId: generateCustomMessageId
    });
  }
}

3. Artifactsツール定義

ファイル: components/artifacts-tool.tsx

export const ArtifactsTool = createTool({
  toolName: 'artifacts',
  parameters: z.object({
    code: z.string().describe('The HTML code to render')
  }),
  execute: async ({ code }) => {
    // フロントエンドで実行されるため、成功を返すのみ
    return { success: true, message: 'HTML artifact created successfully' };
  }
});

4. ToolUI表示

ファイル: components/artifacts-tool.tsx

const RenderComponent = ({ args, status }) => {
  const showArtifact = () => {
    // カスタムイベントを発火
    window.dispatchEvent(
      new CustomEvent('show-artifact', { detail: { code: args.code } })
    );
  };

  return (
    <button onClick={showArtifact}>
      HTMLアーティファクト • クリックして表示
    </button>
  );
};

5. ArtifactsView表示

ファイル: components/artifacts-view.tsx

export const ArtifactsView = ({ children }) => {
  useEffect(() => {
    const handleShowArtifact = (event) => {
      setCurrentArtifact(event.detail.code);
      setIsVisible(true);
    };
    window.addEventListener('show-artifact', handleShowArtifact);
    // ...
  }, []);

  return (
    <iframe srcDoc={artifact} sandbox="allow-scripts" />
  );
};

HTMLのプレビューと、コードの切り替え(コードのハイライトも含む)

┌─────────────────────────────────────────────┐
│  アーティファクト用のコンポーネント             │
│  ┌─────────────────────────────────────┐    │
│  │  Toggle: [Preview] [Source]         │    │
│  └─────────────────────────────────────┘    │
│                                             │
│  ┌─────────────────────────────────────┐    │
│  │                                     │    │
│  │  viewMode === 'preview'             │    │
│  │    → <iframe srcDoc={artifact} />   │    │
│  │                                     │    │
│  │  viewMode === 'source'              │    │
│  │    → <pre><code>{highlight(...)}</code>  │
│  │       ↑ sugar-high でハイライト     │    │
│  │                                     │    │
│  └─────────────────────────────────────┘    │
└─────────────────────────────────────────────┘

実現方法

1. 状態管理

const [viewMode, setViewMode] = useState<'preview' | 'source'>('preview');

2. 条件分岐による表示切り替え

{viewMode === 'preview' ? (
  // iframe でHTMLをレンダリング
  <iframe srcDoc={artifact} sandbox="allow-scripts" />
) : (
  // コードをシンタックスハイライト付きで表示
  <pre>
    <code dangerouslySetInnerHTML={{ __html: highlight(artifact) }} />
  </pre>
)}

コードハイライト

使用ライブラリ: sugar-high

import { highlight } from 'sugar-high';

呼び出し方

「HTMLで~を出力して」とか「artifactで~を出力して」みたいに指示すればartifactっぽく呼び出せます。

※ 諸事情ありスクリーンショットは掲載できないですが(個別にいってもらえればイメージは見せることが可能です)

Discussion