0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SalesforceのInvocable Actionで自作AgentforceチャットUIを作る

0
Posted at

はじめに

AgentforceをApexから呼び出して、独自のチャットUIを実装したい。

そう考えて調べてみると、標準のAgent Panelを経由しない実装パターンが用意されていました。
使うのはCustom Agent Invocable Actionという仕組みで、Apexのわずか数行でエージェントの応答を取得できます。

この記事では、Invocable.Actionクラスを使った具体的な実装コードと、LWC側でのセッション管理の書き方をまとめます。ゼロから動くところまで、実装目線で解説します。

Agent Panelを使わない、という選択肢

Agentforceを組織に導入すると、多くの人はまず標準のAgent Panelを触ります。画面右下に出てくる、あのチャットウィジェットです。

ただ、業務システムに組み込む場面では、標準パネルのデザインや配置がそのまま使えないケースが結構あります。レコード詳細ページの真ん中に埋め込みたい、モバイルアプリ的なUIにしたい、社内ポータルの雰囲気に合わせたい。こうした要望は珍しくありません。

そこで登場するのが、Apexからエージェントを直接呼び出す方法です。仕組みはシンプルで、LWCがApexを呼び、Apexがエージェントを呼び、返ってきた応答をLWCが表示する。この一直線の流れだけで、チャットが成立します。

Salesforceにはもうひとつ、Agentforce Conversation Client API(ACC API、lightning/accApiモジュール)という選択肢もあります。こちらは標準のAgent Panel自体をLWCから開いたり閉じたり、発話を送り込んだりする仕組みです。要するに、パネルを遠隔操作するリモコンのようなものです。

accApiについては以下で解説しています。

あくまで「パネルを操作するAPI」なので、パネルのデザインそのものは変えられません。今回紹介するのは、パネルを使わずエージェントの応答だけを取り出して、完全に自作のUIに載せる方法だと考えてください。

出典:Agentforce Conversation Client API

つまり、こうです。パネルの見た目ごと変えたいならACC API、パネル自体を捨てたいなら今回の方法。目的によって使い分けてください。

generateAiAgentResponseでApexからエージェントを呼び出す

ここからが本題です。Salesforceには「カスタムエージェント呼び出し可能アクション」という仕組みがあり、Apexクラスやフローからエージェントを呼び出せます。要するに、Apexから「エージェントさん、これお願い」と頼むための窓口です。このアクションのタイプ名がgenerateAiAgentResponseです。

正直、記事や動画によってはgenerateAIAgentResponseと大文字で書かれていることもありますが、公式ドキュメントのコード例ではgenerateAiAgentResponseと小文字のiで統一されています。実装時にタイプミスすると動かないので、ここは注意してください。

正直、最初にInvocable.Actionのコードを読んだときは「うわ、なんだこの書き方」と身構えました。でも実際に動かしてみると、引数を数個渡すだけのシンプルな話でした。

public class AgentChatController {
    @AuraEnabled
    public static Map<String, Object> sendMessage(String userMessage, String sessionId) {
        Invocable.Action action = Invocable.Action.createCustomAction(
            'generateAiAgentResponse', null, 'TestAgent1', '1.0.0'
        );

        action.setInvocationParameter('userMessage', userMessage);
        if (String.isNotBlank(sessionId)) {
            action.setInvocationParameter('sessionId', sessionId);
        }

        List<Invocable.Action.Result> results = action.invoke();
        Invocable.Action.Result result = results[0];

        Map<String, Object> response = new Map<String, Object>();
        if (result.isSuccess()) {
            response.put('sessionId', result.getOutputParameters().get('sessionId'));
            response.put('agentResponse', result.getOutputParameters().get('agentResponse'));
        } else {
            List<String> errorMessages = new List<String>();
            for (Invocable.Action.Error err : result.getErrors()) {
                errorMessages.add(err.getMessage());
            }
            response.put('error', errorMessages);
        }
        return response;
    }
}

createCustomActionの第3引数には、呼び出したいエージェントのAPI名を渡します。組織内に複数のエージェントがある場合は、ここを差し替えるだけで対象を切り替えられます。バージョンを1.1.0にすると、テキストの代わりに構造化されたオブジェクトで応答が返ってくるので、フロント側でパースする手間を減らせます。

エージェント側のトピックやアクションの設計さえ済んでいれば、呼び出す側のコードはこれだけです。

このアクションを使うには、Enterprise・Performance・Unlimited・Developerのいずれかのエディションが必要です。加えて、エージェントの種類によっては別途アドオンライセンスが必要になります。具体的な価格は公式ヘルプに明記されていなかったので、契約中のSalesforce担当者に確認するのが確実です。

ユーザー権限としては、Manage AI AgentsとManage Agentforce Service Agentsの両方、またはCustomize Applicationのどちらかが必要になります(日本語UIでの正式な権限名は確認できなかったため、英語表記のまま案内します)。

出典:Call an Agent from a Flow or Apex Class

つまり、エディションと権限さえ整えば、あとはコードを書くだけでOKという理解で大丈夫です。

LWC側でチャット画面を組み立てる

Apex側の準備ができたら、あとはLWCで会話履歴を表示するだけです。messagesというプロパティに、送受信のやり取りを配列で積んでいくイメージです。

リソース

js

import { LightningElement, track } from 'lwc';
import sendMessage from '@salesforce/apex/AgentChatController.sendMessage';

export default class CustomAgentChat extends LightningElement {
    @track messages = [];
    sessionId;
    draftText = '';
    isSending = false;
    errorMessage;

    get isSendDisabled() {
        return this.isSending || !this.draftText;
    }

    get hasMessages() {
        return this.messages.length > 0;
    }

    handleInputChange(event) {
        this.draftText = event.target.value;
    }

    handleKeyDown(event) {
        if (event.key === 'Enter' && !event.shiftKey) {
            event.preventDefault();
            this.handleSend();
        }
    }

    async handleSend() {
        const text = this.draftText.trim();
        if (!text || this.isSending) {
            return;
        }

        this.errorMessage = undefined;
        this.addMessage('user', text);
        this.draftText = '';
        this.isSending = true;

        try {
            const result = await sendMessage({
                userMessage: text,
                sessionId: this.sessionId
            });

            if (result.error) {
                this.errorMessage = Array.isArray(result.error)
                    ? result.error.join(' ')
                    : String(result.error);
            } else {
                this.sessionId = result.sessionId;
                this.addMessage('agent', this.extractAgentText(result.agentResponse));
            }
        } catch (error) {
            this.errorMessage = error?.body?.message || 'メッセージの送信に失敗しました。';
        } finally {
            this.isSending = false;
            this.scrollToBottom();
        }
    }

    extractAgentText(agentResponse) {
        if (typeof agentResponse !== 'string') {
            return agentResponse;
        }
        try {
            const parsed = JSON.parse(agentResponse);
            if (parsed && typeof parsed.value === 'string') {
                return parsed.value;
            }
        } catch (e) {
            // agentResponse wasn't JSON; fall through and use the raw string
        }
        return agentResponse;
    }

    addMessage(role, text) {
        this.messages = [
            ...this.messages,
            {
                key: `${role}-${Date.now()}-${this.messages.length}`,
                role,
                text,
                isUser: role === 'user',
                cssClass: role === 'user' ? 'message-bubble message-bubble-user' : 'message-bubble message-bubble-agent'
            }
        ];
        this.scrollToBottom();
    }

    scrollToBottom() {
        // eslint-disable-next-line @lwc/lwc/no-async-operation
        Promise.resolve().then(() => {
            const list = this.template.querySelector('.message-list');
            if (list) {
                list.scrollTop = list.scrollHeight;
            }
        });
    }
}

html

<template>
    <div class="slds-card">
        <div class="slds-card__header slds-grid">
            <header class="slds-media slds-media_center slds-has-flexi-truncate">
                <div class="slds-media__figure">
                    <lightning-icon icon-name="utility:einstein" size="small" alternative-text="Agent"></lightning-icon>
                </div>
                <div class="slds-media__body">
                    <h2 class="slds-card__header-title">
                        <span>アシスタントに質問</span>
                    </h2>
                </div>
            </header>
        </div>

        <div class="slds-card__body slds-card__body_inner">
            <div class="message-list" role="log" aria-live="polite" aria-relevant="additions">
                <template if:false={hasMessages}>
                    <p class="slds-text-body_small slds-text-color_weak empty-state">
                        メッセージを入力して会話を始めてください。
                    </p>
                </template>

                <template for:each={messages} for:item="message">
                    <div key={message.key} class="message-row" data-user={message.isUser}>
                        <div class={message.cssClass}>
                            <p class="slds-text-longform">{message.text}</p>
                        </div>
                    </div>
                </template>
            </div>

            <template if:true={errorMessage}>
                <div class="slds-notify slds-notify_alert slds-theme_error slds-m-top_small" role="alert">
                    <span class="slds-assistive-text">エラー</span>
                    <h2>{errorMessage}</h2>
                </div>
            </template>
        </div>

        <footer class="slds-card__footer footer">
            <div class="slds-grid slds-grid_vertical-align-end slds-gutters_x-small footer-row">
                <div class="slds-col input-col">
                    <lightning-textarea
                        label="メッセージ"
                        variant="label-hidden"
                        placeholder="メッセージを入力..."
                        value={draftText}
                        onchange={handleInputChange}
                        onkeydown={handleKeyDown}
                        disabled={isSending}
                        max-length="4000"
                    ></lightning-textarea>
                </div>
                <div class="slds-col slds-no-flex">
                    <lightning-button
                        variant="brand"
                        label="送信"
                        onclick={handleSend}
                        disabled={isSendDisabled}
                    ></lightning-button>
                </div>
            </div>
            <template if:true={isSending}>
                <lightning-spinner alternative-text="送信中" size="x-small"></lightning-spinner>
            </template>
        </footer>
    </div>
</template>

css

:host {
    display: block;
}

.message-list {
    max-height: 22rem;
    min-height: 8rem;
    overflow-y: auto;
    padding: 0.25rem;
}

.empty-state {
    padding: 1rem 0;
    text-align: center;
}

.message-row {
    display: flex;
    margin-bottom: 0.5rem;
}

.message-row[data-user='true'] {
    justify-content: flex-end;
}

.message-row[data-user='false'] {
    justify-content: flex-start;
}

.message-bubble {
    max-width: 80%;
    padding: 0.5rem 0.75rem;
    border-radius: 0.75rem;
    word-break: break-word;
}

.message-bubble p {
    margin: 0;
}

.message-bubble-user {
    background-color: var(--slds-g-color-brand-base-50, #0176d3);
    color: var(--slds-g-color-neutral-base-100, #ffffff);
    border-bottom-right-radius: 0.125rem;
}

.message-bubble-agent {
    background-color: var(--slds-g-color-neutral-base-95, #f3f3f3);
    color: var(--slds-g-color-neutral-base-10, #181818);
    border-bottom-left-radius: 0.125rem;
}

.footer {
    text-align: left;
}

.footer-row {
    width: 100%;
}

.input-col {
    flex: 1 1 auto;
    min-width: 0;
}

.input-col lightning-textarea {
    display: block;
    width: 100%;
}

meta

<?xml version="1.0" encoding="UTF-8" ?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
    <apiVersion>66.0</apiVersion>
    <isExposed>true</isExposed>
    <masterLabel>Agent Chat</masterLabel>
    <targets>
        <target>lightning__HomePage</target>
        <target>lightning__AppPage</target>
        <target>lightning__RecordPage</target>
    </targets>
</LightningComponentBundle>

ホーム画面においてみた

こんな感じになりました。
LWCで調整すれば独自のUIにできるのでより使いやすくカスタマイズできます。

実装のポイント

実装時に気になったポイントを記載します。

セッションIDを引き継ぐ

一番大事なのは、sessionIdを毎回持ち回ることです。セッションIDというのは、要するに会話の受付番号のようなものです。最初のメッセージを送ると、Salesforce側からこの番号が発行されます。次のメッセージを送るときに、同じ番号を一緒に渡してあげる。それだけで、エージェントは会話の文脈を覚えたまま応答してくれます。

私も最初はここで一度つまずきました。sessionIdを渡し忘れると、毎回まっさらな新規会話として扱われてしまいます。「さっきの続きだよね?」という質問に、エージェントがまったく噛み合わない返事をしてくるんです。実装自体は単純なのに、気づくまでは「なんでエージェントが記憶喪失なんだ」としばらく悩みました。

エージェントからの出力を扱う

もう一つ、地味だけど詰まりやすかったのが「エージェントからの出力の中身」です。

Apexでresult.getOutputParameters().get('agentResponse')から受け取れる値は、実はプレーンな回答テキストではなく、{"type":"Text","value":"実際の回答テキスト"}というJSON文字列でした。これをそのままLWCの画面に表示すると、回答文ではなく生のJSONがそのままチャットバブルに出てしまいます。

初めて動かしたときは「あれ、なんでエージェントの返事がJSONのまま出てるんだ?」となりました。type/valueという構造になっているのは、テキスト以外の応答タイプにも対応できるようにするための設計だと思われますが、少なくとも今回はテキストしか使わないので、LWC側でJSON.parseしてvalueだけを取り出せば十分でした。

extractAgentText(agentResponse) {
    if (typeof agentResponse !== 'string') {
        return agentResponse;
    }
    try {
        const parsed = JSON.parse(agentResponse);
        if (parsed && typeof parsed.value === 'string') {
            return parsed.value;
        }
    } catch (e) {
        // JSONでなければそのまま使う
    }
    return agentResponse;
}

エラー時にも似た罠がありました。アクションが失敗するとresult.getErrors()Invocable.Action.Errorという独自クラスのリストが返ってくるのですが、これをそのままMapに詰めてLWCに渡すと、画面には[object Object]としか表示されません。Apex側でgetMessage()を呼んで文字列に変換してから渡す必要があります。

List<String> errorMessages = new List<String>();
for (Invocable.Action.Error err : result.getErrors()) {
    errorMessages.add(err.getMessage());
}
response.put('error', errorMessages);

まとめると、エージェントとのやり取りは「送るとき」はsessionIdの受け渡しさえ気をつければ良いのですが、「受け取るとき」は出力パラメータの中身が構造化されている(成功時はtype/valueのJSON、失敗時は独自のErrorオブジェクト)ことを意識して、Apex側かLWC側のどちらかで一度パースしてから画面に渡す、という一手間が必要になります。

これで、Lightning Web Component→Apex→エージェント→Apex→Lightning Web Componentという一本道が完成します。あとは吹き出しのスタイリングや、送信中のローディング表示を足していけば、見た目は完全に自社仕様のチャットに仕上がります。

つまり、標準パネルを経由しないカスタムチャットは、Apexの呼び出し部分とLWCの表示部分、この2つを組み合わせるだけで作れるということです。

ここは注意!つまずきポイント

いくつか、実装する前に知っておいたほうがいい制約があります。

まず、Apexのテストクラスからこのアクションを直接テストすることはできません。公式ヘルプにも「カスタムエージェント呼び出し可能アクションではApexテストがサポートされていない」とはっきり書かれています。呼び出し部分をモック化するか、レスポンスを受け取ったあとの処理だけを単体テストの対象にするといった工夫が必要です。

次に、標準のトピックやアクションは会話用に設計されているため、そのままこの仕組みに載せると期待通りに動かないことがあります。公式ドキュメントでも、業務自動化用にカスタムアクションを別途用意することが推奨されています。既存のエージェントをそのまま流用しようとして、うまく噛み合わなかったら、まずここを疑ってください。

エディションとライセンスの制限も見落としがちです。DeveloperエディションでもEnterpriseエディションでも動くこと自体は確認できましたが、エージェントの種類によっては追加ライセンスが別途必要です。実務で使うなら、ライセンス周りは契約前に確認しておいた方が良さそうです。「基本ライセンスに含まれているだろう」と思い込んで進めると、後で契約周りの調整が発生します。

Experience Cloudのサイトユーザーや、Platform Integration User(システムコンテキスト)からはこのアクションを呼び出せません。ポータル経由での利用を考えている場合は、認証済みの内部ユーザー向け機能として設計し直す必要があります。

まとめ

  • ApexからgenerateAiAgentResponseアクションを呼び出せば、Agent Panelを使わずに完全オリジナルのチャットUIを作れる
  • 正式名称は小文字の「i」を使ったgenerateAiAgentResponseで、大文字表記の記事もあるので注意する
  • 会話の文脈を維持するには、sessionIdを毎回受け渡すことが必須
  • Apexのユニットテストが直接サポートされていない、追加ライセンスが必要な場合があるなど、実装前に押さえておくべき制約がいくつかある

標準パネルでは物足りないと感じたときの選択肢として、ぜひ覚えておいてください。一緒に少しずつ慣れていきましょう!


noteもやっています。

note


0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?