渠道: 支持手工录入模型(无 /models) + 模型映射自定义名称
- AdminChannelAddModel 改为 upstream_model + custom_name: 无 /models 接口的渠道直接填上游模型名, 可选自定义名作为客户端调用名, 全局模型不存在时自动创建 - "支持的模型"弹窗: 手工添加 + 建议下拉(自建,紧贴输入框)/水平排列/交换位置 - 网关仍按绑定映射改写请求 model 字段 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -31,7 +31,8 @@ func (h *Handler) AdminChannelModels(c *gin.Context) {
|
|||||||
resp.OK(c, gin.H{"items": out})
|
resp.OK(c, gin.H{"items": out})
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminChannelAddModel POST /api/v1/admin/channels/:id/models — 绑定模型到渠道。
|
// AdminChannelAddModel POST /api/v1/admin/channels/:id/models — 手工添加渠道支持的模型。
|
||||||
|
// 无需渠道具备 /v1/models 接口:直接填上游模型名,可选自定义名称作为客户端调用名。
|
||||||
func (h *Handler) AdminChannelAddModel(c *gin.Context) {
|
func (h *Handler) AdminChannelAddModel(c *gin.Context) {
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -39,31 +40,35 @@ func (h *Handler) AdminChannelAddModel(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var req struct {
|
var req struct {
|
||||||
ModelName string `json:"model_name" binding:"required"`
|
UpstreamModel string `json:"upstream_model" binding:"required"` // 渠道侧真实模型名
|
||||||
UpstreamModel string `json:"upstream_model"`
|
CustomName string `json:"custom_name"` // 客户端调用名,空=用上游名
|
||||||
Weight *int `json:"weight"`
|
Weight *int `json:"weight"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
resp.Fail(c, http.StatusBadRequest, "invalid input")
|
resp.Fail(c, http.StatusBadRequest, "invalid input")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var m store.Model
|
globalName := req.CustomName
|
||||||
if err := h.a.DB.Where("name = ?", req.ModelName).First(&m).Error; err != nil {
|
if globalName == "" {
|
||||||
resp.Fail(c, http.StatusNotFound, "model not found")
|
globalName = req.UpstreamModel
|
||||||
return
|
|
||||||
}
|
}
|
||||||
upstream := req.UpstreamModel
|
// 解析或创建全局模型(客户端名)
|
||||||
if upstream == "" {
|
var m store.Model
|
||||||
upstream = m.Name
|
if err := h.a.DB.Where("name = ?", globalName).First(&m).Error; err != nil {
|
||||||
|
m = store.Model{Name: globalName, DisplayName: globalName, Enabled: true}
|
||||||
|
if err := h.a.DB.Create(&m).Error; err != nil {
|
||||||
|
resp.Fail(c, http.StatusInternalServerError, "failed to create model")
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
b := store.ChannelModelBinding{
|
b := store.ChannelModelBinding{
|
||||||
ChannelID: id, ModelID: m.ID, UpstreamModel: upstream, Weight: intOr(req.Weight, 1),
|
ChannelID: id, ModelID: m.ID, UpstreamModel: req.UpstreamModel, Weight: intOr(req.Weight, 1),
|
||||||
}
|
}
|
||||||
if err := h.a.DB.Create(&b).Error; err != nil {
|
if err := h.a.DB.Create(&b).Error; err != nil {
|
||||||
resp.Fail(c, http.StatusConflict, "binding may already exist")
|
resp.Fail(c, http.StatusConflict, "binding may already exist")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
resp.Created(c, gin.H{"id": b.ID, "model_id": m.ID, "model_name": m.Name, "upstream_model": upstream, "weight": b.Weight})
|
resp.Created(c, gin.H{"id": b.ID, "model_id": m.ID, "model_name": m.Name, "upstream_model": req.UpstreamModel, "weight": b.Weight})
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminChannelUpdateModel PATCH /api/v1/admin/channels/:id/models/:bid — 改映射名/权重。
|
// AdminChannelUpdateModel PATCH /api/v1/admin/channels/:id/models/:bid — 改映射名/权重。
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { http, errMsg } from '@/api/client'
|
import { http, errMsg } from '@/api/client'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
import { PROTOCOL_OPTIONS, protocolShort } from '@/lib/protocol'
|
import { PROTOCOL_OPTIONS, protocolShort } from '@/lib/protocol'
|
||||||
@@ -127,13 +127,24 @@ async function importModels(ch: Channel) {
|
|||||||
const modelsOpen = ref(false)
|
const modelsOpen = ref(false)
|
||||||
const mappingChan = ref<Channel | null>(null)
|
const mappingChan = ref<Channel | null>(null)
|
||||||
const mappings = ref<ChannelModelMapping[]>([])
|
const mappings = ref<ChannelModelMapping[]>([])
|
||||||
const availableModels = ref<string[]>([])
|
const availableModels = ref<string[]>([]) // 已存在模型名,供上游模型输入联想
|
||||||
const mappingForm = reactive({ model_name: '', upstream_model: '', weight: 1 })
|
const mappingForm = reactive({ upstream_model: '', custom_name: '', weight: 1 })
|
||||||
|
const showModelSuggestions = ref(false)
|
||||||
|
const filteredModels = computed(() => {
|
||||||
|
const q = mappingForm.upstream_model.trim().toLowerCase()
|
||||||
|
if (!q) return availableModels.value
|
||||||
|
return availableModels.value.filter((m) => m.toLowerCase().includes(q))
|
||||||
|
})
|
||||||
|
|
||||||
|
function pickSuggestion(m: string) {
|
||||||
|
mappingForm.upstream_model = m
|
||||||
|
showModelSuggestions.value = false
|
||||||
|
}
|
||||||
|
|
||||||
async function openModels(ch: Channel) {
|
async function openModels(ch: Channel) {
|
||||||
mappingChan.value = ch
|
mappingChan.value = ch
|
||||||
mappingForm.model_name = ''
|
|
||||||
mappingForm.upstream_model = ''
|
mappingForm.upstream_model = ''
|
||||||
|
mappingForm.custom_name = ''
|
||||||
mappingForm.weight = 1
|
mappingForm.weight = 1
|
||||||
modelsOpen.value = true
|
modelsOpen.value = true
|
||||||
await Promise.all([loadMappings(), loadAvailableModels()])
|
await Promise.all([loadMappings(), loadAvailableModels()])
|
||||||
@@ -159,15 +170,17 @@ async function loadAvailableModels() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function addMapping() {
|
async function addMapping() {
|
||||||
if (!mappingChan.value || !mappingForm.model_name) return
|
if (!mappingChan.value || !mappingForm.upstream_model) return
|
||||||
try {
|
try {
|
||||||
await http.post(`/admin/channels/${mappingChan.value.id}/models`, {
|
await http.post(`/admin/channels/${mappingChan.value.id}/models`, {
|
||||||
model_name: mappingForm.model_name,
|
|
||||||
upstream_model: mappingForm.upstream_model,
|
upstream_model: mappingForm.upstream_model,
|
||||||
|
custom_name: mappingForm.custom_name,
|
||||||
weight: Number(mappingForm.weight) || 1,
|
weight: Number(mappingForm.weight) || 1,
|
||||||
})
|
})
|
||||||
toast.ok('已绑定')
|
toast.ok('已添加')
|
||||||
mappingForm.upstream_model = ''
|
mappingForm.upstream_model = ''
|
||||||
|
mappingForm.custom_name = ''
|
||||||
|
showModelSuggestions.value = false
|
||||||
await loadMappings()
|
await loadMappings()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.err(errMsg(e))
|
toast.err(errMsg(e))
|
||||||
@@ -314,13 +327,15 @@ onMounted(load)
|
|||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<!-- 模型名称映射 -->
|
<!-- 模型名称映射 -->
|
||||||
<Modal :open="modelsOpen" :title="`模型映射 · ${mappingChan?.name}`" @close="modelsOpen = false">
|
<Modal :open="modelsOpen" :title="`支持的模型 · ${mappingChan?.name}`" @close="modelsOpen = false">
|
||||||
<div class="mb-3 text-xs text-muted">客户端调用「模型名」时,网关转发为右侧「上游名称」。</div>
|
<div class="mb-3 text-xs text-muted">
|
||||||
|
客户端调用「客户端名称」,网关转发为「上游模型」。无 /models 接口的渠道可手工添加。
|
||||||
|
</div>
|
||||||
<table class="w-full text-sm">
|
<table class="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-edge text-left text-xs text-muted">
|
<tr class="border-b border-edge text-left text-xs text-muted">
|
||||||
<th scope="col" class="px-3 py-2 font-medium">模型</th>
|
<th scope="col" class="px-3 py-2 font-medium">客户端名称</th>
|
||||||
<th scope="col" class="px-3 py-2 font-medium">上游名称</th>
|
<th scope="col" class="px-3 py-2 font-medium">上游模型</th>
|
||||||
<th scope="col" class="px-3 py-2 font-medium">权重</th>
|
<th scope="col" class="px-3 py-2 font-medium">权重</th>
|
||||||
<th scope="col" class="px-3 py-2" />
|
<th scope="col" class="px-3 py-2" />
|
||||||
</tr>
|
</tr>
|
||||||
@@ -343,25 +358,42 @@ onMounted(load)
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="mappings.length === 0">
|
<tr v-if="mappings.length === 0">
|
||||||
<td colspan="4" class="px-3 py-6 text-center text-xs text-muted">尚未绑定模型,可在下方添加</td>
|
<td colspan="4" class="px-3 py-6 text-center text-xs text-muted">尚未添加模型,可在下方手工填写</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div class="mt-3 flex items-center gap-2 border-t border-edge pt-3">
|
<div class="mt-3 flex items-center gap-2 border-t border-edge pt-3">
|
||||||
<select
|
|
||||||
v-model="mappingForm.model_name"
|
|
||||||
class="h-9 flex-1 rounded-md border border-edge2 bg-surface px-2 text-xs text-ink outline-none focus:border-accent"
|
|
||||||
>
|
|
||||||
<option value="" disabled>选择全局模型</option>
|
|
||||||
<option v-for="m in availableModels" :key="m" :value="m">{{ m }}</option>
|
|
||||||
</select>
|
|
||||||
<input
|
<input
|
||||||
v-model="mappingForm.upstream_model"
|
v-model="mappingForm.custom_name"
|
||||||
placeholder="上游名称(默认同名)"
|
placeholder="自定义名称(可选)"
|
||||||
class="h-9 w-44 rounded-md border border-edge2 bg-surface px-2 font-mono text-xs outline-none focus:border-accent"
|
class="h-9 w-36 shrink-0 rounded-md border border-edge2 bg-surface px-2 font-mono text-xs outline-none focus:border-accent"
|
||||||
@keyup.enter="addMapping"
|
@keyup.enter="addMapping"
|
||||||
/>
|
/>
|
||||||
<Button size="sm" @click="addMapping">绑定</Button>
|
<div class="relative min-w-0 flex-1">
|
||||||
|
<input
|
||||||
|
v-model="mappingForm.upstream_model"
|
||||||
|
placeholder="上游模型名"
|
||||||
|
class="h-9 w-full rounded-md border border-edge2 bg-surface px-2 font-mono text-xs outline-none focus:border-accent"
|
||||||
|
@focus="showModelSuggestions = true"
|
||||||
|
@input="showModelSuggestions = true"
|
||||||
|
@keydown.enter="addMapping"
|
||||||
|
@keydown.esc="showModelSuggestions = false"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="showModelSuggestions && filteredModels.length"
|
||||||
|
class="absolute left-0 right-0 top-full z-10 mt-1 max-h-40 overflow-y-auto rounded-md border border-edge bg-surface py-1 shadow-lg"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="m in filteredModels"
|
||||||
|
:key="m"
|
||||||
|
class="block w-full truncate px-2.5 py-1.5 text-left font-mono text-xs text-ink hover:bg-surface2"
|
||||||
|
@mousedown.prevent="pickSuggestion(m)"
|
||||||
|
>
|
||||||
|
{{ m }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" class="shrink-0" @click="addMapping">添加</Button>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user