Switch from substring includes to regex word boundary tests for model names like opus, sonnet, and gemini variants, improving accuracy in the account manager's prioritization logic.
234 lines
8.7 KiB
JavaScript
234 lines
8.7 KiB
JavaScript
/**
|
|
* Account Manager Component
|
|
* Registers itself to window.Components for Alpine.js to consume
|
|
*/
|
|
window.Components = window.Components || {};
|
|
|
|
window.Components.accountManager = () => ({
|
|
searchQuery: '',
|
|
deleteTarget: '',
|
|
refreshing: false,
|
|
toggling: false,
|
|
deleting: false,
|
|
reloading: false,
|
|
|
|
get filteredAccounts() {
|
|
const accounts = Alpine.store('data').accounts || [];
|
|
if (!this.searchQuery || this.searchQuery.trim() === '') {
|
|
return accounts;
|
|
}
|
|
|
|
const query = this.searchQuery.toLowerCase().trim();
|
|
return accounts.filter(acc => {
|
|
return acc.email.toLowerCase().includes(query) ||
|
|
(acc.projectId && acc.projectId.toLowerCase().includes(query)) ||
|
|
(acc.source && acc.source.toLowerCase().includes(query));
|
|
});
|
|
},
|
|
|
|
formatEmail(email) {
|
|
if (!email || email.length <= 40) return email;
|
|
|
|
const [user, domain] = email.split('@');
|
|
if (!domain) return email;
|
|
|
|
// Preserve domain integrity, truncate username if needed
|
|
if (user.length > 20) {
|
|
return `${user.substring(0, 10)}...${user.slice(-5)}@${domain}`;
|
|
}
|
|
return email;
|
|
},
|
|
|
|
async refreshAccount(email) {
|
|
return await window.ErrorHandler.withLoading(async () => {
|
|
const store = Alpine.store('global');
|
|
store.showToast(store.t('refreshingAccount', { email }), 'info');
|
|
|
|
const { response, newPassword } = await window.utils.request(
|
|
`/api/accounts/${encodeURIComponent(email)}/refresh`,
|
|
{ method: 'POST' },
|
|
store.webuiPassword
|
|
);
|
|
if (newPassword) store.webuiPassword = newPassword;
|
|
|
|
const data = await response.json();
|
|
if (data.status === 'ok') {
|
|
store.showToast(store.t('refreshedAccount', { email }), 'success');
|
|
Alpine.store('data').fetchData();
|
|
} else {
|
|
throw new Error(data.error || store.t('refreshFailed'));
|
|
}
|
|
}, this, 'refreshing', { errorMessage: 'Failed to refresh account' });
|
|
},
|
|
|
|
async toggleAccount(email, enabled) {
|
|
const store = Alpine.store('global');
|
|
const password = store.webuiPassword;
|
|
|
|
// Optimistic update: immediately update UI
|
|
const dataStore = Alpine.store('data');
|
|
const account = dataStore.accounts.find(a => a.email === email);
|
|
if (account) {
|
|
account.enabled = enabled;
|
|
}
|
|
|
|
try {
|
|
const { response, newPassword } = await window.utils.request(`/api/accounts/${encodeURIComponent(email)}/toggle`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ enabled })
|
|
}, password);
|
|
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('accountToggled', { email, status }), 'success');
|
|
// Refresh to confirm server state
|
|
await dataStore.fetchData();
|
|
} else {
|
|
store.showToast(data.error || store.t('toggleFailed'), 'error');
|
|
// Rollback optimistic update on error
|
|
if (account) {
|
|
account.enabled = !enabled;
|
|
}
|
|
await dataStore.fetchData();
|
|
}
|
|
} catch (e) {
|
|
store.showToast(store.t('toggleFailed') + ': ' + e.message, 'error');
|
|
// Rollback optimistic update on error
|
|
if (account) {
|
|
account.enabled = !enabled;
|
|
}
|
|
await dataStore.fetchData();
|
|
}
|
|
},
|
|
|
|
async fixAccount(email) {
|
|
const store = Alpine.store('global');
|
|
store.showToast(store.t('reauthenticating', { email }), 'info');
|
|
const password = store.webuiPassword;
|
|
try {
|
|
const urlPath = `/api/auth/url?email=${encodeURIComponent(email)}`;
|
|
const { response, newPassword } = await window.utils.request(urlPath, {}, password);
|
|
if (newPassword) store.webuiPassword = newPassword;
|
|
|
|
const data = await response.json();
|
|
if (data.status === 'ok') {
|
|
window.open(data.url, 'google_oauth', 'width=600,height=700,scrollbars=yes');
|
|
} else {
|
|
store.showToast(data.error || store.t('authUrlFailed'), 'error');
|
|
}
|
|
} catch (e) {
|
|
store.showToast(store.t('authUrlFailed') + ': ' + e.message, 'error');
|
|
}
|
|
},
|
|
|
|
confirmDeleteAccount(email) {
|
|
this.deleteTarget = email;
|
|
document.getElementById('delete_account_modal').showModal();
|
|
},
|
|
|
|
async executeDelete() {
|
|
const email = this.deleteTarget;
|
|
return await window.ErrorHandler.withLoading(async () => {
|
|
const store = Alpine.store('global');
|
|
|
|
const { response, newPassword } = await window.utils.request(
|
|
`/api/accounts/${encodeURIComponent(email)}`,
|
|
{ method: 'DELETE' },
|
|
store.webuiPassword
|
|
);
|
|
if (newPassword) store.webuiPassword = newPassword;
|
|
|
|
const data = await response.json();
|
|
if (data.status === 'ok') {
|
|
store.showToast(store.t('deletedAccount', { email }), 'success');
|
|
Alpine.store('data').fetchData();
|
|
document.getElementById('delete_account_modal').close();
|
|
this.deleteTarget = '';
|
|
} else {
|
|
throw new Error(data.error || store.t('deleteFailed'));
|
|
}
|
|
}, this, 'deleting', { errorMessage: 'Failed to delete account' });
|
|
},
|
|
|
|
async reloadAccounts() {
|
|
return await window.ErrorHandler.withLoading(async () => {
|
|
const store = Alpine.store('global');
|
|
|
|
const { response, newPassword } = await window.utils.request(
|
|
'/api/accounts/reload',
|
|
{ method: 'POST' },
|
|
store.webuiPassword
|
|
);
|
|
if (newPassword) store.webuiPassword = newPassword;
|
|
|
|
const data = await response.json();
|
|
if (data.status === 'ok') {
|
|
store.showToast(store.t('accountsReloaded'), 'success');
|
|
Alpine.store('data').fetchData();
|
|
} else {
|
|
throw new Error(data.error || store.t('reloadFailed'));
|
|
}
|
|
}, this, 'reloading', { errorMessage: 'Failed to reload accounts' });
|
|
},
|
|
|
|
/**
|
|
* Get main model quota for display
|
|
* Prioritizes flagship models (Opus > Sonnet > Flash)
|
|
* @param {Object} account - Account object with limits
|
|
* @returns {Object} { percent: number|null, model: string }
|
|
*/
|
|
getMainModelQuota(account) {
|
|
const limits = account.limits || {};
|
|
|
|
const getQuotaVal = (id) => {
|
|
const l = limits[id];
|
|
if (!l) return -1;
|
|
if (l.remainingFraction !== null) return l.remainingFraction;
|
|
if (l.resetTime) return 0; // Rate limited
|
|
return -1; // Unknown
|
|
};
|
|
|
|
const validIds = Object.keys(limits).filter(id => getQuotaVal(id) >= 0);
|
|
|
|
if (validIds.length === 0) return { percent: null, model: '-' };
|
|
|
|
const getPriority = (id) => {
|
|
const lower = id.toLowerCase();
|
|
const val = getQuotaVal(id);
|
|
const isAlive = val > 0.01; // Treat < 1% as dead for priority purposes
|
|
|
|
// Hierarchy:
|
|
// 1. High Tier (Alive)
|
|
// 2. High Tier (Dead) - to warn user
|
|
// 3. Low Tier (Alive) - fallback
|
|
// 4. Low Tier (Dead)
|
|
|
|
if (/\bopus\b/.test(lower)) return isAlive ? 100 : 60;
|
|
if (/\bsonnet\b/.test(lower)) return isAlive ? 90 : 55;
|
|
// Gemini 3 Pro / Ultra
|
|
if (/\bgemini-3\b/.test(lower) && (/\bpro\b/.test(lower) || /\bultra\b/.test(lower))) return isAlive ? 80 : 50;
|
|
if (/\bpro\b/.test(lower)) return isAlive ? 75 : 45; // Other Pro models
|
|
|
|
// Mid/Low Tier
|
|
if (/\bhaiku\b/.test(lower)) return isAlive ? 30 : 15;
|
|
if (/\bflash\b/.test(lower)) return isAlive ? 20 : 10;
|
|
|
|
return isAlive ? 5 : 0;
|
|
};
|
|
|
|
// Sort by priority desc
|
|
validIds.sort((a, b) => getPriority(b) - getPriority(a));
|
|
|
|
const bestModel = validIds[0];
|
|
const val = getQuotaVal(bestModel);
|
|
|
|
return {
|
|
percent: Math.round(val * 100),
|
|
model: bestModel
|
|
};
|
|
}
|
|
});
|