FLINTERS Engineer's Blog

FLINTERSのエンジニアによる技術ブログ

【生成AI】Slack上で動作する『キャラデザAI』BOTを作る【StableDiffusion】

元エンジニアのPMが、お客様の業務を効率化するため、Slack上で動作するAI BOTを企画・開発しました!

この記事では、企画・開発のプロセスと、実装後の様子、一連の振り返りを大公開いたします!

この記事はFLINTERS10周年記念として133日間ブログを書き続けるチャレンジの25日目の記事となります。
これ以外にもAIについて書かれている記事があるので、興味がある方はこの記事の後でそちらも読んでみてください!
11日目:ChatGPTを活用してブログ執筆を加速する
13日目:【OpenAI】リリースノートおまとめくんを作ってみた

自己紹介

こんにちは!FLINTERSで企画職として働いている小島 一晃(こじま いっこう)と申します。

2019年、FLINTERSにエンジニアとして新卒入社しました。
2年目からは、PO(プロダクトオーナー)にロールを変更。
今年に入ってからは、僕からお願いして契約形態を変更いただき、 週4日稼働で業務委託契約を結ばせていただいております。

現在は、グループ内でのAI活用を推進する活動を主な業務としています。

キャリアを柔軟に描かせてもらえるのは、FLINTERSの大きな魅力です
PR:採用情報

企画/Why? AI活用のハードルがOOにあると考えたため

昨年の6月頃から、僕はコンテンツ制作を主な活動としているメンバーと向き合っており、彼/彼女らの稼働時間あたりの成果を向上させることでコンテンツの数や質に貢献したいと考えていました。

そんななか、生成AIが登場しました。テキストを生成するChatGPTや、イラストを生成するStableDiffusionなどです。
とんでもないインパクトがありそうだ、という予感がありました。そしてこの予感はコンテンツ制作に携わる多くの人間が感じたことでしょう。

メンバーの方々にヒアリングをかける中で、生成AIによってコンテンツの品質と作業効率を向上させられる大きな可能性を感じた領域がいくつかありました。

その中の1つがキャラクターデザイン
マンガやアニメなどの各種コンテンツに登場するキャラクター(主人公やサブキャラ、建物やアイテムなど)の見た目や設定を考える領域です。

生成AIは詰めが甘い*1ところがありますが、逆にたくさんのアイディアを高速で出すことには長けています。

コンテンツの企画フェーズで必要なキャラデザのタスクにおいては、この特長が活かせるのではないかと考えました。

ということで最初は、イラスト生成AI界隈ではスタンダードなツールであるStableDiffusion WEB UI を全員が使えるようにしよう、とハウツー情報の投稿や勉強会という手段で推進活動をしていたのですが、これが思うように行きませんでした。

やはり、高機能で複雑なツールは、使い始める段階にも、使いこなす段階にも大きなハードルがあるのだと感じました。

企画/What? Slack上で動作するシンプルなAI BOT

キャラデザ業務をAIで効率化してもらうために、少なくとも2つのハードルがあることがわかりました。

  1. AIを使い始めるハードル
  2. AIを使いこなすハードル

「AIを使い始めるハードル」については、以下のアプローチによって解決ができると仮説を立てました。

  • ほぼ全員が常駐しており使い慣れたSlackをインターフェースにすること
  • BOTにシンプルな機能(キャラの特徴をメンションするとデザイン画を作成してくれる機能)を1つだけ持たせ、難しいと感じさせないこと

「AIを使いこなすハードル」についても、以下のアプローチによって解決ができると仮説を立てました。

  • ユーザーがキャラクターの特徴をメンションすることだけに集中できるよう、生成クオリティを担保するためのpromptはシステム側で入力すること
  • 同じ理由から、画像サイズや顔補正、サンプラーやステップなどの設定もシステム側で代行すること

開発/How? BoltとStableDiffusionWebUI APIを組み合わせる

以下のようなコンポーネントを組み合わせてシステムを構成しました。

  • Slackアプリサーバー(ローカルで起動)
  • StableDiffusionWebUI API(Colaboratoryで起動しngrokで公開)
  • Slackアプリ認証サーバー(ローカルで起動しngrokで公開)

実装としては、それほど難しいことはしていません。

まず、Slackアプリサーバーを作成できるフレームワーク「Bolt」のテンプレートを利用してプロジェクトを作成しました。

github.com

余談ですが、Slackアプリのテンプレートってたくさんあるんですね。今回はアプリを作るのが初めてだったので学習の意味もありシンプルなものを選定しましたが、目的に合ったものを使用することでより作業を減らせそうです。

次に、Readmeの手順に従って、Slack Appを作成します。

今回はメンションに反応してキャラデザのタスクを実行するBOTを作りたかったので、Slackアプリのダッシュボードからapp_mentions権限を追加で付与します。
同様にして、files:write,files:read権限も付与します。これらは、AIが生成したイラストをアップロードするために必要です。

ここまでで下準備が終わったので、本格的にSlackアプリサーバー/認証サーバーを開発していきます。

テンプレートからの変更点は主に3つです。

  1. イベントapp_mentionに反応するためのリスナーを作成
  2. StableDiffusionWebUI APIへのリクエストを行う関数を作成
  3. OAuth認証の際にリクエストする権限を追加し、認証情報をファイルシステムに保存するように変更

以下、それぞれの変更点を詳細に解説していきます。
実装に興味がない人は、いざ稼働!の章までスキップしてください。

イベントapp_mentionに反応するためのリスナーを作成

こちらのテンプレートでは、リスナーの実装は全てlisteners配下にまとめられており、変更する場所がわかりやすかったです。

まず、listeners/events/app-mention.tsを新規作成します。

内容は以下の通りです

import { AllMiddlewareArgs, SlackEventMiddlewareArgs } from '@slack/bolt';
import characterDesign from '../../stablediffusion/characterdesign';

const appMentionCallback = async ({ client, event }: AllMiddlewareArgs & SlackEventMiddlewareArgs<'app_mention'>) => {
    try {
        // とりあえずレスポンス
        await client.chat.postMessage({
            channel: event.channel,
            thread_ts: event.ts,
            blocks: [
                {
                    "type": "section",
                    "text": {
                        "type": "mrkdwn",
                        "text": `承知しました!タスクが完了し次第メンションいたします!\n※進行中の他タスクが無ければ1分程度で完了します`,
                    }
                },
            ]
        })

        // イラスト生成
        const message = event.text
        const prompt = message.replace(/<@.*>/, "")
        const characterDesignResponse = await characterDesign(prompt)
        const info = JSON.parse(characterDesignResponse.info)
        const imgUploadResponse = await client.files.upload({
            file: characterDesignResponse.img,
            title: prompt,
            filename: "character_design.png",
        })
        if (!imgUploadResponse.file) {
            return
        }

        // 生成したイラストをスレッドに投稿
        const url = imgUploadResponse.file.permalink
        await client.chat.postMessage({
            channel: event.channel,
            thread_ts: event.ts,
            reply_broadcast: true,
            blocks: [
                {
                    "type": "section",
                    "text": {
                        "type": "mrkdwn",
                        "text": `<${url}| >`
                    }
                },
                {
                    "type": "section",
                    "text": {
                        "type": "mrkdwn",
                        "text": `<@${event.user}> キャラクターのデザインが完了いたしました!\n` + url,
                    }
                },
            ]
        })

        // イラスト生成時の設定をスレッドに投稿
        await client.chat.postMessage({
            channel: event.channel,
            thread_ts: event.ts,
            blocks: [
                {
                    "type": "section",
                    "text": {
                        "type": "mrkdwn",
                        "text": `生成時の設定は以下の通りです\n` + "```" + info.infotexts + "```",
                    }
                },
            ]
        })
    } catch (error) {
        console.error(error)
    }
};

export default appMentionCallback;

一番重要な部分はここです。

        // イラスト生成
        const message = event.text
        const prompt = message.replace(/<@.*>/, "")
        const characterDesignResponse = await characterDesign(prompt)
        const info = JSON.parse(characterDesignResponse.info)
        const imgUploadResponse = await client.files.upload({
            file: characterDesignResponse.img,
            title: prompt,
            filename: "character_design.png",
        })

        // 生成したイラストをスレッドに投稿
        const url = imgUploadResponse.file.permalink
        await client.chat.postMessage({
            channel: event.channel,
            thread_ts: event.ts,
            reply_broadcast: true,
            blocks: [
                {
                    "type": "section",
                    "text": {
                        "type": "mrkdwn",
                        "text": `<${url}| >`
                    }
                },
                {
                    "type": "section",
                    "text": {
                        "type": "mrkdwn",
                        "text": `<@${event.user}> キャラクターのデザインが完了いたしました!\n` + url,
                    }
                },
            ]
        })

今回は、@AIキャラデザ girl, green eyes, blond hairのような形式でメンションを飛ばすことを想定しているので、@AIキャラデザの部分を空白にリプレイスしたうえで、丸々characterDesign関数に渡しています。これはこれから作るStableDiffusionWebUI APIへのリクエストを行う関数です。

今回はあくまでもテスト的かつ内部向けの実装なので省きましたが、不特定多数が利用するようなケースを想定している場合は、プロンプトインジェクション*2を対策した方が良いでしょう。こういった記事が参考になります。

characterDesign関数から却ってきた画像を、Slack/Boltのfiles.upload関数を使ってSlackのサーバー上にアップロードしています。

アップロードすると、レスポンスから.file.permalinkで画像のURLを取得できるので、それをchat.postMessageで投稿するだけです。

section/mrkdwnの形式で画像(URL)を構成していることに注意してください。image/image_urlに構成するのを試したのですが、この形式だとなぜか画像がうまく表示されませんでした。

この部分のコードを作る際、投稿の見た目をプレビューしながら、blocksの中身を作れるBlock Kit Builderが役に立ちました。

StableDiffusionWebUI APIへのリクエストを行う関数を作成

stablediffusion/characterdesign.tscharacterDesign関数を定義しました。

import axios from "axios"

export default async function characterDesign(prompt: string) {
    const domain = "https://XXXX-XX-XXX-XXX-XX.ngrok-free.app/"
    const path = "sdapi/v1/txt2img"
    const url = domain + path

    const payload = {
        "prompt": `${prompt}, reference sheet, multiple views, masterpiece, best quality`,
        "negative_prompt": "negativeXL_D, worst quality, low quality, nude, NSFW, monochrome, text",
        "width": 1344,
        "height": 768,
        "sampler_name": "DPM++ 2M SDE Karras",
        "seed": -1,
        "steps": 25,
        "cfg_scale": 10,
        "save_images": true,
        "alwayson_scripts": {
            "ADetailer": {
                "args": [
                    {
                        "ad_model": "face_yolov8s.pt"
                    }
                ]
            }
        }
    }

    const response = await axios.post(url, payload)
    const data = response.data
    const buffer = Buffer.from(data.images[0], 'base64')

    const r = { img: buffer, info: data.info }
    return r
}

StableDiffusion Web UI は、GUIからイラストAIを操作できる大変便利なアプリケーションですが、なんと標準でAPI機能が付いています。

今回は、Colaboratoryというクラウド環境で起動したWeb UI APIを、ngrokで公開して、そこに対してリクエストをおこないました。

Colaboratory上でWebUIを動作させるのに最もおすすめな方法は、公式リポジトリからリンクされているTheLastBen/fast-stable-diffusionです。今回もこれを使用しています。

ポイントは2つです。

  • payloadにクオリティアップのための設定を盛り込む
  • レスポンスをbuffer形式に変換してかえす

payloadの構造は、Web UIを起動した後、/docsにアクセスするとドキュメントが閲覧できるので、これを参考にしました。

ユーザーが入力したワードをpromptに入れ込むだけでなく、そこにクオリティ向上のためのpromptを追加しました(reference sheet, multiple views, masterpiece, best quality

また、Web UIの拡張機能であるADetailerをONに設定しています。
これによって、本来イラスト内で小さくつぶれてしまう顔の部分を修復することができます。

github.com

レスポンスをbuffer形式に変換してかえす理由は、Slackがこの形式でのファイルアップロードを受け入れているからです。

OAuth認証の際にリクエストする権限を追加し、認証情報をファイルシステムに保存するように変更

今回は、テスト用のSlackワークスペース(自分のみ)で実装と動作確認を行い、それを自分が管理者権限を持たない本番ワークスペース(会社のメンバーがいる)にインストールする、という流れだったので、OAuthサーバーの認証が必要でした。

変更が必要なファイルは2つです

  • app-oauth.ts
  • app.ts

公式ドキュメントを参考にして、以下のように変更します。

// app-oauth.ts

import { App, LogLevel } from '@slack/bolt';
import { config } from 'dotenv';
import registerListeners from './listeners';
import { FileInstallationStore } from '@slack/oauth'

config();

const app = new App({
  logLevel: LogLevel.DEBUG,
  signingSecret: process.env.SLACK_SIGNING_SECRET,
  clientId: process.env.SLACK_CLIENT_ID,
  clientSecret: process.env.SLACK_CLIENT_SECRET,
  stateSecret: 'my-state-secret',
  scopes: ['app_mentions:read', 'channels:history', 'chat:write', 'commands', 'files:read', 'files:write'],
  installationStore: new FileInstallationStore({
    baseDir: 'data/installations'
  }),
});

/** Register Listeners */
registerListeners(app);

/** Start Bolt App */
(async () => {
  try {
    await app.start(process.env.PORT || 3000);
    console.log('⚡️ Bolt app is running! ⚡️');
  } catch (error) {
    console.error('Unable to start App', error);
  }
})();
// app.ts
import { App, LogLevel } from '@slack/bolt';
import * as dotenv from 'dotenv';
import registerListeners from './listeners';
import { FileInstallationStore } from '@slack/oauth'

dotenv.config();

/** Initialization */
const app = new App({
  socketMode: true,
  appToken: process.env.SLACK_APP_TOKEN,
  logLevel: LogLevel.DEBUG,
  signingSecret: process.env.SLACK_SIGNING_SECRET,
  clientId: process.env.SLACK_CLIENT_ID,
  clientSecret: process.env.SLACK_CLIENT_SECRET,
  stateSecret: 'my-state-secret',
  scopes: ['app_mentions:read', 'channels:history', 'chat:write', 'commands', 'files:read', 'files:write'],
  installationStore: new FileInstallationStore({
    baseDir: 'data/installations'
  }),
});

/** Register Listeners */
registerListeners(app);

/** Start Bolt App */
(async () => {
  try {
    await app.start(process.env.PORT || 3000);
    console.log('⚡️ Bolt app is running! ⚡️');
  } catch (error) {
    console.error('Unable to start App', error);
  }
})();

重要なのは、new APP時のscopeinstallationStoreです。

今回は、デフォルトからスコープを追加(app_mentions:read,files:read,files:write)しているので、それらも要求するように書き換えてあげる必要があります。

installationStoreは、認証を行った結果得られたトークンをどこに保存するか指定するための項目です。テンプレートではMapに保存するような実装になっているので、これをファイルシステムに保存するように変更します。

いざ稼働!

まずは、管理者権限を持つ担当の方にお願いして、本番ワークスペースにこのSlackアプリをインストールしてもらいます。

OAuthサーバーをローカルで立ち上げ、そのサーバーをngrokで公開します。

Slack APPのダッシュボードで、リダイレクトURLに起動したサーバーのURLを入力します。
OAuth & Permissions > Redirect URLs.

このとき、URLの末尾に、/slack/oauth_redirectをくっつけることを忘れずに。

ここまで終わったら、管理者権限を持つ担当の方に、https://XXXX-XXXX-XX-XXX.ngrok-free.app/slack/installのURLを渡して、アクセスしてもらえばOK です。
URLは、ngrokが作成したURLの末尾に/slack/installをくっつけると作成できます。

このようにして、OAuth認証のページが現れ、インストールが無事完了します。

認証情報は、指定したパスにファイルとして作成されます。間違ってもGitにコミットしたりしないよう注意してください。

インストール後は、アプリを任意のチャンネルに追加し、使い方を告知します。

早速使ってくれる人が複数現れました!

感想・振り返り

使っていただいている様子を見るに、狙い通り2つのハードルを排除することには成功したように見えます。

  1. AIを使い始めるハードル
  2. AIを使いこなすハードル

ユーザーが困ったとき、Slackインターフェースだとすぐにサポートできる点も🙆ですね。

しかし、キャラデザ業務の品質と効率を向上する、というゴールを達成するにはまだ至っていません。

BOTのリリース当初、物珍しさで一時的にたくさんの方が利用してくれはしたのですが、それ以降の利用状況が芳しくなく、業務で使われているとは言い難い状況です。

ものを作ることと、それが使われて価値をもたらすことの間には大きなギャップがあることを再認識できました。

現在は、なぜ利用されないのかを探るために、想定ユーザーの方へヒアリングを行なっています。
そもそも今需要があるか(キャラデザを行うフェーズにあるか)、既存の代替手段はなんなのか、既存の代替手段に対するペインは存在するか、あたりをしっかりとヒアリングして、そのペインを解決する手段として現在のソリューションが適切かを評価し、このBOTを改善していくのが良さそうです。

そしてゆくゆくは、キャラデザ以外のタスクもAI BOT化していくことで、コンテンツの制作に関わる人たちの稼働あたりの成果を大きく向上させ、コンテンツや会社の成功に貢献したいと考えています。

*1:言語モデルにおけるもっともらしい嘘(ハルシネーション)や、イラストAIの手指変形など

*2:AIシステムに悪意のあるプロンプトを入力して不正利用する攻撃手法で、ブランドイメージの既存や、ノウハウの流出が懸念される