iT邦幫忙

2025 iThome 鐵人賽

DAY 24
0
Modern Web

Modern Web × AI《拖延怪日記》:語錄陪伴擺脫拖延系列 第 24

【Day 24】— 入門 JavaScript 網頁架設:串接 Gemini 生成語錄

  • 分享至 

  • xImage
  •  

摘要
承接 Day 23 的「小後端 + ngrok」,今天把 AI 即時回覆接進來:

  1. 在後端新增 /gen-quote 代理路由(Server 端安全保存 API Key);
  2. 前端在使用者按下 「下次吧…」 或 「我有點累…」 時,送出任務與原因給後端;
  3. 後端呼叫 Gemini 產生一句暖心語錄並回傳到頁面。

這是你第一次讓 AI 即時回應,而且 不把 API Key 暴露在前端(僅開發/教學用途)。
參考:官方 Quickstart 與 API key 指南(避免把金鑰放前端、建議以環境變數保存)。

為什麼要用「後端代理」再叫 Gemini?

  • 安全:API Key 不出現在瀏覽器原始碼或網路請求中(避免被存取紀錄、瀏覽器外掛或 DevTools 抓到)。
  • 一致:延續 Day 23 架構(Express + ngrok),只要再加一條路由即可。
  • 可控:在後端統一加入安全過濾、內容長度限制、錯誤處理與日誌。

註:本日僅示範「最小可行」串接,真實產品仍需更嚴格的金鑰限制與安全策略(IP/API 限制、速率限制等)。

學習重點

  1. API key 基本概念(僅開發環境):使用環境變數 GEMINI_API_KEY,不要硬寫在前端。
  2. fetch POST JSON → 回應處理:前端把 { task, reason } 傳給後端,後端再呼叫 Gemini /generateContent 並回傳一句話。

核心流程

  1. 後端(沿用 Day 23 的 server.js):
    新增 /gen-quote 路由:讀取 task、reason、tone,呼叫 Gemini 模型(建議 gemini-1.5-flash)並回傳一句暖心建議。
  2. 前端:
    在「下次吧…」(btnLater)與「我有點累…」(btnLater_2)的事件中呼叫 /gen-quote,把生成結果顯示在 feedbackdecisionFeedback

實作

假設已具備:

  • Day 4–5 → readRecords() / writeRecords()(基礎資料存取;用於讀取目前任務內容與原因)
  • Day 12 → showPage()(頁面切換骨架;recordFormSection/historySection 等區塊已就緒)
  • Day 13 → 表單送出與精神引導區結構(motivationSection、#btnLater、#feedback;並以 type="button" 避免整頁刷新)
  • Day 14 → 精神狀態小測驗狀態的讀寫與回饋(如有實作:loadMoodState() / saveMoodState() / updateMoodFeedback();對 Day 24 只是沿用頁面與資料)
  • Day 15 → 條件分支頁與互動元件(moodDecisionSection、#btnLater_2、#decisionFeedback、#todayRecap)
  • Day 16 → 任務卡渲染與安排流程(如有實作:renderTaskCard() / scheduleTask();本日僅延續整體動線)
  • Day 22 → 匯出工具與 UI(exportJSON() / exportCSV();與本日並存於歷史頁)
  • Day 23 → 既有 Express 後端 + CORS + ngrok 架構與 fetch 範式(例如 postJSONWithTimeout() 的逾時處理思路;本日新增 /gen-quote 代理路由沿用同一台 server)

註:函式實際命名若與你的專案不同,對應到同職責的「資料存取/頁面切換/小測驗狀態/分支頁 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 相同):

  1. 設定環境變數(macOS / Linux)
export GEMINI_API_KEY="你的_API_Key"
  1. 啟動本機伺服器
npm run server
  1. 另外一個終端機:開 ngrok
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() 即可。

驗證

  1. 本機伺服器與 ngrok:

    • npm run server 能看到 listening on http://localhost:3000
    • curl https://你的ngrok/gen-quote -d '{"task":"洗衣服","reason":"tired"}' -H 'content-type: application/json'
      → 應回 { ok: true, line: "..." }
  2. 前端互動:
    在表單填入任務+原因 → 按 「下次吧…」 或到條件分支按 「我有點累…」 → 應顯示一句暖心語錄。

  3. 中斷測試:
    關掉 server 或 ngrok 再按按鈕 → 應看到「伺服器忙碌/網路不穩」等提示,不會卡住。

常見錯誤 & 排查

  1. 401 / 403 金鑰錯誤或權限問題

    • 是否在 後端 設了 export GEMINI_API_KEY=...?重新開機或新開終端機要再設定。
    • 金鑰是否有效、是否有使用限制(建議設限制但注意不要擋到本機 IP 測試)。
  2. 400 / 無法解析
    請求 body 結構是否正確(contents[0].parts[0].text)。

  3. 前端拿不到回應

    • GEN_QUOTE_ENDPOINT 是否換成你的 ngrok 網址?
    • CORS:本範例由「後端 → 外網(ngrok)」回前端,已由 app.use(cors()) 放行。
  4. 輸出太長
    範例用 safeOneLine() 壓到 140 字內;可視需要調整 maxOutputTokens 與自訂 prompt。

安全與實務提醒

  • 不要把 API Key 放前端或提交到 Git;改用環境變數與後端代理存取。
  • 對金鑰施加限制(HTTP 參照來源/IP/特定 API)。
  • 正式環境請加:速率限制、輸入驗證、內容審核、安全日誌;必要時調整 Safety Settings。

上一篇
【Day 23】— 入門 JavaScript 網頁架設:ngrok + backup.json 簡易雲端備份
下一篇
【Day 25】— 入門 JavaScript 網頁架設:AI 語錄清單
系列文
Modern Web × AI《拖延怪日記》:語錄陪伴擺脫拖延26
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言