feat: add usage statistics page, CLI improvements, and UI enhancements

- Add Usage Stats page with token breakdown, model distribution, and 30-day trend
- Pass through cache/cost token fields in BFF (cache_read/write_tokens, reasoning_tokens, actual_cost_usd)
- Add CLI commands: -v/--version, -h/--help, update/upgrade with auto-restart
- Auto-open browser on startup, auto-kill port conflicts (cross-platform)
- Validate all api_server config fields on startup (enabled, host, port, key, cors_origins)
- Add streaming thinking video animation with tool calls panel
- Add context token usage display (used / total) in chat header
- Sidebar: white logo area with shadow, dance video beside logo (canvas seamless loop)
- Fix sidebar nav scroll (app-main overflow-y: auto)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ekko
2026-04-14 14:48:58 +08:00
co-authored by Claude Opus 4.6
parent f8fc64ff5c
commit 9dd5fca9f9
24 changed files with 1433 additions and 137 deletions
+61
View File
@@ -109,6 +109,57 @@ const activeSessionTitle = computed(() =>
chatStore.activeSession?.title || t('chat.newChat'),
)
const totalTokens = computed(() => {
const input = chatStore.activeSession?.inputTokens ?? 0
const output = chatStore.activeSession?.outputTokens ?? 0
return input + output
})
const MODEL_CONTEXT: Record<string, number> = {
'claude-opus-4': 200000,
'claude-sonnet-4': 200000,
'claude-haiku-4': 200000,
'claude-3.5-sonnet': 200000,
'claude-3.5-haiku': 200000,
'claude-3-opus': 200000,
'claude-3-sonnet': 200000,
'claude-3-haiku': 200000,
'gpt-4o': 128000,
'gpt-4o-mini': 128000,
'gpt-4-turbo': 128000,
'gpt-4': 8192,
'gpt-3.5-turbo': 16385,
'o1': 200000,
'o1-mini': 128000,
'o3': 200000,
'o3-mini': 200000,
'o4-mini': 200000,
'deepseek-chat': 65536,
'deepseek-reasoner': 65536,
'gemini-2.5-pro': 1000000,
'gemini-2.5-flash': 1000000,
'gemini-2.0-flash': 1000000,
'glm-4-plus': 128000,
'glm-4': 128000,
'qwen-max': 128000,
'qwen-plus': 128000,
'qwen-turbo': 128000,
}
const contextWindow = computed(() => {
const model = chatStore.activeSession?.model || ''
for (const [key, val] of Object.entries(MODEL_CONTEXT)) {
if (model.includes(key)) return val
}
return null
})
function formatTokens(n: number): string {
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'
if (n >= 1000) return (n / 1000).toFixed(1) + 'k'
return String(n)
}
const activeSessionSource = computed(() =>
chatStore.activeSession?.source || '',
)
@@ -310,6 +361,9 @@ async function handleRenameConfirm() {
</header>
<MessageList />
<div v-if="contextWindow !== null" class="context-info">
<span>{{ formatTokens(totalTokens) }} / {{ formatTokens(contextWindow) }}</span>
</div>
<ChatInput />
</div>
</div>
@@ -542,4 +596,11 @@ async function handleRenameConfirm() {
gap: 4px;
flex-shrink: 0;
}
.context-info {
padding: 0 20px 4px;
font-size: 11px;
color: $text-muted;
flex-shrink: 0;
}
</style>
+137 -54
View File
@@ -1,84 +1,133 @@
<script setup lang="ts">
import type { Message } from '@/stores/chat';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import MarkdownRenderer from './MarkdownRenderer.vue';
import type { Message } from "@/stores/chat";
import { computed, ref } from "vue";
import { useI18n } from "vue-i18n";
import MarkdownRenderer from "./MarkdownRenderer.vue";
const props = defineProps<{ message: Message }>()
const { t } = useI18n()
const props = defineProps<{ message: Message }>();
const { t } = useI18n();
const isSystem = computed(() => props.message.role === 'system')
const toolExpanded = ref(false)
const isSystem = computed(() => props.message.role === "system");
const toolExpanded = ref(false);
const timeStr = computed(() => {
const d = new Date(props.message.timestamp)
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
})
const d = new Date(props.message.timestamp);
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
});
function isImage(type: string): boolean {
return type.startsWith('image/')
return type.startsWith("image/");
}
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
}
const hasAttachments = computed(() => (props.message.attachments?.length ?? 0) > 0)
const hasAttachments = computed(
() => (props.message.attachments?.length ?? 0) > 0,
);
const hasToolDetails = computed(() => !!(props.message.toolArgs || props.message.toolResult))
const hasToolDetails = computed(
() => !!(props.message.toolArgs || props.message.toolResult),
);
const formattedToolArgs = computed(() => {
if (!props.message.toolArgs) return ''
if (!props.message.toolArgs) return "";
try {
return JSON.stringify(JSON.parse(props.message.toolArgs), null, 2)
return JSON.stringify(JSON.parse(props.message.toolArgs), null, 2);
} catch {
return props.message.toolArgs
return props.message.toolArgs;
}
})
});
const formattedToolResult = computed(() => {
if (!props.message.toolResult) return ''
if (!props.message.toolResult) return "";
try {
const parsed = JSON.parse(props.message.toolResult)
const str = JSON.stringify(parsed, null, 2)
const parsed = JSON.parse(props.message.toolResult);
const str = JSON.stringify(parsed, null, 2);
// Truncate very long output
if (str.length > 2000) return str.slice(0, 2000) + '\n' + t('chat.truncated')
return str
if (str.length > 2000)
return str.slice(0, 2000) + "\n" + t("chat.truncated");
return str;
} catch {
const raw = props.message.toolResult
if (raw.length > 2000) return raw.slice(0, 2000) + '\n' + t('chat.truncated')
return raw
const raw = props.message.toolResult;
if (raw.length > 2000)
return raw.slice(0, 2000) + "\n" + t("chat.truncated");
return raw;
}
})
});
</script>
<template>
<div class="message" :class="[message.role]">
<template v-if="message.role === 'tool'">
<div class="tool-line" :class="{ expandable: hasToolDetails }" @click="hasToolDetails && (toolExpanded = !toolExpanded)">
<svg v-if="hasToolDetails" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="tool-chevron" :class="{ rotated: toolExpanded }"><polyline points="9 18 15 12 9 6"/></svg>
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="tool-icon"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
<div
class="tool-line"
:class="{ expandable: hasToolDetails }"
@click="hasToolDetails && (toolExpanded = !toolExpanded)"
>
<svg
v-if="hasToolDetails"
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="tool-chevron"
:class="{ rotated: toolExpanded }"
>
<polyline points="9 18 15 12 9 6" />
</svg>
<svg
v-else
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="tool-icon"
>
<path
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
/>
</svg>
<span class="tool-name">{{ message.toolName }}</span>
<span v-if="message.toolPreview && !toolExpanded" class="tool-preview">{{ message.toolPreview }}</span>
<span v-if="message.toolStatus === 'running'" class="tool-spinner"></span>
<span v-if="message.toolStatus === 'error'" class="tool-error-badge">{{ t('chat.error') }}</span>
<span
v-if="message.toolPreview && !toolExpanded"
class="tool-preview"
>{{ message.toolPreview }}</span
>
<span
v-if="message.toolStatus === 'running'"
class="tool-spinner"
></span>
<span v-if="message.toolStatus === 'error'" class="tool-error-badge">{{
t("chat.error")
}}</span>
</div>
<div v-if="toolExpanded && hasToolDetails" class="tool-details">
<div v-if="formattedToolArgs" class="tool-detail-section">
<div class="tool-detail-label">{{ t('chat.arguments') }}</div>
<div class="tool-detail-label">{{ t("chat.arguments") }}</div>
<pre class="tool-detail-code">{{ formattedToolArgs }}</pre>
</div>
<div v-if="formattedToolResult" class="tool-detail-section">
<div class="tool-detail-label">{{ t('chat.result') }}</div>
<div class="tool-detail-label">{{ t("chat.result") }}</div>
<pre class="tool-detail-code">{{ formattedToolResult }}</pre>
</div>
</div>
</template>
<template v-else>
<div class="msg-body">
<img v-if="message.role === 'assistant'" src="/assets/logo.png" alt="Hermes" class="msg-avatar" />
<img
v-if="message.role === 'assistant'"
src="/assets/logo.png"
alt="Hermes"
class="msg-avatar"
/>
<div class="msg-content" :class="message.role">
<div class="message-bubble" :class="{ system: isSystem }">
<div v-if="hasAttachments" class="msg-attachments">
@@ -89,22 +138,41 @@ const formattedToolResult = computed(() => {
:class="{ image: isImage(att.type) }"
>
<template v-if="isImage(att.type) && att.url">
<img :src="att.url" :alt="att.name" class="msg-attachment-thumb" />
<img
:src="att.url"
:alt="att.name"
class="msg-attachment-thumb"
/>
</template>
<template v-else>
<div class="msg-attachment-file">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
/>
<polyline points="14 2 14 8 20 8" />
</svg>
<span class="att-name">{{ att.name }}</span>
<span class="att-size">{{ formatSize(att.size) }}</span>
</div>
</template>
</div>
</div>
<MarkdownRenderer v-if="message.content" :content="message.content" />
<span v-if="message.isStreaming" class="streaming-cursor"></span>
<div v-if="message.isStreaming && !message.content" class="streaming-dots">
<MarkdownRenderer
v-if="message.content"
:content="message.content"
/>
<span v-if="message.isStreaming && !message.content" class="streaming-dots">
<span></span><span></span><span></span>
</div>
</span>
</div>
<div class="message-time">{{ timeStr }}</div>
</div>
@@ -114,7 +182,7 @@ const formattedToolResult = computed(() => {
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
@use "@/styles/variables" as *;
.message {
display: flex;
@@ -147,9 +215,8 @@ const formattedToolResult = computed(() => {
}
.msg-avatar {
width: 28px;
height: 28px;
border-radius: 4px;
width: 40px;
height: 40px;
flex-shrink: 0;
margin-top: 2px;
}
@@ -345,7 +412,9 @@ const formattedToolResult = computed(() => {
}
@keyframes spin {
to { transform: rotate(360deg); }
to {
transform: rotate(360deg);
}
}
.streaming-cursor {
@@ -376,12 +445,26 @@ const formattedToolResult = computed(() => {
}
@keyframes blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
0%,
50% {
opacity: 1;
}
51%,
100% {
opacity: 0;
}
}
@keyframes pulse {
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
40% { opacity: 1; transform: scale(1); }
0%,
80%,
100% {
opacity: 0.3;
transform: scale(0.8);
}
40% {
opacity: 1;
transform: scale(1);
}
}
</style>
+128 -20
View File
@@ -1,13 +1,30 @@
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import { ref, computed, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import MessageItem from './MessageItem.vue'
import { useChatStore } from '@/stores/chat'
import thinkingVideo from '@/assets/thinking.mp4'
const chatStore = useChatStore()
const { t } = useI18n()
const listRef = ref<HTMLElement>()
const displayMessages = computed(() =>
chatStore.messages.filter(m => m.role !== 'tool'),
)
const currentToolCalls = computed(() => {
const msgs = chatStore.messages
// Find the last user message index
let lastUserIdx = -1
for (let i = msgs.length - 1; i >= 0; i--) {
if (msgs[i].role === 'user') { lastUserIdx = i; break }
}
// Only tool calls after the last user message, newest on top
const tools = msgs.filter((m, i) => m.role === 'tool' && i > lastUserIdx)
return [...tools].reverse()
})
function scrollToBottom() {
nextTick(() => {
if (listRef.value) {
@@ -19,6 +36,7 @@ function scrollToBottom() {
watch(() => chatStore.messages.length, scrollToBottom)
watch(() => chatStore.messages[chatStore.messages.length - 1]?.content, scrollToBottom)
watch(() => chatStore.isStreaming, (v) => { if (v) scrollToBottom() })
watch(currentToolCalls, scrollToBottom)
</script>
<template>
@@ -28,13 +46,38 @@ watch(() => chatStore.isStreaming, (v) => { if (v) scrollToBottom() })
<p>{{ t('chat.emptyState') }}</p>
</div>
<MessageItem
v-for="msg in chatStore.messages"
v-for="msg in displayMessages"
:key="msg.id"
:message="msg"
/>
<div v-if="chatStore.isStreaming" class="streaming-indicator">
<span></span><span></span><span></span>
</div>
<Transition name="fade">
<div v-if="chatStore.isStreaming" class="streaming-indicator">
<video :src="thinkingVideo" autoplay loop muted playsinline class="thinking-video" />
<div v-if="currentToolCalls.length > 0" class="tool-calls-panel">
<div
v-for="tc in currentToolCalls"
:key="tc.id"
class="tool-call-item"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
class="tool-call-icon"
>
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
</svg>
<span class="tool-call-name">{{ tc.toolName }}</span>
<span v-if="tc.toolPreview" class="tool-call-preview">{{ tc.toolPreview }}</span>
<span v-if="tc.toolStatus === 'running'" class="tool-call-spinner"></span>
<span v-if="tc.toolStatus === 'error'" class="tool-call-error">{{ t('chat.error') }}</span>
</div>
</div>
</div>
</Transition>
</div>
</template>
@@ -48,6 +91,7 @@ watch(() => chatStore.isStreaming, (v) => { if (v) scrollToBottom() })
display: flex;
flex-direction: column;
gap: 16px;
background-color: #ffffff;
}
.empty-state {
@@ -70,27 +114,91 @@ watch(() => chatStore.isStreaming, (v) => { if (v) scrollToBottom() })
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.4s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.streaming-indicator {
display: flex;
align-items: center;
gap: 4px;
align-items: flex-start;
gap: 12px;
padding: 4px;
color: $text-muted;
span {
width: 5px;
height: 5px;
background-color: $text-muted;
border-radius: 50%;
animation: stream-pulse 1.4s infinite ease-in-out;
&:nth-child(2) { animation-delay: 0.2s; }
&:nth-child(3) { animation-delay: 0.4s; }
.thinking-video {
width: 120px;
height: 120px;
border-radius: $radius-md;
object-fit: contain;
flex-shrink: 0;
}
}
@keyframes stream-pulse {
0%, 80%, 100% { opacity: 0.2; transform: scale(0.8); }
40% { opacity: 1; transform: scale(1); }
.tool-calls-panel {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 120px;
overflow-y: auto;
padding-top: 4px;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar { display: none; }
}
.tool-call-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: $text-secondary;
padding: 3px 8px;
background: rgba(0, 0, 0, 0.03);
border-radius: $radius-sm;
.tool-call-icon {
flex-shrink: 0;
color: $text-muted;
}
.tool-call-name {
font-family: $font-code;
flex-shrink: 0;
}
.tool-call-preview {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 300px;
color: $text-muted;
}
}
.tool-call-spinner {
width: 10px;
height: 10px;
border: 1.5px solid $text-muted;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.6s linear infinite;
flex-shrink: 0;
}
.tool-call-error {
font-size: 9px;
color: $error;
background: rgba($error, 0.08);
padding: 0 4px;
border-radius: 3px;
line-height: 14px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>