AIを使ってDifyチャットボットをNuxt3アプリに埋め込んでみた(GPT4oとClaude3.5を比較)

本記事ではDifyをNuxt3に実装した話についてご紹介します。Difyをサイトへと埋め込むために提供されているコードを解析し、AIを使いながら実装しています。

- 使用しているAI
  - ChatGPT GPT4o(Web版)
  - Cline claude-3.5-haiku

# 背景

シクミヤでは資本政策データベースサービスの「[shihon](https://shihon.com)」を運用しています。

:::note info
1週間無料トライアル・以降 ¥3,000/月 で利用開始できます
:::

shihonでは資本政策に関する資本取引の詳細や企業の情報、関連する従業員やVCの情報といったデータを収録しており、UIベースで閲覧することができます。

https://shihon.com/discover

加えて、「shihon bot」というチャットボットの機能も用意しており、サービス内のデータを学習させたAIが収録情報について回答してくれます。

https://prtimes.jp/main/html/rd/p/000000005.000096027.html

このshihon botには、会話型AIをノーコードで作成できる「miibo」というサービスを使わせていただいてました。

https://miibo.ai/

miiboを組み込んでみた話は下記のnote記事に書いてありますので、ご興味あればご覧ください。

https://note.com/visionarybase_2/n/n979b2c8fb7dc

その後、アーキテクチャを再選定し、「Dify」というサービスを検証してみることとなりました。

https://dify.ai/

shihonのフロントエンドアーキテクチャではNuxt3を採用しています。Difyの公式ドキュメントやGitHubリポジトリを見てみると、Nextについてはコンポーネント化された実装コードがあるのですが、Nuxtについてはありませんでした。

## Difyが提供する埋め込みコード

Difyのチャットボット作成画面にある「監視」のところから下図のような設定画面が表示されます。通常サイトへ埋め込むには、この画面の「埋め込み」からコードを拾ってサイトへ実装します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/581277/5264485b-7eb9-4129-b3b7-ff08719df308.png)

埋め込み方法は「任意の場所」「右下ポップアップ」「Chrome拡張機能」の3種類が標準で提供されています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/581277/727d5734-fe0a-4581-aa6d-e3ab3849ddc2.png)

shihon botは右下ポップアップ方式を取っているため、図の真ん中のコードを使用してみました。

```html
<script>
 window.difyChatbotConfig = {
  token: '*********'
 }
</script>
<script
 src="https://udify.app/embed.min.js"
 id="*********"
 defer>
</script>
<style>
  #dify-chatbot-bubble-button {
    background-color: #6667ab !important;
  }
  #dify-chatbot-bubble-window {
    width: 24rem !important;
    height: 40rem !important;
  }
</style>
```

このコードはNuxt3のようなフレームワークを使う想定がされていないコードですので、そのままは使えませんでした。

Nuxt3用にDifyを埋め込む必要があり、その実装過程を次よりお話しして行きます。

:::note warn
エンジニアリング過程の読み物として書いていますので、結果だけ知りたい方は次の「実装過程」セクションはスキップしてください。
:::

# 実装過程

Dify標準で用意されている埋め込みコードをNuxt3用に書き換えて行く過程を綴ります。

:::note info
AIへの指示と回答は長くなるので「▶︎」で閉じてあります。開いてご覧ください
:::

## ChatGPTに聞いて実装を試みた

Nuxt3用のコードに書き換えれば動くのでは、という仮説の元、ChatGPTにお手伝いしてもらいながら実装を試みました。

### 埋め込みコードをNuxtコンポーネントに変換してみる

#### Try1: Nuxtコンポーネントへの変換

前述の埋め込みコードをNuxtコンポーネントに変換するようChatGPTへ依頼しました。

<details>
<summary>質問</summary>

> nuxt3で作成したWebアプリのページにコンポーネントとしてDifyチャットボットのウィンドウを組み込もうとしています。Difyから提供された埋め込みコードは以下の通りです。nuxtのアプリで作成しているコンポーネントのコード(TypeScript)は以下の通りです。これらを統合してnuxtアプリで動くようなコンポーネントコードを作ってください。
> 
>> 埋め込みコード
>> 
>> ```html
>> <script>
>>  window.difyChatbotConfig = {
>>   token: '*********'
>>  }
>> </script>
>> <script
>>  src="https://udify.app/embed.min.js"
>>  id="*********"
>>  defer>
>> </script>
>> <style>
>>   #dify-chatbot-bubble-button {
>>     background-color: #1C64F2 !important;
>>   }
>>   #dify-chatbot-bubble-window {
>>     width: 24rem !important;
>>     height: 40rem !important;
>>   }
>> </style>
>> ```
>> 
>> nuxtのアプリで作成しているコンポーネントのコード
>> 
>> ```vue
>> <template>
>> 
>> </template>
>> 
>> <script lang="ts">
>> export default {
>>   name: 'ChatBot'
>> }
>> </script>
>> 
>> <script lang="ts" setup>
>> 
>> </script>
>> ```

</details>

すると、こんなコードが返ってきました。

<details>
<summary>回答</summary>

> 
> ```vue
> <template>
>   <div>
>     <div id="dify-chatbot"></div>
>   </div>
> </template>
> 
> <script setup lang="ts">
> import { onMounted } from 'vue';
> 
> onMounted(() => {
>   if (typeof window !== 'undefined') {
>     window.difyChatbotConfig = {
>       token: '*********'
>     };
> 
>     const script = document.createElement('script');
>     script.src = 'https://udify.app/embed.min.js';
>     script.id = '*********';
>     script.defer = true;
>     document.body.appendChild(script);
>   }
> });
> </script>
> 
> <style>
>   #dify-chatbot-bubble-button {
>     background-color: #1C64F2 !important;
>   }
>   #dify-chatbot-bubble-window {
>     width: 24rem !important;
>     height: 40rem !important;
>   }
> </style>
> ```
</details>

これを動かしてみましたが、ポップアップが出ることはありませんでした。

埋め込みコードを読むと、Difyの挙動は①外部スクリプト(embed.min.js)を読み込むコードを書く、②外部JSがサイト内にiframeを埋め込んで動作する、といった感じになっていました。

その後も会話を続け、ローディング方法の見直しや要素の監視をしたりしてみましたが、どうやら外部JSをこの方法で動かすのはできないようでした。

#### Try2: プラグインにしてみる

コンポーネントベースではダメそうだったので、次のアプローチとしてプラグインにして動かしてみる方法を試してみました。

<details>
<summary>質問1</summary>

> nuxt3でSPAを開発しています。Difyのチャットボットを組み込みたいです。
> 
> plugins/dify.tsを作成しました。コードは以下の通りです。
> 
> ```ts
> export default defineNuxtPlugin(app => {
>   window.difyChatbotConfig = {
>     token: '*********'
>   }
> 
>   const script = document.createElement('script')
>   script.src = "https://udify.app/embed.min.js"
>   script.id = "*********"
>   script.defer = true
> 
>   document.head.appendChild(script)
> 
>   const style = document.createElement('style')
>   style.innerHTML = `
>   #dify-chatbot-bubble-button {
>     background-color: #1C64F2 !important;
>   }
>   #dify-chatbot-bubble-window {
>     width: 24rem !important;
>     height: 40rem !important;
>   }
>   `
>   document.head.appendChild(style)
> 
> })
> ```

しかしながら、これではDifyのチャットボット用のボタンなどの要素が作られません。解決策はありますか？

</details>

<details>
<summary>回答1</summary>

> `plugins/dify.ts` のコードを確認しました。問題を解決するために、いくつかのポイントを修正・確認してみましょう。
> 
> ---
> 
> **問題点**
> 
> 1. **`window.difyChatbotConfig` の設定が適用される前にスクリプトがロードされている可能性**
>    - スクリプトを追加する前に `window.difyChatbotConfig` を適用する必要があります。
> 
> 2. **スクリプトのロードが完了するのを待たずに要素が作られている可能性**
>    - `defer` を設定しているため、スクリプトのロードが非同期になり、適切な順序で動作しない可能性があります。
>    - `script.onload` イベントを使って、スクリプトのロード完了後に初期化を試みる。
> 
> 3. **`window.difyChatbotConfig` の `token` の設定方法**
>    - `window.difyChatbotConfig` を `window` に直接設定しても、Dify のスクリプトがそれを正しく認識しないことがあります。
>    - `window.difyChatbotConfig` を定義するタイミングが重要。
> 
> ---
> 
> **修正後のコード**
> 以下のように修正すると、Difyのチャットボットが正常に動作する可能性が高いです。
> 
> ```ts
> export default defineNuxtPlugin(nuxtApp => {
>   // グローバルにチャットボットの設定を適用
>   window.difyChatbotConfig = {
>     token: 'aJRLs0Tm4WzBt3L6'
>   };
> 
>   // スクリプトを作成
>   const script = document.createElement('script');
>   script.src = "https://udify.app/embed.min.js";
>   script.id = "dify-chatbot-script";
>   script.defer = true;
> 
>   // スクリプトのロード完了後にチャットボットの初期化を試みる
>   script.onload = () => {
>     if (window.difyChatbot) {
>       window.difyChatbot.init();
>     }
>   };
> 
>   document.head.appendChild(script);
> 
>   // スタイルの適用
>   const style = document.createElement('style');
>   style.innerHTML = `
>   #dify-chatbot-bubble-button {
>     background-color: #1C64F2 !important;
>   }
>   #dify-chatbot-bubble-window {
>     width: 24rem !important;
>     height: 40rem !important;
>   }
>   `;
>   document.head.appendChild(style);
> });
> ```
> 
> ---
> 
> **変更点・解説**
> 1. **`window.difyChatbotConfig` の設定を先に行う**
>    - スクリプトが読み込まれる前に設定が適用されるようにした。
> 
> 2. **`script.onload` を使い、スクリプトロード完了後に `window.difyChatbot.init()` を実行**
>    - これにより、スクリプトのロード後に確実にチャットボットが初期化される。
> 
> 3. **スクリプトの `id` を `dify-chatbot-script` に設定**
>    - すでにスクリプトが追加されている場合、重複して追加しないように管理しやすくするため。
> 
> ---
> 
> **追加の確認点**
> もしこれでも動作しない場合、次の点を確認してください：
> 1. **NuxtがSPAモードで動作しているか (`ssr: false` の設定)**
>    - `nuxt.config.ts` に次のような設定を追加：
>    ```ts
>    export default defineNuxtConfig({
>      ssr: false, // SPA モードで動作させる
>    });
>    ```
> 
> 2. **Dify のスクリプト URL が正しいか**
>    - `https://udify.app/embed.min.js` のスクリプトが利用可能か、開発者コンソール (F12 → Network) で確認。
> 
> 3. **コンソールエラーの確認**
>    - 開発者ツール (F12 → Console) でエラーが出ていないかチェック。
> 
> ---
> 
> この修正後のコードを試してみてください！
> 
</details>

これを動かしてみたところ、init()という初期化用の実装はなく、さらに修正をしてもらいました。

ここで気になったのは
- windows.difyChatbot.init()のソース(根拠)はどこか
- window.difyChatbotが存在するという根拠はどこか
という点でした。

これについてさらに質問したところ、公式ドキュメントの記載を確認してくれました。

<details>
<summary>回答2</summary>

> 申し訳ございません。以前の回答で window.difyChatbot やその init() メソッドについて言及しましたが、Dify の公式ドキュメントにはこれらに関する具体的な記載が見当たりませんでした。公式ドキュメントでは、window.difyChatbotConfig を設定し、https://udify.app/embed.min.js スクリプトを読み込むことで、チャットボットが自動的に初期化されると説明されています。

</details>

ここで外部スクリプトのソース自体の解析を依頼しました。このソースコードはminifyされており、読むのがやや困難ではありましたが、中でチャットウィンドウ用の要素をbody要素にappendChildしていることがわかりましたので、それについて質問してみました。

<details>
<summary>回答3</summary>

> ✅ 実際に document.body.appendChild(n) で追加される n は、バブルボタン (#dify-chatbot-bubble-button) やチャットウィンドウ (#dify-chatbot-bubble-window) を含む div 要素

</details>

#### Try3: 外部スクリプトの要素を追加してみる

ということで、次のアプローチとして、Nuxt3でdocument.bodyに対して要素を追加する方法を聞いてみました。

<detailsopen>
<summary>質問4</summary>

> nuxt3のようなテンプレートでHTMLを構成するようなSPAフレームワークにおいて、document.bodyに直接appendChildするようなことは可能なのでしょうか？

</details>

<details>
<summary>回答4</summary>

> ✅ 最も適した方法
> 
> - Dify のスクリプトをロードする場合は useHead() を使うのがベスト
> - 独自の要素 (div など) を document.body.appendChild() する場合は、onMounted() を使う
> - 要素の削除が必要な場合は onBeforeUnmount() も使う
> この方法で Nuxt 3 の環境でも document.body.appendChild() を安全に利用できます！

</details>

可能であるとの回答でした。

しかし、ここでまたひとつ疑問が。外部の読み込みスクリプトからnuxtアプリのDOMにアクセスして要素を追加することは可能なのか？

<detailsopen>
<summary>質問5</summary>

> 仮にonMounted()やuseHead()で読み込んだとして、外部の読み込みスクリプトからNuxtアプリのDOMにアクセスして要素を追加することは可能なのですか？

</details>

<details>
<summary>回答5</summary>

> ✅ 外部スクリプト (embed.min.js など) から Nuxt 3 の DOM にアクセスし、要素を追加することは可能
> ✅ ただし、Nuxt の仮想 DOM (Virtual DOM) の影響を受けて、ページ遷移時に要素が消える可能性がある
> ✅ 安定させる方法
> 1. MutationObserver を使って外部スクリプトの要素が消えたら再追加
> 2. 外部スクリプトは document.body に要素を追加するようにする
> 3. Nuxt 側で <Teleport> を使い、仮想 DOM の影響を受けにくくする
> この方法を試せば、Nuxt 3 環境でも Dify のような外部スクリプトが正しく動作するはずです！

</details>

外部スクリプトの要素を追加するも要素が消えてしまうとのことでした。仮装DOMなので当然といえば当然の動きですね。

少し前に戻って再度、外部スクリプトの挙動について確認してみました。

<details open>
<summary>質問6</summary>

> document.body.appendChild(n)はどのように呼ばれますか？

</details>



<details>
<summary>回答6-1</summary>

> document.body.appendChild(n) が呼ばれる流れ
> 
> 1. Dify のスクリプト (embed.min.js) がロードされる
> 2. document.body.onload = e; により e() が実行される
> 3. e() の中で window.difyChatbotConfig (p) をチェック
>   ・p.token (チャットボットのトークン) がある場合のみ実行
> 4. t() を実行し、#dify-chatbot-bubble-button を作成
>   ・既に #dify-chatbot-bubble-button がある場合は何もしない
> 5. document.body.appendChild(n); により、ボタン (n) を body に追加
> 6. Dify のチャットボタンがページ上に表示される

</details>

<details>
<summary>回答6-2</summary>

> document.body.onload = e; の問題点 (Nuxt 3 の場合)
> 
> ✅ 通常の HTML ページでは document.body.onload = e; で正しく動作する
> ❌ Nuxt 3 の SPA (Single Page Application) では document.body.onload は動作しない
> → Nuxt 3 では SSR (サーバーサイドレンダリング) の影響で document.body.onload が発火しないことがある
> → ページ遷移 (vue-router) では onload は一度しか実行されず、Dify のボタンが消えてしまう可能性がある

</details>

<details>
<summary>回答6-3</summary>

> 結論
> 
> ✅ document.body.appendChild(n); は Dify の embed.min.js 内の e() 関数の t() の中で実行されている
> ✅ document.body.onload = e; によって呼び出されるが、Nuxt 3 では onload が一度しか発火せず、ページ遷移時にボタンが消えることがある
> ✅ Nuxt 3 で確実に動作させるには

> 1. onMounted() を使い、クライアントサイド (process.client) でスクリプトをロードする
> 2. useRouter() の afterEach() を使って、Nuxt のページ遷移 (vue-router) でスクリプトを再ロードする
> この方法を試せば、Nuxt 3 でも Dify のチャットボットが安定して動作するはずです！ 🚀

</details>

結果として、内部ではdocument.body.onloadが呼ばれた際にDifyの要素を生成・追加しているとのことでした。

onMounted()を使う方法でTry1のコードに戻ってしまったので、アプローチを変えることにしました。

#### Try4: 外部スクリプトを内部スクリプトとして動作させてみる

Nuxtアプリに外部スクリプトのコードそのものを埋め込んでしまい内部スクリプトとして動作させる、というアプローチです。

<details open>
<summary>質問7-1</summary>

> componentをこのように書き換えました。先のdocument.body.onload = eはnuxt3では動作しないとのことでしたが、このe()をcomponentから呼ぶことはできますか？

</details>

<details>
<summary>質問7-2</summary>

>> ```vue
>> <template>
>>   <div id="dify-chatbot">
>>   </div>
>> </template>
>> 
>> <script setup lang="ts">
>> 
>> onMounted(() => {
>>   if (process.client) {
>>     window.difyChatbotConfig = {
>>       token: '*********'
>>     };
>> 
>>     if (!document.getElementById('dify-chatbot-script')) {
>>       const script = document.createElement('script');
>>       script.innerHTML = '!function(){const n="difyChatbotConfig",a="dify-chatbot-bubble-button",c="dify-chatbot-bubble-window",p=window[n],h={open:`<svg id="openIcon" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M7.7586 2L16.2412 2C17.0462 1.99999 17.7105 1.99998 18.2517 2.04419C18.8138 2.09012 19.3305 2.18868 19.8159 2.43598C20.5685 2.81947 21.1804 3.43139 21.5639 4.18404C21.8112 4.66937 21.9098 5.18608 21.9557 5.74818C21.9999 6.28937 21.9999 6.95373 21.9999 7.7587L22 14.1376C22.0004 14.933 22.0007 15.5236 21.8636 16.0353C21.4937 17.4156 20.4155 18.4938 19.0352 18.8637C18.7277 18.9461 18.3917 18.9789 17.9999 18.9918L17.9999 20.371C18 20.6062 18 20.846 17.9822 21.0425C17.9651 21.2305 17.9199 21.5852 17.6722 21.8955C17.3872 22.2525 16.9551 22.4602 16.4983 22.4597C16.1013 22.4593 15.7961 22.273 15.6386 22.1689C15.474 22.06 15.2868 21.9102 15.1031 21.7632L12.69 19.8327C12.1714 19.4178 12.0174 19.3007 11.8575 19.219C11.697 19.137 11.5262 19.0771 11.3496 19.0408C11.1737 19.0047 10.9803 19 10.3162 19H7.75858C6.95362 19 6.28927 19 5.74808 18.9558C5.18598 18.9099 4.66928 18.8113 4.18394 18.564C3.43129 18.1805 2.81937 17.5686 2.43588 16.816C2.18859 16.3306 2.09002 15.8139 2.0441 15.2518C1.99988 14.7106 1.99989 14.0463 1.9999 13.2413V7.75868C1.99989 6.95372 1.99988 6.28936 2.0441 5.74818C2.09002 5.18608 2.18859 4.66937 2.43588 4.18404C2.81937 3.43139 3.43129 2.81947 4.18394 2.43598C4.66928 2.18868 5.18598 2.09012 5.74808 2.04419C6.28927 1.99998 6.95364 1.99999 7.7586 2ZM10.5073 7.5C10.5073 6.67157 9.83575 6 9.00732 6C8.1789 6 7.50732 6.67157 7.50732 7.5C7.50732 8.32843 8.1789 9 9.00732 9C9.83575 9 10.5073 8.32843 10.5073 7.5ZM16.6073 11.7001C16.1669 11.3697 15.5426 11.4577 15.2105 11.8959C15.1488 11.9746 15.081 12.0486 15.0119 12.1207C14.8646 12.2744 14.6432 12.4829 14.3566 12.6913C13.7796 13.111 12.9818 13.5001 12.0073 13.5001C11.0328 13.5001 10.235 13.111 9.65799 12.6913C9.37138 12.4829 9.15004 12.2744 9.00274 12.1207C8.93366 12.0486 8.86581 11.9745 8.80418 11.8959C8.472 11.4577 7.84775 11.3697 7.40732 11.7001C6.96549 12.0314 6.87595 12.6582 7.20732 13.1001C7.20479 13.0968 7.21072 13.1043 7.22094 13.1171C7.24532 13.1478 7.29407 13.2091 7.31068 13.2289C7.36932 13.2987 7.45232 13.3934 7.55877 13.5045C7.77084 13.7258 8.08075 14.0172 8.48165 14.3088C9.27958 14.8891 10.4818 15.5001 12.0073 15.5001C13.5328 15.5001 14.735 14.8891 15.533 14.3088C15.9339 14.0172 16.2438 13.7258 16.4559 13.5045C16.5623 13.3934 16.6453 13.2987 16.704 13.2289C16.7333 13.1939 16.7567 13.165 16.7739 13.1432C17.1193 12.6969 17.0729 12.0493 16.6073 11.7001ZM15.0073 6C15.8358 6 16.5073 6.67157 16.5073 7.5C16.5073 8.32843 15.8358 9 15.0073 9C14.1789 9 13.5073 8.32843 13.5073 7.5C13.5073 6.67157 14.1789 6 15.0073 6Z" fill="white"/></svg>`,close:`<svg id="closeIcon" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M18 18L6 6M6 18L18 6" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>`};async function e(){if(p&&p.token){var e=new URLSearchParams(await async function(){var e=p?.inputs||{};const n={};return await Promise.all(Object.entries(e).map(async([e,t])=>{n[e]=(e=t,e=(new TextEncoder).encode(e),e=new Response(new Blob([e]).stream().pipeThrough(new CompressionStream("gzip"))).arrayBuffer(),e=new Uint8Array(await e),await btoa(String.fromCharCode(...e)))})),n}());const i=`${p.baseUrl||`https://${p.isDev?"dev.":""}udify.app`}/chatbot/${p.token}?`+e;function o(){var e,t;window.innerWidth<=640||(e=document.getElementById(c),t=document.getElementById(a),e&&t&&((t=t.getBoundingClientRect()).top-5>e.clientHeight?(e.style.bottom=t.height+5+"px",e.style.top="unset"):(e.style.bottom="unset",e.style.top=t.height+5+"px"),t.right>e.clientWidth?(e.style.right="0",e.style.left="unset"):(e.style.right="unset",e.style.left=0)))}function t(){const n=document.createElement("div");Object.entries(p.containerProps||{}).forEach(([e,t])=>{"className"===e?n.classList.add(...t.split(" ")):"style"===e?"object"==typeof t?Object.assign(n.style,t):n.style.cssText=t:"function"==typeof t?n.addEventListener(e.replace(/^on/,"").toLowerCase(),t):n[e]=t}),n.id=a;var e=document.createElement("style");document.head.appendChild(e),e.sheet.insertRule(`#${n.id} {position: fixed;bottom: var(--${n.id}-bottom, 1rem);right: var(--${n.id}-right, 1rem);left: var(--${n.id}-left, unset);top: var(--${n.id}-top, unset);width: var(--${n.id}-width, 50px);height: var(--${n.id}-height, 50px);border-radius: var(--${n.id}-border-radius, 25px);background-color: var(--${n.id}-bg-color, #155EEF);box-shadow: var(--${n.id}-box-shadow, rgba(0, 0, 0, 0.2) 0px 4px 8px 0px);cursor: pointer;z-index: 2147483647;}`);const t=document.createElement("div");if(t.style.cssText="display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; z-index: 2147483647;",t.innerHTML=h.open,n.appendChild(t),console.log("aaa"),document.getElementById("dify-chatbot").appendChild(n),n.addEventListener("click",function(){var e=document.getElementById(c);e?(e.style.display="none"===e.style.display?"block":"none",t.innerHTML="none"===e.style.display?h.open:h.close,"none"===e.style.display?document.removeEventListener("keydown",d):document.addEventListener("keydown",d),o()):(n.appendChild(((e=document.createElement("iframe")).allow="fullscreen;microphone",e.title="dify chatbot bubble window",e.id=c,e.src=i,e.style.cssText=`border: none; position: absolute; flex-direction: column; justify-content: space-between;box-shadow: rgba(150, 150, 150, 0.2) 0px 10px 30px 0px, rgba(150, 150, 150, 0.2) 0px 0px 0px 1px;bottom: 55px; right: 0; width: 24rem; max-width: calc(100vw - 2rem); height: 40rem;max-height: calc(100vh - 6rem); border-radius: 0.75rem; display: flex; z-index: 2147483647;overflow: hidden; left: unset; background-color: #F3F4F6;user-select: none;`,e)),o(),this.title="Exit (ESC)",t.innerHTML=h.close,document.addEventListener("keydown",d))}),p.draggable){var s=n;var l=p.dragAxis||"both";let i=!1,d,r;s.addEventListener("mousedown",function(e){i=!0,d=e.clientX-s.offsetLeft,r=e.clientY-s.offsetTop}),document.addEventListener("mousemove",function(e){var t,n,o;i&&(s.style.transition="none",s.style.cursor="grabbing",(t=document.getElementById(c))&&(t.style.display="none",s.querySelector("div").innerHTML=h.open),t=e.clientX-d,e=window.innerHeight-e.clientY-r,o=s.getBoundingClientRect(),n=window.innerWidth-o.width,o=window.innerHeight-o.height,"x"!==l&&"both"!==l||s.style.setProperty(`--${a}-left`,Math.max(0,Math.min(t,n))+"px"),"y"!==l&&"both"!==l||s.style.setProperty(`--${a}-bottom`,Math.max(0,Math.min(e,o))+"px"))}),document.addEventListener("mouseup",function(){i=!1,s.style.transition="",s.style.cursor="pointer"})}}2048<i.length&&console.error("The URL is too long, please reduce the number of inputs to prevent the bot from failing to load"),document.getElementById(a)||t()}else console.error(n+" is empty or token is not provided")}function d(e){var t;"Escape"===e.key&&(e=document.getElementById(c),t=document.getElementById(a),e)&&"none"!==e.style.display&&(e.style.display="none",t.document.querySelector("div").innerHTML=h.open)}document.addEventListener("keydown",d),p?.dynamicScript?e():document.body.onload=e}();'
>>       document.body.appendChild(script);
>>     }
>>   }
>> });
>> </script>
>> ```

</details>

内部スクリプト化したとして、document.body.onloadに登録されているe()をコンポーネントから呼び出すことはこのコードではできなさそうです。

<details>
<summary>回答7-1</summary>

> e() を Nuxt 3 のコンポーネントから呼び出せるか？
> 
> 結論から言うと、e() を Nuxt 3 のコンポーネントから呼び出すことは可能 ですが、
> 現状のコードでは スクリプトの実行が遅れるため、Dify のチャットボットが正しく動作しない可能性があります。
> 
> 理由
> 
> - embed.min.js の e() 関数は document.body.onload = e; で登録されているため、Nuxt 3 の SPA モードでは発火しない。
> - e() を明示的に実行する方法がないため、Nuxt 側から手動で実行する必要がある。

</details>

<details>
<summary>回答7-2</summary>

> e() を手動で実行する方法
> 
> e() は embed.min.js の中で document.body.onload = e; によって呼ばれますが、Nuxt 3 では document.body.onload は SPA の初回ロード時にしか発火しないため、ページ遷移でボタンが消える問題 があります。
> 
> そこで、スクリプトを読み込んだ後に e() を手動で実行する 方法を採用します。
> 
> 修正後の DifyChatbot.vue
> ```vue
> <template>
>   <div id="dify-chatbot"></div>
> </template>
> 
> <script setup lang="ts">
> import { onMounted } from 'vue';
> 
> onMounted(() => {
>   if (process.client) {
>     window.difyChatbotConfig = {
>       token: 'aJRLs0Tm4WzBt3L6'
>     };
> 
>     if (!document.getElementById('dify-chatbot-script')) {
>       const script = document.createElement('script');
>       script.src = "https://udify.app/embed.min.js";
>       script.id = "dify-chatbot-script";
>       script.defer = true;
> 
>       script.onload = () => {
>         console.log('Dify chatbot script loaded successfully.');
> 
>         // `e()` を手動で実行する
>         if (typeof window.e === 'function') {
>           window.e();
>         } else {
>           console.error('Dify chatbot initialization function `e()` not found.');
>         }
>       };
> 
>       document.body.appendChild(script);
>     } else {
>       // スクリプトが既に読み込まれている場合も `e()` を実行
>       if (typeof window.e === 'function') {
>         window.e();
>       }
>     }
>   }
> });
> </script>
> ```

</details>

window.e()を手動で実行すれば動くという回答でした。しかし、windowオブジェクトにeは登録されていませんので、グローバルスコープ(types/global.d.ts)にインタフェース定義をしなければなりません。

定義せずとも良い方法を教えてくれましたが、Difyの方でエラーが出てしまい断念しました。


内部スクリプトとして使う方法にはもうひとつ問題があり、scriptタグに外部スクリプトのコードを埋め込んだのは良いものの、チャットウィンドウの要素が消えてしまうという問題でした。

<details open>
<summary>質問8</summary>

> ページ遷移するとscriptタグだけ残って追加したDOMは消えてしまいます。どうやったら残せますか？

</details>

<details open>
<summary>回答8</summary>

| 解決策 | 方法 | メリット | デメリット |
| --- | --- | --- | --- |
| 解決策 1 | \<Teleport\> を使う| ✅ Nuxt の仮想 DOM の影響を受けない | ❌ ページ遷移後に削除されたら復元できない |
| 解決策 2 | localStorage を使って復元 | ✅ ページ遷移後も復元できる | ❌ 初回ロード時に localStorage が空なら復元できない |
| 解決策 3 | layouts/default.vue に配置 | ✅ 最も簡単で確実な方法 | ❌ すべてのページでチャットボットを表示する必要がある |

</details>

3つの方法を提案してもらいましたが、
- 一部のページのみ表示したい
- ページ遷移後も表示したい
この2点を解決してくれる方法はなさそうです。

一応、Difyのチャットウィンドウ自体は表示されましたが、その先でエラーが続出してしまいました。

<details open>
<summary>質問9</summary>

> Difyのウィンドウは出ましたが、「Application error: a client-side exception has occurred (see the browser console for more information).」というエラーが出ました。

</details>

<details open>
<summary>質問10</summary>

> TypeError: (0 , g.getPrevChatList) is not a functionです。初回でもエラーが出てしまいました。

</details>

内部スクリプトとして埋め込む方法では無理なようなので、この方法はここで断念しました。

#### Try5: 外部スクリプト自体をコンポーネント化してみる

次に試したアプローチは、Difyの外部スクリプト(embed.min.js)自体をNuxtコンポーネント化してみる、というものです。

Try4とTry5のいずれもDifyのアップデートと外部スクリプトの中身が変わってしまえば動かなくなる、というリスクはありますが、GitHubに公開されているNextのコードも変わることを考えれば許容範囲であると判断しました。

再びChatGPTに質問を投げました。

<details open>
<summary>質問11</summary>

> 以下のソースコードはDifyというチャットボットサービスのWebアプリへのチャットボット埋め込みに使うJSコードです。私は現在Nuxt3で開発を行なっています。このJSコードをNuxt3のコンポーネントコードに置き換えてください。function名はMinifyされているため、あなたが妥当な名前を考えて付け直してください。

JSコードは割愛します。embed.min.jsの中身を丸っと渡しました。

</details>

<details>
<summary>回答11</summary>

> ```vue
> <template>
>   <div>
>     <div
>       :id="bubbleButtonId"
>       :class="buttonClass"
>       @click="toggleChatWindow"
>       ref="chatButton"
>     >
>       <div v-html="isChatOpen ? closeIcon : openIcon"></div>
>     </div>
>     <iframe
>       v-if="isChatOpen"
>       :id="chatWindowId"
>       :src="iframeSrc"
>       :style="chatWindowStyle"
>       title="Dify Chatbot"
>       allow="fullscreen; microphone"
>     ></iframe>
>   </div>
> </template>
> 
> <script setup>
> import { ref, onMounted } from 'vue'
> 
> const difyChatbotConfig = window.difyChatbotConfig || {}
> const bubbleButtonId = 'dify-chatbot-bubble-button'
> const chatWindowId = 'dify-chatbot-bubble-window'
> const isChatOpen = ref(false)
> const iframeSrc = ref('')
> const chatButton = ref(null)
> 
> const openIcon = `
>   <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
>     <path d="M7 2h10a5 5 0 015 5v7a5 5 0 01-5 5h-1v3l-3-3H7a5 5 0 01-5-5V7a5 5 0 015-5z" fill="white"/>
>   </svg>`
> 
> const closeIcon = `
>   <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
>     <path d="M18 18L6 6M6 18L18 6" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
>   </svg>`
> 
> const buttonClass = 'fixed bottom-4 right-4 w-12 h-12 rounded-full bg-blue-600 shadow-lg flex items-center justify-center cursor-pointer z-50'
> 
> const chatWindowStyle = {
>   position: 'fixed',
>   bottom: '60px',
>   right: '16px',
>   width: '24rem',
>   height: '40rem',
>   maxWidth: 'calc(100vw - 2rem)',
>   maxHeight: 'calc(100vh - 6rem)',
>   backgroundColor: '#F3F4F6',
>   borderRadius: '12px',
>   border: 'none',
>   zIndex: '9999',
>   boxShadow: 'rgba(0, 0, 0, 0.2) 0px 10px 30px',
> }
> 
> const toggleChatWindow = () => {
>   isChatOpen.value = !isChatOpen.value
> }
> 
> const encodeInputs = async (inputs) => {
>   const encoded = {}
>   for (const [key, value] of Object.entries(inputs)) {
>     const encoder = new TextEncoder()
>     const compressed = await new Response(
>       new Blob([encoder.encode(value)]).stream().pipeThrough(new CompressionStream('gzip'))
>     ).arrayBuffer()
>     encoded[key] = btoa(String.fromCharCode(...new Uint8Array(compressed)))
>   }
>   return new URLSearchParams(encoded).toString()
> }
> 
> onMounted(async () => {
>   if (difyChatbotConfig && difyChatbotConfig.token) {
>     const inputs = await encodeInputs(difyChatbotConfig.inputs || {})
>     const baseUrl = difyChatbotConfig.baseUrl || `https://${difyChatbotConfig.isDev ? 'dev.' : ''}udify.app`
>     iframeSrc.value = `${baseUrl}/chatbot/${difyChatbotConfig.token}?${inputs}`
>   } else {
>     console.error('DifyChatbotConfig is missing or token not provided')
>   }
> })
> </script>
> 
> <style scoped>
> iframe {
>   overflow: hidden;
> }
> </style>
> ```

</details>

ようやく期待通りのコードが来ました。このアプローチが正解だったようです。追加で必要なコードを書いてもらいました。

<details open>
<summary>質問12</summary>

> ありがとう。window.difyChatbotConfigの定義が存在しないため、global.d.tsに定義を作りたいです。コードをお願いします。

</details>

<details>
<summary>回答12</summary>

> ```ts
> // types/global.d.ts
> 
> interface DifyChatbotConfig {
>   token: string
>   baseUrl?: string
>   isDev?: boolean
>   inputs?: Record<string, string>
>   containerProps?: {
>     className?: string
>     style?: string | Record<string, string>
>     [key: string]: any // イベントリスナーなどをサポート
>   }
>   draggable?: boolean
>   dragAxis?: 'x' | 'y' | 'both'
>   dynamicScript?: boolean
> }
> 
> // Windowオブジェクトにプロパティを追加
> declare global {
>   interface Window {
>     difyChatbotConfig?: DifyChatbotConfig
>   }
> }
> 
> export {}
> ```

</details>

もう一息。値を入れてなかったらしい。

<details open>
<summary>質問13</summary>

> DifyChatbotConfig is missing or token not providedが表示されます

</details>

<details>
<summary>回答13</summary>

> このエラーは、window.difyChatbotConfig に適切な設定が渡されていない場合に発生します。通常、これは以下のいずれかが原因です：
> 
> 1. window.difyChatbotConfig が定義されていない
> 2. token プロパティが存在しないか、空である
> 3. 設定の読み込みが遅れている
> 
> ```vue
> <script setup>
> if (process.client) {
>   window.difyChatbotConfig = {
>     token: 'your-dify-token', // 必須
>     baseUrl: 'https://udify.app', // 任意
>     isDev: false, // 任意
>     inputs: {
>       userId: '12345', // 任意
>     },
>     containerProps: {
>       className: 'custom-chatbot',
>       style: {
>         backgroundColor: '#fff',
>       },
>     },
>     draggable: true,
>     dragAxis: 'both',
>     dynamicScript: false,
>   }
> }
> </script>
> ```

</details>

ようやくこれで完成！

## Clineと一緒に実装を試みた

Clineでも実装を試してみました。
Anthropicのclaude-3.5-haiku-20241022を使っています。

#### Try1: Nuxtコンポーネントへの変換 

<details open>
<summary>指示1</summary>

> embed.htmlはWebページにDifyのチャットウィンドウを埋め込むためのコードです。このアプリはNuxt3で開発されているため、このままでは動きません。embed.htmlをNuxt3のコンポーネントに置き換えてください。ファイルはDifyChatBot/index.vueでお願いします。

</details>

ChatGPTの最初のコードとほぼ同じになりました。ボタンすら表示されず。

<details>
<summary>結果1-1</summary>

```vue
<template>
  <div>
    <!-- Difyチャットボットは自動的にDOMに追加されます -->
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { useRuntimeConfig } from '#app'

// スクリプトが読み込まれたかどうかを追跡
const scriptLoaded = ref(false)

// ランタイム設定からDifyのアプリIDを取得
const $config = useRuntimeConfig()
const difyAppId = $config.public.difyAppId

onMounted(() => {
  // クライアントサイドでのみスクリプトを読み込む
  if (process.client) {
    loadDifyScript()
  }
})

// Dify埋め込みコードを読み込む関数
const loadDifyScript = () => {
  if (scriptLoaded.value) {
    return
  }
  
  // 設定スクリプトを追加
  const configScript = document.createElement('script')
  configScript.textContent = `
    window.difyChatbotConfig = {
      token: '${difyAppId}'
    }
  `
  document.body.appendChild(configScript)
  
  // 埋め込みスクリプトを追加
  const embedScript = document.createElement('script')
  embedScript.src = 'https://udify.app/embed.min.js'
  embedScript.id = difyAppId
  embedScript.defer = true
  document.body.appendChild(embedScript)
  
  scriptLoaded.value = true
}

// コンポーネントがアンマウントされたときにクリーンアップ
onUnmounted(() => {
  if (!process.client || !scriptLoaded.value) {
    return
  }
  
  // スクリプト要素を削除
  const script = document.getElementById(difyAppId)
  if (script) {
    script.remove()
  }
  
  // チャットボット関連の要素を削除
  const bubbleButton = document.getElementById('dify-chatbot-bubble-button')
  const bubbleWindow = document.getElementById('dify-chatbot-bubble-window')
  
  if (bubbleButton) {
    bubbleButton.remove()
  }
  
  if (bubbleWindow) {
    bubbleWindow.remove()
  }
})
</script>

<style>
#dify-chatbot-bubble-button {
  background-color: #6667ab !important;
}

#dify-chatbot-bubble-window {
  width: 24rem !important;
  height: 40rem !important;
}
</style>
```

</details>

各所にデバッグ用のconsole.logを入れてもらい、コンソールのログを渡しながらコネコネし続けてみました。

ボタンとチャットウィンドウは出たものの、完全に自前で実装したものが出来上がってしまいました。Difyのサービスは一切使われないという悲しい結果に。

<details>
<summary>結果1-2</summary>

```vue
<template>
  <div>
    <!-- Difyチャットボットは自動的にDOMに追加されます -->
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { useRuntimeConfig } from '#app'

console.log('[DifyChatBot] コンポーネント初期化')

// スクリプトが読み込まれたかどうかを追跡
const scriptLoaded = ref(false)

// ランタイム設定からDifyのアプリIDを取得
const $config = useRuntimeConfig()
const difyAppId = $config.public.difyAppId
console.log('[DifyChatBot] アプリID:', difyAppId)

// Dify埋め込みコードを読み込む関数
const loadDifyScript = () => {
  console.log('[DifyChatBot] loadDifyScript 呼び出し')
  
  if (scriptLoaded.value) {
    console.log('[DifyChatBot] スクリプトはすでに読み込まれています')
    return
  }
  
  if (!process.client) {
    console.log('[DifyChatBot] サーバーサイドでの実行のため、スクリプト読み込みをスキップ')
    return
  }
  
  try {
    console.log('[DifyChatBot] Difyスクリプトを読み込みます')
    
    // embed.htmlと完全に同じ方法で実装
    // 重要: 最初に設定オブジェクトをグローバルに定義
    const configScript = document.createElement('script')
    configScript.textContent = `
      window.difyChatbotConfig = {
        token: '${difyAppId}'
      }
    `
    // 設定スクリプトを直接bodyに追加
    document.body.appendChild(configScript)
    console.log('[DifyChatBot] 設定スクリプトを追加しました')
    console.log('[DifyChatBot] window.difyChatbotConfig:', window.difyChatbotConfig)
    
    // 埋め込みスクリプトを追加
    const embedScript = document.createElement('script')
    embedScript.src = 'https://udify.app/embed.min.js'
    embedScript.id = difyAppId
    embedScript.defer = true
    
    embedScript.onload = () => {
      console.log('[DifyChatBot] 埋め込みスクリプトが正常に読み込まれました')
      
      // スクリプト読み込み後にボタンの存在を確認
      setTimeout(() => {
        const button = document.getElementById('dify-chatbot-bubble-button')
        console.log('[DifyChatBot] ボタン要素の存在確認:', !!button)
        
        if (!button) {
          console.warn('[DifyChatBot] ボタンが見つかりません。手動でボタンを作成します。')
          createManualButton()
        }
      }, 1000)
    }
    
    document.body.appendChild(embedScript)
    console.log('[DifyChatBot] 埋め込みスクリプトを追加しました')
    
    // スタイルを追加
    const styleElement = document.createElement('style')
    styleElement.textContent = `
      #dify-chatbot-bubble-button {
        display: block !important;
        visibility: visible !important;
        opacity: 1 !important;
        background-color: #6667ab !important;
        position: fixed !important;
        bottom: 20px !important;
        right: 20px !important;
        width: 60px !important;
        height: 60px !important;
        border-radius: 30px !important;
        z-index: 9999 !important;
      }
      
      #dify-chatbot-bubble-window {
        display: block !important;
        visibility: visible !important;
        opacity: 1 !important;
        width: 24rem !important;
        height: 40rem !important;
        max-width: calc(100vw - 2rem) !important;
        max-height: calc(100vh - 6rem) !important;
        border-radius: 12px !important;
        border: none !important;
        box-shadow: rgba(0, 0, 0, 0.2) 0px 10px 30px !important;
        position: fixed !important;
        bottom: 80px !important;
        right: 20px !important;
        z-index: 9998 !important;
      }
    `
    document.head.appendChild(styleElement)
    console.log('[DifyChatBot] スタイルを追加しました')
    
    scriptLoaded.value = true
    console.log('[DifyChatBot] scriptLoaded =', scriptLoaded.value)
  } catch (error) {
    console.error('[DifyChatBot] スクリプト読み込み処理中にエラーが発生しました:', error)
    scriptLoaded.value = false
  }
}

// チャットウィンドウの表示状態
const isChatWindowVisible = ref(false)

// 手動でボタンとチャットウィンドウを作成する関数
const createManualButton = () => {
  console.log('[DifyChatBot] 手動でボタンを作成します')
  
  // 既存のボタンがあれば削除
  const existingButton = document.getElementById('dify-chatbot-manual-button')
  if (existingButton) {
    existingButton.remove()
  }
  
  // 既存のウィンドウがあれば削除
  const existingWindow = document.getElementById('dify-chatbot-manual-window')
  if (existingWindow) {
    existingWindow.remove()
  }
  
  // 手動でボタンを作成
  const manualButton = document.createElement('div')
  manualButton.id = 'dify-chatbot-manual-button'
  manualButton.style.cssText = `
    display: block;
    position: fixed;
    bottom: 20px;
    right: 20px;
    width: 60px;
    height: 60px;
    border-radius: 30px;
    background-color: #6667ab;
    z-index: 9999;
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
    box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
    transition: transform 0.3s ease;
  `
  manualButton.innerHTML = `
    <svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
      <path d="M7 2h10a5 5 0 015 5v7a5 5 0 01-5 5h-1v3l-3-3H7a5 5 0 01-5-5V7a5 5 0 015-5z" fill="white"/>
    </svg>
  `
  
  // チャットウィンドウを作成
  const chatWindow = document.createElement('div')
  chatWindow.id = 'dify-chatbot-manual-window'
  chatWindow.style.cssText = `
    display: none;
    position: fixed;
    bottom: 90px;
    right: 20px;
    width: 380px;
    height: 500px;
    background-color: white;
    border-radius: 12px;
    box-shadow: 0 5px 20px rgba(0, 0, 0, 0.2);
    z-index: 9998;
    overflow: hidden;
    flex-direction: column;
  `
  
  // チャットウィンドウのヘッダー
  const chatHeader = document.createElement('div')
  chatHeader.style.cssText = `
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 15px 20px;
    background-color: #6667ab;
    color: white;
    font-family: sans-serif;
    font-weight: bold;
  `
  chatHeader.innerHTML = `
    <div>チャットサポート</div>
    <div style="cursor: pointer;" id="dify-chatbot-close-button">
      <svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
        <path d="M18 6L6 18M6 6l12 12" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
      </svg>
    </div>
  `
  
  // チャットウィンドウのコンテンツ
  const chatContent = document.createElement('div')
  chatContent.style.cssText = `
    flex: 1;
    padding: 20px;
    overflow-y: auto;
    background-color: #f8f9fa;
    display: flex;
    flex-direction: column;
  `
  chatContent.innerHTML = `
    <div style="
      background-color: #e9ecef;
      padding: 12px 15px;
      border-radius: 10px 10px 10px 0;
      margin-bottom: 15px;
      max-width: 80%;
      font-family: sans-serif;
      font-size: 14px;
      align-self: flex-start;
    ">
      こんにちは！どのようにお手伝いできますか？
    </div>
  `
  
  // チャットウィンドウの入力エリア
  const chatInput = document.createElement('div')
  chatInput.style.cssText = `
    display: flex;
    padding: 15px;
    border-top: 1px solid #e9ecef;
    background-color: white;
  `
  chatInput.innerHTML = `
    <input type="text" placeholder="メッセージを入力..." style="
      flex: 1;
      padding: 10px 15px;
      border: 1px solid #ced4da;
      border-radius: 20px;
      outline: none;
      font-family: sans-serif;
      font-size: 14px;
    " id="dify-chatbot-input">
    <button style="
      background-color: #6667ab;
      color: white;
      border: none;
      border-radius: 50%;
      width: 40px;
      height: 40px;
      margin-left: 10px;
      cursor: pointer;
      display: flex;
      align-items: center;
      justify-content: center;
    " id="dify-chatbot-send">
      <svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
        <path d="M22 2L11 13M22 2L15 22L11 13M11 13L2 9" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
      </svg>
    </button>
  `
  
  // チャットウィンドウを組み立てる
  chatWindow.appendChild(chatHeader)
  chatWindow.appendChild(chatContent)
  chatWindow.appendChild(chatInput)
  
  // ボタンクリックイベントを追加
  manualButton.addEventListener('click', () => {
    console.log('[DifyChatBot] 手動ボタンがクリックされました')
    toggleChatWindow(chatWindow, manualButton)
  })
  
  // 閉じるボタンのイベントを追加
  document.body.appendChild(chatWindow)
  document.getElementById('dify-chatbot-close-button').addEventListener('click', () => {
    console.log('[DifyChatBot] 閉じるボタンがクリックされました')
    toggleChatWindow(chatWindow, manualButton)
  })
  
  // 送信ボタンのイベントを追加
  document.getElementById('dify-chatbot-send').addEventListener('click', () => {
    const input = document.getElementById('dify-chatbot-input')
    const message = input.value.trim()
    if (message) {
      console.log('[DifyChatBot] メッセージ送信:', message)
      addUserMessage(chatContent, message)
      input.value = ''
      
      // 簡単な応答を追加（実際のAPIを使用する場合はここを変更）
      setTimeout(() => {
        addBotMessage(chatContent, 'ありがとうございます。サポートチームに転送しました。')
      }, 1000)
    }
  })
  
  // Enterキーでメッセージを送信
  document.getElementById('dify-chatbot-input').addEventListener('keypress', (e) => {
    if (e.key === 'Enter') {
      document.getElementById('dify-chatbot-send').click()
    }
  })
  
  document.body.appendChild(manualButton)
  console.log('[DifyChatBot] 手動でボタンを作成しました')
}

// チャットウィンドウの表示/非表示を切り替える
const toggleChatWindow = (chatWindow, button) => {
  isChatWindowVisible.value = !isChatWindowVisible.value
  
  if (isChatWindowVisible.value) {
    chatWindow.style.display = 'flex'
    button.style.transform = 'scale(0.9)'
    // 入力フィールドにフォーカス
    setTimeout(() => {
      const input = document.getElementById('dify-chatbot-input')
      if (input) input.focus()
    }, 300)
  } else {
    chatWindow.style.display = 'none'
    button.style.transform = 'scale(1)'
  }
  
  console.log('[DifyChatBot] チャットウィンドウの表示状態:', isChatWindowVisible.value)
}

// ユーザーメッセージを追加
const addUserMessage = (container, message) => {
  const messageElement = document.createElement('div')
  messageElement.style.cssText = `
    background-color: #6667ab;
    color: white;
    padding: 12px 15px;
    border-radius: 10px 10px 0 10px;
    margin-bottom: 15px;
    max-width: 80%;
    font-family: sans-serif;
    font-size: 14px;
    align-self: flex-end;
  `
  messageElement.textContent = message
  container.appendChild(messageElement)
  container.scrollTop = container.scrollHeight
}

// ボットメッセージを追加
const addBotMessage = (container, message) => {
  const messageElement = document.createElement('div')
  messageElement.style.cssText = `
    background-color: #e9ecef;
    padding: 12px 15px;
    border-radius: 10px 10px 10px 0;
    margin-bottom: 15px;
    max-width: 80%;
    font-family: sans-serif;
    font-size: 14px;
    align-self: flex-start;
  `
  messageElement.textContent = message
  container.appendChild(messageElement)
  container.scrollTop = container.scrollHeight
}

onMounted(() => {
  console.log('[DifyChatBot] onMounted イベント')
  // クライアントサイドでのみスクリプトを読み込む
  if (process.client) {
    console.log('[DifyChatBot] クライアントサイドで実行中、スクリプトを読み込みます')
    loadDifyScript()
  } else {
    console.log('[DifyChatBot] サーバーサイドで実行中、スクリプト読み込みをスキップ')
  }
})

// コンポーネントがアンマウントされたときにクリーンアップ
onUnmounted(() => {
  console.log('[DifyChatBot] onUnmounted イベント')
  
  if (!process.client) {
    console.log('[DifyChatBot] サーバーサイドでの実行のため、クリーンアップをスキップ')
    return
  }
  
  if (!scriptLoaded.value) {
    console.log('[DifyChatBot] スクリプトが読み込まれていないため、クリーンアップは不要')
    return
  }
  
  console.log('[DifyChatBot] クリーンアップを開始')
  
  // 設定スクリプトを削除（textContentで追加したものは直接取得できないため、
  // すべてのインラインスクリプトを検索して内容で判断）
  document.querySelectorAll('script').forEach(script => {
    if (script.textContent && script.textContent.includes('difyChatbotConfig')) {
      script.remove()
      console.log('[DifyChatBot] 設定スクリプト要素を削除しました')
    }
  })
  
  // 埋め込みスクリプトを削除
  const script = document.getElementById(difyAppId)
  if (script) {
    script.remove()
    console.log('[DifyChatBot] 埋め込みスクリプト要素を削除しました')
  } else {
    console.log('[DifyChatBot] 埋め込みスクリプト要素が見つかりません')
  }
  
  // チャットボット関連の要素を削除
  const bubbleButton = document.getElementById('dify-chatbot-bubble-button')
  const bubbleWindow = document.getElementById('dify-chatbot-bubble-window')
  
  if (bubbleButton) {
    bubbleButton.remove()
    console.log('[DifyChatBot] バブルボタン要素を削除しました')
  } else {
    console.log('[DifyChatBot] バブルボタン要素が見つかりません')
  }
  
  if (bubbleWindow) {
    bubbleWindow.remove()
    console.log('[DifyChatBot] チャットウィンドウ要素を削除しました')
  } else {
    console.log('[DifyChatBot] チャットウィンドウ要素が見つかりません')
  }
  
  // グローバル設定オブジェクトをクリア
  if (window.difyChatbotConfig) {
    window.difyChatbotConfig = null
    console.log('[DifyChatBot] グローバル設定オブジェクトをクリアしました')
  }
  
  console.log('[DifyChatBot] クリーンアップ完了')
})
</script>

<style>
#dify-chatbot-bubble-button {
  background-color: #6667ab !important;
}

#dify-chatbot-bubble-window {
  width: 24rem !important;
  height: 40rem !important;
  max-width: calc(100vw - 2rem) !important;
  max-height: calc(100vh - 6rem) !important;
  border-radius: 12px !important;
  border: none !important;
  box-shadow: rgba(0, 0, 0, 0.2) 0px 10px 30px !important;
}
</style>
```

</details>


#### Try2: 外部スクリプト自体をコンポーネント化してみる

仕方ないので、ChatGPTと同じアプローチでembed.min.jsをコンポーネント化してみることとしました。生成を試みましたが、ChatGPTの時と同様には行かず、誤ったコードを書いてしまうことを繰り返したため、もっと具体的な指示をすることにしました。かなり細かく指示をしないと動いてくれなかったので、ChatGPTの方が賢いかもしれません。

<details open>
<summary>指示2</summary>

> /components/models/Common/DifyChatBot/embed.htmlはDifyのチャットボットウィンドウをページに埋め込むためのコードです。これはプレーンなHTMLページでのみ使えますが、shihon-frontendアプリはNuxt3で作られていますので、そのまま使うことができません。
> 
> そこでembed.htmlからiframeで読み込んでいるhttps://udify.app/embed.min.js embed.min.jsを/components/models/Common/DifyChatBot/embed.min.jsに配置しました。
> 
> embed.htmlとembed.min.jsを読み取ってindex.vueを作成してください。index.vueはNuxt3のコンポーネントファイルです。
> 
> なお、embed.min.jsのpはembed.htmlのwindow.difyChatbotConfigです。
> 
> ```ts
> window.difyChatbotConfig = {
>   token: 'xxxxxxxxxx'
> }
> ```

</details>

<details>
<summary>回答2</summary>

```vue:index.vue
<template>
  <div>
    <div 
      id="dify-chatbot-bubble-button" 
      @click="toggleChatbot"
    >
      <div class="button-content">
        <component :is="isLoaded ? CloseIcon : OpenIcon" />
      </div>
    </div>
    <div 
      v-if="isLoaded" 
      id="dify-chatbot-bubble-window"
    >
      <iframe 
        ref="chatbotIframe"
        :src="chatbotUrl"
        allow="fullscreen;microphone"
        title="Dify Chatbot"
      />
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import OpenIcon from './OpenIcon.vue'
import CloseIcon from './CloseIcon.vue'

const isLoaded = ref(false)
const chatbotUrl = ref('')
const chatbotIframe = ref(null)

const config = {
  token: 'aJRLs0Tm4WzBt3L6',
  baseUrl: 'https://udify.app',
  containerProps: {
    style: {
      bottom: '1rem',
      right: '1rem',
      width: '50px',
      height: '50px',
      borderRadius: '25px',
      backgroundColor: '#6667ab',
      boxShadow: 'rgba(0, 0, 0, 0.2) 0px 4px 8px 0px',
      cursor: 'pointer',
      zIndex: 2147483647
    }
  }
}

const toggleChatbot = () => {
  if (!isLoaded.value) {
    chatbotUrl.value = `${config.baseUrl}/chatbot/${config.token}`
    isLoaded.value = true
  } else {
    isLoaded.value = false
  }
}

const handleKeydown = (event) => {
  if (event.key === 'Escape' && isLoaded.value) {
    isLoaded.value = false
  }
}

onMounted(() => {
  document.addEventListener('keydown', handleKeydown)
})
</script>

<style scoped>
#dify-chatbot-bubble-button {
  position: fixed;
  bottom: 1rem;
  right: 1rem;
  width: 50px;
  height: 50px;
  border-radius: 25px;
  background-color: #6667ab;
  box-shadow: rgba(0, 0, 0, 0.2) 0px 4px 8px 0px;
  cursor: pointer;
  z-index: 2147483647;
  display: flex;
  align-items: center;
  justify-content: center;
}

#dify-chatbot-bubble-window {
  position: fixed;
  bottom: 55px;
  right: 0;
  width: 24rem;
  max-width: calc(100vw - 2rem);
  height: 40rem;
  max-height: calc(100vh - 6rem);
  border-radius: 0.75rem;
  box-shadow: rgba(150, 150, 150, 0.2) 0px 10px 30px 0px, rgba(150, 150, 150, 0.2) 0px 0px 0px 1px;
  z-index: 2147483647;
  overflow: hidden;
  background-color: #F3F4F6;
}

#dify-chatbot-bubble-window iframe {
  width: 100%;
  height: 100%;
  border: none;
}
</style>

```

```vue:OpenIcon.vue
<template>
  <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
    <path fill-rule="evenodd" clip-rule="evenodd" d="M7.7586 2L16.2412 2C17.0462 1.99999 17.7105 1.99998 18.2517 2.04419C18.8138 2.09012 19.3305 2.18868 19.8159 2.43598C20.5685 2.81947 21.1804 3.43139 21.5639 4.18404C21.8112 4.66937 21.9098 5.18608 21.9557 5.74818C21.9999 6.28937 21.9999 6.95373 21.9999 7.7587L22 14.1376C22.0004 14.933 22.0007 15.5236 21.8636 16.0353C21.4937 17.4156 20.4155 18.4938 19.0352 18.8637C18.7277 18.9461 18.3917 18.9789 17.9999 18.9918L17.9999 20.371C18 20.6062 18 20.846 17.9822 21.0425C17.9651 21.2305 17.9199 21.5852 17.6722 21.8955C17.3872 22.2525 16.9551 22.4602 16.4983 22.4597C16.1013 22.4593 15.7961 22.273 15.6386 22.1689C15.474 22.06 15.2868 21.9102 15.1031 21.7632L12.69 19.8327C12.1714 19.4178 12.0174 19.3007 11.8575 19.219C11.697 19.137 11.5262 19.0771 11.3496 19.0408C11.1737 19.0047 10.9803 19 10.3162 19H7.75858C6.95362 19 6.28927 19 5.74808 18.9558C5.18598 18.9099 4.66928 18.8113 4.18394 18.564C3.43129 18.1805 2.81937 17.5686 2.43588 16.816C2.18859 16.3306 2.09002 15.8139 2.0441 15.2518C1.99988 14.7106 1.99989 14.0463 1.9999 13.2413V7.75868C1.99989 6.95372 1.99988 6.28936 2.0441 5.74818C2.09002 5.18608 2.18859 4.66937 2.43588 4.18404C2.81937 3.43139 3.43129 2.81947 4.18394 2.43598C4.66928 2.18868 5.18598 2.09012 5.74808 2.04419C6.28927 1.99998 6.95364 1.99999 7.7586 2ZM10.5073 7.5C10.5073 6.67157 9.83575 6 9.00732 6C8.1789 6 7.50732 6.67157 7.50732 7.5C7.50732 8.32843 8.1789 9 9.00732 9C9.83575 9 10.5073 8.32843 10.5073 7.5ZM16.6073 11.7001C16.1669 11.3697 15.5426 11.4577 15.2105 11.8959C15.1488 11.9746 15.081 12.0486 15.0119 12.1207C14.8646 12.2744 14.6432 12.4829 14.3566 12.6913C13.7796 13.111 12.9818 13.5001 12.0073 13.5001C11.0328 13.5001 10.235 13.111 9.65799 12.6913C9.37138 12.4829 9.15004 12.2744 9.00274 12.1207C8.93366 12.0486 8.86581 11.9745 8.80418 11.8959C8.472 11.4577 7.84775 11.3697 7.40732 11.7001C6.96549 12.0314 6.87595 12.6582 7.20732 13.1001C7.20479 13.0968 7.21072 13.1043 7.22094 13.1171C7.24532 13.1478 7.29407 13.2091 7.31068 13.2289C7.36932 13.2987 7.45232 13.3934 7.55877 13.5045C7.77084 13.7258 8.08075 14.0172 8.48165 14.3088C9.27958 14.8891 10.4818 15.5001 12.0073 15.5001C13.5328 15.5001 14.735 14.8891 15.533 14.3088C15.9339 14.0172 16.2438 13.7258 16.4559 13.5045C16.5623 13.3934 16.6453 13.2987 16.704 13.2289C16.7333 13.1939 16.7567 13.165 16.7739 13.1432C17.1193 12.6969 17.0729 12.0493 16.6073 11.7001ZM15.0073 6C15.8358 6 16.5073 6.67157 16.5073 7.5C16.5073 8.32843 15.8358 9 15.0073 9C14.1789 9 13.5073 8.32843 13.5073 7.5C13.5073 6.67157 14.1789 6 15.0073 6Z" fill="white"/>
  </svg>
</template>

<script>
export default {
  name: 'OpenIcon'
}
</script>
```

```vue:CloseIcon.vue
<template>
  <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
    <path d="M18 18L6 6M6 18L18 6" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
  </svg>
</template>

<script>
export default {
  name: 'CloseIcon'
}
</script>

```


</details>

これで動くコードができました。

ただ、本当にここまで指定しないと動かなかったのか？と思ったので一旦クリアしてChatGPTへの指示と同じ文言にしてみました。

<details open>
<summary>指示3-1</summary>

> /components/models/Common/DifyChatBot/embed.min.jsはDifyというチャットボットサービスのWebアプリへのチャットボット埋め込みに使うJSコードです。私は現在Nuxt3で開発を行なっています。このJSコードをNuxt3のコンポーネントコードに置き換えてください。function名はMinifyされているため、あなたが妥当な名前を考えて付け直してください。

</details>

これで進めて行ったのですが、指示が足りなかったようで、全ページにチャットボットを配置するような実装になってしまいました。

そこで、もっと明確な指示をすることにしました。

<details open>
<summary>指示4</summary>

> /components/models/Common/DifyChatBot/embed.min.jsはDifyというチャットボットサービスのチャットボットウィンドウを提供するコードです。
> /components/models/Common/DifyChatBot/embed.htmlははDifyのチャットボットウィンドウの表示/非表示を切り替えるための埋め込みコードです。これはNuxt3用ではなくプレーンなHTML環境で動作するコードです。iframeのsrc属性に設定されているのはembed.min.jsです。
> 
> 私は現在Nuxt3で開発を行なっています。
> これらのコードを元にNuxt3のコンポーネントコードに置き換えてください。function名はMinifyされているため、あなたが妥当な名前を考えて付け直してください。
> 
> なお、このチャットボットはDiscoverページだけに表示させたいです。Discoverページのコンポーネントは/components/pages/Discover/index.vueです。この中で<models-common-dify-chat-bot />という指定でチャットボットウィンドウのコンポーネント(/components/models/Common/DifyChatBot/index.vue)を読み込んでいます。
> 
> /components/models/Common/DifyChatBot/index.vueの中身を実装してください。

</details>

<details>
<summary>回答4</summary>

```vue
<template>
  <div class="dify-chatbot-container">
    <div 
      id="dify-chatbot-bubble-button" 
      @click="toggleChatbot"
      :style="buttonStyles"
    >
      <div class="icon-container">
        <svg v-if="!isChatbotOpen" id="openIcon" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
          <path fill-rule="evenodd" clip-rule="evenodd" d="M7.7586 2L16.2412 2C17.0462 1.99999 17.7105 1.99998 18.2517 2.04419C18.8138 2.09012 19.3305 2.18868 19.8159 2.43598C20.5685 2.81947 21.1804 3.43139 21.5639 4.18404C21.8112 4.66937 21.9098 5.18608 21.9557 5.74818C21.9999 6.28937 21.9999 6.95373 21.9999 7.7587L22 14.1376C22.0004 14.933 22.0007 15.5236 21.8636 16.0353C21.4937 17.4156 20.4155 18.4938 19.0352 18.8637C18.7277 18.9461 18.3917 18.9789 17.9999 18.9918L17.9999 20.371C18 20.6062 18 20.846 17.9822 21.0425C17.9651 21.2305 17.9199 21.5852 17.6722 21.8955C17.3872 22.2525 16.9551 22.4602 16.4983 22.4597C16.1013 22.4593 15.7961 22.273 15.6386 22.1689C15.474 22.06 15.2868 21.9102 15.1031 21.7632L12.69 19.8327C12.1714 19.4178 12.0174 19.3007 11.8575 19.219C11.697 19.137 11.5262 19.0771 11.3496 19.0408C11.1737 19.0047 10.9803 19 10.3162 19H7.75858C6.95362 19 6.28927 19 5.74808 18.9558C5.18598 18.9099 4.66928 18.8113 4.18394 18.564C3.43129 18.1805 2.81937 17.5686 2.43588 16.816C2.18859 16.3306 2.09002 15.8139 2.0441 15.2518C1.99988 14.7106 1.99989 14.0463 1.9999 13.2413V7.75868C1.99989 6.95372 1.99988 6.28936 2.0441 5.74818C2.09002 5.18608 2.18859 4.66937 2.43588 4.18404C2.81937 3.43139 3.43129 2.81947 4.18394 2.43598C4.66928 2.18868 5.18598 2.09012 5.74808 2.04419C6.28927 1.99998 6.95364 1.99999 7.7586 2ZM10.5073 7.5C10.5073 6.67157 9.83575 6 9.00732 6C8.1789 6 7.50732 6.67157 7.50732 7.5C7.50732 8.32843 8.1789 9 9.00732 9C9.83575 9 10.5073 8.32843 10.5073 7.5ZM16.6073 11.7001C16.1669 11.3697 15.5426 11.4577 15.2105 11.8959C15.1488 11.9746 15.081 12.0486 15.0119 12.1207C14.8646 12.2744 14.6432 12.4829 14.3566 12.6913C13.7796 13.111 12.9818 13.5001 12.0073 13.5001C11.0328 13.5001 10.235 13.111 9.65799 12.6913C9.37138 12.4829 9.15004 12.2744 9.00274 12.1207C8.93366 12.0486 8.86581 11.9745 8.80418 11.8959C8.472 11.4577 7.84775 11.3697 7.40732 11.7001C6.96549 12.0314 6.87595 12.6582 7.20732 13.1001C7.20479 13.0968 7.21072 13.1043 7.22094 13.1171C7.24532 13.1478 7.29407 13.2091 7.31068 13.2289C7.36932 13.2987 7.45232 13.3934 7.55877 13.5045C7.77084 13.7258 8.08075 14.0172 8.48165 14.3088C9.27958 14.8891 10.4818 15.5001 12.0073 15.5001C13.5328 15.5001 14.735 14.8891 15.533 14.3088C15.9339 14.0172 16.2438 13.7258 16.4559 13.5045C16.5623 13.3934 16.6453 13.2987 16.704 13.2289C16.7333 13.1939 16.7567 13.165 16.7739 13.1432C17.1193 12.6969 17.0729 12.0493 16.6073 11.7001ZM15.0073 6C15.8358 6 16.5073 6.67157 16.5073 7.5C16.5073 8.32843 15.8358 9 15.0073 9C14.1789 9 13.5073 8.32843 13.5073 7.5C13.5073 6.67157 14.1789 6 15.0073 6Z" fill="white"/>
        </svg>
        <svg v-else id="closeIcon" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
          <path d="M18 18L6 6M6 18L18 6" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
        </svg>
      </div>
    </div>

    <iframe 
      v-if="isChatbotOpen"
      id="dify-chatbot-bubble-window"
      :src="chatbotUrl"
      allow="fullscreen;microphone"
      title="Dify Chatbot Bubble Window"
    ></iframe>
  </div>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'
import type { CSSProperties } from 'vue'

const {isMobile} = useUtil()

const auth = useAuth()
let userId = auth.userInfo.value.userId

const $config = useRuntimeConfig()
const difyAppId = $config.public.difyAppId

const isChatbotOpen = ref(false)

if (!userId) {
  userId = auth.userInfo.value.uniqueClientId
}

const chatbotConfig = {
  token: difyAppId || 'aJRLs0Tm4WzBt3L6',
  baseUrl: 'https://udify.app',
  inputs: {
    userId: userId
  }
}

const chatbotUrl = computed(() => {
  const params = new URLSearchParams()
  Object.entries(chatbotConfig.inputs).forEach(([key, value]) => {
    params.append(key, value)
  })
  return `${chatbotConfig.baseUrl}/chatbot/${chatbotConfig.token}?${params.toString()}`
})

const buttonStyles: CSSProperties = {
  backgroundColor: '#6667ab',
  position: 'fixed',
  bottom: '1rem',
  right: '1rem',
  width: '48px',
  height: '48px',
  borderRadius: '7px',
  cursor: 'pointer',
  zIndex: 2147483647,
  display: 'flex',
  alignItems: 'center',
  justifyContent: 'center',
  boxShadow: 'rgba(0, 0, 0, 0.2) 0px 4px 8px 0px'
}

function toggleChatbot(event: MouseEvent) {
  event.preventDefault()
  isChatbotOpen.value = !isChatbotOpen.value
  auth.userInfo.value.isBotWindowOpen = isChatbotOpen.value
}

function handleEscapeKey(event: KeyboardEvent) {
  if (event.key === 'Escape' && isChatbotOpen.value) {
    isChatbotOpen.value = false
  }
}

if (!isMobile()) {
  if (auth.userInfo.value.isBotWindowOpen) {
    isChatbotOpen.value = true
  }
}

onMounted(() => {
  document.addEventListener('keydown', handleEscapeKey)
})

onUnmounted(() => {
  document.removeEventListener('keydown', handleEscapeKey)
})
</script>

<style scoped>
#dify-chatbot-bubble-window {
  position: fixed;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  right: 0;
  bottom: 0;
  width: 24rem;
  max-width: calc(100vw - 2rem);
  height: 40rem;
  max-height: calc(100vh - 6rem);
  border: none;
  z-index: 2147483640;
  overflow: hidden;
  user-select: none;
}

.icon-container {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 100%;
}
</style>
```

</details>

これでようやく動くコードができました。


# 成果物

## 画面

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/581277/cddb604a-7f1a-46fa-96cc-cb3bf4a20c21.png" width="300">

## コード

以上の実装過程を経て、最終的なコードは以下のようになりました。

(ChatGPTの部分を書いたあとにDify側でスクリプトの修正が入ってしまったので、ChatGPT版は古いコードになってしまいました。Claudeの方が最新です)


<details>
<summary>完成したコード(ChatGPT)</summary>

```vue
<template>
  <div>
    <div
      :id="bubbleButtonId"
      :class="buttonClass"
      @click="toggleChatWindow"
      ref="chatButton"
    >
      <div v-html="isChatOpen ? closeIcon : openIcon"></div>
    </div>
    <iframe
      v-if="isChatOpen"
      :id="chatWindowId"
      :src="iframeSrc"
      :style="chatWindowStyle"
      title="Dify Chatbot"
      allow="fullscreen; microphone"
    ></iframe>
  </div>
</template>

<script setup>
const bubbleButtonId = 'dify-chatbot-bubble-button'
const chatWindowId = 'dify-chatbot-bubble-window'
const isChatOpen = ref(false)
const iframeSrc = ref('')
const chatButton = ref(null)

const openIcon = `
  <svg width="24" height="24" class="fill-current text-white" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
    <path d="M7 2h10a5 5 0 015 5v7a5 5 0 01-5 5h-1v3l-3-3H7a5 5 0 01-5-5V7a5 5 0 015-5z"/>
  </svg>
  `

const closeIcon = `
  <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
    <path d="M18 18L6 6M6 18L18 6" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
  </svg>`

const buttonClass = 'fixed bottom-3 right-4 w-12 h-12 rounded-xl bg-[#6667ab] shadow-lg flex items-center justify-center cursor-pointer z-50'

const chatWindowStyle = {
  position: 'fixed',
  bottom: '66px',
  right: '16px',
  width: '24rem',
  height: '40rem',
  maxWidth: 'calc(100vw - 2rem)',
  maxHeight: 'calc(100vh - 6rem)',
  backgroundColor: '#F3F4F6',
  borderRadius: '12px',
  border: 'none',
  zIndex: '9999',
  boxShadow: 'rgba(0, 0, 0, 0.2) 0px 10px 30px',
}

const toggleChatWindow = () => {
  isChatOpen.value = !isChatOpen.value
}

const encodeInputs = async (inputs) => {
  const encoded = {}
  for (const [key, value] of Object.entries(inputs)) {
    const encoder = new TextEncoder()
    const compressed = await new Response(
      new Blob([encoder.encode(value)]).stream().pipeThrough(new CompressionStream('gzip'))
    ).arrayBuffer()
    encoded[key] = btoa(String.fromCharCode(...new Uint8Array(compressed)))
  }
  return new URLSearchParams(encoded).toString()
}

const $config = useRuntimeConfig()
const difyAppId = $config.public.difyAppId

onMounted(async () => {
  window.difyChatbotConfig = {
    token: difyAppId, // 必須
    containerProps: {
    },
    draggable: true,
    dragAxis: 'both',
    dynamicScript: false,
  }

  const difyChatbotConfig = window.difyChatbotConfig || {}
  if (difyChatbotConfig && difyChatbotConfig.token) {
    const inputs = await encodeInputs(difyChatbotConfig.inputs || {})
    const baseUrl = difyChatbotConfig.baseUrl || `https://${difyChatbotConfig.isDev ? 'dev.' : ''}udify.app`
    iframeSrc.value = `${baseUrl}/chatbot/${difyChatbotConfig.token}?${inputs}`
  } else {
    console.error('DifyChatbotConfig is missing or token not provided')
  }
})
</script>

<style scoped>
iframe {
  overflow: hidden;
}
</style>

```
</details>


<details>
<summary>完成したコード(Claude3.5)</summary>

```vue
<template>
  <div class="dify-chatbot-container">
    <div 
      id="dify-chatbot-bubble-button" 
      @click="toggleChatbot"
      :style="buttonStyles"
    >
      <div class="icon-container">
        <svg v-if="!isChatbotOpen" id="openIcon" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
          <path fill-rule="evenodd" clip-rule="evenodd" d="M7.7586 2L16.2412 2C17.0462 1.99999 17.7105 1.99998 18.2517 2.04419C18.8138 2.09012 19.3305 2.18868 19.8159 2.43598C20.5685 2.81947 21.1804 3.43139 21.5639 4.18404C21.8112 4.66937 21.9098 5.18608 21.9557 5.74818C21.9999 6.28937 21.9999 6.95373 21.9999 7.7587L22 14.1376C22.0004 14.933 22.0007 15.5236 21.8636 16.0353C21.4937 17.4156 20.4155 18.4938 19.0352 18.8637C18.7277 18.9461 18.3917 18.9789 17.9999 18.9918L17.9999 20.371C18 20.6062 18 20.846 17.9822 21.0425C17.9651 21.2305 17.9199 21.5852 17.6722 21.8955C17.3872 22.2525 16.9551 22.4602 16.4983 22.4597C16.1013 22.4593 15.7961 22.273 15.6386 22.1689C15.474 22.06 15.2868 21.9102 15.1031 21.7632L12.69 19.8327C12.1714 19.4178 12.0174 19.3007 11.8575 19.219C11.697 19.137 11.5262 19.0771 11.3496 19.0408C11.1737 19.0047 10.9803 19 10.3162 19H7.75858C6.95362 19 6.28927 19 5.74808 18.9558C5.18598 18.9099 4.66928 18.8113 4.18394 18.564C3.43129 18.1805 2.81937 17.5686 2.43588 16.816C2.18859 16.3306 2.09002 15.8139 2.0441 15.2518C1.99988 14.7106 1.99989 14.0463 1.9999 13.2413V7.75868C1.99989 6.95372 1.99988 6.28936 2.0441 5.74818C2.09002 5.18608 2.18859 4.66937 2.43588 4.18404C2.81937 3.43139 3.43129 2.81947 4.18394 2.43598C4.66928 2.18868 5.18598 2.09012 5.74808 2.04419C6.28927 1.99998 6.95364 1.99999 7.7586 2ZM10.5073 7.5C10.5073 6.67157 9.83575 6 9.00732 6C8.1789 6 7.50732 6.67157 7.50732 7.5C7.50732 8.32843 8.1789 9 9.00732 9C9.83575 9 10.5073 8.32843 10.5073 7.5ZM16.6073 11.7001C16.1669 11.3697 15.5426 11.4577 15.2105 11.8959C15.1488 11.9746 15.081 12.0486 15.0119 12.1207C14.8646 12.2744 14.6432 12.4829 14.3566 12.6913C13.7796 13.111 12.9818 13.5001 12.0073 13.5001C11.0328 13.5001 10.235 13.111 9.65799 12.6913C9.37138 12.4829 9.15004 12.2744 9.00274 12.1207C8.93366 12.0486 8.86581 11.9745 8.80418 11.8959C8.472 11.4577 7.84775 11.3697 7.40732 11.7001C6.96549 12.0314 6.87595 12.6582 7.20732 13.1001C7.20479 13.0968 7.21072 13.1043 7.22094 13.1171C7.24532 13.1478 7.29407 13.2091 7.31068 13.2289C7.36932 13.2987 7.45232 13.3934 7.55877 13.5045C7.77084 13.7258 8.08075 14.0172 8.48165 14.3088C9.27958 14.8891 10.4818 15.5001 12.0073 15.5001C13.5328 15.5001 14.735 14.8891 15.533 14.3088C15.9339 14.0172 16.2438 13.7258 16.4559 13.5045C16.5623 13.3934 16.6453 13.2987 16.704 13.2289C16.7333 13.1939 16.7567 13.165 16.7739 13.1432C17.1193 12.6969 17.0729 12.0493 16.6073 11.7001ZM15.0073 6C15.8358 6 16.5073 6.67157 16.5073 7.5C16.5073 8.32843 15.8358 9 15.0073 9C14.1789 9 13.5073 8.32843 13.5073 7.5C13.5073 6.67157 14.1789 6 15.0073 6Z" fill="white"/>
        </svg>
        <svg v-else id="closeIcon" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
          <path d="M18 18L6 6M6 18L18 6" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
        </svg>
      </div>
    </div>

    <iframe 
      v-if="isChatbotOpen"
      id="dify-chatbot-bubble-window"
      :src="chatbotUrl"
      allow="fullscreen;microphone"
      title="Dify Chatbot Bubble Window"
    ></iframe>
  </div>
</template>

<script setup lang="ts">
import { ref, computed } from 'vue'
import type { CSSProperties } from 'vue'

const {isMobile} = useUtil()

const auth = useAuth()
let userId = auth.userInfo.value.userId

const $config = useRuntimeConfig()
const difyAppId = $config.public.difyAppId

const isChatbotOpen = ref(false)

if (!userId) {
  userId = auth.userInfo.value.uniqueClientId
}

const chatbotConfig = {
  token: difyAppId || 'aJRLs0Tm4WzBt3L6',
  baseUrl: 'https://udify.app',
  inputs: {
    userId: userId
  }
}

const chatbotUrl = computed(() => {
  const params = new URLSearchParams()
  Object.entries(chatbotConfig.inputs).forEach(([key, value]) => {
    params.append(key, value)
  })
  return `${chatbotConfig.baseUrl}/chatbot/${chatbotConfig.token}?${params.toString()}`
})

const buttonStyles: CSSProperties = {
  backgroundColor: '#6667ab',
  position: 'fixed',
  bottom: '1rem',
  right: '1rem',
  width: '48px',
  height: '48px',
  borderRadius: '7px',
  cursor: 'pointer',
  zIndex: 2147483647,
  display: 'flex',
  alignItems: 'center',
  justifyContent: 'center',
  boxShadow: 'rgba(0, 0, 0, 0.2) 0px 4px 8px 0px'
}

function toggleChatbot(event: MouseEvent) {
  event.preventDefault()
  isChatbotOpen.value = !isChatbotOpen.value
  auth.userInfo.value.isBotWindowOpen = isChatbotOpen.value
}

function handleEscapeKey(event: KeyboardEvent) {
  if (event.key === 'Escape' && isChatbotOpen.value) {
    isChatbotOpen.value = false
  }
}

if (!isMobile()) {
  if (auth.userInfo.value.isBotWindowOpen) {
    isChatbotOpen.value = true
  }
}

onMounted(() => {
  document.addEventListener('keydown', handleEscapeKey)
})

onUnmounted(() => {
  document.removeEventListener('keydown', handleEscapeKey)
})
</script>

<style scoped>
#dify-chatbot-bubble-window {
  position: fixed;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  right: 0;
  bottom: 0;
  width: 24rem;
  max-width: calc(100vw - 2rem);
  height: 40rem;
  max-height: calc(100vh - 6rem);
  border: none;
  z-index: 2147483640;
  overflow: hidden;
  user-select: none;
}

.icon-container {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 100%;
}
</style>
```

</details>

# まとめ

ChatGPTとClaude、どちらも結果としては動くコードを作ってくれました。

ただ、結果的にはDifyのサーバーで提供しているJSコードをコンポーネント化する形になってしまいました。今回のように途中でJSコードに変更があった時は修正が必要になります。なんとか回避したいところですが、今後の課題としたいと思います。

最後にそれぞれで実装してみての感想です。

## ChatGPT(GPT4o)

- ChatGPTの会話ページにコードをコピペする形で作業を行ったが、コピペが手間になり手軽さはあまりなかった
- IDEは使っていないのでAIが余計なコードを使うようなことはなく、渡したコードのみで実装してくれたのは良かった
- レスポンスが速いので待ち時間はほとんどなくトータルの時間は短く、結果論ではあるが最初からJSコードを変換するアプローチができていたら短時間でできていたと思われる

## Cline with Claude3.5

Planモードは途中からエラーが出てしまったのでActモードのみで実装しました。

- IDEに統合されており、途中で修正前後のDiffも出してくれるのもよく、手軽さがあった
- ワークスペース内からファイルを検索してくれるのは便利だと思った
- 何度かエラーが出るコードを吐いてしまい、AI自身が修正を行う工程は無駄なコストだと思った
- Planモードで実装前に要件や実装方針を固められるのはとても良い体験だった
- コードの正確性を出すための指示がかなり細かく行わなければならない印象
- 会話を繰り返すたびにレスポンスが遅くなっていくので待ち時間が多く発生した

## おわりに

どちらも一長一短あるものの、適切なアプローチを取れば正しく実装してくれました。人間が読むのには難解なコードを解析して読みやすい形にしてくれるのはかなりのメリットだと思いました。

Claudeの方はclaude-3.7-sonnetで最初はトライしてみたのですが、2日で$11消費という結構なコストがかかってしまったので、途中で安価なclaude-3.5-haikuに変えました。3.7は3.5に比べてコードの読み取りとレスポンスが速かった印象でした。

今後も開発シーンでAIを活用して生産性を上げて行きたいと思います。

最後までお読みいただき、ありがとうございました。