和 AI 一起寫程式:創意生活應用 WEEK 7 2026-10-19(一)09:10–12:00 E403
bot 留在雲端當接收端;「查 …」轉回你電腦的本地模型;期中做你自己想做的東西
今天三件事:
1. Webhook 是什麼:bot 留在雲端當接收端
2. 「查 …」轉回你電腦的本地模型
3. 期中開放題:做你自己想做的東西
上週的簡單版是「一支 Python 就是 bot」,整支 bot 都跑在你的電腦上、電腦關了 bot 就死。今天把它倒過來:bot 留在 W5 那個 Apps Script(Google 機房、一直開著),只有「查 …」那一條路轉回你電腦上的本地模型(會上網搜、附來源),答完原路回到手機。你的工作跟 W5、W6 一樣:看計畫、按同意、看對照、退回一次。
這一頁怎麼用:每一步有「指令或畫面 → 你應該看到 → 測一下 → 卡住了」四件,照順序做、做完打勾。勾選只存在這台瀏覽器裡;E403 的電腦有還原卡,重開機就全部沒了(今天裝的東西也一樣)。想帶走進度就截圖。指令全部是 PowerShell 寫法,按「複製」再貼。
- 第 0 段 一坐下就貼——A、B、C 三塊貼完就去第 1 段;D 選做
- 第 1 段 Webhook 是什麼+你的 bot 還活著嗎——先確定 W5 的 bot 活著、Webhook 指回 /exec
- 第 2 段 開轉接站——第一次下課前開起來、本機測一題
- 第 3 段 Apps Script 加「查 …」——10:50 還沒做到 3-4,就停下來看老師示範
- 第 4 段 期中開放題——第二次下課後;寫一句「我期中想做什麼」
- 第 5 段 收尾與帶走——Ctrl+C 關轉接站、登出
- 卡關總表——出錯了先用關鍵字查這裡
鐘響 10:00、11:00 各休息一次。開場老師會請你舉手三樣:回家作業的對話截圖、repo 連結、一句話——這是上週的成果,也是今天的起點。
第 0 段 一坐下就貼
這段做完你會有什麼:三個安裝在背景跑(本地模型 6.6 GB、Node.js、cloudflared),你可以去做第 1 段。
開 PowerShell(開始功能表搜「PowerShell」,出現的 Windows PowerShell 就可以,不用系統管理員)。下面 A、B、C 三塊整段複製、貼進去、按 Enter,貼完就去第 1 段;D 是選做、主線用不到。這週不裝 gws(W6 第 2 段卡住的,課後用 W6 那頁自己補)。
-
Ollama(跑本地模型的程式)用 winget(Windows 內建的套件安裝指令)裝,不用管理員權限;裝完先把它的路徑加進這個視窗(第二行:PATH 是電腦找程式用的路徑清單,剛裝的程式要加進去才叫得到),第三行會另開一個 PowerShell 視窗下載
gemma4:e4b(Google 的 Gemma 4 模型,6.6 GB)——進度條在那個新視窗跑,你回這個視窗繼續貼 B。跟 W6 一字不差。winget install --id Ollama.Ollama -e --accept-package-agreements --accept-source-agreements --silent $env:PATH += ";$env:LOCALAPPDATA\Programs\Ollama" Start-Process powershell -ArgumentList '-NoExit','-Command','ollama pull gemma4:e4b'
你應該看到winget 跑完說已成功安裝;接著跳出第二個 PowerShell 視窗,裡面出現pulling manifest(在讀模型的目錄清單),然後一條進度條慢慢往 6.6 GB 走。老師的電腦實測約三分半(211 秒);全班一起拉、教室網路會慢很多,先讓它跑。那個視窗不要關。測一下進度條有在動就過。不用等它跑完,直接貼 B。卡住了
- winget 說找不到或被擋→ 到官方網站 ollama.com 下載 Windows 安裝檔雙擊裝,裝完一樣跑第二、三行。
ollama不是指令、或第二個視窗一閃就關→ 第二行(加 PATH)沒貼到;重貼第二、三行。還是不行就關掉 PowerShell 重開一個(新開的視窗才會讀到剛裝好的程式)。- 進度條卡住不動→ 教室網路慢,先往下做;到第 2 段還沒好,兩人一組用已經拉完的那台開轉接站(一台轉接站可以給兩個 bot 用)。
-
轉接站是一支用 Node.js(跑 JavaScript 的環境)寫的小程式,所以要裝 Node.js。第二行把 Node 的路徑加進這個視窗(跟 0-A 同一個道理)。這週沒有 W6 那行 npm 裝 gws。
winget install --id OpenJS.NodeJS.LTS -e --accept-package-agreements --accept-source-agreements --silent $env:PATH += ";$env:ProgramFiles\nodejs;$env:APPDATA\npm"
你應該看到Node.js 裝的時候可能跳出「使用者帳戶控制」問你要不要允許,按「是」;winget 最後說已成功安裝。測一下沒有紅字就過。真正的驗證在 0-C 最後那三行(要新開視窗)。卡住了
- 「使用者帳戶控制」要管理員密碼→ 舉手,老師處理;先去做第 1 段。
- winget 說已經裝過、版本不同→ 電腦本來就有 Node;v18 以上都夠用,不用管。
-
cloudflared 是 Cloudflare 的通道(tunnel)程式,免帳號:等一下它會給你電腦上的轉接站一個外面連得到的臨時 https 網址,Google 機房的 Apps Script 才找得到你的電腦。不用 winget 裝(它的安裝程式要系統管理員權限,教室電腦按不了):下面五行先在你的使用者資料夾(家目錄)建一個
line-relay資料夾、進去,把官方的單一執行檔下載成cloudflared.exe,再把這個資料夾加進 PATH(Node 開程式不會在目前資料夾找,一定要這行),最後印版本。等一下的轉接站也放進同一個資料夾。New-Item -ItemType Directory -Force "$env:USERPROFILE\line-relay" | Out-Null Set-Location "$env:USERPROFILE\line-relay" Invoke-WebRequest -Uri https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe -OutFile cloudflared.exe $env:PATH += ";$env:USERPROFILE\line-relay" cloudflared --version
你應該看到提示字元變成…\line-relay>;Invoke-WebRequest跑一陣子(檔案約 50 MB,教室網路慢的話一兩分鐘,上面會有進度條),結束後最後一行印cloudflared version 2026.10.1(老師 10/17 下載到的版本;之後會更新,2026 開頭就對)。測一下最後一行印出版本號就過。PATH 那行只對這個視窗有效:之後每開一個新的 PowerShell 視窗,要再貼一次$env:PATH += ";$env:USERPROFILE\line-relay",cloudflared 才叫得到(2-2 開轉接站那三行已經包了)。另外新開一個視窗驗 A、B 裝好沒(舊視窗的 PATH 沒有剛裝的程式;驗完這個視窗先別關,2-3 本機測試還要用):前兩行各印出一個版本號(ollama --version node -v ollama list
ollama version is 0.40.x、v24.x);第三行等 A 拉完會有一列gemma4:e4b,還在拉就是空的,等一下再看。卡住了
Invoke-WebRequest紅字(遠端名稱無法解析、連線被拒之類)→ 教室網路連不到 GitHub:用瀏覽器開那個網址(整條複製貼到網址列)會直接下載,把檔案搬到line-relay資料夾、改名成cloudflared.exe,再貼最後兩行。- 下載很慢、進度條跳很久→ 舊版 PowerShell 的進度條會拖慢下載:按 Ctrl+C,先打
$ProgressPreference='SilentlyContinue',再貼第三行起的三行。 cloudflared不是指令→ PATH 那行沒貼到、或你換了視窗(PATH 只對貼過的那個視窗有效)。在這個視窗再貼第四、五行。- 自己的筆電、有管理員權限→ 用 winget 裝也可以(會跳「使用者帳戶控制」,按「是」):
winget install --id Cloudflare.cloudflared -e --accept-package-agreements --accept-source-agreements。裝完要新開視窗,不用 PATH 那行。 - winget 版印「安裝程式失敗,結束代碼:1602」→ 就是沒按到管理員那關;改用上面的免安裝五行。
- cloudflared 怎樣都拿不到→ 先往下做:轉接站沒有通道也開得起來(2-3 本機測得了,只是外面連不到);第 3 段改看老師示範、交卡點截圖。
ollama --version印 Warning: could not connect to a running Ollama instance、然後 client version is 0.40.x→ 裝好了,只是伺服器還沒起來;第 2 段開 Ollama app 就會起來。- ollama、node 都說不是指令→ 你還在舊視窗。新裝的程式只有新開的視窗才找得到。
-
主線用不到這步:轉接站自己用
gemma4:e4b,「不確定就先搜」的規矩寫在它程式裡。想在 Ollama app 直接問、拿來對照「模型本身會不會搜」的才做(A 要先拉完)。做法跟 W6 0-D 一字不差:下面這段整段一起貼,前半把 Modelfile(Ollama 的模型設定檔)寫成一個檔,後半用它做出gemma4-search(同一個模型+一段固定的開場指示,不會重新下載)。$mf = @' FROM gemma4:e4b SYSTEM """你是研究助理。你有網路搜尋與讀網頁的工具。今天的日期可能在你的訓練資料之後,所以遇到「最近發生的事、某一年的得獎名單、版本號、價格、新聞」或任何你不確定的事實,一律先用搜尋工具查,查到再回答,並附上來源網址;不要以「還沒發生」為理由拒絕查詢。回答用繁體中文(台灣用語)。""" PARAMETER num_ctx 16384 '@ [IO.File]::WriteAllText("$env:USERPROFILE\Modelfile", $mf) ollama create gemma4-search -f "$env:USERPROFILE\Modelfile" ollama list你應該看到印幾行using existing layer、writing manifest,最後一行success(老師的電腦 1 秒;看到 success 才算);ollama list多一列gemma4-search:latest。測一下ollama list看得到gemma4:e4b和gemma4-search兩列就過。沒做這步的,第 2 段只要gemma4:e4b那列在就行。卡住了
- 貼完停在
>>不動→ 它在等你把整段貼完:中間那段以@'開頭、'@結尾,'@必須在一行的最前面。按一次 Enter;還是不動就按 Ctrl+C,整段重新複製再貼。 - 說找不到 gemma4:e4b→ 0-A 還沒拉完,
ollama list看一下。 - 顯示卡太小、模型跑不動(這條跟主線有關,沒做 0-D 也看)→ 改拉小一號的
ollama pull gemma4:e2b;第 2 段開轉接站前、在同一個視窗先打$env:MODEL="gemma4:e2b",轉接站就會用它。有做 0-D 的再把這段第二行的FROM gemma4:e4b改成FROM gemma4:e2b重貼。
只想看 Modelfile 本身(給好奇的人)
FROM gemma4:e4b SYSTEM """你是研究助理。你有網路搜尋與讀網頁的工具。今天的日期可能在你的訓練資料之後,所以遇到「最近發生的事、某一年的得獎名單、版本號、價格、新聞」或任何你不確定的事實,一律先用搜尋工具查,查到再回答,並附上來源網址;不要以「還沒發生」為理由拒絕查詢。回答用繁體中文(台灣用語)。""" PARAMETER num_ctx 16384
整份 install-commands-w7.ps1(A~D 合在一起,想一次貼的用這個)
# W7 第 0 段:一進教室先貼的指令(PowerShell) # 這份是 W6 的 install-commands.ps1 改的(2026-10-17): # 改了什麼:① 新加 C 拿 cloudflared(Cloudflare 的通道程式,免帳號)——不用 winget:它的安裝程式要系統管理員權限 # (10/17 實測沒按 UAC 就以代碼 1602 失敗),改成把官方單一執行檔下載進 %USERPROFILE%\line-relay、免安裝; # 轉接站 line-search-relay.mjs 之後也放進同一個資料夾。Node 開程式不會在目前資料夾找(10/17 實測 ENOENT), # 所以要 $env:PATH += 那一行,而且每開一個新的 PowerShell 視窗都要再貼一次 # ② 拿掉 B 裡「npm 裝 gws」那一行(這週用不到 gws) # ③ 版本檢查拿掉 gws --version # 沒改的:A、B 的 winget 與 PATH 那幾行、D 的 Modelfile 內容,跟 W6 一字不差 # A. 本地模型(另開一個視窗跑;6.6 GB,教室網路快的話 3–5 分鐘) winget install --id Ollama.Ollama -e --accept-package-agreements --accept-source-agreements --silent $env:PATH += ";$env:LOCALAPPDATA\Programs\Ollama" Start-Process powershell -ArgumentList '-NoExit','-Command','ollama pull gemma4:e4b' # B. Node.js(轉接站 line-search-relay.mjs 要靠它跑) winget install --id OpenJS.NodeJS.LTS -e --accept-package-agreements --accept-source-agreements --silent $env:PATH += ";$env:ProgramFiles\nodejs;$env:APPDATA\npm" # C. cloudflared(給轉接站一個外面連得到的臨時網址;本週新加;免安裝、不用管理員) New-Item -ItemType Directory -Force "$env:USERPROFILE\line-relay" | Out-Null Set-Location "$env:USERPROFILE\line-relay" Invoke-WebRequest -Uri https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe -OutFile cloudflared.exe $env:PATH += ";$env:USERPROFILE\line-relay" cloudflared --version # 自己筆電有管理員權限的,用 winget 裝也可以(要按 UAC;裝完新開視窗、不用 PATH 那行): # winget install --id Cloudflare.cloudflared -e --accept-package-agreements --accept-source-agreements # 版本檢查(新開一個 PowerShell 視窗再跑,PATH 才會更新;cloudflared 在新視窗要先貼上面那行 $env:PATH += ";$env:USERPROFILE\line-relay") ollama --version # 0.40.x node -v # v24.x ollama list # 等 pull 完會看到 gemma4:e4b # D.(選做;主線用不到——轉接站自己用 gemma4:e4b、「不確定先搜」的規矩寫在它程式裡)想在 Ollama app 直接問對照的才做:做出 gemma4-search(不會重新下載) $mf = @' FROM gemma4:e4b SYSTEM """你是研究助理。你有網路搜尋與讀網頁的工具。今天的日期可能在你的訓練資料之後,所以遇到「最近發生的事、某一年的得獎名單、版本號、價格、新聞」或任何你不確定的事實,一律先用搜尋工具查,查到再回答,並附上來源網址;不要以「還沒發生」為理由拒絕查詢。回答用繁體中文(台灣用語)。""" PARAMETER num_ctx 16384 '@ [IO.File]::WriteAllText("$env:USERPROFILE\Modelfile", $mf) ollama create gemma4-search -f "$env:USERPROFILE\Modelfile" ollama list - 貼完停在
第 1 段 Webhook 是什麼+你的 bot 還活著嗎
這段做完你會有什麼:你說得出 Webhook 是什麼、三種 bot 各在哪裡跑;而且你 W5 的 bot 確定活著、Webhook 指回 /exec。
Webhook 一句話:不是你去問 LINE,是 LINE 主動打到你給它的網址。所以那個網址要「外面連得到、而且一直開著」——Apps Script 的 /exec 就是;你的筆電沒有公開網址,所以上週要通道、今天也要。三種 bot 差在誰在接 LINE。點下面三顆按鈕,看訊息各走哪條路、每一站在誰的電腦上:
Webhook 一句話:不是你去問 LINE,是 LINE 主動打到你給它的網址。實線框=別人機房裡一直開著的;虛線框=你的電腦,關了就不在。
同一件事的文字版(投影片上也是這三行):
| 版本 | 三種 bot 在哪跑 | 接 LINE 的那一端 | 你的電腦關掉會怎樣 |
|---|---|---|---|
| W5 | W5:LINE → Apps Script(Google 機房)→ Gemini | Apps Script 的 /exec,一直開著 | 沒影響,bot 照常 |
| W6 簡單版 | W6 簡單版:LINE → 通道 → 你的電腦(一支 Python 就是 bot) | 你的電腦(透過通道) | bot 整個不回;程式 Ctrl+C 時會把 Webhook 換回 /exec |
| W7 進階版 | W7 進階版:LINE → Apps Script → 只有「查 …」→ 通道 → 你的電腦(轉接站+本地模型) | 還是 Apps Script 的 /exec | 只有「查」會回連不到,待辦照常 |
✋ 先想一下再動手:上週簡單版按 Ctrl+C 之後,LINE 的 Webhook 指哪裡?沒按到、或程式當掉的人,現在指哪裡?——1-2 去親眼看。
-
開你 W5 那份試算表 → 擴充功能 → Apps Script(等一下第 3 段要用)。手機傳一句「明天下午三點要開會」給 bot。
你應該看到手機收到「✅ 記好了(試算表第 N 列)」,試算表多一列。測一下有回覆、有新列,就過。沒反應的不要在這裡等,直接做 1-2。卡住了
- 手機完全沒反應→ 最常見:上週簡單版的 Webhook 沒換回
/exec(Ctrl+C 沒按到、程式當掉、或視窗直接關掉),LINE 現在打的是一條已經關掉的通道。去 1-2 改回來。 - bot 回「⚠️ 出錯了:…」→ bot 活著、只是某個環節壞了:把那段整段貼給 AI 問原因;多半是金鑰或長效通行碼過期。先往下做,第 3 段前修好就行。
- 回的是 W6 簡單版那種回覆(本地模型的口氣、或「連不到」)→ 同第一條:Webhook 還指著你的電腦。去 1-2。
- 根本沒有 Apps Script 的 bot(W5 沒做完、或刪掉了)→ 走 1-3 補救路。
- 手機完全沒反應→ 最常見:上週簡單版的 Webhook 沒換回
-
一路點過去:
- 瀏覽器開
developers.line.biz/console,用你 W5 建 bot 的那組 LINE 帳號登入(可能要用手機 LINE 掃一次 QR code)。 - 登入後是「Providers」清單:點你那個 provider 的名字 → 進去看到「Channels」區塊,點你的 bot 那張卡片(類型寫 Messaging API)。
- 上方一排分頁:「Basic settings」「Messaging API」……點「Messaging API」。
- 往下捲,找到「Webhook settings」這個小標題。底下第一列「Webhook URL」右邊有「Edit」鈕;第二列「Use webhook」是一個開關;第三列是「Verify」鈕(在 Webhook URL 下面)。
看兩件:①「Webhook URL」是
https://script.google.com/macros/s/…/exec這種、結尾/exec②「Use webhook」的開關是開的(綠色)。都對就不用動。想按「Verify」也可以,但它可能顯示 302 之類的失敗——Apps Script 會把回應轉到另一個網址,LINE 看到轉址就判定失敗,Verify 判斷不了成敗,成敗看 1-1 手機有沒有回。你應該看到Webhook URL 結尾是/exec、Use webhook 開著。截圖時把網址遮掉:/exec網址等於你 bot 的大門,不放進任何截圖或對話。測一下網址結尾/exec、Use webhook 開,就過;改過的人回 1-1 再傳一次,手機回「✅ 記好了」才算。卡住了
- Webhook URL 是
…trycloudflare.com/callback→ 就是上週沒換回來(怎麼確認:網址結尾不是/exec,而且 1-1 手機沒回)。先去拿/exec網址:Apps Script 編輯器右上藍色「部署」鈕旁的 ▼ →「管理部署作業」→ 跳出視窗,左邊列著你的部署、右邊「網頁應用程式」底下有一條https://script.google.com/macros/s/…/exec網址、旁邊「複製」。回 LINE Developers:「Webhook URL」右邊「Edit」→ 貼上 →「Update」→ 畫面回到唯讀、網址變成 /exec 結尾。 - Webhook URL 是空的→ 同上,貼
/exec網址、Update,再打開 Use webhook。 - 網址對了、Use webhook 也開了,手機還是沒回→ 另一個後台:LINE 官方帳號管理後台 → 設定 → 回應設定:「Webhook」要開、「自動回應訊息」要關(開著它會搶走訊息)。再不行看 Apps Script「管理部署作業」:誰可以存取要是「所有人」(不是「任何擁有 Google 帳戶的使用者」)。
- Verify 顯示 302 或失敗→ 判斷不了成敗(上面說的轉址問題)。手機傳一句、看試算表多不多一列、Apps Script 左邊「執行紀錄」有沒有這一筆,這三個才是證據。
- 登不進 LINE Developers(要手機驗證)→ 用手機 LINE 掃碼登入;還是不行舉手。
- 瀏覽器開
-
只有 W5 沒做完、或 bot 已經不在的人要做;其他人跳過。做法=W5 實作指南的五步(課程網第 5 週那頁有逐畫面步驟),程式直接拿下面「老師的對照答案」Drive 版,金鑰用你自己的:
超過 10 分鐘還沒好:直接跳第 2 段(轉接站跟 bot 無關、照樣做得完);第 3 段跟旁邊的人兩人一組用他的 bot。
- 用個人 Gmail 新建一份 Google 試算表(取名「LINE待辦」之類)→ 上方選單「擴充功能」→「Apps Script」→ 新分頁開出編輯器,中間是
Code.gs、裡面只有一個空的myFunction。Ctrl+A 全選、貼上下面的Code.drive-reference.gs、Ctrl+S。左邊齒輪「專案設定」→ 勾「在編輯器中顯示 appsscript.json 資訊清單檔案」→ 回「編輯器」(左邊<>圖示),檔案清單多了appsscript.json,點它、全選、換成下面那份、Ctrl+S。 - 左邊齒輪「專案設定」→ 捲到最下面「指令碼屬性」(Script Properties:程式讀設定值的地方,金鑰不寫進程式)→ 按「新增指令碼屬性」→ 出現「屬性」「值」兩格:屬性打
GEMINI_API_KEY、值貼 AI Studio 申請的金鑰;再按一次「新增指令碼屬性」:LINE_CHANNEL_ACCESS_TOKEN、值貼 LINE Developers「Messaging API」分頁最下面「Channel access token」按 Issue 發的長效通行碼 → 按「儲存指令碼屬性」。 - 回「編輯器」,上方工具列「執行」左邊的下拉選單選
testSetup→ 按「執行」→ 跳出「需要授權」→「審查權限」→ 選你的 Gmail →「Google 尚未驗證這個應用程式」→ 左下「進階」→「前往…(不安全)」→ 權限清單每一項都勾 →「繼續」。下方「執行紀錄」印出四行 ✅(LINE token、Google 日曆、Gemini、試算表)。 - 右上藍色「部署」→「新增部署作業」→ 左上齒輪選類型「網頁應用程式」→ 說明隨便、執行身分「我」、誰可以存取「所有人」→「部署」→ 視窗顯示「網頁應用程式」網址(結尾
/exec)→「複製」→「完成」。 - LINE Developers → 你的 channel →「Messaging API」分頁 → Webhook settings →「Webhook URL」的「Edit」→ 貼
/exec→「Update」→ 把「Use webhook」開關打開。再開 LINE 官方帳號管理後台(manager.line.biz)→ 右上「設定」→ 左邊「回應設定」:「Webhook」開、「自動回應訊息」關、「歡迎訊息」關。然後回 1-1 傳一句。
你應該看到testSetup 的執行紀錄四行 ✅(LINE token、Google 日曆、Gemini、試算表);手機回「✅ 記好了」。測一下1-1 過了就過。09:45 還沒好:先去做第 2 段(轉接站跟 bot 無關、照樣做得完);第 3 段兩人一組,用旁邊同學的 bot 接你的轉接站,回家再把自己的 bot 補起來。卡住了
- 沒有 Gemini 金鑰/長效通行碼→ 申請各要幾分鐘,照 W5 實作指南第 2、3 步;教室裡時間不夠就先走「兩人一組」那條。
- testSetup 說成功、表裡沒那列→ 授權畫面沒全勾。到 Google 帳戶的「第三方應用程式與服務」把這個專案移除,重跑 testSetup,這次全勾。
- 手機沒回→ 1-2 那幾條:誰可以存取=所有人、兩個後台的 Webhook 都開、自動回應關。
老師的對照答案(Drive 版,W6 做完的那份)
完整的 Code.gs(Drive 版)
/** * LINE AI 待辦小秘書(Level 2:文字+照片) * * 這份是正本:老師的示範 bot、學生 Level 2 的對照答案,都以這份為準。 * 一則訊息的旅程: * LINE 送來訊息 → 請 Gemini 把口語整理成固定欄位 → 程式驗算欄位 * → 寫進試算表 → 回覆 LINE(只講已經做完的事)→ 排 Google 日曆,結果寫回試算表 * * 金鑰不寫在程式裡,一律放「專案設定 → 指令碼屬性」: * GEMINI_API_KEY 必填,到 Google AI Studio 申請 * LINE_CHANNEL_ACCESS_TOKEN 必填,LINE Developers 後台發的長效 token * SHEET_ID 選填,程式不是從試算表「擴充功能 → Apps Script」開的才要填 * GEMINI_MODEL 選填,模型停用或想換模型時改這裡,不用動程式 */ // ===== 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 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 日曆。"; // ===== 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,直接回 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, "")); } // ===== 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) { if (!source || source.type !== "user") return; try { lineApi("/v2/bot/chat/loading/start", { chatId: source.userId, loadingSeconds: 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")} 列`; }); console.log(results.join("\n")); console.log("👉 打開試算表,親眼看到多了一列才算成功。出現權限錯誤,或這裡說成功、表裡卻沒有,多半是授權畫面沒有全部勾選。"); }完整的 appsscript.json(Drive 版)
{ "timeZone": "Asia/Taipei", "dependencies": {}, "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8", "webapp": { "executeAs": "USER_DEPLOYING", "access": "ANYONE_ANONYMOUS" }, "oauthScopes": [ "https://www.googleapis.com/auth/spreadsheets", "https://www.googleapis.com/auth/calendar", "https://www.googleapis.com/auth/drive", "https://www.googleapis.com/auth/script.external_request" ] } - 用個人 Gmail 新建一份 Google 試算表(取名「LINE待辦」之類)→ 上方選單「擴充功能」→「Apps Script」→ 新分頁開出編輯器,中間是
三題小測驗(選做):按了馬上看對錯
不計分、不存;答錯就看解釋,再按一次。09:50 到了就先去第 2 段,課後再回來按;觀念頁第 6 節還有五題。
① Webhook 是誰打給誰?
② 為什麼 Apps Script 的網址不用通道、你的電腦要?
③ 關掉轉接站之後,bot 哪些功能還在?
觀念還想多玩一下?開 觀念頁:Webhook 誰打給誰、這週的東西各住在哪台電腦、為什麼要通道、轉接站的密碼與回應碼、金鑰該放哪——每一節都能按、按了會動,最後有五題小測驗。這頁是觀念,動手步驟還是回這裡。
第 2 段 開轉接站
這段做完你會有什麼:你電腦上跑著一個只聽本機、要密碼的轉接站,外面有一條臨時網址連得到它;你本機問過它一題,答案附來源。
為什麼要通道
- Apps Script 在 Google 機房,要打回你的電腦。bot 的程式不在你的電腦上——它說 localhost(本機),是指 Google 那台機器自己,找不到你電腦裡的 Ollama。
- 你的筆電在分享器後面,沒有公開網址。外面的人(包括 Google 機房)打不進來。
- 通道借你一個外面連得到的 https 網址,打到那個網址的東西會被送到你電腦上的轉接站;網址每次重開會換。
不想被外面連到也有別的做法:讓電腦主動去 Google 拿題目(polling,定時去問「有沒有新問題」),這週不做。
所以今天要兩樣東西接起來:①轉接站(relay):一支小程式,在你電腦上等問題、替模型跑搜尋、要「通關密碼」才回答 ②通道:cloudflared 給這支小程式一個外面連得到的 https 網址。轉接站只聽 127.0.0.1:8787(只有這台電腦自己連得到),外面只能從通道進來,而且要帶對密碼。密碼它自己產生、存在你的使用者資料夾 .line-search-relay.key,畫面上不印(螢幕可能正在投影)、直接放進剪貼簿。
先開 Ollama app(開始功能表搜 Ollama)。第一次啟動問帳號時,這週建議登入免費帳號(Sign in):轉接站用的「上網搜」是 Ollama 的線上搜尋服務,老師 10/09 在這台不登入三次都通,10/12 在另一台電腦不登入卻被拒(401);登入最保險,不想登入就先試、被拒再登入(2-3 卡住了有寫)。
-
在 0-C 那個視窗貼下面這兩行:第一行先切到 line-relay 資料夾(已經在裡面也沒關係、換了視窗也不怕),第二行直接從課程網把轉接站下載到這個資料夾、跟
cloudflared.exe放一起。Set-Location "$env:USERPROFILE\line-relay" Invoke-WebRequest -Uri https://cwstedctw.github.io/ndhu-ted-course-hub/weekly/11501-ai-coding/w07/starter/line-search-relay.mjs -OutFile line-search-relay.mjs
你應該看到一下子就跑完(檔案只有 14 KB),沒有紅字。測一下貼Get-ChildItem "$env:USERPROFILE\line-relay",印一張小表、Name欄兩列:cloudflared.exe(Length約 55000000,約 50 MB)、line-search-relay.mjs(約 14000)。兩個檔都在、名字一字不差(結尾.exe、.mjs),就過。卡住了
Invoke-WebRequest紅字(404、遠端名稱無法解析、連線被拒)→ 課程網那條網址打不開(教室網路擋 GitHub Pages、或網址被改)。備案:按下面摺疊裡的「下載」鈕,瀏覽器會存到「下載」資料夾,再貼那兩行把它搬進line-relay。- 說找不到
line-relay資料夾、或提示字元不是…\line-relay>→ 0-C 沒跑(那五行的第一行就是建它),先回 0-C;只是換了視窗就先貼Set-Location "$env:USERPROFILE\line-relay"。 - 清單裡沒有
cloudflared.exe→ 0-C 的Invoke-WebRequest沒下載成功(看 0-C 卡住了第一條)。轉接站沒有它也開得起來、本機測得了,只是外面連不到。 - 清單裡是
line-search-relay.mjs.txt→ 用瀏覽器備案下載時被加了副檔名:檔案總管「檢視」→ 勾「副檔名」,名字改回line-search-relay.mjs。 - gemma4:e4b 還沒拉完→ 檔先放好,等拉完再做 2-2(
ollama list看得到 gemma4:e4b 才行)。
備案:課程網那條網址打不開時,用這頁的下載鈕
按「下載」,瀏覽器會存到「下載」資料夾;再貼下面兩行把它搬進
line-relay、列出檔案。「下載」鈕沒反應就按「複製」→ 開記事本貼上 → 另存新檔 → 存檔類型「所有檔案」、檔名line-search-relay.mjs、編碼 UTF-8,直接存進line-relay。// 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 的回覆碼只能用一次、很快就失效(官方沒給秒數),扣掉 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:外面還連不到這裡。照互動頁第 0 段 C:把 cloudflared.exe 下載到 line-relay 資料夾,"); log(" 然後在這個視窗貼 $env:PATH += \";$env:USERPROFILE\\line-relay\" ,關掉本程式再跑一次。"); 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`); } });上面只露出開頭幾行(可以捲動);整份會被下載或複製。它不用 npm 裝任何套件,只用 Node.js 內建的功能。
Move-Item "$env:USERPROFILE\Downloads\line-search-relay.mjs" "$env:USERPROFILE\line-relay\" -Force Get-ChildItem "$env:USERPROFILE\line-relay"
-
三行,在同一個 PowerShell 視窗依序貼:第一行進到
line-relay資料夾;第二行把這個資料夾加進 PATH——Node 開程式不會在目前資料夾找,少了這行轉接站會說找不到 cloudflared,每開一個新的 PowerShell 視窗都要再貼這行;第三行開轉接站。它會:開轉接站 → 把模型載進顯示卡 → 找得到 cloudflared 就自己開通道 → 印出「貼進 Apps Script 的指令碼屬性」那塊。Set-Location "$env:USERPROFILE\line-relay" $env:PATH += ";$env:USERPROFILE\line-relay" node line-search-relay.mjs
你應該看到(時間、秒數是老師 10/17 在自己電腦照這三行跑出來的;網址每個人、每次都不一樣):[13:58:02] 轉接站開好了:http://127.0.0.1:8787(模型 gemma4:e4b) [13:58:02] 載入模型 gemma4:e4b(第一次要等一兩分鐘,之後每題幾秒)… [13:58:20] ✅ 模型已在顯示卡上(18 秒) [13:58:20] 開 Cloudflare 通道中(免帳號的臨時網址,每次重開都會換)… ==================== 貼進 Apps Script 的「指令碼屬性」 ==================== ① SEARCH_RELAY_KEY = 按 Ctrl+V(密碼已經在剪貼簿,畫面上不顯示) ② SEARCH_RELAY_URL = https://….trycloudflare.com 密碼要再複製一次:Get-Content "C:\Users\你\.line-search-relay.key" | Set-Clipboard 網址每次重開都會換:換了只要改指令碼屬性,不用重新部署。 這個視窗不要關;關掉=bot 查不到。按 Ctrl+C 結束。 ==========================================================================
框框裡那五行,每一行各管一件事:# 它印的 意思 1 SEARCH_RELAY_KEY = 按 Ctrl+V(密碼已經在剪貼簿,畫面上不顯示)通關密碼。它已經在剪貼簿裡,第 3 段貼進指令碼屬性時按 Ctrl+V 就好;中間複製過別的東西,就照它印的那行 Get-Content … | Set-Clipboard再放一次。2 SEARCH_RELAY_URL = https://….trycloudflare.com通道網址。外面(Google 機房)就是從這條進來。第 3 段貼進指令碼屬性。 3 密碼要再複製一次:Get-Content "C:\Users\你\.line-search-relay.key" | Set-Clipboard密碼的備份路。密碼不印在畫面上,剪貼簿被別的東西蓋掉時,照這行再放一次( 你的位置會是你的使用者名稱)。4 網址每次重開都會換:換了只要改指令碼屬性,不用重新部署。這支程式關了再開,網址就不同;bot 那邊只要改屬性的值,程式不用重貼、不用新版本。 5 這個視窗不要關;關掉=bot 查不到。按 Ctrl+C 結束。轉接站活在這個視窗裡。下課前才按 Ctrl+C(第 5 段)。 測一下框框印出來、第 2 行的網址結尾是.trycloudflare.com,就過。視窗別關、別把它縮到找不到。它還可能印下面這些(每一句在「卡住了」都有對應的處置;時間戳省略):
⚠️ 模型沒載起來:… 先確認 Ollama app 開著、ollama list 看得到 gemma4:e4b ⛔ 模型還沒準備好,先不開通道(開了 bot 問也只會收到錯誤)。修好 Ollama 後按 Ctrl+C、再跑一次本程式。 ⚠️ 找不到 cloudflared:外面還連不到這裡。照互動頁第 0 段 C:把 cloudflared.exe 下載到 line-relay 資料夾, 然後在這個視窗貼 $env:PATH += ";$env:USERPROFILE\line-relay" ,關掉本程式再跑一次。 ⚠️ 通道程式開不起來:…。轉接站還在,可以另開視窗手動跑:cloudflared tunnel --url http://127.0.0.1:8787 ⚠️ 通道還沒拿到網址就結束了(代碼 N):看網路,或另開視窗手動跑 cloudflared tunnel --url http://127.0.0.1:8787 ⚠️ 通道結束了(代碼 N):bot 現在連不到這裡,重開本程式會拿到新網址 📩 收到一題(N 字) 🔍 模型決定搜:… 📄 模型決定讀網頁:… ✅ 回答好了(N 秒,有上網查) ❌ 超過 45 秒,放棄這題 ⛔ 有人來問,但密碼不對,拒絕 ⛔ 有人來對密碼,但密碼不對
卡住了
node不是指令→ 怎麼確認:另開一個新視窗打node -v,有版本號就是「這個視窗太舊」、沒有就是 0-B 沒裝好。處置:新開視窗、三行重貼;還是沒有就回 0-B。- 印
⚠️ 模型沒載起來:…、下一行先確認 Ollama app 開著、ollama list 看得到 gemma4:e4b,接著⛔ 模型還沒準備好,先不開通道→ 怎麼確認:另開視窗打ollama list——印 Warning: could not connect=Ollama 沒在跑;印得出清單但沒有 gemma4:e4b=0-A 還沒拉完。處置:前者開始功能表開 Ollama app,後者等拉完;然後在轉接站視窗按 Ctrl+C、再貼第三行。 - 印
⚠️ 找不到 cloudflared:外面還連不到這裡。照互動頁第 0 段 C:把 cloudflared.exe 下載到 line-relay 資料夾,、下一行然後在這個視窗貼 $env:PATH += ";$env:USERPROFILE\line-relay" ,關掉本程式再跑一次。→ 怎麼確認:在同一個視窗打cloudflared --version,說不是指令=PATH 那行(2-2 第二行)沒貼到;再打Get-ChildItem "$env:USERPROFILE\line-relay",沒有cloudflared.exe=0-C 沒下載成功。(winget 版只給自己筆電、有系統管理員權限的人用,教室電腦不用理。)處置:按 Ctrl+C,三行重貼;沒有 exe 就回 0-C。轉接站這時其實已經在跑、本機測得了(2-3),只是外面連不到。 - 印
⚠️ 通道還沒拿到網址就結束了(代碼 N):看網路,或另開視窗手動跑 cloudflared tunnel --url http://127.0.0.1:8787→ 怎麼確認:另開視窗、先貼 PATH 那行、再貼cloudflared tunnel --url http://127.0.0.1:8787,看它自己印什麼——有https://….trycloudflare.com就是剛才只是慢;一直 ERR、connection failed 就是教室網路把它擋了。處置:前者拿那個網址當 SEARCH_RELAY_URL(密碼照舊,Get-Content "$env:USERPROFILE\.line-search-relay.key" | Set-Clipboard放剪貼簿);後者第 3 段看老師示範、交卡點截圖,這段回家做得完。 - 印
⚠️ 通道程式開不起來:…→ 怎麼確認:訊息後半有 ENOENT=找不到程式(同第三條,PATH 沒貼);有 EACCES 或「存取被拒」=exe 被防毒隔離。處置:前者三行重貼;後者舉手。 - 有「轉接站開好了」「開 Cloudflare 通道中」,框框一直沒出來→ 怎麼確認:看最後一行的時間戳,離現在不到一分鐘就是還在連。處置:等 30 秒;超過一分鐘還沒有,就是上面「通道還沒拿到網址」那條。
- 框框出來過,後來印
⚠️ 通道結束了(代碼 N):bot 現在連不到這裡,重開本程式會拿到新網址→ 怎麼確認:手機開 2-4 那個 /health 網址,開不了就是通道真的斷了。處置:Ctrl+C、再貼第三行,拿新網址去改指令碼屬性(不用重新部署)。 - 第 1 行印的是「用記事本開 … 複製整行」而不是「按 Ctrl+V」→ 怎麼確認:在記事本按 Ctrl+V,貼出來的不是一串英數字。處置:開記事本 → 開它印的那個路徑的檔(
.line-search-relay.key),整行複製;或在另一個視窗跑Get-Content "$env:USERPROFILE\.line-search-relay.key" | Set-Clipboard。 - 「載入模型」等超過三分鐘→ 怎麼確認:Ctrl+Shift+Esc 開工作管理員 → 效能 → GPU,使用率有沒有在動;專用記憶體是不是被別的程式佔滿。處置:顯示卡被別的程式佔住(LM Studio 之類)就關掉;顯示卡太小就用 0-D 卡住了那條換 e2b。
- 印
Error: listen EADDRINUSE→ 怎麼確認:瀏覽器開http://127.0.0.1:8787/health,開得了=上一次沒關的轉接站還在。處置:找到那個舊視窗按 Ctrl+C;找不到就在這個視窗先打$env:PORT="8788"再貼第三行(之後手動開通道也要跟著換 8788)。 - 想兩人共用一台→ 可以:一台轉接站給兩個 bot 用,兩個人的指令碼屬性填同一個網址與密碼(密碼用
Get-Content … | Set-Clipboard放剪貼簿後,由你親手走到對方電腦前,在對方 Apps Script 的指令碼屬性那格按 Ctrl+V 貼上;不經 LINE、不經任何訊息或群組——傳出去就留下紀錄)。怎麼確認接好了:兩個人各跑一次 testSetup,都看到「轉接站接好了」。
-
先不碰 bot,直接從這台電腦問轉接站一題,證明「模型會搜、會附來源、密碼對得上」。另開一個 PowerShell 視窗(轉接站那個不能動),先在瀏覽器開
http://127.0.0.1:8787/health看它活著,再貼下面這一整行(轉接站用--no-tunnel模式開時,會自己印「本機測試(另開 PowerShell 貼):」加同一段指令;這裡只是把密碼檔路徑寫成$env:USERPROFILE,誰貼都對):Invoke-RestMethod -Method Post -Uri http://127.0.0.1:8787/ask -Headers @{"x-relay-key"=(Get-Content "$env:USERPROFILE\.line-search-relay.key")} -ContentType "application/json; charset=utf-8" -Body ([Text.Encoding]::UTF8.GetBytes('{"question":"2026 年諾貝爾物理獎得主是誰?"}'))你應該看到瀏覽器那頁:{"ok":true,"service":"line-search-relay","model":"gemma4:e4b"}。貼完指令,轉接站視窗多三行(老師 10/17 的;秒數會不同):[14:01:10] 📩 收到一題(17 字) [14:01:11] 🔍 模型決定搜:2026 諾貝爾物理獎 得主 [14:01:13] ✅ 回答好了(3 秒,有上網查)
你這個視窗印五個欄位:answer(答案,講到 Francis Halzen 與 IceCube 微中子觀測站)、searched : True、queries(它搜的關鍵字)、sources(三筆 title/url)、seconds。測一下searched是 True、sources裡有網址、answer 講得出得主(W6 對過答案:Francis Halzen),就過。卡住了
- 紅字
無法連線到遠端伺服器、或 /health 開不了→ 怎麼確認:看轉接站視窗最後一行——沒有「轉接站開好了」或視窗已經不在,就是它。處置:回 2-2 三行重貼。 - 紅字 401、轉接站印
⛔ 有人來問,但密碼不對,拒絕→ 怎麼確認:在這個視窗打Get-Content "$env:USERPROFILE\.line-search-relay.key",印不出一串英數字就是密碼檔不在(轉接站第一次跑之後才會建)。處置:等 2-2 跑過再試;指令整行複製別手打。 - 轉接站印
❌ Ollama /api/experimental/web_search 回應 401:…、你這邊 answer 說查不到→ 怎麼確認:就是那行 401(Ollama 的線上搜尋要登入)。處置:開 Ollama app → 登入免費帳號(Sign in)→ 不用重開轉接站,再貼一次指令;登入後還是 401 就舉手。 - 轉接站印
❌ 超過 45 秒,放棄這題、你這邊 504→ 第一題模型剛載、或教室網路慢;再問一次;還是超時就把題目問短一點。 - 你這邊 429
正在回答上一題,等一下再問→ 一次只答一題,等轉接站印「回答好了」再貼。 ✅ 回答好了(N 秒,沒有上網查)、sources 是空的→ 這次模型決定不搜(小模型的判斷不是每次都穩)。再貼一次;連續兩次都不搜,把題目改成「查 2026 年諾貝爾物理獎得主是誰?請先搜尋再回答」試。- answer 的中文變成問號或亂碼→ 指令要整段複製,別手打;
-Body那段已經把題目轉成 UTF-8,少了就會壞。 - 轉接站印
❌ Ollama /api/chat 回應 500:…→ 顯示卡同時載了別套模型(例如 LM Studio 開著),關掉再試。
- 紅字
-
第 3 段要讓 Google 機房連進來,可以先證明「外面真的連得到」。3-4 的 testSetup 也會從 Google 那端驗通道,這步跳過不影響。把 2-2 框框裡第 2 行的網址複製貼到瀏覽器網址列,後面加
/health,例如https://(一串英文字).trycloudflare.com/health——用電腦瀏覽器開也行(複製貼上),用手機(走行動網路)開更能證明是從外面進來的。這一頁不用密碼。你應該看到畫面一行:{"ok":true,"service":"line-search-relay","model":"gemma4:e4b"}。轉接站視窗不會多印東西(看活著不用密碼、也不記錄)。測一下看到那行 JSON 就過:通道通了,第 3 段只差把網址和密碼交給 bot。(老師 10/17 從外面走這條網址打/check-key:密碼對回 200、錯回 401;問一題也是模型自己搜、3 秒答對,跟本機一樣。)卡住了
- 開出 Cloudflare 的錯誤頁(Error 1033 之類)或「找不到網站」→ 通道已經關了(轉接站視窗被關、或印過
⚠️ 通道結束了(代碼 N):bot 現在連不到這裡,重開本程式會拿到新網址),或網址抄錯一個字。回轉接站視窗看最新一行;重開程式網址會換,要抄新的。 - 2-2 根本沒印框框、轉接站印「找不到 cloudflared」→ 多半是那個視窗沒貼 PATH 那行(2-2 第二行):Ctrl+C、三行重貼。先看
line-relay資料夾裡有沒有cloudflared.exe、轉接站是不是在那個資料夾開的(第一行Set-Location)。 - cloudflared 真的拿不到、通道開不起來→ 這步做不了;第 3 段看老師示範,回家做。本機那題(2-3)過了就先截圖當今天的證據。
- 這裡開得了、等一下 testSetup 卻說連不到→ 通道網址在 testSetup 跑之前換過了(程式重開過);拿最新的那個網址改指令碼屬性。
- 開出 Cloudflare 的錯誤頁(Error 1033 之類)或「找不到網站」→ 通道已經關了(轉接站視窗被關、或印過
第 3 段 Apps Script 加「查 …」
這段做完你會有什麼:手機傳「查 …」,bot 把問題轉回你電腦的本地模型,答案附來源回到手機;轉接站視窗看得到它印了一題。其他訊息照舊是待辦。
這就是 W5、W6 的第二關,換一個題目:在 Drive 版(W6 做完的那份,沒做完的就是 W5 版)上只加「查 …」這一條路。你的工作一樣:看計畫、按同意、看改前改後對照、退回一次。先選路線:
| 你手上有 | 走 | 怎麼改程式 |
|---|---|---|
| 自己的筆電,Antigravity 或 Codex 登得進 | 路線 A | 把程式存成檔案放進一個資料夾,agent(幫你改程式的 AI,這學期用的 Antigravity/Codex)在資料夾裡改,它自己有 diff(改前改後對照)畫面;改完你把檔案內容貼回 Apps Script 編輯器。 |
| 教室電腦,只有瀏覽器 | 路線 B | 把程式整份貼給 ChatGPT/Gemini 網頁版請它改;沒有 diff 畫面,所以動手前先把舊程式存進一份 Google 文件,改完請 AI 列對照、自己對。 |
時間盒:10:50 還沒做到 3-4 的,停下來看老師示範(老師在講台機跑同一條);回家再照這頁做完,每一步回家都做得了。真的想今天就看到手機回答的,直接開這段最下面「老師的對照答案」整份換掉 Code.gs,從 3-4 貼回去繼續。
-
路線 B:在編輯器裡 Ctrl+A 全選 Code.gs、複製,開一份新的 Google 文件(取名「Code.gs 改前 W7」)貼進去。路線 A:在電腦建一個空資料夾(例如
line-bot-w7),把 Code.gs 的內容存成Code.gs(記事本貼上 → 另存新檔 → 存檔類型選「所有檔案」、檔名打Code.gs、編碼 UTF-8,才不會變成 Code.gs.txt);再把 appsscript.json 也存一份(專案設定勾「在編輯器中顯示 appsscript.json 資訊清單檔案」才看得到)。然後用 Antigravity/Codex 開這個資料夾。你應該看到路線 B:Google 文件裡有整份程式。路線 A:資料夾裡有Code.gs(和appsscript.json),agent 左邊檔案列表看得到。測一下舊版在編輯器以外的地方有一份完整的,就過。卡住了
- 想在同一個 Apps Script 專案另開一個 .gs 檔存舊版→ 不行:兩份宣告同名常數,整個專案會載入失敗、bot 直接掛掉。存在 Google 文件或電腦上。
- 找不到 appsscript.json→ 專案設定那個勾沒勾;勾了還沒有就是你的專案用預設清單,這週不用動它(「查 …」只用到 UrlFetchApp,原本就有權限)。
-
這一步不給現成的提示詞。下面是這次要 AI 做到的事,用你自己的話跟它說,一次講完或分幾句都行,像在拜託同學幫你改程式。路線 A 打在 agent 的對話框;路線 B 打給 ChatGPT/Gemini,並把整份 Code.gs 一起貼給它。說漏了哪一條,它多半就會漏做,3-3 會幫你抓出來。
目標- 使用者傳「查 …」或「搜尋 …」開頭的訊息時,把後面的問題送到我的轉接站;轉接站的網址和密碼放在指令碼屬性 SEARCH_RELAY_URL、SEARCH_RELAY_KEY,不要寫進程式
- 轉接站怎麼叫:用 POST 打「那個網址加 /ask」,標頭 x-relay-key 放密碼,內容是 JSON {"question":"問題"};它回的 JSON 裡 answer 是答案、sources 是來源
- 轉接站回什麼就回什麼給使用者,來源一起附上
- 連不到轉接站就老實回「現在連不到我的電腦」,不要改問 Gemini
- 「查 …」以外的訊息照原本的做:待辦、照片那些都不要動
- 這條路不寫進試算表,只是問答
- 先列計畫、停下來等我同意再動手;改完給我改前改後的對照
路線 A:把
line-relay資料夾裡的line-search-relay.mjs(2-1 下載的那份)也複製一份放進同一個資料夾給 agent 看——它讀得到轉接站的 API 就不會瞎猜;JSON 是電腦之間傳資料用的大括號寫法,觀念頁名詞表有解釋。路線 B(沒有 agent):把上面七條貼給 ChatGPT/Gemini 網頁版,連整份 Code.gs 一起貼;它沒有「停下來等同意」的機制,所以第 7 條要講成「先只給計畫,我說同意你再給程式」。改完請它列改前/改後對照,拿 3-1 的 Google 文件自己對。兩條路線怎麼計分,老師開作業時一起說明。你應該看到它先列計畫、停下來問你,沒有動手。計畫裡講得出:在哪裡判斷「查 」開頭(對照答案用的是「查」或「搜尋」加空格或冒號才算,「查一下明天…」照舊當待辦)、網址密碼從指令碼屬性讀、連不到回什麼、等多久放棄(LINE 的回覆碼(reply token)只能用一次、很快就失效(官方沒給確切秒數),等不起六分鐘)。測一下看三件:有先列計畫、有說哪裡不確定、有說還沒動手(在等你同意)。三件都有,再拿上面七條目標對一次它的計畫,七條都照顧到才算過;漏的就跟它補一句。卡住了
- 不知道怎麼開口→ 照目標一條一條講也行,最後補一句「有不清楚的先問我」。不用寫得漂亮,講清楚就好。
- 它沒停、直接把程式改完丟給你→ 你的話裡少了第 7 條,或它沒照做。回它:「請先不要動,先列計畫等我同意」;路線 B 就重開一段對話,這次記得講到第 7 條。
- 它問你要轉接站的網址和密碼→ 不給。回它「放在指令碼屬性,程式用 PROPS.getProperty 讀,你不需要知道值」。
- 它猜轉接站的接法(網址後面亂接、密碼放網址裡)→ 第 2 條沒講到,或路線 A 沒把
line-search-relay.mjs放進資料夾。照第 2 條再講一次,或把那個檔放進去請它自己讀。 - 它說要用 Gemini 的搜尋功能、或連不到時改問 Gemini→ 退回第 4 條:免費的 Gemini 沒有上網搜,連不到就老實說連不到。
- 它想順便重寫轉接站、或幫你寫一支新的→ 不用,轉接站已經在跑了;回它「只改 Code.gs,轉接站不動」。
- 它把「查一下明天要交什麼」也當成查詢→ 跟它說清楚:只有「查」「搜尋」後面接空格或冒號才轉,其他照舊當待辦。
-
先看計畫:有講到指令碼屬性、有講到連不到怎麼回、有講到待辦照片不動,就回「同意」(它問的問題也一併回答,沒意見就說「照你的預設」)。它改完會給摘要與改前/改後對照:路線 A 看 agent 的 diff 畫面(紅色減號=刪掉、綠色加號=加上),路線 B 看它列的對照,必要時拿 Google 文件裡的舊版比。對照裡找三件:
# 找什麼 在哪裡看、怎樣才對 1 網址和密碼是從指令碼屬性讀的,程式裡沒有 整份搜 trycloudflare、搜一長串英數字:都不該有。應該只有PROPS.getProperty("SEARCH_RELAY_URL")、PROPS.getProperty("SEARCH_RELAY_KEY")這種讀法。2 連不到轉接站是老實說,不是改問 Gemini 找「查 …」那條路的錯誤處理:連不到/逾時/密碼錯時回一句「連不到」之類的話,沒有再呼叫 callGemini或askGeminiAboutText。3 待辦和照片那幾段一行沒動 diff 裡 saveAndReply、appendTodoRow、savePhotoToDrive、askGeminiAboutImage、downloadLineImage都沒有紅綠;改動只在handleEvent多一個判斷、多一個新函式(對照答案叫askLocalSearch)、設定區多三個常數(SEARCH_RELAY_URL、SEARCH_RELAY_KEY、SEARCH_PREFIX)。三件都對了,還是退回一次——「把 AI 的東西退回去改」是這門課要帶走的能力。路線 B 的退回:只叫它改你指定的那一段、它回來你也只貼那一段,不要整份重產(重產慢、也容易貼錯)。理由可以很小(請它把英文註解改成中文也算)。用自己的話講,但要講到這三件:
目標- 你不要哪一點、為什麼
- 只改這一點,其他不要動
- 改完再列一次改前/改後對照
你應該看到三件都對;退回之後它只動你說的那一點,重新列對照。測一下三件各指得出在哪一行,而且退回後的對照裡只有你要的那個改動,就過。指不出來就問它「第 N 件在哪裡?」;多改了別的就再退一次。卡住了
- 它把網址寫死在程式裡(看得到 trycloudflare)→ 退回:「網址和密碼只能從指令碼屬性讀,程式裡不能出現」。
- 連不到時它改成問 Gemini→ 退回:「連不到就回連不到,不要問 Gemini」。
- 它把待辦那段也重寫了→ 越界。退回:「只加『查 …』這條路,其他函式恢復原樣」;路線 B 拿 Google 文件那份舊版請它比對。
- 它用的是
UrlFetchApp.fetch但沒設逾時→ 可以退回這點當理由:「請設 timeoutSeconds 50 以內,LINE 的回覆碼很快就失效」。 - 它只給整份新程式、沒有對照→ 回它「請列改前/改後逐段對照,只列有改的段落」。
- 想不到退回的理由→「新函式請加一行中文註解說明它做什麼」「回覆開頭加一個 🔎 讓我分得出是查的」都行。
-
四個動作一次做完:
- 貼回:回 Apps Script 編輯器分頁(左邊
<>「編輯器」),檔案清單點Code.gs→ 在程式區 Ctrl+A 全選、貼上新版、Ctrl+S。上方檔名旁的橘點消失=存好了。appsscript.json 這週不用動。 - 指令碼屬性:左邊齒輪「專案設定」→ 這一頁往下捲到最底「指令碼屬性」區塊 → 按「新增指令碼屬性」→ 多出一列、兩格「屬性」「值」:屬性打
SEARCH_RELAY_URL、值=貼轉接站印的那個網址(https://….trycloudflare.com,結尾不要多斜線、前後不要空白)→ 再按一次「新增指令碼屬性」:屬性SEARCH_RELAY_KEY、值=按 Ctrl+V(密碼還在剪貼簿;不在就回轉接站視窗印的Get-Content … | Set-Clipboard那行再放一次)→ 按藍色「儲存指令碼屬性」→ 區塊上方短暫出現「已儲存」。貼之前可以用下面的檢查器先對格式。 - testSetup:回「編輯器」,上方工具列「執行」左邊的下拉選單選
testSetup→ 按「執行」。這週沒有新權限,通常不會再跳授權畫面;跳了就每一項都勾。下方「執行紀錄」面板會自己展開。 - 新版本:存檔不等於上線——LINE 敲的
/exec跑的是「部署那一刻」的版本。右上藍色「部署」旁的 ▼ →「管理部署作業」→ 視窗右上鉛筆圖示(編輯)→「版本」下拉選「新版本」→ 右下「部署」→ 視窗顯示「已更新部署作業」、網頁應用程式網址跟原本一樣 →「完成」。
指令碼屬性填寫檢查器(只在你的瀏覽器裡檢查格式,不存、不送出、不印到任何地方;頁面重新整理就清空)
兩個都綠勾,再去 Apps Script 貼一模一樣的值。這裡不會幫你貼,也不會記住你輸入的東西。
你應該看到儲存後上方沒有紅字;指令碼屬性清單多兩列(值的欄位密碼會露出來,這個畫面不要截圖);執行紀錄五行 ✅,最後一行是對照答案的版本會印✅ 本地模型(選做):轉接站接好了(網址通、密碼對),模型 gemma4:e4b(AI 幫你改的版本 testSetup 可能沒有這一項,那就看 3-5 手機測);部署版本號加一、/exec網址沒變(不用回 LINE 後台改)、「誰可以存取」還是「所有人」。測一下testSetup 沒有 ❌、版本號加一,就過。瀏覽器開你的/exec網址,看到LINE AI 待辦小秘書運作中(Level 2+本地模型查詢)(對照答案的字;AI 版可能是別的一句)也算。卡住了
- 儲存就報「已宣告」之類的語法錯誤→ 你的專案裡還有一份舊的 .gs(同名常數撞到),刪掉那份;或貼的時候沒全選、新舊疊在一起,重貼。
❌ 本地模型(選做):轉接站活著,但密碼不對:檢查 SEARCH_RELAY_KEY→ 剪貼簿被別的東西蓋掉、貼到的不是密碼。回轉接站視窗印的Get-Content … | Set-Clipboard那行跑一次,屬性值重貼、儲存、再跑 testSetup。❌ 本地模型(選做):轉接站回應 N:電腦上的轉接站視窗開著嗎?網址是不是新的那個?→ 視窗關了、或程式重開過網址換了:看轉接站視窗最新的框框,把新網址填回屬性(不用重新部署)。✅ 本地模型(選做):沒設定 SEARCH_RELAY_URL,「查 …」功能先不開→ 屬性名打錯了(大小寫、底線要一模一樣)、或沒按「儲存指令碼屬性」。- testSetup 的 LINE token 或 Gemini 那行 ❌→ 跟今天無關的舊問題(通行碼過期、金鑰失效);先把「查」做通,回家修。
- 不小心按成「新增部署作業」→ 會產生一個新的
/exec網址,LINE 後台還指著舊的;刪掉新的那個,回去用「管理部署作業」。 - 找不到「指令碼屬性」→ 左邊齒輪「專案設定」那一頁最下面,要往下捲。
- 貼回:回 Apps Script 編輯器分頁(左邊
-
手機傳這句給 bot(「查」後面有一個空格):
查 2026 年諾貝爾物理獎得主是誰?
你應該看到手機先出現「輸入中…」,幾秒到十幾秒後回覆長這樣(對照答案的版本;AI 版格式會不同,看「有答案、有來源」):🔎 2026 年諾貝爾物理獎由 Francis Halzen 獲得,表彰他對 IceCube 微中子觀測站的…… 來源: • https://… • https://… (本地模型 6 秒)
同一時刻,轉接站視窗多了三行:📩 收到一題(N 字)、🔍 模型決定搜:…、✅ 回答好了(N 秒,有上網查)。這就是整條路通了:手機 → LINE → Apps Script → 通道 → 你的電腦 → 回去。測一下①手機答案有來源、得主對(Francis Halzen)②轉接站視窗印了「收到一題」。兩個都有才過。再傳一句「明天要交作業」,bot 回「✅ 記好了」——待辦那條路沒被動到。卡住了
- 回「這支 bot 還沒接上本地模型(指令碼屬性要有 SEARCH_RELAY_URL 和 SEARCH_RELAY_KEY)。」→ 怎麼確認:專案設定最下面的指令碼屬性有沒有那兩列(名字一字不差);有的話就是 3-4 第 4 步「新版本」沒做——「管理部署作業」裡的版本號跟你剛才部署的對不對。處置:補存屬性、或補做新版本。
- 回「🔌 本地模型現在連不到(或等超過 50 秒):電腦關了、轉接站的視窗被關掉,或通道換了網址(指令碼屬性的 SEARCH_RELAY_URL 要改成新的)。」→ 怎麼確認:①轉接站視窗有沒有印「收到一題」——沒有=訊息根本沒到你的電腦(網址舊了或視窗關了:手機開 2-4 那個 /health 網址看開不開);有=到了但太慢(第一題模型剛載)。處置:前者拿最新網址改屬性;後者再傳一次。
- 回「🔒 轉接站說密碼不對:檢查指令碼屬性的 SEARCH_RELAY_KEY。」→ 3-4 卡住了第二條。
- 回「⏳ 本地模型正在回答上一題,等十幾秒再問一次。」→ 一次一題(旁邊共用你轉接站的人剛好在問)。等一下再傳。
- 回「🔌 本地模型這次沒答出來(504):想太久了…」→ 超過 45 秒;再傳一次,或換短一點的問法。
- bot 把它當待辦記進試算表了→ 「查」後面沒有空格也沒有冒號(全形空格、全形冒號都可以);AI 版的判斷式可能跟對照答案不同(對照答案:「查」或「搜尋」後接空格或冒號),看對照答案設定區的 SEARCH_PREFIX 那行。
- 答案尾巴是「ℹ️ 這題模型沒有上網查,只憑記憶回答,請自己再核對。」→ 模型這次沒搜;再傳一次。
- 手機完全沒回、轉接站也沒印→ 訊息根本沒到 Apps Script:1-2 那幾條(Webhook、自動回應、所有人)。有到的話 Apps Script 左邊「執行紀錄」最新那筆會有紅字,整段貼給 AI 問。
- 轉接站印了「收到一題」,接著
❌ Ollama /api/experimental/web_search 回應 401:…→ Ollama app 登入免費帳號再傳一次(2-3 卡住了第三條)。 - 轉接站印了「回答好了」、手機卻沒收到→ 超過 LINE 回覆碼(reply token)的有效時間(它很快就失效;轉接站最多 45 秒+Apps Script 自己的時間);問短一點的題目,或等模型暖好再問。
今天的證據就是這兩張:①手機上這一題的回覆(有來源)②同一時刻轉接站視窗(看得到「收到一題」「回答好了」)。截圖裡不能有
/exec網址、通關密碼、長效通行碼、Channel secret;指令碼屬性那一頁不截。
老師的對照答案(做完再看):Code.search-reference.gs
在 Drive 版上只加「查 …」這一條路:設定區多 SEARCH_RELAY_URL、SEARCH_RELAY_KEY、SEARCH_PREFIX 三個常數,handleEvent 多一個判斷,新函式 askLocalSearch(連不到老實說、不問 Gemini、不寫表),testSetup 多一項打 /check-key 對密碼。其他函式一行沒動。你的版本長得不一樣沒關係,3-3 三件對就好。想整份換掉的用這份(appsscript.json 用 1-3 那份 Drive 版即可)。
/**
* 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 !== "未指定";
// ② 回覆手機:只講已經做完的事(回覆碼只能用一次、很快就失效)
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 的回覆碼很快就失效,等不起
});
} 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("👉 打開試算表,親眼看到多了一列才算成功。出現權限錯誤,或這裡說成功、表裡卻沒有,多半是授權畫面沒有全部勾選。");
}第 4 段 期中開放題
這段做完你會有什麼:你知道期中怎麼算、題目怎麼挑;而且寫下一句「我期中想做什麼」,講給旁邊的人聽過一次。
期中怎麼算(契約):30%|W9(11/02)一次結算|範圍 W2–W7|五構面各 6%:需求符合度/可重現功能/測試證據/本人判斷與安全(含 AI 揭露)/溝通與個人貢獻|個人計分,可以結伴互幫、成績各算|失敗的版本只要證據完整,照樣拿分
題目自選、不給範本:用這學期學到的東西做一個你自己想做的東西,W9 每人 90 秒讓它跑給大家看。
-
五構面各 6%,加起來就是 30%。「失敗的版本只要證據完整,照樣拿分」不是客套話:做不出來但留得下「我試了什麼、哪裡壞、我怎麼判斷」的紀錄,分數一樣算。
你應該看到你不看頁面也說得出:五構面是哪五個、哪一天結算、範圍到哪一週。測一下讀完能說出三件——30%、範圍 W2–W7、失敗的版本證據完整照樣拿分——就過(跟旁邊的人講留到 4-4 一次做)。卡住了
- 以為要交一份報告→ 不用。W9 當天 90 秒讓它跑給大家看,加上證據(截圖、對話紀錄、repo)。繳什麼、怎麼繳,老師 W9 前在 e學苑 說。
- 以為要組隊→ 個人計分。可以結伴互幫(例如共用一台轉接站、互相當測試者),但成績各算、每個人要說得出自己做了什麼。
-
題目自選,只有三個條件:
期中開放題,三個條件- 題目你自己定,用這學期學到的東西至少兩樣(面板、LINE bot、agent、本地模型、試算表)
- W9 當天 90 秒讓它跑給大家看:打開什麼、做什麼、看到什麼
- 說得出你退回 AI 的哪一次、為什麼
你應該看到腦中至少浮出一個題目,而且對得上條件 1 的「至少兩樣」。測一下把你的題目對一次三個條件,三個都答得出,就過。答不出條件 3 沒關係——做的過程一定會退回 AI 至少一次,到時候記下來。卡住了
- 想不到題目→ 從今天手上的東西接出去:bot 多一條路(「天氣 …」「翻譯 …」)、面板上一顆按鈕讓 bot 傳訊息、本地模型幫你整理試算表、agent 每天幫你產一份摘要。先挑一個你自己明天就想用的。
- 想做的太大→ 把「第一步」切到一小時內做得完的大小(例如:先讓 bot 多認得一個開頭字);期中看的是跑得起來的那一塊,不是野心。
- 怕做不出來→ 失敗的版本只要證據完整,照樣拿分。留紀錄比做出來重要。
-
三個空格填一填,下面那句會自動組好(存在這台瀏覽器裡,重新整理還在;離開教室前按「複製」帶走):
我期中想做 ,給 用,第一步是 。我期中想做___,給___用,第一步是___。
想不出題目?別人做過的方向(只列方向、不給範本)
也可以回頭看 W2–W4 自己寫過的作業,把它接上 bot 或本地模型。
- 面板+bot:面板上一顆按鈕,按了 bot 傳一則訊息給你(或反過來,bot 收到關鍵字就改面板的燈)
- bot 幫社團記事:社團群組裡「記 …」開頭的訊息進試算表,每週自動整理成一份摘要
- 本地模型讀自己的筆記:把上課筆記丟給本地模型,問它「這週考什麼」,附出處
- 試算表自動整理:每天固定時間把待辦表裡「今天到期」的整理成一則 LINE 訊息
- bot 多一條路:「天氣 …」「翻譯 …」「查 …」之外再加一個你每天會用的開頭字
- agent 當助理:每天幫你用 gws 讀雲端硬碟某個資料夾、產一份摘要(W6 第 2 段那條路)
- 讓「查 …」永遠可用:把轉接站搬到一台一直開著的電腦(或雲端),不用每次重開改網址
挑一個你明天就想用的;第一步切到一小時內做得完。
你應該看到三格都填了,下面那句沒有底線、是一句完整的話。測一下唸一遍,十秒內講得完、別人聽得懂你要做什麼,就過。卡住了
- 想不出題目→ 先點開上面「別人做過的方向」看一圈,挑一個改成自己的(換個對象、換個開頭字、換個資料來源都算)。
- 「第一步」想不出來→ 小到不會失敗的就好:「把上週的 bot 專案存一份舊版」「把面板那頁打開、確定還跑得起來」。
- 填了、換一台電腦就不見→ 只存在這台瀏覽器;按「複製」貼到 LINE 傳給自己,或截圖。
- 想做的跟課堂內容無關→ 可以,只要用到這學期學到的東西至少兩樣(條件 1)。
-
把 4-3 那句講給旁邊的人聽、多加兩句「為什麼想做」,30 秒。對方一定要問一個問題(「第一步做完你會看到什麼?」「90 秒要秀哪一段?」都行),然後換手。
你應該看到對方問的那個問題,你答得出來;答不出來的那一點,就是你第一步該先弄清楚的。測一下兩個人都講過、都被問過一題,就過。把對方問的問題記在 4-3 那句後面也行。卡住了
- 旁邊沒人→ 講給老師聽,或打給 AI 請它問你一個問題(只要它問、不要它幫你定題目)。
- 對方不知道要問什麼→ 就問「90 秒你會打開什麼、按什麼、我們會看到什麼」。
第 5 段 收尾與帶走
這段做完你會有什麼:今天的證據在你自己手上、轉接站關好、教室電腦上沒留下你的帳號,而且你知道接下來兩週的時程。
-
# 備什麼 哪一步 1 手機上 bot 回「查 …」那一題的截圖(有來源) 3-5 2 同一時刻轉接站視窗的截圖(看得到它印了一題) 3-5 3 一句話:期中我想做什麼(4-3 那句,複製) 4-3 做不到第 1、2 樣的,交你卡在哪一步的截圖+一句卡在什麼(本機那題 2-3 過了,就截那個)。繳交方式老師開作業時一起說明。
你應該看到三樣都在你自己的雲端硬碟或手機裡,不是只在教室電腦的桌面。截圖裡沒有/exec網址、通關密碼、長效通行碼、Channel secret。測一下用手機或自己的帳號打開確認看得到,就過。卡住了
- 截圖快捷鍵→ Win+Shift+S 框選,貼到小畫家或直接貼進 Google 文件。
- 截到了密碼或網址→ 小畫家塗掉再交;指令碼屬性那一頁整頁都不要截。
-
到轉接站那個視窗按 Ctrl+C。這週跟上週不一樣:bot 本體還在雲端,關掉轉接站只是「查 …」這條路斷了,LINE 的 Webhook 不用改、不用換回去。
你應該看到視窗回到提示字元。手機再傳「查 …」會回「🔌 本地模型現在連不到(或等超過 50 秒)…」;傳「明天要開會」還是「✅ 記好了」。測一下待辦那句照常回,就過——這就是「bot 留在雲端當接收端」的意思。卡住了
- 回家想再開→ 新開 PowerShell,把 2-2 那三行再貼一次(PATH 那行每個新視窗都要);網址會換,改指令碼屬性的
SEARCH_RELAY_URL就好,不用重新部署。密碼存在使用者資料夾,同一台電腦是同一組。 - 教室電腦有還原卡→ 重開機後
line-relay資料夾和密碼檔都沒了,回家要重做 0-C、2-1,而且會是新密碼:SEARCH_RELAY_URL、SEARCH_RELAY_KEY兩個都要改。 - 想讓「查」永遠可用→ 要一台一直開著的電腦(或之後把轉接站放到雲端);這是期中可以做的題目之一。
- 回家想再開→ 新開 PowerShell,把 2-2 那三行再貼一次(PATH 那行每個新視窗都要);網址會換,改指令碼屬性的
-
教室電腦有還原卡,重開機會清掉;但在那之前下一個人就坐在這台前面。離開前:①瀏覽器右上頭像 → 登出 Google(雲端硬碟、Apps Script 都跟著登出)②LINE 官方帳號後台與 LINE Developers 登出 ③Ollama app 有登入的話,Settings 裡登出 ④瀏覽器跳出「要儲存密碼嗎」一律按「不要」;有存到的到設定 → 密碼管理員刪掉 ⑤關掉所有瀏覽器視窗與 PowerShell 視窗。
你應該看到重開瀏覽器打開 drive.google.com 要你重新登入;LINE Developers 也要重新登入。測一下drive.google.com 顯示登入頁,就過。卡住了
- Ollama app 找不到登出→ 左下 Settings → 帳號那區;找不到就算了,還原卡重開機會清。
- PowerShell 視窗關不掉(轉接站還在跑)→ 先 Ctrl+C(5-2),再關。
-
接下來兩週:10/26 停課(不補課、不繳交);11/02 期中,每人 90 秒讓它跑給大家看,下半場互相看、互相問。期中前有問題在 e學苑 問。
回家想重做一遍:這一頁每一步在自己電腦都做得了(第 0 段整份指令、轉接站、對照答案都在這頁,複製就好)。今天三件事做完,你手上有:一支留在雲端、「查 …」會轉回你電腦的 bot;一個只聽本機、要密碼的轉接站;一句你自己定的期中題目。
你應該看到這一頁的網址存在你自己的地方(書籤,或用 LINE 傳給自己);4-3 那句也帶走了。測一下沒有要測的;把這一頁的網址和那句話記下來就好。卡住了
- 10/26 想找老師→ 停課那週沒有課,用 e學苑 的討論區問;期中前都看得到。
- 想在家用 W6 簡單版做期中→ 可以,兩種 bot 都是這學期學到的東西;記得條件 1「至少兩樣」。
卡關總表
上面每一步的「卡住了」全部集中在這裡,打關鍵字就過濾(例如 401、cloudflared、Webhook、密碼、新版本)。
| 步驟 | 症狀 → 處理 |
|---|
本頁內容來源:W7 內容規格(硬事實、流程、逐字區);轉接站與 bot 回覆的字句直接抄自 starter/line-search-relay.mjs 與 starter/Code.search-reference.gs(兩份都沿用 W6、沒改);第 0 段 A、B、D 與 W6 一字不差,C 是 10/17 老師在自己電腦照抄實走過的(單檔下載、放資料夾、貼 PATH、轉接站自己開通道、從外面打得通)。沒實走到的(真 LINE 端到端:手機 → LINE → Apps Script → 通道 → 轉接站 → 回手機;E403 學生機的網速與帳戶控制)在對應步驟的「卡住了」給了處置,課堂上以老師示範為準。