Merge remote-tracking branch 'aiclient/main' into fix-400-kiro

This commit is contained in:
Zhafron Kautsar 2026-01-09 06:17:15 -05:00
commit f9428fa294
11 changed files with 591 additions and 49 deletions

View file

@ -1 +1 @@
2.5.8
2.5.9

View file

@ -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<Object>} 默认插件配置
*/
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 - 钩子名称

View file

@ -68,12 +68,6 @@ const apiPotluckPlugin = {
*/
staticPaths: ['potluck.html', 'potluck-user.html'],
/**
* 公开 API 路径不需要 UI 管理 API token 验证
* 这些路径将跳过 handleUIApiRequests 中的 checkAuth 验证
*/
publicApiPaths: ['/api/potluckuser'],
/**
* 路由定义
*/

View file

@ -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);

View file

@ -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;
}

View file

@ -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);

View file

@ -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',

View file

@ -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 += `<span class="plugin-badge middleware" title="${t('plugins.badge.middleware.title')}">Middleware</span>`;
}
if (plugin.hasRoutes) {
badgesHtml += `<span class="plugin-badge routes" title="${t('plugins.badge.routes.title')}">Routes</span>`;
}
if (plugin.hasHooks) {
badgesHtml += `<span class="plugin-badge hooks" title="${t('plugins.badge.hooks.title')}">Hooks</span>`;
}
card.innerHTML = `
<div class="plugin-header">
<div class="plugin-title">
<h3>${plugin.name}</h3>
<span class="plugin-version">v${plugin.version}</span>
</div>
<div class="plugin-actions">
<label class="toggle-switch">
<input type="checkbox" ${plugin.enabled ? 'checked' : ''} onchange="window.togglePlugin('${plugin.name}', this.checked)">
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="plugin-description">${plugin.description || t('plugins.noDescription')}</div>
<div class="plugin-badges">
${badgesHtml}
</div>
<div class="plugin-status">
<i class="fas fa-circle"></i> <span>${plugin.enabled ? t('plugins.status.enabled') : t('plugins.status.disabled')}</span>
</div>
`;
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();
}
}

View file

@ -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);
}

View file

@ -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<any>} 响应数据
*/
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
};

View file

@ -60,6 +60,9 @@
<a href="#usage" class="nav-item" data-section="usage" aria-label="Usage Query" data-i18n-aria-label="nav.usage">
<i class="fas fa-chart-bar" aria-hidden="true"></i> <span data-i18n="nav.usage">用量查询</span>
</a>
<a href="#plugins" class="nav-item" data-section="plugins" aria-label="Plugin Management" data-i18n-aria-label="nav.plugins">
<i class="fas fa-puzzle-piece" aria-hidden="true"></i> <span data-i18n="nav.plugins">插件管理</span>
</a>
<a href="#logs" class="nav-item" data-section="logs" aria-label="Real-time Logs" data-i18n-aria-label="nav.logs">
<i class="fas fa-file-alt" aria-hidden="true"></i> <span data-i18n="nav.logs">实时日志</span>
</a>
@ -965,6 +968,73 @@
</div>
</section>
<!-- Plugins Section -->
<section id="plugins" class="section" aria-labelledby="plugins-title">
<h2 id="plugins-title" data-i18n="plugins.title">插件管理</h2>
<div class="plugins-panel">
<div class="plugins-description">
<div class="highlight-note">
<i class="fas fa-info-circle"></i>
<span data-i18n="plugins.description">插件系统允许您扩展系统功能,启用或禁用插件需要重启服务才能生效</span>
</div>
</div>
<!-- 插件统计 -->
<div class="stats-grid plugins-stats">
<div class="stat-card">
<div class="stat-icon">
<i class="fas fa-puzzle-piece"></i>
</div>
<div class="stat-info">
<h3 id="totalPlugins">0</h3>
<p data-i18n="plugins.stats.total">总插件数</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<i class="fas fa-check-circle"></i>
</div>
<div class="stat-info">
<h3 id="enabledPlugins">0</h3>
<p data-i18n="plugins.stats.enabled">已启用</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">
<i class="fas fa-times-circle"></i>
</div>
<div class="stat-info">
<h3 id="disabledPlugins">0</h3>
<p data-i18n="plugins.stats.disabled">已禁用</p>
</div>
</div>
</div>
<!-- 插件操作按钮 -->
<div class="plugins-controls">
<button class="btn btn-primary" id="refreshPluginsBtn" aria-label="Refresh Plugins" data-i18n-aria-label="plugins.refresh">
<i class="fas fa-sync-alt"></i> <span data-i18n="plugins.refresh">刷新插件列表</span>
</button>
</div>
<!-- 插件加载状态 -->
<div class="plugins-loading" id="pluginsLoading" style="display: none;">
<i class="fas fa-spinner fa-spin"></i> <span data-i18n="plugins.loading">正在加载插件列表...</span>
</div>
<!-- 插件列表 -->
<div class="plugins-list-container">
<div id="pluginsList" class="plugins-list">
<!-- 插件列表将在这里动态生成 -->
</div>
<div class="plugins-empty" id="pluginsEmpty">
<i class="fas fa-puzzle-piece"></i>
<p data-i18n="plugins.empty">暂无已安装的插件</p>
</div>
</div>
</div>
</section>
</main>
</div>
</div>