diff --git a/.DS_Store b/.DS_Store index 65e1939..46381cf 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/.core/__pycache__/config.cpython-313.pyc b/.core/__pycache__/config.cpython-313.pyc index cd438c5..47b8076 100644 Binary files a/.core/__pycache__/config.cpython-313.pyc and b/.core/__pycache__/config.cpython-313.pyc differ diff --git a/.core/config.py b/.core/config.py index e52324c..15facb0 100644 --- a/.core/config.py +++ b/.core/config.py @@ -13,7 +13,7 @@ TEMP_DIR = CORE_DIR / ".temp" MODELS_DIR = ROOT_DIR / "models" DEFAULT_TEMPERATURE = float(os.getenv("AUTO_TEST_TEMPERATURE", "0.1")) -DEFAULT_PROVIDER_NAME = os.getenv("AUTO_TEST_PROVIDER", "LM Studio") +DEFAULT_PROVIDER_NAME = os.getenv("AUTO_TEST_PROVIDER", "Local Engine") def ensure_directories() -> None: diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..cc78c17 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +venv-3.11.9 diff --git a/auto-test.py b/auto-test.py index b7252fd..b00a538 100644 --- a/auto-test.py +++ b/auto-test.py @@ -1,5 +1,4 @@ from __future__ import annotations - import sys from pathlib import Path @@ -7,7 +6,79 @@ CORE_DIR = Path(__file__).resolve().parent / ".core" if str(CORE_DIR) not in sys.path: sys.path.insert(0, str(CORE_DIR)) -from runner import safe_run # type: ignore # noqa: E402 +import argparse +from runner import run_suite, TestRunError, TemplateNotFoundError +from providers import list_provider_names, get_provider +from config import DEFAULT_PROVIDER_NAME + +def list_providers(): + print("Available providers:") + for name in list_provider_names(): + print(f" {name}") + +def list_models(provider_name): + try: + provider = get_provider(provider_name) + models = provider.list_models() + print(f"Models for provider '{provider_name}':") + for m in models: + print(f" {m.id} ({m.display_name})") + except Exception as e: + print(f"Error listing models: {e}") + +def print_usage(): + print(""" +-h --help Lists command cli usage instructions +-p Sets the provider to connect to, uses local engine if unset +-m Sets the model to run tests with. (this should have the ability for tab autocompletion from the models found from the provider) +-l --list Lists providers if used as the only flag, lists models found if used after -p +-t --test defines the test suite to use for the run + +usage example: python3 auto-test.py -m Qwen3-Coder-30B.gguf -t SwiftUI-Knowledge +""") + +def main(): + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument('-h', '--help', action='store_true') + parser.add_argument('-p', type=str, metavar='PROVIDER', help='Provider to use') + parser.add_argument('-m', type=str, metavar='MODEL', help='Model to use') + parser.add_argument('-l', '--list', action='store_true', help='List providers or models') + parser.add_argument('-t', type=str, metavar='TEST', help='Test suite to use (not yet implemented)') + args = parser.parse_args() + + if args.help: + print_usage() + return 0 + + if args.list: + if args.p: + list_models(args.p) + else: + list_providers() + return 0 + + provider = args.p or DEFAULT_PROVIDER_NAME + model = args.m + # args.t is parsed but not yet implemented in runner + + def _print_progress(index: int, total: int, prompt, result): + status = "FAILED" if "error" in result else "DONE" + filename_label = prompt.get("filename", f"test{index}") + title = prompt.get("title", f"Test {index}") + print(f"Running {title} [{filename_label}] ({index}/{total})... {status}") + + print(f"Starting automated diagnostic run with provider '{provider}'…") + try: + report_path = run_suite(provider_name=provider, model_id=model, progress_callback=_print_progress) + except TestRunError as exc: + print(f"Error: {exc}") + return 1 + except TemplateNotFoundError as exc: + print(f"Error: {exc}") + return 1 + + print(f"\n✅ Success! Report saved to '{report_path.name}'.") + return 0 if __name__ == "__main__": - raise SystemExit(safe_run()) + raise SystemExit(main()) diff --git a/diff_viewer.py b/diff_viewer.py new file mode 100644 index 0000000..4ecadd2 --- /dev/null +++ b/diff_viewer.py @@ -0,0 +1,42 @@ +import tkinter as tk +from tkinter import ttk +import difflib + +class DiffViewerDialog(tk.Toplevel): + def __init__(self, parent, expected: str, actual: str, title: str = "Output Diff"): + super().__init__(parent) + self.title(title) + self.geometry("900x600") + self.transient(parent) + self.grab_set() + + paned = ttk.PanedWindow(self, orient=tk.HORIZONTAL) + paned.pack(fill="both", expand=True, padx=10, pady=10) + + left_frame = ttk.Frame(paned) + right_frame = ttk.Frame(paned) + paned.add(left_frame, weight=1) + paned.add(right_frame, weight=1) + + ttk.Label(left_frame, text="Expected Output", font=("Helvetica", 12, "bold")).pack(anchor="w") + self.expected_text = tk.Text(left_frame, wrap="word", bg="#f8f8f8") + self.expected_text.pack(fill="both", expand=True) + self.expected_text.insert("1.0", expected) + self.expected_text.configure(state="disabled") + + ttk.Label(right_frame, text="Actual Output", font=("Helvetica", 12, "bold")).pack(anchor="w") + self.actual_text = tk.Text(right_frame, wrap="word", bg="#f8f8f8") + self.actual_text.pack(fill="both", expand=True) + self.actual_text.insert("1.0", actual) + self.actual_text.configure(state="disabled") + + # Diff summary at the bottom + diff = list(difflib.unified_diff( + expected.splitlines(), actual.splitlines(), lineterm='', n=3, + fromfile='expected', tofile='actual')) + diff_str = '\n'.join(diff) if diff else 'No differences.' + ttk.Label(self, text="Diff Summary", font=("Helvetica", 11, "bold")).pack(anchor="w", padx=10) + diff_box = tk.Text(self, height=8, wrap="none", bg="#f0f0f0") + diff_box.pack(fill="x", padx=10, pady=(0,10)) + diff_box.insert("1.0", diff_str) + diff_box.configure(state="disabled") diff --git a/index.py b/index.py index 52017c2..9127aa2 100644 --- a/index.py +++ b/index.py @@ -36,6 +36,151 @@ def open_path(path: Path) -> None: class App(tk.Tk): + def open_settings_dialog(self): + try: + from settings_dialog import SettingsDialog + except ImportError: + messagebox.showerror("Error", "Settings dialog module not found.") + return + SettingsDialog(self) + def open_prompt_playground(self): + try: + from prompt_playground import PlaygroundDialog + except ImportError: + messagebox.showerror("Error", "Prompt playground module not found.") + return + def send_callback(prompt): + provider_name = self.provider_var.get() + model_display = self.model_var.get() + if not provider_name or not model_display: + return "Please select a provider and model." + try: + provider = get_provider(provider_name, **self._provider_kwargs(provider_name)) + model_id = self._model_id_for_display(model_display) + if not model_id: + return "Model not found." + # Use temperature from the GUI + response = provider.run_inference(model_id, prompt, temperature=self.temperature_var.get()) + return response + except Exception as e: + return f"Error: {e}" + PlaygroundDialog(self, send_callback) + def load_plugins(self): + try: + from plugin_system import PluginManager + self.plugin_manager = PluginManager() + self.plugin_manager.run_hook('on_app_start', self) + except Exception as e: + print(f"Plugin system error: {e}") + def open_results_chart(self): + try: + from results_chart import ResultsChartDialog + except ImportError: + messagebox.showerror("Error", "Results chart module not found.") + return + # Find the latest report + if not RESULTS_DIR.exists(): + messagebox.showinfo("No Reports", "The results directory does not exist yet.") + return + reports = list(RESULTS_DIR.glob("*.md")) + if not reports: + messagebox.showinfo("No Reports", "No markdown reports found yet.") + return + latest_report = max(reports, key=lambda p: p.stat().st_mtime) + ResultsChartDialog(self, latest_report) + def open_diff_viewer(self): + try: + from diff_viewer import DiffViewerDialog + except ImportError: + messagebox.showerror("Error", "Diff viewer module not found.") + return + # Try to get the last test's expected and actual output from the log or a result file + # For now, just prompt the user for both (can be improved to auto-load from last run) + expected = "" + actual = "" + def get_text(title): + d = tk.Toplevel(self) + d.title(title) + d.geometry("600x400") + t = tk.Text(d, wrap="word") + t.pack(fill="both", expand=True) + result = {} + def ok(): + result['text'] = t.get("1.0", "end-1c") + d.destroy() + ttk.Button(d, text="OK", command=ok).pack() + self.wait_window(d) + return result.get('text', "") + expected = get_text("Paste Expected Output") + actual = get_text("Paste Actual Output") + if expected and actual: + DiffViewerDialog(self, expected, actual) + else: + messagebox.showinfo("Diff Viewer", "Both expected and actual output are required.") + def configure_model_paths(self) -> None: + dialog = tk.Toplevel(self) + dialog.title("Configure Model Paths") + dialog.geometry("520x320") + dialog.transient(self) + dialog.grab_set() + + paths: List[str] = get_local_model_paths() + + listbox = tk.Listbox(dialog, selectmode=tk.SINGLE, width=60, height=10) + for entry in paths: + listbox.insert("end", entry) + listbox.pack(fill="both", expand=True, padx=12, pady=(12, 6)) + + button_frame = ttk.Frame(dialog) + button_frame.pack(fill="x", padx=12, pady=6) + + def add_path() -> None: + selection = filedialog.askdirectory(parent=dialog) + if selection and selection not in paths: + paths.append(selection) + listbox.insert("end", selection) + + def remove_path() -> None: + selection = listbox.curselection() + if not selection: + return + index = selection[0] + listbox.delete(index) + paths.pop(index) + + ttk.Button(button_frame, text="Add Path", command=add_path).pack(side="left", padx=(0, 8)) + ttk.Button(button_frame, text="Remove Selected", command=remove_path).pack(side="left") + ttk.Button(button_frame, text="Save", command=lambda: (set_local_model_paths(paths), dialog.destroy())).pack(side="right") + def open_test_editor(self): + try: + from test_editor import TestEditorDialog + except ImportError: + messagebox.showerror("Error", "Test editor module not found.") + return + TestEditorDialog(self, TESTS_DIR) + + def open_latest_report(self) -> None: + if not RESULTS_DIR.exists(): + messagebox.showinfo("No Reports", "The results directory does not exist yet.") + return + reports = list(RESULTS_DIR.glob("*.md")) + if not reports: + messagebox.showinfo("No Reports", "No markdown reports found yet.") + return + latest_report = max(reports, key=lambda p: p.stat().st_mtime) + open_path(latest_report) + self._append_log(f"Opened latest report: {latest_report.name}") + def open_model_downloader(self): + try: + from model_downloader import DownloadModelDialog + except ImportError: + messagebox.showerror("Error", "Model downloader module not found.") + return + paths = get_local_model_paths() + import os + model_dir = Path(paths[0]).expanduser() if paths else (Path(os.getcwd()) / "models") + model_dir.mkdir(parents=True, exist_ok=True) + DownloadModelDialog(self, model_dir) def __init__(self) -> None: super().__init__() self.title("LLM Automated Test Console") @@ -53,6 +198,8 @@ class App(tk.Tk): self.available_models: List[Tuple[str, str]] = [] # (id, display name) self.running = False + self.load_plugins() + self._build_ui() self._build_menu() if self.provider_var.get(): @@ -102,6 +249,12 @@ class App(tk.Tk): self.run_button.pack(side="left") ttk.Button(button_frame, text="Refresh Models", command=self.fetch_models_async).pack(side="left", padx=(12, 0)) + ttk.Button(button_frame, text="New Test", command=self.open_test_editor).pack(side="left", padx=(12, 0)) + ttk.Button(button_frame, text="Compare Outputs", command=self.open_diff_viewer).pack(side="left", padx=(12, 0)) + ttk.Button(button_frame, text="View Results Chart", command=self.open_results_chart).pack(side="left", padx=(12, 0)) + ttk.Button(button_frame, text="Prompt Playground", command=self.open_prompt_playground).pack(side="left", padx=(12, 0)) + ttk.Button(button_frame, text="Settings", command=self.open_settings_dialog).pack(side="left", padx=(12, 0)) + ttk.Button(button_frame, text="Download Model", command=self.open_model_downloader).pack(side="left", padx=(12, 0)) ttk.Button(button_frame, text="Edit Tests", command=lambda: open_path(TESTS_DIR)).pack(side="left", padx=(12, 0)) ttk.Button(button_frame, text="Open Results", command=lambda: open_path(RESULTS_DIR)).pack(side="left", padx=(12, 0)) ttk.Button(button_frame, text="View Latest Report", command=self.open_latest_report).pack(side="left", padx=(12, 0)) @@ -115,7 +268,7 @@ class App(tk.Tk): def _build_menu(self) -> None: menubar = tk.Menu(self) settings_menu = tk.Menu(menubar, tearoff=0) - settings_menu.add_command(label="Configure Model Paths…", command=self.configure_model_paths) + settings_menu.add_command(label="Settings…", command=self.open_settings_dialog) menubar.add_cascade(label="Settings", menu=settings_menu) self.config(menu=menubar) self._settings_menu = settings_menu @@ -148,6 +301,13 @@ class App(tk.Tk): model_pairs = [(model.id, model.display_name) for model in models] self.after(0, lambda: self._on_models_loaded(model_pairs)) + def open_test_editor(self): + try: + from test_editor import TestEditorDialog + except ImportError: + messagebox.showerror("Error", "Test editor module not found.") + return + TestEditorDialog(self, TESTS_DIR) def _on_model_fetch_error(self, message: str) -> None: self._set_status("Model fetch failed") messagebox.showerror("Model Load Error", message) @@ -267,61 +427,61 @@ class App(tk.Tk): return {"search_paths": [path for path in custom_paths if path]} return {} - def configure_model_paths(self) -> None: - dialog = tk.Toplevel(self) - dialog.title("Configure Model Paths") - dialog.geometry("520x320") - dialog.transient(self) - dialog.grab_set() + def _build_ui(self) -> None: + padding = {"padx": 12, "pady": 8} - paths: List[str] = get_local_model_paths() + header = ttk.Label(self, text="Automated Diagnostic Test Runner", font=("Helvetica", 18, "bold")) + header.pack(anchor="w", **padding) - listbox = tk.Listbox(dialog, selectmode=tk.SINGLE, width=60, height=10) - for entry in paths: - listbox.insert("end", entry) - listbox.pack(fill="both", expand=True, padx=12, pady=(12, 6)) + form_frame = ttk.Frame(self) + form_frame.pack(fill="x", **padding) - button_frame = ttk.Frame(dialog) - button_frame.pack(fill="x", padx=12, pady=6) + ttk.Label(form_frame, text="Provider:").grid(row=0, column=0, sticky="w") + self.provider_combo = ttk.Combobox( + form_frame, + textvariable=self.provider_var, + values=self.provider_names, + state="readonly", + ) + self.provider_combo.grid(row=0, column=1, sticky="ew", padx=(6, 18)) + self.provider_combo.bind("<>", lambda event: self.fetch_models_async()) - def add_path() -> None: - selection = filedialog.askdirectory(parent=dialog) - if selection and selection not in paths: - paths.append(selection) - listbox.insert("end", selection) + ttk.Label(form_frame, text="Model:").grid(row=0, column=2, sticky="w") + self.model_combo = ttk.Combobox(form_frame, textvariable=self.model_var, state="readonly") + self.model_combo.grid(row=0, column=3, sticky="ew", padx=(6, 18)) - def remove_path() -> None: - selection = listbox.curselection() - if not selection: - return - index = selection[0] - listbox.delete(index) - paths.pop(index) + ttk.Label(form_frame, text="Temperature:").grid(row=0, column=4, sticky="w") + self.temperature_spin = ttk.Spinbox( + form_frame, + textvariable=self.temperature_var, + from_=0.0, + to=1.0, + increment=0.05, + width=6, + ) + self.temperature_spin.grid(row=0, column=5, sticky="w") - ttk.Button(button_frame, text="Add…", command=add_path).pack(side="left") - ttk.Button(button_frame, text="Remove", command=remove_path).pack(side="left", padx=(8, 0)) + form_frame.columnconfigure(1, weight=1) + form_frame.columnconfigure(3, weight=1) - action_frame = ttk.Frame(dialog) - action_frame.pack(fill="x", padx=12, pady=(0, 12)) + button_frame = ttk.Frame(self) + button_frame.pack(fill="x", **padding) - def on_save() -> None: - normalized = [str(Path(p).expanduser()) for p in paths] - set_local_model_paths(normalized) - dialog.grab_release() - dialog.destroy() - if self.provider_var.get() == LocalEngineProvider.name: - self.fetch_models_async() + self.run_button = ttk.Button(button_frame, text="Run Tests", command=self.start_run) + self.run_button.pack(side="left") - def on_cancel() -> None: - dialog.grab_release() - dialog.destroy() + ttk.Button(button_frame, text="Refresh Models", command=self.fetch_models_async).pack(side="left", padx=(12, 0)) + ttk.Button(button_frame, text="New Test", command=self.open_test_editor).pack(side="left", padx=(12, 0)) + ttk.Button(button_frame, text="Download Model", command=self.open_model_downloader).pack(side="left", padx=(12, 0)) + ttk.Button(button_frame, text="Edit Tests", command=lambda: open_path(TESTS_DIR)).pack(side="left", padx=(12, 0)) + ttk.Button(button_frame, text="Open Results", command=lambda: open_path(RESULTS_DIR)).pack(side="left", padx=(12, 0)) + ttk.Button(button_frame, text="View Latest Report", command=self.open_latest_report).pack(side="left", padx=(12, 0)) - dialog.protocol("WM_DELETE_WINDOW", on_cancel) + ttk.Label(self, textvariable=self.status_var).pack(anchor="w", **padding) - ttk.Button(action_frame, text="Save", command=on_save).pack(side="right", padx=(8, 0)) - ttk.Button(action_frame, text="Cancel", command=on_cancel).pack(side="right") - - def open_latest_report(self) -> None: + self.log = scrolledtext.ScrolledText(self, wrap="word", height=22) + self.log.pack(fill="both", expand=True, padx=12, pady=(0, 12)) + self.log.configure(state="disabled") if not RESULTS_DIR.exists(): messagebox.showinfo("No Reports", "The results directory does not exist yet.") return diff --git a/model_downloader.py b/model_downloader.py new file mode 100644 index 0000000..8cd11fd --- /dev/null +++ b/model_downloader.py @@ -0,0 +1,193 @@ +import tkinter as tk +from tkinter import simpledialog, messagebox +from pathlib import Path +import urllib.request +import shutil +import threading + +class DownloadModelDialog(simpledialog.Dialog): + def __init__(self, parent, model_dir: Path): + self.model_dir = model_dir + self.active_downloads = {} # filename -> {progress, thread, cancel_flag} + super().__init__(parent, title="Download Model") + + + def body(self, master): + tk.Label(master, text="Model URL:").grid(row=0, column=0, sticky="w") + self.url_entry = tk.Entry(master, width=60) + self.url_entry.grid(row=0, column=1, padx=6, pady=4) + + # Browse Hugging Face button + browse_btn = tk.Button(master, text="Browse Hugging Face...", command=self.open_hf_browser, bg="#e0e0e0", fg="#111", width=22) + browse_btn.grid(row=1, column=0, columnspan=2, sticky="w", padx=(0, 0), pady=(2, 2)) + + # Comprehensive caption/instruction at the bottom + caption_text = ( + "Paste a direct download URL for a model file. " + "\n\n" + "Where to find models:\n" + "- Hugging Face: https://huggingface.co (search for GGUF or compatible models)\n" + "- Official model provider pages\n" + "\n" + "The URL should point directly to a downloadable file (e.g., .gguf, .bin).\n" + "Example: https://huggingface.co/ORG/MODEL/resolve/main/model-file.gguf\n" + "\n" + "Downloaded files will be saved to your selected model directory and will appear in the model selection list." + ) + caption = tk.Label( + master, + text=caption_text, + justify="left", + anchor="w", + wraplength=440, + fg="#fff", + bg="#444", + font=(None, 10, "italic") + ) + caption.grid(row=2, column=0, columnspan=2, sticky="ew", pady=(16, 2)) + + # Download cards frame (below caption) + self.cards_frame = tk.Frame(master, bg="#222") + self.cards_frame.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(8, 2)) + self.refresh_cards() + return self.url_entry + def open_hf_browser(self): + # Popup window with embedded browser to Hugging Face + try: + from tkhtmlview import HtmlFrame + except ImportError: + messagebox.showerror("Missing Dependency", "tkhtmlview is required for the integrated browser. Please install it with 'pip install tkhtmlview'.") + return + popup = tk.Toplevel(self) + popup.title("Hugging Face Model Catalogue") + popup.geometry("1000x700") + # Info label + tk.Label(popup, text="Browse models. Right-click a download link and select 'Copy Link', then paste it in the URL field.", bg="#222", fg="#fff").pack(fill="x") + # Embedded browser (read-only, not full browser) + frame = HtmlFrame(popup, horizontal_scrollbar="auto", vertical_scrollbar="auto") + frame.pack(fill="both", expand=True) + frame.load_website("https://huggingface.co/models") + + # Comprehensive caption/instruction at the bottom + caption_text = ( + "Paste a direct download URL for a model file. " + "\n\n" + "Where to find models:\n" + "- Hugging Face: https://huggingface.co (search for GGUF or compatible models)\n" + "- Official model provider pages\n" + "\n" + "The URL should point directly to a downloadable file (e.g., .gguf, .bin).\n" + "Example: https://huggingface.co/ORG/MODEL/resolve/main/model-file.gguf\n" + "\n" + "Downloaded files will be saved to your selected model directory and will appear in the model selection list." + ) + caption = tk.Label( + master, + text=caption_text, + justify="left", + anchor="w", + wraplength=440, + fg="#fff", + bg="#444", + font=(None, 10, "italic") + ) + caption.grid(row=2, column=0, columnspan=2, sticky="ew", pady=(16, 2)) + + # Download cards frame (below caption) + self.cards_frame = tk.Frame(master, bg="#222") + self.cards_frame.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(8, 2)) + self.refresh_cards() + return self.url_entry + + def refresh_cards(self): + # Clear previous cards + for widget in self.cards_frame.winfo_children(): + widget.destroy() + if not self.active_downloads: + placeholder = tk.Label( + self.cards_frame, + text="No downloads in progress.", + bg="#222", + fg="#aaa", + font=(None, 10, "italic") + ) + placeholder.pack(fill="x", pady=12) + else: + row = 0 + for fname, info in self.active_downloads.items(): + card = tk.Frame(self.cards_frame, bg="#333", bd=2, relief="groove") + card.grid(row=row, column=0, sticky="ew", pady=4, padx=2) + # File name + tk.Label(card, text=fname, bg="#333", fg="#fff", font=(None, 10, "bold")).pack(anchor="w", padx=8, pady=(4,0)) + bar_frame = tk.Frame(card, bg="#333") + bar_frame.pack(fill="x", padx=8, pady=(2,6)) + # Percentage label + percent_var = info.get('percent_var') + if not percent_var: + percent_var = tk.StringVar(value="0%") + info['percent_var'] = percent_var + percent_label = tk.Label(bar_frame, textvariable=percent_var, width=5, anchor="e", bg="#333", fg="#fff") + percent_label.pack(side="left") + # Progress bar + progress_var = info.get('progress_var') + if not progress_var: + progress_var = tk.DoubleVar(value=0) + info['progress_var'] = progress_var + bar = tk.ttk.Progressbar(bar_frame, variable=progress_var, maximum=100, length=220) + bar.pack(side="left", fill="x", expand=True, padx=(6,6)) + # Cancel button + cancel_btn = tk.Button(bar_frame, text="Cancel", command=lambda f=fname: self.cancel_download(f), bg="#a33", fg="#fff") + cancel_btn.pack(side="right", padx=(6,0)) + row += 1 + + def apply(self): + url = self.url_entry.get().strip() + if not url: + messagebox.showerror("Error", "Model URL is required.") + return + filename = url.split("/")[-1] + dest_path = self.model_dir / filename + cancel_flag = threading.Event() + info = { + 'progress_var': tk.DoubleVar(value=0), + 'percent_var': tk.StringVar(value="0%"), + 'cancel_flag': cancel_flag, + } + self.active_downloads[filename] = info + self.refresh_cards() + def download(): + try: + with urllib.request.urlopen(url) as response, open(dest_path, 'wb') as out_file: + total = int(response.getheader('Content-Length', 0)) + downloaded = 0 + chunk = 8192 + while True: + if cancel_flag.is_set(): + break + data = response.read(chunk) + if not data: + break + out_file.write(data) + downloaded += len(data) + percent = int((downloaded / total) * 100) if total else 0 + info['progress_var'].set(percent) + info['percent_var'].set(f"{percent}%") + self.cards_frame.after(50, self.refresh_cards) + if not cancel_flag.is_set(): + messagebox.showinfo("Success", f"Model downloaded to {dest_path}") + except Exception as e: + if not cancel_flag.is_set(): + messagebox.showerror("Download Failed", str(e)) + finally: + del self.active_downloads[filename] + self.cards_frame.after(100, self.refresh_cards) + t = threading.Thread(target=download, daemon=True) + info['thread'] = t + t.start() + + def cancel_download(self, filename): + info = self.active_downloads.get(filename) + if info: + info['cancel_flag'].set() + del self.active_downloads[filename] + self.refresh_cards() diff --git a/plugin_system.py b/plugin_system.py new file mode 100644 index 0000000..2293970 --- /dev/null +++ b/plugin_system.py @@ -0,0 +1,35 @@ +# Plugin system scaffold for LM-Gambit +# Drop .py files in the plugins/ directory. Each should define a 'register' function. + +import importlib.util +import sys +from pathlib import Path + +PLUGIN_DIR = Path(__file__).parent / "plugins" + +class PluginManager: + def __init__(self): + self.plugins = [] + self.load_plugins() + + def load_plugins(self): + if not PLUGIN_DIR.exists(): + return + for file in PLUGIN_DIR.glob("*.py"): + name = file.stem + spec = importlib.util.spec_from_file_location(name, file) + if spec and spec.loader: + mod = importlib.util.module_from_spec(spec) + sys.modules[name] = mod + spec.loader.exec_module(mod) + if hasattr(mod, "register"): + self.plugins.append(mod) + + def run_hook(self, hook_name, *args, **kwargs): + for plugin in self.plugins: + hook = getattr(plugin, hook_name, None) + if callable(hook): + try: + hook(*args, **kwargs) + except Exception as e: + print(f"Plugin {plugin.__name__} hook {hook_name} failed: {e}") diff --git a/prompt_playground.py b/prompt_playground.py new file mode 100644 index 0000000..85851f4 --- /dev/null +++ b/prompt_playground.py @@ -0,0 +1,39 @@ +import tkinter as tk +from tkinter import ttk, scrolledtext, messagebox + +class PlaygroundDialog(tk.Toplevel): + def __init__(self, parent, send_callback): + super().__init__(parent) + self.title("Prompt Playground") + self.geometry("700x600") + self.transient(parent) + self.grab_set() + self.send_callback = send_callback + + ttk.Label(self, text="Prompt:").pack(anchor="w", padx=10, pady=(10,0)) + self.prompt_text = scrolledtext.ScrolledText(self, height=8, wrap="word") + self.prompt_text.pack(fill="x", padx=10, pady=(0,10)) + + ttk.Button(self, text="Send", command=self.send_prompt).pack(pady=(0,10)) + + ttk.Label(self, text="Response:").pack(anchor="w", padx=10) + self.response_text = scrolledtext.ScrolledText(self, height=18, wrap="word") + self.response_text.pack(fill="both", expand=True, padx=10, pady=(0,10)) + self.response_text.configure(state="disabled") + + def send_prompt(self): + prompt = self.prompt_text.get("1.0", "end-1c").strip() + if not prompt: + messagebox.showwarning("Prompt Required", "Please enter a prompt.") + return + self.response_text.configure(state="normal") + self.response_text.delete("1.0", "end") + self.response_text.insert("1.0", "Running...") + self.response_text.update() + try: + response = self.send_callback(prompt) + except Exception as e: + response = f"Error: {e}" + self.response_text.delete("1.0", "end") + self.response_text.insert("1.0", response) + self.response_text.configure(state="disabled") diff --git a/requirements.txt b/requirements.txt index 3f3833a..e81b3ca 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,10 @@ +# Core dependencies requests>=2.31.0 llama-cpp-python>=0.2.82 -mlx-lm>=0.10.0; platform_system == "Darwin" + +# Apple Silicon (MLX) support (only needed on macOS ARM) +mlx-lm>=0.10.0; platform_system == "Darwin" and platform_machine == "arm64" + +# GUI and plotting +matplotlib>=3.0 +tkhtmlview diff --git a/results_chart.py b/results_chart.py new file mode 100644 index 0000000..817ab2d --- /dev/null +++ b/results_chart.py @@ -0,0 +1,46 @@ +import tkinter as tk +from tkinter import ttk +from pathlib import Path +import matplotlib +matplotlib.use('Agg') +import matplotlib.pyplot as plt +from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg +import re + +def parse_report(report_path: Path): + # Simple parser for markdown report: looks for lines like 'Pass: 8/10', 'Fail: 2/10', etc. + pass_count = 0 + fail_count = 0 + total = 0 + with open(report_path, 'r', encoding='utf-8') as f: + for line in f: + m = re.match(r'Pass: (\d+)/(\d+)', line) + if m: + pass_count = int(m.group(1)) + total = int(m.group(2)) + m = re.match(r'Fail: (\d+)/(\d+)', line) + if m: + fail_count = int(m.group(1)) + return pass_count, fail_count, total + +class ResultsChartDialog(tk.Toplevel): + def __init__(self, parent, report_path: Path): + super().__init__(parent) + self.title(f"Test Results Chart: {report_path.name}") + self.geometry("600x500") + self.transient(parent) + self.grab_set() + + pass_count, fail_count, total = parse_report(report_path) + fig, ax = plt.subplots(figsize=(5,4)) + ax.bar(['Pass', 'Fail'], [pass_count, fail_count], color=['#4caf50', '#f44336']) + ax.set_ylabel('Count') + ax.set_title(f"Test Results ({pass_count+fail_count}/{total} completed)") + for i, v in enumerate([pass_count, fail_count]): + ax.text(i, v + 0.1, str(v), ha='center', va='bottom', fontweight='bold') + fig.tight_layout() + + canvas = FigureCanvasTkAgg(fig, master=self) + canvas.draw() + canvas.get_tk_widget().pack(fill="both", expand=True) + ttk.Button(self, text="Close", command=self.destroy).pack(pady=8) diff --git a/settings_dialog.py b/settings_dialog.py new file mode 100644 index 0000000..6a3548a --- /dev/null +++ b/settings_dialog.py @@ -0,0 +1,135 @@ +import tkinter as tk +from tkinter import ttk, messagebox, filedialog +import sys +import os +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '.core')) +from settings import load_settings, save_settings +from config import DEFAULT_TEMPERATURE, DEFAULT_PROVIDER_NAME +from providers import list_provider_names + +class SettingsDialog(tk.Toplevel): + def __init__(self, parent): + super().__init__(parent) + self.title("Settings") + self.geometry("500x400") + self.transient(parent) + self.grab_set() + self.settings = load_settings() + + notebook = ttk.Notebook(self) + notebook.pack(fill="both", expand=True, padx=10, pady=10) + + # General tab + general_frame = ttk.Frame(notebook) + notebook.add(general_frame, text="General") + + ttk.Label(general_frame, text="Default Provider:").grid(row=0, column=0, sticky="w", pady=6) + self.provider_var = tk.StringVar(value=self.settings.get("default_provider", DEFAULT_PROVIDER_NAME)) + self.provider_combo = ttk.Combobox(general_frame, textvariable=self.provider_var, values=list_provider_names(), state="readonly", width=32) + self.provider_combo.grid(row=0, column=1, sticky="ew", padx=8) + + ttk.Label(general_frame, text="Default Temperature:").grid(row=1, column=0, sticky="w", pady=6) + self.temp_var = tk.DoubleVar(value=self.settings.get("default_temperature", DEFAULT_TEMPERATURE)) + self.temp_entry = ttk.Entry(general_frame, textvariable=self.temp_var, width=8) + self.temp_entry.grid(row=1, column=1, sticky="w", padx=8) + + # Model paths tab + paths_frame = ttk.Frame(notebook) + notebook.add(paths_frame, text="Model Paths") + # Model paths now as list of dicts: {"nickname": str, "path": str} + self.paths = self.settings.get("local_model_paths", []) + if self.paths and isinstance(self.paths[0], str): + # Migrate from old format + self.paths = [{"nickname": os.path.basename(p.rstrip("/")), "path": p} for p in self.paths] + self.paths_list = tk.Listbox(paths_frame, selectmode=tk.SINGLE, width=60, height=8) + self.paths_list.pack(fill="x", padx=8, pady=(8,4)) + self.refresh_paths_list() + + btn_frame = ttk.Frame(paths_frame) + btn_frame.pack(fill="x", padx=8, pady=4) + ttk.Button(btn_frame, text="Add Path", command=self.add_path).pack(side="left") + ttk.Button(btn_frame, text="Edit Selected", command=self.edit_path).pack(side="left", padx=(8,0)) + ttk.Button(btn_frame, text="Remove Selected", command=self.remove_path).pack(side="left", padx=(8,0)) + + # Key/legend for nicknames + key_frame = ttk.Frame(paths_frame) + key_frame.pack(fill="x", padx=8, pady=(8, 4)) + key_text = ( + "Nickname: A short label for this model path. " + "You can use nicknames to quickly identify or group model folders. " + "For example, use 'Main', 'Backup', or 'Experimental'. " + "Nicknames are for your reference only and do not affect model loading." + ) + ttk.Label(key_frame, text=key_text, wraplength=420, foreground="#878787").pack(anchor="w") + + def refresh_paths_list(self): + self.paths_list.delete(0, "end") + for entry in self.paths: + display = f"{entry.get('nickname','')} — {entry.get('path','')}" + self.paths_list.insert("end", display) + + def add_path(self): + self.edit_path(new=True) + + def edit_path(self, new=False): + idx = None + if not new: + sel = self.paths_list.curselection() + if not sel: + return + idx = sel[0] + entry = self.paths[idx] + else: + entry = {"nickname": "", "path": ""} + popup = tk.Toplevel(self) + popup.title("Edit Model Path") + popup.geometry("400x140") + tk.Label(popup, text="Nickname:").pack(anchor="w", padx=12, pady=(12,0)) + nick_var = tk.StringVar(value=entry.get("nickname", "")) + nick_entry = ttk.Entry(popup, textvariable=nick_var, width=40) + nick_entry.pack(fill="x", padx=12) + tk.Label(popup, text="Path:").pack(anchor="w", padx=12, pady=(8,0)) + path_var = tk.StringVar(value=entry.get("path", "")) + path_entry = ttk.Entry(popup, textvariable=path_var, width=40) + path_entry.pack(fill="x", padx=12) + def save_edit(): + new_entry = {"nickname": nick_var.get().strip(), "path": path_var.get().strip()} + if not new_entry["path"]: + messagebox.showerror("Error", "Path cannot be empty.", parent=popup) + return + if new: + self.paths.append(new_entry) + else: + self.paths[idx] = new_entry + self.refresh_paths_list() + popup.destroy() + btns = ttk.Frame(popup) + btns.pack(fill="x", pady=10) + ttk.Button(btns, text="Save", command=save_edit).pack(side="right", padx=12) + ttk.Button(btns, text="Cancel", command=popup.destroy).pack(side="right") + + # Save/Cancel + save_btn = ttk.Button(self, text="Save", command=self.save) + save_btn.pack(side="right", padx=10, pady=10) + cancel_btn = ttk.Button(self, text="Cancel", command=self.destroy) + cancel_btn.pack(side="right", pady=10) + + def add_path(self): + selection = filedialog.askdirectory(parent=self) + if selection and selection not in self.paths_list.get(0, "end"): + self.paths_list.insert("end", selection) + + def remove_path(self): + sel = self.paths_list.curselection() + if sel: + idx = sel[0] + del self.paths[idx] + self.refresh_paths_list() + + def save(self): + self.settings["default_provider"] = self.provider_var.get().strip() + self.settings["default_temperature"] = self.temp_var.get() + self.settings["local_model_paths"] = self.paths + save_settings(self.settings) + messagebox.showinfo("Settings", "Settings saved.") + self.destroy() diff --git a/test_editor.py b/test_editor.py new file mode 100644 index 0000000..b96572b --- /dev/null +++ b/test_editor.py @@ -0,0 +1,34 @@ +import tkinter as tk +from tkinter import simpledialog, messagebox +from pathlib import Path + +class TestEditorDialog(simpledialog.Dialog): + def __init__(self, parent, test_dir: Path): + self.test_dir = test_dir + super().__init__(parent, title="New/Edit Test") + + def body(self, master): + tk.Label(master, text="Test Filename:").grid(row=0, column=0, sticky="w") + self.filename_entry = tk.Entry(master, width=40) + self.filename_entry.grid(row=0, column=1, padx=6, pady=4) + + tk.Label(master, text="Prompt:").grid(row=1, column=0, sticky="nw") + self.prompt_text = tk.Text(master, width=60, height=10) + self.prompt_text.grid(row=1, column=1, padx=6, pady=4) + + tk.Label(master, text="Expected Output:").grid(row=2, column=0, sticky="nw") + self.output_text = tk.Text(master, width=60, height=10) + self.output_text.grid(row=2, column=1, padx=6, pady=4) + return self.filename_entry + + def apply(self): + filename = self.filename_entry.get().strip() + prompt = self.prompt_text.get("1.0", "end").strip() + output = self.output_text.get("1.0", "end").strip() + if not filename or not prompt: + messagebox.showerror("Error", "Filename and prompt are required.") + return + file_path = self.test_dir / filename + with open(file_path, "w", encoding="utf-8") as f: + f.write(f"PROMPT:\n{prompt}\n\nEXPECTED:\n{output}\n") + messagebox.showinfo("Saved", f"Test saved as {file_path.name}")