From 5838dda1756feba477243c58a7f2d70cd886fe29 Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Tue, 17 Feb 2026 16:48:58 +0800 Subject: [PATCH 01/26] feat(dashboard): add UI metadata fields to FieldSchema type --- dashboard/src/types/config-schema.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/dashboard/src/types/config-schema.ts b/dashboard/src/types/config-schema.ts index e744c300..206c253d 100644 --- a/dashboard/src/types/config-schema.ts +++ b/dashboard/src/types/config-schema.ts @@ -12,6 +12,8 @@ export type FieldType = | 'object' | 'textarea' +export type XWidgetType = 'slider' | 'select' | 'textarea' | 'switch' | 'custom' + export interface FieldSchema { name: string type: FieldType @@ -26,6 +28,9 @@ export interface FieldSchema { type: string } properties?: ConfigSchema + 'x-widget'?: XWidgetType + 'x-icon'?: string + step?: number } export interface ConfigSchema { From e530ee8fa6511b2b3b4db601dd02bfad91b2bb2d Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Tue, 17 Feb 2026 16:49:32 +0800 Subject: [PATCH 02/26] feat(dashboard): create FieldHookRegistry for dynamic form hooks --- dashboard/src/lib/field-hooks.ts | 99 ++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 dashboard/src/lib/field-hooks.ts diff --git a/dashboard/src/lib/field-hooks.ts b/dashboard/src/lib/field-hooks.ts new file mode 100644 index 00000000..2be1b866 --- /dev/null +++ b/dashboard/src/lib/field-hooks.ts @@ -0,0 +1,99 @@ +import type { ReactNode } from 'react' + +/** + * Hook type for field-level customization + */ +export type FieldHookType = 'replace' | 'wrapper' + +/** + * Props passed to a FieldHookComponent + */ +export interface FieldHookComponentProps { + fieldPath: string + value: unknown + onChange?: (value: unknown) => void + children?: ReactNode +} + +/** + * A React component that can be registered as a field hook + */ +export type FieldHookComponent = React.FC + +/** + * Registry entry for a field hook + */ +interface FieldHookEntry { + component: FieldHookComponent + type: FieldHookType +} + +/** + * Registry for managing field-level hooks + * Supports two types of hooks: + * - replace: Completely replaces the default field renderer + * - wrapper: Wraps the default field renderer with additional functionality + */ +export class FieldHookRegistry { + private hooks: Map = new Map() + + /** + * Register a hook for a specific field path + * @param fieldPath The field path (e.g., 'chat.talk_value') + * @param component The React component to register + * @param type The hook type ('replace' or 'wrapper') + */ + register( + fieldPath: string, + component: FieldHookComponent, + type: FieldHookType = 'replace' + ): void { + this.hooks.set(fieldPath, { component, type }) + } + + /** + * Get a registered hook for a specific field path + * @param fieldPath The field path to look up + * @returns The hook entry if found, undefined otherwise + */ + get(fieldPath: string): FieldHookEntry | undefined { + return this.hooks.get(fieldPath) + } + + /** + * Check if a hook is registered for a specific field path + * @param fieldPath The field path to check + * @returns True if a hook is registered, false otherwise + */ + has(fieldPath: string): boolean { + return this.hooks.has(fieldPath) + } + + /** + * Unregister a hook for a specific field path + * @param fieldPath The field path to unregister + */ + unregister(fieldPath: string): void { + this.hooks.delete(fieldPath) + } + + /** + * Clear all registered hooks + */ + clear(): void { + this.hooks.clear() + } + + /** + * Get all registered field paths + * @returns Array of registered field paths + */ + getAllPaths(): string[] { + return Array.from(this.hooks.keys()) + } +} + +/** + * Singleton instance of the field hook registry + */ +export const fieldHooks = new FieldHookRegistry() From 1631774452a5974480b00a28598521c942073a3f Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Tue, 17 Feb 2026 16:49:49 +0800 Subject: [PATCH 03/26] feat(config): add UI metadata to ChatConfig sample fields --- src/config/official_configs.py | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/config/official_configs.py b/src/config/official_configs.py index a1e44d64..3d9468c9 100644 --- a/src/config/official_configs.py +++ b/src/config/official_configs.py @@ -102,10 +102,23 @@ class TalkRulesItem(ConfigBase): class ChatConfig(ConfigBase): """聊天配置类""" - talk_value: float = 1 + talk_value: float = Field( + default=1, + json_schema_extra={ + "x-widget": "slider", + "x-icon": "message-circle", + "step": 0.1, + }, + ) """聊天频率,越小越沉默,范围0-1""" - mentioned_bot_reply: bool = True + mentioned_bot_reply: bool = Field( + default=True, + json_schema_extra={ + "x-widget": "switch", + "x-icon": "at-sign", + }, + ) """是否启用提及必回复""" max_context_size: int = 30 @@ -114,7 +127,13 @@ class ChatConfig(ConfigBase): planner_smooth: float = 3 """规划器平滑,增大数值会减小planner负荷,略微降低反应速度,推荐1-5,0为关闭,必须大于等于0""" - think_mode: Literal["classic", "deep", "dynamic"] = "dynamic" + think_mode: Literal["classic", "deep", "dynamic"] = Field( + default="dynamic", + json_schema_extra={ + "x-widget": "select", + "x-icon": "brain", + }, + ) """ 思考模式配置 - classic: 默认think_level为0(轻量回复,不需要思考和回忆) From 19c9c5a39a4831bbe08fb51db5a7c2a83e7d750e Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Tue, 17 Feb 2026 16:58:59 +0800 Subject: [PATCH 04/26] feat(webui): use get_class_field_docs for schema field descriptions --- src/webui/config_schema.py | 121 +++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 src/webui/config_schema.py diff --git a/src/webui/config_schema.py b/src/webui/config_schema.py new file mode 100644 index 00000000..58f22876 --- /dev/null +++ b/src/webui/config_schema.py @@ -0,0 +1,121 @@ +import inspect +from typing import Any, get_args, get_origin + +from pydantic_core import PydanticUndefined + +from src.config.config_base import ConfigBase + + +class ConfigSchemaGenerator: + @classmethod + def generate_schema(cls, config_class: type[ConfigBase], include_nested: bool = True) -> dict[str, Any]: + return cls.generate_config_schema(config_class, include_nested=include_nested) + + @classmethod + def generate_config_schema(cls, config_class: type[ConfigBase], include_nested: bool = True) -> dict[str, Any]: + fields: list[dict[str, Any]] = [] + nested: dict[str, dict[str, Any]] = {} + + for field_name, field_info in config_class.model_fields.items(): + if field_name in {"field_docs", "_validate_any", "suppress_any_warning"}: + continue + + field_schema = cls._build_field_schema(config_class, field_name, field_info.annotation, field_info) + fields.append(field_schema) + + if include_nested: + nested_schema = cls._build_nested_schema(field_info.annotation) + if nested_schema is not None: + nested[field_name] = nested_schema + + return { + "className": config_class.__name__, + "classDoc": (config_class.__doc__ or "").strip(), + "fields": fields, + "nested": nested, + } + + @classmethod + def _build_nested_schema(cls, annotation: Any) -> dict[str, Any] | None: + origin = get_origin(annotation) + args = get_args(annotation) + + if inspect.isclass(annotation) and issubclass(annotation, ConfigBase): + return cls.generate_config_schema(annotation) + + if origin in {list, tuple} and args: + first = args[0] + if inspect.isclass(first) and issubclass(first, ConfigBase): + return cls.generate_config_schema(first) + + return None + + @classmethod + def _build_field_schema( + cls, config_class: type[ConfigBase], field_name: str, annotation: Any, field_info: Any + ) -> dict[str, Any]: + field_docs = config_class.get_class_field_docs() + field_type = cls._map_field_type(annotation) + schema: dict[str, Any] = { + "name": field_name, + "type": field_type, + "label": field_name, + "description": field_docs.get(field_name, field_info.description or ""), + "required": field_info.is_required(), + } + + if field_info.default is not PydanticUndefined: + schema["default"] = field_info.default + + origin = get_origin(annotation) + args = get_args(annotation) + + if origin is list and args: + schema["items"] = {"type": cls._map_field_type(args[0])} + + options = cls._extract_options(annotation) + if options: + schema["options"] = options + + return schema + + @staticmethod + def _extract_options(annotation: Any) -> list[str] | None: + origin = get_origin(annotation) + if origin is None: + return None + if str(origin) != "typing.Literal": + return None + + args = get_args(annotation) + options = [str(item) for item in args] + return options or None + + @classmethod + def _map_field_type(cls, annotation: Any) -> str: + origin = get_origin(annotation) + args = get_args(annotation) + + if origin in {list, tuple}: + return "array" + if inspect.isclass(annotation) and issubclass(annotation, ConfigBase): + return "object" + if annotation is bool: + return "boolean" + if annotation is int: + return "integer" + if annotation is float: + return "number" + if annotation is str: + return "string" + + if origin in {list, tuple} and args: + return "array" + + if origin in {dict}: + return "object" + + if origin is not None and str(origin) == "typing.Literal": + return "select" + + return "string" From 278a084c23fa0461ab86bab73fad9ee525337592 Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Tue, 17 Feb 2026 17:05:25 +0800 Subject: [PATCH 05/26] feat(webui): enhance ConfigSchemaGenerator with field_docs and UI metadata - Add AttrDocBase.get_class_field_docs() classmethod for class-level field docs extraction - Merge json_schema_extra (x-widget, x-icon, step) into schema output - Map Pydantic constraints (ge/le) to minValue/maxValue for frontend compatibility - Add ge=0, le=1 constraints to ChatConfig.talk_value for validation Completes Task 1 (including subtasks 1a, 1b, 1c, 1d) of webui-config-visualization-refactor plan. --- src/config/config_base.py | 14 ++++++++++++-- src/config/official_configs.py | 2 ++ src/webui/config_schema.py | 12 ++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/config/config_base.py b/src/config/config_base.py index 7baf1bd6..5e2d1827 100644 --- a/src/config/config_base.py +++ b/src/config/config_base.py @@ -5,7 +5,7 @@ import types from dataclasses import dataclass, field from pathlib import Path from pydantic import BaseModel, ConfigDict, Field -from typing import Union, get_args, get_origin, Tuple, Any, List, Dict, Set, Literal +from typing import Any, Dict, List, Literal, Set, Tuple, Union, cast, get_args, get_origin __all__ = ["ConfigBase", "Field", "AttributeData"] @@ -44,6 +44,16 @@ class AttrDocBase: # 从类定义节点中提取字段文档 return self._extract_field_docs(class_node, allow_extra_methods) + @classmethod + def get_class_field_docs(cls) -> dict[str, str]: + class_source = cls._get_class_source() + class_node = cls._find_class_node(class_source) + return AttrDocBase._extract_field_docs( + cast(AttrDocBase, cast(Any, cls)), + class_node, + allow_extra_methods=False, + ) + @classmethod def _get_class_source(cls) -> str: """获取类定义所在文件的完整源代码""" @@ -265,7 +275,7 @@ class ConfigBase(BaseModel, AttrDocBase): if origin_type in (int, float, str, bool, complex, bytes, Any): continue # 允许嵌套的ConfigBase自定义类 - if inspect.isclass(origin_type) and issubclass(origin_type, ConfigBase): # type: ignore + if isinstance(origin_type, type) and issubclass(cast(type, origin_type), ConfigBase): continue # 只允许 list, set, dict 三类泛型 if origin_type not in (list, set, dict, List, Set, Dict, Literal): diff --git a/src/config/official_configs.py b/src/config/official_configs.py index 3d9468c9..868ae4c2 100644 --- a/src/config/official_configs.py +++ b/src/config/official_configs.py @@ -104,6 +104,8 @@ class ChatConfig(ConfigBase): talk_value: float = Field( default=1, + ge=0, + le=1, json_schema_extra={ "x-widget": "slider", "x-icon": "message-circle", diff --git a/src/webui/config_schema.py b/src/webui/config_schema.py index 58f22876..711b18a8 100644 --- a/src/webui/config_schema.py +++ b/src/webui/config_schema.py @@ -77,6 +77,18 @@ class ConfigSchemaGenerator: if options: schema["options"] = options + # Task 1c: Merge json_schema_extra (x-widget, x-icon, step, etc.) + if hasattr(field_info, "json_schema_extra") and field_info.json_schema_extra: + schema.update(field_info.json_schema_extra) + + # Task 1d: Map Pydantic constraints to minValue/maxValue (frontend naming convention) + if hasattr(field_info, "metadata") and field_info.metadata: + for constraint in field_info.metadata: + if hasattr(constraint, "ge"): + schema["minValue"] = constraint.ge + if hasattr(constraint, "le"): + schema["maxValue"] = constraint.le + return schema @staticmethod From 5879164bfe6b8dd45fffabb80fc84667a9501607 Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Tue, 17 Feb 2026 17:09:07 +0800 Subject: [PATCH 06/26] feat(config): add UI metadata to remaining ChatConfig fields (Wave 2) - plan_reply_log_max_per_chat: input widget + file-text icon - llm_quote: switch widget + quote icon - enable_talk_value_rules: switch widget + settings icon - talk_value_rules: custom widget + list icon All ChatConfig fields now have json_schema_extra metadata for complete UI visualization support. --- src/config/official_configs.py | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/src/config/official_configs.py b/src/config/official_configs.py index 868ae4c2..8643a636 100644 --- a/src/config/official_configs.py +++ b/src/config/official_configs.py @@ -143,20 +143,42 @@ class ChatConfig(ConfigBase): - dynamic: think_level由planner动态给出(根据planner返回的think_level决定) """ - plan_reply_log_max_per_chat: int = 1024 + plan_reply_log_max_per_chat: int = Field( + default=1024, + json_schema_extra={ + "x-widget": "input", + "x-icon": "file-text", + }, + ) """每个聊天流最大保存的Plan/Reply日志数量,超过此数量时会自动删除最老的日志""" - llm_quote: bool = False + llm_quote: bool = Field( + default=False, + json_schema_extra={ + "x-widget": "switch", + "x-icon": "quote", + }, + ) """是否在 reply action 中启用 quote 参数,启用后 LLM 可以控制是否引用消息""" - enable_talk_value_rules: bool = True + enable_talk_value_rules: bool = Field( + default=True, + json_schema_extra={ + "x-widget": "switch", + "x-icon": "settings", + }, + ) """是否启用动态发言频率规则""" talk_value_rules: list[TalkRulesItem] = Field( default_factory=lambda: [ TalkRulesItem(platform="", item_id="", rule_type="group", time="00:00-08:59", value=0.8), TalkRulesItem(platform="", item_id="", rule_type="group", time="09:00-18:59", value=1.0), - ] + ], + json_schema_extra={ + "x-widget": "custom", + "x-icon": "list", + }, ) """ _wrap_思考频率规则列表,支持按聊天流/按日内时段配置。 From 2962a9534162dc88b5c06a00c0bdc2686232654f Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Tue, 17 Feb 2026 17:14:41 +0800 Subject: [PATCH 07/26] feat(dashboard): create DynamicField renderer component - Render fields based on x-widget or type - Support slider, switch, textarea, select, custom widgets - Include label, icon, description rendering - Placeholder for unsupported types (array, object) Completes Task 5 of webui-config-visualization-refactor plan. --- .../components/dynamic-form/DynamicField.tsx | 246 ++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 dashboard/src/components/dynamic-form/DynamicField.tsx diff --git a/dashboard/src/components/dynamic-form/DynamicField.tsx b/dashboard/src/components/dynamic-form/DynamicField.tsx new file mode 100644 index 00000000..de9d550c --- /dev/null +++ b/dashboard/src/components/dynamic-form/DynamicField.tsx @@ -0,0 +1,246 @@ +import * as React from "react" +import * as LucideIcons from "lucide-react" + +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Slider } from "@/components/ui/slider" +import { Switch } from "@/components/ui/switch" +import { Textarea } from "@/components/ui/textarea" +import type { FieldSchema } from "@/types/config-schema" + +export interface DynamicFieldProps { + schema: FieldSchema + value: unknown + onChange: (value: unknown) => void + // eslint-disable-next-line @typescript-eslint/no-unused-vars + fieldPath?: string // 用于 Hook 系统(未来使用) +} + +/** + * DynamicField - 根据字段类型和 x-widget 渲染对应的 shadcn/ui 组件 + * + * 渲染逻辑: + * 1. x-widget 优先:如果 schema 有 x-widget,使用对应组件 + * 2. type 回退:如果没有 x-widget,根据 type 选择默认组件 + */ +export const DynamicField: React.FC = ({ + schema, + value, + onChange, +}) => { + /** + * 渲染字段图标 + */ + const renderIcon = () => { + if (!schema['x-icon']) return null + + const IconComponent = (LucideIcons as any)[schema['x-icon']] + if (!IconComponent) return null + + return + } + + /** + * 根据 x-widget 或 type 选择并渲染对应的输入组件 + */ + const renderInputComponent = () => { + const widget = schema['x-widget'] + const type = schema.type + + // x-widget 优先 + if (widget) { + switch (widget) { + case 'slider': + return renderSlider() + case 'switch': + return renderSwitch() + case 'textarea': + return renderTextarea() + case 'select': + return renderSelect() + case 'custom': + return ( +
+ Custom field requires Hook +
+ ) + default: + // 未知的 x-widget,回退到 type + break + } + } + + // type 回退 + switch (type) { + case 'boolean': + return renderSwitch() + case 'number': + case 'integer': + return renderNumberInput() + case 'string': + return renderTextInput() + case 'select': + return renderSelect() + case 'array': + return ( +
+ Array fields not yet supported +
+ ) + case 'object': + return ( +
+ Object fields not yet supported +
+ ) + case 'textarea': + return renderTextarea() + default: + return ( +
+ Unknown field type: {type} +
+ ) + } + } + + /** + * 渲染 Switch 组件(用于 boolean 类型) + */ + const renderSwitch = () => { + const checked = Boolean(value) + return ( + onChange(checked)} + /> + ) + } + + /** + * 渲染 Slider 组件(用于 number 类型 + x-widget: slider) + */ + const renderSlider = () => { + const numValue = typeof value === 'number' ? value : (schema.default as number ?? 0) + const min = schema.minValue ?? 0 + const max = schema.maxValue ?? 100 + const step = schema.step ?? 1 + + return ( +
+ onChange(values[0])} + min={min} + max={max} + step={step} + /> +
+ {min} + {numValue} + {max} +
+
+ ) + } + + /** + * 渲染 Input[type="number"] 组件(用于 number/integer 类型) + */ + const renderNumberInput = () => { + const numValue = typeof value === 'number' ? value : (schema.default as number ?? 0) + const min = schema.minValue + const max = schema.maxValue + const step = schema.step ?? (schema.type === 'integer' ? 1 : 0.1) + + return ( + onChange(parseFloat(e.target.value) || 0)} + min={min} + max={max} + step={step} + /> + ) + } + + /** + * 渲染 Input[type="text"] 组件(用于 string 类型) + */ + const renderTextInput = () => { + const strValue = typeof value === 'string' ? value : (schema.default as string ?? '') + return ( + onChange(e.target.value)} + /> + ) + } + + /** + * 渲染 Textarea 组件(用于 textarea 类型或 x-widget: textarea) + */ + const renderTextarea = () => { + const strValue = typeof value === 'string' ? value : (schema.default as string ?? '') + return ( +