From 8eec231209f6a007e6445d961d76471cc0dc1448 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Mon, 6 Jul 2026 21:11:30 +0800 Subject: [PATCH] feat: add chat token usage indicator --- dashboard/src/components/chat/Chat.vue | 97 ++++++++++++++++++- dashboard/src/components/chat/ChatInput.vue | 78 +++++++++++++++ .../src/i18n/locales/en-US/features/chat.json | 3 + .../src/i18n/locales/ru-RU/features/chat.json | 3 + .../src/i18n/locales/zh-CN/features/chat.json | 3 + dashboard/src/utils/providerMetadata.ts | 29 +++++- 6 files changed, 206 insertions(+), 7 deletions(-) diff --git a/dashboard/src/components/chat/Chat.vue b/dashboard/src/components/chat/Chat.vue index 3045a3f9fc..38e0e7c49a 100644 --- a/dashboard/src/components/chat/Chat.vue +++ b/dashboard/src/components/chat/Chat.vue @@ -357,6 +357,7 @@ :is-running=" Boolean(currSessionId && isSessionRunning(currSessionId)) " + :token-usage="tokenUsageIndicator" :session-id="currSessionId || null" :current-session="currentSession" :reply-to="chatInputReplyTarget" @@ -440,6 +441,7 @@ :is-running=" Boolean(currSessionId && isSessionRunning(currSessionId)) " + :token-usage="tokenUsageIndicator" :session-id="currSessionId || null" :current-session="currentSession" :reply-to="chatInputReplyTarget" @@ -561,7 +563,7 @@ import { Sun, Trash2, } from "@lucide/vue"; -import { chatApi } from "@/api/v1"; +import { chatApi, providerApi } from "@/api/v1"; import StyledMenu from "@/components/shared/StyledMenu.vue"; import ProjectDialog, { type ProjectFormData, @@ -597,6 +599,12 @@ import { } from "@/i18n/composables"; import type { Locale } from "@/i18n/types"; import { askForConfirmation, useConfirmDialog } from "@/utils/confirmDialog"; +import { + contextLimit, + formatTokenCount, + type ProviderModelMetadata, + type ProviderMetadataSource, +} from "@/utils/providerMetadata"; import { useToast } from "@/utils/toast"; const props = withDefaults(defineProps<{ chatboxMode?: boolean; active?: boolean }>(), { @@ -652,6 +660,11 @@ const { type WorkspaceView = "chat" | "providers"; +interface TokenProviderConfig extends ProviderMetadataSource { + id: string; + enable?: boolean; +} + const activeWorkspace = ref("chat"); const projectDialogOpen = ref(false); const editingProject = ref(null); @@ -670,6 +683,9 @@ const projectSessionsById = ref>({}); const loadingProjectSessionIds = ref([]); const loadingSessions = ref(false); const draft = ref(""); +const tokenProviderConfigs = ref([]); +const tokenModelMetadata = ref>({}); +const selectedTokenProviderId = ref(""); const messagesContainer = ref(null); const inputRef = ref | null>(null); const shouldStickToBottom = ref(true); @@ -841,15 +857,58 @@ const chatInputReplyTarget = computed(() => selectedText: replyPreview(replyTarget.value.id, ""), }, ); +const currentTokenProvider = computed(() => { + const selectedProvider = tokenProviderConfigs.value.find( + (provider) => provider.id === selectedTokenProviderId.value, + ); + return selectedProvider || tokenProviderConfigs.value[0] || null; +}); +const currentTokenMetadata = computed(() => { + const model = currentTokenProvider.value?.model; + return model ? tokenModelMetadata.value[model] || null : null; +}); +const latestTokenUsageTotal = computed(() => { + for (let index = activeMessages.value.length - 1; index >= 0; index -= 1) { + const message = activeMessages.value[index]; + if (isUserMessage(message)) continue; + const usage = message.content?.agentStats?.token_usage; + if (!usage) continue; + return ( + readTokenCount(usage.input_other) + + readTokenCount(usage.input_cached) + + readTokenCount(usage.output) + ); + } + return 0; +}); +const tokenUsageIndicator = computed(() => { + const used = latestTokenUsageTotal.value; + const limit = contextLimit(currentTokenProvider.value, currentTokenMetadata.value); + if (used <= 0 || limit <= 0) return null; + + const percent = (used / limit) * 100; + return { + used, + limit, + percent: Math.min(100, Math.max(0, percent)), + tooltip: tm("tokenUsage.tooltip", { + used: formatTokenCount(used), + limit: formatTokenCount(limit), + percent: formatUsagePercent(percent), + }), + }; +}); function getSelectedProviderSelection() { const inputSelection = inputRef.value?.getCurrentSelection(); if (inputSelection?.providerId) { + selectedTokenProviderId.value = inputSelection.providerId; return inputSelection; } if (typeof window === "undefined") { return { providerId: "", modelName: "" }; } + syncSelectedTokenProvider(); return { providerId: localStorage.getItem("selectedProvider") || "", modelName: localStorage.getItem("selectedProviderModel") || "", @@ -869,7 +928,7 @@ watch( onMounted(async () => { loadingSessions.value = true; try { - await Promise.all([getSessions(), getProjects()]); + await Promise.all([getSessions(), getProjects(), loadTokenProviders()]); const routeSessionId = getRouteSessionId(); if (routeSessionId === "models") { activeWorkspace.value = "providers"; @@ -954,6 +1013,40 @@ function sessionTitle(session: Session) { return session.display_name?.trim() || tm("conversation.newConversation"); } +function syncSelectedTokenProvider() { + if (typeof window === "undefined") return; + selectedTokenProviderId.value = localStorage.getItem("selectedProvider") || ""; +} + +async function loadTokenProviders() { + syncSelectedTokenProvider(); + try { + const response = await providerApi.listByProviderType("chat_completion"); + if (response.data.status === "ok") { + tokenModelMetadata.value = ( + (response.data as any).model_metadata || {} + ) as Record; + tokenProviderConfigs.value = ( + (response.data.data || []) as unknown as TokenProviderConfig[] + ).filter((provider) => provider.enable !== false); + } + } catch (error) { + console.error("Failed to load provider context metadata:", error); + } +} + +function readTokenCount(value: unknown) { + const count = Number(value || 0); + return Number.isFinite(count) && count > 0 ? count : 0; +} + +function formatUsagePercent(value: number) { + if (!Number.isFinite(value) || value <= 0) return "0"; + if (value >= 10) return String(Math.round(value)); + if (value >= 1) return String(Math.round(value * 10) / 10); + return String(Math.round(value * 100) / 100); +} + async function startNewChat() { showChatWorkspace(); selectedProjectId.value = null; diff --git a/dashboard/src/components/chat/ChatInput.vue b/dashboard/src/components/chat/ChatInput.vue index cb07f8c1d5..a88eaea789 100644 --- a/dashboard/src/components/chat/ChatInput.vue +++ b/dashboard/src/components/chat/ChatInput.vue @@ -242,6 +242,27 @@ class="mr-1" width="1.5" /> + + + {{ props.tokenUsage?.tooltip }} +