|
2 | 2 |
|
3 | 3 | from dataclasses import dataclass |
4 | 4 | import ctypes |
| 5 | +import json |
5 | 6 | import os |
6 | 7 | import queue |
7 | 8 | import threading |
|
10 | 11 | from tkinter import font as tkfont |
11 | 12 | from tkinter import filedialog, messagebox, ttk |
12 | 13 | from urllib.parse import urlparse |
| 14 | +import urllib.error |
| 15 | +import urllib.request |
13 | 16 |
|
14 | 17 | from mu_unscramble_bot.bot import MuUnscrambleBot |
15 | 18 | from mu_unscramble_bot.config import ( |
@@ -158,6 +161,70 @@ def _normalize_provider_base_url(provider: str, base_url: str) -> str: |
158 | 161 | return f"{cleaned}/v1" |
159 | 162 |
|
160 | 163 |
|
| 164 | +def _fetch_model_candidates(base_url: str, *, api_key: str = "") -> list[str]: |
| 165 | + normalized = base_url.strip().rstrip("/") |
| 166 | + if not normalized: |
| 167 | + raise ValueError("Base URL is empty.") |
| 168 | + |
| 169 | + parsed = urlparse(normalized) |
| 170 | + path = (parsed.path or "").rstrip("/") |
| 171 | + base_root = normalized[:-3] if path.endswith("/v1") else normalized |
| 172 | + base_root = base_root.rstrip("/") |
| 173 | + |
| 174 | + candidate_urls: list[str] = [] |
| 175 | + if path.endswith("/v1"): |
| 176 | + candidate_urls.append(f"{normalized}/models") |
| 177 | + else: |
| 178 | + candidate_urls.append(f"{normalized}/v1/models") |
| 179 | + candidate_urls.append(f"{base_root}/api/tags") |
| 180 | + |
| 181 | + headers = {"User-Agent": "mu-unscramble-bot"} |
| 182 | + if api_key: |
| 183 | + headers["Authorization"] = f"Bearer {api_key}" |
| 184 | + |
| 185 | + errors: list[str] = [] |
| 186 | + seen_models: dict[str, None] = {} |
| 187 | + for url in dict.fromkeys(candidate_urls): |
| 188 | + try: |
| 189 | + request = urllib.request.Request(url, headers=headers) |
| 190 | + with urllib.request.urlopen(request, timeout=8.0) as response: |
| 191 | + payload = json.loads(response.read().decode("utf-8")) |
| 192 | + except urllib.error.HTTPError as exc: |
| 193 | + errors.append(f"{url} -> HTTP {exc.code}") |
| 194 | + continue |
| 195 | + except Exception as exc: |
| 196 | + errors.append(f"{url} -> {type(exc).__name__}: {exc}") |
| 197 | + continue |
| 198 | + |
| 199 | + for model in _extract_model_ids(payload): |
| 200 | + seen_models[str(model)] = None |
| 201 | + if seen_models: |
| 202 | + return sorted(seen_models.keys()) |
| 203 | + |
| 204 | + error_text = errors[0] if errors else "No model endpoints returned data." |
| 205 | + raise RuntimeError(error_text) |
| 206 | + |
| 207 | + |
| 208 | +def _extract_model_ids(payload: object) -> list[str]: |
| 209 | + models: list[str] = [] |
| 210 | + if isinstance(payload, dict): |
| 211 | + if isinstance(payload.get("data"), list): |
| 212 | + for item in payload["data"]: |
| 213 | + if not isinstance(item, dict): |
| 214 | + continue |
| 215 | + model_id = str(item.get("id") or item.get("model") or item.get("name") or "").strip() |
| 216 | + if model_id: |
| 217 | + models.append(model_id) |
| 218 | + if isinstance(payload.get("models"), list): |
| 219 | + for item in payload["models"]: |
| 220 | + if not isinstance(item, dict): |
| 221 | + continue |
| 222 | + model_id = str(item.get("name") or item.get("model") or item.get("id") or "").strip() |
| 223 | + if model_id: |
| 224 | + models.append(model_id) |
| 225 | + return models |
| 226 | + |
| 227 | + |
161 | 228 | class SettingsDialog: |
162 | 229 | def __init__(self, parent: "DesktopApp") -> None: |
163 | 230 | self.parent = parent |
@@ -188,6 +255,7 @@ def __init__(self, parent: "DesktopApp") -> None: |
188 | 255 | self.dictionary_path_var = tk.StringVar(value=self.config.local_dictionary_path) |
189 | 256 | self.send_hint_var = tk.BooleanVar(value=self.config.openai_send_hint) |
190 | 257 | self.speed_text_var = tk.StringVar() |
| 258 | + self.detect_models_status_var = tk.StringVar(value="") |
191 | 259 | self.solver_order = list(self.config.solver_order) |
192 | 260 |
|
193 | 261 | outer = tk.Frame(self.window, bg=WINDOW_BG, padx=18, pady=18) |
@@ -259,15 +327,38 @@ def __init__(self, parent: "DesktopApp") -> None: |
259 | 327 | ).pack(fill="x", pady=(8, 0)) |
260 | 328 |
|
261 | 329 | self._row_label(api_card, "Model").pack(anchor="w", pady=(12, 0)) |
| 330 | + model_row = tk.Frame(api_card, bg=CARD_BG) |
| 331 | + model_row.pack(fill="x", pady=(8, 0)) |
262 | 332 | tk.Entry( |
263 | | - api_card, |
| 333 | + model_row, |
264 | 334 | textvariable=self.model_var, |
265 | 335 | bg="#0b1620", |
266 | 336 | fg=TEXT_MAIN, |
267 | 337 | insertbackground=TEXT_MAIN, |
268 | 338 | relief="flat", |
269 | 339 | font=("Segoe UI", 10), |
270 | | - ).pack(fill="x", pady=(8, 0)) |
| 340 | + ).pack(side="left", fill="x", expand=True) |
| 341 | + self.detect_models_button = tk.Button( |
| 342 | + model_row, |
| 343 | + text="Detect Models", |
| 344 | + command=self._detect_models, |
| 345 | + bg="#345369", |
| 346 | + fg=TEXT_MAIN, |
| 347 | + relief="flat", |
| 348 | + padx=12, |
| 349 | + pady=6, |
| 350 | + font=("Segoe UI Semibold", 9), |
| 351 | + ) |
| 352 | + self.detect_models_button.pack(side="left", padx=(8, 0)) |
| 353 | + tk.Label( |
| 354 | + api_card, |
| 355 | + textvariable=self.detect_models_status_var, |
| 356 | + bg=CARD_BG, |
| 357 | + fg="#79c0ff", |
| 358 | + font=("Segoe UI", 9), |
| 359 | + wraplength=640, |
| 360 | + justify="left", |
| 361 | + ).pack(anchor="w", pady=(6, 0)) |
271 | 362 |
|
272 | 363 | self._row_label(api_card, "Base URL").pack(anchor="w", pady=(12, 0)) |
273 | 364 | tk.Entry( |
@@ -305,7 +396,7 @@ def __init__(self, parent: "DesktopApp") -> None: |
305 | 396 | self._row_label(solver_card, "Solver Order").pack(anchor="w") |
306 | 397 | tk.Label( |
307 | 398 | solver_card, |
308 | | - text="Memory answers stay first. Move the local solver steps up or down to experiment. AI still runs as the background fallback when needed.", |
| 399 | + text="Memory answers stay first. Move the local solver steps up or down to experiment. If AI is first, it starts early in the background while the fast local solvers keep running.", |
309 | 400 | bg=CARD_BG, |
310 | 401 | fg=TEXT_SOFT, |
311 | 402 | font=("Segoe UI", 9), |
@@ -624,6 +715,136 @@ def _browse_dictionary(self) -> None: |
624 | 715 | if path: |
625 | 716 | self.dictionary_path_var.set(path) |
626 | 717 |
|
| 718 | + def _detect_models(self) -> None: |
| 719 | + provider = self.provider_var.get() |
| 720 | + base_url = _normalize_provider_base_url(provider, self.base_url_var.get()) |
| 721 | + if not base_url: |
| 722 | + messagebox.showwarning(APP_NAME, "Enter a base URL before detecting models.") |
| 723 | + return |
| 724 | + |
| 725 | + self.base_url_var.set(base_url) |
| 726 | + self.detect_models_status_var.set("Detecting models from the configured endpoint...") |
| 727 | + self.detect_models_button.config(state="disabled", text="Detecting...") |
| 728 | + api_key = self.api_key_var.get().strip() |
| 729 | + threading.Thread( |
| 730 | + target=self._detect_models_worker, |
| 731 | + args=(base_url, api_key), |
| 732 | + name="mu-detect-models", |
| 733 | + daemon=True, |
| 734 | + ).start() |
| 735 | + |
| 736 | + def _detect_models_worker(self, base_url: str, api_key: str) -> None: |
| 737 | + try: |
| 738 | + models = _fetch_model_candidates(base_url, api_key=api_key) |
| 739 | + except Exception as exc: |
| 740 | + self.window.after(0, lambda: self._finish_detect_models(error=f"{type(exc).__name__}: {exc}")) |
| 741 | + return |
| 742 | + self.window.after(0, lambda: self._finish_detect_models(models=models)) |
| 743 | + |
| 744 | + def _finish_detect_models(self, *, models: list[str] | None = None, error: str | None = None) -> None: |
| 745 | + self.detect_models_button.config(state="normal", text="Detect Models") |
| 746 | + if error: |
| 747 | + self.detect_models_status_var.set(f"Model detect failed: {error}") |
| 748 | + messagebox.showerror(APP_NAME, f"Could not detect models.\n\n{error}") |
| 749 | + return |
| 750 | + |
| 751 | + models = sorted(dict.fromkeys(models or [])) |
| 752 | + if not models: |
| 753 | + self.detect_models_status_var.set("No models were returned by the endpoint.") |
| 754 | + messagebox.showinfo(APP_NAME, "No models were returned by the endpoint.") |
| 755 | + return |
| 756 | + |
| 757 | + if len(models) == 1: |
| 758 | + self.model_var.set(models[0]) |
| 759 | + self.detect_models_status_var.set(f"Detected model: {models[0]}") |
| 760 | + return |
| 761 | + |
| 762 | + self.detect_models_status_var.set(f"Detected {len(models)} models. Choose one.") |
| 763 | + self._open_model_picker(models) |
| 764 | + |
| 765 | + def _open_model_picker(self, models: list[str]) -> None: |
| 766 | + dialog = tk.Toplevel(self.window) |
| 767 | + dialog.title("Choose Model") |
| 768 | + dialog.configure(bg=WINDOW_BG) |
| 769 | + dialog.transient(self.window) |
| 770 | + dialog.grab_set() |
| 771 | + dialog.geometry("520x360") |
| 772 | + |
| 773 | + container = tk.Frame(dialog, bg=WINDOW_BG, padx=16, pady=16) |
| 774 | + container.pack(fill="both", expand=True) |
| 775 | + tk.Label( |
| 776 | + container, |
| 777 | + text="Detected Models", |
| 778 | + bg=WINDOW_BG, |
| 779 | + fg=TEXT_MAIN, |
| 780 | + font=("Segoe UI Semibold", 14), |
| 781 | + ).pack(anchor="w") |
| 782 | + tk.Label( |
| 783 | + container, |
| 784 | + text="Choose the exact model id to use for the local AI endpoint.", |
| 785 | + bg=WINDOW_BG, |
| 786 | + fg=TEXT_SOFT, |
| 787 | + font=("Segoe UI", 9), |
| 788 | + ).pack(anchor="w", pady=(4, 0)) |
| 789 | + |
| 790 | + list_frame = tk.Frame(container, bg=WINDOW_BG) |
| 791 | + list_frame.pack(fill="both", expand=True, pady=(12, 0)) |
| 792 | + listbox = tk.Listbox( |
| 793 | + list_frame, |
| 794 | + bg="#0b1620", |
| 795 | + fg=TEXT_MAIN, |
| 796 | + selectbackground=BLUE, |
| 797 | + selectforeground=TEXT_MAIN, |
| 798 | + relief="flat", |
| 799 | + font=("Consolas", 10), |
| 800 | + activestyle="none", |
| 801 | + exportselection=False, |
| 802 | + ) |
| 803 | + scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=listbox.yview) |
| 804 | + listbox.configure(yscrollcommand=scrollbar.set) |
| 805 | + listbox.pack(side="left", fill="both", expand=True) |
| 806 | + scrollbar.pack(side="right", fill="y") |
| 807 | + for model in models: |
| 808 | + listbox.insert("end", model) |
| 809 | + listbox.selection_set(0) |
| 810 | + listbox.activate(0) |
| 811 | + |
| 812 | + def choose_selected() -> None: |
| 813 | + selection = listbox.curselection() |
| 814 | + if not selection: |
| 815 | + return |
| 816 | + model = str(listbox.get(selection[0])) |
| 817 | + self.model_var.set(model) |
| 818 | + self.detect_models_status_var.set(f"Selected detected model: {model}") |
| 819 | + dialog.destroy() |
| 820 | + |
| 821 | + actions = tk.Frame(container, bg=WINDOW_BG) |
| 822 | + actions.pack(fill="x", pady=(12, 0)) |
| 823 | + tk.Button( |
| 824 | + actions, |
| 825 | + text="Cancel", |
| 826 | + command=dialog.destroy, |
| 827 | + bg="#2d3f4a", |
| 828 | + fg=TEXT_MAIN, |
| 829 | + relief="flat", |
| 830 | + padx=12, |
| 831 | + pady=6, |
| 832 | + font=("Segoe UI Semibold", 9), |
| 833 | + ).pack(side="right") |
| 834 | + tk.Button( |
| 835 | + actions, |
| 836 | + text="Use Selected", |
| 837 | + command=choose_selected, |
| 838 | + bg=GREEN, |
| 839 | + fg=TEXT_MAIN, |
| 840 | + relief="flat", |
| 841 | + padx=12, |
| 842 | + pady=6, |
| 843 | + font=("Segoe UI Semibold", 9), |
| 844 | + ).pack(side="right", padx=(0, 8)) |
| 845 | + |
| 846 | + listbox.bind("<Double-1>", lambda _event: choose_selected()) |
| 847 | + |
627 | 848 | def _render_solver_order_rows(self) -> None: |
628 | 849 | for child in self.solver_order_frame.winfo_children(): |
629 | 850 | child.destroy() |
|
0 commit comments