Next.jsでClaudeのArtifactsのような機能を実装してみた

## はじめに
Next.jsでClaudeのArtifactsのような機能を実装してみました。

## 開発環境
- **OS**: Windows11
- **言語**: TypeScript
- **フレームワーク**: Next.js 14.2.4
- **ライブラリ**:
  - axios
  - @anthropic-ai/sdk

## 実装
### 1. プロジェクトのセットアップ
まず、Next.jsプロジェクトを作成し、必要なライブラリをインストールします。

```bash
npx create-next-app@latest artifacts-app --typescript
cd artifacts-app
npm install axios @anthropic-ai/sdk
```

### 2. 環境変数の設定
Anthropic APIキーを設定するために、`.env.local`ファイルを作成します。キーはご自身のものを入れてください。

```.env.local
ANTHROPIC_API_KEY=your_api_key_here
```

### 3. APIルートの作成
`app/page/api/chat/route.ts`ファイルを作成し、Claudeとの通信を処理するAPIエンドポイントを実装します。

```typescript
import { NextRequest, NextResponse } from 'next/server'
import Anthropic from '@anthropic-ai/sdk'

const anthropic = new Anthropic({
  apiKey: process.env.ANTHROPIC_API_KEY,
})

export async function POST(request: NextRequest) {
  try {
    const { messages } = await request.json()

    messages[messages.length - 1].content += "(システム指示：Webページのコードを出力する場合は、静的なHTML5とCSSのみで完結できるコードを生成してください。JavaScriptは使用せず、単純なHTML5とCSSで実装してください。コードは一括で間に余計な文言を出力せずそのままHTMLファイルに張り付けて表示できる形にしてください。なお、コードは、Webページ内の50%の領域のiFrame内に表示してプレビューのために使用されます。前提として理解してコードを出力してください。)"

    const response = await anthropic.messages.create({
      model: "claude-3-opus-20240229",
      max_tokens: 4096,
      messages: messages,
    })

    const botMessage = response.content[0].type === 'text' 
      ? response.content[0].text 
      : 'Sorry, I could not generate a response.'
    
    let code = ""
    if (botMessage.includes("```html")) {
      const codeBlocks = botMessage.split("```html")
      code = codeBlocks[1].split("```")[0]
    }

    return NextResponse.json({ message: botMessage, code: code })
  } catch (error) {
    console.error('Error:', error)
    return NextResponse.json({ error: 'An error occurred while processing your request.' }, { status: 500 })
  }
}
```

### 4. チャットコンポーネントの作成
`components/ChatWithExecutor.tsx`ファイルを作成し、チャットインターフェースとコード実行機能を実装します。

```typescript
"use client";
import React, { useState, useEffect, useRef } from 'react'
import axios from 'axios'

interface Message {
  text: string
  sender: 'user' | 'bot'
  isCode?: boolean
}

export default function ChatWithExecutor() {
  const [messages, setMessages] = useState<Message[]>([])
  const [input, setInput] = useState('')
  const [content, setContent] = useState('<h1>Welcome! Start chatting to see content here.</h1>')
  const [isLoading, setIsLoading] = useState(false)
  const [viewMode, setViewMode] = useState<'preview' | 'code'>('preview')
  const iframeRef = useRef<HTMLIFrameElement>(null)
  const messagesEndRef = useRef<HTMLDivElement>(null)

  useEffect(() => {
    if (viewMode === 'preview') {
      updateIframeContent(content)
    }
  }, [content, viewMode])

  useEffect(() => {
    scrollToBottom()
  }, [messages])

  const scrollToBottom = () => {
    messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
  }

  const sendMessage = async (message: string = input) => {
    if (message.trim() === '') return

    const newMessage: Message = { text: message, sender: 'user' }
    setMessages(prevMessages => [...prevMessages, newMessage])
    setInput('')
    setIsLoading(true)

    try {
      const chatHistory = messages.slice(-18).map(msg => ({
        role: msg.sender === 'user' ? 'user' : 'assistant',
        content: msg.text
      }))
      chatHistory.push({ role: 'user', content: message })

      const response = await axios.post('/api/chat', { messages: chatHistory })
      const botMessage: Message = { text: response.data.message, sender: 'bot', isCode: !!response.data.code }
      setMessages(prevMessages => [...prevMessages, botMessage])

      if (response.data.code) {
        setContent(response.data.code)
        updateIframeContent(response.data.code)
      } else {
        setContent(`<div>${response.data.message}</div>`)
        updateIframeContent(`<div>${response.data.message}</div>`)
      }
    } catch (error) {
      console.error('エラー:', error)
      setContent('<h1>コンテンツの読み込み中にエラーが発生しました。もう一度お試しください。</h1>')
      updateIframeContent('<h1>コンテンツの読み込み中にエラーが発生しました。もう一度お試しください。</h1>')
    } finally {
      setIsLoading(false)
    }
  }

  const updateIframeContent = (content: string) => {
    if (iframeRef.current) {
      const iframeDoc = iframeRef.current.contentDocument
      iframeDoc?.open()
      iframeDoc?.write(`
        <html>
          <head>
            <style>
              body { margin: 0; padding: 0; font-family: Arial, sans-serif; }
            </style>
          </head>
          <body>${content}</body>
          <script>
            (function() {
              ${extractScriptContent(content)}
            })();
          </script>
        </html>
      `)
      iframeDoc?.close()
    }
  }
  
  const extractScriptContent = (html: string) => {
    const scriptMatch = html.match(/<script>([\s\S]*?)<\/script>/);
    return scriptMatch ? scriptMatch[1] : '';
  };
  

  return (
    <div className="chat-executor-container">
      <div className="chat-container">
        <div className="chat-messages">
          {messages.map((message, index) => (
            <div key={index} className={`message ${message.sender}`}>
              {message.isCode ? (
                <div className="code-message">
                  <div>💻</div>
                  <div>コードが含まれています。プレビューを確認してください。</div>
                </div>
              ) : (
                message.text
              )}
            </div>
          ))}
          <div ref={messagesEndRef} />
        </div>
        <div className="chat-input">
          <input
            type="text"
            value={input}
            onChange={(e) => setInput(e.target.value)}
            onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
            placeholder="Reply to Claude..."
          />
          <button onClick={() => sendMessage()} disabled={isLoading}>
            {isLoading ? '...' : 'Send'}
          </button>
        </div>
      </div>
      <div className="react-executor">
        <div className="view-mode-buttons">
          <button onClick={() => setViewMode('code')} className={viewMode === 'code' ? 'active' : ''}>Code</button>
          <button onClick={() => setViewMode('preview')} className={viewMode === 'preview' ? 'active' : ''}>Preview</button>
        </div>
        {viewMode === 'preview' ? (
          <iframe
            ref={iframeRef}
            title="Preview"
          />
        ) : (
          <pre>
            <code>{content}</code>
          </pre>
        )}
      </div>
    </div>
  )
}
```

### 5. メインページの作成
`page.tsx`ファイルを作成し、チャットコンポーネントを表示します。

```typescript
import ChatWithExecutor from '../components/ChatWithExecutor'

export default function Home() {
  return <ChatWithExecutor />
}
```

### 6. スタイルの適用
`globals.css`ファイルにスタイルを追加して、アプリケーションの見た目を整えます。

```css
body {
  margin: 0;
  padding: 0;
  font-family: Arial, sans-serif;
}

.chat-executor-container {
  display: flex;
  height: 100vh;
  width: 100vw;
  background-color: #FCF3E9;
}

.chat-container, .react-executor {
  flex: 1;
  display: flex;
  flex-direction: column;
  height: 90%;
  margin: 20px;
  background-color: #FCF3E9;
  border-radius: 10px;
  box-shadow: 0 0 10px rgba(0,0,0,0.1);
}

.chat-messages {
  flex: 1;
  overflow-y: auto;
  padding: 20px;
}

.message {
  margin-bottom: 10px;
  padding: 10px;
  border-radius: 5px;
}

.message.user {
  background-color: #E5D8C9;
}

.message.bot {
  background-color: #FBF7F2;
}

.code-message {
  display: flex;
  align-items: center;
  background-color: #f0f0f0;
  padding: 10px;
  border-radius: 5px;
  border: 1px solid #ccc;
}

.code-message > div:first-child {
  margin-right: 10px;
}

.chat-input {
  padding: 20px;
  border-top: 1px solid #e0e0e0;
}

.chat-input input {
  width: calc(100% - 70px);
  margin-right: 10px;
  padding: 10px;
  border-radius: 5px;
  border: 1px solid #ccc;
}

.chat-input button {
  width: 60px;
  padding: 10px;
  border-radius: 5px;
  background-color: #4CAF50;
  color: white;
  border: none;
}

.view-mode-buttons {
  display: flex;
  justify-content: flex-end;
  padding: 10px 20px;
  border-bottom: 1px solid #e0e0e0;
}

.view-mode-buttons button {
  margin-left: 10px;
  padding: 5px 10px;
  border: none;
  border-radius: 5px;
  background-color: #f0f0f0;
  color: black;
}

.view-mode-buttons button.active {
  background-color: #4CAF50;
  color: white;
}

iframe {
  width: 100%;
  height: 100%;
  border: none;
}

pre {
  flex: 1;
  overflow: auto;
  margin: 0;
  padding: 20px;
  background-color: #282c34;
  color: #abb2bf;
  font-size: 14px;
  line-height: 1.5;
}
```

## 実行結果
`npm run dev` でアプリケーションを実行すると、以下のような画面が表示されます。

Preview画面
![チャットアプリケーションのスクリーンショット](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3124507/31d47533-37f7-c603-2e6e-f8074665a2f9.png)

Code画面
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3124507/b26f1a8f-7804-2eb5-01d1-45eefa386d18.png)


ユーザーがClaudeとチャットを行い、生成されたHTMLコードをリアルタイムでプレビューすることができます。
　　
以上です！
最後まで読んでいただき、ありがとうございました！

## 参考資料
https://github.com/hodachi-axcxept/claude/tree/main