🌐 Flask: give your Python a web page · Flask: ให้ Python ของคุณมีหน้าเว็บ · Flask:给你的 Python 一个网页

Ten small steps. A nine-line website grows into a page. It writes your email, reads it aloud, and remembers what you said. The AI and the voice both run on your own machine. · สิบขั้นเล็ก ๆ จากเว็บไซต์เก้าบรรทัด ไปจนถึงหน้าที่ AI เขียนอีเมลให้ อ่านออกเสียงได้ และจำสิ่งที่คุณพูดไว้ได้ ทุกอย่างทำงานบนเครื่องของคุณเอง · 十个小步骤,从九行的网站,到一个 AI 替你写邮件、能念出来、还记得你说过什么的页面。全都跑在你自己的机器上。

🇬🇧 English

Everything you have written so far runs in a black window. Only you can use it.

Flask puts a web page in front of your code. Then anyone with a browser can use it. Your mother. A student. You, from your phone.

Flask is small. Five lines make a website. We will grow it into a page. It will write an email, and send it.

🇹🇭 ไทย

ทุกอย่างที่คุณเขียนมาจนถึงตอนนี้ รันอยู่ในหน้าต่างสีดำ มีแค่คุณที่ใช้ได้

Flask เอาหน้าเว็บมาวางไว้หน้าโค้ดของคุณ แล้วใครก็ตามที่มีเบราว์เซอร์ก็ใช้ได้ แม่ของคุณ นักเรียน หรือตัวคุณเองจากมือถือ

Flask เล็กมาก ห้าบรรทัดก็ได้เว็บไซต์ แล้วเราจะขยายมันให้กลายเป็นหน้าที่เขียนอีเมลและส่งได้

🇨🇳 中文

到目前为止你写的一切,都跑在一个黑窗口里,只有你能用。

Flask 在你的代码前面放上一个网页,于是任何有浏览器的人都能用:你妈妈、一个学生,或者用手机的你自己。

Flask 很小,五行就是一个网站。我们会把它长成一个能写邮件并发送的页面。

⌨️ TERMINAL · install it once

python -m pip install flask
THIS IS THE FIRST THING YOU INSTALL Every other tutorial on this site used only what comes with Python. Flask does not. So we use python -m pip, not plain pip. It installs into the Python you are actually running. On some Windows machines the short command is blocked. This one still works. บทเรียนอื่นทั้งหมดบนเว็บนี้ใช้แต่ของที่มากับ Python อยู่แล้ว แต่ Flask ไม่ใช่ เราจึงใช้ python -m pip ไม่ใช่ pip เฉย ๆ เพราะมันติดตั้งลงใน Python ตัวที่คุณรันอยู่จริง บนเครื่อง Windows บางเครื่อง คำสั่งสั้น ๆ ถูกบล็อก แต่แบบนี้ยังใช้ได้ 这个网站上其他教程只用 Python 自带的东西,Flask 不是。所以我们用 python -m pip 而不是光写 pip:它会装进你真正在运行的那个 Python。在某些 Windows 机器上,简短的写法被拦掉,而这种写法仍然可用。

1 🌐 A website in nine lines · เว็บไซต์ในเก้าบรรทัด · 九行代码的网站

🇬🇧 English

Here is a whole website. Read it once before you run it.

@app.route("/") means: when someone asks for the front page, call this function. Whatever the function returns is what they see.

That is the entire idea of Flask. An address, and a function.

🇹🇭 ไทย

นี่คือเว็บไซต์ทั้งเว็บ อ่านสักรอบก่อนรัน

@app.route("/") แปลว่า เมื่อมีคนขอหน้าแรก ให้เรียกฟังก์ชันนี้ สิ่งที่ฟังก์ชันคืนค่ามา คือสิ่งที่เขาเห็น

นั่นคือแนวคิดทั้งหมดของ Flask ที่อยู่หนึ่งอัน กับฟังก์ชันหนึ่งตัว

🇨🇳 中文

这就是一整个网站。先读一遍再运行。

@app.route("/") 的意思是:有人来要首页时,就调用这个函数。函数返回什么,他们就看到什么。

这就是 Flask 的全部想法:一个地址,一个函数。

📄 NEW FILE · app1.py

"""app1.py — the smallest web page Python can serve."""
from flask import Flask

app = Flask(__name__)


@app.route("/")
def home():
    """Answer anyone who asks for the front page."""
    return "Hello from Python!"


if __name__ == "__main__":
    # debug=True reloads the file when you save it, and shows real errors.
    app.run(debug=True, port=5000)
LineWhat it does · ทำอะไร · 做什么
from flask import FlaskA capital F for the class. A small f for the package.F ตัวใหญ่คือคลาส f ตัวเล็กคือแพ็กเกจ大写 F 是类,小写 f 是包。
app = Flask(__name__)Make the website. __name__ tells Flask where to look for your files.สร้างเว็บไซต์ขึ้นมา __name__ บอก Flask ว่าจะไปหาไฟล์ของคุณที่ไหน造出这个网站。__name__ 告诉 Flask 去哪里找你的文件。
@app.route("/")The address. A single slash means the front page.ที่อยู่ สแลชเดียวหมายถึงหน้าแรก地址。单个斜杠表示首页。
def home():The name is yours to choose. Flask never shows it to anyone.ชื่อนี้คุณตั้งเองได้ Flask ไม่เอาไปแสดงให้ใครเห็น名字随你取,Flask 不会给任何人看到它。
app.run(debug=True, port=5000)Start the server, and keep it running. Stop it with Ctrl and C.เริ่มเซิร์ฟเวอร์และปล่อยให้รันไว้ หยุดด้วย Ctrl กับ C启动服务器并一直跑着。用 Ctrl 加 C 停掉它。
IT DOES NOT STOP BY ITSELF The other programs on this site finish and give the terminal back. This one does not. It waits for visitors until you stop it. That is what a server is. Press Ctrl and C in the terminal to end it. โปรแกรมอื่น ๆ บนเว็บนี้ทำงานจบแล้วคืน terminal ให้คุณ แต่ตัวนี้ไม่ใช่ มันจะรอผู้เข้าชมไปเรื่อย ๆ จนกว่าคุณจะหยุดมัน เซิร์ฟเวอร์เป็นแบบนี้แหละ กด Ctrl กับ C ใน terminal เพื่อจบ 这个网站上别的程序跑完就把终端还给你,这个不会。它会一直等访客,直到你停掉它。服务器就是这样。在终端里按 Ctrl 加 C 结束它。
YOUR TURN
  1. Create app1.py and run it: python app1.py
  2. Open http://127.0.0.1:5000 in your browser.
  3. Check: you see Hello from Python!
  4. Change the words, save the file, and reload the page. No restart needed.
  5. Press Ctrl and C to stop. Reload again: the page is gone.
  1. สร้าง app1.py แล้วรัน: python app1.py
  2. เปิด http://127.0.0.1:5000 ในเบราว์เซอร์
  3. ตรวจ: คุณจะเห็นคำว่า Hello from Python!
  4. ลองเปลี่ยนข้อความ บันทึกไฟล์ แล้วรีโหลดหน้า ไม่ต้องรีสตาร์ท
  5. กด Ctrl กับ C เพื่อหยุด แล้วรีโหลดอีกครั้ง หน้าเว็บหายไปแล้ว
  1. 新建 app1.py 并运行:python app1.py
  2. 在浏览器里打开 http://127.0.0.1:5000
  3. 检查:你会看到 Hello from Python!
  4. 改几个字,保存文件,刷新页面。不用重启。
  5. 按 Ctrl 加 C 停掉,再刷新:页面没了。

2 🔀 More pages, and a name in the address · หลายหน้า และชื่อในที่อยู่ · 更多页面,地址里带个名字

🇬🇧 English

One address is a demo. A real site has several.

Add another decorator, and you have another page. The third one is different. The angle brackets catch a piece of the address. Flask passes it to the function.

So one function can serve a thousand names.

🇹🇭 ไทย

ที่อยู่เดียวคือการสาธิต เว็บจริงมีหลายที่อยู่

เพิ่มเดคอเรเตอร์อีกตัว ก็ได้หน้าใหม่อีกหน้า ตัวที่สามต่างออกไป วงเล็บแหลมจะดักส่วนหนึ่งของที่อยู่ แล้วส่งให้ฟังก์ชัน

ฟังก์ชันเดียวจึงรองรับชื่อได้เป็นพัน

🇨🇳 中文

一个地址是演示,真正的网站有好几个。

再加一个装饰器,就多一个页面。第三个不一样:尖括号会截下地址里的一段,交给函数。

所以一个函数能服务上千个名字。

📄 NEW FILE · app2.py

"""app2.py — more than one page, and a piece of the address you choose."""
from flask import Flask

app = Flask(__name__)


@app.route("/")
def home():
    """The front page. It links to the others."""
    return """<h1>Kru Eng</h1>
       <p><a href="/about">About</a></p>
       <p><a href="/hello/Ploy">Say hello to Ploy</a></p>"""


@app.route("/about")
def about():
    """A second page, at a second address."""
    return "<h1>About</h1><p>An English teacher in Chiang Mai.</p>"


@app.route("/hello/<name>")
def hello(name):
    """The angle brackets catch part of the address and hand it over."""
    return "<h1>Hello, " + name + "!</h1>"


if __name__ == "__main__":
    app.run(debug=True, port=5000)
LineWhat it does · ทำอะไร · 做什么
@app.route("/about")A second address, and a second function. Add as many as you like.ที่อยู่ที่สอง และฟังก์ชันที่สอง จะเพิ่มอีกกี่อันก็ได้第二个地址,第二个函数。想加多少都行。
@app.route("/hello/<name>")The brackets are a hole in the address. Flask fills it in.วงเล็บแหลมคือช่องว่างในที่อยู่ Flask จะเติมให้尖括号是地址里的一个洞,Flask 会把它填上。
def hello(name):The name in the brackets must match the name here. That is how it arrives.ชื่อในวงเล็บต้องตรงกับชื่อตรงนี้ มันถึงจะส่งมาถึง括号里的名字必须和这里的一致,它才送得到。
return "<h1>Hello, " + name + "!</h1>"Building HTML by joining strings. It works, and step 3 shows why we stop.สร้าง HTML ด้วยการต่อสตริง ใช้ได้ แต่ขั้นที่ 3 จะบอกว่าทำไมเราถึงเลิกทำแบบนี้用拼字符串来生成 HTML。能用,但第 3 步会说明我们为什么不这么做。
YOUR TURN
  1. Create app2.py and run it.
  2. Visit the front page, then click both links.
  3. Try /hello/ and your own name.
  4. Try an address you did not write, like /nope. Flask answers 404.
  5. Add a fourth page about your school.
  1. สร้าง app2.py แล้วรัน
  2. เข้าหน้าแรก แล้วคลิกลิงก์ทั้งสอง
  3. ลอง /hello/ ตามด้วยชื่อของคุณเอง
  4. ลองที่อยู่ที่คุณไม่ได้เขียน เช่น /nope Flask จะตอบ 404
  5. เพิ่มหน้าที่สี่เกี่ยวกับโรงเรียนของคุณ
  1. 新建 app2.py 并运行。
  2. 打开首页,然后点那两个链接。
  3. 试试 /hello/ 加上你自己的名字。
  4. 试一个你没写过的地址,比如 /nope,Flask 会回 404。
  5. 再加第四个页面,讲讲你的学校。

3 📄 Keep the HTML in its own file · เก็บ HTML ไว้ในไฟล์ของมันเอง · 把 HTML 放进自己的文件

🇬🇧 English

Joining strings to make HTML gets ugly fast. And it is not safe.

So Flask keeps HTML in a folder called templates. The file is normal HTML, with blanks. {{ }} is a blank to fill. {% %} is an instruction, like a loop.

The folder name matters. Flask looks for templates, and nothing else.

🇹🇭 ไทย

การต่อสตริงเพื่อสร้าง HTML จะน่าเกลียดเร็วมาก และไม่ปลอดภัยด้วย

Flask จึงเก็บ HTML ไว้ในโฟลเดอร์ชื่อ templates ไฟล์เป็น HTML ธรรมดาที่มีช่องว่าง {{ }} คือช่องให้เติม ส่วน {% %} คือคำสั่ง เช่น ลูป

ชื่อโฟลเดอร์สำคัญ Flask มองหาคำว่า templates เท่านั้น

🇨🇳 中文

拼字符串生成 HTML 很快就会变丑,而且不安全。

所以 Flask 把 HTML 放在一个叫 templates 的文件夹里。文件就是普通 HTML,只是留了空。{{ }} 是要填的空,{% %} 是指令,比如循环。

文件夹名字很重要,Flask 只认 templates

📄 NEW FILE · templates/students.html

<!doctype html>
<title>{{ title }}</title>
<h1>{{ title }}</h1>
<ul>
  {% for student in students %}
    <li>{{ student.name }} — level {{ student.level }}</li>
  {% endfor %}
</ul>
<p>{{ students|length }} students.</p>

📄 NEW FILE · app3.py

"""app3.py — keep the HTML in its own file, and fill in the blanks."""
from flask import Flask, render_template

app = Flask(__name__)

STUDENTS = [
    {"name": "Ploy", "level": "A2"},
    {"name": "Nok", "level": "B1"},
    {"name": "Mint", "level": "A1"},
]


@app.route("/")
def students():
    """Hand the list to the template. The template draws it."""
    return render_template("students.html", title="My students", students=STUDENTS)


if __name__ == "__main__":
    app.run(debug=True, port=5000)
LineWhat it does · ทำอะไร · 做什么
{{ title }}A blank. Whatever you passed in under that name appears here.ช่องว่าง สิ่งที่คุณส่งเข้ามาด้วยชื่อนั้นจะโผล่ตรงนี้一个空。你用那个名字传进来的东西会出现在这里。
{% for student in students %}A loop, in the HTML. It needs {% endfor %} to close it.ลูปที่อยู่ใน HTML ต้องมี {% endfor %} มาปิดHTML 里的循环。要用 {% endfor %} 收尾。
{{ students|length }}The bar is a filter. length counts. Others trim, or change case.ขีดตั้งคือฟิลเตอร์ length นับจำนวน ตัวอื่นก็มี เช่น ตัดช่องว่าง หรือเปลี่ยนตัวพิมพ์竖线是过滤器。length 数数量,还有别的能去空格、改大小写。
return render_template("students.html", title=..., students=...)Name the file, then hand over the data. The names here become the blanks there.บอกชื่อไฟล์ แล้วส่งข้อมูลไป ชื่อที่ใช้ตรงนี้จะกลายเป็นช่องว่างตรงนั้น先写文件名,再把数据交过去。这里的名字就是那边的空。
THE TEMPLATE ALSO KEEPS YOU SAFE We tested this. In step 2 we asked for the name <img src=x onerror=alert(1)>. The page sent it back as a real tag. The browser would run it. The template turned the same text into &lt;img ...&gt;, which is just words on a page. One day a visitor will type something strange. Let the template handle it. เราทดสอบมาแล้ว ในขั้นที่ 2 เราขอชื่อว่า <img src=x onerror=alert(1)> หน้าเว็บส่งกลับมาเป็นแท็กจริง ๆ และเบราว์เซอร์จะรันมัน แต่เทมเพลตเปลี่ยนข้อความเดียวกันให้เป็น &lt;img ...&gt; ซึ่งเป็นแค่ตัวหนังสือบนหน้าเว็บ สักวันจะมีคนพิมพ์อะไรแปลก ๆ เข้ามา ปล่อยให้เทมเพลตจัดการ 我们测过了。第 2 步里我们请求的名字是 <img src=x onerror=alert(1)>,页面把它原样当成真标签送回来,浏览器会执行它。而模板把同样的文字变成 &lt;img ...&gt;,只是页面上的普通字。总有一天会有访客输入奇怪的东西,交给模板去处理。
YOUR TURN
  1. Make a folder called templates next to your file.
  2. Create templates/students.html, then app3.py. Run it.
  3. Check: three students, and the line saying 3 students.
  4. Add a fourth student to the list. Reload. You changed no HTML.
  5. Move the file out of templates and reload. Read the error.
  1. สร้างโฟลเดอร์ชื่อ templates ไว้ข้าง ๆ ไฟล์ของคุณ
  2. สร้าง templates/students.html แล้วสร้าง app3.py จากนั้นรัน
  3. ตรวจ: มีนักเรียนสามคน และบรรทัดที่บอกว่า 3 students
  4. เพิ่มนักเรียนคนที่สี่ลงในลิสต์ แล้วรีโหลด คุณไม่ได้แก้ HTML เลย
  5. ลองย้ายไฟล์ออกจาก templates แล้วรีโหลด อ่านข้อผิดพลาดที่ได้
  1. 在你的文件旁边建一个叫 templates 的文件夹。
  2. 新建 templates/students.html,再建 app3.py,然后运行。
  3. 检查:三个学生,还有写着 3 students 的那一行。
  4. 往列表里加第四个学生,刷新。你没有改任何 HTML。
  5. 把文件从 templates 里移出去再刷新,读一下报错。

4 ✉️ A form that really sends · ฟอร์มที่ส่งได้จริง · 一个真的会发送的表单

🇬🇧 English

So far the visitor only reads. Now they can type.

A form sends its boxes back to the same address. It uses POST. So the route accepts both. GET shows the form. POST acts on it.

And the action is one line, because you already wrote it. This is mailer.py from the email lessons, imported unchanged.

🇹🇭 ไทย

ที่ผ่านมาผู้เข้าชมได้แค่อ่าน คราวนี้เขาพิมพ์ได้แล้ว

ฟอร์มจะส่งช่องกรอกกลับไปที่อยู่เดิม ในแบบ POST เส้นทางจึงต้องรับทั้งสองอย่าง GET เพื่อแสดงฟอร์ม และ POST เพื่อลงมือทำ

ส่วนการลงมือทำนั้นมีบรรทัดเดียว เพราะคุณเขียนไว้แล้ว นี่คือ mailer.py จากบทเรียนอีเมล import มาใช้ทั้งดุ้น

🇨🇳 中文

到现在访客只能读,现在他们可以输入了。

表单会把里面的框送回同一个地址,用 POST。所以这条路由要同时接受两种:GET 显示表单,POST 执行动作。

而那个动作只有一行,因为你早就写好了。这就是邮件课里的 mailer.py,原封不动 import 进来。

📄 NEW FILE · templates/send.html

<!doctype html>
<title>Send an email</title>
<h1>Send an email</h1>

{% if message %}
  <p><strong>{{ message }}</strong></p>
{% endif %}

<form method="post">
  <p>To: <input name="to" size="40" required></p>
  <p>Subject: <input name="subject" size="40" required></p>
  <p><textarea name="body" rows="8" cols="50" required></textarea></p>
  <p><button type="submit">Send</button></p>
</form>

📄 NEW FILE · app4.py

"""app4.py — a web form that really sends an email."""
import os

from flask import Flask, render_template, request

from mailer import send_email      # the file you wrote in tutorial 8

app = Flask(__name__)


@app.route("/", methods=["GET", "POST"])
def send_page():
    """Show the form. When the form comes back, send the email."""
    message = ""

    if request.method == "POST":
        sent = send_email(
            os.environ["MAIL_USER"],
            os.environ["MAIL_PASS"],
            request.form["to"],
            request.form["subject"],
            request.form["body"],
        )
        message = "Sent to " + sent

    return render_template("send.html", message=message)


if __name__ == "__main__":
    app.run(debug=True, port=5000)
LineWhat it does · ทำอะไร · 做什么
@app.route("/", methods=["GET", "POST"])One address, two jobs. Without this list, the form gets 405.ที่อยู่เดียว สองหน้าที่ ถ้าไม่มีลิสต์นี้ ฟอร์มจะได้ 405一个地址,两件事。没有这个列表,表单会收到 405。
from mailer import send_emailThe file from tutorial 8 of the email page. You wrote it. Nothing changes.ไฟล์จากบทที่ 8 ของหน้าอีเมล คุณเขียนมันเอง ไม่ต้องแก้อะไรเลย邮件那页第 8 课的文件。你写的,一个字都不用改。
if request.method == "POST":Did they just look, or did they press the button? This tells them apart.เขาแค่เข้ามาดู หรือกดปุ่มไปแล้ว บรรทัดนี้แยกสองอย่างนี้ออกจากกัน他们只是来看看,还是按了按钮?这行把两者分开。
request.form["to"]The boxes come back by name. name="to" in the HTML becomes this.ช่องกรอกส่งกลับมาด้วยชื่อ name="to" ใน HTML กลายเป็นอันนี้输入框按名字送回来。HTML 里的 name="to" 就成了这个。
<form method="post">Without method="post" the boxes go into the address bar instead.ถ้าไม่มี method="post" ช่องกรอกจะไปโผล่ในแถบที่อยู่แทน没有 method="post",内容就会跑到地址栏里去。
YOUR PASSWORD IS NOT IN THIS FILE The form asks for the message, never for the password. That still comes from MAIL_USER and MAIL_PASS in your terminal. Never ask a visitor for an email password. That is how people get robbed. Yours never sees one. ฟอร์มถามแค่ข้อความ ไม่เคยถามรหัสผ่าน รหัสยังมาจาก MAIL_USER และ MAIL_PASS ใน terminal ของคุณ หน้าเว็บที่ถามรหัสผ่านอีเมลจากผู้เข้าชม คือวิธีที่คนโดนขโมย ของคุณไม่เคยเห็นรหัสเลย 表单只问内容,从不问密码。密码仍然来自终端里的 MAIL_USERMAIL_PASS。一个跟访客要邮箱密码的网页,就是人们被骗的方式。你的页面根本看不到密码。
YOUR TURN
  1. Copy mailer.py in beside your app.
  2. Set MAIL_USER and MAIL_PASS in the terminal, then run app4.py.
  3. Fill the form in with your own address and press Send.
  4. Check: the page says Sent to, and the email arrives.
  5. Press Send twice. Two emails. Later you will want to stop that.
  1. คัดลอก mailer.py มาไว้ข้าง ๆ แอปของคุณ
  2. ตั้งค่า MAIL_USER และ MAIL_PASS ใน terminal แล้วรัน app4.py
  3. กรอกฟอร์มด้วยอีเมลของคุณเอง แล้วกด Send
  4. ตรวจ: หน้าเว็บขึ้นว่า Sent to และอีเมลมาถึงจริง
  5. ลองกด Send สองครั้ง จะได้สองฉบับ ทีหลังคุณจะอยากกันไม่ให้เป็นแบบนั้น
  1. mailer.py 复制到你的应用旁边。
  2. 在终端里设好 MAIL_USERMAIL_PASS,然后运行 app4.py
  3. 用你自己的邮箱填表单,按 Send。
  4. 检查:页面显示 Sent to,邮件也到了。
  5. 按两次 Send,会来两封。以后你会想阻止这件事。

5 🤖 Let the AI write it, and you approve · ให้ AI เขียน แล้วคุณอนุมัติ · 让 AI 来写,你来批准

🇬🇧 English

The last one. Type rough notes, and the AI writes the email for you.

Two buttons, one form. They share a name and differ by value. So the function knows which was pressed.

The draft lands in the box you can edit. Nothing is sent until you press Send. Same rule as the email page. The machine types. The person decides.

🇹🇭 ไทย

บทสุดท้าย พิมพ์โน้ตคร่าว ๆ แล้ว AI จะเขียนอีเมลให้คุณ

สองปุ่มในฟอร์มเดียว ใช้ชื่อเดียวกันแต่ค่าต่างกัน ฟังก์ชันจึงรู้ว่าปุ่มไหนถูกกด

ร่างจะมาลงในช่องที่คุณแก้ได้ ไม่มีอะไรถูกส่งจนกว่าคุณจะกด Send กฎเดียวกับหน้าอีเมล เครื่องเป็นคนพิมพ์ คนเป็นคนตัดสินใจ

🇨🇳 中文

最后一课:打几行粗略的笔记,AI 替你把邮件写出来。

一个表单里两个按钮。它们名字相同、值不同,函数就能分辨是哪个被按了。

草稿会落进一个你能编辑的框里。你不按 Send,什么都不会发出。这和邮件那页的规则一样:机器负责打字,人负责决定。

📄 NEW FILE · templates/draft.html

<!doctype html>
<title>Write with help</title>
<h1>Write with help</h1>

{% if message %}
  <p><strong>{{ message }}</strong></p>
{% endif %}

<form method="post">
  <p>To: <input name="to" size="40" value="{{ to }}" required></p>
  <p>Subject: <input name="subject" size="40" value="{{ subject }}" required></p>
  <p>Your rough notes, or the draft:</p>
  <p><textarea name="body" rows="10" cols="60" required>{{ body }}</textarea></p>
  <p>
    <button name="action" value="draft">Ask the AI</button>
    <button name="action" value="send">Send</button>
  </p>
</form>

📄 NEW FILE · app5.py

"""app5.py — write rough notes, let the AI tidy them, then send."""
import json
import os
import urllib.request

from flask import Flask, render_template, request

from mailer import send_email

app = Flask(__name__)

OLLAMA = "http://localhost:11434/api/chat"
MODEL = "hermes3:8b"

TIDY_PROMPT = """Turn these rough notes into a short, polite email.
Keep it under six sentences. Do not invent facts. Write only the email body.

=== NOTES ===
{notes}
=== END OF NOTES ==="""


def ask_local_model(prompt):
    """Send the prompt to Ollama on this computer, and return the answer."""
    body = json.dumps({"model": MODEL, "stream": False,
                       "messages": [{"role": "user", "content": prompt}]}).encode()
    # Call it req, not request. Flask already gave us a request.
    req = urllib.request.Request(OLLAMA, data=body,
                                 headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=300) as answer:
        return json.load(answer)["message"]["content"]


@app.route("/", methods=["GET", "POST"])
def write():
    """One page, three jobs: show the form, draft with the AI, or send."""
    form = request.form
    body = form.get("body", "")
    message = ""

    if form.get("action") == "draft":
        body = ask_local_model(TIDY_PROMPT.format(notes=body))
        message = "Here is the draft. Change anything you like, then press Send."

    elif form.get("action") == "send":
        sent = send_email(os.environ["MAIL_USER"], os.environ["MAIL_PASS"],
                          form["to"], form["subject"], body)
        message = "Sent to " + sent

    return render_template("draft.html", message=message, body=body,
                           to=form.get("to", ""), subject=form.get("subject", ""))


if __name__ == "__main__":
    app.run(debug=True, port=5000)
LineWhat it does · ทำอะไร · 做什么
<button name="action" value="draft">Two buttons, one name, two values. The value says which one you pressed.สองปุ่ม ชื่อเดียว สองค่า ค่านั้นบอกว่าคุณกดปุ่มไหน两个按钮,一个名字,两个值。值告诉你按的是哪个。
if form.get("action") == "draft":get does not crash when the key is missing. The first visit has no form at all.get ไม่พังเวลาไม่มีคีย์นั้น การเข้าครั้งแรกไม่มีฟอร์มเลยสักอัน键不存在时 get 不会崩。第一次访问根本没有表单。
req = urllib.request.Request(...)Called req, not request. Flask already gave us a name called request.ตั้งชื่อว่า req ไม่ใช่ request เพราะ Flask ให้ชื่อ request มาแล้วreq,不叫 request。Flask 已经给了我们一个叫 request 的名字。
<textarea name="body" ...>{{ body }}</textarea>The draft comes back into the box. You can fix it before sending.ร่างจะกลับมาอยู่ในช่อง คุณจึงแก้ได้ก่อนส่ง草稿回到框里,让你在发送前修改。
IT LEFT A BLANK AGAIN Our real run turned four scrappy notes into a decent email. Then it wrote Dear [Name]. The model also added a Subject line. The prompt had said body only. It is a good writer and a poor rule follower. So the draft is editable, and Send is a separate button. การรันจริงของเราเปลี่ยนโน้ตสี่บรรทัดให้เป็นอีเมลที่ใช้ได้ แล้วขึ้นต้นว่า Dear [Name] โมเดลยังใส่บรรทัด Subject มาด้วย ทั้งที่พรอมป์ตบอกให้เขียนแค่เนื้อความ มันเขียนเก่ง แต่ทำตามกฎไม่เก่ง นี่แหละคือเหตุผลที่ร่างต้องแก้ไขได้ และทำไม Send ถึงเป็นปุ่มแยก 我们真跑了一次:四行潦草笔记变成了一封像样的邮件,开头却是 Dear [Name]。模型还加了一行 Subject,尽管提示词说只写正文。它文笔不错,守规矩不行。这正是草稿要可编辑、Send 要单独一个按钮的原因。
DO NOT PUT THIS ON THE INTERNET YET When it starts, Flask prints: WARNING: This is a development server. Do not use it in a production deployment. Believe it. This app has no password on it. Anyone who can reach the address can send email as you. On your own machine at 127.0.0.1 that is nobody. Keep it that way for now. ตอนเริ่มทำงาน Flask จะพิมพ์ว่า WARNING: This is a development server. Do not use it in a production deployment. เชื่อมันเถอะ แอปนี้ไม่มีรหัสผ่านป้องกัน ใครที่เข้าถึงที่อยู่นี้ได้ ก็ส่งอีเมลในนามคุณได้ บนเครื่องของคุณเองที่ 127.0.0.1 นั่นแปลว่าไม่มีใคร เก็บไว้แบบนี้ก่อน 启动时 Flask 会打印:WARNING: This is a development server. Do not use it in a production deployment.相信它。这个应用没有任何密码。凡是能访问到这个地址的人,都能以你的名义发邮件。在你自己机器的 127.0.0.1 上,那等于没有人。暂时就保持这样。
YOUR TURN
  1. Create both files and run app5.py.
  2. Type a few rough notes. Press Ask the AI and wait.
  3. Check: the notes become a proper email in the box.
  4. Fix anything the model left wrong, then press Send.
  5. Ask it to write in Thai instead. Change the prompt, not the code.
  1. สร้างไฟล์ทั้งสอง แล้วรัน app5.py
  2. พิมพ์โน้ตคร่าว ๆ สองสามบรรทัด กด Ask the AI แล้วรอ
  3. ตรวจ: โน้ตกลายเป็นอีเมลที่เรียบร้อยอยู่ในช่อง
  4. แก้ส่วนที่โมเดลทำผิด แล้วกด Send
  5. ลองให้มันเขียนเป็นภาษาไทยดู แก้ที่พรอมป์ต ไม่ใช่ที่โค้ด
  1. 两个文件都建好,运行 app5.py
  2. 打几行粗略的笔记,按 Ask the AI,等一下。
  3. 检查:笔记在框里变成了一封像样的邮件。
  4. 把模型写错的地方改掉,然后按 Send。
  5. 让它改用泰语写。改提示词,别改代码。

6 🔊 Give it a voice · ใส่เสียงให้มัน · 给它一个声音

🇬🇧 English

Your page can read. Now it can talk.

Piper is a speaking voice that runs on your computer. You download a voice file once. After that it needs no key, no account, and no internet.

A voice is two files. The .onnx is the voice. The .json beside it tells Piper how to use it.

🇹🇭 ไทย

หน้าเว็บของคุณอ่านได้แล้ว คราวนี้ให้มันพูดบ้าง

Piper คือเสียงพูดที่ทำงานบนเครื่องของคุณ ดาวน์โหลดไฟล์เสียงครั้งเดียว หลังจากนั้นไม่ต้องมีคีย์ ไม่ต้องมีบัญชี และไม่ต้องมีอินเทอร์เน็ต

เสียงหนึ่งเสียงคือไฟล์สองไฟล์ ไฟล์ .onnx คือตัวเสียง ส่วน .json ข้าง ๆ บอก Piper ว่าจะใช้มันอย่างไร

🇨🇳 中文

你的页面会读了,现在让它会说。

Piper 是跑在你自己电脑上的说话声音。语音文件只下载一次,之后不需要密钥、不需要账号,也不需要网络。

一个语音是两个文件。.onnx 是语音本身,旁边的 .json 告诉 Piper 怎么用它。

⌨️ TERMINAL · install it, then fetch a voice

python -m pip install piper-tts
python -m piper.download_voices en_US-lessac-medium --download-dir voices   # about 60 MB, once

📄 NEW FILE · speak1.py

"""speak1.py — turn a sentence into sound, without leaving your computer."""
import wave

from piper import PiperVoice

VOICE = "voices/en_US-lessac-medium.onnx"


def speak(text, voice_path, out_path):
    """Load a voice, read the text, and save it as a wav file."""
    voice = PiperVoice.load(voice_path)
    with wave.open(out_path, "wb") as wav:
        voice.synthesize_wav(text, wav)
    return out_path


if __name__ == "__main__":
    saved = speak("Good morning, teacher. This never left your computer.",
                  VOICE, "hello.wav")
    print("saved:", saved)
LineWhat it does · ทำอะไร · 做什么
from piper import PiperVoiceOne class does everything. No key, no account, no address.คลาสเดียวทำได้ทุกอย่าง ไม่ต้องมีคีย์ ไม่ต้องมีบัญชี ไม่ต้องมีที่อยู่เว็บ一个类就够了。不用密钥、不用账号、不用网址。
VOICE = "voices/en_US-lessac-medium.onnx"The file you downloaded. Point at the .onnx, not the .json.ไฟล์ที่คุณดาวน์โหลดมา ชี้ไปที่ .onnx ไม่ใช่ .json你下载的那个文件。指向 .onnx,不是 .json。
voice = PiperVoice.load(voice_path)Reading the voice into memory. This is the slow line. Step 7 is about that.โหลดเสียงเข้าหน่วยความจำ บรรทัดนี้ช้า ขั้นที่ 7 จะพูดถึงเรื่องนี้把语音读进内存。这是慢的那一行,第 7 步就讲这个。
with wave.open(out_path, "wb") as wav:Piper writes a wav, not an mp3. wave comes with Python.Piper เขียนไฟล์ wav ไม่ใช่ mp3 โมดูล wave มากับ Python อยู่แล้วPiper 写的是 wav,不是 mp3。wave 是 Python 自带的。
voice.synthesize_wav(text, wav)One line, and the words become sound.บรรทัดเดียว คำก็กลายเป็นเสียง一行,文字就变成了声音。

🇬🇧 English

There are 177 voices to choose from. Here are ones worth knowing.

🇹🇭 ไทย

มีเสียงให้เลือก 177 เสียง นี่คือเสียงที่ควรรู้จัก

🇨🇳 中文

一共有 177 个语音可选。下面是值得知道的几个。

('Voice file', 'ไฟล์เสียง', '语音文件')('Sounds like', 'ฟังดูเป็น', '听起来像')('Good for', 'เหมาะกับ', '适合')
en_US-lessac-mediumAmerican, clearอเมริกัน ชัดเจน美式,清晰The safe first choiceตัวเลือกแรกที่ปลอดภัย第一个安全的选择
en_US-lessac-highThe same, betterตัวเดิม แต่ดีกว่า同一个,更好Slower, bigger fileช้ากว่า ไฟล์ใหญ่กว่า更慢,文件更大
en_US-amy-mediumAmerican womanอเมริกัน ผู้หญิง美式女声A second speakerผู้พูดคนที่สอง第二个说话人
en_US-ryan-highAmerican manอเมริกัน ผู้ชาย美式男声Dialogue pairsบทสนทนาคู่对话搭档
en_GB-alba-mediumScottishสกอต苏格兰Accent practiceฝึกฟังสำเนียง口音练习
en_GB-cori-highBritish womanอังกฤษ ผู้หญิง英式女声Listening practiceฝึกการฟัง听力练习
en_GB-alan-mediumBritish manอังกฤษ ผู้ชาย英式男声Listening practiceฝึกการฟัง听力练习
en_GB-northern_english_male-mediumNorthern Englishอังกฤษตอนเหนือ英格兰北部Accent practiceฝึกฟังสำเนียง口音练习
zh_CN-huayan-mediumMandarin womanจีนกลาง ผู้หญิง普通话女声Chinese studentsนักเรียนจีน中国学生
zh_CN-xiao_ya-mediumMandarin, youngerจีนกลาง เสียงเด็กกว่า普通话,更年轻Chinese studentsนักเรียนจีน中国学生
NOTHING LEAVES THE MACHINE The AI in step 5 ran here too. So does this. That is not an accident. A page that reads email aloud is reading someone else's words. Keep both halves local and you never explain where the text went. AI ในขั้นที่ 5 ก็ทำงานที่นี่ อันนี้ก็เหมือนกัน และไม่ใช่เรื่องบังเอิญ หน้าเว็บที่อ่านอีเมลออกเสียง กำลังอ่านคำพูดของคนอื่น ถ้าเก็บทั้งสองส่วนไว้ในเครื่อง คุณก็ไม่ต้องอธิบายว่าข้อความไปไหน 第 5 步的 AI 也跑在本地,这个也是。这不是巧合。一个会朗读邮件的页面,念的是别人的话。两半都留在本地,你就不用解释文字去了哪里。
YOUR TURN
  1. Install Piper, then download en_US-lessac-medium.
  2. Create speak1.py and run it.
  3. Check: hello.wav appears. Play it.
  4. Turn your wifi off and run it again. It still works.
  5. Download a second voice and change VOICE.
  1. ติดตั้ง Piper แล้วดาวน์โหลด en_US-lessac-medium
  2. สร้าง speak1.py แล้วรัน
  3. ตรวจ: ไฟล์ hello.wav จะโผล่มา ลองเปิดฟัง
  4. ลองปิดไวไฟแล้วรันใหม่ มันก็ยังทำงานได้
  5. ดาวน์โหลดเสียงที่สอง แล้วเปลี่ยนค่า VOICE
  1. 安装 Piper,然后下载 en_US-lessac-medium
  2. 新建 speak1.py 并运行。
  3. 检查:出现 hello.wav,放来听听。
  4. 把 wifi 关掉再跑一次,它照样能用。
  5. 再下载一个语音,改掉 VOICE

7 ⏱️ Load once, speak often · โหลดครั้งเดียว พูดได้เรื่อย ๆ · 加载一次,反复说

🇬🇧 English

Loading a voice is slow. Speaking with it is fast. Those two facts decide your code.

So load the voice once, outside the loop. Then use it as often as you like.

A second language is a second voice file, and nothing else.

🇹🇭 ไทย

การโหลดเสียงนั้นช้า แต่การพูดด้วยเสียงนั้นเร็ว สองข้อนี้เป็นตัวกำหนดว่าโค้ดคุณควรเป็นอย่างไร

ฉะนั้นโหลดเสียงครั้งเดียว ไว้นอกลูป แล้วใช้มันบ่อยแค่ไหนก็ได้

ภาษาที่สองก็คือไฟล์เสียงที่สอง ไม่มีอะไรมากกว่านั้น

🇨🇳 中文

加载语音很慢,用它说话很快。这两件事决定了你的代码该怎么写。

所以把语音加载一次,放在循环外面,之后想用多少次都行。

第二种语言就是第二个语音文件,没有别的。

📄 NEW FILE · speak2.py

"""speak2.py — two languages, and the one habit that makes this fast."""
import sys
import time
import wave

from piper import PiperVoice

# Windows terminals need this before they can print Chinese.
sys.stdout.reconfigure(encoding="utf-8")

ENGLISH = "voices/en_US-lessac-medium.onnx"
CHINESE = "voices/zh_CN-huayan-medium.onnx"

LESSON = [
    "Good morning, teacher.",
    "Open your book, please.",
    "Today we read about Chiang Mai.",
]


def main():
    """Load a voice once, then use it for every line."""
    start = time.time()
    voice = PiperVoice.load(ENGLISH)
    print("loading the voice took", round(time.time() - start, 1), "seconds")

    start = time.time()
    for number, line in enumerate(LESSON, start=1):
        with wave.open("lesson" + str(number) + ".wav", "wb") as wav:
            voice.synthesize_wav(line, wav)
        print("saved: lesson" + str(number) + ".wav |", line)
    print("saying all three took", round(time.time() - start, 1), "seconds")

    # A different language needs a different voice file, and nothing else.
    chinese = PiperVoice.load(CHINESE)
    with wave.open("hello_zh.wav", "wb") as wav:
        chinese.synthesize_wav("老师,早上好。", wav)
    print("saved: hello_zh.wav | 老师,早上好。")


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
voice = PiperVoice.load(ENGLISH)Outside the loop, on purpose. Put it inside and every line pays again.อยู่นอกลูป โดยตั้งใจ ถ้าเอาไว้ข้างใน ทุกบรรทัดจะต้องจ่ายค่าโหลดใหม่故意放在循环外。放进去的话,每一行都要再付一次代价。
for number, line in enumerate(LESSON, start=1):enumerate counts for you, so the files get useful names.enumerate นับให้คุณ ไฟล์จึงได้ชื่อที่มีความหมายenumerate 替你计数,文件就能有像样的名字。
chinese = PiperVoice.load(CHINESE)A different language. Same two lines of code.คนละภาษา แต่โค้ดสองบรรทัดเดิม换一种语言,代码还是那两行。
sys.stdout.reconfigure(encoding="utf-8")Without this a Windows terminal crashes when it prints Chinese.ถ้าไม่มีบรรทัดนี้ terminal ของ Windows จะพังตอนพิมพ์ภาษาจีน没有这一行,Windows 终端打印中文时会崩掉。
('What we timed', 'เราจับเวลาอะไร', '我们测了什么')('Took', 'ใช้เวลา', '用时')
Loading the voice, onceโหลดเสียง หนึ่งครั้ง加载语音,一次1.6s
Saying three sentencesพูดสามประโยค说三句话0.3s
LOAD ONCE, SPEAK OFTEN Loading took five times longer than all three sentences together. Move that line inside the loop. Thirty sentences then take a minute, not three seconds. The next step shows what this means for a web page. การโหลดใช้เวลามากกว่าการพูดครบทั้งสามประโยคถึงห้าเท่า ถ้าย้ายบรรทัดนั้นเข้าไปในลูป สามสิบประโยคจะใช้เวลาหนึ่งนาที แทนที่จะเป็นสามวินาที ขั้นต่อไปจะแสดงว่าเรื่องนี้มีความหมายอย่างไรกับหน้าเว็บ 加载花的时间,是说完三句话的五倍。把那一行挪进循环里,三十句就要一分钟,而不是三秒。下一步会讲这件事对网页意味着什么。
YOUR TURN
  1. Download zh_CN-huayan-medium into the same folder.
  2. Create speak2.py and run it.
  3. Check: three lesson files, and one Chinese file.
  4. Move the PiperVoice.load line inside the loop. Time it again.
  5. Add ten more sentences and watch the difference grow.
  1. ดาวน์โหลด zh_CN-huayan-medium ไว้ในโฟลเดอร์เดียวกัน
  2. สร้าง speak2.py แล้วรัน
  3. ตรวจ: ได้ไฟล์บทเรียนสามไฟล์ และไฟล์ภาษาจีนหนึ่งไฟล์
  4. ลองย้ายบรรทัด PiperVoice.load เข้าไปในลูป แล้วจับเวลาใหม่
  5. เพิ่มประโยคอีกสิบประโยค แล้วดูว่าต่างกันมากขึ้นแค่ไหน
  1. zh_CN-huayan-medium 下载到同一个文件夹。
  2. 新建 speak2.py 并运行。
  3. 检查:三个课文文件,一个中文文件。
  4. PiperVoice.load 挪进循环里,再计一次时。
  5. 再加十句,看看差距怎么变大。

8 🎧 Put the voice on the page · เอาเสียงมาไว้บนหน้าเว็บ · 把声音放到页面上

🇬🇧 English

Now put the voice on the page. Type a sentence, choose a speaker, press Listen.

A web page makes step 7 urgent. Load the voice inside the route and every visitor waits again. So we keep the loaded voices in a dictionary.

Flask serves anything inside static with no route from you. That is where the sound goes.

🇹🇭 ไทย

คราวนี้เอาเสียงมาไว้บนหน้าเว็บ พิมพ์ประโยค เลือกคนพูด แล้วกด Listen

หน้าเว็บทำให้เรื่องในขั้นที่ 7 เร่งด่วนขึ้น ถ้าโหลดเสียงไว้ในเส้นทาง ผู้เข้าชมทุกคนต้องรอใหม่ เราจึงเก็บเสียงที่โหลดแล้วไว้ใน dictionary

Flask เสิร์ฟทุกอย่างในโฟลเดอร์ static โดยที่คุณไม่ต้องเขียนเส้นทางเลย ไฟล์เสียงจะไปอยู่ตรงนั้น

🇨🇳 中文

现在把声音放到页面上。输入一句话,挑一个说话人,按 Listen。

网页让第 7 步变得紧迫。把加载放在路由里,每个访客都要重新等。所以我们把加载好的语音存进一个字典。

Flask 会自动发布 static 里的东西,你不用写路由。声音就放那里。

📄 NEW FILE · templates/speak.html

<!doctype html>
<title>Make it talk</title>
<h1>Make it talk</h1>

<form method="post">
  <p><textarea name="text" rows="3" cols="50" required>{{ text }}</textarea></p>
  <p>
    <select name="voice">
      {% for path, label in voices %}
        <option value="{{ path }}"{% if path == voice %} selected{% endif %}>{{ label }}</option>
      {% endfor %}
    </select>
    <button type="submit">Listen</button>
  </p>
</form>

{% if audio %}
  <audio controls autoplay src="{{ url_for('static', filename=audio) }}"></audio>
{% endif %}

📄 NEW FILE · app6.py

"""app6.py — type a sentence, pick a voice, and hear it in the browser."""
import time
import wave
from pathlib import Path

from flask import Flask, render_template, request
from piper import PiperVoice

app = Flask(__name__)

STATIC = Path("static")

VOICES = [
    ("voices/en_US-lessac-medium.onnx", "American, clear"),
    ("voices/en_GB-alba-medium.onnx", "Scottish"),
    ("voices/zh_CN-huayan-medium.onnx", "Mandarin"),
]

loaded = {}


def get_voice(path):
    """Load a voice the first time it is asked for, then keep it."""
    if path not in loaded:
        loaded[path] = PiperVoice.load(path)
    return loaded[path]


@app.route("/", methods=["GET", "POST"])
def talk():
    """Show the form. When it comes back, make the sound file."""
    audio = ""
    text = request.form.get("text", "Good morning, teacher.")
    voice = request.form.get("voice", VOICES[0][0])

    if request.method == "POST":
        STATIC.mkdir(exist_ok=True)
        # A new name every time, or the browser plays the old sound again.
        audio = "say-" + str(int(time.time() * 1000)) + ".wav"
        with wave.open(str(STATIC / audio), "wb") as wav:
            get_voice(voice).synthesize_wav(text, wav)

    return render_template("speak.html", voices=VOICES, audio=audio,
                           text=text, voice=voice)


if __name__ == "__main__":
    app.run(debug=True, port=5000)
LineWhat it does · ทำอะไร · 做什么
loaded = {}The cache. Empty when the server starts, and it fills as people ask.แคช ว่างเปล่าตอนเซิร์ฟเวอร์เริ่ม แล้วค่อย ๆ เต็มเมื่อมีคนขอ缓存。服务器启动时是空的,有人来就慢慢填满。
if path not in loaded:Load only the first time. Every later visitor gets it free.โหลดแค่ครั้งแรก ผู้เข้าชมที่มาทีหลังได้ใช้ฟรี只在第一次加载。之后来的访客都是白得的。
STATIC = Path("static")The folder Flask already knows. Call it anything else and nothing is served.โฟลเดอร์ที่ Flask รู้จักอยู่แล้ว ถ้าตั้งชื่ออื่น จะไม่มีอะไรถูกเสิร์ฟFlask 早就认得这个文件夹。叫别的名字就什么都发布不了。
audio = "say-" + str(int(time.time() * 1000)) + ".wav"A fresh name each time. Reuse one and the browser replays the old sound.ชื่อใหม่ทุกครั้ง ถ้าใช้ชื่อเดิม เบราว์เซอร์จะเล่นเสียงเก่าซ้ำ每次都用新名字。重复用同一个,浏览器会放回旧声音。
{% for path, label in voices %}The dropdown builds itself. Add a voice to the list and the menu grows.ดรอปดาวน์สร้างตัวเอง เพิ่มเสียงลงในลิสต์ เมนูก็ยาวขึ้น下拉框自己生成。往列表里加个语音,菜单就变长。
{{ url_for('static', filename=audio) }}Let Flask write the address. Never type /static/ by hand.ให้ Flask เขียนที่อยู่ให้ อย่าพิมพ์ /static/ เอง让 Flask 写地址。别手打 /static/
('Pressing Listen', 'ตอนกด Listen', '按下 Listen 时')('Took', 'ใช้เวลา', '用时')
First time with that voiceครั้งแรกของเสียงนั้น那个语音的第一次1.74s
That voice already loadedเสียงนั้นโหลดไว้แล้ว那个语音已经加载过0.05s

🇬🇧 English

Thirty times faster, from six lines of dictionary. The first visitor pays, and nobody else does.

🇹🇭 ไทย

เร็วขึ้นสามสิบเท่า จากโค้ด dictionary หกบรรทัด ผู้เข้าชมคนแรกเป็นคนจ่าย คนอื่นไม่ต้องจ่าย

🇨🇳 中文

快了三十倍,只用了六行字典代码。第一个访客付出代价,别人都不用。

THE FOLDER FILLS UP Every press of Listen leaves a wav on your disk for ever. A class of thirty makes three hundred files in an hour. A real app deletes the old ones. For learning, empty the folder yourself now and then. การกด Listen ทุกครั้งจะทิ้งไฟล์ wav ไว้บนดิสก์ตลอดไป นักเรียนสามสิบคนทำไฟล์ได้สามร้อยไฟล์ในหนึ่งชั่วโมง แอปจริงจะลบไฟล์เก่า สำหรับการเรียน ให้ลบโฟลเดอร์เองเป็นระยะ 每按一次 Listen,硬盘上就永远多一个 wav。三十人的班一小时能造出三百个文件。真正的应用会删掉旧的。学习阶段,自己隔一阵清一下文件夹就行。
YOUR TURN
  1. Download en_GB-alba-medium too, so the menu has three.
  2. Create both files, then run app6.py.
  3. Press Listen. The player appears and plays by itself.
  4. Press it again with the same voice. Notice it is instant.
  5. Choose the Chinese voice and type Chinese. It reads it properly.
  6. Move PiperVoice.load into the route and feel the difference.
  1. ดาวน์โหลด en_GB-alba-medium ด้วย เมนูจะได้มีสามเสียง
  2. สร้างไฟล์ทั้งสอง แล้วรัน app6.py
  3. กด Listen ตัวเล่นจะโผล่ขึ้นมาและเล่นเอง
  4. กดอีกครั้งด้วยเสียงเดิม สังเกตว่ามันทันที
  5. เลือกเสียงภาษาจีน แล้วพิมพ์ภาษาจีน มันอ่านได้ถูกต้อง
  6. ลองย้าย PiperVoice.load เข้าไปในเส้นทาง แล้วรู้สึกถึงความต่าง
  1. 也下载 en_GB-alba-medium,菜单就有三个了。
  2. 两个文件都建好,然后运行 app6.py
  3. 按 Listen,播放器出现并自动播放。
  4. 用同一个语音再按一次,注意它是瞬间的。
  5. 选中文语音,输入中文。它能正确念出来。
  6. PiperVoice.load 挪进路由里,感受一下差别。

9 🧠 The model forgets everything · โมเดลลืมทุกอย่าง · 模型什么都不记得

🇬🇧 English

Here is the thing nobody tells you. The model has no memory at all.

Every question arrives as if it were the first. It does not know your name. It does not know what it said a second ago.

So a conversation is not something the model keeps. It is something you keep. You hold a list, and you send the whole list every time.

🇹🇭 ไทย

นี่คือเรื่องที่ไม่มีใครบอกคุณ โมเดลไม่มีความจำเลยแม้แต่นิดเดียว

ทุกคำถามมาถึงมันเหมือนเป็นคำถามแรกเสมอ มันไม่รู้ชื่อคุณ และไม่รู้ว่าเมื่อครู่มันพูดอะไรไป

บทสนทนาจึงไม่ใช่สิ่งที่โมเดลเก็บไว้ แต่เป็นสิ่งที่คุณเก็บ คุณถือลิสต์ไว้ แล้วส่งทั้งลิสต์ไปทุกครั้ง

🇨🇳 中文

这件事没人会告诉你:模型根本没有记忆。

每个问题到它那里都像是第一个问题。它不知道你叫什么,也不知道自己上一秒说过什么。

所以对话不是模型保存的东西,是你保存的。你拿着一个列表,每次都把整个列表发过去。

('Asked the same way', 'ถามแบบเดียวกัน', '同样的问法')('What it answered', 'มันตอบว่าอะไร', '它怎么回答')
Question aloneส่งคำถามไปอย่างเดียว只发问题“I do not have enough information to determine your name”“I do not have enough information to determine your name”“I do not have enough information to determine your name”
With the list before itส่งลิสต์ก่อนหน้าไปด้วย把前面的列表一起发“Your name is David, and you teach English in Chiang Mai”“Your name is David, and you teach English in Chiang Mai”“Your name is David, and you teach English in Chiang Mai”

🇬🇧 English

Both answers are real. Same model, same question. Only the list changed.

🇹🇭 ไทย

คำตอบทั้งสองเป็นของจริง โมเดลเดียวกัน คำถามเดียวกัน เปลี่ยนแค่ลิสต์

🇨🇳 中文

两个回答都是真的。同一个模型,同一个问题。变的只是那个列表。

📄 NEW FILE · chat1.py

"""chat1.py — a conversation that remembers, in the terminal."""
import json
import sys
import urllib.request

sys.stdout.reconfigure(encoding="utf-8")

OLLAMA = "http://localhost:11434/api/chat"
MODEL = "hermes3:8b"


def ask(messages):
    """Send the whole conversation, and return the next reply."""
    body = json.dumps({"model": MODEL, "stream": False,
                       "messages": messages}).encode()
    request = urllib.request.Request(OLLAMA, data=body,
                                     headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(request, timeout=300) as answer:
        return json.load(answer)["message"]["content"]


def main():
    """Keep one list, and grow it with every turn."""
    messages = [{"role": "system",
                 "content": "You are a friendly English tutor. Keep replies short."}]

    print("Talk to the tutor. Type bye to stop.")
    while True:
        said = input("\nyou: ").strip()
        if said.lower() in ("bye", "quit", "exit"):
            break

        messages.append({"role": "user", "content": said})
        reply = ask(messages)
        messages.append({"role": "assistant", "content": reply})

        print("ai:", reply)
        print("   [" + str(len(messages)) + " messages now go with every question]")


if __name__ == "__main__":
    main()
LineWhat it does · ทำอะไร · 做什么
messages = [{"role": "system", ...}]Three roles exist. system sets the rules, user is you, assistant is the model.มีสามบทบาท system ตั้งกฎ user คือคุณ assistant คือโมเดล有三种角色:system 定规则,user 是你,assistant 是模型。
messages.append({"role": "user", ...})Add what you said, before you ask.ใส่สิ่งที่คุณพูดลงไปก่อน แล้วค่อยถาม先把你说的话加进去,再提问。
messages.append({"role": "assistant", ...})Add what it said too. Forget this line and it forgets its own answers.ใส่สิ่งที่มันพูดลงไปด้วย ถ้าลืมบรรทัดนี้ มันจะลืมคำตอบของตัวเอง它说的也要加进去。忘了这行,它就会忘掉自己的回答。
reply = ask(messages)The whole list goes, every single time. That is the trick, and the cost.ลิสต์ทั้งก้อนถูกส่งไปทุกครั้ง นั่นคือเคล็ดลับ และก็คือต้นทุน每一次都发整个列表。诀窍在这里,代价也在这里。
[N messages now go with every question]Watch this number grow. Turn ten sends everything from turn one.ดูตัวเลขนี้โตขึ้นเรื่อย ๆ รอบที่สิบยังส่งทุกอย่างตั้งแต่รอบแรก看着这个数字变大。第十轮还在发第一轮的东西。
MEMORY IS NOT FREE The list grows, and you resend all of it. Turn twenty costs twenty times turn one. On your own computer that is only time. On a paid API it is money. Every chat program you have used trims this list. Yours will too, in the next step. ลิสต์โตขึ้นเรื่อย ๆ และคุณส่งมันไปใหม่ทั้งหมด รอบที่ยี่สิบแพงกว่ารอบแรกยี่สิบเท่า บนเครื่องของคุณเอง เสียแค่เวลา แต่บน API ที่เสียเงิน มันคือเงินจริง โปรแกรมแชททุกตัวที่คุณเคยใช้ตัดลิสต์นี้ทิ้ง ของคุณก็จะตัดเหมือนกันในขั้นต่อไป 列表会变长,而你每次都全部重发。第二十轮的成本是第一轮的二十倍。在你自己电脑上只是时间,在收费 API 上就是钱。你用过的每个聊天程序都会裁剪这个列表,下一步你的也会。
YOUR TURN
  1. Create chat1.py and run it.
  2. Say your name. Then ask what your name is.
  3. Check: it knows. Watch the message count grow.
  4. Now comment out the assistant append line. Run it again.
  5. It forgets its own answers, but still remembers yours. Work out why.
  1. สร้าง chat1.py แล้วรัน
  2. บอกชื่อของคุณ แล้วถามว่าคุณชื่ออะไร
  3. ตรวจ: มันรู้ ลองดูจำนวนข้อความที่เพิ่มขึ้น
  4. คราวนี้ลองคอมเมนต์บรรทัดที่ append assistant ออก แล้วรันใหม่
  5. มันจะลืมคำตอบของตัวเอง แต่ยังจำของคุณได้ ลองคิดดูว่าทำไม
  1. 新建 chat1.py 并运行。
  2. 说出你的名字,然后问它你叫什么。
  3. 检查:它知道。看着消息数变多。
  4. 现在把 append assistant 那行注释掉,再跑一次。
  5. 它会忘掉自己的回答,却仍然记得你的。想想为什么。

10 💬 One conversation for each visitor · หนึ่งบทสนทนาต่อผู้เข้าชมหนึ่งคน · 每个访客一段对话

🇬🇧 English

In the terminal one list was enough. A web page has many visitors.

Flask gives each visitor a session. It is a small box of data that belongs to one browser. Put the list in there. Then two students never see each other's conversation.

The session needs a secret_key. Without one, nothing is remembered.

🇹🇭 ไทย

ใน terminal มีลิสต์เดียวก็พอ แต่หน้าเว็บมีผู้เข้าชมหลายคน

Flask ให้ session กับผู้เข้าชมแต่ละคน มันคือกล่องข้อมูลเล็ก ๆ ที่เป็นของเบราว์เซอร์เดียว เอาลิสต์ไปใส่ไว้ในนั้น แล้วนักเรียนสองคนจะไม่เห็นบทสนทนาของกันและกัน

session ต้องมี secret_key ถ้าไม่มี จะไม่มีอะไรถูกจำไว้เลย

🇨🇳 中文

在终端里一个列表就够了,但网页有很多访客。

Flask 给每个访客一个 session。它是属于某一个浏览器的一小盒数据。把列表放进去,两个学生就永远看不到彼此的对话。

session 需要一个 secret_key。没有它,什么都不会被记住。

📄 NEW FILE · templates/chat.html

<!doctype html>
<title>Talk to the tutor</title>
<h1>Talk to the tutor</h1>

{% for m in messages %}
  <p><strong>{{ "You" if m.role == "user" else "Tutor" }}:</strong> {{ m.content }}</p>
{% else %}
  <p><em>Nothing said yet.</em></p>
{% endfor %}

<form method="post">
  <p><input name="said" size="50" autofocus required></p>
  <p>
    <button type="submit">Send</button>
    <button name="action" value="forget" formnovalidate>Forget everything</button>
  </p>
</form>

📄 NEW FILE · app7.py

"""app7.py — a chat page that remembers, one conversation per visitor."""
import json
import urllib.request

from flask import Flask, render_template, request, session

app = Flask(__name__)
# Without this, session does not work at all. Change it, and keep it secret.
app.secret_key = "put-a-long-random-string-here"

OLLAMA = "http://localhost:11434/api/chat"
MODEL = "hermes3:8b"
SYSTEM = "You are a friendly English tutor. Keep replies to two short sentences."
REMEMBER = 12          # how many past messages to keep


def ask(messages):
    """Send the whole conversation, and return the next reply."""
    body = json.dumps({"model": MODEL, "stream": False,
                       "messages": messages}).encode()
    req = urllib.request.Request(OLLAMA, data=body,
                                 headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=300) as answer:
        return json.load(answer)["message"]["content"]


@app.route("/", methods=["GET", "POST"])
def chat():
    """The messages live in this visitor's session, not in a shared variable."""
    messages = session.get("messages", [])

    if request.method == "POST":
        if request.form.get("action") == "forget":
            session.pop("messages", None)
            return render_template("chat.html", messages=[])

        messages.append({"role": "user", "content": request.form["said"]})
        # The system line leads, then only the newest few turns.
        sent = [{"role": "system", "content": SYSTEM}] + messages[-REMEMBER:]
        messages.append({"role": "assistant", "content": ask(sent)})
        # Keep only the newest few. The whole session rides in a cookie, and a
        # browser throws away any cookie over about 4000 bytes, without a word.
        session["messages"] = messages[-REMEMBER:]

    return render_template("chat.html", messages=messages)


if __name__ == "__main__":
    app.run(debug=True, port=5000)
LineWhat it does · ทำอะไร · 做什么
app.secret_key = "put-a-long-random-string-here"It signs the session, so a visitor cannot edit their own history. Change it.มันใช้เซ็นกำกับ session ผู้เข้าชมจึงแก้ประวัติของตัวเองไม่ได้ อย่าลืมเปลี่ยนค่านี้它给 session 签名,访客就改不了自己的历史。记得换掉它。
messages = session.get("messages", [])This visitor's list. Another browser gets a different one.ลิสต์ของผู้เข้าชมคนนี้ เบราว์เซอร์อื่นจะได้คนละลิสต์这个访客的列表。别的浏览器拿到的是另一个。
sent = [{...system...}] + messages[-REMEMBER:]The rules first, then the newest few turns. Older ones are dropped.กฎมาก่อน แล้วตามด้วยรอบล่าสุดไม่กี่รอบ ที่เก่ากว่านั้นถูกตัดทิ้ง先是规则,再是最新几轮。更旧的就丢掉。
session["messages"] = messages[-REMEMBER:]Store the short list, not the long one. The next box explains why.เก็บลิสต์สั้น ไม่ใช่ลิสต์ยาว กล่องถัดไปจะอธิบายว่าทำไม存短的那个,不是长的。下一个框会解释原因。
session.pop("messages", None)Forget everything. A chat with no way out is a trap.ลืมทุกอย่าง แชทที่ออกไม่ได้คือกับดัก全部忘掉。一个没法重来的聊天是个陷阱。
<button name="action" value="forget" formnovalidate>formnovalidate lets this button work while the box is empty.formnovalidate ทำให้ปุ่มนี้กดได้ แม้ช่องกรอกจะว่างformnovalidate 让这个按钮在输入框为空时也能按。
THE SESSION IS A COOKIE, AND COOKIES ARE TINY Flask packs the whole session into one cookie. A browser silently throws away any cookie over about 4000 bytes. We measured it. With real replies the cookie grew about 360 bytes a turn. It passed the limit at turn eleven. Flask warned us: The 'session' cookie is too large ... Browsers may silently ignore cookies larger than this. The test client never complained. A real browser would just have forgotten everything. Flask ยัดทั้ง session ลงในคุกกี้อันเดียว และเบราว์เซอร์จะทิ้งคุกกี้ที่ใหญ่เกินราว 4000 ไบต์ไปเงียบ ๆ เราวัดมาแล้ว ด้วยคำตอบจริง คุกกี้โตขึ้นราว 360 ไบต์ต่อรอบ และเกินขีดจำกัดตั้งแต่รอบที่สิบเอ็ด Flask เตือนเราว่า The 'session' cookie is too large ... Browsers may silently ignore cookies larger than this. ตัวทดสอบไม่เคยบ่นเลย แต่เบราว์เซอร์จริงจะลืมทุกอย่างทิ้งไปเฉย ๆ Flask 把整个 session 塞进一个 cookie。浏览器会悄悄丢掉超过约 4000 字节的 cookie。我们实测过:用真实回复时,cookie 每轮增长约 360 字节,到第十一轮就越线了。Flask 警告说:The 'session' cookie is too large ... Browsers may silently ignore cookies larger than this. 测试客户端从不抱怨,但真实浏览器会直接把一切都忘掉。
('Kept in the session', 'เก็บใน session แบบไหน', 'session 里存什么')('Cookie after 40 turns', 'คุกกี้หลัง 40 รอบ', '40 轮后的 cookie')('Warnings', 'คำเตือน', '警告')
Everythingทุกอย่าง全部14,473 bytes30
messages[-12:]2,328 bytes0

🇬🇧 English

One line, and the cookie stops growing for ever. The page shows the last twelve messages. A chat window does that anyway.

🇹🇭 ไทย

บรรทัดเดียว คุกกี้ก็หยุดโตตลอดกาล หน้าเว็บจะแสดงสิบสองข้อความล่าสุด ซึ่งหน้าต่างแชททั่วไปก็ทำแบบนี้อยู่แล้ว

🇨🇳 中文

一行代码,cookie 就永远不再变大。页面显示最近十二条消息,聊天窗口本来也就是这样。

YOUR TURN
  1. Create both files, then run app7.py.
  2. Tell it your name. Ask for it back two messages later.
  3. Check: it knows.
  4. Open the page in a private window. That visitor knows nothing about you.
  5. Press Forget everything, then ask again. It is gone.
  6. Set REMEMBER to 2 and try to hold a conversation.
  1. สร้างไฟล์ทั้งสอง แล้วรัน app7.py
  2. บอกชื่อของคุณ แล้วถามกลับอีกสองข้อความถัดมา
  3. ตรวจ: มันรู้
  4. เปิดหน้าเดิมในหน้าต่างส่วนตัว ผู้เข้าชมคนนั้นไม่รู้จักคุณเลย
  5. กด Forget everything แล้วถามใหม่ มันหายไปแล้ว
  6. ลองตั้ง REMEMBER เป็น 2 แล้วพยายามคุยให้ต่อเนื่อง
  1. 两个文件都建好,然后运行 app7.py
  2. 告诉它你的名字,隔两条消息再问回来。
  3. 检查:它知道。
  4. 用无痕窗口打开同一页。那个访客对你一无所知。
  5. 按 Forget everything,再问一次。没了。
  6. REMEMBER 设成 2,再试着聊下去。

Fifteen lines, your own address, and an app password. That is the whole trick. · สิบห้าบรรทัด อีเมลของคุณเอง และ app password หนึ่งอัน เคล็ดลับมีแค่นี้ · 十五行代码、你自己的邮箱地址,加一个应用专用密码。诀窍就这些。

All fourteen files on this page were run on 20 September 2026, on Flask 3.1.3, piper-tts and Python 3.12. speak2 loaded a voice in 1.6 seconds and then spoke three sentences in 0.3, and app6 answered in 1.74 seconds the first time it used a voice and 0.05 seconds afterwards. app4 and app5 each sent a real email through the mailer.py module from the email page, and both arrived. The escaping shown in step 3 was measured, not assumed: the same text that step 2 returns as a live tag comes back from the template as harmless words. The memory steps were checked both ways: asked cold, the model answered “I do not have enough information to determine your name”; asked with its history, “Your name is David, and you teach English in Chiang Mai”.