"""web_factory.py — window_factory.py, with a web page instead of tkinter.

Same idea as challenge 8 on the classes page: a function builds whole
classes at run time, and the page classes inherit from nothing. The form
page asks your name. The room is the chat.

Two models share the work, on two model servers on the same Pi:

    Qwen 2.5 3B   on the Pi's processor, in real Ollama (port 11435)
    Biscuit       on the AI HAT, in Hailo's model server (port 8000)

✨ Auto, the button pressed when the page opens, is the ReAct agent from
the "Local AI stack" page, LlamaIndex's ReActAgent: the model reads the
conversation, writes a Thought, picks a tool (a coin's price, a map, a web
search, a web page, or what biscuit.md says about Biscuit), reads what the
tool found, and writes the answer. That takes a model that can follow
ReAct's format. The HAT's own model chose the right tool 33 times in 112;
Qwen 2.5 3B on the processor, 94. The other buttons say which tool you
want, or talk to Biscuit on the HAT:

    💬 Chat    Biscuit, on the HAT, talks with you and remembers the chat
    🌐 Page    a model trained on code writes a web page, then changes it
    💰 Price   🗺️ Map   🔎 Search   one lookup each, then Biscuit answers

Run it on the Pi, inside the venv that holds LlamaIndex and folium:

    ~/web-venv/bin/python web_factory.py

Then open http://pihat.local:8080 from any machine on the same Wi-Fi.
"""

import asyncio
import html
import json
import os
import re
import secrets
import shutil
import ssl
import subprocess
import threading
import time
import urllib.error
import urllib.parse
import urllib.request

from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path

import folium
# DuckDuckGo search, no key needed: pip install ddgs
from ddgs import DDGS

# LlamaIndex reads biscuit.md, and finds the pieces of it that fit a question:
# pip install llama-index-core llama-index-embeddings-huggingface
from llama_index.core import Settings, SimpleDirectoryReader, VectorStoreIndex
from llama_index.core.node_parser import MarkdownNodeParser
from llama_index.embeddings.huggingface import HuggingFaceEmbedding

# The ReAct agent from the "Local AI stack" page, and its door to Ollama:
# pip install llama-index-core llama-index-llms-ollama
from llama_index.core.agent.workflow import AgentOutput, ReActAgent, ToolCall, ToolCallResult
from llama_index.core.llms import ChatMessage
from llama_index.core.tools import FunctionTool
from llama_index.llms.ollama import Ollama

# hailo-ollama, on the same Pi. Port 8000, not Ollama's 11434.
LLM_URL = os.environ.get("LLM_URL", "http://localhost:8000")

# The only model on the HAT that answered well enough. Changing models
# reloads the chip, and llama3.2:3b took four minutes to load.
LLM_MODEL = os.environ.get("LLM_MODEL", "qwen2.5-instruct:1.5b")
# Biscuit: the same model, with an identity. make_biscuit.py makes these, the
# way `ollama create` makes a model from a Modelfile. Thai has one of its
# own: it comes from an older model that writes better Thai, and changing to
# it and back costs ~15 seconds each way.
BISCUIT = {"en": "biscuit:1.5b", "zh": "biscuit:1.5b", "th": "biscuit-th:1.5b"}

# ✨ Auto's model runs on the Pi's processor, in real Ollama, on a port of
# its own: Hailo's server has no tool calling, and its model could not
# follow ReAct's format (right 33 times in 112). Qwen 2.5 3B chose right 94
# times in 112, and used the conversation in 9 of 14 follow-ups. Hermes 3
# 8B, the model on the "Local AI stack" page, got 87 and 9, at 21 seconds a
# step on this Pi instead of 6, and 6 GB of memory instead of 3.
AGENT_URL = os.environ.get("AGENT_URL", "http://localhost:11435")
AGENT_MODEL = os.environ.get("AGENT_MODEL", "qwen2.5:3b")
# How many times the agent may go round: think, call a tool, read.
AGENT_STEPS = 6
# Who the agent is. ReAct adds its own instructions after this.
AGENT_PROMPT = ("You are Biscuit, a cheerful cartoon puppy who chats with children. You are"
                " always positive, encouraging and kind, and you keep every answer under"
                " three sentences. For greetings, thanks, jokes, small talk and help with"
                " English, answer by yourself, with no tool. Use a tool only for a coin's"
                " price, a map, a web page, something to look up on the web, or a question"
                " about yourself. You know no prices at all: for every price, even the"
                " second in a row, call coin_price.")
# The Pi has no fan. Two minutes of the agent's model took its processor
# from 50 to 78 °C. At this many degrees and over, ✨ Auto rests: no model
# runs, and the puppy says so. 80 is also where the Pi starts slowing
# itself down, and where the heat guard shuts it down if it stays there.
HOT = 80
CPU_SENSOR = "/sys/class/thermal/thermal_zone0/temp"
# What the puppy says then. Written by Python: no model runs, and the Pi
# cools. The buttons still work, because they need no agent.
TOO_HOT = {"en": "Ruh-roh! My processor is hot: {:.0f} degrees. Give me a minute to cool down."
                 " If you can't wait, press the button you want: 🗺️ Map, 🔎 Search, 💰 Price"
                 " or 💬 Chat.",
           "th": "อุ๊ย! ตัวประมวลผลของฉันร้อน: {:.0f} องศา ขอเวลาสักนาทีให้เย็นลงนะ"
                 " ถ้ารอไม่ไหว กดปุ่มที่ต้องการได้เลย: 🗺️ Map, 🔎 Search, 💰 Price หรือ 💬 Chat",
           "zh": "哎呀！我的处理器好热：{:.0f} 度。让我凉一分钟。等不及的话，按你要的按钮就行："
                 "🗺️ Map、🔎 Search、💰 Price 或 💬 Chat。"}

# Web pages come from a model the same size, trained on code. Asked for a
# page, the chat model above left the button inside an unclosed comment.
# Once, on the Pi: curl localhost:8000/api/pull -d '{"model": "qwen2.5-coder:1.5b"}'
PAGE_MODEL = os.environ.get("PAGE_MODEL", "qwen2.5-coder:1.5b")

# 8000 is taken by hailo-ollama, so the web page goes next door.
PORT = int(os.environ.get("PORT", "8080"))

# The same page again, over https, with a certificate the Pi made for
# itself. Browsers only share where you are (or a microphone) with a
# secure page. The first visit shows a warning: nobody vouched for this
# certificate but the Pi. Make the two files once, next to this one:
#
#     openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem \
#         -out cert.pem -days 3650 -subj "/CN=pihat.local" \
#         -addext "subjectAltName=DNS:pihat.local,DNS:localhost"
SECURE_PORT = int(os.environ.get("SECURE_PORT", "8443"))
CERT = Path(__file__).resolve().parent / "cert.pem"
KEY = Path(__file__).resolve().parent / "key.pem"

# Who can reach this chatbot through Tailscale (challenge 3):
#   "off"     - nobody through Tailscale, only this network
#   "private" - your own devices, logged in to your Tailscale account
#   "public"  - anyone who has the link
# Change it, then: sudo systemctl restart web-factory
SHARE = os.environ.get("SHARE", "off")
# Real Ollama on the processor can be shared the same way, on port 8443 of
# the same address: https://pihat.<your tailnet>.ts.net:8443 answers like
# Ollama on a PC does. Same three values.
SHARE_OLLAMA = os.environ.get("SHARE_OLLAMA", "off")

# The buttons above the typing box, and what each one does. ✨ Auto is
# the one pressed when the page opens.
BUTTONS = {
    "auto": "✨ Auto",
    "chat": "💬 Chat",
    "coin_price": "💰 Price",
    "make_map": "🗺️ Map",
    "make_page": "🌐 Page",
    "web_search": "🔎 Search",
}
# Two of them are conversations: the model reads all that was said before.
# The other three look one thing up, and read only what was typed.
CONVERSATIONS = ("chat", "make_page")

# Who the chatbot is. Its name and its manner are inside the biscuit models,
# where make_biscuit.py put them: the model reads those before every
# conversation, and nobody has to send them. Only the writer uses those
# models: the place-name helper must answer with exact words.
PUPPY_NAME = "Biscuit"
# Everything else Biscuit knows about itself is in a file of its own, in
# English, Thai and Chinese: its friends, its favourite show, its singer.
# LlamaIndex cuts the file into pieces at its headings, and turns each piece
# into an embedding: a list of numbers that stands for its meaning. A
# question becomes an embedding too, and the pieces nearest to it are handed
# to Biscuit with the question. A piece in another language is left out:
# handed Chinese names, the model once answered a Thai question in Chinese.
ABOUT_FILE = Path(__file__).resolve().parent / "biscuit.md"
EMBED_MODEL = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
ABOUT_PIECES = 2         # the most pieces handed over with one question
# How near a piece must be: 1 is the same meaning, 0 nothing in common.
# The line was chosen on 50 questions, then tried on 44 it had never seen.
# There, questions about Biscuit found their piece 20 times in 22. Questions
# about the person, and plain chat, found one wrongly 2 times in 22: "What
# is my dog's name?" is near "You are a puppy".
ABOUT_LINE = 0.40
ABOUT_LABEL = {"en": "About you:", "th": "เกี่ยวกับคุณ:", "zh": "关于你："}
# What the page says while the model is writing. One is picked each time.
WAITING = ["is sniffing around", "is wagging its tail", "is following the scent",
           "is digging for the answer", "is fetching that for you", "is tilting its head",
           "is pricking up its ears", "is chasing a good idea", "is scratching its ear",
           "is running in circles", "is chewing on that", "is looking under the sofa",
           "is humming a Jay Chou song", "is thinking very hard"]
# "Send me a link to a video": the search found real links, and the model
# invented "our YouTube channel" instead. So Python answers these, and the
# chat lists the links the search really found.
LINK_WORDS = r"\b(link|links|video|videos|youtube|watch|website for)\b"
LINK_WORDS_TH_ZH = ["ลิงก์", "ลิงค์", "วิดีโอ", "คลิป", "ยูทูบ", "链接", "视频", "网址"]
HERE_ARE_LINKS = {"en": "Woof! Here are the links I found. Tap one to open it.",
                  "th": "โฮ่ง! นี่คือลิงก์ที่หาเจอ แตะเพื่อเปิดได้เลย",
                  "zh": "汪！这是我找到的链接，点一下就能打开。"}
# "As an AI model, I don't have the ability..." - not a puppy. Sentences
# like that are taken out of what the model writes.
NOT_A_PUPPY = r"\b(as an ai|an ai (language )?model|a language model|i'm an ai|i am an ai)\b"

# A search answer that refuses, "I'm sorry, but I can't provide scores",
# with the results in front of it. Checked on 9 real replies: it catches the
# 5 refusals and none of the 4 good answers, even one saying "real-time".
REFUSED = (r"\b(I|I'm|I am)\b[^.]{0,20}\b(can(no|')t|unable to|don't have|do not have|not able to)\b"
           r"[^.]{0,60}\b(provide|access|browse|assist|help|give|fulfill|comply|answer|information|ability|real-time|up-to-date|current|that request)")
# Not a refusal, but no answer either: "Can you please provide me with the
# information you are asking about?", with the right pages in front of it.
NON_ANSWER = (r"(please )?provide (me )?(with )?(the |more )?(information|details)|could you (please )?(clarify|be more specific)"
              r"|(is|are) not (sufficient|enough)|not enough information|more helpful if you (could )?provide")
SEARCH_FOUND = {"en": "Woof! Here's what the search found:", "th": "โฮ่ง! นี่คือสิ่งที่ค้นเจอ:",
                "zh": "汪！这是搜索找到的："}

# The model remembers nothing between two questions. A conversation is a
# list of messages, and the whole list is sent again each time: that list
# is all the memory there is. Ollama's own documentation keeps a chat this
# way. 💬 Chat has one list, and 🌐 Page has another.
# Tested on the HAT, 6 conversations, 4 tries each: the chat pasted into
# one message got 11 of 24 right, real chat messages 15, and real messages
# that keep what was looked up 19.
# The HAT found a fact at the start of a 5,500-letter prompt, lost it at
# 11,000, and failed with an error from 18,000. So the oldest questions
# are forgotten first, until the memory fits.
MEMORY_CHARS = 4000
MOST_CHATS = 50          # browsers remembered at once
FACTS_ASK = "Facts: {}\n\nUsing only those facts, and copying any numbers exactly, answer: {}"
# A reminder right after the question, in its own language. Asked
# "ชอบการ์ตูนเรื่องอะไร" 15 times, the model answered in Chinese 5 times;
# with this after it, 0 times in 35.
LANGUAGE_REMINDER = {"th": "(ตอบเป็นภาษาไทยเท่านั้น)", "zh": "（只用中文回答。）"}

# Every answer gets one mood, chosen by Python from what happened, not by
# the model. All of them cheerful, even the bad news: that one is "oops".
MOODS = {"happy": "😊", "excited": "🤩", "curious": "🧐", "proud": "🥳",
         "cheering": "💪", "loving": "🥰", "oops": "🙈"}

# What the puppy says first, when Python writes the answer.
PUPPY_OPENERS = {
    "excited": {"en": "Woof woof!", "th": "โฮ่ง ๆ!", "zh": "汪汪！"},
    "proud": {"en": "Ta-da! We made it together!", "th": "ทาดา! เราทำสำเร็จด้วยกัน!",
              "zh": "当当！我们一起做到了！"},
    "cheering": {"en": "Hang in there, paws crossed!", "th": "สู้ ๆ นะ ขอให้โชคดี!",
                 "zh": "加油，我帮你祈祷！"},
}

# Bad news: a flustered puppy, the news, a tip, and a promise to help.
OOPS_OPENERS = {"en": "Ruh-roh! My ears just flopped over.",
                "th": "อุ๊ย! หูตกเลย",
                "zh": "哎呀！我的耳朵都耷拉下来了。"}
OOPS_PROMISES = {"en": "Don't worry, we'll fix it together!",
                 "th": "ไม่ต้องห่วงนะ เราจะแก้ไปด้วยกัน!",
                 "zh": "别担心，我们一起把它修好！"}
# The start of each tool's bad news, and what to say about it.
BAD_NEWS = {
    "OpenStreetMap found no": {
        "en": "I sniffed the whole map and found no such place. Try a shorter name, like Chiang Mai.",
        "th": "ดมทั้งแผนที่แล้วก็ไม่เจอ ลองชื่อที่สั้นลง เช่น เชียงใหม่",
        "zh": "我把整张地图都闻遍了，也没找到。试一个短一点的名字，比如 清迈。"},
    "There is no place": {
        "en": "I could not find a place name in that. Type one, like Chiang Mai.",
        "th": "ไม่เห็นชื่อสถานที่เลย พิมพ์ชื่อมาหน่อย เช่น เชียงใหม่",
        "zh": "我没看到地名。打一个吧，比如 清迈。"},
    # Checked in this order: "no price for LTC" is a coin Bitkub does not sell.
    "Bitkub has no price for": {
        "en": "Bitkub doesn't sell that coin, so it has no baht price for it. Try another, like BTC.",
        "th": "Bitkub ไม่ได้ขายเหรียญนี้ เลยไม่มีราคาเป็นบาท ลองเหรียญอื่น เช่น BTC",
        "zh": "Bitkub 不卖这个币，所以没有泰铢价格。换一个试试，比如 BTC。"},
    "Bitkub has no": {
        "en": "Bitkub has no coin by that name. Try its short name, like BTC.",
        "th": "Bitkub ไม่มีเหรียญชื่อนี้ ลองชื่อย่อ เช่น BTC",
        "zh": "Bitkub 没有这个币。试试它的简称，比如 BTC。"},
    "The web search": {
        "en": "The web search did not work this time. Wait a minute and ask again.",
        "th": "ค้นเว็บไม่ได้รอบนี้ รอสักนาทีแล้วถามใหม่นะ",
        "zh": "这次网页搜索没成功。等一分钟再问一次吧。"},
    "No web page": {
        "en": "The page model is not on the HAT yet. See PAGE_MODEL in web_factory.py.",
        "th": "ยังไม่มีโมเดลหน้าเว็บบน HAT ดู PAGE_MODEL ใน web_factory.py",
        "zh": "HAT 上还没有网页模型。看 web_factory.py 里的 PAGE_MODEL。"},
}

# Emojis the model writes anyway are taken out: the browser would read
# "cat face" aloud, and the face beside the answer shows the mood.
EMOJI = re.compile("[\U0001F000-\U0001FAFF\u2600-\u27BF\uFE0F\u200D]")

BITKUB = "https://api.bitkub.com/api/v3/market/ticker"
NOMINATIM = "https://nominatim.openstreetmap.org/search"
NOMINATIM_REVERSE = "https://nominatim.openstreetmap.org/reverse"

# Ways of saying "where I am". The page catches these first, with 🗺️ Map
# pressed; this list catches them anywhere else.
HERE_PHRASES = {"here", "my location", "my current location", "current location",
                "where am i", "near me", "ที่นี่", "ตำแหน่งของฉัน", "ฉันอยู่ที่ไหน",
                "这里", "我在哪", "我在哪里", "我的位置"}

# The picture of the map. The usual OpenStreetMap tiles label every country
# in its own language: Thai in Thailand. The German OpenStreetMap server
# adds an English name beside it, where one is known. Both are free, with
# no key. No free server labels the map in Chinese.
TILES = {
    "local": ("https://tile.openstreetmap.org/{z}/{x}/{y}.png",
              "&copy; OpenStreetMap contributors"),
    "local_english": ("https://tile.openstreetmap.de/{z}/{x}/{y}.png",
                      "&copy; OpenStreetMap contributors, tiles by openstreetmap.de"),
}

# Maps are saved here, and the web server hands them out from /maps/.
MAP_DIR = Path(__file__).resolve().parent / "maps"
# The same for web pages, from /pages/.
PAGE_DIR = Path(__file__).resolve().parent / "pages"

# OpenStreetMap refuses requests that do not say who is asking.
AGENT = {"User-Agent": "krueng-pi-web-factory/1.0 (https://krueng.ai)"}

# Some tickers are also English words: ALT, BLUR, ANIME. So a word only
# counts as a ticker when it is typed in capitals, or is one of these.
NAMES = {
    "bitcoin": "BTC", "btc": "BTC", "ethereum": "ETH", "ether": "ETH",
    "eth": "ETH", "dogecoin": "DOGE", "doge": "DOGE", "ripple": "XRP",
    "xrp": "XRP", "solana": "SOL", "sol": "SOL", "cardano": "ADA",
    "ada": "ADA", "litecoin": "LTC", "ltc": "LTC", "tether": "USDT",
    "usdt": "USDT", "bnb": "BNB",
    # More coins Bitkub sells in baht (checked 2026-09-29).
    "avalanche": "AVAX", "polygon": "POL", "chainlink": "LINK", "polkadot": "DOT",
    "tron": "TRX", "shiba": "SHIB", "uniswap": "UNI", "cosmos": "ATOM",
    "stellar": "XLM", "aptos": "APT", "arbitrum": "ARB", "sui": "SUI", "pepe": "PEPE",
}
# Coin names in Thai and Chinese. These languages put no spaces between
# words, so they are found by plain searching.
COIN_NAMES = {
    "บิตคอยน์": "BTC", "บิทคอยน์": "BTC", "อีเธอเรียม": "ETH", "โดชคอยน์": "DOGE",
    "ไลท์คอยน์": "LTC", "โซลานา": "SOL",
    "比特币": "BTC", "以太坊": "ETH", "狗狗币": "DOGE", "莱特币": "LTC",
    "瑞波币": "XRP", "泰达币": "USDT",
}

# Words around a place name that are not the place.
NOT_PLACE_TH_ZH = ["แผนที่", "อยู่ที่ไหน", "ขอ", "หน่อย", "ให้ดู", "ของ",
                   "地图", "在哪里", "在哪", "给我看", "请", "的"]

FORM_PAGE = """<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<style>
  body {{ font-family: system-ui, sans-serif; background: #f4f1ea; margin: 0; }}
  form {{ max-width: 380px; margin: 12vh auto; background: #fff; padding: 24px;
          border-radius: 10px; box-shadow: 0 2px 10px #0002; }}
  h1 {{ font-size: 1.3em; margin-top: 0; }}
  input {{ width: 100%; box-sizing: border-box; font-size: 1.1em; padding: 8px; }}
  button {{ margin-top: 14px; font-size: 1em; padding: 8px 20px; float: right; }}
  .warning {{ color: #b00020; min-height: 1.2em; }}
</style></head><body>
<form method="post" action="/start">
  <h1>What is your name?</h1>
  <input name="user_name" autofocus autocomplete="off">
  <p class="warning">{warning}</p>
  <button>Start</button>
</form>
</body></html>"""

CHAT_PAGE = """<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
<style>
  body {{ font-family: system-ui, sans-serif; background: #f4f1ea; margin: 0;
          display: flex; flex-direction: column; height: 100vh; }}
  #transcript {{ flex: 1; overflow-y: auto; padding: 14px; white-space: pre-wrap; }}
  .you {{ color: #1a4d8f; }}
  .step {{ color: #777; font-size: 0.9em; }}
  .map {{ width: 100%; height: 340px; border: 1px solid #ccc; border-radius: 8px;
          margin-bottom: 12px; }}
  form {{ display: flex; gap: 8px; padding: 10px; background: #fff; }}
  input {{ flex: 1; font-size: 1.05em; padding: 8px; min-width: 0; }}
  button {{ font-size: 1em; padding: 8px 16px; }}
  #modes {{ display: flex; flex-wrap: wrap; gap: 6px; padding: 10px 10px 0;
            background: #fff; }}
  #modes button {{ padding: 5px 12px; border-radius: 16px; border: 1px solid #1a4d8f;
                   background: #fff; color: #1a4d8f; font-size: 0.9em; }}
  #modes button.on {{ background: #1a4d8f; color: #fff; }}
  #mapopts {{ display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px;
              padding: 8px 10px 0; background: #fff; font-size: 0.9em; }}
  #mapopts[hidden] {{ display: none; }}
  .made {{ width: 100%; height: 420px; border: 1px solid #ccc; border-radius: 8px;
           background: #fff; }}
  .pagetools {{ display: flex; flex-wrap: wrap; gap: 8px; margin: 4px 0 12px; }}
  .pagetools a, .pagetools button {{ font-size: 0.9em; padding: 5px 12px;
           border-radius: 16px; border: 1px solid #1a4d8f; background: #eef3fa;
           color: #1a4d8f; text-decoration: none; cursor: pointer; }}
  .links {{ margin: 0 0 12px; padding-left: 22px; white-space: normal; }}
  .links a {{ color: #1a4d8f; }}
  .bot {{ display: flex; gap: 10px; align-items: flex-start; margin-bottom: 4px; }}
  .face {{ position: relative; flex: none; font-size: 1.7em; line-height: 1.1; }}
  .face .badge {{ position: absolute; right: -8px; bottom: -4px; font-size: 0.55em; }}
  .face.happy {{ animation: nod 1.2s ease-in-out 2; }}
  .face.excited, .face.proud {{ animation: bounce 0.5s ease-in-out 4; }}
  .face.curious, .face.thinking {{ animation: tilt 1.4s ease-in-out infinite; }}
  .face.cheering, .face.loving {{ animation: nod 0.9s ease-in-out 3; }}
  .face.oops {{ animation: wiggle 0.25s ease-in-out 6; }}
  @keyframes nod {{ 50% {{ transform: translateY(3px); }} }}
  @keyframes bounce {{ 50% {{ transform: translateY(-7px); }} }}
  @keyframes tilt {{ 50% {{ transform: rotate(-14deg); }} }}
  @keyframes wiggle {{ 25% {{ transform: rotate(-10deg); }} 75% {{ transform: rotate(10deg); }} }}
  .dots::after {{ content: ""; animation: dots 1.5s steps(4) infinite; }}
  @keyframes dots {{ 0% {{ content: ""; }} 25% {{ content: "."; }} 50% {{ content: ".."; }}
                     75% {{ content: "..."; }} }}
  @media (prefers-reduced-motion: reduce) {{ .face, .dots::after {{ animation: none; }} }}
  .code {{ background: #1e1e1e; color: #e8e8e8; padding: 10px; border-radius: 8px;
           overflow-x: auto; white-space: pre; font-size: 0.85em; }}
  #mapopts button.on {{ background: #1a4d8f; color: #fff; }}
  #mapopts button {{ padding: 5px 12px; border-radius: 16px; border: 1px solid #1a4d8f;
                     background: #eef3fa; color: #1a4d8f; }}
</style></head><body>
<div id="transcript">{greeting}</div>
<div id="modes">
  <button type="button" class="on" data-pressed="auto">✨ Auto</button>
  <button type="button" data-pressed="chat">💬 Chat</button>
  <button type="button" data-pressed="coin_price">💰 Price</button>
  <button type="button" data-pressed="make_map">🗺️ Map</button>
  <button type="button" data-pressed="make_page">🌐 Page</button>
  <button type="button" data-pressed="web_search">🔎 Search</button>
</div>
<div id="mapopts" hidden>
  <button type="button" id="here">📍 Here</button>
  <label>Labels <select id="labels">
    <option value="local">Locals</option>
    <option value="local_english">Locals + English</option>
  </select></label>
  <label>Names <select id="names">
    <option value="en">EN</option>
    <option value="th">ไทย</option>
    <option value="zh">中文</option>
  </select></label>
  <label><input type="checkbox" id="thailand"> Thailand only</label>
  <label title="Try: Wat, Chiang Mai">Places <select id="howmany">
    <option value="1">1</option>
    <option value="5">5</option>
  </select></label>
  <label><input type="checkbox" id="outline"> Outline</label>
</div>
<form id="row">
  <input id="box" autofocus autocomplete="off">
  <button id="send">Send</button>
  <button type="button" id="voice" title="Read answers aloud">🔊</button>
  <button type="button" id="clean" title="Start over: clear the chat and what Biscuit remembers">🧹</button>
</form>
<script>
// The page never talks to a model. It posts to /ask on the Pi and waits.
// The Pi runs the agent and sends back the lines to show.
const userName = {user_name_json};
// The puppy, and the badge for each mood the Pi can send.
const PUPPY = {puppy_json};
const MOODS = {moods_json};
// What the puppy is doing while the Pi works. One is picked each time.
const WAITING = {waiting_json};
const transcript = document.getElementById("transcript");

// A name for this conversation, made up here. The Pi keeps what was said
// under it, so each browser has its own chat. 🧹 makes up a new name.
const newChat = () => Math.random().toString(36).slice(2) + Date.now().toString(36);
let chat = newChat();
const GREETING = transcript.textContent;
document.getElementById("clean").addEventListener("click", () => {{
  chat = newChat();
  transcript.textContent = GREETING;
  if (window.speechSynthesis) speechSynthesis.cancel();
  box.focus();
}});
const box = document.getElementById("box");
const send = document.getElementById("send");

// ---- the mode buttons ----------------------------------------------------
// ✨ Auto lets the agent on the Pi pick the tool. The other buttons say it
// outright: 💬 Chat and 🌐 Page are conversations, and the Pi keeps what was
// said; the last three look one thing up. The chosen one stays chosen.
let pressed = "auto";
// The https port, or 0 if this Pi has no https running.
const SECURE_PORT = {secure_port};
// The Tailscale address, like https://pihat.something.ts.net, or "".
const SHARED_AT = {shared_at};
// The address on the home network, for when others stop answering.
const HOME = "http://pihat.local:{port}";

// This same page, over https: at the Tailscale address if the Pi has one,
// or at this Pi's own. Port 443 is the usual one, so it is left out.
function secureAddress() {{
  if (SHARED_AT) return SHARED_AT + location.pathname + location.search;
  const port = SECURE_PORT === 443 ? "" : ":" + SECURE_PORT;
  return "https://" + location.hostname + port + location.pathname + location.search;
}}
const HINTS = {{
  "auto": "Ask anything: the agent picks the tool",
  "chat": "Just talk",
  "coin_price": "Which coin? BTC, doge, 比特币...",
  "make_map": "A place, like Wat Phra Singh, or type here for where you are",
  "make_page": "Describe a page, or say what to change. It takes a minute or two",
  "web_search": "Look anything up, like who founded Chiang Mai, or today's air quality",
}};
for (const button of document.querySelectorAll("#modes button")) {{
  button.addEventListener("click", () => {{
    // Maps can show where you are, and browsers only tell a secure page.
    // So on the http page, 🗺️ Map moves to the https one, in Map mode.
    if (button.dataset.pressed === "make_map" && !window.isSecureContext
        && (SHARED_AT || SECURE_PORT)) {{
      location.href = secureAddress() + "#map";
      return;
    }}
    for (const other of document.querySelectorAll("#modes button")) {{
      other.classList.remove("on");
    }}
    button.classList.add("on");
    pressed = button.dataset.pressed;
    box.placeholder = HINTS[pressed];
    // The map's own options only show while 🗺️ Map is chosen.
    document.getElementById("mapopts").hidden = pressed !== "make_map";
    if (pressed !== "make_map") hereOff();
    box.focus();
  }});
}}
box.placeholder = HINTS[pressed];

// Arrived from the http page by pressing 🗺️ Map? Carry on in Map mode.
if (location.hash === "#map") {{
  history.replaceState(null, "", location.pathname + location.search);
  document.querySelector('#modes button[data-pressed="make_map"]').click();
}}

// ---- reading answers aloud ----------------------------------------------
// The browser speaks, not the Pi, so the Pi does no extra work. An answer
// can mix English, Thai and Chinese, so it is cut into pieces by alphabet,
// and each piece gets a voice for its own language. An English voice handed
// Thai letters mumbles or says nothing.
//
// Voices are ranked, because the first one on the list is often the worst:
// Chrome's own Google voices first, then the system's newer natural voices,
// and the old Microsoft David and Zira last. A woman's voice when there is
// one. The list has no gender field, so it is guessed from the name. Test
// "female" first: "male" is inside it.
const FEMALE = new RegExp("female|google us english|zira|aria|jenny|ava|emma|samantha"
                          + "|kanya|premwadee|xiaoxiao|huihui|tingting");
const MALE = /male|david|mark|guy|brian|andrew|ryan|alex|fred|niwat|yunxi/;
const SPEECH = {{
  en: {{tag: "en-US", best: /^en[-_]us/i, any: /^en/i, name: "English"}},
  th: {{tag: "th-TH", best: /^th/i, any: /^th/i, name: "Thai"}},
  zh: {{tag: "zh-CN", best: /^zh[-_]cn/i, any: /^zh/i, name: "Chinese"}},
}};
const chosen = {{}};
let talking = true;

function scoreVoice(v) {{
  const n = v.name.toLowerCase();
  let s = 0;
  if (/^google/.test(n)) s += 20;
  if (/natural|neural/.test(n)) s += 8;
  if (FEMALE.test(n)) s += 5;
  else if (MALE.test(n)) s -= 5;
  if (/david|zira|mark|hazel|susan/.test(n)) s -= 8;
  return s;
}}

function voiceFor(lang) {{
  if (lang in chosen) return chosen[lang];
  const all = speechSynthesis.getVoices().filter(v => SPEECH[lang].any.test(v.lang));
  const best = all.filter(v => SPEECH[lang].best.test(v.lang));
  const pool = best.length ? best : all;
  chosen[lang] = pool.sort((a, b) => scoreVoice(b) - scoreVoice(a))[0] || null;
  return chosen[lang];
}}

// The list is empty until the browser has loaded it, and can change. Choose again.
if (window.speechSynthesis) speechSynthesis.onvoiceschanged = () => {{
  for (const lang in chosen) delete chosen[lang];
}};

// Which alphabet one character is in. Digits, spaces and marks belong to
// none.
function alphabetOf(ch) {{
  if (/[฀-๿]/.test(ch)) return "th";
  if (/[㐀-鿿豈-﫿]/.test(ch)) return "zh";
  if (/[A-Za-z]/.test(ch)) return "en";
  return null;
}}

// Characters with no alphabet wait, and join the next word's piece. So in
// "你好. 1 BTC" the "1" is read with "BTC". Joining the piece before lost
// it: that piece was Chinese, and with no Chinese voice it was skipped.
function pieces(text) {{
  const out = [];
  let waiting = "";
  for (const ch of text) {{
    const lang = alphabetOf(ch);
    if (lang === null) {{
      waiting += ch;
      continue;
    }}
    const last = out[out.length - 1];
    if (last && lang === last.lang) last.text += waiting + ch;
    else out.push({{lang: lang, text: waiting + ch}});
    waiting = "";
  }}
  if (out.length) out[out.length - 1].text += waiting;
  else if (waiting.trim()) out.push({{lang: "en", text: waiting}});
  return out;
}}

// Speaks the pieces one after another. Hands back the languages it had no
// voice for, so the page can say so instead of staying silent.
function say(text) {{
  if (!talking || !window.speechSynthesis) return [];
  speechSynthesis.cancel();
  const missing = [];
  text = text.replace(/\\p{{Extended_Pictographic}}|\\uFE0F|\\u200D/gu, "");
  for (const piece of pieces(text)) {{
    const voice = voiceFor(piece.lang);
    if (!voice && piece.lang !== "en") {{
      if (!missing.includes(piece.lang)) missing.push(piece.lang);
      continue;
    }}
    const words = new SpeechSynthesisUtterance(piece.text);
    words.lang = SPEECH[piece.lang].tag;
    if (voice) words.voice = voice;
    speechSynthesis.speak(words);
  }}
  return missing;
}}

document.getElementById("voice").addEventListener("click", (event) => {{
  talking = !talking;
  event.target.textContent = talking ? "🔊" : "🔇";
  if (!talking && window.speechSynthesis) speechSynthesis.cancel();
}});

// A line from the puppy: its face, with the mood's badge, then the words.
function puppyLine(mood, text) {{
  const line = document.createElement("div");
  line.className = "bot";
  const face = document.createElement("span");
  face.className = "face " + mood;
  face.textContent = "🐶";
  const badge = document.createElement("span");
  badge.className = "badge";
  badge.textContent = MOODS[mood] || "🤔";
  face.appendChild(badge);
  const words = document.createElement("span");
  words.textContent = text;
  line.append(face, words);
  transcript.appendChild(line);
  transcript.scrollTop = transcript.scrollHeight;
  return line;
}}

function write(text, kind) {{
  const line = document.createElement("div");
  line.className = kind || "";
  line.textContent = text;
  transcript.appendChild(line);
  transcript.scrollTop = transcript.scrollHeight;
  return line;
}}

// Send something to the Pi, and hand back its answer.
async function post(path, payload) {{
  let response;
  try {{
    response = await fetch(path, {{
      method: "POST",
      headers: {{"Content-Type": "application/json"}},
      body: JSON.stringify(payload),
    }});
  }} catch (trouble) {{
    // fetch fails like this only when nothing answered at this address:
    // the question never got to the Pi.
    let tip = "Is the Pi on?";
    if (location.hostname.endsWith(".ts.net")) {{
      tip += " Is Tailscale logged in on the Pi, and SHARE not 'off'?";
    }}
    if (location.origin !== HOME) tip += " On the same network, try " + HOME;
    throw new Error("cannot reach the Pi at " + location.host + ". " + tip);
  }}
  try {{
    return await response.json();
  }} catch (trouble) {{
    // It answered, but with something else, like an error page.
    throw new Error("the Pi answered, but not with an answer (" + response.status
                    + "). On the Pi: journalctl -u web-factory -n 20");
  }}
}}

// Show one answer: the grey steps, the reply, and a map if there is one.
function show(answer) {{
  for (const step of answer.steps) write("  " + step, "step");
  puppyLine(answer.mood || "happy", PUPPY + ": " + answer.reply);
  for (const lang of say(answer.reply)) {{
    write("  (this browser has no " + SPEECH[lang].name
          + " voice, so that part was not read)", "step");
  }}

  // A map is its own small web page on the Pi. Show it in a frame.
  if (answer.map) {{
    const frame = document.createElement("iframe");
    frame.src = answer.map;
    frame.className = "map";
    transcript.appendChild(frame);
    transcript.scrollTop = transcript.scrollHeight;
  }}
  if (answer.page) showPage(answer.page);
  if (answer.links && answer.links.length) showLinks(answer.links);
}}

// Where a web search found its answer, so it can be checked.
function showLinks(links) {{
  const list = document.createElement("ol");
  list.className = "links";
  for (const link of links) {{
    const item = document.createElement("li");
    const a = document.createElement("a");
    a.href = link.url;
    a.textContent = link.title;
    a.target = "_blank";
    a.rel = "noopener";
    item.appendChild(a);
    list.appendChild(item);
  }}
  transcript.appendChild(list);
  transcript.scrollTop = transcript.scrollHeight;
}}

// A web page the model wrote: try it here, save it, open it, read its code.
function showPage(address) {{
  // sandbox: its scripts run, but walled off from this chat page.
  const frame = document.createElement("iframe");
  frame.src = address;
  frame.className = "made";
  frame.sandbox = "allow-scripts allow-forms allow-modals";
  const tools = document.createElement("div");
  tools.className = "pagetools";
  const name = address.split("/").pop();
  tools.innerHTML = '<a download="' + name + '" href="' + address + '">⬇ Download</a>'
    + '<a target="_blank" href="' + address + '">🔗 Open</a>'
    + '<button type="button">&lt;/&gt; Code</button>';
  // The code, shown in the chat to read or copy. A second press hides it.
  let code = null;
  tools.querySelector("button").addEventListener("click", async () => {{
    if (code) {{
      code.remove();
      code = null;
      return;
    }}
    code = document.createElement("pre");
    code.className = "code";
    code.textContent = await (await fetch(address)).text();
    tools.after(code);
  }});
  transcript.appendChild(frame);
  transcript.appendChild(tools);
  transcript.scrollTop = transcript.scrollHeight;
}}

// The options under 🗺️ Map, as the Pi expects them. The Pi checks them again.
function mapOptions() {{
  return {{
    labels: document.getElementById("labels").value,
    names: document.getElementById("names").value,
    thailand_only: document.getElementById("thailand").checked,
    how_many: Number(document.getElementById("howmany").value),
    outline: document.getElementById("outline").checked,
  }};
}}

// 📍 Here is a switch. On, it maps where you are at once, and again each
// time Send is pressed with the box empty. Off, the box is for places.
const hereButton = document.getElementById("here");
let hereOn = false;

function hereOff() {{
  hereOn = false;
  hereButton.classList.remove("on");
  if (pressed === "make_map") box.placeholder = HINTS["make_map"];
}}

hereButton.addEventListener("click", () => {{
  if (hereOn) {{
    hereOff();
    return;
  }}
  hereOn = true;
  hereButton.classList.add("on");
  box.placeholder = "Press Send to map where you are again";
  box.value = "";
  document.getElementById("row").requestSubmit();
}});

// ---- a map of where you are ----------------------------------------------
// With 🗺️ Map pressed, "here" means: ask the browser where this device is.
// Browsers only tell a secure page: https, or localhost. On plain http the
// answer is no, before anyone is even asked.
// Ways of saying "where I am". Marks and capitals do not matter.
const HERE_WORDS = ["here", "my location", "my current location",
  "current location", "where am i", "near me", "ที่นี่",
  "ตำแหน่งของฉัน", "ฉันอยู่ที่ไหน", "这里", "我在哪", "我在哪里", "我的位置"];

function saysHere(text) {{
  const plain = text.toLowerCase().replace(/[?？!！.。,，]/g, "").trim();
  return HERE_WORDS.includes(plain);
}}

function whereAmI() {{
  return new Promise((found, failed) => {{
    navigator.geolocation.getCurrentPosition(found, failed,
      {{enableHighAccuracy: true, timeout: 20000}});
  }});
}}

async function mapHere() {{
  if (!window.isSecureContext) {{
    if (!SHARED_AT && !SECURE_PORT) {{
      throw new Error("your browser only shares where you are with a secure page,"
                      + " and this Pi has no https running.");
    }}
    throw new Error("your browser only shares where you are with a secure page."
                    + " Open " + secureAddress() + " instead. The first time, the"
                    + " browser warns you: choose Advanced, then Proceed.");
  }}
  if (!navigator.geolocation) {{
    throw new Error("this browser cannot tell where you are.");
  }}
  let spot;
  try {{
    spot = await whereAmI();
  }} catch (trouble) {{
    throw new Error("the browser did not say where you are: " + trouble.message);
  }}
  return post("/here", {{lat: spot.coords.latitude, lon: spot.coords.longitude,
                         accuracy: spot.coords.accuracy, map: mapOptions()}});
}}

document.getElementById("row").addEventListener("submit", async (event) => {{
  event.preventDefault();
  const asked = box.value.trim();
  const here = pressed === "make_map" && ((hereOn && !asked) || saysHere(asked));
  if (!asked && !here) return;
  box.value = "";
  write(userName + ": " + (asked || "📍 here"), "you");

  // One question at a time. The Pi only works on one anyway.
  box.disabled = send.disabled = true;
  const doing = WAITING[Math.floor(Math.random() * WAITING.length)];
  const waiting = puppyLine("thinking", PUPPY + " " + doing);
  waiting.lastChild.classList.add("dots");
  try {{
    const answer = here ? await mapHere()
                        : await post("/ask", {{asked: asked, pressed: pressed,
                                               map: mapOptions(), chat: chat}});
    waiting.remove();
    show(answer);
  }} catch (trouble) {{
    waiting.remove();
    puppyLine("oops", PUPPY + ": Ruh-roh! " + trouble.message);
  }}
  box.disabled = send.disabled = false;
  box.focus();
}});
</script>
</body></html>"""


# ---- the tools ----------------------------------------------------------


def ticker_for(name):
    """BTC for "BTC", "bitcoin" or "比特币". A model may send any of them."""
    name = name.strip()
    if name.lower() in NAMES:
        return NAMES[name.lower()]
    if name in COIN_NAMES:
        return COIN_NAMES[name]
    return name.upper()


def coin_price(symbol: str) -> str:
    """Look up what one coin is worth in Thai baht, right now.

    Args:
        symbol: the coin's ticker, for example BTC or ETH
    """
    symbol = ticker_for(symbol)
    if not re.fullmatch(r"[A-Z0-9]{1,12}", symbol):
        return "Bitkub has no coin called " + symbol
    one_coin = BITKUB + "?sym=" + symbol + "_THB"
    with urllib.request.urlopen(one_coin, timeout=30) as response:
        coins = json.load(response)
    # A coin Bitkub does not sell, like LTC, comes back as an error, not a
    # list: coins[0] once crashed the tool, and the answer came out muddled.
    if not isinstance(coins, list) or not coins:
        return "Bitkub has no price for " + symbol
    change = float(coins[0]["percent_change"])
    way = "up" if change >= 0 else "down"
    return (f"1 {symbol} = {float(coins[0]['last']):,.2f} baht,"
            f" {way} {abs(change):.2f}% in 24 hours")


def language_of(text):
    """th, zh or en: which alphabet the text is mostly written in."""
    if re.search(r"[฀-๿]", text):
        return "th"
    if re.search(r"[㐀-鿿]", text):
        return "zh"
    return "en"


def has_any(words, text):
    """True if any of the words appears anywhere in the text."""
    return any(word in text for word in words)


def make_map(place: str, names: str = "en", thailand_only: bool = False,
             how_many: int = 1, outline: bool = False, labels: str = "local") -> str:
    """Draw a map of a city or a place, and save it as a web page.

    Args:
        place: the city or place, for example Chiang Mai or Wat Phra Singh
        names: the language for place names: en, th or zh
        thailand_only: look only in Thailand
        how_many: how many matching places to pin, from 1 to 5
        outline: draw the place's border, when OpenStreetMap has one
        labels: the map's own labels: local, or local_english for both
    """
    # Python checks the name before OpenStreetMap sees it. A whole sentence,
    # "Chiang Mai and mark important places", finds nothing.
    asked = place
    # "my current location" is not a place OpenStreetMap can find.
    if tidy_place(place).lower() in HERE_PHRASES:
        return "To map where you are, choose 🗺️ Map and type here."
    place, how = find_place_name(place)
    if not place:
        return "There is no place name to look up in: " + asked

    search = {"names": names, "thailand_only": thailand_only,
              "how_many": max(1, min(int(how_many), 5)), "outline": outline}
    places = search_osm(place, **search)

    # Nothing found, and the model has not tried yet? Let it try once.
    if not places and not how:
        shorter = place_by_model(place)
        if shorter and shorter != place:
            time.sleep(1)   # Nominatim asks for no more than one search a second
            place, how = shorter, "shortened by the model"
            places = search_osm(place, **search)
    if not places:
        return "OpenStreetMap found no place called " + place

    note = f" (looked up as '{place}', {how})" if how else ""
    return draw_places(places, labels, note)


def web_search(query: str) -> str:
    """Search the web for something new or local, like news or the weather.

    Args:
        query: what to search for, for example Chiang Mai air quality today
    """
    try:
        hits = DDGS().text(query, max_results=4)
    except Exception as trouble:
        # Search engines turn away too many searches too fast.
        return "The web search did not work: " + str(trouble)
    if not hits:
        return "The web search found nothing for " + query
    # One line per page: [1] title | address | what the page says.
    lines = [f"[{number}] {hit['title']} | {hit['href']} | {hit['body']}"
             for number, hit in enumerate(hits, 1)]
    return "\n".join(lines)


def write_page(description, memory):
    """🌐 Page: write a small web page from a description, and save it.

    The page model reads its own conversation, memory: each request so far,
    and the page written for it. So the next request can change the page:
    "make the button blue". Hands back what was made, the way a tool does.
    """
    system = ("You write web pages. Reply with one complete HTML file:"
              " start with <!DOCTYPE html> and end with </html>. Put all CSS"
              " in a <style> tag and all JavaScript in a <script> tag. Use no"
              " outside files or images.")
    conversation = ([{"role": "system", "content": system}] + memory
                    + [{"role": "user", "content": description}])
    try:
        written = llm_chat(conversation, PAGE_MODEL)
    except urllib.error.HTTPError:
        return ("No web page: the model " + PAGE_MODEL + " is not on the HAT."
                " See PAGE_MODEL near the top of web_factory.py.")
    page = html_from(written)
    # The model remembers the page as it was saved, not the sentences it
    # wrote around it.
    remember(memory, description, page)

    # The file is named after the page's own <title>, plus a few letters,
    # so a second tip calculator does not replace the first.
    title = re.search(r"<title>(.*?)</title>", page, re.I | re.S)
    title = title.group(1).strip() if title else "page"
    slug = re.sub(r"[^a-z0-9]+", "_", title.lower()).strip("_")[:40] or "page"
    file_name = slug + "_" + secrets.token_hex(2) + ".html"
    PAGE_DIR.mkdir(exist_ok=True)
    (PAGE_DIR / file_name).write_text(page, encoding="utf-8")
    lines = page.count("\n")
    return f"Web page '{title}', {lines} lines of HTML. Saved as /pages/{file_name}"


def make_page(description: str) -> str:
    """Write a small web page, an HTML page, from a description, and save it.

    Args:
        description: what the page should be or do, for example a tip calculator
    """
    # The agent's page starts fresh. 🌐 Page, the button, remembers its pages.
    return write_page(description, [])


def html_from(written):
    """The HTML file in what the model wrote. It likes to wrap it in ```html
    fences, and sometimes says a sentence before or after."""
    fenced = re.search(r"```(?:html)?\s*\n(.*?)```", written, re.S | re.I)
    page = fenced.group(1) if fenced else written
    start = re.search(r"<!doctype html|<html", page, re.I)
    if start:
        page = page[start.start():]
    end = page.lower().rfind("</html>")
    if end != -1:
        page = page[:end + len("</html>")]
    if "<html" not in page.lower():
        # Only a piece of a page came back. Put it in a whole one.
        page = "<!DOCTYPE html>\n<html>\n<head>\n</head>\n<body>\n" + page + "\n</body>\n</html>"
    # Saved to a computer, the file has only this to say it is UTF-8,
    # and without it Thai and Chinese come out as nonsense.
    if "charset" not in page.lower():
        page = re.sub(r"<head[^>]*>", lambda m: m.group(0) + '\n<meta charset="utf-8">',
                      page, count=1, flags=re.I)
    return page.strip() + "\n"


def page_reply(task, found):
    """One plain sentence about the page, in the language of the question."""
    title = re.search(r"Web page '(.*)', ", found)
    title = title.group(1) if title else ""
    return puppy_opener("proud", task) + " " + {
            "th": "นี่คือหน้าเว็บของคุณ " + title + " กด ⬇ Download เพื่อบันทึก",
            "zh": "这是你的网页：" + title + "。按 ⬇ Download 保存。",
            }.get(language_of(task), "Here is your web page, " + title
                  + ". Press ⬇ Download to keep it.")


def draw_places(places, labels, note):
    """Pin every place found on one map, save it, and say what is on it."""
    tiles, credit = TILES.get(labels, TILES["local"])
    the_map = folium.Map(tiles=None)
    folium.TileLayer(tiles=tiles, attr=credit, max_zoom=19).add_to(the_map)

    corners = []
    for spot in places:
        lat, lon = float(spot["lat"]), float(spot["lon"])
        south, north, west, east = (float(x) for x in spot["boundingbox"])
        corners += [[south, west], [north, east]]
        name = spot.get("name") or spot["display_name"].split(",")[0]

        # The outline, when one was asked for and the place has one.
        shape = spot.get("geojson")
        if shape and shape.get("type") != "Point":
            folium.GeoJson(shape, style_function=lambda _: {
                "color": "#1a4d8f", "weight": 3, "fillOpacity": 0.08}).add_to(the_map)

        # The same pin as the Chiang Mai map tutorial: a folium Marker with
        # a popup and a tooltip.
        folium.Marker(
            location=[lat, lon], tooltip=name,
            popup=folium.Popup("<b>" + html.escape(name) + "</b><br>"
                               + html.escape(spot["display_name"]), max_width=260),
            icon=folium.Icon(color="red", icon="location-dot", prefix="fa"),
        ).add_to(the_map)

    # Frame everything found, so a city comes out zoomed out and a temple close.
    the_map.fit_bounds(corners, max_zoom=17)

    first = places[0]
    name = first.get("name") or first["display_name"].split(",")[0]
    MAP_DIR.mkdir(exist_ok=True)
    slug = re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_")
    # A name in Thai or Chinese letters leaves nothing for a file name.
    file_name = (slug or "place_" + secrets.token_hex(4)) + ".html"
    the_map.save(str(MAP_DIR / file_name))

    more = f" and {len(places) - 1} more" if len(places) > 1 else ""
    lat, lon = float(first["lat"]), float(first["lon"])
    return (f"Map of {name}{more} ({lat:.5f}, {lon:.5f}), a {first['addresstype']}.{note}"
            f" Saved as /maps/{file_name}")


def map_reply(task, found):
    """One plain sentence about the map, in the language of the question."""
    lang = language_of(task)
    named = re.match(r"Map of (.+?) \(", found)
    if found.startswith("To map where you are"):
        return found
    name = named.group(1) if named else ""
    return puppy_opener("excited", task) + " " + {
            "th": "นี่คือ " + name + " บนแผนที่",
            "zh": "这是地图上的 " + name + "。",
            }.get(lang, "Here is " + name + " on the map.")


def puppy_opener(mood, task):
    """The puppy's first words for a mood, in the language of the question."""
    return PUPPY_OPENERS[mood].get(language_of(task), PUPPY_OPENERS[mood]["en"])


def bad_news(found):
    """The BAD_NEWS entry this tool result starts with, or None."""
    for start, said in BAD_NEWS.items():
        if found.startswith(start):
            return said
    return None


def oops_reply(task, said):
    """Bad news, puppy style: dismay, the news, and a promise to help."""
    lang = language_of(task)
    return " ".join([OOPS_OPENERS.get(lang, OOPS_OPENERS["en"]),
                     said.get(lang, said["en"]),
                     OOPS_PROMISES.get(lang, OOPS_PROMISES["en"])])


def mood_of(task, tools, found):
    """One mood for the answer, from what happened. No model is asked."""
    if found and bad_news(found):
        return "oops"
    if "make_page" in tools:
        return "proud"
    if "make_map" in tools:
        return "excited"
    if "coin_price" in tools:
        return "excited" if found and ", up " in found else "cheering"
    if "web_search" in tools:
        return "curious"
    if re.search(r"\b(thanks?|thank you|love|peppa)\b", task, re.I) \
            or has_any(["ขอบคุณ", "รัก", "谢谢", "爱", "佩奇"], task):
        return "loving"
    return "happy"


def tidy_place(text):
    """One line, single spaces, no quotes or marks around it, not too long."""
    text = " ".join(text.split())
    text = text.strip(" \"'“”‘’.,;:!?？！。")
    return text[:100]


def find_place_name(text):
    """The place name in what was typed, and how it was found, or ("", "").

    A name with more than three spaces is too long to be a place name, so
    the model on the HAT is asked for the short form. Its answer is only
    kept if it is really in the text: asked about "Chiang Mai and mark
    important places", it once answered "Chiang Rai", a different city.
    Then Python tries: the longest run of Capitalised Words.
    """
    text = tidy_place(text)
    if not re.search(r"[^\W\d_]", text):
        return "", ""
    if text.count(" ") <= 3:
        return text, ""
    shorter = place_by_model(text)
    if shorter:
        return shorter, "shortened by the model"
    run = capital_run(text)
    if run:
        return run, "shortened by Python"
    return text, ""


def place_by_model(text):
    """Ask the model on the HAT for just the place name. None if it made one up."""
    system = ("Reply with only the name of the place in the request, nothing else."
              " Keep it in the request's own language.\n"
              "Show me a map of Chiang Mai and mark important places -> Chiang Mai\n"
              "I would like to see where the night bazaar is please -> night bazaar")
    reply = llm_chat([{"role": "system", "content": system},
                      {"role": "user", "content": text}])
    # Keep the first line only, in case it says more than it was asked to.
    lines = reply.splitlines() or [""]
    shorter = tidy_place(lines[0].split("->")[-1])

    # Checked, not trusted: a real short form is part of what was typed.
    if not shorter or shorter.count(" ") > 5 or shorter.lower() not in text.lower():
        return None
    return shorter


def capital_run(text):
    """The longest run of Capitalised Words: "Three Kings Monument"."""
    word = r"[A-Z][\w'’.-]*"
    runs = re.findall(word + r"(?:\s+(?:" + word + r"|of|de))*", text)
    runs = [run for run in runs if run not in ("I", "Show", "Make", "Can", "Please")]
    return max(runs, key=len) if runs else ""


def search_osm(place, names="en", thailand_only=False, how_many=1, outline=False):
    """Nominatim, OpenStreetMap's search. Hands back a list, maybe empty."""
    # Free, no key, but it asks for a name in the User-Agent and no more
    # than one search a second. Each option here is one of Nominatim's own.
    options = {"q": place, "format": "jsonv2", "limit": how_many,
               "accept-language": names}
    if thailand_only:
        options["countrycodes"] = "th"
    if outline:
        # The border as a shape. Simplified, or a province is a huge file.
        options["polygon_geojson"] = 1
        options["polygon_threshold"] = 0.001
    request = urllib.request.Request(NOMINATIM + "?" + urllib.parse.urlencode(options),
                                     headers=AGENT)
    with urllib.request.urlopen(request, timeout=30) as response:
        return json.load(response)


def clean_map_options(raw):
    """The map options from the page, checked. Anything odd falls back."""
    raw = raw if isinstance(raw, dict) else {}
    return {
        "names": raw.get("names") if raw.get("names") in ("en", "th", "zh") else "en",
        "labels": raw.get("labels") if raw.get("labels") in TILES else "local",
        "thailand_only": raw.get("thailand_only") is True,
        "how_many": 5 if raw.get("how_many") == 5 else 1,
        "outline": raw.get("outline") is True,
    }


def map_here(lat, lon, accuracy, names="en", labels="local"):
    """A map of where the browser says you are. Hands back what the page shows."""
    lat, lon = float(lat), float(lon)
    if not (-90 <= lat <= 90 and -180 <= lon <= 180):
        raise ValueError("that is not a place on Earth")
    accuracy = min(max(float(accuracy or 0), 5), 5000)

    # Only the name of the street goes out, from Nominatim's reverse search:
    # it takes a position and hands back the address there.
    ask = urllib.parse.urlencode({"lat": lat, "lon": lon, "format": "jsonv2",
                                  "zoom": 17, "accept-language": names})
    request = urllib.request.Request(NOMINATIM_REVERSE + "?" + ask, headers=AGENT)
    with urllib.request.urlopen(request, timeout=30) as response:
        name = json.load(response).get("display_name", "somewhere with no name")

    # The blue circle is how sure the browser is: it may be off by that much.
    tiles, credit = TILES.get(labels, TILES["local"])
    the_map = folium.Map(location=[lat, lon], zoom_start=16, tiles=None)
    folium.TileLayer(tiles=tiles, attr=credit, max_zoom=19).add_to(the_map)
    folium.Circle([lat, lon], radius=accuracy, color="#1a4d8f", fill=True,
                  fill_opacity=0.15).add_to(the_map)
    you = folium.Icon(color="blue", icon="person", prefix="fa")
    folium.Marker([lat, lon], tooltip="You are here", icon=you,
                  popup=folium.Popup(html.escape(name), max_width=260)).add_to(the_map)

    # A random file name. Everyone on the Wi-Fi can open the maps folder,
    # and this map says where you are.
    MAP_DIR.mkdir(exist_ok=True)
    file_name = "here_" + secrets.token_hex(8) + ".html"
    the_map.save(str(MAP_DIR / file_name))

    short = ", ".join(name.split(", ")[:3])
    return {"steps": ["chosen by you: " + BUTTONS["make_map"],
                      f"your browser says: {lat:.5f}, {lon:.5f}, within {accuracy:.0f} m",
                      "found: " + name[:300]],
            "reply": PUPPY_OPENERS["excited"]["en"] + " Here is where you are: "
                     + short + ".",
            "map": "/maps/" + file_name, "tools": ["map_here"], "mood": "excited"}


# ---- small helpers ------------------------------------------------------


def llm_chat(messages, model=LLM_MODEL):
    """Send a conversation to the HAT and hand back what it wrote."""
    body = json.dumps({"model": model, "stream": False,
                       "messages": messages}).encode()
    request = urllib.request.Request(LLM_URL + "/api/chat", body,
                                     {"Content-Type": "application/json"})
    with urllib.request.urlopen(request, timeout=300) as response:
        return json.load(response)["message"]["content"].strip()


def llm_ready():
    """True if the model server answers. hailo-ollama has no /v1/models, but it
    answers /api/tags, and so does every ordinary Ollama."""
    try:
        with urllib.request.urlopen(LLM_URL + "/api/tags", timeout=3):
            return True
    except Exception:
        return False


def agent_ready():
    """True if real Ollama answers at AGENT_URL and has the agent's model."""
    try:
        with urllib.request.urlopen(AGENT_URL + "/api/tags", timeout=3) as response:
            return AGENT_MODEL in [model["name"] for model in json.load(response)["models"]]
    except Exception:
        return False


def degrees():
    """The processor's temperature, or 0 if it cannot be read."""
    try:
        return int(Path(CPU_SENSOR).read_text()) / 1000
    except (OSError, ValueError):
        return 0.0


def missing_models():
    """The biscuit models the HAT does not have yet. make_biscuit.py makes them."""
    try:
        with urllib.request.urlopen(LLM_URL + "/api/tags", timeout=3) as response:
            on_the_hat = [model["name"] for model in json.load(response)["models"]]
    except Exception:
        # No model server at all. llm_ready() is the one to say so.
        return []
    return [model for model in BISCUIT.values() if model not in on_the_hat]


def bitkub_symbols():
    """Every coin Bitkub sells for baht, as a set like {'BTC', 'ETH', ...}."""
    with urllib.request.urlopen(BITKUB, timeout=30) as response:
        pairs = json.load(response)
    return {pair["symbol"][:-4] for pair in pairs if pair["symbol"].endswith("_THB")}


def facts_of(found):
    """What a tool found, for the model to read. A search's pages lose their
    numbers and addresses: handed "[3] title | address |" lines, the model
    copied them into its answer."""
    pages = re.findall(r"^\[\d+\] (.*?) \| https?://\S+ \| (.*)$", found, re.M)
    if pages:
        return "\n".join(f"- {title}: {text}" for title, text in pages)
    return found


def remember(memory, asked, reply):
    """Add one question and its answer to a conversation. Then the oldest
    ones are forgotten, until what is left fits in MEMORY_CHARS."""
    memory.append({"role": "user", "content": asked})
    memory.append({"role": "assistant", "content": reply})
    while len(memory) > 2 and sum(len(turn["content"]) for turn in memory) > MEMORY_CHARS:
        del memory[:2]


# ---- what Biscuit knows about itself: biscuit.md, indexed by LlamaIndex --


def about_biscuit():
    """Read biscuit.md and index it. Hands back a function, about(question):
    it finds the pieces of the file that fit a question."""
    # LlamaIndex only looks things up here. Biscuit writes the answers.
    Settings.llm = None
    Settings.embed_model = HuggingFaceEmbedding(model_name=EMBED_MODEL, device="cpu")
    pages = SimpleDirectoryReader(input_files=[str(ABOUT_FILE)]).load_data()
    # One piece for every heading in the file.
    index = VectorStoreIndex.from_documents(pages, transformations=[MarkdownNodeParser()])
    # Every piece comes back, each with its score, the nearest first.
    retriever = index.as_retriever(similarity_top_k=len(index.docstore.docs))
    print(len(index.docstore.docs), "pieces of", ABOUT_FILE.name, "indexed")

    def about(question):
        """What the file says that fits the question, and a line for the
        page to show. Only pieces near enough, in the question's language."""
        language = language_of(question)
        near = [piece for piece in retriever.retrieve(question)
                if piece.score >= ABOUT_LINE
                and language_of(piece.get_content()) == language]
        said = []
        shown = []
        for piece in near[:ABOUT_PIECES]:
            heading, *lines = piece.get_content().splitlines()
            said.append(" ".join(line for line in lines if line))
            shown.append(f"{heading.lstrip('# ')} ({piece.score:.2f})")
        return " ".join(said), ", ".join(shown) or "nothing near enough"

    return about


# ---- the writer: Qwen on the HAT ---------------------------------------


def write_answer(task, found, memory, known=""):
    """Biscuit, on the HAT, writes the reply. It reads memory first, the
    conversation so far, as chat messages: that is all the memory it has.
    A lookup hands over an empty memory: its answer comes from what was
    found, and from nothing said before.

    No system message is sent. Who Biscuit is was put inside the biscuit
    models by make_biscuit.py, and the model reads it first by itself.

    What was found goes in the question, right next to it, and so does
    known, what biscuit.md says about this question. Facts first, question
    last: with the question first, it called Albert Einstein "Alfred" in 3
    answers out of 4.
    """
    question = FACTS_ASK.format(facts_of(found), task) if found else task
    if known:
        label = ABOUT_LABEL.get(language_of(task), ABOUT_LABEL["en"])
        question = label + " " + known + "\n\n" + question
    reminder = LANGUAGE_REMINDER.get(language_of(task))
    if reminder:
        question += "\n\n" + reminder
    conversation = memory + [{"role": "user", "content": question}]
    model = BISCUIT[language_of(task)]
    written = llm_chat(conversation, model)
    # Checked, not trusted: a Thai question once got a Chinese answer.
    # If the alphabets differ, it is asked once more.
    if language_of(written) != language_of(task):
        written = llm_chat(conversation, model)
    return tidy_reply(written)


def tidy_reply(written):
    """What the model wrote, tidied: no emojis, no "as an AI", and three
    sentences at most."""
    written = " ".join(EMOJI.sub("", written).split())
    # Its Thai sometimes gets stuck on one letter: "คิม่าาาาาาาาาาาาาาา...".
    written = re.sub(r"(.)\1{5,}", r"\1\1\1", written)
    kept_sentences = [part for part in re.split(r"(?<=[.!?])\s+", written)
                      if not re.search(NOT_A_PUPPY, part, re.I)]
    written = " ".join(kept_sentences) or written
    # "Under three sentences", the persona says, and it wrote twelve about
    # Peppa Pig videos. Python keeps the first three.
    # A full stop ends a sentence only before a space ("3.5" is one
    # number), and not after a digit: "1. Start by..." is a step, and
    # cutting there left an answer ending "2.". Chinese 。！？ have no
    # space after them.
    sentences = re.split(r"(?<=[^\d\s][.!?])\s+|(?<=[。！？])\s*", written)
    kept = " ".join(part for part in sentences[:3] if part).strip()
    return re.sub(r"(?<=[。！？]) ", "", kept)


# ---- the buttons: the tool the button names ---------------------------


def find_coin(task, symbols):
    """The first coin named in the message, or None. symbols is every
    ticker Bitkub sells."""
    for name, symbol in COIN_NAMES.items():
        if name in task:
            return symbol
    for word in re.findall(r"[A-Za-z0-9]+", task):
        if word.lower() in NAMES:
            return NAMES[word.lower()]
        if word.isupper() and word in symbols:
            return word
    return None


def find_place(task):
    """The place in "show me a map of Wat Phra Singh": Wat Phra Singh."""
    text = task.strip().rstrip("?.! ？！。")
    if language_of(text) != "en":
        # "ขอแผนที่วัดพระสิงห์หน่อย": take the words away, keep the place.
        for word in NOT_PLACE_TH_ZH:
            text = text.replace(word, " ")
        return text.strip()
    lead_in = r"\b(?:map of|map for|map to|where is|where's)\s+(.+)"
    match = re.search(lead_in, text, re.I)
    if match:
        text = match.group(1)
    else:
        # "Can I see Bangkok on a map" - drop the words that are not the place.
        text = re.sub(r"\b(can|could|you|i|me|please|show|see|give|a|an|on|"
                      r"map|draw|make|where|is|located)\b", " ", text, flags=re.I)
    text = re.sub(r"^\s*the\s+", "", text, flags=re.I)

    # Nominatim reads "night bazaar, chiang mai" better than "... in chiang mai".
    return re.sub(r"\s+in\s+", ", ", text).strip(" ,")


def lookup(asked, pressed, map_options, symbols, who):
    """One lookup: the tool the button names runs once, on what was typed,
    and Biscuit replies to what it found. Python still has to find WHICH
    coin or place was typed. Hands back what the page shows, and what was
    found, for the chat's memory."""
    steps = [who]
    if pressed == "make_map":
        # The options row on the page rides along: names, labels and so on.
        place = find_place(asked)
        steps.append(f"called make_map({{'place': {place!r}}} and the map options)")
        found = make_map(place, **map_options)
    elif pressed == "web_search":
        # What was typed is the search, word for word.
        steps.append(f"called web_search({{'query': {asked!r}}})")
        found = web_search(asked)
    else:
        symbol = find_coin(asked, symbols)
        # With 💰 pressed, one word is enough: "doge", "near". Only a
        # word in Latin letters can be a ticker. Thai has no spaces, so a
        # whole Thai question looks like "one word" and must not be sent.
        word = asked.strip(" ?？!！。.")
        if not symbol and re.fullmatch(r"[A-Za-z0-9]{2,12}", word):
            symbol = ticker_for(word)
        if not symbol:
            ask = "They want a coin price but named no coin. Ask which coin, like BTC."
            reply = write_answer(asked, ask, [])
            return result_of(asked, steps + ["no coin named: the model asks which"],
                             reply, [], []), None
        steps.append(f"called coin_price({{'symbol': {symbol!r}}})")
        found = coin_price(symbol)
    steps.append("found: " + found[:300])
    return result_of(asked, steps, reply_to(asked, pressed, found), [pressed], [found]), found


def reply_to(asked, pressed, found):
    """Biscuit's reply to what a lookup found."""
    # Bad news is written by Python, every time: a flustered puppy, the
    # news, a tip, and a promise to help. The model never writes it.
    said = bad_news(found)
    if said:
        return oops_reply(asked, said)

    # A price: Bitkub's own line is the answer. The small model, asked to
    # repeat it, sometimes dropped the number: "Bitcoin is a type of
    # cryptocurrency." Once it added a dollar price it made up.
    # The puppy only adds words in front: the number stays as Bitkub sent it.
    if pressed == "coin_price":
        mood = "excited" if ", up " in found else "cheering"
        return puppy_opener(mood, asked) + " " + found

    # After a map, a plain sentence. The small model, asked to describe
    # the map, rambled and once repeated its own instructions.
    if pressed == "make_map":
        return map_reply(asked, found)

    # A search: the model gets each page's title and what it says, but
    # not the numbers and addresses: handed "[3] title | address |" lines,
    # it copied them into its answer. The titles stay: with the snippets
    # alone, "Peppa Pig videos" found YouTube's Peppa Pig channel, and the
    # model said videos were "not mentioned in the facts provided".
    # The chat lists the pages as links anyway.
    if re.search(LINK_WORDS, asked, re.I) or has_any(LINK_WORDS_TH_ZH, asked):
        return HERE_ARE_LINKS.get(language_of(asked), HERE_ARE_LINKS["en"])
    pages = re.findall(r"^\[\d+\] (.*?) \| https?://\S+ \| (.*)$", found, re.M)
    written = write_answer(asked, found, [])
    # Checked, not trusted: with fresh results in front of it, the
    # model still said "I can't provide scores" 2 times in 6 searches
    # about now. Rewording the prompt did not stop it (4, 3 and 4 in
    # 12). So a refusal is replaced by what the search really found.
    if (re.search(REFUSED, written, re.I) or re.search(NON_ANSWER, written, re.I)) and pages:
        top = pages[0][1]
        cut = re.split(r"(?<=[.!?。])\s", top)
        written = (SEARCH_FOUND.get(language_of(asked), SEARCH_FOUND["en"]) + " "
                   + " ".join(cut[:2]).strip()[:300])
    return written


def result_of(asked, steps, reply, tools, founds):
    """What the page shows: the grey steps, the reply, and a map, a page or
    links, if a tool made or found them."""
    a_map = None
    a_page = None
    links = []
    for found in founds:
        saved = re.search(r"/maps/[a-z0-9_]+\.html", found)
        if saved:
            a_map = saved.group(0)
        made = re.search(r"/pages/[a-z0-9_]+\.html", found)
        if made:
            a_page = made.group(0)
        # A search's lines, [1] title | address | ..., become links.
        for title, url in re.findall(r"^\[\d+\] (.*?) \| (https?://\S+) \|", found, re.M):
            links.append({"title": title, "url": url})
    return {"steps": steps, "reply": reply, "map": a_map, "page": a_page, "links": links,
            "tools": tools, "mood": mood_of(asked, tools, founds[-1] if founds else None)}


# ---- ✨ Auto: the ReAct agent ----------------------------------------------


def make_agent(about):
    """✨ Auto: LlamaIndex's ReAct agent, the one on the "Local AI stack"
    page, with the chatbot's tools and a door to the model on the Pi's
    processor. about(), the way to look things up in biscuit.md, becomes a
    tool too, so the agent can ask what Biscuit knows about itself."""

    def about_biscuit(question: str) -> str:
        """What Biscuit knows about itself: its friends, its favourite show,
        its favourite singer. Call this for any question about Biscuit, you.

        Args:
            question: the question about Biscuit
        """
        known, shown = about(question)
        return known or "Nothing about that is written down."

    def draw_map(place: str) -> str:
        """Draw a map of a city or a place, to show where it is.

        Args:
            place: the city or place, for example Chiang Mai or Wat Phra Singh
        """
        # The agent only names the place. Handed the map's every option, it
        # chose Thai labels for an English question.
        return make_map(place)

    tools = [FunctionTool.from_defaults(coin_price), FunctionTool.from_defaults(web_search),
             FunctionTool.from_defaults(draw_map), FunctionTool.from_defaults(make_page),
             FunctionTool.from_defaults(about_biscuit)]
    # A low temperature: the agent's every step must follow ReAct's format
    # exactly, and a lively guess is a wrong one. Asked "And ethereum?" at
    # the usual 0.75, it once wrote a price instead of calling the tool.
    llm = Ollama(model=AGENT_MODEL, base_url=AGENT_URL, request_timeout=300,
                 context_window=4096, temperature=0.1)
    return ReActAgent(tools=tools, llm=llm, system_prompt=AGENT_PROMPT)


def agent_loop():
    """The agent runs as an async workflow, and needs an event loop. Each
    page request has a thread of its own, so the loop gets a thread of its
    own, and lives as long as the server. (A loop made for every question
    left the agent's connection to Ollama closed: "Event loop is closed".)"""
    loop = asyncio.new_event_loop()
    threading.Thread(target=loop.run_forever, daemon=True).start()
    return loop


def auto(asked, memory, agent, loop):
    """✨ Auto: the agent reads the conversation and this message, picks its
    tools, runs them, and writes the answer. Every step comes back as an
    event, and the page shows them: the tool called, what it found."""
    history = [ChatMessage(role=turn["role"], content=turn["content"]) for turn in memory]
    steps = ["chosen by " + AGENT_MODEL + ", a ReAct agent on the Pi's processor"]
    tools = []
    founds = []

    async def run():
        handler = agent.run(user_msg=asked, chat_history=history, max_iterations=AGENT_STEPS)
        async for event in handler.stream_events():
            # A ToolCallResult is a ToolCall too, so the plain one is checked by type.
            if type(event) is ToolCall:
                steps.append(f"called {event.tool_name}({event.tool_kwargs})")
                tools.append(event.tool_name)
            elif isinstance(event, ToolCallResult):
                found = str(event.tool_output.content)
                founds.append(found)
                steps.append("found: " + found[:300])
            elif isinstance(event, AgentOutput) and not event.tool_calls:
                steps.append("the agent answers")
        return str(await handler)

    # The page's thread hands the run to the agent's loop, and waits for it.
    reply = tidy_reply(asyncio.run_coroutine_threadsafe(run(), loop).result(timeout=600))
    remember(memory, asked, reply)
    return result_of(asked, steps, reply, tools, founds)




# ---- asking: the button, or the agent, decides -------------------


def converse(asked, kind, memory, who, about):
    """One turn of a conversation, 💬 Chat or 🌐 Page. The model reads the
    whole conversation, memory, and this turn is added to it. In a chat it
    also reads what biscuit.md says about the question: about() finds it."""
    steps = [who, f"sent to the model: this message, and the {len(memory)} before it"]
    if kind == "make_page":
        found = write_page(asked, memory)
        said = bad_news(found)
        made = re.search(r"/pages/[a-z0-9_]+\.html", found)
        return {"steps": steps + ["found: " + found],
                "reply": oops_reply(asked, said) if said else page_reply(asked, found),
                "map": None, "page": made.group(0) if made else None, "links": [],
                "tools": [], "mood": mood_of(asked, [kind], found)}
    known, shown = about(asked)
    steps.append("from " + ABOUT_FILE.name + ": " + shown)
    reply = write_answer(asked, None, memory, known)
    remember(memory, asked, reply)
    return {"steps": steps, "reply": reply, "map": None, "page": None, "links": [],
            "tools": [], "mood": mood_of(asked, [], None)}


def answer(asked, pressed, map_options, memory, room):
    """One question. ✨ Auto lets the agent choose. Any other button says
    what happens, and nothing is guessed.

    💬 Chat and 🌐 Page are conversations. Each has a memory of its own, a
    list of messages, and the model reads all of it before it answers.
    ✨ Auto reads the chat's memory too, and adds to it.

    💰 Price, 🗺️ Map and 🔎 Search look one thing up. They read what was
    typed, and nothing that was said before. What they find is added to the
    chat's memory, so 💬 Chat and ✨ Auto can carry on from there.
    map_options come from the row under 🗺️ Map. The room holds the agent,
    about() for biscuit.md, and Bitkub's coin list.
    """
    if pressed not in BUTTONS:
        pressed = "auto"
    who = "chosen by you: " + BUTTONS[pressed]
    try:
        if pressed == "auto":
            warm = degrees()
            if not agent_ready():
                who = f"✨ Auto is off: no {AGENT_MODEL} at {AGENT_URL}. {PUPPY_NAME} answers on the HAT, with no tool"
            elif warm >= HOT:
                # No model at all, not even the HAT's: the hot answer is
                # Python's, and the Pi gets a rest.
                reply = TOO_HOT.get(language_of(asked), TOO_HOT["en"]).format(warm)
                return {"steps": [f"the processor is at {warm:.0f} °C: ✨ Auto is resting"],
                        "reply": reply, "map": None, "page": None, "links": [],
                        "tools": [], "mood": "oops"}
            else:
                return auto(asked, memory["chat"], room.agent, room.loop)
            return converse(asked, "chat", memory["chat"], who, room.about)
        if pressed in CONVERSATIONS:
            return converse(asked, pressed, memory[pressed], who, room.about)
        result, found = lookup(asked, pressed, map_options or {}, room.symbols, who)
        if found and not bad_news(found):
            asked = FACTS_ASK.format(facts_of(found), asked)
        remember(memory["chat"], asked, result["reply"])
        return result
    except Exception as trouble:
        reply = (OOPS_OPENERS["en"] + " Something went wrong: " + str(trouble)
                 + ". Are the model servers running, at " + LLM_URL + " and " + AGENT_URL
                 + "? " + OOPS_PROMISES["en"])
        return {"steps": [], "reply": reply, "map": None, "tools": [], "mood": "oops"}


# ---- the factory ---------------------------------------------------------


def page_class(name, title, kind):
    """Build a new page class and hand it back. Not an object: a class."""

    def __init__(self, agent=None, about=None, symbols=None, loop=None):
        # The form needs none of these. The room is handed them, made in
        # main(): the agent and the loop it runs on, about() for looking
        # things up in biscuit.md, and every ticker Bitkub sells.
        self.agent = agent
        self.about = about
        self.symbols = symbols or set()
        self.loop = loop

        # Many browsers can post at once, each on its own thread. The HAT
        # works on one question at a time, so they queue here, politely.
        self.one_at_a_time = threading.Lock()

        # Every browser has its own two conversations. Their memories are
        # kept here, under the name each page made up.
        self.memories = {}

    # ---- the form kind ------------------------------------------------

    def show_form(self, warning=""):
        """The whole form page, as one string of HTML."""
        return FORM_PAGE.format(title=html.escape(title), warning=warning)

    def submit(self, fields):
        """Keep the name and move on. Nothing typed means ask again.

        Hands back two things: where to send the browser next, and the
        page to show if it should stay here instead.
        """
        typed = fields.get("user_name", [""])[0].strip()
        if not typed:
            return None, self.show_form("Please type a name.")

        # The web has no object that lives between two pages, the way the
        # tkinter form did. So the name travels in the address instead.
        return "/chat?" + urllib.parse.urlencode({"user_name": typed}), None

    # ---- the chat kind ------------------------------------------------

    def show_chat(self, user_name):
        """The chat page. The name is baked into it, for the page to show."""
        # The name stays on the page and out of the model. Handed to this
        # small model, it answered "Nari invented Bitcoin."
        greeting = ("Woof! Hi, " + user_name + ", I'm " + PUPPY_NAME + ", a puppy who"
                    " loves Peppa Pig and listening to Jay Chou. Let's chat! For a"
                    " coin's price, a map, a web page or a web search, press its"
                    " button first. Homework and games too!\n")
        missing = missing_models()
        if not llm_ready():
            greeting += ("\nNo answer from the model server at " + LLM_URL + ".\n"
                         "On the Pi, try: systemctl status hailo-ollama\n")
        elif missing:
            greeting += ("\nThe HAT has no model called " + ", ".join(missing) + ".\n"
                         "On the Pi, run: python3 make_biscuit.py\n"
                         "and then: sudo systemctl restart hailo-ollama\n")
        if not agent_ready():
            greeting += ("\n✨ Auto is off: no " + AGENT_MODEL + " at " + AGENT_URL + ".\n"
                         "On the Pi, try: systemctl status ollama-cpu\n")
        secure_port = getattr(self, "secure_port", 0)
        shared_at = json.dumps(getattr(self, "shared_at", ""))
        return CHAT_PAGE.format(title=html.escape(title), secure_port=secure_port,
                                shared_at=shared_at,
                                port=PORT,
                                puppy_json=json.dumps(PUPPY_NAME),
                                moods_json=json.dumps(MOODS),
                                waiting_json=json.dumps(WAITING),
                                greeting=html.escape(greeting),
                                user_name_json=json.dumps(user_name))

    def ask(self, fields):
        """Answer one message. Hands back the steps, the reply and any map."""
        asked = str(fields.get("asked", "")).strip()
        pressed = str(fields.get("pressed") or "auto")
        with self.one_at_a_time:
            options = clean_map_options(fields.get("map"))
            # Taken out and put back, so the browser that asked last is last
            # in line. The first in line is the one to forget.
            browser = str(fields.get("chat") or "")[:40]
            memory = self.memories.pop(browser, None) or {button: [] for button in CONVERSATIONS}
            self.memories[browser] = memory
            if len(self.memories) > MOST_CHATS:
                del self.memories[next(iter(self.memories))]
            return answer(asked, pressed, options, memory, self)

    def here(self, fields):
        """A map of where the browser says this person is."""
        try:
            options = clean_map_options(fields.get("map"))
            return map_here(fields.get("lat"), fields.get("lon"), fields.get("accuracy"),
                            options["names"], options["labels"])
        except Exception as trouble:
            return {"steps": [], "reply": OOPS_OPENERS["en"] + " No map of where you are: "
                    + str(trouble) + ". " + OOPS_PROMISES["en"],
                    "map": None, "tools": [], "mood": "oops"}

    # type() takes three things: the name, who it inherits from, and a dict
    # of what is inside. The empty brackets mean it inherits from nothing.
    inside = {"__init__": __init__}
    if kind == "form":
        inside.update({"show_form": show_form, "submit": submit})
    else:
        inside.update({"show_chat": show_chat, "ask": ask, "here": here})
    return type(name, (), inside)


def handler_class(form, room):
    """Build the class http.server calls for every request.

    This one cannot inherit from nothing either. http.server insists on
    a BaseHTTPRequestHandler, so the middle argument to type() is filled.
    """

    def send_page(self, text, status=200):
        body = text.encode()
        self.send_response(status)
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def do_GET(self):
        address = urllib.parse.urlparse(self.path)
        if address.path == "/":
            self.send_page(form.show_form())
        elif address.path == "/chat":
            user_name = urllib.parse.parse_qs(address.query).get("user_name", [""])[0]
            if not user_name.strip():
                # Nobody to talk to. Back to the form.
                self.send_response(303)
                self.send_header("Location", "/")
                self.end_headers()
            else:
                self.send_page(room.show_chat(user_name.strip()))
        elif address.path.startswith("/maps/"):
            # Only the file name is used, so "/maps/../secret" cannot climb
            # out of the maps folder.
            a_map = MAP_DIR / Path(address.path).name
            if a_map.suffix == ".html" and a_map.is_file():
                self.send_page(a_map.read_text(encoding="utf-8"))
            else:
                self.send_page("No such map.", 404)
        elif address.path.startswith("/pages/"):
            # The same guard as the maps: only the file name counts.
            made = PAGE_DIR / Path(address.path).name
            if made.suffix == ".html" and made.is_file():
                self.send_page(made.read_text(encoding="utf-8"))
            else:
                self.send_page("No such page.", 404)
        else:
            self.send_page("Not here.", 404)

    def do_POST(self):
        length = int(self.headers.get("Content-Length", 0))
        posted = self.rfile.read(length).decode()
        if self.path == "/start":
            where, page = form.submit(urllib.parse.parse_qs(posted))
            if where:
                # 303 tells the browser: go and GET this address instead.
                self.send_response(303)
                self.send_header("Location", where)
                self.end_headers()
            else:
                self.send_page(page)
        elif self.path in ("/ask", "/here"):
            fields = json.loads(posted)
            answer = room.ask(fields) if self.path == "/ask" else room.here(fields)
            body = json.dumps(answer).encode()
            self.send_response(200)
            self.send_header("Content-Type", "application/json")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)
        else:
            self.send_page("Not here.", 404)

    # timeout: a visitor who goes quiet for 30 seconds is let go, so a
    # stalled browser cannot hold a thread forever.
    return type("Handler", (BaseHTTPRequestHandler,),
                {"send_page": send_page, "do_GET": do_GET, "do_POST": do_POST,
                 "timeout": 30})


def start_https(handler):
    """The same page over https, in its own thread. True if it started."""
    if not (CERT.exists() and KEY.exists()):
        print("no cert.pem and key.pem: no https, so no map of where you are")
        return False
    try:
        secure = ThreadingHTTPServer(("0.0.0.0", SECURE_PORT), handler)
    except PermissionError:
        # Ports under 1024, like 443, are kept for the system. The service
        # file gives this program permission; run by hand, it has none.
        print("no permission for port", SECURE_PORT, "- try SECURE_PORT=8443")
        return False
    except OSError:
        print("port", SECURE_PORT, "is in use by another program: no https")
        return False
    context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
    context.load_cert_chain(CERT, KEY)
    # The handshake, where browser and Pi agree on the encryption, waits
    # for each visitor's own thread. Done in the loop that accepts new
    # visitors, one browser that stopped half way stuck it for everyone.
    secure.socket = context.wrap_socket(secure.socket, server_side=True,
                                        do_handshake_on_connect=False)
    # Its own thread, so the http server can run at the same time.
    threading.Thread(target=secure.serve_forever, daemon=True).start()
    port = "" if SECURE_PORT == 443 else ":" + str(SECURE_PORT)
    print("and https://pihat.local" + port, " (Map moves here; the browser warns once)")
    return True


def tailscale(*words):
    """Run one tailscale command. Returns what it printed, and whether it worked."""
    try:
        done = subprocess.run(["tailscale", *words], capture_output=True,
                              text=True, timeout=30)
    except subprocess.TimeoutExpired as waited:
        # It waits when it needs something done in a browser first.
        return str(waited.stdout or "") + str(waited.stderr or ""), False
    return done.stdout + done.stderr, done.returncode == 0


def start_sharing():
    """Tell Tailscale who may reach this page, and who may reach real
    Ollama. Returns the page's https address, or ""."""
    commands = {"private": "serve", "public": "funnel"}
    for name, value in (("SHARE", SHARE), ("SHARE_OLLAMA", SHARE_OLLAMA)):
        if value != "off" and value not in commands:
            print(name, "should be off, private or public, not", repr(value))
    if not shutil.which("tailscale"):
        if SHARE in commands or SHARE_OLLAMA in commands:
            print("SHARE is on, but Tailscale is not installed")
        return ""
    # Start from nothing, so off really means off.
    tailscale("serve", "reset")
    if SHARE not in commands and SHARE_OLLAMA not in commands:
        return ""
    said, worked = tailscale("status", "--json")
    name = json.loads(said)["Self"]["DNSName"].rstrip(".")
    address = ""
    if SHARE in commands:
        said, worked = tailscale(commands[SHARE], "--bg", str(PORT))
        if worked:
            address = "https://" + name
            who = "your own devices" if SHARE == "private" else "anyone with the link"
            print("and " + address, " (" + who + ")")
        else:
            print("tailscale", commands[SHARE], "did not work:\n" + said)
    if SHARE_OLLAMA in commands:
        # Funnel allows three ports: 443, 8443 and 10000. The page has 443.
        port = str(urllib.parse.urlparse(AGENT_URL).port)
        said, worked = tailscale(commands[SHARE_OLLAMA], "--bg", "--https=8443", port)
        if worked:
            who = "your own devices" if SHARE_OLLAMA == "private" else "anyone with the link"
            print("and https://" + name + ":8443  (real Ollama, " + who + ")")
        else:
            print("tailscale", commands[SHARE_OLLAMA], "for Ollama did not work:\n" + said)
    return address


def main():
    AskName = page_class("AskName", "Before we start", "form")
    ChatRoom = page_class("ChatRoom", "Ask me", "chat")

    print("made", AskName.__name__, "and", ChatRoom.__name__)
    print("AskName can ask? ", hasattr(AskName, "ask"))
    print("ChatRoom can ask?", hasattr(ChatRoom, "ask"))

    symbols = bitkub_symbols()
    print(len(symbols), "coins on Bitkub")

    # The first question after a model change waits while the model is
    # loaded. Ask a tiny one now, so the first visitor does not wait.
    missing = missing_models()
    if not llm_ready():
        print("no answer from the model server at", LLM_URL)
    elif missing:
        print("the HAT has no model called", ", ".join(missing))
        print("run: python3 make_biscuit.py")
        print("and then: sudo systemctl restart hailo-ollama")
    else:
        print("warming up", BISCUIT["en"], "at", LLM_URL, "...")
        llm_chat([{"role": "user", "content": "Say hi."}], BISCUIT["en"])

    # The agent's model, on the Pi's processor: loaded now, so the first
    # question does not wait 20 seconds for it.
    if agent_ready():
        print("warming up", AGENT_MODEL, "at", AGENT_URL, "...")
        body = json.dumps({"model": AGENT_MODEL, "prompt": "Say hi.", "stream": False}).encode()
        urllib.request.urlopen(urllib.request.Request(
            AGENT_URL + "/api/generate", body, {"Content-Type": "application/json"}), timeout=300)
    else:
        print("no", AGENT_MODEL, "at", AGENT_URL, ": ✨ Auto is off (systemctl status ollama-cpu)")

    form = AskName()
    print("reading", ABOUT_FILE.name, "...")
    about = about_biscuit()
    room = ChatRoom(make_agent(about), about, symbols, agent_loop())

    handler = handler_class(form, room)
    # Tailscale first. Shared, it answers https on port 443 with a real
    # certificate, so the Pi's own https would only get in its way.
    room.shared_at = start_sharing()
    if room.shared_at:
        room.secure_port = 0
    else:
        room.secure_port = SECURE_PORT if start_https(handler) else 0

    server = ThreadingHTTPServer(("0.0.0.0", PORT), handler)
    print("open http://pihat.local:" + str(PORT), " (Ctrl+C to stop)")
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print("\nstopped")


if __name__ == "__main__":
    main()
