From a4a93c520fddb64c3dfe89561fc6570c5133cf3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=89=8B=E7=93=9C=E4=B8=80=E5=8D=81=E9=9B=AA?= Date: Thu, 29 Jan 2026 20:18:34 +0800 Subject: [PATCH] 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. --- .../napcat-onebot/network/plugin-manger.ts | 61 ++++- packages/napcat-plugin-builtin/index.ts | 82 ++++++- packages/napcat-plugin-builtin/package.json | 2 +- .../napcat-webui-backend/src/api/Plugin.ts | 217 +++++++++++++++++- .../napcat-webui-backend/src/router/Plugin.ts | 6 +- .../src/controllers/plugin_manager.ts | 43 ++++ .../pages/dashboard/plugin_config_modal.tsx | 210 +++++++++++++++-- pnpm-lock.yaml | 63 +---- 8 files changed, 590 insertions(+), 94 deletions(-) diff --git a/packages/napcat-onebot/network/plugin-manger.ts b/packages/napcat-onebot/network/plugin-manger.ts index 76fdcb9a..bc16e9bb 100644 --- a/packages/napcat-onebot/network/plugin-manger.ts +++ b/packages/napcat-onebot/network/plugin-manger.ts @@ -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) => void; + /** 移除字段 */ + removeField: (key: string) => void; + /** 添加字段 */ + addField: (field: PluginConfigItem, afterKey?: string) => void; + /** 显示字段 */ + showField: (key: string) => void; + /** 隐藏字段 */ + hideField: (key: string) => void; + /** 获取当前配置值 */ + getCurrentConfig: () => Record; } 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 any | Promise; plugin_set_config?: (ctx: NapCatPluginContext, config: any) => void | Promise; + /** + * 配置界面控制器 - 当配置界面打开时调用 + * 返回清理函数,在界面关闭时调用 + */ + plugin_config_controller?: ( + ctx: NapCatPluginContext, + ui: PluginConfigUIController, + initialConfig: Record + ) => void | (() => void) | Promise void)>; + /** + * 响应式字段变化回调 - 当标记为 reactive 的字段值变化时调用 + */ + plugin_on_config_change?: ( + ctx: NapCatPluginContext, + ui: PluginConfigUIController, + key: string, + value: any, + currentConfig: Record + ) => void | Promise; } export interface LoadedPlugin { diff --git a/packages/napcat-plugin-builtin/index.ts b/packages/napcat-plugin-builtin/index.ts index 2fc7c9f0..650aa7fc 100644 --- a/packages/napcat-plugin-builtin/index.ts +++ b/packages/napcat-plugin-builtin/index.ts @@ -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('

👋 Welcome to NapCat Builtin Plugin

This is a demonstration of the plugin configuration interface.

'), + ctx.NapCatConfig.html('

👋 Welcome to NapCat Builtin Plugin

This is a demonstration of the plugin configuration interface with reactive fields.

'), 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) => { + 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; diff --git a/packages/napcat-plugin-builtin/package.json b/packages/napcat-plugin-builtin/package.json index 8c5dde77..99c4fd33 100644 --- a/packages/napcat-plugin-builtin/package.json +++ b/packages/napcat-plugin-builtin/package.json @@ -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" diff --git a/packages/napcat-webui-backend/src/api/Plugin.ts b/packages/napcat-webui-backend/src/api/Plugin.ts index 622fa091..fe8dfe27 100644 --- a/packages/napcat-webui-backend/src/api/Plugin.ts +++ b/packages/napcat-webui-backend/src/api/Plugin.ts @@ -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 void; + currentConfig: Record; +}>(); + +/** + * 插件配置 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 = {}; + 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); diff --git a/packages/napcat-webui-backend/src/router/Plugin.ts b/packages/napcat-webui-backend/src/router/Plugin.ts index 2b426007..fafe4aaa 100644 --- a/packages/napcat-webui-backend/src/router/Plugin.ts +++ b/packages/napcat-webui-backend/src/router/Plugin.ts @@ -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); // 插件商店相关路由 diff --git a/packages/napcat-webui-frontend/src/controllers/plugin_manager.ts b/packages/napcat-webui-frontend/src/controllers/plugin_manager.ts index 36265007..70eb3256 100644 --- a/packages/napcat-webui-frontend/src/controllers/plugin_manager.ts +++ b/packages/napcat-webui-frontend/src/controllers/plugin_manager.ts @@ -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; + /** 是否支持响应式更新 */ + supportReactive?: boolean; } /** 服务端响应 */ @@ -143,4 +149,41 @@ export default class PluginManager { public static async setPluginConfig (id: string, config: Record): Promise { await serverRequest.post>('/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 + ): Promise { + await serverRequest.post>('/Plugin/Config/Change', { + id, + sessionId, + key, + value, + currentConfig + }); + } + + /** + * 获取配置 SSE URL + * @param id 插件包名 + * @param config 初始配置 + */ + public static getConfigSSEUrl (id: string, config?: Record): string { + const params = new URLSearchParams({ id }); + if (config) { + params.set('config', JSON.stringify(config)); + } + return `/api/Plugin/Config/SSE?${params.toString()}`; + } } 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 index ad70373d..2c30a371 100644 --- a/packages/napcat-webui-frontend/src/pages/dashboard/plugin_config_modal.tsx +++ b/packages/napcat-webui-frontend/src/pages/dashboard/plugin_config_modal.tsx @@ -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; + afterKey?: string; +} + export default function PluginConfigModal ({ isOpen, onOpenChange, pluginId }: Props) { const [loading, setLoading] = useState(false); const [schema, setSchema] = useState([]); const [config, setConfig] = useState>({}); const [saving, setSaving] = useState(false); + const [supportReactive, setSupportReactive] = useState(false); + const [sessionId, setSessionId] = useState(null); + const [connected, setConnected] = useState(false); + + // SSE 连接引用 + const eventSourceRef = useRef(null); + // 当前配置引用(用于 SSE 回调) + const configRef = useRef>({}); + + // 同步 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) => { + 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 /> ); - case 'select': - // Handle value matching for default selected keys + case 'select': { const selectedValue = value !== undefined ? String(value) : undefined; + const options = item.options || []; return ( ); - case 'multi-select': + } + case 'multi-select': { const selectedKeys = Array.isArray(value) ? value.map(String) : []; + const options = item.options || []; return ( ); + } case 'html': return (
@@ -178,7 +350,14 @@ export default function PluginConfigModal ({ isOpen, onOpenChange, pluginId }: P {(onClose) => ( <> - 插件配置: {pluginId} +
+ 插件配置: {pluginId} + {supportReactive && ( + + {connected ? '已连接' : '未连接'} + + )} +
{loading ? ( @@ -188,7 +367,7 @@ export default function PluginConfigModal ({ isOpen, onOpenChange, pluginId }: P {schema.length === 0 ? (
No configuration schema available.
) : ( - schema.map(renderField) + schema.filter(item => !item.hidden).map(renderField) )}
)} @@ -207,3 +386,4 @@ export default function PluginConfigModal ({ isOpen, onOpenChange, pluginId }: P ); } + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7d9609d4..b079825e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {}