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

@@ -497,6 +497,46 @@ export async function handleCodexLensConfigRoutes(ctx: RouteContext): Promise<bo
return true; return true;
} }
// API: CodexLens Model Download Custom (download any HuggingFace model)
if (pathname === '/api/codexlens/models/download-custom' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { model_name, model_type } = body as { model_name?: unknown; model_type?: unknown };
const resolvedModelName = typeof model_name === 'string' && model_name.trim().length > 0 ? model_name.trim() : undefined;
const resolvedModelType = typeof model_type === 'string' ? model_type.trim() : 'embedding';
if (!resolvedModelName) {
return { success: false, error: 'model_name is required', status: 400 };
}
// Validate model name format
if (!resolvedModelName.includes('/')) {
return { success: false, error: 'Invalid model_name format. Expected: org/model-name', status: 400 };
}
try {
const result = await executeCodexLens([
'model-download-custom', resolvedModelName,
'--type', resolvedModelType,
'--json'
], { timeout: 600000 }); // 10 min for download
if (result.success) {
try {
const parsed = extractJSON(result.output ?? '');
return { success: true, ...parsed };
} catch {
return { success: true, output: result.output };
}
} else {
return { success: false, error: result.error, status: 500 };
}
} catch (err: unknown) {
return { success: false, error: err instanceof Error ? err.message : String(err), status: 500 };
}
});
return true;
}
// API: CodexLens Model Delete (delete embedding model by profile) // API: CodexLens Model Delete (delete embedding model by profile)
if (pathname === '/api/codexlens/models/delete' && req.method === 'POST') { if (pathname === '/api/codexlens/models/delete' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => { handlePostRequest(req, res, async (body) => {
@@ -526,6 +566,47 @@ export async function handleCodexLensConfigRoutes(ctx: RouteContext): Promise<bo
return true; return true;
} }
// API: CodexLens Model Delete by Path (delete discovered/manually placed model)
if (pathname === '/api/codexlens/models/delete-path' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { cache_path } = body as { cache_path?: unknown };
const resolvedPath = typeof cache_path === 'string' && cache_path.trim().length > 0 ? cache_path.trim() : undefined;
if (!resolvedPath) {
return { success: false, error: 'cache_path is required', status: 400 };
}
// Security: Validate that the path is within the HuggingFace cache directory
const { homedir } = await import('os');
const { join, resolve, normalize } = await import('path');
const { rm } = await import('fs/promises');
const hfCacheDir = process.env.HF_HOME || join(homedir(), '.cache', 'huggingface');
const normalizedCachePath = normalize(resolve(resolvedPath));
const normalizedHfCacheDir = normalize(resolve(hfCacheDir));
// Ensure the path is within the HuggingFace cache directory
if (!normalizedCachePath.startsWith(normalizedHfCacheDir)) {
return { success: false, error: 'Path must be within the HuggingFace cache directory', status: 400 };
}
// Ensure it's a models-- directory
const pathParts = normalizedCachePath.split(/[/\\]/);
const lastPart = pathParts[pathParts.length - 1];
if (!lastPart.startsWith('models--')) {
return { success: false, error: 'Path must be a model cache directory (models--*)', status: 400 };
}
try {
await rm(normalizedCachePath, { recursive: true, force: true });
return { success: true, message: 'Model deleted successfully', cache_path: normalizedCachePath };
} catch (err: unknown) {
return { success: false, error: err instanceof Error ? err.message : String(err), status: 500 };
}
});
return true;
}
// API: CodexLens Model Info (get model info by profile) // API: CodexLens Model Info (get model info by profile)
if (pathname === '/api/codexlens/models/info' && req.method === 'GET') { if (pathname === '/api/codexlens/models/info' && req.method === 'GET') {
const profile = url.searchParams.get('profile'); const profile = url.searchParams.get('profile');

View File

@@ -2458,3 +2458,314 @@ select.cli-input {
justify-content: flex-end; justify-content: flex-end;
} }
} }
/* ===========================
CLI Settings Form
=========================== */
/* Tool Detail Header */
.tool-detail-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.5rem;
border-bottom: 1px solid hsl(var(--border));
background: hsl(var(--muted) / 0.2);
flex-shrink: 0;
}
.tool-detail-header h3 {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: hsl(var(--foreground));
}
.tool-detail-header h3 svg,
.tool-detail-header h3 i {
width: 1.25rem;
height: 1.25rem;
color: hsl(var(--primary));
}
/* Claude Config Form Container */
.claude-config-form {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
/* Config Mode Toggle */
.config-mode-toggle {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
padding: 0.25rem;
background: hsl(var(--muted) / 0.3);
border-radius: 0.5rem;
}
.config-mode-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--muted-foreground));
background: transparent;
border: 1px solid transparent;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.2s;
}
.config-mode-btn:hover {
color: hsl(var(--foreground));
background: hsl(var(--muted) / 0.5);
}
.config-mode-btn.active {
color: hsl(var(--primary));
background: hsl(var(--card));
border-color: hsl(var(--border));
box-shadow: 0 1px 3px hsl(var(--foreground) / 0.05);
}
.config-mode-btn svg,
.config-mode-btn i {
width: 1rem;
height: 1rem;
}
/* Model Config Section */
.model-config-section {
margin-top: 1rem;
padding: 1rem;
background: hsl(var(--muted) / 0.2);
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
}
.model-config-section h4 {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0 0 1rem 0;
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--foreground));
}
.model-config-section h4 svg,
.model-config-section h4 i {
width: 1rem;
height: 1rem;
color: hsl(var(--primary));
}
.model-config-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.75rem;
}
.model-config-grid .form-group {
margin-bottom: 0;
}
.model-config-grid .form-group label {
font-size: 0.75rem;
font-family: 'SF Mono', 'Consolas', 'Liberation Mono', monospace;
color: hsl(var(--muted-foreground));
}
/* Form Actions - Sticky at bottom */
.claude-config-form .form-actions {
position: sticky;
bottom: 0;
margin-top: auto;
padding: 1rem 0 0;
background: hsl(var(--card));
border-top: 1px solid hsl(var(--border));
}
/* Responsive adjustments */
@media (max-width: 640px) {
.model-config-grid {
grid-template-columns: 1fr;
}
.config-mode-toggle {
flex-direction: column;
}
}
/* ===========================
JSON Editor Section
=========================== */
.json-editor-section {
margin-top: 1rem;
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
overflow: hidden;
background: hsl(var(--card));
}
.json-editor-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: hsl(var(--muted) / 0.3);
border-bottom: 1px solid hsl(var(--border));
}
.json-editor-header h4 {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--foreground));
}
.json-editor-header h4 svg,
.json-editor-header h4 i {
width: 1rem;
height: 1rem;
color: hsl(var(--primary));
}
.json-editor-actions {
display: flex;
gap: 0.25rem;
}
.json-editor-actions .btn-ghost {
padding: 0.375rem 0.625rem;
font-size: 0.75rem;
background: transparent;
border: none;
color: hsl(var(--muted-foreground));
cursor: pointer;
border-radius: 0.25rem;
display: inline-flex;
align-items: center;
gap: 0.25rem;
transition: all 0.15s;
}
.json-editor-actions .btn-ghost:hover {
background: hsl(var(--muted) / 0.5);
color: hsl(var(--foreground));
}
.json-editor-actions .btn-ghost svg,
.json-editor-actions .btn-ghost i {
width: 0.875rem;
height: 0.875rem;
}
.json-editor-body {
display: flex;
position: relative;
min-height: 200px;
max-height: 350px;
}
.json-line-numbers {
width: 2.5rem;
padding: 0.75rem 0.5rem;
background: hsl(var(--muted) / 0.2);
border-right: 1px solid hsl(var(--border));
font-family: 'SF Mono', 'Consolas', 'Liberation Mono', monospace;
font-size: 0.75rem;
line-height: 1.5;
color: hsl(var(--muted-foreground));
text-align: right;
user-select: none;
overflow: hidden;
flex-shrink: 0;
}
.json-line-numbers span {
display: block;
}
.json-textarea {
flex: 1;
padding: 0.75rem;
border: none;
resize: none;
font-family: 'SF Mono', 'Consolas', 'Liberation Mono', monospace;
font-size: 0.8125rem;
line-height: 1.5;
color: hsl(var(--foreground));
background: hsl(var(--background));
outline: none;
overflow-y: auto;
tab-size: 2;
}
.json-textarea::placeholder {
color: hsl(var(--muted-foreground) / 0.5);
}
.json-textarea.invalid {
background: hsl(var(--destructive) / 0.05);
border-left: 2px solid hsl(var(--destructive));
}
.json-editor-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 1rem;
background: hsl(var(--muted) / 0.2);
border-top: 1px solid hsl(var(--border));
}
.json-status {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
font-weight: 500;
}
.json-status svg,
.json-status i {
width: 0.875rem;
height: 0.875rem;
}
.json-status.valid {
color: hsl(142 76% 36%);
}
.json-status.invalid {
color: hsl(var(--destructive));
}
.json-hint {
font-size: 0.6875rem;
color: hsl(var(--muted-foreground));
}
/* Button styles for JSON editor */
.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
border-radius: 0.375rem;
}

View File

@@ -28,6 +28,7 @@ const i18n = {
'common.deleteFailed': 'Delete failed', 'common.deleteFailed': 'Delete failed',
'common.retry': 'Retry', 'common.retry': 'Retry',
'common.refresh': 'Refresh', 'common.refresh': 'Refresh',
'common.format': 'Format',
'common.back': 'Back', 'common.back': 'Back',
'common.search': 'Search...', 'common.search': 'Search...',
'common.minutes': 'minutes', 'common.minutes': 'minutes',
@@ -1771,6 +1772,14 @@ const i18n = {
'apiSettings.settingsFilePath': 'Settings File Path', 'apiSettings.settingsFilePath': 'Settings File Path',
'apiSettings.nameRequired': 'Name is required', 'apiSettings.nameRequired': 'Name is required',
'apiSettings.status': 'Status', 'apiSettings.status': 'Status',
'apiSettings.providerBinding': 'Provider Binding',
'apiSettings.directConfig': 'Direct Configuration',
'apiSettings.modelConfig': 'Model Configuration',
'apiSettings.configJson': 'Configuration JSON',
'apiSettings.syncToJson': 'Sync to JSON',
'apiSettings.jsonEditorHint': 'Edit JSON directly to add advanced settings',
'apiSettings.jsonValid': 'Valid JSON',
'apiSettings.jsonInvalid': 'Invalid JSON',
// Model Pools (High Availability) // Model Pools (High Availability)
'apiSettings.modelPools': 'Model Pools', 'apiSettings.modelPools': 'Model Pools',
@@ -2153,6 +2162,7 @@ const i18n = {
'common.deleteFailed': '删除失败', 'common.deleteFailed': '删除失败',
'common.retry': '重试', 'common.retry': '重试',
'common.refresh': '刷新', 'common.refresh': '刷新',
'common.format': '格式化',
'common.back': '返回', 'common.back': '返回',
'common.search': '搜索...', 'common.search': '搜索...',
'common.minutes': '分钟', 'common.minutes': '分钟',
@@ -3906,6 +3916,14 @@ const i18n = {
'apiSettings.nameRequired': '名称为必填项', 'apiSettings.nameRequired': '名称为必填项',
'apiSettings.tokenRequired': 'API 令牌为必填项', 'apiSettings.tokenRequired': 'API 令牌为必填项',
'apiSettings.status': '状态', 'apiSettings.status': '状态',
'apiSettings.providerBinding': '供应商绑定',
'apiSettings.directConfig': '直接配置',
'apiSettings.modelConfig': '模型配置',
'apiSettings.configJson': '配置 JSON',
'apiSettings.syncToJson': '同步到 JSON',
'apiSettings.jsonEditorHint': '直接编辑 JSON 添加高级配置',
'apiSettings.jsonValid': 'JSON 有效',
'apiSettings.jsonInvalid': 'JSON 无效',
// Model Pools (High Availability) // Model Pools (High Availability)
'apiSettings.modelPools': '高可用', 'apiSettings.modelPools': '高可用',

View File

@@ -4031,10 +4031,13 @@ function renderCliConfigModeContent(existingEndpoint) {
if (cliConfigMode === 'provider') { if (cliConfigMode === 'provider') {
renderProviderModeContent(container, settings); renderProviderModeContent(container, settings);
} else { } else {
renderDirectModeContent(container, env); renderDirectModeContent(container, env, settings);
} }
if (window.lucide) lucide.createIcons(); if (window.lucide) lucide.createIcons();
// Initialize JSON editor after rendering
initCliJsonEditor(settings);
} }
/** /**
@@ -4081,13 +4084,16 @@ function renderProviderModeContent(container, settings) {
'<input type="text" id="cli-model-opus" class="form-control" placeholder="claude-3-opus-20240229" value="' + escapeHtml(env.ANTHROPIC_DEFAULT_OPUS_MODEL || '') + '" />' + '<input type="text" id="cli-model-opus" class="form-control" placeholder="claude-3-opus-20240229" value="' + escapeHtml(env.ANTHROPIC_DEFAULT_OPUS_MODEL || '') + '" />' +
'</div>' + '</div>' +
'</div>' + '</div>' +
'</div>'; '</div>' +
// JSON Preview/Editor Section
buildJsonEditorSection(settings);
} }
/** /**
* Render Direct Configuration mode content * Render Direct Configuration mode content
*/ */
function renderDirectModeContent(container, env) { function renderDirectModeContent(container, env, settings) {
settings = settings || { env: env };
container.innerHTML = container.innerHTML =
'<div class="form-group">' + '<div class="form-group">' +
'<label for="cli-auth-token">ANTHROPIC_AUTH_TOKEN *</label>' + '<label for="cli-auth-token">ANTHROPIC_AUTH_TOKEN *</label>' +
@@ -4118,9 +4124,227 @@ function renderDirectModeContent(container, env) {
'<input type="text" id="cli-model-opus" class="form-control" placeholder="claude-3-opus-20240229" value="' + escapeHtml(env.ANTHROPIC_DEFAULT_OPUS_MODEL || '') + '" />' + '<input type="text" id="cli-model-opus" class="form-control" placeholder="claude-3-opus-20240229" value="' + escapeHtml(env.ANTHROPIC_DEFAULT_OPUS_MODEL || '') + '" />' +
'</div>' + '</div>' +
'</div>' + '</div>' +
'</div>' +
// JSON Preview/Editor Section
buildJsonEditorSection(settings);
}
/**
* Build JSON Editor Section HTML
*/
function buildJsonEditorSection(settings) {
return '<div class="json-editor-section">' +
'<div class="json-editor-header">' +
'<h4><i data-lucide="code-2"></i> ' + (t('apiSettings.configJson') || 'Configuration JSON') + '</h4>' +
'<div class="json-editor-actions">' +
'<button type="button" class="btn btn-sm btn-ghost" onclick="formatCliJson()" title="' + (t('common.format') || 'Format') + '">' +
'<i data-lucide="align-left"></i> ' + (t('common.format') || 'Format') +
'</button>' +
'<button type="button" class="btn btn-sm btn-ghost" onclick="syncFormToJson()" title="' + (t('apiSettings.syncToJson') || 'Sync to JSON') + '">' +
'<i data-lucide="refresh-cw"></i>' +
'</button>' +
'</div>' +
'</div>' +
'<div class="json-editor-body">' +
'<div class="json-line-numbers" id="cli-json-line-numbers"></div>' +
'<textarea id="cli-json-editor" class="json-textarea" spellcheck="false" placeholder="{\n &quot;env&quot;: {},\n &quot;model&quot;: &quot;&quot;\n}"></textarea>' +
'</div>' +
'<div class="json-editor-footer">' +
'<span class="json-status" id="cli-json-status"></span>' +
'<span class="json-hint">' + (t('apiSettings.jsonEditorHint') || 'Edit JSON directly to add advanced settings') + '</span>' +
'</div>' +
'</div>'; '</div>';
} }
/**
* Initialize JSON Editor with settings data
*/
function initCliJsonEditor(settings) {
var editor = document.getElementById('cli-json-editor');
if (!editor) return;
// Build initial JSON from settings (without sensitive fields for display)
var displaySettings = buildDisplaySettings(settings);
var jsonStr = JSON.stringify(displaySettings, null, 2);
editor.value = jsonStr;
updateJsonLineNumbers();
validateCliJson();
// Add event listeners
editor.addEventListener('input', function() {
updateJsonLineNumbers();
validateCliJson();
});
editor.addEventListener('scroll', function() {
var lineNumbers = document.getElementById('cli-json-line-numbers');
if (lineNumbers) {
lineNumbers.scrollTop = editor.scrollTop;
}
});
editor.addEventListener('keydown', function(e) {
// Handle Tab key for indentation
if (e.key === 'Tab') {
e.preventDefault();
var start = this.selectionStart;
var end = this.selectionEnd;
this.value = this.value.substring(0, start) + ' ' + this.value.substring(end);
this.selectionStart = this.selectionEnd = start + 2;
updateJsonLineNumbers();
}
});
}
/**
* Build display settings object (hide sensitive values)
*/
function buildDisplaySettings(settings) {
var result = {};
// Copy non-env fields
for (var key in settings) {
if (key !== 'env' && key !== 'configMode' && key !== 'providerId') {
result[key] = settings[key];
}
}
// Copy env with masked sensitive values
if (settings.env) {
result.env = {};
for (var envKey in settings.env) {
if (envKey === 'ANTHROPIC_AUTH_TOKEN') {
// Mask the token
var token = settings.env[envKey] || '';
result.env[envKey] = token ? (token.substring(0, 10) + '...') : '';
} else {
result.env[envKey] = settings.env[envKey];
}
}
}
return result;
}
/**
* Update JSON line numbers
*/
function updateJsonLineNumbers() {
var editor = document.getElementById('cli-json-editor');
var lineNumbers = document.getElementById('cli-json-line-numbers');
if (!editor || !lineNumbers) return;
var lines = editor.value.split('\n').length;
var html = '';
for (var i = 1; i <= lines; i++) {
html += '<span>' + i + '</span>';
}
lineNumbers.innerHTML = html;
}
/**
* Validate JSON in editor
*/
function validateCliJson() {
var editor = document.getElementById('cli-json-editor');
var status = document.getElementById('cli-json-status');
if (!editor || !status) return false;
try {
JSON.parse(editor.value);
status.innerHTML = '<i data-lucide="check-circle"></i> ' + (t('apiSettings.jsonValid') || 'Valid JSON');
status.className = 'json-status valid';
editor.classList.remove('invalid');
if (window.lucide) lucide.createIcons();
return true;
} catch (e) {
status.innerHTML = '<i data-lucide="alert-circle"></i> ' + (t('apiSettings.jsonInvalid') || 'Invalid JSON') + ': ' + e.message;
status.className = 'json-status invalid';
editor.classList.add('invalid');
if (window.lucide) lucide.createIcons();
return false;
}
}
/**
* Format JSON in editor
*/
function formatCliJson() {
var editor = document.getElementById('cli-json-editor');
if (!editor) return;
try {
var obj = JSON.parse(editor.value);
editor.value = JSON.stringify(obj, null, 2);
updateJsonLineNumbers();
validateCliJson();
} catch (e) {
showRefreshToast(t('apiSettings.jsonInvalid') || 'Invalid JSON', 'error');
}
}
window.formatCliJson = formatCliJson;
/**
* Sync form fields to JSON editor
*/
function syncFormToJson() {
var editor = document.getElementById('cli-json-editor');
if (!editor) return;
// Get current JSON
var currentObj = {};
try {
currentObj = JSON.parse(editor.value);
} catch (e) {
currentObj = { env: {} };
}
// Update env from form fields
currentObj.env = currentObj.env || {};
// Model fields
var modelDefault = document.getElementById('cli-model-default');
var modelHaiku = document.getElementById('cli-model-haiku');
var modelSonnet = document.getElementById('cli-model-sonnet');
var modelOpus = document.getElementById('cli-model-opus');
if (modelDefault && modelDefault.value) currentObj.env.ANTHROPIC_MODEL = modelDefault.value;
if (modelHaiku && modelHaiku.value) currentObj.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = modelHaiku.value;
if (modelSonnet && modelSonnet.value) currentObj.env.ANTHROPIC_DEFAULT_SONNET_MODEL = modelSonnet.value;
if (modelOpus && modelOpus.value) currentObj.env.ANTHROPIC_DEFAULT_OPUS_MODEL = modelOpus.value;
// Direct mode fields
if (cliConfigMode === 'direct') {
var authToken = document.getElementById('cli-auth-token');
var baseUrl = document.getElementById('cli-base-url');
if (authToken && authToken.value) currentObj.env.ANTHROPIC_AUTH_TOKEN = authToken.value;
if (baseUrl && baseUrl.value) currentObj.env.ANTHROPIC_BASE_URL = baseUrl.value;
}
// Ensure DISABLE_AUTOUPDATER
currentObj.env.DISABLE_AUTOUPDATER = '1';
editor.value = JSON.stringify(currentObj, null, 2);
updateJsonLineNumbers();
validateCliJson();
}
window.syncFormToJson = syncFormToJson;
/**
* Get settings from JSON editor (merges with form data)
*/
function getSettingsFromJsonEditor() {
var editor = document.getElementById('cli-json-editor');
if (!editor) return null;
try {
return JSON.parse(editor.value);
} catch (e) {
return null;
}
}
/** /**
* Submit CLI Settings Form (handles both Provider and Direct modes) * Submit CLI Settings Form (handles both Provider and Direct modes)
*/ */
@@ -4214,6 +4438,33 @@ async function submitCliSettingsForm() {
data.settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL = opusModel; data.settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL = opusModel;
} }
// Merge additional settings from JSON editor
var jsonSettings = getSettingsFromJsonEditor();
if (jsonSettings) {
// Merge env variables (JSON editor values take precedence for non-core fields)
if (jsonSettings.env) {
for (var envKey in jsonSettings.env) {
// Skip core fields that are managed by form inputs
if (envKey === 'ANTHROPIC_AUTH_TOKEN' || envKey === 'ANTHROPIC_BASE_URL') {
// Only use JSON editor value if form field is empty
if (!data.settings.env[envKey] && jsonSettings.env[envKey] && !jsonSettings.env[envKey].endsWith('...')) {
data.settings.env[envKey] = jsonSettings.env[envKey];
}
} else if (!['ANTHROPIC_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL', 'ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_OPUS_MODEL'].includes(envKey)) {
// For non-model env vars, use JSON editor value
data.settings.env[envKey] = jsonSettings.env[envKey];
}
}
}
// Merge non-env settings from JSON editor
for (var settingKey in jsonSettings) {
if (settingKey !== 'env' && settingKey !== 'configMode' && settingKey !== 'providerId') {
data.settings[settingKey] = jsonSettings[settingKey];
}
}
}
// Set ID if editing // Set ID if editing
if (id) { if (id) {
data.id = id; data.id = id;

View File

@@ -2540,7 +2540,15 @@ async function loadModelList() {
// Show models for local backend // Show models for local backend
if (embeddingBackend !== 'litellm') { if (embeddingBackend !== 'litellm') {
var models = result.result.models; var models = result.result.models;
models.forEach(function(model) { var predefinedModels = models.filter(function(m) { return m.source !== 'discovered'; });
var discoveredModels = models.filter(function(m) { return m.source === 'discovered'; });
// Split predefined models into recommended and others
var recommendedModels = predefinedModels.filter(function(m) { return m.recommended; });
var otherModels = predefinedModels.filter(function(m) { return !m.recommended; });
// Helper function to render model card
function renderModelCard(model) {
var statusIcon = model.installed var statusIcon = model.installed
? '<i data-lucide="check-circle" class="w-3.5 h-3.5 text-success"></i>' ? '<i data-lucide="check-circle" class="w-3.5 h-3.5 text-success"></i>'
: '<i data-lucide="circle" class="w-3.5 h-3.5 text-muted"></i>'; : '<i data-lucide="circle" class="w-3.5 h-3.5 text-muted"></i>';
@@ -2553,11 +2561,15 @@ async function loadModelList() {
? '<button class="text-xs text-destructive hover:underline" onclick="deleteModel(\'' + model.profile + '\')">Delete</button>' ? '<button class="text-xs text-destructive hover:underline" onclick="deleteModel(\'' + model.profile + '\')">Delete</button>'
: '<button class="text-xs text-primary hover:underline" onclick="downloadModel(\'' + model.profile + '\')">Download</button>'; : '<button class="text-xs text-primary hover:underline" onclick="downloadModel(\'' + model.profile + '\')">Download</button>';
html += var recommendedBadge = model.recommended
'<div class="flex items-center justify-between p-2 bg-muted/30 rounded" id="model-' + model.profile + '">' + ? '<span class="text-[10px] px-1 py-0.5 bg-success/20 text-success rounded">Rec</span>'
: '';
return '<div class="flex items-center justify-between p-2 bg-muted/30 rounded" id="model-' + model.profile + '">' +
'<div class="flex items-center gap-2">' + '<div class="flex items-center gap-2">' +
statusIcon + statusIcon +
'<span class="text-sm font-medium">' + model.profile + '</span>' + '<span class="text-sm font-medium">' + model.profile + '</span>' +
recommendedBadge +
'<button class="text-muted-foreground hover:text-foreground p-0.5" onclick="copyToClipboard(\'' + escapeHtml(model.model_name) + '\')" title="' + escapeHtml(model.model_name) + '">' + '<button class="text-muted-foreground hover:text-foreground p-0.5" onclick="copyToClipboard(\'' + escapeHtml(model.model_name) + '\')" title="' + escapeHtml(model.model_name) + '">' +
'<i data-lucide="copy" class="w-3 h-3"></i>' + '<i data-lucide="copy" class="w-3 h-3"></i>' +
'</button>' + '</button>' +
@@ -2568,7 +2580,108 @@ async function loadModelList() {
actionBtn + actionBtn +
'</div>' + '</div>' +
'</div>'; '</div>';
}); }
// Show recommended models (always visible)
if (recommendedModels.length > 0) {
html += '<div class="text-xs font-medium text-muted-foreground mb-1 mt-2 flex items-center gap-1">' +
'<i data-lucide="star" class="w-3 h-3"></i> Recommended Models (' + recommendedModels.length + ')</div>';
recommendedModels.forEach(function(model) {
html += renderModelCard(model);
});
}
// Show other models (collapsed by default)
if (otherModels.length > 0) {
html += '<div class="mt-3">' +
'<button onclick="toggleOtherModels()" class="text-xs font-medium text-muted-foreground mb-1 flex items-center gap-1 hover:text-foreground">' +
'<i data-lucide="chevron-right" class="w-3 h-3 transition-transform" id="otherModelsChevron"></i>' +
'Other Models (' + otherModels.length + ')' +
'</button>' +
'<div id="otherModelsContainer" class="hidden space-y-1">';
otherModels.forEach(function(model) {
html += renderModelCard(model);
});
html += '</div></div>';
}
// Show discovered models (user manually placed)
if (discoveredModels.length > 0) {
html += '<div class="text-xs font-medium text-muted-foreground mb-1 mt-3 flex items-center gap-1">' +
'<i data-lucide="folder-search" class="w-3 h-3"></i> Discovered Models</div>';
discoveredModels.forEach(function(model) {
var sizeText = model.actual_size_mb ? model.actual_size_mb.toFixed(0) + ' MB' : 'Unknown';
var safeProfile = model.profile.replace(/[^a-zA-Z0-9-_]/g, '-');
html +=
'<div class="flex items-center justify-between p-2 bg-amber-500/10 border border-amber-500/20 rounded" id="model-' + safeProfile + '">' +
'<div class="flex items-center gap-2">' +
'<i data-lucide="check-circle" class="w-3.5 h-3.5 text-amber-500"></i>' +
'<span class="text-sm font-medium">' + escapeHtml(model.model_name) + '</span>' +
'<span class="text-[10px] px-1 py-0.5 bg-amber-500/20 text-amber-600 rounded">Manual</span>' +
'<span class="text-xs text-muted-foreground">' + (model.dimensions || '?') + 'd</span>' +
'</div>' +
'<div class="flex items-center gap-3">' +
'<span class="text-xs text-muted-foreground">' + sizeText + '</span>' +
'<button class="text-xs text-destructive hover:underline" onclick="deleteDiscoveredModel(\'' + escapeHtml(model.cache_path) + '\')">Delete</button>' +
'</div>' +
'</div>';
});
}
// Show manual install guide
var guide = result.result.manual_install_guide;
if (guide) {
html += '<div class="mt-4 p-3 bg-blue-500/5 border border-blue-500/20 rounded">' +
'<div class="flex items-center gap-1 text-xs font-medium text-blue-600 mb-2">' +
'<i data-lucide="download" class="w-3 h-3"></i> Manual Model Installation' +
'</div>' +
'<div class="text-xs text-muted-foreground space-y-1">';
if (guide.steps) {
guide.steps.forEach(function(step) {
html += '<div>' + escapeHtml(step) + '</div>';
});
}
if (guide.example) {
html += '<div class="mt-2 font-mono text-[10px] bg-muted/50 p-1.5 rounded overflow-x-auto">' +
'<code>' + escapeHtml(guide.example) + '</code>' +
'</div>';
}
// Show multi-platform paths
if (guide.paths) {
html += '<div class="mt-2 space-y-1">' +
'<div class="text-[10px] font-medium">Cache paths:</div>' +
'<div class="font-mono text-[10px] bg-muted/50 p-1.5 rounded space-y-0.5">';
if (guide.paths.windows) {
html += '<div><span class="text-muted-foreground">Windows:</span> ' + escapeHtml(guide.paths.windows) + '</div>';
}
if (guide.paths.linux) {
html += '<div><span class="text-muted-foreground">Linux:</span> ' + escapeHtml(guide.paths.linux) + '</div>';
}
if (guide.paths.macos) {
html += '<div><span class="text-muted-foreground">macOS:</span> ' + escapeHtml(guide.paths.macos) + '</div>';
}
html += '</div></div>';
}
html += '</div></div>';
}
// Custom model download section
html += '<div class="mt-4 p-3 bg-green-500/5 border border-green-500/20 rounded">' +
'<div class="flex items-center gap-1 text-xs font-medium text-green-600 mb-2">' +
'<i data-lucide="plus-circle" class="w-3 h-3"></i> Download Custom Model' +
'</div>' +
'<div class="flex gap-2">' +
'<input type="text" id="customModelInput" placeholder="e.g., BAAI/bge-small-en-v1.5" ' +
'class="flex-1 text-xs px-2 py-1.5 border border-border rounded bg-background focus:border-primary focus:ring-1 focus:ring-primary outline-none" />' +
'<button onclick="downloadCustomModel()" class="text-xs px-3 py-1.5 bg-primary text-primary-foreground rounded hover:bg-primary/90">' +
'Download' +
'</button>' +
'</div>' +
'<div class="text-[10px] text-muted-foreground mt-2">' +
'Enter any HuggingFace model name compatible with FastEmbed' +
'</div>' +
'</div>';
} else { } else {
// LiteLLM backend - show API info // LiteLLM backend - show API info
html += html +=
@@ -2588,6 +2701,19 @@ async function loadModelList() {
} }
} }
/**
* Toggle visibility of other (non-recommended) models
*/
function toggleOtherModels() {
var container = document.getElementById('otherModelsContainer');
var chevron = document.getElementById('otherModelsChevron');
if (container && chevron) {
var isHidden = container.classList.contains('hidden');
container.classList.toggle('hidden');
chevron.style.transform = isHidden ? 'rotate(90deg)' : '';
}
}
/** /**
* Download model (simplified version) * Download model (simplified version)
*/ */
@@ -2675,6 +2801,82 @@ async function deleteModel(profile) {
} }
} }
/**
* Download a custom HuggingFace model by name
*/
async function downloadCustomModel() {
var input = document.getElementById('customModelInput');
if (!input) return;
var modelName = input.value.trim();
if (!modelName) {
showRefreshToast('Please enter a model name', 'error');
return;
}
if (!modelName.includes('/')) {
showRefreshToast('Invalid format. Use: org/model-name', 'error');
return;
}
// Disable input and show loading
input.disabled = true;
var originalPlaceholder = input.placeholder;
input.placeholder = 'Downloading...';
input.value = '';
try {
var response = await fetch('/api/codexlens/models/download-custom', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model_name: modelName, model_type: 'embedding' })
});
var result = await response.json();
if (result.success) {
showRefreshToast('Custom model downloaded: ' + modelName, 'success');
loadModelList();
} else {
showRefreshToast('Download failed: ' + result.error, 'error');
input.disabled = false;
input.placeholder = originalPlaceholder;
}
} catch (err) {
showRefreshToast('Error: ' + err.message, 'error');
input.disabled = false;
input.placeholder = originalPlaceholder;
}
}
/**
* Delete a discovered (manually placed) model by its cache path
*/
async function deleteDiscoveredModel(cachePath) {
if (!confirm('Delete this manually placed model?\n\nPath: ' + cachePath)) {
return;
}
try {
var response = await fetch('/api/codexlens/models/delete-path', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cache_path: cachePath })
});
var result = await response.json();
if (result.success) {
showRefreshToast('Model deleted successfully', 'success');
loadModelList();
} else {
showRefreshToast('Delete failed: ' + result.error, 'error');
}
} catch (err) {
showRefreshToast('Error: ' + err.message, 'error');
}
}
// ============================================================ // ============================================================
// RERANKER MODEL MANAGEMENT // RERANKER MODEL MANAGEMENT
// ============================================================ // ============================================================

View File

@@ -1995,6 +1995,55 @@ def model_delete(
console.print(f" Freed space: {data['deleted_size_mb']:.1f} MB") 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") @app.command(name="model-info")
def model_info( def model_info(
profile: str = typer.Argument(..., help="Model profile to get info (fast, code, multilingual, balanced)."), 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", "use_case": "Fast reranking with good accuracy",
"recommended": True, "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", "use_case": "High-quality semantic search, balanced performance",
"recommended": False, # 1024d not recommended "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 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]: def list_models() -> Dict[str, any]:
"""List available model profiles and their installation status. """List available model profiles and their installation status.
@@ -224,14 +435,45 @@ def list_models() -> Dict[str, any]:
"description": info["description"], "description": info["description"],
"use_case": info["use_case"], "use_case": info["use_case"],
"installed": installed, "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 { return {
"success": True, "success": True,
"result": { "result": {
"models": models, "models": models,
"cache_dir": str(cache_dir), "cache_dir": str(cache_dir),
"cache_exists": cache_exists, "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]: def delete_model(profile: str) -> Dict[str, any]:
"""Delete a downloaded model from cache. """Delete a downloaded model from cache.
@@ -464,14 +792,35 @@ def list_reranker_models() -> Dict[str, any]:
"use_case": info["use_case"], "use_case": info["use_case"],
"installed": installed, "installed": installed,
"recommended": info.get("recommended", True), "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 { return {
"success": True, "success": True,
"result": { "result": {
"models": models, "models": models,
"cache_dir": str(cache_dir), "cache_dir": str(cache_dir),
"cache_exists": cache_exists, "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>",
},
},
}, },
} }