1
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?

LWC + n8nでSalesforceのAIアシスタントを進化させる——wireデコレータでのコンテキスト連携

1
Posted at

はじめに

前回、LWC + DifyでSalesforceにAIアシスタントを追加する方法という記事で、ユーティリティバーで動作するAIアシスタントを作りました。

あの構成はシンプルで検証には最適でしたが、前回の展望でも言及したように、レコード情報をコンテキストとして渡したり、チャットUIからタスクを依頼できるようにしたいと考えていました。また、最近n8nをVPS上でセルフホストして色々とワークフローを作っていて、Difyよりも柔軟性やエンプラで運用するときに便利な点が多いなと感じました。

そこで今回は、前回の構成について、バックエンドをDifyからn8nに変更し、LWCの@wireデコレータでレコード情報を取得してn8n側に渡す構成へ改善しました。これにより、n8n側からSalesforceのレコード取得・操作も可能になっています。

Dify版からの変更点まとめ

まず、前回の構成と今回の構成を比較します。

前回(Dify版)

[ユーザー] → [LWC(チャットUI)] → [Apex] → [Dify API]
  • できたこと: 汎用的な質問応答、ナレッジベースの参照
  • できなかったこと: 今開いているレコードの文脈を踏まえた回答、チャットからのレコード操作
  • 課題: Difyはチャットフロー向きで、複雑なワークフローや外部システム連携には限界がある

今回(n8n版)

[ユーザー] → [LWC] → [Apex] → [n8n Webhook]
                                         ↓
                                  [n8n ワークフロー]
                                   ┌─────┴─────┐
                         [Salesforceノード]   [RAG]
                                                       
  • できるようになったこと:
    • 今開いているレコードの情報やSalesforceユーザーの情報をn8nに自動で渡す
    • チャットからSalesforceのレコードを取得・更新
    • RAGを使用して社内情報をAIの回答に含める
    • 条件分岐やエラーハンドリングを含む複雑なワークフロー

これらを使うことで、未検証ではありますが、例えばこんなことが実現できそうです。

  • 商談レコードを開いた状態で「この商談の次のアクション提案して」→ フェーズ・金額・活動履歴を踏まえたネクストステップ提案
  • 「この顧客の過去の問い合わせ傾向まとめて」→ 取引先IDからケース履歴を引き、RAGで社内ナレッジも加味した要約を返す
  • 「この商談にフォローアップタスクを作って、期日は来週金曜」→ チャットから直接タスクレコードを作成
  • CS担当が顧客レコードを見ながら「解約リスクある?」→ 活動頻度・NPS・利用状況を横断集計して判定
  • 「このチームの今月のパイプライン状況まとめて」→ ユーザーの部門情報から所属チームを特定し、商談を集計
  • ユーザーの役職・プロファイルに応じてRAGの参照先を切り替え(例:マネージャーには戦略ドキュメント、現場にはオペレーションマニュアル)

なぜn8nか

Difyは「チャットフローをノーコードで素早く作る」には最適ですが、以下の点でn8nに軍配が上がりました。

観点 Dify n8n
チャットフロー構築 ◎ GUI で直感的
○ Webhook + ノードで構築
外部API連携 △ HTTP Requestノードはあるが制約あり ◎ 豊富なインテグレーション
Salesforceコネクタ △ 限定的 ◎ ネイティブ対応
条件分岐・ループ △ 基本的なもの ◎ 柔軟に構築可能
レコード操作(CRUD) △ 限定的 ◎ Salesforceノードで直接操作

これまではチャットフローであればDifyの方が構築は楽だと考えていたのですが、直近のアップデートでチャットフローが作りやすくなったため、n8nがいいなと感じています。

また、以下のイベントに参加をし、各社の事例を伺う中で、開発と本番で環境を分けられる点やプロジェクトという単位でワークフローを管理できる点などからエンタープライズで運用するのであれば、Difyよりもn8nが主流になるだろうなと感じたため、n8nを採用しました。

全体像

今回の構成は大きく3つのレイヤーに分かれます。

1. Salesforce層(フロントエンド)

LWCがチャットUIを提供しつつ、@wireデコレータで現在表示中のレコード情報を取得します。ユーザーの入力メッセージと一緒に、レコードのコンテキスト(オブジェクト種別、レコードID、主要項目の値など)をApex経由でn8nに送信します。

2. n8nワークフロー層(オーケストレーション)

n8nがリクエストを受け取り、以下のような処理を振り分けます。

  • 質問応答: エージェントノードでRAGを呼び出し、関連情報を取得してLLMに渡す
  • レコード操作: Salesforceノード経由でレコードの取得・作成・更新を実行
  • コンテキスト補完: 渡されたレコードIDを元に、レコード情報を取得

3. RAG

簡易的な検証のため、Gemini File Search Toolを使用した簡易的なRAGシステムをn8nで構築しました。

image.png

image.png

HTTPリクエストノードでFile Search ToolのAPIを叩いて、関連情報を取得し、エージェントノードで回答を成形し、セッション管理はSimple Memoryで行っています。

作成にあたって以下の記事を参考にさせていただきました。

検証して気づきましたが、この構成だとレイテンシが高いため、あくまで検証用での構成になります。

レコードコンテキスト連携

今回は、LWCの@wireデコレータを使ってレコード情報をAIに渡す部分を中心に検証を行いました。

前回のDify版では、Salesforceのレコード情報などはDifyに引き継がれないため、SalesforceでAIアシスタントを使ううまみが正直薄かったと思います。

今回は、LWCが配置されたレコードページのコンテキストを自動で取得し、バックエンドに渡します。これにより、ユーザーは自然な言葉で質問するだけで、AIが「今見ているレコード」の情報も加味して回答ができます。

※今回も筆者はClaudeの支援を受けて検証をしています。

import { LightningElement, track, wire } from 'lwc';
import { CurrentPageReference } from 'lightning/navigation';
import sendMessageToN8n from '@salesforce/apex/SalesforceAIChatbot.BotController';
import CHATBOT_ICON_URL from '@salesforce/resourceUrl/ChatBotIcon';

export default class ChatBot extends LightningElement {
    botIconUrl = CHATBOT_ICON_URL;

    @track messages = [
        {
            id: '1',
            text: 'こんにちは!何かお困りですか?',
            cssClass: 'message bot',
            type: 'bot',
            isUser: false,
            iconUrl: this.botIconUrl
        }
    ];

    userInput = '';
    isLoading = false;
    conversationId = '';
    maxInputLength = 2000;

    // @wire(CurrentPageReference) でユーティリティバーから現在開いているページのコンテキストを受け取る
    recordId;
    objectApiName;

    @wire(CurrentPageReference)
    handlePageRef(pageRef) {
        this.recordId = pageRef?.attributes?.recordId ?? null;
        this.objectApiName = pageRef?.attributes?.objectApiName ?? null;
    }

    // ユーザーコンテキスト(Title, Profile等)はApex側のbuildUserContext()で取得

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

    async handleSendMessage() {
        if (!this.userInput) return;

        if (this.userInput.length > this.maxInputLength) {
            this.addMessage(`メッセージは${this.maxInputLength}文字以内で入力してください。`, 'system');
            return;
        }
        // 1. ユーザーのメッセージを表示
        const userMsg = this.userInput;
        this.addMessage(userMsg, 'user');
        this.userInput = '';
        this.isLoading = true;

        try {
            // 2. 初回メッセージ時にクライアント側で会話IDを生成
            if (!this.conversationId) {
                this.conversationId = crypto.randomUUID();
            }

            const response = await sendMessageToN8n({
                query: userMsg,
                conversationId: this.conversationId,
                recordId: this.recordId || '',
                objectApiName: this.objectApiName || ''
            });

            const result = JSON.parse(response);

            // 4. AIの回答を表示
            this.conversationId = result.conversation_id;
            this.addMessage(result.answer, 'bot');

        } catch (error) {
            const errorMsg = error?.body?.message || 'システムエラーが発生しました。';
            this.addMessage('エラーが発生しました: ' + errorMsg, 'system');
        } finally {
            this.isLoading = false;
        }
    }

    handleKeyDown(event) {
        if (event.key === 'Enter' && !this.isLoading) {
            this.handleSendMessage();
        }
    }

    formatMarkdown(text) {
        // 1. HTMLエスケープを先に行う
        let html = this.escapeHtml(text);

        // 2. その後にMarkdown変換
        html = html
            .replace(/^### (.+)$/gm, '<h4>$1</h4>')
            .replace(/^## (.+)$/gm, '<h3>$1</h3>')
            .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
            .replace(/^---$/gm, '<hr>')
            .replace(/\n/g, '<br>');

        return html;
    }

    escapeHtml(text) {
        const escapeMap = {
            '&': '&amp;',
            '<': '&lt;',
            '>': '&gt;',
            '"': '&quot;',
            "'": '&#039;'
        };
        return text.replace(/[&<>"']/g, char => escapeMap[char]);
    }

    addMessage(text, type) {
        const isUser = type === 'user';
        const cssClass = isUser ? 'message user' : (type === 'bot' ? 'message bot' : 'message system');
        const formattedText = isUser ? text : this.formatMarkdown(text);
        const messageData = {
            id: Date.now(),
            text: formattedText,
            cssClass,
            isUser,
            type,
            iconUrl: isUser ? null : this.botIconUrl
        };

        this.messages = [...this.messages, messageData];
    }
}

Apex経由でn8nへ送信

レコードコンテキストをユーザーの質問メッセージと一緒にApexに渡し、n8nのWebhookエンドポイントにPOSTします。

public with sharing class SalesforceAIChatbot {

    // n8n Webhook用の指定ログイン情報(Named Credential)
    private static final String NAMED_CREDENTIAL = 'callout:n8n_Webhook';
    private static final Integer MAX_QUERY_LENGTH = 2000;

    /**
     * LWCから呼び出されるメインメソッド
     * @param query          ユーザーが入力したメッセージ
     * @param conversationId 会話の継続用ID(初回は空文字)
     * @param recordId       現在開いているレコードのID(UtilityBarから取得、なければ空文字)
     * @param objectApiName  レコードのオブジェクトAPI名(なければ空文字)
     * @return n8n Webhookからのレスポンス(JSON文字列)
     */
    @AuraEnabled
    public static String BotController(
        String query,
        String conversationId,
        String recordId,
        String objectApiName
    ) {

        if (String.isBlank(query)) {
            throw new AuraHandledException('メッセージを入力してください。');
        }

        if (query.length() > MAX_QUERY_LENGTH) {
            throw new AuraHandledException('メッセージが長すぎます。' + MAX_QUERY_LENGTH + '文字以内で入力してください。');
        }

        if (String.isNotBlank(recordId) && !isValidSalesforceId(recordId)) {
            throw new AuraHandledException('無効なレコードIDです。');
        }

        if (String.isNotBlank(objectApiName) && !isValidApiName(objectApiName)) {
            throw new AuraHandledException('無効なオブジェクト名です。');
        }

        if (String.isNotBlank(recordId)) {
            verifyRecordAccess(recordId);
        }

        Http http = new Http();
        HttpRequest req = new HttpRequest();
        req.setEndpoint(NAMED_CREDENTIAL);
        req.setMethod('POST');
        req.setHeader('Content-Type', 'application/json');
        req.setTimeout(120000);

        // n8n Webhook用のリクエストボディ
        Map<String, Object> body = new Map<String, Object>();
        body.put('query', query);
        body.put('user', UserInfo.getUserId());

        if (String.isNotBlank(conversationId)) {
            if (!isValidConversationId(conversationId)) {
                throw new AuraHandledException('無効なセッションですページを更新してください。');
            }
            body.put('conversation_id', conversationId);
        }

        // レコードコンテキスト(現在開いているページのレコード情報)
        if (String.isNotBlank(recordId)) {
            body.put('record_id', recordId);
        }
        if (String.isNotBlank(objectApiName)) {
            body.put('object_api_name', objectApiName);
        }

        body.put('user_context', buildUserContext());

        req.setBody(JSON.serialize(body));

        try {
            HttpResponse res = http.send(req);

            if (res.getStatusCode() == 200) {
                return res.getBody();
            }

            throw new CalloutException('Status: ' + res.getStatusCode());

        } catch (CalloutException e) {
            System.debug(LoggingLevel.ERROR, 'n8n Webhook Error: ' + e.getMessage());
            throw new AuraHandledException('チャットボットとの通信に失敗しました。');
        } catch (Exception e) {
            System.debug(LoggingLevel.ERROR, 'Unexpected error: ' + e.getMessage());
            throw new AuraHandledException('予期しないエラーが発生しました。');
        }
    }

    private static Map<String, Object> buildUserContext() {
        Map<String, Object> ctx = new Map<String, Object>();

        try {
            User currentUser = [
                SELECT Title, Profile.Name, UserRole.Name, Department, Email
                FROM User
                WHERE Id = :UserInfo.getUserId()
                LIMIT 1
            ];

            ctx.put('userId',      UserInfo.getUserId());
            ctx.put('userName',    UserInfo.getName());
            ctx.put('title',       currentUser.Title);
            ctx.put('profileName', currentUser.Profile.Name);
            ctx.put('roleName',    currentUser.UserRole != null ? currentUser.UserRole.Name : null);
            ctx.put('department',  currentUser.Department);
            ctx.put('email',       currentUser.Email);
        } catch (Exception e) {
            System.debug(LoggingLevel.WARN, 'ユーザーコンテキストの取得に失敗: ' + e.getMessage());
            // 最低限の情報はUserInfoから取得可能
            ctx.put('userId',   UserInfo.getUserId());
            ctx.put('userName', UserInfo.getName());
        }

        return ctx;
    }

    private static void verifyRecordAccess(String recordId) {
        try {
            List<UserRecordAccess> accessList = [
                SELECT HasReadAccess
                FROM UserRecordAccess
                WHERE UserId = :UserInfo.getUserId()
                  AND RecordId = :recordId
                LIMIT 1
            ];

            if (accessList.isEmpty() || !accessList[0].HasReadAccess) {
                throw new AuraHandledException('このレコードへのアクセス権がありません。');
            }
        } catch (AuraHandledException e) {
            throw e;
        } catch (Exception e) {
            System.debug(LoggingLevel.ERROR, 'レコードアクセス権の検証に失敗: ' + e.getMessage());
            throw new AuraHandledException('レコードのアクセス権を確認できませんでした。');
        }
    }

    private static Boolean isValidConversationId(String conversationId) {
        return Pattern.matches('^[a-zA-Z0-9_-]+$', conversationId);
    }

    // Salesforce ID: 15桁または18桁の英数字
    private static Boolean isValidSalesforceId(String sfId) {
        return Pattern.matches('^[a-zA-Z0-9]{15}$|^[a-zA-Z0-9]{18}$', sfId);
    }

    // オブジェクトAPI名: 英数字・アンダースコアのみ(カスタムオブジェクトの __c も許容)
    private static Boolean isValidApiName(String apiName) {
        return Pattern.matches('^[a-zA-Z][a-zA-Z0-9_]*(__c|__mdt|__e)?$', apiName);
    }
}

コンポーネントの配置

前回と同様にユーティリティバーに配置する場合、ホーム画面やレコード一覧ページでユーティリティーバーを呼び出しても@api recordIdは自動注入されません。ユーティリティバーはレコードページの外にあるため、ユーティリティーバーではなく、レコード詳細ページに配置したほうが、使い勝手がいいなと感じました。

n8nワークフローの構成

n8nのWebhookでリクエストを受け取った後、RAGを参照して回答を生成し、チャットに返す、サブフローを呼び出してタスクを実行するなど、柔軟に構築ができます。

質問応答フロー

  1. Webhookノード: LWCからのリクエストを受信。queryrecord_contextconversation_idをパース
  2. HTTP Requestノード: Gemini File Search Toolを呼び出して関連情報を検索
  3. Codeノード: レコードコンテキストとRAG結果をLLMプロンプトに組み立て
  4. エージェントノード: 回答を生成
  5. Respond to Webhookノード: 生成された回答をSalesforceに返却

Salesforceノードは65種類もあるので、色々なユースケースで使えそうです。Salesforceのユーザー情報をn8nに渡しているので、取得したオブジェクトに応じて処理を分けたり、ユーザーの所属部門や役職に応じたRAGにルーティングしたり、Salesforceのフローを呼び出したりなどなど。

image.png

今後の展望

今回は前回作成したDifyでのチャットAIアシスタントをn8nで利用できるように修正をし、SalesforceのレコードコンテキストをLWC経由でn8nに渡し、チャットUI上でレコードコンテキストを使用した業務自動化の検証を行いました。n8n側のワークフロー次第で色々なユースケースに使えそうです。RAGについては、お手軽なGemini File Search Toolを使用しましたが、今回の構成だと応答速度やワークフロー内での応用に難があるため、本格運用するならば、LayerX様の事例を拝見して以下の構成がよいと考えています。S3 VectorsとBedrock KnowledgeBasesはAWSの基本的な知識があれば設定がしやすく、AWS Lambdaでベクトル化や検索&生成部分を構築すれば手軽に検証を始められるためです。

image.png

S3 vectorsの検証は以下の記事を参照して行いましたが、設定自体は本当にものの5分ででき、感動しました。

今後は上記の構成の検証含め、より高度なAIワークフローの検証を行っていきます。

1
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
1
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?