2024年5月17日金曜日

GAS で GPT-4o API を使った画像処理ができる LINE bot を作る

-----
・追記(2024-5-21)
Gemini の LINE bot も作ってみました → 「GAS で Gemini API を使った LINE bot を作る

・追求(2024-6-20)
cultivationdata.net で、GPT 及び Gemini を搭載した LINE bot の作り方を公開しました。
-----

先日発表された GPT-4o が、いろいろすごいみたいですね。

すでに API の利用ができるそうなので、以前、GAS(Google Apps Script) で作った LINE bot(「GAS で ChatGPT とやりとりできる簡単な LINE bot の作り方」)に画像処理機能を追加してみました。

スプレッドシート(サンプル)の準備等、基本的な作り方は、以前作った LINE bot と同じです。


★ 機能一覧
  • 文頭に「続き)」を付けることで会話を継続。(例:「続き)もっと詳細に教えて」)
  • 文頭に「昔話!」のように付けることで、スプレッドシートの「system」シートにあらかじめ登録したシステム指示を実行。(例:「昔話!きのこ」)
  • 文頭に「画像)」で画像の処理モード。(例:「画像)何ですか?」 → 画像を送信)
  • 文末に「(厳密」「(創造」で temperature を指定。通常 0.5、厳密 0、創造 1。(例:「3行で小噺を作って(創造」)


GAS のコードを、以下のように書き換えます。

const spreadsheet = SpreadsheetApp.openById('スプレッドシート ID');

const chatSheet = spreadsheet.getSheetByName('chat');
let chatLastRow = chatSheet.getLastRow();
let chatRange = chatSheet.getRange(2, 1, chatLastRow, 2);

const systemSheet = spreadsheet.getSheetByName('system');
const systemLastRow = systemSheet.getLastRow();
const systemWatchwordRange = systemSheet.getRange(2, 1, systemLastRow - 1, 1);
const systemContentRange = systemSheet.getRange(2, 2, systemLastRow - 1, 1);

const tempSheet = spreadsheet.getSheetByName('temperature');
const tempRange = tempSheet.getRange(1, 1);

function doPost(e) {
  try {
    const event = JSON.parse(e.postData.contents).events[0];

    switch (event.message.type){
      case 'text': // リクエストがテキストの場合
        let messageContent = event.message.text;

        if (messageContent.startsWith('画像)')) {
          // 画像処理の場合
          messageContent = messageContent.replace('画像)', '');
          getRequestMessage(messageContent); // リクエスト(テキスト)を成型・保存
          sendLineMessage(event.replyToken, '画像を送信してください');  // LINE で応答
        } else {
          // 画像処理以外の場合
          const requestData = getRequestMessage(messageContent); // リクエスト(テキスト)を成型・保存
          const replyMessage = generateOpenAiReply(requestData); // GPT で回答
          setAssistantMessage(replyMessage); // スプレッドシートに'role': 'assistant'で格納
          sendLineMessage(event.replyToken, replyMessage); // LINE で応答
        }
        break;
      case 'image': // リクエストが画像の場合
        const requestData = getRequestImage(event.message.id); // リクエスト(画像)を成型・保存
        const replyMessage = generateOpenAiReply(requestData); // GPT で回答
        setAssistantMessage(replyMessage); // スプレッドシートに'role': 'assistant'で格納
        sendLineMessage(event.replyToken, replyMessage); // LINE で応答
        break;
      default:
        // リクエストがサポート外のデータ形式の場合
        sendLineMessage(event.replyToken, 'サポート外のデータ形式です'); // LINE で応答
    }
  } catch {
    // エラー発生時
    sendLineMessage(event.replyToken, 'エラーが発生しました'); // LINE で応答
  }
}

// リクエストデータ(テキスト)を生成する関数
function getRequestMessage(messageContent) {
  // system ロールの配列を生成
  const systemWatchwordArry = systemWatchwordRange.getValues().flat();
  const systemContentArry = systemContentRange.getValues().flat();
  let systemWatchword = '';
  let systemContent = '';

  // リクエスト内容の処理
  if (messageContent.startsWith('続き)')) {
    // 文頭に「続き)」で会話を継続
    messageContent = messageContent.replace('続き)', '');
    chatSheet.getRange(chatLastRow + 1, 1, 1, 2).setValues([['user', messageContent]]);
  } else {
    // temperatureを取得してスプレッドシートにセット
    // 文末に「(創造」「(厳密」で指定
    if (messageContent.endsWith('(創造')) {
      messageContent = messageContent.replace('(創造', ''); // 1
      tempRange.setValue(1);
    } else if (messageContent.endsWith('(厳密')) {
      messageContent = messageContent.replace('(厳密', ''); // 0
      tempRange.setValue(0);
    } else {
      tempRange.setValue(0.5);
    }
    // スプレッドシートにあらかじめ登録した system ロールの読み込み
    for (let i = 0; i < systemWatchwordArry.length; i++) {
      // 文頭に「〇〇!」でsystem ロールを指定
      if (messageContent.startsWith(systemWatchwordArry[i] + '!')) {
        systemWatchword = systemWatchwordArry[i] + '!';
        systemContent = systemContentArry[i];
        break;
      }
    }
    messageContent = messageContent.replace(systemWatchword, '');

    chatRange.clear(); // 以前のチャット内容を削除

    // リクエスト内容をスプレッドシートに格納
    if (systemContent) {
      chatSheet.getRange(2, 1, 2, 2).setValues([['system', systemContent], ['user', messageContent]]);
      chatRange = chatSheet.getRange(2, 1, 2, 2);
    } else {
      chatSheet.getRange(2, 1, 1, 2).setValues([['user', messageContent]]);
      chatRange = chatSheet.getRange(2, 1, 1, 2);
    }
  }

  // リクエストデータを生成
  let requestData = [];
  if (systemContent) {
    requestData.push({'role': 'system', 'content': systemContent});
  }
  let values = chatRange.getValues();
  for(let i = 0; i < values.length; i++) {
    requestData.push({'role': values[i][0], 'content': values[i][1]});
  }

  return requestData;
}

// リクエストデータ(画像)を生成する関数
function getRequestImage(id) {
  // 画像を取得
  const response = UrlFetchApp.fetch('https://api-data.line.me/v2/bot/message/' + id + '/content',{
    'headers': {
      'Authorization': `Bearer ${PropertiesService.getScriptProperties().getProperty('Line_key')}`,
    },
    'method': 'get'
  });
  const imageBlob = response.getBlob().getAs('image/png');

  // 画像を Base64 にエンコード
  const base64Image = Utilities.base64Encode(imageBlob.getBytes());

  // リクエストデータを生成
  let requestData = [];
  let values = chatSheet.getRange(2, 1, chatLastRow - 1, 2).getValues();
  for(let i = 0; i < values.length; i++) {
    if(values[i][0] == 'user') {
      requestData.push({'role': 'user', 'content': [{'type': 'text', 'text': values[i][1]},{'type': 'image_url', 'image_url': {'url': `data:image/png;base64,${base64Image}`}}]});
    } else {
      requestData.push({'role': values[i][0], 'content': values[i][1]});
    }
  }

  return requestData
}

// GPT から回答を得る関数
function generateOpenAiReply(requestMessage) {
  const openAiHeaders = {
    'Authorization': `Bearer ${PropertiesService.getScriptProperties().getProperty('OpenAI_key')}`,
    'Content-type': 'application/json',
    'X-Slack-No-Retry': 1
  };

  const openAiPayload = {
    'model': 'gpt-4o', // 使用するモデル
    'max_tokens': 1024, // 生成する文章の最大トークン数
    'temperature': tempRange.getValue(), // 生成された文章のランダムさを制御するパラメータ。値が高いほど、よりランダムな文章が生成される
    'messages': requestMessage // 生成する文章の元になるプロンプト
  };

  const openAiParams = {
    'method': 'POST',
    'headers': openAiHeaders,
    'payload': JSON.stringify(openAiPayload),
  };

  const response = UrlFetchApp.fetch('https://api.openai.com/v1/chat/completions', openAiParams);
  const resData = JSON.parse(response.getContentText());

  return resData.choices[0].message.content; // GPT の回答
}

// アシスタントの返答をスプレッドシートに格納する関数
function setAssistantMessage(replyMessage) {
  chatLastRow = chatSheet.getLastRow();
  chatSheet.getRange(chatLastRow + 1, 1, 1, 2).setValues([['assistant', replyMessage]]);
}

// LINE メッセージを送信する関数
function sendLineMessage(replyToken, replyMessage) {
  const linePayload = {
    'method': 'post',
    'headers': {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${PropertiesService.getScriptProperties().getProperty('Line_key')}`,
    },
    'payload': JSON.stringify({
      'replyToken': replyToken,
      'messages': [{
        'type': 'text',
        'text': replyMessage,
      }],
    }),
  };

  UrlFetchApp.fetch('https://api.line.me/v2/bot/message/reply', linePayload);
}
※ コードを一部修正しました。

画像処理に関連しない部分の大きな変更点ですが、GPT-4o はより人間っぽく振る舞うことができるようになったとのことなので、英語をかませるのをやめて、日本語で直接 API とやりとりするようにしました。


改めて、画像処理モードの使い方です。


文頭に「画像)」と付けてプロンプトを書くと画像処理のモードになります。

「画像を送信してください」と返答があるので、続けて画像を送ると処理が行われます。

画像処理モードでも、これまで通り、「画像)日本語!写真の料理のレシピを教えて(創造」のような形式で、system ロールへの指示や、temperature パラメータの指定もできます(詳細は以前の投稿を参照してください)。

ただ、「続き)」を使った継続的なやりとりはできません(あまり意味ないかな?と思って)。



面倒な書き起こしもらくらく(ちょっと間違ってるけど)。


***

ということで、GPT-4o を使って画像処理ができる LINE bot を作ってみました。

GAS で作れるとサーバーのことを考えなくていいので、とても楽です。

この簡便さと能力であれば、アイデア次第で、個人でも農業に役立つものがあれこれ作れそうですね。