5443 lines
254 KiB
JavaScript
5443 lines
254 KiB
JavaScript
// 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;
|
||
}
|
||
},
|
||
}
|
||
}
|