mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-01-03 00:19:05 +08:00
feat: 支持免配置调试
This commit is contained in:
parent
649165bf00
commit
578dda2f17
@ -79,7 +79,10 @@ export async function NCoreInitFramework (
|
||||
WebUiDataRuntime.setWorkingEnv(NapCatCoreWorkingEnv.Framework);
|
||||
InitWebUi(logger, pathWrapper, logSubscription, statusHelperSubscription).then().catch(e => logger.logError(e));
|
||||
// 初始化LLNC的Onebot实现
|
||||
await new NapCatOneBot11Adapter(loaderObject.core, loaderObject.context, pathWrapper).InitOneBot();
|
||||
const oneBotAdapter = new NapCatOneBot11Adapter(loaderObject.core, loaderObject.context, pathWrapper);
|
||||
// 注册到 WebUiDataRuntime,供调试功能使用
|
||||
WebUiDataRuntime.setOneBotContext(oneBotAdapter);
|
||||
await oneBotAdapter.InitOneBot();
|
||||
}
|
||||
|
||||
export class NapCatFramework {
|
||||
|
||||
@ -455,7 +455,11 @@ export class NapCatShell {
|
||||
|
||||
async InitNapCat () {
|
||||
await this.core.initCore();
|
||||
new NapCatOneBot11Adapter(this.core, this.context, this.context.pathWrapper).InitOneBot()
|
||||
const oneBotAdapter = new NapCatOneBot11Adapter(this.core, this.context, this.context.pathWrapper);
|
||||
// 注册到 WebUiDataRuntime,供调试功能使用
|
||||
WebUiDataRuntime.setOneBotContext(oneBotAdapter);
|
||||
oneBotAdapter.InitOneBot()
|
||||
.catch(e => this.context.logger.logError('初始化OneBot失败', e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -22,6 +22,7 @@ import { existsSync, readFileSync } from 'node:fs'; // 引入multer用于错误
|
||||
import { ILogWrapper } from 'napcat-common/src/log-interface';
|
||||
import { ISubscription } from 'napcat-common/src/subscription-interface';
|
||||
import { IStatusHelperSubscription } from '@/napcat-common/src/status-interface';
|
||||
import { handleDebugWebSocket } from '@/napcat-webui-backend/src/api/Debug';
|
||||
// 实例化Express
|
||||
const app = express();
|
||||
/**
|
||||
@ -187,7 +188,15 @@ export async function InitWebUi (logger: ILogWrapper, pathWrapper: NapCatPathWra
|
||||
const isHttps = !!sslCerts;
|
||||
const server = isHttps && sslCerts ? createHttpsServer(sslCerts, app) : createServer(app);
|
||||
server.on('upgrade', (request, socket, head) => {
|
||||
terminalManager.initialize(request, socket, head, logger);
|
||||
const url = new URL(request.url || '', `http://${request.headers.host}`);
|
||||
|
||||
// 检查是否是调试 WebSocket 连接
|
||||
if (url.pathname.startsWith('/api/Debug/ws')) {
|
||||
handleDebugWebSocket(request, socket, head);
|
||||
} else {
|
||||
// 默认为终端 WebSocket
|
||||
terminalManager.initialize(request, socket, head, logger);
|
||||
}
|
||||
});
|
||||
// 挂载API接口
|
||||
app.use('/api', ALLRouter);
|
||||
|
||||
410
packages/napcat-webui-backend/src/api/Debug.ts
Normal file
410
packages/napcat-webui-backend/src/api/Debug.ts
Normal file
@ -0,0 +1,410 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { WebSocket, WebSocketServer } from 'ws';
|
||||
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
|
||||
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
|
||||
import { IncomingMessage } from 'http';
|
||||
import { OB11Response } from '@/napcat-onebot/action/OneBotAction';
|
||||
import { ActionName } from '@/napcat-onebot/action/router';
|
||||
import { OB11LifeCycleEvent, LifeCycleSubType } from '@/napcat-onebot/event/meta/OB11LifeCycleEvent';
|
||||
|
||||
const router = Router();
|
||||
const DEFAULT_ADAPTER_NAME = 'debug-primary';
|
||||
|
||||
/**
|
||||
* 统一的调试适配器
|
||||
* 用于注入到 OneBot NetworkManager,接收所有事件并转发给 WebSocket 客户端
|
||||
*/
|
||||
class DebugAdapter {
|
||||
name: string;
|
||||
isEnable: boolean = true;
|
||||
// 安全令牌
|
||||
readonly token: string;
|
||||
|
||||
// 添加 config 属性,模拟 PluginConfig 结构
|
||||
config: {
|
||||
enable: boolean;
|
||||
name: string;
|
||||
messagePostFormat?: string;
|
||||
reportSelfMessage?: boolean;
|
||||
debug?: boolean;
|
||||
token?: string;
|
||||
heartInterval?: number;
|
||||
};
|
||||
wsClients: Set<WebSocket> = new Set();
|
||||
lastActivityTime: number = Date.now();
|
||||
inactivityTimer: NodeJS.Timeout | null = null;
|
||||
readonly INACTIVITY_TIMEOUT = 5 * 60 * 1000; // 5分钟不活跃
|
||||
|
||||
constructor (sessionId: string) {
|
||||
this.name = `debug-${sessionId}`;
|
||||
// 生成简单的随机 token
|
||||
this.token = Math.random().toString(36).substring(2) + Math.random().toString(36).substring(2);
|
||||
|
||||
this.config = {
|
||||
enable: true,
|
||||
name: this.name,
|
||||
messagePostFormat: 'array',
|
||||
reportSelfMessage: true,
|
||||
debug: true,
|
||||
token: this.token,
|
||||
heartInterval: 30000
|
||||
};
|
||||
this.startInactivityCheck();
|
||||
}
|
||||
|
||||
// 实现 IOB11NetworkAdapter 接口所需的抽象方法
|
||||
async open (): Promise<void> { }
|
||||
async close (): Promise<void> { this.cleanup(); }
|
||||
async reload (_config: any): Promise<any> { return 0; }
|
||||
|
||||
/**
|
||||
* OneBot 事件回调 - 转发给所有 WebSocket 客户端 (原始流)
|
||||
*/
|
||||
async onEvent (event: any) {
|
||||
this.updateActivity();
|
||||
console.log(`[Debug] Adapter ${this.name} 收到事件, 类型: ${event.post_type || 'unknown'}, 客户端数: ${this.wsClients.size}`);
|
||||
|
||||
const payload = JSON.stringify(event);
|
||||
|
||||
if (this.wsClients.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.wsClients.forEach((client) => {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
client.send(payload);
|
||||
} catch (error) {
|
||||
console.error('[Debug] 发送事件到 WebSocket 失败:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 OneBot API (HTTP 接口使用)
|
||||
*/
|
||||
async callApi (actionName: string, params: any): Promise<any> {
|
||||
this.updateActivity();
|
||||
|
||||
const oneBotContext = WebUiDataRuntime.getOneBotContext();
|
||||
if (!oneBotContext) {
|
||||
throw new Error('OneBot 未初始化');
|
||||
}
|
||||
|
||||
const action = oneBotContext.actions.get(actionName);
|
||||
if (!action) {
|
||||
throw new Error(`不支持的 API: ${actionName}`);
|
||||
}
|
||||
|
||||
return await action.handle(params, this.name, {
|
||||
name: this.name,
|
||||
enable: true,
|
||||
messagePostFormat: 'array',
|
||||
reportSelfMessage: true,
|
||||
debug: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 WebSocket 消息 (OneBot 标准)
|
||||
*/
|
||||
async handleWsMessage (ws: WebSocket, message: string | Buffer) {
|
||||
this.updateActivity();
|
||||
let receiveData: { action: typeof ActionName[keyof typeof ActionName], params?: any, echo?: any; } = { action: ActionName.Unknown, params: {} };
|
||||
let echo;
|
||||
|
||||
try {
|
||||
receiveData = JSON.parse(message.toString());
|
||||
echo = receiveData.echo;
|
||||
} catch {
|
||||
this.sendWsResponse(ws, OB11Response.error('json解析失败,请检查数据格式', 1400, echo));
|
||||
return;
|
||||
}
|
||||
|
||||
receiveData.params = (receiveData?.params) ? receiveData.params : {};
|
||||
|
||||
// 兼容 WebUI 之前可能的一些非标准格式 (如果用户是旧前端)
|
||||
// 但既然用户说要"原始流",我们优先支持标准格式
|
||||
|
||||
const oneBotContext = WebUiDataRuntime.getOneBotContext();
|
||||
if (!oneBotContext) {
|
||||
this.sendWsResponse(ws, OB11Response.error('OneBot 未初始化', 1404, echo));
|
||||
return;
|
||||
}
|
||||
|
||||
const action = oneBotContext.actions.get(receiveData.action as any);
|
||||
if (!action) {
|
||||
this.sendWsResponse(ws, OB11Response.error('不支持的API ' + receiveData.action, 1404, echo));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const retdata = await action.websocketHandle(receiveData.params, echo ?? '', this.name, this.config, {
|
||||
send: async (data: object) => {
|
||||
this.sendWsResponse(ws, OB11Response.ok(data, echo ?? '', true));
|
||||
},
|
||||
});
|
||||
this.sendWsResponse(ws, retdata);
|
||||
} catch (e: any) {
|
||||
this.sendWsResponse(ws, OB11Response.error(e.message || '内部错误', 1200, echo));
|
||||
}
|
||||
}
|
||||
|
||||
sendWsResponse (ws: WebSocket, data: any) {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加 WebSocket 客户端
|
||||
*/
|
||||
addWsClient (ws: WebSocket) {
|
||||
this.wsClients.add(ws);
|
||||
this.updateActivity();
|
||||
console.log(`[Debug] WebSocket 客户端已连接 (${this.wsClients.size})`);
|
||||
|
||||
// 发送生命周期事件 (Connect)
|
||||
const oneBotContext = WebUiDataRuntime.getOneBotContext();
|
||||
if (oneBotContext && oneBotContext.core) {
|
||||
try {
|
||||
const event = new OB11LifeCycleEvent(oneBotContext.core, LifeCycleSubType.CONNECT);
|
||||
ws.send(JSON.stringify(event));
|
||||
} catch (e) {
|
||||
console.error('[Debug] 发送生命周期事件失败', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除 WebSocket 客户端
|
||||
*/
|
||||
removeWsClient (ws: WebSocket) {
|
||||
this.wsClients.delete(ws);
|
||||
console.log(`[Debug] WebSocket 客户端已断开 (${this.wsClients.size})`);
|
||||
}
|
||||
|
||||
updateActivity () {
|
||||
this.lastActivityTime = Date.now();
|
||||
}
|
||||
|
||||
startInactivityCheck () {
|
||||
this.inactivityTimer = setInterval(() => {
|
||||
const inactive = Date.now() - this.lastActivityTime;
|
||||
// 如果没有 WebSocket 连接且超时,则自动清理
|
||||
if (inactive > this.INACTIVITY_TIMEOUT && this.wsClients.size === 0) {
|
||||
console.log(`[Debug] Adapter ${this.name} 不活跃,自动关闭`);
|
||||
this.cleanup();
|
||||
}
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
cleanup () {
|
||||
if (this.inactivityTimer) {
|
||||
clearInterval(this.inactivityTimer);
|
||||
this.inactivityTimer = null;
|
||||
}
|
||||
|
||||
// 关闭所有 WebSocket 连接
|
||||
this.wsClients.forEach((client) => {
|
||||
try {
|
||||
client.close();
|
||||
} catch (error) {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
this.wsClients.clear();
|
||||
|
||||
// 从 OneBot NetworkManager 移除
|
||||
const oneBotContext = WebUiDataRuntime.getOneBotContext();
|
||||
if (oneBotContext) {
|
||||
oneBotContext.networkManager.adapters.delete(this.name);
|
||||
}
|
||||
|
||||
// 从管理器中移除
|
||||
debugAdapterManager.removeAdapter(this.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Token
|
||||
*/
|
||||
validateToken (inputToken: string): boolean {
|
||||
return this.token === inputToken;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调试适配器管理器(单例管理)
|
||||
*/
|
||||
class DebugAdapterManager {
|
||||
private currentAdapter: DebugAdapter | null = null;
|
||||
|
||||
getOrCreateAdapter (): DebugAdapter {
|
||||
// 如果已存在且活跃,直接返回
|
||||
if (this.currentAdapter) {
|
||||
this.currentAdapter.updateActivity();
|
||||
return this.currentAdapter;
|
||||
}
|
||||
|
||||
// 创建新实例
|
||||
const adapter = new DebugAdapter('primary');
|
||||
this.currentAdapter = adapter;
|
||||
|
||||
// 注册到 OneBot NetworkManager
|
||||
const oneBotContext = WebUiDataRuntime.getOneBotContext();
|
||||
if (oneBotContext) {
|
||||
oneBotContext.networkManager.adapters.set(adapter.name, adapter as any);
|
||||
console.log(`[Debug] 已注册调试适配器: ${adapter.name}, NetworkManager中适配器数量: ${oneBotContext.networkManager.adapters.size}`);
|
||||
} else {
|
||||
console.warn('[Debug] OneBot 未初始化,无法注册适配器');
|
||||
}
|
||||
|
||||
return adapter;
|
||||
}
|
||||
|
||||
getAdapter (name: string): DebugAdapter | undefined {
|
||||
if (this.currentAdapter && this.currentAdapter.name === name) {
|
||||
return this.currentAdapter;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
removeAdapter (name: string) {
|
||||
if (this.currentAdapter && this.currentAdapter.name === name) {
|
||||
this.currentAdapter = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const debugAdapterManager = new DebugAdapterManager();
|
||||
|
||||
/**
|
||||
* 获取或创建调试会话
|
||||
*/
|
||||
router.post('/create', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const adapter = debugAdapterManager.getOrCreateAdapter();
|
||||
sendSuccess(res, {
|
||||
adapterName: adapter.name,
|
||||
token: adapter.token,
|
||||
message: '调试适配器已就绪',
|
||||
});
|
||||
} catch (error: any) {
|
||||
sendError(res, error.message);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* HTTP 调用 OneBot API (支持默认 adapter)
|
||||
*/
|
||||
const handleCallApi = async (req: Request, res: Response) => {
|
||||
try {
|
||||
let adapterName = req.params['adapterName'] || req.body.adapterName || DEFAULT_ADAPTER_NAME;
|
||||
|
||||
let adapter = debugAdapterManager.getAdapter(adapterName);
|
||||
|
||||
// 如果是默认 adapter 且不存在,尝试创建
|
||||
if (!adapter && adapterName === DEFAULT_ADAPTER_NAME) {
|
||||
adapter = debugAdapterManager.getOrCreateAdapter();
|
||||
}
|
||||
|
||||
if (!adapter) {
|
||||
return sendError(res, '调试适配器不存在');
|
||||
}
|
||||
|
||||
const { action, params } = req.body;
|
||||
const result = await adapter.callApi(action, params || {});
|
||||
sendSuccess(res, result);
|
||||
} catch (error: any) {
|
||||
sendError(res, error.message);
|
||||
}
|
||||
};
|
||||
|
||||
router.post('/call/:adapterName', handleCallApi);
|
||||
router.post('/call', handleCallApi);
|
||||
|
||||
/**
|
||||
* 关闭调试适配器
|
||||
*/
|
||||
router.post('/close/:adapterName', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { adapterName } = req.params;
|
||||
if (!adapterName) {
|
||||
return sendError(res, '缺少 adapterName 参数');
|
||||
}
|
||||
debugAdapterManager.removeAdapter(adapterName);
|
||||
sendSuccess(res, { message: '调试适配器已关闭' });
|
||||
} catch (error: any) {
|
||||
sendError(res, error.message);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* WebSocket 连接处理
|
||||
* 路径: /api/Debug/ws?adapterName=xxx&token=xxx
|
||||
*/
|
||||
export function handleDebugWebSocket (request: IncomingMessage, socket: any, head: any) {
|
||||
const url = new URL(request.url || '', `http://${request.headers.host}`);
|
||||
let adapterName = url.searchParams.get('adapterName');
|
||||
const token = url.searchParams.get('token') || url.searchParams.get('access_token');
|
||||
|
||||
// 默认 adapterName
|
||||
if (!adapterName) {
|
||||
adapterName = DEFAULT_ADAPTER_NAME;
|
||||
}
|
||||
|
||||
// Debug session should provide token
|
||||
if (!token) {
|
||||
console.log('[Debug] WebSocket 连接被拒绝: 缺少 Token');
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
let adapter = debugAdapterManager.getAdapter(adapterName);
|
||||
|
||||
// 如果是默认 adapter 且不存在,尝试创建
|
||||
if (!adapter && adapterName === DEFAULT_ADAPTER_NAME) {
|
||||
adapter = debugAdapterManager.getOrCreateAdapter();
|
||||
}
|
||||
|
||||
if (!adapter) {
|
||||
console.log('[Debug] WebSocket 连接被拒绝: 适配器不存在');
|
||||
socket.write('HTTP/1.1 404 Not Found\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!adapter.validateToken(token)) {
|
||||
console.log('[Debug] WebSocket 连接被拒绝: Token 无效');
|
||||
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建 WebSocket 服务器
|
||||
const wsServer = new WebSocketServer({ noServer: true });
|
||||
|
||||
wsServer.handleUpgrade(request, socket, head, (ws) => {
|
||||
adapter.addWsClient(ws);
|
||||
|
||||
ws.on('message', async (data) => {
|
||||
try {
|
||||
await adapter.handleWsMessage(ws, data as any);
|
||||
} catch (error: any) {
|
||||
console.error('[Debug] handleWsMessage error', error);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
adapter.removeWsClient(ws);
|
||||
});
|
||||
|
||||
ws.on('error', () => {
|
||||
adapter.removeWsClient(ws);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default router;
|
||||
@ -15,6 +15,7 @@ const LoginRuntime: LoginRuntimeType = {
|
||||
nick: '',
|
||||
},
|
||||
QQVersion: 'unknown',
|
||||
OneBotContext: null,
|
||||
onQQLoginStatusChange: async (status: boolean) => {
|
||||
LoginRuntime.QQLoginStatus = status;
|
||||
},
|
||||
@ -154,4 +155,12 @@ export const WebUiDataRuntime = {
|
||||
runWebUiConfigQuickFunction: async function () {
|
||||
await LoginRuntime.WebUiConfigQuickFunction();
|
||||
},
|
||||
|
||||
setOneBotContext (context: any): void {
|
||||
LoginRuntime.OneBotContext = context;
|
||||
},
|
||||
|
||||
getOneBotContext (): any | null {
|
||||
return LoginRuntime.OneBotContext;
|
||||
},
|
||||
};
|
||||
|
||||
@ -15,6 +15,7 @@ import { BaseRouter } from '@/napcat-webui-backend/src/router/Base';
|
||||
import { FileRouter } from './File';
|
||||
import { WebUIConfigRouter } from './WebUIConfig';
|
||||
import { UpdateNapCatRouter } from './UpdateNapCat';
|
||||
import DebugRouter from '@/napcat-webui-backend/src/api/Debug';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@ -41,5 +42,7 @@ router.use('/File', FileRouter);
|
||||
router.use('/WebUIConfig', WebUIConfigRouter);
|
||||
// router:更新NapCat相关路由
|
||||
router.use('/UpdateNapCat', UpdateNapCatRouter);
|
||||
// router:调试相关路由
|
||||
router.use('/Debug', DebugRouter);
|
||||
|
||||
export { router as ALLRouter };
|
||||
|
||||
@ -47,6 +47,7 @@ export interface LoginRuntimeType {
|
||||
onQQLoginStatusChange: (status: boolean) => Promise<void>;
|
||||
onWebUiTokenChange: (token: string) => Promise<void>;
|
||||
WebUiConfigQuickFunction: () => Promise<void>;
|
||||
OneBotContext: any | null; // OneBot 上下文,用于调试功能
|
||||
NapCatHelper: {
|
||||
onQuickLoginRequested: (uin: string) => Promise<{ result: boolean; message: string; }>;
|
||||
onOB11ConfigChanged: (ob11: OneBotConfig) => Promise<void>;
|
||||
|
||||
@ -6,7 +6,7 @@ import { Tab, Tabs } from '@heroui/tabs';
|
||||
import { Chip } from '@heroui/chip';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import clsx from 'clsx';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IoChevronDown, IoSend, IoSettingsSharp, IoCopy } from 'react-icons/io5';
|
||||
import { TbCode, TbMessageCode } from 'react-icons/tb';
|
||||
@ -19,7 +19,7 @@ import CodeEditor from '@/components/code_editor';
|
||||
import PageLoading from '@/components/page_loading';
|
||||
|
||||
import { request } from '@/utils/request';
|
||||
import { parseAxiosResponse } from '@/utils/url';
|
||||
|
||||
import { generateDefaultJson, parse } from '@/utils/zod';
|
||||
|
||||
import DisplayStruct from './display_struct';
|
||||
@ -27,10 +27,11 @@ import DisplayStruct from './display_struct';
|
||||
export interface OneBotApiDebugProps {
|
||||
path: OneBotHttpApiPath;
|
||||
data: OneBotHttpApiContent;
|
||||
adapterName?: string;
|
||||
}
|
||||
|
||||
const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
||||
const { path, data } = props;
|
||||
const { path, data, adapterName } = props;
|
||||
const currentURL = new URL(window.location.origin);
|
||||
currentURL.port = '3000';
|
||||
const defaultHttpUrl = currentURL.href;
|
||||
@ -38,12 +39,15 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
||||
url: defaultHttpUrl,
|
||||
token: '',
|
||||
});
|
||||
|
||||
const [requestBody, setRequestBody] = useState('{}');
|
||||
const [responseContent, setResponseContent] = useState('');
|
||||
const [isFetching, setIsFetching] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<any>('request');
|
||||
const [responseExpanded, setResponseExpanded] = useState(true);
|
||||
const [responseStatus, setResponseStatus] = useState<{ code: number; text: string; } | null>(null);
|
||||
const [responseHeight, setResponseHeight] = useLocalStorage('napcat_debug_response_height', 240); // 默认高度
|
||||
|
||||
const parsedRequest = parse(data.request);
|
||||
const parsedResponse = parse(data.response);
|
||||
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
|
||||
@ -54,8 +58,42 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
||||
setIsFetching(true);
|
||||
setResponseStatus(null);
|
||||
const r = toast.loading('正在发送请求...');
|
||||
|
||||
try {
|
||||
const parsedRequestBody = JSON.parse(requestBody);
|
||||
|
||||
// 如果有 adapterName,走后端转发
|
||||
if (adapterName) {
|
||||
request.post(`/api/Debug/call/${adapterName}`, {
|
||||
action: path.replace(/^\//, ''), // 去掉开头的 /
|
||||
params: parsedRequestBody
|
||||
}, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
}).then((res) => {
|
||||
if (res.data.code === 0) {
|
||||
setResponseContent(JSON.stringify(res.data.data, null, 2));
|
||||
setResponseStatus({ code: 200, text: 'OK' });
|
||||
} else {
|
||||
setResponseContent(JSON.stringify(res.data, null, 2));
|
||||
setResponseStatus({ code: 500, text: res.data.message });
|
||||
}
|
||||
setResponseExpanded(true);
|
||||
toast.success('请求成功');
|
||||
}).catch((err) => {
|
||||
toast.error('请求失败:' + err.message);
|
||||
setResponseContent(JSON.stringify({ error: err.message }, null, 2));
|
||||
setResponseStatus({ code: 500, text: 'Error' });
|
||||
setResponseExpanded(true);
|
||||
}).finally(() => {
|
||||
setIsFetching(false);
|
||||
toast.dismiss(r);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 回退到旧逻辑 (直接请求)
|
||||
const requestURL = new URL(httpConfig.url);
|
||||
requestURL.pathname = path;
|
||||
request
|
||||
@ -63,17 +101,16 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
||||
headers: {
|
||||
Authorization: `Bearer ${httpConfig.token}`,
|
||||
},
|
||||
responseType: 'text',
|
||||
})
|
||||
}) // 移除 responseType: 'text',以便 axios 自动解析 JSON
|
||||
.then((res) => {
|
||||
setResponseContent(parseAxiosResponse(res));
|
||||
setResponseContent(JSON.stringify(res.data, null, 2));
|
||||
setResponseStatus({ code: res.status, text: res.statusText });
|
||||
setResponseExpanded(true);
|
||||
toast.success('请求成功');
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error('请求失败:' + err.message);
|
||||
setResponseContent(parseAxiosResponse(err.response));
|
||||
setResponseContent(JSON.stringify(err.response?.data || { error: err.message }, null, 2));
|
||||
if (err.response) {
|
||||
setResponseStatus({ code: err.response.status, text: err.response.statusText });
|
||||
}
|
||||
@ -96,6 +133,50 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
||||
setResponseStatus(null);
|
||||
}, [path]);
|
||||
|
||||
// Height Resizing Logic
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const startY = e.clientY;
|
||||
const startHeight = responseHeight;
|
||||
|
||||
const handleMouseMove = (mv: MouseEvent) => {
|
||||
const delta = startY - mv.clientY;
|
||||
// 向上拖动 -> 增加高度
|
||||
setResponseHeight(Math.max(100, Math.min(window.innerHeight - 200, startHeight + delta)));
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
}, [responseHeight, setResponseHeight]);
|
||||
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
// 阻止默认滚动行为可能需要谨慎,这里尽量只阻止 handle 上的
|
||||
// e.preventDefault();
|
||||
const touch = e.touches[0];
|
||||
const startY = touch.clientY;
|
||||
const startHeight = responseHeight;
|
||||
|
||||
const handleTouchMove = (mv: TouchEvent) => {
|
||||
const mvTouch = mv.touches[0];
|
||||
const delta = startY - mvTouch.clientY;
|
||||
setResponseHeight(Math.max(100, Math.min(window.innerHeight - 200, startHeight + delta)));
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
document.removeEventListener('touchmove', handleTouchMove);
|
||||
document.removeEventListener('touchend', handleTouchEnd);
|
||||
};
|
||||
|
||||
document.addEventListener('touchmove', handleTouchMove);
|
||||
document.addEventListener('touchend', handleTouchEnd);
|
||||
}, [responseHeight, setResponseHeight]);
|
||||
|
||||
|
||||
return (
|
||||
<section className='h-full flex flex-col overflow-hidden bg-transparent'>
|
||||
{/* URL Bar */}
|
||||
@ -231,14 +312,27 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
||||
<div className='flex-shrink-0 px-3 pb-3'>
|
||||
<div
|
||||
className={clsx(
|
||||
'rounded-xl transition-all overflow-hidden border border-white/5',
|
||||
'rounded-xl transition-all overflow-hidden border border-white/5 flex flex-col',
|
||||
hasBackground ? 'bg-white/5' : 'bg-white/5 dark:bg-black/5'
|
||||
)}
|
||||
>
|
||||
{/* Header & Resize Handle */}
|
||||
<div
|
||||
className='flex items-center justify-between px-4 py-2 cursor-pointer hover:bg-white/5 transition-all select-none'
|
||||
className='flex items-center justify-between px-4 py-2 cursor-pointer hover:bg-white/5 transition-all select-none relative group'
|
||||
onClick={() => setResponseExpanded(!responseExpanded)}
|
||||
>
|
||||
{/* Invisble Resize Area that becomes visible/active */}
|
||||
{responseExpanded && (
|
||||
<div
|
||||
className="absolute -top-1 left-0 w-full h-3 cursor-ns-resize z-50 flex items-center justify-center opacity-0 hover:opacity-100 group-hover:opacity-100 transition-opacity"
|
||||
onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e); }}
|
||||
onTouchStart={(e) => { e.stopPropagation(); handleTouchStart(e); }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="w-12 h-1 bg-white/20 rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='flex items-center gap-2'>
|
||||
<IoChevronDown className={clsx('text-[10px] transition-transform duration-300 opacity-20', !responseExpanded && '-rotate-90')} />
|
||||
<span className='text-[10px] font-semibold tracking-wide opacity-30 uppercase'>Response</span>
|
||||
@ -254,15 +348,27 @@ const OneBotApiDebug: React.FC<OneBotApiDebugProps> = (props) => {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Response Content - Code Editor */}
|
||||
{responseExpanded && (
|
||||
<div className='h-36 overflow-auto relative font-mono text-[11px] px-4 pb-3 no-scrollbar transition-all'>
|
||||
<div style={{ height: responseHeight }} className="relative bg-black/5 dark:bg-black/20">
|
||||
<PageLoading loading={isFetching} />
|
||||
<div className={clsx(
|
||||
'whitespace-pre-wrap break-all leading-relaxed opacity-40 transition-opacity',
|
||||
hasBackground ? 'text-white' : 'text-default-600'
|
||||
)}>
|
||||
{responseContent || '...'}
|
||||
</div>
|
||||
<CodeEditor
|
||||
value={responseContent || '// Waiting for response...'}
|
||||
language='json'
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 11,
|
||||
lineNumbers: 'off',
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'on',
|
||||
readOnly: true,
|
||||
folding: true,
|
||||
padding: { top: 8, bottom: 8 },
|
||||
renderLineHighlight: 'none',
|
||||
automaticLayout: true
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -19,6 +19,8 @@ export default function HttpDebug () {
|
||||
const [backgroundImage] = useLocalStorage<string>(key.backgroundImage, '');
|
||||
const hasBackground = !!backgroundImage;
|
||||
|
||||
const [adapterName, setAdapterName] = useState<string>('');
|
||||
|
||||
// Auto-collapse sidebar on mobile initial load
|
||||
useEffect(() => {
|
||||
if (window.innerWidth < 768) {
|
||||
@ -26,6 +28,37 @@ export default function HttpDebug () {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initialize Debug Adapter
|
||||
useEffect(() => {
|
||||
let currentAdapterName = '';
|
||||
|
||||
const initAdapter = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/Debug/create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.code === 0) {
|
||||
currentAdapterName = data.data.adapterName;
|
||||
setAdapterName(currentAdapterName);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create debug adapter:', error);
|
||||
}
|
||||
};
|
||||
|
||||
initAdapter();
|
||||
|
||||
return () => {
|
||||
// 不再主动关闭 adapter,由后端自动管理活跃状态
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
const handleSelectApi = (api: OneBotHttpApiPath) => {
|
||||
if (!openApis.includes(api)) {
|
||||
setOpenApis([...openApis, api]);
|
||||
@ -149,7 +182,11 @@ export default function HttpDebug () {
|
||||
api === activeApi ? 'opacity-100 z-10' : 'opacity-0 z-0 pointer-events-none'
|
||||
)}
|
||||
>
|
||||
<OneBotApiDebug path={api} data={oneBotHttpApi[api]} />
|
||||
<OneBotApiDebug
|
||||
path={api}
|
||||
data={oneBotHttpApi[api]}
|
||||
adapterName={adapterName}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -3,9 +3,9 @@ import { Card, CardBody } from '@heroui/card';
|
||||
import { Input } from '@heroui/input';
|
||||
import { useLocalStorage } from '@uidotdev/usehooks';
|
||||
import clsx from 'clsx';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { IoFlash, IoFlashOff } from 'react-icons/io5';
|
||||
import { IoFlash, IoFlashOff, IoRefresh } from 'react-icons/io5';
|
||||
|
||||
import key from '@/const/key';
|
||||
|
||||
@ -33,13 +33,68 @@ export default function WSDebug () {
|
||||
const { sendMessage, readyState, FilterMessagesType, filteredMessages, clearMessages } =
|
||||
useWebSocketDebug(socketConfig.url, socketConfig.token, shouldConnect);
|
||||
|
||||
// Auto fetch adapter and set URL
|
||||
useEffect(() => {
|
||||
// 检查是否应该覆盖 URL
|
||||
const isDefaultUrl = socketConfig.url === defaultWsUrl || socketConfig.url === '';
|
||||
const isWebDebugUrl = socketConfig.url && socketConfig.url.includes('/api/Debug/ws');
|
||||
|
||||
if (!isDefaultUrl && !isWebDebugUrl) {
|
||||
setInputUrl(socketConfig.url);
|
||||
setInputToken(socketConfig.token);
|
||||
return; // 已经有自定义/有效的配置,跳过自动创建
|
||||
}
|
||||
|
||||
const initAdapter = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/Debug/create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||
}
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.code === 0) {
|
||||
//const adapterName = data.data.adapterName;
|
||||
const token = data.data.token;
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
|
||||
if (token) {
|
||||
// URL 中不再包含 Token,Token 单独放入输入框
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/Debug/ws`;
|
||||
|
||||
setSocketConfig({
|
||||
url: wsUrl,
|
||||
token: token
|
||||
});
|
||||
setInputUrl(wsUrl);
|
||||
setInputToken(token);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create debug adapter:', error);
|
||||
}
|
||||
};
|
||||
|
||||
initAdapter();
|
||||
}, []);
|
||||
|
||||
const handleConnect = useCallback(() => {
|
||||
if (!inputUrl.startsWith('ws://') && !inputUrl.startsWith('wss://')) {
|
||||
// 允许以 / 开头的相对路径(如代理情况),以及标准的 ws/wss
|
||||
let finalUrl = inputUrl;
|
||||
if (finalUrl.startsWith('/')) {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
finalUrl = `${protocol}//${window.location.host}${finalUrl}`;
|
||||
}
|
||||
|
||||
if (!finalUrl.startsWith('ws://') && !finalUrl.startsWith('wss://')) {
|
||||
toast.error('WebSocket URL 不合法');
|
||||
return;
|
||||
}
|
||||
|
||||
setSocketConfig({
|
||||
url: inputUrl,
|
||||
url: finalUrl,
|
||||
token: inputToken,
|
||||
});
|
||||
setShouldConnect(true);
|
||||
@ -49,6 +104,12 @@ export default function WSDebug () {
|
||||
setShouldConnect(false);
|
||||
}, []);
|
||||
|
||||
const handleResetConfig = useCallback(() => {
|
||||
setSocketConfig({ url: '', token: '' });
|
||||
// 刷新页面以重新触发初始逻辑
|
||||
window.location.reload();
|
||||
}, [setSocketConfig]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<title>Websocket调试 - NapCat WebUI</title>
|
||||
@ -101,16 +162,29 @@ export default function WSDebug () {
|
||||
input: hasBackground ? 'text-white placeholder:text-white/50' : '',
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
onPress={shouldConnect ? handleDisconnect : handleConnect}
|
||||
size='md'
|
||||
radius='full'
|
||||
color={shouldConnect ? 'danger' : 'primary'}
|
||||
className='font-bold shadow-lg min-w-[100px]'
|
||||
startContent={shouldConnect ? <IoFlashOff /> : <IoFlash />}
|
||||
>
|
||||
{shouldConnect ? '断开' : '连接'}
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
isIconOnly
|
||||
size="md"
|
||||
radius="full"
|
||||
color="warning"
|
||||
variant="flat"
|
||||
onPress={handleResetConfig}
|
||||
title="重置配置"
|
||||
>
|
||||
<IoRefresh className="text-xl" />
|
||||
</Button>
|
||||
<Button
|
||||
onPress={shouldConnect ? handleDisconnect : handleConnect}
|
||||
size='md'
|
||||
radius='full'
|
||||
color={shouldConnect ? 'danger' : 'primary'}
|
||||
className='font-bold shadow-lg min-w-[100px] flex-1'
|
||||
startContent={shouldConnect ? <IoFlashOff /> : <IoFlash />}
|
||||
>
|
||||
{shouldConnect ? '断开' : '连接'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Bar */}
|
||||
|
||||
@ -34,6 +34,11 @@ export default defineConfig(({ mode }) => {
|
||||
ws: true,
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/api/Debug/ws': {
|
||||
target: backendDebugUrl,
|
||||
ws: true,
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/api': backendDebugUrl,
|
||||
'/files': backendDebugUrl,
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user