mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-08-17 11:37:20 +08:00
- 修复内存泄漏问题,该可能导致内存异常占用 - 支持俄语增加翻译范围 - 修复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标准接口拉取。
403 lines
14 KiB
Vue
403 lines
14 KiB
Vue
<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>
|