195 lines
7.5 KiB
Vue
195 lines
7.5 KiB
Vue
<script setup lang="ts">
|
||
import { computed, ref } from 'vue'
|
||
|
||
import {
|
||
listAiChannels,
|
||
listAiKeys,
|
||
listAiModels,
|
||
} from '@/api/aigateway'
|
||
import AiChannelPanel from '@/components/ai/AiChannelPanel.vue'
|
||
import AiKeyPanel from '@/components/ai/AiKeyPanel.vue'
|
||
import { useAsync } from '@/composables/useAsync'
|
||
import { useToast } from '@/composables/useToast'
|
||
|
||
const message = useToast()
|
||
|
||
const keys = useAsync(listAiKeys)
|
||
const channels = useAsync(listAiChannels)
|
||
const models = useAsync(listAiModels)
|
||
|
||
const baseUrl = `${window.location.origin}/ai/v1`
|
||
|
||
const endpoints = [
|
||
{ method: 'POST', path: '/chat/completions', note: 'OpenAI · 流式/非流式' },
|
||
{ method: 'POST', path: '/responses', note: 'OpenAI Responses · 无状态子集' },
|
||
{ method: 'POST', path: '/messages', note: 'Claude · 流式/非流式' },
|
||
{ method: 'POST', path: '/embeddings', note: 'OpenAI Embeddings · 向量化' },
|
||
{ method: 'POST', path: '/audio/speech', note: '文本转语音 · xAI Voice' },
|
||
{ method: 'POST', path: '/tts', note: '文本转语音 · xAI 官方格式' },
|
||
{ method: 'POST', path: '/rerank', note: '文档重排 · Cohere Rerank' },
|
||
{ method: 'POST', path: '/moderations', note: '内容审核 · OCI Guardrails' },
|
||
{ method: 'GET', path: '/models', note: '模型列表(按密钥分组过滤)' },
|
||
]
|
||
|
||
/** 厂商显示名:模型名前缀 → 品牌名 */
|
||
const VENDOR_LABEL: Record<string, string> = {
|
||
meta: 'Meta', xai: 'xAI', google: 'Google', openai: 'OpenAI', cohere: 'Cohere',
|
||
}
|
||
|
||
/** 可用模型按厂商分组(取模型名前缀,厂商内按名称排序) */
|
||
const modelGroups = computed(() => {
|
||
const byVendor = new Map<string, string[]>()
|
||
for (const m of models.data.value ?? []) {
|
||
const vendor = m.id.split('.', 1)[0] || m.owned_by || '其他'
|
||
const list = byVendor.get(vendor) ?? []
|
||
list.push(m.id)
|
||
byVendor.set(vendor, list)
|
||
}
|
||
return [...byVendor.entries()]
|
||
.sort((a, b) => b[1].length - a[1].length)
|
||
.map(([vendor, ids]) => ({ vendor, label: VENDOR_LABEL[vendor] ?? vendor, ids: ids.sort() }))
|
||
})
|
||
|
||
/** 手风琴:同时只展开一个厂商,配合展开区限高控制卡片总高度 */
|
||
const expandedVendor = ref<string | null>(null)
|
||
|
||
function toggleVendor(vendor: string) {
|
||
expandedVendor.value = expandedVendor.value === vendor ? null : vendor
|
||
}
|
||
|
||
/** 渠道现有分组去重,供密钥弹窗快捷选择 */
|
||
const groups = computed(() => [...new Set((channels.data.value ?? []).map((c) => c.group).filter(Boolean))])
|
||
|
||
async function copyBase() {
|
||
try {
|
||
await navigator.clipboard.writeText(baseUrl)
|
||
message.success('已复制 Base URL')
|
||
} catch {
|
||
message.error('复制失败,请手动选择复制')
|
||
}
|
||
}
|
||
|
||
async function copyModel(id: string) {
|
||
try {
|
||
await navigator.clipboard.writeText(id)
|
||
message.success(`已复制:${id}`)
|
||
} catch {
|
||
message.error('复制失败')
|
||
}
|
||
}
|
||
|
||
function refreshChannels() {
|
||
void channels.run({ silent: true })
|
||
void models.run({ silent: true })
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="flex flex-col gap-4 p-6 pb-8 max-md:p-3">
|
||
<div class="flex flex-wrap items-baseline gap-2.5">
|
||
<h1 class="page-title">AI 网关</h1>
|
||
<span class="text-[13px] text-ink-3">
|
||
把有 Generative AI 配额的租户接入号池,对外提供 OpenAI / Claude 格式的标准接口
|
||
</span>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-2 items-start gap-4 max-lg:grid-cols-1">
|
||
<div class="panel">
|
||
<div class="border-b border-line-soft px-4.5 py-3 text-sm font-semibold">接入信息</div>
|
||
<div class="px-4.5 py-2">
|
||
<div class="flex items-center gap-2 border-b border-line-soft py-2 text-[13px]">
|
||
<span class="w-13 flex-none text-[10.5px] font-bold text-ok">BASE</span>
|
||
<span class="mono min-w-0 flex-1 truncate">{{ baseUrl }}</span>
|
||
<NButton size="tiny" quaternary type="primary" @click="copyBase">复制</NButton>
|
||
</div>
|
||
<div
|
||
v-for="ep in endpoints"
|
||
:key="ep.path"
|
||
class="flex items-center gap-2 border-b border-line-soft py-2 text-[13px] last:border-b-0"
|
||
>
|
||
<span class="w-13 flex-none text-[10.5px] font-bold text-ok">{{ ep.method }}</span>
|
||
<span class="mono min-w-0 flex-1">{{ ep.path }}</span>
|
||
<span class="flex-none text-xs text-ink-3">{{ ep.note }}</span>
|
||
</div>
|
||
</div>
|
||
<div class="border-t border-line-soft bg-wash px-4.5 py-2.5 text-xs leading-relaxed text-ink-3">
|
||
鉴权:Authorization: Bearer ‹密钥› 或 x-api-key: ‹密钥› · 两种头都支持,OpenAI / Anthropic SDK 可直接指向 Base URL
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel">
|
||
<div class="flex items-center justify-between border-b border-line-soft px-4.5 py-3">
|
||
<div>
|
||
<div class="text-sm font-semibold">可用模型</div>
|
||
<div class="mt-0.5 text-xs text-ink-3">按厂商分组;点击厂商展开,点击模型名复制</div>
|
||
</div>
|
||
<span class="text-xs text-ink-3">{{ models.data.value?.length ?? '…' }} 个</span>
|
||
</div>
|
||
<div class="px-4.5 py-1.5">
|
||
<div
|
||
v-for="g in modelGroups"
|
||
:key="g.vendor"
|
||
class="border-b border-line-soft py-1 last:border-b-0"
|
||
>
|
||
<button
|
||
type="button"
|
||
class="flex w-full cursor-pointer items-center gap-2 py-1.5 text-left"
|
||
@click="toggleVendor(g.vendor)"
|
||
>
|
||
<svg
|
||
class="h-3 w-3 flex-none text-ink-3 transition-transform"
|
||
:class="expandedVendor === g.vendor ? 'rotate-90' : ''"
|
||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"
|
||
stroke-linecap="round" stroke-linejoin="round"
|
||
>
|
||
<path d="m9 18 6-6-6-6" />
|
||
</svg>
|
||
<span class="text-[13px] font-medium">{{ g.label }}</span>
|
||
<span class="text-xs text-ink-3">{{ g.ids.length }} 个</span>
|
||
</button>
|
||
<div
|
||
v-if="expandedVendor === g.vendor"
|
||
class="flex max-h-36 flex-wrap content-start gap-1.5 overflow-y-auto pb-2 pl-5"
|
||
>
|
||
<span
|
||
v-for="id in g.ids"
|
||
:key="id"
|
||
class="mono inline-flex items-center overflow-hidden rounded-[5px] border border-line bg-card text-[11.5px]"
|
||
>
|
||
<button
|
||
type="button"
|
||
class="cursor-pointer py-0.5 pr-1 pl-2 hover:text-accent"
|
||
:title="`复制 ${id}`"
|
||
@click="copyModel(id)"
|
||
>
|
||
{{ id }}
|
||
</button>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div v-if="models.data.value && !models.data.value.length" class="py-2 text-xs text-ink-3">
|
||
暂无可用模型:请先添加渠道并探测(需租户订阅 GenAI 提供区域)
|
||
</div>
|
||
</div>
|
||
<div class="border-t border-line-soft bg-wash px-4.5 py-2.5 text-xs leading-relaxed text-ink-3">
|
||
模型由渠道探测 / 同步自动发现;多模态图片输入视模型而定;Cohere 系工具调用为扁平参数有损降级、不支持图片
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<AiKeyPanel
|
||
:keys="keys.data.value ?? []"
|
||
:loading="keys.loading.value"
|
||
:groups="groups"
|
||
:models="models.data.value ?? []"
|
||
@changed="keys.run({ silent: true })"
|
||
/>
|
||
|
||
<AiChannelPanel
|
||
:channels="channels.data.value ?? []"
|
||
:loading="channels.loading.value"
|
||
@changed="refreshChannels"
|
||
/>
|
||
</div>
|
||
</template>
|