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:
mesalogo
2026-09-29 10:38:37 +08:00
parent 3c9229ac7a
commit 9a9170441e
24 changed files with 991 additions and 286 deletions
+49
View File
@@ -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
View File
@@ -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>
)
}
})
+1
View File
@@ -640,6 +640,7 @@ const api: DesktopApi & RuntimeNativeClientApi = {
},
records: [],
})),
onChanged: vi.fn(() => () => undefined),
},
artifacts: {
list: vi.fn(async () => []),
+21 -1
View File
@@ -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:
+13
View File
@@ -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;
+45 -1
View File
@@ -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