750 lines
30 KiB
Python
750 lines
30 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""
|
||
|
|
Model Conversion and Editing Tools for DarkHal 2.0
|
||
|
|
|
||
|
|
Provides comprehensive model conversion between formats, quantization options,
|
||
|
|
and model editing capabilities.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import tkinter as tk
|
||
|
|
from tkinter import ttk, messagebox, filedialog, scrolledtext
|
||
|
|
import os
|
||
|
|
import sys
|
||
|
|
import json
|
||
|
|
import subprocess
|
||
|
|
import threading
|
||
|
|
import queue
|
||
|
|
import shutil
|
||
|
|
from pathlib import Path
|
||
|
|
from typing import Optional, Dict, Any, List, Tuple
|
||
|
|
from datetime import datetime
|
||
|
|
import tempfile
|
||
|
|
|
||
|
|
|
||
|
|
class ModelConverter:
|
||
|
|
"""Handles model conversion operations."""
|
||
|
|
|
||
|
|
SUPPORTED_FORMATS = {
|
||
|
|
'gguf': 'GGUF (llama.cpp)',
|
||
|
|
'safetensors': 'SafeTensors (HuggingFace)',
|
||
|
|
'bin': 'PyTorch Binary',
|
||
|
|
'pt': 'PyTorch',
|
||
|
|
'pth': 'PyTorch State Dict',
|
||
|
|
'onnx': 'ONNX',
|
||
|
|
'tflite': 'TensorFlow Lite',
|
||
|
|
'h5': 'Keras/HDF5'
|
||
|
|
}
|
||
|
|
|
||
|
|
QUANTIZATION_TYPES = {
|
||
|
|
'q4_0': 'Q4_0 - 4-bit (smallest, lower quality)',
|
||
|
|
'q4_1': 'Q4_1 - 4-bit (small, better than q4_0)',
|
||
|
|
'q4_k_m': 'Q4_K_M - 4-bit (medium, recommended)',
|
||
|
|
'q4_k_s': 'Q4_K_S - 4-bit (small)',
|
||
|
|
'q5_0': 'Q5_0 - 5-bit',
|
||
|
|
'q5_1': 'Q5_1 - 5-bit (better than q5_0)',
|
||
|
|
'q5_k_m': 'Q5_K_M - 5-bit (medium, recommended)',
|
||
|
|
'q5_k_s': 'Q5_K_S - 5-bit (small)',
|
||
|
|
'q6_k': 'Q6_K - 6-bit (good quality/size ratio)',
|
||
|
|
'q8_0': 'Q8_0 - 8-bit (high quality)',
|
||
|
|
'f16': 'FP16 - 16-bit float',
|
||
|
|
'f32': 'FP32 - 32-bit float (original)'
|
||
|
|
}
|
||
|
|
|
||
|
|
def __init__(self):
|
||
|
|
self.conversion_queue = queue.Queue()
|
||
|
|
self.current_process = None
|
||
|
|
|
||
|
|
def get_model_info(self, model_path: str) -> Dict[str, Any]:
|
||
|
|
"""Get information about a model file."""
|
||
|
|
path = Path(model_path)
|
||
|
|
if not path.exists():
|
||
|
|
return None
|
||
|
|
|
||
|
|
info = {
|
||
|
|
'name': path.stem,
|
||
|
|
'path': str(path),
|
||
|
|
'format': path.suffix.lower().lstrip('.'),
|
||
|
|
'size': path.stat().st_size,
|
||
|
|
'size_mb': path.stat().st_size / (1024 * 1024),
|
||
|
|
'modified': datetime.fromtimestamp(path.stat().st_mtime)
|
||
|
|
}
|
||
|
|
|
||
|
|
# Try to extract additional metadata
|
||
|
|
if info['format'] == 'gguf':
|
||
|
|
info.update(self._get_gguf_info(path))
|
||
|
|
elif info['format'] in ['safetensors', 'bin']:
|
||
|
|
info.update(self._get_hf_info(path))
|
||
|
|
|
||
|
|
return info
|
||
|
|
|
||
|
|
def _get_gguf_info(self, path: Path) -> Dict[str, Any]:
|
||
|
|
"""Extract GGUF model information."""
|
||
|
|
info = {}
|
||
|
|
try:
|
||
|
|
# Try to use llama-cpp-python if available
|
||
|
|
from llama_cpp import Llama
|
||
|
|
# This would require actually loading the model which is expensive
|
||
|
|
# For now, extract from filename
|
||
|
|
name = path.stem.lower()
|
||
|
|
|
||
|
|
# Detect quantization
|
||
|
|
for q_type in self.QUANTIZATION_TYPES.keys():
|
||
|
|
if q_type in name:
|
||
|
|
info['quantization'] = q_type
|
||
|
|
break
|
||
|
|
|
||
|
|
# Detect model size
|
||
|
|
import re
|
||
|
|
size_match = re.search(r'(\d+)b', name, re.IGNORECASE)
|
||
|
|
if size_match:
|
||
|
|
info['parameters'] = f"{size_match.group(1)}B"
|
||
|
|
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
|
||
|
|
return info
|
||
|
|
|
||
|
|
def _get_hf_info(self, path: Path) -> Dict[str, Any]:
|
||
|
|
"""Extract HuggingFace model information."""
|
||
|
|
info = {}
|
||
|
|
try:
|
||
|
|
# Look for config.json in the same directory
|
||
|
|
config_path = path.parent / "config.json"
|
||
|
|
if config_path.exists():
|
||
|
|
with open(config_path, 'r') as f:
|
||
|
|
config = json.load(f)
|
||
|
|
info['model_type'] = config.get('model_type', 'unknown')
|
||
|
|
info['architectures'] = config.get('architectures', [])
|
||
|
|
info['vocab_size'] = config.get('vocab_size', 0)
|
||
|
|
|
||
|
|
# Calculate parameters if possible
|
||
|
|
if 'hidden_size' in config and 'num_hidden_layers' in config:
|
||
|
|
hidden = config['hidden_size']
|
||
|
|
layers = config['num_hidden_layers']
|
||
|
|
vocab = config.get('vocab_size', 0)
|
||
|
|
# Rough parameter estimation
|
||
|
|
params = (hidden * hidden * 4 * layers + vocab * hidden) / 1e9
|
||
|
|
info['parameters'] = f"{params:.1f}B"
|
||
|
|
except Exception:
|
||
|
|
pass
|
||
|
|
|
||
|
|
return info
|
||
|
|
|
||
|
|
def convert_to_gguf(self, input_path: str, output_path: str,
|
||
|
|
quantization: str = 'q4_k_m',
|
||
|
|
progress_callback: Optional[callable] = None) -> bool:
|
||
|
|
"""Convert a model to GGUF format."""
|
||
|
|
try:
|
||
|
|
input_format = Path(input_path).suffix.lower().lstrip('.')
|
||
|
|
|
||
|
|
if input_format == 'gguf':
|
||
|
|
# Already GGUF, just quantize if needed
|
||
|
|
return self.quantize_gguf(input_path, output_path, quantization, progress_callback)
|
||
|
|
|
||
|
|
# For HuggingFace models, use convert.py from llama.cpp
|
||
|
|
if input_format in ['safetensors', 'bin']:
|
||
|
|
return self._convert_hf_to_gguf(input_path, output_path, quantization, progress_callback)
|
||
|
|
|
||
|
|
# For other formats, try generic conversion
|
||
|
|
return self._generic_convert(input_path, output_path, 'gguf', progress_callback)
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
if progress_callback:
|
||
|
|
progress_callback(f"Error: {e}", 100, "error")
|
||
|
|
return False
|
||
|
|
|
||
|
|
def quantize_gguf(self, input_path: str, output_path: str,
|
||
|
|
quantization: str = 'q4_k_m',
|
||
|
|
progress_callback: Optional[callable] = None) -> bool:
|
||
|
|
"""Quantize a GGUF model."""
|
||
|
|
try:
|
||
|
|
# Look for quantize executable
|
||
|
|
quantize_exe = self._find_quantize_executable()
|
||
|
|
if not quantize_exe:
|
||
|
|
if progress_callback:
|
||
|
|
progress_callback("Quantize executable not found", 100, "error")
|
||
|
|
return False
|
||
|
|
|
||
|
|
# Run quantization
|
||
|
|
cmd = [str(quantize_exe), input_path, output_path, quantization]
|
||
|
|
|
||
|
|
if progress_callback:
|
||
|
|
progress_callback("Starting quantization...", 0, "info")
|
||
|
|
|
||
|
|
process = subprocess.Popen(
|
||
|
|
cmd,
|
||
|
|
stdout=subprocess.PIPE,
|
||
|
|
stderr=subprocess.PIPE,
|
||
|
|
text=True,
|
||
|
|
bufsize=1
|
||
|
|
)
|
||
|
|
|
||
|
|
self.current_process = process
|
||
|
|
|
||
|
|
# Monitor progress
|
||
|
|
while True:
|
||
|
|
line = process.stderr.readline()
|
||
|
|
if not line:
|
||
|
|
break
|
||
|
|
|
||
|
|
if progress_callback:
|
||
|
|
# Parse progress from output
|
||
|
|
if "%" in line:
|
||
|
|
try:
|
||
|
|
import re
|
||
|
|
match = re.search(r'(\d+)%', line)
|
||
|
|
if match:
|
||
|
|
percent = int(match.group(1))
|
||
|
|
progress_callback(line.strip(), percent, "info")
|
||
|
|
except:
|
||
|
|
progress_callback(line.strip(), -1, "info")
|
||
|
|
else:
|
||
|
|
progress_callback(line.strip(), -1, "info")
|
||
|
|
|
||
|
|
process.wait()
|
||
|
|
|
||
|
|
if process.returncode == 0:
|
||
|
|
if progress_callback:
|
||
|
|
progress_callback("Quantization complete!", 100, "success")
|
||
|
|
return True
|
||
|
|
else:
|
||
|
|
if progress_callback:
|
||
|
|
progress_callback("Quantization failed", 100, "error")
|
||
|
|
return False
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
if progress_callback:
|
||
|
|
progress_callback(f"Error: {e}", 100, "error")
|
||
|
|
return False
|
||
|
|
finally:
|
||
|
|
self.current_process = None
|
||
|
|
|
||
|
|
def _find_quantize_executable(self) -> Optional[Path]:
|
||
|
|
"""Find the quantize executable."""
|
||
|
|
# Common locations
|
||
|
|
locations = [
|
||
|
|
Path("llama-cpp-python/vendor/llama.cpp/quantize"),
|
||
|
|
Path("llama.cpp/quantize"),
|
||
|
|
Path("bin/quantize"),
|
||
|
|
Path("quantize"),
|
||
|
|
Path("quantize.exe")
|
||
|
|
]
|
||
|
|
|
||
|
|
for loc in locations:
|
||
|
|
if loc.exists():
|
||
|
|
return loc
|
||
|
|
|
||
|
|
# Check in PATH
|
||
|
|
import shutil
|
||
|
|
exe = shutil.which("quantize")
|
||
|
|
if exe:
|
||
|
|
return Path(exe)
|
||
|
|
|
||
|
|
return None
|
||
|
|
|
||
|
|
def _convert_hf_to_gguf(self, input_path: str, output_path: str,
|
||
|
|
quantization: str,
|
||
|
|
progress_callback: Optional[callable] = None) -> bool:
|
||
|
|
"""Convert HuggingFace model to GGUF."""
|
||
|
|
try:
|
||
|
|
# Look for convert.py script
|
||
|
|
convert_script = self._find_convert_script()
|
||
|
|
if not convert_script:
|
||
|
|
if progress_callback:
|
||
|
|
progress_callback("Convert script not found", 100, "error")
|
||
|
|
return False
|
||
|
|
|
||
|
|
# First convert to FP16 GGUF
|
||
|
|
temp_gguf = output_path.replace('.gguf', '_fp16.gguf')
|
||
|
|
|
||
|
|
cmd = [
|
||
|
|
sys.executable,
|
||
|
|
str(convert_script),
|
||
|
|
str(Path(input_path).parent), # Model directory
|
||
|
|
"--outfile", temp_gguf,
|
||
|
|
"--outtype", "f16"
|
||
|
|
]
|
||
|
|
|
||
|
|
if progress_callback:
|
||
|
|
progress_callback("Converting to GGUF format...", 0, "info")
|
||
|
|
|
||
|
|
process = subprocess.Popen(
|
||
|
|
cmd,
|
||
|
|
stdout=subprocess.PIPE,
|
||
|
|
stderr=subprocess.PIPE,
|
||
|
|
text=True
|
||
|
|
)
|
||
|
|
|
||
|
|
stdout, stderr = process.communicate()
|
||
|
|
|
||
|
|
if process.returncode != 0:
|
||
|
|
if progress_callback:
|
||
|
|
progress_callback(f"Conversion failed: {stderr}", 100, "error")
|
||
|
|
return False
|
||
|
|
|
||
|
|
if progress_callback:
|
||
|
|
progress_callback("Conversion complete, quantizing...", 50, "info")
|
||
|
|
|
||
|
|
# Then quantize if needed
|
||
|
|
if quantization != 'f16':
|
||
|
|
result = self.quantize_gguf(temp_gguf, output_path, quantization, progress_callback)
|
||
|
|
# Clean up temp file
|
||
|
|
try:
|
||
|
|
os.remove(temp_gguf)
|
||
|
|
except:
|
||
|
|
pass
|
||
|
|
return result
|
||
|
|
else:
|
||
|
|
# Just rename
|
||
|
|
shutil.move(temp_gguf, output_path)
|
||
|
|
if progress_callback:
|
||
|
|
progress_callback("Conversion complete!", 100, "success")
|
||
|
|
return True
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
if progress_callback:
|
||
|
|
progress_callback(f"Error: {e}", 100, "error")
|
||
|
|
return False
|
||
|
|
|
||
|
|
def _find_convert_script(self) -> Optional[Path]:
|
||
|
|
"""Find the convert.py script."""
|
||
|
|
locations = [
|
||
|
|
Path("llama-cpp-python/vendor/llama.cpp/convert.py"),
|
||
|
|
Path("llama.cpp/convert.py"),
|
||
|
|
Path("scripts/convert.py"),
|
||
|
|
Path("convert.py")
|
||
|
|
]
|
||
|
|
|
||
|
|
for loc in locations:
|
||
|
|
if loc.exists():
|
||
|
|
return loc
|
||
|
|
|
||
|
|
return None
|
||
|
|
|
||
|
|
def _generic_convert(self, input_path: str, output_path: str,
|
||
|
|
target_format: str,
|
||
|
|
progress_callback: Optional[callable] = None) -> bool:
|
||
|
|
"""Generic conversion using available tools."""
|
||
|
|
# This would use tools like ONNX converters, TensorFlow converters, etc.
|
||
|
|
# For now, return False as not implemented
|
||
|
|
if progress_callback:
|
||
|
|
progress_callback(f"Conversion to {target_format} not yet implemented", 100, "error")
|
||
|
|
return False
|
||
|
|
|
||
|
|
def merge_lora(self, base_model: str, lora_path: str, output_path: str,
|
||
|
|
progress_callback: Optional[callable] = None) -> bool:
|
||
|
|
"""Merge a LoRA adapter into a base model."""
|
||
|
|
try:
|
||
|
|
# This would use a LoRA merging tool
|
||
|
|
# For now, simplified implementation
|
||
|
|
if progress_callback:
|
||
|
|
progress_callback("LoRA merging not yet implemented", 100, "error")
|
||
|
|
return False
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
if progress_callback:
|
||
|
|
progress_callback(f"Error: {e}", 100, "error")
|
||
|
|
return False
|
||
|
|
|
||
|
|
def split_model(self, input_path: str, output_dir: str,
|
||
|
|
num_shards: int = 2,
|
||
|
|
progress_callback: Optional[callable] = None) -> bool:
|
||
|
|
"""Split a model into multiple shards."""
|
||
|
|
try:
|
||
|
|
# This would split large models for easier distribution
|
||
|
|
if progress_callback:
|
||
|
|
progress_callback("Model splitting not yet implemented", 100, "error")
|
||
|
|
return False
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
if progress_callback:
|
||
|
|
progress_callback(f"Error: {e}", 100, "error")
|
||
|
|
return False
|
||
|
|
|
||
|
|
|
||
|
|
class ModelConverterTab:
|
||
|
|
"""Model conversion and editing tab for DarkHal 2.0."""
|
||
|
|
|
||
|
|
def __init__(self, parent: ttk.Frame, settings_manager):
|
||
|
|
self.parent = parent
|
||
|
|
self.settings = settings_manager
|
||
|
|
self.converter = ModelConverter()
|
||
|
|
self.current_model = None
|
||
|
|
|
||
|
|
self._build_ui()
|
||
|
|
|
||
|
|
def _build_ui(self):
|
||
|
|
"""Build the converter tab UI."""
|
||
|
|
# Create paned window for split view
|
||
|
|
paned = ttk.PanedWindow(self.parent, orient=tk.HORIZONTAL)
|
||
|
|
paned.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
|
||
|
|
|
||
|
|
# Left panel - Model Selection and Info
|
||
|
|
left_frame = ttk.Frame(paned)
|
||
|
|
paned.add(left_frame, weight=1)
|
||
|
|
|
||
|
|
# Model Selection Frame
|
||
|
|
select_frame = ttk.LabelFrame(left_frame, text="Model Selection", padding=10)
|
||
|
|
select_frame.pack(fill=tk.X, pady=(0, 10))
|
||
|
|
|
||
|
|
# Input model
|
||
|
|
ttk.Label(select_frame, text="Input Model:").pack(anchor=tk.W)
|
||
|
|
|
||
|
|
input_frame = ttk.Frame(select_frame)
|
||
|
|
input_frame.pack(fill=tk.X, pady=(5, 10))
|
||
|
|
|
||
|
|
self.input_path_var = tk.StringVar()
|
||
|
|
self.input_entry = ttk.Entry(input_frame, textvariable=self.input_path_var)
|
||
|
|
self.input_entry.pack(side=tk.LEFT, fill=tk.X, expand=True)
|
||
|
|
|
||
|
|
ttk.Button(input_frame, text="Browse", command=self._browse_input).pack(side=tk.LEFT, padx=(5, 0))
|
||
|
|
ttk.Button(input_frame, text="Analyze", command=self._analyze_model).pack(side=tk.LEFT, padx=(5, 0))
|
||
|
|
|
||
|
|
# Model Information Frame
|
||
|
|
info_frame = ttk.LabelFrame(left_frame, text="Model Information", padding=10)
|
||
|
|
info_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10))
|
||
|
|
|
||
|
|
self.info_text = scrolledtext.ScrolledText(info_frame, height=10, wrap=tk.WORD)
|
||
|
|
self.info_text.pack(fill=tk.BOTH, expand=True)
|
||
|
|
|
||
|
|
# Conversion Options Frame
|
||
|
|
options_frame = ttk.LabelFrame(left_frame, text="Conversion Options", padding=10)
|
||
|
|
options_frame.pack(fill=tk.X)
|
||
|
|
|
||
|
|
# Output format
|
||
|
|
ttk.Label(options_frame, text="Output Format:").grid(row=0, column=0, sticky=tk.W, pady=5)
|
||
|
|
self.output_format_var = tk.StringVar(value="gguf")
|
||
|
|
format_combo = ttk.Combobox(options_frame, textvariable=self.output_format_var,
|
||
|
|
values=list(ModelConverter.SUPPORTED_FORMATS.keys()),
|
||
|
|
state="readonly", width=20)
|
||
|
|
format_combo.grid(row=0, column=1, sticky=tk.W, pady=5)
|
||
|
|
format_combo.bind('<<ComboboxSelected>>', self._on_format_change)
|
||
|
|
|
||
|
|
# Quantization (for GGUF)
|
||
|
|
ttk.Label(options_frame, text="Quantization:").grid(row=1, column=0, sticky=tk.W, pady=5)
|
||
|
|
self.quantization_var = tk.StringVar(value="q4_k_m")
|
||
|
|
self.quant_combo = ttk.Combobox(options_frame, textvariable=self.quantization_var,
|
||
|
|
values=list(ModelConverter.QUANTIZATION_TYPES.keys()),
|
||
|
|
state="readonly", width=20)
|
||
|
|
self.quant_combo.grid(row=1, column=1, sticky=tk.W, pady=5)
|
||
|
|
|
||
|
|
# Output path
|
||
|
|
ttk.Label(options_frame, text="Output Path:").grid(row=2, column=0, sticky=tk.W, pady=5)
|
||
|
|
|
||
|
|
output_frame = ttk.Frame(options_frame)
|
||
|
|
output_frame.grid(row=2, column=1, sticky=tk.W+tk.E, pady=5)
|
||
|
|
|
||
|
|
self.output_path_var = tk.StringVar()
|
||
|
|
self.output_entry = ttk.Entry(output_frame, textvariable=self.output_path_var, width=30)
|
||
|
|
self.output_entry.pack(side=tk.LEFT, fill=tk.X, expand=True)
|
||
|
|
|
||
|
|
ttk.Button(output_frame, text="Browse", command=self._browse_output).pack(side=tk.LEFT, padx=(5, 0))
|
||
|
|
|
||
|
|
# Conversion button
|
||
|
|
self.convert_btn = ttk.Button(options_frame, text="Start Conversion",
|
||
|
|
command=self._start_conversion)
|
||
|
|
self.convert_btn.grid(row=3, column=0, columnspan=2, pady=(10, 0))
|
||
|
|
|
||
|
|
# Right panel - Advanced Tools and Progress
|
||
|
|
right_frame = ttk.Frame(paned)
|
||
|
|
paned.add(right_frame, weight=1)
|
||
|
|
|
||
|
|
# Advanced Tools Frame
|
||
|
|
tools_frame = ttk.LabelFrame(right_frame, text="Advanced Tools", padding=10)
|
||
|
|
tools_frame.pack(fill=tk.X, pady=(0, 10))
|
||
|
|
|
||
|
|
# Tool buttons
|
||
|
|
tool_grid = ttk.Frame(tools_frame)
|
||
|
|
tool_grid.pack(fill=tk.X)
|
||
|
|
|
||
|
|
ttk.Button(tool_grid, text="Merge LoRA", command=self._open_lora_merger).grid(row=0, column=0, padx=2, pady=2)
|
||
|
|
ttk.Button(tool_grid, text="Split Model", command=self._open_model_splitter).grid(row=0, column=1, padx=2, pady=2)
|
||
|
|
ttk.Button(tool_grid, text="Optimize", command=self._open_optimizer).grid(row=0, column=2, padx=2, pady=2)
|
||
|
|
|
||
|
|
ttk.Button(tool_grid, text="Batch Convert", command=self._open_batch_converter).grid(row=1, column=0, padx=2, pady=2)
|
||
|
|
ttk.Button(tool_grid, text="Compare Models", command=self._open_model_compare).grid(row=1, column=1, padx=2, pady=2)
|
||
|
|
ttk.Button(tool_grid, text="Edit Metadata", command=self._open_metadata_editor).grid(row=1, column=2, padx=2, pady=2)
|
||
|
|
|
||
|
|
# Quantization Comparison
|
||
|
|
compare_frame = ttk.LabelFrame(right_frame, text="Quantization Comparison", padding=10)
|
||
|
|
compare_frame.pack(fill=tk.X, pady=(0, 10))
|
||
|
|
|
||
|
|
# Comparison table
|
||
|
|
columns = ("Type", "Size", "Quality", "Speed")
|
||
|
|
self.compare_tree = ttk.Treeview(compare_frame, columns=columns, show="headings", height=6)
|
||
|
|
|
||
|
|
self.compare_tree.heading("Type", text="Type")
|
||
|
|
self.compare_tree.heading("Size", text="Size")
|
||
|
|
self.compare_tree.heading("Quality", text="Quality")
|
||
|
|
self.compare_tree.heading("Speed", text="Speed")
|
||
|
|
|
||
|
|
self.compare_tree.column("Type", width=80)
|
||
|
|
self.compare_tree.column("Size", width=80)
|
||
|
|
self.compare_tree.column("Quality", width=80)
|
||
|
|
self.compare_tree.column("Speed", width=80)
|
||
|
|
|
||
|
|
self.compare_tree.pack(fill=tk.X)
|
||
|
|
|
||
|
|
# Populate comparison table
|
||
|
|
self._populate_comparison()
|
||
|
|
|
||
|
|
# Progress Frame
|
||
|
|
progress_frame = ttk.LabelFrame(right_frame, text="Conversion Progress", padding=10)
|
||
|
|
progress_frame.pack(fill=tk.BOTH, expand=True)
|
||
|
|
|
||
|
|
# Progress bar
|
||
|
|
self.progress_var = tk.DoubleVar()
|
||
|
|
self.progress_bar = ttk.Progressbar(progress_frame, variable=self.progress_var,
|
||
|
|
maximum=100, length=400)
|
||
|
|
self.progress_bar.pack(fill=tk.X, pady=(0, 10))
|
||
|
|
|
||
|
|
# Progress log
|
||
|
|
self.progress_text = scrolledtext.ScrolledText(progress_frame, height=10, wrap=tk.WORD,
|
||
|
|
bg="#1a1a1a", fg="#00ff88",
|
||
|
|
font=("Consolas", 9))
|
||
|
|
self.progress_text.pack(fill=tk.BOTH, expand=True)
|
||
|
|
|
||
|
|
# Control buttons
|
||
|
|
control_frame = ttk.Frame(progress_frame)
|
||
|
|
control_frame.pack(fill=tk.X, pady=(5, 0))
|
||
|
|
|
||
|
|
self.cancel_btn = ttk.Button(control_frame, text="Cancel", command=self._cancel_conversion,
|
||
|
|
state=tk.DISABLED)
|
||
|
|
self.cancel_btn.pack(side=tk.LEFT)
|
||
|
|
|
||
|
|
ttk.Button(control_frame, text="Clear Log", command=self._clear_log).pack(side=tk.LEFT, padx=5)
|
||
|
|
|
||
|
|
def _populate_comparison(self):
|
||
|
|
"""Populate the quantization comparison table."""
|
||
|
|
comparisons = [
|
||
|
|
("Q4_0", "~3.5GB", "★★☆☆☆", "★★★★★"),
|
||
|
|
("Q4_K_M", "~3.8GB", "★★★☆☆", "★★★★★"),
|
||
|
|
("Q5_K_M", "~4.5GB", "★★★★☆", "★★★★☆"),
|
||
|
|
("Q6_K", "~5.5GB", "★★★★☆", "★★★☆☆"),
|
||
|
|
("Q8_0", "~7GB", "★★★★★", "★★☆☆☆"),
|
||
|
|
("FP16", "~14GB", "★★★★★", "★☆☆☆☆"),
|
||
|
|
]
|
||
|
|
|
||
|
|
for comp in comparisons:
|
||
|
|
self.compare_tree.insert("", tk.END, values=comp)
|
||
|
|
|
||
|
|
def _browse_input(self):
|
||
|
|
"""Browse for input model."""
|
||
|
|
filename = filedialog.askopenfilename(
|
||
|
|
title="Select Model File",
|
||
|
|
filetypes=[
|
||
|
|
("All Models", "*.gguf;*.safetensors;*.bin;*.pt;*.pth;*.onnx"),
|
||
|
|
("GGUF", "*.gguf"),
|
||
|
|
("SafeTensors", "*.safetensors"),
|
||
|
|
("PyTorch", "*.bin;*.pt;*.pth"),
|
||
|
|
("ONNX", "*.onnx"),
|
||
|
|
("All Files", "*.*")
|
||
|
|
]
|
||
|
|
)
|
||
|
|
|
||
|
|
if filename:
|
||
|
|
self.input_path_var.set(filename)
|
||
|
|
self._analyze_model()
|
||
|
|
|
||
|
|
# Auto-generate output path
|
||
|
|
input_path = Path(filename)
|
||
|
|
output_format = self.output_format_var.get()
|
||
|
|
quantization = self.quantization_var.get()
|
||
|
|
|
||
|
|
output_name = f"{input_path.stem}_{quantization}.{output_format}"
|
||
|
|
output_path = input_path.parent / output_name
|
||
|
|
self.output_path_var.set(str(output_path))
|
||
|
|
|
||
|
|
def _browse_output(self):
|
||
|
|
"""Browse for output location."""
|
||
|
|
filename = filedialog.asksaveasfilename(
|
||
|
|
title="Save Converted Model As",
|
||
|
|
defaultextension=f".{self.output_format_var.get()}",
|
||
|
|
filetypes=[
|
||
|
|
(f"{self.output_format_var.get().upper()}", f"*.{self.output_format_var.get()}"),
|
||
|
|
("All Files", "*.*")
|
||
|
|
]
|
||
|
|
)
|
||
|
|
|
||
|
|
if filename:
|
||
|
|
self.output_path_var.set(filename)
|
||
|
|
|
||
|
|
def _analyze_model(self):
|
||
|
|
"""Analyze the selected model."""
|
||
|
|
model_path = self.input_path_var.get()
|
||
|
|
if not model_path or not os.path.exists(model_path):
|
||
|
|
messagebox.showerror("Error", "Please select a valid model file")
|
||
|
|
return
|
||
|
|
|
||
|
|
self.info_text.delete(1.0, tk.END)
|
||
|
|
self.info_text.insert(tk.END, "Analyzing model...\n\n")
|
||
|
|
|
||
|
|
# Get model info
|
||
|
|
info = self.converter.get_model_info(model_path)
|
||
|
|
if info:
|
||
|
|
self.current_model = info
|
||
|
|
|
||
|
|
# Display info
|
||
|
|
self.info_text.insert(tk.END, f"Name: {info['name']}\n")
|
||
|
|
self.info_text.insert(tk.END, f"Format: {info['format'].upper()}\n")
|
||
|
|
self.info_text.insert(tk.END, f"Size: {info['size_mb']:.1f} MB\n")
|
||
|
|
self.info_text.insert(tk.END, f"Modified: {info['modified'].strftime('%Y-%m-%d %H:%M')}\n")
|
||
|
|
|
||
|
|
if 'quantization' in info:
|
||
|
|
self.info_text.insert(tk.END, f"Quantization: {info['quantization']}\n")
|
||
|
|
if 'parameters' in info:
|
||
|
|
self.info_text.insert(tk.END, f"Parameters: {info['parameters']}\n")
|
||
|
|
if 'model_type' in info:
|
||
|
|
self.info_text.insert(tk.END, f"Model Type: {info['model_type']}\n")
|
||
|
|
if 'architectures' in info:
|
||
|
|
self.info_text.insert(tk.END, f"Architecture: {', '.join(info['architectures'])}\n")
|
||
|
|
if 'vocab_size' in info:
|
||
|
|
self.info_text.insert(tk.END, f"Vocab Size: {info['vocab_size']:,}\n")
|
||
|
|
|
||
|
|
def _on_format_change(self, event=None):
|
||
|
|
"""Handle output format change."""
|
||
|
|
format_type = self.output_format_var.get()
|
||
|
|
|
||
|
|
# Enable/disable quantization based on format
|
||
|
|
if format_type == 'gguf':
|
||
|
|
self.quant_combo.config(state="readonly")
|
||
|
|
else:
|
||
|
|
self.quant_combo.config(state="disabled")
|
||
|
|
|
||
|
|
# Update output path
|
||
|
|
if self.input_path_var.get():
|
||
|
|
input_path = Path(self.input_path_var.get())
|
||
|
|
quantization = self.quantization_var.get() if format_type == 'gguf' else ''
|
||
|
|
|
||
|
|
if quantization:
|
||
|
|
output_name = f"{input_path.stem}_{quantization}.{format_type}"
|
||
|
|
else:
|
||
|
|
output_name = f"{input_path.stem}.{format_type}"
|
||
|
|
|
||
|
|
output_path = input_path.parent / output_name
|
||
|
|
self.output_path_var.set(str(output_path))
|
||
|
|
|
||
|
|
def _start_conversion(self):
|
||
|
|
"""Start the conversion process."""
|
||
|
|
input_path = self.input_path_var.get()
|
||
|
|
output_path = self.output_path_var.get()
|
||
|
|
|
||
|
|
if not input_path or not os.path.exists(input_path):
|
||
|
|
messagebox.showerror("Error", "Please select a valid input model")
|
||
|
|
return
|
||
|
|
|
||
|
|
if not output_path:
|
||
|
|
messagebox.showerror("Error", "Please specify an output path")
|
||
|
|
return
|
||
|
|
|
||
|
|
# Confirm overwrite if exists
|
||
|
|
if os.path.exists(output_path):
|
||
|
|
if not messagebox.askyesno("Confirm", f"Output file exists. Overwrite?\n{output_path}"):
|
||
|
|
return
|
||
|
|
|
||
|
|
# Disable UI
|
||
|
|
self.convert_btn.config(state=tk.DISABLED)
|
||
|
|
self.cancel_btn.config(state=tk.NORMAL)
|
||
|
|
|
||
|
|
# Clear progress
|
||
|
|
self.progress_var.set(0)
|
||
|
|
self.progress_text.delete(1.0, tk.END)
|
||
|
|
|
||
|
|
# Start conversion in thread
|
||
|
|
threading.Thread(target=self._conversion_thread,
|
||
|
|
args=(input_path, output_path),
|
||
|
|
daemon=True).start()
|
||
|
|
|
||
|
|
def _conversion_thread(self, input_path: str, output_path: str):
|
||
|
|
"""Run conversion in background thread."""
|
||
|
|
def progress_callback(message: str, percent: int, level: str = "info"):
|
||
|
|
# Update UI in main thread
|
||
|
|
self.parent.after(0, self._update_progress, message, percent, level)
|
||
|
|
|
||
|
|
try:
|
||
|
|
output_format = self.output_format_var.get()
|
||
|
|
|
||
|
|
if output_format == 'gguf':
|
||
|
|
quantization = self.quantization_var.get()
|
||
|
|
success = self.converter.convert_to_gguf(
|
||
|
|
input_path, output_path, quantization, progress_callback
|
||
|
|
)
|
||
|
|
else:
|
||
|
|
# Other format conversions
|
||
|
|
success = False
|
||
|
|
progress_callback(f"Conversion to {output_format} not yet implemented", 100, "error")
|
||
|
|
|
||
|
|
if success:
|
||
|
|
self.parent.after(0, self._conversion_complete, True)
|
||
|
|
else:
|
||
|
|
self.parent.after(0, self._conversion_complete, False)
|
||
|
|
|
||
|
|
except Exception as e:
|
||
|
|
progress_callback(f"Error: {e}", 100, "error")
|
||
|
|
self.parent.after(0, self._conversion_complete, False)
|
||
|
|
|
||
|
|
def _update_progress(self, message: str, percent: int, level: str):
|
||
|
|
"""Update progress display."""
|
||
|
|
# Update progress bar
|
||
|
|
if percent >= 0:
|
||
|
|
self.progress_var.set(percent)
|
||
|
|
|
||
|
|
# Add to log
|
||
|
|
timestamp = datetime.now().strftime("%H:%M:%S")
|
||
|
|
|
||
|
|
# Color based on level
|
||
|
|
if level == "error":
|
||
|
|
self.progress_text.insert(tk.END, f"[{timestamp}] ERROR: {message}\n", "error")
|
||
|
|
elif level == "success":
|
||
|
|
self.progress_text.insert(tk.END, f"[{timestamp}] SUCCESS: {message}\n", "success")
|
||
|
|
else:
|
||
|
|
self.progress_text.insert(tk.END, f"[{timestamp}] {message}\n")
|
||
|
|
|
||
|
|
self.progress_text.see(tk.END)
|
||
|
|
|
||
|
|
def _conversion_complete(self, success: bool):
|
||
|
|
"""Handle conversion completion."""
|
||
|
|
self.convert_btn.config(state=tk.NORMAL)
|
||
|
|
self.cancel_btn.config(state=tk.DISABLED)
|
||
|
|
|
||
|
|
if success:
|
||
|
|
messagebox.showinfo("Success", "Model conversion completed successfully!")
|
||
|
|
else:
|
||
|
|
messagebox.showerror("Error", "Model conversion failed. Check the log for details.")
|
||
|
|
|
||
|
|
def _cancel_conversion(self):
|
||
|
|
"""Cancel the current conversion."""
|
||
|
|
if self.converter.current_process:
|
||
|
|
self.converter.current_process.terminate()
|
||
|
|
self._update_progress("Conversion cancelled", 0, "error")
|
||
|
|
self.convert_btn.config(state=tk.NORMAL)
|
||
|
|
self.cancel_btn.config(state=tk.DISABLED)
|
||
|
|
|
||
|
|
def _clear_log(self):
|
||
|
|
"""Clear the progress log."""
|
||
|
|
self.progress_text.delete(1.0, tk.END)
|
||
|
|
self.progress_var.set(0)
|
||
|
|
|
||
|
|
def _open_lora_merger(self):
|
||
|
|
"""Open LoRA merger dialog."""
|
||
|
|
messagebox.showinfo("LoRA Merger", "LoRA merging tool coming soon!")
|
||
|
|
|
||
|
|
def _open_model_splitter(self):
|
||
|
|
"""Open model splitter dialog."""
|
||
|
|
messagebox.showinfo("Model Splitter", "Model splitting tool coming soon!")
|
||
|
|
|
||
|
|
def _open_optimizer(self):
|
||
|
|
"""Open model optimizer dialog."""
|
||
|
|
messagebox.showinfo("Model Optimizer", "Model optimization tool coming soon!")
|
||
|
|
|
||
|
|
def _open_batch_converter(self):
|
||
|
|
"""Open batch converter dialog."""
|
||
|
|
messagebox.showinfo("Batch Converter", "Batch conversion tool coming soon!")
|
||
|
|
|
||
|
|
def _open_model_compare(self):
|
||
|
|
"""Open model comparison dialog."""
|
||
|
|
messagebox.showinfo("Model Compare", "Model comparison tool coming soon!")
|
||
|
|
|
||
|
|
def _open_metadata_editor(self):
|
||
|
|
"""Open metadata editor dialog."""
|
||
|
|
messagebox.showinfo("Metadata Editor", "Metadata editing tool coming soon!")
|