dsh-token-total 1.0.0 → 1.0.2
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.md +5 -4
- package/lib/client.js +566 -159
- package/lib/index.js +355 -54
- package/lib/types/client/ContextUsageEntry.d.ts +22 -0
- package/lib/types/client/context-meter-core.d.ts +26 -0
- package/lib/types/client/context-takeover.d.ts +4 -0
- package/lib/types/client/locales.d.ts +1 -1
- package/lib/types/client/session-usage-core.d.ts +9 -0
- package/lib/types/client/use-session-usage.d.ts +2 -18
- package/lib/types/collector.d.ts +27 -12
- package/lib/types/context-types.d.ts +23 -2
- package/lib/types/model-names.d.ts +6 -0
- package/lib/types/query.d.ts +8 -0
- package/lib/types/session-usage.d.ts +8 -9
- package/lib/types/store.d.ts +32 -0
- package/lib/types/wire.d.ts +30 -1
- package/package.json +1 -1
- package/src/client/ContextUsageEntry.module.css +20 -0
- package/src/client/ContextUsageEntry.tsx +89 -0
- package/src/client/StatsLineEnhanced.tsx +17 -35
- package/src/client/TokenTotalPanel.module.css +72 -5
- package/src/client/TokenTotalPanel.tsx +204 -56
- package/src/client/context-meter-core.ts +54 -0
- package/src/client/context-takeover.ts +35 -0
- package/src/client/index.tsx +21 -3
- package/src/client/locales.ts +4 -0
- package/src/client/session-usage-core.ts +54 -0
- package/src/client/use-session-usage.ts +45 -98
- package/src/collector.ts +138 -67
- package/src/context-types.ts +17 -2
- package/src/index.ts +29 -0
- package/src/model-names.ts +34 -0
- package/src/query.ts +84 -13
- package/src/routes.ts +20 -2
- package/src/session-usage.ts +50 -38
- package/src/store.ts +136 -0
- package/src/wire.ts +24 -1
|
@@ -55,10 +55,8 @@ import {
|
|
|
55
55
|
type ConversationNodeLike, type PartialAssistantLike, type UseProjection, type WindowStats,
|
|
56
56
|
} from './stats-line-core.ts'
|
|
57
57
|
import { formatTokens } from './format.ts'
|
|
58
|
-
import {
|
|
59
|
-
|
|
60
|
-
type SessionTreeReading,
|
|
61
|
-
} from './use-session-usage.ts'
|
|
58
|
+
import { useSessionUsage, type SessionTreeReading } from './use-session-usage.ts'
|
|
59
|
+
import { billedTotal, hasAnyTokens, sessionUsageForDisplay } from './session-usage-core.ts'
|
|
62
60
|
import type { SessionUsageTotals } from '../wire.ts'
|
|
63
61
|
import css from './StatsLineEnhanced.module.css'
|
|
64
62
|
|
|
@@ -299,7 +297,7 @@ function TimePill({ stats, t, dialog, useChat, scale, dock }: {
|
|
|
299
297
|
)
|
|
300
298
|
}
|
|
301
299
|
return (
|
|
302
|
-
<span ref={rootRef} className={anchorClass(dock)}>
|
|
300
|
+
<span ref={rootRef} className={anchorClass(dock)} data-dsh-token-total-stat>
|
|
303
301
|
<button
|
|
304
302
|
type="button"
|
|
305
303
|
className={css.pill}
|
|
@@ -363,10 +361,8 @@ function TimePill({ stats, t, dialog, useChat, scale, dock }: {
|
|
|
363
361
|
}
|
|
364
362
|
|
|
365
363
|
function UsagePill({ usage, tree, t, dialog, dock }: {
|
|
366
|
-
usage: Parameters<typeof billedInputTokens>[0]
|
|
367
|
-
/**
|
|
368
|
-
* answered. Subagent spend is invisible to the native projection, so the
|
|
369
|
-
* pill's total and its dialog both switch to this once it carries any. */
|
|
364
|
+
usage: Parameters<typeof billedInputTokens>[0] | undefined
|
|
365
|
+
/** Descendant spend from the host; the main-agent figure stays live. */
|
|
370
366
|
tree?: SessionTreeReading
|
|
371
367
|
t: T
|
|
372
368
|
/** The row's shared exclusive slot; omitted by a split-mode entry. */
|
|
@@ -375,20 +371,10 @@ function UsagePill({ usage, tree, t, dialog, dock }: {
|
|
|
375
371
|
dock: boolean
|
|
376
372
|
}) {
|
|
377
373
|
const { open, setOpen, rootRef, panelRef, pos } = useStatDialog(dialog)
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
const nativeTotals: SessionUsageTotals = {
|
|
383
|
-
uncachedInputTokens: usage.uncachedInputTokens,
|
|
384
|
-
outputTokens: usage.outputTokens,
|
|
385
|
-
cacheReadTokens: usage.cacheReadTokens,
|
|
386
|
-
cacheWriteTokens: usage.cacheWriteTokens,
|
|
387
|
-
}
|
|
388
|
-
const treeTotals = tree?.totals ?? null
|
|
389
|
-
const subagentTotals = tree?.tree?.subagents
|
|
390
|
-
const showSubagents = subagentTotals !== undefined && hasAnyTokens(subagentTotals)
|
|
391
|
-
const billed = showSubagents && treeTotals !== null ? treeTotals : nativeTotals
|
|
374
|
+
const display = sessionUsageForDisplay(usage, tree?.tree ?? null)
|
|
375
|
+
const subagentTotals = display.subagents
|
|
376
|
+
const showSubagents = display.subagentSessions > 0
|
|
377
|
+
const billed = display.total
|
|
392
378
|
// Same aggregate as the official pill total: every prompt-side billing bucket plus output.
|
|
393
379
|
const total = billedTotal(billed)
|
|
394
380
|
const totalText = t('message.turnUsage.count', { count: formatTokensCompact(total) })
|
|
@@ -397,7 +383,7 @@ function UsagePill({ usage, tree, t, dialog, dock }: {
|
|
|
397
383
|
const exactCount = (value: number): string =>
|
|
398
384
|
t('message.turnUsage.count', { count: formatTokens(value) })
|
|
399
385
|
return (
|
|
400
|
-
<span ref={rootRef} className={anchorClass(dock)}>
|
|
386
|
+
<span ref={rootRef} className={anchorClass(dock)} data-dsh-token-total-stat>
|
|
401
387
|
<button
|
|
402
388
|
type="button"
|
|
403
389
|
className={css.pill}
|
|
@@ -461,10 +447,10 @@ function UsagePill({ usage, tree, t, dialog, dock }: {
|
|
|
461
447
|
<dd>{exactCount(billed.outputTokens)}</dd>
|
|
462
448
|
{/* The split that the native figure cannot show: this session's own
|
|
463
449
|
main agent against every subagent it delegated to. */}
|
|
464
|
-
{showSubagents &&
|
|
450
|
+
{showSubagents && (
|
|
465
451
|
<>
|
|
466
452
|
<dt>{t('stats.dialog.main')}</dt>
|
|
467
|
-
<dd>{exactCount(billedTotal(
|
|
453
|
+
<dd>{exactCount(billedTotal(display.self))}</dd>
|
|
468
454
|
<dt>{t('stats.dialog.subagents')}</dt>
|
|
469
455
|
<dd>
|
|
470
456
|
{exactCount(billedTotal(subagentTotals))}
|
|
@@ -472,7 +458,7 @@ function UsagePill({ usage, tree, t, dialog, dock }: {
|
|
|
472
458
|
{t('stats.dialog.subagentCount', { count: tree?.tree?.subagentSessions ?? 0 })}
|
|
473
459
|
</dd>
|
|
474
460
|
<dt>{t('stats.dialog.treeTotal')}</dt>
|
|
475
|
-
<dd>{exactCount(billedTotal(
|
|
461
|
+
<dd>{exactCount(billedTotal(billed))}</dd>
|
|
476
462
|
</>
|
|
477
463
|
)}
|
|
478
464
|
</dl>
|
|
@@ -506,13 +492,10 @@ export const StatsLineEnhanced = memo(function StatsLineEnhanced(
|
|
|
506
492
|
// density predicted for the same text. 1 (the raw prior) until a settled step
|
|
507
493
|
// carries both, so the first stream of a session still reads sensibly.
|
|
508
494
|
const tokenScale = useMemo(() => measuredTokenScale(settledNodes ?? []) ?? 1, [settledNodes])
|
|
509
|
-
//
|
|
510
|
-
// usage figure moves, which is exactly when a main-agent call settled.
|
|
495
|
+
// Refresh on main-agent settlements and poll for child-only activity.
|
|
511
496
|
const tree = useSessionUsage(sessionId, usageSignature(usage, stats.steps))
|
|
512
|
-
//
|
|
513
|
-
|
|
514
|
-
const hasTokens = usage !== undefined
|
|
515
|
-
&& (billedInputTokens(usage) > 0 || usage.outputTokens > 0)
|
|
497
|
+
// A delegated-only session must still show its usage pill.
|
|
498
|
+
const hasTokens = hasAnyTokens(sessionUsageForDisplay(usage, tree.tree).total)
|
|
516
499
|
if (stats.steps === 0 && !hasTokens) return null
|
|
517
500
|
// data-composer-stats: through host 0.1.6-alpha.1 InputBar tightened the
|
|
518
501
|
// composer bottom clearance while this row rendered (the official pills'
|
|
@@ -585,8 +568,7 @@ export const StatsUsageEntry = memo(function StatsUsageEntry(
|
|
|
585
568
|
) {
|
|
586
569
|
const usage = useProjection('tokenUsage')
|
|
587
570
|
const tree = useSessionUsage(sessionId, usageSignature(usage, usage === undefined ? 0 : 1))
|
|
588
|
-
const hasTokens = usage
|
|
589
|
-
&& (billedInputTokens(usage) > 0 || usage.outputTokens > 0)
|
|
571
|
+
const hasTokens = hasAnyTokens(sessionUsageForDisplay(usage, tree.tree).total)
|
|
590
572
|
if (!hasTokens) return null
|
|
591
573
|
return (
|
|
592
574
|
<UsagePill
|
|
@@ -499,11 +499,26 @@
|
|
|
499
499
|
font-size: 11px;
|
|
500
500
|
}
|
|
501
501
|
|
|
502
|
-
.
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
502
|
+
/* Two ranking cards, side by side: MODELS left, PROVIDERS right. Both are
|
|
503
|
+
always on screen (no lens/tab switch), so they share one row grid and
|
|
504
|
+
stretch to a common height; a narrow pane stacks them instead. */
|
|
505
|
+
.rankCards {
|
|
506
|
+
display: grid;
|
|
507
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
508
|
+
gap: 12px;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
@media (max-width: 720px) {
|
|
512
|
+
.rankCards {
|
|
513
|
+
grid-template-columns: minmax(0, 1fr);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/* Each card is the bordered surface its rows live in; the title is a recessed
|
|
518
|
+
header strip, so the two rankings read as two equal panels. */
|
|
519
|
+
.rankCard {
|
|
520
|
+
display: flex;
|
|
521
|
+
flex-direction: column;
|
|
507
522
|
min-width: 0;
|
|
508
523
|
border: 1px solid var(--dsw-alias-border-l1);
|
|
509
524
|
border-radius: 10px;
|
|
@@ -511,6 +526,29 @@
|
|
|
511
526
|
overflow: hidden;
|
|
512
527
|
}
|
|
513
528
|
|
|
529
|
+
.rankCardTitle {
|
|
530
|
+
padding: 8px 12px;
|
|
531
|
+
border-bottom: 1px solid var(--dsw-alias-border-l1);
|
|
532
|
+
background: color-mix(in srgb, var(--dsw-alias-bg-layer-2) 55%, transparent);
|
|
533
|
+
color: var(--dsw-alias-label-secondary);
|
|
534
|
+
font-size: 12px;
|
|
535
|
+
font-weight: 600;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
.rankList {
|
|
539
|
+
list-style: none;
|
|
540
|
+
margin: 0;
|
|
541
|
+
padding: 0;
|
|
542
|
+
flex: 1 1 auto;
|
|
543
|
+
min-width: 0;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/* Every row opens a hover tip (the cross-dimension detail), so it carries the
|
|
547
|
+
same hover affordance as the chart marks. */
|
|
548
|
+
.modelRow:hover {
|
|
549
|
+
background: color-mix(in srgb, var(--dsw-alias-bg-layer-2) 60%, transparent);
|
|
550
|
+
}
|
|
551
|
+
|
|
514
552
|
/* Position column: the row's rank inside its section. The Other bucket holds
|
|
515
553
|
no single rank, so it renders an empty (but equally wide) cell. */
|
|
516
554
|
.modelRank {
|
|
@@ -667,6 +705,35 @@
|
|
|
667
705
|
margin-bottom: 4px;
|
|
668
706
|
}
|
|
669
707
|
|
|
708
|
+
/* A provider row's raw route id, kept out of the row label but reachable on
|
|
709
|
+
hover (the row shows the display name). */
|
|
710
|
+
.tipSubtitle {
|
|
711
|
+
margin: -2px 0 4px;
|
|
712
|
+
color: var(--dsw-alias-label-tertiary);
|
|
713
|
+
font-size: 12px;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/* Breakdown heading inside a row tip: names the cross-dimension list under it
|
|
717
|
+
("By provider" under a model, "By model" under a provider). */
|
|
718
|
+
.tipLabel {
|
|
719
|
+
margin-bottom: 2px;
|
|
720
|
+
color: var(--dsw-alias-label-tertiary);
|
|
721
|
+
font-size: 12px;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/* The breakdown line pushes its volume to the right edge of the plate. */
|
|
725
|
+
.tipName {
|
|
726
|
+
flex: 1 1 auto;
|
|
727
|
+
min-width: 0;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
.tipValue {
|
|
731
|
+
margin-left: auto;
|
|
732
|
+
padding-left: 12px;
|
|
733
|
+
color: var(--dsw-alias-label-secondary);
|
|
734
|
+
font-variant-numeric: tabular-nums;
|
|
735
|
+
}
|
|
736
|
+
|
|
670
737
|
.tipRow {
|
|
671
738
|
display: flex;
|
|
672
739
|
align-items: center;
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type RefObject } from 'react'
|
|
22
22
|
import clsx from 'clsx'
|
|
23
|
-
import { Activity, ArrowDownToLine, ArrowUpFromLine,
|
|
23
|
+
import { Activity, ArrowDownToLine, ArrowUpFromLine, Coins, Database, MessagesSquare } from 'lucide-react'
|
|
24
24
|
import { Button, Input, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
|
25
25
|
import type { DailyTokenUsage, ModelTokenUsage, ProviderTokenUsage, TokenTotalRange, TokenTotalRequest } from '../wire.ts'
|
|
26
26
|
import { fetchRange, UsageApiError } from './api.ts'
|
|
@@ -52,8 +52,8 @@ const TOP_MODELS = 10
|
|
|
52
52
|
const OTHER_MODEL = '\u0000other' // sentinel; cannot collide with a real model ref
|
|
53
53
|
const OTHER_COLOR = 'var(--dsw-chart-other)'
|
|
54
54
|
|
|
55
|
-
/** How many rows each
|
|
56
|
-
const TOP_BREAKDOWN =
|
|
55
|
+
/** How many rows each ranking card lists (扩展到 8 个排行). */
|
|
56
|
+
const TOP_BREAKDOWN = 8
|
|
57
57
|
|
|
58
58
|
/** Shared empty key set: one stable identity for a collapsed expand state. */
|
|
59
59
|
const NO_KEYS: ReadonlySet<string> = new Set()
|
|
@@ -254,7 +254,14 @@ export function TokenTotalPanel({ t }: { t: Translator }): JSX.Element {
|
|
|
254
254
|
<StatCards stats={stats} t={t} />
|
|
255
255
|
<Heatmap daily={heatDaily} from={heatWindow.from} to={heatWindow.to} t={t} panelRef={panelRef} />
|
|
256
256
|
<DailyTrend models={trendModels} daily={trendDaily} granularity={stats?.granularity ?? 'day'} t={t} colorForModel={colorForModel} panelRef={panelRef} />
|
|
257
|
-
<UsageBreakdown
|
|
257
|
+
<UsageBreakdown
|
|
258
|
+
models={stats.models}
|
|
259
|
+
providers={stats.providers}
|
|
260
|
+
total={stats.tokens}
|
|
261
|
+
providerNames={stats.providerNames}
|
|
262
|
+
t={t}
|
|
263
|
+
panelRef={panelRef}
|
|
264
|
+
/>
|
|
258
265
|
{stats.to && (
|
|
259
266
|
<div className={css.foot}>
|
|
260
267
|
{t('asOf')} {stats.to}
|
|
@@ -275,7 +282,7 @@ export function TokenTotalPanel({ t }: { t: Translator }): JSX.Element {
|
|
|
275
282
|
* (zero input/output but real cache hits) or a failed-call day (requests
|
|
276
283
|
* but no tokens) both carry real usage and must not read as "no data". */
|
|
277
284
|
function isEmptyRange(stats: TokenTotalRange): boolean {
|
|
278
|
-
return stats.tokens === 0 && stats.cacheHit === 0 && stats.requests === 0 && stats.turns === 0
|
|
285
|
+
return stats.tokens === 0 && stats.cacheHit === 0 && stats.requests === 0 && stats.failures === 0 && stats.turns === 0
|
|
279
286
|
}
|
|
280
287
|
|
|
281
288
|
function StatCards({ stats, t }: { stats: TokenTotalRange; t: Translator }) {
|
|
@@ -292,8 +299,10 @@ function StatCards({ stats, t }: { stats: TokenTotalRange; t: Translator }) {
|
|
|
292
299
|
{ icon: Database, label: t('cacheTokens'), value: formatCompact(stats.cacheTokens), sub: `${formatTokens(stats.cacheTokens)} ${t('tokensUnit')}` },
|
|
293
300
|
// 缓存命中率 —cached share of all prompt tokens in the range.
|
|
294
301
|
{ icon: Activity, label: t('cacheRate'), value: cacheRateText(stats.cacheHit, stats.cacheMiss), hint: t('cacheRateHint'), sub: `${formatCompact(stats.cacheHit)} ${t('cachedTokens')}` },
|
|
295
|
-
// 请求数量 —provider calls (tokens or not).
|
|
296
|
-
|
|
302
|
+
// 请求数量 —provider calls (tokens or not). The failed share sits
|
|
303
|
+
// underneath, and only when there were any: a healthy range stays a
|
|
304
|
+
// single line. Failed calls are a SUBSET of requests, never additive.
|
|
305
|
+
{ icon: MessagesSquare, label: t('requests'), value: String(stats.requests), sub: stats.failures > 0 ? `${t('requestsFailed')} ${stats.failures} · ${formatPercent((stats.failures / Math.max(stats.requests, 1)) * 100)}` : undefined },
|
|
297
306
|
]
|
|
298
307
|
return (
|
|
299
308
|
<div className={css.cards}>
|
|
@@ -683,104 +692,243 @@ function DailyTrend({ models, daily, granularity, t, colorForModel, panelRef }:
|
|
|
683
692
|
)
|
|
684
693
|
}
|
|
685
694
|
|
|
686
|
-
// ── Section 6: bottom
|
|
695
|
+
// ── Section 6: bottom ranking — top 8 by model / by provider ──────────────
|
|
687
696
|
//
|
|
688
|
-
//
|
|
689
|
-
//
|
|
697
|
+
// Two side-by-side cards, always on screen (no tabs): the left card ranks
|
|
698
|
+
// MODELS, the right ranks PROVIDERS, each listing its top 8 rows.
|
|
690
699
|
//
|
|
691
|
-
// The by-model
|
|
700
|
+
// The by-model card aggregates by MODEL NAME, not by the provider/model ref:
|
|
692
701
|
// the same model served through two providers must show as ONE row
|
|
693
|
-
// (同名模型不同供应商需要聚合). The by-provider
|
|
694
|
-
// provider upstream, so it ranks the provider rows the host returned
|
|
702
|
+
// (同名模型不同供应商需要聚合). The by-provider card is already aggregated by
|
|
703
|
+
// provider upstream, so it ranks the provider rows the host returned, labelled
|
|
704
|
+
// with the selector's display name (`providerNames`).
|
|
705
|
+
//
|
|
706
|
+
// Both cards keep the same row anatomy — rank, swatch, name, tokens, share,
|
|
707
|
+
// cache hit rate — and move the cross-dimension detail into the hover tip: a
|
|
708
|
+
// model row explains which providers served it, a provider row explains which
|
|
709
|
+
// models it served. Hovering therefore never displaces the share column.
|
|
710
|
+
|
|
711
|
+
/** One child line of a row's hover detail (a provider under a model, or a
|
|
712
|
+
* model under a provider). `tokens` is null when the host payload predates
|
|
713
|
+
* the cross-dimension split and only the source NAME is known. */
|
|
714
|
+
interface RankChild {
|
|
715
|
+
key: string
|
|
716
|
+
name: string
|
|
717
|
+
tokens: number | null
|
|
718
|
+
color: string
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/** The hover detail of one ranked row. The tip itself renders once, in
|
|
722
|
+
* UsageBreakdown, so only one row can be explained at a time. */
|
|
723
|
+
interface RankTip {
|
|
724
|
+
anchor: Element
|
|
725
|
+
title: string
|
|
726
|
+
/** The raw route id, set only when it differs from the display label. */
|
|
727
|
+
subtitle?: string
|
|
728
|
+
tokens: number
|
|
729
|
+
cacheHit: number
|
|
730
|
+
cacheMiss: number
|
|
731
|
+
breakdownLabel: string
|
|
732
|
+
breakdown: RankChild[]
|
|
733
|
+
}
|
|
695
734
|
|
|
696
735
|
interface BreakdownRow {
|
|
697
736
|
key: string
|
|
698
737
|
name: string
|
|
699
|
-
|
|
738
|
+
subtitle?: string
|
|
700
739
|
tokens: number
|
|
701
740
|
cacheHit: number
|
|
702
741
|
cacheMiss: number
|
|
703
742
|
color: string
|
|
743
|
+
breakdownLabel: string
|
|
744
|
+
breakdown: RankChild[]
|
|
704
745
|
}
|
|
705
746
|
|
|
706
|
-
|
|
747
|
+
/** Provider hues run 1..5 then the gray tail (a provider beyond the palette is
|
|
748
|
+
* rare: the card lists 8 rows, the palette names 5). */
|
|
749
|
+
function providerColor(index: number): string {
|
|
750
|
+
return index < 5 ? `var(--dsw-provider-${index + 1})` : 'var(--dsw-provider-other)'
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/** Model hues run 1..10 then the gray Other step (mirrors colorForModel). */
|
|
754
|
+
function modelColor(index: number): string {
|
|
755
|
+
return index < TOP_MODELS ? `var(--dsw-chart-${index + 1})` : OTHER_COLOR
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function UsageBreakdown({ models, providers, total, providerNames, t, panelRef }: {
|
|
707
759
|
models: ModelTokenUsage[]
|
|
708
760
|
providers: ProviderTokenUsage[]
|
|
709
761
|
total: number
|
|
762
|
+
providerNames?: Record<string, string>
|
|
710
763
|
t: Translator
|
|
764
|
+
panelRef: RefObject<HTMLDivElement | null>
|
|
711
765
|
}) {
|
|
712
|
-
const [
|
|
766
|
+
const [tip, setTip] = useState<RankTip | null>(null)
|
|
767
|
+
|
|
768
|
+
// The display label for a provider route: the host-resolved selector name
|
|
769
|
+
// when there is one, else the raw historical id (a deleted/renamed route).
|
|
770
|
+
// The typeof guard keeps a prototype-shaped key ("constructor") from being
|
|
771
|
+
// read as a label.
|
|
772
|
+
const providerLabel = useCallback((provider: string): string => {
|
|
773
|
+
const named = providerNames?.[provider]
|
|
774
|
+
return typeof named === 'string' && named.trim() !== '' ? named : provider
|
|
775
|
+
}, [providerNames])
|
|
776
|
+
|
|
777
|
+
const shareText = (n: number): string => (total > 0 ? formatPercent((n / total) * 100) : '—')
|
|
713
778
|
|
|
714
779
|
const modelRows = useMemo<BreakdownRow[]>(() => {
|
|
715
|
-
const byName = new Map<string, {
|
|
780
|
+
const byName = new Map<string, {
|
|
781
|
+
tokens: number
|
|
782
|
+
cacheHit: number
|
|
783
|
+
cacheMiss: number
|
|
784
|
+
/** Provider -> tokens, from the host's cross-dimension split. */
|
|
785
|
+
split: Map<string, number>
|
|
786
|
+
/** Every provider the host named, split or not. */
|
|
787
|
+
sources: Set<string>
|
|
788
|
+
}>()
|
|
716
789
|
for (const m of models) {
|
|
717
790
|
const name = m.model
|
|
718
|
-
const acc = byName.get(name) ?? { tokens: 0, cacheHit: 0, cacheMiss: 0,
|
|
791
|
+
const acc = byName.get(name) ?? { tokens: 0, cacheHit: 0, cacheMiss: 0, split: new Map<string, number>(), sources: new Set<string>() }
|
|
719
792
|
acc.tokens += m.tokens
|
|
720
793
|
acc.cacheHit += m.cacheHit
|
|
721
794
|
acc.cacheMiss += m.cacheMiss
|
|
722
|
-
for (const
|
|
795
|
+
for (const item of m.providerTokens ?? []) {
|
|
796
|
+
acc.split.set(item.provider, (acc.split.get(item.provider) ?? 0) + item.tokens)
|
|
797
|
+
}
|
|
798
|
+
for (const provider of m.providers ?? (m.provider === '' ? [] : [m.provider])) acc.sources.add(provider)
|
|
723
799
|
byName.set(name, acc)
|
|
724
800
|
}
|
|
725
801
|
return [...byName.entries()]
|
|
726
802
|
.sort((a, b) => b[1].tokens - a[1].tokens)
|
|
727
803
|
.slice(0, TOP_BREAKDOWN)
|
|
728
|
-
.map(([name, acc], i) =>
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
804
|
+
.map(([name, acc], i) => {
|
|
805
|
+
const split: RankChild[] = [...acc.split.entries()]
|
|
806
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
807
|
+
.map(([provider, tokens], j) => ({ key: provider, name: providerLabel(provider), tokens, color: providerColor(j) }))
|
|
808
|
+
// A payload from before the split still names its sources; list them
|
|
809
|
+
// without a volume rather than inventing a number.
|
|
810
|
+
const known = new Set(split.map((child) => child.key))
|
|
811
|
+
const named: RankChild[] = [...acc.sources]
|
|
812
|
+
.filter((provider) => !known.has(provider))
|
|
813
|
+
.sort()
|
|
814
|
+
.map((provider, j) => ({ key: provider, name: providerLabel(provider), tokens: null, color: providerColor(split.length + j) }))
|
|
815
|
+
return {
|
|
816
|
+
key: name,
|
|
817
|
+
name,
|
|
818
|
+
tokens: acc.tokens,
|
|
819
|
+
cacheHit: acc.cacheHit,
|
|
820
|
+
cacheMiss: acc.cacheMiss,
|
|
821
|
+
color: modelColor(i),
|
|
822
|
+
breakdownLabel: t('byProvider'),
|
|
823
|
+
breakdown: [...split, ...named],
|
|
824
|
+
}
|
|
825
|
+
})
|
|
826
|
+
}, [models, providerLabel, t])
|
|
738
827
|
|
|
739
828
|
const providerRows = useMemo<BreakdownRow[]>(() => {
|
|
740
829
|
return [...providers]
|
|
741
830
|
.filter((p) => p.tokens > 0)
|
|
742
831
|
.sort((a, b) => b.tokens - a.tokens)
|
|
743
832
|
.slice(0, TOP_BREAKDOWN)
|
|
744
|
-
.map((p, i) =>
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
833
|
+
.map((p, i) => {
|
|
834
|
+
const label = providerLabel(p.provider)
|
|
835
|
+
return {
|
|
836
|
+
key: p.provider,
|
|
837
|
+
name: label,
|
|
838
|
+
// Keep the route id reachable: the row shows the display name only.
|
|
839
|
+
subtitle: label === p.provider ? undefined : p.provider,
|
|
840
|
+
tokens: p.tokens,
|
|
841
|
+
cacheHit: p.cacheHit,
|
|
842
|
+
cacheMiss: p.cacheMiss,
|
|
843
|
+
color: providerColor(i),
|
|
844
|
+
breakdownLabel: t('byModel'),
|
|
845
|
+
breakdown: [...(p.modelTokens ?? [])]
|
|
846
|
+
.sort((a, b) => b.tokens - a.tokens || a.model.localeCompare(b.model))
|
|
847
|
+
.map((m, j) => ({ key: m.model, name: m.model, tokens: m.tokens, color: modelColor(j) })),
|
|
848
|
+
}
|
|
849
|
+
})
|
|
850
|
+
}, [providers, providerLabel, t])
|
|
756
851
|
|
|
757
852
|
return (
|
|
758
853
|
<section className={css.section}>
|
|
759
854
|
<div className={css.sectionHead}>
|
|
760
|
-
{/* One
|
|
761
|
-
|
|
762
|
-
section rather than the current lens. */}
|
|
855
|
+
{/* One heading for the whole section: both rankings are always on
|
|
856
|
+
screen, side by side, so there is no lens to switch. */}
|
|
763
857
|
<h3 className={css.sectionTitle}>{t('usageRanking')}</h3>
|
|
764
|
-
<div className={css.group} role="tablist" aria-label={t('usageRanking')}>
|
|
765
|
-
<Button size="sm" variant="ghost" role="tab" aria-selected={tab === 'model'} className={clsx(css.segItem, tab === 'model' && css.segActive)} onClick={() => setTab('model')}>
|
|
766
|
-
{t('byModel')}
|
|
767
|
-
</Button>
|
|
768
|
-
<Button size="sm" variant="ghost" role="tab" aria-selected={tab === 'provider'} className={clsx(css.segItem, tab === 'provider' && css.segActive)} onClick={() => setTab('provider')}>
|
|
769
|
-
{t('byProvider')}
|
|
770
|
-
</Button>
|
|
771
|
-
</div>
|
|
772
858
|
</div>
|
|
859
|
+
<div className={css.rankCards}>
|
|
860
|
+
<RankCard title={t('byModel')} rows={modelRows} total={total} t={t} onTip={setTip} />
|
|
861
|
+
<RankCard title={t('byProvider')} rows={providerRows} total={total} t={t} onTip={setTip} />
|
|
862
|
+
</div>
|
|
863
|
+
<ChartTip anchor={tip?.anchor ?? null} panelRef={panelRef}>
|
|
864
|
+
{tip && (
|
|
865
|
+
<>
|
|
866
|
+
<div className={css.tipTitle}>{tip.title}</div>
|
|
867
|
+
{tip.subtitle !== undefined && <div className={css.tipSubtitle}>{tip.subtitle}</div>}
|
|
868
|
+
<div className={css.tipRow}>{t('total')} {formatTokens(tip.tokens)} · {shareText(tip.tokens)}</div>
|
|
869
|
+
<div className={css.tipRow}>{t('cacheRateColumn')} {cacheRateText(tip.cacheHit, tip.cacheMiss)}</div>
|
|
870
|
+
{tip.breakdown.length > 0 && (
|
|
871
|
+
<div className={css.tipBreakdown}>
|
|
872
|
+
<div className={css.tipLabel}>{tip.breakdownLabel}</div>
|
|
873
|
+
{tip.breakdown.map((child) => (
|
|
874
|
+
<div key={child.key} className={css.tipRow}>
|
|
875
|
+
<i className={css.legendSwatch} style={{ background: child.color }} />
|
|
876
|
+
<span className={css.tipName}>{child.name}</span>
|
|
877
|
+
<span className={css.tipValue}>
|
|
878
|
+
{child.tokens === null ? '' : `${formatCompact(child.tokens)} · ${shareText(child.tokens)}`}
|
|
879
|
+
</span>
|
|
880
|
+
</div>
|
|
881
|
+
))}
|
|
882
|
+
</div>
|
|
883
|
+
)}
|
|
884
|
+
</>
|
|
885
|
+
)}
|
|
886
|
+
</ChartTip>
|
|
887
|
+
</section>
|
|
888
|
+
)
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/** One ranking card: a titled list whose rows explain themselves on hover.
|
|
892
|
+
* The tip is owned by UsageBreakdown (one open tip at a time), so a row only
|
|
893
|
+
* reports which row it is, never where to draw. */
|
|
894
|
+
function RankCard({ title, rows, total, t, onTip }: {
|
|
895
|
+
title: string
|
|
896
|
+
rows: BreakdownRow[]
|
|
897
|
+
total: number
|
|
898
|
+
t: Translator
|
|
899
|
+
onTip: (tip: RankTip | null) => void
|
|
900
|
+
}) {
|
|
901
|
+
const share = (n: number): string => (total > 0 ? formatPercent((n / total) * 100) : '—')
|
|
902
|
+
return (
|
|
903
|
+
<div className={css.rankCard}>
|
|
904
|
+
<div className={css.rankCardTitle}>{title}</div>
|
|
773
905
|
{rows.length === 0 ? (
|
|
774
906
|
<div className={css.empty}>{t('empty')}</div>
|
|
775
907
|
) : (
|
|
776
|
-
<ul className={css.
|
|
908
|
+
<ul className={css.rankList} data-dsh-usage-glass="list">
|
|
777
909
|
{rows.map((r, rank) => (
|
|
778
|
-
<li
|
|
910
|
+
<li
|
|
911
|
+
key={r.key}
|
|
912
|
+
className={css.modelRow}
|
|
913
|
+
onMouseEnter={(e) => onTip({
|
|
914
|
+
anchor: e.currentTarget,
|
|
915
|
+
title: r.name,
|
|
916
|
+
subtitle: r.subtitle,
|
|
917
|
+
tokens: r.tokens,
|
|
918
|
+
cacheHit: r.cacheHit,
|
|
919
|
+
cacheMiss: r.cacheMiss,
|
|
920
|
+
breakdownLabel: r.breakdownLabel,
|
|
921
|
+
breakdown: r.breakdown,
|
|
922
|
+
})}
|
|
923
|
+
onMouseLeave={() => onTip(null)}
|
|
924
|
+
>
|
|
779
925
|
<span className={css.modelRank} aria-hidden="true">{rank + 1}</span>
|
|
780
926
|
<i className={css.legendSwatch} style={{ background: r.color }} />
|
|
781
927
|
<div className={css.modelId}>
|
|
782
928
|
<span className={css.modelName}>{r.name}</span>
|
|
783
|
-
|
|
929
|
+
{/* The share keeps its own line: the hover detail never
|
|
930
|
+
displaces it (悬浮详情不占用百分比的位置). */}
|
|
931
|
+
<span className={css.modelProvider}>{share(r.tokens)}</span>
|
|
784
932
|
</div>
|
|
785
933
|
<div className={css.modelValues}>
|
|
786
934
|
<span className={css.modelTokens}>{formatCompact(r.tokens)}</span>
|
|
@@ -790,7 +938,7 @@ function UsageBreakdown({ models, providers, total, t }: {
|
|
|
790
938
|
))}
|
|
791
939
|
</ul>
|
|
792
940
|
)}
|
|
793
|
-
</
|
|
941
|
+
</div>
|
|
794
942
|
)
|
|
795
943
|
}
|
|
796
944
|
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export interface ContextPressureLike {
|
|
2
|
+
projectedTokens?: number
|
|
3
|
+
pressureTokens?: number
|
|
4
|
+
contextWindow?: number
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface ContextBreakdownLike {
|
|
8
|
+
systemTokens: number
|
|
9
|
+
toolsTokens: number
|
|
10
|
+
messageTokens: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ContextOccupancy {
|
|
14
|
+
percent: number
|
|
15
|
+
usedTokens: number
|
|
16
|
+
contextWindow: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Mirrors the host meter's occupancy calculation exactly. */
|
|
20
|
+
export function contextOccupancy(pressure: ContextPressureLike | undefined): ContextOccupancy | null {
|
|
21
|
+
const usedTokens = pressure?.projectedTokens ?? pressure?.pressureTokens
|
|
22
|
+
if (usedTokens === undefined || pressure?.contextWindow === undefined) return null
|
|
23
|
+
return {
|
|
24
|
+
percent: Math.min(100, Math.round(usedTokens / pressure.contextWindow * 100)),
|
|
25
|
+
usedTokens,
|
|
26
|
+
contextWindow: pressure.contextWindow,
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function contextBreakdownTotal(breakdown: ContextBreakdownLike | undefined): number {
|
|
31
|
+
return breakdown === undefined ? 0 : breakdown.systemTokens + breakdown.toolsTokens + breakdown.messageTokens
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function contextBreakdownSegments(
|
|
35
|
+
percent: number,
|
|
36
|
+
breakdown: ContextBreakdownLike | undefined,
|
|
37
|
+
): Array<{ key: string; width: number }> {
|
|
38
|
+
const total = contextBreakdownTotal(breakdown)
|
|
39
|
+
if (breakdown === undefined || total === 0) return [{ key: 'total', width: percent }]
|
|
40
|
+
return [
|
|
41
|
+
{ key: 'systemTokens', width: percent * breakdown.systemTokens / total },
|
|
42
|
+
{ key: 'toolsTokens', width: percent * breakdown.toolsTokens / total },
|
|
43
|
+
{ key: 'messageTokens', width: percent * breakdown.messageTokens / total },
|
|
44
|
+
].filter(segment => segment.width > 0)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function formatContextTokens(value: number, units: { thousand: string; million: string }): string {
|
|
48
|
+
const scaled = (candidate: number): string => candidate >= 100
|
|
49
|
+
? String(Math.round(candidate))
|
|
50
|
+
: String(Math.round(candidate * 10) / 10)
|
|
51
|
+
if (value < 1e3) return String(value)
|
|
52
|
+
if (value < 1e6) return units.thousand.replace('{value}', scaled(value / 1e3))
|
|
53
|
+
return units.million.replace('{value}', scaled(value / 1e6))
|
|
54
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** The host ContextMeter is hard-coded beside composer.dock, not a slot.
|
|
2
|
+
* Suppress only that recognizable sibling while our replacement is mounted;
|
|
3
|
+
* never hide a whole dock, slot wrapper, or another plugin's readout. */
|
|
4
|
+
export function installContextTakeover(anchor: HTMLElement): () => void {
|
|
5
|
+
const dock = anchor.closest<HTMLElement>('[data-composer-dock]')
|
|
6
|
+
const view = anchor.ownerDocument.defaultView
|
|
7
|
+
if (dock === null || view === null) return () => {}
|
|
8
|
+
const hidden = new Map<HTMLElement, { display: string; priority: string }>()
|
|
9
|
+
const reconcile = (): void => {
|
|
10
|
+
for (const sibling of Array.from(dock.children)) {
|
|
11
|
+
if (!(sibling instanceof view.HTMLElement) || sibling.tagName !== 'SPAN'
|
|
12
|
+
|| sibling.contains(anchor) || sibling.hasAttribute('data-slot-entry')
|
|
13
|
+
|| sibling.hasAttribute('data-dsh-token-total-stat')
|
|
14
|
+
|| hidden.has(sibling)) continue
|
|
15
|
+
// ContextMeter's stable SVG geometry, not a private CSS-module hash or
|
|
16
|
+
// a translated label. Current hosts mount its span directly in the dock.
|
|
17
|
+
if (sibling.querySelector('button[aria-haspopup="dialog"] svg[viewBox="0 0 14 14"] circle[cx="7"][cy="7"][r="5.5"]') === null) continue
|
|
18
|
+
hidden.set(sibling, { display: sibling.style.getPropertyValue('display'), priority: sibling.style.getPropertyPriority('display') })
|
|
19
|
+
sibling.setAttribute('data-dsh-token-total-native-context', '')
|
|
20
|
+
sibling.style.setProperty('display', 'none', 'important')
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
reconcile()
|
|
24
|
+
const observer = new view.MutationObserver(reconcile)
|
|
25
|
+
observer.observe(dock, { childList: true, subtree: true })
|
|
26
|
+
return () => {
|
|
27
|
+
observer.disconnect()
|
|
28
|
+
for (const [element, previous] of hidden) {
|
|
29
|
+
element.removeAttribute('data-dsh-token-total-native-context')
|
|
30
|
+
if (previous.display === '') element.style.removeProperty('display')
|
|
31
|
+
else element.style.setProperty('display', previous.display, previous.priority)
|
|
32
|
+
}
|
|
33
|
+
hidden.clear()
|
|
34
|
+
}
|
|
35
|
+
}
|