🇬🇧 English
A chat window on your own computer. It has a box to type in, an answer that arrives word by word, and a memory.
Nothing to install. tkinter makes windows and comes with Python. urllib and json come with it too.
You do need Ollama running, from the email lessons. Everything here stays on your machine.
Five steps. Every file here is one you would keep. The traps are in the warnings, not in the code.
🇹🇭 ไทย
หน้าต่างแชตบนเครื่องของคุณเอง มีช่องให้พิมพ์ มีคำตอบที่ทยอยมาทีละคำ และมีความจำ
ไม่ต้องติดตั้งอะไรเลย tkinter ใช้สร้างหน้าต่างและมากับ Python ส่วน urllib กับ json ก็มากับมันเหมือนกัน
แต่ต้องมี Ollama รันอยู่ แบบเดียวกับในบทเรียนอีเมล ทุกอย่างที่นี่อยู่บนเครื่องของคุณ
ห้าขั้น ทุกไฟล์ในนี้เป็นไฟล์ที่คุณเก็บไว้ใช้ได้จริง กับดักอยู่ในคำเตือน ไม่ได้อยู่ในโค้ด
🇨🇳 中文
一个跑在你自己电脑上的聊天窗口:有输入框,答案一个词一个词地出现,还有记忆。
什么都不用装。tkinter 用来做窗口,是 Python 自带的,urllib 和 json 也是。
但你需要 Ollama 在跑,跟邮件那几课一样。这里的一切都留在你的机器上。
五步。这里每个文件都是你会留着用的。陷阱写在警告里,不在代码里。
| ('What you need', 'ต้องมีอะไร', '需要什么') | ('Why', 'ทำไม', '为什么') |
|---|---|
python 3.10 or newer | tkinter comes with ittkinter มากับมันtkinter 是自带的 |
ollama | the part that answersส่วนที่ตอบคำถาม负责回答的那一半 |
ollama pull llama3.2 | small and quickเล็กและเร็ว小而快 |
🪟 Tutorial 1 — The window · บทที่ 1 — หน้าต่าง · 教程 1 —— 窗口
🇬🇧 English
Before anything talks, there has to be somewhere to talk in.
Three parts. A place for the conversation, a box to type in, and a button. The order you pack them matters more than you would guess.
🇹🇭 ไทย
ก่อนที่อะไรจะพูดได้ ต้องมีที่ให้พูดก่อน
มีสามส่วน ที่สำหรับบทสนทนา ช่องให้พิมพ์ และปุ่ม ลำดับการ pack สำคัญกว่าที่คุณคิดมาก
🇨🇳 中文
在任何东西开口之前,得先有个说话的地方。
三个部分:放对话的地方、输入框,还有一个按钮。你 pack 它们的顺序,比你想的重要得多。
1 A window that listens · หน้าต่างที่รับฟัง · 一个会听你说话的窗口
🇬🇧 English
No AI yet. Type, press Enter, and your words appear above.
The transcript is state="disabled", so nobody can type into the middle of it. We open it only to write.
🇹🇭 ไทย
ยังไม่มี AI พิมพ์ กด Enter แล้วคำของคุณจะไปโผล่ด้านบน
ช่องบทสนทนาตั้งเป็น state="disabled" จะได้ไม่มีใครพิมพ์แทรกกลางได้ เราเปิดมันเฉพาะตอนจะเขียน
🇨🇳 中文
还没有 AI。输入,按回车,你的话就出现在上面。
对话区是 state="disabled",所以没人能往里面插字。只有要写的时候我们才打开它。
📄 NEW FILE · win1.py
"""win1.py — a window, a box to type in, and somewhere for the words to go."""
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 · ทำอะไร · 做什么 |
|---|---|
| self.transcript.pack(side="top", fill="both", expand=True, | Packed last, on purpose. See the warning below.pack เป็นอันดับสุดท้าย โดยตั้งใจ ดูคำเตือนข้างล่าง故意放在最后 pack。看下面那个警告。 |
| self.box.bind("<Return>", self.send) | Enter sends too. Nobody wants to reach for the mouse to chat.กด Enter ก็ส่งได้ ไม่มีใครอยากคว้าเมาส์ทุกครั้งที่จะคุย回车也能发送。没人想聊个天还得去摸鼠标。 |
| self.transcript.configure(state="normal") | Open it, write, close it again. That is how a read-only box is written to.เปิด เขียน แล้วปิดกลับ นี่คือวิธีเขียนลงกล่องที่อ่านได้อย่างเดียว打开、写入、再关上。只读的框就是这么写进去的。 |
:set paste first, paste, then :set nopaste. Or press Download. It writes the file for you, already indented.
ปุ่ม Copy คัดลอกโค้ดนี้ไปแบบเป๊ะ ๆ ทีละช่องว่าง แต่โปรแกรมแก้ไขข้อความบางตัวจะย่อหน้าให้ใหม่ทุกบรรทัดตอนวาง ทำให้แต่ละบรรทัดลึกกว่าบรรทัดก่อนหน้าเรื่อย ๆ ถ้าใช้ vim ให้พิมพ์ :set paste ก่อน แล้วค่อยวาง จากนั้นพิมพ์ :set nopaste หรือจะกด Download ซึ่งเขียนไฟล์ให้เลย และไม่มีใครมาย่อหน้าใหม่ระหว่างทาง
Copy 按钮是一个空格一个空格地原样复制的。但有些编辑器在粘贴时会把每一行再缩进一次,于是每行都比上一行更深。在 vim 里,先输 :set paste,粘贴,再输 :set nopaste。或者直接按 Download,它把文件写好,路上没人能再给它缩进。
ScrolledText asks for 24 lines by default. That is taller than the window. It takes everything, and the typing box falls off the bottom. The window looks fine and you cannot type in it. Pack the fixed rows from the bottom first. Give the transcript height=10, and pack it last.
ScrolledText ขอพื้นที่ 24 บรรทัดเป็นค่าเริ่มต้น ซึ่งสูงกว่าหน้าต่าง มันจึงกินที่ทั้งหมด แล้วดันช่องพิมพ์ตกขอบล่างไป หน้าต่างดูปกติดี แต่พิมพ์ไม่ได้ ให้ pack แถวที่ความสูงคงที่จากด้านล่างก่อน ตั้ง height=10 ให้ช่องบทสนทนา แล้ว pack มันเป็นอันสุดท้าย
ScrolledText 默认要 24 行,比窗口还高,于是它把空间全占了,输入框被挤出下边缘。窗口看起来好好的,但你打不了字。先从底部 pack 那些固定高度的行,给对话区设 height=10,最后再 pack 它。
- Run it:
python win1.py. - Check: you can see the typing box. Type and press Enter.
- Take out
height=10and run it again. Where did the box go? - Put it back, and move the transcript's
packto the top.
- รัน
python win1.py - ตรวจ: ต้องเห็นช่องพิมพ์ ลองพิมพ์แล้วกด Enter
- ลบ
height=10ออกแล้วรันใหม่ ช่องพิมพ์หายไปไหน - ใส่กลับ แล้วลองย้าย
packของช่องบทสนทนาขึ้นไปไว้บนสุด
- 运行:
python win1.py。 - 检查:你能看到输入框。输入点东西按回车。
- 把
height=10删掉再运行一次,输入框去哪了? - 加回来,再把对话区的
pack移到最前面试试。
🧊 Tutorial 2 — Make it answer, without freezing · บทที่ 2 — ทำให้มันตอบ โดยไม่ค้าง · 教程 2 —— 让它回答,而且不卡死
🇬🇧 English
Now it answers. The obvious way is to ask Ollama in the button handler.
Do that and the window freezes while it thinks. We measured it once: 34.9 seconds dead. So this step does it the right way instead.
🇹🇭 ไทย
คราวนี้มันตอบได้แล้ว วิธีที่นึกออกง่ายที่สุดคือเรียก Ollama ในตัวจัดการปุ่มไปเลย
ทำแบบนั้นแล้วหน้าต่างจะค้างตอนที่มันคิด เราเคยวัดไว้ครั้งหนึ่ง ค้างไป 34.9 วินาที ขั้นนี้จึงทำด้วยวิธีที่ถูกต้องตั้งแต่แรก
🇨🇳 中文
现在它会回答了。最直觉的写法是直接在按钮处理函数里问 Ollama。
那样做,它思考的时候窗口就会卡死。我们量过一次:死了 34.9 秒。所以这一步一开始就用正确的写法。
2 A second thread, and a queue · อีกหนึ่งเธรด กับคิวหนึ่งอัน · 另一个线程,加一个队列
🇬🇧 English
Waiting for Ollama inside the button handler is what freezes the window. Move the waiting onto another thread and send returns at once.
But a thread that did not make the window may not touch the window. Not the transcript, and not even after().
So the thread puts the words in a queue, and drain takes them out on the main thread, fifty milliseconds at a time.
🇹🇭 ไทย
การรอ Ollama ในตัวจัดการปุ่มคือสิ่งที่ทำให้หน้าต่างค้าง ย้ายการรอไปไว้อีกเธรดหนึ่ง แล้ว send จะคืนค่าทันที
แต่เธรดที่ไม่ได้สร้างหน้าต่าง จะไปแตะหน้าต่างไม่ได้ ทั้งช่องบทสนทนา และแม้แต่ after() ก็ไม่ได้
เธรดจึงหย่อนคำลงคิว แล้ว drain มาหยิบออกบนเธรดหลัก ทุก ๆ ห้าสิบมิลลิวินาที
🇨🇳 中文
在按钮处理函数里等 Ollama,就是窗口卡死的原因。把等待挪到另一个线程上,send 就立刻返回了。
但不是创建窗口的那个线程,不能碰窗口——对话区不行,连 after() 都不行。
所以线程把词放进队列,由 drain 在主线程上每五十毫秒取一次。
📄 NEW FILE · talk2.py
"""talk2.py — the same chat, but the window stays alive while it thinks."""
import json
import queue
import threading
import tkinter as tk
import urllib.request
from tkinter import scrolledtext
OLLAMA = "http://localhost:11434/api/chat"
MODEL = "llama3.2"
def ask_ollama(question, inbox):
"""Ask Ollama on another thread, and post the answer into the queue.
This function never touches the window. A thread that did not make the
window may not touch it at all, and that includes calling after().
"""
payload = json.dumps({
"model": MODEL,
"stream": True,
"messages": [{"role": "user", "content": question}],
}).encode()
request = urllib.request.Request(
OLLAMA, data=payload, headers={"Content-Type": "application/json"})
with urllib.request.urlopen(request, timeout=300) as response:
# Streaming sends one small JSON object per line, not one per answer.
for line in response:
if not line.strip():
continue
packet = json.loads(line)
piece = packet.get("message", {}).get("content", "")
if piece:
inbox.put(("word", piece))
if packet.get("done"):
break
inbox.put(("done", ""))
class ChatWindow:
def __init__(self, root):
self.root = root
self.inbox = queue.Queue()
self.waiting = False
root.title("Chat that keeps moving")
root.geometry("520x420")
self.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)
self.box = tk.Entry(row, font=("Segoe UI", 11))
self.box.pack(side="left", fill="x", expand=True, ipady=6)
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))
# Start the collecting loop now, before anything is waiting. It runs
# for the life of the window, empty or not, checking twenty times a
# second. That is cheap, and it means nothing has to wake it up.
self.drain()
def write(self, text):
self.transcript.configure(state="normal")
self.transcript.insert("end", text)
self.transcript.see("end")
self.transcript.configure(state="disabled")
def drain(self):
"""Empty the queue into the window, then book the next visit.
This runs on the main thread, the only one allowed to change a widget.
"""
try:
while True:
kind, text = self.inbox.get_nowait()
if kind == "word":
self.write(text)
elif kind == "done":
self.write("\n")
self.waiting = False
except queue.Empty:
pass
# after books one call, 50ms from now. It is not a repeating timer,
# which is why the last thing drain does is book the next drain. Miss
# this line out and the queue is emptied exactly once, ever.
self.root.after(50, self.drain)
def send(self, event=None):
"""Hand the question to a thread, and give the window straight back."""
said = self.box.get().strip()
if not said or self.waiting:
return
self.box.delete(0, "end")
self.write("You: " + said + "\n")
self.write("Bot: ")
self.waiting = True
worker = threading.Thread(target=ask_ollama, args=(said, self.inbox),
daemon=True)
worker.start()
# send() ends here, in no time at all. The window carries on.
def main():
root = tk.Tk()
ChatWindow(root)
root.mainloop()
if __name__ == "__main__":
main()
| Line | What it does · ทำอะไร · 做什么 |
|---|---|
| threading.Thread(target=ask_ollama, args=(said, self.inbox), | The waiting happens here, off to one side, where it bothers nobody.การรอเกิดขึ้นตรงนี้ อยู่ข้าง ๆ ไม่ไปกวนใคร等待发生在这里,放在一边,谁也不打扰。 |
| inbox.put(("word", piece)) | The thread's only way of speaking. It never touches a widget.นี่คือทางเดียวที่เธรดพูดได้ มันไม่แตะวิดเจ็ตเลย这是线程唯一的说话方式。它绝不碰任何控件。 |
| self.root.after(50, self.drain) | Book the next visit. This is on the main thread, which is allowed.นัดรอบถัดไป อันนี้อยู่บนเธรดหลัก ซึ่งทำได้预约下一次。这是在主线程上,所以允许。 |
| "stream": True | Ollama sends one JSON object per line. Words arrive as they are written.Ollama ส่ง JSON บรรทัดละหนึ่งก้อน คำจึงทยอยมาตามที่มันเขียนOllama 一行发一个 JSON,所以词是边写边到的。 |
🖥️ MEASURED · the same question, the same model, both ways
asking inside the button handler:
send() blocked for 34.9 seconds
a clock set to tick 50 times a second managed 2 ticks
asking on a thread, through a queue:
send() returned in 2 ms, and the answer still arrived
root.after() from the worker thread. It fails with main thread is not in main loop. after has to register a command with Tk. Only the main thread may do that. The queue is the fix. The worker touches nothing.
คำแนะนำจำนวนมากบอกให้เรียก root.after() จากเธรดที่ทำงานอยู่ แต่มันจะพังด้วยข้อความ main thread is not in main loop เพราะ after ต้องไปลงทะเบียนคำสั่งกับ Tk และมีแต่เธรดหลักเท่านั้นที่ทำได้ คิวคือทางแก้ เธรดนั้นไม่ต้องแตะอะไรเลย
很多建议会让你在工作线程里调 root.after()。它会以 main thread is not in main loop 失败,因为 after 要向 Tk 注册一个命令,而只有主线程能做。队列才是解法:工作线程什么都不碰。
- Run it and ask for that story again.
- Check: the words appear one at a time.
- While it writes, scroll the transcript. It moves.
- Change
after(50, ...)toafter(1000, ...). Watch it stutter.
- รันแล้วขอให้มันเล่าเรื่องเดิมอีกครั้ง
- ตรวจ: คำต้องทยอยโผล่มาทีละคำ
- ระหว่างที่มันเขียน ลองเลื่อนบทสนทนาดู มันเลื่อนได้
- เปลี่ยน
after(50, ...)เป็นafter(1000, ...)แล้วดูมันกระตุก
- 运行它,再让它讲一次那个故事。
- 检查:词是一个一个出现的。
- 它在写的时候滚动对话区,能动。
- 把
after(50, ...)改成after(1000, ...),看它一顿一顿的。
🧠 Tutorial 3 — Make it remember · บทที่ 3 — ทำให้มันจำได้ · 教程 3 —— 让它记得住
🇬🇧 English
Tell it your name, then ask what your name is. It has no idea.
Ollama keeps nothing between questions. The memory is not in the model. It is a list in your program, and you send it every time.
🇹🇭 ไทย
บอกชื่อคุณกับมัน แล้วถามว่าคุณชื่ออะไร มันจะไม่รู้เลย
Ollama ไม่เก็บอะไรไว้ระหว่างคำถาม ความจำไม่ได้อยู่ในโมเดล แต่เป็นลิสต์ในโปรแกรมของคุณ และคุณส่งมันไปใหม่ทุกครั้ง
🇨🇳 中文
告诉它你的名字,再问它你叫什么。它完全不知道。
Ollama 在两次提问之间什么都不留。记忆不在模型里,而是你程序里的一个列表,每一次你都要把它整个发过去。
3 The memory is a list you resend · ความจำคือลิสต์ที่คุณส่งซ้ำ · 记忆就是一个你反复重发的列表
🇬🇧 English
One list. Your questions and its answers, in order.
Every time you ask something, you send the whole list again. That is all a chatbot's memory has ever been.
🇹🇭 ไทย
ลิสต์เดียว คำถามของคุณและคำตอบของมัน เรียงตามลำดับ
ทุกครั้งที่คุณถาม คุณส่งลิสต์ทั้งก้อนไปใหม่ ความจำของแชตบอตมีแค่นี้มาตลอด
🇨🇳 中文
一个列表。你的问题和它的回答,按顺序排好。
每次你问东西,就把整个列表再发一遍。聊天机器人的记忆从来就只是这样。
📄 NEW FILE · mem1.py
"""mem1.py — now it remembers, because you tell it everything every time."""
import json
import queue
import threading
import tkinter as tk
import urllib.request
from tkinter import scrolledtext
OLLAMA = "http://localhost:11434/api/chat"
MODEL = "llama3.2"
SYSTEM = "You are a friendly assistant. Keep answers short."
REMEMBER = 20 # how many past messages to send back
def ask_ollama(messages, inbox):
"""Ask Ollama on another thread. messages is the whole conversation."""
payload = json.dumps({
"model": MODEL,
"stream": True,
"messages": messages,
}).encode()
request = urllib.request.Request(
OLLAMA, data=payload, headers={"Content-Type": "application/json"})
with urllib.request.urlopen(request, timeout=300) as response:
for line in response:
if not line.strip():
continue
packet = json.loads(line)
piece = packet.get("message", {}).get("content", "")
if piece:
inbox.put(("word", piece))
if packet.get("done"):
break
inbox.put(("done", ""))
class ChatWindow:
def __init__(self, root):
self.root = root
self.inbox = queue.Queue()
self.waiting = False
self.reply = []
# The whole memory. Ollama keeps nothing between questions, so this
# list is the only place the conversation exists.
self.history = []
root.title("Chat that remembers")
root.geometry("520x420")
self.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)
self.box = tk.Entry(row, font=("Segoe UI", 11))
self.box.pack(side="left", fill="x", expand=True, ipady=6)
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))
# The collecting loop runs for the life of the window. after() books
# one call at a time, so drain books the next one before it returns.
self.drain()
def write(self, text):
self.transcript.configure(state="normal")
self.transcript.insert("end", text)
self.transcript.see("end")
self.transcript.configure(state="disabled")
def drain(self):
"""Empty the queue into the window, on the main thread."""
try:
while True:
kind, text = self.inbox.get_nowait()
if kind == "word":
self.reply.append(text)
self.write(text)
elif kind == "done":
self.write("\n")
# The answer becomes part of what it is told next time.
self.history.append({"role": "assistant",
"content": "".join(self.reply)})
self.waiting = False
except queue.Empty:
pass
self.root.after(50, self.drain)
def send(self, event=None):
"""Add the question to the history, then send the history."""
said = self.box.get().strip()
if not said or self.waiting:
return
self.box.delete(0, "end")
self.history.append({"role": "user", "content": said})
self.write("You: " + said + "\n")
self.write("Bot: ")
self.waiting = True
self.reply = []
# The system line leads, then the newest few turns. This is the whole
# of the memory: the model is told the conversation, every single time.
messages = [{"role": "system", "content": SYSTEM}]
messages.extend(self.history[-REMEMBER:])
threading.Thread(target=ask_ollama, args=(messages, self.inbox),
daemon=True).start()
def main():
root = tk.Tk()
ChatWindow(root)
root.mainloop()
if __name__ == "__main__":
main()
| Line | What it does · ทำอะไร · 做什么 |
|---|---|
| self.history = [] | The whole memory. Lose this list and it forgets everything.ความจำทั้งหมดอยู่ตรงนี้ ทำลิสต์นี้หาย มันก็ลืมหมด全部的记忆就在这里。弄丢这个列表,它就什么都忘了。 |
| messages.extend(self.history[-REMEMBER:]) | The newest twenty only. Send everything and it gets slow and dear.เอาแค่ยี่สิบอันล่าสุด ถ้าส่งหมดจะช้าและแพง只发最新的二十条。全发就又慢又贵。 |
| {"role": "system", "content": SYSTEM} | The standing instruction. It goes first, and it is not part of the history.คำสั่งประจำตัว วางไว้หน้าสุด และไม่นับเป็นส่วนหนึ่งของประวัติ常驻指令。放在最前面,而且不算历史的一部分。 |
🖥️ WHAT IT ANSWERED · two questions, one run
You: My name is Nari. Just say hello.
Bot: Hello Nari! Nice to meet you.
You: What is my name?
Bot: Your name is Nari.
- Run it. Tell it your name, then ask what your name is.
- Check: it knows.
- Set
REMEMBER = 2and try the same thing. - Check: it forgets. You just moved its memory.
- รันมัน บอกชื่อคุณ แล้วถามว่าคุณชื่ออะไร
- ตรวจ: มันต้องรู้
- ตั้ง
REMEMBER = 2แล้วลองแบบเดิมอีกครั้ง - ตรวจ: มันลืม คุณเพิ่งขยับความจำของมันเอง
- 运行它。告诉它你的名字,再问它你叫什么。
- 检查:它知道。
- 把
REMEMBER = 2设好,再试一次同样的事。 - 检查:它忘了。你刚刚亲手挪动了它的记忆。
4 And still remember tomorrow · และพรุ่งนี้ก็ยังจำได้ · 而且明天还记得
🇬🇧 English
The memory is a list of dictionaries. That is already JSON, so saving it is one line.
Load it when the window opens, and the conversation carries on where it stopped, even after the computer was off.
🇹🇭 ไทย
ความจำคือลิสต์ของดิกชันนารี ซึ่งก็คือ JSON อยู่แล้ว การบันทึกจึงใช้บรรทัดเดียว
โหลดมันตอนเปิดหน้าต่าง แล้วบทสนทนาจะเดินต่อจากจุดที่ค้างไว้ ต่อให้ปิดเครื่องไปแล้วก็ตาม
🇨🇳 中文
记忆是一个字典组成的列表,本来就是 JSON,所以保存只要一行。
窗口打开时读回来,对话就从上次停下的地方继续,哪怕中间电脑关过机。
📄 NEW FILE · mem2.py
"""mem2.py — and it still remembers tomorrow, because the list is a file."""
import json
import queue
import threading
import tkinter as tk
import urllib.request
from pathlib import Path
from tkinter import scrolledtext
OLLAMA = "http://localhost:11434/api/chat"
MODEL = "llama3.2"
SYSTEM = "You are a friendly assistant. Keep answers short."
REMEMBER = 20
HISTORY = Path("chat_history.json")
def ask_ollama(messages, inbox):
"""Ask Ollama on another thread. messages is the whole conversation."""
payload = json.dumps({
"model": MODEL,
"stream": True,
"messages": messages,
}).encode()
request = urllib.request.Request(
OLLAMA, data=payload, headers={"Content-Type": "application/json"})
with urllib.request.urlopen(request, timeout=300) as response:
for line in response:
if not line.strip():
continue
packet = json.loads(line)
piece = packet.get("message", {}).get("content", "")
if piece:
inbox.put(("word", piece))
if packet.get("done"):
break
inbox.put(("done", ""))
class ChatWindow:
def __init__(self, root):
self.root = root
self.inbox = queue.Queue()
self.waiting = False
self.reply = []
self.history = []
root.title("Chat that remembers tomorrow")
root.geometry("520x420")
self.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)
self.box = tk.Entry(row, font=("Segoe UI", 11))
self.box.pack(side="left", fill="x", expand=True, ipady=6)
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))
# Fill the window from the file before the first drain, so the old
# conversation is on screen the moment it opens.
self.load()
self.drain()
def write(self, text):
self.transcript.configure(state="normal")
self.transcript.insert("end", text)
self.transcript.see("end")
self.transcript.configure(state="disabled")
def load(self):
"""Read yesterday's conversation back in, if there is one."""
if not HISTORY.exists():
return
try:
saved = json.loads(HISTORY.read_text(encoding="utf-8"))
except ValueError:
# A file half written when the power went is not worth crashing
# over. Leave it, and start the conversation again.
self.write("The old conversation could not be read.\n")
return
self.history = saved
for message in self.history:
who = "You: " if message["role"] == "user" else "Bot: "
self.write(who + message["content"] + "\n")
def save(self):
"""The memory is a list, and a list of dicts is already JSON."""
HISTORY.write_text(json.dumps(self.history, indent=2), encoding="utf-8")
def drain(self):
try:
while True:
kind, text = self.inbox.get_nowait()
if kind == "word":
self.reply.append(text)
self.write(text)
elif kind == "done":
self.write("\n")
self.history.append({"role": "assistant",
"content": "".join(self.reply)})
self.save()
self.waiting = False
except queue.Empty:
pass
self.root.after(50, self.drain)
def send(self, event=None):
said = self.box.get().strip()
if not said or self.waiting:
return
self.box.delete(0, "end")
self.history.append({"role": "user", "content": said})
self.write("You: " + said + "\n")
self.write("Bot: ")
self.waiting = True
self.reply = []
messages = [{"role": "system", "content": SYSTEM}]
messages.extend(self.history[-REMEMBER:])
threading.Thread(target=ask_ollama, args=(messages, self.inbox),
daemon=True).start()
def main():
root = tk.Tk()
ChatWindow(root)
root.mainloop()
if __name__ == "__main__":
main()
| Line | What it does · ทำอะไร · 做什么 |
|---|---|
| HISTORY.write_text(json.dumps(self.history, indent=2) | A list of dictionaries is JSON already. No converting needed.ลิสต์ของดิกชันนารีคือ JSON อยู่แล้ว ไม่ต้องแปลงอะไร字典组成的列表本来就是 JSON,不用转换。 |
| except ValueError: | A half-written file must not stop the program starting.ไฟล์ที่เขียนค้างตอนไฟดับ ต้องไม่ทำให้โปรแกรมเปิดไม่ขึ้น断电时写了一半的文件,不能让程序连启动都不行。 |
| for message in self.history: | Put yesterday back on screen, so the window matches the memory.เอาของเมื่อวานกลับขึ้นจอ ให้หน้าต่างตรงกับความจำ把昨天的内容放回屏幕,让窗口和记忆对得上。 |
🖥️ WHAT IT ANSWERED · closed, reopened, then asked
mem2 : after closing and reopening, Khao soi.
- Run it, tell it something, then close the window.
- Open
chat_history.jsonin an editor. Read it. - Run it again. Check: the old conversation is there.
- Ask about the thing you told it before. It still knows.
- รันมัน บอกอะไรมันสักอย่าง แล้วปิดหน้าต่าง
- เปิด
chat_history.jsonในโปรแกรมแก้ไขข้อความ แล้วอ่านดู - รันใหม่ ตรวจ: บทสนทนาเก่าต้องอยู่ครบ
- ถามเรื่องที่คุณบอกมันไว้ก่อนหน้า มันยังรู้อยู่
- 运行它,告诉它点什么,然后关掉窗口。
- 用编辑器打开
chat_history.json,读一读。 - 再运行一次。检查:旧对话还在。
- 问问你之前告诉它的那件事。它还记得。
🛡️ Tutorial 4 — Make it survive being used · บทที่ 4 — ทำให้มันทนการใช้งานจริง · 教程 4 —— 让它经得起真的被人用
🇬🇧 English
Everything above works when nothing goes wrong. Now make it work when things do.
Five faults, all of them found by running the program rather than by reading it. Each one is a few lines.
🇹🇭 ไทย
ทุกอย่างข้างบนใช้ได้ตอนที่ไม่มีอะไรผิดพลาด คราวนี้ทำให้มันใช้ได้ตอนที่มีอะไรผิด
ห้าจุดบกพร่อง ทั้งหมดเจอจากการรันโปรแกรม ไม่ใช่จากการนั่งอ่าน แต่ละจุดแก้ด้วยโค้ดไม่กี่บรรทัด
🇨🇳 中文
上面这些在一切顺利的时候都能用。现在让它在出事的时候也能用。
五个毛病,全都是把程序跑起来才发现的,不是读出来的。每个都只要几行。
5 Five things that go wrong · ห้าเรื่องที่มักพัง · 五件会出错的事
🇬🇧 English
This is the finished program. It is the one above, plus five defences and a status line.
Read the table before the code. Each row is a real failure, and the line that answers it.
🇹🇭 ไทย
นี่คือโปรแกรมฉบับสมบูรณ์ เหมือนข้างบน บวกเกราะป้องกันห้าอย่าง และแถบสถานะหนึ่งแถบ
อ่านตารางก่อนอ่านโค้ด แต่ละแถวคือความพังที่เกิดขึ้นจริง และบรรทัดที่แก้มัน
🇨🇳 中文
这是完成版。就是上面那个,加上五道防线和一行状态栏。
先读表格再读代码。每一行都是一次真实的故障,以及解决它的那行代码。
| ('What goes wrong', 'อะไรพัง', '什么会出错') | ('What it looked like', 'อาการเป็นอย่างไร', '当时是什么样') | ('The fix', 'วิธีแก้', '怎么修') |
|---|---|---|
| A model name with a typoพิมพ์ชื่อโมเดลผิด模型名字打错了 | HTTP Error 404: Not Found | read the body: model 'llama9' not foundอ่านเนื้อหาในนั้น: model 'llama9' not found去读正文:model 'llama9' not found |
| The history file is damagedไฟล์ประวัติเสีย历史文件损坏 | it would not start at allเปิดไม่ขึ้นเลย根本启动不了 | except (ValueError, OSError) |
| Forget, mid-answerกดลืม ระหว่างที่คำตอบกำลังมา答案到一半时按了忘记 | the answer landed in an empty historyคำตอบไปตกในประวัติที่ว่างเปล่า答案掉进了空的历史里 | if self.waiting: return |
| The model says nothingโมเดลไม่ตอบอะไรเลย模型什么都没说 | a turn with no answer was savedบันทึกรอบที่ไม่มีคำตอบไว้存下了一轮没有答案的对话 | if answer.strip(): |
| Closing the windowตอนปิดหน้าต่าง关窗口的时候 | Tk shouted about a missing commandTk ร้องว่าไม่มีคำสั่งนั้นTk 抱怨命令不存在 | after_cancel |
📄 NEW FILE · chatbot.py — the finished program
"""chatbot.py — a chat window that talks to Ollama and remembers what was said."""
import json
import os
import queue
import threading
import tkinter as tk
import urllib.error
import urllib.request
from pathlib import Path
from tkinter import scrolledtext
OLLAMA = os.environ.get("OLLAMA_URL", "http://localhost:11434/api/chat")
MODEL = os.environ.get("OLLAMA_MODEL", "llama3.2")
SYSTEM = "You are a friendly assistant. Keep answers short unless asked for more."
REMEMBER = 20 # how many past messages to send back
HISTORY = Path("chat_history.json") # so it still remembers tomorrow
def ask_ollama(messages, inbox):
"""Stream one reply from Ollama. Runs on its own thread.
It never touches the window. Every piece of the answer goes into the queue,
and the main thread takes it out. A thread that is not the one that made
the window may not touch the window at all, not even through after().
"""
payload = json.dumps({
"model": MODEL,
"stream": True,
"messages": messages,
}).encode()
request = urllib.request.Request(
OLLAMA, data=payload, headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(request, timeout=300) as response:
# Streaming sends one JSON object per line, not one per answer.
for line in response:
if not line.strip():
continue
packet = json.loads(line)
piece = packet.get("message", {}).get("content", "")
if piece:
inbox.put(("word", piece))
if packet.get("done"):
break
except urllib.error.HTTPError as refused:
# urllib shows only the status line. Ollama puts the reason in the
# body, so "404: Not Found" is really "model 'llama9' not found".
body = refused.read().decode(errors="replace")
try:
body = json.loads(body).get("error", body)
except ValueError:
pass
inbox.put(("error", str(refused.code) + ": " + body))
return
except Exception as problem:
inbox.put(("error", str(problem)))
return
inbox.put(("done", ""))
class ChatWindow:
"""The window, the transcript, and the memory behind it."""
def __init__(self, root):
self.root = root
self.history = []
self.waiting = False
self.inbox = queue.Queue()
self.next_drain = None
self.reply = []
root.title("Ollama chat")
# The X in the corner destroys the window by itself. protocol puts our
# own function in the way first, so there is somewhere to tidy up.
root.protocol("WM_DELETE_WINDOW", self.close)
root.geometry("620x560")
root.minsize(420, 380)
# height=10 keeps the transcript from asking for more room than the
# window has. Ask for 24 lines, the default, and it pushes the typing
# box off the bottom edge - where you cannot see it to wonder why.
self.transcript = scrolledtext.ScrolledText(
root, wrap="word", font=("Segoe UI", 11), padx=10, pady=10,
height=10, background="#fbfaf7", relief="flat", state="disabled")
# A tag is a name for a look. Define it once here, then pass the name
# to insert(), and that piece of text is drawn that way. It is how one
# text box can hold blue names, green names and red errors at once.
self.transcript.tag_config("you", foreground="#1d4ed8",
font=("Segoe UI", 11, "bold"), spacing1=8)
self.transcript.tag_config("bot", foreground="#166534",
font=("Segoe UI", 11, "bold"), spacing1=8)
self.transcript.tag_config("words", foreground="#222222")
self.transcript.tag_config("problem", foreground="#b91c1c")
row = tk.Frame(root)
row.pack(side="bottom", fill="x", padx=10, pady=(0, 6))
self.box = tk.Entry(row, font=("Segoe UI", 11))
self.box.pack(side="left", fill="x", expand=True, ipady=6)
self.box.bind("<Return>", self.send)
self.box.focus_set()
self.send_button = tk.Button(row, text="Send", width=9, command=self.send)
self.send_button.pack(side="left", padx=(8, 0))
bottom = tk.Frame(root)
bottom.pack(side="bottom", fill="x", padx=10, pady=(0, 10))
# A Label just shows text. anchor="w" keeps it against the west edge,
# so it does not jump about as the number of messages changes.
self.status = tk.Label(bottom, anchor="w", fg="#666666",
font=("Segoe UI", 9))
self.status.pack(side="left")
self.forget_button = tk.Button(bottom, text="Forget everything",
command=self.forget, font=("Segoe UI", 9))
self.forget_button.pack(side="right")
# Packed last and stretchy: it takes the space the fixed rows did
# not want, which is how the typing box stays on screen.
self.transcript.pack(side="top", fill="both", expand=True,
padx=10, pady=(10, 4))
self.load()
self.show_status()
self.drain()
def drain(self):
"""Empty the queue into the window, then ask to be called again.
This runs on the main thread, which is the only thread allowed to
change a widget. It keeps running whether anything is waiting or not.
"""
try:
while True:
kind, text = self.inbox.get_nowait()
if kind == "word":
self.got_word(text)
elif kind == "done":
self.got_all()
elif kind == "error":
self.went_wrong(text)
except queue.Empty:
pass
self.next_drain = self.root.after(50, self.drain)
def close(self):
"""Stop the drain loop first, then shut the window.
A drain still booked when the window goes leaves Tk calling a command
that no longer exists, and it says so on the way out.
"""
if self.next_drain is not None:
self.root.after_cancel(self.next_drain)
self.next_drain = None
self.root.destroy()
# ── the transcript ──────────────────────────────────────────────────
def write(self, text, tag="words"):
"""Put text in the transcript. Only the main thread may call this."""
self.transcript.configure(state="normal")
self.transcript.insert("end", text, tag)
self.transcript.see("end")
self.transcript.configure(state="disabled")
def show_status(self):
remembered = len(self.history)
sent = min(remembered, REMEMBER)
self.status.config(
text=f"{MODEL} · remembering {remembered} messages, sending {sent}")
# ── memory ──────────────────────────────────────────────────────────
def load(self):
"""Read yesterday's conversation back in, if there is one."""
if not HISTORY.exists():
return
try:
saved = json.loads(HISTORY.read_text(encoding="utf-8"))
except (ValueError, OSError):
# A file half-written when the power went is not worth crashing
# over. Say so, and start the conversation again.
self.write("The saved conversation could not be read. "
"Starting fresh.\n", "problem")
return
if not isinstance(saved, list):
return
self.history = saved
for message in self.history:
who = "You: " if message["role"] == "user" else "Bot: "
self.write(who, "you" if message["role"] == "user" else "bot")
self.write(message["content"] + "\n")
def save(self):
HISTORY.write_text(json.dumps(self.history, indent=2), encoding="utf-8")
def forget(self):
"""Empty the memory, the file and the window."""
if self.waiting:
# An answer is still arriving. Clear now and that answer lands in
# an empty history, and gets saved as a conversation of one.
return
self.history = []
if HISTORY.exists():
HISTORY.unlink()
self.transcript.configure(state="normal")
self.transcript.delete("1.0", "end")
self.transcript.configure(state="disabled")
self.show_status()
# ── sending ─────────────────────────────────────────────────────────
def send(self, event=None):
"""Take what was typed, show it, and ask Ollama about it."""
said = self.box.get().strip()
if not said or self.waiting:
return
self.box.delete(0, "end")
self.history.append({"role": "user", "content": said})
self.write("You: ", "you")
self.write(said + "\n")
self.write("Bot: ", "bot")
self.waiting = True
self.send_button.config(state="disabled")
self.forget_button.config(state="disabled")
self.reply = []
# The system line leads, then only the newest few turns. This is the
# whole of the memory: the model is told the conversation every time.
messages = [{"role": "system", "content": SYSTEM}]
messages.extend(self.history[-REMEMBER:])
# Ollama takes seconds, and a window that waits is a window that
# freezes. So the asking happens on another thread, and drain() picks
# the answer up from the queue.
worker = threading.Thread(target=ask_ollama, args=(messages, self.inbox),
daemon=True)
worker.start()
def got_word(self, piece):
self.reply.append(piece)
self.write(piece)
def got_all(self):
"""The answer is finished. Remember it, and let the user type again."""
self.write("\n")
answer = "".join(self.reply)
if answer.strip():
self.history.append({"role": "assistant", "content": answer})
self.save()
else:
# Nothing came back. Saving that would leave a turn with no answer,
# so drop the question too and let the user ask again.
self.write("The model said nothing. Ask again.\n", "problem")
if self.history and self.history[-1]["role"] == "user":
self.history.pop()
self.done_waiting()
def went_wrong(self, problem):
self.write("\n" + problem + "\n", "problem")
self.write("Is Ollama running? Try: ollama serve\n", "problem")
# Drop the question, so a failed turn does not poison the memory.
if self.history and self.history[-1]["role"] == "user":
self.history.pop()
self.done_waiting()
def done_waiting(self):
self.waiting = False
self.send_button.config(state="normal")
self.forget_button.config(state="normal")
self.show_status()
self.box.focus_set()
def main():
root = tk.Tk()
ChatWindow(root)
root.mainloop()
if __name__ == "__main__":
main()
- Run it. Check: the status line counts your messages.
- Set
OLLAMA_MODELto a name that does not exist. Read the error. - Put nonsense in
chat_history.json. It should still open. - Stop Ollama, then send a message. Start it, and carry on.
- รันมัน ตรวจ: แถบสถานะต้องนับจำนวนข้อความของคุณ
- ตั้ง
OLLAMA_MODELเป็นชื่อที่ไม่มีอยู่จริง แล้วอ่านข้อผิดพลาด - ใส่ข้อความมั่ว ๆ ลงใน
chat_history.jsonมันต้องยังเปิดได้ - ปิด Ollama แล้วลองส่งข้อความ จากนั้นเปิดใหม่ แล้วคุยต่อ
- 运行它。检查:状态栏会数你的消息条数。
- 把
OLLAMA_MODEL设成一个不存在的名字,读一下错误。 - 往
chat_history.json里塞点乱码,它应该还能打开。 - 把 Ollama 停掉再发一条消息,然后再启动,继续聊。
🇬🇧 English
Six steps, and a program you can leave on your desktop.
Two of them are worth more than the rest. Slow work belongs off the main thread, and a thread may only speak through a queue. That is true of every window you will ever write, in any language.
And the memory turned out to be a list. Not a clever thing at all.
🇹🇭 ไทย
หกขั้น และโปรแกรมหนึ่งตัวที่วางไว้บนเดสก์ท็อปได้เลย
สองขั้นในนั้นมีค่ากว่าขั้นอื่น งานที่ช้าต้องไม่อยู่บนเธรดหลัก และเธรดจะพูดได้ผ่านคิวเท่านั้น เรื่องนี้จริงกับทุกหน้าต่างที่คุณจะเขียน ในทุกภาษา
ส่วนความจำ สุดท้ายก็แค่ลิสต์ ไม่ได้ฉลาดอะไรเลย
🇨🇳 中文
六步,换来一个可以放在桌面上的程序。
其中两步比其余的都值钱:慢活不能放在主线程上,而线程只能通过队列说话。这对你将来写的每一个窗口都成立,不管用什么语言。
至于记忆,到头来不过是个列表,一点都不神奇。
| ('You want', 'คุณอยากได้', '你想要') | ('Look up', 'ไปหาคำว่า', '去查') |
|---|---|
| Buttons that look modernปุ่มที่ดูทันสมัย好看一点的按钮 | tkinter.ttk |
| Two windows at onceเปิดสองหน้าต่างพร้อมกัน同时开两个窗口 | tk.Toplevel |
| Pick the model in the windowเลือกโมเดลในหน้าต่างได้在窗口里选模型 | ttk.Combobox + /api/tags |
| One file to hand someoneไฟล์เดียวส่งให้คนอื่น一个文件发给别人 | pyinstaller --onefile |