Files
cursor-byok/frontend/src/components/HomeMetricsCard.vue
T
leookun 0f6b0f9484 release: 0.0.41
- 修复内存泄漏问题,该可能导致内存异常占用
- 支持俄语增加翻译范围
- 修复qwen-3.8-max中断问题(mimo也应该属于同一类问题)
- 修复claude模型可能无法识别图片问题 @GGHansome
- 支持自定义Openai端点 @Sxuan-Coder
- WebSearch 接入百度搜索,DuckDuckGo 作为兜底 @杨超
- 修复一些兼容性问题 @kael-odin
- 修复window上一些表现问题 @philau2512

🔔 如何让AI自动拉模型配置? (以下为提示词,把地址和密钥换为你的)
我的模型配置在 ~/.cursor-local-assistant-v2/config.yaml,我的API地址是:https://xxx 密钥是xxx,帮我拉所有模型配置进去,不要影响已有模型,根据models标准接口拉取。
2026-07-27 00:55:02 +08:00

403 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import CacheHitRateChart from "@/components/charts/CacheHitRateChart.vue";
import Switch from "@/components/ui/Switch.vue";
import Tooltip from "@/components/ui/Tooltip.vue";
import { appState, saveIncludeCacheWriteInHitRate } from "@/state/appState";
import { formatCompactInteger, formatInteger } from "@/utils/numberFormat";
import { computed, ref } from "vue";
const emit = defineEmits(["refresh", "open-ad"]);
const TOKEN_PRICE_PER_MILLION = {
input: 5,
output: 25,
cacheRead: 0.5,
cacheWrite: 6.25,
};
const props = defineProps({
metrics: {
type: Object,
required: true,
},
loading: {
type: Boolean,
default: false,
},
error: {
type: String,
default: "",
},
homeAd: {
type: Object,
default: null,
},
homeAds: {
type: Array,
default: () => [],
},
});
const homeMetricsConfigSaving = ref(false);
const homeMetricsConfigError = ref("");
function normalizeNumber(value) {
const number = Number(value);
if (!Number.isFinite(number)) {
return 0;
}
return Math.round(number);
}
function formatMetricValue(value) {
const full = formatInteger(value);
const compact = formatCompactInteger(value);
return full === compact ? full : `${full} (${compact})`;
}
function formatRateLabel(value) {
const rate = Number(value);
if (!Number.isFinite(rate)) {
return "暂无数据";
}
return `${(Math.max(0, Math.min(1, rate)) * 100).toFixed(2)}%`;
}
function calculateRate(numerator, denominator) {
const top = normalizeNumber(numerator);
const bottom = normalizeNumber(denominator);
if (bottom <= 0) {
return null;
}
return top / bottom;
}
function priceTokens(tokens, pricePerMillion) {
return (normalizeNumber(tokens) / 1_000_000) * pricePerMillion;
}
function formatUSD(value) {
const amount = Number(value);
if (!Number.isFinite(amount)) {
return "$0.00";
}
if (amount > 0 && amount < 0.01) {
return "<$0.01";
}
return `$${amount.toFixed(2)}`;
}
const cacheReadTokensTotal = computed(() => normalizeNumber(props.metrics?.cacheReadTokens));
const cacheWriteTokensTotal = computed(() => normalizeNumber(props.metrics?.cacheWriteTokens));
const inputTokensTotal = computed(() => {
const promptTokensTotal = normalizeNumber(props.metrics?.promptTokensTotal);
return Math.max(0, promptTokensTotal - cacheReadTokensTotal.value - cacheWriteTokensTotal.value);
});
const defaultCacheHitRate = computed(() =>
calculateRate(cacheReadTokensTotal.value, cacheReadTokensTotal.value + inputTokensTotal.value),
);
const cacheReuseRate = computed(() =>
calculateRate(
cacheReadTokensTotal.value,
cacheReadTokensTotal.value + cacheWriteTokensTotal.value + inputTokensTotal.value,
),
);
const includeCacheWriteInHitRate = computed(() => appState.includeCacheWriteInHitRate);
const selectedCacheHitRate = computed(() =>
includeCacheWriteInHitRate.value ? cacheReuseRate.value : defaultCacheHitRate.value,
);
const selectedCacheRateModeLabel = computed(() =>
includeCacheWriteInHitRate.value ? "计入缓存创建" : "默认口径",
);
const validTurnsRate = computed(() => {
const turnsTotal = normalizeNumber(props.metrics?.turnsTotal);
if (turnsTotal <= 0) {
return null;
}
return normalizeNumber(props.metrics?.validTurnsTotal) / turnsTotal;
});
const completionTokensTotal = computed(() => {
const requestTokensTotal = normalizeNumber(props.metrics?.requestTokensTotal);
const promptTokensTotal = normalizeNumber(props.metrics?.promptTokensTotal);
return Math.max(0, requestTokensTotal - promptTokensTotal);
});
const estimatedTokenCost = computed(() => {
const input = priceTokens(inputTokensTotal.value, TOKEN_PRICE_PER_MILLION.input);
const output = priceTokens(completionTokensTotal.value, TOKEN_PRICE_PER_MILLION.output);
const cacheRead = priceTokens(cacheReadTokensTotal.value, TOKEN_PRICE_PER_MILLION.cacheRead);
const cacheWrite = priceTokens(cacheWriteTokensTotal.value, TOKEN_PRICE_PER_MILLION.cacheWrite);
return {
input,
output,
cacheRead,
cacheWrite,
total: input + output + cacheRead + cacheWrite,
};
});
const cacheTooltipContent = computed(() => {
const formula = includeCacheWriteInHitRate.value
? "缓存读取 /(缓存读取 + 缓存创建 + 非缓存输入)"
: "缓存读取 /(缓存读取 + 非缓存输入)";
return [
`当前:${formatRateLabel(selectedCacheHitRate.value)}`,
`公式:${formula}`,
`默认 ${formatRateLabel(defaultCacheHitRate.value)} / 计入创建 ${formatRateLabel(cacheReuseRate.value)}`,
].join("\n");
});
const turnsTooltipContent = computed(() =>
[
"按历史记录里扫描到的回合 summary 汇总。",
"",
`总轮次:${formatMetricValue(props.metrics?.turnsTotal)}`,
`有效轮次:${formatMetricValue(props.metrics?.validTurnsTotal)}`,
`异常轮次:${formatMetricValue(props.metrics?.invalidTurnsTotal)}`,
`有效占比:${formatRateLabel(validTurnsRate.value)}`,
].join("\n"),
);
const tokensTooltipContent = computed(() =>
[
"总请求 Token 包含 Prompt 和模型输出。",
"",
`总请求:${formatMetricValue(props.metrics?.requestTokensTotal)}`,
`Prompt${formatMetricValue(props.metrics?.promptTokensTotal)}`,
`输出推算:${formatMetricValue(completionTokensTotal.value)}`,
`非缓存输入:${formatMetricValue(inputTokensTotal.value)}`,
`缓存读取:${formatMetricValue(cacheReadTokensTotal.value)}`,
`缓存写入:${formatMetricValue(cacheWriteTokensTotal.value)}`,
"",
"缓存读写已计入 Prompt 侧统计。",
].join("\n"),
);
const costTooltipContent = computed(() =>
[
"按 Claude Opus 4.7 价格估算。",
`缓存统计策略:${selectedCacheRateModeLabel.value}${formatRateLabel(selectedCacheHitRate.value)}`,
"",
`普通输入:${formatMetricValue(inputTokensTotal.value)} × $${TOKEN_PRICE_PER_MILLION.input}/1M = ${formatUSD(estimatedTokenCost.value.input)}`,
`模型输出:${formatMetricValue(completionTokensTotal.value)} × $${TOKEN_PRICE_PER_MILLION.output}/1M = ${formatUSD(estimatedTokenCost.value.output)}`,
`缓存读取:${formatMetricValue(cacheReadTokensTotal.value)} × $${TOKEN_PRICE_PER_MILLION.cacheRead}/1M = ${formatUSD(estimatedTokenCost.value.cacheRead)}`,
`缓存写入:${formatMetricValue(cacheWriteTokensTotal.value)} × $${TOKEN_PRICE_PER_MILLION.cacheWrite}/1M = ${formatUSD(estimatedTokenCost.value.cacheWrite)}`,
"",
`合计:${formatUSD(estimatedTokenCost.value.total)}`,
].join("\n"),
);
function normalizeHomeAd(item, index) {
const source = item && typeof item === "object" ? item : {};
const title = typeof source.title === "string" ? source.title.trim() : "";
if (!title) {
return null;
}
return {
id: typeof source.id === "string" && source.id.trim() ? source.id.trim() : String(index + 1),
title,
subtitle: typeof source.subtitle === "string" ? source.subtitle.trim() : "",
};
}
async function toggleIncludeCacheWriteInHitRate(value) {
const nextValue = Boolean(value);
homeMetricsConfigSaving.value = true;
homeMetricsConfigError.value = "";
try {
const result = await saveIncludeCacheWriteInHitRate(nextValue);
if (!result?.ok) {
homeMetricsConfigError.value = result?.error || "保存失败";
}
} catch (error) {
homeMetricsConfigError.value = error?.message || "保存失败";
} finally {
homeMetricsConfigSaving.value = false;
}
}
const normalizedHomeAds = computed(() => {
const list = Array.isArray(props.homeAds) && props.homeAds.length > 0 ? props.homeAds : [props.homeAd];
return list.map(normalizeHomeAd).filter(Boolean);
});
const hasHomeAd = computed(() => normalizedHomeAds.value.length > 0);
</script>
<template>
<div>
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between gap-4 h-[42px]">
<div v-if="!hasHomeAd" class="flex flex-col gap-1 w-[200px] shrink-0">
<h2 class="text-[14px] font-medium text-white/80">会话统计</h2>
</div>
<div v-else class="grid min-w-0 grid-cols-3 gap-2 shrink-0">
<div
v-for="ad in normalizedHomeAds"
:key="ad.id"
style="font-family: var(--font-num)"
class="center-row h-[42px] min-w-0 cursor-pointer gap-[8px] rounded-[6px] border border-[#343434] bg-[#242424] px-[8px] pr-[10px] text-left transition-colors duration-150 hover:border-[#4a4a4a] hover:bg-[#2a2a2a] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/50"
role="button"
tabindex="0"
:title="ad.subtitle ? `${ad.title}\n${ad.subtitle}` : ad.title"
@click="emit('open-ad', ad.id)"
@keydown.enter.prevent="emit('open-ad', ad.id)"
@keydown.space.prevent="emit('open-ad', ad.id)"
>
<div
class="center-row h-[20px] w-[20px] shrink-0 justify-center text-[20px] text-amber-400"
>
<span class="icon-[cil--badge]"></span>
</div>
<div class="min-w-0 flex-1">
<div class="truncate text-[13px] font-medium leading-[16px] text-white">
{{ ad.title }}
</div>
<div
v-if="ad.subtitle"
class="mt-[2px] center-row min-w-0 gap-[2px] text-[11px] leading-[12px] text-[#8A8A8A]"
>
<span class="truncate">{{ ad.subtitle }}</span>
</div>
</div>
</div>
</div>
<div
class="flex-1 center-row justify-end shrink-0 gap-2 text-xs text-[#6f6f6f] pr-4 w-[200px]"
>
<button
type="button"
class="center-row justify-center h-[24px] w-[24px] rounded-[6px] border border-[#3b3b3b] bg-[#242424] text-[#9d9d9d] transition-colors duration-150 hover:border-[#4c4c4c] hover:text-white disabled:cursor-not-allowed disabled:opacity-60"
:disabled="loading"
:title="loading ? '刷新中' : '刷新统计'"
@click="emit('refresh')"
>
<span
class="icon-[mdi--refresh] text-[14px]"
:class="{ '!animate-spin': loading }"
></span>
</button>
</div>
</div>
<div
class="mt-[-4px] grid grid-cols-4 gap-0 overflow-hidden rounded-[8px] border border-[#343434] bg-[#242424] h-[130px]"
>
<div class="min-w-0 px-4 py-4 flex flex-col justify-between">
<div class="center-row justify-start gap-1 text-xs text-[#7f7f7f]">
<span>缓存命中率</span>
<Tooltip>
<div class="w-[280px] space-y-3">
<div class="border-b border-[#343434] pb-3">
<Switch
compact
label="计入缓存创建"
description="开启后把缓存创建纳入分母"
enabled-text="当前按复用率口径显示"
disabled-text="当前按默认命中率口径显示"
:enabled="includeCacheWriteInHitRate"
:busy="homeMetricsConfigSaving"
:disabled="homeMetricsConfigSaving"
@change="toggleIncludeCacheWriteInHitRate"
/>
</div>
<div class="whitespace-pre-wrap">{{ cacheTooltipContent }}</div>
<div v-if="homeMetricsConfigError" class="text-[11px] text-[#f87171]">
{{ homeMetricsConfigError }}
</div>
</div>
</Tooltip>
</div>
<CacheHitRateChart :rate="selectedCacheHitRate" />
</div>
<div
class="min-w-0 border-l border-[#343434] px-4 py-4 flex flex-col justify-between"
>
<div class="center-row justify-start gap-1 text-xs text-[#7f7f7f]">
<span>对话轮次</span>
<Tooltip :content="turnsTooltipContent" />
</div>
<div>
<div
class="text-[30px] leading-none text-white"
style="font-family: var(--font-num)"
:title="formatInteger(metrics.turnsTotal)"
>
{{ formatCompactInteger(metrics.turnsTotal) }}
</div>
<div class="mt-3 text-xs leading-5 text-[#8c8c8c]">
有效
<span :title="formatInteger(metrics.validTurnsTotal)">
{{ formatCompactInteger(metrics.validTurnsTotal) }}
</span>
/ 异常
<span :title="formatInteger(metrics.invalidTurnsTotal)">
{{ formatCompactInteger(metrics.invalidTurnsTotal) }}
</span>
</div>
</div>
</div>
<div
class="min-w-0 border-l border-[#343434] px-4 py-4 flex flex-col justify-between"
>
<div class="center-row justify-start gap-1 text-xs text-[#7f7f7f]">
<span>Token 消耗</span>
<Tooltip :content="tokensTooltipContent" />
</div>
<div>
<div
class="truncate text-[30px] leading-none text-white"
style="font-family: var(--font-num)"
:title="formatInteger(metrics.requestTokensTotal)"
>
{{ formatCompactInteger(metrics.requestTokensTotal) }}
</div>
<div class="mt-3 text-xs leading-5 text-[#8c8c8c]">
Prompt
<span :title="formatInteger(metrics.promptTokensTotal)">
{{ formatCompactInteger(metrics.promptTokensTotal) }}
</span>
</div>
</div>
</div>
<div
class="min-w-0 border-l border-[#343434] px-4 py-4 flex flex-col justify-between"
>
<div class="center-row justify-start gap-1 text-xs text-[#7f7f7f]">
<span>价值估算</span>
<Tooltip :content="costTooltipContent" />
</div>
<div>
<div
class="truncate text-[30px] leading-none text-white"
style="font-family: var(--font-num)"
:title="formatUSD(estimatedTokenCost.total)"
>
{{ formatUSD(estimatedTokenCost.total) }}
</div>
<div class="mt-3 text-xs leading-5 text-[#8c8c8c]">
缓存读写
<span :title="formatUSD(estimatedTokenCost.cacheRead + estimatedTokenCost.cacheWrite)">
{{ formatUSD(estimatedTokenCost.cacheRead + estimatedTokenCost.cacheWrite) }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped></style>