摘要
承接 Day 23 的「小後端 + ngrok」,今天把 AI 即時回覆接進來:
- 在後端新增 /gen-quote 代理路由(Server 端安全保存 API Key);
- 前端在使用者按下 「下次吧…」 或 「我有點累…」 時,送出任務與原因給後端;
- 後端呼叫 Gemini 產生一句暖心語錄並回傳到頁面。
這是你第一次讓 AI 即時回應,而且 不把 API Key 暴露在前端(僅開發/教學用途)。
參考:官方 Quickstart 與 API key 指南(避免把金鑰放前端、建議以環境變數保存)。
註:本日僅示範「最小可行」串接,真實產品仍需更嚴格的金鑰限制與安全策略(IP/API 限制、速率限制等)。
btnLater
)與「我有點累…」(btnLater_2
)的事件中呼叫 /gen-quote
,把生成結果顯示在 feedback
或 decisionFeedback
。假設已具備:
註:函式實際命名若與你的專案不同,對應到同職責的「資料存取/頁面切換/小測驗狀態/分支頁 UI」即可
A. 後端:server.js 新增 /gen-quote 路由
保持與 backup.json 同一支伺服器。你只要把以下區塊「加到現有的 server.js」即可。
// [Day24-NEW] 讀取環境變數中的 Gemini API Key(請在 shell 設定 export GEMINI_API_KEY=...)
const GEMINI_API_KEY = process.env.GEMINI_API_KEY;
// [Day24-NEW] 最小規格的輸出清洗(避免傳回過長字串)
function safeOneLine(text = '', max = 140) {
const s = String(text).replace(/\s+/g, ' ').trim();
return s.length > max ? s.slice(0, max - 1) + '…' : s;
}
// [Day24-NEW] 呼叫 Gemini REST API 產生一句暖心語錄
async function generateGentleQuote({ task, reason, tone = 'gentle' }) {
if (!GEMINI_API_KEY) {
throw new Error('SERVER_MISCONFIG: GEMINI_API_KEY is missing');
}
// 官方 REST:/v1beta/models/{model}:generateContent
const endpoint = 'https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent'; // :contentReference[oaicite:6]{index=6}
// 最小 prompt:控制在一句話,口吻溫柔、具體、短句
const userText = [
`你是一位體貼的夥伴,請用一句話鼓勵使用者。`,
`任務: ${task || '(未填)'}`,
`原因: ${reason || '(未填)'}`,
`語氣: ${tone}、務必簡短(不超過 25 字)。`,
].join('\n');
const body = {
contents: [{ parts: [{ text: userText }]}],
generationConfig: {
maxOutputTokens: 64,
temperature: 0.9,
}
// 可視需要加 safetySettings;此處保持預設即可。 :contentReference[oaicite:7]{index=7}
};
const res = await fetch(`${endpoint}?key=${encodeURIComponent(GEMINI_API_KEY)}`, { // :contentReference[oaicite:8]{index=8}
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) {
const errText = await res.text().catch(()=>'');
throw new Error(`GEMINI_HTTP_${res.status}: ${errText.slice(0, 200)}`);
}
const json = await res.json();
const text =
json?.candidates?.[0]?.content?.parts?.[0]?.text ||
json?.candidates?.[0]?.content?.parts?.map(p=>p?.text).filter(Boolean).join(' ') ||
'';
return safeOneLine(text || '今天先照顧自己,明早再輕鬆開始。');
}
// [Day24-NEW] 後端代理路由:前端送任務與原因,伺服器代叫 Gemini
app.post('/gen-quote', async (req, res) => {
try {
const { task, reason, tone } = req.body || {};
if (!task && !reason) {
return res.status(400).json({ ok:false, error: 'INVALID_INPUT', message: '至少需要 task 或 reason' });
}
const line = await generateGentleQuote({ task, reason, tone });
res.json({ ok:true, line });
} catch (e) {
console.error('[gen-quote] failed:', e);
res.status(500).json({ ok:false, error: 'GEN_QUOTE_FAILED' });
}
});
啟動方式(與 Day 23 相同):
export GEMINI_API_KEY="你的_API_Key"
npm run server
ngrok http 3000
取得公開 HTTPS,例如 https://xxxx-xxxx.ngrok-free.app
B. 前端:在「下次吧… / 我有點累…」時呼叫 /gen-quote
只貼 新增或修改 的 JS。以下請加在你原本的 script.js(靠近 Day 22/23 區塊附近最清楚)。
// [Day24-NEW] 你的 ngrok 代理網址(指向本機 server.js):請替換為自己隧道域名
const GEN_QUOTE_ENDPOINT = "https://你的ngrok網址.ngrok-free.app/gen-quote";
// [Day24-NEW] 最小工具:POST 並取回 { ok, line }
async function genQuote(payload, timeoutMs = 12000) {
const ctrl = new AbortController();
const id = setTimeout(() => ctrl.abort('TIMEOUT'), timeoutMs);
try {
const res = await fetch(GEN_QUOTE_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type':'application/json' },
body: JSON.stringify(payload),
signal: ctrl.signal
});
clearTimeout(id);
const data = await res.json().catch(()=> ({}));
if (!res.ok || !data?.ok) throw new Error(data?.message || `HTTP ${res.status}`);
return String(data.line || '');
} finally {
clearTimeout(id);
}
}
// [Day24-NEW] 從表單取得目前任務與原因(若沒填就給空字串)
function getCurrentTaskAndReason() {
const taskInput = document.getElementById('taskInput');
const reasonSelect = document.getElementById('reasonSelect');
return {
task: taskInput?.value?.trim() || '',
reason: reasonSelect?.value || ''
};
}
// [Day24-NEW] 對「下次吧…」(表單區塊)掛載 AI 語錄生成
const btnLater = document.getElementById('btnLater');
const feedback = document.getElementById('feedback');
btnLater?.addEventListener('click', async () => {
const { task, reason } = getCurrentTaskAndReason();
feedback.textContent = '想一想…(AI 正在回覆)';
try {
const line = await genQuote({ task, reason, tone: 'gentle' });
feedback.textContent = line || '先緩一下也很好,等精神回來再開始。';
} catch (err) {
feedback.textContent = '沒關係,你隨時都能回來開始。我會一直在這等你 😊';
}
});
// [Day24-NEW] 對「我有點累…」(條件分支區)掛載 AI 語錄生成
const btnLater_2 = document.getElementById('btnLater_2');
const decisionFeedback = document.getElementById('decisionFeedback');
btnLater_2?.addEventListener('click', async () => {
const { task, reason } = getCurrentTaskAndReason();
decisionFeedback.textContent = '想一想…(AI 正在回覆)';
try {
const line = await genQuote({ task, reason, tone: 'reassuring' });
decisionFeedback.textContent = line || '你已經很努力了,補個眠就是前進。';
} catch {
decisionFeedback.textContent = '別擔心,你隨時都能回來,我一直都在 ✨';
}
});
如果你在 Day 13 的「精神引導選擇」有其他自訂按鈕名稱,一樣可以掛在事件裡呼叫 genQuote() 即可。
本機伺服器與 ngrok:
http://localhost:3000
前端互動:
在表單填入任務+原因 → 按 「下次吧…」 或到條件分支按 「我有點累…」 → 應顯示一句暖心語錄。
中斷測試:
關掉 server 或 ngrok 再按按鈕 → 應看到「伺服器忙碌/網路不穩」等提示,不會卡住。
401 / 403 金鑰錯誤或權限問題
400 / 無法解析
請求 body 結構是否正確(contents[0].parts[0].text)。
前端拿不到回應
輸出太長
範例用 safeOneLine() 壓到 140 字內;可視需要調整 maxOutputTokens 與自訂 prompt。