上下文
一个复合组件系统,用于显示 AI 模型上下文窗口使用情况、token 消耗和成本估算。
Context 组件通过复合组件系统提供 AI 模型使用情况的全面视图。它在交互式悬停卡片界面中显示上下文窗口利用率、token 消耗明细(输入、输出、推理、缓存)和成本估算。
Install using CLI
AI Elements Vue
shadcn-vue CLI
npx ai-elements-vue@latest add context
npx shadcn-vue@latest add https://registry.ai-elements-vue.com/context.json
Install Manually
Copy and paste the following files into the same folder.
Context.vue
ContextIcon.vue
ContextTrigger.vue
ContextContent.vue
ContextContentHeader.vue
ContextContentBody.vue
ContextContentFooter.vue
ContextInputUsage.vue
ContextOutputUsage.vue
ContextReasoningUsage.vue
ContextCacheUsage.vue
TokensWithCost.vue
context.ts
index.ts
<script setup lang="ts">
import type { LanguageModelUsage } from 'ai'
import type { ModelId } from './context'
import { HoverCard } from '@repo/shadcn-vue/components/ui/hover-card'
import { computed, provide } from 'vue'
import { ContextKey } from './context'
interface Props {
usedTokens: number
maxTokens: number
usage?: LanguageModelUsage
modelId?: ModelId
}
const props = defineProps<Props>()
provide(ContextKey, {
usedTokens: computed(() => props.usedTokens),
maxTokens: computed(() => props.maxTokens),
usage: computed(() => props.usage),
modelId: computed(() => props.modelId),
})
</script>
<template>
<HoverCard :close-delay="0" :open-delay="0" v-bind="{ ...$attrs, ...props }">
<slot />
</HoverCard>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useContextValue } from './context'
const ICON_RADIUS = 10
const ICON_VIEWBOX = 24
const ICON_CENTER = 12
const ICON_STROKE_WIDTH = 2
const { usedTokens, maxTokens } = useContextValue()
const circumference = 2 * Math.PI * ICON_RADIUS
const usedPercent = computed(() => {
if (maxTokens.value === 0)
return 0
return usedTokens.value / maxTokens.value
})
const dashOffset = computed(() => {
return circumference * (1 - usedPercent.value)
})
const svgStyle = {
transformOrigin: 'center',
transform: 'rotate(-90deg)',
}
</script>
<template>
<svg
aria-label="Model context usage"
height="20"
role="img"
style="color: currentcolor"
:viewBox="`0 0 ${ICON_VIEWBOX} ${ICON_VIEWBOX}`"
width="20"
>
<circle
:cx="ICON_CENTER"
:cy="ICON_CENTER"
fill="none"
opacity="0.25"
:r="ICON_RADIUS"
stroke="currentColor"
:stroke-width="ICON_STROKE_WIDTH"
/>
<circle
:cx="ICON_CENTER"
:cy="ICON_CENTER"
fill="none"
opacity="0.7"
:r="ICON_RADIUS"
stroke="currentColor"
:stroke-dasharray="`${circumference} ${circumference}`"
:stroke-dashoffset="dashOffset"
stroke-linecap="round"
:stroke-width="ICON_STROKE_WIDTH"
:style="svgStyle"
/>
</svg>
</template>
<script setup lang="ts">
import { Button } from '@repo/shadcn-vue/components/ui/button'
import { HoverCardTrigger } from '@repo/shadcn-vue/components/ui/hover-card'
import { computed } from 'vue'
import { useContextValue } from './context'
import ContextIcon from './ContextIcon.vue'
const { usedTokens, maxTokens } = useContextValue()
const renderedPercent = computed(() => {
if (maxTokens.value === 0)
return '0%'
const usedPercent = usedTokens.value / maxTokens.value
return new Intl.NumberFormat('en-US', {
style: 'percent',
maximumFractionDigits: 1,
}).format(usedPercent)
})
</script>
<template>
<HoverCardTrigger as-child>
<slot v-if="$slots.default" />
<Button v-else type="button" variant="ghost" v-bind="$attrs">
<span class="font-medium text-muted-foreground">
{{ renderedPercent }}
</span>
<ContextIcon />
</Button>
</HoverCardTrigger>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { HoverCardContent } from '@repo/shadcn-vue/components/ui/hover-card'
import { cn } from '@repo/shadcn-vue/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<HoverCardContent
:class="
cn('min-w-60 divide-y overflow-hidden p-0', props.class)
"
v-bind="$attrs"
>
<slot />
</HoverCardContent>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { Progress } from '@repo/shadcn-vue/components/ui/progress'
import { cn } from '@repo/shadcn-vue/lib/utils'
import { computed } from 'vue'
import { useContextValue } from './context'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
const PERCENT_MAX = 100
const { usedTokens, maxTokens } = useContextValue()
const formatter = new Intl.NumberFormat('en-US', { notation: 'compact' })
const usedPercent = computed(() => {
if (maxTokens.value === 0)
return 0
return usedTokens.value / maxTokens.value
})
const displayPct = computed(() => {
return new Intl.NumberFormat('en-US', {
style: 'percent',
maximumFractionDigits: 1,
}).format(usedPercent.value)
})
const used = computed(() => formatter.format(usedTokens.value))
const total = computed(() => formatter.format(maxTokens.value))
</script>
<template>
<div :class="cn('w-full space-y-2 p-3', props.class)">
<slot v-if="$slots.default" />
<template v-else>
<div class="flex items-center justify-between gap-3 text-xs">
<p>{{ displayPct }}</p>
<p class="font-mono text-muted-foreground">
{{ used }} / {{ total }}
</p>
</div>
<div class="space-y-2">
<Progress class="bg-muted" :model-value="usedPercent * PERCENT_MAX" />
</div>
</template>
</div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@repo/shadcn-vue/lib/utils'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
</script>
<template>
<div :class="cn('w-full p-3', props.class)" v-bind="$attrs">
<slot />
</div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@repo/shadcn-vue/lib/utils'
import { getUsage } from 'tokenlens'
import { computed } from 'vue'
import { useContextValue } from './context'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
const { modelId, usage } = useContextValue()
const totalCost = computed(() => {
if (!modelId.value)
return 0
const costUSD = getUsage({
modelId: modelId.value,
usage: {
input: usage.value?.inputTokens ?? 0,
output: usage.value?.outputTokens ?? 0,
},
}).costUSD?.totalUSD
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(costUSD ?? 0)
})
</script>
<template>
<div
:class="
cn(
'flex w-full items-center justify-between gap-3 bg-secondary p-3 text-xs',
props.class,
)
"
>
<slot v-if="$slots.default" />
<template v-else>
<span class="text-muted-foreground">Total cost</span>
<span>{{ totalCost }}</span>
</template>
</div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@repo/shadcn-vue/lib/utils'
import { getUsage } from 'tokenlens'
import { computed } from 'vue'
import { useContextValue } from './context'
import TokensWithCost from './TokensWithCost.vue'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
const { usage, modelId } = useContextValue()
const inputTokens = computed(() => usage.value?.inputTokens ?? 0)
const inputCostText = computed(() => {
if (!modelId.value || !inputTokens.value)
return undefined
const inputCost = getUsage({
modelId: modelId.value,
usage: { input: inputTokens.value, output: 0 },
}).costUSD?.totalUSD
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(inputCost ?? 0)
})
</script>
<template>
<slot v-if="$slots.default" />
<div
v-else-if="inputTokens > 0"
:class="
cn('flex items-center justify-between text-xs', props.class)
"
v-bind="$attrs"
>
<span class="text-muted-foreground">Input</span>
<TokensWithCost :cost-text="inputCostText" :tokens="inputTokens" />
</div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@repo/shadcn-vue/lib/utils'
import { getUsage } from 'tokenlens'
import { computed } from 'vue'
import { useContextValue } from './context'
import TokensWithCost from './TokensWithCost.vue'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
const { usage, modelId } = useContextValue()
const outputTokens = computed(() => usage.value?.outputTokens ?? 0)
const outputCostText = computed(() => {
if (!modelId.value || !outputTokens.value)
return undefined
const outputCost = getUsage({
modelId: modelId.value,
usage: { input: 0, output: outputTokens.value },
}).costUSD?.totalUSD
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(outputCost ?? 0)
})
</script>
<template>
<slot v-if="$slots.default" />
<div
v-else-if="outputTokens > 0"
:class="
cn('flex items-center justify-between text-xs', props.class)
"
v-bind="$attrs"
>
<span class="text-muted-foreground">Output</span>
<TokensWithCost :cost-text="outputCostText" :tokens="outputTokens" />
</div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@repo/shadcn-vue/lib/utils'
import { getUsage } from 'tokenlens'
import { computed } from 'vue'
import { useContextValue } from './context'
import TokensWithCost from './TokensWithCost.vue'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
const { usage, modelId } = useContextValue()
const reasoningTokens = computed(() => usage.value?.reasoningTokens ?? 0)
const reasoningCostText = computed(() => {
if (!modelId.value || !reasoningTokens.value)
return undefined
const reasoningCost = getUsage({
modelId: modelId.value,
usage: { reasoningTokens: reasoningTokens.value },
}).costUSD?.totalUSD
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(reasoningCost ?? 0)
})
</script>
<template>
<slot v-if="$slots.default" />
<div
v-else-if="reasoningTokens > 0"
:class="
cn('flex items-center justify-between text-xs', props.class)
"
v-bind="$attrs"
>
<span class="text-muted-foreground">Reasoning</span>
<TokensWithCost
:cost-text="reasoningCostText"
:tokens="reasoningTokens"
/>
</div>
</template>
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { cn } from '@repo/shadcn-vue/lib/utils'
import { getUsage } from 'tokenlens'
import { computed } from 'vue'
import { useContextValue } from './context'
import TokensWithCost from './TokensWithCost.vue'
const props = defineProps<{
class?: HTMLAttributes['class']
}>()
const { usage, modelId } = useContextValue()
const cacheTokens = computed(() => usage.value?.cachedInputTokens ?? 0)
const cacheCostText = computed(() => {
if (!modelId.value || !cacheTokens.value)
return undefined
const cacheCost = getUsage({
modelId: modelId.value,
usage: { cacheReads: cacheTokens.value, input: 0, output: 0 },
}).costUSD?.totalUSD
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(cacheCost ?? 0)
})
</script>
<template>
<slot v-if="$slots.default" />
<div
v-else-if="cacheTokens > 0"
:class="
cn('flex items-center justify-between text-xs', props.class)
"
v-bind="$attrs"
>
<span class="text-muted-foreground">Cache</span>
<TokensWithCost :cost-text="cacheCostText" :tokens="cacheTokens" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
tokens?: number
costText?: string
}>()
const formattedTokens = computed(() => {
return props.tokens === undefined
? '—'
: new Intl.NumberFormat('en-US', {
notation: 'compact',
}).format(props.tokens)
})
</script>
<template>
<span>
{{ formattedTokens }}
<span v-if="costText" class="ml-2 text-muted-foreground">
• {{ costText }}
</span>
</span>
</template>
import type { LanguageModelUsage } from 'ai'
import type { ComputedRef, InjectionKey } from 'vue'
import { inject } from 'vue'
export type ModelId = string
export interface ContextContextValue {
usedTokens: ComputedRef<number>
maxTokens: ComputedRef<number>
usage: ComputedRef<LanguageModelUsage | undefined>
modelId: ComputedRef<ModelId | undefined>
}
export const ContextKey: InjectionKey<ContextContextValue>
= Symbol('ContextContext')
export function useContextValue(): ContextContextValue {
const context = inject<ContextContextValue>(ContextKey)
if (!context) {
throw new Error('Context components must be used within Context')
}
return context
}
export { default as Context } from './Context.vue'
export { default as ContextCacheUsage } from './ContextCacheUsage.vue'
export { default as ContextContent } from './ContextContent.vue'
export { default as ContextContentBody } from './ContextContentBody.vue'
export { default as ContextContentFooter } from './ContextContentFooter.vue'
export { default as ContextContentHeader } from './ContextContentHeader.vue'
export { default as ContextIcon } from './ContextIcon.vue'
export { default as ContextInputUsage } from './ContextInputUsage.vue'
export { default as ContextOutputUsage } from './ContextOutputUsage.vue'
export { default as ContextReasoningUsage } from './ContextReasoningUsage.vue'
export { default as ContextTrigger } from './ContextTrigger.vue'
export { default as TokensWithCost } from './TokensWithCost.vue'
组件架构
Context 组件使用复合组件模式,通过 Vue 的 provide/inject API 进行数据共享:
<Context>- 根提供者组件,保存所有上下文数据<ContextTrigger>- 交互式触发器元素(默认:带百分比的按钮)<ContextContent>- 悬停卡片内容容器<ContextContentHeader>- 带有进度可视化的标题部分<ContextContentBody>- 用于使用明细的主体部分<ContextContentFooter>- 用于总成本的页脚部分- 使用组件 - 单独的 token 使用显示(输入、输出、推理、缓存)
Token 格式化
组件使用 Intl.NumberFormat 和紧凑表示法进行自动格式化:
- 1,000 以下:显示确切计数(例如,"842")
- 1,000+:显示带 K 后缀(例如,"32K")
- 1,000,000+:显示带 M 后缀(例如,"1.5M")
- 1,000,000,000+:显示带 B 后缀(例如,"2.1B")
成本计算
当提供 modelId 时,组件使用 tokenlens 库自动计算成本:
- 输入 tokens:基于模型的输入定价
- 输出 tokens:基于模型的输出定价
- 推理 tokens:支持推理模型的特殊定价
- 缓存 tokens:缓存输入 tokens 的降低定价
- 总成本:所有 token 类型成本的总和
成本使用 Intl.NumberFormat 和 USD 货币进行格式化。
样式
组件使用 Tailwind CSS 类并遵循你的设计系统:
- 进度指示器使用
currentColor进行主题适配 - 悬停卡片具有可自定义的宽度和内边距
- 页脚具有次要背景以进行视觉分离
- 所有文本大小使用
text-xs类以保持一致性 - 次要信息使用静音前景色
特性
- 复合组件架构:灵活的上下文显示元素组合
- 可视化进度指示器:显示上下文使用百分比的圆形 SVG 进度环
- Token 明细:输入、输出、推理和缓存 tokens 的详细视图
- 成本估算:使用
tokenlens库进行实时成本计算 - 智能格式化:自动 token 计数格式化(K、M、B 后缀)
- 交互式悬停卡片:悬停时显示详细信息
- 上下文提供者模式:通过 Vue 的
provide/injectAPI 进行清晰的数据流 - TypeScript 支持:所有组件的完整类型定义
- 可访问设计:适当的 ARIA 标签和语义 HTML
- 主题集成:使用 currentColor 进行自动主题适配
Props
<Context />
maxTokensnumber
usedTokensnumber
usageLanguageModelUsage
modelIdstring
<ContextContent />
classstring
<ContextContentHeader />
classstring
<ContextContentBody />
classstring
<ContextContentFooter />
classstring
使用组件
所有使用组件(ContextInputUsage、ContextOutputUsage、ContextReasoningUsage、ContextCacheUsage)共享相同的 props:
classstring