-
-
Notifications
You must be signed in to change notification settings - Fork 2.5k
feat: add chat token usage indicator #9162
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<WorkspaceView>("chat"); | ||
| const projectDialogOpen = ref(false); | ||
| const editingProject = ref<Project | null>(null); | ||
|
|
@@ -670,6 +683,9 @@ const projectSessionsById = ref<Record<string, Session[]>>({}); | |
| const loadingProjectSessionIds = ref<string[]>([]); | ||
| const loadingSessions = ref(false); | ||
| const draft = ref(""); | ||
| const tokenProviderConfigs = ref<TokenProviderConfig[]>([]); | ||
| const tokenModelMetadata = ref<Record<string, ProviderModelMetadata>>({}); | ||
| const selectedTokenProviderId = ref(""); | ||
| const messagesContainer = ref<HTMLElement | null>(null); | ||
| const inputRef = ref<InstanceType<typeof ChatInput> | 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; | ||
| }); | ||
|
Comment on lines
+860
to
+865
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The To fix this, consider having |
||
| 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<string, ProviderModelMetadata>; | ||
| tokenProviderConfigs.value = ( | ||
| (response.data.data || []) as unknown as TokenProviderConfig[] | ||
| ).filter((provider) => provider.enable !== false); | ||
|
Comment on lines
+1029
to
+1031
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Some providers might use the |
||
| } | ||
| } 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; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
suggestion: Keep
selectedTokenProviderIdin sync when falling back to the first provider.In
currentTokenProvider, whenselectedTokenProviderIddoesn't match any loaded provider, you fall back totokenProviderConfigs[0]but leaveselectedTokenProviderIdpointing to a non-existent provider. This desynchronizes the persisted selection (e.g. localStorage) from the actual provider used forcontextLimit. Please either updateselectedTokenProviderIdto the fallback provider’s id when no match is found, or introduce a derivedeffectiveSelectedProviderIdthat always reflects the provider actually used.Suggested implementation:
Any logic that depends on the “actually used” token provider (e.g. for
contextLimit, pricing display, or usage calculation) should useeffectiveSelectedTokenProviderIdorcurrentTokenProviderrather thanselectedTokenProviderId. Search for usages ofselectedTokenProviderIdelsewhere inChat.vue(and related components) and update them where they should reflect the effective provider, not just the persisted/raw selection.