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'
|
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 键关闭弹窗或其他可关闭的组件
|
* 用于按 ESC 键关闭弹窗或其他可关闭的组件
|
||||||
*
|
*
|
||||||
* @param callback - 按 ESC 键时执行的回调函数,返回 true 表示已处理事件,阻止其他监听器执行
|
* 支持嵌套对话框场景:只有最上层的对话框(最后打开的)会响应 ESC 键
|
||||||
|
*
|
||||||
|
* @param callback - 按 ESC 键时执行的回调函数,返回 true 表示已处理事件
|
||||||
* @param options - 配置选项
|
* @param options - 配置选项
|
||||||
*/
|
*/
|
||||||
export function useEscapeKey(
|
export function useEscapeKey(
|
||||||
@@ -18,13 +50,12 @@ export function useEscapeKey(
|
|||||||
) {
|
) {
|
||||||
const { disableOnInput = true, once = false } = options
|
const { disableOnInput = true, once = false } = options
|
||||||
const isActive = ref(true)
|
const isActive = ref(true)
|
||||||
|
const isInStack = ref(false)
|
||||||
|
|
||||||
function handleKeyDown(event: KeyboardEvent) {
|
// 包装原始回调,添加输入框检查
|
||||||
// 只处理 ESC 键
|
function wrappedCallback(): void | boolean {
|
||||||
if (event.key !== 'Escape') return
|
|
||||||
|
|
||||||
// 检查组件是否还活跃
|
// 检查组件是否还活跃
|
||||||
if (!isActive.value) return
|
if (!isActive.value) return false
|
||||||
|
|
||||||
// 如果配置了在输入框获得焦点时禁用,则检查当前焦点元素
|
// 如果配置了在输入框获得焦点时禁用,则检查当前焦点元素
|
||||||
if (disableOnInput) {
|
if (disableOnInput) {
|
||||||
@@ -39,13 +70,15 @@ export function useEscapeKey(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// 如果焦点在输入框中,不处理 ESC 键
|
// 如果焦点在输入框中,不处理 ESC 键
|
||||||
if (isInputElement) return
|
if (isInputElement) return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 执行回调,如果返回 true 则阻止其他监听器
|
// 执行原始回调
|
||||||
const handled = callback()
|
const handled = callback()
|
||||||
if (handled === true) {
|
|
||||||
event.stopImmediatePropagation()
|
// 如果只监听一次,则从栈中移除
|
||||||
|
if (once && handled === true) {
|
||||||
|
removeFromStack()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 移除当前元素的焦点,避免残留样式
|
// 移除当前元素的焦点,避免残留样式
|
||||||
@@ -53,31 +86,51 @@ export function useEscapeKey(
|
|||||||
document.activeElement.blur()
|
document.activeElement.blur()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果只监听一次,则移除监听器
|
return handled
|
||||||
if (once) {
|
}
|
||||||
removeEventListener()
|
|
||||||
|
function addToStack() {
|
||||||
|
if (isInStack.value) return
|
||||||
|
|
||||||
|
// 将当前处理器加入栈顶
|
||||||
|
dialogStack.value.push(wrappedCallback)
|
||||||
|
isInStack.value = true
|
||||||
|
|
||||||
|
// 确保全局监听器已添加
|
||||||
|
if (!globalListenerAttached) {
|
||||||
|
document.addEventListener('keydown', globalEscapeHandler, true) // 使用捕获阶段
|
||||||
|
globalListenerAttached = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addEventListener() {
|
function removeFromStack() {
|
||||||
document.addEventListener('keydown', handleKeyDown)
|
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(() => {
|
onMounted(() => {
|
||||||
addEventListener()
|
addToStack()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
isActive.value = false
|
isActive.value = false
|
||||||
removeEventListener()
|
removeFromStack()
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
addEventListener,
|
addToStack,
|
||||||
removeEventListener
|
removeFromStack
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -374,6 +374,7 @@
|
|||||||
v-if="provider"
|
v-if="provider"
|
||||||
:key="`models-${provider.id}`"
|
:key="`models-${provider.id}`"
|
||||||
:provider="provider"
|
:provider="provider"
|
||||||
|
:endpoints="endpoints"
|
||||||
@edit-model="handleEditModel"
|
@edit-model="handleEditModel"
|
||||||
@delete-model="handleDeleteModel"
|
@delete-model="handleDeleteModel"
|
||||||
@batch-assign="handleBatchAssign"
|
@batch-assign="handleBatchAssign"
|
||||||
|
|||||||
@@ -109,14 +109,42 @@
|
|||||||
${{ formatPrice(model.effective_price_per_request ?? model.price_per_request) }}/次
|
${{ formatPrice(model.effective_price_per_request ?? model.price_per_request) }}/次
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<!-- 无计<EFBFBD><EFBFBD>配置 -->
|
<!-- 无计费配置 -->
|
||||||
<template v-if="!hasTokenPricing(model) && !hasRequestPricing(model)">
|
<template v-if="!hasTokenPricing(model) && !hasRequestPricing(model)">
|
||||||
<span class="text-muted-foreground">—</span>
|
<span class="text-muted-foreground">—</span>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="align-top px-4 py-3">
|
<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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -169,17 +197,26 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { Box, Edit, Trash2, Layers, Power, Copy } from 'lucide-vue-next'
|
import { Box, Edit, Trash2, Layers, Power, Copy, Loader2, Play } from 'lucide-vue-next'
|
||||||
import Card from '@/components/ui/card.vue'
|
import Card from '@/components/ui/card.vue'
|
||||||
import Button from '@/components/ui/button.vue'
|
import Button from '@/components/ui/button.vue'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { useClipboard } from '@/composables/useClipboard'
|
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 { 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<{
|
const props = defineProps<{
|
||||||
provider: any
|
provider: any
|
||||||
|
endpoints?: Endpoint[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -195,6 +232,16 @@ const { copyToClipboard } = useClipboard()
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const models = ref<Model[]>([])
|
const models = ref<Model[]>([])
|
||||||
const togglingModelId = ref<string | null>(null)
|
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(() => {
|
const sortedModels = computed(() => {
|
||||||
@@ -295,7 +342,7 @@ function getStatusTitle(model: Model): string {
|
|||||||
return '活跃但不可用'
|
return '活跃但不可用'
|
||||||
}
|
}
|
||||||
|
|
||||||
// <EFBFBD><EFBFBD>辑模型
|
// 编辑模型
|
||||||
function editModel(model: Model) {
|
function editModel(model: Model) {
|
||||||
emit('editModel', 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(() => {
|
onMounted(() => {
|
||||||
loadModels()
|
loadModels()
|
||||||
|
document.addEventListener('click', handleClickOutside)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('click', handleClickOutside)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -641,7 +641,7 @@ class ChatAdapterBase(ApiAdapter):
|
|||||||
url=url,
|
url=url,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
json_body=body,
|
json_body=body,
|
||||||
api_format=cls.name,
|
api_format=cls.FORMAT_ID,
|
||||||
# 用量计算参数(现在强制记录)
|
# 用量计算参数(现在强制记录)
|
||||||
db=db,
|
db=db,
|
||||||
user=user,
|
user=user,
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ class RedisClientManager:
|
|||||||
max_connections=redis_max_conn,
|
max_connections=redis_max_conn,
|
||||||
decode_responses=True,
|
decode_responses=True,
|
||||||
socket_connect_timeout=5.0,
|
socket_connect_timeout=5.0,
|
||||||
|
health_check_interval=30, # 每 30 秒检查连接健康状态
|
||||||
)
|
)
|
||||||
safe_url = f"sentinel://{sentinel_service}"
|
safe_url = f"sentinel://{sentinel_service}"
|
||||||
else:
|
else:
|
||||||
@@ -182,6 +183,7 @@ class RedisClientManager:
|
|||||||
socket_timeout=5.0,
|
socket_timeout=5.0,
|
||||||
socket_connect_timeout=5.0,
|
socket_connect_timeout=5.0,
|
||||||
max_connections=redis_max_conn,
|
max_connections=redis_max_conn,
|
||||||
|
health_check_interval=30, # 每 30 秒检查连接健康状态
|
||||||
)
|
)
|
||||||
safe_url = redis_url.split("@")[-1] if "@" in redis_url else redis_url
|
safe_url = redis_url.split("@")[-1] if "@" in redis_url else redis_url
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user