gravityyy-proxyyy / public /js /components /server-config.js
bardd's picture
Fix gemini-3.1-pro-high by routing to pro-low with thinkingLevel high.
4badc3b
Raw
History Blame Contribute Delete
57.1 kB
/**
* Server Config Component
* Registers itself to window.Components for Alpine.js to consume
*/
window.Components = window.Components || {};
window.Components.serverConfig = () => ({
serverConfig: {},
loading: false,
advancedExpanded: false,
debounceTimers: {}, // Store debounce timers for each config field
// Server presets state
serverPresets: [],
selectedServerPreset: '',
loadingPreset: false,
savingServerPreset: false,
deletingServerPreset: false,
newServerPresetName: '',
newServerPresetDescription: '',
editingPresetMode: false,
editingPresetOriginalName: '',
presetPreviewExpanded: false,
editingPresetConfig: false,
editingConfigDraft: { accountSelection: { strategy: 'hybrid' } },
editingConfigErrors: {},
savingPresetConfig: false,
configEditMode: 'ui',
editingJsonText: '',
jsonParseError: null,
init() {
// Initial fetch if this is the active sub-tab
if (this.$store.global.settingsTab === 'server') {
this.fetchServerConfig();
this.fetchServerPresets();
}
// Watch settings sub-tab (skip initial trigger)
this.$watch('$store.global.settingsTab', (tab, oldTab) => {
if (tab === 'server' && oldTab !== undefined) {
this.fetchServerConfig();
this.fetchServerPresets();
}
});
// Cancel config editing when switching presets
this.$watch('selectedServerPreset', () => {
if (this.editingPresetConfig) {
this.cancelPresetConfigEdit();
}
});
},
async fetchServerConfig() {
const password = Alpine.store('global').webuiPassword;
try {
const { response, newPassword } = await window.utils.request('/api/config', {}, password);
if (newPassword) Alpine.store('global').webuiPassword = newPassword;
if (!response.ok) throw new Error('Failed to fetch config');
const data = await response.json();
this.serverConfig = data.config || {};
} catch (e) {
console.error('Failed to fetch server config:', e);
}
},
// Password management
passwordDialog: {
show: false,
oldPassword: '',
newPassword: '',
confirmPassword: ''
},
showPasswordDialog() {
this.passwordDialog = {
show: true,
oldPassword: '',
newPassword: '',
confirmPassword: ''
};
},
hidePasswordDialog() {
this.passwordDialog = {
show: false,
oldPassword: '',
newPassword: '',
confirmPassword: ''
};
},
async changePassword() {
const store = Alpine.store('global');
const { oldPassword, newPassword, confirmPassword } = this.passwordDialog;
if (newPassword !== confirmPassword) {
store.showToast(store.t('passwordsNotMatch'), 'error');
return;
}
if (newPassword.length < 6) {
store.showToast(store.t('passwordTooShort'), 'error');
return;
}
try {
const { response } = await window.utils.request('/api/config/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ oldPassword, newPassword })
}, store.webuiPassword);
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || store.t('failedToChangePassword'));
}
// Update stored password
store.webuiPassword = newPassword;
store.showToast(store.t('passwordChangedSuccess'), 'success');
this.hidePasswordDialog();
} catch (e) {
store.showToast(store.t('failedToChangePassword') + ': ' + e.message, 'error');
}
},
// Toggle Developer Mode with instant save
async toggleDevMode(enabled) {
const store = Alpine.store('global');
// Optimistic update
const previousDevMode = this.serverConfig.devMode;
const previousDebug = this.serverConfig.debug;
this.serverConfig.devMode = enabled;
this.serverConfig.debug = enabled;
try {
const { response, newPassword } = await window.utils.request('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ devMode: enabled })
}, store.webuiPassword);
if (newPassword) store.webuiPassword = newPassword;
const data = await response.json();
if (data.status === 'ok') {
const status = enabled ? store.t('enabledStatus') : store.t('disabledStatus');
store.showToast(store.t('devModeToggled', { status }), 'success');
// Update data store
Alpine.store('data').devMode = enabled;
await this.fetchServerConfig(); // Confirm server state
} else {
throw new Error(data.error || store.t('failedToUpdateDevMode'));
}
} catch (e) {
// Rollback on error
this.serverConfig.devMode = previousDevMode;
this.serverConfig.debug = previousDebug;
store.showToast(store.t('failedToUpdateDevMode') + ': ' + e.message, 'error');
}
},
// Toggle Token Cache with instant save
async toggleTokenCache(enabled) {
const store = Alpine.store('global');
// Optimistic update
const previousValue = this.serverConfig.persistTokenCache;
this.serverConfig.persistTokenCache = enabled;
try {
const { response, newPassword } = await window.utils.request('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ persistTokenCache: enabled })
}, store.webuiPassword);
if (newPassword) store.webuiPassword = newPassword;
const data = await response.json();
if (data.status === 'ok') {
const status = enabled ? store.t('enabledStatus') : store.t('disabledStatus');
store.showToast(store.t('tokenCacheToggled', { status }), 'success');
await this.fetchServerConfig(); // Confirm server state
} else {
throw new Error(data.error || store.t('failedToUpdateTokenCache'));
}
} catch (e) {
// Rollback on error
this.serverConfig.persistTokenCache = previousValue;
store.showToast(store.t('failedToUpdateTokenCache') + ': ' + e.message, 'error');
}
},
// Generic debounced save method for numeric configs with validation
async saveConfigField(fieldName, value, displayName, validator = null) {
const store = Alpine.store('global');
// Validate input if validator provided
if (validator) {
const validation = window.Validators.validate(value, validator, true);
if (!validation.isValid) {
// Rollback to clamped value
this.serverConfig[fieldName] = validation.value;
return;
}
value = validation.value;
} else {
value = parseInt(value);
}
// Clear existing timer for this field
if (this.debounceTimers[fieldName]) {
clearTimeout(this.debounceTimers[fieldName]);
}
// Optimistic update
const previousValue = this.serverConfig[fieldName];
this.serverConfig[fieldName] = value;
// Set new timer
this.debounceTimers[fieldName] = setTimeout(async () => {
try {
const payload = {};
payload[fieldName] = value;
const { response, newPassword } = await window.utils.request('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}, store.webuiPassword);
if (newPassword) store.webuiPassword = newPassword;
const data = await response.json();
if (data.status === 'ok') {
store.showToast(store.t('fieldUpdated', { displayName, value }), 'success');
await this.fetchServerConfig(); // Confirm server state
} else {
throw new Error(data.error || store.t('failedToUpdateField', { displayName }));
}
} catch (e) {
// Rollback on error
this.serverConfig[fieldName] = previousValue;
store.showToast(store.t('failedToUpdateField', { displayName }) + ': ' + e.message, 'error');
}
}, window.AppConstants.INTERVALS.CONFIG_DEBOUNCE);
},
// Individual toggle methods for each Advanced Tuning field with validation
toggleMaxRetries(value) {
const { MAX_RETRIES_MIN, MAX_RETRIES_MAX } = window.AppConstants.VALIDATION;
this.saveConfigField('maxRetries', value, 'Max Retries',
(v) => window.Validators.validateRange(v, MAX_RETRIES_MIN, MAX_RETRIES_MAX, 'Max Retries'));
},
toggleRetryBaseMs(value) {
const { RETRY_BASE_MS_MIN, RETRY_BASE_MS_MAX } = window.AppConstants.VALIDATION;
this.saveConfigField('retryBaseMs', value, 'Retry Base Delay',
(v) => window.Validators.validateRange(v, RETRY_BASE_MS_MIN, RETRY_BASE_MS_MAX, 'Retry Base Delay'));
},
toggleRetryMaxMs(value) {
const { RETRY_MAX_MS_MIN, RETRY_MAX_MS_MAX } = window.AppConstants.VALIDATION;
this.saveConfigField('retryMaxMs', value, 'Retry Max Delay',
(v) => window.Validators.validateRange(v, RETRY_MAX_MS_MIN, RETRY_MAX_MS_MAX, 'Retry Max Delay'));
},
toggleDefaultCooldownMs(value) {
const { DEFAULT_COOLDOWN_MIN, DEFAULT_COOLDOWN_MAX } = window.AppConstants.VALIDATION;
this.saveConfigField('defaultCooldownMs', value, 'Default Cooldown',
(v) => window.Validators.validateTimeout(v, DEFAULT_COOLDOWN_MIN, DEFAULT_COOLDOWN_MAX));
},
toggleMaxWaitBeforeErrorMs(value) {
const { MAX_WAIT_MIN, MAX_WAIT_MAX } = window.AppConstants.VALIDATION;
this.saveConfigField('maxWaitBeforeErrorMs', value, 'Max Wait Threshold',
(v) => window.Validators.validateTimeout(v, MAX_WAIT_MIN, MAX_WAIT_MAX));
},
toggleGlobalQuotaThreshold(value) {
const { GLOBAL_QUOTA_THRESHOLD_MIN, GLOBAL_QUOTA_THRESHOLD_MAX } = window.AppConstants.VALIDATION;
const store = Alpine.store('global');
const pct = parseInt(value);
if (isNaN(pct) || pct < GLOBAL_QUOTA_THRESHOLD_MIN || pct > GLOBAL_QUOTA_THRESHOLD_MAX) return;
// Store as percentage in UI, convert to fraction for backend
const fraction = pct / 100;
if (this.debounceTimers['globalQuotaThreshold']) {
clearTimeout(this.debounceTimers['globalQuotaThreshold']);
}
const previousValue = this.serverConfig.globalQuotaThreshold;
this.serverConfig.globalQuotaThreshold = fraction;
this.debounceTimers['globalQuotaThreshold'] = setTimeout(async () => {
try {
const { response, newPassword } = await window.utils.request('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ globalQuotaThreshold: fraction })
}, store.webuiPassword);
if (newPassword) store.webuiPassword = newPassword;
const data = await response.json();
if (data.status === 'ok') {
store.showToast(store.t('fieldUpdated', { displayName: 'Minimum Quota Level', value: pct + '%' }), 'success');
await this.fetchServerConfig();
} else {
throw new Error(data.error || store.t('failedToUpdateField', { displayName: 'Minimum Quota Level' }));
}
} catch (e) {
this.serverConfig.globalQuotaThreshold = previousValue;
store.showToast(store.t('failedToUpdateField', { displayName: 'Minimum Quota Level' }) + ': ' + e.message, 'error');
}
}, window.AppConstants.INTERVALS.CONFIG_DEBOUNCE);
},
async toggleRequestThrottling(enabled) {
const store = Alpine.store('global');
const previousValue = this.serverConfig.requestThrottlingEnabled;
this.serverConfig.requestThrottlingEnabled = enabled;
try {
const { response, newPassword } = await window.utils.request('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requestThrottlingEnabled: enabled })
}, store.webuiPassword);
if (newPassword) store.webuiPassword = newPassword;
const data = await response.json();
if (data.status === 'ok') {
store.showToast(`Request Throttling ${enabled ? 'enabled' : 'disabled'}`, 'success');
} else {
throw new Error(data.error || 'Failed to update');
}
} catch (e) {
this.serverConfig.requestThrottlingEnabled = previousValue;
store.showToast('Failed to update Request Throttling: ' + e.message, 'error');
}
},
toggleRequestDelayMs(value) {
const { REQUEST_DELAY_MIN, REQUEST_DELAY_MAX } = window.AppConstants.VALIDATION;
this.saveConfigField('requestDelayMs', value, 'Request Delay',
(v) => window.Validators.validateRange(v, REQUEST_DELAY_MIN, REQUEST_DELAY_MAX, 'Request Delay'));
},
toggleMaxAccounts(value) {
const { MAX_ACCOUNTS_MIN, MAX_ACCOUNTS_MAX } = window.AppConstants.VALIDATION;
this.saveConfigField('maxAccounts', value, 'Max Accounts',
(v) => window.Validators.validateRange(v, MAX_ACCOUNTS_MIN, MAX_ACCOUNTS_MAX, 'Max Accounts'));
},
toggleRateLimitDedupWindowMs(value) {
const { RATE_LIMIT_DEDUP_MIN, RATE_LIMIT_DEDUP_MAX } = window.AppConstants.VALIDATION;
this.saveConfigField('rateLimitDedupWindowMs', value, 'Rate Limit Dedup Window',
(v) => window.Validators.validateTimeout(v, RATE_LIMIT_DEDUP_MIN, RATE_LIMIT_DEDUP_MAX));
},
toggleMaxConsecutiveFailures(value) {
const { MAX_CONSECUTIVE_FAILURES_MIN, MAX_CONSECUTIVE_FAILURES_MAX } = window.AppConstants.VALIDATION;
this.saveConfigField('maxConsecutiveFailures', value, 'Max Consecutive Failures',
(v) => window.Validators.validateRange(v, MAX_CONSECUTIVE_FAILURES_MIN, MAX_CONSECUTIVE_FAILURES_MAX, 'Max Consecutive Failures'));
},
toggleExtendedCooldownMs(value) {
const { EXTENDED_COOLDOWN_MIN, EXTENDED_COOLDOWN_MAX } = window.AppConstants.VALIDATION;
this.saveConfigField('extendedCooldownMs', value, 'Extended Cooldown',
(v) => window.Validators.validateTimeout(v, EXTENDED_COOLDOWN_MIN, EXTENDED_COOLDOWN_MAX));
},
toggleMaxCapacityRetries(value) {
const { MAX_CAPACITY_RETRIES_MIN, MAX_CAPACITY_RETRIES_MAX } = window.AppConstants.VALIDATION;
this.saveConfigField('maxCapacityRetries', value, 'Max Capacity Retries',
(v) => window.Validators.validateRange(v, MAX_CAPACITY_RETRIES_MIN, MAX_CAPACITY_RETRIES_MAX, 'Max Capacity Retries'));
},
// Toggle Account Selection Strategy
async toggleStrategy(strategy) {
const store = Alpine.store('global');
const validStrategies = ['sticky', 'round-robin', 'hybrid'];
if (!validStrategies.includes(strategy)) {
store.showToast(store.t('invalidStrategy'), 'error');
return;
}
// Optimistic update
const previousValue = this.serverConfig.accountSelection?.strategy || 'hybrid';
if (!this.serverConfig.accountSelection) {
this.serverConfig.accountSelection = {};
}
this.serverConfig.accountSelection.strategy = strategy;
try {
const { response, newPassword } = await window.utils.request('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accountSelection: { strategy } })
}, store.webuiPassword);
if (newPassword) store.webuiPassword = newPassword;
const data = await response.json();
if (data.status === 'ok') {
const strategyLabel = this.getStrategyLabel(strategy);
store.showToast(store.t('strategyUpdated', { strategy: strategyLabel }), 'success');
await this.fetchServerConfig(); // Confirm server state
} else {
throw new Error(data.error || store.t('failedToUpdateStrategy'));
}
} catch (e) {
// Rollback on error
if (!this.serverConfig.accountSelection) {
this.serverConfig.accountSelection = {};
}
this.serverConfig.accountSelection.strategy = previousValue;
store.showToast(store.t('failedToUpdateStrategy') + ': ' + e.message, 'error');
}
},
// Get display label for a strategy
getStrategyLabel(strategy) {
const store = Alpine.store('global');
const labels = {
'sticky': store.t('strategyStickyLabel'),
'round-robin': store.t('strategyRoundRobinLabel'),
'hybrid': store.t('strategyHybridLabel')
};
return labels[strategy] || strategy;
},
// Get description for current strategy
currentStrategyDescription() {
const store = Alpine.store('global');
const strategy = this.serverConfig.accountSelection?.strategy || 'hybrid';
const descriptions = {
'sticky': store.t('strategyStickyDesc'),
'round-robin': store.t('strategyRoundRobinDesc'),
'hybrid': store.t('strategyHybridDesc')
};
return descriptions[strategy] || '';
},
// ==========================================
// Server Configuration Presets
// ==========================================
async fetchServerPresets() {
const password = Alpine.store('global').webuiPassword;
try {
const { response, newPassword } = await window.utils.request('/api/server/presets', {}, password);
if (newPassword) Alpine.store('global').webuiPassword = newPassword;
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
if (data.status === 'ok') {
this.serverPresets = data.presets || [];
if (this.serverPresets.length > 0 && !this.selectedServerPreset) {
this.selectedServerPreset = this.serverPresets[0].name;
}
}
} catch (e) {
console.error('Failed to fetch server presets:', e);
}
},
/**
* Load a server preset — applies all config values via POST /api/config
*/
async loadServerPreset(name) {
const preset = this.serverPresets.find(p => p.name === name);
if (!preset) return;
this.loadingPreset = true;
const store = Alpine.store('global');
try {
const { response, newPassword } = await window.utils.request('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(preset.config)
}, store.webuiPassword);
if (newPassword) store.webuiPassword = newPassword;
const data = await response.json();
if (data.status === 'ok') {
store.showToast(store.t('serverPresetLoaded', { name }) || `Preset "${name}" applied`, 'success');
await this.fetchServerConfig();
} else {
throw new Error(data.error || 'Failed to apply preset');
}
} catch (e) {
store.showToast((store.t('failedToLoadServerPreset') || 'Failed to apply preset') + ': ' + e.message, 'error');
} finally {
this.loadingPreset = false;
}
},
/**
* Save current server config as a new custom preset
*/
async saveCurrentAsServerPreset() {
this.editingPresetMode = false;
this.editingPresetOriginalName = '';
this.newServerPresetName = '';
this.newServerPresetDescription = '';
document.getElementById('save_server_preset_modal').showModal();
},
/**
* Edit an existing custom preset's name and description
*/
editServerPreset() {
const preset = this.serverPresets.find(p => p.name === this.selectedServerPreset);
if (!preset || preset.builtIn) return;
this.editingPresetMode = true;
this.editingPresetOriginalName = preset.name;
this.newServerPresetName = preset.name;
this.newServerPresetDescription = preset.description || '';
document.getElementById('save_server_preset_modal').showModal();
},
/**
* Execute PATCH to update preset metadata
*/
async executeEditServerPreset() {
const name = this.newServerPresetName.trim();
if (!name) {
Alpine.store('global').showToast(Alpine.store('global').t('presetNameRequired') || 'Preset name is required', 'error');
return;
}
this.savingServerPreset = true;
const store = Alpine.store('global');
try {
const payload = { name, description: this.newServerPresetDescription.trim() || '' };
const { response, newPassword } = await window.utils.request(
`/api/server/presets/${encodeURIComponent(this.editingPresetOriginalName)}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
},
store.webuiPassword
);
if (newPassword) store.webuiPassword = newPassword;
if (!response.ok) {
const errData = await response.json();
throw new Error(errData.error || `HTTP ${response.status}`);
}
const data = await response.json();
if (data.status === 'ok') {
this.serverPresets = data.presets || [];
this.selectedServerPreset = name;
this.editingPresetMode = false;
this.editingPresetOriginalName = '';
this.newServerPresetName = '';
this.newServerPresetDescription = '';
store.showToast(store.t('serverPresetUpdated') || 'Preset updated', 'success');
document.getElementById('save_server_preset_modal').close();
} else {
throw new Error(data.error || 'Failed to update preset');
}
} catch (e) {
store.showToast((store.t('failedToEditServerPreset') || 'Failed to update preset') + ': ' + e.message, 'error');
} finally {
this.savingServerPreset = false;
}
},
async executeSaveServerPreset(name) {
if (!name || !name.trim()) {
Alpine.store('global').showToast(Alpine.store('global').t('presetNameRequired') || 'Preset name is required', 'error');
return;
}
this.savingServerPreset = true;
const store = Alpine.store('global');
const password = store.webuiPassword;
try {
// Extract relevant config fields (exclude sensitive/non-tunable)
const relevantKeys = [
'maxRetries', 'retryBaseMs', 'retryMaxMs', 'defaultCooldownMs',
'maxWaitBeforeErrorMs', 'maxAccounts', 'globalQuotaThreshold',
'rateLimitDedupWindowMs', 'maxConsecutiveFailures', 'extendedCooldownMs',
'maxCapacityRetries', 'switchAccountDelayMs', 'capacityBackoffTiersMs',
'accountSelection'
];
const presetConfig = {};
relevantKeys.forEach(k => {
if (this.serverConfig[k] !== undefined) {
presetConfig[k] = JSON.parse(JSON.stringify(this.serverConfig[k]));
}
});
const payload = { name: name.trim(), config: presetConfig };
if (this.newServerPresetDescription.trim()) {
payload.description = this.newServerPresetDescription.trim();
}
const { response, newPassword } = await window.utils.request('/api/server/presets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}, password);
if (newPassword) store.webuiPassword = newPassword;
if (!response.ok) {
const errData = await response.json();
throw new Error(errData.error || `HTTP ${response.status}`);
}
const data = await response.json();
if (data.status === 'ok') {
this.serverPresets = data.presets || [];
this.selectedServerPreset = name.trim();
this.newServerPresetName = '';
this.newServerPresetDescription = '';
store.showToast(store.t('serverPresetSaved') || `Preset "${name}" saved`, 'success');
document.getElementById('save_server_preset_modal').close();
} else {
throw new Error(data.error || 'Failed to save preset');
}
} catch (e) {
store.showToast((store.t('failedToSaveServerPreset') || 'Failed to save preset') + ': ' + e.message, 'error');
} finally {
this.savingServerPreset = false;
}
},
async deleteSelectedServerPreset() {
if (!this.selectedServerPreset) return;
// Check if built-in
const preset = this.serverPresets.find(p => p.name === this.selectedServerPreset);
if (preset?.builtIn) {
Alpine.store('global').showToast(Alpine.store('global').t('cannotDeleteBuiltIn') || 'Cannot delete built-in presets', 'warning');
return;
}
const store = Alpine.store('global');
const translated = store.t('deletePresetConfirm', { name: this.selectedServerPreset });
const confirmMsg = translated === 'deletePresetConfirm' ? `Delete preset "${this.selectedServerPreset}"?` : translated;
if (!confirm(confirmMsg)) return;
this.deletingServerPreset = true;
try {
const { response, newPassword } = await window.utils.request(
`/api/server/presets/${encodeURIComponent(this.selectedServerPreset)}`,
{ method: 'DELETE' },
store.webuiPassword
);
if (newPassword) store.webuiPassword = newPassword;
if (!response.ok) {
const errData = await response.json();
throw new Error(errData.error || `HTTP ${response.status}`);
}
const data = await response.json();
if (data.status === 'ok') {
this.serverPresets = data.presets || [];
this.selectedServerPreset = this.serverPresets.length > 0 ? this.serverPresets[0].name : '';
store.showToast(store.t('serverPresetDeleted') || 'Preset deleted', 'success');
} else {
throw new Error(data.error || 'Failed to delete preset');
}
} catch (e) {
store.showToast((store.t('failedToDeleteServerPreset') || 'Failed to delete preset') + ': ' + e.message, 'error');
} finally {
this.deletingServerPreset = false;
}
},
isSelectedPresetBuiltIn() {
const preset = this.serverPresets.find(p => p.name === this.selectedServerPreset);
return preset?.builtIn === true;
},
/**
* Format a millisecond value to a human-readable string.
* e.g. 60000 → "1m", 1000 → "1s", 1500 → "1.5s", 90000 → "1m 30s"
*/
formatMsValue(ms) {
if (ms == null) return '—';
if (ms < 1000) return ms + 'ms';
const totalSeconds = ms / 1000;
if (totalSeconds < 60) {
return Number.isInteger(totalSeconds) ? totalSeconds + 's' : totalSeconds.toFixed(1) + 's';
}
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (seconds === 0) return minutes + 'm';
return minutes + 'm ' + (Number.isInteger(seconds) ? seconds : seconds.toFixed(1)) + 's';
},
/**
* Get preview sections for the currently selected preset.
* Returns { strategy, strategyLabel, sections } where each section has { label, rows }.
* Each row has { label, value, differs } where differs is true when the preset
* value doesn't match the current running serverConfig.
*/
getPresetPreviewSections() {
const preset = this.serverPresets.find(p => p.name === this.selectedServerPreset);
if (!preset?.config) return null;
const cfg = preset.config;
const cur = this.serverConfig;
const store = Alpine.store('global');
const strategy = cfg.accountSelection?.strategy || 'hybrid';
const currentStrategy = cur.accountSelection?.strategy || 'hybrid';
const differs = (presetVal, currentVal) => {
if (presetVal == null && currentVal == null) return false;
if (presetVal == null || currentVal == null) return true;
return JSON.stringify(presetVal) !== JSON.stringify(currentVal);
};
const fmtQuota = (val) => {
if (!val || val === 0) return store.t('quotaDisabled') || 'Disabled';
return Math.round(val * 100) + '%';
};
const V = window.AppConstants.VALIDATION;
const sections = [
{
label: store.t('networkRetry') || 'Network Retry Settings',
rows: [
{ label: store.t('maxRetries') || 'Max Retries', value: cfg.maxRetries ?? '—', differs: differs(cfg.maxRetries, cur.maxRetries), key: 'maxRetries', min: V.MAX_RETRIES_MIN, max: V.MAX_RETRIES_MAX, step: 1 },
{ label: store.t('retryBaseDelay') || 'Retry Base Delay', value: this.formatMsValue(cfg.retryBaseMs), differs: differs(cfg.retryBaseMs, cur.retryBaseMs), key: 'retryBaseMs', min: V.RETRY_BASE_MS_MIN, max: V.RETRY_BASE_MS_MAX, step: 100, suffix: 'ms' },
{ label: store.t('retryMaxDelay') || 'Retry Max Delay', value: this.formatMsValue(cfg.retryMaxMs), differs: differs(cfg.retryMaxMs, cur.retryMaxMs), key: 'retryMaxMs', min: V.RETRY_MAX_MS_MIN, max: V.RETRY_MAX_MS_MAX, step: 1000, suffix: 'ms' },
]
},
{
label: store.t('rateLimiting') || 'Rate Limiting',
rows: [
{ label: store.t('defaultCooldown') || 'Default Cooldown', value: this.formatMsValue(cfg.defaultCooldownMs), differs: differs(cfg.defaultCooldownMs, cur.defaultCooldownMs), key: 'defaultCooldownMs', min: V.DEFAULT_COOLDOWN_MIN, max: V.DEFAULT_COOLDOWN_MAX, step: 1000, suffix: 'ms' },
{ label: store.t('maxWaitThreshold') || 'Max Wait Before Error', value: this.formatMsValue(cfg.maxWaitBeforeErrorMs), differs: differs(cfg.maxWaitBeforeErrorMs, cur.maxWaitBeforeErrorMs), key: 'maxWaitBeforeErrorMs', min: V.MAX_WAIT_MIN, max: V.MAX_WAIT_MAX, step: 1000, suffix: 'ms' },
{ label: store.t('maxAccounts') || 'Max Accounts', value: cfg.maxAccounts ?? '—', differs: differs(cfg.maxAccounts, cur.maxAccounts), key: 'maxAccounts', min: V.MAX_ACCOUNTS_MIN, max: V.MAX_ACCOUNTS_MAX, step: 1 },
{ label: store.t('switchAccountDelay') || 'Switch Account Delay', value: this.formatMsValue(cfg.switchAccountDelayMs), differs: differs(cfg.switchAccountDelayMs, cur.switchAccountDelayMs), key: 'switchAccountDelayMs', min: V.SWITCH_ACCOUNT_DELAY_MIN, max: V.SWITCH_ACCOUNT_DELAY_MAX, step: 1000, suffix: 'ms' },
]
},
{
label: store.t('quotaProtection') || 'Quota Protection',
rows: [
{ label: store.t('minimumQuotaLevel') || 'Minimum Quota Level', value: fmtQuota(cfg.globalQuotaThreshold), differs: differs(cfg.globalQuotaThreshold, cur.globalQuotaThreshold), key: 'globalQuotaThreshold', min: V.GLOBAL_QUOTA_THRESHOLD_MIN, max: V.GLOBAL_QUOTA_THRESHOLD_MAX, step: 1, suffix: '%' },
]
},
{
label: store.t('errorHandlingTuning') || 'Error Handling',
rows: [
{ label: store.t('rateLimitDedupWindow') || 'Dedup Window', value: this.formatMsValue(cfg.rateLimitDedupWindowMs), differs: differs(cfg.rateLimitDedupWindowMs, cur.rateLimitDedupWindowMs), key: 'rateLimitDedupWindowMs', min: V.RATE_LIMIT_DEDUP_MIN, max: V.RATE_LIMIT_DEDUP_MAX, step: 1000, suffix: 'ms' },
{ label: store.t('maxConsecutiveFailures') || 'Max Consecutive Failures', value: cfg.maxConsecutiveFailures ?? '—', differs: differs(cfg.maxConsecutiveFailures, cur.maxConsecutiveFailures), key: 'maxConsecutiveFailures', min: V.MAX_CONSECUTIVE_FAILURES_MIN, max: V.MAX_CONSECUTIVE_FAILURES_MAX, step: 1 },
{ label: store.t('extendedCooldown') || 'Extended Cooldown', value: this.formatMsValue(cfg.extendedCooldownMs), differs: differs(cfg.extendedCooldownMs, cur.extendedCooldownMs), key: 'extendedCooldownMs', min: V.EXTENDED_COOLDOWN_MIN, max: V.EXTENDED_COOLDOWN_MAX, step: 1000, suffix: 'ms' },
{ label: store.t('maxCapacityRetries') || 'Max Capacity Retries', value: cfg.maxCapacityRetries ?? '—', differs: differs(cfg.maxCapacityRetries, cur.maxCapacityRetries), key: 'maxCapacityRetries', min: V.MAX_CAPACITY_RETRIES_MIN, max: V.MAX_CAPACITY_RETRIES_MAX, step: 1 },
{ label: store.t('capacityBackoffTiers') || 'Capacity Backoff Tiers', value: (cfg.capacityBackoffTiersMs || []).map(v => this.formatMsValue(v)).join(', ') || '—', differs: differs(cfg.capacityBackoffTiersMs, cur.capacityBackoffTiersMs), key: 'capacityBackoffTiersMs', type: 'text' },
]
}
];
// Add hybrid-only sections when strategy is hybrid
if (strategy === 'hybrid') {
const as = cfg.accountSelection || {};
const curAs = cur.accountSelection || {};
// Scoring Weights
const w = as.weights || {};
const curW = curAs.weights || {};
sections.push({
label: store.t('scoringWeights') || 'Scoring Weights',
hybridOnly: true,
rows: [
{ label: store.t('weightHealth') || 'Health Weight', value: w.health ?? '—', differs: differs(w.health, curW.health), key: 'w_health', min: V.W_HEALTH_MIN, max: V.W_HEALTH_MAX, step: 0.1 },
{ label: store.t('weightTokens') || 'Tokens Weight', value: w.tokens ?? '—', differs: differs(w.tokens, curW.tokens), key: 'w_tokens', min: V.W_TOKENS_MIN, max: V.W_TOKENS_MAX, step: 0.1 },
{ label: store.t('weightQuota') || 'Quota Weight', value: w.quota ?? '—', differs: differs(w.quota, curW.quota), key: 'w_quota', min: V.W_QUOTA_MIN, max: V.W_QUOTA_MAX, step: 0.1 },
{ label: store.t('weightLru') || 'LRU Weight', value: w.lru ?? '—', differs: differs(w.lru, curW.lru), key: 'w_lru', min: V.W_LRU_MIN, max: V.W_LRU_MAX, step: 0.01 },
]
});
// Health Score
const hs = as.healthScore || {};
const curHs = curAs.healthScore || {};
sections.push({
label: store.t('healthScoreSection') || 'Health Score',
hybridOnly: true,
rows: [
{ label: store.t('hsInitial') || 'Initial Score', value: hs.initial ?? '—', differs: differs(hs.initial, curHs.initial), key: 'hs_initial', min: V.HS_INITIAL_MIN, max: V.HS_INITIAL_MAX, step: 1 },
{ label: store.t('hsSuccessReward') || 'Success Reward', value: hs.successReward ?? '—', differs: differs(hs.successReward, curHs.successReward), key: 'hs_successReward', min: V.HS_SUCCESS_REWARD_MIN, max: V.HS_SUCCESS_REWARD_MAX, step: 1 },
{ label: store.t('hsRateLimitPenalty') || 'Rate Limit Penalty', value: hs.rateLimitPenalty ?? '—', differs: differs(hs.rateLimitPenalty, curHs.rateLimitPenalty), key: 'hs_rateLimitPenalty', min: V.HS_RATE_LIMIT_PENALTY_MIN, max: V.HS_RATE_LIMIT_PENALTY_MAX, step: 1 },
{ label: store.t('hsFailurePenalty') || 'Failure Penalty', value: hs.failurePenalty ?? '—', differs: differs(hs.failurePenalty, curHs.failurePenalty), key: 'hs_failurePenalty', min: V.HS_FAILURE_PENALTY_MIN, max: V.HS_FAILURE_PENALTY_MAX, step: 1 },
{ label: store.t('hsRecoveryPerHour') || 'Recovery/Hour', value: hs.recoveryPerHour ?? '—', differs: differs(hs.recoveryPerHour, curHs.recoveryPerHour), key: 'hs_recoveryPerHour', min: V.HS_RECOVERY_PER_HOUR_MIN, max: V.HS_RECOVERY_PER_HOUR_MAX, step: 1 },
{ label: store.t('hsMinUsable') || 'Min Usable Score', value: hs.minUsable ?? '—', differs: differs(hs.minUsable, curHs.minUsable), key: 'hs_minUsable', min: V.HS_MIN_USABLE_MIN, max: V.HS_MIN_USABLE_MAX, step: 1 },
{ label: store.t('hsMaxScore') || 'Max Score', value: hs.maxScore ?? '—', differs: differs(hs.maxScore, curHs.maxScore), key: 'hs_maxScore', min: V.HS_MAX_SCORE_MIN, max: V.HS_MAX_SCORE_MAX, step: 1 },
]
});
// Token Bucket
const tb = as.tokenBucket || {};
const curTb = curAs.tokenBucket || {};
sections.push({
label: store.t('tokenBucketSection') || 'Token Bucket',
hybridOnly: true,
rows: [
{ label: store.t('tbMaxTokens') || 'Max Tokens', value: tb.maxTokens ?? '—', differs: differs(tb.maxTokens, curTb.maxTokens), key: 'tb_maxTokens', min: V.TB_MAX_TOKENS_MIN, max: V.TB_MAX_TOKENS_MAX, step: 1 },
{ label: store.t('tbTokensPerMinute') || 'Tokens/Minute', value: tb.tokensPerMinute ?? '—', differs: differs(tb.tokensPerMinute, curTb.tokensPerMinute), key: 'tb_tokensPerMinute', min: V.TB_TOKENS_PER_MINUTE_MIN, max: V.TB_TOKENS_PER_MINUTE_MAX, step: 1 },
{ label: store.t('tbInitialTokens') || 'Initial Tokens', value: tb.initialTokens ?? '—', differs: differs(tb.initialTokens, curTb.initialTokens), key: 'tb_initialTokens', min: V.TB_INITIAL_TOKENS_MIN, max: V.TB_INITIAL_TOKENS_MAX, step: 1 },
]
});
// Quota Awareness
const q = as.quota || {};
const curQ = curAs.quota || {};
sections.push({
label: store.t('quotaAwarenessSection') || 'Quota Awareness',
hybridOnly: true,
rows: [
{ label: store.t('qLowThreshold') || 'Low Threshold', value: q.lowThreshold != null ? Math.round(q.lowThreshold * 100) + '%' : '—', differs: differs(q.lowThreshold, curQ.lowThreshold), key: 'q_lowThreshold', min: V.Q_LOW_THRESHOLD_MIN, max: V.Q_LOW_THRESHOLD_MAX, step: 1, suffix: '%' },
{ label: store.t('qCriticalThreshold') || 'Critical Threshold', value: q.criticalThreshold != null ? Math.round(q.criticalThreshold * 100) + '%' : '—', differs: differs(q.criticalThreshold, curQ.criticalThreshold), key: 'q_criticalThreshold', min: V.Q_CRITICAL_THRESHOLD_MIN, max: V.Q_CRITICAL_THRESHOLD_MAX, step: 1, suffix: '%' },
{ label: store.t('qStaleMs') || 'Stale Data Timeout', value: this.formatMsValue(q.staleMs), differs: differs(q.staleMs, curQ.staleMs), key: 'q_staleMs', min: V.Q_STALE_MS_MIN, max: V.Q_STALE_MS_MAX, step: 1000, suffix: 'ms' },
]
});
}
return {
strategy,
strategyLabel: this.getStrategyLabel(strategy),
strategyDiffers: differs(strategy, currentStrategy),
sections
};
},
// ==========================================
// Inline Preset Config Editing
// ==========================================
/**
* Flatten a nested config object into a flat draft for UI editing.
* Converts fractions to percentages where needed.
*/
flattenConfigToDraft(cfg) {
const as = cfg.accountSelection || {};
const hs = as.healthScore || {};
const tb = as.tokenBucket || {};
const q = as.quota || {};
const w = as.weights || {};
return {
maxRetries: cfg.maxRetries,
retryBaseMs: cfg.retryBaseMs,
retryMaxMs: cfg.retryMaxMs,
defaultCooldownMs: cfg.defaultCooldownMs,
maxWaitBeforeErrorMs: cfg.maxWaitBeforeErrorMs,
maxAccounts: cfg.maxAccounts,
globalQuotaThreshold: cfg.globalQuotaThreshold ? Math.round(cfg.globalQuotaThreshold * 100) : 0,
rateLimitDedupWindowMs: cfg.rateLimitDedupWindowMs,
maxConsecutiveFailures: cfg.maxConsecutiveFailures,
extendedCooldownMs: cfg.extendedCooldownMs,
maxCapacityRetries: cfg.maxCapacityRetries,
switchAccountDelayMs: cfg.switchAccountDelayMs,
capacityBackoffTiersMs: Array.isArray(cfg.capacityBackoffTiersMs) ? cfg.capacityBackoffTiersMs.join(', ') : '',
accountSelection: { strategy: as.strategy || 'hybrid' },
// Health score (prefixed hs_)
hs_initial: hs.initial,
hs_successReward: hs.successReward,
hs_rateLimitPenalty: hs.rateLimitPenalty,
hs_failurePenalty: hs.failurePenalty,
hs_recoveryPerHour: hs.recoveryPerHour,
hs_minUsable: hs.minUsable,
hs_maxScore: hs.maxScore,
// Token bucket (prefixed tb_)
tb_maxTokens: tb.maxTokens,
tb_tokensPerMinute: tb.tokensPerMinute,
tb_initialTokens: tb.initialTokens,
// Quota (prefixed q_, fractions → percentages)
q_lowThreshold: q.lowThreshold != null ? Math.round(q.lowThreshold * 100) : 0,
q_criticalThreshold: q.criticalThreshold != null ? Math.round(q.criticalThreshold * 100) : 0,
q_staleMs: q.staleMs,
// Weights (prefixed w_)
w_health: w.health,
w_tokens: w.tokens,
w_quota: w.quota,
w_lru: w.lru,
};
},
/**
* Build a nested config object from a flat UI draft.
* Converts percentages back to fractions where needed.
*/
buildConfigFromDraft(draft) {
const cfg = {
maxRetries: draft.maxRetries,
retryBaseMs: draft.retryBaseMs,
retryMaxMs: draft.retryMaxMs,
defaultCooldownMs: draft.defaultCooldownMs,
maxWaitBeforeErrorMs: draft.maxWaitBeforeErrorMs,
maxAccounts: draft.maxAccounts,
globalQuotaThreshold: (draft.globalQuotaThreshold || 0) / 100,
rateLimitDedupWindowMs: draft.rateLimitDedupWindowMs,
maxConsecutiveFailures: draft.maxConsecutiveFailures,
extendedCooldownMs: draft.extendedCooldownMs,
maxCapacityRetries: draft.maxCapacityRetries,
switchAccountDelayMs: draft.switchAccountDelayMs,
};
// Parse capacityBackoffTiersMs from comma-separated string
if (typeof draft.capacityBackoffTiersMs === 'string' && draft.capacityBackoffTiersMs.trim()) {
const tiers = draft.capacityBackoffTiersMs.split(',').map(s => Number(s.trim())).filter(n => !isNaN(n));
if (tiers.length > 0) cfg.capacityBackoffTiersMs = tiers;
} else if (Array.isArray(draft.capacityBackoffTiersMs)) {
cfg.capacityBackoffTiersMs = draft.capacityBackoffTiersMs;
}
// Build accountSelection with nested objects
cfg.accountSelection = {
strategy: draft.accountSelection?.strategy || 'hybrid',
healthScore: {
initial: draft.hs_initial,
successReward: draft.hs_successReward,
rateLimitPenalty: draft.hs_rateLimitPenalty,
failurePenalty: draft.hs_failurePenalty,
recoveryPerHour: draft.hs_recoveryPerHour,
minUsable: draft.hs_minUsable,
maxScore: draft.hs_maxScore,
},
tokenBucket: {
maxTokens: draft.tb_maxTokens,
tokensPerMinute: draft.tb_tokensPerMinute,
initialTokens: draft.tb_initialTokens,
},
quota: {
lowThreshold: (draft.q_lowThreshold || 0) / 100,
criticalThreshold: (draft.q_criticalThreshold || 0) / 100,
staleMs: draft.q_staleMs,
},
weights: {
health: draft.w_health,
tokens: draft.w_tokens,
quota: draft.w_quota,
lru: draft.w_lru,
}
};
// Clean undefined values from nested objects
for (const subKey of ['healthScore', 'tokenBucket', 'quota', 'weights']) {
const obj = cfg.accountSelection[subKey];
const cleaned = {};
let hasValues = false;
for (const [k, v] of Object.entries(obj)) {
if (v !== undefined && v !== null) {
cleaned[k] = v;
hasValues = true;
}
}
if (hasValues) {
cfg.accountSelection[subKey] = cleaned;
} else {
delete cfg.accountSelection[subKey];
}
}
return cfg;
},
enterPresetConfigEdit() {
const preset = this.serverPresets.find(p => p.name === this.selectedServerPreset);
if (!preset?.config || preset.builtIn) return;
this.editingConfigDraft = this.flattenConfigToDraft(preset.config);
this.editingConfigErrors = {};
this.editingPresetConfig = true;
this.presetPreviewExpanded = true;
this.configEditMode = 'ui';
this.editingJsonText = '';
this.jsonParseError = null;
},
cancelPresetConfigEdit() {
this.editingPresetConfig = false;
this.editingConfigDraft = { accountSelection: { strategy: 'hybrid' } };
this.editingConfigErrors = {};
this.configEditMode = 'ui';
this.editingJsonText = '';
this.jsonParseError = null;
},
switchConfigEditMode(mode) {
if (mode === this.configEditMode) return;
if (mode === 'json') {
// UI → JSON: convert flat draft to nested config, then stringify
const nested = this.buildConfigFromDraft(this.editingConfigDraft);
this.editingJsonText = JSON.stringify(nested, null, 2);
this.jsonParseError = null;
this.editingConfigErrors = {};
this.configEditMode = 'json';
} else {
// JSON → UI: parse nested config and flatten to draft
try {
const parsed = JSON.parse(this.editingJsonText);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
this.jsonParseError = Alpine.store('global').t('invalidJson') || 'Invalid JSON';
return;
}
this.editingConfigDraft = this.flattenConfigToDraft(parsed);
this.editingConfigErrors = {};
this.jsonParseError = null;
// Re-validate all numeric fields
const allKeys = [
'maxRetries', 'retryBaseMs', 'retryMaxMs', 'defaultCooldownMs',
'maxWaitBeforeErrorMs', 'maxAccounts', 'globalQuotaThreshold',
'rateLimitDedupWindowMs', 'maxConsecutiveFailures', 'extendedCooldownMs',
'maxCapacityRetries', 'switchAccountDelayMs',
'hs_initial', 'hs_successReward', 'hs_rateLimitPenalty', 'hs_failurePenalty',
'hs_recoveryPerHour', 'hs_minUsable', 'hs_maxScore',
'tb_maxTokens', 'tb_tokensPerMinute', 'tb_initialTokens',
'q_lowThreshold', 'q_criticalThreshold', 'q_staleMs',
'w_health', 'w_tokens', 'w_quota', 'w_lru'
];
allKeys.forEach(k => {
if (this.editingConfigDraft[k] !== undefined) this.validatePresetConfigField(k, this.editingConfigDraft[k]);
});
// Validate capacityBackoffTiersMs text
this.validatePresetConfigField('capacityBackoffTiersMs', this.editingConfigDraft.capacityBackoffTiersMs);
this.configEditMode = 'ui';
} catch (e) {
this.jsonParseError = Alpine.store('global').t('invalidJson') || 'Invalid JSON';
}
}
},
validateJsonText() {
try {
const parsed = JSON.parse(this.editingJsonText);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
this.jsonParseError = Alpine.store('global').t('invalidJson') || 'Invalid JSON';
} else {
this.jsonParseError = null;
}
} catch {
this.jsonParseError = Alpine.store('global').t('invalidJson') || 'Invalid JSON';
}
},
validatePresetConfigField(key, value) {
const V = window.AppConstants.VALIDATION;
// Special text validation for capacityBackoffTiersMs
if (key === 'capacityBackoffTiersMs') {
const str = String(value || '').trim();
if (!str) {
delete this.editingConfigErrors[key];
return;
}
const parts = str.split(',').map(s => s.trim());
if (parts.length < V.CAPACITY_BACKOFF_TIERS_MIN_LENGTH || parts.length > V.CAPACITY_BACKOFF_TIERS_MAX_LENGTH) {
this.editingConfigErrors[key] = `${V.CAPACITY_BACKOFF_TIERS_MIN_LENGTH}${V.CAPACITY_BACKOFF_TIERS_MAX_LENGTH} values`;
return;
}
const allValid = parts.every(s => {
const n = Number(s);
return !isNaN(n) && n >= V.CAPACITY_BACKOFF_TIER_MIN && n <= V.CAPACITY_BACKOFF_TIER_MAX;
});
if (!allValid) {
this.editingConfigErrors[key] = `each ${V.CAPACITY_BACKOFF_TIER_MIN}${V.CAPACITY_BACKOFF_TIER_MAX}`;
} else {
delete this.editingConfigErrors[key];
}
return;
}
const numVal = Number(value);
const ranges = {
maxRetries: [V.MAX_RETRIES_MIN, V.MAX_RETRIES_MAX],
retryBaseMs: [V.RETRY_BASE_MS_MIN, V.RETRY_BASE_MS_MAX],
retryMaxMs: [V.RETRY_MAX_MS_MIN, V.RETRY_MAX_MS_MAX],
defaultCooldownMs: [V.DEFAULT_COOLDOWN_MIN, V.DEFAULT_COOLDOWN_MAX],
maxWaitBeforeErrorMs: [V.MAX_WAIT_MIN, V.MAX_WAIT_MAX],
maxAccounts: [V.MAX_ACCOUNTS_MIN, V.MAX_ACCOUNTS_MAX],
globalQuotaThreshold: [V.GLOBAL_QUOTA_THRESHOLD_MIN, V.GLOBAL_QUOTA_THRESHOLD_MAX],
rateLimitDedupWindowMs: [V.RATE_LIMIT_DEDUP_MIN, V.RATE_LIMIT_DEDUP_MAX],
maxConsecutiveFailures: [V.MAX_CONSECUTIVE_FAILURES_MIN, V.MAX_CONSECUTIVE_FAILURES_MAX],
extendedCooldownMs: [V.EXTENDED_COOLDOWN_MIN, V.EXTENDED_COOLDOWN_MAX],
maxCapacityRetries: [V.MAX_CAPACITY_RETRIES_MIN, V.MAX_CAPACITY_RETRIES_MAX],
switchAccountDelayMs: [V.SWITCH_ACCOUNT_DELAY_MIN, V.SWITCH_ACCOUNT_DELAY_MAX],
// Health score
hs_initial: [V.HS_INITIAL_MIN, V.HS_INITIAL_MAX],
hs_successReward: [V.HS_SUCCESS_REWARD_MIN, V.HS_SUCCESS_REWARD_MAX],
hs_rateLimitPenalty: [V.HS_RATE_LIMIT_PENALTY_MIN, V.HS_RATE_LIMIT_PENALTY_MAX],
hs_failurePenalty: [V.HS_FAILURE_PENALTY_MIN, V.HS_FAILURE_PENALTY_MAX],
hs_recoveryPerHour: [V.HS_RECOVERY_PER_HOUR_MIN, V.HS_RECOVERY_PER_HOUR_MAX],
hs_minUsable: [V.HS_MIN_USABLE_MIN, V.HS_MIN_USABLE_MAX],
hs_maxScore: [V.HS_MAX_SCORE_MIN, V.HS_MAX_SCORE_MAX],
// Token bucket
tb_maxTokens: [V.TB_MAX_TOKENS_MIN, V.TB_MAX_TOKENS_MAX],
tb_tokensPerMinute: [V.TB_TOKENS_PER_MINUTE_MIN, V.TB_TOKENS_PER_MINUTE_MAX],
tb_initialTokens: [V.TB_INITIAL_TOKENS_MIN, V.TB_INITIAL_TOKENS_MAX],
// Quota
q_lowThreshold: [V.Q_LOW_THRESHOLD_MIN, V.Q_LOW_THRESHOLD_MAX],
q_criticalThreshold: [V.Q_CRITICAL_THRESHOLD_MIN, V.Q_CRITICAL_THRESHOLD_MAX],
q_staleMs: [V.Q_STALE_MS_MIN, V.Q_STALE_MS_MAX],
// Weights
w_health: [V.W_HEALTH_MIN, V.W_HEALTH_MAX],
w_tokens: [V.W_TOKENS_MIN, V.W_TOKENS_MAX],
w_quota: [V.W_QUOTA_MIN, V.W_QUOTA_MAX],
w_lru: [V.W_LRU_MIN, V.W_LRU_MAX],
};
const range = ranges[key];
if (!range) return;
if (isNaN(numVal) || numVal < range[0] || numVal > range[1]) {
this.editingConfigErrors[key] = `${range[0]}${range[1]}`;
} else {
delete this.editingConfigErrors[key];
this.editingConfigDraft[key] = numVal;
}
},
hasPresetConfigErrors() {
if (this.configEditMode === 'json') return !!this.jsonParseError;
return Object.keys(this.editingConfigErrors).length > 0;
},
async savePresetConfig() {
if (this.hasPresetConfigErrors() || this.savingPresetConfig) return;
const store = Alpine.store('global');
const presetName = this.selectedServerPreset;
if (!presetName) return;
let configPayload;
if (this.configEditMode === 'json') {
// Parse JSON textarea directly into config payload
try {
configPayload = JSON.parse(this.editingJsonText);
} catch {
this.jsonParseError = store.t('invalidJson') || 'Invalid JSON';
return;
}
// Must be a plain object (not null, not an array, not a primitive)
if (typeof configPayload !== 'object' || configPayload === null || Array.isArray(configPayload)) {
this.jsonParseError = store.t('invalidJson') || 'Invalid JSON';
return;
}
} else {
// Build nested config from flat UI draft
configPayload = this.buildConfigFromDraft(this.editingConfigDraft);
}
this.savingPresetConfig = true;
try {
const { response, newPassword } = await window.utils.request(
`/api/server/presets/${encodeURIComponent(presetName)}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config: configPayload })
},
store.webuiPassword
);
if (newPassword) store.webuiPassword = newPassword;
if (!response.ok) {
const errData = await response.json();
throw new Error(errData.error || `HTTP ${response.status}`);
}
const data = await response.json();
if (data.status === 'ok') {
this.serverPresets = data.presets || [];
this.editingPresetConfig = false;
this.editingConfigDraft = { accountSelection: { strategy: 'hybrid' } };
this.editingConfigErrors = {};
this.configEditMode = 'ui';
this.editingJsonText = '';
this.jsonParseError = null;
store.showToast(store.t('presetConfigSaved') || 'Preset config updated', 'success');
} else {
throw new Error(data.error || 'Failed to save preset config');
}
} catch (e) {
store.showToast((store.t('failedToSavePresetConfig') || 'Failed to update preset config') + ': ' + e.message, 'error');
} finally {
this.savingPresetConfig = false;
}
}
});