feat: Add custom model download functionality and enhance model management

- Implemented `model-download-custom` command to download HuggingFace models.
- Added support for discovering manually placed models in the cache.
- Enhanced the model list view to display recommended and discovered models separately.
- Introduced JSON editor for direct configuration mode in API settings.
- Added validation and formatting features for JSON input.
- Updated translations for new API settings and common actions.
- Improved user interface for model management, including action buttons and tooltips.
This commit is contained in:
catlog22
2026-01-11 15:13:11 +08:00
parent 16083130f8
commit 1e91fa9f9e
7 changed files with 1268 additions and 7 deletions

View File

@@ -1995,6 +1995,55 @@ def model_delete(
console.print(f" Freed space: {data['deleted_size_mb']:.1f} MB")
@app.command(name="model-download-custom")
def model_download_custom(
model_name: str = typer.Argument(..., help="Full HuggingFace model name (e.g., BAAI/bge-small-en-v1.5)."),
model_type: str = typer.Option("embedding", "--type", help="Model type: embedding or reranker."),
json_mode: bool = typer.Option(False, "--json", help="Output JSON response."),
) -> None:
"""Download a custom HuggingFace model by name.
This allows downloading any fastembed-compatible model from HuggingFace.
Example:
codexlens model-download-custom BAAI/bge-small-en-v1.5
codexlens model-download-custom BAAI/bge-reranker-base --type reranker
"""
try:
from codexlens.cli.model_manager import download_custom_model
if not json_mode:
console.print(f"[bold]Downloading custom model:[/bold] {model_name}")
console.print(f"[dim]Model type: {model_type}[/dim]")
console.print("[dim]This may take a few minutes depending on your internet connection...[/dim]\n")
progress_callback = None if json_mode else lambda msg: console.print(f"[cyan]{msg}[/cyan]")
result = download_custom_model(model_name, model_type=model_type, progress_callback=progress_callback)
if json_mode:
print_json(**result)
else:
if not result["success"]:
console.print(f"[red]Error:[/red] {result.get('error', 'Unknown error')}")
raise typer.Exit(code=1)
data = result["result"]
console.print(f"[green]✓[/green] Custom model downloaded successfully!")
console.print(f" Model: {data['model_name']}")
console.print(f" Type: {data['model_type']}")
console.print(f" Cache size: {data['cache_size_mb']:.1f} MB")
console.print(f" Location: [dim]{data['cache_path']}[/dim]")
except ImportError:
if json_mode:
print_json(success=False, error="fastembed not installed. Install with: pip install codexlens[semantic]")
else:
console.print("[red]Error:[/red] fastembed not installed")
console.print("[yellow]Install with:[/yellow] pip install codexlens[semantic]")
raise typer.Exit(code=1)
@app.command(name="model-info")
def model_info(
profile: str = typer.Argument(..., help="Model profile to get info (fast, code, multilingual, balanced)."),

View File

@@ -76,6 +76,31 @@ RERANKER_MODEL_PROFILES = {
"use_case": "Fast reranking with good accuracy",
"recommended": True,
},
# Additional reranker models (commonly used)
"bge-reranker-v2-m3": {
"model_name": "BAAI/bge-reranker-v2-m3",
"cache_name": "BAAI/bge-reranker-v2-m3",
"size_mb": 560,
"description": "BGE v2 M3 reranker, multilingual",
"use_case": "Multilingual reranking, latest BGE version",
"recommended": True,
},
"bge-reranker-v2-gemma": {
"model_name": "BAAI/bge-reranker-v2-gemma",
"cache_name": "BAAI/bge-reranker-v2-gemma",
"size_mb": 2000,
"description": "BGE v2 Gemma reranker, best quality",
"use_case": "Maximum quality with Gemma backbone",
"recommended": False,
},
"cross-encoder-ms-marco": {
"model_name": "cross-encoder/ms-marco-MiniLM-L-6-v2",
"cache_name": "cross-encoder/ms-marco-MiniLM-L-6-v2",
"size_mb": 90,
"description": "Original cross-encoder MS MARCO",
"use_case": "Classic cross-encoder baseline",
"recommended": False,
},
}
@@ -138,6 +163,106 @@ MODEL_PROFILES = {
"use_case": "High-quality semantic search, balanced performance",
"recommended": False, # 1024d not recommended
},
# Additional embedding models (commonly used)
"bge-large": {
"model_name": "BAAI/bge-large-en-v1.5",
"cache_name": "qdrant/bge-large-en-v1.5-onnx-q",
"dimensions": 1024,
"size_mb": 650,
"description": "BGE large model, highest quality",
"use_case": "Maximum quality semantic search",
"recommended": False,
},
"e5-small": {
"model_name": "intfloat/e5-small-v2",
"cache_name": "qdrant/e5-small-v2-onnx",
"dimensions": 384,
"size_mb": 80,
"description": "E5 small model, fast and lightweight",
"use_case": "Low latency applications",
"recommended": True,
},
"e5-base": {
"model_name": "intfloat/e5-base-v2",
"cache_name": "qdrant/e5-base-v2-onnx",
"dimensions": 768,
"size_mb": 220,
"description": "E5 base model, balanced",
"use_case": "General purpose semantic search",
"recommended": True,
},
"e5-large": {
"model_name": "intfloat/e5-large-v2",
"cache_name": "qdrant/e5-large-v2-onnx",
"dimensions": 1024,
"size_mb": 650,
"description": "E5 large model, high quality",
"use_case": "High quality semantic search",
"recommended": False,
},
"jina-base-en": {
"model_name": "jinaai/jina-embeddings-v2-base-en",
"cache_name": "jinaai/jina-embeddings-v2-base-en",
"dimensions": 768,
"size_mb": 150,
"description": "Jina base English model",
"use_case": "English text semantic search",
"recommended": True,
},
"jina-small-en": {
"model_name": "jinaai/jina-embeddings-v2-small-en",
"cache_name": "jinaai/jina-embeddings-v2-small-en",
"dimensions": 512,
"size_mb": 60,
"description": "Jina small English model, very fast",
"use_case": "Low latency English text search",
"recommended": True,
},
"snowflake-arctic": {
"model_name": "Snowflake/snowflake-arctic-embed-m",
"cache_name": "Snowflake/snowflake-arctic-embed-m",
"dimensions": 768,
"size_mb": 220,
"description": "Snowflake Arctic embedding model",
"use_case": "Enterprise semantic search, high quality",
"recommended": True,
},
"nomic-embed": {
"model_name": "nomic-ai/nomic-embed-text-v1.5",
"cache_name": "nomic-ai/nomic-embed-text-v1.5",
"dimensions": 768,
"size_mb": 280,
"description": "Nomic embedding model, open source",
"use_case": "Open source text embedding",
"recommended": True,
},
"gte-small": {
"model_name": "thenlper/gte-small",
"cache_name": "thenlper/gte-small",
"dimensions": 384,
"size_mb": 70,
"description": "GTE small model, fast",
"use_case": "Fast text embedding",
"recommended": True,
},
"gte-base": {
"model_name": "thenlper/gte-base",
"cache_name": "thenlper/gte-base",
"dimensions": 768,
"size_mb": 220,
"description": "GTE base model, balanced",
"use_case": "General purpose text embedding",
"recommended": True,
},
"gte-large": {
"model_name": "thenlper/gte-large",
"cache_name": "thenlper/gte-large",
"dimensions": 1024,
"size_mb": 650,
"description": "GTE large model, high quality",
"use_case": "High quality text embedding",
"recommended": False,
},
}
@@ -179,6 +304,92 @@ def _get_model_cache_path(cache_dir: Path, info: Dict) -> Path:
return cache_dir / sanitized_name
def scan_discovered_models(model_type: str = "embedding") -> List[Dict]:
"""Scan cache directory for manually placed models not in predefined profiles.
This allows users to manually download models (e.g., via huggingface-cli or
by copying the model directory) and have them recognized automatically.
Args:
model_type: Type of models to scan for ("embedding" or "reranker")
Returns:
List of discovered model info dictionaries
"""
cache_dir = get_cache_dir()
if not cache_dir.exists():
return []
# Get known model cache names based on type
if model_type == "reranker":
known_cache_names = {
f"models--{info.get('cache_name', info['model_name']).replace('/', '--')}"
for info in RERANKER_MODEL_PROFILES.values()
}
else:
known_cache_names = {
f"models--{info.get('cache_name', info['model_name']).replace('/', '--')}"
for info in MODEL_PROFILES.values()
}
discovered = []
# Scan for model directories in cache
for item in cache_dir.iterdir():
if not item.is_dir() or not item.name.startswith("models--"):
continue
# Skip known predefined models
if item.name in known_cache_names:
continue
# Parse model name from directory (models--org--model -> org/model)
parts = item.name[8:].split("--") # Remove "models--" prefix
if len(parts) >= 2:
model_name = "/".join(parts)
else:
model_name = parts[0] if parts else item.name
# Detect model type by checking for common patterns
is_reranker = any(keyword in model_name.lower() for keyword in [
"reranker", "cross-encoder", "ms-marco"
])
is_embedding = any(keyword in model_name.lower() for keyword in [
"embed", "bge", "e5", "jina", "minilm", "gte", "nomic", "arctic"
])
# Filter based on requested type
if model_type == "reranker" and not is_reranker:
continue
if model_type == "embedding" and is_reranker:
continue
# Calculate cache size
try:
total_size = sum(
f.stat().st_size
for f in item.rglob("*")
if f.is_file()
)
cache_size_mb = round(total_size / (1024 * 1024), 1)
except (OSError, PermissionError):
cache_size_mb = 0
discovered.append({
"profile": f"discovered:{model_name.replace('/', '-')}",
"model_name": model_name,
"cache_name": model_name,
"cache_path": str(item),
"actual_size_mb": cache_size_mb,
"description": f"Manually discovered model",
"use_case": "User-provided model",
"installed": True,
"source": "discovered", # Mark as discovered
})
return discovered
def list_models() -> Dict[str, any]:
"""List available model profiles and their installation status.
@@ -224,14 +435,45 @@ def list_models() -> Dict[str, any]:
"description": info["description"],
"use_case": info["use_case"],
"installed": installed,
"source": "predefined", # Mark as predefined
"recommended": info.get("recommended", True),
})
# Add discovered models (manually placed by user)
discovered = scan_discovered_models(model_type="embedding")
for model in discovered:
# Try to estimate dimensions based on common model patterns
dimensions = 768 # Default
name_lower = model["model_name"].lower()
if "small" in name_lower or "mini" in name_lower:
dimensions = 384
elif "large" in name_lower:
dimensions = 1024
model["dimensions"] = dimensions
model["estimated_size_mb"] = model.get("actual_size_mb", 0)
model["recommended"] = False # User-provided models are not recommended by default
models.append(model)
return {
"success": True,
"result": {
"models": models,
"cache_dir": str(cache_dir),
"cache_exists": cache_exists,
"manual_install_guide": {
"steps": [
"1. Download: huggingface-cli download <org>/<model>",
"2. Or copy to cache directory (see paths below)",
"3. Refresh to see discovered models"
],
"example": "huggingface-cli download BAAI/bge-small-en-v1.5",
"paths": {
"windows": "%USERPROFILE%\\.cache\\huggingface\\models--<org>--<model>",
"linux": "~/.cache/huggingface/models--<org>--<model>",
"macos": "~/.cache/huggingface/models--<org>--<model>",
},
},
},
}
@@ -313,6 +555,92 @@ def download_model(profile: str, progress_callback: Optional[callable] = None) -
}
def download_custom_model(model_name: str, model_type: str = "embedding", progress_callback: Optional[callable] = None) -> Dict[str, any]:
"""Download a custom model by HuggingFace model name.
This allows users to download any HuggingFace model that is compatible
with fastembed (TextEmbedding or TextCrossEncoder).
Args:
model_name: Full HuggingFace model name (e.g., "BAAI/bge-small-en-v1.5")
model_type: Type of model ("embedding" or "reranker")
progress_callback: Optional callback function to report progress
Returns:
Result dictionary with success status
"""
if model_type == "embedding":
if not FASTEMBED_AVAILABLE:
return {
"success": False,
"error": "fastembed not installed. Install with: pip install codexlens[semantic]",
}
else:
if not RERANKER_AVAILABLE:
return {
"success": False,
"error": "fastembed reranker not available. Install with: pip install fastembed>=0.4.0",
}
# Validate model name format (org/model-name)
if not model_name or "/" not in model_name:
return {
"success": False,
"error": "Invalid model name format. Expected: 'org/model-name' (e.g., 'BAAI/bge-small-en-v1.5')",
}
try:
cache_dir = get_cache_dir()
if progress_callback:
progress_callback(f"Downloading custom model {model_name}...")
if model_type == "reranker":
# Download reranker model
reranker = TextCrossEncoder(model_name=model_name, cache_dir=str(cache_dir))
if progress_callback:
progress_callback(f"Initializing reranker {model_name}...")
list(reranker.rerank("test query", ["test document"]))
else:
# Download embedding model
embedder = TextEmbedding(model_name=model_name, cache_dir=str(cache_dir))
if progress_callback:
progress_callback(f"Initializing {model_name}...")
list(embedder.embed(["test"]))
if progress_callback:
progress_callback(f"Custom model {model_name} downloaded successfully")
# Get cache info
sanitized_name = f"models--{model_name.replace('/', '--')}"
model_cache_path = cache_dir / sanitized_name
cache_size = 0
if model_cache_path.exists():
total_size = sum(
f.stat().st_size
for f in model_cache_path.rglob("*")
if f.is_file()
)
cache_size = round(total_size / (1024 * 1024), 1)
return {
"success": True,
"result": {
"model_name": model_name,
"model_type": model_type,
"cache_size_mb": cache_size,
"cache_path": str(model_cache_path),
},
}
except Exception as e:
return {
"success": False,
"error": f"Failed to download custom model: {str(e)}",
}
def delete_model(profile: str) -> Dict[str, any]:
"""Delete a downloaded model from cache.
@@ -464,14 +792,35 @@ def list_reranker_models() -> Dict[str, any]:
"use_case": info["use_case"],
"installed": installed,
"recommended": info.get("recommended", True),
"source": "predefined", # Mark as predefined
})
# Add discovered reranker models (manually placed by user)
discovered = scan_discovered_models(model_type="reranker")
for model in discovered:
model["estimated_size_mb"] = model.get("actual_size_mb", 0)
model["recommended"] = False # User-provided models are not recommended by default
models.append(model)
return {
"success": True,
"result": {
"models": models,
"cache_dir": str(cache_dir),
"cache_exists": cache_exists,
"manual_install_guide": {
"steps": [
"1. Download: huggingface-cli download <org>/<model>",
"2. Or copy to cache directory (see paths below)",
"3. Refresh to see discovered models",
],
"example": "huggingface-cli download BAAI/bge-reranker-base",
"paths": {
"windows": "%USERPROFILE%\\.cache\\huggingface\\models--<org>--<model>",
"linux": "~/.cache/huggingface/models--<org>--<model>",
"macos": "~/.cache/huggingface/models--<org>--<model>",
},
},
},
}