Skip to content

Commit f8f1885

Browse files
committed
Add model detection and early AI start
1 parent 9b77be8 commit f8f1885

6 files changed

Lines changed: 263 additions & 6 deletions

File tree

mu_unscramble_bot/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""MU unscramble bot package."""
22

3-
__version__ = "0.3.19"
3+
__version__ = "0.3.20"

mu_unscramble_bot/bot.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,20 @@ def run_once(self) -> tuple[Puzzle | None, SolverResult | None]:
178178
ocr_text=live_ocr_text,
179179
)
180180

181+
if (
182+
self._pending_online_solve is None
183+
and self._should_start_online_solve()
184+
and self.solver.prefers_early_online()
185+
):
186+
self._start_online_solve(puzzle)
187+
self._update_overlay_from_puzzle(
188+
puzzle,
189+
status="AI started in background while fast solvers keep checking...",
190+
answer_text="-",
191+
method_text="api early",
192+
ocr_text=live_ocr_text,
193+
)
194+
181195
if observed_answer and self.solver.remember(
182196
puzzle,
183197
SolverResult(answer=observed_answer, method="observed-guess", confidence=1.0),
@@ -271,6 +285,7 @@ def _finalize_solution(
271285
live_ocr_text: str,
272286
cycle_started_at: float,
273287
) -> tuple[Puzzle, SolverResult]:
288+
self._cancel_pending_online_if_matches(puzzle.round_key)
274289
self._last_failed_at.pop(puzzle.signature, None)
275290
self._last_solved_at[puzzle.signature] = time.monotonic()
276291
self._update_overlay_from_puzzle(

mu_unscramble_bot/gui.py

Lines changed: 224 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from dataclasses import dataclass
44
import ctypes
5+
import json
56
import os
67
import queue
78
import threading
@@ -10,6 +11,8 @@
1011
from tkinter import font as tkfont
1112
from tkinter import filedialog, messagebox, ttk
1213
from urllib.parse import urlparse
14+
import urllib.error
15+
import urllib.request
1316

1417
from mu_unscramble_bot.bot import MuUnscrambleBot
1518
from mu_unscramble_bot.config import (
@@ -158,6 +161,70 @@ def _normalize_provider_base_url(provider: str, base_url: str) -> str:
158161
return f"{cleaned}/v1"
159162

160163

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+
161228
class SettingsDialog:
162229
def __init__(self, parent: "DesktopApp") -> None:
163230
self.parent = parent
@@ -188,6 +255,7 @@ def __init__(self, parent: "DesktopApp") -> None:
188255
self.dictionary_path_var = tk.StringVar(value=self.config.local_dictionary_path)
189256
self.send_hint_var = tk.BooleanVar(value=self.config.openai_send_hint)
190257
self.speed_text_var = tk.StringVar()
258+
self.detect_models_status_var = tk.StringVar(value="")
191259
self.solver_order = list(self.config.solver_order)
192260

193261
outer = tk.Frame(self.window, bg=WINDOW_BG, padx=18, pady=18)
@@ -259,15 +327,38 @@ def __init__(self, parent: "DesktopApp") -> None:
259327
).pack(fill="x", pady=(8, 0))
260328

261329
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))
262332
tk.Entry(
263-
api_card,
333+
model_row,
264334
textvariable=self.model_var,
265335
bg="#0b1620",
266336
fg=TEXT_MAIN,
267337
insertbackground=TEXT_MAIN,
268338
relief="flat",
269339
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))
271362

272363
self._row_label(api_card, "Base URL").pack(anchor="w", pady=(12, 0))
273364
tk.Entry(
@@ -305,7 +396,7 @@ def __init__(self, parent: "DesktopApp") -> None:
305396
self._row_label(solver_card, "Solver Order").pack(anchor="w")
306397
tk.Label(
307398
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.",
309400
bg=CARD_BG,
310401
fg=TEXT_SOFT,
311402
font=("Segoe UI", 9),
@@ -624,6 +715,136 @@ def _browse_dictionary(self) -> None:
624715
if path:
625716
self.dictionary_path_var.set(path)
626717

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+
627848
def _render_solver_order_rows(self) -> None:
628849
for child in self.solver_order_frame.winfo_children():
629850
child.destroy()

mu_unscramble_bot/solver.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,7 @@ def __init__(
520520
self._cache: dict[str, SolverResult] = {}
521521
self._offline_solvers = [solver for solver in solvers if not isinstance(solver, OpenAIHintSolver)]
522522
self._online_solvers = [solver for solver in solvers if isinstance(solver, OpenAIHintSolver)]
523+
self._prefer_early_online = self._compute_prefer_early_online(solvers)
523524

524525
def solve(self, puzzle: Puzzle) -> SolverResult | None:
525526
result = self.solve_fast(puzzle)
@@ -573,6 +574,9 @@ def startup_check(self, prompt: str, timeout_seconds: float = 20.0) -> ApiTestRe
573574
def has_online_solver(self) -> bool:
574575
return bool(self._online_solvers)
575576

577+
def prefers_early_online(self) -> bool:
578+
return self._prefer_early_online and bool(self._online_solvers)
579+
576580
def memory_size(self) -> int:
577581
if self.question_memory is None:
578582
return 0
@@ -586,6 +590,23 @@ def remember(self, puzzle: Puzzle, result: SolverResult) -> bool:
586590
self._cache[puzzle.signature] = result
587591
return True
588592

593+
@staticmethod
594+
def _compute_prefer_early_online(solvers: list[Solver]) -> bool:
595+
first_online_index: int | None = None
596+
first_offline_index: int | None = None
597+
for index, solver in enumerate(solvers):
598+
if isinstance(solver, OpenAIHintSolver):
599+
if first_online_index is None:
600+
first_online_index = index
601+
continue
602+
if first_offline_index is None:
603+
first_offline_index = index
604+
return (
605+
first_online_index is not None
606+
and first_offline_index is not None
607+
and first_online_index < first_offline_index
608+
)
609+
589610

590611
def build_solver_chain(config: BotConfig) -> SolverChain:
591612
question_memory = None

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "mu-unscramble-bot"
7-
version = "0.3.19"
7+
version = "0.3.20"
88
description = "Screen-reading MU Online helper that watches the center yellow text, solves the puzzle, and can auto-submit the answer."
99
readme = "README.md"
1010
requires-python = ">=3.12"

scripts/build_release.ps1

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
param(
2-
[string]$Version = "0.3.19",
2+
[string]$Version = "0.3.20",
33
[string]$PythonPath = ""
44
)
55

0 commit comments

Comments
 (0)