feat: add session info panel with message count, context usage, cache stats, and skills

Add GET /bots/:bot_id/sessions/:session_id/info API endpoint that returns
per-session message count, latest input token usage with model context window,
aggregated KV cache hit rate, and skills invoked via use_skill tool calls.
Frontend Info tab in the right sidebar now displays this data in a compact
key-value layout with a context usage progress bar and clickable skill links.
This commit is contained in:
Acbox
2026-04-02 03:17:28 +08:00
parent b308c27f74
commit b3c783fb0b
16 changed files with 898 additions and 11 deletions
+11 -1
View File
@@ -202,7 +202,17 @@
"scheduleDescription": "Description",
"schedulePattern": "Cron Pattern",
"scheduleMaxCalls": "Max Calls",
"viewSchedule": "View Schedule"
"viewSchedule": "View Schedule",
"infoMessages": "Messages",
"infoContextUsage": "Context Usage",
"infoContextTokens": "{used} / {window}",
"infoContextTokensNoWindow": "{used} / --",
"infoCacheHitRate": "Cache Hit Rate",
"infoCacheRead": "Cache Read",
"infoCacheWrite": "Cache Write",
"infoSkills": "Skills",
"infoNoSkills": "No skills used in this session",
"infoNoData": "No data available"
},
"models": {
"title": "Models",
+11 -1
View File
@@ -198,7 +198,17 @@
"scheduleDescription": "描述",
"schedulePattern": "Cron 表达式",
"scheduleMaxCalls": "最大调用次数",
"viewSchedule": "查看定时任务"
"viewSchedule": "查看定时任务",
"infoMessages": "消息数",
"infoContextUsage": "上下文使用率",
"infoContextTokens": "{used} / {window}",
"infoContextTokensNoWindow": "{used} / --",
"infoCacheHitRate": "Cache 命中率",
"infoCacheRead": "Cache 读取",
"infoCacheWrite": "Cache 写入",
"infoSkills": "Skills",
"infoNoSkills": "此会话未使用任何 Skill",
"infoNoData": "暂无数据"
},
"models": {
"title": "模型",
@@ -290,11 +290,12 @@
</div>
<div
v-if="activeRightTab === 'info'"
class="absolute inset-0 flex items-center justify-center"
class="absolute inset-0"
>
<p class="text-sm text-muted-foreground">
Info
</p>
<SessionInfoPanel
:visible="activeRightTab === 'info'"
:override-model-id="overrideModelId"
/>
</div>
</div>
</div>
@@ -376,6 +377,7 @@ import FileManager from '@/components/file-manager/index.vue'
import TerminalComponent from '@/components/terminal/index.vue'
import ModelOptions from '@/pages/bots/components/model-options.vue'
import ReasoningEffortSelect from '@/pages/bots/components/reasoning-effort-select.vue'
import SessionInfoPanel from './session-info-panel.vue'
import { EFFORT_LABELS, EFFORT_OPACITY } from '@/pages/bots/components/reasoning-effort'
import { useMediaGallery } from '../composables/useMediaGallery'
import { openInFileManagerKey } from '../composables/useFileManagerProvider'
@@ -0,0 +1,168 @@
<template>
<ScrollArea class="h-full">
<div class="px-4 py-3">
<!-- No session -->
<div
v-if="!sessionId"
class="flex items-center justify-center h-40"
>
<p class="text-xs text-muted-foreground">
{{ $t('chat.infoNoData') }}
</p>
</div>
<template v-else>
<!-- Key-value rows -->
<div class="divide-y divide-border text-xs">
<!-- Messages -->
<div class="flex items-center justify-between py-2">
<span class="text-muted-foreground">{{ $t('chat.infoMessages') }}</span>
<span class="font-medium text-foreground tabular-nums">{{ info?.message_count ?? '--' }}</span>
</div>
<!-- Context Usage -->
<div class="py-2 space-y-1.5">
<div class="flex items-center justify-between">
<span class="text-muted-foreground">{{ $t('chat.infoContextUsage') }}</span>
<span class="font-medium text-foreground tabular-nums">
<template v-if="contextWindow != null">
{{ formatTokenCount(usedTokens) }} / {{ formatTokenCount(contextWindow) }}
<span class="text-muted-foreground font-normal ml-1">({{ contextPercent.toFixed(1) }}%)</span>
</template>
<template v-else>
{{ formatTokenCount(usedTokens) }} / --
</template>
</span>
</div>
<div
v-if="contextWindow != null && contextWindow > 0"
class="w-full h-1 rounded-full bg-accent overflow-hidden"
>
<div
class="h-full rounded-full transition-all"
:class="contextBarColor"
:style="{ width: `${Math.min(contextPercent, 100)}%` }"
/>
</div>
</div>
<!-- Cache Hit Rate -->
<div class="flex items-center justify-between py-2">
<span class="text-muted-foreground">{{ $t('chat.infoCacheHitRate') }}</span>
<span class="font-medium text-foreground tabular-nums">{{ cacheHitRate }}%</span>
</div>
<!-- Cache Read -->
<div class="flex items-center justify-between py-2">
<span class="text-muted-foreground">{{ $t('chat.infoCacheRead') }}</span>
<span class="font-medium text-foreground tabular-nums">{{ formatTokenCount(info?.cache_stats?.cache_read_tokens ?? 0) }}</span>
</div>
<!-- Cache Write -->
<div class="flex items-center justify-between py-2">
<span class="text-muted-foreground">{{ $t('chat.infoCacheWrite') }}</span>
<span class="font-medium text-foreground tabular-nums">{{ formatTokenCount(info?.cache_stats?.cache_write_tokens ?? 0) }}</span>
</div>
</div>
<!-- Skills -->
<div class="mt-3">
<p class="text-[11px] font-medium text-muted-foreground uppercase tracking-wider mb-1.5">
{{ $t('chat.infoSkills') }}
</p>
<div
v-if="!skills.length"
class="text-xs text-muted-foreground"
>
{{ $t('chat.infoNoSkills') }}
</div>
<div
v-else
class="space-y-0.5"
>
<button
v-for="skill in skills"
:key="skill"
type="button"
class="flex items-center gap-1.5 w-full px-2 py-1 rounded-md text-xs text-foreground hover:bg-accent transition-colors text-left"
@click="openSkillFile(skill)"
>
<Sparkles class="size-3 text-muted-foreground shrink-0" />
<span class="truncate">{{ skill }}</span>
<ExternalLink class="size-3 text-muted-foreground shrink-0 ml-auto" />
</button>
</div>
</div>
</template>
</div>
</ScrollArea>
</template>
<script setup lang="ts">
import { computed, inject } from 'vue'
import { storeToRefs } from 'pinia'
import { useQuery } from '@pinia/colada'
import { Sparkles, ExternalLink } from 'lucide-vue-next'
import { ScrollArea } from '@memohai/ui'
import { getBotsByBotIdSessionsBySessionIdInfo } from '@memohai/sdk'
import type { HandlersSessionInfoResponse } from '@memohai/sdk'
import { useChatStore } from '@/store/chat-list'
import { openInFileManagerKey } from '../composables/useFileManagerProvider'
const props = defineProps<{
visible: boolean
overrideModelId?: string
}>()
const chatStore = useChatStore()
const { currentBotId, sessionId } = storeToRefs(chatStore)
const openInFileManager = inject(openInFileManagerKey, undefined)
const { data: info } = useQuery({
key: () => ['session-info', currentBotId.value ?? '', sessionId.value ?? '', props.overrideModelId ?? ''],
query: async () => {
const { data } = await getBotsByBotIdSessionsBySessionIdInfo({
path: {
bot_id: currentBotId.value!,
session_id: sessionId.value!,
},
query: {
model_id: props.overrideModelId || undefined,
},
throwOnError: true,
})
return data as HandlersSessionInfoResponse
},
enabled: () => !!currentBotId.value && !!sessionId.value && props.visible,
refetchOnWindowFocus: false,
})
const usedTokens = computed(() => info.value?.context_usage?.used_tokens ?? 0)
const contextWindow = computed(() => info.value?.context_usage?.context_window ?? null)
const contextPercent = computed(() => {
if (contextWindow.value == null || contextWindow.value <= 0) return 0
return (usedTokens.value / contextWindow.value) * 100
})
const contextBarColor = computed(() => {
if (contextPercent.value >= 90) return 'bg-destructive'
if (contextPercent.value >= 70) return 'bg-amber-500'
return 'bg-foreground'
})
const cacheHitRate = computed(() => {
const rate = info.value?.cache_stats?.cache_hit_rate ?? 0
return rate.toFixed(1)
})
const skills = computed(() => info.value?.skills ?? [])
function formatTokenCount(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
return String(n)
}
function openSkillFile(skillName: string) {
openInFileManager?.(`/data/skills/${skillName}/SKILL.md`, false)
}
</script>