OpenAIのAPI Ver.1.0で履歴をローカルで管理するデスクトップチャットアプリ(tkinter版)

# OpenAI API Ver.1.0-で履歴をローカルストレージで管理するチャットプログラムのtkinter版
　python１年生のベテランプログラマです。Qiitaでは2回目の投稿となります。

 :::note info
 記事の書き方で以下の２点を改良しました。
 (1)コピペしやすいように改良
 (2)受動態の多用を改め能動態をメインに
 以下は未解決の課題です。
 (1)記事が長い
:::

 OpenAI API Ver.1.0-によるチャット用デスクトップアプリケーションです。履歴はローカルなドライブで管理しています。チャットの内容が生成AIの学習に使われたり、サーバーに残り続けることはありません。生成AIの実用的なデスクトップアプリケーションの開発の勉強用です。
 
　OpenAIのAPI Ver.1.0-は、APIのVer.0.28のAPIとは全く互換性が無く、古いAPIでは動作しないので注意してください。巷のAPI情報(特に書籍)はVer.0.28が多いようです。APIを使うのでChatGPT 有料プランへの加入とAPIキーの取得が必須となります。
 
　設定情報は環境変数"INIFILE_FOLDER"で指示したフォルダの"chat.ini"に保存します。INIFILE_FOLDERを設定していない場合"."を代わりに使います。
　環境変数を使わず、"."に設定ファイルができるのも嫌だという人は、ソースに以下のような行が2行あるので、inipathを適当なフォルダへのパス文字列に変更してください。
```python
inipath = os.environ["INIFILE_FOLDER"]
```

:::note info
windowsの場合、通常INIFILE_FOLDERは
 C:\Users\■■■\AppData\Roaming
の下に適当なフォルダを作ります(■■■はユーザー名)。
:::

前回はjupyter notebook上で、ipywidgetsを使ったGUIで動作するチャットプログラムを作成しました。

https://qiita.com/ngktakanobu/items/c31d9c9df84970f6661f

上のプログラムにはいくつかの問題がありました。問題点のうち以下の点を改良したバージョンについて記事を書きました。
<ul>
<li><b>スタントアローンのデスクトップアプリケーションとして動作するように改良</b></li>
前回はjupyter notebook上での動作なので作りかけのような見た目でした。
今回は標準ライブラリであるtkinterでGUIを構築したので、普通のデスクトップアプリケーションのような見栄えと機能を持っています。
<li><b>APIキーがユーザーからは見られないように改良(コンパイルした場合)</b></li>
</ol>

本稿の構成は以下の通りです。
<ol type="A">
<li>まず、最初にコピペ用の全ソースを提示します</li>
<li>次にipywidgets版と異なる点を中心に重要なメンバ関数の説明を行います</li>
<li>次にスタントアローン実行とpyinstallerによるコンパイルについて</li>
<li>最後に次回予告</li>
</ol>
となります。

# １．コピペ用の全ソース
600行以上あるのでGistに保存しています。

https://gist.github.com/TakanobuSaito/eefc7b7d83381a0a2f7987f8ad0a5242

ソースは、大きく以下の４つのプログラムからなっています。
<ol>
<li><b>import、グローバル変数、履歴情報管理クラス(CChatHistoryクラス)</b></li>
フォルダを変更するresetfolderメソッドを追加しています。また、タイトル文字列が読みやすくなっています。
<li><b>チャットクラス(ChatMain)</b></li>
前回のChatMainクラスとメンバ関数構成は似ています。GUI周りは大きく異なっています。APIキーのiniファイルからの読み込みと復号化の関数を追加しています。
<li><b>チャットAPIKEYチェッククラス(ChatPrepare)</b></li>
APIキーの暗号化とiniファイルへの書き込みのクラスです。
<li><b>main()関数と__name == "__main__"のおまじない</b></li>
jupyter notebookだと、__name__は"__main__"になっているので、notebook上での実行でもmain()を実行します。ソースを"Chatbytkinter.py"にまとめて

```
python Chatbytkinter.py
```

と実行した場合でもmain()を実行します。
</ol>

# ２．各論
## ipywidgetsからtkinterへ
　tkinter はPythonでGUIのプログラムを作るための標準ライブラリです。widgetがたくさんある場合、ipywidgets版と比べて格段に画面設計が楽になっています。
　ipywidgetsはjupyter notebook上で簡単なGUIを作るときは非常に便利ですが、widgetが増えてくると収拾がつかなくなります。デスクトップアプリケーションを作ることもできません。
　tkinterは標準ライブラリなので何らかのモジュールをインストールしないでも使えます。見た目もクールです。<b>何よりもスタンドアローンのデスクトップアプリケーションを作ることができます。</b>

## 画面構成（ChatMainクラス）
　画面構成は以下の通りです。メインウィンドウのサイズ変更に応じてwidgetの大きさを自動調節してくれます(tkinterが勝手にやってくれます)。左に履歴のタイトルのリストを表示しており、右上が「出力エリア」、右下が「入力ボックス」となっています。「Send」ボタンと「Clear」ボタンは出力エリアと入力ボックスの間にあります。 ipywidgets版から追加となるwidgetが「Send」ボタンと「Clear」ボタンの間にある「プログレスバー」と、「Clear」ボタンの右にある「◎」ボタンです。
 
![画面構成](https://www.artlogic.co.jp/artlogic/GenerativeAI/tkinterwindow.png "画面構成")

## 操作法
<ol>
<li><b>「Send」ボタンクリック</b></li>
「入力ボックス」に生成AIへの問いかけを入れて「Send」ボタンをクリックすると、APIを通してGPT-4によって生成された回答を返します。やりとり(セッション)は「出力エリア」に表示します。

<li><b>「Clear」ボタンをクリック</b></li>
  「Clear」ボタンをクリックすると、現在のセッションにGPT-4が付けたタイトルを付加して、jsonファイルに保存、「履歴表示欄」にタイトルを追加します。

<li><b>「履歴表示欄」の選択</b></li>
  「履歴表示欄」で履歴を選択すると、履歴を読み込み「出力エリア」に表示します。履歴に保存しているセッションの続きからやりとりを継続することができます。選択時にセッションの内容に変更があれば、新しいタイトルをつけてjsonファイルに保存、「履歴表示欄」を更新します。
<li><b>「◎」ボタンをクリック</b></li>
　「◎」ボタンをクリックすると、履歴保存フォルダを変更するための、フォルダ名設定ダイアログを表示します。フォルダを変更する前に、現在のセッションに変更があれば、新しいタイトルをつけて保存します。フォルダ名を変更後に「履歴表示欄」を更新します。
<li><b>メインウィンドウのクローズ</b></li>
 　メインウィンドウを「×」ボタンでクローズするときの、現在のセッションに変更があれば、新しいタイトルをつけて保存します。
</ol>

## 画面（widget）設計（ChatMainクラス）
設計は Microsoft Excel で行いました。3行6列のframe widgetです。列幅と行高を調整してwidgetを配置。列幅と行高はframe widgetのcolumnconfigure/rowconfigureメソッドで反映します。htmlのtableにあるような"rowspan"、"columnspan"を使って、複数行、複数列にまたがるwidgetも作成可能です。
　ピクセル単位ではなく、行と列で位置とサイズを指定できるので、あまり複雑でなければ配置は簡単です。マウスで位置とサイズを指定して視覚的に配置することができるGUIエディタもあるそうです。今回は複雑ではないので使っていません。

![画面設計](https://www.artlogic.co.jp/artlogic/GenerativeAI/tkinterxls.png "画面設計")

### 行幅、列幅の変更に相当する weight 値の変更部分
```python
        self.frame1 = ttk.Frame(self.root, padding=8)
        self.frame1.columnconfigure(0, weight=6)
        self.frame1.columnconfigure(1, weight=1)
        self.frame1.columnconfigure(2, weight=5)
        self.frame1.columnconfigure(3, weight=7)
        self.frame1.columnconfigure(4, weight=4)
        self.frame1.columnconfigure(5, weight=1)
        self.frame1.rowconfigure(0, weight=6)
        self.frame1.rowconfigure(1, weight=1)
        self.frame1.rowconfigure(2, weight=3)
        self.frame1.grid(sticky=(N, W, S, E))      
```
たとえば"self.frame1.columnconfigure(0, weight=6)"では１列目を基準となる幅の6倍にしています。1列目には履歴表示欄があるので、幅を広めにとっています。"self.frame1.columnconfigure(1, weight=1)"は、履歴表示欄のスクロールバーの部分で幅は"1"と狭くなっています。最後の"grid(sticky=(N, W, S, E))"で、frame1 widgetは、外側のウィンドウサイズの変更に応じて同じ割合で列幅と行高を自動変更します。

### 履歴表示欄とスクロールバー
```python
        # Listbox　widgetsインスタンス変数名はMFC(microsoft foundation class)風
        self.m_listboxctrlhistory = Listbox(self.frame1)
        self.m_listboxctrlhistory.grid(row=0, column=0, rowspan=3, sticky=(N, W, S, E))
        self.m_listboxctrlhistory.bind('<<ListboxSelect>>',self.selchange)
        
        # Scrollbar for listbox
        self.m_scrollbarctrllistbox = ttk.Scrollbar(
            self.frame1,
            orient=VERTICAL,
            command=self.m_listboxctrlhistory.yview)
        self.m_listboxctrlhistory['yscrollcommand'] = self.m_scrollbarctrllistbox.set
        self.m_scrollbarctrllistbox.grid(row=0, column=1, rowspan=3, sticky=(N, S))
```
履歴表示欄はListbox widget、スクロールバーはScrollbar widgetのインスタンスです。"command=self.m_listboxctrlhistory.yview"で、スクロールバーの動作に応じて履歴表示欄をスクロールします。"self.m_listboxctrlhistory['yscrollcommand'] = self.m_scrollbarctrllistbox.set"で、逆に履歴表示欄のスクロールをスクロールバーの方に反映します。
　スクロールバーは”sticky=(N, S)”となっています。frame1のサイズを変更しても、スクロールバーは上下には伸びますが、幅が広がることはありません。

### 出力エリア
```python
        # Text Output Area
        # f = Font(family='Helvetica', size=16)
        # v1 = StringVar()　　# 変数で内容を定義してupdateメソッドで自動的に書き換えることも可能
        self.m_editctrloutputarea = Text(self.frame1, height=20, width=70)
        # m_editctrloutputarea.configure(font=f) # フォントの変更も可能
        self.m_editctrloutputarea.grid(row=0, column=2, columnspan = 3, sticky=(N, W, S, E))
        self.m_editctrloutputarea.tag_config('user', foreground="blue")                       # ユーザー文字色は青
        self.m_editctrloutputarea.tag_config('assistant', foreground="green")                 # GPT-4からの回答の文字色は緑
        self.m_editctrloutputarea.tag_config('system', foreground="gray")                     # system文字色は灰色
        self.m_editctrloutputarea.tag_config('error', background="yellow", foreground="red")  # エラーは赤

        # Scrollbar for Text Output Area
        self.m_scrollbarctrloutputarea = ttk.Scrollbar(
            self.frame1,
            orient=VERTICAL,
            command=self.m_editctrloutputarea.yview)
        self.m_editctrloutputarea['yscrollcommand'] = self.m_scrollbarctrloutputarea.set
        self.m_scrollbarctrloutputarea.grid(row=0, column=5, sticky=(N, S))
```
出力エリアのテキストは、ユーザーテキストを<font color='blue'>青</font>、GPT-4からの回答を<font color='green'>緑</font>で表示します。また、出力エリアの右側には、履歴表示欄と同じようにスクロールバーを置いています。

:::note info
出力エリアは書き込み不可にしていません。本来なら書き込み不可にして、プログラムでテキストを設定したりクリアするたびに、いったん書き込み可能にしてから書き込み不可へ戻す必要があります。出力エリアを編集してもセッション内容には反映されません。
::: 

## apiキー設定ダイアログ（ChatPrepareクラス）
  今回、最も苦労したのが「apiキー設定ダイアログ」です。apiキーを一回も設定していないときに、アプリケーション起動時にモーダルダイアログ(simpledialog widget)を出してapiキーを設定するようになっています。
  当初、モーダルダイアログの処理が済んでからメイン画面を出そうとしました。ところが、モーダルダイアログが出ているのにメイン画面が表示されてしまうのです。

![APIキー設定](https://www.artlogic.co.jp/artlogic/GenerativeAI/apikey.png "APIキー設定")

　長時間かけて調べたところ同じrootを使っていることが原因でした。メイン画面とは別のroot widget()を使うことで解決しました。withdraw()メソッドでrootウィンドウを表示しないようにすることがポイントです。
  今回は Chatmain クラスとは別の ChatPrepare クラスのインスタンスでapiキーの設定を行いました。
　チャットアプリケーションではAPIキーが管理者以外へ漏ないようにすることも重要です。今回は、設定ファイルに暗号化したAPIキーを書き込むことで秘匿しました。
　APIキー入力のエディットボックスが狭いのは、tkinterのsimpledialogでエディットボックスを大きくする方法が難しかったからです。しかしAPIキー全体が見えないので管理者がコピペする時に隠さなくても済みます。怪我の功名ということにして小さいまま残してあります。

### APIキー設定部分のソース
```python
    # simpleダイアログにAPIキーを入力して、設定ファイルに作る
    def makeini(self):
        # デフォルト root ウィンドウ
        root = Tk()
        root.withdraw() # 非表示のルートウィンドウを作成
        inputdata = simpledialog.askstring("APIキー", "OPENAIのAPIキーの値を入力してください",)
        # ダイアログボックスが閉じられるまで待機
        if(inputdata != None):
            self.apikey = inputdata
            # 暗号化
            self.token = self.cryptencode(self.apikey, self.cryptkey)
            config_ini = configparser.ConfigParser()
            # ついでに履歴保存フォルダ名も設定
            config_ini['Path'] = {'historypath': self.savefolder}
            # iniファイルに保存
            config_ini['General'] = {'apikey': self.token}
            with open(self.inifullpath, 'w') as configfile:
                config_ini.write(configfile)
            self.prepared = True
```
　windowsのiniファイルと同じようなフォーマットの設定ファイルを読み書きする標準ライブラリ configparser によって、APIキーと履歴保存フォルダの初期値を"chat.ini"ファイルに書き込みます。
　iniファイルは以下のようになっています。暗号化apikeyの部分は一部、隠してあります。
```
[Path]
historypath = d:/tmp/chat_history

[General]
apikey = =B1rrs490**********************************************************
```
暗号化と復号化については後述します。

### APIキー設定以外のソース（ChatPrepareクラス）
```python
# API キー設定クラス
# (1)すでに設定ファイルにAPIキーがある--->何もしないで終了
# (2)設定ファイル無い、あるいはAPIキーが無い--->APIキー設定ダイアログ
# rootを変えないでsimpledialogを出そうとすると、simpledialogの上に、メインウィンドウが表示されてしまう。
# そこで異なるrootでsimpledialogを出すようにした。
class ChatPrepare:
    def __init__(self, savefolder):
        self.savefolder = savefolder
        self.token = ""     # 暗号化apikey
        self.apikey = ""    # apikey
        self.prepared = False  # 準備完了でTrue
        self.cryptkey = 13264

    # 設定ファイルを読み込んで apikey と self.savefolder を設定する
    def chatinit(self):
        try:
            inipath = os.environ["INIFILE_FOLDER"]
        except Exception as e:
            messagebox.showwarning("環境変数 警告", "環境変数 INIFILE_FOLDER が設定されていないのでカレントディレクトリを設定ファイル保存場所に設定します。")
            inipath = "."
        os.makedirs(inipath, exist_ok=True)
        self.inifullpath = os.path.join(inipath, "chat.ini")
        cryptkey = 13264
        config_ini = configparser.ConfigParser()
        # iniファイルが無い
        if not os.path.exists(self.inifullpath):
            self.makeini()
        # iniファイルがあった
        else:
            # iniファイルが存在する場合、ファイルを読み込む
            with open(self.inifullpath, 'r', encoding='utf-8') as fp:
                config_ini.read_file(fp)
                read_general = config_ini['General']
                read_path = config_ini['Path']
                self.token = read_general.get('apikey')
                self.savefolder = read_path.get('historypath')
                if(self.savefolder == None):
                    self.savefolder = savefolder
            if(self.token == None):
                self.makeini()
            else:
                self.prepared = True
        
    # 元のapikey文字列str1をエンコード文字列にする
    # 可読文字列apikeyを8bitのASCIIコード数列に変換、暗号化後に8bit数列を6bit数列に小分けして、暗号化トークン(可読文字列)にする
    def cryptencode(self,str1, acryptseed):
        (後述するので略)
        return astr2
```
"self.cryptkey = 13264"の部分は、暗号化のキーでChatMainクラスでも同じself.crypykeyを設定する必要があります。
　iniファイルが無い場合と、iniファイルがあってもapikeyが無い場合は、先ほどの「APIキー設定ダイアログ」を表示してAPIキーの入力を促します。
  iniファイルにAPIキーがあったときは、self.preparedをTrueに変更します。ChatMainクラスのインスタンスの初期化時にChatPrepareクラスのインスタンス変数"prepared"がTrueであることをチェックします。

## 履歴保存フォルダ名設定ダイアログ
 画面の「◎」ボタンをクリックすると履歴保存フォルダを変更できます。フォルダを変更して「OK」ボタンをクリックすると、「Clear」ボタンクリック時と同じ処理(現在のセッションにタイトルをつけて保存。出力エリアと入力ボックスをクリア)をした後に、履歴保存フォルダを切り替えて、履歴表示欄の内容を更新します。
 
!["履歴保存フォルダ名設定"](https://www.artlogic.co.jp/artlogic/GenerativeAI/folderdialog.png "履歴保存フォルダ名設定")
 
履歴保存フォルダ名設定部分のソース
```python
    # 設定ファイルを読み込んで apikey と self.savefolder を設定する
    def set_history_folder(self):
        fld = filedialog.askdirectory(initialdir = self.savefolder, title='履歴保存フォルダを指定してください')
        if(len(fld) > 0 and fld.lower() != self.savefolder.lower()):
            # セッションのクリア処理(self.savefolderの変更前にすること)
            self.clear_session()
            self.savefolder = fld
            config = configparser.ConfigParser()
            # iniファイルに保存
            config['General'] = {'apikey': self.token}
            config['Path'] = {'historypath': self.savefolder}
            with open(self.inifullpath, 'w') as configfile:
                config.write(configfile)
            self.history.resetfolder(self.savefolder)
            self.m_listboxctrlhistory.delete(0,END)
            for i in range(len(self.history.titlelist)):
                self.m_listboxctrlhistory.insert(i, self.history.titlelist[i])
            self.root.title(f'GPT-4 : 現在の履歴保存フォルダは{self.savefolder}')
```
tkinterのfiledialog widgetは、キャンセルボタンやウィンドウクローズボタンをクリックしたときは長さ0の文字列が返ります。また initialdir に元のフォルダを指定しても、勝手に大文字に変更されることがあります。そこで、文字列のlower()メソッドで全て小文字に変更してから比較します。
　比較時に区切り文字が代わっている(バックスラッシュ"\"がスラッシュ"/"に代わったり、その逆もある)と、キャンセルボタンをクリックしてもキャンセル扱いにならないことがあります(clear_sessionを呼んで出力エリアと入力ボックスをクリアする)。

## 暗号化・復号化プログラム
　APIキーは、暗号化プログラムでエンコードした後にiniファイルへ保存しています。暗号化・復号化のライブラリはたくさんあるのですが、外部モジュールはできるだけ使いたくないので、pythonだけで簡易版の暗号化・復号化プログラムを作って組み込んでおきました。
　全く同じ乱数系列を使って8ビット単位で排他的論理和を使うことで、数値を暗号化・復号化するプログラムを使っています。8bitの任意の数値は、可読文字のコードであるとは限りません。そこで、8bit数値を6bit単位で区切って6bitの数値を64の可読文字に変換する表を使って可読文字化します。
　復号時用に使う64の可読文字を6bitの数値に戻す逆引きの表は、元の表から自動的に計算・生成します。

:::note info
暗号化・復号化プログラムの細かい解説を別の記事にしました。
同じプログラムの解説ですが、メンバ関数のselfの箇所だけ異なっています。
別記事は以下の点を改良しています。
(1)暗号化・復号化プログラムを日本語の平文にも対応
(2)総当たり法への抵抗力をアップした改良版の紹介
::: 

https://qiita.com/ngktakanobu/items/aec2ae414b07413e3891

### 暗号化部分のソース（ChatPrepareクラス）
暗号化は、ユーザーが入力したOpenAIのAPIキーを暗号化するときだけ使うため、ChatPrepareクラスのメソッドとなっています。文字列を8bit文字コード列へ変換した後に、cryptseedで初期化した乱数系列と排他的論理和でエンコードします。エンコードバイト列は可読文字列ではないので、6bitごとに区切って、「6bit数値コードを可読文字列に変換する表」を使って可読文字列にコード化します。

```python
    # 元のapikey文字列str1をエンコード文字列にする
    # 可読文字列apikeyを8bitのASCIIコード数列に変換、暗号化後に8bit数列を6bit数列に小分けして、暗号化トークン(可読文字列)にする
    def cryptencode(self,str1, acryptseed):
        str1byte = str1.encode('utf-8')
        len1 = len(str1byte)
        random.seed(acryptseed)
        str1crypt = []
        for i in range(len1):
            str1crypt.append(str1byte[i] ^ random.randint(0,255))
        (6bit系列への変換と可読文字列化は略)
        return astr2
```

### 復号化部分のソース（ChatMainクラス）
復号化は、ChatMainクラスのメソッドです。iniファイルから読み込んだ暗号化APIキーを、まず「可読文字列を6bitコードに変換する表」で6bit数値列に変換。8bit数値列に編成しなおしてから、暗号化と同じacrypyseedで初期化した乱数系列と排他的論理和を行うことで平文のAPIキーに復号します。
```diff_python
    # エンコード文字列str1を元の文字列にする
    # 可読文字列(暗号化トークン)を6bitの数列に変換、6bit数列を8bit数列にしてデコードすると元の可読apikeyとなる
    def cryptdecode(self, str1, acryptseed):
        # 文字列を6bit整数配列にする
        len1 = len(str1)
        random.seed(acryptseed)
        code6 = []
        for i in range(len1):
            code6.append(str2code6[str1[i]])
        # 6bit整数配列を8bit整数配列に変換する
        code8 = []
        bit = 0
        k = 0
        for i in range(len1):
            for j in range(6):
                if k % 8 == 0:
                    if (i != 0 or j != 0):
                        code8.append(bit)
                    if (code6[i] & (0x20 >> j)) != 0:
                        bit = 0x80 >> (k % 8)
                    else:
                        bit = 0
                else:
                    if (code6[i] & (0x20 >> j)) != 0:
                        bit |= (0x80 >> (k % 8))
                k += 1
-       code8.append(bit)
+       if(k % 8 == 0):
+           code8.append(bit)

+       code8decode = []
+       for i in range(len(code8)):
+           code8decode.append(code8[i] ^ random.randint(0,255))
+       return  bytes(code8decode).decode('utf-8')
-       astr2 = ""
-       len2 = len(code8)
-       for i in range(len2):
-           astr2 += chr(code8[i] ^ random.randint(0,255))
-       return astr2
```
:::note warn
最初の投稿に気づきにくいバグがありました。apikeyの長さが3の倍数以外の場合および2バイト以上の文字が含まれる場合にdecodeが失敗します。-の行を削って、+の行に変えてください。GISTのソースも更新してあります。
OpenAIのapikeyの長さは3の倍数で、なおかつ日本語を含まないので、このバグの影響はありません。
:::

## プログレスバー（ChatMainクラス）
今回、二番目に苦労したのがプログレスバーです。threading.Threadで、処理時間がかかるapiを別スレッドでスタートしてからprogressbar.start()したのですが、全くプログレスバーが進みません。いろいろと試行錯誤した結果プログレスバーを進めるためにはプログレスバーのupdate()メソッドの呼び出しが必要だという情報を得て解決しました。
　プログレスバーのモードが、処理がいつ終わるかを定量化できる"determinate"でも、処理がいつ終わるかわからない"indeterminate"でもupdate()メソッドの呼び出しが必要となります。

### 「Sendボタン」処理のプログレスバー関連個所のソース（ChatMainクラス）
```python
    # 経過付きAPIコール
    def apicall_progress(self):
        # 時間のかかる処理をスレッドで処理
        self.t = threading.Thread(target=self.apicall)
        self.m_progressbarctrl.start()
        self.t.start()
        # スレッドが終わるまでループ
        while True:
            '''
            determinateの場合はコメントアウトを取る
            var1 = self.var.get()
            if(var1 >= 100):
                var1 = 0
            self.var.set(var1+10)
            '''
            self.m_progressbarctrl.update()
            time.sleep(0.1)             # 0.1秒間はapicallスレッドをメインに実行しているはず
            if(not self.t.is_alive()): # スレッドが終了していたらループを抜ける
                break
        self.m_progressbarctrl.stop()
```
self.apicallが時間のかかる処理なので別スレッドで実行します。その後にプログレスバーをスタートします。whileループの中で、プログレスバーのupdate()/time.sleep()/スレッド終了チェックをします。スレッドが終了したらwhileループを抜けてプログレスバーをストップします。
　time.sleep()中に時間のかかる処理が進みます。プログレスバーのmodeが"indeterminate"の場合はこのままで、"determinate"の場合はself.varの値をインクリメントします。最大値になったら戻すようにすれば"indeterminate"のような動作となります。

## そのほかの動作
 「Clear」ボタンと「Send」ボタンの動作とプログラムは、出力エリアと入力ボックスへの表示処理を除いてipywidgets版のそれと一緒です。

### 「Clearボタン」処理のソース（ChatMainクラス）
```python
    # クリアボタンの処理
    # 流れは ipywidgets 版と同じ
    def clear_session(self):
        # saveptr.modified == Trueの場合、やりとりにタイトルを付けて セッションヘッダに保存する
        self.appendtitle()
        # sessionmessagesを保存
        self.save_sessionmessages()
        # sessionmessagesを初期化
        self.sessionmessages = [{"role": "system", "content": "You are a helpful assistant.\n"}]
        # 念のため
        self.modified = False
        current_time = datetime.now().strftime('%Y%m%d_%H%M%S')
        self.currentfilename = f"{current_time}_chat_history.json"
        # ユーザーの入力ボックスをクリア
        self.m_editctrlinputbox.delete("1.0", "end")
        # 出力エリアをクリア
        self.m_editctrloutputarea.delete("1.0", "end")
```
ipywidgets版と、ほぼ一緒ですが、widget周り(入力ボックス/出力ボックスのクリア)だけ異なっています。

# ３．スタンドアローン実行
ソースを"Chatbytkinter.py"というファイル名で、一時的なワークフォルダに保存します。コンパイルのことを考えると、ワークフォルダにはこのpyファイルだけ入っている方が良いです。ワークフォルダで
```
python -V
```
でpythonのバージョンが3.7以上であることを確認します。次に
```
pip install openai
```
でopenaiモジュールをインストールして、
```
python Chatbytkinter.py
```

で実行します。OpenAI以外は全て標準ライブラリなので、問題があるとすれば
<ol>
<li>OpenAIのAPIキーを取得していない</li>
<li>設定ファイル"chat.ini"ファイルを書き込めない</li>
<li>設定ファイル"chat.ini"ファイルがあるが内容がおかしい</li>
</ol>
あたりが原因だと思われます。実行して動作すれば成功です。64bit windows 10/11で動作確認をしています。

# コンパイル実行
:::note warn
注意
最初、anacondaのターミナルでコンパイルしようとしたところコンパイルエラーが出てうまくいきません。OSのターミナル(windowsだとコマンドプロンプトなど)のような余分な機能がついていない環境でコンパイルするとうまくいくようです。
:::

```
pip install pyinstaller
```
でpyinstallerモジュールをインストールして、
```
pyinstaller Chatbytkinter.py
```
でコンパイルします。ワークフォルダの下のdistフォルダの下のChatbytkinterフォルダに実行ファイル(windowsの場合はChatbytkinter.exe)ができていれば成功です。場合によっては数百メガバイトの実行ファイルができることもあるらしいのですが、今回のアプリは4メガバイト程度の実行ファイルサイズでした。64bit windows 10/11でコンパイルと動作確認をしています。

# ４．次回について
次回はAPIに送るテキスト量の調整機能を付けたマイナーバージョンアップを記事にしたいと思います。セッションのテキスト量が数千～数万文字となるとAPIの利用料金もバカになりません。セッション内容を圧縮(要約)してAPIに送るような仕組みを入れたいと思います。

次々回はOpenAIのGPT-4のライバルであるGoogleのGeminiのAPIに関する記事を発表する予定です。GPT-4とGeminiをいじっていると得意分野が大きく異なっていることに気づきます。まず両者の特性の比較。Geminiの得意分野を生かしたAPI利用アプリなどの記事を考えています。
 
<!--
こちらは、製品化するので記事はボツにしました。
次回は、チャットプログラムより高度な実用的プログラムとして、翻訳用のデスクトップアプリケーションを作成します。ただの翻訳であればチャットプログラムでもできますが、翻訳ならではの機能(専門用語辞書登録や翻訳メモリ)を付加することで差別化します。
　ソースが大規模になりそうなのでgithubへのリンクを貼るなどして工夫したいと思います。
 -->