【0からGASを学ぶ】GAS × Gemini Pro API × LINE Messaging API の相談Botで会話しよう

# はじめに
本シリーズでは、GASの始め方や便利な使い方、ビジネス活用まで幅広く解説します。シリーズをひと通り読んでいただければ、あなたもきっとGASマスターになれるはずです。

# シリーズの対象者
* そもそもGASってなんだか**わからない**方
* GASを学びたいけど**何から始めればいいか**わからない方
* GASはわかり始めたけど、**もっと活用ができないか**と模索している方
* とにかく**Googleが好き！** という方

# 前回記事
https://qiita.com/gas-suke/items/b9165944b432a58bc7a1

# Gemini Pro API をGASからアクセスする
では早速始めていきましょう。【0からGASを学ぶ】シリーズの第16回は「GAS × Gemini Pro API × LINE Messaging API の相談Botで会話しよう」です。前回は、**GAS × Gemini Pro API × LINE Messaging API**により相談Botを作成するまでを行いました。しかしながら、<a href="https://qiita.com/gas-suke/items/b9165944b432a58bc7a1#%E5%AE%9F%E3%81%AF%E3%81%BE%E3%81%A0%E3%81%93%E3%82%8C%E3%81%A7%E3%81%8A%E3%82%8F%E3%82%8A%E3%81%A7%E3%81%AF%E3%81%AA%E3%81%84" target="_blank">記事の末尾</a>に記載した通り、相談Botでありながら**会話の継続性**が実現できておらず、今一つの印象だったかと思います。そこで、今回は前回作成した相談Botをもう一段階進化させましょう。

::: note info
**前回作成した相談Botを前提**に記事を書いておりますので、まだ相談Botを作成していない場合は、必ず<a href="https://qiita.com/gas-suke/items/b9165944b432a58bc7a1#step4gemini-pro-api%E3%81%AE%E8%BF%94%E7%AD%94%E3%82%92line-messaging-api%E3%81%AB%E8%BF%94%E5%8D%B4" target="_blank">こちら</a>を参考にして作成してください。
:::

## 今回やること
1. 入力テキストと出力テキストの保存
1. 保存したテキストにより会話の継続性を実現

## STEP.1　入力テキストと出力テキストの保存
GASには短時間のデータ保存に役立つ`CacheService`があります。今回はこれを活用して、**LINEに入力した文字列**および**Gemini Proからの回答文字列**を保存し、会話に利用します。
`CacheService`は以下のように3種類ありますが、今回は`ScriptCache`を使用します。

| メソッド | 説明 |
|:--|:--|
| getDocumentCache() | 現在のドキュメントとスクリプトをスコープとするキャッシュインスタンスを取得します。 |
| getScriptCache() | スクリプトをスコープとするキャッシュインスタンスを取得します。 |
| getUserCache() | 現在のユーザーとスクリプトをスコープとするキャッシュインスタンスを取得します。 |

https://developers.google.com/apps-script/reference/cache/cache-service?hl=ja

```diff_javascript:doPostの改修
+ const sCache = CacheService.getScriptCache();
/**
 * LINEのトークでメッセージが送信された際に起動するメソッド
 * @param {EventObject} e - イベントオブジェクト
 */
function doPost(e){
  // イベントデータはJSON形式となっているため、parseして取得
  const eventData = JSON.parse(e.postData.contents).events[0]
        , repToken = eventData.replyToken
        , msgType = eventData.message.type;
  // テキストメッセージのときのみ
  if (msgType=='text') {
    let uText = eventData.message.text
        , gemini = getGeminiProAnswerTxt(uText);
    replyTxt(repToken, gemini);
+   sCache.put('user', uText.slice(0, 10000));
+   sCache.put('model', gemini.slice(0, 10000));
  }
}
```
上記のように`ScriptCache`を使用するための宣言を行い、**入力テキストと出力テキスト**を保存します。

:::note info
`Cache`にはキーごとに保存できる最大量が**100KB（日本語だとおよそ50,000文字程度）** であるため、ひとまず**10,000文字程度**に切り出しをしています。
また、**デフォルトでは600秒の有効期限**となっています。最大**6時間（21,600秒）** まで保存できるため、もし会話の継続性をながーーーくしたければ、
`sCache.put('user', uText.slice(0, 10000), 21600);`
とすることで**長時間保存が可能**です。
:::

## STEP.2　保存したテキストにより会話の継続性を実現
### 仕様の確認
改めて、Gemini Pro APIの仕様を確認しましょう。

https://ai.google.dev/tutorials/rest_quickstart#gemini_and_content_based_apis

こちらで確認できるよう`role`を指定し、複数のパラメタを設定することで`Multi-turn conversations (chat)`を実現できることがわかります。
```bash
curl https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent?key=$API_KEY \
    -H 'Content-Type: application/json' \
    -X POST \
    -d '{
      "contents": [
        {"role":"user",
         "parts":[{
           "text": "Write the first line of a story about a magic backpack."}]},
        {"role": "model",
         "parts":[{
           "text": "In the bustling city of Meadow brook, lived a young girl named Sophie. She was a bright and curious soul with an imaginative mind."}]},
        {"role": "user",
         "parts":[{
           "text": "Can you set it in a quiet village in 1600s France?"}]},
      ]
    }' 2> /dev/null | grep "text"
```
つまり、これまでは`contents`には`'parts'` > `'text'`のみの設定でしたが、そこに`role`ごとの会話を設定することで、**会話の継続性を実現できる**ということです。

### 実装
それではやってみましょう。
```diff_javascript:getGeminiProAnswerTxtの改修
/**
 * LINEのトークに送信されたメッセージをGemini Pro APIに渡して回答を得るメソッド
 * @param {String} txt - 送信されたメッセージ
 */
function getGeminiProAnswerTxt(txt) {
+ let contentsStr = '';
+ // キャッシュにuidに紐づく情報が存在した場合、情報には過去の質問文が入っているためそれを取得
+ if (sCache.get('user')) {
+   contentsStr += `{
+     "role": "user",
+     "parts": [{ 
+       "text": ${JSON.stringify(sCache.get('user'))}
+     }]
+   },
+   {
+     "role": "model",
+     "parts": [{
+       "text": ${JSON.stringify(sCache.get('model'))}
+     }]
+   },`
+ }
+ contentsStr += `{
+   "role": "user",
+   "parts": [{
+     "text": ${JSON.stringify(txt)}
+   }]
+ }`
  const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent?key=${GEMINI_API}`
        , payload = {
-           'contents': [
-             {
-               'parts': [{
-                 'text': txt
-               }]
-             }
-           ]
+           'contents': JSON.parse(`[${contentsStr}]`)
          }
        , options = {
            'method': 'post',
            'contentType': 'application/json',
            'payload': JSON.stringify(payload)
          };
  const res = UrlFetchApp.fetch(url, options)
        , resJson = JSON.parse(res.getContentText());

  if (resJson && resJson.candidates && resJson.candidates.length > 0) {
    return resJson.candidates[0].content.parts[0].text;
  } else {
    return '回答を取得できませんでした。';
  }
}
```

このようにすることで`sCache.get('user')`がある、つまりは前回の入力テキストがある場合は、**入力テキストを`role:user`** に**出力テキストを`role:model`** に設定したうえで、今回の入力テキストを`role:user`に設定し、Gemini Pro APIに依頼を出せるようになります。
ただし、上記のやり方は**あくまでも1回前の入出力を設定**するだけです。そのため、**複数回にわたるトーク履歴**を設定したいのであれば、それを`ScriptCache`に保存し、`role:user`および`role:model`を構築すればばっちりです。ただし、1回前の入出力だけでも大抵の場合は会話が成り立ちます。**会話というのは単純なものですね笑**

::: note info
`${JSON.stringify(sCache.get('model'))}`としているのは、入力文字列やGeminiの回答文字列内の**特殊文字や改行をエスケープ**するためです。
:::

### プログラム全文
```javascript:Qiita016
const GEMINI_API    = '**第14回記事のSTEP.1のNo.5で取得したAPI keyを記載する**';
const REPLY_URL     = 'https://api.line.me/v2/bot/message/reply';
const LINEAPI_TOKEN = '**第15回記事のSTEP.1のNo.17で取得したチャネルアクセストークンを記載する**';

const sCache = CacheService.getScriptCache();
/**
 * LINEのトークでメッセージが送信された際に起動するメソッド
 * @param {EventObject} e - イベントオブジェクト
 */
function doPost(e){
  // イベントデータはJSON形式となっているため、parseして取得
  const eventData = JSON.parse(e.postData.contents).events[0]
        , repToken = eventData.replyToken
        , msgType = eventData.message.type;
  // テキストメッセージのときのみ
  if (msgType=='text') {
    let uText = eventData.message.text
        , gemini = getGeminiProAnswerTxt(uText);
    replyTxt(repToken, gemini);
    sCache.put('user', uText.slice(0, 10000));
    sCache.put('model', gemini.slice(0, 10000));
  }
}

/**
 * LINEのトークに送信されたメッセージをGemini Pro APIに渡して回答を得るメソッド
 * @param {String} txt - 送信されたメッセージ
 */
function getGeminiProAnswerTxt(txt) {
  let contentsStr = '';
  // キャッシュにuidに紐づく情報が存在した場合、情報には過去の質問文が入っているためそれを取得
  if (sCache.get('user')) {
    contentsStr += `{
      "role": "user",
      "parts": [{
        "text": ${JSON.stringify(sCache.get('user'))}
      }]
    },
    {
      "role": "model",
      "parts": [{
        "text": ${JSON.stringify(sCache.get('model'))}
      }]
    },`
  }
  contentsStr += `{
    "role": "user",
    "parts": [{
      "text": ${JSON.stringify(txt)}
    }]
  }`
  const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent?key=${GEMINI_API}`
        , payload = {
            'contents': JSON.parse(`[${contentsStr}]`)
          }
        , options = {
            'method': 'post',
            'contentType': 'application/json',
            'payload': JSON.stringify(payload)
          };
  const res = UrlFetchApp.fetch(url, options)
        , resJson = JSON.parse(res.getContentText());

  if (resJson && resJson.candidates && resJson.candidates.length > 0) {
    return resJson.candidates[0].content.parts[0].text;
  } else {
    return '回答を取得できませんでした。';
  }
}

/**
 * LINEのトークにメッセージを返却するメソッド
 * @param {String} token - メッセージ返却用のtoken
 * @param {String} text - 返却テキスト
 */
function replyTxt(token, txt){
  const message = {
                    'replyToken' : token,
                    'messages' : [{
                      'type': 'text',
                      'text': txt
                    }]
                  }
        , options = {
                    'method' : 'post',
                    'headers' : {
                      'Content-Type': 'application/json; charset=UTF-8',
                      'Authorization': 'Bearer ' + LINEAPI_TOKEN,
                    },
                    'payload' : JSON.stringify(message)
                  };
  UrlFetchApp.fetch(REPLY_URL, options);
}
```

:::note info
プログラムの修正が終わったら、**再デプロイ**しましょう。デプロイ方法を誤るとデプロイされた**URLが変わってしまう**ので、必ず<a href="https://qiita.com/gas-suke/items/b9165944b432a58bc7a1#%E3%83%87%E3%83%97%E3%83%AD%E3%82%A4%E3%81%AE%E6%9B%B4%E6%96%B0" target="_blank">こちら</a>を参考に行ってください。
※URLが変わると**LINE Developer側の設定も変更**しなければなりません。
:::


### 確認
…は、ぜひお手持ちのスマホで実施してみてください。お楽しみに！

# おわりに
お疲れ様でした。
第16回は「GAS × Gemini Pro API × LINE Messaging API の相談Botで会話しよう」ということで、第15回で作成した**相談Botをさらに進化**させました。
ですが、これはあくまでも"**あなただけの**"相談Botなので、`ScriptCache`のキーは`user`と`model`と固定文字です。そのため、もしこの相談Botを**複数人で使いたい**という場合には、キーに対する値は**他人の入出力により更新**されてしまいます。これでは会話の継続性がまた失われてしまいますよね。
そういった場合は、以下のように**LINEのuserIdをprefixに設定**したりすれば解決できます。
```javascript:uidの活用
sCache.put(eventData.source.userId+'user', uText.slice(0, 10000));
sCache.put(eventData.source.userId+'model', gemini.slice(0, 10000));
```
どうでしょう、もう**GASはなんでもできる！** は疑いようがないですね！！！引き続き、GASを楽しんでいきましょう！！
記事を読んで、「良いな」や「今後に期待できる！」と感じて頂けたら**いいね**や**フォロー**、**コメント**いただけると幸いです。それではまた次回をお楽しみに！

## ブログでより詳しく解説しています！
以下画像をクリックしてブログにアクセス！！
<a href="https://gas-4u.com/gas15/" target="_blank"><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3575702/3b01dafe-58c0-f415-164e-c26c0d8021f8.png" width="600"/></a>