feat(update): add a Buzz price alerts panel to the notification bell

P5a of docs/plans/paid-model-price-tracking.md: one surface that answers "what
got cheaper / became free", without a permanent button (the grid filter was
dropped by owner decision, so the panel carries the actions itself).

- price_alert_since records when an alert started, so the panel can say
  "dropped 3 d ago" and count what is new since the user last looked; it is set
  on the first sight of an already-cheap version, preserved while the alert
  stands, and cleared when the price rises back above the threshold
- get_price_alerts() compares the threshold at read time (editing it takes
  effect immediately, no refresh needed) and returns both kinds in one list;
  model_type=None covers every type, which the shared update DB makes a single
  query. "became free" needs no price data, so it is reported even while price
  tracking is off
- GET /api/lm/price-alerts, registered once in MiscRoutes rather than per model
  type, decorating rows best-effort with the local model name and file path from
  the scanner indexes (a cold cache just omits them)
- a third tab in the notification bell: segments for under-threshold and
  became-free, the three states (tracking off / nothing matching / stale), and
  per-row actions (CivitAI always, Open when the model is local)
- two non-permanent entry points share one helper: the controls-bar updates
  dropdown and the global context menu, whose label carries the unread count
- unread state stays client-side (localStorage watermark); the count is fetched
  once on init and only when price tracking is enabled
- the per-type frontend client method is removed as dead code; the per-type
  backend route stays for the companion extension
This commit is contained in:
Will Miao
2026-10-04 15:08:48 +08:00
parent ad2402724b
commit 7ed19c185c
27 changed files with 2142 additions and 86 deletions
-27
View File
@@ -1086,33 +1086,6 @@ export class BaseModelApiClient {
}
}
/**
* Versions whose stored price currently crosses the alert threshold.
* Backed by the persisted alert state, so it matches the version badges.
* @param {{limit?: number}} [options]
* @returns {Promise<{alerts: Array, thresholdBuzz: number, enabled: boolean}>}
*/
async getPriceAlerts({ limit = 200 } = {}) {
const endpoint = this.apiConfig?.endpoints?.priceAlerts;
if (!endpoint) {
return { alerts: [], thresholdBuzz: 0, enabled: false };
}
const response = await fetch(`${endpoint}?limit=${encodeURIComponent(limit)}`, {
method: 'GET'
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload?.success !== true) {
throw new Error(payload?.error || response.statusText || 'Failed to load price alerts');
}
return {
alerts: Array.isArray(payload.alerts) ? payload.alerts : [],
thresholdBuzz: payload.thresholdBuzz ?? 0,
enabled: payload.enabled === true
};
}
async refreshUpdatesForModels(modelIds, { force = false } = {}) {
if (!Array.isArray(modelIds) || modelIds.length === 0) {
throw new Error('No model IDs provided');