mirror of
https://github.com/kvcache-ai/ktransformers.git
synced 2026-10-20 00:26:53 +08:00
2905 lines
115 KiB
Python
2905 lines
115 KiB
Python
"""
|
||
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()
|