中級者向け No.100

チャットボットUI(Ollama 連携)

ローカル LLM(Ollama)に接続して対話できる tkinter 製チャットボット GUI。Ollama が起動していない場合はルールベース応答にフォールバック。会話履歴の JSON 保存付き。外部 SDK 不要・urllib 標準ライブラリのみで実装。

🎯 難易度: ★★★ 難しい 📦 ライブラリ: tkinter / urllib(標準ライブラリ) ⏱️ 制作時間: 30〜90分

1. アプリ概要

ローカル LLM(Ollama)に接続して対話できる、Python tkinter 製のチャットボット GUI クライアントです。Ollama が起動していない場合は ルールベース応答(あいさつ・時刻・電卓など)にフォールバックするため、LLM 環境がなくても動作確認できます。

使用ライブラリは tkinter / urllib(いずれも標準ライブラリ)、難易度は ★★★ です。OpenAI などのクラウド LLM SDK は使用せず、Ollama の HTTP API を urllib で直接叩く構成にしているため、外部 pip パッケージが一切不要です。

会話履歴は JSON ファイルとして保存・読み込みでき、ダークモード/ライトモードの切り替えも可能。ソースコードをコピーして実行し、仕組みを理解したうえでカスタマイズに挑戦してみてください。

GUI アプリ開発はプログラミングの楽しさを実感できる最も効果的な学習方法のひとつです。変数・関数・クラス・イベント処理・HTTP 通信・スレッド処理などの重要な概念が自然と身につきます。

ℹ️
本アプリは「OpenAI 版」ではなくローカル LLM 版です

URL に openai-api が含まれていますが、本サンプルはクラウド API(OpenAI/ChatGPT)を呼び出さず、ローカル LLM ランタイム「Ollama」の HTTP エンドポイント localhost:11434 を叩く実装です。OpenAI 公式 SDK 版に差し替えたい場合は 7. カスタマイズアイデア を参照してください。

⚠️
Ollama・LLM モデルの利用規約とライセンスについて
  • Ollama ランタイム本体は MIT ライセンスですが、Ollama 経由で実行する LLM モデル(Llama / Mistral / Qwen / Gemma など)はそれぞれ独自のライセンスを持ちます。商用利用の可否・派生作品の扱い・再配布条件はモデルごとに必ず確認してください(例:Llama は Meta Llama Community License、Gemma は Google Gemma Terms of Use)。
  • 本サンプルは 個人学習・動作確認用です。生成テキストを公開コンテンツ・商用サービスに転用する場合は、使用モデルのライセンスと生成物の取り扱い規約を必ず再確認してください。
  • OpenAI API 版に切り替える場合は OpenAI 利用規約と Usage Policies の遵守、API キーの安全管理(環境変数化・Git にコミットしない)が必須です。
  • LLM の出力は 事実誤認や偏見を含む可能性があります。生成結果をそのまま提示するのではなく、用途に応じた人手検証が必要です。
チャットボットUI 実行画面(Windows)
実行画面(Windows)
チャットボットUI 実行画面(Linux Mint)
実行画面(Linux Mint)

2. 機能一覧

  • ループで生成するボタン群を _quick_send() に接続
  • 「🎨」ボタン(_toggle_theme())・「💾」ボタン(_save_history())・「🗑」ボタン(_clear_history())・「送信 ↵」ボタン(_send())で操作
  • <Configure> イベント(ウィンドウ側からの通知)で lambda e: (self.canvas.configure(scrollregion=self.canvas.bbox('all')), self.canvas.itemconfig(self._cwin, width=self.canvas.winfo_width())) を自動実行
  • <Configure> イベント(ウィンドウ側からの通知)で lambda e: self.canvas.itemconfig(self._cwin, width=e.width) を自動実行
  • キー <Return> から _on_enter() を実行
  • キー <Shift-Return> から lambda e: None を実行
  • JSON ファイルの保存(「💾」ボタン)・読み込み(起動時に自動実行)
  • filedialog.asksaveasfilename() によるファイル保存ダイアログ
  • messagebox.askyesno()・messagebox.showerror()・messagebox.showinfo() によるダイアログ通知
  • ウィンドウはタイトル「チャットボットUI」・サイズ 780x620 で起動

3. 事前準備・環境

ℹ️
動作確認環境

Python 3.11 以上(Ubuntu で 3.14・3.15 の起動を確認) / Windows 11(実機)で起動・基本操作を確認 / Linux Mint 22.3(仮想マシン)で起動・画面表示を確認(macOS は未検証)

Windows 11(実機)で起動と基本操作を確認しています(全機能の網羅テストではありません)。Linux Mint 22.3(仮想マシン)では起動と画面表示を確認しました。macOS は標準ライブラリの範囲で動作する想定ですが、未検証です。

  • Python 3.11 以上(Ubuntu で 3.14・3.15 の起動を確認)
  • OS: Windows 11(実機で起動・基本操作を確認)・Linux Mint 22.3(仮想マシンで起動・画面表示を確認)

追加のインストールは不要です(標準ライブラリのみ)。

4. 完全なソースコード

💡
コードのコピー方法

右上の「コピー」ボタンをクリックするとコードをクリップボードにコピーできます。

✅ 追加インストール不要(標準ライブラリのみ使用)
app100.py
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
import json
import os
import threading
import urllib.request
import urllib.error
from datetime import datetime

# ────────────────────────────────────────────────────────────────
# ボット設定: Ollama (ローカル) または シンプルルールベースで動作
# pip install requests は不要 — urllib.request のみ使用
# ────────────────────────────────────────────────────────────────

SAVE_PATH = os.path.join(os.path.dirname(__file__), "chatbot_history.json")

# ────── ルールベースフォールバック ──────
RULES = [
    (["こんにちは", "hello", "hi", "はじめまして"],
     "こんにちは!何かお手伝いできることはありますか?"),
    (["ありがとう", "thank", "感謝"],
     "どういたしまして!他にご質問があればお気軽にどうぞ。"),
    (["python", "パイソン"],
     "Python は汎用プログラミング言語で、データ分析・Web開発・機械学習など多くの分野で使われています。"),
    (["tkinter", "tk"],
     "tkinter は Python 標準の GUI ライブラリです。クロスプラットフォームで動作します。"),
    (["天気"],
     "申し訳ありませんが、私はリアルタイムの天気情報を持っていません。"),
    (["助けて", "help", "ヘルプ"],
     "お手伝いします!質問をどうぞ。"),
    (["時間", "日付", "今日"],
     f"現在の日時は {datetime.now().strftime('%Y年%m月%d日 %H:%M')} です。"),
    (["バイバイ", "さようなら", "bye", "goodbye"],
     "またね!いつでも話しかけてください 👋"),
]

DEFAULT_REPLY = "なるほど、興味深いですね。もう少し詳しく教えてもらえますか?"


def rule_reply(text: str) -> str:
    lower = text.lower()
    for keywords, reply in RULES:
        if any(kw in lower for kw in keywords):
            # 時刻は毎回更新
            if "時間" in reply or "日付" in reply or "今日" in reply:
                return f"現在の日時は {datetime.now().strftime('%Y年%m月%d日 %H:%M')} です。"
            return reply
    return DEFAULT_REPLY


def ollama_reply(model: str, messages: list, timeout: int = 30) -> str:
    """Ollama REST API に送信して応答を取得 (ローカルのみ)"""
    url = "http://localhost:11434/api/chat"
    payload = json.dumps({"model": model, "messages": messages,
                           "stream": False}).encode()
    req = urllib.request.Request(
        url, data=payload,
        headers={"Content-Type": "application/json"},
        method="POST")
    with urllib.request.urlopen(req, timeout=timeout) as resp:
        data = json.loads(resp.read())
    return data["message"]["content"]


class App100:
    """チャットボットUI"""

    MODES = ["ルールベース", "Ollama (ローカル)"]
    THEMES = {
        "ダーク":  {"bg": "#0d1117", "header": "#161b22", "chat_bg": "#0d1117",
                   "user_bg": "#1f6feb", "bot_bg": "#21262d",
                   "user_fg": "#ffffff", "bot_fg": "#c9d1d9", "input_bg": "#161b22"},
        "ライト": {"bg": "#f6f8fa", "header": "#ffffff", "chat_bg": "#ffffff",
                   "user_bg": "#0550ae", "bot_bg": "#eaeef2",
                   "user_fg": "#ffffff", "bot_fg": "#24292f", "input_bg": "#ffffff"},
    }

    def __init__(self, root):
        self.root = root
        self.root.title("チャットボットUI")
        self.root.geometry("780x620")
        self._history = []   # [{"role": "user"|"bot", "content": ..., "ts": ...}]
        self._ollama_messages = []  # for Ollama API context
        self._theme = "ダーク"
        self._build_ui()
        self._load_history()

    def _build_ui(self):
        self._th = self.THEMES[self._theme]
        th = self._th

        self.root.configure(bg=th["bg"])

        # ヘッダー
        self.header = tk.Frame(self.root, bg=th["header"], pady=8)
        self.header.pack(fill=tk.X)
        self.title_lbl = tk.Label(self.header, text="🤖 チャットボットUI",
                                   font=("Noto Sans JP", 13, "bold"),
                                   bg=th["header"], fg="#4fc3f7")
        self.title_lbl.pack(side=tk.LEFT, padx=12)

        # 設定ボタン群
        tk.Label(self.header, text="モード:", bg=th["header"], fg="#8b949e",
                 font=("Arial", 9)).pack(side=tk.LEFT, padx=(8, 2))
        self.mode_var = tk.StringVar(value="ルールベース")
        self.mode_cb = ttk.Combobox(self.header, textvariable=self.mode_var,
                                     values=self.MODES, state="readonly", width=16)
        self.mode_cb.pack(side=tk.LEFT)

        tk.Label(self.header, text="Ollamaモデル:", bg=th["header"], fg="#8b949e",
                 font=("Arial", 9)).pack(side=tk.LEFT, padx=(8, 2))
        self.model_var = tk.StringVar(value="llama3")
        ttk.Entry(self.header, textvariable=self.model_var, width=10).pack(side=tk.LEFT)

        ttk.Button(self.header, text="🎨",
                   command=self._toggle_theme).pack(side=tk.RIGHT, padx=4)
        ttk.Button(self.header, text="💾",
                   command=self._save_history).pack(side=tk.RIGHT, padx=2)
        ttk.Button(self.header, text="🗑",
                   command=self._clear_history).pack(side=tk.RIGHT, padx=2)

        # チャットエリア (Canvas + Scrollbar で仮想スクロール)
        self.chat_frame_outer = tk.Frame(self.root, bg=th["chat_bg"])
        self.chat_frame_outer.pack(fill=tk.BOTH, expand=True, padx=0, pady=0)

        self.canvas = tk.Canvas(self.chat_frame_outer, bg=th["chat_bg"],
                                 highlightthickness=0)
        vsb = ttk.Scrollbar(self.chat_frame_outer, command=self.canvas.yview)
        self.canvas.configure(yscrollcommand=vsb.set)
        vsb.pack(side=tk.RIGHT, fill=tk.Y)
        self.canvas.pack(fill=tk.BOTH, expand=True)

        self.chat_inner = tk.Frame(self.canvas, bg=th["chat_bg"])
        self._cwin = self.canvas.create_window((0, 0), window=self.chat_inner,
                                                anchor="nw")
        self.chat_inner.bind("<Configure>",
                              lambda e: (self.canvas.configure(
                                  scrollregion=self.canvas.bbox("all")),
                                  self.canvas.itemconfig(
                                      self._cwin, width=self.canvas.winfo_width())))
        self.canvas.bind("<Configure>",
                          lambda e: self.canvas.itemconfig(
                              self._cwin, width=e.width))

        # ウェルカムメッセージ
        self._add_bubble("bot",
                         "こんにちは!私はチャットボットです。\n"
                         "何でも話しかけてください。")

        # 入力エリア
        input_f = tk.Frame(self.root, bg=th["input_bg"], pady=6)
        input_f.pack(fill=tk.X, padx=0)

        self.input_text = tk.Text(input_f, height=3, bg=th["input_bg"],
                                   fg=th["bot_fg"],
                                   font=("Arial", 11), relief=tk.FLAT,
                                   insertbackground=th["bot_fg"],
                                   wrap=tk.WORD)
        self.input_text.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(12, 4))
        self.input_text.bind("<Return>",      self._on_enter)
        self.input_text.bind("<Shift-Return>", lambda e: None)

        tk.Button(input_f, text="送信 ↵",
                  command=self._send,
                  bg="#1565c0", fg="white", relief=tk.FLAT,
                  font=("Arial", 10, "bold"), padx=14, pady=6,
                  activebackground="#0d47a1", bd=0).pack(side=tk.LEFT, padx=4)

        # クイック返信候補
        quick_f = tk.Frame(self.root, bg=th["bg"])
        quick_f.pack(fill=tk.X, padx=8, pady=(2, 4))
        for q in ["こんにちは", "Pythonについて教えて", "今日の日時は?", "ありがとう"]:
            tk.Button(quick_f, text=q, command=lambda t=q: self._quick_send(t),
                      bg=th["header"], fg="#8b949e", relief=tk.FLAT,
                      font=("Arial", 8), padx=6, pady=2,
                      activebackground=th["bot_bg"], bd=0).pack(
                side=tk.LEFT, padx=2)

        self.status_var = tk.StringVar(value="準備完了")
        self.status_lbl = tk.Label(self.root, textvariable=self.status_var,
                                    bg=th["header"], fg="#8b949e",
                                    font=("Arial", 9), anchor="w", padx=8)
        self.status_lbl.pack(fill=tk.X, side=tk.BOTTOM)

    def _add_bubble(self, role: str, content: str):
        th = self._th
        is_user = (role == "user")
        outer = tk.Frame(self.chat_inner, bg=th["chat_bg"])
        outer.pack(fill=tk.X, padx=8, pady=3)

        bg   = th["user_bg"] if is_user else th["bot_bg"]
        fg   = th["user_fg"] if is_user else th["bot_fg"]
        side = tk.RIGHT if is_user else tk.LEFT

        icon = "🧑" if is_user else "🤖"
        tk.Label(outer, text=icon, bg=th["chat_bg"],
                 font=("Arial", 14)).pack(side=side, anchor="n", padx=(0, 4))

        bubble = tk.Frame(outer, bg=bg, padx=10, pady=6)
        bubble.pack(side=side, anchor="n")

        tk.Label(bubble, text=content, bg=bg, fg=fg,
                 font=("Arial", 10), justify=tk.LEFT,
                 wraplength=480, anchor="w").pack(anchor="w")

        ts = datetime.now().strftime("%H:%M")
        tk.Label(bubble, text=ts, bg=bg, fg="#777",
                 font=("Arial", 7)).pack(anchor="e")

        self._history.append({"role": role, "content": content,
                               "ts": datetime.now().isoformat()})
        # スクロールを最下部へ
        self.root.after(50, lambda: self.canvas.yview_moveto(1.0))

    def _on_enter(self, event):
        if not (event.state & 0x1):  # Shift押下中でなければ送信
            self._send()
            return "break"

    def _send(self):
        text = self.input_text.get("1.0", tk.END).strip()
        if not text:
            return
        self.input_text.delete("1.0", tk.END)
        self._add_bubble("user", text)
        self.status_var.set("応答中...")
        threading.Thread(target=self._get_reply, args=(text,), daemon=True).start()

    def _quick_send(self, text: str):
        self.input_text.delete("1.0", tk.END)
        self.input_text.insert("1.0", text)
        self._send()

    def _get_reply(self, user_text: str):
        mode = self.mode_var.get()
        try:
            if mode == "Ollama (ローカル)":
                self._ollama_messages.append({"role": "user", "content": user_text})
                reply = ollama_reply(self.model_var.get(), self._ollama_messages)
                self._ollama_messages.append({"role": "assistant", "content": reply})
            else:
                reply = rule_reply(user_text)
        except urllib.error.URLError:
            reply = "Ollama に接続できません。localhost:11434 でサービスが起動しているか確認してください。"
        except Exception as e:
            reply = f"エラーが発生しました: {e}"

        self.root.after(0, self._add_bubble, "bot", reply)
        self.root.after(0, self.status_var.set, "準備完了")

    def _toggle_theme(self):
        self._theme = "ライト" if self._theme == "ダーク" else "ダーク"
        # ウィンドウを再構築(簡易)
        for widget in self.root.winfo_children():
            widget.destroy()
        self._build_ui()
        for msg in self._history:
            if msg["role"] != "bot" or "こんにちは!私はチャットボット" not in msg["content"]:
                self._add_bubble(msg["role"], msg["content"])

    def _clear_history(self):
        if messagebox.askyesno("確認", "履歴を全て削除しますか?"):
            self._history.clear()
            self._ollama_messages.clear()
            for widget in self.chat_inner.winfo_children():
                widget.destroy()
            self._add_bubble("bot", "履歴をクリアしました。また話しかけてください!")

    def _save_history(self):
        path = filedialog.asksaveasfilename(
            defaultextension=".json",
            filetypes=[("JSON", "*.json")])
        if not path:
            return
        try:
            with open(path, "w", encoding="utf-8") as f:
                json.dump(self._history, f, ensure_ascii=False, indent=2)
            messagebox.showinfo("完了", f"保存: {path}")
        except Exception as e:
            messagebox.showerror("エラー", str(e))

    def _load_history(self):
        if os.path.exists(SAVE_PATH):
            try:
                with open(SAVE_PATH, encoding="utf-8") as f:
                    self._history = json.load(f)
                # 過去の会話を再表示 (最新50件)
                for msg in self._history[-50:]:
                    self._add_bubble(msg["role"], msg["content"])
            except Exception:
                self._history = []

    def _auto_save(self):
        try:
            with open(SAVE_PATH, "w", encoding="utf-8") as f:
                json.dump(self._history[-200:], f, ensure_ascii=False)
        except Exception:
            pass


if __name__ == "__main__":
    root = tk.Tk()
    app = App100(root)
    root.mainloop()

5. コード解説

チャットボットUIのコードを、実際に書かれている実装に沿って解説します。

クラス設計とコンストラクタ

App100 クラスにアプリの全機能をまとめています(メソッド12個・全304行)。__init__ ではタイトル「チャットボットUI」とウィンドウサイズ 780x620 を設定します。あわせて状態を保持する変数(self._theme)を初期化し、そのあと _build_ui() → _load_history() の順に呼び出して初期状態を作ります。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

ウィジェット構成

画面は tk.Frame×7・tk.Label×7・ttk.Combobox・ttk.Entry・ttk.Button×3・tk.Canvas・ttk.Scrollbar・tk.Text・tk.Button(ループで生成) で構成しています。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

イベント処理とボタンの接続

「🎨」ボタン(_toggle_theme())・「💾」ボタン(_save_history())・「🗑」ボタン(_clear_history())・「送信 ↵」ボタン(_send())を command= で接続しています。ボタンはループ内で command=lambda t=q: self._quick_send(t) の形で生成しています。既定引数でループ変数を固定するのがポイントで、これを書かないと全ボタンが最後の値で動いてしまいます。また bind("<Configure>", ...) から lambda e: (self.canvas.configure(scrollregion=self.canvas.bbox('all')), self.canvas.itemconfig(self._cwin, width=self.canvas.winfo_width()))、bind("<Configure>", ...) から lambda e: self.canvas.itemconfig(self._cwin, width=e.width)、bind("<Return>", ...) から _on_enter()、bind("<Shift-Return>", ...) から lambda e: None を呼べるようにイベントも登録しています。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

JSON への保存と読み込み

保存は「💾」ボタンから _save_history() が呼ばれ、json.dump() で保存ダイアログで選んだファイル(既定名 chatbot_history.json)へ書き出します。起動時は json.load() で読み戻します。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

クラス定数によるデータ定義

クラスの先頭でデータを定数として定義しています:MODES = ['ルールベース', 'Ollama (ローカル)']。ロジックの中に直接書かず定数にまとめておくと、内容を変えたいときに1か所を書き換えるだけで済みます。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

例外処理

try-except で Exception・urllib.error.URLError を捕捉しています。あわせて messagebox.askyesno()・messagebox.showerror()・messagebox.showinfo() のダイアログで、ユーザーへの通知・確認を行います。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

6. ステップバイステップガイド

このアプリをゼロから自分で作る手順を解説します。コードをコピーするだけでなく、実際に手順を追って自分で書いてみましょう。

  1. 1
    ファイルを作成する

    新しいファイルを作成して app100.py と保存します。使うのは datetime・json・os・threading・tkinter・urllib だけなので、追加インストールは不要です。

  2. 2
    クラスの骨格を作る

    App100 クラスを定義し、__init__ と、末尾の root = tk.Tk() ~ mainloop() の最小構成を書いて、まず空のウィンドウが出ることを確認します。

  3. 3
    ウィンドウを設定する

    title("チャットボットUI")・geometry("780x620") をコンストラクタで設定します。

  4. 4
    画面部品を並べる

    _build_ui() の中で、tk.Frame×7・tk.Label×7・ttk.Combobox・ttk.Entry・ttk.Button×3・tk.Canvas・ttk.Scrollbar・tk.Text・tk.Button(ループで生成) を作って配置します(掲載コードと同じ並び順で書くとレイアウトが一致します)。まず表示だけ確認しましょう。

  5. 5
    イベントを接続する

    「2. 機能一覧」で挙げた各ボタンを command= で対応するメソッド(_toggle_theme()・_save_history()・_clear_history()・_send())につなぎます。ループ生成のボタンは command=lambda t=q: self._quick_send(t) の形で接続します。bind("<Configure>", ...)・bind("<Configure>", ...)・bind("<Return>", ...)・bind("<Shift-Return>", ...) の登録も忘れずに。

  6. 6
    中心になるメソッドを実装する

    アプリの本体である _add_bubble()(29行)・_save_history()(12行)・_toggle_theme()(9行)など を実装します。

  7. 7
    動作確認する

    python app100.py で起動し、各ボタンが反応すること・キー操作(<Return>・<Shift-Return>)が効くことを確認します。

7. カスタマイズアイデア

基本機能を習得したら、以下のカスタマイズに挑戦してみましょう。

💡 ダークモードを追加する

bg色・fg色を辞書で管理し、ボタン1つでダークモード・ライトモードを切り替えられるようにしましょう。

💡 クラス定数を書き換えて動きを変える

データはクラス定数(MODES・THEMES)にまとまっています。中身を書き換えて動きが変わることを確かめましょう。複数の定数が対応関係にある場合は、セットで整合させる必要がある点に注意してください。

💡 設定ダイアログ

フォントサイズや色などの設定をユーザーが変更できるオプションダイアログを追加しましょう。

8. よくある問題と解決法

❌ 文字のフォントが崩れる・見た目が違う

原因:このコードは font=("Noto Sans JP", ...) のようにフォント名を直接指定しています。環境にこのフォントが入っていないと、OS が代わりのフォントで表示するため、見本と見た目が変わることがあります。

解決法:動作には支障ありません。気になる場合は font 引数の名前を自分の OS に入っているフォントへ書き換えるか、font 引数を省略してください。

❌ 写経したら全部のボタンが同じ動きになる

原因:ループでボタンを作るとき、このコードは command=lambda t=q: self._quick_send(t) のように既定引数でループ変数を固定しています。これを省略してループ変数を直接書くと、ループ終了後の最後の値が全ボタンで共有されてしまいます(Python のクロージャの有名な罠です)。

解決法:掲載コードのとおり、lambda の既定引数でその時点の値を渡してください。

❌ <Return> キーを押しても反応しない

原因:bind("<Return>", ...) はウィジェット単位で登録されるため、ウィンドウ(またはバインド先)にキーボードフォーカスが無いとイベントが届きません。

解決法:一度ウィンドウ内をクリックしてフォーカスを与えてから、キーを押してください。

❌ ウィンドウの大きさが画面に合わない

原因:geometry("780x620") の固定サイズで起動するためです。

解決法:この数値を書き換えて起動サイズを調整してください。なお、このコードにはウィンドウサイズを固定する設定(resizable の指定)が無いため、ウィンドウの端をドラッグしたサイズ変更は既定どおり可能です。

9. 練習問題

アプリの理解を深めるための練習問題です。気になるものから挑戦してみてください(課題3は定型の発展課題です)。どちらの課題も「ルールベース」モードで確かめられるため、Ollama の導入は要りません。会話の中身は暗号化されない JSON でパソコンに残るので、本物の個人情報や仕事の秘密は入力しないでください。

  1. 課題1:会話を自動保存して次の起動でも残るようにする

    起動時の _load_history() は、コードと同じフォルダの chatbot_history.json を読み込みます。ところが、そのファイルを書き出す _auto_save() はどこからも呼ばれていません。会話が自動で残るようにつないでください。

    期待結果:何度か会話してからアプリを閉じ、もう一度起動すると前回の会話が吹き出しで復元される。

    合格条件

    • コードと同じフォルダに chatbot_history.json ができ、中身が JSON として読める
    • 「🗑」で履歴を消してから閉じると、次の起動でも消えたままになる
    • 「💾」の手動保存は、これまでどおり好きな場所へ別名で書き出せる

    ヒント①(どこを触るか): 触るのは1〜2か所です。_add_bubble() の末尾にある self._history.append(...) の後ろで self._auto_save() を呼ぶのが最短です。閉じるときにまとめて書き出すなら、__init__() に self.root.protocol("WM_DELETE_WINDOW", ...) を足す形になります。
    ヒント②(使うもの): 保存先の SAVE_PATH は、ファイル先頭で os.path.dirname(__file__) から組み立て済みです。_auto_save() が書き出すのは self._history[-200:] なので、会話が伸びてもファイルは太り続けません。
    つまずきやすい点: _add_bubble() は _load_history() の復元ループからも呼ばれるため、起動した直後にも保存が走ります。_clear_history() は履歴を空にした直後に案内の吹き出しを1つ足すので、消したあとのファイルは空ではなく1件になります。

  2. 課題2:テーマを切り替えても吹き出しが増え続けないようにする

    「🎨」を押すと _toggle_theme() が画面を作り直し、self._history を順に回して吹き出しを描き直します。ところが _add_bubble() は末尾で self._history に1件足すため、回している最中に中身が増えていきます。会話が1件でもあるとこのループは終わりません。

    期待結果:2〜3往復ぶん会話してから「🎨」を押しても、吹き出しの並びと件数が切り替え前と同じまま。

    合格条件

    • 切り替えたあとに送った発言も、吹き出しが1つだけ増える
    • テーマを2回切り替えて元に戻しても、同じ発言が重複しない
    • 「🗑」の履歴削除は、これまでどおり動く

    ヒント①(どこを触るか): 触るのは _toggle_theme() の最後のループ1か所です。_build_ui() の呼び出しと for msg in self._history: の間に、写しを取る処理を入れます。
    ヒント②(使うもの): msgs = list(self._history) で写しを作り、self._history.clear() で空にしてから msgs を回すと件数が保たれます。_add_bubble() に record=False のような引数を足し、追記だけを止めるやり方も有効です。
    つまずきやすい点: 写しを取る位置が _build_ui() より前だと、ウェルカムメッセージの扱いがずれます。_build_ui() の中で _add_bubble() が1回呼ばれ、その1件も self._history に入るためです。

  3. 課題3:新しいボタンを追加する

    既存のボタンと同じ書き方で新しいボタンを1つ足し、押されたときに動くメソッドを自作してみましょう。ボタンの作成と command= の接続がセットで身に付きます。

🚀
次に挑戦するアプリ

このアプリをマスターしたら、次のアプリに挑戦しましょう。

🐛
エラーが出て動かないときは

写経中に赤いエラー文が出たら、Pythonエラー一覧&解決法(英語メッセージ逆引き)で原因と直し方をすぐ確認できます。

📖
次のレベルへ進む参考書

このレベルのアプリが作れたら、入門書の次の「作るための本」へ進む時期です。実践におすすめのPython本(当サイトの参考書ランキング総合3〜4位)で自動化とコードの書き方の2冊を、その直後の用途別専門書の節でデータ分析・Web・AIの本を比較しています。