Markdown AIで昔話生成Botを公開してみた！

# はじめに
[記事投稿キャンペーン](https://qiita.com/official-events/6914e7e898953881ef94)の賞品のポータブル電源が欲しい…！！！

と、いうことでついに追加されたMarkdown AIのサーバーAI機能を使って、昔話風の物語を作ってくれる＆朗読してくれるWebアプリを公開したので紹介します！

気になったらここからアクセス！
↓↓↓

https://mdown.ai/content/21c6822a-9e32-41f2-ac9d-fc373401a66f


# どんな技術を使ってるの？
Markdown AIにJavaScriptを組み合わせ、ブラウザ標準のWeb Speech APIを利用することで実現しました

Markdown AIでは、サーバーAIを利用する際にJavaScriptを使っており、この部分に独自のコードを入れ込みました

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2921137/9e3966d4-c86e-9c5c-aae9-6a9b8d18e436.png)

Web Speech APIはW3Cで策定されているWeb標準APIで、近年のモダンなブラウザならばほとんどの端末で利用でき、読み上げだけでなく音声認識も行うことができます

https://developer.mozilla.org/ja/docs/Web/API/Web_Speech_API

# 特徴・こだわった点
## Markdownで書いたときと同じUI
Markdown AIで挿入できる「サーバーAIを利用するためのスクリプト」(前項画像参照)では、LLMが生成したテキストを、div要素の中に直接入れて表示するようになっています

つまりLLMにMarkdownで出力するように指示していると、タイトルが「#」から始まるようなテキストがただ表示されるだけのWebアプリになってしまいます

このWebアプリでは、最低限のMarkdown記法をMarkdown AIがhtmlでの表示に利用しているタグに変換してあげることで、デザイン性を確保しました

<details open><summary>変換スクリプト</summary>

``` JavaScript:main.js
function markdownToHtml(markdown) {
    // HTML出力を格納する変数
    let html = "";

    // Markdownテキストを行ごとに分割
    const lines = markdown.split("\n");

    lines.forEach((line) => {
        // 見出し1 (# ) -> <h1 class="is-size-1">
        if (/^# (.+)/.test(line)) {
            const content = line.match(/^# (.+)/)[1];
            html += `<h1 class="is-size-1">${content}</h1>`;
        }
        // 見出し2 (## ) -> <h2 class="is-size-2">
        else if (/^## (.+)/.test(line)) {
            const content = line.match(/^## (.+)/)[1];
            html += `<h2 class="is-size-2">${content}</h2>`;
        }
        // 段落 -> <p>
        else if (/^(.+)$/.test(line) && !/^#/.test(line)) {
            const content = line.match(/^(.+)$/)[1];
            html += `<p>${content}</p>`;
        }
        // 空行 -> <p style="margin-bottom: 1.35rem"></p>
        else if (/^\s*$/.test(line)) {
            html += `<p style="margin-bottom: 1.35rem"></p>`;
        }
        // 水平線 (---) -> <hr class="horizontal-rule">
        else if (/^---$/.test(line)) {
            html += `<hr class="horizontal-rule">`;
        }
    });

    return html;
}
```


</details>

## 文章の読み上げ
昔話と言えば朗読だろう、ということで、Web Speech APIを利用した読み上げ機能を実装しました

導入に外部ライブラリ等を必要としないので、Markdown AIでも利用することができました

# Markdown AIの良かったところ
## 公開しているページのリンクが変わらない
一度公開したページは、内容を編集してもリンクが変わらないので、ページを共有する際に使い勝手が良いなと思いました

## 即時更新
編集画面で保存ボタンをクリックすると、すぐに公開中のページの内容が切り替わるので、更新待ちの時間がなくて便利でした

# Markdown AIの改善できそうなところ
## エディタ画面
エディタがhtmlのtextarea要素そのままなので、段落やTabなどの表示が凄く見にくいです
Qiitaのようなリッチなエディタになると使いやすくなるかなと思います

# 作成したMarkdown(JavaScript)

<details open><summary>折りたためます</summary>

``` html:main.md
# 昔話生成Bot
##### あなたの好きなテーマ・登場人物で昔話風の物語を作ろう！

---

<div style="display: inline-block; margin-bottom: 30px;">
  <h5>テーマを入力</h5>
  <input type="text" id="text-1732073162"
    style="width: 200px; height: 30px; border: 0.1svw solid #000; border-radius: 0.5svw;" value="">
  <button type="button" id="button-1732073162"
    style="text-align: center; width: 75px; height: 30px; background-color: white; border: 0.1svw solid #000; border-radius: 0.5svw;">生成開始</button>
  <button type="button" id="startstopSpeech"
    style="text-align: center; width: 75px; height: 30px; background-color: white; border: 0.1svw solid #000; border-radius: 0.5svw;">朗読開始</button>
  <button type="button" id="resetSpeech"
    style="text-align: center; width: 95px; height: 30px; background-color: white; border: 0.1svw solid #000; border-radius: 0.5svw;">朗読リセット</button>
</div>
<div id="answer-1732073162"></div>

<script>
  (() => {
    function markdownToHtml(markdown) {
      // HTML出力を格納する変数
      let html = "";

      // Markdownテキストを行ごとに分割
      const lines = markdown.split("\n");

      lines.forEach((line) => {
        // 見出し1 (# ) -> <h1 class="is-size-1">
        if (/^# (.+)/.test(line)) {
          const content = line.match(/^# (.+)/)[1];
          html += `<h1 class="is-size-1">${content}</h1>`;
        }
        // 見出し2 (## ) -> <h2 class="is-size-2">
        else if (/^## (.+)/.test(line)) {
          const content = line.match(/^## (.+)/)[1];
          html += `<h2 class="is-size-2">${content}</h2>`;
        }
        // 段落 -> <p>
        else if (/^(.+)$/.test(line) && !/^#/.test(line)) {
          const content = line.match(/^(.+)$/)[1];
          html += `<p>${content}</p>`;
        }
        // 空行 -> <p style="margin-bottom: 1.35rem"></p>
        else if (/^\s*$/.test(line)) {
          html += `<p style="margin-bottom: 1.35rem"></p>`;
        }
        // 水平線 (---) -> <hr class="horizontal-rule">
        else if (/^---$/.test(line)) {
          html += `<hr class="horizontal-rule">`;
        }
      });

      return html;
    }

    var answer = "";
    const button = document.getElementById('button-1732073162');

    var isSpeechingNow = false;
    var isSpeechPause = false;
    const speech = document.getElementById('startstopSpeech');
    const reset = document.getElementById('resetSpeech');

    
    button.addEventListener('click', async event => {
      button.disabled = true;

      const serverAi = new ServerAI();
      const message = document.getElementById('text-1732073162').value;
      answer = await serverAi.getAnswerText('hxyNtBuxZrDopufEacW8Vu', '', message);

      document.getElementById('answer-1732073162').innerHTML = markdownToHtml(answer);
      button.disabled = false;

      speechSynthesis.cancel();
      isSpeechingNow = false;
      isSpeechPause = false;
      speech.innerHTML = "朗読開始";

    });

    speech.addEventListener('click', async event => {
      if (!'speechSynthesis' in window) {
        alert("このブラウザは音声合成に対応していません")
      }
      if (!isSpeechingNow && !isSpeechPause) {
        isSpeechingNow = true;
        const uttr = new SpeechSynthesisUtterance(answer.replaceAll("#", ""));
        speechSynthesis.speak(uttr);
        speech.innerHTML = "一時停止";
        uttr.onend = () => {
          speech.innerHTML = "朗読開始";
          isSpeechingNow = false;
          isSpeechPause = false;
        };
      } else if (isSpeechingNow && !isSpeechPause) {
        speechSynthesis.pause();
        isSpeechingNow = false;
        isSpeechPause = true;
        speech.innerHTML = "再生再開";
      } else if (!isSpeechingNow && isSpeechPause) {
        speechSynthesis.resume();
        isSpeechingNow = true;
        isSpeechPause = false;
        speech.innerHTML = "一時停止";
      }
    });

    reset.addEventListener('click', async event => {
      if (isSpeechingNow || isSpeechPause) {
        speechSynthesis.cancel();
        isSpeechingNow = false;
        isSpeechPause = false;
        speech.innerHTML = "朗読開始";
      }
    });

    /* タブが非アクティブになったら再生を停止するスクリプト */
    /*document.addEventListener("visibilitychange", () => {
      if (document.visibilityState === "hidden") {
        speechSynthesis.pause();
        isSpeechingNow = false;
        isSpeechPause = true;
        speech.innerHTML = "再生再開";
      }
    });*/

  })();
</script>
```

</details>

# サーバーAIに指定したプロンプト
``` txt:promt.txt
あなたは日本の昔話研究家 兼 脚本家です。
いま、プロンプトが与えられます。
クライアントがどのような物語を期待しているかを想定し、各パートごとにタイトルをつけながら、Markdownで出力してください。
```

# 今後の展望・おわりに
Markdown AIのサーバーAIでは、「Gemini」・「Claude」・「GPT-4o mini」・「GPT-3.5 Turbo」・「GPT-4」の5種類のLLMを利用できますが、今回はGPT-4o miniで実装しました（理由は特になし）

それぞれのモデルに特徴・癖があるので、モデルを切り替えられるようにして、同じお題を出した時にどんな物語を生成するのかを比較できるようにすると面白いかなと思っています


最後までお読みいただきありがとうございました！
いいね・コメント頂けると大変喜びます！

ついでに「**昔話生成Bot**」にアクセス！

https://mdown.ai/content/21c6822a-9e32-41f2-ac9d-fc373401a66f


# 参考文献
### Web Speech APIについて

https://qiita.com/studio_haneya/items/4209b079570252bb5b11

https://developer.mozilla.org/ja/docs/Web/API/Web_Speech_API