feat: 新增 Skills Usage 监控统计与图表 (#668)
* feat: add skills usage monitoring * fix: localize Skills Usage page copy * fix: keep Skills Usage labels compact
This commit is contained in:
@@ -39,13 +39,57 @@ function collectLiteralTranslationKeys(): string[] {
|
||||
return [...keys].sort()
|
||||
}
|
||||
|
||||
function hasPath(messages: Record<string, unknown>, key: string): boolean {
|
||||
function getPath(messages: Record<string, unknown>, key: string): unknown {
|
||||
let current: unknown = messages
|
||||
for (const part of key.split('.')) {
|
||||
if (!current || typeof current !== 'object' || !(part in current)) return false
|
||||
if (!current || typeof current !== 'object' || !(part in current)) return undefined
|
||||
current = (current as Record<string, unknown>)[part]
|
||||
}
|
||||
return typeof current !== 'undefined'
|
||||
return current
|
||||
}
|
||||
|
||||
function hasPath(messages: Record<string, unknown>, key: string): boolean {
|
||||
return typeof getPath(messages, key) !== 'undefined'
|
||||
}
|
||||
|
||||
const SKILLS_USAGE_LOCALIZED_KEYS = [
|
||||
'sidebar.skillsUsage',
|
||||
'skillsUsage.title',
|
||||
'skillsUsage.subtitle',
|
||||
'skillsUsage.refresh',
|
||||
'skillsUsage.periodSelector',
|
||||
'skillsUsage.periodLabel',
|
||||
'skillsUsage.summary',
|
||||
'skillsUsage.totalActions',
|
||||
'skillsUsage.loads',
|
||||
'skillsUsage.edits',
|
||||
'skillsUsage.distinctSkills',
|
||||
'skillsUsage.topSkills',
|
||||
'skillsUsage.dailyTrend',
|
||||
'skillsUsage.periodSummary',
|
||||
'skillsUsage.skill',
|
||||
'skillsUsage.share',
|
||||
'skillsUsage.lastUsed',
|
||||
'skillsUsage.noData',
|
||||
'skillsUsage.loadFailed',
|
||||
'skillsUsage.otherSkills',
|
||||
]
|
||||
|
||||
const SKILLS_USAGE_COMPACT_LABEL_LIMITS: Record<string, number> = {
|
||||
'skillsUsage.totalActions': 12,
|
||||
'skillsUsage.loads': 10,
|
||||
'skillsUsage.edits': 10,
|
||||
'skillsUsage.distinctSkills': 12,
|
||||
'skillsUsage.topSkills': 16,
|
||||
'skillsUsage.dailyTrend': 16,
|
||||
'skillsUsage.skill': 10,
|
||||
'skillsUsage.share': 10,
|
||||
'skillsUsage.lastUsed': 12,
|
||||
'skillsUsage.otherSkills': 16,
|
||||
}
|
||||
|
||||
function labelLength(value: unknown): number {
|
||||
return typeof value === 'string' ? Array.from(value.replace(/\{[^}]+\}/g, '')).length : Infinity
|
||||
}
|
||||
|
||||
describe('i18n locale coverage', () => {
|
||||
@@ -75,6 +119,35 @@ describe('i18n locale coverage', () => {
|
||||
expect(missing).toEqual([])
|
||||
})
|
||||
|
||||
it('localizes Skills Usage page copy in every non-English locale instead of falling back to English', () => {
|
||||
const englishMessages = rawMessages.en
|
||||
const untranslated = Object.entries(rawMessages).flatMap(([locale, localeMessages]) => {
|
||||
if (locale === 'en') return []
|
||||
|
||||
return SKILLS_USAGE_LOCALIZED_KEYS.flatMap((key) => {
|
||||
const localeValue = getPath(localeMessages, key)
|
||||
if (typeof localeValue === 'undefined') return [`${locale}: ${key} missing`]
|
||||
return localeValue === getPath(englishMessages, key) ? [`${locale}: ${key}`] : []
|
||||
})
|
||||
})
|
||||
|
||||
expect(untranslated).toEqual([])
|
||||
})
|
||||
|
||||
|
||||
it('keeps Skills Usage summary and table labels compact across locales', () => {
|
||||
const oversized = Object.entries(rawMessages).flatMap(([locale, localeMessages]) =>
|
||||
Object.entries(SKILLS_USAGE_COMPACT_LABEL_LIMITS).flatMap(([key, maxLength]) => {
|
||||
const localeValue = getPath(localeMessages, key)
|
||||
return labelLength(localeValue) > maxLength
|
||||
? [`${locale}: ${key} (${labelLength(localeValue)} > ${maxLength})`]
|
||||
: []
|
||||
}),
|
||||
)
|
||||
|
||||
expect(oversized).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps the coverage scanner rooted in client source files', () => {
|
||||
expect(relative(process.cwd(), SOURCE_ROOT)).toBe(join('packages', 'client', 'src'))
|
||||
})
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
|
||||
const fetchSkillUsageStatsMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/api/hermes/skills', () => ({
|
||||
fetchSkillUsageStats: fetchSkillUsageStatsMock,
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string, params?: Record<string, unknown>) => {
|
||||
if (key === 'skillsUsage.periodLabel') return `${params?.days}d`
|
||||
if (key === 'skillsUsage.periodSummary') return `Last ${params?.days} days`
|
||||
return key
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('naive-ui', async () => {
|
||||
const actual = await vi.importActual<any>('naive-ui')
|
||||
return {
|
||||
...actual,
|
||||
NButton: {
|
||||
props: ['loading', 'type', 'size', 'quaternary', 'secondary'],
|
||||
inheritAttrs: false,
|
||||
template: '<button :data-type="type" :aria-pressed="$attrs[\'aria-pressed\']" @click="$emit(\'click\')"><slot /></button>',
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
import SkillsUsageView from '@/views/hermes/SkillsUsageView.vue'
|
||||
|
||||
const sevenDayStats = {
|
||||
period_days: 7,
|
||||
summary: {
|
||||
total_skill_loads: 3,
|
||||
total_skill_edits: 1,
|
||||
total_skill_actions: 4,
|
||||
distinct_skills_used: 2,
|
||||
},
|
||||
by_day: [
|
||||
{
|
||||
date: '2026-05-10',
|
||||
view_count: 1,
|
||||
manage_count: 0,
|
||||
total_count: 1,
|
||||
skills: [
|
||||
{ skill: 'github-pr-workflow', view_count: 1, manage_count: 0, total_count: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
date: '2026-05-11',
|
||||
view_count: 2,
|
||||
manage_count: 1,
|
||||
total_count: 3,
|
||||
skills: [
|
||||
{ skill: 'hermes-agent', view_count: 2, manage_count: 1, total_count: 3 },
|
||||
],
|
||||
},
|
||||
],
|
||||
top_skills: [
|
||||
{ skill: 'hermes-agent', view_count: 2, manage_count: 1, total_count: 3, percentage: 75, last_used_at: 1_700_000_000 },
|
||||
{ skill: 'github-pr-workflow', view_count: 1, manage_count: 0, total_count: 1, percentage: 25, last_used_at: null },
|
||||
],
|
||||
}
|
||||
|
||||
describe('SkillsUsageView', () => {
|
||||
beforeEach(() => {
|
||||
fetchSkillUsageStatsMock.mockReset()
|
||||
fetchSkillUsageStatsMock.mockResolvedValue(sevenDayStats)
|
||||
})
|
||||
|
||||
it('loads rolling 7 day skill usage and renders statistics beside a skill-colored visual trend', async () => {
|
||||
const wrapper = mount(SkillsUsageView)
|
||||
await flushPromises()
|
||||
|
||||
expect(fetchSkillUsageStatsMock).toHaveBeenCalledWith(7)
|
||||
expect(wrapper.text()).toContain('skillsUsage.title')
|
||||
expect(wrapper.find('[data-testid="skills-usage-chart"]').exists()).toBe(true)
|
||||
expect(wrapper.findAll('.skill-bar-col')).toHaveLength(2)
|
||||
expect(wrapper.findAll('.skill-bar-segment[data-skill="hermes-agent"]')).toHaveLength(1)
|
||||
expect(wrapper.findAll('.skill-bar-segment[data-skill="github-pr-workflow"]')).toHaveLength(1)
|
||||
expect(wrapper.find('[data-testid="skills-usage-legend"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-testid="skills-usage-stats"]').text()).toContain('4')
|
||||
expect(wrapper.text()).toContain('hermes-agent')
|
||||
expect(wrapper.text()).toContain('github-pr-workflow')
|
||||
expect(wrapper.text()).toContain('75.0%')
|
||||
})
|
||||
|
||||
it('reloads the selected period when the period button changes', async () => {
|
||||
const wrapper = mount(SkillsUsageView)
|
||||
await flushPromises()
|
||||
fetchSkillUsageStatsMock.mockClear()
|
||||
|
||||
const thirtyDayButton = wrapper.findAll('button').find(button => button.text() === '30d')
|
||||
expect(thirtyDayButton).toBeTruthy()
|
||||
|
||||
await thirtyDayButton!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(fetchSkillUsageStatsMock).toHaveBeenCalledWith(30)
|
||||
expect(thirtyDayButton!.attributes('aria-pressed')).toBe('true')
|
||||
})
|
||||
|
||||
it('flips the chart tooltip away from the hovered side of the bars', async () => {
|
||||
const wrapper = mount(SkillsUsageView)
|
||||
await flushPromises()
|
||||
|
||||
const bars = wrapper.findAll('.skill-bar-col')
|
||||
expect(bars).toHaveLength(2)
|
||||
|
||||
await bars[1].trigger('mouseenter')
|
||||
expect(wrapper.find('.floating-tooltip.align-left').exists()).toBe(true)
|
||||
expect(wrapper.find('.floating-tooltip').text()).toContain('2026-05-11')
|
||||
|
||||
await bars[0].trigger('mouseenter')
|
||||
expect(wrapper.find('.floating-tooltip.align-right').exists()).toBe(true)
|
||||
expect(wrapper.find('.floating-tooltip').text()).toContain('2026-05-10')
|
||||
})
|
||||
|
||||
it('keeps stale data visible while refreshing an already loaded period', async () => {
|
||||
const wrapper = mount(SkillsUsageView)
|
||||
await flushPromises()
|
||||
|
||||
let resolveRefresh!: (value: unknown) => void
|
||||
fetchSkillUsageStatsMock.mockReturnValueOnce(new Promise(resolve => {
|
||||
resolveRefresh = resolve
|
||||
}))
|
||||
|
||||
const refreshButton = wrapper.findAll('button').find(button => button.text() === 'skillsUsage.refresh')
|
||||
expect(refreshButton).toBeTruthy()
|
||||
|
||||
await refreshButton!.trigger('click')
|
||||
|
||||
expect(fetchSkillUsageStatsMock).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.find('[data-testid="skills-usage-chart"]').exists()).toBe(true)
|
||||
expect(wrapper.find('.usage-panel.is-refreshing').exists()).toBe(true)
|
||||
|
||||
resolveRefresh({
|
||||
period_days: 7,
|
||||
summary: { total_skill_loads: 1, total_skill_edits: 0, total_skill_actions: 1, distinct_skills_used: 1 },
|
||||
by_day: [
|
||||
{
|
||||
date: '2026-05-12',
|
||||
view_count: 1,
|
||||
manage_count: 0,
|
||||
total_count: 1,
|
||||
skills: [{ skill: 'test-driven-development', view_count: 1, manage_count: 0, total_count: 1 }],
|
||||
},
|
||||
],
|
||||
top_skills: [
|
||||
{ skill: 'test-driven-development', view_count: 1, manage_count: 0, total_count: 1, percentage: 100, last_used_at: null },
|
||||
],
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('test-driven-development')
|
||||
})
|
||||
|
||||
it('does not let an older refresh overwrite newer stats for the same period', async () => {
|
||||
const wrapper = mount(SkillsUsageView)
|
||||
await flushPromises()
|
||||
|
||||
let resolveOlder!: (value: unknown) => void
|
||||
let resolveNewer!: (value: unknown) => void
|
||||
fetchSkillUsageStatsMock
|
||||
.mockReturnValueOnce(new Promise(resolve => { resolveOlder = resolve }))
|
||||
.mockReturnValueOnce(new Promise(resolve => { resolveNewer = resolve }))
|
||||
|
||||
const refreshButton = wrapper.findAll('button').find(button => button.text() === 'skillsUsage.refresh')
|
||||
expect(refreshButton).toBeTruthy()
|
||||
|
||||
await refreshButton!.trigger('click')
|
||||
await refreshButton!.trigger('click')
|
||||
|
||||
resolveNewer({
|
||||
period_days: 7,
|
||||
summary: { total_skill_loads: 2, total_skill_edits: 0, total_skill_actions: 2, distinct_skills_used: 1 },
|
||||
by_day: [
|
||||
{
|
||||
date: '2026-05-13',
|
||||
view_count: 2,
|
||||
manage_count: 0,
|
||||
total_count: 2,
|
||||
skills: [{ skill: 'newer-skill', view_count: 2, manage_count: 0, total_count: 2 }],
|
||||
},
|
||||
],
|
||||
top_skills: [
|
||||
{ skill: 'newer-skill', view_count: 2, manage_count: 0, total_count: 2, percentage: 100, last_used_at: null },
|
||||
],
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('newer-skill')
|
||||
|
||||
resolveOlder({
|
||||
period_days: 7,
|
||||
summary: { total_skill_loads: 1, total_skill_edits: 0, total_skill_actions: 1, distinct_skills_used: 1 },
|
||||
by_day: [
|
||||
{
|
||||
date: '2026-05-12',
|
||||
view_count: 1,
|
||||
manage_count: 0,
|
||||
total_count: 1,
|
||||
skills: [{ skill: 'older-skill', view_count: 1, manage_count: 0, total_count: 1 }],
|
||||
},
|
||||
],
|
||||
top_skills: [
|
||||
{ skill: 'older-skill', view_count: 1, manage_count: 0, total_count: 1, percentage: 100, last_used_at: null },
|
||||
],
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('newer-skill')
|
||||
expect(wrapper.text()).not.toContain('older-skill')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,192 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
|
||||
const profileMock = vi.hoisted(() => ({
|
||||
getActiveProfileDir: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
|
||||
getActiveProfileDir: profileMock.getActiveProfileDir,
|
||||
getProfileDir: vi.fn(),
|
||||
}))
|
||||
|
||||
function createStateDb(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'hermes-skill-usage-'))
|
||||
const db = new DatabaseSync(join(dir, 'state.db'))
|
||||
db.exec(`
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
source TEXT,
|
||||
started_at INTEGER
|
||||
);
|
||||
CREATE INDEX idx_sessions_started ON sessions(started_at);
|
||||
CREATE TABLE messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT,
|
||||
role TEXT,
|
||||
content TEXT,
|
||||
tool_call_id TEXT,
|
||||
tool_calls TEXT,
|
||||
tool_name TEXT,
|
||||
timestamp INTEGER
|
||||
);
|
||||
CREATE INDEX idx_messages_session ON messages(session_id, timestamp);
|
||||
`)
|
||||
db.close()
|
||||
return dir
|
||||
}
|
||||
|
||||
function insertSession(dir: string, row: { id: string; source?: string; started_at: number }) {
|
||||
const db = new DatabaseSync(join(dir, 'state.db'))
|
||||
db.prepare('INSERT INTO sessions (id, source, started_at) VALUES (?, ?, ?)')
|
||||
.run(row.id, row.source ?? 'cli', row.started_at)
|
||||
db.close()
|
||||
}
|
||||
|
||||
function insertToolResult(dir: string, row: {
|
||||
sessionId: string
|
||||
timestamp: number
|
||||
toolName?: string | null
|
||||
content: string
|
||||
}) {
|
||||
const db = new DatabaseSync(join(dir, 'state.db'))
|
||||
db.prepare('INSERT INTO messages (session_id, role, content, tool_name, timestamp) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(row.sessionId, 'tool', row.content, row.toolName ?? null, row.timestamp)
|
||||
db.close()
|
||||
}
|
||||
|
||||
function insertAssistantToolCalls(dir: string, sessionId: string, timestamp: number, toolCalls: unknown) {
|
||||
const db = new DatabaseSync(join(dir, 'state.db'))
|
||||
db.prepare('INSERT INTO messages (session_id, role, tool_calls, timestamp) VALUES (?, ?, ?, ?)')
|
||||
.run(sessionId, 'assistant', JSON.stringify(toolCalls), timestamp)
|
||||
db.close()
|
||||
}
|
||||
|
||||
describe('Hermes skill usage analytics DB aggregation', () => {
|
||||
let profileDir: string | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
profileMock.getActiveProfileDir.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (profileDir) rmSync(profileDir, { recursive: true, force: true })
|
||||
profileDir = null
|
||||
})
|
||||
|
||||
it('counts completed skill loads and edits from compact tool result rows inside the requested period', async () => {
|
||||
const now = 1_700_000_000
|
||||
profileDir = createStateDb()
|
||||
profileMock.getActiveProfileDir.mockReturnValue(profileDir)
|
||||
|
||||
insertSession(profileDir, { id: 'recent-cli', source: 'cli', started_at: now - 60 })
|
||||
insertToolResult(profileDir, {
|
||||
sessionId: 'recent-cli',
|
||||
timestamp: now - 50,
|
||||
content: '[skill_view] name=hermes-agent (64,764 chars)',
|
||||
})
|
||||
insertToolResult(profileDir, {
|
||||
sessionId: 'recent-cli',
|
||||
timestamp: now - 45,
|
||||
toolName: 'skill_view',
|
||||
content: '[skill_view] name=hermes-agent (64,764 chars)',
|
||||
})
|
||||
insertToolResult(profileDir, {
|
||||
sessionId: 'recent-cli',
|
||||
timestamp: now - 40,
|
||||
toolName: 'skill_manage',
|
||||
content: JSON.stringify({ success: true, message: "Patched SKILL.md in skill 'hermes-agent' (1 replacement)." }),
|
||||
})
|
||||
insertToolResult(profileDir, {
|
||||
sessionId: 'recent-cli',
|
||||
timestamp: now - 35,
|
||||
content: '[skill_view] name=github-pr-workflow (22,106 chars)',
|
||||
})
|
||||
insertAssistantToolCalls(profileDir, 'recent-cli', now - 30, [
|
||||
{ function: { name: 'skill_view', arguments: JSON.stringify({ name: 'planned-but-not-counted' }) } },
|
||||
])
|
||||
insertToolResult(profileDir, {
|
||||
sessionId: 'recent-cli',
|
||||
timestamp: now - 25,
|
||||
toolName: 'terminal',
|
||||
content: 'noop',
|
||||
})
|
||||
|
||||
insertSession(profileDir, { id: 'web-local-copy', source: 'api_server', started_at: now - 30 })
|
||||
insertToolResult(profileDir, {
|
||||
sessionId: 'web-local-copy',
|
||||
timestamp: now - 20,
|
||||
content: '[skill_view] name=ignored-local-copy (1 chars)',
|
||||
})
|
||||
|
||||
insertSession(profileDir, { id: 'old-cli', source: 'cli', started_at: now - 10 * 86400 })
|
||||
insertToolResult(profileDir, {
|
||||
sessionId: 'old-cli',
|
||||
timestamp: now - 10 * 86400,
|
||||
content: '[skill_view] name=old-skill (1 chars)',
|
||||
})
|
||||
|
||||
insertSession(profileDir, { id: 'long-running-cli', source: 'cli', started_at: now - 10 * 86400 })
|
||||
insertToolResult(profileDir, {
|
||||
sessionId: 'long-running-cli',
|
||||
timestamp: now - 40,
|
||||
content: '[skill_view] name=late-session-skill (1 chars)',
|
||||
})
|
||||
|
||||
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
|
||||
const result = await mod.getSkillUsageStatsFromDb(7, now)
|
||||
|
||||
expect(result).toEqual({
|
||||
period_days: 7,
|
||||
summary: {
|
||||
total_skill_loads: 4,
|
||||
total_skill_edits: 1,
|
||||
total_skill_actions: 5,
|
||||
distinct_skills_used: 3,
|
||||
},
|
||||
by_day: [
|
||||
{
|
||||
date: '2023-11-14',
|
||||
view_count: 4,
|
||||
manage_count: 1,
|
||||
total_count: 5,
|
||||
skills: [
|
||||
{ skill: 'hermes-agent', view_count: 2, manage_count: 1, total_count: 3 },
|
||||
{ skill: 'github-pr-workflow', view_count: 1, manage_count: 0, total_count: 1 },
|
||||
{ skill: 'late-session-skill', view_count: 1, manage_count: 0, total_count: 1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
top_skills: [
|
||||
{
|
||||
skill: 'hermes-agent',
|
||||
view_count: 2,
|
||||
manage_count: 1,
|
||||
total_count: 3,
|
||||
percentage: 60,
|
||||
last_used_at: now - 40,
|
||||
},
|
||||
{
|
||||
skill: 'github-pr-workflow',
|
||||
view_count: 1,
|
||||
manage_count: 0,
|
||||
total_count: 1,
|
||||
percentage: 20,
|
||||
last_used_at: now - 35,
|
||||
},
|
||||
{
|
||||
skill: 'late-session-skill',
|
||||
view_count: 1,
|
||||
manage_count: 0,
|
||||
total_count: 1,
|
||||
percentage: 20,
|
||||
last_used_at: now - 40,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user