Files
oci-portal-dash/src/views/AiGatewayView.vue
T
2026-07-16 12:32:21 +08:00

195 lines
7.5 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>