@goodandready/dsh-cost-meter 0.8.2 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -13,6 +13,7 @@ window.__ModuleLoader__.load({
13
13
  const NS = 'dsh-cost-meter'
14
14
  const STATE_PATH = '/dsh-cost-meter/state'
15
15
  const REFRESH_PATH = '/dsh-cost-meter/refresh'
16
+ const UPDATE_PATH = '/dsh-cost-meter/update'
16
17
  const DAY = 1440
17
18
 
18
19
  // ----------------------------------------------------------- localization
@@ -22,6 +23,13 @@ window.__ModuleLoader__.load({
22
23
  subtitle: 'Model rates, peak discount windows and session spend',
23
24
  'header.title': 'Cost Meter',
24
25
  'header.sub': 'Token spend monitoring, automatic tariff resolution and discount windows',
26
+ 'update.checking': 'Checking for updates…',
27
+ 'update.up_to_date': 'Up to date',
28
+ 'update.available': 'Update available: v{latestVersion} (current: v{currentVersion})',
29
+ 'update.btn': 'Update Now',
30
+ 'update.updating': 'Updating…',
31
+ 'update.done': 'Successfully updated to v{version}! Please restart DSH.',
32
+ 'update.failed': 'Update failed: {error}',
25
33
  'badge.catalog_ok': 'Catalog: {count} models',
26
34
  'badge.catalog_empty': 'Catalog: loading…',
27
35
  'badge.catalog_off': 'Catalog: disabled',
@@ -93,6 +101,13 @@ window.__ModuleLoader__.load({
93
101
  subtitle: '模型费率、高峰优惠窗口与会话消费统计',
94
102
  'header.title': '成本计量器 (Cost Meter)',
95
103
  'header.sub': 'Token 消费监控、费率自动匹配与折扣窗口管理',
104
+ 'update.checking': '正在检查更新…',
105
+ 'update.up_to_date': '已是最新版本',
106
+ 'update.available': '发现新版本 v{latestVersion}(当前版本 v{currentVersion})',
107
+ 'update.btn': '立即更新',
108
+ 'update.updating': '正在更新…',
109
+ 'update.done': '更新成功至 v{version}!请重启 DSH 服务生效。',
110
+ 'update.failed': '更新失败:{error}',
96
111
  'badge.catalog_ok': '目录:{count} 个模型',
97
112
  'badge.catalog_empty': '目录:加载中…',
98
113
  'badge.catalog_off': '目录:已禁用',
@@ -200,19 +215,22 @@ window.__ModuleLoader__.load({
200
215
  .dcm-grid-2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; }
201
216
  .dcm-field { display: flex; flex-direction: column; gap: 6px; }
202
217
  .dcm-row-check { display: flex; align-items: center; gap: 10px; padding: 4px 0; }
203
- .dcm-check { width: 16px; height: 16px; accent-color: var(--dsw-alias-state-brand-primary, #6366f1); cursor: pointer; }
218
+ .dcm-check { width: 16px; height: 16px; accent-color: var(--dsw-alias-state-brand-primary); cursor: pointer; }
204
219
  .dcm-label { color: var(--dsw-alias-label-primary); font-size: 13px; font-weight: 500; }
205
220
  .dcm-hint { color: var(--dsw-alias-label-secondary); font-size: 12px; line-height: 1.4; }
206
221
 
207
222
  .dcm-input { height: 34px; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-3); color: var(--dsw-alias-label-primary); border-radius: 8px; padding: 0 12px; font-size: 13px; width: 100%; box-sizing: border-box; }
208
- .dcm-input:focus { outline: none; border-color: var(--dsw-alias-state-brand-primary, #6366f1); }
209
- .dcm-input-err { border-color: var(--dsw-alias-state-error-primary, #ef4444); }
223
+ .dcm-input:focus { outline: none; border-color: var(--dsw-alias-state-brand-primary); }
224
+ .dcm-input-err { border-color: var(--dsw-alias-state-error-primary); }
225
+
226
+ .dcm-chevron { display: inline-flex; align-items: center; justify-content: center; transition: transform .16s ease; color: var(--dsw-alias-label-tertiary); }
227
+ .dcm-chevron-open { transform: rotate(180deg); }
210
228
 
211
229
  .dcm-badges { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 4px; }
212
230
  .dcm-badge { font-size: 11px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--dsw-alias-border-l2); display: inline-flex; align-items: center; gap: 4px; font-weight: 500; }
213
- .dcm-badge-ok { border-color: var(--dsw-alias-state-success-primary); color: var(--dsw-alias-state-success-primary); background: rgba(16,185,129,0.08); }
214
- .dcm-badge-warn { border-color: var(--dsw-alias-state-warning-primary); color: var(--dsw-alias-state-warning-primary); background: rgba(245,158,11,0.08); }
215
- .dcm-badge-bad { border-color: var(--dsw-alias-state-error-primary); color: var(--dsw-alias-state-error-primary); background: rgba(239,68,68,0.08); }
231
+ .dcm-badge-ok { border-color: var(--dsw-alias-state-success-primary); color: var(--dsw-alias-state-success-primary); background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 8%, transparent); }
232
+ .dcm-badge-warn { border-color: var(--dsw-alias-state-warning-primary); color: var(--dsw-alias-state-warning-primary); background: color-mix(in srgb, var(--dsw-alias-state-warning-primary) 8%, transparent); }
233
+ .dcm-badge-bad { border-color: var(--dsw-alias-state-error-primary); color: var(--dsw-alias-state-error-primary); background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent); }
216
234
 
217
235
  .dcm-btn { appearance: none; font: inherit; cursor: pointer; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; padding: 6px 14px; font-size: 13px; background: var(--dsw-alias-bg-layer-3); color: var(--dsw-alias-label-primary); font-weight: 500; display: inline-flex; align-items: center; justify-content: center; gap: 6px; transition: all .15s ease; }
218
236
  .dcm-btn:hover:not(:disabled) { background: var(--dsw-alias-bg-layer-4, var(--dsw-alias-bg-layer-3)); }
@@ -222,10 +240,10 @@ window.__ModuleLoader__.load({
222
240
  .dcm-btn-sm { padding: 4px 10px; font-size: 12px; border-radius: 6px; }
223
241
 
224
242
  .dcm-alert { padding: 10px 14px; border-radius: 8px; font-size: 13px; line-height: 1.4; }
225
- .dcm-alert-ok { background: rgba(16,185,129,0.1); color: var(--dsw-alias-state-success-primary); }
226
- .dcm-alert-err { background: rgba(239,68,68,0.1); color: var(--dsw-alias-state-error-primary); }
227
- .dcm-tip-box { padding: 8px 12px; border-radius: 8px; background: rgba(99,102,241,0.08); border: 1px solid rgba(99,102,241,0.2); color: var(--dsw-alias-label-primary); font-size: 12px; line-height: 1.4; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
228
- .dcm-cache-box { padding: 7px 11px; border-radius: 8px; background: rgba(16,185,129,0.08); border: 1px solid rgba(16,185,129,0.22); color: var(--dsw-alias-state-success-primary); font-size: 11.5px; font-weight: 500; line-height: 1.4; font-variant-numeric: tabular-nums; }
243
+ .dcm-alert-ok { background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent); color: var(--dsw-alias-state-success-primary); }
244
+ .dcm-alert-err { background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent); color: var(--dsw-alias-state-error-primary); }
245
+ .dcm-tip-box { padding: 8px 12px; border-radius: 8px; background: color-mix(in srgb, var(--dsw-alias-state-brand-primary) 8%, transparent); border: 1px solid color-mix(in srgb, var(--dsw-alias-state-brand-primary) 20%, transparent); color: var(--dsw-alias-label-primary); font-size: 12px; line-height: 1.4; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
246
+ .dcm-cache-box { padding: 7px 11px; border-radius: 8px; background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 8%, transparent); border: 1px solid color-mix(in srgb, var(--dsw-alias-state-success-primary) 22%, transparent); color: var(--dsw-alias-state-success-primary); font-size: 11.5px; font-weight: 500; line-height: 1.4; font-variant-numeric: tabular-nums; }
229
247
 
230
248
  .dcm-stat-box { padding: 10px 12px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; background: var(--dsw-alias-bg-layer-3); display: flex; flex-direction: column; gap: 2px; }
231
249
  .dcm-stat-val { font-size: 14px; font-weight: 600; color: var(--dsw-alias-label-primary); font-variant-numeric: tabular-nums; }
@@ -234,13 +252,13 @@ window.__ModuleLoader__.load({
234
252
  .dcm-foot { display: flex; justify-content: flex-end; align-items: center; gap: 10px; padding-top: 6px; }
235
253
 
236
254
  /* Native dot-indicator chip */
237
- .dcm-chip { display: inline-flex; align-items: center; gap: 6px; height: 24px; padding: 0 9px; border-radius: 7px; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-2, rgba(255,255,255,0.05)); color: var(--dsw-alias-label-primary); cursor: pointer; font: inherit; font-size: 12px; font-variant-numeric: tabular-nums; line-height: 24px; transition: all .15s ease; user-select: none; }
238
- .dcm-chip:hover { border-color: var(--dsw-alias-border-l3, var(--dsw-alias-label-tertiary)); background: var(--dsw-alias-bg-layer-3, rgba(255,255,255,0.08)); }
255
+ .dcm-chip { display: inline-flex; align-items: center; gap: 6px; height: 24px; padding: 0 9px; border-radius: 7px; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-2); color: var(--dsw-alias-label-primary); cursor: pointer; font: inherit; font-size: 12px; font-variant-numeric: tabular-nums; line-height: 24px; transition: all .15s ease; user-select: none; }
256
+ .dcm-chip:hover { border-color: var(--dsw-alias-border-l3, var(--dsw-alias-label-tertiary)); background: var(--dsw-alias-bg-layer-3); }
239
257
  .dcm-dot { width: 6.5px; height: 6.5px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
240
- .dcm-dot-ok { background: var(--dsw-alias-state-success-primary, #10b981); box-shadow: 0 0 5px rgba(16,185,129,0.5); }
241
- .dcm-dot-warn { background: var(--dsw-alias-state-warning-primary, #f59e0b); box-shadow: 0 0 5px rgba(245,158,11,0.4); }
242
- .dcm-dot-err { background: var(--dsw-alias-state-error-primary, #ef4444); box-shadow: 0 0 6px rgba(239,68,68,0.7); animation: dcm-pulse 1.8s infinite ease-in-out; }
243
- .dcm-dot-idle { background: var(--dsw-alias-label-tertiary, #9ca3af); }
258
+ .dcm-dot-ok { background: var(--dsw-alias-state-success-primary); box-shadow: 0 0 5px color-mix(in srgb, var(--dsw-alias-state-success-primary) 50%, transparent); }
259
+ .dcm-dot-warn { background: var(--dsw-alias-state-warning-primary); box-shadow: 0 0 5px color-mix(in srgb, var(--dsw-alias-state-warning-primary) 40%, transparent); }
260
+ .dcm-dot-err { background: var(--dsw-alias-state-error-primary); box-shadow: 0 0 6px color-mix(in srgb, var(--dsw-alias-state-error-primary) 70%, transparent); animation: dcm-pulse 1.8s infinite ease-in-out; }
261
+ .dcm-dot-idle { background: var(--dsw-alias-label-tertiary); }
244
262
 
245
263
  .dcm-chip-timer { opacity: 0.72; font-size: 11px; padding-left: 2px; border-left: 1px solid var(--dsw-alias-border-l2); }
246
264
  @media (max-width: 680px) {
@@ -249,7 +267,7 @@ window.__ModuleLoader__.load({
249
267
 
250
268
  @keyframes dcm-pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.4; transform: scale(0.85); } }
251
269
 
252
- .dcm-panel { position: absolute; top: 30px; right: 0; z-index: 40; width: 336px; padding: 14px 16px; border-radius: 12px; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-2, var(--dsw-alias-bg-base)); box-shadow: 0 10px 32px rgba(0,0,0,.32); font-size: 12px; line-height: 1.5; color: var(--dsw-alias-label-primary); text-align: left; font-variant-numeric: tabular-nums; }
270
+ .dcm-panel { position: absolute; top: 30px; right: 0; z-index: 40; width: 336px; padding: 14px 16px; border-radius: 12px; border: 1px solid var(--dsw-alias-border-l2); background: var(--dsw-alias-bg-layer-2, var(--dsw-alias-bg-base)); box-shadow: var(--dsw-alias-shadow-layer-3, 0 10px 32px var(--dsw-alias-bg-base)); font-size: 12px; line-height: 1.5; color: var(--dsw-alias-label-primary); text-align: left; font-variant-numeric: tabular-nums; }
253
271
  .dcm-panel-title { font-weight: 600; padding-bottom: 2px; font-size: 13.5px; display: flex; justify-content: space-between; align-items: baseline; }
254
272
  .dcm-panel-note { color: var(--dsw-alias-label-tertiary); font-size: 12px; }
255
273
  .dcm-hr { height: 1px; background: var(--dsw-alias-border-l2); margin: 10px -16px; border: 0; }
@@ -264,7 +282,7 @@ window.__ModuleLoader__.load({
264
282
  .dcm-cost { text-align: right; padding: 3.5px 0; font-weight: 500; color: var(--dsw-alias-label-primary); }
265
283
  .dcm-section-lbl { color: var(--dsw-alias-label-tertiary); padding-bottom: 4px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; }
266
284
  .dcm-total { display: flex; justify-content: space-between; font-weight: 600; font-size: 13.5px; }
267
- .dcm-accordion-btn { appearance: none; background: transparent; border: 0; color: var(--dsw-alias-state-brand-primary, #6366f1); font: inherit; font-size: 11.5px; cursor: pointer; padding: 2px 0; text-align: left; }
285
+ .dcm-accordion-btn { appearance: none; background: transparent; border: 0; color: var(--dsw-alias-state-brand-primary); font: inherit; font-size: 11.5px; cursor: pointer; padding: 2px 0; text-align: left; }
268
286
  .dcm-accordion-btn:hover { text-decoration: underline; }
269
287
  `
270
288
 
@@ -318,51 +336,83 @@ window.__ModuleLoader__.load({
318
336
  }
319
337
  const ErrorBoundary = createErrorBoundary()
320
338
 
339
+ function getSettingsScope(ctx) {
340
+ return (ctx && typeof ctx.get === 'function' ? ctx.get('settingsScope') : null) || (ctx && ctx.settingsScope) || (ctx && ctx.services && ctx.services.settingsScope) || null
341
+ }
342
+
321
343
  function refreshMirrorUntilVisible(ctx) {
344
+ const s = getSettingsScope(ctx)
322
345
  const visible = () => {
323
346
  try {
324
- const s = (ctx?.get && ctx.get('lanSettings')) || (ctx?.get && ctx.get('settingsScope')) || ctx?.settingsScope
325
347
  const view = s?.describe?.()?.getSnapshot?.()?.view
326
348
  return !!view && Array.isArray(view.namespaces) && view.namespaces.some((row) => row.ns === NS)
327
- } catch (_) {
349
+ } catch (e) {
350
+ if (ctx?.logger?.debug) ctx.logger.debug('[dsh-cost-meter] mirror check error: ' + (e?.message || e))
328
351
  return false
329
352
  }
330
353
  }
331
- if (visible()) return () => {}
354
+ if (!s || visible()) return () => {}
332
355
  let tries = 0
333
356
  const timer = setInterval(() => {
334
- if (visible() || tries >= 15) { clearInterval(timer); return }
357
+ if (visible() || tries >= 15) {
358
+ if (!visible() && tries >= 15 && ctx?.logger?.warn) {
359
+ ctx.logger.warn('[dsh-cost-meter] settings mirror did not appear after 15 attempts')
360
+ }
361
+ clearInterval(timer)
362
+ return
363
+ }
335
364
  tries += 1
336
365
  try {
337
- const s = (ctx?.get && ctx.get('lanSettings')) || (ctx?.get && ctx.get('settingsScope')) || ctx?.settingsScope
338
366
  s?.describe?.()?.load?.()
339
- } catch (_) {}
367
+ } catch (e) {
368
+ if (ctx?.logger?.debug) ctx.logger.debug('[dsh-cost-meter] settings load error: ' + (e?.message || e))
369
+ }
340
370
  }, 1000)
341
371
  return () => clearInterval(timer)
342
372
  }
343
373
 
344
- function Chevron(props) {
374
+ let ChevronIcon = null
375
+ try {
376
+ const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
377
+ ChevronIcon = primitives && (primitives.IconChevronDownOutline14 || primitives.IconChevronDownOutline)
378
+ } catch (e) {
379
+ ChevronIcon = null
380
+ }
381
+
382
+ function FallbackChevron(props) {
345
383
  return React.createElement(
346
384
  'svg',
347
385
  {
348
- width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none',
349
- style: {
350
- marginLeft: 'auto',
351
- transform: props.open ? 'rotate(180deg)' : 'none',
352
- transition: 'transform .18s ease',
353
- color: 'var(--dsw-alias-label-secondary)',
354
- },
386
+ width: 14,
387
+ height: 14,
388
+ viewBox: '0 0 14 14',
389
+ fill: 'none',
390
+ 'aria-hidden': true,
355
391
  },
356
392
  React.createElement('path', {
357
- d: 'M4 6L8 10L12 6',
393
+ d: 'M3.5 5.25L7 8.75L10.5 5.25',
358
394
  stroke: 'currentColor',
359
- strokeWidth: 1.6,
395
+ strokeWidth: 1.5,
360
396
  strokeLinecap: 'round',
361
397
  strokeLinejoin: 'round',
362
398
  }),
363
399
  )
364
400
  }
365
401
 
402
+ function Chevron(props) {
403
+ const icon = ChevronIcon
404
+ ? React.createElement(ChevronIcon, { 'aria-hidden': true })
405
+ : React.createElement(FallbackChevron, null)
406
+ return React.createElement(
407
+ 'span',
408
+ {
409
+ className: 'dcm-chevron' + (props.open ? ' dcm-chevron-open' : ''),
410
+ style: { marginLeft: 'auto' },
411
+ },
412
+ icon,
413
+ )
414
+ }
415
+
366
416
  function isValidTimeZone(tz) {
367
417
  if (!tz || typeof tz !== 'string') return false
368
418
  try {
@@ -400,7 +450,72 @@ window.__ModuleLoader__.load({
400
450
  const [syncing, setSyncing] = React.useState(false)
401
451
  const [syncMsg, setSyncMsg] = React.useState('')
402
452
 
403
- const settingsScope = (ctx && typeof ctx.get === 'function' ? ctx.get('settingsScope') : null) || (ctx && ctx.settingsScope) || (ctx && ctx.services && ctx.services.settingsScope)
453
+ // Plugin in-app updater state
454
+ const [updateState, setUpdateState] = React.useState({
455
+ checking: false,
456
+ updating: false,
457
+ currentVersion: '0.8.3',
458
+ latestVersion: '',
459
+ updateAvailable: false,
460
+ canAutoUpdate: true,
461
+ error: '',
462
+ notice: '',
463
+ })
464
+
465
+ const checkUpdate = React.useCallback(async () => {
466
+ setUpdateState((s) => ({ ...s, checking: true, error: '' }))
467
+ try {
468
+ const res = await fetch(UPDATE_PATH)
469
+ if (!res.ok) throw new Error('HTTP ' + res.status)
470
+ const data = await res.json().catch(() => ({}))
471
+ setUpdateState((s) => ({
472
+ ...s,
473
+ checking: false,
474
+ currentVersion: data.currentVersion || s.currentVersion,
475
+ latestVersion: data.latestVersion || '',
476
+ updateAvailable: Boolean(data.updateAvailable),
477
+ canAutoUpdate: data.canAutoUpdate !== false,
478
+ }))
479
+ } catch (e) {
480
+ setUpdateState((s) => ({ ...s, checking: false }))
481
+ }
482
+ }, [])
483
+
484
+ React.useEffect(() => {
485
+ if (open) checkUpdate()
486
+ }, [open, checkUpdate])
487
+
488
+ async function handleTriggerUpdate() {
489
+ if (updateState.updating) return
490
+ setUpdateState((s) => ({ ...s, updating: true, error: '', notice: '' }))
491
+ try {
492
+ const res = await fetch(UPDATE_PATH, {
493
+ method: 'POST',
494
+ headers: { 'x-dsh-plugin-update': '1' },
495
+ })
496
+ const data = await res.json().catch(() => ({}))
497
+ if (!res.ok || data.ok === false || data.error) {
498
+ throw new Error(data.error || ('HTTP ' + res.status))
499
+ }
500
+ const newVer = data.updatedVersion || updateState.latestVersion || updateState.currentVersion
501
+ setUpdateState((s) => ({
502
+ ...s,
503
+ updating: false,
504
+ updateAvailable: false,
505
+ currentVersion: newVer,
506
+ notice: t('update.done', { version: newVer }),
507
+ }))
508
+ setTimeout(() => checkUpdate(), 2000)
509
+ } catch (err) {
510
+ setUpdateState((s) => ({
511
+ ...s,
512
+ updating: false,
513
+ error: t('update.failed', { error: String(err.message || err) }),
514
+ }))
515
+ }
516
+ }
517
+
518
+ const settingsScope = getSettingsScope(ctx)
404
519
  const scope = React.useMemo(
405
520
  () => (settingsScope ? settingsScope.bind({ namespace: NS }) : undefined),
406
521
  [settingsScope],
@@ -566,6 +681,38 @@ window.__ModuleLoader__.load({
566
681
  : status !== 'ready'
567
682
  ? React.createElement('div', { className: 'dcm-alert dcm-alert-err' }, t('settings.unavailable'))
568
683
  : React.createElement(React.Fragment, null,
684
+ // In-app Update Bar
685
+ React.createElement('div', {
686
+ className: 'dcm-section-card',
687
+ style: { padding: '10px 14px', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
688
+ },
689
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 } },
690
+ React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)', fontWeight: 500 } },
691
+ 'v' + updateState.currentVersion
692
+ ),
693
+ updateState.checking
694
+ ? React.createElement('span', { style: { color: 'var(--dsw-alias-label-tertiary)', fontSize: 12 } }, t('update.checking'))
695
+ : updateState.updateAvailable
696
+ ? React.createElement('span', { className: 'dcm-badge dcm-badge-warn' },
697
+ t('update.available', { latestVersion: updateState.latestVersion, currentVersion: updateState.currentVersion })
698
+ )
699
+ : React.createElement('span', { className: 'dcm-badge dcm-badge-ok' },
700
+ '✓ ' + t('update.up_to_date')
701
+ )
702
+ ),
703
+ updateState.updateAvailable
704
+ ? React.createElement('button', {
705
+ type: 'button',
706
+ className: 'dcm-btn dcm-btn-primary',
707
+ disabled: updateState.updating,
708
+ onClick: handleTriggerUpdate,
709
+ style: { padding: '4px 10px', fontSize: 12 },
710
+ }, updateState.updating ? t('update.updating') : t('update.btn'))
711
+ : null
712
+ ),
713
+ updateState.notice ? React.createElement('div', { className: 'dcm-alert dcm-alert-ok' }, '✓ ' + updateState.notice) : null,
714
+ updateState.error ? React.createElement('div', { className: 'dcm-alert dcm-alert-err' }, updateState.error) : null,
715
+
569
716
  // Section 1: General Parameters
570
717
  React.createElement('div', { className: 'dcm-section-card' },
571
718
  React.createElement('div', { className: 'dcm-section-title' }, t('section.general')),
@@ -855,7 +1002,7 @@ window.__ModuleLoader__.load({
855
1002
 
856
1003
  React.useEffect(() => { ensureStyles() }, [])
857
1004
 
858
- const settingsScope = (ctx && typeof ctx.get === 'function' ? ctx.get('settingsScope') : null) || (ctx && ctx.settingsScope) || (ctx && ctx.services && ctx.services.settingsScope)
1005
+ const settingsScope = getSettingsScope(ctx)
859
1006
  const settingsSnap = React.useSyncExternalStore
860
1007
  ? React.useSyncExternalStore(
861
1008
  (cb) => (settingsScope && typeof settingsScope.subscribe === 'function' ? settingsScope.subscribe(cb) : () => {}),
@@ -960,7 +1107,7 @@ window.__ModuleLoader__.load({
960
1107
  ? 'dcm-dot dcm-dot-err'
961
1108
  : (flat ? 'dcm-dot dcm-dot-idle' : (cheapNow ? 'dcm-dot dcm-dot-ok' : 'dcm-dot dcm-dot-warn'))
962
1109
 
963
- const activeCell = { textAlign: 'right', padding: '3px 6px', borderRadius: 4, background: 'rgba(99,102,241,0.12)', color: 'var(--dsw-alias-state-brand-primary, #6366f1)', fontWeight: 600 }
1110
+ const activeCell = { textAlign: 'right', padding: '3px 6px', borderRadius: 4, background: 'color-mix(in srgb, var(--dsw-alias-state-brand-primary) 12%, transparent)', color: 'var(--dsw-alias-state-brand-primary)', fontWeight: 600 }
964
1111
 
965
1112
  const rateRow = (label, key) => flat
966
1113
  ? [
@@ -1203,11 +1350,43 @@ window.__ModuleLoader__.load({
1203
1350
  }
1204
1351
 
1205
1352
  function apply(ctx) {
1206
- if (ctx.locale && ctx.locale.register) {
1207
- try { ctx.locale.register(NS, { en, zh }) } catch (_) {}
1353
+ const registerDictionaries = () => {
1354
+ try {
1355
+ // ctx.locale.register(NS, { en, zh }) is canonical form in DSH
1356
+ return ctx.locale.register(NS, { en, zh })
1357
+ } catch (err) {
1358
+ if (ctx?.logger?.warn) {
1359
+ ctx.logger.warn(`[dsh-cost-meter] locale register failed: ${err?.message || err}`)
1360
+ }
1361
+ return () => {}
1362
+ }
1208
1363
  }
1209
1364
 
1210
- refreshMirrorUntilVisible(ctx)
1365
+ if (ctx.locale && typeof ctx.locale.register === 'function') {
1366
+ if (typeof ctx.effect === 'function') {
1367
+ ctx.effect(() => {
1368
+ const undo = registerDictionaries()
1369
+ return () => {
1370
+ try {
1371
+ if (typeof undo === 'function') undo()
1372
+ } catch (e) {
1373
+ if (ctx?.logger?.debug) ctx.logger.debug('[dsh-cost-meter] locale undo error: ' + (e?.message || e))
1374
+ }
1375
+ }
1376
+ }, 'dsh-cost-meter: locale dictionaries')
1377
+ } else {
1378
+ registerDictionaries()
1379
+ }
1380
+ }
1381
+
1382
+ if (typeof ctx.effect === 'function') {
1383
+ ctx.effect(
1384
+ () => refreshMirrorUntilVisible(ctx),
1385
+ 'dsh-cost-meter: refresh settings mirror',
1386
+ )
1387
+ } else {
1388
+ refreshMirrorUntilVisible(ctx)
1389
+ }
1211
1390
 
1212
1391
  if (ctx.slots && typeof ctx.slots.inject === 'function') {
1213
1392
  ctx.slots.inject('conversation.session.header.utilities', () =>
package/lib/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { registerPluginUpdater } from './updater.js'
1
2
  // dsh-cost-meter — host half.
2
3
  //
3
4
  // The harness prices nothing on purpose: pi-ai carries a `cost` field but
@@ -20,12 +21,38 @@ import { dirname, join } from 'node:path'
20
21
  import z from '@deepseek-ai/schemastery'
21
22
  import { z as zod } from 'zod'
22
23
 
23
- export const name = 'dsh-cost-meter'
24
+ export const name = '@goodandready/dsh-cost-meter'
24
25
  export const inject = ['webServer']
25
26
 
26
27
  const NS = 'dsh-cost-meter'
27
28
  const STATE_PATH = '/dsh-cost-meter/state'
28
29
  const REFRESH_PATH = '/dsh-cost-meter/refresh'
30
+ export function isLoopback(value) {
31
+ const address = value?.toLowerCase().replace(/^\[|\]$/g, '')
32
+ return address === 'localhost' || address === 'localhost.' || address === '::1'
33
+ || address?.startsWith('127.') === true
34
+ || address?.startsWith('::ffff:127.') === true
35
+ }
36
+
37
+ export function isTrustedCaller(request) {
38
+ const remote = request?.socket?.remoteAddress || request?.connection?.remoteAddress
39
+ if (isLoopback(remote)) return true
40
+ const site = request?.headers?.['sec-fetch-site']
41
+ if (site === 'same-origin' || site === 'same-site') return true
42
+ if (site === 'cross-site') return false
43
+ const origin = request?.headers?.['origin']
44
+ const host = request?.headers?.['host']
45
+ if (origin && host) {
46
+ try {
47
+ const url = new URL(origin)
48
+ if (url.host === host) return true
49
+ } catch (e) {
50
+ /* invalid url */
51
+ }
52
+ }
53
+ return false
54
+ }
55
+
29
56
  const CATALOG_URL = 'https://openrouter.ai/api/v1/models'
30
57
  const CACHE_FILE = join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'storages', 'dsh-cost-meter-catalog.json')
31
58
 
@@ -532,6 +559,11 @@ export function apply(ctx, config) {
532
559
  res.end(JSON.stringify({ error: 'method not allowed' }))
533
560
  return
534
561
  }
562
+ if (!isTrustedCaller(req)) {
563
+ res.writeHead(403, { 'Content-Type': 'application/json' })
564
+ res.end(JSON.stringify({ error: 'forbidden source origin' }))
565
+ return
566
+ }
535
567
  const now = Date.now()
536
568
  const COOLDOWN_MS = 30000
537
569
  if (now - lastManualRefreshAt < COOLDOWN_MS) {
@@ -627,4 +659,14 @@ export function apply(ctx, config) {
627
659
  }),
628
660
  'dsh-cost-meter: state route',
629
661
  )
662
+
663
+ // Route 3: /dsh-cost-meter/update (in-app one-click updater)
664
+ if (ctx.webServer?.register) {
665
+ ctx.effect(() => registerPluginUpdater(ctx, {
666
+ endpoint: '/dsh-cost-meter/update',
667
+ packageName: name,
668
+ manifestUrl: new URL('../package.json', import.meta.url),
669
+ }), 'dsh-cost-meter: updater')
670
+ }
671
+
630
672
  }