项目文件夹

文件
wehub-resource-sync e9a2f726c9
CI / test (3.11) (push) Has been cancelled
CI / test (3.12) (push) Has been cancelled
CI / test (3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:29:51 +08:00

5443 行
254 KiB
JavaScript

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
// OCR model types that require temperature=0.0 (deterministic output)
const OCR_CONFIG_MODEL_TYPES = new Set([
'deepseekocr', 'deepseekocr_2', 'dots_ocr', 'glm_ocr',
]);
const DSA_MODEL_TYPES = new Set([
'deepseek_v32', 'glm_moe_dsa',
]);
const DIFFUSION_CONFIG_MODEL_TYPES = new Set([
'diffusion_gemma',
]);
const DIFFUSION_UNSUPPORTED_PROFILE_FIELDS = new Set([
'top_p',
'top_k',
'min_p',
'repetition_penalty',
'presence_penalty',
'force_sampling',
'enable_thinking',
'preserve_thinking',
'thinking_budget_enabled',
'thinking_budget_tokens',
'reasoning_parser',
'guided_grammar_enabled',
'guided_grammar',
'max_tool_result_tokens',
'index_cache_freq',
'turboquant_kv_enabled',
'turboquant_kv_bits',
'turboquant_skip_last',
'specprefill_enabled',
'specprefill_draft_model',
'specprefill_keep_pct',
'specprefill_threshold',
'dflash_enabled',
'dflash_draft_model',
'dflash_draft_quant_enabled',
'dflash_draft_quant_weight_bits',
'dflash_draft_quant_activation_bits',
'dflash_draft_quant_group_size',
'dflash_max_ctx',
'dflash_in_memory_cache',
'dflash_in_memory_cache_max_entries',
'dflash_in_memory_cache_max_bytes',
'dflash_ssd_cache',
'dflash_ssd_cache_max_bytes',
'dflash_draft_window_size',
'dflash_draft_sink_size',
'dflash_verify_mode',
'mtp_enabled',
'vlm_mtp_enabled',
'vlm_mtp_draft_model',
'vlm_mtp_draft_block_size',
]);
const DIFFUSION_UNSUPPORTED_CT_KWARGS = new Set([
'enable_thinking',
'reasoning_effort',
'preserve_thinking',
]);
const VLM_MTP_DRAFTER_CONFIG_MODEL_TYPES = new Set([
'gemma4_assistant',
'gemma4_unified_assistant',
'qwen3_5_mtp',
]);
const DASHBOARD_MAIN_TABS = new Set(['status', 'settings', 'models', 'logs', 'bench']);
const DASHBOARD_SETTINGS_TABS = new Set(['global', 'integrations', 'models']);
const DASHBOARD_MODELS_TABS = new Set(['manager', 'downloader', 'quantizer', 'uploader']);
const DASHBOARD_BENCH_TABS = new Set(['throughput', 'accuracy']);
// Default sort for the settings and manager model tables. Also the target
// state for the "reset sort" action.
const MODELS_SORT_DEFAULT = { by: 'id', order: 'asc' };
const MANAGER_SORT_DEFAULT = { by: 'name', order: 'asc' };
function dashboard() {
return {
// Theme
theme: localStorage.getItem('omlx-chat-theme') || 'auto',
activeTheme: 'light', // Will be updated by applyTheme
systemThemeListener: null,
// Mobile menu
mobileMenuOpen: false,
// Main tab state (Status, Settings, or Logs)
mainTab: 'status',
activeTab: 'global',
settingsDropdown: false,
themeDropdown: false,
// Global settings
globalSettings: {
base_path: '',
server: { host: '127.0.0.1', port: 8000, log_level: 'info', sse_keepalive_mode: 'chunk', burst_decode_mode: 'balanced', preserve_mid_system_cache: true },
model: { model_dirs: [''], model_fallback: false, hide_helper_models: false },
memory: { prefill_memory_guard: true, memory_guard_tier: 'balanced', memory_guard_custom_ceiling_gb: 0 },
scheduler: { max_concurrent_requests: 8, embedding_batch_size: 32, chunked_prefill: false },
cache: { enabled: true, ssd_cache_dir: '', ssd_cache_max_size: 'auto', hot_cache_max_size: '0', initial_cache_blocks: 256, hot_cache_only: false },
sampling: { max_context_window: 32768, max_context_window_policy: null, max_tokens: 32768, temperature: 1.0, top_p: 0.95, top_k: 0, repetition_penalty: 1.0 },
mcp: { config_path: '' },
huggingface: { endpoint: '', hf_cache_enabled: true, hf_cache_path: '' },
network: { http_proxy: '', https_proxy: '', no_proxy: '', ca_bundle: '' },
auth: { api_key_set: false, api_key: '', skip_api_key_verification: false, sub_keys: [] },
claude_code: { context_scaling_enabled: false, target_context_size: 200000, mode: 'cloud', opus_model: null, sonnet_model: null, haiku_model: null },
integrations: {
copilot_model: null,
codex_model: null,
opencode_model: null,
openclaw_model: null,
hermes_model: null,
pi_model: null,
openclaw_tools_profile: 'full',
markitdown_enabled: true,
markitdown_expose_model: false,
markitdown_max_file_size_mb: 25,
markitdown_max_files_per_request: 5,
markitdown_pdf_processing_engine: 'markitdown',
},
ui: { language: 'en' },
idle_timeout: { idle_timeout_seconds: null },
system: { total_memory_bytes: 0, total_memory: '', auto_model_memory: '', ssd_total_bytes: 0, ssd_total: '' },
},
// Cache slider (0-100%)
cachePercent: 10,
editingCache: false,
// Hot cache slider (0-50%)
hotCachePercent: 0,
// Editing state for direct GB input
editingHotCache: false,
// Idle timeout string value for select binding (null ↔ '')
idleTimeoutValue: '',
// Models
models: [],
loadingModels: false,
reloading: false,
// Sort state persists across refreshes/restarts via localStorage.
sortBy: localStorage.getItem('omlx_models_sort_by') || MODELS_SORT_DEFAULT.by,
sortOrder: localStorage.getItem('omlx_models_sort_order') || MODELS_SORT_DEFAULT.order,
modelSearch: '',
// Manager tab (Browse Models > Local) sort + search state.
managerSortBy: localStorage.getItem('omlx_manager_sort_by') || MANAGER_SORT_DEFAULT.by,
managerSortOrder: localStorage.getItem('omlx_manager_sort_order') || MANAGER_SORT_DEFAULT.order,
managerSearch: '',
// Auth UI state
showApiKey: false,
// Sub key management
newSubKeyValue: '',
newSubKeyName: '',
showNewSubKeyForm: false,
showNewSubKey: false,
subKeyError: '',
showSubKeys: {},
// Saving state
saving: false,
saveSuccess: false,
saveMessage: '',
saveError: '',
// Model settings modal
showModelSettingsModal: false,
selectedModel: null,
modelSettings: {
model_alias: '',
model_type_override: '',
max_context_window: null,
max_tokens: null,
temperature: null,
top_p: null,
top_k: null,
repetition_penalty: null,
min_p: null,
presence_penalty: null,
force_sampling: false,
enableToolResultLimit: false,
max_tool_result_tokens: null,
ctKwargEntries: [],
is_diffusion_model: false,
trust_remote_code: false,
},
savingModelSettings: false,
loadingGenDefaults: false,
reasoningParsers: [],
// Profile / template / preset state
profiles: [], // per-model profiles for selectedModel
templates: [], // global templates
presets: [], // curated presets (bundled + remote refresh)
profileFields: { universal: [], model_specific: [] }, // loaded from /api/profile-fields
profileScope: 'model', // 'preset' | 'global' | 'model'
refreshingPresets: false,
activeProfileName: null, // currently-active profile for the form
profilesDrift: false, // true if form values differ from active profile
_applySeq: 0, // monotonic counter for apply race guard
profileError: '',
showNewProfileForm: false,
newProfile: { display_name: '', api_name: '', api_name_touched: false, description: '', also_as_template: false },
showNewTemplateForm: false,
newTemplate: { name: '', display_name: '', description: '' },
editingProfile: null, // profile name being edited inline
editingTemplate: null, // template name being edited inline
profileDeleteConfirm: null,
templateDeleteConfirm: null,
// Status tab state
stats: {
total_prompt_tokens: 0,
total_cached_tokens: 0,
cache_efficiency: 0.0,
avg_prefill_tps: 0.0,
avg_generation_tps: 0.0,
total_requests: 0,
host: '127.0.0.1',
port: 8000,
api_key: '',
engines: {},
active_models: {
models: [],
model_memory_used: 0,
model_memory_max: 0,
memory_pressure: {
enabled: false,
current_bytes: 0,
soft_bytes: 0,
hard_bytes: 0,
pressure_level: 'ok',
},
total_active_requests: 0,
total_waiting_requests: 0,
},
runtime_cache: {
base_path: '',
ssd_cache_dir: '',
response_state_dir: '',
models: [],
total_num_files: 0,
total_size_bytes: 0,
effective_block_sizes: [],
hot_cache_size_bytes: 0,
hot_cache_entries: 0,
hot_cache_max_bytes: 0,
disk_max_bytes: 0,
},
},
alltimeStats: {
total_prompt_tokens: 0,
total_cached_tokens: 0,
cache_efficiency: 0.0,
avg_prefill_tps: 0.0,
avg_generation_tps: 0.0,
total_requests: 0,
},
// Server connectivity info (from /admin/api/server-info)
serverAliases: [],
selectedAlias: '',
// Server-restart state machine (driven by Settings > Server > Restart).
// status transitions: idle → restarting → waiting → idle (success)
// | |
// | └─→ error (timeout / non-200)
// └─→ unsupported (no menubar supervisor)
// └─→ error (POST failed)
restartServer: {
status: 'idle',
message: '',
},
statsScope: 'session',
selectedStatsModel: '',
showClearStatsConfirm: false,
showClearAlltimeConfirm: false,
showClearSsdCacheConfirm: false,
showClearHotCacheConfirm: false,
_statsRefreshTimer: null,
// Log viewer state
logContent: '',
logLines: 500,
logRefreshInterval: 5, // seconds, 0 = disabled
logAutoRefresh: false,
logAutoScroll: true,
logLoading: false,
logError: '',
logFile: 'server.log',
logAvailableFiles: ['server.log'],
logTotalLines: 0,
logLastUpdated: '',
logMinLevel: 'TRACE',
_logRefreshTimer: null,
// Models sub-tab state
modelsTab: 'manager',
modelsDropdown: false,
// HF Mirror settings modal
showHfMirrorModal: false,
hfMirrorEndpoint: '',
hfMirrorSaving: false,
// Update check state
updateAvailable: false,
latestVersion: null,
releaseUrl: null,
versionHover: false,
_updateCheckTimer: null,
// HF Downloader state
hfRepoId: '',
hfToken: '',
hfDownloading: false,
hfTasks: [],
hfModels: [],
hfModelsLoaded: false,
hfError: '',
hfSuccess: '',
_hfRefreshTimer: null,
hfDeleteConfirm: null,
// Recommended models state
hfRecommended: { trending: [], popular: [] },
hfRecommendedLoaded: false,
hfRecommendedLoading: false,
hfRecommendedTab: 'trending',
hfMlxOnly: true,
// Pagination state
hfPage: { trending: 1, popular: 1, search: 1 },
hfPageSize: 10,
// Search state
hfSearchQuery: '',
hfSearchSort: 'downloads',
hfSearchResults: [],
hfSearchLoading: false,
hfSearchLoaded: false,
hfSearchDebounceTimer: null,
// Search filters
hfSearchFiltersOpen: false,
hfSearchMinParams: '',
hfSearchMaxParams: '',
hfSearchMaxSize: '',
hfSearchMinSize: '',
// Table sort state for Browse Models
hfTableSort: 'downloads',
hfTableSortDir: 'desc',
// Computed: check if any filters are active
get hfSearchFiltersActive() {
return this.hfSearchMinParams || this.hfSearchMaxParams || this.hfSearchMaxSize || this.hfSearchMinSize;
},
// Search history
hfSearchHistory: JSON.parse(localStorage.getItem('hfSearchHistory') || '[]'),
hfSearchHistoryOpen: false,
// Model detail modal
hfModelDetail: null,
hfModelDetailLoading: false,
// ModelScope Downloader state
downloaderSource: 'hf',
msAvailable: false,
msInitialized: false,
msRepoId: '',
msToken: '',
msDownloading: false,
msTasks: [],
msError: '',
msSuccess: '',
_msRefreshTimer: null,
// MS Recommended models state
msRecommended: { trending: [], popular: [] },
msRecommendedLoaded: false,
msRecommendedLoading: false,
msRecommendedTab: 'trending',
msMlxOnly: true,
// MS Pagination state
msPage: { trending: 1, popular: 1, search: 1 },
msPageSize: 10,
// MS Search state
msSearchQuery: '',
msSearchSort: 'trending',
msSearchResults: [],
msSearchLoading: false,
msSearchLoaded: false,
msSearchHistory: JSON.parse(localStorage.getItem('msSearchHistory') || '[]'),
msSearchHistoryOpen: false,
msSearchDebounceTimer: null,
// MS Model detail modal
msModelDetail: null,
msModelDetailLoading: false,
// oQ Quantizer state
oqModels: [],
oqAllModels: [],
oqModelsLoaded: false,
oqSelectedModelPath: '',
oqLevel: 4,
oqStarting: false,
oqTasks: [],
oqError: '',
oqSuccess: '',
_oqRefreshTimer: null,
// oQ Advanced Settings
oqAdvancedOpen: false,
oqTextOnly: false,
oqDtype: 'bfloat16',
oqSensitivityModelPath: '',
oqPreserveMtp: false,
oqEnhanced: false,
oqeReuseImatrixCache: true,
oqeImatrixCachePath: '',
oqeStrictImatrix: false,
// oQ Uploader state
uploadHfToken: localStorage.getItem('omlx-hf-upload-token') || '',
uploadHfUsername: '',
uploadHfOrgs: [],
uploadHfNamespace: '',
uploadTokenValidated: false,
uploadTokenValidating: false,
uploadOqModels: [],
uploadAllModels: [],
uploadOqModelsLoaded: false,
uploadTasks: [],
uploadError: '',
uploadSuccess: '',
_uploadRefreshTimer: null,
// Upload modal
uploadModalOpen: false,
uploadModalModelPath: '',
uploadModalModelName: '',
uploadModalRepoId: '',
uploadReadmeSource: '',
uploadAutoReadme: true,
uploadPrivate: false,
uploadStarting: false,
// Benchmark state
benchModelId: '',
benchPromptLengths: { 1024: true, 4096: true, 8192: false, 16384: false, 32768: false, 65536: false, 131072: false, 200000: false },
benchBatchSizes: { 2: true, 4: true, 8: false },
benchForceLmEngine: false,
benchAdvancedOptionsOpen: false,
benchExternalEnabled: false,
// Shared external endpoint settings (persisted in localStorage,
// used by both the throughput and accuracy bench tabs)
externalBaseUrl: localStorage.getItem('omlx_bench_external_base_url') || '',
externalApiKey: localStorage.getItem('omlx_bench_external_api_key') || '',
externalModel: localStorage.getItem('omlx_bench_external_model') || '',
// { base_url, model } snapshot of the current run when external
// (no API key — used for the text export header)
benchRunExternal: null,
benchRunning: false,
benchBenchId: null,
benchProgress: null,
benchSingleResults: [],
benchBatchResults: [],
benchError: '',
benchEventSource: null,
benchShowMetrics: false,
benchShowText: false,
benchCopied: false,
benchTip: null,
benchDeviceInfo: null,
benchUploadResults: [],
benchUploadDone: null,
benchUploading: false,
benchUploadSkipped: null, // { features: [...] } when upload was skipped due to experimental features
// { bench_id, model_id } when the server reports a running bench
// that is NOT the one this tab is displaying. Drives the "another
// bench is running" banner + disables Start so the user doesn't
// race a 409 on the server.
benchOtherActive: null,
// Bench sub-tab & dropdown
benchTab: 'throughput',
benchDropdown: false,
// Accuracy benchmark state
accModelId: '',
accBenchmarks: { mmlu: true, mmlu_pro: false, kmmlu: false, cmmlu: false, jmmlu: false, hellaswag: false, truthfulqa: true, arc_challenge: false, winogrande: false, gsm8k: false, mathqa: false, humaneval: true, mbpp: false, livecodebench: false, bbq: false, safetybench: false },
accSampleSizes: { mmlu: 1000, mmlu_pro: 300, kmmlu: 300, cmmlu: 300, jmmlu: 300, hellaswag: 200, truthfulqa: 0, arc_challenge: 300, winogrande: 300, gsm8k: 100, mathqa: 300, humaneval: 0, mbpp: 200, livecodebench: 100, bbq: 300, safetybench: 300 },
accBenchmarkGroups: [
{
name: 'Knowledge',
benchmarks: [
{ key: 'mmlu', label: 'MMLU', desc: 'Knowledge · 57 subjects', fullSize: 14042, sizes: [30, 50, 100, 200, 300, 500, 1000, 2000] },
{ key: 'mmlu_pro', label: 'MMLU-Pro', desc: 'Hard knowledge · 14 subjects (10-way)', fullSize: 12032, sizes: [30, 50, 100, 200, 300, 500, 1000, 2000] },
{ key: 'kmmlu', label: 'KMMLU', desc: '한국어 지식 · 45 과목', fullSize: 35030, sizes: [30, 50, 100, 200, 300, 500, 1000, 2000] },
{ key: 'cmmlu', label: 'CMMLU', desc: '中文知识 · 67 科目', fullSize: 11582, sizes: [30, 50, 100, 200, 300, 500, 1000, 2000] },
{ key: 'jmmlu', label: 'JMMLU', desc: '日本語知識 · 112 科目', fullSize: 7536, sizes: [30, 50, 100, 200, 300, 500, 1000, 2000] },
],
},
{
name: 'Commonsense & Reasoning',
benchmarks: [
{ key: 'hellaswag', label: 'HellaSwag', desc: 'Commonsense reasoning', fullSize: 10042, sizes: [30, 50, 100, 200, 300, 500, 1000, 2000] },
{ key: 'arc_challenge', label: 'ARC-C', desc: 'Science reasoning', fullSize: 1172, sizes: [30, 50, 100, 200, 300] },
{ key: 'winogrande', label: 'Winogrande', desc: 'Coreference resolution', fullSize: 1267, sizes: [30, 50, 100, 200, 300] },
{ key: 'truthfulqa', label: 'TruthfulQA', desc: 'Truthfulness', fullSize: 817, sizes: [30, 50, 100, 200, 300] },
],
},
{
name: 'Math',
benchmarks: [
{ key: 'gsm8k', label: 'GSM8K', desc: 'Math reasoning', fullSize: 1319, sizes: [30, 50, 100, 200, 300] },
{ key: 'mathqa', label: 'MathQA', desc: 'Quantitative reasoning · 5-way', fullSize: 2985, sizes: [30, 50, 100, 200, 300, 500, 1000] },
],
},
{
name: 'Coding',
benchmarks: [
{ key: 'humaneval', label: 'HumanEval', desc: 'Function completion', fullSize: 164, sizes: [30, 50, 100] },
{ key: 'mbpp', label: 'MBPP', desc: 'Python problems', fullSize: 500, sizes: [30, 50, 100, 200, 300] },
{ key: 'livecodebench', label: 'LiveCodeBench', desc: 'Code generation', fullSize: 1055, sizes: [30, 50, 100, 200, 300] },
],
},
{
name: 'Safety & Alignment',
benchmarks: [
{ key: 'bbq', label: 'BBQ', desc: 'Social bias · 11 categories', fullSize: 10864, sizes: [30, 50, 100, 200, 300, 500, 1000, 2000] },
{ key: 'safetybench', label: 'SafetyBench', desc: 'Safety · 7 categories', fullSize: 11435, sizes: [30, 50, 100, 200, 300, 500, 1000, 2000] },
],
},
],
accBatchSize: 1,
accEnableThinking: false,
accSamplingProfile: 'deterministic',
accAdvancedOptionsOpen: false,
accExternalEnabled: false,
accRunning: false,
accCurrentModel: '',
accCurrentBenchId: null,
accProgress: null,
accAllResults: [], // accumulated across all models
accQueue: [], // server queue mirror
accError: '',
accEventSource: null,
accShowText: false,
accCopied: false,
async init() {
// Apply theme
this.applyTheme();
this.applyTabStateFromUrl();
await Promise.all([
this.loadGlobalSettings(),
this.loadModels(),
this.loadServerInfo(),
this.loadProfileFields(),
this.loadPresets(),
this.checkForUpdate()
]);
this.startUpdateCheckTimer();
await this.handleMainTabChange(this.mainTab);
// Watch for main tab changes to manage refresh timers
this.$watch('mainTab', (value) => {
this.handleMainTabChange(value);
});
// When the user returns to this browser tab after looking
// elsewhere, re-check whether a different bench just started
// in another tab. Fires the banner without requiring an
// in-app tab switch.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'visible') return;
if (this.mainTab === 'bench' && this.benchTab === 'throughput') {
this.loadBenchState();
}
});
this.$watch('hfMlxOnly', () => {
this.hfRecommended = { trending: [], popular: [] };
this.hfRecommendedLoaded = false;
this.hfSearchResults = [];
this.hfSearchLoaded = false;
this.loadRecommendedModels();
if (this.hfSearchQuery.trim()) {
this.searchHFModels();
}
});
this.$watch('msMlxOnly', () => {
this.msRecommended = { trending: [], popular: [] };
this.msRecommendedLoaded = false;
this.msSearchResults = [];
this.msSearchLoaded = false;
this.loadMsRecommendedModels();
if (this.msSearchQuery.trim()) {
this.searchMSModels();
}
});
window.addEventListener('popstate', () => {
this.applyTabStateFromUrl();
});
// Pause stats polling when tab is hidden to reduce server load
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
this.stopStatsRefresh();
} else if (this.mainTab === 'status') {
this.loadStats();
this.startStatsRefresh();
}
});
},
async handleMainTabChange(value) {
if (value === 'status') {
await this.loadStats();
this.startStatsRefresh();
} else {
this.stopStatsRefresh();
}
if (value === 'logs') {
await this.loadLogs();
this.startLogRefresh();
} else {
this.stopLogRefresh();
}
if (value === 'models') {
const loads = [this.loadHFModels(), this.loadHFTasks(), this.loadOQTasks()];
if (this.modelsTab === 'downloader' && !this.hfRecommendedLoaded) {
loads.push(this.loadRecommendedModels());
}
if (this.modelsTab === 'quantizer') {
loads.push(this.loadOQModels());
}
if (this.msInitialized && this.msAvailable) {
loads.push(this.loadMSTasks());
}
await Promise.all(loads);
const hasActive = this.hfTasks.some(t =>
t.status === 'pending' || t.status === 'downloading');
if (hasActive) this.startHFRefresh();
const hasMsActive = this.msTasks.some(t =>
t.status === 'pending' || t.status === 'downloading');
if (hasMsActive) this.startMSRefresh();
const hasOqActive = this.oqTasks.some(t =>
['pending', 'loading', 'quantizing', 'saving'].includes(t.status));
if (hasOqActive) this.startOQRefresh();
} else {
this.stopHFRefresh();
this.stopMSRefresh();
this.stopOQRefresh();
}
if (value === 'bench') {
if (!this.benchDeviceInfo) await this.loadBenchDeviceInfo();
await this.loadBenchState();
await this.loadAccState();
}
},
applyTabStateFromUrl() {
const params = new URLSearchParams(window.location.search);
const mainTab = params.get('tab');
const settingsTab = params.get('settingsTab');
const modelsTab = params.get('modelsTab');
const benchTab = params.get('benchTab');
this.mainTab = DASHBOARD_MAIN_TABS.has(mainTab) ? mainTab : 'status';
this.activeTab = DASHBOARD_SETTINGS_TABS.has(settingsTab) ? settingsTab : 'global';
this.modelsTab = DASHBOARD_MODELS_TABS.has(modelsTab) ? modelsTab : 'manager';
this.benchTab = DASHBOARD_BENCH_TABS.has(benchTab) ? benchTab : 'throughput';
},
syncTabStateToUrl() {
const url = new URL(window.location.href);
url.searchParams.set('tab', this.mainTab);
if (this.mainTab === 'settings') {
url.searchParams.set('settingsTab', this.activeTab);
} else {
url.searchParams.delete('settingsTab');
}
if (this.mainTab === 'models') {
url.searchParams.set('modelsTab', this.modelsTab);
} else {
url.searchParams.delete('modelsTab');
}
if (this.mainTab === 'bench') {
url.searchParams.set('benchTab', this.benchTab);
} else {
url.searchParams.delete('benchTab');
}
window.history.replaceState({}, '', url);
},
setMainTab(tab) {
if (!DASHBOARD_MAIN_TABS.has(tab)) return;
this.mainTab = tab;
this.syncTabStateToUrl();
},
setSettingsTab(tab) {
if (!DASHBOARD_SETTINGS_TABS.has(tab)) return;
this.activeTab = tab;
this.mainTab = 'settings';
this.syncTabStateToUrl();
},
setModelsTab(tab) {
if (!DASHBOARD_MODELS_TABS.has(tab)) return;
this.modelsTab = tab;
this.mainTab = 'models';
this.syncTabStateToUrl();
if (tab === 'quantizer') {
this.loadOQModels();
}
if (tab === 'uploader') {
if (!this.uploadOqModelsLoaded) this.loadUploadOqModels();
this.loadUploadTasks();
}
},
async checkForUpdate() {
try {
const resp = await fetch('/admin/api/update-check');
if (resp.ok) {
const data = await resp.json();
this.updateAvailable = data.update_available;
this.latestVersion = data.latest_version;
this.releaseUrl = data.release_url;
}
} catch (e) {
// Silently ignore - not critical
}
},
startUpdateCheckTimer() {
this._updateCheckTimer = setInterval(() => this.checkForUpdate(), 3600000);
},
stopUpdateCheckTimer() {
if (this._updateCheckTimer) {
clearInterval(this._updateCheckTimer);
this._updateCheckTimer = null;
}
},
async loadGlobalSettings() {
try {
const response = await fetch('/admin/api/global-settings');
if (response.ok) {
const data = await response.json();
// Deep merge to preserve defaults for missing fields
// Handle model_dirs: prefer list, fallback to single model_dir
const modelDirs = data.model?.model_dirs?.length
? data.model.model_dirs
: (data.model?.model_dir ? [data.model.model_dir] : ['']);
this.globalSettings = {
...this.globalSettings,
...data,
server: { ...this.globalSettings.server, ...data.server },
model: { ...this.globalSettings.model, ...data.model, model_dirs: modelDirs },
memory: { ...this.globalSettings.memory, ...data.memory },
scheduler: { ...this.globalSettings.scheduler, ...data.scheduler },
cache: { ...this.globalSettings.cache, ...data.cache },
sampling: { ...this.globalSettings.sampling, ...data.sampling },
mcp: { ...this.globalSettings.mcp, ...data.mcp },
huggingface: { ...this.globalSettings.huggingface, ...data.huggingface },
network: { ...this.globalSettings.network, ...data.network },
auth: { ...this.globalSettings.auth, ...data.auth },
claude_code: { ...this.globalSettings.claude_code, ...data.claude_code },
integrations: { ...this.globalSettings.integrations, ...data.integrations },
idle_timeout: { ...this.globalSettings.idle_timeout, ...data.idle_timeout },
system: { ...this.globalSettings.system, ...data.system },
};
this.globalSettings.ui = data.ui || { language: 'en' };
// Sync idle timeout select value
this.idleTimeoutValue = this.globalSettings.idle_timeout?.idle_timeout_seconds != null
? String(this.globalSettings.idle_timeout.idle_timeout_seconds)
: '';
// Normalize memory guard tier to one of the known values.
const validTiers = ['safe', 'balanced', 'aggressive', 'custom'];
if (!validTiers.includes(this.globalSettings.memory.memory_guard_tier)) {
this.globalSettings.memory.memory_guard_tier = 'balanced';
}
// Calculate cache percent from stored value (based on total capacity)
this.cachePercent = this.parseCacheToPercent(
this.globalSettings.cache.ssd_cache_max_size,
this.globalSettings.system.ssd_total_bytes
);
// Sync the cache string value from percent
this.updateCacheFromSlider();
// Calculate hot cache percent from stored value
this.globalSettings.cache.hot_cache_max_size = this.normalizeHotCacheMaxSize(
this.globalSettings.cache.hot_cache_max_size
);
this.hotCachePercent = this.parseHotCacheToPercent(
this.globalSettings.cache.hot_cache_max_size,
this.globalSettings.system.total_memory_bytes
);
} else if (response.status === 401) {
window.location.href = '/admin';
}
} catch (err) {
console.error('Failed to load global settings:', err);
}
},
async saveGlobalSettings() {
this.saving = true;
this.saveSuccess = false;
this.saveError = '';
// Validate required fields
const errors = [];
const s = this.globalSettings;
if (!s.server.host) errors.push('Host');
if (!s.server.port) errors.push('Port');
if (!s.model.model_dirs || !s.model.model_dirs.some(d => d.trim())) errors.push('Model Directory');
if (!s.scheduler.max_concurrent_requests) errors.push('Max Concurrent Requests');
if (!s.scheduler.embedding_batch_size) errors.push('Embedding Batch Size');
if (!s.cache.ssd_cache_max_size) errors.push('Max Cache Size');
if (!s.sampling.max_context_window) errors.push('Max Context Window');
if (!s.sampling.max_tokens) errors.push('Max Tokens');
if (errors.length > 0) {
this.saveError = window.t('js.error.required_fields').replace('{fields}', errors.join(', '));
this.saving = false;
return;
}
// Validate API key if provided
if (s.auth.api_key) {
if (s.auth.api_key.length < 4) {
this.saveError = window.t('js.error.api_key_min_length');
this.saving = false;
return;
}
if (/\s/.test(s.auth.api_key)) {
this.saveError = window.t('js.error.api_key_no_whitespace');
this.saving = false;
return;
}
}
try {
const response = await fetch('/admin/api/global-settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
host: this.globalSettings.server.host,
port: this.globalSettings.server.port,
log_level: this.globalSettings.server.log_level,
sse_keepalive_mode: this.globalSettings.server.sse_keepalive_mode,
burst_decode_mode: this.globalSettings.server.burst_decode_mode,
preserve_mid_system_cache: this.globalSettings.server.preserve_mid_system_cache,
model_dirs: this.globalSettings.model.model_dirs.filter(d => d.trim()),
model_fallback: this.globalSettings.model.model_fallback,
hide_helper_models: this.globalSettings.model.hide_helper_models,
memory_prefill_memory_guard: this.globalSettings.memory.prefill_memory_guard,
memory_guard_tier: this.globalSettings.memory.memory_guard_tier,
memory_guard_custom_ceiling_gb: this.globalSettings.memory.memory_guard_custom_ceiling_gb,
max_concurrent_requests: this.globalSettings.scheduler.max_concurrent_requests,
embedding_batch_size: this.globalSettings.scheduler.embedding_batch_size,
chunked_prefill: this.globalSettings.scheduler.chunked_prefill,
cache_enabled: this.globalSettings.cache.enabled,
ssd_cache_dir: this.globalSettings.cache.ssd_cache_dir,
ssd_cache_max_size: this.globalSettings.cache.ssd_cache_max_size,
hot_cache_max_size: this.normalizeHotCacheMaxSize(
this.globalSettings.cache.hot_cache_max_size
),
initial_cache_blocks: this.globalSettings.cache.initial_cache_blocks,
hot_cache_only: this.globalSettings.cache.hot_cache_only,
sampling_max_context_window: this.globalSettings.sampling.max_context_window,
sampling_max_context_window_policy: this.globalSettings.sampling.max_context_window_policy || null,
sampling_max_tokens: this.globalSettings.sampling.max_tokens,
sampling_temperature: this.globalSettings.sampling.temperature,
sampling_top_p: this.globalSettings.sampling.top_p,
sampling_top_k: this.globalSettings.sampling.top_k,
sampling_repetition_penalty: this.globalSettings.sampling.repetition_penalty,
mcp_config: this.globalSettings.mcp.config_path,
hf_cache_enabled: this.globalSettings.huggingface.hf_cache_enabled,
network_http_proxy: this.globalSettings.network.http_proxy,
network_https_proxy: this.globalSettings.network.https_proxy,
network_no_proxy: this.globalSettings.network.no_proxy,
network_ca_bundle: this.globalSettings.network.ca_bundle,
...(this.globalSettings.auth.api_key ? { api_key: this.globalSettings.auth.api_key } : {}),
skip_api_key_verification: this.globalSettings.auth.skip_api_key_verification,
idle_timeout_seconds: this.globalSettings.idle_timeout?.idle_timeout_seconds ?? null,
}),
});
if (response.ok) {
const data = await response.json();
this.saveSuccess = true;
this.saveMessage = data.message || 'Settings saved successfully';
// Refresh stats and model list (cache changes unload models)
await this.loadStats();
await this.loadModels();
setTimeout(() => { this.saveSuccess = false; }, 5000);
} else if (response.status === 401) {
window.location.href = '/admin';
} else {
const data = await response.json();
this.saveError = Array.isArray(data.detail) ? data.detail.map(e => (e && typeof e === 'object') ? (e.msg || JSON.stringify(e)) : String(e)).join(', ') : (data.detail || window.t('js.error.save_settings_failed'));
// Reload settings to revert to server values
await this.loadGlobalSettings();
}
} catch (err) {
console.error('Failed to save global settings:', err);
this.saveError = window.t('js.error.save_settings_failed');
// Reload settings to revert to server values
await this.loadGlobalSettings();
} finally {
this.saving = false;
}
},
// Sub key management
generateSubKey() {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
const rand = Array.from(crypto.getRandomValues(new Uint8Array(16)))
.map(b => chars[b % chars.length]).join('');
this.newSubKeyValue = 'omlx-' + rand;
this.showNewSubKey = true;
},
async createSubKey() {
this.subKeyError = '';
if (!this.newSubKeyValue || this.newSubKeyValue.length < 4) {
this.subKeyError = window.t('js.error.api_key_min_length');
return;
}
if (/\s/.test(this.newSubKeyValue)) {
this.subKeyError = window.t('js.error.api_key_no_whitespace');
return;
}
try {
const response = await fetch('/admin/api/sub-keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: this.newSubKeyValue, name: this.newSubKeyName }),
});
if (response.ok) {
this.newSubKeyValue = '';
this.newSubKeyName = '';
this.showNewSubKeyForm = false;
this.showNewSubKey = false;
await this.loadGlobalSettings();
} else if (response.status === 401) {
window.location.href = '/admin';
} else {
const data = await response.json();
this.subKeyError = data.detail || window.t('js.error.save_settings_failed');
}
} catch (err) {
this.subKeyError = window.t('js.error.save_settings_failed');
}
},
async deleteSubKey(key) {
if (!confirm(window.t('settings.auth.sub_keys_delete_confirm'))) return;
try {
const response = await fetch('/admin/api/sub-keys', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key }),
});
if (response.ok) {
await this.loadGlobalSettings();
} else if (response.status === 401) {
window.location.href = '/admin';
}
} catch (err) {
console.error('Failed to delete sub key:', err);
}
},
async loadModels() {
this.loadingModels = true;
try {
const response = await fetch('/admin/api/models');
if (response.ok) {
const data = await response.json();
this.models = data.models || [];
} else if (response.status === 401) {
window.location.href = '/admin';
}
} catch (err) {
console.error('Failed to load models:', err);
} finally {
this.loadingModels = false;
}
},
async reloadModels() {
if (this.reloading) return;
this.reloading = true;
try {
const response = await fetch('/admin/api/reload', { method: 'POST' });
if (response.ok) {
await Promise.all([this.loadModels(), this.loadHFModels()]);
} else if (response.status === 401) {
window.location.href = '/admin';
} else {
const data = await response.json();
alert(data.detail || window.t('js.error.reload_failed'));
}
} catch (err) {
console.error('Failed to reload models:', err);
alert(window.t('js.error.reload_failed'));
} finally {
this.reloading = false;
}
},
async updateModelSetting(modelId, field, value) {
try {
const response = await fetch(`/admin/api/models/${encodeURIComponent(modelId)}/settings`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [field]: value }),
});
if (response.ok) {
if (field === 'is_default' && value === true) {
this.models.forEach(m => { m.is_default = (m.id === modelId); });
} else if (field === 'is_pinned') {
const model = this.models.find(m => m.id === modelId);
if (model) model.pinned = value;
} else if (field === 'is_hidden') {
const model = this.models.find(m => m.id === modelId);
if (model) model.is_hidden = value;
} else if (field === 'is_favorite') {
const model = this.models.find(m => m.id === modelId);
if (model) model.is_favorite = value;
}
} else if (response.status === 401) {
window.location.href = '/admin';
} else {
const data = await response.json();
alert(data.detail || window.t('js.error.update_model_setting_failed'));
await this.loadModels();
}
} catch (err) {
console.error('Failed to update model setting:', err);
alert(window.t('js.error.update_model_setting_failed'));
await this.loadModels();
}
},
async loadModel(modelId) {
const model = this.models.find(m => m.id === modelId);
if (model) model.is_loading = true;
try {
const response = await fetch(`/admin/api/models/${encodeURIComponent(modelId)}/load`, {
method: 'POST',
});
if (response.ok) {
await this.loadModels();
} else if (response.status === 401) {
window.location.href = '/admin';
} else {
const data = await response.json();
alert(data.detail || window.t('js.error.load_model_failed'));
await this.loadModels();
}
} catch (err) {
console.error('Failed to load model:', err);
alert(window.t('js.error.load_model_failed'));
await this.loadModels();
}
},
async unloadModel(modelId) {
try {
const response = await fetch(`/admin/api/models/${encodeURIComponent(modelId)}/unload`, {
method: 'POST',
});
if (response.ok) {
const model = this.models.find(m => m.id === modelId);
if (model) model.loaded = false;
} else if (response.status === 401) {
window.location.href = '/admin';
} else {
const data = await response.json();
alert(data.detail || window.t('js.error.unload_model_failed'));
}
await this.loadModels();
} catch (err) {
console.error('Failed to unload model:', err);
alert(window.t('js.error.unload_model_failed'));
await this.loadModels();
}
},
// ===== Profiles / Templates =====
formValuesForProfile() {
const ms = this.modelSettings;
const out = {};
const isDiffusion = !!ms.is_diffusion_model;
for (const k of this.profileFields.universal.concat(this.profileFields.model_specific)) {
if (k === 'chat_template_kwargs' || k === 'forced_ct_kwargs') continue; // handle below
if (isDiffusion && this.isDiffusionUnsupportedProfileField(k)) continue;
if (k === 'thinking_budget_enabled') {
if (ms.enableThinkingBudget) out.thinking_budget_tokens = ms.thinking_budget_tokens ?? null;
continue;
}
if (k === 'index_cache_freq') {
if (ms.enableIndexCache) out.index_cache_freq = ms.index_cache_freq || 4;
continue;
}
if (k === 'max_tool_result_tokens') {
if (ms.enableToolResultLimit) out.max_tool_result_tokens = ms.max_tool_result_tokens || null;
continue;
}
if (k === 'guided_grammar_enabled') {
out.guided_grammar_enabled = !!ms.guided_grammar_enabled;
continue;
}
if (k === 'guided_grammar') {
out.guided_grammar = ms.guided_grammar_enabled
? ((ms.guided_grammar || '').trim() || null)
: null;
continue;
}
// Standard field: apply nullish coalescing; coerce string numerics
let v = ms[k] ?? null;
if (typeof v === 'string' && v !== '' && !isNaN(Number(v))) v = Number(v);
out[k] = v;
}
// Build chat_template_kwargs and forced_ct_kwargs from ctKwargEntries
const ctk = {};
const forced = [];
for (const e of (ms.ctKwargEntries || [])) {
if (e.type === 'enable_thinking') {
if (isDiffusion) continue;
ctk.enable_thinking = e.value === 'true';
if (e.force) forced.push('enable_thinking');
} else if (e.type === 'reasoning_effort') {
if (isDiffusion) continue;
ctk.reasoning_effort = e.value;
if (e.force) forced.push('reasoning_effort');
} else if (e.type === 'custom' && e.key && e.key.trim()) {
if (isDiffusion && this.isDiffusionUnsupportedCtKwarg(e.key.trim())) {
continue;
}
let v = e.value;
if (v === 'true') v = true;
else if (v === 'false') v = false;
else if (!isNaN(Number(v)) && String(v).trim() !== '') v = Number(v);
ctk[e.key.trim()] = v;
if (e.force) forced.push(e.key.trim());
}
}
if (Object.keys(ctk).length > 0) out.chat_template_kwargs = ctk;
if (forced.length > 0) out.forced_ct_kwargs = forced;
return out;
},
formValuesForTemplate() {
const full = this.formValuesForProfile();
const out = {};
for (const k of this.profileFields.universal) {
if (k in full) out[k] = full[k];
}
return out;
},
computeDrift() {
if (!this.activeProfileName) { this.profilesDrift = false; return; }
const active = this.profiles.find(p => p.name === this.activeProfileName);
if (!active) { this.profilesDrift = false; return; }
const form = this.formValuesForProfile();
for (const [k, v] of Object.entries(active.settings || {})) {
if (JSON.stringify(form[k]) !== JSON.stringify(v)) {
this.profilesDrift = true;
return;
}
}
this.profilesDrift = false;
},
matchedPreset(settings) {
// Return the preset whose universal-field settings match the current model
// settings exactly, otherwise null. Used by the models list to show "which
// preset was applied" as a pill without any server-side tracking.
if (!settings || !this.presets || this.presets.length === 0) return null;
const universal = this.profileFields.universal || [];
if (universal.length === 0) return null;
const canonical = v => {
if (v === undefined || v === null || v === false) return null;
if (typeof v === 'object') {
return JSON.stringify(v, Object.keys(v).sort());
}
return v;
};
for (const p of this.presets) {
const ps = p.settings || {};
let ok = true;
for (const k of universal) {
if (canonical(ps[k]) !== canonical(settings[k])) {
ok = false;
break;
}
}
if (ok) return p;
}
return null;
},
profileTooltip(profile) {
const lines = [];
if (profile?.expose_as_model && profile.model_id) {
lines.push(profile.model_id);
}
const description = (profile?.description || '').trim();
if (description) lines.push(description);
return lines.join('\n');
},
async loadProfilesForModel(modelId) {
this.profiles = [];
try {
const r = await fetch(`/admin/api/models/${encodeURIComponent(modelId)}/profiles`);
if (r.ok) {
const data = await r.json();
this.profiles = data.profiles || [];
} else if (r.status === 401) {
window.location.href = '/admin';
}
} catch (e) {
console.error('Failed to load profiles:', e);
}
},
async loadTemplates() {
try {
const r = await fetch('/admin/api/profile-templates');
if (r.ok) {
const data = await r.json();
this.templates = data.templates || [];
} else if (r.status === 401) {
window.location.href = '/admin';
}
} catch (e) {
console.error('Failed to load templates:', e);
}
},
async loadProfileFields() {
try {
const r = await fetch('/admin/api/profile-fields');
if (r.ok) {
const data = await r.json();
this.profileFields = {
universal: data.universal || [],
model_specific: data.model_specific || [],
};
} else if (r.status === 401) {
window.location.href = '/admin';
}
} catch (e) {
console.error('Failed to load profile field definitions:', e);
}
},
async loadPresets() {
// Use localStorage cache if present, otherwise fall back to the bundled file.
const cached = localStorage.getItem('omlx_preset_cache');
if (cached) {
try {
const parsed = JSON.parse(cached);
this.presets = parsed.presets || [];
return;
} catch (e) { /* corrupted, fall through */ }
}
try {
const r = await fetch('/admin/static/omlx_preset.json');
if (r.ok) {
const data = await r.json();
this.presets = data.presets || [];
}
} catch (e) {
console.error('Failed to load bundled presets:', e);
}
},
async refreshPresets() {
if (this.refreshingPresets) return;
this.refreshingPresets = true;
try {
const r = await fetch('/admin/api/presets/refresh', { method: 'POST' });
if (r.ok) {
const data = await r.json();
this.presets = data.presets || [];
localStorage.setItem('omlx_preset_cache', JSON.stringify(data));
} else if (r.status === 401) {
window.location.href = '/admin';
}
} catch (e) {
console.error('Preset refresh failed:', e);
} finally {
this.refreshingPresets = false;
}
},
// Floating tooltip shared by preset/profile pills (position:fixed escapes
// the scroll container's overflow clipping, unlike absolute+group-hover).
tip: { visible: false, text: '', x: 0, y: 0 },
showTip(el, text) {
if (!text) return;
const rect = el.getBoundingClientRect();
this.tip = {
visible: true,
text: text,
x: rect.left + rect.width / 2,
y: rect.bottom + 6,
};
},
hideTip() {
this.tip.visible = false;
},
isDiffusionModel(model) {
const modelType = String(model?.config_model_type || '')
.toLowerCase()
.replace(/-/g, '_');
return DIFFUSION_CONFIG_MODEL_TYPES.has(modelType);
},
isDiffusionUnsupportedProfileField(field) {
return DIFFUSION_UNSUPPORTED_PROFILE_FIELDS.has(field);
},
isDiffusionUnsupportedCtKwarg(key) {
return DIFFUSION_UNSUPPORTED_CT_KWARGS.has(key);
},
draftModelSearchText(model) {
return [
model?.id,
model?.name,
model?.model_path,
model?.source_repo_id,
model?.config_model_type,
].filter(Boolean).join(' ').toLowerCase();
},
isDraftModelBaseCandidate(model) {
if (!model || model.virtual) return false;
if (model.id === this.selectedModel?.id) return false;
return model.model_type === 'llm' || model.model_type === 'vlm' || !model.model_type;
},
isDflashDraftModel(model) {
return /(^|[-_/\s])dflash($|[-_/\s])/i.test(this.draftModelSearchText(model));
},
isVlmMtpDraftModel(model) {
const configType = String(model?.config_model_type || '').toLowerCase();
if (configType) {
return VLM_MTP_DRAFTER_CONFIG_MODEL_TYPES.has(configType);
}
return /assistant|(^|[-_/\s])mtp($|[-_/\s])/i.test(this.draftModelSearchText(model));
},
isSpecPrefillDraftModel(model) {
return !this.isDflashDraftModel(model)
&& !this.isVlmMtpDraftModel(model);
},
draftModelCandidates(filterFn, { fallbackToBase = true } = {}) {
const base = (this.models || []).filter((model) => (
this.isDraftModelBaseCandidate(model)
));
const filtered = base.filter(filterFn);
return (filtered.length > 0 || !fallbackToBase) ? filtered : base;
},
specPrefillDraftModelCandidates() {
return this.draftModelCandidates((model) => this.isSpecPrefillDraftModel(model));
},
dflashDraftModelCandidates() {
return this.draftModelCandidates((model) => this.isDflashDraftModel(model));
},
vlmMtpDraftModelCandidates() {
return this.draftModelCandidates(
(model) => this.isVlmMtpDraftModel(model),
{ fallbackToBase: false },
);
},
buildCtKwargEntries(chatTemplateKwargs, forcedCtKwargs, isDiffusion = false) {
const ctk = chatTemplateKwargs || {};
const forced = new Set(forcedCtKwargs || []);
const entries = [];
for (const [key, value] of Object.entries(ctk)) {
if (isDiffusion && this.isDiffusionUnsupportedCtKwarg(key)) {
continue;
}
if (key === 'enable_thinking') {
entries.push({
type: 'enable_thinking',
value: String(value),
force: forced.has('enable_thinking'),
});
} else if (key === 'reasoning_effort') {
entries.push({
type: 'reasoning_effort',
value: String(value),
force: forced.has('reasoning_effort'),
});
} else {
entries.push({
type: 'custom',
key,
value: String(value),
force: forced.has(key),
});
}
}
return entries;
},
buildModelSettingsState(model, settings) {
const s = settings || {};
const isDiffusion = this.isDiffusionModel(model);
const ctKwargEntries = this.buildCtKwargEntries(
s.chat_template_kwargs,
s.forced_ct_kwargs,
isDiffusion,
);
const isOcr = OCR_CONFIG_MODEL_TYPES.has(model?.config_model_type || '');
return {
model_alias: s.model_alias || '',
model_type_override: s.model_type_override || '',
max_context_window: s.max_context_window || null,
max_tokens: s.max_tokens || null,
temperature: isOcr ? 0.0 : (s.temperature ?? null),
top_p: s.top_p ?? null,
top_k: s.top_k ?? null,
repetition_penalty: s.repetition_penalty ?? null,
min_p: s.min_p ?? null,
presence_penalty: s.presence_penalty ?? null,
force_sampling: s.force_sampling || false,
enable_thinking: s.enable_thinking ?? null,
thinking_default: model?.thinking_default ?? null,
enableThinkingBudget: !!(s.thinking_budget_tokens),
thinking_budget_tokens: s.thinking_budget_tokens || null,
guided_grammar_enabled: s.guided_grammar_enabled || false,
guided_grammar: s.guided_grammar || '',
enableToolResultLimit: !!(s.max_tool_result_tokens),
max_tool_result_tokens: s.max_tool_result_tokens || null,
reasoning_parser: s.reasoning_parser || '',
ttl_seconds: s.ttl_seconds ?? null,
enableIndexCache: !!(s.index_cache_freq),
index_cache_freq: s.index_cache_freq || null,
turboquant_kv_enabled: s.turboquant_kv_enabled || false,
turboquant_kv_bits: s.turboquant_kv_bits || 4,
specprefill_enabled: s.specprefill_enabled || false,
specprefill_draft_model: s.specprefill_draft_model || '',
specprefill_keep_pct: s.specprefill_keep_pct ? String(s.specprefill_keep_pct) : '0.2',
specprefill_threshold: s.specprefill_threshold || null,
dflash_enabled: s.dflash_enabled || false,
dflash_draft_model: s.dflash_draft_model || '',
dflash_draft_quant_enabled: s.dflash_draft_quant_enabled || false,
dflash_draft_quant_weight_bits: s.dflash_draft_quant_weight_bits || 4,
dflash_draft_quant_activation_bits: s.dflash_draft_quant_activation_bits || 16,
dflash_draft_quant_group_size: s.dflash_draft_quant_group_size || 64,
dflash_max_ctx: s.dflash_max_ctx ?? null,
dflash_in_memory_cache: s.dflash_in_memory_cache !== false,
dflash_in_memory_cache_max_entries: s.dflash_in_memory_cache_max_entries || 4,
dflash_in_memory_cache_max_gib: s.dflash_in_memory_cache_max_bytes
? Math.round(s.dflash_in_memory_cache_max_bytes / (1024 ** 3))
: 8,
dflash_ssd_cache: s.dflash_ssd_cache || false,
dflash_ssd_cache_max_gib: s.dflash_ssd_cache_max_bytes
? Math.round(s.dflash_ssd_cache_max_bytes / (1024 ** 3))
: 20,
dflash_draft_window_size: s.dflash_draft_window_size ?? null,
dflash_draft_sink_size: s.dflash_draft_sink_size ?? null,
dflash_verify_mode: s.dflash_verify_mode || 'adaptive',
dflash_compatible: model?.dflash_compatible !== false,
dflash_compatibility_reason: model?.dflash_compatibility_reason || '',
dflash_ssd_cache_available: !!model?.dflash_ssd_cache_available,
mtp_enabled: s.mtp_enabled || false,
mtp_compatible: model?.mtp_compatible === true,
mtp_compatibility_reason: model?.mtp_compatibility_reason || '',
is_paroquant: model?.is_paroquant === true,
paroquant_reason: model?.paroquant_reason || '',
vlm_mtp_enabled: s.vlm_mtp_enabled || false,
vlm_mtp_draft_model: s.vlm_mtp_draft_model || '',
vlm_mtp_draft_block_size: s.vlm_mtp_draft_block_size ?? null,
ctKwargEntries,
is_diffusion_model: isDiffusion,
trust_remote_code: s.trust_remote_code || false,
};
},
_resetPresetApplicableFields() {
// Reset all fields a preset can touch so switching presets does not leave
// stale values. Intentionally does NOT touch model_alias / model_type_override
// / is_pinned / is_default / turboquant_* / dflash_* / specprefill_* / index_cache_*.
const ms = this.modelSettings;
ms.temperature = null;
ms.top_p = null;
ms.top_k = null;
ms.min_p = null;
ms.repetition_penalty = null;
ms.presence_penalty = null;
ms.force_sampling = false;
ms.max_context_window = null;
ms.max_tokens = null;
ms.reasoning_parser = null;
ms.guided_grammar_enabled = false;
ms.guided_grammar = '';
ms.ttl_seconds = null;
ms.enable_thinking = null;
ms.enableThinkingBudget = false;
ms.thinking_budget_tokens = null;
ms.enableToolResultLimit = false;
ms.max_tool_result_tokens = null;
ms.ctKwargEntries = [];
},
applyPresetToForm(preset) {
// Reset first so previous preset's fields (e.g. presence_penalty) do not stick.
this._resetPresetApplicableFields();
const s = preset.settings || {};
const ms = this.modelSettings;
const isDiffusion = !!ms.is_diffusion_model;
for (const k of Object.keys(s)) {
if (isDiffusion
&& k !== 'chat_template_kwargs'
&& k !== 'forced_ct_kwargs'
&& this.isDiffusionUnsupportedProfileField(k)) {
continue;
}
if (k === 'thinking_budget_enabled') {
ms.enableThinkingBudget = !!s[k];
} else if (k === 'max_tool_result_tokens') {
ms.enableToolResultLimit = s[k] != null;
ms.max_tool_result_tokens = s[k] ?? null;
} else if (k === 'guided_grammar_enabled') {
ms.guided_grammar_enabled = !!s[k];
} else if (k === 'guided_grammar') {
ms.guided_grammar = s[k] || '';
} else if (k === 'chat_template_kwargs' || k === 'forced_ct_kwargs') {
ms.ctKwargEntries = this.buildCtKwargEntries(
s.chat_template_kwargs,
s.forced_ct_kwargs,
isDiffusion,
);
} else {
ms[k] = s[k];
}
}
this.activeProfileName = null;
this.profilesDrift = false;
},
setScope(scope) {
this.profileScope = scope;
try { localStorage.setItem('omlx_profile_scope', scope); } catch (e) {}
},
isValidProfileName(name) {
// Mirror of the backend rule (validate_profile_name) and the
// Mac app's isValidSlug. api_name is the exposed model ID
// suffix (<model>:<api_name>), so it must be a clean slug.
return /^[a-z0-9][a-z0-9_-]{0,31}$/.test((name || '').trim());
},
slugifyProfileApiName(value) {
let slug = (value || '')
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, '-')
.replace(/-+/g, '-')
.replace(/^[-_]+|[-_]+$/g, '')
.slice(0, 32)
.replace(/[-_]+$/g, '');
return slug || 'profile';
},
async createProfile() {
if (!this.selectedModel) return;
this.profileError = '';
const displayName = (this.newProfile.display_name || '').trim();
if (!displayName) {
this.profileError = 'Name required';
return;
}
const apiName = (this.newProfile.api_name || this.slugifyProfileApiName(displayName)).trim();
if (!this.isValidProfileName(apiName)) {
this.profileError = window.t('modal.model_settings.profiles.invalid_name');
return;
}
const autoId = 'p-' + Date.now().toString(36) + '-' +
Math.random().toString(36).slice(2, 6);
const body = {
name: autoId,
display_name: displayName,
api_name: apiName,
description: (this.newProfile.description || '').trim() || null,
settings: this.formValuesForProfile(),
also_save_as_template: false,
};
try {
const r = await fetch(
`/admin/api/models/${encodeURIComponent(this.selectedModel.id)}/profiles`,
{ method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body) }
);
if (r.ok) {
await this.loadProfilesForModel(this.selectedModel.id);
if (body.also_save_as_template) await this.loadTemplates();
this.showNewProfileForm = false;
this.newProfile = { display_name: '', api_name: '', api_name_touched: false, description: '', also_as_template: false };
} else if (r.status === 401) {
window.location.href = '/admin';
} else {
const data = await r.json().catch(() => ({}));
this.profileError = data.detail || 'Failed to save profile';
}
} catch (e) {
this.profileError = String(e);
}
},
async applyProfileToForm(profile) {
const seq = ++this._applySeq;
try {
const r = await fetch(
`/admin/api/models/${encodeURIComponent(this.selectedModel.id)}/profiles/${encodeURIComponent(profile.name)}/apply`,
{ method: 'POST' }
);
if (seq !== this._applySeq) return; // superseded by a newer click
if (r.ok) {
const data = await r.json();
const activeName = data.settings?.active_profile_name || profile.name;
const settings = {
...(data.settings || {}),
active_profile_name: activeName,
};
this.modelSettings = this.buildModelSettingsState(
this.selectedModel,
settings,
);
if (this.selectedModel) {
this.selectedModel.settings = { ...settings };
}
this.activeProfileName = activeName;
this.profilesDrift = false;
// Update the models list so the profile badge reflects the change
const m = this.models.find(m => m.id === this.selectedModel.id);
if (m) m.settings = { ...settings };
} else if (r.status === 401) {
window.location.href = '/admin';
}
} catch (e) {
console.error('Failed to apply profile:', e);
}
},
async applyTemplateToForm(template) {
// Check if a profile with this template's name already exists
const existingProfile = this.profiles.find(p => p.name === template.name);
if (existingProfile) {
// Global templates are the source of truth in this scope.
const updatedProfile = await this.updateProfile(existingProfile.name, {
settings: template.settings,
source_template: template.name,
});
if (updatedProfile) {
await this.applyProfileToForm(updatedProfile);
}
} else {
// Create a new profile from the template
const body = {
name: template.name,
display_name: template.display_name,
api_name: this.slugifyProfileApiName(template.display_name || template.name),
description: template.description || null,
settings: template.settings,
source_template: template.name,
};
try {
const r = await fetch(
`/admin/api/models/${encodeURIComponent(this.selectedModel.id)}/profiles`,
{ method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body) }
);
if (r.ok) {
// Reload profiles first to include the new one
await this.loadProfilesForModel(this.selectedModel.id);
// Find the newly created profile in the refreshed list
const newProfile = this.profiles.find(p => p.name === template.name);
if (newProfile) {
await this.applyProfileToForm(newProfile);
}
}
} catch (e) {
console.error('Failed to create profile from template:', e);
}
}
},
async deleteProfile(name) {
if (!this.selectedModel) return;
try {
const r = await fetch(
`/admin/api/models/${encodeURIComponent(this.selectedModel.id)}/profiles/${encodeURIComponent(name)}`,
{ method: 'DELETE' }
);
if (r.ok) {
if (this.activeProfileName === name) this.activeProfileName = null;
await this.loadProfilesForModel(this.selectedModel.id);
} else if (r.status === 401) {
window.location.href = '/admin';
}
} catch (e) {
console.error('Delete profile failed:', e);
} finally {
this.profileDeleteConfirm = null;
}
},
updateProfileFromEdit(p) {
// Edit-dialog save. Internal profile name stays stable; api_name
// is the API-visible suffix used by exposed model IDs.
this.profileError = '';
const displayName = (p._editDisplayName ?? p.display_name ?? p.name).trim();
const apiName = (p._editApiName ?? p.api_name ?? p.name).trim();
const description = (p._editDescription ?? p.description ?? '').trim();
const exposeAsModel = !!(p._editExposeAsModel ?? p.expose_as_model);
if (!displayName) {
this.profileError = 'Name required';
return;
}
if (!this.isValidProfileName(apiName)) {
this.profileError = window.t('modal.model_settings.profiles.invalid_name');
return;
}
const patch = {
display_name: displayName,
api_name: apiName,
description: description,
expose_as_model: exposeAsModel,
};
return this.updateProfile(p.name, patch);
},
updateProfileSettingsFromForm(p) {
return this.updateProfile(p.name, {
settings: this.formValuesForProfile(),
});
},
async updateProfile(name, patch) {
// patch: { new_name?, display_name?, api_name?, description?, expose_as_model?, settings?, also_save_as_template? }
if (!this.selectedModel) return;
this.profileError = '';
try {
const r = await fetch(
`/admin/api/models/${encodeURIComponent(this.selectedModel.id)}/profiles/${encodeURIComponent(name)}`,
{ method: 'PUT', headers: {'Content-Type':'application/json'},
body: JSON.stringify(patch) }
);
if (r.ok) {
const data = await r.json();
if (this.activeProfileName === name && patch.new_name) {
this.activeProfileName = patch.new_name;
}
await this.loadProfilesForModel(this.selectedModel.id);
if (patch.also_save_as_template) await this.loadTemplates();
this.editingProfile = null;
return data.profile;
} else if (r.status === 401) {
window.location.href = '/admin';
} else {
const data = await r.json().catch(() => ({}));
this.profileError = data.detail || 'Failed to update profile';
}
} catch (e) {
this.profileError = String(e);
}
},
async createTemplate() {
this.profileError = '';
const displayName = this.newTemplate.display_name.trim();
if (!displayName) {
this.profileError = 'Name required';
return;
}
const autoId = 't-' + Date.now().toString(36) + '-' +
Math.random().toString(36).slice(2, 6);
const body = {
name: autoId,
display_name: displayName,
description: this.newTemplate.description.trim() || null,
// Only universal fields — server will filter again defensively.
settings: this.formValuesForTemplate(),
};
try {
const r = await fetch('/admin/api/profile-templates', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
});
if (r.ok) {
await this.loadTemplates();
this.showNewTemplateForm = false;
this.newTemplate = { name: '', display_name: '', description: '' };
} else if (r.status === 401) {
window.location.href = '/admin';
} else {
const data = await r.json().catch(() => ({}));
this.profileError = data.detail || 'Failed to save template';
}
} catch (e) {
this.profileError = String(e);
}
},
async updateTemplate(name, patch) {
this.profileError = '';
try {
const r = await fetch(
`/admin/api/profile-templates/${encodeURIComponent(name)}`,
{ method: 'PUT', headers: {'Content-Type':'application/json'},
body: JSON.stringify(patch) }
);
if (r.ok) {
await this.loadTemplates();
this.editingTemplate = null;
} else if (r.status === 401) {
window.location.href = '/admin';
} else {
const data = await r.json().catch(() => ({}));
this.profileError = data.detail || 'Failed to update template';
}
} catch (e) {
this.profileError = String(e);
}
},
async deleteTemplate(name) {
try {
const r = await fetch(
`/admin/api/profile-templates/${encodeURIComponent(name)}`,
{ method: 'DELETE' }
);
if (r.ok) {
await this.loadTemplates();
} else if (r.status === 401) {
window.location.href = '/admin';
}
} catch (e) {
console.error('Delete template failed:', e);
} finally {
this.templateDeleteConfirm = null;
}
},
async openModelSettings(model) {
this.profileError = '';
this.showNewProfileForm = false;
this.showNewTemplateForm = false;
this.editingProfile = null;
this.editingTemplate = null;
this.profileDeleteConfirm = null;
this.templateDeleteConfirm = null;
const isDiffusion = this.isDiffusionModel(model);
this.activeProfileName = isDiffusion
? null
: ((model.settings && model.settings.active_profile_name) || null);
if (isDiffusion) {
this.profiles = [];
this.templates = [];
this.profilesDrift = false;
} else {
try {
const saved = localStorage.getItem('omlx_profile_scope');
if (saved === 'preset' || saved === 'global' || saved === 'model') {
this.profileScope = saved;
}
} catch (e) {}
await Promise.all([
this.loadProfilesForModel(model.id),
this.loadTemplates(),
]);
if (this.reasoningParsers.length === 0) {
try {
const resp = await fetch('/admin/api/grammar/parsers');
if (resp.ok) this.reasoningParsers = await resp.json();
else if (resp.status === 401) window.location.href = '/admin';
} catch (_) { /* network error */ }
}
}
this.selectedModel = model;
this.modelSettings = this.buildModelSettingsState(
model,
model.settings || {},
);
if (isDiffusion) {
this.profilesDrift = false;
} else {
this.computeDrift();
}
this.showModelSettingsModal = true;
},
async saveModelSettings() {
if (!this.selectedModel) return;
this.savingModelSettings = true;
try {
const response = await fetch(`/admin/api/models/${encodeURIComponent(this.selectedModel.id)}/settings`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify((() => {
const isDiffusion = !!this.modelSettings.is_diffusion_model;
// Build chat_template_kwargs and forced_ct_kwargs from ctKwargEntries
const chatTemplateKwargs = {};
const forcedCtKwargs = [];
for (const entry of this.modelSettings.ctKwargEntries) {
if (entry.type === 'enable_thinking') {
if (isDiffusion) continue;
chatTemplateKwargs.enable_thinking = entry.value === 'true';
if (entry.force) forcedCtKwargs.push('enable_thinking');
} else if (entry.type === 'reasoning_effort') {
if (isDiffusion) continue;
chatTemplateKwargs.reasoning_effort = entry.value;
if (entry.force) forcedCtKwargs.push('reasoning_effort');
} else if (entry.type === 'custom' && entry.key && entry.key.trim()) {
let val = entry.value;
if (val === 'true') val = true;
else if (val === 'false') val = false;
else if (!isNaN(Number(val)) && val.trim() !== '') val = Number(val);
const key = entry.key.trim();
if (isDiffusion && this.isDiffusionUnsupportedCtKwarg(key)) {
continue;
}
chatTemplateKwargs[key] = val;
if (entry.force) forcedCtKwargs.push(key);
}
}
const payload = {
model_alias: this.modelSettings.model_alias?.trim() || null,
model_type_override: this.modelSettings.model_type_override || null,
max_context_window: this.modelSettings.max_context_window || null,
max_tokens: this.modelSettings.max_tokens || null,
temperature: Number.isFinite(this.modelSettings.temperature) ? this.modelSettings.temperature : null,
top_p: Number.isFinite(this.modelSettings.top_p) ? this.modelSettings.top_p : null,
top_k: Number.isFinite(this.modelSettings.top_k) ? this.modelSettings.top_k : null,
repetition_penalty: Number.isFinite(this.modelSettings.repetition_penalty) ? this.modelSettings.repetition_penalty : null,
min_p: Number.isFinite(this.modelSettings.min_p) ? this.modelSettings.min_p : null,
presence_penalty: Number.isFinite(this.modelSettings.presence_penalty) ? this.modelSettings.presence_penalty : null,
force_sampling: this.modelSettings.force_sampling,
reasoning_parser: this.modelSettings.reasoning_parser || null,
ttl_seconds: this.modelSettings.ttl_seconds || null,
index_cache_freq: this.modelSettings.enableIndexCache
? (this.modelSettings.index_cache_freq || 4)
: 0,
enable_thinking: this.modelSettings.enable_thinking,
thinking_budget_enabled: this.modelSettings.enableThinkingBudget,
thinking_budget_tokens: this.modelSettings.enableThinkingBudget
? (this.modelSettings.thinking_budget_tokens || null)
: 0,
guided_grammar_enabled: this.modelSettings.guided_grammar_enabled,
guided_grammar: this.modelSettings.guided_grammar_enabled
? (this.modelSettings.guided_grammar || null)
: null,
max_tool_result_tokens: this.modelSettings.enableToolResultLimit
? (this.modelSettings.max_tool_result_tokens || null)
: 0,
chat_template_kwargs: Object.keys(chatTemplateKwargs).length > 0
? chatTemplateKwargs : null,
forced_ct_kwargs: forcedCtKwargs.length > 0
? forcedCtKwargs : null,
turboquant_kv_enabled: this.modelSettings.turboquant_kv_enabled,
turboquant_kv_bits: this.modelSettings.turboquant_kv_enabled
? (parseFloat(this.modelSettings.turboquant_kv_bits) || 4)
: 4,
specprefill_enabled: this.modelSettings.specprefill_enabled,
specprefill_draft_model: this.modelSettings.specprefill_draft_model || null,
specprefill_keep_pct: this.modelSettings.specprefill_enabled
? parseFloat(this.modelSettings.specprefill_keep_pct) || 0.2
: null,
specprefill_threshold: this.modelSettings.specprefill_enabled
? (this.modelSettings.specprefill_threshold || null)
: null,
dflash_enabled: this.modelSettings.dflash_enabled,
dflash_draft_model: this.modelSettings.dflash_draft_model || null,
dflash_draft_quant_enabled: this.modelSettings.dflash_enabled && !!this.modelSettings.dflash_draft_quant_enabled,
dflash_draft_quant_weight_bits: this.modelSettings.dflash_enabled && this.modelSettings.dflash_draft_quant_enabled
? parseInt(this.modelSettings.dflash_draft_quant_weight_bits)
: null,
dflash_draft_quant_activation_bits: this.modelSettings.dflash_enabled && this.modelSettings.dflash_draft_quant_enabled
? parseInt(this.modelSettings.dflash_draft_quant_activation_bits)
: null,
dflash_draft_quant_group_size: this.modelSettings.dflash_enabled && this.modelSettings.dflash_draft_quant_enabled
? parseInt(this.modelSettings.dflash_draft_quant_group_size)
: null,
dflash_max_ctx: this.modelSettings.dflash_enabled && this.modelSettings.dflash_max_ctx
? parseInt(this.modelSettings.dflash_max_ctx)
: null,
dflash_in_memory_cache: this.modelSettings.dflash_enabled
? !!this.modelSettings.dflash_in_memory_cache
: true,
dflash_in_memory_cache_max_entries: this.modelSettings.dflash_enabled
? (parseInt(this.modelSettings.dflash_in_memory_cache_max_entries) || 4)
: 4,
dflash_in_memory_cache_max_bytes: this.modelSettings.dflash_enabled
? Math.max(1, parseInt(this.modelSettings.dflash_in_memory_cache_max_gib) || 8) * (1024 ** 3)
: 8 * (1024 ** 3),
dflash_ssd_cache: this.modelSettings.dflash_enabled
&& !!this.modelSettings.dflash_in_memory_cache
&& !!this.modelSettings.dflash_ssd_cache_available
&& !!this.modelSettings.dflash_ssd_cache,
dflash_ssd_cache_max_bytes: this.modelSettings.dflash_enabled
? Math.max(1, parseInt(this.modelSettings.dflash_ssd_cache_max_gib) || 20) * (1024 ** 3)
: 20 * (1024 ** 3),
// Long-context tuning. Null → server keeps it null → dflash-mlx default.
dflash_draft_window_size: this.modelSettings.dflash_enabled
&& this.modelSettings.dflash_draft_window_size
? parseInt(this.modelSettings.dflash_draft_window_size)
: null,
dflash_draft_sink_size: this.modelSettings.dflash_enabled
&& this.modelSettings.dflash_draft_sink_size !== null
&& this.modelSettings.dflash_draft_sink_size !== undefined
&& this.modelSettings.dflash_draft_sink_size !== ''
? parseInt(this.modelSettings.dflash_draft_sink_size)
: null,
dflash_verify_mode: this.modelSettings.dflash_enabled
? (this.modelSettings.dflash_verify_mode || 'adaptive')
: null,
mtp_enabled: !!this.modelSettings.mtp_enabled,
vlm_mtp_enabled: !!this.modelSettings.vlm_mtp_enabled,
vlm_mtp_draft_model: this.modelSettings.vlm_mtp_enabled
? (this.modelSettings.vlm_mtp_draft_model || null)
: null,
vlm_mtp_draft_block_size: this.modelSettings.vlm_mtp_enabled
&& this.modelSettings.vlm_mtp_draft_block_size
? parseInt(this.modelSettings.vlm_mtp_draft_block_size)
: null,
trust_remote_code: this.modelSettings.trust_remote_code,
};
if (isDiffusion) {
Object.assign(payload, {
top_p: null,
top_k: null,
repetition_penalty: null,
min_p: null,
presence_penalty: null,
force_sampling: false,
reasoning_parser: null,
index_cache_freq: 0,
enable_thinking: null,
thinking_budget_enabled: false,
thinking_budget_tokens: 0,
guided_grammar_enabled: false,
guided_grammar: null,
max_tool_result_tokens: 0,
turboquant_kv_enabled: false,
turboquant_kv_bits: 4,
specprefill_enabled: false,
specprefill_draft_model: null,
specprefill_keep_pct: null,
specprefill_threshold: null,
dflash_enabled: false,
dflash_draft_model: null,
dflash_draft_quant_enabled: false,
dflash_draft_quant_weight_bits: null,
dflash_draft_quant_activation_bits: null,
dflash_draft_quant_group_size: null,
dflash_max_ctx: null,
dflash_in_memory_cache: true,
dflash_in_memory_cache_max_entries: 4,
dflash_in_memory_cache_max_bytes: 8 * (1024 ** 3),
dflash_ssd_cache: false,
dflash_ssd_cache_max_bytes: 20 * (1024 ** 3),
dflash_draft_window_size: null,
dflash_draft_sink_size: null,
dflash_verify_mode: null,
mtp_enabled: false,
vlm_mtp_enabled: false,
vlm_mtp_draft_model: null,
vlm_mtp_draft_block_size: null,
});
}
return payload;
})()),
});
if (response.ok) {
// Refresh the model list to update badges
await this.loadModels();
const data = await response.json();
this.showModelSettingsModal = false;
if (data.requires_reload) {
if (data.auto_reloaded) {
alert(window.t('js.info.model_settings_auto_reloaded'));
} else if (data.auto_unloaded) {
alert(window.t('js.info.model_settings_auto_unloaded'));
} else {
alert(window.t('js.info.model_type_reload_required'));
}
}
} else if (response.status === 401) {
window.location.href = '/admin';
} else {
const data = await response.json();
alert(data.detail || window.t('js.error.save_model_settings_failed'));
}
} catch (err) {
console.error('Failed to save model settings:', err);
alert(window.t('js.error.save_model_settings_failed'));
} finally {
this.savingModelSettings = false;
}
},
async loadGenerationDefaults() {
if (!this.selectedModel) return;
this.loadingGenDefaults = true;
try {
const response = await fetch(`/admin/api/models/${encodeURIComponent(this.selectedModel.id)}/generation_config`);
if (response.ok) {
const data = await response.json();
// Set values from config, clear everything else to Default (null)
this.modelSettings.max_context_window = data.max_context_window ?? null;
this.modelSettings.temperature = data.temperature ?? null;
this.modelSettings.top_p = data.top_p ?? null;
this.modelSettings.top_k = data.top_k ?? null;
this.modelSettings.repetition_penalty = data.repetition_penalty ?? null;
this.modelSettings.max_tokens = null;
this.modelSettings.min_p = null;
this.modelSettings.presence_penalty = null;
this.modelSettings.force_sampling = false;
this.modelSettings.reasoning_parser = null;
this.modelSettings.guided_grammar_enabled = false;
this.modelSettings.guided_grammar = '';
this.modelSettings.ttl_seconds = null;
this.modelSettings.enableIndexCache = false;
this.modelSettings.index_cache_freq = 0;
this.modelSettings.enable_thinking = false;
this.modelSettings.enableThinkingBudget = false;
this.modelSettings.thinking_budget_tokens = 0;
this.modelSettings.enableToolResultLimit = false;
this.modelSettings.max_tool_result_tokens = 0;
this.modelSettings.ctKwargEntries = [];
this.modelSettings.turboquant_kv_enabled = false;
this.modelSettings.turboquant_kv_bits = 4;
this.modelSettings.specprefill_enabled = false;
this.modelSettings.specprefill_draft_model = null;
this.modelSettings.specprefill_keep_pct = 0.2;
this.modelSettings.specprefill_threshold = null;
this.modelSettings.dflash_enabled = false;
this.modelSettings.dflash_draft_model = null;
this.modelSettings.dflash_draft_quant_enabled = false;
this.modelSettings.dflash_draft_quant_weight_bits = null;
this.modelSettings.dflash_draft_quant_activation_bits = null;
this.modelSettings.dflash_draft_quant_group_size = null;
this.modelSettings.dflash_max_ctx = null;
this.modelSettings.dflash_in_memory_cache = true;
this.modelSettings.dflash_in_memory_cache_max_entries = 4;
this.modelSettings.dflash_in_memory_cache_max_gib = 8;
this.modelSettings.dflash_ssd_cache = false;
this.modelSettings.dflash_ssd_cache_max_gib = 20;
this.modelSettings.dflash_draft_window_size = null;
this.modelSettings.dflash_draft_sink_size = null;
this.modelSettings.dflash_verify_mode = 'adaptive';
this.modelSettings.mtp_enabled = false;
this.modelSettings.trust_remote_code = false;
} else if (response.status === 404) {
alert(window.t('js.error.no_config_defaults'));
} else if (response.status === 401) {
window.location.href = '/admin';
} else {
const data = await response.json();
alert(data.detail || window.t('js.error.load_generation_config_failed'));
}
} catch (err) {
console.error('Failed to load generation config:', err);
alert(window.t('js.error.load_generation_config_failed'));
} finally {
this.loadingGenDefaults = false;
}
this.activeProfileName = null;
this.profilesDrift = false;
},
// Status tab functions
// Normalizes a host string for safe URL embedding:
// - unwraps existing IPv6 brackets so we can re-bracket consistently
// - maps unspecified bind addresses (0.0.0.0, ::) to a placeholder
// since they are not routable from a client
// - maps `localhost` to 127.0.0.1 for consistency with other URLs
// - bracket-wraps IPv6 addresses per RFC 3986 (`http://[::1]:8000/v1`)
formatDisplayHost(host) {
const value = (host || '').trim();
if (!value) return '127.0.0.1';
const unwrapped = value.startsWith('[') && value.endsWith(']')
? value.slice(1, -1)
: value;
if (unwrapped === '0.0.0.0' || unwrapped === '::') return 'your-ip-address';
if (unwrapped === 'localhost') return '127.0.0.1';
if (unwrapped.includes(':')) return `[${unwrapped}]`;
return unwrapped;
},
get displayHost() {
const host = this.selectedAlias || this.stats.host || '127.0.0.1';
return this.formatDisplayHost(host);
},
get ttlPlaceholder() {
if (this.selectedModel?.pinned) return window.t('modal.model_settings.ttl_pinned');
const globalTtl = this.globalSettings.idle_timeout?.idle_timeout_seconds;
if (globalTtl) {
return window.t('modal.model_settings.ttl_global_fallback').replace('{seconds}', globalTtl);
}
return window.t('modal.model_settings.ttl_no_ttl');
},
async loadServerInfo() {
try {
const response = await fetch('/admin/api/server-info');
if (response.ok) {
const data = await response.json();
const aliases = Array.isArray(data.aliases) ? data.aliases : [];
this.serverAliases = aliases;
// Preserve user selection across reloads if still valid;
// otherwise default to the first alias when available.
if (this.selectedAlias && !aliases.includes(this.selectedAlias)) {
this.selectedAlias = '';
}
if (!this.selectedAlias && aliases.length > 0) {
this.selectedAlias = aliases[0];
}
} else if (response.status === 401) {
window.location.href = '/admin';
}
} catch (err) {
console.error('Failed to load server info:', err);
}
},
async restartServerStart() {
if (this.restartServer.status === 'restarting'
|| this.restartServer.status === 'waiting') {
return;
}
if (!window.confirm(window.t('settings.server.restart_confirm'))) {
return;
}
this.restartServer = {
status: 'restarting',
message: window.t('settings.server.restart_status_sending'),
};
let response;
try {
response = await fetch('/admin/api/server/restart', { method: 'POST' });
} catch (err) {
// Network errors mid-restart are expected if the server
// dies before sending the 202; fall through to polling.
this.restartServer = {
status: 'waiting',
message: window.t('settings.server.restart_status_waiting'),
};
this._restartServerPoll();
return;
}
if (response.status === 503) {
let msg = window.t('settings.server.restart_status_unavailable');
try {
const data = await response.json();
if (data && data.detail) msg = data.detail;
} catch (e) { /* ignore */ }
this.restartServer = { status: 'unsupported', message: msg };
return;
}
if (response.status === 401) {
window.location.href = '/admin';
return;
}
if (response.status !== 202) {
this.restartServer = {
status: 'error',
message: window.t('settings.server.restart_status_unexpected')
.replace('{status}', String(response.status)),
};
return;
}
this.restartServer = {
status: 'waiting',
message: window.t('settings.server.restart_status_waiting'),
};
this._restartServerPoll();
},
_restartServerPoll() {
const deadline = Date.now() + 60000; // 60s max wait
let sawDownAt = 0;
const tick = async () => {
if (Date.now() > deadline) {
this.restartServer = {
status: 'error',
message: window.t('settings.server.restart_status_timeout'),
};
return;
}
let alive = false;
try {
const r = await fetch('/health', { cache: 'no-store' });
alive = r.ok;
} catch (e) {
alive = false;
}
if (!alive) {
// First time we see it down — record it. We require a
// down-then-up transition before declaring success, so
// a fast supervisor that hasn't killed the old process
// yet doesn't trick us into "instant success".
if (!sawDownAt) sawDownAt = Date.now();
setTimeout(tick, 1000);
return;
}
// Alive again. If we never observed the down state, the
// restart hasn't actually fired yet — keep polling.
if (!sawDownAt) {
setTimeout(tick, 1000);
return;
}
this.restartServer = {
status: 'idle',
message: window.t('settings.server.restart_status_back'),
};
// Small delay so the user sees the success state, then
// reload to ensure all caches/sessions re-sync.
setTimeout(() => window.location.reload(), 500);
};
tick();
},
get llmModels() {
return this.models.filter(m => m.model_type === 'llm' || m.model_type === 'vlm' || !m.model_type);
},
shellQuote(value) {
const s = String(value ?? '');
if (!s) return "''";
return `'${s.replace(/'/g, `'"'"'`)}'`;
},
shellEnvAssign(name, value) {
return `${name}=${this.shellQuote(value)}`;
},
get claudeCodeCommand() {
const mode = this.globalSettings.claude_code.mode;
if (mode === 'cloud') {
return 'env -u ANTHROPIC_BASE_URL -u ANTHROPIC_AUTH_TOKEN -u ANTHROPIC_DEFAULT_OPUS_MODEL -u ANTHROPIC_DEFAULT_SONNET_MODEL -u ANTHROPIC_DEFAULT_HAIKU_MODEL -u API_TIMEOUT_MS -u CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC claude';
}
// Local mode
const port = this.stats.port || 8000;
const opusModel = this.globalSettings.claude_code.opus_model || 'select-a-model';
const sonnetModel = this.globalSettings.claude_code.sonnet_model || 'select-a-model';
const haikuModel = this.globalSettings.claude_code.haiku_model || 'select-a-model';
const parts = [];
parts.push(this.shellEnvAssign('ANTHROPIC_BASE_URL', `http://${this.displayHost}:${port}`));
if (this.stats.api_key) {
parts.push(this.shellEnvAssign('ANTHROPIC_AUTH_TOKEN', this.stats.api_key));
}
parts.push(this.shellEnvAssign('ANTHROPIC_DEFAULT_OPUS_MODEL', opusModel));
parts.push(this.shellEnvAssign('ANTHROPIC_DEFAULT_SONNET_MODEL', sonnetModel));
parts.push(this.shellEnvAssign('ANTHROPIC_DEFAULT_HAIKU_MODEL', haikuModel));
parts.push('API_TIMEOUT_MS=3000000');
parts.push('CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1');
parts.push('claude');
return parts.join(' ');
},
async saveClaudeCodeSettings() {
try {
const response = await fetch('/admin/api/global-settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
claude_code_context_scaling_enabled: this.globalSettings.claude_code.context_scaling_enabled,
claude_code_target_context_size: this.globalSettings.claude_code.target_context_size,
claude_code_mode: this.globalSettings.claude_code.mode,
claude_code_opus_model: this.globalSettings.claude_code.opus_model,
claude_code_sonnet_model: this.globalSettings.claude_code.sonnet_model,
claude_code_haiku_model: this.globalSettings.claude_code.haiku_model,
}),
});
if (!response.ok) {
console.error('Failed to save Claude Code settings');
}
} catch (err) {
console.error('Failed to save Claude Code settings:', err);
}
},
_launchCmd(tool) {
const raw = this.stats.cli_prefix || 'omlx';
const cli = raw === 'omlx' ? raw : this.shellQuote(raw);
return `${cli} launch ${tool}`;
},
get claudeCommand() {
return this._launchCmd('claude');
},
get codexCommand() {
return this._launchCmd('codex');
},
get codexAppCommand() {
return this._launchCmd('codex_app');
},
get copilotCommand() {
return this._launchCmd('copilot');
},
get opencodeCommand() {
return this._launchCmd('opencode');
},
get openclawCommand() {
const profile = this.globalSettings.integrations.openclaw_tools_profile || 'coding';
return `${this._launchCmd('openclaw')} --tools-profile ${profile}`;
},
get hermesCommand() {
return this._launchCmd('hermes');
},
get piCommand() {
return this._launchCmd('pi');
},
get markitdownOcrModels() {
return (this.models || []).filter((model) => {
const configType = String(model.config_model_type || '').toLowerCase();
return configType.includes('ocr');
});
},
async saveIntegrationSettings() {
try {
const response = await fetch('/admin/api/global-settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
integrations_copilot_model: this.globalSettings.integrations.copilot_model,
integrations_codex_model: this.globalSettings.integrations.codex_model,
integrations_opencode_model: this.globalSettings.integrations.opencode_model,
integrations_openclaw_model: this.globalSettings.integrations.openclaw_model,
integrations_hermes_model: this.globalSettings.integrations.hermes_model,
integrations_pi_model: this.globalSettings.integrations.pi_model,
integrations_openclaw_tools_profile: this.globalSettings.integrations.openclaw_tools_profile,
markitdown_enabled: this.globalSettings.integrations.markitdown_enabled,
markitdown_expose_model: this.globalSettings.integrations.markitdown_expose_model,
markitdown_max_file_size_mb: this.globalSettings.integrations.markitdown_max_file_size_mb,
markitdown_max_files_per_request: this.globalSettings.integrations.markitdown_max_files_per_request,
markitdown_pdf_processing_engine: this.globalSettings.integrations.markitdown_pdf_processing_engine,
}),
});
if (!response.ok) {
console.error('Failed to save integration settings');
}
} catch (err) {
console.error('Failed to save integration settings:', err);
}
},
async saveLanguage(lang) {
try {
const response = await fetch('/admin/api/global-settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ui_language: lang })
});
if (response.ok) {
location.reload();
} else {
console.error('Failed to save language');
}
} catch (e) {
console.error('Failed to save language:', e);
}
},
async loadStats(includeAlltime = true) {
try {
const params = new URLSearchParams();
if (this.selectedStatsModel) {
params.set('model', this.selectedStatsModel);
}
const url = '/admin/api/stats' + (params.toString() ? '?' + params : '');
const response = await fetch(url);
if (response.ok) {
const data = await response.json();
this.stats = { ...this.stats, ...data };
} else if (response.status === 401) {
window.location.href = '/admin';
}
if (!includeAlltime) {
return;
}
// Load all-time stats
const alltimeParams = new URLSearchParams({ scope: 'alltime' });
if (this.selectedStatsModel) {
alltimeParams.set('model', this.selectedStatsModel);
}
const alltimeUrl = '/admin/api/stats?' + alltimeParams;
const alltimeResponse = await fetch(alltimeUrl);
if (alltimeResponse.ok) {
const alltimeData = await alltimeResponse.json();
this.alltimeStats = { ...this.alltimeStats, ...alltimeData };
}
} catch (err) {
console.error('Failed to load stats:', err);
}
},
async clearStats() {
try {
await fetch('/admin/api/stats/clear', { method: 'POST' });
this.showClearStatsConfirm = false;
await this.loadStats();
} catch (err) {
console.error('Failed to clear stats:', err);
this.showClearStatsConfirm = false;
}
},
async clearAlltimeStats() {
try {
await fetch('/admin/api/stats/clear-alltime', { method: 'POST' });
this.showClearAlltimeConfirm = false;
await this.loadStats();
} catch (err) {
console.error('Failed to clear all-time stats:', err);
this.showClearAlltimeConfirm = false;
}
},
async clearSsdCache() {
try {
const resp = await fetch('/admin/api/ssd-cache/clear', { method: 'POST' });
if (!resp.ok) console.error('SSD cache clear failed:', resp.status);
this.showClearSsdCacheConfirm = false;
await this.loadStats();
} catch (err) {
console.error('Failed to clear SSD cache:', err);
this.showClearSsdCacheConfirm = false;
}
},
async clearHotCache() {
try {
const resp = await fetch('/admin/api/hot-cache/clear', { method: 'POST' });
if (!resp.ok) console.error('Hot cache clear failed:', resp.status);
this.showClearHotCacheConfirm = false;
await this.loadStats();
} catch (err) {
console.error('Failed to clear hot cache:', err);
this.showClearHotCacheConfirm = false;
}
},
startStatsRefresh() {
this.stopStatsRefresh();
this.loadStats();
this._statsRefreshTimer = setInterval(() => {
this.loadStats(false);
}, 500);
},
stopStatsRefresh() {
if (this._statsRefreshTimer) {
clearInterval(this._statsRefreshTimer);
this._statsRefreshTimer = null;
}
},
formatNumber(num) {
if (num >= 1000000000) return (num / 1000000000).toFixed(1) + 'B';
if (num >= 10000000) return (num / 1000000).toFixed(1) + 'M';
return num.toLocaleString();
},
cacheObsCumulative(stats, selectedModel) {
const entries = stats.runtime_cache?.models || [];
if (entries.length === 0) return {};
if (selectedModel) {
const entry = entries.find(m => m.id === selectedModel);
return entry?.cache_rates?.cumulative || {};
}
const sumKeys = ['prefix_hits', 'prefix_misses', 'evictions', 'ssd_hot_hits', 'ssd_disk_loads', 'ssd_saves', 'hot_cache_evictions', 'hot_cache_promotions'];
let agg = {};
for (const m of entries) {
const c = m.cache_rates?.cumulative;
if (!c || Object.keys(c).length === 0) continue;
for (const k of sumKeys) {
agg[k] = (agg[k] || 0) + (c[k] || 0);
}
}
const ph = agg.prefix_hits || 0;
const pm = agg.prefix_misses || 0;
const sh = agg.ssd_hot_hits || 0;
const sd = agg.ssd_disk_loads || 0;
agg.prefix_hit_rate = (ph + pm) > 0 ? ph / (ph + pm) : 0;
agg.ssd_hot_rate = (sh + sd) > 0 ? sh / (sh + sd) : 0;
return agg;
},
getStatFontClass(value) {
if (value >= 1000000000) return 'text-2xl';
if (value >= 1000000) return 'text-3xl';
return 'text-5xl';
},
formatSizeBytes(bytes) {
if (bytes >= 1024 * 1024 * 1024) return (bytes / (1024 * 1024 * 1024)).toFixed(1) + ' GB';
if (bytes >= 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(0) + ' MB';
return '0';
},
formatByteCount(bytes) {
if (bytes == null || !Number.isFinite(bytes)) return '';
if (bytes >= 1024 * 1024 * 1024) return (bytes / (1024 * 1024 * 1024)).toFixed(1) + ' GB';
if (bytes >= 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
return Math.max(0, Math.round(bytes)) + ' B';
},
formatTokenCount(n) {
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
return String(n);
},
formatDurationShort(seconds) {
if (seconds == null || !Number.isFinite(seconds)) return '—';
if (seconds < 1) return seconds.toFixed(1) + 's';
if (seconds < 60) return Math.round(seconds) + 's';
const minutes = Math.floor(seconds / 60);
const rem = Math.round(seconds % 60);
if (minutes < 60) return minutes + 'm ' + rem + 's';
const hours = Math.floor(minutes / 60);
return hours + 'h ' + (minutes % 60) + 'm';
},
formatActivityAge(seconds) {
if (seconds == null || !Number.isFinite(seconds)) return '';
return 'last token ' + this.formatDurationShort(seconds) + ' ago';
},
formatActivityMetadata(activity) {
const parts = [];
if (activity.input_count != null) parts.push(activity.input_count + ' inputs');
if (activity.document_count != null) parts.push(activity.document_count + ' docs');
if (activity.token_count != null) parts.push(this.formatTokenCount(activity.token_count) + ' tok');
if (activity.text_length != null) parts.push(activity.text_length + ' chars');
if (activity.chunk_count != null) parts.push(activity.chunk_count + ' chunks');
if (activity.output_bytes != null) parts.push(this.formatByteCount(activity.output_bytes));
if (activity.file_size_bytes != null && activity.file_size_bytes > 0) parts.push(this.formatByteCount(activity.file_size_bytes));
return parts.join(' · ');
},
activityDotClass(seconds) {
if (seconds == null || !Number.isFinite(seconds)) return 'bg-green-400 animate-pulse';
if (seconds < 15) return 'bg-green-400 animate-pulse';
if (seconds < 30) return 'bg-amber-400 animate-pulse';
return 'bg-red-400';
},
get runtimeHotCachePercent() {
const rc = this.stats.runtime_cache;
if (!rc || !rc.hot_cache_max_bytes) return 0;
return Math.min(100, (rc.hot_cache_size_bytes / rc.hot_cache_max_bytes) * 100);
},
get runtimeSsdCachePercent() {
const rc = this.stats.runtime_cache;
if (!rc || !rc.disk_max_bytes) return 0;
return Math.min(100, (rc.total_size_bytes / rc.disk_max_bytes) * 100);
},
get activeModelsPressurePercent() {
const mp = this.stats.active_models?.memory_pressure;
if (!mp || !mp.hard_bytes) return 0;
return Math.min(100, (mp.current_bytes / mp.hard_bytes) * 100);
},
get activeModelsSoftPercent() {
const mp = this.stats.active_models?.memory_pressure;
if (!mp || !mp.hard_bytes || !mp.soft_bytes) return 0;
return Math.min(100, (mp.soft_bytes / mp.hard_bytes) * 100);
},
get activeModelsPressureBarColor() {
const pct = this.activeModelsPressurePercent;
if (pct >= 90) return '#ef4444';
if (pct >= 80) return '#f97316';
if (pct >= 70) return '#f59e0b';
if (pct >= 60) return '#facc15';
return '#22c55e';
},
get activeModelsPressureBarStyle() {
return `width: ${this.activeModelsPressurePercent}%; height: 100%; display: block; background-color: ${this.activeModelsPressureBarColor};`;
},
get activeModelsSoftMarkerStyle() {
return `left: ${this.activeModelsSoftPercent}%; width: 1px; background-color: rgba(64, 64, 64, 0.6);`;
},
activeModelsPressureLabel() {
const mp = this.stats.active_models?.memory_pressure;
if (!mp || !mp.enabled || !mp.hard_bytes) {
return window.t('status.active_models.enforcer_disabled');
}
return `${this.formatSizeBytes(mp.current_bytes)} / ${this.formatSizeBytes(mp.soft_bytes)} soft / ${this.formatSizeBytes(mp.hard_bytes)} hard`;
},
modelSizeLabel(model) {
if (!model) return '-';
const estimated = model.estimated_size_formatted || '-';
if (model.is_loading) {
return estimated;
}
// actual_size is a rough phys_footprint delta captured at load
// time and can include neighboring KV growth — mark with ~obs
// so it doesn't read as exact.
const actual = model.actual_size_formatted;
if (!actual) {
return estimated;
}
if (!estimated || estimated === actual) {
return `~${actual} obs`;
}
return `~${actual} obs / ${estimated} est`;
},
copyToClipboard(text) {
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).catch(() => {
this._copyFallback(text);
});
} else {
this._copyFallback(text);
}
},
_copyFallback(text) {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand('copy');
} catch (err) {
console.error('Failed to copy:', err);
}
document.body.removeChild(textarea);
},
async logout() {
try {
await fetch('/admin/api/logout', { method: 'POST' });
} catch (err) {
console.error('Logout error:', err);
} finally {
window.location.href = '/admin';
}
},
// Shared external endpoint settings (both bench tabs)
saveExternalEndpoint() {
localStorage.setItem('omlx_bench_external_base_url', this.externalBaseUrl.trim());
localStorage.setItem('omlx_bench_external_api_key', this.externalApiKey);
localStorage.setItem('omlx_bench_external_model', this.externalModel.trim());
},
externalConfigValid() {
return !!(this.externalBaseUrl.trim() && this.externalModel.trim());
},
externalRequestBody() {
return {
base_url: this.externalBaseUrl.trim(),
api_key: this.externalApiKey,
model: this.externalModel.trim(),
};
},
// Benchmark functions
async startBenchmark() {
if (this.benchExternalEnabled) {
if (!this.externalConfigValid()) {
this.benchError = window.t('js.error.external_endpoint_required');
return;
}
} else if (!this.benchModelId) {
return;
}
// Collect selected prompt lengths
const promptLengths = Object.entries(this.benchPromptLengths)
.filter(([_, v]) => v)
.map(([k, _]) => parseInt(k));
if (promptLengths.length === 0) {
this.benchError = window.t('js.error.select_prompt_length');
return;
}
// Collect selected batch sizes
const batchSizes = Object.entries(this.benchBatchSizes)
.filter(([_, v]) => v)
.map(([k, _]) => parseInt(k));
// Load device info if not loaded yet
if (!this.benchDeviceInfo) {
this.loadBenchDeviceInfo();
}
// Reset state
this.benchRunning = true;
this.benchProgress = null;
this.benchSingleResults = [];
this.benchBatchResults = [];
this.benchError = '';
this.benchBenchId = null;
this.benchUploadResults = [];
this.benchUploadDone = null;
this.benchUploading = false;
this.benchUploadSkipped = null;
this.benchRunExternal = this.benchExternalEnabled
? { base_url: this.externalBaseUrl.trim(), model: this.externalModel.trim() }
: null;
try {
const response = await fetch('/admin/api/bench/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model_id: this.benchExternalEnabled ? this.externalModel.trim() : this.benchModelId,
prompt_lengths: promptLengths,
generation_length: 128,
batch_sizes: batchSizes,
force_lm_engine: this.benchExternalEnabled ? false : this.benchForceLmEngine,
external: this.benchExternalEnabled ? this.externalRequestBody() : null,
}),
});
if (response.status === 401) {
window.location.href = '/admin';
return;
}
if (!response.ok) {
const data = await response.json();
this.benchError = data.detail || window.t('js.error.start_benchmark_failed');
this.benchRunning = false;
return;
}
const data = await response.json();
this.benchBenchId = data.bench_id;
this.connectBenchSSE(data.bench_id);
} catch (err) {
console.error('Failed to start benchmark:', err);
this.benchError = window.t('js.error.start_benchmark_error').replace('{message}', err.message);
this.benchRunning = false;
}
},
connectBenchSSE(benchId) {
if (this.benchEventSource) {
this.benchEventSource.close();
}
const es = new EventSource(`/admin/api/bench/${benchId}/stream`);
this.benchEventSource = es;
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'progress') {
this.benchProgress = {
phase: data.phase,
message: data.message,
current: data.current,
total: data.total,
};
} else if (data.type === 'result') {
// SSE replay-on-subscribe re-delivers every event on
// every reconnect (incl. page refresh), so append-only
// arrays must dedupe. Single rows are keyed by
// (pp, tg); batch rows by batch_size.
if (data.data.test_type === 'single') {
const exists = this.benchSingleResults.some(
r => r.pp === data.data.pp && r.tg === data.data.tg
);
if (!exists) {
this.benchSingleResults = [...this.benchSingleResults, data.data];
}
} else if (data.data.test_type === 'batch') {
const exists = this.benchBatchResults.some(
r => r.batch_size === data.data.batch_size
);
if (!exists) {
this.benchBatchResults = [...this.benchBatchResults, data.data];
}
}
} else if (data.type === 'done') {
// Benchmark tests done, uploading starts
this.benchUploading = true;
this.benchProgress = {
phase: 'upload',
message: 'Uploading to community benchmarks...',
current: 0,
total: 0,
};
this.loadModels();
} else if (data.type === 'upload') {
// Dedupe on replay: upload entries are unique by context_length.
const exists = this.benchUploadResults.some(
r => r.context_length === data.data.context_length
);
if (!exists) {
this.benchUploadResults = [...this.benchUploadResults, data.data];
}
} else if (data.type === 'upload_done') {
this.benchUploadDone = data.data;
this.benchUploading = false;
this.benchRunning = false;
this.benchProgress = null;
es.close();
this.benchEventSource = null;
} else if (data.type === 'upload_skipped') {
this.benchUploadSkipped = {
reason: data.reason || 'experimental_features',
features: data.features || [],
};
this.benchUploading = false;
this.benchRunning = false;
this.benchProgress = null;
es.close();
this.benchEventSource = null;
this.loadModels();
} else if (data.type === 'error') {
this.benchError = data.message;
this.benchRunning = false;
this.benchProgress = null;
es.close();
this.benchEventSource = null;
this.loadModels();
}
} catch (err) {
console.error('Failed to parse SSE event:', err);
}
};
es.onerror = () => {
if (this.benchRunning) {
this.benchError = window.t('js.error.benchmark_connection_lost');
this.benchRunning = false;
this.benchProgress = null;
}
es.close();
this.benchEventSource = null;
};
},
async cancelBenchmark() {
if (!this.benchBenchId) return;
try {
await fetch(`/admin/api/bench/${this.benchBenchId}/cancel`, { method: 'POST' });
} catch (err) {
console.error('Failed to cancel benchmark:', err);
}
// SSE handler will update state when error/done event arrives
},
benchGetSpeedup(batchResult) {
const baseline = this.benchSingleResults.find(r => r.pp === 1024);
if (!baseline || !baseline.gen_tps || baseline.gen_tps <= 0) return 0;
return batchResult.tg_tps / baseline.gen_tps;
},
benchFormatMemory(bytes) {
if (!bytes || bytes === 0) return '-';
const gb = bytes / (1024 * 1024 * 1024);
if (gb >= 1) return gb.toFixed(2) + ' GB';
const mb = bytes / (1024 * 1024);
return mb.toFixed(0) + ' MB';
},
benchBuildText() {
const pad = (s, w) => s.toString().padStart(w);
const rpad = (s, w) => s.toString().padEnd(w);
let lines = [];
lines.push('oMLX - LLM inference, optimized for your Mac');
lines.push('https://github.com/jundot/omlx');
if (this.benchRunExternal) {
lines.push(`Benchmark Model: ${this.benchRunExternal.model} @ ${this.benchRunExternal.base_url}`);
lines.push('Engine: External OpenAI-compatible endpoint');
} else {
lines.push(`Benchmark Model: ${this.benchModelId}`);
lines.push(`Engine: ${this.benchForceLmEngine ? 'Force mlx-lm' : 'Auto'}`);
}
lines.push('='.repeat(80));
// Single Request Results
if (this.benchSingleResults.length > 0) {
lines.push('');
lines.push('Single Request Results');
lines.push('-'.repeat(80));
const hdr = [rpad('Test', 16), pad('TTFT(ms)', 10), pad('TPOT(ms)', 10), pad('pp TPS', 12), pad('tg TPS', 12), pad('E2E(s)', 10), pad('Throughput', 12), pad('Peak Mem', 10)];
lines.push(hdr.join(' '));
for (const r of this.benchSingleResults) {
const row = [
rpad(`pp${r.pp}/tg${r.tg}`, 16),
pad(r.ttft_ms.toFixed(1), 10),
pad(r.tpot_ms.toFixed(2), 10),
pad(r.processing_tps.toFixed(1) + ' tok/s', 12),
pad(r.gen_tps.toFixed(1) + ' tok/s', 12),
pad(r.e2e_latency_s.toFixed(3), 10),
pad(r.total_throughput.toFixed(1) + ' tok/s', 12),
pad(this.benchFormatMemory(r.peak_memory_bytes), 10),
];
lines.push(row.join(' '));
}
}
// Helper for batch table text
const buildBatchText = (title, subtitle, results) => {
if (results.length === 0) return;
const baseline = this.benchSingleResults.find(r => r.pp === 1024);
lines.push('');
lines.push(`${title}`);
lines.push(subtitle);
lines.push('-'.repeat(80));
const hdr = [rpad('Batch', 8), pad('tg TPS', 12), pad('Speedup', 8), pad('pp TPS', 12), pad('pp TPS/req', 12), pad('TTFT(ms)', 10), pad('E2E(s)', 10)];
lines.push(hdr.join(' '));
if (baseline) {
const row = [
rpad('1x', 8),
pad(baseline.gen_tps.toFixed(1) + ' tok/s', 12),
pad('1.00x', 8),
pad(baseline.processing_tps.toFixed(1) + ' tok/s', 12),
pad(baseline.processing_tps.toFixed(1) + ' tok/s', 12),
pad(baseline.ttft_ms.toFixed(1), 10),
pad(baseline.e2e_latency_s.toFixed(3), 10),
];
lines.push(row.join(' '));
}
for (const r of results) {
const speedup = baseline && baseline.gen_tps > 0 ? (r.tg_tps / baseline.gen_tps).toFixed(2) + 'x' : '-';
const row = [
rpad(r.batch_size + 'x', 8),
pad(r.tg_tps.toFixed(1) + ' tok/s', 12),
pad(speedup, 8),
pad(r.pp_tps.toFixed(1) + ' tok/s', 12),
pad((r.pp_tps / r.batch_size).toFixed(1) + ' tok/s', 12),
pad(r.avg_ttft_ms.toFixed(1), 10),
pad(r.e2e_latency_s.toFixed(3), 10),
];
lines.push(row.join(' '));
}
};
buildBatchText(
'Continuous Batching',
'pp1024 / tg128',
this.benchBatchResults
);
return lines.join('\n');
},
benchCopyText() {
const text = this.benchBuildText();
const onSuccess = () => {
this.benchCopied = true;
setTimeout(() => { this.benchCopied = false; }, 2000);
};
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(onSuccess).catch(() => {
this._copyFallback(text);
onSuccess();
});
} else {
this._copyFallback(text);
onSuccess();
}
},
async loadBenchDeviceInfo() {
try {
const resp = await fetch('/admin/api/device-info');
if (resp.ok) {
this.benchDeviceInfo = await resp.json();
}
} catch (err) {
console.error('Failed to load device info:', err);
}
},
async loadBenchState() {
// Discover an in-progress throughput run on tab/page load so
// a second tab (or a refresh) can attach to its SSE stream
// and replay the run's full event history.
//
// Three cases:
// 1. No active run → clear any stale banner state.
// 2. Active run, this tab is already attached → no-op.
// 3. Active run, this tab is fresh → auto-attach.
// 4. Active run, this tab is displaying a *different*
// completed bench → show banner; let the user decide
// whether to clobber their result view.
try {
const resp = await fetch('/admin/api/bench/active');
if (!resp.ok) return;
const data = await resp.json();
if (!data.running || !data.bench_id) {
this.benchOtherActive = null;
return;
}
// Already attached to this bench — nothing to do.
if (this.benchBenchId === data.bench_id && this.benchEventSource) {
return;
}
// We have completed results from a DIFFERENT bench on
// screen — don't silently swap them out. Show a banner
// so the user can explicitly accept the new bench.
const hasStaleResults = !this.benchRunning
&& this.benchBenchId
&& this.benchBenchId !== data.bench_id
&& (this.benchSingleResults.length > 0
|| this.benchBatchResults.length > 0);
if (hasStaleResults) {
this.benchOtherActive = {
bench_id: data.bench_id,
model_id: data.model_id,
force_lm_engine: !!data.force_lm_engine,
external: !!data.external,
};
return;
}
// Fresh slate: attach.
this.benchBenchId = data.bench_id;
this._restoreBenchRunSource(data);
this.benchRunning = true;
this.benchOtherActive = null;
this.connectBenchSSE(data.bench_id);
} catch (err) {
console.error('Failed to load bench state:', err);
}
},
// Restore the config UI from an active run discovered via
// /api/bench/active. External model ids aren't in the local
// dropdown, so the external flag drives which controls light up.
_restoreBenchRunSource(data) {
if (data.external) {
this.benchExternalEnabled = true;
this.benchRunExternal = {
// base_url is intentionally not exposed by the API;
// fall back to this browser's stored setting.
base_url: this.externalBaseUrl.trim(),
model: data.model_id,
};
} else {
this.benchModelId = data.model_id;
this.benchForceLmEngine = !!data.force_lm_engine;
this.benchExternalEnabled = false;
this.benchRunExternal = null;
}
},
// User clicked "View live" on the banner — clear the stale
// result display, attach to the active run. The replay-on-
// subscribe stream re-delivers every event so the new bench
// populates its table from the start.
acceptOtherBench() {
if (!this.benchOtherActive) return;
const other = this.benchOtherActive;
this.benchOtherActive = null;
this.benchBenchId = other.bench_id;
this._restoreBenchRunSource(other);
this.benchRunning = true;
this.benchSingleResults = [];
this.benchBatchResults = [];
this.benchUploadResults = [];
this.benchUploadDone = null;
this.benchUploadSkipped = null;
this.benchProgress = null;
this.benchError = '';
this.connectBenchSSE(other.bench_id);
},
dismissOtherBench() {
// Hide for now; the banner reappears next loadBenchState if
// the run is still active. Use case: user wants to keep
// reviewing their previous result for a moment.
this.benchOtherActive = null;
},
// Bench sub-tab
setBenchTab(tab) {
if (!DASHBOARD_BENCH_TABS.has(tab)) return;
this.benchTab = tab;
this.mainTab = 'bench';
this.syncTabStateToUrl();
if (tab === 'throughput') {
this.loadBenchDeviceInfo();
this.loadBenchState();
}
},
// Accuracy benchmark functions
async loadAccState() {
// Load accumulated results + queue status from server (page load / tab switch)
try {
const resp = await fetch('/admin/api/bench/accuracy/results');
if (resp.ok) {
const data = await resp.json();
this.accAllResults = (data.results || []).map(r => ({ ...r, _showCategories: false }));
this.accRunning = data.running || false;
this.accCurrentModel = data.current_model || '';
if (data.current_bench_id && data.running) {
this.accCurrentBenchId = data.current_bench_id;
this.connectAccSSE(data.current_bench_id);
}
}
} catch (err) {
console.error('Failed to load accuracy state:', err);
}
await this.loadAccQueueStatus();
},
async loadAccQueueStatus() {
try {
const resp = await fetch('/admin/api/bench/accuracy/queue/status');
if (resp.ok) {
const data = await resp.json();
this.accQueue = data.queue || [];
this.accRunning = data.running || false;
this.accCurrentModel = data.current_model || '';
if (data.current_bench_id) {
this.accCurrentBenchId = data.current_bench_id;
}
// Restore last progress for reconnect
if (data.last_progress && data.running) {
this.accProgress = data.last_progress;
}
}
} catch (err) {
console.error('Failed to load queue status:', err);
}
},
async addToAccQueue() {
if (this.accExternalEnabled) {
if (!this.externalConfigValid()) {
this.accError = window.t('js.error.external_endpoint_required');
return;
}
} else if (!this.accModelId) {
return;
}
const selected = Object.entries(this.accBenchmarks)
.filter(([_, v]) => v)
.map(([k]) => k);
if (selected.length === 0) return;
this.accError = '';
try {
const resp = await fetch('/admin/api/bench/accuracy/queue/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model_id: this.accExternalEnabled ? this.externalModel.trim() : this.accModelId,
benchmarks: Object.fromEntries(
selected.map(k => [k, this.accSampleSizes[k]])
),
batch_size: this.accBatchSize,
enable_thinking: this.accExternalEnabled ? false : this.accEnableThinking,
sampling_profile: this.accSamplingProfile,
external: this.accExternalEnabled ? this.externalRequestBody() : null,
}),
});
if (!resp.ok) {
const err = await resp.json();
throw new Error(err.detail || 'Failed to add to queue');
}
const data = await resp.json();
this.accQueue = data.queue || [];
this.accRunning = data.running || false;
this.accCurrentModel = data.current_model || '';
if (data.last_progress) this.accProgress = data.last_progress;
// Connect SSE to current run
if (data.current_bench_id) {
this.accCurrentBenchId = data.current_bench_id;
this.connectAccSSE(data.current_bench_id);
}
} catch (err) {
this.accError = err.message;
}
},
async removeFromAccQueue(idx) {
try {
await fetch(`/admin/api/bench/accuracy/queue/${idx}`, { method: 'DELETE' });
await this.loadAccQueueStatus();
} catch (err) {
console.error('Failed to remove from queue:', err);
}
},
connectAccSSE(benchId) {
if (this.accEventSource) {
this.accEventSource.close();
}
this._stopAccPolling();
const es = new EventSource(`/admin/api/bench/accuracy/${benchId}/stream`);
this.accEventSource = es;
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
switch (data.type) {
case 'progress':
this.accProgress = data;
this.accCurrentModel = data.model_id || this.accCurrentModel;
break;
case 'result':
// Dedupe on replay: accuracy results are unique by
// (model_id, benchmark).
{
const exists = this.accAllResults.some(
r => r.model_id === data.data.model_id
&& r.benchmark === data.data.benchmark
);
if (!exists) {
data.data._showCategories = false;
this.accAllResults.push(data.data);
}
}
break;
case 'done':
this.accProgress = null;
es.close();
this.accEventSource = null;
// Check for next in queue
this._pollForNextRun();
break;
case 'error':
this.accError = data.message;
this.accProgress = null;
es.close();
this.accEventSource = null;
this.loadAccQueueStatus();
break;
}
} catch (err) {
console.error('SSE parse error:', err);
}
};
es.onerror = () => {
es.close();
this.accEventSource = null;
// SSE disconnected — fall back to polling
this._startAccPolling();
};
},
_startAccPolling() {
this._stopAccPolling();
this._accPollTimer = setInterval(async () => {
await this.loadAccQueueStatus();
// Load latest results too
try {
const resp = await fetch('/admin/api/bench/accuracy/results');
if (resp.ok) {
const data = await resp.json();
this.accAllResults = (data.results || []).map(r => ({ ...r, _showCategories: false }));
}
} catch (e) {}
// Try to reconnect SSE if running
if (this.accRunning && this.accCurrentBenchId && !this.accEventSource) {
this._stopAccPolling();
this.connectAccSSE(this.accCurrentBenchId);
}
if (!this.accRunning) {
this._stopAccPolling();
}
}, 3000);
},
_stopAccPolling() {
if (this._accPollTimer) {
clearInterval(this._accPollTimer);
this._accPollTimer = null;
}
},
_pollForNextRun() {
// After a run completes, poll briefly for the next run to start
let attempts = 0;
const poll = setInterval(async () => {
attempts++;
await this.loadAccQueueStatus();
if (this.accCurrentBenchId && this.accRunning) {
clearInterval(poll);
this.connectAccSSE(this.accCurrentBenchId);
} else if (!this.accRunning || attempts > 10) {
clearInterval(poll);
}
}, 1000);
},
async cancelAccuracyBenchmark() {
try {
await fetch('/admin/api/bench/accuracy/cancel', { method: 'POST' });
} catch (err) {
console.error('Cancel error:', err);
}
this.accRunning = false;
this.accProgress = null;
this.accQueue = [];
this.accCurrentModel = '';
if (this.accEventSource) {
this.accEventSource.close();
this.accEventSource = null;
}
},
async resetAccResults() {
try {
await fetch('/admin/api/bench/accuracy/results/reset', { method: 'POST' });
this.accAllResults = [];
} catch (err) {
console.error('Reset error:', err);
}
},
accBuildText() {
if (this.accAllResults.length === 0) return '';
const pad = (s, w) => s.toString().padStart(w);
const rpad = (s, w) => s.toString().padEnd(w);
// Group by model
const models = [...new Set(this.accAllResults.map(r => r.model_id))];
const benchmarks = [...new Set(this.accAllResults.map(r => r.benchmark))];
// Build lookup: model -> benchmark -> accuracy
const lookup = {};
for (const r of this.accAllResults) {
if (!lookup[r.model_id]) lookup[r.model_id] = {};
lookup[r.model_id][r.benchmark] = r;
}
// Full sizes lookup
const fullSizes = {};
for (const grp of this.accBenchmarkGroups) {
for (const bl of grp.benchmarks) fullSizes[bl.key] = bl.fullSize;
}
// Determine column widths
const modelWidth = Math.max(12, ...models.map(m => m.length + 2));
const modeW = 8;
const sampledW = 14;
const benchWidth = Math.max(14, ...benchmarks.map(b => b.length + 2));
let lines = [];
lines.push('Intelligence Benchmark Comparison');
lines.push('');
// Header row
let header = rpad('', benchWidth) + rpad('Mode', modeW) + rpad('Sampled', sampledW);
for (const m of models) header += pad(m, modelWidth);
lines.push(header);
lines.push('-'.repeat(benchWidth + modeW + sampledW + models.length * modelWidth));
// Data rows
for (const b of benchmarks) {
// Get sample info from first available result for this benchmark
const sample = models.map(m => lookup[m]?.[b]).find(r => r);
const total = sample?.total || 0;
const full = fullSizes[b] || 0;
const isFull = total >= full;
const mode = isFull ? 'Full' : 'Sample';
const sampledStr = isFull ? String(full) : (total + '/' + full);
let row = rpad(b.toUpperCase(), benchWidth) + rpad(mode, modeW) + rpad(sampledStr, sampledW);
for (const m of models) {
const r = lookup[m]?.[b];
row += pad(r ? (r.accuracy * 100).toFixed(1) + '%' : '-', modelWidth);
}
lines.push(row);
}
// Detail section per model
lines.push('');
lines.push('--- Detail ---');
for (const m of models) {
lines.push('');
lines.push('Model: ' + m);
lines.push(rpad('Benchmark', 16) + pad('Accuracy', 10) + pad('Correct', 10) + pad('Total', 8) + pad('Time(s)', 10) + pad('Think', 8));
lines.push('-'.repeat(62));
for (const r of this.accAllResults.filter(r => r.model_id === m)) {
lines.push(
rpad(r.benchmark.toUpperCase(), 16) +
pad((r.accuracy * 100).toFixed(1) + '%', 10) +
pad(r.correct, 10) +
pad(r.total, 8) +
pad(r.time_s, 10) +
pad(r.thinking_used ? 'Yes' : 'No', 8)
);
}
}
return lines.join('\n');
},
accCopyText() {
const text = this.accBuildText();
const onSuccess = () => {
this.accCopied = true;
setTimeout(() => { this.accCopied = false; }, 2000);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(onSuccess).catch(() => {
const ta = document.getElementById('accTextarea');
if (ta) { ta.select(); document.execCommand('copy'); onSuccess(); }
});
} else {
const ta = document.getElementById('accTextarea');
if (ta) { ta.select(); document.execCommand('copy'); onSuccess(); }
}
},
accDownloadResult(r, format) {
const filename = `${r.model_id}_${r.benchmark}.${format}`;
let content, mime;
const qr = r.question_results || [];
if (format === 'json') {
content = JSON.stringify({
model_id: r.model_id,
benchmark: r.benchmark,
accuracy: r.accuracy,
correct: r.correct,
total: r.total,
time_s: r.time_s,
thinking_used: r.thinking_used || false,
category_scores: r.category_scores || null,
questions: qr,
}, null, 2);
mime = 'application/json';
} else if (format === 'csv') {
const esc = s => '"' + (s || '').replace(/"/g, '""') + '"';
const lines = ['id,category,correct,expected,predicted,question,raw_response,time_s'];
for (const q of qr) {
lines.push([q.id, esc(q.category || ''), q.correct, esc(q.expected), esc(q.predicted), esc(q.question), esc(q.raw_response), q.time_s].join(','));
}
content = lines.join('\n');
mime = 'text/csv';
} else {
const lines = [
`Model: ${r.model_id}`,
`Benchmark: ${r.benchmark.toUpperCase()}`,
`Accuracy: ${(r.accuracy * 100).toFixed(1)}% (${r.correct}/${r.total})`,
`Time: ${r.time_s}s`,
'',
];
for (const q of qr) {
lines.push(`--- Q${q.id} [${q.correct ? 'CORRECT' : 'WRONG'}] ---`);
if (q.category) lines.push(`Category: ${q.category}`);
lines.push(`Question: ${q.question || ''}`);
lines.push(`Expected: ${q.expected}`);
lines.push(`Predicted: ${q.predicted}`);
lines.push(`Raw response: ${q.raw_response || '(empty)'}`);
lines.push(`Time: ${q.time_s}s`);
lines.push('');
}
content = lines.join('\n');
mime = 'text/plain';
}
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
},
// Log viewer functions
filteredLogContent() {
const LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'];
const minIdx = LEVELS.indexOf(this.logMinLevel);
if (minIdx <= 0) return this.logContent;
const levelRe = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3} - \S+ - (TRACE|DEBUG|INFO|WARNING|ERROR|CRITICAL) - /;
let visible = true;
return this.logContent.split('\n').filter(line => {
const m = line.match(levelRe);
if (m) visible = LEVELS.indexOf(m[1]) >= minIdx;
return visible;
}).join('\n');
},
levelButtonClass(lvl) {
const LEVELS = ['TRACE', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'];
const idx = LEVELS.indexOf(lvl);
const minIdx = LEVELS.indexOf(this.logMinLevel);
if (idx < minIdx) return 'bg-neutral-100 text-neutral-300';
if (idx === minIdx) return 'bg-neutral-900 text-white';
return 'bg-neutral-200 text-neutral-700';
},
async loadLogs() {
this.logLoading = true;
this.logError = '';
try {
const params = new URLSearchParams({
lines: this.logLines.toString(),
});
if (this.logFile && this.logFile !== 'server.log') {
params.append('file', this.logFile);
}
const response = await fetch(`/admin/api/logs?${params}`);
if (response.ok) {
const data = await response.json();
this.logContent = data.logs;
this.logTotalLines = data.total_lines;
this.logAvailableFiles = data.available_files || ['server.log'];
this.logLastUpdated = new Date().toLocaleTimeString();
// Auto-scroll to bottom
if (this.logAutoScroll) {
this.$nextTick(() => {
const textarea = this.$refs.logTextarea;
if (textarea) {
textarea.scrollTop = textarea.scrollHeight;
}
});
}
} else if (response.status === 401) {
window.location.href = '/admin';
} else {
const data = await response.json();
this.logError = data.detail || window.t('js.error.load_logs_failed');
}
} catch (err) {
console.error('Failed to load logs:', err);
this.logError = window.t('js.error.load_logs_failed');
} finally {
this.logLoading = false;
}
},
startLogRefresh() {
this.stopLogRefresh(); // Clear existing timer
if (this.logRefreshInterval > 0) {
this.logAutoRefresh = true;
this._logRefreshTimer = setInterval(() => {
this.loadLogs();
}, this.logRefreshInterval * 1000);
}
},
stopLogRefresh() {
if (this._logRefreshTimer) {
clearInterval(this._logRefreshTimer);
this._logRefreshTimer = null;
}
this.logAutoRefresh = false;
},
restartLogRefresh() {
if (this.mainTab === 'logs') {
this.startLogRefresh();
}
},
// Parse cache size string (e.g., "10GB") to percent of SSD total capacity
parseCacheToPercent(cacheStr, totalBytes) {
if (!cacheStr || cacheStr === 'auto' || !totalBytes || totalBytes === 0) {
return 10; // Default 10%
}
const match = cacheStr.match(/^(\d+(?:\.\d+)?)\s*(GB|MB|TB)?$/i);
if (!match) return 10;
let bytes = parseFloat(match[1]);
const unit = (match[2] || 'GB').toUpperCase();
if (unit === 'TB') bytes *= 1024 * 1024 * 1024 * 1024;
else if (unit === 'GB') bytes *= 1024 * 1024 * 1024;
else if (unit === 'MB') bytes *= 1024 * 1024;
const percent = Math.round((bytes / totalBytes) * 100);
return Math.min(100, percent);
},
// Convert percent to cache size string
percentToCacheString(percent, totalBytes) {
if (!totalBytes || totalBytes === 0) return 'auto';
const bytes = Math.floor((percent / 100) * totalBytes);
const gb = Math.floor(bytes / (1024 * 1024 * 1024));
return `${gb}GB`;
},
// Helper: parse GB from a settings string like "68GB", "1TB", "512MB"
_parseSettingsGB(val) {
if (!val) return null;
const match = val.match(/^(\d+(?:\.\d+)?)\s*(GB|MB|TB)?$/i);
if (!match) return null;
let num = parseFloat(match[1]);
const unit = (match[2] || 'GB').toUpperCase();
if (unit === 'TB') return Math.round(num * 1024);
if (unit === 'MB') return Math.round(num / 1024);
return Math.round(num);
},
// Memory guard tier → live hard ceiling (GB) for the selected tier.
// Mirrors ProcessMemoryEnforcer._get_hard_limit_bytes:
// static_ceiling = total - tier.static_reserve
// dynamic_ceiling = omlx_phys + free + inactive + active * ratio
// final = min(static, dynamic, metal_cap)
// The static / dynamic inputs come from the global-settings
// response and reflect the moment that response was fetched.
// Warning shown below the breakdown when the kernel
// iogpu.wired_limit_mb is lower than what oMLX asked Metal
// to allow at start. Returns an HTML string with the exact
// sysctl command the user can paste into Terminal, or "" when
// the kernel cap is fine.
// True when the kernel iogpu.wired_limit_mb (or Apple default
// working set) caps oMLX below its desired static ceiling.
get memoryGuardShowWiredLimitWarning() {
const sys = this.globalSettings.system || {};
const kernelBytes = sys.iogpu_wired_limit_bytes || 0;
const requestedBytes = sys.omlx_wired_limit_request_bytes || 0;
if (kernelBytes <= 0 || requestedBytes <= 0) return false;
return kernelBytes < requestedBytes;
},
// Red bold warning text (no copy button). The button is a
// separate sibling in the template so Alpine can wire @click.
get memoryGuardWiredLimitWarningHTML() {
if (!this.memoryGuardShowWiredLimitWarning) return '';
const sys = this.globalSettings.system;
const kernelGB = (sys.iogpu_wired_limit_bytes / (1024 ** 3)).toFixed(1);
const template = window.t('settings.resource.guard_tier.wired_limit_warning');
return template.replaceAll(
'{kernel}',
`<strong>${kernelGB} GB</strong>`,
);
},
// The sysctl command rendered in the dark bold <code> chip.
get memoryGuardWiredLimitCommand() {
if (!this.memoryGuardShowWiredLimitWarning) return '';
const requested = this.globalSettings.system.omlx_wired_limit_request_bytes;
const requestedMB = Math.ceil(requested / (1024 ** 2));
return `sudo sysctl iogpu.wired_limit_mb=${requestedMB}`;
},
// 2-second "Copied!" affordance after the clipboard button is
// pressed. Reset by the same setTimeout so it's harmless if
// the user clicks rapidly.
wiredLimitCopied: false,
copyWiredLimitCommand() {
const text = this.memoryGuardWiredLimitCommand;
if (!text) return;
const onSuccess = () => {
this.wiredLimitCopied = true;
setTimeout(() => { this.wiredLimitCopied = false; }, 2000);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(onSuccess).catch(() => {
onSuccess();
});
} else {
onSuccess();
}
},
// Description text shown next to the Memory guard tier dropdown.
// safe / balanced / aggressive get a "free + inactive + N% of
// active (via macOS reclaim_method)" sentence. custom shows the
// user-supplied ceiling.
get memoryGuardTierDescription() {
const tier = this.globalSettings.memory?.memory_guard_tier || 'balanced';
const tierLabel = window.t('settings.resource.guard_tier.' + tier);
if (tier === 'custom') {
const gb = Number(
this.globalSettings.memory?.memory_guard_custom_ceiling_gb || 0
).toFixed(1);
return window
.t('settings.resource.guard_tier.description_custom')
.replace('{custom_gb}', gb);
}
const pct = { safe: 20, balanced: 50, aggressive: 80 }[tier] ?? 50;
const method = window.t(
'settings.resource.guard_tier.reclaim_method.' + tier
);
return window
.t('settings.resource.guard_tier.description_template')
.replace('{tier}', tierLabel)
.replace('{active_pct}', pct)
.replace('{reclaim_method}', method);
},
// Breakdown line. For ratio tiers: `Free X, inactive Y, active Z
// × N% = R → ceiling C`. For custom: `Custom ceiling X GB →
// effective ceiling C` (after clamp by static / metal cap).
get memoryGuardBreakdownHTML() {
const sys = this.globalSettings.system || {};
const GB = 1024 ** 3;
const tier = this.globalSettings.memory?.memory_guard_tier || 'balanced';
const fmt = (gb) => Number(gb).toFixed(1);
const bold = (gb) => `<strong>${fmt(gb)} GB</strong>`;
// Static / metal cap for the final clamp shown to the user.
const totalGB = (sys.total_memory_bytes || 0) / GB;
const staticReserveGB =
tier === 'custom'
? 2
: totalGB < 16
? 4
: { safe: 8, balanced: 6, aggressive: 4 }[tier] ?? 6;
const staticCeiling = Math.max(0, totalGB - staticReserveGB);
const metalCapGB = (sys.iogpu_wired_limit_bytes || 0) / GB;
// Helper: is the kernel iogpu.wired_limit_mb the smallest
// of the three candidates? When yes we swap "→ ceiling" for
// "/ effective ceiling X (kernel limit)" so the user knows
// why the value isn't what their tier math suggested.
const kernelBinds = (candidates, finalCeiling) =>
metalCapGB > 0 &&
Math.abs(metalCapGB - finalCeiling) < 1e-6 &&
candidates.every((c) => c >= metalCapGB - 1e-6);
if (tier === 'custom') {
const custom = Number(
this.globalSettings.memory?.memory_guard_custom_ceiling_gb || 0
);
const candidates = [custom, staticCeiling];
if (metalCapGB > 0) candidates.push(metalCapGB);
const ceiling = Math.max(0, Math.min(...candidates));
const tmpl = kernelBinds([custom, staticCeiling], ceiling)
? 'settings.resource.guard_tier.breakdown_custom_kernel_limit'
: 'settings.resource.guard_tier.breakdown_custom';
return window
.t(tmpl)
.replace('{custom_gb}', bold(custom))
.replace('{ceiling}', bold(ceiling));
}
const freeGB = (sys.free_memory_bytes || 0) / GB;
const inactiveGB = (sys.inactive_memory_bytes || 0) / GB;
const activeGB = (sys.active_memory_bytes || 0) / GB;
const ratio = { safe: 0.2, balanced: 0.5, aggressive: 0.8 }[tier] ?? 0.5;
const pct = Math.round(ratio * 100);
const reclaim = activeGB * ratio;
const omlxGB = (sys.omlx_phys_footprint_bytes || 0) / GB;
const dynamicCeiling = omlxGB + freeGB + inactiveGB + reclaim;
const candidates = [dynamicCeiling, staticCeiling];
if (metalCapGB > 0) candidates.push(metalCapGB);
const ceiling = Math.max(0, Math.min(...candidates));
const tmpl = kernelBinds([dynamicCeiling, staticCeiling], ceiling)
? 'settings.resource.guard_tier.breakdown_kernel_limit'
: 'settings.resource.guard_tier.breakdown';
return window
.t(tmpl)
.replace('{free}', bold(freeGB))
.replace('{inactive}', bold(inactiveGB))
.replace('{active}', bold(activeGB))
.replace(/{active_pct}/g, pct)
.replace('{reclaim}', bold(reclaim))
.replace('{ceiling}', bold(ceiling));
},
// Computed hot cache size in GB (for manual input)
get hotCacheSizeGB() {
const val = this.globalSettings.cache?.hot_cache_max_size;
if (val && val !== '0') {
const parsed = this._parseSettingsGB(val);
if (parsed !== null) return parsed;
}
if (this.hotCachePercent === 0) return 0;
const totalBytes = this.globalSettings.system?.total_memory_bytes || 0;
const bytes = Math.floor((this.hotCachePercent / 100) * totalBytes);
return Math.floor(bytes / (1024 * 1024 * 1024));
},
// Update hot cache from manual GB input
updateHotCacheFromInput(gbValue) {
const gb = parseInt(gbValue) || 0;
if (gb === 0) {
this.hotCachePercent = 0;
this.globalSettings.cache.hot_cache_max_size = '0';
} else {
const totalBytes = this.globalSettings.system?.total_memory_bytes || 0;
if (totalBytes > 0) {
const bytes = gb * 1024 * 1024 * 1024;
this.hotCachePercent = Math.min(50, Math.max(1, Math.round((bytes / totalBytes) * 100)));
}
this.globalSettings.cache.hot_cache_max_size = `${gb}GB`;
}
},
// Computed cache size in GB (for manual input)
get cacheSizeGB() {
const val = this.globalSettings.cache?.ssd_cache_max_size;
if (val && val !== 'auto') {
const parsed = this._parseSettingsGB(val);
if (parsed !== null) return parsed;
}
const totalBytes = this.globalSettings.system?.ssd_total_bytes || 0;
if (!totalBytes) return 0;
const bytes = Math.floor((this.cachePercent / 100) * totalBytes);
return Math.round(bytes / (1024 * 1024 * 1024));
},
// Update cache from slider
updateCacheFromSlider() {
const totalBytes = this.globalSettings.system?.ssd_total_bytes || 0;
this.globalSettings.cache.ssd_cache_max_size = this.percentToCacheString(this.cachePercent, totalBytes);
},
// Update cache from manual GB input
updateCacheFromInput(gbValue) {
const gb = parseInt(gbValue) || 0;
this.globalSettings.cache.ssd_cache_max_size = `${gb}GB`;
// Update percent slider
const totalBytes = this.globalSettings.system?.ssd_total_bytes || 0;
if (totalBytes > 0) {
const bytes = gb * 1024 * 1024 * 1024;
this.cachePercent = Math.min(100, Math.round((bytes / totalBytes) * 100));
}
},
// Parse hot cache size string to percent of total memory
normalizeHotCacheMaxSize(value) {
const normalized = String(value ?? '').trim();
if (!normalized || normalized.toLowerCase() === 'auto') return '0';
return normalized;
},
parseHotCacheToPercent(hotCacheStr, totalBytes) {
if (!hotCacheStr || hotCacheStr === '0' || !totalBytes || totalBytes === 0) {
return 0;
}
const match = hotCacheStr.match(/^(\d+(?:\.\d+)?)\s*(GB|MB|TB)?$/i);
if (!match) return 0;
let bytes = parseFloat(match[1]);
const unit = (match[2] || 'GB').toUpperCase();
if (unit === 'TB') bytes *= 1024 * 1024 * 1024 * 1024;
else if (unit === 'GB') bytes *= 1024 * 1024 * 1024;
else if (unit === 'MB') bytes *= 1024 * 1024;
const percent = Math.round((bytes / totalBytes) * 100);
return Math.min(50, Math.max(0, percent));
},
// Update hot cache setting from slider
updateHotCacheFromSlider() {
if (this.hotCachePercent === 0) {
this.globalSettings.cache.hot_cache_max_size = '0';
} else {
const totalBytes = this.globalSettings.system?.total_memory_bytes || 0;
const bytes = Math.floor((this.hotCachePercent / 100) * totalBytes);
const gb = Math.floor(bytes / (1024 * 1024 * 1024));
this.globalSettings.cache.hot_cache_max_size = gb > 0 ? `${gb}GB` : '0';
}
},
// Get formatted hot cache size for display
getHotCacheDisplay() {
if (this.hotCachePercent === 0) return '0GB';
const totalBytes = this.globalSettings.system?.total_memory_bytes || 0;
const bytes = Math.floor((this.hotCachePercent / 100) * totalBytes);
const gb = Math.floor(bytes / (1024 * 1024 * 1024));
return `${gb}GB`;
},
// Filter + sort models
get sortedModels() {
return [...this.filterModelsByName(this.models, this.modelSearch)].sort((a, b) => {
// Favorites always sort first, regardless of the active column.
const favDiff = (b.is_favorite ? 1 : 0) - (a.is_favorite ? 1 : 0);
if (favDiff !== 0) return favDiff;
let aVal, bVal;
switch (this.sortBy) {
case 'id':
aVal = (a.display_name || a.id || '').toLowerCase();
bVal = (b.display_name || b.id || '').toLowerCase();
break;
case 'type':
aVal = (a.model_type || 'llm').toLowerCase();
bVal = (b.model_type || 'llm').toLowerCase();
break;
case 'size':
aVal = a.estimated_size || 0;
bVal = b.estimated_size || 0;
break;
case 'loaded':
aVal = a.loaded ? 1 : 0;
bVal = b.loaded ? 1 : 0;
break;
case 'pinned':
aVal = a.pinned ? 1 : 0;
bVal = b.pinned ? 1 : 0;
break;
case 'is_default':
aVal = a.is_default ? 1 : 0;
bVal = b.is_default ? 1 : 0;
break;
case 'is_hidden':
aVal = a.is_hidden ? 1 : 0;
bVal = b.is_hidden ? 1 : 0;
break;
default:
return 0;
}
if (aVal < bVal) return this.sortOrder === 'asc' ? -1 : 1;
if (aVal > bVal) return this.sortOrder === 'asc' ? 1 : -1;
return 0;
});
},
toggleSort(column) {
if (this.sortBy === column) {
this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc';
} else {
this.sortBy = column;
this.sortOrder = 'asc';
}
this.persistSort('omlx_models_sort_by', this.sortBy, 'omlx_models_sort_order', this.sortOrder);
},
resetSort() {
this.sortBy = MODELS_SORT_DEFAULT.by;
this.sortOrder = MODELS_SORT_DEFAULT.order;
try {
localStorage.removeItem('omlx_models_sort_by');
localStorage.removeItem('omlx_models_sort_order');
} catch (e) { /* storage disabled */ }
},
get isModelsSortDefault() {
return this.sortBy === MODELS_SORT_DEFAULT.by
&& this.sortOrder === MODELS_SORT_DEFAULT.order;
},
// ---- Manager (Browse Models > Local) filter + sort ----
// Cross-reference the richer /api/models entry (has model_type,
// settings) for a manager row keyed by its model name.
managerModelInfo(name) {
return this.models.find(m => m.id === name);
},
filterModelsByName(list, query) {
const q = (query || '').trim().toLowerCase();
if (!q) return list;
return list.filter(m => {
const id = (m.id || m.name || '').toLowerCase();
const display = (m.display_name || '').toLowerCase();
const alias = (
(m.settings && m.settings.model_alias)
|| (this.managerModelInfo(m.name) && this.managerModelInfo(m.name).settings
&& this.managerModelInfo(m.name).settings.model_alias)
|| ''
).toLowerCase();
return id.includes(q) || display.includes(q) || alias.includes(q);
});
},
get sortedManagerModels() {
const list = this.filterModelsByName(this.hfModels, this.managerSearch);
return [...list].sort((a, b) => {
// Favorites always sort first, regardless of the active column.
const aFav = this.managerModelInfo(a.name)?.is_favorite ? 1 : 0;
const bFav = this.managerModelInfo(b.name)?.is_favorite ? 1 : 0;
if (aFav !== bFav) return bFav - aFav;
let aVal, bVal;
switch (this.managerSortBy) {
case 'name':
aVal = (a.display_name || a.name || '').toLowerCase();
bVal = (b.display_name || b.name || '').toLowerCase();
break;
case 'type':
aVal = (this.managerModelInfo(a.name)?.model_type || 'llm').toLowerCase();
bVal = (this.managerModelInfo(b.name)?.model_type || 'llm').toLowerCase();
break;
case 'size':
aVal = a.size || 0;
bVal = b.size || 0;
break;
default:
return 0;
}
if (aVal < bVal) return this.managerSortOrder === 'asc' ? -1 : 1;
if (aVal > bVal) return this.managerSortOrder === 'asc' ? 1 : -1;
return 0;
});
},
toggleManagerSort(column) {
if (this.managerSortBy === column) {
this.managerSortOrder = this.managerSortOrder === 'asc' ? 'desc' : 'asc';
} else {
this.managerSortBy = column;
this.managerSortOrder = 'asc';
}
this.persistSort('omlx_manager_sort_by', this.managerSortBy, 'omlx_manager_sort_order', this.managerSortOrder);
},
resetManagerSort() {
this.managerSortBy = MANAGER_SORT_DEFAULT.by;
this.managerSortOrder = MANAGER_SORT_DEFAULT.order;
try {
localStorage.removeItem('omlx_manager_sort_by');
localStorage.removeItem('omlx_manager_sort_order');
} catch (e) { /* storage disabled */ }
},
get isManagerSortDefault() {
return this.managerSortBy === MANAGER_SORT_DEFAULT.by
&& this.managerSortOrder === MANAGER_SORT_DEFAULT.order;
},
persistSort(byKey, byVal, orderKey, orderVal) {
try {
localStorage.setItem(byKey, byVal);
localStorage.setItem(orderKey, orderVal);
} catch (e) { /* storage disabled */ }
},
// Deeplink from a manager row to that model's settings card (modal).
openModelSettingsFromManager(name) {
const model = this.managerModelInfo(name);
if (model) this.openModelSettings(model);
},
// Theme select
setTheme(theme) {
this.theme = theme;
localStorage.setItem('omlx-chat-theme', this.theme);
this.applyTheme();
},
applyTheme() {
// Clean up existing listener
if (this.systemThemeListener) {
window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', this.systemThemeListener);
this.systemThemeListener = null;
}
if (this.theme === 'auto') {
// Detect system theme
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
this.activeTheme = prefersDark ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', this.activeTheme);
// Add listener for system theme changes
this.systemThemeListener = (e) => {
this.activeTheme = e.matches ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', this.activeTheme);
};
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', this.systemThemeListener);
} else {
// Use explicit theme
this.activeTheme = this.theme;
document.documentElement.setAttribute('data-theme', this.activeTheme);
}
},
// =================================================================
// HuggingFace Mirror Settings
// =================================================================
openHfMirrorModal() {
this.hfMirrorEndpoint = this.globalSettings.huggingface.endpoint || '';
this.showHfMirrorModal = true;
},
async saveHfMirrorEndpoint() {
this.hfMirrorSaving = true;
try {
const response = await fetch('/admin/api/global-settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hf_endpoint: this.hfMirrorEndpoint }),
});
if (response.ok) {
this.globalSettings.huggingface.endpoint = this.hfMirrorEndpoint;
this.showHfMirrorModal = false;
} else if (response.status === 401) {
window.location.href = '/admin';
} else {
const data = await response.json();
alert(Array.isArray(data.detail) ? data.detail.map(e => (e && typeof e === 'object') ? (e.msg || JSON.stringify(e)) : String(e)).join(', ') : (data.detail || 'Failed to save'));
}
} catch (err) {
console.error('Failed to save HF mirror endpoint:', err);
} finally {
this.hfMirrorSaving = false;
}
},
// =================================================================
// HuggingFace Downloader Functions
// =================================================================
async startHFDownload() {
const repoId = this.hfRepoId.trim();
if (!repoId) return;
this.hfError = '';
this.hfSuccess = '';
this.hfDownloading = true;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 15000);
try {
const response = await fetch('/admin/api/hf/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
repo_id: repoId,
hf_token: this.hfToken,
}),
signal: controller.signal,
});
if (response.ok) {
this.hfSuccess = window.t('js.success.download_started').replace('{repo_id}', repoId);
this.hfRepoId = '';
await this.loadHFTasks();
this.startHFRefresh();
setTimeout(() => { this.hfSuccess = ''; }, 5000);
} else if (response.status === 401) {
window.location.href = '/admin';
} else {
const data = await response.json().catch(() => ({}));
this.hfError = data.detail || window.t('js.error.start_download_failed');
}
} catch (err) {
if (err.name === 'AbortError') {
this.hfError = 'HuggingFace request timed out. The service may be unavailable.';
} else {
this.hfError = window.t('js.error.start_download_connection');
}
console.error('Failed to start download:', err);
} finally {
clearTimeout(timeoutId);
this.hfDownloading = false;
}
},
async loadHFTasks() {
try {
const response = await fetch('/admin/api/hf/tasks');
if (response.ok) {
const data = await response.json();
this.hfTasks = data.tasks || [];
// Stop refresh if no active downloads
const hasActive = this.hfTasks.some(t =>
t.status === 'pending' || t.status === 'downloading');
if (!hasActive) {
this.stopHFRefresh();
// Refresh model lists when all downloads finish
if (this.hfTasks.some(t => t.status === 'completed')) {
await this.loadHFModels();
await this.loadModels();
}
}
} else if (response.status === 401) {
window.location.href = '/admin';
}
} catch (err) {
console.error('Failed to load HF tasks:', err);
}
},
async loadHFModels() {
try {
const response = await fetch('/admin/api/hf/models');
if (response.ok) {
const data = await response.json();
this.hfModels = data.models || [];
this.hfModelsLoaded = true;
} else if (response.status === 401) {
window.location.href = '/admin';
}
} catch (err) {
console.error('Failed to load HF models:', err);
}
},
async cancelHFDownload(taskId) {
try {
const response = await fetch(`/admin/api/hf/cancel/${taskId}`, {
method: 'POST',
});
if (response.ok) {
await this.loadHFTasks();
}
} catch (err) {
console.error('Failed to cancel download:', err);
}
},
async retryHFDownload(taskId) {
try {
const response = await fetch(`/admin/api/hf/retry/${taskId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hf_token: this.hfToken || '' }),
});
if (response.ok) {
await this.loadHFTasks();
this.startHFRefresh();
} else {
const data = await response.json().catch(() => ({}));
this.hfError = data.detail || 'Retry failed';
setTimeout(() => { this.hfError = ''; }, 5000);
}
} catch (err) {
console.error('Failed to retry download:', err);
}
},
async removeHFTask(taskId) {
try {
const response = await fetch(`/admin/api/hf/task/${taskId}`, {
method: 'DELETE',
});
if (response.ok) {
await this.loadHFTasks();
}
} catch (err) {
console.error('Failed to remove task:', err);
}
},
async deleteHFModel(modelName) {
this.hfDeleteConfirm = null;
try {
const response = await fetch(`/admin/api/hf/models/${encodeURIComponent(modelName)}`, {
method: 'DELETE',
});
if (response.ok) {
await this.loadHFModels();
await this.loadModels();
} else {
const data = await response.json();
this.hfError = data.detail || window.t('js.error.delete_model_failed');
setTimeout(() => { this.hfError = ''; }, 5000);
}
} catch (err) {
console.error('Failed to delete model:', err);
this.hfError = window.t('js.error.delete_model_connection');
setTimeout(() => { this.hfError = ''; }, 5000);
}
},
startHFRefresh() {
this.stopHFRefresh();
this._hfRefreshTimer = setInterval(() => {
this.loadHFTasks();
}, 2000);
},
stopHFRefresh() {
if (this._hfRefreshTimer) {
clearInterval(this._hfRefreshTimer);
this._hfRefreshTimer = null;
}
},
formatProgress(task) {
const pct = Math.round(task.progress || 0);
const dlGB = (task.downloaded_size / (1024 ** 3)).toFixed(1);
const totalGB = (task.total_size / (1024 ** 3)).toFixed(1);
return `${pct}% \u00b7 ${dlGB} GB / ${totalGB} GB`;
},
// =================================================================
// oQ Quantization Functions
// =================================================================
async loadOQModels() {
try {
const response = await fetch('/admin/api/oq/models');
if (response.ok) {
const data = await response.json();
this.oqModels = data.models || [];
this.oqAllModels = data.all_models || [];
this.oqModelsLoaded = true;
}
} catch (err) {
console.error('Failed to load quantizable models:', err);
}
},
async startOQQuantization() {
if (!this.oqSelectedModelPath || this.oqStarting) return;
this.oqError = '';
this.oqSuccess = '';
this.oqStarting = true;
try {
const payload = {
model_path: this.oqSelectedModelPath,
oq_level: this.oqLevel,
group_size: 64,
sensitivity_model_path: this.oqSensitivityModelPath,
text_only: this.oqTextOnly,
dtype: this.oqDtype,
preserve_mtp: this.oqSelectedModelHasMtp() ? this.oqPreserveMtp : false,
};
if (this.oqEnhanced) {
payload.enhanced = true;
payload.imatrix_reuse_cache = this.oqeReuseImatrixCache;
payload.imatrix_cache_path = this.oqeImatrixCachePath.trim();
payload.imatrix_strict = this.oqeStrictImatrix;
}
const response = await fetch('/admin/api/oq/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await response.json().catch(() => ({}));
if (response.ok) {
const model = this.oqModels.find(m => m.path === this.oqSelectedModelPath);
const name = model ? model.name : this.oqSelectedModelPath;
this.oqSuccess = `Quantization started: ${name} → oQ${this.oqLevel}${this.oqEnhanced ? 'e' : ''}`;
await this.loadOQTasks();
this.startOQRefresh();
setTimeout(() => { this.oqSuccess = ''; }, 5000);
} else {
this.oqError = data.detail || 'Failed to start quantization';
}
} catch (err) {
this.oqError = 'Connection error. Server may be unavailable.';
} finally {
this.oqStarting = false;
}
},
async loadOQTasks() {
try {
const response = await fetch('/admin/api/oq/tasks');
if (response.ok) {
const data = await response.json();
this.oqTasks = data.tasks || [];
const hasActive = this.oqTasks.some(t =>
['pending', 'loading', 'quantizing', 'saving'].includes(t.status));
if (!hasActive) {
this.stopOQRefresh();
if (this.oqTasks.some(t => t.status === 'completed')) {
await this.loadHFModels();
await this.loadModels();
await this.loadOQModels();
}
}
}
} catch (err) {
console.error('Failed to load oQ tasks:', err);
}
},
async cancelOQTask(taskId) {
try {
await fetch(`/admin/api/oq/cancel/${taskId}`, { method: 'POST' });
await this.loadOQTasks();
} catch (err) {
console.error('Failed to cancel oQ task:', err);
}
},
async removeOQTask(taskId) {
try {
await fetch(`/admin/api/oq/task/${taskId}`, { method: 'DELETE' });
await this.loadOQTasks();
} catch (err) {
console.error('Failed to remove oQ task:', err);
}
},
startOQRefresh() {
this.stopOQRefresh();
this._oqRefreshTimer = setInterval(() => {
this.loadOQTasks();
}, 2000);
},
stopOQRefresh() {
if (this._oqRefreshTimer) {
clearInterval(this._oqRefreshTimer);
this._oqRefreshTimer = null;
}
},
formatOQProgress(task) {
const pct = Math.round(task.progress || 0);
const label = task.progress_detail || task.phase || task.status;
return `${pct}% · ${label}`;
},
formatOQElapsed(task) {
if (!task.started_at) return '';
const now = task.completed_at || (Date.now() / 1000);
const elapsed = now - task.started_at;
const mins = Math.floor(elapsed / 60);
const secs = Math.floor(elapsed % 60);
return `${mins}:${String(secs).padStart(2, '0')}`;
},
oqSensitivityModelCandidates() {
if (!this.oqSelectedModelPath) return [];
const source = this.oqModels.find(m => m.path === this.oqSelectedModelPath);
if (!source) return [];
return this.oqAllModels.filter(m =>
m.path !== this.oqSelectedModelPath &&
m.is_quantized &&
m.model_type === source.model_type
);
},
oqLevelLabel(level) {
return `oQ${level}${this.oqEnhanced ? 'e' : ''}`;
},
oqSelectedModelIsVLM() {
const model = this.oqModels.find(m => m.path === this.oqSelectedModelPath);
return model?.is_vlm || false;
},
oqSelectedModelHasMtp() {
const model = this.oqModels.find(m => m.path === this.oqSelectedModelPath);
return model?.has_mtp_heads || false;
},
oqEstimatedMemory() {
// Use precise estimate from API if available
if (this.oqEstimate) {
// If sensitivity model selected, memory ≈ sensitivity model size × 1.5
if (this.oqSensitivityModelPath) {
const sensModel = this.oqAllModels.find(m => m.path === this.oqSensitivityModelPath);
if (sensModel) {
const bytes = Math.round(sensModel.size * 1.5) + 5 * 1024 * 1024 * 1024;
if (bytes > 1024 * 1024 * 1024) return (bytes / (1024 * 1024 * 1024)).toFixed(1) + ' GB';
return (bytes / (1024 * 1024)).toFixed(0) + ' MB';
}
}
return this.oqEstimate.memory_streaming_formatted || '';
}
// Fallback to rough model-level estimate
const model = this.oqModels.find(m => m.path === this.oqSelectedModelPath);
if (!model) return '';
return model.memory_streaming?.peak_formatted || '';
},
oqEstimate: null,
_oqEstimateTimer: null,
oqEstimatedBpw() {
return this.oqEstimate?.effective_bpw?.toFixed(1) || '';
},
oqEstimatedOutputSize() {
return this.oqEstimate?.output_size_formatted || '';
},
oqRefreshEstimate() {
// Debounce: wait 300ms after last change
if (this._oqEstimateTimer) clearTimeout(this._oqEstimateTimer);
if (!this.oqSelectedModelPath) {
this.oqEstimate = null;
return;
}
this._oqEstimateTimer = setTimeout(async () => {
try {
const params = new URLSearchParams({
model_path: this.oqSelectedModelPath,
oq_level: this.oqLevel,
preserve_mtp: this.oqSelectedModelHasMtp() && this.oqPreserveMtp ? 'true' : 'false',
});
const resp = await fetch(`/admin/api/oq/estimate?${params}`);
if (resp.ok) {
this.oqEstimate = await resp.json();
}
} catch (e) {
console.error('Failed to estimate oQ:', e);
}
}, 300);
},
// =================================================================
// oQ Uploader Functions
// =================================================================
async validateUploadToken() {
if (!this.uploadHfToken || this.uploadTokenValidating) return;
this.uploadTokenValidating = true;
this.uploadError = '';
try {
const response = await fetch('/admin/api/upload/validate-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hf_token: this.uploadHfToken }),
});
const data = await response.json().catch(() => ({}));
if (response.ok) {
this.uploadHfUsername = data.username || '';
this.uploadHfOrgs = data.orgs || [];
this.uploadHfNamespace = this.uploadHfUsername;
this.uploadTokenValidated = true;
localStorage.setItem('omlx-hf-upload-token', this.uploadHfToken);
this.loadUploadOqModels();
} else {
this.uploadError = data.detail || window.t('models.uploader.invalid_token');
this.uploadTokenValidated = false;
}
} catch (err) {
this.uploadError = 'Connection error. Server may be unavailable.';
} finally {
this.uploadTokenValidating = false;
}
},
async loadUploadOqModels() {
try {
const response = await fetch('/admin/api/upload/oq-models');
if (response.ok) {
const data = await response.json();
this.uploadOqModels = data.oq_models || [];
this.uploadAllModels = data.all_models || [];
this.uploadOqModelsLoaded = true;
}
} catch (err) {
console.error('Failed to load oQ models for upload:', err);
}
},
openUploadModal(model) {
this.uploadModalModelPath = model.path;
this.uploadModalModelName = model.name;
this.uploadModalRepoId = (this.uploadHfNamespace || this.uploadHfUsername) + '/' + model.name;
this.uploadReadmeSource = '';
this.uploadAutoReadme = true;
this.uploadRedownloadNotice = false;
this.uploadPrivate = false;
this.uploadStarting = false;
this.uploadModalOpen = true;
},
async startUpload() {
if (!this.uploadModalRepoId || this.uploadStarting) return;
this.uploadStarting = true;
this.uploadError = '';
try {
const response = await fetch('/admin/api/upload/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model_path: this.uploadModalModelPath,
repo_id: this.uploadModalRepoId,
hf_token: this.uploadHfToken,
readme_source_path: this.uploadReadmeSource,
auto_readme: this.uploadAutoReadme,
redownload_notice: this.uploadRedownloadNotice && this.uploadReadmeSource === '',
private: this.uploadPrivate,
}),
});
const data = await response.json().catch(() => ({}));
if (response.ok) {
this.uploadModalOpen = false;
this.uploadSuccess = `Upload queued: ${this.uploadModalModelName}`;
await this.loadUploadTasks();
this.startUploadRefresh();
setTimeout(() => { this.uploadSuccess = ''; }, 5000);
} else {
this.uploadError = data.detail || 'Failed to start upload';
}
} catch (err) {
this.uploadError = 'Connection error. Server may be unavailable.';
} finally {
this.uploadStarting = false;
}
},
async loadUploadTasks() {
try {
const response = await fetch('/admin/api/upload/tasks');
if (response.ok) {
const data = await response.json();
this.uploadTasks = data.tasks || [];
const hasActive = this.uploadTasks.some(t =>
['pending', 'uploading'].includes(t.status));
if (!hasActive) {
this.stopUploadRefresh();
}
}
} catch (err) {
console.error('Failed to load upload tasks:', err);
}
},
async cancelUploadTask(taskId) {
try {
await fetch(`/admin/api/upload/cancel/${taskId}`, { method: 'POST' });
await this.loadUploadTasks();
} catch (err) {
console.error('Failed to cancel upload task:', err);
}
},
async removeUploadTask(taskId) {
try {
await fetch(`/admin/api/upload/task/${taskId}`, { method: 'DELETE' });
await this.loadUploadTasks();
} catch (err) {
console.error('Failed to remove upload task:', err);
}
},
startUploadRefresh() {
this.stopUploadRefresh();
this._uploadRefreshTimer = setInterval(() => {
this.loadUploadTasks();
}, 2000);
},
stopUploadRefresh() {
if (this._uploadRefreshTimer) {
clearInterval(this._uploadRefreshTimer);
this._uploadRefreshTimer = null;
}
},
formatUploadElapsed(task) {
if (!task.started_at) return '';
const now = task.completed_at || (Date.now() / 1000);
const elapsed = now - task.started_at;
const mins = Math.floor(elapsed / 60);
const secs = Math.floor(elapsed % 60);
return `${mins}:${String(secs).padStart(2, '0')}`;
},
// =================================================================
// Recommended Models Functions
// =================================================================
async loadRecommendedModels() {
this.hfRecommendedLoading = true;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 15000);
try {
const response = await fetch(`/admin/api/hf/recommended?mlx_only=${this.hfMlxOnly}`, { signal: controller.signal });
if (response.ok) {
const data = await response.json();
// Attach original rank so the # column survives column-header re-sorts
this.hfRecommended = {
trending: (data.trending || []).map((m, i) => ({ ...m, rank: i + 1 })),
popular: (data.popular || []).map((m, i) => ({ ...m, rank: i + 1 })),
};
this.hfRecommendedLoaded = true;
this.hfPage.trending = 1;
this.hfPage.popular = 1;
// Default sort for trending/popular is original rank
this.hfTableSort = 'rank';
this.hfTableSortDir = 'asc';
} else if (response.status === 401) {
window.location.href = '/admin';
} else {
const data = await response.json().catch(() => ({}));
this.hfError = data.detail || 'Failed to load recommended models';
setTimeout(() => { this.hfError = ''; }, 5000);
}
} catch (err) {
if (err.name === 'AbortError') {
this.hfError = 'HuggingFace request timed out. The service may be unavailable.';
} else {
this.hfError = 'Failed to connect to HuggingFace.';
}
setTimeout(() => { this.hfError = ''; }, 5000);
console.error('Failed to load recommended models:', err);
} finally {
clearTimeout(timeoutId);
this.hfRecommendedLoading = false;
}
},
downloadRecommended(repoId) {
this.hfRepoId = repoId;
this.startHFDownload();
},
getMemoryFitStatus(sizeBytes) {
const totalBytes = this.globalSettings.system?.total_memory_bytes || 0;
if (!totalBytes || !sizeBytes) return 'safe';
const ratio = sizeBytes / totalBytes;
if (ratio > 0.95) return 'danger';
if (ratio > 0.80) return 'warning';
return 'safe';
},
formatDownloads(count) {
if (count >= 1000000) return (count / 1000000).toFixed(1) + 'M';
if (count >= 1000) return (count / 1000).toFixed(1) + 'K';
return count.toString();
},
// Table sort helpers for Browse Models
sortModels(list) {
const sortBy = this.hfTableSort;
const dir = this.hfTableSortDir === 'asc' ? 1 : -1;
return [...list].sort((a, b) => {
if (sortBy === 'rank') {
return dir * ((a.rank || 0) - (b.rank || 0));
} else if (sortBy === 'name') {
return dir * (a.name || '').localeCompare(b.name || '');
} else if (sortBy === 'downloads') {
return dir * ((a.downloads || 0) - (b.downloads || 0));
} else if (sortBy === 'likes') {
return dir * ((a.likes || 0) - (b.likes || 0));
} else if (sortBy === 'size') {
return dir * ((a.size || 0) - (b.size || 0));
} else if (sortBy === 'params') {
return dir * ((a.params || 0) - (b.params || 0));
}
return 0;
});
},
toggleTableSort(column) {
if (this.hfTableSort === column) {
this.hfTableSortDir = this.hfTableSortDir === 'asc' ? 'desc' : 'asc';
} else {
this.hfTableSort = column;
// Name and rank read more naturally as ascending by default
this.hfTableSortDir = (column === 'name' || column === 'rank') ? 'asc' : 'desc';
}
},
syncTableSortToDropdown() {
const map = {
largest: { col: 'size', dir: 'desc' },
smallest: { col: 'size', dir: 'asc' },
most_params: { col: 'params', dir: 'desc' },
least_params: { col: 'params', dir: 'asc' },
downloads: { col: 'downloads', dir: 'desc' },
trending: { col: 'downloads', dir: 'desc' },
created: { col: 'downloads', dir: 'desc' },
updated: { col: 'downloads', dir: 'desc' },
};
const m = map[this.hfSearchSort];
if (m) {
this.hfTableSort = m.col;
this.hfTableSortDir = m.dir;
}
},
// Pagination helpers
getPagedModels(tab) {
const page = this.hfPage[tab] || 1;
const size = this.hfPageSize;
let list;
if (tab === 'trending') list = this.hfRecommended.trending || [];
else if (tab === 'popular') list = this.hfRecommended.popular || [];
else list = this.hfSearchResults || [];
// Apply table sorting
const sorted = this.sortModels(list);
return sorted.slice((page - 1) * size, page * size);
},
getTotalPages(tab) {
let total;
if (tab === 'trending') total = (this.hfRecommended.trending || []).length;
else if (tab === 'popular') total = (this.hfRecommended.popular || []).length;
else total = (this.hfSearchResults || []).length;
const maxPages = tab === 'search' ? 10 : 5;
return Math.min(Math.ceil(total / this.hfPageSize), maxPages);
},
setPage(tab, page) {
this.hfPage[tab] = page;
},
// Search
async searchHFModels() {
if (!this.hfSearchQuery.trim()) return;
this.hfSearchLoading = true;
this.hfRecommendedTab = 'search';
this.hfPage.search = 1;
// Sync table sort with dropdown choice so the frontend re-sort
// does not override what the backend returned
this.syncTableSortToDropdown();
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 15000);
try {
const params = new URLSearchParams({
q: this.hfSearchQuery,
sort: this.hfSearchSort,
limit: '100',
mlx_only: this.hfMlxOnly,
});
// Add filter parameters if set. Sizes use binary GiB to match
// _format_model_size on the backend.
const GIB = 1024 * 1024 * 1024;
if (this.hfSearchMinParams) params.set('min_params', (parseFloat(this.hfSearchMinParams) * 1e9).toString());
if (this.hfSearchMaxParams) params.set('max_params', (parseFloat(this.hfSearchMaxParams) * 1e9).toString());
if (this.hfSearchMaxSize) params.set('max_size', (parseFloat(this.hfSearchMaxSize) * GIB).toString());
if (this.hfSearchMinSize) params.set('min_size', (parseFloat(this.hfSearchMinSize) * GIB).toString());
// Wire largest/smallest sort params to backend
if (this.hfSearchSort === 'largest') {
params.set('sort_by_size', 'true');
params.set('sort_ascending', 'false');
} else if (this.hfSearchSort === 'smallest') {
params.set('sort_by_size', 'true');
params.set('sort_ascending', 'true');
}
const response = await fetch(`/admin/api/hf/search?${params}`, { signal: controller.signal });
if (response.ok) {
const data = await response.json();
this.hfSearchResults = data.models || [];
this.hfSearchLoaded = true;
// Save to search history
this.addSearchHistory(this.hfSearchQuery.trim());
} else if (response.status === 401) {
window.location.href = '/admin';
} else {
const data = await response.json().catch(() => ({}));
this.hfError = data.detail || 'Search failed';
setTimeout(() => { this.hfError = ''; }, 5000);
}
} catch (err) {
if (err.name === 'AbortError') {
this.hfError = 'HuggingFace request timed out. The service may be unavailable.';
} else {
this.hfError = 'Failed to connect to HuggingFace.';
}
setTimeout(() => { this.hfError = ''; }, 5000);
console.error('Search failed:', err);
} finally {
clearTimeout(timeoutId);
this.hfSearchLoading = false;
}
},
clearHFSearchFilters() {
this.hfSearchMinParams = '';
this.hfSearchMaxParams = '';
this.hfSearchMaxSize = '';
this.hfSearchMinSize = '';
if (this.hfSearchQuery.trim()) this.immediateSearch();
},
debounceSearch() {
clearTimeout(this.hfSearchDebounceTimer);
if (!this.hfSearchQuery.trim()) return;
this.hfSearchDebounceTimer = setTimeout(() => this.searchHFModels(), 500);
},
immediateSearch() {
clearTimeout(this.hfSearchDebounceTimer);
this.searchHFModels();
},
formatParamCount(params) {
if (!params) return null;
if (params >= 1e12) return (params / 1e12).toFixed(1) + 'T';
if (params >= 1e9) return (params / 1e9).toFixed(1) + 'B';
if (params >= 1e6) return (params / 1e6).toFixed(1) + 'M';
return params.toString();
},
// Search history
addSearchHistory(query) {
let history = this.hfSearchHistory.filter(h => h !== query);
history.unshift(query);
history = history.slice(0, 5);
this.hfSearchHistory = history;
localStorage.setItem('hfSearchHistory', JSON.stringify(history));
},
selectSearchHistory(query) {
this.hfSearchQuery = query;
this.hfSearchHistoryOpen = false;
this.searchHFModels();
},
closeSearchHistory() {
setTimeout(() => { this.hfSearchHistoryOpen = false; }, 150);
},
// Model detail modal
async openModelDetail(repoId) {
this.hfModelDetailLoading = true;
this.hfModelDetail = null;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 15000);
try {
const params = new URLSearchParams({ repo_id: repoId });
const response = await fetch(`/admin/api/hf/model-info?${params}`, { signal: controller.signal });
if (response.ok) {
this.hfModelDetail = await response.json();
} else if (response.status === 401) {
window.location.href = '/admin';
} else {
const data = await response.json().catch(() => ({}));
this.hfError = data.detail || 'Failed to fetch model info';
setTimeout(() => { this.hfError = ''; }, 5000);
}
} catch (err) {
if (err.name === 'AbortError') {
this.hfError = 'HuggingFace request timed out. The service may be unavailable.';
} else {
this.hfError = 'Failed to connect to HuggingFace.';
}
setTimeout(() => { this.hfError = ''; }, 5000);
console.error('Failed to fetch model info:', err);
} finally {
clearTimeout(timeoutId);
this.hfModelDetailLoading = false;
}
},
closeModelDetail() {
this.hfModelDetail = null;
this.hfModelDetailLoading = false;
this.msModelDetail = null;
this.msModelDetailLoading = false;
},
formatFileSize(bytes) {
if (!bytes) return '';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
return (bytes / (1024 * 1024 * 1024)).toFixed(1) + ' GB';
},
// =================================================================
// ModelScope Downloader Functions
// =================================================================
async initMsDownloader() {
if (this.msInitialized) return;
this.msInitialized = true;
try {
const response = await fetch('/admin/api/ms/status');
if (response.ok) {
const data = await response.json();
this.msAvailable = data.available === true;
} else {
this.msAvailable = false;
}
} catch (err) {
this.msAvailable = false;
console.error('Failed to check MS status:', err);
}
if (this.msAvailable) {
await this.loadMSTasks();
}
},
async startMSDownload() {
let repoId = this.msRepoId.trim();
if (!repoId) return;
// Default owner to mlx-community if not specified
if (!repoId.includes('/')) {
repoId = 'mlx-community/' + repoId;
}
this.msError = '';
this.msSuccess = '';
this.msDownloading = true;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 20000);
try {
const response = await fetch('/admin/api/ms/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model_id: repoId,
ms_token: this.msToken || '',
}),
signal: controller.signal,
});
if (response.ok) {
this.msSuccess = window.t('js.success.download_started').replace('{repo_id}', repoId);
this.msRepoId = '';
await this.loadMSTasks();
this.startMSRefresh();
setTimeout(() => { this.msSuccess = ''; }, 5000);
} else if (response.status === 401) {
window.location.href = '/admin';
} else {
const data = await response.json().catch(() => ({}));
this.msError = data.detail || window.t('js.error.start_download_failed');
}
} catch (err) {
if (err.name === 'AbortError') {
this.msError = 'ModelScope request timed out. The service may be unavailable.';
} else {
this.msError = window.t('js.error.start_download_connection');
}
console.error('Failed to start MS download:', err);
} finally {
clearTimeout(timeoutId);
this.msDownloading = false;
}
},
async loadMSTasks() {
try {
const response = await fetch('/admin/api/ms/tasks');
if (response.ok) {
const data = await response.json();
this.msTasks = data.tasks || [];
const hasActive = this.msTasks.some(t =>
t.status === 'pending' || t.status === 'downloading');
if (!hasActive) {
this.stopMSRefresh();
if (this.msTasks.some(t => t.status === 'completed')) {
await this.loadHFModels();
await this.loadModels();
}
}
} else if (response.status === 401) {
window.location.href = '/admin';
}
} catch (err) {
console.error('Failed to load MS tasks:', err);
}
},
async cancelMSDownload(taskId) {
try {
const response = await fetch(`/admin/api/ms/cancel/${taskId}`, {
method: 'POST',
});
if (response.ok) {
await this.loadMSTasks();
}
} catch (err) {
console.error('Failed to cancel MS download:', err);
}
},
async retryMSDownload(taskId) {
try {
const response = await fetch(`/admin/api/ms/retry/${taskId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ms_token: this.msToken || null }),
});
if (response.ok) {
await this.loadMSTasks();
this.startMSRefresh();
} else {
const data = await response.json().catch(() => ({}));
this.msError = data.detail || 'Retry failed';
setTimeout(() => { this.msError = ''; }, 5000);
}
} catch (err) {
console.error('Failed to retry MS download:', err);
}
},
async removeMSTask(taskId) {
try {
const response = await fetch(`/admin/api/ms/task/${taskId}`, {
method: 'DELETE',
});
if (response.ok) {
await this.loadMSTasks();
}
} catch (err) {
console.error('Failed to remove MS task:', err);
}
},
startMSRefresh() {
this.stopMSRefresh();
this._msRefreshTimer = setInterval(() => {
this.loadMSTasks();
}, 2000);
},
stopMSRefresh() {
if (this._msRefreshTimer) {
clearInterval(this._msRefreshTimer);
this._msRefreshTimer = null;
}
},
downloadMsModel(repoId) {
this.msRepoId = repoId;
this.startMSDownload();
},
// MS Recommended models
async loadMsRecommendedModels() {
this.msRecommendedLoading = true;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 20000);
try {
const response = await fetch(`/admin/api/ms/recommended?mlx_only=${this.msMlxOnly}`, { signal: controller.signal });
if (response.ok) {
const data = await response.json();
this.msRecommended = data;
this.msRecommendedLoaded = true;
this.msPage.trending = 1;
this.msPage.popular = 1;
} else if (response.status === 401) {
window.location.href = '/admin';
} else {
const data = await response.json().catch(() => ({}));
this.msError = data.detail || 'Failed to load recommended models';
setTimeout(() => { this.msError = ''; }, 5000);
}
} catch (err) {
if (err.name === 'AbortError') {
this.msError = 'ModelScope request timed out. The service may be unavailable.';
} else {
this.msError = 'Failed to connect to ModelScope.';
}
setTimeout(() => { this.msError = ''; }, 5000);
console.error('Failed to load MS recommended models:', err);
} finally {
clearTimeout(timeoutId);
this.msRecommendedLoading = false;
}
},
// MS Pagination helpers
getMsPagedModels(tab) {
const page = this.msPage[tab] || 1;
const size = this.msPageSize;
let list;
if (tab === 'trending') list = (this.msRecommended.trending || []);
else if (tab === 'popular') list = (this.msRecommended.popular || []);
else list = this.msSearchResults || [];
return list.slice((page - 1) * size, page * size);
},
getMsTotalPages(tab) {
let total;
if (tab === 'trending') total = (this.msRecommended.trending || []).length;
else if (tab === 'popular') total = (this.msRecommended.popular || []).length;
else total = (this.msSearchResults || []).length;
const maxPages = tab === 'search' ? 10 : 5;
return Math.min(Math.ceil(total / this.msPageSize), maxPages);
},
setMsPage(tab, page) {
this.msPage[tab] = page;
},
// MS Search
async searchMSModels() {
if (!this.msSearchQuery.trim()) return;
this.msSearchLoading = true;
this.msRecommendedTab = 'search';
this.msPage.search = 1;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 20000);
try {
const params = new URLSearchParams({
q: this.msSearchQuery,
sort: this.msSearchSort,
limit: '50',
mlx_only: this.msMlxOnly,
});
const response = await fetch(`/admin/api/ms/search?${params}`, { signal: controller.signal });
if (response.ok) {
const data = await response.json();
this.msSearchResults = data.models || [];
this.msSearchLoaded = true;
} else if (response.status === 401) {
window.location.href = '/admin';
} else {
const data = await response.json().catch(() => ({}));
this.msError = data.detail || 'Search failed';
setTimeout(() => { this.msError = ''; }, 5000);
}
} catch (err) {
if (err.name === 'AbortError') {
this.msError = 'ModelScope request timed out. The service may be unavailable.';
} else {
this.msError = 'Failed to connect to ModelScope.';
}
setTimeout(() => { this.msError = ''; }, 5000);
console.error('MS search failed:', err);
} finally {
clearTimeout(timeoutId);
this.msSearchLoading = false;
}
},
mImmediateSearch() {
const query = this.msSearchQuery.trim();
if (query) {
// Save to search history
this.msSearchHistory = [query, ...this.msSearchHistory.filter(h => h !== query)].slice(0, 10);
localStorage.setItem('msSearchHistory', JSON.stringify(this.msSearchHistory));
}
this.msSearchHistoryOpen = false;
this.searchMSModels();
},
msDebounceSearch() {
clearTimeout(this.msSearchDebounceTimer);
this.msSearchDebounceTimer = setTimeout(() => {
if (this.msSearchQuery.trim()) {
this.searchMSModels();
}
}, 500);
},
closeMsSearchHistory() {
setTimeout(() => { this.msSearchHistoryOpen = false; }, 200);
},
selectMsSearchHistory(item) {
this.msSearchQuery = item;
this.msSearchHistoryOpen = false;
this.mImmediateSearch();
},
// MS Model detail modal
async openMsModelDetail(repoId) {
this.msModelDetailLoading = true;
this.msModelDetail = null;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 20000);
try {
const params = new URLSearchParams({ model_id: repoId });
const response = await fetch(`/admin/api/ms/model-info?${params}`, { signal: controller.signal });
if (response.ok) {
this.msModelDetail = await response.json();
} else if (response.status === 401) {
window.location.href = '/admin';
} else {
const data = await response.json().catch(() => ({}));
this.msError = data.detail || 'Failed to fetch model info';
setTimeout(() => { this.msError = ''; }, 5000);
}
} catch (err) {
if (err.name === 'AbortError') {
this.msError = 'ModelScope request timed out. The service may be unavailable.';
} else {
this.msError = 'Failed to connect to ModelScope.';
}
setTimeout(() => { this.msError = ''; }, 5000);
console.error('Failed to fetch MS model info:', err);
} finally {
clearTimeout(timeoutId);
this.msModelDetailLoading = false;
}
},
}
}