/** * Models Component * Displays model quota/status list * Registers itself to window.Components for Alpine.js to consume */ window.Components = window.Components || {}; window.Components.models = () => ({ // Color palette for per-account threshold markers thresholdColors: [ { bg: '#eab308', shadow: 'rgba(234,179,8,0.5)' }, // yellow { bg: '#06b6d4', shadow: 'rgba(6,182,212,0.5)' }, // cyan { bg: '#a855f7', shadow: 'rgba(168,85,247,0.5)' }, // purple { bg: '#22c55e', shadow: 'rgba(34,197,94,0.5)' }, // green { bg: '#ef4444', shadow: 'rgba(239,68,68,0.5)' }, // red { bg: '#f97316', shadow: 'rgba(249,115,22,0.5)' }, // orange { bg: '#ec4899', shadow: 'rgba(236,72,153,0.5)' }, // pink { bg: '#8b5cf6', shadow: 'rgba(139,92,246,0.5)' }, // violet ], getThresholdColor(index) { return this.thresholdColors[index % this.thresholdColors.length]; }, // Drag state for threshold markers dragging: { active: false, email: null, modelId: null, barRect: null, currentPct: 0, originalPct: 0 }, // Model editing state (from main) editingModelId: null, newMapping: '', isEditing(modelId) { return this.editingModelId === modelId; }, startEditing(modelId) { this.editingModelId = modelId; }, stopEditing() { this.editingModelId = null; }, /** * Start dragging a threshold marker */ startDrag(event, q, row) { // Find the progress bar element (closest .relative container) const markerEl = event.currentTarget; const barContainer = markerEl.parentElement; const barRect = barContainer.getBoundingClientRect(); this.dragging = { active: true, email: q.fullEmail, modelId: row.modelId, barRect, currentPct: q.thresholdPct, originalPct: q.thresholdPct }; // Prevent text selection while dragging document.body.classList.add('select-none'); // Bind document-level listeners for smooth dragging outside the marker this._onDrag = (e) => this.onDrag(e); this._endDrag = () => this.endDrag(); document.addEventListener('mousemove', this._onDrag); document.addEventListener('mouseup', this._endDrag); document.addEventListener('touchmove', this._onDrag, { passive: false }); document.addEventListener('touchend', this._endDrag); }, /** * Handle drag movement — compute percentage from mouse position */ onDrag(event) { if (!this.dragging.active) return; event.preventDefault(); const clientX = event.touches ? event.touches[0].clientX : event.clientX; const { left, width } = this.dragging.barRect; let pct = Math.round((clientX - left) / width * 100); pct = Math.max(0, Math.min(99, pct)); this.dragging.currentPct = pct; }, /** * End drag — save the new threshold value */ endDrag() { if (!this.dragging.active) return; // Clean up listeners document.removeEventListener('mousemove', this._onDrag); document.removeEventListener('mouseup', this._endDrag); document.removeEventListener('touchmove', this._onDrag); document.removeEventListener('touchend', this._endDrag); document.body.classList.remove('select-none'); const { email, modelId, currentPct, originalPct } = this.dragging; // Only save if value actually changed if (currentPct !== originalPct) { // Optimistic in-place update: mutate existing quotaInfo entries directly // to avoid full DOM rebuild from computeQuotaRows() const dataStore = Alpine.store('data'); const account = dataStore.accounts.find(a => a.email === email); if (account) { if (!account.modelQuotaThresholds) account.modelQuotaThresholds = {}; if (currentPct === 0) { delete account.modelQuotaThresholds[modelId]; } else { account.modelQuotaThresholds[modelId] = currentPct / 100; } } // Patch quotaRows in-place so Alpine updates without tearing down DOM const rows = dataStore.quotaRows || []; for (const row of rows) { if (row.modelId !== modelId) continue; for (const q of row.quotaInfo) { if (q.fullEmail !== email) continue; q.thresholdPct = currentPct; } // Recompute row-level threshold stats const activePcts = row.quotaInfo.map(q => q.thresholdPct).filter(t => t > 0); row.effectiveThresholdPct = activePcts.length > 0 ? Math.max(...activePcts) : 0; row.hasVariedThresholds = new Set(activePcts).size > 1; } this.dragging.active = false; this.saveModelThreshold(email, modelId, currentPct); } else { this.dragging.active = false; } }, /** * Save a per-model threshold for an account via PATCH */ async saveModelThreshold(email, modelId, pct) { const store = Alpine.store('global'); const dataStore = Alpine.store('data'); const account = dataStore.accounts.find(a => a.email === email); if (!account) return; // Snapshot for rollback on failure const previousModelThresholds = account.modelQuotaThresholds ? { ...account.modelQuotaThresholds } : {}; // Build full modelQuotaThresholds for API (full replacement, not merge) const existingModelThresholds = { ...(account.modelQuotaThresholds || {}) }; // Preserve the account-level quotaThreshold const quotaThreshold = account.quotaThreshold !== undefined ? account.quotaThreshold : null; try { const { response, newPassword } = await window.utils.request( `/api/accounts/${encodeURIComponent(email)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ quotaThreshold, modelQuotaThresholds: existingModelThresholds }) }, store.webuiPassword ); if (newPassword) store.webuiPassword = newPassword; const data = await response.json(); if (data.status === 'ok') { const label = pct === 0 ? 'removed' : pct + '%'; store.showToast(`${email.split('@')[0]} ${modelId} threshold: ${label}`, 'success'); // Skip fetchData() — optimistic update is already applied, // next polling cycle will sync server state } else { throw new Error(data.error || 'Failed to save threshold'); } } catch (e) { // Revert optimistic update on failure account.modelQuotaThresholds = previousModelThresholds; dataStore.computeQuotaRows(); store.showToast('Failed to save threshold: ' + e.message, 'error'); } }, /** * Check if a specific marker is currently being dragged */ isDragging(q, row) { return this.dragging.active && this.dragging.email === q.fullEmail && this.dragging.modelId === row.modelId; }, /** * Get the display percentage for a marker (live during drag, stored otherwise) */ getMarkerPct(q, row) { if (this.isDragging(q, row)) return this.dragging.currentPct; return q.thresholdPct; }, /** * Compute pixel offset for overlapping markers so stacked ones fan out. * Markers within 2% of each other are considered overlapping. * Returns a CSS pixel offset string (e.g., '6px' or '-6px'). */ getMarkerOffset(q, row, qIdx) { const pct = this.getMarkerPct(q, row); const visible = row.quotaInfo.filter(item => item.thresholdPct > 0 || this.isDragging(item, row)); // Find all markers within 2% of this one const cluster = []; visible.forEach((item, idx) => { const itemPct = this.getMarkerPct(item, row); if (Math.abs(itemPct - pct) <= 2) { cluster.push({ item, idx }); } }); if (cluster.length <= 1) return '0px'; // Find position of this marker within its cluster const posInCluster = cluster.findIndex(c => c.item.fullEmail === q.fullEmail); // Spread markers 10px apart, centered on the base position const spread = 10; const totalWidth = (cluster.length - 1) * spread; return (posInCluster * spread - totalWidth / 2) + 'px'; }, init() { // Ensure data is fetched when this tab becomes active (skip initial trigger) this.$watch('$store.global.activeTab', (val, oldVal) => { if (val === 'models' && oldVal !== undefined) { // Trigger recompute to ensure filters are applied this.$nextTick(() => { Alpine.store('data').computeQuotaRows(); }); } }); // Initial compute if already on models tab if (this.$store.global.activeTab === 'models') { this.$nextTick(() => { Alpine.store('data').computeQuotaRows(); }); } }, /** * Update model configuration (delegates to shared utility) * @param {string} modelId - The model ID to update * @param {object} configUpdates - Configuration updates (pinned, hidden) */ async updateModelConfig(modelId, configUpdates) { return window.ModelConfigUtils.updateModelConfig(modelId, configUpdates); } });