- 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
341 lines
15 KiB
Vue
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>
|