diff --git a/packages/napcat-onebot/network/plugin-manger.ts b/packages/napcat-onebot/network/plugin-manger.ts index 1a13ba8d..740831cb 100644 --- a/packages/napcat-onebot/network/plugin-manger.ts +++ b/packages/napcat-onebot/network/plugin-manger.ts @@ -15,6 +15,45 @@ export interface PluginPackageJson { author?: string; } +export interface PluginConfigItem { + key: string; + type: 'string' | 'number' | 'boolean' | 'select' | 'multi-select' | 'html' | 'text'; + label: string; + description?: string; + default?: any; + options?: { label: string; value: string | number; }[]; + placeholder?: string; +} + +export class NapCatConfig { + static text (key: string, label: string, defaultValue?: string, description?: string): PluginConfigItem { + return { key, type: 'string', label, default: defaultValue, description }; + } + static number (key: string, label: string, defaultValue?: number, description?: string): PluginConfigItem { + return { key, type: 'number', label, default: defaultValue, description }; + } + static boolean (key: string, label: string, defaultValue?: boolean, description?: string): PluginConfigItem { + return { key, type: 'boolean', label, default: defaultValue, description }; + } + static select (key: string, label: string, options: { label: string; value: string | number; }[], defaultValue?: string | number, description?: string): PluginConfigItem { + return { key, type: 'select', label, options, default: defaultValue, description }; + } + static multiSelect (key: string, label: string, options: { label: string; value: string | number; }[], defaultValue?: (string | number)[], description?: string): PluginConfigItem { + return { key, type: 'multi-select', label, options, default: defaultValue, description }; + } + static html (content: string): PluginConfigItem { + return { key: `_html_${Math.random().toString(36).slice(2)}`, type: 'html', label: '', default: content }; + } + static plainText (content: string): PluginConfigItem { + return { key: `_text_${Math.random().toString(36).slice(2)}`, type: 'text', label: '', default: content }; + } + static combine (...items: PluginConfigItem[]): PluginConfigSchema { + return items; + } +} + +export type PluginConfigSchema = PluginConfigItem[]; + export interface PluginModule { plugin_init: ( core: NapCatCore, @@ -44,6 +83,10 @@ export interface PluginModule void | Promise; + plugin_config_schema?: PluginConfigSchema; + plugin_config_ui?: PluginConfigSchema; + plugin_get_config?: () => any | Promise; + plugin_set_config?: (config: any) => void | Promise; } export interface LoadedPlugin { @@ -613,4 +656,11 @@ export class OB11PluginMangerAdapter extends IOB11NetworkAdapter { return false; } } + public getPluginDataPath (pluginName: string): string { + return path.join(this.pluginPath, pluginName, 'data'); + } + + public getPluginConfigPath (pluginName: string): string { + return path.join(this.getPluginDataPath(pluginName), 'config.json'); + } } diff --git a/packages/napcat-plugin-builtin/package.json b/packages/napcat-plugin-builtin/package.json index bae9f649..c3006c5c 100644 --- a/packages/napcat-plugin-builtin/package.json +++ b/packages/napcat-plugin-builtin/package.json @@ -6,7 +6,7 @@ "description": "NapCat 内置插件", "author": "NapNeko", "dependencies": { - "napcat-types": "0.0.3" + "napcat-types": "0.0.5" }, "devDependencies": { "@types/node": "^22.0.1" diff --git a/packages/napcat-types/package.public.json b/packages/napcat-types/package.public.json index 0fcf3674..fa2b6af2 100644 --- a/packages/napcat-types/package.public.json +++ b/packages/napcat-types/package.public.json @@ -1,6 +1,6 @@ { "name": "napcat-types", - "version": "0.0.4", + "version": "0.0.5", "private": false, "type": "module", "types": "./napcat-types/index.d.ts", diff --git a/packages/napcat-webui-backend/src/api/Plugin.ts b/packages/napcat-webui-backend/src/api/Plugin.ts index b52909bb..ceb2ae3e 100644 --- a/packages/napcat-webui-backend/src/api/Plugin.ts +++ b/packages/napcat-webui-backend/src/api/Plugin.ts @@ -47,7 +47,8 @@ export const GetPluginListHandler: RequestHandler = async (_req, res) => { author: p.packageJson?.author || '', status: 'active', filename: fsName, // 真实文件/目录名 - loadedName: p.name // 运行时注册的名称,用于重载/卸载 + loadedName: p.name, // 运行时注册的名称,用于重载/卸载 + hasConfig: !!(p.module.plugin_config_schema || p.module.plugin_config_ui) }); } @@ -186,7 +187,7 @@ export const SetPluginStatusHandler: RequestHandler = async (req, res) => { }; export const UninstallPluginHandler: RequestHandler = async (req, res) => { - const { name, filename } = req.body; + const { name, filename, cleanData } = req.body; // If it's loaded, we use name. If it's disabled, we might use filename. const pluginManager = getPluginManager(); @@ -219,8 +220,82 @@ export const UninstallPluginHandler: RequestHandler = async (req, res) => { if (fs.existsSync(fsPath)) { fs.rmSync(fsPath, { recursive: true, force: true }); } + + if (cleanData && name) { + const dataPath = pluginManager.getPluginDataPath(name); + if (fs.existsSync(dataPath)) { + fs.rmSync(dataPath, { recursive: true, force: true }); + } + } + return sendSuccess(res, { message: 'Uninstalled successfully' }); } catch (e: any) { return sendError(res, 'Failed to uninstall: ' + e.message); } }; + +export const GetPluginConfigHandler: RequestHandler = async (req, res) => { + const name = req.query['name'] as string; + if (!name) return sendError(res, 'Plugin Name is required'); + + const pluginManager = getPluginManager(); + if (!pluginManager) return sendError(res, 'Plugin Manager not found'); + + const plugin = pluginManager.getPluginInfo(name); + if (!plugin) return sendError(res, 'Plugin not loaded'); + + // Support legacy schema or new API + const schema = plugin.module.plugin_config_schema || plugin.module.plugin_config_ui; + let config = {}; + + if (plugin.module.plugin_get_config) { + try { + config = await plugin.module.plugin_get_config(); + } catch (e) { } + } else if (schema) { + // Default behavior: read from default config path + try { + const configPath = pluginManager.getPluginConfigPath(name); + if (fs.existsSync(configPath)) { + config = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + } + } catch (e) { } + } + + return sendSuccess(res, { schema, config }); +}; + +export const SetPluginConfigHandler: RequestHandler = async (req, res) => { + const { name, config } = req.body; + if (!name || !config) return sendError(res, 'Name and Config required'); + + const pluginManager = getPluginManager(); + if (!pluginManager) return sendError(res, 'Plugin Manager not found'); + + const plugin = pluginManager.getPluginInfo(name); + if (!plugin) return sendError(res, 'Plugin not loaded'); + + if (plugin.module.plugin_set_config) { + try { + await plugin.module.plugin_set_config(config); + return sendSuccess(res, { message: 'Config updated' }); + } catch (e: any) { + return sendError(res, 'Error updating config: ' + e.message); + } + } else if (plugin.module.plugin_config_schema || plugin.module.plugin_config_ui) { + // Default behavior: write to default config path + try { + const configPath = pluginManager.getPluginConfigPath(name); + const configDir = path.dirname(configPath); + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }); + } + fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); + return sendSuccess(res, { message: 'Config saved' }); + } catch (e: any) { + return sendError(res, 'Error saving config: ' + e.message); + } + } else { + return sendError(res, 'Plugin does not support config update'); + } +}; diff --git a/packages/napcat-webui-backend/src/router/Plugin.ts b/packages/napcat-webui-backend/src/router/Plugin.ts index 41a3b230..17b80d64 100644 --- a/packages/napcat-webui-backend/src/router/Plugin.ts +++ b/packages/napcat-webui-backend/src/router/Plugin.ts @@ -1,6 +1,18 @@ import { Router } from 'express'; -import { GetPluginListHandler, ReloadPluginHandler, SetPluginStatusHandler, UninstallPluginHandler } from '@/napcat-webui-backend/src/api/Plugin'; -import { GetPluginStoreListHandler, GetPluginStoreDetailHandler, InstallPluginFromStoreHandler, InstallPluginFromStoreSSEHandler } from '@/napcat-webui-backend/src/api/PluginStore'; +import { + GetPluginListHandler, + ReloadPluginHandler, + SetPluginStatusHandler, + UninstallPluginHandler, + GetPluginConfigHandler, + SetPluginConfigHandler +} from '@/napcat-webui-backend/src/api/Plugin'; +import { + GetPluginStoreListHandler, + GetPluginStoreDetailHandler, + InstallPluginFromStoreHandler, + InstallPluginFromStoreSSEHandler +} from '@/napcat-webui-backend/src/api/PluginStore'; const router: Router = Router(); @@ -8,6 +20,8 @@ router.get('/List', GetPluginListHandler); router.post('/Reload', ReloadPluginHandler); router.post('/SetStatus', SetPluginStatusHandler); router.post('/Uninstall', UninstallPluginHandler); +router.get('/Config', GetPluginConfigHandler); +router.post('/Config', SetPluginConfigHandler); // 插件商店相关路由 router.get('/Store/List', GetPluginStoreListHandler); diff --git a/packages/napcat-webui-frontend/src/components/display_card/plugin_card.tsx b/packages/napcat-webui-frontend/src/components/display_card/plugin_card.tsx index 7351d2d1..4c6a2de0 100644 --- a/packages/napcat-webui-frontend/src/components/display_card/plugin_card.tsx +++ b/packages/napcat-webui-frontend/src/components/display_card/plugin_card.tsx @@ -3,7 +3,7 @@ import { Switch } from '@heroui/switch'; import { Chip } from '@heroui/chip'; import { useState } from 'react'; -import { MdDeleteForever, MdPublishedWithChanges } from 'react-icons/md'; +import { MdDeleteForever, MdPublishedWithChanges, MdSettings } from 'react-icons/md'; import DisplayCardContainer from './container'; import { PluginItem } from '@/controllers/plugin_manager'; @@ -13,6 +13,8 @@ export interface PluginDisplayCardProps { onReload: () => Promise; onToggleStatus: () => Promise; onUninstall: () => Promise; + onConfig?: () => void; + hasConfig?: boolean; } const PluginDisplayCard: React.FC = ({ @@ -20,6 +22,8 @@ const PluginDisplayCard: React.FC = ({ onReload, onToggleStatus, onUninstall, + onConfig, + hasConfig = false, }) => { const { name, version, author, description, status } = data; const isEnabled = status !== 'disabled'; @@ -44,32 +48,47 @@ const PluginDisplayCard: React.FC = ({ - +
+
+ - + +
+ {hasConfig && ( + + )}
} enableSwitch={ diff --git a/packages/napcat-webui-frontend/src/controllers/plugin_manager.ts b/packages/napcat-webui-frontend/src/controllers/plugin_manager.ts index 220cc444..841b9aae 100644 --- a/packages/napcat-webui-frontend/src/controllers/plugin_manager.ts +++ b/packages/napcat-webui-frontend/src/controllers/plugin_manager.ts @@ -8,6 +8,7 @@ export interface PluginItem { author: string; status: 'active' | 'disabled' | 'stopped'; filename?: string; + hasConfig?: boolean; } export interface PluginListResponse { @@ -15,6 +16,21 @@ export interface PluginListResponse { pluginManagerNotFound: boolean; } +export interface PluginConfigSchemaItem { + key: string; + type: 'string' | 'number' | 'boolean' | 'select' | 'multi-select' | 'html' | 'text'; + label: string; + description?: string; + default?: any; + options?: { label: string; value: string | number; }[]; + placeholder?: string; +} + +export interface PluginConfigResponse { + schema: PluginConfigSchemaItem[]; + config: any; +} + export interface ServerResponse { code: number; message: string; @@ -35,8 +51,8 @@ export default class PluginManager { await serverRequest.post>('/Plugin/SetStatus', { name, enable, filename }); } - public static async uninstallPlugin (name: string, filename?: string) { - await serverRequest.post>('/Plugin/Uninstall', { name, filename }); + public static async uninstallPlugin (name: string, filename?: string, cleanData?: boolean) { + await serverRequest.post>('/Plugin/Uninstall', { name, filename, cleanData }); } // 插件商店相关方法 @@ -56,4 +72,14 @@ export default class PluginManager { timeout: 300000, // 5分钟 }); } + + // 插件配置相关方法 + public static async getPluginConfig (name: string) { + const { data } = await serverRequest.get>('/Plugin/Config', { params: { name } }); + return data.data; + } + + public static async setPluginConfig (name: string, config: any) { + await serverRequest.post>('/Plugin/Config', { name, config }); + } } diff --git a/packages/napcat-webui-frontend/src/pages/dashboard/plugin.tsx b/packages/napcat-webui-frontend/src/pages/dashboard/plugin.tsx index 82b829a9..4e5d2285 100644 --- a/packages/napcat-webui-frontend/src/pages/dashboard/plugin.tsx +++ b/packages/napcat-webui-frontend/src/pages/dashboard/plugin.tsx @@ -2,11 +2,13 @@ import { Button } from '@heroui/button'; import { useEffect, useState } from 'react'; import toast from 'react-hot-toast'; import { IoMdRefresh } from 'react-icons/io'; +import { useDisclosure } from '@heroui/modal'; import PageLoading from '@/components/page_loading'; import PluginDisplayCard from '@/components/display_card/plugin_card'; import PluginManager, { PluginItem } from '@/controllers/plugin_manager'; import useDialog from '@/hooks/use-dialog'; +import PluginConfigModal from '@/pages/dashboard/plugin_config_modal'; export default function PluginPage () { const [plugins, setPlugins] = useState([]); @@ -14,16 +16,20 @@ export default function PluginPage () { const [pluginManagerNotFound, setPluginManagerNotFound] = useState(false); const dialog = useDialog(); + const { isOpen, onOpen, onOpenChange } = useDisclosure(); + const [currentPluginName, setCurrentPluginName] = useState(''); + const loadPlugins = async () => { setLoading(true); setPluginManagerNotFound(false); try { - const result = await PluginManager.getPluginList(); - if (result.pluginManagerNotFound) { + const listResult = await PluginManager.getPluginList(); + + if (listResult.pluginManagerNotFound) { setPluginManagerNotFound(true); setPlugins([]); } else { - setPlugins(result.plugins); + setPlugins(listResult.plugins); } } catch (e: any) { toast.error(e.message); @@ -64,11 +70,31 @@ export default function PluginPage () { return new Promise((resolve, reject) => { dialog.confirm({ title: '卸载插件', - content: `确定要卸载插件「${plugin.name}」吗? 此操作不可恢复。`, + content: ( +
+

确定要卸载插件「{plugin.name}」吗? 此操作不可恢复。

+

如果插件创建了数据文件,是否一并删除?

+
+ ), + // This 'dialog' utility might not support returning a value from UI interacting. + // We might need to implement a custom confirmation flow if we want a checkbox. + // Alternatively, use two buttons? "Uninstall & Clean", "Uninstall Only"? + // Standard dialog usually has Confirm/Cancel. + // Let's stick to a simpler "Uninstall" and then maybe a second prompt? Or just clean data? + // User requested: "Uninstall prompts whether to clean data". + // Let's use `window.confirm` for the second step or assume `dialog.confirm` is flexible enough? + // I will implement a two-step confirmation or try to modify the dialog hook if visible (not visible here). + // Let's use a standard `window.confirm` for the data cleanup question if the custom dialog doesn't support complex return. + // Better: Inside onConfirm, ask again? onConfirm: async () => { + // Ask for data cleanup + // Since we are in an async callback, we can use another dialog or confirm. + // Native confirm is ugly but works reliably for logic: + const cleanData = window.confirm(`是否同时清理插件「${plugin.name}」的数据文件?\n点击“确定”清理数据,点击“取消”仅卸载插件。`); + const loadingToast = toast.loading('卸载中...'); try { - await PluginManager.uninstallPlugin(plugin.name, plugin.filename); + await PluginManager.uninstallPlugin(plugin.name, plugin.filename, cleanData); toast.success('卸载成功', { id: loadingToast }); loadPlugins(); resolve(); @@ -84,11 +110,22 @@ export default function PluginPage () { }); }; + const handleConfig = (plugin: PluginItem) => { + setCurrentPluginName(plugin.name); // Use Loaded Name for config lookup + onOpen(); + }; + return ( <> 插件管理 - NapCat WebUI
+ +

插件管理

diff --git a/packages/napcat-webui-frontend/src/pages/dashboard/plugin_config_modal.tsx b/packages/napcat-webui-frontend/src/pages/dashboard/plugin_config_modal.tsx new file mode 100644 index 00000000..bffee022 --- /dev/null +++ b/packages/napcat-webui-frontend/src/pages/dashboard/plugin_config_modal.tsx @@ -0,0 +1,208 @@ +import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter } from '@heroui/modal'; +import { Button } from '@heroui/button'; +import { Input } from '@heroui/input'; +import { Select, SelectItem } from '@heroui/select'; +import { Switch } from '@heroui/switch'; +import { useEffect, useState } from 'react'; +import toast from 'react-hot-toast'; +import PluginManager, { PluginConfigSchemaItem } from '@/controllers/plugin_manager'; + +interface Props { + isOpen: boolean; + onOpenChange: () => void; + pluginName: string; +} + +export default function PluginConfigModal ({ isOpen, onOpenChange, pluginName }: Props) { + const [loading, setLoading] = useState(false); + const [schema, setSchema] = useState([]); + const [config, setConfig] = useState({}); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (isOpen && pluginName) { + loadConfig(); + } + }, [isOpen, pluginName]); + + const loadConfig = async () => { + setLoading(true); + setSchema([]); + setConfig({}); + try { + const data = await PluginManager.getPluginConfig(pluginName); + setSchema(data.schema || []); + setConfig(data.config || {}); + } catch (e: any) { + toast.error('Load config failed: ' + e.message); + } finally { + setLoading(false); + } + }; + + const handleSave = async () => { + setSaving(true); + try { + await PluginManager.setPluginConfig(pluginName, config); + toast.success('Configuration saved'); + onOpenChange(); + } catch (e: any) { + toast.error('Save failed: ' + e.message); + } finally { + setSaving(false); + } + }; + + const updateConfig = (key: string, value: any) => { + setConfig((prev: any) => ({ ...prev, [key]: value })); + }; + + const renderField = (item: PluginConfigSchemaItem) => { + const value = config[item.key] ?? item.default; + + switch (item.type) { + case 'string': + return ( + updateConfig(item.key, val)} + description={item.description} + className="mb-4" + /> + ); + case 'number': + return ( + updateConfig(item.key, Number(val))} + description={item.description} + className="mb-4" + /> + ); + case 'boolean': + return ( +
+
+ {item.label} + {item.description && {item.description}} +
+ updateConfig(item.key, val)} + /> +
+ ); + case 'select': + // Handle value matching for default selected keys + const selectedValue = value !== undefined ? String(value) : undefined; + return ( + + ); + case 'multi-select': + const selectedKeys = Array.isArray(value) ? value.map(String) : []; + return ( + + ); + case 'html': + return ( +
+ {item.label &&

{item.label}

} +
+ {item.description &&

{item.description}

} +
+ ); + case 'text': + return ( +
+ {item.label &&

{item.label}

} +
{item.default || ''}
+ {item.description &&

{item.description}

} +
+ ); + default: + return null; + } + }; + + return ( + + + {(onClose) => ( + <> + + Configuration: {pluginName} + + + {loading ? ( +
Loading configuration...
+ ) : ( +
+ {schema.length === 0 ? ( +
No configuration schema available.
+ ) : ( + schema.map(renderField) + )} +
+ )} +
+ + + + + + )} +
+
+ ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1743df51..97b33165 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -223,8 +223,8 @@ importers: packages/napcat-plugin-builtin: dependencies: napcat-types: - specifier: 0.0.3 - version: 0.0.3 + specifier: 0.0.5 + version: 0.0.5 devDependencies: '@types/node': specifier: ^22.0.1 @@ -5448,8 +5448,8 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - napcat-types@0.0.3: - resolution: {integrity: sha512-YZVBvtIw7N2TRck+JcVAoZJRqcoKf9PbKhHggZ/EcQzTkqGLgu8iIgMfQnCYscgXRglYBPexpb78piaEwlVcjQ==} + napcat-types@0.0.5: + resolution: {integrity: sha512-ihbIdCAsqx4wdiSaKfGgsbC5nn3rjk/xbPO1lqr+3GzU+w/oeWsP0XzdF44Z3Uz6ODOijsoR1VR15HXuP8ukcQ==} napcat.protobuf@1.1.4: resolution: {integrity: sha512-z7XtLSBJ/PxmYb0VD/w+eYr/X3LyGz+SZ2QejFTOczwt6zWNxy2yV1mTMTvJoc3BWkI3ESVFRxkuT6+pj1tb1Q==} @@ -12819,8 +12819,9 @@ snapshots: nanoid@3.3.11: {} - napcat-types@0.0.3: + napcat-types@0.0.5: dependencies: + '@sinclair/typebox': 0.34.41 '@types/cors': 2.8.19 '@types/express': 4.17.25 '@types/ip': 1.1.3