mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-16 09:40:30 +08:00
feat: add chat token usage indicator
Show a neutral token usage ring in ChatUI when the current model has context-window metadata.
This commit is contained in:
@@ -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;
|
||||
});
|
||||
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);
|
||||
}
|
||||
} 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;
|
||||
|
||||
@@ -242,6 +242,27 @@
|
||||
class="mr-1"
|
||||
width="1.5"
|
||||
/>
|
||||
<v-tooltip
|
||||
v-if="tokenUsageVisible"
|
||||
location="top"
|
||||
max-width="320"
|
||||
>
|
||||
<template #activator="{ props: tokenTooltipProps }">
|
||||
<span
|
||||
v-bind="tokenTooltipProps"
|
||||
class="token-usage-indicator"
|
||||
:style="{ '--token-usage-color': tokenUsageColor }"
|
||||
>
|
||||
<v-progress-circular
|
||||
:model-value="tokenUsagePercent"
|
||||
size="24"
|
||||
width="2.5"
|
||||
class="token-usage-progress"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
<span>{{ props.tokenUsage?.tooltip }}</span>
|
||||
</v-tooltip>
|
||||
<!-- <v-btn @click="$emit('openLiveMode')"
|
||||
icon
|
||||
variant="text"
|
||||
@@ -332,6 +353,13 @@ interface ReplyInfo {
|
||||
selectedText?: string;
|
||||
}
|
||||
|
||||
interface TokenUsageInfo {
|
||||
used: number;
|
||||
limit: number;
|
||||
percent: number;
|
||||
tooltip: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
prompt: string;
|
||||
stagedImagesUrl: string[];
|
||||
@@ -347,6 +375,7 @@ interface Props {
|
||||
replyTo?: ReplyInfo | null;
|
||||
sendShortcut?: "enter" | "shift_enter";
|
||||
showProviderSelector?: boolean;
|
||||
tokenUsage?: TokenUsageInfo | null;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -357,6 +386,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
replyTo: null,
|
||||
sendShortcut: "shift_enter",
|
||||
showProviderSelector: true,
|
||||
tokenUsage: null,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -569,6 +599,29 @@ function handleReplyAfterLeave() {
|
||||
|
||||
const { mobile } = useDisplay();
|
||||
|
||||
const tokenUsageVisible = computed(() => {
|
||||
const usage = props.tokenUsage;
|
||||
return Boolean(
|
||||
usage &&
|
||||
Number.isFinite(usage.used) &&
|
||||
Number.isFinite(usage.limit) &&
|
||||
usage.used > 0 &&
|
||||
usage.limit > 0,
|
||||
);
|
||||
});
|
||||
|
||||
const tokenUsagePercent = computed(() => {
|
||||
const percent = props.tokenUsage?.percent || 0;
|
||||
if (!Number.isFinite(percent)) return 0;
|
||||
return Math.min(100, Math.max(0, percent));
|
||||
});
|
||||
|
||||
const tokenUsageColor = computed(() =>
|
||||
isDark.value
|
||||
? "rgba(var(--v-theme-on-surface), 0.82)"
|
||||
: "rgba(var(--v-theme-on-surface), 0.72)",
|
||||
);
|
||||
|
||||
// Auto-resize textarea
|
||||
function autoResize() {
|
||||
const el = inputField.value;
|
||||
@@ -996,6 +1049,31 @@ defineExpose({
|
||||
background: rgba(var(--v-theme-on-surface), 0.04) !important;
|
||||
}
|
||||
|
||||
.token-usage-indicator {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 24px;
|
||||
border-radius: 50%;
|
||||
color: var(--token-usage-color);
|
||||
}
|
||||
|
||||
.token-usage-progress {
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.token-usage-progress :deep(.v-progress-circular__underlay) {
|
||||
color: rgba(var(--v-theme-on-surface), 0.18);
|
||||
stroke: currentColor;
|
||||
opacity: 0.24;
|
||||
}
|
||||
|
||||
.token-usage-progress :deep(.v-progress-circular__overlay) {
|
||||
stroke: currentColor;
|
||||
}
|
||||
|
||||
.input-outline-control {
|
||||
width: 36px !important;
|
||||
height: 36px !important;
|
||||
|
||||
@@ -156,6 +156,9 @@
|
||||
"duration": "Duration",
|
||||
"ttft": "Time to First Token"
|
||||
},
|
||||
"tokenUsage": {
|
||||
"tooltip": "{used} / {limit} tokens ({percent}%)"
|
||||
},
|
||||
"refs": {
|
||||
"title": "References",
|
||||
"sources": "Sources"
|
||||
|
||||
@@ -156,6 +156,9 @@
|
||||
"duration": "Время",
|
||||
"ttft": "Время до первого токена"
|
||||
},
|
||||
"tokenUsage": {
|
||||
"tooltip": "{used} / {limit} токенов ({percent}%)"
|
||||
},
|
||||
"refs": {
|
||||
"title": "Ссылки",
|
||||
"sources": "Источники"
|
||||
|
||||
@@ -156,6 +156,9 @@
|
||||
"duration": "耗时",
|
||||
"ttft": "首字时间"
|
||||
},
|
||||
"tokenUsage": {
|
||||
"tooltip": "{used} / {limit} tokens({percent}%)"
|
||||
},
|
||||
"refs": {
|
||||
"title": "引用",
|
||||
"sources": "来源"
|
||||
|
||||
@@ -19,15 +19,28 @@ export interface ProviderCapabilityBadge {
|
||||
tooltip: string
|
||||
}
|
||||
|
||||
export function contextLimit(
|
||||
provider: ProviderMetadataSource | null | undefined,
|
||||
metadata?: ProviderModelMetadata | null
|
||||
): number {
|
||||
const context = Number(metadata?.limit?.context || provider?.max_context_tokens || 0)
|
||||
return Number.isFinite(context) && context > 0 ? context : 0
|
||||
}
|
||||
|
||||
export function formatTokenCount(value: number): string {
|
||||
if (!Number.isFinite(value)) return ''
|
||||
const absValue = Math.abs(value)
|
||||
if (absValue >= 1_000_000) return `${formatCompactNumber(value / 1_000_000)}M`
|
||||
if (absValue >= 1_000) return `${formatCompactNumber(value / 1_000)}K`
|
||||
return `${Math.round(value)}`
|
||||
}
|
||||
|
||||
export function formatContextLimit(
|
||||
provider: ProviderMetadataSource | null | undefined,
|
||||
metadata?: ProviderModelMetadata | null
|
||||
): string {
|
||||
const context = metadata?.limit?.context || provider?.max_context_tokens
|
||||
if (!context || typeof context !== 'number') return ''
|
||||
if (context >= 1_000_000) return `${Math.round(context / 1_000_000)}M`
|
||||
if (context >= 1_000) return `${Math.round(context / 1_000)}K`
|
||||
return `${context}`
|
||||
const context = contextLimit(provider, metadata)
|
||||
return context ? formatTokenCount(context) : ''
|
||||
}
|
||||
|
||||
export function providerCapabilityBadges(
|
||||
@@ -81,3 +94,9 @@ export function providerCapabilityBadges(
|
||||
: tm('models.metadata.enabled', { capability: item.label })
|
||||
}))
|
||||
}
|
||||
|
||||
function formatCompactNumber(value: number): string {
|
||||
const absValue = Math.abs(value)
|
||||
const rounded = absValue >= 10 ? Math.round(value) : Math.round(value * 10) / 10
|
||||
return String(rounded).replace(/\.0$/, '')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user