mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
feat(provider): 增加 Claude Code 适配器、高级配置能力与 OAuth 账号类型统一解析
- 新增 Claude Code provider adapter (context, envelope, plugin, constants) - 扩展 provider admin 路由,支持 Claude Code 高级配置 (CRUD) - 统一 OAuth 账号类型解析逻辑,前后端对齐 - 重构 BatchAssignModelsDialog / ModelMappingDialog,简化组件逻辑 - handler 基类增强: request_builder 支持 Claude Code 信封格式 - CLI stream/sync mixin 适配 Claude Code 流式与同步模式 - 扩展 candidate builder / failover / scheduler 对 Claude Code 的支持 - 前端增加请求时间线可视化 (HorizontalRequestTimeline) - 补充 Claude Code envelope / runtime controls / distributed sessions 等测试 Closes #183 Closes #185 Co-Authored-By: AAEE86 <ppk0227@hotmail.com>
This commit is contained in:
@@ -7,3 +7,4 @@ export * from './health'
|
||||
export * from './models'
|
||||
export * from './adaptive'
|
||||
export * from './global-models'
|
||||
export * from './pool'
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import client from '../client'
|
||||
import type { ProviderWithEndpointsSummary, ProxyConfig } from './types'
|
||||
import type {
|
||||
ClaudeCodeAdvancedConfig,
|
||||
PoolAdvancedConfig,
|
||||
ProviderWithEndpointsSummary,
|
||||
ProxyConfig,
|
||||
} from './types'
|
||||
|
||||
/**
|
||||
* 获取 Providers 摘要(包含 Endpoints 统计)
|
||||
@@ -41,6 +46,8 @@ export async function updateProvider(
|
||||
max_probe_interval_minutes: number
|
||||
enable_format_conversion: boolean // 是否允许格式转换(提供商级别开关)
|
||||
is_active: boolean
|
||||
claude_code_advanced: ClaudeCodeAdvancedConfig | null
|
||||
pool_advanced: PoolAdvancedConfig | null
|
||||
}>
|
||||
): Promise<ProviderWithEndpointsSummary> {
|
||||
const response = await client.patch(`/api/admin/providers/${providerId}`, data)
|
||||
@@ -68,6 +75,8 @@ export async function createProvider(
|
||||
stream_first_byte_timeout?: number | null
|
||||
request_timeout?: number | null
|
||||
proxy?: ProxyConfig | null
|
||||
claude_code_advanced?: ClaudeCodeAdvancedConfig | null
|
||||
pool_advanced?: PoolAdvancedConfig | null
|
||||
}
|
||||
): Promise<{ id: string; name: string; message?: string }> {
|
||||
const response = await client.post('/api/admin/providers/', data)
|
||||
|
||||
@@ -442,6 +442,30 @@ export interface PublicEndpointStatusMonitorResponse {
|
||||
|
||||
export type ProviderType = 'custom' | 'claude_code' | 'codex' | 'gemini_cli' | 'antigravity' | 'kiro'
|
||||
|
||||
export interface ClaudeCodeAdvancedConfig {
|
||||
// 会话数量控制:null/undefined 表示不限制
|
||||
max_sessions?: number | null
|
||||
session_idle_timeout_minutes?: number | null
|
||||
// TLS 指纹模拟(模拟 Node.js/Claude Code 客户端指纹)
|
||||
enable_tls_fingerprint?: boolean
|
||||
// 会话 ID 伪装(固定 metadata.user_id 中 session 片段)
|
||||
session_id_masking_enabled?: boolean
|
||||
}
|
||||
|
||||
export interface PoolAdvancedConfig {
|
||||
sticky_session_ttl_seconds?: number | null
|
||||
load_threshold_percent?: number | null
|
||||
lru_enabled?: boolean
|
||||
cost_window_seconds?: number | null
|
||||
cost_limit_per_key_tokens?: number | null
|
||||
cost_soft_threshold_percent?: number | null
|
||||
rate_limit_cooldown_seconds?: number | null
|
||||
overload_cooldown_seconds?: number | null
|
||||
proactive_refresh_seconds?: number | null
|
||||
health_policy_enabled?: boolean
|
||||
unschedulable_rules?: Array<Record<string, unknown>> | null
|
||||
}
|
||||
|
||||
export interface ProviderWithEndpointsSummary {
|
||||
id: string
|
||||
name: string
|
||||
@@ -475,6 +499,8 @@ export interface ProviderWithEndpointsSummary {
|
||||
unhealthy_endpoints: number
|
||||
api_formats: string[]
|
||||
endpoint_health_details: EndpointHealthDetail[]
|
||||
claude_code_advanced?: ClaudeCodeAdvancedConfig | null
|
||||
pool_advanced?: PoolAdvancedConfig | null
|
||||
ops_configured: boolean // 是否配置了扩展操作(余额监控等)
|
||||
ops_architecture_id?: string // 扩展操作使用的架构 ID(如 cubence, anyrouter)
|
||||
created_at: string
|
||||
|
||||
@@ -19,35 +19,9 @@
|
||||
class="pl-8 h-9"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
v-if="upstreamModelsLoaded"
|
||||
type="button"
|
||||
class="p-2 hover:bg-muted rounded-md transition-colors shrink-0"
|
||||
title="刷新上游模型"
|
||||
:disabled="fetchingUpstreamModels"
|
||||
@click="fetchUpstreamModels(true)"
|
||||
>
|
||||
<RefreshCw
|
||||
class="w-4 h-4"
|
||||
:class="{ 'animate-spin': fetchingUpstreamModels }"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
v-else-if="!fetchingUpstreamModels"
|
||||
type="button"
|
||||
class="p-2 hover:bg-muted rounded-md transition-colors shrink-0"
|
||||
title="从提供商获取模型"
|
||||
@click="fetchUpstreamModels()"
|
||||
>
|
||||
<Zap class="w-4 h-4" />
|
||||
</button>
|
||||
<Loader2
|
||||
v-else
|
||||
class="w-4 h-4 animate-spin text-muted-foreground shrink-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 单列模型列表 -->
|
||||
<!-- 模型列表 -->
|
||||
<div class="border rounded-lg overflow-hidden">
|
||||
<div class="max-h-96 overflow-y-auto">
|
||||
<div
|
||||
@@ -58,17 +32,12 @@
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- 全局模型组 -->
|
||||
<div v-if="filteredGlobalModels.length > 0 || !upstreamModelsLoaded">
|
||||
<!-- 全局模型列表 -->
|
||||
<div v-if="filteredGlobalModels.length > 0">
|
||||
<div
|
||||
class="flex items-center justify-between px-3 py-2 bg-muted sticky top-0 z-10 cursor-pointer hover:bg-muted/80 transition-colors"
|
||||
@click="toggleGroupCollapse('global')"
|
||||
class="flex items-center justify-between px-3 py-2 bg-muted sticky top-0 z-10"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<ChevronDown
|
||||
class="w-4 h-4 transition-transform shrink-0"
|
||||
:class="collapsedGroups.has('global') ? '-rotate-90' : ''"
|
||||
/>
|
||||
<span class="text-xs font-medium">全局模型</span>
|
||||
<span class="text-xs text-muted-foreground">({{ filteredGlobalModels.length }})</span>
|
||||
</div>
|
||||
@@ -81,16 +50,7 @@
|
||||
{{ isAllGlobalModelsSelected ? '取消全选' : '全选' }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-show="!collapsedGroups.has('global')"
|
||||
class="space-y-1 p-2"
|
||||
>
|
||||
<div
|
||||
v-if="filteredGlobalModels.length === 0"
|
||||
class="py-4 text-center text-xs text-muted-foreground"
|
||||
>
|
||||
暂无可用全局模型
|
||||
</div>
|
||||
<div class="space-y-1 p-2">
|
||||
<div
|
||||
v-for="model in filteredGlobalModels"
|
||||
:key="model.id"
|
||||
@@ -118,85 +78,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 上游模型组 -->
|
||||
<div v-if="filteredUpstreamModels.length > 0">
|
||||
<div
|
||||
class="flex items-center justify-between px-3 py-2 bg-muted sticky top-0 z-10 cursor-pointer hover:bg-muted/80 transition-colors"
|
||||
@click="toggleGroupCollapse('upstream')"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<ChevronDown
|
||||
class="w-4 h-4 transition-transform shrink-0"
|
||||
:class="collapsedGroups.has('upstream') ? '-rotate-90' : ''"
|
||||
/>
|
||||
<span class="text-xs font-medium">上游模型</span>
|
||||
<span class="text-xs text-muted-foreground">({{ filteredUpstreamModels.length }})</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs text-primary hover:underline shrink-0"
|
||||
@click.stop="toggleAllUpstreamModels"
|
||||
>
|
||||
{{ isAllUpstreamModelsSelected ? '取消全选' : '全选' }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-show="!collapsedGroups.has('upstream')"
|
||||
class="space-y-1 p-2"
|
||||
>
|
||||
<div
|
||||
v-for="model in filteredUpstreamModels"
|
||||
:key="model.id"
|
||||
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted cursor-pointer"
|
||||
@click="toggleUpstreamModelSelection(model.id)"
|
||||
>
|
||||
<div
|
||||
class="w-4 h-4 border rounded flex items-center justify-center shrink-0"
|
||||
:class="isUpstreamModelSelected(model.id) ? 'bg-primary border-primary' : ''"
|
||||
>
|
||||
<Check
|
||||
v-if="isUpstreamModelSelected(model.id)"
|
||||
class="w-3 h-3 text-primary-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<p class="text-sm font-medium truncate">
|
||||
{{ model.id }}
|
||||
</p>
|
||||
<span
|
||||
v-for="fmt in model.api_formats"
|
||||
:key="fmt"
|
||||
class="text-[10px] px-1 py-0.5 rounded bg-muted text-muted-foreground shrink-0"
|
||||
>
|
||||
{{ formatApiFormat(fmt) }}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
v-if="model.owned_by"
|
||||
class="text-xs text-muted-foreground truncate"
|
||||
>
|
||||
{{ model.owned_by }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div
|
||||
v-if="filteredGlobalModels.length === 0 && filteredUpstreamModels.length === 0"
|
||||
v-if="filteredGlobalModels.length === 0"
|
||||
class="flex flex-col items-center justify-center py-12 text-muted-foreground"
|
||||
>
|
||||
<Layers class="w-10 h-10 mb-2 opacity-30" />
|
||||
<p class="text-sm">
|
||||
{{ searchQuery ? '无匹配结果' : '暂无可用模型' }}
|
||||
{{ searchQuery ? '无匹配结果' : '暂无可用全局模型' }}
|
||||
</p>
|
||||
<p
|
||||
v-if="!upstreamModelsLoaded"
|
||||
class="text-xs mt-1"
|
||||
>
|
||||
点击上方按钮从上游获取模型
|
||||
<p class="text-xs mt-1">
|
||||
请先前往"模型目录"页面创建全局模型
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -234,7 +126,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Layers, Loader2, ChevronDown, Zap, RefreshCw, Search, Check } from 'lucide-vue-next'
|
||||
import { Layers, Loader2, Search, Check } from 'lucide-vue-next'
|
||||
import Dialog from '@/components/ui/dialog/Dialog.vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
@@ -249,11 +141,8 @@ import {
|
||||
getProviderModels,
|
||||
batchAssignModelsToProvider,
|
||||
deleteModel,
|
||||
importModelsFromUpstream,
|
||||
type Model
|
||||
} from '@/api/endpoints'
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
import { useUpstreamModelsCache, type UpstreamModel } from '../composables/useUpstreamModelsCache'
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
@@ -266,32 +155,22 @@ const emit = defineEmits<{
|
||||
'changed': []
|
||||
}>()
|
||||
|
||||
const { fetchModels: fetchCachedModels } = useUpstreamModelsCache()
|
||||
|
||||
const { error: showError, success } = useToast()
|
||||
const { confirmWarning } = useConfirm()
|
||||
|
||||
// 状态
|
||||
const loadingGlobalModels = ref(false)
|
||||
const fetchingUpstreamModels = ref(false)
|
||||
const upstreamModelsLoaded = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
// 数据
|
||||
const allGlobalModels = ref<GlobalModelResponse[]>([])
|
||||
const existingModels = ref<Model[]>([])
|
||||
const upstreamModels = ref<UpstreamModel[]>([])
|
||||
|
||||
// 选择状态(本地状态,保存时才提交)
|
||||
const selectedGlobalModelIds = ref<Set<string>>(new Set())
|
||||
const selectedUpstreamModelIds = ref<Set<string>>(new Set())
|
||||
|
||||
// 初始状态(用于计算变更)
|
||||
const initialGlobalModelIds = ref<Set<string>>(new Set())
|
||||
const initialUpstreamModelNames = ref<Set<string>>(new Set())
|
||||
|
||||
// 折叠状态
|
||||
const collapsedGroups = ref<Set<string>>(new Set())
|
||||
|
||||
// 搜索状态
|
||||
const searchQuery = ref('')
|
||||
@@ -305,18 +184,6 @@ const existingGlobalModelIds = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
// 已关联的上游模型名称集合
|
||||
const existingUpstreamModelNames = computed(() => {
|
||||
const names = new Set<string>()
|
||||
for (const m of existingModels.value) {
|
||||
names.add(m.provider_model_name)
|
||||
for (const mapping of m.provider_model_mappings ?? []) {
|
||||
if (mapping.name) names.add(mapping.name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
})
|
||||
|
||||
// 过滤后的全局模型
|
||||
const filteredGlobalModels = computed(() => {
|
||||
const query = searchQuery.value.toLowerCase().trim()
|
||||
@@ -328,59 +195,17 @@ const filteredGlobalModels = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
// 过滤后的上游模型(后端已按 id 聚合)
|
||||
const filteredUpstreamModels = computed(() => {
|
||||
if (!upstreamModelsLoaded.value) return []
|
||||
|
||||
const query = searchQuery.value.toLowerCase().trim()
|
||||
let models = upstreamModels.value
|
||||
|
||||
if (query) {
|
||||
models = models.filter(m => m.id.toLowerCase().includes(query))
|
||||
}
|
||||
|
||||
// 按 id 排序
|
||||
return [...models].sort((a, b) => a.id.localeCompare(b.id))
|
||||
})
|
||||
|
||||
// 上游模型是否全选
|
||||
const isAllUpstreamModelsSelected = computed(() => {
|
||||
if (filteredUpstreamModels.value.length === 0) return false
|
||||
return filteredUpstreamModels.value.every(m => selectedUpstreamModelIds.value.has(m.id))
|
||||
})
|
||||
|
||||
// 全选/取消全选上游模型
|
||||
function toggleAllUpstreamModels() {
|
||||
const allIds = filteredUpstreamModels.value.map(m => m.id)
|
||||
if (isAllUpstreamModelsSelected.value) {
|
||||
// 取消全选
|
||||
for (const id of allIds) {
|
||||
selectedUpstreamModelIds.value.delete(id)
|
||||
}
|
||||
} else {
|
||||
// 全选
|
||||
for (const id of allIds) {
|
||||
selectedUpstreamModelIds.value.add(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 检查全局模型是否已选中
|
||||
function isGlobalModelSelected(globalModelId: string): boolean {
|
||||
return selectedGlobalModelIds.value.has(globalModelId)
|
||||
}
|
||||
|
||||
// 检查上游模型是否已选中
|
||||
function isUpstreamModelSelected(modelId: string): boolean {
|
||||
return selectedUpstreamModelIds.value.has(modelId)
|
||||
}
|
||||
|
||||
// 全局模型是否全选
|
||||
const isAllGlobalModelsSelected = computed(() => {
|
||||
if (filteredGlobalModels.value.length === 0) return false
|
||||
return filteredGlobalModels.value.every(m => isGlobalModelSelected(m.id))
|
||||
})
|
||||
|
||||
// 检查全局模型是否已选中
|
||||
function isGlobalModelSelected(globalModelId: string): boolean {
|
||||
return selectedGlobalModelIds.value.has(globalModelId)
|
||||
}
|
||||
|
||||
// 计算待添加的全局模型
|
||||
const globalModelsToAdd = computed(() => {
|
||||
const toAdd: string[] = []
|
||||
@@ -403,42 +228,16 @@ const globalModelsToRemove = computed(() => {
|
||||
return toRemove
|
||||
})
|
||||
|
||||
// 计算待添加的上游模型
|
||||
const upstreamModelsToAdd = computed(() => {
|
||||
const toAdd: string[] = []
|
||||
for (const id of selectedUpstreamModelIds.value) {
|
||||
if (!initialUpstreamModelNames.value.has(id)) {
|
||||
toAdd.push(id)
|
||||
}
|
||||
}
|
||||
return toAdd
|
||||
})
|
||||
|
||||
// 计算待移除的上游模型
|
||||
const upstreamModelsToRemove = computed(() => {
|
||||
const toRemove: string[] = []
|
||||
for (const id of initialUpstreamModelNames.value) {
|
||||
if (!selectedUpstreamModelIds.value.has(id)) {
|
||||
toRemove.push(id)
|
||||
}
|
||||
}
|
||||
return toRemove
|
||||
})
|
||||
|
||||
// 是否有变更
|
||||
const hasChanges = computed(() => {
|
||||
return globalModelsToAdd.value.length > 0 ||
|
||||
globalModelsToRemove.value.length > 0 ||
|
||||
upstreamModelsToAdd.value.length > 0 ||
|
||||
upstreamModelsToRemove.value.length > 0
|
||||
globalModelsToRemove.value.length > 0
|
||||
})
|
||||
|
||||
// 待变更数量
|
||||
const pendingChangesCount = computed(() => {
|
||||
return globalModelsToAdd.value.length +
|
||||
globalModelsToRemove.value.length +
|
||||
upstreamModelsToAdd.value.length +
|
||||
upstreamModelsToRemove.value.length
|
||||
globalModelsToRemove.value.length
|
||||
})
|
||||
|
||||
// 切换全局模型选择
|
||||
@@ -451,26 +250,14 @@ function toggleGlobalModelSelection(id: string) {
|
||||
selectedGlobalModelIds.value = new Set(selectedGlobalModelIds.value)
|
||||
}
|
||||
|
||||
// 切换上游模型选择
|
||||
function toggleUpstreamModelSelection(id: string) {
|
||||
if (selectedUpstreamModelIds.value.has(id)) {
|
||||
selectedUpstreamModelIds.value.delete(id)
|
||||
} else {
|
||||
selectedUpstreamModelIds.value.add(id)
|
||||
}
|
||||
selectedUpstreamModelIds.value = new Set(selectedUpstreamModelIds.value)
|
||||
}
|
||||
|
||||
// 全选/取消全选全局模型
|
||||
function toggleAllGlobalModels() {
|
||||
const allIds = filteredGlobalModels.value.map(m => m.id)
|
||||
if (isAllGlobalModelsSelected.value) {
|
||||
// 取消全选
|
||||
for (const id of allIds) {
|
||||
selectedGlobalModelIds.value.delete(id)
|
||||
}
|
||||
} else {
|
||||
// 全选
|
||||
for (const id of allIds) {
|
||||
selectedGlobalModelIds.value.add(id)
|
||||
}
|
||||
@@ -478,16 +265,6 @@ function toggleAllGlobalModels() {
|
||||
selectedGlobalModelIds.value = new Set(selectedGlobalModelIds.value)
|
||||
}
|
||||
|
||||
// 切换折叠状态
|
||||
function toggleGroupCollapse(group: string) {
|
||||
if (collapsedGroups.value.has(group)) {
|
||||
collapsedGroups.value.delete(group)
|
||||
} else {
|
||||
collapsedGroups.value.add(group)
|
||||
}
|
||||
collapsedGroups.value = new Set(collapsedGroups.value)
|
||||
}
|
||||
|
||||
// 处理关闭
|
||||
async function handleClose() {
|
||||
if (hasChanges.value) {
|
||||
@@ -530,23 +307,6 @@ async function handleSave() {
|
||||
}
|
||||
}
|
||||
|
||||
// 移除上游模型
|
||||
for (const modelId of upstreamModelsToRemove.value) {
|
||||
const existingModel = existingModels.value.find(m =>
|
||||
m.provider_model_name === modelId ||
|
||||
m.provider_model_mappings?.some(mapping => mapping.name === modelId)
|
||||
)
|
||||
if (existingModel) {
|
||||
hasAnyOperation = true
|
||||
try {
|
||||
await deleteModel(props.providerId, existingModel.id)
|
||||
totalSuccess++
|
||||
} catch (err: unknown) {
|
||||
allErrors.push(parseApiError(err, '移除失败'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加全局模型
|
||||
if (globalModelsToAdd.value.length > 0) {
|
||||
hasAnyOperation = true
|
||||
@@ -561,20 +321,6 @@ async function handleSave() {
|
||||
}
|
||||
}
|
||||
|
||||
// 添加上游模型
|
||||
if (upstreamModelsToAdd.value.length > 0) {
|
||||
hasAnyOperation = true
|
||||
try {
|
||||
const result = await importModelsFromUpstream(props.providerId, upstreamModelsToAdd.value)
|
||||
totalSuccess += result.success.length
|
||||
if (result.errors.length > 0) {
|
||||
allErrors.push(...result.errors.map(e => e.error))
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
allErrors.push(parseApiError(err, '导入上游模型失败'))
|
||||
}
|
||||
}
|
||||
|
||||
if (totalSuccess > 0) {
|
||||
success(`成功处理 ${totalSuccess} 个模型`)
|
||||
}
|
||||
@@ -587,7 +333,6 @@ async function handleSave() {
|
||||
emit('update:open', false)
|
||||
} catch (err: unknown) {
|
||||
showError(parseApiError(err, '保存失败'), '错误')
|
||||
// 即使出错,如果已执行过操作,也通知父组件刷新数据
|
||||
if (hasAnyOperation) {
|
||||
emit('changed')
|
||||
}
|
||||
@@ -596,52 +341,28 @@ async function handleSave() {
|
||||
}
|
||||
}
|
||||
|
||||
// 从已有数据同步选择状态(全局模型)
|
||||
// 从已有数据同步选择状态
|
||||
function syncGlobalModelSelection() {
|
||||
const globalIds = [...existingGlobalModelIds.value].filter((id): id is string => id !== undefined)
|
||||
selectedGlobalModelIds.value = new Set(globalIds)
|
||||
initialGlobalModelIds.value = new Set(globalIds)
|
||||
}
|
||||
|
||||
// 从已有数据同步选择状态(上游模型)
|
||||
function syncUpstreamModelSelection() {
|
||||
// 只同步当前已加载的上游模型中,与已关联模型匹配的部分
|
||||
const selected = new Set<string>()
|
||||
for (const model of upstreamModels.value) {
|
||||
if (existingUpstreamModelNames.value.has(model.id)) {
|
||||
selected.add(model.id)
|
||||
}
|
||||
}
|
||||
selectedUpstreamModelIds.value = selected
|
||||
initialUpstreamModelNames.value = new Set(selected)
|
||||
}
|
||||
|
||||
// 监听打开状态
|
||||
watch(() => props.open, async (isOpen) => {
|
||||
if (isOpen && props.providerId) {
|
||||
await loadData()
|
||||
} else {
|
||||
// 重置状态
|
||||
upstreamModels.value = []
|
||||
upstreamModelsLoaded.value = false
|
||||
collapsedGroups.value = new Set()
|
||||
searchQuery.value = ''
|
||||
selectedGlobalModelIds.value = new Set()
|
||||
selectedUpstreamModelIds.value = new Set()
|
||||
initialGlobalModelIds.value = new Set()
|
||||
initialUpstreamModelNames.value = new Set()
|
||||
}
|
||||
})
|
||||
|
||||
// 加载数据
|
||||
async function loadData() {
|
||||
await Promise.all([loadGlobalModels(), loadExistingModels()])
|
||||
|
||||
// 同步全局模型选择状态
|
||||
syncGlobalModelSelection()
|
||||
|
||||
// 初始折叠状态
|
||||
collapsedGroups.value = new Set()
|
||||
}
|
||||
|
||||
// 加载全局模型列表
|
||||
@@ -665,26 +386,4 @@ async function loadExistingModels() {
|
||||
showError(parseApiError(err, '加载已关联模型失败'), '错误')
|
||||
}
|
||||
}
|
||||
|
||||
// 从提供商获取模型
|
||||
async function fetchUpstreamModels(forceRefresh = false) {
|
||||
try {
|
||||
fetchingUpstreamModels.value = true
|
||||
const result = await fetchCachedModels(props.providerId, undefined, forceRefresh)
|
||||
if (result) {
|
||||
if (result.error) {
|
||||
showError(result.error, '错误')
|
||||
} else {
|
||||
upstreamModels.value = result.models
|
||||
upstreamModelsLoaded.value = true
|
||||
// 同步上游模型选择状态
|
||||
syncUpstreamModelSelection()
|
||||
// 全部折叠
|
||||
collapsedGroups.value = new Set(['global', 'upstream'])
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
fetchingUpstreamModels.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -63,33 +63,6 @@
|
||||
>
|
||||
已选 {{ selectedNames.length }} 个
|
||||
</span>
|
||||
<!-- 刷新上游模型按钮 -->
|
||||
<button
|
||||
v-if="upstreamModelsLoaded"
|
||||
type="button"
|
||||
class="p-2 hover:bg-muted rounded-md transition-colors shrink-0"
|
||||
:disabled="fetchingUpstreamModels"
|
||||
title="刷新上游模型"
|
||||
@click="fetchUpstreamModels()"
|
||||
>
|
||||
<RefreshCw
|
||||
class="w-4 h-4"
|
||||
:class="{ 'animate-spin': fetchingUpstreamModels }"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
v-else-if="!fetchingUpstreamModels"
|
||||
type="button"
|
||||
class="p-2 hover:bg-muted rounded-md transition-colors shrink-0"
|
||||
title="从提供商获取模型"
|
||||
@click="fetchUpstreamModels()"
|
||||
>
|
||||
<Zap class="w-4 h-4" />
|
||||
</button>
|
||||
<Loader2
|
||||
v-else
|
||||
class="w-4 h-4 animate-spin text-muted-foreground shrink-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 模型列表 -->
|
||||
@@ -124,22 +97,14 @@
|
||||
<!-- 自定义映射名称 -->
|
||||
<div v-if="customNames.length > 0">
|
||||
<div
|
||||
class="flex items-center justify-between px-3 py-2 bg-muted sticky top-0 z-20 cursor-pointer hover:bg-muted/80 transition-colors"
|
||||
@click="toggleGroupCollapse('custom')"
|
||||
class="flex items-center justify-between px-3 py-2 bg-muted sticky top-0 z-20"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<ChevronDown
|
||||
class="w-4 h-4 transition-transform shrink-0"
|
||||
:class="collapsedGroups.has('custom') ? '-rotate-90' : ''"
|
||||
/>
|
||||
<span class="text-xs font-medium">自定义模型</span>
|
||||
<span class="text-xs text-muted-foreground">({{ customNames.length }})</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-show="!collapsedGroups.has('custom')"
|
||||
class="space-y-1 p-2"
|
||||
>
|
||||
<div class="space-y-1 p-2">
|
||||
<div
|
||||
v-for="name in sortedCustomNames"
|
||||
:key="name"
|
||||
@@ -160,52 +125,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 上游模型 -->
|
||||
<template v-if="filteredUpstreamModels.length > 0">
|
||||
<div
|
||||
class="flex items-center justify-between px-3 py-2 bg-muted sticky top-0 z-20 cursor-pointer hover:bg-muted/80 transition-colors"
|
||||
@click="toggleGroupCollapse('upstream')"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<ChevronDown
|
||||
class="w-4 h-4 transition-transform shrink-0"
|
||||
:class="collapsedGroups.has('upstream') ? '-rotate-90' : ''"
|
||||
/>
|
||||
<span class="text-xs font-medium">上游模型</span>
|
||||
<span class="text-xs text-muted-foreground">({{ upstreamModelNames.length }})</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs text-primary hover:underline"
|
||||
@click.stop="toggleAllUpstreamModels"
|
||||
>
|
||||
{{ isAllUpstreamModelsSelected ? '取消全选' : '全选' }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-show="!collapsedGroups.has('upstream')"
|
||||
class="space-y-1 p-2"
|
||||
>
|
||||
<div
|
||||
v-for="name in filteredUpstreamModels"
|
||||
:key="name"
|
||||
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-muted cursor-pointer"
|
||||
@click="toggleName(name)"
|
||||
>
|
||||
<div
|
||||
class="w-4 h-4 border rounded flex items-center justify-center shrink-0"
|
||||
:class="selectedNames.includes(name) ? 'bg-primary border-primary' : ''"
|
||||
>
|
||||
<Check
|
||||
v-if="selectedNames.includes(name)"
|
||||
class="w-3 h-3 text-primary-foreground"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-sm font-mono truncate flex-1">{{ name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div
|
||||
v-if="showEmptyState"
|
||||
@@ -248,7 +167,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Tag, Loader2, Plus, Search, Check, ChevronDown, RefreshCw, Zap } from 'lucide-vue-next'
|
||||
import { Tag, Loader2, Plus, Search, Check } from 'lucide-vue-next'
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
@@ -265,16 +184,14 @@ import { parseApiError } from '@/utils/errorParser'
|
||||
import {
|
||||
type Model,
|
||||
type ProviderModelAlias,
|
||||
type UpstreamModel,
|
||||
} from '@/api/endpoints'
|
||||
import { updateModel } from '@/api/endpoints/models'
|
||||
import { useUpstreamModelsCache } from '../composables/useUpstreamModelsCache'
|
||||
|
||||
export interface AliasGroup {
|
||||
model: Model
|
||||
/** @deprecated 作用域功能已废弃,将在后续版本移除 */
|
||||
/** @deprecated */
|
||||
apiFormatsKey: string
|
||||
/** @deprecated 作用域功能已废弃,将在后续版本移除 */
|
||||
/** @deprecated */
|
||||
apiFormats: string[]
|
||||
aliases: ProviderModelAlias[]
|
||||
}
|
||||
@@ -282,12 +199,11 @@ export interface AliasGroup {
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
providerId: string
|
||||
/** @deprecated 作用域功能已废弃,此 prop 将在后续版本移除 */
|
||||
/** @deprecated */
|
||||
providerApiFormats?: string[]
|
||||
models: Model[]
|
||||
editingGroup?: AliasGroup | null
|
||||
preselectedModelId?: string | null
|
||||
hasAutoFetchKey?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -296,23 +212,14 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { error: showError, success: showSuccess } = useToast()
|
||||
const { fetchModels: fetchCachedModels } = useUpstreamModelsCache()
|
||||
|
||||
// 状态
|
||||
const submitting = ref(false)
|
||||
const loadingModels = ref(false)
|
||||
const fetchingUpstreamModels = ref(false)
|
||||
const upstreamModelsLoaded = ref(false)
|
||||
|
||||
// 搜索
|
||||
const searchQuery = ref('')
|
||||
|
||||
// 折叠状态
|
||||
const collapsedGroups = ref<Set<string>>(new Set())
|
||||
|
||||
// 上游模型
|
||||
const upstreamModels = ref<UpstreamModel[]>([])
|
||||
|
||||
// 表单数据
|
||||
const formData = ref<{
|
||||
modelId: string
|
||||
@@ -326,26 +233,9 @@ const selectedNames = ref<string[]>([])
|
||||
// 自定义名称列表(手动添加的)
|
||||
const allCustomNames = ref<string[]>([])
|
||||
|
||||
// 所有已知名称集合
|
||||
const allKnownNames = computed(() => {
|
||||
const set = new Set<string>()
|
||||
upstreamModels.value.forEach(m => set.add(m.id))
|
||||
return set
|
||||
})
|
||||
|
||||
// 上游模型名称列表(去重后)
|
||||
const upstreamModelNames = computed(() => {
|
||||
const names = new Set<string>()
|
||||
upstreamModels.value.forEach(m => {
|
||||
names.add(m.id)
|
||||
})
|
||||
return Array.from(names).sort()
|
||||
})
|
||||
|
||||
// 自定义名称列表(排除上游模型中已有的)
|
||||
// 自定义名称列表
|
||||
const customNames = computed(() => {
|
||||
const upstreamSet = new Set(upstreamModelNames.value)
|
||||
return allCustomNames.value.filter(name => !upstreamSet.has(name))
|
||||
return allCustomNames.value
|
||||
})
|
||||
|
||||
// 排序后的自定义名称
|
||||
@@ -369,31 +259,14 @@ const sortedCustomNames = computed(() => {
|
||||
const canAddAsCustom = computed(() => {
|
||||
const search = searchQuery.value.trim()
|
||||
if (!search) return false
|
||||
// 已经选中了就不显示
|
||||
if (selectedNames.value.includes(search)) return false
|
||||
// 已经在自定义列表中就不显示
|
||||
if (allCustomNames.value.includes(search)) return false
|
||||
// 精确匹配上游模型就不显示
|
||||
if (upstreamModelNames.value.includes(search)) return false
|
||||
return true
|
||||
})
|
||||
|
||||
// 过滤后的上游模型
|
||||
const filteredUpstreamModels = computed(() => {
|
||||
if (!searchQuery.value.trim()) return upstreamModelNames.value
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
return upstreamModelNames.value.filter(name => name.toLowerCase().includes(query))
|
||||
})
|
||||
|
||||
// 空状态判断
|
||||
const showEmptyState = computed(() => {
|
||||
return filteredUpstreamModels.value.length === 0 && customNames.value.length === 0
|
||||
})
|
||||
|
||||
// 上游模型是否全选
|
||||
const isAllUpstreamModelsSelected = computed(() => {
|
||||
if (filteredUpstreamModels.value.length === 0) return false
|
||||
return filteredUpstreamModels.value.every(name => selectedNames.value.includes(name))
|
||||
return customNames.value.length === 0
|
||||
})
|
||||
|
||||
// 切换名称选中状态
|
||||
@@ -411,73 +284,17 @@ function addCustomName() {
|
||||
const name = searchQuery.value.trim()
|
||||
if (name && !selectedNames.value.includes(name)) {
|
||||
selectedNames.value.push(name)
|
||||
if (!allKnownNames.value.has(name) && !allCustomNames.value.includes(name)) {
|
||||
if (!allCustomNames.value.includes(name)) {
|
||||
allCustomNames.value.push(name)
|
||||
}
|
||||
searchQuery.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 全选/取消全选上游模型
|
||||
function toggleAllUpstreamModels() {
|
||||
const allNames = filteredUpstreamModels.value
|
||||
if (isAllUpstreamModelsSelected.value) {
|
||||
selectedNames.value = selectedNames.value.filter(name => !allNames.includes(name))
|
||||
} else {
|
||||
allNames.forEach(name => {
|
||||
if (!selectedNames.value.includes(name)) {
|
||||
selectedNames.value.push(name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 切换折叠状态
|
||||
function toggleGroupCollapse(group: string) {
|
||||
if (collapsedGroups.value.has(group)) {
|
||||
collapsedGroups.value.delete(group)
|
||||
} else {
|
||||
collapsedGroups.value.add(group)
|
||||
}
|
||||
collapsedGroups.value = new Set(collapsedGroups.value)
|
||||
}
|
||||
|
||||
// 从提供商获取模型(使用缓存)
|
||||
async function fetchUpstreamModels() {
|
||||
if (!props.providerId) return
|
||||
try {
|
||||
loadingModels.value = true
|
||||
fetchingUpstreamModels.value = true
|
||||
const result = await fetchCachedModels(props.providerId)
|
||||
if (result.models.length > 0) {
|
||||
upstreamModels.value = result.models
|
||||
upstreamModelsLoaded.value = true
|
||||
// 获取上游模型后,将不在上游列表中的已选名称添加到自定义列表
|
||||
const upstreamIds = new Set(result.models.map(m => m.id))
|
||||
const customFromSelected = selectedNames.value.filter(name => !upstreamIds.has(name))
|
||||
// 合并现有自定义名称和从已选中提取的自定义名称
|
||||
const mergedCustom = new Set([...allCustomNames.value, ...customFromSelected])
|
||||
allCustomNames.value = Array.from(mergedCustom).filter(name => !upstreamIds.has(name))
|
||||
}
|
||||
if (result.error) {
|
||||
showError(result.error, '获取上游模型失败')
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
showError(parseApiError(err, '获取上游模型列表失败'), '错误')
|
||||
} finally {
|
||||
loadingModels.value = false
|
||||
fetchingUpstreamModels.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 监听打开状态
|
||||
watch(() => props.open, async (isOpen) => {
|
||||
if (isOpen) {
|
||||
initForm()
|
||||
// 只有在有 key 配置了自动获取时才自动加载上游模型
|
||||
if (props.hasAutoFetchKey) {
|
||||
await fetchUpstreamModels()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -489,7 +306,6 @@ function initForm() {
|
||||
}
|
||||
const existingNames = props.editingGroup.aliases.map(a => a.name)
|
||||
selectedNames.value = [...existingNames]
|
||||
// 将已有映射名称添加到自定义列表,使其在列表中可见(可取消选中来移除映射)
|
||||
allCustomNames.value = [...existingNames]
|
||||
} else {
|
||||
formData.value = {
|
||||
@@ -499,10 +315,6 @@ function initForm() {
|
||||
allCustomNames.value = []
|
||||
}
|
||||
searchQuery.value = ''
|
||||
upstreamModels.value = []
|
||||
upstreamModelsLoaded.value = false
|
||||
// 默认展开所有分组
|
||||
collapsedGroups.value = new Set()
|
||||
}
|
||||
|
||||
// 处理模型选择变更
|
||||
@@ -532,7 +344,6 @@ async function handleSubmit() {
|
||||
const currentAliases = targetModel.provider_model_mappings || []
|
||||
let newAliases: ProviderModelAlias[]
|
||||
|
||||
// 为每个选中的名称创建映射(所有映射使用相同优先级,实现同级负载均衡)
|
||||
const buildAliases = (names: string[]): ProviderModelAlias[] => {
|
||||
return names.map((name) => ({
|
||||
name: name.trim(),
|
||||
|
||||
@@ -159,7 +159,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-xs font-medium text-muted-foreground">TOTP Secret (可选)</label>
|
||||
<label class="text-xs font-medium text-muted-foreground">TOTP Secret (可选, 2FA认证)</label>
|
||||
<input
|
||||
v-model="device.totp_secret"
|
||||
type="text"
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
</Button>
|
||||
</span>
|
||||
<Popover
|
||||
v-if="provider.provider_type !== 'custom'"
|
||||
v-if="provider.pool_advanced"
|
||||
:open="providerProxyPopoverOpen"
|
||||
@update:open="handleProviderProxyPopoverToggle"
|
||||
>
|
||||
@@ -427,9 +427,9 @@
|
||||
>
|
||||
<Shield class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<!-- 代理节点配置(仅非 custom 类型显示) -->
|
||||
<!-- 代理节点配置(仅号池模式显示) -->
|
||||
<Popover
|
||||
v-if="provider.provider_type !== 'custom'"
|
||||
v-if="provider.pool_advanced"
|
||||
:open="proxyPopoverOpenKeyId === key.id"
|
||||
@update:open="(v: boolean) => handleProxyPopoverToggle(key.id, v)"
|
||||
>
|
||||
|
||||
@@ -37,11 +37,14 @@
|
||||
<SelectValue placeholder="请选择" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<!-- 新建模式:允许自定义、Codex、Kiro 和 Antigravity -->
|
||||
<!-- 新建模式:允许自定义及各反代类型 -->
|
||||
<template v-if="!isEditMode">
|
||||
<SelectItem value="custom">
|
||||
自定义
|
||||
</SelectItem>
|
||||
<SelectItem value="claude_code">
|
||||
ClaudeCode
|
||||
</SelectItem>
|
||||
<SelectItem value="codex">
|
||||
Codex
|
||||
</SelectItem>
|
||||
@@ -216,14 +219,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 格式转换配置 -->
|
||||
<!-- 功能开关 -->
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-medium border-b pb-2">
|
||||
格式转换
|
||||
功能开关
|
||||
</h3>
|
||||
|
||||
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
|
||||
<div class="space-y-0.5">
|
||||
<span class="text-sm font-medium">保持优先级</span>
|
||||
<span class="text-sm font-medium">格式转换保持优先级</span>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
跨格式请求时保持原优先级排名,不降级到格式匹配的提供商之后
|
||||
</p>
|
||||
@@ -233,30 +237,17 @@
|
||||
@update:model-value="(v: boolean) => form.keep_priority_on_conversion = v"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 代理配置 -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-medium">
|
||||
代理配置
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch
|
||||
:model-value="form.proxy_enabled"
|
||||
@update:model-value="handleProxyToggle"
|
||||
/>
|
||||
<span class="text-sm text-muted-foreground">启用代理</span>
|
||||
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
|
||||
<div class="space-y-0.5">
|
||||
<span class="text-sm font-medium">号池调度模式</span>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
启用后该提供商的密钥将由号池统一调度
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="form.proxy_enabled"
|
||||
class="space-y-1.5 p-3 border rounded-lg bg-muted/50"
|
||||
>
|
||||
<Label class="text-xs">代理节点 *</Label>
|
||||
<ProxyNodeSelect
|
||||
ref="proxyNodeSelectRef"
|
||||
v-model="form.proxy_node_id"
|
||||
<Switch
|
||||
:model-value="form.pool_mode_enabled"
|
||||
@update:model-value="(v: boolean) => form.pool_mode_enabled = v"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -282,7 +273,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import {
|
||||
Dialog,
|
||||
Button,
|
||||
@@ -301,8 +292,6 @@ import { useFormDialog } from '@/composables/useFormDialog'
|
||||
import { createProvider, updateProvider, type ProviderWithEndpointsSummary } from '@/api/endpoints'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { parseNumberInput } from '@/utils/form'
|
||||
import ProxyNodeSelect from './ProxyNodeSelect.vue'
|
||||
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
@@ -318,16 +307,6 @@ const emit = defineEmits<{
|
||||
|
||||
const { success, error: showError } = useToast()
|
||||
const loading = ref(false)
|
||||
const proxyNodeSelectRef = ref<InstanceType<typeof ProxyNodeSelect> | null>(null)
|
||||
const proxyNodesStore = useProxyNodesStore()
|
||||
|
||||
/** 启用代理时懒加载节点列表 */
|
||||
function handleProxyToggle(v: boolean) {
|
||||
form.value.proxy_enabled = v
|
||||
if (v) {
|
||||
proxyNodesStore.ensureLoaded()
|
||||
}
|
||||
}
|
||||
|
||||
// 内部状态
|
||||
const internalOpen = computed(() => props.modelValue)
|
||||
@@ -363,9 +342,8 @@ const form = ref({
|
||||
// 超时配置(秒)
|
||||
stream_first_byte_timeout: undefined as number | undefined,
|
||||
request_timeout: undefined as number | undefined,
|
||||
// 代理配置
|
||||
proxy_enabled: false,
|
||||
proxy_node_id: '',
|
||||
// 号池模式
|
||||
pool_mode_enabled: false,
|
||||
})
|
||||
|
||||
// 重置表单
|
||||
@@ -390,9 +368,8 @@ function resetForm() {
|
||||
// 超时配置
|
||||
stream_first_byte_timeout: undefined,
|
||||
request_timeout: undefined,
|
||||
// 代理配置
|
||||
proxy_enabled: false,
|
||||
proxy_node_id: '',
|
||||
// 号池模式
|
||||
pool_mode_enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,7 +377,6 @@ function resetForm() {
|
||||
function loadProviderData() {
|
||||
if (!props.provider) return
|
||||
|
||||
const proxy = props.provider.proxy
|
||||
form.value = {
|
||||
name: props.provider.name,
|
||||
provider_type: props.provider.provider_type || 'custom',
|
||||
@@ -423,14 +399,8 @@ function loadProviderData() {
|
||||
// 超时配置
|
||||
stream_first_byte_timeout: props.provider.stream_first_byte_timeout ?? undefined,
|
||||
request_timeout: props.provider.request_timeout ?? undefined,
|
||||
// 代理配置
|
||||
proxy_enabled: proxy?.enabled ?? false,
|
||||
proxy_node_id: proxy?.node_id || '',
|
||||
}
|
||||
|
||||
// 如果有代理配置,确保加载节点列表(直接调用 store,避免 ref 未挂载时静默失败)
|
||||
if (proxy?.enabled) {
|
||||
proxyNodesStore.ensureLoaded()
|
||||
// 号池模式
|
||||
pool_mode_enabled: !!props.provider.pool_advanced,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,6 +414,13 @@ const { isEditMode, handleDialogUpdate, handleCancel } = useFormDialog({
|
||||
resetForm,
|
||||
})
|
||||
|
||||
// 新建模式下切换 provider_type 时自动设置号池模式:非自定义类型默认开启
|
||||
watch(() => form.value.provider_type, (newType) => {
|
||||
if (!isEditMode.value) {
|
||||
form.value.pool_mode_enabled = newType !== 'custom'
|
||||
}
|
||||
})
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
// 月卡类型必须设置周期开始时间
|
||||
@@ -452,20 +429,8 @@ const handleSubmit = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
// 启用代理时的验证
|
||||
if (form.value.proxy_enabled && !form.value.proxy_node_id) {
|
||||
showError('请选择代理节点', '验证失败')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
// 构建代理配置
|
||||
const proxy = form.value.proxy_enabled && form.value.proxy_node_id ? {
|
||||
node_id: form.value.proxy_node_id,
|
||||
enabled: true,
|
||||
} : null
|
||||
|
||||
const payload = {
|
||||
name: form.value.name,
|
||||
provider_type: form.value.provider_type,
|
||||
@@ -484,7 +449,9 @@ const handleSubmit = async () => {
|
||||
// 超时配置(null 表示清除,使用全局配置)
|
||||
stream_first_byte_timeout: form.value.stream_first_byte_timeout ?? null,
|
||||
request_timeout: form.value.request_timeout ?? null,
|
||||
proxy,
|
||||
pool_advanced: form.value.pool_mode_enabled
|
||||
? (props.provider?.pool_advanced ?? {})
|
||||
: null,
|
||||
}
|
||||
|
||||
if (isEditMode.value && props.provider) {
|
||||
|
||||
@@ -13,3 +13,4 @@ export { default as OAuthKeyEditDialog } from './OAuthKeyEditDialog.vue'
|
||||
|
||||
export { default as ModelsTab } from './provider-tabs/ModelsTab.vue'
|
||||
export { default as ProviderAuthDialog } from './ProviderAuthDialog.vue'
|
||||
export { default as PoolStatusCard } from './PoolStatusCard.vue'
|
||||
|
||||
@@ -325,7 +325,6 @@
|
||||
:models="models"
|
||||
:editing-group="editingGroup"
|
||||
:preselected-model-id="preselectedModelId"
|
||||
:has-auto-fetch-key="hasAutoFetchKey"
|
||||
@saved="onDialogSaved"
|
||||
/>
|
||||
|
||||
@@ -424,11 +423,6 @@ const providerKeysState = computed(() => props.providerKeys ?? [])
|
||||
// 展开状态
|
||||
const expandedItems = ref<Set<string>>(new Set())
|
||||
|
||||
// 是否有 key 配置了自动获取上游模型
|
||||
const hasAutoFetchKey = computed(() => {
|
||||
return providerKeysState.value.some(k => k.auto_fetch_models)
|
||||
})
|
||||
|
||||
// 生成作用域唯一键
|
||||
function getApiFormatsKey(formats: string[] | undefined): string {
|
||||
if (!formats || formats.length === 0) return ''
|
||||
|
||||
@@ -252,6 +252,62 @@
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="currentAttempt.extra_data?.pool_selection"
|
||||
class="info-item"
|
||||
>
|
||||
<span class="info-label">号池调度</span>
|
||||
<span class="info-value info-value-stacked">
|
||||
<span class="pool-reason">
|
||||
<span
|
||||
class="pool-reason-tag"
|
||||
:class="'pool-' + currentAttempt.extra_data.pool_selection.reason"
|
||||
>
|
||||
{{ poolSelectionLabel(currentAttempt.extra_data.pool_selection.reason) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="currentAttempt.extra_data.pool_selection.cost_soft_threshold"
|
||||
class="pool-cost-warn"
|
||||
>接近限额</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="currentAttempt.extra_data.pool_selection.cost_window_usage"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{{ formatNumber(currentAttempt.extra_data.pool_selection.cost_window_usage) }}
|
||||
<template v-if="currentAttempt.extra_data.pool_selection.cost_limit">
|
||||
/ {{ formatNumber(currentAttempt.extra_data.pool_selection.cost_limit) }}
|
||||
</template>
|
||||
tokens
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="currentAttempt.extra_data?.pool_skip"
|
||||
class="info-item"
|
||||
>
|
||||
<span class="info-label">号池跳过</span>
|
||||
<span class="info-value info-value-stacked">
|
||||
<span class="pool-skip-type">
|
||||
{{ poolSkipLabel(currentAttempt.extra_data.pool_skip.type) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="currentAttempt.extra_data.pool_skip.cooldown_reason"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{{ currentAttempt.extra_data.pool_skip.cooldown_reason }}
|
||||
<template v-if="currentAttempt.extra_data.pool_skip.cooldown_ttl != null">
|
||||
({{ currentAttempt.extra_data.pool_skip.cooldown_ttl }}s)
|
||||
</template>
|
||||
</span>
|
||||
<span
|
||||
v-if="currentAttempt.extra_data.pool_skip.cost_window_usage"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{{ formatNumber(currentAttempt.extra_data.pool_skip.cost_window_usage) }} tokens
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="mergedCapabilities.length > 0"
|
||||
class="info-item"
|
||||
@@ -764,6 +820,25 @@ const formatCapabilityLabel = (cap: string): string => {
|
||||
return labels[cap] || cap
|
||||
}
|
||||
|
||||
const poolSelectionLabel = (reason: string): string => {
|
||||
const labels: Record<string, string> = {
|
||||
sticky: '粘性会话',
|
||||
lru: 'LRU',
|
||||
random: '随机',
|
||||
tiebreak: '随机 (平分)',
|
||||
}
|
||||
return labels[reason] || reason
|
||||
}
|
||||
|
||||
const poolSkipLabel = (type: string): string => {
|
||||
const labels: Record<string, string> = {
|
||||
cooldown: '冷却中',
|
||||
cost_exhausted: '额度耗尽',
|
||||
upstream: '上游跳过',
|
||||
}
|
||||
return labels[type] || type
|
||||
}
|
||||
|
||||
// 检查组是否被悬浮
|
||||
const isGroupHovered = (groupIndex: number) => {
|
||||
return hoveredGroupIndex.value === groupIndex
|
||||
@@ -1485,6 +1560,52 @@ const getStatusColorClass = (status: string) => {
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
/* 号池调度 */
|
||||
.pool-reason {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.pool-reason-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.15rem 0.5rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
border: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.pool-reason-tag.pool-sticky {
|
||||
color: hsl(var(--chart-4));
|
||||
border-color: hsl(var(--chart-4) / 0.3);
|
||||
background: hsl(var(--chart-4) / 0.08);
|
||||
}
|
||||
|
||||
.pool-reason-tag.pool-lru {
|
||||
color: hsl(var(--chart-2));
|
||||
border-color: hsl(var(--chart-2) / 0.3);
|
||||
background: hsl(var(--chart-2) / 0.08);
|
||||
}
|
||||
|
||||
.pool-reason-tag.pool-random,
|
||||
.pool-reason-tag.pool-tiebreak {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.pool-cost-warn {
|
||||
font-size: 0.65rem;
|
||||
color: hsl(var(--chart-5));
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pool-skip-type {
|
||||
font-weight: 500;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
/* 能力标签 */
|
||||
.capability-tags {
|
||||
display: flex;
|
||||
|
||||
@@ -370,6 +370,51 @@
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 号池调度摘要 -->
|
||||
<Card v-if="poolSummary">
|
||||
<div class="p-3 sm:p-4">
|
||||
<div class="text-xs text-muted-foreground mb-2 font-medium">
|
||||
号池调度
|
||||
</div>
|
||||
<div class="flex items-center gap-3 flex-wrap text-sm">
|
||||
<span class="font-mono">
|
||||
{{ poolSummary.total_keys }} 候选
|
||||
</span>
|
||||
<span class="text-muted-foreground">|</span>
|
||||
<span class="font-mono">
|
||||
{{ poolSummary.attempted }} 尝试
|
||||
</span>
|
||||
<template v-if="poolSummary.skipped_cooldown > 0">
|
||||
<span class="text-muted-foreground">|</span>
|
||||
<span class="font-mono text-amber-600 dark:text-amber-400">
|
||||
{{ poolSummary.skipped_cooldown }} 冷却跳过
|
||||
</span>
|
||||
</template>
|
||||
<template v-if="poolSummary.skipped_cost > 0">
|
||||
<span class="text-muted-foreground">|</span>
|
||||
<span class="font-mono text-orange-600 dark:text-orange-400">
|
||||
{{ poolSummary.skipped_cost }} 成本跳过
|
||||
</span>
|
||||
</template>
|
||||
<template v-if="poolSummary.sticky_session">
|
||||
<span class="text-muted-foreground">|</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="text-[10px] px-1.5 py-0 h-4"
|
||||
>
|
||||
粘性会话
|
||||
</Badge>
|
||||
</template>
|
||||
<template v-if="poolSummary.success_reason">
|
||||
<span class="text-muted-foreground">|</span>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ poolSummary.success_reason }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 请求链路追踪卡片 -->
|
||||
<div v-if="detail.request_id || detail.id">
|
||||
<HorizontalRequestTimeline
|
||||
@@ -733,6 +778,13 @@ const isDark = computed(() => {
|
||||
return document.documentElement.classList.contains('dark')
|
||||
})
|
||||
|
||||
// 号池调度摘要
|
||||
const poolSummary = computed(() => {
|
||||
const ps = detail.value?.metadata?.pool_summary as Record<string, unknown> | undefined
|
||||
if (!ps || !ps.enabled) return null
|
||||
return ps
|
||||
})
|
||||
|
||||
// 检测是否有提供商请求头
|
||||
const hasProviderHeaders = computed(() => {
|
||||
return !!(detail.value?.provider_request_headers &&
|
||||
|
||||
@@ -357,6 +357,7 @@ import {
|
||||
Gauge,
|
||||
Layers,
|
||||
FolderTree,
|
||||
Database,
|
||||
Box,
|
||||
LogOut,
|
||||
SunMoon,
|
||||
@@ -554,6 +555,7 @@ const navigation = computed(() => {
|
||||
items: [
|
||||
{ name: '用户管理', href: '/admin/users', icon: Users },
|
||||
{ name: '提供商', href: '/admin/providers', icon: FolderTree },
|
||||
{ name: '号池管理', href: '/admin/pool', icon: Database },
|
||||
{ name: '模型管理', href: '/admin/models', icon: Layers },
|
||||
{ name: '独立密钥', href: '/admin/keys', icon: Key },
|
||||
{ name: '异步任务', href: '/admin/async-tasks', icon: Zap },
|
||||
|
||||
@@ -160,6 +160,11 @@ const routes: RouteRecordRaw[] = [
|
||||
name: 'ProviderManagement',
|
||||
component: () => importWithRetry(() => import('@/views/admin/ProviderManagement.vue'))
|
||||
},
|
||||
{
|
||||
path: 'pool',
|
||||
name: 'PoolManagement',
|
||||
component: () => importWithRetry(() => import('@/views/admin/PoolManagement.vue'))
|
||||
},
|
||||
{
|
||||
path: 'models',
|
||||
name: 'ModelManagement',
|
||||
|
||||
Reference in New Issue
Block a user