🇬🇧 English
A class keeps things that belong together in one place. That is the whole idea. Everything else is spelling.
This page teaches classes by building a window. You will type at the prompt first, with no file and no class at all.
Then the same window becomes a class, one piece at a time. Then eight challenges, each with the answer folded away.
🇹🇭 ไทย
คลาสคือการเก็บของที่อยู่ด้วยกันไว้ในที่เดียว นั่นคือแนวคิดทั้งหมด ที่เหลือเป็นแค่การสะกดคำ
หน้านี้สอนคลาสด้วยการสร้างหน้าต่าง คุณจะพิมพ์ที่พรอมต์ก่อน โดยไม่มีไฟล์และไม่มีคลาสเลย
จากนั้นหน้าต่างเดิมจะกลายเป็นคลาส ทีละชิ้น แล้วตามด้วยโจทย์แปดข้อ แต่ละข้อมีเฉลยพับซ่อนไว้
🇨🇳 中文
类就是把该在一起的东西放在一个地方。想法就这么多,其余都是拼写。
这一页用做一个窗口来讲类。你先在提示符下打字,没有文件,也完全没有类。
然后同一个窗口一块一块变成一个类。再然后是八道题,每道题的答案都折起来放着。
| Without a classไม่มีคลาส没有类 | With a classมีคลาส有类 | |
|---|---|---|
| Where the widgets liveวิดเจ็ตอยู่ที่ไหน控件放在哪 | loose in the file, as globalsลอยอยู่ในไฟล์ เป็นตัวแปรโกลบอล散在文件里,当全局变量 | on the object, as self.boxอยู่บนออบเจกต์ เช่น self.box在对象上,写作 self.box |
| How a function reaches themฟังก์ชันเข้าถึงมันยังไง函数怎么够得着 | it must reach outside itselfมันต้องเอื้อมออกไปนอกตัวเอง它得伸到自己外面去 | it is already insideมันอยู่ข้างในอยู่แล้ว它本来就在里面 |
| Two windows at onceสองหน้าต่างพร้อมกัน同时开两个窗口 | they fight over the same namesมันแย่งชื่อเดียวกัน它们抢同一批名字 | each one owns its ownต่างคนต่างเป็นเจ้าของของตัวเอง各有各的一套 |
no display name. Sit at the computer. Everything here works on Windows, macOS and Linux.
tkinter เปิดหน้าต่างจริง ถ้าต่อผ่าน SSH ธรรมดาจะไม่มีที่ให้วางหน้าต่าง แล้ว Python จะบอกว่า no display name ให้นั่งหน้าเครื่องจริง ทุกอย่างในหน้านี้ใช้ได้ทั้ง Windows, macOS และ Linux
tkinter 会开一个真的窗口。走普通 SSH 就没地方放它,Python 会说 no display name。请坐到电脑前。这一页的东西在 Windows、macOS 和 Linux 上都能跑。
⌨️ Tutorial 1 — Drive it by hand first · บทที่ 1 — ลองบังคับมันด้วยมือก่อน · 教程 1 —— 先用手开它
🇬🇧 English
Before any file, before any class. Open Python and make a window appear, one line at a time.
Nothing teaches a library faster than watching it answer you.
🇹🇭 ไทย
ก่อนจะมีไฟล์ ก่อนจะมีคลาส เปิด Python แล้วทำให้หน้าต่างโผล่ขึ้นมา ทีละบรรทัด
ไม่มีอะไรสอนไลบรารีได้เร็วเท่าการเห็นมันตอบคุณ
🇨🇳 中文
在有文件之前,在有类之前。打开 Python,一行一行把一个窗口变出来。
没有什么比看着它当场回答你更快学会一个库。
1 A window in two lines · หน้าต่างในสองบรรทัด · 两行就是一个窗口
🇬🇧 English
Open a terminal and type python on its own. You get three arrows, and Python waits for you.
Type the lines below one at a time. The window appears at the second one, and every line after it changes the window you can see.
🇹🇭 ไทย
เปิดเทอร์มินัลแล้วพิมพ์ python เฉย ๆ คุณจะเห็นลูกศรสามอัน และ Python จะรอคุณอยู่
พิมพ์บรรทัดข้างล่างทีละบรรทัด หน้าต่างจะโผล่ตอนบรรทัดที่สอง และทุกบรรทัดหลังจากนั้นจะเปลี่ยนหน้าต่างที่คุณมองเห็นอยู่
🇨🇳 中文
打开终端,单独输入 python。你会看到三个箭头,Python 在等你。
把下面的行一行一行输进去。第二行时窗口就出现了,之后每一行都在改你眼前那个窗口。
💻 START PYTHON · a prompt, not a file
python # Windows
python3 # macOS and Linux
⌨️ TYPE THESE · one line at a time
>>> import tkinter as tk
>>> root = tk.Tk()
>>> type(root)
<class 'tkinter.Tk'>
>>> root.title("Chat")
''
>>> root.geometry("520x420")
''
>>> root.configure(bg="#f4f4f4")
>>> hello = tk.Label(root, text="Hello", font=("Segoe UI", 20), bg="#f4f4f4")
>>> hello.pack(pady=40)
>>> hello.configure(text="Hello, Chiang Mai")
>>> root.winfo_children()
[<tkinter.Label object .!label>]
>>> root.destroy()
| Line | What it does · ทำอะไร · 做什么 |
|---|---|
| root = tk.Tk() | The window itself. Making it is enough to show it.ตัวหน้าต่างเอง แค่สร้างมันขึ้นมาก็แสดงผลแล้ว窗口本身。造出来就等于显示出来了。 |
| root.title("Chat") | Changes the window that is already open. Watch it happen.เปลี่ยนหน้าต่างที่เปิดอยู่แล้ว ดูมันเปลี่ยนต่อหน้าเลย改的是已经开着的那个窗口。盯着看它变。 |
| '' | The empty string it answers with. Ask with no argument to read it.สตริงว่างที่มันตอบกลับมา ถ้าเรียกโดยไม่ใส่ค่าจะได้ค่าปัจจุบัน它回的空字符串。不带参数地问,就是读当前的值。 |
| hello.pack(pady=40) | Nothing appears until pack is called. Build it and forget, and it is invisible.จะไม่มีอะไรโผล่จนกว่าจะเรียก pack สร้างแล้วลืม มันก็ไม่ปรากฏ不调用 pack 就什么都不会出现。造了却忘了,它就是看不见。 |
| root.winfo_children() | Everything sitting inside the window, as a list. Useful when lost.ทุกอย่างที่อยู่ในหน้าต่าง เป็นลิสต์ มีประโยชน์ตอนหลงทาง窗口里装着的全部东西,一个列表。迷路时很有用。 |
🇬🇧 English
Those same lines, kept in a file, are below. Run it and you get the window in one go.
🇹🇭 ไทย
บรรทัดเดียวกันนั้น เก็บไว้ในไฟล์ อยู่ข้างล่างนี้ รันแล้วจะได้หน้าต่างในทีเดียว
🇨🇳 中文
同样那些行,装进一个文件,就在下面。运行它,窗口一次就出来。
📄 NEW FILE · hand1.py
"""hand1.py — the lines you typed at the terminal, kept in a file.
Nothing here is new. It is exactly the session from step 1, so you can
see the whole thing at once instead of scrolled up the screen.
"""
import tkinter as tk
# Tk() is the window. Making it is enough to put it on the screen.
root = tk.Tk()
# Every one of these changes the window that is already open.
root.title("Chat") # the words in the title bar
root.geometry("520x420") # width by height, in pixels
root.configure(bg="#f4f4f4") # the colour behind everything
# A Label is a piece of text sitting on the window. The first argument says
# which window it belongs to. Nothing appears until pack() is called.
hello = tk.Label(root, text="Hello", font=("Segoe UI", 20), bg="#f4f4f4")
hello.pack(pady=40)
# You can keep changing a widget after it is on screen.
hello.configure(text="Hello, Chiang Mai")
# mainloop hands control to tkinter. It draws the window and waits for keys
# and clicks until the window is closed. At the terminal you do not need it,
# because the prompt keeps the window alive between lines. In a file you do.
root.mainloop()
>>> prompt. Do not paste it into IDLE either. Both add their own indent to lines that already have one. The code arrives as a staircase, and Python refuses it. Two ways that always work. Press ⬇ Download and the file lands in your Downloads folder. Or press 📋 Copy, then type pbpaste > hand1.py in the Terminal. On a Mac the Copy button shows you that exact line after it copies.
อย่าวางทั้งไฟล์ลงในพรอมต์ >>> และอย่าวางลงใน IDLE ด้วย ทั้งสองอย่างจะเติมย่อหน้าของมันเองให้บรรทัดที่มีย่อหน้าอยู่แล้ว โค้ดจึงกลายเป็นขั้นบันได แล้ว Python ก็ไม่รับ มีสองวิธีที่ได้ผลเสมอ กด ⬇ Download แล้วไฟล์จะไปอยู่ในโฟลเดอร์ Downloads หรือกด 📋 Copy แล้วพิมพ์ pbpaste > hand1.py ในเทอร์มินัล บนแมค ปุ่ม Copy จะแสดงบรรทัดนั้นให้คุณเห็นหลังคัดลอกเสร็จ
不要把整个文件粘进 >>> 提示符,也不要粘进 IDLE。这两个都会给本来就有缩进的行再加一层自己的缩进,代码就变成一道楼梯,Python 不收。两个一定管用的办法:按 ⬇ Download,文件就落到你的 Downloads 文件夹;或者按 📋 Copy,然后在终端里打 pbpaste > hand1.py。在 Mac 上,Copy 按钮复制完会把那一行原样显示给你。
mainloop, and the window still worked. Python was waiting for your next line. tkinter used that wait to redraw. A file has nothing to wait for. It reaches the end and closes. That is what root.mainloop() is for.
ตอนอยู่ที่พรอมต์ คุณไม่เคยเรียก mainloop เลย แต่หน้าต่างก็ยังทำงาน เพราะ Python กำลังรอบรรทัดถัดไปของคุณ และ tkinter ใช้ช่วงรอนั้นวาดหน้าจอใหม่ ส่วนไฟล์ไม่มีอะไรให้รอ มันวิ่งถึงบรรทัดสุดท้ายแล้วปิด นั่นคือหน้าที่ของ root.mainloop()
在提示符下你从没调用过 mainloop,窗口照样能用。因为 Python 在等你的下一行,而 tkinter 借着这个等待去重画。文件没有什么好等的,跑到最后一行就关掉了。这就是 root.mainloop() 的用处。
- Type the lines at the prompt. Watch the window change.
- Try
root.geometry("300x700"). A tall thin window. - Try
root.configure(bg="pink")and pick your own colour. - Make a second label. Pack it. Where did it go?
- พิมพ์บรรทัดพวกนี้ที่พรอมต์ แล้วดูหน้าต่างเปลี่ยน
- ลอง
root.geometry("300x700")จะได้หน้าต่างสูงผอม - ลอง
root.configure(bg="pink")แล้วเลือกสีของคุณเอง - สร้างเลเบลอันที่สอง แล้ว pack มันดู มันไปอยู่ตรงไหน
- 在提示符下把这些行打进去,看窗口变。
- 试
root.geometry("300x700"),一个又高又窄的窗口。 - 试
root.configure(bg="pink"),再挑你自己的颜色。 - 再做一个 label,pack 它。它跑哪去了?
2 The same window, with no class · หน้าต่างเดิม แบบไม่มีคลาส · 同一个窗口,不用类
🇬🇧 English
Here is a working chat window with no class in it. Type, press Enter, and your words appear above.
It works. Read the comment above the widgets, because that is the problem this whole page is about.
🇹🇭 ไทย
นี่คือหน้าต่างแชตที่ใช้งานได้จริงโดยไม่มีคลาสเลย พิมพ์ กด Enter แล้วคำของคุณจะโผล่ขึ้นข้างบน
มันใช้ได้ ลองอ่านคอมเมนต์เหนือวิดเจ็ต เพราะนั่นคือปัญหา ที่ทั้งหน้านี้พูดถึง
🇨🇳 中文
这是一个能用的聊天窗口,里面一个类都没有。打字,按回车,你的话就出现在上面。
它能用。读一下控件上面那段注释,因为那正是这一整页要讲的问题。
📄 NEW FILE · hand2.py
"""hand2.py — a working chat window with no class, and the trouble that brings."""
import tkinter as tk
from tkinter import scrolledtext
root = tk.Tk()
root.title("Chat")
root.geometry("520x420")
# Every widget has to be a global. send() below is a separate function, and a
# function cannot see names that live inside another one. Globals are the only
# thing both halves of this file can reach.
transcript = scrolledtext.ScrolledText(root, wrap="word", font=("Segoe UI", 11),
padx=10, pady=10, height=10,
state="disabled")
row = tk.Frame(root)
row.pack(side="bottom", fill="x", padx=10, pady=10)
box = tk.Entry(row, font=("Segoe UI", 11))
box.pack(side="left", fill="x", expand=True, ipady=6)
def send(event=None):
"""Works, but only because box and transcript are globals."""
said = box.get().strip()
if not said:
return
box.delete(0, "end")
transcript.configure(state="normal")
transcript.insert("end", "You: " + said + "\n")
transcript.see("end")
transcript.configure(state="disabled")
tk.Button(row, text="Send", width=8, command=send).pack(side="left", padx=(8, 0))
box.bind("<Return>", send)
box.focus_set()
transcript.pack(side="top", fill="both", expand=True, padx=10, pady=(10, 0))
root.mainloop()
| Line | What it does · ทำอะไร · 做什么 |
|---|---|
| transcript = scrolledtext.ScrolledText( | A global, because send() below has no other way to reach it.เป็นโกลบอล เพราะ send() ข้างล่างไม่มีทางอื่นที่จะเข้าถึงมัน一个全局变量,因为下面的 send() 没有别的办法够到它。 |
| def send(event=None): | event is None from the button. The Enter key sends an object.event เป็น None เมื่อปุ่มเรียก และเป็นออบเจกต์เมื่อปุ่ม Enter เรียก按钮调用时 event 是 None,回车键调用时是一个对象。 |
| command=send | The function itself, with no brackets. Brackets call it right now.ตัวฟังก์ชันเอง ไม่ใส่วงเล็บ ถ้าใส่วงเล็บคือเรียกมันเดี๋ยวนี้เลย函数本身,不加括号。加了括号就是现在就调用它。 |
transcript, and the second one wins. Now every send writes into the same box. There is one way out. Put the widgets somewhere that belongs to one window.
ลองเปิดหน้าต่างแบบนี้สองอันพร้อมกัน ทั้งคู่จะใช้ชื่อ transcript และอันที่สองจะชนะ ทีนี้ send ทุกครั้งก็จะเขียนลงกล่องเดียวกัน ไม่มีทางออกจากเรื่องนี้ได้เลย ถ้าไม่เอาวิดเจ็ตไปเก็บไว้ในที่ที่เป็นของหน้าต่างอันเดียว
试着同时开两个这样的窗口。两个都会用 transcript 这个名字,第二个赢。于是每一次 send 都写进同一个框。只要不把控件放进某个属于单个窗口的地方,这件事就无解。
- Run it. Type something. Press Enter, then click Send.
- Rename
transcripttoteverywhere. Still works? - Now change only the one inside
send. Read the error. - That error is the reason the next tutorial exists.
- รันดู พิมพ์อะไรสักอย่าง กด Enter แล้วลองกดปุ่ม Send
- เปลี่ยนชื่อ
transcriptเป็นtให้หมดทุกที่ ยังใช้ได้ไหม - ทีนี้เปลี่ยนเฉพาะอันที่อยู่ใน
sendแล้วอ่านข้อความผิดพลาด - ข้อผิดพลาดนั้นแหละคือเหตุผลที่มีบทต่อไป
- 运行它。打点字。按回车,再点 Send。
- 把所有
transcript改名成t。还能用吗? - 现在只改
send里面那一个,读那条报错。 - 那条报错就是下一章存在的理由。
🧱 Tutorial 2 — Now make it a class · บทที่ 2 — ทีนี้ทำให้มันเป็นคลาส · 教程 2 —— 现在把它变成类
🇬🇧 English
Four files, and each one adds a single idea. Type them yourself. Copying teaches your fingers, not your head.
We build from the outside in: the file's shape first, the class last.
🇹🇭 ไทย
สี่ไฟล์ แต่ละไฟล์เพิ่มแนวคิดเดียว พิมพ์เองนะ การก๊อปสอนแค่นิ้วมือ ไม่ได้สอนหัว
เราจะสร้างจากข้างนอกเข้าข้างใน รูปร่างของไฟล์ก่อน คลาสทีหลังสุด
🇨🇳 中文
四个文件,每个只加一个想法。自己打。复制粘贴教的是手指,不是脑子。
我们从外往里建:先是文件的形状,最后才是类。
3 The shape of the file, before anything is in it · รูปร่างของไฟล์ ก่อนจะมีอะไรอยู่ในนั้น · 文件的形状,在里面还没东西之前
🇬🇧 English
Every Python program on this site has the same three parts, in the same order.
Imports at the top. The work in the middle. The line that starts it at the bottom.
🇹🇭 ไทย
ทุกโปรแกรม Python บนเว็บนี้มีสามส่วนเหมือนกัน และเรียงเหมือนกัน
อิมพอร์ตอยู่บนสุด งานอยู่ตรงกลาง บรรทัดที่สั่งให้เริ่มอยู่ล่างสุด
🇨🇳 中文
这个网站上每一个 Python 程序都有同样的三段,顺序也一样。
import 在最上面。干活的在中间。让它开始跑的那行在最下面。
📄 NEW FILE · chat1.py
"""chat1.py — the shape of the program, before there is anything in it."""
import tkinter as tk
def main():
"""Make the window, then hand control to tkinter."""
root = tk.Tk()
root.title("Chat")
root.geometry("520x420")
root.mainloop()
# Python sets __name__ to "__main__" only when this file is the one you ran.
# Import it from somewhere else and __name__ becomes "chat1" instead, so main()
# does not fire. That is how a file can be both a program and a toolbox.
if __name__ == "__main__":
main()
| Line | What it does · ทำอะไร · 做什么 |
|---|---|
| import tkinter as tk | as tk gives it a short name. Everything is then tk.something.as tk คือตั้งชื่อสั้นให้มัน จากนั้นทุกอย่างจะเป็น tk.อะไรสักอย่างas tk 给它一个短名字。之后所有东西都是 tk.什么什么。 |
| def main(): | One function that starts everything. Easy to find, easy to read.ฟังก์ชันเดียวที่เริ่มทุกอย่าง หาง่าย อ่านง่าย一个启动一切的函数。好找,好读。 |
| if __name__ == "__main__": | True only when you ran this file. False when something imported it.จริงเฉพาะตอนที่คุณรันไฟล์นี้ เป็นเท็จเมื่อมีอย่างอื่นอิมพอร์ตมัน只有你直接运行这个文件时才为真。被别人 import 时为假。 |
| main() | The only line that runs on its own. Everything else waits to be called.บรรทัดเดียวที่ทำงานเอง ที่เหลือรอให้ถูกเรียกทั้งหมด唯一一行自己会跑的。其余的都在等人来叫。 |
chat4.py. Without that line, importing it opens a window at once. The new file has not said anything yet. The guard lets a file be both program and toolbox.
โจทย์ข้อ 1 ข้างหน้าจะอิมพอร์ตคลาสของคุณจาก chat4.py ถ้าไม่มีบรรทัดนั้น การอิมพอร์ตจะเปิดหน้าต่างขึ้นมาทันที ก่อนที่ไฟล์ใหม่จะได้พูดอะไรเลย บรรทัดนี้คือสิ่งที่ทำให้ไฟล์หนึ่งเป็นได้ทั้งโปรแกรมและกล่องเครื่องมือ
后面第 1 题会从 chat4.py 里 import 你的类。没有那一行,import 的瞬间就会弹出一个窗口,新文件还一句话都没说。正是这道关卡让一个文件既能当程序,又能当工具箱。
- Type it. Run it. An empty window with your title.
- Add
print("starting")insidemain. Run again. - Open Python and type
import chat1. Nothing happens. - Delete the
ifline, import again, and watch.
- พิมพ์เอง รันดู จะได้หน้าต่างว่าง ๆ พร้อมชื่อของคุณ
- ใส่
print("starting")ไว้ในmainแล้วรันอีกที - เปิด Python แล้วพิมพ์
import chat1จะไม่มีอะไรเกิดขึ้น - ลบบรรทัด
ifออก แล้วอิมพอร์ตใหม่ ดูสิ
- 打出来。运行。一个空窗口,带着你的标题。
- 在
main里加print("starting"),再跑一次。 - 打开 Python,输入
import chat1。什么都没发生。 - 把
if那行删掉,再 import 一次,看着。
4 __init__ and self, which are the whole idea · __init__ กับ self ซึ่งคือแนวคิดทั้งหมด · __init__ 和 self,想法全在这
🇬🇧 English
A class is a shape. An object is one thing made in that shape.
ChatWindow(root) makes the object. That call runs __init__, and nothing else, once.
self is the object being built. You never pass it. Python puts it there for you.
🇹🇭 ไทย
คลาสคือแม่พิมพ์ ออบเจกต์คือของหนึ่งชิ้นที่ทำจากแม่พิมพ์นั้น
ChatWindow(root) สร้างออบเจกต์ การเรียกนั้นจะรัน __init__ หนึ่งครั้ง และไม่มีอย่างอื่น
self คือออบเจกต์ที่กำลังถูกสร้าง คุณไม่เคยส่งมันเอง Python ใส่ให้
🇨🇳 中文
类是一个形状。对象是照这个形状做出来的一件东西。
ChatWindow(root) 造出对象。那次调用会运行一次 __init__,别的都不运行。
self 就是正在被造的那个对象。你从不传它,Python 替你放进去。
📄 NEW FILE · chat2.py
"""chat2.py — the same window, but now a class owns it."""
import tkinter as tk
class ChatWindow:
"""Everything the window is made of, kept in one place."""
def __init__(self, root):
"""Runs once, the moment ChatWindow(root) is called.
self is the new object being built. Anything stored on self now can
be read back later by any method in this class, which is the whole
reason the class is here.
"""
self.root = root
root.title("Chat")
root.geometry("520x420")
def main():
root = tk.Tk()
# This line calls __init__ with root. You do not pass self; Python does.
ChatWindow(root)
root.mainloop()
if __name__ == "__main__":
main()
| Line | What it does · ทำอะไร · 做什么 |
|---|---|
| class ChatWindow: | The shape. On its own it does nothing at all.แม่พิมพ์ ตัวมันเองไม่ทำอะไรเลย那个形状。它自己什么都不做。 |
| def __init__(self, root): | Two names here, but callers pass one. Python supplies self.ตรงนี้มีสองชื่อ แต่คนเรียกส่งมาแค่อันเดียว Python ใส่ self ให้这里有两个名字,但调用者只传一个。self 由 Python 提供。 |
| self.root = root | Stored on the object, so a method can find it tomorrow.เก็บไว้บนออบเจกต์ เมธอดอื่นจะได้หามันเจอในวันหลัง存在对象上,这样以后某个方法还找得到它。 |
| ChatWindow(root) | No self here. The brackets are what run __init__.ตรงนี้ไม่มี self วงเล็บนี่แหละที่ทำให้ __init__ ทำงาน这里没有 self。是这对括号让 __init__ 跑起来。 |
| You writeคุณเขียน你写的 | Python runsPython รันPython 跑的 |
|---|---|
ChatWindow(root) | ChatWindow.__init__(new_object, root) |
window.write("hi") | ChatWindow.write(window, "hi") |
me inside one method and it still works. Do not actually keep it. Every Python reader expects self. Surprising them costs more than it saves.
มันเป็นแค่ชื่อ และทุกคนใช้ชื่อเดียวกันจนเป็นนิสัย ลองเปลี่ยนเป็น me ในทุกบรรทัดของเมธอดหนึ่ง เมธอดนั้นก็ยังทำงานได้ แต่อย่าเก็บไว้จริง ๆ คนอ่าน Python ทั้งโลกคาดหวังคำว่า self และการทำให้เขาประหลาดใจนั้นเสียมากกว่าได้
它只是个名字,只是大家习惯上都用同一个。把某个方法里每一行的它都改成 me,那个方法照样能用。但别真留着。全世界读 Python 的人都在等 self 这个词,让他们意外的代价比省下的多。
- Type it and run it. The window looks the same.
- Add
print(self)as the last line of__init__. - In
main, callChatWindow(root)twice. Read both lines. - Two objects, one shape. That is the whole point.
- พิมพ์แล้วรัน หน้าต่างหน้าตาเหมือนเดิม
- ใส่
print(self)เป็นบรรทัดสุดท้ายของ__init__ - ใน
mainเรียกChatWindow(root)สองครั้ง อ่านทั้งสองบรรทัด - สองออบเจกต์ แม่พิมพ์เดียว นั่นแหละประเด็นทั้งหมด
- 打出来,运行。窗口看起来一模一样。
- 在
__init__最后加一行print(self)。 - 在
main里调用两次ChatWindow(root),读那两行。 - 两个对象,一个形状。要点全在这。
5 Widgets that live on self · วิดเจ็ตที่อยู่บน self · 住在 self 上的控件
🇬🇧 English
Now the window gets its pieces. Look at which ones get self. in front and which do not.
The rule is short. Keep it on self if something later needs it. Otherwise let it go.
🇹🇭 ไทย
ทีนี้หน้าต่างจะได้ชิ้นส่วนของมัน ดูให้ดีว่าอันไหนมี self. นำหน้า และอันไหนไม่มี
กฎนั้นสั้นมาก เก็บไว้บน self ถ้ามีอะไรข้างหน้าต้องใช้มัน นอกนั้นปล่อยไป
🇨🇳 中文
现在窗口拿到它的零件了。看清楚哪些前面有 self.,哪些没有。
规则很短:以后还要用的,就留在 self 上。其余的放手。
📄 NEW FILE · chat3.py
"""chat3.py — the pieces of the window, each one kept on self."""
import tkinter as tk
# ScrolledText is a Text box with a scrollbar already attached to it. It lives
# in its own module, so importing tkinter alone is not enough.
from tkinter import scrolledtext
class ChatWindow:
"""Everything the window is made of, kept in one place."""
def __init__(self, root):
self.root = root
root.title("Chat")
root.geometry("520x420")
# self.transcript, because send() will need to write to it later.
# height=10 asks for ten lines. The default is 24, which is taller
# than the window, and it pushes the typing box off the bottom.
self.transcript = scrolledtext.ScrolledText(
root, wrap="word", font=("Segoe UI", 11), padx=10, pady=10,
height=10, state="disabled")
# Plain row, not self.row. Nothing later needs to find this frame
# again, so it does not have to be kept. Store what you will use.
row = tk.Frame(root)
row.pack(side="bottom", fill="x", padx=10, pady=10)
# self.box, because send() will read what was typed into it.
self.box = tk.Entry(row, font=("Segoe UI", 11))
self.box.pack(side="left", fill="x", expand=True, ipady=6)
self.box.focus_set()
# No command yet. The button appears and does nothing when clicked,
# because the method it will call does not exist until chat4.py.
tk.Button(row, text="Send", width=8).pack(side="left", padx=(8, 0))
# Packed last on purpose. pack hands out space in the order the calls
# are made, so the fixed row above is served first, and expand=True
# here means the transcript takes everything still going.
self.transcript.pack(side="top", fill="both", expand=True,
padx=10, pady=(10, 0))
def main():
root = tk.Tk()
ChatWindow(root)
root.mainloop()
if __name__ == "__main__":
main()
| Line | What it does · ทำอะไร · 做什么 |
|---|---|
| self.transcript = scrolledtext.ScrolledText( | On self, because write() will need it in the next step.อยู่บน self เพราะ write() จะต้องใช้มันในขั้นถัดไป放在 self 上,因为下一步的 write() 要用它。 |
| row = tk.Frame(root) | Not on self. Nothing later looks for this frame again.ไม่ได้อยู่บน self เพราะไม่มีอะไรข้างหน้าต้องกลับมาหาเฟรมนี้อีก不在 self 上。后面没有任何东西还要再找这个 frame。 |
| row.pack(side="bottom", fill="x", padx=10, pady=10) | side picks an edge. fill stretches it. padx is space outside.side เลือกขอบ fill ยืดมันออก padx คือช่องว่างด้านนอกside 选一条边。fill 把它拉开。padx 是外面的空隙。 |
| self.transcript.pack(side="top", fill="both", expand=True, | Packed last on purpose. The fixed row above is served first.จงใจ pack เป็นอันสุดท้าย แถวที่ความสูงคงที่ข้างบนได้ที่ก่อน故意最后 pack。上面那个固定高度的行先分到地方。 |
- Run it. The Send button does nothing yet. That is expected.
- Change
height=10toheight=24. What vanished? - Move the
self.transcript.packline aboverow.pack. - Put it back. Order matters more than you think.
- รันดู ปุ่ม Send ยังไม่ทำอะไร นั่นถูกแล้ว
- เปลี่ยน
height=10เป็นheight=24อะไรหายไป - ย้ายบรรทัด
self.transcript.packขึ้นไปเหนือrow.pack - แล้วย้ายกลับ ลำดับสำคัญกว่าที่คุณคิด
- 运行。Send 按钮还什么都不做,这是对的。
- 把
height=10改成height=24。什么不见了? - 把
self.transcript.pack那行移到row.pack上面。 - 再移回来。顺序比你以为的更重要。
6 Methods, and the window finally does something · เมธอด แล้วหน้าต่างก็เริ่มทำอะไรสักที · 方法,窗口终于动起来了
🇬🇧 English
A method is a function that lives in a class. It gets self, so it can reach everything the window owns.
Two of them finish the job. write puts text in the box. send takes what you typed.
🇹🇭 ไทย
เมธอดคือฟังก์ชันที่อยู่ในคลาส มันได้ self มาด้วย จึงเอื้อมถึงทุกอย่างที่หน้าต่างเป็นเจ้าของ
สองอันนี้ทำให้งานเสร็จ write ใส่ข้อความลงกล่อง send รับสิ่งที่คุณพิมพ์
🇨🇳 中文
方法就是住在类里的函数。它会拿到 self,所以够得着窗口拥有的一切。
两个方法就收尾了。write 往框里放字,send 接住你打的东西。
📄 FINISHED FILE · chat4.py
"""chat4.py — the methods, and the window finally does something."""
import tkinter as tk
# ScrolledText is a Text box with a scrollbar already attached to it. It lives
# in its own module, so importing tkinter alone is not enough.
from tkinter import scrolledtext
class ChatWindow:
"""Everything the window is made of, kept in one place."""
def __init__(self, root):
# root is the window itself. Every widget must say which thing it sits
# inside, and that is what the first argument to each one below is.
self.root = root
root.title("Chat") # the words in the title bar
root.geometry("520x420") # width by height, in pixels
# wrap="word" breaks long lines at spaces instead of mid-word.
# state="disabled" makes it read-only, so a click cannot type into it.
# height=10 asks for ten lines. The default is 24, which is taller
# than the window, and it pushes the typing box off the bottom.
self.transcript = scrolledtext.ScrolledText(
root, wrap="word", font=("Segoe UI", 11), padx=10, pady=10,
height=10, state="disabled")
# A Frame is an invisible box for grouping widgets. This one holds the
# typing box and the button, so they sit side by side on one row.
row = tk.Frame(root)
# pack() is what actually puts a widget on screen. Build it and forget
# to pack it and it simply never appears, with no error to tell you.
# side - which edge of the space left over it goes against
# fill - stretch to fill that space: "x", "y" or "both"
# padx - space outside the widget. ipady is space inside it.
row.pack(side="bottom", fill="x", padx=10, pady=10)
# Entry is one line of text. Text and ScrolledText hold many.
# expand=True gives it the leftover width, so it grows with the window.
self.box = tk.Entry(row, font=("Segoe UI", 11))
self.box.pack(side="left", fill="x", expand=True, ipady=6)
# bind ties an event to a function. "<Return>" is the Enter key, and
# tkinter hands the function an event object, which is why send takes
# one. focus_set puts the cursor here, so you can type straight away.
self.box.bind("<Return>", self.send)
self.box.focus_set()
# command takes the function itself, with no brackets after it. Write
# command=self.send() and Python calls send now, once, and hands the
# button whatever it returned. The button then does nothing on a click.
tk.Button(row, text="Send", width=8, command=self.send).pack(
side="left", padx=(8, 0))
# Packed last on purpose. pack gives each widget its space in the order
# the calls are made, so the fixed row above is served first, and
# expand=True here means the transcript takes everything still going.
self.transcript.pack(side="top", fill="both", expand=True,
padx=10, pady=(10, 0))
def write(self, text):
"""Put text in the transcript. It is read-only until we open it."""
self.transcript.configure(state="normal")
# "end" is a text index, not a number. Others are "1.0" for the very
# start, and "2.5" for line two, character five.
self.transcript.insert("end", text)
# Scroll down so the newest line is showing.
self.transcript.see("end")
self.transcript.configure(state="disabled")
def send(self, event=None):
"""Take what was typed and show it. No AI here yet.
event is None when the button calls this, and an event object when the
Enter key does. Nothing here needs it, but the argument has to exist.
"""
said = self.box.get().strip()
if not said:
return
# Entry counts from 0 to "end". This empties the box.
self.box.delete(0, "end")
self.write("You: " + said + "\n")
def main():
# Tk() makes the window. One per program.
root = tk.Tk()
ChatWindow(root)
# mainloop draws the window and waits for keys and clicks, over and over,
# until the window closes. Nothing after this line runs until then, and
# anything slow called from inside it stops the drawing. That is the whole
# difficulty of writing a window, and the next step is about it.
root.mainloop()
if __name__ == "__main__":
main()
| Line | What it does · ทำอะไร · 做什么 |
|---|---|
| def write(self, text): | self first, always. Callers write window.write(text) and skip it.self มาก่อนเสมอ คนเรียกเขียน window.write(text) แล้วข้ามมันไปself 永远在最前。调用者写 window.write(text),把它跳过。 |
| self.transcript.configure(state="normal") | Read-only boxes refuse writing. Open it, write, close it again.กล่องอ่านอย่างเดียวจะไม่ยอมให้เขียน เปิดมัน เขียน แล้วปิดใหม่只读的框不让写。打开它,写完,再关上。 |
| self.transcript.insert("end", text) | end is a text index, not a number. So is 1.0 and 2.5.end คือดัชนีข้อความ ไม่ใช่ตัวเลข เช่นเดียวกับ 1.0 และ 2.5end 是文本索引,不是数字。1.0 和 2.5 也是。 |
| self.box.bind("<Return>", self.send) | The Enter key calls the same method the button calls.ปุ่ม Enter เรียกเมธอดเดียวกับที่ปุ่มกดเรียก回车键调用的,和按钮调用的是同一个方法。 |
| command=self.send | No brackets. With them Python calls send now and hands back None.ไม่มีวงเล็บ ถ้าใส่ Python จะเรียก send เดี๋ยวนี้แล้วคืนค่า None กลับมา不加括号。加了的话 Python 现在就调用 send,然后把 None 交回去。 |
🖥️ WHAT IT DID · typed, then read back out of the widget
transcript = 'You: sawasdee krub'
command=self.send hands over the function. command=self.send() calls it immediately, once, while the window is still being built. The button then gets what it returned, which is None. Clicking does nothing, forever.
command=self.send คือส่งตัวฟังก์ชันไป ส่วน command=self.send() คือเรียกมันทันทีหนึ่งครั้ง ตอนที่หน้าต่างยังสร้างไม่เสร็จ แล้วปุ่มจะได้ค่าที่มันคืนมา ซึ่งก็คือ None และการคลิกจะไม่ทำอะไรเลยตลอดไป
command=self.send 交过去的是函数本身。command=self.send() 是在窗口还在搭的时候就立刻调用它一次,然后按钮拿到它的返回值,也就是 None,从此点它永远没反应。
- Run it. Type, press Enter. Type, click Send.
- Add brackets to
command=self.send. Click. Nothing. - Take them off again.
- Make
sendalso write the time.import timefirst.
- รันดู พิมพ์แล้วกด Enter พิมพ์แล้วกดปุ่ม Send
- ใส่วงเล็บให้
command=self.sendแล้วคลิก ไม่มีอะไรเกิดขึ้น - เอาวงเล็บออก
- ทำให้
sendเขียนเวลาด้วย อิมพอร์ตtimeก่อน
- 运行。打字,按回车。再打字,点 Send。
- 给
command=self.send加上括号。点一下。没反应。 - 再把括号去掉。
- 让
send也写上时间。先import time。
📐 Tutorial 3 — Move things, and wire buttons · บทที่ 3 — ขยับของ และต่อสายปุ่ม · 教程 3 —— 挪东西,接按钮
🇬🇧 English
You now have a window that works. This is the part where you make it yours.
Change one thing, run it, look. Then change the next one.
🇹🇭 ไทย
ตอนนี้คุณมีหน้าต่างที่ใช้งานได้แล้ว ส่วนนี้คือการทำให้มันเป็นของคุณ
เปลี่ยนทีละอย่าง รันดู มองให้เห็น แล้วค่อยเปลี่ยนอันต่อไป
🇨🇳 中文
你现在有一个能用的窗口了。这一章是把它变成你的。
改一样,跑一次,看一眼。然后再改下一样。
7 One change at a time · เปลี่ยนทีละอย่าง · 一次改一样
🇬🇧 English
Everything below goes in __init__, in chat4.py. Try them in order.
🇹🇭 ไทย
ทุกอย่างข้างล่างนี้ใส่ใน __init__ ของ chat4.py ลองไล่ตามลำดับ
🇨🇳 中文
下面这些都放进 chat4.py 的 __init__ 里。按顺序试。
| To changeจะเปลี่ยน要改的 | Write thisเขียนแบบนี้这样写 | What happensจะเกิดอะไร会怎样 |
|---|---|---|
| The sizeขนาด大小 | root.geometry("800x600") | Width by height, in pixelsกว้างคูณสูง เป็นพิกเซล宽乘高,单位像素 |
| Where it opensเปิดตรงไหน在哪打开 | root.geometry("520x420+100+50") | The two extra numbers are the cornerตัวเลขสองตัวหลังคือมุมบนซ้าย多出来的两个数是左上角 |
| Stop resizingห้ามย่อขยาย不许改大小 | root.resizable(False, False) | Width and height, each separatelyกว้างและสูง แยกกันคนละอัน宽和高,各管各的 |
| The fontฟอนต์字体 | font=("Georgia", 14) | Any widget that shows textวิดเจ็ตไหนก็ได้ที่แสดงข้อความ任何显示文字的控件 |
| Coloursสี颜色 | bg="#222", fg="#eee" | Background and foregroundพื้นหลังและสีตัวอักษร背景色和前景色 |
| Space outsideช่องว่างด้านนอก外面的空隙 | padx=20, pady=20 | Goes in the pack callใส่ตอนเรียก pack写在 pack 调用里 |
| Space insideช่องว่างด้านใน里面的空隙 | ipadx=20, ipady=20 | Makes the widget itself fatterทำให้ตัววิดเจ็ตอ้วนขึ้น让控件本身变胖 |
| Which edgeชิดขอบไหน靠哪条边 | side="left" | top, bottom, left or righttop, bottom, left หรือ righttop、bottom、left 或 right |
| Fill the spaceเติมเต็มพื้นที่填满空间 | fill="both", expand=True | fill stretches, expand claims leftoversfill ยืดออก ส่วน expand ขอพื้นที่ที่เหลือfill 拉伸,expand 去抢剩下的 |
🇬🇧 English
Two ways to place things, and they must never meet.
🇹🇭 ไทย
มีสองวิธีในการวางของ และทั้งสองต้องไม่เจอกันเด็ดขาด
🇨🇳 中文
摆放东西有两种办法,而它们绝不能碰面。
| packpackpack | gridgridgrid | |
|---|---|---|
| Thinks inคิดเป็น它的思路 | edges and leftover spaceขอบ และพื้นที่ที่เหลือ边,和剩下的空间 | rows and columnsแถวและคอลัมน์行和列 |
| Good forเหมาะกับ适合 | toolbars, stacks, whole panelsแถบเครื่องมือ การวางซ้อน แผงทั้งแผง工具栏、堆叠、整块面板 | forms with labels beside boxesฟอร์มที่มีป้ายอยู่ข้างช่องกรอก标签挨着输入框的表单 |
| Together?ใช้ร่วมกันได้ไหม能一起用吗 | never in one frameห้ามในเฟรมเดียวกันเด็ดขาด同一个 frame 里绝对不行 | never in one frameห้ามในเฟรมเดียวกันเด็ดขาด同一个 frame 里绝对不行 |
cannot use geometry manager grid inside . which already has slaves managed by pack. Separate frames may each use whichever they like.
ในเฟรมเดียว เลือกอย่างเดียว ถ้าใช้ทั้งคู่ tkinter จะหยุดพร้อมข้อความผิดพลาด: cannot use geometry manager grid inside . which already has slaves managed by pack ส่วนเฟรมคนละอันจะใช้แบบไหนก็ได้ตามใจ
一个 frame 里,只能选一个。两个都用,tkinter 就会报错并停下:cannot use geometry manager grid inside . which already has slaves managed by pack。不同的 frame 各用各的,没问题。
🇬🇧 English
Three ways to make something happen when a person acts.
🇹🇭 ไทย
สามวิธีที่จะทำให้บางอย่างเกิดขึ้นเมื่อมีคนลงมือ
🇨🇳 中文
让人一动手就发生点什么,有三种办法。
| Line | What it does · ทำอะไร · 做什么 |
|---|---|
| command=self.send | For buttons. The function itself, never with brackets.สำหรับปุ่ม ใส่ตัวฟังก์ชันเอง ห้ามใส่วงเล็บ给按钮用。函数本身,绝不加括号。 |
| self.box.bind("<Return>", self.send) | For keys. The function must accept one extra argument.สำหรับปุ่มบนคีย์บอร์ด ฟังก์ชันต้องรับอาร์กิวเมนต์เพิ่มอีกหนึ่งตัว给按键用。函数必须多收一个参数。 |
| command=lambda: self.show_price("ETH_THB") | When you need to pass something. lambda makes a function on the spot.เมื่อคุณต้องส่งค่าอะไรสักอย่าง lambda สร้างฟังก์ชันขึ้นมาตรงนั้นเลย当你需要传东西进去。lambda 当场造一个函数。 |
- Make the window 800x600. Run it.
- Give the transcript a dark background and light text.
- Move the Send button to the right edge with
side="right". - Add a second button that clears the transcript.
- ทำให้หน้าต่างเป็น 800x600 แล้วรัน
- ให้กล่องบทสนทนาเป็นพื้นเข้ม ตัวอักษรสว่าง
- ย้ายปุ่ม Send ไปชิดขวาด้วย
side="right" - เพิ่มปุ่มที่สองที่ล้างบทสนทนาทิ้ง
- 把窗口改成 800x600,运行。
- 给记录框换成深底浅字。
- 用
side="right"把 Send 按钮挪到右边。 - 再加一个按钮,把记录清空。
🏆 Tutorial 4 — Eight challenges · บทที่ 4 — โจทย์แปดข้อ · 教程 4 —— 八道题
🇬🇧 English
Each one starts from chat4.py and adds one thing. Try it before you open the answer.
Challenges 1, 2, 3, 5 and 6 use the same trick. A new class says (ChatWindow) and inherits the lot. Challenges 4, 7 and 8 each hold one class instead.
🇹🇭 ไทย
ทุกข้อเริ่มจาก chat4.py แล้วเพิ่มของอย่างหนึ่ง ลองทำเองก่อนเปิดเฉลย
ข้อ 1, 2, 3, 5 และ 6 ใช้ทริกเดียวกัน คือคลาสใหม่ที่เขียนว่า (ChatWindow) แล้วรับทุกอย่างสืบทอดมา ส่วนข้อ 4, 7 และ 8 มีคลาสเดียวแทน
🇨🇳 中文
每一题都从 chat4.py 出发,加一样东西。先自己试,再打开答案。
第 1、2、3、5、6 题用同一个招数:一个新类写上 (ChatWindow),把全套都继承过来。第 4、7、8 题各自只有一个类。
8 Try first. The answers are folded away. · ลองเองก่อน เฉลยพับซ่อนไว้ · 先自己试。答案折起来放着。
🇬🇧 English
Click a solution to open it. Each one was run before it was written down here.
🇹🇭 ไทย
คลิกที่เฉลยเพื่อเปิดดู ทุกอันถูกรันจริงก่อนจะถูกเขียนลงตรงนี้
🇨🇳 中文
点一下答案就展开。每一个都是先跑过,才写到这里来的。
1A button that knows the bitcoin price · ปุ่มที่รู้ราคาบิตคอยน์ · 一个知道比特币价格的按钮
🇬🇧 English
Add a button. When clicked it asks Bitkub for the price, and writes it into the transcript.
The address is https://api.bitkub.com/api/v3/market/ticker?sym=BTC_THB. It answers with a list.
🇹🇭 ไทย
เพิ่มปุ่มหนึ่งปุ่ม เมื่อคลิกให้ไปถาม Bitkub เรื่องราคา แล้วเขียนลงในกล่องบทสนทนา
ที่อยู่คือ https://api.bitkub.com/api/v3/market/ticker?sym=BTC_THB มันตอบกลับมาเป็นลิสต์
🇨🇳 中文
加一个按钮。点它就去问 Bitkub 价格,然后写进记录框。
地址是 https://api.bitkub.com/api/v3/market/ticker?sym=BTC_THB。它回的是一个列表。
Show the answerดูเฉลย看答案
📄 price_button.py
"""price_button.py — challenge 1: a button that asks Bitkub for the price."""
import json
import tkinter as tk
import urllib.request
from chat4 import ChatWindow
BITKUB = "https://api.bitkub.com/api/v3/market/ticker"
class PriceWindow(ChatWindow):
"""ChatWindow, plus a button. Everything else is inherited."""
def __init__(self, root):
# Run the parent's __init__ first. That builds the window, the
# transcript, the typing box and the Send button. Then add to it.
super().__init__(root)
root.title("Chat and coins")
# chat3.py kept the typing row in a plain variable instead of on self,
# so there is no way to reach it from here. A second row is the easy
# answer. Storing it as self.row back then would have been the other.
coins = tk.Frame(root)
coins.pack(side="bottom", fill="x", padx=10, pady=(0, 10))
tk.Button(coins, text="BTC price", width=10,
command=self.show_price).pack(side="left")
tk.Button(coins, text="ETH price", width=10,
command=lambda: self.show_price("ETH_THB")).pack(
side="left", padx=(8, 0))
def show_price(self, symbol="BTC_THB"):
"""Ask Bitkub, and write the answer into the transcript.
lambda is how the second button passes an argument. command wants a
function it can call with nothing, and lambda makes one on the spot.
"""
with urllib.request.urlopen(BITKUB + "?sym=" + symbol,
timeout=30) as response:
coins = json.load(response)
# Bitkub sends back a list, even when you ask about one coin, and
# every number in it arrives as text.
price = float(coins[0]["last"])
self.write(f"1 {symbol.split('_')[0]} = {price:,.2f} baht\n")
def main():
root = tk.Tk()
PriceWindow(root)
root.mainloop()
if __name__ == "__main__":
main()
| Line | What it does · ทำอะไร · 做什么 |
|---|---|
| class PriceWindow(ChatWindow): | The brackets mean: start with everything ChatWindow has.วงเล็บนี้แปลว่า เริ่มจากทุกอย่างที่ ChatWindow มี括号的意思是:从 ChatWindow 有的一切开始。 |
| super().__init__(root) | Run the parent's setup first. Otherwise there is no window yet.รันการตั้งค่าของคลาสแม่ก่อน ไม่งั้นจะไม่มีหน้าต่างให้เพิ่มของ先跑父类的搭建,不然没有窗口可加东西。 |
| self.write( | Inherited. You never wrote this method in this file.ได้รับสืบทอดมา คุณไม่เคยเขียนเมธอดนี้ในไฟล์นี้เลย继承来的。这个文件里你根本没写过这个方法。 |
🖥️ WHAT IT PRINTED · both buttons, against the real Bitkub
1 BTC = 2,805,205.52 baht
1 ETH = 106,725.00 baht
window frozen for 270 ms during the call
2Email me what I typed · ส่งอีเมลสิ่งที่ฉันพิมพ์ · 把我打的字寄给我
🇬🇧 English
A button that emails the typing box to you. Use the code from the email page.
Read the address and password from the environment. Never type a password into a file.
🇹🇭 ไทย
ปุ่มที่ส่งอีเมลข้อความในช่องพิมพ์ไปหาคุณ ใช้โค้ดจากหน้าเรื่องอีเมลได้เลย
อ่านที่อยู่และรหัสผ่านจาก environment อย่าพิมพ์รหัสผ่านลงในไฟล์เด็ดขาด
🇨🇳 中文
一个按钮,把输入框里的内容寄给你自己。用邮件那一页的代码。
从环境变量里读地址和密码。绝不要把密码打进文件里。
Show the answerดูเฉลย看答案
📄 mail_button.py
"""mail_button.py — challenge 2: type a line, press the button, it is sent."""
import os
import smtplib
import tkinter as tk
from email.message import EmailMessage
from chat4 import ChatWindow
SMTP_HOST = "smtp.gmail.com" # yahoo: smtp.mail.yahoo.com
class MailWindow(ChatWindow):
"""ChatWindow, plus a button that emails whatever you typed."""
def __init__(self, root):
super().__init__(root)
root.title("Chat and mail")
# Read once, when the window opens, so a missing password is a
# complaint in the transcript rather than a crash on the first click.
self.user = os.environ.get("MAIL_USER")
self.password = os.environ.get("MAIL_PASS")
mail = tk.Frame(root)
mail.pack(side="bottom", fill="x", padx=10, pady=(0, 10))
tk.Button(mail, text="Email it to me", width=14,
command=self.send_mail).pack(side="left")
if not self.user or not self.password:
self.write("No MAIL_USER or MAIL_PASS set. The button will not work.\n")
def send_mail(self):
"""Send what is in the typing box to yourself."""
said = self.box.get().strip()
if not said:
return
if not self.user or not self.password:
self.write("Still no MAIL_USER or MAIL_PASS.\n")
return
letter = EmailMessage()
letter["From"] = self.user
letter["To"] = self.user
letter["Subject"] = "From my tkinter window"
letter.set_content(said + "\n")
with smtplib.SMTP_SSL(SMTP_HOST, 465) as server:
server.login(self.user, self.password)
server.send_message(letter)
self.box.delete(0, "end")
self.write("Sent to " + self.user + ": " + said + "\n")
def main():
root = tk.Tk()
MailWindow(root)
root.mainloop()
if __name__ == "__main__":
main()
| Line | What it does · ทำอะไร · 做什么 |
|---|---|
| self.user = os.environ.get("MAIL_USER") | Read once, when the window opens, not on every click.อ่านครั้งเดียวตอนเปิดหน้าต่าง ไม่ใช่ทุกครั้งที่คลิก开窗口时读一次,不是每次点击都读。 |
| if not self.user or not self.password: | A missing password becomes a message, not a crash.รหัสผ่านที่หายไปกลายเป็นข้อความบอก ไม่ใช่โปรแกรมพัง少了密码就变成一句提示,不是崩溃。 |
🖥️ WHAT IT PRINTED · one real email, to a real mailbox
Sent to you@yahoo.com: Sent from a tkinter window, for the classes page.
send took 4.9s (the window is frozen for all of it)
3A model that answers, without freezing · โมเดลที่ตอบได้ โดยไม่ค้าง · 一个会回话又不卡住的模型
🇬🇧 English
Send what you typed to Ollama, and show the reply. Keep the whole conversation, so it remembers your name.
This one takes seconds. If you call it the simple way, the window turns white and stops.
🇹🇭 ไทย
ส่งสิ่งที่คุณพิมพ์ไปให้ Ollama แล้วแสดงคำตอบ เก็บบทสนทนาทั้งหมดไว้ มันจะได้จำชื่อคุณได้
ข้อนี้ใช้เวลาเป็นวินาที ถ้าคุณเรียกแบบง่าย ๆ หน้าต่างจะกลายเป็นสีขาวแล้วหยุดนิ่ง
🇨🇳 中文
把你打的字发给 Ollama,再把回复显示出来。把整段对话留着,它才记得你的名字。
这一题要等好几秒。要是你用最直接的写法,窗口会变白、卡死。
Show the answerดูเฉลย看答案
📄 ask_bot.py
"""ask_bot.py — challenge 3: the window talks to Ollama without freezing."""
import json
import queue
import threading
import tkinter as tk
import urllib.request
from chat4 import ChatWindow
OLLAMA = "http://localhost:11434/api/chat"
MODEL = "llama3.2"
class BotWindow(ChatWindow):
"""ChatWindow, but send() also asks a model, on a thread of its own."""
def __init__(self, root):
super().__init__(root)
root.title("Chat with a model")
# The whole conversation so far. Without this the model forgets your
# name the moment it has answered you.
self.history = []
# A thread must never touch a widget. It puts finished text here
# instead, and the main thread takes it out and writes it.
self.answers = queue.Queue()
self.drain()
def send(self, event=None):
"""Show what was typed, then go and ask the model for a reply."""
said = self.box.get().strip()
if not said:
return
self.box.delete(0, "end")
self.write("You: " + said + "\n")
self.history.append({"role": "user", "content": said})
# daemon=True so a half-finished answer cannot keep the program alive
# after the window is closed.
threading.Thread(target=self.ask, args=(list(self.history),),
daemon=True).start()
def ask(self, history):
"""Runs on the worker thread. No widget may be touched from here."""
payload = json.dumps({"model": MODEL, "stream": False,
"messages": history}).encode()
request = urllib.request.Request(
OLLAMA, data=payload, headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(request, timeout=300) as response:
said = json.load(response)["message"]["content"].strip()
except Exception as trouble:
said = "(no answer: " + str(trouble) + ")"
self.answers.put(said)
def drain(self):
"""Runs on the main thread, four times a second, forever."""
while not self.answers.empty():
said = self.answers.get()
self.history.append({"role": "assistant", "content": said})
self.write("Bot: " + said + "\n\n")
# after() books one more run. This is the only safe way for slow work
# to get its answer onto the screen.
self.root.after(250, self.drain)
def main():
root = tk.Tk()
BotWindow(root)
root.mainloop()
if __name__ == "__main__":
main()
| Line | What it does · ทำอะไร · 做什么 |
|---|---|
| def send(self, event=None): | Same name as the parent's. Yours is used instead of it.ชื่อเดียวกับของคลาสแม่ ของคุณจะถูกใช้แทนอันนั้น和父类同名。用的是你这个,不是那个。 |
| threading.Thread(target=self.ask, | The slow work moves off the thread that draws the window.งานที่ช้าถูกย้ายออกจากเธรดที่วาดหน้าต่าง把慢活挪出负责画窗口的那条线程。 |
| self.answers.put(said) | A queue is the post box. A thread must not touch a widget.คิวคือตู้ไปรษณีย์ เธรดต้องไม่แตะวิดเจ็ตเด็ดขาด队列就是信箱。线程绝不能碰控件。 |
| self.root.after(250, self.drain) | Books the next look. This runs on the drawing thread.จองรอบตรวจถัดไป อันนี้ทำงานบนเธรดที่วาดหน้าจอ预约下一次查看。这一句跑在画图的线程上。 |
🖥️ WHAT IT PRINTED · read back out of the widget
You: My name is Nari. Reply in one short sentence.
Bot: Nice to meet you, Nari!
self.write from the worker thread and tkinter complains. The error says: main thread is not in main loop. Or worse, it says nothing and corrupts the display. The queue and after are the way across.
ถ้าเรียก self.write จากเธรดลูก tkinter จะโยน main thread is not in main loop ออกมา หรือแย่กว่านั้น คือไม่พูดอะไรเลยแล้วทำให้หน้าจอเพี้ยน คิวกับ after คือสะพานข้าม
从工人线程里调用 self.write,tkinter 会抛出 main thread is not in main loop,更糟的是一声不吭然后把画面弄乱。队列加 after 才是过河的桥。
4Ask who is who, before the chat starts · ถามก่อนว่าใครเป็นใคร ก่อนเริ่มแชต · 开聊之前,先问清楚谁是谁
🇬🇧 English
Open with a form. Ask for the model's name, your name, and what it should be.
Then clear the form away and start the chat, with all three already known.
An empty box must stop it. Say which one is empty.
🇹🇭 ไทย
เริ่มด้วยฟอร์ม ถามชื่อของโมเดล ชื่อของคุณ และให้มันเป็นอะไร
จากนั้นล้างฟอร์มออกแล้วเริ่มแชต โดยรู้ทั้งสามอย่างแล้ว
ช่องที่ว่างต้องหยุดมันไว้ และบอกด้วยว่าช่องไหนว่าง
🇨🇳 中文
先开一个表单。问模型叫什么、你叫什么、它该是谁。
然后把表单清掉,开始聊天,三样都已经知道了。
有空框就必须拦住,并且说清楚是哪一个空着。
Show the answerดูเฉลย看答案
📄 setup_form.py
"""setup_form.py — challenge 4: ask who is who, then start the chat.
Two windows' worth of work in one window, and one class. A form collects who
the model should be and who you are. Filling it in clears the form away and
the chat takes over, with all three answers already known.
Challenge 7 asks the same questions from a window of its own, and closes it.
Compare the two. This one talks to Ollama directly, so you can see the
conversation being built by hand: a list of messages, with a system message
at the front that never moves.
"""
import json
import os
import queue
# A thread lets the slow model run without freezing the window. A queue is
# how that thread passes its answer back, because a thread must never touch
# a widget itself.
import threading
import tkinter as tk
import urllib.request
# ScrolledText is a Text box with a scrollbar already attached. It lives in
# its own module, so importing tkinter alone is not enough.
from tkinter import scrolledtext
# Where the model runs. os.environ.get takes the value if it is set, and the
# second argument if it is not, so you can point this at another machine
# without editing the file.
OLLAMA = os.environ.get("OLLAMA_URL", "http://localhost:11434/api/chat")
MODEL = os.environ.get("OLLAMA_MODEL", "llama3.2")
# What the form offers before you type your own. The first one is the
# default the window starts with.
ROLES = ["a patient English teacher",
"a Chiang Mai street food guide",
"a Python tutor who never gives the whole answer"]
class Window:
"""One window. It asks three questions, then becomes a chat."""
def __init__(self, kind):
# Keep the kind on self, because send() reads it later to decide
# whether to ask the model or just echo.
self.kind = kind
# Who is who. ask_first() fills these in from the form, and
# start() turns them into the system message.
self.bot_name = "Kru Som"
self.user_name = ""
self.role = ROLES[0]
# The whole conversation so far. Without it the model forgets your
# name the moment it has answered you.
self.history = []
# Tk() is the window. Making it is enough to put it on screen.
self.root = tk.Tk()
self.root.title("Who is who?")
self.root.geometry("520x420") # width by height, in pixels
# state="disabled" makes it read only, so a click cannot type into
# it. write() opens it for a moment when it has something to add.
self.transcript = scrolledtext.ScrolledText(
self.root, wrap="word", font=("Segoe UI", 11), padx=10, pady=10,
height=10, state="disabled")
# A Frame is an invisible box for grouping. This one holds the
# typing box and the button. It is a plain name, not self.row,
# because nothing looks for it again.
row = tk.Frame(self.root)
row.pack(side="bottom", fill="x", padx=10, pady=10)
self.box = tk.Entry(row, font=("Segoe UI", 11))
self.box.pack(side="left", fill="x", expand=True, ipady=6)
# bind ties an event to a function. "<Return>" is the Enter key,
# and tkinter hands the function an event object, which is why
# send takes one it never uses.
self.box.bind("<Return>", self.send)
# command takes the function itself, with no brackets after it.
tk.Button(row, text="Send", width=8, command=self.send).pack(
side="left", padx=(8, 0))
self.transcript.pack(side="top", fill="both", expand=True,
padx=10, pady=(10, 0))
# Only a bot window needs the queue and the drain loop. A chat
# window skips both and just echoes what you type.
if kind == "bot":
self.answers = queue.Queue()
# after() hands back a booking number. Keep it, so close()
# can cancel the one still outstanding.
self.ticking = None
self.drain()
# Clicking the X runs close() instead of destroying the window
# straight away, so the drain loop can be stopped first.
self.root.protocol("WM_DELETE_WINDOW", self.close)
def ask_first(self):
"""Cover the window with a form. It clears itself when filled in."""
# Everything goes in one frame, so clearing the form later is a
# single destroy() instead of hunting down each widget. place()
# with relwidth=1 and relheight=1 lays it over the whole window.
self.form = tk.Frame(self.root, padx=20, pady=20, bg="#f4f4f4")
self.form.place(relx=0, rely=0, relwidth=1, relheight=1)
# columnspan=2 lets the heading run across both columns below it.
tk.Label(self.form, text="Before we start", font=("Segoe UI", 16),
bg="#f4f4f4").grid(row=0, column=0, columnspan=2,
pady=(0, 16), sticky="w")
# grid puts widgets in rows and columns, which is what a form
# wants. pack stacks them against an edge. Never use both in one
# frame; tkinter will wait forever trying to satisfy the two of
# them and the window never finishes drawing. This frame uses grid
# inside itself and is placed from outside, which is allowed,
# because those are two different frames.
self.fields = {}
# enumerate(..., start=1) numbers the rows, leaving row 0 for the
# heading. Your name starts empty on purpose, so the empty-box
# check below has something to catch.
for number, (key, label, start) in enumerate([
("bot_name", "The model's name", self.bot_name),
("user_name", "Your name", self.user_name),
("role", "What should it be?", self.role)], start=1):
tk.Label(self.form, text=label, font=("Segoe UI", 11),
bg="#f4f4f4").grid(row=number, column=0, sticky="e",
padx=(0, 10), pady=6)
entry = tk.Entry(self.form, font=("Segoe UI", 11), width=30)
entry.insert(0, start)
entry.grid(row=number, column=1, sticky="we", pady=6)
# Keep the entry itself, not its text. start() reads them all
# later, when there is something in them.
self.fields[key] = entry
# Let the second column take any extra width when the window grows.
self.form.columnconfigure(1, weight=1)
tk.Button(self.form, text="Start chatting",
command=self.start).grid(row=4, column=1, sticky="e",
pady=(16, 0))
# An empty label now, filled in by start() when a box is empty.
# Making it here means the form never changes shape later.
self.warning = tk.Label(self.form, text="", fg="#b00020",
font=("Segoe UI", 10), bg="#f4f4f4")
self.warning.grid(row=5, column=0, columnspan=2, sticky="w")
self.fields["user_name"].focus_set()
def start(self):
"""Read the form. Complain if a box is empty, otherwise carry on."""
# A dict comprehension reads every entry in one line. strip()
# means a box holding only spaces counts as empty.
answers = {key: entry.get().strip()
for key, entry in self.fields.items()}
# Collect every empty box rather than stopping at the first, so
# the reader is told about all of them at once.
empty = [key for key, value in answers.items() if not value]
if empty:
self.warning.configure(text="Still empty: " + ", ".join(empty))
# return leaves the form up. Nothing below this line runs.
return
self.bot_name = answers["bot_name"]
self.user_name = answers["user_name"]
self.role = answers["role"]
# The whole form goes in one line, because it was one frame.
self.form.destroy()
self.root.title(self.bot_name + " and " + self.user_name)
# A system message is an instruction to the model rather than a
# line of the conversation. It goes first, and it stays first,
# which is why history is rebuilt here rather than appended to.
self.history = [{"role": "system", "content":
"You are " + self.bot_name + ", " + self.role
+ ". You are talking to " + self.user_name
+ ". Use their name sometimes. "
"Keep every reply under three sentences."}]
self.write("You are talking to " + self.bot_name + ".\n\n")
# The form had the cursor. Give it back to the typing box.
self.box.focus_set()
def write(self, text):
"""Put text in the transcript. It is read-only until we open it."""
self.transcript.configure(state="normal")
# "end" is a text index, not a number. Others are "1.0" for the
# very start, and "2.5" for line two, character five.
self.transcript.insert("end", text)
# Scroll down so the newest line is showing.
self.transcript.see("end")
self.transcript.configure(state="disabled")
def send(self, event=None):
"""Show what was typed. If this is a bot window, also ask the model."""
# event is None when the button calls this, and an event object
# when the Enter key does. Nothing here needs it, but the argument
# has to exist for the binding to work.
said = self.box.get().strip()
if not said:
return
# Entry counts from 0 to "end". This empties the box.
self.box.delete(0, "end")
self.write("You: " + said + "\n")
if self.kind == "bot":
self.history.append({"role": "user", "content": said})
# list(self.history) hands the thread a copy. The real list
# keeps growing on this side, and a thread reading a list
# while it changes is a bug waiting for a busy day.
# daemon=True so a half finished answer cannot keep the
# program alive after the window is closed.
threading.Thread(target=self.think, args=(list(self.history),),
daemon=True).start()
def think(self, history):
"""Runs on the worker thread. No widget may be touched from here."""
# stream False means one whole answer comes back, rather than a
# word at a time. Simpler to read, and the drain loop below is
# what keeps the window alive while it waits.
payload = json.dumps({"model": MODEL, "stream": False,
"messages": history}).encode()
request = urllib.request.Request(
OLLAMA, data=payload,
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(request, timeout=300) as answer:
said = json.load(answer)["message"]["content"].strip()
except Exception as trouble:
# Catching everything is right here. Whatever went wrong, the
# window should say so rather than sit there looking busy.
said = "(no answer: " + str(trouble) + ")"
# put() is the only safe way out of this thread. A thread that
# touched a widget would crash tkinter, or worse, corrupt the
# display and say nothing at all.
self.answers.put(said)
def drain(self):
"""Runs on the main thread, and is the only thing that writes."""
# empty() then get() is safe because this is the only reader.
while not self.answers.empty():
said = self.answers.get()
# The model's own replies go into the history too, or it
# forgets what it just said.
self.history.append({"role": "assistant", "content": said})
self.write(self.bot_name + ": " + said + "\n\n")
# after() books one more run in 250ms, and hands back a number
# naming that booking. Keep the number: a booking still
# outstanding when the window goes will fire into a dead window,
# and tkinter prints "invalid command name" at you.
self.ticking = self.root.after(250, self.drain)
def close(self):
"""Stop the drain loop, then take the window down.
Cancelling has to happen before destroy(). A booking fires by
number inside tkinter, so a guard at the top of drain() is too
late: the error happens before Python is reached at all.
"""
if getattr(self, "ticking", None) is not None:
self.root.after_cancel(self.ticking)
self.ticking = None
self.root.destroy()
def run(self):
"""Draw the window and wait for keys and clicks, until it closes."""
# Nothing after this line runs until the window is closed.
self.root.mainloop()
def main():
# Build one, ask who is who, then let it run. Change "bot" to "chat"
# and the same class gives you a window that only echoes.
window = Window("bot")
window.ask_first()
window.run()
if __name__ == "__main__":
main()
| Line | What it does · ทำอะไร · 做什么 |
|---|---|
| self.form.place(relwidth=1, relheight=1) | Lays the form over the whole window, so the chat needs no hiding.วางฟอร์มคลุมทั้งหน้าต่าง แชตข้างล่างจึงไม่ต้องซ่อน把表单铺满整个窗口,底下的聊天就不用藏。 |
| .grid(row=number, column=1, sticky="we", pady=6) | Rows and columns, which is what a form wants. Not pack.แถวและคอลัมน์ ซึ่งคือสิ่งที่ฟอร์มต้องการ ไม่ใช่ pack行和列,这才是表单要的。不是 pack。 |
| self.form.destroy() | The whole form goes in one line, because it was one frame.ฟอร์มทั้งอันหายไปในบรรทัดเดียว เพราะมันเป็นเฟรมเดียว整个表单一行就没了,因为它本来就是一个 frame。 |
| self.history = [{"role": "system", | An instruction to the model, not a line of the chat.คำสั่งถึงโมเดล ไม่ใช่บรรทัดหนึ่งของบทสนทนา这是给模型的指令,不是对话里的一句话。 |
🖥️ WHAT IT PRINTED · empty box refused, then a real answer, 7.5 seconds
refused with an empty name: 'Still empty: user_name'
title after submit : Kru Som and Nari
history[0] is the role : system
You are talking to Kru Som.
You: What is my name, and who are you?
Kru Som: Your name is Nari, and I'm Kru Som, your English teacher. I'll be
helping you with your language skills today, so let's get started!
5Put a web page in the window · เอาหน้าเว็บมาไว้ในหน้าต่าง · 把网页放进窗口
🇬🇧 English
Fetch a page and show it. tkinter has no widget that draws HTML, so you have two honest choices.
Strip the tags and show the words, or hand the address to the browser. Do both.
🇹🇭 ไทย
ดึงหน้าเว็บมาแล้วแสดงผล tkinter ไม่มีวิดเจ็ตที่วาด HTML ได้ คุณจึงมีทางเลือกที่ซื่อตรงสองทาง
ลอกแท็กออกแล้วแสดงแต่คำ หรือส่งที่อยู่ให้เบราว์เซอร์ ทำทั้งสองอย่าง
🇨🇳 中文
抓一个网页下来显示。tkinter 没有能画 HTML 的控件,所以你有两个老实的选择。
把标签剥掉只留字,或者把地址交给浏览器。两个都做。
Show the answerดูเฉลย看答案
📄 show_page.py
"""show_page.py — challenge 5: put a web page in the window.
tkinter cannot draw HTML. There is no widget for it and no module in the
standard library that makes one. So there are two honest answers, and this
file does both: strip the tags and show the words, or hand the address to
the browser, which was built for it.
"""
import re
import tkinter as tk
import urllib.request
import webbrowser
from chat4 import ChatWindow
PAGE = "https://example.com/"
class PageWindow(ChatWindow):
"""ChatWindow, plus two ways of looking at a web page."""
def __init__(self, root):
super().__init__(root)
root.title("Chat and pages")
web = tk.Frame(root)
web.pack(side="bottom", fill="x", padx=10, pady=(0, 10))
tk.Button(web, text="Read it here", width=12,
command=self.read_here).pack(side="left")
tk.Button(web, text="Open in browser", width=14,
command=lambda: webbrowser.open(PAGE)).pack(
side="left", padx=(8, 0))
def read_here(self):
"""Fetch the page, throw the tags away, and show what is left."""
request = urllib.request.Request(
PAGE, headers={"User-Agent": "a python lesson"})
with urllib.request.urlopen(request, timeout=30) as response:
html = response.read().decode("utf-8", errors="replace")
# script and style hold code, not words. Remove those whole, then
# every remaining tag, then squeeze the leftover blank lines.
html = re.sub(r"<(script|style)[\s\S]*?</\1>", " ", html)
words = re.sub(r"<[^>]+>", " ", html)
words = re.sub(r"[ \t]+", " ", words)
words = re.sub(r"\n\s*\n+", "\n\n", words).strip()
self.write("--- " + PAGE + " ---\n" + words + "\n\n")
def main():
root = tk.Tk()
PageWindow(root)
root.mainloop()
if __name__ == "__main__":
main()
| Line | What it does · ทำอะไร · 做什么 |
|---|---|
| re.sub(r"<(script|style)[\s\S]*?</\1>", " ", html) | Those two hold code, not words. Remove them whole first.สองอันนั้นเก็บโค้ดไว้ ไม่ใช่คำ ต้องเอาออกทั้งก้อนก่อน这两个装的是代码不是文字。先整块去掉。 |
| webbrowser.open(PAGE) | One line, and the real browser does what it is good at.บรรทัดเดียว แล้วเบราว์เซอร์จริงก็ทำสิ่งที่มันถนัด一行,真正的浏览器去做它擅长的事。 |
🖥️ WHAT IT PRINTED · the first line of the transcript
--- https://example.com/ --- Example Domain Example Domain This domain is for use in documentation examples without needing permission.
6Show a picture, and keep it · แสดงรูป และเก็บมันไว้ · 显示一张图,并且留住它
🇬🇧 English
Add a button that opens a file picker and shows the picture the person chose.
There is a trap here, and it is the one tkinter question asked most often on the internet.
🇹🇭 ไทย
เพิ่มปุ่มที่เปิดหน้าต่างเลือกไฟล์ แล้วแสดงรูปที่คนเลือก
ตรงนี้มีกับดัก และมันคือคำถามเรื่อง tkinter ที่ถูกถามบ่อยที่สุดบนอินเทอร์เน็ต
🇨🇳 中文
加一个按钮,打开选文件的窗口,显示这个人选的图。
这里有个坑,而且是互联网上关于 tkinter 被问得最多的那一个。
Show the answerดูเฉลย看答案
📄 show_image.py
"""show_image.py — challenge 6: a picture in the window, and the trap in it."""
import sys
import tkinter as tk
from tkinter import filedialog
from chat4 import ChatWindow
class ImageWindow(ChatWindow):
"""ChatWindow, plus a label that can hold a picture."""
def __init__(self, root):
super().__init__(root)
root.title("Chat and pictures")
pictures = tk.Frame(root)
pictures.pack(side="bottom", fill="x", padx=10, pady=(0, 10))
tk.Button(pictures, text="Pick a picture", width=14,
command=self.pick).pack(side="left")
# An empty label now, filled in later. A Label holds either text or
# an image, and swapping one for the other is just configure().
self.picture = tk.Label(root)
self.picture.pack(side="top", pady=(10, 0))
# This is the trap. PhotoImage is the only widget argument Python
# will throw away behind your back: tkinter keeps no reference to it,
# so once the local variable goes the picture is collected and the
# label goes blank, with no error. Keeping it on self is the fix.
self.held = None
def pick(self):
"""Ask for a file, then show it."""
chosen = filedialog.askopenfilename(
title="Pick a PNG or GIF",
filetypes=[("Pictures", "*.png *.gif"), ("All files", "*.*")])
if chosen:
self.show(chosen)
def show(self, path):
"""Put the picture in the label, and hold on to it."""
# PhotoImage reads PNG and GIF on its own. Anything else needs Pillow.
self.held = tk.PhotoImage(file=path)
# subsample(2) shows every second pixel, so the picture is half size.
# There is no resize in plain tkinter; this is the whole toolbox.
if self.held.width() > 460:
self.held = self.held.subsample(self.held.width() // 460 + 1)
self.picture.configure(image=self.held)
self.write("showing " + path + " (" + str(self.held.width()) + "x"
+ str(self.held.height()) + ")\n")
def main():
root = tk.Tk()
window = ImageWindow(root)
# A path on the command line saves picking one every time you test.
if len(sys.argv) > 1:
window.show(sys.argv[1])
root.mainloop()
if __name__ == "__main__":
main()
| Line | What it does · ทำอะไร · 做什么 |
|---|---|
| self.held = tk.PhotoImage(file=path) | Kept on self. A local variable here and the picture vanishes.เก็บไว้บน self ถ้าเป็นตัวแปรท้องถิ่นตรงนี้ รูปจะหายไป存在 self 上。这里要是个局部变量,图就没了。 |
| self.held.subsample( | Shows every second pixel. Plain tkinter cannot resize.แสดงทุกพิกเซลที่สอง tkinter ล้วน ๆ ย่อขยายรูปไม่ได้每隔一个像素取一个。纯 tkinter 不会缩放。 |
🖥️ WHAT IT PRINTED · a real PNG, opened and measured
showing ac_title.png (449x337)
reference held: True
PhotoImage. Store it in a local variable and it is collected. That happens the moment the method ends. The label then shows nothing, with no error. Keeping it on self is the whole fix.
tkinter ไม่ได้เก็บการอ้างอิงถึง PhotoImage ไว้ ถ้าเก็บไว้ในตัวแปรท้องถิ่น Python จะเก็บกวาดมันทันทีที่เมธอดจบ แล้วเลเบลจะไม่แสดงอะไรเลย และไม่มีข้อผิดพลาดด้วย เก็บไว้บน self คือวิธีแก้ทั้งหมด
tkinter 不会替 PhotoImage 保留引用。把它放在局部变量里,方法一结束 Python 就把它回收了。然后 label 什么都不显示,也不报错。存到 self 上就是全部的解法。
7Give the model a tool, and stand back · ให้เครื่องมือกับโมเดล แล้วถอยออกมา · 给模型一个工具,然后退后
🇬🇧 English
Every button so far was wired by you. You decided when it ran.
Now write the price lookup as a @tool and hand it to the model. Ask in English.
Nobody wires anything. The model works out whether it needs the tool, and calls it.
Build two windows. The first one asks for a name and a job. It closes.
The second one is the tutor. It already knows who it is.
🇹🇭 ไทย
ทุกปุ่มที่ผ่านมาคุณเป็นคนต่อสายเอง คุณเป็นคนตัดสินใจว่ามันทำงานเมื่อไหร่
ทีนี้เขียนการดูราคาให้เป็น @tool แล้วยื่นให้โมเดล ถามเป็นภาษาอังกฤษได้เลย
ไม่มีใครต่อสายอะไรทั้งนั้น โมเดลจะคิดเองว่าต้องใช้เครื่องมือไหม แล้วเรียกมันเอง
สร้างสองหน้าต่าง หน้าต่างแรกถามชื่อกับหน้าที่ แล้วมันก็ปิดตัวเอง
หน้าต่างที่สองคือติวเตอร์ และมันรู้แล้วว่าตัวเองเป็นใคร
🇨🇳 中文
到目前为止每个按钮都是你接的线。什么时候跑,你说了算。
现在把查价格写成一个 @tool,递给模型。用英文问它。
没有人接任何线。模型自己判断要不要用这个工具,然后自己调用。
造两个窗口。第一个问名字和职务,然后它关掉自己。
第二个是家教,而它已经知道自己是谁了。
💻 INSTALL IT FIRST
pip install smolagents
Show the answerดูเฉลย看答案
ChatWindow. This one does not. There is a single class here. You pick the kind when you build it. Pass "form" and you get two boxes and a Start button. Pass "tutor" and you get the chat, the model and the tool. Read main(). The form runs first. Filling it in closes it, and run() hands control back. Its answers are still there. Closing a window does not destroy the object. Those two answers go into the second window. It builds them into the agent as its instructions.
โจทย์ข้อ 1 ถึง 6 สร้างคลาสที่สองทับบน ChatWindow ข้อนี้ไม่ได้ทำแบบนั้น ตรงนี้มีคลาสเดียว และคุณบอกมันตอนสร้างว่าต้องการแบบไหน ส่ง "form" ไป คุณจะได้ช่องกรอกสองช่องกับปุ่ม Start ส่ง "tutor" ไป คุณจะได้หน้าแชท โมเดล และเครื่องมือ อ่าน main() ดู ฟอร์มทำงานก่อน พอกรอกเสร็จมันก็ปิดตัวเอง แล้ว run() ก็คืนการควบคุมกลับมา คำตอบยังอยู่ครบ เพราะหน้าต่างที่ปิดไปไม่ได้ทำให้อ็อบเจกต์ที่มันเกาะอยู่หายไปด้วย คำตอบสองอย่างนั้นถูกส่งต่อเข้าหน้าต่างที่สอง ซึ่งจะประกอบมันเป็น instructions ของเอเจนต์
第 1 到第 6 题都在 ChatWindow 上面再造一个类。这一题没有。这里只有一个类,你在造它的时候告诉它要哪一种。传 "form",你得到两个输入框和一个 Start 按钮;传 "tutor",你得到聊天窗、模型和工具。读一下 main():表单先跑,填完它就关掉自己,run() 把控制权交回来。答案还在,因为窗口关掉并不会让挂着它的那个对象消失。这两个答案接着被传进第二个窗口,在那里被组装成智能体的 instructions。
📄 agent_window.py
"""agent_window.py — challenge 7: a tutor that can also check the price.
Two windows, one class. The first one asks who the tutor should be. When you
submit it, that window closes and hands its answers to the second one, which
is the chat. The tutor is a tutor first. It happens to carry one tool, and it
works out for itself whether a question needs it.
Nobody wires that tool to a button. You ask in English and the model decides.
setup = Window("form") first window: the questions
tutor = Window("tutor", setup.bot_name, ...) second window: the chat
"""
import json
import os
import queue
# A thread lets the slow model run without freezing the window. A queue is
# how that thread passes its answer back, because a thread must never touch
# a widget itself.
import threading
import tkinter as tk
import urllib.request
# ScrolledText is a Text box with a scrollbar already attached. It lives in
# its own module, so importing tkinter alone is not enough.
from tkinter import scrolledtext
# ActionStep and FinalAnswerStep are what agent.run hands back as it works.
# describe() below tells them apart.
from smolagents import ActionStep, FinalAnswerStep
from smolagents import OpenAIServerModel, ToolCallingAgent, tool
# Where the model runs. os.environ.get takes the value if it is set, and the
# second argument if it is not, so you can point this at another machine
# without editing the file.
LLM_URL = os.environ.get("LLM_URL", "http://localhost:11434/v1")
# The model matters more than every other line in this file put together.
# Four were tried on the same two questions, twelve runs each:
# llama3.2:3b used the tool fine, but said the past tense of go is "goed"
# gemma2:9b cannot call a tool at all. The server refuses the request
# llama3.1:8b said "went", then wrote its tool call as prose, so it
# ended up telling the user to call the tool themselves
# qwen2.5:14b said "went" every time, and got the price right every time
# So a tutor needs the big one. Challenge 7 on the page has the whole table.
LLM_MODEL = os.environ.get("LLM_MODEL", "qwen2.5:14b")
# The OpenAI client always sends a key. Ollama accepts any word.
LLM_KEY = os.environ.get("LLM_KEY", "ollama")
# A model that cannot work out what to do will keep trying forever. Six
# steps is plenty for one tool, and it fails in a minute instead of never.
MAX_STEPS = 6
# The price service. It answers anyone, with no key and no sign up.
BITKUB = "https://api.bitkub.com/api/v3/market/ticker"
# The form starts on the first of these. They are all teaching jobs,
# because the coin tool is meant to be the sideline, not the point.
ROLES = ["a patient English teacher",
"a maths tutor who works through every step",
"a Python tutor who never gives the whole answer"]
@tool
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
"""
# No double quotes in the line above. That description is copied into
# the tool list the model is shown, and a small model will copy it
# straight back into its answer, quotes and all, which is then not
# valid JSON. It costs a whole step every time.
# Bitkub wants BTC_THB rather than BTC, and shouts at lower case.
pair = symbol.upper().strip() + "_THB"
with urllib.request.urlopen(BITKUB + "?sym=" + pair, timeout=30) as response:
coins = json.load(response)
# An unknown coin comes back as an empty list rather than an error, so
# say so in words. The model reads this line and can try another one.
if not coins:
return "no coin called " + symbol + " on Bitkub"
# Bitkub sends a list even for one coin, and every number arrives as
# text, so index into it and then convert.
price = float(coins[0]["last"])
return "1 " + symbol.upper() + " = " + format(price, ",.2f") + " baht"
def describe(step):
"""Turn one step of the agent's working into a line for the window."""
# The last step carries the answer itself.
if isinstance(step, FinalAnswerStep):
return str(step.output) + "\n\n"
# Every other step is the agent using a tool, or failing to.
if isinstance(step, ActionStep):
if step.error:
# A model that fumbles the JSON gets another go. Say so, or the
# window looks stuck while it quietly tries again.
return f" step {step.step_number}: that went wrong, trying again\n"
# One step can call more than one tool, so join their names.
called = ", ".join(call.name for call in (step.tool_calls or []))
return f" step {step.step_number}: called {called or 'nothing'}\n"
# Other kinds of step exist and are not worth showing. None means
# nothing gets written for this one.
return None
class Window:
"""One class, two kinds. A form that asks, and a tutor that answers."""
def __init__(self, kind, bot_name="", role=""):
# Keep the kind on self. send() and close() both read it later.
self.kind = kind
# The form leaves these empty and fills them in when you submit.
# The tutor is handed them already filled in.
self.bot_name = bot_name
self.role = role
# Tk() is the window. Making it is enough to put it on screen.
self.root = tk.Tk()
# Two kinds, two sets of widgets, one class. Everything below this
# point is shared: write, close, run, and the thread work.
if kind == "form":
self.build_form()
else:
self.build_chat()
# Clicking the X runs close() instead of destroying the window
# straight away, so the drain loop can be stopped first.
self.root.protocol("WM_DELETE_WINDOW", self.close)
# ---- the first window -------------------------------------------
def build_form(self):
"""Three labels, two boxes and a button. No chat widgets at all."""
self.root.title("Who is your tutor?")
self.root.geometry("420x210")
# One frame holds the lot, so the window has a margin without
# every widget carrying its own padding.
frame = tk.Frame(self.root, padx=20, pady=20)
frame.pack(fill="both", expand=True)
# columnspan=2 lets the heading run across both columns below it.
tk.Label(frame, text="Before we start", font=("Segoe UI", 15)).grid(
row=0, column=0, columnspan=2, pady=(0, 14), sticky="w")
# grid puts widgets in rows and columns, which is what a form
# wants. pack stacks them against an edge. Never use both in one
# frame; tkinter will wait forever trying to satisfy the two of
# them and the window never finishes drawing. This frame uses grid
# inside, and is packed into the window from outside, which is
# allowed because those are two different frames.
self.fields = {}
# enumerate(..., start=1) numbers the rows, leaving row 0 for the
# heading. The name starts empty so the check in submit() has
# something to catch.
for number, (key, label, start) in enumerate([
("bot_name", "Its name", ""),
("role", "What is it?", ROLES[0])], start=1):
tk.Label(frame, text=label, font=("Segoe UI", 11)).grid(
row=number, column=0, sticky="e", padx=(0, 10), pady=6)
entry = tk.Entry(frame, font=("Segoe UI", 11), width=28)
entry.insert(0, start)
entry.grid(row=number, column=1, sticky="we", pady=6)
# Keep the entry itself, not its text. submit() reads them
# later, when there is something in them.
self.fields[key] = entry
# Enter submits, the same as clicking the button.
self.fields["bot_name"].bind("<Return>", self.submit)
self.fields["role"].bind("<Return>", self.submit)
# Let the second column take any extra width when the window grows.
frame.columnconfigure(1, weight=1)
tk.Button(frame, text="Start", width=10, command=self.submit).grid(
row=3, column=1, sticky="e", pady=(14, 0))
# An empty label now, filled in by submit() when a box is empty.
# Making it here means the form never changes shape later.
self.warning = tk.Label(frame, text="", fg="#b00020",
font=("Segoe UI", 10))
self.warning.grid(row=4, column=0, columnspan=2, sticky="w")
self.fields["bot_name"].focus_set()
def submit(self, event=None):
"""Read the boxes. If they are filled in, close this window."""
# event is None when the button calls this, and an event object
# when the Enter key does. Nothing here needs it.
# A dict comprehension reads every entry in one line. strip()
# means a box holding only spaces counts as empty.
answers = {key: entry.get().strip()
for key, entry in self.fields.items()}
# Collect every empty box rather than stopping at the first, so
# the reader is told about all of them at once.
empty = [key for key, value in answers.items() if not value]
if empty:
self.warning.configure(text="Still empty: " + ", ".join(empty))
# return leaves the window up. Nothing below this line runs.
return
# Keep the answers on self. This window is about to close, but the
# object survives it, which is how main() reads them afterwards.
self.bot_name = answers["bot_name"]
self.role = answers["role"]
self.close()
# ---- the second window ------------------------------------------
def build_chat(self):
"""The transcript, the typing box, and the model behind them."""
self.root.title(self.bot_name)
self.root.geometry("520x420") # width by height, in pixels
# wrap="word" breaks long lines at spaces instead of mid word.
# state="disabled" makes it read only, so a click cannot type into
# it. write() opens it for a moment when it has something to add.
self.transcript = scrolledtext.ScrolledText(
self.root, wrap="word", font=("Segoe UI", 11), padx=10, pady=10,
height=10, state="disabled")
# A Frame is an invisible box for grouping. This one holds the
# typing box and the button. It is a plain name, not self.row,
# because nothing looks for it again.
row = tk.Frame(self.root)
row.pack(side="bottom", fill="x", padx=10, pady=10)
self.box = tk.Entry(row, font=("Segoe UI", 11))
self.box.pack(side="left", fill="x", expand=True, ipady=6)
# bind ties an event to a function. "<Return>" is the Enter key.
self.box.bind("<Return>", self.send)
self.box.focus_set()
# command takes the function itself, with no brackets after it.
tk.Button(row, text="Send", width=8, command=self.send).pack(
side="left", padx=(8, 0))
# Packed last on purpose, so the fixed row above is served first.
self.transcript.pack(side="top", fill="both", expand=True,
padx=10, pady=(10, 0))
llm = OpenAIServerModel(model_id=LLM_MODEL, api_base=LLM_URL,
api_key=LLM_KEY)
# instructions is how smolagents carries a persona. It reaches the
# model ahead of every question. Teaching comes first here, and
# the tool is mentioned last on purpose: a model told it owns a
# price tool will reach for it when nobody asked.
# The list of tools is the whole permission system. A tool that is
# not in this list cannot be called, however nicely you ask.
self.agent = ToolCallingAgent(
tools=[coin_price], model=llm,
instructions="You are " + self.bot_name + ", " + self.role
+ ". Teach in short, plain English, and keep every "
"reply under three sentences. If, and only if, "
"someone asks what a coin is worth, you may use "
"your coin_price tool to look it up.")
self.answers = queue.Queue()
# after() hands back a booking number. Keep it, so close() can
# cancel the one still outstanding.
self.ticking = None
self.drain()
# ---- shared by both ----------------------------------------------
def write(self, text):
"""Put text in the transcript. It is read-only until we open it."""
self.transcript.configure(state="normal")
# "end" is a text index, not a number. Others are "1.0" for the
# very start, and "2.5" for line two, character five.
self.transcript.insert("end", text)
# Scroll down so the newest line is showing.
self.transcript.see("end")
self.transcript.configure(state="disabled")
def send(self, event=None):
"""Show what was typed, then hand it to the model."""
asked = self.box.get().strip()
if not asked:
return
# Entry counts from 0 to "end". This empties the box.
self.box.delete(0, "end")
self.write("You: " + asked + "\n")
# The model takes seconds. Calling it here would freeze the window
# for all of them, so it goes on a thread of its own. daemon=True
# so a half finished answer cannot keep the program alive after
# the window is closed.
threading.Thread(target=self.think, args=(asked,), daemon=True).start()
def think(self, asked):
"""Runs on the worker thread. No widget may be touched from here.
stream=True hands back each step as it finishes instead of one
answer at the end, so the window can show its working. Without it
a slow model is indistinguishable from a crashed one.
"""
try:
for step in self.agent.run(asked, stream=True, max_steps=MAX_STEPS):
line = describe(step)
# describe returns None for steps not worth showing.
if line:
# put() is the only safe way out of this thread. A
# thread that touched a widget would crash tkinter,
# or worse, corrupt the display and say nothing.
self.answers.put(line)
except Exception as trouble:
# Catching everything is right here. Whatever went wrong, the
# window should say so rather than sit there looking busy.
self.answers.put("no answer: " + str(trouble) + "\n\n")
def drain(self):
"""Runs on the main thread, and is the only thing that writes."""
# empty() then get() is safe because this is the only reader.
while not self.answers.empty():
line = self.answers.get()
# A step line starts with spaces. The answer does not, so it
# is the one that gets the tutor's name in front of it.
if line.startswith(" "):
self.write(line)
else:
self.write(self.bot_name + ": " + line)
# after() books one more run in 250ms, and hands back a number
# naming that booking. Keep the number: a booking still
# outstanding when the window goes will fire into a dead window,
# and tkinter prints "invalid command name" at you.
self.ticking = self.root.after(250, self.drain)
def close(self):
"""Stop the drain loop, then take the window down.
Cancelling has to happen before destroy(). A booking fires by
number inside tkinter, so a guard at the top of drain() is too
late: the error happens before Python is reached at all.
"""
if getattr(self, "ticking", None) is not None:
self.root.after_cancel(self.ticking)
self.ticking = None
self.root.destroy()
def run(self):
"""Draw the window and wait, until this window closes.
For the form that is the moment you press Start. For the tutor it
is the moment you close it, and the program ends there.
"""
self.root.mainloop()
def main():
# First window. run() comes back when the form closes itself.
setup = Window("form")
setup.run()
# Closing the form without filling it in leaves the name empty, and
# there is nothing to teach, so stop here.
if not setup.bot_name:
return
# Second window, handed what the first one collected.
tutor = Window("tutor", setup.bot_name, setup.role)
tutor.write(setup.bot_name + " is ready. Ask it to teach you something.\n")
tutor.write("It can also look up a coin price, if you ask for one.\n\n")
tutor.run()
if __name__ == "__main__":
main()
| Line | What it does · ทำอะไร · 做什么 |
|---|---|
| @tool | Turns a plain function into something a model may call.เปลี่ยนฟังก์ชันธรรมดาให้เป็นสิ่งที่โมเดลเรียกใช้ได้把一个普通函数变成模型可以调用的东西。 |
| def coin_price(symbol: str) -> str: | The types are required. The model reads them to call it right.ต้องระบุชนิดข้อมูล โมเดลอ่านมันเพื่อจะเรียกได้ถูก类型是必须的。模型靠读它们才知道怎么调。 |
| Args: | The docstring is the manual. This is what the model sees.ด็อกสตริงคือคู่มือ นี่คือสิ่งที่โมเดลมองเห็น文档字符串就是说明书。模型看到的就是这个。 |
| symbol: the coin's ticker, for example BTC or ETH | No double quotes here. The careful box below says why.ห้ามมีอัญประกาศคู่ตรงนี้ กล่องคำเตือนข้างล่างบอกเหตุผล这里不要有双引号。下面的警告框说明原因。 |
| ToolCallingAgent(tools=[coin_price], model=llm) | This list is the whole permission system. Not in it, not callable.ลิสต์นี้คือระบบสิทธิ์ทั้งหมด ไม่อยู่ในนี้ก็เรียกไม่ได้这个列表就是全部的权限系统。不在里面就调不了。 |
| LLM_MODEL = os.environ.get("LLM_MODEL", "qwen2.5:14b") | The most important line in the file. The box below has the numbers.บรรทัดที่สำคัญที่สุดในไฟล์ กล่องข้างล่างมีตัวเลขให้ดู这个文件里最要紧的一行。下面那个框里有数字。 |
| setup = Window("form") | The first window. Two boxes, a button, no model at all.หน้าต่างแรก สองช่องกรอก หนึ่งปุ่ม ไม่มีโมเดลเลย第一个窗口。两个输入框、一个按钮,完全没有模型。 |
| if not setup.bot_name: | Closed without filling it in. Nothing to teach, so stop.ปิดไปโดยไม่กรอก ก็ไม่มีอะไรจะสอน จบตรงนี้没填就关了。没什么可教的,就停在这里。 |
| tutor = Window("tutor", setup.bot_name, setup.role) | The handoff. The first window is gone; its answers are not.การส่งต่อ หน้าต่างแรกหายไปแล้ว แต่คำตอบของมันยังอยู่交接。第一个窗口没了,它的答案还在。 |
| for step in self.agent.run(asked, stream=True, | Each step arrives as it finishes. The window can show its working.แต่ละขั้นมาถึงทันทีที่เสร็จ หน้าต่างจึงแสดงสิ่งที่มันกำลังทำได้每一步一完成就送回来,窗口才能把它的过程显示出来。 |
🖥️ WHAT THE FIRST WINDOW DID · the form, driven step by step
AskName can send? False
CoinRoom can send? True
the form window
title : Before we start
has transcript : False
an empty name is refused
warning : 'Please type a name.'
still open : True
typing Nok and pressing Start
window gone : True - can't invoke "winfo" command: application has been destroyed
collected : 'Nok'
the second window, built from that name
title : Coins
greeting : 'Hello Nok. Ask me about a coin.'
🖥️ AND WITH NO SERVER RUNNING · pointed at a dead port on purpose
Hello Nok. Ask me about a coin.
No server at http://127.0.0.1:9/v1
Start one, or set LLM_URL to where yours is.
The vLLM page has the command.
You: What should I learn first?
Bot: no answer from http://127.0.0.1:9/v1
Error while generating output:
Connection error.
If that address is wrong, set LLM_URL.
step 1: called nothing, then Connection error. That names neither the address nor anything to do about it. Worse, the step line read like a choice the model made. The model was never reached at all. The window now asks the server what it serves the moment it opens. A dead address is named before you type a word. Two seconds, and it saves half a minute of silence.
มีคนอ่านเจอปัญหานี้ก่อนที่หน้านี้จะเจอเสียอีก เมื่อก่อนหน้าต่างจะพิมพ์ step 1: called nothing แล้วตามด้วย Connection error ซึ่งไม่ได้บอกทั้งที่อยู่และไม่ได้บอกว่าต้องทำอะไร แย่กว่านั้นคือบรรทัดขั้นตอนนั้นอ่านเหมือนโมเดลเป็นคนเลือก ทั้งที่ยังไปไม่ถึงโมเดลเลยด้วยซ้ำ ตอนนี้หน้าต่างจะถามเซิร์ฟเวอร์ทันทีที่เปิดว่ามันเสิร์ฟอะไรอยู่ ที่อยู่ที่ใช้ไม่ได้จึงถูกบอกตั้งแต่ก่อนคุณพิมพ์ ใช้เวลาสองวินาที และประหยัดความเงียบครึ่งนาทีที่คำถามแรกต้องเสียไป
有读者比这一页更早撞上这个。窗口以前会打出 step 1: called nothing,然后是 Connection error——既没说地址,也没说该怎么办。更糟的是那行步骤读起来像是模型做的选择,可根本就没连上模型。现在窗口一打开就去问服务器它在服务什么,所以地址不通会在你打字之前就被说出来。两秒钟,省掉第一个问题本来要白等的半分钟。
🖥️ WHAT THE SECOND WINDOW SHOWED · the form said Kru Nari, a patient English teacher
Kru Nari is ready. Ask it to teach you something.
It can also look up a coin price, if you ask for one.
You: What is the past tense of go?
step 1: called final_answer
Kru Nari: went
You: What is BTC worth in baht right now?
step 1: called coin_price
step 2: that went wrong, trying again
step 3: called coin_price
step 4: called final_answer
Kru Nari: 1 BTC is worth 2,800,899.96 Thai baht right now.
🇬🇧 English
Compare the two questions. The grammar one used no tool. The price one did.
Nobody told it which. It read the question and decided.
Look at the price steps. You never wrote BTC anywhere. The model took it out of the sentence.
That is the difference between a button and a tool.
Step 2 failed and step 3 tried again. Watch for that. It is normal.
🇹🇭 ไทย
เทียบคำถามสองข้อดู ข้อไวยากรณ์ไม่ได้ใช้เครื่องมือ ข้อราคาใช้
ไม่มีใครบอกมันว่าข้อไหนต้องใช้ มันอ่านคำถามแล้วตัดสินใจเอง
ดูขั้นตอนของข้อราคา คุณไม่เคยเขียน BTC ไว้ที่ไหนเลย โมเดลดึงมันออกมาจากประโยคเอง
นั่นคือความต่างระหว่างปุ่มกับเครื่องมือ
ขั้นที่ 2 พลาด แล้วขั้นที่ 3 ลองใหม่ สังเกตไว้ เรื่องนี้เป็นเรื่องปกติ
🇨🇳 中文
把两个问题对比一下。语法那个没用工具,价格那个用了。
没有人告诉它该用哪个。它读了问题,自己决定。
看价格那几步。你从没在任何地方写过 BTC,是模型自己从句子里挑出来的。
这就是按钮和工具的区别。
第 2 步失败了,第 3 步又试了一次。留意这个,它很正常。
1 BTC = 2,830,217.02 baht, which was right. Then the model handed final_answer the number 1430547.41, which appears nowhere. Not a rounding slip. A different number. Six runs were measured. Five matched the tool exactly. One did not. It happened again later, differently. The tool said 2,796,258.86 and the model answered 27,926,588.86. Ten times too big, from a number it had just been handed. So do not trust the last line on its own. If the number matters, read the called coin_price step above it. That is why this window prints every step.
หนึ่งในหกครั้งเป็นแบบนี้ เครื่องมือทำงานถูกต้อง มันคืนค่า 1 BTC = 2,830,217.02 baht ซึ่งถูก แล้วโมเดลก็ยื่นเลข 1430547.41 ให้ final_answer ซึ่งไม่ปรากฏที่ไหนเลย ไม่ใช่การปัดเศษ แต่เป็นคนละเลข วัดจากคำถามเดียวกันหกครั้ง ห้าครั้งตรงกับเครื่องมือเป๊ะ หนึ่งครั้งไม่ตรง ดังนั้นอย่าเชื่อบรรทัดสุดท้ายเพียงอย่างเดียว ถ้าตัวเลขนั้นสำคัญ ให้อ่านบรรทัด called coin_price เหนือมันด้วย นั่นคือเหตุผลที่หน้าต่างนี้พิมพ์ทุกขั้นออกมา
六次里有一次是这样。工具没问题,它返回 1 BTC = 2,830,217.02 baht,是对的。然后模型把 1430547.41 这个数交给了 final_answer,而这个数哪里都没出现过。不是四舍五入,是另一个数。同一个问题实测六次:五次和工具完全一致,一次不一致。所以别只信最后一行。如果那个数字要紧,把它上面那行 called coin_price 也读了——这正是这个窗口把每一步都打出来的原因。
Expecting ',' delimiter. It broke on the text ke "BTC" . Write examples without quotation marks.
บรรทัดนี้ตอนแรกเขียนว่า the coin's ticker, like "BTC" or "ETH" smolagents จะคัดลอกคำอธิบายนั้นไปใส่ในรายการเครื่องมือที่โมเดลเห็น แล้วโมเดลเล็กก็คัดลอกมันกลับมาในคำตอบ พร้อมอัญประกาศครบถ้วน ผลลัพธ์จึงไม่ใช่ JSON ที่ถูกต้อง มีคนอ่านเจอปัญหานี้ตั้งแต่ขั้นที่ 1 Expecting ',' delimiter ตรงข้อความ ke "BTC" ให้เขียนตัวอย่างโดยไม่ใส่เครื่องหมายคำพูด
这一行原本写的是 the coin's ticker, like "BTC" or "ETH"。smolagents 会把那段描述抄进模型看到的工具清单里。小模型接着又把它连引号一起抄回自己的答案里,结果就不是合法的 JSON。有读者在第 1 步就撞上了:Expecting ',' delimiter,出错的正是 ke "BTC" 这段。写例子时别用引号。
self.root.destroy() takes the window away for good. After that you cannot even ask the root whether it exists. Try it and Tcl says application has been destroyed. But setup is still a perfectly ordinary Python object. It still holds bot_name and role. A widget dying does not kill the thing that was holding it. That is why main() can read the answers after the form has gone. Two windows in a row is also safe. Three Tk() roots were built, run and destroyed one after another to check.
นี่คือวิธีที่การส่งต่อทำงาน และมันทำให้คนแปลกใจ self.root.destroy() เอาหน้าต่างออกไปอย่างถาวร หลังจากนั้นคุณถามรูทไม่ได้เลยแม้แต่ว่ามันยังอยู่ไหม ลองดูแล้ว Tcl จะบอกว่า application has been destroyed แต่ setup ยังเป็นอ็อบเจกต์ Python ธรรมดา ๆ อยู่ มันยังถือ bot_name กับ role ไว้ วิดเจ็ตตายไม่ได้ทำให้สิ่งที่ถือมันอยู่ตายไปด้วย นั่นคือเหตุผลที่ main() อ่านคำตอบได้หลังฟอร์มหายไปแล้ว การเปิดสองหน้าต่างต่อกันก็ปลอดภัย เราสร้างรูท Tk() สามอัน รันแล้วทำลายทีละอันเพื่อตรวจสอบมาแล้ว
交接就是这么成的,而这一点常让人意外。self.root.destroy() 把窗口彻底拿掉。之后你连问一句根窗口还在不在都不行,试了会得到 Tcl 的 application has been destroyed。但 setup 仍然是一个再普通不过的 Python 对象,它还握着 bot_name 和 role。控件死了,不会把握着它的那个东西一起弄死。这就是表单消失之后 main() 还能读到答案的原因。前后开两个窗口也是安全的:我们连着造了三个 Tk() 根窗口,逐个运行再销毁,验证过。
llama3.2:3b called the tool cleanly every time. Then it said the past tense of go is goed. Twelve times out of twelve. gemma2:9b cannot call a tool at all. The server refuses the request outright. llama3.1:8b said went. But it wrote its tool call as prose, not JSON. So the arguments were lost. Then it told the user to call the tool themselves. qwen2.5:14b said went every time. It got the price right every time too. That is the one in the file. There is a cost. The big model is slow on a laptop. With no graphics card it takes about a minute. A reader watched an earlier version sit there. They thought it was frozen. That is why every step is printed as it finishes. Set LLM_MODEL if you want quick instead of right. One more thing about that line. It points at Ollama, which serves one person at a time. Serving a whole class is a different job, and the vLLM page does that one.
ลองโมเดลสี่ตัวกับไฟล์นี้ แต่ละตัวได้คำถามสองข้อเดียวกัน ตัวละสิบสองรอบ llama3.2:3b เรียกเครื่องมือได้สะอาดทุกครั้ง แล้วมันก็บอกว่าอดีตกาลของ go คือ goed สิบสองครั้งจากสิบสองครั้ง gemma2:9b เรียกเครื่องมือไม่ได้เลย เซิร์ฟเวอร์ปฏิเสธคำขอตรง ๆ llama3.1:8b ตอบว่า went แต่มันเขียนการเรียกเครื่องมือเป็นข้อความธรรมดา ไม่ใช่ JSON อาร์กิวเมนต์จึงหายไป แล้วมันก็บอกให้ผู้ใช้ไปเรียกเครื่องมือเอง qwen2.5:14b ตอบว่า went ทุกครั้ง และได้ราคาถูกต้องทุกครั้งด้วย ตัวนี้คือตัวที่อยู่ในไฟล์ แต่มันมีราคาที่ต้องจ่าย โมเดลใหญ่ทำงานช้าบนแล็ปท็อป ถ้าไม่มีการ์ดจอ มันใช้เวลาราวหนึ่งนาที มีคนอ่านนั่งดูเวอร์ชันก่อนหน้านิ่งอยู่อย่างนั้น เขาคิดว่ามันค้าง นั่นคือเหตุผลที่ทุกขั้นถูกพิมพ์ออกมาทันทีที่เสร็จ ถ้าคุณอยากได้เร็วแทนที่จะได้ถูก ก็ตั้งค่า LLM_MODEL
这个文件试了四个模型。每个都问同样两个问题,各跑十二次。llama3.2:3b 每次都干净地调用了工具,然后它说 go 的过去式是 goed。十二次里十二次。gemma2:9b 根本不会调工具,服务器直接拒绝这个请求。llama3.1:8b 说的是 went,但它把工具调用写成了散文,不是 JSON,参数就丢了,接着它让用户自己去调那个工具。qwen2.5:14b 每次都说 went,价格也每次都对。文件里用的就是它。这是有代价的。大模型在笔记本上很慢,没有显卡的话要花大约一分钟。有读者盯着早一个版本一直不动,以为它卡死了。这就是每一步一完成就打印出来的原因。你要是想要快而不是要对,就设 LLM_MODEL。
8Build a class while the program runs · สร้างคลาสตอนโปรแกรมกำลังทำงาน · 在程序跑着的时候造一个类
🇬🇧 English
Every class so far was written with the word class. Write a function that makes one instead.
Make two with it. One asks your name and closes.
The other is the chat, and it already knows who you are.
Give the factory a kind. Let it decide which methods each class even gets.
You will rarely need this. Do it once, to see what a class really is.
🇹🇭 ไทย
ทุกคลาสที่ผ่านมาเขียนด้วยคำว่า class ทีนี้ลองเขียนฟังก์ชันที่สร้างคลาสแทน
สร้างสองคลาสด้วยมัน คลาสแรกถามชื่อคุณแล้วปิดตัวเอง
อีกคลาสคือหน้าแชท และมันรู้อยู่แล้วว่าคุณเป็นใคร
ส่งชนิดให้โรงงาน แล้วให้มันตัดสินใจว่าคลาสไหนจะได้เมธอดอะไรบ้าง
คุณจะไม่ค่อยได้ใช้มัน ทำสักครั้งเพื่อให้เห็นว่าคลาสคืออะไรจริง ๆ
🇨🇳 中文
到目前为止每个类都是用 class 这个词写出来的。这次写一个函数来造类。
用它造两个。第一个问你的名字,然后关掉自己。
另一个是聊天窗,而它已经知道你是谁了。
给工厂一个种类,让它决定每个类到底能拿到哪些方法。
你很少会需要这个。做一次,是为了看清类到底是什么。
Show the answerดูเฉลย看答案
💻 START A SERVER FIRST
pip install smolagents
# The image, once. It is 9 GB, so start it before you make tea.
docker pull vllm/vllm-openai:cu130-nightly
# The server. It answers at http://localhost:8000/v1
docker run --rm --name vllm --gpus all --ipc=host -p 8000:8000 -v "$HOME/.cache/huggingface:/root/.cache/huggingface" vllm/vllm-openai:cu130-nightly --model Qwen/Qwen2.5-7B-Instruct-AWQ --gpu-memory-utilization 0.25 --max-model-len 8192 --enable-auto-tool-choice --tool-call-parser hermes
🖥️ WHAT IT PRINTED · the second time, with the model already cached
Model loading took 5.2 GiB memory and 34.420278 seconds
Maximum concurrency for 8,192 tokens per request: 49.04x
INFO: Application startup complete.
No server at http://localhost:8000/v1. Three things about that command are worth knowing. The tag is cu130-nightly, not latest. The ordinary image does not run on every card. --ipc=host is required; leaving it out looks like a hang. The last two lines switch tool calling on. Without them, a request carrying a tool is refused. The first start also takes a while, because it downloads the model. The second took 34 seconds. The vLLM page explains all of it. It also measures what the server buys you.
นี่คือไฟล์แรกในหน้านี้ที่ต้องมีเซิร์ฟเวอร์ ถ้าไม่มี หน้าต่างจะเปิดขึ้นมาแล้วบอกว่า No server at http://localhost:8000/v1 และไม่ตอบอะไรเลย มีสามเรื่องในคำสั่งนั้นที่ควรรู้ แท็กคือ cu130-nightly ไม่ใช่ latest เพราะอิมเมจธรรมดาทำงานไม่ได้กับการ์ดทุกรุ่น --ipc=host จำเป็นต้องมี ถ้าไม่ใส่ อาการจะเหมือนค้าง และสองบรรทัดสุดท้ายคือการเปิดการเรียกเครื่องมือ ถ้าไม่มี ทุกคำขอที่แนบเครื่องมือมาจะถูกปฏิเสธ การเริ่มครั้งแรกใช้เวลาสักพักด้วย เพราะต้องดาวน์โหลดโมเดล ครั้งที่สองใช้ 34 วินาที หน้า vLLM อธิบายเรื่องพวกนี้ไว้ทั้งหมด และวัดให้ดูว่าเซิร์ฟเวอร์นี้ให้อะไรคุณ
这是本页第一个需要服务器的文件。没有它,窗口会打开,说一句 No server at http://localhost:8000/v1,然后什么都答不了。那条命令里有三件事值得知道:标签是 cu130-nightly 而不是 latest,因为普通镜像并不是每张卡都能跑;--ipc=host 是必须的,不加看起来就像卡住;最后两行是打开工具调用的开关,不加的话每个带工具的请求都会被拒绝。第一次启动也要等一会儿,因为要下载模型;第二次用了 34 秒。vLLM 那一页把这些都讲了,还测了这台服务器到底给你换来什么。
📄 window_factory.py
"""window_factory.py — challenge 8: a function that builds a whole class.
Every class so far was written with the word class. This file builds two
while the program is running, out of strings handed in at the time.
They inherit from nothing. There is no parent class anywhere in this file,
so every method is written inside the factory and handed to type().
The kind is decided when the CLASS is made, not when a window is built.
That is the difference from challenge 7. There the same class checked
self.kind every time; here the factory hands type() a different set of
methods, so the form class does not own a send at all. Ask it for one and
Python says it has no attribute. The factory decides what a class IS.
Two windows again. The first asks your name and closes. The second is the
chat, and it knows who it is talking to.
"""
import json
import os
import queue
# A thread lets the slow model run without freezing the window. A queue is
# how that thread passes its answer back, because a thread must never touch
# a widget itself.
import threading
import tkinter as tk
import urllib.request
from tkinter import scrolledtext
from smolagents import ActionStep, FinalAnswerStep
from smolagents import OpenAIServerModel, ToolCallingAgent, tool
# This one points at vLLM, not Ollama. The two speak the same /v1 API, so
# nothing below this line changes when you swap them. The vLLM page shows
# the swap, and the measurement that says when it is worth making.
LLM_URL = os.environ.get("LLM_URL", "http://localhost:8000/v1")
# vLLM names the model you started the server with, written out in full.
# Ollama would call the same model qwen2.5:7b. Send one server the other
# one's name and you get a 404.
LLM_MODEL = os.environ.get("LLM_MODEL", "Qwen/Qwen2.5-7B-Instruct-AWQ")
# TO USE OLLAMA INSTEAD, change both of these together:
#
# LLM_URL = "http://localhost:11434/v1"
# LLM_MODEL = "qwen2.5:7b"
#
# Or leave the file alone and set them outside it, which is what the
# os.environ.get above is for:
#
# set LLM_URL=http://localhost:11434/v1
# set LLM_MODEL=qwen2.5:7b
#
# Both, or neither. Change only the address and Ollama answers, politely,
# with model 'Qwen/Qwen2.5-7B-Instruct-AWQ' not found. That one is worth
# causing once on purpose. The server is up and the address is right, so
# it does not look like a naming problem until you read the 404.
#
# Nothing else in this file changes. Not the class, not the factory, not
# the form, not the tool. That is what the two servers sharing one /v1
# API buys you.
# vLLM ignores this, but every OpenAI client sends one, so it is here.
LLM_KEY = os.environ.get("LLM_KEY", "none")
MAX_STEPS = 6
BITKUB = "https://api.bitkub.com/api/v3/market/ticker"
@tool
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
"""
# No double quotes above. smolagents shows that description to the
# model, and a model that copies them back breaks its own JSON.
pair = symbol.upper().strip() + "_THB"
with urllib.request.urlopen(BITKUB + "?sym=" + pair, timeout=30) as response:
coins = json.load(response)
if not coins:
return "no coin called " + symbol + " on Bitkub"
return ("1 " + symbol.upper() + " = "
+ format(float(coins[0]["last"]), ",.2f") + " baht")
def reachable(url):
"""True if something answers at that address. Two seconds, no more."""
try:
request = urllib.request.Request(
url + "/models", headers={"Authorization": "Bearer " + LLM_KEY})
with urllib.request.urlopen(request, timeout=2):
return True
except Exception:
# Any failure at all means no. Which failure it was does not
# change what you have to do about it.
return False
def describe(step):
"""Turn one step of the agent's working into a line for the window."""
if isinstance(step, FinalAnswerStep):
return str(step.output) + "\n\n"
if isinstance(step, ActionStep):
if step.error:
return f" step {step.step_number}: that went wrong, trying again\n"
# A step with nothing in it is a step that never reached the model.
# Saying "called nothing" here reads as a choice the model made,
# which it was not. Stay quiet and let the failure speak instead.
if not step.tool_calls:
return None
called = ", ".join(call.name for call in step.tool_calls)
return f" step {step.step_number}: called {called}\n"
return None
def window_class(name, title, kind):
"""Build a new window class and hand it back. Not an object: a class."""
def __init__(self, user_name=""):
# The form leaves this empty and fills it in. The chat is handed it.
self.user_name = user_name
self.root = tk.Tk()
self.root.title(title)
if kind == "form":
self.build_form()
else:
self.build_chat()
self.root.protocol("WM_DELETE_WINDOW", self.close)
# ---- the form kind ------------------------------------------------
def build_form(self):
"""One label, one box, one button. No transcript, no model."""
self.root.geometry("400x170")
frame = tk.Frame(self.root, padx=20, pady=20)
frame.pack(fill="both", expand=True)
tk.Label(frame, text="What is your name?",
font=("Segoe UI", 14)).pack(anchor="w", pady=(0, 14))
self.box = tk.Entry(frame, font=("Segoe UI", 12))
self.box.pack(fill="x", ipady=5)
self.box.bind("<Return>", self.submit)
self.box.focus_set()
tk.Button(frame, text="Start", width=10, command=self.submit).pack(
anchor="e", pady=(14, 0))
# Made now, filled in by submit(), so the form never changes shape.
self.warning = tk.Label(frame, text="", fg="#b00020",
font=("Segoe UI", 10))
self.warning.pack(anchor="w")
def submit(self, event=None):
"""Keep the name and close. Nothing to say means nothing happens."""
typed = self.box.get().strip()
if not typed:
self.warning.configure(text="Please type a name.")
return
# The window is about to go. This object is not, which is how
# main() reads the name afterwards.
self.user_name = typed
self.close()
# ---- the chat kind ------------------------------------------------
def build_chat(self):
"""The transcript, the typing box, and the model behind them."""
self.root.geometry("520x420")
self.transcript = scrolledtext.ScrolledText(
self.root, wrap="word", font=("Segoe UI", 11), padx=10, pady=10,
height=10, state="disabled")
row = tk.Frame(self.root)
row.pack(side="bottom", fill="x", padx=10, pady=10)
self.box = tk.Entry(row, font=("Segoe UI", 11))
self.box.pack(side="left", fill="x", expand=True, ipady=6)
# self.send works here because send is in the dict handed to type()
# below. By the time anyone builds one of these, it is a method.
self.box.bind("<Return>", self.send)
self.box.focus_set()
tk.Button(row, text="Send", width=8, command=self.send).pack(
side="left", padx=(8, 0))
self.transcript.pack(side="top", fill="both", expand=True,
padx=10, pady=(10, 0))
llm = OpenAIServerModel(model_id=LLM_MODEL, api_base=LLM_URL,
api_key=LLM_KEY)
# The name collected by the first window goes in here, so the model
# is told who it is speaking to before it is asked anything.
self.agent = ToolCallingAgent(
tools=[coin_price], model=llm,
instructions="You are talking to " + self.user_name
+ ". Use their name in your reply. Keep every reply "
"under three sentences. If they ask what a coin is "
"worth, use your coin_price tool to look it up.")
self.answers = queue.Queue()
# after() hands back a booking number. Keep it, so close() can
# cancel the one still outstanding.
self.ticking = None
self.drain()
self.write("Hello " + self.user_name + ". Ask me about a coin.\n\n")
# Ask the server what it serves, before anyone types anything. A
# server that is not running is the commonest thing wrong here, and
# without this the window looks fine until the first question, then
# sits quiet for half a minute. Say it now, and say the address.
if not reachable(LLM_URL):
self.write("No server at " + LLM_URL + "\n"
"Start one, or set LLM_URL to where yours is.\n"
"The vLLM page has the command.\n\n")
def write(self, text):
"""Put text in the transcript. It is read-only until we open it."""
self.transcript.configure(state="normal")
self.transcript.insert("end", text)
self.transcript.see("end")
self.transcript.configure(state="disabled")
def send(self, event=None):
"""Show what was typed, then hand it to the model."""
asked = self.box.get().strip()
if not asked:
return
self.box.delete(0, "end")
self.write("You: " + asked + "\n")
# daemon=True so a half finished answer cannot keep the program
# alive after the window is closed.
threading.Thread(target=self.think, args=(asked,), daemon=True).start()
def think(self, asked):
"""Runs on the worker thread. No widget may be touched from here.
The name is added to the question, not left to the instructions
above. Both were measured. Told only in the instructions, this
model used the name in none of six answers, even when the
instruction said to begin every reply with it. Added to the
question, it used it every time it wrote an answer of its own.
The instructions sit far from the model's attention; the question
does not.
"""
# What the transcript shows is what you typed. This extra line is
# only ever sent to the model.
for_model = (asked + "\n\n(My name is " + self.user_name
+ ". Say my name in your answer.)")
try:
for step in self.agent.run(for_model, stream=True, max_steps=MAX_STEPS):
line = describe(step)
if line:
self.answers.put(line)
except Exception as trouble:
# Name the address. "Connection error" on its own tells a
# reader nothing they can act on, and this is the failure they
# will meet most often.
self.answers.put("no answer from " + LLM_URL + "\n"
+ str(trouble).strip() + "\n"
+ "If that address is wrong, set LLM_URL.\n\n")
def drain(self):
"""Runs on the main thread, and is the only thing that writes."""
while not self.answers.empty():
line = self.answers.get()
# A step line starts with spaces. The answer does not.
self.write(line if line.startswith(" ") else "Bot: " + line)
# A booking still outstanding when the window goes will fire into a
# dead window, and tkinter prints invalid command name at you.
self.ticking = self.root.after(250, self.drain)
# ---- shared by both -----------------------------------------------
def close(self):
"""Stop the drain loop, then take the window down."""
# getattr, because a form has no ticking: the factory never gave it
# a drain to book one.
if getattr(self, "ticking", None) is not None:
self.root.after_cancel(self.ticking)
self.ticking = None
self.root.destroy()
def run(self):
"""Draw the window and wait, until this window closes."""
self.root.mainloop()
# type() takes three things: the name, who it inherits from, and a dict
# of what is inside. The empty brackets are the point of this file. They
# mean it inherits from nothing, so every method it has is one written
# above and handed over here by name.
inside = {"__init__": __init__, "close": close, "run": run}
if kind == "form":
inside.update({"build_form": build_form, "submit": submit})
else:
inside.update({"build_chat": build_chat, "write": write,
"send": send, "think": think, "drain": drain})
return type(name, (), inside)
def main():
# Each of these lines makes a whole class, at the moment it runs.
# They live in here rather than at the top of the file, so importing
# this file builds nothing. Step 3 is the same idea: a file can be a
# program and a toolbox, and only one of those runs on import.
AskName = window_class("AskName", "Before we start", "form")
CoinRoom = window_class("CoinRoom", "Coins", "coins")
print("made", AskName.__name__, "and", CoinRoom.__name__)
# The same question asked of both classes, and two different answers.
# The factory did not set a flag; it handed type() a different dict.
print("AskName can send? ", hasattr(AskName, "send"))
print("CoinRoom can send?", hasattr(CoinRoom, "send"))
asked = AskName()
asked.run()
# Closed without typing a name. Nobody to talk to, so stop.
if not asked.user_name:
return
coins = CoinRoom(asked.user_name)
coins.run()
if __name__ == "__main__":
main()
| Line | What it does · ทำอะไร · 做什么 |
|---|---|
| def window_class(name, title, kind): | A plain function. What it hands back is a class.ฟังก์ชันธรรมดา สิ่งที่มันคืนกลับมาคือคลาส一个普通函数。它交回来的是一个类。 |
| def __init__(self, user_name=""): | Written outside any class body. That matters on the next line.เขียนอยู่นอกตัวคลาส เรื่องนี้สำคัญในบรรทัดถัดไป写在任何类的外面。这一点下一行就有关系。 |
| self.box.bind("<Return>", self.send) | send is not a method yet. It becomes one when type() runs.ตอนนี้ send ยังไม่เป็นเมธอด มันจะเป็นเมื่อ type() ทำงาน此刻 send 还不是方法。等 type() 跑了它才是。 |
| type(name, (), {...}) | The empty brackets mean it inherits from nothing at all.วงเล็บเปล่าแปลว่ามันไม่ได้สืบทอดจากอะไรเลย那对空括号的意思是:它什么都不继承。 |
| inside = {...}; if kind == "form": ... | Every method, handed over by name. Nothing is inherited.ทุกเมธอดถูกส่งมอบด้วยชื่อ ไม่มีอะไรได้มาจากการสืบทอด每个方法都按名字交过去。没有一样是继承来的。 |
| AskName = window_class(...) | Inside main, so importing this file builds nothing at all.อยู่ใน main การอิมพอร์ตไฟล์นี้จึงไม่สร้างอะไรเลย写在 main 里,所以 import 这个文件什么都不会造。 |
📄 factory_check.py
"""factory_check.py — proof that the factory really makes classes.
window_factory only builds its classes inside main, so importing it builds
nothing at all. This file calls the factory itself. No window opens either:
tk.Tk() is only called when you build one.
"""
import window_factory as wf
AskName = wf.window_class("AskName", "Before we start", "form")
CoinRoom = wf.window_class("CoinRoom", "Coins", "coins")
print("type(AskName) :", type(AskName).__name__)
print("is it a class? :", isinstance(AskName, type))
print("its own name :", AskName.__name__)
print("the other one :", CoinRoom.__name__)
print("what it inherits :", AskName.__bases__[0].__name__)
print("two separate classes:", AskName is not CoinRoom)
# The interesting part. The factory did not set a flag that a method reads
# later; it handed type() a different dict, so the two classes do not even
# own the same methods.
print()
print("AskName methods :", sorted(
n for n in vars(AskName) if not n.startswith("_") or n == "__init__"))
print("CoinRoom methods :", sorted(
n for n in vars(CoinRoom) if not n.startswith("_") or n == "__init__"))
print("AskName can send? :", hasattr(AskName, "send"))
print("CoinRoom can send? :", hasattr(CoinRoom, "send"))
# Written with class instead of type(), this is the same thing.
class HandMade:
pass
print()
print("made by type() :", type(AskName).__name__)
print("made by class :", type(HandMade).__name__)
print("same kind of thing :", type(AskName) is type(HandMade))
🖥️ WHAT IT PRINTED · two classes out of one factory
type(AskName) : type
is it a class? : True
its own name : AskName
the other one : CoinRoom
what it inherits : object
two separate classes: True
AskName methods : ['__init__', 'build_form', 'close', 'run', 'submit']
CoinRoom methods : ['__init__', 'build_chat', 'close', 'drain', 'run', 'send', 'think', 'write']
AskName can send? : False
CoinRoom can send? : True
made by type() : type
made by class : type
same kind of thing : True
🖥️ WHAT THE FIRST WINDOW DID · the form, driven step by step
AskName can send? False
CoinRoom can send? True
the form window
title : Before we start
has transcript : False
an empty name is refused
warning : 'Please type a name.'
still open : True
typing Nok and pressing Start
window gone : True - can't invoke "winfo" command: application has been destroyed
collected : 'Nok'
the second window, built from that name
title : Coins
greeting : 'Hello Nok. Ask me about a coin.'
🖥️ AND WITH NO SERVER RUNNING · pointed at a dead port on purpose
Hello Nok. Ask me about a coin.
No server at http://127.0.0.1:9/v1
Start one, or set LLM_URL to where yours is.
The vLLM page has the command.
You: What should I learn first?
Bot: no answer from http://127.0.0.1:9/v1
Error while generating output:
Connection error.
If that address is wrong, set LLM_URL.
step 1: called nothing, then Connection error. That names neither the address nor anything to do about it. Worse, the step line read like a choice the model made. The model was never reached at all. The window now asks the server what it serves the moment it opens. A dead address is named before you type a word. Two seconds, and it saves half a minute of silence.
มีคนอ่านเจอปัญหานี้ก่อนที่หน้านี้จะเจอเสียอีก เมื่อก่อนหน้าต่างจะพิมพ์ step 1: called nothing แล้วตามด้วย Connection error ซึ่งไม่ได้บอกทั้งที่อยู่และไม่ได้บอกว่าต้องทำอะไร แย่กว่านั้นคือบรรทัดขั้นตอนนั้นอ่านเหมือนโมเดลเป็นคนเลือก ทั้งที่ยังไปไม่ถึงโมเดลเลยด้วยซ้ำ ตอนนี้หน้าต่างจะถามเซิร์ฟเวอร์ทันทีที่เปิดว่ามันเสิร์ฟอะไรอยู่ ที่อยู่ที่ใช้ไม่ได้จึงถูกบอกตั้งแต่ก่อนคุณพิมพ์ ใช้เวลาสองวินาที และประหยัดความเงียบครึ่งนาทีที่คำถามแรกต้องเสียไป
有读者比这一页更早撞上这个。窗口以前会打出 step 1: called nothing,然后是 Connection error——既没说地址,也没说该怎么办。更糟的是那行步骤读起来像是模型做的选择,可根本就没连上模型。现在窗口一打开就去问服务器它在服务什么,所以地址不通会在你打字之前就被说出来。两秒钟,省掉第一个问题本来要白等的半分钟。
🖥️ WHAT THE SECOND WINDOW SHOWED · the form said Nok, the server was vLLM
Hello Nok. Ask me about a coin.
You: What should I learn first?
step 1: called final_answer
Bot: Hello Nok, to start learning, you should first identify your goals
and interests. Based on those, you could begin with programming basics
or a new language.
You: What is BTC worth in baht right now?
step 1: called coin_price
step 2: called final_answer
Bot: 1 BTC is worth 2,792,079.34 baht right now.
🇬🇧 English
Read the two method lists. They are not the same.
The factory did not set a flag for a method to check later.
It handed type() a different dict, so one class has no send at all.
Read the last three lines too. A class built by type() is the same kind of thing. So is one written with class.
That is because class has always called type() for you. You just never saw it.
🇹🇭 ไทย
อ่านรายการเมธอดสองชุดนั้น มันไม่เหมือนกัน
โรงงานไม่ได้ตั้งค่าธงไว้ให้เมธอดมาตรวจทีหลัง
มันส่งดิกชันนารีคนละชุดให้ type() คลาสหนึ่งจึงไม่มี send เลย
อ่านสามบรรทัดสุดท้ายด้วย คลาสที่สร้างด้วย type() กับคลาสที่เขียนด้วย class เป็นของชนิดเดียวกัน
เพราะ class เรียก type() ให้คุณมาตลอด คุณแค่ไม่เคยเห็นมัน
🇨🇳 中文
读那两串方法清单。它们不一样。
工厂没有设一个标志让方法以后去查。
它给了 type() 不同的字典,所以有一个类根本没有 send。
也读一下最后三行。用 type() 造的类,和用 class 写的类,是同一种东西。
因为 class 一直在替你调用 type(),你只是没看见。
instructions and this model used it in none of six answers. Three wordings were tried. One of them said to begin every reply with it. Add the same name to the question instead. Then it used it every time it wrote its own answer. The instructions sit far from the model’s attention; the question does not. One thing still will not carry the name: an answer that is only relaying a tool. Asked the price, it hands back the number and nothing else. That is what you want here. The number has to match coin_price to the digit.
หน้าต่างแชทถูกบอกว่ากำลังคุยกับใครถึงสองที่ แต่ได้ผลแค่ที่เดียว ถ้าใส่ชื่อไว้ใน instructions ของเอเจนต์ โมเดลตัวนี้ไม่ใช้ชื่อเลยสักครั้งจากหกครั้ง แม้คำสั่งจะบอกให้ขึ้นต้นทุกคำตอบด้วยชื่อก็ตาม ลองมาแล้วสามสำนวน แต่ถ้าเติมชื่อเดียวกันนั้นลงไปในคำถามแทน มันใช้ทุกครั้งที่ต้องเรียบเรียงคำตอบเอง เพราะคำสั่งอยู่ไกลจากความสนใจของโมเดล ส่วนคำถามไม่ไกล มีอยู่อย่างเดียวที่ยังไม่พาชื่อมาด้วย คือคำตอบที่แค่ส่งต่อผลจากเครื่องมือ พอถามราคา มันคืนตัวเลขมาเฉย ๆ ซึ่งตรงนี้เป็นสิ่งที่เราต้องการ เพราะตัวเลขนั้นต้องตรงกับ coin_price ทุกหลัก
聊天窗口被告知在跟谁说话,一共两处,但只有一处真的管用。把名字放进智能体的 instructions 里,这个模型六次回答一次都没用上,哪怕指令写明「每句回答都要以名字开头」。三种写法都试过了。改成把同一个名字加进问题里,它每次自己组织回答时都用上了。指令离模型的注意力很远,问题不远。只有一种情况仍然带不上名字:那种只是把工具结果转述一遍的回答。问价格时,它把数字原样交回来,别的什么都没有——而这正是这里想要的,因为那个数字必须和 coin_price 一位不差。
write and send are plain local functions. They only become methods when type() is handed the dict. That is why __init__ can say self.send and still work. That line runs long before. But by the time anyone builds a window, the class exists and send is on it. Take one name out of that dict. The window still builds. It breaks at the first click.
ภายในโรงงานนั้น write กับ send เป็นแค่ฟังก์ชันท้องถิ่นธรรมดา มันจะกลายเป็นเมธอดก็ต่อเมื่อ type() ได้รับดิกชันนารีไปแล้ว นั่นคือเหตุผลที่ __init__ เขียน self.send ในบรรทัดที่ทำงานก่อนหน้านั้นนานได้ และยังใช้ได้อยู่ เพราะตอนที่ใครสักคนสร้างหน้าต่าง คลาสมีอยู่แล้วและ send ก็อยู่บนนั้นแล้ว ถ้าเอาชื่อใดชื่อหนึ่งออกจากดิกชันนารีนั้น หน้าต่างจะยังสร้างได้ แล้วค่อยพังตอนคลิกครั้งแรก
在工厂内部,write 和 send 只是普通的局部函数。它们要等 type() 拿到那个字典之后才变成方法。这就是为什么 __init__ 可以在早得多的一行里写 self.send 却照样管用:等有人真去造窗口时,类已经存在,send 也已经在上面了。从那个字典里拿掉一个名字,窗口还是造得出来,然后在第一次点击时才坏。
- Run
factory_check.py. Then runwindow_factory.py. - Make a third kind. Give it only the methods it needs.
- Delete
"send": sendfrom the dict. Build one. Click Send. - Now write the same two classes with
class. Which is shorter?
- รัน
factory_check.pyแล้วรันwindow_factory.py - สร้างชนิดที่สาม ให้มันได้เฉพาะเมธอดที่จำเป็น
- ลบ
"send": sendออกจากดิกชันนารี สร้างหน้าต่างแล้วกด Send - ทีนี้เขียนสองคลาสเดิมด้วยคำว่า
classอันไหนสั้นกว่า
- 跑
factory_check.py,再跑window_factory.py。 - 造第三种。只给它需要的那些方法。
- 从字典里删掉
"send": send。造一个,点 Send。 - 现在用
class把同样两个类写出来。哪个更短?
🇬🇧 English
You started by typing at a prompt with no file. You finished with a model that picks its own tools.
The class did not make any of it possible. It made all of it readable. Every challenge was one short class.
That is what classes are for. Not cleverness. Somewhere obvious to put the next thing.
🇹🇭 ไทย
คุณเริ่มจากการพิมพ์ที่พรอมต์โดยไม่มีไฟล์ และจบลงด้วยโมเดลที่เลือกเครื่องมือของตัวเองได้
คลาสไม่ได้ทำให้สิ่งเหล่านี้เป็นไปได้ แต่มันทำให้ทั้งหมดอ่านรู้เรื่อง ทุกโจทย์คือคลาสสั้น ๆ หนึ่งคลาส
นั่นคือหน้าที่ของคลาส ไม่ใช่ความฉลาด แต่คือที่ที่ชัดเจนสำหรับวางของชิ้นต่อไป
🇨🇳 中文
你从在提示符下打字、连文件都没有开始,最后做出一个会自己挑工具的模型。
类并没有让这些变得可能,它只是让这一切变得读得懂。每一道题都只是一个短短的类。
这就是类的用处。不是为了聪明,而是给下一样东西一个明显该放的地方。
| You wantคุณอยากได้你想要 | Look upไปหาคำว่า去查 |
|---|---|
| Menus along the topเมนูด้านบน顶上的菜单 | tk.Menu |
| Tabsแท็บ标签页 | ttk.Notebook |
| A table of rowsตารางแบบหลายแถว一张有行的表 | ttk.Treeview |
| Buttons that look nativeปุ่มที่ดูเข้ากับระบบ看起来像原生的按钮 | from tkinter import ttk |
| Drawing shapesวาดรูปทรง画图形 | tk.Canvas |
| One window opening anotherหน้าต่างหนึ่งเปิดอีกหน้าต่าง一个窗口再开一个 | tk.Toplevel |
🇬🇧 English
Next: the chat window in full, bitcoin with Python, or agents and tools.
🇹🇭 ไทย
ต่อไป: หน้าต่างแชตแบบเต็ม, บิตคอยน์ด้วย Python หรือ เอเจนต์และเครื่องมือ
🇨🇳 中文
接下来:完整的聊天窗口、用 Python 玩比特币,或者智能体和工具。