iT邦幫忙

2026 iThome 鐵人賽

0
AI Engineering

Java × AI:30天打造智慧健康助理系列 第 29 篇

打造智慧健康助理完整網頁

  • 分享至 

  • xImage
  •  

Day 29|打造智慧健康助理完整網頁

回顧昨天,我們終於使用 HTML、CSS 和 JavaScript,替智慧健康助理建立了第一個聊天介面。

原本只能透過 Postman 發送問題,現在已經可以直接在瀏覽器裡和 AI 對話。

不過昨天完成後,我發現還有一件事情沒有做好。

我們之前花了很多天建立健康紀錄功能,但網頁上卻只能聊天!

例如,前面已經完成:

  • BMI 計算與分類
  • 飲水紀錄與統計
  • 睡眠紀錄與統計
  • 每日健康摘要
  • AI 產生每日健康文字摘要
  • AI 多輪對話
  • 聊天紀錄查詢與刪除

這些功能都已經存在 Spring Boot 後端。

但使用者如果想查看今天喝了多少水,還是需要使用 Postman 呼叫 API。

如果想查看之前的聊天,也要手動輸入 conversationId。

這樣好像還不算是一個真正方便使用的健康助理 XD

所以今天決定把這些功能整合到同一個網頁。

今天的目標是:

透過 JavaScript Fetch API,整合健康摘要、AI 文字摘要與歷史聊天紀錄,讓使用者可以在同一個網頁查看健康資料、取得 AI 摘要,以及接續之前的對話。


一、今天要完成什麼功能?

今天會延續 Day 28 的網頁,不需要重新建立 Spring Boot 專案。

預計新增五個功能:

  1. 選擇想查看的健康紀錄日期。
  2. 顯示指定日期的飲水總量。
  3. 顯示指定日期的睡眠總時數。
  4. 點擊按鈕產生 AI 每日健康摘要。
  5. 顯示歷史對話列表,並且可以恢復之前的聊天。

最後的網頁大概會長這樣:

┌─────────────────────────────────────────────────┐
│                                                 │
│              智慧健康助理                       │
│           AI Health Assistant                   │
│                                                 │
├──────────────────┬──────────────────────────────┤
│                  │                              │
│  每日健康摘要    │        AI 健康聊天           │
│                  │                              │
│  日期            │ AI:你好!有什麼想了解的?   │
│  [2026-10-17]    │                              │
│                  │ 使用者:什麼是 BMI?         │
│  飲水量          │                              │
│  1500 ml         │ AI:BMI 是身體質量指數。     │
│                  │                              │
│  睡眠時間        │ 使用者:怎麼計算?           │
│  7.5 小時        │                              │
│                  │ AI:體重除以身高的平方。     │
│ [產生 AI 摘要]   │                              │
│                  │                              │
│  AI 健康摘要     ├──────────────────────────────┤
│  今日已記錄……    │ 請輸入問題……         [送出]  │
│                  │                              │
├──────────────────┴──────────────────────────────┤
│                                                 │
│  歷史對話                                      │
│                                                 │
│  [對話 A]   [對話 B]   [對話 C]                 │
│                                                 │
└─────────────────────────────────────────────────┘

今天會以功能整合為主。

不會使用 React、Vue 或其他前端框架。

因為我希望先透過最基本的 HTML、CSS 和 JavaScript,理解前後端資料是怎麼串接的。


二、先整理今天會用到的 API

在開始寫網頁之前,先回顧之前建立的 API。

功能 HTTP Method API
查詢每日健康摘要 GET /health/summary?date=YYYY-MM-DD
產生 AI 每日健康摘要 GET /health/ai/summary?date=YYYY-MM-DD
AI 多輪對話 POST /health/ai/conversation
查詢所有對話 GET /health/ai/conversations
查詢指定對話 GET /health/ai/conversations/{id}

這些 API 都已經在前面的文章完成。

所以今天主要要做的事情,就是使用 JavaScript 呼叫它們。

例如:

fetch("/health/summary?date=2026-10-17")

就可以取得指定日期的健康資料。

而:

fetch("/health/ai/conversations")

則可以取得之前保存的聊天列表。

今天我們會把這些 API 串接到網頁按鈕和畫面上。


三、今天的前後端架構

目前我們的專案架構可以表示成:

使用者
   ↓
瀏覽器
   │
   ├── HTML
   │
   ├── CSS
   │
   └── JavaScript
           ↓
        Fetch API
           ↓
       Spring Boot
           │
           ├── HealthSummaryController
           │       ↓
           │   HealthSummaryService
           │       ↓
           │   WaterService / SleepService
           │       ↓
           │   H2 Database
           │
           └── AIController
                   │
                   ├── AIHealthSummaryService
                   │       ↓
                   │   AIService
                   │
                   ├── AIConversationService
                   │
                   └── ConversationManagementService
                           ↓
                    ConversationRepository
                           ↓
                       H2 Database

今天最重要的觀念是:

前端不需要知道資料庫怎麼查詢,也不需要知道 AI API 怎麼呼叫。

它只需要透過 HTTP Request,向 Spring Boot 取得資料。

而後端則負責:

資料查詢
資料整理
AI 呼叫
對話保存

這樣就可以讓前後端各自負責不同的工作。


Part 1|修改 HTML 網頁結構

四、重新設計 index.html

昨天的網頁只有聊天區域。

今天準備加入:

每日健康摘要

以及:

歷史對話列表

因此需要修改:

src/main/resources/static/index.html

這次我們直接更新成完整版本。

<!DOCTYPE html>
<html lang="zh-Hant">

<head>
    <meta charset="UTF-8">

    <meta
        name="viewport"
        content="width=device-width, initial-scale=1.0"
    >

    <title>智慧健康助理</title>

    <link rel="stylesheet" href="/style.css">
</head>

<body>

    <div class="app-container">

        <header class="app-header">

            <h1>智慧健康助理</h1>

            <p>AI Health Assistant</p>

        </header>

        <div class="main-layout">

            <!-- 每日健康摘要 -->

            <aside class="health-panel">

                <h2>每日健康摘要</h2>

                <label for="summaryDate">
                    選擇日期
                </label>

                <input
                    type="date"
                    id="summaryDate"
                >

                <button
                    id="loadSummaryButton"
                    type="button"
                >
                    查詢健康紀錄
                </button>

                <div class="health-card">

                    <h3>今日飲水量</h3>

                    <p id="waterValue">
                        尚未查詢
                    </p>

                </div>

                <div class="health-card">

                    <h3>今日睡眠時間</h3>

                    <p id="sleepValue">
                        尚未查詢
                    </p>

                </div>

                <button
                    id="aiSummaryButton"
                    type="button"
                >
                    產生 AI 健康摘要
                </button>

                <div class="ai-summary-card">

                    <h3>AI 健康摘要</h3>

                    <p id="aiSummaryText">
                        點擊按鈕產生摘要。
                    </p>

                </div>

                <p
                    id="summaryStatus"
                    class="status-text"
                    role="status"
                ></p>

            </aside>

            <!-- AI 聊天區 -->

            <section class="chat-panel">

                <div class="chat-header">

                    <h2>AI 健康聊天</h2>

                    <button
                        id="newChatButton"
                        type="button"
                    >
                        新對話
                    </button>

                </div>

                <div
                    id="chatMessages"
                    class="chat-messages"
                    aria-live="polite"
                    aria-relevant="additions"
                >

                    <div class="message assistant">

                        <div class="message-content">
                            你好!我是智慧健康助理。
                            有什麼健康相關問題想了解嗎?
                        </div>

                    </div>

                </div>

                <p
                    id="chatStatus"
                    class="status-text"
                    role="status"
                ></p>

                <form
                    id="chatForm"
                    class="chat-input-area"
                >

                    <input
                        type="text"
                        id="questionInput"
                        placeholder="請輸入你的問題……"
                        maxlength="500"
                        autocomplete="off"
                        aria-label="聊天問題"
                        required
                    >

                    <button
                        id="sendButton"
                        type="submit"
                    >
                        送出
                    </button>

                </form>

            </section>

        </div>

        <!-- 歷史對話 -->

        <section class="history-panel">

            <div class="history-header">

                <h2>歷史對話</h2>

                <button
                    id="refreshHistoryButton"
                    type="button"
                >
                    重新整理
                </button>

            </div>

            <div id="conversationList">

                <p>正在載入對話紀錄……</p>

            </div>

            <p
                id="historyStatus"
                class="status-text"
                role="status"
            ></p>

        </section>

        <footer class="app-footer">

            本系統僅提供一般健康資訊,
            不取代專業醫療診斷或治療。

        </footer>

    </div>

    <script src="/app.js"></script>

</body>
</html>

這次的 HTML 比昨天多了不少內容。

但其實可以分成三個主要區域。


五、認識今天新增的三個區域

1. health-panel

第一個區域是:

<aside class="health-panel">

主要負責顯示每日健康資料。

包含:

日期選擇
飲水總量
睡眠總時數
AI 健康摘要

其中:

<input type="date" id="summaryDate">

可以讓使用者選擇日期。

例如:

2026-10-17

接著點擊:

查詢健康紀錄

就可以透過 API 查詢指定日期的資料。

2. chat-panel

第二個區域是:

<section class="chat-panel">

這裡延續昨天建立的 AI 聊天功能。

包含:

聊天訊息
問題輸入框
送出按鈕
新對話按鈕

所以今天不需要重新建立後端的聊天 API。

只要沿用昨天的 JavaScript 邏輯即可。

3. history-panel

第三個區域是:

<section class="history-panel">

負責顯示歷史對話。

例如:

歷史對話

2026-10-17 14:00
4 則訊息

2026-10-17 14:05
2 則訊息

使用者可以點擊其中一段對話,重新載入之前的聊天內容。

這樣就不用再手動複製 conversationId。


Part 2|使用 CSS 整合網頁版面

六、更新 style.css

接下來要修改昨天建立的:

style.css

因為今天的畫面不再只有聊天視窗,而是多了健康摘要與歷史對話區域。

所以我們使用 CSS Grid 搭配 Flexbox 排版。

將 style.css 更新成以下內容:

* {
    box-sizing: border-box;
}

body {
    margin: 0;
    padding: 24px;

    font-family:
        Arial,
        "Noto Sans TC",
        sans-serif;

    background-color: #f1f7f5;
    color: #333333;
}

button {
    cursor: pointer;
    font-family: inherit;
}

button:disabled {
    opacity: 0.55;
    cursor: not-allowed;
}

.app-container {
    max-width: 1200px;
    margin: 0 auto;

    background-color: white;

    border-radius: 18px;

    overflow: hidden;

    box-shadow:
        0 8px 30px rgba(0, 0, 0, 0.08);
}

.app-header {
    padding: 24px;

    background-color: #4a9b84;
    color: white;

    text-align: center;
}

.app-header h1 {
    margin: 0;
    font-size: 28px;
}

.app-header p {
    margin: 8px 0 0;
    font-size: 14px;
}

.main-layout {
    display: grid;

    grid-template-columns:
        minmax(260px, 1fr)
        minmax(0, 2fr);

    min-height: 600px;
}

.health-panel {
    padding: 24px;

    background-color: #f9fcfb;

    border-right: 1px solid #eeeeee;
}

.health-panel h2 {
    margin-top: 0;
    font-size: 20px;
}

.health-panel label {
    display: block;
    margin-bottom: 8px;
}

.health-panel input {
    width: 100%;

    padding: 10px;

    border: 1px solid #dddddd;
    border-radius: 8px;

    margin-bottom: 12px;
}

.health-panel button {
    width: 100%;

    padding: 12px;

    border: none;
    border-radius: 8px;

    background-color: #4a9b84;
    color: white;

    margin-bottom: 16px;
}

.health-panel button:hover:not(:disabled) {
    background-color: #397c69;
}

.health-card {
    padding: 16px;

    margin-bottom: 14px;

    background-color: white;

    border: 1px solid #e2ece8;
    border-radius: 12px;
}

.health-card h3 {
    margin: 0 0 10px;

    font-size: 15px;
    color: #666666;
}

.health-card p {
    margin: 0;

    font-size: 24px;
    font-weight: bold;

    color: #397c69;
}

.ai-summary-card {
    padding: 16px;

    background-color: #eaf5f0;

    border-radius: 12px;
}

.ai-summary-card h3 {
    margin-top: 0;
    font-size: 16px;
}

.ai-summary-card p {
    margin-bottom: 0;

    line-height: 1.8;
    white-space: pre-wrap;
    overflow-wrap: anywhere;
}

.chat-panel {
    min-width: 0;

    display: flex;
    flex-direction: column;

    height: 600px;
}

.chat-header {
    display: flex;
    justify-content: space-between;
    align-items: center;

    padding: 18px 24px;

    border-bottom: 1px solid #eeeeee;
}

.chat-header h2 {
    margin: 0;
    font-size: 20px;
}

.chat-header button {
    padding: 9px 14px;

    border: 1px solid #4a9b84;
    border-radius: 8px;

    background-color: white;
    color: #397c69;
}

.chat-messages {
    flex: 1;

    padding: 24px;

    overflow-y: auto;

    display: flex;
    flex-direction: column;
    gap: 16px;
}

.message {
    display: flex;
}

.message.user {
    justify-content: flex-end;
}

.message.assistant {
    justify-content: flex-start;
}

.message-content {
    max-width: 75%;

    padding: 12px 16px;

    border-radius: 14px;

    line-height: 1.7;

    white-space: pre-wrap;
    overflow-wrap: anywhere;
}

.message.user .message-content {
    background-color: #d9f0e8;
    color: #244b3f;
}

.message.assistant .message-content {
    background-color: #f1f3f5;
}

.chat-input-area {
    display: flex;
    gap: 10px;

    padding: 16px 20px;

    border-top: 1px solid #eeeeee;
}

.chat-input-area input {
    flex: 1;
    min-width: 0;

    padding: 12px;

    border: 1px solid #dddddd;
    border-radius: 10px;
}

.chat-input-area button {
    padding: 12px 20px;

    border: none;
    border-radius: 10px;

    background-color: #4a9b84;
    color: white;
}

.history-panel {
    padding: 24px;

    border-top: 1px solid #eeeeee;
}

.history-header {
    display: flex;
    justify-content: space-between;
    align-items: center;

    gap: 12px;
    margin-bottom: 16px;
}

.history-header h2 {
    margin: 0;
    font-size: 20px;
}

.history-header button {
    padding: 9px 14px;

    border: 1px solid #4a9b84;
    border-radius: 8px;

    background-color: white;
    color: #397c69;
}

#conversationList {
    display: grid;

    grid-template-columns:
        repeat(auto-fill, minmax(220px, 1fr));

    gap: 12px;
}

.conversation-item {
    padding: 16px;

    text-align: left;

    background-color: #f9fcfb;

    border: 1px solid #e2ece8;
    border-radius: 10px;

    color: #333333;
}

.conversation-item:hover {
    border-color: #4a9b84;
}

.conversation-item.active {
    background-color: #e0f1e9;
    border-color: #4a9b84;
}

.conversation-item strong {
    display: block;
    margin-bottom: 8px;
}

.conversation-item span {
    display: block;

    font-size: 13px;
    color: #666666;
}

.status-text {
    min-height: 20px;

    margin: 8px 20px;

    font-size: 13px;
    color: #777777;
}

.app-footer {
    padding: 18px;

    text-align: center;

    background-color: #fafafa;

    color: #888888;
    font-size: 12px;
}

@media (max-width: 800px) {

    body {
        padding: 0;
    }

    .app-container {
        border-radius: 0;
    }

    .main-layout {
        grid-template-columns: 1fr;
    }

    .health-panel {
        border-right: none;
        border-bottom: 1px solid #eeeeee;
    }

    .chat-panel {
        height: 550px;
    }

    .message-content {
        max-width: 88%;
    }
}

這次 CSS 最主要的改變,是加入:

display: grid;

以及:

grid-template-columns:
    minmax(260px, 1fr)
    minmax(0, 2fr);

這代表桌面版會分成左右兩個區域。

左邊顯示健康摘要。

右邊顯示 AI 聊天。

當螢幕寬度小於 800px 時,則改成上下排列。

這樣在手機上也比較容易閱讀。


Part 3|使用 JavaScript 查詢每日健康資料

七、先確認每日健康摘要 API

還記得 Day 20 嗎?

我們建立了:

GET /health/summary?date=2026-10-17

這個 API 會整合飲水與睡眠紀錄。

例如回傳:

{
  "date": "2026-10-17",
  "water": {
    "totalWater": 1500,
    "unit": "ml",
    "hasRecord": true
  },
  "sleep": {
    "totalHours": 7.5,
    "unit": "hours",
    "hasRecord": true
  }
}

其中:

water.totalWater

代表指定日期的飲水總量。

而:

sleep.totalHours

代表指定日期的睡眠總時數。

另外還有:

hasRecord

用來判斷當天是否有紀錄。

這個欄位很重要。

因為:

沒有紀錄

不代表:

實際飲水量或睡眠時間是 0

所以今天的網頁也會保留這個區別。


八、使用 fetch() 查詢健康摘要

接下來打開:

app.js

今天我們會重新整理 JavaScript,將健康摘要、聊天和歷史對話功能放在同一個檔案。

先看健康摘要的核心程式:

async function loadHealthSummary() {

    const date = summaryDate.value;

    if (!date) {
        summaryStatus.textContent = "請先選擇日期。";
        return;
    }

    summaryStatus.textContent = "正在查詢健康紀錄……";

    try {

        const response = await fetch(
            `/health/summary?date=${encodeURIComponent(date)}`
        );

        if (!response.ok) {
            throw new Error("健康摘要查詢失敗");
        }

        const data = await response.json();

        waterValue.textContent = data.water.hasRecord
            ? `${data.water.totalWater} ml`
            : "當日尚無紀錄";

        sleepValue.textContent = data.sleep.hasRecord
            ? `${data.sleep.totalHours} 小時`
            : "當日尚無紀錄";

        summaryStatus.textContent = "健康紀錄查詢完成。";

    } catch (error) {

        console.error(error);

        summaryStatus.textContent =
            "無法取得健康紀錄,請稍後再試。";
    }
}

這段程式會完成三件事情。

首先取得使用者選擇的日期:

const date = summaryDate.value;

接著呼叫:

fetch(`/health/summary?date=${date}`)

最後根據 API 回傳的 JSON 更新畫面。

例如:

waterValue.textContent = "1500 ml";

以及:

sleepValue.textContent = "7.5 小時";

這樣就可以直接在網頁上看到健康資料。


九、為什麼要檢查 hasRecord?

今天有一個細節不能忽略。

假設使用者當天沒有輸入任何飲水紀錄。

API 可能回傳:

{
  "totalWater": 0,
  "unit": "ml",
  "hasRecord": false
}

如果我們直接顯示:

今日飲水量:0 ml

使用者可能會誤以為自己今天完全沒有喝水。

但實際上只是:

系統沒有紀錄

所以我們使用:

data.water.hasRecord

判斷。

如果是:

true

就顯示飲水總量。

如果是:

false

則顯示:

當日尚無紀錄

睡眠紀錄也是相同概念。

這也是健康資訊系統很重要的一個觀念:

沒有資料,不應該直接解讀成數值為零。


Part 4|整合 AI 每日健康摘要

十、回顧 AI 健康摘要 API

在 Day 23,我們已經建立:

GET /health/ai/summary?date=2026-10-17

這個 API 會先取得指定日期的健康紀錄,再透過 AI 產生自然語言摘要。

例如:

{
  "date": "2026-10-17",
  "summary": "今天已記錄飲水量 1500 ml,睡眠時間 7.5 小時。可以持續記錄,方便日後觀察生活習慣。"
}

今天我們準備在網頁上加入:

產生 AI 健康摘要

按鈕。

使用者點擊後,就可以直接看到 AI 產生的文字。


十一、使用 JavaScript 呼叫 AI 摘要 API

核心程式如下:

async function generateAISummary() {

    const date = summaryDate.value;

    if (!date) {
        summaryStatus.textContent = "請先選擇日期。";
        return;
    }

    aiSummaryButton.disabled = true;

    aiSummaryText.textContent = "AI 正在整理健康紀錄……";

    try {

        const response = await fetch(
            `/health/ai/summary?date=${encodeURIComponent(date)}`
        );

        if (!response.ok) {
            throw new Error("AI 摘要產生失敗");
        }

        const data = await response.json();

        aiSummaryText.textContent = data.summary;

    } catch (error) {

        console.error(error);

        aiSummaryText.textContent =
            "目前無法產生 AI 摘要,請稍後再試。";

    } finally {

        aiSummaryButton.disabled = false;
    }
}

這裡的流程是:

使用者選擇日期
       ↓
點擊產生 AI 摘要
       ↓
JavaScript 呼叫 Spring Boot
       ↓
HealthSummaryService 查詢健康資料
       ↓
AIHealthSummaryService 建立 Prompt
       ↓
AIService 呼叫外部 AI API
       ↓
回傳 AI 文字摘要
       ↓
JavaScript 顯示在網頁上

這樣就完成 AI 健康摘要的前後端整合。

不過要注意:

每次點擊產生 AI 摘要,都可能觸發一次付費的 AI API 呼叫。

所以今天先透過按鈕讓使用者主動產生摘要,而不是每次開啟網頁就自動呼叫 AI。

另外,之前的 AI 摘要 API 使用 GET 觸發模型生成,是教學階段的簡化設計。

正式系統更適合使用 POST,並加入驗證、速率限制和快取機制。


Part 5|整合歷史對話功能

十二、回顧歷史對話 API

在 Day 27,我們建立了:

GET /health/ai/conversations

這個 API 可以查詢所有對話。

例如:

[
  {
    "conversationId": "550e8400-e29b-41d4-a716-446655440000",
    "createdAt": "2026-10-17T14:00:00",
    "messageCount": 4
  },
  {
    "conversationId": "9c1e4b20-2f7a-4c88-a2d3-123456789abc",
    "createdAt": "2026-10-17T14:05:00",
    "messageCount": 2
  }
]

今天要把這些資料顯示在網頁上。

每一段對話都會變成一個可以點擊的項目。

例如:

┌──────────────────────┐
│ 2026/10/17 14:00     │
│ 4 則訊息             │
└──────────────────────┘

點擊後,就會載入完整聊天紀錄。


十三、使用 fetch() 取得對話列表

核心程式如下:

async function loadConversationList() {

    historyStatus.textContent = "正在載入歷史對話……";

    try {

        const response = await fetch(
            "/health/ai/conversations"
        );

        if (!response.ok) {
            throw new Error("歷史對話查詢失敗");
        }

        const conversations = await response.json();

        conversationList.replaceChildren();

        if (conversations.length === 0) {

            const emptyText = document.createElement("p");
            emptyText.textContent = "目前沒有歷史對話。";

            conversationList.appendChild(emptyText);

            historyStatus.textContent = "";
            return;
        }

        conversations.forEach(conversation => {

            const button = document.createElement("button");

            button.type = "button";
            button.className = "conversation-item";

            const title = document.createElement("strong");

            title.textContent =
                formatDateTime(conversation.createdAt);

            const count = document.createElement("span");

            count.textContent =
                `${conversation.messageCount} 則訊息`;

            button.appendChild(title);
            button.appendChild(count);

            button.addEventListener("click", () => {
                loadConversation(conversation.conversationId);
            });

            conversationList.appendChild(button);
        });

        historyStatus.textContent = "";

    } catch (error) {

        console.error(error);

        historyStatus.textContent =
            "無法載入歷史對話。";
    }
}

這裡我們使用:

conversations.forEach()

逐一處理 API 回傳的對話資料。

每一段對話都會建立一個:

<button>

再透過:

addEventListener("click", ...)

設定點擊事件。

當使用者點擊某段對話時,就會呼叫:

loadConversation(conversation.conversationId);

取得完整聊天紀錄。


十四、查詢指定對話的完整訊息

接下來使用 Day 27 的另一個 API:

GET /health/ai/conversations/{id}

例如:

GET /health/ai/conversations/550e8400-e29b-41d4-a716-446655440000

預期回傳:

{
  "conversationId": "550e8400-e29b-41d4-a716-446655440000",
  "messages": [
    {
      "id": 1,
      "role": "user",
      "content": "什麼是 BMI?",
      "createdAt": "2026-10-17T14:00:01"
    },
    {
      "id": 2,
      "role": "assistant",
      "content": "BMI 是身體質量指數。",
      "createdAt": "2026-10-17T14:00:03"
    }
  ]
}

接著我們就可以把這些訊息顯示在聊天視窗中。

核心程式如下:

async function loadConversation(id) {

    try {

        const response = await fetch(
            `/health/ai/conversations/${encodeURIComponent(id)}`
        );

        if (!response.ok) {
            throw new Error("對話查詢失敗");
        }

        const data = await response.json();

        conversationId = data.conversationId;

        chatMessages.replaceChildren();

        data.messages.forEach(message => {

            addMessage(
                message.role,
                message.content
            );
        });

    } catch (error) {

        console.error(error);

        historyStatus.textContent =
            "無法載入指定對話。";
    }
}

這樣就可以把資料庫中的聊天紀錄重新顯示在網頁上。

而且因為:

conversationId = data.conversationId;

所以使用者接著輸入新問題時,仍然會使用同一個對話 ID。

也就是說:

不只能查看舊對話,還可以直接接續聊天!


Part 6|整合完整 app.js

十五、將所有功能放進同一個 JavaScript 檔案

前面已經分別介紹:

健康摘要查詢
AI 健康摘要
歷史對話列表
指定對話查詢

接下來我們要把這些功能,和昨天的聊天功能整合在一起。

請將:

src/main/resources/static/app.js

更新成以下完整版本。

這份程式碼包含今天所有功能,可以直接搭配前面的 HTML 和 CSS 使用。

// ===============================
// API URLs
// ===============================

const CHAT_API = "/health/ai/conversation";
const HISTORY_API = "/health/ai/conversations";
const HEALTH_SUMMARY_API = "/health/summary";
const AI_SUMMARY_API = "/health/ai/summary";


// ===============================
// HTML Elements
// ===============================

const summaryDate =
    document.getElementById("summaryDate");

const loadSummaryButton =
    document.getElementById("loadSummaryButton");

const aiSummaryButton =
    document.getElementById("aiSummaryButton");

const waterValue =
    document.getElementById("waterValue");

const sleepValue =
    document.getElementById("sleepValue");

const aiSummaryText =
    document.getElementById("aiSummaryText");

const summaryStatus =
    document.getElementById("summaryStatus");

const chatForm =
    document.getElementById("chatForm");

const questionInput =
    document.getElementById("questionInput");

const sendButton =
    document.getElementById("sendButton");

const newChatButton =
    document.getElementById("newChatButton");

const chatMessages =
    document.getElementById("chatMessages");

const chatStatus =
    document.getElementById("chatStatus");

const conversationList =
    document.getElementById("conversationList");

const refreshHistoryButton =
    document.getElementById("refreshHistoryButton");

const historyStatus =
    document.getElementById("historyStatus");


// ===============================
// State
// ===============================

let conversationId = null;
let isChatLoading = false;
let isConversationLoading = false;


// ===============================
// Helper Functions
// ===============================

function formatDateTime(value) {

    if (!value) {
        return "未知時間";
    }

    return value.replace("T", " ").slice(0, 16);
}

function addMessage(role, content) {

    const messageDiv = document.createElement("div");

    messageDiv.classList.add("message", role);

    const contentDiv = document.createElement("div");

    contentDiv.classList.add("message-content");

    contentDiv.textContent = content;

    messageDiv.appendChild(contentDiv);

    chatMessages.appendChild(messageDiv);

    chatMessages.scrollTop = chatMessages.scrollHeight;
}

function setChatLoading(loading) {

    isChatLoading = loading;

    sendButton.disabled = loading;
    questionInput.disabled = loading;
    newChatButton.disabled = loading;

    chatStatus.textContent = loading
        ? "AI 正在思考中……"
        : "";
}

function setConversationLoading(loading) {

    isConversationLoading = loading;

    refreshHistoryButton.disabled = loading;
    newChatButton.disabled = loading || isChatLoading;
    sendButton.disabled = loading || isChatLoading;
    questionInput.disabled = loading || isChatLoading;
}


// ===============================
// Daily Health Summary
// ===============================

async function loadHealthSummary() {

    const date = summaryDate.value;

    if (!date) {
        summaryStatus.textContent = "請先選擇日期。";
        return;
    }

    loadSummaryButton.disabled = true;

    summaryStatus.textContent = "正在查詢健康紀錄……";

    try {

        const response = await fetch(
            `${HEALTH_SUMMARY_API}?date=${encodeURIComponent(date)}`
        );

        if (!response.ok) {
            throw new Error("健康摘要查詢失敗");
        }

        const data = await response.json();

        waterValue.textContent = data.water.hasRecord
            ? `${data.water.totalWater} ml`
            : "當日尚無紀錄";

        sleepValue.textContent = data.sleep.hasRecord
            ? `${data.sleep.totalHours} 小時`
            : "當日尚無紀錄";

        summaryStatus.textContent = "健康紀錄查詢完成。";

    } catch (error) {

        console.error(error);

        waterValue.textContent = "查詢失敗";
        sleepValue.textContent = "查詢失敗";

        summaryStatus.textContent =
            "無法取得健康紀錄,請稍後再試。";

    } finally {

        loadSummaryButton.disabled = false;
    }
}


// ===============================
// AI Daily Summary
// ===============================

async function generateAISummary() {

    const date = summaryDate.value;

    if (!date) {
        summaryStatus.textContent = "請先選擇日期。";
        return;
    }

    aiSummaryButton.disabled = true;

    aiSummaryText.textContent = "AI 正在整理健康紀錄……";

    try {

        const response = await fetch(
            `${AI_SUMMARY_API}?date=${encodeURIComponent(date)}`
        );

        if (!response.ok) {
            throw new Error("AI 摘要產生失敗");
        }

        const data = await response.json();

        aiSummaryText.textContent = data.summary;

    } catch (error) {

        console.error(error);

        aiSummaryText.textContent =
            "目前無法產生 AI 摘要,請稍後再試。";

    } finally {

        aiSummaryButton.disabled = false;
    }
}


// ===============================
// AI Chat
// ===============================

async function sendQuestion(question) {

    if (isChatLoading || isConversationLoading) {
        return;
    }

    setChatLoading(true);

    addMessage("user", question);

    const requestBody = {
        question: question
    };

    if (conversationId !== null) {
        requestBody.conversationId = conversationId;
    }

    try {

        const response = await fetch(CHAT_API, {

            method: "POST",

            headers: {
                "Content-Type": "application/json"
            },

            body: JSON.stringify(requestBody)
        });

        if (!response.ok) {
            throw new Error(
                "聊天 API 請求失敗:" + response.status
            );
        }

        const data = await response.json();

        if (!data.conversationId || !data.answer) {
            throw new Error("聊天 API 回傳格式不正確");
        }

        conversationId = data.conversationId;

        addMessage("assistant", data.answer);

        await loadConversationList();

    } catch (error) {

        console.error(error);

        addMessage(
            "assistant",
            "抱歉,目前無法取得回答,請稍後再試。"
        );

    } finally {

        setChatLoading(false);

        questionInput.focus();
    }
}


// ===============================
// Conversation List
// ===============================

async function loadConversationList() {

    historyStatus.textContent = "正在載入歷史對話……";

    try {

        const response = await fetch(HISTORY_API);

        if (!response.ok) {
            throw new Error("歷史對話查詢失敗");
        }

        const conversations = await response.json();

        conversationList.replaceChildren();

        if (conversations.length === 0) {

            const emptyText = document.createElement("p");

            emptyText.textContent = "目前沒有歷史對話。";

            conversationList.appendChild(emptyText);

            historyStatus.textContent = "";
            return;
        }

        conversations.forEach(conversation => {

            const button = document.createElement("button");

            button.type = "button";

            button.className = "conversation-item";

            if (conversation.conversationId === conversationId) {
                button.classList.add("active");
            }

            const title = document.createElement("strong");

            title.textContent =
                formatDateTime(conversation.createdAt);

            const count = document.createElement("span");

            count.textContent =
                `${conversation.messageCount} 則訊息`;

            button.appendChild(title);
            button.appendChild(count);

            button.addEventListener("click", () => {

                if (isChatLoading || isConversationLoading) {
                    return;
                }

                loadConversation(conversation.conversationId);
            });

            conversationList.appendChild(button);
        });

        historyStatus.textContent = "";

    } catch (error) {

        console.error(error);

        historyStatus.textContent =
            "無法載入歷史對話。";
    }
}


// ===============================
// Load Conversation
// ===============================

async function loadConversation(id) {

    if (isChatLoading || isConversationLoading) {
        return;
    }

    setConversationLoading(true);

    historyStatus.textContent = "正在載入對話……";

    try {

        const response = await fetch(
            `${HISTORY_API}/${encodeURIComponent(id)}`
        );

        if (!response.ok) {
            throw new Error("指定對話查詢失敗");
        }

        const data = await response.json();

        conversationId = data.conversationId;

        chatMessages.replaceChildren();

        data.messages.forEach(message => {

            addMessage(
                message.role,
                message.content
            );
        });

        historyStatus.textContent = "";

        await loadConversationList();

    } catch (error) {

        console.error(error);

        historyStatus.textContent =
            "無法載入指定對話。";

    } finally {

        setConversationLoading(false);

        questionInput.focus();
    }
}


// ===============================
// Event Listeners
// ===============================

loadSummaryButton.addEventListener(
    "click",
    loadHealthSummary
);

aiSummaryButton.addEventListener(
    "click",
    generateAISummary
);

refreshHistoryButton.addEventListener(
    "click",
    loadConversationList
);

chatForm.addEventListener("submit", function (event) {

    event.preventDefault();

    if (isChatLoading || isConversationLoading) {
        return;
    }

    const question = questionInput.value.trim();

    if (question === "") {
        return;
    }

    if (question.length > 500) {

        alert("問題不能超過 500 個字元。");
        return;
    }

    questionInput.value = "";

    sendQuestion(question);
});

newChatButton.addEventListener("click", function () {

    if (isChatLoading || isConversationLoading) {
        return;
    }

    conversationId = null;

    chatMessages.replaceChildren();

    addMessage(
        "assistant",
        "已開始新對話!你可以輸入新的健康相關問題。"
    );

    loadConversationList();

    questionInput.focus();
});


// ===============================
// Initialize
// ===============================

function getTodayLocalDate() {

    const now = new Date();

    const year = now.getFullYear();

    const month = String(
        now.getMonth() + 1
    ).padStart(2, "0");

    const day = String(
        now.getDate()
    ).padStart(2, "0");

    return `${year}-${month}-${day}`;
}

summaryDate.value = getTodayLocalDate();

loadHealthSummary();

loadConversationList();

這樣我們就把今天的所有功能整合完成了!


十六、為什麼需要管理 JavaScript State?

今天的程式裡面有三個重要變數:

let conversationId = null;
let isChatLoading = false;
let isConversationLoading = false;

其中:

conversationId

代表目前正在使用的對話。

而:

isChatLoading

代表 AI 是否正在回覆。

最後:

isConversationLoading

代表是否正在載入歷史對話。

為什麼需要這些變數?

假設使用者正在等待 AI 回答時,突然點擊另一段歷史對話。

如果沒有控制好,可能發生:

使用者送出問題
       ↓
AI 正在產生回答
       ↓
使用者切換對話
       ↓
舊對話的 AI 回答出現
       ↓
畫面內容混在一起

所以今天先透過:

isChatLoading

以及:

isConversationLoading

避免在請求進行時切換對話。

這是一個簡單的處理方式。

正式系統還可以使用 AbortController、請求識別碼,以及更完整的狀態管理,避免非同步請求造成畫面不一致。


Part 7|使用瀏覽器測試完整功能

十七、啟動 Spring Boot

接下來確認今天修改的三個檔案:

src/main/resources/static
│
├── index.html
├── style.css
└── app.js

都已經儲存。

然後重新啟動 Spring Boot。

如果使用 Maven:

./mvnw spring-boot:run

Windows 可以使用:

.\mvnw.cmd spring-boot:run

接著打開:

http://localhost:8080/

如果一切正常,就可以看到今天整合後的智慧健康助理網頁。


十八、測試每日健康摘要

首先選擇日期:

2026-10-17

再點擊:

查詢健康紀錄

如果當天已經有資料,畫面可能顯示:

今日飲水量
1500 ml

以及:

今日睡眠時間
7.5 小時

如果當天沒有紀錄,則會顯示:

當日尚無紀錄

這樣就完成第一個測試。

如果資料庫還沒有測試資料,可以先透過之前的飲水與睡眠 API 新增紀錄。


十九、測試 AI 每日健康摘要

接下來點擊:

產生 AI 健康摘要

畫面會先顯示:

AI 正在整理健康紀錄……

接著等待 Spring Boot 呼叫 AI API。

如果成功,可能看到:

今天已記錄飲水量 1500 ml,
睡眠時間 7.5 小時。

可以持續記錄每天的生活習慣,
方便之後觀察變化。

實際回覆會依照模型輸出而不同。

這樣就代表我們成功把 Day 23 的 AI 健康摘要功能整合到網頁!


二十、測試 AI 聊天功能

接著在右邊的聊天區輸入:

什麼是 BMI?

按下送出。

預期會先看到:

使用者:什麼是 BMI?

接著:

AI 正在思考中……

最後顯示 AI 回答。

接著再輸入:

那它怎麼計算?

如果多輪對話功能正常,AI 就可以根據前面的問題理解:

它

指的是:

BMI

這樣就完成聊天功能測試。


二十一、測試歷史對話列表

接下來往下捲動到:

歷史對話

這個區域。

應該可以看到剛剛建立的對話。

例如:

2026-10-17 14:00
4 則訊息

接著點擊其中一段對話。

網頁就會呼叫:

GET /health/ai/conversations/{id}

並重新顯示之前的聊天紀錄。

例如:

User:什麼是 BMI?
AI:BMI 是身體質量指數。

User:那它怎麼計算?
AI:體重除以身高的平方。

這樣就完成歷史對話載入功能。


二十二、測試恢復舊對話後繼續聊天

這是今天很重要的一個測試。

首先建立一段新對話:

User:什麼是 BMI?
AI:BMI 是身體質量指數。

接著按下:

新對話

建立另一段聊天。

例如:

User:為什麼規律作息很重要?
AI:規律作息有助於維持穩定的生活節奏。

接下來在歷史對話列表中,點擊第一段 BMI 對話。

畫面就會重新載入:

User:什麼是 BMI?
AI:BMI 是身體質量指數。

這時候再輸入:

可以給我一個計算範例嗎?

因為 JavaScript 已經更新:

conversationId = data.conversationId;

所以這個問題會接續原本的 BMI 對話。

而不是接續剛剛的睡眠對話。

這樣就代表:

歷史對話不只能查看,也能重新接續!


二十三、重新整理網頁後還能找到聊天嗎?

昨天我們提到:

conversationId

只保存在 JavaScript 記憶體中。

因此重新整理網頁後:

conversationId = null;

目前的聊天狀態就會消失。

不過今天我們已經整合:

GET /health/ai/conversations

所以即使重新整理網頁,也可以重新載入歷史對話列表。

接著點擊之前的對話,就能恢復聊天紀錄。

這樣就不需要額外把完整聊天內容保存到瀏覽器裡。

因為真正的資料仍然保存在 H2 Database。


Part 8|今天的整合還有哪些限制?

二十四、健康摘要不代表完整的健康評估

今天網頁顯示:

飲水量
睡眠時間

但這些只是使用者自行輸入的紀錄。

例如:

1500 ml

不一定代表使用者當天所有的實際飲水量。

同樣地:

7.5 小時

也不能單獨用來判斷睡眠品質。

因此 AI 摘要只能協助整理紀錄,不能直接當成醫療評估結果。


二十五、目前仍然沒有登入與權限管理

今天整合的:

GET /health/ai/conversations

會回傳資料庫中的所有對話。

目前我們仍然以單人本機練習為前提。

如果未來公開部署,就不能讓所有人都查看相同的聊天紀錄。

正式系統至少需要:

使用者登入
       ↓
驗證身分
       ↓
確認資料所有權
       ↓
只回傳自己的健康資料與對話

這部分會是未來很重要的改進方向。


二十六、AI 摘要與聊天功能仍然可能產生費用

今天的網頁有兩個地方會呼叫外部 AI API。

第一個:

產生 AI 健康摘要

第二個:

AI 健康聊天

每次呼叫都可能產生 API 使用費用。

所以正式系統需要考慮:

API 使用次數限制
使用量監控
快取機制

以及:

錯誤重試策略

避免不必要的重複請求。


二十七、今天尚未加入新增健康紀錄表單

雖然今天可以在網頁查看:

飲水紀錄

以及:

睡眠紀錄

但目前還沒有提供新增資料的表單。

也就是說,使用者仍然需要透過之前的 API 新增健康紀錄。

未來可以在網頁上加入:

新增飲水紀錄
新增睡眠紀錄

以及:

BMI 計算表單

讓健康紀錄功能更加完整。

今天先以整合查詢與 AI 功能為主。


二十八、目前的專案結構

今天沒有新增 Java 類別。

主要修改:

index.html
style.css
app.js

目前的專案架構如下:

health-assistant
│
├── src/main/java/com/example/healthassistant
│   │
│   ├── HealthAssistantApplication.java
│   │
│   ├── controller
│   │   ├── HealthController.java
│   │   ├── WaterController.java
│   │   ├── SleepController.java
│   │   ├── HealthSummaryController.java
│   │   └── AIController.java
│   │
│   ├── service
│   │   ├── BmiService.java
│   │   ├── WaterService.java
│   │   ├── SleepService.java
│   │   ├── HealthSummaryService.java
│   │   ├── AIService.java
│   │   ├── AIHealthSummaryService.java
│   │   ├── AIChatService.java
│   │   ├── AIConversationService.java
│   │   ├── ConversationPersistenceService.java
│   │   └── ConversationManagementService.java
│   │
│   ├── repository
│   │   ├── WaterRepository.java
│   │   ├── SleepRepository.java
│   │   └── ConversationRepository.java
│   │
│   ├── dto
│   │   ├── HealthSummaryResponse.java
│   │   ├── AIHealthSummaryResponse.java
│   │   ├── ConversationRequest.java
│   │   ├── ConversationResponse.java
│   │   ├── ConversationMessage.java
│   │   ├── ConversationListResponse.java
│   │   └── ConversationDetailResponse.java
│   │
│   └── exception
│       ├── GlobalExceptionHandler.java
│       └── ConversationNotFoundException.java
│
├── src/main/resources
│   │
│   ├── application.properties
│   ├── schema.sql
│   │
│   └── static
│       ├── index.html
│       ├── style.css
│       └── app.js
│
└── pom.xml

這裡只列出部分 DTO,其他之前建立的類別仍然保留。

目前智慧健康助理的主要功能,已經可以透過同一個網頁操作。


Day 29 小結

今天成功把之前建立的健康紀錄與 AI 功能整合到同一個網頁!

原本我們有很多分散的 API。

例如:

健康摘要 API
AI 摘要 API
AI 聊天 API
歷史對話 API

雖然各自都可以正常使用,但對一般使用者來說,操作起來並不方便。

今天透過 HTML、CSS 和 JavaScript,終於把這些功能整合起來。

今天完成的功能包括:

  1. 重新設計智慧健康助理網頁
  2. 使用 CSS Grid 建立左右分欄
  3. 建立日期選擇功能
  4. 使用 Fetch API 查詢每日健康摘要
  5. 顯示飲水量與睡眠時間
  6. 正確區分沒有紀錄與數值為零
  7. 整合 AI 每日健康文字摘要
  8. 顯示歷史對話列表
  9. 查詢指定對話的完整訊息
  10. 恢復之前的聊天紀錄
  11. 接續歷史對話
  12. 加入基本的非同步狀態控制
  13. 測試前端、後端與資料庫整合流程

現在智慧健康助理已經具備:

智慧健康助理
│
├── 健康紀錄功能
│   ├── BMI 計算與分類
│   ├── 飲水紀錄
│   ├── 睡眠紀錄
│   └── 每日健康摘要
│
├── AI 功能
│   ├── AI 健康知識問答
│   ├── AI 每日健康摘要
│   ├── AI 多輪對話
│   └── AI 對話紀錄管理
│
├── 資料庫
│   ├── 飲水紀錄
│   ├── 睡眠紀錄
│   └── AI 聊天紀錄
│
└── 網頁介面
    ├── 每日健康摘要查詢
    ├── AI 健康摘要產生
    ├── AI 聊天
    ├── 歷史對話列表
    └── 恢復歷史對話

今天最大的收穫是:

開發一個應用程式,不只是把功能分別完成,更重要的是讓這些功能可以整合在一起,形成使用者真正能操作的流程。

從最初只會使用 Postman 測試 Spring Boot API,到現在可以直接在瀏覽器查看健康紀錄、產生 AI 摘要,以及接續歷史聊天,真的很有成就感。

雖然目前還有很多可以改善的地方,但至少已經完成一個具備前端、後端、資料庫與 AI 串接功能的智慧健康助理原型!


明日預告

Day 30|30 天挑戰最終章!智慧健康助理成果展示、完整測試與開發心得

從 Day 1 開始,我們一路學習 Java、Spring Boot、REST API、H2 Database,再到生成式 AI API 與前端網頁整合。

原本只是想做一個簡單的健康紀錄工具,沒想到最後真的一步一步完成了智慧健康助理的基本原型。

最後一天,我準備回顧整個專案的開發過程。

包含:

  • 30 天開發歷程回顧
  • 智慧健康助理完整功能整理
  • 專案架構總覽
  • API 功能測試清單
  • 從 Java 初學者角度整理學習心得
  • 開發過程遇到的問題
  • 目前系統仍然存在的限制
  • 未來可以繼續擴充的功能

也會整理這 30 天最大的收穫。

Day 30,我們明天見!


上一篇
用 HTML、CSS 和 JavaScript 打造智慧健康助理聊天介面
下一篇
智慧健康助理成果展示、完整測試與開發心得
系列文
Java × AI:30天打造智慧健康助理 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言