iT邦幫忙

2026 iThome 鐵人賽

DAY 21
0
Build on Google AI

單鐵的人生如履薄冰!AI 教練 APP 30天開發旅程,你說能走到最後嗎?系列 第 21

Day 21 | AI 動態重算實戰:將狀態發送至雲端,智慧合併新課表並無縫刷新 UI

  • 分享至 

  • xImage
  •  

前言:當計畫遇上變化,展現 AI 教練的真正價值

昨天成功加入了「AI 教練診療室」,提供跑者回報「疲勞指數」與「具體狀況」的精美介面。
但在真實的運動情境中,跑者可能剛參加完一場週末的網球賽,大腿嚴重緊繃痠痛,今天連下樓梯都有困難。這時候如果硬著頭皮執行原本安排的 14 公里長距離跑,受傷風險極高。

這正是 KAKERU 專案的核心精神——動態調整。
今天要將打卡資料發送到 Cloud Run,喚醒後端的 Gemini 大腦。此外,我們還將解決一個在 AI 應用開發中極為經典的架構難題:「AI 只回傳局部變更 (Delta Patch),前端如何智慧合併 (Merge) 並實現無縫刷新?」

[ClinicScreen 狀況打卡] 
       │ (fatigueLevel, feedbackText, sessionId, originalScheduleJson)
       ▼
[Cloud Run 後端 (Spring Boot)]
       │ (調用 Gemini,根據目前進度與突發狀況推演)
       ▼
[回傳局部微調 JSON (revisedSchedule)]
       │
       ▼
[前端智慧合併 (Upsert Merge)] ──▶ [保留第 1 週歷史 + 覆蓋第 2, 3 週變更 + 保留第 4 週]
       │
       ▼
[Zustand 全域狀態覆蓋] ────────▶ [課表頁無縫刷新,即時呈現最新訓練計畫!]
  • 收集與打包:從 Zustand 取得當前執行的課表與 session,結合跑者的疲勞度與文字回饋組合成 Payload。
  • 雲端推演:後端將狀況注入 Prompt,Gemini 產出受影響天數的重算課表。
  • 前端智慧合併 (Smart Merge):以原 4 週課表為基底,將 AI 回傳的局部調整透過 (weekNumber, dayOfWeek) 鍵值進行覆蓋。
  • 無縫刷新:寫入 Zustand,所有綁定 aiPlan 的元件自動重新渲染!

動手實作 1:擴充 apiService.ts,新增課表調整端點

加入專門用來呼叫動態調整的 API 函式adjustMarathonPlan,並定義好包含originalScheduleJsoncurrentWeek等資訊的 Payload 介面。

export interface AdjustSchedulePayload {
  originalScheduleJson: string; // 帶入舊課表讓 AI 參考
  userFeedback: string;
  currentWeek: number;
  currentDay: number;
  /* ...省略其他屬性... */
}

export const adjustMarathonPlan = async (payload: AdjustSchedulePayload) => {
  try {
    const response = await apiClient.post('/adjust', payload);
    return response.data;
  } catch (error: any) {
    throw new Error(error.response?.data?.message || '無法連線至 AI 診療服務');
  }
};

動手實作 2:課表智慧合併演算法 (Smart Merge)

由於 Gemini 在微調時著重於「未來受影響的天數」,若直接覆蓋會導致過去完成的週次與未變更週次消失。我們實作了一個鍵值合併器,以 (weekNumber-dayOfWeek) 作為唯一 Key 進行 Upsert 覆蓋:

// 鍵值合併器:以 (weekNumber-dayOfWeek) 作為 Key 進行 Upsert 覆蓋
function mergeSchedules(original: WorkoutItem[], adjustments: WorkoutItem[]): WorkoutItem[] {
  const map = new Map<string, WorkoutItem>();
  
  // 1. 先載入原始完整 4 週課表
  original.forEach((item) => {
    map.set(`${item.weekNumber}-${item.dayOfWeek}`, item);
  });
  
  // 2. 覆蓋 AI 調整的訓練日 (Delta Patch)
  adjustments.forEach((item) => {
    if (!item) return;
    const existing = map.get(`${item.weekNumber}-${item.dayOfWeek}`) || {};
    map.set(`${item.weekNumber}-${item.dayOfWeek}`, { ...existing, ...item });
  });
  
  // 3. 依週次與星期排序並回傳
  return Array.from(map.values()).sort((a, b) => {
    if (a.weekNumber !== b.weekNumber) return a.weekNumber - b.weekNumber;
    return a.dayOfWeek - b.dayOfWeek;
  });
}

(註:若還搭配一個 extractWorkoutList 函式,將不同結構的 JSON 扁平化為標準的 WorkoutItem[] 陣列,方便進行合併。)

動手實作 3:為診療室注入靈魂 (ClinicScreen.tsx)

現在將按鈕綁定真實的非同步請求。在handleSubmit中,呼叫 API 後取得回傳資料,並利用剛剛寫好的mergeSchedules

將新舊課表完美融合,最後透過setAiPlan更新 Zustand 全域狀態:

const handleSubmit = async () => {
    // ...省略表單驗證與 payload 組裝...
    setIsLoading(true);

    try {
      // 1. 呼叫雲端重算 API
      const apiResponse = await adjustMarathonPlan(payload);
      const adjustedData = apiResponse.data || apiResponse;
      
      // 2. 前端智慧合併課表
      const originalList = extractWorkoutList(existingPlan);
      const incomingList = [ ...extractWorkoutList(adjustedData.revisedSchedule) ];
      const mergedSchedule = mergeSchedules(originalList, incomingList);
      
      // 3. 封裝並更新 Zustand 全域狀態
      const updatedPlanObj = {
        ...existingPlan,
        evaluation: adjustedData.recoveryAdvice || 'AI 教練已動態重算課表!',
        scheduleData: mergedSchedule,
      };
      
      setAiPlan(updatedPlanObj);
      
      // 4. 成功引導
      Alert.alert('🎉 課表已更新', 'AI 教練已為您調整了最新計畫!', [
        { text: '查看新課表', onPress: () => router.navigate('/schedule') },
      ]);
    } catch (error: any) {
      Alert.alert('發生錯誤', error.message);
    } finally {
      setIsLoading(false);
    }
  };

動手實作 4:見證 Zustand 無縫刷新

這個實作好的地方在於:ScheduleScreen.tsx 不需要改動任何重新拉取邏輯!

因為課表頁面透過 useStore((state) => state.aiPlan) 與 DailyWorkoutList 響應式綁定:

  1. 當在診療室呼叫 setAiPlan(updatedPlanObj) 的瞬間,Zustand 會觸發廣播。
  2. React Native 會在毫秒內重新渲染深色評估卡片與每日清單。
  3. 過去的週次完整無損,而當天因參加網球賽導致大腿酸痛,當週(例如週末的 LSD 長距離跑)已精準替換為完全休息!

結果如下:
首先是送出我們的狀況
https://ithelp.ithome.com.tw/upload/images/20260830/201650437xrY3V5emx.png

送出後得到
https://ithelp.ithome.com.tw/upload/images/20260830/20165043YnnEZ1LZnf.png

調整的結果
https://ithelp.ithome.com.tw/upload/images/20260830/20165043y4Sx4J35H3.png

今日總結與明日預告

今天完成了 KAKERU 專案中最關鍵的功能—「動態重算與智慧合併」。透過前端帶入 originalScheduleJson,結合後端 Gemini 的推演,以及前端的 Upsert 合併機制,徹底實現了彈性且穩健的 AI 互動體驗。

有了會思考且隨時適應體能的課表後,再來進入我們另一個核心功能!

明天(Day 22)將進入第二階段的最後:多模態 AI 實戰 (Multimodal AI)!我們將打破傳統手動輸入或繁瑣 API 串接的限制,在 APP 中實作「圖片上傳」功能,讓 Gemini Vision 直接解析你的訓練數據截圖或賽後數據,為 KAKERU 注入真正的視覺辨識能力!我們明天見!


上一篇
Day 20 | 介面實作 (3):打造課表診療室,實作疲勞度打卡與文字發問介面
下一篇
Day 22 | 繞過 API 授權地獄,用 Gemini 多模態看懂跑錶與跑步機螢幕
系列文
單鐵的人生如履薄冰!AI 教練 APP 30天開發旅程,你說能走到最後嗎?22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言