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.
This commit is contained in:
@@ -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<string, unknown>
|
||||
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<DynamicConfigFormProps> = ({
|
||||
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<DynamicConfigFormProps> = ({
|
||||
fieldPath={fieldPath}
|
||||
value={values[field.name]}
|
||||
onChange={(v) => onChange(field.name, v)}
|
||||
schema={field}
|
||||
/>
|
||||
)
|
||||
} else {
|
||||
@@ -58,6 +82,7 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
|
||||
fieldPath={fieldPath}
|
||||
value={values[field.name]}
|
||||
onChange={(v) => onChange(field.name, v)}
|
||||
schema={field}
|
||||
>
|
||||
<DynamicField
|
||||
schema={field}
|
||||
@@ -81,34 +106,146 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
/** 渲染 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 <IconComponent className="h-5 w-5 text-muted-foreground" />
|
||||
}
|
||||
|
||||
// 过滤出不属于 nested 的顶层字段
|
||||
const topLevelFields = schema.fields.filter(
|
||||
(field) => !schema.nested?.[field.name]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-6">
|
||||
{/* 渲染顶层字段 */}
|
||||
{schema.fields.map((field) => (
|
||||
<div key={field.name}>{renderField(field)}</div>
|
||||
))}
|
||||
{topLevelFields.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{topLevelFields.map((field, index) => (
|
||||
<React.Fragment key={field.name}>
|
||||
{index > 0 && field.type !== 'boolean' && topLevelFields[index - 1]?.type !== 'boolean' && (
|
||||
<Separator className="my-1" />
|
||||
)}
|
||||
<div>{renderField(field)}</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 渲染嵌套 schema */}
|
||||
{schema.nested &&
|
||||
Object.entries(schema.nested).map(([key, nestedSchema]) => (
|
||||
<div key={key} className="mt-6 space-y-4">
|
||||
{/* 嵌套 schema 标题 */}
|
||||
<div className="border-b pb-2">
|
||||
<h3 className="text-lg font-semibold">{nestedSchema.className}</h3>
|
||||
{nestedSchema.classDoc && (
|
||||
<p className="text-sm text-muted-foreground">{nestedSchema.classDoc}</p>
|
||||
)}
|
||||
</div>
|
||||
Object.entries(schema.nested).map(([key, nestedSchema]) => {
|
||||
const nestedField = fieldMap.get(key)
|
||||
const nestedFieldPath = buildFieldPath(key)
|
||||
|
||||
{/* 递归渲染嵌套表单 */}
|
||||
<DynamicConfigForm
|
||||
schema={nestedSchema}
|
||||
values={(values[key] as Record<string, unknown>) || {}}
|
||||
onChange={(field, value) => onChange(`${key}.${field}`, value)}
|
||||
hooks={hooks}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
// Hook 系统处理
|
||||
if (hooks.has(nestedFieldPath)) {
|
||||
const hookEntry = hooks.get(nestedFieldPath)
|
||||
if (!hookEntry) return null
|
||||
|
||||
const HookComponent = hookEntry.component
|
||||
if (hookEntry.type === 'replace') {
|
||||
return (
|
||||
<div key={key}>
|
||||
<HookComponent
|
||||
fieldPath={nestedFieldPath}
|
||||
value={values[key]}
|
||||
onChange={(v) => onChange(key, v)}
|
||||
schema={nestedField ?? nestedSchema}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={key}>
|
||||
<HookComponent
|
||||
fieldPath={nestedFieldPath}
|
||||
value={values[key]}
|
||||
onChange={(v) => onChange(key, v)}
|
||||
schema={nestedField ?? nestedSchema}
|
||||
>
|
||||
<DynamicConfigForm
|
||||
schema={nestedSchema}
|
||||
values={(values[key] as Record<string, unknown>) || {}}
|
||||
onChange={(field, value) => onChange(`${key}.${field}`, value)}
|
||||
basePath={nestedFieldPath}
|
||||
hooks={hooks}
|
||||
level={level + 1}
|
||||
/>
|
||||
</HookComponent>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const sectionTitle =
|
||||
nestedSchema.uiLabel || nestedSchema.classDoc || nestedSchema.className
|
||||
const sectionDescription =
|
||||
nestedSchema.classDoc && nestedSchema.classDoc !== sectionTitle
|
||||
? nestedSchema.classDoc
|
||||
: undefined
|
||||
|
||||
// 一级嵌套:使用 Card 包裹,清晰的 section 边界
|
||||
if (level === 0) {
|
||||
return (
|
||||
<Card key={key}>
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{renderSectionIcon(nestedSchema.uiIcon)}
|
||||
<CardTitle className="text-lg">{sectionTitle}</CardTitle>
|
||||
</div>
|
||||
{sectionDescription && (
|
||||
<CardDescription>{sectionDescription}</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DynamicConfigForm
|
||||
schema={nestedSchema}
|
||||
values={(values[key] as Record<string, unknown>) || {}}
|
||||
onChange={(field, value) => onChange(`${key}.${field}`, value)}
|
||||
basePath={nestedFieldPath}
|
||||
hooks={hooks}
|
||||
level={level + 1}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// 二级及更深嵌套:使用左侧指示条 + 轻量分组
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="relative space-y-4 rounded-lg border-l-2 border-muted-foreground/20 pl-4 pt-1 pb-1"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{renderSectionIcon(nestedSchema.uiIcon)}
|
||||
<h4 className="text-sm font-semibold">{sectionTitle}</h4>
|
||||
</div>
|
||||
{sectionDescription && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{sectionDescription}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DynamicConfigForm
|
||||
schema={nestedSchema}
|
||||
values={(values[key] as Record<string, unknown>) || {}}
|
||||
onChange={(field, value) => onChange(`${key}.${field}`, value)}
|
||||
basePath={nestedFieldPath}
|
||||
hooks={hooks}
|
||||
level={level + 1}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<DynamicFieldProps> = ({
|
||||
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 (
|
||||
<Textarea
|
||||
value={textareaValue}
|
||||
onChange={(e) => {
|
||||
const nextItems = e.target.value
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.map((line) => {
|
||||
if (itemType === 'integer') {
|
||||
return parseInt(line, 10) || 0
|
||||
}
|
||||
if (itemType === 'number') {
|
||||
return parseFloat(line) || 0
|
||||
}
|
||||
if (itemType === 'boolean') {
|
||||
return line === 'true'
|
||||
}
|
||||
return line
|
||||
})
|
||||
onChange(nextItems)
|
||||
}}
|
||||
rows={Math.max(4, arrayValue.length || 4)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const renderObjectEditor = () => {
|
||||
const objectValue =
|
||||
value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {}
|
||||
|
||||
return (
|
||||
<KeyValueEditor
|
||||
value={objectValue}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染字段图标
|
||||
*/
|
||||
@@ -53,6 +105,12 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
|
||||
switch (widget) {
|
||||
case 'slider':
|
||||
return renderSlider()
|
||||
case 'input':
|
||||
return renderTextInput()
|
||||
case 'number':
|
||||
return renderNumberInput()
|
||||
case 'password':
|
||||
return renderTextInput('password')
|
||||
case 'switch':
|
||||
return renderSwitch()
|
||||
case 'textarea':
|
||||
@@ -60,6 +118,12 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
|
||||
case 'select':
|
||||
return renderSelect()
|
||||
case 'custom':
|
||||
if (type === 'array' && schema.items && schema.items.type !== 'object') {
|
||||
return renderPrimitiveArrayEditor()
|
||||
}
|
||||
if (type === 'object') {
|
||||
return renderObjectEditor()
|
||||
}
|
||||
return (
|
||||
<div className="rounded-md border border-dashed border-muted-foreground/25 bg-muted/10 p-4 text-center text-sm text-muted-foreground">
|
||||
Custom field requires Hook
|
||||
@@ -83,17 +147,16 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
|
||||
case 'select':
|
||||
return renderSelect()
|
||||
case 'array':
|
||||
return (
|
||||
<div className="rounded-md border border-dashed border-muted-foreground/25 bg-muted/10 p-4 text-center text-sm text-muted-foreground">
|
||||
Array fields not yet supported
|
||||
</div>
|
||||
)
|
||||
if (!schema.items || schema.items.type === 'object') {
|
||||
return (
|
||||
<div className="rounded-md border border-dashed border-muted-foreground/25 bg-muted/10 p-4 text-center text-sm text-muted-foreground">
|
||||
Complex array requires Hook
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return renderPrimitiveArrayEditor()
|
||||
case 'object':
|
||||
return (
|
||||
<div className="rounded-md border border-dashed border-muted-foreground/25 bg-muted/10 p-4 text-center text-sm text-muted-foreground">
|
||||
Object fields not yet supported
|
||||
</div>
|
||||
)
|
||||
return renderObjectEditor()
|
||||
case 'textarea':
|
||||
return renderTextarea()
|
||||
default:
|
||||
@@ -107,14 +170,27 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
|
||||
|
||||
/**
|
||||
* 渲染 Switch 组件(用于 boolean 类型)
|
||||
* 使用水平布局:标签+描述在左,开关在右
|
||||
*/
|
||||
const renderSwitch = () => {
|
||||
const checked = Boolean(value)
|
||||
return (
|
||||
<Switch
|
||||
checked={checked}
|
||||
onCheckedChange={(checked) => onChange(checked)}
|
||||
/>
|
||||
<div className="flex items-center justify-between rounded-lg border p-3 sm:p-4">
|
||||
<div className="space-y-0.5 pr-4">
|
||||
<Label className="text-sm font-medium flex items-center gap-2">
|
||||
{renderIcon()}
|
||||
{schema.label}
|
||||
{schema.required && <span className="text-destructive">*</span>}
|
||||
</Label>
|
||||
{schema.description && (
|
||||
<p className="text-[13px] text-muted-foreground">{schema.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onCheckedChange={(checked) => onChange(checked)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -169,11 +245,11 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
|
||||
/**
|
||||
* 渲染 Input[type="text"] 组件(用于 string 类型)
|
||||
*/
|
||||
const renderTextInput = () => {
|
||||
const renderTextInput = (type: 'password' | 'text' = 'text') => {
|
||||
const strValue = typeof value === 'string' ? value : (schema.default as string ?? '')
|
||||
return (
|
||||
<Input
|
||||
type="text"
|
||||
type={type}
|
||||
value={strValue}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
@@ -225,6 +301,16 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
// 判断当前字段是否为 Switch/Boolean 类型(独立处理布局)
|
||||
const isBoolean =
|
||||
schema['x-widget'] === 'switch' ||
|
||||
(!schema['x-widget'] && schema.type === 'boolean')
|
||||
|
||||
// Switch/Boolean 字段自带完整布局,直接返回
|
||||
if (isBoolean) {
|
||||
return renderInputComponent()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Label with icon */}
|
||||
@@ -239,7 +325,7 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
|
||||
|
||||
{/* Description */}
|
||||
{schema.description && (
|
||||
<p className="text-sm text-muted-foreground">{schema.description}</p>
|
||||
<p className="text-[13px] text-muted-foreground">{schema.description}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -85,7 +85,6 @@ describe('DynamicConfigForm', () => {
|
||||
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} />)
|
||||
|
||||
expect(screen.getByText('Top Field')).toBeInTheDocument()
|
||||
expect(screen.getByText('SubConfig')).toBeInTheDocument()
|
||||
expect(screen.getByText('Sub configuration')).toBeInTheDocument()
|
||||
expect(screen.getByText('Nested Field')).toBeInTheDocument()
|
||||
})
|
||||
@@ -305,6 +304,71 @@ describe('DynamicConfigForm', () => {
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('hooked_field', 'hook_value')
|
||||
})
|
||||
|
||||
it('renders nested Hook component with full field path', async () => {
|
||||
const NestedHookComponent: React.FC<FieldHookComponentProps> = ({ fieldPath, onChange }) => {
|
||||
return (
|
||||
<button onClick={() => onChange?.([{ enabled: true }])}>
|
||||
{fieldPath}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const hooks = new FieldHookRegistry()
|
||||
hooks.register('mcp.servers', NestedHookComponent, 'replace')
|
||||
|
||||
const schema: ConfigSchema = {
|
||||
className: 'RootConfig',
|
||||
classDoc: 'Root configuration',
|
||||
fields: [],
|
||||
nested: {
|
||||
mcp: {
|
||||
className: 'MCPConfig',
|
||||
classDoc: 'MCP 配置',
|
||||
fields: [
|
||||
{
|
||||
name: 'enable',
|
||||
type: 'boolean',
|
||||
label: '启用 MCP',
|
||||
description: '是否启用 MCP',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'servers',
|
||||
type: 'array',
|
||||
label: '服务器列表',
|
||||
description: '复杂对象数组',
|
||||
required: false,
|
||||
items: {
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
],
|
||||
nested: {
|
||||
servers: {
|
||||
className: 'MCPServerItemConfig',
|
||||
classDoc: 'MCP 服务器项',
|
||||
fields: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
const values = {
|
||||
mcp: {
|
||||
enable: true,
|
||||
servers: [],
|
||||
},
|
||||
}
|
||||
const onChange = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} hooks={hooks} />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'mcp.servers' }))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('mcp.servers', [{ enabled: true }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
@@ -334,7 +398,7 @@ describe('DynamicConfigForm', () => {
|
||||
|
||||
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} />)
|
||||
|
||||
expect(screen.getByText('SubConfig')).toBeInTheDocument()
|
||||
expect(screen.getByText('Sub configuration')).toBeInTheDocument()
|
||||
expect(screen.getByText('Nested Field')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@@ -207,22 +207,25 @@ describe('DynamicField', () => {
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders placeholder for array type', () => {
|
||||
it('renders textarea editor for primitive array type', () => {
|
||||
const schema: FieldSchema = {
|
||||
name: 'test_array',
|
||||
type: 'array',
|
||||
label: 'Test Array',
|
||||
description: 'A test array',
|
||||
required: false,
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
}
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(<DynamicField schema={schema} value={[]} onChange={onChange} />)
|
||||
render(<DynamicField schema={schema} value={['a', 'b']} onChange={onChange} />)
|
||||
|
||||
expect(screen.getByText('Array fields not yet supported')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox')).toHaveValue('a\nb')
|
||||
})
|
||||
|
||||
it('renders placeholder for object type', () => {
|
||||
it('renders key-value editor for object type', () => {
|
||||
const schema: FieldSchema = {
|
||||
name: 'test_object',
|
||||
type: 'object',
|
||||
@@ -232,9 +235,10 @@ describe('DynamicField', () => {
|
||||
}
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(<DynamicField schema={schema} value={{}} onChange={onChange} />)
|
||||
render(<DynamicField schema={schema} value={{ foo: 'bar' }} onChange={onChange} />)
|
||||
|
||||
expect(screen.getByText('Object fields not yet supported')).toBeInTheDocument()
|
||||
expect(screen.getByText('可视化编辑')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('foo')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user