diff --git a/VERSION b/VERSION index ecd7ee5..30f69e8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.5.8 +2.5.9 diff --git a/src/plugin-manager.js b/src/plugin-manager.js index 2a8e96d..264c057 100644 --- a/src/plugin-manager.js +++ b/src/plugin-manager.js @@ -67,15 +67,8 @@ class PluginManager { const content = await fs.readFile(PLUGINS_CONFIG_FILE, 'utf8'); this.pluginsConfig = JSON.parse(content); } else { - // 创建默认配置 - this.pluginsConfig = { - plugins: { - 'api-potluck': { - enabled: true, - description: 'API 大锅饭 - Key 管理和用量统计' - } - } - }; + // 扫描 plugins 目录生成默认配置 + this.pluginsConfig = await this.generateDefaultConfig(); await this.saveConfig(); } } catch (error) { @@ -84,6 +77,55 @@ class PluginManager { } } + /** + * 扫描 plugins 目录生成默认配置 + * @returns {Promise} 默认插件配置 + */ + async generateDefaultConfig() { + const defaultConfig = { plugins: {} }; + const pluginsDir = path.join(process.cwd(), 'src', 'plugins'); + + try { + if (!existsSync(pluginsDir)) { + return defaultConfig; + } + + const entries = await fs.readdir(pluginsDir, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const pluginPath = path.join(pluginsDir, entry.name, 'index.js'); + if (!existsSync(pluginPath)) continue; + + try { + // 动态导入插件以获取其元信息 + const pluginModule = await import(`file://${pluginPath}`); + const plugin = pluginModule.default || pluginModule; + + if (plugin && plugin.name) { + defaultConfig.plugins[plugin.name] = { + enabled: true, + description: plugin.description || '' + }; + console.log(`[PluginManager] Found plugin for default config: ${plugin.name}`); + } + } catch (importError) { + // 如果导入失败,使用目录名作为插件名 + defaultConfig.plugins[entry.name] = { + enabled: true, + description: '' + }; + console.warn(`[PluginManager] Could not import plugin ${entry.name}, using directory name:`, importError.message); + } + } + } catch (error) { + console.error('[PluginManager] Failed to scan plugins directory:', error.message); + } + + return defaultConfig; + } + /** * 保存插件配置文件 */ @@ -370,30 +412,6 @@ class PluginManager { return staticPaths.some(sp => path === sp || path === '/' + sp); } - /** - * 获取所有插件的公开 API 路径(不需要 UI 管理 API token 验证) - * @returns {string[]} - */ - getPublicApiPaths() { - const paths = []; - for (const plugin of this.getEnabledPlugins()) { - if (Array.isArray(plugin.publicApiPaths)) { - paths.push(...plugin.publicApiPaths); - } - } - return paths; - } - - /** - * 检查路径是否是插件公开 API 路径(不需要 UI 管理 API token 验证) - * @param {string} path - 请求路径 - * @returns {boolean} - */ - isPluginPublicApiPath(path) { - const publicPaths = this.getPublicApiPaths(); - return publicPaths.some(pp => path === pp || path.startsWith(pp + '/')); - } - /** * 执行钩子函数 * @param {string} hookName - 钩子名称 diff --git a/src/plugins/api-potluck/index.js b/src/plugins/api-potluck/index.js index 7e04c99..b248ca8 100644 --- a/src/plugins/api-potluck/index.js +++ b/src/plugins/api-potluck/index.js @@ -68,12 +68,6 @@ const apiPotluckPlugin = { */ staticPaths: ['potluck.html', 'potluck-user.html'], - /** - * 公开 API 路径(不需要 UI 管理 API 的 token 验证) - * 这些路径将跳过 handleUIApiRequests 中的 checkAuth 验证 - */ - publicApiPaths: ['/api/potluckuser'], - /** * 路由定义 */ diff --git a/src/request-handler.js b/src/request-handler.js index 1150d16..dfa48c8 100644 --- a/src/request-handler.js +++ b/src/request-handler.js @@ -63,13 +63,13 @@ export function createRequestHandler(config, providerPoolManager) { if (served) return; } - const uiHandled = await handleUIApiRequests(method, path, req, res, currentConfig, providerPoolManager); - if (uiHandled) return; - // 执行插件路由 const pluginRouteHandled = await pluginManager.executeRoutes(method, path, req, res); if (pluginRouteHandled) return; + const uiHandled = await handleUIApiRequests(method, path, req, res, currentConfig, providerPoolManager); + if (uiHandled) return; + // Ollama show endpoint with model name if (method === 'POST' && path === '/ollama/api/show') { await handleOllamaShow(req, res); diff --git a/src/ui-manager.js b/src/ui-manager.js index e23ca22..54a131f 100644 --- a/src/ui-manager.js +++ b/src/ui-manager.js @@ -56,8 +56,8 @@ import { getAllProviderModels, getProviderModels } from './provider-models.js'; import { CONFIG } from './config-manager.js'; import { serviceInstances, getServiceAdapter } from './adapter.js'; import { initApiService } from './service-manager.js'; -import { getPluginManager } from './plugin-manager.js'; import { handleGeminiCliOAuth, handleGeminiAntigravityOAuth, handleQwenOAuth, handleKiroOAuth, handleIFlowOAuth, batchImportKiroRefreshTokens, batchImportKiroRefreshTokensStream, importAwsCredentials } from './oauth-handlers.js'; +import { getPluginManager } from './plugin-manager.js'; import { generateUUID, normalizePath, @@ -518,10 +518,8 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo return true; } - // Handle UI management API requests (需要token验证,除了登录接口、健康检查、Events接口和插件公开API路径) - const pluginManager = getPluginManager(); - const isPluginPublicApi = pluginManager.isPluginPublicApiPath(pathParam); - if (pathParam.startsWith('/api/') && pathParam !== '/api/login' && pathParam !== '/api/health' && pathParam !== '/api/events' && !isPluginPublicApi) { + // Handle UI management API requests (需要token验证,除了登录接口、健康检查和Events接口) + if (pathParam.startsWith('/api/') && pathParam !== '/api/login' && pathParam !== '/api/health' && pathParam !== '/api/events' ) { // 检查token验证 const isAuth = await checkAuth(req); if (!isAuth) { @@ -2196,6 +2194,77 @@ export async function handleUIApiRequests(method, pathParam, req, res, currentCo } } + // Get plugins list + if (method === 'GET' && pathParam === '/api/plugins') { + try { + const pluginManager = getPluginManager(); + const plugins = pluginManager.getPluginList(); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ plugins })); + return true; + } catch (error) { + console.error('[UI API] Failed to get plugins:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: 'Failed to get plugins list: ' + error.message + } + })); + return true; + } + } + + // Toggle plugin status + const togglePluginMatch = pathParam.match(/^\/api\/plugins\/(.+)\/toggle$/); + if (method === 'POST' && togglePluginMatch) { + try { + const pluginName = decodeURIComponent(togglePluginMatch[1]); + const body = await getRequestBody(req); + const { enabled } = body; + + if (typeof enabled !== 'boolean') { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: 'Enabled status must be a boolean' + } + })); + return true; + } + + const pluginManager = getPluginManager(); + await pluginManager.setPluginEnabled(pluginName, enabled); + + // 广播更新事件 + broadcastEvent('plugin_update', { + action: 'toggle', + pluginName, + enabled, + timestamp: new Date().toISOString() + }); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: true, + message: `Plugin ${pluginName} ${enabled ? 'enabled' : 'disabled'} successfully`, + plugin: { + name: pluginName, + enabled + } + })); + return true; + } catch (error) { + console.error('[UI API] Failed to toggle plugin:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + error: { + message: 'Failed to toggle plugin: ' + error.message + } + })); + return true; + } + } + return false; } diff --git a/static/app/app.js b/static/app/app.js index e12226d..772b9e8 100644 --- a/static/app/app.js +++ b/static/app/app.js @@ -76,6 +76,11 @@ import { initImageZoom } from './image-zoom.js'; +import { + initPluginManager, + togglePlugin +} from './plugin-manager.js'; + /** * 加载初始数据 */ @@ -111,6 +116,7 @@ function initApp() { initUploadConfigManager(); // 初始化配置管理功能 initUsageManager(); // 初始化用量管理功能 initImageZoom(); // 初始化图片放大功能 + initPluginManager(); // 初始化插件管理功能 loadInitialData(); // 显示欢迎消息 @@ -164,6 +170,9 @@ window.reloadConfig = reloadConfig; // 用量管理相关全局函数 window.refreshUsage = refreshUsage; +// 插件管理相关全局函数 +window.togglePlugin = togglePlugin; + // 导出调试函数 window.getProviderStats = () => getProviderStats(providerStats); diff --git a/static/app/i18n.js b/static/app/i18n.js index 9794f7c..e6134ed 100644 --- a/static/app/i18n.js +++ b/static/app/i18n.js @@ -31,6 +31,7 @@ const translations = { 'nav.upload': '配置管理', 'nav.usage': '用量查询', 'nav.logs': '实时日志', + 'nav.plugins': '插件管理', // Dashboard 'dashboard.title': '系统概览', @@ -436,6 +437,26 @@ const translations = { 'logs.autoScroll.on': '自动滚动: 开', 'logs.autoScroll.off': '自动滚动: 关', + // Plugins + 'plugins.title': '插件管理', + 'plugins.description': '插件系统允许您扩展系统功能,启用或禁用插件需要重启服务才能生效', + 'plugins.stats.total': '总插件数', + 'plugins.stats.enabled': '已启用', + 'plugins.stats.disabled': '已禁用', + 'plugins.refresh': '刷新插件列表', + 'plugins.loading': '正在加载插件列表...', + 'plugins.empty': '暂无已安装的插件', + 'plugins.noDescription': '暂无描述', + 'plugins.status.enabled': '已启用', + 'plugins.status.disabled': '已禁用', + 'plugins.badge.middleware.title': '包含中间件', + 'plugins.badge.routes.title': '包含路由', + 'plugins.badge.hooks.title': '包含钩子', + 'plugins.toggle.success': '插件 {name} 已{status}', + 'plugins.toggle.failed': '切换插件状态失败', + 'plugins.load.failed': '加载插件列表失败', + 'plugins.restart.required': '更改已保存,请重启服务以生效', + // Common 'common.confirm': '确定', 'common.cancel': '取消', @@ -505,6 +526,7 @@ const translations = { 'nav.upload': 'Config Management', 'nav.usage': 'Usage Query', 'nav.logs': 'Real-time Logs', + 'nav.plugins': 'Plugin Management', // Dashboard 'dashboard.title': 'System Overview', @@ -910,11 +932,33 @@ const translations = { 'logs.autoScroll.on': 'Auto Scroll: On', 'logs.autoScroll.off': 'Auto Scroll: Off', + // Plugins + 'plugins.title': 'Plugin Management', + 'plugins.description': 'The plugin system allows you to extend system functionality. Enabling or disabling plugins requires a service restart to take effect.', + 'plugins.stats.total': 'Total Plugins', + 'plugins.stats.enabled': 'Enabled', + 'plugins.stats.disabled': 'Disabled', + 'plugins.refresh': 'Refresh Plugins', + 'plugins.loading': 'Loading plugins...', + 'plugins.empty': 'No installed plugins', + 'plugins.noDescription': 'No description', + 'plugins.status.enabled': 'Enabled', + 'plugins.status.disabled': 'Disabled', + 'plugins.badge.middleware.title': 'Contains Middleware', + 'plugins.badge.routes.title': 'Contains Routes', + 'plugins.badge.hooks.title': 'Contains Hooks', + 'plugins.toggle.success': 'Plugin {name} {status}', + 'plugins.toggle.failed': 'Failed to toggle plugin status', + 'plugins.load.failed': 'Failed to load plugins list', + 'plugins.restart.required': 'Changes saved, please restart service to take effect', + // Common 'common.togglePassword': 'Show/Hide Password', 'common.confirm': 'Confirm', 'common.cancel': 'Cancel', 'common.success': 'Success', + 'common.enabled': 'Enabled', + 'common.disabled': 'Disabled', 'common.error': 'Error', 'common.warning': 'Warning', 'common.info': 'Info', diff --git a/static/app/plugin-manager.js b/static/app/plugin-manager.js new file mode 100644 index 0000000..a21f654 --- /dev/null +++ b/static/app/plugin-manager.js @@ -0,0 +1,141 @@ +import { t } from './i18n.js'; +import { showToast, apiRequest } from './utils.js'; + +// 插件列表状态 +let pluginsList = []; + +/** + * 初始化插件管理器 + */ +export function initPluginManager() { + const refreshBtn = document.getElementById('refreshPluginsBtn'); + if (refreshBtn) { + refreshBtn.addEventListener('click', loadPlugins); + } + + // 初始加载 + loadPlugins(); +} + +/** + * 加载插件列表 + */ +export async function loadPlugins() { + const loadingEl = document.getElementById('pluginsLoading'); + const emptyEl = document.getElementById('pluginsEmpty'); + const listEl = document.getElementById('pluginsList'); + const totalEl = document.getElementById('totalPlugins'); + const enabledEl = document.getElementById('enabledPlugins'); + const disabledEl = document.getElementById('disabledPlugins'); + + if (loadingEl) loadingEl.style.display = 'block'; + if (emptyEl) emptyEl.style.display = 'none'; + if (listEl) listEl.innerHTML = ''; + + try { + const response = await apiRequest('/api/plugins'); + + if (response && response.plugins) { + pluginsList = response.plugins; + renderPluginsList(); + + // 更新统计信息 + if (totalEl) totalEl.textContent = pluginsList.length; + if (enabledEl) enabledEl.textContent = pluginsList.filter(p => p.enabled).length; + if (disabledEl) disabledEl.textContent = pluginsList.filter(p => !p.enabled).length; + } else { + if (emptyEl) emptyEl.style.display = 'flex'; + } + } catch (error) { + console.error('Failed to load plugins:', error); + showToast(t('common.error'), t('plugins.load.failed'), 'error'); + if (emptyEl) emptyEl.style.display = 'flex'; + } finally { + if (loadingEl) loadingEl.style.display = 'none'; + } +} + +/** + * 渲染插件列表 + */ +function renderPluginsList() { + const listEl = document.getElementById('pluginsList'); + const emptyEl = document.getElementById('pluginsEmpty'); + + if (!listEl) return; + + if (pluginsList.length === 0) { + if (emptyEl) emptyEl.style.display = 'flex'; + return; + } + + if (emptyEl) emptyEl.style.display = 'none'; + + pluginsList.forEach(plugin => { + const card = document.createElement('div'); + card.className = `plugin-card ${plugin.enabled ? 'enabled' : 'disabled'}`; + + // 构建标签 HTML + let badgesHtml = ''; + if (plugin.hasMiddleware) { + badgesHtml += `Middleware`; + } + if (plugin.hasRoutes) { + badgesHtml += `Routes`; + } + if (plugin.hasHooks) { + badgesHtml += `Hooks`; + } + + card.innerHTML = ` +
+
+

${plugin.name}

+ v${plugin.version} +
+
+ +
+
+
${plugin.description || t('plugins.noDescription')}
+
+ ${badgesHtml} +
+
+ ${plugin.enabled ? t('plugins.status.enabled') : t('plugins.status.disabled')} +
+ `; + + listEl.appendChild(card); + }); +} + +/** + * 切换插件启用状态 + * @param {string} pluginName - 插件名称 + * @param {boolean} enabled - 是否启用 + */ +export async function togglePlugin(pluginName, enabled) { + try { + await apiRequest(`/api/plugins/${encodeURIComponent(pluginName)}/toggle`, { + method: 'POST', + body: JSON.stringify({ enabled }) + }); + + showToast(t('common.success'), t('plugins.toggle.success', { name: pluginName, status: enabled ? t('common.enabled') : t('common.disabled') }), 'success'); + + // 重新加载列表以更新状态 + loadPlugins(); + + // 提示需要重启 + showToast(t('common.info'), t('plugins.restart.required'), 'info'); + } catch (error) { + console.error(`Failed to toggle plugin ${pluginName}:`, error); + showToast(t('common.error'), t('plugins.toggle.failed'), 'error'); + // 恢复开关状态 + loadPlugins(); + } +} \ No newline at end of file diff --git a/static/app/styles.css b/static/app/styles.css index 7a2a731..a78153a 100644 --- a/static/app/styles.css +++ b/static/app/styles.css @@ -5850,3 +5850,186 @@ body { .provider-modal-content { transition: background-color 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease; } + +/* ==================== 插件管理样式 ==================== */ +.plugins-panel { + background: var(--bg-primary); + padding: 2rem; + border-radius: 0.5rem; + box-shadow: var(--shadow-md); +} + +.plugins-description { + margin-bottom: 2rem; +} + +.plugins-stats { + margin-bottom: 2rem; +} + +.plugins-controls { + display: flex; + justify-content: flex-end; + margin-bottom: 1.5rem; +} + +.plugins-list-container { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 0.5rem; + overflow: hidden; +} + +.plugins-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1.5rem; + padding: 1.5rem; +} + +.plugin-card { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 0.5rem; + padding: 1.5rem; + transition: var(--transition); + display: flex; + flex-direction: column; + gap: 1rem; +} + +.plugin-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-md); + border-color: var(--primary-color); +} + +.plugin-header { + display: flex; + justify-content: space-between; + align-items: flex-start; +} + +.plugin-title h3 { + margin: 0; + font-size: 1.125rem; + font-weight: 600; + color: var(--text-primary); +} + +.plugin-version { + font-size: 0.75rem; + color: var(--text-secondary); + background: var(--bg-tertiary); + padding: 0.125rem 0.5rem; + border-radius: 9999px; + margin-top: 0.25rem; + display: inline-block; +} + +.plugin-description { + font-size: 0.875rem; + color: var(--text-secondary); + line-height: 1.5; + flex: 1; +} + +.plugin-badges { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.plugin-badge { + font-size: 0.75rem; + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.plugin-badge.middleware { + background: var(--info-bg); + color: var(--info-text); +} + +.plugin-badge.routes { + background: var(--success-bg); + color: var(--success-text); +} + +.plugin-badge.hooks { + background: var(--warning-bg); + color: var(--warning-text); +} + +.plugin-status { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + font-weight: 500; + padding-top: 1rem; + border-top: 1px solid var(--border-color); +} + +.plugin-card.enabled .plugin-status { + color: var(--success-color); +} + +.plugin-card.disabled .plugin-status { + color: var(--text-secondary); +} + +.plugin-card.disabled { + opacity: 0.8; + background: var(--bg-secondary); +} + +.plugins-loading, +.plugins-empty { + text-align: center; + padding: 3rem; + color: var(--text-secondary); +} + +.plugins-empty { + display: none; + flex-direction: column; + align-items: center; + gap: 1rem; +} + +.plugins-empty i { + font-size: 3rem; + opacity: 0.5; +} + +/* 暗黑主题适配 */ +[data-theme="dark"] .plugins-list-container { + background: var(--bg-secondary); +} + +[data-theme="dark"] .plugin-card { + background: var(--bg-primary); +} + +[data-theme="dark"] .plugin-card.disabled { + background: var(--bg-tertiary); +} + +[data-theme="dark"] .plugin-badge.middleware { + background: var(--info-bg); + color: var(--info-text); +} + +[data-theme="dark"] .plugin-badge.routes { + background: var(--success-bg); + color: var(--success-text); +} + +[data-theme="dark"] .plugin-badge.hooks { + background: var(--warning-bg); + color: var(--warning-text); +} diff --git a/static/app/utils.js b/static/app/utils.js index 159c8e6..e4e8f22 100644 --- a/static/app/utils.js +++ b/static/app/utils.js @@ -1,5 +1,6 @@ // 工具函数 import { t, getCurrentLanguage } from './i18n.js'; +import { apiClient } from './auth.js'; /** * 格式化运行时间 @@ -275,6 +276,18 @@ function getProviderStats(providerStats) { }; } +/** + * 通用 API 请求函数 + * @param {string} url - API 端点 URL + * @param {Object} options - fetch 选项 + * @returns {Promise} 响应数据 + */ +async function apiRequest(url, options = {}) { + // 如果 URL 以 /api 开头,去掉它(因为 apiClient.request 会自动添加) + const endpoint = url.startsWith('/api') ? url.slice(4) : url; + return apiClient.request(endpoint, options); +} + // 导出所有工具函数 export { formatUptime, @@ -282,5 +295,6 @@ export { showToast, getFieldLabel, getProviderTypeFields, - getProviderStats + getProviderStats, + apiRequest }; \ No newline at end of file diff --git a/static/index.html b/static/index.html index 457db93..ecf1dfb 100644 --- a/static/index.html +++ b/static/index.html @@ -60,6 +60,9 @@ 用量查询 + + 插件管理 + 实时日志 @@ -965,6 +968,73 @@ + +
+

插件管理

+
+
+
+ + 插件系统允许您扩展系统功能,启用或禁用插件需要重启服务才能生效 +
+
+ + +
+
+
+ +
+
+

0

+

总插件数

+
+
+
+
+ +
+
+

0

+

已启用

+
+
+
+
+ +
+
+

0

+

已禁用

+
+
+
+ + +
+ +
+ + + + + +
+
+ +
+
+ +

暂无已安装的插件

+
+
+
+
+