import { useState, useRef, useEffect, useCallback } from 'react' import { Alert, AlertDescription } from '@/components/ui/alert' import { Info, Upload, Download, FileText, Trash2, FolderOpen, Save, RefreshCw, Package, ChevronDown } from 'lucide-react' import { ScrollArea } from '@/components/ui/scroll-area' import { useToast } from '@/hooks/use-toast' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Switch } from '@/components/ui/switch' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from '@/components/ui/collapsible' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select' import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger, } from '@/components/ui/alert-dialog' import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from '@/components/ui/card' import { getSavedConfigPath, saveConfigPath, loadConfigFromPath, saveConfigToPath, } from '@/lib/adapter-config-api' import type { AdapterConfig, PresetKey } from './adapter/types' import { DEFAULT_CONFIG, PRESETS } from './adapter/types' import { parseTOML, generateTOML, validatePath } from './adapter/utils' export function AdapterConfigPage() { // 工作模式:'upload' = 上传文件模式, 'path' = 指定路径模式, 'preset' = 预设模式 const [mode, setMode] = useState<'upload' | 'path' | 'preset'>('upload') const [config, setConfig] = useState(null) const [fileName, setFileName] = useState('') const [configPath, setConfigPath] = useState('') const [selectedPreset, setSelectedPreset] = useState('oneclick') const [pathError, setPathError] = useState('') const [isSaving, setIsSaving] = useState(false) const [isLoading, setIsLoading] = useState(false) const [showModeSwitchDialog, setShowModeSwitchDialog] = useState(false) const [showClearPathDialog, setShowClearPathDialog] = useState(false) const [pendingMode, setPendingMode] = useState<'upload' | 'path' | 'preset' | null>(null) const [isModeConfigOpen, setIsModeConfigOpen] = useState(false) const fileInputRef = useRef(null) const { toast } = useToast() const saveTimeoutRef = useRef(null) // 处理路径输入变化 const handlePathChange = (value: string) => { setConfigPath(value) // 实时验证 if (value.trim()) { const validation = validatePath(value) setPathError(validation.error) } else { setPathError('') } } // 从预设加载配置 const handleLoadFromPreset = useCallback(async (presetKey: PresetKey) => { const preset = PRESETS[presetKey] setIsLoading(true) try { const content = await loadConfigFromPath(preset.path) const parsedConfig = parseTOML(content) setConfig(parsedConfig) setSelectedPreset(presetKey) setConfigPath(preset.path) // 保存路径偏好 await saveConfigPath(preset.path) toast({ title: '加载成功', description: `已从${preset.name}预设加载配置`, }) } catch (error) { console.error('加载预设配置失败:', error) toast({ title: '加载失败', description: error instanceof Error ? error.message : '无法读取预设配置文件', variant: 'destructive', }) } finally { setIsLoading(false) } }, [toast]) // 从指定路径加载配置 const handleLoadFromPath = useCallback(async (path: string) => { // 验证路径 const validation = validatePath(path) if (!validation.valid) { setPathError(validation.error) toast({ title: '路径无效', description: validation.error, variant: 'destructive', }) return } setPathError('') setIsLoading(true) try { const content = await loadConfigFromPath(path) const parsedConfig = parseTOML(content) setConfig(parsedConfig) setConfigPath(path) // 保存路径偏好 await saveConfigPath(path) toast({ title: '加载成功', description: `已从配置文件加载`, }) } catch (error) { console.error('加载配置失败:', error) toast({ title: '加载失败', description: error instanceof Error ? error.message : '无法读取配置文件', variant: 'destructive', }) } finally { setIsLoading(false) } }, [toast]) // 组件挂载时加载保存的路径 useEffect(() => { const loadSavedPath = async () => { try { const savedPath = await getSavedConfigPath() if (savedPath && savedPath.path) { setConfigPath(savedPath.path) // 检查是否是预设路径 const presetEntry = Object.entries(PRESETS).find(([, preset]) => preset.path === savedPath.path) if (presetEntry) { setMode('preset') setSelectedPreset(presetEntry[0] as PresetKey) await handleLoadFromPreset(presetEntry[0] as PresetKey) } else { setMode('path') await handleLoadFromPath(savedPath.path) } } } catch (error) { console.error('加载保存的路径失败:', error) } } loadSavedPath() }, [handleLoadFromPath, handleLoadFromPreset]) // 自动保存配置到路径(防抖) const autoSaveToPath = useCallback((updatedConfig: AdapterConfig) => { if ((mode !== 'path' && mode !== 'preset') || !configPath) return // 清除之前的定时器 if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current) } // 设置新的定时器(1秒后保存) saveTimeoutRef.current = setTimeout(async () => { setIsSaving(true) try { const tomlContent = generateTOML(updatedConfig) await saveConfigToPath(configPath, tomlContent) toast({ title: '自动保存成功', description: '配置已保存到文件', }) } catch (error) { console.error('自动保存失败:', error) toast({ title: '自动保存失败', description: error instanceof Error ? error.message : '保存配置失败', variant: 'destructive', }) } finally { setIsSaving(false) } }, 1000) }, [mode, configPath, toast]) // 手动保存配置 const handleManualSave = async () => { if (!config || !configPath) return // 再次验证路径 const validation = validatePath(configPath) if (!validation.valid) { toast({ title: '保存失败', description: validation.error, variant: 'destructive', }) return } setIsSaving(true) try { const tomlContent = generateTOML(config) await saveConfigToPath(configPath, tomlContent) toast({ title: '保存成功', description: '配置已保存到文件', }) } catch (error) { console.error('保存失败:', error) toast({ title: '保存失败', description: error instanceof Error ? error.message : '保存配置失败', variant: 'destructive', }) } finally { setIsSaving(false) } } // 刷新配置(重新从文件加载) const handleRefresh = async () => { if (!configPath) return await handleLoadFromPath(configPath) } // 切换模式 const handleModeChange = (newMode: 'upload' | 'path' | 'preset') => { if (newMode === mode) return // 如果有未保存的配置,显示确认对话框 if (config) { setPendingMode(newMode) setShowModeSwitchDialog(true) return } // 直接切换模式 performModeSwitch(newMode) } // 执行模式切换 const performModeSwitch = (newMode: 'upload' | 'path' | 'preset') => { setConfig(null) setFileName('') setPathError('') setMode(newMode) // 如果切换到预设模式,自动加载默认预设 if (newMode === 'preset') { handleLoadFromPreset('oneclick') } const modeNames = { upload: '现在可以上传配置文件', path: '现在可以指定配置文件路径', preset: '现在可以使用预设配置', } toast({ title: '已切换模式', description: modeNames[newMode], }) } // 确认模式切换 const confirmModeSwitch = () => { if (pendingMode) { performModeSwitch(pendingMode) setPendingMode(null) } setShowModeSwitchDialog(false) } // 清空路径 const handleClearPath = () => { if (config) { setShowClearPathDialog(true) return } // 直接清空 performClearPath() } // 执行清空路径 const performClearPath = () => { setConfigPath('') setConfig(null) setPathError('') toast({ title: '已清空', description: '路径和配置已清空', }) } // 确认清空路径 const confirmClearPath = () => { performClearPath() setShowClearPathDialog(false) } // 上传文件处理 const handleFileUpload = (event: React.ChangeEvent) => { const file = event.target.files?.[0] if (!file) return const reader = new FileReader() reader.onload = (e) => { try { const content = e.target?.result as string const parsedConfig = parseTOML(content) setConfig(parsedConfig) setFileName(file.name) toast({ title: '上传成功', description: `已加载配置文件:${file.name}`, }) } catch (error) { console.error('解析配置文件失败:', error) toast({ title: '解析失败', description: '配置文件格式错误,请检查文件内容', variant: 'destructive', }) } } reader.readAsText(file) } // 下载配置文件 const handleDownload = () => { if (!config) return const tomlContent = generateTOML(config) const blob = new Blob([tomlContent], { type: 'text/plain;charset=utf-8' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = fileName || 'config.toml' document.body.appendChild(a) a.click() document.body.removeChild(a) URL.revokeObjectURL(url) toast({ title: '下载成功', description: '配置文件已下载,请手动覆盖并重启适配器', }) } // 使用默认配置 const handleUseDefault = () => { setConfig(JSON.parse(JSON.stringify(DEFAULT_CONFIG))) setFileName('config.toml') toast({ title: '已加载默认配置', description: '可以开始编辑配置', }) } return (
{/* 页面标题 */}

麦麦适配器配置

管理麦麦的 QQ 适配器的配置文件

{/* 模式选择 */}
工作模式 选择配置文件的管理方式
{/* 预设模式 */}
handleModeChange('preset')} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleModeChange('preset') } }} >

预设模式

使用预设的部署配置

{/* 上传模式 */}
handleModeChange('upload')} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleModeChange('upload') } }} >

上传文件模式

上传配置文件,编辑后下载并手动覆盖

{/* 路径模式 */}
handleModeChange('path')} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleModeChange('path') } }} >

指定路径模式

指定配置文件路径,自动加载和保存

{/* 预设模式配置 */} {mode === 'preset' && (
{Object.entries(PRESETS).map(([key, preset]) => { const Icon = preset.icon const isSelected = selectedPreset === key return (
{ setSelectedPreset(key as PresetKey) handleLoadFromPreset(key as PresetKey) }} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setSelectedPreset(key as PresetKey); handleLoadFromPreset(key as PresetKey) } }} >

{preset.name}

{preset.description}

{preset.path}

) })}
)} {/* 路径模式配置 */} {mode === 'path' && (
handlePathChange(e.target.value)} placeholder="例: C:\Adapter\config.toml" className={`text-sm ${pathError ? 'border-destructive' : ''}`} /> {pathError && (

{pathError}

)}
路径格式说明
Windows
C:\Adapter\config.toml
D:\MaiBot\adapter\config.toml
\\server\share\config.toml
Linux
/opt/adapter/config.toml
/home/user/adapter/config.toml
~/adapter/config.toml

💡 配置会自动保存到指定文件,修改后 1 秒自动保存

)}
{/* 操作提示 */} {mode === 'preset' ? ( <> 预设模式:选择预设的部署方式,配置会自动加载,修改后 1 秒自动保存{isSaving && ' (正在保存...)'} ) : mode === 'upload' ? ( <> 上传文件模式:上传配置文件 → 在线编辑 → 下载文件 → 手动覆盖并重启适配器 ) : ( <> 指定路径模式:指定配置文件路径后,配置会自动加载,修改后 1 秒自动保存{isSaving && ' (正在保存...)'} )} {/* 上传模式的操作按钮 */} {mode === 'upload' && !config && (
)} {/* 上传模式的下载按钮 */} {mode === 'upload' && config && (
)} {/* 预设和路径模式的操作按钮 */} {(mode === 'preset' || mode === 'path') && config && (
{mode === 'path' && ( )}
)} {/* 配置编辑区域 */} {!config ? (

尚未加载配置

{mode === 'preset' ? '请选择预设的部署方式' : mode === 'upload' ? '请上传现有配置文件,或使用默认配置开始编辑' : '请指定配置文件路径并点击加载按钮'}

) : (
Napcat 连接 Napcat 麦麦连接 麦麦 聊天控制 聊天 语音与转发 语音 调试
{/* Napcat 服务器配置 */} { setConfig(newConfig) autoSaveToPath(newConfig) }} /> {/* 麦麦服务器配置 */} { setConfig(newConfig) autoSaveToPath(newConfig) }} /> {/* 聊天控制配置 */} { setConfig(newConfig) autoSaveToPath(newConfig) }} /> {/* 语音配置 */} { setConfig(newConfig) autoSaveToPath(newConfig) }} /> {/* 调试配置 */} { setConfig(newConfig) autoSaveToPath(newConfig) }} />
)} {/* 模式切换确认对话框 */} 确认切换模式 切换模式将清空当前配置,确定要继续吗?
请确保已保存重要配置
{ setShowModeSwitchDialog(false) setPendingMode(null) }}> 取消 确认切换
{/* 清空路径确认对话框 */} 确认清空路径 清空路径将清除当前配置,确定要继续吗?
此操作不会删除配置文件,只是清除界面中的配置
setShowClearPathDialog(false)}> 取消 确认清空
) } // Napcat 服务器配置组件 function NapcatServerSection({ config, onChange, }: { config: AdapterConfig onChange: (config: AdapterConfig) => void }) { return (

Napcat WebSocket 服务设置

onChange({ ...config, napcat_server: { ...config.napcat_server, host: e.target.value }, }) } placeholder="localhost" className="text-sm md:text-base" />

Napcat 设定的主机地址

onChange({ ...config, napcat_server: { ...config.napcat_server, port: e.target.value ? parseInt(e.target.value) : 0 }, }) } placeholder="8095" className="text-sm md:text-base" />

Napcat 设定的端口(留空使用默认值 8095)

onChange({ ...config, napcat_server: { ...config.napcat_server, token: e.target.value }, }) } placeholder="留空表示无需令牌" className="text-sm md:text-base" />

Napcat 设定的访问令牌,若无则留空

onChange({ ...config, napcat_server: { ...config.napcat_server, heartbeat_interval: e.target.value ? parseInt(e.target.value) : 0, }, }) } placeholder="30" className="text-sm md:text-base" />

与 Napcat 设置的心跳间隔保持一致(留空使用默认值 30)

) } // 麦麦服务器配置组件 function MaiBotServerSection({ config, onChange, }: { config: AdapterConfig onChange: (config: AdapterConfig) => void }) { return (

麦麦 WebSocket 服务设置

onChange({ ...config, maibot_server: { ...config.maibot_server, host: e.target.value }, }) } placeholder="localhost" className="text-sm md:text-base" />

麦麦在 .env 文件中设置的 HOST 字段

onChange({ ...config, maibot_server: { ...config.maibot_server, port: e.target.value ? parseInt(e.target.value) : 0 }, }) } placeholder="8000" className="text-sm md:text-base" />

麦麦在 .env 文件中设置的 PORT 字段(留空使用默认值 8000)

) } // 聊天控制配置组件 function ChatControlSection({ config, onChange, }: { config: AdapterConfig onChange: (config: AdapterConfig) => void }) { const addToList = (listType: 'group' | 'private' | 'ban') => { const newConfig = { ...config } if (listType === 'group') { newConfig.chat.group_list = [...newConfig.chat.group_list, 0] } else if (listType === 'private') { newConfig.chat.private_list = [...newConfig.chat.private_list, 0] } else { newConfig.chat.ban_user_id = [...newConfig.chat.ban_user_id, 0] } onChange(newConfig) } const removeFromList = (listType: 'group' | 'private' | 'ban', index: number) => { const newConfig = { ...config } if (listType === 'group') { newConfig.chat.group_list = newConfig.chat.group_list.filter((_, i) => i !== index) } else if (listType === 'private') { newConfig.chat.private_list = newConfig.chat.private_list.filter((_, i) => i !== index) } else { newConfig.chat.ban_user_id = newConfig.chat.ban_user_id.filter((_, i) => i !== index) } onChange(newConfig) } const updateListItem = (listType: 'group' | 'private' | 'ban', index: number, value: number) => { const newConfig = { ...config } if (listType === 'group') { newConfig.chat.group_list[index] = value } else if (listType === 'private') { newConfig.chat.private_list[index] = value } else { newConfig.chat.ban_user_id[index] = value } onChange(newConfig) } return (

聊天黑白名单功能

{/* 群组名单 */}
{config.chat.group_list.map((groupId, index) => (
updateListItem('group', index, parseInt(e.target.value) || 0)} placeholder="输入群号" className="text-sm md:text-base" /> 确认删除 确定要删除群号 {groupId} 吗?此操作无法撤销。 取消 removeFromList('group', index)}> 删除
))} {config.chat.group_list.length === 0 && (

暂无群组

)}
{/* 私聊名单 */}
{config.chat.private_list.map((userId, index) => (
updateListItem('private', index, parseInt(e.target.value) || 0)} placeholder="输入QQ号" className="text-sm md:text-base" /> 确认删除 确定要删除用户 {userId} 吗?此操作无法撤销。 取消 removeFromList('private', index)}> 删除
))} {config.chat.private_list.length === 0 && (

暂无用户

)}
{/* 全局禁止名单 */}

名单中的用户无法进行任何聊天

{config.chat.ban_user_id.map((userId, index) => (
updateListItem('ban', index, parseInt(e.target.value) || 0)} placeholder="输入QQ号" className="text-sm md:text-base" /> 确认删除 确定要从全局禁止名单中删除用户 {userId} 吗?此操作无法撤销。 取消 removeFromList('ban', index)}> 删除
))} {config.chat.ban_user_id.length === 0 && (

暂无禁止用户

)}
{/* 其他设置 */}

是否屏蔽来自QQ官方机器人的消息

onChange({ ...config, chat: { ...config.chat, ban_qq_bot: checked }, }) } />

是否响应戳一戳消息

onChange({ ...config, chat: { ...config.chat, enable_poke: checked }, }) } />
) } // 语音和转发消息配置组件 function VoiceSection({ config, onChange, }: { config: AdapterConfig onChange: (config: AdapterConfig) => void }) { return (
{/* 语音设置 */}

发送语音设置

请确保已配置 TTS 并有对应的适配器

onChange({ ...config, voice: { ...config.voice, use_tts: checked }, }) } />
{/* 转发消息处理设置 */}

转发消息处理设置

onChange({ ...config, forward: { ...config.forward, image_threshold: e.target.value ? parseInt(e.target.value) : 0 }, }) } placeholder="30" className="text-sm md:text-base" />

转发消息中图片数量超过此值时使用占位符(避免麦麦VLM处理卡死)

) } // 调试配置组件 function DebugSection({ config, onChange, }: { config: AdapterConfig onChange: (config: AdapterConfig) => void }) { return (

调试设置

设置适配器的日志输出等级

) }