mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
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:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user