diff --git a/ccw/src/core/routes/codexlens-routes.ts b/ccw/src/core/routes/codexlens-routes.ts index b59868c4..1daa392b 100644 --- a/ccw/src/core/routes/codexlens-routes.ts +++ b/ccw/src/core/routes/codexlens-routes.ts @@ -1252,7 +1252,7 @@ export async function handleCodexLensRoutes(ctx: RouteContext): Promise const { backend, model_name, api_provider, api_key, litellm_endpoint } = body; // Validate backend - const validBackends = ['onnx', 'api', 'litellm', 'legacy']; + const validBackends = ['onnx', 'api', 'litellm', 'legacy', 'fastembed']; if (backend && !validBackends.includes(backend)) { return { success: false, error: `Invalid backend: ${backend}. Valid options: ${validBackends.join(', ')}`, status: 400 }; } @@ -1310,6 +1310,129 @@ export async function handleCodexLensRoutes(ctx: RouteContext): Promise return true; } + // ============================================================ + // RERANKER MODEL MANAGEMENT ENDPOINTS + // ============================================================ + + // API: List Reranker Models (list available reranker models) + if (pathname === '/api/codexlens/reranker/models' && req.method === 'GET') { + try { + // Check if CodexLens is installed first + const venvStatus = await checkVenvStatus(); + if (!venvStatus.ready) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'CodexLens not installed' })); + return true; + } + const result = await executeCodexLens(['reranker-model-list', '--json']); + if (result.success) { + try { + const parsed = extractJSON(result.output); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(parsed)); + } catch { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true, result: { models: [] }, output: result.output })); + } + } else { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: result.error })); + } + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: err.message })); + } + return true; + } + + // API: Download Reranker Model (download reranker model by profile) + if (pathname === '/api/codexlens/reranker/models/download' && req.method === 'POST') { + handlePostRequest(req, res, async (body) => { + const { profile } = body; + + if (!profile) { + return { success: false, error: 'profile is required', status: 400 }; + } + + try { + const result = await executeCodexLens(['reranker-model-download', profile, '--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) { + return { success: false, error: err.message, status: 500 }; + } + }); + return true; + } + + // API: Delete Reranker Model (delete reranker model by profile) + if (pathname === '/api/codexlens/reranker/models/delete' && req.method === 'POST') { + handlePostRequest(req, res, async (body) => { + const { profile } = body; + + if (!profile) { + return { success: false, error: 'profile is required', status: 400 }; + } + + try { + const result = await executeCodexLens(['reranker-model-delete', profile, '--json']); + 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) { + return { success: false, error: err.message, status: 500 }; + } + }); + return true; + } + + // API: Reranker Model Info (get reranker model info by profile) + if (pathname === '/api/codexlens/reranker/models/info' && req.method === 'GET') { + const profile = url.searchParams.get('profile'); + + if (!profile) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'profile parameter is required' })); + return true; + } + + try { + const result = await executeCodexLens(['reranker-model-info', profile, '--json']); + if (result.success) { + try { + const parsed = extractJSON(result.output); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(parsed)); + } catch { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: 'Failed to parse response' })); + } + } else { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: result.error })); + } + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: false, error: err.message })); + } + return true; + } + // ============================================================ // FILE WATCHER CONTROL ENDPOINTS // ============================================================ diff --git a/ccw/src/templates/dashboard-js/i18n.js b/ccw/src/templates/dashboard-js/i18n.js index 0fec1b46..a78e2316 100644 --- a/ccw/src/templates/dashboard-js/i18n.js +++ b/ccw/src/templates/dashboard-js/i18n.js @@ -283,6 +283,22 @@ const i18n = { 'codexlens.indexManagement': 'Management', 'codexlens.incrementalUpdate': 'Incremental Update', 'codexlens.environmentVariables': 'Environment Variables', + 'codexlens.envGroup.embedding': 'Embedding Configuration', + 'codexlens.envGroup.reranker': 'Reranker Configuration', + 'codexlens.envGroup.concurrency': 'Concurrency Settings', + 'codexlens.envGroup.cascade': 'Cascade Search Settings', + 'codexlens.envGroup.llm': 'LLM Features', + 'codexlens.usingApiReranker': 'Using API Reranker', + 'codexlens.currentModel': 'Current Model', + 'codexlens.localModels': 'Local Models', + 'codexlens.active': 'Active', + 'codexlens.useLocal': 'Use Local', + 'codexlens.select': 'Select', + 'codexlens.switchedToLocal': 'Switched to local', + 'codexlens.configuredInApiSettings': 'Configured in API Settings', + 'codexlens.commonModels': 'Common Models', + 'codexlens.selectApiModel': 'Select API model...', + 'codexlens.autoDownloadHint': 'Models are auto-downloaded on first use', 'codexlens.embeddingBackend': 'Embedding Backend', 'codexlens.localFastembed': 'Local (FastEmbed)', 'codexlens.apiLitellm': 'API (LiteLLM)', @@ -2248,6 +2264,22 @@ const i18n = { 'codexlens.indexManagement': '管理', 'codexlens.incrementalUpdate': '增量更新', 'codexlens.environmentVariables': '环境变量', + 'codexlens.envGroup.embedding': '嵌入配置', + 'codexlens.envGroup.reranker': '重排序配置', + 'codexlens.envGroup.concurrency': '并发设置', + 'codexlens.envGroup.cascade': '级联搜索设置', + 'codexlens.envGroup.llm': 'LLM 功能', + 'codexlens.usingApiReranker': '使用 API 重排序', + 'codexlens.currentModel': '当前模型', + 'codexlens.localModels': '本地模型', + 'codexlens.active': '已激活', + 'codexlens.useLocal': '切换本地', + 'codexlens.select': '选择', + 'codexlens.switchedToLocal': '已切换到本地', + 'codexlens.configuredInApiSettings': '已在 API 设置中配置', + 'codexlens.commonModels': '常用模型', + 'codexlens.selectApiModel': '选择 API 模型...', + 'codexlens.autoDownloadHint': '模型会在首次使用时自动下载', 'codexlens.embeddingBackend': '嵌入后端', 'codexlens.localFastembed': '本地 (FastEmbed)', 'codexlens.apiLitellm': 'API (LiteLLM)', diff --git a/ccw/src/templates/dashboard-js/views/codexlens-manager.js b/ccw/src/templates/dashboard-js/views/codexlens-manager.js index c2626d8f..4511f92c 100644 --- a/ccw/src/templates/dashboard-js/views/codexlens-manager.js +++ b/ccw/src/templates/dashboard-js/views/codexlens-manager.js @@ -660,7 +660,7 @@ window.getModelLockState = getModelLockState; // Embedding and Reranker are configured separately var ENV_VAR_GROUPS = { embedding: { - label: 'Embedding Configuration', + labelKey: 'codexlens.envGroup.embedding', icon: 'box', vars: { 'CODEXLENS_EMBEDDING_BACKEND': { label: 'Backend', type: 'select', options: ['fastembed', 'litellm'], default: 'fastembed', settingsPath: 'embedding.backend' }, @@ -687,7 +687,7 @@ var ENV_VAR_GROUPS = { } }, reranker: { - label: 'Reranker Configuration', + labelKey: 'codexlens.envGroup.reranker', icon: 'arrow-up-down', vars: { 'CODEXLENS_RERANKER_ENABLED': { label: 'Enabled', type: 'select', options: ['true', 'false'], default: 'true', settingsPath: 'reranker.enabled' }, @@ -711,17 +711,8 @@ var ENV_VAR_GROUPS = { 'CODEXLENS_RERANKER_TOP_K': { label: 'Top K Results', type: 'number', placeholder: '50', default: '50', settingsPath: 'reranker.top_k', min: 5, max: 200 } } }, - apiCredentials: { - label: 'API Credentials', - icon: 'key', - showWhen: function(env) { return env['CODEXLENS_EMBEDDING_BACKEND'] === 'litellm' || env['CODEXLENS_RERANKER_BACKEND'] === 'litellm' || env['CODEXLENS_RERANKER_BACKEND'] === 'api'; }, - vars: { - 'LITELLM_API_KEY': { label: 'API Key', placeholder: 'sk-...', type: 'password' }, - 'LITELLM_API_BASE': { label: 'API Base URL', placeholder: 'https://api.openai.com/v1' } - } - }, concurrency: { - label: 'Concurrency Settings', + labelKey: 'codexlens.envGroup.concurrency', icon: 'cpu', vars: { 'CODEXLENS_API_MAX_WORKERS': { label: 'Max Workers', type: 'number', placeholder: '4', default: '4', settingsPath: 'api.max_workers', min: 1, max: 32 }, @@ -729,7 +720,7 @@ var ENV_VAR_GROUPS = { } }, cascade: { - label: 'Cascade Search Settings', + labelKey: 'codexlens.envGroup.cascade', icon: 'git-branch', vars: { 'CODEXLENS_CASCADE_STRATEGY': { label: 'Search Strategy', type: 'select', options: ['binary', 'hybrid', 'binary_rerank', 'dense_rerank'], default: 'dense_rerank', settingsPath: 'cascade.strategy' }, @@ -738,7 +729,7 @@ var ENV_VAR_GROUPS = { } }, llm: { - label: 'LLM Features', + labelKey: 'codexlens.envGroup.llm', icon: 'sparkles', collapsed: true, vars: { @@ -802,10 +793,11 @@ async function loadEnvVariables() { continue; } + var groupLabel = group.labelKey ? t(group.labelKey) : group.label; html += '
' + '
' + '' + - group.label + + groupLabel + '
' + '
'; @@ -927,14 +919,84 @@ async function loadEnvVariables() { container.innerHTML = html; if (window.lucide) lucide.createIcons(); - // Add change handler for backend selects to refresh display + // Add change handler for backend selects to dynamically update model options var backendSelects = container.querySelectorAll('select[data-env-key*="BACKEND"]'); backendSelects.forEach(function(select) { select.addEventListener('change', function() { - // Trigger a re-render after saving + var backendKey = select.getAttribute('data-env-key'); + var newBackend = select.value; + var isApiBackend = newBackend === 'litellm' || newBackend === 'api'; + + // Determine which model input to update + var isEmbedding = backendKey.indexOf('EMBEDDING') !== -1; + var modelKey = isEmbedding ? 'CODEXLENS_EMBEDDING_MODEL' : 'CODEXLENS_RERANKER_MODEL'; + var modelInput = document.querySelector('[data-env-key="' + modelKey + '"]'); + + if (modelInput) { + var datalistId = modelInput.getAttribute('list'); + var datalist = document.getElementById(datalistId); + + if (datalist) { + // Get model config from ENV_VAR_GROUPS + var groupKey = isEmbedding ? 'embedding' : 'reranker'; + var modelConfig = ENV_VAR_GROUPS[groupKey]?.vars[modelKey]; + + if (modelConfig) { + var modelList = isApiBackend + ? (modelConfig.apiModels || modelConfig.models || []) + : (modelConfig.localModels || modelConfig.models || []); + var configuredModels = isEmbedding ? configuredEmbeddingModels : configuredRerankerModels; + + // Rebuild datalist + var html = ''; + if (isApiBackend && configuredModels.length > 0) { + html += ''; + configuredModels.forEach(function(model) { + var providers = model.providers ? model.providers.join(', ') : ''; + html += ''; + }); + if (modelList.length > 0) { + html += ''; + } + } + + modelList.forEach(function(group) { + group.items.forEach(function(model) { + var exists = configuredModels.some(function(m) { return m.modelId === model; }); + if (!exists) { + html += ''; + } + }); + }); + + datalist.innerHTML = html; + + // Clear current model value if it doesn't match new backend type + var currentValue = modelInput.value; + var isCurrentLocal = modelConfig.localModels?.some(function(g) { + return g.items.includes(currentValue); + }); + var isCurrentApi = modelConfig.apiModels?.some(function(g) { + return g.items.includes(currentValue); + }); + + // If switching to API and current is local (or vice versa), clear or set default + if (isApiBackend && isCurrentLocal) { + modelInput.value = ''; + modelInput.placeholder = t('codexlens.selectApiModel'); + } else if (!isApiBackend && isCurrentApi) { + modelInput.value = modelConfig.localModels?.[0]?.items?.[0] || ''; + } + } + } + } + + // Save and refresh after a short delay saveEnvVariables().then(function() { loadEnvVariables(); - // Refresh model lists to reflect new backend loadModelList(); loadRerankerModelList(); }); @@ -947,6 +1009,7 @@ async function loadEnvVariables() { /** * Apply LiteLLM provider settings to environment variables + * Note: API credentials are now managed via API Settings page */ function applyLiteLLMProvider(providerId) { if (!providerId) return; @@ -961,19 +1024,9 @@ function applyLiteLLMProvider(providerId) { return; } - // Auto-fill fields - var apiKeyInput = document.querySelector('[data-env-key="LITELLM_API_KEY"]'); - var apiBaseInput = document.querySelector('[data-env-key="LITELLM_API_BASE"]'); - var embeddingModelInput = document.querySelector('[data-env-key="LITELLM_EMBEDDING_MODEL"]'); - var rerankerModelInput = document.querySelector('[data-env-key="LITELLM_RERANKER_MODEL"]'); - - if (apiKeyInput && provider.api_key) { - apiKeyInput.value = provider.api_key; - } - - if (apiBaseInput && provider.api_base) { - apiBaseInput.value = provider.api_base; - } + // Auto-fill model fields based on provider + var embeddingModelInput = document.querySelector('[data-env-key="CODEXLENS_EMBEDDING_MODEL"]'); + var rerankerModelInput = document.querySelector('[data-env-key="CODEXLENS_RERANKER_MODEL"]'); // Set default models based on provider type var providerName = (provider.name || provider.id || '').toLowerCase(); @@ -1706,17 +1759,18 @@ async function deleteModel(profile) { // RERANKER MODEL MANAGEMENT // ============================================================ -// Available reranker models (fastembed TextCrossEncoder) +// Available reranker models (fastembed TextCrossEncoder) - fallback if API unavailable var RERANKER_MODELS = [ - { id: 'ms-marco-mini', name: 'Xenova/ms-marco-MiniLM-L-6-v2', size: 80, desc: 'Fast, lightweight' }, - { id: 'ms-marco-12', name: 'Xenova/ms-marco-MiniLM-L-12-v2', size: 120, desc: 'Better accuracy' }, - { id: 'bge-base', name: 'BAAI/bge-reranker-base', size: 1040, desc: 'High quality' }, - { id: 'jina-tiny', name: 'jinaai/jina-reranker-v1-tiny-en', size: 130, desc: 'Tiny, fast' }, - { id: 'jina-turbo', name: 'jinaai/jina-reranker-v1-turbo-en', size: 150, desc: 'Balanced' } + { id: 'ms-marco-mini', name: 'Xenova/ms-marco-MiniLM-L-6-v2', size: 90, desc: 'Fast, lightweight', recommended: true }, + { id: 'ms-marco-12', name: 'Xenova/ms-marco-MiniLM-L-12-v2', size: 130, desc: 'Better accuracy', recommended: true }, + { id: 'bge-base', name: 'BAAI/bge-reranker-base', size: 280, desc: 'High quality', recommended: true }, + { id: 'bge-large', name: 'BAAI/bge-reranker-large', size: 560, desc: 'Maximum quality', recommended: false }, + { id: 'jina-tiny', name: 'jinaai/jina-reranker-v1-tiny-en', size: 70, desc: 'Tiny, fast', recommended: true }, + { id: 'jina-turbo', name: 'jinaai/jina-reranker-v1-turbo-en', size: 150, desc: 'Balanced', recommended: true } ]; /** - * Load reranker model list + * Load reranker model list with download/delete support */ async function loadRerankerModelList() { // Update both containers (advanced tab and page model management) @@ -1725,25 +1779,57 @@ async function loadRerankerModelList() { document.getElementById('pageRerankerModelListContainer') ].filter(Boolean); - if (containers.length === 0) return; + console.log('[CodexLens] loadRerankerModelList - containers found:', containers.length); + + if (containers.length === 0) { + console.warn('[CodexLens] No reranker model list containers found'); + return; + } try { - // Get current reranker config - var response = await fetch('/api/codexlens/reranker/config'); - if (!response.ok) { - throw new Error('Failed to load reranker config: ' + response.status); + // Fetch both config and models list in parallel + var [configResponse, modelsResponse] = await Promise.all([ + fetch('/api/codexlens/reranker/config'), + fetch('/api/codexlens/reranker/models') + ]); + + if (!configResponse.ok) { + throw new Error('Failed to load reranker config: ' + configResponse.status); } - var config = await response.json(); + var config = await configResponse.json(); + console.log('[CodexLens] Reranker config loaded:', { backend: config.backend, model: config.model_name }); // Handle API response format var currentModel = config.model_name || config.result?.reranker_model || 'Xenova/ms-marco-MiniLM-L-6-v2'; var currentBackend = config.backend || config.result?.reranker_backend || 'fastembed'; + // Try to use API models, fall back to static list + var models = RERANKER_MODELS; + var modelsFromApi = false; + if (modelsResponse.ok) { + var modelsData = await modelsResponse.json(); + if (modelsData.success && modelsData.result && modelsData.result.models) { + models = modelsData.result.models.map(function(m) { + return { + id: m.profile, + name: m.model_name, + size: m.installed && m.actual_size_mb ? m.actual_size_mb : m.estimated_size_mb, + desc: m.description, + installed: m.installed, + recommended: m.recommended + }; + }); + modelsFromApi = true; + console.log('[CodexLens] Loaded ' + models.length + ' reranker models from API'); + } + } + var html = '
'; // Show current backend status - var backendLabel = currentBackend === 'litellm' ? 'API (LiteLLM)' : 'Local (FastEmbed)'; - var backendIcon = currentBackend === 'litellm' ? 'cloud' : 'hard-drive'; + var isApiBackend = currentBackend === 'litellm' || currentBackend === 'api'; + var backendLabel = isApiBackend ? 'API (' + (currentBackend === 'litellm' ? 'LiteLLM' : 'Remote') + ')' : 'Local (FastEmbed)'; + var backendIcon = isApiBackend ? 'cloud' : 'hard-drive'; html += '
' + '
' + @@ -1753,60 +1839,95 @@ async function loadRerankerModelList() { 'via Environment Variables' + '
'; - // Show models for local backend only - if (currentBackend === 'fastembed' || currentBackend === 'onnx') { - // Helper to match model names (handles different prefixes like Xenova/ vs cross-encoder/) - function modelMatches(current, target) { - if (!current || !target) return false; - // Exact match - if (current === target) return true; - // Match by base name (after last /) - var currentBase = current.split('/').pop(); - var targetBase = target.split('/').pop(); - return currentBase === targetBase; - } + // Helper to match model names (handles different prefixes like Xenova/ vs cross-encoder/) + function modelMatches(current, target) { + if (!current || !target) return false; + // Exact match + if (current === target) return true; + // Match by base name (after last /) + var currentBase = current.split('/').pop(); + var targetBase = target.split('/').pop(); + return currentBase === targetBase; + } - RERANKER_MODELS.forEach(function(model) { - var isActive = modelMatches(currentModel, model.name); - var statusIcon = isActive - ? '' - : ''; - - var actionBtn = isActive - ? 'Active' - : ''; - - html += - '
' + - '
' + - statusIcon + - '' + model.id + '' + - '' + model.desc + '' + - '
' + - '
' + - '~' + model.size + ' MB' + - actionBtn + - '
' + - '
'; - }); - } else { - // API/LiteLLM backend - show current model info + // Show API info when using API backend + if (isApiBackend) { html += - '
' + - '
' + - '' + - 'Using API Reranker' + + '
' + + '
' + + '' + + '' + t('codexlens.usingApiReranker') + '' + '
' + - '
' + - '
Current Model:
' + - '
' + + '
' + + '' + t('codexlens.currentModel') + ':' + + '' + escapeHtml(currentModel) + - '
' + + '' + '
' + - '
Configure via Environment Variables below
' + '
'; } + // Local models section title + html += + '
' + + '' + + t('codexlens.localModels') + + '
'; + + models.forEach(function(model) { + var isActive = !isApiBackend && modelMatches(currentModel, model.name); + var isInstalled = model.installed; + + // Status icon + var statusIcon; + if (isActive) { + statusIcon = ''; + } else if (isInstalled) { + statusIcon = ''; + } else { + statusIcon = ''; + } + + // Action buttons + var actionBtns = ''; + if (isActive) { + actionBtns = '' + t('codexlens.active') + ''; + if (isInstalled) { + actionBtns += ''; + } + } else if (isInstalled) { + // Installed but not active - can select or delete + if (isApiBackend) { + actionBtns = ''; + } else { + actionBtns = ''; + } + actionBtns += ''; + } else { + // Not installed - show download button + actionBtns = ''; + } + + // Size display + var sizeText = (isInstalled && model.size) ? model.size + ' MB' : '~' + model.size + ' MB'; + + // Recommendation badge + var recBadge = model.recommended ? ' ' : ''; + + html += + '
' + + '
' + + statusIcon + + '' + model.id + recBadge + '' + + '' + model.desc + '' + + '
' + + '
' + + '' + sizeText + '' + + actionBtns + + '
' + + '
'; + }); + html += '
'; // Update all containers containers.forEach(function(container) { @@ -1821,6 +1942,68 @@ async function loadRerankerModelList() { } } +/** + * Download reranker model + */ +async function downloadRerankerModel(profile) { + var container = document.getElementById('reranker-' + profile); + if (container) { + container.innerHTML = + '
' + + '' + + '' + t('codexlens.downloading') + '' + + '
'; + if (window.lucide) lucide.createIcons(); + } + + try { + var response = await fetch('/api/codexlens/reranker/models/download', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ profile: profile }) + }); + var result = await response.json(); + + if (result.success) { + showRefreshToast(t('codexlens.downloadComplete') + ': ' + profile, 'success'); + loadRerankerModelList(); + } else { + showRefreshToast(t('codexlens.downloadFailed') + ': ' + (result.error || 'Unknown error'), 'error'); + loadRerankerModelList(); + } + } catch (err) { + showRefreshToast(t('codexlens.downloadFailed') + ': ' + err.message, 'error'); + loadRerankerModelList(); + } +} + +/** + * Delete reranker model + */ +async function deleteRerankerModel(profile) { + if (!confirm(t('codexlens.deleteModelConfirm') + ' ' + profile + '?')) { + return; + } + + try { + var response = await fetch('/api/codexlens/reranker/models/delete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ profile: profile }) + }); + var result = await response.json(); + + if (result.success) { + showRefreshToast(t('codexlens.modelDeleted') + ': ' + profile, 'success'); + loadRerankerModelList(); + } else { + showRefreshToast('Failed to delete: ' + (result.error || 'Unknown error'), 'error'); + } + } catch (err) { + showRefreshToast('Error: ' + err.message, 'error'); + } +} + /** * Update reranker backend */ @@ -1867,6 +2050,47 @@ async function selectRerankerModel(modelName) { } } +/** + * Switch from API to local reranker backend and select model + */ +async function switchToLocalReranker(modelName) { + try { + // First switch backend to fastembed + var backendResponse = await fetch('/api/codexlens/reranker/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ backend: 'fastembed' }) + }); + var backendResult = await backendResponse.json(); + + if (!backendResult.success) { + showRefreshToast('Failed to switch backend: ' + (backendResult.error || 'Unknown error'), 'error'); + return; + } + + // Then select the model + var modelResponse = await fetch('/api/codexlens/reranker/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model_name: modelName }) + }); + var modelResult = await modelResponse.json(); + + if (modelResult.success) { + showRefreshToast(t('codexlens.switchedToLocal') + ': ' + modelName.split('/').pop(), 'success'); + loadRerankerModelList(); + // Also reload env variables to reflect the change + if (typeof loadEnvVariables === 'function') { + loadEnvVariables(); + } + } else { + showRefreshToast('Failed to select model: ' + (modelResult.error || 'Unknown error'), 'error'); + } + } catch (err) { + showRefreshToast('Error: ' + err.message, 'error'); + } +} + // ============================================================ // MODEL TAB & MODE MANAGEMENT // ============================================================ @@ -1896,12 +2120,16 @@ function switchCodexLensModelTab(tabName) { if (embeddingContent && rerankerContent) { if (tabName === 'embedding') { + embeddingContent.classList.remove('hidden'); embeddingContent.style.display = 'block'; + rerankerContent.classList.add('hidden'); rerankerContent.style.display = 'none'; // Reload embedding models when switching to embedding tab loadModelList(); } else { + embeddingContent.classList.add('hidden'); embeddingContent.style.display = 'none'; + rerankerContent.classList.remove('hidden'); rerankerContent.style.display = 'block'; // Load reranker models when switching to reranker tab loadRerankerModelList(); diff --git a/codex-lens/src/codexlens/cli/commands.py b/codex-lens/src/codexlens/cli/commands.py index 1f082a9d..3aeb7729 100644 --- a/codex-lens/src/codexlens/cli/commands.py +++ b/codex-lens/src/codexlens/cli/commands.py @@ -1975,6 +1975,175 @@ def model_info( console.print(f" Use case: {data['use_case']}") +# ==================== Reranker Model Management Commands ==================== + + +@app.command(name="reranker-model-list") +def reranker_model_list( + json_mode: bool = typer.Option(False, "--json", help="Output JSON response."), +) -> None: + """List available reranker models and their installation status. + + Shows reranker model profiles with: + - Installation status + - Model size + - Use case recommendations + """ + try: + from codexlens.cli.model_manager import list_reranker_models + + result = list_reranker_models() + + 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"] + models = data["models"] + cache_dir = data["cache_dir"] + cache_exists = data["cache_exists"] + + console.print("[bold]Available Reranker Models:[/bold]") + console.print(f"Cache directory: [dim]{cache_dir}[/dim] {'(exists)' if cache_exists else '(not found)'}\n") + + table = Table(show_header=True, header_style="bold") + table.add_column("Profile", style="cyan") + table.add_column("Model", style="dim") + table.add_column("Size", justify="right") + table.add_column("Status") + table.add_column("Description") + + for m in models: + status = "[green]✓ Installed[/green]" if m["installed"] else "[dim]Not installed[/dim]" + size = f"{m['actual_size_mb']:.1f} MB" if m["installed"] and m["actual_size_mb"] else f"~{m['estimated_size_mb']} MB" + rec = " [yellow]★[/yellow]" if m.get("recommended") else "" + table.add_row(m["profile"] + rec, m["model_name"], size, status, m["description"]) + + console.print(table) + console.print("\n[yellow]★[/yellow] = Recommended") + + except ImportError: + if json_mode: + print_json(success=False, error="fastembed reranker not available. Install with: pip install fastembed>=0.4.0") + else: + console.print("[red]Error:[/red] fastembed reranker not available") + console.print("Install with: [cyan]pip install fastembed>=0.4.0[/cyan]") + raise typer.Exit(code=1) + + +@app.command(name="reranker-model-download") +def reranker_model_download( + profile: str = typer.Argument(..., help="Reranker model profile to download."), + json_mode: bool = typer.Option(False, "--json", help="Output JSON response."), +) -> None: + """Download a reranker model by profile name. + + Example: + codexlens reranker-model-download ms-marco-mini # Download default reranker + """ + try: + from codexlens.cli.model_manager import download_reranker_model + + if not json_mode: + console.print(f"[bold]Downloading reranker model:[/bold] {profile}") + 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_reranker_model(profile, 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] Reranker model downloaded successfully!") + console.print(f" Profile: {data['profile']}") + console.print(f" Model: {data['model_name']}") + 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 reranker not available. Install with: pip install fastembed>=0.4.0") + else: + console.print("[red]Error:[/red] fastembed reranker not available") + console.print("Install with: [cyan]pip install fastembed>=0.4.0[/cyan]") + raise typer.Exit(code=1) + + +@app.command(name="reranker-model-delete") +def reranker_model_delete( + profile: str = typer.Argument(..., help="Reranker model profile to delete."), + json_mode: bool = typer.Option(False, "--json", help="Output JSON response."), +) -> None: + """Delete a downloaded reranker model from cache. + + Example: + codexlens reranker-model-delete ms-marco-mini # Delete reranker model + """ + from codexlens.cli.model_manager import delete_reranker_model + + if not json_mode: + console.print(f"[bold yellow]Deleting reranker model:[/bold yellow] {profile}") + + result = delete_reranker_model(profile) + + 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] Reranker model deleted successfully!") + console.print(f" Profile: {data['profile']}") + console.print(f" Model: {data['model_name']}") + console.print(f" Freed space: {data['deleted_size_mb']:.1f} MB") + + +@app.command(name="reranker-model-info") +def reranker_model_info( + profile: str = typer.Argument(..., help="Reranker model profile to get info."), + json_mode: bool = typer.Option(False, "--json", help="Output JSON response."), +) -> None: + """Get detailed information about a reranker model profile. + + Example: + codexlens reranker-model-info ms-marco-mini # Get reranker model details + """ + from codexlens.cli.model_manager import get_reranker_model_info + + result = get_reranker_model_info(profile) + + 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"[bold]Reranker Model Profile:[/bold] {data['profile']}") + console.print(f" Model name: {data['model_name']}") + console.print(f" Status: {'[green]Installed[/green]' if data['installed'] else '[dim]Not installed[/dim]'}") + if data['installed'] and data['actual_size_mb']: + console.print(f" Cache size: {data['actual_size_mb']:.1f} MB") + console.print(f" Location: [dim]{data['cache_path']}[/dim]") + else: + console.print(f" Estimated size: ~{data['estimated_size_mb']} MB") + console.print(f" Recommended: {'[green]Yes[/green]' if data.get('recommended') else '[dim]No[/dim]'}") + console.print(f"\n Description: {data['description']}") + console.print(f" Use case: {data['use_case']}") + + # ==================== Embedding Management Commands ==================== @app.command(name="embeddings-status", hidden=True, deprecated=True) diff --git a/codex-lens/src/codexlens/cli/model_manager.py b/codex-lens/src/codexlens/cli/model_manager.py index c6c297ed..30b53395 100644 --- a/codex-lens/src/codexlens/cli/model_manager.py +++ b/codex-lens/src/codexlens/cli/model_manager.py @@ -12,6 +12,66 @@ try: except ImportError: FASTEMBED_AVAILABLE = False +try: + from fastembed import TextCrossEncoder + RERANKER_AVAILABLE = True +except ImportError: + RERANKER_AVAILABLE = False + + +# Reranker model profiles with metadata +# Note: fastembed TextCrossEncoder uses ONNX models from HuggingFace +RERANKER_MODEL_PROFILES = { + "ms-marco-mini": { + "model_name": "Xenova/ms-marco-MiniLM-L-6-v2", + "cache_name": "Xenova/ms-marco-MiniLM-L-6-v2", + "size_mb": 90, + "description": "Fast, lightweight reranker (default)", + "use_case": "Quick prototyping, resource-constrained environments", + "recommended": True, + }, + "ms-marco-12": { + "model_name": "Xenova/ms-marco-MiniLM-L-12-v2", + "cache_name": "Xenova/ms-marco-MiniLM-L-12-v2", + "size_mb": 130, + "description": "Better quality, 12-layer MiniLM", + "use_case": "General purpose reranking with better accuracy", + "recommended": True, + }, + "bge-base": { + "model_name": "BAAI/bge-reranker-base", + "cache_name": "BAAI/bge-reranker-base", + "size_mb": 280, + "description": "BGE reranker base model", + "use_case": "High-quality reranking for production", + "recommended": True, + }, + "bge-large": { + "model_name": "BAAI/bge-reranker-large", + "cache_name": "BAAI/bge-reranker-large", + "size_mb": 560, + "description": "BGE reranker large model (high resource usage)", + "use_case": "Maximum quality reranking", + "recommended": False, + }, + "jina-tiny": { + "model_name": "jinaai/jina-reranker-v1-tiny-en", + "cache_name": "jinaai/jina-reranker-v1-tiny-en", + "size_mb": 70, + "description": "Jina tiny reranker, very fast", + "use_case": "Ultra-low latency applications", + "recommended": True, + }, + "jina-turbo": { + "model_name": "jinaai/jina-reranker-v1-turbo-en", + "cache_name": "jinaai/jina-reranker-v1-turbo-en", + "size_mb": 150, + "description": "Jina turbo reranker, balanced", + "use_case": "Fast reranking with good accuracy", + "recommended": True, + }, +} + # Model profiles with metadata # Note: 768d is max recommended dimension for optimal performance/quality balance @@ -348,3 +408,235 @@ def get_model_info(profile: str) -> Dict[str, any]: "cache_path": str(model_cache_path) if installed else None, }, } + + +# ============================================================================ +# Reranker Model Management Functions +# ============================================================================ + + +def list_reranker_models() -> Dict[str, any]: + """List available reranker model profiles and their installation status. + + Returns: + Dictionary with reranker model profiles, installed status, and cache info + """ + if not RERANKER_AVAILABLE: + return { + "success": False, + "error": "fastembed reranker not available. Install with: pip install fastembed>=0.4.0", + } + + cache_dir = get_cache_dir() + cache_exists = cache_dir.exists() + + models = [] + for profile, info in RERANKER_MODEL_PROFILES.items(): + model_name = info["model_name"] + + # Check if model is cached + installed = False + cache_size_mb = 0 + + if cache_exists: + model_cache_path = _get_model_cache_path(cache_dir, info) + if model_cache_path.exists(): + installed = True + total_size = sum( + f.stat().st_size + for f in model_cache_path.rglob("*") + if f.is_file() + ) + cache_size_mb = round(total_size / (1024 * 1024), 1) + + models.append({ + "profile": profile, + "model_name": model_name, + "estimated_size_mb": info["size_mb"], + "actual_size_mb": cache_size_mb if installed else None, + "description": info["description"], + "use_case": info["use_case"], + "installed": installed, + "recommended": info.get("recommended", True), + }) + + return { + "success": True, + "result": { + "models": models, + "cache_dir": str(cache_dir), + "cache_exists": cache_exists, + }, + } + + +def download_reranker_model(profile: str, progress_callback: Optional[callable] = None) -> Dict[str, any]: + """Download a reranker model by profile name. + + Args: + profile: Reranker model profile name + progress_callback: Optional callback function to report progress + + Returns: + Result dictionary with success status + """ + if not RERANKER_AVAILABLE: + return { + "success": False, + "error": "fastembed reranker not available. Install with: pip install fastembed>=0.4.0", + } + + if profile not in RERANKER_MODEL_PROFILES: + return { + "success": False, + "error": f"Unknown reranker profile: {profile}. Available: {', '.join(RERANKER_MODEL_PROFILES.keys())}", + } + + info = RERANKER_MODEL_PROFILES[profile] + model_name = info["model_name"] + + try: + cache_dir = get_cache_dir() + + if progress_callback: + progress_callback(f"Downloading reranker {model_name}...") + + # Download model by instantiating TextCrossEncoder with explicit cache_dir + reranker = TextCrossEncoder(model_name=model_name, cache_dir=str(cache_dir)) + + # Trigger actual download by calling rerank + if progress_callback: + progress_callback(f"Initializing {model_name}...") + + list(reranker.rerank("test query", ["test document"])) + + if progress_callback: + progress_callback(f"Reranker {model_name} downloaded successfully") + + # Get cache info + model_cache_path = _get_model_cache_path(cache_dir, info) + + 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": { + "profile": profile, + "model_name": model_name, + "cache_size_mb": cache_size, + "cache_path": str(model_cache_path), + }, + } + + except Exception as e: + return { + "success": False, + "error": f"Failed to download reranker model: {str(e)}", + } + + +def delete_reranker_model(profile: str) -> Dict[str, any]: + """Delete a downloaded reranker model from cache. + + Args: + profile: Reranker model profile name to delete + + Returns: + Result dictionary with success status + """ + if profile not in RERANKER_MODEL_PROFILES: + return { + "success": False, + "error": f"Unknown reranker profile: {profile}. Available: {', '.join(RERANKER_MODEL_PROFILES.keys())}", + } + + info = RERANKER_MODEL_PROFILES[profile] + model_name = info["model_name"] + cache_dir = get_cache_dir() + model_cache_path = _get_model_cache_path(cache_dir, info) + + if not model_cache_path.exists(): + return { + "success": False, + "error": f"Reranker model {profile} ({model_name}) is not installed", + } + + try: + total_size = sum( + f.stat().st_size + for f in model_cache_path.rglob("*") + if f.is_file() + ) + size_mb = round(total_size / (1024 * 1024), 1) + + shutil.rmtree(model_cache_path) + + return { + "success": True, + "result": { + "profile": profile, + "model_name": model_name, + "deleted_size_mb": size_mb, + "cache_path": str(model_cache_path), + }, + } + + except Exception as e: + return { + "success": False, + "error": f"Failed to delete reranker model: {str(e)}", + } + + +def get_reranker_model_info(profile: str) -> Dict[str, any]: + """Get detailed information about a reranker model profile. + + Args: + profile: Reranker model profile name + + Returns: + Result dictionary with model information + """ + if profile not in RERANKER_MODEL_PROFILES: + return { + "success": False, + "error": f"Unknown reranker profile: {profile}. Available: {', '.join(RERANKER_MODEL_PROFILES.keys())}", + } + + info = RERANKER_MODEL_PROFILES[profile] + model_name = info["model_name"] + + cache_dir = get_cache_dir() + model_cache_path = _get_model_cache_path(cache_dir, info) + installed = model_cache_path.exists() + + cache_size_mb = None + if installed: + total_size = sum( + f.stat().st_size + for f in model_cache_path.rglob("*") + if f.is_file() + ) + cache_size_mb = round(total_size / (1024 * 1024), 1) + + return { + "success": True, + "result": { + "profile": profile, + "model_name": model_name, + "estimated_size_mb": info["size_mb"], + "actual_size_mb": cache_size_mb, + "description": info["description"], + "use_case": info["use_case"], + "installed": installed, + "recommended": info.get("recommended", True), + "cache_path": str(model_cache_path) if installed else None, + }, + }