diff --git a/docs/specs/settings-dashboard/plan.md b/docs/specs/settings-dashboard/plan.md new file mode 100644 index 000000000..1bc7f2831 --- /dev/null +++ b/docs/specs/settings-dashboard/plan.md @@ -0,0 +1,46 @@ +# Plan + +## Data Model + +- Add `deepchat_usage_stats` keyed by `message_id`. +- Store final per-message usage snapshots: + - session, provider, model + - input/output/total tokens + - cached input tokens + - estimated USD cost + - local usage date + - source (`backfill` or `live`) + +## Backfill + +- Trigger in `AFTER_START` with a non-blocking hook. +- Scan only `deepchat_messages` joined with `deepchat_sessions`. +- Use message metadata provider/model first, then session fallback. +- Persist backfill status in config under `dashboardStatsBackfillV1`. +- Re-running is safe because stats rows are upserted by `message_id`. + +## Live Recording + +- Extend stream usage metadata with optional `cached_tokens`. +- Persist cached input tokens into assistant message metadata. +- Upsert stats from `DeepChatMessageStore.finalizeAssistantMessage` and `setMessageError`. + +## Dashboard Query + +- Expose `newAgentPresenter.getUsageDashboard()`. +- Aggregate summary, 365-day calendar, provider breakdown, and model breakdown from `deepchat_usage_stats`. + +## UI + +- Add `DashboardSettings.vue` as a scrollable settings page. +- Keep the visual language aligned with the current project theme. +- Show loading, empty, running backfill, and failed backfill states. +- Render four summary cards only; remove the cache hit rate card from the dashboard overview. +- Adopt the official `shadcn-vue chart` component with `Unovis` for dashboard chart rendering. +- Rebuild the overview layout as `1 large + 3 small`, with total tokens as the hero chart. +- Replace the total-token number card with a donut-based hero chart that visualizes input/output ratio. +- Visualize cached input tokens with a compact horizontal stacked bar for cached versus uncached input. +- Visualize estimated cost with a 30-day area chart while keeping the total cost as the primary value. +- Reuse `recordingStartedAt` to render a locale-specific, number-first "days with DeepChat" summary card in the renderer. +- Keep provider/model ranking queries unchanged, but render them as horizontal token bar charts with internal scrolling. +- Translate changed dashboard copy per locale instead of falling back to English sentence structure. diff --git a/docs/specs/settings-dashboard/spec.md b/docs/specs/settings-dashboard/spec.md new file mode 100644 index 000000000..47efb1f69 --- /dev/null +++ b/docs/specs/settings-dashboard/spec.md @@ -0,0 +1,33 @@ +# Settings Dashboard + +## Goal + +Add a dedicated dashboard page under settings to show token usage, cached token usage, estimated cost, and a GitHub-like contribution calendar. + +## User Stories + +- As a user, I want to see my total token usage, cached token usage, and estimated cost in one place. +- As an existing user, I want the dashboard to initialize from the current `deepchat_messages` table once, without scanning legacy tables. +- As a user, I want the dashboard to keep growing from newly recorded usage without repeatedly recomputing from old chat tables. + +## Acceptance Criteria + +- A new settings route named `settings-dashboard` is available after provider settings. +- The dashboard reads from a dedicated `deepchat_usage_stats` table only. +- Existing users get a one-time background backfill from current `deepchat_messages`. +- Historical backfill sets cached input tokens to `0`. +- New assistant message finalization and error finalization upsert usage rows into `deepchat_usage_stats`. +- Price estimation uses current provider pricing first and falls back to `aihubmix` for the same model id when needed. +- The page contains four overview cards arranged as `1 large + 3 small`: a total-token hero chart, a cached-token ratio card, an estimated-cost trend card, and a days-with-DeepChat card. +- The total-token hero chart uses the shared `shadcn-vue chart + Unovis` visual language, keeps the donut semantics for input/output composition, and shows exact values plus percentages. +- The cached-token card uses the same chart system to visualize cached versus uncached input tokens and shows exact values plus percentages. +- The estimated-cost card uses the same chart system to show the total estimated cost plus a lightweight 30-day area trend. +- The "days with DeepChat" card is derived from the earliest recorded usage date and rendered in a number-first, locale-specific layout. +- The page contains a 365-day contribution calendar and provider/model breakdowns. +- Provider and model breakdown cards render horizontal token bar charts with internal scrolling without growing the full page indefinitely. + +## Non-Goals + +- No backfill from legacy `messages` or `conversations` tables. +- No delete-triggered rollback of accumulated usage stats. +- No additional day-level rollup table in v1. diff --git a/docs/specs/settings-dashboard/tasks.md b/docs/specs/settings-dashboard/tasks.md new file mode 100644 index 000000000..b88f42874 --- /dev/null +++ b/docs/specs/settings-dashboard/tasks.md @@ -0,0 +1,8 @@ +# Tasks + +1. Add shared dashboard types and cached token usage plumbing. +2. Add `deepchat_usage_stats` table and wire it into `SQLitePresenter`. +3. Record live usage stats on assistant finalize and error finalize. +4. Implement one-time historical backfill and dashboard query methods in `NewAgentPresenter`. +5. Add settings route, dashboard page, and i18n strings. +6. Add focused main/renderer tests and run format, i18n, and lint. diff --git a/package.json b/package.json index b2a63fe56..2aa5ce649 100644 --- a/package.json +++ b/package.json @@ -115,6 +115,8 @@ "@lingual/i18n-check": "0.8.12", "@mcp-ui/client": "^5.13.3", "@pinia/colada": "^0.20.0", + "@unovis/ts": "1.6.4", + "@unovis/vue": "1.6.4", "@tailwindcss/typography": "^0.5.19", "@tailwindcss/vite": "^4.1.18", "@tiptap/core": "^2.11.7", diff --git a/src/main/presenter/deepchatAgentPresenter/accumulator.ts b/src/main/presenter/deepchatAgentPresenter/accumulator.ts index 6d6569baa..141259676 100644 --- a/src/main/presenter/deepchatAgentPresenter/accumulator.ts +++ b/src/main/presenter/deepchatAgentPresenter/accumulator.ts @@ -123,6 +123,7 @@ export function accumulate(state: StreamState, event: LLMCoreStreamEvent): void state.metadata.inputTokens = event.usage.prompt_tokens state.metadata.outputTokens = event.usage.completion_tokens state.metadata.totalTokens = event.usage.total_tokens + state.metadata.cachedInputTokens = event.usage.cached_tokens break } case 'stop': { diff --git a/src/main/presenter/deepchatAgentPresenter/messageStore.ts b/src/main/presenter/deepchatAgentPresenter/messageStore.ts index a9ad90b4c..914dd51ee 100644 --- a/src/main/presenter/deepchatAgentPresenter/messageStore.ts +++ b/src/main/presenter/deepchatAgentPresenter/messageStore.ts @@ -8,7 +8,14 @@ import type { MessageMetadata } from '@shared/types/agent-interface' import type { SearchResult } from '@shared/types/core/search' +import logger from '@shared/logger' import type { DeepChatMessageRow } from '../sqlitePresenter/tables/deepchatMessages' +import { + buildUsageStatsRecord, + parseMessageMetadata, + resolveUsageModelId, + resolveUsageProviderId +} from '../usageStats' export class DeepChatMessageStore { private sqlitePresenter: SQLitePresenter @@ -81,6 +88,7 @@ export class DeepChatMessageStore { 'sent', metadata ) + this.persistUsageStats(messageId, metadata, 'live') } updateCompactionMessage( @@ -112,6 +120,7 @@ export class DeepChatMessageStore { 'error', metadata ) + this.persistUsageStats(messageId, metadata, 'live') } getMessages(sessionId: string): ChatMessageRecord[] { @@ -376,4 +385,55 @@ export class DeepChatMessageStore { summaryUpdatedAt } } + + private persistUsageStats( + messageId: string, + metadataRaw: string, + source: 'backfill' | 'live' + ): void { + const usageStatsTable = this.sqlitePresenter.deepchatUsageStatsTable + if (!usageStatsTable) { + return + } + + const messageRow = this.sqlitePresenter.deepchatMessagesTable.get(messageId) + if (!messageRow || messageRow.role !== 'assistant') { + return + } + + try { + const metadata = parseMessageMetadata(metadataRaw) + if (metadata.messageType === 'compaction') { + return + } + + const sessionRow = this.sqlitePresenter.deepchatSessionsTable.get(messageRow.session_id) + const providerId = resolveUsageProviderId(metadata, sessionRow?.provider_id) + const modelId = resolveUsageModelId(metadata, sessionRow?.model_id) + + if (!providerId || !modelId) { + return + } + + const usageRecord = buildUsageStatsRecord({ + messageId: messageRow.id, + sessionId: messageRow.session_id, + createdAt: messageRow.created_at, + updatedAt: messageRow.updated_at, + providerId, + modelId, + metadata, + source + }) + + if (!usageRecord) { + return + } + + usageStatsTable.upsert(usageRecord) + } catch (error) { + logger.error('Failed to persist deepchat usage stats', { messageId, source }, error) + return + } + } } diff --git a/src/main/presenter/deepchatAgentPresenter/process.ts b/src/main/presenter/deepchatAgentPresenter/process.ts index 1b12b7ada..36b963c4a 100644 --- a/src/main/presenter/deepchatAgentPresenter/process.ts +++ b/src/main/presenter/deepchatAgentPresenter/process.ts @@ -225,5 +225,8 @@ function buildUsageSnapshot(state: StreamState): Record { if (typeof state.metadata.outputTokens === 'number') { usage.outputTokens = state.metadata.outputTokens } + if (typeof state.metadata.cachedInputTokens === 'number') { + usage.cachedInputTokens = state.metadata.cachedInputTokens + } return usage } diff --git a/src/main/presenter/lifecyclePresenter/hooks/after-start/usageStatsBackfillHook.ts b/src/main/presenter/lifecyclePresenter/hooks/after-start/usageStatsBackfillHook.ts new file mode 100644 index 000000000..178ea261f --- /dev/null +++ b/src/main/presenter/lifecyclePresenter/hooks/after-start/usageStatsBackfillHook.ts @@ -0,0 +1,26 @@ +import { LifecycleHook, LifecycleContext } from '@shared/presenter' +import { LifecyclePhase } from '@shared/lifecycle' +import { presenter } from '@/presenter' + +export const usageStatsBackfillHook: LifecycleHook = { + name: 'usage-stats-backfill', + phase: LifecyclePhase.AFTER_START, + priority: 21, + critical: false, + execute: async (_context: LifecycleContext) => { + if (!presenter) { + throw new Error('usageStatsBackfillHook: Presenter not initialized') + } + + const newAgentPresenter = presenter.newAgentPresenter as unknown as { + startUsageStatsBackfill?: () => Promise + } + if (!newAgentPresenter.startUsageStatsBackfill) { + return + } + + void newAgentPresenter.startUsageStatsBackfill().catch((error) => { + console.error('usageStatsBackfillHook: failed to start usage stats backfill:', error) + }) + } +} diff --git a/src/main/presenter/lifecyclePresenter/hooks/index.ts b/src/main/presenter/lifecyclePresenter/hooks/index.ts index 4ba6a86ec..fc3b652ff 100644 --- a/src/main/presenter/lifecyclePresenter/hooks/index.ts +++ b/src/main/presenter/lifecyclePresenter/hooks/index.ts @@ -11,6 +11,7 @@ export { eventListenerSetupHook } from './ready/eventListenerSetupHook' export { traySetupHook } from './after-start/traySetupHook' export { windowCreationHook } from './after-start/windowCreationHook' export { legacyImportHook } from './after-start/legacyImportHook' +export { usageStatsBackfillHook } from './after-start/usageStatsBackfillHook' export { trayDestroyHook } from './beforeQuit/trayDestroyHook' export { floatingDestroyHook } from './beforeQuit/floatingDestroyHook' export { presenterDestroyHook } from './beforeQuit/presenterDestroyHook' diff --git a/src/main/presenter/llmProviderPresenter/providers/openAICompatibleProvider.ts b/src/main/presenter/llmProviderPresenter/providers/openAICompatibleProvider.ts index abb3d7043..eac2fafce 100644 --- a/src/main/presenter/llmProviderPresenter/providers/openAICompatibleProvider.ts +++ b/src/main/presenter/llmProviderPresenter/providers/openAICompatibleProvider.ts @@ -66,6 +66,28 @@ const SUPPORTED_IMAGE_SIZES = { // Add list of models with configurable sizes const SIZE_CONFIGURABLE_MODELS = ['gpt-image-1', 'gpt-4o-image', 'gpt-4o-all'] +function getOpenAIChatCachedTokens(usage: unknown): number | undefined { + if (!usage || typeof usage !== 'object') { + return undefined + } + + const promptTokensDetails = (usage as { prompt_tokens_details?: unknown }).prompt_tokens_details + const inputTokensDetails = (usage as { input_tokens_details?: unknown }).input_tokens_details + const promptCachedTokens = + promptTokensDetails && typeof promptTokensDetails === 'object' + ? (promptTokensDetails as { cached_tokens?: unknown }).cached_tokens + : undefined + const inputCachedTokens = + inputTokensDetails && typeof inputTokensDetails === 'object' + ? (inputTokensDetails as { cached_tokens?: unknown }).cached_tokens + : undefined + const cachedTokens = + typeof promptCachedTokens === 'number' ? promptCachedTokens : inputCachedTokens + return typeof cachedTokens === 'number' && Number.isFinite(cachedTokens) + ? cachedTokens + : undefined +} + export class OpenAICompatibleProvider extends BaseLLMProvider { protected openai!: OpenAI protected isNoModelsApi: boolean = false @@ -976,7 +998,8 @@ export class OpenAICompatibleProvider extends BaseLLMProvider { yield createStreamEvent.usage({ prompt_tokens: result.usage.input_tokens || 0, completion_tokens: result.usage.output_tokens || 0, - total_tokens: result.usage.total_tokens || 0 + total_tokens: result.usage.total_tokens || 0, + cached_tokens: getOpenAIChatCachedTokens(result.usage) }) } @@ -1142,6 +1165,7 @@ export class OpenAICompatibleProvider extends BaseLLMProvider { prompt_tokens: number completion_tokens: number total_tokens: number + cached_tokens?: number } | undefined = undefined @@ -1156,7 +1180,10 @@ export class OpenAICompatibleProvider extends BaseLLMProvider { // 1. 处理非内容事件 (如 usage, reasoning, tool_calls) if (chunk.usage) { - usage = chunk.usage + usage = { + ...chunk.usage, + cached_tokens: getOpenAIChatCachedTokens(chunk.usage) + } } // 原生 reasoning 内容处理(直接产出) diff --git a/src/main/presenter/llmProviderPresenter/providers/openAIResponsesProvider.ts b/src/main/presenter/llmProviderPresenter/providers/openAIResponsesProvider.ts index 63e80ad34..8ae15e28c 100644 --- a/src/main/presenter/llmProviderPresenter/providers/openAIResponsesProvider.ts +++ b/src/main/presenter/llmProviderPresenter/providers/openAIResponsesProvider.ts @@ -57,6 +57,22 @@ const SUPPORTED_IMAGE_SIZES = { // 添加可设置尺寸的模型列表 const SIZE_CONFIGURABLE_MODELS = ['gpt-image-1', 'gpt-4o-image', 'gpt-4o-all'] +function getOpenAIResponseCachedTokens( + usage: + | { + input_tokens_details?: { + cached_tokens?: number + } + } + | null + | undefined +): number | undefined { + const cachedTokens = usage?.input_tokens_details?.cached_tokens + return typeof cachedTokens === 'number' && Number.isFinite(cachedTokens) + ? cachedTokens + : undefined +} + export class OpenAIResponsesProvider extends BaseLLMProvider { protected openai!: OpenAI private isNoModelsApi: boolean = false @@ -521,7 +537,8 @@ export class OpenAIResponsesProvider extends BaseLLMProvider { yield createStreamEvent.usage({ prompt_tokens: result.usage.input_tokens || 0, completion_tokens: result.usage.output_tokens || 0, - total_tokens: result.usage.total_tokens || 0 + total_tokens: result.usage.total_tokens || 0, + cached_tokens: getOpenAIResponseCachedTokens(result.usage) }) } @@ -645,6 +662,7 @@ export class OpenAIResponsesProvider extends BaseLLMProvider { prompt_tokens: number completion_tokens: number total_tokens: number + cached_tokens?: number } | undefined = undefined @@ -954,7 +972,8 @@ export class OpenAIResponsesProvider extends BaseLLMProvider { usage = { prompt_tokens: response.usage.input_tokens || 0, completion_tokens: response.usage.output_tokens || 0, - total_tokens: response.usage.total_tokens || 0 + total_tokens: response.usage.total_tokens || 0, + cached_tokens: getOpenAIResponseCachedTokens(response.usage) } yield createStreamEvent.usage(usage) } diff --git a/src/main/presenter/newAgentPresenter/index.ts b/src/main/presenter/newAgentPresenter/index.ts index d7a4c175a..5fcd47d2b 100644 --- a/src/main/presenter/newAgentPresenter/index.ts +++ b/src/main/presenter/newAgentPresenter/index.ts @@ -15,7 +15,10 @@ import type { SessionCompactionState, SessionGenerationSettings, ToolInteractionResponse, - ToolInteractionResult + ToolInteractionResult, + UsageDashboardData, + UsageDashboardBreakdownItem, + UsageStatsBackfillStatus } from '@shared/types/agent-interface' import type { Message } from '@shared/chat' import type { SearchResult } from '@shared/types/core/search' @@ -39,6 +42,18 @@ import { generateExportFilename, type ConversationExportFormat } from '../exporter/formats/conversationExporter' +import { + DASHBOARD_STATS_BACKFILL_KEY, + buildUsageDashboardCalendar, + buildUsageStatsRecord, + getModelLabel, + getProviderLabel, + isUsageBackfillRunningStale, + normalizeUsageStatsBackfillStatus, + parseMessageMetadata as parseUsageMetadata, + resolveUsageModelId, + resolveUsageProviderId +} from '../usageStats' export class NewAgentPresenter { private agentRegistry: AgentRegistry @@ -49,6 +64,7 @@ export class NewAgentPresenter { private configPresenter: IConfigPresenter private legacyImportService: LegacyChatImportService private skillPresenter?: Pick + private usageStatsBackfillPromise: Promise | null = null constructor( deepchatAgent: DeepChatAgentPresenter, @@ -457,6 +473,90 @@ export class NewAgentPresenter { this.legacyImportService.startInBackground(false) } + async startUsageStatsBackfill(): Promise { + const currentStatus = this.getUsageStatsBackfillStatus() + if (currentStatus.status === 'completed') { + return + } + + if (currentStatus.status === 'running' && !isUsageBackfillRunningStale(currentStatus)) { + return + } + + if (this.usageStatsBackfillPromise) { + return await this.usageStatsBackfillPromise + } + + this.usageStatsBackfillPromise = this.runUsageStatsBackfill().finally(() => { + this.usageStatsBackfillPromise = null + }) + + return await this.usageStatsBackfillPromise + } + + async getUsageDashboard(): Promise { + const backfillStatus = this.getUsageStatsBackfillStatus() + const usageStatsTable = this.sqlitePresenter.deepchatUsageStatsTable + const summaryRow = usageStatsTable.getSummary() + const mostActiveDay = usageStatsTable.getMostActiveDay() + const recordingStartedAt = usageStatsTable.getRecordingStartedAt() + const cacheHitRate = + summaryRow.inputTokens > 0 ? summaryRow.cachedInputTokens / summaryRow.inputTokens : 0 + + const dateFrom = new Date() + dateFrom.setHours(0, 0, 0, 0) + dateFrom.setDate(dateFrom.getDate() - 364) + + const calendar = buildUsageDashboardCalendar( + usageStatsTable.getDailyCalendarRows(this.toLocalDateKey(dateFrom.getTime())) + ) + + const providerBreakdown = this.sortUsageBreakdown( + usageStatsTable.getProviderBreakdownRows().map((row) => ({ + id: row.id, + label: getProviderLabel(this.configPresenter, row.id), + messageCount: row.messageCount, + inputTokens: row.inputTokens, + outputTokens: row.outputTokens, + totalTokens: row.totalTokens, + cachedInputTokens: row.cachedInputTokens, + estimatedCostUsd: row.estimatedCostUsd + })) + ) + + const modelBreakdown = this.sortUsageBreakdown( + usageStatsTable.getModelBreakdownRows(10).map((row) => ({ + id: row.id, + label: getModelLabel('', row.id), + messageCount: row.messageCount, + inputTokens: row.inputTokens, + outputTokens: row.outputTokens, + totalTokens: row.totalTokens, + cachedInputTokens: row.cachedInputTokens, + estimatedCostUsd: row.estimatedCostUsd + })) + ) + + return { + recordingStartedAt, + backfillStatus, + summary: { + messageCount: summaryRow.messageCount, + sessionCount: summaryRow.sessionCount, + inputTokens: summaryRow.inputTokens, + outputTokens: summaryRow.outputTokens, + totalTokens: summaryRow.totalTokens, + cachedInputTokens: summaryRow.cachedInputTokens, + cacheHitRate, + estimatedCostUsd: summaryRow.estimatedCostUsd, + mostActiveDay + }, + calendar, + providerBreakdown, + modelBreakdown + } + } + async repairImportedLegacySessionSkills(sessionId: string): Promise { return await this.legacyImportService.repairImportedLegacySessionSkills(sessionId) } @@ -1205,6 +1305,7 @@ export class NewAgentPresenter { totalTokens?: number inputTokens?: number outputTokens?: number + cachedInputTokens?: number generationTime?: number firstTokenTime?: number tokensPerSecond?: number @@ -1218,6 +1319,8 @@ export class NewAgentPresenter { totalTokens: typeof parsed.totalTokens === 'number' ? parsed.totalTokens : undefined, inputTokens: typeof parsed.inputTokens === 'number' ? parsed.inputTokens : undefined, outputTokens: typeof parsed.outputTokens === 'number' ? parsed.outputTokens : undefined, + cachedInputTokens: + typeof parsed.cachedInputTokens === 'number' ? parsed.cachedInputTokens : undefined, generationTime: typeof parsed.generationTime === 'number' ? parsed.generationTime : undefined, firstTokenTime: @@ -1232,6 +1335,139 @@ export class NewAgentPresenter { } } + private async runUsageStatsBackfill(): Promise { + const startedAt = Date.now() + this.setUsageStatsBackfillStatus({ + status: 'running', + startedAt, + finishedAt: null, + error: null, + updatedAt: startedAt + }) + + try { + const usageStatsTable = this.sqlitePresenter.deepchatUsageStatsTable + const candidates = this.sqlitePresenter.deepchatMessagesTable.listAssistantUsageCandidates() + + let processedCount = 0 + for (const row of candidates) { + const metadata = parseUsageMetadata(row.metadata) + if (metadata.messageType === 'compaction') { + continue + } + + const providerId = resolveUsageProviderId(metadata, row.provider_id) + const modelId = resolveUsageModelId(metadata, row.model_id) + if (!providerId || !modelId) { + continue + } + + const usageRecord = buildUsageStatsRecord({ + messageId: row.id, + sessionId: row.session_id, + createdAt: row.created_at, + updatedAt: row.updated_at, + providerId, + modelId, + metadata: { + ...metadata, + cachedInputTokens: 0 + }, + source: 'backfill' + }) + + if (!usageRecord) { + continue + } + + usageStatsTable.upsert(usageRecord) + processedCount += 1 + + if (processedCount % 200 === 0) { + this.setUsageStatsBackfillStatus({ + status: 'running', + startedAt, + finishedAt: null, + error: null, + updatedAt: Date.now() + }) + await this.yieldToEventLoop() + } + } + + this.setUsageStatsBackfillStatus({ + status: 'completed', + startedAt, + finishedAt: Date.now(), + error: null, + updatedAt: Date.now() + }) + } catch (error) { + this.setUsageStatsBackfillStatus({ + status: 'failed', + startedAt, + finishedAt: Date.now(), + error: error instanceof Error ? error.message : String(error), + updatedAt: Date.now() + }) + throw error + } + } + + private getUsageStatsBackfillStatus(): UsageStatsBackfillStatus { + const normalized = this.normalizeUsageStatsBackfillStatus( + this.configPresenter.getSetting(DASHBOARD_STATS_BACKFILL_KEY) + ) + if (normalized.status === 'failed' && normalized.error === 'Usage stats backfill timed out') { + this.configPresenter.setSetting(DASHBOARD_STATS_BACKFILL_KEY, normalized) + } + return normalized + } + + private setUsageStatsBackfillStatus(status: UsageStatsBackfillStatus): void { + this.configPresenter.setSetting(DASHBOARD_STATS_BACKFILL_KEY, status) + } + + private normalizeUsageStatsBackfillStatus(status: unknown): UsageStatsBackfillStatus { + const normalized = normalizeUsageStatsBackfillStatus(status) + if (isUsageBackfillRunningStale(normalized)) { + return { + status: 'failed', + startedAt: normalized.startedAt, + finishedAt: normalized.finishedAt, + error: normalized.error ?? 'Usage stats backfill timed out', + updatedAt: Date.now() + } + } + return normalized + } + + private sortUsageBreakdown(items: UsageDashboardBreakdownItem[]): UsageDashboardBreakdownItem[] { + return [...items].sort((left, right) => { + const leftCost = left.estimatedCostUsd ?? -1 + const rightCost = right.estimatedCostUsd ?? -1 + if (rightCost !== leftCost) { + return rightCost - leftCost + } + if (right.totalTokens !== left.totalTokens) { + return right.totalTokens - left.totalTokens + } + return left.label.localeCompare(right.label) + }) + } + + private toLocalDateKey(timestamp: number): string { + const date = new Date(timestamp) + const year = date.getFullYear() + const month = `${date.getMonth() + 1}`.padStart(2, '0') + const day = `${date.getDate()}`.padStart(2, '0') + return `${year}-${month}-${day}` + } + + private async yieldToEventLoop(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)) + } + private buildTitleMessages( records: ChatMessageRecord[] ): Array<{ role: 'system' | 'user' | 'assistant'; content: string }> { diff --git a/src/main/presenter/sqlitePresenter/index.ts b/src/main/presenter/sqlitePresenter/index.ts index 40eb7e7cb..1de391f34 100644 --- a/src/main/presenter/sqlitePresenter/index.ts +++ b/src/main/presenter/sqlitePresenter/index.ts @@ -19,6 +19,7 @@ import { DeepChatSessionsTable } from './tables/deepchatSessions' import { DeepChatMessagesTable } from './tables/deepchatMessages' import { DeepChatMessageTracesTable } from './tables/deepchatMessageTraces' import { DeepChatMessageSearchResultsTable } from './tables/deepchatMessageSearchResults' +import { DeepChatUsageStatsTable } from './tables/deepchatUsageStats' import { LegacyImportStatusTable } from './tables/legacyImportStatus' /** @@ -41,6 +42,7 @@ export class SQLitePresenter implements ISQLitePresenter { public deepchatMessagesTable!: DeepChatMessagesTable public deepchatMessageTracesTable!: DeepChatMessageTracesTable public deepchatMessageSearchResultsTable!: DeepChatMessageSearchResultsTable + public deepchatUsageStatsTable!: DeepChatUsageStatsTable public legacyImportStatusTable!: LegacyImportStatusTable private currentVersion: number = 0 private dbPath: string @@ -158,6 +160,7 @@ export class SQLitePresenter implements ISQLitePresenter { this.deepchatMessagesTable = new DeepChatMessagesTable(this.db) this.deepchatMessageTracesTable = new DeepChatMessageTracesTable(this.db) this.deepchatMessageSearchResultsTable = new DeepChatMessageSearchResultsTable(this.db) + this.deepchatUsageStatsTable = new DeepChatUsageStatsTable(this.db) this.legacyImportStatusTable = new LegacyImportStatusTable(this.db) // Create only active tables for the new stack. @@ -168,6 +171,7 @@ export class SQLitePresenter implements ISQLitePresenter { this.deepchatMessagesTable.createTable() this.deepchatMessageTracesTable.createTable() this.deepchatMessageSearchResultsTable.createTable() + this.deepchatUsageStatsTable.createTable() this.legacyImportStatusTable.createTable() } @@ -197,6 +201,7 @@ export class SQLitePresenter implements ISQLitePresenter { this.deepchatMessagesTable, this.deepchatMessageTracesTable, this.deepchatMessageSearchResultsTable, + this.deepchatUsageStatsTable, this.legacyImportStatusTable ] @@ -283,6 +288,7 @@ export class SQLitePresenter implements ISQLitePresenter { DELETE FROM deepchat_message_search_results; DELETE FROM deepchat_message_traces; DELETE FROM deepchat_messages; + DELETE FROM deepchat_usage_stats; DELETE FROM deepchat_sessions; DELETE FROM new_sessions; `) diff --git a/src/main/presenter/sqlitePresenter/tables/deepchatMessages.ts b/src/main/presenter/sqlitePresenter/tables/deepchatMessages.ts index 9d5dc7717..46dcb65b9 100644 --- a/src/main/presenter/sqlitePresenter/tables/deepchatMessages.ts +++ b/src/main/presenter/sqlitePresenter/tables/deepchatMessages.ts @@ -15,6 +15,16 @@ export interface DeepChatMessageRow { trace_count?: number } +export interface DeepChatMessageUsageCandidateRow { + id: string + session_id: string + metadata: string + created_at: number + updated_at: number + provider_id: string | null + model_id: string | null +} + export class DeepChatMessagesTable extends BaseTable { constructor(db: Database.Database) { super(db, 'deepchat_messages') @@ -184,6 +194,26 @@ export class DeepChatMessagesTable extends BaseTable { return row.max_seq ?? 0 } + listAssistantUsageCandidates(): DeepChatMessageUsageCandidateRow[] { + return this.db + .prepare( + `SELECT + m.id, + m.session_id, + m.metadata, + m.created_at, + m.updated_at, + s.provider_id, + s.model_id + FROM deepchat_messages m + LEFT JOIN deepchat_sessions s + ON s.id = m.session_id + WHERE m.role = 'assistant' + ORDER BY m.created_at ASC` + ) + .all() as DeepChatMessageUsageCandidateRow[] + } + getLastUserMessageBeforeOrAtOrderSeq( sessionId: string, orderSeq: number diff --git a/src/main/presenter/sqlitePresenter/tables/deepchatUsageStats.ts b/src/main/presenter/sqlitePresenter/tables/deepchatUsageStats.ts new file mode 100644 index 000000000..edada206e --- /dev/null +++ b/src/main/presenter/sqlitePresenter/tables/deepchatUsageStats.ts @@ -0,0 +1,319 @@ +import Database from 'better-sqlite3-multiple-ciphers' +import { BaseTable } from './baseTable' +import type { UsageStatsRecordInput } from '../../usageStats' + +export interface DeepChatUsageStatsRow { + message_id: string + session_id: string + usage_date: string + provider_id: string + model_id: string + input_tokens: number + output_tokens: number + total_tokens: number + cached_input_tokens: number + estimated_cost_usd: number | null + source: 'backfill' | 'live' + created_at: number + updated_at: number +} + +type AggregateRow = { + message_count: number + session_count: number + input_tokens: number | null + output_tokens: number | null + total_tokens: number | null + cached_input_tokens: number | null + estimated_cost_usd: number | null + priced_messages: number +} + +export interface DeepChatUsageStatsSummary { + messageCount: number + sessionCount: number + inputTokens: number + outputTokens: number + totalTokens: number + cachedInputTokens: number + estimatedCostUsd: number | null +} + +export interface DeepChatUsageStatsMostActiveDay { + date: string | null + messageCount: number +} + +export interface DeepChatUsageStatsCalendarRow { + date: string + messageCount: number + inputTokens: number + outputTokens: number + totalTokens: number + cachedInputTokens: number + estimatedCostUsd: number | null +} + +export interface DeepChatUsageStatsBreakdownRow { + id: string + messageCount: number + inputTokens: number + outputTokens: number + totalTokens: number + cachedInputTokens: number + estimatedCostUsd: number | null +} + +function normalizeAggregate(row: AggregateRow | undefined): DeepChatUsageStatsSummary { + return { + messageCount: row?.message_count ?? 0, + sessionCount: row?.session_count ?? 0, + inputTokens: row?.input_tokens ?? 0, + outputTokens: row?.output_tokens ?? 0, + totalTokens: row?.total_tokens ?? 0, + cachedInputTokens: row?.cached_input_tokens ?? 0, + estimatedCostUsd: row && row.priced_messages > 0 ? (row.estimated_cost_usd ?? 0) : null + } +} + +export class DeepChatUsageStatsTable extends BaseTable { + constructor(db: Database.Database) { + super(db, 'deepchat_usage_stats') + } + + getCreateTableSQL(): string { + return ` + CREATE TABLE IF NOT EXISTS deepchat_usage_stats ( + message_id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + usage_date TEXT NOT NULL, + provider_id TEXT NOT NULL, + model_id TEXT NOT NULL, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + total_tokens INTEGER NOT NULL DEFAULT 0, + cached_input_tokens INTEGER NOT NULL DEFAULT 0, + estimated_cost_usd REAL, + source TEXT NOT NULL DEFAULT 'live', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_deepchat_usage_stats_date ON deepchat_usage_stats(usage_date); + CREATE INDEX IF NOT EXISTS idx_deepchat_usage_stats_provider_date ON deepchat_usage_stats(provider_id, usage_date); + CREATE INDEX IF NOT EXISTS idx_deepchat_usage_stats_model_date ON deepchat_usage_stats(model_id, usage_date); + ` + } + + getMigrationSQL(version: number): string | null { + if (version === 17) { + return this.getCreateTableSQL() + } + return null + } + + getLatestVersion(): number { + return 17 + } + + upsert(row: UsageStatsRecordInput): void { + this.db + .prepare( + `INSERT INTO deepchat_usage_stats ( + message_id, + session_id, + usage_date, + provider_id, + model_id, + input_tokens, + output_tokens, + total_tokens, + cached_input_tokens, + estimated_cost_usd, + source, + created_at, + updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(message_id) DO UPDATE SET + session_id = excluded.session_id, + usage_date = excluded.usage_date, + provider_id = excluded.provider_id, + model_id = excluded.model_id, + input_tokens = excluded.input_tokens, + output_tokens = excluded.output_tokens, + total_tokens = excluded.total_tokens, + cached_input_tokens = excluded.cached_input_tokens, + estimated_cost_usd = excluded.estimated_cost_usd, + source = excluded.source, + created_at = excluded.created_at, + updated_at = excluded.updated_at` + ) + .run( + row.messageId, + row.sessionId, + row.usageDate, + row.providerId, + row.modelId, + row.inputTokens, + row.outputTokens, + row.totalTokens, + row.cachedInputTokens, + row.estimatedCostUsd, + row.source, + row.createdAt, + row.updatedAt + ) + } + + getByMessageId(messageId: string): DeepChatUsageStatsRow | undefined { + return this.db + .prepare('SELECT * FROM deepchat_usage_stats WHERE message_id = ?') + .get(messageId) as DeepChatUsageStatsRow | undefined + } + + count(): number { + const row = this.db.prepare('SELECT COUNT(*) AS count FROM deepchat_usage_stats').get() as { + count: number + } + return row.count + } + + deleteAll(): void { + this.db.prepare('DELETE FROM deepchat_usage_stats').run() + } + + getRecordingStartedAt(): number | null { + const row = this.db + .prepare('SELECT MIN(created_at) AS started_at FROM deepchat_usage_stats') + .get() as { started_at: number | null } + return row.started_at ?? null + } + + getSummary(): DeepChatUsageStatsSummary { + const row = this.db + .prepare( + `SELECT + COUNT(*) AS message_count, + COUNT(DISTINCT session_id) AS session_count, + SUM(input_tokens) AS input_tokens, + SUM(output_tokens) AS output_tokens, + SUM(total_tokens) AS total_tokens, + SUM(cached_input_tokens) AS cached_input_tokens, + SUM(COALESCE(estimated_cost_usd, 0)) AS estimated_cost_usd, + COUNT(estimated_cost_usd) AS priced_messages + FROM deepchat_usage_stats` + ) + .get() as AggregateRow | undefined + + return normalizeAggregate(row) + } + + getMostActiveDay(): DeepChatUsageStatsMostActiveDay { + const row = this.db + .prepare( + `SELECT + usage_date AS date, + COUNT(*) AS message_count + FROM deepchat_usage_stats + GROUP BY usage_date + ORDER BY message_count DESC, usage_date ASC + LIMIT 1` + ) + .get() as { date: string | null; message_count: number | null } | undefined + + return { + date: row?.date ?? null, + messageCount: row?.message_count ?? 0 + } + } + + getDailyCalendarRows(dateFrom: string): DeepChatUsageStatsCalendarRow[] { + const rows = this.db + .prepare( + `SELECT + usage_date AS date, + COUNT(*) AS message_count, + SUM(input_tokens) AS input_tokens, + SUM(output_tokens) AS output_tokens, + SUM(total_tokens) AS total_tokens, + SUM(cached_input_tokens) AS cached_input_tokens, + SUM(COALESCE(estimated_cost_usd, 0)) AS estimated_cost_usd, + COUNT(estimated_cost_usd) AS priced_messages + FROM deepchat_usage_stats + WHERE usage_date >= ? + GROUP BY usage_date + ORDER BY usage_date ASC` + ) + .all(dateFrom) as Array + + return rows.map((row) => ({ + date: row.date, + messageCount: row.message_count, + inputTokens: row.input_tokens ?? 0, + outputTokens: row.output_tokens ?? 0, + totalTokens: row.total_tokens ?? 0, + cachedInputTokens: row.cached_input_tokens ?? 0, + estimatedCostUsd: row.priced_messages > 0 ? (row.estimated_cost_usd ?? 0) : null + })) + } + + getProviderBreakdownRows(): DeepChatUsageStatsBreakdownRow[] { + const rows = this.db + .prepare( + `SELECT + provider_id AS id, + COUNT(*) AS message_count, + SUM(input_tokens) AS input_tokens, + SUM(output_tokens) AS output_tokens, + SUM(total_tokens) AS total_tokens, + SUM(cached_input_tokens) AS cached_input_tokens, + SUM(COALESCE(estimated_cost_usd, 0)) AS estimated_cost_usd, + COUNT(estimated_cost_usd) AS priced_messages + FROM deepchat_usage_stats + GROUP BY provider_id` + ) + .all() as Array + + return rows.map((row) => ({ + id: row.id, + messageCount: row.message_count, + inputTokens: row.input_tokens ?? 0, + outputTokens: row.output_tokens ?? 0, + totalTokens: row.total_tokens ?? 0, + cachedInputTokens: row.cached_input_tokens ?? 0, + estimatedCostUsd: row.priced_messages > 0 ? (row.estimated_cost_usd ?? 0) : null + })) + } + + getModelBreakdownRows(limit = 10): DeepChatUsageStatsBreakdownRow[] { + const rows = this.db + .prepare( + `SELECT + model_id AS id, + COUNT(*) AS message_count, + SUM(input_tokens) AS input_tokens, + SUM(output_tokens) AS output_tokens, + SUM(total_tokens) AS total_tokens, + SUM(cached_input_tokens) AS cached_input_tokens, + SUM(COALESCE(estimated_cost_usd, 0)) AS estimated_cost_usd, + COUNT(estimated_cost_usd) AS priced_messages + FROM deepchat_usage_stats + GROUP BY model_id + ORDER BY + CASE WHEN COUNT(estimated_cost_usd) > 0 THEN SUM(COALESCE(estimated_cost_usd, 0)) ELSE -1 END DESC, + SUM(total_tokens) DESC, + model_id ASC + LIMIT ?` + ) + .all(limit) as Array + + return rows.map((row) => ({ + id: row.id, + messageCount: row.message_count, + inputTokens: row.input_tokens ?? 0, + outputTokens: row.output_tokens ?? 0, + totalTokens: row.total_tokens ?? 0, + cachedInputTokens: row.cached_input_tokens ?? 0, + estimatedCostUsd: row.priced_messages > 0 ? (row.estimated_cost_usd ?? 0) : null + })) + } +} diff --git a/src/main/presenter/usageStats.ts b/src/main/presenter/usageStats.ts new file mode 100644 index 000000000..495465737 --- /dev/null +++ b/src/main/presenter/usageStats.ts @@ -0,0 +1,334 @@ +import type { + MessageMetadata, + UsageDashboardCalendarDay, + UsageStatsBackfillStatus +} from '@shared/types/agent-interface' +import type { IConfigPresenter } from '@shared/presenter' +import type { ProviderModel } from '@shared/types/model-db' +import { providerDbLoader } from './configPresenter/providerDbLoader' + +export const DASHBOARD_STATS_BACKFILL_KEY = 'dashboardStatsBackfillV1' +export const DASHBOARD_BACKFILL_STALE_MS = 10 * 60 * 1000 + +export type UsageStatsSource = 'backfill' | 'live' + +export interface UsageStatsRecordInput { + messageId: string + sessionId: string + usageDate: string + providerId: string + modelId: string + inputTokens: number + outputTokens: number + totalTokens: number + cachedInputTokens: number + estimatedCostUsd: number | null + source: UsageStatsSource + createdAt: number + updatedAt: number +} + +export interface UsageCalendarBucket { + date: string + messageCount: number + inputTokens: number + outputTokens: number + totalTokens: number + cachedInputTokens: number + estimatedCostUsd: number | null +} + +function toFiniteNumber(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} + +function toNonNegativeInteger(value: unknown): number | undefined { + const normalized = toFiniteNumber(value) + if (normalized === undefined) { + return undefined + } + return Math.max(0, Math.round(normalized)) +} + +function normalizeTextId(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined + } + const normalized = value.trim() + return normalized.length > 0 ? normalized : undefined +} + +function getCostNumber(model: ProviderModel | undefined, key: string): number | undefined { + const raw = model?.cost?.[key] + if (typeof raw === 'number' && Number.isFinite(raw)) { + return raw + } + if (typeof raw === 'string') { + const parsed = Number(raw) + if (Number.isFinite(parsed)) { + return parsed + } + } + return undefined +} + +function resolvePricedModel(providerId: string, modelId: string): ProviderModel | undefined { + return ( + providerDbLoader.getModel(providerId, modelId) ?? providerDbLoader.getModel('aihubmix', modelId) + ) +} + +function getPercentile(sorted: number[], percentile: number): number { + if (sorted.length === 0) { + return 0 + } + const index = Math.min(sorted.length - 1, Math.floor(percentile * (sorted.length - 1))) + return sorted[index] +} + +export function getDefaultUsageStatsBackfillStatus(): UsageStatsBackfillStatus { + return { + status: 'idle', + startedAt: null, + finishedAt: null, + error: null, + updatedAt: 0 + } +} + +export function normalizeUsageStatsBackfillStatus(value: unknown): UsageStatsBackfillStatus { + if (!value || typeof value !== 'object') { + return getDefaultUsageStatsBackfillStatus() + } + + const input = value as Record + const status = + input.status === 'running' || + input.status === 'completed' || + input.status === 'failed' || + input.status === 'idle' + ? input.status + : 'idle' + + return { + status, + startedAt: toFiniteNumber(input.startedAt) ?? null, + finishedAt: toFiniteNumber(input.finishedAt) ?? null, + error: typeof input.error === 'string' ? input.error : null, + updatedAt: toFiniteNumber(input.updatedAt) ?? 0 + } +} + +export function isUsageBackfillRunningStale( + status: UsageStatsBackfillStatus, + now = Date.now() +): boolean { + return status.status === 'running' && now - status.updatedAt > DASHBOARD_BACKFILL_STALE_MS +} + +export function parseMessageMetadata(raw: string | MessageMetadata): MessageMetadata { + if (typeof raw !== 'string') { + return raw ?? {} + } + + try { + const parsed = JSON.parse(raw) as MessageMetadata + return parsed && typeof parsed === 'object' ? parsed : {} + } catch { + return {} + } +} + +export function hasUsageNumbers(metadata: MessageMetadata): boolean { + return ( + toFiniteNumber(metadata.totalTokens) !== undefined || + toFiniteNumber(metadata.inputTokens) !== undefined || + toFiniteNumber(metadata.outputTokens) !== undefined + ) +} + +export function normalizeUsageCounts(metadata: MessageMetadata): { + inputTokens: number + outputTokens: number + totalTokens: number + cachedInputTokens: number +} { + const inputTokens = toNonNegativeInteger(metadata.inputTokens) ?? 0 + const outputTokens = toNonNegativeInteger(metadata.outputTokens) ?? 0 + const totalTokens = toNonNegativeInteger(metadata.totalTokens) ?? inputTokens + outputTokens + const rawCached = toNonNegativeInteger(metadata.cachedInputTokens) ?? 0 + const cachedInputTokens = inputTokens > 0 ? Math.min(rawCached, inputTokens) : rawCached + + return { + inputTokens, + outputTokens, + totalTokens, + cachedInputTokens + } +} + +export function getLocalDateKey(timestamp: number): string { + const date = new Date(timestamp) + const year = date.getFullYear() + const month = `${date.getMonth() + 1}`.padStart(2, '0') + const day = `${date.getDate()}`.padStart(2, '0') + return `${year}-${month}-${day}` +} + +export function resolveUsageProviderId( + metadata: MessageMetadata, + fallbackProviderId?: string | null +): string | null { + return normalizeTextId(metadata.provider) ?? normalizeTextId(fallbackProviderId) ?? null +} + +export function resolveUsageModelId( + metadata: MessageMetadata, + fallbackModelId?: string | null +): string | null { + return normalizeTextId(metadata.model) ?? normalizeTextId(fallbackModelId) ?? null +} + +export function estimateUsageCostUsd(params: { + providerId: string + modelId: string + inputTokens: number + outputTokens: number + cachedInputTokens: number +}): number | null { + const model = resolvePricedModel(params.providerId, params.modelId) + const inputRate = getCostNumber(model, 'input') + const outputRate = getCostNumber(model, 'output') + + if (inputRate === undefined || outputRate === undefined) { + return null + } + + const cacheReadRate = getCostNumber(model, 'cache_read') + const billableInput = Math.max(params.inputTokens - params.cachedInputTokens, 0) + + return ( + (billableInput * inputRate + + params.outputTokens * outputRate + + params.cachedInputTokens * (cacheReadRate ?? inputRate)) / + 1_000_000 + ) +} + +export function buildUsageStatsRecord(params: { + messageId: string + sessionId: string + createdAt: number + updatedAt: number + providerId: string + modelId: string + metadata: MessageMetadata + source: UsageStatsSource +}): UsageStatsRecordInput | null { + if (!hasUsageNumbers(params.metadata)) { + return null + } + + const usage = normalizeUsageCounts(params.metadata) + const providerId = normalizeTextId(params.providerId) + const modelId = normalizeTextId(params.modelId) + + if (!providerId || !modelId) { + return null + } + + return { + messageId: params.messageId, + sessionId: params.sessionId, + usageDate: getLocalDateKey(params.createdAt), + providerId, + modelId, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + totalTokens: usage.totalTokens, + cachedInputTokens: usage.cachedInputTokens, + estimatedCostUsd: estimateUsageCostUsd({ + providerId, + modelId, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + cachedInputTokens: usage.cachedInputTokens + }), + source: params.source, + createdAt: params.createdAt, + updatedAt: params.updatedAt + } +} + +export function getProviderLabel(configPresenter: IConfigPresenter, providerId: string): string { + const provider = + configPresenter.getProviders().find((item) => item.id === providerId) ?? + configPresenter.getProviderById(providerId) + + if (provider?.name?.trim()) { + return provider.name.trim() + } + + const dbProvider = providerDbLoader.getProvider(providerId) + return dbProvider?.display_name || dbProvider?.name || providerId +} + +export function getModelLabel(providerId: string, modelId: string): string { + const model = + providerDbLoader.getModel(providerId, modelId) ?? providerDbLoader.getModel('aihubmix', modelId) + return model?.display_name || model?.name || modelId +} + +export function buildUsageDashboardCalendar( + buckets: UsageCalendarBucket[], + totalDays = 365 +): UsageDashboardCalendarDay[] { + const today = new Date() + const startDate = new Date(today.getFullYear(), today.getMonth(), today.getDate()) + startDate.setDate(startDate.getDate() - (totalDays - 1)) + + const bucketMap = new Map(buckets.map((item) => [item.date, item])) + const days: UsageDashboardCalendarDay[] = [] + + for (let offset = 0; offset < totalDays; offset += 1) { + const date = new Date(startDate) + date.setDate(startDate.getDate() + offset) + const dateKey = getLocalDateKey(date.getTime()) + const bucket = bucketMap.get(dateKey) + days.push({ + date: dateKey, + messageCount: bucket?.messageCount ?? 0, + inputTokens: bucket?.inputTokens ?? 0, + outputTokens: bucket?.outputTokens ?? 0, + totalTokens: bucket?.totalTokens ?? 0, + cachedInputTokens: bucket?.cachedInputTokens ?? 0, + estimatedCostUsd: bucket?.estimatedCostUsd ?? null, + level: 0 + }) + } + + const nonZeroTotals = days + .map((item) => item.totalTokens) + .filter((value) => value > 0) + .sort((a, b) => a - b) + + const q1 = getPercentile(nonZeroTotals, 0.25) + const q2 = getPercentile(nonZeroTotals, 0.5) + const q3 = getPercentile(nonZeroTotals, 0.75) + + return days.map((item) => { + if (item.totalTokens <= 0) { + return item + } + if (item.totalTokens <= q1) { + return { ...item, level: 1 } + } + if (item.totalTokens <= q2) { + return { ...item, level: 2 } + } + if (item.totalTokens <= q3) { + return { ...item, level: 3 } + } + return { ...item, level: 4 } + }) +} diff --git a/src/renderer/settings/components/DashboardSettings.vue b/src/renderer/settings/components/DashboardSettings.vue new file mode 100644 index 000000000..633b7ec3b --- /dev/null +++ b/src/renderer/settings/components/DashboardSettings.vue @@ -0,0 +1,1249 @@ + + + + + diff --git a/src/renderer/settings/main.ts b/src/renderer/settings/main.ts index 956c08f15..24821b46d 100644 --- a/src/renderer/settings/main.ts +++ b/src/renderer/settings/main.ts @@ -52,6 +52,16 @@ const router = createRouter({ position: 3 } }, + { + path: '/dashboard', + name: 'settings-dashboard', + component: () => import('./components/DashboardSettings.vue'), + meta: { + titleKey: 'routes.settings-dashboard', + icon: 'lucide:layout-dashboard', + position: 3.5 + } + }, { path: '/mcp', name: 'settings-mcp', diff --git a/src/renderer/src/i18n/da-DK/routes.json b/src/renderer/src/i18n/da-DK/routes.json index 4627e8d05..e25f1d544 100644 --- a/src/renderer/src/i18n/da-DK/routes.json +++ b/src/renderer/src/i18n/da-DK/routes.json @@ -15,5 +15,6 @@ "settings-mcp-market": "MCP-marked", "settings-acp": "ACP-agenter", "settings-skills": "Skills", - "settings-notifications-hooks": "Notifikationer og Hooks" + "settings-notifications-hooks": "Hooks", + "settings-dashboard": "Dashboard" } diff --git a/src/renderer/src/i18n/da-DK/settings.json b/src/renderer/src/i18n/da-DK/settings.json index 93d6995c4..e274285e9 100644 --- a/src/renderer/src/i18n/da-DK/settings.json +++ b/src/renderer/src/i18n/da-DK/settings.json @@ -132,6 +132,80 @@ "title": "YoBrowser Sandbox" } }, + "dashboard": { + "badge": "Brugsoverblik", + "title": "Brugsoverblik", + "description": "Vis tokenforbrug, cachetræf og estimerede omkostninger. Token- og omkostningsdata er estimeret ud fra offentligt tilgængelige oplysninger, og nogle udbydere eller ældre beskeder kan mangle data, så tallene er kun vejledende.", + "actions": { + "refresh": "Opdater" + }, + "backfill": { + "runningTitle": "Opdaterer brugsdata", + "runningDescription": "Brugsdata opdateres stadig i baggrunden. De aktuelle resultater kan allerede ses.", + "failedTitle": "Kunne ikke opdatere brugsdata", + "failedDescription": "Opdater siden eller prøv igen senere." + }, + "error": { + "title": "Kunne ikke indlæse oversigten", + "description": "Prøv igen senere." + }, + "empty": { + "title": "Ingen brugsstatistik endnu", + "description": "Når der findes brugsdata, vises tokenforbrug, cachetræf og estimerede omkostninger her.", + "historyNote": "Token- og omkostningsdata er estimeret ud fra offentligt tilgængelige oplysninger. Nogle udbydere eller ældre beskeder kan mangle data, så tallene er kun vejledende." + }, + "summary": { + "totalTokens": "Samlede tokens", + "totalTokensDescription": "Viser hvordan input- og outputtokens fordeler sig i det samlede forbrug.", + "inputTokensLabel": "Input", + "outputTokensLabel": "Output", + "cachedTokens": "Cachelagrede tokens", + "cachedTokensDescription": "Prompt-tokens leveret fra udbyderens cache.", + "cachedTokensCachedLabel": "Cachelagret", + "cachedTokensUncachedLabel": "Ikke cachelagret", + "cacheHitRate": "Cachetræfrate", + "cacheHitRateDescription": "Cachelagrede prompt-tokens divideret med alle inputtokens.", + "estimatedCost": "Estimeret pris", + "estimatedCostDescription": "Anslået pris i USD ud fra udbyderens priser eller AIHubMix som reserve.", + "estimatedCostTrendLabel": "Udvikling de seneste 30 dage", + "estimatedCostTrendEmpty": "Ingen registreret omkostning de seneste 30 dage.", + "recordingStartedAt": "Første registrering", + "recordingStartedAtDescription": "Den tidligste brugsregistrering, der aktuelt findes i denne oversigt.", + "withDeepChatDaysLabel": "Dage sammen", + "withDeepChatDaysValue": "{days} dage", + "withDeepChatDaysSentence": "Du er nu på dag {days} med DeepChat.", + "withDeepChatDaysDescription": "Baseret på din tidligste registrerede brug fra {date}.", + "withDeepChatDaysDescriptionUnavailable": "Der er endnu ingen brugsregistreringer.", + "tokenUsage": "Tokenforbrug", + "nostalgiaLabel": "Gamle minder", + "nostalgiaDaysValue": "{days} dage", + "nostalgiaSessionsValue": "{count} samtaler", + "nostalgiaMessagesValue": "{count} beskeder", + "nostalgiaDaysDetailLabel": "Tid sammen", + "nostalgiaDaysDetail": "Du og DeepChat har tilbragt {days} dage sammen.", + "nostalgiaSessionsDetailLabel": "Samtaler", + "nostalgiaSessionsDetail": "I har haft {count} samtaler sammen.", + "nostalgiaMessagesDetailLabel": "Beskeder", + "nostalgiaMessagesDetail": "I har udvekslet {count} beskeder.", + "nostalgiaMostActiveDayLabel": "Mest aktive dag", + "nostalgiaMostActiveDayDetail": "{date} var din mest aktive dag med {count} beskeder." + }, + "calendar": { + "title": "Bidragskalender", + "description": "Dagligt tokenforbrug de seneste 365 dage.", + "legend": "Fra lav til høj", + "tooltip": "{date}: {tokens} tokens" + }, + "breakdown": { + "providerTitle": "Udbyderrangliste", + "providerDescription": "Sorteret først efter estimeret pris og derefter efter samlede tokens.", + "modelTitle": "Modelrangliste", + "modelDescription": "Top 10 modeller efter estimeret pris og derefter samlede tokens.", + "messages": "{count} beskeder", + "empty": "Der er endnu ingen fordelingsdata." + }, + "unavailable": "Ikke tilgængelig" + }, "model": { "title": "Modelindstillinger", "systemPrompt": { @@ -1291,6 +1365,6 @@ "success": "succes", "testing": "Tester..." }, - "title": "Notifikationer og Hooks" + "title": "Hooks" } } diff --git a/src/renderer/src/i18n/en-US/routes.json b/src/renderer/src/i18n/en-US/routes.json index 22c2af75b..7ec5fe30f 100644 --- a/src/renderer/src/i18n/en-US/routes.json +++ b/src/renderer/src/i18n/en-US/routes.json @@ -15,5 +15,6 @@ "settings-mcp-market": "MCP Market", "settings-acp": "ACP Agents", "settings-skills": "Skills", - "settings-notifications-hooks": "Notifications & Hooks" + "settings-notifications-hooks": "Hooks", + "settings-dashboard": "Dashboard" } diff --git a/src/renderer/src/i18n/en-US/settings.json b/src/renderer/src/i18n/en-US/settings.json index 5b819d752..c3dac0ff6 100644 --- a/src/renderer/src/i18n/en-US/settings.json +++ b/src/renderer/src/i18n/en-US/settings.json @@ -78,7 +78,7 @@ } }, "notificationsHooks": { - "title": "Notifications & Hooks", + "title": "Hooks", "description": "Configure webhook notifications and lifecycle hooks.", "events": { "title": "Events", @@ -186,6 +186,80 @@ "title": "YoBrowser Sandbox" } }, + "dashboard": { + "badge": "Usage Dashboard", + "title": "Usage Dashboard", + "description": "Track token usage, cache hits, and estimated cost. Token and cost figures are estimated from public information, and some providers or earlier messages may be incomplete, so treat these figures as reference only.", + "actions": { + "refresh": "Refresh" + }, + "backfill": { + "runningTitle": "Updating usage data", + "runningDescription": "Usage data is still being updated in the background. Current results are already available.", + "failedTitle": "Failed to update usage data", + "failedDescription": "Refresh this page or try again later." + }, + "error": { + "title": "Failed to load dashboard", + "description": "Please try again later." + }, + "empty": { + "title": "No usage stats yet", + "description": "Token usage, cache hits, and estimated cost will appear here once usage data is available.", + "historyNote": "Token and cost figures are estimated from public information. Some providers or earlier messages may be incomplete, so treat these figures as reference only." + }, + "summary": { + "totalTokens": "Total tokens", + "totalTokensDescription": "Shows how input and output tokens contribute to the total.", + "inputTokensLabel": "Input", + "outputTokensLabel": "Output", + "cachedTokens": "Cached tokens", + "cachedTokensDescription": "Prompt tokens served from provider cache.", + "cachedTokensCachedLabel": "Cached", + "cachedTokensUncachedLabel": "Uncached", + "cacheHitRate": "Cache hit rate", + "cacheHitRateDescription": "Cached prompt tokens divided by total input tokens.", + "estimatedCost": "Estimated cost", + "estimatedCostDescription": "Estimated in USD using provider pricing or AIHubMix fallback pricing.", + "estimatedCostTrendLabel": "Trend over the last 30 days", + "estimatedCostTrendEmpty": "No cost recorded in the last 30 days.", + "recordingStartedAt": "Recording started", + "recordingStartedAtDescription": "The earliest usage record currently stored in this dashboard.", + "withDeepChatDaysLabel": "Days together", + "withDeepChatDaysValue": "{days} days", + "withDeepChatDaysSentence": "You are on day {days} with DeepChat.", + "withDeepChatDaysDescription": "Based on your earliest usage record from {date}.", + "withDeepChatDaysDescriptionUnavailable": "No usage record yet.", + "tokenUsage": "Token usage", + "nostalgiaLabel": "Echoes", + "nostalgiaDaysValue": "{days} days", + "nostalgiaSessionsValue": "{count} sessions", + "nostalgiaMessagesValue": "{count} messages", + "nostalgiaDaysDetailLabel": "Days together", + "nostalgiaDaysDetail": "You and DeepChat have spent {days} days together.", + "nostalgiaSessionsDetailLabel": "Sessions", + "nostalgiaSessionsDetail": "You have shared {count} sessions together.", + "nostalgiaMessagesDetailLabel": "Messages", + "nostalgiaMessagesDetail": "You have exchanged {count} messages.", + "nostalgiaMostActiveDayLabel": "Most active day", + "nostalgiaMostActiveDayDetail": "{date} was your most active day, with {count} messages." + }, + "calendar": { + "title": "Contribution Calendar", + "description": "Daily token usage over the last 365 days.", + "legend": "Less to more", + "tooltip": "{date}: {tokens} tokens" + }, + "breakdown": { + "providerTitle": "Top providers", + "providerDescription": "Ranked by estimated cost, then by total tokens.", + "modelTitle": "Top models", + "modelDescription": "Top 10 models by estimated cost, then by total tokens.", + "messages": "{count} messages", + "empty": "No breakdown data yet." + }, + "unavailable": "N/A" + }, "model": { "title": "Model Settings", "systemPrompt": { diff --git a/src/renderer/src/i18n/fa-IR/routes.json b/src/renderer/src/i18n/fa-IR/routes.json index 7a357ab11..55c4f40b9 100644 --- a/src/renderer/src/i18n/fa-IR/routes.json +++ b/src/renderer/src/i18n/fa-IR/routes.json @@ -15,5 +15,6 @@ "settings-mcp-market": "بازار MCP", "settings-acp": "نماینده ACP", "settings-skills": "Skills", - "settings-notifications-hooks": "اعلان‌ها و هوک‌ها" + "settings-notifications-hooks": "هوک‌ها", + "settings-dashboard": "داشبورد" } diff --git a/src/renderer/src/i18n/fa-IR/settings.json b/src/renderer/src/i18n/fa-IR/settings.json index 43a97c571..be9cd2c59 100644 --- a/src/renderer/src/i18n/fa-IR/settings.json +++ b/src/renderer/src/i18n/fa-IR/settings.json @@ -78,7 +78,7 @@ } }, "notificationsHooks": { - "title": "اعلان‌ها و هوک‌ها", + "title": "هوک‌ها", "description": "پیکربندی اعلان‌های وب‌هوک و هوک‌های چرخهٔ عمر.", "events": { "title": "رویدادها", @@ -186,6 +186,80 @@ "title": "YoBrowser Sandbox" } }, + "dashboard": { + "badge": "داشبورد مصرف", + "title": "داشبورد مصرف", + "description": "مصرف توکن، نرخ اصابت کش و هزینهٔ تخمینی را نمایش می‌دهد. داده‌های توکن و هزینه بر پایهٔ اطلاعات عمومی برآورد می‌شوند و ممکن است برخی ارائه‌دهندگان یا پیام‌های قدیمی‌تر دادهٔ کاملی نداشته باشند؛ این ارقام فقط برای مرجع هستند.", + "actions": { + "refresh": "به‌روزرسانی" + }, + "backfill": { + "runningTitle": "در حال به‌روزرسانی داده‌های مصرف", + "runningDescription": "داده‌های مصرف هنوز در پس‌زمینه در حال به‌روزرسانی هستند. نتایج فعلی از همین حالا قابل مشاهده‌اند.", + "failedTitle": "به‌روزرسانی داده‌های مصرف ناموفق بود", + "failedDescription": "این صفحه را تازه‌سازی کنید یا بعداً دوباره تلاش کنید." + }, + "error": { + "title": "بارگذاری داشبورد ناموفق بود", + "description": "لطفاً بعداً دوباره تلاش کنید." + }, + "empty": { + "title": "هنوز آماری از مصرف وجود ندارد", + "description": "وقتی داده‌های مصرف در دسترس باشند، مصرف توکن، نرخ اصابت کش و هزینهٔ تخمینی در اینجا نمایش داده می‌شود.", + "historyNote": "داده‌های توکن و هزینه بر پایهٔ اطلاعات عمومی برآورد می‌شوند. ممکن است برخی ارائه‌دهندگان یا پیام‌های قدیمی‌تر دادهٔ کاملی نداشته باشند؛ این ارقام فقط برای مرجع هستند." + }, + "summary": { + "totalTokens": "مجموع توکن‌ها", + "totalTokensDescription": "نشان می‌دهد توکن‌های ورودی و خروجی چه سهمی از مجموع دارند.", + "inputTokensLabel": "ورودی", + "outputTokensLabel": "خروجی", + "cachedTokens": "توکن‌های کش‌شده", + "cachedTokensDescription": "توکن‌های پرامپتی که از کش ارائه‌دهنده پاسخ داده شده‌اند.", + "cachedTokensCachedLabel": "کش‌شده", + "cachedTokensUncachedLabel": "بدون کش", + "cacheHitRate": "نرخ اصابت کش", + "cacheHitRateDescription": "نسبت توکن‌های کش‌شدهٔ پرامپت به کل توکن‌های ورودی.", + "estimatedCost": "هزینهٔ تخمینی", + "estimatedCostDescription": "برآورد به دلار آمریکا بر اساس قیمت‌گذاری ارائه‌دهنده یا قیمت جایگزین AIHubMix.", + "estimatedCostTrendLabel": "روند ۳۰ روز گذشته", + "estimatedCostTrendEmpty": "در ۳۰ روز گذشته هیچ هزینه‌ای ثبت نشده است.", + "recordingStartedAt": "شروع ثبت", + "recordingStartedAtDescription": "قدیمی‌ترین رکورد مصرفی که اکنون در این داشبورد نگه‌داری می‌شود.", + "withDeepChatDaysLabel": "روزهای همراهی", + "withDeepChatDaysValue": "{days} روز", + "withDeepChatDaysSentence": "اکنون در روز {days} همراهی با DeepChat هستید.", + "withDeepChatDaysDescription": "بر پایهٔ قدیمی‌ترین رکورد استفادهٔ شما در {date}.", + "withDeepChatDaysDescriptionUnavailable": "هنوز هیچ رکورد استفاده‌ای وجود ندارد.", + "tokenUsage": "مصرف توکن", + "nostalgiaLabel": "یادها", + "nostalgiaDaysValue": "{days} روز", + "nostalgiaSessionsValue": "{count} نشست", + "nostalgiaMessagesValue": "{count} پیام", + "nostalgiaDaysDetailLabel": "مدت همراهی", + "nostalgiaDaysDetail": "شما {days} روز را با DeepChat گذرانده‌اید.", + "nostalgiaSessionsDetailLabel": "نشست‌ها", + "nostalgiaSessionsDetail": "با هم {count} نشست داشته‌اید.", + "nostalgiaMessagesDetailLabel": "پیام‌ها", + "nostalgiaMessagesDetail": "{count} پیام با هم ردوبدل کرده‌اید.", + "nostalgiaMostActiveDayLabel": "فعال‌ترین روز", + "nostalgiaMostActiveDayDetail": "{date} فعال‌ترین روز شما بود و {count} پیام در آن ثبت شد." + }, + "calendar": { + "title": "تقویم مشارکت", + "description": "مصرف روزانهٔ توکن در ۳۶۵ روز گذشته.", + "legend": "کم به زیاد", + "tooltip": "{date}: {tokens} توکن" + }, + "breakdown": { + "providerTitle": "رتبه‌بندی ارائه‌دهنده‌ها", + "providerDescription": "ابتدا بر اساس هزینهٔ تخمینی و سپس بر اساس مجموع توکن‌ها مرتب شده است.", + "modelTitle": "رتبه‌بندی مدل‌ها", + "modelDescription": "۱۰ مدل برتر بر اساس هزینهٔ تخمینی و سپس مجموع توکن‌ها.", + "messages": "{count} پیام", + "empty": "هنوز داده‌ای برای نمایش جزئیات وجود ندارد." + }, + "unavailable": "ناموجود" + }, "model": { "title": "تنظیمات مدل", "systemPrompt": { diff --git a/src/renderer/src/i18n/fr-FR/routes.json b/src/renderer/src/i18n/fr-FR/routes.json index bb8d802a1..0ca3e5845 100644 --- a/src/renderer/src/i18n/fr-FR/routes.json +++ b/src/renderer/src/i18n/fr-FR/routes.json @@ -15,5 +15,6 @@ "settings-mcp-market": "Marché MCP", "settings-acp": "Agent ACP", "settings-skills": "Skills", - "settings-notifications-hooks": "Notifications et hooks" + "settings-notifications-hooks": "Hooks", + "settings-dashboard": "Tableau de bord" } diff --git a/src/renderer/src/i18n/fr-FR/settings.json b/src/renderer/src/i18n/fr-FR/settings.json index 42e9d6b24..b3a1883a9 100644 --- a/src/renderer/src/i18n/fr-FR/settings.json +++ b/src/renderer/src/i18n/fr-FR/settings.json @@ -78,7 +78,7 @@ } }, "notificationsHooks": { - "title": "Notifications et hooks", + "title": "Hooks", "description": "Configurer les notifications webhook et les hooks du cycle de vie.", "events": { "title": "Événements", @@ -186,6 +186,80 @@ "title": "Bac à sable YoBrowser" } }, + "dashboard": { + "badge": "Tableau de bord d'utilisation", + "title": "Tableau de bord d'utilisation", + "description": "Affiche la consommation de tokens, les accès au cache et le coût estimé. Les données de tokens et de coût sont estimées à partir d’informations publiques ; certains fournisseurs ou messages plus anciens peuvent être incomplets, donc ces chiffres sont fournis à titre indicatif.", + "actions": { + "refresh": "Actualiser" + }, + "backfill": { + "runningTitle": "Mise à jour des données d’utilisation", + "runningDescription": "Les données d’utilisation sont encore en cours de mise à jour en arrière-plan. Les résultats actuels sont déjà consultables.", + "failedTitle": "Échec de la mise à jour des données d’utilisation", + "failedDescription": "Actualisez cette page ou réessayez plus tard." + }, + "error": { + "title": "Échec du chargement du tableau de bord", + "description": "Veuillez réessayer plus tard." + }, + "empty": { + "title": "Pas encore de statistiques d'utilisation", + "description": "La consommation de tokens, les accès au cache et le coût estimé apparaîtront ici dès que des données d’utilisation seront disponibles.", + "historyNote": "Les données de tokens et de coût sont estimées à partir d’informations publiques. Certains fournisseurs ou messages plus anciens peuvent être incomplets, donc ces chiffres sont fournis à titre indicatif." + }, + "summary": { + "totalTokens": "Total des tokens", + "totalTokensDescription": "Affiche la part des tokens d’entrée et de sortie dans le total.", + "inputTokensLabel": "Entrée", + "outputTokensLabel": "Sortie", + "cachedTokens": "Tokens en cache", + "cachedTokensDescription": "Tokens de prompt servis depuis le cache du fournisseur.", + "cachedTokensCachedLabel": "En cache", + "cachedTokensUncachedLabel": "Sans cache", + "cacheHitRate": "Taux de cache", + "cacheHitRateDescription": "Tokens de prompt en cache divisés par le total des tokens d'entrée.", + "estimatedCost": "Coût estimé", + "estimatedCostDescription": "Estimation en USD à partir des tarifs du fournisseur ou du tarif de secours AIHubMix.", + "estimatedCostTrendLabel": "Tendance sur les 30 derniers jours", + "estimatedCostTrendEmpty": "Aucun coût enregistré sur les 30 derniers jours.", + "recordingStartedAt": "Début de l'enregistrement", + "recordingStartedAtDescription": "Le plus ancien enregistrement d'utilisation actuellement stocké dans ce tableau de bord.", + "withDeepChatDaysLabel": "Jours ensemble", + "withDeepChatDaysValue": "{days} jours", + "withDeepChatDaysSentence": "Vous en êtes à votre {days}e jour avec DeepChat.", + "withDeepChatDaysDescription": "Calculé à partir de votre premier enregistrement d’utilisation, le {date}.", + "withDeepChatDaysDescriptionUnavailable": "Aucun enregistrement d’utilisation pour le moment.", + "tokenUsage": "Utilisation des tokens", + "nostalgiaLabel": "Souvenirs", + "nostalgiaDaysValue": "{days} jours", + "nostalgiaSessionsValue": "{count} sessions", + "nostalgiaMessagesValue": "{count} messages", + "nostalgiaDaysDetailLabel": "Temps ensemble", + "nostalgiaDaysDetail": "Vous et DeepChat avez passé {days} jours ensemble.", + "nostalgiaSessionsDetailLabel": "Sessions", + "nostalgiaSessionsDetail": "Vous avez partagé {count} sessions ensemble.", + "nostalgiaMessagesDetailLabel": "Messages", + "nostalgiaMessagesDetail": "Vous avez échangé {count} messages.", + "nostalgiaMostActiveDayLabel": "Jour le plus actif", + "nostalgiaMostActiveDayDetail": "{date} a été votre journée la plus active, avec {count} messages." + }, + "calendar": { + "title": "Calendrier de contribution", + "description": "Consommation quotidienne de tokens sur les 365 derniers jours.", + "legend": "De moins à plus", + "tooltip": "{date} : {tokens} tokens" + }, + "breakdown": { + "providerTitle": "Classement des fournisseurs", + "providerDescription": "Triés d’abord par coût estimé, puis par nombre total de tokens.", + "modelTitle": "Classement des modèles", + "modelDescription": "Top 10 des modèles par coût estimé, puis par nombre total de tokens.", + "messages": "{count} messages", + "empty": "Aucune donnée de répartition à afficher pour le moment." + }, + "unavailable": "Indisponible" + }, "model": { "title": "Paramètres du modèle", "systemPrompt": { diff --git a/src/renderer/src/i18n/he-IL/routes.json b/src/renderer/src/i18n/he-IL/routes.json index 9e6190e39..6682fa933 100644 --- a/src/renderer/src/i18n/he-IL/routes.json +++ b/src/renderer/src/i18n/he-IL/routes.json @@ -15,5 +15,6 @@ "settings-mcp-market": "חנות MCP", "settings-acp": "סוכני ACP", "settings-skills": "Skills", - "settings-notifications-hooks": "התראות ו‑Hooks" + "settings-notifications-hooks": "Hooks", + "settings-dashboard": "Dashboard" } diff --git a/src/renderer/src/i18n/he-IL/settings.json b/src/renderer/src/i18n/he-IL/settings.json index 68c8c4df5..f4aeeb057 100644 --- a/src/renderer/src/i18n/he-IL/settings.json +++ b/src/renderer/src/i18n/he-IL/settings.json @@ -78,7 +78,7 @@ } }, "notificationsHooks": { - "title": "התראות ו‑Hooks", + "title": "Hooks", "description": "הגדרת התראות webhook ו‑hooks של מחזור החיים.", "events": { "title": "אירועים", @@ -186,6 +186,80 @@ "title": "ארגז חול של YoBrowser" } }, + "dashboard": { + "badge": "לוח שימוש", + "title": "לוח שימוש", + "description": "מציג שימוש בטוקנים, פגיעות מטמון ועלות משוערת. נתוני הטוקנים והעלות מוערכים על סמך מידע ציבורי, וחלק מהספקים או מההודעות המוקדמות יותר עשויים להיות חסרים, לכן יש להתייחס לנתונים כהערכה בלבד.", + "actions": { + "refresh": "רענון" + }, + "backfill": { + "runningTitle": "מעדכן נתוני שימוש", + "runningDescription": "נתוני השימוש עדיין מתעדכנים ברקע. אפשר כבר לעיין בתוצאות הנוכחיות.", + "failedTitle": "עדכון נתוני השימוש נכשל", + "failedDescription": "רענן את הדף הזה או נסה שוב מאוחר יותר." + }, + "error": { + "title": "טעינת לוח השימוש נכשלה", + "description": "נסה שוב מאוחר יותר." + }, + "empty": { + "title": "עדיין אין נתוני שימוש", + "description": "שימוש בטוקנים, פגיעות מטמון ועלות משוערת יוצגו כאן ברגע שנתוני השימוש יהיו זמינים.", + "historyNote": "נתוני הטוקנים והעלות מוערכים על סמך מידע ציבורי. חלק מהספקים או מההודעות המוקדמות יותר עשויים להיות חסרים, לכן יש להתייחס לנתונים כהערכה בלבד." + }, + "summary": { + "totalTokens": "סך כל הטוקנים", + "totalTokensDescription": "מציג את החלוקה של טוקני הקלט והפלט מתוך הסך הכולל.", + "inputTokensLabel": "קלט", + "outputTokensLabel": "פלט", + "cachedTokens": "טוקנים מהמטמון", + "cachedTokensDescription": "טוקני פרומפט שסופקו ממטמון הספק.", + "cachedTokensCachedLabel": "במטמון", + "cachedTokensUncachedLabel": "ללא מטמון", + "cacheHitRate": "שיעור פגיעות מטמון", + "cacheHitRateDescription": "טוקני פרומפט מהמטמון חלקי כלל טוקני הקלט.", + "estimatedCost": "עלות משוערת", + "estimatedCostDescription": "עלות משוערת בדולרים לפי תמחור הספק או תמחור הגיבוי של AIHubMix.", + "estimatedCostTrendLabel": "מגמה ב־30 הימים האחרונים", + "estimatedCostTrendEmpty": "לא נרשמה עלות ב־30 הימים האחרונים.", + "recordingStartedAt": "תחילת הרישום", + "recordingStartedAtDescription": "רשומת השימוש המוקדמת ביותר השמורה כעת בלוח זה.", + "withDeepChatDaysLabel": "ימים יחד", + "withDeepChatDaysValue": "{days} ימים", + "withDeepChatDaysSentence": "זה היום ה־{days} שלך עם DeepChat.", + "withDeepChatDaysDescription": "מבוסס על רשומת השימוש המוקדמת ביותר שלך מ־{date}.", + "withDeepChatDaysDescriptionUnavailable": "עדיין אין רשומות שימוש.", + "tokenUsage": "שימוש בטוקנים", + "nostalgiaLabel": "זיכרונות", + "nostalgiaDaysValue": "{days} ימים", + "nostalgiaSessionsValue": "{count} שיחות", + "nostalgiaMessagesValue": "{count} הודעות", + "nostalgiaDaysDetailLabel": "הזמן יחד", + "nostalgiaDaysDetail": "אתם ו-DeepChat כבר {days} ימים יחד.", + "nostalgiaSessionsDetailLabel": "שיחות", + "nostalgiaSessionsDetail": "קיימתם יחד {count} שיחות.", + "nostalgiaMessagesDetailLabel": "הודעות", + "nostalgiaMessagesDetail": "החלפתם {count} הודעות.", + "nostalgiaMostActiveDayLabel": "היום הפעיל ביותר", + "nostalgiaMostActiveDayDetail": "{date} היה היום הפעיל ביותר שלך, עם {count} הודעות." + }, + "calendar": { + "title": "לוח תרומות", + "description": "צריכת טוקנים יומית ב־365 הימים האחרונים.", + "legend": "מפחות ליותר", + "tooltip": "{date}: {tokens} טוקנים" + }, + "breakdown": { + "providerTitle": "דירוג ספקים", + "providerDescription": "מדורג קודם לפי עלות משוערת ולאחר מכן לפי סך הטוקנים.", + "modelTitle": "דירוג מודלים", + "modelDescription": "10 המודלים המובילים לפי עלות משוערת ולאחר מכן לפי סך הטוקנים.", + "messages": "{count} הודעות", + "empty": "עדיין אין נתוני פירוט להצגה." + }, + "unavailable": "לא זמין" + }, "model": { "title": "הגדרות מודל", "systemPrompt": { diff --git a/src/renderer/src/i18n/ja-JP/routes.json b/src/renderer/src/i18n/ja-JP/routes.json index 5828db2be..15f0fe67d 100644 --- a/src/renderer/src/i18n/ja-JP/routes.json +++ b/src/renderer/src/i18n/ja-JP/routes.json @@ -15,5 +15,6 @@ "settings-mcp-market": "MCP市場", "settings-acp": "ACPエージェント", "settings-skills": "Skills", - "settings-notifications-hooks": "通知とフック" + "settings-notifications-hooks": "フック", + "settings-dashboard": "Dashboard" } diff --git a/src/renderer/src/i18n/ja-JP/settings.json b/src/renderer/src/i18n/ja-JP/settings.json index 585f771a6..7c3a7c30f 100644 --- a/src/renderer/src/i18n/ja-JP/settings.json +++ b/src/renderer/src/i18n/ja-JP/settings.json @@ -78,7 +78,7 @@ } }, "notificationsHooks": { - "title": "通知とフック", + "title": "フック", "description": "Webhook 通知とライフサイクルフックを設定します。", "events": { "title": "イベント", @@ -186,6 +186,80 @@ "title": "YoBrowser サンドボックス" } }, + "dashboard": { + "badge": "使用ダッシュボード", + "title": "使用ダッシュボード", + "description": "token 消費、キャッシュヒット、推定コストを集計します。token と費用のデータは公開情報をもとに推定しており、一部のプロバイダーや過去のメッセージでは欠損する場合があるため、参考値としてご利用ください。", + "actions": { + "refresh": "更新" + }, + "backfill": { + "runningTitle": "使用データを更新しています", + "runningDescription": "使用データはバックグラウンドで引き続き更新中です。現在の結果は先に確認できます。", + "failedTitle": "使用データの更新に失敗しました", + "failedDescription": "このページを再読み込みするか、しばらくしてから再試行してください。" + }, + "error": { + "title": "ダッシュボードの読み込みに失敗しました", + "description": "しばらくしてからもう一度お試しください。" + }, + "empty": { + "title": "使用統計はまだありません", + "description": "使用データが利用可能になると、token 消費、キャッシュヒット、推定コストがここに表示されます。", + "historyNote": "token と費用のデータは公開情報をもとに推定しています。一部のプロバイダーや過去のメッセージでは欠損する場合があるため、参考値としてご利用ください。" + }, + "summary": { + "totalTokens": "総 token", + "totalTokensDescription": "総 token に占める入力 / 出力 token の構成を表示します。", + "inputTokensLabel": "入力", + "outputTokensLabel": "出力", + "cachedTokens": "キャッシュ token", + "cachedTokensDescription": "provider のキャッシュから返された prompt token。", + "cachedTokensCachedLabel": "キャッシュ", + "cachedTokensUncachedLabel": "未キャッシュ", + "cacheHitRate": "キャッシュヒット率", + "cacheHitRateDescription": "キャッシュされた prompt token を総入力 token で割った割合です。", + "estimatedCost": "推定コスト", + "estimatedCostDescription": "provider の価格、または AIHubMix の代替価格をもとに米ドルで推定したコストです。", + "estimatedCostTrendLabel": "直近30日間の推移", + "estimatedCostTrendEmpty": "直近30日間のコスト記録はありません。", + "recordingStartedAt": "記録開始時点", + "recordingStartedAtDescription": "このダッシュボードに現在保存されている最も古い使用記録です。", + "withDeepChatDaysLabel": "一緒に過ごした日数", + "withDeepChatDaysValue": "{days}日", + "withDeepChatDaysSentence": "DeepChatと過ごして{days}日目です。", + "withDeepChatDaysDescription": "最も古い使用記録({date})をもとに計算しています。", + "withDeepChatDaysDescriptionUnavailable": "まだ使用記録がありません。", + "tokenUsage": "Token 消費", + "nostalgiaLabel": "これまでの記録", + "nostalgiaDaysValue": "{days}日", + "nostalgiaSessionsValue": "{count}件のセッション", + "nostalgiaMessagesValue": "{count}件のメッセージ", + "nostalgiaDaysDetailLabel": "一緒に過ごした日々", + "nostalgiaDaysDetail": "あなたはDeepChatと{days}日を一緒に過ごしました。", + "nostalgiaSessionsDetailLabel": "セッション数", + "nostalgiaSessionsDetail": "一緒に{count}件のセッションを重ねました。", + "nostalgiaMessagesDetailLabel": "メッセージ数", + "nostalgiaMessagesDetail": "{count}件のメッセージをやり取りしました。", + "nostalgiaMostActiveDayLabel": "最もアクティブな日", + "nostalgiaMostActiveDayDetail": "{date}は最も活発な一日で、{count}件のメッセージがありました。" + }, + "calendar": { + "title": "コントリビューションカレンダー", + "description": "直近 365 日の token 消費を日ごとに集計します。", + "legend": "少ない順から多い順", + "tooltip": "{date}: {tokens} token" + }, + "breakdown": { + "providerTitle": "プロバイダー別ランキング", + "providerDescription": "推定コスト順で、同率の場合は総トークン数順です。", + "modelTitle": "モデル別ランキング", + "modelDescription": "推定コスト順、同率時は総トークン数順の上位10モデルです。", + "messages": "{count}件のメッセージ", + "empty": "表示できる内訳データはまだありません。" + }, + "unavailable": "該当なし" + }, "model": { "title": "モデル設定", "systemPrompt": { diff --git a/src/renderer/src/i18n/ko-KR/routes.json b/src/renderer/src/i18n/ko-KR/routes.json index 6a5477c96..08249835f 100644 --- a/src/renderer/src/i18n/ko-KR/routes.json +++ b/src/renderer/src/i18n/ko-KR/routes.json @@ -15,5 +15,6 @@ "settings-mcp-market": "MCP 시장", "settings-acp": "ACP 프록시", "settings-skills": "Skills", - "settings-notifications-hooks": "알림 및 훅" + "settings-notifications-hooks": "훅", + "settings-dashboard": "Dashboard" } diff --git a/src/renderer/src/i18n/ko-KR/settings.json b/src/renderer/src/i18n/ko-KR/settings.json index d8d4df810..5b0174f5c 100644 --- a/src/renderer/src/i18n/ko-KR/settings.json +++ b/src/renderer/src/i18n/ko-KR/settings.json @@ -78,7 +78,7 @@ } }, "notificationsHooks": { - "title": "알림 및 훅", + "title": "훅", "description": "웹훅 알림과 라이프사이클 훅을 설정합니다.", "events": { "title": "이벤트", @@ -186,6 +186,80 @@ "title": "요브라우저 샌드박스" } }, + "dashboard": { + "badge": "사용 대시보드", + "title": "사용 대시보드", + "description": "토큰 사용량, 캐시 적중, 예상 비용을 보여줍니다. 토큰 및 비용 데이터는 공개 정보를 바탕으로 추정되며, 일부 제공업체나 이전 메시지 데이터가 누락될 수 있으므로 참고용으로만 활용해 주세요.", + "actions": { + "refresh": "새로고침" + }, + "backfill": { + "runningTitle": "사용 데이터 업데이트 중", + "runningDescription": "사용 데이터가 아직 백그라운드에서 업데이트되고 있습니다. 현재 집계된 결과는 먼저 확인할 수 있습니다.", + "failedTitle": "사용 데이터 업데이트 실패", + "failedDescription": "이 페이지를 새로고침하거나 나중에 다시 시도해 주세요." + }, + "error": { + "title": "대시보드를 불러오지 못했어요", + "description": "잠시 후 다시 시도해 주세요." + }, + "empty": { + "title": "아직 사용 통계가 없어요", + "description": "사용 데이터가 생기면 토큰 사용량, 캐시 적중, 예상 비용이 여기에 표시됩니다.", + "historyNote": "토큰 및 비용 데이터는 공개 정보를 바탕으로 추정됩니다. 일부 제공업체나 이전 메시지 데이터가 누락될 수 있으므로 참고용으로만 활용해 주세요." + }, + "summary": { + "totalTokens": "총 토큰", + "totalTokensDescription": "총 token 중 입력과 출력 token의 비율을 보여줘요.", + "inputTokensLabel": "입력", + "outputTokensLabel": "출력", + "cachedTokens": "캐시된 토큰", + "cachedTokensDescription": "제공업체 캐시에서 제공된 프롬프트 토큰이에요.", + "cachedTokensCachedLabel": "캐시됨", + "cachedTokensUncachedLabel": "미캐시", + "cacheHitRate": "캐시 적중률", + "cacheHitRateDescription": "캐시된 프롬프트 토큰을 전체 입력 토큰으로 나눈 비율이에요.", + "estimatedCost": "예상 비용", + "estimatedCostDescription": "제공업체 가격 또는 AIHubMix 대체 가격을 기준으로 추정한 USD 비용이에요.", + "estimatedCostTrendLabel": "최근 30일 추이", + "estimatedCostTrendEmpty": "최근 30일 동안 기록된 비용이 없어요.", + "recordingStartedAt": "기록 시작 시점", + "recordingStartedAtDescription": "이 대시보드에 현재 저장된 가장 이른 사용 기록이에요.", + "withDeepChatDaysLabel": "함께한 날", + "withDeepChatDaysValue": "{days}일", + "withDeepChatDaysSentence": "DeepChat와 함께한 지 {days}일째예요.", + "withDeepChatDaysDescription": "가장 이른 사용 기록인 {date}를 기준으로 계산해요.", + "withDeepChatDaysDescriptionUnavailable": "아직 사용 기록이 없어요.", + "tokenUsage": "토큰 사용량", + "nostalgiaLabel": "지난 기록", + "nostalgiaDaysValue": "{days}일", + "nostalgiaSessionsValue": "{count}개 세션", + "nostalgiaMessagesValue": "{count}개 메시지", + "nostalgiaDaysDetailLabel": "함께한 시간", + "nostalgiaDaysDetail": "DeepChat과 함께 {days}일을 보냈어요.", + "nostalgiaSessionsDetailLabel": "세션 수", + "nostalgiaSessionsDetail": "함께한 세션은 총 {count}개예요.", + "nostalgiaMessagesDetailLabel": "메시지 수", + "nostalgiaMessagesDetail": "서로 주고받은 메시지는 {count}개예요.", + "nostalgiaMostActiveDayLabel": "가장 활발했던 날", + "nostalgiaMostActiveDayDetail": "{date}은 가장 활발했던 날로, 메시지가 {count}개 있었어요." + }, + "calendar": { + "title": "기여 캘린더", + "description": "최근 365일의 일별 토큰 사용량이에요.", + "legend": "적음에서 많음", + "tooltip": "{date}: {tokens} 토큰" + }, + "breakdown": { + "providerTitle": "프로바이더별 순위", + "providerDescription": "예상 비용이 높은 순, 같으면 총 토큰 수 순으로 정렬해요.", + "modelTitle": "모델별 순위", + "modelDescription": "예상 비용과 총 토큰 수 기준 상위 10개 모델이에요.", + "messages": "메시지 {count}개", + "empty": "표시할 세부 통계가 아직 없어요." + }, + "unavailable": "해당 없음" + }, "model": { "title": "모델 설정", "systemPrompt": { diff --git a/src/renderer/src/i18n/pt-BR/routes.json b/src/renderer/src/i18n/pt-BR/routes.json index 762f4c572..d42b33d4d 100644 --- a/src/renderer/src/i18n/pt-BR/routes.json +++ b/src/renderer/src/i18n/pt-BR/routes.json @@ -15,5 +15,6 @@ "settings-mcp-market": "Mercado MCP", "settings-acp": "Proxy ACP", "settings-skills": "Skills", - "settings-notifications-hooks": "Notificações e hooks" + "settings-notifications-hooks": "Hooks", + "settings-dashboard": "Dashboard" } diff --git a/src/renderer/src/i18n/pt-BR/settings.json b/src/renderer/src/i18n/pt-BR/settings.json index 7e980b7be..1fc9aa8b7 100644 --- a/src/renderer/src/i18n/pt-BR/settings.json +++ b/src/renderer/src/i18n/pt-BR/settings.json @@ -78,7 +78,7 @@ } }, "notificationsHooks": { - "title": "Notificações e hooks", + "title": "Hooks", "description": "Configure notificações de webhook e hooks de ciclo de vida.", "events": { "title": "Eventos", @@ -186,6 +186,80 @@ "title": "Caixa de areia YoBrowser" } }, + "dashboard": { + "badge": "Painel de uso", + "title": "Painel de uso", + "description": "Mostra o consumo de tokens, os acertos de cache e o custo estimado. Os dados de tokens e custo são estimados com base em informações públicas, e alguns provedores ou mensagens mais antigas podem estar incompletos, então use esses números apenas como referência.", + "actions": { + "refresh": "Atualizar" + }, + "backfill": { + "runningTitle": "Atualizando dados de uso", + "runningDescription": "Os dados de uso ainda estão sendo atualizados em segundo plano. Os resultados atuais já podem ser consultados.", + "failedTitle": "Falha ao atualizar os dados de uso", + "failedDescription": "Atualize esta página ou tente novamente mais tarde." + }, + "error": { + "title": "Falha ao carregar o painel", + "description": "Tente novamente mais tarde." + }, + "empty": { + "title": "Ainda não há estatísticas de uso", + "description": "O consumo de tokens, os acertos de cache e o custo estimado aparecerão aqui assim que houver dados de uso.", + "historyNote": "Os dados de tokens e custo são estimados com base em informações públicas. Alguns provedores ou mensagens mais antigas podem estar incompletos, então use esses números apenas como referência." + }, + "summary": { + "totalTokens": "Total de tokens", + "totalTokensDescription": "Mostra a participação dos tokens de entrada e saída no total.", + "inputTokensLabel": "Entrada", + "outputTokensLabel": "Saída", + "cachedTokens": "Tokens em cache", + "cachedTokensDescription": "Tokens de prompt atendidos pelo cache do provedor.", + "cachedTokensCachedLabel": "Em cache", + "cachedTokensUncachedLabel": "Sem cache", + "cacheHitRate": "Taxa de acerto do cache", + "cacheHitRateDescription": "Tokens de prompt em cache divididos pelo total de tokens de entrada.", + "estimatedCost": "Custo estimado", + "estimatedCostDescription": "Estimado em USD com base no preço do provedor ou no preço de fallback da AIHubMix.", + "estimatedCostTrendLabel": "Tendência dos últimos 30 dias", + "estimatedCostTrendEmpty": "Nenhum custo registrado nos últimos 30 dias.", + "recordingStartedAt": "Início do registro", + "recordingStartedAtDescription": "O registro de uso mais antigo atualmente armazenado neste painel.", + "withDeepChatDaysLabel": "Dias juntos", + "withDeepChatDaysValue": "{days} dias", + "withDeepChatDaysSentence": "Você está no dia {days} com o DeepChat.", + "withDeepChatDaysDescription": "Com base no seu registro de uso mais antigo, em {date}.", + "withDeepChatDaysDescriptionUnavailable": "Ainda não há registros de uso.", + "tokenUsage": "Uso de tokens", + "nostalgiaLabel": "Memórias", + "nostalgiaDaysValue": "{days} dias", + "nostalgiaSessionsValue": "{count} sessões", + "nostalgiaMessagesValue": "{count} mensagens", + "nostalgiaDaysDetailLabel": "Tempo juntos", + "nostalgiaDaysDetail": "Você e o DeepChat já passaram {days} dias juntos.", + "nostalgiaSessionsDetailLabel": "Sessões", + "nostalgiaSessionsDetail": "Vocês compartilharam {count} sessões juntos.", + "nostalgiaMessagesDetailLabel": "Mensagens", + "nostalgiaMessagesDetail": "Vocês trocaram {count} mensagens.", + "nostalgiaMostActiveDayLabel": "Dia mais ativo", + "nostalgiaMostActiveDayDetail": "{date} foi o seu dia mais ativo, com {count} mensagens." + }, + "calendar": { + "title": "Calendário de contribuições", + "description": "Uso diário de tokens nos últimos 365 dias.", + "legend": "De menos para mais", + "tooltip": "{date}: {tokens} tokens" + }, + "breakdown": { + "providerTitle": "Ranking por provedor", + "providerDescription": "Ordenado primeiro por custo estimado e depois por total de tokens.", + "modelTitle": "Ranking por modelo", + "modelDescription": "Top 10 modelos por custo estimado e depois por total de tokens.", + "messages": "{count} mensagens", + "empty": "Ainda não há dados detalhados para exibir." + }, + "unavailable": "Indisponível" + }, "model": { "title": "Configurações do Modelo", "systemPrompt": { diff --git a/src/renderer/src/i18n/ru-RU/routes.json b/src/renderer/src/i18n/ru-RU/routes.json index b10e02fd9..fb1042991 100644 --- a/src/renderer/src/i18n/ru-RU/routes.json +++ b/src/renderer/src/i18n/ru-RU/routes.json @@ -15,5 +15,6 @@ "settings-mcp-market": "MCP Market", "settings-acp": "ACP-агент", "settings-skills": "Skills", - "settings-notifications-hooks": "Уведомления и хуки" + "settings-notifications-hooks": "Хуки", + "settings-dashboard": "Панель" } diff --git a/src/renderer/src/i18n/ru-RU/settings.json b/src/renderer/src/i18n/ru-RU/settings.json index f829169fe..7d5032063 100644 --- a/src/renderer/src/i18n/ru-RU/settings.json +++ b/src/renderer/src/i18n/ru-RU/settings.json @@ -78,7 +78,7 @@ } }, "notificationsHooks": { - "title": "Уведомления и хуки", + "title": "Хуки", "description": "Настройте webhook-уведомления и хуки жизненного цикла.", "events": { "title": "События", @@ -186,6 +186,80 @@ "title": "Песочница YoBrowser" } }, + "dashboard": { + "badge": "Панель использования", + "title": "Панель использования", + "description": "Показывает расход токенов, попадания в кэш и оценочную стоимость. Данные по токенам и стоимости рассчитываются на основе открытой информации; у некоторых провайдеров или более ранних сообщений данные могут отсутствовать, поэтому используйте эти значения только как ориентир.", + "actions": { + "refresh": "Обновить" + }, + "backfill": { + "runningTitle": "Обновление данных об использовании", + "runningDescription": "Данные об использовании всё ещё обновляются в фоновом режиме. Текущие результаты уже доступны для просмотра.", + "failedTitle": "Не удалось обновить данные об использовании", + "failedDescription": "Обновите эту страницу или попробуйте позже." + }, + "error": { + "title": "Не удалось загрузить панель", + "description": "Попробуйте позже." + }, + "empty": { + "title": "Статистики использования пока нет", + "description": "Расход токенов, попадания в кэш и оценочная стоимость появятся здесь, когда будут доступны данные об использовании.", + "historyNote": "Данные по токенам и стоимости рассчитываются на основе открытой информации. У некоторых провайдеров или более ранних сообщений данные могут отсутствовать, поэтому используйте эти значения только как ориентир." + }, + "summary": { + "totalTokens": "Всего токенов", + "totalTokensDescription": "Показывает долю входных и выходных токенов в общем объёме.", + "inputTokensLabel": "Вход", + "outputTokensLabel": "Выход", + "cachedTokens": "Токены из кэша", + "cachedTokensDescription": "Токены промпта, полученные из кэша провайдера.", + "cachedTokensCachedLabel": "Кэш", + "cachedTokensUncachedLabel": "Без кэша", + "cacheHitRate": "Доля попаданий в кэш", + "cacheHitRateDescription": "Токены промпта из кэша, делённые на общее число входных токенов.", + "estimatedCost": "Оценочная стоимость", + "estimatedCostDescription": "Оценка в долларах США по тарифам провайдера или резервным тарифам AIHubMix.", + "estimatedCostTrendLabel": "Тренд за последние 30 дней", + "estimatedCostTrendEmpty": "За последние 30 дней расходов не зафиксировано.", + "recordingStartedAt": "Начало учёта", + "recordingStartedAtDescription": "Самая ранняя запись об использовании, которая сейчас хранится в этой панели.", + "withDeepChatDaysLabel": "Дней вместе", + "withDeepChatDaysValue": "{days} дней", + "withDeepChatDaysSentence": "Вы уже {days}‑й день с DeepChat.", + "withDeepChatDaysDescription": "На основе вашей самой ранней записи об использовании от {date}.", + "withDeepChatDaysDescriptionUnavailable": "Записей об использовании пока нет.", + "tokenUsage": "Расход токенов", + "nostalgiaLabel": "Былое", + "nostalgiaDaysValue": "{days} дн.", + "nostalgiaSessionsValue": "{count} сессий", + "nostalgiaMessagesValue": "{count} сообщений", + "nostalgiaDaysDetailLabel": "Вместе", + "nostalgiaDaysDetail": "Вы и DeepChat уже {days} дней вместе.", + "nostalgiaSessionsDetailLabel": "Сессии", + "nostalgiaSessionsDetail": "Вы провели вместе {count} сессий.", + "nostalgiaMessagesDetailLabel": "Сообщения", + "nostalgiaMessagesDetail": "Вы обменялись {count} сообщениями.", + "nostalgiaMostActiveDayLabel": "Самый активный день", + "nostalgiaMostActiveDayDetail": "{date} был вашим самым активным днём: {count} сообщений." + }, + "calendar": { + "title": "Календарь активности", + "description": "Ежедневный расход токенов за последние 365 дней.", + "legend": "От меньшего к большему", + "tooltip": "{date}: {tokens} токенов" + }, + "breakdown": { + "providerTitle": "Рейтинг провайдеров", + "providerDescription": "Сначала по оценочной стоимости, затем по общему числу токенов.", + "modelTitle": "Рейтинг моделей", + "modelDescription": "Топ-10 моделей по оценочной стоимости, затем по общему числу токенов.", + "messages": "{count} сообщений", + "empty": "Данных для разбивки пока нет." + }, + "unavailable": "Недоступно" + }, "model": { "title": "Настройки модели", "systemPrompt": { diff --git a/src/renderer/src/i18n/zh-CN/routes.json b/src/renderer/src/i18n/zh-CN/routes.json index 56fa89ac8..c68928b79 100644 --- a/src/renderer/src/i18n/zh-CN/routes.json +++ b/src/renderer/src/i18n/zh-CN/routes.json @@ -15,5 +15,6 @@ "settings-mcp-market": "MCP市场", "settings-acp": "ACP Agent", "settings-skills": "Skills设置", - "settings-notifications-hooks": "通知与Hooks" + "settings-notifications-hooks": "Hooks", + "settings-dashboard": "数据看板" } diff --git a/src/renderer/src/i18n/zh-CN/settings.json b/src/renderer/src/i18n/zh-CN/settings.json index 21d9be99c..5ac16f3fb 100644 --- a/src/renderer/src/i18n/zh-CN/settings.json +++ b/src/renderer/src/i18n/zh-CN/settings.json @@ -78,7 +78,7 @@ } }, "notificationsHooks": { - "title": "通知与Hooks", + "title": "Hooks", "description": "配置 webhook 通知与生命周期 hooks。", "events": { "title": "事件", @@ -186,6 +186,80 @@ "clearFailedDescription": "请重试或查看日志获取更多信息。" } }, + "dashboard": { + "badge": "使用看板", + "title": "使用看板", + "description": "统计 token 消耗、缓存命中与估算价格。token 和费用数据基于公开信息估算,部分供应商或较早消息数据可能缺失,仅供参考。", + "actions": { + "refresh": "刷新" + }, + "backfill": { + "runningTitle": "正在更新使用数据", + "runningDescription": "使用数据仍在后台整理,当前结果可以先查看。", + "failedTitle": "使用数据更新失败", + "failedDescription": "请刷新此页面或稍后重试。" + }, + "error": { + "title": "加载看板失败", + "description": "请稍后再试。" + }, + "empty": { + "title": "暂无统计数据", + "description": "有使用数据后,这里会展示 token 消耗、缓存命中和估算价格。", + "historyNote": "token 和费用数据基于公开信息估算,部分供应商或较早消息数据可能缺失,仅供参考。" + }, + "summary": { + "totalTokens": "总 Token", + "totalTokensDescription": "展示输入与输出 token 在总量中的占比。", + "inputTokensLabel": "输入", + "outputTokensLabel": "输出", + "cachedTokens": "缓存 Token", + "cachedTokensDescription": "由 provider 缓存命中的 prompt token。", + "cachedTokensCachedLabel": "缓存命中", + "cachedTokensUncachedLabel": "未命中", + "cacheHitRate": "缓存命中率", + "cacheHitRateDescription": "缓存 prompt token 占总输入 token 的比例。", + "estimatedCost": "估算价格", + "estimatedCostDescription": "按当前 provider 价格或 AIHubMix 回退价格估算的美元成本。", + "estimatedCostTrendLabel": "最近 30 天趋势", + "estimatedCostTrendEmpty": "最近 30 天还没有成本记录。", + "recordingStartedAt": "统计开始时间", + "recordingStartedAtDescription": "当前看板中最早一条 usage 记录的时间。", + "withDeepChatDaysLabel": "陪伴天数", + "withDeepChatDaysValue": "{days} 天", + "withDeepChatDaysSentence": "你已经和 DeepChat 一起度过了 {days} 天", + "withDeepChatDaysDescription": "基于你最早的一条使用记录:{date}。", + "withDeepChatDaysDescriptionUnavailable": "暂时还没有使用记录。", + "tokenUsage": "Token 消耗", + "nostalgiaLabel": "前尘往事", + "nostalgiaDaysValue": "{days} 天", + "nostalgiaSessionsValue": "{count} 个会话", + "nostalgiaMessagesValue": "{count} 条消息", + "nostalgiaDaysDetailLabel": "一起走过", + "nostalgiaDaysDetail": "你已经和 DeepChat 一起度过了 {days} 天。", + "nostalgiaSessionsDetailLabel": "会话总数", + "nostalgiaSessionsDetail": "你们一起进行了 {count} 个会话。", + "nostalgiaMessagesDetailLabel": "消息总数", + "nostalgiaMessagesDetail": "互相发送了 {count} 条消息。", + "nostalgiaMostActiveDayLabel": "最活跃的一天", + "nostalgiaMostActiveDayDetail": "{date}是你最活跃的一天,共有 {count} 条消息。" + }, + "calendar": { + "title": "贡献日历", + "description": "最近 365 天按日统计的 token 消耗。", + "legend": "少到多", + "tooltip": "{date}:{tokens} token" + }, + "breakdown": { + "providerTitle": "提供者排行", + "providerDescription": "先按估算价格排序,再按总 token 排序。", + "modelTitle": "模型排行", + "modelDescription": "按估算价格和总 token 排序的前 10 个模型。", + "messages": "{count} 条消息", + "empty": "暂时还没有可展示的明细数据。" + }, + "unavailable": "暂无" + }, "model": { "title": "模型设置", "systemPrompt": { diff --git a/src/renderer/src/i18n/zh-HK/routes.json b/src/renderer/src/i18n/zh-HK/routes.json index c9f4fd99a..09b325bdd 100644 --- a/src/renderer/src/i18n/zh-HK/routes.json +++ b/src/renderer/src/i18n/zh-HK/routes.json @@ -15,5 +15,6 @@ "playground": "Playground 實驗室", "settings-acp": "ACP Agent", "settings-skills": "Skills設置", - "settings-notifications-hooks": "通知與 Hooks" + "settings-notifications-hooks": "Hooks", + "settings-dashboard": "資料看板" } diff --git a/src/renderer/src/i18n/zh-HK/settings.json b/src/renderer/src/i18n/zh-HK/settings.json index d87a13651..edaa708c8 100644 --- a/src/renderer/src/i18n/zh-HK/settings.json +++ b/src/renderer/src/i18n/zh-HK/settings.json @@ -78,7 +78,7 @@ } }, "notificationsHooks": { - "title": "通知與 Hooks", + "title": "Hooks", "description": "配置 webhook 通知與生命週期 Hooks。", "events": { "title": "事件", @@ -186,6 +186,80 @@ "title": "YoBrowser 沙盒" } }, + "dashboard": { + "badge": "使用看板", + "title": "使用看板", + "description": "統計 token 消耗、緩存命中與估算價格。token 和費用資料基於公開資訊估算,部分供應商或較早訊息資料可能缺失,僅供參考。", + "actions": { + "refresh": "刷新" + }, + "backfill": { + "runningTitle": "正在更新使用數據", + "runningDescription": "使用數據仍在背景整理,當前結果可以先查看。", + "failedTitle": "使用數據更新失敗", + "failedDescription": "請重新整理此頁面或稍後再試。" + }, + "error": { + "title": "載入看板失敗", + "description": "請稍後再試。" + }, + "empty": { + "title": "暫無統計資料", + "description": "有使用數據後,這裡會顯示 token 消耗、緩存命中和估算價格。", + "historyNote": "token 和費用資料基於公開資訊估算,部分供應商或較早訊息資料可能缺失,僅供參考。" + }, + "summary": { + "totalTokens": "總 Token", + "totalTokensDescription": "展示輸入與輸出 token 在總量中的佔比。", + "inputTokensLabel": "輸入", + "outputTokensLabel": "輸出", + "cachedTokens": "緩存 Token", + "cachedTokensDescription": "由 provider 緩存命中的 prompt token。", + "cachedTokensCachedLabel": "緩存命中", + "cachedTokensUncachedLabel": "未命中", + "cacheHitRate": "緩存命中率", + "cacheHitRateDescription": "緩存 prompt token 佔總輸入 token 的比例。", + "estimatedCost": "估算價格", + "estimatedCostDescription": "按目前 provider 價格或 AIHubMix 回退價格估算的美元成本。", + "estimatedCostTrendLabel": "最近 30 天趨勢", + "estimatedCostTrendEmpty": "最近 30 天還沒有成本記錄。", + "recordingStartedAt": "統計開始時間", + "recordingStartedAtDescription": "目前看板中最早一條 usage 記錄的時間。", + "withDeepChatDaysLabel": "陪伴天數", + "withDeepChatDaysValue": "{days} 天", + "withDeepChatDaysSentence": "你已經和 DeepChat 一起度過了 {days} 天", + "withDeepChatDaysDescription": "基於你最早的一條使用記錄:{date}。", + "withDeepChatDaysDescriptionUnavailable": "暫時還沒有使用記錄。", + "tokenUsage": "Token 消耗", + "nostalgiaLabel": "前塵往事", + "nostalgiaDaysValue": "{days} 天", + "nostalgiaSessionsValue": "{count} 個會話", + "nostalgiaMessagesValue": "{count} 條消息", + "nostalgiaDaysDetailLabel": "一起走過", + "nostalgiaDaysDetail": "你已經和 DeepChat 一起度過了 {days} 天。", + "nostalgiaSessionsDetailLabel": "會話總數", + "nostalgiaSessionsDetail": "你們一起進行了 {count} 個會話。", + "nostalgiaMessagesDetailLabel": "消息總數", + "nostalgiaMessagesDetail": "彼此發送了 {count} 條消息。", + "nostalgiaMostActiveDayLabel": "最活躍的一天", + "nostalgiaMostActiveDayDetail": "{date}是你最活躍的一天,共有 {count} 條消息。" + }, + "calendar": { + "title": "Contribution Calendar", + "description": "最近 365 天按日統計的 token 消耗。", + "legend": "少到多", + "tooltip": "{date}:{tokens} token" + }, + "breakdown": { + "providerTitle": "供應商排行", + "providerDescription": "先按估算價格排序,再按總 token 排序。", + "modelTitle": "模型排行", + "modelDescription": "按估算價格和總 token 排序的前 10 個模型。", + "messages": "{count} 條消息", + "empty": "暫時還沒有可展示的明細資料。" + }, + "unavailable": "暫無" + }, "model": { "title": "模型設置", "systemPrompt": { diff --git a/src/renderer/src/i18n/zh-TW/routes.json b/src/renderer/src/i18n/zh-TW/routes.json index 299a039d6..0ae0b961c 100644 --- a/src/renderer/src/i18n/zh-TW/routes.json +++ b/src/renderer/src/i18n/zh-TW/routes.json @@ -15,5 +15,6 @@ "playground": "Playground 實驗室", "settings-acp": "ACP Agent", "settings-skills": "Skills設定", - "settings-notifications-hooks": "通知與 Hooks" + "settings-notifications-hooks": "Hooks", + "settings-dashboard": "資料看板" } diff --git a/src/renderer/src/i18n/zh-TW/settings.json b/src/renderer/src/i18n/zh-TW/settings.json index 634b04395..6ca816637 100644 --- a/src/renderer/src/i18n/zh-TW/settings.json +++ b/src/renderer/src/i18n/zh-TW/settings.json @@ -78,7 +78,7 @@ } }, "notificationsHooks": { - "title": "通知與 Hooks", + "title": "Hooks", "description": "設定 webhook 通知與生命週期 Hooks。", "events": { "title": "事件", @@ -186,6 +186,80 @@ "title": "YoBrowser 沙盒" } }, + "dashboard": { + "badge": "使用看板", + "title": "使用看板", + "description": "統計 token 消耗、快取命中與估算價格。token 和費用資料基於公開資訊估算,部分供應商或較早訊息資料可能缺失,僅供參考。", + "actions": { + "refresh": "重新整理" + }, + "backfill": { + "runningTitle": "正在更新使用資料", + "runningDescription": "使用資料仍在背景整理,當前結果可以先查看。", + "failedTitle": "使用資料更新失敗", + "failedDescription": "請重新整理此頁面或稍後再試。" + }, + "error": { + "title": "載入看板失敗", + "description": "請稍後再試。" + }, + "empty": { + "title": "暫無統計資料", + "description": "有使用資料後,這裡會顯示 token 消耗、快取命中和估算價格。", + "historyNote": "token 和費用資料基於公開資訊估算,部分供應商或較早訊息資料可能缺失,僅供參考。" + }, + "summary": { + "totalTokens": "總 Token", + "totalTokensDescription": "顯示輸入與輸出 token 在總量中的佔比。", + "inputTokensLabel": "輸入", + "outputTokensLabel": "輸出", + "cachedTokens": "快取 Token", + "cachedTokensDescription": "由 provider 快取命中的 prompt token。", + "cachedTokensCachedLabel": "快取命中", + "cachedTokensUncachedLabel": "未命中", + "cacheHitRate": "快取命中率", + "cacheHitRateDescription": "快取 prompt token 佔總輸入 token 的比例。", + "estimatedCost": "估算價格", + "estimatedCostDescription": "按目前 provider 價格或 AIHubMix 回退價格估算的美元成本。", + "estimatedCostTrendLabel": "最近 30 天趨勢", + "estimatedCostTrendEmpty": "最近 30 天還沒有成本記錄。", + "recordingStartedAt": "統計開始時間", + "recordingStartedAtDescription": "目前看板中最早一條 usage 記錄的時間。", + "withDeepChatDaysLabel": "陪伴天數", + "withDeepChatDaysValue": "{days} 天", + "withDeepChatDaysSentence": "你已經和 DeepChat 一起度過了 {days} 天", + "withDeepChatDaysDescription": "基於你最早的一筆使用記錄:{date}。", + "withDeepChatDaysDescriptionUnavailable": "暫時還沒有使用記錄。", + "tokenUsage": "Token 消耗", + "nostalgiaLabel": "前塵往事", + "nostalgiaDaysValue": "{days} 天", + "nostalgiaSessionsValue": "{count} 個會話", + "nostalgiaMessagesValue": "{count} 則訊息", + "nostalgiaDaysDetailLabel": "一起走過", + "nostalgiaDaysDetail": "你已經和 DeepChat 一起度過了 {days} 天。", + "nostalgiaSessionsDetailLabel": "會話總數", + "nostalgiaSessionsDetail": "你們一起進行了 {count} 個會話。", + "nostalgiaMessagesDetailLabel": "訊息總數", + "nostalgiaMessagesDetail": "彼此傳送了 {count} 則訊息。", + "nostalgiaMostActiveDayLabel": "最活躍的一天", + "nostalgiaMostActiveDayDetail": "{date}是你最活躍的一天,共有 {count} 則訊息。" + }, + "calendar": { + "title": "貢獻日曆", + "description": "最近 365 天按日統計的 token 消耗。", + "legend": "少到多", + "tooltip": "{date}:{tokens} token" + }, + "breakdown": { + "providerTitle": "供應商排行", + "providerDescription": "先按估算價格排序,再按總 token 排序。", + "modelTitle": "模型排行", + "modelDescription": "按估算價格和總 token 排序的前 10 個模型。", + "messages": "{count} 則訊息", + "empty": "暫時還沒有可顯示的明細資料。" + }, + "unavailable": "暫無" + }, "model": { "title": "模型設定", "systemPrompt": { diff --git a/src/shadcn/components/ui/chart/ChartContainer.vue b/src/shadcn/components/ui/chart/ChartContainer.vue new file mode 100644 index 000000000..9a74cd8c0 --- /dev/null +++ b/src/shadcn/components/ui/chart/ChartContainer.vue @@ -0,0 +1,59 @@ + + + + + diff --git a/src/shadcn/components/ui/chart/ChartLegendContent.vue b/src/shadcn/components/ui/chart/ChartLegendContent.vue new file mode 100644 index 000000000..2334508c6 --- /dev/null +++ b/src/shadcn/components/ui/chart/ChartLegendContent.vue @@ -0,0 +1,60 @@ + + + diff --git a/src/shadcn/components/ui/chart/ChartStyle.vue b/src/shadcn/components/ui/chart/ChartStyle.vue new file mode 100644 index 000000000..3be6cb64e --- /dev/null +++ b/src/shadcn/components/ui/chart/ChartStyle.vue @@ -0,0 +1,42 @@ + + + diff --git a/src/shadcn/components/ui/chart/ChartTooltipContent.vue b/src/shadcn/components/ui/chart/ChartTooltipContent.vue new file mode 100644 index 000000000..0fe5332d3 --- /dev/null +++ b/src/shadcn/components/ui/chart/ChartTooltipContent.vue @@ -0,0 +1,105 @@ + + + diff --git a/src/shadcn/components/ui/chart/index.ts b/src/shadcn/components/ui/chart/index.ts new file mode 100644 index 000000000..12b806276 --- /dev/null +++ b/src/shadcn/components/ui/chart/index.ts @@ -0,0 +1,29 @@ +import type { Component, Ref } from "vue" +import { createContext } from "reka-ui" + +export { default as ChartContainer } from "./ChartContainer.vue" +export { default as ChartLegendContent } from "./ChartLegendContent.vue" +export { default as ChartTooltipContent } from "./ChartTooltipContent.vue" +export { componentToString } from "./utils" + +// Format: { THEME_NAME: CSS_SELECTOR } +export const THEMES = { light: "", dark: ".dark" } as const + +export type ChartConfig = { + [k in string]: { + label?: string | Component + icon?: string | Component + } & ( + | { color?: string, theme?: never } + | { color?: never, theme: Record } + ) +} + +interface ChartContextProps { + id: string + config: Ref +} + +export const [useChart, provideChartContext] = createContext("Chart") + +export { VisCrosshair as ChartCrosshair, VisTooltip as ChartTooltip } from "@unovis/vue" diff --git a/src/shadcn/components/ui/chart/utils.ts b/src/shadcn/components/ui/chart/utils.ts new file mode 100644 index 000000000..ba40728bc --- /dev/null +++ b/src/shadcn/components/ui/chart/utils.ts @@ -0,0 +1,60 @@ +import type { ChartConfig } from "." +import { isClient } from "@vueuse/core" +import { useId } from "reka-ui" +import { h, render } from "vue" + +// Simple cache using a Map to store serialized object keys +const cache = new Map() + +function stableNormalize(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => stableNormalize(item)) + } + + if (value && typeof value === 'object') { + return Object.keys(value as Record) + .sort() + .reduce>((normalized, key) => { + normalized[key] = stableNormalize((value as Record)[key]) + return normalized + }, {}) + } + + return value +} + +function serializeKey(key: Record): string { + return JSON.stringify(stableNormalize(key)) +} + +interface Constructor

{ + __isFragment?: never + __isTeleport?: never + __isSuspense?: never + new (...args: any[]): { + $props: P + } +} + +export function componentToString

(config: ChartConfig, component: Constructor

, props?: P) { + if (!isClient) + return + + // This function will be called once during mount lifecycle + const id = useId() + + // https://unovis.dev/docs/auxiliary/Crosshair#component-props + return (_data: any, x: number | Date) => { + const data = "data" in _data ? _data.data : _data + const serializedKey = `${id}-${serializeKey(data)}` + const cachedContent = cache.get(serializedKey) + if (cachedContent) + return cachedContent + + const vnode = h(component, { ...props, payload: data, config, x }) + const div = document.createElement("div") + render(vnode, div) + cache.set(serializedKey, div.innerHTML) + return div.innerHTML + } +} diff --git a/src/shared/types/agent-interface.d.ts b/src/shared/types/agent-interface.d.ts index fbdde3549..9a1500e8c 100644 --- a/src/shared/types/agent-interface.d.ts +++ b/src/shared/types/agent-interface.d.ts @@ -232,6 +232,7 @@ export interface MessageMetadata { totalTokens?: number inputTokens?: number outputTokens?: number + cachedInputTokens?: number generationTime?: number firstTokenTime?: number reasoningStartTime?: number @@ -258,6 +259,60 @@ export interface ChatMessageRecord { updatedAt: number } +export interface UsageStatsBackfillStatus { + status: 'idle' | 'running' | 'completed' | 'failed' + startedAt: number | null + finishedAt: number | null + error: string | null + updatedAt: number +} + +export interface UsageDashboardSummary { + messageCount: number + sessionCount: number + inputTokens: number + outputTokens: number + totalTokens: number + cachedInputTokens: number + cacheHitRate: number + estimatedCostUsd: number | null + mostActiveDay: { + date: string | null + messageCount: number + } +} + +export interface UsageDashboardCalendarDay { + date: string + messageCount: number + inputTokens: number + outputTokens: number + totalTokens: number + cachedInputTokens: number + estimatedCostUsd: number | null + level: 0 | 1 | 2 | 3 | 4 +} + +export interface UsageDashboardBreakdownItem { + id: string + label: string + messageCount: number + inputTokens: number + outputTokens: number + totalTokens: number + cachedInputTokens: number + estimatedCostUsd: number | null +} + +export interface UsageDashboardData { + recordingStartedAt: number | null + backfillStatus: UsageStatsBackfillStatus + summary: UsageDashboardSummary + calendar: UsageDashboardCalendarDay[] + providerBreakdown: UsageDashboardBreakdownItem[] + modelBreakdown: UsageDashboardBreakdownItem[] +} + export interface MessageTraceRecord { id: string messageId: string diff --git a/src/shared/types/core/llm-events.ts b/src/shared/types/core/llm-events.ts index 2e49b0199..f1999ab89 100644 --- a/src/shared/types/core/llm-events.ts +++ b/src/shared/types/core/llm-events.ts @@ -57,6 +57,7 @@ export interface UsageStreamEvent { prompt_tokens: number completion_tokens: number total_tokens: number + cached_tokens?: number } } @@ -134,6 +135,7 @@ export const createStreamEvent = { prompt_tokens: number completion_tokens: number total_tokens: number + cached_tokens?: number }): UsageStreamEvent => ({ type: 'usage', usage diff --git a/src/shared/types/presenters/new-agent.presenter.d.ts b/src/shared/types/presenters/new-agent.presenter.d.ts index 23bf2635e..ca93452a5 100644 --- a/src/shared/types/presenters/new-agent.presenter.d.ts +++ b/src/shared/types/presenters/new-agent.presenter.d.ts @@ -10,7 +10,8 @@ import type { LegacyImportStatus, SendMessageInput, ToolInteractionResponse, - ToolInteractionResult + ToolInteractionResult, + UsageDashboardData } from '../agent-interface' import type { SearchResult } from './thread.presenter' @@ -81,4 +82,5 @@ export interface INewAgentPresenter { sessionId: string, settings: Partial ): Promise + getUsageDashboard(): Promise } diff --git a/test/main/presenter/deepchatAgentPresenter/accumulator.test.ts b/test/main/presenter/deepchatAgentPresenter/accumulator.test.ts index 89bc8f987..35bd3b6cf 100644 --- a/test/main/presenter/deepchatAgentPresenter/accumulator.test.ts +++ b/test/main/presenter/deepchatAgentPresenter/accumulator.test.ts @@ -168,12 +168,13 @@ describe('accumulate', () => { it('usage sets metadata', () => { accumulate(state, { type: 'usage', - usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 } + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15, cached_tokens: 3 } }) expect(state.metadata.inputTokens).toBe(10) expect(state.metadata.outputTokens).toBe(5) expect(state.metadata.totalTokens).toBe(15) + expect(state.metadata.cachedInputTokens).toBe(3) }) it('stop sets stopReason', () => { diff --git a/test/main/presenter/deepchatAgentPresenter/messageStore.test.ts b/test/main/presenter/deepchatAgentPresenter/messageStore.test.ts index 91c21846d..81899ba85 100644 --- a/test/main/presenter/deepchatAgentPresenter/messageStore.test.ts +++ b/test/main/presenter/deepchatAgentPresenter/messageStore.test.ts @@ -1,7 +1,13 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { DeepChatMessageStore } from '@/presenter/deepchatAgentPresenter/messageStore' +import logger from '@shared/logger' vi.mock('nanoid', () => ({ nanoid: vi.fn(() => 'mock-msg-id') })) +vi.mock('@shared/logger', () => ({ + default: { + error: vi.fn() + } +})) function createMockSqlitePresenter() { return { @@ -21,6 +27,9 @@ function createMockSqlitePresenter() { deleteFromOrderSeq: vi.fn(), recoverPendingMessages: vi.fn().mockReturnValue(0) }, + deepchatSessionsTable: { + get: vi.fn() + }, deepchatMessageTracesTable: { insert: vi.fn().mockReturnValue(1), listByMessageId: vi.fn().mockReturnValue([]), @@ -33,6 +42,9 @@ function createMockSqlitePresenter() { listByMessageId: vi.fn().mockReturnValue([]), deleteByMessageIds: vi.fn(), deleteBySessionId: vi.fn() + }, + deepchatUsageStatsTable: { + upsert: vi.fn() } } as any } @@ -108,6 +120,80 @@ describe('DeepChatMessageStore', () => { metadata ) }) + + it('persists usage stats for assistant messages with usage metadata', () => { + sqlitePresenter.deepchatMessagesTable.get.mockReturnValue({ + id: 'm1', + session_id: 's1', + role: 'assistant', + created_at: 1000, + updated_at: 2000 + }) + sqlitePresenter.deepchatSessionsTable.get.mockReturnValue({ + provider_id: 'openai', + model_id: 'gpt-4o' + }) + + store.finalizeAssistantMessage( + 'm1', + [], + JSON.stringify({ + inputTokens: 120, + outputTokens: 30, + totalTokens: 150, + cachedInputTokens: 20 + }) + ) + + expect(sqlitePresenter.deepchatUsageStatsTable.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + messageId: 'm1', + sessionId: 's1', + providerId: 'openai', + modelId: 'gpt-4o', + inputTokens: 120, + outputTokens: 30, + totalTokens: 150, + cachedInputTokens: 20, + source: 'live' + }) + ) + }) + + it('swallows usage stats persistence failures and logs them', () => { + sqlitePresenter.deepchatMessagesTable.get.mockReturnValue({ + id: 'm1', + session_id: 's1', + role: 'assistant', + created_at: 1000, + updated_at: 2000 + }) + sqlitePresenter.deepchatSessionsTable.get.mockReturnValue({ + provider_id: 'openai', + model_id: 'gpt-4o' + }) + sqlitePresenter.deepchatUsageStatsTable.upsert.mockImplementation(() => { + throw new Error('boom') + }) + + expect(() => + store.finalizeAssistantMessage( + 'm1', + [], + JSON.stringify({ + inputTokens: 120, + outputTokens: 30, + totalTokens: 150, + cachedInputTokens: 20 + }) + ) + ).not.toThrow() + expect(logger.error).toHaveBeenCalledWith( + 'Failed to persist deepchat usage stats', + { messageId: 'm1', source: 'live' }, + expect.any(Error) + ) + }) }) describe('setMessageError', () => { diff --git a/test/main/presenter/llmProviderPresenter/coreEvents.test.ts b/test/main/presenter/llmProviderPresenter/coreEvents.test.ts index 118a3bf73..3d3df51a4 100644 --- a/test/main/presenter/llmProviderPresenter/coreEvents.test.ts +++ b/test/main/presenter/llmProviderPresenter/coreEvents.test.ts @@ -63,7 +63,8 @@ describe('LLMCoreStreamEvent Factory Functions', () => { const usage = { prompt_tokens: 10, completion_tokens: 20, - total_tokens: 30 + total_tokens: 30, + cached_tokens: 6 } const event = createStreamEvent.usage(usage) diff --git a/test/main/presenter/llmProviderPresenter/openAIResponsesProvider.test.ts b/test/main/presenter/llmProviderPresenter/openAIResponsesProvider.test.ts index e1b38a05c..207d0db17 100644 --- a/test/main/presenter/llmProviderPresenter/openAIResponsesProvider.test.ts +++ b/test/main/presenter/llmProviderPresenter/openAIResponsesProvider.test.ts @@ -172,7 +172,10 @@ describe('OpenAIResponsesProvider tool call id mapping', () => { usage: { input_tokens: 10, output_tokens: 5, - total_tokens: 15 + total_tokens: 15, + input_tokens_details: { + cached_tokens: 4 + } } } } @@ -201,6 +204,7 @@ describe('OpenAIResponsesProvider tool call id mapping', () => { const startEvent = events.find((event) => event.type === 'tool_call_start') const chunkEvent = events.find((event) => event.type === 'tool_call_chunk') const endEvent = events.find((event) => event.type === 'tool_call_end') + const usageEvent = events.find((event) => event.type === 'usage') const stopEvent = events.find((event) => event.type === 'stop') expect(startEvent).toBeDefined() @@ -211,6 +215,12 @@ describe('OpenAIResponsesProvider tool call id mapping', () => { expect(endEvent).toBeDefined() expect(endEvent?.tool_call_id).toBe('call_123') expect(endEvent?.tool_call_arguments_complete).toBe('{"city":"shanghai"}') + expect(usageEvent).toMatchObject({ + type: 'usage', + usage: expect.objectContaining({ + cached_tokens: 4 + }) + }) expect(stopEvent?.stop_reason).toBe('tool_use') expect(mockMcpToolsToOpenAIResponsesTools).toHaveBeenCalledWith(tools, mockProvider.id) }) diff --git a/test/main/presenter/newAgentPresenter/usageDashboard.test.ts b/test/main/presenter/newAgentPresenter/usageDashboard.test.ts new file mode 100644 index 000000000..19dbc58a6 --- /dev/null +++ b/test/main/presenter/newAgentPresenter/usageDashboard.test.ts @@ -0,0 +1,654 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { NewAgentPresenter } from '@/presenter/newAgentPresenter/index' +import { DeepChatMessageStore } from '@/presenter/deepchatAgentPresenter/messageStore' +import { DASHBOARD_STATS_BACKFILL_KEY, type UsageStatsRecordInput } from '@/presenter/usageStats' + +vi.mock('@/eventbus', () => ({ + eventBus: { sendToRenderer: vi.fn(), sendToMain: vi.fn(), on: vi.fn() }, + SendTarget: { ALL_WINDOWS: 'all' } +})) + +vi.mock('@/events', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + SESSION_EVENTS: { + LIST_UPDATED: 'session:list-updated', + ACTIVATED: 'session:activated', + DEACTIVATED: 'session:deactivated', + STATUS_CHANGED: 'session:status-changed', + COMPACTION_UPDATED: 'session:compaction-updated' + } + } +}) + +vi.mock('@/presenter', () => ({ + presenter: { + commandPermissionService: { + extractCommandSignature: vi.fn().mockReturnValue('mock-signature'), + approve: vi.fn(), + clearConversation: vi.fn() + }, + filePermissionService: { approve: vi.fn(), clearConversation: vi.fn() }, + settingsPermissionService: { approve: vi.fn(), clearConversation: vi.fn() }, + mcpPresenter: { + grantPermission: vi.fn().mockResolvedValue(undefined) + } + } +})) + +type SessionRow = { + id: string + provider_id: string + model_id: string +} + +type MessageRow = { + id: string + session_id: string + order_seq: number + role: 'user' | 'assistant' | 'tool' | 'system' + content: string + status: 'pending' | 'sent' | 'error' + metadata: string | null + is_context_edge: number + trace_count: number + created_at: number + updated_at: number +} + +type UsageStatsRow = { + message_id: string + session_id: string + usage_date: string + provider_id: string + model_id: string + input_tokens: number + output_tokens: number + total_tokens: number + cached_input_tokens: number + estimated_cost_usd: number | null + source: 'backfill' | 'live' + created_at: number + updated_at: number +} + +function aggregateUsageRows(rows: UsageStatsRow[]) { + let messageCount = 0 + const sessionIds = new Set() + let inputTokens = 0 + let outputTokens = 0 + let totalTokens = 0 + let cachedInputTokens = 0 + let estimatedCostSum = 0 + let pricedMessages = 0 + + for (const row of rows) { + messageCount += 1 + sessionIds.add(row.session_id) + inputTokens += row.input_tokens + outputTokens += row.output_tokens + totalTokens += row.total_tokens + cachedInputTokens += row.cached_input_tokens + if (typeof row.estimated_cost_usd === 'number') { + estimatedCostSum += row.estimated_cost_usd + pricedMessages += 1 + } + } + + return { + messageCount, + sessionCount: sessionIds.size, + inputTokens, + outputTokens, + totalTokens, + cachedInputTokens, + estimatedCostUsd: pricedMessages > 0 ? estimatedCostSum : null + } +} + +function createMockDeepChatAgent() { + return { + initSession: vi.fn().mockResolvedValue(undefined), + destroySession: vi.fn().mockResolvedValue(undefined), + getSessionState: vi.fn().mockResolvedValue(null), + processMessage: vi.fn().mockResolvedValue(undefined), + cancelGeneration: vi.fn().mockResolvedValue(undefined), + getMessages: vi.fn().mockResolvedValue([]), + getMessageIds: vi.fn().mockResolvedValue([]), + getMessage: vi.fn().mockResolvedValue(null), + getSessionCompactionState: vi.fn().mockResolvedValue({ + status: 'idle', + cursorOrderSeq: 1, + summaryUpdatedAt: null + }) + } +} + +function createMockLlmProviderPresenter() { + return { + summaryTitles: vi.fn().mockResolvedValue('Usage Dashboard'), + generateText: vi.fn().mockResolvedValue({ content: '' }), + prepareAcpSession: vi.fn().mockResolvedValue(undefined), + clearAcpSession: vi.fn().mockResolvedValue(undefined), + getAcpSessionCommands: vi.fn().mockResolvedValue([]) + } +} + +function createMockConfigPresenter() { + const store = new Map() + const providers = [{ id: 'openai', name: 'OpenAI' }] + + return { + getDefaultModel: vi.fn().mockReturnValue({ providerId: 'openai', modelId: 'gpt-4o' }), + getModelConfig: vi.fn().mockReturnValue({}), + getAcpAgents: vi.fn().mockResolvedValue([]), + getProviders: vi.fn().mockReturnValue(providers), + getProviderById: vi.fn((providerId: string) => + providers.find((item) => item.id === providerId) + ), + getSetting: vi.fn((key: string) => store.get(key)), + setSetting: vi.fn((key: string, value: unknown) => { + store.set(key, value) + }), + store + } +} + +function createMockSqlitePresenter() { + const sessions = new Map() + const messages = new Map() + const usageStats = new Map() + + const deepchatSessionsTable = { + create(sessionId: string, providerId: string, modelId: string) { + sessions.set(sessionId, { + id: sessionId, + provider_id: providerId, + model_id: modelId + }) + }, + get(sessionId: string) { + return sessions.get(sessionId) ?? null + } + } + + const deepchatMessagesTable = { + insert(input: { + id: string + sessionId: string + orderSeq: number + role: MessageRow['role'] + content: string + status: MessageRow['status'] + metadata?: string + createdAt?: number + updatedAt?: number + }) { + const now = Date.now() + messages.set(input.id, { + id: input.id, + session_id: input.sessionId, + order_seq: input.orderSeq, + role: input.role, + content: input.content, + status: input.status, + metadata: input.metadata ?? null, + is_context_edge: 0, + trace_count: 0, + created_at: input.createdAt ?? now, + updated_at: input.updatedAt ?? input.createdAt ?? now + }) + }, + get(messageId: string) { + return messages.get(messageId) + }, + updateContentAndStatus( + messageId: string, + content: string, + status: MessageRow['status'], + metadata?: string + ) { + const row = messages.get(messageId) + if (!row) return + row.content = content + row.status = status + if (metadata !== undefined) { + row.metadata = metadata + } + row.updated_at = Date.now() + }, + listAssistantUsageCandidates() { + return Array.from(messages.values()) + .filter((row) => row.role === 'assistant' && typeof row.metadata === 'string') + .map((row) => { + const session = sessions.get(row.session_id) + return { + id: row.id, + session_id: row.session_id, + metadata: row.metadata, + provider_id: session?.provider_id ?? null, + model_id: session?.model_id ?? null, + created_at: row.created_at, + updated_at: row.updated_at + } + }) + } + } + + const deepchatUsageStatsTable = { + upsert(input: UsageStatsRecordInput) { + usageStats.set(input.messageId, { + message_id: input.messageId, + session_id: input.sessionId, + usage_date: input.usageDate, + provider_id: input.providerId, + model_id: input.modelId, + input_tokens: input.inputTokens, + output_tokens: input.outputTokens, + total_tokens: input.totalTokens, + cached_input_tokens: input.cachedInputTokens, + estimated_cost_usd: input.estimatedCostUsd, + source: input.source, + created_at: input.createdAt, + updated_at: input.updatedAt + }) + }, + getByMessageId(messageId: string) { + return usageStats.get(messageId) ?? null + }, + count() { + return usageStats.size + }, + getRecordingStartedAt() { + const rows = Array.from(usageStats.values()) + if (rows.length === 0) { + return null + } + return Math.min(...rows.map((row) => row.created_at)) + }, + getSummary() { + return aggregateUsageRows(Array.from(usageStats.values())) + }, + getMostActiveDay() { + const buckets = new Map() + + for (const row of usageStats.values()) { + buckets.set(row.usage_date, (buckets.get(row.usage_date) ?? 0) + 1) + } + + const rows = Array.from(buckets.entries()) + .map(([date, messageCount]) => ({ date, messageCount })) + .sort((left, right) => { + if (right.messageCount !== left.messageCount) { + return right.messageCount - left.messageCount + } + + return left.date.localeCompare(right.date) + }) + + return rows[0] ?? { date: null, messageCount: 0 } + }, + getDailyCalendarRows(dateFrom: string) { + const buckets = new Map< + string, + { + date: string + messageCount: number + inputTokens: number + outputTokens: number + totalTokens: number + cachedInputTokens: number + estimatedCostUsd: number | null + } + >() + + for (const row of usageStats.values()) { + if (row.usage_date < dateFrom) { + continue + } + + const current = buckets.get(row.usage_date) ?? { + date: row.usage_date, + messageCount: 0, + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + cachedInputTokens: 0, + estimatedCostUsd: null + } + + current.messageCount += 1 + current.inputTokens += row.input_tokens + current.outputTokens += row.output_tokens + current.totalTokens += row.total_tokens + current.cachedInputTokens += row.cached_input_tokens + if (typeof row.estimated_cost_usd === 'number') { + current.estimatedCostUsd = (current.estimatedCostUsd ?? 0) + row.estimated_cost_usd + } + buckets.set(row.usage_date, current) + } + + return Array.from(buckets.values()).sort((left, right) => left.date.localeCompare(right.date)) + }, + getProviderBreakdownRows() { + const buckets = new Map() + for (const row of usageStats.values()) { + const list = buckets.get(row.provider_id) ?? [] + list.push(row) + buckets.set(row.provider_id, list) + } + + return Array.from(buckets.entries()).map(([id, rows]) => ({ + id, + ...aggregateUsageRows(rows) + })) + }, + getModelBreakdownRows(limit: number) { + const buckets = new Map() + for (const row of usageStats.values()) { + const list = buckets.get(row.model_id) ?? [] + list.push(row) + buckets.set(row.model_id, list) + } + + return Array.from(buckets.entries()) + .map(([id, rows]) => ({ + id, + ...aggregateUsageRows(rows) + })) + .slice(0, limit) + } + } + + return { + deepchatSessionsTable, + deepchatMessagesTable, + deepchatUsageStatsTable, + newSessionsTable: { + create: vi.fn(), + get: vi.fn().mockReturnValue(null), + list: vi.fn().mockReturnValue([]), + update: vi.fn(), + delete: vi.fn(), + getDisabledAgentTools: vi.fn().mockReturnValue([]), + updateDisabledAgentTools: vi.fn(), + getActiveSkills: vi.fn().mockReturnValue([]) + }, + legacyImportStatusTable: { + get: vi.fn().mockReturnValue(null), + upsert: vi.fn() + } + } as any +} + +describe('NewAgentPresenter usage dashboard', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + function createPresenter() { + const sqlitePresenter = createMockSqlitePresenter() + const configPresenter = createMockConfigPresenter() + const presenter = new NewAgentPresenter( + createMockDeepChatAgent() as any, + createMockLlmProviderPresenter() as any, + configPresenter as any, + sqlitePresenter + ) + + return { + presenter, + sqlitePresenter, + configPresenter + } + } + + it('backfills current deepchat_messages and uses session provider/model fallback', async () => { + const { presenter, sqlitePresenter, configPresenter } = createPresenter() + + sqlitePresenter.deepchatSessionsTable.create('session-1', 'openai', 'gpt-4o') + sqlitePresenter.deepchatMessagesTable.insert({ + id: 'message-1', + sessionId: 'session-1', + orderSeq: 1, + role: 'assistant', + content: '[]', + status: 'sent', + metadata: JSON.stringify({ + inputTokens: 120, + outputTokens: 80, + totalTokens: 200 + }), + createdAt: Date.UTC(2026, 2, 10, 8, 0, 0), + updatedAt: Date.UTC(2026, 2, 10, 8, 0, 1) + }) + + await presenter.startUsageStatsBackfill() + + const row = sqlitePresenter.deepchatUsageStatsTable.getByMessageId('message-1') + expect(row).toMatchObject({ + message_id: 'message-1', + provider_id: 'openai', + model_id: 'gpt-4o', + cached_input_tokens: 0, + source: 'backfill' + }) + + const status = configPresenter.store.get(DASHBOARD_STATS_BACKFILL_KEY) as { + status: string + finishedAt: number + } + expect(status.status).toBe('completed') + expect(status.finishedAt).toBeGreaterThan(0) + }) + + it('keeps a single stats row when live finalize updates a previously backfilled message', async () => { + const { presenter, sqlitePresenter } = createPresenter() + const messageStore = new DeepChatMessageStore(sqlitePresenter) + + sqlitePresenter.deepchatSessionsTable.create('session-1', 'openai', 'gpt-4o') + sqlitePresenter.deepchatMessagesTable.insert({ + id: 'message-1', + sessionId: 'session-1', + orderSeq: 1, + role: 'assistant', + content: '[]', + status: 'sent', + metadata: JSON.stringify({ + inputTokens: 120, + outputTokens: 80, + totalTokens: 200 + }), + createdAt: Date.UTC(2026, 2, 10, 8, 0, 0), + updatedAt: Date.UTC(2026, 2, 10, 8, 0, 1) + }) + + await presenter.startUsageStatsBackfill() + + messageStore.finalizeAssistantMessage( + 'message-1', + [], + JSON.stringify({ + provider: 'openai', + model: 'gpt-4o', + inputTokens: 140, + outputTokens: 60, + totalTokens: 200, + cachedInputTokens: 20 + }) + ) + + expect(sqlitePresenter.deepchatUsageStatsTable.count()).toBe(1) + const row = sqlitePresenter.deepchatUsageStatsTable.getByMessageId('message-1') + expect(row).toMatchObject({ + source: 'live', + cached_input_tokens: 20, + input_tokens: 140, + output_tokens: 60 + }) + }) + + it('reads dashboard data from deepchat_usage_stats only', async () => { + const { presenter, sqlitePresenter } = createPresenter() + + sqlitePresenter.deepchatSessionsTable.create('session-1', 'openai', 'gpt-4o') + sqlitePresenter.deepchatMessagesTable.insert({ + id: 'message-1', + sessionId: 'session-1', + orderSeq: 1, + role: 'assistant', + content: '[]', + status: 'sent', + metadata: JSON.stringify({ + provider: 'openai', + model: 'gpt-4o', + inputTokens: 100, + outputTokens: 20, + totalTokens: 120 + }), + createdAt: Date.UTC(2026, 2, 10, 8, 0, 0), + updatedAt: Date.UTC(2026, 2, 10, 8, 0, 1) + }) + + const dashboard = await presenter.getUsageDashboard() + + expect(dashboard.summary.messageCount).toBe(0) + expect(dashboard.summary.sessionCount).toBe(0) + expect(dashboard.summary.totalTokens).toBe(0) + expect(dashboard.summary.mostActiveDay).toEqual({ date: null, messageCount: 0 }) + expect(dashboard.providerBreakdown).toEqual([]) + expect(dashboard.calendar).toHaveLength(365) + }) + + it('returns session count and most active day from usage stats summary', async () => { + const { presenter, sqlitePresenter } = createPresenter() + + sqlitePresenter.deepchatUsageStatsTable.upsert({ + messageId: 'message-1', + sessionId: 'session-1', + usageDate: '2026-03-03', + providerId: 'openai', + modelId: 'gpt-4o', + inputTokens: 120, + outputTokens: 80, + totalTokens: 200, + cachedInputTokens: 0, + estimatedCostUsd: 0.01, + source: 'live', + createdAt: Date.UTC(2026, 2, 3, 8, 0, 0), + updatedAt: Date.UTC(2026, 2, 3, 8, 0, 1) + }) + sqlitePresenter.deepchatUsageStatsTable.upsert({ + messageId: 'message-2', + sessionId: 'session-1', + usageDate: '2026-03-03', + providerId: 'openai', + modelId: 'gpt-4o', + inputTokens: 60, + outputTokens: 40, + totalTokens: 100, + cachedInputTokens: 0, + estimatedCostUsd: 0.004, + source: 'live', + createdAt: Date.UTC(2026, 2, 3, 8, 1, 0), + updatedAt: Date.UTC(2026, 2, 3, 8, 1, 1) + }) + sqlitePresenter.deepchatUsageStatsTable.upsert({ + messageId: 'message-3', + sessionId: 'session-2', + usageDate: '2026-03-04', + providerId: 'openai', + modelId: 'gpt-4o', + inputTokens: 30, + outputTokens: 20, + totalTokens: 50, + cachedInputTokens: 0, + estimatedCostUsd: 0.002, + source: 'live', + createdAt: Date.UTC(2026, 2, 4, 8, 0, 0), + updatedAt: Date.UTC(2026, 2, 4, 8, 0, 1) + }) + + const dashboard = await presenter.getUsageDashboard() + + expect(dashboard.summary.messageCount).toBe(3) + expect(dashboard.summary.sessionCount).toBe(2) + expect(dashboard.summary.mostActiveDay).toEqual({ + date: '2026-03-03', + messageCount: 2 + }) + }) + + it('uses the earlier date when the most active day is tied on message count', async () => { + const { presenter, sqlitePresenter } = createPresenter() + + sqlitePresenter.deepchatUsageStatsTable.upsert({ + messageId: 'message-1', + sessionId: 'session-1', + usageDate: '2026-03-05', + providerId: 'openai', + modelId: 'gpt-4o', + inputTokens: 10, + outputTokens: 10, + totalTokens: 20, + cachedInputTokens: 0, + estimatedCostUsd: null, + source: 'live', + createdAt: Date.UTC(2026, 2, 5, 8, 0, 0), + updatedAt: Date.UTC(2026, 2, 5, 8, 0, 1) + }) + sqlitePresenter.deepchatUsageStatsTable.upsert({ + messageId: 'message-2', + sessionId: 'session-1', + usageDate: '2026-03-05', + providerId: 'openai', + modelId: 'gpt-4o', + inputTokens: 10, + outputTokens: 10, + totalTokens: 20, + cachedInputTokens: 0, + estimatedCostUsd: null, + source: 'live', + createdAt: Date.UTC(2026, 2, 5, 8, 1, 0), + updatedAt: Date.UTC(2026, 2, 5, 8, 1, 1) + }) + sqlitePresenter.deepchatUsageStatsTable.upsert({ + messageId: 'message-3', + sessionId: 'session-2', + usageDate: '2026-03-06', + providerId: 'openai', + modelId: 'gpt-4o', + inputTokens: 10, + outputTokens: 10, + totalTokens: 20, + cachedInputTokens: 0, + estimatedCostUsd: null, + source: 'live', + createdAt: Date.UTC(2026, 2, 6, 8, 0, 0), + updatedAt: Date.UTC(2026, 2, 6, 8, 0, 1) + }) + sqlitePresenter.deepchatUsageStatsTable.upsert({ + messageId: 'message-4', + sessionId: 'session-2', + usageDate: '2026-03-06', + providerId: 'openai', + modelId: 'gpt-4o', + inputTokens: 10, + outputTokens: 10, + totalTokens: 20, + cachedInputTokens: 0, + estimatedCostUsd: null, + source: 'live', + createdAt: Date.UTC(2026, 2, 6, 8, 1, 0), + updatedAt: Date.UTC(2026, 2, 6, 8, 1, 1) + }) + + const dashboard = await presenter.getUsageDashboard() + + expect(dashboard.summary.mostActiveDay).toEqual({ + date: '2026-03-05', + messageCount: 2 + }) + }) +}) diff --git a/test/renderer/components/ChartComponents.test.ts b/test/renderer/components/ChartComponents.test.ts new file mode 100644 index 000000000..4c8254485 --- /dev/null +++ b/test/renderer/components/ChartComponents.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it, vi } from 'vitest' +import { defineComponent, h } from 'vue' +import { mount } from '@vue/test-utils' +import { + ChartContainer, + ChartTooltipContent, + componentToString, + useChart +} from '@shadcn/components/ui/chart' + +describe('chart components', () => { + it('uses the computed chart id for both context and slot props', () => { + const config = { + input: { + label: 'Input', + color: '#2563eb' + } + } + + const ChartIdProbe = defineComponent({ + setup() { + const { id } = useChart() + return { id } + }, + template: '

{{ id }}
' + }) + + const wrapper = mount( + defineComponent({ + components: { + ChartContainer, + ChartIdProbe + }, + setup() { + return { config } + }, + template: ` + + + + ` + }) + ) + + expect(wrapper.get('[data-slot="chart"]').attributes('data-chart')).toBe('chart-usage') + expect(wrapper.get('[data-testid="slot-id"]').text()).toBe('chart-usage') + expect(wrapper.get('[data-testid="context-id"]').text()).toBe('chart-usage') + expect(wrapper.html()).toContain('[data-chart="chart-usage"]') + }) + + it('renders zero values inside tooltip content', () => { + const wrapper = mount(ChartTooltipContent, { + props: { + payload: { + input: 0 + }, + config: { + input: { + label: 'Input', + color: '#2563eb' + } + }, + x: new Date('2026-03-17T00:00:00Z') + } + }) + + expect(wrapper.text()).toContain('Input') + expect(wrapper.text()).toContain('0') + }) + + it('stably serializes nested payloads for tooltip caching', () => { + const renderSpy = vi.fn() + let tooltipRenderer: ReturnType | undefined + + const TooltipProbe = defineComponent({ + props: { + payload: { + type: Object, + required: true + } + }, + setup(props) { + renderSpy(props.payload) + return () => h('div', JSON.stringify(props.payload)) + } + }) + + mount( + defineComponent({ + setup() { + tooltipRenderer = componentToString( + { + input: { + label: 'Input', + color: '#2563eb' + } + }, + TooltipProbe as never + ) + + return () => null + } + }) + ) + + const firstPayload = { + nested: { + beta: 2, + alpha: 1 + }, + list: [{ delta: 4, gamma: 3 }] + } + const secondPayload = { + list: [{ gamma: 3, delta: 4 }], + nested: { + alpha: 1, + beta: 2 + } + } + + expect(tooltipRenderer).toBeTypeOf('function') + expect(tooltipRenderer?.(firstPayload, 0)).toBe(tooltipRenderer?.(secondPayload, 0)) + expect(renderSpy).toHaveBeenCalledTimes(1) + }) +}) diff --git a/test/renderer/components/DashboardSettings.test.ts b/test/renderer/components/DashboardSettings.test.ts new file mode 100644 index 000000000..d9dd79b98 --- /dev/null +++ b/test/renderer/components/DashboardSettings.test.ts @@ -0,0 +1,633 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { defineComponent, ref } from 'vue' +import type { PropType } from 'vue' +import { flushPromises, mount } from '@vue/test-utils' +import type { UsageDashboardData } from '@shared/types/agent-interface' + +const passthrough = (name: string) => + defineComponent({ + name, + template: '
' + }) + +const buttonStub = defineComponent({ + name: 'Button', + emits: ['click'], + template: '' +}) + +const chartCrosshairStub = defineComponent({ + name: 'ChartCrosshair', + props: { + template: { + type: Function as PropType< + | (( + datum: unknown, + x: number | Date, + data: unknown[], + leftNearestDatumIndex?: number + ) => HTMLElement | undefined) + | undefined + >, + default: undefined + }, + tooltip: { + type: Object as PropType | undefined>, + default: undefined + }, + hideWhenFarFromPointer: { + type: Boolean, + default: false + }, + data: { + type: Array as PropType, + default: () => [] + }, + x: { + type: Function as PropType<((point: unknown) => number) | undefined>, + default: undefined + }, + y: { + type: Array as PropType number>>, + default: () => [] + } + }, + template: '
' +}) + +const chartTooltipStub = defineComponent({ + name: 'ChartTooltip', + props: { + attributes: { + type: Object as PropType>, + default: () => ({}) + } + }, + setup(props, { expose }) { + expose({ + component: { + attributes: props.attributes + } + }) + + return {} + }, + template: '
' +}) + +const chartTooltipContentStub = defineComponent({ + name: 'ChartTooltipContent', + props: { + payload: { + type: Object as PropType>, + default: () => ({}) + }, + x: { + type: [String, Number, Date] as PropType, + default: undefined + }, + labelFormatter: { + type: Function as PropType<((value: string | number | Date) => string) | undefined>, + default: undefined + } + }, + template: ` +
+
+ {{ labelFormatter && x !== undefined ? labelFormatter(x) : x }} +
+
+ {{ String(key) }}:{{ value }} +
+
+ ` +}) + +function buildDashboard(overrides: Partial = {}): UsageDashboardData { + return { + recordingStartedAt: new Date(2026, 2, 1, 12, 0, 0).getTime(), + backfillStatus: { + status: 'completed', + startedAt: new Date(2026, 2, 1, 12, 0, 0).getTime(), + finishedAt: new Date(2026, 2, 1, 12, 0, 5).getTime(), + error: null, + updatedAt: new Date(2026, 2, 1, 12, 0, 5).getTime() + }, + summary: { + messageCount: 2, + sessionCount: 3, + inputTokens: 800, + outputTokens: 400, + totalTokens: 1200, + cachedInputTokens: 200, + cacheHitRate: 0.25, + estimatedCostUsd: 0.0123, + mostActiveDay: { + date: '2026-03-09', + messageCount: 2 + } + }, + calendar: Array.from({ length: 28 }, (_, index) => ({ + date: `2026-03-${`${index + 1}`.padStart(2, '0')}`, + messageCount: index % 4 === 0 ? 1 : 0, + inputTokens: index % 4 === 0 ? 40 : 0, + outputTokens: index % 4 === 0 ? 20 : 0, + totalTokens: index % 4 === 0 ? 60 : 0, + cachedInputTokens: index % 8 === 0 ? 10 : 0, + estimatedCostUsd: index % 4 === 0 ? 0.0006 : null, + level: index % 4 === 0 ? 3 : 0 + })), + providerBreakdown: [ + { + id: 'openai', + label: 'OpenAI', + messageCount: 2, + inputTokens: 800, + outputTokens: 400, + totalTokens: 1200, + cachedInputTokens: 200, + estimatedCostUsd: 0.0123 + } + ], + modelBreakdown: [ + { + id: 'gpt-4o', + label: 'GPT-4o', + messageCount: 2, + inputTokens: 800, + outputTokens: 400, + totalTokens: 1200, + cachedInputTokens: 200, + estimatedCostUsd: 0.0123 + } + ], + ...overrides + } +} + +async function setup( + data: UsageDashboardData, + options: { + getUsageDashboard?: ReturnType + } = {} +) { + vi.resetModules() + const getUsageDashboard = options.getUsageDashboard ?? vi.fn().mockResolvedValue(data) + + vi.doMock('@/composables/usePresenter', () => ({ + usePresenter: () => ({ + getUsageDashboard + }) + })) + + vi.doMock('@shadcn/components/ui/chart', () => ({ + ChartContainer: passthrough('ChartContainer'), + ChartCrosshair: chartCrosshairStub, + ChartTooltip: chartTooltipStub, + ChartTooltipContent: chartTooltipContentStub + })) + + vi.doMock('@unovis/vue', () => ({ + VisSingleContainer: passthrough('VisSingleContainer'), + VisXYContainer: passthrough('VisXYContainer'), + VisDonut: passthrough('VisDonut'), + VisArea: passthrough('VisArea'), + VisStackedBar: passthrough('VisStackedBar') + })) + + vi.doMock('vue-i18n', () => ({ + useI18n: () => ({ + locale: ref('en-US'), + t: (key: string, params?: Record) => { + if (key === 'settings.dashboard.unavailable') return 'N/A' + if (key === 'settings.dashboard.breakdown.messages') { + return `${params?.count ?? 0} messages` + } + if (key === 'settings.dashboard.summary.cachedTokensCachedLabel') { + return 'Cached' + } + if (key === 'settings.dashboard.summary.cachedTokensUncachedLabel') { + return 'Uncached' + } + if (key === 'settings.dashboard.summary.inputTokensLabel') { + return 'Input' + } + if (key === 'settings.dashboard.summary.outputTokensLabel') { + return 'Output' + } + if (key === 'settings.dashboard.summary.tokenUsage') { + return 'Token usage' + } + if (key === 'settings.dashboard.summary.estimatedCostTrendLabel') { + return 'Trend over the last 30 days' + } + if (key === 'settings.dashboard.summary.estimatedCostTrendEmpty') { + return 'No cost recorded in the last 30 days.' + } + if (key === 'settings.dashboard.summary.nostalgiaLabel') { + return 'Echoes' + } + if (key === 'settings.dashboard.summary.nostalgiaDaysValue') { + return `${params?.days ?? '0'} days` + } + if (key === 'settings.dashboard.summary.nostalgiaSessionsValue') { + return `${params?.count ?? '0'} sessions` + } + if (key === 'settings.dashboard.summary.nostalgiaMessagesValue') { + return `${params?.count ?? '0'} messages` + } + if (key === 'settings.dashboard.summary.nostalgiaDaysDetailLabel') { + return 'Days together' + } + if (key === 'settings.dashboard.summary.nostalgiaDaysDetail') { + return `You and DeepChat have spent ${params?.days ?? '0'} days together.` + } + if (key === 'settings.dashboard.summary.nostalgiaSessionsDetailLabel') { + return 'Sessions' + } + if (key === 'settings.dashboard.summary.nostalgiaSessionsDetail') { + return `You have shared ${params?.count ?? '0'} sessions together.` + } + if (key === 'settings.dashboard.summary.nostalgiaMessagesDetailLabel') { + return 'Messages' + } + if (key === 'settings.dashboard.summary.nostalgiaMessagesDetail') { + return `You have exchanged ${params?.count ?? '0'} messages.` + } + if (key === 'settings.dashboard.summary.nostalgiaMostActiveDayLabel') { + return 'Most active day' + } + if (key === 'settings.dashboard.summary.nostalgiaMostActiveDayDetail') { + return `${params?.date ?? 'unknown'} was your most active day, with ${params?.count ?? '0'} messages.` + } + if (key === 'settings.dashboard.calendar.tooltip') { + return `${params?.date}: ${params?.tokens}` + } + return key + } + }) + })) + + const DashboardSettings = ( + await import('../../../src/renderer/settings/components/DashboardSettings.vue') + ).default + + const wrapper = mount(DashboardSettings, { + global: { + stubs: { + ScrollArea: passthrough('ScrollArea'), + Button: buttonStub, + Badge: passthrough('Badge'), + Card: passthrough('Card'), + CardContent: passthrough('CardContent'), + CardDescription: passthrough('CardDescription'), + CardHeader: passthrough('CardHeader'), + CardTitle: passthrough('CardTitle'), + Icon: defineComponent({ name: 'Icon', template: '' }) + } + } + }) + + await flushPromises() + + return { + wrapper, + getUsageDashboard + } +} + +describe('DashboardSettings', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + vi.setSystemTime(new Date(2026, 2, 17, 12, 0, 0)) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('renders the empty state when no stats are available', async () => { + const { wrapper } = await setup( + buildDashboard({ + summary: { + messageCount: 0, + sessionCount: 0, + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + cachedInputTokens: 0, + cacheHitRate: 0, + estimatedCostUsd: null, + mostActiveDay: { + date: null, + messageCount: 0 + } + }, + providerBreakdown: [], + modelBreakdown: [] + }) + ) + + expect(wrapper.find('[data-testid="dashboard-empty"]').exists()).toBe(true) + }) + + it('renders the backfill banner while historical stats are initializing', async () => { + const { wrapper } = await setup( + buildDashboard({ + backfillStatus: { + status: 'running', + startedAt: new Date(2026, 2, 1, 12, 0, 0).getTime(), + finishedAt: null, + error: null, + updatedAt: new Date(2026, 2, 1, 12, 0, 5).getTime() + } + }) + ) + + expect(wrapper.find('[data-testid="dashboard-backfill-banner"]').exists()).toBe(true) + }) + + it('renders summary cards and breakdown rows when stats exist', async () => { + const { wrapper, getUsageDashboard } = await setup(buildDashboard()) + const summaryCards = wrapper.findAll('[data-testid^="summary-card-"]') + + expect(getUsageDashboard).toHaveBeenCalledTimes(1) + expect(wrapper.text()).toContain('OpenAI') + expect(wrapper.text()).toContain('GPT-4o') + expect(wrapper.text()).toContain('1.2k') + expect(wrapper.text()).toContain('Input') + expect(wrapper.text()).toContain('Output') + expect(wrapper.text()).toContain('66.7%') + expect(wrapper.text()).toContain('33.3%') + expect(wrapper.text()).toContain('Cached') + expect(wrapper.text()).toContain('25%') + expect(wrapper.text()).toContain('17 days') + expect(wrapper.text()).toContain('You and DeepChat have spent 17 days together.') + expect(wrapper.text()).toContain('You have shared 3 sessions together.') + expect(wrapper.text()).toContain('You have exchanged 2 messages.') + expect(wrapper.text()).toContain('Mar 9, 2026 was your most active day, with 2 messages.') + expect(wrapper.text()).not.toContain('settings.dashboard.summary.cacheHitRate') + expect(summaryCards).toHaveLength(2) + expect(wrapper.find('[data-testid="summary-card-tokenUsage"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="summary-card-nostalgia"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="token-usage-trend-chart"]').exists()).toBe(true) + expect(wrapper.findComponent({ name: 'ChartTooltip' }).exists()).toBe(true) + expect(wrapper.findComponent({ name: 'ChartCrosshair' }).exists()).toBe(true) + expect(wrapper.find('[data-testid="token-usage-input-dot"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="token-usage-input-dot"]').attributes('style')).toContain( + 'var(--primary-600)' + ) + expect(wrapper.find('[data-testid="token-usage-output-dot"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="token-usage-cached-dot"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="token-usage-cost-dot"]').exists()).toBe(true) + expect(wrapper.findAllComponents({ name: 'VisArea' })).toHaveLength(4) + expect(wrapper.find('[data-testid="token-usage-total-row"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="token-usage-cost-row"]').text()).toContain( + 'Trend over the last 30 days' + ) + expect(wrapper.find('[data-testid="token-usage-list"]').text()).not.toContain('Uncached') + expect(wrapper.find('[data-testid="cached-tokens-bar"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="provider-breakdown-chart"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="model-breakdown-chart"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="provider-breakdown-scroll"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="model-breakdown-scroll"]').exists()).toBe(true) + expect(wrapper.find('[title="1,200"]').exists()).toBe(true) + expect(wrapper.findAll('[data-testid="calendar-cell"]').length).toBeGreaterThan(0) + expect(wrapper.find('[data-testid="summary-card-nostalgia"]').html()).toContain( + 'whitespace-normal' + ) + expect(wrapper.find('[data-testid="summary-card-nostalgia"]').html()).toContain('md:col-span-2') + expect(wrapper.find('[data-testid="summary-card-nostalgia"]').html()).toContain( + 'md:grid-cols-[minmax(0,14rem)_minmax(0,1fr)]' + ) + expect(wrapper.find('[data-testid="nostalgia-details"]').html()).toContain('space-y-2') + expect(wrapper.find('[data-testid="nostalgia-rotating-value"]').text()).toBe('17 days') + + await vi.advanceTimersByTimeAsync(4000) + expect(wrapper.find('[data-testid="nostalgia-rotating-value"]').text()).toBe('3 sessions') + + await vi.advanceTimersByTimeAsync(4000) + expect(wrapper.find('[data-testid="nostalgia-rotating-value"]').text()).toBe('2 messages') + }) + + it('renders token usage tooltip content with raw values for all series', async () => { + const { wrapper } = await setup( + buildDashboard({ + calendar: [ + { + date: '2026-03-01', + messageCount: 1, + inputTokens: 50, + outputTokens: 20, + totalTokens: 70, + cachedInputTokens: 10, + estimatedCostUsd: 0.0012, + level: 1 + }, + { + date: '2026-03-02', + messageCount: 1, + inputTokens: 25, + outputTokens: 5, + totalTokens: 30, + cachedInputTokens: 4, + estimatedCostUsd: 0.0007, + level: 1 + } + ] + }) + ) + + const crosshair = wrapper.getComponent({ name: 'ChartCrosshair' }) + const template = crosshair.props('template') as ( + datum: { + index: number + date: string + inputTokens: number + outputTokens: number + cachedTokens: number + cost: number + inputValue: number + outputValue: number + cachedValue: number + costValue: number + }, + x: number | Date, + data: unknown[], + leftNearestDatumIndex?: number + ) => HTMLElement | undefined + + const tooltip = template( + { + index: 1, + date: '2026-03-02', + inputTokens: 25, + outputTokens: 5, + cachedTokens: 4, + cost: 0.0007, + inputValue: 50, + outputValue: 25, + cachedValue: 40, + costValue: 58.3 + }, + 1, + [], + 1 + ) + + expect(tooltip).toBeInstanceOf(HTMLElement) + expect(tooltip?.textContent).toContain('Mar 2, 2026') + expect(tooltip?.textContent).toContain('input:25') + expect(tooltip?.textContent).toContain('output:5') + expect(tooltip?.textContent).toContain('cached:4') + expect(tooltip?.textContent).toContain('cost:$0.0007') + expect(tooltip?.textContent).not.toContain('input:50') + }) + + it('renders an empty trend summary with 0% ratios when total tokens are zero', async () => { + const { wrapper } = await setup( + buildDashboard({ + summary: { + messageCount: 1, + sessionCount: 1, + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + cachedInputTokens: 0, + cacheHitRate: 0, + estimatedCostUsd: null, + mostActiveDay: { + date: null, + messageCount: 0 + } + } + }) + ) + + expect(wrapper.find('[data-testid="summary-card-tokenUsage"]').text()).toContain('0') + expect(wrapper.find('[data-testid="token-usage-trend-chart"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="total-tokens-input-ratio"]').text()).toBe('0%') + expect(wrapper.find('[data-testid="total-tokens-output-ratio"]').text()).toBe('0%') + expect(wrapper.find('[data-testid="cached-tokens-cached-ratio"]').text()).toBe('0%') + }) + + it('renders cached token ratio without uncached rows when input tokens are zero', async () => { + const { wrapper } = await setup( + buildDashboard({ + summary: { + messageCount: 1, + sessionCount: 1, + inputTokens: 0, + outputTokens: 400, + totalTokens: 400, + cachedInputTokens: 0, + cacheHitRate: 0, + estimatedCostUsd: 0.0123, + mostActiveDay: { + date: '2026-03-10', + messageCount: 1 + } + } + }) + ) + + expect(wrapper.find('[data-testid="cached-tokens-bar"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="cached-tokens-cached-ratio"]').text()).toBe('0%') + expect(wrapper.find('[data-testid="cached-tokens-uncached-ratio"]').exists()).toBe(false) + }) + + it('keeps the merged token usage chart when the last 30 days have no cost data', async () => { + const { wrapper } = await setup( + buildDashboard({ + calendar: Array.from({ length: 28 }, (_, index) => ({ + date: `2026-03-${`${index + 1}`.padStart(2, '0')}`, + messageCount: 0, + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + cachedInputTokens: 0, + estimatedCostUsd: null, + level: 0 as const + })) + }) + ) + + expect(wrapper.find('[data-testid="token-usage-trend-chart"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="token-usage-cost-row"]').text()).toContain( + 'Trend over the last 30 days' + ) + }) + + it('renders N/A for days together when the first usage record is unavailable', async () => { + const { wrapper } = await setup( + buildDashboard({ + recordingStartedAt: null + }) + ) + + const summaryCard = wrapper.find('[data-testid="summary-card-nostalgia"]') + + expect(summaryCard.exists()).toBe(true) + expect(summaryCard.text()).toContain('N/A') + expect(summaryCard.text()).toContain('You have shared 3 sessions together.') + expect(wrapper.find('[data-testid="nostalgia-rotating-value"]').text()).toBe('3 sessions') + }) + + it('renders N/A for the most active day when that summary is unavailable', async () => { + const { wrapper } = await setup( + buildDashboard({ + summary: { + messageCount: 2, + sessionCount: 3, + inputTokens: 800, + outputTokens: 400, + totalTokens: 1200, + cachedInputTokens: 200, + cacheHitRate: 0.25, + estimatedCostUsd: 0.0123, + mostActiveDay: { + date: null, + messageCount: 0 + } + } + }) + ) + + expect(wrapper.find('[data-testid="nostalgia-detail-most-active-day"]').text()).toContain('N/A') + }) + + it('cleans up scheduled timers when the component unmounts', async () => { + const { wrapper } = await setup(buildDashboard()) + + expect(vi.getTimerCount()).toBeGreaterThan(0) + + wrapper.unmount() + + expect(vi.getTimerCount()).toBe(0) + }) + + it('does not reschedule timers when an async dashboard load resolves after unmount', async () => { + let resolveDashboard: ((value: UsageDashboardData) => void) | null = null + const getUsageDashboard = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveDashboard = resolve + }) + ) + + const { wrapper } = await setup(buildDashboard(), { getUsageDashboard }) + + expect(getUsageDashboard).toHaveBeenCalledTimes(1) + + wrapper.unmount() + resolveDashboard?.(buildDashboard()) + await flushPromises() + + expect(vi.getTimerCount()).toBe(0) + }) +})