@goodandready/dsh-cost-meter 0.8.2 → 0.8.4

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
@@ -11,8 +11,13 @@ window.__ModuleLoader__.load({
11
11
  const h = React.createElement
12
12
 
13
13
  const NS = 'dsh-cost-meter'
14
+ // Plugins page row seat (DSH 0.1.6-alpha.2): key = '<package name>#<row id>'.
15
+ const PKG = '@goodandready/dsh-cost-meter'
16
+ const ROW_ID = 'dsh-cost-meter'
17
+ const ROW_CONFIG_KEY = PKG + '#' + ROW_ID
14
18
  const STATE_PATH = '/dsh-cost-meter/state'
15
19
  const REFRESH_PATH = '/dsh-cost-meter/refresh'
20
+ const UPDATE_PATH = '/dsh-cost-meter/update'
16
21
  const DAY = 1440
17
22
 
18
23
  // ----------------------------------------------------------- localization
@@ -22,6 +27,13 @@ window.__ModuleLoader__.load({
22
27
  subtitle: 'Model rates, peak discount windows and session spend',
23
28
  'header.title': 'Cost Meter',
24
29
  'header.sub': 'Token spend monitoring, automatic tariff resolution and discount windows',
30
+ 'update.checking': 'Checking for updates…',
31
+ 'update.up_to_date': 'Up to date',
32
+ 'update.available': 'Update available: v{latestVersion} (current: v{currentVersion})',
33
+ 'update.btn': 'Update Now',
34
+ 'update.updating': 'Updating…',
35
+ 'update.done': 'Successfully updated to v{version}! Please restart DSH.',
36
+ 'update.failed': 'Update failed: {error}',
25
37
  'badge.catalog_ok': 'Catalog: {count} models',
26
38
  'badge.catalog_empty': 'Catalog: loading…',
27
39
  'badge.catalog_off': 'Catalog: disabled',
@@ -93,6 +105,13 @@ window.__ModuleLoader__.load({
93
105
  subtitle: '模型费率、高峰优惠窗口与会话消费统计',
94
106
  'header.title': '成本计量器 (Cost Meter)',
95
107
  'header.sub': 'Token 消费监控、费率自动匹配与折扣窗口管理',
108
+ 'update.checking': '正在检查更新…',
109
+ 'update.up_to_date': '已是最新版本',
110
+ 'update.available': '发现新版本 v{latestVersion}(当前版本 v{currentVersion})',
111
+ 'update.btn': '立即更新',
112
+ 'update.updating': '正在更新…',
113
+ 'update.done': '更新成功至 v{version}!请重启 DSH 服务生效。',
114
+ 'update.failed': '更新失败:{error}',
96
115
  'badge.catalog_ok': '目录:{count} 个模型',
97
116
  'badge.catalog_empty': '目录:加载中…',
98
117
  'badge.catalog_off': '目录:已禁用',
@@ -200,19 +219,22 @@ window.__ModuleLoader__.load({
200
219
  .dcm-grid-2 { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; }
201
220
  .dcm-field { display: flex; flex-direction: column; gap: 6px; }
202
221
  .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; }
222
+ .dcm-check { width: 16px; height: 16px; accent-color: var(--dsw-alias-state-brand-primary); cursor: pointer; }
204
223
  .dcm-label { color: var(--dsw-alias-label-primary); font-size: 13px; font-weight: 500; }
205
224
  .dcm-hint { color: var(--dsw-alias-label-secondary); font-size: 12px; line-height: 1.4; }
206
225
 
207
226
  .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); }
227
+ .dcm-input:focus { outline: none; border-color: var(--dsw-alias-state-brand-primary); }
228
+ .dcm-input-err { border-color: var(--dsw-alias-state-error-primary); }
229
+
230
+ .dcm-chevron { display: inline-flex; align-items: center; justify-content: center; transition: transform .16s ease; color: var(--dsw-alias-label-tertiary); }
231
+ .dcm-chevron-open { transform: rotate(180deg); }
210
232
 
211
233
  .dcm-badges { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-top: 4px; }
212
234
  .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); }
235
+ .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); }
236
+ .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); }
237
+ .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
238
 
217
239
  .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
240
  .dcm-btn:hover:not(:disabled) { background: var(--dsw-alias-bg-layer-4, var(--dsw-alias-bg-layer-3)); }
@@ -222,10 +244,10 @@ window.__ModuleLoader__.load({
222
244
  .dcm-btn-sm { padding: 4px 10px; font-size: 12px; border-radius: 6px; }
223
245
 
224
246
  .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; }
247
+ .dcm-alert-ok { background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 10%, transparent); color: var(--dsw-alias-state-success-primary); }
248
+ .dcm-alert-err { background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent); color: var(--dsw-alias-state-error-primary); }
249
+ .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; }
250
+ .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
251
 
230
252
  .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
253
  .dcm-stat-val { font-size: 14px; font-weight: 600; color: var(--dsw-alias-label-primary); font-variant-numeric: tabular-nums; }
@@ -234,13 +256,13 @@ window.__ModuleLoader__.load({
234
256
  .dcm-foot { display: flex; justify-content: flex-end; align-items: center; gap: 10px; padding-top: 6px; }
235
257
 
236
258
  /* 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)); }
259
+ .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; }
260
+ .dcm-chip:hover { border-color: var(--dsw-alias-border-l3, var(--dsw-alias-label-tertiary)); background: var(--dsw-alias-bg-layer-3); }
239
261
  .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); }
262
+ .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); }
263
+ .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); }
264
+ .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; }
265
+ .dcm-dot-idle { background: var(--dsw-alias-label-tertiary); }
244
266
 
245
267
  .dcm-chip-timer { opacity: 0.72; font-size: 11px; padding-left: 2px; border-left: 1px solid var(--dsw-alias-border-l2); }
246
268
  @media (max-width: 680px) {
@@ -249,7 +271,7 @@ window.__ModuleLoader__.load({
249
271
 
250
272
  @keyframes dcm-pulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: 0.4; transform: scale(0.85); } }
251
273
 
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; }
274
+ .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
275
  .dcm-panel-title { font-weight: 600; padding-bottom: 2px; font-size: 13.5px; display: flex; justify-content: space-between; align-items: baseline; }
254
276
  .dcm-panel-note { color: var(--dsw-alias-label-tertiary); font-size: 12px; }
255
277
  .dcm-hr { height: 1px; background: var(--dsw-alias-border-l2); margin: 10px -16px; border: 0; }
@@ -264,7 +286,7 @@ window.__ModuleLoader__.load({
264
286
  .dcm-cost { text-align: right; padding: 3.5px 0; font-weight: 500; color: var(--dsw-alias-label-primary); }
265
287
  .dcm-section-lbl { color: var(--dsw-alias-label-tertiary); padding-bottom: 4px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; }
266
288
  .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; }
289
+ .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
290
  .dcm-accordion-btn:hover { text-decoration: underline; }
269
291
  `
270
292
 
@@ -318,51 +340,83 @@ window.__ModuleLoader__.load({
318
340
  }
319
341
  const ErrorBoundary = createErrorBoundary()
320
342
 
343
+ function getSettingsScope(ctx) {
344
+ return (ctx && typeof ctx.get === 'function' ? ctx.get('settingsScope') : null) || (ctx && ctx.settingsScope) || (ctx && ctx.services && ctx.services.settingsScope) || null
345
+ }
346
+
321
347
  function refreshMirrorUntilVisible(ctx) {
348
+ const s = getSettingsScope(ctx)
322
349
  const visible = () => {
323
350
  try {
324
- const s = (ctx?.get && ctx.get('lanSettings')) || (ctx?.get && ctx.get('settingsScope')) || ctx?.settingsScope
325
351
  const view = s?.describe?.()?.getSnapshot?.()?.view
326
352
  return !!view && Array.isArray(view.namespaces) && view.namespaces.some((row) => row.ns === NS)
327
- } catch (_) {
353
+ } catch (e) {
354
+ if (ctx?.logger?.debug) ctx.logger.debug('[dsh-cost-meter] mirror check error: ' + (e?.message || e))
328
355
  return false
329
356
  }
330
357
  }
331
- if (visible()) return () => {}
358
+ if (!s || visible()) return () => {}
332
359
  let tries = 0
333
360
  const timer = setInterval(() => {
334
- if (visible() || tries >= 15) { clearInterval(timer); return }
361
+ if (visible() || tries >= 15) {
362
+ if (!visible() && tries >= 15 && ctx?.logger?.warn) {
363
+ ctx.logger.warn('[dsh-cost-meter] settings mirror did not appear after 15 attempts')
364
+ }
365
+ clearInterval(timer)
366
+ return
367
+ }
335
368
  tries += 1
336
369
  try {
337
- const s = (ctx?.get && ctx.get('lanSettings')) || (ctx?.get && ctx.get('settingsScope')) || ctx?.settingsScope
338
370
  s?.describe?.()?.load?.()
339
- } catch (_) {}
371
+ } catch (e) {
372
+ if (ctx?.logger?.debug) ctx.logger.debug('[dsh-cost-meter] settings load error: ' + (e?.message || e))
373
+ }
340
374
  }, 1000)
341
375
  return () => clearInterval(timer)
342
376
  }
343
377
 
344
- function Chevron(props) {
378
+ let ChevronIcon = null
379
+ try {
380
+ const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
381
+ ChevronIcon = primitives && (primitives.IconChevronDownOutline14 || primitives.IconChevronDownOutline)
382
+ } catch (e) {
383
+ ChevronIcon = null
384
+ }
385
+
386
+ function FallbackChevron(props) {
345
387
  return React.createElement(
346
388
  'svg',
347
389
  {
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
- },
390
+ width: 14,
391
+ height: 14,
392
+ viewBox: '0 0 14 14',
393
+ fill: 'none',
394
+ 'aria-hidden': true,
355
395
  },
356
396
  React.createElement('path', {
357
- d: 'M4 6L8 10L12 6',
397
+ d: 'M3.5 5.25L7 8.75L10.5 5.25',
358
398
  stroke: 'currentColor',
359
- strokeWidth: 1.6,
399
+ strokeWidth: 1.5,
360
400
  strokeLinecap: 'round',
361
401
  strokeLinejoin: 'round',
362
402
  }),
363
403
  )
364
404
  }
365
405
 
406
+ function Chevron(props) {
407
+ const icon = ChevronIcon
408
+ ? React.createElement(ChevronIcon, { 'aria-hidden': true })
409
+ : React.createElement(FallbackChevron, null)
410
+ return React.createElement(
411
+ 'span',
412
+ {
413
+ className: 'dcm-chevron' + (props.open ? ' dcm-chevron-open' : ''),
414
+ style: { marginLeft: 'auto' },
415
+ },
416
+ icon,
417
+ )
418
+ }
419
+
366
420
  function isValidTimeZone(tz) {
367
421
  if (!tz || typeof tz !== 'string') return false
368
422
  try {
@@ -389,7 +443,8 @@ window.__ModuleLoader__.load({
389
443
  const ctx = props.ctx
390
444
  const locale = useActiveLocale(ctx)
391
445
  const t = props.t || makeT(locale === 'zh' ? zh : en, en)
392
- const [open, setOpen] = React.useState(false)
446
+ const page = !!(props && props.view === 'page')
447
+ const [open, setOpen] = React.useState(!!page)
393
448
  const [draft, setDraft] = React.useState(null)
394
449
  const [saving, setSaving] = React.useState(false)
395
450
  const [err, setErr] = React.useState('')
@@ -400,7 +455,72 @@ window.__ModuleLoader__.load({
400
455
  const [syncing, setSyncing] = React.useState(false)
401
456
  const [syncMsg, setSyncMsg] = React.useState('')
402
457
 
403
- const settingsScope = (ctx && typeof ctx.get === 'function' ? ctx.get('settingsScope') : null) || (ctx && ctx.settingsScope) || (ctx && ctx.services && ctx.services.settingsScope)
458
+ // Plugin in-app updater state
459
+ const [updateState, setUpdateState] = React.useState({
460
+ checking: false,
461
+ updating: false,
462
+ currentVersion: '0.8.3',
463
+ latestVersion: '',
464
+ updateAvailable: false,
465
+ canAutoUpdate: true,
466
+ error: '',
467
+ notice: '',
468
+ })
469
+
470
+ const checkUpdate = React.useCallback(async () => {
471
+ setUpdateState((s) => ({ ...s, checking: true, error: '' }))
472
+ try {
473
+ const res = await fetch(UPDATE_PATH)
474
+ if (!res.ok) throw new Error('HTTP ' + res.status)
475
+ const data = await res.json().catch(() => ({}))
476
+ setUpdateState((s) => ({
477
+ ...s,
478
+ checking: false,
479
+ currentVersion: data.currentVersion || s.currentVersion,
480
+ latestVersion: data.latestVersion || '',
481
+ updateAvailable: Boolean(data.updateAvailable),
482
+ canAutoUpdate: data.canAutoUpdate !== false,
483
+ }))
484
+ } catch (e) {
485
+ setUpdateState((s) => ({ ...s, checking: false }))
486
+ }
487
+ }, [])
488
+
489
+ React.useEffect(() => {
490
+ if (open) checkUpdate()
491
+ }, [open, checkUpdate])
492
+
493
+ async function handleTriggerUpdate() {
494
+ if (updateState.updating) return
495
+ setUpdateState((s) => ({ ...s, updating: true, error: '', notice: '' }))
496
+ try {
497
+ const res = await fetch(UPDATE_PATH, {
498
+ method: 'POST',
499
+ headers: { 'x-dsh-plugin-update': '1' },
500
+ })
501
+ const data = await res.json().catch(() => ({}))
502
+ if (!res.ok || data.ok === false || data.error) {
503
+ throw new Error(data.error || ('HTTP ' + res.status))
504
+ }
505
+ const newVer = data.updatedVersion || updateState.latestVersion || updateState.currentVersion
506
+ setUpdateState((s) => ({
507
+ ...s,
508
+ updating: false,
509
+ updateAvailable: false,
510
+ currentVersion: newVer,
511
+ notice: t('update.done', { version: newVer }),
512
+ }))
513
+ setTimeout(() => checkUpdate(), 2000)
514
+ } catch (err) {
515
+ setUpdateState((s) => ({
516
+ ...s,
517
+ updating: false,
518
+ error: t('update.failed', { error: String(err.message || err) }),
519
+ }))
520
+ }
521
+ }
522
+
523
+ const settingsScope = getSettingsScope(ctx)
404
524
  const scope = React.useMemo(
405
525
  () => (settingsScope ? settingsScope.bind({ namespace: NS }) : undefined),
406
526
  [settingsScope],
@@ -535,9 +655,15 @@ window.__ModuleLoader__.load({
535
655
  const catalogHasErr = diagState && Boolean(diagState.catalogError)
536
656
  const openRouterEnabled = draft ? Boolean(draft.useOpenRouter) : true
537
657
 
538
- return React.createElement('li', { className: 'dcm-card' },
658
+ // Row seat (plugins.row.config): the host page draws title/icon/crumb and the
659
+ // padding, so the summary is a one-liner and the page drops our card chrome.
660
+ if (props && props.view === 'summary') {
661
+ return React.createElement('span', { className: 'dcm-sub' }, t('header.sub'))
662
+ }
663
+
664
+ return React.createElement(page ? 'div' : 'li', { className: page ? 'dcm-page' : 'dcm-card' },
539
665
  React.createElement('button', {
540
- type: 'button', className: 'dcm-head', 'aria-expanded': open,
666
+ type: 'button', className: 'dcm-head', style: page ? { display: 'none' } : undefined, 'aria-expanded': page ? true : open,
541
667
  onClick: () => setOpen((v) => !v),
542
668
  },
543
669
  React.createElement('div', { style: { flex: 1, display: 'flex', flexDirection: 'column', gap: 2 } },
@@ -566,6 +692,38 @@ window.__ModuleLoader__.load({
566
692
  : status !== 'ready'
567
693
  ? React.createElement('div', { className: 'dcm-alert dcm-alert-err' }, t('settings.unavailable'))
568
694
  : React.createElement(React.Fragment, null,
695
+ // In-app Update Bar
696
+ React.createElement('div', {
697
+ className: 'dcm-section-card',
698
+ style: { padding: '10px 14px', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
699
+ },
700
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 8, fontSize: 13 } },
701
+ React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary)', fontWeight: 500 } },
702
+ 'v' + updateState.currentVersion
703
+ ),
704
+ updateState.checking
705
+ ? React.createElement('span', { style: { color: 'var(--dsw-alias-label-tertiary)', fontSize: 12 } }, t('update.checking'))
706
+ : updateState.updateAvailable
707
+ ? React.createElement('span', { className: 'dcm-badge dcm-badge-warn' },
708
+ t('update.available', { latestVersion: updateState.latestVersion, currentVersion: updateState.currentVersion })
709
+ )
710
+ : React.createElement('span', { className: 'dcm-badge dcm-badge-ok' },
711
+ '✓ ' + t('update.up_to_date')
712
+ )
713
+ ),
714
+ updateState.updateAvailable
715
+ ? React.createElement('button', {
716
+ type: 'button',
717
+ className: 'dcm-btn dcm-btn-primary',
718
+ disabled: updateState.updating,
719
+ onClick: handleTriggerUpdate,
720
+ style: { padding: '4px 10px', fontSize: 12 },
721
+ }, updateState.updating ? t('update.updating') : t('update.btn'))
722
+ : null
723
+ ),
724
+ updateState.notice ? React.createElement('div', { className: 'dcm-alert dcm-alert-ok' }, '✓ ' + updateState.notice) : null,
725
+ updateState.error ? React.createElement('div', { className: 'dcm-alert dcm-alert-err' }, updateState.error) : null,
726
+
569
727
  // Section 1: General Parameters
570
728
  React.createElement('div', { className: 'dcm-section-card' },
571
729
  React.createElement('div', { className: 'dcm-section-title' }, t('section.general')),
@@ -855,7 +1013,7 @@ window.__ModuleLoader__.load({
855
1013
 
856
1014
  React.useEffect(() => { ensureStyles() }, [])
857
1015
 
858
- const settingsScope = (ctx && typeof ctx.get === 'function' ? ctx.get('settingsScope') : null) || (ctx && ctx.settingsScope) || (ctx && ctx.services && ctx.services.settingsScope)
1016
+ const settingsScope = getSettingsScope(ctx)
859
1017
  const settingsSnap = React.useSyncExternalStore
860
1018
  ? React.useSyncExternalStore(
861
1019
  (cb) => (settingsScope && typeof settingsScope.subscribe === 'function' ? settingsScope.subscribe(cb) : () => {}),
@@ -960,7 +1118,7 @@ window.__ModuleLoader__.load({
960
1118
  ? 'dcm-dot dcm-dot-err'
961
1119
  : (flat ? 'dcm-dot dcm-dot-idle' : (cheapNow ? 'dcm-dot dcm-dot-ok' : 'dcm-dot dcm-dot-warn'))
962
1120
 
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 }
1121
+ 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
1122
 
965
1123
  const rateRow = (label, key) => flat
966
1124
  ? [
@@ -1203,11 +1361,43 @@ window.__ModuleLoader__.load({
1203
1361
  }
1204
1362
 
1205
1363
  function apply(ctx) {
1206
- if (ctx.locale && ctx.locale.register) {
1207
- try { ctx.locale.register(NS, { en, zh }) } catch (_) {}
1364
+ const registerDictionaries = () => {
1365
+ try {
1366
+ // ctx.locale.register(NS, { en, zh }) is canonical form in DSH
1367
+ return ctx.locale.register(NS, { en, zh })
1368
+ } catch (err) {
1369
+ if (ctx?.logger?.warn) {
1370
+ ctx.logger.warn(`[dsh-cost-meter] locale register failed: ${err?.message || err}`)
1371
+ }
1372
+ return () => {}
1373
+ }
1208
1374
  }
1209
1375
 
1210
- refreshMirrorUntilVisible(ctx)
1376
+ if (ctx.locale && typeof ctx.locale.register === 'function') {
1377
+ if (typeof ctx.effect === 'function') {
1378
+ ctx.effect(() => {
1379
+ const undo = registerDictionaries()
1380
+ return () => {
1381
+ try {
1382
+ if (typeof undo === 'function') undo()
1383
+ } catch (e) {
1384
+ if (ctx?.logger?.debug) ctx.logger.debug('[dsh-cost-meter] locale undo error: ' + (e?.message || e))
1385
+ }
1386
+ }
1387
+ }, 'dsh-cost-meter: locale dictionaries')
1388
+ } else {
1389
+ registerDictionaries()
1390
+ }
1391
+ }
1392
+
1393
+ if (typeof ctx.effect === 'function') {
1394
+ ctx.effect(
1395
+ () => refreshMirrorUntilVisible(ctx),
1396
+ 'dsh-cost-meter: refresh settings mirror',
1397
+ )
1398
+ } else {
1399
+ refreshMirrorUntilVisible(ctx)
1400
+ }
1211
1401
 
1212
1402
  if (ctx.slots && typeof ctx.slots.inject === 'function') {
1213
1403
  ctx.slots.inject('conversation.session.header.utilities', () =>
@@ -1217,6 +1407,14 @@ window.__ModuleLoader__.load({
1217
1407
  ),
1218
1408
  )
1219
1409
 
1410
+ // Row seat first (the seat the current core renders), legacy seat kept below.
1411
+ ctx.slots.inject('plugins.row.config', () =>
1412
+ ctx.slots.register(
1413
+ { name: 'plugins.row.config', key: ROW_CONFIG_KEY, locale: NS, order: 20, inject: () => ({ ctx }) },
1414
+ (p) => React.createElement(ErrorBoundary, null, React.createElement(CostMeterCard, { ...p, ctx: (p && p.ctx) || ctx })),
1415
+ ),
1416
+ )
1417
+
1220
1418
  ctx.slots.inject('settings.plugin.item', () =>
1221
1419
  ctx.slots.register(
1222
1420
  { name: 'settings.plugin.item', key: NS, locale: NS, order: 20, inject: () => ({ ctx }) },
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
  }