mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Desktop slash commands now separate local UI panels from CLI turn execution. The session inspector exposes status, usage, and context data from the active session, including transcript and context fallbacks, so /status, /cost, and /context can render structured desktop UI instead of raw terminal text. The inspector and help surfaces now use the existing desktop i18n catalogs for English and Chinese labels. Constraint: Desktop read-only slash commands must not spawn duplicate CLI processes or depend on submitting a normal user turn. Rejected: Render raw CLI command text in chat | it keeps terminal-specific layout constraints and does not fit the desktop panel UX. Confidence: high Scope-risk: moderate Directive: Keep /status, /cost, and /context routed through the local inspector unless the CLI exposes a structured interactive command protocol. Tested: cd desktop && bun run lint Tested: cd desktop && bun test src/components/chat/composerUtils.test.ts Tested: bun test src/server/__tests__/conversations.test.ts Tested: cd desktop && bun run build Tested: agent-browser smoke test on http://127.0.0.1:2024/ for /context localized inspector Tested: git diff --check Not-tested: Full packaged Tauri desktop build.
382 lines
12 KiB
TypeScript
382 lines
12 KiB
TypeScript
import type { BetaUsage as Usage } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
|
import chalk from 'chalk'
|
|
import {
|
|
addToTotalCostState,
|
|
addToTotalLinesChanged,
|
|
getCostCounter,
|
|
getModelUsage,
|
|
getSdkBetas,
|
|
getSessionId,
|
|
getTokenCounter,
|
|
getTotalAPIDuration,
|
|
getTotalAPIDurationWithoutRetries,
|
|
getTotalCacheCreationInputTokens,
|
|
getTotalCacheReadInputTokens,
|
|
getTotalCostUSD,
|
|
getTotalDuration,
|
|
getTotalInputTokens,
|
|
getTotalLinesAdded,
|
|
getTotalLinesRemoved,
|
|
getTotalOutputTokens,
|
|
getTotalToolDuration,
|
|
getTotalWebSearchRequests,
|
|
getUsageForModel,
|
|
hasUnknownModelCost,
|
|
resetCostState,
|
|
resetStateForTests,
|
|
setCostStateForRestore,
|
|
setHasUnknownModelCost,
|
|
} from './bootstrap/state.js'
|
|
import type { ModelUsage } from './entrypoints/agentSdkTypes.js'
|
|
import {
|
|
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
logEvent,
|
|
} from './services/analytics/index.js'
|
|
import { getAdvisorUsage } from './utils/advisor.js'
|
|
import {
|
|
getCurrentProjectConfig,
|
|
saveCurrentProjectConfig,
|
|
} from './utils/config.js'
|
|
import {
|
|
getContextWindowForModel,
|
|
getModelMaxOutputTokens,
|
|
} from './utils/context.js'
|
|
import { isFastModeEnabled } from './utils/fastMode.js'
|
|
import { formatDuration, formatNumber } from './utils/format.js'
|
|
import type { FpsMetrics } from './utils/fpsTracker.js'
|
|
import { getCanonicalName } from './utils/model/model.js'
|
|
import { calculateUSDCost } from './utils/modelCost.js'
|
|
export {
|
|
getTotalCostUSD as getTotalCost,
|
|
getTotalDuration,
|
|
getTotalAPIDuration,
|
|
getTotalAPIDurationWithoutRetries,
|
|
addToTotalLinesChanged,
|
|
getTotalLinesAdded,
|
|
getTotalLinesRemoved,
|
|
getTotalInputTokens,
|
|
getTotalOutputTokens,
|
|
getTotalCacheReadInputTokens,
|
|
getTotalCacheCreationInputTokens,
|
|
getTotalWebSearchRequests,
|
|
formatCost,
|
|
hasUnknownModelCost,
|
|
resetStateForTests,
|
|
resetCostState,
|
|
setHasUnknownModelCost,
|
|
getModelUsage,
|
|
getUsageForModel,
|
|
}
|
|
|
|
export type SessionUsageSnapshot = {
|
|
totalCostUSD: number
|
|
costDisplay: string
|
|
hasUnknownModelCost: boolean
|
|
totalAPIDuration: number
|
|
totalDuration: number
|
|
totalLinesAdded: number
|
|
totalLinesRemoved: number
|
|
totalInputTokens: number
|
|
totalOutputTokens: number
|
|
totalCacheReadInputTokens: number
|
|
totalCacheCreationInputTokens: number
|
|
totalWebSearchRequests: number
|
|
models: Array<{
|
|
model: string
|
|
displayName: string
|
|
inputTokens: number
|
|
outputTokens: number
|
|
cacheReadInputTokens: number
|
|
cacheCreationInputTokens: number
|
|
webSearchRequests: number
|
|
costUSD: number
|
|
costDisplay: string
|
|
contextWindow: number
|
|
maxOutputTokens: number
|
|
}>
|
|
}
|
|
|
|
type StoredCostState = {
|
|
totalCostUSD: number
|
|
totalAPIDuration: number
|
|
totalAPIDurationWithoutRetries: number
|
|
totalToolDuration: number
|
|
totalLinesAdded: number
|
|
totalLinesRemoved: number
|
|
lastDuration: number | undefined
|
|
modelUsage: { [modelName: string]: ModelUsage } | undefined
|
|
}
|
|
|
|
/**
|
|
* Gets stored cost state from project config for a specific session.
|
|
* Returns the cost data if the session ID matches, or undefined otherwise.
|
|
* Use this to read costs BEFORE overwriting the config with saveCurrentSessionCosts().
|
|
*/
|
|
export function getStoredSessionCosts(
|
|
sessionId: string,
|
|
): StoredCostState | undefined {
|
|
const projectConfig = getCurrentProjectConfig()
|
|
|
|
// Only return costs if this is the same session that was last saved
|
|
if (projectConfig.lastSessionId !== sessionId) {
|
|
return undefined
|
|
}
|
|
|
|
// Build model usage with context windows
|
|
let modelUsage: { [modelName: string]: ModelUsage } | undefined
|
|
if (projectConfig.lastModelUsage) {
|
|
modelUsage = Object.fromEntries(
|
|
Object.entries(projectConfig.lastModelUsage).map(([model, usage]) => [
|
|
model,
|
|
{
|
|
...usage,
|
|
contextWindow: getContextWindowForModel(model, getSdkBetas()),
|
|
maxOutputTokens: getModelMaxOutputTokens(model).default,
|
|
},
|
|
]),
|
|
)
|
|
}
|
|
|
|
return {
|
|
totalCostUSD: projectConfig.lastCost ?? 0,
|
|
totalAPIDuration: projectConfig.lastAPIDuration ?? 0,
|
|
totalAPIDurationWithoutRetries:
|
|
projectConfig.lastAPIDurationWithoutRetries ?? 0,
|
|
totalToolDuration: projectConfig.lastToolDuration ?? 0,
|
|
totalLinesAdded: projectConfig.lastLinesAdded ?? 0,
|
|
totalLinesRemoved: projectConfig.lastLinesRemoved ?? 0,
|
|
lastDuration: projectConfig.lastDuration,
|
|
modelUsage,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Restores cost state from project config when resuming a session.
|
|
* Only restores if the session ID matches the last saved session.
|
|
* @returns true if cost state was restored, false otherwise
|
|
*/
|
|
export function restoreCostStateForSession(sessionId: string): boolean {
|
|
const data = getStoredSessionCosts(sessionId)
|
|
if (!data) {
|
|
return false
|
|
}
|
|
setCostStateForRestore(data)
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Saves the current session's costs to project config.
|
|
* Call this before switching sessions to avoid losing accumulated costs.
|
|
*/
|
|
export function saveCurrentSessionCosts(fpsMetrics?: FpsMetrics): void {
|
|
saveCurrentProjectConfig(current => ({
|
|
...current,
|
|
lastCost: getTotalCostUSD(),
|
|
lastAPIDuration: getTotalAPIDuration(),
|
|
lastAPIDurationWithoutRetries: getTotalAPIDurationWithoutRetries(),
|
|
lastToolDuration: getTotalToolDuration(),
|
|
lastDuration: getTotalDuration(),
|
|
lastLinesAdded: getTotalLinesAdded(),
|
|
lastLinesRemoved: getTotalLinesRemoved(),
|
|
lastTotalInputTokens: getTotalInputTokens(),
|
|
lastTotalOutputTokens: getTotalOutputTokens(),
|
|
lastTotalCacheCreationInputTokens: getTotalCacheCreationInputTokens(),
|
|
lastTotalCacheReadInputTokens: getTotalCacheReadInputTokens(),
|
|
lastTotalWebSearchRequests: getTotalWebSearchRequests(),
|
|
lastFpsAverage: fpsMetrics?.averageFps,
|
|
lastFpsLow1Pct: fpsMetrics?.low1PctFps,
|
|
lastModelUsage: Object.fromEntries(
|
|
Object.entries(getModelUsage()).map(([model, usage]) => [
|
|
model,
|
|
{
|
|
inputTokens: usage.inputTokens,
|
|
outputTokens: usage.outputTokens,
|
|
cacheReadInputTokens: usage.cacheReadInputTokens,
|
|
cacheCreationInputTokens: usage.cacheCreationInputTokens,
|
|
webSearchRequests: usage.webSearchRequests,
|
|
costUSD: usage.costUSD,
|
|
},
|
|
]),
|
|
),
|
|
lastSessionId: getSessionId(),
|
|
}))
|
|
}
|
|
|
|
function formatCost(cost: number, maxDecimalPlaces: number = 4): string {
|
|
return `$${cost > 0.5 ? round(cost, 100).toFixed(2) : cost.toFixed(maxDecimalPlaces)}`
|
|
}
|
|
|
|
function formatModelUsage(): string {
|
|
const modelUsageMap = getModelUsage()
|
|
if (Object.keys(modelUsageMap).length === 0) {
|
|
return 'Usage: 0 input, 0 output, 0 cache read, 0 cache write'
|
|
}
|
|
|
|
// Accumulate usage by short name
|
|
const usageByShortName: { [shortName: string]: ModelUsage } = {}
|
|
for (const [model, usage] of Object.entries(modelUsageMap)) {
|
|
const shortName = getCanonicalName(model)
|
|
if (!usageByShortName[shortName]) {
|
|
usageByShortName[shortName] = {
|
|
inputTokens: 0,
|
|
outputTokens: 0,
|
|
cacheReadInputTokens: 0,
|
|
cacheCreationInputTokens: 0,
|
|
webSearchRequests: 0,
|
|
costUSD: 0,
|
|
contextWindow: 0,
|
|
maxOutputTokens: 0,
|
|
}
|
|
}
|
|
const accumulated = usageByShortName[shortName]
|
|
accumulated.inputTokens += usage.inputTokens
|
|
accumulated.outputTokens += usage.outputTokens
|
|
accumulated.cacheReadInputTokens += usage.cacheReadInputTokens
|
|
accumulated.cacheCreationInputTokens += usage.cacheCreationInputTokens
|
|
accumulated.webSearchRequests += usage.webSearchRequests
|
|
accumulated.costUSD += usage.costUSD
|
|
}
|
|
|
|
let result = 'Usage by model:'
|
|
for (const [shortName, usage] of Object.entries(usageByShortName)) {
|
|
const usageString =
|
|
` ${formatNumber(usage.inputTokens)} input, ` +
|
|
`${formatNumber(usage.outputTokens)} output, ` +
|
|
`${formatNumber(usage.cacheReadInputTokens)} cache read, ` +
|
|
`${formatNumber(usage.cacheCreationInputTokens)} cache write` +
|
|
(usage.webSearchRequests > 0
|
|
? `, ${formatNumber(usage.webSearchRequests)} web search`
|
|
: '') +
|
|
` (${formatCost(usage.costUSD)})`
|
|
result += `\n` + `${shortName}:`.padStart(21) + usageString
|
|
}
|
|
return result
|
|
}
|
|
|
|
export function formatTotalCost(): string {
|
|
const costDisplay =
|
|
formatCost(getTotalCostUSD()) +
|
|
(hasUnknownModelCost()
|
|
? ' (costs may be inaccurate due to usage of unknown models)'
|
|
: '')
|
|
|
|
const modelUsageDisplay = formatModelUsage()
|
|
|
|
return chalk.dim(
|
|
`Total cost: ${costDisplay}\n` +
|
|
`Total duration (API): ${formatDuration(getTotalAPIDuration())}
|
|
Total duration (wall): ${formatDuration(getTotalDuration())}
|
|
Total code changes: ${getTotalLinesAdded()} ${getTotalLinesAdded() === 1 ? 'line' : 'lines'} added, ${getTotalLinesRemoved()} ${getTotalLinesRemoved() === 1 ? 'line' : 'lines'} removed
|
|
${modelUsageDisplay}`,
|
|
)
|
|
}
|
|
|
|
export function getSessionUsageSnapshot(): SessionUsageSnapshot {
|
|
return {
|
|
totalCostUSD: getTotalCostUSD(),
|
|
costDisplay: formatCost(getTotalCostUSD()),
|
|
hasUnknownModelCost: hasUnknownModelCost(),
|
|
totalAPIDuration: getTotalAPIDuration(),
|
|
totalDuration: getTotalDuration(),
|
|
totalLinesAdded: getTotalLinesAdded(),
|
|
totalLinesRemoved: getTotalLinesRemoved(),
|
|
totalInputTokens: getTotalInputTokens(),
|
|
totalOutputTokens: getTotalOutputTokens(),
|
|
totalCacheReadInputTokens: getTotalCacheReadInputTokens(),
|
|
totalCacheCreationInputTokens: getTotalCacheCreationInputTokens(),
|
|
totalWebSearchRequests: getTotalWebSearchRequests(),
|
|
models: Object.entries(getModelUsage()).map(([model, usage]) => ({
|
|
model,
|
|
displayName: getCanonicalName(model),
|
|
inputTokens: usage.inputTokens,
|
|
outputTokens: usage.outputTokens,
|
|
cacheReadInputTokens: usage.cacheReadInputTokens,
|
|
cacheCreationInputTokens: usage.cacheCreationInputTokens,
|
|
webSearchRequests: usage.webSearchRequests,
|
|
costUSD: usage.costUSD,
|
|
costDisplay: formatCost(usage.costUSD),
|
|
contextWindow: usage.contextWindow,
|
|
maxOutputTokens: usage.maxOutputTokens,
|
|
})),
|
|
}
|
|
}
|
|
|
|
function round(number: number, precision: number): number {
|
|
return Math.round(number * precision) / precision
|
|
}
|
|
|
|
function addToTotalModelUsage(
|
|
cost: number,
|
|
usage: Usage,
|
|
model: string,
|
|
): ModelUsage {
|
|
const modelUsage = getUsageForModel(model) ?? {
|
|
inputTokens: 0,
|
|
outputTokens: 0,
|
|
cacheReadInputTokens: 0,
|
|
cacheCreationInputTokens: 0,
|
|
webSearchRequests: 0,
|
|
costUSD: 0,
|
|
contextWindow: 0,
|
|
maxOutputTokens: 0,
|
|
}
|
|
|
|
modelUsage.inputTokens += usage.input_tokens
|
|
modelUsage.outputTokens += usage.output_tokens
|
|
modelUsage.cacheReadInputTokens += usage.cache_read_input_tokens ?? 0
|
|
modelUsage.cacheCreationInputTokens += usage.cache_creation_input_tokens ?? 0
|
|
modelUsage.webSearchRequests +=
|
|
usage.server_tool_use?.web_search_requests ?? 0
|
|
modelUsage.costUSD += cost
|
|
modelUsage.contextWindow = getContextWindowForModel(model, getSdkBetas())
|
|
modelUsage.maxOutputTokens = getModelMaxOutputTokens(model).default
|
|
return modelUsage
|
|
}
|
|
|
|
export function addToTotalSessionCost(
|
|
cost: number,
|
|
usage: Usage,
|
|
model: string,
|
|
): number {
|
|
const modelUsage = addToTotalModelUsage(cost, usage, model)
|
|
addToTotalCostState(cost, modelUsage, model)
|
|
|
|
const attrs =
|
|
isFastModeEnabled() && usage.speed === 'fast'
|
|
? { model, speed: 'fast' }
|
|
: { model }
|
|
|
|
getCostCounter()?.add(cost, attrs)
|
|
getTokenCounter()?.add(usage.input_tokens, { ...attrs, type: 'input' })
|
|
getTokenCounter()?.add(usage.output_tokens, { ...attrs, type: 'output' })
|
|
getTokenCounter()?.add(usage.cache_read_input_tokens ?? 0, {
|
|
...attrs,
|
|
type: 'cacheRead',
|
|
})
|
|
getTokenCounter()?.add(usage.cache_creation_input_tokens ?? 0, {
|
|
...attrs,
|
|
type: 'cacheCreation',
|
|
})
|
|
|
|
let totalCost = cost
|
|
for (const advisorUsage of getAdvisorUsage(usage)) {
|
|
const advisorCost = calculateUSDCost(advisorUsage.model, advisorUsage)
|
|
logEvent('tengu_advisor_tool_token_usage', {
|
|
advisor_model:
|
|
advisorUsage.model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
|
input_tokens: advisorUsage.input_tokens,
|
|
output_tokens: advisorUsage.output_tokens,
|
|
cache_read_input_tokens: advisorUsage.cache_read_input_tokens ?? 0,
|
|
cache_creation_input_tokens:
|
|
advisorUsage.cache_creation_input_tokens ?? 0,
|
|
cost_usd_micros: Math.round(advisorCost * 1_000_000),
|
|
})
|
|
totalCost += addToTotalSessionCost(
|
|
advisorCost,
|
|
advisorUsage,
|
|
advisorUsage.model,
|
|
)
|
|
}
|
|
return totalCost
|
|
}
|