GASのWebアプリでOpenAIのAPIを動かしてみる

# はじめに

現在絶賛流行中のChatGPTですが提供元のOpenAIからAPIが公開されています。
Google Apps Script（以降これをGASと略します）からAPIを利用した簡単なWebアプリを作ってみましたので内容を解説していこうと思います。

# アプリ概要

ChatGPTに質問する内容をフォームに入力し送信後AIの回答を画面に表示するだけの非常にシンプルな構成です。
この程度の規模の処理であればGASで十分に要件を満たすことが可能です。
AWSなどのクラウドインフラ無しでWebが動作する環境が作れます。
外部データベースも不要でスプレッドシートだけで完全にスタンドアローンに処理が完結できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123439/ffd77a66-5f62-5b0a-6a4f-69ad1d5ec7c4.png)


# 処理解説

## UI

GASをウェブアプリとして動作させます。
エンドポイントが発行されますのでまずはdoGetをコールして画面をレンダリングするようにします。

```js:main.js
function doGet(e) {
  Logger.log(e);
  let html = HtmlService.createTemplateFromFile('index');  
  html.question = "";

  const all_logs = chatAllLog();
  const disp_log = all_logs.disp_log;

  html.history = disp_log;
  html.answer = "AI answer coming up here";
  return html.evaluate();
}
```
indexというファイル名のhtmlファイルをテンプレートとして使用します。
画面表示に必要な情報を揃えてレンダリングしてあげます。

```html:index.html
  <body>
    <form method="post" action="<?= getAppUrl() ?>">
      <div><h1>ChatGPT Question Form</h1></div>
      <div><h2>Question</h2></div>
      <div><textarea name="question" rows="2" class="form-control" ><?!= question ?></textarea></div>
      <div><input type="submit" value="Send Question" class="btn btn-secondary"></div>
      <div><input type="checkbox" name="reset" value="1">Reset History</div>
      <div><h2>Answer</h2></div>
      <div><textarea name="answer" rows="2" class="form-control" ><?!= answer ?></textarea></div>
      <div><h2>Chat history</h2></div>
      <div><?!= history ?></div>
    </form>
  </body>
```
画面表示が動的に変わる部分として`question` `answer` `history`といった埋め込み変数を仕掛けます。
これらはChatGPTへの質問や回答そして会話履歴を設定するために利用します。
ここでポイントになることとして画面上でフォームからsubmitする際のaction先を`getAppUrl()`という関数で取得していることです。
```js:main.js
function getAppUrl() {
  return ScriptApp.getService().getUrl();
}
```
エンドポイントをハードコードしなくてもこのように動的に取得することが可能です。


## メイン処理

フォームからの入力はdoPost関数で受け付けます。
ユーザからのChatGPTへの質問を受信しそれをOpenAIに送信しAIの回答を取得します。

```js:main.js
  // load question
  const question = e.parameter.question;

  chat_seq.push({"role": "user", "content": question});

  var params = {
    'model': "gpt-3.5-turbo",
//    'model': "gpt-4",
    'messages': chat_seq,
  };
  var headers = {
    'Content-Type': 'application/json',
    'Authorization': 'Bearer ' + OPENAI_API_KEY,
  };
  var data = JSON.stringify(params);
  var options = {
     "method" : "post",
     "payload" : data,
     "headers" : headers,
  };
  var response = UrlFetchApp.fetch("https://api.openai.com/v1/chat/completions", options);
  console.log(response.toString())
  let json = JSON.parse(response);
  const answer = json.choices[0].message.content;
```
必要なパラメータを作成してOpenAIが用意しているエンドポイントを`fetch`関数でリクエストします。
今回はOpenAIから発行されたAPIキーはスプレッドシートに保持して取得するようにしました。
```js:main.js
let book = SpreadsheetApp.openById('スプレッドシートのID');
let sheet = book.getSheetByName('config');
const OPENAI_API_KEY      = sheet.getRange("B1").getValue();
```
ここからのポイントとしてChatGPTのやり取りは基本対話型になるので会話の履歴を保持しておく必要があります。
履歴データをスプレッドシートに保持していく方式で作ってみました。
```js:main.js
function makeInsert(chat, role) {
  const email = Session.getActiveUser().getEmail();
  let my_sheet = book.getSheetByName(email);
  if (!my_sheet) {
    my_sheet = book.insertSheet();
    my_sheet.setName(email);
  }
  let row = [chat, role];
  my_sheet.appendRow(row);
}
```
このような関数を作成しユーザが送信した質問とAIの回答をそれぞれ保存します。
シート名にユーザのキー情報としてGmailアドレスを利用します。
ユーザ単位でシートを作成して最終行に内容と誰の発言かを追記していきます。
```js:main.js
function chatAllLog() {
  const email = Session.getActiveUser().getEmail();
  let my_sheet = book.getSheetByName(email);
  var chat_seq = [];
  let disp_log = "<table class=\"table table-striped table-bordered table-sm\"><tr><th>Sender</th><th>Message</th></tr>";
  if (!my_sheet) {
    disp_log = disp_log + "</table>"
    return {"chat_seq":chat_seq, "disp_log":disp_log}
  }

  const range = my_sheet.getDataRange();
  const values = range.getValues();
  Logger.log(values);
  for (value of values) {
      chat_seq.push({"role": value[1], "content": value[0]});
      disp_log = disp_log + "<tr><td>" +  value[1] + "</td><td>" + value[0] + "</td></tr>"
  }

  disp_log = disp_log + "</table>"

  return {"chat_seq":chat_seq, "disp_log":disp_log}
}
```
こちらの関数ではスプレッドシートから今までの会話履歴を全て取得します。
画面に表示するための情報はあらかじめHTMLタグとして`disp_log`にバックエンド側で作成していまいます。
そして片方はChatGPTに履歴として渡すデータを`chat_seq`として別々に保持します。
```js:main.js
  chat_seq.push({"role": "user", "content": question});
```
ユーザからの質問をパラメータに設定する部分なのですが
`chatAllLog`関数にて取得した`chat_seq`フィールドに会話履歴情報が詰まっていますので最後尾に今回の質問データを積み上げます。

```js:main.js
  let html = HtmlService.createTemplateFromFile('index');  
  html.question = question;
  html.answer = answer;
  html.history = disp_log;
  return html.evaluate();
```
HTMLタグとして作成した`disp_log`とAIからの返答を`answer`それぞれ埋め込み変数に設定して画面をレンダリングすることで一連の流れが完了します。

さらにトピックを変更したい場合のために履歴をリセットする機能を追加しました。
```html:index.html
      <div><input type="checkbox" name="reset" value="1">Reset History</div>
```
こちらのチェックボックスをオンにしてフォーム送信した場合に履歴を消去します。
```js:main.js
  const reset = e.parameter.reset;
  if (reset == "1") {
    const email = Session.getActiveUser().getEmail();
    let my_sheet = book.getSheetByName(email);
    book.deleteSheet(my_sheet);
    let html = HtmlService.createTemplateFromFile('index');  
    html.question = "";
    const all_logs = chatAllLog();
    const disp_log = all_logs.disp_log;
    html.history = disp_log;
    html.answer = "delete all chat histories";
    return html.evaluate();
  }
```
こちらはユーザ単位のシートを削除してすべての会話履歴を消去します。
新しい履歴はユーザ単位で再度作成していきます。

# ソースコード
[こちら](https://github.com/yahayuta/gas_samples/tree/master/openai_web_app)
Gitで構成管理できるように自分のPCでスクリプトを編集してClaspを利用しGASにロードしていました。
もちろんこれらのソースコードをGASエディタ上で直接コピペ＆編集しても問題ありません！