渠道: 支持手工录入模型(无 /models) + 模型映射自定义名称

- AdminChannelAddModel 改为 upstream_model + custom_name:
  无 /models 接口的渠道直接填上游模型名, 可选自定义名作为客户端调用名,
  全局模型不存在时自动创建
- "支持的模型"弹窗: 手工添加 + 建议下拉(自建,紧贴输入框)/水平排列/交换位置
- 网关仍按绑定映射改写请求 model 字段

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Sakurasan
2026-08-16 00:57:06 +08:00
co-authored by Claude
parent 4d4d09ba58
commit 79f3f4395c
2 changed files with 72 additions and 35 deletions
+17 -12
View File
@@ -31,7 +31,8 @@ func (h *Handler) AdminChannelModels(c *gin.Context) {
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) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
@@ -39,31 +40,35 @@ func (h *Handler) AdminChannelAddModel(c *gin.Context) {
return
}
var req struct {
ModelName string `json:"model_name" binding:"required"`
UpstreamModel string `json:"upstream_model"`
UpstreamModel string `json:"upstream_model" binding:"required"` // 渠道侧真实模型名
CustomName string `json:"custom_name"` // 客户端调用名,空=用上游名
Weight *int `json:"weight"`
}
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid input")
return
}
var m store.Model
if err := h.a.DB.Where("name = ?", req.ModelName).First(&m).Error; err != nil {
resp.Fail(c, http.StatusNotFound, "model not found")
return
globalName := req.CustomName
if globalName == "" {
globalName = req.UpstreamModel
}
upstream := req.UpstreamModel
if upstream == "" {
upstream = m.Name
// 解析或创建全局模型(客户端名)
var m store.Model
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{
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 {
resp.Fail(c, http.StatusConflict, "binding may already exist")
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 — 改映射名/权重。
+55 -23
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { http, errMsg } from '@/api/client'
import { useToastStore } from '@/stores/toast'
import { PROTOCOL_OPTIONS, protocolShort } from '@/lib/protocol'
@@ -127,13 +127,24 @@ async function importModels(ch: Channel) {
const modelsOpen = ref(false)
const mappingChan = ref<Channel | null>(null)
const mappings = ref<ChannelModelMapping[]>([])
const availableModels = ref<string[]>([])
const mappingForm = reactive({ model_name: '', upstream_model: '', weight: 1 })
const availableModels = ref<string[]>([]) // 已存在模型名,供上游模型输入联想
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) {
mappingChan.value = ch
mappingForm.model_name = ''
mappingForm.upstream_model = ''
mappingForm.custom_name = ''
mappingForm.weight = 1
modelsOpen.value = true
await Promise.all([loadMappings(), loadAvailableModels()])
@@ -159,15 +170,17 @@ async function loadAvailableModels() {
}
async function addMapping() {
if (!mappingChan.value || !mappingForm.model_name) return
if (!mappingChan.value || !mappingForm.upstream_model) return
try {
await http.post(`/admin/channels/${mappingChan.value.id}/models`, {
model_name: mappingForm.model_name,
upstream_model: mappingForm.upstream_model,
custom_name: mappingForm.custom_name,
weight: Number(mappingForm.weight) || 1,
})
toast.ok('已绑定')
toast.ok('已添加')
mappingForm.upstream_model = ''
mappingForm.custom_name = ''
showModelSuggestions.value = false
await loadMappings()
} catch (e) {
toast.err(errMsg(e))
@@ -314,13 +327,15 @@ onMounted(load)
</Modal>
<!-- 模型名称映射 -->
<Modal :open="modelsOpen" :title="`模型映射 · ${mappingChan?.name}`" @close="modelsOpen = false">
<div class="mb-3 text-xs text-muted">客户端调用「模型名」时,网关转发为右侧「上游名称」。</div>
<Modal :open="modelsOpen" :title="`支持的模型 · ${mappingChan?.name}`" @close="modelsOpen = false">
<div class="mb-3 text-xs text-muted">
客户端调用「客户端名称」,网关转发为「上游模型」。无 /models 接口的渠道可手工添加。
</div>
<table class="w-full text-sm">
<thead>
<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" />
</tr>
@@ -343,25 +358,42 @@ onMounted(load)
</td>
</tr>
<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>
</tbody>
</table>
<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
v-model="mappingForm.upstream_model"
placeholder="上游名称(默认同名)"
class="h-9 w-44 rounded-md border border-edge2 bg-surface px-2 font-mono text-xs outline-none focus:border-accent"
v-model="mappingForm.custom_name"
placeholder="自定义名称(可选)"
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"
/>
<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>
</Modal>
</div>