@linxin666/dsh-usage 0.3.23 → 0.4.0
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/README.i18n.yaml +2 -2
- package/README.md +7 -9
- package/README.zh.md +7 -9
- package/icon.svg +3 -0
- package/lib/client.js +621 -65
- package/lib/client.js.map +1 -1
- package/lib/index.js +258 -271
- package/lib/types/client/UsageFootCard.d.ts +55 -0
- package/lib/types/client/UsageSectionCard.d.ts +3 -4
- package/lib/types/client/body-mutations.d.ts +45 -0
- package/lib/types/client/foot-card-mount.d.ts +34 -0
- package/lib/types/client/index.d.ts +21 -8
- package/lib/types/client/locales.d.ts +6 -4
- package/lib/types/core/provider-routes.d.ts +26 -0
- package/lib/types/core/types.d.ts +14 -1
- package/lib/types/host/access.d.ts +17 -0
- package/lib/types/host/http.d.ts +18 -1
- package/lib/types/host/pair-access.d.ts +34 -0
- package/lib/types/host/routes.d.ts +16 -7
- package/lib/types/host/usage-service.d.ts +32 -76
- package/lib/types/index.d.ts +14 -2
- package/package.json +20 -17
- package/src/client/UsageFootCard.tsx +300 -0
- package/src/client/UsageSectionCard.tsx +41 -23
- package/src/client/body-mutations.ts +133 -0
- package/src/client/foot-card-mount.tsx +112 -0
- package/src/client/index.ts +57 -16
- package/src/client/locales.ts +12 -8
- package/src/client/usage.module.css +188 -0
- package/src/core/adapters.ts +40 -6
- package/src/core/provider-routes.ts +37 -0
- package/src/core/types.ts +14 -1
- package/src/host/access.ts +21 -0
- package/src/host/http.ts +23 -1
- package/src/host/pair-access.ts +49 -0
- package/src/host/routes.ts +19 -10
- package/src/host/usage-service.ts +74 -218
- package/src/index.ts +59 -60
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sidebar foot card mounting.
|
|
3
|
+
*
|
|
4
|
+
* The foot's only slot (sidebar.footer.action) stacks ABOVE the Settings row
|
|
5
|
+
* and cannot host a block, so the card container is inserted into the shell's
|
|
6
|
+
* foot area directly above the Settings seat — keeping Settings the foot's
|
|
7
|
+
* terminal row instead of hanging a card off the sidebar floor. It self-heals
|
|
8
|
+
* with the page-wide body-mutation hub: a React re-render that displaces the
|
|
9
|
+
* container re-seats it on the next frame, and a whole-pane rebuild is noticed
|
|
10
|
+
* by the same body-level watcher the family entry rows use.
|
|
11
|
+
*
|
|
12
|
+
* The container is a plain div carrying its own React root, so it can never
|
|
13
|
+
* disturb the shell's reconciliation.
|
|
14
|
+
* @module @linxin666/dsh-usage/client/foot-card-mount
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { createElement } from 'react'
|
|
18
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
19
|
+
import { subscribeBodyInvalidations } from './body-mutations.ts'
|
|
20
|
+
import { UsageFootCard, type UsageFootCardProps } from './UsageFootCard.tsx'
|
|
21
|
+
|
|
22
|
+
/** Stable data attribute identifying the injected card container. */
|
|
23
|
+
export const FOOT_CARD_SELECTOR = '[data-dsh-usage-foot-card]'
|
|
24
|
+
|
|
25
|
+
/** The shell sidebar's foot area (footer actions + the Settings row), when mounted. */
|
|
26
|
+
function footArea(): HTMLElement | undefined {
|
|
27
|
+
const column = document.querySelector<HTMLElement>('[data-pane="sidebar"], [class*="sidebarCol"]')
|
|
28
|
+
return column?.querySelector<HTMLElement>('[class*="footArea"]') ?? undefined
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Open the settings panel on the usage section. The shell owns the panel's
|
|
33
|
+
* open state and exposes no service for it, so the card replays the user's
|
|
34
|
+
* own path: activate the sidebar Settings trigger, then pick the nav row
|
|
35
|
+
* carrying the section's (localized) label. Every step degrades silently —
|
|
36
|
+
* with the panel already open only the nav pick runs, and a missing row
|
|
37
|
+
* leaves the panel on its default section.
|
|
38
|
+
* @param label - the usage section's nav label in the active locale.
|
|
39
|
+
*/
|
|
40
|
+
export function openUsageSettings(label: () => string): void {
|
|
41
|
+
/** Click the nav row carrying the usage label inside the open panel. */
|
|
42
|
+
const pick = (panel: Element): void => {
|
|
43
|
+
const wanted = label()
|
|
44
|
+
for (const row of panel.querySelectorAll<HTMLButtonElement>('nav button')) {
|
|
45
|
+
if (row.textContent !== null && row.textContent.includes(wanted)) {
|
|
46
|
+
row.click()
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
// Panel open but the usage row is gone (plugin toggled off): stay put.
|
|
51
|
+
}
|
|
52
|
+
// A panel that is already open must never be toggled shut by the trigger;
|
|
53
|
+
// only the nav pick runs.
|
|
54
|
+
const open = document.querySelector('[role="dialog"]')
|
|
55
|
+
if (open !== null) {
|
|
56
|
+
pick(open)
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
const column = document.querySelector<HTMLElement>('[data-pane="sidebar"], [class*="sidebarCol"]')
|
|
60
|
+
const trigger = column?.querySelector<HTMLButtonElement>('[class*="settingsArea"] button')
|
|
61
|
+
trigger?.click()
|
|
62
|
+
let tries = 0
|
|
63
|
+
const attempt = (): void => {
|
|
64
|
+
const panel = document.querySelector('[role="dialog"]')
|
|
65
|
+
if (panel !== null) {
|
|
66
|
+
pick(panel)
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
tries += 1
|
|
70
|
+
// The panel mounts on the shell's next frames; wait briefly, then give up.
|
|
71
|
+
if (tries <= 20) window.setTimeout(attempt, 50)
|
|
72
|
+
}
|
|
73
|
+
window.setTimeout(attempt, 0)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Mount the sidebar foot card directly above the Settings row.
|
|
78
|
+
* @param props - the store/poll/settings/open inputs of the apply body.
|
|
79
|
+
* @returns disposer removing the container and its observers.
|
|
80
|
+
*/
|
|
81
|
+
export function mountUsageFootCard(props: UsageFootCardProps): () => void {
|
|
82
|
+
// DOM-level idempotency: whatever path mounted a card before this call (a
|
|
83
|
+
// duplicated apply, an HMR re-injection), never mount a second one.
|
|
84
|
+
if (typeof document !== 'undefined' && document.querySelector(FOOT_CARD_SELECTOR) !== null) {
|
|
85
|
+
return () => {}
|
|
86
|
+
}
|
|
87
|
+
const container = document.createElement('div')
|
|
88
|
+
container.setAttribute('data-dsh-usage-foot-card', '')
|
|
89
|
+
const root: Root = createRoot(container)
|
|
90
|
+
root.render(createElement(UsageFootCard, props))
|
|
91
|
+
|
|
92
|
+
/** Keep the container directly above the Settings seat inside the foot area. */
|
|
93
|
+
const place = (): void => {
|
|
94
|
+
const foot = footArea()
|
|
95
|
+
if (foot === undefined) return
|
|
96
|
+
const settings = foot.querySelector<HTMLElement>('[class*="settingsArea"]')
|
|
97
|
+
if (settings !== null) {
|
|
98
|
+
if (container.nextElementSibling !== settings) foot.insertBefore(container, settings)
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
// No Settings seat to anchor against (shell change): fall back to the tail.
|
|
102
|
+
if (foot.lastElementChild !== container) foot.append(container)
|
|
103
|
+
}
|
|
104
|
+
place()
|
|
105
|
+
const unsubscribeBody = subscribeBodyInvalidations(place)
|
|
106
|
+
|
|
107
|
+
return () => {
|
|
108
|
+
unsubscribeBody()
|
|
109
|
+
root.unmount()
|
|
110
|
+
container.remove()
|
|
111
|
+
}
|
|
112
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -1,24 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* dsh-usage browser half — seats the first-level 使用统计 settings section
|
|
3
|
-
* (below the Workshop entry)
|
|
4
|
-
*
|
|
5
|
-
* the
|
|
3
|
+
* (below the Workshop entry) plus the compact usage glance card below the
|
|
4
|
+
* sidebar's Settings row. The section polls the host overview only while it
|
|
5
|
+
* is open; the foot card runs its own relaxed loop because the sidebar foot
|
|
6
|
+
* is permanently mounted. All provider probing and credential handling
|
|
7
|
+
* happens in the host half; this bundle only renders the overview document.
|
|
6
8
|
* @module @linxin666/dsh-usage/client
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
11
|
import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
|
10
|
-
import type {
|
|
12
|
+
import type { ConfigForm } from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
11
13
|
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
|
12
14
|
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
|
13
|
-
// Type-only: pulls the
|
|
15
|
+
// Type-only: pulls the shared-forms Context merge (ctx.configForms).
|
|
14
16
|
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
15
17
|
import type {} from '@deepseek-ai/dsh-client-ui-slots'
|
|
16
18
|
// Type-only: pulls the ctx.slots merge (the renderer owns the slot registry since 0.1.2).
|
|
17
19
|
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
|
18
|
-
import { createElement } from 'react'
|
|
19
20
|
import { createUsageStore, type UsageStoreInstance } from './usage-store.ts'
|
|
20
21
|
import { UsageSectionCard, type UsageSectionFace, type UsageSettings } from './UsageSectionCard.tsx'
|
|
21
|
-
import {
|
|
22
|
+
import { mountUsageFootCard, openUsageSettings } from './foot-card-mount.tsx'
|
|
23
|
+
import { NS, en, zh, t } from './locales.ts'
|
|
22
24
|
import type { UsageOverviewView } from '../core/types.ts'
|
|
23
25
|
|
|
24
26
|
/** The host usage API as the browser sees it (same-origin JSON endpoints). */
|
|
@@ -44,27 +46,39 @@ const usageApi: UsageHttpApi = {
|
|
|
44
46
|
refresh: () => usageFetch('/api/dsh-usage/refresh', 'POST'),
|
|
45
47
|
}
|
|
46
48
|
|
|
47
|
-
/** Settings namespace the section edits (
|
|
49
|
+
/** Settings namespace the section edits (dsh-web-settings maps it onto this row's profile entry id). */
|
|
48
50
|
const USAGE_SETTINGS_NS = 'dsh-usage'
|
|
49
51
|
|
|
50
52
|
/** First-level nav position: directly below the Workshop section (order 150). */
|
|
51
53
|
const SECTION_ORDER = 151
|
|
52
54
|
|
|
53
55
|
/** Required services. */
|
|
54
|
-
export const inject = ['slots', 'locale', 'connection', '
|
|
56
|
+
export const inject = ['slots', 'locale', 'connection', 'configForms', 'remote']
|
|
55
57
|
|
|
56
58
|
export type { UsageSectionProps, UsageSectionFace } from './UsageSectionCard.tsx'
|
|
57
59
|
export type { UsageUiState } from './usage-store.ts'
|
|
58
60
|
export type { UsageSettings }
|
|
59
61
|
|
|
62
|
+
/**
|
|
63
|
+
* One settings namespace a family card binds. The 0.1.7 client exports no spec
|
|
64
|
+
* type (the form controller takes it privately), so the binder's input shape is
|
|
65
|
+
* restated here.
|
|
66
|
+
*/
|
|
67
|
+
export interface UsageSettingsBindSpec<T> {
|
|
68
|
+
/** Settings namespace registered by the owning host plugin. */
|
|
69
|
+
namespace: string
|
|
70
|
+
/** Narrow one wire section; undefined keeps the last accepted value. */
|
|
71
|
+
decode?: (section: unknown) => T | undefined
|
|
72
|
+
}
|
|
73
|
+
|
|
60
74
|
declare module '@deepseek-ai/cordis' {
|
|
61
75
|
interface Context {
|
|
62
76
|
/**
|
|
63
|
-
* Optional
|
|
64
|
-
*
|
|
65
|
-
*
|
|
77
|
+
* Optional family settings binder provided by dsh-web-settings; absent when
|
|
78
|
+
* that group plugin is not installed, so callers fall back to the native
|
|
79
|
+
* per-entry forms (`ctx.configForms`).
|
|
66
80
|
*/
|
|
67
|
-
webUiSettings?: { bind<S>(spec:
|
|
81
|
+
webUiSettings?: { bind<S>(spec: UsageSettingsBindSpec<S>): ConfigForm<S> }
|
|
68
82
|
}
|
|
69
83
|
}
|
|
70
84
|
|
|
@@ -83,8 +97,16 @@ export function apply(ctx: ClientContext): void {
|
|
|
83
97
|
}
|
|
84
98
|
}, 'dsh-usage: dictionaries')
|
|
85
99
|
|
|
86
|
-
|
|
87
|
-
|
|
100
|
+
// The family binder is what maps this namespace onto the row's profile entry
|
|
101
|
+
// id and hands back the native form. Without the group plugin the client can
|
|
102
|
+
// only address an entry id it already knows: the namespace itself is one when
|
|
103
|
+
// the profile keeps the family spelling. Any other entry id leaves the form
|
|
104
|
+
// unavailable, which renders the row's controls disabled — the Host's own
|
|
105
|
+
// generated page for the row still edits the same Config.
|
|
106
|
+
const binder = ctx.get('webUiSettings')
|
|
107
|
+
const settingsForm = binder !== undefined
|
|
108
|
+
? binder.bind<UsageSettings>({ namespace: USAGE_SETTINGS_NS })
|
|
109
|
+
: ctx.configForms.get<UsageSettings>(USAGE_SETTINGS_NS)
|
|
88
110
|
|
|
89
111
|
// One store instance per apply body; the section mounts and unmounts with
|
|
90
112
|
// the settings page, and the store survives between visits so the last
|
|
@@ -121,7 +143,26 @@ export function apply(ctx: ClientContext): void {
|
|
|
121
143
|
})
|
|
122
144
|
}
|
|
123
145
|
|
|
124
|
-
const face = (): UsageSectionFace => ({ store, poll, refresh, settings:
|
|
146
|
+
const face = (): UsageSectionFace => ({ store, poll, refresh, settings: settingsForm })
|
|
147
|
+
|
|
148
|
+
// Sidebar foot card: the compact usage glance seated below the shell's
|
|
149
|
+
// Settings row. It shares the section's store and poll path (the sequence
|
|
150
|
+
// guard absorbs interleaved calls), runs its own relaxed poll loop, and
|
|
151
|
+
// opens the settings panel on the usage section when clicked.
|
|
152
|
+
const disposeFootCard = mountUsageFootCard({
|
|
153
|
+
store,
|
|
154
|
+
poll,
|
|
155
|
+
settings: settingsForm,
|
|
156
|
+
onOpen: () => { openUsageSettings(() => t('usage.title')) },
|
|
157
|
+
locale: ctx.locale,
|
|
158
|
+
})
|
|
159
|
+
ctx.effect(() => () => {
|
|
160
|
+
try {
|
|
161
|
+
disposeFootCard()
|
|
162
|
+
} catch {
|
|
163
|
+
// Card container already gone (teardown race).
|
|
164
|
+
}
|
|
165
|
+
}, 'dsh-usage: sidebar foot card')
|
|
125
166
|
|
|
126
167
|
ctx.slots.inject('settings.section', () => {
|
|
127
168
|
try {
|
package/src/client/locales.ts
CHANGED
|
@@ -48,10 +48,7 @@ export const zh = {
|
|
|
48
48
|
'usage.config.title': '设置',
|
|
49
49
|
'usage.config.enabled': '启用插件',
|
|
50
50
|
'usage.config.pollIntervalSec': '轮询间隔(秒)',
|
|
51
|
-
'usage.config.
|
|
52
|
-
'usage.config.bubbleMode.always': '常驻显示',
|
|
53
|
-
'usage.config.bubbleMode.change': '仅变化时',
|
|
54
|
-
'usage.config.bubbleMode.off': '关闭',
|
|
51
|
+
'usage.config.saveFailed': '保存失败:设置未被接受。',
|
|
55
52
|
'usage.bank.title': '鲸元券',
|
|
56
53
|
'usage.bank.hint': '官方 API 每消耗 100 万 tokens 铸造 1 鲸元;保存或分享这张票券。',
|
|
57
54
|
'usage.bank.noUsage': '暂无 DeepSeek 官方用量数据(统计自插件启用起)',
|
|
@@ -62,6 +59,11 @@ export const zh = {
|
|
|
62
59
|
'usage.bank.save': '保存图片',
|
|
63
60
|
'usage.bank.share': '分享',
|
|
64
61
|
'usage.bank.drawError': '票券生成失败:{error}',
|
|
62
|
+
'usage.foot.cost': '今日消费',
|
|
63
|
+
'usage.foot.noData': '今日暂无用量',
|
|
64
|
+
'usage.foot.open': '打开使用统计设置',
|
|
65
|
+
'usage.foot.collapse': '收起用量卡片',
|
|
66
|
+
'usage.foot.expand': '展开用量卡片',
|
|
65
67
|
}
|
|
66
68
|
|
|
67
69
|
/** English mirror; every zh key present. */
|
|
@@ -105,10 +107,7 @@ export const en: Record<UsageKey, string> = {
|
|
|
105
107
|
'usage.config.title': 'Settings',
|
|
106
108
|
'usage.config.enabled': 'Enable plugin',
|
|
107
109
|
'usage.config.pollIntervalSec': 'Poll interval (seconds)',
|
|
108
|
-
'usage.config.
|
|
109
|
-
'usage.config.bubbleMode.always': 'Always visible',
|
|
110
|
-
'usage.config.bubbleMode.change': 'On change',
|
|
111
|
-
'usage.config.bubbleMode.off': 'Off',
|
|
110
|
+
'usage.config.saveFailed': 'Save failed: the deployment did not accept the setting.',
|
|
112
111
|
'usage.bank.title': 'Whale-yuan voucher',
|
|
113
112
|
'usage.bank.hint': 'Every 1,000,000 tokens spent on the official API mint one whale yuan; save or share the note.',
|
|
114
113
|
'usage.bank.noUsage': 'No official DeepSeek usage yet (counting starts when the plugin is enabled)',
|
|
@@ -119,6 +118,11 @@ export const en: Record<UsageKey, string> = {
|
|
|
119
118
|
'usage.bank.save': 'Save image',
|
|
120
119
|
'usage.bank.share': 'Share',
|
|
121
120
|
'usage.bank.drawError': 'Failed to render the voucher: {error}',
|
|
121
|
+
'usage.foot.cost': 'Today spend',
|
|
122
|
+
'usage.foot.noData': 'No usage yet today',
|
|
123
|
+
'usage.foot.open': 'Open usage statistics settings',
|
|
124
|
+
'usage.foot.collapse': 'Collapse the usage card',
|
|
125
|
+
'usage.foot.expand': 'Expand the usage card',
|
|
122
126
|
}
|
|
123
127
|
|
|
124
128
|
export type UsageKey = keyof typeof zh
|
|
@@ -381,3 +381,191 @@
|
|
|
381
381
|
outline: none;
|
|
382
382
|
box-shadow: 0 0 0 2px color-mix(in srgb, currentColor 45%, transparent);
|
|
383
383
|
}
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
/* ---------------------------------------------------------------------------
|
|
387
|
+
Sidebar foot card: the compact usage glance the mount seats as the shell
|
|
388
|
+
foot area's last child, directly below the Settings row. The chrome is an
|
|
389
|
+
outer div; the content is one transparent main button (the whole card opens
|
|
390
|
+
the usage settings section) with a sibling corner chevron toggling the
|
|
391
|
+
collapsed strip — sibling buttons, since nesting buttons is invalid HTML.
|
|
392
|
+
Quiet card on the same currentColor mixes as the section cards; hidden in
|
|
393
|
+
the 56px rail (the frame carries data-sidebar-collapsed).
|
|
394
|
+
--------------------------------------------------------------------------- */
|
|
395
|
+
|
|
396
|
+
/* Seated above the Settings row as a native sidebar row: the shell's row
|
|
397
|
+
radius and hover fill, with none of the card chrome that made it read as a
|
|
398
|
+
pill pasted onto the foot. Only the expanded body keeps a faint surface to
|
|
399
|
+
hold its four lines together; the collapsed strip is a plain 36px row. */
|
|
400
|
+
.footCard {
|
|
401
|
+
position: relative;
|
|
402
|
+
box-sizing: border-box;
|
|
403
|
+
width: 100%;
|
|
404
|
+
margin: 2px 0 4px;
|
|
405
|
+
border: none;
|
|
406
|
+
border-radius: 12px;
|
|
407
|
+
background: color-mix(in srgb, currentColor 4%, transparent);
|
|
408
|
+
color: inherit;
|
|
409
|
+
transition: background-color 120ms ease;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
.footCard:hover {
|
|
413
|
+
background: var(--dsw-alias-interactive-bg-hover);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
.footCardCollapsed {
|
|
417
|
+
background: transparent;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
.footMain {
|
|
421
|
+
appearance: none;
|
|
422
|
+
box-sizing: border-box;
|
|
423
|
+
display: flex;
|
|
424
|
+
flex-direction: column;
|
|
425
|
+
gap: 4px;
|
|
426
|
+
width: 100%;
|
|
427
|
+
padding: 8px;
|
|
428
|
+
border: none;
|
|
429
|
+
border-radius: 12px;
|
|
430
|
+
background: transparent;
|
|
431
|
+
color: inherit;
|
|
432
|
+
font: inherit;
|
|
433
|
+
text-align: left;
|
|
434
|
+
cursor: pointer;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
.footMain:focus-visible {
|
|
438
|
+
outline: none;
|
|
439
|
+
box-shadow: 0 0 0 2px color-mix(in srgb, currentColor 45%, transparent);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
.footToggle {
|
|
443
|
+
appearance: none;
|
|
444
|
+
position: absolute;
|
|
445
|
+
top: 6px;
|
|
446
|
+
right: 6px;
|
|
447
|
+
display: inline-flex;
|
|
448
|
+
align-items: center;
|
|
449
|
+
justify-content: center;
|
|
450
|
+
width: 20px;
|
|
451
|
+
height: 20px;
|
|
452
|
+
padding: 0;
|
|
453
|
+
border: none;
|
|
454
|
+
border-radius: 6px;
|
|
455
|
+
background: transparent;
|
|
456
|
+
color: inherit;
|
|
457
|
+
cursor: pointer;
|
|
458
|
+
opacity: 0.55;
|
|
459
|
+
transition: opacity 120ms ease, background-color 120ms ease;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
.footCard:hover .footToggle {
|
|
463
|
+
opacity: 0.8;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
.footToggle:hover {
|
|
467
|
+
opacity: 1;
|
|
468
|
+
background: color-mix(in srgb, currentColor 10%, transparent);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
.footToggle:focus-visible {
|
|
472
|
+
outline: none;
|
|
473
|
+
box-shadow: 0 0 0 2px color-mix(in srgb, currentColor 45%, transparent);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
.footToggle svg {
|
|
477
|
+
display: block;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
.footHead {
|
|
481
|
+
display: flex;
|
|
482
|
+
align-items: baseline;
|
|
483
|
+
justify-content: space-between;
|
|
484
|
+
gap: 8px;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
.footTitle {
|
|
488
|
+
display: inline-flex;
|
|
489
|
+
align-items: center;
|
|
490
|
+
gap: 6px;
|
|
491
|
+
min-width: 0;
|
|
492
|
+
font-size: 12px;
|
|
493
|
+
opacity: 0.65;
|
|
494
|
+
white-space: nowrap;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
.footTitle svg {
|
|
498
|
+
display: block;
|
|
499
|
+
flex: none;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/* The headline value leaves room for the corner toggle. */
|
|
503
|
+
.footValue {
|
|
504
|
+
margin-right: 18px;
|
|
505
|
+
font-size: 15px;
|
|
506
|
+
font-weight: 600;
|
|
507
|
+
font-variant-numeric: tabular-nums;
|
|
508
|
+
white-space: nowrap;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
.footLine {
|
|
512
|
+
font-size: 11px;
|
|
513
|
+
opacity: 0.7;
|
|
514
|
+
font-variant-numeric: tabular-nums;
|
|
515
|
+
overflow: hidden;
|
|
516
|
+
text-overflow: ellipsis;
|
|
517
|
+
white-space: nowrap;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
.footMeta {
|
|
521
|
+
font-size: 10px;
|
|
522
|
+
opacity: 0.5;
|
|
523
|
+
font-variant-numeric: tabular-nums;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/* Collapsed strip: tighter chrome, one horizontal line, room for the toggle. */
|
|
527
|
+
.footCardCollapsed .footMain {
|
|
528
|
+
min-height: 36px;
|
|
529
|
+
padding: 7px 8px;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
.footStrip {
|
|
533
|
+
display: flex;
|
|
534
|
+
align-items: center;
|
|
535
|
+
gap: 6px;
|
|
536
|
+
padding-right: 20px;
|
|
537
|
+
font-size: 12px;
|
|
538
|
+
white-space: nowrap;
|
|
539
|
+
overflow: hidden;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
.footStrip svg {
|
|
543
|
+
display: block;
|
|
544
|
+
flex: none;
|
|
545
|
+
opacity: 0.65;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
.footStripProvider {
|
|
549
|
+
flex: none;
|
|
550
|
+
max-width: 45%;
|
|
551
|
+
overflow: hidden;
|
|
552
|
+
text-overflow: ellipsis;
|
|
553
|
+
font-weight: 500;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
.footStripLabel {
|
|
557
|
+
opacity: 0.65;
|
|
558
|
+
overflow: hidden;
|
|
559
|
+
text-overflow: ellipsis;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
.footStripValue {
|
|
563
|
+
margin-left: auto;
|
|
564
|
+
font-weight: 600;
|
|
565
|
+
font-variant-numeric: tabular-nums;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
:global([data-dsh-frame][data-sidebar-collapsed]) .footCard,
|
|
569
|
+
:global([data-sidebar-collapsed]) .footCard {
|
|
570
|
+
display: none;
|
|
571
|
+
}
|
package/src/core/adapters.ts
CHANGED
|
@@ -67,6 +67,11 @@ function str(value: unknown): string | undefined {
|
|
|
67
67
|
return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/** Clamp a provider percentage into the renderable 0-100 band. */
|
|
71
|
+
function clampPercent(value: number): number {
|
|
72
|
+
return Math.max(0, Math.min(100, value))
|
|
73
|
+
}
|
|
74
|
+
|
|
70
75
|
/** Format a number to a fixed 2-decimal display string. */
|
|
71
76
|
function money(value: number): string {
|
|
72
77
|
return value.toFixed(2)
|
|
@@ -190,7 +195,26 @@ const KIMI_CODING: ProviderAdapter = {
|
|
|
190
195
|
},
|
|
191
196
|
}
|
|
192
197
|
|
|
193
|
-
/**
|
|
198
|
+
/**
|
|
199
|
+
* GLM Coding Plan quota windows, keyed by the provider's own `unit` code
|
|
200
|
+
* rather than by row position so an added or reordered limit cannot shift a
|
|
201
|
+
* window's meaning: 3 = the rolling 5-hour window, 6 = the weekly window,
|
|
202
|
+
* 5 = the monthly one. Two row kinds carry quota — `TOKENS_LIMIT` (token
|
|
203
|
+
* plans) and `CREDIT_LIMIT` (credit plans, whose exact ratio is
|
|
204
|
+
* `currentValue` / `usage`) — while `TIME_LIMIT` is the MCP request cap and
|
|
205
|
+
* is not a plan window at all. Auth is the RAW key without a Bearer prefix.
|
|
206
|
+
*/
|
|
207
|
+
const GLM_UNIT_KEYS: Readonly<Record<number, string>> = { 3: '5h', 5: 'month', 6: 'week' }
|
|
208
|
+
|
|
209
|
+
/** Percent from a credit row's exact ratio, else the provider's own percentage. */
|
|
210
|
+
function glmCreditPercent(row: Record<string, unknown>): number | undefined {
|
|
211
|
+
const used = toNum(row.currentValue)
|
|
212
|
+
const limit = toNum(row.usage)
|
|
213
|
+
if (used !== undefined && limit !== undefined && limit > 0) return clampPercent((used / limit) * 100)
|
|
214
|
+
const percentage = toNum(row.percentage)
|
|
215
|
+
return percentage === undefined ? undefined : clampPercent(percentage)
|
|
216
|
+
}
|
|
217
|
+
|
|
194
218
|
function glmPlan(host: string, ids: readonly string[]): ProviderAdapter {
|
|
195
219
|
return {
|
|
196
220
|
ids,
|
|
@@ -212,11 +236,21 @@ function glmPlan(host: string, ids: readonly string[]): ProviderAdapter {
|
|
|
212
236
|
for (const entry of limits) {
|
|
213
237
|
if (typeof entry !== 'object' || entry === null) continue
|
|
214
238
|
const row = entry as Record<string, unknown>
|
|
215
|
-
const
|
|
216
|
-
|
|
239
|
+
const kind = str(row.type)
|
|
240
|
+
// The MCP request cap is a per-minute request budget, not a plan window.
|
|
241
|
+
if (kind === 'TIME_LIMIT') continue
|
|
242
|
+
const key = GLM_UNIT_KEYS[toNum(row.unit) ?? Number.NaN]
|
|
243
|
+
// Unknown units fall through: rendering them would invent a window.
|
|
244
|
+
if (key === undefined) continue
|
|
245
|
+
if (kind !== 'TOKENS_LIMIT' && kind !== 'CREDIT_LIMIT') continue
|
|
217
246
|
windows.push({
|
|
218
|
-
key
|
|
219
|
-
percent:
|
|
247
|
+
key,
|
|
248
|
+
percent: kind === 'CREDIT_LIMIT'
|
|
249
|
+
? glmCreditPercent(row)
|
|
250
|
+
: (() => {
|
|
251
|
+
const percentage = toNum(row.percentage)
|
|
252
|
+
return percentage === undefined ? undefined : clampPercent(percentage)
|
|
253
|
+
})(),
|
|
220
254
|
resetsAt: toIso(row.nextResetTime),
|
|
221
255
|
})
|
|
222
256
|
}
|
|
@@ -409,7 +443,7 @@ export const PROVIDER_ADAPTERS: readonly ProviderAdapter[] = [
|
|
|
409
443
|
moonshotBalance('api.moonshot.ai', 'USD', ['moonshotai']),
|
|
410
444
|
KIMI_CODING,
|
|
411
445
|
glmPlan('open.bigmodel.cn', ['zai-coding-cn']),
|
|
412
|
-
glmPlan('api.z.ai', ['zai-coding']),
|
|
446
|
+
glmPlan('api.z.ai', ['zai', 'zai-coding']),
|
|
413
447
|
OPENCODE_GO,
|
|
414
448
|
minimaxPlan('api.minimaxi.com', ['minimax-cn']),
|
|
415
449
|
minimaxPlan('api.minimax.io', ['minimax']),
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alias folding for the enumerated provider routes. Kept in core because the
|
|
3
|
+
* rule is pure — the host enumerates routes and this decides which of two
|
|
4
|
+
* routes serving one adapter family survives.
|
|
5
|
+
* @module @linxin666/dsh-usage/core/provider-routes
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { adapterFor } from './adapters.ts'
|
|
9
|
+
|
|
10
|
+
/** The facts alias folding needs about one enumerated provider route. */
|
|
11
|
+
export interface FoldableRoute {
|
|
12
|
+
/** Provider route key (`deepseek`, `deepseek-official`, custom routes, ...). */
|
|
13
|
+
id: string
|
|
14
|
+
/** Whether the LLM runtime serves requests on this route; a catalog-only entry is dormant. */
|
|
15
|
+
live: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Drop dormant catalog aliases that shadow a live route of the same adapter
|
|
20
|
+
* family. The pi-ai catalog ships route keys the runtime serves nothing on
|
|
21
|
+
* until a profile declares them: for DeepSeek the catalog entry `deepseek`
|
|
22
|
+
* sits beside the llm-deepseek runtime route `deepseek-official`, and both
|
|
23
|
+
* resolve the same credential (the family's `apiKeyEnv` fallback), so probing
|
|
24
|
+
* and rendering both turns one account into two identical balance rows.
|
|
25
|
+
*
|
|
26
|
+
* A family keeps every live route it has — two configured profiles are two
|
|
27
|
+
* real accounts, not aliases. A route with no adapter, or one whose family
|
|
28
|
+
* has no live route at all, is returned untouched.
|
|
29
|
+
*/
|
|
30
|
+
export function foldAliasRoutes<T extends FoldableRoute>(routes: readonly T[]): T[] {
|
|
31
|
+
return routes.filter((route) => {
|
|
32
|
+
if (route.live) return true
|
|
33
|
+
const family = adapterFor(route.id)
|
|
34
|
+
if (family === undefined) return true
|
|
35
|
+
return !routes.some((other) => other.live && adapterFor(other.id) === family)
|
|
36
|
+
})
|
|
37
|
+
}
|
package/src/core/types.ts
CHANGED
|
@@ -140,12 +140,25 @@ export interface ProviderSnapshotView {
|
|
|
140
140
|
export interface UsageOverviewView {
|
|
141
141
|
updatedAt: number
|
|
142
142
|
providers: ProviderSnapshotView[]
|
|
143
|
-
/** The provider the
|
|
143
|
+
/** The provider the collapsed sidebar strip and the header highlight; `source` says how it was picked. */
|
|
144
144
|
current: {
|
|
145
145
|
provider?: string
|
|
146
146
|
model?: string
|
|
147
147
|
/** `live` — last request seen this boot; `default` — the agent default model. */
|
|
148
148
|
source: 'live' | 'default'
|
|
149
|
+
/**
|
|
150
|
+
* Resolved display name (snapshot first, route-derived otherwise), so the
|
|
151
|
+
* strip needs no name-resolution of its own. Optional so an older host
|
|
152
|
+
* document still renders.
|
|
153
|
+
*/
|
|
154
|
+
displayName?: string
|
|
155
|
+
/**
|
|
156
|
+
* Today's ledger totals for the provider's adapter family (the route
|
|
157
|
+
* itself when adapter-less), carrying tokens, calls, and the priced cost.
|
|
158
|
+
* Absent on a day without usage; optional for the same older-host
|
|
159
|
+
* tolerance as displayName.
|
|
160
|
+
*/
|
|
161
|
+
today?: UsageTokenTotals
|
|
149
162
|
}
|
|
150
163
|
usage: {
|
|
151
164
|
today: { date: string; totals: UsageTokenTotals; providers: UsageProviderSummary[] }
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usage trust fence (issue #1592): loopback (the desktop) always passes, and a
|
|
3
|
+
* live paired-device cookie is an additional allow path when remote-web-ui is
|
|
4
|
+
* loaded — the same family fence the skill center uses. Without that service
|
|
5
|
+
* the fence stays loopback-only, so an unpaired LAN browser keeps its 403.
|
|
6
|
+
* The decision logic lives in the generated pair-access.ts copy (shared by
|
|
7
|
+
* git-graph / pet / skill-explorer).
|
|
8
|
+
*/
|
|
9
|
+
import type { IncomingMessage } from 'node:http'
|
|
10
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
11
|
+
import { isPairedOrLoopbackAllowed } from './pair-access.ts'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Whether this request may read the usage overview or trigger a refresh.
|
|
15
|
+
* @param ctx - host context; may expose remoteWebUiPairing.
|
|
16
|
+
* @param request - the incoming HTTP request.
|
|
17
|
+
* @returns true for loopback, or a live paired-device cookie.
|
|
18
|
+
*/
|
|
19
|
+
export function isUsageAllowed(ctx: Context, request: IncomingMessage): boolean {
|
|
20
|
+
return isPairedOrLoopbackAllowed(ctx, request)
|
|
21
|
+
}
|
package/src/host/http.ts
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Shared JSON body/response helpers for the host route families: one strict
|
|
4
4
|
* bounded body reader, one lenient bounded body reader, one JSON object
|
|
5
|
-
* narrow,
|
|
5
|
+
* narrow, one JSON writer, and the outbound request-init helper that keeps a
|
|
6
|
+
* host fetch body decodable (withIdentityEncoding). Previously the readers
|
|
7
|
+
* were copy-pasted across the
|
|
6
8
|
* package route files (routes.ts, update-routes.ts, mobile-api.ts, and each
|
|
7
9
|
* family's route module) with drifting contracts: body caps ranging 4 KiB to
|
|
8
10
|
* 1 MiB and four distinct overflow behaviors (reject, undefined, null, throw).
|
|
@@ -88,6 +90,26 @@ export function asJsonObject(value: unknown): Record<string, unknown> | undefine
|
|
|
88
90
|
return isJsonObject(value) ? value : undefined
|
|
89
91
|
}
|
|
90
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Request init that asks a remote origin for an uncompressed body.
|
|
95
|
+
*
|
|
96
|
+
* The DSH host boots `@deepseek-ai/dsh-http-proxy`, whose top-level import of
|
|
97
|
+
* the npm `undici` copy replaces the legacy `undici.globalDispatcher.1` slot
|
|
98
|
+
* Node's built-in `fetch()` reads. That cross-major wrapper drops the
|
|
99
|
+
* `content-encoding` header and automatic decompression, so a plain fetch
|
|
100
|
+
* resolves to raw gzip/brotli/zstd bytes — compressed noise no JSON or text
|
|
101
|
+
* consumer can read. Every host fetch that parses a remote body must request
|
|
102
|
+
* identity encoding. Caller headers survive; `accept-encoding` is forced to
|
|
103
|
+
* `identity` because a caller value has no decoder behind it in this host.
|
|
104
|
+
* @param init - the caller's request init (signal, headers, method, ...).
|
|
105
|
+
* @returns a copy with `accept-encoding: identity` merged in.
|
|
106
|
+
*/
|
|
107
|
+
export function withIdentityEncoding(init: RequestInit = {}): RequestInit {
|
|
108
|
+
const headers = new Headers(init.headers)
|
|
109
|
+
headers.set('accept-encoding', 'identity')
|
|
110
|
+
return { ...init, headers }
|
|
111
|
+
}
|
|
112
|
+
|
|
91
113
|
/**
|
|
92
114
|
* Write one JSON response. Default headers are the family defaults
|
|
93
115
|
* (content-type and referrer-policy); caller headers are appended or
|