fix: SkillsUsage 页面样式修复与 API server skill usage 统计 (#698)
* Reapply "feat: 新增 Skills Usage 监控统计与图表 (#668)" (#670)
This reverts commit 91de3b12a1.
* fix: count API-server skill usage
* fix: align SkillsUsageView header with other pages and update sidebar icon
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Zhicheng Han <zhicheng.han@mathematik.uni-goettingen.de>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
||||
safeReadFile, extractDescription, listFilesRecursive, getHermesDir,
|
||||
} from '../../services/config-helpers'
|
||||
import { pinSkill } from '../../services/hermes/hermes-cli'
|
||||
import { getSkillUsageStatsFromDb } from '../../db/hermes/sessions-db'
|
||||
|
||||
/** Read bundled manifest as a name→hash map from ~/.hermes/skills/.bundled_manifest */
|
||||
function readBundledManifest(manifestContent: string | null): Map<string, string> {
|
||||
@@ -239,6 +240,18 @@ export async function list(ctx: any) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function usageStats(ctx: any) {
|
||||
const rawDays = parseInt(String(ctx.query?.days ?? '7'), 10)
|
||||
const days = Number.isFinite(rawDays) && rawDays > 0 ? Math.min(rawDays, 365) : 7
|
||||
|
||||
try {
|
||||
ctx.body = await getSkillUsageStatsFromDb(days)
|
||||
} catch (err: any) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: `Failed to read skill usage stats: ${err.message}` }
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggle(ctx: any) {
|
||||
const { name, enabled } = ctx.request.body as { name?: string; enabled?: boolean }
|
||||
if (!name || typeof enabled !== 'boolean') {
|
||||
|
||||
@@ -789,6 +789,42 @@ export interface HermesUsageStats extends LocalUsageStats {
|
||||
total_api_calls: number
|
||||
}
|
||||
|
||||
export interface HermesSkillUsageRow {
|
||||
skill: string
|
||||
view_count: number
|
||||
manage_count: number
|
||||
total_count: number
|
||||
percentage: number
|
||||
last_used_at: number | null
|
||||
}
|
||||
|
||||
export interface HermesSkillUsageDailySkillRow {
|
||||
skill: string
|
||||
view_count: number
|
||||
manage_count: number
|
||||
total_count: number
|
||||
}
|
||||
|
||||
export interface HermesSkillUsageDailyRow {
|
||||
date: string
|
||||
view_count: number
|
||||
manage_count: number
|
||||
total_count: number
|
||||
skills: HermesSkillUsageDailySkillRow[]
|
||||
}
|
||||
|
||||
export interface HermesSkillUsageStats {
|
||||
period_days: number
|
||||
summary: {
|
||||
total_skill_loads: number
|
||||
total_skill_edits: number
|
||||
total_skill_actions: number
|
||||
distinct_skills_used: number
|
||||
}
|
||||
by_day: HermesSkillUsageDailyRow[]
|
||||
top_skills: HermesSkillUsageRow[]
|
||||
}
|
||||
|
||||
function tableHasColumn(
|
||||
db: { prepare: (sql: string) => { all: (...params: any[]) => Record<string, unknown>[] } },
|
||||
tableName: string,
|
||||
@@ -798,6 +834,270 @@ function tableHasColumn(
|
||||
return columns.some(column => String(column.name || '') === columnName)
|
||||
}
|
||||
|
||||
function parseJsonObject(value: unknown): Record<string, unknown> | null {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) return value as Record<string, unknown>
|
||||
if (typeof value !== 'string') return null
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
type SkillUsageAction = 'view' | 'manage'
|
||||
|
||||
interface RawSkillUsageEvent {
|
||||
skill: string
|
||||
action: SkillUsageAction
|
||||
timestamp: number | null
|
||||
}
|
||||
|
||||
function extractSkillNameFromViewContent(content: string): string {
|
||||
const match = content.match(/^\[skill_view\]\s+name=(.+?)(?:\s+\(|\s*$)/)
|
||||
if (match?.[1]) return match[1].trim()
|
||||
|
||||
const parsed = parseJsonObject(content)
|
||||
return typeof parsed?.name === 'string' ? parsed.name.trim() : ''
|
||||
}
|
||||
|
||||
function extractSkillNameFromManageContent(content: string): string {
|
||||
const bracketMatch = content.match(/^\[skill_manage\]\s+name=(.+?)(?:\s+|\(|$)/)
|
||||
if (bracketMatch?.[1]) return bracketMatch[1].trim()
|
||||
|
||||
const parsed = parseJsonObject(content)
|
||||
const message = typeof parsed?.message === 'string' ? parsed.message : content
|
||||
const quotedMatch = message.match(/skill ['"]([^'"]+)['"]/i)
|
||||
if (quotedMatch?.[1]) return quotedMatch[1].trim()
|
||||
|
||||
const namedMatch = message.match(/\bname=([^\s)]+)/i)
|
||||
return namedMatch?.[1]?.trim() || ''
|
||||
}
|
||||
|
||||
function extractSkillToolCall(row: Record<string, unknown>): { action: SkillUsageAction; skill: string } | null {
|
||||
const toolCallId = typeof row.tool_call_id === 'string' ? row.tool_call_id : ''
|
||||
const rawToolCalls = typeof row.assistant_tool_calls === 'string' ? row.assistant_tool_calls : ''
|
||||
if (!toolCallId || !rawToolCalls) return null
|
||||
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(rawToolCalls)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const calls = Array.isArray(parsed) ? parsed : [parsed]
|
||||
for (const call of calls) {
|
||||
if (!call || typeof call !== 'object') continue
|
||||
const record = call as Record<string, unknown>
|
||||
const functionRecord = record.function && typeof record.function === 'object'
|
||||
? record.function as Record<string, unknown>
|
||||
: {}
|
||||
const ids = [record.id, record.call_id, record.tool_call_id, functionRecord.call_id]
|
||||
.filter((value): value is string => typeof value === 'string')
|
||||
if (!ids.includes(toolCallId)) continue
|
||||
|
||||
const name = typeof functionRecord.name === 'string'
|
||||
? functionRecord.name
|
||||
: typeof record.name === 'string'
|
||||
? record.name
|
||||
: ''
|
||||
const action: SkillUsageAction | null = name === 'skill_view'
|
||||
? 'view'
|
||||
: name === 'skill_manage'
|
||||
? 'manage'
|
||||
: null
|
||||
if (!action) return null
|
||||
|
||||
const args = parseJsonObject(functionRecord.arguments ?? record.arguments)
|
||||
const skill = typeof args?.name === 'string' ? args.name.trim() : ''
|
||||
return { action, skill }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function mapSkillUsageEvent(row: Record<string, unknown>): RawSkillUsageEvent | null {
|
||||
const content = typeof row.content === 'string' ? row.content : ''
|
||||
const toolName = typeof row.tool_name === 'string' ? row.tool_name : ''
|
||||
const toolCall = extractSkillToolCall(row)
|
||||
const action: SkillUsageAction | null = toolName === 'skill_view' || content.startsWith('[skill_view]')
|
||||
? 'view'
|
||||
: toolName === 'skill_manage' || content.startsWith('[skill_manage]')
|
||||
? 'manage'
|
||||
: toolCall?.action ?? null
|
||||
|
||||
if (!action) return null
|
||||
|
||||
const skill = toolCall?.skill || (action === 'view'
|
||||
? extractSkillNameFromViewContent(content)
|
||||
: extractSkillNameFromManageContent(content))
|
||||
|
||||
if (!skill) return null
|
||||
|
||||
return {
|
||||
skill,
|
||||
action,
|
||||
timestamp: normalizeNullableNumber(row.timestamp),
|
||||
}
|
||||
}
|
||||
|
||||
function formatUnixDate(timestamp: number | null): string {
|
||||
if (timestamp == null) return ''
|
||||
return new Date(timestamp * 1000).toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
export async function getSkillUsageStatsFromDb(
|
||||
days = 7,
|
||||
nowSeconds = Math.floor(Date.now() / 1000),
|
||||
): Promise<HermesSkillUsageStats> {
|
||||
const normalizedDays = Number.isFinite(days) ? days : 7
|
||||
const safeDays = Math.max(1, Math.floor(normalizedDays))
|
||||
const since = nowSeconds - safeDays * 24 * 60 * 60
|
||||
const db = await openSessionDb()
|
||||
|
||||
try {
|
||||
const hasStartedIndex = db.prepare("PRAGMA index_list(sessions)").all()
|
||||
.some(index => String(index.name || '') === 'idx_sessions_started')
|
||||
const hasMessagesIndex = db.prepare("PRAGMA index_list(messages)").all()
|
||||
.some(index => String(index.name || '') === 'idx_messages_session')
|
||||
const sessionsTable = hasStartedIndex ? 'sessions s INDEXED BY idx_sessions_started' : 'sessions s'
|
||||
const messagesTable = hasMessagesIndex ? 'messages m INDEXED BY idx_messages_session' : 'messages m'
|
||||
const toolPredicate = `
|
||||
m.role = 'tool'
|
||||
AND (
|
||||
m.tool_name IN ('skill_view', 'skill_manage')
|
||||
OR m.content LIKE '[skill_view]%'
|
||||
OR m.content LIKE '[skill_manage]%'
|
||||
OR m.tool_call_id IS NOT NULL
|
||||
)
|
||||
`
|
||||
const recentRows = db.prepare(`
|
||||
SELECT
|
||||
m.tool_name,
|
||||
m.tool_call_id,
|
||||
SUBSTR(m.content, 1, 300) AS content,
|
||||
COALESCE(m.timestamp, s.started_at) AS timestamp,
|
||||
(
|
||||
SELECT a.tool_calls
|
||||
FROM messages a
|
||||
WHERE a.session_id = m.session_id
|
||||
AND a.role = 'assistant'
|
||||
AND m.tool_call_id IS NOT NULL
|
||||
AND a.tool_calls LIKE '%' || m.tool_call_id || '%'
|
||||
ORDER BY a.timestamp DESC
|
||||
LIMIT 1
|
||||
) AS assistant_tool_calls
|
||||
FROM ${sessionsTable}
|
||||
JOIN ${messagesTable} ON m.session_id = s.id
|
||||
WHERE s.started_at > ?
|
||||
AND ${toolPredicate}
|
||||
`).all(since) as Record<string, unknown>[]
|
||||
const lateRows = db.prepare(`
|
||||
SELECT
|
||||
m.tool_name,
|
||||
m.tool_call_id,
|
||||
SUBSTR(m.content, 1, 300) AS content,
|
||||
COALESCE(m.timestamp, s.started_at) AS timestamp,
|
||||
(
|
||||
SELECT a.tool_calls
|
||||
FROM messages a
|
||||
WHERE a.session_id = m.session_id
|
||||
AND a.role = 'assistant'
|
||||
AND m.tool_call_id IS NOT NULL
|
||||
AND a.tool_calls LIKE '%' || m.tool_call_id || '%'
|
||||
ORDER BY a.timestamp DESC
|
||||
LIMIT 1
|
||||
) AS assistant_tool_calls
|
||||
FROM ${sessionsTable}
|
||||
JOIN ${messagesTable} ON m.session_id = s.id
|
||||
WHERE s.started_at <= ?
|
||||
AND COALESCE(m.timestamp, s.started_at) > ?
|
||||
AND ${toolPredicate}
|
||||
`).all(since, since) as Record<string, unknown>[]
|
||||
|
||||
const skillMap = new Map<string, { skill: string; view_count: number; manage_count: number; last_used_at: number | null }>()
|
||||
const dayMap = new Map<string, { date: string; view_count: number; manage_count: number }>()
|
||||
const daySkillMap = new Map<string, Map<string, { skill: string; view_count: number; manage_count: number }>>()
|
||||
|
||||
for (const row of [...recentRows, ...lateRows]) {
|
||||
const event = mapSkillUsageEvent(row)
|
||||
if (!event) continue
|
||||
|
||||
const entry = skillMap.get(event.skill) || {
|
||||
skill: event.skill,
|
||||
view_count: 0,
|
||||
manage_count: 0,
|
||||
last_used_at: null,
|
||||
}
|
||||
if (event.action === 'view') entry.view_count += 1
|
||||
else entry.manage_count += 1
|
||||
if (event.timestamp != null && (entry.last_used_at == null || event.timestamp > entry.last_used_at)) {
|
||||
entry.last_used_at = event.timestamp
|
||||
}
|
||||
skillMap.set(event.skill, entry)
|
||||
|
||||
const date = formatUnixDate(event.timestamp)
|
||||
if (date) {
|
||||
const day = dayMap.get(date) || { date, view_count: 0, manage_count: 0 }
|
||||
if (event.action === 'view') day.view_count += 1
|
||||
else day.manage_count += 1
|
||||
dayMap.set(date, day)
|
||||
|
||||
const skillsForDay = daySkillMap.get(date) || new Map<string, { skill: string; view_count: number; manage_count: number }>()
|
||||
const skillForDay = skillsForDay.get(event.skill) || { skill: event.skill, view_count: 0, manage_count: 0 }
|
||||
if (event.action === 'view') skillForDay.view_count += 1
|
||||
else skillForDay.manage_count += 1
|
||||
skillsForDay.set(event.skill, skillForDay)
|
||||
daySkillMap.set(date, skillsForDay)
|
||||
}
|
||||
}
|
||||
|
||||
const totalLoads = [...skillMap.values()].reduce((sum, skill) => sum + skill.view_count, 0)
|
||||
const totalEdits = [...skillMap.values()].reduce((sum, skill) => sum + skill.manage_count, 0)
|
||||
const totalActions = totalLoads + totalEdits
|
||||
const byDay = [...dayMap.values()]
|
||||
.map(day => ({
|
||||
...day,
|
||||
total_count: day.view_count + day.manage_count,
|
||||
skills: [...(daySkillMap.get(day.date)?.values() || [])]
|
||||
.map(skill => ({
|
||||
...skill,
|
||||
total_count: skill.view_count + skill.manage_count,
|
||||
}))
|
||||
.sort((a, b) => b.total_count - a.total_count || a.skill.localeCompare(b.skill)),
|
||||
}))
|
||||
.sort((a, b) => a.date.localeCompare(b.date))
|
||||
const topSkills = [...skillMap.values()]
|
||||
.map(skill => ({
|
||||
...skill,
|
||||
total_count: skill.view_count + skill.manage_count,
|
||||
percentage: totalActions > 0 ? (skill.view_count + skill.manage_count) / totalActions * 100 : 0,
|
||||
}))
|
||||
.sort((a, b) =>
|
||||
b.total_count - a.total_count ||
|
||||
b.view_count - a.view_count ||
|
||||
b.manage_count - a.manage_count ||
|
||||
(b.last_used_at || 0) - (a.last_used_at || 0) ||
|
||||
a.skill.localeCompare(b.skill),
|
||||
)
|
||||
|
||||
return {
|
||||
period_days: safeDays,
|
||||
summary: {
|
||||
total_skill_loads: totalLoads,
|
||||
total_skill_edits: totalEdits,
|
||||
total_skill_actions: totalActions,
|
||||
distinct_skills_used: skillMap.size,
|
||||
},
|
||||
by_day: byDay,
|
||||
top_skills: topSkills,
|
||||
}
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUsageStatsFromDb(
|
||||
days = 30,
|
||||
nowSeconds = Math.floor(Date.now() / 1000),
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as ctrl from '../../controllers/hermes/skills'
|
||||
export const skillRoutes = new Router()
|
||||
|
||||
skillRoutes.get('/api/hermes/skills', ctrl.list)
|
||||
skillRoutes.get('/api/hermes/skills/usage/stats', ctrl.usageStats)
|
||||
skillRoutes.put('/api/hermes/skills/toggle', ctrl.toggle)
|
||||
skillRoutes.put('/api/hermes/skills/pin', ctrl.pin_)
|
||||
skillRoutes.get('/api/hermes/skills/:category/:skill/files', ctrl.listFiles)
|
||||
|
||||
Reference in New Issue
Block a user