Add plugin install SSE API and mirror selection UI

Introduces a new SSE-based plugin installation API for real-time progress updates and adds frontend support for selecting download mirrors, especially for GitHub-based plugins. Refactors backend plugin directory handling, improves logging, and updates the frontend to use the new API with user-selectable mirrors and progress feedback.
This commit is contained in:
手瓜一十雪 2026-01-27 22:51:45 +08:00
parent 24623f18d8
commit 6b8cc6756d
5 changed files with 263 additions and 48 deletions

View File

@ -383,17 +383,14 @@ export async function testUrlHead (url: string, timeout: number = 5000): Promise
}, (res) => {
const statusCode = res.statusCode || 0;
const contentType = (res.headers['content-type'] as string) || '';
const contentLength = parseInt((res.headers['content-length'] as string) || '0', 10);
// 验证条件:
// 简化验证条件:
// 1. 状态码 2xx 或 3xx
// 2. Content-Type 不应该是 text/html表示错误页面
// 3. 对于 .zip 文件Content-Length 应该 > 1MB避免获取到错误页面
const isValidStatus = statusCode >= 200 && statusCode < 400;
const isNotHtmlError = !contentType.includes('text/html');
const isValidSize = url.endsWith('.zip') ? contentLength > 1024 * 1024 : true;
resolve(isValidStatus && isNotHtmlError && isValidSize);
resolve(isValidStatus && isNotHtmlError);
});
req.on('error', () => resolve(false));
@ -437,10 +434,9 @@ export async function validateUrl (url: string, timeout: number = 5000): Promise
const contentType = (res.headers['content-type'] as string) || '';
const contentLength = parseInt((res.headers['content-length'] as string) || '0', 10);
// 验证条件
// 简化验证条件
const isValidStatus = statusCode >= 200 && statusCode < 400;
const isNotHtmlError = !contentType.includes('text/html');
const isValidSize = url.endsWith('.zip') ? contentLength > 1024 * 1024 : true;
if (!isValidStatus) {
resolve({
@ -458,14 +454,6 @@ export async function validateUrl (url: string, timeout: number = 5000): Promise
contentLength,
error: '返回了 HTML 页面而非文件',
});
} else if (!isValidSize) {
resolve({
valid: false,
statusCode,
contentType,
contentLength,
error: `文件过小 (${contentLength} bytes),可能是错误页面`,
});
} else {
resolve({
valid: true,
@ -542,21 +530,21 @@ export async function findAvailableDownloadUrl (
const testWithValidation = async (url: string): Promise<boolean> => {
if (validateContent) {
const result = await validateUrl(url, timeout);
// 额外检查文件大小
// 额外检查文件大小(仅当指定了 minFileSize 时)
if (result.valid && minFileSize && result.contentLength && result.contentLength < minFileSize) {
return false;
}
return result.valid;
}
return testMethod === 'head' ? testUrlHead(url, timeout) : testUrl(url, timeout);
// 不验证内容,只检查状态码
const isValid = testMethod === 'head' ? await testUrlHead(url, timeout) : await testUrl(url, timeout);
return isValid;
};
// 1. 如果设置了自定义镜像,优先使用
// 1. 如果设置了自定义镜像,直接使用(不测试,信任用户选择)
if (customMirror) {
const customUrl = buildMirrorUrl(originalUrl, customMirror);
if (await testWithValidation(customUrl)) {
return customUrl;
}
return customUrl;
}
// 2. 先测试原始 URL

View File

@ -7,19 +7,15 @@ import { pipeline } from 'stream/promises';
import { createWriteStream } from 'fs';
import compressing from 'compressing';
import { findAvailableDownloadUrl, GITHUB_RAW_MIRRORS } from 'napcat-common/src/mirror';
import { webUiPathWrapper } from '@/napcat-webui-backend/index';
// 插件商店源配置
const PLUGIN_STORE_SOURCES = [
'https://raw.githubusercontent.com/NapNeko/napcat-plugin-index/main/plugins.v4.json',
];
// 插件目录
const PLUGINS_DIR = path.join(process.cwd(), 'plugins');
// 确保插件目录存在
if (!fs.existsSync(PLUGINS_DIR)) {
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
}
// 插件目录 - 使用 pathWrapper
const getPluginsDir = () => webUiPathWrapper.pluginPath;
// 插件列表缓存
let pluginListCache: PluginStoreList | null = null;
@ -80,7 +76,7 @@ async function fetchPluginList (): Promise<PluginStoreList> {
* 使
* GitHub Release URL 使
*/
async function downloadFile (url: string, destPath: string): Promise<void> {
async function downloadFile (url: string, destPath: string, customMirror?: string): Promise<void> {
try {
let downloadUrl: string;
@ -91,25 +87,36 @@ async function downloadFile (url: string, destPath: string): Promise<void> {
if (githubReleasePattern.test(url)) {
// 使用镜像系统查找可用的下载 URL支持 GitHub Release 镜像)
console.log(`Detected GitHub Release URL: ${url}`);
console.log(`Custom mirror: ${customMirror || 'auto'}`);
downloadUrl = await findAvailableDownloadUrl(url, {
validateContent: true,
minFileSize: 1024, // 最小 1KB
timeout: 60000, // 60秒超时
useFastMirrors: true, // 使用快速镜像列表
validateContent: false, // 不验证内容,只检查状态码和 Content-Type
timeout: 5000, // 每个镜像测试5秒超时
useFastMirrors: false, // 不使用快速镜像列表(避免测速阻塞)
customMirror: customMirror || undefined, // 使用用户选择的镜像
});
console.log(`Selected download URL: ${downloadUrl}`);
} else {
// 其他URL直接下载
console.log(`Direct download URL: ${url}`);
downloadUrl = url;
}
console.log(`Downloading from: ${downloadUrl}`);
console.log(`Starting download from: ${downloadUrl}`);
// 确保目标目录存在
const destDir = path.dirname(destPath);
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true });
console.log(`Created directory: ${destDir}`);
}
const response = await fetch(downloadUrl, {
headers: {
'User-Agent': 'NapCat-WebUI',
},
signal: AbortSignal.timeout(60000),
signal: AbortSignal.timeout(120000), // 实际下载120秒超时
});
if (!response.ok) {
@ -138,20 +145,38 @@ async function downloadFile (url: string, destPath: string): Promise<void> {
*
*/
async function extractPlugin (zipPath: string, pluginId: string): Promise<void> {
const PLUGINS_DIR = getPluginsDir();
const pluginDir = path.join(PLUGINS_DIR, pluginId);
console.log(`[extractPlugin] PLUGINS_DIR: ${PLUGINS_DIR}`);
console.log(`[extractPlugin] pluginId: ${pluginId}`);
console.log(`[extractPlugin] Target directory: ${pluginDir}`);
console.log(`[extractPlugin] Zip file: ${zipPath}`);
// 确保插件根目录存在
if (!fs.existsSync(PLUGINS_DIR)) {
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
console.log(`[extractPlugin] Created plugins root directory: ${PLUGINS_DIR}`);
}
// 如果目录已存在,先删除
if (fs.existsSync(pluginDir)) {
console.log(`[extractPlugin] Directory exists, removing: ${pluginDir}`);
fs.rmSync(pluginDir, { recursive: true, force: true });
}
// 创建插件目录
fs.mkdirSync(pluginDir, { recursive: true });
console.log(`[extractPlugin] Created directory: ${pluginDir}`);
// 解压
await compressing.zip.uncompress(zipPath, pluginDir);
//console.log(`Plugin extracted to: ${pluginDir}`);
console.log(`[extractPlugin] Plugin extracted to: ${pluginDir}`);
// 列出解压后的文件
const files = fs.readdirSync(pluginDir);
console.log(`[extractPlugin] Extracted files:`, files);
}
/**
@ -186,11 +211,11 @@ export const GetPluginStoreDetailHandler: RequestHandler = async (req, res) => {
};
/**
*
* - POST
*/
export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) => {
try {
const { id } = req.body;
const { id, mirror } = req.body;
if (!id) {
return sendError(res, 'Plugin ID is required');
@ -205,10 +230,11 @@ export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) =>
}
// 下载插件
const PLUGINS_DIR = getPluginsDir();
const tempZipPath = path.join(PLUGINS_DIR, `${id}.temp.zip`);
try {
await downloadFile(plugin.downloadUrl, tempZipPath);
await downloadFile(plugin.downloadUrl, tempZipPath, mirror);
// 解压插件
await extractPlugin(tempZipPath, id);
@ -232,3 +258,83 @@ export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) =>
return sendError(res, 'Failed to install plugin: ' + e.message);
}
};
/**
* - SSE
*/
export const InstallPluginFromStoreSSEHandler: RequestHandler = async (req, res) => {
const { id, mirror } = req.query;
if (!id || typeof id !== 'string') {
res.status(400).json({ error: 'Plugin ID is required' });
return;
}
// 设置 SSE 响应头
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
const sendProgress = (message: string, progress?: number) => {
res.write(`data: ${JSON.stringify({ message, progress })}\n\n`);
};
try {
sendProgress('正在获取插件信息...', 10);
// 获取插件信息
const data = await fetchPluginList();
const plugin = data.plugins.find(p => p.id === id);
if (!plugin) {
sendProgress('错误: 插件不存在', 0);
res.write(`data: ${JSON.stringify({ error: 'Plugin not found in store' })}\n\n`);
res.end();
return;
}
sendProgress(`找到插件: ${plugin.name} v${plugin.version}`, 20);
sendProgress(`下载地址: ${plugin.downloadUrl}`, 25);
if (mirror && typeof mirror === 'string') {
sendProgress(`使用镜像: ${mirror}`, 28);
}
// 下载插件
const PLUGINS_DIR = getPluginsDir();
const tempZipPath = path.join(PLUGINS_DIR, `${id}.temp.zip`);
try {
sendProgress('正在下载插件...', 30);
await downloadFile(plugin.downloadUrl, tempZipPath, mirror as string | undefined);
sendProgress('下载完成,正在解压...', 70);
await extractPlugin(tempZipPath, id);
sendProgress('解压完成,正在清理...', 90);
fs.unlinkSync(tempZipPath);
sendProgress('安装成功!', 100);
res.write(`data: ${JSON.stringify({
success: true,
message: 'Plugin installed successfully',
plugin: plugin,
installPath: path.join(PLUGINS_DIR, id),
})}\n\n`);
res.end();
} catch (downloadError: any) {
// 清理临时文件
if (fs.existsSync(tempZipPath)) {
fs.unlinkSync(tempZipPath);
}
sendProgress(`错误: ${downloadError.message}`, 0);
res.write(`data: ${JSON.stringify({ error: downloadError.message })}\n\n`);
res.end();
}
} catch (e: any) {
sendProgress(`错误: ${e.message}`, 0);
res.write(`data: ${JSON.stringify({ error: e.message })}\n\n`);
res.end();
}
};

View File

@ -1,6 +1,6 @@
import { Router } from 'express';
import { GetPluginListHandler, ReloadPluginHandler, SetPluginStatusHandler, UninstallPluginHandler } from '@/napcat-webui-backend/src/api/Plugin';
import { GetPluginStoreListHandler, GetPluginStoreDetailHandler, InstallPluginFromStoreHandler } from '@/napcat-webui-backend/src/api/PluginStore';
import { GetPluginStoreListHandler, GetPluginStoreDetailHandler, InstallPluginFromStoreHandler, InstallPluginFromStoreSSEHandler } from '@/napcat-webui-backend/src/api/PluginStore';
const router: Router = Router();
@ -13,5 +13,6 @@ router.post('/Uninstall', UninstallPluginHandler);
router.get('/Store/List', GetPluginStoreListHandler);
router.get('/Store/Detail/:id', GetPluginStoreDetailHandler);
router.post('/Store/Install', InstallPluginFromStoreHandler);
router.get('/Store/Install/SSE', InstallPluginFromStoreSSEHandler);
export { router as PluginRouter };

View File

@ -50,7 +50,10 @@ export default class PluginManager {
return data.data;
}
public static async installPluginFromStore (id: string) {
await serverRequest.post<ServerResponse<void>>('/Plugin/Store/Install', { id });
public static async installPluginFromStore (id: string, mirror?: string) {
// 插件安装可能需要较长时间(下载+解压设置5分钟超时
await serverRequest.post<ServerResponse<void>>('/Plugin/Store/Install', { id, mirror }, {
timeout: 300000, // 5分钟
});
}
}

View File

@ -1,15 +1,21 @@
import { Button } from '@heroui/button';
import { Input } from '@heroui/input';
import { Select, SelectItem } from '@heroui/select';
import { Tab, Tabs } from '@heroui/tabs';
import { useEffect, useMemo, useState } from 'react';
import toast from 'react-hot-toast';
import { IoMdRefresh, IoMdSearch } from 'react-icons/io';
import clsx from 'clsx';
import { useRequest } from 'ahooks';
import { EventSourcePolyfill } from 'event-source-polyfill';
import PageLoading from '@/components/page_loading';
import PluginStoreCard from '@/components/display_card/plugin_store_card';
import PluginManager from '@/controllers/plugin_manager';
import WebUIManager from '@/controllers/webui_manager';
import { PluginStoreItem } from '@/types/plugin-store';
import useDialog from '@/hooks/use-dialog';
import key from '@/const/key';
interface EmptySectionProps {
isEmpty: boolean;
@ -32,6 +38,14 @@ export default function PluginStorePage () {
const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [activeTab, setActiveTab] = useState<string>('all');
const dialog = useDialog();
// 获取镜像列表
const { data: mirrorsData } = useRequest(WebUIManager.getMirrors, {
cacheKey: 'napcat-mirrors',
staleTime: 60 * 60 * 1000,
});
const mirrors = mirrorsData?.mirrors || [];
const loadPlugins = async () => {
setLoading(true);
@ -86,13 +100,116 @@ export default function PluginStorePage () {
];
}, [categorizedPlugins]);
const handleInstall = async (pluginId: string) => {
const handleInstall = async (plugin: PluginStoreItem) => {
// 检测是否是 GitHub 下载链接
const githubPattern = /^https:\/\/github\.com\//;
const isGitHubUrl = githubPattern.test(plugin.downloadUrl);
// 如果是 GitHub 链接,弹出镜像选择对话框
if (isGitHubUrl) {
let selectedMirror: string | undefined = undefined;
dialog.confirm({
title: '安装插件',
content: (
<div className="space-y-4">
<div>
<p className="text-sm mb-2">
: <span className="font-semibold">{plugin.name}</span>
</p>
<p className="text-sm mb-2">
: <span className="font-semibold">v{plugin.version}</span>
</p>
<p className="text-sm text-default-500 mb-4">
{plugin.description}
</p>
</div>
<div>
<label className="text-sm font-medium mb-2 block"></label>
<Select
placeholder="自动选择 (默认)"
defaultSelectedKeys={['default']}
onSelectionChange={(keys) => {
const m = Array.from(keys)[0] as string;
selectedMirror = m === 'default' ? undefined : m;
}}
size="sm"
aria-label="选择镜像源"
>
{['default', ...mirrors].map(m => (
<SelectItem key={m} textValue={m === 'default' ? '自动选择' : m}>
{m === 'default' ? '自动选择 (默认)' : m}
</SelectItem>
))}
</Select>
</div>
</div>
),
confirmText: '开始安装',
cancelText: '取消',
onConfirm: async () => {
await installPluginWithSSE(plugin.id, selectedMirror);
},
});
} else {
// 非 GitHub 链接,直接安装
await installPluginWithSSE(plugin.id);
}
};
const installPluginWithSSE = async (pluginId: string, mirror?: string) => {
const loadingToast = toast.loading('正在准备安装...');
try {
await PluginManager.installPluginFromStore(pluginId);
toast.success('插件安装成功!');
// 可以选择刷新插件列表或导航到插件管理页面
// 获取认证 token
const token = localStorage.getItem(key.token);
if (!token) {
toast.error('未登录,请先登录', { id: loadingToast });
return;
}
const _token = JSON.parse(token);
const params = new URLSearchParams({ id: pluginId });
if (mirror) {
params.append('mirror', mirror);
}
const eventSource = new EventSourcePolyfill(
`/api/Plugin/Store/Install/SSE?${params.toString()}`,
{
headers: {
Authorization: `Bearer ${_token}`,
Accept: 'text/event-stream',
},
withCredentials: true,
}
);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.error) {
toast.error(`安装失败: ${data.error}`, { id: loadingToast });
eventSource.close();
} else if (data.success) {
toast.success('插件安装成功!', { id: loadingToast });
eventSource.close();
} else if (data.message) {
toast.loading(data.message, { id: loadingToast });
}
} catch (e) {
console.error('Failed to parse SSE message:', e);
}
};
eventSource.onerror = (error) => {
console.error('SSE连接出错:', error);
toast.error('连接中断,安装失败', { id: loadingToast });
eventSource.close();
};
} catch (error: any) {
toast.error(`安装失败: ${error.message || '未知错误'}`);
toast.error(`安装失败: ${error.message || '未知错误'}`, { id: loadingToast });
}
};
@ -148,7 +265,7 @@ export default function PluginStorePage () {
<PluginStoreCard
key={plugin.id}
data={plugin}
onInstall={() => handleInstall(plugin.id)}
onInstall={() => handleInstall(plugin)}
/>
))}
</div>