takataka430’s blog

.NET系を中心に勉強したことのまとめを書きます

C#のコンソールでChatGPTと英会話するアプリを作ってみた

はじめに

最近ChatGPTを英語の勉強のために英語で質問するようにしているのですが、「これって会話できないかな?」というのが気になったので作ってみました。

環境

.NET6 コンソールアプリ
Microsoft.CognitiveServices.Speech 1.26.0

コード

OpenAIに質問を投げる

以下のようになります。

static async Task<string> AnswerQuestionAsync(string question, HttpClient client, string openai_api_key)
{
    //OpenAIのエンドポイントを入力
    string endpoint = "https://api.openai.com/v1/chat/completions";

    //OpenAIのエンドポイントに送るリクエスト本文を作成
    var content = JsonContent.Create(new
    {
        model = "gpt-3.5-turbo",
        messages = new List<Message> { new Message("user", question) },
        stream = true
    });

    //リクエストを送る
    HttpRequestMessage requestMessage = new HttpRequestMessage(HttpMethod.Post, endpoint);
    requestMessage.Content = content;// new StringContent(request, Encoding.UTF8, "application/json");
    requestMessage.Headers.Add("Authorization", "Bearer " + openai_api_key);

    Console.Write("");
    
    HttpResponseMessage response = await client.SendAsync(requestMessage);

    if(response.IsSuccessStatusCode)
    {
        //Choicesの中のcontenを取得
        var resultContent = await response.Content.ReadAsStringAsync();

        var obj = JsonNode.Parse(resultContent)!["choices"]![0]!["message"]!["content"];
        if (obj != null)
        {
            var answer = obj.ToString().Replace("\n", "");
            return answer;
        }
        else
        {
            return "Can not get an answer.";
        }
    }
    else
    {
        return "An error has occurred.";
    }

}

本文のJSONを作成するためのクラスを作っておきます。

class message
{
    public message(string _role, string _content)
    {
        role = _role;
        content = _content;
    }
    public string role { get; set; }
    public string content { get; set; }
}

音声を文字に変換

static async Task<string> OutputSpeechRecognitionResultAsync(string speechKey, string speechRegion)
{
    var speechConfig = SpeechConfig.FromSubscription(speechKey, speechRegion);
    speechConfig.SpeechRecognitionLanguage = "en-US";

    using var audioConfig = AudioConfig.FromDefaultMicrophoneInput();
    using var speechRecognizer = new SpeechRecognizer(speechConfig, audioConfig);

    Console.WriteLine("マイクに向けて話してください。");
    var speechRecognitionResult = await speechRecognizer.RecognizeOnceAsync();

    string answer = "";

    switch (speechRecognitionResult.Reason)
    {
        case ResultReason.RecognizedSpeech:
            Console.WriteLine($"RECOGNIZED: Text={speechRecognitionResult.Text}");
            answer = speechRecognitionResult.Text;
            break;
        case ResultReason.NoMatch:
            Console.WriteLine($"NOMATCH: Speech could not be recognized.");
            break;
        case ResultReason.Canceled:
            var cancellation = CancellationDetails.FromResult(speechRecognitionResult);
            Console.WriteLine($"CANCELED: Reason={cancellation.Reason}");

            if (cancellation.Reason == CancellationReason.Error)
            {
                Console.WriteLine($"CANCELED: ErrorCode={cancellation.ErrorCode}");
                Console.WriteLine($"CANCELED: ErrorDetails={cancellation.ErrorDetails}");
                Console.WriteLine($"CANCELED: Did you set the speech resource key and region values?");
            }
            break;
    }
    return answer;
}

以下のページのコードを参考にしました。

音声テキスト変換クイックスタート - Speech サービス - Azure Cognitive Services | Microsoft Learn

文字を音声に変換

static async Task OutputSpeechSynthesisResultAsync(string text, string speechKey, string speechRegion)
{
    var speechConfig = SpeechConfig.FromSubscription(speechKey, speechRegion);

    speechConfig.SpeechSynthesisVoiceName = "en-US-JennyNeural";

    using (var speechSynthesizer = new SpeechSynthesizer(speechConfig))
    {
        var speechSynthesisResult = await speechSynthesizer.SpeakTextAsync(text);          

        switch (speechSynthesisResult.Reason)
        {
            case ResultReason.SynthesizingAudioCompleted:
                Console.WriteLine($"Speech synthesized for text: [{text}]");
                break;
            case ResultReason.Canceled:
                var cancellation = SpeechSynthesisCancellationDetails.FromResult(speechSynthesisResult);
                Console.WriteLine($"CANCELED: Reason={cancellation.Reason}");

                if (cancellation.Reason == CancellationReason.Error)
                {
                    Console.WriteLine($"CANCELED: ErrorCode={cancellation.ErrorCode}");
                    Console.WriteLine($"CANCELED: ErrorDetails=[{cancellation.ErrorDetails}]");
                    Console.WriteLine($"CANCELED: Did you set the speech resource key and region values?");
                }
                break;
            default:
                break;
        }
    }
}

以下のページを参考にしました。

テキスト読み上げクイックスタート - Speech サービス - Azure Cognitive Services | Microsoft Learn

全体のコード

コード全体は以下のようになります。

using System.Net.Http.Json;
using System.Text.Json.Nodes;
using Microsoft.CognitiveServices.Speech;
using Microsoft.CognitiveServices.Speech.Audio;

class Program
{
    async static Task Main(string[] args)
    {
        // Azure Speech Serviceの "SPEECH_KEY" と "SPEECH_REGION"を入力
        string speechKey = "[Azure Speech Serviceのキー]";
        string speechRegion = "[Azure Speech Serviceのリージョン]";

        //OpenAIのAPIキーを入力
        string openai_api_key = "[OpenAIのAPIキー]";

        HttpClient client = new HttpClient();


        var inProcess = true;

        while(inProcess)
        {
            //話した内容を文字として取得
            var questionText = await OutputSpeechRecognitionResultAsync(speechKey, speechRegion);

            //Byeと言われたら終了
            if (questionText.ToLower().Contains("bye"))
            {
                inProcess = false;
            }

            //OpenAIに質問を投げる
            var answer = await AnswerQuestionAsync(questionText, client,openai_api_key);
            
            //答えを話してもらう
            await OutputSpeechSynthesisResultAsync(answer, speechKey, speechRegion);
            
        }
    }


    static async Task<string> AnswerQuestionAsync(string question, HttpClient client, string openai_api_key)
    {
        //OpenAIのエンドポイントを入力
        string endpoint = "https://api.openai.com/v1/chat/completions";

        //OpenAIのエンドポイントに送るリクエスト本文を作成
        var content = JsonContent.Create(new
        {
            model = "gpt-3.5-turbo",
            messages = new List<Message> { new Message("user", question) },
            stream = true
        });

        //リクエストを送る
        HttpRequestMessage requestMessage = new HttpRequestMessage(HttpMethod.Post, endpoint);
        requestMessage.Content = content;// new StringContent(request, Encoding.UTF8, "application/json");
        requestMessage.Headers.Add("Authorization", "Bearer " + openai_api_key);

        Console.Write("");
        
        HttpResponseMessage response = await client.SendAsync(requestMessage);

        if(response.IsSuccessStatusCode)
        {
            //Choicesの中のcontenを取得
            var resultContent = await response.Content.ReadAsStringAsync();

            var obj = JsonNode.Parse(resultContent)!["choices"]![0]!["message"]!["content"];
            if (obj != null)
            {
                var answer = obj.ToString().Replace("\n", "");
                return answer;
            }
            else
            {
                return "Can not get an answer.";
            }
        }
        else
        {
            return "An error has occurred.";
        }

    }


    static async Task<string> OutputSpeechRecognitionResultAsync(string speechKey, string speechRegion)
    {
        var speechConfig = SpeechConfig.FromSubscription(speechKey, speechRegion);
        speechConfig.SpeechRecognitionLanguage = "en-US";

        using var audioConfig = AudioConfig.FromDefaultMicrophoneInput();
        using var speechRecognizer = new SpeechRecognizer(speechConfig, audioConfig);

        Console.WriteLine("マイクに向けて話してください。");
        var speechRecognitionResult = await speechRecognizer.RecognizeOnceAsync();

        string answer = "";

        switch (speechRecognitionResult.Reason)
        {
            case ResultReason.RecognizedSpeech:
                Console.WriteLine($"RECOGNIZED: Text={speechRecognitionResult.Text}");
                answer = speechRecognitionResult.Text;
                break;
            case ResultReason.NoMatch:
                Console.WriteLine($"NOMATCH: Speech could not be recognized.");
                break;
            case ResultReason.Canceled:
                var cancellation = CancellationDetails.FromResult(speechRecognitionResult);
                Console.WriteLine($"CANCELED: Reason={cancellation.Reason}");

                if (cancellation.Reason == CancellationReason.Error)
                {
                    Console.WriteLine($"CANCELED: ErrorCode={cancellation.ErrorCode}");
                    Console.WriteLine($"CANCELED: ErrorDetails={cancellation.ErrorDetails}");
                    Console.WriteLine($"CANCELED: Did you set the speech resource key and region values?");
                }
                break;
        }
        return answer;
    }

    

    static async Task OutputSpeechSynthesisResultAsync(string text, string speechKey, string speechRegion)
    {
        var speechConfig = SpeechConfig.FromSubscription(speechKey, speechRegion);

        speechConfig.SpeechSynthesisVoiceName = "en-US-JennyNeural";

        using (var speechSynthesizer = new SpeechSynthesizer(speechConfig))
        {
            var speechSynthesisResult = await speechSynthesizer.SpeakTextAsync(text);          

            switch (speechSynthesisResult.Reason)
            {
                case ResultReason.SynthesizingAudioCompleted:
                    Console.WriteLine($"Speech synthesized for text: [{text}]");
                    break;
                case ResultReason.Canceled:
                    var cancellation = SpeechSynthesisCancellationDetails.FromResult(speechSynthesisResult);
                    Console.WriteLine($"CANCELED: Reason={cancellation.Reason}");

                    if (cancellation.Reason == CancellationReason.Error)
                    {
                        Console.WriteLine($"CANCELED: ErrorCode={cancellation.ErrorCode}");
                        Console.WriteLine($"CANCELED: ErrorDetails=[{cancellation.ErrorDetails}]");
                        Console.WriteLine($"CANCELED: Did you set the speech resource key and region values?");
                    }
                    break;
                default:
                    break;
            }
        }
    }
}

//JSON作成用のクラス
class Message
{
    public Message(string _role, string _content)
    {
        role = _role;
        content = _content;
    }
    public string role { get; set; }
    public string content { get; set; }
}

「Bye」という単語が含まれるまで繰り返し会話ができます。ぜひ試してみてください。

C#を使ってOpenAIのAPIからの結果をChatGPTのように順次表示する方法

はじめに

ChatGPTは回答が一気に表示されるのではなく、少しずつ表示されます。これができたらユーザーは待機時間が短く感じらますよね。
今回はChatGPTのAPIを利用してどのように実装するのかを調査してみました。

環境

.NET 6 コンソールアプリ
OpenAIのモデル:gpt-3.5-turbo

ポイント

結果を順次受け取るようにする際のポイントをまとめました。

streamパラメータの設定

以下記事の通り、APIにリクエストを送る際のパラメータでstreamをtrueにする必要があります。

https://platform.openai.com/docs/api-reference/chat/create#chat/create-stream

結果の受け取り

上記のようにstreamを有効にした場合、結果は以下のようなJSONになります。

data: {
    "id": "...",
    "object": "...",
    "created": ...,
    "model": "gpt-3.5-turbo-0301",
    "choices": [
        {
            "delta": {
                "content": "..."
            },
            "index": 0,
            "finish_reason": null
        }
    ]
}

data: {
    "id": "...",
    "object": "...",
    "created": ...,
    "model": "gpt-3.5-turbo-0301",
    "choices": [
        {
            "delta": {
                "content": "..."
            },
            "index": 0,
            "finish_reason": null
        }
    ]
}

(省略)

data: [DONE
]

上記はすべての結果をまとめて表示していますが、コードを実行した際にはdata:[JSON形式のデータ]という結果を繰り返し取得できます。このままではJSONに変換しにくいので「data:」の部分は削除すると扱いやすいです。
また、回答はcontentの中にあるのでJSONの解析でこの値を取得するといいでしょう。

コード

では実際にコードを見てみましょう。C#のコンソールアプリでの例です。

using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json.Nodes;
using System.Threading.Tasks;

class Program
{
    static async Task Main(string[] args)
    {
        string key = "[OpenAIのAPIキー]";

        Console.WriteLine("質問を入力してください。");
        string question = Console.ReadLine();
        
        Console.WriteLine("--------------------------------------");

        var client = new HttpClient();
        var request = new HttpRequestMessage(HttpMethod.Post, "https://api.openai.com/v1/chat/completions");
        request.Headers.Add("Authorization", $"Bearer {key}");

        var content = JsonContent.Create(new
        {
            model = "gpt-3.5-turbo",
            messages= new List<message> { new message("user", question) },
            stream = true
        });

        request.Content = content;

        var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
        using var streamReader = new StreamReader(await response.Content.ReadAsStreamAsync());

        while (!streamReader.EndOfStream)
        {
            var line = await streamReader.ReadLineAsync();
            if (string.IsNullOrEmpty(line))
                continue;

            //冒頭の[data]を削除
            line = line.Remove(0, 6);
            
            //[DONE]の場合は終了
            if (line == "[DONE]")
                continue;

            var resultContent = JsonNode.Parse(line)?["choices"]?[0]?["delta"]?["content"]?.ToString();
            if(resultContent != null)
                Console.Write(resultContent);  //結果をコンソールに表示
        }
    }
}

class message
{
    public message(string _role, string _content)
    {
        role = _role;
        content = _content;
    }
    public string role { get; set; }
    public string content { get; set; }
}

実行結果

以下のように結果が順次表示されました。

youtu.be

OpenAIを使ってみた

最近よく聞くOpenAIのAPIを使ってみました。

環境

Python 3.9.13
Visual Studio Code 1.75.0

手順

APIキーの取得

以下のページからログインします。(利用にはサインアップが必要です)
https://beta.openai.com/

ログインしたら以下のページからAPIキーを取得できます。
https://platform.openai.com/account/api-keys

コードを書く

今回はPythonのライブラリを使うので以下のコマンドでインストールします。

pip install openai

これでライブラリを使う準備は完了です。
OpenAIでできることは以下のページにまとめられています。

https://platform.openai.com/examples

今回はQ&Aのサンプルコードを少し修正して使います。

import openai

question = "Where is the Valley of Kings?"

# 本来APIキーはソースコードに含めてはいけないのですが、今回は動作確認なのでここで設定します
openai.api_key = "[OpenAIのAPIキー]"

response = openai.Completion.create(
  model="text-davinci-003",
  prompt="I am a highly intelligent question answering bot. If you ask me a question that is rooted in truth, I will give you the answer. If you ask me a question that is nonsense, trickery, or has no clear answer, I will respond with ""Unknown"". Q: " + question  + " A:",
  temperature=0,
  max_tokens=300,
  top_p=1,
  frequency_penalty=0.0,
  presence_penalty=0.0,
  stop=["\n"]
)

# 翻訳結果の表示
print(response["choices"][0]["text"])

結果は以下のようになりました。

 The Valley of the Kings is located in Egypt, on the west bank of the Nile River in Luxor.

ソースコードquestionを変更すると色々質問することができます。是非皆さんも使ってみてください!

スクリプトから3Dモデルを読み込む【MRTK】

はじめに

Unityで3Dオブジェクトを読み込むためにはUnityのプロジェクトにドラッグアンドドロップでインポートする方法があると思います。今回はその方法とは別に、スクリプトから3Dオブジェクトを読み込む方法を調べてみました。

環境

MRTK 2.8.2
Unity 2020.3.27f1

手順

基本的な使い方はMixed Reality Toolkit ExamplesのDemos - Gltfを見ていただければわかると思います。

以下のような方法でgltfまたはglb形式の3Dモデルを読み込みGameObjectとして扱うことができます。

名前空間Microsoft.MixedReality.Toolkit.Utilities.Gltf.Serialization

//gltfから読み込む場合
string path = "[ファイルパス]";
var gltf = await GltfUtility.ImportGltfObjectFromPathAsync(path);
GameObject gltfobj = gltf.GameObjectReference;

//glbから読み込む場合
byte[] glbbytearray = ~~~; //何らかの形でglb形式の3Dモデルをバイト配列で取得
var gltfObject = GltfUtility.GetGltfObjectFromGlb(glbbytearray);
GameObject gltfobj = await gltfObject.ConstructAsync();

上記のどちらを使う場合でも、異なる形式の3Dモデルを読み込もうとすると例外になるので例外処理をする必要があると思います。

この方法を使えばクラウドのストレージから呼び出すなんてこともできるので応用が利きそうですね。

model-viewerを使ってWEBで3Dモデルを表示してみた

はじめに

3DモデルをWEB上で表示したいと思ってツールを探してみたらmodel-viewerというものを見つけました。

modelviewer.dev

これを利用すれば簡単に3Dモデルを表示することができそうなので、さっそく使ってみました。

環境

Visual Studio Code 1.72.2
Live Server v5.7.9 (Visual Studio Code拡張機能

Live Serverについては以下をご覧ください。 marketplace.visualstudio.com

利用手順

まずはhtmlファイル(index.html)と3Dモデル(TestObject.glb)を用意し、同じ場所に配置します。

index.htmlは以下のように記載しました。

<!DOCTYPE html>
<html>
<body>
<model-viewer 
    src="TestObject.glb" 
    camera-controls
    style="width: 1000px; height: 800px;">
</model-viewer>

<script type="module" src="https://unpkg.com/@google/model-viewer/dist/model-viewer.min.js"></script>

</body>
</html>

とりあえずさっと動かすだけならこれでOKです。
あとはWEBブラウザ経由でindex.htmlにアクセスすればいいのですが、そのまま開くと3Dモデルへアクセスする際にCORSのエラーが発生します。そのためLive Serverを使用して開きます。

実際に動かしてみると以下のようになります。

3Dモデルが表示されて、マウスで操作することもできていますね。

Blazor WebAssemblyでBootstrapのModalを使う

はじめに

Blazorは空のプロジェクトを作っても最初からBootstrapが導入されています。ですが、以下のページのModalをコンポーネントにはりつけてもうまく動きませんでした。

getbootstrap.com

どうやったら動くのか気になったので、今回調査してみました。

環境

Microsoft Visual Studio Community 2022 Version 17.1.1
Blazor WebAssembly (.NET 6)
Bootstrap 5.1

原因は?

Bootstrap用のJavaScriptファイルがないことが原因のようでした。以下のページを見るとCSSファイルとJavaScriptファイルの2種類が必要かと思いますが、BlazorのテンプレートにはCSSファイルしかありません。

getbootstrap.com

導入手順

以下のページのCDN via jsDelivrから必要なリンクを2つ(CSSJavaScript)取得します。2種類ありますが、今回は上のものを利用しました。

getbootstrap.com

次にBlazorのプロジェクトでこれを利用できるようにします。
wwwroot/index.htmlファイルを開き、先ほどコピーしたリンクをはりつけます。

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
    <title>BlazorWasmStudy</title>
    <base href="/" />
    <!-- ここはコメントアウト -->
    <!--<link href="css/bootstrap/bootstrap.min.css" rel="stylesheet" />-->
    
    <!-- 貼り付け1か所目 -->
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous">

    <link href="css/app.css" rel="stylesheet" />
    <link href="BlazorWasmStudy.styles.css" rel="stylesheet" />
</head>

<body>
    <div id="app">Loading...</div>

    <div id="blazor-error-ui">
        An unhandled error has occurred.
        <a href="" class="reload">Reload</a>
        <a class="dismiss">🗙</a>
    </div>
    <script src="_framework/blazor.webassembly.js"></script>
    
    <!-- 貼り付け2か所目 -->
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p" crossorigin="anonymous"></script>

</body>

</html>

これで準備完了です。
あとはrazorコンポーネントにModalのコードを張り付ければOKです。

getbootstrap.com

例えば、Live demoのコードを使うと以下のように動きます。

WEBブラウザでお絵描きする方法を調べてみた

お絵描きする機能をWEBアプリケーションに実装したいと思い、JavaScriptならできるのでは?と思って調べてみました。
今回はその実装方法について書こうと思います。

実装のポイント

いくつか紹介します。

canvas要素の取得

//canvas要素の取得
const myCanvas = document.getElementById('canvas');

//canvas要素に平面の線を引くためのコンテクストを取得
const ctx = myCanvas.getContext('2d');

HTMLでcanvas要素を配置し、JavaScriptcanvas要素とそのコンテクストを取得します。
コンテクストはcanvasに線を引くために必要です。

マウスが動いた時の座標を取得

//マウスを動かしたときに実行するイベントを定義
myCanvas.onmousemove = OnMouseMove;        
function OnMouseMove(e){
    Draw(e.offsetX, e.offsetY); //線を引く処理を実行する
}

1行目でマウスが動いた時に実行する関数を設定しています。
このイベントからcanvas要素内のマウスポインタの座標を取得できるので(e.offsetX, e.offsetY)、この値を用いて何らかの線を引く処理を実行します。

マウスのボタンを押している間だけ線を引く

上記2つだけだとマウスのポインタがcanvas要素上を移動している間はずっと線を引いてしまうので、以下のように状態を表す数値を設定します。

//線を引くために状態を持つ変数
//0:マウスをクリックしていない
//1:マウスをクリックしている
//2:マウスをクリックしてなおかつ動かしている
let lineStatus = 0;

//マウスのボタンを押したときのイベント
myCanvas.onmousedown = function(){
    lineStatus = 1;
}

//マウスのボタンを放したときのイベント
myCanvas.onmouseup = function(){
    lineStatus = 0;
}

これらを利用してマウスのボタンを押している間だけ線を引くようにしました。

全体のコード

以下のようにしました。

<!DOCTYPE html>
<html lang="en">
<head>
    <title>Test</title>
    <meta charset="utf-8">
</head>
<body>
    <canvas id="canvas" width="500" height="300" style="border: solid 1px #000;box-sizing: border-box;"></canvas>
    <input type="button" value="clear" id="clear">
    <script>
        //線を引くために状態を持つ変数
        //0:マウスをクリックしていない
        //1:マウスをクリックしている
        //2:マウスをクリックしてなおかつ動かしている
        let lineStatus = 0;

        //canvas要素の取得
        const myCanvas = document.getElementById('canvas');

        //canvas要素に平面の線を引くためのコンテクストを取得
        const ctx = myCanvas.getContext('2d');
        
        //マウスのボタンを押したときのイベント
        myCanvas.onmousedown = function(){
            lineStatus = 1;
        }

        //マウスのボタンを放したときのイベント
        myCanvas.onmouseup = function(){
            lineStatus = 0;
        }

        //マウスを動かしたときに実行するイベントを定義
        myCanvas.onmousemove = OnMouseMove;        
        function OnMouseMove(e){
            Draw(e.offsetX, e.offsetY);
        }

        //線を引く
        function Draw(pos_x, pos_y){
            if(lineStatus == "1"){
                ctx.lineWidth = 5;        //線の太さ
                ctx.lineCap = "round"     //線の末端のスタイル
                ctx.beginPath();          //パスの開始
                ctx.moveTo(pos_x, pos_y); //始点
                lineStatus = 2;

            }else if(lineStatus == "2"){
                ctx.lineTo(pos_x, pos_y); //線を引く座標
                ctx.stroke();             //線を引く
            }
        }

        //以下は描いた線を消すためのもの
        const canvasWidth = myCanvas.width;
        const canvasHeight = myCanvas.height;
        const clear = document.getElementById('clear');
        clear.onclick = function(){
            ctx.clearRect(0,0,canvasWidth, canvasHeight)
        }

    </script>
</body>
</html>

これをWEBブラウザで表示すると以下のようにお絵描きをすることができます。

f:id:takataka430:20220318204931g:plain:w500

参考

developer.mozilla.org

developer.mozilla.org