You've already forked agentic-coding-workflow
feat(web): admin views for LLM endpoints/models + usage dashboard
This commit is contained in:
@@ -0,0 +1,228 @@
|
|||||||
|
<template>
|
||||||
|
<AppShell>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h2 class="text-lg font-semibold">
|
||||||
|
LLM Endpoints
|
||||||
|
</h2>
|
||||||
|
<NButton
|
||||||
|
type="primary"
|
||||||
|
@click="openCreate"
|
||||||
|
>
|
||||||
|
+ 新增
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
<NDataTable
|
||||||
|
:data="endpoints"
|
||||||
|
:columns="columns"
|
||||||
|
:row-key="rowKey"
|
||||||
|
/>
|
||||||
|
<NModal
|
||||||
|
v-model:show="showForm"
|
||||||
|
preset="card"
|
||||||
|
title="LLM Endpoint"
|
||||||
|
style="width: 520px"
|
||||||
|
>
|
||||||
|
<NForm>
|
||||||
|
<NFormItem label="Provider">
|
||||||
|
<NSelect
|
||||||
|
v-model:value="form.provider"
|
||||||
|
:options="providerOpts"
|
||||||
|
:disabled="!!editing"
|
||||||
|
@update:value="onProviderChange"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="显示名">
|
||||||
|
<NInput v-model:value="form.display_name" />
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="Base URL">
|
||||||
|
<NInput v-model:value="form.base_url" />
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem :label="editing ? 'API Key(留空则不修改)' : 'API Key'">
|
||||||
|
<NInput
|
||||||
|
v-model:value="form.api_key"
|
||||||
|
type="password"
|
||||||
|
show-password-on="click"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
</NForm>
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<NButton @click="showForm = false">
|
||||||
|
取消
|
||||||
|
</NButton>
|
||||||
|
<NButton
|
||||||
|
type="primary"
|
||||||
|
:loading="saving"
|
||||||
|
@click="save"
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</NModal>
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { h, onMounted, ref } from 'vue'
|
||||||
|
import {
|
||||||
|
NButton,
|
||||||
|
NDataTable,
|
||||||
|
NForm,
|
||||||
|
NFormItem,
|
||||||
|
NInput,
|
||||||
|
NModal,
|
||||||
|
NSelect,
|
||||||
|
useDialog,
|
||||||
|
useMessage,
|
||||||
|
} from 'naive-ui'
|
||||||
|
import type { DataTableColumns } from 'naive-ui'
|
||||||
|
|
||||||
|
import AppShell from '@/layouts/AppShell.vue'
|
||||||
|
import {
|
||||||
|
llmAdminApi,
|
||||||
|
type LLMEndpoint,
|
||||||
|
type LLMProvider,
|
||||||
|
} from '@/api/llmAdmin'
|
||||||
|
|
||||||
|
const endpoints = ref<LLMEndpoint[]>([])
|
||||||
|
const showForm = ref(false)
|
||||||
|
const editing = ref<LLMEndpoint | null>(null)
|
||||||
|
const form = ref<{
|
||||||
|
provider: LLMProvider
|
||||||
|
display_name: string
|
||||||
|
base_url: string
|
||||||
|
api_key: string
|
||||||
|
}>({
|
||||||
|
provider: 'anthropic',
|
||||||
|
display_name: '',
|
||||||
|
base_url: '',
|
||||||
|
api_key: '',
|
||||||
|
})
|
||||||
|
const saving = ref(false)
|
||||||
|
const msg = useMessage()
|
||||||
|
const dlg = useDialog()
|
||||||
|
|
||||||
|
const providerOpts: { label: string; value: LLMProvider }[] = [
|
||||||
|
{ label: 'Anthropic', value: 'anthropic' },
|
||||||
|
{ label: 'OpenAI / Compatible', value: 'openai' },
|
||||||
|
{ label: 'Gemini', value: 'gemini' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const defaultBaseURL: Record<LLMProvider, string> = {
|
||||||
|
anthropic: 'https://api.anthropic.com',
|
||||||
|
openai: 'https://api.openai.com/v1',
|
||||||
|
gemini: 'https://generativelanguage.googleapis.com/v1beta',
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: DataTableColumns<LLMEndpoint> = [
|
||||||
|
{ title: '显示名', key: 'display_name' },
|
||||||
|
{ title: 'Provider', key: 'provider' },
|
||||||
|
{ title: 'Base URL', key: 'base_url' },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'actions',
|
||||||
|
render: (row) =>
|
||||||
|
h('div', { class: 'flex gap-1' }, [
|
||||||
|
h(
|
||||||
|
NButton,
|
||||||
|
{ size: 'small', onClick: () => testEndpoint(row.id) },
|
||||||
|
{ default: () => '测试' },
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
NButton,
|
||||||
|
{ size: 'small', onClick: () => openEdit(row) },
|
||||||
|
{ default: () => '编辑' },
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
NButton,
|
||||||
|
{ size: 'small', type: 'error', onClick: () => del(row) },
|
||||||
|
{ default: () => '删除' },
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const rowKey = (row: LLMEndpoint) => row.id
|
||||||
|
|
||||||
|
onMounted(reload)
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
endpoints.value = await llmAdminApi.listEndpoints()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editing.value = null
|
||||||
|
form.value = {
|
||||||
|
provider: 'anthropic',
|
||||||
|
display_name: '',
|
||||||
|
base_url: defaultBaseURL.anthropic,
|
||||||
|
api_key: '',
|
||||||
|
}
|
||||||
|
showForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(ep: LLMEndpoint) {
|
||||||
|
editing.value = ep
|
||||||
|
form.value = {
|
||||||
|
provider: ep.provider,
|
||||||
|
display_name: ep.display_name,
|
||||||
|
base_url: ep.base_url,
|
||||||
|
api_key: '',
|
||||||
|
}
|
||||||
|
showForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onProviderChange(v: LLMProvider) {
|
||||||
|
form.value.base_url = defaultBaseURL[v]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
if (editing.value) {
|
||||||
|
await llmAdminApi.updateEndpoint(editing.value.id, {
|
||||||
|
display_name: form.value.display_name,
|
||||||
|
base_url: form.value.base_url,
|
||||||
|
api_key: form.value.api_key || undefined,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await llmAdminApi.createEndpoint({
|
||||||
|
provider: form.value.provider,
|
||||||
|
display_name: form.value.display_name,
|
||||||
|
base_url: form.value.base_url,
|
||||||
|
api_key: form.value.api_key,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
showForm.value = false
|
||||||
|
await reload()
|
||||||
|
} catch (e) {
|
||||||
|
msg.error(e instanceof Error ? e.message : String(e))
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testEndpoint(id: string) {
|
||||||
|
try {
|
||||||
|
await llmAdminApi.testEndpoint(id)
|
||||||
|
msg.success('连通正常')
|
||||||
|
} catch (e) {
|
||||||
|
msg.error(e instanceof Error ? e.message : String(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function del(ep: LLMEndpoint) {
|
||||||
|
dlg.warning({
|
||||||
|
title: `删除 ${ep.display_name}?`,
|
||||||
|
positiveText: '删除',
|
||||||
|
negativeText: '取消',
|
||||||
|
onPositiveClick: async () => {
|
||||||
|
await llmAdminApi.deleteEndpoint(ep.id)
|
||||||
|
await reload()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
<template>
|
||||||
|
<AppShell>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h2 class="text-lg font-semibold">
|
||||||
|
LLM Models
|
||||||
|
</h2>
|
||||||
|
<NButton
|
||||||
|
type="primary"
|
||||||
|
@click="openCreate"
|
||||||
|
>
|
||||||
|
+ 新增
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
<NDataTable
|
||||||
|
:data="models"
|
||||||
|
:columns="columns"
|
||||||
|
:row-key="rowKey"
|
||||||
|
/>
|
||||||
|
<NModal
|
||||||
|
v-model:show="showForm"
|
||||||
|
preset="card"
|
||||||
|
title="LLM Model"
|
||||||
|
style="width: 720px"
|
||||||
|
>
|
||||||
|
<NForm>
|
||||||
|
<NFormItem label="Endpoint">
|
||||||
|
<NSelect
|
||||||
|
v-model:value="form.endpoint_id"
|
||||||
|
:options="endpointOpts"
|
||||||
|
:disabled="!!editing"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="Model ID(provider 侧)">
|
||||||
|
<NInput
|
||||||
|
v-model:value="form.model_id"
|
||||||
|
:disabled="!!editing"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="显示名">
|
||||||
|
<NInput v-model:value="form.display_name" />
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="能力">
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<NCheckbox v-model:checked="form.capabilities.image">
|
||||||
|
Image
|
||||||
|
</NCheckbox>
|
||||||
|
<NCheckbox v-model:checked="form.capabilities.pdf">
|
||||||
|
PDF
|
||||||
|
</NCheckbox>
|
||||||
|
<NCheckbox v-model:checked="form.capabilities.audio">
|
||||||
|
Audio
|
||||||
|
</NCheckbox>
|
||||||
|
<NCheckbox v-model:checked="form.capabilities.reasoning">
|
||||||
|
Reasoning
|
||||||
|
</NCheckbox>
|
||||||
|
<NCheckbox v-model:checked="form.capabilities.tools">
|
||||||
|
Tools
|
||||||
|
</NCheckbox>
|
||||||
|
</div>
|
||||||
|
</NFormItem>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<NFormItem label="Context Window">
|
||||||
|
<NInputNumber
|
||||||
|
v-model:value="form.context_window"
|
||||||
|
:min="1"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="Max Output Tokens">
|
||||||
|
<NInputNumber
|
||||||
|
v-model:value="form.max_output_tokens"
|
||||||
|
:min="1"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="Prompt Price (USD / 1M)">
|
||||||
|
<NInputNumber
|
||||||
|
v-model:value="form.prompt_price_per_million_usd"
|
||||||
|
:min="0"
|
||||||
|
:step="0.01"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="Completion Price (USD / 1M)">
|
||||||
|
<NInputNumber
|
||||||
|
v-model:value="form.completion_price_per_million_usd"
|
||||||
|
:min="0"
|
||||||
|
:step="0.01"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="Thinking Price (USD / 1M)">
|
||||||
|
<NInputNumber
|
||||||
|
v-model:value="form.thinking_price_per_million_usd"
|
||||||
|
:min="0"
|
||||||
|
:step="0.01"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
<NFormItem label="Sort Order">
|
||||||
|
<NInputNumber
|
||||||
|
v-model:value="form.sort_order"
|
||||||
|
:min="0"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-4 mt-2">
|
||||||
|
<NCheckbox v-model:checked="form.enabled">
|
||||||
|
Enabled
|
||||||
|
</NCheckbox>
|
||||||
|
<NCheckbox v-model:checked="form.is_title_generator">
|
||||||
|
Title Generator
|
||||||
|
</NCheckbox>
|
||||||
|
</div>
|
||||||
|
</NForm>
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<NButton @click="showForm = false">
|
||||||
|
取消
|
||||||
|
</NButton>
|
||||||
|
<NButton
|
||||||
|
type="primary"
|
||||||
|
:loading="saving"
|
||||||
|
@click="save"
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</NModal>
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, h, onMounted, ref } from 'vue'
|
||||||
|
import {
|
||||||
|
NButton,
|
||||||
|
NCheckbox,
|
||||||
|
NDataTable,
|
||||||
|
NForm,
|
||||||
|
NFormItem,
|
||||||
|
NInput,
|
||||||
|
NInputNumber,
|
||||||
|
NModal,
|
||||||
|
NSelect,
|
||||||
|
NTag,
|
||||||
|
useDialog,
|
||||||
|
useMessage,
|
||||||
|
} from 'naive-ui'
|
||||||
|
import type { DataTableColumns } from 'naive-ui'
|
||||||
|
|
||||||
|
import AppShell from '@/layouts/AppShell.vue'
|
||||||
|
import {
|
||||||
|
llmAdminApi,
|
||||||
|
type CreateModelBody,
|
||||||
|
type LLMEndpoint,
|
||||||
|
type LLMModel,
|
||||||
|
type ModelCapabilities,
|
||||||
|
} from '@/api/llmAdmin'
|
||||||
|
|
||||||
|
const models = ref<LLMModel[]>([])
|
||||||
|
const endpoints = ref<LLMEndpoint[]>([])
|
||||||
|
const showForm = ref(false)
|
||||||
|
const editing = ref<LLMModel | null>(null)
|
||||||
|
const saving = ref(false)
|
||||||
|
const msg = useMessage()
|
||||||
|
const dlg = useDialog()
|
||||||
|
|
||||||
|
interface ModelForm {
|
||||||
|
endpoint_id: string
|
||||||
|
model_id: string
|
||||||
|
display_name: string
|
||||||
|
capabilities: ModelCapabilities
|
||||||
|
context_window: number
|
||||||
|
max_output_tokens: number
|
||||||
|
prompt_price_per_million_usd: number
|
||||||
|
completion_price_per_million_usd: number
|
||||||
|
thinking_price_per_million_usd: number
|
||||||
|
is_title_generator: boolean
|
||||||
|
enabled: boolean
|
||||||
|
sort_order: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function blankForm(): ModelForm {
|
||||||
|
return {
|
||||||
|
endpoint_id: '',
|
||||||
|
model_id: '',
|
||||||
|
display_name: '',
|
||||||
|
capabilities: {
|
||||||
|
image: false,
|
||||||
|
pdf: false,
|
||||||
|
audio: false,
|
||||||
|
reasoning: false,
|
||||||
|
tools: false,
|
||||||
|
},
|
||||||
|
context_window: 200000,
|
||||||
|
max_output_tokens: 8192,
|
||||||
|
prompt_price_per_million_usd: 0,
|
||||||
|
completion_price_per_million_usd: 0,
|
||||||
|
thinking_price_per_million_usd: 0,
|
||||||
|
is_title_generator: false,
|
||||||
|
enabled: true,
|
||||||
|
sort_order: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = ref<ModelForm>(blankForm())
|
||||||
|
|
||||||
|
const endpointOpts = computed(() =>
|
||||||
|
endpoints.value.map((e) => ({
|
||||||
|
label: `${e.display_name} (${e.provider})`,
|
||||||
|
value: e.id,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const endpointName = (id: string) =>
|
||||||
|
endpoints.value.find((e) => e.id === id)?.display_name ?? id
|
||||||
|
|
||||||
|
const capLabel = (c: ModelCapabilities) => {
|
||||||
|
const parts: string[] = []
|
||||||
|
if (c.image) parts.push('I')
|
||||||
|
if (c.pdf) parts.push('P')
|
||||||
|
if (c.audio) parts.push('A')
|
||||||
|
if (c.reasoning) parts.push('R')
|
||||||
|
if (c.tools) parts.push('T')
|
||||||
|
return parts.join('') || '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: DataTableColumns<LLMModel> = [
|
||||||
|
{ title: '显示名', key: 'display_name' },
|
||||||
|
{ title: 'Model ID', key: 'model_id' },
|
||||||
|
{
|
||||||
|
title: 'Endpoint',
|
||||||
|
key: 'endpoint_id',
|
||||||
|
render: (row) => endpointName(row.endpoint_id),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '能力',
|
||||||
|
key: 'capabilities',
|
||||||
|
render: (row) => capLabel(row.capabilities),
|
||||||
|
},
|
||||||
|
{ title: 'Ctx', key: 'context_window' },
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
key: 'enabled',
|
||||||
|
render: (row) =>
|
||||||
|
h(
|
||||||
|
NTag,
|
||||||
|
{ type: row.enabled ? 'success' : 'default', size: 'small' },
|
||||||
|
{ default: () => (row.enabled ? '启用' : '停用') },
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Title?',
|
||||||
|
key: 'is_title_generator',
|
||||||
|
render: (row) => (row.is_title_generator ? '✓' : ''),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'actions',
|
||||||
|
render: (row) =>
|
||||||
|
h('div', { class: 'flex gap-1' }, [
|
||||||
|
h(
|
||||||
|
NButton,
|
||||||
|
{ size: 'small', onClick: () => openEdit(row) },
|
||||||
|
{ default: () => '编辑' },
|
||||||
|
),
|
||||||
|
h(
|
||||||
|
NButton,
|
||||||
|
{ size: 'small', type: 'error', onClick: () => del(row) },
|
||||||
|
{ default: () => '删除' },
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const rowKey = (row: LLMModel) => row.id
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
endpoints.value = await llmAdminApi.listEndpoints()
|
||||||
|
await reload()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
models.value = await llmAdminApi.listModels(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editing.value = null
|
||||||
|
form.value = blankForm()
|
||||||
|
if (endpoints.value.length > 0) {
|
||||||
|
form.value.endpoint_id = endpoints.value[0].id
|
||||||
|
}
|
||||||
|
showForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(m: LLMModel) {
|
||||||
|
editing.value = m
|
||||||
|
form.value = {
|
||||||
|
endpoint_id: m.endpoint_id,
|
||||||
|
model_id: m.model_id,
|
||||||
|
display_name: m.display_name,
|
||||||
|
capabilities: { ...m.capabilities },
|
||||||
|
context_window: m.context_window,
|
||||||
|
max_output_tokens: m.max_output_tokens,
|
||||||
|
prompt_price_per_million_usd: m.prompt_price_per_million_usd,
|
||||||
|
completion_price_per_million_usd: m.completion_price_per_million_usd,
|
||||||
|
thinking_price_per_million_usd: m.thinking_price_per_million_usd,
|
||||||
|
is_title_generator: m.is_title_generator,
|
||||||
|
enabled: m.enabled,
|
||||||
|
sort_order: m.sort_order,
|
||||||
|
}
|
||||||
|
showForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
if (editing.value) {
|
||||||
|
await llmAdminApi.updateModel(editing.value.id, {
|
||||||
|
display_name: form.value.display_name,
|
||||||
|
capabilities: form.value.capabilities,
|
||||||
|
context_window: form.value.context_window,
|
||||||
|
max_output_tokens: form.value.max_output_tokens,
|
||||||
|
prompt_price_per_million_usd: form.value.prompt_price_per_million_usd,
|
||||||
|
completion_price_per_million_usd:
|
||||||
|
form.value.completion_price_per_million_usd,
|
||||||
|
thinking_price_per_million_usd:
|
||||||
|
form.value.thinking_price_per_million_usd,
|
||||||
|
is_title_generator: form.value.is_title_generator,
|
||||||
|
enabled: form.value.enabled,
|
||||||
|
sort_order: form.value.sort_order,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const body: CreateModelBody = {
|
||||||
|
endpoint_id: form.value.endpoint_id,
|
||||||
|
model_id: form.value.model_id,
|
||||||
|
display_name: form.value.display_name,
|
||||||
|
capabilities: form.value.capabilities,
|
||||||
|
context_window: form.value.context_window,
|
||||||
|
max_output_tokens: form.value.max_output_tokens,
|
||||||
|
prompt_price_per_million_usd: form.value.prompt_price_per_million_usd,
|
||||||
|
completion_price_per_million_usd:
|
||||||
|
form.value.completion_price_per_million_usd,
|
||||||
|
thinking_price_per_million_usd:
|
||||||
|
form.value.thinking_price_per_million_usd,
|
||||||
|
is_title_generator: form.value.is_title_generator,
|
||||||
|
enabled: form.value.enabled,
|
||||||
|
sort_order: form.value.sort_order,
|
||||||
|
}
|
||||||
|
await llmAdminApi.createModel(body)
|
||||||
|
}
|
||||||
|
showForm.value = false
|
||||||
|
await reload()
|
||||||
|
} catch (e) {
|
||||||
|
msg.error(e instanceof Error ? e.message : String(e))
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function del(m: LLMModel) {
|
||||||
|
dlg.warning({
|
||||||
|
title: `删除 ${m.display_name}?`,
|
||||||
|
positiveText: '删除',
|
||||||
|
negativeText: '取消',
|
||||||
|
onPositiveClick: async () => {
|
||||||
|
await llmAdminApi.deleteModel(m.id)
|
||||||
|
await reload()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<template>
|
||||||
|
<AppShell>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h2 class="text-lg font-semibold">
|
||||||
|
{{ adminMode ? '用量(全局)' : '我的用量' }}
|
||||||
|
</h2>
|
||||||
|
<div class="flex gap-2 items-center flex-wrap">
|
||||||
|
<NDatePicker
|
||||||
|
v-model:value="range"
|
||||||
|
type="daterange"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
<NSelect
|
||||||
|
v-if="adminMode"
|
||||||
|
v-model:value="groupBy"
|
||||||
|
:options="groupOpts"
|
||||||
|
style="width: 160px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<NDataTable
|
||||||
|
:data="rows"
|
||||||
|
:columns="columns"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AppShell>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import { NDataTable, NDatePicker, NSelect } from 'naive-ui'
|
||||||
|
import type { DataTableColumns } from 'naive-ui'
|
||||||
|
|
||||||
|
import AppShell from '@/layouts/AppShell.vue'
|
||||||
|
import {
|
||||||
|
llmAdminApi,
|
||||||
|
type AdminUsageGroupBy,
|
||||||
|
type AdminUsageItem,
|
||||||
|
} from '@/api/llmAdmin'
|
||||||
|
import { chatApi, type UsageDailyItem } from '@/api/chat'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
adminMode?: boolean
|
||||||
|
}
|
||||||
|
const props = withDefaults(defineProps<Props>(), { adminMode: false })
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const monthAgo = now - 30 * 86400_000
|
||||||
|
const range = ref<[number, number] | null>([monthAgo, now])
|
||||||
|
const groupBy = ref<AdminUsageGroupBy>('day')
|
||||||
|
const groupOpts: { label: string; value: AdminUsageGroupBy }[] = [
|
||||||
|
{ label: '按用户', value: 'user' },
|
||||||
|
{ label: '按模型', value: 'model' },
|
||||||
|
{ label: '按天', value: 'day' },
|
||||||
|
]
|
||||||
|
|
||||||
|
type Row = AdminUsageItem | UsageDailyItem
|
||||||
|
const rows = ref<Row[]>([])
|
||||||
|
|
||||||
|
const columns = computed<DataTableColumns<Row>>(() => {
|
||||||
|
const tokenCols: DataTableColumns<Row> = [
|
||||||
|
{ title: 'Prompt', key: 'prompt_tokens' },
|
||||||
|
{ title: 'Completion', key: 'completion_tokens' },
|
||||||
|
{ title: 'Thinking', key: 'thinking_tokens' },
|
||||||
|
{
|
||||||
|
title: 'Cost USD',
|
||||||
|
key: 'cost_usd',
|
||||||
|
render: (r) => Number(r.cost_usd ?? 0).toFixed(4),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
if (props.adminMode) {
|
||||||
|
const labelCol: DataTableColumns<Row>[number] = {
|
||||||
|
title:
|
||||||
|
groupBy.value === 'user'
|
||||||
|
? '用户'
|
||||||
|
: groupBy.value === 'model'
|
||||||
|
? '模型'
|
||||||
|
: '日期',
|
||||||
|
key: 'key',
|
||||||
|
render: (r) => {
|
||||||
|
const k = (r as AdminUsageItem).key
|
||||||
|
if (groupBy.value === 'day') {
|
||||||
|
return new Date(k).toLocaleDateString('zh-CN')
|
||||||
|
}
|
||||||
|
return k
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return [labelCol, ...tokenCols]
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: '日期',
|
||||||
|
key: 'day',
|
||||||
|
render: (r) => new Date((r as UsageDailyItem).day).toLocaleDateString('zh-CN'),
|
||||||
|
},
|
||||||
|
...tokenCols,
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
const r = range.value
|
||||||
|
if (!r) {
|
||||||
|
rows.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const from = new Date(r[0]).toISOString()
|
||||||
|
const to = new Date(r[1]).toISOString()
|
||||||
|
if (props.adminMode) {
|
||||||
|
const data = await llmAdminApi.adminUsage(from, to, groupBy.value)
|
||||||
|
rows.value = data.items
|
||||||
|
} else {
|
||||||
|
rows.value = await chatApi.myDailyUsage(from, to)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(reload)
|
||||||
|
watch([range, groupBy], reload)
|
||||||
|
</script>
|
||||||
Reference in New Issue
Block a user