Files
mai-bot/dashboard/src/routes/config/bot.tsx

1087 lines
38 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { parse as parseToml } from 'smol-toml'
import { AlertDescription, Alert } from '@/components/ui/alert'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { CodeEditor } from '@/components/CodeEditor'
import { DynamicConfigForm } from '@/components/dynamic-form'
import { RestartOverlay } from '@/components/restart-overlay'
import { useToast } from '@/hooks/use-toast'
import { getBotConfig, getBotConfigRaw, getBotConfigSchema, updateBotConfig, updateBotConfigRaw } from '@/lib/config-api'
import { fieldHooks } from '@/lib/field-hooks'
import { RestartProvider, useRestart } from '@/lib/restart-context'
import { cn } from '@/lib/utils'
import { ChevronDown, ChevronUp, Code2, Info, Layout, Power, RefreshCw, Save } from 'lucide-react'
import type { ConfigSchema } from '@/types/config-schema'
import {
BotPlatformsHook,
ChatPromptsHook,
ChatTalkValueRulesHook,
ExpressionGroupsHook,
ExpressionLearningListHook,
KeywordRulesHook,
MCPRootItemsHook,
MCPServersHook,
RegexRulesHook,
useAutoSave,
useConfigAutoSave,
} from './bot/hooks'
type ConfigSectionData = Record<string, unknown>
// ==================== 常量定义 ====================
/** Toast 显示前的延迟时间 (毫秒) */
const TOAST_DISPLAY_DELAY = 500
/** Tab 标签页的首选排列顺序 (host field name) */
const TAB_ORDER = [
'bot',
'chat',
'expression',
'a_memorix',
'visual',
'message_receive',
'emoji',
'voice',
'response_post_process',
'webui',
'plugin_runtime',
'log',
]
/** 默认展示的主配置栏目 */
const DEFAULT_VISIBLE_TAB_IDS = new Set([
'bot',
'chat',
'expression',
'a_memorix',
])
// ==================== Tab 分组类型与构建 ====================
interface TabGroup {
id: string
label: string
icon: string
sections: string[]
}
/**
* 从 schema 的 nested 字段解析出 tab 分组信息。
* - 有 uiLabel 且无 uiParent → 独立 tab
* - 有 uiParent → 递归找到最终 host并归入对应 tab
*/
function buildTabGroupsFromSchema(schema: ConfigSchema): TabGroup[] {
const nested = schema.nested || {}
const nestedEntries = Object.entries(nested)
const hosts = new Map<string, TabGroup>()
const resolveHostId = (fieldName: string, visited: Set<string> = new Set()): string | null => {
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, {
id: fieldName,
label: fieldSchema.uiLabel,
icon: fieldSchema.uiIcon || '',
sections: [fieldName],
})
}
}
for (const [fieldName] of nestedEntries) {
const hostId = resolveHostId(fieldName)
if (!hostId || hostId === fieldName) {
continue
}
const parent = hosts.get(hostId)
if (parent && !parent.sections.includes(fieldName)) {
parent.sections.push(fieldName)
}
}
// 按 TAB_ORDER 排序;未列入的 tab 追加到末尾
return Array.from(hosts.values()).sort((a, b) => {
const ai = TAB_ORDER.indexOf(a.id)
const bi = TAB_ORDER.indexOf(b.id)
return (ai === -1 ? Infinity : ai) - (bi === -1 ? Infinity : bi)
})
}
// 主导出组件:包装 RestartProvider
export function BotConfigPage() {
return (
<RestartProvider>
<BotConfigPageContent />
</RestartProvider>
)
}
// 内部实现组件
function BotConfigPageContent() {
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [autoSaving, setAutoSaving] = useState(false)
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
const [editMode, setEditMode] = useState<'visual' | 'source'>('visual')
const [sourceCode, setSourceCode] = useState<string>('')
const [hasTomlError, setHasTomlError] = useState(false)
const [tomlErrorMessage, setTomlErrorMessage] = useState<string>('')
const [restartNoticeVisible, setRestartNoticeVisible] = useState(
() => localStorage.getItem('bot-config-restart-notice-dismissed') !== 'true'
)
const { toast } = useToast()
const { triggerRestart, isRestarting } = useRestart()
// 配置状态
const [botConfig, setBotConfig] = useState<ConfigSectionData | null>(null)
const [personalityConfig, setPersonalityConfig] = useState<ConfigSectionData | null>(null)
const [chatConfig, setChatConfig] = useState<ConfigSectionData | null>(null)
const [expressionConfig, setExpressionConfig] = useState<ConfigSectionData | null>(null)
const [emojiConfig, setEmojiConfig] = useState<ConfigSectionData | null>(null)
const [memoryConfig, setMemoryConfig] = useState<ConfigSectionData | null>(null)
const [visualConfig, setVisualConfig] = useState<ConfigSectionData | null>(null)
const [voiceConfig, setVoiceConfig] = useState<ConfigSectionData | null>(null)
const [messageReceiveConfig, setMessageReceiveConfig] = useState<ConfigSectionData | null>(null)
const [keywordReactionConfig, setKeywordReactionConfig] = useState<ConfigSectionData | null>(null)
const [responsePostProcessConfig, setResponsePostProcessConfig] = useState<ConfigSectionData | null>(null)
const [chineseTypoConfig, setChineseTypoConfig] = useState<ConfigSectionData | null>(null)
const [responseSplitterConfig, setResponseSplitterConfig] = useState<ConfigSectionData | null>(null)
const [logConfig, setLogConfig] = useState<ConfigSectionData | null>(null)
const [debugConfig, setDebugConfig] = useState<ConfigSectionData | null>(null)
const [maimMessageConfig, setMaimMessageConfig] = useState<ConfigSectionData | null>(null)
const [telemetryConfig, setTelemetryConfig] = useState<ConfigSectionData | null>(null)
const [webuiConfig, setWebuiConfig] = useState<ConfigSectionData | null>(null)
const [databaseConfig, setDatabaseConfig] = useState<ConfigSectionData | null>(null)
const [mcpConfig, setMcpConfig] = useState<ConfigSectionData | null>(null)
const [pluginRuntimeConfig, setPluginRuntimeConfig] = useState<ConfigSectionData | null>(null)
const [aMemorixConfig, setAMemorixConfig] = useState<ConfigSectionData | null>(null)
// Schema 状态(用于动态 tab 分组)
const [configSchema, setConfigSchema] = useState<ConfigSchema | null>(null)
// 用于标记初始加载和配置缓存
const initialLoadRef = useRef(true)
const configRef = useRef<Record<string, unknown>>({})
// ==================== 辅助函数 ====================
/**
* 翻译 TOML 错误信息为中文
*/
const translateTomlError = (errorMessage: string): string => {
// 分行处理,保留多行格式
const lines = errorMessage.split('\n')
// 翻译第一行(主要错误信息)
let firstLine = lines[0]
// 移除 "Error: " 前缀(如果有)
firstLine = firstLine.replace(/^Error:\s*/, '')
// 常见 TOML 错误模式匹配和翻译
const translations: Array<[RegExp, string | ((match: RegExpMatchArray) => string)]> = [
// Invalid TOML document 系列
[/Invalid TOML document: unrecognized escape sequence/, 'TOML 文档错误:无法识别的转义序列(提示:在双引号字符串中使用 \\\\ 转义反斜杠,或使用单引号字符串)'],
[/Invalid TOML document: only letter, numbers, dashes and underscores are allowed in keys/, 'TOML 文档错误:键名只能包含字母、数字、短横线和下划线'],
[/Invalid TOML document: (.+)/, 'TOML 文档错误:$1'],
// 位置错误系列
[/Unexpected character.*at line (\d+), column (\d+)/, '第 $1 行第 $2 列:意外的字符'],
[/Expected.*at line (\d+), column (\d+)/, '第 $1 行第 $2 列:缺少必要的字符'],
[/Invalid.*at line (\d+), column (\d+)/, '第 $1 行第 $2 列:无效的语法'],
[/Unterminated string at line (\d+)/, '第 $1 行:字符串未正常结束(缺少引号)'],
[/Duplicate key.*at line (\d+)/, '第 $1 行:重复的键名'],
[/Invalid escape sequence at line (\d+)/, '第 $1 行:无效的转义序列(提示:在双引号字符串中使用 \\\\ 转义反斜杠)'],
[/Expected.*but got.*at line (\d+)/, '第 $1 行:类型不匹配'],
[/line (\d+), column (\d+)/, '第 $1 行第 $2 列'],
// 通用错误系列
[/Unexpected end of input/, '意外的文件结束(可能缺少闭合符号)'],
[/Unexpected token/, '意外的标记'],
[/Invalid number/, '无效的数字'],
[/Invalid date/, '无效的日期格式'],
[/Invalid boolean/, '无效的布尔值(应为 true 或 false'],
[/Unexpected character/, '意外的字符'],
[/unrecognized escape sequence/, '无法识别的转义序列'],
]
// 尝试翻译第一行
for (const [pattern, replacement] of translations) {
if (pattern.test(firstLine)) {
firstLine = firstLine.replace(pattern, replacement as string)
break
}
}
// 重组多行错误信息
if (lines.length > 1) {
lines[0] = firstLine
return lines.join('\n')
}
return firstLine
}
/**
* 解析并设置所有配置状态
* 抽取自 loadConfig 和 handleModeChange 中的重复逻辑
*/
const parseAndSetConfig = useCallback((config: Record<string, unknown>) => {
configRef.current = config
setBotConfig((config.bot ?? {}) as ConfigSectionData)
setPersonalityConfig((config.personality ?? {}) as ConfigSectionData)
setChatConfig((config.chat ?? {}) as ConfigSectionData)
setExpressionConfig((config.expression ?? {}) as ConfigSectionData)
setEmojiConfig((config.emoji ?? {}) as ConfigSectionData)
setMemoryConfig((config.memory ?? {}) as ConfigSectionData)
setVisualConfig((config.visual ?? {}) as ConfigSectionData)
setVoiceConfig((config.voice ?? {}) as ConfigSectionData)
setMessageReceiveConfig((config.message_receive ?? {}) as ConfigSectionData)
setKeywordReactionConfig((config.keyword_reaction ?? {}) as ConfigSectionData)
setResponsePostProcessConfig((config.response_post_process ?? {}) as ConfigSectionData)
setChineseTypoConfig((config.chinese_typo ?? {}) as ConfigSectionData)
setResponseSplitterConfig((config.response_splitter ?? {}) as ConfigSectionData)
setLogConfig((config.log ?? {}) as ConfigSectionData)
setDebugConfig((config.debug ?? {}) as ConfigSectionData)
setMaimMessageConfig((config.maim_message ?? {}) as ConfigSectionData)
setTelemetryConfig((config.telemetry ?? {}) as ConfigSectionData)
setWebuiConfig((config.webui ?? {}) as ConfigSectionData)
setDatabaseConfig((config.database ?? {}) as ConfigSectionData)
setMcpConfig((config.mcp ?? {}) as ConfigSectionData)
setPluginRuntimeConfig((config.plugin_runtime ?? {}) as ConfigSectionData)
setAMemorixConfig((config.a_memorix ?? {}) as ConfigSectionData)
}, [])
/**
* 构建完整的配置对象用于保存
* 抽取自 saveConfig 和 handleSaveAndRestart 中的重复逻辑
*/
const buildFullConfig = useCallback(() => {
return {
...configRef.current,
bot: botConfig,
personality: personalityConfig,
chat: chatConfig,
expression: expressionConfig,
emoji: emojiConfig,
memory: memoryConfig,
visual: visualConfig,
voice: voiceConfig,
message_receive: messageReceiveConfig,
keyword_reaction: keywordReactionConfig,
response_post_process: responsePostProcessConfig,
chinese_typo: chineseTypoConfig,
response_splitter: responseSplitterConfig,
log: logConfig,
debug: debugConfig,
maim_message: maimMessageConfig,
telemetry: telemetryConfig,
webui: webuiConfig,
database: databaseConfig,
mcp: mcpConfig,
plugin_runtime: pluginRuntimeConfig,
a_memorix: aMemorixConfig,
}
}, [
botConfig,
personalityConfig,
chatConfig,
expressionConfig,
emojiConfig,
memoryConfig,
visualConfig,
voiceConfig,
messageReceiveConfig,
keywordReactionConfig,
responsePostProcessConfig,
chineseTypoConfig,
responseSplitterConfig,
logConfig,
debugConfig,
maimMessageConfig,
telemetryConfig,
webuiConfig,
databaseConfig,
mcpConfig,
pluginRuntimeConfig,
aMemorixConfig,
])
// 加载源代码
const loadSourceCode = useCallback(async () => {
try {
const result = await getBotConfigRaw()
if (!result.success) {
toast({
variant: 'destructive',
title: '加载失败',
description: result.error,
})
return
}
const raw = (result.data as unknown as Record<string, unknown>).content as string
// 将 TOML 基本字符串中的转义序列转换为实际字符以便在编辑器中正确显示
// 使用正则表达式只处理双引号字符串内的转义序列,不影响单引号字符串
const unescaped = raw.replace(/"([^"]*)"/g, (_match, content) => {
const decoded = content
.replace(/\\n/g, '\n') // 换行符
.replace(/\\t/g, '\t') // 制表符
.replace(/\\r/g, '\r') // 回车符
.replace(/\\"/g, '"') // 双引号
.replace(/\\\\/g, '\\') // 反斜杠(必须放在最后)
return `"${decoded}"`
})
setSourceCode(unescaped)
setHasTomlError(false)
} catch (error) {
toast({
variant: 'destructive',
title: '加载失败',
description: error instanceof Error ? error.message : '加载源代码失败',
})
}
}, [toast])
// 加载配置
const loadConfig = useCallback(async () => {
try {
setLoading(true)
const [result, schemaResult] = await Promise.all([getBotConfig(), getBotConfigSchema()])
if (!result.success) {
toast({
title: '加载失败',
description: result.error,
variant: 'destructive',
})
setLoading(false)
return
}
parseAndSetConfig((result.data as Record<string, unknown>).config as Record<string, unknown>)
if (schemaResult.success && schemaResult.data) {
setConfigSchema((schemaResult.data as unknown as Record<string, unknown>).schema as ConfigSchema)
}
setHasUnsavedChanges(false)
initialLoadRef.current = false
// 同时加载源代码
await loadSourceCode()
} catch (error) {
console.error('加载配置失败:', error)
toast({
title: '加载失败',
description: '无法加载配置文件',
variant: 'destructive',
})
} finally {
setLoading(false)
}
}, [toast, loadSourceCode, parseAndSetConfig])
useEffect(() => {
loadConfig()
}, [loadConfig])
useEffect(() => {
const hookEntries = [
['bot.platforms', BotPlatformsHook],
['chat.chat_prompts', ChatPromptsHook],
['chat.talk_value_rules', ChatTalkValueRulesHook],
['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 () => {
for (const [fieldPath] of hookEntries) {
fieldHooks.unregister(fieldPath)
}
}
}, [])
// 使用模块化的 useAutoSave hook
const { triggerAutoSave, cancelPendingAutoSave } = useAutoSave(
initialLoadRef.current,
setAutoSaving,
setHasUnsavedChanges
)
// 使用 useConfigAutoSave hook 简化配置变化监听
// 注意: useConfigAutoSave 是一个 hook不能在条件语句或循环中调用
// 因此我们仍然需要逐个调用,但代码更简洁
useConfigAutoSave(botConfig, 'bot', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(personalityConfig, 'personality', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(chatConfig, 'chat', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(expressionConfig, 'expression', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(emojiConfig, 'emoji', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(memoryConfig, 'memory', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(visualConfig, 'visual', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(voiceConfig, 'voice', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(messageReceiveConfig, 'message_receive', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(keywordReactionConfig, 'keyword_reaction', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(responsePostProcessConfig, 'response_post_process', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(chineseTypoConfig, 'chinese_typo', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(responseSplitterConfig, 'response_splitter', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(logConfig, 'log', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(debugConfig, 'debug', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(maimMessageConfig, 'maim_message', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(telemetryConfig, 'telemetry', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(webuiConfig, 'webui', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(databaseConfig, 'database', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(mcpConfig, 'mcp', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(pluginRuntimeConfig, 'plugin_runtime', initialLoadRef.current, triggerAutoSave)
useConfigAutoSave(aMemorixConfig, 'a_memorix', initialLoadRef.current, triggerAutoSave)
// 保存源代码
const saveSourceCode = async () => {
try {
setSaving(true)
// 编辑器展示时会把 basic string 内的 \n 展开成真实换行;保存前先转回 TOML 转义序列。
const escapedSourceCode = sourceCode.replace(/"([^"]*)"/g, (_match, content) => {
const encoded = content
.replace(/\\/g, '\\\\') // 反斜杠必须先转义,避免 \s 等序列被 TOML 当作非法转义
.replace(/"/g, '\\"')
.replace(/\n/g, '\\n')
.replace(/\t/g, '\\t')
.replace(/\r/g, '\\r')
return `"${encoded}"`
})
// 前端验证 TOML 格式
try {
parseToml(escapedSourceCode)
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'TOML 格式错误'
const translatedMsg = translateTomlError(errorMsg)
setHasTomlError(true)
setTomlErrorMessage(translatedMsg)
toast({
variant: 'destructive',
title: 'TOML 格式错误',
description: translatedMsg,
})
setSaving(false)
return
}
const result = await updateBotConfigRaw(escapedSourceCode)
if (!result.success) {
setHasTomlError(true)
const errorMsg = result.error
setTomlErrorMessage(errorMsg)
toast({
variant: 'destructive',
title: '保存失败',
description: errorMsg,
})
return
}
setHasUnsavedChanges(false)
setHasTomlError(false)
setTomlErrorMessage('')
toast({
title: '保存成功',
description: '配置已保存',
})
// 重新加载可视化配置
await loadConfig()
} catch (error) {
setHasTomlError(true)
const errorMsg = error instanceof Error ? error.message : '保存配置失败'
setTomlErrorMessage(errorMsg)
toast({
variant: 'destructive',
title: '保存失败',
description: errorMsg,
})
} finally {
setSaving(false)
}
}
// 处理模式切换
const handleModeChange = async (mode: 'visual' | 'source') => {
if (hasUnsavedChanges) {
toast({
variant: 'destructive',
title: '切换失败',
description: '请先保存当前更改',
})
return
}
setEditMode(mode)
if (mode === 'source') {
await loadSourceCode()
} else {
// 切换回可视化时,直接重新加载配置但不显示全局 loading
try {
const result = await getBotConfig()
if (!result.success) {
toast({
title: '加载失败',
description: result.error,
variant: 'destructive',
})
return
}
parseAndSetConfig((result.data as Record<string, unknown>).config as Record<string, unknown>)
setHasUnsavedChanges(false)
} catch (error) {
console.error('加载配置失败:', error)
toast({
title: '加载失败',
description: '无法加载配置文件',
variant: 'destructive',
})
}
}
}
// 手动保存
const saveConfig = async () => {
try {
setSaving(true)
// 取消待处理的自动保存
cancelPendingAutoSave()
const result = await updateBotConfig(buildFullConfig())
if (!result.success) {
toast({
title: '保存失败',
description: result.error,
variant: 'destructive',
})
setSaving(false)
return
}
setHasUnsavedChanges(false)
toast({
title: '保存成功',
description: '麦麦主程序配置已保存',
})
} catch (error) {
console.error('保存配置失败:', error)
toast({
title: '保存失败',
description: (error as Error).message,
variant: 'destructive',
})
} finally {
setSaving(false)
}
}
// 重启麦麦
const handleRestart = async () => {
await triggerRestart()
}
const dismissRestartNotice = () => {
localStorage.setItem('bot-config-restart-notice-dismissed', 'true')
setRestartNoticeVisible(false)
}
const handleReloadFromFile = async () => {
cancelPendingAutoSave()
await loadConfig()
setHasUnsavedChanges(false)
toast({
title: '已刷新',
description: '已从 bot_config.toml 重新读取配置',
})
}
// 保存并重启
const handleSaveAndRestart = async () => {
try {
setSaving(true)
// 取消待处理的自动保存
cancelPendingAutoSave()
const result = await updateBotConfig(buildFullConfig())
if (!result.success) {
toast({
title: '保存失败',
description: result.error,
variant: 'destructive',
})
setSaving(false)
return
}
setHasUnsavedChanges(false)
toast({
title: '保存成功',
description: '配置已保存,即将重启麦麦...',
})
// 等待一下让用户看到保存成功的提示
await new Promise(resolve => setTimeout(resolve, TOAST_DISPLAY_DELAY))
await handleRestart()
} catch (error) {
console.error('保存失败:', error)
toast({
title: '保存失败',
description: (error as Error).message,
variant: 'destructive',
})
} finally {
setSaving(false)
}
}
// 根据 schema 构建 tab 分组
const tabGroups = useMemo(() => {
if (!configSchema) return []
return buildTabGroupsFromSchema(configSchema)
}, [configSchema])
const sectionValues = useMemo<Record<string, ConfigSectionData | null>>(
() => ({
bot: botConfig,
personality: personalityConfig,
chat: chatConfig,
expression: expressionConfig,
emoji: emojiConfig,
memory: memoryConfig,
visual: visualConfig,
voice: voiceConfig,
message_receive: messageReceiveConfig,
keyword_reaction: keywordReactionConfig,
response_post_process: responsePostProcessConfig,
chinese_typo: chineseTypoConfig,
response_splitter: responseSplitterConfig,
log: logConfig,
debug: debugConfig,
maim_message: maimMessageConfig,
telemetry: telemetryConfig,
webui: webuiConfig,
database: databaseConfig,
mcp: mcpConfig,
plugin_runtime: pluginRuntimeConfig,
a_memorix: aMemorixConfig,
}),
[
botConfig,
personalityConfig,
chatConfig,
expressionConfig,
emojiConfig,
memoryConfig,
visualConfig,
voiceConfig,
messageReceiveConfig,
keywordReactionConfig,
responsePostProcessConfig,
chineseTypoConfig,
responseSplitterConfig,
logConfig,
debugConfig,
maimMessageConfig,
telemetryConfig,
webuiConfig,
databaseConfig,
mcpConfig,
pluginRuntimeConfig,
aMemorixConfig,
]
)
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,
visual: setVisualConfig,
voice: setVoiceConfig,
message_receive: setMessageReceiveConfig,
keyword_reaction: setKeywordReactionConfig,
response_post_process: setResponsePostProcessConfig,
chinese_typo: setChineseTypoConfig,
response_splitter: setResponseSplitterConfig,
log: setLogConfig,
debug: setDebugConfig,
maim_message: setMaimMessageConfig,
telemetry: setTelemetryConfig,
webui: setWebuiConfig,
database: setDatabaseConfig,
mcp: setMcpConfig,
plugin_runtime: setPluginRuntimeConfig,
a_memorix: setAMemorixConfig,
}
sectionSetterMap[sectionName]?.(value)
}, [])
if (loading) {
return (
<ScrollArea className="h-full">
<div className="space-y-4 sm:space-y-6 p-4 sm:p-6">
<div className="flex items-center justify-center h-64">
<p className="text-muted-foreground">...</p>
</div>
</div>
</ScrollArea>
)
}
return (
<ScrollArea className="h-full">
<div className="space-y-4 sm:space-y-6 p-4 sm:p-6">
{/* 页面标题 */}
<div className="flex flex-col gap-3 sm:gap-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="min-w-0">
<h1 className="text-xl sm:text-2xl md:text-3xl font-bold"></h1>
<p className="text-muted-foreground mt-1 text-xs sm:text-sm"></p>
</div>
{/* 按钮组 - 桌面端靠右 */}
<div className="flex flex-wrap gap-2 flex-shrink-0 sm:justify-end">
<Tabs
value={editMode}
onValueChange={(v) => handleModeChange(v as 'visual' | 'source')}
className="w-full min-w-[13rem] sm:w-[14rem]"
>
<TabsList className="grid h-8 w-full grid-cols-2 sm:h-9">
<TabsTrigger value="visual" className="px-2 text-xs">
<Layout className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
</TabsTrigger>
<TabsTrigger value="source" className="px-2 text-xs">
<Code2 className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
</TabsTrigger>
</TabsList>
</Tabs>
<Button
onClick={handleReloadFromFile}
disabled={saving || autoSaving || isRestarting}
size="sm"
variant="outline"
className="w-20 sm:w-24"
>
<RefreshCw className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
</Button>
<Button
onClick={editMode === 'visual' ? saveConfig : saveSourceCode}
disabled={saving || autoSaving || !hasUnsavedChanges || isRestarting}
size="sm"
variant="outline"
className="w-20 sm:w-24"
>
<Save className="h-4 w-4 flex-shrink-0" strokeWidth={2} fill="none" />
<span className="ml-1 truncate text-xs sm:text-sm">
{saving ? '保存中' : autoSaving ? '自动' : hasUnsavedChanges ? '保存' : '已保存'}
</span>
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
disabled={saving || autoSaving || isRestarting}
size="sm"
className="w-20 sm:w-28"
>
<Power className="h-4 w-4 flex-shrink-0" />
<span className="ml-1 truncate text-xs sm:text-sm">
{isRestarting ? '重启中' : hasUnsavedChanges ? '保存重启' : '重启'}
</span>
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription asChild>
<div>
<p>
{hasUnsavedChanges
? '当前有未保存的配置更改。点击确认将先保存配置,然后重启麦麦使新配置生效。重启过程中麦麦将暂时离线。'
: '即将重启麦麦主程序。重启过程中麦麦将暂时离线,配置将在重启后生效。'
}
</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={hasUnsavedChanges ? handleSaveAndRestart : handleRestart}>
{hasUnsavedChanges ? '保存并重启' : '确认重启'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
</div>
{/* 重启提示 */}
{restartNoticeVisible && (
<Alert>
<Info className="h-4 w-4" />
<AlertDescription className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<span>
<strong></strong>"保存并重启"
</span>
<Button type="button" variant="outline" size="sm" onClick={dismissRestartNotice}>
</Button>
</AlertDescription>
</Alert>
)}
{/* 源代码模式 */}
{editMode === 'source' && (
<div className="space-y-4">
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
<strong></strong> TOML TOML
{hasTomlError && tomlErrorMessage && (
<div className="text-destructive font-semibold mt-3 p-3 bg-destructive/10 rounded-md">
<div className="font-bold mb-2"> TOML </div>
<pre className="text-sm font-mono whitespace-pre-wrap break-words">
{tomlErrorMessage}
</pre>
</div>
)}
</AlertDescription>
</Alert>
<CodeEditor
value={sourceCode}
onChange={(value) => {
setSourceCode(value)
setHasUnsavedChanges(true)
// 清除之前的错误状态
if (hasTomlError) {
setHasTomlError(false)
setTomlErrorMessage('')
}
}}
language="toml"
height="calc(100vh - 280px)"
minHeight="500px"
placeholder="TOML 配置内容"
/>
</div>
)}
{/* 可视化模式 */}
{editMode === 'visual' && (
<DynamicConfigTabs
configSchema={configSchema}
tabGroups={tabGroups}
sectionValues={sectionValues}
setSectionValue={setSectionValue}
setHasUnsavedChanges={setHasUnsavedChanges}
/>
)}
{/* 重启遮罩层 */}
<RestartOverlay />
</div>
</ScrollArea>
)
}
// ==================== 动态 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 {
configSchema: ConfigSchema | null
tabGroups: TabGroup[]
sectionValues: Record<string, ConfigSectionData | null>
setSectionValue: (sectionName: string, value: ConfigSectionData) => void
setHasUnsavedChanges: (v: boolean) => void
}
function DynamicConfigTabs(props: DynamicConfigTabsProps) {
const { configSchema, sectionValues, setHasUnsavedChanges, setSectionValue, tabGroups } = props
const [expanded, setExpanded] = useState(false)
const [activeTab, setActiveTab] = useState(tabGroups[0]?.id ?? '')
useEffect(() => {
if (!tabGroups.some((tab) => tab.id === activeTab)) {
setActiveTab(tabGroups[0]?.id ?? '')
}
}, [activeTab, tabGroups])
if (tabGroups.length === 0 || !configSchema?.nested) {
return null
}
const visibleTabGroups = expanded
? tabGroups
: tabGroups.filter((tab) => DEFAULT_VISIBLE_TAB_IDS.has(tab.id))
const hasCollapsibleTabs = tabGroups.some((tab) => !DEFAULT_VISIBLE_TAB_IDS.has(tab.id))
const firstExpandedTabId = visibleTabGroups.find(
(tab) => !DEFAULT_VISIBLE_TAB_IDS.has(tab.id)
)?.id
const toggleExpanded = () => {
setExpanded((current) => {
if (current && !DEFAULT_VISIBLE_TAB_IDS.has(activeTab)) {
const firstDefaultTab = tabGroups.find((tab) => DEFAULT_VISIBLE_TAB_IDS.has(tab.id))
setActiveTab(firstDefaultTab?.id ?? tabGroups[0]?.id ?? '')
}
return !current
})
}
const renderTabContent = (tab: TabGroup) => {
const tabNestedEntries = tab.sections
.map((sectionName) => [sectionName, configSchema.nested?.[sectionName]] as const)
.filter((entry): entry is readonly [string, ConfigSchema] => Boolean(entry[1]))
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
schema={tabSchema}
values={values}
onChange={(fieldPath, value) => {
const [sectionName, ...restPath] = fieldPath.split('.')
if (!sectionName) {
return
}
const currentSectionValue = sectionValues[sectionName] ?? {}
const nextSectionValue =
restPath.length === 0
? (value as ConfigSectionData)
: updateNestedValue(currentSectionValue, restPath, value)
setSectionValue(sectionName, nextSectionValue)
setHasUnsavedChanges(true)
}}
hooks={fieldHooks}
/>
)
}
return (
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="flex flex-wrap h-auto gap-1 p-1">
{visibleTabGroups.map((tab) => {
const isExpandedOnlyTab = !DEFAULT_VISIBLE_TAB_IDS.has(tab.id)
return (
<Fragment key={tab.id}>
{tab.id === firstExpandedTabId && (
<span className="mx-1 hidden h-6 w-px bg-border/80 sm:block" />
)}
<TabsTrigger
value={tab.id}
className={cn(
"text-xs px-2 py-1.5 sm:px-3 sm:py-2 data-[state=active]:shadow-sm",
isExpandedOnlyTab &&
"border border-dashed border-border/70 bg-background/45 text-muted-foreground/80 hover:bg-background/70 data-[state=active]:border-primary/45 data-[state=active]:bg-primary/10 data-[state=active]:text-primary data-[state=active]:shadow-none"
)}
>
{tab.label}
</TabsTrigger>
</Fragment>
)
})}
{hasCollapsibleTabs && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 px-2 text-xs sm:h-9 sm:px-3"
onClick={toggleExpanded}
>
{expanded ? (
<ChevronUp className="mr-1 h-3.5 w-3.5" />
) : (
<ChevronDown className="mr-1 h-3.5 w-3.5" />
)}
{expanded ? '收起' : '更多'}
</Button>
)}
</TabsList>
{tabGroups.map((tab) => (
<TabsContent key={tab.id} value={tab.id} className="space-y-4">
{renderTabContent(tab)}
</TabsContent>
))}
</Tabs>
)
}