feat: 三协议互转网关 + 鉴权修复 + 管理端增强
后端 - 新增 proxy/convert 三协议(chat/messages/responses)请求、响应与 SSE 流式互转, 以 Chat 为中间模型;usage.go 统一提取三协议 token 用量(含单测) - gateway: 跨协议调度(渠道未声明客户端协议时转为渠道首选格式), streamResponse 按 \n\n 分块逐行转换直通,bufferResponse 转换失败时剥非 JSON 前缀 - gateway: 新增 SetUsageRecorder 注入异步用量记录器 - auth_llm: 修复 key_prefix 查询长度错配([:8] vs 存储的 [:12])导致全部 401; 修复长度 8-11 的 key 切片越界 panic;统一 unauthorized 响应 - usage: 日报表改为增量累加 upsert,避免多次 flush 互相清零;记录协议/错误码/时延等字段 - channel: 新增渠道并发槽 TryAcquire;健康检查支持可配置参数 - api: 新增 admin 渠道/模型/系统配置管理端点(旧端点保留兼容) 前端 - 新增渠道管理、模型管理、系统配置视图与 ChannelModelsDrawer - 新增 ui 基础组件(Button/Badge/Input/Modal)与 protocol.ts - 调整 Toast 样式、密钥页、路由菜单;dev 代理默认指向 3000 端口
This commit is contained in:
@@ -87,6 +87,10 @@
|
||||
|
||||
<div class="flex items-center justify-end gap-3 border-t border-base-300/40 pt-4">
|
||||
<button type="button" @click="goBack" class="btn btn-ghost btn-sm">Back</button>
|
||||
<button type="button" @click="testChannel" class="btn btn-warning btn-sm" :disabled="testing">
|
||||
<span v-if="testing" class="loading loading-spinner loading-xs" aria-hidden="true"></span>
|
||||
Test Connection
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary btn-sm px-5" :disabled="updating">
|
||||
<span v-if="updating" class="loading loading-spinner loading-xs" aria-hidden="true"></span>
|
||||
Save Changes
|
||||
@@ -94,6 +98,47 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Model Bindings -->
|
||||
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||
<div class="card-body gap-4 p-4 sm:p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Model Bindings</h2>
|
||||
<button class="btn btn-primary btn-sm" @click="openAddModelModal">
|
||||
<PlusIcon class="h-4 w-4" aria-hidden="true" />Add Model
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="bindings.length > 0" class="overflow-x-auto">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr class="text-xs uppercase tracking-wider text-base-content/50">
|
||||
<th>Model Name</th>
|
||||
<th>Upstream Model</th>
|
||||
<th class="text-right">Weight</th>
|
||||
<th class="text-right"><span class="sr-only">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="b in bindings" :key="b.id" class="border-base-300/40">
|
||||
<td class="font-medium">{{ b.model_name }}</td>
|
||||
<td class="font-mono text-xs">{{ b.upstream_model }}</td>
|
||||
<td class="text-right">{{ b.weight }}</td>
|
||||
<td class="text-right">
|
||||
<button class="btn btn-ghost btn-xs btn-square text-error" @click="confirmDeleteBinding(b)"
|
||||
aria-label="Delete binding">
|
||||
<TrashIcon class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else class="py-6 text-center text-sm text-base-content/50">
|
||||
No model bindings configured.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
@@ -104,22 +149,59 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Model Modal -->
|
||||
<dialog ref="addModelModalRef" class="modal">
|
||||
<div class="modal-box max-w-lg px-0 sm:px-6">
|
||||
<form method="dialog">
|
||||
<button class="btn btn-circle btn-ghost btn-sm absolute right-2 top-2" aria-label="Close dialog">✕</button>
|
||||
</form>
|
||||
<h3 class="mb-4 text-lg font-bold">Add Model Binding</h3>
|
||||
<form @submit.prevent="addModelBinding" class="space-y-4">
|
||||
<label class="floating-label">
|
||||
<span>Model ID *</span>
|
||||
<input v-model.number="newBinding.model_id" type="number" placeholder="Model ID" class="input w-full" required />
|
||||
</label>
|
||||
<label class="floating-label">
|
||||
<span>Upstream Model Name *</span>
|
||||
<input v-model="newBinding.upstream_model" type="text" placeholder="e.g. gpt-4o" class="input w-full" required />
|
||||
</label>
|
||||
<label class="floating-label">
|
||||
<span>Weight</span>
|
||||
<input v-model.number="newBinding.weight" type="number" min="1" placeholder="1" class="input w-full" />
|
||||
</label>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn btn-ghost" @click="closeAddModelModal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="addingModel">
|
||||
{{ addingModel ? 'Adding...' : 'Add' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button aria-label="Close dialog">close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useChannelStore, type Channel } from '../../stores/channel';
|
||||
import { useChannelStore, type Channel, type ChannelModelBinding } from '../../stores/channel';
|
||||
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
|
||||
import { useToast } from '@/composables/toast';
|
||||
|
||||
import { PlusIcon, TrashIcon } from '@lucide/vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const channelStore = useChannelStore();
|
||||
const { setToast } = useToast();
|
||||
const updating = ref(false);
|
||||
const testing = ref(false);
|
||||
const api_key = ref('');
|
||||
const bindings = ref<ChannelModelBinding[]>([]);
|
||||
|
||||
const channelId = computed(() => route.query.id);
|
||||
const ch = computed(() => channelStore.channel);
|
||||
@@ -127,9 +209,16 @@ const ch = computed(() => channelStore.channel);
|
||||
onMounted(async () => {
|
||||
if (channelId.value) {
|
||||
await channelStore.fetchChannel(channelId.value as string);
|
||||
await fetchBindings();
|
||||
}
|
||||
});
|
||||
|
||||
const fetchBindings = async () => {
|
||||
if (channelId.value) {
|
||||
bindings.value = await channelStore.fetchChannelModels(channelId.value as string);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleEnabled = () => {
|
||||
if (!ch.value) return;
|
||||
ch.value.enabled = !ch.value.enabled;
|
||||
@@ -163,7 +252,65 @@ const updateCh = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const testChannel = async () => {
|
||||
if (!ch.value) return;
|
||||
testing.value = true;
|
||||
try {
|
||||
const result = await channelStore.testChannel(ch.value.id);
|
||||
setToast(`Connection OK (${result.data?.latency_ms}ms)`, 'success');
|
||||
} catch (err: any) {
|
||||
setToast(err.response?.data?.error || 'Connection test failed', 'error');
|
||||
} finally {
|
||||
testing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
router.push({ name: 'Channels' });
|
||||
};
|
||||
|
||||
// Model binding
|
||||
const addModelModalRef = ref<HTMLDialogElement | null>(null);
|
||||
const addingModel = ref(false);
|
||||
const newBinding = ref({
|
||||
model_id: 0,
|
||||
upstream_model: '',
|
||||
weight: 1,
|
||||
});
|
||||
|
||||
const openAddModelModal = () => {
|
||||
newBinding.value = { model_id: 0, upstream_model: '', weight: 1 };
|
||||
addModelModalRef.value?.showModal();
|
||||
};
|
||||
|
||||
const closeAddModelModal = () => {
|
||||
addModelModalRef.value?.close();
|
||||
};
|
||||
|
||||
const addModelBinding = async () => {
|
||||
if (!channelId.value || !newBinding.value.model_id || !newBinding.value.upstream_model) return;
|
||||
addingModel.value = true;
|
||||
try {
|
||||
await channelStore.addChannelModel(channelId.value as string, newBinding.value);
|
||||
setToast('Model binding added', 'success');
|
||||
closeAddModelModal();
|
||||
await fetchBindings();
|
||||
} catch (err: any) {
|
||||
setToast(err.response?.data?.error || 'Failed to add binding', 'error');
|
||||
} finally {
|
||||
addingModel.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDeleteBinding = async (b: ChannelModelBinding) => {
|
||||
if (confirm(`Remove binding for model "${b.model_name}"?`)) {
|
||||
try {
|
||||
await channelStore.deleteChannelModel(channelId.value as string, b.id);
|
||||
setToast('Binding removed', 'success');
|
||||
await fetchBindings();
|
||||
} catch (err: any) {
|
||||
setToast('Delete failed', 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user