Files
zn-ai/src/pages/agents/index.vue
duanshuwen 5f542715cb feat(models): add models configuration page with provider management and token usage
- Replace "Task Center" with "Models" in sidebar and routing
- Add new models configuration page with AI provider account management
- Implement token usage history with filtering, grouping, and visualization
- Create provider store with Pinia for state management
- Add internationalization support for models feature
- Include development analysis documentation for feature implementation
2026-04-09 21:49:52 +08:00

341 lines
15 KiB
Vue

<template>
<layout>
<div class="bg-white w-full h-full flex flex-col p-[20px] rounded-[16px] overflow-hidden">
<!-- Header -->
<TitleSection :title="t('models.title')" :desc="t('models.subtitle')" />
<!-- Content Area -->
<div class="flex-1 overflow-y-auto pr-2 pb-10 min-h-0 space-y-12">
<!-- AI Providers Section -->
<ProvidersSettings />
<!-- Token Usage History Section -->
<div>
<h2 class="text-2xl font-serif text-[#171717] mb-6 font-normal tracking-tight">
{{ t('models.recentTokenHistory.title', 'Token Usage History') }}
</h2>
<div v-if="usageLoading" class="flex items-center justify-center py-12 text-[#99A0AE] bg-gray-50 rounded-2xl border border-transparent border-dashed">
<el-icon class="is-loading mr-2 text-xl"><Loading /></el-icon>
{{ t('models.recentTokenHistory.loading', 'Loading usage history...') }}
</div>
<div v-else-if="visibleUsageHistory.length === 0" class="flex items-center justify-center py-12 text-[#99A0AE] bg-gray-50 rounded-2xl border border-transparent border-dashed">
{{ t('models.recentTokenHistory.empty', 'No usage history available.') }}
</div>
<div v-else-if="filteredUsageHistory.length === 0" class="flex items-center justify-center py-12 text-[#99A0AE] bg-gray-50 rounded-2xl border border-transparent border-dashed">
{{ t('models.recentTokenHistory.emptyForWindow', 'No usage history in the selected time window.') }}
</div>
<div v-else class="space-y-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-wrap items-center gap-3">
<div class="flex rounded-lg bg-gray-100 p-1 border border-gray-200">
<el-button
:type="usageGroupBy === 'model' ? 'primary' : 'default'"
:plain="usageGroupBy !== 'model'"
size="small"
@click="usageGroupBy = 'model'; usagePage = 1"
class="border-0"
>
{{ t('models.recentTokenHistory.groupByModel', 'By Model') }}
</el-button>
<el-button
:type="usageGroupBy === 'day' ? 'primary' : 'default'"
:plain="usageGroupBy !== 'day'"
size="small"
@click="usageGroupBy = 'day'; usagePage = 1"
class="border-0"
>
{{ t('models.recentTokenHistory.groupByTime', 'By Time') }}
</el-button>
</div>
<div class="flex rounded-lg bg-gray-100 p-1 border border-gray-200">
<el-button
:type="usageWindow === '7d' ? 'primary' : 'default'"
:plain="usageWindow !== '7d'"
size="small"
@click="usageWindow = '7d'; usagePage = 1"
class="border-0"
>
{{ t('models.recentTokenHistory.last7Days', 'Last 7 Days') }}
</el-button>
<el-button
:type="usageWindow === '30d' ? 'primary' : 'default'"
:plain="usageWindow !== '30d'"
size="small"
@click="usageWindow = '30d'; usagePage = 1"
class="border-0"
>
{{ t('models.recentTokenHistory.last30Days', 'Last 30 Days') }}
</el-button>
<el-button
:type="usageWindow === 'all' ? 'primary' : 'default'"
:plain="usageWindow !== 'all'"
size="small"
@click="usageWindow = 'all'; usagePage = 1"
class="border-0"
>
{{ t('models.recentTokenHistory.allTime', 'All Time') }}
</el-button>
</div>
</div>
<p class="text-[13px] font-medium text-[#99A0AE]">
{{ usageRefreshing
? t('models.recentTokenHistory.loading', 'Loading...')
: t('models.recentTokenHistory.showingLast', `Showing ${filteredUsageHistory.length} records`) }}
</p>
</div>
<UsageBarChart
:groups="usageGroups"
:emptyLabel="t('models.recentTokenHistory.empty', 'Empty')"
:totalLabel="t('models.recentTokenHistory.totalTokens', 'Total')"
:inputLabel="t('models.recentTokenHistory.inputShort', 'Input')"
:outputLabel="t('models.recentTokenHistory.outputShort', 'Output')"
:cacheLabel="t('models.recentTokenHistory.cacheShort', 'Cache')"
/>
<div class="space-y-3 pt-2">
<div
v-for="entry in pagedUsageHistory"
:key="`${entry.sessionId}-${entry.timestamp}`"
class="rounded-xl bg-transparent border border-gray-200 p-5 hover:bg-gray-50 transition-colors"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="font-semibold text-[15px] text-[#171717] truncate">
{{ entry.model || t('models.recentTokenHistory.unknownModel', 'Unknown Model') }}
</p>
<p class="text-[13px] text-[#99A0AE] truncate mt-0.5">
{{ [formatUsageSource(entry.provider), formatUsageSource(entry.agentId), entry.sessionId].filter(Boolean).join(' • ') }}
</p>
</div>
<div class="text-right shrink-0">
<p :class="getUsageTotalClass(entry)">
{{ formatUsageTotal(entry) }}
</p>
<p v-if="entry.usageStatus === 'missing'" class="text-[12px] text-[#99A0AE] mt-0.5">
{{ t('models.recentTokenHistory.noUsage', 'No usage info') }}
</p>
<p v-if="entry.usageStatus === 'error'" class="text-[12px] text-red-500 mt-0.5">
{{ t('models.recentTokenHistory.usageParseError', 'Error parsing usage') }}
</p>
<p class="text-[12px] text-[#99A0AE] mt-0.5">
{{ formatUsageTimestamp(entry.timestamp) }}
</p>
</div>
</div>
<div class="mt-3 flex flex-wrap gap-x-4 gap-y-1.5 text-[12.5px] font-medium text-[#99A0AE]">
<template v-if="entry.usageStatus === 'available' || entry.usageStatus === undefined">
<span class="flex items-center gap-1.5"><div class="w-2 h-2 rounded-full bg-sky-500"></div>{{ t('models.recentTokenHistory.input', `Input: ${formatTokenCount(entry.inputTokens)}`) }}</span>
<span class="flex items-center gap-1.5"><div class="w-2 h-2 rounded-full bg-violet-500"></div>{{ t('models.recentTokenHistory.output', `Output: ${formatTokenCount(entry.outputTokens)}`) }}</span>
<span v-if="entry.cacheReadTokens > 0" class="flex items-center gap-1.5"><div class="w-2 h-2 rounded-full bg-amber-500"></div>{{ t('models.recentTokenHistory.cacheRead', `Cache Read: ${formatTokenCount(entry.cacheReadTokens)}`) }}</span>
<span v-if="entry.cacheWriteTokens > 0" class="flex items-center gap-1.5"><div class="w-2 h-2 rounded-full bg-amber-500"></div>{{ t('models.recentTokenHistory.cacheWrite', `Cache Write: ${formatTokenCount(entry.cacheWriteTokens)}`) }}</span>
</template>
<span v-else class="text-[12px]">
{{ entry.usageStatus === 'missing'
? t('models.recentTokenHistory.noUsage', 'No usage reported')
: t('models.recentTokenHistory.usageParseError', 'Parse error') }}
</span>
<span v-if="typeof entry.costUsd === 'number' && Number.isFinite(entry.costUsd)" class="flex items-center gap-1.5 ml-auto text-[#171717] bg-gray-100 px-2 py-0.5 rounded-md">
{{ t('models.recentTokenHistory.cost', `$${entry.costUsd.toFixed(4)}`) }}
</span>
<el-button
v-if="devModeUnlocked && entry.content"
size="small"
class="h-6 rounded-full px-2.5 text-[11.5px] ml-2"
@click="selectedUsageEntry = entry"
>
{{ t('models.recentTokenHistory.viewContent', 'View Content') }}
</el-button>
</div>
</div>
</div>
<div class="flex items-center justify-between gap-3 pt-2">
<p class="text-[13px] font-medium text-[#99A0AE]">
{{ t('models.recentTokenHistory.page', `Page ${safeUsagePage} of ${usageTotalPages}`) }}
</p>
<div class="flex items-center gap-2">
<el-button
size="small"
@click="usagePage = Math.max(1, usagePage - 1)"
:disabled="safeUsagePage <= 1"
class="rounded-full px-4 h-9"
>
<el-icon class="mr-1"><ArrowLeft /></el-icon>
{{ t('models.recentTokenHistory.prev', 'Prev') }}
</el-button>
<el-button
size="small"
@click="usagePage = Math.min(usageTotalPages, usagePage + 1)"
:disabled="safeUsagePage >= usageTotalPages"
class="rounded-full px-4 h-9"
>
{{ t('models.recentTokenHistory.next', 'Next') }}
<el-icon class="ml-1"><ArrowRight /></el-icon>
</el-button>
</div>
</div>
</div>
</div>
</div>
<!-- Dev Mode Popup -->
<RequestContentDialog
v-if="devModeUnlocked && selectedUsageEntry"
v-model="popupVisible"
:entry="selectedUsageEntry"
@closed="selectedUsageEntry = null"
/>
</div>
</layout>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import ProvidersSettings from './components/ProvidersSettings.vue';
import UsageBarChart from './components/UsageBarChart.vue';
import RequestContentDialog from './components/RequestContentDialog.vue';
import TitleSection from '@src/components/TitleSection/index.vue'
import { hostApiFetch } from '@lib/host-api';
import {
filterUsageHistoryByWindow,
groupUsageHistory,
resolveStableUsageHistory,
resolveVisibleUsageHistory,
type UsageGroupBy,
type UsageHistoryEntry,
type UsageWindow,
} from './usage-history';
const { t } = useI18n();
// States
const devModeUnlocked = ref(true); // Default true for now or fetch from a settings store
const usageGroupBy = ref<UsageGroupBy>('model');
const usageWindow = ref<UsageWindow>('7d');
const usagePage = ref(1);
const selectedUsageEntry = ref<UsageHistoryEntry | null>(null);
const popupVisible = computed({
get: () => selectedUsageEntry.value !== null,
set: (v) => { if (!v) selectedUsageEntry.value = null; }
});
const fetchState = ref<{
status: 'idle' | 'loading' | 'done';
data: UsageHistoryEntry[];
stableData: UsageHistoryEntry[];
}>({
status: 'idle',
data: [],
stableData: [],
});
const usageLoading = computed(() => fetchState.value.status === 'loading' && visibleUsageHistory.value.length === 0);
const usageRefreshing = computed(() => fetchState.value.status === 'loading' && visibleUsageHistory.value.length > 0);
// Fetching logic
let refreshTimer: ReturnType<typeof setInterval> | null = null;
const fetchUsage = async () => {
if (fetchState.value.status === 'loading') return;
fetchState.value.status = 'loading';
try {
const entries = await hostApiFetch<UsageHistoryEntry[]>('/api/usage/recent-token-history');
const normalized = Array.isArray(entries) ? entries : [];
fetchState.value.stableData = resolveStableUsageHistory(fetchState.value.stableData, normalized);
fetchState.value.data = normalized;
fetchState.value.status = 'done';
} catch (error) {
console.error('Failed to fetch usage history', error);
fetchState.value.status = 'done';
}
};
onMounted(() => {
fetchUsage();
refreshTimer = setInterval(fetchUsage, 15000);
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
fetchUsage();
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
});
onUnmounted(() => {
if (refreshTimer) clearInterval(refreshTimer);
});
// Computed properties
const HIDDEN_USAGE_MARKERS = ['gateway-injected', 'delivery-mirror'];
const isHiddenUsageSource = (source?: string) => {
if (!source) return false;
const normalizedSource = source.trim().toLowerCase();
return HIDDEN_USAGE_MARKERS.some((marker) => normalizedSource.includes(marker));
};
const formatUsageSource = (source?: string) => {
if (!source) return undefined;
if (isHiddenUsageSource(source)) return undefined;
return source;
};
const shouldHideUsageEntry = (entry: UsageHistoryEntry) => {
return isHiddenUsageSource(entry.provider) || isHiddenUsageSource(entry.model);
};
const usageHistory = computed(() => fetchState.value.data.filter((entry) => !shouldHideUsageEntry(entry)));
const stableUsageHistory = computed(() => fetchState.value.stableData.filter((entry) => !shouldHideUsageEntry(entry)));
const visibleUsageHistory = computed(() => resolveVisibleUsageHistory(usageHistory.value, stableUsageHistory.value, {
preferStableOnEmpty: fetchState.value.status === 'loading',
}));
const filteredUsageHistory = computed(() => filterUsageHistoryByWindow(visibleUsageHistory.value, usageWindow.value));
const usageGroups = computed(() => groupUsageHistory(filteredUsageHistory.value, usageGroupBy.value));
const usagePageSize = 5;
const usageTotalPages = computed(() => Math.max(1, Math.ceil(filteredUsageHistory.value.length / usagePageSize)));
const safeUsagePage = computed(() => Math.min(usagePage.value, usageTotalPages.value));
const pagedUsageHistory = computed(() => {
const start = (safeUsagePage.value - 1) * usagePageSize;
const end = safeUsagePage.value * usagePageSize;
return filteredUsageHistory.value.slice(start, end);
});
const formatTokenCount = (value: number) => Intl.NumberFormat().format(value);
const getUsageTotalClass = (entry: UsageHistoryEntry) => {
if (entry.usageStatus === 'error') return 'font-bold text-[15px] text-red-500';
if (entry.usageStatus === 'missing') return 'font-bold text-[15px] text-[#99A0AE]';
return 'font-bold text-[15px] text-[#171717]';
};
const formatUsageTotal = (entry: UsageHistoryEntry) => {
if (entry.usageStatus === 'error') return '✕';
if (entry.usageStatus === 'missing') return '—';
return formatTokenCount(entry.totalTokens);
};
const formatUsageTimestamp = (timestamp: string) => {
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) return timestamp;
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(date);
};
watch(usageGroupBy, () => usagePage.value = 1);
watch(usageWindow, () => usagePage.value = 1);
</script>