diff --git a/dashboard/src/components/dynamic-form/DynamicField.tsx b/dashboard/src/components/dynamic-form/DynamicField.tsx index 27ce7856..139165b2 100644 --- a/dashboard/src/components/dynamic-form/DynamicField.tsx +++ b/dashboard/src/components/dynamic-form/DynamicField.tsx @@ -31,21 +31,25 @@ export const DynamicField: React.FC = ({ value, onChange, }) => { - const parseNumericValue = (rawValue: unknown, fallback: number) => { + const isNumericField = schema.type === 'integer' || schema.type === 'number' + + const parseNumericValue = (rawValue: unknown, fallbackValue: unknown = 0) => { if (typeof rawValue === 'number' && Number.isFinite(rawValue)) { return rawValue } if (typeof rawValue === 'string') { - const parsedValue = schema.type === 'integer' - ? parseInt(rawValue, 10) - : parseFloat(rawValue) + const parsedValue = parseFloat(rawValue) if (Number.isFinite(parsedValue)) { - return parsedValue + return schema.type === 'integer' ? Math.trunc(parsedValue) : parsedValue } } - return fallback + if (fallbackValue !== rawValue) { + return parseNumericValue(fallbackValue, 0) + } + + return 0 } const renderPrimitiveArrayEditor = () => { @@ -145,16 +149,17 @@ export const DynamicField: React.FC = ({ const renderInputComponent = () => { const widget = schema['x-widget'] const type = schema.type + const resolvedWidget = + isNumericField && (widget === 'input' || widget === 'number' || !widget) + ? 'number' + : widget // x-widget 优先 - if (widget) { - switch (widget) { + if (resolvedWidget) { + switch (resolvedWidget) { case 'slider': return renderSlider() case 'input': - if (type === 'integer' || type === 'number') { - return renderNumberInput() - } return renderTextInput() case 'number': return renderNumberInput() @@ -240,7 +245,7 @@ export const DynamicField: React.FC = ({ * 渲染 Slider 组件(用于 number 类型 + x-widget: slider) */ const renderSlider = () => { - const numValue = parseNumericValue(value, schema.default as number ?? 0) + const numValue = parseNumericValue(value, schema.default) const min = schema.minValue ?? 0 const max = schema.maxValue ?? 100 const step = schema.step ?? 1 @@ -267,7 +272,7 @@ export const DynamicField: React.FC = ({ * 渲染 Input[type="number"] 组件(用于 number/integer 类型) */ const renderNumberInput = () => { - const numValue = parseNumericValue(value, schema.default as number ?? 0) + const numValue = parseNumericValue(value, schema.default) const min = schema.minValue const max = schema.maxValue const step = schema.step ?? (schema.type === 'integer' ? 1 : 0.1) diff --git a/src/config/official_configs.py b/src/config/official_configs.py index a599f22e..2ff199dd 100644 --- a/src/config/official_configs.py +++ b/src/config/official_configs.py @@ -239,6 +239,8 @@ class ChatConfig(ConfigBase): json_schema_extra={ "x-widget": "input", "x-icon": "layers", + "x-layout": "inline-right", + "x-input-width": "12rem", }, ) """上下文长度""" @@ -248,6 +250,8 @@ class ChatConfig(ConfigBase): json_schema_extra={ "x-widget": "input", "x-icon": "layers", + "x-layout": "inline-right", + "x-input-width": "12rem", }, ) """私聊上下文长度""" diff --git a/src/webui/routers/config.py b/src/webui/routers/config.py index 9aadc207..8aa36c97 100644 --- a/src/webui/routers/config.py +++ b/src/webui/routers/config.py @@ -2,19 +2,20 @@ 配置管理API路由 """ +from pathlib import Path +from typing import Annotated, Any, Dict, List, Tuple, Union, get_args, get_origin import copy import os -from pathlib import Path -from typing import Annotated, Any, Dict, List, Tuple +import types -import tomlkit from fastapi import APIRouter, Body, Depends, HTTPException, Query from fastapi.responses import FileResponse from pydantic import BaseModel, Field +import tomlkit from src.common.logger import get_logger from src.config.config import CONFIG_DIR, PROJECT_ROOT, Config, ModelConfig -from src.config.config_base import AttributeData +from src.config.config_base import AttributeData, ConfigBase from src.config.model_configs import ( APIProvider, ModelInfo, @@ -129,6 +130,66 @@ def _toml_to_plain_dict(obj: Any) -> Any: return obj +def _coerce_numeric_value(value: Any, target_type: Any) -> Any: + """根据配置字段类型,把旧 WebUI 可能写入的数字字符串还原为数字。""" + if target_type is int: + if isinstance(value, str): + try: + parsed_value = float(value.strip()) + except ValueError: + return value + if parsed_value.is_integer(): + return int(parsed_value) + return value + + if target_type is float: + if isinstance(value, str): + try: + return float(value.strip()) + except ValueError: + return value + return value + + return value + + +def _coerce_value_by_annotation(value: Any, annotation: Any) -> Any: + """递归按 ConfigBase 字段注解修正数据类型,避免保存时把数字写成字符串。""" + value = _coerce_numeric_value(value, annotation) + origin = get_origin(annotation) + args = get_args(annotation) + + if origin in {Union, types.UnionType}: + for candidate_type in args: + if candidate_type is type(None): + continue + coerced_value = _coerce_value_by_annotation(value, candidate_type) + if coerced_value != value or type(coerced_value) is not type(value): + return coerced_value + return value + + if origin in {list, List} and isinstance(value, list) and args: + item_type = args[0] + return [_coerce_value_by_annotation(item, item_type) for item in value] + + if origin in {dict, Dict} and isinstance(value, dict) and len(args) >= 2: + value_type = args[1] + return {key: _coerce_value_by_annotation(item, value_type) for key, item in value.items()} + + if isinstance(value, dict) and isinstance(annotation, type) and issubclass(annotation, ConfigBase): + return _coerce_config_numeric_values(value, annotation) + + return value + + +def _coerce_config_numeric_values(data: Dict[str, Any], config_type: type[ConfigBase]) -> Dict[str, Any]: + """按配置类 schema 统一修正所有数字字段类型。""" + for field_name, field_info in config_type.model_fields.items(): + if field_name in data: + data[field_name] = _coerce_value_by_annotation(data[field_name], field_info.annotation) + return data + + # ===== 架构获取接口 ===== @@ -347,6 +408,8 @@ async def get_model_config(): async def update_bot_config(config_data: ConfigBody): """更新麦麦主程序配置""" try: + config_data = _coerce_config_numeric_values(config_data, Config) + # 验证配置数据 try: Config.from_dict(AttributeData(), copy.deepcopy(config_data)) @@ -370,6 +433,8 @@ async def update_bot_config(config_data: ConfigBody): async def update_model_config(config_data: ConfigBody): """更新模型配置""" try: + config_data = _coerce_config_numeric_values(config_data, ModelConfig) + # 验证配置数据 try: ModelConfig.from_dict(AttributeData(), copy.deepcopy(config_data)) @@ -422,10 +487,13 @@ async def update_bot_config_section(section_name: str, section_data: SectionBody # 验证完整配置 try: - Config.from_dict(AttributeData(), _toml_to_plain_dict(config_data)) + plain_config_data = _coerce_config_numeric_values(_toml_to_plain_dict(config_data), Config) + Config.from_dict(AttributeData(), copy.deepcopy(plain_config_data)) except Exception as e: raise HTTPException(status_code=400, detail=f"配置数据验证失败: {str(e)}") from e + config_data = plain_config_data + # 保存配置(格式化数组为多行,保留注释) save_toml_with_format(config_data, config_path) @@ -520,13 +588,14 @@ async def update_model_config_section(section_name: str, section_data: SectionBo # 验证完整配置 try: - ModelConfig.from_dict(AttributeData(), _toml_to_plain_dict(config_data)) + plain_config_data = _coerce_config_numeric_values(_toml_to_plain_dict(config_data), ModelConfig) + ModelConfig.from_dict(AttributeData(), copy.deepcopy(plain_config_data)) except Exception as e: logger.error(f"配置数据验证失败,详细错误: {str(e)}") # 特殊处理:如果是更新 api_providers,检查是否有模型引用了已删除的provider if section_name == "api_providers" and "api_provider" in str(e): provider_names = {p.get("name") for p in section_data if isinstance(p, dict)} - models = config_data.get("models", []) + models = plain_config_data.get("models", []) orphaned_models: List[str] = [ str(model_name) for m in models @@ -539,6 +608,8 @@ async def update_model_config_section(section_name: str, section_data: SectionBo raise HTTPException(status_code=400, detail=error_msg) from e raise HTTPException(status_code=400, detail=f"配置数据验证失败: {str(e)}") from e + config_data = plain_config_data + # 保存配置(格式化数组为多行,保留注释) save_toml_with_format(config_data, config_path)