feat: separate system task token usage and add usage refresh
Add a System tasks tab to run history that splits background usage (supervisor, auto-supervision, magic notes, knowledge base, delegation) from conversation usage, with a manual refresh button and automatic refresh when usage is recorded. Record previously dropped usage from supervisor reviews, image regeneration, knowledge graph extraction, and remote embeddings.
This commit is contained in:
@@ -932,4 +932,53 @@ describe('ActivityPanel', () => {
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByText('goodbuddy')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('moves system task usage into its own tab with separate totals', () => {
|
||||
const usage = makeTokenUsage()
|
||||
const systemRecord = (requestId: string, systemSource: 'heartbeat' | 'supervision', input: number) => ({
|
||||
requestId, systemSource, conversationId: `${systemSource}:x`, runtime: 'model', provider: 'openai', model: 'gpt-sys',
|
||||
callCount: 1, input, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: input + 1
|
||||
})
|
||||
usage.records.push(systemRecord('sys-1', 'heartbeat', 7), systemRecord('sys-2', 'supervision', 9))
|
||||
usage.conversationTotals = usage.totals
|
||||
usage.systemTotals = { callCount: 2, input: 16, output: 2, cacheRead: 0, cacheWrite: 0, totalTokens: 18 }
|
||||
|
||||
render(<ActivityPanel onClear={vi.fn()} onOpenConversation={vi.fn()} records={[]} tokenUsage={usage} />)
|
||||
fireEvent.click(screen.getByRole('tab', { name: '用量统计' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '按会话' }))
|
||||
expect(screen.queryByText('heartbeat:x')).not.toBeInTheDocument()
|
||||
expect(within(screen.getByLabelText('Token 用量统计')).getByText('150')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '系统任务' }))
|
||||
const stats = screen.getByLabelText('系统任务用量统计')
|
||||
expect(within(stats).getByText('18')).toBeInTheDocument()
|
||||
expect(screen.getByRole('row', { name: '自动监督 7 1 0 0 0% 8' })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '监督者' }))
|
||||
expect(screen.getByRole('row', { name: '直连模型 · gpt-sys 9 1 0 0 0% 10' })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '按模型' }))
|
||||
expect(screen.getByRole('row', { name: '直连模型 · gpt-sys 16 2 0 0 0% 18' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows an empty system tab and refreshes usage on demand', async () => {
|
||||
let resolveRefresh!: () => void
|
||||
const onRefreshTokenUsage = vi.fn(() => new Promise<void>((resolve) => { resolveRefresh = resolve }))
|
||||
render(<ActivityPanel onClear={vi.fn()} onOpenConversation={vi.fn()} onRefreshTokenUsage={onRefreshTokenUsage}
|
||||
records={[]} tokenUsage={makeTokenUsage()} />)
|
||||
fireEvent.click(screen.getByRole('tab', { name: '系统任务' }))
|
||||
expect(screen.getByText('暂无系统任务 Token 用量')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新' }))
|
||||
expect(onRefreshTokenUsage).toHaveBeenCalledOnce()
|
||||
const busy = screen.getByRole('button', { name: '正在刷新' })
|
||||
expect(busy).toBeDisabled()
|
||||
fireEvent.click(busy)
|
||||
expect(onRefreshTokenUsage).toHaveBeenCalledOnce()
|
||||
resolveRefresh()
|
||||
expect(await screen.findByRole('button', { name: '刷新' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('hides the refresh button when no refresh handler is provided', () => {
|
||||
render(<ActivityPanel onClear={vi.fn()} onOpenConversation={vi.fn()} records={[]} tokenUsage={makeTokenUsage()} />)
|
||||
fireEvent.click(screen.getByRole('tab', { name: '用量统计' }))
|
||||
expect(screen.queryByRole('button', { name: '刷新' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
+269
-162
@@ -1,4 +1,4 @@
|
||||
import { Activity, ChevronRight, Trash2 } from 'lucide-react'
|
||||
import { Activity, ChevronRight, RefreshCw, Trash2 } from 'lucide-react'
|
||||
import { memo, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { InlineHelp } from './InlineHelp'
|
||||
@@ -11,7 +11,9 @@ import { type ActivityRecord } from './activity-store'
|
||||
import {
|
||||
getTokenUsageTotals,
|
||||
groupTokenUsage,
|
||||
type TokenUsageGroup
|
||||
splitTokenUsage,
|
||||
type TokenUsageGroup,
|
||||
type TokenUsageGroupRow
|
||||
} from './token-usage'
|
||||
import {
|
||||
DestructiveConfirmActions,
|
||||
@@ -25,7 +27,9 @@ import {
|
||||
import { getProjectDisplayText } from './project-display'
|
||||
|
||||
type ActivityFilter = 'all' | 'active' | 'failed'
|
||||
type ActivityView = 'tasks' | 'timeline' | 'usage'
|
||||
type ActivityView = 'tasks' | 'timeline' | 'usage' | 'system'
|
||||
type SystemTokenUsageGroup = Extract<TokenUsageGroup, 'source' | 'model'>
|
||||
type TokenUsageView = Extract<ActivityView, 'usage' | 'system'>
|
||||
type ActivityActorKind =
|
||||
| 'user'
|
||||
| 'assistant'
|
||||
@@ -41,6 +45,8 @@ export type ActivityPanelProps = {
|
||||
tokenUsage: TokenUsageSummary
|
||||
onClear: () => void
|
||||
onOpenConversation: (conversationId: string) => void
|
||||
/** Re-reads token usage from storage; the button is hidden when omitted. */
|
||||
onRefreshTokenUsage?: () => Promise<void>
|
||||
}
|
||||
|
||||
type ConversationActivityGroup = {
|
||||
@@ -244,7 +250,8 @@ export const ActivityPanel = memo(function ActivityPanel({
|
||||
records,
|
||||
tokenUsage,
|
||||
onClear,
|
||||
onOpenConversation
|
||||
onOpenConversation,
|
||||
onRefreshTokenUsage
|
||||
}: ActivityPanelProps): React.JSX.Element {
|
||||
const { t, i18n } = useTranslation('activity')
|
||||
const { t: tWorkspace } = useTranslation('workspace')
|
||||
@@ -252,7 +259,10 @@ export const ActivityPanel = memo(function ActivityPanel({
|
||||
useState<ActivityView>('tasks')
|
||||
const [filter, setFilter] = useState<ActivityFilter>('all')
|
||||
const [tokenGroup, setTokenGroup] =
|
||||
useState<TokenUsageGroup>('project')
|
||||
useState<Exclude<TokenUsageGroup, 'source'>>('project')
|
||||
const [systemTokenGroup, setSystemTokenGroup] =
|
||||
useState<SystemTokenUsageGroup>('source')
|
||||
const [refreshingTokenUsage, setRefreshingTokenUsage] = useState(false)
|
||||
const [expandedTokenRows, setExpandedTokenRows] = useState<Set<string>>(
|
||||
() => new Set()
|
||||
)
|
||||
@@ -311,10 +321,27 @@ export const ActivityPanel = memo(function ActivityPanel({
|
||||
}> = [
|
||||
{ id: 'tasks', label: t('tabs.tasks') },
|
||||
{ id: 'timeline', label: t('tabs.timeline') },
|
||||
{ id: 'usage', label: t('tabs.usage') }
|
||||
{ id: 'usage', label: t('tabs.usage') },
|
||||
{ id: 'system', label: t('tabs.system') }
|
||||
]
|
||||
const systemTokenGroups: ReadonlyArray<{
|
||||
value: SystemTokenUsageGroup
|
||||
label: string
|
||||
columnLabel: string
|
||||
}> = [
|
||||
{
|
||||
value: 'source',
|
||||
label: t('systemUsage.groups.source'),
|
||||
columnLabel: t('systemUsage.columns.source')
|
||||
},
|
||||
{
|
||||
value: 'model',
|
||||
label: t('tokenUsage.groups.model'),
|
||||
columnLabel: t('tokenUsage.columns.model')
|
||||
}
|
||||
]
|
||||
const tokenGroups: ReadonlyArray<{
|
||||
value: TokenUsageGroup
|
||||
value: Exclude<TokenUsageGroup, 'source'>
|
||||
label: string
|
||||
columnLabel: string
|
||||
}> = [
|
||||
@@ -452,17 +479,26 @@ export const ActivityPanel = memo(function ActivityPanel({
|
||||
})
|
||||
}
|
||||
]
|
||||
const splitUsage = useMemo(
|
||||
() => splitTokenUsage(displayTokenUsage),
|
||||
[displayTokenUsage]
|
||||
)
|
||||
const tokenTotals = useMemo(
|
||||
() => getTokenUsageTotals(tokenUsage),
|
||||
[tokenUsage]
|
||||
() => getTokenUsageTotals(splitUsage.conversation),
|
||||
[splitUsage]
|
||||
)
|
||||
const tokenRows = useMemo(
|
||||
() => groupTokenUsage(displayTokenUsage, tokenGroup),
|
||||
[displayTokenUsage, tokenGroup]
|
||||
() => groupTokenUsage(splitUsage.conversation, tokenGroup),
|
||||
[splitUsage, tokenGroup]
|
||||
)
|
||||
const systemTokenTotals = useMemo(
|
||||
() => getTokenUsageTotals(splitUsage.system),
|
||||
[splitUsage]
|
||||
)
|
||||
const systemTokenRows = useMemo(
|
||||
() => groupTokenUsage(splitUsage.system, systemTokenGroup),
|
||||
[splitUsage, systemTokenGroup]
|
||||
)
|
||||
const tokenGroupLabel =
|
||||
tokenGroups.find((item) => item.value === tokenGroup)?.columnLabel ??
|
||||
t('tokenUsage.columns.project')
|
||||
const emptyDescription =
|
||||
filter === 'active'
|
||||
? t('empty.active')
|
||||
@@ -530,8 +566,25 @@ export const ActivityPanel = memo(function ActivityPanel({
|
||||
{ kind: 'tool', label: t('timeline.actors.tool') },
|
||||
{ kind: 'approval', label: t('timeline.actors.approval') }
|
||||
]
|
||||
const systemSourceLabel = (key: string): string => {
|
||||
switch (key) {
|
||||
case 'source:heartbeat':
|
||||
return t('systemUsage.sources.heartbeat')
|
||||
case 'source:supervision':
|
||||
return t('systemUsage.sources.supervision')
|
||||
case 'source:magic-notes':
|
||||
return t('systemUsage.sources.magicNotes')
|
||||
case 'source:knowledge':
|
||||
return t('systemUsage.sources.knowledge')
|
||||
case 'source:delegation':
|
||||
return t('systemUsage.sources.delegation')
|
||||
default:
|
||||
return t('systemUsage.sources.unknown')
|
||||
}
|
||||
}
|
||||
const localizeTokenRow = (
|
||||
row: (typeof tokenRows)[number]
|
||||
row: TokenUsageGroupRow,
|
||||
group: TokenUsageGroup
|
||||
): { label: string } => {
|
||||
const modelLabel =
|
||||
row.model || t('tokenUsage.fallbacks.unknownModel')
|
||||
@@ -546,17 +599,30 @@ export const ActivityPanel = memo(function ActivityPanel({
|
||||
? t('tokenUsage.runtimes.deepseekHarness')
|
||||
: row.runtime || t('tokenUsage.fallbacks.unknownRuntime')
|
||||
const label =
|
||||
tokenGroup === 'project' &&
|
||||
row.key === 'project:unassigned'
|
||||
? t('tokenUsage.fallbacks.unassignedProject')
|
||||
: tokenGroup === 'conversation' &&
|
||||
row.key === 'conversation:deleted'
|
||||
? t('tokenUsage.fallbacks.deletedConversation')
|
||||
: !row.children
|
||||
? `${runtimeLabel} · ${modelLabel}`
|
||||
: row.label
|
||||
group === 'source' && row.children
|
||||
? systemSourceLabel(row.key)
|
||||
: group === 'project' &&
|
||||
row.key === 'project:unassigned'
|
||||
? t('tokenUsage.fallbacks.unassignedProject')
|
||||
: group === 'conversation' &&
|
||||
row.key === 'conversation:deleted'
|
||||
? t('tokenUsage.fallbacks.deletedConversation')
|
||||
: !row.children
|
||||
? `${runtimeLabel} · ${modelLabel}`
|
||||
: row.label
|
||||
return { label }
|
||||
}
|
||||
const refreshTokenUsage = async (): Promise<void> => {
|
||||
if (!onRefreshTokenUsage || refreshingTokenUsage) {
|
||||
return
|
||||
}
|
||||
setRefreshingTokenUsage(true)
|
||||
try {
|
||||
await onRefreshTokenUsage()
|
||||
} finally {
|
||||
setRefreshingTokenUsage(false)
|
||||
}
|
||||
}
|
||||
const renderRecordCard = (
|
||||
record: ActivityRecord,
|
||||
showContext: boolean
|
||||
@@ -984,135 +1050,195 @@ export const ActivityPanel = memo(function ActivityPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeView === 'usage' && (
|
||||
<div
|
||||
aria-labelledby="activity-view-tab-usage"
|
||||
className="activity-panel__tab-panel"
|
||||
id="activity-view-panel-usage"
|
||||
role="tabpanel"
|
||||
tabIndex={0}
|
||||
>
|
||||
<section
|
||||
aria-labelledby="token-usage-title"
|
||||
className="token-usage"
|
||||
>
|
||||
<header className="token-usage__header">
|
||||
<h2 id="token-usage-title">{t('tokenUsage.title')}</h2>
|
||||
<SegmentedControl
|
||||
ariaLabel={t('tokenUsage.groupAriaLabel')}
|
||||
onChange={setTokenGroup}
|
||||
options={tokenGroups}
|
||||
value={tokenGroup}
|
||||
/>
|
||||
</header>
|
||||
{activeView === 'usage' &&
|
||||
renderTokenUsagePanel({
|
||||
view: 'usage',
|
||||
title: t('tokenUsage.title'),
|
||||
description: undefined,
|
||||
group: tokenGroup,
|
||||
groups: tokenGroups,
|
||||
onGroupChange: setTokenGroup,
|
||||
totals: tokenTotals,
|
||||
rows: tokenRows,
|
||||
empty: t('tokenUsage.empty')
|
||||
})}
|
||||
|
||||
<dl
|
||||
aria-label={t('tokenUsage.statsAriaLabel')}
|
||||
className="token-usage__stats"
|
||||
>
|
||||
<div>
|
||||
<dt>{t('tokenUsage.columns.input')}</dt>
|
||||
<dd>
|
||||
{tokenCountFormatter.format(tokenTotals.inputTokens)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t('tokenUsage.columns.output')}</dt>
|
||||
<dd>
|
||||
{tokenCountFormatter.format(tokenTotals.outputTokens)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t('tokenUsage.columns.cacheWrite')}</dt>
|
||||
<dd>
|
||||
{tokenCountFormatter.format(
|
||||
tokenTotals.cacheWriteTokens
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t('tokenUsage.columns.cacheRead')}</dt>
|
||||
<dd>
|
||||
{tokenCountFormatter.format(
|
||||
tokenTotals.cacheReadTokens
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t('tokenUsage.columns.cacheHitRate')}</dt>
|
||||
<dd>
|
||||
{formatCacheHitRate(tokenTotals.cacheHitRate)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t('tokenUsage.columns.total')}</dt>
|
||||
<dd>
|
||||
{tokenCountFormatter.format(tokenTotals.totalTokens)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{activeView === 'system' &&
|
||||
renderTokenUsagePanel({
|
||||
view: 'system',
|
||||
title: t('systemUsage.title'),
|
||||
description: t('systemUsage.description'),
|
||||
group: systemTokenGroup,
|
||||
groups: systemTokenGroups,
|
||||
onGroupChange: setSystemTokenGroup,
|
||||
totals: systemTokenTotals,
|
||||
rows: systemTokenRows,
|
||||
empty: t('systemUsage.empty')
|
||||
})}
|
||||
</section>
|
||||
)
|
||||
|
||||
<div className="token-usage__table-scroll">
|
||||
<table
|
||||
aria-label={t('tokenUsage.detailAriaLabel', {
|
||||
group: tokenGroupLabel
|
||||
})}
|
||||
function renderTokenUsagePanel<Group extends TokenUsageGroup>({
|
||||
view,
|
||||
title,
|
||||
description,
|
||||
group,
|
||||
groups,
|
||||
onGroupChange,
|
||||
totals,
|
||||
rows,
|
||||
empty
|
||||
}: {
|
||||
view: TokenUsageView
|
||||
title: string
|
||||
description: string | undefined
|
||||
group: Group
|
||||
groups: ReadonlyArray<{ value: Group; label: string; columnLabel: string }>
|
||||
onGroupChange: (group: Group) => void
|
||||
totals: ReturnType<typeof getTokenUsageTotals>
|
||||
rows: TokenUsageGroupRow[]
|
||||
empty: string
|
||||
}): React.JSX.Element {
|
||||
const titleId = view === 'usage' ? 'token-usage-title' : 'system-usage-title'
|
||||
const groupLabel =
|
||||
groups.find((item) => item.value === group)?.columnLabel ??
|
||||
groups[0]!.columnLabel
|
||||
const expansionPrefix = `${view}:`
|
||||
return (
|
||||
<div
|
||||
aria-labelledby={`activity-view-tab-${view}`}
|
||||
className="activity-panel__tab-panel"
|
||||
id={`activity-view-panel-${view}`}
|
||||
role="tabpanel"
|
||||
tabIndex={0}
|
||||
>
|
||||
<section aria-labelledby={titleId} className="token-usage">
|
||||
<header className="token-usage__header">
|
||||
<h2 id={titleId}>{title}</h2>
|
||||
<SegmentedControl
|
||||
ariaLabel={
|
||||
view === 'usage'
|
||||
? t('tokenUsage.groupAriaLabel')
|
||||
: t('systemUsage.groupAriaLabel')
|
||||
}
|
||||
onChange={onGroupChange}
|
||||
options={groups}
|
||||
value={group}
|
||||
/>
|
||||
{onRefreshTokenUsage && (
|
||||
<button
|
||||
aria-busy={refreshingTokenUsage}
|
||||
className="secondary-button token-usage__refresh"
|
||||
disabled={refreshingTokenUsage}
|
||||
onClick={() => void refreshTokenUsage()}
|
||||
title={t('tokenUsage.refreshHint')}
|
||||
type="button"
|
||||
>
|
||||
<thead>
|
||||
<RefreshCw aria-hidden="true" size={13} />
|
||||
{refreshingTokenUsage
|
||||
? t('tokenUsage.refreshing')
|
||||
: t('tokenUsage.refresh')}
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
{description && (
|
||||
<p className="token-usage__description">{description}</p>
|
||||
)}
|
||||
|
||||
<dl
|
||||
aria-label={
|
||||
view === 'usage'
|
||||
? t('tokenUsage.statsAriaLabel')
|
||||
: t('systemUsage.statsAriaLabel')
|
||||
}
|
||||
className="token-usage__stats"
|
||||
>
|
||||
<div>
|
||||
<dt>{t('tokenUsage.columns.input')}</dt>
|
||||
<dd>{tokenCountFormatter.format(totals.inputTokens)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t('tokenUsage.columns.output')}</dt>
|
||||
<dd>{tokenCountFormatter.format(totals.outputTokens)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t('tokenUsage.columns.cacheWrite')}</dt>
|
||||
<dd>{tokenCountFormatter.format(totals.cacheWriteTokens)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t('tokenUsage.columns.cacheRead')}</dt>
|
||||
<dd>{tokenCountFormatter.format(totals.cacheReadTokens)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t('tokenUsage.columns.cacheHitRate')}</dt>
|
||||
<dd>{formatCacheHitRate(totals.cacheHitRate)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t('tokenUsage.columns.total')}</dt>
|
||||
<dd>{tokenCountFormatter.format(totals.totalTokens)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="token-usage__table-scroll">
|
||||
<table
|
||||
aria-label={t('tokenUsage.detailAriaLabel', {
|
||||
group: groupLabel
|
||||
})}
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{groupLabel}</th>
|
||||
<th scope="col">{t('tokenUsage.columns.input')}</th>
|
||||
<th scope="col">{t('tokenUsage.columns.output')}</th>
|
||||
<th scope="col">{t('tokenUsage.columns.cacheWrite')}</th>
|
||||
<th scope="col">{t('tokenUsage.columns.cacheRead')}</th>
|
||||
<th scope="col">{t('tokenUsage.columns.cacheHitRate')}</th>
|
||||
<th scope="col">{t('tokenUsage.columns.total')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<th scope="col">{tokenGroupLabel}</th>
|
||||
<th scope="col">{t('tokenUsage.columns.input')}</th>
|
||||
<th scope="col">{t('tokenUsage.columns.output')}</th>
|
||||
<th scope="col">
|
||||
{t('tokenUsage.columns.cacheWrite')}
|
||||
</th>
|
||||
<th scope="col">
|
||||
{t('tokenUsage.columns.cacheRead')}
|
||||
</th>
|
||||
<th scope="col">
|
||||
{t('tokenUsage.columns.cacheHitRate')}
|
||||
</th>
|
||||
<th scope="col">
|
||||
{t('tokenUsage.columns.total')}
|
||||
</th>
|
||||
<td className="token-usage__empty" colSpan={7}>
|
||||
{empty}
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tokenRows.length === 0 ? (
|
||||
<tr>
|
||||
<td className="token-usage__empty" colSpan={7}>
|
||||
{t('tokenUsage.empty')}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
tokenRows.flatMap((parent) => [
|
||||
) : (
|
||||
rows
|
||||
.flatMap((parent) => [
|
||||
{ row: parent, isChild: false },
|
||||
...(expandedTokenRows.has(parent.key)
|
||||
...(expandedTokenRows.has(expansionPrefix + parent.key)
|
||||
? (parent.children ?? []).map((row) => ({
|
||||
row: { ...row, key: `${parent.key}:${row.key}` },
|
||||
isChild: true
|
||||
}))
|
||||
: [])
|
||||
]).map(({ row, isChild }) => {
|
||||
const localizedRow = localizeTokenRow(row)
|
||||
])
|
||||
.map(({ row, isChild }) => {
|
||||
const localizedRow = localizeTokenRow(row, group)
|
||||
const expansionKey = expansionPrefix + row.key
|
||||
return (
|
||||
<tr
|
||||
key={row.key}
|
||||
className={isChild ? 'token-usage__model-row' : undefined}
|
||||
className={
|
||||
isChild ? 'token-usage__model-row' : undefined
|
||||
}
|
||||
>
|
||||
<th scope="row">
|
||||
{row.children ? (
|
||||
<button
|
||||
type="button"
|
||||
className="token-usage__expand"
|
||||
aria-expanded={expandedTokenRows.has(row.key)}
|
||||
aria-expanded={expandedTokenRows.has(
|
||||
expansionKey
|
||||
)}
|
||||
onClick={() =>
|
||||
setExpandedTokenRows((current) => {
|
||||
const next = new Set(current)
|
||||
if (next.has(row.key)) {
|
||||
next.delete(row.key)
|
||||
if (next.has(expansionKey)) {
|
||||
next.delete(expansionKey)
|
||||
} else {
|
||||
next.add(row.key)
|
||||
next.add(expansionKey)
|
||||
}
|
||||
return next
|
||||
})
|
||||
@@ -1125,44 +1251,25 @@ export const ActivityPanel = memo(function ActivityPanel({
|
||||
<span>{localizedRow.label}</span>
|
||||
)}
|
||||
</th>
|
||||
<td>{tokenCountFormatter.format(row.inputTokens)}</td>
|
||||
<td>{tokenCountFormatter.format(row.outputTokens)}</td>
|
||||
<td>
|
||||
{tokenCountFormatter.format(
|
||||
row.inputTokens
|
||||
)}
|
||||
{tokenCountFormatter.format(row.cacheWriteTokens)}
|
||||
</td>
|
||||
<td>
|
||||
{tokenCountFormatter.format(
|
||||
row.outputTokens
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{tokenCountFormatter.format(
|
||||
row.cacheWriteTokens
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{tokenCountFormatter.format(
|
||||
row.cacheReadTokens
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{formatCacheHitRate(row.cacheHitRate)}
|
||||
</td>
|
||||
<td>
|
||||
{tokenCountFormatter.format(
|
||||
row.totalTokens
|
||||
)}
|
||||
{tokenCountFormatter.format(row.cacheReadTokens)}
|
||||
</td>
|
||||
<td>{formatCacheHitRate(row.cacheHitRate)}</td>
|
||||
<td>{tokenCountFormatter.format(row.totalTokens)}</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -640,6 +640,7 @@ const api: DesktopApi & RuntimeNativeClientApi = {
|
||||
},
|
||||
records: [],
|
||||
})),
|
||||
onChanged: vi.fn(() => () => undefined),
|
||||
},
|
||||
artifacts: {
|
||||
list: vi.fn(async () => []),
|
||||
|
||||
@@ -6096,9 +6096,28 @@ function App(): React.JSX.Element {
|
||||
}),
|
||||
);
|
||||
}, 0);
|
||||
return () => clearTimeout(timeout);
|
||||
// Background work (supervisor, heartbeat, knowledge, notes) records usage
|
||||
// without a renderer request; main notifies so the view stays current.
|
||||
const removeUsageListener = window.goodbuddy.usage.onChanged?.(() => {
|
||||
void refreshTokenUsage().catch(() => undefined);
|
||||
});
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
removeUsageListener?.();
|
||||
};
|
||||
}, [refreshTokenUsage, view]);
|
||||
|
||||
const manualRefreshTokenUsage = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
await refreshTokenUsage();
|
||||
} catch {
|
||||
notify({
|
||||
tone: "error",
|
||||
message: tRef.current("notices.tokenUsageReadFailed"),
|
||||
});
|
||||
}
|
||||
}, [notify, refreshTokenUsage]);
|
||||
|
||||
useEffect(() => {
|
||||
void window.goodbuddy.artifacts
|
||||
.list()
|
||||
@@ -11406,6 +11425,7 @@ function App(): React.JSX.Element {
|
||||
<ActivityPanel
|
||||
onClear={clearActivity}
|
||||
onOpenConversation={openActivityConversation}
|
||||
onRefreshTokenUsage={manualRefreshTokenUsage}
|
||||
projects={projects}
|
||||
records={activityRecords}
|
||||
tokenUsage={tokenUsage}
|
||||
|
||||
@@ -11,7 +11,8 @@ export const activity = {
|
||||
ariaLabel: 'Run history views',
|
||||
tasks: 'Tasks and conversations',
|
||||
timeline: 'Activity timeline',
|
||||
usage: 'Usage analytics'
|
||||
usage: 'Usage analytics',
|
||||
system: 'System tasks'
|
||||
},
|
||||
clear: {
|
||||
confirmAriaLabel: 'Confirm clearing activity history',
|
||||
@@ -71,6 +72,9 @@ export const activity = {
|
||||
},
|
||||
detailAriaLabel: 'Token usage details by {{group}}',
|
||||
empty: 'No token usage',
|
||||
refresh: 'Refresh',
|
||||
refreshing: 'Refreshing',
|
||||
refreshHint: 'Reload saved token usage',
|
||||
fallbacks: {
|
||||
unassignedProject: 'Unassigned project',
|
||||
deletedConversation: 'Deleted conversation',
|
||||
@@ -78,6 +82,28 @@ export const activity = {
|
||||
unknownRuntime: 'Unknown Runtime'
|
||||
}
|
||||
},
|
||||
systemUsage: {
|
||||
title: 'System task usage',
|
||||
description:
|
||||
'Tokens used by the supervisor, auto-supervision, magic notes, knowledge base, and background delegation. Not counted in conversation usage.',
|
||||
groupAriaLabel: 'Group system task usage',
|
||||
statsAriaLabel: 'System task usage totals',
|
||||
empty: 'No system task token usage',
|
||||
groups: {
|
||||
source: 'By source'
|
||||
},
|
||||
columns: {
|
||||
source: 'Source'
|
||||
},
|
||||
sources: {
|
||||
heartbeat: 'Auto-supervision',
|
||||
supervision: 'Supervisor',
|
||||
magicNotes: 'Magic notes',
|
||||
knowledge: 'Knowledge base',
|
||||
delegation: 'Background delegation',
|
||||
unknown: 'Other system tasks'
|
||||
}
|
||||
},
|
||||
timeline: {
|
||||
ariaLabel: 'Parallel activity tracks grouped by project and conversation',
|
||||
description:
|
||||
|
||||
@@ -8,7 +8,8 @@ export const activity = {
|
||||
ariaLabel: '运行记录视图',
|
||||
tasks: '任务与会话',
|
||||
timeline: '活动时间线',
|
||||
usage: '用量统计'
|
||||
usage: '用量统计',
|
||||
system: '系统任务'
|
||||
},
|
||||
clear: {
|
||||
confirmAriaLabel: '确认清空 {{formattedCount}} 条活动记录',
|
||||
@@ -68,6 +69,9 @@ export const activity = {
|
||||
},
|
||||
detailAriaLabel: 'Token 用量{{group}}明细',
|
||||
empty: '暂无 Token 用量',
|
||||
refresh: '刷新',
|
||||
refreshing: '正在刷新',
|
||||
refreshHint: '重新读取已保存的 Token 用量',
|
||||
fallbacks: {
|
||||
unassignedProject: '未归属项目',
|
||||
deletedConversation: '已删除会话',
|
||||
@@ -75,6 +79,28 @@ export const activity = {
|
||||
unknownRuntime: '未知 Runtime'
|
||||
}
|
||||
},
|
||||
systemUsage: {
|
||||
title: '系统任务用量',
|
||||
description:
|
||||
'监督者、自动监督、魔法笔记、知识库和后台委派等非会话任务消耗的 Token,不计入会话用量。',
|
||||
groupAriaLabel: '系统任务用量分组',
|
||||
statsAriaLabel: '系统任务用量统计',
|
||||
empty: '暂无系统任务 Token 用量',
|
||||
groups: {
|
||||
source: '按来源'
|
||||
},
|
||||
columns: {
|
||||
source: '来源'
|
||||
},
|
||||
sources: {
|
||||
heartbeat: '自动监督',
|
||||
supervision: '监督者',
|
||||
magicNotes: '魔法笔记',
|
||||
knowledge: '知识库',
|
||||
delegation: '后台委派',
|
||||
unknown: '其他系统任务'
|
||||
}
|
||||
},
|
||||
timeline: {
|
||||
ariaLabel: '按项目和会话分组的并行活动轨道',
|
||||
description:
|
||||
|
||||
@@ -14957,6 +14957,19 @@ textarea.field-control {
|
||||
font-size: var(--font-section-title);
|
||||
}
|
||||
|
||||
.token-usage__refresh {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.token-usage__description {
|
||||
margin: 8px 0 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-caption);
|
||||
}
|
||||
|
||||
.token-usage__stats {
|
||||
display: grid;
|
||||
margin: 12px 0;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { TokenUsageSummary } from '../../shared/assistant-contracts'
|
||||
|
||||
export type TokenUsageGroup = 'project' | 'conversation' | 'model'
|
||||
export type TokenUsageGroup = 'project' | 'conversation' | 'model' | 'source'
|
||||
|
||||
export type TokenUsageTotals = {
|
||||
inputTokens: number
|
||||
@@ -88,6 +88,15 @@ function groupIdentity(
|
||||
}
|
||||
}
|
||||
|
||||
if (group === 'source') {
|
||||
return {
|
||||
key: `source:${record.systemSource ?? 'unknown'}`,
|
||||
label: '',
|
||||
model: '',
|
||||
runtime: ''
|
||||
}
|
||||
}
|
||||
|
||||
if (group === 'conversation') {
|
||||
const conversationKey = record.conversationId
|
||||
? `conversation:${record.conversationId}`
|
||||
@@ -114,6 +123,41 @@ export function getTokenUsageTotals(
|
||||
return usageNumbers(tokenUsage.totals)
|
||||
}
|
||||
|
||||
export function isSystemTokenUsageRecord(record: TokenUsageRecord): boolean {
|
||||
return record.systemSource !== undefined
|
||||
}
|
||||
|
||||
/** Splits a summary into conversation-only and system-only views. */
|
||||
export function splitTokenUsage(tokenUsage: TokenUsageSummary): {
|
||||
conversation: TokenUsageSummary
|
||||
system: TokenUsageSummary
|
||||
} {
|
||||
const conversationRecords = tokenUsage.records.filter(
|
||||
(record) => !isSystemTokenUsageRecord(record)
|
||||
)
|
||||
const systemRecords = tokenUsage.records.filter(isSystemTokenUsageRecord)
|
||||
const emptyTotals = {
|
||||
callCount: 0,
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
cacheInput: 0,
|
||||
totalTokens: 0
|
||||
}
|
||||
return {
|
||||
// Legacy summaries without split totals keep their overall totals.
|
||||
conversation: {
|
||||
totals: tokenUsage.conversationTotals ?? tokenUsage.totals,
|
||||
records: conversationRecords
|
||||
},
|
||||
system: {
|
||||
totals: tokenUsage.systemTotals ?? emptyTotals,
|
||||
records: systemRecords
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function groupTokenUsage(
|
||||
tokenUsage: TokenUsageSummary,
|
||||
group: TokenUsageGroup
|
||||
|
||||
Reference in New Issue
Block a user