diff --git a/packages/napcat-webui-frontend/src/components/onebot/api/debug.tsx b/packages/napcat-webui-frontend/src/components/onebot/api/debug.tsx index 19ecbed9..8e654d07 100644 --- a/packages/napcat-webui-frontend/src/components/onebot/api/debug.tsx +++ b/packages/napcat-webui-frontend/src/components/onebot/api/debug.tsx @@ -7,7 +7,7 @@ import { Tab, Tabs } from '@heroui/tabs'; import { Chip } from '@heroui/chip'; import { useLocalStorage } from '@uidotdev/usehooks'; import clsx from 'clsx'; -import { forwardRef, useEffect, useImperativeHandle, useState, useCallback } from 'react'; +import { forwardRef, useEffect, useImperativeHandle, useState, useCallback, useMemo } from 'react'; import toast from 'react-hot-toast'; import { IoChevronDown, IoSend, IoSettingsSharp, IoCopy } from 'react-icons/io5'; import { TbCode, TbMessageCode } from 'react-icons/tb'; @@ -59,16 +59,30 @@ const OneBotApiDebug = forwardRef((props const [responseHeight, setResponseHeight] = useState(240); const [storedHeight, setStoredHeight] = useLocalStorage('napcat_debug_response_height', 240); - const parsedRequest = parseTypeBox(data?.payload); + // 使用 useMemo 缓存解析结果,避免每次渲染都重新解析 + const parsedRequest = useMemo(() => { + try { + return parseTypeBox(data?.payload); + } catch (e) { + console.error('Error parsing request schema:', e); + return []; + } + }, [data?.payload]); // 将返回值的 data 结构包装进 BaseResponseSchema 进行展示 // 使用解构属性的方式重新构建对象,确保 parseTypeBox 能够识别为 object 类型 - const wrappedResponseSchema = Type.Object({ - ...BaseResponseSchema.properties, - data: data?.response || Type.Any({ description: '数据' }) - }); - - const parsedResponse = parseTypeBox(wrappedResponseSchema); + const parsedResponse = useMemo(() => { + try { + const wrappedResponseSchema = Type.Object({ + ...BaseResponseSchema.properties, + data: data?.response || Type.Any({ description: '数据' }) + }); + return parseTypeBox(wrappedResponseSchema); + } catch (e) { + console.error('Error parsing response schema:', e); + return []; + } + }, [data?.response]); const [backgroundImage] = useLocalStorage(key.backgroundImage, ''); const hasBackground = !!backgroundImage; @@ -166,7 +180,12 @@ const OneBotApiDebug = forwardRef((props if (data?.payloadExample) { setRequestBody(JSON.stringify(data.payloadExample, null, 2)); } else { - setRequestBody(JSON.stringify(generateDefaultFromTypeBox(data?.payload), null, 2)); + try { + setRequestBody(JSON.stringify(generateDefaultFromTypeBox(data?.payload), null, 2)); + } catch (e) { + console.error('Error generating default:', e); + setRequestBody('{}'); + } } setResponseContent(''); setResponseStatus(null); @@ -320,7 +339,14 @@ const OneBotApiDebug = forwardRef((props )} - diff --git a/packages/napcat-webui-frontend/src/components/onebot/api/display_struct.tsx b/packages/napcat-webui-frontend/src/components/onebot/api/display_struct.tsx index b1acda21..96bb10b1 100644 --- a/packages/napcat-webui-frontend/src/components/onebot/api/display_struct.tsx +++ b/packages/napcat-webui-frontend/src/components/onebot/api/display_struct.tsx @@ -127,6 +127,20 @@ const SchemaContainer: React.FC<{ }; const RenderSchema: React.FC<{ schema: ParsedSchema; }> = ({ schema }) => { + // 处理循环引用和截断的情况,直接显示提示而不继续递归 + if (schema.isCircularRef || schema.isTruncated) { + return ( +
+ {schema.name && ( + {schema.name} + )} + + {schema.description || '...'} + +
+ ); + } + if (schema.type === 'object') { return ( diff --git a/packages/napcat-webui-frontend/src/pages/dashboard/debug/http/index.tsx b/packages/napcat-webui-frontend/src/pages/dashboard/debug/http/index.tsx index 589deeaa..a59c7229 100644 --- a/packages/napcat-webui-frontend/src/pages/dashboard/debug/http/index.tsx +++ b/packages/napcat-webui-frontend/src/pages/dashboard/debug/http/index.tsx @@ -112,9 +112,17 @@ export default function HttpDebug () { const executeCommand = (commandId: string, mode: CommandPaletteExecuteMode) => { const api = commandId as OneBotHttpApiPath; const item = oneBotHttpApi[api]; - const body = item?.payloadExample - ? JSON.stringify(item.payloadExample, null, 2) - : (item?.payload ? JSON.stringify(generateDefaultFromTypeBox(item.payload), null, 2) : '{}'); + let body = '{}'; + if (item?.payloadExample) { + body = JSON.stringify(item.payloadExample, null, 2); + } else if (item?.payload) { + try { + body = JSON.stringify(generateDefaultFromTypeBox(item.payload), null, 2); + } catch (e) { + console.error('Error generating default:', e); + body = '{}'; + } + } handleSelectApi(api); // 确保请求参数可见 diff --git a/packages/napcat-webui-frontend/src/utils/typebox.ts b/packages/napcat-webui-frontend/src/utils/typebox.ts index 92d1ae55..ca037147 100644 --- a/packages/napcat-webui-frontend/src/utils/typebox.ts +++ b/packages/napcat-webui-frontend/src/utils/typebox.ts @@ -9,6 +9,7 @@ export type ParsedSchema = { children?: ParsedSchema[]; description?: string; isCircularRef?: boolean; // 标记循环引用 + isTruncated?: boolean; // 标记被截断 }; // 定义基础响应结构 (TypeBox 格式) @@ -21,95 +22,77 @@ export const BaseResponseSchema = Type.Object({ echo: Type.Optional(Type.String({ description: '回显' })), }); -// 最大解析深度,防止过深的嵌套 -const MAX_PARSE_DEPTH = 6; - -/** - * 获取 schema 的唯一标识符用于循环引用检测 - * 优先使用 $id 字符串标识符 - */ -function getSchemaId (schema: TSchema): string | undefined { - // 优先使用 $id - if (schema.$id) { - return schema.$id; - } - return undefined; -} - -/** - * 收集 schema 中所有的 $id,用于预先检测可能的循环引用 - */ -function collectSchemaIds (schema: TSchema, ids: Set = new Set()): Set { - if (!schema) return ids; - if (schema.$id) { - ids.add(schema.$id); - } - if (schema.anyOf) { - (schema.anyOf as TSchema[]).forEach(s => collectSchemaIds(s, ids)); - } - if (schema.oneOf) { - (schema.oneOf as TSchema[]).forEach(s => collectSchemaIds(s, ids)); - } - if (schema.allOf) { - (schema.allOf as TSchema[]).forEach(s => collectSchemaIds(s, ids)); - } - if (schema.items) { - collectSchemaIds(schema.items as TSchema, ids); - } - if (schema.properties) { - Object.values(schema.properties).forEach(s => collectSchemaIds(s as TSchema, ids)); - } - return ids; -} +// 最大解析深度 +const MAX_PARSE_DEPTH = 4; +// 最大生成深度 +const MAX_GENERATE_DEPTH = 3; +// anyOf/oneOf 最大解析选项数量 +const MAX_UNION_OPTIONS = 5; export function parseTypeBox ( schema: TSchema | undefined, name?: string, isRoot = true, - visited: Set = new Set(), + visitedIds: Set = new Set(), depth = 0 ): ParsedSchema | ParsedSchema[] { + // 基础检查 if (!schema) { return isRoot ? [] : { name, type: 'unknown', optional: false }; } - // 检查深度限制 + // 深度限制检查 if (depth > MAX_PARSE_DEPTH) { - return { name, type: 'object', optional: false, description: '(嵌套层级过深)', isCircularRef: true }; + return { name, type: 'object', optional: false, description: '...', isCircularRef: true }; } - // 检查循环引用 - const schemaId = getSchemaId(schema); - if (visited.has(schemaId)) { - const refName = typeof schemaId === 'string' ? schemaId : (schema.description || 'object'); - return { name, type: 'object', optional: false, description: `(循环引用: ${refName})`, isCircularRef: true }; + // $id 循环引用检查 + const schemaId = schema.$id; + if (schemaId && visitedIds.has(schemaId)) { + return { name, type: 'object', optional: false, description: `(${schemaId})`, isCircularRef: true }; } - // 对于复合类型,加入访问集合 - const isComplexType = schema.type === 'object' || schema.type === 'array' || schema.anyOf || schema.oneOf || schema.allOf; - if (isComplexType) { - visited = new Set(visited); // 创建副本避免影响兄弟节点的解析 - visited.add(schemaId); + // 创建副本并添加当前 $id + const newVisitedIds = new Set(visitedIds); + if (schemaId) { + newVisitedIds.add(schemaId); } const description = schema.description; const optional = false; const type = schema.type; + // 常量值 if (schema.const !== undefined) { return { name, type: 'value', value: schema.const, optional, description }; } + // 枚举 if (schema.enum) { return { name, type: 'enum', enum: schema.enum, optional, description }; } + // 联合类型 (anyOf/oneOf) - 限制解析的选项数量 if (schema.anyOf || schema.oneOf) { - const options = (schema.anyOf || schema.oneOf) as TSchema[]; - const children = options.map(opt => parseTypeBox(opt, undefined, false, visited, depth + 1) as ParsedSchema); + const allOptions = (schema.anyOf || schema.oneOf) as TSchema[]; + // 只取前 MAX_UNION_OPTIONS 个选项 + const options = allOptions.slice(0, MAX_UNION_OPTIONS); + const children = options.map(opt => parseTypeBox(opt, undefined, false, newVisitedIds, depth + 1) as ParsedSchema); + + // 如果有更多选项被截断 + if (allOptions.length > MAX_UNION_OPTIONS) { + children.push({ + name: undefined, + type: 'object', + optional: false, + description: `... 还有 ${allOptions.length - MAX_UNION_OPTIONS} 个类型`, + isTruncated: true + }); + } return { name, type: 'union', children, optional, description }; } + // allOf 交叉类型 if (schema.allOf) { const parts = schema.allOf as TSchema[]; const allProperties: Record = {}; @@ -125,17 +108,19 @@ export function parseTypeBox ( }); if (canMerge) { - return parseTypeBox({ ...schema, type: 'object', properties: allProperties, required: allRequired }, name, isRoot, visited, depth); + return parseTypeBox({ ...schema, type: 'object', properties: allProperties, required: allRequired }, name, isRoot, newVisitedIds, depth); } - const children = parts.map(part => parseTypeBox(part, undefined, false, visited, depth + 1) as ParsedSchema); + const children = parts.slice(0, MAX_UNION_OPTIONS).map(part => parseTypeBox(part, undefined, false, newVisitedIds, depth + 1) as ParsedSchema); return { name, type: 'intersection', children, optional, description }; } + // 对象类型 if (type === 'object') { const properties = schema.properties || {}; const required = schema.required || []; - const children = Object.keys(properties).map(key => { - const child = parseTypeBox(properties[key], key, false, visited, depth + 1) as ParsedSchema; + const keys = Object.keys(properties); + const children = keys.map(key => { + const child = parseTypeBox(properties[key], key, false, newVisitedIds, depth + 1) as ParsedSchema; child.optional = !required.includes(key); return child; }); @@ -143,12 +128,17 @@ export function parseTypeBox ( return { name, type: 'object', children, optional, description }; } + // 数组类型 if (type === 'array') { const items = schema.items as TSchema; - const child = parseTypeBox(items, undefined, false, visited, depth + 1) as ParsedSchema; - return { name, type: 'array', children: [child], optional, description }; + if (items) { + const child = parseTypeBox(items, undefined, false, newVisitedIds, depth + 1) as ParsedSchema; + return { name, type: 'array', children: [child], optional, description }; + } + return { name, type: 'array', children: [], optional, description }; } + // 基础类型 if (type === 'string') return { name, type: 'string', optional, description }; if (type === 'number' || type === 'integer') return { name, type: 'number', optional, description }; if (type === 'boolean') return { name, type: 'boolean', optional, description }; @@ -157,67 +147,76 @@ export function parseTypeBox ( return { name, type: type || 'unknown', optional, description }; } -// 最大生成深度 -const MAX_GENERATE_DEPTH = 8; - export function generateDefaultFromTypeBox ( schema: TSchema | undefined, - visited: Set = new Set(), + visitedIds: Set = new Set(), depth = 0 ): any { + // 基础检查 if (!schema) return {}; - // 检查深度限制 + // 深度限制 if (depth > MAX_GENERATE_DEPTH) { return null; } - // 检查循环引用 - const schemaId = getSchemaId(schema); - if (visited.has(schemaId)) { - // 遇到循环引用,返回空值而不是继续递归 + // $id 循环引用检查 + const schemaId = schema.$id; + if (schemaId && visitedIds.has(schemaId)) { return schema.type === 'array' ? [] : schema.type === 'object' ? {} : null; } + // 创建副本并添加当前 $id + const newVisitedIds = new Set(visitedIds); + if (schemaId) { + newVisitedIds.add(schemaId); + } + + // 常量/默认值/枚举 if (schema.const !== undefined) return schema.const; if (schema.default !== undefined) return schema.default; if (schema.enum) return schema.enum[0]; + // 联合类型 - 优先选择简单类型 if (schema.anyOf || schema.oneOf) { const options = (schema.anyOf || schema.oneOf) as TSchema[]; - // 优先选择非递归的简单类型 - const simpleOption = options.find(opt => opt.type === 'string' || opt.type === 'number' || opt.type === 'boolean'); - if (simpleOption) { - return generateDefaultFromTypeBox(simpleOption, visited, depth + 1); + // 优先找简单类型 + const stringOption = options.find(opt => opt.type === 'string'); + if (stringOption) return ''; + const numberOption = options.find(opt => opt.type === 'number' || opt.type === 'integer'); + if (numberOption) return 0; + const boolOption = options.find(opt => opt.type === 'boolean'); + if (boolOption) return false; + // 否则只取第一个 + if (options.length > 0) { + return generateDefaultFromTypeBox(options[0], newVisitedIds, depth + 1); } - return generateDefaultFromTypeBox(options[0], visited, depth + 1); + return null; } const type = schema.type; + // 对象类型 if (type === 'object') { - // 对于复合类型,加入访问集合 - visited = new Set(visited); - visited.add(schemaId); - const obj: any = {}; const props = schema.properties || {}; const required = schema.required || []; + // 只为必填字段和浅层字段生成默认值 for (const key in props) { - // 只为必填字段生成默认值,减少嵌套深度 - if (required.includes(key) || depth < 3) { - obj[key] = generateDefaultFromTypeBox(props[key], visited, depth + 1); + if (required.includes(key) || depth < 1) { + obj[key] = generateDefaultFromTypeBox(props[key], newVisitedIds, depth + 1); } } return obj; } + // 数组类型 - 返回空数组 if (type === 'array') { - // 数组类型返回空数组,避免在数组项中继续递归 return []; } + // 基础类型 if (type === 'string') return ''; if (type === 'number' || type === 'integer') return 0; if (type === 'boolean') return false; diff --git a/packages/napcat-webui-frontend/src/utils/zod.ts b/packages/napcat-webui-frontend/src/utils/zod.ts index afe7c087..3acf7402 100644 --- a/packages/napcat-webui-frontend/src/utils/zod.ts +++ b/packages/napcat-webui-frontend/src/utils/zod.ts @@ -25,13 +25,15 @@ import { export type LiteralValue = string | number | boolean | null; export type ParsedSchema = { - name?: string - type: string | string[] - optional: boolean - value?: LiteralValue - enum?: LiteralValue[] - children?: ParsedSchema[] - description?: string + name?: string; + type: string | string[]; + optional: boolean; + value?: LiteralValue; + enum?: LiteralValue[]; + children?: ParsedSchema[]; + description?: string; + isCircularRef?: boolean; // 标记循环引用 + isTruncated?: boolean; // 标记被截断 }; export function parse (