diff --git a/ccw/src/core/routes/codexlens/config-handlers.ts b/ccw/src/core/routes/codexlens/config-handlers.ts index 64f29ea8..cc775339 100644 --- a/ccw/src/core/routes/codexlens/config-handlers.ts +++ b/ccw/src/core/routes/codexlens/config-handlers.ts @@ -497,6 +497,46 @@ export async function handleCodexLensConfigRoutes(ctx: RouteContext): Promise { + 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) if (pathname === '/api/codexlens/models/delete' && req.method === 'POST') { handlePostRequest(req, res, async (body) => { @@ -526,6 +566,47 @@ export async function handleCodexLensConfigRoutes(ctx: RouteContext): Promise { + 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) if (pathname === '/api/codexlens/models/info' && req.method === 'GET') { const profile = url.searchParams.get('profile'); diff --git a/ccw/src/templates/dashboard-css/31-api-settings.css b/ccw/src/templates/dashboard-css/31-api-settings.css index f159f01a..a69f9fe7 100644 --- a/ccw/src/templates/dashboard-css/31-api-settings.css +++ b/ccw/src/templates/dashboard-css/31-api-settings.css @@ -2457,4 +2457,315 @@ select.cli-input { width: 100%; 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; } \ No newline at end of file diff --git a/ccw/src/templates/dashboard-js/i18n.js b/ccw/src/templates/dashboard-js/i18n.js index 0d6209f9..d7154702 100644 --- a/ccw/src/templates/dashboard-js/i18n.js +++ b/ccw/src/templates/dashboard-js/i18n.js @@ -28,6 +28,7 @@ const i18n = { 'common.deleteFailed': 'Delete failed', 'common.retry': 'Retry', 'common.refresh': 'Refresh', + 'common.format': 'Format', 'common.back': 'Back', 'common.search': 'Search...', 'common.minutes': 'minutes', @@ -1771,6 +1772,14 @@ const i18n = { 'apiSettings.settingsFilePath': 'Settings File Path', 'apiSettings.nameRequired': 'Name is required', '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) 'apiSettings.modelPools': 'Model Pools', @@ -2153,6 +2162,7 @@ const i18n = { 'common.deleteFailed': '删除失败', 'common.retry': '重试', 'common.refresh': '刷新', + 'common.format': '格式化', 'common.back': '返回', 'common.search': '搜索...', 'common.minutes': '分钟', @@ -3906,6 +3916,14 @@ const i18n = { 'apiSettings.nameRequired': '名称为必填项', 'apiSettings.tokenRequired': 'API 令牌为必填项', '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) 'apiSettings.modelPools': '高可用', diff --git a/ccw/src/templates/dashboard-js/views/api-settings.js b/ccw/src/templates/dashboard-js/views/api-settings.js index df89f7df..688ca503 100644 --- a/ccw/src/templates/dashboard-js/views/api-settings.js +++ b/ccw/src/templates/dashboard-js/views/api-settings.js @@ -4031,10 +4031,13 @@ function renderCliConfigModeContent(existingEndpoint) { if (cliConfigMode === 'provider') { renderProviderModeContent(container, settings); } else { - renderDirectModeContent(container, env); + renderDirectModeContent(container, env, settings); } if (window.lucide) lucide.createIcons(); + + // Initialize JSON editor after rendering + initCliJsonEditor(settings); } /** @@ -4081,13 +4084,16 @@ function renderProviderModeContent(container, settings) { '' + '' + '' + - ''; + '' + + // JSON Preview/Editor Section + buildJsonEditorSection(settings); } /** * Render Direct Configuration mode content */ -function renderDirectModeContent(container, env) { +function renderDirectModeContent(container, env, settings) { + settings = settings || { env: env }; container.innerHTML = '
' + '' + @@ -4118,9 +4124,227 @@ function renderDirectModeContent(container, env) { '' + '
' + '' + + '' + + // JSON Preview/Editor Section + buildJsonEditorSection(settings); +} + +/** + * Build JSON Editor Section HTML + */ +function buildJsonEditorSection(settings) { + return '
' + + '
' + + '

' + (t('apiSettings.configJson') || 'Configuration JSON') + '

' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + '
' + + '' + + '
' + + '' + '
'; } +/** + * 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 += '' + i + ''; + } + 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 = ' ' + (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 = ' ' + (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) */ @@ -4214,6 +4438,33 @@ async function submitCliSettingsForm() { 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 if (id) { data.id = id; diff --git a/ccw/src/templates/dashboard-js/views/codexlens-manager.js b/ccw/src/templates/dashboard-js/views/codexlens-manager.js index e3f64b32..f6672b07 100644 --- a/ccw/src/templates/dashboard-js/views/codexlens-manager.js +++ b/ccw/src/templates/dashboard-js/views/codexlens-manager.js @@ -2540,7 +2540,15 @@ async function loadModelList() { // Show models for local backend if (embeddingBackend !== 'litellm') { 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 ? '' : ''; @@ -2553,11 +2561,15 @@ async function loadModelList() { ? '' : ''; - html += - '
' + + var recommendedBadge = model.recommended + ? 'Rec' + : ''; + + return '
' + '
' + statusIcon + '' + model.profile + '' + + recommendedBadge + '' + @@ -2568,7 +2580,108 @@ async function loadModelList() { actionBtn + '
' + '
'; - }); + } + + // Show recommended models (always visible) + if (recommendedModels.length > 0) { + html += '
' + + ' Recommended Models (' + recommendedModels.length + ')
'; + recommendedModels.forEach(function(model) { + html += renderModelCard(model); + }); + } + + // Show other models (collapsed by default) + if (otherModels.length > 0) { + html += '
' + + '' + + '
'; + } + + // Show discovered models (user manually placed) + if (discoveredModels.length > 0) { + html += '
' + + ' Discovered Models
'; + 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 += + '
' + + '
' + + '' + + '' + escapeHtml(model.model_name) + '' + + 'Manual' + + '' + (model.dimensions || '?') + 'd' + + '
' + + '
' + + '' + sizeText + '' + + '' + + '
' + + '
'; + }); + } + + // Show manual install guide + var guide = result.result.manual_install_guide; + if (guide) { + html += '
' + + '
' + + ' Manual Model Installation' + + '
' + + '
'; + if (guide.steps) { + guide.steps.forEach(function(step) { + html += '
' + escapeHtml(step) + '
'; + }); + } + if (guide.example) { + html += '
' + + '' + escapeHtml(guide.example) + '' + + '
'; + } + // Show multi-platform paths + if (guide.paths) { + html += '
' + + '
Cache paths:
' + + '
'; + if (guide.paths.windows) { + html += '
Windows: ' + escapeHtml(guide.paths.windows) + '
'; + } + if (guide.paths.linux) { + html += '
Linux: ' + escapeHtml(guide.paths.linux) + '
'; + } + if (guide.paths.macos) { + html += '
macOS: ' + escapeHtml(guide.paths.macos) + '
'; + } + html += '
'; + } + html += '
'; + } + + // Custom model download section + html += '
' + + '
' + + ' Download Custom Model' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + 'Enter any HuggingFace model name compatible with FastEmbed' + + '
' + + '
'; } else { // LiteLLM backend - show API info 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) */ @@ -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 // ============================================================ diff --git a/codex-lens/src/codexlens/cli/commands.py b/codex-lens/src/codexlens/cli/commands.py index 776ba7b0..e845b902 100644 --- a/codex-lens/src/codexlens/cli/commands.py +++ b/codex-lens/src/codexlens/cli/commands.py @@ -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)."), diff --git a/codex-lens/src/codexlens/cli/model_manager.py b/codex-lens/src/codexlens/cli/model_manager.py index 36e6802a..670cce53 100644 --- a/codex-lens/src/codexlens/cli/model_manager.py +++ b/codex-lens/src/codexlens/cli/model_manager.py @@ -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 /", + "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----", + "linux": "~/.cache/huggingface/models----", + "macos": "~/.cache/huggingface/models----", + }, + }, }, } @@ -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 /", + "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----", + "linux": "~/.cache/huggingface/models----", + "macos": "~/.cache/huggingface/models----", + }, + }, }, }