mirror of
https://github.com/fawney19/Aether.git
synced 2026-01-13 21:17:21 +08:00
feat(ui): 增加模型连接性测试功能并优化对话框 ESC 键处理
- 模型测试: 新增测试按钮,支持多 API 格式选择 - ESC 键: 重构为全局栈管理,支持嵌套对话框 - 修复: check_endpoint 使用 FORMAT_ID 替代 name - 优化: Redis 连接池添加健康检查 Co-authored-by: htmambo <htmambo@users.noreply.github.com>
This commit is contained in:
@@ -1,10 +1,42 @@
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
/**
|
||||
* ESC 键监听 Composable(简化版本,直接使用独立监听器)
|
||||
* 全局对话框栈,用于管理嵌套对话框的 ESC 键处理顺序
|
||||
* 栈顶的对话框优先处理 ESC 键
|
||||
*/
|
||||
const dialogStack = ref<Array<() => void | boolean>>([])
|
||||
|
||||
/**
|
||||
* 全局 ESC 键监听器(只有一个)
|
||||
*/
|
||||
let globalListenerAttached = false
|
||||
|
||||
function globalEscapeHandler(event: KeyboardEvent) {
|
||||
// 只处理 ESC 键
|
||||
if (event.key !== 'Escape') return
|
||||
|
||||
// 从栈顶向栈底查找能处理 ESC 键的对话框
|
||||
// 倒序遍历,先检查最后加入的(栈顶)
|
||||
for (let i = dialogStack.value.length - 1; i >= 0; i--) {
|
||||
const handler = dialogStack.value[i]
|
||||
const handled = handler()
|
||||
|
||||
if (handled === true) {
|
||||
// 该对话框已处理事件,阻止传播
|
||||
event.stopPropagation()
|
||||
event.stopImmediatePropagation()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ESC 键监听 Composable(栈管理版本)
|
||||
* 用于按 ESC 键关闭弹窗或其他可关闭的组件
|
||||
*
|
||||
* @param callback - 按 ESC 键时执行的回调函数,返回 true 表示已处理事件,阻止其他监听器执行
|
||||
* 支持嵌套对话框场景:只有最上层的对话框(最后打开的)会响应 ESC 键
|
||||
*
|
||||
* @param callback - 按 ESC 键时执行的回调函数,返回 true 表示已处理事件
|
||||
* @param options - 配置选项
|
||||
*/
|
||||
export function useEscapeKey(
|
||||
@@ -18,13 +50,12 @@ export function useEscapeKey(
|
||||
) {
|
||||
const { disableOnInput = true, once = false } = options
|
||||
const isActive = ref(true)
|
||||
const isInStack = ref(false)
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
// 只处理 ESC 键
|
||||
if (event.key !== 'Escape') return
|
||||
|
||||
// 包装原始回调,添加输入框检查
|
||||
function wrappedCallback(): void | boolean {
|
||||
// 检查组件是否还活跃
|
||||
if (!isActive.value) return
|
||||
if (!isActive.value) return false
|
||||
|
||||
// 如果配置了在输入框获得焦点时禁用,则检查当前焦点元素
|
||||
if (disableOnInput) {
|
||||
@@ -39,13 +70,15 @@ export function useEscapeKey(
|
||||
)
|
||||
|
||||
// 如果焦点在输入框中,不处理 ESC 键
|
||||
if (isInputElement) return
|
||||
if (isInputElement) return false
|
||||
}
|
||||
|
||||
// 执行回调,如果返回 true 则阻止其他监听器
|
||||
// 执行原始回调
|
||||
const handled = callback()
|
||||
if (handled === true) {
|
||||
event.stopImmediatePropagation()
|
||||
|
||||
// 如果只监听一次,则从栈中移除
|
||||
if (once && handled === true) {
|
||||
removeFromStack()
|
||||
}
|
||||
|
||||
// 移除当前元素的焦点,避免残留样式
|
||||
@@ -53,31 +86,51 @@ export function useEscapeKey(
|
||||
document.activeElement.blur()
|
||||
}
|
||||
|
||||
// 如果只监听一次,则移除监听器
|
||||
if (once) {
|
||||
removeEventListener()
|
||||
return handled
|
||||
}
|
||||
|
||||
function addToStack() {
|
||||
if (isInStack.value) return
|
||||
|
||||
// 将当前处理器加入栈顶
|
||||
dialogStack.value.push(wrappedCallback)
|
||||
isInStack.value = true
|
||||
|
||||
// 确保全局监听器已添加
|
||||
if (!globalListenerAttached) {
|
||||
document.addEventListener('keydown', globalEscapeHandler, true) // 使用捕获阶段
|
||||
globalListenerAttached = true
|
||||
}
|
||||
}
|
||||
|
||||
function addEventListener() {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
function removeFromStack() {
|
||||
if (!isInStack.value) return
|
||||
|
||||
function removeEventListener() {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
// 从栈中移除当前处理器
|
||||
const index = dialogStack.value.indexOf(wrappedCallback)
|
||||
if (index > -1) {
|
||||
dialogStack.value.splice(index, 1)
|
||||
}
|
||||
isInStack.value = false
|
||||
|
||||
// 如果栈为空,移除全局监听器
|
||||
if (dialogStack.value.length === 0 && globalListenerAttached) {
|
||||
document.removeEventListener('keydown', globalEscapeHandler, true)
|
||||
globalListenerAttached = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
addEventListener()
|
||||
addToStack()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
isActive.value = false
|
||||
removeEventListener()
|
||||
removeFromStack()
|
||||
})
|
||||
|
||||
return {
|
||||
addEventListener,
|
||||
removeEventListener
|
||||
addToStack,
|
||||
removeFromStack
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,6 +374,7 @@
|
||||
v-if="provider"
|
||||
:key="`models-${provider.id}`"
|
||||
:provider="provider"
|
||||
:endpoints="endpoints"
|
||||
@edit-model="handleEditModel"
|
||||
@delete-model="handleDeleteModel"
|
||||
@batch-assign="handleBatchAssign"
|
||||
|
||||
@@ -109,14 +109,42 @@
|
||||
${{ formatPrice(model.effective_price_per_request ?? model.price_per_request) }}/次
|
||||
</span>
|
||||
</template>
|
||||
<!-- 无计<EFBFBD><EFBFBD>配置 -->
|
||||
<!-- 无计费配置 -->
|
||||
<template v-if="!hasTokenPricing(model) && !hasRequestPricing(model)">
|
||||
<span class="text-muted-foreground">—</span>
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
<td class="align-top px-4 py-3">
|
||||
<div class="flex justify-center gap-1.5">
|
||||
<div class="flex justify-center gap-1">
|
||||
<!-- 测试按钮(支持多格式选择) -->
|
||||
<div class="relative">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="测试模型"
|
||||
:disabled="testingModelId === model.id"
|
||||
@click="handleTestClick(model)"
|
||||
>
|
||||
<Loader2 v-if="testingModelId === model.id" class="w-3.5 h-3.5 animate-spin" />
|
||||
<Play v-else class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<!-- 格式选择下拉菜单 -->
|
||||
<div
|
||||
v-if="formatMenuModelId === model.id && availableApiFormats.length > 1"
|
||||
class="absolute top-full left-0 mt-1 z-10 bg-popover border rounded-md shadow-md py-1 min-w-[120px]"
|
||||
>
|
||||
<button
|
||||
v-for="fmt in availableApiFormats"
|
||||
:key="fmt"
|
||||
class="w-full px-3 py-1.5 text-left text-sm hover:bg-muted transition-colors"
|
||||
@click="testModelConnection(model, fmt)"
|
||||
>
|
||||
{{ fmt }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -169,17 +197,26 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Box, Edit, Trash2, Layers, Power, Copy } from 'lucide-vue-next'
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { Box, Edit, Trash2, Layers, Power, Copy, Loader2, Play } from 'lucide-vue-next'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { getProviderModels, type Model } from '@/api/endpoints'
|
||||
import { getProviderModels, type Model, testModel } from '@/api/endpoints'
|
||||
import { updateModel } from '@/api/endpoints/models'
|
||||
import { parseTestModelError } from '@/utils/errorParser'
|
||||
|
||||
interface Endpoint {
|
||||
id: string
|
||||
api_format: string
|
||||
is_active: boolean
|
||||
active_keys?: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
provider: any
|
||||
endpoints?: Endpoint[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -195,6 +232,16 @@ const { copyToClipboard } = useClipboard()
|
||||
const loading = ref(false)
|
||||
const models = ref<Model[]>([])
|
||||
const togglingModelId = ref<string | null>(null)
|
||||
const testingModelId = ref<string | null>(null)
|
||||
const formatMenuModelId = ref<string | null>(null)
|
||||
|
||||
// 获取可用的 API 格式(有活跃端点且有活跃 Key)
|
||||
const availableApiFormats = computed(() => {
|
||||
if (!props.endpoints) return []
|
||||
return props.endpoints
|
||||
.filter(ep => ep.is_active && (ep.active_keys ?? 0) > 0)
|
||||
.map(ep => ep.api_format)
|
||||
})
|
||||
|
||||
// 按名称排序的模型列表
|
||||
const sortedModels = computed(() => {
|
||||
@@ -295,7 +342,7 @@ function getStatusTitle(model: Model): string {
|
||||
return '活跃但不可用'
|
||||
}
|
||||
|
||||
// <EFBFBD><EFBFBD>辑模型
|
||||
// 编辑模型
|
||||
function editModel(model: Model) {
|
||||
emit('editModel', model)
|
||||
}
|
||||
@@ -327,7 +374,69 @@ async function toggleModelActive(model: Model) {
|
||||
}
|
||||
}
|
||||
|
||||
// 测试模型连接性
|
||||
async function testModelConnection(model: Model, apiFormat?: string) {
|
||||
if (testingModelId.value) return
|
||||
|
||||
testingModelId.value = model.id
|
||||
formatMenuModelId.value = null
|
||||
try {
|
||||
const result = await testModel({
|
||||
provider_id: props.provider.id,
|
||||
model_name: model.provider_model_name,
|
||||
message: "hello",
|
||||
api_format: apiFormat
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
// 根据响应内容显示不同的成功消息
|
||||
if (result.data?.response?.choices?.[0]?.message?.content) {
|
||||
const content = result.data.response.choices[0].message.content
|
||||
showSuccess(`测试成功,响应: ${content.substring(0, 100)}${content.length > 100 ? '...' : ''}`)
|
||||
} else if (result.data?.content_preview) {
|
||||
showSuccess(`流式测试成功,预览: ${result.data.content_preview}`)
|
||||
} else {
|
||||
showSuccess(`模型 "${model.provider_model_name}" 测试成功`)
|
||||
}
|
||||
} else {
|
||||
showError(`模型测试失败: ${parseTestModelError(result)}`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMsg = err.response?.data?.detail || err.message || '测试请求失败'
|
||||
showError(`模型测试失败: ${errorMsg}`)
|
||||
} finally {
|
||||
testingModelId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 处理测试按钮点击
|
||||
function handleTestClick(model: Model) {
|
||||
const formats = availableApiFormats.value
|
||||
if (formats.length === 0) {
|
||||
// 没有可用格式信息,使用默认行为
|
||||
testModelConnection(model)
|
||||
} else if (formats.length === 1) {
|
||||
// 只有一种格式,直接测试
|
||||
testModelConnection(model, formats[0])
|
||||
} else {
|
||||
// 多种格式,显示选择菜单
|
||||
formatMenuModelId.value = formatMenuModelId.value === model.id ? null : model.id
|
||||
}
|
||||
}
|
||||
|
||||
// 点击外部关闭格式选择菜单
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (formatMenuModelId.value && !(event.target as Element).closest('.relative')) {
|
||||
formatMenuModelId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadModels()
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -641,7 +641,7 @@ class ChatAdapterBase(ApiAdapter):
|
||||
url=url,
|
||||
headers=headers,
|
||||
json_body=body,
|
||||
api_format=cls.name,
|
||||
api_format=cls.FORMAT_ID,
|
||||
# 用量计算参数(现在强制记录)
|
||||
db=db,
|
||||
user=user,
|
||||
|
||||
@@ -172,6 +172,7 @@ class RedisClientManager:
|
||||
max_connections=redis_max_conn,
|
||||
decode_responses=True,
|
||||
socket_connect_timeout=5.0,
|
||||
health_check_interval=30, # 每 30 秒检查连接健康状态
|
||||
)
|
||||
safe_url = f"sentinel://{sentinel_service}"
|
||||
else:
|
||||
@@ -182,6 +183,7 @@ class RedisClientManager:
|
||||
socket_timeout=5.0,
|
||||
socket_connect_timeout=5.0,
|
||||
max_connections=redis_max_conn,
|
||||
health_check_interval=30, # 每 30 秒检查连接健康状态
|
||||
)
|
||||
safe_url = redis_url.split("@")[-1] if "@" in redis_url else redis_url
|
||||
|
||||
|
||||
Reference in New Issue
Block a user