先講清楚一件事:模型本身不會上網
會搜尋的不是模型,是「app 幫模型接了一個搜尋工具」。模型做的只有兩件事:決定要不要搜、搜什麼;以及讀搜尋結果、寫出答案。真正連到網路、把網頁抓回來的,是 app(或你寫的程式)。
ChatGPT 看起來會上網,也是同一回事:背後有個程式替模型執行搜尋,再把結果塞回去給模型讀。所以「本地模型+搜尋」=本地模型+一個會給它工具的程式。四條路差別只在「那個程式是誰」:Ollama 桌面 app、LM Studio、你自己的 Python 小程式、還是一個瀏覽器擴充功能。
- 工具呼叫(tool calling):模型回一段「我要用某個工具、參數是什麼」,由程式去執行、再把結果送回去。
- 系統提示(system prompt):放在對話最前面、給模型的工作說明。路 A 的
gemma4-search就是靠這個,模型才會「不確定就先搜」。 - MCP(Model Context Protocol):一種讓 app 接外部工具的共同格式;路 B 用它接 DuckDuckGo 搜尋。
- 思考模式(thinking/reasoning):模型先自言自語推理再回答。對這類「要不要搜」的判斷,Gemma 4 開著思考反而拖慢、甚至拒搜(見〈共同的坑〉)。
四條路一眼看
| 路 | 要裝什麼 | 要不要帳號 | 模型自動搜? | 速度(msi 實測) | 適合誰 |
|---|---|---|---|---|---|
| A・Ollama 桌面 app+gemma4-search 課堂主線 |
Ollama app(含模型 gemma4:e4b 6.6 GB)+一個 Modelfile |
不用。第一次開 app 選「No thanks」照樣能搜;免費帳號選用。⚠️沒登入的搜尋額度上限未驗(當天連跑 3 次都通) | 是。但要用 gemma4-search(加了系統提示的版本);原始 gemma4:e4b 會以「2026 還沒到」為由不搜 |
物理獎題:思考 7.9 秒後開始作答(截圖);整題總時間沒量 ⚠️ | 想要最像 ChatGPT 的體驗、不想碰終端機 |
| B・LM Studio+MCP | LM Studio+兩個 GGUF 模型檔(5.34 GB+0.99 GB)+uv+一個 mcp.json |
不用 | 是,而且每次呼叫工具會先跳核准框讓你按 Proceed | Think 開著時想了 7 分 31 秒才下工具;關掉 Think 才可用。按下 Proceed 之後的完整答案我們沒跑完 ⚠️ | 想親眼看到「模型要呼叫工具」那一刻、想玩 MCP |
| C・終端機小程式 search_agent.py | Ollama(指令列即可)+gemma4:e4b+Python+pip install ddgs |
不用 | 是,終端機會印出每一次工具呼叫與參數 | 三題各 7–23 秒(首題含載入約 27 秒),三題全對、都附來源 | 想看懂機制、想自己改程式(這頁最透明的一條) |
| D・Page Assist(Chrome 擴充功能) 不推薦 |
Ollama+gemma4:e4b+嵌入模型(embedding model)nomic-embed-text+Chrome 擴充功能(extension) |
不用 | 否。擴充功能先搜、再把結果塞給模型,不是模型自己決定 | 在這台三題都答不出(搜尋引擎被擋) | 試過了,不推薦;留著當「為什麼不推薦」的例子 |
路 A・Ollama 桌面 app+gemma4-search 課堂用・最像 ChatGPT
已勾 0/0 步
做什麼
Ollama 的桌面 app 內建「網頁搜尋」開關(輸入框旁的地球圖示)。開著之後,模型覺得該查就會查,app 幫它搜、再把結果交回去。我們多做一件事:用一個 Modelfile 幫 gemma4:e4b 加上系統提示,做成 gemma4-search,模型才會「不確定就先搜,不要以還沒發生為理由拒查」。
要裝什麼
- Ollama 桌面 app(實走版本 0.40.1,winget 套件
Ollama.Ollama,不需要管理員權限) - 模型
gemma4:e4b(6.6 GB;msi 實拉 211 秒、約 36 MB/s) - 一個文字檔
Modelfile(內容在第 3 步,複製即可)
逐步做
-
到官網下載安裝,或在 PowerShell 打:
winget install Ollama.Ollama第一次開 app 會問「Create an account/Sign in/No thanks」。選 No thanks 就能先本機用,搜尋也照樣能跑(當天連跑 3 次都通)。免費帳號是選用的(可以提高搜尋額度、用雲端模型),不是必要。⚠️沒登入的搜尋額度上限官方文件沒寫,我們沒驗到。
-
ollama pull gemma4:e4b6.6 GB,看網速;msi 上 211 秒。
-
從課程網的 W6
starter資料夾取得,或把下面內容複製後存成一個叫Modelfile的檔案(沒有副檔名;記事本存檔時「存檔類型」選「所有檔案」,編碼用 UTF-8)。starter/Modelfile(建置時從原檔塞入)
FROM gemma4:e4b SYSTEM """你是研究助理。你有網路搜尋與讀網頁的工具。今天的日期可能在你的訓練資料之後,所以遇到「最近發生的事、某一年的得獎名單、版本號、價格、新聞」或任何你不確定的事實,一律先用搜尋工具查,查到再回答,並附上來源網址;不要以「還沒發生」為理由拒絕查詢。回答用繁體中文(台灣用語)。""" PARAMETER num_ctx 16384三行意思:以
gemma4:e4b為底、加一段系統提示(「日期可能在訓練之後,不確定一律先搜、附來源、不要以還沒發生為由拒查」)、把對話長度(num_ctx)拉到 16384,搜尋結果才塞得下。 -
在 Modelfile 所在的資料夾開 PowerShell:
ollama create gemma4-search -f Modelfile大約 1 秒,不會重新下載(它只是在
gemma4:e4b外面包一層設定)。 -
模型名在輸入框右下角。app 預設選的是雲端模型
glm-5.3-flash:cloud(中國模型,本課不用),一定要換掉。點模型名、在清單裡搜gemma4-search。 -
亮起來(有藍色外框)才是開著。
-
例如〈三道考題〉的第 1 題。題目照平常問就好,不必寫「請搜尋」。
你應該看到
gemma4:e4b——做完第 4、5 步後,這裡應該顯示 gemma4-search。gemma4-search 問第 1 題:「Thought for 7.9 seconds」後直接搜,答得主 Francis Halzen(哈爾岑)。全程沒登入帳號。卡住了?(路 A 常見狀況)
- 模型清單裡預設是雲端的 glm:那是
glm-5.3-flash:cloud,請換成gemma4-search(第 5 步)。 - app 卡在「Loading…/Run Ollama」導覽頁:通常是之前從指令列另外啟動過 Ollama 伺服器。把工作管理員裡的
ollama app.exe和ollama.exe都結束,再開 app。 - 模型想了一陣子後說「2026 年還沒到」、不搜:你用的是原始的
gemma4:e4b(實測想 19 秒後拒搜)。app 沒有地方放系統提示,所以才要第 3、4 步做出gemma4-search。 - 搜到了卻回「請告訴我你要我做什麼」:原始
gemma4:e4b在第 3 題出現過這種不穩。再問一次,或換gemma4-search。 - 找不到地球圖示:Settings 裡有「Cloud:Enable cloud models and web search」開關(預設開),搜尋功能歸它管。⚠️我們沒有把它關掉再試,關了會怎樣未驗。
優點
- 最像 ChatGPT:有視窗、有搜尋開關、答案附來源。
- 不用帳號、不用終端機(只有
pull/create兩行指令)。 - 搜尋結果可以展開看原始資料,機制看得到。
缺點
- 系統提示要靠 Modelfile 另做一個模型,app 本身沒地方改。
- 模型判斷不穩,同一題偶爾會拒搜或答非所問。
- ⚠️沒登入的搜尋額度上限不明。
路 B・LM Studio+MCP 模型自己呼叫 DuckDuckGo,有核准框
已勾 0/0 步
做什麼
LM Studio 也是桌面 app,但工具是用 MCP(Model Context Protocol)接的:你在一個 mcp.json 裡宣告「有一個叫 duckduckgo 的工具伺服器」,模型想搜時 LM Studio 會跳一個核准框「Model wants to call search」,你按 Proceed 它才去搜。跟路 A 是同一個機制,只是把「模型要呼叫工具」那一刻亮給你看。
要裝什麼
- LM Studio(實走版本 0.4.25,winget 套件
ElementLabs.LMStudio) - 兩個 GGUF 模型檔(lmstudio-community):
gemma-4-E4B-it-Q4_K_M.gguf5.34 GB+mmproj-gemma-4-E4B-it-BF16.gguf0.99 GB uv(Python 套件工具,實走版本 0.9.7;mcp.json用它的uvx啟動搜尋伺服器)
逐步做
-
winget install ElementLabs.LMStudio指令列工具
lms在%USERPROFILE%\.lmstudio\bin\lms.exe,要等第一次開過視窗才會出現。 -
app 內建下載(
lms get gemma-4-e4b)在 msi 只有 0.45–0.8 MB/s,估要 2.5–3.75 小時;改用瀏覽器到 Hugging Face 的 lmstudio-community 頁面直接抓那兩個 GGUF,29–42 MB/s、182 秒。 -
lms import <gguf 檔路徑> --user-repo <自己取個名字> --copy --yes⚠️mmproj 那支是給模型看圖用的附屬檔;紀錄只寫了主模型這一行指令,文字問答需不需要它、怎麼一起匯入,我們沒單獨驗。
-
pip install uv或
winget install astral-sh.uv。裝好要有uvx這個指令。 -
檔案位置
%USERPROFILE%\.lmstudio\mcp.json,內容:{"mcpServers":{"duckduckgo":{"command":"uvx","args":["duckduckgo-mcp-server"]}}}意思:有一個叫 duckduckgo 的工具伺服器,用
uvx duckduckgo-mcp-server啟動(第一次會自動下載)。 -
載入 msi 上約 15 秒。
-
LM Studio 載 Gemma 4 預設思考是開的。開著時模型想了 7 分 31 秒才決定呼叫工具(見下圖),關掉才可用。⚠️這一步的開關位置我們沒截圖,在聊天輸入區的設定裡找 Think/Reasoning。
-
核准框會顯示模型自己填的搜尋關鍵字(Arguments)。下面有「Always allow search from mcp/duckduckgo」可勾,之後就不用每次按。
你應該看到
search,LM Studio 先問你。注意上面那行「Thought for 7 minutes 31 seconds」——這是 Think 沒關的代價。⚠️按 Proceed 之後的完整答案,我們這次實走沒有跑完三題。卡住了?(路 B 常見狀況)
- app 內建下載極慢:這台只有 0.5–0.8 MB/s。改用 Hugging Face 直接下載再
lms import(第 2、3 步)。 - 模型檔明明在、
lms ls卻看不到:中斷過的官方下載會把同名資料夾卡住。lms import時換一個--user-repo名字就好。 - 問了很久都沒動靜:多半是 Think 開著(第 7 步)。
- 用程式打 API 帶
"integrations":["mcp/duckduckgo"]回「Permission denied to use plugin」:要先在 LM Studio 的 Server Settings 開「Allow calling servers from mcp.json」,而這個開關要先把「Require Authentication」(API 要帶 token)打開才按得下去(設定鍵pluginUse從 deny 改 allowAll)。學生走視窗操作不需要這一步,只有自己寫程式呼叫 API 才會碰到。 - Ollama 同時在跑、回 CUDA 錯誤:8 GB 顯示卡只能載一套,見〈共同的坑〉。
優點
- 「模型要呼叫工具」那一刻看得見,連它填的搜尋關鍵字都能改(Edit)。
- MCP 是通用格式,之後想接別的工具也是同一套
mcp.json。 - 不用帳號。
缺點
- 要裝的東西最多(app、兩個模型檔、uv、mcp.json)。
- 模型檔要另外想辦法下載,app 內建下載在這台太慢。
- Think 忘了關就要等很久;⚠️完整三題我們沒跑完。
路 C・終端機(terminal)小程式 search_agent.py 最透明,看得到每一次工具呼叫
已勾 0/0 步
做什麼
一支一百行不到的 Python 程式:把「web_search」「fetch_page」兩個工具的說明交給模型,模型回「我要呼叫 web_search、關鍵字是…」,程式就真的去 DuckDuckGo 搜,把結果送回去,最多來回六輪。終端機上每一次呼叫都印出來——這是這一頁裡唯一能讓你把流程圖每一格都對上程式碼的做法。
要裝什麼
- Ollama 在跑+已經
ollama pull gemma4:e4b(路 A 第 2 步)。實走是用指令列ollama serve當後端;桌面 app 開著時背後也是同一個伺服器,⚠️但我們沒用 app 當後端跑過這支程式 - Python 3(程式只用標準函式庫+一個套件
ddgs)
逐步做
-
pip install ddgs -
從課程網的 W6
starter資料夾取得,或展開下面的〈程式全文〉複製後存成search_agent.py。 -
python search_agent.py輸入問題按 Enter;打
q離開。 -
python search_agent.py "2026 年諾貝爾物理獎得主是誰?得獎理由是什麼?"
你應該看到
終端機會先印模型名,然後每當模型決定用工具就印一行 🔍,最後印秒數與答案(下面是依實走紀錄整理的示意,不是逐字截圖):
msi 實測三題(見〈三道考題〉)全部由模型自己決定呼叫工具、全對、都附來源:
- 第 1 題物理獎:搜一次,答 Halzen+IceCube,附兩個來源。
- 第 2 題醫學獎:搜一次,答三位得主+光遺傳學,附來源。
- 第 3 題 Ollama 版本:搜一次、再用
fetch_page讀 GitHub releases 頁,答 v0.40.2/10 月 8 日並說明那是快照(snapshot)版。當天releases/latest查到的還是 v0.40.1(10-07),隔天 10/10 重查已是 v0.40.2——看的頁面、查的那一天不同,答案就不同,這正是第 3 題答案會變的原因。
配方要點:think: false+系統提示+題目本身不提搜尋。三樣都在程式裡,不用自己設。
程式全文 search_agent.py(建置時從 starter 原檔塞入)
# 本地模型+網路搜尋:模型自己決定要不要搜(工具呼叫)
# 需要:Ollama 在跑、已 pull gemma4:e4b;pip install ddgs
# 用法:python search_agent.py (互動模式,輸入 q 離開)
# python search_agent.py "你的問題" (問一題就結束)
import json, sys, time, urllib.request, re, html
from ddgs import DDGS
MODEL = "gemma4:e4b"
OLLAMA = "http://127.0.0.1:11434/api/chat"
SYSTEM = ("你是研究助理。你有 web_search 與 fetch_page 兩個工具。遇到最近發生的事、日期在你知識之後的事、版本號、價格,"
"或任何你不確定的事實,先呼叫 web_search;搜尋結果不夠明確時,用 fetch_page 讀官方頁面確認。"
"回答用繁體中文,附上你實際用到的來源網址;查不到就老實說查不到。")
TOOLS = [
{"type": "function", "function": {"name": "web_search", "description": "用 DuckDuckGo 搜尋網路,回傳前幾筆的標題、網址、摘要。",
"parameters": {"type": "object", "properties": {"query": {"type": "string", "description": "搜尋關鍵字"}}, "required": ["query"]}}},
{"type": "function", "function": {"name": "fetch_page", "description": "讀取一個網頁,回傳主要文字(前 3000 字)。",
"parameters": {"type": "object", "properties": {"url": {"type": "string"}}, "required": ["url"]}}},
]
def web_search(query):
rows = DDGS().text(query, max_results=5)
return json.dumps([{"title": r.get("title"), "url": r.get("href"), "snippet": r.get("body")} for r in rows], ensure_ascii=False)
def fetch_page(url):
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
raw = urllib.request.urlopen(req, timeout=20).read(400000).decode("utf-8", "replace")
txt = re.sub(r"<script.*?</script>|<style.*?</style>", " ", raw, flags=re.S | re.I)
txt = html.unescape(re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", txt)))
return txt[:3000]
def ask_model(messages):
body = json.dumps({"model": MODEL, "messages": messages, "tools": TOOLS, "stream": False,
"think": False, "options": {"num_ctx": 16384}}).encode()
req = urllib.request.Request(OLLAMA, data=body, headers={"Content-Type": "application/json"})
return json.loads(urllib.request.urlopen(req, timeout=900).read())["message"]
def answer(question, log=print):
msgs = [{"role": "system", "content": SYSTEM}, {"role": "user", "content": question}]
t0 = time.time()
for _ in range(6): # 最多六輪工具呼叫
m = ask_model(msgs); msgs.append(m)
calls = m.get("tool_calls") or []
if not calls:
break
for c in calls:
fn = c["function"]["name"]; args = c["function"].get("arguments") or {}
log(f" 🔍 模型決定呼叫 {fn}:{json.dumps(args, ensure_ascii=False)}")
try:
out = web_search(args.get("query", "")) if fn == "web_search" else fetch_page(args.get("url", ""))
except Exception as e:
out = f"ERROR: {e}"
msgs.append({"role": "tool", "content": out, "tool_name": fn})
log(f" ⏱ {time.time() - t0:.0f} 秒")
return msgs[-1].get("content", "")
if __name__ == "__main__":
if len(sys.argv) > 1:
print(answer(" ".join(sys.argv[1:])))
else:
print(f"本地模型 {MODEL},輸入問題(q 離開)")
while True:
q = input("\n你:").strip()
if q.lower() in ("q", "quit", "exit"): break
if q: print("\n模型:" + answer(q))讀法:TOOLS 是交給模型的工具說明書;ask_model 打本機 Ollama 的 /api/chat(think: false、num_ctx 16384);answer 那個迴圈就是流程圖——模型回 tool_calls 就去執行、把結果以 role: tool 送回,直到模型不再呼叫工具。
卡住了?(路 C 常見狀況)
- 第一題特別慢:含模型載入,約 27 秒;之後單題 7–23 秒。
- 印出
ERROR: …當工具結果:程式把搜尋或讀網頁的錯誤原樣交給模型,讓它決定下一步(換關鍵字或老實說查不到)。 - 模型沒搜就答:系統提示已要求「不確定就先搜」,但模型判斷不是百分之百;再問一次或把題目講具體。
- LM Studio 同時載著模型、Ollama 回 CUDA 錯誤:見〈共同的坑〉。
優點
- 每一次工具呼叫都印出來,機制完全透明。
- 三題全對附來源,單題 7–23 秒,是四條路裡實測最穩的。
- 程式短,可以自己改:換工具、換模型、加規則。
缺點
- 沒有視窗,要在終端機操作。
- 搜尋靠 DuckDuckGo 的
ddgs套件,被擋或改版就要修。
路 D・Page Assist(Chrome 擴充功能) 試過、不推薦
為什麼不推薦
Page Assist(實走版本 1.5.86)是一個瀏覽器擴充功能(extension),把 Ollama 的模型包成聊天視窗,也有「網路搜尋」開關。我們裝了、設了、三題都問了,結論是不當主線,理由有三:
- 不是模型自己決定搜:它是擴充功能先去搜、把結果塞給模型,模型只能照單讀。跟這一頁要教的「模型判斷要不要搜」不是同一件事。
- 要多裝一個嵌入模型:
ollama pull nomic-embed-text(274 MB),沒裝會顯示「Please set an embedding model on the Settings > RAG page」。 - 在這台搜尋引擎幾乎都被擋:DuckDuckGo 對擴充功能回 HTTP 202(機器人驗證頁,14 KB、沒有結果;同一網址用 curl 是 200 有結果,所以是針對瀏覽器端的擋);Google 回一頁要 JavaScript 的空頁;只有 Bing 真的回結果,但中文結果差。結果三題都答不出。
留著這條是為了對照:同樣是「本地模型+搜尋」,誰決定搜、誰去搜,差很多。
延伸・LINE bot 簡單版:一支 Python 程式就是 bot W6 課堂示範・回家選做
已勾 0/0 步
做什麼
W5 的 bot 住在 Google 的 Apps Script;這一版改成一支 Python 程式 line_bot_local.py 就是整個 bot,跑在你自己的電腦上:
- 收文字 → 交給你電腦上的 Ollama(
gemma4:e4b)回答;模型覺得需要就自己上網搜,回覆附來源 - 收照片 → 同一個模型看照片,說出上面有什麼、有沒有日期時間地點
- 每一則都先寫進你的 Google 試算表(用第 2 段登入好的 gws),寫好了才回覆
程式就在你的電腦上,所以叫 Ollama 用 localhost 就行,不用轉接站,也不用 Gemini 金鑰。只剩一座橋省不掉:LINE 的伺服器要把訊息送進來,得找得到你的電腦,所以還是要 Cloudflare 的免費臨時通道。程式開起來會自己開通道,自己把 LINE 的 Webhook 換到你的電腦;按 Ctrl+C 結束時再換回原本的(例如你 W5 的 Apps Script),不用進 LINE 後台改。
要準備什麼
- Python 3:PowerShell 打
python --version印得出版本就好;沒有就winget install --id Python.Python.3.13 -e,裝完新開視窗。程式只用 Python 內建功能,不用pip install - Ollama+
gemma4:e4b(路 A 第 1、2 步;W6 第 0 段裝過) - gws 已登入、有試算表權限(W6 第 2 段 2-7 那行
gws auth login -s "drive,sheets") - cloudflared:
winget install --id Cloudflare.cloudflared -e - LINE 的兩個值(W5 建的那個 channel):長效通行碼(Channel access token)、Channel secret
逐步做
-
雲端硬碟新增一張空白試算表,取個名字(例如「LINE bot 簡單版紀錄」)。網址長這樣:
docs.google.com/spreadsheets/d/一長串英數字/edit,中間那一長串就是 ID。第一列可以自己打上標題:時間、類型、傳了什麼、bot 回了什麼。⚠️ 用跟 gws 登入同一個 Google 帳號開,不然 gws 寫不進去。
-
LINE Developers → 你 W5 建的 channel:
- Channel secret:「Basic settings」分頁往下找
- Channel access token(長效通行碼):「Messaging API」分頁最下面,W5 按 Issue 拿到的那一串
兩個都等於鑰匙:不截圖、不貼進對話。等一下程式會問你,貼進去畫面不會顯示,也不會存進任何檔案。
-
從課程網的 W6
starter資料夾下載,或展開下面的〈程式全文〉複製、用記事本存成line_bot_local.py(存檔類型選「所有檔案」)。Ollama app 開著,在 PowerShell 進到那個資料夾:python line_bot_local.py它會依序問三件事:長效通行碼、Channel secret(這兩個貼上去畫面不顯示,貼完直接按 Enter)、試算表 ID。
你應該看到(⚠️預期畫面:程式在 msi 測過「通行碼錯會擋下來」與收訊息、寫表、回覆;開通道、換 Webhook 那幾行還沒在裝了 cloudflared 的電腦跑過):
[15:20:01] ✅ LINE bot「你的 bot 名字」 [15:20:01] 載入模型 gemma4:e4b(第一次要等一下)… [15:20:17] ✅ 模型已在顯示卡上 [15:20:21] ✅ LINE 的 Webhook 換到這台電腦了:https://(一串英文字).trycloudflare.com/callback [15:20:21] 原本的是:(你 W5 的 /exec 網址)——按 Ctrl+C 會換回去 [15:20:21] LINE 說換網址最多要 1 分鐘才生效;等一下再用手機傳訊息 [15:20:26] LINE 測試連線:✅ 通了 [15:20:26] 開好了。手機傳訊息給 bot 試試看;這個視窗不要關,按 Ctrl+C 結束。畫面上「原本的是:…」那行就是你 W5 的
/exec網址,先抄下來(萬一程式沒換回去,要手動貼回 LINE 後台)。 -
2026 年諾貝爾物理獎得主是誰?再拍一張有日期的活動海報傳過去。你應該看到電腦視窗出現
📩 收到文字→🔍 模型決定搜:…→📝 寫進試算表了→✅ 回覆了;手機收到(下面是 10/10 msi 用同一支程式實測的回覆,LINE 那端換成記錄用的假函式,手機畫面待老師彩排補):結論:2026 年諾貝爾物理獎的得主是弗朗西斯·哈爾岑。…… 來源: • https://www.nobelprize.org/prizes/physics/2026/press-release/ • https://www.cas.cn/kj/202610/t20261007_5121442.shtml 📝 已記進試算表(本地模型 32 秒)這是一則關於「生成式 AI 實作講座」的宣傳資訊。 活動日期是 2026 年 10 月 20 日(二),時間為下午 3:10–5:00,地點在理工二館 A101。 講座免費入場,提醒參加者請攜帶筆電。 📝 已記進試算表(本地模型 3 秒)文字那題 32 秒是模型還沒載進顯示卡就問;正常開機會先載好,之後每題約 4–5 秒(同一台的轉接站實測)。照片是測試用的假海報。打開試算表,要多兩列:「文字」和「照片」。
-
在程式視窗按 Ctrl+C。你應該看到
↩️ LINE 的 Webhook 換回原本的了,之後手機傳訊息又回到你 W5 的 Apps Script bot(照樣要等最多 1 分鐘)。⚠️ 這支程式開著的時候,訊息全部送到你電腦,Apps Script 那支 bot 收不到(試算表、日曆、Drive 那些功能暫停)。直接按視窗右上角的 ✕ 關掉,程式來不及換回去——要到 LINE Developers →「Messaging API」→ Webhook URL,貼回第 3 步抄下的
/exec網址。
程式全文 line_bot_local.py(建置時從 starter 原檔塞入)
# LINE bot 簡單版:一支 Python 程式就是整個 bot,跑在你自己的電腦上
#
# 傳什麼進來,它做什麼:
# 問問題(「2026 年諾貝爾物理獎得主是誰?」)→ 交給你電腦上的 Ollama 本地模型,需要就自己上網搜,回覆附來源
# 講行程(「明天下午三點開會」) → 整理出日期時間,排進 Google 日曆
# 傳照片(活動海報、便條紙) → 照片存進雲端硬碟;模型看照片說上面有什麼;有日期時間就排進日曆
# 問題、行程、照片都先寫一列進 Google 試算表,才回覆(只講已經做完的事;打招呼和看不懂的格式不記)
# 雲端硬碟、試算表、日曆都用第 2 段登入好的 gws;模型一律打本機 Ollama 的 API(http://127.0.0.1:11434)。
#
# 為什麼還要「通道」:LINE 的伺服器要把訊息送進來,得找得到你的電腦;
# 教室和家裡的電腦都躲在網路後面,所以用 Cloudflare 的免費臨時通道給它一個 https 網址。
# 程式開起來會自己把 LINE 的 Webhook 換成這個網址,按 Ctrl+C 結束時再換回原本的(例如你 W5 的 Apps Script)。
#
# 需要:Python 3(只用內建功能,不用 pip install)、Ollama 開著且有 gemma4:e4b、
# gws 已登入(要有 drive、sheets、calendar 權限)、cloudflared、LINE 的長效通行碼(W5 申請過)
# 用法(PowerShell,在這個檔所在的資料夾):
# python line_bot_local.py
# 開起來會問你 LINE 長效通行碼、Channel secret(畫面不顯示)和試算表 ID,貼上按 Enter 就好。
import base64, getpass, hashlib, hmac, json, os, queue, re, shutil, subprocess, sys, tempfile, threading, time, urllib.error, urllib.request
from datetime import datetime, timedelta
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
MODEL = os.environ.get("MODEL", "gemma4:e4b")
PORT = int(os.environ.get("PORT", "8000"))
CALENDAR_ID = os.environ.get("CALENDAR_ID", "primary") # 排進哪個日曆;primary=你的主日曆
PHOTO_FOLDER = os.environ.get("PHOTO_FOLDER", "LINE待辦照片") # 照片存進雲端硬碟的哪個資料夾(沒有就建)
OLLAMA = "http://127.0.0.1:11434"
GREETING = re.compile(r"^(hi|hello|你好|嗨|哈囉|測試|test)$", re.I)
HELP = ("你好!我是跑在同學電腦上的本地模型 🤖\n"
"• 問我問題:需要的話我會上網查,附來源\n• 講行程(「明天下午三點開會」):我排進 Google 日曆\n"
"• 傳照片:我存進雲端硬碟、告訴你上面有什麼,有日期時間也幫你排日曆\n問題、行程、照片都會記進試算表。")
TOOLS = [
{"type": "function", "function": {"name": "web_search", "description": "搜尋網路,回傳前幾筆標題、網址與內文。",
"parameters": {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}}},
{"type": "function", "function": {"name": "web_fetch", "description": "讀一個網頁的主要文字。",
"parameters": {"type": "object", "properties": {"url": {"type": "string"}}, "required": ["url"]}}},
]
EVENT_SHAPE = {"type": "object", "required": ["isEvent", "title", "date", "time"], "properties": {
"isEvent": {"type": "boolean"}, "title": {"type": "string"},
"date": {"type": "string", "description": "YYYY-MM-DD,沒提到就填空字串"},
"time": {"type": "string", "description": "HH:MM(24 小時制);有結束時間就寫 HH:MM-HH:MM;沒提到就填空字串"}}}
LINE_TOKEN = LINE_SECRET = SHEET_ID = "" # main() 開起來時問你,再填進這裡
GWS = None
_folder_id = None
_turn = threading.Lock() # 一次只處理一則:顯示卡一次只答一題,照片資料夾也不會被同時建兩個
_procs = [] # 開出去的 cloudflared,結束時一定要關
TUNNEL_WAIT = 60 # 等 cloudflared 給網址最多幾秒
def log(s):
print(f"[{datetime.now():%H:%M:%S}] {s}", flush=True)
def today():
return datetime.now().strftime("%Y-%m-%d(%A)") # 附上星期幾,模型才算得準「下週五」
# ===== 1. 設定:開起來時問一次,不寫進檔案 =====
def ask_settings():
token = os.environ.get("LINE_CHANNEL_ACCESS_TOKEN") or getpass.getpass("貼上 LINE 長效通行碼(Channel access token,畫面不顯示):").strip()
secret = os.environ.get("LINE_CHANNEL_SECRET") or getpass.getpass("貼上 Channel secret(LINE Developers → Basic settings;必填;畫面不顯示):").strip()
sheet = os.environ.get("SHEET_ID") or input("試算表 ID(網址 /d/ 和 /edit 中間那段;不寫試算表就直接 Enter):").strip()
return token, secret, sheet
# ===== 2. 叫 LINE、叫 Ollama、叫 gws 的小工具 =====
def http_json(method, url, body=None, headers=None, timeout=60):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method=method, headers={"Content-Type": "application/json", **(headers or {})})
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
raw = r.read()
return r.status, (json.loads(raw) if raw.strip() else {})
except urllib.error.HTTPError as e:
return e.code, {"error": e.read().decode("utf-8", "replace")[:200]}
def line(method, path, body=None):
return http_json(method, "https://api.line.me" + path, body, {"Authorization": "Bearer " + LINE_TOKEN})
def reply(token, text):
code, res = line("POST", "/v2/bot/message/reply", {"replyToken": token, "messages": [{"type": "text", "text": text[:5000]}]})
if code != 200:
log(f"⚠️ LINE 回覆失敗 {code}:{res}")
def download_image(message_id):
req = urllib.request.Request(f"https://api-data.line.me/v2/bot/message/{message_id}/content",
headers={"Authorization": "Bearer " + LINE_TOKEN})
with urllib.request.urlopen(req, timeout=30) as r:
return r.read()
def ollama(path, body, timeout=60):
code, res = http_json("POST", OLLAMA + path, body, timeout=timeout)
if code != 200:
raise RuntimeError(f"Ollama {path} 回應 {code}:{res}")
return res
def chat(messages, timeout=60, **extra):
body = {"model": MODEL, "messages": messages, "stream": False, "think": False, "keep_alive": "1h", "options": {"num_ctx": 16384}}
return ollama("/api/chat", {**body, **extra}, timeout).get("message", {})
def gws(*args, upload=None, cwd=None):
"""跑一條 gws 指令,回傳它印出的 JSON。(gws 只肯上傳「目前資料夾」裡的檔,所以上傳時用 cwd 指到檔案所在的資料夾)"""
cmd = [GWS, *args] + (["--upload", upload] if upload else [])
r = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", timeout=60, cwd=cwd)
if r.returncode != 0:
raise RuntimeError((r.stderr or r.stdout).strip()[-300:])
return json.loads(r.stdout or "{}")
def find_gws():
# npm 裝的 gws 背後有一個真正的 gws.exe;直接叫它,JSON 的引號才不會被 Windows 的 .cmd 轉接弄壞
roots = [os.path.join(os.environ.get("APPDATA", ""), "npm", "node_modules")]
try: # npm 裝到別的地方時,問 npm 它的全域資料夾在哪
roots.append(subprocess.run("npm root -g", shell=True, capture_output=True, text=True, timeout=30).stdout.strip())
except Exception:
pass
for root in roots:
exe = os.path.join(root, "@googleworkspace", "cli", "bin", "gws.exe")
if root and os.path.exists(exe):
return exe
return None
# ===== 3. 模型:回答問題(自己決定要不要搜)、看照片、找出行程 =====
def ask_text(question):
"""模型自己決定要不要搜;回傳(答案, 來源網址清單)。"""
msgs = [{"role": "system", "content": f"你是 LINE 上的助理,今天是 {today()},可能比你的訓練資料新。"
"遇到最近的事、版本號、價格或任何你不確定的事實,先呼叫 web_search;不夠明確再用 web_fetch 讀官方頁面。"
"回答用台灣的繁體中文、純文字(不要用星號或井字號),150 字以內,先講結論;不用自己寫網址,程式會附上來源。"},
{"role": "user", "content": question}]
sources = []
for _ in range(4): # 最多來回四次工具呼叫
m = chat(msgs, tools=TOOLS)
msgs.append(m)
if not m.get("tool_calls"):
return m.get("content", "").replace("**", "").strip(), sources
for c in m["tool_calls"]:
name, args = c["function"]["name"], c["function"].get("arguments") or {}
try:
if name == "web_search":
log(f" 🔍 模型決定搜:{args.get('query')}")
rows = ollama("/api/experimental/web_search", {"query": args.get("query", ""), "max_results": 3}).get("results", [])
sources += [r["url"] for r in rows]
out = json.dumps([{"title": r["title"], "url": r["url"], "content": r["content"][:1500]} for r in rows], ensure_ascii=False)
else:
log(f" 📄 模型決定讀網頁:{args.get('url')}")
page = ollama("/api/experimental/web_fetch", {"url": args.get("url", "")})
sources.insert(0, args.get("url", ""))
out = page.get("content", "")[:3000]
except Exception as e:
out = f"ERROR: {e}"
msgs.append({"role": "tool", "tool_name": name, "content": out})
return "查了幾次還整理不出答案,換個問法再試一次。", sources
def ask_image(jpg):
m = chat([{"role": "user", "images": [base64.b64encode(jpg).decode()],
"content": "這是使用者用 LINE 傳來的照片。用台灣的繁體中文、三句以內說明照片裡有什麼;"
"看得到日期、時間、地點、活動或繳費資訊就列出來,看不清楚的不要猜。"}])
return re.sub(r"^\s*[*-]\s+", "• ", m.get("content", "").replace("**", ""), flags=re.M).strip() # 星號清單在 LINE 上很亂,換成 •
def find_event(text):
"""請模型照固定格式回答「這是不是有日期和時間的行程」;欄位驗算過才算數,不照單全收。"""
m = chat([{"role": "user", "content": f"今天是 {today()},台北時間。下面這段文字如果是一個有日期和時間的行程、活動或提醒"
"(不是在問問題),把「明天」「下週五」換算成實際日期,整理成 JSON;不是就 isEvent 填 false。\n\n" + text}],
format=EVENT_SHAPE)
try:
ev = json.loads(m.get("content", "{}"))
times = re.findall(r"\d{1,2}:\d{2}", ev["time"]) # 「15:10-17:00」這種區間:第一個是開始、第二個是結束
ev["start"] = datetime.strptime(f"{ev['date']} {times[0]}", "%Y-%m-%d %H:%M") # 2/30、25:99 這種不存在的會在這裡被擋掉
ev["end"] = datetime.strptime(f"{ev['date']} {times[1]}", "%Y-%m-%d %H:%M") if len(times) > 1 else ev["start"] + timedelta(minutes=30)
ev["time"] = f"{ev['start']:%H:%M}" + (f"–{ev['end']:%H:%M}" if len(times) > 1 else "")
except (ValueError, KeyError, TypeError, IndexError):
return None
# 原文寫了「幾月幾日」或「10/20」,模型整理出來的日期要對得上:模型會偷偷把「2 月 30 日」改成 2 月 28 日
said = re.search(r"(\d{4})\s*[-/年]\s*(\d{1,2})\s*[-/月]\s*(\d{1,2})|(\d{1,2})\s*月\s*(\d{1,2})\s*[日號]|(?<!\d)(\d{1,2})/(\d{1,2})(?!\d)", text)
nums = [int(x) for x in said.groups() if x] if said else []
if nums and nums != [ev["start"].year, ev["start"].month, ev["start"].day][-len(nums):]:
log(f" ⚠️ 模型整理的日期 {ev['date']} 跟原文「{said.group(0)}」對不上,不排日曆")
return None
return ev if ev.get("isEvent") is True and str(ev.get("title", "")).strip() and ev["end"] > ev["start"] else None
# ===== 4. Google:照片存雲端硬碟、排日曆、寫試算表(都用 gws) =====
def save_photo(jpg):
"""照片存進 PHOTO_FOLDER 資料夾(沒有就建),回傳檔案連結。"""
global _folder_id
if not _folder_id:
q = f"name='{PHOTO_FOLDER}' and mimeType='application/vnd.google-apps.folder' and trashed=false"
found = gws("drive", "files", "list", "--params", json.dumps({"q": q, "fields": "files(id)"}, ensure_ascii=False)).get("files", [])
_folder_id = found[0]["id"] if found else gws("drive", "files", "create", "--json", json.dumps(
{"name": PHOTO_FOLDER, "mimeType": "application/vnd.google-apps.folder"}, ensure_ascii=False))["id"]
with tempfile.TemporaryDirectory() as tmp: # 照片先存成暫存檔,上傳完整個暫存資料夾自動刪掉
with open(os.path.join(tmp, "photo.jpg"), "wb") as fh:
fh.write(jpg)
f = gws("drive", "files", "create", "--params", json.dumps({"fields": "id,webViewLink"}),
"--json", json.dumps({"name": f"line-{datetime.now():%Y%m%d-%H%M%S}.jpg", "parents": [_folder_id]}),
upload="photo.jpg", cwd=tmp)
log(" 📷 照片存進雲端硬碟了")
return f.get("webViewLink", "")
def add_calendar(ev):
gws("calendar", "events", "insert", "--params", json.dumps({"calendarId": CALENDAR_ID}),
"--json", json.dumps({"summary": ev["title"], "description": "由 LINE bot 簡單版建立",
"start": {"dateTime": f"{ev['start']:%Y-%m-%dT%H:%M:00}", "timeZone": "Asia/Taipei"},
"end": {"dateTime": f"{ev['end']:%Y-%m-%dT%H:%M:00}", "timeZone": "Asia/Taipei"}}, ensure_ascii=False))
log(f" 🗓️ 排進日曆了:{ev['date']} {ev['time']}")
def write_sheet(row):
"""寫一列進試算表:時間、類型、傳了什麼、bot 回了什麼、照片連結、日曆。"""
gws("sheets", "spreadsheets", "values", "append",
"--params", json.dumps({"spreadsheetId": SHEET_ID, "range": "A1", "valueInputOption": "USER_ENTERED"}),
"--json", json.dumps({"values": [row]}, ensure_ascii=False))
log(" 📝 寫進試算表了")
def try_do(what, fn, *args):
"""Google 那邊哪一件失敗,都不讓整則訊息掛掉;回傳(結果, 錯誤訊息;成功時是空字串)。"""
try:
return fn(*args), ""
except Exception as e:
log(f"⚠️ {what}失敗:{e}")
return None, str(e)[:120] or "不明原因"
# ===== 5. 處理一則訊息:先把事情做完(存照片、排日曆、寫試算表)→ 才回覆,只講已經做完的事 =====
def handle(event):
with _turn:
_handle(event)
def _handle(event):
if event.get("type") != "message" or not event.get("replyToken"):
return
msg, token = event["message"], event["replyToken"]
user = event.get("source", {})
if user.get("type") == "user": # 「對方正在輸入…」動畫,最多 60 秒
line("POST", "/v2/bot/chat/loading/start", {"chatId": user["userId"], "loadingSeconds": 60})
t0, notes, link, cal = time.time(), [], "", ""
try:
if msg["type"] == "text":
text = msg["text"].strip()
if GREETING.match(text):
return reply(token, HELP)
log(f"📩 收到文字({len(text)} 字)") # 不印內容:螢幕可能正在投影
kind, said, ev = "文字", text, find_event(text)
if ev: # 講行程:排日曆就好,不必上網查
answer = f"📌 {ev['title']}\n📅 {ev['date']} ⏰ {ev['time']}"
else: # 問問題:交給模型,需要就自己搜
answer, sources = ask_text(text)
answer += ("\n\n來源:\n" + "\n".join("• " + u for u in list(dict.fromkeys(sources))[:3]) if sources
else "\n\nℹ️ 這題沒有上網查,只憑模型記憶,請自己核對。")
elif msg["type"] == "image":
log("📩 收到照片")
jpg = download_image(msg["id"])
kind, said = "照片", "(照片)"
link, err = try_do("存照片", save_photo, jpg)
notes.append(f"📷 照片已存進雲端硬碟「{PHOTO_FOLDER}」" if not err else "⚠️ 照片沒存進雲端硬碟:" + err)
answer = ask_image(jpg)
ev = find_event(answer)
else:
return reply(token, "目前我看得懂文字和照片,其他類型還不行喔。")
if ev:
_, err = try_do("排日曆", add_calendar, ev)
cal = "已建立" if not err else "失敗:" + err
notes.append(f"🗓️ 已排進 Google 日曆({ev['date']} {ev['time']})" if not err else "⚠️ 日曆沒排成:" + err)
if SHEET_ID:
_, err = try_do("寫試算表", write_sheet, [f"{datetime.now():%Y-%m-%d %H:%M:%S}", kind, said, answer[:500], link or "", cal])
notes.append("📝 已記進試算表" if not err else "⚠️ 這則沒寫進試算表:" + err)
reply(token, f"{answer}\n\n" + "\n".join(notes + [f"(本地模型 {time.time() - t0:.0f} 秒)"]))
log(f"✅ 回覆了({time.time() - t0:.0f} 秒)")
except Exception as e:
log(f"❌ {e}")
reply(token, "⚠️ 出錯了:" + str(e)[:300])
class Webhook(BaseHTTPRequestHandler):
def do_POST(self):
body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
if LINE_SECRET: # 對簽章:確定真的是 LINE 送來的,不是知道網址的路人
sig = base64.b64encode(hmac.new(LINE_SECRET.encode(), body, hashlib.sha256).digest()).decode()
if not hmac.compare_digest(sig, self.headers.get("x-line-signature", "")):
log("⛔ 簽章不對,不是 LINE 送來的,丟掉")
self.send_response(401)
self.end_headers()
return
self.send_response(200) # 先跟 LINE 說收到了(LINE 要 2 秒內回應),再慢慢處理
self.end_headers()
for ev in json.loads(body or b"{}").get("events", []):
threading.Thread(target=handle, args=(ev,), daemon=True).start()
def do_GET(self):
self.send_response(200)
self.end_headers()
self.wfile.write("LINE bot 簡單版運作中".encode())
def log_message(self, *args): # 不要印每一個連線
pass
# ===== 6. 開通道、把 LINE 的 Webhook 換過來;結束時換回去 =====
def open_tunnel():
"""開 Cloudflare 臨時通道,最多等 60 秒拿網址;拿不到就把它關掉,回傳 None。"""
if not shutil.which("cloudflared"):
log("⚠️ 找不到 cloudflared,外面連不到這裡。另開 PowerShell 貼:winget install --id Cloudflare.cloudflared -e")
log(" 裝完關掉這個視窗、新開一個再跑一次。")
return None
cf = subprocess.Popen(["cloudflared", "tunnel", "--no-autoupdate", "--url", f"http://127.0.0.1:{PORT}"],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace")
_procs.append(cf)
found = queue.Queue()
def watch(): # 另一條執行緒一行一行讀它的輸出(之後的也照讀照丟,免得塞住);主程式只等 60 秒
for row in cf.stdout:
m = re.search(r"https://[a-z0-9-]+\.trycloudflare\.com", row)
if m:
found.put(m.group(0))
found.put(None)
threading.Thread(target=watch, daemon=True).start()
try:
url = found.get(timeout=TUNNEL_WAIT)
except queue.Empty:
url = None
if not url:
log(f"⚠️ {TUNNEL_WAIT} 秒內通道沒拿到網址:看網路(學校網路可能擋),或再跑一次")
return url
def restore(old):
"""把 LINE 的 Webhook 換回原本的,再讀一次確認真的換回去了(換的那條路說成功不算數)。"""
if not old:
log("ℹ️ 原本沒有 Webhook 網址,現在指著一個已經關掉的通道;要用 Apps Script 版就回 LINE Developers 貼回 /exec 網址")
return
line("PUT", "/v2/bot/channel/webhook/endpoint", {"endpoint": old})
code, now = line("GET", "/v2/bot/channel/webhook/endpoint")
if code == 200 and now.get("endpoint") == old:
log("↩️ LINE 的 Webhook 換回原本的了(重新讀過一次,確認是原本那個)")
else:
log(f"⚠️ 沒能確認換回去:到 LINE Developers →「Messaging API」→ Webhook URL 手動貼回:{old}")
def main():
global LINE_TOKEN, LINE_SECRET, SHEET_ID, GWS
LINE_TOKEN, LINE_SECRET, SHEET_ID = ask_settings()
GWS = find_gws()
code, info = line("GET", "/v2/bot/info")
if code != 200:
sys.exit(f"LINE 通行碼不對({code}):回 LINE Developers 重新複製長效通行碼")
log(f"✅ LINE bot「{info.get('displayName')}」")
if not LINE_SECRET:
sys.exit("沒有 Channel secret 就不開通道:沒有它,知道通道網址的人都能假冒 LINE 送訊息進來。"
"回 LINE Developers →「Basic settings」複製,再跑一次。")
if not GWS:
log("⚠️ 找不到 gws.exe:照片、日曆、試算表都會失敗(先裝 gws:指南 1-3)")
code, cur = line("GET", "/v2/bot/channel/webhook/endpoint")
if code == 200:
old = cur.get("endpoint")
elif code == 404: # LINE 說這個 channel 原本就沒設 Webhook
old = None
else: # 讀不到原本的網址就不換,免得換了回不去
sys.exit(f"讀不到 LINE 現在的 Webhook 網址({code}):先不換。等一下再跑一次。{cur}")
log(f" 原本的 Webhook:{old or '(沒有設定)'}——先抄下來,萬一沒換回去要手動貼回")
log(f"載入模型 {MODEL}(第一次要等一下)…")
chat([], timeout=300)
log("✅ 模型已在顯示卡上")
server = ThreadingHTTPServer(("127.0.0.1", PORT), Webhook) # 只聽本機:外面只能從通道進來
threading.Thread(target=server.serve_forever, daemon=True).start()
switched = False
try: # 從開通道起就在 try 裡:只要 Webhook 換過,不管怎麼結束都會換回去
url = open_tunnel()
if url:
code, res = line("PUT", "/v2/bot/channel/webhook/endpoint", {"endpoint": url + "/callback"})
if code != 200:
log(f"⚠️ 換 Webhook 失敗 {code}:{res}。可以手動貼到 LINE Developers:{url}/callback")
else:
switched = True
log(f"✅ LINE 的 Webhook 換到這台電腦了:{url}/callback(按 Ctrl+C 會換回去)")
log(" LINE 說換網址最多要 1 分鐘才生效;等一下再用手機傳訊息")
time.sleep(5)
code, test = line("POST", "/v2/bot/channel/webhook/test", {})
log(f" LINE 測試連線:{'✅ 通了' if test.get('success') else '還沒通(' + str(test.get('reason')) + '),等一分鐘再傳'}")
log("開好了。手機傳訊息給 bot 試試看;這個視窗不要關,按 Ctrl+C 結束。" if switched
else "⚠️ 這次沒接上 LINE(看上面的訊息)。按 Ctrl+C 結束。")
while True:
time.sleep(1)
except KeyboardInterrupt:
pass
finally:
if switched:
restore(old)
for cf in _procs:
cf.terminate()
server.shutdown()
if __name__ == "__main__":
main()讀法:Webhook 收 LINE 送來的訊息、對簽章、先回 200(LINE 要 2 秒內回應),再開一條執行緒慢慢做;handle 就是一則訊息的旅程——ask_text(跟路 C 一樣的工具迴圈,工具換成 Ollama 自己的搜尋)或 ask_image → write_sheet → reply;main 開通道、換 Webhook,結束時換回去。
卡住了?(簡單版)
- 「LINE 通行碼不對(401)」:貼成 Channel secret 或少複製幾個字。回 LINE Developers「Messaging API」分頁最下面重新複製長效通行碼。
- 「python 不是指令」:Python 沒裝,或裝完沒新開視窗;也可能打開的是微軟商店的空殼——用
winget install --id Python.Python.3.13 -e裝。 - 「找不到 cloudflared」:裝完沒新開視窗。
- 手機傳了沒反應:LINE 換網址最多要 1 分鐘,等一下再傳;再不行看程式視窗有沒有「⛔ 簽章不對」(Channel secret 貼錯,重開程式重貼)。
- 手機回答了,但寫「⚠️ 這則沒寫進試算表」:看程式視窗的原因。試算表 ID 抄錯、試算表不是用 gws 登入的那個帳號開的,或 gws 登入時沒有
sheets(重做第 2 段 2-7)。 - 「⚠️ 出錯了:Ollama … 回應 404」:沒有
gemma4:e4b,ollama pull gemma4:e4b。 - 結束後 Apps Script bot 沒回來:等 1 分鐘;還是沒有,到 LINE Developers 把 Webhook URL 貼回
/exec網址。
優點
- 一支程式、一個指令;不用金鑰、不用改 Apps Script、不用進 LINE 後台改網址。
- 文字會搜、照片會看、每則都記進試算表,全部在自己電腦跑。
- 對得到 LINE 的簽章,比 Apps Script 版安全。
缺點
- 電腦關了 bot 就停;開著的時候 Apps Script 那支收不到訊息。
- 每次開都換一個臨時網址(程式自己換,但要等最多 1 分鐘生效)。
- 要多裝 Python 和 cloudflared。
延伸・Apps Script 版:bot 留在 Google,加一個轉接站 進階版・下週或有時間再做
已勾 0/0 步
先搞懂:為什麼 bot 不能直接叫 localhost
你的 LINE bot 程式是在瀏覽器裡的 Apps Script 編輯器寫的(路線 A 還會在電腦存一份給 AI 改),但它跑在 Google 的機房,不在你的電腦上:LINE 收到訊息,敲的是 Google 給你的 /exec 網址,由 Google 的機器執行 doPost。所以程式裡寫 localhost,指的是 Google 那台機器自己——那裡沒有你的 Ollama。你的電腦又躲在學校或家裡的網路後面,外面找不到它。
所以中間要搭一座橋,一共多兩樣東西:
gemma4:e4b:自己決定要不要搜 → Ollama 內建的搜尋(跟路 A 地球圖示同一個服務)
← 🔎 答案+真的來源網址,一路回到手機轉接站其實就是路 C 的同一個機制(模型回「我要搜」→ 程式去搜 → 把結果送回去),只是改成「等網路上的人來問」,而且搜尋改用 Ollama 自己的搜尋服務(10/10 msi 實測不登入也能用),所以不用裝 Python,用第 0 段裝好的 Node.js 就能跑。
要裝什麼
- Ollama+
gemma4:e4b(路 A 第 1、2 步;W6 第 0 段裝過)。顯示卡要載得動模型,電腦要一直開著——關機、睡眠,bot 就查不到 - Node.js(W6 第 0 段裝過;轉接站不用
npm install任何套件) cloudflared(Cloudflare 官方的通道程式,winget 套件Cloudflare.cloudflared,免帳號)- 你 W5/W6 的 LINE bot(Apps Script),要能改程式、改指令碼屬性、更新部署
逐步做
-
winget install --id Cloudflare.cloudflared -e --accept-package-agreements --accept-source-agreements裝完關掉這個 PowerShell、新開一個(舊視窗看不到剛裝的程式),再打:
cloudflared --version你應該看到
cloudflared version 2026.…開頭的一行。 -
從課程網的 W6
starter資料夾下載,或展開下面的〈轉接站全文〉複製、用記事本存成line-search-relay.mjs(存檔類型選「所有檔案」,副檔名才不會變成 .txt)。建議放在「下載」裡新開的line-search資料夾。你應該看到:在那個資料夾開 PowerShell 打
dir,檔名是line-search-relay.mjs,不是line-search-relay.mjs.txt。 -
Ollama app 開著,在 PowerShell 進到放檔案的資料夾再跑:
cd "$env:USERPROFILE\Downloads\line-search" node line-search-relay.mjs你應該看到(⚠️預期畫面,不是實測截圖:前三行是 10/10 msi 實測,「開 Cloudflare 通道」以下照程式寫、還沒在裝了 cloudflared 的電腦跑過,老師彩排後會換成真畫面;網址每次不同):
[14:31:39] 轉接站開好了:http://127.0.0.1:8787(模型 gemma4:e4b) [14:31:39] 載入模型 gemma4:e4b(第一次要等一兩分鐘,之後每題幾秒)… [14:31:56] ✅ 模型已在顯示卡上(17 秒) 開 Cloudflare 通道中(免帳號的臨時網址,每次重開都會換)… ==================== 貼進 Apps Script 的「指令碼屬性」 ==================== ① SEARCH_RELAY_KEY = 按 Ctrl+V(密碼已經在剪貼簿,畫面上不顯示) ② SEARCH_RELAY_URL = https://(一串英文字).trycloudflare.com這個視窗不要關:關掉=通道消失、bot 查不到。密碼存在
%USERPROFILE%\.line-search-relay.key,重開轉接站還是同一組;網址每次重開都會換。印出「⛔ 模型還沒準備好,先不開通道」=Ollama 沒開或沒有gemma4:e4b,修好再跑一次。 -
跟第二關一樣:先把舊程式整份存進 Google 文件,再用自己的話把下面的目標交代給 AI(Antigravity、Codex 或瀏覽器裡的 ChatGPT/Gemini)。不給現成提示詞,講清楚就好:
- 訊息開頭是「查」加空格或冒號時,不問 Gemini、不寫試算表,把後面的問題送到我電腦上的轉接站
- 轉接站的網址和密碼從指令碼屬性
SEARCH_RELAY_URL、SEARCH_RELAY_KEY讀;密碼放在標頭x-relay-key,不放進網址 - 用 POST 送到「網址+
/ask」,內容是{"question": "問題"};回來的answer當回覆、sources裡的網址列在後面當來源 - 連不到、密碼不對、轉接站說忙碌(429)時,老實回覆原因,不要改問 Gemini(它不會搜,答案可能是猜的)
- 待辦文字、照片這些原本的功能照舊,跟這次無關的地方不要動
- 先列計畫等我同意才動手;做完給我改前/改後對照和測法
你應該看到:AI 先列計畫、停下來等你;同意後給的對照只動到「收到文字訊息」那一段和新加的查詢函式,
askGeminiAboutText、照片、寫試算表那些段落沒有變。對答案:〈bot 的對照答案〉是老師在第二關+存 Drive 的對照答案上只加這條路的版本(另外把「輸入中」動畫拉長到 60 秒、打轉接站最多等 50 秒、
testSetup多一行檢查網址和密碼)。 -
Apps Script → 左邊齒輪「專案設定」→ 最下面「指令碼屬性」→ 新增:
SEARCH_RELAY_KEY:值按 Ctrl+V(轉接站放在剪貼簿的密碼)SEARCH_RELAY_URL:值是轉接站印的https://….trycloudflare.com(後面不用加/ask)
程式改過了,要「部署 → 管理部署作業 → 鉛筆 → 版本選『新版本』」(
/exec網址不變)。之後轉接站重開、網址換了,只要改SEARCH_RELAY_URL,不用再部署——Apps Script 每處理一則訊息都會從頭跑程式、重新讀指令碼屬性。改完如果手機還回舊網址連不到,等幾秒再傳一次。你應該看到:指令碼屬性清單裡多了
SEARCH_RELAY_KEY、SEARCH_RELAY_URL兩列(名稱一個字都不能錯);管理部署作業裡的版本號比原本多 1、/exec網址跟原本一樣。 -
編輯器上方選
testSetup按執行,執行紀錄要多一行✅ 本地模型(選做):轉接站接好了(網址通、密碼對),模型 gemma4:e4b(是❌ …密碼不對就回第 5 步重貼密碼)。然後手機傳:查 2026 年諾貝爾物理獎得主是誰?你應該看到:轉接站視窗出現
📩 收到一題→🔍 模型決定搜:…→✅ 回答好了;手機收到(10/10 msi 把同一份程式接上真的轉接站模擬跑出來的回覆,⚠️真的 LINE 手機畫面待老師彩排補截圖):🔎 結論:2026年的諾貝爾物理獎得主是來自美國威斯康星大學麥迪遜分校的科學家弗朗西斯·哈爾岑。他獲得此獎項是為了對冰立方中微子天文台做出決定性貢獻…… 來源: • https://www.nobelprize.org/prizes/physics/2026/press-release/ • https://www.cas.cn/kj/202610/t20261007_5121442.shtml (本地模型 5 秒)這段是 10/10 第一版轉接站的原文:模型照搜到的大陸網站寫成「中微子」(台灣說「微中子」)、人名也照對岸譯法。之後轉接站的系統提示加了「用台灣用語」,但小模型不一定每次都照做——引用 AI 的答案前,用語和來源都要自己看過。
一般的待辦訊息、照片照舊走 Gemini、照舊寫表;只有「查」開頭的會轉給本地模型。
轉接站全文 line-search-relay.mjs(建置時從 starter 原檔塞入)
// LINE bot 的「本地模型轉接站」:讓雲端的 Apps Script 叫得到你電腦上的 Ollama,而且會上網搜
//
// 為什麼要這支:LINE bot 的程式跑在 Google 的機房,不在你的電腦上——
// 它說 localhost,是指 Google 那台機器自己,找不到你電腦裡的 Ollama。
// 所以要兩樣東西接起來:
// ① 這支小程式:在你電腦上等問題,替模型跑搜尋,要「通關密碼」才回答
// ② Cloudflare 通道(cloudflared):給這支小程式一個外面連得到的 https 網址
//
// 一則問題的旅程:
// 手機傳「查 …」→ LINE → Apps Script → 通道網址 → 這支程式(對密碼)
// → Ollama 模型自己決定要不要搜 → Ollama 內建的搜尋(web_search/web_fetch)→ 回答+真的來源網址
//
// 需要:Ollama 開著、已 pull gemma4:e4b;Node.js(W6 第 0 段裝過);cloudflared(沒有也能跑,只是外面連不到)
// 用法(PowerShell,在這個檔所在的資料夾):
// node line-search-relay.mjs 開轉接站+自動開通道,印出網址(密碼只放剪貼簿、不顯示)
// node line-search-relay.mjs --no-tunnel 只開轉接站(自己在本機測)
// 不用 npm install 任何套件,只用 Node.js 內建的功能。
import http from "node:http";
import { spawn, execFileSync } from "node:child_process";
import { randomBytes, timingSafeEqual } from "node:crypto";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
// ===== 1. 設定(想換就在 PowerShell 先設環境變數,例如 $env:MODEL="gemma4:e2b") =====
const MODEL = process.env.MODEL || "gemma4:e4b";
const PORT = Number(process.env.PORT || 8787);
const OLLAMA = process.env.OLLAMA_URL || "http://127.0.0.1:11434";
const BUDGET_MS = 45000; // LINE 的回覆碼大約 1 分鐘內要用掉,扣掉 Apps Script 自己的時間,最多想 45 秒
const MAX_ROUNDS = 4; // 模型最多來回呼叫工具幾次
const KEY_FILE = join(homedir(), ".line-search-relay.key"); // 密碼存在家目錄,重開程式還是同一組(不放進專案資料夾,免得被 git 帶走)
const WANT_TUNNEL = !process.argv.includes("--no-tunnel");
function loadKey() {
if (process.env.RELAY_KEY) return process.env.RELAY_KEY;
if (existsSync(KEY_FILE)) return readFileSync(KEY_FILE, "utf8").trim();
const key = randomBytes(18).toString("base64url");
writeFileSync(KEY_FILE, key);
return key;
}
const KEY = loadKey();
function today() {
return new Intl.DateTimeFormat("zh-TW", { timeZone: "Asia/Taipei", dateStyle: "full" }).format(new Date());
}
// 系統提示:日期可能在模型訓練之後,所以不確定就先搜(同 starter/Modelfile 的那段規矩)
function systemPrompt() {
return `你是 LINE 上的查詢助理。今天是 ${today()}(台北時間),可能比你的訓練資料新。
你有 web_search 與 web_fetch 兩個工具。遇到最近的事、日期在你知識之後的事、版本號、價格,或任何你不確定的事實,先呼叫 web_search;搜尋結果不夠明確時,用 web_fetch 讀官方頁面確認。不要以「還沒發生」為由拒絕查詢。
回答用台灣的繁體中文用語(例如「微中子」不寫「中微子」)、純文字(不要用 Markdown 的星號或井字號),150 字以內,先講結論。
不用自己寫網址,程式會附上你實際查到的來源。查不到就老實說查不到。`;
}
const TOOLS = [
{ type: "function", function: { name: "web_search", description: "搜尋網路,回傳前幾筆的標題、網址與內文摘要。",
parameters: { type: "object", properties: { query: { type: "string", description: "搜尋關鍵字" } }, required: ["query"] } } },
{ type: "function", function: { name: "web_fetch", description: "讀取一個網頁的主要文字。",
parameters: { type: "object", properties: { url: { type: "string", description: "要讀的網址" } }, required: ["url"] } } }
];
// ===== 2. 叫 Ollama:模型本身、還有 Ollama 內建的搜尋 =====
async function ollama(path, body, signal) {
const res = await fetch(OLLAMA + path, {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), signal
});
const text = await res.text();
if (!res.ok) throw new Error(`Ollama ${path} 回應 ${res.status}:${text.slice(0, 150)}`);
return JSON.parse(text);
}
// 搜尋結果的內文可能很長,截短再給模型,免得塞爆它的記憶(num_ctx)
async function webSearch(query, signal, sources) {
const data = await ollama("/api/experimental/web_search", { query, max_results: 3 }, signal);
const rows = (data.results || []).map(r => ({ title: r.title, url: r.url, content: String(r.content || "").slice(0, 1500) }));
rows.forEach(r => sources.push({ title: r.title, url: r.url }));
return JSON.stringify(rows);
}
async function webFetch(url, signal, sources) {
const data = await ollama("/api/experimental/web_fetch", { url }, signal);
sources.unshift({ title: data.title || url, url }); // 模型特地去讀的那頁,排在來源最前面
return JSON.stringify({ title: data.title, content: String(data.content || "").slice(0, 3000) });
}
async function answer(question) {
const signal = AbortSignal.timeout(BUDGET_MS);
const t0 = Date.now();
const messages = [{ role: "system", content: systemPrompt() }, { role: "user", content: question }];
const queries = [], sources = [];
let reply = "";
for (let round = 0; round < MAX_ROUNDS; round++) {
const data = await ollama("/api/chat", {
model: MODEL, messages, tools: TOOLS, stream: false, think: false,
keep_alive: "1h", options: { num_ctx: 16384 }
}, signal);
const msg = data.message || {};
messages.push(msg);
const calls = msg.tool_calls || [];
if (!calls.length) { reply = msg.content || ""; break; }
for (const c of calls) {
const fn = c.function.name, args = c.function.arguments || {};
let out;
try {
if (fn === "web_search") {
log(` 🔍 模型決定搜:${args.query}`);
queries.push(args.query);
out = await webSearch(args.query, signal, sources);
} else if (fn === "web_fetch") {
log(` 📄 模型決定讀網頁:${args.url}`);
out = await webFetch(args.url, signal, sources);
} else {
out = `ERROR: 沒有 ${fn} 這個工具`;
}
} catch (err) {
if (signal.aborted) throw err;
out = `ERROR: ${err.message}`;
}
messages.push({ role: "tool", tool_name: fn, content: out });
}
}
if (!reply) reply = "查了幾次還整理不出答案,換個問法再試一次。";
const seen = new Set();
const unique = sources.filter(s => s.url && !seen.has(s.url) && seen.add(s.url)).slice(0, 3);
return {
answer: reply.replace(/\*\*|__|^#+\s*|`/gm, "").trim().slice(0, 600),
searched: queries.length > 0 || unique.length > 0,
queries, sources: unique,
seconds: Math.round((Date.now() - t0) / 1000)
};
}
// ===== 3. 轉接站本身:只回答帶對密碼的請求 =====
function log(s) {
const t = new Date().toLocaleTimeString("zh-TW", { hour12: false, timeZone: "Asia/Taipei" });
console.log(`[${t}] ${s}`);
}
function keyMatches(given) {
const a = Buffer.from(String(given || "")), b = Buffer.from(KEY);
return a.length === b.length && timingSafeEqual(a, b);
}
function send(res, code, obj) {
res.writeHead(code, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(obj));
}
let busy = false; // 顯示卡一次只答一題;同時來第二題就請它等一下
const server = http.createServer(async (req, res) => {
const path = (req.url || "/").split("?")[0];
if (req.method === "GET" && (path === "/" || path === "/health")) {
return send(res, 200, { ok: true, service: "line-search-relay", model: MODEL }); // 給瀏覽器確認活著,不需要密碼
}
const isCheck = req.method === "GET" && path === "/check-key"; // testSetup 用:只對密碼、不叫模型
if (!isCheck && (req.method !== "POST" || path !== "/ask")) return send(res, 404, { error: "只有 POST /ask" });
if (!keyMatches(req.headers["x-relay-key"])) {
log(isCheck ? "⛔ 有人來對密碼,但密碼不對" : "⛔ 有人來問,但密碼不對,拒絕");
return send(res, 401, { error: "密碼不對" });
}
if (isCheck) return send(res, 200, { ok: true, model: MODEL });
let raw = "";
for await (const chunk of req) {
raw += chunk;
if (raw.length > 20000) return send(res, 413, { error: "問題太長" });
}
let question = "";
try { question = String(JSON.parse(raw || "{}").question || "").trim(); } catch (e) { /* 下面一起處理 */ }
if (!question) return send(res, 400, { error: "沒有收到問題(JSON 要有 question 欄位)" });
if (busy) return send(res, 429, { error: "正在回答上一題,等一下再問" });
busy = true;
log(`📩 收到一題(${question.length} 字)`); // 不把問題內容印出來:螢幕可能正在投影
try {
const result = await answer(question);
log(`✅ 回答好了(${result.seconds} 秒,${result.searched ? "有上網查" : "沒有上網查"})`);
send(res, 200, result);
} catch (err) {
const timeout = err.name === "TimeoutError" || err.name === "AbortError";
log(`❌ ${timeout ? "超過 45 秒,放棄這題" : err.message}`);
send(res, timeout ? 504 : 502, { error: timeout ? "想太久了(超過 45 秒),換個簡單一點的問法" : err.message });
} finally {
busy = false;
}
});
// ===== 4. 開機:先把模型載進顯示卡,再開通道 =====
async function warmUp() {
log(`載入模型 ${MODEL}(第一次要等一兩分鐘,之後每題幾秒)…`);
const t0 = Date.now();
try {
// num_ctx 要跟答題時一樣,不然第一題又會重新載入一次模型
await ollama("/api/chat", { model: MODEL, messages: [], keep_alive: "1h", options: { num_ctx: 16384 } }, AbortSignal.timeout(300000));
log(`✅ 模型已在顯示卡上(${Math.round((Date.now() - t0) / 1000)} 秒)`);
return true;
} catch (err) {
log(`⚠️ 模型沒載起來:${err.message}`);
log(" 先確認 Ollama app 開著、ollama list 看得到 " + MODEL);
return false;
}
}
function hasCloudflared() {
try { execFileSync("cloudflared", ["--version"], { stdio: "ignore" }); return true; } catch (e) { return false; }
}
function startTunnel() {
if (!hasCloudflared()) {
log("⚠️ 找不到 cloudflared:外面還連不到這裡。另開一個 PowerShell 貼:");
log(" winget install --id Cloudflare.cloudflared");
log(" 裝完關掉這個視窗、新開一個再跑一次本程式。");
return;
}
log("開 Cloudflare 通道中(免帳號的臨時網址,每次重開都會換)…");
const cf = spawn("cloudflared", ["tunnel", "--no-autoupdate", "--url", `http://127.0.0.1:${PORT}`], { stdio: ["ignore", "pipe", "pipe"] });
let shown = false, tail = "";
// 網址可能被切成兩段送來,所以先累積最後一段輸出再找
const watch = buf => {
tail = (tail + String(buf)).slice(-4000);
const m = tail.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/);
if (m && !shown) {
shown = true;
// 密碼不印在畫面上(螢幕可能正在投影),直接放進剪貼簿;網址單獨用沒用,可以印
let copied = false;
try { execFileSync("clip", { input: KEY }); copied = true; } catch (e) { /* 不是 Windows 就不複製 */ }
console.log("\n==================== 貼進 Apps Script 的「指令碼屬性」 ====================");
console.log(` ① SEARCH_RELAY_KEY = ${copied ? "按 Ctrl+V(密碼已經在剪貼簿,畫面上不顯示)" : "用記事本開 " + KEY_FILE + " 複製整行"}`);
console.log(` ② SEARCH_RELAY_URL = ${m[0]}`);
console.log(` 密碼要再複製一次:Get-Content "${KEY_FILE}" | Set-Clipboard`);
console.log(" 網址每次重開都會換:換了只要改指令碼屬性,不用重新部署。");
console.log(" 這個視窗不要關;關掉=bot 查不到。按 Ctrl+C 結束。");
console.log("==========================================================================\n");
}
};
cf.stdout.on("data", watch);
cf.stderr.on("data", watch);
cf.on("error", err => log(`⚠️ 通道程式開不起來:${err.message}。轉接站還在,可以另開視窗手動跑:cloudflared tunnel --url http://127.0.0.1:${PORT}`));
cf.on("exit", code => log(shown
? `⚠️ 通道結束了(代碼 ${code}):bot 現在連不到這裡,重開本程式會拿到新網址`
: `⚠️ 通道還沒拿到網址就結束了(代碼 ${code}):看網路,或另開視窗手動跑 cloudflared tunnel --url http://127.0.0.1:${PORT}`));
process.on("exit", () => cf.kill());
process.on("SIGINT", () => process.exit(0));
}
server.listen(PORT, "127.0.0.1", async () => { // 只聽本機:外面只能從通道進來
log(`轉接站開好了:http://127.0.0.1:${PORT}(模型 ${MODEL})`);
const ready = await warmUp();
if (WANT_TUNNEL && !ready) {
log("⛔ 模型還沒準備好,先不開通道(開了 bot 問也只會收到錯誤)。修好 Ollama 後按 Ctrl+C、再跑一次本程式。");
} else if (WANT_TUNNEL) startTunnel();
else {
console.log(`\n本機測試(另開 PowerShell 貼):`);
const keyExpr = process.env.RELAY_KEY ? "$env:RELAY_KEY" : `(Get-Content "${KEY_FILE}")`;
console.log(` Invoke-RestMethod -Method Post -Uri http://127.0.0.1:${PORT}/ask -Headers @{"x-relay-key"=${keyExpr}} -ContentType "application/json; charset=utf-8" -Body ([Text.Encoding]::UTF8.GetBytes('{"question":"2026 年諾貝爾物理獎得主是誰?"}'))\n`);
}
});讀法:answer 那個迴圈跟路 C 一樣——模型回 tool_calls 就去執行、把結果以 role: tool 送回;差別是工具改打 Ollama 自己的 /api/experimental/web_search、web_fetch。keyMatches 對密碼;busy 讓顯示卡一次只答一題;BUDGET_MS 45 秒是因為 LINE 的回覆碼大約 1 分鐘內要用掉。
bot 的對照答案 Code.search-reference.gs(建置時從 starter 原檔塞入)
/**
* LINE AI 待辦小秘書(Level 2:文字+照片+存 Drive;W6 選做:「查 …」交給本地模型上網搜)
*
* 這份是 W6 選做的對照答案:在 Code.drive-reference.gs 上只加「查 …」這一條路,其他不動。
* 一則訊息的旅程:
* LINE 送來訊息 → 請 Gemini 把口語整理成固定欄位 → 程式驗算欄位
* → 寫進試算表 → 回覆 LINE(只講已經做完的事)→ 排 Google 日曆,結果寫回試算表
* 「查 …」開頭的:不問 Gemini、不寫表,轉給你電腦上的本地模型(line-search-relay.mjs)查好再回覆
*
* 這份程式跑在 Google 的機房,不在你的電腦上:程式裡的 localhost 是 Google 那台機器自己,
* 所以要靠 Cloudflare 通道給你電腦上的轉接站一個外面連得到的網址。
*
* 金鑰不寫在程式裡,一律放「專案設定 → 指令碼屬性」:
* GEMINI_API_KEY 必填,到 Google AI Studio 申請
* LINE_CHANNEL_ACCESS_TOKEN 必填,LINE Developers 後台發的長效 token
* SHEET_ID 選填,程式不是從試算表「擴充功能 → Apps Script」開的才要填
* GEMINI_MODEL 選填,模型停用或想換模型時改這裡,不用動程式
* SEARCH_RELAY_URL 選填,轉接站印出來的 https://….trycloudflare.com(每次重開都會換,換了改這裡就好、不用重新部署)
* SEARCH_RELAY_KEY 選填,轉接站的通關密碼(它放在剪貼簿給你貼)
*/
// ===== 1. 設定 =====
const PROPS = PropertiesService.getScriptProperties();
const GEMINI_API_KEY = PROPS.getProperty("GEMINI_API_KEY") || "";
const LINE_ACCESS_TOKEN = PROPS.getProperty("LINE_CHANNEL_ACCESS_TOKEN") || "";
const SHEET_ID = PROPS.getProperty("SHEET_ID") || "";
// 模型名稱會隨 Google 改版而停用:上課前到 https://ai.google.dev/gemini-api/docs/models 確認還在
const GEMINI_MODEL = PROPS.getProperty("GEMINI_MODEL") || "gemini-3.5-flash-lite";
const SEARCH_RELAY_URL = (PROPS.getProperty("SEARCH_RELAY_URL") || "").trim().replace(/\/+$/, "");
const SEARCH_RELAY_KEY = (PROPS.getProperty("SEARCH_RELAY_KEY") || "").trim();
const SEARCH_PREFIX = /^(?:查|搜尋)[\s::]+([\s\S]+)$/; // 「查 」「查:」「搜尋 」開頭才轉給本地模型;「查一下明天…」照舊當待辦
const TZ = "Asia/Taipei";
const CATEGORIES = ["繳費", "會議", "課業", "生活", "其他"];
const HEADERS = ["建立時間", "事項", "分類", "日期", "時間", "來源", "日曆", "照片連結"];
const GREETING = /^(hi|hello|hey|你好|嗨|哈囉|安安|早安|午安|晚安|謝謝|感謝|ok|好的|掰|bye|test|測試)$/i;
const HELP_TEXT = "你好!我是你的 AI 待辦小秘書 🤖\n\n直接傳給我:\n•「明天下午三點要開會」\n•「週五前記得繳房租」\n• 或拍一張活動海報、便條紙\n\n我會記進試算表;有日期和時間的,再幫你排進 Google 日曆。\n\n想查最新消息,開頭打「查」加一個空格:\n•「查 今年諾貝爾物理獎得主是誰」\n(要我的本地模型開著才查得到)";
// ===== 2. LINE 送訊息進來的入口 =====
function doPost(e) {
try {
const body = JSON.parse((e && e.postData && e.postData.contents) || "{}");
// 在 LINE 後台按 Verify 時,送來的 events 是空的,這裡就什麼都不做
(body.events || []).forEach(handleEvent);
} catch (err) {
console.error("讀不懂 LINE 送來的內容:", err);
}
return ContentService.createTextOutput("OK");
}
// 用瀏覽器打開 /exec 網址會看到這行字,可以確認部署成功
function doGet() {
return ContentService.createTextOutput("LINE AI 待辦小秘書運作中(Level 2+本地模型查詢)");
}
// 一次處理一則訊息;其中一則出錯,不影響同一批的其他訊息
function handleEvent(event) {
if (event.type !== "message" || !event.replyToken) return;
try {
const msg = event.message;
if (msg.type === "text") {
const text = msg.text.trim();
if (GREETING.test(text)) return replyLine(event.replyToken, HELP_TEXT); // 打招呼不必問 AI,直接回
const asked = text.match(SEARCH_PREFIX);
if (asked) { // 查詢不是待辦:不問 Gemini、不寫表,交給本地模型
showLoading(event.source, 60);
return replyLine(event.replyToken, askLocalSearch(asked[1].trim()));
}
showLoading(event.source);
return saveAndReply(event.replyToken, askGeminiAboutText(text), "文字");
}
if (msg.type === "image") {
showLoading(event.source);
const image = downloadLineImage(msg.id);
const photoUrl = savePhotoToDrive(image); // 先把照片存進 Drive,拿到連結
return saveAndReply(event.replyToken, askGeminiAboutImage(image), "照片", photoUrl);
}
replyLine(event.replyToken, "目前我看得懂文字和照片,其他類型還不行喔。");
} catch (err) {
console.error("處理訊息失敗:", err);
// 課堂版刻意把錯誤原因回給手機:學生可以整段貼給 AI,問它怎麼修
try {
replyLine(event.replyToken, "⚠️ 出錯了:" + err.message);
} catch (err2) {
console.error("連錯誤訊息都回不出去(多半是 LINE token 沒設定或貼錯):", err2);
}
}
}
// ===== 3. 記錄、回覆、排日曆 =====
function saveAndReply(replyToken, todo, source, photoUrl) {
if (!todo.isTodo) return replyLine(replyToken, todo.chatReply || HELP_TEXT);
// ① 先寫進試算表:確定真的寫進去了,才能跟使用者說「記好了」
const row = appendTodoRow(todo, source, photoUrl || "");
const wantsCalendar = todo.date !== "未指定" && todo.time !== "未指定";
// ② 回覆手機:只講已經做完的事(回覆碼要在收到訊息後 1 分鐘內用掉)
let reply = `✅ 記好了(試算表第 ${row} 列)\n\n📌 ${todo.summary}\n🏷️ ${todo.category}\n📅 ${todo.date} ⏰ ${todo.time}`;
reply += wantsCalendar
? "\n🗓️ 日曆提醒建立中,結果會寫在試算表的「日曆」欄"
: "\nℹ️ 沒有明確的日期和時間,這筆先不排日曆";
if (photoUrl) reply += "\n📷 照片已存進 Drive 的「LINE待辦照片」資料夾";
replyLine(replyToken, reply);
// ③ 最後才排日曆;成功或失敗都寫回試算表,不讓人誤以為做好了
if (wantsCalendar) getSheet().getRange(row, 7).setValue(createCalendarEvent(todo));
}
function getSheet() {
const ss = SHEET_ID ? SpreadsheetApp.openById(SHEET_ID) : SpreadsheetApp.getActiveSpreadsheet();
if (!ss) throw new Error("找不到試算表:請從試算表的「擴充功能 → Apps Script」開程式,或在指令碼屬性填 SHEET_ID");
const sheet = ss.getSheets()[0];
if (sheet.getLastRow() === 0) {
sheet.appendRow(HEADERS);
sheet.getRange(1, 1, 1, HEADERS.length).setFontWeight("bold").setBackground("#0E7C7B").setFontColor("#FFFFFF");
}
return sheet;
}
function appendTodoRow(todo, source, photoUrl) {
const sheet = getSheet();
const now = Utilities.formatDate(new Date(), TZ, "yyyy-MM-dd HH:mm:ss");
// 全班同時傳訊息時,先排隊再寫,避免兩筆搶到同一列
const lock = LockService.getScriptLock();
lock.waitLock(10000);
try {
sheet.appendRow([now, todo.summary, todo.category, todo.date, todo.time, source, "", photoUrl || ""]);
return sheet.getLastRow();
} finally {
lock.releaseLock();
}
}
// 照片先存進 Google Drive 的「LINE待辦照片」資料夾(沒有就建一個),回傳檔案連結
function savePhotoToDrive(blob) {
const folderName = "LINE待辦照片";
const found = DriveApp.getFoldersByName(folderName);
const folder = found.hasNext() ? found.next() : DriveApp.createFolder(folderName);
const stamp = Utilities.formatDate(new Date(), TZ, "yyyyMMdd-HHmmss");
const file = folder.createFile(blob.setName("line-" + stamp + ".jpg"));
return file.getUrl();
}
function createCalendarEvent(todo) {
try {
const start = new Date(`${todo.date}T${todo.time}:00+08:00`);
const end = new Date(start.getTime() + 30 * 60 * 1000);
const event = CalendarApp.getDefaultCalendar().createEvent(`【${todo.category}】${todo.summary}`, start, end, {
description: "由 LINE AI 待辦小秘書建立"
});
event.addPopupReminder(30);
return "已建立";
} catch (err) {
console.error("建立日曆失敗:", err);
return "失敗:" + err.message;
}
}
// ===== 4. 請 Gemini 把口語整理成固定欄位 =====
const JSON_SHAPE = `只輸出一個 JSON 物件,欄位固定如下:
{"isTodo": true 或 false,
"summary": "事項,簡短一句",
"category": "繳費、會議、課業、生活、其他 五選一",
"date": "YYYY-MM-DD,沒提到就填 未指定",
"time": "HH:mm(24 小時制),沒提到就填 未指定",
"chatReply": "不是待辦時,給使用者的一句簡短回覆;是待辦就填空字串"}`;
// 附上星期幾,AI 才算得準「下週五」是哪一天
function nowText() {
return Utilities.formatDate(new Date(), TZ, "yyyy-MM-dd EEEE HH:mm");
}
function askGeminiAboutText(text) {
const prompt = `你是待辦事項助理。現在是台北時間 ${nowText()}。
判斷使用者這句話是不是待辦、提醒或行程。
是的話,把「明天」「下週五」這類說法換算成實際日期;不是的話,isTodo 填 false。
${JSON_SHAPE}
使用者說:${text}`;
return checkTodo(callGemini([{ text: prompt }]));
}
function askGeminiAboutImage(blob) {
const prompt = `你是待辦事項助理。現在是台北時間 ${nowText()}。
這是一張照片,可能是活動海報、便條紙或繳費單。找出上面最重要的一件待辦或活動。
照片上沒寫的欄位就填 未指定,不要自己猜。
${JSON_SHAPE}`;
return checkTodo(callGemini([
{ text: prompt },
{ inlineData: { mimeType: blob.getContentType() || "image/jpeg", data: Utilities.base64Encode(blob.getBytes()) } }
]));
}
function callGemini(parts) {
if (!GEMINI_API_KEY) throw new Error("還沒設定 GEMINI_API_KEY(專案設定 → 指令碼屬性)");
const res = UrlFetchApp.fetch(`https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent`, {
method: "post",
contentType: "application/json",
headers: { "x-goog-api-key": GEMINI_API_KEY }, // 金鑰放標頭、不放網址,才不會留在各種紀錄裡
payload: JSON.stringify({
contents: [{ role: "user", parts: parts }],
generationConfig: { responseMimeType: "application/json" }
}),
muteHttpExceptions: true
});
const code = res.getResponseCode();
if (code !== 200) throw new Error(`Gemini 回應 ${code}:${res.getContentText().slice(0, 150)}`);
const data = JSON.parse(res.getContentText());
const cand = (data.candidates || [])[0];
const text = cand && cand.content && (cand.content.parts || []).map(p => p.text || "").join("");
if (!text) throw new Error(`Gemini 沒有給內容(原因:${cand ? cand.finishReason : "沒有任何答案"})`);
return JSON.parse(text.replace(/^```(json)?\s*|\s*```$/g, ""));
}
// ===== 4-2. 「查 …」:交給你電腦上的本地模型(W6 選做) =====
// 免費的 Gemini 沒有上網搜尋;本地模型+Ollama 內建的搜尋有。
// 連不到就老實說連不到,不改問 Gemini——它不會搜,答案可能是猜的。
function askLocalSearch(question) {
if (!SEARCH_RELAY_URL || !SEARCH_RELAY_KEY) {
return "這支 bot 還沒接上本地模型(指令碼屬性要有 SEARCH_RELAY_URL 和 SEARCH_RELAY_KEY)。";
}
let res;
try {
res = UrlFetchApp.fetch(SEARCH_RELAY_URL + "/ask", {
method: "post",
contentType: "application/json",
headers: { "x-relay-key": SEARCH_RELAY_KEY }, // 密碼放標頭、不放網址
payload: JSON.stringify({ question: question }),
muteHttpExceptions: true,
timeoutSeconds: 50 // 預設會等 6 分鐘;LINE 的回覆碼大約 1 分鐘就失效,等不起
});
} catch (err) {
console.error("連不到轉接站:", err);
return "🔌 本地模型現在連不到(或等超過 50 秒):電腦關了、轉接站的視窗被關掉,或通道換了網址(指令碼屬性的 SEARCH_RELAY_URL 要改成新的)。";
}
const code = res.getResponseCode();
let data = {};
try { data = JSON.parse(res.getContentText()); } catch (err) { /* 不是 JSON:多半是通道已經關了,下面一起處理 */ }
if (code === 401) return "🔒 轉接站說密碼不對:檢查指令碼屬性的 SEARCH_RELAY_KEY。";
if (code === 429) return "⏳ 本地模型正在回答上一題,等十幾秒再問一次。";
if (code !== 200 || !data.answer) {
console.error("轉接站回應", code, res.getContentText().slice(0, 200));
return `🔌 本地模型這次沒答出來(${code}):${data.error || "通道可能已經關了,或網址換了"}`;
}
let reply = "🔎 " + data.answer;
if (data.sources && data.sources.length) {
reply += "\n\n來源:\n" + data.sources.map(s => "• " + s.url).join("\n");
} else {
reply += "\n\nℹ️ 這題模型沒有上網查,只憑記憶回答,請自己再核對。";
}
return reply + `\n(本地模型 ${data.seconds} 秒)`;
}
// ===== 5. 驗算:AI 給的欄位不直接照單全收 =====
function checkTodo(t) {
return {
isTodo: t.isTodo === true,
summary: String(t.summary || "").trim() || "(沒抓到事項)",
category: CATEGORIES.includes(t.category) ? t.category : "其他",
date: isRealDate(t.date) ? t.date : "未指定",
time: /^([01]\d|2[0-3]):[0-5]\d$/.test(t.time) ? t.time : "未指定",
chatReply: String(t.chatReply || "")
};
}
// 「2026-02-30」格式對、日子卻不存在:轉成日期再轉回來,對得上才算數
function isRealDate(s) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return false;
const d = new Date(`${s}T12:00:00+08:00`);
return !isNaN(d.getTime()) && Utilities.formatDate(d, TZ, "yyyy-MM-dd") === s;
}
// ===== 6. LINE 相關 =====
function lineApi(path, payload, host) {
if (!LINE_ACCESS_TOKEN) throw new Error("還沒設定 LINE_CHANNEL_ACCESS_TOKEN(專案設定 → 指令碼屬性)");
const options = {
method: payload ? "post" : "get",
headers: { Authorization: "Bearer " + LINE_ACCESS_TOKEN },
muteHttpExceptions: true
};
if (payload) {
options.contentType = "application/json";
options.payload = JSON.stringify(payload);
}
return UrlFetchApp.fetch((host || "https://api.line.me") + path, options);
}
function replyLine(replyToken, text) {
const res = lineApi("/v2/bot/message/reply", {
replyToken: replyToken,
messages: [{ type: "text", text: text.slice(0, 5000) }]
});
if (res.getResponseCode() !== 200) console.error("LINE 回覆失敗:", res.getResponseCode(), res.getContentText());
}
// 「對方正在輸入…」動畫:只有一對一聊天看得到,沒開成也不影響功能
function showLoading(source, seconds) {
if (!source || source.type !== "user") return;
try {
lineApi("/v2/bot/chat/loading/start", { chatId: source.userId, loadingSeconds: seconds || 20 });
} catch (err) {
console.warn("輸入中動畫沒開成:", err);
}
}
// 照片要到另一個網域(api-data)下載,而且 LINE 不會一直保存,收到就要抓
function downloadLineImage(messageId) {
const res = lineApi(`/v2/bot/message/${messageId}/content`, null, "https://api-data.line.me");
if (res.getResponseCode() !== 200) throw new Error(`照片下載失敗(LINE 回應 ${res.getResponseCode()})`);
const blob = res.getBlob();
// 送給 Gemini 的請求有大小上限,照片轉成文字(Base64)還會再大三分之一,太大的先請使用者縮小
if (blob.getBytes().length > 10 * 1024 * 1024) throw new Error("照片太大(超過 10 MB),請截圖或壓縮後再傳");
return blob;
}
// ===== 7. 自我檢查:在編輯器上方選 testSetup,按「執行」 =====
function testSetup() {
const results = [];
const check = (name, fn) => {
try {
results.push(`✅ ${name}:${fn()}`);
} catch (err) {
results.push(`❌ ${name}:${err.message}`);
}
};
check("LINE token", () => {
const res = lineApi("/v2/bot/info");
if (res.getResponseCode() !== 200) throw new Error(`LINE 回應 ${res.getResponseCode()},token 可能貼錯或已經重發`);
return "bot 名稱「" + JSON.parse(res.getContentText()).displayName + "」";
});
check("Google 日曆", () => "可以使用「" + CalendarApp.getDefaultCalendar().getName() + "」");
let todo = null;
check("Gemini", () => {
todo = askGeminiAboutText("明天早上十點要交房租,記得轉帳給房東");
return JSON.stringify(todo);
});
check("試算表", () => {
if (!todo) throw new Error("Gemini 那關沒過,先不寫");
return `寫進第 ${appendTodoRow(todo, "testSetup")} 列`;
});
check("本地模型(選做)", () => {
if (!SEARCH_RELAY_URL) return "沒設定 SEARCH_RELAY_URL,「查 …」功能先不開";
// 只對密碼、不叫模型:網址通、密碼也對,才算接好
const res = UrlFetchApp.fetch(SEARCH_RELAY_URL + "/check-key", {
headers: { "x-relay-key": SEARCH_RELAY_KEY }, muteHttpExceptions: true, timeoutSeconds: 20
});
if (res.getResponseCode() === 401) throw new Error("轉接站活著,但密碼不對:檢查 SEARCH_RELAY_KEY");
if (res.getResponseCode() !== 200) throw new Error(`轉接站回應 ${res.getResponseCode()}:電腦上的轉接站視窗開著嗎?網址是不是新的那個?`);
return "轉接站接好了(網址通、密碼對),模型 " + JSON.parse(res.getContentText()).model;
});
console.log(results.join("\n"));
console.log("👉 打開試算表,親眼看到多了一列才算成功。出現權限錯誤,或這裡說成功、表裡卻沒有,多半是授權畫面沒有全部勾選。");
}新加的部分:檔頭兩個指令碼屬性、SEARCH_PREFIX、handleEvent 裡「查」的那幾行、askLocalSearch 整個函式、showLoading 多一個秒數參數、testSetup 最後一項。appsscript.json 不用改:打外部網址的權限第一關就有了。
卡住了?(LINE bot 也會查)
- 手機回「🔌 本地模型現在連不到」:轉接站視窗被關、電腦睡著,或通道換了網址。看轉接站視窗還在不在;重開過就把新網址貼回
SEARCH_RELAY_URL。 - 回「🔒 轉接站說密碼不對」:
SEARCH_RELAY_KEY貼錯(多了空白或貼成網址)。PowerShell 跑Get-Content "$env:USERPROFILE\.line-search-relay.key" | Set-Clipboard再貼一次。 - 回「這支 bot 還沒接上本地模型」:兩個指令碼屬性名稱打錯,或沒加。
- 回「⏳ 本地模型正在回答上一題」:顯示卡一次只答一題,等十幾秒再問。
- 回「🔌 本地模型這次沒答出來(504):想太久了」:超過 45 秒。第一題要先把模型載進顯示卡,轉接站開好後先等它印「✅ 模型已在顯示卡上」再問;題目換簡單一點。
- 「查一下明天要交的作業」被當成待辦:正常。「查」後面要空格或冒號才算查詢,免得一般待辦被誤判。
- 轉接站印「⚠️ 找不到 cloudflared」:第 1 步裝完沒有新開視窗;新開 PowerShell 再跑一次。
- 轉接站印「⚠️ 模型沒載起來」:Ollama app 沒開,或
ollama list沒有gemma4:e4b。8 GB 顯示卡同時開著 LM Studio 也會失敗(見〈共同的坑〉)。 - 手機回答了、卻寫「這題模型沒有上網查」:模型判斷不用搜就直接答了,不是每次都穩;問具體一點、或再問一次。
- 改了程式,手機還是舊行為:忘了「管理部署作業 → 新版本」。
%USERPROFILE%\.line-search-relay.key 再開,會產生新密碼(記得重貼)。不用的時候按 Ctrl+C,通道就消失。這不是私密管道:模型在你電腦上,但問題會經過 LINE、Google、Cloudflare,搜尋那一下還會送到 Ollama 的線上搜尋服務,別拿它查私事或貼個資。另外,Apps Script 讀不到 LINE 附在訊息上的簽章(W5 教材講過),知道你
/exec 網址的人能假冒 LINE 送「查 …」進來,所以 /exec 網址一樣不要公開。優點
- bot 第一次「上網查最新消息、附真的來源」,免費 Gemini 做不到的事。
- 模型在自己電腦、沒有 API 費用;不用另外申請搜尋 API 金鑰。
- 原本的待辦、照片功能一行都沒動,查不到時老實說。
缺點
- 你的電腦要一直開著、轉接站視窗不能關;臨時網址每次重開都換。
- 一次只能答一題,不適合很多人同時問。
- 多了一座橋就多一個會斷的地方:通道、轉接站、Ollama 任何一個沒開,都只會回「連不到」。
三道考題:不開搜尋問一次、開搜尋問一次
這三題都是「要搜尋才答得對」:兩題是 2026 年 10 月初剛公布的諾貝爾獎,一題是軟體版本號。建議同一題問兩次——先不開搜尋(路 A 把地球關掉),再開搜尋——只看兩件事:答對沒、有沒有附來源。msi 實測不開搜尋時,Gemma 4 e4b 三題都答「不知道/尚未公布」。
標準答案查核日:2026-10-10(查核方式:網路搜尋+GitHub API)。
| # | 題目 | 標準答案 | 出處(查核日) |
|---|---|---|---|
| 1 | 2026 年諾貝爾物理獎得主是誰?得獎理由是什麼? | Francis Halzen 一人獨得;理由=對 IceCube 微中子觀測站的決定性貢獻、偵測到來自天體的高能微中子。2026-10-06 斯德哥爾摩公布。 | nobelprize.org/prizes/physics/2026;Daily Maverick 2026-10-06 |
| 2 | 2026 年諾貝爾生理醫學獎的三位得主是誰?研究什麼? | Karl Deisseroth(史丹佛)、Peter Hegemann(柏林洪堡大學)、Georg Nagel(符茲堡大學),各三分之一;光遺傳學(光控離子通道,用光開關單一神經元)。2026-10-05 公布。 | nobelprize.org/prizes/medicine/2026;NPR 2026-10-05 |
| 3 | Ollama 目前最新的正式版本號是多少?什麼時候發布的? | v0.40.2,2026-10-08(UTC 16:53)發布。 | gh api repos/ollama/ollama/releases/latest(2026-10-10 查;10/09 查時還是 v0.40.1) |
gh api repos/ollama/ollama/releases/latest。10/09 路 C 實走時模型答 v0.40.2,當天 releases/latest 查到的卻還是 v0.40.1;10/10 重查,v0.40.2 已經是正式版——「最新版」一天之內就可能換,答案要跟查核日一起看。英文版題目(AA 班用)
- Who won the 2026 Nobel Prize in Physics, and for what?
- Who are the three laureates of the 2026 Nobel Prize in Physiology or Medicine, and what is their work about?
- What is the latest stable release version of Ollama, and when was it released?
共同的坑(四條路都會踩到)
- 8 GB 顯示卡一次只能載一套:Ollama 和 LM Studio 同時把 Gemma 4 e4b 載進顯示卡記憶體(VRAM)會撞,Ollama 回 500「CUDA error」。只裝一套,或換邊前先
lms unload --all(LM Studio)/關掉另一邊。實測 Ollama 載gemma4:e4b用掉 6.0 GB。 - 思考模式會拖慢、甚至拒搜:LM Studio 開著 Think 想了 7 分 31 秒才下工具;Ollama app 用原始
gemma4:e4b想 19 秒後認定「2026 還沒到」不搜。路 C 的程式直接think: false,路 A 靠 Modelfile 的系統提示,路 B 要手動關 Think。 - 第一次載入要等:Ollama 首問含載入 150 秒(不開搜尋量到的)、之後每題約 6 秒(82–87 tok/s);LM Studio 載入約 15 秒、31 tok/s,思考預設開時每題 32–43 秒。
- 模型政策:本課只用美國公司的開放模型——Gemma 4(Google)與 gpt-oss(OpenAI);不用 Qwen、DeepSeek、GLM。Ollama app 預設的
glm-5.3-flash:cloud就是要換掉的那一個。 - 下載速度差很多:
ollama pull約 36 MB/s;LM Studio 內建下載在這台只有 0.5–0.8 MB/s;Hugging Face 直接下載 29–42 MB/s。課堂若用 LM Studio,模型檔要另外給下載法。 - 模型判斷不是百分之百:同一題偶爾會拒搜或答非所問(路 A 的「請告訴我你要我做什麼」)。再問一次、把題目講具體、或換有系統提示的版本。
- 題目不用寫「請搜尋」:三題實測題目本身都不提搜尋,模型照樣自己決定搜。寫了反而測不出「它會不會判斷」。
版本對照(2026-10-09 msi 實查)
之後版本會變,這裡記的是實走當天的狀態,讓你對得上「我裝的跟教材一不一樣」。
| 東西 | 版本/大小 | 怎麼裝或哪裡拿 | 用在 |
|---|---|---|---|
| Ollama | 0.40.1 | winget install Ollama.Ollama | 路 A、C、D |
| Gemma 4 e4b(Ollama 版) | gemma4:e4b,6.6 GB | ollama pull gemma4:e4b(msi 211 秒) | 路 A、C、D |
| LM Studio | 0.4.25 | winget install ElementLabs.LMStudio | 路 B |
| Gemma 4 e4b(LM Studio 版) | gemma-4-E4B-it-Q4_K_M.gguf 5.34 GB+mmproj-gemma-4-E4B-it-BF16.gguf 0.99 GB | Hugging Face lmstudio-community 直接下載(msi 182 秒)→ lms import | 路 B |
| uv | 0.9.7 | pip install uv 或 winget install astral-sh.uv | 路 B |
| ddgs(Python 套件) | 9.16.0(msi 2026-10-09 實走) | pip install ddgs | 路 C |
| Page Assist | 1.5.86 | Chrome 線上應用程式商店 | 路 D(不推薦) |
| nomic-embed-text | 274 MB | ollama pull nomic-embed-text(12 秒) | 路 D(不推薦) |
| 顯示卡 | RTX 2080 Super 8 GB,驅動 616.56 | — | 所有實測數字的前提 |
勾選進度
上面每一步左邊的方框可以勾。進度存在這台瀏覽器的 localStorage,不會上傳、換電腦就不見。
全部已勾 0/0 步