🎉

Slack-Azure OpenAI GPT-4ボットを画像入力対応にバージョンアップしました[全コードあり]

2024/05/30に公開

株式会社ジェイテックジャパン CTOの高丘 @tomohisaです。社内では昨年の3月より1年くらいChatGPT BotがSlackのBotとして運用しています。

Copilotも使っているのですが、社内で言葉の定義や概念の概要を話している時や、新たな機能を追加する時の調査などで皆が活用していました。もちろん、ChatGPTのWebでできるような、おおきなpdfの解析やGPTs、ベクターデータベースなどは使っていないため、機能は限られたものとなります。それでもChatGPTに馴染みのないメンバーも簡単にSlackから使うことができ、DMにも開放しているので、結構使われています。

https://zenn.dev/jtechjapan_pub/articles/3579c91093c833

https://zenn.dev/jtechjapan_pub/articles/5eb5d879088c23

GPT-4oの対応

せっかくGPT-4oが発表され、現在使っているAzure OpenAI でもデプロイできるようになったので、対応してみようと思い、やってみたところ、GPT-4oへの対応をまず行いました。こちらはAzure OpenAIのインスタンスを対応リージョン(今回はUS West 3)で作成し、gpt-4o のモデルをデプロイして、以下の情報をBotのコンフィグファイルに設定することで簡単に変更できました。(javascriptのコード変更は不要でした。)

  • エンドポイントのURL
  • Azure OpenAIのキー
  • モデルのデプロイ名称

画像読み込みの対応

画像読み込みを行うためには以下のステップが必要になります。

  1. Slackで画像が送られてきた時にFunctionsでダウンロードする
  2. Base64でエンコードしてリクエストに追加する

ステップで見ると簡単なのですが、以下の問題が起きてそれに対応する必要がありました。

  • オフィシャルのAzure OpenAI パッケージではなく、個人作成のパッケージだったので個人作成のパッケージを @azure/openai パッケージに変更してコードも調整する
  • ファイルのダウンロードはSlackのAPIではなく、axios を使って行うので、Azure Functionsのコンソールで npm install axiosでaxiosを追加する
  • ここで詰まったのですが SlackのApp Bot トークンにfile:readの権限がなかったのでSlackのアプリビルダーページで権限を追加
    上記の調整を行う必要がありました。

出来上がったjavascriptのコードは以下のものとなります。


const axios = require('axios');
const { WebClient } = require('@slack/web-api');
const { OpenAIClient, AzureKeyCredential } = require("@azure/openai");

const openaiClient = new OpenAIClient(
    process.env.OPENAI_API_BASE, 
    new AzureKeyCredential(process.env.OPENAI_AZURE_API_KEY)
  );

const slackClient = new WebClient(process.env.SLACK_BOT_TOKEN);
delete slackClient["axios"].defaults.headers["User-Agent"];
const GPT_BOT_USER_ID = process.env.GPT_BOT_USER_ID;
const CHAT_GPT_SYSTEM_PROMPT = process.env.CHAT_GPT_SYSTEM_PROMPT;
const GPT_THREAD_MAX_COUNT = process.env.GPT_THREAD_MAX_COUNT;
const GPT_MODEL = process.env.GPT_MODEL;

module.exports = async function (context, req) {
    const postMessage = async (channel, text, threadTs) => {
        var response = await slackClient.chat.postMessage({
            channel: channel,
            text: text,
            thread_ts: threadTs
        });
        context.log(text)
        return response.ts;
    };

    const deleteMesssage = async (channel, ts) => {
        await slackClient.chat.delete({
            channel: channel,
            ts: ts
        });
    };

    const downloadImage = async (url) => {
        const response = await axios.get(url, {
            headers: { 'Authorization': `Bearer ${process.env.SLACK_BOT_TOKEN}` },
            responseType: 'arraybuffer'
        });
        return Buffer.from(response.data).toString('base64');
    };

    const createCompletion = async (messages) => {
        try {
            const  { id, created, choices, usage } = await openaiClient.getChatCompletions(GPT_MODEL,messages);
            return choices[0].message.content;
        } catch (err) {
            context.log.error(err);
            return err.message;
        }
    };

    const extractImageUrls = async (messages) => {
        return await Promise.all(messages.map(async m => {
            const urls = [];
            if (m.files) {
                for (const file of m.files) {
                    if (file.mimetype.startsWith('image/')) {
                        const base64Image = await downloadImage(file.url_private);
                        urls.push(`data:${file.mimetype};base64,${base64Image}`);
                    }
                }
            }
            return { ...m, imageUrls: urls };
        }));
    };
    function createContentFromTextAndUrls(text, urls) {
        const contentArray = [
            {
                type: "text",
                text: text
            }
        ];
    
        urls.forEach(url => {
            contentArray.push({
                type: "image_url",
                imageUrl: { url : url} 
            });
        });
    
        return contentArray;
    }

    // Ignore retry requests
    if (req.headers['x-slack-retry-num']) {
        context.log('Ignoring Retry request...');
        return { statusCode: 200, body: JSON.stringify({ message: "No need to resend" }) };
    }
    const body = eval(req.body);
    if (body.challenge) {
        context.res = {
            body: body.challenge
        };
        return;
    }
    context.log(req.body);
    const event = body.event;
    const threadTs = event.thread_ts || event.ts;
    if (event.type === 'app_mention') {
        try {
            var thinking = await postMessage(event.channel, '返答を作成中...', threadTs);
            const threadMessagesResponse = await slackClient.conversations.replies({
                channel: event.channel,
                ts: threadTs,
            });
            if (threadMessagesResponse.ok !== true) {
                await deleteMesssage(event.channel, thinking);
                await postMessage(event.channel, '[Bot]メッセージの取得に失敗しました。', threadTs);
                return;
            }
            const messagesWithUrl = await extractImageUrls(threadMessagesResponse.messages.sort(
                (a, b) => Number(a.ts) - Number(b.ts)
            ).filter(message => !message.text.includes('返答を作成中...') && (message.text.includes(GPT_BOT_USER_ID) || message.user == GPT_BOT_USER_ID)).slice(GPT_THREAD_MAX_COUNT * -1));
            const botMessages = messagesWithUrl.map(m => {
                const role = m.bot_id ? "assistant" : "user"
                return { role: role, content: createContentFromTextAndUrls(m.text.replace(/<@[^>]+>/g, ''), m.imageUrls) }
            });
            if (botMessages.length < 1) {
                await deleteMesssage(event.channel, thinking);
                await postMessage(event.channel, '[Bot]質問メッセージが見つかりませんでした。@chatGPTbot を付けて質問してみて下さい。', threadTs);
                return;
            }
            context.log(botMessages);
            var postMessages = [
                { role: "system", content: CHAT_GPT_SYSTEM_PROMPT },
                ...botMessages
            ];
            const openaiResponse = await createCompletion(postMessages);
            if (openaiResponse == null || openaiResponse == '') {
                await deleteMesssage(event.channel, thinking);
                await postMessage(context, event.channel, '[Bot]ChatGPTから返信がありませんでした。この症状は、ChatGPTのサーバーの調子が悪い時に起こります。少し待って再度試してみて下さい。', threadTs);
                return { statusCode: 200 };
            }
            await deleteMesssage(event.channel, thinking);
            await postMessage(event.channel, openaiResponse, threadTs);
            context.log('ChatGPTBot function post message successfully.');
            return { statusCode: 200 };
        } catch (error) {
            context.log(await postMessage(event.channel, `Error happened: ${error}`, threadTs));
            await deleteMesssage(event.channel, thinking);
        }
    }
    if (event.type === 'message' && event.user != null && event.user !== GPT_BOT_USER_ID && event.subtype !== 'message_deleted' && event.bot_id == null) {
        try {
            var thinking = await postMessage(event.channel, '返答を作成中...', threadTs);
            const threadMessagesResponse = await slackClient.conversations.replies({
                channel: event.channel,
                ts: threadTs,
            });
            if (threadMessagesResponse.ok !== true) {
                await deleteMesssage(event.channel, thinking);
                await postMessage(event.channel, '[Bot]メッセージの取得に失敗しました。', threadTs);
                return;
            }
            const messagesWithUrl = await extractImageUrls(threadMessagesResponse.messages.sort(
                (a, b) => Number(a.ts) - Number(b.ts)
            ).slice(GPT_THREAD_MAX_COUNT * -1));
                
            const botMessages = messagesWithUrl.map(m => {
                const role = m.bot_id ? "assistant" : "user"
                return { role: role, content: createContentFromTextAndUrls(m.text.replace(/<@[^>]+>/g, ''), m.imageUrls) }
            });
            if (botMessages.length < 1) {
                await deleteMesssage(event.channel, thinking);
                await postMessage(event.channel, '[Bot]質問メッセージが見つかりませんでした。@chatGPTbot を付けて質問してみて下さい。', threadTs);
                return;
            }
            context.log(botMessages);
            var postMessages = [
                { role: "system", content: CHAT_GPT_SYSTEM_PROMPT },
                ...botMessages
            ];
            const openaiResponse = await createCompletion(postMessages);
            if (openaiResponse == null || openaiResponse == '') {
                await deleteMesssage(event.channel, thinking);
                await postMessage(context, event.channel, '[Bot]ChatGPTから返信がありませんでした。この症状は、ChatGPTのサーバーの調子が悪い時に起こります。少し待って再度試してみて下さい。', threadTs);
                return { statusCode: 200 };
            }
            await deleteMesssage(event.channel, thinking);
            await postMessage(event.channel, openaiResponse, threadTs);
            context.log('ChatGPTBot function post message successfully.');
            return { statusCode: 200 };
        } catch (error) {
            context.log(await postMessage(event.channel, `Error happened: ${error}`, threadTs));
            await deleteMesssage(event.channel, thinking);
        }
    }
    return { statusCode: 200 };
}


使ってみたサンプルはこちら

bot sample

githubのdiffを画像で貼ったのですが、画像に書いている内容をテキストで貼り付けたかのように理解して説明してくれるのは、最近では慣れてきたものの、すごいですね。数学の問題をカメラで撮って解くことなどもかなりの精度でできるようになってきています。

よろしければご使用ください。

まとめ

ChatGPTはシステムと連結すれば色々な機能を作れます。私たちもいくつかの顧客プロダクトでシステムと連携したLLM連携をすでに開発しています。ひきつづきLLMの活用に関する知見についても書いていきたいと思います。

ジェイテックジャパンブログ

Discussion