feat(models): 添加嵌入和聊天模型状态检查接口 #282

- 在知识路由中新增获取指定嵌入模型和聊天模型状态的接口
- 实现获取所有嵌入模型和聊天模型状态的功能
- 更新前端API以支持模型状态检查,增强用户体验
- 优化日志记录格式,提升调试信息的可读性
This commit is contained in:
Wenjie Zhang 2025-09-22 22:02:57 +08:00
parent a337cba3f7
commit f1fe10f22c
10 changed files with 544 additions and 35 deletions

View File

@ -11,13 +11,15 @@ Yuxi-Know/
├── server/ # 服务端代码(部分)
├── src/ # 主要源代码目录
│ ├── agents/ # 智能体应用
│ ├── config/ # 配置文件
│ ├── config/ # 配置文件
│ ├── knowledge/ # 知识库相关
│ ├── models/ # 数据模型
│ ├── plugins/ # 插件存放OCR
│ ├── static/ # 静态资源(配置文件)
│ └── utils/ # 工具函数
├── web/ # 前端代码
├── web/src/ # 前端代码
│ ├── apis/ # 前端的接口定义
│ └── views/ # 主要 vue 文件
└── docker-compose.yml # Docker Compose 配置
```
@ -37,7 +39,20 @@ Yuxi-Know/
核心原则: 由于 api-dev 和 web-dev 服务均配置了热重载 (hot-reloading),本地修改代码后无需重启容器,服务会自动更新。应该先检查项目是否已经在后台启动(`docker ps`),具体的可以阅读 [docker-compose.yml](docker-compose.yml).
## 风格说明
前端开发规范:
- UI风格要简洁同时要保持一致性颜色要尽量参考 [base.css](web/src/assets/css/base.css) 中的颜色。不要悬停位移,不要过度使用阴影以及渐变色。
- Python 代码要符合 Python 的规范,尽量使用较新的语法,避免使用旧版本的语法(版本兼容到 3.12+),使用 uvx ruff check 检查 lint。
- API 接口规范:所有的 API 接口都应该定义在 web/src/apis 下面,并继承自 apiGet/apiPost/apiRequest
- Icon 应该从 @ant-design/icons-vue 或者 lucide-vue-next
- Vue 中的样式使用 less并尽量使用[base.css](web/src/assets/css/base.css) 中的颜色。
- UI风格要简洁同时要保持一致性颜色要尽量参考 不要悬停位移,不要过度使用阴影以及渐变色。
后端开发规范:
- 项目使用 uv 来管理依赖
- Python 代码要符合 Python 的规范,尽量使用较新的语法,避免使用旧版本的语法(版本兼容到 3.12+),使用 make lint 检查 lint。使用 make format 来格式化代码。
其他:
- 如果需要新建说明文档,则保存在 docs/vibe 文件夹下面
- 测试脚本可以放在 test 文件夹下面,可以从 docker 中启动测试

View File

@ -10,6 +10,7 @@ from server.models.user_model import User
from server.utils.auth_middleware import get_admin_user
from src import config, knowledge_base
from src.knowledge.indexing import process_file_to_markdown
from src.models.embed import test_embedding_model_status, test_all_embedding_models_status
from src.utils import hashstr, logger
knowledge = APIRouter(prefix="/knowledge", tags=["knowledge"])
@ -554,3 +555,35 @@ async def get_knowledge_base_statistics(current_user: User = Depends(get_admin_u
except Exception as e:
logger.error(f"获取知识库统计失败 {e}, {traceback.format_exc()}")
return {"message": f"获取知识库统计失败 {e}", "stats": {}}
# =============================================================================
# === Embedding模型状态检查分组 ===
# =============================================================================
@knowledge.get("/embedding-models/{model_id}/status")
async def get_embedding_model_status(model_id: str, current_user: User = Depends(get_admin_user)):
"""获取指定embedding模型的状态"""
logger.debug(f"Checking embedding model status: {model_id}")
try:
status = await test_embedding_model_status(model_id)
return {"status": status, "message": "success"}
except Exception as e:
logger.error(f"获取embedding模型状态失败 {model_id}: {e}, {traceback.format_exc()}")
return {
"message": f"获取embedding模型状态失败: {e}",
"status": {"model_id": model_id, "status": "error", "message": str(e)},
}
@knowledge.get("/embedding-models/status")
async def get_all_embedding_models_status(current_user: User = Depends(get_admin_user)):
"""获取所有embedding模型的状态"""
logger.debug("Checking all embedding models status")
try:
status = await test_all_embedding_models_status()
return {"status": status, "message": "success"}
except Exception as e:
logger.error(f"获取所有embedding模型状态失败: {e}, {traceback.format_exc()}")
return {"message": f"获取所有embedding模型状态失败: {e}", "status": {"models": {}, "total": 0, "available": 0}}

View File

@ -9,6 +9,7 @@ from fastapi import APIRouter, Body, Depends, HTTPException
from server.models.user_model import User
from server.utils.auth_middleware import get_admin_user, get_superadmin_user
from src import config, graph_base
from src.models.chat import test_chat_model_status, test_all_chat_models_status
from src.utils.logging_config import logger
system = APIRouter(prefix="/system", tags=["system"])
@ -244,3 +245,35 @@ async def check_ocr_services_health(current_user: User = Depends(get_admin_user)
overall_status = "healthy" if any(svc["status"] == "healthy" for svc in health_status.values()) else "unhealthy"
return {"overall_status": overall_status, "services": health_status, "message": "OCR服务健康检查完成"}
# =============================================================================
# === 聊天模型状态检查分组 ===
# =============================================================================
@system.get("/chat-models/status")
async def get_chat_model_status(provider: str, model_name: str, current_user: User = Depends(get_admin_user)):
"""获取指定聊天模型的状态"""
logger.debug(f"Checking chat model status: {provider}/{model_name}")
try:
status = await test_chat_model_status(provider, model_name)
return {"status": status, "message": "success"}
except Exception as e:
logger.error(f"获取聊天模型状态失败 {provider}/{model_name}: {e}")
return {
"message": f"获取聊天模型状态失败: {e}",
"status": {"provider": provider, "model_name": model_name, "status": "error", "message": str(e)},
}
@system.get("/chat-models/all/status")
async def get_all_chat_models_status(current_user: User = Depends(get_admin_user)):
"""获取所有聊天模型的状态"""
logger.debug("Checking all chat models status")
try:
status = await test_all_chat_models_status()
return {"status": status, "message": "success"}
except Exception as e:
logger.error(f"获取所有聊天模型状态失败: {e}")
return {"message": f"获取所有聊天模型状态失败: {e}", "status": {"models": {}, "total": 0, "available": 0}}

View File

@ -131,5 +131,70 @@ def get_custom_model(model_id):
return modle_info
async def test_chat_model_status(provider: str, model_name: str) -> dict:
"""
测试指定聊天模型的状态
Args:
provider: 模型提供商
model_name: 模型名称
Returns:
dict: 包含状态信息的字典
"""
try:
# 加载模型
logger.debug(f"Selecting chat model {provider}/{model_name}")
model = select_model(provider, model_name)
# 使用简单的测试消息
test_messages = [{"role": "user", "content": "Say 1"}]
# 发送测试请求
response = model.call(test_messages, stream=False)
logger.debug(f"Test chat model status response: {response}")
# 检查响应是否有效
if response and response.content:
return {"provider": provider, "model_name": model_name, "status": "available", "message": "连接正常"}
else:
return {"provider": provider, "model_name": model_name, "status": "unavailable", "message": "响应无效"}
except Exception as e:
logger.error(f"测试聊天模型状态失败 {provider}/{model_name}: {e}")
return {"provider": provider, "model_name": model_name, "status": "error", "message": str(e)}
async def test_all_chat_models_status() -> dict:
"""
测试所有支持的聊天模型状态
Returns:
dict: 包含所有模型状态的字典
"""
from src import config
results = {}
# 获取所有可用的模型
for provider, provider_info in config.model_names.items():
if provider == "custom":
# 处理自定义模型
for custom_model in config.custom_models:
model_id = f"custom/{custom_model['custom_id']}"
status = await test_chat_model_status("custom", custom_model["custom_id"])
results[model_id] = status
else:
# 处理普通模型
for model_name in provider_info.models:
model_id = f"{provider}/{model_name}"
status = await test_chat_model_status(provider, model_name)
results[model_id] = status
available_count = len([m for m in results.values() if m["status"] == "available"])
return {"models": results, "total": len(results), "available": available_count}
if __name__ == "__main__":
pass

View File

@ -168,12 +168,89 @@ class OtherEmbedding(BaseEmbeddingModel):
logger.error(f"Other Embedding async request failed: {e}, {payload}")
raise ValueError(f"Other Embedding async request failed: {e}")
async def test_connection(self) -> tuple[bool, str]:
"""
测试embedding模型的连接性
Returns:
tuple: (success: bool, message: str)
"""
try:
# 使用简单的测试文本
test_text = ["Hello world"]
await self.aencode(test_text)
return True, "连接正常"
except Exception as e:
error_msg = str(e)
return False, error_msg
async def test_embedding_model_status(model_id: str) -> dict:
"""
测试指定embedding模型的状态
Args:
model_id: 模型ID格式为 "provider/model_name"
Returns:
dict: 包含状态信息的字典
"""
try:
support_embed_models = config.embed_model_names.keys()
if model_id not in support_embed_models:
return {"model_id": model_id, "status": "unsupported", "message": f"不支持的模型: {model_id}"}
# 选择并创建模型实例
model = select_embedding_model(model_id)
# 测试连接
success, message = await model.test_connection()
return {
"model_id": model_id,
"status": "available" if success else "unavailable",
"message": message if not success else "连接正常",
"dimension": model.dimension,
}
except Exception as e:
logger.warning(f"测试embedding模型状态失败 {model_id}: {e}")
return {"model_id": model_id, "status": "error", "message": str(e)}
async def test_all_embedding_models_status() -> dict:
"""
测试所有支持的embedding模型状态
Returns:
dict: 包含所有模型状态的字典
"""
support_embed_models = list(config.embed_model_names.keys())
results = {}
# 并发测试所有模型
tasks = [test_embedding_model_status(model_id) for model_id in support_embed_models]
model_statuses = await asyncio.gather(*tasks, return_exceptions=True)
for i, status in enumerate(model_statuses):
if isinstance(status, Exception):
model_id = support_embed_models[i]
results[model_id] = {"model_id": model_id, "status": "error", "message": str(status)}
else:
results[status["model_id"]] = status
return {
"models": results,
"total": len(support_embed_models),
"available": len([m for m in results.values() if m["status"] == "available"]),
}
def select_embedding_model(model_id):
provider, model_name = model_id.split("/", 1) if model_id else ("", "")
support_embed_models = config.embed_model_names.keys()
assert model_id in support_embed_models, f"Unsupported embed model: {model_id}, only support {support_embed_models}"
logger.debug(f"Loading embedding model {model_id}")
logger.info(f"Loading embedding model {model_id}")
if provider == "local":
raise ValueError("Local embedding model is not supported, please use other embedding models")

View File

@ -20,7 +20,7 @@ def setup_logger(name, level="DEBUG", console=True):
loguru_logger.add(
LOG_FILE,
level=level,
format="{time:YYYY-MM-DD HH:mm:ss} - {level} - {name}:{line} - {message}",
format="{time:YYYY-MM-DD HH:mm:ss} - {level} - {file}:{line} - {message}",
encoding="utf-8",
rotation="10 MB", # 文件大小达到 10MB 时轮转
retention="30 days", # 保留30天的日志
@ -35,7 +35,7 @@ def setup_logger(name, level="DEBUG", console=True):
format=(
"<green>{time:MM-DD HH:mm:ss}</green> "
"<level>{level}</level> "
"<cyan>{name}:{line}</cyan>: "
"<cyan>{file}:{line}</cyan>: "
"<level>{message}</level>"
),
colorize=True,

View File

@ -198,3 +198,26 @@ export const typeApi = {
}
}
// =============================================================================
// === Embedding模型状态检查分组 ===
// =============================================================================
export const embeddingApi = {
/**
* 获取指定embedding模型的状态
* @param {string} modelId - 模型ID
* @returns {Promise} - 模型状态
*/
getModelStatus: async (modelId) => {
return apiAdminGet(`/api/knowledge/embedding-models/${modelId}/status`)
},
/**
* 获取所有embedding模型的状态
* @returns {Promise} - 所有模型状态
*/
getAllModelsStatus: async () => {
return apiAdminGet('/api/knowledge/embedding-models/status')
}
}

View File

@ -100,5 +100,29 @@ export const ocrApi = {
getHealth: async () => apiAdminGet('/api/system/ocr/health')
}
// =============================================================================
// === 聊天模型状态检查分组 ===
// =============================================================================
export const chatModelApi = {
/**
* 获取指定聊天模型的状态
* @param {string} provider - 模型提供商
* @param {string} modelName - 模型名称
* @returns {Promise} - 模型状态
*/
getModelStatus: async (provider, modelName) => {
return apiAdminGet(`/api/system/chat-models/status?provider=${encodeURIComponent(provider)}&model_name=${encodeURIComponent(modelName)}`)
},
/**
* 获取所有聊天模型的状态
* @returns {Promise} - 所有模型状态
*/
getAllModelsStatus: async () => {
return apiAdminGet('/api/system/chat-models/all/status')
}
}

View File

@ -1,12 +1,35 @@
<template>
<a-dropdown trigger="click">
<a class="model-select" @click.prevent>
<!-- <BulbOutlined /> -->
<a-tooltip :title="model_name" placement="right">
<span class="model-text text"> {{ model_name }} </span>
</a-tooltip>
<span class="text" style="color: #aaa;">{{ model_provider }} </span>
</a>
<div class="model-select" @click.prevent>
<div class="model-select-content">
<div class="model-info">
<a-tooltip :title="model_name" placement="right">
<span class="model-text text"> {{ model_name }} </span>
</a-tooltip>
<span class="model-provider">{{ model_provider }}</span>
</div>
<div class="model-status-controls">
<span
v-if="currentModelStatus"
class="model-status-indicator"
:class="currentModelStatus.status"
:title="getCurrentModelStatusTooltip()"
>
{{ modelStatusIcon }}
</span>
<a-button
size="small"
type="text"
:loading="state.checkingStatus"
@click.stop="checkCurrentModelStatus"
:disabled="state.checkingStatus"
class="status-check-button"
>
{{ state.checkingStatus ? '检查中...' : '检查' }}
</a-button>
</div>
</div>
</div>
<template #overlay>
<a-menu class="scrollable-menu">
<a-menu-item-group v-for="(item, key) in modelKeys" :key="key" :title="modelNames[item]?.name">
@ -25,9 +48,9 @@
</template>
<script setup>
import { computed } from 'vue'
import { BulbOutlined } from '@ant-design/icons-vue'
import { computed, reactive } from 'vue'
import { useConfigStore } from '@/stores/config'
import { chatModelApi } from '@/apis/system_api'
const props = defineProps({
model_name: {
@ -43,6 +66,12 @@ const props = defineProps({
const configStore = useConfigStore()
const emit = defineEmits(['select-model'])
//
const state = reactive({
currentModelStatus: null, //
checkingStatus: false //
})
// configStore
const modelNames = computed(() => configStore.config?.model_names)
const modelStatus = computed(() => configStore.config?.model_provider_status)
@ -53,13 +82,78 @@ const modelKeys = computed(() => {
return Object.keys(modelStatus.value || {}).filter(key => modelStatus.value?.[key])
})
//
const currentModelStatus = computed(() => {
return state.currentModelStatus
})
//
const checkCurrentModelStatus = async () => {
if (!props.model_provider || !props.model_name) return
try {
state.checkingStatus = true
const response = await chatModelApi.getModelStatus(props.model_provider, props.model_name)
if (response.status) {
state.currentModelStatus = response.status
} else {
state.currentModelStatus = null
}
} catch (error) {
console.error(`检查当前模型 ${props.model_provider}/${props.model_name} 状态失败:`, error)
state.currentModelStatus = { status: 'error', message: error.message }
} finally {
state.checkingStatus = false
}
}
const modelStatusIcon = computed(() => {
const status = currentModelStatus.value
if (!status) return '○'
if (status.status === 'available') return '✓'
if (status.status === 'unavailable') return '✗'
if (status.status === 'error') return '⚠'
return '○'
})
//
const getCurrentModelStatusTooltip = () => {
const status = currentModelStatus.value
if (!status) return '状态未知'
let statusText = ''
if (status.status === 'available') statusText = '可用'
else if (status.status === 'unavailable') statusText = '不可用'
else if (status.status === 'error') statusText = '错误'
const message = status.message || '无详细信息'
return `${statusText}: ${message}`
}
//
const handleSelectModel = (provider, name) => {
const handleSelectModel = async (provider, name) => {
emit('select-model', { provider, name })
}
</script>
<style lang="less" scoped>
//
@status-success: #52c41a;
@status-error: #ff4d4f;
@status-warning: #faad14;
@status-default: #999;
@border-radius: 8px;
@scrollbar-width: 6px;
@status-indicator-padding: 2px 4px;
@status-check-button-padding: 0 4px;
@status-check-button-font-size: 12px;
@status-indicator-font-size: 11px;
@model-provider-color: #aaa;
//
.model-select {
overflow: hidden;
text-overflow: ellipsis;
@ -67,12 +161,14 @@ const handleSelectModel = (provider, name) => {
padding: 4px 8px;
cursor: pointer;
border: 1px solid var(--gray-200);
border-radius: 8px;
border-radius: @border-radius;
background-color: white;
min-width: 0;
display: flex;
align-items: center;
gap: 0.5rem;
background-color: white;
//
&.borderless {
border: none;
}
@ -81,21 +177,79 @@ const handleSelectModel = (provider, name) => {
max-width: 380px;
}
.model-text {
overflow: hidden;
text-overflow: ellipsis;
color: #000;
//
.model-select-content {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
width: 100%;
//
.model-info {
flex: 1;
min-width: 0;
overflow: hidden;
.model-text {
overflow: hidden;
text-overflow: ellipsis;
color: #000;
white-space: nowrap;
}
.model-provider {
color: @model-provider-color;
margin-left: 4px;
}
}
//
.model-status-controls {
display: flex;
align-items: center;
gap: 4px;
flex: 0;
margin-left: auto;
//
.model-status-indicator {
font-size: @status-indicator-font-size;
font-weight: bold;
padding: @status-indicator-padding;
border-radius: 3px;
//
&.available {
color: @status-success;
}
&.unavailable {
color: @status-error;
}
&.error {
color: @status-warning;
}
}
//
.status-check-button {
font-size: @status-check-button-font-size;
padding: @status-check-button-padding;
}
}
}
}
//
.scrollable-menu {
max-height: 300px;
overflow-y: auto;
//
&::-webkit-scrollbar {
width: 6px;
width: @scrollbar-width;
}
&::-webkit-scrollbar-track {
@ -106,10 +260,10 @@ const handleSelectModel = (provider, name) => {
&::-webkit-scrollbar-thumb {
background: var(--gray-400);
border-radius: 3px;
}
&::-webkit-scrollbar-thumb:hover {
background: var(--gray-500);
&:hover {
background: var(--gray-500);
}
}
}
</style>

View File

@ -26,20 +26,50 @@
/>
</div>
<div class="card card-select">
<span class="label">{{ items?.embed_model.des }}</span>
<a-select style="width: 300px"
<div style="display: flex; justify-content: space-between; align-items: center;">
<span class="label">{{ items?.embed_model.des }}</span>
<!-- <a-button
size="small"
:loading="state.checkingStatus"
@click="checkAllModelStatus"
:disabled="state.checkingStatus"
>
检查状态
</a-button> -->
</div>
<a-select style="width: 320px"
:value="configStore.config?.embed_model"
@change="handleChange('embed_model', $event)"
@dropdownVisibleChange="checkAllModelStatus"
>
<a-select-option
v-for="(name, idx) in items?.embed_model.choices" :key="idx"
:value="name">{{ name }}
:value="name"
>
<div style="display: flex; align-items: center; gap: 8px; min-width: 0;">
<span style="flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
{{ name }}
</span>
<span
:style="{
color: getModelStatusColor(name),
fontSize: '11px',
fontWeight: 'bold',
flexShrink: 0,
padding: '2px 4px',
borderRadius: '3px',
}"
:title="getModelStatusTooltip(name)"
>
{{ getModelStatusIcon(name) }}
</span>
</div>
</a-select-option>
</a-select>
</div>
<div class="card card-select">
<span class="label">{{ items?.reranker.des }}</span>
<a-select style="width: 300px"
<a-select style="width: 320px"
:value="configStore.config?.reranker"
@change="handleChange('reranker', $event)"
>
@ -156,6 +186,7 @@ import ModelProvidersComponent from '@/components/ModelProvidersComponent.vue';
import UserManagementComponent from '@/components/UserManagementComponent.vue';
import { notification, Button } from 'ant-design-vue';
import { configApi } from '@/apis/system_api'
import { embeddingApi } from '@/apis/knowledge_api'
import ModelSelectorComponent from '@/components/ModelSelectorComponent.vue';
const configStore = useConfigStore()
@ -165,7 +196,9 @@ const isNeedRestart = ref(false)
const state = reactive({
loading: false,
section: 'base',
windowWidth: window?.innerWidth || 0
windowWidth: window?.innerWidth || 0,
modelStatuses: {}, // embedding
checkingStatus: false //
})
const handleModelLocalPathsUpdate = (config) => {
@ -225,6 +258,8 @@ onMounted(() => {
updateWindowWidth()
window.addEventListener('resize', updateWindowWidth)
state.section = userStore.isSuperAdmin ? 'base' : 'user'
checkAllModelStatus()
})
onUnmounted(() => {
@ -249,6 +284,56 @@ const sendRestart = () => {
});
}
// embedding
const checkAllModelStatus = async () => {
try {
state.checkingStatus = true
const response = await embeddingApi.getAllModelsStatus()
if (response.status.models) {
state.modelStatuses = response.status.models
}
} catch (error) {
console.error('检查所有模型状态失败:', error)
message.error('获取模型状态失败')
} finally {
state.checkingStatus = false
}
}
//
const getModelStatusIcon = (modelId) => {
const status = state.modelStatuses[modelId]
if (!status) return '○'
if (status.status === 'available') return '✓'
if (status.status === 'unavailable') return '✗'
if (status.status === 'error') return '⚠'
return '○'
}
//
const getModelStatusColor = (modelId) => {
const status = state.modelStatuses[modelId]
if (!status) return '#999'
if (status.status === 'available') return '#52c41a'
if (status.status === 'unavailable') return '#ff4d4f'
if (status.status === 'error') return '#faad14'
return '#999'
}
//
const getModelStatusTooltip = (modelId) => {
const status = state.modelStatuses[modelId]
if (!status) return '状态未知'
let statusText = ''
if (status.status === 'available') statusText = '可用'
else if (status.status === 'unavailable') statusText = '不可用'
else if (status.status === 'error') statusText = '错误'
const message = status.message || '无详细信息'
return `${statusText}: ${message}`
}
const openLink = (url) => {
window.open(url, '_blank')
}