生成AIを用いたチャットアプリケーションでのReactサーバーコンポーネント、クライアントコンポーネントの実装

VercelのAI SDKを用いてNext.jsで作った生成AIのサンプルアプリケーションの解説です。今回は、生成AIの返答内容をユーザーに表示する方法について解説します。

動作するデモアプリケーションは下記をご覧ください。

https://mulai3.vercel.app/

https://mulai.vercel.app/

Mulai3とMulaiの違いについては過去のこちらの記事をご参照ください。

https://qiita.com/twk/items/5234aaeeb741efeec444

# 利用する外部ライブラリー

生成AIの出力は、基本的にテキストが中心ですが、質問内容によって、**太字**や箇条書きなどのMarkdown形式を含むことがあり、これらを加工して出力できると、公式のWebアプリケーションのように綺麗に表示することができます。

今回のアプリケーションはNext.jsで作っているので、ReactでMarkdown出力可能なライブラリーを探すと、**react-markdown**というものが良いようです。また、`react-markdown`は拡張可能な作りになっており、**react-syntax-highlighter**というライブラリーと組み合わせて使うとWebアプリケーションにおいて有用な場合が多そうです。同様に、**remarkGfm**、**rehypeExternalLinks**による機能拡張も推奨します。

https://github.com/remarkjs/react-markdown

https://github.com/react-syntax-highlighter/react-syntax-highlighter

----

Mulaiはサーバーサイドコンポーネントの`ai-action`内にて、`render`の引数`text`オブジェクトにて、文字列形式のレスポンスの処理を記述します。

```ai-action.tsx
  const ui:React.ReactNode = render({
    model: aiState.get().model.sdkModelValue,
    provider: getProvider(aiState.get().model),
    messages: 
      [
        { role: 'system', content: 'You are a helpful assistant' },
        ...aiState.get().messages as ChatCompletionMessageParam[],
      ],
    // `text` is called when an AI returns a text response (as opposed to a tool call).
    // Its content is streamed from the LLM, so this function will be called
    // multiple times with `content` being incremental.
    text: ({ content, done }) => {
      // When it's the final content, mark the state as done and ready for the client to access.
      if (done) {
        aiState.done({
          ...aiState.get(),
          messages: [
            ...aiState.get().messages,
            {
              role: "assistant",
              content
            }
          ]
        });
      }
 
      return <ChatMessage locale={locale} role="assistant">{content}</ChatMessage>
    },
```

`<ChatMessage>`はchat-message.tsxで定義されており、ユーザーの入力とアシスタント(AI)の応答のいずれも処理できるように記述され、根本的な処理は`<ChatContentMarkdown>`に委譲されています。なお、このアプリケーションではユーザー入力は単に`pre-wrap`された`<div>`に出力されていることがわかります。

```chat-message.tsx
export default function ChatMessage({locale, role, children}:{locale:string, role:string, children:React.ReactNode}) {
  const {t} = getTranslations(locale)

  return (
    <div className={
      "rounded-sm px-2 py-1 m-1 max-w-full text-sm leading-normal prose prose-sm prose-p:mt-0 prose-pre:mt-1 prose-pre:mb-1 prose-pre:bg-[#2b2b2b] prose-img:my-1 " + 
      (role === "user"
        ? " bg-slate-100"
        : role === "assistant"
        ? " "
        : process.env.NODE_ENV !== 'development'
        ? " hidden" // system
        : " bg-gray-100 text-gray-400"
    )}>
      <div className={'font-bold text-xs ' +
        (role === 'user' ? ' text-slate-800'
        : ' text-teal-800')
      }>
        {role === 'user' ? t('user')
        : role === 'assistant' ? t('ai')
        : t('system')}
      </div>
      {role === "user" 
        ? <div className='whitespace-pre-wrap overflow-auto'>{children}</div>
        : <ChatContentMarkdown>{children}</ChatContentMarkdown>
      }
    </div>
  )
}
```

`<ChatContentMarkdown>`も同じファイルのすぐ下に定義されています。実際のコンテンツは子要素として指定されていますが、幾つかの属性値が指定されており、それぞれにより、`ReactMarkdown`の標準処理を拡張しています。

例えば**urlTransform**は、応答中のURLの無害化処理を制御しています。ReactMarkdownでは標準でURLの無害化処理を行っており、万が一AIが問題のあるURLを返却した時の対策として、`defaultUrlTransform`による無害化は意味があると思いますが、標準の処理では、[このアプリで画像生成時](https://qiita.com/twk/items/b51fbc375a96bef5f71c)に使っているdata URIを消してしまいます。data URIが出力に含まれることによるセキュリティリスクは無視できると考えられるので、該当のURLの時には無害化処理を行わないようにしています。

**rehypePlugins**では、外部URLへのリンクを別タブで開く設定をしています。
***remarkPlugins**では、外部モジュールであるremarkGfmを指定しています。これにより、標準のReactMarkdownが対応していない幾つかのタグに対応します。
**components**では、MarkdownをHTMLに変換した後に、幾つかのタグについて個別処理を指定しています。これは、下にて改めて説明します。

```chat-message.tsx
export function ChatContentMarkdown({
    children,
  }: Readonly<{
    children: React.ReactNode;
  }>){
    // console.log(children)
    return <ReactMarkdown
      urlTransform={(url: string) => {
        // image data https://github.com/remarkjs/react-markdown/issues/774
        if (url.startsWith('data:image/'))
          return url
        return defaultUrlTransform(url)
        }}
      rehypePlugins={[[rehypeExternalLinks, {target: '_blank'}]]}
      remarkPlugins={[remarkGfm]}
      components={{
        img({alt, ...props}) {
          // dynamic image, width/height are not clear
          /* eslint-disable @next/next/no-img-element */
          return (<img className="max-w-64 max-h-64" alt={alt ?? ''} {...props} />)
        },
        pre({children}) {
          const text = extractTextFromElement(children)
          return (
            <div className='relative'>
              {children}
              <div className='absolute top-1 right-1'>
                <ContentCopyButton text={text} />
              </div>
            </div>
          )
        },
        code({className, children}) {
          const language = (/language-(\w+)/.exec(className || '') || ['',''])[1]
          if (language || String(children).length >= 50) {
            return (
              <SyntaxHighlighter language={language} style={a11yDark} wrapLongLines={true}>
                {children as any}
              </SyntaxHighlighter>
            )
          } else {
            // inline
            return <code>{children}</code>
          }
        },
    }}>
    {children?.toString()}
    </ReactMarkdown>
}
```

`components`にはいずれも関数形式の`{img, pre, code}`の3つの要素が指定されています。reactの中括弧と組み合わせて中括弧が二重になっていますが、実際のところ、よく見るアロー形式の関数定義で書き直すと、下記のようになります。それぞれの要素はそれぞれのHTMLタグに対応します。

`img`は、画像の表示最大サイズに制限を設けています。
`pre`は、プログラムコードを出力する時に利用されるので、Qiitaと同様に、コードをコピーするボタン（`<ContentCopyButton>`）を右上部分に表示するようにしています。コピーするコードの内容は`extractTextFromElement`関数であらかじめ取得して`text`属性として渡しています。
`code`も、プログラムコードを出力する時に利用されます。ここでは、SyntaxHighlighterを利用して、言語ごとにハイライトを行うようにしています。クラス名として言語名が入ってくるので、それを取得して利用しています。`a11yDark`はハイライトのスタイルの一つです。

```typescript
    {
        img: ({alt, ...props}) => {
          // dynamic image, width/height are not clear
          /* eslint-disable @next/next/no-img-element */
          return (<img className="max-w-64 max-h-64" alt={alt ?? ''} {...props} />)
        },
        pre: ({children}) => {
          const text = extractTextFromElement(children)
          return (
            <div className='relative'>
              {children}
              <div className='absolute top-1 right-1'>
                <ContentCopyButton text={text} />
              </div>
            </div>
          )
        },
        code: ({className, children}) => {
          const language = (/language-(\w+)/.exec(className || '') || ['',''])[1]
          if (language || String(children).length >= 50) {
            return (
              <SyntaxHighlighter language={language} style={a11yDark} wrapLongLines={true}>
                {children as any}
              </SyntaxHighlighter>
            )
          } else {
            // inline
            return <code>{children}</code>
          }
        },
    }
```

`pre`の`extractTextFromElement`について、Node内のテキストを連結した値を取得するために関数を用意して再帰的に取得しています。`ContentCopyButton`はマウスイベントに反応する必要があるためクライアントコンポーネントになっていますが、処理としては単純にイベントハンドラーを登録しています。

```chat-message.tsx
function extractTextFromElement(element:any):string {
  if (typeof element === 'string' || typeof element === 'number') {
    return element.toString();
  }

  if (element.length > 0) {
    return element.map((child:any) => extractTextFromElement(child)).join('');
  }

  const children = element?.props?.children
  if (children) {
    return extractTextFromElement(children)
  }

  return '';
}
```

```content-copy-button.tsx
export default function ContentCopyButton({text}:{text:string}) {
    const handleClick = () => {
      navigator.clipboard.writeText(text);
    }
    return (
      <button onClick={handleClick} className="text-teal-600 enabled:hover:text-teal-500 enabled:active:text-teal-100">
        <ClipboardCopyIcon className="h-5 w-5" />
        <span className='sr-only'>Copy</span>
      </button>
    )
}
```

以上の説明を参考にしていただくことで、あなたのReactアプリケーションでも生成AIのレスポンスの整形を容易にできるかと思います。

# クライアントコンポーネントの場合

Mulaiはクライアントコンポーネント中心に実装しているため、同様の処理が別の箇所にて記述されています。`chat-message.tsx`にはやや長い処理がまとめて記述されています。

Reactのクライアントコンポーネントで実装している場合は、こちらを元に実装いただいた方がわかりやすいかと思います。

```chat.tsx
        <div className='px-3 py-1 font-bold text-teal-800'>{t('ai')}<span className='text-teal-800'>{getAILabel(modelValue, character, locale)}</span></div>
        {chatOptions.messages.map((m, index) => (
          <ChatMessage key={index} message={m} />
        ))}
      </div>
```

```chat-message.tsx
export default function ChatMessage({message}:{message:Message}) {
	const locale = useContext(LocaleContext)
  const {t} = getTranslations(locale)

  return (
  <div className={
    "rounded-sm px-2 py-1 m-1 max-w-full text-sm leading-normal prose prose-sm prose-p:mt-0 prose-pre:mt-1 prose-pre:mb-1 prose-pre:bg-[#2b2b2b] prose-img:my-1 " + 
    (message.role === "user"
      ? " bg-slate-100"
      : message.role === "assistant"
      ? " "
      : process.env.NODE_ENV !== 'development'
      ? " hidden" // system
      : " bg-gray-100 text-gray-400"
  )}>
    <div className={'font-bold text-xs ' +
      (message.role === 'user' ? ' text-slate-800'
      : ' text-teal-800')
    }>
      {message.role === 'user' ? t('user')
      : message.role === 'assistant' ? t('ai')
      : t('system')}
    </div>
    {message.role === "user" 
      ? <div className='whitespace-pre-wrap overflow-auto'>{message.content}</div>
      : <ReactMarkdown
          urlTransform={(url: string) => {
            // image data https://github.com/remarkjs/react-markdown/issues/774
            // console.log(url)
            if (url.startsWith('data:image/'))
              return url
            return defaultUrlTransform(url)
          }}
          rehypePlugins={[[rehypeExternalLinks, {target: '_blank'}]]}
          remarkPlugins={[remarkGfm]}
          components={{
            img({alt, ...props}) {
              // dynamic image, width/height are not clear
              /* eslint-disable @next/next/no-img-element */
              return (<img className="max-w-64 max-h-64" alt={alt ?? ''} {...props} />)
            },
            pre({children}) {
              return (
                <div className='relative'>
                  {children}
                  <div className='absolute top-1 right-1'>
                    <CodeCopyBtn>{children as any}</CodeCopyBtn>
                  </div>
                </div>
              )
            },
            code({className, children}) {
              const language = (/language-(\w+)/.exec(className || '') || ['',''])[1]
              if (language || String(children).length >= 50) {
                return (
                  <SyntaxHighlighter language={language} style={a11yDark} wrapLongLines={true}>
                    {children as any}
                  </SyntaxHighlighter>
                )
              } else {
                // inline
                return <code>{children}</code>
              }
            },
        }}>
        {message.content}
      </ReactMarkdown>
    }
  </div>
  )
}
```

それぞれの全体のソースコードは下記よりご参照ください。

https://github.com/twxxk/mulai3

https://github.com/twxxk/mulai


このサンプルアプリケーションに関するそのたの記事は、タグ`Mulai`をご利用ください。

https://qiita.com/tags/mulai