iT邦幫忙

2026 iThome 鐵人賽

DAY 28
0
AI Engineering

從零打造情感感知 Agentic System:FSM 狀態機與 RAG 的整合實作系列 第 28 篇

Day 28:【UI 與隱私責任】她記得你這麼多事,你卻看不到

  • 分享至 

  • xImage
  •  

前言

前幾天把記憶做得很完美,讓Agent能自動提取、矛盾偵測、過期淘汰。但在Day 25 時我心中一直在想一件事

這整套機制,使用者完全看不見。

她默默記下「使用者討厭吃香菜」、「使用者下週二有期末考」,而使用者不知道被記了什麼、不能修正、也不能刪。這在功能上叫長期記憶,在使用者體驗與隱私設計上,這就成了使用者不知道系統記錄了什麼的問題。

所以 Day 28 不只是「UI 美化」。介面當然要做,但真正該補的是那條缺失的權利:

  • 主題色(Theme Color):切換角色時要一眼看出換人了。
  • 好感度視覺化:使用者要的是「離下一個狀態多遠」,不是精確數字。
  • 記憶庫檢視(Memory Inspector):看得到、看得懂、改得動。

記得住是功能,讓人查得到才是責任。

今天的重量級目標:

  • 角色主題色:persona.THEME_COLOR,跟著角色即時切換。
  • 好感度進度條:st.metric 加上 st.progress。
  • 記憶庫介面:查看、封存、復原、刪除四個動作。
  • 封存原因白話化:使用者要看得懂某筆為什麼不見了。
  • test_day28.py + 用瀏覽器真的點過一輪。

第一關:主題色不該用 CSS 注入

原本的計畫是 THEME_COLOR = "#D9578E" 然後注入 CSS。查了 Streamlit 官方指引才發現這是反模式,主題應該走 config.toml,不要自己塞 <style>。

但 config.toml 是啟動時讀的靜態設定,做不到「換角色就換色」。折衷的答案是用 Streamlit 的語意色名稱:

# persona_lily.py
THEME_COLOR = "orange"   # 莉莉取暖色系

# persona_shike.py
THEME_COLOR = "violet"   # 時刻取紫,對應她深沉神秘的調性
st.markdown(f":{persona.THEME_COLOR}-badge[:material/person: {persona.DISPLAY_NAME}]")

好處不只是「照官方建議做」:語意色會跟著使用者的淺色/深色模式自動換算,寫死的 hex 在深色模式下常常糊成一團。 基礎配色則老老實實寫進 config.toml,而且同時定義 [theme.light] 與 [theme.dark],只寫一組會把 app 鎖死在單一模式。

好感度那邊也順手改了。原本只有 st.metric 顯示「29 / 100」,現在加上 st.progress,使用者要的其實是「離下一個狀態還有多遠」的感覺,不是精確到個位數的數值。狀態文字也從純文字改成主題色 badge,切角色時整條側邊欄的色調會跟著換。

改完的側邊欄長這樣,角色 badge、好感度進度條、狀態 badge、以及那顆「查看記憶庫」按鈕:

第二關:使用者的「刪除」跟系統的「忘記」不是同一件事

Day 25 立過一條原則:記憶只封存不刪除,因為自動判斷可能出錯,封存可逆、delete 不可逆。

但這條原則不能整套搬到使用者身上。他按下「刪除」,要的是消失,不是藏起來。把使用者的刪除也做成封存,等於嘴上答應、資料還留著。

記憶庫本身放在 st.dialog 而不是塞進側邊欄。側邊欄已經有角色選擇和心情儀表板,再擠進一份可捲動的清單會兩邊都難用;而且記憶庫是「偶爾才會打開來檢查」的東西,不需要常駐佔版面。

所以資料層新增兩支語意相反的 API:

def restore_memory(memory_id: str):
    """把封存的記憶放回檢索範圍,Day 25 說「封存可逆」,這裡兌現成按鈕"""

def delete_memory(memory_id: str):
    """真正刪除,不可復原。
    系統自動判斷的「忘記」→ 封存(可能判錯,要留退路)
    使用者親手按的「刪除」→ 真刪(他要求的是消失)"""

介面上兩者的份量也不同:封存是普通按鈕,刪除要跳出「刪除後無法復原,確定嗎?」。

還有一件小事但影響很大,封存原因原本直接顯示 contradicted / expired / evicted。使用者看不懂這些字,而「看不懂為什麼不見了」跟「壞掉了」在體感上沒有區別。

https://ithelp.ithome.com.tw/upload/images/20261012/20183877DuCG1xnDYr.png

第三關:實測,然後踩到兩個坑

test_day28.py 六案例全過。但 UI 不是跑腳本能驗的東西,所以我開了瀏覽器真的點一遍,結果兩個坑都是這樣才發現的。

坑一:按下「封存」,視窗直接關掉。

功能是對的(記憶確實封存了),但 st.rerun(scope="app") 會重跑整頁,dialog 跟著消失。使用者看不到結果,也沒辦法連續封存第二筆。

st.rerun(scope="fragment")   # dialog 底層是 fragment,只重繪視窗內容

改完之後,清單即時更新、視窗留在原地、上方計數從「有效 2、封存 1」變成「有效 3、封存 0」。

坑二:畫面印出「原因:user_archived」。

這個更值得記,因為測試明明過了。原因是我在測試裡寫的 fallback 跟 UI 寫的不一樣:

# 測試:查不到就給通用說明 → 看起來正常
memory_ui.REASON_LABELS.get(r, "使用者手動封存")

# UI:查不到就顯示原始代碼 → 畫面漏出 user_archived
REASON_LABELS.get(m["reason"], m["reason"] or "使用者手動封存")

修法不是補上那個 key 就算了,而是把規則抽成一個函式 reason_label(),讓 UI 與測試共用同一條規則:

def reason_label(reason: str) -> str:
    """查不到就回通用說明,絕不把原始代碼原封不動印出去"""
    return REASON_LABELS.get(reason, "系統自動封存")

測試與實作各寫一份 fallback,測到的就不是實際會發生的事。

順帶還撞到一個 ChromaDB 的行為:update 是合併 metadata 而不是取代。復原時我以為「在 dict 裡不寫 archived_reason 就等於清掉它」,實際上完全沒動,要明確傳 None 才會真的移除。

測試畫面

https://ithelp.ithome.com.tw/upload/images/20261012/20183877zqHHWS8yFX.png

今日心得

Day 28 掛的是「UI 美化」,做起來卻是整季離「產品」最近的一天 (沒有要販賣)。

因為使用者不在乎你的記憶架構有幾層。 他在乎的是:你記了我什麼?可以改嗎?可以刪嗎?前面五天做的所有治理邏輯,到今天才第一次變成使用者按得到的東西。

第二個收穫是關於驗證方式:UI 的坑,只有真的點下去才會出現。 那兩個坑(視窗被關掉、原始代碼漏出畫面)在腳本裡全都測不到,一個是 Streamlit 的重跑行為,一個是測試自己抄錯了 fallback。我原本以為開瀏覽器是「順便看一下漂不漂亮」,結果它是今天唯一抓到 bug 的手段。

測試過了不代表使用者看到的是對的

做記憶庫介面時,我第一次以「使用者」而不是「開發者」的角度看那三筆記憶。「使用者的名字是小明」「使用者是資工系的學生」「使用者正在準備下週二的期末考」。這些句子是我自己設計的提取格式,讀了幾十次,但排在一個有分類標籤、有日期、旁邊還有刪除按鈕的清單裡看,這裡感受完全不一樣:不再是「資料」,而是「有人記著我這些事」。

(註:本文僅刊登關鍵段落
完整原始碼包含 memory_ui.py、rag_memory.py 與 test_day28.py),太長了所以一樣不放


上一篇
Day 27:【例外處理與 API 限流】當免費額度撞牆時,該犧牲誰?
下一篇
Day 29:【端到端整合測試】要寫測試,才發現主流程根本測不到
系列文
從零打造情感感知 Agentic System:FSM 狀態機與 RAG 的整合實作 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言