# AI人工智慧導論期末專題
AI agent 期末專題進度: https://hackmd.io/@moolimuli/SkAElaGMbg/edit
## :triangular_flag_on_post: 25/12/07 Update:
:::info
1. 頭像圖片自己下載,到 **js** 改頭像檔案
2. 背景圖片
:::spoiler 圖檔

:::
4. 字體載點:https://www.ziti.net.cn/mianfeiziti/955.html
- 新增font資料夾,ttf檔案放入
:::spoiler 示意圖

:::
- ttf檔案更名 **GenWanMinTWTTFSemiBold**
5. 頭像圖檔
:::spoiler 用戶頭像

:::
:::spoiler kamisama

:::
:::
### html
:::spoiler html
```html=
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>AI 小幫手 - 戀愛急診室</title>
<link rel="icon" href="data:,">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="{{url_for('static', filename='css/style.css')}}">
</head>
<body>
<header>
戀愛急診室
</header>
<div class="scrollable-container" id="dialog-div">
</div>
<div class="input-controls">
<input type="text" id="message" name="message" placeholder="輸入你的訊息..." autocomplete="off">
<button id="submit" type="button">傳送</button>
</div>
<script src="//unpkg.com/jquery"></script>
<script src="{{url_for('static', filename='js/main.js')}}"></script>
</body>
</html>
```
:::
---
### css
:::spoiler css
```css=
/* =========================================
全局樣式設定
========================================= */
html, body {
height: 100%; /* 強制高度為視窗高度 */
margin: 0;
padding: 0;
overflow: hidden; /* 關鍵:禁止整個網頁出現捲軸 */
}
body {
font-family: 'Microsoft JhengHei', Arial, sans-serif;
display: flex;
flex-direction: column; /* 垂直排列:標題 -> 對話框 -> 輸入框 */
align-items: center;
/* 背景設定 */
background-image: url('../images/back.jpg');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
/* 讓背景淡入 */
animation: fadeIn 1s ease-out;
}
body::before {
content: '';
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(154, 32, 62, 0.2);
z-index: 0;
pointer-events: none;
mix-blend-mode: multiply;
}
/* =========================================
字型載入
========================================= */
@font-face {
font-family: 'GenWanMin';
src: url('../font/GenWanMinTWTTFSemiBold.ttf') format('truetype');
font-style: normal;
}
/* =========================================
標題樣式
========================================= */
header {
padding: 30px 0;
font-family: 'GenWanMin', serif;
font-size: 4rem;
font-weight: 600;
letter-spacing: 0.15em;
z-index: 1;
text-shadow: 2px 2px 4px rgba(0,0,0,0.1);
color : #73242A;
flex-shrink: 0;
}
/* =========================================
聊天視窗容器樣式
========================================= */
/* --- 聊天視窗容器 --- */
.scrollable-container {
/* 佈局核心設定 */
flex: 1; /* 關鍵:自動佔滿剩餘空間 */
min-height: 0; /* 關鍵:允許容器縮小,否則內容多時會撐爆 Flex 容器 */
width: 900px;
max-width: 95vw;
/* 外距調整:上下留一點空間 */
margin: 0px auto 20px auto;
padding: 30px;
box-sizing: border-box;
/* 內部捲動設定 */
overflow-y: auto; /* 內容超出時,只有這裡會捲動 */
overflow-x: hidden;
/* 外觀維持不變 */
border: none;
border-radius: 20px;
background: rgba(255, 255, 255, 0.4);
backdrop-filter: blur(25px);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
position: relative;
z-index: 1;
animation: fadeIn 1.2s ease-out;
}
/* =========================================
訊息行樣式
========================================= */
/* --- 訊息行通用樣式 --- */
.chat-row {
display: flex;
align-items: flex-start;
margin-bottom: 25px;
width: 100%;
}
.chat-row img {
width: 55px; /* 稍微加大頭像 */
height: 55px;
border-radius: 50%;
object-fit: cover;
border: 3px solid rgba(233, 177, 188, 0.5);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
flex-shrink: 0;
}
/* --- 訊息氣泡通用樣式 --- */
.msg-content {
padding: 14px 20px;
border-radius: 20px;
font-size: 16px;
line-height: 1.25;
position: relative;
max-width: calc(100% - 150px);
width: fit-content;
word-break: break-word;
/* white-space: pre-wrap; */
/* 3. 確保文字靠左 */
text-align: left;
}
/* --- AI (左側) --- */
.ai-row {
justify-content: flex-start;
}
.ai-row .msg-content {
margin-left: 18px;
margin-top: 10px;
background: rgba(240, 227, 206, 0.75); /* 稍微不透明一點以確保閱讀 */
border: 2px solid #EBB471;
box-shadow: 0 7px 15px rgba(218, 165, 32, 0.15);
border-top-left-radius: 4px;
color: #5a4a42; /* 深咖啡色字體 */
}
.ai-row .msg-content::before {
display: block;
font-size: 12px;
color: #d4a017;
font-weight: bold;
margin-bottom: 6px;
}
/* --- User (右側) --- */
.user-row {
justify-content: flex-end;
}
.user-row .msg-content {
margin-right: 18px;
margin-top: 10px;
background: rgba(240, 217, 214, 0.75);
border: 2px solid rgba(212, 66, 95, 0.5);
box-shadow: 0 7px 15px rgba(255, 105, 180, 0.15);
border-top-right-radius: 4px;
color: #5a4a42;
}
.user-row .msg-content::before {
display: none; /* 使用者通常不需要顯示自己的名字 */
}
/* =========================================
輸入框區域 (整合式膠囊設計)
========================================= */
/* 1. 容器變成大膠囊:負責背景、邊框、陰影 */
.input-controls {
display: flex;
align-items: center;
width: 900px; /* 配合上方對話框寬度 */
max-width: 95vw;
margin-bottom: 30px;
flex-shrink: 0;
/* 膠囊外觀核心 */
background: rgba(255, 255, 255, 0.6); /* 通透的半透明白 */
backdrop-filter: blur(10px); /* 毛玻璃效果 */
border: 1px solid rgba(255, 255, 255, 0.8); /* 極細的白邊框 */
border-radius: 50px; /* 大圓角,形成膠囊狀 */
padding: 6px 8px 6px 20px; /* 右側留少一點給按鈕,左側留多一點給文字 */
/* 陰影與層次 */
box-shadow: 0 8px 32px rgba(31, 38, 135, 0.07);
/* 動畫設定 (保留原本的浮現效果) */
animation: slideUp 0.8s ease-out 0.5s both;
/* 互動過渡 */
transition: all 0.3s ease;
box-sizing: border-box;
z-index: 2; /* 確保浮在背景上 */
}
/* 當使用者點擊輸入框時,讓整個容器發光 (Focus-within) */
.input-controls:focus-within {
background: rgba(255, 255, 255, 0.85);
box-shadow: 0 8px 32px rgba(255, 105, 180, 0.15); /* 淡淡的粉色光暈 */
transform: translateY(-2px);
border-color: #fff;
}
/* 2. 輸入框變成透明內容:負責文字輸入 */
#message {
flex-grow: 1; /* 佔滿剩餘空間 */
height: 40px; /* 高度適中 */
background: transparent; /* 關鍵:透明背景 */
border: none; /* 關鍵:移除邊框 */
outline: none; /* 移除點擊時的黑框 */
padding: 0; /* 內距交給容器處理 */
font-size: 16px;
color: #4a4a4a; /* 深灰色文字,閱讀舒適 */
font-family: inherit;
}
/* Placeholder (提示文字) 樣式優化 */
#message::placeholder {
color: rgba(100, 100, 100, 0.35); /* 極淺的灰色,更有質感 */
font-weight: 300;
letter-spacing: 1px;
}
/* 3. 按鈕變成膠囊內的元件 */
#submit {
flex-shrink: 0;
width: 80px; /* 稍微縮小寬度 */
height: 40px; /* 高度配合輸入框 */
margin-left: 10px; /* 與文字保持距離 */
/* 漸層按鈕 */
background: linear-gradient(135deg, #DDACB2 0%, #C43739 100%);
color: white;
border: none;
border-radius: 40px; /* 膠囊狀 */
cursor: pointer;
font-weight: bold;
font-size: 15px;
letter-spacing: 1px;
/* 按鈕陰影 */
box-shadow: 0 4px 15px rgba(255, 64, 129, 0.3);
transition: all 0.3s ease;
}
/* 按鈕懸停效果 */
#submit:hover {
transform: scale(1.05); /* 輕微放大 */
box-shadow: 0 6px 20px rgba(255, 64, 129, 0.5);
background: linear-gradient(135deg, #DDACB2 0%, #C43739 100%);
}
#submit:active {
transform: scale(0.95);
}
/* =========================================
捲軸樣式設定
========================================= */
/* 1. 設定捲軸整體的寬度 */
.scrollable-container::-webkit-scrollbar {
width: 6px;
}
/* 2. 設定捲軸的「軌道」 (背景) */
.scrollable-container::-webkit-scrollbar-track {
background: transparent; /* 改成透明 */
/* 讓捲軸上下內縮,不要頂天立地 */
margin-block: 20px; /* 上下各留 20px 的空白 */
border-radius: 10px;
}
/* 3. 設定捲軸的「滑塊」 (會動的那塊) */
.scrollable-container::-webkit-scrollbar-thumb {
background-color: rgba(212, 66, 95, 0.5);
border-radius: 10px;
/* 增加一個懸停效果,滑鼠移過去變深,提示感更好 */
transition: background-color 0.3s;
}
/* [進階] 當滑鼠移到捲軸上時,顏色變深一點 */
.scrollable-container::-webkit-scrollbar-thumb:hover {
background-color: rgba(212, 66, 95, 0.8);
}
```
:::
---
### js
:::spoiler js
```javascript=
$(function () {
$("#submit").click(chatWithLLM);
$("#message").keypress(function (e) {
if (e.which == 13) {
chatWithLLM();
}
});
});
let message_count = 0;
function chatWithLLM() {
var $msgInput = $("#message");
var message = $msgInput.val();
// 防止傳送空白訊息
if (!message || message.trim().length === 0) {
return;
}
$msgInput.val("");
message_count += 1;
// 顯示使用者訊息 (User)
// !!! 這裡改頭像圖片檔案
let userHtml = `
<div class="chat-row user-row">
<div class="msg-content user-content" id="msg-${message_count}">
${escapeHtml(message)}
</div>
<img src="/static/images/girl.png" alt="User">
</div>
`;
$("#dialog-div").append(userHtml);
scrollToBottom();
// 發送請求
var data = { message: message };
$.post("/call_llm", data, function (response) {
message_count += 1;
// 顯示 AI 訊息 (AI)
// !!! 這裡改頭像圖片檔案
let aiHtml = `
<div class="chat-row ai-row">
<img src="/static/images/kamisama.png" alt="AI">
<div class="msg-content ai-content" id="msg-${message_count}">
${response}
</div>
</div>
`;
$("#dialog-div").append(aiHtml);
scrollToBottom();
});
}
function scrollToBottom() {
var $dialog = $("#dialog-div");
$dialog.scrollTop($dialog[0].scrollHeight);
}
function escapeHtml(text) {
if (!text) return text;
return text
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
```
:::
---
---
## 1. Top_Score_Recommend Code
:::info
- 當我與對象配對分數小於40分時,自動啟動尋找另外三個高分配對
- **把對象排除**,其餘星座再一次Random配對分數,再排序取前三
- (25/12/07 update) 移除**排除自身**,邏輯上不需要排除自身
:::
:::spoiler Code :triangular_flag_on_post:
```python=
import random
import json
from fastmcp import FastMCP
import threading
# 定義所有星座 (英文名/中文名)
ALL_CONSTELLATIONS = [
"Aries/牡羊座", "Taurus/金牛座", "Gemini/雙子座", "Cancer/巨蟹座",
"Leo/獅子座", "Virgo/處女座", "Libra/天秤座", "Scorpio/天蠍座",
"Sagittarius/射手座", "Capricorn/摩羯座", "Aquarius/水瓶座", "Pisces/雙魚座"
]
# 初始化 FastMCP 伺服器
mcp_nav = FastMCP(name="top_score_server")
@mcp_nav.tool
def get_top_matches(my_sign: str, partner_sign: str) -> str:
"""
計算提問者與其他星座的隨機高分速配指數,並回傳 Top 3 名單。
必須排除原始查詢的對象 (partner_sign)。
Args:
my_sign (str): 提問者自己的星座名稱 (例如:雙魚座)。
partner_sign (str): 原始查詢的伴侶星座名稱 (例如:天蠍座,必須被排除)。
Returns:
str: 包含 Top 3 星座和分數的 JSON 格式字串。
"""
results = []
# 標準化輸入 (用於比較和排除)
my_sign_standard = my_sign.lower().split('/')[0]
partner_sign_standard = partner_sign.lower().split('/')[0] # 獲取需要排除的對象
# 遍歷所有星座進行計算
for full_other_sign in ALL_CONSTELLATIONS:
parts = full_other_sign.split('/')
other_sign_en = parts[0].lower()
other_sign_cn = parts[1]
# 排除原始查詢的對象 (例如:如果查詢天蠍,就不能推薦天蠍)
if other_sign_en == partner_sign_standard:
continue
# 模擬計算分數 (簡單隨機高分區間)
score = random.randint(80, 100)
results.append({
"constellation": other_sign_cn,
"score": score
})
# 根據分數降序排序
top_3 = sorted(results, key=lambda x: x['score'], reverse=True)[:3]
# 將 Top 3 列表轉換為 JSON 字串回傳給 Agent
# 這裡不需要將 threading 導入,因為 FastMCP 內部會處理
return json.dumps(top_3, ensure_ascii=False)
if __name__ == "__main__":
print("get_top_matches Server is starting...")
# 使用 SSE 傳輸模式,運行在 Port 5006
mcp_nav.run(transport="sse", port=5006)
```
:::
## 2. Agent Code
:::info
主要更改instructor,加入 Top_Score_Recommend mcp,效果如圖
:::
:::spoiler Code
```python=
import datetime
from zoneinfo import ZoneInfo
from google.adk.agents import LlmAgent
from google.adk.tools.mcp_tool.mcp_toolset import (
MCPToolset,
StdioServerParameters,
SseConnectionParams,
)
import requests
import os
from dotenv import load_dotenv, dotenv_values
# Load environment variables from .env file
load_dotenv()
# --- 內部 Python 函式 (保留不變) ---
# 為了程式碼的完整性,保留 get_weather 和 get_current_time 函式定義
def get_weather(city: str) -> dict:
"""Retrieves the current weather report for a specified city."""
api_key = os.getenv("OPEN_WEATHER_MAP_API_KEY")
if not api_key:
return {"status": "error", "error_message": "API key for OpenWeatherMap is not set."}
url = f"http://api.openweathermap.org/data/2.5/weather?q={city}&appid={api_key}&units=metric"
try:
response = requests.get(url)
response.raise_for_status()
data = response.json()
if data["cod"] != 200:
return {"status": "error", "error_message": f"Weather information for '{city}' is not available."}
weather_description = data["weather"][0]["description"]
temperature = data["main"]["temp"]
report = (
f"The weather in {city} is {weather_description} with a temperature of "
f"{temperature} degrees Celsius."
)
return {"status": "success", "report": report}
except requests.exceptions.RequestException as e:
return {"status": "error", "error_message": f"An error occurred while fetching the weather data: {str(e)}"}
def get_current_time(tz_identifier: str) -> dict:
"""Returns the current time in a specified time zone identifier."""
try:
tz = ZoneInfo(tz_identifier)
now = datetime.datetime.now(tz)
report = f'The current time is {now.strftime("%Y-%m-%d %H:%M:%S %Z%z")}'
return {"status": "success", "report": report}
except Exception as e:
return {"status": "error", "error_message": f"An error occurred while fetching the current time: {str(e)}"}
# --- 代理定義與工具整合 (修正版) ---
root_agent = LlmAgent(
name="LoveMatching_agent",
model="gemini-2.5-flash",
description=(
"Agent is a 'Matchmaking Cupid' (月老) AI specializing in relationship guidance, "
"horoscope matching, and providing advice to users confused or deeply involved "
"in love. It can also provide weather information and access files."
),
instruction=(
"""
你是掌管緣分的「月老」AI,專門為對愛情感到迷茫或暈船的使用者提供指引和建議。
請用繁體中文,並使用月老或長輩的語氣來回答問題。
【月老配對任務流程】
當使用者詢問星座速配時,你**必須**執行以下步驟:
1. 必須呼叫 `match_index_server:get_match_index(sign1, sign2)` 獲取速配指數 (Score)。
2. IF Score < 40 (低分): 這是【複合導航任務】。你必須嚴格按照以下順序執行:
a. 呼叫 `top_score_server:get_top_matches(sign1, sign2)` 獲取三個高分推薦名單。
b. 接著,呼叫 `low_advice_server:get_low_advice` 獲取建議。
3. IF Score >= 40 (高分): 必須呼叫 `high_advice_server:get_high_advice` 獲取建議。
【通用指引】
* 在呼叫工具 (例如 get_weather) 時,city name 必須使用英文。
* 當使用者的需求跟檔案存取有關時,請先呼叫 list_allowed_directories 取得預設檔案存取的位置。
"""
),
tools=[
get_weather,
get_current_time,
# 1. 核心分數計算 MCP (Port 5004) - 獲取單點分數
MCPToolset(
connection_params=SseConnectionParams(url="http://127.0.0.1:5004/sse"),
),
# 2. 導航名單 MCP (Port 5006) - 獲取 Top 3 名單 (與先前配置一致)
MCPToolset(
connection_params=SseConnectionParams(url="http://127.0.0.1:5006/sse"),
),
# 3. 高分建議 MCP (新增/修正 Port 5007)
MCPToolset(
connection_params=SseConnectionParams(url="http://127.0.0.1:5005/sse"),
),
# 5. 檔案存取 MCP (保留不變)
MCPToolset(
connection_params=StdioServerParameters(
command="npx",
args=["-y", "@modelcontextprotocol/server-filesystem", "/Users/suyuhan/SynologyDrive/03_Coding/test_file_20250922"],
),
),
# 6. 自定義工具範例 (保留不變)
MCPToolset(
connection_params=StdioServerParameters(
command="/Users/suyuhan/.local/bin/uv",
args=["--directory", "/Users/suyuhan/SynologyDrive/03_Coding/114_1_Weather2Mood", "run", "server.py"],
),
),
],
)
```
:::
