pref:优化webui界面,增加prompt模板元信息

This commit is contained in:
SengokuCola
2026-05-05 17:57:19 +08:00
parent 0d43d3ec05
commit a5e4ac8531
42 changed files with 826 additions and 410 deletions

View File

@@ -175,6 +175,7 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
value={values[field.name]}
onChange={(v) => onChange(field.name, v)}
schema={field}
parentValues={values}
/>
)
}
@@ -185,6 +186,7 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
value={values[field.name]}
onChange={(v) => onChange(field.name, v)}
schema={field}
parentValues={values}
>
<DynamicField
schema={field}
@@ -300,6 +302,7 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
onChange={(v) => onChange(key, v)}
schema={nestedField ?? nestedSchema}
nestedSchema={nestedSchema}
parentValues={values}
/>
</div>
)
@@ -313,6 +316,7 @@ export const DynamicConfigForm: React.FC<DynamicConfigFormProps> = ({
onChange={(v) => onChange(key, v)}
schema={nestedField ?? nestedSchema}
nestedSchema={nestedSchema}
parentValues={values}
>
<DynamicConfigForm
schema={nestedSchema}

View File

@@ -15,6 +15,7 @@ export interface FieldHookComponentProps {
onChange?: (value: unknown) => void
children?: ReactNode
schema?: ConfigSchema | FieldSchema
parentValues?: Record<string, unknown>
/**
* 如果当前字段是 `List[ConfigBase]` 或嵌套 ConfigBase
* 这里会传入对应子配置类的 ConfigSchema便于自定义编辑器

View File

@@ -8,6 +8,9 @@ export interface PromptFileInfo {
name: string
size: number
modified_at: number
display_name: string
advanced: boolean
description: string
}
export interface PromptCatalog {

View File

@@ -5,7 +5,7 @@
* 修改此处的版本号后,所有展示版本的地方都会自动更新
*/
export const APP_VERSION = '1.0.3'
export const APP_VERSION = '1.0.5'
export const APP_NAME = 'MaiBot Dashboard'
export const APP_FULL_NAME = `${APP_NAME} v${APP_VERSION}`

View File

@@ -48,11 +48,10 @@ const TOAST_DISPLAY_DELAY = 500
/** Tab 标签页的首选排列顺序 (host field name) */
const TAB_ORDER = [
'bot',
'personality',
'chat',
'expression',
'visual',
'a_memorix',
'visual',
'message_receive',
'emoji',
'voice',
@@ -65,10 +64,8 @@ const TAB_ORDER = [
/** 默认展示的主配置栏目 */
const DEFAULT_VISIBLE_TAB_IDS = new Set([
'bot',
'personality',
'chat',
'expression',
'visual',
'a_memorix',
])

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, type CSSProperties } from 'react'
import { useCallback, useEffect, useMemo, useState, type CSSProperties } from 'react'
import * as LucideIcons from 'lucide-react'
import { Plus, Trash2 } from 'lucide-react'
@@ -37,6 +37,11 @@ export interface ListItemEditorOptions {
fieldSchemaOverrides?: Record<string, Partial<FieldSchema>>
/** 添加按钮位置 */
addButtonPlacement?: 'top' | 'bottom'
/** 根据同级配置决定是否默认折叠 */
collapseWhen?: (context: { parentValues?: Record<string, unknown> }) => boolean
collapsedText?: string
expandLabel?: string
collapseLabel?: string
}
function resolveLabel(schema?: ConfigSchema | FieldSchema, fieldPath?: string): string {
@@ -159,6 +164,7 @@ export function createListItemEditorHook(
onChange,
schema,
nestedSchema,
parentValues,
value,
}) => {
const items = useMemo<Record<string, unknown>[]>(() => {
@@ -283,6 +289,16 @@ export function createListItemEditorHook(
const description = resolveDescription(schema)
const iconName = resolveIconName(options.iconName, schema, nestedSchema)
const addButtonPlacement = options.addButtonPlacement ?? 'bottom'
const shouldCollapse = options.collapseWhen?.({ parentValues }) ?? false
const [manuallyExpanded, setManuallyExpanded] = useState(false)
const collapsed = shouldCollapse && !manuallyExpanded
useEffect(() => {
if (!shouldCollapse) {
setManuallyExpanded(false)
}
}, [shouldCollapse])
const addButton = (
<Button
type="button"
@@ -310,9 +326,23 @@ export function createListItemEditorHook(
return (
<Card>
<CardHeader className="space-y-2 pb-4">
<div className="flex items-center gap-2">
{renderLucideIcon(iconName, 'h-5 w-5 text-muted-foreground')}
<CardTitle className="text-base">{label}</CardTitle>
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
{renderLucideIcon(iconName, 'h-5 w-5 flex-shrink-0 text-muted-foreground')}
<CardTitle className="truncate text-base">{label}</CardTitle>
</div>
{shouldCollapse && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setManuallyExpanded((current) => !current)}
>
{collapsed
? (options.expandLabel ?? '展开')
: (options.collapseLabel ?? '折叠')}
</Button>
)}
</div>
{description && (
<CardDescription className="whitespace-pre-line">{description}</CardDescription>
@@ -322,6 +352,12 @@ export function createListItemEditorHook(
)}
</CardHeader>
<CardContent className="space-y-3">
{collapsed ? (
<div className="rounded-md border border-dashed border-muted-foreground/25 bg-muted/10 p-4 text-sm text-muted-foreground">
{options.collapsedText ?? '当前配置已折叠,可手动展开查看或编辑。'}
</div>
) : (
<>
{addButtonPlacement === 'top' && addButton}
{items.length === 0 ? (
<div className="rounded-md border border-dashed border-muted-foreground/25 bg-muted/10 p-6 text-center text-sm text-muted-foreground">
@@ -360,6 +396,8 @@ export function createListItemEditorHook(
})
)}
{addButtonPlacement === 'bottom' && addButton}
</>
)}
</CardContent>
</Card>
)

View File

@@ -1,6 +1,33 @@
import { Plus, Trash2 } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import type { FieldHookComponent } from '@/lib/field-hooks'
import { createJsonFieldHook } from './JsonFieldHookFactory'
import { createListItemEditorHook } from './ListItemEditorHookFactory'
type ExpressionRuleType = 'group' | 'private'
interface ExpressionGroupTarget {
platform: string
item_id: string
rule_type: ExpressionRuleType
}
interface ExpressionGroupValue {
expression_groups: ExpressionGroupTarget[]
}
const ruleTypeLabel = (rule: unknown) => {
if (rule === 'private') return '私聊'
if (rule === 'group') return '群聊'
@@ -28,9 +55,60 @@ const collectStringList = (value: unknown): string[] => {
.filter((item) => item.length > 0)
}
const normalizeExpressionRuleType = (value: unknown): ExpressionRuleType => {
return value === 'private' ? 'private' : 'group'
}
const normalizeExpressionTarget = (value: unknown): ExpressionGroupTarget => {
const source =
value && typeof value === 'object'
? (value as Record<string, unknown>)
: {}
return {
platform:
typeof source.platform === 'string' ? source.platform.trim() : 'qq',
item_id:
typeof source.item_id === 'string' ? source.item_id.trim() : '',
rule_type: normalizeExpressionRuleType(source.rule_type),
}
}
const normalizeExpressionGroups = (value: unknown): ExpressionGroupValue[] => {
if (!Array.isArray(value)) return []
return value.map((item) => {
const source =
item && typeof item === 'object'
? (item as Record<string, unknown>)
: {}
const members = Array.isArray(source.expression_groups)
? source.expression_groups.map(normalizeExpressionTarget)
: []
return { expression_groups: members }
})
}
const createExpressionTarget = (): ExpressionGroupTarget => ({
platform: 'qq',
item_id: '',
rule_type: 'group',
})
const formatExpressionTarget = (target: ExpressionGroupTarget): string => {
const platform = target.platform.trim()
const itemId = target.item_id.trim()
const rule = ruleTypeLabel(target.rule_type)
if (!platform && !itemId) return `全局 · ${rule}`
if (!itemId) return `${platform} · ${rule}`
return `${platform}:${itemId} · ${rule}`
}
export const ChatTalkValueRulesHook = createListItemEditorHook({
addLabel: '添加发言频率规则',
addButtonPlacement: 'top',
collapseWhen: ({ parentValues }) => parentValues?.enable_talk_value_rules === false,
collapsedText: '动态发言频率规则未启用,规则列表已折叠。展开后仍可查看或编辑已有规则。',
expandLabel: '展开规则',
collapseLabel: '折叠规则',
helperText: '可按平台/聊天流/时段分别配置发言频率,留空表示全局。',
emptyText: '尚未配置任何规则,将使用全局默认频率。',
fieldRows: [
@@ -135,11 +213,219 @@ export const RegexRulesHook = createListItemEditorHook({
},
})
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 ExpressionGroupsHook: FieldHookComponent = ({ onChange, value }) => {
const groups = normalizeExpressionGroups(value)
const updateGroups = (nextGroups: ExpressionGroupValue[]) => {
onChange?.(nextGroups)
}
const addGroup = () => {
updateGroups([...groups, { expression_groups: [] }])
}
const removeGroup = (groupIndex: number) => {
updateGroups(groups.filter((_, index) => index !== groupIndex))
}
const addMember = (groupIndex: number) => {
updateGroups(
groups.map((group, index) =>
index === groupIndex
? {
expression_groups: [
...group.expression_groups,
createExpressionTarget(),
],
}
: group
)
)
}
const removeMember = (groupIndex: number, memberIndex: number) => {
updateGroups(
groups.map((group, index) =>
index === groupIndex
? {
expression_groups: group.expression_groups.filter(
(_, currentMemberIndex) => currentMemberIndex !== memberIndex
),
}
: group
)
)
}
const updateMember = (
groupIndex: number,
memberIndex: number,
patch: Partial<ExpressionGroupTarget>
) => {
updateGroups(
groups.map((group, index) =>
index === groupIndex
? {
expression_groups: group.expression_groups.map(
(member, currentMemberIndex) =>
currentMemberIndex === memberIndex
? { ...member, ...patch }
: member
),
}
: group
)
)
}
return (
<div className="space-y-4 rounded-lg border bg-card p-4 sm:p-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="space-y-1">
<h3 className="text-base font-semibold"></h3>
<p className="text-sm text-muted-foreground">
expression_groups
</p>
</div>
<Button type="button" size="sm" variant="outline" onClick={addGroup}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
{groups.length === 0 ? (
<div className="rounded-md border border-dashed bg-muted/30 px-4 py-8 text-center text-sm text-muted-foreground">
</div>
) : (
<div className="space-y-3">
{groups.map((group, groupIndex) => (
<div
key={groupIndex}
className="space-y-3 rounded-md border bg-muted/20 p-3 sm:p-4"
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">
{groupIndex + 1}
</span>
<Badge variant="secondary">
{group.expression_groups.length}
</Badge>
</div>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => addMember(groupIndex)}
>
<Plus className="mr-2 h-4 w-4" />
</Button>
<Button
type="button"
size="icon"
variant="ghost"
aria-label={`删除互通组 ${groupIndex + 1}`}
onClick={() => removeGroup(groupIndex)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
{group.expression_groups.length === 0 ? (
<div className="rounded-md bg-background/70 px-3 py-4 text-sm text-muted-foreground">
</div>
) : (
<div className="space-y-2">
{group.expression_groups.map((member, memberIndex) => (
<div
key={`${groupIndex}-${memberIndex}`}
className="grid gap-3 rounded-md bg-background/80 p-3 md:grid-cols-[minmax(7rem,0.75fr)_minmax(10rem,1fr)_minmax(8rem,0.8fr)_auto]"
>
<div className="space-y-1">
<Label className="text-xs"></Label>
<Input
value={member.platform}
placeholder="qq"
onChange={(event) =>
updateMember(groupIndex, memberIndex, {
platform: event.target.value,
})
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs"> / </Label>
<Input
className="font-mono"
value={member.item_id}
placeholder="123456"
onChange={(event) =>
updateMember(groupIndex, memberIndex, {
item_id: event.target.value,
})
}
/>
</div>
<div className="space-y-1">
<Label className="text-xs"></Label>
<Select
value={member.rule_type}
onValueChange={(nextRuleType) =>
updateMember(groupIndex, memberIndex, {
rule_type: normalizeExpressionRuleType(nextRuleType),
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="group"></SelectItem>
<SelectItem value="private"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-end justify-between gap-2 md:justify-end">
<span className="min-w-0 truncate text-xs text-muted-foreground md:hidden">
{formatExpressionTarget(member)}
</span>
<Button
type="button"
size="icon"
variant="ghost"
aria-label={`删除互通组 ${groupIndex + 1} 的成员 ${memberIndex + 1}`}
onClick={() => removeMember(groupIndex, memberIndex)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
{group.expression_groups.length > 0 && (
<div className="flex flex-wrap gap-2">
{group.expression_groups.map((member, memberIndex) => (
<Badge key={memberIndex} variant="outline">
{formatExpressionTarget(member)}
</Badge>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
)
}
export const MCPRootItemsHook = createJsonFieldHook({
emptyValue: [],

View File

@@ -63,27 +63,29 @@ export const BotInfoSection = React.memo(function BotInfoSection({ config, onCha
return (
<div className="rounded-lg border bg-card p-4 sm:p-6 space-y-6">
<div>
<h3 className="text-lg font-semibold mb-4"></h3>
<h3 className="text-lg font-semibold mb-4"></h3>
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="platform"></Label>
<Input
id="platform"
value={config.platform}
onChange={(e) => onChange({ ...config, platform: e.target.value })}
placeholder="qq"
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="platform"></Label>
<Input
id="platform"
value={config.platform}
onChange={(e) => onChange({ ...config, platform: e.target.value })}
placeholder="qq"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="qq_account">QQ账号</Label>
<Input
id="qq_account"
value={config.qq_account}
onChange={(e) => onChange({ ...config, qq_account: e.target.value })}
placeholder="123456789"
/>
<div className="grid gap-2">
<Label htmlFor="qq_account">QQ账号</Label>
<Input
id="qq_account"
value={config.qq_account}
onChange={(e) => onChange({ ...config, qq_account: e.target.value })}
placeholder="123456789"
/>
</div>
</div>
<div className="grid gap-2">

View File

@@ -311,23 +311,6 @@ export const FeaturesSection = React.memo(function FeaturesSection({
</Label>
</div>
{emojiConfig.content_filtration && (
<div className="grid gap-2 pl-6 border-l-2 border-primary/20">
<Label htmlFor="filtration_prompt"></Label>
<Input
id="filtration_prompt"
value={emojiConfig.filtration_prompt}
onChange={(e) =>
onEmojiChange({ ...emojiConfig, filtration_prompt: e.target.value })
}
placeholder="符合公序良俗"
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
)}
</div>
</div>
</div>

View File

@@ -78,7 +78,6 @@ export interface EmojiConfig {
check_interval: number
steal_emoji: boolean
content_filtration: boolean
filtration_prompt: string
}
export interface MemoryConfig {

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { FileText, Loader2, RefreshCw, Save, Search } from 'lucide-react'
import { FileText, Loader2, RefreshCw, Save, Search, SlidersHorizontal } from 'lucide-react'
import { CodeEditor } from '@/components/CodeEditor'
import { Badge } from '@/components/ui/badge'
@@ -36,6 +36,7 @@ export function PromptManagementPage() {
const [loadingFile, setLoadingFile] = useState(false)
const [saving, setSaving] = useState(false)
const [query, setQuery] = useState('')
const [showAdvancedPrompts, setShowAdvancedPrompts] = useState(false)
const hasUnsavedChanges = content !== savedContent
@@ -44,13 +45,30 @@ export function PromptManagementPage() {
return catalog.files[language] ?? []
}, [catalog, language])
const visiblePromptFiles = useMemo<PromptFileInfo[]>(() => {
return showAdvancedPrompts ? promptFiles : promptFiles.filter((file) => !file.advanced)
}, [promptFiles, showAdvancedPrompts])
const filteredFiles = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase()
if (!normalizedQuery) return promptFiles
return promptFiles.filter((file) => file.name.toLowerCase().includes(normalizedQuery))
}, [promptFiles, query])
if (!normalizedQuery) return visiblePromptFiles
return visiblePromptFiles.filter((file) => {
const searchableText = [
file.name,
file.display_name,
file.description,
].join(' ').toLowerCase()
return searchableText.includes(normalizedQuery)
})
}, [visiblePromptFiles, query])
const selectedFile = promptFiles.find((file) => file.name === filename)
useEffect(() => {
if (!filename || showAdvancedPrompts) return
const currentFile = promptFiles.find((file) => file.name === filename)
if (!currentFile?.advanced) return
setFilename(visiblePromptFiles[0]?.name ?? '')
}, [filename, promptFiles, showAdvancedPrompts, visiblePromptFiles])
const loadCatalog = useCallback(async () => {
try {
@@ -70,7 +88,10 @@ export function PromptManagementPage() {
setLanguage(nextLanguage)
const nextFiles = nextLanguage ? result.data.files[nextLanguage] ?? [] : []
setFilename((current) => nextFiles.some((file) => file.name === current) ? current : nextFiles[0]?.name ?? '')
const nextBasicFiles = nextFiles.filter((file) => !file.advanced)
setFilename((current) =>
nextFiles.some((file) => file.name === current) ? current : nextBasicFiles[0]?.name ?? nextFiles[0]?.name ?? ''
)
} catch (error) {
toast({
title: '加载 Prompt 目录失败',
@@ -130,7 +151,8 @@ export function PromptManagementPage() {
setLanguage(nextLanguage)
setQuery('')
const nextFiles = catalog?.files[nextLanguage] ?? []
setFilename(nextFiles[0]?.name ?? '')
const nextVisibleFiles = showAdvancedPrompts ? nextFiles : nextFiles.filter((file) => !file.advanced)
setFilename(nextVisibleFiles[0]?.name ?? '')
}
const handleSave = async () => {
@@ -181,6 +203,14 @@ export function PromptManagementPage() {
<RefreshCw className={cn('mr-2 h-4 w-4', loadingCatalog && 'animate-spin')} />
</Button>
<Button
variant={showAdvancedPrompts ? 'default' : 'outline'}
size="sm"
onClick={() => setShowAdvancedPrompts((current) => !current)}
>
<SlidersHorizontal className="mr-2 h-4 w-4" />
{showAdvancedPrompts ? '隐藏高级' : '显示高级'}
</Button>
<Button size="sm" onClick={handleSave} disabled={!hasUnsavedChanges || saving || loadingFile || !filename}>
<Save className="mr-2 h-4 w-4" />
{saving ? '保存中' : hasUnsavedChanges ? '保存' : '已保存'}
@@ -194,7 +224,7 @@ export function PromptManagementPage() {
<CardTitle className="flex items-center gap-2 text-sm">
<FileText className="h-4 w-4" />
Prompt
<Badge variant="secondary" className="ml-auto">{promptFiles.length}</Badge>
<Badge variant="secondary" className="ml-auto">{filteredFiles.length}</Badge>
</CardTitle>
<div className="relative">
<Search className="pointer-events-none absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
@@ -226,8 +256,16 @@ export function PromptManagementPage() {
filename === file.name ? 'bg-accent text-accent-foreground' : 'text-muted-foreground',
)}
>
<div className="truncate font-medium" title={file.name}>{file.name}</div>
<div className="mt-0.5 text-xs text-muted-foreground">{formatFileSize(file.size)}</div>
<div className="flex items-center gap-2">
<div className="truncate font-medium" title={file.display_name || file.name}>
{file.display_name || file.name}
</div>
{file.advanced && <Badge variant="outline" className="shrink-0 text-[10px]"></Badge>}
</div>
<div className="mt-0.5 truncate text-xs text-muted-foreground">{file.name} · {formatFileSize(file.size)}</div>
{file.description && (
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground">{file.description}</div>
)}
</button>
))
) : (
@@ -240,12 +278,18 @@ export function PromptManagementPage() {
<Card className="min-h-0 overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between gap-3 space-y-0 pb-3">
<div className="min-w-0">
<CardTitle className="truncate text-sm">{filename || '未选择文件'}</CardTitle>
<CardTitle className="flex items-center gap-2 truncate text-sm">
<span className="truncate">{selectedFile?.display_name || filename || '未选择文件'}</span>
{selectedFile?.advanced && <Badge variant="outline" className="shrink-0"></Badge>}
</CardTitle>
<p className="mt-1 text-xs text-muted-foreground">
{language}
{selectedFile ? ` · ${formatFileSize(selectedFile.size)}` : ''}
{hasUnsavedChanges ? ' · 有未保存修改' : ''}
</p>
{selectedFile?.description && (
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">{selectedFile.description}</p>
)}
</div>
</CardHeader>
<CardContent className="min-h-0 p-0">