import { Chip } from '@heroui/react' import { formatDistanceToNow, parse } from 'date-fns' import { zhCN } from 'date-fns/locale' import { AlertCircle, Braces, Clock, FileText, FileWarning, Fingerprint, Gauge, GitBranch, ListChecks, Puzzle, QrCode, Radio, Repeat, Route, ShieldAlert, Terminal, Timer } from 'lucide-react' import _ from 'node-karin/lodash' import React from 'react' import { MdSchedule } from 'react-icons/md' import { DefaultLayout } from '../../../components/DefaultLayout' import type { PosterProps } from '../../../types/ctx' import { generateQRCode } from '../../../../utils/QRcode' import { isDark } from '../../../../utils/theme' import { getRandomErrorTitle } from './errorTitles' import { highlightStack } from './stackHighlight' import type { AmagiErrorDetail, ApiErrorData } from './types' /** 业务错误类型:从总数据类型里取,不再单独导出。 */ type BusinessError = ApiErrorData['error'] /** 日志等级:从总数据类型里逐步取。 */ type LogLevel = NonNullable[number]['level'] /** * 取「最后一次 `node_modules/` 之后的那一段」——包名,供调用栈的归属判定使用。 * * 不能只看帧的路径里有没有 `node_modules`:发布后插件**自己**就装在 `node_modules` 里, * 帧形如 `…/node_modules/.pnpm/koishi-plugin-kkk@…/node_modules/koishi-plugin-kkk/lib/core_chunk/main.js:…`, * `node_modules` 出现两次 —— 只看文本特征会把每一帧都当成依赖压暗,整块图失去分层。 * 所以改问「这一帧和本模块是不是同一个包」:模块自身的位置就是包根的答案,两边各取包名比对即可。 * 源码树(开发态)里取不到包名,返回 `undefined`,此时凡进了 `node_modules` 的帧一律算外部帧。 * * 只做字符串切分,**不做 URL→路径转换**:模板在 ktr dev 的 iframe 沙箱里由浏览器求值, * `import.meta.url` 是 `http://localhost:5174/@fs/D:/…`,生产 bundle 里是 `file:///…`, * 折算成文件系统路径要同时处理协议、主机、`/@fs` 与前导斜杠四种差异,漏一样就会把所有帧 * 都判成外部帧、整块压暗;而这两种形态都原样带着 `node_modules` 这一段,直接切更稳。 * 也**不能**为图省事引入 `node:*` 内置模块:模板会被 vite 当浏览器代码打包,`node:url` * 这类会被 externalize 成空壳,模块求值期直接抛错,整个判定失效。 * @param input - 模块 URL 或帧里的路径原文 * @returns 包名,形如 `koishi-plugin-kkk`;不在 `node_modules` 里时返回 `undefined` */ const packageSegmentOf = (input: string): string | undefined => { const path = input.replace(/\\/g, '/') const marker = '/node_modules/' const at = path.lastIndexOf(marker) if (at < 0) return undefined return path.slice(at + marker.length).split('/')[0] } /** * 本模块所在的包名;开发态跑源码树时为 `undefined`。 * * Koishi 移植:上游用 `import.meta.url`(模板在 ktr dev 的浏览器沙箱里求值)。 * 我们的产物是 CJS,`import.meta` 会让 Node 把整个文件当 ESM(报 "exports is not defined"), * 因此改用 `__filename` 拼一个 file:// URL —— 这里只做字符串切分找 `node_modules`,语义一致。 */ const selfModuleUrl = typeof __filename === 'string' ? 'file:///' + __filename.replace(/\\/g, '/') : '' const selfPackageSegment = packageSegmentOf(selfModuleUrl) /** * 判断一个调用帧的位置是否属于本插件自己的代码。 * @param location - 帧里的位置原文,形如 `路径:行:列` 或 `node:internal/…` * @returns 是否属于本插件自己的代码 */ const isOwnFrameLocation = (location: string): boolean => { const path = location.replace(/\\/g, '/') // node 内部帧没有包名可言,直接算外部帧(与 stackHighlight 的兜底判定同款) if (/^node:|\(node:/.test(path)) return false // 没进 node_modules 的帧就是源码树里的自己(开发态) if (!path.includes('/node_modules/')) return true // 进了 node_modules:只有和本模块同属一个包才算自己的代码 return packageSegmentOf(path) === selfPackageSegment } /** * ANSI 颜色代码映射 */ const ansiColorMap: Record = { 30: 'text-foreground', 31: 'text-danger', 32: 'text-success', 33: 'text-warning', 34: 'text-accent', 35: 'text-accent', 36: 'text-cyan-600', 37: 'text-muted', 90: 'text-foreground/70', 91: 'text-danger', 92: 'text-success', 93: 'text-warning', 94: 'text-accent', 95: 'text-accent', 96: 'text-muted', 97: 'text-background/80' } const ansi256ToColor = (colorCode: number): string => { const standardColors = [ '#000000', '#800000', '#008000', '#808000', '#000080', '#800080', '#008080', '#c0c0c0', '#808080', '#ff0000', '#00ff00', '#ffff00', '#0000ff', '#ff00ff', '#00ffff', '#ffffff' ] if (colorCode < 16) return standardColors[colorCode] if (colorCode < 232) { const index = colorCode - 16 const r = Math.floor(index / 36), g = Math.floor((index % 36) / 6), b = index % 6 const toHex = (v: number) => (v === 0 ? 0 : 55 + v * 40).toString(16).padStart(2, '0') return `#${toHex(r)}${toHex(g)}${toHex(b)}` } const gray = (colorCode - 232) * 10 + 8 const hex = gray.toString(16).padStart(2, '0') return `#${hex}${hex}${hex}` } const convertAnsiToHtml = (text: string): string => { // 完全避免在正则字面量中出现任何转义 const ESC = String.fromCharCode(27) const ansiRegex = new RegExp(ESC + '\\[([0-9;]+)m', 'g') let result = '', lastIndex = 0 let currentStyles: { classes: string[]; inlineColor?: string } = { classes: [] } let match const escapeHtml = (str: string) => str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''') const formatLogContent = (content: string) => escapeHtml(content).replace( /([\u3400-\u9fff\uf900-\ufaff\u3000-\u303f\uff00-\uffef]+)/g, '$1' ) const makeSpan = (content: string) => { const hasClass = currentStyles.classes.length > 0, hasInline = currentStyles.inlineColor if (!hasClass && !hasInline) return formatLogContent(content) const classAttr = hasClass ? ` class="${currentStyles.classes.join(' ')}"` : '' const styleAttr = hasInline ? ` style="color: ${currentStyles.inlineColor}"` : '' return `${formatLogContent(content)}` } while ((match = ansiRegex.exec(text)) !== null) { if (match.index > lastIndex) result += makeSpan(text.substring(lastIndex, match.index)) const codes = match[1].split(';').map(Number) let i = 0 while (i < codes.length) { const code = codes[i] if (code === 90 && codes[i + 1] === 2) { currentStyles.classes = currentStyles.classes.filter((c) => !c.startsWith('text-')) currentStyles.inlineColor = undefined currentStyles.classes.push('text-muted') i++ } else if (code === 0 || code === 39 || code === 49) { currentStyles.classes = currentStyles.classes.filter( (c) => !c.startsWith('text-') && !c.startsWith('bg-') && !c.startsWith('dark:') ) currentStyles.inlineColor = undefined } else if (code === 1) { if (!currentStyles.classes.includes('font-bold')) currentStyles.classes.push('font-bold') } else if (code === 22) { currentStyles.classes = currentStyles.classes.filter((c) => c !== 'font-bold') } else if (code === 38 && codes[i + 1] === 5) { const colorCode = codes[i + 2] if (colorCode !== undefined) { currentStyles.classes = currentStyles.classes.filter((c) => !c.startsWith('text-') && !c.startsWith('dark:')) currentStyles.inlineColor = ansi256ToColor(colorCode) i += 2 } } else if (ansiColorMap[code]) { currentStyles.classes = currentStyles.classes.filter((c) => !c.startsWith('text-') && !c.startsWith('dark:')) currentStyles.inlineColor = undefined currentStyles.classes.push(ansiColorMap[code]) } i++ } lastIndex = ansiRegex.lastIndex } if (lastIndex < text.length) result += makeSpan(text.substring(lastIndex)) return result } /** 把请求 URL 拆成「主体」与「查询参数名」两截:签名 URL 动辄上千字符,全印没法看 */ const splitRequestUrl = (raw: string): { head: string; names: string[]; queryLength: number } => { try { const u = new URL(raw) const names = [...u.searchParams.keys()] return { head: `${u.origin}${u.pathname}`, names, queryLength: u.search.length } } catch { return { head: raw, names: [], queryLength: 0 } } } /** trace 里 reason 的中文名 */ const TRACE_REASON_LABELS: Record = { initial: '首次', retry: '重试', page: '翻页', segment: '分段', prepare: '前置' } const getLogLevelTheme = (level: LogLevel, dark: boolean) => { const themeMap: Record< LogLevel, { bgClass: string borderClass: string textClass: string iconClass: string levelClass: string dotClass: string } > = { TRAC: { bgClass: dark ? 'bg-muted/10' : 'bg-muted/5', borderClass: 'border-muted/20', textClass: 'text-muted', iconClass: 'text-muted', levelClass: dark ? 'text-muted/10' : 'text-muted/10', dotClass: 'bg-muted/40' }, DEBU: { bgClass: dark ? 'bg-cyan-400/10' : 'bg-cyan-500/5', borderClass: dark ? 'border-cyan-400/20' : 'border-cyan-500/20', textClass: dark ? 'text-cyan-400' : 'text-cyan-600', iconClass: dark ? 'text-cyan-400' : 'text-cyan-600', levelClass: dark ? 'text-cyan-400/10' : 'text-cyan-600/10', dotClass: dark ? 'bg-cyan-400/40' : 'bg-cyan-500/40' }, MARK: { bgClass: dark ? 'bg-muted/10' : 'bg-muted/5', borderClass: 'border-muted/20', textClass: 'text-muted', iconClass: 'text-muted', levelClass: dark ? 'text-muted/10' : 'text-muted/10', dotClass: 'bg-muted/40' }, INFO: { bgClass: 'bg-success-soft', borderClass: 'border-success/25', textClass: 'text-success', iconClass: 'text-success', levelClass: dark ? 'text-success/10' : 'text-success/10', dotClass: 'bg-success/40' }, WARN: { bgClass: 'bg-warning-soft', borderClass: 'border-warning/25', textClass: 'text-warning', iconClass: 'text-warning', levelClass: dark ? 'text-warning/10' : 'text-warning-soft', dotClass: 'bg-warning/40' }, ERRO: { bgClass: 'bg-danger-soft', borderClass: 'border-danger/25', textClass: 'text-danger', iconClass: 'text-danger', levelClass: dark ? 'text-danger/10' : 'text-danger/10', dotClass: 'bg-danger/40' }, FATA: { bgClass: dark ? 'bg-pink-400/10' : 'bg-pink-500/5', borderClass: dark ? 'border-pink-400/25' : 'border-pink-500/25', textClass: dark ? 'text-pink-400' : 'text-pink-500', iconClass: dark ? 'text-pink-400' : 'text-pink-500', levelClass: dark ? 'text-pink-400/10' : 'text-pink-500/10', dotClass: dark ? 'bg-pink-400/40' : 'bg-pink-500/40' } } return themeMap[level] || themeMap['TRAC'] } /** * 实现端关键字 → 图标。 * * ⚠️ **顺序有意义**:`getAdapterLogo` 命中第一个就返回,所以「具体的」必须排在「笼统的」前面。 * 尤其 `onebot` / `satori` 这两个兜底项**只能放最后** —— 它们会匹配到 * `NapCat.Onebot` / `Lagrange.OneBot` 这类实现端名字,放前面就把各家自己的图标顶掉了。 * * 资源文件在 `resources/image/other/handlerError/`(Milky 的图是 `Milky.png`,注意大小写)。 */ const ADAPTER_LOGO_MAP: Record = { napcat: '/image/other/handlerError/napcat.webp', lagrange: '/image/other/handlerError/lagrange.webp', chronocat: '/image/other/handlerError/chronocat.svg', llonebot: '/image/other/handlerError/llonebot.webp', lltwobot: '/image/other/handlerError/llonebot.webp', conwechat: '/image/other/handlerError/conwechat.webp', gocq: '/image/other/handlerError/gocq-http.webp', /** Milky:QQ NT 的实现端之一,和 NapCat 同级,不是 OneBot 系 */ milky: '/image/other/handlerError/Milky.png', // ↓↓ 兜底项:只在这上面都配不上时才用 onebot: '/image/other/handlerError/onebot.png', satori: '/image/other/handlerError/satori.png' } /** * 适配器图标。 * * 要拿**实现端名字**(`NapCat.Onebot` / `LLOneBot`)去配,不能拿平台名: * OneBot 系各家都自报 `onebot`,用平台名永远配不上,卡片上就只剩一个万能拼图。 * 识别不出实现端时退回平台名再试一次(QQ / 频道这类平台名本身就是标识), * 还是配不上才用拼图。 */ const getAdapterLogo = (adapterInfo: { name?: string; implementationName?: string }): React.ReactNode => { const candidates = [String(adapterInfo?.implementationName ?? ''), String(adapterInfo?.name ?? '')] for (const candidate of candidates) { const nameLower = candidate.toLowerCase() if (!nameLower) continue for (const [key, logoPath] of Object.entries(ADAPTER_LOGO_MAP)) { if (nameLower.includes(key)) return {candidate} } } return } /** * amagi 12 个错误大类的中文名与「该怎么办」。 * * 印 `kind` 本身对看图的人没用 —— 有用的是它指向哪一类处置:改配置、等一会儿、 * 还是提 issue。所以每一类都带一句处置提示。 */ /** 未知 kind 的兜底(表里找不到时用它,避免整张错误卡片渲染失败) */ const FALLBACK_KIND_META = { zh: '接口错误', hint: '接口没有返回预期数据,可能是登录凭证过期或被风控拦截。' } const ERROR_KIND_META: Record = { validation: { zh: '参数校验', hint: '调用参数不合法,通常是插件自身的问题' }, auth: { zh: '身份失效', hint: 'Cookie 缺失或已过期,重新扫码登录即可' }, rate_limit: { zh: '触发限流', hint: '请求太频繁,等一会儿再试' }, risk: { zh: '命中风控', hint: '平台要求人机验证,或需要换一个账号的 Cookie' }, not_found: { zh: '内容不存在', hint: '作品可能已删除、被设为私密或仅粉丝可见' }, forbidden: { zh: '无权访问', hint: '当前账号看不到这份内容(付费 / 地区 / 权限)' }, unavailable: { zh: '服务不可用', hint: '平台侧临时故障,稍后重试' }, network: { zh: '网络错误', hint: '检查代理配置与出口网络' }, timeout: { zh: '请求超时', hint: '调大 amagi.timeout,或检查代理链路' }, parse: { zh: '响应解析失败', hint: '平台改了协议,或返回的是反爬页面' }, internal: { zh: 'amagi 内部错误', hint: '解析库内部异常,建议反馈' }, unknown: { zh: '未分类错误', hint: '平台返回了未被识别的错误码' } } /** 诊断区的一格:小标签 + 大值,值缺失时整格不渲染 */ const DetailCell: React.FC<{ icon: React.ReactNode label: string value?: string | number color: string valueColor: string }> = ({ icon, label, value, color, valueColor }) => { if (value === undefined || value === null || value === '') return null return (
{icon} {label}
{value}
) } const SectionTitle: React.FC<{ icon: React.ReactNode; en: string; zh: string; color: string }> = ({ icon, en, zh, color }) => (
{icon}
{en} {zh}
) /** * API错误显示组件 - 手机端 Apple 风格 */ export const handlerError: React.FC> = (props) => { const { data } = props const dark = isDark(props.ctx) const isBusinessError = data.type === 'business_error' const businessError = isBusinessError ? (data.error as BusinessError) : null const displayMethod = businessError?.businessName || data.method // 高亮方式看文本本身有没有 ANSI,而不是看错误来源:amagi 与非 amagi 现在都只印 // 纯文本调用帧,走结构上色;只有调用方显式覆盖进来的 util.inspect 转储仍走 ANSI 那条。 // 归属判定必须由本包给出:插件发布后自己就装在 node_modules 里,只看路径特征会把 // 每一帧都当成依赖压暗(构建产物整块图失去分层)。 const stackText = String(businessError?.stack || data.error?.stack || '') const stackHtml = stackText.includes(String.fromCharCode(27)) ? convertAnsiToHtml(stackText) : highlightStack(stackText, dark, { isOwnFrame: isOwnFrameLocation }) // 631 配色 - 红/珊瑚色系 const bgColor = dark ? '#0f0a0a' : '#faf5f5' const primaryColor = dark ? '#f87171' : '#dc2626' const secondaryColor = dark ? '#fca5a5' : '#b91c1c' const mutedColor = dark ? 'rgba(248,113,113,0.7)' : '#991b1b' const accentColor = dark ? '#fecaca' : '#7f1d1d' return ( {/* 弥散光背景 - 深浅模式完全适配 */}
{/* 左上主光斑 */}
{/* 右侧光斑 */}
{/* 底部光斑 */}
{/* 单色噪点层 - 明显颗粒感 */}
{/* 背景大字装饰 */}
ERROR
{/* 四周装饰性图形点缀 */}
{/* 右上角:实心方块阵列 */}
{/* 左下角:对角线条纹 */}
{/* 右下角:同心圆弧 */}
{/* 内容层 */}
{/* 顶部状态栏 */}
{/* 优化后的左侧状态标签 */}
{/* 左侧装饰竖条 */}
{/* 主标签容器 */}
{/* 四角装饰钉 */}
{/* 状态指示器 */}
ERR.01
{/* 文字信息 */}
System Alert Runtime Exception
{/* 优化后的时间显示模块 */}
{/* 装饰线条组 */}
{/* 时间数字显示 */}
System Time
{new Date(data.timestamp).toLocaleTimeString('en-GB', { hour12: false })}
{/* 分割线 */}
{/* 日期显示 */}
Date
{new Date(data.timestamp) .toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }) .replaceAll('/', '-')}
{/* 主标题 */}

{getRandomErrorTitle()}

{displayMethod}

{/* amagi 错误诊断:只有失败来自解析库时才有这一块 */} {data.amagi && (
} en="Amagi Diagnosis" zh="解析库错误诊断" color={mutedColor} />
{/* 第一行:错误大类 + 是否可重试。这两个决定「现在该做什么」 */}
{(ERROR_KIND_META[data.amagi.kind] ?? FALLBACK_KIND_META).zh} {data.amagi.retryable ? '可以重试' : '重试无用'} {data.amagi.kind}

{(ERROR_KIND_META[data.amagi.kind] ?? FALLBACK_KIND_META).hint}

{/* 错误说明:与堆栈里那份 inspect 转储不同,这里是干净的一句话。 标签随 `raw` 在场与否切换 —— 响应体是纯文本时 amagi 提不出平台文案, 这一句必然是它自己的兜底句,继续印「平台原文」会让看图的人以为那是平台说的 */}
{data.amagi.raw ? '错误说明' : '平台原文'}

{data.amagi.reason || '(平台未给出说明)'}

{/* 响应体原文:只有反爬页 / 拦截文本那一类才有(amagi 判 ANTIBOT_PAGE 的情形)。 上面那句是兜底文案,平台究竟回了什么只在这里,所以单独一块 */} {data.amagi.raw && (
响应体原文
                    {data.amagi.raw}
                  
)} {/* 分层错误码与请求归因 */}
} label="AMAGI 码" value={data.amagi.code} color={mutedColor} valueColor={accentColor} /> } label="平台业务码" value={data.amagi.platformCode} color={mutedColor} valueColor={accentColor} /> } label="HTTP 状态" value={data.amagi.httpStatus} color={mutedColor} valueColor={accentColor} /> } label="请求 ID" value={data.amagi.requestId} color={mutedColor} valueColor={accentColor} /> } label="实际请求次数" value={data.amagi.attempts} color={mutedColor} valueColor={accentColor} /> } label="耗时" value={data.amagi.durationMs === undefined ? undefined : `${data.amagi.durationMs} ms`} color={mutedColor} valueColor={accentColor} />
{/* 参数校验的字段级错误,只有 kind === 'validation' 时有 */} {data.amagi.issues && data.amagi.issues.length > 0 && (
参数问题
    {data.amagi.issues.map((issue, index) => (
  • {issue.path || '(根)'} {issue.message}
  • ))}
)} {/* 逐个请求的明细。一次「翻 3 页 + 重试 1 次」在这里就是 4 行 */} {data.amagi.trace && data.amagi.trace.length > 0 && (
请求轨迹({data.amagi.trace.length} 次)
{data.amagi.trace.map((entry, index) => { const { head, names, queryLength } = splitRequestUrl(entry.url) const ok = entry.status !== undefined && entry.status >= 200 && entry.status < 300 return (
{TRACE_REASON_LABELS[entry.reason] ?? entry.reason} {entry.method} {entry.status ?? '未发出'} {entry.durationMs} ms {entry.retryOf && ( 重试于 {entry.retryOf} )}

{head}

{names.length > 0 && (

{names.length} 个查询参数 / {queryLength} 字符:{names.slice(0, 12).join(' ')} {names.length > 12 ? ` …+${names.length - 12}` : ''}

)}
) })}
)}
)} {/* 验证二维码 */} {data.isVerification && data.verificationUrl && (
人机验证
验证二维码

请在 120 秒内完成验证

  1. 1. 使用手机扫描二维码
  2. 2. 在网页中完成人机验证
  3. 3. 将验证结果发送至此对话
{data.verificationUrl && (

{data.verificationUrl}

)}
)} {/* 触发命令 */} {data.triggerCommand && (
} en="Trigger Command" zh="触发命令" color={mutedColor} />
            
)} {/* 错误堆栈 */}
} en="Stack Trace" zh={data.amagi ? '调用栈(结构化上下文见上)' : data.error?.dump ? '调用栈(对象转储见下)' : '错误堆栈'} color={mutedColor} />
          
{/* 对象转储:非 amagi 异常的自有属性,帧已经在上面单独高亮了 */} {data.error?.dump && (
} en="Object Dump" zh="对象转储" color={mutedColor} />
            
)} {/* 执行日志 */} {data.logs && data.logs.length > 0 && (
} en="Execution Logs" zh="执行日志" color={mutedColor} />
{data.logs.map((log, index) => { const theme = getLogLevelTheme(log.level, dark) return (
{/* 时间戳 */} {/* 左侧圆角装饰 */} {log.timestamp} {/* 右侧圆角装饰 */} {/* 日志等级 */}
{log.level}」
{/* 日志内容 */}
) })}
)} {/* 底部版本信息 */}
{/* 版本信息:框架版本 / 插件版本 / 适配器 —— 合并成一行;适配器带版本号(取不到就不占位) */}
Framework

Framework / 框架版本

{data.frameworkVersion}

Plugin / 插件版本

{data.pluginVersion}

{data.adapterInfo && (
{getAdapterLogo(data.adapterInfo)}

Adapter / 适配器

{data.adapterInfo.name}

{data.adapterInfo.version && (

v{data.adapterInfo.version}

)}
)}
{/* 次要信息 */}
{data.buildTime && (
Built Time: {data.buildTime} 于{' '} {formatDistanceToNow(parse(data.buildTime, 'yyyy年MM月dd日 HH:mm', new Date()), { locale: zhCN })}前
)} {data.commitHash && (
Commit Hash: {data.commitHash}
)}
{/* 帮助提示 */}

Need Help? / 需要帮助?

{data.report ? '错误信息已上传,进群发送下面的上报编号或这张图片即可。' : '提交问题时请附上完整报错截图、复现步骤和环境版本信息。'}

Support

GitHub Issue

https://github.com/maimai993/koishi-plugin-kkk/issues/new/choose

GitHub Repository

https://github.com/maimai993/koishi-plugin-kkk

QQ 群

{data.report?.group ?? '1050229473'}

{data.report?.groupUrl ?? 'https://qm.qq.com/q/viymkIPvvq'}

附带信息

此图片 + 触发命令 + 对应配置(自行脱敏处理)

{data.report && (

错误上报编号

{data.report.id}

{data.report.url}

)}
Tips: 信息越完整,定位越快。
) } handlerError.displayName = 'handlerError' export default handlerError