ollama-gui/ollama_gui.py
chyok dbe49cef26
Version 1.2.1 (#4)
Update version to 1.2.1
2024-07-18 00:32:07 +08:00

678 lines
25 KiB
Python

import sys
import json
import time
import pprint
import platform
import webbrowser
import urllib.parse
import urllib.request
from threading import Thread
from typing import Optional, List, Generator
try:
import tkinter as tk
from tkinter import ttk, font, messagebox
except (ModuleNotFoundError, ImportError):
print(
"Your Python installation does not include the Tk library. \n"
"Please refer to https://github.com/chyok/ollama-gui?tab=readme-ov-file#-qa")
sys.exit(0)
__version__ = "1.2.1"
def _system_check(root: tk.Tk) -> Optional[str]:
"""
Detected some system and software compatibility issues,
and returned the information in the form of a string to alert the user
:param root: Tk instance
:return: None or message string
"""
def _version_tuple(v):
"""A lazy way to avoid importing third-party libraries"""
filled = []
for point in v.split("."):
filled.append(point.zfill(8))
return tuple(filled)
# Tcl and macOS issue: https://github.com/python/cpython/issues/110218
if platform.system().lower() == "darwin":
version = platform.mac_ver()[0]
if version and 14 <= float(version) < 15:
tcl_version = root.tk.call("info", "patchlevel")
if _version_tuple(tcl_version) <= _version_tuple("8.6.12"):
return (
"Warning: Tkinter Responsiveness Issue Detected\n\n"
"You may experience unresponsive GUI elements when "
"your cursor is inside the window during startup. "
"This is a known issue with Tcl/Tk versions 8.6.12 "
"and older on macOS Sonoma.\n\nTo resolve this:\n"
"Update to Python 3.11.7+ or 3.12+\n"
"Or install Tcl/Tk 8.6.13 or newer separately\n\n"
"Temporary workaround: Move your cursor out of "
"the window and back in if elements become unresponsive.\n\n"
"For more information, visit: https://github.com/python/cpython/issues/110218"
)
class OllamaInterface:
chat_box: tk.Text
user_input: tk.Text
host_input: ttk.Entry
progress: ttk.Progressbar
stop_button: ttk.Button
send_button: ttk.Button
refresh_button: ttk.Button
download_button: ttk.Button
delete_button: ttk.Button
model_select: ttk.Combobox
log_textbox: tk.Text
models_list: tk.Listbox
def __init__(self, root: tk.Tk):
self.root: tk.Tk = root
self.api_url: str = "http://127.0.0.1:11434"
self.chat_history: List[dict] = []
self.label_widgets: List[tk.Label] = []
self.default_font: str = font.nametofont("TkTextFont").actual()["family"]
self.layout = LayoutManager(self)
self.layout.init_layout()
self.root.after(200, self.check_system)
self.refresh_models()
def copy_text(self, text: str):
if text:
self.chat_box.clipboard_clear()
self.chat_box.clipboard_append(text)
def copy_all(self):
self.copy_text(pprint.pformat(self.chat_history))
@staticmethod
def open_homepage():
webbrowser.open("https://github.com/chyok/ollama-gui")
def show_help(self):
info = ("Project: Ollama GUI\n"
f"Version: {__version__}\n"
"Author: chyok\n"
"Github: https://github.com/chyok/ollama-gui\n\n"
"<Enter>: send\n"
"<Shift+Enter>: new line\n"
"<Double click dialog>: edit dialog\n")
messagebox.showinfo("About", info, parent=self.root)
def check_system(self):
message = _system_check(self.root)
if message is not None:
messagebox.showwarning("Warning", message, parent=self.root)
def append_text_to_chat(self,
text: str,
*args,
use_label: bool = False):
self.chat_box.config(state=tk.NORMAL)
if use_label:
cur_label_widget = self.label_widgets[-1]
cur_label_widget.config(text=cur_label_widget.cget("text") + text)
else:
self.chat_box.insert(tk.END, text, *args)
self.chat_box.see(tk.END)
self.chat_box.config(state=tk.DISABLED)
def append_log_to_inner_textbox(self,
message: Optional[str] = None,
clear: bool = False):
if self.log_textbox.winfo_exists():
self.log_textbox.config(state=tk.NORMAL)
if clear:
self.log_textbox.delete(1.0, tk.END)
elif message:
self.log_textbox.insert(tk.END, message + "\n")
self.log_textbox.config(state=tk.DISABLED)
self.log_textbox.see(tk.END)
def resize_inner_text_widget(self, event: tk.Event):
for i in self.label_widgets:
current_width = event.widget.winfo_width()
max_width = int(current_width) * 0.7
i.config(wraplength=max_width)
def show_error(self, text):
self.model_select.set(text)
self.model_select.config(foreground="red")
self.model_select["values"] = []
self.send_button.state(["disabled"])
def show_process_bar(self):
self.progress.grid(row=0, column=0, sticky="nsew")
self.stop_button.grid(row=0, column=1, padx=20)
self.progress.start(5)
def hide_process_bar(self):
self.progress.stop()
self.stop_button.grid_remove()
self.progress.grid_remove()
def handle_key_press(self, event: tk.Event):
if event.keysym == "Return":
if event.state & 0x1 == 0x1: # Shift key is pressed
self.user_input.insert("end", "\n")
elif "disabled" not in self.send_button.state():
self.on_send_button(event)
return "break"
def refresh_models(self):
self.update_host()
self.model_select.config(foreground="black")
self.model_select.set("Waiting...")
self.send_button.state(["disabled"])
self.refresh_button.state(["disabled"])
Thread(target=self.update_model_select, daemon=True).start()
def update_host(self):
self.api_url = self.host_input.get()
def update_model_select(self):
try:
models = self.fetch_models()
self.model_select["values"] = models
if models:
self.model_select.set(models[0])
self.send_button.state(["!disabled"])
else:
self.show_error("You need download a model!")
except Exception: # noqa
self.show_error("Error! Please check the host.")
finally:
self.refresh_button.state(["!disabled"])
def update_model_list(self):
if self.models_list.winfo_exists():
self.models_list.delete(0, tk.END)
try:
models = self.fetch_models()
for model in models:
self.models_list.insert(tk.END, model)
except Exception: # noqa
self.append_log_to_inner_textbox("Error! Please check the Ollama host.")
def on_send_button(self, _=None):
message = self.user_input.get("1.0", "end-1c")
if message:
self.layout.create_inner_label(on_right_side=True)
self.append_text_to_chat(f"{message}", use_label=True)
self.append_text_to_chat(f"\n\n")
self.user_input.delete("1.0", "end")
self.chat_history.append({"role": "user", "content": message})
Thread(
target=self.generate_ai_response,
daemon=True,
).start()
def generate_ai_response(self):
self.show_process_bar()
self.send_button.state(["disabled"])
self.refresh_button.state(["disabled"])
try:
self.append_text_to_chat(f"{self.model_select.get()}\n", ("Bold",))
ai_message = ""
self.layout.create_inner_label()
for i in self.fetch_chat_stream_result():
self.append_text_to_chat(f"{i}", use_label=True)
ai_message += i
self.chat_history.append({"role": "assistant", "content": ai_message})
self.append_text_to_chat("\n\n")
except Exception: # noqa
self.append_text_to_chat(tk.END, f"\nAI error!\n\n", ("Error",))
finally:
self.hide_process_bar()
self.send_button.state(["!disabled"])
self.refresh_button.state(["!disabled"])
self.stop_button.state(["!disabled"])
def fetch_models(self) -> List[str]:
with urllib.request.urlopen(
urllib.parse.urljoin(self.api_url, "/api/tags")
) as response:
data = json.load(response)
models = [model["name"] for model in data["models"]]
return models
def fetch_chat_stream_result(self) -> Generator:
request = urllib.request.Request(
urllib.parse.urljoin(self.api_url, "/api/chat"),
data=json.dumps(
{
"model": self.model_select.get(),
"messages": self.chat_history,
"stream": True,
}
).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(request) as resp:
for line in resp:
if "disabled" in self.stop_button.state(): # stop
break
data = json.loads(line.decode("utf-8"))
if "message" in data:
time.sleep(0.01)
yield data["message"]["content"]
def delete_model(self, model_name: str):
self.append_log_to_inner_textbox(clear=True)
if not model_name:
return
req = urllib.request.Request(
urllib.parse.urljoin(self.api_url, "/api/delete"),
data=json.dumps({"name": model_name}).encode("utf-8"),
method="DELETE",
)
try:
with urllib.request.urlopen(req) as response:
if response.status == 200:
self.append_log_to_inner_textbox("Model deleted successfully.")
elif response.status == 404:
self.append_log_to_inner_textbox("Model not found.")
except Exception as e:
self.append_log_to_inner_textbox(f"Failed to delete model: {e}")
finally:
self.update_model_list()
self.update_model_select()
def download_model(self, model_name: str, insecure: bool = False):
self.append_log_to_inner_textbox(clear=True)
if not model_name:
return
self.download_button.state(["disabled"])
req = urllib.request.Request(
urllib.parse.urljoin(self.api_url, "/api/pull"),
data=json.dumps(
{"name": model_name, "insecure": insecure, "stream": True}
).encode("utf-8"),
method="POST",
)
try:
with urllib.request.urlopen(req) as response:
for line in response:
data = json.loads(line.decode("utf-8"))
log = data.get("error") or data.get("status") or "No response"
if "status" in data:
total = data.get("total")
completed = data.get("completed", 0)
if total:
log += f" [{completed}/{total}]"
self.append_log_to_inner_textbox(log)
except Exception as e:
self.append_log_to_inner_textbox(f"Failed to download model: {e}")
finally:
self.update_model_list()
self.update_model_select()
if self.download_button.winfo_exists():
self.download_button.state(["!disabled"])
def clear_chat(self):
for i in self.label_widgets:
i.destroy()
self.label_widgets.clear()
self.chat_box.config(state=tk.NORMAL)
self.chat_box.delete(1.0, tk.END)
self.chat_box.config(state=tk.DISABLED)
self.chat_history.clear()
class LayoutManager:
"""
Manages the layout and arrangement of the OllamaInterface.
The LayoutManager is responsible for the visual organization and positioning
of the various components within the OllamaInterface, such as the header,
chat container, progress bar, and input fields. It handles the sizing,
spacing, and alignment of these elements to create a cohesive and
user-friendly layout.
"""
def __init__(self, interface: OllamaInterface):
self.interface: OllamaInterface = interface
self.management_window: Optional[tk.Toplevel] = None
self.editor_window: Optional[tk.Toplevel] = None
def init_layout(self):
self._header_frame()
self._chat_container_frame()
self._processbar_frame()
self._input_frame()
def _header_frame(self):
header_frame = ttk.Frame(self.interface.root)
header_frame.grid(row=0, column=0, sticky="ew", padx=20, pady=20)
header_frame.grid_columnconfigure(3, weight=1)
model_select = ttk.Combobox(header_frame, state="readonly", width=30)
model_select.grid(row=0, column=0)
settings_button = ttk.Button(
header_frame, text="⚙️", command=self.show_model_management_window, width=3
)
settings_button.grid(row=0, column=1, padx=(5, 0))
refresh_button = ttk.Button(header_frame, text="Refresh", command=self.interface.refresh_models)
refresh_button.grid(row=0, column=2, padx=(5, 0))
ttk.Label(header_frame, text="Host:").grid(row=0, column=4, padx=(10, 0))
host_input = ttk.Entry(header_frame, width=24)
host_input.grid(row=0, column=5, padx=(5, 15))
host_input.insert(0, self.interface.api_url)
self.interface.model_select = model_select
self.interface.refresh_button = refresh_button
self.interface.host_input = host_input
def _chat_container_frame(self):
chat_frame = ttk.Frame(self.interface.root)
chat_frame.grid(row=1, column=0, sticky="nsew", padx=20)
chat_frame.grid_columnconfigure(0, weight=1)
chat_frame.grid_rowconfigure(0, weight=1)
chat_box = tk.Text(
chat_frame,
wrap=tk.WORD,
state=tk.DISABLED,
font=(self.interface.default_font, 12),
spacing1=5,
highlightthickness=0,
)
chat_box.grid(row=0, column=0, sticky="nsew")
scrollbar = ttk.Scrollbar(chat_frame, orient="vertical", command=chat_box.yview)
scrollbar.grid(row=0, column=1, sticky="ns")
chat_box.configure(yscrollcommand=scrollbar.set)
chat_box_menu = tk.Menu(chat_box, tearoff=0)
chat_box_menu.add_command(label="Copy All", command=self.interface.copy_all)
chat_box_menu.add_separator()
chat_box_menu.add_command(label="Clear Chat", command=self.interface.clear_chat)
chat_box.bind("<Configure>", self.interface.resize_inner_text_widget)
_right_click = (
"<Button-2>" if platform.system().lower() == "darwin" else "<Button-3>"
)
chat_box.bind(_right_click, lambda e: chat_box_menu.post(e.x_root, e.y_root))
self.interface.chat_box = chat_box
def _processbar_frame(self):
process_frame = ttk.Frame(self.interface.root, height=28)
process_frame.grid(row=2, column=0, sticky="ew", padx=20, pady=10)
progress = ttk.Progressbar(
process_frame,
mode="indeterminate",
style="LoadingBar.Horizontal.TProgressbar",
)
stop_button = ttk.Button(
process_frame,
width=5,
text="Stop",
command=lambda: stop_button.state(["disabled"]),
)
self.interface.progress = progress
self.interface.stop_button = stop_button
def _input_frame(self):
input_frame = ttk.Frame(self.interface.root)
input_frame.grid(row=3, column=0, sticky="ew", padx=20, pady=(0, 20))
input_frame.grid_columnconfigure(0, weight=1)
user_input = tk.Text(
input_frame, font=(self.interface.default_font, 12), height=4, wrap=tk.WORD
)
user_input.grid(row=0, column=0, sticky="ew", padx=(0, 10))
user_input.bind("<Key>", self.interface.handle_key_press)
send_button = ttk.Button(
input_frame,
text="Send",
command=self.interface.on_send_button,
)
send_button.grid(row=0, column=1)
send_button.state(["disabled"])
menubar = tk.Menu(self.interface.root)
self.interface.root.config(menu=menubar)
file_menu = tk.Menu(menubar, tearoff=0)
menubar.add_cascade(label="File", menu=file_menu)
file_menu.add_command(label="Model Management", command=self.show_model_management_window)
file_menu.add_command(label="Exit", command=self.interface.root.quit)
edit_menu = tk.Menu(menubar, tearoff=0)
menubar.add_cascade(label="Edit", menu=edit_menu)
edit_menu.add_command(label="Copy All", command=self.interface.copy_all)
edit_menu.add_command(label="Clear Chat", command=self.interface.clear_chat)
help_menu = tk.Menu(menubar, tearoff=0)
menubar.add_cascade(label="Help", menu=help_menu)
help_menu.add_command(label="Source Code", command=self.interface.open_homepage)
help_menu.add_command(label="Help", command=self.interface.show_help)
self.interface.user_input = user_input
self.interface.send_button = send_button
def show_model_management_window(self):
self.interface.update_host()
if self.management_window and self.management_window.winfo_exists():
self.management_window.lift()
return
management_window = tk.Toplevel(self.interface.root)
management_window.title("Model Management")
screen_width = self.interface.root.winfo_screenwidth()
screen_height = self.interface.root.winfo_screenheight()
x = int((screen_width / 2) - (400 / 2))
y = int((screen_height / 2) - (500 / 2))
management_window.geometry(f"{400}x{500}+{x}+{y}")
management_window.grid_columnconfigure(0, weight=1)
management_window.grid_rowconfigure(3, weight=1)
frame = ttk.Frame(management_window)
frame.grid(row=0, column=0, sticky="ew", padx=10, pady=10)
frame.grid_columnconfigure(0, weight=1)
model_name_input = ttk.Entry(frame)
model_name_input.grid(row=0, column=0, sticky="ew", padx=(0, 5))
def _download():
arg = model_name_input.get().strip()
if arg.startswith("ollama run "):
arg = arg[11:]
Thread(
target=self.interface.download_model, daemon=True, args=(arg,)
).start()
def _delete():
arg = models_list.get(tk.ACTIVE).strip()
Thread(target=self.interface.delete_model, daemon=True, args=(arg,)).start()
download_button = ttk.Button(frame, text="Download", command=_download)
download_button.grid(row=0, column=1, sticky="ew")
tips = tk.Label(
frame,
text="find models: https://ollama.com/library",
fg="blue",
cursor="hand2",
)
tips.bind("<Button-1>", lambda e: webbrowser.open("https://ollama.com/library"))
tips.grid(row=1, column=0, sticky="W", padx=(0, 5), pady=5)
list_action_frame = ttk.Frame(management_window)
list_action_frame.grid(row=2, column=0, sticky="nsew", padx=10, pady=(0, 10))
list_action_frame.grid_columnconfigure(0, weight=1)
list_action_frame.grid_rowconfigure(0, weight=1)
models_list = tk.Listbox(list_action_frame)
models_list.grid(row=0, column=0, sticky="nsew")
scrollbar = ttk.Scrollbar(
list_action_frame, orient="vertical", command=models_list.yview
)
scrollbar.grid(row=0, column=1, sticky="ns")
models_list.config(yscrollcommand=scrollbar.set)
delete_button = ttk.Button(list_action_frame, text="Delete", command=_delete)
delete_button.grid(row=0, column=2, sticky="ew", padx=(5, 0))
log_textbox = tk.Text(management_window)
log_textbox.grid(row=3, column=0, sticky="nsew", padx=10, pady=(0, 10))
log_textbox.config(state="disabled")
self.management_window = management_window
self.interface.log_textbox = log_textbox
self.interface.download_button = download_button
self.interface.delete_button = delete_button
self.interface.models_list = models_list
Thread(
target=self.interface.update_model_list, daemon=True,
).start()
def show_editor_window(self, _, inner_label):
if self.editor_window and self.editor_window.winfo_exists():
self.editor_window.lift()
return
editor_window = tk.Toplevel(self.interface.root)
editor_window.title("Chat Editor")
screen_width = self.interface.root.winfo_screenwidth()
screen_height = self.interface.root.winfo_screenheight()
x = int((screen_width / 2) - (400 / 2))
y = int((screen_height / 2) - (300 / 2))
editor_window.geometry(f"{400}x{300}+{x}+{y}")
chat_editor = tk.Text(editor_window)
chat_editor.grid(row=0, column=0, columnspan=2, sticky="nsew", padx=5, pady=5)
chat_editor.insert(tk.END, inner_label.cget("text"))
editor_window.grid_rowconfigure(0, weight=1)
editor_window.grid_columnconfigure(0, weight=1)
editor_window.grid_columnconfigure(1, weight=1)
def _save():
idx = self.interface.label_widgets.index(inner_label)
if len(self.interface.chat_history) > idx:
self.interface.chat_history[idx]["content"] = chat_editor.get("1.0", "end-1c")
inner_label.config(text=chat_editor.get("1.0", "end-1c"))
editor_window.destroy()
save_button = tk.Button(editor_window, text="Save", command=_save)
save_button.grid(row=1, column=0, sticky="ew", padx=5, pady=5)
cancel_button = tk.Button(
editor_window, text="Cancel", command=editor_window.destroy
)
cancel_button.grid(row=1, column=1, sticky="ew", padx=5, pady=5)
editor_window.grid_columnconfigure(0, weight=1, uniform="btn")
editor_window.grid_columnconfigure(1, weight=1, uniform="btn")
self.editor_window = editor_window
def create_inner_label(self, on_right_side: bool = False):
background = "#48a4f2" if on_right_side else "#eaeaea"
foreground = "white" if on_right_side else "black"
max_width = int(self.interface.chat_box.winfo_reqwidth()) * 0.7
inner_label = tk.Label(
self.interface.chat_box,
justify=tk.LEFT,
wraplength=max_width,
background=background,
highlightthickness=0,
highlightbackground=background,
foreground=foreground,
padx=8,
pady=8,
font=(self.interface.default_font, 12),
borderwidth=0,
)
self.interface.label_widgets.append(inner_label)
inner_label.bind(
"<MouseWheel>",
lambda e:
self.interface.chat_box.yview_scroll(int(-1 * (e.delta / 120)), "units")
)
inner_label.bind("<Double-1>", lambda e: self.show_editor_window(e, inner_label))
_right_menu = tk.Menu(inner_label, tearoff=0)
_right_menu.add_command(
label="Edit", command=lambda: self.show_editor_window(None, inner_label)
)
_right_menu.add_command(
label="Copy This", command=lambda: self.interface.copy_text(inner_label.cget("text"))
)
_right_menu.add_separator()
_right_menu.add_command(label="Clear Chat", command=self.interface.clear_chat)
_right_click = (
"<Button-2>" if platform.system().lower() == "darwin" else "<Button-3>"
)
inner_label.bind(_right_click, lambda e: _right_menu.post(e.x_root, e.y_root))
self.interface.chat_box.window_create(tk.END, window=inner_label)
if on_right_side:
idx = self.interface.chat_box.index("end-1c").split(".")[0]
self.interface.chat_box.tag_add("Right", f"{idx}.0", f"{idx}.end")
def run():
root = tk.Tk()
root.title("Ollama GUI")
screen_width = root.winfo_screenwidth()
screen_height = root.winfo_screenheight()
root.geometry(f"800x600+{(screen_width - 800) // 2}+{(screen_height - 600) // 2}")
root.grid_columnconfigure(0, weight=1)
root.grid_rowconfigure(1, weight=1)
root.grid_rowconfigure(2, weight=0)
root.grid_rowconfigure(3, weight=0)
app = OllamaInterface(root)
app.chat_box.tag_configure(
"Bold", foreground="#ff007b", font=(app.default_font, 10, "bold")
)
app.chat_box.tag_configure("Error", foreground="red")
app.chat_box.tag_configure("Right", justify="right")
root.mainloop()
if __name__ == "__main__":
run()