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 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 type { ConfigSchema, FieldSchema } from '@/types/config-schema'
|
||||||
import { fieldHooks, type FieldHookRegistry } from '@/lib/field-hooks'
|
import { fieldHooks, type FieldHookRegistry } from '@/lib/field-hooks'
|
||||||
|
|
||||||
@@ -9,7 +18,10 @@ export interface DynamicConfigFormProps {
|
|||||||
schema: ConfigSchema
|
schema: ConfigSchema
|
||||||
values: Record<string, unknown>
|
values: Record<string, unknown>
|
||||||
onChange: (field: string, value: unknown) => void
|
onChange: (field: string, value: unknown) => void
|
||||||
|
basePath?: string
|
||||||
hooks?: FieldHookRegistry
|
hooks?: FieldHookRegistry
|
||||||
|
/** 嵌套层级:0 = tab 内容层, 1 = section 内容层, 2+ = 更深嵌套 */
|
||||||
|
level?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,21 +31,32 @@ export interface DynamicConfigFormProps {
|
|||||||
* 1. Hook 系统:通过 FieldHookRegistry 自定义字段渲染
|
* 1. Hook 系统:通过 FieldHookRegistry 自定义字段渲染
|
||||||
* - replace 模式:完全替换默认渲染
|
* - replace 模式:完全替换默认渲染
|
||||||
* - wrapper 模式:包装默认渲染(通过 children 传递)
|
* - wrapper 模式:包装默认渲染(通过 children 传递)
|
||||||
* 2. 嵌套 schema:递归渲染 schema.nested 中的子配置
|
* 2. 嵌套 schema:递归渲染 schema.nested 中的子配置,使用 Card 容器区分层级
|
||||||
* 3. 默认渲染:使用 DynamicField 组件
|
* 3. 默认渲染:使用 DynamicField 组件
|
||||||
*/
|
*/
|
||||||
export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
|
export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
|
||||||
schema,
|
schema,
|
||||||
values,
|
values,
|
||||||
onChange,
|
onChange,
|
||||||
|
basePath = '',
|
||||||
hooks = fieldHooks, // 默认使用全局单例
|
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 类型选择渲染方式
|
* 检查是否有注册的 Hook,根据 Hook 类型选择渲染方式
|
||||||
*/
|
*/
|
||||||
const renderField = (field: FieldSchema) => {
|
const renderField = (field: FieldSchema) => {
|
||||||
const fieldPath = field.name
|
const fieldPath = buildFieldPath(field.name)
|
||||||
|
|
||||||
// 检查是否有注册的 Hook
|
// 检查是否有注册的 Hook
|
||||||
if (hooks.has(fieldPath)) {
|
if (hooks.has(fieldPath)) {
|
||||||
@@ -49,6 +72,7 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
|
|||||||
fieldPath={fieldPath}
|
fieldPath={fieldPath}
|
||||||
value={values[field.name]}
|
value={values[field.name]}
|
||||||
onChange={(v) => onChange(field.name, v)}
|
onChange={(v) => onChange(field.name, v)}
|
||||||
|
schema={field}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -58,6 +82,7 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
|
|||||||
fieldPath={fieldPath}
|
fieldPath={fieldPath}
|
||||||
value={values[field.name]}
|
value={values[field.name]}
|
||||||
onChange={(v) => onChange(field.name, v)}
|
onChange={(v) => onChange(field.name, v)}
|
||||||
|
schema={field}
|
||||||
>
|
>
|
||||||
<DynamicField
|
<DynamicField
|
||||||
schema={field}
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-6">
|
||||||
{/* 渲染顶层字段 */}
|
{/* 渲染顶层字段 */}
|
||||||
{schema.fields.map((field) => (
|
{topLevelFields.length > 0 && (
|
||||||
<div key={field.name}>{renderField(field)}</div>
|
<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 */}
|
||||||
{schema.nested &&
|
{schema.nested &&
|
||||||
Object.entries(schema.nested).map(([key, nestedSchema]) => (
|
Object.entries(schema.nested).map(([key, nestedSchema]) => {
|
||||||
<div key={key} className="mt-6 space-y-4">
|
const nestedField = fieldMap.get(key)
|
||||||
{/* 嵌套 schema 标题 */}
|
const nestedFieldPath = buildFieldPath(key)
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* 递归渲染嵌套表单 */}
|
// Hook 系统处理
|
||||||
<DynamicConfigForm
|
if (hooks.has(nestedFieldPath)) {
|
||||||
schema={nestedSchema}
|
const hookEntry = hooks.get(nestedFieldPath)
|
||||||
values={(values[key] as Record<string, unknown>) || {}}
|
if (!hookEntry) return null
|
||||||
onChange={(field, value) => onChange(`${key}.${field}`, value)}
|
|
||||||
hooks={hooks}
|
const HookComponent = hookEntry.component
|
||||||
/>
|
if (hookEntry.type === 'replace') {
|
||||||
</div>
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import * as React from "react"
|
|||||||
import * as LucideIcons from "lucide-react"
|
import * as LucideIcons from "lucide-react"
|
||||||
|
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { KeyValueEditor } from "@/components/ui/key-value-editor"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { Slider } from "@/components/ui/slider"
|
import { Slider } from "@/components/ui/slider"
|
||||||
@@ -29,6 +30,57 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
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) {
|
switch (widget) {
|
||||||
case 'slider':
|
case 'slider':
|
||||||
return renderSlider()
|
return renderSlider()
|
||||||
|
case 'input':
|
||||||
|
return renderTextInput()
|
||||||
|
case 'number':
|
||||||
|
return renderNumberInput()
|
||||||
|
case 'password':
|
||||||
|
return renderTextInput('password')
|
||||||
case 'switch':
|
case 'switch':
|
||||||
return renderSwitch()
|
return renderSwitch()
|
||||||
case 'textarea':
|
case 'textarea':
|
||||||
@@ -60,6 +118,12 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
|
|||||||
case 'select':
|
case 'select':
|
||||||
return renderSelect()
|
return renderSelect()
|
||||||
case 'custom':
|
case 'custom':
|
||||||
|
if (type === 'array' && schema.items && schema.items.type !== 'object') {
|
||||||
|
return renderPrimitiveArrayEditor()
|
||||||
|
}
|
||||||
|
if (type === 'object') {
|
||||||
|
return renderObjectEditor()
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border border-dashed border-muted-foreground/25 bg-muted/10 p-4 text-center text-sm text-muted-foreground">
|
<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
|
Custom field requires Hook
|
||||||
@@ -83,17 +147,16 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
|
|||||||
case 'select':
|
case 'select':
|
||||||
return renderSelect()
|
return renderSelect()
|
||||||
case 'array':
|
case 'array':
|
||||||
return (
|
if (!schema.items || schema.items.type === 'object') {
|
||||||
<div className="rounded-md border border-dashed border-muted-foreground/25 bg-muted/10 p-4 text-center text-sm text-muted-foreground">
|
return (
|
||||||
Array fields not yet supported
|
<div className="rounded-md border border-dashed border-muted-foreground/25 bg-muted/10 p-4 text-center text-sm text-muted-foreground">
|
||||||
</div>
|
Complex array requires Hook
|
||||||
)
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return renderPrimitiveArrayEditor()
|
||||||
case 'object':
|
case 'object':
|
||||||
return (
|
return renderObjectEditor()
|
||||||
<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>
|
|
||||||
)
|
|
||||||
case 'textarea':
|
case 'textarea':
|
||||||
return renderTextarea()
|
return renderTextarea()
|
||||||
default:
|
default:
|
||||||
@@ -107,14 +170,27 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 渲染 Switch 组件(用于 boolean 类型)
|
* 渲染 Switch 组件(用于 boolean 类型)
|
||||||
|
* 使用水平布局:标签+描述在左,开关在右
|
||||||
*/
|
*/
|
||||||
const renderSwitch = () => {
|
const renderSwitch = () => {
|
||||||
const checked = Boolean(value)
|
const checked = Boolean(value)
|
||||||
return (
|
return (
|
||||||
<Switch
|
<div className="flex items-center justify-between rounded-lg border p-3 sm:p-4">
|
||||||
checked={checked}
|
<div className="space-y-0.5 pr-4">
|
||||||
onCheckedChange={(checked) => onChange(checked)}
|
<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 类型)
|
* 渲染 Input[type="text"] 组件(用于 string 类型)
|
||||||
*/
|
*/
|
||||||
const renderTextInput = () => {
|
const renderTextInput = (type: 'password' | 'text' = 'text') => {
|
||||||
const strValue = typeof value === 'string' ? value : (schema.default as string ?? '')
|
const strValue = typeof value === 'string' ? value : (schema.default as string ?? '')
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type={type}
|
||||||
value={strValue}
|
value={strValue}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
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 (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{/* Label with icon */}
|
{/* Label with icon */}
|
||||||
@@ -239,7 +325,7 @@ export const DynamicField: React.FC<DynamicFieldProps> = ({
|
|||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
{schema.description && (
|
{schema.description && (
|
||||||
<p className="text-sm text-muted-foreground">{schema.description}</p>
|
<p className="text-[13px] text-muted-foreground">{schema.description}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -85,7 +85,6 @@ describe('DynamicConfigForm', () => {
|
|||||||
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} />)
|
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} />)
|
||||||
|
|
||||||
expect(screen.getByText('Top Field')).toBeInTheDocument()
|
expect(screen.getByText('Top Field')).toBeInTheDocument()
|
||||||
expect(screen.getByText('SubConfig')).toBeInTheDocument()
|
|
||||||
expect(screen.getByText('Sub configuration')).toBeInTheDocument()
|
expect(screen.getByText('Sub configuration')).toBeInTheDocument()
|
||||||
expect(screen.getByText('Nested Field')).toBeInTheDocument()
|
expect(screen.getByText('Nested Field')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
@@ -305,6 +304,71 @@ describe('DynamicConfigForm', () => {
|
|||||||
|
|
||||||
expect(onChange).toHaveBeenCalledWith('hooked_field', 'hook_value')
|
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', () => {
|
describe('edge cases', () => {
|
||||||
@@ -334,7 +398,7 @@ describe('DynamicConfigForm', () => {
|
|||||||
|
|
||||||
render(<DynamicConfigForm schema={schema} values={values} onChange={onChange} />)
|
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()
|
expect(screen.getByText('Nested Field')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -207,22 +207,25 @@ describe('DynamicField', () => {
|
|||||||
expect(screen.getByRole('combobox')).toBeInTheDocument()
|
expect(screen.getByRole('combobox')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders placeholder for array type', () => {
|
it('renders textarea editor for primitive array type', () => {
|
||||||
const schema: FieldSchema = {
|
const schema: FieldSchema = {
|
||||||
name: 'test_array',
|
name: 'test_array',
|
||||||
type: 'array',
|
type: 'array',
|
||||||
label: 'Test Array',
|
label: 'Test Array',
|
||||||
description: 'A test array',
|
description: 'A test array',
|
||||||
required: false,
|
required: false,
|
||||||
|
items: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
const onChange = vi.fn()
|
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 = {
|
const schema: FieldSchema = {
|
||||||
name: 'test_object',
|
name: 'test_object',
|
||||||
type: 'object',
|
type: 'object',
|
||||||
@@ -232,9 +235,10 @@ describe('DynamicField', () => {
|
|||||||
}
|
}
|
||||||
const onChange = vi.fn()
|
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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
import type { ConfigSchema, FieldSchema } from '@/types/config-schema'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook type for field-level customization
|
* Hook type for field-level customization
|
||||||
@@ -13,6 +14,7 @@ export interface FieldHookComponentProps {
|
|||||||
value: unknown
|
value: unknown
|
||||||
onChange?: (value: unknown) => void
|
onChange?: (value: unknown) => void
|
||||||
children?: ReactNode
|
children?: ReactNode
|
||||||
|
schema?: ConfigSchema | FieldSchema
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -27,55 +27,37 @@ import { RestartProvider, useRestart } from '@/lib/restart-context'
|
|||||||
import { Code2, Info, Layout, Power, Save } from 'lucide-react'
|
import { Code2, Info, Layout, Power, Save } from 'lucide-react'
|
||||||
|
|
||||||
import type { ConfigSchema } from '@/types/config-schema'
|
import type { ConfigSchema } from '@/types/config-schema'
|
||||||
import type {
|
|
||||||
BotConfig,
|
|
||||||
ChatConfig,
|
|
||||||
ChineseTypoConfig,
|
|
||||||
DebugConfig,
|
|
||||||
DreamConfig,
|
|
||||||
EmojiConfig,
|
|
||||||
ExperimentalConfig,
|
|
||||||
ExpressionConfig,
|
|
||||||
KeywordReactionConfig,
|
|
||||||
LogConfig,
|
|
||||||
LPMMKnowledgeConfig,
|
|
||||||
MaimMessageConfig,
|
|
||||||
MemoryConfig,
|
|
||||||
MessageReceiveConfig,
|
|
||||||
PersonalityConfig,
|
|
||||||
ResponsePostProcessConfig,
|
|
||||||
ResponseSplitterConfig,
|
|
||||||
TelemetryConfig,
|
|
||||||
ToolConfig,
|
|
||||||
VoiceConfig,
|
|
||||||
WebUIConfig,
|
|
||||||
} from './bot/types'
|
|
||||||
import { useAutoSave, useConfigAutoSave } from './bot/hooks'
|
|
||||||
import { ChatSectionHook } from './bot/hooks'
|
|
||||||
import {
|
import {
|
||||||
BotInfoSection,
|
ChatTalkValueRulesHook,
|
||||||
DebugSection,
|
ExperimentalChatPromptsHook,
|
||||||
DreamSection,
|
ExpressionGroupsHook,
|
||||||
ExperimentalSection,
|
ExpressionLearningListHook,
|
||||||
ExpressionSection,
|
KeywordRulesHook,
|
||||||
FeaturesSection,
|
MCPRootItemsHook,
|
||||||
LogSection,
|
MCPServersHook,
|
||||||
LPMMSection,
|
RegexRulesHook,
|
||||||
MaimMessageSection,
|
useAutoSave,
|
||||||
MessageReceiveSection,
|
useConfigAutoSave,
|
||||||
PersonalitySection,
|
} from './bot/hooks'
|
||||||
ProcessingSection,
|
|
||||||
TelemetrySection,
|
type ConfigSectionData = Record<string, unknown>
|
||||||
WebUISection,
|
|
||||||
} from './bot/sections'
|
|
||||||
// ==================== 常量定义 ====================
|
// ==================== 常量定义 ====================
|
||||||
/** Toast 显示前的延迟时间 (毫秒) */
|
/** Toast 显示前的延迟时间 (毫秒) */
|
||||||
const TOAST_DISPLAY_DELAY = 500
|
const TOAST_DISPLAY_DELAY = 500
|
||||||
|
|
||||||
/** Tab 标签页的首选排列顺序 (host field name) */
|
/** Tab 标签页的首选排列顺序 (host field name) */
|
||||||
const TAB_ORDER = [
|
const TAB_ORDER = [
|
||||||
'bot', 'personality', 'chat', 'expression', 'emoji',
|
'bot',
|
||||||
'response_post_process', 'dream', 'lpmm_knowledge', 'webui', 'debug',
|
'personality',
|
||||||
|
'chat',
|
||||||
|
'expression',
|
||||||
|
'emoji',
|
||||||
|
'response_post_process',
|
||||||
|
'lpmm_knowledge',
|
||||||
|
'webui',
|
||||||
|
'maisaka',
|
||||||
|
'plugin_runtime',
|
||||||
|
'debug',
|
||||||
]
|
]
|
||||||
|
|
||||||
// ==================== Tab 分组类型与构建 ====================
|
// ==================== Tab 分组类型与构建 ====================
|
||||||
@@ -88,30 +70,51 @@ interface TabGroup {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 从 schema 的 nested 字段解析出 tab 分组信息。
|
* 从 schema 的 nested 字段解析出 tab 分组信息。
|
||||||
* - 有 uiLabel 且无 uiParent → 独立 tab (host)
|
* - 有 uiLabel 且无 uiParent → 独立 tab
|
||||||
* - 有 uiParent → 归入对应 host tab 的 sections
|
* - 有 uiParent → 递归找到最终 host,并归入对应 tab
|
||||||
*/
|
*/
|
||||||
function buildTabGroupsFromSchema(schema: ConfigSchema): TabGroup[] {
|
function buildTabGroupsFromSchema(schema: ConfigSchema): TabGroup[] {
|
||||||
const nested = schema.nested || {}
|
const nested = schema.nested || {}
|
||||||
|
const nestedEntries = Object.entries(nested)
|
||||||
const hosts = new Map<string, TabGroup>()
|
const hosts = new Map<string, TabGroup>()
|
||||||
const children: Array<{ fieldName: string; parentId: string }> = []
|
|
||||||
|
|
||||||
for (const [fieldName, fieldSchema] of Object.entries(nested)) {
|
const resolveHostId = (fieldName: string, visited: Set<string> = new Set()): string | null => {
|
||||||
if (fieldSchema.uiLabel && !fieldSchema.uiParent) {
|
if (visited.has(fieldName)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldSchema = nested[fieldName]
|
||||||
|
if (!fieldSchema) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fieldSchema.uiParent) {
|
||||||
|
return fieldSchema.uiLabel && fieldSchema.uiIcon ? fieldName : null
|
||||||
|
}
|
||||||
|
|
||||||
|
visited.add(fieldName)
|
||||||
|
return resolveHostId(fieldSchema.uiParent, visited)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [fieldName, fieldSchema] of nestedEntries) {
|
||||||
|
if (fieldSchema.uiLabel && fieldSchema.uiIcon && !fieldSchema.uiParent) {
|
||||||
hosts.set(fieldName, {
|
hosts.set(fieldName, {
|
||||||
id: fieldName,
|
id: fieldName,
|
||||||
label: fieldSchema.uiLabel,
|
label: fieldSchema.uiLabel,
|
||||||
icon: fieldSchema.uiIcon || '',
|
icon: fieldSchema.uiIcon || '',
|
||||||
sections: [fieldName],
|
sections: [fieldName],
|
||||||
})
|
})
|
||||||
} else if (fieldSchema.uiParent) {
|
|
||||||
children.push({ fieldName, parentId: fieldSchema.uiParent })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const { fieldName, parentId } of children) {
|
for (const [fieldName] of nestedEntries) {
|
||||||
const parent = hosts.get(parentId)
|
const hostId = resolveHostId(fieldName)
|
||||||
if (parent) {
|
if (!hostId || hostId === fieldName) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = hosts.get(hostId)
|
||||||
|
if (parent && !parent.sections.includes(fieldName)) {
|
||||||
parent.sections.push(fieldName)
|
parent.sections.push(fieldName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,27 +150,29 @@ function BotConfigPageContent() {
|
|||||||
const { triggerRestart, isRestarting } = useRestart()
|
const { triggerRestart, isRestarting } = useRestart()
|
||||||
|
|
||||||
// 配置状态
|
// 配置状态
|
||||||
const [botConfig, setBotConfig] = useState<BotConfig | null>(null)
|
const [botConfig, setBotConfig] = useState<ConfigSectionData | null>(null)
|
||||||
const [personalityConfig, setPersonalityConfig] = useState<PersonalityConfig | null>(null)
|
const [personalityConfig, setPersonalityConfig] = useState<ConfigSectionData | null>(null)
|
||||||
const [chatConfig, setChatConfig] = useState<ChatConfig | null>(null)
|
const [chatConfig, setChatConfig] = useState<ConfigSectionData | null>(null)
|
||||||
const [expressionConfig, setExpressionConfig] = useState<ExpressionConfig | null>(null)
|
const [expressionConfig, setExpressionConfig] = useState<ConfigSectionData | null>(null)
|
||||||
const [emojiConfig, setEmojiConfig] = useState<EmojiConfig | null>(null)
|
const [emojiConfig, setEmojiConfig] = useState<ConfigSectionData | null>(null)
|
||||||
const [memoryConfig, setMemoryConfig] = useState<MemoryConfig | null>(null)
|
const [memoryConfig, setMemoryConfig] = useState<ConfigSectionData | null>(null)
|
||||||
const [toolConfig, setToolConfig] = useState<ToolConfig | null>(null)
|
const [relationshipConfig, setRelationshipConfig] = useState<ConfigSectionData | null>(null)
|
||||||
const [voiceConfig, setVoiceConfig] = useState<VoiceConfig | null>(null)
|
const [voiceConfig, setVoiceConfig] = useState<ConfigSectionData | null>(null)
|
||||||
const [messageReceiveConfig, setMessageReceiveConfig] = useState<MessageReceiveConfig | null>(null)
|
const [messageReceiveConfig, setMessageReceiveConfig] = useState<ConfigSectionData | null>(null)
|
||||||
const [dreamConfig, setDreamConfig] = useState<DreamConfig | null>(null)
|
const [lpmmConfig, setLpmmConfig] = useState<ConfigSectionData | null>(null)
|
||||||
const [lpmmConfig, setLpmmConfig] = useState<LPMMKnowledgeConfig | null>(null)
|
const [keywordReactionConfig, setKeywordReactionConfig] = useState<ConfigSectionData | null>(null)
|
||||||
const [keywordReactionConfig, setKeywordReactionConfig] = useState<KeywordReactionConfig | null>(null)
|
const [responsePostProcessConfig, setResponsePostProcessConfig] = useState<ConfigSectionData | null>(null)
|
||||||
const [responsePostProcessConfig, setResponsePostProcessConfig] = useState<ResponsePostProcessConfig | null>(null)
|
const [chineseTypoConfig, setChineseTypoConfig] = useState<ConfigSectionData | null>(null)
|
||||||
const [chineseTypoConfig, setChineseTypoConfig] = useState<ChineseTypoConfig | null>(null)
|
const [responseSplitterConfig, setResponseSplitterConfig] = useState<ConfigSectionData | null>(null)
|
||||||
const [responseSplitterConfig, setResponseSplitterConfig] = useState<ResponseSplitterConfig | null>(null)
|
const [debugConfig, setDebugConfig] = useState<ConfigSectionData | null>(null)
|
||||||
const [logConfig, setLogConfig] = useState<LogConfig | null>(null)
|
const [experimentalConfig, setExperimentalConfig] = useState<ConfigSectionData | null>(null)
|
||||||
const [debugConfig, setDebugConfig] = useState<DebugConfig | null>(null)
|
const [maimMessageConfig, setMaimMessageConfig] = useState<ConfigSectionData | null>(null)
|
||||||
const [experimentalConfig, setExperimentalConfig] = useState<ExperimentalConfig | null>(null)
|
const [telemetryConfig, setTelemetryConfig] = useState<ConfigSectionData | null>(null)
|
||||||
const [maimMessageConfig, setMaimMessageConfig] = useState<MaimMessageConfig | null>(null)
|
const [webuiConfig, setWebuiConfig] = useState<ConfigSectionData | null>(null)
|
||||||
const [telemetryConfig, setTelemetryConfig] = useState<TelemetryConfig | null>(null)
|
const [databaseConfig, setDatabaseConfig] = useState<ConfigSectionData | null>(null)
|
||||||
const [webuiConfig, setWebuiConfig] = useState<WebUIConfig | null>(null)
|
const [maisakaConfig, setMaisakaConfig] = useState<ConfigSectionData | null>(null)
|
||||||
|
const [mcpConfig, setMcpConfig] = useState<ConfigSectionData | null>(null)
|
||||||
|
const [pluginRuntimeConfig, setPluginRuntimeConfig] = useState<ConfigSectionData | null>(null)
|
||||||
|
|
||||||
// Schema 状态(用于动态 tab 分组)
|
// Schema 状态(用于动态 tab 分组)
|
||||||
const [configSchema, setConfigSchema] = useState<ConfigSchema | null>(null)
|
const [configSchema, setConfigSchema] = useState<ConfigSchema | null>(null)
|
||||||
@@ -242,34 +247,29 @@ function BotConfigPageContent() {
|
|||||||
const parseAndSetConfig = useCallback((config: Record<string, unknown>) => {
|
const parseAndSetConfig = useCallback((config: Record<string, unknown>) => {
|
||||||
configRef.current = config
|
configRef.current = config
|
||||||
|
|
||||||
setBotConfig(config.bot as BotConfig)
|
setBotConfig((config.bot ?? {}) as ConfigSectionData)
|
||||||
setPersonalityConfig(config.personality as PersonalityConfig)
|
setPersonalityConfig((config.personality ?? {}) as ConfigSectionData)
|
||||||
|
setChatConfig((config.chat ?? {}) as ConfigSectionData)
|
||||||
// 确保 chat 配置和 talk_value_rules 有默认值
|
setExpressionConfig((config.expression ?? {}) as ConfigSectionData)
|
||||||
const chatConfigData = (config.chat ?? {}) as ChatConfig
|
setEmojiConfig((config.emoji ?? {}) as ConfigSectionData)
|
||||||
if (!chatConfigData.talk_value_rules) {
|
setMemoryConfig((config.memory ?? {}) as ConfigSectionData)
|
||||||
chatConfigData.talk_value_rules = []
|
setRelationshipConfig((config.relationship ?? {}) as ConfigSectionData)
|
||||||
}
|
setVoiceConfig((config.voice ?? {}) as ConfigSectionData)
|
||||||
setChatConfig(chatConfigData)
|
setMessageReceiveConfig((config.message_receive ?? {}) as ConfigSectionData)
|
||||||
|
setLpmmConfig((config.lpmm_knowledge ?? {}) as ConfigSectionData)
|
||||||
setExpressionConfig(config.expression as ExpressionConfig)
|
setKeywordReactionConfig((config.keyword_reaction ?? {}) as ConfigSectionData)
|
||||||
setEmojiConfig(config.emoji as EmojiConfig)
|
setResponsePostProcessConfig((config.response_post_process ?? {}) as ConfigSectionData)
|
||||||
setMemoryConfig(config.memory as MemoryConfig)
|
setChineseTypoConfig((config.chinese_typo ?? {}) as ConfigSectionData)
|
||||||
setToolConfig(config.tool as ToolConfig)
|
setResponseSplitterConfig((config.response_splitter ?? {}) as ConfigSectionData)
|
||||||
setVoiceConfig(config.voice as VoiceConfig)
|
setDebugConfig((config.debug ?? {}) as ConfigSectionData)
|
||||||
setMessageReceiveConfig(config.message_receive as MessageReceiveConfig)
|
setExperimentalConfig((config.experimental ?? {}) as ConfigSectionData)
|
||||||
setDreamConfig(config.dream as DreamConfig)
|
setMaimMessageConfig((config.maim_message ?? {}) as ConfigSectionData)
|
||||||
setLpmmConfig(config.lpmm_knowledge as LPMMKnowledgeConfig)
|
setTelemetryConfig((config.telemetry ?? {}) as ConfigSectionData)
|
||||||
setKeywordReactionConfig(config.keyword_reaction as KeywordReactionConfig)
|
setWebuiConfig((config.webui ?? {}) as ConfigSectionData)
|
||||||
setResponsePostProcessConfig(config.response_post_process as ResponsePostProcessConfig)
|
setDatabaseConfig((config.database ?? {}) as ConfigSectionData)
|
||||||
setChineseTypoConfig(config.chinese_typo as ChineseTypoConfig)
|
setMaisakaConfig((config.maisaka ?? {}) as ConfigSectionData)
|
||||||
setResponseSplitterConfig(config.response_splitter as ResponseSplitterConfig)
|
setMcpConfig((config.mcp ?? {}) as ConfigSectionData)
|
||||||
setLogConfig(config.log as LogConfig)
|
setPluginRuntimeConfig((config.plugin_runtime ?? {}) as ConfigSectionData)
|
||||||
setDebugConfig(config.debug as DebugConfig)
|
|
||||||
setExperimentalConfig(config.experimental as ExperimentalConfig)
|
|
||||||
setMaimMessageConfig(config.maim_message as MaimMessageConfig)
|
|
||||||
setTelemetryConfig(config.telemetry as TelemetryConfig)
|
|
||||||
setWebuiConfig(config.webui as WebUIConfig)
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -285,28 +285,48 @@ function BotConfigPageContent() {
|
|||||||
expression: expressionConfig,
|
expression: expressionConfig,
|
||||||
emoji: emojiConfig,
|
emoji: emojiConfig,
|
||||||
memory: memoryConfig,
|
memory: memoryConfig,
|
||||||
tool: toolConfig,
|
relationship: relationshipConfig,
|
||||||
voice: voiceConfig,
|
voice: voiceConfig,
|
||||||
message_receive: messageReceiveConfig,
|
message_receive: messageReceiveConfig,
|
||||||
dream: dreamConfig,
|
|
||||||
lpmm_knowledge: lpmmConfig,
|
lpmm_knowledge: lpmmConfig,
|
||||||
keyword_reaction: keywordReactionConfig,
|
keyword_reaction: keywordReactionConfig,
|
||||||
response_post_process: responsePostProcessConfig,
|
response_post_process: responsePostProcessConfig,
|
||||||
chinese_typo: chineseTypoConfig,
|
chinese_typo: chineseTypoConfig,
|
||||||
response_splitter: responseSplitterConfig,
|
response_splitter: responseSplitterConfig,
|
||||||
log: logConfig,
|
|
||||||
debug: debugConfig,
|
debug: debugConfig,
|
||||||
experimental: experimentalConfig,
|
experimental: experimentalConfig,
|
||||||
maim_message: maimMessageConfig,
|
maim_message: maimMessageConfig,
|
||||||
telemetry: telemetryConfig,
|
telemetry: telemetryConfig,
|
||||||
webui: webuiConfig,
|
webui: webuiConfig,
|
||||||
|
database: databaseConfig,
|
||||||
|
maisaka: maisakaConfig,
|
||||||
|
mcp: mcpConfig,
|
||||||
|
plugin_runtime: pluginRuntimeConfig,
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
botConfig, personalityConfig, chatConfig, expressionConfig,
|
botConfig,
|
||||||
emojiConfig, memoryConfig, toolConfig,
|
personalityConfig,
|
||||||
voiceConfig, messageReceiveConfig, dreamConfig, lpmmConfig, keywordReactionConfig, responsePostProcessConfig,
|
chatConfig,
|
||||||
chineseTypoConfig, responseSplitterConfig, logConfig, debugConfig, experimentalConfig,
|
expressionConfig,
|
||||||
maimMessageConfig, telemetryConfig, webuiConfig
|
emojiConfig,
|
||||||
|
memoryConfig,
|
||||||
|
relationshipConfig,
|
||||||
|
voiceConfig,
|
||||||
|
messageReceiveConfig,
|
||||||
|
lpmmConfig,
|
||||||
|
keywordReactionConfig,
|
||||||
|
responsePostProcessConfig,
|
||||||
|
chineseTypoConfig,
|
||||||
|
responseSplitterConfig,
|
||||||
|
debugConfig,
|
||||||
|
experimentalConfig,
|
||||||
|
maimMessageConfig,
|
||||||
|
telemetryConfig,
|
||||||
|
webuiConfig,
|
||||||
|
databaseConfig,
|
||||||
|
maisakaConfig,
|
||||||
|
mcpConfig,
|
||||||
|
pluginRuntimeConfig,
|
||||||
])
|
])
|
||||||
|
|
||||||
// 加载源代码
|
// 加载源代码
|
||||||
@@ -384,9 +404,25 @@ function BotConfigPageContent() {
|
|||||||
}, [loadConfig])
|
}, [loadConfig])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fieldHooks.register('chat', ChatSectionHook, 'replace')
|
const hookEntries = [
|
||||||
|
['chat.talk_value_rules', ChatTalkValueRulesHook],
|
||||||
|
['experimental.chat_prompts', ExperimentalChatPromptsHook],
|
||||||
|
['expression.expression_groups', ExpressionGroupsHook],
|
||||||
|
['expression.learning_list', ExpressionLearningListHook],
|
||||||
|
['keyword_reaction.keyword_rules', KeywordRulesHook],
|
||||||
|
['keyword_reaction.regex_rules', RegexRulesHook],
|
||||||
|
['mcp.client.roots.items', MCPRootItemsHook],
|
||||||
|
['mcp.servers', MCPServersHook],
|
||||||
|
] as const
|
||||||
|
|
||||||
|
for (const [fieldPath, hookComponent] of hookEntries) {
|
||||||
|
fieldHooks.register(fieldPath, hookComponent, 'replace')
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
fieldHooks.unregister('chat')
|
for (const [fieldPath] of hookEntries) {
|
||||||
|
fieldHooks.unregister(fieldPath)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -406,19 +442,23 @@ function BotConfigPageContent() {
|
|||||||
useConfigAutoSave(expressionConfig, 'expression', initialLoadRef.current, triggerAutoSave)
|
useConfigAutoSave(expressionConfig, 'expression', initialLoadRef.current, triggerAutoSave)
|
||||||
useConfigAutoSave(emojiConfig, 'emoji', initialLoadRef.current, triggerAutoSave)
|
useConfigAutoSave(emojiConfig, 'emoji', initialLoadRef.current, triggerAutoSave)
|
||||||
useConfigAutoSave(memoryConfig, 'memory', initialLoadRef.current, triggerAutoSave)
|
useConfigAutoSave(memoryConfig, 'memory', initialLoadRef.current, triggerAutoSave)
|
||||||
useConfigAutoSave(toolConfig, 'tool', initialLoadRef.current, triggerAutoSave)
|
useConfigAutoSave(relationshipConfig, 'relationship', initialLoadRef.current, triggerAutoSave)
|
||||||
useConfigAutoSave(voiceConfig, 'voice', initialLoadRef.current, triggerAutoSave)
|
useConfigAutoSave(voiceConfig, 'voice', initialLoadRef.current, triggerAutoSave)
|
||||||
useConfigAutoSave(dreamConfig, 'dream', initialLoadRef.current, triggerAutoSave)
|
useConfigAutoSave(messageReceiveConfig, 'message_receive', initialLoadRef.current, triggerAutoSave)
|
||||||
useConfigAutoSave(lpmmConfig, 'lpmm_knowledge', initialLoadRef.current, triggerAutoSave)
|
useConfigAutoSave(lpmmConfig, 'lpmm_knowledge', initialLoadRef.current, triggerAutoSave)
|
||||||
useConfigAutoSave(keywordReactionConfig, 'keyword_reaction', initialLoadRef.current, triggerAutoSave)
|
useConfigAutoSave(keywordReactionConfig, 'keyword_reaction', initialLoadRef.current, triggerAutoSave)
|
||||||
useConfigAutoSave(responsePostProcessConfig, 'response_post_process', initialLoadRef.current, triggerAutoSave)
|
useConfigAutoSave(responsePostProcessConfig, 'response_post_process', initialLoadRef.current, triggerAutoSave)
|
||||||
useConfigAutoSave(chineseTypoConfig, 'chinese_typo', initialLoadRef.current, triggerAutoSave)
|
useConfigAutoSave(chineseTypoConfig, 'chinese_typo', initialLoadRef.current, triggerAutoSave)
|
||||||
useConfigAutoSave(responseSplitterConfig, 'response_splitter', initialLoadRef.current, triggerAutoSave)
|
useConfigAutoSave(responseSplitterConfig, 'response_splitter', initialLoadRef.current, triggerAutoSave)
|
||||||
useConfigAutoSave(logConfig, 'log', initialLoadRef.current, triggerAutoSave)
|
|
||||||
useConfigAutoSave(debugConfig, 'debug', initialLoadRef.current, triggerAutoSave)
|
useConfigAutoSave(debugConfig, 'debug', initialLoadRef.current, triggerAutoSave)
|
||||||
|
useConfigAutoSave(experimentalConfig, 'experimental', initialLoadRef.current, triggerAutoSave)
|
||||||
useConfigAutoSave(maimMessageConfig, 'maim_message', initialLoadRef.current, triggerAutoSave)
|
useConfigAutoSave(maimMessageConfig, 'maim_message', initialLoadRef.current, triggerAutoSave)
|
||||||
useConfigAutoSave(telemetryConfig, 'telemetry', initialLoadRef.current, triggerAutoSave)
|
useConfigAutoSave(telemetryConfig, 'telemetry', initialLoadRef.current, triggerAutoSave)
|
||||||
useConfigAutoSave(webuiConfig, 'webui', initialLoadRef.current, triggerAutoSave)
|
useConfigAutoSave(webuiConfig, 'webui', initialLoadRef.current, triggerAutoSave)
|
||||||
|
useConfigAutoSave(databaseConfig, 'database', initialLoadRef.current, triggerAutoSave)
|
||||||
|
useConfigAutoSave(maisakaConfig, 'maisaka', initialLoadRef.current, triggerAutoSave)
|
||||||
|
useConfigAutoSave(mcpConfig, 'mcp', initialLoadRef.current, triggerAutoSave)
|
||||||
|
useConfigAutoSave(pluginRuntimeConfig, 'plugin_runtime', initialLoadRef.current, triggerAutoSave)
|
||||||
|
|
||||||
// 保存源代码
|
// 保存源代码
|
||||||
const saveSourceCode = async () => {
|
const saveSourceCode = async () => {
|
||||||
@@ -609,6 +649,89 @@ function BotConfigPageContent() {
|
|||||||
return buildTabGroupsFromSchema(configSchema)
|
return buildTabGroupsFromSchema(configSchema)
|
||||||
}, [configSchema])
|
}, [configSchema])
|
||||||
|
|
||||||
|
const sectionValues = useMemo<Record<string, ConfigSectionData | null>>(
|
||||||
|
() => ({
|
||||||
|
bot: botConfig,
|
||||||
|
personality: personalityConfig,
|
||||||
|
chat: chatConfig,
|
||||||
|
expression: expressionConfig,
|
||||||
|
emoji: emojiConfig,
|
||||||
|
memory: memoryConfig,
|
||||||
|
relationship: relationshipConfig,
|
||||||
|
voice: voiceConfig,
|
||||||
|
message_receive: messageReceiveConfig,
|
||||||
|
lpmm_knowledge: lpmmConfig,
|
||||||
|
keyword_reaction: keywordReactionConfig,
|
||||||
|
response_post_process: responsePostProcessConfig,
|
||||||
|
chinese_typo: chineseTypoConfig,
|
||||||
|
response_splitter: responseSplitterConfig,
|
||||||
|
debug: debugConfig,
|
||||||
|
experimental: experimentalConfig,
|
||||||
|
maim_message: maimMessageConfig,
|
||||||
|
telemetry: telemetryConfig,
|
||||||
|
webui: webuiConfig,
|
||||||
|
database: databaseConfig,
|
||||||
|
maisaka: maisakaConfig,
|
||||||
|
mcp: mcpConfig,
|
||||||
|
plugin_runtime: pluginRuntimeConfig,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
botConfig,
|
||||||
|
personalityConfig,
|
||||||
|
chatConfig,
|
||||||
|
expressionConfig,
|
||||||
|
emojiConfig,
|
||||||
|
memoryConfig,
|
||||||
|
relationshipConfig,
|
||||||
|
voiceConfig,
|
||||||
|
messageReceiveConfig,
|
||||||
|
lpmmConfig,
|
||||||
|
keywordReactionConfig,
|
||||||
|
responsePostProcessConfig,
|
||||||
|
chineseTypoConfig,
|
||||||
|
responseSplitterConfig,
|
||||||
|
debugConfig,
|
||||||
|
experimentalConfig,
|
||||||
|
maimMessageConfig,
|
||||||
|
telemetryConfig,
|
||||||
|
webuiConfig,
|
||||||
|
databaseConfig,
|
||||||
|
maisakaConfig,
|
||||||
|
mcpConfig,
|
||||||
|
pluginRuntimeConfig,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
const setSectionValue = useCallback((sectionName: string, value: ConfigSectionData) => {
|
||||||
|
const sectionSetterMap: Record<string, (nextValue: ConfigSectionData) => void> = {
|
||||||
|
bot: setBotConfig,
|
||||||
|
personality: setPersonalityConfig,
|
||||||
|
chat: setChatConfig,
|
||||||
|
expression: setExpressionConfig,
|
||||||
|
emoji: setEmojiConfig,
|
||||||
|
memory: setMemoryConfig,
|
||||||
|
relationship: setRelationshipConfig,
|
||||||
|
voice: setVoiceConfig,
|
||||||
|
message_receive: setMessageReceiveConfig,
|
||||||
|
lpmm_knowledge: setLpmmConfig,
|
||||||
|
keyword_reaction: setKeywordReactionConfig,
|
||||||
|
response_post_process: setResponsePostProcessConfig,
|
||||||
|
chinese_typo: setChineseTypoConfig,
|
||||||
|
response_splitter: setResponseSplitterConfig,
|
||||||
|
debug: setDebugConfig,
|
||||||
|
experimental: setExperimentalConfig,
|
||||||
|
maim_message: setMaimMessageConfig,
|
||||||
|
telemetry: setTelemetryConfig,
|
||||||
|
webui: setWebuiConfig,
|
||||||
|
database: setDatabaseConfig,
|
||||||
|
maisaka: setMaisakaConfig,
|
||||||
|
mcp: setMcpConfig,
|
||||||
|
plugin_runtime: setPluginRuntimeConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
sectionSetterMap[sectionName]?.(value)
|
||||||
|
}, [])
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<ScrollArea className="h-full">
|
<ScrollArea className="h-full">
|
||||||
@@ -748,28 +871,10 @@ function BotConfigPageContent() {
|
|||||||
{/* 可视化模式 */}
|
{/* 可视化模式 */}
|
||||||
{editMode === 'visual' && (
|
{editMode === 'visual' && (
|
||||||
<DynamicConfigTabs
|
<DynamicConfigTabs
|
||||||
|
configSchema={configSchema}
|
||||||
tabGroups={tabGroups}
|
tabGroups={tabGroups}
|
||||||
botConfig={botConfig} setBotConfig={setBotConfig}
|
sectionValues={sectionValues}
|
||||||
personalityConfig={personalityConfig} setPersonalityConfig={setPersonalityConfig}
|
setSectionValue={setSectionValue}
|
||||||
chatConfig={chatConfig} setChatConfig={setChatConfig}
|
|
||||||
expressionConfig={expressionConfig} setExpressionConfig={setExpressionConfig}
|
|
||||||
emojiConfig={emojiConfig} setEmojiConfig={setEmojiConfig}
|
|
||||||
memoryConfig={memoryConfig} setMemoryConfig={setMemoryConfig}
|
|
||||||
toolConfig={toolConfig} setToolConfig={setToolConfig}
|
|
||||||
voiceConfig={voiceConfig} setVoiceConfig={setVoiceConfig}
|
|
||||||
messageReceiveConfig={messageReceiveConfig} setMessageReceiveConfig={setMessageReceiveConfig}
|
|
||||||
dreamConfig={dreamConfig} setDreamConfig={setDreamConfig}
|
|
||||||
lpmmConfig={lpmmConfig} setLpmmConfig={setLpmmConfig}
|
|
||||||
keywordReactionConfig={keywordReactionConfig} setKeywordReactionConfig={setKeywordReactionConfig}
|
|
||||||
responsePostProcessConfig={responsePostProcessConfig} setResponsePostProcessConfig={setResponsePostProcessConfig}
|
|
||||||
chineseTypoConfig={chineseTypoConfig} setChineseTypoConfig={setChineseTypoConfig}
|
|
||||||
responseSplitterConfig={responseSplitterConfig} setResponseSplitterConfig={setResponseSplitterConfig}
|
|
||||||
logConfig={logConfig} setLogConfig={setLogConfig}
|
|
||||||
debugConfig={debugConfig} setDebugConfig={setDebugConfig}
|
|
||||||
experimentalConfig={experimentalConfig} setExperimentalConfig={setExperimentalConfig}
|
|
||||||
maimMessageConfig={maimMessageConfig} setMaimMessageConfig={setMaimMessageConfig}
|
|
||||||
telemetryConfig={telemetryConfig} setTelemetryConfig={setTelemetryConfig}
|
|
||||||
webuiConfig={webuiConfig} setWebuiConfig={setWebuiConfig}
|
|
||||||
setHasUnsavedChanges={setHasUnsavedChanges}
|
setHasUnsavedChanges={setHasUnsavedChanges}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -783,133 +888,90 @@ function BotConfigPageContent() {
|
|||||||
|
|
||||||
// ==================== 动态 Tab 渲染组件 ====================
|
// ==================== 动态 Tab 渲染组件 ====================
|
||||||
|
|
||||||
|
function updateNestedValue(
|
||||||
|
target: ConfigSectionData | null | undefined,
|
||||||
|
pathSegments: string[],
|
||||||
|
value: unknown
|
||||||
|
): ConfigSectionData {
|
||||||
|
const currentTarget = target && typeof target === 'object' && !Array.isArray(target) ? target : {}
|
||||||
|
const [currentPath, ...restPath] = pathSegments
|
||||||
|
|
||||||
|
if (!currentPath) {
|
||||||
|
return currentTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
if (restPath.length === 0) {
|
||||||
|
return {
|
||||||
|
...currentTarget,
|
||||||
|
[currentPath]: value,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...currentTarget,
|
||||||
|
[currentPath]: updateNestedValue(currentTarget[currentPath] as ConfigSectionData | undefined, restPath, value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface DynamicConfigTabsProps {
|
interface DynamicConfigTabsProps {
|
||||||
|
configSchema: ConfigSchema | null
|
||||||
tabGroups: TabGroup[]
|
tabGroups: TabGroup[]
|
||||||
botConfig: BotConfig | null
|
sectionValues: Record<string, ConfigSectionData | null>
|
||||||
setBotConfig: (c: BotConfig) => void
|
setSectionValue: (sectionName: string, value: ConfigSectionData) => void
|
||||||
personalityConfig: PersonalityConfig | null
|
|
||||||
setPersonalityConfig: (c: PersonalityConfig) => void
|
|
||||||
chatConfig: ChatConfig | null
|
|
||||||
setChatConfig: (c: ChatConfig) => void
|
|
||||||
expressionConfig: ExpressionConfig | null
|
|
||||||
setExpressionConfig: (c: ExpressionConfig) => void
|
|
||||||
emojiConfig: EmojiConfig | null
|
|
||||||
setEmojiConfig: (c: EmojiConfig) => void
|
|
||||||
memoryConfig: MemoryConfig | null
|
|
||||||
setMemoryConfig: (c: MemoryConfig) => void
|
|
||||||
toolConfig: ToolConfig | null
|
|
||||||
setToolConfig: (c: ToolConfig) => void
|
|
||||||
voiceConfig: VoiceConfig | null
|
|
||||||
setVoiceConfig: (c: VoiceConfig) => void
|
|
||||||
messageReceiveConfig: MessageReceiveConfig | null
|
|
||||||
setMessageReceiveConfig: (c: MessageReceiveConfig) => void
|
|
||||||
dreamConfig: DreamConfig | null
|
|
||||||
setDreamConfig: (c: DreamConfig) => void
|
|
||||||
lpmmConfig: LPMMKnowledgeConfig | null
|
|
||||||
setLpmmConfig: (c: LPMMKnowledgeConfig) => void
|
|
||||||
keywordReactionConfig: KeywordReactionConfig | null
|
|
||||||
setKeywordReactionConfig: (c: KeywordReactionConfig) => void
|
|
||||||
responsePostProcessConfig: ResponsePostProcessConfig | null
|
|
||||||
setResponsePostProcessConfig: (c: ResponsePostProcessConfig) => void
|
|
||||||
chineseTypoConfig: ChineseTypoConfig | null
|
|
||||||
setChineseTypoConfig: (c: ChineseTypoConfig) => void
|
|
||||||
responseSplitterConfig: ResponseSplitterConfig | null
|
|
||||||
setResponseSplitterConfig: (c: ResponseSplitterConfig) => void
|
|
||||||
logConfig: LogConfig | null
|
|
||||||
setLogConfig: (c: LogConfig) => void
|
|
||||||
debugConfig: DebugConfig | null
|
|
||||||
setDebugConfig: (c: DebugConfig) => void
|
|
||||||
experimentalConfig: ExperimentalConfig | null
|
|
||||||
setExperimentalConfig: (c: ExperimentalConfig) => void
|
|
||||||
maimMessageConfig: MaimMessageConfig | null
|
|
||||||
setMaimMessageConfig: (c: MaimMessageConfig) => void
|
|
||||||
telemetryConfig: TelemetryConfig | null
|
|
||||||
setTelemetryConfig: (c: TelemetryConfig) => void
|
|
||||||
webuiConfig: WebUIConfig | null
|
|
||||||
setWebuiConfig: (c: WebUIConfig) => void
|
|
||||||
setHasUnsavedChanges: (v: boolean) => void
|
setHasUnsavedChanges: (v: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function DynamicConfigTabs(props: DynamicConfigTabsProps) {
|
function DynamicConfigTabs(props: DynamicConfigTabsProps) {
|
||||||
const { tabGroups } = props
|
const { configSchema, sectionValues, setHasUnsavedChanges, setSectionValue, tabGroups } = props
|
||||||
|
|
||||||
// 每个 tab host field name → 对应的 ReactNode 内容
|
if (tabGroups.length === 0 || !configSchema?.nested) {
|
||||||
const tabContentMap: Record<string, React.ReactNode> = {
|
return null
|
||||||
bot: props.botConfig && (
|
}
|
||||||
<BotInfoSection config={props.botConfig} onChange={props.setBotConfig} />
|
|
||||||
),
|
const renderTabContent = (tab: TabGroup) => {
|
||||||
personality: props.personalityConfig && (
|
const tabNestedEntries = tab.sections
|
||||||
<PersonalitySection config={props.personalityConfig} onChange={props.setPersonalityConfig} />
|
.map((sectionName) => [sectionName, configSchema.nested?.[sectionName]] as const)
|
||||||
),
|
.filter((entry): entry is readonly [string, ConfigSchema] => Boolean(entry[1]))
|
||||||
chat: props.chatConfig && (
|
|
||||||
|
if (tabNestedEntries.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = Object.fromEntries(
|
||||||
|
tabNestedEntries.map(([sectionName]) => [sectionName, sectionValues[sectionName] ?? {}])
|
||||||
|
)
|
||||||
|
|
||||||
|
const tabSchema: ConfigSchema = {
|
||||||
|
className: tab.id,
|
||||||
|
classDoc: tab.label,
|
||||||
|
fields: [],
|
||||||
|
nested: Object.fromEntries(tabNestedEntries),
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
<DynamicConfigForm
|
<DynamicConfigForm
|
||||||
schema={{ className: 'ChatConfig', classDoc: '聊天配置', fields: [{ name: 'chat', type: 'object', label: '聊天', description: '聊天配置', required: false }], nested: {} }}
|
schema={tabSchema}
|
||||||
values={{ chat: props.chatConfig }}
|
values={values}
|
||||||
onChange={(field, value) => {
|
onChange={(fieldPath, value) => {
|
||||||
if (field === 'chat') {
|
const [sectionName, ...restPath] = fieldPath.split('.')
|
||||||
props.setChatConfig(value as ChatConfig)
|
if (!sectionName) {
|
||||||
props.setHasUnsavedChanges(true)
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentSectionValue = sectionValues[sectionName] ?? {}
|
||||||
|
const nextSectionValue =
|
||||||
|
restPath.length === 0
|
||||||
|
? (value as ConfigSectionData)
|
||||||
|
: updateNestedValue(currentSectionValue, restPath, value)
|
||||||
|
|
||||||
|
setSectionValue(sectionName, nextSectionValue)
|
||||||
|
setHasUnsavedChanges(true)
|
||||||
}}
|
}}
|
||||||
hooks={fieldHooks}
|
hooks={fieldHooks}
|
||||||
/>
|
/>
|
||||||
),
|
)
|
||||||
expression: props.expressionConfig && (
|
|
||||||
<ExpressionSection config={props.expressionConfig} onChange={props.setExpressionConfig} />
|
|
||||||
),
|
|
||||||
emoji: props.emojiConfig && props.memoryConfig && props.toolConfig && props.voiceConfig && (
|
|
||||||
<FeaturesSection
|
|
||||||
emojiConfig={props.emojiConfig}
|
|
||||||
memoryConfig={props.memoryConfig}
|
|
||||||
toolConfig={props.toolConfig}
|
|
||||||
voiceConfig={props.voiceConfig}
|
|
||||||
onEmojiChange={props.setEmojiConfig}
|
|
||||||
onMemoryChange={props.setMemoryConfig}
|
|
||||||
onToolChange={props.setToolConfig}
|
|
||||||
onVoiceChange={props.setVoiceConfig}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
response_post_process: (
|
|
||||||
<>
|
|
||||||
{props.keywordReactionConfig && props.responsePostProcessConfig && props.chineseTypoConfig && props.responseSplitterConfig && (
|
|
||||||
<ProcessingSection
|
|
||||||
keywordReactionConfig={props.keywordReactionConfig}
|
|
||||||
responsePostProcessConfig={props.responsePostProcessConfig}
|
|
||||||
chineseTypoConfig={props.chineseTypoConfig}
|
|
||||||
responseSplitterConfig={props.responseSplitterConfig}
|
|
||||||
onKeywordReactionChange={props.setKeywordReactionConfig}
|
|
||||||
onResponsePostProcessChange={props.setResponsePostProcessConfig}
|
|
||||||
onChineseTypoChange={props.setChineseTypoConfig}
|
|
||||||
onResponseSplitterChange={props.setResponseSplitterConfig}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{props.messageReceiveConfig && (
|
|
||||||
<MessageReceiveSection config={props.messageReceiveConfig} onChange={props.setMessageReceiveConfig} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
dream: props.dreamConfig && (
|
|
||||||
<DreamSection config={props.dreamConfig} onChange={props.setDreamConfig} />
|
|
||||||
),
|
|
||||||
lpmm_knowledge: props.lpmmConfig && (
|
|
||||||
<LPMMSection config={props.lpmmConfig} onChange={props.setLpmmConfig} />
|
|
||||||
),
|
|
||||||
webui: props.webuiConfig && (
|
|
||||||
<WebUISection config={props.webuiConfig} onChange={props.setWebuiConfig} />
|
|
||||||
),
|
|
||||||
debug: (
|
|
||||||
<>
|
|
||||||
{props.logConfig && <LogSection config={props.logConfig} onChange={props.setLogConfig} />}
|
|
||||||
{props.debugConfig && <DebugSection config={props.debugConfig} onChange={props.setDebugConfig} />}
|
|
||||||
{props.experimentalConfig && <ExperimentalSection config={props.experimentalConfig} onChange={props.setExperimentalConfig} />}
|
|
||||||
{props.maimMessageConfig && <MaimMessageSection config={props.maimMessageConfig} onChange={props.setMaimMessageConfig} />}
|
|
||||||
{props.telemetryConfig && <TelemetrySection config={props.telemetryConfig} onChange={props.setTelemetryConfig} />}
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tabGroups.length === 0) return null
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs defaultValue={tabGroups[0].id} className="w-full">
|
<Tabs defaultValue={tabGroups[0].id} className="w-full">
|
||||||
<TabsList className="flex flex-wrap h-auto gap-1 p-1">
|
<TabsList className="flex flex-wrap h-auto gap-1 p-1">
|
||||||
@@ -925,7 +987,7 @@ function DynamicConfigTabs(props: DynamicConfigTabsProps) {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
{tabGroups.map((tab) => (
|
{tabGroups.map((tab) => (
|
||||||
<TabsContent key={tab.id} value={tab.id} className="space-y-4">
|
<TabsContent key={tab.id} value={tab.id} className="space-y-4">
|
||||||
{tabContentMap[tab.id]}
|
{renderTabContent(tab)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
))}
|
))}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
103
dashboard/src/routes/config/bot/hooks/JsonFieldHookFactory.tsx
Normal file
103
dashboard/src/routes/config/bot/hooks/JsonFieldHookFactory.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import type { FieldHookComponent } from '@/lib/field-hooks'
|
||||||
|
import type { ConfigSchema, FieldSchema } from '@/types/config-schema'
|
||||||
|
|
||||||
|
interface JsonFieldHookOptions {
|
||||||
|
emptyValue: unknown
|
||||||
|
helperText: string
|
||||||
|
placeholder: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLabel(schema?: ConfigSchema | FieldSchema, fieldPath?: string): string {
|
||||||
|
if (!schema) {
|
||||||
|
return fieldPath?.split('.').at(-1) || 'JSON 配置'
|
||||||
|
}
|
||||||
|
if ('label' in schema && schema.label) {
|
||||||
|
return schema.label
|
||||||
|
}
|
||||||
|
if ('uiLabel' in schema && schema.uiLabel) {
|
||||||
|
return schema.uiLabel
|
||||||
|
}
|
||||||
|
if ('classDoc' in schema && schema.classDoc) {
|
||||||
|
return schema.classDoc
|
||||||
|
}
|
||||||
|
if ('className' in schema && schema.className) {
|
||||||
|
return schema.className
|
||||||
|
}
|
||||||
|
return fieldPath?.split('.').at(-1) || 'JSON 配置'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDescription(schema?: ConfigSchema | FieldSchema): string {
|
||||||
|
if (!schema) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
if ('description' in schema) {
|
||||||
|
return schema.description || ''
|
||||||
|
}
|
||||||
|
if ('classDoc' in schema) {
|
||||||
|
return schema.classDoc || ''
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createJsonFieldHook(options: JsonFieldHookOptions): FieldHookComponent {
|
||||||
|
const JsonFieldHook: FieldHookComponent = ({ fieldPath, onChange, schema, value }) => {
|
||||||
|
const normalizedValue = useMemo(() => {
|
||||||
|
if (value === undefined) {
|
||||||
|
return options.emptyValue
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}, [value])
|
||||||
|
|
||||||
|
const [editorValue, setEditorValue] = useState(() => JSON.stringify(normalizedValue, null, 2))
|
||||||
|
const [errorMessage, setErrorMessage] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEditorValue(JSON.stringify(normalizedValue, null, 2))
|
||||||
|
setErrorMessage('')
|
||||||
|
}, [normalizedValue])
|
||||||
|
|
||||||
|
const label = resolveLabel(schema, fieldPath)
|
||||||
|
const description = resolveDescription(schema)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 rounded-lg border bg-card p-4 sm:p-6">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-base font-semibold">{label}</h3>
|
||||||
|
{description && (
|
||||||
|
<p className="text-sm text-muted-foreground">{description}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">{options.helperText}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
className="min-h-[220px] font-mono text-sm"
|
||||||
|
placeholder={options.placeholder}
|
||||||
|
value={editorValue}
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextValue = event.target.value
|
||||||
|
setEditorValue(nextValue)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(nextValue)
|
||||||
|
setErrorMessage('')
|
||||||
|
onChange?.(parsed)
|
||||||
|
} catch (error) {
|
||||||
|
setErrorMessage(error instanceof Error ? error.message : 'JSON 格式错误')
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{errorMessage ? (
|
||||||
|
<p className="text-sm text-destructive">JSON 解析失败:{errorMessage}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">JSON 有效,修改会立即写回配置草稿。</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonFieldHook
|
||||||
|
}
|
||||||
49
dashboard/src/routes/config/bot/hooks/complexFieldHooks.tsx
Normal file
49
dashboard/src/routes/config/bot/hooks/complexFieldHooks.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { createJsonFieldHook } from './JsonFieldHookFactory'
|
||||||
|
|
||||||
|
export const ChatTalkValueRulesHook = createJsonFieldHook({
|
||||||
|
emptyValue: [],
|
||||||
|
helperText: '复杂对象数组使用 JSON 编辑。每一项对应一个聊天频率规则对象。',
|
||||||
|
placeholder: '[\n {\n "platform": "",\n "item_id": "",\n "rule_type": "group",\n "time": "00:00-23:59",\n "value": 1.0\n }\n]',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ExpressionLearningListHook = createJsonFieldHook({
|
||||||
|
emptyValue: [],
|
||||||
|
helperText: '表达学习配置较复杂,使用 JSON 编辑更稳妥。每一项对应一个学习规则。',
|
||||||
|
placeholder: '[\n {\n "platform": "",\n "item_id": "",\n "rule_type": "group",\n "use_expression": true,\n "enable_learning": true,\n "enable_jargon_learning": true\n }\n]',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ExpressionGroupsHook = createJsonFieldHook({
|
||||||
|
emptyValue: [],
|
||||||
|
helperText: '表达互通组使用 JSON 编辑。每一项包含一个 expression_groups 数组。',
|
||||||
|
placeholder: '[\n {\n "expression_groups": [\n {\n "platform": "qq",\n "item_id": "123456",\n "rule_type": "group"\n }\n ]\n }\n]',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ExperimentalChatPromptsHook = createJsonFieldHook({
|
||||||
|
emptyValue: [],
|
||||||
|
helperText: '实验配置中的定向 Prompt 列表使用 JSON 编辑。每一项应包含 platform、item_id、rule_type、prompt。',
|
||||||
|
placeholder: '[\n {\n "platform": "qq",\n "item_id": "123456",\n "rule_type": "group",\n "prompt": "这里填写额外提示词"\n }\n]',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const KeywordRulesHook = createJsonFieldHook({
|
||||||
|
emptyValue: [],
|
||||||
|
helperText: '关键词规则为对象数组,建议直接编辑 JSON。',
|
||||||
|
placeholder: '[\n {\n "keywords": ["早安"],\n "regex": [],\n "reaction": "早安呀"\n }\n]',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const RegexRulesHook = createJsonFieldHook({
|
||||||
|
emptyValue: [],
|
||||||
|
helperText: '正则规则为对象数组,建议直接编辑 JSON。',
|
||||||
|
placeholder: '[\n {\n "keywords": [],\n "regex": ["https?://[^\\\\s]+"],\n "reaction": "检测到链接:[0]"\n }\n]',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const MCPRootItemsHook = createJsonFieldHook({
|
||||||
|
emptyValue: [],
|
||||||
|
helperText: 'MCP Roots 条目为对象数组,使用 JSON 编辑。',
|
||||||
|
placeholder: '[\n {\n "enabled": true,\n "uri": "file:///Users/example/project",\n "name": "project-root"\n }\n]',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const MCPServersHook = createJsonFieldHook({
|
||||||
|
emptyValue: [],
|
||||||
|
helperText: 'MCP 服务器配置结构较复杂,使用 JSON 编辑。',
|
||||||
|
placeholder: '[\n {\n "name": "example-server",\n "enabled": true,\n "transport": "stdio",\n "command": "uvx",\n "args": ["example-server"],\n "env": {},\n "url": "",\n "headers": {},\n "http_timeout_seconds": 30.0,\n "read_timeout_seconds": 300.0,\n "authorization": {\n "mode": "none",\n "bearer_token": ""\n }\n }\n]',
|
||||||
|
})
|
||||||
@@ -10,6 +10,16 @@ export type {
|
|||||||
UseAutoSaveConfig,
|
UseAutoSaveConfig,
|
||||||
UseAutoSaveReturnGeneric,
|
UseAutoSaveReturnGeneric,
|
||||||
} from './useAutoSave'
|
} from './useAutoSave'
|
||||||
|
export {
|
||||||
|
ChatTalkValueRulesHook,
|
||||||
|
ExperimentalChatPromptsHook,
|
||||||
|
ExpressionGroupsHook,
|
||||||
|
ExpressionLearningListHook,
|
||||||
|
KeywordRulesHook,
|
||||||
|
MCPRootItemsHook,
|
||||||
|
MCPServersHook,
|
||||||
|
RegexRulesHook,
|
||||||
|
} from './complexFieldHooks'
|
||||||
export { ChatSectionHook } from './ChatSectionHook'
|
export { ChatSectionHook } from './ChatSectionHook'
|
||||||
export { PersonalitySectionHook } from './PersonalitySectionHook'
|
export { PersonalitySectionHook } from './PersonalitySectionHook'
|
||||||
export { DebugSectionHook } from './DebugSectionHook'
|
export { DebugSectionHook } from './DebugSectionHook'
|
||||||
|
|||||||
@@ -261,6 +261,7 @@ export type ConfigSectionName =
|
|||||||
| 'expression'
|
| 'expression'
|
||||||
| 'emoji'
|
| 'emoji'
|
||||||
| 'memory'
|
| 'memory'
|
||||||
|
| 'relationship'
|
||||||
| 'tool'
|
| 'tool'
|
||||||
| 'voice'
|
| 'voice'
|
||||||
| 'message_receive'
|
| 'message_receive'
|
||||||
@@ -276,3 +277,7 @@ export type ConfigSectionName =
|
|||||||
| 'maim_message'
|
| 'maim_message'
|
||||||
| 'telemetry'
|
| 'telemetry'
|
||||||
| 'webui'
|
| 'webui'
|
||||||
|
| 'database'
|
||||||
|
| 'maisaka'
|
||||||
|
| 'mcp'
|
||||||
|
| 'plugin_runtime'
|
||||||
|
|||||||
@@ -12,7 +12,15 @@ export type FieldType =
|
|||||||
| 'object'
|
| 'object'
|
||||||
| 'textarea'
|
| 'textarea'
|
||||||
|
|
||||||
export type XWidgetType = 'slider' | 'select' | 'textarea' | 'switch' | 'custom'
|
export type XWidgetType =
|
||||||
|
| 'custom'
|
||||||
|
| 'input'
|
||||||
|
| 'number'
|
||||||
|
| 'password'
|
||||||
|
| 'select'
|
||||||
|
| 'slider'
|
||||||
|
| 'switch'
|
||||||
|
| 'textarea'
|
||||||
|
|
||||||
export interface FieldSchema {
|
export interface FieldSchema {
|
||||||
name: string
|
name: string
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from src.config.official_configs import ChatConfig
|
from src.config.official_configs import ChatConfig, MessageReceiveConfig
|
||||||
from src.config.config import Config
|
from src.config.config import Config
|
||||||
from src.webui.config_schema import ConfigSchemaGenerator
|
from src.webui.config_schema import ConfigSchemaGenerator
|
||||||
|
|
||||||
@@ -60,17 +60,49 @@ def test_nested_model_schema():
|
|||||||
def test_field_without_extra_metadata():
|
def test_field_without_extra_metadata():
|
||||||
"""Test that fields without json_schema_extra still generate valid schema."""
|
"""Test that fields without json_schema_extra still generate valid schema."""
|
||||||
schema = ConfigSchemaGenerator.generate_schema(ChatConfig)
|
schema = ConfigSchemaGenerator.generate_schema(ChatConfig)
|
||||||
max_context_size = next(f for f in schema["fields"] if f["name"] == "max_context_size")
|
inevitable_at_reply = next(f for f in schema["fields"] if f["name"] == "inevitable_at_reply")
|
||||||
|
|
||||||
# Verify basic fields are generated
|
# Verify basic fields are generated
|
||||||
assert "name" in max_context_size
|
assert "name" in inevitable_at_reply
|
||||||
assert max_context_size["name"] == "max_context_size"
|
assert inevitable_at_reply["name"] == "inevitable_at_reply"
|
||||||
assert "type" in max_context_size
|
assert "type" in inevitable_at_reply
|
||||||
assert max_context_size["type"] == "integer"
|
assert inevitable_at_reply["type"] == "boolean"
|
||||||
assert "label" in max_context_size
|
assert "label" in inevitable_at_reply
|
||||||
assert "required" in max_context_size
|
assert "required" in inevitable_at_reply
|
||||||
|
|
||||||
# Verify no x-widget or x-icon from json_schema_extra (since field has none)
|
# Verify no x-widget or x-icon from json_schema_extra (since field has none)
|
||||||
# These fields should only be present if explicitly defined in json_schema_extra
|
# These fields should only be present if explicitly defined in json_schema_extra
|
||||||
assert not max_context_size.get("x-widget")
|
assert not inevitable_at_reply.get("x-widget")
|
||||||
assert not max_context_size.get("x-icon")
|
assert not inevitable_at_reply.get("x-icon")
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_top_level_sections_have_ui_metadata():
|
||||||
|
"""所有顶层配置节都必须声明 uiParent 或独立 Tab 的标签与图标。"""
|
||||||
|
schema = ConfigSchemaGenerator.generate_schema(Config)
|
||||||
|
|
||||||
|
for section_name, section_schema in schema["nested"].items():
|
||||||
|
has_parent = bool(section_schema.get("uiParent"))
|
||||||
|
has_host_meta = bool(section_schema.get("uiLabel")) and bool(section_schema.get("uiIcon"))
|
||||||
|
assert has_parent or has_host_meta, f"{section_name} 缺少 UI 元数据"
|
||||||
|
|
||||||
|
|
||||||
|
def test_maisaka_is_host_tab_and_mcp_is_attached_to_it():
|
||||||
|
"""MaiSaka 应作为独立 Tab,MCP 作为其子配置挂载。"""
|
||||||
|
schema = ConfigSchemaGenerator.generate_schema(Config)
|
||||||
|
|
||||||
|
maisaka_schema = schema["nested"]["maisaka"]
|
||||||
|
mcp_schema = schema["nested"]["mcp"]
|
||||||
|
|
||||||
|
assert maisaka_schema.get("uiParent") is None
|
||||||
|
assert maisaka_schema.get("uiLabel") == "MaiSaka"
|
||||||
|
assert maisaka_schema.get("uiIcon") == "message-circle"
|
||||||
|
assert mcp_schema.get("uiParent") == "maisaka"
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_field_is_mapped_as_array():
|
||||||
|
"""set[str] 应映射为前端可识别的 array。"""
|
||||||
|
schema = ConfigSchemaGenerator.generate_schema(MessageReceiveConfig)
|
||||||
|
ban_words = next(field for field in schema["fields"] if field["name"] == "ban_words")
|
||||||
|
|
||||||
|
assert ban_words["type"] == "array"
|
||||||
|
assert ban_words["items"]["type"] == "string"
|
||||||
|
|||||||
@@ -1473,7 +1473,6 @@ class MaiSakaConfig(ConfigBase):
|
|||||||
|
|
||||||
__ui_label__ = "MaiSaka"
|
__ui_label__ = "MaiSaka"
|
||||||
__ui_icon__ = "message-circle"
|
__ui_icon__ = "message-circle"
|
||||||
__ui_parent__ = "experimental"
|
|
||||||
|
|
||||||
enable_knowledge_module: bool = Field(
|
enable_knowledge_module: bool = Field(
|
||||||
default=True,
|
default=True,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import inspect
|
|
||||||
from typing import Any, Dict, List, get_args, get_origin
|
from typing import Any, Dict, List, get_args, get_origin
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
|
||||||
from pydantic_core import PydanticUndefined
|
from pydantic_core import PydanticUndefined
|
||||||
|
|
||||||
from src.config.config_base import ConfigBase
|
from src.config.config_base import ConfigBase
|
||||||
@@ -56,7 +57,7 @@ class ConfigSchemaGenerator:
|
|||||||
if inspect.isclass(annotation) and issubclass(annotation, ConfigBase):
|
if inspect.isclass(annotation) and issubclass(annotation, ConfigBase):
|
||||||
return cls.generate_config_schema(annotation)
|
return cls.generate_config_schema(annotation)
|
||||||
|
|
||||||
if origin in {list, tuple} and args:
|
if origin in {list, set, tuple} and args:
|
||||||
first = args[0]
|
first = args[0]
|
||||||
if inspect.isclass(first) and issubclass(first, ConfigBase):
|
if inspect.isclass(first) and issubclass(first, ConfigBase):
|
||||||
return cls.generate_config_schema(first)
|
return cls.generate_config_schema(first)
|
||||||
@@ -83,7 +84,7 @@ class ConfigSchemaGenerator:
|
|||||||
origin = get_origin(annotation)
|
origin = get_origin(annotation)
|
||||||
args = get_args(annotation)
|
args = get_args(annotation)
|
||||||
|
|
||||||
if origin is list and args:
|
if origin in {list, set} and args:
|
||||||
schema["items"] = {"type": cls._map_field_type(args[0])}
|
schema["items"] = {"type": cls._map_field_type(args[0])}
|
||||||
|
|
||||||
if options := cls._extract_options(annotation):
|
if options := cls._extract_options(annotation):
|
||||||
@@ -120,7 +121,7 @@ class ConfigSchemaGenerator:
|
|||||||
origin = get_origin(annotation)
|
origin = get_origin(annotation)
|
||||||
args = get_args(annotation)
|
args = get_args(annotation)
|
||||||
|
|
||||||
if origin in {list, tuple}:
|
if origin in {list, set, tuple}:
|
||||||
return "array"
|
return "array"
|
||||||
if inspect.isclass(annotation) and issubclass(annotation, ConfigBase):
|
if inspect.isclass(annotation) and issubclass(annotation, ConfigBase):
|
||||||
return "object"
|
return "object"
|
||||||
@@ -133,7 +134,7 @@ class ConfigSchemaGenerator:
|
|||||||
if annotation is str:
|
if annotation is str:
|
||||||
return "string"
|
return "string"
|
||||||
|
|
||||||
if origin in {list, tuple} and args:
|
if origin in {list, set, tuple} and args:
|
||||||
return "array"
|
return "array"
|
||||||
|
|
||||||
if origin in {dict}:
|
if origin in {dict}:
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
"""WebSocket 路由聚合导出。"""
|
"""WebSocket 路由包。"""
|
||||||
|
|
||||||
from .auth import router as ws_auth_router
|
|
||||||
from .unified import router as unified_ws_router
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"unified_ws_router",
|
"auth",
|
||||||
"ws_auth_router",
|
"manager",
|
||||||
|
"unified",
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user