Files
oci-portal-dash/src/components/ai/AiChannelPanel.vue
T

262 lines
10 KiB
Vue

<script setup lang="ts">
import { NButton, NDataTable, NInput, NSelect, NTooltip, useDialog, type DataTableColumns } from 'naive-ui'
import { computed, h, reactive, ref } from 'vue'
import { createAiChannel, deleteAiChannel, probeAiChannel, updateAiChannel } from '@/api/aigateway'
import { listCachedRegions } from '@/api/configs'
import AiChannelModelsModal from '@/components/ai/AiChannelModelsModal.vue'
import GroupChips from '@/components/ai/GroupChips.vue'
import PanelHeader from '@/components/PanelHeader.vue'
import AppInputNumber from '@/components/AppInputNumber.vue'
import FormField from '@/components/FormField.vue'
import FormModal from '@/components/FormModal.vue'
import StatusBadge from '@/components/StatusBadge.vue'
import TenantPicker from '@/components/TenantPicker.vue'
import { useAsync } from '@/composables/useAsync'
import { fmtRelative } from '@/composables/useFormat'
import { useToast } from '@/composables/useToast'
import { useScopeStore } from '@/stores/scope'
import type { AiChannel, AiProbeStatus } from '@/types/api'
const props = defineProps<{ channels: AiChannel[]; loading: boolean }>()
const emit = defineEmits<{ changed: [] }>()
const groupHints = computed(() => [...new Set(props.channels.map((c) => c.group).filter(Boolean))])
const toast = useToast()
const dialog = useDialog()
const scope = useScopeStore()
const PROBE_META: Record<Exclude<AiProbeStatus, ''>, { kind: 'run' | 'warn' | 'term' | 'stop'; label: string }> = {
ok: { kind: 'run', label: '可用' },
no_service: { kind: 'warn', label: '无服务' },
no_quota: { kind: 'warn', label: '无配额' },
error: { kind: 'term', label: '不可用' },
}
// ---- 新建 / 编辑弹窗 ----
const showForm = ref(false)
const editing = ref<AiChannel | null>(null)
const submitting = ref(false)
const form = reactive({ ociConfigId: null as number | null, region: '', name: '', group: '', priority: 1, weight: 1 })
const regions = useAsync(
() => (form.ociConfigId === null ? Promise.resolve([]) : listCachedRegions(form.ociConfigId).catch(() => [])),
false,
)
const regionOptions = computed(() =>
(regions.data.value ?? []).map((r) => ({ label: r.name, value: r.name })),
)
function onCfgChange() {
form.region = ''
void regions.run()
}
function openCreate() {
editing.value = null
Object.assign(form, { ociConfigId: null, region: '', name: '', group: '', priority: 1, weight: 1 })
showForm.value = true
}
function openEdit(ch: AiChannel) {
editing.value = ch
Object.assign(form, { ociConfigId: ch.ociConfigId, region: ch.region, name: ch.name, group: ch.group, priority: ch.priority, weight: ch.weight })
showForm.value = true
}
async function submit() {
submitting.value = true
try {
if (editing.value) {
await updateAiChannel(editing.value.id, { name: form.name, group: form.group, priority: form.priority, weight: form.weight })
toast.success('已更新渠道')
showForm.value = false
emit('changed')
} else {
const ch = await createAiChannel({
ociConfigId: form.ociConfigId ?? undefined, region: form.region, name: form.name || undefined,
group: form.group, priority: form.priority, weight: form.weight,
})
showForm.value = false
emit('changed')
toast.success('渠道已添加,正在探测…')
void doProbe(ch.id)
}
} catch (e) {
toast.error(e instanceof Error ? e.message : '保存失败')
} finally {
submitting.value = false
}
}
const probing = ref(new Set<number>())
async function doProbe(id: number) {
probing.value.add(id)
try {
const ch = await probeAiChannel(id)
const meta = ch.probeStatus ? PROBE_META[ch.probeStatus as Exclude<AiProbeStatus, ''>] : null
if (ch.probeStatus === 'ok') {
toast.success('探测完成:可用')
} else {
toast.warning(`探测完成:${meta?.label ?? ch.probeStatus}`, ch.probeError || undefined)
}
} catch (e) {
toast.error(e instanceof Error ? e.message : '探测失败')
} finally {
probing.value.delete(id)
emit('changed')
}
}
// ---- 模型列表弹窗 ----
const showModels = ref(false)
const modelsChannel = ref<AiChannel | null>(null)
function openModels(ch: AiChannel) {
modelsChannel.value = ch
showModels.value = true
}
async function toggle(ch: AiChannel) {
try {
await updateAiChannel(ch.id, { enabled: !ch.enabled })
toast.success(ch.enabled ? '已停用' : '已启用')
emit('changed')
} catch (e) {
toast.error(e instanceof Error ? e.message : '操作失败')
}
}
function confirmRemove(ch: AiChannel) {
dialog.warning({
title: '删除渠道',
content: `删除渠道「${ch.name}」及其模型缓存?`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
await deleteAiChannel(ch.id)
toast.success('已删除')
emit('changed')
},
})
}
function probeCell(r: AiChannel) {
const badge = probeBadge(r)
const tip = [r.probeError, r.lastProbeAt ? `探测于 ${fmtRelative(r.lastProbeAt)}` : '']
.filter(Boolean)
.join('\n')
if (!tip) return badge
return h(
NTooltip,
{ style: { maxWidth: '380px' } },
{
trigger: () => h('span', { class: 'inline-flex cursor-help' }, [badge]),
default: () => h('span', { class: 'text-xs leading-relaxed whitespace-pre-wrap break-all' }, tip),
},
)
}
function probeBadge(r: AiChannel) {
if (!r.enabled) return h(StatusBadge, { kind: 'stop', label: '已停用' })
if (r.disabledUntil && new Date(r.disabledUntil) > new Date())
return h(StatusBadge, { kind: 'warn', label: '熔断中' })
const meta = r.probeStatus ? PROBE_META[r.probeStatus as Exclude<AiProbeStatus, ''>] : null
return meta ? h(StatusBadge, meta) : h('span', { class: 'text-xs text-ink-3' }, '未探测')
}
const columns = computed<DataTableColumns<AiChannel>>(() => [
{ title: '名称', key: 'name', minWidth: 200, ellipsis: { tooltip: true }, render: (r) => h('span', { class: 'text-[13px] font-medium' }, r.name) },
{ title: '区域', key: 'region', width: 150, ellipsis: { tooltip: true }, render: (r) => h('span', { class: 'mono text-xs text-ink-2' }, r.region) },
{
title: '分组', key: 'group', width: 100,
render: (r) => r.group
? h('span', { class: 'rounded-[5px] bg-info/15 px-2 py-0.5 text-xs font-medium text-info' }, r.group)
: h('span', { class: 'text-xs text-ink-3' }, '默认'),
},
{ title: '优先级', key: 'priority', width: 76, render: (r) => h('span', { class: 'mono text-[13px]' }, String(r.priority)) },
{ title: '权重', key: 'weight', width: 66, render: (r) => h('span', { class: 'mono text-[13px]' }, String(r.weight)) },
{
title: '模型数量', key: 'modelCount', width: 86,
render: (r) => h('span', { class: r.modelCount ? 'mono text-[13px]' : 'text-xs text-ink-3' }, String(r.modelCount ?? 0)),
},
{ title: '探测状态', key: 'probeStatus', width: 120, render: probeCell },
{
title: '操作', key: 'actions', width: 250,
render: (r) => h('div', { class: 'flex items-center gap-0.5' }, [
h(NButton, { size: 'tiny', quaternary: true, type: 'primary', loading: probing.value.has(r.id), onClick: () => doProbe(r.id) }, { default: () => '探测' }),
h(NButton, { size: 'tiny', quaternary: true, type: 'primary', onClick: () => openModels(r) }, { default: () => '模型列表' }),
h(NButton, { size: 'tiny', quaternary: true, onClick: () => toggle(r) }, { default: () => (r.enabled ? '停用' : '启用') }),
h(NButton, { size: 'tiny', quaternary: true, type: 'primary', onClick: () => openEdit(r) }, { default: () => '编辑' }),
h(NButton, { size: 'tiny', quaternary: true, type: 'error', onClick: () => confirmRemove(r) }, { default: () => '删除' }),
]),
},
])
</script>
<template>
<div class="panel">
<PanelHeader
title="渠道(号池)"
desc="渠道 = 租户 × 区域;请求按优先级分组、组内权重随机,429/超时自动换渠道重试,连续失败指数退避熔断"
>
<NButton size="small" type="primary" @click="openCreate">添加渠道</NButton>
</PanelHeader>
<NDataTable :columns="columns" :data="channels" :loading="loading" :bordered="false" size="small" />
<div class="border-t border-line-soft bg-wash px-4.5 py-2.5 text-xs leading-relaxed text-ink-3">
探测三步:列模型(服务可见性) 同步模型缓存 max_tokens=16 试调(配额)· 探测成功自动复位熔断 ·
有渠道时系统自动维护AI渠道探测后台任务(每天 00:00 逐渠道探测,渠道清空自动暂停)
</div>
<AiChannelModelsModal
v-model:show="showModels"
:channel="modelsChannel"
@changed="emit('changed')"
/>
<FormModal
v-model:show="showForm"
:title="editing ? '编辑渠道' : '添加渠道'"
:submitting="submitting"
:submit-text="editing ? '保存' : '添加并探测'"
:submit-disabled="!editing && (form.ociConfigId === null || !form.region)"
@submit="submit"
>
<template v-if="!editing">
<FormField label="租户配置" required>
<TenantPicker
v-model:value="form.ociConfigId"
:configs="scope.configs.data ?? []"
:loading="scope.configs.loading"
@update:value="onCfgChange"
/>
</FormField>
<FormField label="区域" required hint="建议选 GenAI 提供区域:芝加哥/阿什本/凤凰城/法兰克福/伦敦/大阪等;租户需已订阅该区域">
<NSelect v-model:value="form.region" :options="regionOptions" :loading="regions.loading.value" placeholder="选择区域" filterable tag />
</FormField>
</template>
<FormField v-else label="名称">
<NInput v-model:value="form.name" />
</FormField>
<div class="grid grid-cols-3 gap-3">
<FormField label="分组" hint="分组供密钥定向路由;留空归入默认分组">
<NInput v-model:value="form.group" placeholder="默认" />
<GroupChips :groups="groupHints" @pick="form.group = $event" />
</FormField>
<FormField label="优先级" hint="数字小优先">
<AppInputNumber v-model:value="form.priority" :min="1" />
</FormField>
<FormField label="权重" hint="同优先级内按权重分流">
<AppInputNumber v-model:value="form.weight" :min="1" />
</FormField>
</div>
<div v-if="!editing" class="rounded-lg bg-wash px-3 py-2 text-xs leading-relaxed text-ink-3">
创建后自动发起一次探测,并自动维护AI渠道探测后台任务(渠道清空自动暂停)
</div>
</FormModal>
</div>
</template>