在昨天的文章中,我們完成了 10 天基礎核心架構的總驗收:從使用者的口語輸入,到 Dify 提取意圖、FastAPI 篩選餐廳,最後透過 LINE Reply API 把推薦文字送回手機聊天室。
不過,傳統的純文字訊息排版單調,使用者很難一目了然地比較店家資訊,也無法直接點擊查看地圖或店家照片。
今天 Day 11,我們將正式跨入進階優化階段:利用 LINE 強大的 Flex Message(彈性訊息) 與 Carousel(輪播卡片),將推薦結果升級為圖文並茂、具備評分標籤與一鍵導航按鈕的高質感互動小卡!
Flex Message 是 LINE 官方提供的一種高自由度訊息格式,基於 CSS Flexible Box(Flexbox)的概念設計:
LINE 官方甚至提供了視覺化的線上編輯工具——LINE Flex Message Simulator,可以直接預覽卡片排版。
在 LINE 的規範中,Carousel 是由多個 Bubble(對話泡泡) 組成的一組陣列。
我們先為每一間推薦店家設計單個 Bubble 範本:包含店面封面圖、店名、評分星星、平均消費,以及點擊前往 Google Maps 導航的動作按鈕。
單張 Bubble 的 JSON 結構範例如下:
{
"type": "bubble",
"hero": {
"type": "image",
"url": "[https://images.unsplash.com/photo-1551024709-8f23befc6f87?w=600](https://images.unsplash.com/photo-1551024709-8f23befc6f87?w=600)",
"size": "full",
"aspectRatio": "20:13",
"aspectMode": "cover"
},
"body": {
"type": "box",
"layout": "vertical",
"contents": [
{
"type": "text",
"text": "恬淡日常法式甜點",
"weight": "bold",
"size": "xl"
},
{
"type": "box",
"layout": "baseline",
"margin": "md",
"contents": [
{ "type": "text", "text": "⭐️ 4.7", "size": "sm", "color": "#FF9900", "weight": "bold" },
{ "type": "text", "text": "甜點/下午茶", "size": "sm", "color": "#999999", "margin": "md" },
{ "type": "text", "text": "約 280 元", "size": "sm", "color": "#666666", "margin": "md" }
]
},
{
"type": "text",
"text": "招牌:草莓戚風蛋糕、伯爵生乳捲",
"size": "xs",
"color": "#444444",
"margin": "md",
"wrap": true
},
{
"type": "text",
"text": "巷弄隱密甜點店,氣氛溫馨悠閒,適合放鬆聊天。",
"size": "xs",
"color": "#888888",
"margin": "xs",
"wrap": true
}
]
},
"footer": {
"type": "box",
"layout": "vertical",
"contents": [
{
"type": "button",
"style": "primary",
"color": "#06C755",
"height": "sm",
"action": {
"type": "uri",
"label": "開啟地圖導航",
"uri": "[https://www.google.com/maps/search/?api=1&query=板橋區恬淡日常法式甜點](https://www.google.com/maps/search/?api=1&query=板橋區恬淡日常法式甜點)"
}
}
]
}
}
現在我們要在 n8n 工作流中,將原先輸出純文字的節點,替換為動態組裝上述 Flex 結構的 JavaScript 邏輯。
點開昨天的 Code 節點(原先負責文字排版的節點),將其改寫如下:
const responseData = $input.first().json;
const replyToken = $('Parse AI JSON').first().json.reply_token;
const recommendations = responseData.recommendations || [];
// 1. 若查無店家,回傳純文字提示
if (recommendations.length === 0) {
return {
reply_token: replyToken,
messages: [
{
type: "text",
text: "抱歉,目前在該區域與預算條件下找不到合適的店家,建議放寬預算或更換料理分類再試試看哦!"
}
]
};
}
// 2. 預備幾張乾淨的 Unsplash 美食佔位圖(若資料庫無圖片可用)
const defaultImages = [
"[https://images.unsplash.com/photo-1551024709-8f23befc6f87?w=600](https://images.unsplash.com/photo-1551024709-8f23befc6f87?w=600)",
"[https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=600](https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=600)",
"[https://images.unsplash.com/photo-1517248135467-4c7edcad34c4?w=600](https://images.unsplash.com/photo-1517248135467-4c7edcad34c4?w=600)"
];
// 3. 將每筆推薦餐廳轉換為 Flex Bubble 結構(最多顯示 5 家,避免超過 LINE 上限)
const bubbles = recommendations.slice(0, 5).map((shop, index) => {
const mapQuery = encodeURIComponent(`${shop.location} ${shop.name}`);
const imageUrl = defaultImages[index % defaultImages.length];
return {
type: "bubble",
hero: {
type: "image",
url: imageUrl,
size: "full",
aspectRatio: "20:13",
aspectMode: "cover"
},
body: {
type: "box",
layout: "vertical",
contents: [
{
type: "text",
text: shop.name,
weight: "bold",
size: "lg",
wrap: true
},
{
type: "box",
layout: "baseline",
margin: "sm",
contents: [
{ type: "text", text: `⭐️ ${shop.rating}`, size: "sm", color: "#FF9900", weight: "bold" },
{ type: "text", text: shop.cuisine, size: "sm", color: "#888888", margin: "md" },
{ type: "text", text: `約 ${shop.price_avg} 元`, size: "sm", color: "#555555", margin: "md" }
]
},
{
type: "text",
text: `招牌:${shop.signature_dish}`,
size: "xs",
color: "#333333",
margin: "md",
wrap: true
},
{
type: "text",
text: shop.reason || "精選優質店家,值得一試!",
size: "xs",
color: "#888888",
margin: "xs",
wrap: true
}
]
},
footer: {
type: "box",
layout: "vertical",
contents: [
{
type: "button",
style: "primary",
color: "#06C755",
height: "sm",
action: {
type: "uri",
label: "開啟 Google 地圖",
uri: `[https://www.google.com/maps/search/?api=1&query=$](https://www.google.com/maps/search/?api=1&query=$){mapQuery}`
}
}
]
}
};
});
// 4. 打包成 LINE Flex Carousel 格式
const flexMessage = {
type: "flex",
altText: `為您推薦 ${recommendations.length} 間精選美食餐廳!`,
contents: {
type: "carousel",
contents: bubbles
}
};
return {
reply_token: replyToken,
messages: [flexMessage]
};
將最後一個呼叫 LINE Reply API 的 HTTP Request 節點做小幅調整,直接帶入上一個節點組合好的 messages 陣列:
Method:POST
URL:https://api.line.me/v2/bot/message/reply
Authentication:Header Auth(保持不變)
Headers:Content-Type: application/json
Specify Body:選擇 Using JSON
JSON Body:
{
"replyToken": "={{ $json.reply_token }}",
"messages": "={{ $json.messages }}"
}
儲存並確保工作流為 Active 狀態後,打開手機 LINE 聊天室,發送一句包含多個結果或寬鬆條件的測試語句:
使用者輸入:
「我想要看板橋好吃的甜點或餐廳,預算 500 以內都可以!」
驗收畫面亮點:
多卡輪播(Carousel):手機聊天室不再只是一長串冰冷的文字,而是展示精緻的橫向滑動卡片!
直覺資訊階層:店名加粗、評分亮黃顯示、均消清楚列出。
一鍵外開地圖:點擊卡片下方的綠色按鈕 「開啟 Google 地圖」,手機會自動喚起 Google Maps 並搜尋該店家,實現無縫導航體驗!
今日結語與明日預告
今天我們成功把原本陽春的純文字回覆,升級為現代商業等級的 LINE Flex Message Carousel,讓整體的使用者體驗有了質的飛躍!
目前我們的資料庫仍然是 Day 05 寫在程式內的靜態 Mock 假資料。
明天 Day 12,我們將要把微服務接軌真實世界:串接 Google Maps Places API(或在地開放資料),讓推薦 Agent 具備查詢真實世界商家、即時營業狀態與真實評論的能力! 敬請期待!