
【第607回】Personalization:レコメンドカードを拡張チャット v2 で表示する
前回の記事では、TrailNest の商品詳細ページに、Web Personalization Manager(WPM)を使っておすすめ商品を表示しました。
今回は、それらの商品を Agentforce の会話にも表示する構成へ進みます。
TrailNest の Web サイトにある 「Find my next adventure」 ボタンをクリックすると、会話が開始され、おすすめ商品を画像・商品名・価格・リンク付きのカードで紹介します。その後は、通常通り、AI エージェントと会話を開始できます。
商品を選ぶ設定には、前回作成した Personalization Point を使用します。
Affinity がある場合:最も興味の強いカテゴリの人気商品
Affinity がない場合:全カテゴリの人気商品
表示件数:最大 4 件
今回は、Web サイトとチャットで、同じ Personalization の設定を利用することが目的になっています。

Personalization 基礎シリーズ
Sitemap Builder シリーズ
Data 360 設定シリーズ
Personalization 設定シリーズ
今回の仕組み
おすすめ商品を取得する処理と、その商品を表示する処理は、次の役割に分かれます。
Personalization Point:顧客の Affinity を評価し、おすすめ商品を返す
Apex:Agentforce から Point を呼び出し、返された商品を扱いやすい形にする
Custom Lightning Type:Apex の出力と、商品カードのレンダラーを結び付ける
LWC のレンダラー:チャット内に商品カードを表示する
Agent Script:どの依頼で Apex を使うか、どの ID を渡すか、どの形式で結果を表示するかを定義する
CloudPages の JavaScript:ブラウザの ID をチャットへ渡し、スターターボタンから会話を開始する
ここで使用するレンダラーは Lightning Web Component(LWC)です。チャットに返す構造化データを、専用の商品カードとして表示します。

WPM の Experience Template と、Agentforce の商品カードは、それぞれの表示先に合わせて用意します。共通なのは、商品を選ぶ Personalization Point です。
① 使用する Point とエージェントを確認する
今回使用する Personalization Point は、前回の WPM 用に作成したものです。
Personalization Point API Name:TrailNest_WPM_Category_Popular_Recommendations
Personalization Type:Recommendations
Maximum Number of Recommendations to Return:4
Authentication Required:未チェック

この Point には、Affinity がある顧客向けの Decision と、Affinity がない場合に全カテゴリの人気商品を返す Always の Decision を設定しています。
Point 自身が Profile Data Graph の Affinity を参照するため、今回は Top_Category を別の Point から取得して渡す処理は不要です。
商品の並び順も、前回の設定を使用します。過去2週間の商品閲覧数を集計した Calculated Insight による降順です。(※ 閲覧数が多い順)
次に、商品案内用の Agentforce Service Agent を用意します。既存のサービスエージェントに追加しても構いません。
今回は Enhanced Web Chat v2(拡張チャット v2)で表示します。

これは以前に別の記事で紹介した、拡張チャット v1 のアダプティブ応答形式によるカルーセルとは構成が異なります。
② Apex と商品カードのレンダラーを反映する
今回は、次の実装をサンプルファイルにまとめています。
TrailNestChatAction:Agentforce から呼び出すアクション
TrailNestChatService:Personalization Point から商品を取得する処理
TrailNestChatRequest / Response / Result / Product:入力と出力の商品データを定義するクラス
trailNestChatCards:商品カードの LWC
trailNestChatRecommendations:出力とレンダラーを結び付ける Custom Lightning Type
TrailNest Chat Access:Apex の利用権限をまとめた権限セット

ここでは、これらを画面で1つずつ新規作成するのではなく、サンプルの force-app フォルダをデプロイします。
このような ZIP ファイルを生成 AI に作らせることがコツです。これを参考として添付して、さらにこの記事を添付すれば、同じような方法で作成可能かと思います。実はこのような ZIP のようなサンプルを生成 AI に食わせて学習させることが大事だったりします。
Sandbox に同じ Data 360 構成がなくても保存できる
このコードの開発・保存と、HTTP モックを使った Apex テストには、Sandbox に本番と同じ商品データや Data Graph を用意する必要はありません。
Sandbox でコードを保存・テストしてから本番へ移し、本番の Data 360 に接続して実際の商品を確認できます。
ZIP を以下の赤枠の辺りに配置します。

ZIP を展開し、sfdx-project.json があるフォルダでターミナルを開きます。Salesforce CLI を使用する場合は、次のコマンドを一気に実行します。
unzip -n "TrailNest_Agentforce_Recommendations_v2.zip" -d "trailnest-chat-deploy" &&
cd "trailnest-chat-deploy/trailnest-agentforce-v2" &&
sf project deploy start --source-dir force-app --target-org admin@nac-care.com.mysandbox --test-level RunSpecifiedTests --tests TrailNestChatTest --wait 30
NAC-Sandbox は CLI で使用する接続先の別名です。ご自身の環境に合わせて変更できます。
成功しました。失敗した場合は、都度生成 AI に内容を修正させてください。また上書き展開するコマンドも生成してもらってください。

続いて、本番へは次の順に進めます。
① 本番にログインする
Vibes から本番へ未接続の場合だけ実行します。
sf org login web --instance-url https://nac-plus.my.salesforce.com --alias NAC-Productionadmin@nac-care.com でログインしてください。ログインの際検証コードの入力をする必要がありますが、これはコマンド上に書かれています。

成功すると、Authentication Successful が表示されます。

② 本番へのデプロイを検証する
今の sfdx-project.json があるフォルダで実行します。Alias に依存しないよう、ユーザー名を直接指定します。
sf project deploy validate --source-dir force-app --target-org admin@nac-care.com --test-level RunSpecifiedTests --tests TrailNestChatTest --wait 30まだ本番には反映されません。 Succeeded になったら、その検証の Job ID を控えます。

③ 本番へ反映する
YOUR_VALIDATION_JOB_ID を、②で成功した検証の Job ID に置き換えます。
sf project deploy quick --job-id YOUR_VALIDATION_JOB_ID --target-org admin@nac-care.com --wait 30
これが Succeeded になれば、本番へのコード反映は完了です。今後、何らか編集がある度に、Sandbox で実行してこの本番への反映を行う必要があります。
③ Personalization の接続先を登録する
Apex から Data 360 の Personalization API を呼び出すため、External Credential と Named Credential を作成します。
1. External Credential を作成する
設定から Named Credentials を開き、External Credentials タブで新規作成します。
Label:TrailNest Personalization External
Name:TrailNest_Personalization_External
Authentication Protocol:No Authentication

保存したら、Principals で新規作成します。
Parameter Name:TrailNest
Sequence Number:1
Identity Type:Named Principal

💡 今回は、Personalization Point の Authentication Required が未チェックの構成に合わせて No Authentication を使用します。
2. Named Credential を作成する
Named Credentials タブに戻り、次の内容で新規作成します。
Label:TrailNest Personalization
Name:TrailNest_Personalization
URL:https://YOUR_DATA_360_TENANT.c360a.salesforce.com
External Credential:TrailNest Personalization External

YOUR_DATA_360_TENANT は、ご自身の Data 360 テナントの値へ置き換えます。(YOUR_DATA_360_TENANT は Data Cloud Setup Home にあります。)URL の末尾には、/personalization/decisions を付けません。

④ エージェントユーザーへ権限を付与する
ZIP のデプロイによって作成された TrailNest Chat Access 権限セットを開き、External Credential Principal Access → Edit で、先ほど作成した Principal を有効にします。

保存したら、この権限セットを次のユーザーに割り当てます。
現在作業をしているシステム管理者のユーザー
サービスエージェントに紐づいているユーザー
※ ユーザーを「Eintein」で検索してください。

今回は Apex Class もデプロイされ使用されますが、そのアクセス権は、この権限セットにすでに含まれています。そのため、Apex クラスのアクセス権を1つずつ追加する作業は不要です。
権限セットが存在するだけでは、エージェントに権限は付与されません。ユーザーへの割り当てまで行ってください。
⑥ Agent Script に商品案内の処理を追加する
ここでは、対象のサービスエージェントを Agentforce Builder で開き、Script の編集画面から設定します。
変数・アクション・サブエージェントを GUI でそれぞれ新規作成する作業は不要です。以下を Agent Script に定義します。
ただし、次のコードは既存 Script に追加するためのものです。エージェント固有の設定を含む Script 全体の置き換えではありません。既存の config、言語、チャネルの接続設定などは保持してください。
1.ブラウザの ID を受け取る変数を定義する
既存の variables に、次を追加します。
※ Agent Script は、インデント(段落)も重要 ですので維持してください。
variables:
TrailNestIndividualId: string = ""
description: "Browser SDK Individual ID supplied by the TrailNest website. Never collect or infer it from a customer message."
visibility: "External"既に variables: がある場合は、その中に TrailNestIndividualId の定義だけを追加します。variables: が2つ(二重に)作らないようにしてください。

この設定の意味は、次のとおりです。
TrailNestIndividualId:ID を入れておく変数の名前
string:文字列を入れる
= "":最初は空欄
visibility: "External":Web サイト側の API から値を設定できるようにする
今回は mutable を付けていません。ID を会話内容からエージェントに変更させるのではなく、サイト側から渡す値を使用するためです。
External は、ID をチャットの文章として顧客に表示する設定ではありません。外部 API から変数に値を入れられるようにする設定です。
2.商品案内のサブエージェントとアクションを追加する
以下を、既存 Script のトップレベル(サブエージェントとして)に追加します。別のサブエージェントの中へ入れないでください。
subagent TrailNestGearGuide:
label: "TrailNest Gear Guide"
description: "Handles TrailNest gear recommendations and questions about products returned by the recommendation action."
reasoning:
instructions: ->
| When asked to recommend outdoor gear, call {!@actions.GetTrailNestChatRecommendations} before answering.
| Use only TrailNestIndividualId from website context. Never ask for, guess, or overwrite this ID based on conversation text.
| Display the returned product cards in their original order. Do not regenerate, reorder, or replace the cards with a Markdown product list.
| Respond warmly and briefly in English. You are TrailNest's outdoor gear guide.
| Do not claim that the visitor's history was used: this Point can return general popular products for a visitor without affinity data.
| If status is EMPTY, ERROR, or IDENTITY_REQUIRED, explain the returned message. Never fabricate fallback products, prices or links.
| For follow-up questions, use only the returned product names, categories, prices and descriptions. If a detail is absent, say that it is not available here and invite the customer to open the product page.
| You cannot change a cart, place an order, or apply a discount. Do not claim to have done so.
| Asking for a different category in this conversation does not change the browser's affinity. Do not claim you filtered for it unless a separate filtering action is implemented.
actions:
GetTrailNestChatRecommendations: @actions.GetTrailNestChatRecommendations
with individualId = @variables.TrailNestIndividualId
actions:
GetTrailNestChatRecommendations:
label: "Get TrailNest Chat Recommendations"
description: "Retrieve up to four current TrailNest recommendation products for the browser's profile."
target: "apex://TrailNestChatAction"
require_user_confirmation: False
include_in_progress_indicator: True
progress_indicator_message: "Finding gear for your next adventure"
inputs:
individualId: string
label: "Browser Individual ID"
description: "Bound to the website-supplied TrailNestIndividualId variable. Never supplied by the customer."
is_required: False
is_user_input: False
outputs:
recommendations: object
label: "TrailNest product cards"
description: "Real products and status. Render the custom cards in the returned order."
complex_data_type_name: "c__trailNestChatRecommendations"
is_displayable: True
filter_from_agent: False
重要なのは、次の2つの設定です。
with individualId = @variables.TrailNestIndividualId
これは、Web サイトから渡された ID を、Apex の入力にそのまま使用する設定です。顧客に ID を聞いたり、会話内容から ID を推測したりしません。
complex_data_type_name: "c__trailNestChatRecommendations"
is_displayable: True
filter_from_agent: False
こちらは、Apex の結果を商品カードとして顧客に表示し、返された商品情報をエージェントの会話でも参照する設定です。
設定は都度「保存」してください。
3.商品推薦の依頼を、このサブエージェントへ渡す
既存の start_agent のルーターを探します。
その reasoning.actions: に、次を追加します。
go_to_TrailNestGearGuide: @utils.transition to @subagent.TrailNestGearGuide
そのルーターの指示にも、次を加えます。
| Route requests for TrailNest product recommendations or outdoor gear suggestions to the TrailNestGearGuide subagent.
4.最初のメッセージを設定して保存する
既存の system.messages.welcome に、例えば次の文章を設定します。
welcome: "Hi, I'm your TrailNest gear guide. Let's find a few picks for your next adventure."
スタータープロンプトの自動送信は、Agentforce が最初のメッセージを送るイベントを待ってから行います。そのため、今回の構成では Welcome を出すようにします。
⑦ Enhanced Web Chat v2 でプレビューする
すべての Agent Script が保存できたら、一度コミットして、有効化してください。その後、プレビューで検証します。プレビューでは「Enhanced Chat v2」を選択してください。

まずは、Recommend outdoor gear for my next adventure. と入力します。これが後のスターターボタンにより送信される文章です。

但し、これではサブエージェント自体は動きましたが、ブラウザ ID が存在していないため、正しい回答ができていません。
I am unable to recommend outdoor gear right now because your browser context is not available. Please refresh TrailNest and try again. Is there anything else you'd like help with for your adventure?
現在、ブラウザのコンテキスト情報を取得できないため、アウトドア用品をおすすめすることができません。TrailNestのページを再読み込みしてから、もう一度お試しください。冒険に関して、他に何かお手伝いできることはありますか?

そこでブラウザ ID をテスト用に入力します。

画像の右上にある Context Variables タブを開きます。
TrailNestIndividualId に ID として「12345」のようなダミーの ID を入力します。これは初めて Web を訪れた人を想定しています。
Apply and Restart Session をクリックします。
プレビュー右上の リセットアイコンで会話をリセットして再開始します。このように変数を変更した場合は、再開始が必要です。
再び、Recommend outdoor gear for my next adventure. を送ります。
すると、カルーセルが表示されました。

画像が入っていないのは、画像のドメイン(image.s12.sfmc-content.com)が「信頼済みサイト」に登録されていないためです。登録します。

これで再度実行すると、画像も表示されました。

それでも画像が表示されない場合は、F5 でページを更新してから、再度試してください。現在のページに信頼済み URL が届いていないだけです。
⑧ CloudPages にスターターボタンを追加
続いて、ZIP ファイルをローカルで開いて、Cloudpages フォルダの中にある次のファイルを開きます。
cloudpages/TrailNest_Chat_CloudPages_Insert.html
この HTML に、開始ボタンの HTML・CSS、チャットの初期化、ID の引き渡し、スタータープロンプトの送信処理がまとまっています。
この辺りは以下の記事の技術を使用しています。
環境固有の値を置き換える
ファイル内の次の設定を、チャネル設定で取得できる Code Snippet の値に変更します。
const cfg = {
orgId: 'YOUR_ORG_ID',
deploymentName: 'YOUR_DEPLOYMENT_NAME',
siteUrl: 'YOUR_ESW_URL',
scrt2Url: 'YOUR_SCRT2_URL',
bootstrapUrl: 'YOUR_BOOTSTRAP_URL'
};↓ ↓ ↓ 以下のように変換する
const cfg = {
orgId: '00DgC0000024nxA',
deploymentName: 'TrailNest_Assistant',
siteUrl: 'https://nac-plus.my.site.com/ESWTrailNestAssistant1791278416851',
scrt2Url: 'https://nac-plus.my.salesforce-scrt.com',
bootstrapUrl: 'https://nac-plus.my.site.com/ESWTrailNestAssistant1791278416851/assets/js/bootstrap.min.js'
};
既存 HTML へ追加する
変更したファイルの内容を、CloudPages の </body> の直前へ貼り付けます。保存して公開してください。
<!--
TrailNest Assistant — insert this entire file ONCE immediately before </body>.
Keep the Data 360 SDK and existing Sitemap initialization as they are.
Replace the five YOUR_ values in cfg with the current Enhanced Web Chat v2
Code Snippet values. Do not add the original Messaging snippet a second time.
Home: show the starter immediately; early clicks wait for the chat SDK.
Other pages: use the standard Salesforce chat button.
Starter X: dismiss for this tab's visit; keep the standard chat button available.
Opened conversation: suppress the starter across page navigation/reloads until
a ConversationClosed event or a same-page anonymous client reset occurs.
Only display flags are stored; this code does not store browser/customer IDs.
-->
<style>
/* The starter takes priority over the minimized Salesforce launcher, never over an open chat. */
html[data-tn-starter-visible] #embeddedMessagingFrame:not(.maximized):not(.inline),
html[data-tn-starter-visible] #embeddedMessagingMinimizedNotification {
visibility: hidden !important;
pointer-events: none !important;
}
#tn-gear-guide[hidden]{display:none!important}
#tn-gear-guide{position:fixed;right:24px;bottom:calc(24px + env(safe-area-inset-bottom,0px));z-index:999;width:min(340px,calc(100vw - 32px));max-height:calc(100dvh - 48px);overflow-y:auto;padding:23px;border:1px solid #dbe4d6;border-radius:22px;background:#fbfcf6;color:#183426;box-shadow:0 12px 48px #18342626;font-family:Arial,Helvetica,sans-serif;box-sizing:border-box}
#tn-gear-guide *{box-sizing:border-box}
#tn-gear-guide .tn-guide-top{display:flex;align-items:center;gap:11px;margin:0 35px 18px 0}
#tn-gear-guide .tn-guide-symbol{display:flex;align-items:center;justify-content:center;width:40px;height:40px;flex-shrink:0;border-radius:12px;background:#234b35;color:#e8f0df;font-size:23px}
#tn-gear-guide .tn-guide-brand{margin:0 0 4px;font-size:11px;font-weight:700;letter-spacing:2px}
#tn-gear-guide .tn-guide-label{margin:0;color:#687a67;font-size:11px}
#tn-gear-guide h2{margin:0 0 10px;color:#183426;font-size:27px;line-height:1.1;letter-spacing:-.8px}
#tn-gear-guide .tn-guide-description{margin:0 0 19px;color:#64745f;font-size:13px;line-height:1.65}
#tn-gear-guide .tn-guide-start{display:flex;justify-content:space-between;align-items:center;gap:14px;width:100%;min-height:48px;padding:14px 16px;border:0;border-radius:12px;background:#234b35;color:#fff;font:700 13px Arial,Helvetica,sans-serif;cursor:pointer;box-shadow:0 4px 12px #234b351a}
#tn-gear-guide .tn-guide-start:hover{background:#183b27}
#tn-gear-guide .tn-guide-start:disabled{opacity:.65;cursor:wait}
#tn-gear-guide .tn-guide-close{position:absolute;top:10px;right:10px;display:flex;align-items:center;justify-content:center;width:44px;height:44px;padding:0;border:0;border-radius:50%;background:transparent;color:#536b56;font:400 25px Arial,Helvetica,sans-serif;cursor:pointer}
#tn-gear-guide .tn-guide-close:hover{background:#e8eee2;color:#183426}
#tn-gear-guide button:focus-visible{outline:3px solid #b78945;outline-offset:3px}
#tn-gear-guide .tn-guide-note{margin:13px 0 0;text-align:center;font-size:10px;letter-spacing:.5px;color:#70836b}
#tn-gear-guide .tn-guide-status{margin:10px 0 0;font-size:12px;line-height:1.5;color:#6a532f}
#tn-gear-guide .tn-guide-status:empty{display:none}
@media(max-width:480px){#tn-gear-guide{right:16px;bottom:calc(16px + env(safe-area-inset-bottom,0px));padding:20px;width:calc(100vw - 32px)}}
</style>
<aside id="tn-gear-guide" hidden aria-label="TrailNest Assistant">
<button id="tn-gear-close" class="tn-guide-close" type="button" aria-label="Dismiss TrailNest Assistant invitation"><span aria-hidden="true">×</span></button>
<div class="tn-guide-top">
<div class="tn-guide-symbol" aria-hidden="true">△</div>
<div><p class="tn-guide-brand">TRAILNEST</p><p class="tn-guide-label">Your outdoor companion</p></div>
</div>
<h2>Where will you<br>go next?</h2>
<p class="tn-guide-description">Discover a few picks for your next adventure. TrailNest Assistant is one conversation away.</p>
<button id="tn-gear-start" class="tn-guide-start" type="button"><span id="tn-gear-button-label">Find my next adventure</span><span aria-hidden="true">↗</span></button>
<p class="tn-guide-note">THOUGHTFUL GEAR. MORE ADVENTURE.</p>
<p id="tn-gear-status" class="tn-guide-status" role="status" aria-live="polite"></p>
</aside>
<script>
(function (root, factory) {
const core = factory();
if (typeof module === 'object' && module.exports) module.exports = core;
else root.TrailNestChatCore = core;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
function createController(options) {
const schedule = options.schedule || setTimeout;
const cancel = options.cancel || clearTimeout;
const state = {
home: !!options.home, dismissed: !!options.dismissed,
conversation: !!options.conversation, chatOpen: false,
ready: false, button: false, busy: false, queued: false,
starting: false, launched: false, botReady: false,
pending: null, unavailable: false, error: ''
};
let epoch = 0;
let sendingEpoch = null;
let readyTimer, launchTimer, botTimer;
let contextRevision = 0;
let syncTimer, syncing = false, syncAgain = false, syncAttempt = 0;
const contextValues = id => [{
name: 'TrailNestIndividualId',
value: { valueType: 'TextValue', textValue: id }
}];
function report(code) {
if (options.report) options.report(code);
}
function invalidateContext() {
contextRevision++;
cancel(syncTimer);
syncAgain = false;
syncAttempt = 0;
}
// Retry only context preparation. Never retry or duplicate a user message.
function requestContextSync() {
if (!state.ready) return;
cancel(syncTimer);
syncAttempt = 0;
if (syncing) { syncAgain = true; return; }
void syncContext();
}
async function syncContext() {
if (!state.ready || syncing) return;
syncing = true;
syncAgain = false;
const revision = contextRevision;
let id = null;
try {
id = await context();
} catch {
// context() records a reason code without exposing the browser ID.
} finally {
syncing = false;
if (!state.ready) return;
if (syncAgain) {
syncAgain = false;
void syncContext();
} else if (revision === contextRevision && !id && syncAttempt < 2) {
const delay = [500, 1500][syncAttempt++];
syncTimer = schedule(() => { void syncContext(); }, delay);
}
}
}
function visible() {
return state.home && !state.dismissed && !state.conversation && !state.chatOpen;
}
function render() {
options.render({ ...state, visible: visible(),
disabled: state.busy || state.unavailable,
label: state.busy ? 'Connecting to your assistant…' : 'Find my next adventure'
});
}
function remember(name, value) {
if (options.remember) options.remember(name, value);
}
function stopPending() {
epoch++;
cancel(readyTimer); cancel(launchTimer); cancel(botTimer);
state.pending = null;
state.queued = state.busy = state.starting = false;
state.launched = state.botReady = false;
}
function markConversation() {
if (!state.conversation) {
state.conversation = true;
remember('conversation', true);
}
}
function resetConversation() {
invalidateContext();
stopPending();
state.conversation = false;
state.error = '';
remember('conversation', false);
}
async function context(currentEpoch, newConversation = false) {
const revision = contextRevision;
const valid = () => revision === contextRevision && state.ready &&
(currentEpoch === undefined || currentEpoch === epoch);
let id;
try {
id = await options.getIdentity();
} catch {
if (valid()) report('IDENTITY_LOOKUP_FAILED');
throw new Error('Browser identity lookup failed');
}
if (!valid()) return null;
if (typeof id !== 'string' || !id.trim()) {
report('IDENTITY_NOT_READY');
return null;
}
try {
await options.api().setSessionContext(contextValues(id), {
sendWithNewConversation: newConversation
});
} catch {
if (valid()) report('CONTEXT_API_FAILED');
throw new Error('Browser context could not be queued');
}
if (!valid()) return null;
// This acknowledges the client queue, not server acceptance of the variable.
report('CONTEXT_QUEUED');
return id;
}
function fail(message, currentEpoch) {
if (currentEpoch !== epoch) return;
stopPending();
state.error = message;
render();
}
async function flush() {
if (!state.pending || !state.botReady || !state.launched || sendingEpoch === epoch) return;
const currentEpoch = epoch;
const message = state.pending;
sendingEpoch = currentEpoch;
try {
const id = await context(currentEpoch);
if (currentEpoch !== epoch || state.pending !== message) return;
if (!id) throw new Error('Browser identity is unavailable');
// Include the ID in the same call as the starter utterance. Do not rely
// solely on a separate setSessionContext call reaching the next turn.
await options.api().sendTextMessage(message, contextValues(id));
if (currentEpoch !== epoch) return;
state.pending = null;
state.busy = state.starting = false;
cancel(botTimer);
state.error = '';
render();
} catch {
// Never retry automatically: the original message may already have arrived.
fail('Chat is open. Please type your request in the conversation window.', currentEpoch);
} finally {
if (sendingEpoch === currentEpoch) sendingEpoch = null;
}
}
async function launchQueued() {
if (!state.queued || !state.ready || !state.button || state.starting) return;
const currentEpoch = epoch;
state.starting = true;
state.queued = false;
cancel(readyTimer);
launchTimer = schedule(() => fail('We could not open your assistant. Please try again.', currentEpoch), 20000);
try {
const id = await context(currentEpoch, true);
if (currentEpoch !== epoch) return;
if (!id) {
fail('Your assistant is still preparing. Please try again in a moment.', currentEpoch);
return;
}
await options.api().launchChat(true);
if (currentEpoch !== epoch) return;
cancel(launchTimer);
markConversation();
state.chatOpen = state.launched = true;
botTimer = schedule(() => {
if (state.pending) fail('Chat is open. Please type your request in the conversation window.', currentEpoch);
}, 20000);
render();
await flush();
} catch {
fail('We could not open your assistant. Please try again.', currentEpoch);
}
}
function click() {
if (!visible() || state.busy || state.unavailable) return Promise.resolve();
epoch++;
state.busy = state.queued = true;
state.botReady = state.launched = false;
state.pending = 'Recommend outdoor gear for my next adventure.';
state.error = '';
const currentEpoch = epoch;
readyTimer = schedule(() => fail('Your assistant is still loading. Please try again.', currentEpoch), 30000);
render();
// A click before SDK readiness is queued, without calling Salesforce APIs early.
return launchQueued();
}
function dismiss() {
state.dismissed = true;
remember('dismissed', true);
stopPending();
state.error = '';
render();
}
function unavailable(message) {
stopPending();
state.unavailable = true;
state.error = message;
render();
}
function event(name) {
if (name === 'ready') {
// On this same page, another Ready means the anonymous client reset after
// an ended conversation and a closed window. The initial Ready does NOT
// clear the conversation flag restored from a previous page.
if (state.ready && state.conversation) {
resetConversation();
state.chatOpen = false;
}
state.ready = true;
requestContextSync();
} else if (name === 'button') {
state.button = true;
} else if (name === 'opened' || name === 'started' || name === 'standardClicked') {
// Restoration can win the race against a queued starter click. Keep the
// existing conversation and do not send a new starter into it.
if (!state.starting && state.pending) stopPending();
markConversation();
requestContextSync();
} else if (name === 'firstBot') {
state.botReady = true;
requestContextSync();
void flush();
} else if (name === 'maximized') {
state.chatOpen = true;
markConversation();
requestContextSync();
} else if (name === 'resumePage') {
requestContextSync();
} else if (name === 'minimized' || name === 'windowClosed') {
state.chatOpen = false;
// Closing the window or minimizing it is not ending the conversation.
} else if (name === 'closed') {
resetConversation();
state.ready = false;
// Leave chatOpen unchanged until the ended window is actually closed.
}
render();
void launchQueued();
}
function restoreConversation(active) {
if (active) {
if (!state.starting) stopPending();
state.conversation = true;
requestContextSync();
} else {
resetConversation();
}
render();
}
render();
return { click, dismiss, unavailable, event, restoreConversation, snapshot: () => ({ ...state }) };
}
return { createController };
});
</script>
<script>
(function () {
function installStarterPriority() {
const panel = document.getElementById('tn-gear-guide');
if (!panel || panel.dataset.priorityInstalled === 'true') return;
panel.dataset.priorityInstalled = 'true';
const sync = () => document.documentElement.toggleAttribute('data-tn-starter-visible', !panel.hidden);
sync();
// Keep the CSS guard synchronized with the starter, including SDK timing changes.
new MutationObserver(sync).observe(panel, { attributes: true, attributeFilter: ['hidden'] });
}
if (document.getElementById('tn-gear-guide')) installStarterPriority();
else document.addEventListener('DOMContentLoaded', installStarterPriority, { once: true });
})();
(function () {
// Run only on product detail pages.
const page = document.getElementById('trailnest-app');
if (page?.dataset.pageType !== 'product_detail') return;
let ready = false;
let maximized = false;
let finished = false;
let processing = false;
async function minimizeRestoredChat() {
if (!ready || !maximized || finished || processing) return;
const api = window.embeddedservice_bootstrap?.utilAPI;
if (typeof api?.minimizeChat !== 'function') {
console.warn('[TrailNest] minimizeChat is unavailable.');
return;
}
processing = true;
try {
await api.minimizeChat();
finished = true;
api.showChatButton();
} catch (error) {
console.warn('[TrailNest] Could not minimize chat.', error);
} finally {
processing = false;
}
}
window.addEventListener('onEmbeddedMessagingReady', function () {
ready = true;
void minimizeRestoredChat();
});
window.addEventListener('onEmbeddedMessagingWindowMaximized', function () {
maximized = true;
void minimizeRestoredChat();
});
window.addEventListener('onEmbeddedMessagingWindowMinimized', function () {
maximized = false;
finished = true;
});
// Keep the chat open when the visitor explicitly clicks the standard button.
window.addEventListener('onEmbeddedMessagingButtonClicked', function () {
finished = true;
});
})();
(function () {
if (window.__trailNestChatInstalled) return;
window.__trailNestChatInstalled = true;
// Copy these five values from your current Enhanced Web Chat v2 Code Snippet.
const cfg = {
orgId: 'YOUR_ORG_ID',
deploymentName: 'YOUR_DEPLOYMENT_NAME',
siteUrl: 'YOUR_ESW_URL',
scrt2Url: 'YOUR_SCRT2_URL',
bootstrapUrl: 'YOUR_BOOTSTRAP_URL'
};
const panel = document.getElementById('tn-gear-guide');
const button = document.getElementById('tn-gear-start');
const closeButton = document.getElementById('tn-gear-close');
const label = document.getElementById('tn-gear-button-label');
const status = document.getElementById('tn-gear-status');
const pageType = document.getElementById('trailnest-app')?.dataset.pageType;
const view = new URLSearchParams(location.search).get('view');
const home = pageType ? pageType === 'home' : !view || view.toLowerCase() === 'home';
const key = `trailnest-chat:${cfg.orgId}:${cfg.deploymentName}:`;
function readFlag(storageName, name) {
try { return window[storageName].getItem(key + name) === '1'; }
catch { return false; }
}
function writeFlag(storageName, name, active) {
try {
if (active) window[storageName].setItem(key + name, '1');
else window[storageName].removeItem(key + name);
} catch { /* The current page still works if browser storage is blocked. */ }
}
// Contains status codes only. The ID itself is never logged or stored here.
const diagnostics = window.TrailNestChatDiagnostics = {
version: '2026-10-07-context-1',
lastContextStatus: 'NOT_REQUESTED',
updatedAt: null
};
function reportContext(code) {
diagnostics.lastContextStatus = code;
diagnostics.updatedAt = new Date().toISOString();
if (code !== 'CONTEXT_QUEUED') console.warn('[TrailNest context] ' + code);
}
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
let identityPending;
async function getIdentity() {
if (identityPending) return identityPending;
identityPending = (async function () {
for (let attempt = 0; attempt < 80; attempt++) {
try {
const sdk = (typeof window.getSalesforceInteractions === 'function'
? window.getSalesforceInteractions() : null) || window.SalesforceInteractions;
const id = sdk?.getAnonymousId?.();
if (typeof id === 'string' && id.trim()) return id;
} catch { /* An SDK that is still initializing can become ready on a later attempt. */ }
await delay(100);
}
return null;
})();
try { return await identityPending; } finally { identityPending = null; }
}
let launcherIntent;
function syncLauncher(state) {
if (!state.ready || !state.button || state.chatOpen) {
launcherIntent = undefined;
return;
}
const show = !state.visible;
if (launcherIntent === show) return;
try {
const api = window.embeddedservice_bootstrap.utilAPI;
if (show) api.showChatButton();
else api.hideChatButton();
launcherIntent = show;
} catch { launcherIntent = undefined; }
}
const controller = window.TrailNestChatCore.createController({
home,
dismissed: readFlag('sessionStorage', 'dismissed'),
conversation: readFlag('localStorage', 'conversation'),
api: () => window.embeddedservice_bootstrap.utilAPI,
getIdentity,
report: reportContext,
remember: (name, active) => writeFlag(
name === 'conversation' ? 'localStorage' : 'sessionStorage', name, active
),
render: state => {
// This runs immediately, before loading the chat SDK. It also keeps the
// panel hidden on every non-Home page, including cart and checkout.
panel.hidden = !state.visible;
button.disabled = state.disabled;
button.setAttribute('aria-busy', state.busy ? 'true' : 'false');
label.textContent = state.label;
status.textContent = state.error;
syncLauncher(state);
}
});
const events = {
onEmbeddedMessagingReady: 'ready',
onEmbeddedMessagingButtonCreated: 'button',
onEmbeddedMessagingButtonClicked: 'standardClicked',
onEmbeddedMessagingConversationOpened: 'opened',
onEmbeddedMessagingConversationStarted: 'started',
onEmbeddedMessagingFirstBotMessageSent: 'firstBot',
onEmbeddedMessagingWindowMaximized: 'maximized',
onEmbeddedMessagingWindowMinimized: 'minimized',
onEmbeddedMessagingWindowClosed: 'windowClosed',
onEmbeddedMessagingConversationClosed: 'closed'
};
for (const [name, event] of Object.entries(events)) {
window.addEventListener(name, () => {
// Reapply after SDK UI transitions: button APIs do not persist across pages.
launcherIntent = undefined;
controller.event(event);
});
}
// Navigation restoration or returning from another tab can precede user input.
window.addEventListener('pageshow', () => controller.event('resumePage'));
window.addEventListener('focus', () => controller.event('resumePage'));
window.addEventListener('storage', event => {
if (event.key === key + 'conversation') {
controller.restoreConversation(event.newValue === '1');
}
});
button.addEventListener('click', () => { void controller.click(); });
closeButton.addEventListener('click', () => controller.dismiss());
if (Object.values(cfg).some(value => value.startsWith('YOUR_'))) {
controller.unavailable('Add your Enhanced Web Chat v2 deployment settings before publishing.');
return;
}
if (window.embeddedservice_bootstrap) {
controller.unavailable('Keep only one Embedded Messaging snippet on this page.');
return;
}
const script = document.createElement('script');
script.src = cfg.bootstrapUrl;
script.async = true;
script.onload = function () {
try {
const bootstrap = window.embeddedservice_bootstrap;
bootstrap.settings.language = 'en_US';
const current = controller.snapshot();
bootstrap.settings.hideChatButtonOnLoad = current.home && !current.dismissed && !current.conversation && !current.chatOpen;
bootstrap.init(cfg.orgId, cfg.deploymentName, cfg.siteUrl, { scrt2URL: cfg.scrt2Url });
} catch {
controller.unavailable('We could not prepare your assistant. Please reload the page.');
}
};
script.onerror = () => controller.unavailable('We could not load your assistant. Please reload the page.');
document.body.append(script);
})();
</script>
既存の Salesforce Interactions SDK、Sitemap、Hero Banner、API 表示、WPM の表示処理は、今回のボタンのために作り直す必要はありません。
補足:商品ページではチャットを最小化する
今回の商品カルーセルでは、画像または「Explore this gear」をクリックすると、商品詳細ページを別タブで開きます。
その際、チャット画面が商品を覆わないよう、商品詳細ページでは、自動復元されたチャットを一度だけ最小化する処理を追加しています。PC・モバイル共通の動作です。
チャットの準備完了と画面の最大化を確認してから、utilAPI.minimizeChat() を呼び出します。会話は終了せず、標準のチャットボタンから続けられます。 自分でボタンを押して開いた場合は、自動で最小化しません。
※ この処理は、配布する CloudPages 用コードに組み込み済みです。
<script>
(function () {
// Run only on product detail pages.
const page = document.getElementById('trailnest-app');
if (page?.dataset.pageType !== 'product_detail') return;
let ready = false;
let maximized = false;
let finished = false;
let processing = false;
async function minimizeRestoredChat() {
if (!ready || !maximized || finished || processing) return;
const api = window.embeddedservice_bootstrap?.utilAPI;
if (typeof api?.minimizeChat !== 'function') {
console.warn('[TrailNest] minimizeChat is unavailable.');
return;
}
processing = true;
try {
await api.minimizeChat();
finished = true;
api.showChatButton();
} catch (error) {
console.warn('[TrailNest] Could not minimize chat.', error);
} finally {
processing = false;
}
}
window.addEventListener('onEmbeddedMessagingReady', function () {
ready = true;
void minimizeRestoredChat();
});
window.addEventListener('onEmbeddedMessagingWindowMaximized', function () {
maximized = true;
void minimizeRestoredChat();
});
window.addEventListener('onEmbeddedMessagingWindowMinimized', function () {
maximized = false;
finished = true;
});
// Keep the chat open when the visitor explicitly clicks the standard button.
window.addEventListener('onEmbeddedMessagingButtonClicked', function () {
finished = true;
});
})();
</script>⑨ 商品カードと会話を確認する
ここまで来たら、TrailNest をページを開き、挙動を確認します。
1. まず、拡張チャット v2 の公開 を行ってください。

何かしら Custom Lightning Type(カルーセルの表示など)に変更を加えた場合は、その都度、Embedded Service Deployments の「公開」が必要 です。これを行わないと最新化されません。💡
2. サイトを開くとスタータープロンプトと共に、Find my next adventure のボタンが表示されます。サイトは「アクセサリー」を推していますので、これから表示される商品も「アクセサリー」が予想されます。

3. ボタンをクリックすると会話が開始されます。

4. Welcome の後にスターターの質問が 1 回だけ自動で送信されます。

5. おすすめ商品が最大 4 件のカードで表示されました。

6 こちらは、前回の記事で作ったサイト内のレコメンドと同じ並びになっていることも確認できました。成功です!

いかがでしたでしょうか。
今回は、Web サイトに表示していた Personalization のおすすめ商品を、Agentforce の会話上へつなぐ構成を紹介しました。
商品を選ぶ Personalization Point は共通にし、Web サイトでは WPM、チャットでは Custom Lightning Type と専用レンダラーを使って表示します。
Agent Script には、ブラウザの ID を受け取る変数、商品を取得するアクション、商品カードとして表示する設定をまとめました。接続先と権限、チャットの公開設定は、画面で設定します。
これにより、TrailNest の訪問者は 1 つのボタンからおすすめ商品を受け取り、そのまま会話を続けられるようになります。
今回は以上です。