Files
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 lines
254 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
}
},
}
}