From aea87e18f1bfbcbe1b652d3a76b3ae985d8d7c7d Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Fri, 3 Apr 2026 02:46:07 +0800 Subject: [PATCH] feat: enhance bot configuration with new sections and JSON field hooks - Added support for new configuration sections: relationship, database, maisaka, mcp, and plugin_runtime. - Introduced complex field hooks for handling JSON configurations in chat talk value rules, expression learning lists, and more. - Updated the field hooks to include schema metadata for better UI representation. - Refactored the bot configuration page to utilize a more dynamic approach for managing section values and state. - Improved the configuration schema generation to ensure all top-level sections have UI metadata. - Added tests to validate the new configuration schema and ensure proper functionality of the JSON field hooks. --- .../dynamic-form/DynamicConfigForm.tsx | 185 +++++- .../components/dynamic-form/DynamicField.tsx | 120 +++- .../__tests__/DynamicConfigForm.test.tsx | 68 ++- .../__tests__/DynamicField.test.tsx | 16 +- dashboard/src/lib/field-hooks.ts | 2 + dashboard/src/routes/config/bot.tsx | 560 ++++++++++-------- .../config/bot/hooks/JsonFieldHookFactory.tsx | 103 ++++ .../config/bot/hooks/complexFieldHooks.tsx | 49 ++ .../src/routes/config/bot/hooks/index.ts | 10 + dashboard/src/routes/config/bot/types.ts | 5 + dashboard/src/types/config-schema.ts | 10 +- pytests/webui/test_config_schema.py | 52 +- src/config/official_configs.py | 1 - src/webui/config_schema.py | 11 +- src/webui/routers/websocket/__init__.py | 10 +- 15 files changed, 881 insertions(+), 321 deletions(-) create mode 100644 dashboard/src/routes/config/bot/hooks/JsonFieldHookFactory.tsx create mode 100644 dashboard/src/routes/config/bot/hooks/complexFieldHooks.tsx diff --git a/dashboard/src/components/dynamic-form/DynamicConfigForm.tsx b/dashboard/src/components/dynamic-form/DynamicConfigForm.tsx index cd04b78f..abb377c2 100644 --- a/dashboard/src/components/dynamic-form/DynamicConfigForm.tsx +++ b/dashboard/src/components/dynamic-form/DynamicConfigForm.tsx @@ -1,5 +1,14 @@ import * as React from 'react' +import * as LucideIcons from 'lucide-react' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Separator } from '@/components/ui/separator' import type { ConfigSchema, FieldSchema } from '@/types/config-schema' import { fieldHooks, type FieldHookRegistry } from '@/lib/field-hooks' @@ -9,7 +18,10 @@ export interface DynamicConfigFormProps { schema: ConfigSchema values: Record onChange: (field: string, value: unknown) => void + basePath?: string hooks?: FieldHookRegistry + /** 嵌套层级:0 = tab 内容层, 1 = section 内容层, 2+ = 更深嵌套 */ + level?: number } /** @@ -19,21 +31,32 @@ export interface DynamicConfigFormProps { * 1. Hook 系统:通过 FieldHookRegistry 自定义字段渲染 * - replace 模式:完全替换默认渲染 * - wrapper 模式:包装默认渲染(通过 children 传递) - * 2. 嵌套 schema:递归渲染 schema.nested 中的子配置 + * 2. 嵌套 schema:递归渲染 schema.nested 中的子配置,使用 Card 容器区分层级 * 3. 默认渲染:使用 DynamicField 组件 */ export const DynamicConfigForm: React.FC = ({ schema, values, onChange, + basePath = '', hooks = fieldHooks, // 默认使用全局单例 + level = 0, }) => { + const fieldMap = React.useMemo( + () => new Map(schema.fields.map((field) => [field.name, field])), + [schema.fields] + ) + + const buildFieldPath = (fieldName: string) => { + return basePath ? `${basePath}.${fieldName}` : fieldName + } + /** * 渲染单个字段 * 检查是否有注册的 Hook,根据 Hook 类型选择渲染方式 */ const renderField = (field: FieldSchema) => { - const fieldPath = field.name + const fieldPath = buildFieldPath(field.name) // 检查是否有注册的 Hook if (hooks.has(fieldPath)) { @@ -49,6 +72,7 @@ export const DynamicConfigForm: React.FC = ({ fieldPath={fieldPath} value={values[field.name]} onChange={(v) => onChange(field.name, v)} + schema={field} /> ) } else { @@ -58,6 +82,7 @@ export const DynamicConfigForm: React.FC = ({ fieldPath={fieldPath} value={values[field.name]} onChange={(v) => onChange(field.name, v)} + schema={field} > = ({ ) } + /** 渲染 section 图标 */ + const renderSectionIcon = (iconName?: string) => { + if (!iconName) return null + const IconComponent = LucideIcons[iconName as keyof typeof LucideIcons] as + | React.ComponentType<{ className?: string }> + | undefined + if (!IconComponent) return null + return + } + + // 过滤出不属于 nested 的顶层字段 + const topLevelFields = schema.fields.filter( + (field) => !schema.nested?.[field.name] + ) + return ( -
+
{/* 渲染顶层字段 */} - {schema.fields.map((field) => ( -
{renderField(field)}
- ))} + {topLevelFields.length > 0 && ( +
+ {topLevelFields.map((field, index) => ( + + {index > 0 && field.type !== 'boolean' && topLevelFields[index - 1]?.type !== 'boolean' && ( + + )} +
{renderField(field)}
+
+ ))} +
+ )} {/* 渲染嵌套 schema */} {schema.nested && - Object.entries(schema.nested).map(([key, nestedSchema]) => ( -
- {/* 嵌套 schema 标题 */} -
-

{nestedSchema.className}

- {nestedSchema.classDoc && ( -

{nestedSchema.classDoc}

- )} -
+ Object.entries(schema.nested).map(([key, nestedSchema]) => { + const nestedField = fieldMap.get(key) + const nestedFieldPath = buildFieldPath(key) - {/* 递归渲染嵌套表单 */} - ) || {}} - onChange={(field, value) => onChange(`${key}.${field}`, value)} - hooks={hooks} - /> -
- ))} + // Hook 系统处理 + if (hooks.has(nestedFieldPath)) { + const hookEntry = hooks.get(nestedFieldPath) + if (!hookEntry) return null + + const HookComponent = hookEntry.component + if (hookEntry.type === 'replace') { + return ( +
+ onChange(key, v)} + schema={nestedField ?? nestedSchema} + /> +
+ ) + } + + return ( +
+ onChange(key, v)} + schema={nestedField ?? nestedSchema} + > + ) || {}} + onChange={(field, value) => onChange(`${key}.${field}`, value)} + basePath={nestedFieldPath} + hooks={hooks} + level={level + 1} + /> + +
+ ) + } + + const sectionTitle = + nestedSchema.uiLabel || nestedSchema.classDoc || nestedSchema.className + const sectionDescription = + nestedSchema.classDoc && nestedSchema.classDoc !== sectionTitle + ? nestedSchema.classDoc + : undefined + + // 一级嵌套:使用 Card 包裹,清晰的 section 边界 + if (level === 0) { + return ( + + +
+ {renderSectionIcon(nestedSchema.uiIcon)} + {sectionTitle} +
+ {sectionDescription && ( + {sectionDescription} + )} +
+ + ) || {}} + onChange={(field, value) => onChange(`${key}.${field}`, value)} + basePath={nestedFieldPath} + hooks={hooks} + level={level + 1} + /> + +
+ ) + } + + // 二级及更深嵌套:使用左侧指示条 + 轻量分组 + return ( +
+
+
+ {renderSectionIcon(nestedSchema.uiIcon)} +

{sectionTitle}

+
+ {sectionDescription && ( +

+ {sectionDescription} +

+ )} +
+ + ) || {}} + onChange={(field, value) => onChange(`${key}.${field}`, value)} + basePath={nestedFieldPath} + hooks={hooks} + level={level + 1} + /> +
+ ) + })}
) } diff --git a/dashboard/src/components/dynamic-form/DynamicField.tsx b/dashboard/src/components/dynamic-form/DynamicField.tsx index 875ec721..b1e4dc59 100644 --- a/dashboard/src/components/dynamic-form/DynamicField.tsx +++ b/dashboard/src/components/dynamic-form/DynamicField.tsx @@ -2,6 +2,7 @@ import * as React from "react" import * as LucideIcons from "lucide-react" import { Input } from "@/components/ui/input" +import { KeyValueEditor } from "@/components/ui/key-value-editor" import { Label } from "@/components/ui/label" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Slider } from "@/components/ui/slider" @@ -29,6 +30,57 @@ export const DynamicField: React.FC = ({ value, onChange, }) => { + const renderPrimitiveArrayEditor = () => { + const itemType = schema.items?.type ?? 'string' + const arrayValue = Array.isArray(value) + ? value + : Array.isArray(schema.default) + ? schema.default + : [] + + const textareaValue = arrayValue.map((item) => String(item ?? '')).join('\n') + + return ( +