From 58d5221ca1453454341fe31777d5297def7cb6b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 12 Jun 2026 10:41:33 +0800 Subject: [PATCH] feat(activity): add plugin and skill usage insights Add historical tool and skill aggregation to activity stats, compact the token summary, and show Codex-style activity insights with plugin/skill ranking. Tested: bun test src/utils/__tests__/stats.test.ts Tested: bun run check:persistence-upgrade Tested: bun run check:desktop Not-tested: bun run check:server (previous full run exposed unrelated conversations WebSocket timeouts; focused stats and persistence checks passed) Confidence: medium Scope-risk: moderate --- desktop/src/api/activityStats.ts | 11 +- desktop/src/i18n/locales/en.ts | 11 + desktop/src/i18n/locales/jp.ts | 11 + desktop/src/i18n/locales/kr.ts | 11 + desktop/src/i18n/locales/zh-TW.ts | 11 + desktop/src/i18n/locales/zh.ts | 11 + desktop/src/pages/ActivitySettings.test.tsx | 53 ++++- desktop/src/pages/ActivitySettings.tsx | 232 +++++++++++++++++--- desktop/src/theme/globals.css | 16 +- desktop/src/theme/globals.test.ts | 11 +- src/server/__tests__/settings.test.ts | 2 + src/utils/__tests__/stats.test.ts | 72 +++++- src/utils/stats.ts | 65 ++++++ src/utils/statsCache.ts | 57 ++++- 14 files changed, 502 insertions(+), 72 deletions(-) diff --git a/desktop/src/api/activityStats.ts b/desktop/src/api/activityStats.ts index bd7d274c..695799f3 100644 --- a/desktop/src/api/activityStats.ts +++ b/desktop/src/api/activityStats.ts @@ -29,6 +29,13 @@ export type SessionStats = { timestamp: string } +export type ModelUsage = { + inputTokens?: number + outputTokens?: number + cacheReadInputTokens?: number + cacheCreationInputTokens?: number +} + export type ActivityStats = { totalSessions: number totalMessages: number @@ -38,7 +45,9 @@ export type ActivityStats = { dailyActivity: DailyActivity[] dailyModelTokens: DailyModelTokens[] longestSession: SessionStats | null - modelUsage: Record + modelUsage: Record + toolUsage: Record + skillUsage: Record firstSessionDate: string | null lastSessionDate: string | null peakActivityDay: string | null diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index dc73a734..0122b022 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -226,6 +226,8 @@ export const en = { 'settings.activity.count.sessionOther': '{count} sessions', 'settings.activity.count.dayOne': '{count} day', 'settings.activity.count.dayOther': '{count} days', + 'settings.activity.count.runOne': '{count} run', + 'settings.activity.count.runOther': '{count} runs', 'settings.activity.weekday.mon': 'Mon', 'settings.activity.weekday.wed': 'Wed', 'settings.activity.weekday.fri': 'Fri', @@ -234,6 +236,15 @@ export const en = { 'settings.activity.tokens': 'Tokens', 'settings.activity.messages': 'Messages', 'settings.activity.tools': 'Tools', + 'settings.activity.activityInsights': 'Activity insights', + 'settings.activity.activeRate': 'Active rate', + 'settings.activity.mostUsedModel': 'Most used model', + 'settings.activity.exploredSkills': 'Skills explored', + 'settings.activity.totalSkillUses': 'Skill uses', + 'settings.activity.totalToolCalls': 'Tool calls', + 'settings.activity.totalSessions': 'Total sessions', + 'settings.activity.mostUsedPluginsAndSkills': 'Most used plugins & skills', + 'settings.activity.none': 'None', 'settings.activity.less': 'Less', 'settings.activity.more': 'More', diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index 4ac0d9d4..b0781967 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -228,6 +228,8 @@ export const jp: Record = { 'settings.activity.count.sessionOther': '{count} セッション', 'settings.activity.count.dayOne': '{count} 日', 'settings.activity.count.dayOther': '{count} 日', + 'settings.activity.count.runOne': '{count} 回実行', + 'settings.activity.count.runOther': '{count} 回実行', 'settings.activity.weekday.mon': '月', 'settings.activity.weekday.wed': '水', 'settings.activity.weekday.fri': '金', @@ -236,6 +238,15 @@ export const jp: Record = { 'settings.activity.tokens': 'トークン', 'settings.activity.messages': 'メッセージ', 'settings.activity.tools': 'ツール', + 'settings.activity.activityInsights': 'アクティビティ分析', + 'settings.activity.activeRate': 'アクティブ率', + 'settings.activity.mostUsedModel': '最も使ったモデル', + 'settings.activity.exploredSkills': '使用したスキル', + 'settings.activity.totalSkillUses': 'スキル使用数', + 'settings.activity.totalToolCalls': 'ツール呼び出し', + 'settings.activity.totalSessions': 'セッション総数', + 'settings.activity.mostUsedPluginsAndSkills': 'よく使うプラグイン/スキル', + 'settings.activity.none': 'なし', 'settings.activity.less': '少ない', 'settings.activity.more': '多い', diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index e43721a7..0561056d 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -228,6 +228,8 @@ export const kr: Record = { 'settings.activity.count.sessionOther': '{count} 세션', 'settings.activity.count.dayOne': '{count}일', 'settings.activity.count.dayOther': '{count}일', + 'settings.activity.count.runOne': '{count}회 실행', + 'settings.activity.count.runOther': '{count}회 실행', 'settings.activity.weekday.mon': '월', 'settings.activity.weekday.wed': '수', 'settings.activity.weekday.fri': '금', @@ -236,6 +238,15 @@ export const kr: Record = { 'settings.activity.tokens': '토큰', 'settings.activity.messages': '메시지', 'settings.activity.tools': '도구', + 'settings.activity.activityInsights': '활동 인사이트', + 'settings.activity.activeRate': '활성 비율', + 'settings.activity.mostUsedModel': '가장 많이 쓴 모델', + 'settings.activity.exploredSkills': '사용한 스킬', + 'settings.activity.totalSkillUses': '스킬 사용 수', + 'settings.activity.totalToolCalls': '도구 호출', + 'settings.activity.totalSessions': '총 세션', + 'settings.activity.mostUsedPluginsAndSkills': '자주 쓰는 플러그인/스킬', + 'settings.activity.none': '없음', 'settings.activity.less': '적음', 'settings.activity.more': '많음', diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index bf538026..9e9ab57d 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -228,6 +228,8 @@ export const zh: Record = { 'settings.activity.count.sessionOther': '{count} 次會話', 'settings.activity.count.dayOne': '{count} 天', 'settings.activity.count.dayOther': '{count} 天', + 'settings.activity.count.runOne': '{count} 次執行', + 'settings.activity.count.runOther': '{count} 次執行', 'settings.activity.weekday.mon': '週一', 'settings.activity.weekday.wed': '週三', 'settings.activity.weekday.fri': '週五', @@ -236,6 +238,15 @@ export const zh: Record = { 'settings.activity.tokens': 'Token', 'settings.activity.messages': '訊息', 'settings.activity.tools': '工具', + 'settings.activity.activityInsights': '活動洞察', + 'settings.activity.activeRate': '活躍率', + 'settings.activity.mostUsedModel': '最常用模型', + 'settings.activity.exploredSkills': '已使用的技能', + 'settings.activity.totalSkillUses': '技能使用總數', + 'settings.activity.totalToolCalls': '工具呼叫', + 'settings.activity.totalSessions': '會話總數', + 'settings.activity.mostUsedPluginsAndSkills': '最常用的外掛和技能', + 'settings.activity.none': '暫無', 'settings.activity.less': '少', 'settings.activity.more': '多', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 479d7a35..bd06b081 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -228,6 +228,8 @@ export const zh: Record = { 'settings.activity.count.sessionOther': '{count} 次会话', 'settings.activity.count.dayOne': '{count} 天', 'settings.activity.count.dayOther': '{count} 天', + 'settings.activity.count.runOne': '{count} 次运行', + 'settings.activity.count.runOther': '{count} 次运行', 'settings.activity.weekday.mon': '周一', 'settings.activity.weekday.wed': '周三', 'settings.activity.weekday.fri': '周五', @@ -236,6 +238,15 @@ export const zh: Record = { 'settings.activity.tokens': 'Token', 'settings.activity.messages': '消息', 'settings.activity.tools': '工具', + 'settings.activity.activityInsights': '活动洞察', + 'settings.activity.activeRate': '活跃率', + 'settings.activity.mostUsedModel': '最常用模型', + 'settings.activity.exploredSkills': '已使用的技能', + 'settings.activity.totalSkillUses': '技能使用总数', + 'settings.activity.totalToolCalls': '工具调用', + 'settings.activity.totalSessions': '会话总数', + 'settings.activity.mostUsedPluginsAndSkills': '最常用的插件和技能', + 'settings.activity.none': '暂无', 'settings.activity.less': '少', 'settings.activity.more': '多', diff --git a/desktop/src/pages/ActivitySettings.test.tsx b/desktop/src/pages/ActivitySettings.test.tsx index 62e34e87..f15edb4b 100644 --- a/desktop/src/pages/ActivitySettings.test.tsx +++ b/desktop/src/pages/ActivitySettings.test.tsx @@ -62,7 +62,28 @@ const activityResponse = { { date: '2026-05-09', tokensByModel: { 'claude-sonnet': 128_000 } }, ], longestSession: null, - modelUsage: {}, + modelUsage: { + 'claude-sonnet': { + inputTokens: 1_900_000, + outputTokens: 700_000, + cacheReadInputTokens: 230_000, + cacheCreationInputTokens: 34_000, + }, + }, + toolUsage: { + Bash: 180, + Read: 160, + Skill: 40, + mcp__github__get_pull_request: 14, + mcp__chrome_devtools__new_page: 8, + mcp__figma__get_screenshot: 7, + mcp__linear__create_issue: 2, + }, + skillUsage: { + 'frontend-design': 24, + 'git-commit-pr': 16, + 'code-review': 11, + }, firstSessionDate: '2025-06-01T10:00:00.000Z', lastSessionDate: '2026-05-09T11:00:00.000Z', peakActivityDay: '2026-04-20', @@ -195,6 +216,27 @@ describe('ActivitySettings', () => { expect(screen.getByText('0m')).toBeInTheDocument() expect(screen.getByText('9 days')).toBeInTheDocument() expect(screen.getByText('18 days')).toBeInTheDocument() + expect(screen.getByText('Activity insights')).toBeInTheDocument() + expect(screen.getByText('Active rate')).toBeInTheDocument() + expect(screen.getByText('Most used model')).toBeInTheDocument() + expect(screen.getByText('Skills explored')).toBeInTheDocument() + expect(screen.getByText('Skill uses')).toBeInTheDocument() + expect(screen.getByText('Tool calls')).toBeInTheDocument() + expect(screen.getByText('Total sessions')).toBeInTheDocument() + expect(screen.getByText('Most used plugins & skills')).toBeInTheDocument() + expect(screen.getByText('Sonnet')).toBeInTheDocument() + expect(screen.getByText('$frontend-design')).toBeInTheDocument() + expect(screen.getByText('$git-commit-pr')).toBeInTheDocument() + expect(screen.getByText('$code-review')).toBeInTheDocument() + expect(screen.getByText('@github')).toBeInTheDocument() + expect(screen.getByText('@chrome-devtools')).toBeInTheDocument() + expect(screen.getByText('@figma')).toBeInTheDocument() + expect(screen.getByText('24 runs')).toBeInTheDocument() + expect(screen.getByText('14 runs')).toBeInTheDocument() + expect(screen.getByText('7 runs')).toBeInTheDocument() + expect(screen.queryByText('@linear')).not.toBeInTheDocument() + expect(screen.queryByText('Bash')).not.toBeInTheDocument() + expect(screen.queryByText('Read')).not.toBeInTheDocument() expect(screen.getAllByText('May').length).toBeGreaterThan(0) expect(screen.queryByText('5月')).not.toBeInTheDocument() @@ -239,13 +281,14 @@ describe('ActivitySettings', () => { expect(editButton.closest('div')).toHaveClass('group/activity-profile') }) - it('uses a balanced responsive summary grid instead of the loose medium-width layout', async () => { + it('uses a compact summary strip instead of the loose card layout', async () => { render() await flushActivityLoad() const summaryPanel = screen.getByText('Total tokens').closest('section') expect(summaryPanel).toHaveClass('activity-summary-panel') + expect(summaryPanel).toHaveClass('max-w-[900px]') const summaryGrid = summaryPanel?.querySelector('.activity-summary-grid') expect(summaryGrid).not.toHaveClass('sm:grid-cols-2') @@ -256,10 +299,11 @@ describe('ActivitySettings', () => { expect(primaryMetric).toHaveClass('activity-summary-metric-primary') expect(primaryMetric).not.toHaveClass('sm:col-span-2') expect(primaryMetric).not.toHaveClass('lg:col-span-1') + expect(primaryMetric).toHaveClass('text-center') const longestTaskValue = screen.getByText('0m') expect(longestTaskValue).toHaveClass('activity-summary-value') - expect(longestTaskValue).not.toHaveClass('truncate') + expect(longestTaskValue).toHaveClass('truncate') expect(longestTaskValue).not.toHaveClass('break-words') }) @@ -364,6 +408,8 @@ describe('ActivitySettings', () => { activeDays: 0, dailyActivity: [], dailyModelTokens: [], + toolUsage: {}, + skillUsage: {}, longestSession: { id: 'session-1', startedAt: '2026-05-09T08:00:00.000Z', @@ -388,5 +434,6 @@ describe('ActivitySettings', () => { expect(screen.getByText('1 小时 30 分钟')).toBeInTheDocument() expect(screen.getByText('12 消息')).toBeInTheDocument() expect(screen.getByText('暂无本地用量')).toBeInTheDocument() + expect(screen.queryByText('活动洞察')).not.toBeInTheDocument() }) }) diff --git a/desktop/src/pages/ActivitySettings.tsx b/desktop/src/pages/ActivitySettings.tsx index 8f581c5a..bd473316 100644 --- a/desktop/src/pages/ActivitySettings.tsx +++ b/desktop/src/pages/ActivitySettings.tsx @@ -28,6 +28,19 @@ type SummaryMetric = { detail?: string } +type InsightMetric = { + label: string + value: string + detail?: string +} + +type PluginRankItem = { + id: string + label: string + count: number + kind: 'plugin' | 'skill' +} + type HeatmapMode = 'daily' | 'weekly' | 'cumulative' const WEEK_COUNT = 52 @@ -102,6 +115,18 @@ function formatTokens(tokens: number) { return `${tokens}` } +function formatInteger(value: number, locale: Locale) { + return new Intl.NumberFormat(DATE_LOCALES[locale], { maximumFractionDigits: 0 }).format(value) +} + +function formatPercent(numerator: number, denominator: number, locale: Locale) { + if (denominator <= 0) return '0%' + return new Intl.NumberFormat(DATE_LOCALES[locale], { + maximumFractionDigits: 0, + style: 'percent', + }).format(numerator / denominator) +} + function formatDayCount(value: number, t: ReturnType) { return t(value === 1 ? 'settings.activity.count.dayOne' : 'settings.activity.count.dayOther', { count: value }) } @@ -131,6 +156,67 @@ function formatMessageCount(value: number, t: ReturnType) return `${value} ${t('settings.activity.messages')}` } +function formatRunCount(value: number, t: ReturnType) { + return t(value === 1 ? 'settings.activity.count.runOne' : 'settings.activity.count.runOther', { count: value }) +} + +function getModelTokenTotal(usage: ActivityStatsResponse['modelUsage'][string] | undefined) { + if (!usage) return 0 + return ( + (usage.inputTokens ?? 0) + + (usage.outputTokens ?? 0) + + (usage.cacheReadInputTokens ?? 0) + + (usage.cacheCreationInputTokens ?? 0) + ) +} + +function formatModelName(model: string) { + return model + .replace(/^claude-/i, '') + .replace(/-/g, ' ') + .replace(/\b\w/g, (char) => char.toUpperCase()) +} + +function getPluginNameFromToolName(toolName: string) { + if (!toolName.startsWith('mcp__')) return null + const parts = toolName.split('__').filter(Boolean) + const serverName = parts[1] + if (!serverName) return null + if (serverName === 'codex_apps' && parts[2]) return parts[2] + return serverName +} + +function formatPluginName(pluginName: string) { + return pluginName.replace(/_/g, '-') +} + +function buildPluginAndSkillRankItems(stats: ActivityStatsResponse | null) { + const skillItems = Object.entries(stats?.skillUsage ?? {}).map(([skill, count]) => ({ + id: `skill:${skill}`, + label: `$${skill}`, + count, + kind: 'skill', + })) + + const pluginUsage = new Map() + for (const [toolName, count] of Object.entries(stats?.toolUsage ?? {})) { + const pluginName = getPluginNameFromToolName(toolName) + if (!pluginName || count <= 0) continue + pluginUsage.set(pluginName, (pluginUsage.get(pluginName) || 0) + count) + } + const pluginItems = [...pluginUsage.entries()].map(([pluginName, count]) => ({ + id: `plugin:${pluginName}`, + label: `@${formatPluginName(pluginName)}`, + count, + kind: 'plugin', + })) + + return [...skillItems, ...pluginItems] + .filter((item) => item.count > 0) + .sort((a, b) => b.count - a.count || a.label.localeCompare(b.label)) + .slice(0, 6) +} + function withProfileDefaults(profile: Partial | null | undefined): DesktopProfilePreferences { return { ...DEFAULT_PROFILE, ...profile } } @@ -461,12 +547,31 @@ export function ActivitySettings() { sum + Object.values(day.tokensByModel).reduce((daySum, tokens) => daySum + tokens, 0) ), 0) }, [stats]) + const totalToolCalls = useMemo(() => { + return (stats?.dailyActivity ?? []).reduce((sum, day) => sum + day.toolCallCount, 0) + }, [stats]) + const totalSkillUses = useMemo(() => { + return Object.values(stats?.skillUsage ?? {}).reduce((sum, count) => sum + count, 0) + }, [stats]) + const exploredSkillsCount = Object.keys(stats?.skillUsage ?? {}).length + const topModel = useMemo(() => { + return Object.entries(stats?.modelUsage ?? {}).reduce<{ + model: string + tokens: number + } | null>((top, [model, usage]) => { + const tokens = getModelTokenTotal(usage) + if (tokens <= 0) return top + if (!top || tokens > top.tokens) return { model, tokens } + return top + }, null) + }, [stats]) const peakTokens = useMemo(() => { return (stats?.dailyModelTokens ?? []).reduce((peak, day) => { const dayTotal = Object.values(day.tokensByModel).reduce((sum, tokens) => sum + tokens, 0) return Math.max(peak, dayTotal) }, 0) }, [stats]) + const topPluginItems = useMemo(() => buildPluginAndSkillRankItems(stats), [stats]) const metrics: SummaryMetric[] = [ { label: t('settings.activity.totalTokens'), @@ -494,6 +599,33 @@ export function ActivitySettings() { detail: formatSessionCount(last30Usage.sessions, t), }, ] + const insightMetrics: InsightMetric[] = [ + { + label: t('settings.activity.activeRate'), + value: formatPercent(stats?.activeDays ?? 0, stats?.totalDays ?? 0, locale), + }, + { + label: t('settings.activity.mostUsedModel'), + value: topModel ? formatModelName(topModel.model) : t('settings.activity.none'), + detail: topModel ? `${formatTokens(topModel.tokens)} ${t('settings.activity.tokens')}` : undefined, + }, + { + label: t('settings.activity.exploredSkills'), + value: formatInteger(exploredSkillsCount, locale), + }, + { + label: t('settings.activity.totalSkillUses'), + value: formatInteger(totalSkillUses, locale), + }, + { + label: t('settings.activity.totalToolCalls'), + value: formatInteger(totalToolCalls, locale), + }, + { + label: t('settings.activity.totalSessions'), + value: formatInteger(stats?.totalSessions ?? 0, locale), + }, + ] const avatarSrc = profile.avatarFile ? getProfileAvatarUrl(profile.avatarUpdatedAt) : DEFAULT_AVATAR_SRC const avatarClassName = profile.avatarFile ? 'h-full w-full object-cover' @@ -565,9 +697,9 @@ export function ActivitySettings() { } return ( -
-
-
+
+
+
{`${profile.displayName}
-
-

{profile.displayName}

+
+

{profile.displayName}

-
+
{isLoading ? (
{Array.from({ length: 5 }).map((_, index) => (
-
-
-
+
+
+
))}
@@ -634,31 +766,21 @@ export function ActivitySettings() { return (
-
-
-