Show / hide languages:

🍎 Mac Stack: Going Live

Give your local Hermes agent four new powers — search the web, generate images, reply to LINE, and run on a schedule. Same Ollama foundation. Still no cloud LLM.

เพิ่มความสามารถสี่อย่างให้ Hermes agent บนเครื่องคุณ — ค้นหาเว็บ สร้างรูป ตอบ LINE และทำงานตามเวลา ใช้ Ollama เดิม ไม่ต้องพึ่ง LLM บนคลาวด์

给本地 Hermes 代理加四项新能力 — 搜索网页、生成图像、回复 LINE、定时运行。还是同一个 Ollama 底座,依然不用云端 LLM。

Hermes 3 DuckDuckGo SDXL Turbo LINE Messaging launchd Flask + ngrok Open WebUI

🧭 What you're adding

🇬🇧 English

The Mac Stack already taught your agent four ways to know things: a baked system prompt, RAG over your notes, RAG over a blog, and a Hermes tool loop. Now we teach it to do things in the world — search live, paint, message, and wake itself up on a schedule.

🇹🇭 ไทย

Mac Stack สอน agent ให้ รู้ เรื่องต่าง ๆ แล้ว — system prompt ที่อบไว้ RAG บนโน้ตของคุณ RAG บนบล็อก และ tool loop ของ Hermes ตอนนี้เราจะสอนให้มัน ทำ สิ่งต่าง ๆ ในโลกจริง — ค้นเว็บ วาดรูป ส่งข้อความ และตื่นมาทำงานตามเวลา

🇨🇳 中文

Mac Stack 已经教会代理四种 知道 事情的方式 — 烤进去的系统提示、对自己笔记的 RAG、对博客的 RAG、Hermes 工具循环。现在我们教它 事 — 实时搜索、画图、发消息、按计划自己醒来。

💡 The big idea — four new powers, one Hermes brain

All four pieces hang off the same Hermes agent loop you already built. Web search and image gen become two new FunctionTools. LINE is a thin Flask server that hands incoming messages to agent.chat(). Cron is a launchd .plist that runs a Python script — which also uses the agent. Nothing is replaced. Everything bolts on.

ทั้งสี่ส่วนต่อเข้ากับ ลูป Hermes agent ตัวเดิม ที่คุณสร้างไปแล้ว ค้นเว็บกับสร้างรูปกลายเป็น FunctionTool สองตัว LINE คือ Flask server บาง ๆ ที่ส่งข้อความเข้า agent.chat() Cron คือไฟล์ launchd .plist ที่รันสคริปต์ Python ซึ่งก็ใช้ agent เหมือนกัน ไม่ได้ทิ้งของเก่า ทุกอย่างต่อยอด

四块都挂在你已经搭好的 同一个 Hermes 代理循环 上。网页搜索和图像生成是两个新的 FunctionTool。LINE 是个轻量 Flask 服务,把消息交给 agent.chat()。Cron 是个 launchd .plist,按计划跑 Python 脚本 — 脚本里照样用代理。什么都没换,全是加法。

Before you start

🇬🇧 English

Work through the Mac Stack page first. You should already have Ollama running, hermes3:8b pulled, llama3.2 pulled, a .venv with LlamaIndex installed, and agent.py working on the command line.

🇹🇭 ไทย

ทำตาม หน้า Mac Stack ให้จบก่อน ควรมี Ollama รันอยู่แล้ว hermes3:8b และ llama3.2 ถูก pull แล้ว มี .venv ที่ติดตั้ง LlamaIndex แล้ว และ agent.py ทำงานได้บน command line

🇨🇳 中文

先把 Mac Stack 那一页过一遍。Ollama 已经在跑、hermes3:8bllama3.2 都已 pull、.venv 里装好了 LlamaIndex、agent.py 能在命令行跑通。

1 Install the extras

In the same mac_stack/ folder where agent.py lives:

ในโฟลเดอร์ mac_stack/ เดียวกันกับ agent.py:

在和 agent.py 同一个 mac_stack/ 文件夹里:

$ source .venv/bin/activate $ pip install duckduckgo-search diffusers transformers accelerate torch flask requests

2 Two new accounts you might need

Web search and image gen are local — no signup. LINE needs a free Messaging API channel from developers.line.biz. ngrok needs a free account at ngrok.com so LINE can reach your Mac in development.

ค้นเว็บกับสร้างรูปทำเองในเครื่อง ไม่ต้องสมัคร LINE ต้องเปิด Messaging API channel ฟรีที่ developers.line.biz ngrok ต้องสมัครบัญชีฟรีที่ ngrok.com เพื่อให้ LINE เข้าถึง Mac ของคุณตอนทดสอบ

网页搜索和图像生成都在本地,不用注册。LINE 需要在 developers.line.biz 开一个免费 Messaging API 频道。ngrok 需要在 ngrok.com 注册免费账号,让 LINE 在开发阶段能找到你的 Mac。

🔎 Tech 1 — Web search as a Hermes tool

🔎 duckduckgo-search live information

LLMs only know what they were trained on. Ask llama3.2 about tonight's weather and it will guess. A search tool lets Hermes look it up just like a person would, then use the snippets to answer.

LLM รู้แค่สิ่งที่เคยถูก train มา ถาม llama3.2 เรื่องอากาศคืนนี้ มันจะเดา เครื่องมือค้นเว็บทำให้ Hermes ค้นดูจริง ๆ เหมือนคน แล้วเอาผลค้นมาตอบ

LLM 只知道训练时见过的东西。问 llama3.2 今晚的天气,它会瞎猜。搜索工具让 Hermes 像人一样 真的去查,然后用搜索片段回答。

you ask: "weather tonight?" Hermes "I don't know. Better search." → web_search() DuckDuckGo top-5 snippets (title, url, body) no API key Hermes reads → answers internet needed only here
Hermes decides it needs a fact it doesn't have, calls web_search, reads the snippets, answers you.

The tool

def web_search(query: str) -> str: """Search the web with DuckDuckGo and return the top 5 result snippets. Use this whenever the user asks about current events, prices, weather, sports scores, or anything you wouldn't already know. """ from duckduckgo_search import DDGS with DDGS() as ddg: results = list(ddg.text(query, max_results=5)) if not results: return "No results found." return "\n\n".join( f"{r['title']}\n{r['href']}\n{r['body']}" for r in results )

🦴 Why the docstring is a paragraph, not a line

Hermes reads the docstring to decide when to call this tool. A two-sentence docstring — "what it does, when to use it" — cuts wrong-tool errors in half. "Use this whenever the user asks about current events, prices, weather, sports scores, or anything you wouldn't already know" turns a vague tool into one the agent reaches for at the right moment.

Hermes อ่าน docstring เพื่อตัดสินใจว่า ควร เรียกเครื่องมือนี้ เมื่อไหร่ docstring สองประโยค — "ทำอะไร เมื่อไหร่ควรใช้" — ลด error เรียกผิดเครื่องมือไปครึ่งหนึ่ง การเขียน "ใช้เมื่อผู้ใช้ถามเรื่องเหตุการณ์ปัจจุบัน ราคา อากาศ คะแนนกีฬา หรือสิ่งที่คุณไม่น่าจะรู้" เปลี่ยนเครื่องมือคลุมเครือให้ agent หยิบมาใช้ได้ถูกที่

Hermes 用 docstring 决定 什么时候 调这个工具。两句话的 docstring —— "做什么、什么时候用" —— 能把调错工具的错误率减半。写明 "当用户问当前事件、价格、天气、比分,或你本来就不知道的事情时使用",就把一个模糊工具变成代理在对的时机会主动伸手去拿的工具。

Try it

$ python agent_pro.py you > What's the weather in Chiang Mai tonight? # Thought: I don't have live weather data, I should search. # Action: web_search # Action Input: {"query": "Chiang Mai weather tonight"} # Observation: Chiang Mai forecast — clear skies, low 22°C... agent > Tonight in Chiang Mai expect clear skies and a low of about 22°C.

🌉 Alternative — Kimi WebBridge instead of DuckDuckGo

duckduckgo-search is the smallest possible web tool — pip install and you have search snippets. But snippets only: no logins, no clicking through, no JS-rendered pages. When you need a real browser, swap in Kimi WebBridge — Moonshot's local browser-automation service that pairs a desktop app with a Chrome/Edge extension and drives your real browser through Chrome DevTools Protocol. Same Hermes agent, much sharper teeth: it can log in, click through paginated results, fill forms. Page content never leaves your machine.

duckduckgo-search เป็นเครื่องมือเว็บที่เล็กที่สุดเท่าที่เป็นไปได้ — pip install เสร็จก็ได้ snippet มาแล้ว แต่ได้แค่ snippet — ล็อกอินไม่ได้ คลิกต่อไม่ได้ อ่านหน้า JS ไม่ได้ ถ้าต้องการเบราว์เซอร์จริง ให้สลับมาใช้ Kimi WebBridge — บริการ browser-automation ในเครื่องของ Moonshot ที่จับคู่แอปเดสก์ท็อปกับส่วนขยาย Chrome/Edge แล้วขับเบราว์เซอร์ จริง ของคุณผ่าน Chrome DevTools Protocol Hermes ตัวเดิม แต่เขี้ยวคมขึ้นเยอะ: ล็อกอินได้ คลิกผ่านหน้าเพจได้ กรอกฟอร์มได้ เนื้อหาเพจไม่เคยออกจากเครื่อง

duckduckgo-search 是最小的网页工具 — pip install 完就有搜索片段了。但只有片段:不能登录、不能点击翻页、读不了 JS 渲染的页面。需要真浏览器时,换成 Kimi WebBridge — Moonshot 的本地浏览器自动化服务,把桌面 App 和 Chrome/Edge 扩展配对,通过 Chrome DevTools Protocol 驱动你的浏览器。同样的 Hermes 代理,但牙齿锋利多了:能登录、点击翻页、填表单。页面内容永远不离开你的机器。

Install (one-time) — 安装一次即可

  1. Download the Kimi Desktop App: kimi.com/features/webbridge (Mac .dmg).
  2. Install the Kimi WebBridge extension from the Chrome Web Store (search "Kimi WebBridge").
  3. Open Kimi Desktop → Settings → WebBridge — pair the desktop app with the extension. Both should show as connected.
  4. Copy the MCP endpoint URL the WebBridge settings show (looks like http://127.0.0.1:PORT/sse), then run:
$ pip install llama-index-tools-mcp $ export WEBBRIDGE_MCP_URL="http://127.0.0.1:PORT/sse" # your URL from step 4 $ python agent_pro_webbridge.py

The new agent_pro_webbridge.py (in the downloads below) is async because LlamaIndex's MCP client and the workflow-based ReActAgent.run() are both async — that is the only structural difference from agent_pro.py. Run them side by side to compare; pick whichever fits the task at hand.

🎨 Tech 2 — Local image generation

🎨 SDXL Turbo via diffusers images, on-device

Ollama doesn't generate images — it only reads them. For local pictures we add a second tiny stack: HuggingFace diffusers running Stable Diffusion XL Turbo on your Mac's MPS backend (the same chip Ollama uses). Four denoising steps. ~5-10 seconds per image on an M2 Pro. Offline after the first model download.

Ollama สร้างรูปไม่ได้ — มันแค่อ่านรูปเป็น สำหรับสร้างรูปในเครื่อง เราเพิ่มสแต็คเล็ก ๆ อันที่สอง: diffusers ของ HuggingFace รัน Stable Diffusion XL Turbo บน MPS backend ของ Mac (ชิปเดียวกับที่ Ollama ใช้) 4 denoising steps ~5-10 วินาทีต่อรูปบน M2 Pro หลังโหลดโมเดลครั้งแรก ใช้แบบออฟไลน์ได้เลย

Ollama 不生成图像 — 它只能读图。要本地画图,我们再加一个小堆栈:HuggingFace 的 diffusers,在 Mac 的 MPS 后端(Ollama 用的同一块芯片)上跑 Stable Diffusion XL Turbo。4 步去噪,M2 Pro 上每张图 ~5-10 秒。首次下载模型后就能离线用了。

"a Lanna night market" Hermes generate_image( prompt, file ) SDXL Turbo · MPS 4 denoise steps ~5–10s on M2 Pro 512×512 PNG offline after 1st download ./images/out.png no internet at all
SDXL Turbo runs entirely on your Mac's GPU. The agent calls it as just another tool.

The standalone runner

# image_gen.py — usable from agent OR from the command line. def generate(prompt: str, out_path: str) -> str: """Generate an image from prompt and save to out_path.""" global _PIPE if _PIPE is None: _PIPE = _load_pipe() # MPS / CUDA / CPU auto-detected image = _PIPE( prompt=prompt, num_inference_steps=4, # Turbo only needs ~4 guidance_scale=0.0, # Turbo recommends 0.0 ).images[0] image.save(out_path) return out_path

The Hermes tool wrapper

def generate_image(prompt: str, filename: str = "out.png") -> str: """Generate an image from a text prompt and save it under ./images/. Returns the path of the saved PNG. Use this when the user asks for a picture, illustration, drawing, or any visual content. The prompt should be a short English description; SDXL Turbo handles ~75 tokens. """ from image_gen import generate as _gen_image Path("images").mkdir(exist_ok=True) out_path = Path("images") / filename _gen_image(prompt, str(out_path)) return f"Saved image to {out_path}"

🐌 First call is slow

The very first time you call generate_image, diffusers downloads ~6 GB of model weights from HuggingFace. After that the pipeline is cached in memory inside the Python process — second image is fast.

ครั้งแรกที่เรียก generate_image diffusers จะดาวน์โหลดโมเดล ~6 GB จาก HuggingFace หลังจากนั้น pipeline ถูก cache ไว้ใน memory ของ process Python — รูปที่สองจะเร็ว

第一次调 generate_image,diffusers 会从 HuggingFace 下载约 6 GB 模型。之后管道会缓存在 Python 进程内存里 — 后续画图很快。

💬 Tech 3 — Responding to LINE

💬 Flask + ngrok + LINE Messaging API chat where your students already live

LINE is the messaging app every Thai student already has. With a free Messaging API channel, you give your agent a phone number — students message your Official Account, the message lands on your Mac, Hermes answers, the reply goes back to LINE. Everything in between is one short Flask file.

LINE คือแอปแชทที่นักเรียนไทยทุกคนมีอยู่แล้ว ด้วย Messaging API channel ฟรี คุณกำลังให้ agent มี "เบอร์โทร" — นักเรียนส่งข้อความไปที่ Official Account ของคุณ ข้อความเข้ามาที่ Mac, Hermes ตอบ, คำตอบกลับไปที่ LINE ทุกอย่างตรงกลางคือไฟล์ Flask สั้น ๆ ไฟล์เดียว

LINE 是每个泰国学生都已经在用的聊天软件。开一个免费 Messaging API 频道,你就给代理装上了 "手机号" — 学生给你的官方账号发消息,消息进到你 Mac,Hermes 回复,结果再回到 LINE。中间所有事都在一个短短的 Flask 文件里。

LINE "สวัสดี ครูเอไอ" ↓ message ↑ reply LINE servers webhook POST + signature ngrok https tunnel → localhost Your Mac line_bot.py · Flask :8000 verify signature, parse event agent.chat(user_text) Hermes 3 · all 5 tools POST /v2/bot/message/reply reply travels back to the LINE app
Message in, agent thinks, reply out. ngrok exists so LINE's HTTPS server can reach your laptop in dev.

The Flask webhook

from agent_pro import build_agent app = Flask(__name__) agent = build_agent() LINE_TOKEN = os.environ["LINE_CHANNEL_ACCESS_TOKEN"] LINE_SECRET = os.environ["LINE_CHANNEL_SECRET"].encode() def _signature_ok(body: bytes, header: str) -> bool: """Verify the LINE webhook signature so attackers can't fake messages.""" mac = hmac.new(LINE_SECRET, body, hashlib.sha256).digest() return hmac.compare_digest(base64.b64encode(mac).decode(), header or "") @app.post("/webhook") def webhook(): body = request.get_data() if not _signature_ok(body, request.headers.get("X-Line-Signature", "")): abort(401) for event in request.json.get("events", []): if event.get("type") == "message" and event["message"]["type"] == "text": user_text = event["message"]["text"] response = str(agent.chat(user_text)) reply(event["replyToken"], response) return "ok"
  • Reuse the same agent. build_agent() is imported from agent_pro.py — exactly the same 5-tool Hermes loop that works on the command line. The LINE bot is just a different doorway into the same brain.
  • Verify the signature. LINE signs every webhook with HMAC-SHA256 of your channel secret. Without this check, anyone who guesses your URL could push fake messages and burn your tokens.
  • Iterate events. One webhook can carry several events (multiple messages, follow events, etc.). We only handle text messages here — image and sticker handling is left as an extension.
  • Hand the text to Hermes. The agent loop is unchanged: it may search the web, generate an image, even fire send_line_push to a different user mid-conversation. From the LINE bot's perspective the chat() call is a single line that returns a string.
  • Send the reply. One POST to /v2/bot/message/reply. The replyToken is consumed once and expires fast (~30s) — that's why we do the agent call synchronously instead of pushing it to a queue.

Wire LINE to your laptop

$ export LINE_CHANNEL_ACCESS_TOKEN="..." $ export LINE_CHANNEL_SECRET="..." $ python line_bot.py * Running on http://0.0.0.0:8000 # Second terminal — expose your Mac to the public internet over HTTPS $ ngrok http 8000 Forwarding https://random-id.ngrok-free.app -> http://localhost:8000 # Paste this into the LINE console: # Webhook URL: https://random-id.ngrok-free.app/webhook # Use webhook: ON

🔑 Why ngrok

LINE's servers won't reach your laptop directly — your Mac has no public IP, your router blocks inbound, and LINE only talks HTTPS. ngrok solves all three by tunnelling a public HTTPS URL through to your localhost:8000. When you ship for real, replace ngrok with a small cloud server (a Lambda, a $5 droplet, or the user's existing lambda/line-chatbot/ pattern).

เซิร์ฟเวอร์ LINE เข้ามาที่แล็ปท็อปคุณตรง ๆ ไม่ได้ — Mac ของคุณไม่มี public IP, router บล็อกขาเข้า, และ LINE คุยแค่ HTTPS ngrok แก้ทั้งสามด้วยการ tunnel public HTTPS URL ไปที่ localhost:8000 ของคุณ ตอน deploy จริงให้แทนที่ ngrok ด้วย cloud server เล็ก ๆ (Lambda, droplet $5, หรือใช้ pattern lambda/line-chatbot/ ที่มีอยู่)

LINE 服务器没办法直接连到你的笔记本 — Mac 没有公网 IP,路由器挡住入站,而且 LINE 只认 HTTPS。ngrok 一次性解决这三件事,把一个公网 HTTPS 地址通到你的 localhost:8000。真要上线时,把 ngrok 换成一台小云服务器(Lambda、5 美元的 droplet,或者你已有的 lambda/line-chatbot/ 模式)。

Tech 4 — Scheduled / event-driven tasks

launchd · .plist macOS-native cron

macOS replaced classic cron with launchd. You drop a small XML .plist file in ~/Library/LaunchAgents/, tell launchctl about it, and macOS will wake your script up at the times you set — even after a reboot. The script that wakes up is just Python, and it can use the same Hermes agent.

macOS แทน cron เดิมด้วย launchd วางไฟล์ XML .plist เล็ก ๆ ไว้ที่ ~/Library/LaunchAgents/ บอก launchctl แล้ว macOS จะปลุกสคริปต์ของคุณตามเวลาที่ตั้ง — แม้หลังรีบูตเครื่องก็ยังทำงาน สคริปต์ที่ถูกปลุกคือ Python ธรรมดา และใช้ Hermes agent ตัวเดิมได้

macOS 用 launchd 替代了老 cron。在 ~/Library/LaunchAgents/ 放一个小 XML .plist,告诉 launchctl 一声,macOS 就会按你设置的时间把脚本叫醒 — 重启之后也照常生效。醒来的脚本就是普通 Python,照样能用同一个 Hermes 代理。

07:00 daily launchd reads .plist runs python daily_word.py agent.chat("pick a word for today") LINE push message same agent_pro.build_agent() — same brain, different trigger
launchd is the trigger. The agent is the brain. Anything you can do in agent_pro.py you can do on a schedule.

The Python job

# daily_word.py — Hermes picks an English word, sends it to LINE. from agent_pro import build_agent, send_line_push def main() -> None: target = os.getenv("LINE_BROADCAST_USER_ID") agent = build_agent() prompt = ( "Pick ONE useful English word for a 14-year-old Thai student living in " "Chiang Mai. Reply in this exact 3-line shape:\n" " Word: <english>\n" " ความหมาย: <Thai>\n" " Example: <one short English sentence using the word>" ) word_card = str(agent.chat(prompt)) print(send_line_push(target, word_card))

The launchd .plist

<!-- com.kruai.daily.plist — saved to ~/Library/LaunchAgents/ --> <plist version="1.0"> <dict> <key>Label</key> <string>com.kruai.daily</string> <key>ProgramArguments</key> <array> <string>/Users/you/Downloads/mac_stack/.venv/bin/python</string> <string>/Users/you/Downloads/mac_stack/daily_word.py</string> </array> <key>StartCalendarInterval</key> <dict> <key>Hour</key><integer>7</integer> <key>Minute</key><integer>0</integer> </dict> <key>EnvironmentVariables</key> <dict> <key>LINE_CHANNEL_ACCESS_TOKEN</key><string>...</string> <key>LINE_BROADCAST_USER_ID</key><string>Uxxxxx</string> </dict> <key>StandardOutPath</key><string>/tmp/kruai.out.log</string> <key>StandardErrorPath</key><string>/tmp/kruai.err.log</string> </dict> </plist>
  • Full absolute paths. launchd does not run inside your shell — there is no PATH with python on it, no ~ expansion. Use the path to the Python inside your virtualenv so the right packages are picked up.
  • StartCalendarInterval = wall-clock time. Use this for "every day at 7am". For "every 15 minutes" use StartInterval with an integer of seconds. Both can be combined, but pick one for clarity.
  • Pass env vars in. launchd ignores your ~/.zshrc. Anything your script reads from os.environ must live here — including the LINE token and the target user/group ID.
  • Log somewhere. Background jobs fail silently by default. Pointing stdout and stderr at /tmp/ turns "why didn't it run?" into a one-line tail.

Install & test

$ cp com.kruai.daily.plist ~/Library/LaunchAgents/ $ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.kruai.daily.plist $ launchctl start com.kruai.daily # run immediately to test $ tail -f /tmp/kruai.out.log # watch it work $ launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.kruai.daily.plist # uninstall

🎯 The same pattern, ten different jobs

Once you have launchd → Python → agent, every recurring chore becomes a tiny script: scrape a competitor's website each morning and email yourself a diff; summarise your inbox at 6 PM into one LINE message; ping a webhook when a folder you watch changes (WatchPaths in the plist). The agent loop is the same. Only the prompt changes.

เมื่อมี launchd → Python → agent แล้ว ทุกงานประจำกลายเป็นสคริปต์เล็ก ๆ: ดึงข้อมูลเว็บคู่แข่งทุกเช้าแล้วส่งอีเมล diff ให้ตัวเอง สรุปกล่องอีเมล 6 โมงเย็นเป็นข้อความ LINE หนึ่งข้อความ เรียก webhook เมื่อโฟลเดอร์ที่จับตามองมีการเปลี่ยน (WatchPaths ใน plist) ลูป agent ตัวเดิม เปลี่ยนแค่ prompt

有了 launchd → Python → agent 这条链,所有重复杂事都变成一个小脚本:每天早上爬一下对手网站、把 diff 邮件发给自己;下午 6 点把收件箱总结成一条 LINE 消息;监视的文件夹一有变化就触发 webhook(plist 里的 WatchPaths)。代理循环不变,只换 prompt。

🖥️ Tech 5 — A tiny web UI

🖥️ Flask + one HTML page browser chat, ~70 lines

Ollama exposes a REST API on localhost:11434 but ships no web UI of its own. The terminal works, LINE works — but sometimes you want to chat in a browser, on your own laptop, with markdown, copy-paste, and a textarea bigger than one line. A second tiny Flask file plus one HTML page gets you there. Same build_agent() as before — just a new front door.

Ollama เปิด REST API ที่ localhost:11434 แต่ไม่ได้แถม UI มาเอง terminal ใช้ได้ LINE ใช้ได้ — แต่บางทีก็อยากคุยในเบราว์เซอร์ บนเครื่องตัวเอง มี markdown copy-paste และ textarea ใหญ่กว่าหนึ่งบรรทัด ไฟล์ Flask อันที่สองเล็ก ๆ บวกหน้า HTML หนึ่งหน้าก็พอ ใช้ build_agent() ตัวเดิม — แค่เพิ่มประตูหน้าใหม่

Ollama 在 localhost:11434 上暴露 REST API,但它自己不带网页 UI。终端能用,LINE 能用 — 但有时候你就是想在浏览器里聊,跑在自己电脑上,能看 markdown、能复制粘贴、textarea 比一行大。再加一个小 Flask 文件加一个 HTML 页面就够了。还是用同一个 build_agent() — 只是多了一扇前门。

browser chat.html fetch(/chat) JSON Flask :5001 GET / → HTML POST /chat → agent build_agent() same Hermes brain + same 5 tools Ollama :11434 local browser ←→ Flask ←→ build_agent() — three small parts, no Docker, no cloud
Three small parts you control end to end. The browser POSTs JSON; Flask hands it to the agent; the agent replies; the page renders.

The Flask server

# chat_ui.py — a 30-line web chat for the same agent. from flask import Flask, request, jsonify, send_from_directory from agent_pro import build_agent app = Flask(__name__) agent = build_agent() @app.route("/") def index(): return send_from_directory(".", "chat.html") @app.route("/chat", methods=["POST"]) def chat(): message = request.json.get("message", "") if not message.strip(): return jsonify({"reply": ""}) reply = str(agent.chat(message)) return jsonify({"reply": reply}) if __name__ == "__main__": app.run(host="127.0.0.1", port=5001)
  • Build the agent once. Loading Hermes + registering tools takes a few seconds. Build at import time, not per-request — otherwise every message feels like a fresh boot.
  • Serve the HTML from the same Flask app. No nginx, no static server. The page and the API are one process, one port — drag the file next to chat_ui.py and it just works.
  • One call into the agent. agent.chat() handles the tool loop internally — web search, image gen, LINE push are all available because build_agent() registered them. The web UI inherits every tool for free.
  • Bind to 127.0.0.1, not 0.0.0.0. No auth on this endpoint — anyone on your Wi-Fi could chat with your agent (and burn your CPU) if you open it up. Keep it local. If you need it on the LAN, add a password first.

The HTML page

<!-- chat.html — one file. No build step, no framework. --> <!DOCTYPE html> <meta charset="utf-8"> <title>Kru AI</title> <style> body{font-family:system-ui;max-width:720px;margin:30px auto;padding:0 16px;} #log{border:1px solid #ddd;border-radius:10px;padding:14px;height:60vh;overflow-y:auto;} .msg{margin:8px 0;padding:10px 14px;border-radius:14px;max-width:85%;} .you{background:#e0e7ff;margin-left:auto;} .bot{background:#f1f5f9;white-space:pre-wrap;} form{display:flex;gap:8px;margin-top:12px;} textarea{flex:1;padding:10px;border:1px solid #ddd;border-radius:10px;min-height:54px;} button{padding:0 22px;border:0;border-radius:10px;background:#7c3aed;color:#fff;font-weight:700;cursor:pointer;} </style> <h2>🍎 Kru AI — local chat</h2> <div id="log"></div> <form id="f"> <textarea id="m" placeholder="Ask anything…" autofocus></textarea> <button>Send</button> </form> <script> const log = document.getElementById("log"); const add = (who, text) => { const d = document.createElement("div"); d.className = "msg " + who; d.textContent = text; log.appendChild(d); log.scrollTop = log.scrollHeight; }; document.getElementById("f").onsubmit = async (e) => { e.preventDefault(); const ta = document.getElementById("m"); const msg = ta.value.trim(); if (!msg) return; add("you", msg); ta.value = ""; add("bot", "…thinking"); const r = await fetch("/chat", { method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({message: msg}) }); const data = await r.json(); log.lastChild.textContent = data.reply || "(no reply)"; }; </script>

Run it

$ source .venv/bin/activate $ python chat_ui.py * Running on http://127.0.0.1:5001 $ open http://localhost:5001 # or just paste in your browser

🚪 The same brain behind four doors

You now have four ways to reach the same build_agent(): the CLI from the original Mac Stack, the LINE webhook from Tech 3, the launchd job from Tech 4, and this browser UI. None of them know about the others. None of them duplicate logic. Add a new tool to agent_pro.py and all four surfaces inherit it the next time you restart them. That is the payoff of the build_agent() refactor.

ตอนนี้คุณเข้าถึง build_agent() ตัวเดียวกันได้สี่ทาง: CLI จาก Mac Stack เดิม, LINE webhook จาก Tech 3, launchd job จาก Tech 4 และ UI เบราว์เซอร์นี้ ไม่มีตัวไหนรู้จักตัวอื่น ไม่มีตรรกะซ้ำ เพิ่ม tool ใหม่ใน agent_pro.py แล้วทั้งสี่ทางจะได้รับ tool นั้นเองตอนรีสตาร์ทครั้งต่อไป นี่คือ ผลตอบแทน ของการแยก build_agent() ออกมา

现在你有四种方式接到同一个 build_agent():Mac Stack 里的 CLI、Tech 3 的 LINE webhook、Tech 4 的 launchd 任务、还有这个浏览器 UI。它们彼此不知道对方存在,也没有重复逻辑。在 agent_pro.py 里加个新工具,下次重启时这四扇门都会自动继承它。这就是把 build_agent() 抽出来的 回报

If you want polish — Open WebUI

The 70-line UI above is yours to bend. If you'd rather not own the code and just want a ChatGPT-clone — model picker sidebar, multi-user, chat history, file uploads, markdown rendering — install Open WebUI. It's the community-standard front end for Ollama, runs in Docker, and talks to the same localhost:11434 Ollama is already serving. Trade-off: heavier (Docker Desktop required) and it talks to Ollama directly, so it doesn't see the agent's tools — only the raw model.

UI 70 บรรทัดข้างบนคือของคุณ ดัดแปลงได้เลย ถ้าไม่อยากเขียนเอง อยากได้ ChatGPT-clone — sidebar เลือกโมเดล หลายผู้ใช้ ประวัติแชท อัพโหลดไฟล์ render markdown — ติดตั้ง Open WebUI เป็น UI มาตรฐานของชุมชน Ollama รันใน Docker คุยกับ localhost:11434 ตัวเดียวกับที่ Ollama ฟังอยู่ ข้อแลก: หนักกว่า (ต้องมี Docker Desktop) และคุยกับ Ollama โดยตรง เลยไม่เห็น tool ของ agent — เห็นแค่โมเดลล้วน ๆ

上面 70 行的 UI 是你的,怎么改都行。如果你不想自己写代码,只想要一个 ChatGPT 克隆 — 侧边栏选模型、多用户、聊天历史、文件上传、markdown 渲染 — 装 Open WebUI。它是 Ollama 社区标准前端,跑在 Docker 里,连接 Ollama 已经在监听的同一个 localhost:11434。代价:更重(需要 Docker Desktop),而且它 直接 跟 Ollama 对话,所以看不到 agent 的工具 — 只能看到原始模型。

# Install Docker Desktop for Mac first, then: $ docker run -d -p 3000:8080 \ --add-host=host.docker.internal:host-gateway \ -v open-webui:/app/backend/data \ --name open-webui --restart always \ ghcr.io/open-webui/open-webui:main $ open http://localhost:3000 # first user becomes admin

🎯 Which one should you pick?

Start with the Flask UI. It's 70 lines you can read in a coffee break, it inherits every tool you've built, and there's no Docker layer to debug. Move to Open WebUI only if you need conversation history, multi-user accounts, or you're sharing the box with non-developers who expect a polished interface.

เริ่มที่ Flask UI ก่อน 70 บรรทัดที่อ่านจบในช่วงพักกาแฟ ได้ tool ทุกตัวที่คุณสร้างไปแล้วฟรี และไม่มี Docker ให้ดีบั๊ก ขยับไป Open WebUI ก็ต่อเมื่อต้องการประวัติบทสนทนา หลายบัญชี หรือใช้เครื่องร่วมกับคนที่ไม่ใช่นักพัฒนา ซึ่งคาดหวัง UI ที่ขัดเงาแล้ว

先用 Flask UI。70 行代码喝杯咖啡就能看完,免费继承你已经写好的全部工具,也没有 Docker 层要排查。等你需要会话历史、多用户账号,或者要和不懂代码的人共用这台机器、需要一个抛光过的界面,再考虑 Open WebUI。

📚 Tech 6 — Curated cloud research via NotebookLM

📚 notebooklm-py CLI cited research, on-demand

Hermes is fast but its training is months stale, and DuckDuckGo gives you snippets without context. NotebookLM is Google's "second brain" — you upload sources (PDFs, web pages, YouTube transcripts) and Gemini answers questions with inline citations from only those sources. The notebooklm-py CLI lets a Python script — and therefore a Hermes tool — drive it: create notebooks, add deep-research sources, ask questions, get back grounded answers. The model lives in the cloud; your curated source set is the brain.

Hermes เร็ว แต่ training ของมันค้างหลายเดือน และ DuckDuckGo ให้แค่ snippet ไม่มีบริบท NotebookLM คือ "สมองที่สอง" ของ Google — คุณอัพแหล่งข้อมูล (PDF, หน้าเว็บ, transcript YouTube) แล้ว Gemini ตอบคำถามพร้อม citation จาก เฉพาะ แหล่งเหล่านั้น CLI ชื่อ notebooklm-py ทำให้สคริปต์ Python — และจึงเป็น Hermes tool — ขับมันได้: สร้าง notebook, เพิ่ม deep research sources, ถามคำถาม, ได้คำตอบที่อ้างอิง โมเดลอยู่บนคลาวด์ แต่ ชุดแหล่งข้อมูลที่คุณคัด คือสมอง

Hermes 很快,但它的训练数据已经过期几个月,DuckDuckGo 又只给片段、没有上下文。NotebookLM 是 Google 的"第二大脑" — 你上传资料(PDF、网页、YouTube 字幕),Gemini 仅根据 这些 资料回答问题并标注引用。notebooklm-py CLI 让 Python 脚本 — 也就是 Hermes 工具 — 来开车:建笔记本、添加深度研究素材、提问、拿到带引用的回答。模型在云上跑,你 挑选的资料集 就是它的大脑。

"What does my CEFR paper say about…?" Hermes "Cite-able sources? Ask the notebook." → ask_notebook() NotebookLM Gemini reads ONLY your curated sources PDFs · web · YouTube + deep research cloud · free Google account answer + [1] [2] cites Google login, free
Hermes decides "this needs sources, not a guess", calls ask_notebook, gets back a cited answer.

Install & log in once

$ pip install notebooklm-py $ notebooklm login # opens a browser — sign in to Google, then ENTER $ notebooklm doctor # confirms the SID cookie is saved $ notebooklm create "Hermes Research" # the notebook the agent will read $ notebooklm use "Hermes" # set as default for future commands

Login is one-time per machine — the cookie sits in ~/.notebooklm/profiles/default/storage_state.json and lasts months. After that, every notebooklm call (from your shell or from Python) reuses it. Add sources to the notebook by hand in the web UI, or via notebooklm source add <url> / add-research <topic> --mode deep --import-all.

login ครั้งเดียวต่อเครื่อง — cookie เก็บที่ ~/.notebooklm/profiles/default/storage_state.json และอยู่ได้หลายเดือน หลังจากนั้นทุก call notebooklm (จาก shell หรือจาก Python) จะใช้ซ้ำ เพิ่มแหล่งข้อมูลผ่าน web UI ด้วยมือ หรือใช้ notebooklm source add <url> / add-research <topic> --mode deep --import-all

每台机器登录一次就够 — cookie 存在 ~/.notebooklm/profiles/default/storage_state.json,能用好几个月。之后每次 notebooklm 调用(命令行或 Python)都会复用。添加资料可以在网页 UI 手动加,也可以用 notebooklm source add <url>add-research <topic> --mode deep --import-all

The standalone runner

# notebook_research.py — usable from agent OR from the command line. import subprocess NOTEBOOK = "Hermes Research" # whatever you named yours def ask(question: str) -> str: """Ask the saved notebook a question and return Gemini's grounded answer.""" out = subprocess.run( ["notebooklm", "-n", NOTEBOOK, "ask", question], capture_output=True, text=True, timeout=180, ) return out.stdout.strip() or out.stderr.strip() def research(topic: str) -> str: """Kick off a deep web research pass and import all results into the notebook.""" out = subprocess.run( ["notebooklm", "-n", NOTEBOOK, "source", "add-research", topic, "--mode", "deep", "--import-all"], capture_output=True, text=True, timeout=600, ) return out.stdout.strip() or out.stderr.strip()

The Hermes tool wrapper

def ask_notebook(question: str) -> str: """Ask a NotebookLM notebook full of curated sources and return a cited answer. Use this whenever the user asks something where citations matter — quoting a paper, summarising a book chapter, comparing two articles, or any question whose answer must come from the user's own saved sources rather than your training data or a casual web snippet. """ from notebook_research import ask as _ask return _ask(question) def research_topic(topic: str) -> str: """Run a NotebookLM deep web research pass on a topic and import all sources. Use this when the user asks you to "look into" a topic, gather sources, or build up a reading list. This adds material to the notebook so future ask_notebook calls have something to cite. Slow — takes minutes. """ from notebook_research import research as _research return _research(topic)

Add both to agent_pro.py and append them to TOOLS:

เพิ่มทั้งสองลงใน agent_pro.py แล้วต่อท้าย TOOLS:

把两个都加到 agent_pro.py,然后 append 到 TOOLS

TOOLS = [ # ... the original 5 ... FunctionTool.from_defaults(ask_notebook), # NEW FunctionTool.from_defaults(research_topic), # NEW ]

Try it

$ python agent_pro.py you > Use my notebook to summarise what the CEFR B1 descriptors say about writing. # Thought: This needs my saved sources, not a guess. ask_notebook. # Action: ask_notebook # Action Input: {"question": "What do CEFR B1 descriptors say about writing?"} # Observation: At B1 a learner can write straightforward connected text on familiar topics… [1] [2] agent > Your saved CEFR descriptors say B1 writers can produce connected text on familiar topics, with simple linking words [1, 2].

🔑 Two tools, two jobs

Keep ask_notebook and research_topic as separate tools. Asking is fast (seconds) and used in normal conversation; researching is slow (minutes) and only worth it when you want to permanently grow the notebook. Bundling them into one tool teaches Hermes the wrong cost model and it will keep firing slow research passes when a quick lookup would do.

เก็บ ask_notebook กับ research_topic เป็นเครื่องมือคนละตัว ถามคือเร็ว (วินาที) ใช้ในบทสนทนาปกติ; research คือช้า (นาที) คุ้มต่อเมื่อต้องการเพิ่มของลง notebook ถาวร รวมเป็นตัวเดียวจะสอน Hermes ผิด มันจะยิง research ช้า ๆ ทั้งที่ ask ก็พอ

ask_notebookresearch_topic 保持两个独立工具。问问题是秒级,用于日常对话;做研究是分钟级,只有真要往笔记本里加资料才值得。合成一个工具会让 Hermes 学错"代价模型",明明 ask 就够了,它还会去跑慢慢的 research。

☁️ This one isn't local

NotebookLM runs on Google's servers — the model is Gemini, not Hermes. Your sources, questions, and answers leave your laptop. Use it for material you're already happy to share with Google (public papers, your own published blog posts, course PDFs you're allowed to use). Keep anything sensitive in the local Hermes RAG flow you built in the Mac Stack.

NotebookLM รันบน server ของ Google — โมเดลคือ Gemini ไม่ใช่ Hermes แหล่งข้อมูล คำถาม และคำตอบของคุณออกจากเครื่อง ใช้กับเนื้อหาที่คุณยินดีแชร์กับ Google อยู่แล้ว (paper สาธารณะ บล็อกของตัวเอง PDF เรียนที่ใช้ได้) อะไรที่อ่อนไหวเก็บไว้ใน RAG ของ Hermes ในเครื่องที่คุณสร้างใน Mac Stack

NotebookLM 跑在 Google 服务器上 — 模型是 Gemini,不是 Hermes。你的资料、问题、答案都会离开本机。只用在你本来就愿意分享给 Google 的材料上(公开论文、自己发的博客、有授权的课程 PDF)。敏感内容继续走你在 Mac Stack 里搭的本地 Hermes RAG。

🧩 Putting it all together

One file holds the upgraded agent. Two files use it (the LINE bot and the scheduled job). One file is just an image helper. That's the whole bolt-on.

ไฟล์เดียวเก็บ agent ตัวที่อัพเกรดแล้ว สองไฟล์ใช้มัน (LINE bot กับงานตั้งเวลา) อีกหนึ่งไฟล์เป็นแค่ตัวช่วยสร้างรูป เท่านี้คือสิ่งที่เพิ่มทั้งหมด

一个文件装升级后的代理。两个文件用它(LINE 机器人和定时任务)。还有一个文件只是图像助手。这就是全部新增内容。

agent_pro.py build_agent() = Hermes 3 multiply · days_until web_search · generate_image send_line_push line_bot.py Flask + ngrok webhook daily_word.py run by launchd .plist image_gen.py SDXL Turbo · MPS DuckDuckGo no API key imports build_agent imports build_agent tool calls tool calls
Both doorways (LINE bot, scheduled job) reach the same Hermes agent. Both new tools call out to local code (SDXL) or one network endpoint (DDG).

The whole upgraded agent

# agent_pro.py — same skeleton as agent.py, three new tools. TOOLS = [ FunctionTool.from_defaults(multiply), FunctionTool.from_defaults(days_until), FunctionTool.from_defaults(web_search), # NEW FunctionTool.from_defaults(generate_image), # NEW FunctionTool.from_defaults(send_line_push), # NEW ] def build_agent() -> ReActAgent: llm = Ollama(model="hermes3:8b", request_timeout=300) return ReActAgent(tools=TOOLS, llm=llm, verbose=True)

🧮 When to use which

I want the agent to…UseWhy
Answer questions about today's facts web_search tool Hermes can't know prices, weather, or news after its training cutoff.
Make an image from a prompt generate_image tool SDXL Turbo on MPS keeps it local — same "no cloud" promise as Ollama.
Chat with students in LINE line_bot.py + ngrok Flask is one file. The webhook is the only LINE-specific code; the agent is unchanged.
Post a daily message at 7am launchd .plist + Python job macOS-native cron. The job is just a script that uses the same agent.
React when a folder changes launchd WatchPaths Same .plist shape — drop StartCalendarInterval, add WatchPaths.
Send a message from the agent mid-conversation send_line_push tool Different from reply — push lets the agent message anyone whose user ID it has.
Chat with the agent in a browser on your laptop chat_ui.py (Flask) + chat.html ~70 lines total, no Docker, inherits every tool. Use Open WebUI instead if you need accounts & history.
Answer with citations from my own saved sources ask_notebook tool Hermes can't cite; NotebookLM can — and only from sources you chose, so the answer stays grounded.
Build up a reading list on a topic research_topic tool NotebookLM's deep research pulls cited web sources into the notebook so future ask_notebook calls have context.

🔬 Code walkthroughs

Click each panel to expand the file with numbered notes.

คลิกที่แต่ละแผงเพื่อขยายดูไฟล์พร้อมหมายเหตุประกอบ

点击每个面板展开,查看带编号注释的源码。

🛠️ How agent_pro.py wires three new tools

Same skeleton as agent.py from the Mac Stack. The only changes:

  • Three new top-level functions: web_search, generate_image, send_line_push. Each has a 2-sentence docstring.
  • The TOOLS list grows to 5 entries.
  • The agent-builder is extracted into build_agent() so line_bot.py and daily_word.py can import and reuse the same configured agent.

Nothing else moves. The ReActAgent loop, the Ollama model choice, the verbose logging — all unchanged.

🎯 Why one Hermes, not three

You could spin up a separate ReActAgent for the LINE bot and another for the cron job. Don't. Building the agent is the slow part (model load, tool registration); reuse is free. The CLI, the LINE bot, and the cron job all import the same build_agent() and get the same brain.

💬 How line_bot.py turns LINE into a chat interface

The full source is ~50 lines. The shape is: verify the signature → walk the events → for each text message, run it through the agent → reply with the answer.

Why signature verification matters

LINE webhook URLs are guessable enough that without HMAC verification, an attacker can blast fake events at you. Each fake event:

  • Spends one of your daily LINE replies.
  • Triggers your agent to run — wasting CPU and potentially money if any tool calls cost (e.g., paid search APIs).
  • Looks like a real user did it in your logs, masking actual abuse.

HMAC-SHA256 of the raw request body with your channel secret, compared in constant time, makes spoofing infeasible.

Why we synchronously call the agent

replyToken expires in ~30 seconds. If Hermes takes 60 seconds because of a slow tool, the reply fails. For the demo we just call agent.chat() synchronously and accept the limit. In production you'd either:

  • Send an instant ack reply ("คิดอยู่นะ…") and follow up with push when done — uses one push message quota per long answer.
  • Make the agent finish in <30s by skipping image generation on first reply.
🎨 How image_gen.py stays fast and local

Two design choices keep this practical on a laptop:

  1. Lazy import. torch alone takes ~1 second to import. We don't pay that cost until the first generate() call — so importing image_gen in agent_pro.py is free.
  2. Pipeline caching. The _PIPE module-level variable holds the loaded diffusers pipeline between calls. First image: ~15 seconds (model load + 4 steps). Subsequent images in the same process: ~5 seconds (4 steps only).

Device selection

The loader picks the fastest available backend automatically: MPS on Apple Silicon, CUDA on Linux/Windows with an NVIDIA GPU, otherwise CPU. Float16 weights for GPU paths; float32 only on CPU where fp16 is slower.

Why SDXL Turbo, not full SDXL

Full SDXL needs 30+ denoising steps and CFG. That's ~45 seconds on M2 Pro per image. SDXL Turbo is the distilled version: 4 steps, no guidance, comparable quality for chat-scale prompts. The agent feels responsive instead of like batch art.

⏰ How com.kruai.daily.plist survives reboots

launchd reads every plist in ~/Library/LaunchAgents/ the moment your user session starts. Your scheduled job is enrolled until you explicitly remove it — no terminal needed at startup, no login items, no manual ollama serve dance.

But Ollama needs to be running too

Your job calls Hermes via Ollama's :11434 server. If Ollama isn't running when 7 AM hits, the call fails. Two fixes:

  • Easy: set the Ollama desktop app to launch at login (System Settings → General → Login Items). It's already in the menu bar; just check the box.
  • Pure: add a second .plist that runs ollama serve with RunAtLoad=true and KeepAlive=true. The daily-word plist gets KeepAlive for SuccessfulExit=false so missed runs catch up. Overkill unless you turn the desktop app off on purpose.

launchctl cheat sheet

$ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.kruai.daily.plist # load $ launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.kruai.daily.plist # unload $ launchctl start com.kruai.daily # run now $ launchctl list | grep kruai # show status + last exit code $ launchctl print gui/$(id -u)/com.kruai.daily # full job details

🎙 Tech 7 — Voice: listen and speak

🎙 Whisper + Edge TTS two-way voice

Typing is fine. Talking is faster. Whisper turns your microphone into text (runs locally — your audio never leaves the Mac). Edge TTS turns the reply into spoken audio (uses Microsoft's free neural voices — sounds great, supports Thai). Together: a hands-free agent.

พิมพ์ก็ดี แต่พูดเร็วกว่า Whisper แปลงเสียงไมค์เป็นข้อความ (รันในเครื่อง — เสียงไม่ออกจาก Mac) Edge TTS แปลงคำตอบเป็นเสียงพูด (ใช้เสียงนิวรัลฟรีของ Microsoft เสียงดีมาก รองรับภาษาไทย) รวมกันคือ agent ที่ไม่ต้องใช้มือ

打字也行。说话更快。Whisper 把麦克风转成文字(本地跑 — 音频不离开你的 Mac)。Edge TTS 把回复转成语音(用微软免费的神经语音,音质很好,支持中文)。两者合起来就是一个免提的代理。

🐳 The easy way — Docker

Don't install these one by one. The local_ai_agent project bundles Ollama + Whisper + Edge TTS + a web UI into one docker compose up. Each piece is a separate container that talks to the others over a private network. See 🐳 Docker AI Agent — Mac install & overview for the full setup walkthrough with diagrams; the rest of this section is the no-Docker version for anyone who wants to wire it by hand.

อย่าติดตั้งทีละตัว โปรเจค local_ai_agent รวม Ollama + Whisper + Edge TTS + Web UI ไว้ใน docker compose up เดียว แต่ละชิ้นเป็น container แยกที่คุยกันผ่านเครือข่ายส่วนตัว ดูหน้า 🐳 Docker AI Agent — Mac install & overview สำหรับขั้นตอนเต็มพร้อมไดอะแกรม ส่วนที่เหลือของหัวข้อนี้คือเวอร์ชันไม่ใช้ Docker สำหรับคนอยากต่อเอง

别一个一个装。local_ai_agent 项目把 Ollama + Whisper + Edge TTS + Web UI 打包到一个 docker compose up 里。每个组件都是独立容器,通过私有网络互相通信。完整步骤和图解见 🐳 Docker AI Agent — Mac install & overview;本节余下部分是手动接线的无 Docker 版本。

1 Install Whisper (speech → text)

The faster-whisper backend runs the small model on CPU at near real-time on Apple silicon. No GPU needed.

backend faster-whisper รันโมเดล small บน CPU ได้เกือบ real-time บน Apple silicon ไม่ต้องใช้ GPU

faster-whisper 后端在 Apple silicon CPU 上跑 small 模型,几乎实时。不用 GPU。

$ source .venv/bin/activate $ pip install faster-whisper sounddevice scipy $ # first run downloads ~150 MB model weights to ~/.cache/huggingface

2 Record from your mic and transcribe

# voice_in.py — hold ENTER, talk, release import sounddevice as sd from scipy.io.wavfile import write from faster_whisper import WhisperModel model = WhisperModel("small", device="cpu", compute_type="int8") def listen(seconds=5): fs = 16000 audio = sd.rec(int(seconds * fs), samplerate=fs, channels=1) sd.wait() write("rec.wav", fs, audio) segments, _ = model.transcribe("rec.wav", beam_size=5) return " ".join(s.text for s in segments).strip() print(listen())

3 Install Edge TTS (text → speech)

No model download, no API key. The edge-tts package talks to Microsoft's free public neural voice endpoint. Online but instant.

ไม่ต้องโหลดโมเดล ไม่ต้อง API key แพ็คเกจ edge-tts คุยกับ endpoint เสียงนิวรัลฟรีของ Microsoft ออนไลน์แต่ทันที

不下模型,不要 API key。edge-tts 直接调微软免费的公共神经语音端点。在线但很快。

$ pip install edge-tts $ # try it from the command line $ edge-tts --voice en-US-JennyNeural --text "Hello from Chiang Mai" --write-media out.mp3 $ afplay out.mp3

4 Speak the reply

# voice_out.py — async edge-tts in 6 lines import asyncio, edge_tts async def say(text, voice="en-US-JennyNeural"): com = edge_tts.Communicate(text, voice) await com.save("out.mp3") asyncio.run(say("Sawasdee. The lesson is ready.")) # for Thai: voice="th-TH-PremwadeeNeural" # for Chinese: voice="zh-CN-XiaoxiaoNeural"

5 Glue it into the Hermes loop

Replace the input("you: ") in agent.py with listen(), and pipe the agent's reply into say(). The agent itself doesn't change — only the I/O around it.

เปลี่ยน input("you: ") ใน agent.py เป็น listen() และส่งคำตอบของ agent เข้า say() ตัว agent เหมือนเดิม เปลี่ยนแค่ I/O รอบ ๆ

agent.py 里的 input("you: ") 换成 listen(),把代理的回复送进 say()。代理本身不动 — 只换 I/O。

while True: user = listen() # mic → text if not user: continue reply = agent.chat(user).response # same Hermes loop print("agent:", reply) asyncio.run(say(reply)) # text → speaker

💡 Mixed-language replies — split by script

If your agent answers with "Hello! สวัสดีค่ะ" and you pass it to a single English voice, the Thai sounds robotic. Walk the string by Unicode script (Thai block ฀-๿, CJK 一-鿿, etc.), synthesize each run with the matching voice, then concatenate with ffmpeg -f concat. The local_ai_agent/tts/server.py has the working function.

ถ้า agent ตอบ "Hello! สวัสดีค่ะ" แล้วส่งเข้าเสียงอังกฤษเดียว ภาษาไทยจะออกหุ่นยนต์ เดิน string แบ่งตาม Unicode script (บล็อกไทย ฀-๿, CJK 一-鿿, ฯลฯ) สังเคราะห์แต่ละช่วงด้วยเสียงที่ตรง แล้วต่อด้วย ffmpeg -f concat ฟังก์ชันใช้งานได้อยู่ใน local_ai_agent/tts/server.py

如果代理回 "Hello! สวัสดีค่ะ" 然后塞给单一英文嗓音,泰文听起来很机械。按 Unicode 脚本切(泰文块 ฀-๿、CJK 一-鿿 等等),每段用对的嗓音合成,再用 ffmpeg -f concat 拼。local_ai_agent/tts/server.py 里有现成函数。

⚠️ Mic permission on macOS

First time sounddevice opens the mic, macOS asks for permission. If Terminal isn't allowed, you get silence with no error. Check System Settings → Privacy & Security → Microphone and toggle Terminal (or iTerm) on. Same for the browser if you use the web UI.

ครั้งแรกที่ sounddevice เปิดไมค์ macOS จะขอ permission ถ้า Terminal ไม่ได้อนุญาต จะเงียบโดยไม่มี error ดู System Settings → Privacy & Security → Microphone แล้วเปิดให้ Terminal (หรือ iTerm) เช่นเดียวกันกับ browser ถ้าใช้ web UI

第一次 sounddevice 打开麦克风时,macOS 会弹权限。Terminal 没授权的话就是静音、无报错。到 系统设置 → 隐私与安全性 → 麦克风 把 Terminal(或 iTerm)打开。用网页 UI 的话浏览器同理。

🤝 Tech 8 — Two agents talking to each other

🤝 Hermes × Hermes — an agent duet multi-agent

So far one agent answered you. Now we let two agents answer each other. Same Ollama, same hermes3:8b — two different system prompts, a tiny loop that bounces the reply back and forth. That's the seed of every "teacher + student", "writer + critic", or "planner + executor" pattern you'll read about.

ที่ผ่านมา agent หนึ่งตัวตอบ คุณ ตอนนี้เราให้ agent สองตัวตอบ กันเอง ใช้ Ollama เดิม โมเดล hermes3:8b เดิม — แค่ system prompt คนละแบบ และลูปสั้น ๆ ที่โยนคำตอบกลับไปกลับมา นี่คือเมล็ดของแพทเทิร์น "ครู + นักเรียน" "นักเขียน + นักวิจารณ์" หรือ "ผู้วางแผน + ผู้ลงมือ" ที่เห็นทั่วไป

到目前为止只有一个代理在回答 。现在我们让两个代理互相回答。还是同一个 Ollama、同一个 hermes3:8b — 只是两套不同的系统提示,外加一个小循环把回复来回扔。这就是 "老师 + 学生"、"作家 + 评论家"、"规划者 + 执行者" 这类模式的最小种子。

Agent A · "May" curious tourist asks short questions system_prompt #1 Agent B · "Khun Nan" Chiang Mai food guide answers with one Thai phrase system_prompt #2 A's reply → B's prompt B's reply → A's prompt same Ollama, two personas
Two DialogueAgent instances. Each one's reply becomes the other's next input. The loop cap is the only thing that stops them.

💡 Why two agents beat one prompt

You could fake this with one agent told to "play both sides" — but it collapses into a single voice that always agrees with itself. Two separate DialogueAgents keep separate chat memories, so each one tracks only its own side of the dialogue. That's what makes the disagreement, the follow-up questions, and the role-play feel real.

คุณจะแกล้งทำเป็น "เล่นทั้งสองฝั่ง" ในพรอมต์เดียวก็ได้ — แต่มันยุบรวมเป็นเสียงเดียวที่เห็นด้วยกับตัวเองเสมอ DialogueAgent สองตัวแยกกัน มี memory คนละก้อน แต่ละตัวจำเฉพาะฝั่งของตัวเอง ความขัดแย้ง คำถามตามต่อ และการสวมบทบาทจึงรู้สึกจริง

你也可以让一个代理 "演两个角色" — 但它会塌成一个永远自我同意的声音。两个独立的 DialogueAgent 各自保留聊天记忆,只跟踪自己那一侧的对话。这正是分歧、追问、角色扮演显得真实的原因。

1 Save this as agent_duet.py

In the same mac_stack/ folder. No new pip installs — it reuses everything from agent.py.

ในโฟลเดอร์ mac_stack/ เดียวกัน ไม่ต้อง pip install เพิ่ม ใช้ของจาก agent.py เดิมทั้งหมด

放到同一个 mac_stack/ 文件夹。不用新装 pip — 全部复用 agent.py 已有的东西。

# agent_duet.py — two Hermes agents bounce a conversation back and forth. import asyncio, sys from llama_index.core.llms import ChatMessage from llama_index.llms.ollama import Ollama sys.stdout.reconfigure(encoding="utf-8", errors="backslashreplace") # Thai/CJK-safe LLM = Ollama(model="hermes3:8b", request_timeout=300) class DialogueAgent: # tool-less, parser-less def __init__(self, name: str, persona: str): self.system = ChatMessage(role="system", content=f"You are {name}. {persona} Reply in under 60 words.") self.history: list[ChatMessage] = [] async def run(self, message: str) -> str: self.history.append(ChatMessage(role="user", content=message)) resp = await LLM.achat([self.system, *self.history]) reply = resp.message.content or "" self.history.append(ChatMessage(role="assistant", content=reply)) return reply def make_agent(name: str, persona: str) -> DialogueAgent: return DialogueAgent(name, persona) may = make_agent( "May", "A first-time tourist in Chiang Mai. You ask short, curious questions " "about food, temples, and getting around. You don't speak Thai yet.", ) khun_nan = make_agent( "Khun Nan", "A Chiang Mai-born street-food guide. You answer in friendly English " "and end every reply with ONE Thai phrase (Roman letters + meaning).", ) async def converse(): # May speaks first (seed line is hers); Khun Nan answers next. speakers = [("Khun Nan", khun_nan), ("May", may)] message = "I just landed at CNX. What should I eat tonight?" print(f"\nMay: {message}") for turn in range(6): # hard cap — see the warning below name, agent = speakers[turn % 2] reply = await agent.run(message) # plain async chat, no ReAct loop print(f"\n{name}: {reply}") message = reply # next turn's prompt asyncio.run(converse())

2 Run it

$ source .venv/bin/activate $ python agent_duet.py

You'll see six alternating turns — May asks, Khun Nan answers with a khao soi recommendation and a Thai phrase, May follows up about songthaews, and so on. Each turn takes a few seconds on a Mac because Ollama runs both agents through the same local model.

จะเห็นบทสนทนาหกรอบสลับกัน — May ถาม Khun Nan ตอบเรื่องข้าวซอยพร้อมคำไทยหนึ่งคำ แล้ว May ถามต่อเรื่องสองแถว เป็นต้น แต่ละรอบใช้เวลาสองสามวินาทีบน Mac เพราะ Ollama รัน agent ทั้งคู่ผ่านโมเดลตัวเดียวกัน

你会看到六轮一来一回 — May 问、Khun Nan 推荐 khao soi 并配一句泰文,May 接着问 songthaew,依此类推。Mac 上每轮要几秒,因为 Ollama 是用同一个本地模型轮流跑两个代理。

💡 Swap the personas, change the pattern

The two-agent skeleton is the same — only the system prompts move. Teacher + student: one explains, the other asks the next question. Writer + critic: one drafts a paragraph, the other returns one improvement. Planner + executor: one writes a step, the other (with tools attached) carries it out and reports back. Same 30 lines, completely different behavior.

โครง agent สองตัวเหมือนเดิม เปลี่ยนแค่ system prompt ครู + นักเรียน: ฝ่ายหนึ่งอธิบาย อีกฝ่ายถามต่อ นักเขียน + นักวิจารณ์: ฝ่ายหนึ่งร่างย่อหน้า อีกฝ่ายเสนอจุดปรับหนึ่งจุด ผู้วางแผน + ผู้ลงมือ: ฝ่ายหนึ่งเขียนขั้นตอน อีกฝ่าย (ที่มี tools) ลงมือทำแล้วรายงานกลับ โค้ด 30 บรรทัดเดิม พฤติกรรมต่างกันสุดขั้ว

两代理骨架不变 — 只动 system prompt。老师 + 学生: 一个讲解,一个提下一个问题。作家 + 评论家: 一个写段落,一个返回一个改进建议。规划者 + 执行者: 一个写步骤,另一个(挂上工具)去执行并回报。同样的 30 行,行为天差地别。

⚠️ Always cap the loop

Without the range(6) cap, two polite agents can chat forever — "Thanks!" / "You're welcome, anything else?" / "Thanks again!" — burning your CPU and producing no new information. Cap turns, or end the loop when a reply looks like a goodbye (if "bye" in reply.lower(): break). A third "judge" agent that votes done / keep going is the more robust pattern once you outgrow a fixed cap.

ถ้าไม่กำหนด range(6) agent สุภาพสองตัวจะคุยกันไม่มีจบ — "ขอบคุณ!" / "ยินดี มีอะไรเพิ่มไหม?" / "ขอบคุณอีกครั้ง!" — กิน CPU โดยไม่ได้ข้อมูลใหม่ จำกัดรอบ หรือจบลูปเมื่อคำตอบดูเหมือนลา (if "bye" in reply.lower(): break) เมื่อ cap คงที่ไม่พอ ลองเพิ่ม agent ตัวที่สามเป็น "กรรมการ" โหวต จบ / ไปต่อ จะ robust กว่า

没有 range(6) 这个上限,两个礼貌的代理能聊到天荒地老 —— "谢谢!"/"不客气,还有别的吗?"/"再谢一次!" —— 烧 CPU 还没新信息。设个轮数上限,或在回复像告别时就退出(if "bye" in reply.lower(): break)。当固定上限不够用时,加一个 "裁判" 代理投票 结束/继续,是更稳的写法。

📱 Bonus — chat from your phone

📱 Tech 5, but on your phone's browser no app, no fork

You already have the Tech 5 chat UI. Bind it to your LAN instead of localhost and any phone on the same Wi-Fi can open it in Safari/Chrome — full Hermes with every tool you registered. No new app, no fork of Google's AI Edge Gallery, no on-device model to maintain. The phone is just a chat window; the Mac does the thinking.

Tech 5 chat UI พร้อมอยู่แล้ว แค่ผูกกับ LAN แทน localhost มือถือเครื่องไหนใน Wi-Fi เดียวกันก็เปิดด้วย Safari/Chrome ได้ — ได้ Hermes เต็ม ๆ พร้อมทุก tool ที่ลงทะเบียนไว้ ไม่ต้องลงแอปใหม่ ไม่ต้อง fork AI Edge Gallery ของ Google ไม่ต้องดูแลโมเดลในเครื่อง มือถือเป็นแค่หน้าต่างแชท Mac เป็นคนคิด

Tech 5 的聊天 UI 你已经有了。把它绑到 LAN 而不是 localhost,同一个 Wi-Fi 上的任何手机用 Safari/Chrome 都能打开 — 完整的 Hermes,连同你注册过的每个工具。不用装新 App,不用 fork Google 的 AI Edge Gallery,也不用维护设备上的模型。手机只是聊天窗口,Mac 做思考。

One line, no file edit

$ source .venv/bin/activate $ python -c "from chat_ui import app; app.run(host='0.0.0.0', port=5001, threaded=True)"

Then find your Mac's LAN IP and open it on the phone:

หา IP ของ Mac ใน LAN แล้วเปิดบนมือถือ:

查到 Mac 在 LAN 上的 IP,然后在手机上打开:

$ ipconfig getifaddr en0 # → 192.168.1.42 # on the phone, open Safari/Chrome: # http://192.168.1.42:5001

💡 Why not run AI Edge Gallery on the phone?

Google's AI Edge Gallery runs Gemma on the phone — impressive offline, but it has no public API, so driving it from the Mac means forking and rebuilding the app. The easy path flips the direction: the Mac (which is already running Hermes + tools) does the inference, and the phone is a thin client. You keep web search, image gen, and LINE push working from the couch.

AI Edge Gallery ของ Google รัน Gemma บนมือถือ — ใช้ออฟไลน์ได้น่าทึ่ง แต่ไม่มี API สาธารณะ จะคุมจาก Mac ก็ต้อง fork และ build แอปใหม่ easy path สลับด้าน: Mac (ที่รัน Hermes + tools อยู่แล้ว) เป็นคนคิด มือถือเป็นแค่ client บาง ๆ ได้ใช้ web search สร้างรูป และ LINE push จากโซฟา

Google 的 AI Edge Gallery 在手机上跑 Gemma — 离线挺惊艳,但没有公开 API,想从 Mac 控制它就得 fork 整个 App 重新打包。简单路径反过来:Mac(本来就在跑 Hermes + 工具)做推理,手机做瘦客户端。沙发上也能用网页搜索、生图、LINE 推送。

⚠️ Same Wi-Fi only — there is no auth

The chat UI has no login. On 0.0.0.0:5001, anyone on your LAN can talk to your agent and trigger any tool (including LINE push). Fine at home; don't do this on café or hotel Wi-Fi. To reach it from outside the house, put it behind Tailscale instead of opening the port to the internet.

chat UI ไม่มี login ที่ 0.0.0.0:5001 ใครก็ตามใน LAN คุณคุยกับ agent ได้ และเรียกใช้ tool ได้ทุกตัว (รวมถึง LINE push) อยู่บ้านใช้ได้ อย่าทำที่ Wi-Fi ร้านกาแฟหรือโรงแรม ถ้าจะเข้าจากนอกบ้าน เอาไปอยู่หลัง Tailscale อย่าเปิด port ตรง ๆ ออกอินเทอร์เน็ต

聊天 UI 没登录。0.0.0.0:5001 上,LAN 里任何人都能跟你的代理对话,也能触发任何工具(包括 LINE 推送)。在家没问题,别在咖啡店、酒店 Wi-Fi 上这么干。想从外网访问,把它放到 Tailscale 后面,不要直接对公网开端口。

🩹 When things go wrong

Hermes never calls web_search

The docstring is too vague. Add concrete trigger phrases: "Use this whenever the user asks about current events, prices, weather, sports scores, or anything you wouldn't already know." That's literally a list of cues for Hermes to match against the user's question.

docstring คลุมเครือเกิน เพิ่มประโยคตั้งคำถามชัด ๆ: "Use this whenever the user asks about current events, prices, weather, sports scores, or anything you wouldn't already know." นี่คือลิสต์ของ cue ให้ Hermes จับคู่กับคำถามผู้ใช้

docstring 太模糊。加上具体的触发短语:"Use this whenever the user asks about current events, prices, weather, sports scores, or anything you wouldn't already know." 这就是 Hermes 用来匹配用户问题的关键词列表。

diffusers install fails on torch

Make sure you're inside the .venv when running pip, and that your Python is 3.10+. On older Macs (pre-M1) torch falls back to CPU and SDXL Turbo will take ~60 seconds per image — not broken, just slow. Try stabilityai/sd-turbo instead (smaller, faster, 512×512 only).

ตรวจให้แน่ใจว่าคุณอยู่ใน .venv ตอนรัน pip และ Python ต้อง 3.10+ บน Mac เก่า (ก่อน M1) torch จะ fallback ไป CPU และ SDXL Turbo จะใช้ ~60 วินาทีต่อรูป — ไม่เสีย แค่ช้า ลองใช้ stabilityai/sd-turbo แทน (เล็กกว่า เร็วกว่า เฉพาะ 512×512)

确认 pip 是在 .venv 里跑,Python 是 3.10+。M1 之前的老 Mac 上 torch 会回落到 CPU,SDXL Turbo 每张图要 ~60 秒 — 不是坏了,只是慢。可以换成 stabilityai/sd-turbo(更小更快,只支持 512×512)。

LINE webhook returns 401

Either your LINE_CHANNEL_SECRET is wrong, or the body got mangled before signature check (e.g., Flask auto-parsed JSON). Use request.get_data() for the raw bytes — that's what the signature was computed over.

อาจเป็นเพราะ LINE_CHANNEL_SECRET ผิด หรือ body ถูกแก้ก่อนเช็ค signature (เช่น Flask parse JSON อัตโนมัติ) ใช้ request.get_data() เพื่อรับ raw bytes — เพราะ signature คำนวณจากข้อมูลนี้

LINE_CHANNEL_SECRET 不对,或者签名校验前 body 被改过(比如 Flask 自动解析了 JSON)。用 request.get_data() 拿原始字节 — 签名就是基于这个算的。

launchd says "loaded" but the job never runs

Almost always a path problem. launchd doesn't expand ~, doesn't read ~/.zshrc, and runs as your user but with a minimal PATH. Spell out the python interpreter and script path in full, and check /tmp/kruai.err.log — the missing module or file error is in there.

เกือบทุกครั้งคือปัญหา path launchd ไม่ขยาย ~, ไม่อ่าน ~/.zshrc, และรันด้วยผู้ใช้คุณแต่ PATH น้อยมาก เขียน path ของ python และ script ให้เต็ม และดู /tmp/kruai.err.log — error เรื่อง module หรือไฟล์หายอยู่ตรงนั้น

几乎都是路径问题。launchd 不展开 ~、不读 ~/.zshrc,以你的用户身份运行但 PATH 很短。把 python 解释器和脚本路径都写全,再看 /tmp/kruai.err.log — 缺模块、找不到文件之类的报错都在那里。

chat_ui.py answers one message then hangs

Flask's dev server is single-threaded by default — while the agent is thinking, a second request waits. For one user that's fine. For a classroom, run app.run(host="127.0.0.1", port=5001, threaded=True) or put gunicorn in front: gunicorn -w 1 --threads 4 chat_ui:app. Keep workers at 1 so there's still one agent instance, not four loading the model in parallel.

dev server ของ Flask เป็น single-thread โดยปริยาย — ระหว่างที่ agent กำลังคิด คำขอที่สองจะรอ ถ้าใช้คนเดียวก็พอ ถ้าทั้งห้องเรียนใช้ ให้รัน app.run(host="127.0.0.1", port=5001, threaded=True) หรือเอา gunicorn มาคุม: gunicorn -w 1 --threads 4 chat_ui:app เก็บ worker ไว้ที่ 1 จะได้มี agent instance เดียว ไม่ใช่ 4 ตัวพร้อมกันแย่งโหลดโมเดล

Flask 开发服务器默认是单线程 — 代理在思考的时候,第二个请求只能等。一个人用没问题。一个班级用,加上 app.run(host="127.0.0.1", port=5001, threaded=True) 或者前面套个 gunicorn:gunicorn -w 1 --threads 4 chat_ui:app。worker 保持 1,这样只有一个 agent 实例,而不是 4 个并行加载模型。

The agent generates an image but LINE only shows text

LINE image messages need an HTTPS URL — they can't carry a local file. To send images via LINE, expose the ./images/ folder via ngrok or upload to S3 first, then send {"type":"image","originalContentUrl":..., "previewImageUrl":...} instead of plain text.

ข้อความรูปของ LINE ต้องการ URL HTTPS — ส่งไฟล์ local ไปตรง ๆ ไม่ได้ ถ้าจะส่งรูปผ่าน LINE ต้อง expose โฟลเดอร์ ./images/ ผ่าน ngrok หรืออัพไป S3 ก่อน แล้วส่ง {"type":"image","originalContentUrl":..., "previewImageUrl":...} แทน text

LINE 的图片消息要 HTTPS 链接 — 不能带本地文件。要走 LINE 发图,先用 ngrok 暴露 ./images/ 或上传到 S3,然后发 {"type":"image","originalContentUrl":..., "previewImageUrl":...} 而不是纯文本。