OpenAIのAPI Ver.1.0で履歴をローカルに保存・読み込みできるチャットプログラム

# ChatGPTのAPI Ver.1.0で履歴管理機能付きチャットプログラムを作ってみた
ChatGPT API Ver.1.0でチャットプログラムを作ってみました。ローカルストレージへの「履歴保存」「履歴再利用」をサポートしています。言語はpythonです。jupyter notebookで動作するGUIプログラムです。APIを使うので、ChatGPT 有料プランへの加入とAPIキーの取得が必須となります。

私は長年プログラマーをやっていますがpythonは1年生です。初投稿です。

ChatGPTがあるのに、なぜチャットプログラムを作るのか？答えは以下の通り。

## ChatGPTの「設定」ウィンドウの「チャット履歴とトレーニング」をオフだと困る
　ChatGPTでのチャットのやりとりは、初期設定のままでは全てOpenAIのサーバーに保存されます。さらにAIの再学習に使われることもあります。たとえば顧客情報などを使ったチャットなどは大問題となります。
「設定」によって「チャット履歴とトレーニング」をオフにすることはできます。ですが「履歴」がオフになると、(当たり前ですが)過去のやりとりを見ることも再利用することもできません。
　過去の履歴を管理しつつ情報漏洩にも対応する必要があります。その方法は簡単です。まず単純なチャットプログラムで実験することにしました。

## 履歴をローカルドライブに保存するチャットプログラムを作る
　情報漏洩対策は<b>履歴をローカル(OneDriveやGoogle Driveも可)に保存するだけ</b>です。そのためにはAPIを使ってチャットプログラムを構築し直す必要があります。APIを使ったやり取りのデータは、一時的にサーバーに残りますが、じきに消えます。AIの再学習にも使われません。

　私自身はc++でプログラミングしたいところですが、生成AIのAPIを使った開発情報の多くがpython言語を使っています。当面はpythonを使わざるを得ないようです。

## c++歴30年だけど、python 1年生
　入手したpython入門書は1/4くらい眺めて挫折しました。結局、OepnAIのドキュメントにあるサンプル、Googleの生成AIのbard(Gemini)に質問した回答を元に行き当たりばったりで開発しました。<b>コード記述で分からないことがあったら、検索するよりも生成AIに聞いた方が圧倒的に満足度が高いようです。</b>
 
　最初の大きな壁はAPIのバージョンです。ネットに散在しているプログラムはOpenAI APIのVer.0.28を元にしていますが、最新のAPIバージョンはVer.1.0です。しかも互換性が全くありません。生成AIにAPIのことを聞いても、学習テキストが古いためか最新版で動作する回答が得られません。
 
　その壁を乗り越えるとpython1年生のぶつかる様々な壁が押し寄せてきます。
　独特なインデントルール。リストのスライス、辞書、タプル...などの固有の用語。関数のキーワード引数！？...。

 閑話休題。

## GUIはとりあえずipywidgets(tkinter/PySimpleGUI版に移植中)
　jupyter notebookで開発していたのでGUIはひとまずipywidgetsを使うことにしました。jupyter notebook向けのGUIだと聞いたのが理由です。
　なおGUI関係でも書籍は参考にならないことが分かりました。書籍だと基本的なwidgetsの説明で終っています。ボタンと入力欄だけでどうすんねん。<b>繰り返しになりますが行き詰ったらGeminiやGPT-4などの生成AIに聞きましょう。<b>

## 履歴つきチャットプログラムコード
では履歴つきのチャットプログラムのコードです。
OPENAI_API_KEYという環境変数にopenaiのAPIのキーが設定されていることを前提としています。動作確認はWindowsで行っています。Mac/Linuxでも動作するはずですが確認はしていません。OS別の環境変数の設定方法はネットにたくさんあるので、そちらを参考にしてください。

### プログラムの先頭は以下の通りです。
　savefolderには、windowsのdドライブのtmpフォルダ下のchat_historyフォルダを指定してあります。適当に変えてください。
```
from openai import OpenAI
import ipywidgets as widgets
from IPython.display import display, clear_output, HTML
import os
# 履歴をjsonファイルで管理
import json
# 履歴のjsonファイルが、セッションごとにフォルダに作られているのをglobで取得
import glob
# ファイル名にセッションが作られた日付・時刻を記録
from datetime import datetime
# API呼び出し時の経過表示(なんちゃってプログレスバー)用
import sys,time,threading

# 履歴のjsonファイルを保存するフォルダを指定
savefolder = "d:/tmp/chat_history"
# フォルダが無ければ作る
if not os.path.exists(savefolder):
    os.makedirs(savefolder)
```
### 履歴情報の管理クラスは以下の通りです。
 次はチャットのセッションの履歴の管理と表示です。セッションとは、話題が変わるまでの一連のユーザーと生成AIサーバーとのやりとりのことです。
　履歴はOpenAIのAPIで、加工せずに使える形で管理しています。ただし、セッションのタイトル(これもAPIで「これまでのやり取りに15文字くらいでタイトルをつけて」と生成させています)が最後に付加されています。
　セッション内容はjson形式で保存してあるので、jsonファイルを読み込んでタイトル部分のみを履歴表示欄に表示します。
titlelistに「タイトル」:「通し番号」の辞書のリストが格納されています。GUIのリストボックス(ipywidgetsだとSelect widget)にtitlelistを表示して、選択したタイトルの「通し番号」を取得してfilelistを参照すれば、タイトルに対応するjsonファイル名を取得することができます。
```
class CChatHistory:
    def __init__(self, savefolder):
        self.savefolder = savefolder
    def reset(self):
        self.filelist = []
        self.titlelist = []
        searchpath = os.path.join(self.savefolder, "*.json")
        self.filelist = glob.glob(searchpath)
        ptr = 0
        for filepath1 in self.filelist:
            with open(filepath1, "r", encoding='utf-8') as fd1:
                sessions = json.load(fd1)
                cont1 = sessions[-1].get('session_title')
                if cont1 != None:
                    filename1 = os.path.basename(filepath1)
                    self.titlelist.append({filename1[:15]+' '+cont1 : ptr})
                    ptr += 1

```
### 画面構成です
　まず、セッションのやりとりをIPythonのDisplayで出力エリアに表示します。その下の左側がユーザーの入力ボックス、右に「Send」ボタンと「Clear」と「履歴表示欄」が縦に並んでいます。

![チャット画面](https://www.artlogic.co.jp/artlogic/GenerativeAI/chatbyipywidgets.png "チャット画面")

:::note info
　「Send」ボタン、「Clear」ボタンの処理は、セッションの内容が長い場合は長時間かかることもあります。気長にお待ちください。
　現在処理中であることをユーザーに知らせるプログレスバーが必要となりますが ipywidgetsではややこしそうなので、self.output_areaの下に点々"........Done"と出すだけです。tkinter版ではプログレスバーをサポートする予定です。
:::

### チャットクラスの定義は以下の通り
```
'''
チャットクラス ipywidgets版
'''
class ChatMain:
    def __init__(self, folder1):
        self.client = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
        current_time = datetime.now().strftime('%Y%m%d_%H%M%S')
        self.currentfilename = f"{current_time}_chat_history.json"
        # OpenAIドキュメントに準拠して "system"でAIアシスタントの設定
        # ex.self.sessionmessages = [{"role": "system", "content": "あなたは大阪のおばちゃんです"}]
        self.sessionmessages = [{"role": "system", "content": "You are a helpful assistant."}]
        self.modified = False
        self.savefolder = folder1
        
        # 履歴管理クラスの作成
        self.history = CChatHistory(self.savefolder)
        self.history.reset()
        self.label = widgets.Label('履歴')
        self.select = widgets.Select(
            options=self.history.titlelist,
            value=None,            # これをしないと最初の履歴が選択状態になってロードされてしまう
            disabled=False,
            raws=len(self.history.titlelist),  # rawsを設定しないとなぜか2回目以降選択してもコールバック関数が呼ばれない
            layout=widgets.Layout(width='80%', height='130px')
        )
        
        # ユーザー入力ウィジェットの作成
        self.input_box = widgets.Textarea(description='You:',
                                     rows=10,
                                     layout=widgets.Layout(width='100%'))  # 動的に変動
        self.send_button = widgets.Button(description='Send')
        self.clear_button = widgets.Button(description='Clear')
        # 出力エリアの作成
        self.output_area = widgets.Output()

    # メッセージをjsonファイルに書き込む
    def save_sessionmessages(self):
        # ファイルの完全なパスを生成
        if self.modified == True:
            filepath = os.path.join(self.savefolder, self.currentfilename)
            existflag = os.path.exists(filepath)
            # 先頭の {"role": "system", "content": "You are a helpful assistant."} は除く
            with open(filepath, 'w', encoding='utf-8') as fdout:
                json.dump(self.sessionmessages[1:], fdout,ensure_ascii=False,indent=4)
            # 保存したので modifiedflagはFalseに
            # self.selectの更新より前にしないとself.modifiedflagがTrueのまま
            # selchangeが繰り返し呼ばれることがある(これが分かるまで2日つぶした)
            self.modified = False
            # 新しいファイル名で保存した場合はresetと履歴欄の再構築
            self.history.reset()
            self.select.value=None
            self.select.options=self.history.titlelist
            # rawsを設定しないとなぜか2回目以降選択してもコールバック関数が呼ばれない
            self.select.raws=len(self.history.titlelist)
#           print(f"メッセージは '{filepath}' に保存されました。")

    # ユーザーからの入力を受け取ってAPIリクエストを実行する関数
    def send_to_chatbot(self, button):
        user_input = self.input_box.value
        self.sessionmessages.append({"role": "user", "content": user_input})
        self.modified = True
     
        # APIにリクエストを送信し、レスポンスを得る
        try:
            self.apicall_progress()    # なんちゃってプログレスバー付き

        except Exception as e:
            with self.output_area:
                clear_output()   # IPythonメソッド
                print(f"An error occurred: {e}")
            return

        # アウトプットエリアにメッセージを表示
        with self.output_area:
            clear_output()   # IPythonメソッド
            for message in self.sessionmessages:
                if message['role'] == 'user':
                    role_label = 'You'
                    color = 'blue'
                elif message['role'] == 'assistant':
                    role_label = 'GPT-4'
                    color = 'green'
                else:  # ここで 'system' ロールを処理します
                    role_label = 'System'
                    color = 'gray'  # システムメッセージには異なる色を使用することもできます

                # テキスト内の改行を保持するためにwhite-spaceスタイルを使用 display/HTMLはIPythonメソッド
                display(HTML(f"<span style='color: {color}; white-space: pre-wrap;'>{role_label}: {message['content']}</span><br><br>"))


        # ユーザーの入力ボックスをクリア
        self.input_box.value = ''

    # 経過付きAPIコール
    def apicall_progress(self):
        # 時間のかかる処理をスレッドで処理
        self.t = threading.Thread(target=self.apicall)
        self.t.start()
        # スレッドが終わるまでループ
        while True:
            sys.stdout.write('.')
            sys.stdout.flush()
            time.sleep(1)
            if(not self.t.is_alive()): # スレッドが終了していたらループを抜ける
                break
        print('DONE')

    # APIコール部分(時間がかかる処理) 
    def apicall(self):
        completion = self.client.chat.completions.create(
            model="gpt-4",
            messages = self.sessionmessages  # 過去のメッセージを含むリストを渡す
        )
        # APIから得られたレスポンスメッセージを過去のメッセージに追加
        if completion.choices[0].message.content:
            self.sessionmessages.append({
                "role": "assistant",
                "content": completion.choices[0].message.content
            })

    # クリアボタンの処理
    def clear_session(self, button):
        # saveptr > 0の場合、やりとりにタイトルを付けて セッションヘッダに保存する
        self.appendtitle()
        # sessionmessagesを保存
        self.save_sessionmessages()
        # sessionmessagesを初期化
        self.sessionmessages = [{"role": "system", "content": "You are a helpful assistant."}]
        current_time = datetime.now().strftime('%Y%m%d_%H%M%S')
        self.currentfilename = f"{current_time}_chat_history.json"
        # 念のため
        self.modified = False
        # ユーザーの入力ボックスをクリア
        self.input_box.value = ''
        # 出力エリアをクリア
        with self.output_area:
            clear_output()   # IPythonメソッド
        
    # タイトル付加処理
    # (1)これまでのやりとりにタイトルを付ける
    # (2)"session_title" : titleとする
    def appendtitle(self):
        # len(sessionmessages) > 1 の場合、やりとりにタイトルを付けて セッションヘッダに保存する
        if self.modified == True:
            self.sessionmessages.append({"role": "user", "content": "これまでのやりとりに、日本語で15文字くらいのタイトルを付けてください"})
            # APIにリクエストを送信し、レスポンスを得る
            try:
                completion = self.client.chat.completions.create(
                    model="gpt-4",
                    messages = self.sessionmessages  # 過去のメッセージを含むリストを渡す
                )
                # APIから得られたレスポンスメッセージを"タイトルを付けてください"リクエストの代わりにする
                if completion.choices[0].message.content:
                    self.sessionmessages[-1] = {
                        "session_title": completion.choices[0].message.content
                    }

            except Exception as e:
                with self.output_area:
                    self.sessionmessages[-1] = {
                        "session_title": "no title"
                    }
                return

    # 履歴表示欄選択処理
    def selchange(self, title):
        if title != None:
            t, no = title.popitem()
            # 最初からSelectの0が選択されている。最初は履歴がロードされないようにする。
            self.clear_session(self.clear_button)
            self.currentfilename = os.path.basename(self.history.filelist[no])
            # jsonファイルのロード
            with open(self.history.filelist[no], 'r', encoding='utf-8') as fdin:
                amessages = json.load(fdin)
            # session_titleを除く部分
            self.sessionmessages += amessages[:-1]
            # アウトプットエリアにメッセージを表示
            with self.output_area:
                clear_output()   # IPythonメソッド
                for message in self.sessionmessages:
                    if message['role'] == 'user':
                        role_label = 'You'
                        color = 'blue'
                    elif message['role'] == 'assistant':
                        role_label = 'GPT-4'
                        color = 'green'
                    else:  # ここで 'system' ロールを処理します
                        role_label = 'System'
                        color = 'gray'  # システムメッセージには異なる色を使用することもできます

                    # テキスト内の改行を保持するためにwhite-spaceスタイルを使用 display/HTMLはIPythonメソッド
                    display(HTML(f"<span style='color: {color}; white-space: pre-wrap;'>{role_label}: {message['content']}</span><br><br>"))
    
    # メイン処理
    def chatloop(self):
        # ボタンが押された際に上記関数を呼び出すイベントハンドラの設定
        self.send_button.on_click(self.send_to_chatbot)
        self.clear_button.on_click(self.clear_session)
        # interactive_outputを指定しないと Selectの項目をクリックしても self.selchangeが呼び出されない
        # よく分からないけど、 https://qiita.com/studio_haneya/items/adbaa01b637e7e699e75 の情報で回避
        out = widgets.interactive_output(self.selchange, {"title":self.select})
        # ウィジェットの表示
        vbox1 = widgets.VBox([self.send_button, self.clear_button,self.label, self.select])
        hbox1 = widgets.HBox([self.input_box, vbox1])
        display(self.output_area, hbox1, out)
```

### 実行するには
チャットクラスChatMainのインスタンスを作って"chatloop()"を呼び出します。savefolderは、プログラムの先頭に定義されている履歴ファイルを保存するフォルダ名です。
```
demo = ChatMain(savefolder)
demo.chatloop()
```
## ソース解説
### __init__の処理
　__init__にある、self.sessionmessagesがセッションのやりとりを保持するリストです。このリストを、そのままAPIにそのまま送ることができます。入力ボックスの内容や生成AIからの回答をアペンドするなどして、セッションのデータを管理します。
　"self.sessionmessages = [{"role": "system", "content": "You are a helpful assistant."}]"の部分は、self.sessionmessagesリストの先頭の固定値を指定しています。有能なアシスタント役になって回答を返すように生成AIに指示しています。「クリア」ボタンや履歴の読み込みのたびに、セッションリストの先頭要素に同じ値を設定します。
　self.sessionmessagesの初期値を変えることも可能です。"content"を"あなたは大阪のおばちゃんです"に変えると、大阪のおばちゃんが回答を返してくれます。
　self.modified変数は、セッション内容をファイル保存する必要があるときにTrue、保存する必要が無い時にFalseと設定します。
　OpenAIのAPIキーを使って、self.clientインスタンスを生成。self.clientを通してOpenAIのサーバーとやりとりします。

### 「Send」ボタンを押したときの動作は
　APIを呼び出して、ユーザーの入力テキストと生成AIからの回答を出力エリアに追加します。また、入力ボックスをクリアします。
　処理は、メンバ関数"send_to_chatbot"にあります。ユーザーの入力を受け取り、過去のセッションの内容と一緒にAPIリクエストを発行します。生成AIからの回答を受け取るとセッションリストにユーザーの入力と生成AIの回答をアペンドして。出力エリアの更新(self.sessionmessagesの内容をDisplay)と入力ボックスのクリアを行います。セッション内容が更新されたのでself.modified変数はTrueになります。
```
    # ユーザーからの入力を受け取ってAPIリクエストを実行する関数
    def send_to_chatbot(self, button):
        user_input = self.input_box.value
        self.sessionmessages.append({"role": "user", "content": user_input})
        self.modified = True
     
        # APIにリクエストを送信し、レスポンスを得る
        try:
            self.apicall_progress()    # なんちゃってプログレスバー付き
    # ------------------------------------------
    # 中略(一部省略しています)
    # ------------------------------------------
    # 「プログレスバーらしきもの」付きAPIコール
    def apicall_progress(self):
        # 時間のかかる処理をスレッドで処理
        self.t = threading.Thread(target=self.apicall)
        self.t.start()
        # スレッドが終わるまでループ
        while True:
            sys.stdout.write('.')
            sys.stdout.flush()
            time.sleep(1)
            if(not self.t.is_alive()): # スレッドが終了していたらループを抜ける
                break
        print('DONE')

    # APIコール部分(時間がかかる処理) 
    def apicall(self):
        completion = self.client.chat.completions.create(
            model="gpt-4",
            messages = self.sessionmessages  # 過去のメッセージを含むリストを渡す
        )
        # APIから得られたレスポンスメッセージを過去のメッセージに追加
        if completion.choices[0].message.content:
            self.sessionmessages.append({
                "role": "assistant",
                "content": completion.choices[0].message.content
            })

```
:::note info
時間がかかるAPIコール処理は
https://sekitaka-1214.hatenablog.com/entry/2014/03/03/211058
の、標準出力に"......Done"を出す「プログレスバーっぽいもの」を使っています。apicall_progressの代わりにapicallを直接呼び出すと「プログレスバーっぽいもの」無しでAPIを呼び出すことができます。場合によっては、数十秒かかるので気長にお待ちください。"..."が伸びている間は通信中です。
:::

:::note info
　APIにリクエストを送る部分はAPIのバージョン 1.0に対応しています。最近のバージョンアップでAPIの仕様が大きく変わったので、古いAPIを組み込んでいる方は

$ pip install -U openai

 で最新のモジュールに更新してください。2/11現在、ネットにはバージョン0.28のAPIに対応したソースが多いので注意が必要です。API呼び出しで"openai.ChatCompletion.create"を使っているソースは、0.28対応の古いソース形式です。

$ pip install openai==0.28

でAPIを古いバージョンに戻すことができます。
:::

### 「クリア」ボタンを押したときの動作は
　セッションの内容にタイトルをつけて、jsonファイルに保存します。セッションデータを初期値に戻して、出力エリアと入力ボックスをクリアします。
　処理はメンバ関数clear_sessionにあります。self.appendtitle関数でタイトルをつけ、save_sessionmessages関数でセッションをjsonファイルに保存。関連変数を初期値に戻します。"self.currentfilename"には、現在の日付時刻のついたファイルが設定されます。また、
"self.modified"変数はFalseになります。
```
    # クリアボタンの処理
    def clear_session(self, button):
        # saveptr > 0の場合、やりとりにタイトルを付けて セッションヘッダに保存する
        self.appendtitle()
        # sessionmessagesを保存
        self.save_sessionmessages()
        # sessionmessagesを初期化
        self.sessionmessages = [{"role": "system", "content": "You are a helpful assistant."}]
        self.currentfilename = f"{current_time}_chat_history.json"
        self.modified = False
        # ユーザーの入力ボックスをクリア
        self.input_box.value = ''
        # 出力エリアをクリア
        with self.output_area:
            clear_output()   # IPythonメソッド
```
### 履歴表示欄で過去の履歴を選択したときの動作は
　「クリアボタン」を押した時と同じ動作をした後に、選択した過去の履歴を読み込んで出力エリアに読み込んだセッションの内容を表示。入力ボックスをクリアします。
　処理は、メンバ関数selchangeにあります。なぜか、全ての項目が非選択状態のタイミングでも、この関数に来ることがあります。その場合、titleがNoneなので判別することができます。
　"selchange"の中では、最初に"self.clear_session"を呼び出して、「クリア」ボタンを押したときと同じ処理をします。
　選択した履歴のオフセットがnoに入るので、"self.history.filelist[no]"で履歴ファイルのパスを取得して、"json.load"でファイルをロードします。"self.currentfilename"はロードしたファイルに代わります。
　jsonデータの最後には、セッションのタイトルがついているので、タイトルを除いた部分を、固定の"role":"system"の後に追加して、"self.sessionmessages"に置き換え、出力エリアに履歴を表示します。
　新しくデータをロードしたので、self.modifiedはFalseになります。
```
    # 履歴表示欄選択処理
    def selchange(self, title):
        if title != None:
            t, no = title.popitem()
            # 最初からSelectの0が選択されている。最初は履歴がロードされないようにする。
            self.clear_session(self.clear_button)
            self.currentfilename = os.path.basename(self.history.filelist[no])
            # jsonファイルのロード
            with open(self.history.filelist[no], 'r', encoding='utf-8') as fdin:
                amessages = json.load(fdin)
            # session_titleを除く部分
            self.sessionmessages += amessages[:-1]
            # アウトプットエリアにメッセージを表示
            with self.output_area:
                clear_output()   # IPythonメソッド
                for message in self.sessionmessages:
                    if message['role'] == 'user':
                        role_label = 'You'
                        color = 'blue'
                    elif message['role'] == 'assistant':
                        role_label = 'GPT-4'
                        color = 'green'
                    else:  # ここで 'system' ロールを処理します
                        role_label = 'System'
                        color = 'gray'  # システムメッセージには異なる色を使用することもできます

                    # テキスト内の改行を保持するためにwhite-spaceスタイルを使用 display/HTMLはIPythonメソッド
                    display(HTML(f"<span style='color: {color}; white-space: pre-wrap;'>{role_label}: {message['content']}</span><br><br>"))
```
### 履歴保存処理は
　self.modifiedがTrue、つまり変更がある場合だけ、json.dumpでself.sessionmessagesの"system"roleを除いた部分を保存します。
　履歴ファイルの数や、タイトルに変更があるため、self.history.reset()を呼び出して、self.history.titlelistを更新しています。それに合わせて関数の中でSelect widgetsの属性を変更しています。Select widgetsの属性を変更すると、変更の内容次第ではコールバック関数selchangeが、呼ばれることがあります。Select widgetsの更新の前にself.modifiedをFalseにしないと、コールバック関数が入れ子で呼ばれ続けて大変なことになります。問題を突き止めて解決するのに時間を浪費しました。 
```
    # メッセージをjsonファイルに書き込む
    def save_sessionmessages(self):
        # ファイルの完全なパスを生成
        if self.modified == True:
            filepath = os.path.join(self.savefolder, self.currentfilename)
            existflag = os.path.exists(filepath)
            # 先頭の {"role": "system", "content": "You are a helpful assistant."} は除く
            with open(filepath, 'w', encoding='utf-8') as fdout:
                json.dump(self.sessionmessages[1:], fdout,ensure_ascii=False,indent=4)
            # 保存したので modifiedflagはFalseに
            # self.selectの更新より前にしないとself.modifiedflagがTrueのまま、selchangeが繰り返し呼ばれることがある
            self.modified = False
            # 新しいファイル名で保存した場合はresetと履歴欄の再構築
            self.history.reset()
            self.select.value=None
            self.select.options=self.history.titlelist
            # rawsを設定しないとなぜか2回目以降選択してもコールバック関数が呼ばれない
            self.select.raws=len(self.history.titlelist)
```

### タイトル付加処理は、
　clear_sessionが呼ばれたときに、self.modifiedがTrue(変更あり)の場合に、"これまでのやりとりに、日本語で15文字くらいのタイトルを付けてください"という要望をAPIで送って、生成AIにタイトルを付けてもらいます。
　わざわざ"日本語で"と付いているのは、生成AIで翻訳をしてもらったりしたときに、訳文の言語でタイトルが付いてしまうことがあるからです。英語でチャットする人などは"日本語で"の要望を除いた方が良いかも知れません。
　セッションが長くなると、APIで送信するテキスト量も増えるので、応答時間が長くなります。プログレスバー表示をする方が良いのですが、ipywidgetsだと「なんちゃってプログレスバー」になってしまうのでtkinter版までお預けにします。
```
    # タイトル付加処理
    # (1)これまでのやりとりにタイトルを付ける
    # (2)"session_title" : titleとする
    def appendtitle(self):
        # len(sessionmessages) > 1 の場合、やりとりにタイトルを付けて セッションヘッダに保存する
        if self.modified == True:
            self.sessionmessages.append({"role": "user", "content": "これまでのやりとりに、日本語で15文字くらいのタイトルを付けてください"})
            # APIにリクエストを送信し、レスポンスを得る
            try:
                completion = self.client.chat.completions.create(
                    model="gpt-4",
                    messages = self.sessionmessages  # 過去のメッセージを含むリストを渡す
                )
                # APIから得られたレスポンスメッセージを"タイトルを付けてください"リクエストの代わりにする
                if completion.choices[0].message.content:
                    self.sessionmessages[-1] = {
                        "session_title": completion.choices[0].message.content
                    }

            except Exception as e:
                with self.output_area:
                    self.sessionmessages[-1] = {
                        "session_title": "no title"
                    }
                return
```

### メインの動作は
　IPythonのDisplayエリアの下にipywidgetsのwidgetsを配置してイベントループで実行します。
　ipywidgetsのwidgetsのコールバック処理を定義してwidgetsを表示すると、勝手にイベントループを実行してくれるようです。
　つまづいたところは、Select widgetsで選択してもコールバック関数が動作しなかったところです。ソースのコメントにもありますが、Qiitaの情報
 https://qiita.com/studio_haneya/items/adbaa01b637e7e699e75
 にあるように、<b>interactive_output widgets</b>を作成して一緒にdisplayするとうまくいくようです。なぜうまくいくのかは分かりません。
```
# メイン処理
    def chatloop(self):
        # ボタンが押された際に上記関数を呼び出すイベントハンドラの設定
        self.send_button.on_click(self.send_to_chatbot)
        self.clear_button.on_click(self.clear_session)
        # interactive_outputを指定しないと Selectの項目をクリックしても self.selchangeが呼び出されない
        # よく分からないけど、 https://qiita.com/studio_haneya/items/adbaa01b637e7e699e75 の情報で回避
        out = widgets.interactive_output(self.selchange, {"title":self.select})
        # ウィジェットの表示
        vbox1 = widgets.VBox([self.send_button, self.clear_button,self.label, self.select])
        hbox1 = widgets.HBox([self.input_box, vbox1])
        display(self.output_area, hbox1, out)
```
　python一年生(c++歴は30年)なので、素人丸出しなところがあるかも知れません。既知の問題としては、以下の4点が挙げられます。
<ol>
<li>ipywidgetsのSelect widgetsを動的に更新した時の動作がおかしい。コールバック関数をエラーが発生するまで何度も呼んだり、逆に選択してもコールバック関数が呼ばれなくなったり。項目数(raws)を設定するようにしたらなぜか直ったが、どうして直ったのかは謎。</li>
<li>セッションのサイズに制限を設けていないので、<b>テキストサイズの大きいセッションではAPIからエラーが返ってくることがあり得ます。</b>長いセッションの前半部分を要約する等の工夫が必要となります。</li>
<li>fainally処理をしていないので、<b>強制終了した場合に最後のセッションが保存されません。</b>シグナル処理を追加する必要があります。</li>
<li>APIキーはprint(os.environ["OPENAI_API_KEY"])で丸見えです。他の人には使わせないようにしましょう。tkinterあるいはPySimpleGUIを使ったGUIに変更してスタンドアプリケーションとしてコンパイルしてリリースすれば、<b>APIキー漏洩対策</b>になります。</li>
<li>プログレスバーが、文字で点々("......")を出す「プログレスバーらしきもの」</li>
</ol>

:::note info
どうもipywidgets版は問題が多いので、tkinter版 and/or PySimpleGUI版に移植中です。できたら記事にしたいと思います。
:::

tkinter版ができました。

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