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

410 lines
16 KiB
Python

"""
Progress screens.
"""
from pathlib import Path
from typing import Optional, List, Dict, Any
from textual.app import ComposeResult
from textual.containers import Container, Horizontal, Vertical, ScrollableContainer, Center
from textual.widgets import Button, Checkbox, DataTable, Input, Label, RichLog, Select, Static
from textual.screen import ModalScreen
from textual.worker import Worker, WorkerState
class QuantProgressScreen(ModalScreen):
"""Modal screen for showing quantization progress"""
CSS_PATH = str(Path(__file__).parent.parent / "styles" / "progress.tcss")
def __init__(self, model_name: str):
super().__init__()
self.model_name = model_name
self._quant_running = True
self.success = None
def compose(self) -> ComposeResult:
from textual.widgets import RichLog
with Container(id="quant-progress-dialog"):
yield Label(f"[bold cyan]Quantizing: {self.model_name}[/bold cyan]")
yield Label("")
yield Static("", id="quant-status")
yield RichLog(id="quant-output", wrap=False, highlight=True, markup=True)
yield Label("")
with Center():
yield Button("Close", id="btn-close", variant="primary", disabled=True)
def append_output(self, line: str) -> None:
"""Append a line to the output display"""
from textual.widgets import RichLog
try:
output_widget = self.query_one("#quant-output", RichLog)
output_widget.write(line)
# Auto-scroll to bottom
self.call_after_refresh(output_widget.scroll_end, animate=False)
except:
pass # Screen not mounted yet
def update_status(self, status: str, running: bool = True) -> None:
"""Update the status message"""
self._quant_running = running
try:
status_widget = self.query_one("#quant-status", Static)
status_widget.update(status)
# Enable close button when done
if not running:
close_btn = self.query_one("#btn-close", Button)
close_btn.disabled = False
except:
pass # Screen not mounted yet
def set_success(self, success: bool) -> None:
"""Mark quantization as successful or failed"""
self.success = success
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "btn-close":
self.dismiss(self.success)
class DownloadProgressScreen(ModalScreen):
"""Modal screen for showing download progress"""
CSS_PATH = str(Path(__file__).parent.parent / "styles" / "progress.tcss")
def __init__(
self,
cmd: list,
model_name: str,
repo_id: str,
repo_type: str,
save_path: str,
files: list = None,
total_size: int = 0,
):
super().__init__()
self.cmd = cmd
self.model_name = model_name
self.repo_id = repo_id
self.repo_type = repo_type
self.save_path = save_path
self.files = files or [] # Already verified files from DownloadScreen
self.total_size = total_size # Already calculated total size
self._download_running = True
self.success = None
self.process = None
def compose(self) -> ComposeResult:
from textual.widgets import RichLog
from textual.containers import Horizontal
with Container(id="download-progress-dialog"):
yield Label(f"[bold cyan]Downloading: {self.repo_id}[/bold cyan]")
yield Label("")
yield Static("[yellow]Starting download...[/yellow]", id="download-progress-status")
yield RichLog(id="download-progress-output", wrap=False, highlight=True, markup=True)
yield Label("")
with Center():
with Horizontal(id="download-buttons"):
yield Button("Cancel", id="btn-cancel-download", variant="error")
yield Button("Close", id="btn-close-download", variant="primary", disabled=True)
def on_mount(self) -> None:
"""Start download process when screen is mounted"""
# Get parent app to use run_worker
app = self.app
if hasattr(app, "run_worker"):
app.run_worker(self.download_async(), name=f"download-{self.repo_id}", group="download", exclusive=False)
else:
# Fallback to thread
import threading
threading.Thread(target=self._run_download_sync, daemon=True).start()
def append_output(self, line: str) -> None:
"""Append a line to the output display"""
from textual.widgets import RichLog
try:
output_widget = self.query_one("#download-progress-output", RichLog)
output_widget.write(line)
self.call_after_refresh(output_widget.scroll_end, animate=False)
except:
pass
def update_status(self, status: str, running: bool = True) -> None:
"""Update the status message"""
self._download_running = running
try:
status_widget = self.query_one("#download-progress-status", Static)
status_widget.update(status)
# Note: Button states are now managed explicitly in download_async and _cancel_download
except:
pass
async def download_async(self):
"""Download model in subprocess and capture stdout"""
import sys
import subprocess
from pathlib import Path
try:
# Update initial status
self.update_status("[bold yellow]Preparing download...[/bold yellow]")
self.append_output(f"[cyan]Repository: {self.repo_id}[/cyan]")
self.append_output(f"[cyan]Type: {self.repo_type}[/cyan]")
self.append_output(f"[cyan]Destination: {self.save_path}[/cyan]")
self.append_output("")
# Show verified file info
if self.files:
from kt_kernel.cli.utils.model_scanner import format_size
self.append_output(
f"[green]Files to download: {len(self.files)} files ({format_size(self.total_size)})[/green]"
)
for i, file in enumerate(self.files[:5]):
file_name = Path(file["path"]).name
self.append_output(f" • {file_name} ({format_size(file['size'])})")
if len(self.files) > 5:
self.append_output(f" ... and {len(self.files) - 5} more")
self.append_output("")
# Extract pattern
pattern = "*"
if "--pattern" in self.cmd:
pattern_idx = self.cmd.index("--pattern") + 1
if pattern_idx < len(self.cmd):
pattern = self.cmd[pattern_idx]
# Create download directory
download_path = Path(self.save_path)
download_path.mkdir(parents=True, exist_ok=True)
self.update_status("[bold yellow]Downloading files...[/bold yellow]")
self.append_output("[yellow]Starting download...[/yellow]")
self.append_output("")
# Build Python code to execute in subprocess
if self.repo_type == "huggingface":
download_code = f"""
import sys
from huggingface_hub import snapshot_download
print("Downloading from HuggingFace...", file=sys.stderr)
sys.stderr.flush()
snapshot_download(
repo_id="{self.repo_id}",
local_dir="{download_path}",
allow_patterns={repr(pattern if pattern != "*" else None)},
local_dir_use_symlinks=False,
resume_download=True
)
print("Download complete!", file=sys.stderr)
"""
else: # modelscope
download_code = f"""
import sys
from modelscope.hub.snapshot_download import snapshot_download
print("Downloading from ModelScope...", file=sys.stderr)
sys.stderr.flush()
snapshot_download(
model_id="{self.repo_id}",
local_dir="{download_path}",
allow_file_pattern={repr(pattern if pattern != "*" else None)}
)
print("Download complete!", file=sys.stderr)
"""
# Execute in subprocess and capture output
self.process = await asyncio.create_subprocess_exec(
sys.executable, "-u", "-c", download_code, stdout=subprocess.PIPE, stderr=subprocess.STDOUT
)
# Read output line by line
while True:
line = await self.process.stdout.readline()
if not line:
break
line_str = line.decode("utf-8", errors="replace").rstrip()
if line_str:
self.append_output(line_str)
# Wait for completion
await self.process.wait()
# Check result
if self.process.returncode == 0:
# Check downloaded files
files_on_disk = list(download_path.glob("*"))
self.append_output("")
self.append_output(f"[green]✓ Download completed![/green]")
self.append_output(f"[green]✓ {len(files_on_disk)} files in destination[/green]")
self.update_status("[bold green]✓ Download completed successfully![/bold green]", running=False)
self.set_success(True)
# Add to registry
self._add_to_registry()
# Enable Close button, disable Cancel
self.query_one("#btn-close-download", Button).disabled = False
self.query_one("#btn-cancel-download", Button).disabled = True
else:
self.append_output("")
self.append_output(f"[red]✗ Download failed with exit code {self.process.returncode}[/red]")
self.update_status(f"[bold red]✗ Download failed[/bold red]", running=False)
self.set_success(False)
# Enable Close button, disable Cancel
self.query_one("#btn-close-download", Button).disabled = False
self.query_one("#btn-cancel-download", Button).disabled = True
except Exception as e:
self.append_output("")
self.append_output(f"[red]✗ Error: {e}[/red]")
import traceback
tb = traceback.format_exc()
for line in tb.split("\n")[:10]:
if line.strip():
self.append_output(f"[dim]{line}[/dim]")
self.update_status(f"[bold red]✗ Download failed[/bold red]", running=False)
self.set_success(False)
# Enable Close button, disable Cancel
try:
self.query_one("#btn-close-download", Button).disabled = False
self.query_one("#btn-cancel-download", Button).disabled = True
except:
pass
def _run_download_sync(self) -> None:
"""Fallback sync download (kept for compatibility)"""
import subprocess
from pathlib import Path
try:
self.call_from_thread(self.append_output, f"[cyan]Repository: {self.repo_id}[/cyan]")
self.call_from_thread(self.append_output, f"[cyan]Type: {self.repo_type}[/cyan]")
self.call_from_thread(self.append_output, f"[cyan]Destination: {self.save_path}[/cyan]")
self.call_from_thread(self.append_output, "")
self.call_from_thread(self.append_output, f"Command: {' '.join(self.cmd)}")
self.call_from_thread(self.append_output, "")
# Run subprocess
process = subprocess.Popen(self.cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1)
# Read output line by line
for line in process.stdout:
line = line.rstrip()
if line:
self.call_from_thread(self.append_output, line)
process.wait()
if process.returncode == 0:
self.call_from_thread(self.append_output, "")
self.call_from_thread(self.append_output, "[green]✓ Download completed![/green]")
self.call_from_thread(self.update_status, "[green]✓ Download completed successfully[/green]", False)
self.call_from_thread(self.set_success, True)
self.call_from_thread(self._add_to_registry)
else:
self.call_from_thread(
self.update_status, f"[red]✗ Download failed (exit code {process.returncode})[/red]", False
)
self.call_from_thread(self.set_success, False)
except Exception as e:
self.call_from_thread(self.append_output, f"[red]Error: {e}[/red]")
self.call_from_thread(self.update_status, "[red]✗ Download failed[/red]", False)
self.call_from_thread(self.set_success, False)
def _add_to_registry(self) -> None:
"""Add downloaded model to registry"""
from pathlib import Path
from kt_kernel.cli.utils.user_model_registry import UserModelRegistry, UserModel
from kt_kernel.cli.utils.model_scanner import scan_single_path
try:
output_path = Path(self.save_path)
if output_path.exists():
scanned = scan_single_path(output_path)
if scanned:
registry = UserModelRegistry()
# Check if already exists
if not registry.find_by_path(str(output_path)):
new_model = UserModel(
name=self.model_name,
path=str(output_path),
format=scanned.format,
repo_id=self.repo_id,
repo_type=self.repo_type,
)
registry.add_model(new_model)
self.append_output(f"[green]✓ Model added to registry: {self.model_name}[/green]")
except Exception as e:
self.append_output(f"[yellow]Warning: Failed to add to registry: {e}[/yellow]")
def set_success(self, success: bool) -> None:
"""Mark download as successful or failed"""
self.success = success
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button clicks"""
if event.button.id == "btn-close-download":
# Close button - just dismiss
self.dismiss(self.success)
elif event.button.id == "btn-cancel-download":
# Cancel button - kill process and cleanup
self._cancel_download()
def _cancel_download(self) -> None:
"""Cancel the download and cleanup"""
import shutil
from pathlib import Path
self.append_output("")
self.append_output("[yellow]Cancelling download...[/yellow]")
# Kill the download process if it's running
if self.process and self.process.returncode is None:
try:
self.process.kill()
self.append_output("[yellow]Download process terminated[/yellow]")
except Exception as e:
self.append_output(f"[yellow]Warning: Failed to kill process: {e}[/yellow]")
# Delete the download directory
download_path = Path(self.save_path)
if download_path.exists():
try:
shutil.rmtree(download_path)
self.append_output(f"[yellow]Deleted directory: {self.save_path}[/yellow]")
except Exception as e:
self.append_output(f"[yellow]Warning: Failed to delete directory: {e}[/yellow]")
self.append_output("")
self.append_output("[red]✗ Download cancelled by user[/red]")
self.update_status("[bold red]✗ Download cancelled[/bold red]", running=False)
self.set_success(False)
# Disable Cancel button, enable Close
try:
self.query_one("#btn-cancel-download", Button).disabled = True
self.query_one("#btn-close-download", Button).disabled = False
except:
pass
# RunConfigScreen moved to screens/config.py