初始提交:OCI 面板前端(含 GenAI 网关一期)
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, useMessage } from 'naive-ui'
|
||||
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'
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
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: '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"
|
||||
>
|
||||
<button
|
||||
v-for="id in g.ids"
|
||||
:key="id"
|
||||
type="button"
|
||||
class="mono cursor-pointer rounded-[5px] border border-line bg-card px-2 py-0.5 text-[11.5px] hover:border-accent hover:text-accent"
|
||||
:title="`复制 ${id}`"
|
||||
@click="copyModel(id)"
|
||||
>
|
||||
{{ id }}
|
||||
</button>
|
||||
</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"
|
||||
@changed="keys.run({ silent: true })"
|
||||
/>
|
||||
|
||||
<AiChannelPanel
|
||||
:channels="channels.data.value ?? []"
|
||||
:loading="channels.loading.value"
|
||||
@changed="refreshChannels"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user