iT邦幫忙

2025 iThome 鐵人賽

DAY 27
0
AI & Data

雲端情人 - AI 愛系列 第 27

繼續優化her -把 Flex UI 模組化:宣告式菜單系統(FastAPI × LINE SDK v3)

  • 分享至 

  • xImage
  •  

目標:把你在專案裡反覆複製的 FlexMessage,整理成可重用、可維護的 UI 套件。
一套「宣告式」Schema → 自動產出 主選單 / 子選單 / 快速回覆,再也不怕改文案、加功能。

  1. 設計重點
    • 宣告式:用一個 MENU_SCHEMA 就能定義所有 UI。
    • 單一輸出:build_menu_flex(kind) 只靠 kind 就能畫出對應菜單。
    • 一致樣式:顏色、按鈕密度、字級統一;想改風格只改一處。
    • 擴充容易:新增功能 = 在 Schema 加一行;Postback 自動路由。

  1. 目錄建議

app/
├─ app_fastapi.py # 事件入口(呼叫 menu 模組)
├─ menu/
│ ├─ schema.py # 宣告式菜單定義
│ ├─ builder.py # Flex/QuickReply 建置工具
│ └─ handlers.py # Postback 路由(menu:*)

也可放在既有專案中,路徑自訂即可。

  1. 宣告式 Schema(menu/schema.py)

menu/schema.py

from typing import Dict, List, TypedDict, Literal

Style = Literal["primary", "secondary", "link"]

class MenuButton(TypedDict, total=False):
label: str
text: str # MessageAction 用:使用者看到的輸入
data: str # PostbackAction 用:menu:xxx 或你自訂
style: Style # LINE 的按鈕 style
color: str # 自訂十六進位色(可省略)

class MenuSpec(TypedDict):
title: str
rows: List[List[MenuButton]] # 2 欄或 3 欄一列,自己安排

MENU_SCHEMA: Dict[str, MenuSpec] = {
"main": {
"title": "AI 助理主選單",
"rows": [
[
{"label": "💹 金融查詢", "data": "menu:finance", "style": "primary"},
{"label": "🎰 彩票分析", "data": "menu:lottery", "style": "primary"}
],
[
{"label": "💖 AI 角色扮演", "data": "menu:persona", "style": "secondary"},
{"label": "🌐 翻譯工具", "data": "menu:translate", "style": "secondary"}
],
]
},
"finance": {
"title": "💹 金融查詢",
"rows": [
[
{"label": "台股大盤", "text": "台股大盤", "style": "primary"},
{"label": "美股大盤", "text": "美股大盤", "style": "primary"},
],
[
{"label": "黃金價格", "text": "金價", "style": "secondary"},
{"label": "日圓匯率", "text": "JPY", "style": "secondary"},
],
[
{"label": "查 2330 台積電", "text": "2330", "style": "link"},
{"label": "查 NVDA 輝達", "text": "NVDA", "style": "link"},
],
]
},
"lottery": {
"title": "🎰 彩票分析",
"rows": [
[
{"label": "大樂透", "text": "大樂透", "style": "primary"},
{"label": "威力彩", "text": "威力彩", "style": "primary"},
],
[
{"label": "今彩539", "text": "539", "style": "secondary"},
]
]
},
"persona": {
"title": "💖 AI 角色扮演",
"rows": [
[
{"label": "甜美女友", "text": "甜", "style": "primary"},
{"label": "傲嬌女友", "text": "鹹", "style": "primary"},
],
[
{"label": "萌系女友", "text": "萌", "style": "secondary"},
{"label": "酷系御姐", "text": "酷", "style": "secondary"},
],
[
{"label": "隨機切換", "text": "random", "style": "link"},
]
]
},
"translate": {
"title": "🌐 翻譯工具",
"rows": [
[
{"label": "翻成英文", "text": "翻譯->英文", "style": "primary"},
{"label": "翻成日文", "text": "翻譯->日文", "style": "primary"},
],
[
{"label": "翻成繁中", "text": "翻譯->繁體中文", "style": "secondary"},
{"label": "結束翻譯模式", "text": "翻譯->結束", "style": "secondary"},
]
]
}
}

  1. Builder:一套 API 產出 Flex/QuickReply(menu/builder.py)

menu/builder.py

from typing import List
from linebot.v3.messaging import (
FlexMessage, FlexBubble, FlexBox, FlexText, FlexButton,
MessageAction, PostbackAction, QuickReply, QuickReplyItem
)
from .schema import MENU_SCHEMA, MenuSpec, MenuButton

--- 樣式設定(集中管理) ---

PRIMARY_COLOR = "#5E86C1"
SECONDARY_COLOR = "#5EC186"
LINK_COLOR = "#6B7280" # 中性灰

def _btn_color(btn: MenuButton) -> str:
if "color" in btn: return btn["color"]
return {"primary": PRIMARY_COLOR, "secondary": SECONDARY_COLOR, "link": LINK_COLOR}.get(btn.get("style","secondary"), LINK_COLOR)

def _mk_button(btn: MenuButton) -> FlexButton:
style = btn.get("style", "secondary")
color = _btn_color(btn)
if "data" in btn:
action = PostbackAction(label=btn["label"], data=btn["data"])
else:
action = MessageAction(label=btn["label"], text=btn.get("text",""))
return FlexButton(action=action, style=style, color=color)

def build_menu_flex(kind: str) -> FlexMessage:
spec: MenuSpec = MENU_SCHEMA.get(kind) or {"title": "未定義選單", "rows": []} # 防呆
rows_boxes: List[FlexBox] = []
for row in spec["rows"]:
buttons = [_mk_button(b) for b in row]
rows_boxes.append(FlexBox(layout="horizontal", spacing="sm", contents=buttons))
bubble = FlexBubble(
header=FlexBox(layout="vertical", contents=[FlexText(text=spec["title"], weight="bold", size="lg")]),
body=FlexBox(layout="vertical", spacing="md", contents=rows_boxes or [FlexText(text="(尚無項目)")]),
)
return FlexMessage(alt_text=spec["title"], contents=bubble)

def build_quick_reply_default() -> QuickReply:
# 這組在各處共用;要改只改這裡
items = [
QuickReplyItem(action=MessageAction(label="主選單", text="選單")),
QuickReplyItem(action=MessageAction(label="台股大盤", text="台股大盤")),
QuickReplyItem(action=MessageAction(label="美股大盤", text="美股大盤")),
QuickReplyItem(action=MessageAction(label="黃金價格", text="金價")),
QuickReplyItem(action=MessageAction(label="查台積電", text="2330")),
QuickReplyItem(action=MessageAction(label="查輝達", text="NVDA")),
QuickReplyItem(action=MessageAction(label="查日圓", text="JPY")),
QuickReplyItem(action=PostbackAction(label="💖 AI 人設", data="menu:persona")),
QuickReplyItem(action=PostbackAction(label="🎰 彩票選單", data="menu:lottery")),
]
return QuickReply(items=items)

  1. Postback 路由(menu/handlers.py)

menu/handlers.py

from linebot.v3.messaging import ReplyMessageRequest
from .builder import build_menu_flex

async def handle_menu_postback(kind: str, line_bot_api, reply_token: str):
msg = build_menu_flex(kind)
await line_bot_api.reply_message(ReplyMessageRequest(reply_token=reply_token, messages=[msg]))

  1. 在 app_fastapi.py 接上即可

只需要在兩個地方動手:
1. 回主選單

from menu.builder import build_menu_flex, build_quick_reply_default

...

if low in ("menu", "選單", "主選單"):
await line_bot_api.reply_message(
ReplyMessageRequest(reply_token=reply_token, messages=[build_menu_flex("main")])
)
return

2.	處理 Postback

from menu.handlers import handle_menu_postback

@handler.add(PostbackEvent)
async def handle_postback(event: PostbackEvent):
data = (event.postback.data or "").strip()
if data.startswith("menu:"):
kind = data.split(":", 1)[-1]
await handle_menu_postback(kind, line_bot_api, event.reply_token)

其他功能(金融、彩票、翻譯、人設)原本怎麼寫就怎麼寫,UI 都由 Builder 處理。

  1. 版本化與 A/B(小技巧)
    • 把 MENU_SCHEMA 拆成 MENU_SCHEMA_V1, MENU_SCHEMA_V2,以環境變數切換:
    ACTIVE_MENU_VERSION = os.getenv("MENU_VER","V1")
    • 你可以針對不同群組 ID 顯示不同排版:
    kind = f"main:{group_segment}" → Schema 裡做分支。

  1. UX 細節清單
    • alt_text 要簡短好懂(已以 title 填入)。
    • Button style 混搭:主要流程用 primary、資訊性用 secondary、輕量/低權重用 link。
    • 每列最多 2–3 顆,行距留白不要擠。
    • 文案一致性:動詞用「查、看、開啟」,避免「查詢」、「查看」混用。
    • 回 QuickReply:所有文字回覆都套 build_quick_reply_default(),維持導流。

  1. 除錯與維護
    • 新功能只要在 MENU_SCHEMA 新增一顆按鈕,不用改 Builder。
    • 想換一套配色?改 PRIMARY_COLOR/SECONDARY_COLOR/LINK_COLOR 即可。
    • 如果某些舊機型 Flex 兼容性差,改成較少欄位的 rows 就能立即緩解。

  1. 範例截圖(心中畫面)
    • 主選單:上方標題、兩列各兩顆主按鈕;下列兩顆次要按鈕。
    • 金融子選單:大盤×2(主)、工具×2(次)、常用個股(link)。

你不需要重畫 JSON,Schema 一改就生效。

https://ithelp.ithome.com.tw/upload/images/20250920/20112100VjlKsSJ5iV.png

結語

把 UI 結構抽象成 Schema + Builder,ai her 開發速度會飛快成長:
加一個功能 = 加一顆按鈕,不再有到處複製 Flex JSON 的痛苦。


上一篇
讓 AI 女友更懂「即時對話」-互動 + 快速回覆修復記錄
下一篇
一路走過來風風雨雨 - 如何Render.com 使用免費方案 (Free plan) 部署專案時遇到的困難與解決方案
系列文
雲端情人 - AI 愛29
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言