koishi-plugin-kkk 3.2.2 → 3.3.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,58 @@
1
+ /**
2
+ * 面板(/kkk)分类冒烟测试。
3
+ *
4
+ * 覆盖两件事:
5
+ * 1. 「Koishi 设置」这一组分类确实注入进了面板前端包(分类入口 / 分发 case / 4 个字段路径),
6
+ * 并且**没有 masters**(主人账号属于 Koishi 权限体系,面板是免登录页面,不给它开口子);
7
+ * 2. 服务端(src/webui.ts)的白名单与面板字段一一对应 —— 前端能改的,服务端就认这几个;
8
+ * 前端塞别的东西进来,服务端会丢掉。
9
+ *
10
+ * 用法:node scripts/smoke-panel-tabs.cjs
11
+ */
12
+ const fs = require('node:fs')
13
+ const path = require('node:path')
14
+
15
+ const pluginRoot = path.resolve(__dirname, '..')
16
+ const webAssets = path.join(pluginRoot, 'assets', 'web', 'assets')
17
+ const BT = String.fromCharCode(96)
18
+
19
+ const results = []
20
+ const check = (name, ok, detail) => {
21
+ results.push({ name, ok })
22
+ console.log((ok ? ' ✅ ' : ' ❌ ') + name + (detail ? ' —— ' + detail : ''))
23
+ }
24
+
25
+ const bundle = fs.readdirSync(webAssets).filter((name) => /^index-.*[.]js$/.test(name))
26
+ .map((name) => path.join(webAssets, name))
27
+ .sort((a, b) => fs.statSync(b).size - fs.statSync(a).size)[0]
28
+ const text = fs.readFileSync(bundle, 'utf8')
29
+ const count = (needle) => text.split(needle).length - 1
30
+
31
+ console.log('\n[1] 面板里多了「Koishi 设置」分类(' + path.basename(bundle) + ')')
32
+ check('分类入口存在且只有一个', count('{key:' + BT + 'koishi' + BT) === 1, '× ' + count('{key:' + BT + 'koishi' + BT))
33
+ check('分发 case 存在且只有一个', count('case' + BT + 'koishi' + BT) === 1, '× ' + count('case' + BT + 'koishi' + BT))
34
+ check('组件定义存在', count('KKKNativeConfig=') === 1)
35
+
36
+ console.log('\n[2] 这一组里是 4 个字段,且没有 masters')
37
+ for (const key of ['dataPath', 'debug', 'autoParse', 'webUiAuth']) {
38
+ const needle = BT + 'koishi' + BT + ',' + BT + key + BT
39
+ check('字段路径 ' + key, count(needle) === 1, '× ' + count(needle))
40
+ }
41
+ check('没有 masters(面板不给主人账号开口子)', count(BT + 'koishi' + BT + ',' + BT + 'masters' + BT) === 0)
42
+
43
+ console.log('\n[3] 没有把 undefined 塞进 JSX children(拼串时容易多一个逗号)')
44
+ check('不存在 children:[, 这种空位', count('children:[,') === 0)
45
+
46
+ console.log('\n[4] 服务端白名单与面板字段一一对应')
47
+ const webui = fs.readFileSync(path.join(pluginRoot, 'src', 'webui.ts'), 'utf8')
48
+ const match = webui.match(/const NATIVE_PANEL_KEYS = \[([^\]]*)\]/)
49
+ const keys = match ? match[1].split(',').map((item) => item.trim().replace(/['"]/g, '')).filter(Boolean) : []
50
+ check('找到 NATIVE_PANEL_KEYS', keys.length > 0, keys.join(', '))
51
+ check('白名单正好是面板那 4 个字段', JSON.stringify(keys) === JSON.stringify(['dataPath', 'debug', 'autoParse', 'webUiAuth']), JSON.stringify(keys))
52
+ check('白名单里没有 masters', !keys.includes('masters'))
53
+ check('GET 会把这一组返回给面板', /koishi: readNativePanelOptions\(config\)/.test(webui))
54
+ check('POST 会把这一组写回插件配置顶层', /const \{ qq, koishi: nativeGroup, \.\.\.upstream \} = normalizeLists\(body\)/.test(webui))
55
+
56
+ const failed = results.filter((item) => !item.ok)
57
+ console.log('\n=== ' + (results.length - failed.length) + '/' + results.length + ' 通过 ===')
58
+ process.exitCode = failed.length ? 1 : 0
@@ -14,6 +14,9 @@ import path from 'node:path'
14
14
  import { h } from 'koishi'
15
15
  import type { Bot, Context, Session } from 'koishi'
16
16
 
17
+ // 版本比较复用注入器那边的实现(它处理了 -beta.1 这类预发布号的先后)
18
+ import { isSemverGreater } from '../karin/module/utils/semver'
19
+
17
20
  import { logger } from './logger'
18
21
  import { COLLECTED_MESSAGE_ID, collectForward } from './forward-collect'
19
22
  import { UnconfirmedSendError, classifySendFailure, describeSendFailure, isPassiveLimitFailure } from './sendError'
@@ -963,9 +966,18 @@ export const checkPkgUpdate = async (name: string, _options?: { compare?: string
963
966
  const response = await fetch('https://registry.npmmirror.com/' + name, { signal: AbortSignal.timeout(10000) })
964
967
  if (!response.ok) return { status: 'error' as const, error: new Error('HTTP ' + response.status) }
965
968
  const meta: any = await response.json()
966
- const remote = meta?.['dist-tags']?.latest
969
+ const tags: Record<string, string> = meta?.['dist-tags'] ?? {}
970
+ /**
971
+ * 预览版(3.3.0-beta.1 这种)**不能拿 latest 比**:本地跑 beta 时 latest 还在 3.2.2,
972
+ * 按老逻辑会报「有新版本 3.2.2」—— 那其实是**降级**。
973
+ * 所以带 - 的本地版本改看 beta 通道;beta 没有(说明预览已经并进正式版)才回退到 latest,
974
+ * 并且只在远端确实比本地新时才说「有更新」。
975
+ */
976
+ const isPreview = local.includes('-')
977
+ const remote = (isPreview ? (tags.beta || tags.latest) : tags.latest) || ''
967
978
  if (!remote) return { status: 'error' as const, error: new Error('响应缺少 dist-tags.latest') }
968
979
  if (remote === local) return { status: 'no' as const, local }
980
+ if (isPreview && !isSemverGreater(remote, local)) return { status: 'no' as const, local }
969
981
  return { status: 'yes' as const, local, remote }
970
982
  } catch (error) {
971
983
  return { status: 'error' as const, error: error as Error }
package/src/index.ts CHANGED
@@ -129,13 +129,32 @@ export interface Config {
129
129
  /** 摊平后由「Koishi 原生设置」分组提供的字段 */
130
130
  const NATIVE_KEYS = ['masters', 'dataPath', 'debug', 'autoParse', 'webUiAuth']
131
131
 
132
+ /**
133
+ * 控制台里**唯一可见**的一段文字:把用户指到 WebUI 面板去。
134
+ *
135
+ * 为什么把设置全藏了:同一份配置在控制台改一半、在面板改一半,两边都会把整份配置写回
136
+ * koishi.yml,很容易互相覆盖(用户实际遇到过「面板里关了、控制台一保存又回来了」)。
137
+ * 现在控制台不再提供编辑入口,只留这块说明。
138
+ *
139
+ * 注意:**隐藏不等于删除** —— 字段仍然在 schema 里声明着,所以在控制台里点保存
140
+ * 一样会把已有值带上,面板里的设置不会被清空。
141
+ */
142
+ const WEBUI_GUIDE = [
143
+ '**所有设置都在 WebUI 配置面板里改**,地址:/kkk(打开 Koishi 控制台后,左侧边栏也有一个「**kkk 配置**」入口)。',
144
+ '',
145
+ '面板里包含:接口库 / 通用(含**错误上报**、在线播放器、合并转发)/ 抖音 / 哔哩哔哩 / 快手 / 小红书 / **QQ 适配器** / 推送列表。' +
146
+ '改完点右下角保存即可 —— 会写回 koishi.yml 并热重载,**不用重启**。',
147
+ '',
148
+ '**Koishi 控制台里的设置项已经全部隐藏**(值仍然保留,在这里点保存也不会把它们弄丢),免得两边各改一半、互相覆盖。',
149
+ '',
150
+ '面板里还有一组「**Koishi 设置**」(数据目录、调试日志、自动解析、面板是否要求先登录控制台)。' +
151
+ '只有 masters(主人账号)没有面板入口 —— 它属于 Koishi 的权限体系,而面板是免登录页面,' +
152
+ '需要直接改 koishi.yml 里本插件的配置段(改完重启 Koishi)。'
153
+ ].join('\n')
154
+
132
155
  export const Config: Schema<Config> = Schema.intersect([
133
156
  Schema.object({
134
- webuiGuide: Schema.const('').description(
135
- '**本插件的配置都在配置面板里改**:Koishi 控制台左侧边栏 →「**kkk 配置**」。\n\n' +
136
- '(原版配置面板:接口库 / 通用 / 抖音 / 哔哩哔哩 / 快手 / 小红书 / **QQ 适配器** / 推送列表,' +
137
- '改完点右下角保存即可,写回 koishi.yml 并热重载,不用重启。)'
138
- ),
157
+ webuiGuide: Schema.const('').description(WEBUI_GUIDE),
139
158
  // 全部隐藏:控制台表单里不显示,配置统一在控制台侧边栏的「kkk 配置」面板里改
140
159
  qq: buildQqSchema(Schema).hidden().description('QQ 适配器(只对 QQ 平台生效)'),
141
160
  advanced: Schema.object({
@@ -145,7 +164,7 @@ export const Config: Schema<Config> = Schema.intersect([
145
164
  autoParse: Schema.boolean().default(true).description('群里有人发链接(或回复一条带链接的消息)就自动解析,不用打指令'),
146
165
  webUiAuth: Schema.boolean().default(true).description('配置面板 /kkk 是否要求先登录 Koishi 控制台(默认开)。装了 auth 插件的部署只有登录后才能打开面板;没装 auth 插件时本来就没有登录这一说,这里不生效'),
147
166
  }).collapse().hidden().description('Koishi 原生设置(一般不用改,已折叠)'),
148
- }),
167
+ }).description('配置入口:请在 WebUI 面板(/kkk)里修改'),
149
168
  Schema.object({
150
169
  /**
151
170
  * 合并转发(两级开关)。
@@ -182,7 +201,7 @@ export const Config: Schema<Config> = Schema.intersect([
182
201
  xiaohongshu: Schema.boolean().default(false).description('小红书:单独打开合并转发(全局关着时生效)'),
183
202
  xiaohongshuContent: Schema.array(Schema.union(['text', 'image', 'video', 'file'])).default([])
184
203
  .description('小红书转发合并哪些内容(留空 = 用全局那份)')
185
- }).description('合并转发(全局 + 各平台)'),
204
+ }).collapse().hidden().description('合并转发(全局 + 各平台)'),
186
205
  upstream: buildUpstreamSchema(pluginRootDir).hidden().description(
187
206
  '插件自身的配置(与 Karin 版 config.json 一致)。每项都带着上游默认值,枚举型字段是下拉框;' +
188
207
  '**与默认值不同**的项会在启动时写回 config.json,保持默认值的项不写(这样你直接改文件的内容不会被覆盖)'
@@ -18,7 +18,6 @@ import { createHash } from 'node:crypto'
18
18
  import { logger } from 'node-karin'
19
19
 
20
20
  import { tryGetRuntime } from '../../../compat/runtime'
21
- import { douyinFetcher } from './amagiClient'
22
21
  import { Config } from './Config'
23
22
 
24
23
  /** 卡片里挖出来的信息 */
@@ -417,55 +416,6 @@ export const searchBiliVideos = async (
417
416
  }
418
417
  }
419
418
 
420
- /* ------------------------------------------------------------------ *
421
- * 抖音搜索(直接用 amagi 的 search 端点)
422
- * ------------------------------------------------------------------ */
423
-
424
- export const searchDouyinWorks = async (
425
- keyword: string,
426
- limit = 8,
427
- authors: string[] = []
428
- ): Promise<Array<{ aweme_id: string; desc: string; author: string; score: number }>> => {
429
- try {
430
- // 这版接口库的抖音 fetcher 不一定有 search(实测 6.6.0 上没有),没有就干脆跳过
431
- const fetcher: any = douyinFetcher as any
432
- if (typeof fetcher?.search !== 'function') {
433
- logger.debug('[卡片解析] 当前接口库没有抖音搜索能力,跳过')
434
- return []
435
- }
436
- const res: any = await fetcher.search({ query: String(keyword ?? '').trim(), type: 'video', number: limit })
437
- const list: any[] =
438
- res?.data?.data?.aweme_list ?? res?.data?.aweme_list ?? res?.aweme_list ?? []
439
- const titleKey = normalizeText(keyword)
440
- const authorKeys = authors.map((item) => normalizeText(item)).filter((item) => item.length >= 2)
441
- return list
442
- .filter((item: any) => item?.aweme_id)
443
- .map((item: any, index: number) => {
444
- const desc = String(item.desc ?? '')
445
- const sim = titleSimilarity(normalizeText(desc), titleKey)
446
- // 作者对上一样加分:抖音搜索噪声大,光靠标题常常分不出是哪一条
447
- const authorKey = normalizeText(String(item.author?.nickname ?? ''))
448
- let authorScore = 0
449
- for (const key of authorKeys) {
450
- if (!authorKey) break
451
- if (authorKey === key) { authorScore = 60; break }
452
- if (authorKey.includes(key) || key.includes(authorKey)) authorScore = Math.max(authorScore, 35)
453
- }
454
- return {
455
- aweme_id: String(item.aweme_id),
456
- desc,
457
- author: String(item.author?.nickname ?? ''),
458
- score: Math.round(sim * 100) + authorScore - index
459
- }
460
- })
461
- .sort((left, right) => right.score - left.score)
462
- .slice(0, Math.max(1, Math.min(20, limit)))
463
- } catch (error: any) {
464
- logger.warn('[卡片解析] 抖音搜索异常: ' + String(error?.message ?? error))
465
- return []
466
- }
467
- }
468
-
469
419
  /* ------------------------------------------------------------------ *
470
420
  * 对外入口
471
421
  * ------------------------------------------------------------------ */
@@ -477,6 +427,7 @@ export const searchDouyinWorks = async (
477
427
  * 任何一步失败都返回 null(调用方据此走原来的「未找到链接」逻辑,不影响已有功能)。
478
428
  */
479
429
  export type CardCandidate = {
430
+ /** 卡片解析只可能落在 B站上(抖音没有卡片消息) */
480
431
  platform: 'bilibili' | 'douyin'
481
432
  id: string
482
433
  title: string
@@ -527,12 +478,7 @@ export const resolveCardToUrl = async (
527
478
  return null
528
479
  }
529
480
 
530
- /** 卡片自带的 source 字段最准(实测 `source: 哔哩哔哩`),其次是 OCR 文本 */
531
- const hint = String(card.source ?? '') + ' ' + card.title + ' ' + card.desc + ' ' + upName + ' ' + ocrText
532
- const looksDouyin = /抖音|douyin|快手|ks\./i.test(hint)
533
- const looksBili = /bilibili|哔哩|B站|UP主/i.test(hint)
534
-
535
- // ③ 先按最可能的平台搜,命中就返回
481
+ // ③ B站搜一把:标题 + 作者都对上才自动继续,否则把候选交给用户挑
536
482
  const tryBili = async (): Promise<{ url?: string; candidates: CardCandidate[]; matchedAuthor?: string }> => {
537
483
  const hits = await searchBiliVideos(keyword, card.title, upNames, 8)
538
484
  const candidates = hits.slice(0, 6).map((item) => ({
@@ -547,30 +493,17 @@ export const resolveCardToUrl = async (
547
493
  }
548
494
  return { candidates }
549
495
  }
550
- const tryDouyin = async (): Promise<{ url?: string; candidates: CardCandidate[] }> => {
551
- const hits = await searchDouyinWorks(keyword || card.title, 8, upNames)
552
- const candidates = hits.slice(0, 6).map((item) => ({
553
- platform: 'douyin' as const, id: item.aweme_id, title: item.desc, author: item.author, score: item.score
554
- }))
555
- const best = hits[0]
556
- // 抖音搜索噪声大,要求分数足够高才自动继续,否则交给用户挑
557
- if (best && best.score > 40) return { url: 'https://www.douyin.com/video/' + best.aweme_id, candidates }
558
- return { candidates }
559
- }
560
-
561
- const order = looksDouyin && !looksBili ? [tryDouyin, tryBili] : [tryBili, tryDouyin]
562
- const candidates: CardCandidate[] = []
563
- for (const attempt of order) {
564
- const hit = await attempt()
565
- if (hit) {
566
- candidates.push(...hit.candidates)
567
- if (hit.url) {
568
- logger.mark('[卡片解析] 定位成功: ' + hit.url)
569
- // 提示语里报「真正匹配上的那个 UP 名」,而不是卡片摘要里那个不准的
570
- const matched = (hit as any).matchedAuthor || upName
571
- return { url: hit.url, platform: hit.url.includes('bilibili') ? 'bilibili' : 'douyin', card, candidates, ocrText, upName: matched }
572
- }
573
- }
496
+ /**
497
+ * **只搜 B站**:抖音没有卡片消息这种玩法(那条「当前接口库没有抖音搜索能力」的日志就是
498
+ * 以前多此一举地搜抖音留下的)。卡片解析这条链路只处理 B站卡片(入口处已经按 source 过滤过),
499
+ * 少搜一个平台就少一次风控风险、也少几秒等待。
500
+ */
501
+ const hit = await tryBili()
502
+ const candidates: CardCandidate[] = hit.candidates
503
+ if (hit.url) {
504
+ logger.mark('[卡片解析] 定位成功: ' + hit.url)
505
+ // 提示语里报「真正匹配上的那个 UP 名」,而不是卡片摘要里那个不准的
506
+ return { url: hit.url, platform: 'bilibili', card, candidates, ocrText, upName: hit.matchedAuthor || upName }
574
507
  }
575
508
 
576
509
  logger.mark('[卡片解析] 没能唯一定位(标题: ' + card.title + ',UP: ' + upName + '),候选 ' + candidates.length + ' 条')
@@ -325,10 +325,16 @@ export const errorHelpSegments = (report: ErrorReportResult | null, platform: st
325
325
  ? '错误信息已上传,ID:' + report.id
326
326
  : '错误信息上传失败(不影响使用)'
327
327
  const ask = '可以前往 QQ 群内寻找帮助,在群内发送错误 ID 或该错误图片'
328
+ /**
329
+ * 两段之间必须**空一行**(\n\n)。
330
+ *
331
+ * markdown 里单个换行不算换行:两段会被拼成一整行,QQ 那边按一行显示时字号会被撑得很难看
332
+ * (用户原话「不换行大小会崩」)。纯文本那条也一样留空行,两种平台的观感才对得上。
333
+ */
328
334
  if (officialQq) {
329
335
  // QQ 官方机器人认 markdown:群号做成可点的加群链接
330
- return [segment.markdown(head + '\n' + '可以前往 [QQ 群 ' + group + '](' + link + ') 内寻找帮助,在群内发送错误 ID 或该错误图片')]
336
+ return [segment.markdown(head + '\n\n' + '可以前往 [QQ 群 ' + group + '](' + link + ') 内寻找帮助,在群内发送错误 ID 或该错误图片')]
331
337
  }
332
338
  // 个人号(OneBot / NapCat)不渲染 markdown:裸链接 QQ 客户端自己会变蓝可点
333
- return [segment.text(head + '\nQQ 群 ' + group + ':' + ask + '\n' + link)]
339
+ return [segment.text(head + '\n\nQQ 群 ' + group + ':' + ask + '\n' + link)]
334
340
  }
@@ -7,6 +7,8 @@ import { ParseSteps, SendTasks } from '@/module/utils/ParseSteps'
7
7
  import { sendParseTip } from '@/module/utils/parseTip'
8
8
  import type { ParseWorkType } from '@/module/db'
9
9
  import { Config } from '@/module/utils/Config'
10
+ // 注意用相对写法:@/ 别名在仓库里指向 karin/,@/player 会被解析成不存在的 karin/player
11
+ import { applyForceOnlinePlayer } from '../../../player'
10
12
  import { kuaishouComments, type KuaishouDataResult, type KuaishouOneWorkPayload } from '@/platform/kuaishou'
11
13
  import type { ExtendedKuaishouOptionsType, KuaishouDataTypes } from '@/types'
12
14
 
@@ -25,6 +25,8 @@ import {
25
25
  Render,
26
26
  uploadFile
27
27
  } from '@/module'
28
+ // 注意用相对写法:@/ 别名在仓库里指向 karin/,@/player 会被解析成不存在的 karin/player
29
+ import { applyForceOnlinePlayer } from '../../../player'
28
30
  import { ParseSteps, SendTasks } from '@/module/utils/ParseSteps'
29
31
  import type { ParseWorkType } from '@/module/db'
30
32
  import { Config } from '@/module/utils/Config'
package/src/webui.ts CHANGED
@@ -30,6 +30,33 @@ import { QQ_KEYS, readQqOptions } from './qqOptions'
30
30
  const COOKIE_NAME = 'kkk_config_token'
31
31
  const TOKEN_TTL = 7 * 24 * 60 * 60 * 1000
32
32
 
33
+ /**
34
+ * 面板「Koishi 设置」分类里能改的项 —— 也就是 Koishi 侧的原生选项(koishi.yml 里本插件的顶层键)。
35
+ *
36
+ * **masters 不在这里**:主人账号是 Koishi 的权限体系(谁能执行主人指令、谁收报错),
37
+ * 面板是免登录页面,不该给它开这个口子 —— 仍然只在 koishi.yml 里改。
38
+ * 控制台那边也已经全部隐藏,所以这 4 项是唯一还有图形入口的地方。
39
+ */
40
+ const NATIVE_PANEL_KEYS = ['dataPath', 'debug', 'autoParse', 'webUiAuth'] as const
41
+ type NativePanelKey = typeof NATIVE_PANEL_KEYS[number]
42
+
43
+ /** 面板要显示的原生设置(只取白名单,缺的用默认值兜底) */
44
+ const NATIVE_PANEL_DEFAULTS: Record<NativePanelKey, any> = {
45
+ dataPath: 'data',
46
+ debug: false,
47
+ autoParse: true,
48
+ webUiAuth: true
49
+ }
50
+
51
+ const readNativePanelOptions = (source: any): Record<string, any> => {
52
+ const out: Record<string, any> = {}
53
+ for (const key of NATIVE_PANEL_KEYS) {
54
+ const value = source?.[key]
55
+ out[key] = value === undefined || value === null ? NATIVE_PANEL_DEFAULTS[key] : value
56
+ }
57
+ return out
58
+ }
59
+
33
60
  export interface WebUiDeps {
34
61
  ctx: Context
35
62
  config: any
@@ -345,7 +372,8 @@ export function registerWebUi ({ ctx, config, rawConfig, logger, pluginRoot }: W
345
372
  // 在 Koishi 这边它就存在 `upstream` 里,启动时同步进 config.json。
346
373
  // 另外附一份 `qq`(「QQ 适配器」分类):面板/切片/番剧选集/卡片识别这些是 Koishi 侧才有的开关,
347
374
  // 面板里作为独立分类显示,保存时由下面的 POST 拆出来写回 koishi.yml。
348
- ok(response, { ...(config?.upstream ?? {}), qq: readQqOptions(config) }, '')
375
+ // koishi 这一组是「Koishi 设置」分类:Koishi 侧的原生选项(masters 不在里面,见 NATIVE_PANEL_KEYS)
376
+ ok(response, { ...(config?.upstream ?? {}), qq: readQqOptions(config), koishi: readNativePanelOptions(config) }, '')
349
377
  })
350
378
 
351
379
  server.post('/kkk/v1/config', async (response: any) => {
@@ -357,10 +385,20 @@ export function registerWebUi ({ ctx, config, rawConfig, logger, pluginRoot }: W
357
385
  // - `qq`(面板里的「QQ 适配器」分类)→ 写回插件自己的配置(koishi.yml)
358
386
  // - 其余整份 → upstream(Karin 版的 config.json 形状)
359
387
  // 保存都走 scope.update → 落盘 koishi.yml → 热重载 → 启动时同步回 config.json
360
- const { qq, ...upstream } = normalizeLists(body)
388
+ const { qq, koishi: nativeGroup, ...upstream } = normalizeLists(body)
389
+ // 「Koishi 设置」分类:只认白名单里的键(前端传什么都写不进来别的东西)
390
+ const native: Record<string, any> = {}
391
+ if (nativeGroup && typeof nativeGroup === 'object') {
392
+ for (const key of NATIVE_PANEL_KEYS) {
393
+ if (nativeGroup[key] === undefined) continue
394
+ native[key] = typeof NATIVE_PANEL_DEFAULTS[key] === 'boolean' ? !!nativeGroup[key] : String(nativeGroup[key])
395
+ }
396
+ }
361
397
  await (ctx as any).scope.update(normalize({
362
398
  ...(rawConfig ?? config),
363
399
  ...(qq && typeof qq === 'object' ? { qq: { ...readQqOptions(config), ...qq } } : {}),
400
+ // 注意:原生这几项是**顶层键**(dataPath / debug / autoParse / webUiAuth),不要塞进分组
401
+ ...native,
364
402
  upstream
365
403
  }))
366
404
  /**