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
This commit is contained in:
400
src/pages/agents/components/ProvidersSettings.vue
Normal file
400
src/pages/agents/components/ProvidersSettings.vue
Normal file
@@ -0,0 +1,400 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-[#171717]">{{ t('models.aiProviders.title', 'AI Providers') }}</h3>
|
||||
<p class="text-sm text-[#99A0AE] mt-1">{{ t('models.aiProviders.subtitle', 'Manage your AI models and API keys.') }}</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="showAddDialog = true">
|
||||
<el-icon class="mr-1"><Plus /></el-icon>
|
||||
{{ t('models.aiProviders.addProvider', 'Add Provider') }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- Provider List -->
|
||||
<div v-if="loading" class="py-8 text-center text-[#99A0AE]">
|
||||
<el-icon class="is-loading text-2xl"><Loading /></el-icon>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<el-card v-for="item in displayProviders" :key="item.account.id" shadow="hover" class="provider-card">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center text-xl overflow-hidden border border-gray-200">
|
||||
{{ item.vendor?.icon || '🤖' }}
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-[16px] text-[#171717]">{{ item.account.label || item.vendor?.name }}</span>
|
||||
<el-tag size="small" :type="item.account.isDefault ? 'success' : 'info'" v-if="item.account.isDefault">
|
||||
{{ t('models.aiProviders.default', 'Default') }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="text-sm text-[#99A0AE] mt-1 flex items-center gap-2">
|
||||
<span v-if="item.account.baseUrl" class="truncate max-w-[200px]">{{ item.account.baseUrl }}</span>
|
||||
<span v-if="item.account.model" class="truncate max-w-[150px] bg-gray-100 px-2 py-0.5 rounded text-xs">{{ item.account.model }}</span>
|
||||
<span v-if="item.status?.hasKey" class="text-green-600 text-xs flex items-center">
|
||||
<el-icon class="mr-1"><Check /></el-icon> {{ t('models.aiProviders.configured', 'Configured') }}
|
||||
</span>
|
||||
<span v-else class="text-orange-500 text-xs flex items-center">
|
||||
<el-icon class="mr-1"><Warning /></el-icon> {{ t('models.aiProviders.notConfigured', 'Not Configured') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<el-button v-if="!item.account.isDefault && item.status?.hasKey" size="small" @click="handleSetDefault(item.account.id)">
|
||||
{{ t('models.aiProviders.setDefault', 'Set Default') }}
|
||||
</el-button>
|
||||
<el-button size="small" @click="handleEdit(item)">
|
||||
{{ t('models.aiProviders.edit', 'Edit') }}
|
||||
</el-button>
|
||||
<el-popconfirm :title="t('models.aiProviders.deleteConfirm', 'Are you sure you want to delete this provider?')" @confirm="handleDelete(item.account.id)">
|
||||
<template #reference>
|
||||
<el-button size="small" type="danger" plain>
|
||||
{{ t('models.aiProviders.delete', 'Delete') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<div v-if="displayProviders.length === 0" class="text-center py-12 border border-dashed border-gray-300 rounded-lg text-gray-500">
|
||||
{{ t('models.aiProviders.empty', 'No providers configured yet. Click "Add Provider" to start.') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Dialog -->
|
||||
<el-dialog
|
||||
v-model="editDialogVisible"
|
||||
width="640px"
|
||||
:show-close="false"
|
||||
class="custom-provider-dialog"
|
||||
>
|
||||
<div class="p-[30px] pb-[20px]">
|
||||
<div class="flex justify-between items-start mb-[24px]">
|
||||
<div>
|
||||
<h2 class="text-[24px] font-serif text-[#171717] mb-[8px] font-normal tracking-tight">
|
||||
{{ editingItem?.account.id.includes('-') ? t('models.aiProviders.addProvider', '添加 AI 提供商') : t('models.aiProviders.editProvider', '编辑提供商') }}
|
||||
</h2>
|
||||
<p class="text-[14px] text-[#99A0AE]">
|
||||
配置新的 AI 模型提供商
|
||||
</p>
|
||||
</div>
|
||||
<button @click="editDialogVisible = false" class="text-[#99A0AE] hover:text-[#171717] transition-colors mt-[4px]">
|
||||
<el-icon class="text-[20px]"><Close /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-[16px] p-[20px] flex items-center gap-[16px] mb-[24px] shadow-sm">
|
||||
<div class="w-[48px] h-[48px] rounded-[14px] bg-[#F4F3EB] flex items-center justify-center text-[24px] overflow-hidden">
|
||||
{{ editingItem?.vendor?.icon || '🤖' }}
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-[16px] font-medium text-[#171717] mb-[4px]">{{ editingItem?.vendor?.name }}</span>
|
||||
<div class="flex items-center text-[13px] text-[#007AFF]">
|
||||
<a href="javascript:void(0)" @click="showAddDialog = true; editDialogVisible = false" class="hover:underline">更换提供商</a>
|
||||
<span class="mx-[8px] text-[#E5E8EE]">|</span>
|
||||
<a v-if="editingItem?.vendor?.docsUrl" :href="editingItem.vendor.docsUrl" target="_blank" class="hover:underline flex items-center gap-[4px]">
|
||||
查看文档
|
||||
<el-icon class="text-[12px]"><TopRight /></el-icon>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form :model="editForm" label-position="top" @submit.prevent class="custom-form">
|
||||
<el-form-item label="显示名称" class="mb-[24px]">
|
||||
<el-input v-if="editingItem" v-model="editingItem.account.label" :placeholder="editingItem?.vendor?.name" class="custom-input font-mono" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="API 密钥" class="mb-[8px]">
|
||||
<el-input
|
||||
v-model="editForm.apiKey"
|
||||
:placeholder="editingItem?.vendor?.placeholder || t('models.aiProviders.apiKeyPlaceholder', 'Enter API Key')"
|
||||
show-password
|
||||
clearable
|
||||
class="custom-input font-mono"
|
||||
>
|
||||
</el-input>
|
||||
<div class="text-[12px] text-[#99A0AE] mt-[8px]">
|
||||
您的 API 密钥存储在本地机器上。
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<template v-if="editingItem?.vendor?.showBaseUrl">
|
||||
<el-form-item :label="t('models.aiProviders.baseUrl', 'Base URL (Optional)')" class="mt-[24px] mb-[24px]">
|
||||
<el-input v-model="editForm.baseUrl" :placeholder="editingItem?.vendor?.defaultBaseUrl || 'https://api.example.com/v1'" clearable class="custom-input font-mono" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<template v-if="editingItem?.vendor?.showModelId">
|
||||
<el-form-item :label="t('models.aiProviders.defaultModel', 'Default Model (Optional)')" class="mb-[24px]">
|
||||
<el-input v-model="editForm.model" :placeholder="editingItem?.vendor?.modelIdPlaceholder || editingItem?.vendor?.defaultModelId || 'e.g. gpt-4'" clearable class="custom-input font-mono" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
|
||||
<div class="h-[1px] bg-[rgba(0,0,0,0.05)] w-full my-[24px]"></div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<el-button type="primary" @click="saveEdit" :loading="saving" class="h-[40px] px-[24px] rounded-[20px] bg-[#007AFF] hover:bg-[#0066FF] border-none text-[14px] font-medium">
|
||||
{{ editingItem?.account.id.includes('-') ? '添加提供商' : t('common.save', 'Save') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Add Dialog -->
|
||||
<el-dialog
|
||||
v-model="showAddDialog"
|
||||
width="800px"
|
||||
:show-close="false"
|
||||
class="custom-provider-dialog"
|
||||
>
|
||||
<div class="p-[30px]">
|
||||
<div class="flex justify-between items-start mb-[30px]">
|
||||
<div>
|
||||
<h2 class="text-[24px] font-serif text-[#171717] mb-[8px] font-normal tracking-tight">
|
||||
{{ t('models.aiProviders.addProvider', 'Add AI Provider') }}
|
||||
</h2>
|
||||
<p class="text-[14px] text-[#99A0AE]">
|
||||
配置新的 AI 模型提供商
|
||||
</p>
|
||||
</div>
|
||||
<button @click="showAddDialog = false" class="text-[#99A0AE] hover:text-[#171717] transition-colors mt-[4px]">
|
||||
<el-icon class="text-[20px]"><Close /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-[16px]">
|
||||
<div
|
||||
v-for="provider in availableProviders"
|
||||
:key="provider.id"
|
||||
@click="selectNewProvider(provider)"
|
||||
class="cursor-pointer border border-[rgba(0,0,0,0.05)] rounded-[16px] p-[20px] flex flex-col items-center justify-center gap-[12px] bg-[rgba(255,255,255,0.4)] hover:bg-[rgba(255,255,255,0.8)] transition-all duration-200"
|
||||
>
|
||||
<div class="w-[48px] h-[48px] rounded-[14px] bg-[rgba(0,0,0,0.05)] flex items-center justify-center text-[24px] overflow-hidden">
|
||||
{{ provider.icon || '🤖' }}
|
||||
</div>
|
||||
<span class="text-[14px] font-medium text-[#171717] text-center">{{ provider.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Plus, Loading, Check, Warning, Close, TopRight } from '@element-plus/icons-vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { useProviderStore } from '@src/store/providers';
|
||||
import { PROVIDER_TYPE_INFO, type ProviderTypeInfo } from '@lib/providers';
|
||||
import type { ProviderListItem } from '@lib/provider-accounts';
|
||||
|
||||
const { t } = useI18n();
|
||||
const providerStore = useProviderStore();
|
||||
|
||||
const showAddDialog = ref(false);
|
||||
const editDialogVisible = ref(false);
|
||||
const validating = ref(false);
|
||||
const saving = ref(false);
|
||||
const editingItem = ref<ProviderListItem | null>(null);
|
||||
|
||||
const editForm = ref({
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
model: ''
|
||||
});
|
||||
|
||||
const loading = computed(() => providerStore.loading);
|
||||
const displayProviders = computed(() => {
|
||||
const { accounts, statuses, vendors, defaultAccountId } = providerStore;
|
||||
|
||||
// Safe map building
|
||||
const safeAccounts = accounts || [];
|
||||
const safeStatuses = statuses || [];
|
||||
const safeVendors = vendors || [];
|
||||
|
||||
const vendorMap = new Map(safeVendors.map((vendor) => [vendor.id, vendor]));
|
||||
const statusMap = new Map(safeStatuses.map((status) => [status.id, status]));
|
||||
|
||||
if (safeAccounts.length > 0) {
|
||||
return safeAccounts
|
||||
.map((account) => ({
|
||||
account: { ...account, isDefault: account.id === defaultAccountId },
|
||||
vendor: vendorMap.get(account.vendorId) || PROVIDER_TYPE_INFO.find(p => p.id === account.vendorId) as any,
|
||||
status: statusMap.get(account.id),
|
||||
}))
|
||||
.sort((left, right) => {
|
||||
if (left.account.id === defaultAccountId) return -1;
|
||||
if (right.account.id === defaultAccountId) return 1;
|
||||
return right.account.updatedAt.localeCompare(left.account.updatedAt);
|
||||
});
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const availableProviders = computed(() => {
|
||||
return PROVIDER_TYPE_INFO.filter(p => !p.hidden);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
providerStore.init();
|
||||
});
|
||||
|
||||
const handleSetDefault = async (id: string) => {
|
||||
try {
|
||||
await providerStore.setDefaultAccount(id);
|
||||
ElMessage.success(t('models.aiProviders.setDefaultSuccess', 'Default provider set successfully'));
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.message || 'Failed to set default provider');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await providerStore.deleteAccount(id);
|
||||
ElMessage.success(t('models.aiProviders.deleteSuccess', 'Provider deleted successfully'));
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.message || 'Failed to delete provider');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (item: ProviderListItem) => {
|
||||
editingItem.value = item;
|
||||
editForm.value = {
|
||||
apiKey: '', // Don't fetch actual key to UI unless requested, usually handled via backend updates
|
||||
baseUrl: item.account.baseUrl || '',
|
||||
model: item.account.model || ''
|
||||
};
|
||||
editDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const selectNewProvider = (provider: ProviderTypeInfo) => {
|
||||
showAddDialog.value = false;
|
||||
|
||||
// Setup a new mock item to edit
|
||||
editingItem.value = {
|
||||
account: {
|
||||
id: `${provider.id}-${Date.now()}`,
|
||||
vendorId: provider.id,
|
||||
label: provider.name,
|
||||
authMode: provider.requiresApiKey ? 'api_key' : 'local',
|
||||
baseUrl: provider.defaultBaseUrl || '',
|
||||
model: provider.defaultModelId || '',
|
||||
enabled: true,
|
||||
isDefault: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
vendor: provider as any,
|
||||
status: undefined
|
||||
};
|
||||
|
||||
editForm.value = {
|
||||
apiKey: '',
|
||||
baseUrl: provider.defaultBaseUrl || '',
|
||||
model: provider.defaultModelId || ''
|
||||
};
|
||||
editDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const validateKey = async (providerId: string, apiKey: string, baseUrl?: string) => {
|
||||
if (!apiKey) {
|
||||
ElMessage.warning(t('models.aiProviders.apiKeyRequired', 'Please enter an API Key first'));
|
||||
return;
|
||||
}
|
||||
validating.value = true;
|
||||
try {
|
||||
// Note: If adding a new provider, we pass the vendor ID. If updating, we pass the account ID.
|
||||
// The backend `validateApiKey` handles this mapping or we can adjust logic accordingly.
|
||||
const vendorId = editingItem.value?.account.vendorId || providerId;
|
||||
const res = await providerStore.validateApiKey(vendorId, apiKey, { baseUrl });
|
||||
if (res.valid) {
|
||||
ElMessage.success(t('models.aiProviders.validateSuccess', 'API Key is valid!'));
|
||||
} else {
|
||||
ElMessage.error(res.error || t('models.aiProviders.validateFail', 'Invalid API Key'));
|
||||
}
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.message || 'Validation failed');
|
||||
} finally {
|
||||
validating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
if (!editingItem.value) return;
|
||||
saving.value = true;
|
||||
|
||||
try {
|
||||
const isNew = !displayProviders.value.find(p => p.account.id === editingItem.value!.account.id);
|
||||
const updates = {
|
||||
baseUrl: editForm.value.baseUrl || undefined,
|
||||
model: editForm.value.model || undefined
|
||||
};
|
||||
|
||||
if (isNew) {
|
||||
// It's a new provider
|
||||
const accountToCreate = {
|
||||
...editingItem.value.account,
|
||||
...updates
|
||||
};
|
||||
await providerStore.createAccount(accountToCreate, editForm.value.apiKey);
|
||||
} else {
|
||||
// Update existing
|
||||
await providerStore.updateAccount(editingItem.value.account.id, updates, editForm.value.apiKey || undefined);
|
||||
}
|
||||
|
||||
ElMessage.success(t('models.aiProviders.saveSuccess', 'Provider saved successfully'));
|
||||
editDialogVisible.value = false;
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e.message || 'Failed to save provider');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.provider-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid #E5E8EE;
|
||||
box-shadow: none;
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
.custom-provider-dialog {
|
||||
background-color: #F4F3EB !important;
|
||||
border-radius: 20px !important;
|
||||
}
|
||||
.custom-provider-dialog .el-dialog__header {
|
||||
display: none !important;
|
||||
}
|
||||
.custom-provider-dialog .el-dialog__body {
|
||||
padding: 0 !important;
|
||||
}
|
||||
.custom-form .el-form-item__label {
|
||||
font-weight: 600 !important;
|
||||
color: #171717 !important;
|
||||
padding-bottom: 8px !important;
|
||||
font-size: 15px !important;
|
||||
}
|
||||
.custom-input .el-input__wrapper {
|
||||
background-color: #F4F3EB !important;
|
||||
border: 1px solid rgba(0,0,0,0.1) !important;
|
||||
border-radius: 12px !important;
|
||||
box-shadow: none !important;
|
||||
padding: 10px 16px !important;
|
||||
}
|
||||
.custom-input .el-input__wrapper:hover, .custom-input .el-input__wrapper.is-focus {
|
||||
border-color: rgba(0,0,0,0.2) !important;
|
||||
}
|
||||
.custom-input .el-input__inner {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important;
|
||||
}
|
||||
</style>
|
||||
84
src/pages/agents/components/RequestContentDialog.vue
Normal file
84
src/pages/agents/components/RequestContentDialog.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
width="800px"
|
||||
:show-close="false"
|
||||
class="custom-request-dialog"
|
||||
@closed="handleClosed"
|
||||
>
|
||||
<div class="p-[30px]">
|
||||
<div class="flex justify-between items-start mb-[24px]">
|
||||
<div>
|
||||
<h2 class="text-[24px] font-serif text-[#171717] mb-[8px] font-normal tracking-tight">
|
||||
{{ t('models.recentTokenHistory.contentDialogTitle', 'Request Content') }}
|
||||
</h2>
|
||||
<p class="text-[14px] text-[#99A0AE]">
|
||||
{{ entry?.model || t('models.recentTokenHistory.unknownModel', 'Unknown Model') }} • {{ formatUsageTimestamp(entry?.timestamp) }}
|
||||
</p>
|
||||
</div>
|
||||
<button @click="visible = false" class="text-[#99A0AE] hover:text-[#171717] transition-colors mt-[4px]">
|
||||
<el-icon class="text-[20px]"><Close /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="max-h-[60vh] overflow-y-auto p-[20px] bg-white rounded-[16px] border border-[rgba(0,0,0,0.05)] shadow-sm">
|
||||
<pre class="whitespace-pre-wrap break-words text-[13px] leading-relaxed text-[#171717] font-mono">
|
||||
{{ entry?.content }}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Close } from '@element-plus/icons-vue';
|
||||
import type { UsageHistoryEntry } from '../usage-history';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
entry: UsageHistoryEntry | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void;
|
||||
(e: 'closed'): void;
|
||||
}>();
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
});
|
||||
|
||||
const handleClosed = () => {
|
||||
emit('closed');
|
||||
};
|
||||
|
||||
const formatUsageTimestamp = (timestamp?: string) => {
|
||||
if (!timestamp) return '';
|
||||
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);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.custom-request-dialog {
|
||||
background-color: #F4F3EB !important;
|
||||
border-radius: 20px !important;
|
||||
}
|
||||
.custom-request-dialog .el-dialog__header {
|
||||
display: none !important;
|
||||
}
|
||||
.custom-request-dialog .el-dialog__body {
|
||||
padding: 0 !important;
|
||||
}
|
||||
</style>
|
||||
84
src/pages/agents/components/UsageBarChart.vue
Normal file
84
src/pages/agents/components/UsageBarChart.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<div v-if="groups.length === 0" class="rounded-2xl border border-dashed border-gray-300 dark:border-gray-700 p-8 text-center text-[14px] font-medium text-gray-500">
|
||||
{{ emptyLabel }}
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4 bg-transparent p-5 rounded-2xl border border-gray-200 dark:border-gray-800">
|
||||
<div class="flex flex-wrap gap-4 text-[13px] font-medium text-gray-500 mb-2">
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<span class="h-2.5 w-2.5 rounded-full bg-sky-500" />
|
||||
{{ inputLabel }}
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<span class="h-2.5 w-2.5 rounded-full bg-violet-500" />
|
||||
{{ outputLabel }}
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<span class="h-2.5 w-2.5 rounded-full bg-amber-500" />
|
||||
{{ cacheLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-for="group in groups" :key="group.label" class="space-y-1.5">
|
||||
<div class="flex items-center justify-between gap-3 text-[13.5px]">
|
||||
<span class="truncate font-semibold text-gray-900 dark:text-gray-100">{{ group.label }}</span>
|
||||
<span class="text-gray-500 font-medium">
|
||||
{{ totalLabel }}: {{ formatTokenCount(group.totalTokens) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-3.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
|
||||
<div
|
||||
class="flex h-full overflow-hidden rounded-full"
|
||||
:style="{
|
||||
width: group.totalTokens > 0
|
||||
? `${Math.max((group.totalTokens / maxTokens) * 100, 6)}%`
|
||||
: '0%',
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-if="group.inputTokens > 0"
|
||||
class="h-full bg-sky-500"
|
||||
:style="{ width: `${(group.inputTokens / group.totalTokens) * 100}%` }"
|
||||
/>
|
||||
<div
|
||||
v-if="group.outputTokens > 0"
|
||||
class="h-full bg-violet-500"
|
||||
:style="{ width: `${(group.outputTokens / group.totalTokens) * 100}%` }"
|
||||
/>
|
||||
<div
|
||||
v-if="group.cacheTokens > 0"
|
||||
class="h-full bg-amber-500"
|
||||
:style="{ width: `${(group.cacheTokens / group.totalTokens) * 100}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
groups: Array<{
|
||||
label: string;
|
||||
totalTokens: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheTokens: number;
|
||||
}>;
|
||||
emptyLabel: string;
|
||||
totalLabel: string;
|
||||
inputLabel: string;
|
||||
outputLabel: string;
|
||||
cacheLabel: string;
|
||||
}>();
|
||||
|
||||
const maxTokens = computed(() => {
|
||||
return Math.max(...props.groups.map((group) => group.totalTokens), 1);
|
||||
});
|
||||
|
||||
const formatTokenCount = (value: number) => {
|
||||
return new Intl.NumberFormat().format(value);
|
||||
};
|
||||
</script>
|
||||
340
src/pages/agents/index.vue
Normal file
340
src/pages/agents/index.vue
Normal file
@@ -0,0 +1,340 @@
|
||||
<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>
|
||||
118
src/pages/agents/usage-history.ts
Normal file
118
src/pages/agents/usage-history.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
export type UsageHistoryEntry = {
|
||||
timestamp: string;
|
||||
sessionId: string;
|
||||
agentId: string;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
content?: string;
|
||||
usageStatus?: 'available' | 'missing' | 'error';
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
cacheWriteTokens: number;
|
||||
totalTokens: number;
|
||||
costUsd?: number;
|
||||
};
|
||||
|
||||
export type UsageWindow = '7d' | '30d' | 'all';
|
||||
export type UsageGroupBy = 'model' | 'day';
|
||||
|
||||
export type UsageGroup = {
|
||||
label: string;
|
||||
totalTokens: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheTokens: number;
|
||||
sortKey: number | string;
|
||||
};
|
||||
|
||||
export function resolveStableUsageHistory(
|
||||
previousStableEntries: UsageHistoryEntry[],
|
||||
nextEntries: UsageHistoryEntry[],
|
||||
options: { preservePreviousOnEmpty?: boolean } = {},
|
||||
): UsageHistoryEntry[] {
|
||||
if (nextEntries.length > 0) {
|
||||
return nextEntries;
|
||||
}
|
||||
|
||||
return options.preservePreviousOnEmpty ? previousStableEntries : [];
|
||||
}
|
||||
|
||||
export function resolveVisibleUsageHistory(
|
||||
currentEntries: UsageHistoryEntry[],
|
||||
stableEntries: UsageHistoryEntry[],
|
||||
options: { preferStableOnEmpty?: boolean } = {},
|
||||
): UsageHistoryEntry[] {
|
||||
if (options.preferStableOnEmpty && currentEntries.length === 0) {
|
||||
return stableEntries;
|
||||
}
|
||||
|
||||
return currentEntries;
|
||||
}
|
||||
|
||||
export function formatUsageDay(timestamp: string): string {
|
||||
const date = new Date(timestamp);
|
||||
if (Number.isNaN(date.getTime())) return timestamp;
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function getUsageDaySortKey(timestamp: string): number {
|
||||
const date = new Date(timestamp);
|
||||
if (Number.isNaN(date.getTime())) return 0;
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return date.getTime();
|
||||
}
|
||||
|
||||
export function groupUsageHistory(
|
||||
entries: UsageHistoryEntry[],
|
||||
groupBy: UsageGroupBy,
|
||||
): UsageGroup[] {
|
||||
const grouped = new Map<string, UsageGroup>();
|
||||
|
||||
for (const entry of entries) {
|
||||
const label = groupBy === 'model'
|
||||
? (entry.model || 'Unknown')
|
||||
: formatUsageDay(entry.timestamp);
|
||||
const current = grouped.get(label) ?? {
|
||||
label,
|
||||
totalTokens: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheTokens: 0,
|
||||
sortKey: groupBy === 'day' ? getUsageDaySortKey(entry.timestamp) : label.toLowerCase(),
|
||||
};
|
||||
current.totalTokens += entry.totalTokens;
|
||||
current.inputTokens += entry.inputTokens;
|
||||
current.outputTokens += entry.outputTokens;
|
||||
current.cacheTokens += entry.cacheReadTokens + entry.cacheWriteTokens;
|
||||
grouped.set(label, current);
|
||||
}
|
||||
|
||||
const sorted = Array.from(grouped.values()).sort((a, b) => {
|
||||
if (groupBy === 'day') {
|
||||
return Number(a.sortKey) - Number(b.sortKey);
|
||||
}
|
||||
return b.totalTokens - a.totalTokens;
|
||||
});
|
||||
|
||||
return groupBy === 'model' ? sorted.slice(0, 8) : sorted;
|
||||
}
|
||||
|
||||
export function filterUsageHistoryByWindow(
|
||||
entries: UsageHistoryEntry[],
|
||||
window: UsageWindow,
|
||||
now = Date.now(),
|
||||
): UsageHistoryEntry[] {
|
||||
if (window === 'all') return entries;
|
||||
|
||||
const days = window === '7d' ? 7 : 30;
|
||||
const cutoff = now - days * 24 * 60 * 60 * 1000;
|
||||
|
||||
return entries.filter((entry) => {
|
||||
const timestamp = Date.parse(entry.timestamp);
|
||||
return Number.isFinite(timestamp) && timestamp >= cutoff;
|
||||
});
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
<template>
|
||||
|
||||
<layout>
|
||||
<div class="bg-white h-full flex flex-col p-[20px]">
|
||||
任务中心
|
||||
</div>
|
||||
</layout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
Reference in New Issue
Block a user