|
| 1 | +import sys |
| 2 | +import os |
| 3 | +import json |
| 4 | +import tkinter as tk |
| 5 | +from tkinter import ttk, messagebox, simpledialog |
| 6 | +import sv_ttk |
| 7 | + |
| 8 | +# ========================= |
| 9 | +# Helpers |
| 10 | +# ========================= |
| 11 | +def resource_path(file_name): |
| 12 | + base_path = getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__))) |
| 13 | + return os.path.join(base_path, file_name) |
| 14 | + |
| 15 | +QA_FILE = "qa_data.json" |
| 16 | + |
| 17 | +def load_qa(): |
| 18 | + if os.path.exists(QA_FILE): |
| 19 | + with open(QA_FILE, "r", encoding="utf-8") as f: |
| 20 | + return json.load(f) |
| 21 | + else: |
| 22 | + return [ |
| 23 | + {"condo": "Condo A", "category": "Sell", "question": "Sell Condo", "response": "We can assist with selling Condo A."}, |
| 24 | + {"condo": "Condo A", "category": "Rent", "question": "Rent Condo", "response": "We can list Condo A for rent."}, |
| 25 | + {"condo": "Condo B", "category": "Price", "question": "Price", "response": "Condo B prices vary by floor and size."}, |
| 26 | + {"condo": "Condo B", "category": "Service Fee", "question": "Service Fee", "response": "Service fee depends on condo type."}, |
| 27 | + {"condo": "Condo C", "category": "Facilities", "question": "Facilities", "response": "Condo C has gym, pool, and security."}, |
| 28 | + ] |
| 29 | + |
| 30 | +def save_qa(): |
| 31 | + with open(QA_FILE, "w", encoding="utf-8") as f: |
| 32 | + json.dump(qa_data, f, ensure_ascii=False, indent=4) |
| 33 | + |
| 34 | +def set_status(msg): |
| 35 | + status_var.set(msg) |
| 36 | + root.update_idletasks() |
| 37 | + |
| 38 | +# ========================= |
| 39 | +# App Setup |
| 40 | +# ========================= |
| 41 | +root = tk.Tk() |
| 42 | +root.title("💬 Condo Assistant Bot") |
| 43 | +root.geometry("1180x650") |
| 44 | +sv_ttk.set_theme("light") |
| 45 | + |
| 46 | +# ========================= |
| 47 | +# Globals |
| 48 | +# ========================= |
| 49 | +qa_data = load_qa() |
| 50 | +dark_mode_var = tk.BooleanVar(value=False) |
| 51 | + |
| 52 | +# ========================= |
| 53 | +# Helper Functions |
| 54 | +# ========================= |
| 55 | +def get_condos(): return sorted(list({qa["condo"] for qa in qa_data})) |
| 56 | +def get_categories(condo): return sorted(list({qa["category"] for qa in qa_data if qa["condo"] == condo})) |
| 57 | +def get_questions(condo, category): return [qa["question"] for qa in qa_data if qa["condo"] == condo and qa["category"] == category] |
| 58 | +def get_response(condo, category, question): |
| 59 | + for qa in qa_data: |
| 60 | + if qa["condo"] == condo and qa["category"] == category and qa["question"] == question: |
| 61 | + return qa["response"] |
| 62 | + return "No response found." |
| 63 | + |
| 64 | +# ========================= |
| 65 | +# Chat Functions with Bubble Styling |
| 66 | +# ========================= |
| 67 | +def insert_bubble(message, sender="user"): |
| 68 | + chat_text.config(state="normal") |
| 69 | + if sender == "user": |
| 70 | + chat_text.insert(tk.END, f" {message} \n\n", "user_bubble") |
| 71 | + else: |
| 72 | + chat_text.insert(tk.END, f" {message} \n\n", "bot_bubble") |
| 73 | + chat_text.see(tk.END) |
| 74 | + chat_text.config(state="disabled") |
| 75 | + |
| 76 | +def update_category_dropdown(event=None): |
| 77 | + selected_condo = condo_combo.get() |
| 78 | + categories = get_categories(selected_condo) |
| 79 | + category_combo['values'] = categories |
| 80 | + if categories: |
| 81 | + category_combo.current(0) |
| 82 | + update_question_dropdown() |
| 83 | + |
| 84 | +def update_question_dropdown(event=None): |
| 85 | + selected_condo = condo_combo.get() |
| 86 | + selected_category = category_combo.get() |
| 87 | + questions = get_questions(selected_condo, selected_category) |
| 88 | + question_combo['values'] = questions |
| 89 | + if questions: |
| 90 | + question_combo.current(0) |
| 91 | + |
| 92 | +def send_message(): |
| 93 | + condo = condo_combo.get() |
| 94 | + category = category_combo.get() |
| 95 | + question = question_combo.get() |
| 96 | + if not (condo and category and question): |
| 97 | + messagebox.showwarning("Incomplete Selection", "Please select condo, category, and question.") |
| 98 | + return |
| 99 | + response = get_response(condo, category, question) |
| 100 | + insert_bubble(f"You: {condo} | {category} | {question}", "user") |
| 101 | + insert_bubble(f"Bot: {response}", "bot") |
| 102 | + set_status(f"Responded to: {question}") |
| 103 | + |
| 104 | +# ========================= |
| 105 | +# Admin Functions with Top Add Section |
| 106 | +# ========================= |
| 107 | +def manage_qa_ui(): |
| 108 | + admin_win = tk.Toplevel(root) |
| 109 | + admin_win.title("Manage Q&A") |
| 110 | + admin_win.geometry("1050x550") |
| 111 | + admin_win.resizable(False, False) |
| 112 | + |
| 113 | + # Make it modal |
| 114 | + admin_win.transient(root) # Keep above main window |
| 115 | + admin_win.grab_set() # Block interaction with main window |
| 116 | + |
| 117 | + # Add New Q&A Above Tree |
| 118 | + add_frame = ttk.LabelFrame(admin_win, text="➕ Add New Q&A", padding=10) |
| 119 | + add_frame.pack(fill="x", padx=10, pady=5) |
| 120 | + |
| 121 | + ttk.Label(add_frame, text="Condo:").grid(row=0, column=0, padx=5, pady=2) |
| 122 | + new_condo = tk.Entry(add_frame, width=15) |
| 123 | + new_condo.grid(row=0, column=1, padx=5) |
| 124 | + |
| 125 | + ttk.Label(add_frame, text="Category:").grid(row=0, column=2, padx=5, pady=2) |
| 126 | + new_category = tk.Entry(add_frame, width=15) |
| 127 | + new_category.grid(row=0, column=3, padx=5) |
| 128 | + |
| 129 | + ttk.Label(add_frame, text="Question:").grid(row=0, column=4, padx=5, pady=2) |
| 130 | + new_question = tk.Entry(add_frame, width=25) |
| 131 | + new_question.grid(row=0, column=5, padx=5) |
| 132 | + |
| 133 | + ttk.Label(add_frame, text="Response:").grid(row=0, column=6, padx=5, pady=2) |
| 134 | + new_response = tk.Entry(add_frame, width=35) |
| 135 | + new_response.grid(row=0, column=7, padx=5) |
| 136 | + |
| 137 | + def add_new_qa(): |
| 138 | + condo_val = new_condo.get().strip() |
| 139 | + category_val = new_category.get().strip() |
| 140 | + question_val = new_question.get().strip() |
| 141 | + response_val = new_response.get().strip() |
| 142 | + if condo_val and category_val and question_val and response_val: |
| 143 | + qa_data.append({"condo": condo_val, "category": category_val, "question": question_val, "response": response_val}) |
| 144 | + save_qa() |
| 145 | + refresh_tree() |
| 146 | + new_condo.delete(0, tk.END) |
| 147 | + new_category.delete(0, tk.END) |
| 148 | + new_question.delete(0, tk.END) |
| 149 | + new_response.delete(0, tk.END) |
| 150 | + set_status(f"Added Q&A: {question_val}") |
| 151 | + else: |
| 152 | + messagebox.showwarning("Incomplete", "Fill all fields to add Q&A.") |
| 153 | + |
| 154 | + ttk.Button(add_frame, text="Add Q&A", command=add_new_qa).grid(row=0, column=8, padx=5) |
| 155 | + |
| 156 | + # Treeview Section |
| 157 | + tree_frame = ttk.Frame(admin_win) |
| 158 | + tree_frame.pack(expand=True, fill="both", padx=10, pady=10) |
| 159 | + columns = ("condo", "category", "question", "response") |
| 160 | + global tree |
| 161 | + tree = ttk.Treeview(tree_frame, columns=columns, show="headings") |
| 162 | + for col in columns: |
| 163 | + tree.heading(col, text=col) |
| 164 | + tree.column(col, width=150 if col != "response" else 350) |
| 165 | + tree.pack(expand=True, fill="both", side="left") |
| 166 | + scrollbar = ttk.Scrollbar(tree_frame, orient="vertical", command=tree.yview) |
| 167 | + scrollbar.pack(side="right", fill="y") |
| 168 | + tree.configure(yscrollcommand=scrollbar.set) |
| 169 | + |
| 170 | + def refresh_tree(): |
| 171 | + tree.delete(*tree.get_children()) |
| 172 | + for qa in qa_data: |
| 173 | + tree.insert("", "end", values=(qa["condo"], qa["category"], qa["question"], qa["response"])) |
| 174 | + condo_combo['values'] = get_condos() |
| 175 | + update_category_dropdown() |
| 176 | + save_qa() |
| 177 | + |
| 178 | + def update_selected_qa(): |
| 179 | + selected = tree.selection() |
| 180 | + if not selected: |
| 181 | + messagebox.showwarning("No Selection", "Select a Q&A to update.") |
| 182 | + return |
| 183 | + idx = tree.index(selected[0]) |
| 184 | + qa = qa_data[idx] |
| 185 | + |
| 186 | + condo_val = simpledialog.askstring("Update Q&A", "Condo:", initialvalue=qa["condo"]) |
| 187 | + category_val = simpledialog.askstring("Update Q&A", "Category:", initialvalue=qa["category"]) |
| 188 | + question_val = simpledialog.askstring("Update Q&A", "Question:", initialvalue=qa["question"]) |
| 189 | + response_val = simpledialog.askstring("Update Q&A", "Response:", initialvalue=qa["response"]) |
| 190 | + |
| 191 | + if condo_val and category_val and question_val and response_val: |
| 192 | + qa_data[idx] = {"condo": condo_val, "category": category_val, "question": question_val, "response": response_val} |
| 193 | + save_qa() |
| 194 | + refresh_tree() |
| 195 | + set_status(f"Updated Q&A: {question_val}") |
| 196 | + |
| 197 | + ttk.Button(admin_win, text="✏️ Update Selected Q&A", command=update_selected_qa).pack(pady=5) |
| 198 | + |
| 199 | + refresh_tree() |
| 200 | + |
| 201 | +# ========================= |
| 202 | +# Styles |
| 203 | +# ========================= |
| 204 | +style = ttk.Style() |
| 205 | +style.theme_use("clam") |
| 206 | +style.configure("TButton", font=("Segoe UI", 11, "bold"), padding=6) |
| 207 | +style.configure("Ask.TButton", foreground="white", background="#2196F3") |
| 208 | +style.configure("Update.TButton", foreground="white", background="#FF9800") |
| 209 | + |
| 210 | +# Bubble tags |
| 211 | +chat_text = tk.Text(root, wrap="word", state="disabled", font=("Segoe UI", 12), bg="#f5f5f5") |
| 212 | +chat_text.tag_configure("user_bubble", background="#DCF8C6", foreground="black", spacing3=5, lmargin1=30, rmargin=5) |
| 213 | +chat_text.tag_configure("bot_bubble", background="#FFFFFF", foreground="black", spacing3=5, lmargin1=5, rmargin=30) |
| 214 | + |
| 215 | +# ========================= |
| 216 | +# Main UI |
| 217 | +# ========================= |
| 218 | +main_frame = ttk.Frame(root, padding=20) |
| 219 | +main_frame.pack(expand=True, fill="both") |
| 220 | + |
| 221 | +ttk.Label(main_frame, text="💬 Condo Assistant Bot", |
| 222 | + font=("Segoe UI", 22, "bold")).pack(pady=(0,5)) |
| 223 | +ttk.Label(main_frame, text="Select Condo, Category, and Question to ask", |
| 224 | + font=("Segoe UI", 12)).pack(pady=(0,10)) |
| 225 | + |
| 226 | +# Input Frame |
| 227 | +input_frame = ttk.LabelFrame(main_frame, text="Ask a Question", padding=10) |
| 228 | +input_frame.pack(fill="x", pady=8) |
| 229 | + |
| 230 | +ttk.Label(input_frame, text="Condo:").grid(row=0, column=0, padx=5, pady=2) |
| 231 | +condo_combo = ttk.Combobox(input_frame, state="readonly", width=20) |
| 232 | +condo_combo.grid(row=0, column=1, padx=5) |
| 233 | +condo_combo['values'] = get_condos() |
| 234 | +if condo_combo['values']: |
| 235 | + condo_combo.current(0) |
| 236 | + |
| 237 | +ttk.Label(input_frame, text="Category:").grid(row=0, column=2, padx=5) |
| 238 | +category_combo = ttk.Combobox(input_frame, state="readonly", width=20) |
| 239 | +category_combo.grid(row=0, column=3, padx=5) |
| 240 | + |
| 241 | +ttk.Label(input_frame, text="Question:").grid(row=0, column=4, padx=5) |
| 242 | +question_combo = ttk.Combobox(input_frame, state="readonly", width=30) |
| 243 | +question_combo.grid(row=0, column=5, padx=5) |
| 244 | + |
| 245 | +condo_combo.bind("<<ComboboxSelected>>", update_category_dropdown) |
| 246 | +category_combo.bind("<<ComboboxSelected>>", update_question_dropdown) |
| 247 | +update_category_dropdown() |
| 248 | +update_question_dropdown() |
| 249 | + |
| 250 | +ttk.Button(input_frame, text="💬 Ask", command=send_message, style="Ask.TButton").grid(row=0, column=6, padx=5) |
| 251 | + |
| 252 | +# Options |
| 253 | +options_frame = ttk.LabelFrame(main_frame, text="Options", padding=10) |
| 254 | +options_frame.pack(fill="x", pady=8) |
| 255 | +ttk.Button(options_frame, text="Manage Q&A", command=manage_qa_ui, style="Update.TButton").pack(side="right") |
| 256 | + |
| 257 | +# ========================= |
| 258 | +# Chat Frame with Scrollbar |
| 259 | +# ========================= |
| 260 | +chat_frame = ttk.Frame(main_frame) |
| 261 | +chat_frame.pack(expand=True, fill="both", pady=10) |
| 262 | + |
| 263 | +# Chat Text widget |
| 264 | +chat_text = tk.Text( |
| 265 | + chat_frame, |
| 266 | + wrap="word", |
| 267 | + state="disabled", |
| 268 | + font=("Segoe UI", 12), |
| 269 | + bg="#f5f5f5", |
| 270 | + relief="flat", |
| 271 | + padx=10, |
| 272 | + pady=10 |
| 273 | +) |
| 274 | +chat_text.pack(side="left", expand=True, fill="both") |
| 275 | + |
| 276 | +# Scrollbar |
| 277 | +scrollbar = ttk.Scrollbar(chat_frame, orient="vertical", command=chat_text.yview) |
| 278 | +scrollbar.pack(side="right", fill="y") |
| 279 | +chat_text.configure(yscrollcommand=scrollbar.set) |
| 280 | + |
| 281 | +# Bubble styling |
| 282 | +chat_text.tag_configure( |
| 283 | + "user_bubble", |
| 284 | + background="#DCF8C6", |
| 285 | + foreground="black", |
| 286 | + spacing3=5, |
| 287 | + lmargin1=30, |
| 288 | + rmargin=5, |
| 289 | + font=("Segoe UI", 12) |
| 290 | +) |
| 291 | +chat_text.tag_configure( |
| 292 | + "bot_bubble", |
| 293 | + background="#FFFFFF", |
| 294 | + foreground="black", |
| 295 | + spacing3=5, |
| 296 | + lmargin1=5, |
| 297 | + rmargin=30, |
| 298 | + font=("Segoe UI", 12) |
| 299 | +) |
| 300 | + |
| 301 | + |
| 302 | +# Status Bar |
| 303 | +status_var = tk.StringVar(value="Ready") |
| 304 | +ttk.Label(root, textvariable=status_var, anchor="w").pack(side=tk.BOTTOM, fill="x") |
| 305 | + |
| 306 | +# ========================= |
| 307 | +# Run App |
| 308 | +# ========================= |
| 309 | +root.mainloop() |
0 commit comments