Skip to content

feat(ai): add an AI assistant that answers from DPIP's data - #641

Open
whes1015 wants to merge 3 commits into
mainfrom
feat/ai-chat
Open

whes1015 wants to merge 3 commits into
mainfrom
feat/ai-chat

Conversation

@whes1015

@whes1015 whes1015 commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

這個 PR 做了什麼

在首頁右上角(地區列的右端)新增「AI 助理」入口,點開是蓋在 shell 上的全螢幕頁面;導覽列維持 5 格。使用者填入自己的 AI 服務(OpenAI 相容格式)的網址、金鑰和模型後,就能問地震、天氣、颱風、海嘯和災害通知。AI 會先透過 DPIP 的 MCP 伺服器(/mcp)查即時資料,再用使用者的語言回答。

第一次進入會先顯示說明頁,寫清楚資料會送到哪裡、哪些會留在手機上。說明頁也提供 MCP 伺服器網址,讓使用者接到自己的工具(Claude、ChatGPT 等)。

這個 PR 沒有 public AI:ExpTech 自己提供的 AI、限流與排隊,以及「不要濫用、歡迎贊助」的說明,之後另外處理。

改動內容

正式程式碼

  • 3523a20a 把 secret 存在平台的金鑰庫(core/platform/secret_store.dart)
    • 照 Native-first 自己寫 platform channel,沒有新增套件。
    • iOS(SecretStorePlugin.swift):Keychain generic password,kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,所以備份還原到別支手機時不會帶走。
    • Android(SecretStoreChannel.kt):AES-GCM 金鑰在 Android Keystore 內產生、不會離開 Keystore,只把密文寫到 noBackupFilesDir。寫入先寫暫存檔再 rename;無法解密的檔案(例如移除螢幕鎖後 Keystore 金鑰失效)會刪掉,當成沒存過。
    • Dart 端用 SecretKey enum 當 key,和 SettingKey 同樣的理由:不能用任意字串存取。
    • 為什麼不放 SettingsStore:dpip.db 在 application-support,iOS 會把它放進備份,這樣說明頁「金鑰不離開手機」就不成立。
  • 024baf92 AI 助理(lib/features/ai_chat/)
    • AI 服務(data/openai_chat_model.dart):OpenAI 相容的 /chat/completions,stream: true。
      • 處理各家差異:tool call 分段送來(有的沒有 index)、reasoning_content、開頭的 <think>…</think>(自架模型常見)、不串流的 JSON 回應、200 串流裡夾帶的錯誤(OpenRouter)。
      • 工具的 JSON Schema 會簡化:["null","integer"] 改成 integer,拿掉 additionalProperties。DPIP 的 schema 用了這兩種寫法,Gemini 的相容端點會因此拒絕整個請求。
    • MCP(data/dpip_mcp_tool_host.dart):initialize → tools/list(每個 session 列一次)→ tools/call,回應 SSE 或 JSON 都能解析。
      • 走 ApiClient 的 coreApi,和其他 DPIP 請求一樣區域綁定、可以切換區域。
      • 伺服器的 instructions(單位、震度刻度、代碼格式)會放進系統提示。
    • 對話迴圈(domain/chat_agent.dart):
      • 每個問題最多 8 輪模型請求,每輪最多 6 個工具呼叫。
      • 單一工具結果超過 16,000 字元會截斷並註明。
      • 之前問題的工具結果在之後的請求裡換成一行說明,避免長對話每輪都把查過的資料重送一次。
      • 按「停止」會立即中止請求,不用等模型的下一個字;已說出的文字留在歷史裡,送出去的工具呼叫不會留下沒有回應的紀錄(否則下一題的請求會被拒絕)。
    • 系統提示(domain/system_prompt.dart):
      • 帶入校正過的台北時間(AppTime.utc8),模型才知道「今天」「一小時前」是什麼時候。
      • 帶入回覆語言,以及所在地和收藏的鄉鎮(只有名稱和 3 碼代碼,不含座標)。
      • 要求先查資料、不可編造數字,並說明自己不是官方警報來源。
    • 頁面:
      • 說明頁:第一次進入顯示,之後可從資訊按鈕再開。
      • 對話頁:
        • 回答以全寬 markdown 顯示,問題用泡泡顯示。
        • 會顯示正在查哪類資料(「正在查詢地震資料…」)。
        • 有停止、重試、新對話、建議問題。
        • 輸入框下方一直顯示「AI 可能出錯,緊急時請以官方警報為準」。
      • 設定頁:
        • 一鍵填入 OpenAI、Gemini、Anthropic、OpenRouter、Groq 的網址。
        • 金鑰欄位可以切換顯示或隱藏。
        • 可抓模型清單(附搜尋,OpenRouter 有好幾百個)。
        • 也可以清除全部 AI 設定。
    • 對話只存在記憶體,關掉 App 就沒了,沒有新增資料表。
    • 只接受 https://,localhost 例外。
    • 日誌只記狀態碼和錯誤種類,不記網址、金鑰或對話。
    • core/network/api_client.dart 新增 streamAbsolute:絕對網址、串流回應、回傳所有狀態碼。AI 服務的兩個呼叫都走它:串流回應不進 ETag 快取,而這些回應取決於使用者的金鑰(getAbsolute 的 GET 會被快取)。
    • 新增 SettingKeys.aiBaseUrl、aiModel、aiIntroSeen;金鑰不是 setting。
    • 入口與路由:
      • shared/widgets/region_bar.dart 新增可選的 trailing:一個圖示按鈕放在地區列右端,跟著地區列一起淡出、滑走,用和地區 badge 相同的天空配色;有 trailing 時兩側留相同的 inset,選取中的地區仍在螢幕正中間。事件頁不傳,所以不變。
      • 首頁把 AI 按鈕放進去,context.pushNamed(AppRoutes.ai);首頁不 import AI 這個 feature。
      • AppRoutes.ai 和 aiSettings 都是蓋在 shell 上的頁面,不是 branch,導覽列 5 格、既有分頁的索引都沒變。離開再回來,對話還在(存在 AiChatController,不在頁面裡)。
    • 92 個新字串,12 個語系都有翻譯。
  • fc0e3623 文件:api.md 補上 /mcp 和使用者 AI 服務的兩個呼叫;ARCHITECTURE.md § Persistence 說明 secret 為什麼是 SQLite 以外的唯一例外。

測試

  • Dart 新增約 80 個:
    • 對話迴圈:工具順序、錯誤參數、未知工具、步數和呼叫數上限、截斷、三種取消情境。
    • 串流解析:分段的 tool call、<think>、非串流回應、200 裡的錯誤、取消時中止請求。
    • MCP:SSE 和 JSON 回應、分頁 cursor、工具錯誤和 RPC 錯誤、session id。
    • 設定:金鑰只進 key store、留空保留原本的金鑰。
    • URL 正規化、系統提示、controller 狀態、頁面流程。
    • 版面:輸入框在系統手勢列上方、在鍵盤上方。拿掉底部 padding 時這個測試會失敗。
    • 地區列:trailing 在右端、按得到、跟著地區列消失;選取中的 badge 仍置中(只留右側 inset 時這個測試會失敗)。
    • 首頁:點 AI 按鈕會導到 AppRoutes.ai。路由:shell 只有 5 個 branch,AI 是 shell 外的頁面。
  • 既有的兩個測試替身 ApiClient(earthquake_api_test.dart、replay_session_test.dart)補上 streamAbsolute。
  • iOS:SecretStorePluginTests 5 個,包含「Keychain 項目是 ThisDeviceOnly」。

沒做的與已知限制

  • 區網的 http://(例如另一台電腦上的 Ollama)不能用,Android 的 network security config 只允許 localhost 走明文。
  • 對話不會保存,也沒有歷史清單。

相關 issue

沒有對應的 issue。

怎麼驗

  • tool/check.sh 全部通過,Dart 測試 3,703 個全過(pre-push hook 也跑過一次)。
  • 對實際的 MCP 伺服器跑過 DpipMcpToolHost(臨時測試,沒有 commit):
    • 15 個工具都列得到,instructions 有 1,016 字元。
    • township_lookup、earthquake_reports 都查得到資料。
    • 參數型別錯誤時回傳 validating /properties/limit: … want "integer",模型可以讀到並重試。
    • /mcp 在 core-tnn1 和 core-tyo1 都有回應。
  • iOS 原生測試 419 個通過。Keychain 的 5 個測試需要有簽章的 test host:CI(tool/dev/test_ios.sh 用 CODE_SIGNING_ALLOWED=NO)沒有 keychain entitlement,會拿到 errSecMissingEntitlement(-34018),所以這種情況下會跳過。另外用 ad-hoc 簽章(tool/dev/test_ios.sh CODE_SIGNING_ALLOWED=YES CODE_SIGN_IDENTITY=-)跑過,5 個全部通過。
  • Android debug APK 建置成功(Kotlin 編譯通過)。
  • 還沒做的,請在實機上確認:
    • 用真的金鑰實際對話。我手邊沒有金鑰,所以模型這端只有用測試替身驗過。建議 OpenAI、Gemini 各試一個,特別是 Gemini 的 schema 簡化。
    • Android Keystore 實際存取:建議存一次、重開 App、確認金鑰還在。
    • 兩個平台上鍵盤和手勢列的實際位置,深色模式。
    • 首頁右上角的 AI 按鈕在晴天、夜晚背景上看不看得清楚,以及往上滑時有沒有跟著地區列消失。
  • 安全相關:沒有改到 EEW 推估、警報門檻、通知路徑或背景定位。系統提示和畫面都說明 AI 不是官方警報來源。

檢查清單

  • tool/check/commits.sh origin/main..HEAD 通過(3 個 commit)
  • 一個 commit 一件事
  • analyze 與測試通過(tool/commit.sh --push)
  • 新的使用者可見字串都走 AppLocalizations,12 個語系都有翻譯
  • UI 使用 AppSpacing / AppRadius;深色模式與大字級還沒在實機上看過

@codecov

codecov Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

@whes1015

Copy link
Copy Markdown
Member Author

Force-push:rebase 到目前的 main(ce4b24b5,包含 #638、#639、#642),並把一個修正併進 chore(platform): keep secrets in the platform key store。

修正:PlatformSecretStore.write 失敗時原本什麼都沒記,所以設定頁顯示「無法儲存金鑰」時,log 裡查不到原因(設定頁的註解還寫著 log 已經記下錯誤碼)。現在會記下 PlatformException 的 code 和 message(OSStatus 或 Keystore 例外的類別名稱,不會含金鑰),以及「沒有原生端」的情況(hot restart 到沒有這個 channel 的 build)。兩種情況都會照樣往上丟,不會被當成已儲存;新增了一個測試鎖住這點。

另外兩個 commit 內容沒變。push 前 tool/commit.sh --push 全部通過,3,700 個測試。iOS 檢查之前失敗是 main 上 Crashlytics script 的問題,#642 合併後應該會過。

Platform: all

New(zh-Hant): 首頁右上角新增 AI 助理:用你自己的 AI 服務問地震、天氣、颱風等,AI 會先查 DPIP 的即時資料再回答;金鑰只存在這支手機,對話不會儲存
New(en-US): a new AI assistant, opened from the top right of Home, answers questions about earthquakes, weather and typhoons with your own AI service, looking up DPIP's live data first; your key stays on this phone and conversations are not saved
@whes1015 whes1015 changed the title feat(ai): add an AI assistant tab that answers from DPIP's data feat(ai): add an AI assistant that answers from DPIP's data Oct 22, 2026
@whes1015

Copy link
Copy Markdown
Member Author

Force-push:AI 不再佔導覽列的第六格。Material 3 建議導覽列放 3 到 5 個 destination,6 個在小螢幕和部分語系上標籤會擠。

  • 入口改到首頁右上角:RegionBar 新增可選的 trailing,首頁放一個 AI 圖示按鈕;事件頁不受影響。
  • AI 頁改成蓋在 shell 上的全螢幕路由,導覽列回到 5 格;拿掉不再用到的 navAi 字串(12 個語系)。
  • 修改併進原本的 feat(ai) 和 docs commit,兩則訊息也改了:commit 訊息就是更新日誌,原本寫的「導覽列新增 AI 分頁」已經不對。PR 標題和描述跟著更新。
  • 新增測試:地區列右側按鈕(含置中,有做 mutation 檢查)、首頁點按鈕會導到 AI 頁、shell 只有 5 個 branch。

push 前 tool/commit.sh --push 全部通過,3,703 個測試。

@YinCheng0106 YinCheng0106 added android Issues or pull requests related to the Android platform. ios Issues or pull requests related to the iOS platform. labels Oct 23, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

android Issues or pull requests related to the Android platform. ios Issues or pull requests related to the iOS platform. size/XXL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants