24 / 08 / 11
v1.0.7
import tkinter as tk from tkinter import scrolledtext from tkinter import messagebox from PIL import Image, ImageTk from openai import OpenAI import json import threading class ChatApp: def __init__(self, root): self.root = root self.root.title("Chat Application") self.root.geometry("800x600") # 配置根窗口的网格权重 self.root.grid_columnconfigure(0, weight=1) self.root.grid_rowconfigure(0, weight=1) # 创建并配置消息框架 self.message_frame = tk.Frame(self.root) self.message_frame.grid(column=0, row=0, padx=10, pady=10, sticky="nsew") self.message_frame.grid_columnconfigure(0, weight=1) self.message_frame.grid_rowconfigure(0, weight=1) # 创建并配置画布和滚动条 self.canvas = tk.Canvas(self.message_frame) self.scrollbar = tk.Scrollbar(self.message_frame, orient="vertical", command=self.canvas.yview) self.scrollable_frame = tk.Frame(self.canvas) self.scrollable_frame.bind( "<Configure>", lambda e: self.canvas.configure( scrollregion=self.canvas.bbox("all") ) ) self.canvas.create_window((0, 0), window=self.scrollable_frame, anchor="nw") self.canvas.configure(yscrollcommand=self.scrollbar.set) self.canvas.pack(side="left", fill="both", expand=True) self.scrollbar.pack(side="right", fill="y") # 绑定鼠标滚轮事件 self.canvas.bind_all("<MouseWheel>", self._on_mousewheel) # 输入框和发送按钮框架 input_frame = tk.Frame(self.root) input_frame.grid(column=0, row=1, padx=10, pady=5, sticky="ew") input_frame.grid_columnconfigure(0, weight=1) # 输入框 self.entry = tk.Entry(input_frame, width=50) self.entry.grid(column=0, row=0, padx=(0, 5), pady=5, sticky="ew") # 发送按钮 self.send_button = tk.Button(input_frame, text="发送", command=self.send_message) self.send_button.grid(column=1, row=0, padx=5, pady=5, sticky="e") # 控制按钮框架 control_frame = tk.Frame(self.root) control_frame.grid(column=0, row=2, padx=10, pady=5, sticky="ew") # 新对话按钮 self.new_chat_button = tk.Button(control_frame, text="新对话", command=self.new_chat) self.new_chat_button.grid(column=0, row=0, padx=(0, 5), pady=5, sticky="w") # Debug按钮 self.debug_button = tk.Button(control_frame, text="Debug", command=self.show_debug_info) self.debug_button.grid(column=1, row=0, padx=5, pady=5, sticky="w") # 流式输出复选框 self.stream_var = tk.BooleanVar(value=True) self.stream_checkbox = tk.Checkbutton(control_frame, text="流式输出", variable=self.stream_var) self.stream_checkbox.grid(column=2, row=0, padx=5, pady=5, sticky="w") # 模式选择下拉菜单 self.context_mode_var = tk.StringVar(value="上下文连续对话") self.context_mode_menu = tk.OptionMenu(control_frame, self.context_mode_var, "独立对话", "上下文连续对话") self.context_mode_menu.grid(column=3, row=0, padx=5, pady=5, sticky="w") # 加载用户和助手头像 self.user_img = self.load_and_resize_image("images/user_icon.png", (25, 25)) self.assistant_img = self.load_and_resize_image("images/chatgpt_icon.png", (25, 25)) # 初始化OpenAI API self.client = OpenAI( api_key="sk-azd0QHBY7", base_url="https://api.chatanywhere.com.cn/v1/" ) self.debug_info = "" self.messages = [{"role": "system", "content": "You are a helpful assistant."}] # 绑定窗口大小变化事件以调整wraplength self.root.bind('<Configure>', self.update_wraplengths) def load_and_resize_image(self, path, size): img = Image.open(path) img = img.resize(size, Image.LANCZOS) return ImageTk.PhotoImage(img) def new_chat(self): for widget in self.scrollable_frame.winfo_children(): widget.destroy() self.messages = [{"role": "system", "content": "You are a helpful assistant."}] # 重新配置画布的滚动区域为初始状态 self.canvas.configure(scrollregion=(0, 0, 0, 0)) def send_message(self): user_message = self.entry.get() if user_message: self.add_message_to_frame("user", user_message) self.entry.delete(0, tk.END) if self.context_mode_var.get() == "独立对话": self.messages = [{"role": "system", "content": "You are a helpful assistant."}] self.messages.append({"role": "user", "content": user_message}) if self.stream_var.get(): threading.Thread(target=self.get_ai_reply_stream, daemon=True).start() else: threading.Thread(target=self.get_ai_reply, daemon=True).start() def add_message_to_frame(self, role, message): frame = tk.Frame(self.scrollable_frame) frame.pack(fill=tk.X, padx=5, pady=5) frame.grid_columnconfigure(1, weight=1) if role == "user": img_label = tk.Label(frame, image=self.user_img) else: img_label = tk.Label(frame, image=self.assistant_img) img_label.grid(row=0, column=0, sticky="nw", padx=(0, 5)) # 动态设置wraplength wraplength = self.get_wraplength() text_widget = tk.Label(frame, text=message, wraplength=wraplength, justify="left", anchor="w") text_widget.grid(row=0, column=1, sticky="nsew") button_frame = tk.Frame(frame) button_frame.grid(row=1, column=1, sticky="nw", pady=(5, 0)) copy_button = tk.Label(button_frame, text="复制", fg="gray", cursor="hand2") copy_button.pack(side="left", padx=(0, 5)) copy_button.bind("<Button-1>", lambda e: self.copy_to_clipboard(message, copy_button)) edit_button = tk.Label(button_frame, text="编辑", fg="gray", cursor="hand2") edit_button.pack(side="left") edit_button.bind("<Button-1>", lambda e: self.toggle_edit_mode(frame, text_widget, edit_button, role, message)) self.adjust_scroll_region() def copy_to_clipboard(self, text, copy_button): self.root.clipboard_clear() self.root.clipboard_append(text) self.root.update() # 这将更新剪贴板 # 更改按钮文字为"✔" copy_button.config(text="✔") # 1秒后恢复按钮文字为"复制" self.root.after(1000, lambda: copy_button.config(text="复制")) def toggle_edit_mode(self, frame, widget, edit_button, role, original_message): if edit_button["text"] == "编辑": # 切换到编辑模式 text_widget = tk.Text(frame, wrap=tk.WORD) # 使用之前定义的 get_text_width 方法计算字符宽度 a = self.get_text_width() text_widget.config(width=a) text_widget.insert(tk.END, widget["text"]) text_widget.grid(row=0, column=1, sticky="nsew") widget.grid_remove() edit_button.configure(text="完成") # 保存对 text_widget 的引用 frame.text_widget = text_widget # 保存原始消息内容 frame.original_message = original_message # 自动调整Text高度以适应内容 max_height = 20 # 设置最大行数为20行,可以根据需要调整 extra_lines = 10 num_lines = int(text_widget.index('end-1c').split('.')[0]) + extra_lines text_widget.configure(height=num_lines) # 创建取消按钮,并添加到edit_button后面 cancel_button = tk.Label(frame, text="取消", fg="gray", cursor="hand2") cancel_button.grid(row=0, column=2, padx=(5, 0), sticky="w") frame.cancel_button = cancel_button # 绑定取消按钮的事件 cancel_button.bind("<Button-1>", lambda e: self.cancel_edit_mode(frame, widget, edit_button)) else: # 保存编辑并切换回显示模式 new_text = frame.text_widget.get("1.0", tk.END).strip() widget.configure(text=new_text) frame.text_widget.grid_remove() widget.grid() edit_button.configure(text="编辑") frame.cancel_button.grid_remove() # 移除取消按钮 # 删除 text_widget 和 cancel_button 的引用 del frame.text_widget del frame.cancel_button # 更新消息历史,替换原始消息内容 for msg in self.messages: if msg["role"] == role and msg["content"] == original_message: msg["content"] = new_text break self.adjust_scroll_region() def cancel_edit_mode(self, frame, widget, edit_button): # 恢复到编辑前的内容 widget.configure(text=frame.original_message) frame.text_widget.grid_remove() widget.grid() edit_button.configure(text="编辑") frame.cancel_button.grid_remove() # 移除取消按钮 # 删除 text_widget 和 cancel_button 的引用 del frame.text_widget del frame.cancel_button self.adjust_scroll_region() def adjust_scroll_region(self): self.scrollable_frame.update_idletasks() bbox = self.canvas.bbox("all") # 获取画布的高度 canvas_height = self.canvas.winfo_height() # 如果内容高度小于画布高度,将内容固定在顶部 if (bbox[3] - bbox[1]) < canvas_height: self.canvas.configure(scrollregion=(bbox[0], bbox[1], bbox[2], canvas_height)) self.canvas.yview_moveto(0) else: self.canvas.configure(scrollregion=bbox) self.canvas.yview_moveto(1) def get_ai_reply_stream(self): response = self.client.chat.completions.create( model="gpt-4o-mini", messages=self.messages, stream=True ) debug_info_list = [] ai_reply = "" temp_frame = tk.Frame(self.scrollable_frame) temp_frame.pack(fill=tk.X, padx=5, pady=5) temp_frame.grid_columnconfigure(1, weight=1) img_label = tk.Label(temp_frame, image=self.assistant_img) img_label.grid(row=0, column=0, sticky="nw", padx=(0, 5)) wraplength = self.get_wraplength() text_widget = tk.Label(temp_frame, wraplength=wraplength, justify="left", anchor="w") text_widget.grid(row=0, column=1, sticky="nsew") button_frame = tk.Frame(temp_frame) button_frame.grid(row=1, column=1, sticky="nw", pady=(5, 0)) copy_button = tk.Label(button_frame, text="复制", fg="gray", cursor="hand2") copy_button.pack(side="left", padx=(0, 5)) copy_button.bind("<Button-1>", lambda e: self.copy_to_clipboard(ai_reply, copy_button)) edit_button = tk.Label(button_frame, text="编辑", fg="gray", cursor="hand2") edit_button.pack(side="left") for chunk in response: debug_info_list.append(chunk) content = chunk.choices[0].delta.content if content: ai_reply += content text_widget.config(text=ai_reply) self.adjust_scroll_region() self.root.update() edit_button.bind("<Button-1>", lambda e: self.toggle_edit_mode(temp_frame, text_widget, edit_button, "assistant", ai_reply)) self.debug_info = json.dumps([chunk.to_dict() for chunk in debug_info_list], indent=4, ensure_ascii=False) self.messages.append({"role": "assistant", "content": ai_reply}) def get_ai_reply(self): response = self.client.chat.completions.create( model="gpt-4o-mini", messages=self.messages ) ai_reply = response.choices[0].message.content # 创建新的框架来显示AI回复 temp_frame = tk.Frame(self.scrollable_frame) temp_frame.pack(fill=tk.X, padx=5, pady=5) temp_frame.grid_columnconfigure(1, weight=1) img_label = tk.Label(temp_frame, image=self.assistant_img) img_label.grid(row=0, column=0, sticky="nw", padx=(0, 5)) wraplength = self.get_wraplength() text_widget = tk.Label(temp_frame, text=ai_reply, wraplength=wraplength, justify="left", anchor="w") text_widget.grid(row=0, column=1, sticky="nsew") button_frame = tk.Frame(temp_frame) button_frame.grid(row=1, column=1, sticky="nw", pady=(5, 0)) copy_button = tk.Label(button_frame, text="复制", fg="gray", cursor="hand2") copy_button.pack(side="left", padx=(0, 5)) copy_button.bind("<Button-1>", lambda e: self.copy_to_clipboard(ai_reply, copy_button)) edit_button = tk.Label(button_frame, text="编辑", fg="gray", cursor="hand2") edit_button.pack(side="left") edit_button.bind("<Button-1>", lambda e: self.toggle_edit_mode(temp_frame, text_widget, edit_button, "assistant", ai_reply)) self.adjust_scroll_region() self.debug_info = json.dumps(response.to_dict(), indent=4, ensure_ascii=False) self.messages.append({"role": "assistant", "content": ai_reply}) def show_debug_info(self): debug_window = tk.Toplevel(self.root) debug_window.title("Debug Information") debug_text = scrolledtext.ScrolledText(debug_window, wrap=tk.WORD, width=60, height=20) debug_text.pack(padx=10, pady=10, fill=tk.BOTH, expand=True) debug_text.insert(tk.END, self.debug_info) def _on_mousewheel(self, event): # 获取当前的滚动位置 current_position = self.canvas.yview()[0] # 如果当前位置已经在顶部,并且尝试向上滚动,则不执行滚动 if (current_position <= 0 and event.delta > 0): return self.canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") def update_wraplengths(self, event=None): # 获取wraplength和Text部件宽度 wraplength = self.get_wraplength() text_width = self.get_text_width() # 更新所有Label部件的wraplength和Text部件的width for frame in self.scrollable_frame.winfo_children(): for widget in frame.winfo_children(): if isinstance(widget, tk.Label): widget.config(wraplength=wraplength) elif isinstance(widget, tk.Text): widget.config(width=text_width) def get_wraplength(self): # 获取窗口的当前宽度 window_width = self.root.winfo_width() # 假设图片宽度为30像素,设置Label的wraplength为窗口宽度减去图片宽度 return window_width - 110 # 50包含了图片宽度和一些边距 def get_text_width(self): # 获取窗口的当前宽度 window_width = self.root.winfo_width() # 假设每个字符的平均宽度为7像素,图片宽度为30像素 return (window_width - 130) // 9 # 减去图片宽度和一些边距 if __name__ == "__main__": root = tk.Tk() app = ChatApp(root) root.mainloop()
v1.0.8
import tkinter as tk from tkinter import scrolledtext from tkinter import messagebox from PIL import Image, ImageTk from openai import OpenAI import json import threading class ChatApp: def __init__(self, root): self.root = root self.root.title("Chat Application") self.root.geometry("800x600") # 配置根窗口的网格权重 self.root.grid_columnconfigure(0, weight=1) self.root.grid_rowconfigure(0, weight=1) # 创建并配置消息框架 self.message_frame = tk.Frame(self.root) self.message_frame.grid(column=0, row=0, padx=10, pady=10, sticky="nsew") self.message_frame.grid_columnconfigure(0, weight=1) self.message_frame.grid_rowconfigure(0, weight=1) # 创建并配置画布和滚动条 self.canvas = tk.Canvas(self.message_frame) self.scrollbar = tk.Scrollbar(self.message_frame, orient="vertical", command=self.canvas.yview) self.scrollable_frame = tk.Frame(self.canvas) self.scrollable_frame.bind( "<Configure>", lambda e: self.canvas.configure( scrollregion=self.canvas.bbox("all") ) ) self.canvas.create_window((0, 0), window=self.scrollable_frame, anchor="nw") self.canvas.configure(yscrollcommand=self.scrollbar.set) self.canvas.pack(side="left", fill="both", expand=True) self.scrollbar.pack(side="right", fill="y") # 绑定鼠标滚轮事件 self.canvas.bind_all("<MouseWheel>", self._on_mousewheel) # 输入框和发送按钮框架 input_frame = tk.Frame(self.root) input_frame.grid(column=0, row=1, padx=10, pady=5, sticky="ew") input_frame.grid_columnconfigure(0, weight=1) # 输入框 self.entry = tk.Entry(input_frame, width=50) self.entry.grid(column=0, row=0, padx=(0, 5), pady=5, sticky="ew") # 发送按钮 self.send_button = tk.Button(input_frame, text="发送", command=self.send_message) self.send_button.grid(column=1, row=0, padx=5, pady=5, sticky="e") # 控制按钮框架 control_frame = tk.Frame(self.root) control_frame.grid(column=0, row=2, padx=10, pady=5, sticky="ew") # 新对话按钮 self.new_chat_button = tk.Button(control_frame, text="新对话", command=self.new_chat) self.new_chat_button.grid(column=0, row=0, padx=(0, 5), pady=5, sticky="w") # Debug按钮 self.debug_button = tk.Button(control_frame, text="Debug", command=self.show_debug_info) self.debug_button.grid(column=1, row=0, padx=5, pady=5, sticky="w") # 流式输出复选框 self.stream_var = tk.BooleanVar(value=True) self.stream_checkbox = tk.Checkbutton(control_frame, text="流式输出", variable=self.stream_var) self.stream_checkbox.grid(column=2, row=0, padx=5, pady=5, sticky="w") # 模式选择下拉菜单 self.context_mode_var = tk.StringVar(value="上下文连续对话") self.context_mode_menu = tk.OptionMenu(control_frame, self.context_mode_var, "独立对话", "上下文连续对话") self.context_mode_menu.grid(column=3, row=0, padx=5, pady=5, sticky="w") # 加载用户和助手头像 self.user_img = self.load_and_resize_image("images/user_icon.png", (25, 25)) self.assistant_img = self.load_and_resize_image("images/chatgpt_icon.png", (25, 25)) # 初始化OpenAI API self.client = OpenAI( api_key="sk-azdBY7", base_url="https://api.chatanywhere.com.cn/v1/" ) self.debug_info = "" self.messages = [{"role": "system", "content": "You are a helpful assistant."}] # 绑定窗口大小变化事件以调整wraplength self.root.bind('<Configure>', self.update_wraplengths) def load_and_resize_image(self, path, size): img = Image.open(path) img = img.resize(size, Image.LANCZOS) return ImageTk.PhotoImage(img) def new_chat(self): for widget in self.scrollable_frame.winfo_children(): widget.destroy() self.messages = [{"role": "system", "content": "You are a helpful assistant."}] # 重新配置画布的滚动区域为初始状态 self.canvas.configure(scrollregion=(0, 0, 0, 0)) def send_message(self): user_message = self.entry.get() if user_message: self.add_message_to_frame("user", user_message) self.entry.delete(0, tk.END) if self.context_mode_var.get() == "独立对话": self.messages = [{"role": "system", "content": "You are a helpful assistant."}] self.messages.append({"role": "user", "content": user_message}) if self.stream_var.get(): threading.Thread(target=self.get_ai_reply_stream, daemon=True).start() else: threading.Thread(target=self.get_ai_reply, daemon=True).start() def add_message_to_frame(self, role, message): temp_frame = tk.Frame(self.scrollable_frame) temp_frame.pack(fill=tk.X, padx=5, pady=5) temp_frame.grid_columnconfigure(1, weight=1) if role == "user": img_label = tk.Label(temp_frame, image=self.user_img) else: img_label = tk.Label(temp_frame, image=self.assistant_img) img_label.grid(row=0, column=0, sticky="nw", padx=(0, 5)) wraplength = self.get_wraplength() text_widget = tk.Label(temp_frame, text=message, wraplength=wraplength, justify="left", anchor="w") text_widget.grid(row=0, column=1, sticky="nsew") button_frame = tk.Frame(temp_frame) button_frame.grid(row=1, column=1, sticky="nw", pady=(5, 0)) copy_button = tk.Label(button_frame, text="复制", fg="gray", cursor="hand2") copy_button.pack(side="left", padx=(0, 5)) copy_button.bind("<Button-1>", lambda e: self.copy_to_clipboard(message, copy_button)) edit_button = tk.Label(button_frame, text="编辑", fg="gray", cursor="hand2") edit_button.pack(side="left") edit_button.bind("<Button-1>", lambda e: self.toggle_edit_mode(temp_frame, text_widget, edit_button, role, message)) if role == "user": regenerate_button = tk.Label(button_frame, text="重新发送", fg="gray", cursor="hand2") regenerate_button.pack(side="left", padx=(5, 0)) regenerate_button.bind("<Button-1>", lambda e, m=message: self.regenerate_message(m)) # 创建取消按钮,但初始时不显示 cancel_button = tk.Label(button_frame, text="取消", fg="gray", cursor="hand2") cancel_button.pack(side="left", padx=(5, 0)) cancel_button.pack_forget() # 初始时隐藏 temp_frame.cancel_button = cancel_button self.adjust_scroll_region() return temp_frame def copy_to_clipboard(self, text, copy_button): self.root.clipboard_clear() self.root.clipboard_append(text) self.root.update() # 这将更新剪贴板 # 更改按钮文字为"✔" copy_button.config(text="✔") # 1秒后恢复按钮文字为"复制" self.root.after(1000, lambda: copy_button.config(text="复制")) def toggle_edit_mode(self, frame, widget, edit_button, role, original_message): if edit_button["text"] == "编辑": # 切换到编辑模式 text_widget = tk.Text(frame, wrap=tk.WORD) a = self.get_text_width() text_widget.config(width=a) text_widget.insert(tk.END, widget["text"]) text_widget.grid(row=0, column=1, sticky="nsew") widget.grid_remove() edit_button.configure(text="保存") frame.text_widget = text_widget frame.original_message = original_message max_height = 20 extra_lines = 10 num_lines = int(text_widget.index('end-1c').split('.')[0]) + extra_lines text_widget.configure(height=num_lines) frame.cancel_button.pack() frame.cancel_button.bind("<Button-1>", lambda e: self.cancel_edit_mode(frame, widget, edit_button)) else: # 保存编辑并切换回显示模式 new_text = frame.text_widget.get("1.0", tk.END).strip() widget.configure(text=new_text) frame.text_widget.grid_remove() widget.grid() edit_button.configure(text="编辑") frame.cancel_button.pack_forget() del frame.text_widget # 更新消息历史,替换原始消息内容 for msg in self.messages: if msg["role"] == role and msg["content"] == original_message: msg["content"] = new_text break # 更新frame的original_message frame.original_message = new_text # 如果是用户消息,更新"重新发送"按钮的绑定 if role == "user": for child in frame.winfo_children(): if isinstance(child, tk.Frame): # 这应该是button_frame for button in child.winfo_children(): if button["text"] == "重新发送": button.bind("<Button-1>", lambda e, m=new_text: self.regenerate_message(m)) break break self.adjust_scroll_region() def cancel_edit_mode(self, frame, widget, edit_button): # 恢复到编辑前的内容 widget.configure(text=frame.original_message) frame.text_widget.grid_remove() widget.grid() edit_button.configure(text="编辑") frame.cancel_button.pack_forget() # 隐藏取消按钮 # 删除 text_widget 的引用 del frame.text_widget self.adjust_scroll_region() def adjust_scroll_region(self): self.scrollable_frame.update_idletasks() bbox = self.canvas.bbox("all") # 获取画布的高度 canvas_height = self.canvas.winfo_height() # 如果内容高度小于画布高度,将内容固定在顶部 if (bbox[3] - bbox[1]) < canvas_height: self.canvas.configure(scrollregion=(bbox[0], bbox[1], bbox[2], canvas_height)) self.canvas.yview_moveto(0) else: self.canvas.configure(scrollregion=bbox) self.canvas.yview_moveto(1) def get_ai_reply_stream(self): response = self.client.chat.completions.create( model="gpt-4o-mini", messages=self.messages, stream=True ) debug_info_list = [] ai_reply = "" temp_frame = tk.Frame(self.scrollable_frame) temp_frame.pack(fill=tk.X, padx=5, pady=5) temp_frame.grid_columnconfigure(1, weight=1) img_label = tk.Label(temp_frame, image=self.assistant_img) img_label.grid(row=0, column=0, sticky="nw", padx=(0, 5)) wraplength = self.get_wraplength() text_widget = tk.Label(temp_frame, wraplength=wraplength, justify="left", anchor="w") text_widget.grid(row=0, column=1, sticky="nsew") button_frame = tk.Frame(temp_frame) button_frame.grid(row=1, column=1, sticky="nw", pady=(5, 0)) copy_button = tk.Label(button_frame, text="复制", fg="gray", cursor="hand2") copy_button.pack(side="left", padx=(0, 5)) copy_button.bind("<Button-1>", lambda e: self.copy_to_clipboard(ai_reply, copy_button)) edit_button = tk.Label(button_frame, text="编辑", fg="gray", cursor="hand2") edit_button.pack(side="left") # 创建取消按钮,但初始时不显示 cancel_button = tk.Label(button_frame, text="取消", fg="gray", cursor="hand2") cancel_button.pack(side="left", padx=(5, 0)) cancel_button.pack_forget() # 初始时隐藏 temp_frame.cancel_button = cancel_button for chunk in response: debug_info_list.append(chunk) content = chunk.choices[0].delta.content if content: ai_reply += content text_widget.config(text=ai_reply) self.adjust_scroll_region() self.root.update() edit_button.bind("<Button-1>", lambda e: self.toggle_edit_mode(temp_frame, text_widget, edit_button, "assistant", ai_reply)) self.debug_info = json.dumps([chunk.to_dict() for chunk in debug_info_list], indent=4, ensure_ascii=False) self.messages.append({"role": "assistant", "content": ai_reply}) def get_ai_reply(self): response = self.client.chat.completions.create( model="gpt-4o-mini", messages=self.messages ) ai_reply = response.choices[0].message.content # 创建新的框架来显示AI回复 temp_frame = tk.Frame(self.scrollable_frame) temp_frame.pack(fill=tk.X, padx=5, pady=5) temp_frame.grid_columnconfigure(1, weight=1) img_label = tk.Label(temp_frame, image=self.assistant_img) img_label.grid(row=0, column=0, sticky="nw", padx=(0, 5)) wraplength = self.get_wraplength() text_widget = tk.Label(temp_frame, text=ai_reply, wraplength=wraplength, justify="left", anchor="w") text_widget.grid(row=0, column=1, sticky="nsew") button_frame = tk.Frame(temp_frame) button_frame.grid(row=1, column=1, sticky="nw", pady=(5, 0)) copy_button = tk.Label(button_frame, text="复制", fg="gray", cursor="hand2") copy_button.pack(side="left", padx=(0, 5)) copy_button.bind("<Button-1>", lambda e: self.copy_to_clipboard(ai_reply, copy_button)) edit_button = tk.Label(button_frame, text="编辑", fg="gray", cursor="hand2") edit_button.pack(side="left") edit_button.bind("<Button-1>", lambda e: self.toggle_edit_mode(temp_frame, text_widget, edit_button, "assistant", ai_reply)) # 创建取消按钮,但初始时不显示 cancel_button = tk.Label(button_frame, text="取消", fg="gray", cursor="hand2") cancel_button.pack(side="left", padx=(5, 0)) cancel_button.pack_forget() # 初始时隐藏 temp_frame.cancel_button = cancel_button self.adjust_scroll_region() self.debug_info = json.dumps(response.to_dict(), indent=4, ensure_ascii=False) self.messages.append({"role": "assistant", "content": ai_reply}) def show_debug_info(self): debug_window = tk.Toplevel(self.root) debug_window.title("Debug Information") debug_text = scrolledtext.ScrolledText(debug_window, wrap=tk.WORD, width=60, height=20) debug_text.pack(padx=10, pady=10, fill=tk.BOTH, expand=True) debug_text.insert(tk.END, self.debug_info) def _on_mousewheel(self, event): # 获取当前的滚动位置 current_position = self.canvas.yview()[0] # 如果当前位置已经在顶部,并且尝试向上滚动,则不执行滚动 if (current_position <= 0 and event.delta > 0): return self.canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") def update_wraplengths(self, event=None): # 获取wraplength和Text部件宽度 wraplength = self.get_wraplength() text_width = self.get_text_width() # 更新所有Label部件的wraplength和Text部件的width for frame in self.scrollable_frame.winfo_children(): for widget in frame.winfo_children(): if isinstance(widget, tk.Label): widget.config(wraplength=wraplength) elif isinstance(widget, tk.Text): widget.config(width=text_width) def get_wraplength(self): # 获取窗口的当前宽度 window_width = self.root.winfo_width() # 假设图片宽度为30像素,设置Label的wraplength为窗口宽度减去图片宽度 return window_width - 110 # 50包含了图片宽度和一些边距 def get_text_width(self): # 获取窗口的当前宽度 window_width = self.root.winfo_width() # 假设每个字符的平均宽度为7像素,图片宽度为30像素 return (window_width - 130) // 9 # 减去图片宽度和一些边距 def regenerate_message(self, message): # 删除AI回复和对应的用户消息 user_frame = None for i in range(len(self.scrollable_frame.winfo_children()) - 1, -1, -1): frame = self.scrollable_frame.winfo_children()[i] if frame.winfo_children()[1]["text"] == message: user_frame = frame # 删除AI回复 if i + 1 < len(self.scrollable_frame.winfo_children()): self.scrollable_frame.winfo_children()[i + 1].destroy() break # 从消息历史中删除对应的消息 for i in range(len(self.messages) - 1, -1, -1): if self.messages[i]["role"] == "user" and self.messages[i]["content"] == message: del self.messages[i:i + 2] break # 重新发送消息 self.messages.append({"role": "user", "content": message}) if self.stream_var.get(): threading.Thread(target=self.get_ai_reply_stream, daemon=True).start() else: threading.Thread(target=self.get_ai_reply, daemon=True).start() # 更新用户消息框架中的"重新发送"按钮绑定 if user_frame: for child in user_frame.winfo_children(): if isinstance(child, tk.Frame): # 这应该是button_frame for button in child.winfo_children(): if button["text"] == "重新发送": button.bind("<Button-1>", lambda e, m=message: self.regenerate_message(m)) break break if __name__ == "__main__": root = tk.Tk() app = ChatApp(root) root.mainloop()
v1.0.9
import tkinter as tk from tkinter import scrolledtext from tkinter import messagebox from PIL import Image, ImageTk from openai import OpenAI import json import threading class ChatApp: def __init__(self, root): self.root = root self.root.title("Chat Application") self.root.geometry("800x600") # 配置根窗口的网格权重 self.root.grid_columnconfigure(0, weight=1) self.root.grid_rowconfigure(0, weight=1) # 创建并配置消息框架 self.message_frame = tk.Frame(self.root) self.message_frame.grid(column=0, row=0, padx=10, pady=10, sticky="nsew") self.message_frame.grid_columnconfigure(0, weight=1) self.message_frame.grid_rowconfigure(0, weight=1) # 创建并配置画布和滚动条 self.canvas = tk.Canvas(self.message_frame) self.scrollbar = tk.Scrollbar(self.message_frame, orient="vertical", command=self.canvas.yview) self.scrollable_frame = tk.Frame(self.canvas) self.scrollable_frame.bind( "<Configure>", lambda e: self.canvas.configure( scrollregion=self.canvas.bbox("all") ) ) self.canvas.create_window((0, 0), window=self.scrollable_frame, anchor="nw") self.canvas.configure(yscrollcommand=self.scrollbar.set) self.canvas.pack(side="left", fill="both", expand=True) self.scrollbar.pack(side="right", fill="y") # 绑定鼠标滚轮事件 self.canvas.bind_all("<MouseWheel>", self._on_mousewheel) # 输入框和发送按钮框架 input_frame = tk.Frame(self.root) input_frame.grid(column=0, row=1, padx=10, pady=5, sticky="ew") input_frame.grid_columnconfigure(0, weight=1) # Stop按钮 self.stop_button = tk.Button(input_frame, text="Stop", bg="lightcoral", command=self.stop_reply) self.stop_button.grid(column=0, row=0, padx=(0, 5), pady=5) self.stop_button.config(width=8) # 设置宽度为100像素 self.stop_button.grid_remove() # 初始时隐藏 # 输入框 self.entry = tk.Entry(input_frame, width=50) self.entry.grid(column=0, row=1, padx=(0, 5), pady=5, sticky="ew") # 发送按钮 self.send_button = tk.Button(input_frame, text="发送", command=self.send_message) self.send_button.grid(column=1, row=1, padx=5, pady=5, sticky="e") # 控制按钮框架 control_frame = tk.Frame(self.root) control_frame.grid(column=0, row=2, padx=10, pady=5, sticky="ew") # 新对话按钮 self.new_chat_button = tk.Button(control_frame, text="新对话", command=self.new_chat) self.new_chat_button.grid(column=0, row=0, padx=(0, 5), pady=5, sticky="w") # Debug按钮 self.debug_button = tk.Button(control_frame, text="Debug", command=self.show_debug_info) self.debug_button.grid(column=1, row=0, padx=5, pady=5, sticky="w") # 流式输出复选框 self.stream_var = tk.BooleanVar(value=True) self.stream_checkbox = tk.Checkbutton(control_frame, text="流式输出", variable=self.stream_var) self.stream_checkbox.grid(column=2, row=0, padx=5, pady=5, sticky="w") # 模式选择下拉菜单 self.context_mode_var = tk.StringVar(value="上下文连续对话") self.context_mode_menu = tk.OptionMenu(control_frame, self.context_mode_var, "独立对话", "上下文连续对话") self.context_mode_menu.grid(column=3, row=0, padx=5, pady=5, sticky="w") # 加载用户和助手头像 self.user_img = self.load_and_resize_image("images/user_icon.png", (25, 25)) self.assistant_img = self.load_and_resize_image("images/chatgpt_icon.png", (25, 25)) # 初始化OpenAI API self.client = OpenAI( api_key="sk-azd0QbjHBY7", base_url="https://api.chatanywhere.com.cn/v1/" ) self.debug_info = "" self.messages = [{"role": "system", "content": "You are a helpful assistant."}] self.stop_streaming = False # 增加一个标志位来控制流式输出的停止 # 绑定窗口大小变化事件以调整wraplength self.root.bind('<Configure>', self.update_wraplengths) def load_and_resize_image(self, path, size): img = Image.open(path) img = img.resize(size, Image.LANCZOS) return ImageTk.PhotoImage(img) def new_chat(self): for widget in self.scrollable_frame.winfo_children(): widget.destroy() self.messages = [{"role": "system", "content": "You are a helpful assistant."}] self.canvas.configure(scrollregion=(0, 0, 0, 0)) def send_message(self): user_message = self.entry.get() if user_message: self.add_message_to_frame("user", user_message) self.entry.delete(0, tk.END) if self.context_mode_var.get() == "独立对话": self.messages = [{"role": "system", "content": "You are a helpful assistant."}] self.messages.append({"role": "user", "content": user_message}) if self.stream_var.get(): self.stop_button.grid() self.stop_streaming = False threading.Thread(target=self.get_ai_reply_stream, daemon=True).start() else: threading.Thread(target=self.get_ai_reply, daemon=True).start() def add_message_to_frame(self, role, message): temp_frame = tk.Frame(self.scrollable_frame) temp_frame.pack(fill=tk.X, padx=5, pady=5) temp_frame.grid_columnconfigure(1, weight=1) if role == "user": img_label = tk.Label(temp_frame, image=self.user_img) else: img_label = tk.Label(temp_frame, image=self.assistant_img) img_label.grid(row=0, column=0, sticky="nw", padx=(0, 5)) wraplength = self.get_wraplength() text_widget = tk.Label(temp_frame, text=message, wraplength=wraplength, justify="left", anchor="w") text_widget.grid(row=0, column=1, sticky="nsew") button_frame = tk.Frame(temp_frame) button_frame.grid(row=1, column=1, sticky="nw", pady=(5, 0)) copy_button = tk.Label(button_frame, text="复制", fg="gray", cursor="hand2") copy_button.pack(side="left", padx=(0, 5)) copy_button.bind("<Button-1>", lambda e: self.copy_to_clipboard(message, copy_button)) edit_button = tk.Label(button_frame, text="编辑", fg="gray", cursor="hand2") edit_button.pack(side="left") edit_button.bind("<Button-1>", lambda e: self.toggle_edit_mode(temp_frame, text_widget, edit_button, role, message)) if role == "user": regenerate_button = tk.Label(button_frame, text="重新发送", fg="gray", cursor="hand2") regenerate_button.pack(side="left", padx=(5, 0)) regenerate_button.bind("<Button-1>", lambda e, m=message: self.regenerate_message(m)) # 创建取消按钮,但初始时不显示 cancel_button = tk.Label(button_frame, text="取消", fg="gray", cursor="hand2") cancel_button.pack(side="left", padx=(5, 0)) cancel_button.pack_forget() # 初始时隐藏 temp_frame.cancel_button = cancel_button self.adjust_scroll_region() return temp_frame def copy_to_clipboard(self, text, copy_button): self.root.clipboard_clear() self.root.clipboard_append(text) self.root.update() # 这将更新剪贴板 copy_button.config(text="✔") self.root.after(1000, lambda: copy_button.config(text="复制")) def toggle_edit_mode(self, frame, widget, edit_button, role, original_message): if edit_button["text"] == "编辑": # 切换到编辑模式 text_widget = tk.Text(frame, wrap=tk.WORD) a = self.get_text_width() text_widget.config(width=a) text_widget.insert(tk.END, widget["text"]) text_widget.grid(row=0, column=1, sticky="nsew") widget.grid_remove() edit_button.configure(text="保存") frame.text_widget = text_widget frame.original_message = original_message max_height = 20 extra_lines = 7 num_lines = int(text_widget.index('end-1c').split('.')[0]) + extra_lines text_widget.configure(height=num_lines) frame.cancel_button.pack() frame.cancel_button.bind("<Button-1>", lambda e: self.cancel_edit_mode(frame, widget, edit_button)) else: # 保存编辑并切换回显示模式 new_text = frame.text_widget.get("1.0", tk.END).strip() widget.configure(text=new_text) frame.text_widget.grid_remove() widget.grid() edit_button.configure(text="编辑") frame.cancel_button.pack_forget() del frame.text_widget # 更新消息历史,替换原始消息内容 for msg in self.messages: if msg["role"] == role and msg["content"] == original_message: msg["content"] = new_text break frame.original_message = new_text if role == "user": for child in frame.winfo_children(): if isinstance(child, tk.Frame): for button in child.winfo_children(): if button["text"] == "重新发送": button.bind("<Button-1>", lambda e, m=new_text: self.regenerate_message(m)) break break self.adjust_scroll_region() def cancel_edit_mode(self, frame, widget, edit_button): widget.configure(text=frame.original_message) frame.text_widget.grid_remove() widget.grid() edit_button.configure(text="编辑") frame.cancel_button.pack_forget() del frame.text_widget self.adjust_scroll_region() def adjust_scroll_region(self): self.scrollable_frame.update_idletasks() bbox = self.canvas.bbox("all") canvas_height = self.canvas.winfo_height() if (bbox[3] - bbox[1]) < canvas_height: self.canvas.configure(scrollregion=(bbox[0], bbox[1], bbox[2], canvas_height)) self.canvas.yview_moveto(0) else: self.canvas.configure(scrollregion=bbox) self.canvas.yview_moveto(1) def get_ai_reply_stream(self): response = self.client.chat.completions.create( model="gpt-4o-mini", messages=self.messages, stream=True ) debug_info_list = [] ai_reply = "" temp_frame = tk.Frame(self.scrollable_frame) temp_frame.pack(fill=tk.X, padx=5, pady=5) temp_frame.grid_columnconfigure(1, weight=1) img_label = tk.Label(temp_frame, image=self.assistant_img) img_label.grid(row=0, column=0, sticky="nw", padx=(0, 5)) wraplength = self.get_wraplength() text_widget = tk.Label(temp_frame, wraplength=wraplength, justify="left", anchor="w") text_widget.grid(row=0, column=1, sticky="nsew") button_frame = tk.Frame(temp_frame) button_frame.grid(row=1, column=1, sticky="nw", pady=(5, 0)) copy_button = tk.Label(button_frame, text="复制", fg="gray", cursor="hand2") copy_button.pack(side="left", padx=(0, 5)) copy_button.bind("<Button-1>", lambda e: self.copy_to_clipboard(ai_reply, copy_button)) edit_button = tk.Label(button_frame, text="编辑", fg="gray", cursor="hand2") edit_button.pack(side="left") cancel_button = tk.Label(button_frame, text="取消", fg="gray", cursor="hand2") cancel_button.pack(side="left", padx=(5, 0)) cancel_button.pack_forget() temp_frame.cancel_button = cancel_button for chunk in response: if self.stop_streaming: break debug_info_list.append(chunk) content = chunk.choices[0].delta.content if content: ai_reply += content text_widget.config(text=ai_reply) self.adjust_scroll_region() self.root.update() self.stop_button.grid_remove() edit_button.bind("<Button-1>", lambda e: self.toggle_edit_mode(temp_frame, text_widget, edit_button, "assistant", ai_reply)) self.debug_info = json.dumps([chunk.to_dict() for chunk in debug_info_list], indent=4, ensure_ascii=False) # 只保存实际显示的内容 self.messages.append({"role": "assistant", "content": ai_reply}) def get_ai_reply(self): response = self.client.chat.completions.create( model="gpt-4o-mini", messages=self.messages ) ai_reply = response.choices[0].message.content # 创建新的框架来显示AI回复 temp_frame = tk.Frame(self.scrollable_frame) temp_frame.pack(fill=tk.X, padx=5, pady=5) temp_frame.grid_columnconfigure(1, weight=1) img_label = tk.Label(temp_frame, image=self.assistant_img) img_label.grid(row=0, column=0, sticky="nw", padx=(0, 5)) wraplength = self.get_wraplength() text_widget = tk.Label(temp_frame, text=ai_reply, wraplength=wraplength, justify="left", anchor="w") text_widget.grid(row=0, column=1, sticky="nsew") button_frame = tk.Frame(temp_frame) button_frame.grid(row=1, column=1, sticky="nw", pady=(5, 0)) copy_button = tk.Label(button_frame, text="复制", fg="gray", cursor="hand2") copy_button.pack(side="left", padx=(0, 5)) copy_button.bind("<Button-1>", lambda e: self.copy_to_clipboard(ai_reply, copy_button)) edit_button = tk.Label(button_frame, text="编辑", fg="gray", cursor="hand2") edit_button.pack(side="left") edit_button.bind("<Button-1>", lambda e: self.toggle_edit_mode(temp_frame, text_widget, edit_button, "assistant", ai_reply)) # 创建取消按钮,但初始时不显示 cancel_button = tk.Label(button_frame, text="取消", fg="gray", cursor="hand2") cancel_button.pack(side="left", padx=(5, 0)) cancel_button.pack_forget() # 初始时隐藏 temp_frame.cancel_button = cancel_button self.adjust_scroll_region() self.debug_info = json.dumps(response.to_dict(), indent=4, ensure_ascii=False) self.messages.append({"role": "assistant", "content": ai_reply}) def stop_reply(self): self.stop_streaming = True self.stop_button.grid_remove() def show_debug_info(self): debug_window = tk.Toplevel(self.root) debug_window.title("Debug Information") debug_text = scrolledtext.ScrolledText(debug_window, wrap=tk.WORD, width=60, height=20) debug_text.pack(padx=10, pady=10, fill=tk.BOTH, expand=True) debug_text.insert(tk.END, self.debug_info) def _on_mousewheel(self, event): current_position = self.canvas.yview()[0] if (current_position <= 0 and event.delta > 0): return self.canvas.yview_scroll(int(-1 * (event.delta / 120)), "units") def update_wraplengths(self, event=None): wraplength = self.get_wraplength() text_width = self.get_text_width() for frame in self.scrollable_frame.winfo_children(): for widget in frame.winfo_children(): if isinstance(widget, tk.Label): widget.config(wraplength=wraplength) elif isinstance(widget, tk.Text): widget.config(width=text_width) def get_wraplength(self): window_width = self.root.winfo_width() return window_width - 110 def get_text_width(self): window_width = self.root.winfo_width() return (window_width - 130) // 9 def regenerate_message(self, message): user_frame = None for i in range(len(self.scrollable_frame.winfo_children()) - 1, -1, -1): frame = self.scrollable_frame.winfo_children()[i] if frame.winfo_children()[1]["text"] == message: user_frame = frame if i + 1 < len(self.scrollable_frame.winfo_children()): self.scrollable_frame.winfo_children()[i + 1].destroy() break for i in range(len(self.messages) - 1, -1, -1): if self.messages[i]["role"] == "user" and self.messages[i]["content"] == message: del self.messages[i:i + 2] break self.messages.append({"role": "user", "content": message}) if self.stream_var.get(): self.stop_button.grid() # 显示Stop按钮 self.stop_streaming = False threading.Thread(target=self.get_ai_reply_stream, daemon=True).start() else: threading.Thread(target=self.get_ai_reply, daemon=True).start() if user_frame: for child in user_frame.winfo_children(): if isinstance(child, tk.Frame): for button in child.winfo_children(): if button["text"] == "重新发送": button.bind("<Button-1>", lambda e, m=message: self.regenerate_message(m)) break break if __name__ == "__main__": root = tk.Tk() app = ChatApp(root) root.mainloop()