Markdown AIでサーバーAIがコーチをしてくれるMarkdown学習環境を作ってみた

# はじめに
ご覧いただきありがとうございます！

Qiita Advent Calendar 2024「[Markdown AIのサーバーAI機能を使ってWebサイトを作ってみよう](https://qiita.com/advent-calendar/2024/markdownai)」15日目です！

最近"流行り"の（違う、そうじゃない）インフルエンザになってしまい、記事投稿がだいぶ遅れてしまいました… (スミマセン)

みなさま体調管理にはお気をつけて、冬を楽しんでいきましょう！

さて今回は、Markdown AIのサーバーAI機能を使い、自分の好きなテーマでMarkdownを学習できる環境を構築してみたので紹介します！

# どんなサイト？
自分が作りたい記事のテーマを入力すると、好みのLLMがMarkdownの書き方を指南してくれるサイトです

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2921137/9b3e7250-f081-0d83-24ed-281b83bb88ab.png)

https://mdown.ai/content/83fca318-dc31-46bf-995a-09115d69d041?from=Qiita

（ちなみにClaude以外だとタイトルの作成から先に進ませてくれません… なんで？）

# 問題提起
Markdownって、初心者には難しいと思うんです

よくQiitaでも「#」と文字列の間に半角スペースを入れ忘れて「**```#タイトル```**」みたいになっている記事を見ませんか…？

私の場合は、Markdownに触れる前からHTMLを学んでいたので、Markdownという概念に馴染むのは容易だったのですが、見出し(#・HTMLだとhタグに相当)以外の記法を覚えきれず、よく[Qiitaのチートシート](https://qiita.com/Qiita/items/c686397e4a0f4f11683d)とにらめっこしてました

そんなことを思い出して、Markdownの学習サイトとかあったのかな〜なんて適当にググったら、Progateみたいな課題提示形のサイトは一件しか見つからず、あとはチートシートが載せてあるサイトしか見当たりません

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2921137/73b6bbdf-dd69-33a9-ee9f-4d5594b7a836.png)

確かにチートシートを見ながらであればMarkdownを"扱う"ことはできますが、覚えるまでの時間がかなりかかりますし、マスターするまでの労力がハードルになってMarkdownから離れてしまう人も出てくるでしょう

# 課題解決までの流れ
先程挙げたサイト、つまり課題提示形の学習講座の難点は、「最初は自分が興味の無いことをやり続けないといけない」という所にあると思っています

例えるなら「ただ英語で〇〇を紹介する記事を作りたいだけなのに、なんで中1の英文法から始めないといけないんだ！」、といった感じでしょうか

講座をユーザーごとに分けるためには人が個別に対応しないといけないので、無料サービスには難しい…というのが常識だと思いますが、今や単純なことはLLMに任せられる時代

そして目の前には自由にLLMをWebサイトに組み込めるMarkdown AI…

てなわけで、Markdown学習サイトを作ることにしました

# このサイトの利点
LLMとの対話形式で、自分の好きなことを書きながらMarkdownを学べるところに革新性があると思っています

リアルタイムのMarkdownプレビュー機能がついているので、一画面で「LLMに聞く ⇒ Markdownを書いてみる ⇒ 表示のフィードバックが返ってくる」という一連の流れに取り組むことができます

LLMは何回間違えても怒らないけど、理解するまでフィードバックを返してくれますし、自分の書きたい記法があれば教えてくれます

例えば画像のように、間違った記法でMarkdownを書くと、

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2921137/a5120dfb-a05d-5f84-d08c-7b584a8c6886.png)

正しい書き方を教えてくれます

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2921137/d14dbaa0-37b3-d410-4a92-7918f3442d64.png)

# 難しかったこと
Markdown AIはMarkdownからHTMLへの変換をサーバー側で行っているようで、フロントエンド側で変換をかけるための別のスクリプトが必要でした

[以前](https://qiita.com/kazu_iroiro/items/408ab51b6858003d2785)、1行毎にh*タグや水平線等の単純な記述を変換する簡易なスクリプトは作成したことがありましたが、Markdown AIがサポートしている他の記法、例えば表やコードブロックは一筋縄ではいきません

探してみるとMarkdownからHTMLへの変換をサポートするmarked.jsというライブラリがあるとのことで、そちらを採用

https://github.com/markedjs/marked

変換後のHTMLタグにMarkdown AI独自のCSSクラス名を振ってあげると、通常のMarkdown AIのスタイルで表示できました

変換スクリプトは以下のJavaScriptに入ってます
ご自由にお使いください
(ついでに記事を紹介してもらえると嬉しいです)

# 実装したJavaScript
APIキーは一応伏せておきます(Markdown AIの場合はそんなに意味がない)

動かす際はhogehogeの部分にAPIキーを移植してあげてください
(marked.jsをロードしてあげる必要もあります)

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

``` html:main.js
<script>
            (() => {
                // 初期値を設定
                const DEFAULT_VALUE = "0"; // 初期値: GPT-4o mini
                const buttons = document.querySelectorAll('.llm-button');

                // グローバル変数としてMarkdownを保持
                let globalMarkdown = '';

                // ページロード時にLocalStorageから値を取得して反映
                const savedValue = localStorage.getItem('selectedLLM') || DEFAULT_VALUE;

                // ボタンの状態をリセットする関数
                function resetButtonStyles() {
                    buttons.forEach(button => {
                        button.style.backgroundColor = '#fff';
                        button.style.color = '#000';
                    });
                }

                // 初期状態の反映
                resetButtonStyles();
                const initialButton = Array.from(buttons).find(button => button.getAttribute('data-value') === savedValue);
                if (initialButton) {
                    initialButton.style.backgroundColor = '#007BFF';
                    initialButton.style.color = '#fff';
                }

                // ボタンクリック時の処理
                buttons.forEach(button => {
                    button.addEventListener('click', (event) => {
                        resetButtonStyles(); // 他のボタンのスタイルをリセット

                        // クリックされたボタンのスタイルを更新
                        event.target.style.backgroundColor = '#007BFF';
                        event.target.style.color = '#fff';

                        // LocalStorageに値を保存
                        const value = event.target.getAttribute('data-value');
                        localStorage.setItem('selectedLLM', value);
                        console.log(`Saved in LocalStorage: selectedLLM=${value}`);
                    });
                });

                // marked.jsの設定
                marked.setOptions({
                    breaks: true,
                    gfm: true,
                    smartLists: true,
                    smartypants: true,
                });

                // MarkdownをHTMLに変換する関数
                function markdownToHTML(markdownText) {
                    let htmlContent = marked.parse(markdownText);
                    const parser = new DOMParser();
                    const doc = parser.parseFromString(htmlContent, 'text/html');

                    const tables = doc.querySelectorAll('table');
                    tables.forEach(table => {
                        table.classList.add('table', 'is-bordered', 'is-hoverable');
                    });

                    const blockquotes = doc.querySelectorAll('blockquote');
                    blockquotes.forEach(blockquote => {
                        blockquote.classList.add('markdown');
                    });

                    for (let i = 1; i <= 6; i++) {
                        const headers = doc.querySelectorAll(`h${i}`);
                        headers.forEach(header => {
                            header.classList.add(`is-size-${i}`);
                        });
                    }

                    const uls = doc.querySelectorAll('ul');
                    uls.forEach(ul => {
                        ul.classList.add('markdown');
                    });
                    const ols = doc.querySelectorAll('ol');
                    ols.forEach(ol => {
                        ol.classList.add('markdown');
                    });
                    const lis = doc.querySelectorAll('li');
                    lis.forEach(li => {
                        if (li.querySelector('input[type="checkbox"]')) {
                            li.classList.add('li-checkbox');
                        }
                    });

                    const checkboxes = doc.querySelectorAll('input[type="checkbox"]');
                    checkboxes.forEach(checkbox => {
                        checkbox.classList.add('checkbox');
                    });

                    const codeBlocks = doc.querySelectorAll('pre > code');
                    codeBlocks.forEach(code => {
                        if (code.className) {
                            code.parentNode.classList.add(`language-${code.className.replace('language-', '')}`);
                        }
                    });

                    const hrs = doc.querySelectorAll('hr');
                    hrs.forEach(hr => {
                        hr.classList.add('horizontal-rule');
                    });

                    const anchors = doc.querySelectorAll('a');
                    anchors.forEach(anchor => {
                        anchor.innerHTML = anchor.innerHTML.replace(/ /g, '&nbsp;');
                    });
                    const images = doc.querySelectorAll('img');
                    images.forEach(image => {
                        image.classList.add('image');
                    });

                    const elementsToCheck = ['p', 'ol', 'blockquote'];
                    elementsToCheck.forEach(tagName => {
                        const elements = doc.querySelectorAll(tagName);
                        elements.forEach(element => {
                            const inBlockquote = !!element.closest('blockquote');
                            const inTable = !!element.closest('table');

                            if (!inBlockquote && !inTable) {
                                const emptyP = document.createElement('p');
                                if (tagName === 'p') {
                                    emptyP.style.marginBottom = '2.7rem';
                                } else if (tagName === 'ol' || tagName === 'blockquote') {
                                    emptyP.style.marginBottom = '1.35rem';
                                }
                                element.insertAdjacentElement('afterend', emptyP);
                            }
                        });
                    });

                    return doc.body.innerHTML;
                }

                // 入力エリアの更新を処理
                const textarea = document.getElementById('textarea');
                const viewer = document.getElementById('converted-view');
                let debounceTimer;

                // 入力イベントのリスナー
                textarea.addEventListener('input', () => {
                    clearTimeout(debounceTimer);
                    debounceTimer = setTimeout(() => {
                        const markdownText = textarea.value;
                        globalMarkdown = markdownText; // グローバル変数に保存
                        viewer.innerHTML = markdownToHTML(markdownText);
                    }, 1000); // 1秒後に更新
                });

                window.addEventListener('beforeunload', (event) => {
                        event.preventDefault();
                        event.returnValue = "";
                });

                const button = document.getElementById('button-1733474089');
                button.addEventListener('click', async event => {
                    button.disabled = true;

                    const serverAi = new ServerAI();
                    const additionalContext = globalMarkdown; // グローバルMarkdownを追加
                    const message = document.getElementById('text-1733474089').value;

                    var api_key = ""
                    if (savedValue == 0 || savedValue == null) {
                        // Claude
                        api_key = 'hogehoge0';
                    } else if (savedValue == 1) {
                        // GPT-4o mini
                        api_key = 'hogehoge1';
                    } else if (savedValue == 2) {
                        // GPT-4
                        api_key = 'hogehoge2';
                    } else if (savedValue == 3) {
                        // GPT-3.5 Turbo
                        api_key = 'hogehoge3';
                    } else if (savedValue == 4) {
                        // Gemini
                        api_key = 'hogehoge4';
                    } else {
                        // Claudeのものを使用
                        api_key = 'hogehoge0';
                    }

                    const answer = await serverAi.getAnswerText(api_key, '', message + "現在受講者が入力しているMarkdownを以下に示します" + additionalContext);

                    document.getElementById('answer-1733474089').innerHTML = markdownToHTML(answer);
                    button.disabled = false;
                });
            })();
</script>
```

</details>

# 使用したプロンプト
色々と試行錯誤を繰り返して辿り着きました
```
あなたはMarkdownを専門に教える講座の講師に選ばれました。
以下の順序に従い、Markdownを使った記事の作成の援助を行ってください。
なお、あなたからのメッセージは受講者の画面に自動で表示され、受講者が入力したMarkdownは逐一あなたに送信されます。
あなたからの出力は、MarkdownからHTMLに変換するコンバーターを通して表示されます。Markdownの記法を例示する場合はインラインのコードブロックを利用してください。
もしも受講者のMarkdownの記法が間違っていた場合は、優しく訂正を述べてください。

1.テーマを確認する - 受講者からテーマが送信されます。その内容を記憶してください。
2.タイトルを決めてもらう - 受講者にタイトルを決めるように促してください。その際、Markdownの基本となる「#」の使い方について説明をしてください。
3.サブタイトルを考えてもらう - 受講者にサブタイトルを決めるように促してください。その際、「##」のような構文の使い方について説明してください。サブタイトルの例をいくつか受講者に提示してもかまいません。
4.本文を考えてもらう - 本文の作成を促してください。受講者から記法などの質問がされた場合は、逐次回答をしてください。あなたに送信された最新のMarkdownを参照しながら回答を行うと良いでしょう。
5.受講者が満足するまで「手順4」を繰り返してください。その際、「手順3」以前には戻らないでください。
```

# Markdown AIさんに要望
画像やタイトルは自動生成でも構わないので、OGP機能が欲しいです

https://qiita.com/kazu_iroiro/items/408ab51b6858003d2785

Qiitaの場合はこんな感じになりますが、タイトルや画像が表示されると、新規でアクセスする人がより分かりやすくMarkdown AIのページに入れるので、安心感が増すかと思います

**2024年12月20日 追記**

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2921137/f207a9b9-998d-1af9-35b2-d8e54920442d.png)

右上のプロフィールアイコンをクリックしてアクセスできるSite Settingから、OGPにも反映されるサイトタイトルとアイコンを変更できました

ただし、複数のページをリリースする場合でも同じタイトル・アイコンになってしまうようです

ページごとに設定できるとより便利になると思います

以上・追記終わり


# おわりに
簡単にLLMを利用したサービスをリリースできるところが、やはりMarkdown AIの魅力だと思います

既に共通テストまで1か月を切ってしまった(実は高3で受験生です…)ので、今回のサイトのアップデートは今のところ考えてないのですが、エディタをよりリッチにして使い勝手を向上させたいなと思っています

操作性にはまだまだ改善の余地がありますが、ぜひ使ってみてください！

https://mdown.ai/content/83fca318-dc31-46bf-995a-09115d69d041?from=Qiita

いいね・サイトのフィードバック等いただけると喜びます！
最後までお読みいただきありがとうございました

# 参考文献

https://qiita.com/sk_3122/items/cbad70e32bcc89afce6a

https://qiita.com/samuraibrass/items/d40d54aa0754692d5439