├── .gitignore ├── LICENSE ├── gpt-interactions └── uuid.md ├── gpt.py ├── history_menu.py ├── icon.ico ├── main_window.py ├── preprompt_editor.py ├── preprompt_menu.py ├── preprompts.json ├── readme.md ├── requirements.txt ├── screenshots ├── history_menu.png ├── main_window.png ├── preprompts.png └── search_window.png ├── search_window.py ├── text_box.py └── todo.md /.gitignore: -------------------------------------------------------------------------------- 1 | # local chat history 2 | history/*.json 3 | 4 | # shortcut 5 | GPT.lnk 6 | 7 | __pycache__/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2023 Adam Ries 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /gpt-interactions/uuid.md: -------------------------------------------------------------------------------- 1 | what is uuid? how does it work? 2 | 3 | --- 4 | 5 | `uuid` stands for "Universally Unique Identifier". It is a Python module that provides functions for generating unique IDs, which are typically used for identifying objects in distributed systems. 6 | 7 | UUIDs are generated using a combination of time, system information, and random numbers, and are designed to be globally unique without relying on a central authority. They are typically represented as 128-bit hexadecimal values, which can be either lowercase or uppercase. 8 | 9 | The `uuid` module provides several functions for generating UUIDs, including: 10 | 11 | - `uuid1()`: Generates a UUID based on the host ID, sequence number, and the current time. This is the most commonly used function for generating UUIDs in Python. 12 | 13 | - `uuid3(namespace, name)`: Generates a UUID based on a namespace UUID and a name. The namespace UUID is typically a pre-defined UUID such as `uuid.NAMESPACE_DNS` or `uuid.NAMESPACE_URL`, and the name is a string that is unique within the namespace. 14 | 15 | - `uuid4()`: Generates a random UUID. 16 | 17 | - `uuid5(namespace, name)`: Generates a UUID based on a namespace UUID and a name, using the SHA-1 algorithm. 18 | 19 | UUIDs are used in many different applications, including databases, distributed systems, and web applications. They are often used as primary keys for database tables, and as identifiers for objects in distributed systems. 20 | 21 | --- 22 | 23 | what are host ID and sequence number? how are they determined? 24 | 25 | --- 26 | 27 | The `uuid1()` function generates a UUID based on the host ID, sequence number, and the current time. The host ID and sequence number are combined with the MAC address of the network card on the computer and the current time, respectively, to create a unique number that identifies the host and the sequence of UUIDs generated by that host. 28 | 29 | The host ID is based on the MAC address of the network card on the computer. Every network card has a unique MAC address, which is a 48-bit number that is assigned by the manufacturer of the card. The `uuid1()` function uses the MAC address of the network card to generate a 32-bit host ID, which is used to identify the computer that is generating the UUID. 30 | 31 | The sequence number is an integer that is incremented by one every time a UUID is generated. It is used to distinguish between UUIDs that are generated within the same system clock tick. If two UUIDs are generated within the same system clock tick, they will have the same time-based component, but different sequence numbers. 32 | 33 | The `uuid1()` function combines these three components (MAC address, timestamp, and sequence number) to produce a 128-bit UUID. The UUID is guaranteed to be unique, provided that the MAC address is unique and the system clock is set correctly. 34 | 35 | --- 36 | 37 | what is a mac address? 38 | 39 | --- 40 | 41 | A MAC address, which stands for Media Access Control address, is a unique identifier that is assigned to a network interface controller (NIC) for use as a network address in communications within a network segment. 42 | 43 | The MAC address is a 48-bit hexadecimal number, which is usually represented as 12 hexadecimal digits in six groups separated by colons, hyphens or no separator. An example of a MAC address is `00:1A:2B:3C:4D:5E`. 44 | 45 | MAC addresses are assigned to network cards or other network interface devices by the device manufacturer. This allows devices to be uniquely identified on a network, even when they share the same IP address. 46 | 47 | MAC addresses are used in the data link layer of network protocols, such as Ethernet and Wi-Fi, to provide a unique identifier for each network interface. When a packet is sent over the network, the MAC addresses of the source and destination devices are included in the packet header. This allows the network devices to route packets to their intended destination. 48 | 49 | --- 50 | 51 | ```py 52 | a=[] 53 | for i in range(25): 54 | a.append({uuid.uuid1():i}) 55 | 56 | {UUID('ab11c9f8-e534-11ed-9e80-5404a6b05715'): 0} 57 | {UUID('ab11c9f9-e534-11ed-8e18-5404a6b05715'): 1} 58 | {UUID('ab11c9fa-e534-11ed-b3f6-5404a6b05715'): 2} 59 | {UUID('ab11c9fb-e534-11ed-882b-5404a6b05715'): 3} 60 | ... 61 | ``` 62 | what are each of these? 63 | - ab11c9f8 64 | - e534 65 | - 11ed 66 | - 9e80 67 | - 5404a6b05715 68 | 69 | --- 70 | 71 | Each of the values in the UUID represents a different part of the UUID. The UUID is divided into five parts: 72 | 73 | AB11C9F8 - This represents the first 32 bits of the UUID, which is the high-order bits of the time stamp. 74 | 75 | E534 - This represents the next 16 bits of the UUID, which is the middle-order bits of the time stamp. 76 | 77 | 11ED - This represents the next 16 bits of the UUID, which is the low-order bits of the time stamp. 78 | 79 | 9E80 - This represents the next 16 bits of the UUID, which is a clock sequence identifier used to help avoid duplicates. 80 | 81 | 5404A6B05715 - This represents the final 48 bits of the UUID, which is a unique identifier generated per UUID. 82 | 83 | In the example code you provided, the `uuid.uuid1()` function is generating a new UUID for each number in the range 0-24. The output shows a dictionary with a UUID object as the key and an integer as the value. Each UUID is unique and contains the five parts described above. -------------------------------------------------------------------------------- /gpt.py: -------------------------------------------------------------------------------- 1 | import openai 2 | import os 3 | 4 | # this file is separated because ChatGPT's 2021 training data cutoff does not include new ChatCompletion formatting 5 | # the API call is hidden here to prevent ChatGPT from trying to replace it with openai.Completion.create 6 | def api_call(engine, history): 7 | return openai.ChatCompletion.create( 8 | model = engine, 9 | messages = history 10 | ) 11 | 12 | # very simple console testing 13 | # breaks when thread becomes too large (4096 tokens) 14 | if __name__ == "__main__": 15 | preprompt = input("Preprompt: ") or "Be helpful." 16 | history = [{"role": "system", "content": preprompt}] 17 | engine = "gpt-3.5-turbo" 18 | 19 | while True: 20 | in_content = input("> ") 21 | history.append({"role": "user", "content": in_content}) 22 | response = api_call(engine, history) 23 | out_content = response["choices"][0]["message"]["content"] 24 | print(out_content) 25 | history.append({"role": "assistant", "content": out_content}) -------------------------------------------------------------------------------- /history_menu.py: -------------------------------------------------------------------------------- 1 | from tkinter import * 2 | import json 3 | import os 4 | 5 | class HistoryMenu(OptionMenu): 6 | def __init__(self, parent, title): 7 | super().__init__(parent, title, "Default") 8 | self.option_add("*Menu*activeBackground", "#333") 9 | self.option_add("*Menu*activeForeground", "#975") 10 | self.option_add("*Menu*background", "#000") 11 | self.option_add("*Menu*foreground", "#975") 12 | self.parent = parent 13 | self.font = parent.font 14 | self.padding = parent.padding 15 | self.directory = parent.directory 16 | self.history_title = StringVar(self, "History") 17 | self.history_filename_list = self.get_history_titles() 18 | self.grid(row=1, column=0, sticky=S+E+W, padx=(self.padding, 0), pady=(0, self.padding)) 19 | self.configure(background="#333", foreground="#FFF", 20 | activebackground="#555", activeforeground="#FFF", 21 | relief="raised", direction="above") 22 | self.option_add("*Menu*activeBackground", "#333") 23 | self.option_add("*Menu*activeForeground", "#975") 24 | self.option_add("*Menu*background", "#000") 25 | self.option_add("*Menu*foreground", "#975") 26 | self.populate() 27 | 28 | def populate(self): 29 | self["menu"].delete(0, "end") 30 | for filename in self.history_filename_list: 31 | filepath = os.path.join(self.directory, "history", filename) 32 | file_size = round(os.path.getsize(f"{filepath}.json") / 1024, 1) 33 | self["menu"].add_command(label=f"{filename} ({file_size} KB)", 34 | font=self.font, command=lambda f=filename: self.parent.load_file(f)) 35 | 36 | def get_history_titles(self): 37 | filenames = os.listdir(os.path.join(self.parent.directory,"history")) 38 | filenames = sorted([f.rsplit(".", 1)[0] for f in filenames if f.endswith(".json")], reverse=True) 39 | return filenames -------------------------------------------------------------------------------- /icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RealityAnchor/ries-gpt-ui/8d881a1f089573e918f93266f2ef6b21e3faee15/icon.ico -------------------------------------------------------------------------------- /main_window.py: -------------------------------------------------------------------------------- 1 | ''' 2 | main_window.py 3 | Adam Ries, adamalexanderries{}gmail{}com 4 | 2023-04-06 5 | tkinter desktop chat interface for interacting with GPT-3 via OpenAI's API 6 | ''' 7 | 8 | # built in 9 | from datetime import datetime 10 | import json 11 | import os 12 | import re 13 | import threading 14 | from tkinter import * 15 | from tkinter.simpledialog import askstring 16 | from tkinter.font import Font 17 | 18 | # pip install 19 | import tiktoken 20 | 21 | # custom 22 | from search_window import SearchWindow 23 | from text_box import ThreadBox, InputBox 24 | from preprompt_menu import PrepromptMenu 25 | from preprompt_editor import PrepromptEditor 26 | from history_menu import HistoryMenu 27 | import gpt 28 | 29 | 30 | # main tkinter window 31 | class MainWindow(Tk): 32 | def __init__(self): 33 | super().__init__() 34 | self.init_variables() 35 | self.update() 36 | 37 | # input and output widgets 38 | self.input_box = InputBox(self) 39 | self.thread_box = ThreadBox(self) 40 | 41 | # thread history menu 42 | self.history_title = StringVar(self, "History") 43 | self.history_menu = HistoryMenu(self, self.history_title) 44 | 45 | # preprompt menu 46 | self.pp_title = StringVar(self, "Default") 47 | self.pp_menu = PrepromptMenu(self, self.pp_title) 48 | 49 | # format grid 50 | self.grid_columnconfigure(0, weight = 1) 51 | self.grid_columnconfigure(1, weight = 4) 52 | self.grid_columnconfigure(2, weight = 1) 53 | self.grid_rowconfigure(0, weight=1) 54 | self.grid_rowconfigure(1, weight=0) 55 | 56 | self.update() 57 | self.bind_shortcuts() 58 | self.new_conversation() 59 | 60 | def init_variables(self): 61 | self.directory = os.path.dirname(__file__) 62 | self.engine = "gpt-3.5-turbo" 63 | self.title(self.engine) 64 | self.state("zoomed") 65 | self.minsize(500, 500) 66 | self.configure(bg="#040404") # dark grey background 67 | self.history = [] 68 | self.padding = 8 69 | # tokenization is done to measure thread length 70 | # gpt-3.5-turbo can't handle more than 4096 tokens 71 | # long threads are truncated to allow for at least 1000 response tokens 72 | self.encoding = tiktoken.encoding_for_model(self.engine) 73 | self.max_tokens = 3096 74 | self.history_slice_index = 0 75 | self.slice_token_index = 0 76 | # thread title set to current date and time yyyy-mm-dd_hhmmss 77 | self.filename = datetime.now().strftime("%Y-%m-%d_%H%M%S") 78 | self.font = Font(family="Arial", size=14) 79 | self.search_window_attributes = {} 80 | self.search_window = None 81 | self.pp_editor = None 82 | self.protocol("WM_DELETE_WINDOW", self.on_close) 83 | 84 | def bind_shortcuts(self): 85 | self.bind("", self.send_message) 86 | self.bind("", self.resize_window) # keep horizontal padding proportional to window width 87 | self.bind("", self.toggle_box_focus) # toggle focus between input and thread box 88 | self.bind("", self.toggle_search_window) 89 | self.bind("", self.on_close) # close current window 90 | self.bind("", self.toggle_pp_editor) 91 | self.bind("", self.new_conversation) # clear thread_box, prepare for new file 92 | self.bind("", self.toggle_fullscreen) 93 | 94 | #-----------# 95 | # Messaging # 96 | #-----------# 97 | 98 | # Return 99 | def send_message(self, event=None): 100 | user_prompt = self.input_box.get_message() 101 | self.history.append({"role": "user", "content": user_prompt}) 102 | self.thread_box.add_message(user_prompt, "user") 103 | self.save_file() 104 | # make GPT API call to OpenAI with current message thread 105 | t = threading.Thread(target=self.invoke_gpt, args=(self.engine, self.truncated_thread())) 106 | t.start() 107 | return "break" # no newline character 108 | 109 | # reduce long threads to not send more than max_tokens via API call 110 | def truncated_thread(self): 111 | thread = self.history.copy() 112 | thread_token_counts = [len(self.encoding.encode(entry["content"])) for entry in self.history] 113 | while sum(thread_token_counts) > self.max_tokens: 114 | thread_token_counts.pop(0) 115 | if len(thread_token_counts) < len(self.history): 116 | thread = thread[-(len(thread_token_counts)+1):] 117 | # first_message_token_count = len(self.encoding.encode(thread[0]["content"])) 118 | truncate_index = self.max_tokens - sum(thread_token_counts) 119 | thread[0]["content"] = self.encoding.decode(self.encoding.encode(thread[0]["content"])[-truncate_index:]) 120 | return thread 121 | 122 | def invoke_gpt(self, engine, history): 123 | try: 124 | if self.pp_menu.pp: 125 | history.insert(-1, {"role": "system", "content": self.pp_menu.pp}) # Preprompt included as last message 126 | response = gpt.api_call(engine, history) # using ChatCompletion 127 | if self.pp_menu.pp: 128 | history.pop(-1) # Preprompt removed (not included in history) 129 | gpt_response = response["choices"][0]["message"]["content"] 130 | self.history.append({"role": "assistant", "content": gpt_response}) 131 | self.thread_box.add_message(gpt_response, "assistant") 132 | self.save_file() 133 | except Exception as err: 134 | self.thread_box.config(state=NORMAL) 135 | self.thread_box.insert(END, f"\n\n{err}", "error") 136 | self.thread_box.config(state=DISABLED) 137 | self.thread_box.see(END) 138 | 139 | def load_file(self, filename): 140 | filepath = os.path.join(self.directory, 'history', filename) 141 | with open(f"{filepath}.json", "r") as f: 142 | self.history = json.load(f) 143 | self.filename = filename 144 | self.thread_box.clear() 145 | for m in self.history: 146 | self.thread_box.add_message(m["content"], m["role"]) 147 | file_size = round(os.path.getsize(f"{filepath}.json") / 1024, 1) 148 | self.title(f"{filename} ({file_size} KB)") # main_window title 149 | 150 | def save_file(self): 151 | old_name = self.filename 152 | new_name = datetime.now().strftime("%Y-%m-%d_%H%M%S") 153 | old_path = os.path.join(self.directory, "history", old_name) 154 | new_path = os.path.join(self.directory, "history", new_name) 155 | with open(f"{old_path}.json", "w") as f: 156 | json.dump(self.history, f) 157 | os.rename(f"{old_path}.json", f"{new_path}.json") 158 | self.filename = new_name 159 | self.title(self.filename) 160 | 161 | #-----------# 162 | # Shortcuts # 163 | #-----------# 164 | 165 | # Ctrl-E 166 | def toggle_box_focus(self, event=None): 167 | if self.focus_get() == self.input_box: 168 | self.thread_box.focus_set() 169 | else: 170 | self.input_box.focus_set() 171 | 172 | # Ctrl-F 173 | def toggle_search_window(self, event=None): 174 | if not self.search_window_attributes: # set default attributes 175 | self.search_window_attributes["position"] = f"+{int(self.winfo_rootx() + self.winfo_width() / 2)}+{int(self.winfo_rooty() + self.winfo_height() / 2)}" # center window 176 | self.search_window_attributes["history"] = False # search only in this file 177 | self.search_window_attributes["backwards"] = True # search forwards 178 | self.search_window_attributes["query"] = "" # empty search string 179 | if self.search_window: # do not make new search window if one already exists 180 | self.search_window.search_entry.focus_set() 181 | else: 182 | search_window = SearchWindow(self, self.search_window_attributes) 183 | self.search_window = search_window 184 | 185 | # Ctrl-P 186 | def toggle_pp_editor(self, event=None): 187 | if self.pp_editor: 188 | self.pp_editor.destroy() 189 | self.pp_editor = None 190 | else: 191 | self.pp_editor = PrepromptEditor(self) 192 | 193 | # F5 194 | def new_conversation(self, event=None): 195 | self.history = [] 196 | self.title(self.engine) 197 | self.thread_box.clear() 198 | 199 | # F11 200 | def toggle_fullscreen(self, event=None): 201 | self.attributes("-fullscreen", not self.attributes("-fullscreen")) 202 | 203 | #--------# 204 | # Window # 205 | #--------# 206 | 207 | # update formatting dynamically whenever window changes size or position 208 | def resize_window(self, event=None): 209 | self.input_box.resize() 210 | 211 | # cleanup tkinter windows 212 | def on_close(self, event=None): 213 | if self.search_window: 214 | self.search_window.destroy() 215 | self.destroy() 216 | 217 | if __name__ == "__main__": 218 | root = MainWindow() 219 | root.iconbitmap(os.path.join(os.path.dirname(__file__), "icon.ico")) 220 | root.mainloop() 221 | -------------------------------------------------------------------------------- /preprompt_editor.py: -------------------------------------------------------------------------------- 1 | from tkinter import Toplevel, StringVar, Entry, END, Button, ttk 2 | import json 3 | 4 | class PrepromptEditor(Toplevel): 5 | def __init__(self, parent): 6 | super().__init__(parent) 7 | self.title("PrepromptEditor") 8 | self.parent = parent 9 | self.resizable(False, False) 10 | self.font = parent.font 11 | self.padding = parent.padding 12 | self.pp_title = StringVar(self, "Default") 13 | 14 | # Load preprompts from json file 15 | with open("preprompts.json", "r") as f: 16 | self.preprompts = json.load(f) 17 | 18 | # Display existing preprompts 19 | self.tree = ttk.Treeview(self, columns=("Title", "Prompt"), show="headings") 20 | self.tree.heading("Title", text="Title") 21 | self.tree.heading("Prompt", text="Prompt") 22 | self.tree.column("Title", width=200) 23 | self.tree.column("Prompt", width=400) 24 | for preprompt in self.preprompts: 25 | self.tree.insert("", "end", values=(preprompt["title"], preprompt["prompt"])) 26 | self.tree.pack(padx=self.padding, pady=self.padding) 27 | 28 | # Entry fields to add new preprompt 29 | self.title_entry = Entry(self, width=30) 30 | self.title_entry.insert(END, "New Preprompt Title") 31 | self.title_entry.pack(padx=self.padding, pady=self.padding) 32 | self.prompt_entry = Entry(self, width=50) 33 | self.prompt_entry.insert(END, "New Preprompt Prompt") 34 | self.prompt_entry.pack(padx=self.padding, pady=self.padding) 35 | 36 | # Buttons to add and delete preprompt 37 | add_button = ttk.Button(self, text="Add Preprompt", command=self.add_preprompt) 38 | add_button.pack(side="left", padx=self.padding, pady=self.padding) 39 | delete_button = ttk.Button(self, text="Delete Preprompt", command=self.delete_preprompt) 40 | delete_button.pack(side="right", padx=self.padding, pady=self.padding) 41 | 42 | # Add new preprompt and refresh display 43 | def add_preprompt(self): 44 | new_preprompt = {"title": self.title_entry.get(), "prompt": self.prompt_entry.get()} 45 | self.preprompts.append(new_preprompt) 46 | with open("preprompts.json", "w") as f: 47 | json.dump(self.preprompts, f, indent=2) 48 | self.tree.insert("", "end", values=(new_preprompt["title"], new_preprompt["prompt"])) 49 | self.title_entry.delete(0, END) 50 | self.prompt_entry.delete(0, END) 51 | 52 | # Delete selected preprompt and refresh display 53 | def delete_preprompt(self): 54 | selected_iid = self.tree.selection() 55 | if selected_iid: 56 | for item in selected_iid: 57 | self.tree.delete(item) 58 | self.preprompts.pop(item, None) 59 | with open("preprompts.json", "w") as f: 60 | json.dump(self.preprompts, f, indent=2) -------------------------------------------------------------------------------- /preprompt_menu.py: -------------------------------------------------------------------------------- 1 | from tkinter import Toplevel, StringVar, LEFT, Label, END, OptionMenu, N, S, E, W 2 | import json 3 | 4 | class PrepromptMenu(OptionMenu): 5 | def __init__(self, parent, title): 6 | super().__init__(parent, title, "Default") 7 | self.option_add("*Menu*activeBackground", "#333") 8 | self.option_add("*Menu*activeForeground", "#975") 9 | self.option_add("*Menu*background", "#000") 10 | self.option_add("*Menu*foreground", "#975") 11 | self.pp = None 12 | self.parent = parent 13 | self.font = parent.font 14 | self.padding = parent.padding 15 | self.pp_title = StringVar(self, "Default") 16 | self.pp_title_list = self.get_pp_titles() 17 | self.grid(row=1, column=2, sticky=S+E+W, padx=(0, self.padding), pady=(0, self.padding)) 18 | self.configure(background="#333", foreground="#FFF", 19 | activebackground="#555", activeforeground="#FFF", 20 | relief="raised", direction="above") 21 | self.bind("", self.on_pp_hover) 22 | self.bind("", self.on_pp_hover) 23 | self.populate_pp_menu() 24 | 25 | def set_pp(self, title="Default"): 26 | if title == "Default": 27 | self.pp = None 28 | else: 29 | self.pp = self.pp_list[title] 30 | self.parent.pp_title.set(title) 31 | 32 | def populate_pp_menu(self): 33 | self["menu"].delete(0, "end") 34 | self["menu"].add_command(label="Default", font=self.font, command=lambda t="Default": self.set_pp(t)) 35 | for k,v in self.pp_list.items(): 36 | self["menu"].add_command(label=k, font=self.font, command=lambda t=k: self.set_pp(t)) 37 | 38 | def get_pp_titles(self): 39 | with open("preprompts.json") as f: 40 | pp_data = json.load(f) 41 | self.pp_list = {p['title']: p['prompt'] for p in pp_data} 42 | return ["Default"] + [k for k,v in self.pp_list.items()] 43 | 44 | def on_pp_hover(self, event): 45 | if event.type == "7": 46 | if self.pp: 47 | hover_text = self.pp 48 | hover_colour = "#66f" 49 | else: 50 | hover_text = "Default: no preprompt." 51 | hover_colour = "#555" 52 | screen_width = self.parent.winfo_screenwidth() 53 | screen_height = self.parent.winfo_screenheight() 54 | max_width = int(screen_width / 2.5) 55 | text_width = self.font.measure(hover_text) 56 | text_height = int(self.font.metrics("linespace") * text_width / max_width) 57 | x = int((screen_width - min(text_width, max_width)) / 2) 58 | y = int((screen_height - text_height) / 2) 59 | self.pp_hover_label = Toplevel(self) 60 | self.pp_hover_label.wm_overrideredirect(True) 61 | self.pp_hover_label.wm_geometry(f"+{x}+{y}") 62 | label = Label(self.pp_hover_label, text=hover_text, wraplength=max_width, 63 | bg="#000", fg=hover_colour, font=self.font, relief="raised", justify=LEFT) 64 | label.pack() 65 | elif event.type == "8": 66 | if self.pp_hover_label: 67 | self.pp_hover_label.destroy() -------------------------------------------------------------------------------- /preprompts.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "title": "Socrates", 4 | "prompt": "Reply in the Socratic style. Do not provide answers. Instead, patiently and methodically ask questions such that curious minds may lead themselves to truth and wisdom. When appropriate you may break down topics into ever-finer detail, or broaden the scope, or make insightful lateral leaps." 5 | }, 6 | { 7 | "title": "Cthulu", 8 | "prompt": "Reply in horrific Lovecraftian style. You are an eldritch god, whose words are dredged from an unfathomable cosmic abyss." 9 | }, 10 | { 11 | "title": "Poet", 12 | "prompt": "Write beautifully and metaphorically." 13 | }, 14 | { 15 | "title": "XY Problem", 16 | "prompt": "In your reply, consider the XY problem (asking about an attempted solution rather than the underlying problem)." 17 | }, 18 | { 19 | "title": "Dev", 20 | "prompt": "You are a senior software developer. For each new/changed/deleted line in your reply, label it and explain the addition/change/deletion. Do not rewrite unchanged code. Consider suggesting optimization, security, or readability improvements. Use 2-space tabs." 21 | } 22 | ] -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # GPT Desktop Chatbot 2 | Python tkinter desktop chat interface for GPT via OpenAI's API 3 | 4 | ## Author 5 | Adam Ries 6 | 7 | Calgary, Alberta, Canada 8 | 9 | adamalexanderries{}gmail{}com 10 | 11 | ## Features 12 | - thread autosaving in local `history/` folder 13 | - search current or all threads `ctrl-f` 14 | - manually rename conversations `ctrl-s` 15 | - preprompt dropdown menu 16 | - [keyboard controls](#hotkeys) 17 | 18 | ## Setup 19 | - Dependencies (older versions are for Windows 7 compatibility, because I'm a dinosaur) 20 | - Python 3.8.0 21 | - OpenAI 0.27.0 22 | - TikToken 0.3.0 23 | 24 | - Set `OPENAI_API_KEY` in your environment variables 25 | - [Get an API key here.](https://platform.openai.com/account/api-keys) 26 | - `import openai` automatically finds your key if you've set it to that name 27 | 28 | - run main_window.py 29 | 30 | ## Project Directory 31 | 32 | ### main_window.py 33 | ![main_window](screenshots/main_window.png) 34 | - three dropdown menus 35 | - `Preprompts` - select system message 36 | - `Saved`, `History` - load threads 37 | - thread box contains current conversation history 38 | - assistant: gold #997755 39 | - user: grey #555555 40 | - system: blue #6666ff 41 | - error: red #ff0000 42 | - input box below 43 | - threads are truncated if longer than max_tokens 44 | - currently hardcoded 3096 leaving 1000 tokens for response 45 | - blue horizontal line shows where previous API call sliced thread 46 | 47 | ### search_window.py 48 | ![search_window](screenshots/search_window.png) 49 | 50 | ### gpt.py 51 | - separated from main_window.py because it kept trying to correct itself after OpenAI changed [API formatting](https://platform.openai.com/docs/guides/chat) from `Completion` to `ChatCompletion` on 2023-03-01 with `gpt-3.5-turbo` release (due to 2021 training data cutoff) 52 | - run this by itself for barebones interaction with GPT in terminal 53 | 54 | ### history/ 55 | - threads saved in this folder as json files 56 | - open old threads with `History` and `Saved` dropdown menus 57 | ![history menu](screenshots/history_menu.png) 58 | 59 | Format: 60 | - 2023-03-02_151106.json (`datetime.now().strftime("%Y-%m-%d_%H%M%S")`) 61 | - [{"role": "user", "content": "user input"}, {"role": "assistant", "content": "AI response"}, ...] 62 | 63 | ### preprompts.json 64 | ![preprompts](screenshots/preprompts.png) 65 | - inserted before each API call as `{"role": "system", "content": "[preprompt text]"}` 66 | - not saved in conversation history 67 | - add/remove/edit manually for now (any text editor) 68 | 69 | Included preprompts: 70 | - Default: Be accurate, detailed, and clear. Predict my needs. 71 | - Coder: You are a senior software developer and mentor. I am a novice developer and student. 72 | - Socrates: Reply in the Socratic style. Do not provide answers. Instead, patiently and methodically ask questions such that curious minds may lead themselves to truth and wisdom. When appropriate you may break down topics into ever-finer detail, or broaden the scope, or make insightful lateral leaps. 73 | - Cthulu: Reply in horrific Lovecraftian style. You are an eldritch god, whose words are dredged from an unfathomable cosmic abyss. 74 | - Poet: Write beautifully and metaphorically. 75 | 76 | ## Hotkeys 77 | Main window: 78 | - `ctrl-e` toggle focus between input box and thread box 79 | - `ctrl-f` toggle search window 80 | - `ctrl-s` rename current thread 81 | - `ctrl-w` close current window 82 | - `f5` start a new conversation thread 83 | - `f11` toggle fullscreen 84 | - `Return` send message 85 | - `Shift-Return` insert newline 86 | 87 | Search window: 88 | - `return` next match 89 | - `ctrl-d` toggle search direction 90 | - `ctrl-g` toggle searching in current/all files 91 | - `ctrl-f`, `esc` close search window -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Python 3.8.0 2 | openai==0.27.0 3 | tiktoken==0.3.0 -------------------------------------------------------------------------------- /screenshots/history_menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RealityAnchor/ries-gpt-ui/8d881a1f089573e918f93266f2ef6b21e3faee15/screenshots/history_menu.png -------------------------------------------------------------------------------- /screenshots/main_window.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RealityAnchor/ries-gpt-ui/8d881a1f089573e918f93266f2ef6b21e3faee15/screenshots/main_window.png -------------------------------------------------------------------------------- /screenshots/preprompts.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RealityAnchor/ries-gpt-ui/8d881a1f089573e918f93266f2ef6b21e3faee15/screenshots/preprompts.png -------------------------------------------------------------------------------- /screenshots/search_window.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RealityAnchor/ries-gpt-ui/8d881a1f089573e918f93266f2ef6b21e3faee15/screenshots/search_window.png -------------------------------------------------------------------------------- /search_window.py: -------------------------------------------------------------------------------- 1 | from tkinter import * 2 | from tkinter import ttk 3 | from tkinter.simpledialog import askstring 4 | import os 5 | import json 6 | 7 | # Ctrl-f function in MainWindow opens a SearchWindow 8 | # Toplevel specifies that this is a subclass of another tkinter window 9 | class SearchWindow(Toplevel): 10 | def __init__(self, parent, attributes=None): 11 | super().__init__(parent) 12 | self.title("SearchWindow") 13 | self.parent = parent 14 | self.resizable(False, False) 15 | self.cur_file_index = 0 16 | self.cur_char_index = "" 17 | 18 | self.attr = attributes 19 | self.search_history = BooleanVar() 20 | self.search_backwards = BooleanVar() 21 | self.search_entry = Entry(self, width=30) 22 | 23 | # 24 | if self.attr: 25 | self.geometry(self.attr["position"]) 26 | self.search_history.set(self.attr["history"]) 27 | self.search_backwards.set(self.attr["backwards"]) 28 | self.search_entry.insert(END, self.attr['query']) 29 | else: # redundant with default attribute settings in MainWindow 30 | self.geometry("+100+100") 31 | self.search_history.set(False) 32 | self.search_backwards.set(True) 33 | 34 | self.search_entry.bind("", self.increment_search) 35 | self.bind("", lambda event: self.toggle_search_history()) 36 | self.bind("", lambda event: self.toggle_search_backwards()) 37 | # different ways to close 38 | self.bind("", self.on_close) # toggle window 39 | self.bind("", self.on_close) 40 | self.protocol("WM_DELETE_WINDOW", self.on_close) 41 | 42 | # buttons 43 | search_button = ttk.Button(self, text="Search", command=self.increment_search) 44 | self.search_backwards_toggle = ttk.Checkbutton(self, text="Backwards search [Ctrl-D]", variable=self.search_backwards) 45 | self.search_history_toggle = ttk.Checkbutton(self, text="File search [Ctrl-G]", variable=self.search_history) 46 | 47 | # formatting 48 | self.search_entry.grid(row=0, column=0, padx=(5,0), pady=5) 49 | search_button.grid(row=0, column=1, padx=(5,5), pady=5) 50 | self.search_backwards_toggle.grid(row=1,column=0, padx=5, sticky="w") 51 | self.search_history_toggle.grid(row=2,column=0, padx=5, sticky="w") 52 | self.search_entry.focus_set() 53 | 54 | # Ctrl-G 55 | def toggle_search_history(self): 56 | toggled_history = not self.search_history.get() 57 | self.search_history.set(toggled_history) 58 | self.parent.search_window_attributes["history"] = toggled_history 59 | 60 | # Ctrl-D 61 | def toggle_search_backwards(self): 62 | toggled_backwards = not self.search_backwards.get() 63 | self.search_backwards.set(toggled_backwards) 64 | self.parent.search_window_attributes["backwards"] = toggled_backwards 65 | 66 | # find the next or the previous term, in all files or current file 67 | def increment_search(self, event=None): 68 | backwards = self.search_backwards.get() 69 | query = self.search_entry.get().lower() 70 | self.parent.search_window_attributes["query"] = query 71 | if not query: 72 | return 73 | 74 | if self.search_history.get(): # search all files in history 75 | matching_filenames = self.get_json_filenames("history", query) 76 | self.parent.populate_history_menu(matching_filenames) 77 | file_count = len(matching_filenames) 78 | if file_count > 0: 79 | if backwards: 80 | if self.cur_file_index == 0: 81 | self.cur_file_index = file_count - 1 82 | else: 83 | self.cur_file_index -= 1 84 | else: 85 | if self.cur_file_index < file_count - 1: 86 | self.cur_file_index += 1 87 | else: 88 | self.cur_file_index = 0 89 | self.parent.load_file(matching_filenames[self.cur_file_index]) 90 | # refactor so that i can self.increment_search(search_history=False) 91 | self.toggle_search_history() 92 | self.increment_search() 93 | self.toggle_search_history() 94 | 95 | else: # search current thread 96 | if self.cur_char_index == "": # when search begins 97 | self.cur_char_index = END if backwards else "1.0" # search back to front or front to back 98 | if not backwards: 99 | self.cur_char_index = f"{self.cur_char_index}+1c" # increment to prevent returning same string repeatedly 100 | self.cur_char_index = self.find_next_string(query, self.cur_char_index, backwards) # highlight match in MainWindow 101 | 102 | def find_next_string(self, query_string, starting_index, backwards=False): 103 | first_char_index = None 104 | last_char_index = None 105 | first_char_index = self.parent.thread_box.search(query_string, starting_index, nocase=True, backwards=backwards) 106 | if first_char_index: # add highlighting on matches 107 | self.parent.thread_box.tag_remove("highlight", "1.0", END) 108 | last_char_index = f"{first_char_index}+{len(query_string)}c" 109 | self.parent.thread_box.tag_add("highlight", first_char_index, last_char_index) 110 | self.parent.thread_box.see(first_char_index) 111 | return first_char_index 112 | 113 | # return all json files in specified folder which contain the search terms 114 | def get_json_filenames(self, folder, query): 115 | results = [] 116 | for filename in os.listdir(folder): 117 | title, ext = os.path.splitext(filename) 118 | if ext == ".json": 119 | with open(os.path.join(folder, filename), "r") as file: 120 | data = json.load(file) 121 | for entry in data: 122 | if query.lower() in entry["content"].lower(): 123 | results.append(title) 124 | break # after finding a match, stop searching in current file 125 | return results 126 | 127 | # parent MainWindow cleanup: 128 | # remove highlights 129 | # store current window position and search string 130 | def on_close(self, event=None): 131 | self.parent.thread_box.tag_remove("highlight", "1.0", END) 132 | self.parent.search_window = None 133 | self.parent.search_window_attributes["position"] = self.geometry() 134 | self.parent.search_window_attributes["query"] = self.search_entry.get().lower() 135 | super().destroy() 136 | -------------------------------------------------------------------------------- /text_box.py: -------------------------------------------------------------------------------- 1 | from tkinter import * 2 | from tkinter.font import Font 3 | 4 | class TextBox(Text): 5 | def __init__(self, parent): 6 | super().__init__(parent) 7 | self.padding = parent.padding 8 | self.font = parent.font 9 | self.config(wrap=WORD, bg="#000", font=parent.font) 10 | self.grid(column=1, sticky="nsew", padx=self.padding, pady=self.padding) 11 | self.bind("", lambda event, box=self: toggle_bg_colour(box, event)) 12 | self.bind("", lambda event, box=self: toggle_bg_colour(box, event)) 13 | 14 | self.context_menu = Menu(parent, tearoff=0, bg="#111") 15 | self.context_menu.add_command(label="Copy", command=lambda: self.event_generate("<>")) 16 | self.bind("", lambda event: self.context_menu.tk_popup(event.x_root, event.y_root)) 17 | 18 | class ThreadBox(TextBox): 19 | def __init__(self, parent): 20 | super().__init__(parent) 21 | self.config(state=DISABLED, fg="#888") 22 | self.grid(row=0) 23 | self.tag_configure("user", foreground="#555") 24 | self.tag_configure("assistant", foreground="#975") 25 | self.tag_configure("system", foreground="#66f") 26 | self.tag_configure("prompt", foreground="#66f") 27 | self.tag_configure("error", foreground="#f00") 28 | self.tag_configure("highlight", background="#ff6", foreground="#000") 29 | 30 | # display current message history in thread_box 31 | def add_message(self, message, role): 32 | self.config(state=NORMAL) # enable editing text in thread_box 33 | if self.index(INSERT) != "1.0": 34 | self.insert(END, "\n---\n", "system") 35 | self.insert(END, message, role) 36 | self.config(state=DISABLED) # disable editing text in thread_box 37 | self.see(END) 38 | 39 | def clear(self): 40 | self.config(state=NORMAL) 41 | self.delete("1.0", "end") 42 | self.config(state=DISABLED) 43 | 44 | class InputBox(TextBox): 45 | def __init__(self, parent): 46 | super().__init__(parent) 47 | self.config(insertbackground="#888", undo=True, height=1, fg="#555", bg="#000") 48 | self.width = self.winfo_width() 49 | self.grid(row=1) 50 | self.context_menu.add_command(label="Cut", command=lambda: self.event_generate("<>")) 51 | self.context_menu.add_command(label="Paste", command=lambda: self.event_generate("<>")) 52 | self.bind("", self.input_newline) 53 | self.bind("", self.resize) 54 | self.bind("", self.resize) 55 | self.focus_set() 56 | 57 | # Shift-Return 58 | def input_newline(self, event=None): 59 | self.insert(INSERT, "\n") 60 | return "break" 61 | 62 | # dynamically resize input_box while typing or resizing window 63 | def resize(self, event=None): 64 | input_width = self.winfo_width() 65 | num_lines = 0 66 | for line in self.get("1.0", "end").splitlines(): 67 | num_lines += 1 + int(self.font.measure(line) / input_width) 68 | self.config(height=min(num_lines, 20)) 69 | self.see(INSERT) 70 | 71 | def get_message(self): 72 | message = self.get("1.0", "end").strip() 73 | self.delete("1.0", "end") 74 | return message 75 | 76 | # clarify caret location 77 | def toggle_bg_colour(box, event=None): 78 | if event.type == "9": # 79 | box.config(background="#080808") 80 | elif event.type == "10": # 81 | box.config(background="#000") -------------------------------------------------------------------------------- /todo.md: -------------------------------------------------------------------------------- 1 | # To Do 2 | 3 | ## Bugs 4 | - unfuck save/load 5 | - history menu white bg -> black 6 | 7 | ## Features 8 | - Help button ctrl-h 9 | - Ctrl-up/down, snap to prompts, up-down always navigate one line 10 | - Responsive web design for phone/desktop online app 11 | - Whisper/elevenlabs for speech i/o 12 | - Blank string message = resend last message 13 | - Ctrl-n new tab, ctrl-left/right tab switching 14 | - Code editing mode 15 | - Read code from file, always send as first message 16 | - Codex model "handles whitespace more efficiently" 17 | - Add preprompt and timestamp to history JSON files 18 | - Drag inner window to adjust width or toggle narrow/wide for reading/coding 19 | - Field to set API key within app 20 | - Lock vertical position toggle 21 | - Scrollbar 22 | - add alpha channel for icon 23 | 24 | ## History menu 25 | - Update without restarting 26 | - Make OptionMenu in left column 27 | - Remove top menu bar 28 | - Sort files by recently-edited 29 | - Replace "Saved" with tags (include or disclude) 30 | - Use only date+time for filename 31 | - Store thread title in json 32 | - Automatically generate titles with GPT 33 | - Upgrade to history window 34 | - Display threads in folders in tree structure 35 | - Implement click-and-drag 36 | - Display table with columns: time created, time modified, filesize, thread length, tags 37 | 38 | ## Options menu `ctrl-o` 39 | - Store user settings in JSON file 40 | - Keybinds 41 | - Font 42 | - Colours 43 | 44 | ## SearchWindow class `ctrl-s` 45 | - Add progress bar for file search 46 | - Cache data for faster search 47 | - Indicate failed search 48 | - Add "match case" toggle 49 | - Separate tag search field + content search field 50 | - Stop file search after first match 51 | 52 | ## PrepromptEditor class `ctrl-p` 53 | - Implement pp editing/creation window 54 | 55 | ## Chapters 56 | - Represent prompt-response pairs in left column, numbered 57 | - Snap to prompt when chapter number clicked 58 | - Display options below chapter numbers 59 | - Copy 60 | - Collapse/expand 61 | - Delete (and undo) 62 | - 3-way toggle for token limit 63 | - force-include 64 | - force-disclude 65 | - chronological (default) 66 | 67 | ## GitHub 68 | - Fix redundant language in social media preview 69 | - Implement unit testing 70 | - Implement packaging 71 | 72 | ## GPT-4 73 | - Image input 74 | - Plugins 75 | 76 | ## Dependencies 77 | - Linux dual boot -> Py 3.11 --------------------------------------------------------------------------------