gravityyy-proxyyy / public /js /data-store.js
bardd's picture
Fix gemini-3.1-pro-high by routing to pro-low with thinkingLevel high.
4badc3b
Raw
History Blame Contribute Delete
22.4 kB
/**
* Data Store
* Holds Accounts, Models, and Computed Quota Rows
* Shared between Dashboard and AccountManager
*/
// utils is loaded globally as window.utils in utils.js
document.addEventListener('alpine:init', () => {
Alpine.store('data', {
accounts: [],
models: [], // Source of truth
modelConfig: {}, // Model metadata (hidden, pinned, alias)
quotaRows: [], // Filtered view
usageHistory: {}, // Usage statistics history (from /account-limits?includeHistory=true)
globalQuotaThreshold: 0, // Global minimum quota threshold (fraction 0-0.99)
maxAccounts: 10, // Maximum number of accounts allowed (from config)
devMode: false, // Developer mode flag (from server config)
placeholderMode: false, // Inject placeholder account data for UI testing
placeholderIncludeReal: true, // Include real accounts alongside placeholder data
_realAccounts: null, // Stash for real accounts when placeholder mode is on
_realModels: null, // Stash for real models when placeholder mode is on
loading: false,
initialLoad: true, // Track first load for skeleton screen
connectionStatus: 'connecting',
lastUpdated: '-',
healthCheckTimer: null,
// Filters state
filters: {
account: 'all',
family: 'all',
search: '',
sortCol: 'avgQuota',
sortAsc: true
},
// Settings for calculation
// We need to access global settings? Or duplicate?
// Let's assume settings are passed or in another store.
// For simplicity, let's keep relevant filters here.
init() {
// Restore from cache first for instant render
this.loadFromCache();
// Restore placeholder mode from persisted settings
// Read localStorage directly since settings store may not be initialized yet
try {
const saved = JSON.parse(localStorage.getItem('antigravity_settings') || '{}');
if (saved.placeholderMode) {
this.setPlaceholderMode(true, saved.placeholderIncludeReal !== false);
}
} catch (e) { /* ignore parse errors */ }
// Watch filters to recompute
// Alpine stores don't have $watch automatically unless inside a component?
// We can manually call compute when filters change.
// Start health check monitoring
this.startHealthCheck();
},
loadFromCache() {
try {
const cached = localStorage.getItem('ag_data_cache');
if (cached) {
const data = JSON.parse(cached);
const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
// Check TTL
if (data.timestamp && (Date.now() - data.timestamp > CACHE_TTL)) {
if (window.UILogger) window.UILogger.debug('Cache expired, skipping restoration');
localStorage.removeItem('ag_data_cache');
return;
}
// Basic validity check
if (data.accounts && data.models) {
this.accounts = data.accounts;
this.models = data.models;
this.modelConfig = data.modelConfig || {};
this.usageHistory = data.usageHistory || {};
// Don't show loading on initial load if we have cache
this.initialLoad = false;
this.computeQuotaRows();
if (window.UILogger) window.UILogger.debug('Restored data from cache');
}
}
} catch (e) {
if (window.UILogger) window.UILogger.debug('Failed to load cache', e.message);
}
},
saveToCache() {
try {
const cacheData = {
accounts: this.accounts,
models: this.models,
modelConfig: this.modelConfig,
usageHistory: this.usageHistory,
timestamp: Date.now()
};
localStorage.setItem('ag_data_cache', JSON.stringify(cacheData));
} catch (e) {
if (window.UILogger) window.UILogger.debug('Failed to save cache', e.message);
}
},
async fetchData() {
// Only show skeleton on initial load if we didn't restore from cache
if (this.initialLoad) {
this.loading = true;
}
try {
// Get password from global store
const password = Alpine.store('global').webuiPassword;
// Include history for dashboard (single API call optimization)
const url = '/account-limits?includeHistory=true';
const { response, newPassword } = await window.utils.request(url, {}, password);
if (newPassword) Alpine.store('global').webuiPassword = newPassword;
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
this.accounts = data.accounts || [];
if (data.models && data.models.length > 0) {
this.models = data.models;
}
this.modelConfig = data.modelConfig || {};
this.globalQuotaThreshold = data.globalQuotaThreshold || 0;
// Store usage history if included (for dashboard)
if (data.history) {
this.usageHistory = data.history;
}
this.saveToCache(); // Save fresh data
// Re-inject placeholder data if active
if (this.placeholderMode) {
this._realAccounts = [...this.accounts];
this._realModels = [...this.models];
const { accounts: fakeAccounts, models: fakeModels } = this._generatePlaceholderData();
if (this.placeholderIncludeReal) {
this.accounts = [...this._realAccounts, ...fakeAccounts];
const modelSet = new Set([...this._realModels, ...fakeModels]);
this.models = Array.from(modelSet).sort();
} else {
this.accounts = fakeAccounts;
this.models = fakeModels;
}
}
this.computeQuotaRows();
this.lastUpdated = new Date().toLocaleTimeString();
} catch (error) {
// Keep error logging for actual fetch failures
console.error('Fetch error:', error);
const store = Alpine.store('global');
store.showToast(store.t('connectionLost'), 'error');
} finally {
this.loading = false;
this.initialLoad = false; // Mark initial load as complete
}
},
async performHealthCheck() {
try {
// Get password from global store
const password = Alpine.store('global').webuiPassword;
// Use lightweight endpoint (no quota fetching)
const { response, newPassword } = await window.utils.request('/api/config', {}, password);
if (newPassword) Alpine.store('global').webuiPassword = newPassword;
if (response.ok) {
this.connectionStatus = 'connected';
// Update devMode from server config
try {
const data = await response.json();
if (data.config) {
this.devMode = !!data.config.devMode;
}
} catch (e) { /* ignore parse errors */ }
} else {
this.connectionStatus = 'disconnected';
}
} catch (error) {
console.error('Health check error:', error);
this.connectionStatus = 'disconnected';
}
},
startHealthCheck() {
// Clear existing timer
if (this.healthCheckTimer) {
clearInterval(this.healthCheckTimer);
}
// Setup visibility change listener (only once)
if (!this._healthVisibilitySetup) {
this._healthVisibilitySetup = true;
this._visibilityHandler = () => {
if (document.hidden) {
// Tab hidden - stop health checks
this.stopHealthCheck();
} else {
// Tab visible - restart health checks
this.startHealthCheck();
}
};
document.addEventListener('visibilitychange', this._visibilityHandler);
}
// Perform immediate health check
this.performHealthCheck();
// Schedule regular health checks every 15 seconds
this.healthCheckTimer = setInterval(() => {
// Only perform health check if tab is visible
if (!document.hidden) {
this.performHealthCheck();
}
}, 15000);
},
stopHealthCheck() {
if (this.healthCheckTimer) {
clearInterval(this.healthCheckTimer);
this.healthCheckTimer = null;
}
},
computeQuotaRows() {
const models = this.models || [];
const rows = [];
const showExhausted = Alpine.store('settings')?.showExhausted ?? true;
models.forEach(modelId => {
// Config
const config = this.modelConfig[modelId] || {};
const family = this.getModelFamily(modelId);
// Visibility Logic for Models Page (quotaRows):
// 1. If explicitly hidden via config, ALWAYS hide (clean interface)
// 2. If no config, default 'unknown' families to HIDDEN
// 3. Known families (Claude/Gemini) default to VISIBLE
// Note: To manage hidden models, use Settings → Models tab
let isHidden = config.hidden;
if (isHidden === undefined) {
isHidden = (family === 'other' || family === 'unknown');
}
// Models Page: Check settings for visibility
const showHidden = Alpine.store('settings')?.showHiddenModels ?? false;
if (isHidden && !showHidden) return;
// Filters
if (this.filters.family !== 'all' && this.filters.family !== family) return;
if (this.filters.search) {
const searchLower = this.filters.search.toLowerCase();
const idMatch = modelId.toLowerCase().includes(searchLower);
if (!idMatch) return;
}
// Data Collection
const quotaInfo = [];
let minQuota = 100;
let totalQuotaSum = 0;
let validAccountCount = 0;
let minResetTime = null;
let maxEffectiveThreshold = 0;
const globalThreshold = this.globalQuotaThreshold || 0;
this.accounts.forEach(acc => {
if (acc.enabled === false) return;
if (this.filters.account !== 'all' && acc.email !== this.filters.account) return;
const limit = acc.limits?.[modelId];
if (!limit) return;
const pct = limit.remainingFraction !== null ? Math.round(limit.remainingFraction * 100) : 0;
minQuota = Math.min(minQuota, pct);
// Accumulate for average
totalQuotaSum += pct;
validAccountCount++;
if (limit.resetTime && (!minResetTime || new Date(limit.resetTime) < new Date(minResetTime))) {
minResetTime = limit.resetTime;
}
// Resolve effective threshold: per-model > per-account > global
const accModelThreshold = acc.modelQuotaThresholds?.[modelId];
const accThreshold = acc.quotaThreshold;
const effective = accModelThreshold ?? accThreshold ?? globalThreshold;
if (effective > maxEffectiveThreshold) {
maxEffectiveThreshold = effective;
}
// Determine threshold source for display
let thresholdSource = 'global';
if (accModelThreshold !== undefined) thresholdSource = 'model';
else if (accThreshold !== undefined) thresholdSource = 'account';
quotaInfo.push({
email: acc.email.split('@')[0],
fullEmail: acc.email,
pct: pct,
resetTime: limit.resetTime,
thresholdPct: Math.round(effective * 100),
thresholdSource
});
});
if (quotaInfo.length === 0) return;
const avgQuota = validAccountCount > 0 ? Math.round(totalQuotaSum / validAccountCount) : 0;
if (!showExhausted && minQuota === 0) return;
// Check if thresholds vary across accounts
const uniqueThresholds = new Set(quotaInfo.map(q => q.thresholdPct));
const hasVariedThresholds = uniqueThresholds.size > 1;
rows.push({
modelId,
displayName: modelId, // Simplified: no longer using alias
family,
minQuota,
avgQuota, // Added Average Quota
minResetTime,
resetIn: minResetTime ? window.utils.formatTimeUntil(minResetTime) : '-',
quotaInfo,
pinned: !!config.pinned,
hidden: !!isHidden, // Use computed visibility
activeCount: quotaInfo.filter(q => q.pct > 0).length,
effectiveThresholdPct: Math.round(maxEffectiveThreshold * 100),
hasVariedThresholds
});
});
// Sort: Pinned first, then by selected column
const sortCol = this.filters.sortCol;
const sortAsc = this.filters.sortAsc;
this.quotaRows = rows.sort((a, b) => {
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
let valA = a[sortCol];
let valB = b[sortCol];
// Handle nulls (always push to bottom)
if (valA === valB) return 0;
if (valA === null || valA === undefined) return 1;
if (valB === null || valB === undefined) return -1;
if (typeof valA === 'string' && typeof valB === 'string') {
return sortAsc ? valA.localeCompare(valB) : valB.localeCompare(valA);
}
return sortAsc ? valA - valB : valB - valA;
});
// Trigger Dashboard Update if active
// Ideally dashboard watches this store.
},
setSort(col) {
if (this.filters.sortCol === col) {
this.filters.sortAsc = !this.filters.sortAsc;
} else {
this.filters.sortCol = col;
// Default sort direction: Descending for numbers/stats, Ascending for text/time
if (['avgQuota', 'activeCount'].includes(col)) {
this.filters.sortAsc = false;
} else {
this.filters.sortAsc = true;
}
}
this.computeQuotaRows();
},
getModelFamily(modelId) {
const lower = modelId.toLowerCase();
if (lower.includes('claude')) return 'claude';
if (lower.includes('gemini')) return 'gemini';
return 'other';
},
/**
* Get quota data without filters applied (for Dashboard global charts)
* Returns array of { modelId, family, quotaInfo: [{pct}] }
*/
getUnfilteredQuotaData() {
const models = this.models || [];
const rows = [];
const showHidden = Alpine.store('settings')?.showHiddenModels ?? false;
models.forEach(modelId => {
const config = this.modelConfig[modelId] || {};
const family = this.getModelFamily(modelId);
// Smart visibility (same logic as computeQuotaRows)
let isHidden = config.hidden;
if (isHidden === undefined) {
isHidden = (family === 'other' || family === 'unknown');
}
if (isHidden && !showHidden) return;
const quotaInfo = [];
// Use ALL accounts (no account filter)
this.accounts.forEach(acc => {
if (acc.enabled === false) return;
const limit = acc.limits?.[modelId];
if (!limit) return;
const pct = limit.remainingFraction !== null ? Math.round(limit.remainingFraction * 100) : 0;
quotaInfo.push({ pct });
});
// treat missing quotaInfo as 0%/unknown; still include row
rows.push({ modelId, family, quotaInfo });
});
return rows;
},
/**
* Generate placeholder account and model data for UI testing
*/
_generatePlaceholderData() {
const models = [
'claude-opus-4-6-thinking',
'claude-sonnet-4-6',
'gemini-3.1-pro-high',
'gemini-3.1-pro-low',
'gemini-3-flash',
'gemini-3.1-flash-lite'
];
const tiers = ['ultra', 'pro', 'pro', 'free'];
const names = ['alice', 'bob', 'charlie', 'diana'];
const domains = ['workspace.dev', 'company.io', 'example.org', 'test.net'];
const accounts = names.map((name, i) => {
const email = `${name}@${domains[i]}`;
const tier = tiers[i];
// Generate varied quota per model per account
const limits = {};
models.forEach((modelId, mi) => {
// Create a deterministic but varied fraction
const seed = ((i * 7 + mi * 13) % 100);
const fraction = seed < 10 ? 0 : seed / 100;
const resetTime = fraction === 0
? new Date(Date.now() + (30 + i * 15) * 60000).toISOString()
: null;
limits[modelId] = {
remaining: Math.round(fraction * 100) + '%',
remainingFraction: fraction,
resetTime
};
});
return {
email,
status: i === 3 ? 'invalid' : 'ok',
error: i === 3 ? 'Token expired' : null,
source: i === 0 ? 'database' : 'oauth',
enabled: i !== 2 ? true : false,
projectId: `proj-${name}-${1000 + i}`,
isInvalid: i === 3,
invalidReason: i === 3 ? 'Token expired' : null,
lastUsed: new Date(Date.now() - i * 3600000).toISOString(),
modelRateLimits: {},
quotaThreshold: i === 1 ? 0.15 : undefined,
modelQuotaThresholds: i === 0 ? { 'claude-opus-4-6-thinking': 0.25 } : {},
subscription: { tier, projectId: `proj-${name}-${1000 + i}`, detectedAt: Date.now() },
limits
};
});
return { accounts, models };
},
/**
* Enable or disable placeholder data injection
*/
setPlaceholderMode(enabled, includeReal) {
this.placeholderMode = enabled;
this.placeholderIncludeReal = includeReal;
// Persist to settings store
const settings = Alpine.store('settings');
if (settings) {
settings.placeholderMode = enabled;
settings.placeholderIncludeReal = includeReal;
settings.saveSettings(true);
}
if (enabled) {
// Stash real data
this._realAccounts = [...this.accounts];
this._realModels = [...this.models];
const { accounts: fakeAccounts, models: fakeModels } = this._generatePlaceholderData();
if (includeReal && this._realAccounts.length > 0) {
// Merge: real accounts first, then placeholders
this.accounts = [...this._realAccounts, ...fakeAccounts];
// Union of models
const modelSet = new Set([...this._realModels, ...fakeModels]);
this.models = Array.from(modelSet).sort();
} else {
this.accounts = fakeAccounts;
this.models = fakeModels;
}
} else {
// Restore real data
if (this._realAccounts !== null) {
this.accounts = this._realAccounts;
this._realAccounts = null;
}
if (this._realModels !== null) {
this.models = this._realModels;
this._realModels = null;
}
}
this.computeQuotaRows();
}
});
});