Files
2026-01-13 14:06:34 +00:00

2905 lines
115 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
KTransformers Interactive Model Manager
A Textual-based TUI for managing AI models.
"""
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Container, Vertical, Horizontal, ScrollableContainer, Center
from textual.widgets import Header, Footer, DataTable, Static, Label, Button, Input, Select, Checkbox
from textual.screen import ModalScreen
from textual.worker import Worker, WorkerState
from textual.message import Message
from pathlib import Path
from typing import Optional, List, Tuple, Dict, Any
import asyncio
# Local - Screen modules
from .screens.dialogs import ConfirmDialog, PathInputScreen, PathSelectScreen
from .screens.model_info import (
InfoScreen,
EditModelScreen,
RenameInputScreen,
RepoEditScreen,
RepoInputScreen,
AutoRepoSelectScreen,
)
from .screens.config import QuantConfigScreen, RunConfigScreen
from .screens.progress import QuantProgressScreen, DownloadProgressScreen
from .screens.download import DownloadScreen
from .screens.linking import LinkModelsScreen
from .screens.system import DoctorScreen, SettingsScreen
# InfoScreen, RepoEditScreen, RepoInputScreen, RenameInputScreen,
# AutoRepoSelectScreen, EditModelScreen moved to screens/model_info.py
# QuantConfigScreen moved to screens/config.py
# QuantProgressScreen moved to screens/
# DoctorScreen moved to screens/
# LinkModelsScreen moved to screens/
# DownloadScreen moved to screens/
# DownloadProgressScreen moved to screens/
class SettingsScreen(ModalScreen):
"""Modal screen for application settings"""
CSS = """
SettingsScreen {
align: center middle;
}
#settings-dialog {
width: 90;
height: auto;
max-height: 80%;
border: thick $accent;
background: $surface;
padding: 1 2;
}
#settings-content {
width: 100%;
height: auto;
max-height: 30;
overflow-y: auto;
padding: 1 0;
}
#settings-actions {
width: 100%;
height: auto;
padding: 1 0;
}
#settings-actions Button {
margin: 0 1;
}
#settings-buttons {
width: 100%;
height: auto;
align: center middle;
padding: 1 0 0 0;
}
#settings-buttons Button {
margin: 0 1;
}
"""
def __init__(self):
super().__init__()
def compose(self) -> ComposeResult:
from kt_kernel.cli.config.settings import get_settings
settings = get_settings()
model_paths = settings.get_model_paths()
with Container(id="settings-dialog"):
yield Label("[bold cyan]⚙ Settings[/bold cyan]")
yield Label("")
with Container(id="settings-content"):
yield Label("[bold]Model Paths:[/bold]")
for i, path in enumerate(model_paths, 1):
exists = "✓" if path.exists() else "✗"
yield Label(f" {i}. {exists} {path}")
yield Label("")
yield Label("[dim]Config: {0}[/dim]".format(settings.config_path))
with Horizontal(id="settings-actions"):
yield Button("➕ Add Path", id="btn-add-path", variant="success")
yield Button("✏ Edit Path", id="btn-edit-path")
yield Button("➖ Remove Path", id="btn-remove-path", variant="error")
yield Label("")
with Horizontal(id="settings-buttons"):
yield Button("🔄 Force Refresh All", id="btn-force-refresh", variant="warning")
yield Button("🔍 Auto-Repo", id="btn-auto-repo", variant="success")
yield Button("✓ Verify-All", id="btn-verify-all", variant="primary")
yield Button("Close", id="btn-close")
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "btn-close":
self.dismiss(None)
elif event.button.id == "btn-force-refresh":
self.dismiss("force_refresh")
elif event.button.id == "btn-add-path":
self.dismiss("add_path")
elif event.button.id == "btn-edit-path":
self.dismiss("edit_path")
elif event.button.id == "btn-remove-path":
self.dismiss("remove_path")
elif event.button.id == "btn-auto-repo":
self.dismiss("auto_repo")
elif event.button.id == "btn-verify-all":
self.dismiss("verify_all")
def on_key(self, event) -> None:
"""Handle key presses"""
if event.key == "escape":
self.dismiss(None)
# EditModelScreen moved to screens/model_info.py
class ModelManagerApp(App):
"""Interactive Model Manager TUI Application"""
class VerifyProgress(Message):
"""Message sent during verification progress"""
def __init__(
self, model_id: str, progress_msg: str, total: int = None, current: int = None, is_milestone: bool = False
) -> None:
self.model_id = model_id
self.progress_msg = progress_msg
self.total = total
self.current = current
self.is_milestone = is_milestone
super().__init__()
class VerifyComplete(Message):
"""Message sent when verification completes"""
def __init__(self, model_id: str, result: Dict[str, Any]) -> None:
self.model_id = model_id
self.result = result
super().__init__()
CSS_PATH = "app.tcss"
TITLE = "KTransformers Model Manager"
BINDINGS = [
Binding("q", "quit", "Quit", priority=True),
Binding("r", "refresh", "Refresh"),
Binding("s", "scan", "Scan"),
Binding("c", "settings", "Settings"),
Binding("d", "download", "Download"),
Binding("o", "doctor", "Doctor"),
Binding("enter", "run", "Run Model"),
Binding("1", "action_1", "", show=False),
Binding("2", "action_2", "", show=False),
Binding("3", "action_3", "", show=False),
Binding("4", "action_4", "", show=False),
Binding("5", "action_5", "", show=False),
Binding("6", "action_6", "", show=False),
Binding("7", "action_7", "", show=False),
Binding("tab", "next_table", "Next Section", show=False),
Binding("shift+tab", "prev_table", "Prev Section", show=False),
]
def __init__(self):
super().__init__()
self.selected_model = None
self.moe_results = {}
self.active_table_id = "gpu-table"
self.linked_cpu_rows = [] # Track which CPU rows are linked to selected GPU
self.info_screen_open = False # Track if info screen is currently displayed
def compose(self) -> ComposeResult:
"""Create child widgets for the app"""
yield Header(show_clock=True)
with ScrollableContainer(id="main-container"):
# MoE GPU Models Section
with Vertical(id="moe-section", classes="model-section"):
yield Label("MoE Models (GPU)", classes="section-header")
yield DataTable(id="gpu-table", classes="model-table", cursor_type="row")
# GGUF Models Section
with Vertical(id="gguf-section", classes="model-section"):
yield Label("GGUF Models (Llamafile)", classes="section-header")
yield DataTable(id="gguf-table", classes="model-table", cursor_type="row")
# AMX Models Section
with Vertical(id="amx-section", classes="model-section"):
yield Label("AMX Models (CPU)", classes="section-header")
yield DataTable(id="amx-table", classes="model-table", cursor_type="row")
# Non-MoE GPU Models Section (Last, informational only)
with Vertical(id="non-moe-section", classes="model-section"):
yield Label("Non-MoE GPU Models (Not Supported)", classes="section-header")
yield DataTable(id="non-moe-table", classes="model-table", cursor_type="none")
# Verification progress widget
yield Static("", id="verify-progress", classes="hidden")
# Action Panel
with Container(id="action-panel"):
yield Static("No model selected", id="selected-info")
yield Static("") # Empty line for spacing
with Horizontal(id="action-buttons"):
yield Static("[1] Run", classes="action-btn")
yield Static("[2] Edit", classes="action-btn")
yield Static("[3] Verify", classes="action-btn")
yield Static("[4] Quant", classes="action-btn")
yield Static("[5] Remove", classes="action-btn")
yield Static("[6] Info", classes="action-btn")
yield Static("[7] Links", classes="action-btn")
yield Footer()
def on_mount(self) -> None:
"""Initialize when app starts"""
self.title = "KTransformers Model Manager"
self.sub_title = "Loading models..."
# Set up tables
self.setup_tables()
# Load models
self.load_models()
def setup_tables(self) -> None:
"""Configure DataTable columns"""
# MoE GPU table
gpu_table = self.query_one("#gpu-table", DataTable)
# Exps = Experts×Layers (e.g., 256×64 = 256 experts per layer, 64 layers)
gpu_table.add_columns("Name", "Total", "Exps×Layers", "1-Expert", "Skeleton", "Repo", "SHA256", "Path")
gpu_table.cursor_type = "row"
gpu_table.zebra_stripes = True
gpu_table.focus()
# GGUF table
gguf_table = self.query_one("#gguf-table", DataTable)
gguf_table.add_columns("Name", "Size", "GPU Links", "Repo", "SHA256", "Path")
gguf_table.cursor_type = "row"
gguf_table.zebra_stripes = True
# AMX table
amx_table = self.query_one("#amx-table", DataTable)
amx_table.add_columns("Name", "Format", "NUMA", "Size", "GPU Links", "Path")
amx_table.cursor_type = "row"
amx_table.zebra_stripes = True
# Non-MoE GPU table (last, not selectable)
non_moe_table = self.query_one("#non-moe-table", DataTable)
non_moe_table.add_columns("Name", "Size", "Repo", "SHA256", "Reason", "Path")
non_moe_table.cursor_type = "none" # Not selectable
non_moe_table.zebra_stripes = True
def load_models(self) -> None:
"""Load models from registry - runs analysis in background"""
# Start async loading in background worker
self.run_worker(self._load_models_async(), name="load-models", group="load", exclusive=True)
async def _load_models_async(self) -> None:
"""Async version: Load models from registry with MoE analysis in background"""
import threading
try:
from kt_kernel.cli.utils.user_model_registry import UserModelRegistry
from kt_kernel.cli.commands.model import is_amx_weights
from kt_kernel.cli.utils.model_scanner import format_size
from kt_kernel.cli.utils.analyze_moe_model import analyze_moe_model
# Check if we're in a worker thread
is_worker = threading.current_thread() != threading.main_thread()
registry = UserModelRegistry()
models = registry.list_models()
# Categorize models
gpu_models = []
gguf_models = []
amx_models = []
for model in models:
if not model.path_exists():
continue
if model.format == "gguf":
gguf_models.append(model)
elif model.format == "safetensors":
is_amx, numa_count = is_amx_weights(model.path)
if is_amx:
amx_models.append((model, numa_count))
else:
gpu_models.append(model)
# Pre-analyze GPU models and categorize into MoE and Non-MoE
moe_models = []
non_moe_models = []
if gpu_models:
if is_worker:
self.call_from_thread(setattr, self, "sub_title", f"🔬 Analyzing {len(gpu_models)} GPU models...")
else:
self.sub_title = f"🔬 Analyzing {len(gpu_models)} GPU models..."
for i, model in enumerate(gpu_models):
# Update progress in subtitle
if is_worker:
self.call_from_thread(
setattr, self, "sub_title", f"🔬 Analyzing GPU models... ({i+1}/{len(gpu_models)})"
)
else:
self.sub_title = f"🔬 Analyzing GPU models... ({i+1}/{len(gpu_models)})"
try:
result = analyze_moe_model(model.path, use_cache=True)
if result and result.get("num_experts", 0) > 0:
# Valid MoE model
self.moe_results[model.id] = result
moe_models.append(model)
else:
# Not a MoE model or has 0 experts
reason = "Not a MoE model"
non_moe_models.append((model, reason))
except Exception as e:
# Analysis failed
reason = f"Analysis failed"
non_moe_models.append((model, reason))
# Calculate total before UI update
total = len(moe_models) + len(non_moe_models) + len(gguf_models) + len(amx_models)
# Populate tables and update UI
def update_ui():
self.populate_gpu_table(moe_models)
self.populate_non_moe_table(non_moe_models)
self.populate_gguf_table(gguf_models)
self.populate_amx_table(amx_models)
# Update subtitle
self.sub_title = f"Total: {total} models | MoE: {len(moe_models)} | Non-MoE: {len(non_moe_models)} | GGUF: {len(gguf_models)} | AMX: {len(amx_models)}"
# Auto-select first model in GPU table
gpu_table = self.query_one("#gpu-table", DataTable)
if gpu_table.row_count > 0:
gpu_table.move_cursor(row=0)
if is_worker:
self.call_from_thread(update_ui)
else:
update_ui()
# Check if no models found - auto scan on first run
if total == 0 and not hasattr(self, "_initial_scan_done"):
self._initial_scan_done = True
# Check if this is truly first run (no paths configured or only default empty path)
from kt_kernel.cli.config.settings import get_settings, DEFAULT_MODELS_DIR
settings = get_settings()
model_paths = settings.get_model_paths()
# Check if we only have the default path and it doesn't exist (first run)
is_first_run = not model_paths or (
len(model_paths) == 1
and str(model_paths[0]) == str(DEFAULT_MODELS_DIR)
and not model_paths[0].exists()
)
if is_first_run:
# First run - automatically scan all disks in background
if is_worker:
self.call_from_thread(
self.notify,
"🌍 First run detected - scanning all disks for models...",
"information",
5,
)
else:
self.notify("🌍 First run detected - scanning all disks for models...", "information", 5)
# Note: Cannot call run_worker from worker thread - global scan will be triggered on next load
await self._global_scan_async()
else:
# Paths exist but no models - just notify
if is_worker:
self.call_from_thread(
self.notify,
"No models found. Add models or scan paths in Settings (press 's' or '7')",
"warning",
5,
)
else:
self.notify(
"No models found. Add models or scan paths in Settings (press 's' or '7')", "warning", 5
)
except Exception as e:
if is_worker:
self.call_from_thread(setattr, self, "sub_title", f"Error loading models: {e}")
else:
self.sub_title = f"Error loading models: {e}"
def populate_gpu_table(self, models: List) -> None:
"""Populate GPU models table"""
from kt_kernel.cli.utils.model_scanner import format_size
table = self.query_one("#gpu-table", DataTable)
table.clear()
for model in models:
# Calculate total size
try:
model_path = Path(model.path)
files = list(model_path.glob("*.safetensors"))
total_size = sum(f.stat().st_size for f in files if f.exists())
size_str = format_size(total_size)
except:
size_str = "-"
# Get repo info with prefix
if model.repo_id:
prefix = "hf:" if model.repo_type == "huggingface" else "ms:" if model.repo_type == "modelscope" else ""
repo_str = f"{prefix}{model.repo_id}"
else:
repo_str = "-"
# Get SHA256 status
sha256_map = {"passed": "✓", "failed": "✗", "not_checked": "-", "checking": "...", "no_repo": "-"}
sha256_str = sha256_map.get(model.sha256_status, "-")
# MoE info from pre-analysis
moe_result = self.moe_results.get(model.id)
if moe_result and "error" not in moe_result:
# Success - show MoE data
num_experts = moe_result.get("num_experts", 0)
num_layers = moe_result.get("num_layers", 0)
# Format: 256×64 (experts per layer × number of layers)
exps = f"{num_experts}×{num_layers}"
expert_size_gb = moe_result.get("single_expert_size_gb", 0)
expert_size = format_size(expert_size_gb * 1024**3)
skeleton_gb = moe_result.get("rest_size_gb", 0)
skeleton = format_size(skeleton_gb * 1024**3)
elif moe_result and "error" in moe_result:
# Failed analysis
exps = "✗"
expert_size = "Failed"
skeleton = "-"
else:
# No analysis result
exps = "-"
expert_size = "-"
skeleton = "-"
table.add_row(
model.name,
size_str, # Total
exps, # Exps×Layers
expert_size, # 1-Expert
skeleton, # Skeleton
repo_str, # Repo
sha256_str, # SHA256
model.path, # Path
key=model.id,
)
def populate_non_moe_table(self, models: List[Tuple]) -> None:
"""Populate Non-MoE GPU models table"""
from kt_kernel.cli.utils.model_scanner import format_size
table = self.query_one("#non-moe-table", DataTable)
table.clear()
for model, reason in models:
# Calculate total size
try:
model_path = Path(model.path)
files = list(model_path.glob("*.safetensors"))
total_size = sum(f.stat().st_size for f in files if f.exists())
size_str = format_size(total_size)
except:
size_str = "-"
# Get repo info with prefix
if model.repo_id:
prefix = "hf:" if model.repo_type == "huggingface" else "ms:" if model.repo_type == "modelscope" else ""
repo_str = f"{prefix}{model.repo_id}"
else:
repo_str = "-"
# Get SHA256 status
sha256_map = {"passed": "✓", "failed": "✗", "not_checked": "-", "checking": "...", "no_repo": "-"}
sha256_str = sha256_map.get(model.sha256_status, "-")
table.add_row(model.name, size_str, repo_str, sha256_str, reason, model.path, key=model.id) # Path
def populate_gguf_table(self, models: List) -> None:
"""Populate GGUF models table"""
from kt_kernel.cli.utils.model_scanner import format_size
table = self.query_one("#gguf-table", DataTable)
table.clear()
from kt_kernel.cli.utils.user_model_registry import UserModelRegistry
registry = UserModelRegistry()
for model in models:
# Calculate size
try:
model_path = Path(model.path)
files = list(model_path.glob("*.gguf"))
total_size = sum(f.stat().st_size for f in files if f.exists())
size_str = format_size(total_size)
except:
size_str = "-"
# Get GPU links
gpu_links = "-"
if model.gpu_model_ids:
gpu_names = []
for gpu_id in model.gpu_model_ids:
gpu_obj = registry.get_model_by_id(gpu_id)
if gpu_obj:
gpu_names.append(gpu_obj.name)
if gpu_names:
gpu_links = ", ".join(gpu_names[:2])
if len(gpu_names) > 2:
gpu_links += f" +{len(gpu_names) - 2}"
# Get repo info with prefix
if model.repo_id:
prefix = "hf:" if model.repo_type == "huggingface" else "ms:" if model.repo_type == "modelscope" else ""
repo_str = f"{prefix}{model.repo_id}"
else:
repo_str = "-"
# Get SHA256 status
sha256_map = {"passed": "✓", "failed": "✗", "not_checked": "-", "checking": "...", "no_repo": "-"}
sha256_str = sha256_map.get(model.sha256_status, "-")
table.add_row(
model.name,
size_str,
gpu_links,
repo_str, # Repo
sha256_str, # SHA256
model.path, # Path
key=model.id,
)
def populate_amx_table(self, models: List[Tuple]) -> None:
"""Populate AMX models table"""
from kt_kernel.cli.utils.model_scanner import format_size
table = self.query_one("#amx-table", DataTable)
table.clear()
from kt_kernel.cli.utils.user_model_registry import UserModelRegistry
registry = UserModelRegistry()
for model, numa_count in models:
# Calculate size
try:
model_path = Path(model.path)
files = list(model_path.glob("*.safetensors"))
total_size = sum(f.stat().st_size for f in files if f.exists())
size_str = format_size(total_size)
except:
size_str = "-"
# Get GPU links
gpu_links = "-"
if model.gpu_model_ids:
gpu_names = []
for gpu_id in model.gpu_model_ids:
gpu_obj = registry.get_model_by_id(gpu_id)
if gpu_obj:
gpu_names.append(gpu_obj.name)
if gpu_names:
gpu_links = ", ".join(gpu_names[:2])
if len(gpu_names) > 2:
gpu_links += f" +{len(gpu_names) - 2}"
# Detect AMX quantization format
format_str = self._detect_amx_quant_type(model.path)
table.add_row(
model.name, format_str, f"{numa_count} NUMA", size_str, gpu_links, model.path, key=model.id
) # Path
def on_model_manager_app_verify_progress(self, message: VerifyProgress) -> None:
"""Handle verification progress message"""
try:
# Show all progress messages in the progress widget
progress_widget = self.query_one("#verify-progress", Static)
# Remove "hidden" class to show the widget
progress_widget.remove_class("hidden")
# Build progress display
lines = []
# Line 1: Title
lines.append("🔍 [bold cyan]Verifying Model[/bold cyan]")
# Line 2: Progress message
msg = message.progress_msg
# If we have total and current, show progress bar
if message.total and message.current is not None:
percentage = int((message.current / message.total) * 100)
bar_width = 30
filled = int((message.current / message.total) * bar_width)
bar = "█" * filled + "░" * (bar_width - filled)
# Extract the main message (remove [X/Y] prefix if present)
clean_msg = msg
if msg.startswith("[") and "]" in msg:
# Keep the [X/Y] part
bracket_end = msg.index("]")
counter = msg[: bracket_end + 1]
rest = msg[bracket_end + 1 :].strip()
# Show full message without truncation
lines.append(f"{counter} {percentage}% {bar}")
if rest:
lines.append(f" {rest}")
else:
lines.append(f"{percentage}% {bar}")
# Show full message without truncation
lines.append(f" {clean_msg}")
else:
# No progress info, just show full message
lines.append(f" {msg}")
# Update widget
progress_widget.update("\n".join(lines))
# Log for debugging
self.log.info(f"Verify progress: {message.progress_msg} ({message.current}/{message.total})")
except Exception as e:
self.log.error(f"Error updating progress: {e}")
def on_model_manager_app_verify_complete(self, message: VerifyComplete) -> None:
"""Handle verification completion message"""
from kt_kernel.cli.utils.user_model_registry import UserModelRegistry
try:
result = message.result
registry = UserModelRegistry()
# Get model by ID first
model = registry.get_model_by_id(message.model_id)
if not model:
self.notify("Model not found", severity="error")
self._hide_verify_progress()
return
# Update progress widget with final status
progress_widget = self.query_one("#verify-progress", Static)
if result["status"] == "passed":
final_msg = f"✓ Verification Passed: {result['files_passed']}/{result['files_checked']} files OK"
progress_widget.update(final_msg)
# Update model with correct parameter format: name and dictionary
registry.update_model(model.name, {"sha256_status": "passed"})
elif result["status"] == "failed":
failed_count = len(result.get("files_failed", []))
final_msg = f"✗ Verification Failed: {failed_count} file(s) failed"
progress_widget.update(final_msg)
registry.update_model(model.name, {"sha256_status": "failed"})
else:
# Error status
error_msg = result.get("error_message", "Unknown error")
# Show full error message without truncation
final_msg = f"✗ Verification Error: {error_msg}"
progress_widget.update(final_msg)
# Log completion
self.log.info(f"Verification complete: {final_msg}")
# Refresh to show updated status
self.load_models()
# Check if we're in verify-all (batch) mode
if hasattr(self, "_verify_models_queue") and self._verify_models_queue:
# Batch mode: continue with next model
self.log.info(f"Verify-all mode: {len(self._verify_models_queue)} models remaining")
next_model = self._verify_models_queue.pop(0)
self._start_verification(next_model, is_batch=True)
elif hasattr(self, "_verify_models_queue"):
# Batch mode complete (queue exists but empty)
self.notify("✓ Verification complete for all models", severity="information", timeout=5)
# Clean up batch state
delattr(self, "_verify_models_queue")
delattr(self, "_verify_total_count")
# Auto-hide after showing completion
self.set_timer(3.0, self._hide_verify_progress)
else:
# Single verification mode - auto-hide after 3 seconds
self.set_timer(3.0, self._hide_verify_progress)
except Exception as e:
self.log.error(f"Error in verify complete handler: {e}")
self._hide_verify_progress()
# If in verify-all mode and error occurred, continue with next
if hasattr(self, "_verify_models_queue") and self._verify_models_queue:
next_model = self._verify_models_queue.pop(0)
self._start_verification(next_model, is_batch=True)
def _hide_verify_progress(self) -> None:
"""Hide the verification progress widget"""
try:
progress_widget = self.query_one("#verify-progress", Static)
progress_widget.add_class("hidden")
self.log.info("Progress widget hidden")
except Exception as e:
self.log.warning(f"Could not hide progress widget: {e}")
def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
"""Handle row highlight (cursor movement) in any table"""
try:
from kt_kernel.cli.utils.user_model_registry import UserModelRegistry
# Close info screen if open when switching models
if self.info_screen_open:
# Pop the info screen
self.pop_screen()
self.info_screen_open = False
model_id = event.row_key.value
registry = UserModelRegistry()
self.selected_model = registry.get_model_by_id(model_id)
# Update which table is active
self.active_table_id = event.data_table.id
if self.selected_model:
self.update_action_panel()
# If GPU model selected, show linked CPU models
if event.data_table.id == "gpu-table":
self.show_linked_cpu_models(model_id)
else:
# Clear highlights if selecting non-GPU model
self.clear_cpu_highlights()
else:
# Model not found in registry - log warning
self.log.warning(f"Model {model_id} not found in registry")
except Exception as e:
# Catch any errors to prevent crashes
self.log.error(f"Error in row highlight handler: {e}")
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
"""Handle row selection (Enter key) - same as highlight"""
# Trigger run action when user presses Enter
self.action_run()
def show_linked_cpu_models(self, gpu_model_id: str) -> None:
"""Highlight CPU models linked to selected GPU model"""
# Safety check: ensure selected_model is set
if not self.selected_model:
return
from kt_kernel.cli.utils.user_model_registry import UserModelRegistry
# Clear previous highlights
self.clear_cpu_highlights()
try:
# Find CPU models that link to this GPU model
registry = UserModelRegistry()
all_models = registry.list_models()
amx_table = self.query_one("#amx-table", DataTable)
for model in all_models:
if model.gpu_model_ids and gpu_model_id in model.gpu_model_ids:
# This CPU model is linked to the selected GPU model
# Find its row in the AMX table and highlight it
for row_key in amx_table.rows:
if row_key.value == model.id:
self.linked_cpu_rows.append(row_key)
# Update row to show link indicator
current_values = list(amx_table.get_row(row_key))
if len(current_values) >= 4:
# Add indicator to GPU Links column
current_values[3] = f"→ {self.selected_model.name}"
amx_table.update_cell(row_key, "GPU Links", current_values[3])
break
except Exception as e:
# Silently ignore errors in highlighting to avoid crashes
self.log.warning(f"Error highlighting CPU models: {e}")
def clear_cpu_highlights(self) -> None:
"""Clear CPU model highlights"""
if not self.linked_cpu_rows:
return
try:
from kt_kernel.cli.utils.user_model_registry import UserModelRegistry
registry = UserModelRegistry()
amx_table = self.query_one("#amx-table", DataTable)
for row_key in self.linked_cpu_rows:
try:
# Restore original GPU links text
model_id = row_key.value
model = registry.get_model_by_id(model_id)
if model and model.gpu_model_ids:
gpu_names = []
for gpu_id in model.gpu_model_ids:
gpu_obj = registry.get_model_by_id(gpu_id)
if gpu_obj:
gpu_names.append(gpu_obj.name)
if gpu_names:
gpu_links = ", ".join(gpu_names[:2])
if len(gpu_names) > 2:
gpu_links += f" (+{len(gpu_names)-2})"
amx_table.update_cell(row_key, "GPU Links", gpu_links)
else:
amx_table.update_cell(row_key, "GPU Links", "-")
else:
amx_table.update_cell(row_key, "GPU Links", "-")
except Exception:
# Silently skip errors for individual rows
pass
self.linked_cpu_rows.clear()
except Exception as e:
# Catch any outer errors
self.log.warning(f"Error clearing CPU highlights: {e}")
self.linked_cpu_rows.clear()
def update_action_panel(self) -> None:
"""Update action buttons based on selected model"""
if not self.selected_model:
return
# Update selected info with repo status
info = self.query_one("#selected-info", Static)
model = self.selected_model
# Build info string with repo status
info_parts = [f"Selected: [cyan]{model.name}[/cyan] ({model.format})"]
# Show repo status for GPU and GGUF models (both can be verified)
if model.format in ["safetensors", "gguf"]:
if model.repo_id:
prefix = "hf:" if model.repo_type == "huggingface" else "ms:" if model.repo_type == "modelscope" else ""
repo_display = f"{prefix}{model.repo_id}"
info_parts.append(f"[dim]| Repo: {repo_display}[/dim]")
else:
info_parts.append(f"[dim yellow]| No repo (verification unavailable)[/dim yellow]")
info.update(" ".join(info_parts))
# Update action buttons based on model type
actions = self.get_actions_for_model(self.selected_model)
action_btns = self.query("#action-buttons > .action-btn")
for i, btn in enumerate(action_btns):
if i < len(actions):
btn.update(f"[{i+1}] {actions[i]}")
else:
btn.update("")
def get_actions_for_model(self, model) -> List[str]:
"""Get available actions for model type"""
from kt_kernel.cli.commands.model import is_amx_weights
if model.format == "gguf":
# GGUF models can also be verified if they have repo
verify_action = "Verify" if model.repo_id else "No Repo"
return ["Link GPU", "Edit", verify_action, "Unlink", "Remove", "Info"]
elif model.format == "safetensors":
is_amx, _ = is_amx_weights(model.path)
if is_amx:
# AMX models cannot be verified (no repo support)
return ["Link GPU", "Edit", "Unlink", "Remove", "Info", "Test"]
else:
# For GPU models, show Verify only if repo is set
verify_action = "Verify" if model.repo_id else "No Repo"
return ["Run", "Edit", verify_action, "Quant", "Remove", "Info", "Link CPU"]
return []
# Action handlers
def action_quit(self) -> None:
"""Quit the app"""
self.exit()
def action_refresh(self) -> None:
"""Refresh model list"""
self.sub_title = "Refreshing..."
self.load_models()
def action_run(self) -> None:
"""Run selected model (only for GPU MoE models)"""
if not self.selected_model:
self.notify("No model selected", severity="warning")
return
# Check if it's a GPU MoE model
if self.selected_model.format != "safetensors":
self.notify("Only GPU models can be run", severity="warning")
return
from kt_kernel.cli.commands.model import is_amx_weights
is_amx, _ = is_amx_weights(self.selected_model.path)
if is_amx:
self.notify("AMX models cannot be run directly", severity="warning")
return
# Get MoE result for this model
moe_result = self.moe_results.get(self.selected_model.id)
if not moe_result:
self.notify("This is not a MoE model", severity="warning")
return
# Show run configuration screen
self.push_screen(RunConfigScreen(self.selected_model, moe_result), callback=self._on_run_config)
def _on_run_config(self, config) -> None:
"""Handle run configuration result"""
if config is None:
return
try:
import sys
import subprocess
from pathlib import Path
from kt_kernel.cli.utils.user_model_registry import UserModelRegistry
from kt_kernel.cli.config.settings import get_settings
# Extract config
gpu_model = config["gpu_model"]
cpu_model_id = config["cpu_model_id"]
gpu_experts = config["gpu_experts"]
cpu_threads = config["cpu_threads"]
numa_nodes = config["numa_nodes"]
total_tokens = config["total_tokens"]
tensor_parallel = config.get("tensor_parallel", 1)
selected_gpus = config.get("selected_gpus", list(range(tensor_parallel)))
# Get CPU model from registry
registry = UserModelRegistry()
cpu_model = registry.get_model_by_id(cpu_model_id)
if not cpu_model:
self.notify("CPU model not found", severity="error")
return
# Detect kt-method based on CPU model format
kt_method = self._detect_kt_method(cpu_model)
self.log.info(f"Detected kt-method: {kt_method} for model {cpu_model.name} ({cpu_model.format})")
# Get settings
settings = get_settings()
host = settings.get("server.host", "0.0.0.0")
port = settings.get("server.port", 30000)
# Build sglang.launch_server command
cmd = [
sys.executable,
"-m",
"sglang.launch_server",
"--host",
str(host),
"--port",
str(port),
"--model",
str(gpu_model.path),
"--trust-remote-code",
"--mem-fraction-static",
"0.92",
"--chunked-prefill-size",
"4096",
"--max-running-requests",
"32",
"--max-total-tokens",
str(total_tokens),
"--watchdog-timeout",
"3000",
"--enable-mixed-chunk",
"--tensor-parallel-size",
str(tensor_parallel),
"--enable-p2p-check",
"--attention-backend",
"triton",
]
# Add kt-kernel parameters (only if using CPU offloading or quantized weights)
if cpu_threads > 0 or gpu_experts >= 0:
cmd.extend(
[
"--kt-weight-path",
str(cpu_model.path),
"--kt-cpuinfer",
str(cpu_threads),
"--kt-threadpool-count",
str(numa_nodes),
"--kt-num-gpu-experts",
str(gpu_experts),
"--kt-method",
kt_method,
"--kt-gpu-prefill-token-threshold",
"4096",
]
)
# Add served model name if available
if gpu_model.name:
cmd.extend(["--served-model-name", gpu_model.name])
# Build environment variables
import os
env = os.environ.copy()
# Set CUDA_VISIBLE_DEVICES to selected GPUs
if selected_gpus:
env["CUDA_VISIBLE_DEVICES"] = ",".join(str(gpu_id) for gpu_id in selected_gpus)
self.log.info(f"CUDA_VISIBLE_DEVICES: {env['CUDA_VISIBLE_DEVICES']}")
# Add default env vars
env.update(
{
"PYTORCH_ALLOC_CONF": "expandable_segments:True",
"SGLANG_ENABLE_JIT_DEEPGEMM": "0",
}
)
# Add custom env vars from config
inference_env = settings.get("inference.env", {})
if isinstance(inference_env, dict):
env.update({k: str(v) for k, v in inference_env.items()})
# Show command preview
cmd_str = " ".join(cmd)
self.notify(
f"Starting server: {gpu_model.name} (kt-method: {kt_method})", severity="information", timeout=5
)
self.log.info(f"Command: {cmd_str}")
# Execute command in new terminal or subprocess
# For now, just show the command and ask user to run it
self.notify("Server starting... Check terminal for output", severity="information", timeout=10)
# Execute in background
try:
process = subprocess.Popen(
cmd, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1
)
self.notify(f"✓ Server started (PID: {process.pid})", severity="information", timeout=5)
self.log.info(f"Server process PID: {process.pid}")
except Exception as e:
self.notify(f"Failed to start server: {e}", severity="error", timeout=10)
self.log.error(f"Server start error: {e}")
except Exception as e:
self.notify(f"Run config error: {e}", severity="error", timeout=10)
self.log.error(f"Run config error: {e}")
def _detect_amx_quant_type(self, model_path: str) -> str:
"""
Detect AMX quantization type from config.json metadata or model path
Priority:
1. Read from config.json amx_quantization.method (new format)
2. Fallback to path-based detection (legacy)
Returns:
"amx-int4", "amx-int8", "amx-awq", "amx-moe-int4", "amx-moe-int8", or "amx"
"""
from pathlib import Path
import json
path = Path(model_path)
# Priority 1: Read from config.json
config_path = path / "config.json"
if config_path.exists():
try:
with open(config_path, "r") as f:
config = json.load(f)
# Check for amx_quantization metadata
amx_meta = config.get("amx_quantization")
if amx_meta and amx_meta.get("converted"):
method = amx_meta.get("method", "").lower()
if method:
# Map method to display format
method_map = {
"int4": "amx-int4",
"int8": "amx-int8",
"awq": "amx-awq",
"moe_int4": "amx-moe-int4",
"moe_int8": "amx-moe-int8",
}
return method_map.get(method, f"amx-{method}")
except Exception:
pass # Fallback to path detection
# Priority 2: Fallback to path-based detection (legacy)
path_str = str(path).upper()
if "INT4" in path_str or "AMXINT4" in path_str:
return "amx-int4"
elif "INT8" in path_str or "AMXINT8" in path_str:
return "amx-int8"
elif "AWQ" in path_str:
return "amx-awq"
# Default to int4 as it's the most common
return "amx-int4"
def _detect_kt_method(self, cpu_model) -> str:
"""
Detect kt-method based on CPU model format
Returns:
"LLAMAFILE" for GGUF models
"AMXINT4" or "AMXINT8" for AMX safetensors models
"""
# GGUF models use LLAMAFILE
if cpu_model.format == "gguf":
return "LLAMAFILE"
# Safetensors (AMX) models - convert display format to kt-method
if cpu_model.format == "safetensors":
amx_type = self._detect_amx_quant_type(cpu_model.path)
# Convert display format to kt-method format
# "amx-int4" → "AMXINT4"
method_map = {
"amx-int4": "AMXINT4",
"amx-int8": "AMXINT8",
"amx-awq": "AMXAWQ",
"amx-moe-int4": "AMXINT4", # MoE still uses AMXINT4
"amx-moe-int8": "AMXINT8", # MoE still uses AMXINT8
}
return method_map.get(amx_type, "AMXINT4")
# Fallback
return "LLAMAFILE"
def action_edit(self) -> None:
"""Edit selected model"""
if not self.selected_model:
return
moe_info = self.moe_results.get(self.selected_model.id, {})
# Show edit dialog
self.push_screen(EditModelScreen(self.selected_model, moe_info), callback=self._on_edit_choice)
def _on_edit_choice(self, result) -> None:
"""Handle edit dialog result"""
if result == "rename":
self.edit_rename()
elif result == "repo":
self.edit_repo()
elif result == "delete":
self.edit_delete()
def edit_rename(self) -> None:
"""Handle rename action"""
# Show rename input dialog
self.push_screen(RenameInputScreen(self.selected_model.name), callback=self._on_rename_done)
def _on_rename_done(self, new_name) -> None:
"""Handle rename completion"""
from kt_kernel.cli.utils.user_model_registry import UserModelRegistry
if not new_name:
return
registry = UserModelRegistry()
old_name = self.selected_model.name
# Check for name conflicts
if registry.check_name_conflict(new_name, exclude_name=old_name):
self.notify(f"Model name '{new_name}' already exists", severity="error")
return
# Update the model name
success = registry.update_model(old_name, {"name": new_name})
if success:
self.notify(f"✓ Renamed '{old_name}' to '{new_name}'", severity="information")
self.load_models()
else:
self.notify(f"Failed to rename model", severity="error")
def edit_repo(self) -> None:
"""Handle repo edit action"""
# Show repo edit dialog
self.push_screen(RepoEditScreen(self.selected_model), callback=self._on_repo_edit_done)
def _on_repo_edit_done(self, result) -> None:
"""Handle repo edit result"""
from kt_kernel.cli.utils.user_model_registry import UserModelRegistry
if not result:
return
registry = UserModelRegistry()
model_name = self.selected_model.name
if result["action"] == "remove":
# Remove repo info
updates = {"repo_type": None, "repo_id": None, "sha256_status": "no_repo"}
registry.update_model(model_name, updates)
self.notify(f"✓ Removed repo info for {model_name}", severity="information")
self.load_models()
elif result["action"] == "set":
# Set repo info
updates = {"repo_type": result["repo_type"], "repo_id": result["repo_id"], "sha256_status": "not_checked"}
registry.update_model(model_name, updates)
prefix = (
"hf:" if result["repo_type"] == "huggingface" else "ms:" if result["repo_type"] == "modelscope" else ""
)
self.notify(f"✓ Updated repo: {prefix}{result['repo_id']}", severity="information")
self.load_models()
def edit_delete(self) -> None:
"""Handle delete action"""
from kt_kernel.cli.utils.user_model_registry import UserModelRegistry
registry = UserModelRegistry()
# Show confirmation dialog
self.push_screen(
ConfirmDialog(
f"Delete model '{self.selected_model.name}'?",
"This will remove the model from the registry (files will NOT be deleted).",
),
callback=self._on_delete_confirmed,
)
def _on_delete_confirmed(self, confirm) -> None:
"""Handle delete confirmation result"""
from kt_kernel.cli.utils.user_model_registry import UserModelRegistry
if confirm:
registry = UserModelRegistry()
model_name = self.selected_model.name
registry.remove_model(model_name)
self.notify(f"✓ Deleted {model_name}", severity="information")
self.load_models()
def _start_verification(self, model, is_batch=False) -> None:
"""
Start verification for a model (shared logic for single and batch verify)
Args:
model: Model to verify
is_batch: Whether this is part of batch verification
"""
try:
# Show verification progress widget
progress_widget = self.query_one("#verify-progress", Static)
progress_widget.remove_class("hidden")
# Update progress message based on mode
if is_batch and hasattr(self, "_verify_models_queue"):
# Batch mode: show progress in queue
remaining = len(self._verify_models_queue)
total = self._verify_total_count
current = total - remaining
progress_widget.update(
f"🔍 [bold cyan]Verify-All Progress[/bold cyan]\n"
f" [{current}/{total}] Verifying: {model.name}..."
)
else:
# Single model mode
progress_widget.update(
"🔍 [bold cyan]Verifying Model[/bold cyan]\n" f" Initializing verification for {model.name}..."
)
# Log
self.log.info(f"Starting verification for {model.name}")
# Run verification in background worker (same for both modes)
self.run_worker(
self.verify_model_async(model),
name=f"verify-{model.id}",
group="verification",
description=f"Verifying {model.name}",
)
except Exception as e:
self.notify(f"Error starting verification: {e}", severity="error")
self.log.error(f"Verification error: {e}")
# If in batch mode, continue with next
if is_batch and hasattr(self, "_verify_models_queue") and self._verify_models_queue:
model = self._verify_models_queue.pop(0)
self._start_verification(model, is_batch=True)
def action_verify(self) -> None:
"""Verify selected model"""
if not self.selected_model:
self.notify("No model selected", severity="warning")
return
# Verify requires repo_id
if not self.selected_model.repo_id:
self.notify(
f"Cannot verify '{self.selected_model.name}': No repository configured.\n"
f"Press 'e' (Edit) → 'Repo Info' to set HuggingFace or ModelScope repository.",
severity="warning",
timeout=8,
)
return
# Use shared verification logic
self._start_verification(self.selected_model, is_batch=False)
async def verify_model_async(self, model):
"""Async worker to verify model with progress updates"""
from kt_kernel.cli.utils.model_verifier import (
verify_model_integrity_with_progress,
check_huggingface_connectivity,
)
from kt_kernel.cli.utils.user_model_registry import UserModelRegistry
try:
# Determine repo type
repo_type = model.repo_type or "huggingface"
# Check HuggingFace connectivity and decide whether to use mirror
use_mirror = False
if repo_type == "huggingface":
# Check connectivity
is_accessible, message = await asyncio.to_thread(check_huggingface_connectivity, timeout=5)
if not is_accessible:
# HuggingFace is not accessible, use mirror
use_mirror = True
self.log.warning(f"HuggingFace not accessible: {message}")
self.log.info("Auto-switching to HuggingFace mirror: hf-mirror.com")
# Notify user
self.post_message(
self.VerifyProgress(
model.id, "⚠ HuggingFace not accessible, using mirror: hf-mirror.com", is_milestone=True
)
)
# Progress callback to update UI
def progress_callback(msg, total=None, current=None):
# Post progress message to UI with total and current info
# Determine if this is a milestone (phase change)
is_milestone = any(keyword in msg for keyword in ["Fetching", "Calculating", "Comparing", "Using"])
self.post_message(
self.VerifyProgress(model.id, msg, total=total, current=current, is_milestone=is_milestone)
)
# For GGUF models, only verify files that exist locally
# (since GGUF repos often contain multiple quantizations)
files_to_verify = None
if model.format == "gguf":
model_path = Path(model.path)
local_gguf_files = [f.name for f in model_path.glob("*.gguf") if f.is_file()]
if local_gguf_files:
files_to_verify = local_gguf_files
# Run verification with progress (with mirror support)
result = await asyncio.to_thread(
verify_model_integrity_with_progress,
repo_type=repo_type,
repo_id=model.repo_id,
local_dir=Path(model.path),
progress_callback=progress_callback,
files_to_verify=files_to_verify,
use_mirror=use_mirror, # Pass mirror flag
)
# Post completion message
self.post_message(self.VerifyComplete(model.id, result))
except Exception as e:
self.post_message(self.VerifyComplete(model.id, {"status": "error", "error_message": str(e)}))
def action_quant(self) -> None:
"""Quantize selected model"""
if not self.selected_model:
self.notify("No model selected", severity="warning")
return
# Only GPU models (safetensors, non-AMX) can be quantized
if self.selected_model.format != "safetensors":
self.notify("Only GPU models (safetensors) can be quantized", severity="warning")
return
from kt_kernel.cli.commands.model import is_amx_weights
is_amx, _ = is_amx_weights(self.selected_model.path)
if is_amx:
self.notify("This model is already quantized (AMX format)", severity="warning")
return
# Show quantization config screen
self.push_screen(QuantConfigScreen(self.selected_model), callback=self._on_quant_config_done)
def _on_quant_config_done(self, config) -> None:
"""Handle quantization config completion"""
if config is None:
# User cancelled
return
# Store config for the worker
self._quant_config = config
self._quant_config["model"] = self.selected_model
# Show progress screen and start quantization
progress_screen = QuantProgressScreen(self.selected_model.name)
self._quant_progress_screen = progress_screen
# Push screen first, then start worker after a small delay to ensure screen is mounted
self.push_screen(progress_screen, callback=self._on_quant_complete)
# Start quantization worker after screen is mounted (use call_later)
self.call_later(self._start_quant_worker)
def _start_quant_worker(self) -> None:
"""Start the quantization worker after screen is mounted"""
config = self._quant_config
model = config["model"]
progress_screen = self._quant_progress_screen
# Start quantization worker
self.run_worker(
self.quantize_model_async(model, config, progress_screen),
name=f"quant-{model.id}",
group="quantization",
exclusive=True,
)
async def quantize_model_async(self, model, config, progress_screen):
"""Async worker to quantize model with real-time output"""
import sys
import subprocess
from pathlib import Path
try:
# Update initial status
progress_screen.update_status("🔄 [bold yellow]Preparing quantization...[/bold yellow]")
# Find conversion script
try:
import kt_kernel
kt_kernel_path = Path(kt_kernel.__file__).parent.parent
except ImportError:
kt_kernel_path = None
if kt_kernel_path is None or not kt_kernel_path.exists():
raise Exception("kt-kernel not found. Install with: kt install inference")
script_path = kt_kernel_path / "scripts" / "convert_cpu_weights.py"
if not script_path.exists():
raise Exception(f"Conversion script not found: {script_path}")
# Build command
cmd = [
sys.executable,
str(script_path),
"--input-path",
model.path,
"--input-type",
config["input_type"],
"--output",
config["output_path"],
"--quant-method",
config["method"],
"--cpuinfer-threads",
str(config["cpu_threads"]),
"--threadpool-count",
str(config["numa_nodes"]),
]
# Add --gpu flag if enabled
if config.get("use_gpu", False):
cmd.append("--gpu")
progress_screen.append_output(f"Command: {' '.join(cmd)}\n")
progress_screen.update_status("🔄 [bold yellow]Quantizing model...[/bold yellow]")
# Run subprocess with real-time output
process = await asyncio.create_subprocess_exec(
*cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, cwd=str(kt_kernel_path)
)
# Read output line by line
while True:
line = await process.stdout.readline()
if not line:
break
line_str = line.decode("utf-8", errors="replace").rstrip()
if line_str:
progress_screen.append_output(line_str)
# Wait for completion
await process.wait()
if process.returncode == 0:
progress_screen.update_status(
"[bold green]✓ Quantization completed successfully![/bold green]", running=False
)
progress_screen.set_success(True)
return {
"status": "success",
"output_path": config["output_path"],
"method": config["method"],
"numa_nodes": config["numa_nodes"],
}
else:
progress_screen.update_status(
f"[bold red]✗ Quantization failed (exit code {process.returncode})[/bold red]", running=False
)
progress_screen.set_success(False)
return {"status": "error", "error": f"Exit code {process.returncode}"}
except Exception as e:
progress_screen.update_status(f"[bold red]✗ Error: {str(e)}[/bold red]", running=False)
progress_screen.set_success(False)
return {"status": "error", "error": str(e)}
def _on_quant_complete(self, success) -> None:
"""Handle quantization completion"""
if success:
# Quantization succeeded - add to model registry and set GPU links
from kt_kernel.cli.utils.user_model_registry import UserModelRegistry
from pathlib import Path
registry = UserModelRegistry()
config = self._quant_config
output_path = Path(config["output_path"])
self.log.info(f"Checking output path: {output_path}")
if not output_path.exists():
self.log.error(f"Output path does not exist: {output_path}")
self.notify(f"Quantization succeeded, but output path not found: {output_path}", severity="warning")
return
# Scan the quantized model directory
from kt_kernel.cli.utils.model_scanner import scan_directory
try:
self.log.info(f"Scanning directory: {output_path}")
scanned_models, warnings = scan_directory(
output_path, min_size_gb=0.1, exclude_paths=[] # Lower threshold for AMX models
)
self.log.info(f"Found {len(scanned_models)} models")
if not scanned_models:
self.log.error("No models found in scanned directory")
self.notify(
"Quantization succeeded, but no valid model files found in output directory", severity="warning"
)
return
# Add the quantized model
quantized_model = scanned_models[0]
self.log.info(f"Adding model: {quantized_model.folder_name}, format: {quantized_model.format}")
# Check if model already exists
existing = registry.find_by_path(str(output_path))
if existing:
self.log.info(f"Model already exists: {existing.name}")
quantized_name = existing.name
else:
from kt_kernel.cli.utils.user_model_registry import UserModel
# Suggest unique name to avoid conflicts
unique_name = registry.suggest_name(quantized_model.folder_name)
self.log.info(f"Using name: {unique_name} for quantized model")
new_model = UserModel(name=unique_name, path=str(output_path), format=quantized_model.format)
registry.add_model(new_model)
quantized_name = unique_name
self.notify(f"✓ Quantized model added: {quantized_name}", severity="information")
# Set GPU link: AMX model should link to original GPU model
# gpu_model_ids is for AMX/CPU models to store which GPU models they can run with
original_model_name = config["model"].name
self.log.info(f"Setting GPU link on AMX model: {quantized_name} → {original_model_name}")
# Get fresh copy of the original GPU model from registry
original_model_obj = registry.get_model(original_model_name)
if not original_model_obj or not original_model_obj.id:
self.log.error(f"Could not find original GPU model in registry: {original_model_name}")
self.notify(
f"✓ Quantized model added, but original GPU model not found for linking", severity="warning"
)
# Still reload models to show the new quantized model
self.load_models()
return
self.log.info(f"Original GPU model ID: {original_model_obj.id}")
# Get the quantized AMX model
quant_model_obj = registry.get_model(quantized_name)
if not quant_model_obj:
self.log.error(f"Could not find quantized model by name: {quantized_name}")
self.notify(f"✓ Quantized model added, but failed to retrieve it for GPU link", severity="warning")
# Still reload models to show the new quantized model
self.load_models()
return
# Set gpu_model_ids on the AMX model to point to the original GPU model
existing_links = quant_model_obj.gpu_model_ids or []
self.log.info(f"Existing GPU links for AMX model {quantized_name}: {existing_links}")
# Add original GPU model ID to AMX model's gpu_model_ids if not already there
if original_model_obj.id not in existing_links:
existing_links.append(original_model_obj.id)
success = registry.update_model(quantized_name, {"gpu_model_ids": existing_links})
if success:
self.log.info(f"Successfully updated GPU links on AMX model: {existing_links}")
self.notify(f"✓ GPU link set: {quantized_name} → {original_model_name}", severity="information")
else:
self.log.error(f"Failed to update GPU links on AMX model")
self.notify(f"✓ Quantized model added, but failed to update GPU link", severity="warning")
else:
self.log.info(f"GPU link already exists on AMX model")
self.notify(f"✓ Quantized model added (GPU link already exists)", severity="information")
# Reload models to show changes
self.load_models()
except Exception as e:
import traceback
self.log.error(f"Failed to add quantized model: {e}")
self.log.error(traceback.format_exc())
self.notify(f"Quantization succeeded, but failed to add model: {e}", severity="warning")
# Try to reload models anyway in case partial success
try:
self.load_models()
except:
pass
# Clean up
if hasattr(self, "_quant_config"):
delattr(self, "_quant_config")
if hasattr(self, "_quant_progress_screen"):
delattr(self, "_quant_progress_screen")
def action_link_gpu(self) -> None:
"""Link CPU model to GPU models"""
if not self.selected_model:
self.notify("No model selected", severity="warning")
return
# Show link screen
self.push_screen(LinkModelsScreen(self.selected_model, "gpu"), callback=self._on_link_models)
def action_link_cpu(self) -> None:
"""Link GPU model to CPU models"""
if not self.selected_model:
self.notify("No model selected", severity="warning")
return
# Show link screen
self.push_screen(LinkModelsScreen(self.selected_model, "cpu"), callback=self._on_link_models)
def action_unlink(self) -> None:
"""Clear all links for the selected model with bidirectional sync"""
if not self.selected_model:
self.notify("No model selected", severity="warning")
return
from kt_kernel.cli.utils.user_model_registry import UserModelRegistry
registry = UserModelRegistry()
all_models = registry.list_models()
# Get current links before clearing
old_links = set(self.selected_model.gpu_model_ids or [])
# Clear source model's links
success = registry.update_model(self.selected_model.name, {"gpu_model_ids": []})
if not success:
self.notify(f"Failed to clear links", severity="error")
return
# Bidirectional sync: remove this model from all previously linked models
for model_id in old_links:
target_model = next((m for m in all_models if m.id == model_id), None)
if target_model:
target_links = set(target_model.gpu_model_ids or [])
target_links.discard(self.selected_model.id)
registry.update_model(target_model.name, {"gpu_model_ids": list(target_links)})
self.notify(f"✓ Cleared all links for {self.selected_model.name} (bidirectional)", severity="information")
self.load_models()
def _on_link_models(self, selected_ids) -> None:
"""Handle link models screen result with bidirectional sync"""
if selected_ids is None:
return
from kt_kernel.cli.utils.user_model_registry import UserModelRegistry
registry = UserModelRegistry()
all_models = registry.list_models()
# Get current links before update
old_links = set(self.selected_model.gpu_model_ids or [])
new_links = set(selected_ids)
# Models that were added
added_links = new_links - old_links
# Models that were removed
removed_links = old_links - new_links
# Update source model
success = registry.update_model(self.selected_model.name, {"gpu_model_ids": list(selected_ids)})
if not success:
self.notify(f"Failed to update links", severity="error")
return
# Bidirectional sync: update all linked models
# For each newly added link, add source model to target's links
for model_id in added_links:
target_model = next((m for m in all_models if m.id == model_id), None)
if target_model:
target_links = set(target_model.gpu_model_ids or [])
target_links.add(self.selected_model.id)
registry.update_model(target_model.name, {"gpu_model_ids": list(target_links)})
# For each removed link, remove source model from target's links
for model_id in removed_links:
target_model = next((m for m in all_models if m.id == model_id), None)
if target_model:
target_links = set(target_model.gpu_model_ids or [])
target_links.discard(self.selected_model.id)
registry.update_model(target_model.name, {"gpu_model_ids": list(target_links)})
count = len(selected_ids)
self.notify(f"✓ Updated links: {count} model(s) linked (bidirectional)", severity="information")
self.load_models()
def action_remove(self) -> None:
"""Remove selected model with confirmation dialog"""
if not self.selected_model:
self.notify("No model selected", severity="warning")
return
# Show confirmation dialog
message = f"Are you sure you want to remove:\n\n"
message += f" [cyan]{self.selected_model.name}[/cyan]\n\n"
message += f"[dim]Path: {self.selected_model.path}[/dim]\n\n"
message += f"[yellow]Note: This only removes from registry, files will not be deleted.[/yellow]"
self.push_screen(
ConfirmDialog("Remove Model", message, confirm_variant="error"), callback=self._on_remove_confirm
)
def _on_remove_confirm(self, confirmed: bool) -> None:
"""Handle remove confirmation result"""
if not confirmed or not self.selected_model:
return
from kt_kernel.cli.utils.user_model_registry import UserModelRegistry
registry = UserModelRegistry()
all_models = registry.list_models()
model_to_remove = self.selected_model
model_uuid = model_to_remove.id
# Check if this model has links (bidirectional cleanup needed)
old_links = set(model_to_remove.gpu_model_ids or [])
# Check if other models link to this one
linked_by = []
for m in all_models:
if m.gpu_model_ids and model_uuid in m.gpu_model_ids:
linked_by.append(m)
# Remove the model
success = registry.remove_model(model_to_remove.name)
if success:
# Bidirectional cleanup: remove this model from all linked models
for model_id in old_links:
target_model = next((m for m in all_models if m.id == model_id), None)
if target_model:
target_links = set(target_model.gpu_model_ids or [])
target_links.discard(model_uuid)
registry.update_model(target_model.name, {"gpu_model_ids": list(target_links)})
# Remove links from models that linked to this one
for linked_model in linked_by:
linked_links = set(linked_model.gpu_model_ids or [])
linked_links.discard(model_uuid)
registry.update_model(linked_model.name, {"gpu_model_ids": list(linked_links)})
self.notify(f"✓ Removed: {model_to_remove.name}", severity="information")
self.load_models()
else:
self.notify(f"Failed to remove model", severity="error")
def action_info(self) -> None:
"""Toggle model info display"""
if not self.selected_model:
return
# If info screen is already open, close it
if self.info_screen_open:
self.pop_screen()
self.info_screen_open = False
return
model = self.selected_model
moe_info = self.moe_results.get(model.id, {})
# Show info screen
self.info_screen_open = True
self.push_screen(InfoScreen(model, moe_info), callback=self._on_info_dismissed)
def _on_info_dismissed(self, result=None) -> None:
"""Called when info screen is dismissed"""
self.info_screen_open = False
def action_scan(self) -> None:
"""Scan for models"""
from kt_kernel.cli.utils.model_scanner import scan_directory
from kt_kernel.cli.utils.user_model_registry import UserModelRegistry
from kt_kernel.cli.config.settings import get_settings
from pathlib import Path
self.notify("Scanning for models...")
try:
settings = get_settings()
model_paths = settings.get_model_paths()
if not model_paths:
self.notify("No model path configured", severity="warning")
return
# Get existing models to exclude
registry = UserModelRegistry()
existing_paths = {m.path for m in registry.list_models()}
# Scan all configured paths
all_scanned = []
all_warnings = []
for model_path in model_paths:
scanned_models, warnings = scan_directory(
Path(model_path), min_size_gb=1.0, exclude_paths=existing_paths
)
all_scanned.extend(scanned_models)
all_warnings.extend(warnings)
# Show warnings if any
for warning in all_warnings:
self.log.warning(warning)
if not all_scanned:
self.notify("No new models found", severity="information")
return
# Add to registry
added_count = 0
from kt_kernel.cli.utils.user_model_registry import UserModel
for scanned in all_scanned:
if not registry.find_by_path(scanned.path):
# Suggest unique name to avoid conflicts
unique_name = registry.suggest_name(scanned.folder_name)
new_model = UserModel(name=unique_name, path=scanned.path, format=scanned.format, repo_id=None)
registry.add_model(new_model)
added_count += 1
if added_count > 0:
self.notify(f"Added {added_count} new model(s)", severity="information")
self.load_models() # Refresh tables
else:
self.notify("All found models already registered", severity="information")
except Exception as e:
self.notify(f"Scan failed: {e}", severity="error")
self.log.error(f"Scan error: {e}")
def action_doctor(self) -> None:
"""Show system diagnostics"""
self.push_screen(DoctorScreen())
def action_settings(self) -> None:
"""Open settings dialog"""
self.push_screen(SettingsScreen(), callback=self._on_settings_action)
def _on_settings_action(self, result) -> None:
"""Handle settings dialog result"""
if result == "force_refresh":
# Run force refresh in background to avoid blocking UI
self.run_worker(self._force_refresh_all_async(), name="force-refresh", group="scan", exclusive=True)
elif result == "add_path":
self._add_path()
elif result == "edit_path":
self._edit_path()
elif result == "remove_path":
self._remove_path()
elif result == "auto_repo":
self._auto_repo()
elif result == "verify_all":
self._verify_all()
async def _force_refresh_all_async(self) -> None:
"""
Async version: Force refresh all models and recalculate SHA256.
Runs in background worker to avoid blocking UI.
"""
import threading
from kt_kernel.cli.utils.model_scanner import scan_directory
from kt_kernel.cli.utils.user_model_registry import UserModelRegistry, UserModel
from kt_kernel.cli.config.settings import get_settings
from pathlib import Path
# Check if we're in a worker thread
is_worker = threading.current_thread() != threading.main_thread()
try:
if is_worker:
self.call_from_thread(self.notify, "🔄 Force refreshing all models...", "information", 3)
else:
self.notify("🔄 Force refreshing all models...", severity="information", timeout=3)
settings = get_settings()
model_paths = settings.get_model_paths()
# If no paths configured, use global scan
if not model_paths or not any(p.exists() for p in model_paths):
await self._global_scan_async()
return
# Scan all configured paths
if is_worker:
self.call_from_thread(setattr, self, "sub_title", "📂 Scanning directories...")
else:
self.sub_title = "📂 Scanning directories..."
all_scanned = []
all_warnings = []
for i, model_path in enumerate(model_paths, 1):
if is_worker:
self.call_from_thread(
setattr, self, "sub_title", f"📂 Scanning path {i}/{len(model_paths)}: {model_path.name}"
)
else:
self.sub_title = f"📂 Scanning path {i}/{len(model_paths)}: {model_path.name}"
scanned_models, warnings = scan_directory(
Path(model_path), min_size_gb=1.0, exclude_paths=None # Don't exclude anything for force refresh
)
all_scanned.extend(scanned_models)
all_warnings.extend(warnings)
# Show warnings if any
for warning in all_warnings:
self.log.warning(warning)
# Clear existing registry and rebuild
registry = UserModelRegistry()
# Get existing models to preserve custom settings
existing_models = {m.path: m for m in registry.list_models()}
# Helper function to update UI
def set_subtitle(text):
if is_worker:
self.call_from_thread(setattr, self, "sub_title", text)
else:
self.sub_title = text
def notify(msg, severity="information", timeout=3):
if is_worker:
self.call_from_thread(self.notify, msg, severity, timeout)
else:
self.notify(msg, severity=severity, timeout=timeout)
# Clear all models
set_subtitle("🗑️ Clearing registry...")
for model in registry.list_models():
registry.remove_model(model.name)
# Re-add all scanned models
added_count = 0
models_without_repo = [] # Track models without repo_id for auto-detection
set_subtitle("➕ Adding models...")
for i, scanned in enumerate(all_scanned, 1):
set_subtitle(f"➕ Adding model {i}/{len(all_scanned)}: {scanned.folder_name}")
path = scanned.path
# Preserve repo info if it existed
repo_id = None
repo_type = None
if path in existing_models:
old_model = existing_models[path]
repo_id = old_model.repo_id
repo_type = old_model.repo_type
# Suggest unique name
base_name = scanned.folder_name
name = registry.suggest_name(base_name)
new_model = UserModel(
name=name,
path=path,
format=scanned.format,
repo_id=repo_id,
repo_type=repo_type,
sha256_status="not_checked", # Reset SHA256 status
)
registry.add_model(new_model)
added_count += 1
# Track models without repo_id for auto-detection
if not repo_id:
models_without_repo.append(new_model)
# Auto-detect repo_id for models without it
if models_without_repo:
from kt_kernel.cli.utils.repo_detector import detect_repo_for_model
set_subtitle("📖 Detecting repository information...")
repo_detected_count = 0
for i, model in enumerate(models_without_repo, 1):
set_subtitle(f"📖 Analyzing model {i}/{len(models_without_repo)}: {model.name}")
try:
repo_info = detect_repo_for_model(model.path)
if repo_info:
repo_id, repo_type = repo_info
registry.update_model(model.name, {"repo_id": repo_id, "repo_type": repo_type})
repo_detected_count += 1
except Exception as e:
self.log.warning(f"Failed to detect repo for {model.name}: {e}")
if repo_detected_count > 0:
notify(f"✓ Detected {repo_detected_count} repository IDs", "information", 3)
# Analyze MoE models (all models, using cache)
if all_scanned:
from kt_kernel.cli.utils.analyze_moe_model import analyze_moe_model
set_subtitle("🔬 Analyzing MoE models...")
moe_analyzed_count = 0
all_models_list = registry.list_models()
for i, model in enumerate(all_models_list, 1):
if model.format != "safetensors":
continue
set_subtitle(f"🔬 Analyzing MoE {i}/{len(all_models_list)}: {model.name}")
try:
result = analyze_moe_model(model.path, use_cache=True)
if result and result.get("num_experts"):
moe_analyzed_count += 1
except Exception as e:
self.log.warning(f"Failed to analyze MoE for {model.name}: {e}")
if moe_analyzed_count > 0:
notify(f"✓ Analyzed {moe_analyzed_count} MoE models", "information", 3)
notify(f"✓ Force refresh complete: {added_count} models", "information", 5)
set_subtitle("")
# Reload models display - call async version directly since we're already in a worker
await self._load_models_async()
except Exception as e:
if is_worker:
self.call_from_thread(self.notify, f"Force refresh failed: {e}", "error", 10)
else:
self.notify(f"Force refresh failed: {e}", severity="error", timeout=10)
self.log.error(f"Force refresh error: {e}")
async def _scan_and_add_path_async(self, path: Path) -> None:
"""
Async version: Scan a newly added path and add models to registry.
Runs in background worker to avoid blocking UI.
"""
import threading
from kt_kernel.cli.utils.model_scanner import scan_directory
from kt_kernel.cli.utils.user_model_registry import UserModelRegistry, UserModel
from kt_kernel.cli.utils.repo_detector import detect_repo_for_model
from kt_kernel.cli.utils.analyze_moe_model import analyze_moe_model
# Check if we're in a worker thread
is_worker = threading.current_thread() != threading.main_thread()
# Helper functions
def set_subtitle(text):
if is_worker:
self.call_from_thread(setattr, self, "sub_title", text)
else:
self.sub_title = text
def notify(msg, severity="information", timeout=3):
if is_worker:
self.call_from_thread(self.notify, msg, severity, timeout)
else:
self.notify(msg, severity=severity, timeout=timeout)
try:
set_subtitle(f"📂 Scanning {path.name}...")
# Scan the new path
scanned_models, warnings = scan_directory(path, min_size_gb=1.0, exclude_paths=None)
# Show warnings if any
for warning in warnings:
self.log.warning(warning)
if not scanned_models:
notify(f"No models found in {path.name}", "warning", 5)
set_subtitle("")
# Reload models display - call async version directly since we're already in a worker
await self._load_models_async()
return
# Add models to registry
registry = UserModelRegistry()
added_count = 0
added_models = []
for i, scanned in enumerate(scanned_models, 1):
set_subtitle(f"➕ Adding model {i}/{len(scanned_models)}: {scanned.folder_name}")
# Suggest unique name
name = registry.suggest_name(scanned.folder_name)
new_model = UserModel(
name=name,
path=scanned.path,
format=scanned.format,
repo_id=None,
repo_type=None,
sha256_status="not_checked",
)
registry.add_model(new_model)
added_count += 1
added_models.append(new_model)
# Auto-detect repo_id
if added_models:
set_subtitle("📖 Detecting repository information...")
repo_detected_count = 0
for i, model in enumerate(added_models, 1):
set_subtitle(f"📖 Analyzing model {i}/{len(added_models)}: {model.name}")
try:
repo_info = detect_repo_for_model(model.path)
if repo_info:
repo_id, repo_type = repo_info
registry.update_model(model.name, {"repo_id": repo_id, "repo_type": repo_type})
repo_detected_count += 1
except Exception as e:
self.log.warning(f"Failed to detect repo for {model.name}: {e}")
if repo_detected_count > 0:
notify(f"✓ Detected {repo_detected_count} repository IDs", "information", 3)
# Analyze MoE models
if added_models:
set_subtitle("🔬 Analyzing MoE models...")
moe_analyzed_count = 0
for i, model in enumerate(added_models, 1):
if model.format != "safetensors":
continue
set_subtitle(f"🔬 Analyzing MoE {i}/{len(added_models)}: {model.name}")
try:
result = analyze_moe_model(model.path, use_cache=True)
if result and result.get("num_experts"):
moe_analyzed_count += 1
except Exception as e:
self.log.warning(f"Failed to analyze MoE for {model.name}: {e}")
if moe_analyzed_count > 0:
notify(f"✓ Analyzed {moe_analyzed_count} MoE models", "information", 3)
notify(f"✓ Added {added_count} models from {path.name}", "information", 5)
set_subtitle("")
# Reload models display - call async version directly since we're already in a worker
await self._load_models_async()
except Exception as e:
notify(f"Failed to scan path: {e}", "error", 10)
self.log.error(f"Scan path error: {e}")
set_subtitle("")
async def _global_scan_async(self) -> None:
"""
Async version: Scan all disks and common locations for models.
Used when no model paths are configured.
Runs in background worker to avoid blocking UI.
"""
import threading
from kt_kernel.cli.utils.environment import scan_storage_locations, scan_models_in_location
from kt_kernel.cli.utils.user_model_registry import UserModelRegistry, UserModel
from kt_kernel.cli.utils.model_scanner import scan_single_path
from kt_kernel.cli.config.settings import get_settings
from pathlib import Path
# Check if we're in a worker thread
is_worker = threading.current_thread() != threading.main_thread()
# Helper functions
def set_subtitle(text):
if is_worker:
self.call_from_thread(setattr, self, "sub_title", text)
else:
self.sub_title = text
def notify(msg, severity="information", timeout=3):
if is_worker:
self.call_from_thread(self.notify, msg, severity, timeout)
else:
self.notify(msg, severity=severity, timeout=timeout)
try:
notify("🌍 Scanning all disks for models...", "information", 3)
set_subtitle("Scanning storage locations...")
# Scan all storage locations (disks + common paths)
locations = scan_storage_locations(min_size_gb=10.0)
if not locations:
notify("No suitable storage locations found", "warning", 5)
return
# Find models in each location
all_models = []
for i, loc in enumerate(locations[:10], 1): # Scan top 10 locations
set_subtitle(f"Scanning location {i}/{min(len(locations), 10)}: {loc.path}")
try:
models = scan_models_in_location(loc, max_depth=3)
if models:
all_models.extend(models)
except Exception as e:
self.log.warning(f"Failed to scan {loc.path}: {e}")
if not all_models:
notify("No models found in any location", "warning", 5)
set_subtitle("")
return
# Add models to registry
registry = UserModelRegistry()
added_count = 0
added_models = [] # Track newly added models for post-processing
set_subtitle("Adding models to registry...")
for model in all_models:
# Scan the specific path to get detailed info
scanned = scan_single_path(Path(model.path))
if not scanned:
continue
# Suggest unique name
name = registry.suggest_name(scanned.folder_name)
new_model = UserModel(
name=name,
path=scanned.path,
format=scanned.format,
repo_id=None,
repo_type=None,
sha256_status="not_checked",
)
registry.add_model(new_model)
added_models.append(new_model)
added_count += 1
# Auto-detect repo_id from README.md
if added_models:
from kt_kernel.cli.utils.repo_detector import detect_repo_for_model
set_subtitle("Detecting repository information...")
repo_detected_count = 0
for i, model in enumerate(added_models, 1):
set_subtitle(f"📖 Analyzing model {i}/{len(added_models)}: {model.name}")
try:
# Detect repo from README
repo_info = detect_repo_for_model(model.path)
if repo_info:
repo_id, repo_type = repo_info
registry.update_model(model.name, {"repo_id": repo_id, "repo_type": repo_type})
repo_detected_count += 1
self.log.info(f"Detected repo for {model.name}: {repo_id}")
except Exception as e:
self.log.warning(f"Failed to detect repo for {model.name}: {e}")
if repo_detected_count > 0:
notify(
f"✓ Detected {repo_detected_count}/{len(added_models)} repository IDs",
"information",
3,
)
# Analyze MoE models
if added_models:
from kt_kernel.cli.utils.analyze_moe_model import analyze_moe_model
set_subtitle("Analyzing MoE models...")
moe_analyzed_count = 0
for i, model in enumerate(added_models, 1):
# Only analyze safetensors models
if model.format != "safetensors":
continue
set_subtitle(f"🔬 Analyzing MoE {i}/{len(added_models)}: {model.name}")
try:
result = analyze_moe_model(model.path, use_cache=True)
if result and result.get("num_experts"):
moe_analyzed_count += 1
self.log.info(
f"MoE detected for {model.name}: "
f"{result['num_experts']} experts, "
f"{result.get('experts_per_tok', 'N/A')} per token"
)
except Exception as e:
self.log.warning(f"Failed to analyze MoE for {model.name}: {e}")
if moe_analyzed_count > 0:
notify(f"✓ Analyzed {moe_analyzed_count} MoE models", "information", 3)
# Calculate common parent directories for all found models
# This groups models by their parent directory
model_parent_dirs = {} # parent_dir -> [model_paths]
for model in all_models:
parent = str(Path(model.path).parent)
if parent not in model_parent_dirs:
model_parent_dirs[parent] = []
model_parent_dirs[parent].append(model.path)
# Save discovered parent directories to settings
settings = get_settings()
current_paths = settings.get_model_paths()
current_path_strs = {str(p) for p in current_paths}
# Add new unique parent paths (directories that contain models)
for parent_dir in model_parent_dirs.keys():
if parent_dir not in current_path_strs:
settings.add_model_path(parent_dir)
self.log.info(f"Added path: {parent_dir} ({len(model_parent_dirs[parent_dir])} models)")
notify(f"✓ Global scan complete: {added_count} models found", "information", 5)
set_subtitle("")
# Reload models display - call async version directly since we're already in a worker
await self._load_models_async()
except Exception as e:
notify(f"Global scan failed: {e}", "error", 10)
self.log.error(f"Global scan error: {e}")
set_subtitle("")
def _add_path(self) -> None:
"""Add a new model path"""
self.push_screen(PathInputScreen("Add Model Path"), callback=self._on_path_added)
def _on_path_added(self, new_path) -> None:
"""Handle new path input"""
if not new_path:
return
from kt_kernel.cli.config.settings import get_settings
from pathlib import Path
try:
settings = get_settings()
model_paths = settings.get_model_paths()
# Convert to Path object
new_path_obj = Path(new_path).expanduser().resolve()
# Check if path already exists in config
if new_path_obj in model_paths:
self.notify(f"Path already exists: {new_path_obj}", severity="warning")
# Reopen settings
self.push_screen(SettingsScreen(), callback=self._on_settings_action)
return
# Add to settings
settings.add_model_path(str(new_path_obj))
self.notify(f"✓ Added path: {new_path_obj}", severity="information")
# Scan the new path in background and update models
self.notify("🔍 Scanning new path for models...", severity="information", timeout=3)
self.run_worker(
self._scan_and_add_path_async(new_path_obj), name="scan-new-path", group="scan", exclusive=True
)
# Reopen settings to show updated paths
self.push_screen(SettingsScreen(), callback=self._on_settings_action)
except Exception as e:
self.notify(f"Failed to add path: {e}", severity="error")
self.log.error(f"Add path error: {e}")
# Reopen settings even on error
self.push_screen(SettingsScreen(), callback=self._on_settings_action)
def _edit_path(self) -> None:
"""Edit an existing model path"""
from kt_kernel.cli.config.settings import get_settings
try:
settings = get_settings()
model_paths = settings.get_model_paths()
if not model_paths:
self.notify("No model paths to edit", severity="warning")
self.push_screen(SettingsScreen(), callback=self._on_settings_action)
return
# Show path selection dialog
self.push_screen(
PathSelectScreen("Select Path to Edit", [str(p) for p in model_paths]),
callback=self._on_path_selected_for_edit,
)
except Exception as e:
self.notify(f"Failed to open path editor: {e}", severity="error")
self.log.error(f"Edit path error: {e}")
self.push_screen(SettingsScreen(), callback=self._on_settings_action)
def _on_path_selected_for_edit(self, selected_index) -> None:
"""Handle path selection for editing"""
if selected_index is None:
# User cancelled, reopen settings
self.push_screen(SettingsScreen(), callback=self._on_settings_action)
return
from kt_kernel.cli.config.settings import get_settings
try:
settings = get_settings()
model_paths = settings.get_model_paths()
old_path = str(model_paths[selected_index])
# Store old path for later
self.editing_path_index = selected_index
self.editing_old_path = old_path
# Show input dialog with current path
self.push_screen(PathInputScreen("Edit Model Path", default_value=old_path), callback=self._on_path_edited)
except Exception as e:
self.notify(f"Failed to edit path: {e}", severity="error")
self.log.error(f"Path selection error: {e}")
self.push_screen(SettingsScreen(), callback=self._on_settings_action)
def _on_path_edited(self, new_path) -> None:
"""Handle edited path"""
if not new_path:
# User cancelled, reopen settings
self.push_screen(SettingsScreen(), callback=self._on_settings_action)
return
from kt_kernel.cli.config.settings import get_settings
from pathlib import Path
try:
settings = get_settings()
model_paths = settings.get_model_paths()
# Convert to Path object
new_path_obj = Path(new_path).expanduser().resolve()
old_path = self.editing_old_path
# Check if unchanged
if str(new_path_obj) == old_path:
self.notify("Path unchanged", severity="information")
self.push_screen(SettingsScreen(), callback=self._on_settings_action)
return
# Check if new path already exists
if new_path_obj in model_paths:
self.notify(f"Path already exists: {new_path_obj}", severity="warning")
self.push_screen(SettingsScreen(), callback=self._on_settings_action)
return
# Remove old path and add new one
settings.remove_model_path(old_path)
settings.add_model_path(str(new_path_obj))
self.notify(f"✓ Updated path: {new_path_obj}", severity="information")
# Reopen settings
self.push_screen(SettingsScreen(), callback=self._on_settings_action)
except Exception as e:
self.notify(f"Failed to update path: {e}", severity="error")
self.log.error(f"Path edit error: {e}")
self.push_screen(SettingsScreen(), callback=self._on_settings_action)
def _remove_path(self) -> None:
"""Remove a model path"""
from kt_kernel.cli.config.settings import get_settings
try:
settings = get_settings()
model_paths = settings.get_model_paths()
if not model_paths:
self.notify("No model paths to remove", severity="warning")
self.push_screen(SettingsScreen(), callback=self._on_settings_action)
return
# Show path selection dialog
self.push_screen(
PathSelectScreen("Select Path to Remove", [str(p) for p in model_paths]),
callback=self._on_path_selected_for_remove,
)
except Exception as e:
self.notify(f"Failed to open path remover: {e}", severity="error")
self.log.error(f"Remove path error: {e}")
self.push_screen(SettingsScreen(), callback=self._on_settings_action)
def _on_path_selected_for_remove(self, selected_index) -> None:
"""Handle path selection for removal"""
if selected_index is None:
# User cancelled, reopen settings
self.push_screen(SettingsScreen(), callback=self._on_settings_action)
return
from kt_kernel.cli.config.settings import get_settings
try:
settings = get_settings()
model_paths = settings.get_model_paths()
path_to_remove = str(model_paths[selected_index])
# Show confirmation dialog
self.push_screen(
ConfirmDialog(
f"Remove path '{path_to_remove}'?", "Models in this path will no longer be accessible in the TUI."
),
callback=lambda confirm: self._on_path_remove_confirmed(confirm, path_to_remove),
)
except Exception as e:
self.notify(f"Failed to remove path: {e}", severity="error")
self.log.error(f"Path removal error: {e}")
self.push_screen(SettingsScreen(), callback=self._on_settings_action)
def _on_path_remove_confirmed(self, confirm, path_to_remove) -> None:
"""Handle path removal confirmation"""
if not confirm:
# User cancelled, reopen settings
self.push_screen(SettingsScreen(), callback=self._on_settings_action)
return
from kt_kernel.cli.config.settings import get_settings
try:
settings = get_settings()
settings.remove_model_path(path_to_remove)
self.notify(f"✓ Removed path: {path_to_remove}", severity="information")
# Reopen settings
self.push_screen(SettingsScreen(), callback=self._on_settings_action)
except Exception as e:
self.notify(f"Failed to remove path: {e}", severity="error")
self.log.error(f"Path removal error: {e}")
self.push_screen(SettingsScreen(), callback=self._on_settings_action)
def _auto_repo(self) -> None:
"""Auto-detect repository information for models"""
from kt_kernel.cli.utils.user_model_registry import UserModelRegistry
from kt_kernel.cli.utils.repo_detector import scan_models_for_repo
try:
self.notify("Scanning for repository information...", severity="information")
registry = UserModelRegistry()
models = registry.list_models()
if not models:
self.notify("No models found", severity="warning")
self.push_screen(SettingsScreen(), callback=self._on_settings_action)
return
# Scan for repo information
results = scan_models_for_repo(models)
if not results["detected"]:
self.notify("No repository information detected", severity="information")
self.push_screen(SettingsScreen(), callback=self._on_settings_action)
return
# Show selection screen
self.push_screen(AutoRepoSelectScreen(results["detected"]), callback=self._on_auto_repo_selected)
except Exception as e:
self.notify(f"Auto-repo detection failed: {e}", severity="error")
self.log.error(f"Auto-repo error: {e}")
self.push_screen(SettingsScreen(), callback=self._on_settings_action)
def _on_auto_repo_selected(self, selected_models) -> None:
"""Handle auto-repo selection result"""
if not selected_models:
# User cancelled, reopen settings
self.push_screen(SettingsScreen(), callback=self._on_settings_action)
return
from kt_kernel.cli.utils.user_model_registry import UserModelRegistry
try:
registry = UserModelRegistry()
updated_count = 0
for model, repo_id, repo_type in selected_models:
success = registry.update_model(model.name, {"repo_id": repo_id, "repo_type": repo_type})
if success:
updated_count += 1
if updated_count > 0:
self.notify(f"✓ Updated {updated_count} model(s) with repository information", severity="information")
self.load_models() # Refresh tables
else:
self.notify("No models were updated", severity="warning")
# Reopen settings
self.push_screen(SettingsScreen(), callback=self._on_settings_action)
except Exception as e:
self.notify(f"Failed to apply repository information: {e}", severity="error")
self.log.error(f"Auto-repo apply error: {e}")
self.push_screen(SettingsScreen(), callback=self._on_settings_action)
def _verify_all(self) -> None:
"""Verify all models with repo_id but unverified SHA256"""
from kt_kernel.cli.utils.user_model_registry import UserModelRegistry
try:
registry = UserModelRegistry()
models = registry.list_models()
# Filter models that need verification
models_to_verify = [m for m in models if m.repo_id and m.sha256_status != "passed"]
if not models_to_verify:
self.notify("All models with repo are already verified", severity="information")
self.push_screen(SettingsScreen(), callback=self._on_settings_action)
return
self.notify(
f"Starting verification for {len(models_to_verify)} model(s)...", severity="information", timeout=3
)
# Setup batch verification queue
self._verify_models_queue = models_to_verify.copy()
self._verify_total_count = len(models_to_verify)
# Start with first model (use shared verification logic)
first_model = self._verify_models_queue.pop(0)
self._start_verification(first_model, is_batch=True)
except Exception as e:
self.notify(f"Verify-all failed: {e}", severity="error")
self.log.error(f"Verify-all error: {e}")
self.push_screen(SettingsScreen(), callback=self._on_settings_action)
def action_download(self) -> None:
"""Download a model"""
self.push_screen(DownloadScreen(), callback=self._on_download_config)
def _on_download_config(self, config) -> None:
"""Handle download configuration and start download"""
if config is None:
return
# Start download in background
import subprocess
import sys
from pathlib import Path
repo_id = config["repo_id"]
repo_type = config["repo_type"]
pattern = config["pattern"]
model_name = config["model_name"]
save_path = config["save_path"]
# Get already-verified files from DownloadScreen
files = config.get("files", [])
total_size = config.get("total_size", 0)
# Build command
# Note: kt model download uses Rich console which may suppress output in non-TTY
# We rely on subprocess to capture whatever output there is
cmd = [
sys.executable,
"-u", # Force unbuffered output
"-m",
"kt_kernel.cli.commands.model",
"download",
repo_id,
"--repo-type",
repo_type,
"--pattern",
pattern,
"--local-dir",
save_path,
"--yes",
]
self.log.info(f"Download command: {' '.join(cmd)}")
self.log.info(f"Repository: {repo_id}")
self.log.info(f"Pattern: {pattern}")
self.log.info(f"Destination: {save_path}")
self.log.info(f"Files count: {len(files)}")
self.log.info(f"Total size: {total_size}")
# Show progress screen and start download
progress_screen = DownloadProgressScreen(cmd, model_name, repo_id, repo_type, save_path, files, total_size)
self.push_screen(progress_screen, callback=self._on_download_complete)
def _on_download_complete(self, success: bool) -> None:
"""Handle download completion"""
if success:
self.notify("Download completed successfully", severity="information")
self.load_models()
else:
self.notify("Download failed or cancelled", severity="warning")
def action_action_1(self) -> None:
"""Execute action 1"""
if self.selected_model:
actions = self.get_actions_for_model(self.selected_model)
if actions:
self.execute_action(actions[0])
def action_action_2(self) -> None:
"""Execute action 2"""
if self.selected_model:
actions = self.get_actions_for_model(self.selected_model)
if len(actions) > 1:
self.execute_action(actions[1])
def action_action_3(self) -> None:
"""Execute action 3"""
if self.selected_model:
actions = self.get_actions_for_model(self.selected_model)
if len(actions) > 2:
self.execute_action(actions[2])
def action_action_4(self) -> None:
"""Execute action 4"""
if self.selected_model:
actions = self.get_actions_for_model(self.selected_model)
if len(actions) > 3:
self.execute_action(actions[3])
def action_action_5(self) -> None:
"""Execute action 5"""
if self.selected_model:
actions = self.get_actions_for_model(self.selected_model)
if len(actions) > 4:
self.execute_action(actions[4])
def action_action_6(self) -> None:
"""Execute action 6"""
if self.selected_model:
actions = self.get_actions_for_model(self.selected_model)
if len(actions) > 5:
self.execute_action(actions[5])
def action_action_7(self) -> None:
"""Execute action 7"""
if self.selected_model:
actions = self.get_actions_for_model(self.selected_model)
if len(actions) > 6:
self.execute_action(actions[6])
def action_next_table(self) -> None:
"""Move focus to next table"""
tables = ["gpu-table", "gguf-table", "amx-table"]
current_idx = tables.index(self.active_table_id)
next_idx = (current_idx + 1) % len(tables)
next_table = self.query_one(f"#{tables[next_idx]}", DataTable)
next_table.focus()
def action_prev_table(self) -> None:
"""Move focus to previous table"""
tables = ["gpu-table", "gguf-table", "amx-table"]
current_idx = tables.index(self.active_table_id)
prev_idx = (current_idx - 1) % len(tables)
prev_table = self.query_one(f"#{tables[prev_idx]}", DataTable)
prev_table.focus()
def execute_action(self, action_name: str) -> None:
"""Execute named action"""
# Handle special case: No Repo
if action_name == "No Repo":
self.notify(
f"Cannot verify '{self.selected_model.name}': No repository configured.\n"
f"Press 'e' (Edit) → 'Repo Info' to set HuggingFace or ModelScope repository.",
severity="warning",
timeout=8,
)
return
action_map = {
"Run": self.action_run,
"Edit": self.action_edit,
"Verify": self.action_verify,
"Quant": self.action_quant,
"Remove": self.action_remove,
"Info": self.action_info,
"Link GPU": self.action_link_gpu,
"Link CPU": self.action_link_cpu,
"Unlink": self.action_unlink,
"Test": self.action_run,
}
handler = action_map.get(action_name)
if handler:
handler()
else:
self.notify(f"Action '{action_name}' not implemented yet")
if __name__ == "__main__":
app = ModelManagerApp()
app.run()