昨天成功加入了「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 全域狀態覆蓋] ────────▶ [課表頁無縫刷新,即時呈現最新訓練計畫!]
(weekNumber, dayOfWeek) 鍵值進行覆蓋。aiPlan 的元件自動重新渲染!apiService.ts,新增課表調整端點加入專門用來呼叫動態調整的 API 函式adjustMarathonPlan,並定義好包含originalScheduleJson與currentWeek等資訊的 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 診療服務');
}
};
由於 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[] 陣列,方便進行合併。)
現在將按鈕綁定真實的非同步請求。在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);
}
};
這個實作好的地方在於:ScheduleScreen.tsx 不需要改動任何重新拉取邏輯!
因為課表頁面透過 useStore((state) => state.aiPlan) 與 DailyWorkoutList 響應式綁定:
setAiPlan(updatedPlanObj) 的瞬間,Zustand 會觸發廣播。結果如下:
首先是送出我們的狀況
送出後得到
調整的結果
今天完成了 KAKERU 專案中最關鍵的功能—「動態重算與智慧合併」。透過前端帶入 originalScheduleJson,結合後端 Gemini 的推演,以及前端的 Upsert 合併機制,徹底實現了彈性且穩健的 AI 互動體驗。
有了會思考且隨時適應體能的課表後,再來進入我們另一個核心功能!
明天(Day 22)將進入第二階段的最後:多模態 AI 實戰 (Multimodal AI)!我們將打破傳統手動輸入或繁瑣 API 串接的限制,在 APP 中實作「圖片上傳」功能,讓 Gemini Vision 直接解析你的訓練數據截圖或賽後數據,為 KAKERU 注入真正的視覺辨識能力!我們明天見!