Add reactive plugin config UI with SSE support

Introduces a reactive plugin configuration system with dynamic schema updates via server-sent events (SSE). Adds new fields and controller interfaces to the plugin manager, updates the built-in plugin to demonstrate dynamic config fields, and implements backend and frontend logic for real-time config UI updates. Also updates napcat-types to 0.0.10.
This commit is contained in:
手瓜一十雪 2026-01-29 20:18:34 +08:00
parent 9f62570fc2
commit a4a93c520f
8 changed files with 590 additions and 94 deletions

View File

@ -24,23 +24,45 @@ export interface PluginConfigItem {
default?: any;
options?: { label: string; value: string | number; }[];
placeholder?: string;
/** 标记此字段为响应式:值变化时触发 schema 刷新 */
reactive?: boolean;
/** 是否隐藏此字段 */
hidden?: boolean;
}
/** 插件配置 UI 控制器 - 用于动态控制配置界面 */
export interface PluginConfigUIController {
/** 更新整个 schema */
updateSchema: (schema: PluginConfigSchema) => void;
/** 更新单个字段 */
updateField: (key: string, field: Partial<PluginConfigItem>) => void;
/** 移除字段 */
removeField: (key: string) => void;
/** 添加字段 */
addField: (field: PluginConfigItem, afterKey?: string) => void;
/** 显示字段 */
showField: (key: string) => void;
/** 隐藏字段 */
hideField: (key: string) => void;
/** 获取当前配置值 */
getCurrentConfig: () => Record<string, any>;
}
export class NapCatConfig {
static text (key: string, label: string, defaultValue?: string, description?: string): PluginConfigItem {
return { key, type: 'string', label, default: defaultValue, description };
static text (key: string, label: string, defaultValue?: string, description?: string, reactive?: boolean): PluginConfigItem {
return { key, type: 'string', label, default: defaultValue, description, reactive };
}
static number (key: string, label: string, defaultValue?: number, description?: string): PluginConfigItem {
return { key, type: 'number', label, default: defaultValue, description };
static number (key: string, label: string, defaultValue?: number, description?: string, reactive?: boolean): PluginConfigItem {
return { key, type: 'number', label, default: defaultValue, description, reactive };
}
static boolean (key: string, label: string, defaultValue?: boolean, description?: string): PluginConfigItem {
return { key, type: 'boolean', label, default: defaultValue, description };
static boolean (key: string, label: string, defaultValue?: boolean, description?: string, reactive?: boolean): PluginConfigItem {
return { key, type: 'boolean', label, default: defaultValue, description, reactive };
}
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 select (key: string, label: string, options: { label: string; value: string | number; }[], defaultValue?: string | number, description?: string, reactive?: boolean): PluginConfigItem {
return { key, type: 'select', label, options, default: defaultValue, description, reactive };
}
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 multiSelect (key: string, label: string, options: { label: string; value: string | number; }[], defaultValue?: (string | number)[], description?: string, reactive?: boolean): PluginConfigItem {
return { key, type: 'multi-select', label, options, default: defaultValue, description, reactive };
}
static html (content: string): PluginConfigItem {
return { key: `_html_${Math.random().toString(36).slice(2)}`, type: 'html', label: '', default: content };
@ -103,6 +125,25 @@ export interface PluginModule<T extends OB11EmitEventContent = OB11EmitEventCont
plugin_config_ui?: PluginConfigSchema;
plugin_get_config?: (ctx: NapCatPluginContext) => any | Promise<any>;
plugin_set_config?: (ctx: NapCatPluginContext, config: any) => void | Promise<void>;
/**
* -
*
*/
plugin_config_controller?: (
ctx: NapCatPluginContext,
ui: PluginConfigUIController,
initialConfig: Record<string, any>
) => void | (() => void) | Promise<void | (() => void)>;
/**
* - reactive
*/
plugin_on_config_change?: (
ctx: NapCatPluginContext,
ui: PluginConfigUIController,
key: string,
value: any,
currentConfig: Record<string, any>
) => void | Promise<void>;
}
export interface LoadedPlugin {

View File

@ -1,10 +1,12 @@
import type { ActionMap } from 'napcat-types/napcat-onebot/action/index';
import { EventType } from 'napcat-types/napcat-onebot/event/index';
import type { PluginModule, PluginLogger, PluginConfigSchema } from 'napcat-types/napcat-onebot/network/plugin-manger';
import type { PluginModule, PluginLogger, PluginConfigSchema, PluginConfigUIController } from 'napcat-types/napcat-onebot/network/plugin-manger';
import type { OB11Message, OB11PostSendMsg } from 'napcat-types/napcat-onebot/types/index';
import fs from 'fs';
import path from 'path';
import { NetworkAdapterConfig } from 'napcat-types/napcat-onebot/config/config';
let startTime: number = Date.now();
let logger: PluginLogger | null = null;
@ -14,6 +16,8 @@ interface BuiltinPluginConfig {
description: string;
theme?: string;
features?: string[];
apiUrl?: string;
apiEndpoints?: string[];
[key: string]: unknown;
}
@ -30,9 +34,11 @@ const plugin_init: PluginModule['plugin_init'] = async (ctx) => {
logger = ctx.logger;
logger.info('NapCat 内置插件已初始化');
plugin_config_ui = ctx.NapCatConfig.combine(
ctx.NapCatConfig.html('<div style="padding: 10px; background: rgba(0,0,0,0.05); border-radius: 8px;"><h3>👋 Welcome to NapCat Builtin Plugin</h3><p>This is a demonstration of the plugin configuration interface.</p></div>'),
ctx.NapCatConfig.html('<div style="padding: 10px; background: rgba(0,0,0,0.05); border-radius: 8px;"><h3>👋 Welcome to NapCat Builtin Plugin</h3><p>This is a demonstration of the plugin configuration interface with reactive fields.</p></div>'),
ctx.NapCatConfig.text('prefix', 'Command Prefix', '#napcat', 'The prefix to trigger the version info command'),
ctx.NapCatConfig.boolean('enableReply', 'Enable Reply', true, 'Switch to enable or disable the reply functionality'),
// 代表监听 apiUrl 字段的变化
{ ...ctx.NapCatConfig.text('apiUrl', 'API URL', '', 'Enter an API URL to load available endpoints'), reactive: true },
ctx.NapCatConfig.select('theme', 'Theme Selection', [
{ label: 'Light Mode', value: 'light' },
{ label: 'Dark Mode', value: 'dark' },
@ -59,11 +65,11 @@ const plugin_init: PluginModule['plugin_init'] = async (ctx) => {
};
export const plugin_get_config = async () => {
export const plugin_get_config: PluginModule['plugin_get_config'] = async () => {
return currentConfig;
};
export const plugin_set_config = async (ctx: any, config: BuiltinPluginConfig) => {
export const plugin_set_config: PluginModule['plugin_set_config'] = async (ctx, config: BuiltinPluginConfig) => {
currentConfig = config;
if (ctx && ctx.configPath) {
try {
@ -80,6 +86,74 @@ export const plugin_set_config = async (ctx: any, config: BuiltinPluginConfig) =
}
};
/**
* -
* UI
*/
export const plugin_config_controller: PluginModule['plugin_config_controller'] = async (_ctx, ui, initialConfig) => {
logger?.info('配置控制器已初始化', initialConfig);
// 如果初始配置中有 apiUrl立即加载端点
if (initialConfig['apiUrl']) {
await loadEndpointsForUrl(ui, initialConfig['apiUrl'] as string);
}
// 返回清理函数
return () => {
logger?.info('配置控制器已清理');
};
};
/**
* - reactive
*/
export const plugin_on_config_change: PluginModule['plugin_on_config_change'] = async (_ctx, ui, key, value, _currentConfig: Partial<BuiltinPluginConfig>) => {
logger?.info(`配置字段变化: ${key} = ${value}`);
if (key === 'apiUrl') {
await loadEndpointsForUrl(ui, value as string);
}
};
/**
* API URL
*/
async function loadEndpointsForUrl (ui: PluginConfigUIController, apiUrl: string) {
if (!apiUrl) {
// URL 为空时,移除端点选择字段
ui.removeField('apiEndpoints');
return;
}
// 模拟从 API 获取端点列表(实际使用时可以 fetch 真实 API
const mockEndpoints = [
{ label: `${apiUrl}/users`, value: '/users' },
{ label: `${apiUrl}/posts`, value: '/posts' },
{ label: `${apiUrl}/comments`, value: '/comments' },
{ label: `${apiUrl}/albums`, value: '/albums' },
];
// 动态添加或更新端点选择字段
const currentSchema = ui.getCurrentConfig();
if ('apiEndpoints' in currentSchema) {
// 更新现有字段的选项
ui.updateField('apiEndpoints', {
options: mockEndpoints,
description: `${apiUrl} 加载的端点`
});
} else {
// 添加新字段
ui.addField({
key: 'apiEndpoints',
type: 'multi-select',
label: 'API Endpoints',
description: `${apiUrl} 加载的端点`,
options: mockEndpoints,
default: []
}, 'apiUrl');
}
}
const plugin_onmessage: PluginModule['plugin_onmessage'] = async (_ctx, event) => {
if (currentConfig.enableReply === false) {
return;

View File

@ -7,7 +7,7 @@
"description": "NapCat 内置插件",
"author": "NapNeko",
"dependencies": {
"napcat-types": "0.0.9"
"napcat-types": "0.0.10"
},
"devDependencies": {
"@types/node": "^22.0.1"

View File

@ -181,6 +181,7 @@ export const UninstallPluginHandler: RequestHandler = async (req, res) => {
export const GetPluginConfigHandler: RequestHandler = async (req, res) => {
const id = req.query['id'] as string;
if (!id) return sendError(res, 'Plugin id is required');
const pluginManager = getPluginManager();
@ -189,18 +190,15 @@ export const GetPluginConfigHandler: RequestHandler = async (req, res) => {
const plugin = pluginManager.getPluginInfo(id);
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(plugin.context);
} catch (e) { }
} else if (schema) {
} else {
// Default behavior: read from default config path
try {
// Use context configPath if available
const configPath = plugin.context?.configPath || pluginManager.getPluginConfigPath(id);
if (fs.existsSync(configPath)) {
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
@ -208,7 +206,212 @@ export const GetPluginConfigHandler: RequestHandler = async (req, res) => {
} catch (e) { }
}
return sendSuccess(res, { schema, config });
// 获取静态 schema
const schema = plugin.module.plugin_config_schema || plugin.module.plugin_config_ui || [];
// 检查是否支持动态控制
const supportReactive = !!(plugin.module.plugin_config_controller || plugin.module.plugin_on_config_change);
return sendSuccess(res, { schema, config, supportReactive });
};
/** 活跃的 SSE 连接 */
const activeConfigSessions = new Map<string, {
res: any;
cleanup?: () => void;
currentConfig: Record<string, any>;
}>();
/**
* SSE -
*/
export const PluginConfigSSEHandler: RequestHandler = (req, res): void => {
const id = req.query['id'] as string;
const initialConfigStr = req.query['config'] as string;
if (!id) {
res.status(400).json({ error: 'Plugin id is required' });
return;
}
const pluginManager = getPluginManager();
if (!pluginManager) {
res.status(400).json({ error: 'Plugin Manager not found' });
return;
}
const plugin = pluginManager.getPluginInfo(id);
if (!plugin) {
res.status(400).json({ error: 'Plugin not loaded' });
return;
}
// 设置 SSE 头
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders();
// 生成会话 ID
const sessionId = `${id}_${Date.now()}_${Math.random().toString(36).slice(2)}`;
// 解析初始配置
let currentConfig: Record<string, any> = {};
if (initialConfigStr) {
try {
currentConfig = JSON.parse(initialConfigStr);
} catch (e) { }
}
// 发送 SSE 消息的辅助函数
const sendSSE = (event: string, data: any) => {
res.write(`event: ${event}\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`);
};
// 创建 UI 控制器
const uiController = {
updateSchema: (schema: any[]) => {
sendSSE('schema', { type: 'full', schema });
},
updateField: (key: string, field: any) => {
sendSSE('schema', { type: 'updateField', key, field });
},
removeField: (key: string) => {
sendSSE('schema', { type: 'removeField', key });
},
addField: (field: any, afterKey?: string) => {
sendSSE('schema', { type: 'addField', field, afterKey });
},
showField: (key: string) => {
sendSSE('schema', { type: 'showField', key });
},
hideField: (key: string) => {
sendSSE('schema', { type: 'hideField', key });
},
getCurrentConfig: () => currentConfig
};
// 存储会话
activeConfigSessions.set(sessionId, { res, currentConfig });
// 发送连接成功消息
sendSSE('connected', { sessionId });
// 调用插件的控制器初始化(异步处理)
(async () => {
let cleanup: (() => void) | undefined;
if (plugin.module.plugin_config_controller) {
try {
const result = await plugin.module.plugin_config_controller(
plugin.context,
uiController,
currentConfig
);
if (typeof result === 'function') {
cleanup = result;
}
} catch (e: any) {
sendSSE('error', { message: e.message });
}
}
// 更新会话的 cleanup
const session = activeConfigSessions.get(sessionId);
if (session) {
session.cleanup = cleanup;
}
})();
// 心跳保持连接
const heartbeat = setInterval(() => {
sendSSE('ping', { time: Date.now() });
}, 30000);
// 连接关闭时清理
req.on('close', () => {
clearInterval(heartbeat);
const session = activeConfigSessions.get(sessionId);
if (session?.cleanup) {
try {
session.cleanup();
} catch (e) { }
}
activeConfigSessions.delete(sessionId);
});
};
/**
*
*/
export const PluginConfigChangeHandler: RequestHandler = async (req, res) => {
const { id, sessionId, key, value, currentConfig } = req.body;
if (!id || !sessionId || !key) {
return sendError(res, 'Missing required parameters');
}
const pluginManager = getPluginManager();
if (!pluginManager) return sendError(res, 'Plugin Manager not found');
const plugin = pluginManager.getPluginInfo(id);
if (!plugin) return sendError(res, 'Plugin not loaded');
// 获取会话
const session = activeConfigSessions.get(sessionId);
if (!session) {
return sendError(res, 'Session not found');
}
// 更新会话中的当前配置
session.currentConfig = currentConfig || {};
// 如果插件有响应式处理器,调用它
if (plugin.module.plugin_on_config_change) {
const uiController = {
updateSchema: (schema: any[]) => {
session.res.write(`event: schema\n`);
session.res.write(`data: ${JSON.stringify({ type: 'full', schema })}\n\n`);
},
updateField: (fieldKey: string, field: any) => {
session.res.write(`event: schema\n`);
session.res.write(`data: ${JSON.stringify({ type: 'updateField', key: fieldKey, field })}\n\n`);
},
removeField: (fieldKey: string) => {
session.res.write(`event: schema\n`);
session.res.write(`data: ${JSON.stringify({ type: 'removeField', key: fieldKey })}\n\n`);
},
addField: (field: any, afterKey?: string) => {
session.res.write(`event: schema\n`);
session.res.write(`data: ${JSON.stringify({ type: 'addField', field, afterKey })}\n\n`);
},
showField: (fieldKey: string) => {
session.res.write(`event: schema\n`);
session.res.write(`data: ${JSON.stringify({ type: 'showField', key: fieldKey })}\n\n`);
},
hideField: (fieldKey: string) => {
session.res.write(`event: schema\n`);
session.res.write(`data: ${JSON.stringify({ type: 'hideField', key: fieldKey })}\n\n`);
},
getCurrentConfig: () => session.currentConfig
};
try {
await plugin.module.plugin_on_config_change(
plugin.context,
uiController,
key,
value,
currentConfig || {}
);
} catch (e: any) {
session.res.write(`event: error\n`);
session.res.write(`data: ${JSON.stringify({ message: e.message })}\n\n`);
}
}
return sendSuccess(res, { message: 'Change processed' });
};
export const SetPluginConfigHandler: RequestHandler = async (req, res) => {
@ -228,7 +431,7 @@ export const SetPluginConfigHandler: RequestHandler = async (req, res) => {
} catch (e: any) {
return sendError(res, 'Error updating config: ' + e.message);
}
} else if (plugin.module.plugin_config_schema || plugin.module.plugin_config_ui) {
} else if (plugin.module.plugin_config_schema || plugin.module.plugin_config_ui || plugin.module.plugin_config_controller) {
// Default behavior: write to default config path
try {
const configPath = plugin.context?.configPath || pluginManager.getPluginConfigPath(id);

View File

@ -5,7 +5,9 @@ import {
UninstallPluginHandler,
GetPluginConfigHandler,
SetPluginConfigHandler,
RegisterPluginManagerHandler
RegisterPluginManagerHandler,
PluginConfigSSEHandler,
PluginConfigChangeHandler
} from '@/napcat-webui-backend/src/api/Plugin';
import {
GetPluginStoreListHandler,
@ -21,6 +23,8 @@ router.post('/SetStatus', SetPluginStatusHandler);
router.post('/Uninstall', UninstallPluginHandler);
router.get('/Config', GetPluginConfigHandler);
router.post('/Config', SetPluginConfigHandler);
router.get('/Config/SSE', PluginConfigSSEHandler);
router.post('/Config/Change', PluginConfigChangeHandler);
router.post('/RegisterManager', RegisterPluginManagerHandler);
// 插件商店相关路由

View File

@ -37,12 +37,18 @@ export interface PluginConfigSchemaItem {
default?: any;
options?: { label: string; value: string | number; }[];
placeholder?: string;
/** 标记此字段为响应式:值变化时触发 schema 刷新 */
reactive?: boolean;
/** 是否隐藏此字段 */
hidden?: boolean;
}
/** 插件配置响应 */
export interface PluginConfigResponse {
schema: PluginConfigSchemaItem[];
config: Record<string, unknown>;
/** 是否支持响应式更新 */
supportReactive?: boolean;
}
/** 服务端响应 */
@ -143,4 +149,41 @@ export default class PluginManager {
public static async setPluginConfig (id: string, config: Record<string, unknown>): Promise<void> {
await serverRequest.post<ServerResponse<void>>('/Plugin/Config', { id, config });
}
/**
*
* @param id
* @param sessionId SSE ID
* @param key
* @param value
* @param currentConfig
*/
public static async notifyConfigChange (
id: string,
sessionId: string,
key: string,
value: unknown,
currentConfig: Record<string, unknown>
): Promise<void> {
await serverRequest.post<ServerResponse<void>>('/Plugin/Config/Change', {
id,
sessionId,
key,
value,
currentConfig
});
}
/**
* SSE URL
* @param id
* @param config
*/
public static getConfigSSEUrl (id: string, config?: Record<string, unknown>): string {
const params = new URLSearchParams({ id });
if (config) {
params.set('config', JSON.stringify(config));
}
return `/api/Plugin/Config/SSE?${params.toString()}`;
}
}

View File

@ -3,9 +3,11 @@ 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 { useEffect, useState, useRef, useCallback } from 'react';
import toast from 'react-hot-toast';
import { EventSourcePolyfill } from 'event-source-polyfill';
import PluginManager, { PluginConfigSchemaItem } from '@/controllers/plugin_manager';
import key from '@/const/key';
interface Props {
isOpen: boolean;
@ -14,26 +16,182 @@ interface Props {
pluginId: string;
}
/** Schema 更新事件类型 */
interface SchemaUpdateEvent {
type: 'full' | 'updateField' | 'removeField' | 'addField' | 'showField' | 'hideField';
schema?: PluginConfigSchemaItem[];
key?: string;
field?: Partial<PluginConfigSchemaItem>;
afterKey?: string;
}
export default function PluginConfigModal ({ isOpen, onOpenChange, pluginId }: Props) {
const [loading, setLoading] = useState(false);
const [schema, setSchema] = useState<PluginConfigSchemaItem[]>([]);
const [config, setConfig] = useState<Record<string, unknown>>({});
const [saving, setSaving] = useState(false);
const [supportReactive, setSupportReactive] = useState(false);
const [sessionId, setSessionId] = useState<string | null>(null);
const [connected, setConnected] = useState(false);
// SSE 连接引用
const eventSourceRef = useRef<EventSourcePolyfill | null>(null);
// 当前配置引用(用于 SSE 回调)
const configRef = useRef<Record<string, unknown>>({});
// 同步 config 到 ref
useEffect(() => {
configRef.current = config;
}, [config]);
/** 处理 schema 更新事件 */
const handleSchemaUpdate = useCallback((event: SchemaUpdateEvent) => {
switch (event.type) {
case 'full':
if (event.schema) {
setSchema(event.schema);
}
break;
case 'updateField':
if (event.key && event.field) {
setSchema(prev => prev.map(item =>
item.key === event.key ? { ...item, ...event.field } : item
));
}
break;
case 'removeField':
if (event.key) {
setSchema(prev => prev.filter(item => item.key !== event.key));
}
break;
case 'addField':
if (event.field) {
setSchema(prev => {
const newField = event.field as PluginConfigSchemaItem;
// 检查字段是否已存在,如果存在则更新
const existingIndex = prev.findIndex(item => item.key === newField.key);
if (existingIndex !== -1) {
// 字段已存在,更新它
const newSchema = [...prev];
newSchema[existingIndex] = { ...newSchema[existingIndex], ...newField };
return newSchema;
}
// 字段不存在,添加新字段
if (event.afterKey) {
const index = prev.findIndex(item => item.key === event.afterKey);
if (index !== -1) {
const newSchema = [...prev];
newSchema.splice(index + 1, 0, newField);
return newSchema;
}
}
return [...prev, newField];
});
}
break;
case 'showField':
if (event.key) {
setSchema(prev => prev.map(item =>
item.key === event.key ? { ...item, hidden: false } : item
));
}
break;
case 'hideField':
if (event.key) {
setSchema(prev => prev.map(item =>
item.key === event.key ? { ...item, hidden: true } : item
));
}
break;
}
}, []);
/** 建立 SSE 连接 */
const connectSSE = useCallback((initialConfig: Record<string, unknown>) => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
}
const token = localStorage.getItem(key.token);
if (!token) {
console.warn('未登录,无法建立 SSE 连接');
return;
}
const _token = JSON.parse(token);
const url = PluginManager.getConfigSSEUrl(pluginId, initialConfig);
const es = new EventSourcePolyfill(url, {
headers: {
Authorization: `Bearer ${_token}`,
Accept: 'text/event-stream',
},
withCredentials: true,
});
eventSourceRef.current = es;
es.addEventListener('connected', (e) => {
const data = JSON.parse((e as MessageEvent).data);
setSessionId(data.sessionId);
setConnected(true);
});
es.addEventListener('schema', (e) => {
const data = JSON.parse((e as MessageEvent).data);
handleSchemaUpdate(data);
});
es.addEventListener('error', (e) => {
try {
const data = JSON.parse((e as MessageEvent).data);
toast.error('插件错误: ' + data.message);
} catch {
// SSE 连接错误
setConnected(false);
}
});
es.onerror = () => {
setConnected(false);
};
}, [pluginId, handleSchemaUpdate]);
/** 关闭 SSE 连接 */
const disconnectSSE = useCallback(() => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
setSessionId(null);
setConnected(false);
}, []);
useEffect(() => {
if (isOpen && pluginId) {
loadConfig();
}
}, [isOpen, pluginId]);
return () => {
disconnectSSE();
};
}, [isOpen, pluginId, disconnectSSE]);
/** 初始加载配置 */
const loadConfig = async () => {
setLoading(true);
setSchema([]);
setConfig({});
setSupportReactive(false);
disconnectSSE();
try {
const data = await PluginManager.getPluginConfig(pluginId);
setSchema(data.schema || []);
setConfig(data.config || {});
setSupportReactive(!!data.supportReactive);
// 如果支持响应式,建立 SSE 连接
if (data.supportReactive) {
connectSSE(data.config || {});
}
} catch (e: any) {
toast.error('加载配置失败: ' + e.message);
} finally {
@ -54,9 +212,21 @@ export default function PluginConfigModal ({ isOpen, onOpenChange, pluginId }: P
}
};
const updateConfig = (key: string, value: any) => {
setConfig((prev: any) => ({ ...prev, [key]: value }));
};
/** 更新配置 */
const updateConfig = useCallback((key: string, value: any) => {
setConfig((prev) => {
const newConfig = { ...prev, [key]: value };
// 如果是响应式字段且已连接 SSE通知后端
const field = schema.find(item => item.key === key);
if (field?.reactive && sessionId && connected) {
PluginManager.notifyConfigChange(pluginId, sessionId, key, value, newConfig)
.catch(e => console.error('通知配置变化失败:', e));
}
return newConfig;
});
}, [schema, sessionId, connected, pluginId]);
const renderField = (item: PluginConfigSchemaItem) => {
const value = config[item.key] ?? item.default;
@ -100,9 +270,9 @@ export default function PluginConfigModal ({ isOpen, onOpenChange, pluginId }: P
/>
</div>
);
case 'select':
// Handle value matching for default selected keys
case 'select': {
const selectedValue = value !== undefined ? String(value) : undefined;
const options = item.options || [];
return (
<Select
key={item.key}
@ -111,22 +281,23 @@ export default function PluginConfigModal ({ isOpen, onOpenChange, pluginId }: P
selectedKeys={selectedValue ? [selectedValue] : []}
onSelectionChange={(keys) => {
const val = Array.from(keys)[0];
// Map back to value
const opt = item.options?.find(o => String(o.value) === val);
const opt = options.find(o => String(o.value) === val);
updateConfig(item.key, opt ? opt.value : val);
}}
description={item.description}
className="mb-4"
>
{(item.options || []).map((opt) => (
{options.map((opt) => (
<SelectItem key={String(opt.value)} textValue={opt.label}>
{opt.label}
</SelectItem>
))}
</Select>
);
case 'multi-select':
}
case 'multi-select': {
const selectedKeys = Array.isArray(value) ? value.map(String) : [];
const options = item.options || [];
return (
<Select
key={item.key}
@ -136,7 +307,7 @@ export default function PluginConfigModal ({ isOpen, onOpenChange, pluginId }: P
selectedKeys={new Set(selectedKeys)}
onSelectionChange={(keys) => {
const selected = Array.from(keys).map(k => {
const opt = item.options?.find(o => String(o.value) === k);
const opt = options.find(o => String(o.value) === k);
return opt ? opt.value : k;
});
updateConfig(item.key, selected);
@ -144,13 +315,14 @@ export default function PluginConfigModal ({ isOpen, onOpenChange, pluginId }: P
description={item.description}
className="mb-4"
>
{(item.options || []).map((opt) => (
{options.map((opt) => (
<SelectItem key={String(opt.value)} textValue={opt.label}>
{opt.label}
</SelectItem>
))}
</Select>
);
}
case 'html':
return (
<div key={item.key} className="mb-4">
@ -178,7 +350,14 @@ export default function PluginConfigModal ({ isOpen, onOpenChange, pluginId }: P
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">
: {pluginId}
<div className="flex items-center gap-2">
: {pluginId}
{supportReactive && (
<span className={`text-tiny px-2 py-0.5 rounded ${connected ? 'bg-success-100 text-success-600' : 'bg-warning-100 text-warning-600'}`}>
{connected ? '已连接' : '未连接'}
</span>
)}
</div>
</ModalHeader>
<ModalBody>
{loading ? (
@ -188,7 +367,7 @@ export default function PluginConfigModal ({ isOpen, onOpenChange, pluginId }: P
{schema.length === 0 ? (
<div className="text-center text-default-500">No configuration schema available.</div>
) : (
schema.map(renderField)
schema.filter(item => !item.hidden).map(renderField)
)}
</div>
)}
@ -207,3 +386,4 @@ export default function PluginConfigModal ({ isOpen, onOpenChange, pluginId }: P
</Modal>
);
}

View File

@ -213,8 +213,8 @@ importers:
packages/napcat-plugin-builtin:
dependencies:
napcat-types:
specifier: 0.0.9
version: 0.0.9
specifier: 0.0.10
version: 0.0.10
devDependencies:
'@types/node':
specifier: ^22.0.1
@ -2943,15 +2943,9 @@ packages:
'@types/event-source-polyfill@1.0.5':
resolution: {integrity: sha512-iaiDuDI2aIFft7XkcwMzDWLqo7LVDixd2sR6B4wxJut9xcp/Ev9bO4EFg4rm6S9QxATLBj5OPxdeocgmhjwKaw==}
'@types/express-serve-static-core@4.19.8':
resolution: {integrity: sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==}
'@types/express-serve-static-core@5.1.0':
resolution: {integrity: sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==}
'@types/express@4.17.25':
resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==}
'@types/express@5.0.5':
resolution: {integrity: sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==}
@ -2967,9 +2961,6 @@ packages:
'@types/http-errors@2.0.5':
resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
'@types/ip@1.1.3':
resolution: {integrity: sha512-64waoJgkXFTYnCYDUWgSATJ/dXEBanVkaP5d4Sbk7P6U7cTTMhxVyROTckc6JKdwCrgnAjZMn0k3177aQxtDEA==}
'@types/js-cookie@3.0.6':
resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==}
@ -3053,17 +3044,9 @@ packages:
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
'@types/winston@2.4.4':
resolution: {integrity: sha512-BVGCztsypW8EYwJ+Hq+QNYiT/MUyCif0ouBH+flrY66O5W+KIXAMML6E/0fJpm7VjIzgangahl5S03bJJQGrZw==}
deprecated: This is a stub types definition. winston provides its own type definitions, so you do not need this installed.
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
'@types/yaml@1.9.7':
resolution: {integrity: sha512-8WMXRDD1D+wCohjfslHDgICd2JtMATZU8CkhH8LVJqcJs6dyYj5TGptzP8wApbmEullGBSsCEzzap73DQ1HJaA==}
deprecated: This is a stub types definition. yaml provides its own type definitions, so you do not need this installed.
'@typescript-eslint/eslint-plugin@8.46.4':
resolution: {integrity: sha512-R48VhmTJqplNyDxCyqqVkFSZIx1qX6PzwqgcXn1olLrzxcSBDlOsbtcnQuQhNtnNiJ4Xe5gREI1foajYaYU2Vg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -5417,8 +5400,8 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
napcat-types@0.0.9:
resolution: {integrity: sha512-lhK9SgGotQc58jobqnZEnDTadwcwT/Y62aGCH4hQijdiP2eSiu3YWX2oFFoUJxyIpUKFYAfWJkPUv1YHZ/aaWQ==}
napcat-types@0.0.10:
resolution: {integrity: sha512-Y3EIdDm6rDJjdDqorQMbfyTVpyZvRRPa95yPmIl1LCTbmZMkfJw2YLQHvAcItEpTxUEW1Pve1ipNfmHBVmZL8Q==}
napcat.protobuf@1.1.4:
resolution: {integrity: sha512-z7XtLSBJ/PxmYb0VD/w+eYr/X3LyGz+SZ2QejFTOczwt6zWNxy2yV1mTMTvJoc3BWkI3ESVFRxkuT6+pj1tb1Q==}
@ -9805,13 +9788,6 @@ snapshots:
'@types/event-source-polyfill@1.0.5': {}
'@types/express-serve-static-core@4.19.8':
dependencies:
'@types/node': 22.19.1
'@types/qs': 6.14.0
'@types/range-parser': 1.2.7
'@types/send': 1.2.1
'@types/express-serve-static-core@5.1.0':
dependencies:
'@types/node': 22.19.1
@ -9819,13 +9795,6 @@ snapshots:
'@types/range-parser': 1.2.7
'@types/send': 1.2.1
'@types/express@4.17.25':
dependencies:
'@types/body-parser': 1.19.6
'@types/express-serve-static-core': 4.19.8
'@types/qs': 6.14.0
'@types/serve-static': 1.15.10
'@types/express@5.0.5':
dependencies:
'@types/body-parser': 1.19.6
@ -9845,10 +9814,6 @@ snapshots:
'@types/http-errors@2.0.5': {}
'@types/ip@1.1.3':
dependencies:
'@types/node': 22.19.1
'@types/js-cookie@3.0.6': {}
'@types/json-schema@7.0.15': {}
@ -9930,18 +9895,10 @@ snapshots:
'@types/use-sync-external-store@0.0.6': {}
'@types/winston@2.4.4':
dependencies:
winston: 3.18.3
'@types/ws@8.18.1':
dependencies:
'@types/node': 22.19.1
'@types/yaml@1.9.7':
dependencies:
yaml: 2.8.2
'@typescript-eslint/eslint-plugin@8.46.4(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@ -12788,17 +12745,10 @@ snapshots:
nanoid@3.3.11: {}
napcat-types@0.0.9:
napcat-types@0.0.10:
dependencies:
'@sinclair/typebox': 0.34.41
'@types/cors': 2.8.19
'@types/express': 4.17.25
'@types/ip': 1.1.3
'@types/multer': 1.4.13
'@types/node': 22.19.1
'@types/winston': 2.4.4
'@types/ws': 8.18.1
'@types/yaml': 1.9.7
napcat.protobuf@1.1.4:
dependencies:
@ -14591,7 +14541,8 @@ snapshots:
yallist@4.0.0: {}
yaml@2.8.2: {}
yaml@2.8.2:
optional: true
yargs-parser@20.2.9: {}