皆さん、Googleが2023年12月7日にとんでもないものを発表したことをご存じでしょうか?
そう、Googleの生成系AI「Gemini」です
3つのモデルのうち、2023年12月13日から「Gemini Pro」だけ使えるようになっています!「Google×生成AI」これは使うしかないでしょ!
これをLINE botに組み込めるの、超やばくないですか??
というワケで今回は、話題の「Gemini Pro Vision」を使って画像も扱えるLINE botを作っちゃいましょう!
前回の記事で作ったLINE botで画像を扱えるようにもう一段階レベルアップさせてみましょう!
この機会に、皆さんも話題の「Gemini Pro API」を使ってみてね!
この記事を読めば、「Gemini Pro Vision」が搭載された会話可能なLINE botが完成します!
かなりワクワクする内容だと思うので、是非読んでいってください!
本記事は全3回のうち、最終章です!
まだ見てない方はこちらから見てね!
GASを極めたい方や、業務の効率化を図りたい方は、ぜひこの記事を読んください!
難しいことはGASに任せて、我々人間は楽しちゃいましょう!
当ブログでは実際に仕事でGASを扱っている私が、GASの魅力について徹底的に取り上げていきます!
LINE botに「Gemini Pro Vision」を搭載しよう
完成形のイメージはこんなカンジ!
今回の記事では、前回作ったLINE botをさらにレベルアップさせて「Gemini Pro Vision」の機能も使えるようにしちゃいます!
サーティワンアイスクリームの写真を送ったところ、「サーティワンのストロベリーチーズケーキとチョコレートのアイスです」と送られてきました すごい
前回の記事で、GAS上で「Gemini Pro API」を動かしてみるところまでやりました
Gemini Pro Visionは、Googleが開発したマルチモーダル言語モデルです
Gemini Pro Visionは、テキストと画像の両方を入力として受け入れ、テキスト出力として生成します
テキストと画像の間の関連性を理解し、テキスト出力をより関連性のあるものにすることができます
【事前準備】画像を一時保存するためのフォルダを作成する
今回はGoogleドライブ上に画像ファイルを一時保存するため、一時保存領域を作成しておきましょう
私は同一フォルダ内にtmp
を作成しました
このtmpのフォルダIDを確認しておいてください
【STEP1】LINEによる画像送信時のGoogleドライブへの一時保存
LINEでは画像とテキストを同時におくることはできません
そこで……以下のような仕様にしたいと思います!
- 画像を送信し、Googleドライブに一時保存する
- 次にテキストを送信し、一時保存された画像データとともにGemini Pro Visionに渡す
ここでLINEの「Messaging APIリファレンス」を確認してみましょう!
こちらを確認すると、contentProvider.type
がline
、つまりユーザーが画像ファイルを送信した場合、
画像ファイルのバイナリデータは、メッセージIDを指定してコンテンツを取得するエンドポイントを使用することで取得できます。
Messaging APIリファレンスより
そこで、コンテンツを取得するエンドポイントも併せて確認しましょう!
curl -v -X GET https://api-data.line.me/v2/bot/message/{messageId}/content \
-H 'Authorization: Bearer {channel access token}'
{messageId}
と{channel access token}
は実際の値に置き換える必要があります!
これにより、指定されたメッセージのコンテンツを取得することができます
そして、これを見ると、以下のことがわかります
項目 | 値 |
---|---|
Fetch先のURL | https://api-data.line.me/v2/bot/message/{messageId}/content |
method種別 | GET |
認証 | Bearer {channel access token} |
LINEのAPIエンドポイント(https://api-data.line.me/v2/bot/message/{messageId}/content)にGETリクエストを行うことで、指定された{messageId}
に関連するメッセージのコンテンツを取得できます
const LINEAPI_TOKEN = '**LINEのチャネルアクセストークンを記載する**';
const OUT_DIR = '**一時保存領域のフォルダID**';
/**
* 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));
// 画像メッセージのとき
} else if (msgType=='image') {
let imageId = getImageId4Create(eventData);
}
}
/**
* LINEのトークで送信された画像をGoogleドライブに保存し、ファイルIDを返却するメソッド
* @param {EventObject} e - イベントオブジェクト
*/
function getImageId4Create(e) {
const url = 'https://api-data.line.me/v2/bot/message/' + e.message.id + '/content',
options = {
'method': 'get',
'headers': {
'Authorization': 'Bearer ' + LINEAPI_TOKEN,
}
};
const data = UrlFetchApp.fetch(url, options)
,imageData = data.getBlob().getAs('image/png').setName(Number(new Date()));
return DriveApp.getFolderById(OUT_DIR).createFile(imageData).getId();
}
- LINEのAPIエンドポイント( https://api-data.line.me/v2/bot/message/{messageId}/content )を使用して、ユーザーが送信した画像データを取得
- 取得した画像データをBlobとして取得し、ファイル名を日時で指定
- その後、Googleドライブの指定されたフォルダに画像を保存
- 保存した画像ファイルのファイルIDを取得して、それを返却
上記のようにmsgType
で判別して、imageであった場合にはコンテンツを取得するエンドポイントを用いてデータを取得し、Googleドライブへ保存します
curl -v -X GET https://api-data.line.me/v2/bot/message/{messageId}/content \
-H 'Authorization: Bearer {channel access token}'
{messageId}
と{channel access token}
は実際の値に置き換える必要があるので…
function getImageId4Create(e) {
const url = 'https://api-data.line.me/v2/bot/message/' + e.message.id + '/content',
options = {
'method': 'get',
'headers': {
'Authorization': 'Bearer ' + LINEAPI_TOKEN,
}
};
こんなカンジで実際の値に置き換えているよ!
【STEP2】画像データをGemini Pro Visionに渡して回答を受領
これまではGemini Proを利用していましたが、今回はGemini Pro Visionを利用するため、APIの仕様を確認しましょう
echo '{
"contents":[
{
"parts":[
{"text": "What is this picture?"},
{
"inline_data": {
"mime_type":"image/jpeg",
"data": "'$(base64 -w0 image.jpg)'"
}
}
]
}
]
}' > request.json
curl https://generativelanguage.googleapis.com/v1beta/models/gemini-pro-vision:generateContent?key=${API_KEY} \
-H 'Content-Type: application/json' \
-d @request.json 2> /dev/null | grep "text"
これまではcontents
には'parts'
> 'text'
のみ(role
の指定もあり)でしたが、ここにinline_data
を指定し、data
をbase64で渡せば、Gemini Pro Visionは受け付けてくれるようです
また、今回の場合は画像メッセージを送信し、次にテキストメッセージを送信すると2つのイベントが発生しますが、GAS側から見るとこの2つのイベントは独立しています
そのため、後者のテキストメッセージを送信した際にその前にユーザが画像を送信していたという事実をGAS側は何らかの手段を使わないと理解できません
えっじゃあ、どうすれば良いの?
ここで登場するのが、前回ご紹介した Cache
です
つまり、画像データをGoogleドライブに保存し、この際のファイルIDをScriptCache
に保存しておきます
あとはテキストメッセージを受信した際に、ScriptCache
が存在するか否かで処理を分岐させればバッチリです!
では、やってみましょう!
const GEMINI_API = '**「Gemini Pro」で取得したAPI keyを記載する**';
const REPLY_URL = 'https://api.line.me/v2/bot/message/reply';
const LINEAPI_TOKEN = 'LINEで取得したチャネルアクセストークンを記載する**';
const OUT_DIR = '**一時保存領域のフォルダID**';
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') {
gemini;
if (!sCache.get('image')) {
gemini = getGeminiProAnswerTxt(uText);
} else {
gemini = getGeminiProVisionAnswerTxt(uText, sCache.get('image'));
DriveApp.getFileById(sCache.get('image')).setTrashed(true);
sCache.remove('image');
}
replyTxt(repToken, gemini);
sCache.put('user', uText.slice(0, 10000));
sCache.put('model', gemini.slice(0, 10000));
// 画像メッセージのとき
} else if (msgType == 'image') {
let imageId = getImageId4Create(eventData);
sCache.put('image', imageId);
replyTxt(repToken, '送信された画像について聞きたいことは何ですか?');
}
}
/**
* LINEのトークに送信されたメッセージをGemini Pro Vision APIに渡して回答を得るメソッド
* @param {String} txt - 送信されたメッセージ
* @param {String} imageid - 画像のファイルID
*/
function getGeminiProVisionAnswerTxt(txt, imageid) {
try {
const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-pro-vision:generateContent?key=${GEMINI_API}`
, payload = {
'contents': [{
'parts': [{
'text': txt
},
{
'inlineData': {
'mimeType': 'image/png',
'data': Utilities.base64Encode(DriveApp.getFileById(imageid).getBlob().getBytes())
}
}]
}]
}
, 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 '申し訳ございません。お答えできません。';
}
} catch (ex) {
writeLog(ex.toString());
return '申し訳ございません。Gemini Pro Visionの呼び出しで異常終了しました。';
}
}
このようにすることでsCache.get('image')
がある、つまりは前回の画像保存データがある場合は、入力テキストと前回保存した画像をBase64にエンコードしたうえで、Gemini Pro Visionに渡します
getGeminiProVisionAnswerTxt
を呼び出したあとは、
- 一時保存した画像を削除
Cache
に設定した画像ファイルIDを削除
することを忘れずに行いましょう。
前者を行わないとGoogleドライブがどんどん逼迫されます。後者を行わないとCache
がクリアされるまで(デフォルトで600秒)、Gemini Pro Visionを利用するモードとなります。
しかし…上記のプログラムには考慮漏れがあります
Gemini Pro Visionの仕様を改めて確認すると以下のように記載があります
画像データを使用するプロンプトには、次の制限と要件が適用されます。
Gemini Pro Visionプロンプトの画像の要件
・画像とテキストを含むプロンプト全体で最大 4 MB
Base64にエンコードした場合、元の画像サイズが3.5 MB程度であっても4 MBを超えてしまいます
最近のスマホは非常に画像サイズが大きいため、
return '申し訳ございません。Gemini Proの呼び出しで異常終了しました。';
が多発する可能性があります
そこで、ImgAppライブラリを利用しましょう!
【STEP3】ImgAppライブラリを活用して、画像データを圧縮する
このライブラリはGASが苦手とする画像編集を補完するライブラリです
主に、画像のサイズ取得やリサイズ、トリミング等を行うことができます
ライブラリの追加方法は過去記事を参考にしてね!
使用するメソッドは以下の通りです
メソッド名 | 使い方 |
---|---|
getSize | ImgApp.getSize(blob) |
doResize | ImgApp.doResize(fileId, width) |
ImgAppライブラリを用いてGemini Pro Visionに渡すデータを編集してみましょう!
function getGeminiProVisionAnswerTxt(txt, imageid) {
try {
let convWidth = ImgApp.getSize(DriveApp.getFileById(imageid).getBlob()).width/5
, convImage = ImgApp.doResize(imageid, Math.round(convWidth));
const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-pro-vision:generateContent?key=${GEMINI_API}`
, payload = {
'contents': [{
'parts': [{
'text': txt
},
{
'inlineData': {
'mimeType': 'image/png',
'data': Utilities.base64Encode(convImage.blob.getBytes())
}
}]
}]
}
, 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 '申し訳ございません。お答えできません。';
}
} catch (ex) {
return '申し訳ございません。Gemini Proの呼び出しで異常終了しました。';
}
}
getBlob()
は、DriveApp.getFileById(imageid)
で取得したファイルに対して.getBlob()
を呼び出して、そのファイルのデータをBlobとして取得しています
let convWidth = ImgApp.getSize(DriveApp.getFileById(imageid).getBlob()).width/5
このようにImgAppライブラリを用いて画像のWidthを取得し、ひとまず1/5くらいに修正してみました
これにより、元画像の幅の1/5がconvWidth
として計算されます
ただし、これでもあまりにも大きな元画像である場合は、4 MBを超えることはあると思います
そのため、一律でWidthを1000等に設定し、doResizeのみを使うのもありかもしれません
ここは、自分で実際に相談Botを使いながらチューニングしていきましょう
プログラム全文
const GEMINI_API = '**取得した「Gemini Pro」のAPI keyを記載する**';
const REPLY_URL = 'https://api.line.me/v2/bot/message/reply';
const LINEAPI_TOKEN = '**LINEで取得したチャネルアクセストークンを記載する**';
const OUT_DIR = '**一時保存領域のフォルダID**';
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;
if (!sCache.get('image')) {
gemini = getGeminiProAnswerTxt(uText);
} else {
gemini = getGeminiProVisionAnswerTxt(uText, sCache.get('image'));
DriveApp.getFileById(sCache.get('image')).setTrashed(true);
sCache.remove('image');
}
replyTxt(repToken, gemini);
sCache.put('user', uText.slice(0, 10000));
sCache.put('model', gemini.slice(0, 10000));
// 画像メッセージのとき
} else if (msgType=='image') {
let imageId = getImageId4Create(eventData);
sCache.put('image', imageId);
replyTxt(repToken, '送信された画像について聞きたいことは何ですか?');
}
}
/**
* 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のトークで送信された画像をGoogleドライブに保存し、ファイルIDを返却するメソッド
* @param {EventObject} e - イベントオブジェクト
*/
function getImageId4Create(e) {
const url = 'https://api-data.line.me/v2/bot/message/' + e.message.id + '/content'
, options = {
'method': 'get',
'headers': {
'Authorization': 'Bearer ' + LINEAPI_TOKEN,
}
};
const data = UrlFetchApp.fetch(url, options)
, imageData = data.getBlob().getAs('image/png').setName(Number(new Date()));
return DriveApp.getFolderById(OUT_DIR).createFile(imageData).getId();
}
/**
* LINEのトークに送信されたメッセージをGemini Pro Vision APIに渡して回答を得るメソッド
* @param {String} txt - 送信されたメッセージ
*/
function getGeminiProVisionAnswerTxt(txt, imageid) {
try {
let convWidth = ImgApp.getSize(DriveApp.getFileById(imageid).getBlob()).width/5
, convImage = ImgApp.doResize(imageid, Math.round(convWidth));
const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-pro-vision:generateContent?key=${GEMINI_API}`
, payload = {
'contents': [{
'parts': [{
'text': txt
},
{
'inlineData': {
'mimeType': 'image/png',
'data': Utilities.base64Encode(convImage.blob.getBytes())
}
}]
}]
}
, 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 '申し訳ございません。お答えできません。';
}
} catch (ex) {
return '申し訳ございません。Gemini Proの呼び出しで異常終了しました。';
}
}
/**
* 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);
}
プログラムの修正が終わったら、再デプロイしましょう
全3回に渡る「Gemini Pro API」を使ったLINE bot講座はこれにておしまいです!
お疲れさまでした!
私が作った「LINE bot」です!
良かったらお友だちになってください!
まとめ
お疲れ様でした
今回は「GAS × Gemini Pro API × LINE Messaging API の相談Botで画像を扱う」ということで、これまで作成した相談Botをさらに進化させ、テキストだけでなく画像も扱えるようになりました
どうでしょう、GASってやばくないですか!?
ひとまず、今回で「 Gemini Pro API × LINE編」は完結です
次回以降はまたGoogleフォームに戻ろうと思います
GAS×LINE APIだとログが出力されない!と質問を受けたので、外部サービスと連携する際にログを出力する方法についてまとめました!
ぜひ、皆さんも実業務でのGASの活用に取り組んでみてください
引き続き、GASを楽しんでいきましょう!!
X(旧:Twieer)にて、ブログの更新やQiita記事の更新、GAS情報をお届けしますので、是非フォローしてください!
おかげさまで今年5月に起業しました!
GASやGoogleサービス、プログラミング全般のご相談承ります!
YouTubeでGoogleサービスをより
快適に使う方法をご紹介しています!
見て頂けたらうれしいです
コメント