feat: add economy/maximum presets, tool-lab and subagent-cursor extensions
Some checks failed
CI / windows node 24 / wine blocking (push) Has been skipped
CI / node 22.19 (push) Has been skipped
CI / node 26 (push) Has been skipped
CI / python 3.10 / keyless SDK (push) Has been skipped
CI / python runtime / release-shaped Linux x64 (push) Has been skipped
CI / wine apt cache (push) Successful in 7s
CI / serial / linux (push) Has been skipped
Deploy documentation / build (push) Failing after 1m25s
Deploy documentation / deploy (push) Has been skipped
Landlock Run / Matrix (push) Successful in 5s
Release (vendor) / Pack npm tarballs (push) Failing after 2m47s
Release (dsh) / Pack npm tarballs (push) Failing after 1m56s
Sandbox / sandbox e2e (landlock, ubuntu-24.04) (push) Failing after 1m57s
Sandbox / sandbox e2e (bwrap, ubuntu-latest) (push) Failing after 1m19s
Release (vendor) / Publish to npm (push) Has been skipped
Release (dsh) / Publish to npm (push) Has been skipped
CI / serial / windows (self-hosted standby) (push) Has been cancelled
CI / larger-runner-benchmark (16, linux, dsh-ubuntu-24-04-16core, typecheck) (push) Has been cancelled
Landlock Run / darwin (no platform package — degradation proof) (push) Has been cancelled
Landlock Run / ${{ matrix.platform }} (push) Has been cancelled
CI / node 24 / static (push) Has been cancelled
CI / node 24 / coverage (push) Has been cancelled
CI / node 24 / snapshots and artifacts (push) Has been cancelled
CI / windows node 24 / native complete (push) Has been cancelled
CI / serial / linux (self-hosted standby) (push) Has been cancelled
CI / serial / macos (push) Has been cancelled
CI / larger-runner-benchmark (16, windows, dsh-windows-2025-16core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (32, linux, dsh-ubuntu-24-04-32core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (32, windows, dsh-windows-2025-32core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (4, linux, dsh-ubuntu-24-04-4core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (4, windows, dsh-windows-2025-4core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (64, linux, dsh-ubuntu-24-04-64core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (64, windows, dsh-windows-2025-64core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (8, windows, dsh-windows-2025-8core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (96, linux, dsh-ubuntu-24-04-96core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (96, windows, dsh-windows-2025-96core, production-site) (push) Has been cancelled
CI / consolidated-runner-benchmark (16, linux, dsh-ubuntu-24-04-16core, 16) (push) Has been cancelled
CI / consolidated-runner-benchmark (16, windows, dsh-windows-2025-16core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (32, linux, dsh-ubuntu-24-04-32core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (32, windows, dsh-windows-2025-32core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (4, linux, dsh-ubuntu-24-04-4core, 4) (push) Has been cancelled
CI / consolidated-runner-benchmark (4, windows, dsh-windows-2025-4core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (64, linux, dsh-ubuntu-24-04-64core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (64, windows, dsh-windows-2025-64core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (8, linux, dsh-ubuntu-24-04-8core, 8) (push) Has been cancelled
CI / consolidated-runner-benchmark (8, windows, dsh-windows-2025-8core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (96, linux, dsh-ubuntu-24-04-96core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (96, windows, dsh-windows-2025-96core, 2) (push) Has been cancelled
CI / all checks passed (push) Has been cancelled
Sandbox / sandbox e2e (seatbelt, macos-latest) (push) Has been cancelled
CI / larger-runner-benchmark (8, linux, dsh-ubuntu-24-04-8core, typecheck) (push) Has been cancelled
Sandbox / sandbox e2e (landlock, ubuntu-24.04-arm) (push) Has been cancelled
E2E (real DeepSeek API) / e2e (push) Failing after 1m24s
Some checks failed
CI / windows node 24 / wine blocking (push) Has been skipped
CI / node 22.19 (push) Has been skipped
CI / node 26 (push) Has been skipped
CI / python 3.10 / keyless SDK (push) Has been skipped
CI / python runtime / release-shaped Linux x64 (push) Has been skipped
CI / wine apt cache (push) Successful in 7s
CI / serial / linux (push) Has been skipped
Deploy documentation / build (push) Failing after 1m25s
Deploy documentation / deploy (push) Has been skipped
Landlock Run / Matrix (push) Successful in 5s
Release (vendor) / Pack npm tarballs (push) Failing after 2m47s
Release (dsh) / Pack npm tarballs (push) Failing after 1m56s
Sandbox / sandbox e2e (landlock, ubuntu-24.04) (push) Failing after 1m57s
Sandbox / sandbox e2e (bwrap, ubuntu-latest) (push) Failing after 1m19s
Release (vendor) / Publish to npm (push) Has been skipped
Release (dsh) / Publish to npm (push) Has been skipped
CI / serial / windows (self-hosted standby) (push) Has been cancelled
CI / larger-runner-benchmark (16, linux, dsh-ubuntu-24-04-16core, typecheck) (push) Has been cancelled
Landlock Run / darwin (no platform package — degradation proof) (push) Has been cancelled
Landlock Run / ${{ matrix.platform }} (push) Has been cancelled
CI / node 24 / static (push) Has been cancelled
CI / node 24 / coverage (push) Has been cancelled
CI / node 24 / snapshots and artifacts (push) Has been cancelled
CI / windows node 24 / native complete (push) Has been cancelled
CI / serial / linux (self-hosted standby) (push) Has been cancelled
CI / serial / macos (push) Has been cancelled
CI / larger-runner-benchmark (16, windows, dsh-windows-2025-16core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (32, linux, dsh-ubuntu-24-04-32core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (32, windows, dsh-windows-2025-32core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (4, linux, dsh-ubuntu-24-04-4core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (4, windows, dsh-windows-2025-4core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (64, linux, dsh-ubuntu-24-04-64core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (64, windows, dsh-windows-2025-64core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (8, windows, dsh-windows-2025-8core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (96, linux, dsh-ubuntu-24-04-96core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (96, windows, dsh-windows-2025-96core, production-site) (push) Has been cancelled
CI / consolidated-runner-benchmark (16, linux, dsh-ubuntu-24-04-16core, 16) (push) Has been cancelled
CI / consolidated-runner-benchmark (16, windows, dsh-windows-2025-16core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (32, linux, dsh-ubuntu-24-04-32core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (32, windows, dsh-windows-2025-32core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (4, linux, dsh-ubuntu-24-04-4core, 4) (push) Has been cancelled
CI / consolidated-runner-benchmark (4, windows, dsh-windows-2025-4core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (64, linux, dsh-ubuntu-24-04-64core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (64, windows, dsh-windows-2025-64core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (8, linux, dsh-ubuntu-24-04-8core, 8) (push) Has been cancelled
CI / consolidated-runner-benchmark (8, windows, dsh-windows-2025-8core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (96, linux, dsh-ubuntu-24-04-96core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (96, windows, dsh-windows-2025-96core, 2) (push) Has been cancelled
CI / all checks passed (push) Has been cancelled
Sandbox / sandbox e2e (seatbelt, macos-latest) (push) Has been cancelled
CI / larger-runner-benchmark (8, linux, dsh-ubuntu-24-04-8core, typecheck) (push) Has been cancelled
Sandbox / sandbox e2e (landlock, ubuntu-24.04-arm) (push) Has been cancelled
E2E (real DeepSeek API) / e2e (push) Failing after 1m24s
- new economy and maximum agent presets with three-role pipeline skill - new packages/extensions/tool-lab (home-lab ComfyUI/Docling/Whishper tools) - new packages/subagent/subagent-cursor provider - openrouter balance UI with on-demand refresh - session projection context-seed boundary fold - regenerate docs catalogs; keep local searxng benchmark scripts
This commit is contained in:
@@ -8,6 +8,7 @@ export type AgentPresetSettingsKey =
|
||||
| 'presetCodeName' | 'presetCodeDescription'
|
||||
| 'presetMinimalName' | 'presetMinimalDescription'
|
||||
| 'presetCordisName' | 'presetCordisDescription'
|
||||
| 'presetMaximumName' | 'presetMaximumDescription'
|
||||
| 'duplicate' | 'duplicateUnavailable' | 'delete' | 'presetId' | 'presetIdPlaceholder' | 'copyOf'
|
||||
| 'displayName' | 'displayNamePlaceholder'
|
||||
| 'inUse' | 'noDescription' | 'builtInGroup' | 'customGroup'
|
||||
@@ -46,6 +47,9 @@ export const en: Record<AgentPresetSettingsKey, string> = {
|
||||
presetCordisName: 'Creator mode',
|
||||
presetCordisDescription:
|
||||
'Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, plugin experiments, and preset-authoring guidance.',
|
||||
presetMaximumName: 'Maximum mode',
|
||||
presetMaximumDescription:
|
||||
'Every capability this deployment can compose — terminals, language servers, session history, MCP, external agents, and Code Mode beside the native tools — delivering each change through an architect, an implementer, and a reviewer.',
|
||||
duplicate: 'Duplicate',
|
||||
duplicateUnavailable: 'This deployment has no writable preset directory',
|
||||
delete: 'Delete',
|
||||
@@ -106,6 +110,8 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
|
||||
presetMinimalDescription: '仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。',
|
||||
presetCordisName: '创造模式',
|
||||
presetCordisDescription: '用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、插件实验和 preset 创作指导。',
|
||||
presetMaximumName: '全能模式',
|
||||
presetMaximumDescription: '组合本部署能提供的全部能力 —— 终端、语言服务器、会话历史、MCP、外部 Agent,以及与原生工具并存的 Code Mode —— 并由架构师、实现者、审查者三个角色接力交付每一次改动。',
|
||||
duplicate: '复制',
|
||||
duplicateUnavailable: '此部署未配置可写的预设目录',
|
||||
delete: '删除',
|
||||
@@ -171,6 +177,7 @@ const BUILT_IN_PRESET_KEYS: Readonly<Partial<Record<string, PresetLocaleKeys>>>
|
||||
code: { name: 'presetCodeName', description: 'presetCodeDescription' },
|
||||
minimal: { name: 'presetMinimalName', description: 'presetMinimalDescription' },
|
||||
cordis: { name: 'presetCordisName', description: 'presetCordisDescription' },
|
||||
maximum: { name: 'presetMaximumName', description: 'presetMaximumDescription' },
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,13 +13,21 @@ only once a step actually priced — no zero-cost group. An unknown-pricing step
|
||||
count surfaces in a tooltip so the figure's coverage stays visible. `CostDock`
|
||||
therefore owns no store, refresh chain, or event listener.
|
||||
|
||||
The figure covers the session plus its subagent subtree, walked over the
|
||||
session list's `parentId`/`origin` rows; a subtree share names itself in the
|
||||
label. Each projection value describes only its own session's work — the host
|
||||
fold excludes a forked child's inherited prefix — so the walk sums rather than
|
||||
double counts. Ordinary forks are not delegated work and stay out of it.
|
||||
|
||||
`BalanceBadge` is account-global, so it is not a session projection: the slot
|
||||
inject face carries a `snapshot()` callback that polls the host
|
||||
`ctx.remote.openRouterUsage.snapshot()` Remote on a 60s interval and drives
|
||||
local state. In the collapsed rail it renders only the balance marker; in the
|
||||
wide column a labeled pill. Unknown or absent figures render the empty
|
||||
placeholder. Both entries render nothing before real data exists, so an
|
||||
assembly without the OpenRouter gateway or key costs no layout.
|
||||
local state. Clicking the badge calls the `refresh()` callback instead, which
|
||||
makes the host fetch the account immediately; the badge marks itself
|
||||
`aria-busy` for the duration and ignores further clicks until it settles, and
|
||||
a failed refresh keeps the last-known figure. In the collapsed rail it renders
|
||||
only the balance marker; in the wide column a labeled pill. Unknown or absent
|
||||
figures render the empty placeholder.
|
||||
|
||||
The `/client` exports are the plugin body (`apply`/`inject`), the
|
||||
`CostDock`/`BalanceBadge` components, and the injected face types.
|
||||
@@ -42,5 +50,10 @@ account figure; neither is a new model-visible input.
|
||||
projection holds; figures for already-folded history reflect the pricing
|
||||
table at fold time (see `dsh-openrouter-usage`).
|
||||
- **Polled, not pushed** — `BalanceBadge` polls the host snapshot; there is
|
||||
no `openRouterUsage` forwarded-event channel, so the figure updates on the
|
||||
poll interval rather than instantly.
|
||||
no `openRouterUsage` forwarded-event channel, so an account change the user
|
||||
did not click for surfaces on the poll interval rather than instantly.
|
||||
- **A cold subagent contributes nothing** — the subtree walk reads the
|
||||
descendants' projection values the client holds. A subagent session never
|
||||
opened, and with no version-matching row in the host's cold projection
|
||||
cache, carries no value and is indistinguishable from a zero-cost child, so
|
||||
its spend is missing from the total rather than marked absent.
|
||||
|
||||
@@ -14,7 +14,12 @@
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 12px;
|
||||
line-height: 28px;
|
||||
cursor: default;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.badge[aria-busy='true'] {
|
||||
cursor: progress;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.badge:hover {
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
// plus the latest snapshot's USD figure in both widths (rail = marker-only,
|
||||
// wide = label + figure). The value is account-global, so it is not a session
|
||||
// projection: an injected `snapshot` callback polls the host Remote gateway on
|
||||
// an interval and drives local state. `wide` arrives from the sidebar's owner
|
||||
// share; `snapshot` from the register's inject face.
|
||||
// an interval, a click runs `refresh` for an on-demand read, and both drive
|
||||
// local state. `wide` arrives from the sidebar's owner share; the two
|
||||
// callbacks from the register's inject face.
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { OpenRouterBalance } from '@deepseek-ai/dsh-openrouter-usage/client'
|
||||
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { formatUsd } from './money.ts'
|
||||
@@ -14,25 +15,34 @@ import css from './BalanceBadge.module.css'
|
||||
/** Poll interval for the balance figure, ms. */
|
||||
export const BALANCE_POLL_MS = 60_000
|
||||
|
||||
/** Injected face: the balance read callback the badge polls. */
|
||||
/** Injected face: the balance reads the badge polls and re-runs on demand. */
|
||||
export interface BalanceBadgeActions {
|
||||
/** Fetch the latest account snapshot from the host gateway. */
|
||||
/** Read the host gateway's cached account snapshot. */
|
||||
snapshot: () => Promise<OpenRouterBalance>
|
||||
/** Make the host fetch the account NOW and serve the result. */
|
||||
refresh: () => Promise<OpenRouterBalance>
|
||||
}
|
||||
|
||||
/** Composed props: the sidebar footer-action owner share plus the injected read. */
|
||||
/** Composed props: the sidebar footer-action owner share plus the injected reads. */
|
||||
export type BalanceBadgeProps =
|
||||
PropsRuntime<'sidebar.footer.action'> & BalanceBadgeActions & PropsLocale<'openRouterUsage'>
|
||||
|
||||
/**
|
||||
* Poll `snapshot` on an interval and render the account balance. Unknown or
|
||||
* absent figures render the empty placeholder so the seat costs no confusion.
|
||||
* A transient failure keeps the last-known figure and retries next tick.
|
||||
* @param props - owner state plus the injected snapshot callback.
|
||||
* Poll `snapshot` on an interval, re-read through `refresh` when the user
|
||||
* clicks, and render the account balance. Unknown or absent figures render the
|
||||
* empty placeholder so the seat costs no confusion. A transient failure keeps
|
||||
* the last-known figure and retries next tick; a click while one refresh is
|
||||
* still outstanding is ignored rather than queued.
|
||||
* @param props - owner state plus the injected snapshot/refresh callbacks.
|
||||
* @returns the balance badge.
|
||||
*/
|
||||
export function BalanceBadge({ wide, snapshot, t }: BalanceBadgeProps) {
|
||||
export function BalanceBadge({ wide, snapshot, refresh, t }: BalanceBadgeProps) {
|
||||
const [balance, setBalance] = useState<OpenRouterBalance | undefined>(undefined)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
// Survives the unmount a disposed registration causes mid-fetch: the
|
||||
// resolving callback must not set state on a gone component.
|
||||
const mounted = useRef(true)
|
||||
useEffect(() => () => { mounted.current = false }, [])
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false
|
||||
@@ -53,14 +63,38 @@ export function BalanceBadge({ wide, snapshot, t }: BalanceBadgeProps) {
|
||||
}
|
||||
}, [snapshot])
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
if (refreshing) return
|
||||
setRefreshing(true)
|
||||
void (async () => {
|
||||
try {
|
||||
const next = await refresh()
|
||||
if (mounted.current) setBalance(next)
|
||||
} catch (_failedRefresh) {
|
||||
// The click keeps the last-known figure; the interval poll retries.
|
||||
} finally {
|
||||
if (mounted.current) setRefreshing(false)
|
||||
}
|
||||
})()
|
||||
}, [refresh, refreshing])
|
||||
|
||||
const usd = balance?.balanceUsd ?? null
|
||||
const amount = usd === null
|
||||
? t('balance.empty')
|
||||
: formatUsd(usd)
|
||||
const label = t('balance.label', { amount })
|
||||
const tooltip = balance?.label == null ? undefined : t('balance.tooltip', { label: balance.label })
|
||||
const tooltip = refreshing
|
||||
? t('balance.refreshing')
|
||||
: balance?.label == null ? undefined : t('balance.tooltip', { label: balance.label })
|
||||
return (
|
||||
<button type="button" className={css.badge} title={tooltip} aria-label={label}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.badge}
|
||||
title={tooltip}
|
||||
aria-label={label}
|
||||
aria-busy={refreshing}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className={css.marker} aria-hidden>◈</span>
|
||||
{wide && <span className={css.text}>{label}</span>}
|
||||
</button>
|
||||
|
||||
@@ -2,9 +2,15 @@
|
||||
// Reads the durable openRouterCost projection, so paging and compaction
|
||||
// cannot change the figure; the entry renders nothing until at least one
|
||||
// step priced (no zero-cost group, mirroring the stats line's billing gate).
|
||||
//
|
||||
// The figure covers the session plus its subagent subtree. Each projection
|
||||
// value describes only its own session's work — the host fold excludes a
|
||||
// forked child's inherited prefix — so summing the lineage is a sum, not a
|
||||
// double count.
|
||||
|
||||
import { memo } from 'react'
|
||||
import { memo, useMemo } from 'react'
|
||||
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: merges openRouterCost into SessionProjectionMap for useProjection.
|
||||
import type {} from '@deepseek-ai/dsh-openrouter-usage/client'
|
||||
import type { OpenRouterCost } from '@deepseek-ai/dsh-openrouter-usage/client'
|
||||
@@ -15,22 +21,75 @@ import css from './CostDock.module.css'
|
||||
export type CostDockProps =
|
||||
PropsRuntime<'conversation.composer.dock'> & PropsLocale<'openRouterUsage'>
|
||||
|
||||
/** Summed spend over one session's subagent subtree. */
|
||||
interface SubtreeCost {
|
||||
totalUsd: number
|
||||
pricedSteps: number
|
||||
unknownModelSteps: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the running session cost as `会话费用 $X.XX`, shown only when the
|
||||
* session actually priced steps. An unknown-pricing step count feeds a
|
||||
* tooltip so the figure's coverage stays visible without polluting the row.
|
||||
* session or any of its subagents actually priced steps. An unknown-pricing
|
||||
* step count feeds a tooltip so the figure's coverage stays visible without
|
||||
* polluting the row.
|
||||
* @param props - framework/runtime props.
|
||||
* @returns the cost group, or null on a zero/absent projection.
|
||||
*/
|
||||
export const CostDock = memo(function CostDock({ useProjection, t }: CostDockProps) {
|
||||
const cost = useProjection('openRouterCost') as OpenRouterCost | undefined
|
||||
if (cost === undefined || cost.pricedSteps === 0) return null
|
||||
const label = t('cost.label', { amount: formatUsd(cost.totalUsd) })
|
||||
export const CostDock = memo(function CostDock({ sessionId, useProjection, useSessions, t }: CostDockProps) {
|
||||
const cost: OpenRouterCost | undefined = useProjection('openRouterCost')
|
||||
const sessionById = useSessions(s => s.byId)
|
||||
|
||||
const subagentCost = useMemo((): SubtreeCost => {
|
||||
let totalUsd = 0
|
||||
let pricedSteps = 0
|
||||
let unknownModelSteps = 0
|
||||
const rows = Object.values(sessionById)
|
||||
// Breadth-first over subagent-origin descendants, one frontier at a time.
|
||||
// `visited` also closes a parentId cycle a corrupt lineage could present.
|
||||
const visited = new Set<SessionId>([sessionId])
|
||||
let frontier: SessionId[] = [sessionId]
|
||||
while (frontier.length > 0) {
|
||||
const next: SessionId[] = []
|
||||
for (const parent of frontier) {
|
||||
for (const row of rows) {
|
||||
if (row.parentId !== parent || row.origin !== 'subagent' || visited.has(row.id)) continue
|
||||
visited.add(row.id)
|
||||
next.push(row.id)
|
||||
// A descendant the client holds no projection value for (never
|
||||
// opened, and no version-matching cold cache row) contributes
|
||||
// nothing; it cannot be told apart from a zero-cost child here.
|
||||
const childCost = row.projectionValues?.openRouterCost
|
||||
if (childCost === undefined) continue
|
||||
totalUsd += childCost.totalUsd
|
||||
pricedSteps += childCost.pricedSteps
|
||||
unknownModelSteps += childCost.unknownModelSteps
|
||||
}
|
||||
}
|
||||
frontier = next
|
||||
}
|
||||
return { totalUsd, pricedSteps, unknownModelSteps }
|
||||
}, [sessionById, sessionId])
|
||||
|
||||
const selfPricedSteps = cost?.pricedSteps ?? 0
|
||||
if (selfPricedSteps === 0 && subagentCost.pricedSteps === 0) return null
|
||||
|
||||
const selfUsd = cost?.totalUsd ?? 0
|
||||
const totalUsd = selfUsd + subagentCost.totalUsd
|
||||
const unknownModelSteps = (cost?.unknownModelSteps ?? 0) + subagentCost.unknownModelSteps
|
||||
|
||||
const label = subagentCost.pricedSteps > 0
|
||||
? t('cost.labelWithSubagents', {
|
||||
amount: formatUsd(totalUsd),
|
||||
subagentAmount: formatUsd(subagentCost.totalUsd),
|
||||
})
|
||||
: t('cost.label', { amount: formatUsd(totalUsd) })
|
||||
|
||||
return (
|
||||
<span
|
||||
className={css.cost}
|
||||
title={cost.unknownModelSteps > 0
|
||||
? t('cost.tooltipUnknown', { count: String(cost.unknownModelSteps) })
|
||||
title={unknownModelSteps > 0
|
||||
? t('cost.tooltipUnknown', { count: String(unknownModelSteps) })
|
||||
: undefined}
|
||||
>
|
||||
{label}
|
||||
|
||||
@@ -63,6 +63,14 @@ export function apply(ctx: ClientContext): void {
|
||||
return result.value
|
||||
}
|
||||
|
||||
const refresh: BalanceBadgeActions['refresh'] = async () => {
|
||||
const result = await ctx.remote.openRouterUsage.refresh()
|
||||
if (!result.ok) {
|
||||
throw new Error(`openRouterUsage.refresh failed: ${result.error.code}: ${result.error.message}`)
|
||||
}
|
||||
return result.value
|
||||
}
|
||||
|
||||
// Account balance is account-global (root scope): the injected face carries
|
||||
// the snapshot read the badge polls. The seat is declared but unhoused by
|
||||
// default; this registration names it, so an assembly without ui-sidebar
|
||||
@@ -72,6 +80,6 @@ export function apply(ctx: ClientContext): void {
|
||||
id: 'openrouter-balance',
|
||||
order: 10,
|
||||
locale: NS,
|
||||
inject: (): BalanceBadgeActions => ({ snapshot }),
|
||||
inject: (): BalanceBadgeActions => ({ snapshot, refresh }),
|
||||
}, BalanceBadge))
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
/** Simplified Chinese dictionary (the key-set source of truth). */
|
||||
export const zh = {
|
||||
'cost.label': '会话费用 {amount}',
|
||||
'cost.labelWithSubagents': '会话费用 {amount}(含子代理 {subagentAmount})',
|
||||
'cost.tooltipUnknown': '包含 {count} 个未知定价步骤',
|
||||
'balance.label': '余额 {amount}',
|
||||
'balance.tooltip': 'OpenRouter 账户余额({label})',
|
||||
'balance.tooltip': 'OpenRouter 账户余额({label})— 点击刷新',
|
||||
'balance.empty': '——',
|
||||
'balance.refreshing': '正在刷新 OpenRouter 账户余额',
|
||||
} satisfies Record<string, string>
|
||||
|
||||
/** The openrouterUsage namespace key union. */
|
||||
@@ -15,8 +17,10 @@ export type OpenRouterUsageKey = keyof typeof zh
|
||||
/** English dictionary, checked complete against the zh key set. */
|
||||
export const en = {
|
||||
'cost.label': 'Session cost {amount}',
|
||||
'cost.labelWithSubagents': 'Session cost {amount} (subagents {subagentAmount})',
|
||||
'cost.tooltipUnknown': 'Includes {count} unpriced steps',
|
||||
'balance.label': 'Balance {amount}',
|
||||
'balance.tooltip': 'OpenRouter account balance ({label})',
|
||||
'balance.tooltip': 'OpenRouter account balance ({label}) — click to refresh',
|
||||
'balance.empty': '——',
|
||||
'balance.refreshing': 'Refreshing the OpenRouter account balance',
|
||||
} satisfies Record<OpenRouterUsageKey, string>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// @vitest-environment jsdom
|
||||
// BalanceBadge presentation: polls an injected snapshot callback and renders
|
||||
// 余额 in both widths, hiding unknown figures behind the empty placeholder.
|
||||
// 余额 in both widths, hiding unknown figures behind the empty placeholder,
|
||||
// and re-reads through the injected refresh callback when the user clicks it.
|
||||
|
||||
import { act, cleanup, render, screen } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
@@ -34,6 +35,7 @@ function makeProps(over: Partial<BalanceBadgeProps> = {}) {
|
||||
return {
|
||||
wide: true,
|
||||
snapshot: vi.fn(async () => makeBalance()),
|
||||
refresh: vi.fn(async () => makeBalance()),
|
||||
t,
|
||||
...over,
|
||||
} as unknown as Parameters<typeof BalanceBadge>[0]
|
||||
@@ -67,4 +69,37 @@ describe('BalanceBadge', () => {
|
||||
render(<BalanceBadge {...makeProps({ snapshot: vi.fn(async () => makeBalance({ balanceUsd: null })) })} />)
|
||||
expect(await screen.findByLabelText('余额 ——')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('re-reads through refresh on click and renders the fetched figure', async () => {
|
||||
const refresh = vi.fn(async () => makeBalance({ balanceUsd: 42.25 }))
|
||||
render(<BalanceBadge {...makeProps({ refresh })} />)
|
||||
const badge = await screen.findByLabelText('余额 $10.50')
|
||||
await act(async () => { fireEvent.click(badge) })
|
||||
expect(refresh).toHaveBeenCalledOnce()
|
||||
expect(await screen.findByLabelText('余额 $42.25')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('ignores a click while a refresh is still outstanding', async () => {
|
||||
let release: (value: OpenRouterBalance) => void = () => {}
|
||||
const refresh = vi.fn(() => new Promise<OpenRouterBalance>((resolve) => { release = resolve }))
|
||||
render(<BalanceBadge {...makeProps({ refresh })} />)
|
||||
const badge = await screen.findByLabelText('余额 $10.50')
|
||||
await act(async () => { fireEvent.click(badge) })
|
||||
expect(badge.getAttribute('aria-busy')).toBe('true')
|
||||
await act(async () => { fireEvent.click(badge) })
|
||||
expect(refresh).toHaveBeenCalledOnce()
|
||||
await act(async () => { release(makeBalance({ balanceUsd: 7 })) })
|
||||
expect(badge.getAttribute('aria-busy')).toBe('false')
|
||||
await act(async () => { fireEvent.click(badge) })
|
||||
expect(refresh).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps the last-known figure when a refresh fails', async () => {
|
||||
const refresh = vi.fn(async () => { throw new Error('boom') })
|
||||
render(<BalanceBadge {...makeProps({ refresh })} />)
|
||||
const badge = await screen.findByLabelText('余额 $10.50')
|
||||
await act(async () => { fireEvent.click(badge) })
|
||||
expect(await screen.findByLabelText('余额 $10.50')).toBeTruthy()
|
||||
expect(badge.getAttribute('aria-busy')).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -37,8 +37,10 @@ async function bench() {
|
||||
new RemoteService(ctx)
|
||||
const snapshot = vi.fn<() => Promise<RemoteResult<OpenRouterBalance>>>()
|
||||
.mockResolvedValue({ ok: true, value: balance() })
|
||||
ctx.provide('remote.openRouterUsage', { snapshot })
|
||||
return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, snapshot }
|
||||
const refresh = vi.fn<() => Promise<RemoteResult<OpenRouterBalance>>>()
|
||||
.mockResolvedValue({ ok: true, value: balance({ balanceUsd: 9 }) })
|
||||
ctx.provide('remote.openRouterUsage', { snapshot, refresh })
|
||||
return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, snapshot, refresh }
|
||||
}
|
||||
|
||||
function declare(slots: SlotRegistry): () => void {
|
||||
@@ -84,11 +86,14 @@ describe('ui-openrouter-usage browser plugin', () => {
|
||||
expect(badge.options).toMatchObject({ id: 'openrouter-balance', order: 10 })
|
||||
expect(badge.locale).toBe(NS)
|
||||
|
||||
// Registration reads nothing live; the snapshot rolls at call time.
|
||||
// Registration reads nothing live; both reads roll at call time.
|
||||
expect(b.snapshot).not.toHaveBeenCalled()
|
||||
expect(b.refresh).not.toHaveBeenCalled()
|
||||
const injected = (badge.inject as unknown as () => BalanceBadgeActions)()
|
||||
await expect(injected.snapshot()).resolves.toEqual(balance())
|
||||
expect(b.snapshot).toHaveBeenCalledOnce()
|
||||
await expect(injected.refresh()).resolves.toEqual(balance({ balanceUsd: 9 }))
|
||||
expect(b.refresh).toHaveBeenCalledOnce()
|
||||
await b.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -102,6 +107,16 @@ describe('ui-openrouter-usage browser plugin', () => {
|
||||
await b.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('forwards a Remote failure out of the injected refresh verbatim', async () => {
|
||||
const b = await bench()
|
||||
declare(b.slots)
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
b.refresh.mockResolvedValueOnce({ ok: false, error: { code: 'REMOTE_ERROR', message: 'nope', details: {} } })
|
||||
const injected = (b.slots.entries('sidebar.footer.action')[0]!.inject as unknown as () => BalanceBadgeActions)()
|
||||
await expect(injected.refresh()).rejects.toThrow('openRouterUsage.refresh failed: REMOTE_ERROR: nope')
|
||||
await b.ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('follows locale and recovers across late declaration and declarer reload', async () => {
|
||||
const b = await bench()
|
||||
const fiber = b.ctx.plugin({ inject: [...inject], apply })
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
// CostDock presentation: renders the running session cost from the
|
||||
// openRouterCost projection, and nothing at all until a step was priced.
|
||||
// openRouterCost projection plus the subagent subtree read off the session
|
||||
// list, and nothing at all until a step was priced.
|
||||
|
||||
import { act, cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
@@ -13,9 +14,37 @@ afterEach(cleanup)
|
||||
|
||||
const t = makeTranslate(zh, commonZh)
|
||||
|
||||
function makeProps(over: Partial<Parameters<typeof CostDock>[0]> = {}) {
|
||||
const PARENT = 'session-parent'
|
||||
|
||||
/** One session-list row as much of it as the dock reads. */
|
||||
interface Row {
|
||||
id: string
|
||||
parentId?: string
|
||||
origin?: 'subagent'
|
||||
cost?: { totalUsd: number; pricedSteps: number; unknownModelSteps: number }
|
||||
}
|
||||
|
||||
/** A `useSessions` stub serving `byId` built from the given rows. */
|
||||
function sessionsHook(rows: readonly Row[]) {
|
||||
const byId: Record<string, unknown> = {}
|
||||
for (const row of rows) {
|
||||
byId[row.id] = {
|
||||
id: row.id,
|
||||
...row.parentId === undefined ? {} : { parentId: row.parentId },
|
||||
...row.origin === undefined ? {} : { origin: row.origin },
|
||||
...row.cost === undefined
|
||||
? {}
|
||||
: { projectionValues: { openRouterCost: { ...row.cost, currency: 'USD' } } },
|
||||
}
|
||||
}
|
||||
return (selector: (state: { byId: Record<string, unknown> }) => unknown) => selector({ byId })
|
||||
}
|
||||
|
||||
function makeProps(over: Partial<Parameters<typeof CostDock>[0]> = {}, rows: readonly Row[] = []) {
|
||||
return {
|
||||
sessionId: PARENT,
|
||||
useProjection: vi.fn(() => undefined),
|
||||
useSessions: sessionsHook(rows),
|
||||
t,
|
||||
...over,
|
||||
} as unknown as Parameters<typeof CostDock>[0]
|
||||
@@ -57,4 +86,53 @@ describe('CostDock', () => {
|
||||
act(() => {})
|
||||
expect(useProjection).toHaveBeenCalledWith('openRouterCost')
|
||||
})
|
||||
|
||||
it('adds the subagent subtree to the figure and names its share', () => {
|
||||
render(<CostDock {...makeProps(
|
||||
{ useProjection: () => ({ totalUsd: 1, pricedSteps: 1, unknownModelSteps: 0, currency: 'USD' }) },
|
||||
[{ id: 'child', parentId: PARENT, origin: 'subagent', cost: { totalUsd: 0.5, pricedSteps: 1, unknownModelSteps: 1 } }],
|
||||
)} />)
|
||||
const row = screen.getByText('会话费用 $1.50(含子代理 $0.50)')
|
||||
expect(row.getAttribute('title')).toBe('包含 1 个未知定价步骤')
|
||||
})
|
||||
|
||||
it('follows a nested subagent chain and ignores non-subagent children', () => {
|
||||
render(<CostDock {...makeProps(
|
||||
{ useProjection: () => ({ totalUsd: 1, pricedSteps: 1, unknownModelSteps: 0, currency: 'USD' }) },
|
||||
[
|
||||
{ id: 'child', parentId: PARENT, origin: 'subagent', cost: { totalUsd: 0.25, pricedSteps: 1, unknownModelSteps: 0 } },
|
||||
{ id: 'grandchild', parentId: 'child', origin: 'subagent', cost: { totalUsd: 0.125, pricedSteps: 1, unknownModelSteps: 0 } },
|
||||
// An ordinary fork of the same parent is not delegated work.
|
||||
{ id: 'sibling', parentId: PARENT, cost: { totalUsd: 9, pricedSteps: 1, unknownModelSteps: 0 } },
|
||||
],
|
||||
)} />)
|
||||
expect(screen.getByText('会话费用 $1.38(含子代理 $0.38)')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows the subagent subtree alone when the session itself priced nothing', () => {
|
||||
render(<CostDock {...makeProps(
|
||||
{ useProjection: () => undefined },
|
||||
[{ id: 'child', parentId: PARENT, origin: 'subagent', cost: { totalUsd: 0.75, pricedSteps: 2, unknownModelSteps: 0 } }],
|
||||
)} />)
|
||||
expect(screen.getByText('会话费用 $0.75(含子代理 $0.75)')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('skips a descendant the client holds no projection value for', () => {
|
||||
render(<CostDock {...makeProps(
|
||||
{ useProjection: () => ({ totalUsd: 2, pricedSteps: 1, unknownModelSteps: 0, currency: 'USD' }) },
|
||||
[{ id: 'cold-child', parentId: PARENT, origin: 'subagent' }],
|
||||
)} />)
|
||||
expect(screen.getByText('会话费用 $2.00')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('terminates on a parentId cycle', () => {
|
||||
render(<CostDock {...makeProps(
|
||||
{ useProjection: () => ({ totalUsd: 1, pricedSteps: 1, unknownModelSteps: 0, currency: 'USD' }) },
|
||||
[
|
||||
{ id: 'a', parentId: PARENT, origin: 'subagent', cost: { totalUsd: 0.5, pricedSteps: 1, unknownModelSteps: 0 } },
|
||||
{ id: PARENT, parentId: 'a', origin: 'subagent', cost: { totalUsd: 99, pricedSteps: 1, unknownModelSteps: 0 } },
|
||||
],
|
||||
)} />)
|
||||
expect(screen.getByText('会话费用 $1.50(含子代理 $0.50)')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -282,6 +282,10 @@ function CatalogRows({
|
||||
entry.activity,
|
||||
now,
|
||||
)
|
||||
const costUsd = summary?.projectionValues?.openRouterCost?.totalUsd
|
||||
const costMetric = (costUsd !== undefined && costUsd > 0)
|
||||
? `$${costUsd < 0.01 ? '<0.01' : costUsd.toFixed(2)}`
|
||||
: undefined
|
||||
const tokenMetric = totalTokens === undefined
|
||||
? undefined
|
||||
: `${formatTokens(totalTokens)} tok`
|
||||
@@ -291,7 +295,7 @@ function CatalogRows({
|
||||
compact: formatDuration(durationMs, t),
|
||||
exact: formatExactDuration(durationMs, t),
|
||||
}
|
||||
const metrics = [tokenMetric, durationMetric?.exact]
|
||||
const metrics = [costMetric, tokenMetric, durationMetric?.exact]
|
||||
.filter(value => value !== undefined)
|
||||
.join(' · ')
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@ interface UsageLike {
|
||||
cacheWriteTokens?: number
|
||||
outputTokens?: number
|
||||
reasoningTokens?: number
|
||||
costUsd?: number
|
||||
}
|
||||
|
||||
function requestUsage(value: unknown): TrajectoryUsage | undefined {
|
||||
@@ -205,7 +206,9 @@ export function TrajectoryView({
|
||||
const provider = request?.provenance?.provider ?? node?.provenance?.provider
|
||||
const model = request?.provenance?.model ?? node?.provenance?.model
|
||||
const requestConfig = request?.requestConfig ?? node?.requestConfig
|
||||
const stepCostUsd = stepCosts?.[`${turn}:${step}`]
|
||||
const directCostUsd = (entry.request?.usage as UsageLike | undefined)?.costUsd
|
||||
?? (entry.node?.usage as UsageLike | undefined)?.costUsd
|
||||
const stepCostUsd = directCostUsd ?? stepCosts?.[`${turn}:${step}`]
|
||||
numbered.push({
|
||||
seq: entry.seq,
|
||||
turn,
|
||||
@@ -232,6 +235,7 @@ export function TrajectoryView({
|
||||
continue
|
||||
}
|
||||
const request = entry.request
|
||||
const directCostUsd = (request.usage as UsageLike | undefined)?.costUsd
|
||||
numbered.push({
|
||||
seq: request.startSeq,
|
||||
turn: request.turn,
|
||||
@@ -253,6 +257,7 @@ export function TrajectoryView({
|
||||
...(request.requestConfig === undefined ? {} : { requestConfig: request.requestConfig }),
|
||||
...(usage === undefined ? {} : { usage }),
|
||||
...(cumulativeUsage === undefined ? {} : { cumulativeUsage }),
|
||||
...(directCostUsd === undefined ? {} : { stepCostUsd: directCostUsd }),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ interface UsageLike {
|
||||
cacheWriteTokens?: number
|
||||
outputTokens?: number
|
||||
reasoningTokens?: number
|
||||
costUsd?: number
|
||||
}
|
||||
|
||||
/** Cell plus absolute ms for group wall-span descriptions. */
|
||||
|
||||
@@ -18,6 +18,7 @@ interface UsageValue {
|
||||
readonly cacheReadTokens?: number
|
||||
readonly cacheWriteTokens?: number
|
||||
readonly reasoningTokens?: number
|
||||
readonly costUsd?: number
|
||||
}
|
||||
|
||||
interface RetryValue {
|
||||
@@ -101,6 +102,9 @@ function addUsage(current: UsageValue | undefined, next: UsageValue): UsageValue
|
||||
...(current?.reasoningTokens === undefined && next.reasoningTokens === undefined
|
||||
? {}
|
||||
: { reasoningTokens: (current?.reasoningTokens ?? 0) + (next.reasoningTokens ?? 0) }),
|
||||
...(current?.costUsd === undefined && next.costUsd === undefined
|
||||
? {}
|
||||
: { costUsd: (current?.costUsd ?? 0) + (next.costUsd ?? 0) }),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1226,6 +1226,27 @@ describe('TrajectoryView state', () => {
|
||||
expect(screen.getByText('$0.0028')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('attaches the per-step cost directly from node usage even when projection is undefined', () => {
|
||||
const nodesWithCost = [
|
||||
NODES[0]!,
|
||||
{
|
||||
...NODES[1]!,
|
||||
usage: { inputTokens: 100, outputTokens: 50, costUsd: 0.0045 },
|
||||
},
|
||||
NODES[2]!,
|
||||
NODES[3]!,
|
||||
]
|
||||
render(
|
||||
<TrajectoryView
|
||||
{...standaloneProps(nodesWithCost)}
|
||||
{...standaloneHistory(historySnapshot(nodesWithCost))}
|
||||
{...standaloneDuration()}
|
||||
useProjection={(() => undefined) as never}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Request #1 · $0.0045' })).toBeTruthy()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('node half', () => {
|
||||
|
||||
Reference in New Issue
Block a user