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;
}
// 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)
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<bo
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)
if (pathname === '/api/codexlens/models/info' && req.method === 'GET') {
const profile = url.searchParams.get('profile');

View File

@@ -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;
}

View File

@@ -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': '高可用',

View File

@@ -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) {
'<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>' +
// 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 =
'<div class="form-group">' +
'<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 || '') + '" />' +
'</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>';
}
/**
* 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)
*/
@@ -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;

View File

@@ -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
? '<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>';
@@ -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-primary hover:underline" onclick="downloadModel(\'' + model.profile + '\')">Download</button>';
html +=
'<div class="flex items-center justify-between p-2 bg-muted/30 rounded" id="model-' + model.profile + '">' +
var recommendedBadge = model.recommended
? '<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">' +
statusIcon +
'<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) + '">' +
'<i data-lucide="copy" class="w-3 h-3"></i>' +
'</button>' +
@@ -2568,7 +2580,108 @@ async function loadModelList() {
actionBtn +
'</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 {
// 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
// ============================================================