From f3633c685683b1b5f511a76f98eeb8da9d6dee5f Mon Sep 17 00:00:00 2001 From: zhangmo8 Date: Mon, 20 Jul 2026 17:34:43 +0800 Subject: [PATCH 1/3] perf(settings): reduce dashboard CPU Cache locale-bound formatters and precompute calendar view data. Pause stable polling while Settings is hidden or unfocused, and deduplicate dashboard requests. --- .../settings-dashboard-cpu-usage/spec.md | 86 +++++++ .../settings/components/DashboardSettings.vue | 237 ++++++++++++------ .../components/DashboardSettings.test.ts | 122 ++++++++- 3 files changed, 365 insertions(+), 80 deletions(-) create mode 100644 docs/issues/settings-dashboard-cpu-usage/spec.md diff --git a/docs/issues/settings-dashboard-cpu-usage/spec.md b/docs/issues/settings-dashboard-cpu-usage/spec.md new file mode 100644 index 0000000000..d48a47328b --- /dev/null +++ b/docs/issues/settings-dashboard-cpu-usage/spec.md @@ -0,0 +1,86 @@ +# Settings Dashboard CPU 占用优化 + +## 问题描述 + +打开 Settings 默认 Overview 后,`DashboardSettings` 会渲染 usage chart、breakdown 和最多一年左右的 +calendar cells。当前每个 calendar cell 的 title 都在 render 路径中调用 `formatDateKey()` 与 +`formatFullTokens()`,两者每次分别创建新的 `Intl.DateTimeFormat` 和 `Intl.NumberFormat`;其他 summary +与 breakdown 格式化函数也在每次调用时创建 formatter。 + +Dashboard 在 backfill 运行时每 3 秒刷新,稳定后仍每 15 秒刷新。Settings 窗口隐藏、最小化或失焦时 +timer 不会暂停,每次响应又替换完整 payload,触发 calendar、chart 和 breakdown 重算。这让 formatter +构造与 IPC/数据库 dashboard 聚合持续叠加,造成打开 Settings 时的 CPU 峰值和后台额外唤醒。 + +## 影响 + +- calendar 数据越长,单次 render 创建的 `Intl` formatter 越多,首屏和刷新时 CPU 峰值越明显。 +- Settings 留在后台时仍周期性执行 IPC、dashboard 聚合和 Vue/Unovis 更新。 +- 手动刷新、timer 与慢请求缺少统一 in-flight owner,后续扩展容易引入重叠请求或旧结果覆盖。 +- 完成 backfill 后 15 秒刷新频率高于稳定 usage dashboard 的实际需要。 + +## 根因位置 + +- `src/renderer/settings/components/DashboardSettings.vue` + - `formatFullTokens`、`formatPercent`、`formatCurrency`、`formatDateKey` 等按调用构造 `Intl` formatter; + - `calendarCellTitle()` 位于 template render 路径,对每个有效 cell 重复格式化; + - `calendarCellStyle()` 每次调用创建新 style object; + - `scheduleRefresh()` 仅判断 mount/dashboard 状态,不判断 `visibilityState` 或窗口 focus; + - 稳定态固定 15 秒轮询,且没有显式的 in-flight 请求 owner。 + +## 修复计划 + +1. 用 locale 依赖的 computed 一次创建 number、percent、currency、date、month 与 weekday formatters; + locale 变化时自动重建,普通 render 复用实例。 +2. 将 dashboard payload 与外部 tooltip 实例改为 `shallowRef`;payload 保持 immutable replacement。 +3. 在 calendar computed 中预生成每个 day 的 title 与静态 level style,template 只读取值。 +4. 将刷新改为单 owner 的 self-scheduling timeout: + - backfill `running`:3 秒; + - 其他稳定状态:60 秒; + - document hidden 或 window unfocused:清除 timer 并暂停; + - 恢复可见/焦点时按上次完成时间计算剩余延迟,已过期则立即刷新。 +5. 同一时间只允许一个 dashboard IPC;手动、timer 和 focus 恢复复用 in-flight Promise。卸载后响应不得 + 更新 state 或重新调度。 + +## 非目标 + +- 不修改 usage dashboard 主进程查询、数据 schema、backfill 批处理或 RTK health contract。 +- 不改变 Dashboard 布局、图表、calendar 范围或用户可见文案。 +- 不引入 worker、虚拟化 calendar 或跨页面 dashboard cache。 +- 不创建 GitHub issue;通过独立 PR 评审该修复。 + +## 验收标准 + +1. 同一 locale 下,calendar 长度增加不会线性增加 `Intl.NumberFormat` / `Intl.DateTimeFormat` 构造次数。 +2. calendar title、月份、weekday、summary、breakdown 与 tooltip 保持原有本地化输出。 +3. backfill 运行时每 3 秒刷新;完成/失败/空闲状态每 60 秒刷新。 +4. hidden 或 unfocused 期间不发 dashboard IPC;恢复后仅在数据已过期时立即刷新,否则等待剩余时间。 +5. 慢请求期间的手动刷新或 timer 不创建第二个 IPC;卸载后不更新、不调度。 +6. 组件行为测试覆盖 formatter 复用、两档 interval、visibility/focus pause-resume、in-flight dedupe 和 cleanup。 +7. format、i18n、lint、typecheck 与 Dashboard/Settings 相关 renderer tests 通过。 + +## 任务清单 + +- [x] 定位 calendar render 中的线性 `Intl` 构造和后台持续轮询根因。 +- [x] 缓存 locale formatter,并预计算 calendar title/style view model。 +- [x] 将 dashboard payload/tooltip 改为 shallow reactive 边界。 +- [x] 实现 3 秒/60 秒两档、visibility/focus aware 的单请求自调度刷新。 +- [x] 补 formatter、interval、pause/resume、dedupe 与 unmount 回归测试(19 个用例通过)。 +- [x] 运行 format、i18n、lint、typecheck、architecture baseline 和相关 renderer tests。 +- [ ] 提交、推送并创建以 `dev` 为 base 的独立 PR。 + +## 验证 + +```bash +pnpm exec vitest --config vitest.config.renderer.ts --run test/renderer/components/DashboardSettings.test.ts +pnpm run format +pnpm run i18n +pnpm run lint +pnpm run typecheck +pnpm run test:renderer +``` + +- `format`、`i18n`、`lint`、`typecheck` 与 renderer architecture baseline 均通过。 +- `DashboardSettings` 定向测试 19/19 通过。 +- 全量 renderer suite:173 个测试文件通过,1 个失败;1331 个测试通过,15 个失败。失败均来自 + `origin/dev` 已存在的 `App.startup.test.ts` mock:`initAppStores` 返回 `undefined`,而未修改的 + `ChatMainApp.vue` 对结果调用 `.then()`;本次 Dashboard 变更没有引入新的全量测试失败。 diff --git a/src/renderer/settings/components/DashboardSettings.vue b/src/renderer/settings/components/DashboardSettings.vue index eb7e5ce61e..722b5d275f 100644 --- a/src/renderer/settings/components/DashboardSettings.vue +++ b/src/renderer/settings/components/DashboardSettings.vue @@ -333,23 +333,23 @@
@@ -403,8 +403,8 @@ data-testid="calendar-cell" class="calendar-cell rounded-sm border border-border/70" :class="day ? 'opacity-100' : 'opacity-0'" - :style="day ? calendarCellStyle(day.level) : undefined" - :title="day ? calendarCellTitle(day) : ''" + :style="day ? day.cellStyle : undefined" + :title="day ? day.title : ''" > @@ -646,8 +646,10 @@ diff --git a/test/renderer/components/DashboardSettings.test.ts b/test/renderer/components/DashboardSettings.test.ts index f4f1f2b9ea..07be057463 100644 --- a/test/renderer/components/DashboardSettings.test.ts +++ b/test/renderer/components/DashboardSettings.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { defineComponent, ref } from 'vue' +import { defineComponent, nextTick, ref } from 'vue' import type { PropType } from 'vue' import { flushPromises, mount } from '@vue/test-utils' import type { UsageDashboardData } from '@shared/types/agent-interface' @@ -195,6 +195,7 @@ async function setup( options: { getUsageDashboard?: ReturnType retryRtkHealthCheck?: ReturnType + hideNostalgia?: boolean } = {} ) { vi.resetModules() @@ -329,6 +330,9 @@ async function setup( ).default const wrapper = mount(DashboardSettings, { + props: { + hideNostalgia: options.hideNostalgia + }, global: { stubs: { ScrollArea: passthrough('ScrollArea'), @@ -353,11 +357,16 @@ async function setup( } } +let mockedDocumentVisibility: DocumentVisibilityState + describe('DashboardSettings', () => { beforeEach(() => { vi.clearAllMocks() vi.useFakeTimers() vi.setSystemTime(new Date(2026, 2, 17, 12, 0, 0)) + mockedDocumentVisibility = 'visible' + vi.spyOn(document, 'hasFocus').mockReturnValue(true) + vi.spyOn(document, 'visibilityState', 'get').mockImplementation(() => mockedDocumentVisibility) }) afterEach(() => { @@ -405,6 +414,117 @@ describe('DashboardSettings', () => { expect(wrapper.find('[data-testid="dashboard-backfill-banner"]').exists()).toBe(true) }) + it('polls every three seconds while backfill is running', async () => { + const { getUsageDashboard } = 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() + } + }) + ) + + await vi.advanceTimersByTimeAsync(2_999) + expect(getUsageDashboard).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(1) + expect(getUsageDashboard).toHaveBeenCalledTimes(2) + }) + + it('polls every sixty seconds after backfill completes', async () => { + const { getUsageDashboard } = await setup(buildDashboard()) + + await vi.advanceTimersByTimeAsync(59_999) + expect(getUsageDashboard).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(1) + expect(getUsageDashboard).toHaveBeenCalledTimes(2) + }) + + it('pauses while hidden and refreshes immediately when stale and visible again', async () => { + const { getUsageDashboard } = await setup(buildDashboard()) + + mockedDocumentVisibility = 'hidden' + document.dispatchEvent(new Event('visibilitychange')) + await vi.advanceTimersByTimeAsync(120_000) + expect(getUsageDashboard).toHaveBeenCalledTimes(1) + + mockedDocumentVisibility = 'visible' + document.dispatchEvent(new Event('visibilitychange')) + await flushPromises() + expect(getUsageDashboard).toHaveBeenCalledTimes(2) + }) + + it('pauses while unfocused and preserves the remaining delay after an early focus', async () => { + const { getUsageDashboard } = await setup(buildDashboard()) + + window.dispatchEvent(new Event('blur')) + await vi.advanceTimersByTimeAsync(30_000) + expect(getUsageDashboard).toHaveBeenCalledTimes(1) + + window.dispatchEvent(new Event('focus')) + await flushPromises() + expect(getUsageDashboard).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(29_999) + expect(getUsageDashboard).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(1) + expect(getUsageDashboard).toHaveBeenCalledTimes(2) + }) + + it('deduplicates manual refreshes while a dashboard request is in flight', async () => { + let resolveDashboard: ((value: UsageDashboardData) => void) | null = null + const getUsageDashboard = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveDashboard = resolve + }) + ) + const { wrapper } = await setup(buildDashboard(), { getUsageDashboard }) + + await wrapper.get('[data-testid="dashboard-header"] button').trigger('click') + await wrapper.get('[data-testid="dashboard-header"] button').trigger('click') + expect(getUsageDashboard).toHaveBeenCalledTimes(1) + + resolveDashboard?.(buildDashboard()) + await flushPromises() + expect(getUsageDashboard).toHaveBeenCalledTimes(1) + }) + + it('reuses a bounded set of Intl formatters for a full calendar render', async () => { + const numberFormat = vi.spyOn(Intl, 'NumberFormat') + const dateTimeFormat = vi.spyOn(Intl, 'DateTimeFormat') + const firstDay = new Date(2025, 0, 1) + const calendar = Array.from({ length: 365 }, (_, index) => { + const date = new Date(firstDay) + date.setDate(firstDay.getDate() + index) + return { + date: date.toISOString().slice(0, 10), + messageCount: 1, + inputTokens: 40, + outputTokens: 20, + totalTokens: 60, + cachedInputTokens: 10, + estimatedCostUsd: 0.0006, + level: 3 as const + } + }) + + const { wrapper } = await setup(buildDashboard({ calendar }), { hideNostalgia: true }) + + expect(numberFormat).toHaveBeenCalledTimes(6) + expect(dateTimeFormat).toHaveBeenCalledTimes(3) + + wrapper.vm.$forceUpdate() + await nextTick() + expect(numberFormat).toHaveBeenCalledTimes(6) + expect(dateTimeFormat).toHaveBeenCalledTimes(3) + }) + 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-"]') From a1b7fcc6be80e512eb6ca9945bdf22e67a02c03d Mon Sep 17 00:00:00 2001 From: zhangmo8 Date: Mon, 20 Jul 2026 17:36:11 +0800 Subject: [PATCH 2/3] docs(settings): record dashboard validation --- docs/issues/settings-dashboard-cpu-usage/spec.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/issues/settings-dashboard-cpu-usage/spec.md b/docs/issues/settings-dashboard-cpu-usage/spec.md index d48a47328b..dcd82e0d01 100644 --- a/docs/issues/settings-dashboard-cpu-usage/spec.md +++ b/docs/issues/settings-dashboard-cpu-usage/spec.md @@ -66,7 +66,7 @@ timer 不会暂停,每次响应又替换完整 payload,触发 calendar、cha - [x] 实现 3 秒/60 秒两档、visibility/focus aware 的单请求自调度刷新。 - [x] 补 formatter、interval、pause/resume、dedupe 与 unmount 回归测试(19 个用例通过)。 - [x] 运行 format、i18n、lint、typecheck、architecture baseline 和相关 renderer tests。 -- [ ] 提交、推送并创建以 `dev` 为 base 的独立 PR。 +- [x] 提交、推送并创建以 `dev` 为 base 的独立 PR。 ## 验证 @@ -84,3 +84,4 @@ pnpm run test:renderer - 全量 renderer suite:173 个测试文件通过,1 个失败;1331 个测试通过,15 个失败。失败均来自 `origin/dev` 已存在的 `App.startup.test.ts` mock:`initAppStores` 返回 `undefined`,而未修改的 `ChatMainApp.vue` 对结果调用 `.then()`;本次 Dashboard 变更没有引入新的全量测试失败。 +- PR: [#2004](https://github.com/ThinkInAIXYZ/deepchat/pull/2004) From a42faa73ab57a120a0f9645637c3d44f2f2c89be Mon Sep 17 00:00:00 2001 From: zhangmo8 Date: Mon, 20 Jul 2026 18:07:00 +0800 Subject: [PATCH 3/3] refactor(dashboard): useTimeoutFn for refresh timer Swap manual refreshTimer/clearRefreshTimer bookkeeping for VueUse useTimeoutFn start/stop + tryOnScopeDispose cleanup. Behavior unchanged (backfill 3s / stable 60s polling, focus/visibility pause, stale-immediate refresh). --- .../settings/components/DashboardSettings.vue | 37 +++++++++---------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/src/renderer/settings/components/DashboardSettings.vue b/src/renderer/settings/components/DashboardSettings.vue index 722b5d275f..73e1ab607e 100644 --- a/src/renderer/settings/components/DashboardSettings.vue +++ b/src/renderer/settings/components/DashboardSettings.vue @@ -649,7 +649,7 @@ import { computed, h, onBeforeUnmount, onMounted, render, shallowRef, watch } from 'vue' import type { CSSProperties } from 'vue' import { useI18n } from 'vue-i18n' -import { useDocumentVisibility, useWindowFocus } from '@vueuse/core' +import { useDocumentVisibility, useTimeoutFn, useWindowFocus } from '@vueuse/core' import { Icon } from '@iconify/vue' import { CurveType } from '@unovis/ts' import type { Tooltip as UnovisTooltip } from '@unovis/ts' @@ -725,7 +725,17 @@ const tokenUsageTooltip = shallowRef<{ component?: UnovisTooltip } | null>(null) const documentVisibility = useDocumentVisibility() const isWindowFocused = useWindowFocus() let isDashboardMounted = false -let refreshTimer: number | null = null +const refreshDelay = shallowRef(0) +const { start: startRefreshTimer, stop: stopRefreshTimer } = useTimeoutFn( + () => { + if (!isDashboardMounted) { + return + } + void loadDashboard() + }, + () => refreshDelay.value, + { immediate: false } +) let dashboardLoadPromise: Promise | null = null let lastDashboardLoadCompletedAt: number | null = null @@ -1010,7 +1020,7 @@ async function loadDashboard(): Promise { return } - clearRefreshTimer() + stopRefreshTimer() const request = runDashboardLoad() dashboardLoadPromise = request @@ -1064,19 +1074,12 @@ async function retryRtkHealthCheck(): Promise { } } -function clearRefreshTimer(): void { - if (refreshTimer !== null) { - window.clearTimeout(refreshTimer) - refreshTimer = null - } -} - function canScheduleRefresh(): boolean { return documentVisibility.value === 'visible' && isWindowFocused.value } function scheduleRefresh(): void { - clearRefreshTimer() + stopRefreshTimer() if (!isDashboardMounted || !dashboard.value || !canScheduleRefresh()) { return @@ -1097,13 +1100,8 @@ function scheduleRefresh(): void { return } - refreshTimer = window.setTimeout(() => { - refreshTimer = null - if (!isDashboardMounted) { - return - } - void loadDashboard() - }, delay) + refreshDelay.value = delay + startRefreshTimer() } function buildBreakdownCard( @@ -1287,7 +1285,7 @@ watch( } if (visibility !== 'visible' || !focused) { - clearRefreshTimer() + stopRefreshTimer() return } @@ -1303,7 +1301,6 @@ onMounted(() => { onBeforeUnmount(() => { isDashboardMounted = false - clearRefreshTimer() })