feat(config): 添加模型助手功能以选择和管理模型

本次提交主要包含以下更改:

1. **后端更新**:
   - 在 `app/service/config/config_service.py` 中新增 `fetch_ui_models` 方法,用于获取可用于 UI 的模型列表,并处理相关的错误情况。
   - 在 `app/router/config_routes.py` 中新增 `/ui/models` 路由,提供模型列表的 API 接口,并添加身份验证逻辑。

2. **前端更新**:
   - 在 `app/static/js/config_editor.js` 中实现模型助手的功能,包括模型列表的加载、搜索和选择。
   - 在 `app/templates/config_editor.html` 中添加模型助手的模态框和相关的 UI 元素,允许用户从列表中选择模型。

这些更改旨在增强用户体验,使用户能够更方便地选择和管理模型,提高配置界面的交互性和功能性。
This commit is contained in:
snaily
2025-05-08 19:48:03 +08:00
parent 30bf666a57
commit f1f568afca
4 changed files with 405 additions and 44 deletions

View File

@@ -1,18 +1,21 @@
"""
配置路由模块
"""
from typing import Any, Dict
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import RedirectResponse
from app.core.security import verify_auth_token
from app.log.logger import get_config_routes_logger, Logger
from app.log.logger import Logger, get_config_routes_logger
from app.service.config.config_service import ConfigService
router = APIRouter(prefix="/api/config", tags=["config"])
logger = get_config_routes_logger()
@router.get("", response_model=Dict[str, Any])
async def get_config(request: Request):
auth_token = request.cookies.get("auth_token")
@@ -49,3 +52,23 @@ async def reset_config(request: Request):
return await ConfigService.reset_config()
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/ui/models")
async def get_ui_models(request: Request):
auth_token_cookie = request.cookies.get("auth_token")
if not auth_token_cookie or not verify_auth_token(auth_token_cookie):
logger.warning("Unauthorized access attempt to /api/config/ui/models")
raise HTTPException(status_code=403, detail="Not authenticated")
try:
models = await ConfigService.fetch_ui_models()
return models
except HTTPException as e:
raise e
except Exception as e:
logger.error(f"Unexpected error in /ui/models endpoint: {e}", exc_info=True)
raise HTTPException(
status_code=500,
detail=f"An unexpected error occurred while fetching UI models: {str(e)}",
)

View File

@@ -1,41 +1,49 @@
"""
配置服务模块
"""
import datetime
import json
from typing import Any, Dict, List
from dotenv import find_dotenv, load_dotenv
from fastapi import HTTPException
from sqlalchemy import insert, update
from app.config.config import Settings as ConfigSettings
from app.config.config import settings
from app.database.connection import database
from app.database.models import Settings
from app.config.config import Settings as ConfigSettings
from app.database.services import get_all_settings
from app.service.key.key_manager import get_key_manager_instance, reset_key_manager_instance
from app.log.logger import get_config_routes_logger
from app.service.key.key_manager import (
get_key_manager_instance,
reset_key_manager_instance,
)
from app.service.model.model_service import ModelService
logger = get_config_routes_logger()
class ConfigService:
"""配置服务类,用于管理应用程序配置"""
@staticmethod
async def get_config() -> Dict[str, Any]:
return settings.model_dump()
@staticmethod
async def update_config(config_data: Dict[str, Any]) -> Dict[str, Any]:
for key, value in config_data.items():
if hasattr(settings, key):
setattr(settings, key, value)
logger.debug(f"Updated setting in memory: {key}")
logger.debug(f"Updated setting in memory: {key}")
# 获取现有设置
existing_settings_raw: List[Dict[str, Any]] = await get_all_settings()
existing_settings_map: Dict[str, Dict[str, Any]] = {s['key']: s for s in existing_settings_raw}
existing_settings_map: Dict[str, Dict[str, Any]] = {
s["key"]: s for s in existing_settings_raw
}
existing_keys = set(existing_settings_map.keys())
settings_to_update: List[Dict[str, Any]] = []
@@ -47,7 +55,7 @@ class ConfigService:
# 处理不同类型的值
if isinstance(value, list):
db_value = json.dumps(value)
elif isinstance(value, dict): # 新增对 dict 类型的处理
elif isinstance(value, dict): # 新增对 dict 类型的处理
db_value = json.dumps(value)
elif isinstance(value, bool):
db_value = str(value).lower()
@@ -55,24 +63,26 @@ class ConfigService:
db_value = str(value)
# 仅当值发生变化时才更新
if key in existing_keys and existing_settings_map[key]['value'] == db_value:
continue
if key in existing_keys and existing_settings_map[key]["value"] == db_value:
continue
description = f"{key}配置项"
description = f"{key}配置项"
data = {
'key': key,
'value': db_value,
'description': description,
'updated_at': now
"key": key,
"value": db_value,
"description": description,
"updated_at": now,
}
if key in existing_keys:
# Preserve original description if not explicitly provided
data['description'] = existing_settings_map[key].get('description', description)
data["description"] = existing_settings_map[key].get(
"description", description
)
settings_to_update.append(data)
else:
data['created_at'] = now
data["created_at"] = now
settings_to_insert.append(data)
# 在事务中执行批量插入和更新
@@ -82,17 +92,19 @@ class ConfigService:
if settings_to_insert:
query_insert = insert(Settings).values(settings_to_insert)
await database.execute(query=query_insert)
logger.info(f"Bulk inserted {len(settings_to_insert)} settings.")
logger.info(
f"Bulk inserted {len(settings_to_insert)} settings."
)
if settings_to_update:
for setting_data in settings_to_update:
query_update = (
update(Settings)
.where(Settings.key == setting_data['key'])
.where(Settings.key == setting_data["key"])
.values(
value=setting_data['value'],
description=setting_data['description'],
updated_at=setting_data['updated_at']
value=setting_data["value"],
description=setting_data["description"],
updated_at=setting_data["updated_at"],
)
)
await database.execute(query=query_update)
@@ -112,7 +124,7 @@ class ConfigService:
# For now, we log the error and continue
return await ConfigService.get_config()
@staticmethod
async def reset_config() -> Dict[str, Any]:
"""
@@ -124,7 +136,9 @@ class ConfigService:
"""
# 1. 重新加载配置对象,它应该处理环境变量和 .env 的优先级
_reload_settings()
logger.info("Settings object reloaded, prioritizing system environment variables then .env file.")
logger.info(
"Settings object reloaded, prioritizing system environment variables then .env file."
)
# 2. 重置并重新初始化 KeyManager
try:
@@ -140,6 +154,36 @@ class ConfigService:
# 3. 返回更新后的配置
return await ConfigService.get_config()
@staticmethod
async def fetch_ui_models() -> List[Dict[str, Any]]:
"""获取用于UI显示的模型列表"""
try:
key_manager = await get_key_manager_instance()
model_service = ModelService()
api_key = await key_manager.get_first_valid_key()
if not api_key:
logger.error("No valid API keys available to fetch model list for UI.")
raise HTTPException(
status_code=500,
detail="No valid API keys available to fetch model list.",
)
models = await model_service.get_gemini_openai_models(api_key)
return models
except HTTPException as e:
# Re-raise HTTPExceptions directly if they are already specific
raise e
except Exception as e:
logger.error(
f"Failed to fetch models for UI in ConfigService: {e}", exc_info=True
)
# Raise a generic HTTPException for other errors
raise HTTPException(
status_code=500, detail=f"Failed to fetch models for UI: {str(e)}"
)
# 重新加载配置的函数
def _reload_settings():
"""重新加载环境变量并更新配置"""
@@ -147,4 +191,4 @@ def _reload_settings():
load_dotenv(find_dotenv(), override=True)
# 更新现有 settings 对象的属性,而不是新建实例
for key, value in ConfigSettings().model_dump().items():
setattr(settings, key, value)
setattr(settings, key, value)

View File

@@ -31,6 +31,23 @@ const bulkDeleteProxyInput = document.getElementById("bulkDeleteProxyInput");
const resetConfirmModal = document.getElementById("resetConfirmModal");
const configForm = document.getElementById("configForm"); // Added for frequent use
// Model Helper Modal Elements
const modelHelperModal = document.getElementById("modelHelperModal");
const modelHelperTitleElement = document.getElementById("modelHelperTitle");
const modelHelperSearchInput = document.getElementById(
"modelHelperSearchInput"
);
const modelHelperListContainer = document.getElementById(
"modelHelperListContainer"
);
const closeModelHelperModalBtn = document.getElementById(
"closeModelHelperModalBtn"
);
const cancelModelHelperBtn = document.getElementById("cancelModelHelperBtn");
let cachedModelsList = null;
let currentModelHelperTarget = null; // { type: 'input'/'array', target: elementOrIdOrKey }
// Modal Control Functions
function openModal(modalElement) {
if (modalElement) {
@@ -227,6 +244,7 @@ document.addEventListener("DOMContentLoaded", function () {
bulkDeleteApiKeyModal,
proxyModal,
bulkDeleteProxyModal,
modelHelperModal,
];
modals.forEach((modal) => {
if (event.target === modal) {
@@ -353,6 +371,44 @@ document.addEventListener("DOMContentLoaded", function () {
}
initializeSensitiveFields(); // Initialize sensitive field handling
// Model Helper Modal Event Listeners
if (closeModelHelperModalBtn) {
closeModelHelperModalBtn.addEventListener("click", () =>
closeModal(modelHelperModal)
);
}
if (cancelModelHelperBtn) {
cancelModelHelperBtn.addEventListener("click", () =>
closeModal(modelHelperModal)
);
}
if (modelHelperSearchInput) {
modelHelperSearchInput.addEventListener("input", () =>
renderModelsInModal()
);
}
// Add event listeners to all model helper trigger buttons
const modelHelperTriggerBtns = document.querySelectorAll(
".model-helper-trigger-btn"
);
modelHelperTriggerBtns.forEach((btn) => {
btn.addEventListener("click", () => {
const targetInputId = btn.dataset.targetInputId;
const targetArrayKey = btn.dataset.targetArrayKey;
if (targetInputId) {
currentModelHelperTarget = {
type: "input",
target: document.getElementById(targetInputId),
};
} else if (targetArrayKey) {
currentModelHelperTarget = { type: "array", targetKey: targetArrayKey };
}
openModelHelperModal();
});
});
}); // <-- DOMContentLoaded end
/**
@@ -1719,3 +1775,140 @@ function addSafetySettingItem(category = "", threshold = "") {
container.appendChild(settingItem);
}
// --- Model Helper Functions ---
async function fetchModels() {
if (cachedModelsList) {
return cachedModelsList;
}
try {
showNotification("正在从 /api/config/ui/models 加载模型列表...", "info");
const response = await fetch("/api/config/ui/models");
if (!response.ok) {
const errorData = await response.text();
throw new Error(`HTTP error ${response.status}: ${errorData}`);
}
const responseData = await response.json(); // Changed variable name to responseData
// The backend returns an object like: { object: "list", data: [{id: "m1"}, {id: "m2"}], success: true }
if (
responseData &&
responseData.success &&
Array.isArray(responseData.data)
) {
cachedModelsList = responseData.data; // Use responseData.data
showNotification("模型列表加载成功", "success");
return cachedModelsList;
} else {
console.error("Invalid model list format received:", responseData);
throw new Error("模型列表格式无效或请求未成功");
}
} catch (error) {
console.error("加载模型列表失败:", error);
showNotification(`加载模型列表失败: ${error.message}`, "error");
cachedModelsList = []; // Avoid repeated fetches on error for this session, or set to null to retry
return [];
}
}
function renderModelsInModal() {
if (!modelHelperListContainer) return;
if (!cachedModelsList) {
modelHelperListContainer.innerHTML =
'<p class="text-gray-400 text-sm italic">模型列表尚未加载。</p>';
return;
}
const searchTerm = modelHelperSearchInput.value.toLowerCase();
const filteredModels = cachedModelsList.filter((model) =>
model.id.toLowerCase().includes(searchTerm)
);
modelHelperListContainer.innerHTML = ""; // Clear previous items
if (filteredModels.length === 0) {
modelHelperListContainer.innerHTML =
'<p class="text-gray-400 text-sm italic">未找到匹配的模型。</p>';
return;
}
filteredModels.forEach((model) => {
const modelItemElement = document.createElement("button");
modelItemElement.type = "button";
modelItemElement.textContent = model.id;
modelItemElement.className =
"block w-full text-left px-4 py-2 rounded-md hover:bg-violet-700 focus:bg-violet-700 focus:outline-none transition-colors text-gray-200";
// Add any other classes for styling, e.g., from existing modals or array items
modelItemElement.addEventListener("click", () =>
handleModelSelection(model.id)
);
modelHelperListContainer.appendChild(modelItemElement);
});
}
async function openModelHelperModal() {
if (!currentModelHelperTarget) {
console.error("Model helper target not set.");
showNotification("无法打开模型助手:目标未设置", "error");
return;
}
await fetchModels(); // Ensure models are loaded
renderModelsInModal(); // Render them (handles empty/error cases internally)
if (modelHelperTitleElement) {
if (
currentModelHelperTarget.type === "input" &&
currentModelHelperTarget.target
) {
const label = document.querySelector(
`label[for="${currentModelHelperTarget.target.id}"]`
);
modelHelperTitleElement.textContent = label
? `为 "${label.textContent.trim()}" 选择模型`
: "选择模型";
} else if (currentModelHelperTarget.type === "array") {
modelHelperTitleElement.textContent = `${currentModelHelperTarget.targetKey} 添加模型`;
} else {
modelHelperTitleElement.textContent = "选择模型";
}
}
if (modelHelperSearchInput) modelHelperSearchInput.value = ""; // Clear search on open
if (modelHelperModal) openModal(modelHelperModal);
}
function handleModelSelection(selectedModelId) {
if (!currentModelHelperTarget) return;
if (
currentModelHelperTarget.type === "input" &&
currentModelHelperTarget.target
) {
const inputElement = currentModelHelperTarget.target;
inputElement.value = selectedModelId;
// If the input is a sensitive field, dispatch focusout to trigger masking behavior if needed
if (inputElement.classList.contains(SENSITIVE_INPUT_CLASS)) {
const event = new Event("focusout", { bubbles: true, cancelable: true });
inputElement.dispatchEvent(event);
}
// Dispatch input event for any other listeners
inputElement.dispatchEvent(new Event("input", { bubbles: true }));
} else if (
currentModelHelperTarget.type === "array" &&
currentModelHelperTarget.targetKey
) {
const modelId = addArrayItemWithValue(
currentModelHelperTarget.targetKey,
selectedModelId
);
if (currentModelHelperTarget.targetKey === "THINKING_MODELS" && modelId) {
// Automatically add corresponding budget map item with default budget 0
createAndAppendBudgetMapItem(selectedModelId, 0, modelId);
}
}
if (modelHelperModal) closeModal(modelHelperModal);
currentModelHelperTarget = null; // Reset target
}
// -- End Model Helper Functions --

View File

@@ -557,13 +557,23 @@ endblock %} {% block head_extra_styles %}
<label for="TEST_MODEL" class="block font-semibold mb-2 text-gray-700"
>测试模型</label
>
<input
type="text"
id="TEST_MODEL"
name="TEST_MODEL"
placeholder="gemini-1.5-flash"
class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 form-input-themed"
/>
<div class="flex items-center gap-2">
<input
type="text"
id="TEST_MODEL"
name="TEST_MODEL"
placeholder="gemini-1.5-flash"
class="flex-grow px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 form-input-themed"
/>
<button
type="button"
title="选择模型"
class="model-helper-trigger-btn p-2 rounded-md text-violet-300 hover:bg-violet-700 transition-colors"
data-target-input-id="TEST_MODEL"
>
<i class="fas fa-list-ul"></i>
</button>
</div>
<small class="text-gray-500 mt-1 block">用于测试API密钥的模型</small>
</div>
@@ -577,7 +587,15 @@ endblock %} {% block head_extra_styles %}
<div class="array-container" id="IMAGE_MODELS_container">
<!-- 数组项将在这里动态添加 -->
</div>
<div class="flex justify-end">
<div class="flex justify-end gap-2 mt-2">
<button
type="button"
title="从列表选择模型添加到下方"
class="model-helper-trigger-btn p-2 rounded-md text-violet-300 hover:bg-violet-700 transition-colors"
data-target-array-key="IMAGE_MODELS"
>
<i class="fas fa-list-ul"></i>
</button>
<button
type="button"
class="bg-violet-600 hover:bg-violet-700 text-white px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center gap-2"
@@ -599,7 +617,15 @@ endblock %} {% block head_extra_styles %}
<div class="array-container" id="SEARCH_MODELS_container">
<!-- 数组项将在这里动态添加 -->
</div>
<div class="flex justify-end">
<div class="flex justify-end gap-2 mt-2">
<button
type="button"
title="从列表选择模型添加到下方"
class="model-helper-trigger-btn p-2 rounded-md text-violet-300 hover:bg-violet-700 transition-colors"
data-target-array-key="SEARCH_MODELS"
>
<i class="fas fa-list-ul"></i>
</button>
<button
type="button"
class="bg-violet-600 hover:bg-violet-700 text-white px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center gap-2"
@@ -621,7 +647,15 @@ endblock %} {% block head_extra_styles %}
<div class="array-container" id="FILTERED_MODELS_container">
<!-- 数组项将在这里动态添加 -->
</div>
<div class="flex justify-end">
<div class="flex justify-end gap-2 mt-2">
<button
type="button"
title="从列表选择模型添加到下方"
class="model-helper-trigger-btn p-2 rounded-md text-violet-300 hover:bg-violet-700 transition-colors"
data-target-array-key="FILTERED_MODELS"
>
<i class="fas fa-list-ul"></i>
</button>
<button
type="button"
class="bg-violet-600 hover:bg-violet-700 text-white px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center gap-2"
@@ -708,7 +742,15 @@ endblock %} {% block head_extra_styles %}
<div class="array-container" id="THINKING_MODELS_container">
<!-- 数组项将在这里动态添加 -->
</div>
<div class="flex justify-end">
<div class="flex justify-end gap-2 mt-2">
<button
type="button"
title="从列表选择模型添加到下方"
class="model-helper-trigger-btn p-2 rounded-md text-violet-300 hover:bg-violet-700 transition-colors"
data-target-array-key="THINKING_MODELS"
>
<i class="fas fa-list-ul"></i>
</button>
<button
type="button"
class="bg-violet-600 hover:bg-violet-700 text-white px-4 py-2 rounded-lg font-medium transition-all duration-200 flex items-center gap-2"
@@ -829,13 +871,23 @@ endblock %} {% block head_extra_styles %}
class="block font-semibold mb-2 text-gray-700"
>图像生成模型</label
>
<input
type="text"
id="CREATE_IMAGE_MODEL"
name="CREATE_IMAGE_MODEL"
placeholder="imagen-3.0-generate-002"
class="w-full px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 form-input-themed"
/>
<div class="flex items-center gap-2">
<input
type="text"
id="CREATE_IMAGE_MODEL"
name="CREATE_IMAGE_MODEL"
placeholder="imagen-3.0-generate-002"
class="flex-grow px-4 py-3 rounded-lg border border-gray-300 focus:border-primary-500 focus:ring focus:ring-primary-200 focus:ring-opacity-50 form-input-themed"
/>
<button
type="button"
title="选择模型"
class="model-helper-trigger-btn p-2 rounded-md text-violet-300 hover:bg-violet-700 transition-colors"
data-target-input-id="CREATE_IMAGE_MODEL"
>
<i class="fas fa-list-ul"></i>
</button>
</div>
<small class="text-gray-500 mt-1 block">用于图像生成的模型</small>
</div>
@@ -1505,6 +1557,55 @@ endblock %} {% block head_extra_styles %}
</div>
</div>
<!-- Model Helper Modal -->
<div id="modelHelperModal" class="modal">
<div
class="w-full max-w-lg mx-auto rounded-2xl shadow-2xl overflow-hidden animate-fade-in"
style="
background-color: rgba(70, 50, 150, 0.95);
color: #ffffff;
border: 1px solid rgba(120, 100, 200, 0.4);
"
>
<div class="p-6">
<div class="flex justify-between items-center mb-4">
<h2 id="modelHelperTitle" class="text-xl font-bold text-gray-100">
选择模型
</h2>
<button
id="closeModelHelperModalBtn"
class="text-gray-300 hover:text-gray-100 text-xl"
>
&times;
</button>
</div>
<input
type="text"
id="modelHelperSearchInput"
placeholder="搜索模型..."
class="w-full px-4 py-3 mb-4 rounded-lg border font-mono text-sm form-input-themed"
/>
<div
id="modelHelperListContainer"
class="array-container"
style="max-height: 300px; overflow-y: auto"
>
<!-- Model items will be populated here -->
<p class="text-gray-400 text-sm italic">正在加载模型列表...</p>
</div>
<div class="flex justify-end gap-3 mt-6">
<button
type="button"
id="cancelModelHelperBtn"
class="bg-gray-500 bg-opacity-50 hover:bg-opacity-70 text-gray-200 px-6 py-2 rounded-lg font-medium transition"
>
关闭
</button>
</div>
</div>
</div>
</div>
{% endblock %} {% block body_scripts %}
<script src="/static/js/config_editor.js"></script>
<!-- 增强下拉框样式和交互性 -->