@zaofan/dsh-qqbot 1.7.9 → 1.8.0

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.
@@ -200,36 +200,400 @@ window.__ModuleLoader__.load({
200
200
  h('button', { className: 'qqs-btn', style: { marginTop: 4 }, onClick: () => props.onRemove(r.id) }, '删除这条提醒'))
201
201
  }
202
202
 
203
+ // ══════════════════════════════════════════════════════════════════════════
204
+ // 「配置即数据」引擎 —— Schema 驱动渲染 + 只提交 diff(2026-10-10 主人定)
205
+ // ══════════════════════════════════════════════════════════════════════════
206
+ // 旧版同一个字段要在三处手写:① load() 里手写 normalize;② doSave() 里手写全量
207
+ // patch;③ 渲染时手写 label + 控件。加一个字段要改三处,漏一处 =「面板勾了却存不
208
+ // 下去」(本文件历史注释里反复出现的就是这个病)。现在只有一处:FGROUPS。
209
+ // · 渲染 → renderField() 按 t 出控件
210
+ // · 默认值 → 渲染时按 def 回落(**不写进 cfg**,这样 diff 才是"真实改动")
211
+ // · 提交 → buildPatch() 只放**变化了的顶层 key**(值是当前完整快照,host 侧
212
+ // 浅合并也不会丢子字段)
213
+ // 顺带修掉两个真 bug:
214
+ // ① ⑥「提问卡片」的 questionsMode 从来没进过 patch —— 选"后台/阻塞"永远存不下去;
215
+ // ② 列表类字段(禁发群、owner 列表)原来是**受控** input,逗号打不进去
216
+ // (RuleEditor 2026-09-14 踩过同一个坑)→ 现在统一非受控。
217
+ // --------------------------------------------------------------------------
218
+
219
+ function trimS(s) { return String(s).trim() }
220
+ function gPath(o, path) {
221
+ var ps = String(path).split('.'), c = o, i
222
+ for (i = 0; i < ps.length; i++) { if (c === null || c === undefined) return undefined; c = c[ps[i]] }
223
+ return c
224
+ }
225
+ function sPath(o, path, val) {
226
+ var ps = String(path).split('.')
227
+ function rec(node, i) {
228
+ var copy = (node && typeof node === 'object' && !Array.isArray(node)) ? Object.assign({}, node) : {}
229
+ if (i === ps.length - 1) { copy[ps[i]] = val; return copy }
230
+ copy[ps[i]] = rec(node ? node[ps[i]] : undefined, i + 1)
231
+ return copy
232
+ }
233
+ return rec(o, 0)
234
+ }
235
+ function deepEq(a, b) {
236
+ if (a === b) return true
237
+ if (a === null || b === null || a === undefined || b === undefined) return a === b
238
+ if (typeof a !== 'object' || typeof b !== 'object') return false
239
+ if (Array.isArray(a) !== Array.isArray(b)) return false
240
+ var ka = Object.keys(a), kb = Object.keys(b), i
241
+ if (ka.length !== kb.length) return false
242
+ for (i = 0; i < ka.length; i++) { if (!deepEq(a[ka[i]], b[ka[i]])) return false }
243
+ return true
244
+ }
245
+ function cloneCfg(v) { try { return JSON.parse(JSON.stringify(v)) } catch (e) { return v } }
246
+ function numOf(v, def) { var n = Number(v); return isFinite(n) ? n : (typeof def === 'number' ? def : 0) }
247
+ /** 只提交变化了的「顶层 key」—— 没碰过的字段(含本页不编辑的 schedule 等)一律不提交 */
248
+ function buildPatch(cur, saved) {
249
+ var patch = {}, keys = {}, k
250
+ var c = cur || {}, s = saved || {}
251
+ for (k in c) keys[k] = 1
252
+ for (k in s) keys[k] = 1
253
+ for (k in keys) { if (!deepEq(c[k], s[k])) patch[k] = c[k] }
254
+ return patch
255
+ }
256
+ /** 字段当前值(undefined 用 def 回落) */
257
+ function fieldVal(cfg, f) {
258
+ var v = gPath(cfg || {}, f.k)
259
+ if (v === undefined || v === null) return f.def
260
+ if (f.t === 'num') return numOf(v, f.def)
261
+ if (f.t === 'bool') return v === true
262
+ return v
263
+ }
264
+
265
+ // ── 字段 / 分组定义(label、说明 一律集中在这里;以后改文案只改这一处)──
266
+ // t: bool | num | str | area | radio | list | note | custom
267
+ // k: 配置里的点分路径 def: 默认值 sec: 单位换算(存 = 显示 × sec)
268
+ // wide: 宽输入框 rows: textarea 行数 options: radio 选项
269
+ var FGROUPS = [
270
+ {
271
+ id: 'prompt', icon: '①', title: '群里她要一直记住的守则',
272
+ desc: '每轮回复都会带上的一段提醒(所有 QQ 群都生效,不碰你的人格设定)。默认是"表情包礼仪";想让她不主动发图,把这里清空即可(留空=没守则,不会自动填回)。',
273
+ fields: [
274
+ { k: 'groupPrompt', kw: '守则 人设 人格 提示词 提醒 每轮 遵守 规则 prompt', t: 'area', label: '', rows: 5, def: DEFAULT_GROUP_PROMPT },
275
+ {
276
+ t: 'custom', render: function (ctx) {
277
+ return h('button', {
278
+ className: 'qqs-btn', style: { padding: '2px 10px', fontSize: 12 },
279
+ onClick: function () { ctx.set('groupPrompt', DEFAULT_GROUP_PROMPT) },
280
+ }, '恢复默认守则(表情包礼仪, 点下方「保存」生效)')
281
+ }
282
+ },
283
+ ],
284
+ },
285
+ {
286
+ id: 'rate', icon: '②', title: '多久回一次消息',
287
+ desc: '控制她回复的速度。全填 0 = 来一条回一条(不限制)。',
288
+ fields: [
289
+ { k: 'behavior.freeIntervalSec', kw: '回复 间隔 频率 冷却 限速 慢 秒 群 没人at', t: 'num', label: '群里没人 @ 她时,隔几秒才回一次(0=每条都回)', def: 0 },
290
+ { k: 'behavior.mentionIntervalSec', kw: '回复 间隔 at 提及 秒 快 随叫随到', t: 'num', label: '有人 @ 她时,两次回复至少隔几秒(0=随叫随到)', def: 0 },
291
+ { k: 'behavior.directIntervalSec', kw: '回复 间隔 私聊 单聊 秒', t: 'num', label: '私聊里隔几秒回一次(0=不限制)', def: 0 },
292
+ {
293
+ k: 'outboundMode', kw: '出站 发送 主动 被动 静默 连发 丢消息 模式', t: 'radio', label: '出站方式(连发消息 QQ 端丢失时切主动):', def: 'adaptive',
294
+ options: [
295
+ { v: 'adaptive', label: '适配主动(推荐默认)' },
296
+ { v: 'detail', label: '详细主动(连工具调用一起推)' },
297
+ { v: 'passive', label: '被动(只回最后一句)' },
298
+ { v: 'silent', label: '完全不出站(静默)' },
299
+ { v: 'nothink', label: '完全不思考(QQ入站不唤醒,仅设置页)' },
300
+ ],
301
+ hint: '适配主动=刚收到真人消息时前5条带引用回你, 第6条起自动转独立新消息(连发不被QQ吞); 一段时间没新消息的主动推送(定时等)也走独立消息。被动=始终回你那条(连发约4~5条后被QQ吞)。完全不出站=照常思考但不向QQ发任何回复(AI 可用工具切回)。完全不思考=QQ入站不唤醒AI, 消息只记录(仅本页可开; 唤醒请发 /outmode adaptive)。保存即热更新, 不用重启。',
302
+ },
303
+ { t: 'note', label: '延迟聚合(另一套机制,和上面冷却不冲突): 她收到消息先等一小会儿, 把连发的话攒一起综合回, 免得只回第一句。' },
304
+ { k: 'behavior.debounce.enabled', kw: '延迟 聚合 攒 合并 一起回 开关', t: 'bool', label: '开启延迟聚合(不勾=回到来一条回一条)', def: true },
305
+ { k: 'behavior.debounce.silenceSec', kw: '延迟 聚合 停顿 静默 等待 秒', t: 'num', label: '对方停口几秒后她才开口(默认3;0=不停顿)', def: 3 },
306
+ { k: 'behavior.debounce.maxMsgs', kw: '延迟 聚合 攒 条数 上限 立即', t: 'num', label: '攒满几条立即开口,不等对方停(默认10)', def: 10 },
307
+ { k: 'behavior.debounce.mentionDelayed', kw: '延迟 聚合 at 提及 秒回', t: 'bool', label: '有人 @ 她时也走延迟(不勾=@到秒回)', def: true },
308
+ { t: 'note', label: '她正在思考/输出时, 群里又来了新消息怎么办:' },
309
+ {
310
+ k: 'behavior.debounce.busySendMode', kw: '插话 排队 忙碌 正在输出 中途 消息', t: 'radio', def: 'queue',
311
+ options: [
312
+ { v: 'queue', label: '排队发送(默认: 攒着, 等这轮回完再一起看)' },
313
+ { v: 'interject', label: '插话发送(在这回的轮次之间立刻插进去)' },
314
+ ],
315
+ hint: '排队=她正忙时消息攒在窗口里, 等这回合结束才整批送进去(带"上次回复前群友所发"的提示)。插话=照样先聚合(静默秒数/条数上限都不变, 不会一条条吵醒她), 但不等回合结束, 直接在回合内的轮次之间插进去 —— 每次插话都带一句"先做完当前任务、别被带偏"的轻提示(同一回合第2次起提示会自动缩短省 token)。注意: 若她这一步是纯文字收尾(后面没有工具轮次), 插话会留到下次唤醒才被读到, 效果和排队一样(宿主 inject 的固有语义)。保存即热更新, 不用重启。',
316
+ },
317
+ ],
318
+ },
319
+ {
320
+ id: 'sticker', icon: '③', title: '发表情包的限制(全默认不限制)',
321
+ desc: '防止她聊天时表情包刷屏;下面两项是"她主动发图"时才用——群里很热闹才发,冷清就憋着。不想管就保持全 0,也别勾总开关。',
322
+ fields: [
323
+ { t: 'note', label: '发图闸门' },
324
+ { k: 'sticker.gates.enabled', kw: '表情包 图 限制 闸门 开关 刷屏', t: 'bool', label: '开启表情包限制(不勾=完全不限制)', def: false },
325
+ { k: 'sticker.gates.perTurnMax', kw: '表情包 图 每轮 一次 上限 几张', t: 'num', label: '她一次回复最多带几张图(0=不限)', def: 0 },
326
+ { k: 'sticker.gates.maxPerWindow', kw: '表情包 图 频率 窗口 上限 几张', t: 'num', label: '每隔"一小段时间"最多发几张(0=不限)', def: 0 },
327
+ { k: 'sticker.gates.perWindowSec', kw: '窗口 时间 秒 频率 区间', t: 'num', label: '上面说的"一小段时间"是多长(秒,如600=10分钟)', def: 0 },
328
+ { k: 'sticker.gates.dailyBudgetPerGroup', kw: '表情包 每日 每天 预算 上限 群', t: 'num', label: '每个群一天最多发几张表情包(0=不限)', def: 0 },
329
+ { k: 'sticker.gates.dupTTLHours', kw: '重复 去重 同一张 小时 冷却', t: 'num', label: '同一张图多久内不许重复发(小时,0=不查)', def: 0 },
330
+ { k: 'sticker.gates.activityWindowSec', kw: '活跃 热闹 窗口 时间 多久', t: 'num', label: '看"最近"多长时间的聊天来判热闹(3600=最近1小时)', def: 0 },
331
+ { k: 'sticker.gates.activityMinMsgs', kw: '活跃 热闹 消息数 门槛 冷清', t: 'num', label: '最近这段时间群消息达到几条,她才肯主动发图(0=不管冷不冷都发)', def: 0 },
332
+ { k: 'sticker.gates.bannedGroups', kw: '禁止 禁发 群 黑名单 排除', t: 'list', label: '禁止发图的群(填群ID,逗号分隔;一般不用填)', wide: true, def: [] },
333
+ { t: 'note', label: '图库维护' },
334
+ { k: 'sticker.autoTagEnabled', kw: '自动 打标 识图 视觉 标签 图库', t: 'bool', label: '新图自动后台识图打标(会花视觉额度;不勾=全靠人工手动补)', def: false },
335
+ { k: 'sticker.visionCli', kw: '视觉 引擎 命令 cli 识图 ocr 高级', t: 'str', label: '视觉引擎命令(高级;留空自动探测)', wide: true, def: '' },
336
+ ],
337
+ },
338
+ {
339
+ id: 'rules', icon: '④', title: '自定义小提醒(高级)',
340
+ desc: '当群友发的消息满足下面条件,就偷偷给机器人加一条要照做的提醒。比如:勾上"含链接",提醒写"引用前先核实"。',
341
+ fields: [
342
+ {
343
+ t: 'custom', render: function (ctx) {
344
+ var list = Array.isArray(ctx.cfg.injectRules) ? ctx.cfg.injectRules : []
345
+ return h('div', null,
346
+ list.map(function (r, i) {
347
+ return h(RuleEditor, {
348
+ key: r.id || ('r' + i), rule: r,
349
+ onChange: function (nr) { var l = list.slice(); l[i] = nr; ctx.set('injectRules', l) },
350
+ onRemove: function (id) { ctx.set('injectRules', list.filter(function (x) { return x.id !== id })) },
351
+ })
352
+ }),
353
+ h('button', {
354
+ className: 'qqs-btn', onClick: function () {
355
+ var l = list.slice()
356
+ l.push({
357
+ id: 'rule-' + Date.now().toString(36), name: '', enabled: true,
358
+ conditions: { hasImage: false, hasLink: false, contentRegex: '', contentKeywords: [], matchScope: 'any' }, prompt: '',
359
+ })
360
+ ctx.set('injectRules', l)
361
+ },
362
+ }, '+ 添加一条提醒'))
363
+ }
364
+ },
365
+ { k: 'imageHint', kw: '图片 看图 提示 识图 注入', t: 'bool', label: '图片消息自动提示 AI 看图(内置兜底; 不勾=不再注入「请把URL传给识图工具」那条)', def: true },
366
+ { k: 'messageReference', kw: '引用 消息号 原文 rf 回复某条', t: 'bool', label: '引用消息(默认开): 入站消息带短消息号(本地台账索引,省token) + 引用消息附原文; AI 用 [rf:短号] 引用对方消息', def: true },
367
+ ],
368
+ },
369
+ {
370
+ id: 'diag', kw: '日志 诊断 排查 log 落盘', icon: '⑤', title: '诊断日志(排查用)',
371
+ desc: '默认关。开了才会把排查用的诊断日志写进 {DSH_HOME|~/.dsh} 下的 *.log(单文件 2MB 自动轮转,只留 1 份旧档)。平时别开——它会持续占盘;出问题时再开,排查完可以关掉、并把那些 .log 删掉。',
372
+ fields: [
373
+ { k: 'diagLog', kw: '日志 诊断 排查 log 落盘 记录', t: 'bool', label: '诊断日志落盘(默认关): 写 ~/.dsh/*.log 便于离线排查', def: false },
374
+ ],
375
+ },
376
+ {
377
+ id: 'timers', kw: '定时 提醒 计划 到点 主动 唤醒 闹钟', icon: '⑥', title: '定时唤醒',
378
+ desc: '已合并到「定时任务」页(顶部 tab)一起编辑——到点主动开口的群/人分组,与她答应你的定时提醒,都在那边管理。',
379
+ fields: [],
380
+ },
381
+ {
382
+ id: 'approval', icon: '⑦', title: 'QQ 远程审批(在 QQ 里放行 dsh 权限申请)',
383
+ desc: '机器人的工具要动"工作区外"的东西时,dsh 会申请权限。开启后审批请求直接发到你的 QQ(私聊/群聊看你从哪发起),回 /approve 验证码 放行、/deny 拒绝——只放行这一次,验证码一次性,只有你能批。',
384
+ fields: [
385
+ { k: 'enableApprovals', kw: '审批 权限 申请 放行 授权 approve', t: 'bool', label: '开启 QQ 远程审批(不勾=保持默认审批方式)', def: false },
386
+ { k: 'approvalTimeoutMs', kw: '审批 超时 等待 秒 拒绝', t: 'num', label: '审批等待秒数(超时自动拒绝;默认120)', def: 120000, sec: 1000 },
387
+ ],
388
+ },
389
+ {
390
+ id: 'ask', icon: '⑧', title: '提问卡片(她问人时要不要在旁边干等)',
391
+ desc: '她调用 ask_user_question 问你或群友时怎么等答案。后台提问=卡片发出去她就能接着聊天/干活, 对方答完之后答案会作为一条"提问卡片·回执"自动送回她的会话(她正忙→插进当前回合不打断; 她闲着→叫醒她接着处理), 超时没答也会收到一条"超时未答"的回执。阻塞等待=旧行为, 她的这一回合一直挂到对方作答或超时, 期间收不了新消息。',
392
+ fields: [
393
+ {
394
+ k: 'questionsMode', kw: '提问 卡片 等待 后台 阻塞 回执 答案', t: 'radio', label: '等待方式:', def: 'async',
395
+ options: [
396
+ { v: 'async', label: '后台提问(推荐默认: 不卡她)' },
397
+ { v: 'blocking', label: '阻塞等待(旧行为: 她会卡住)' },
398
+ ],
399
+ },
400
+ ],
401
+ },
402
+ {
403
+ id: 'group', icon: '⑨', title: '群管理基础设置',
404
+ desc: '开启后 QQ 会话里可用群管理工具; 踢人/成员列表等官方未开放能力会在下方 Tab 里提示等待公测。',
405
+ fields: [
406
+ { k: 'groupAdmin.enabled', kw: '群管理 踢人 禁言 成员 入群 管理员', t: 'bool', label: '开启 QQ 群管理(不勾=群管理工具不可用)', def: false },
407
+ { k: 'groupAdmin.owners', kw: '群管理 主人 权限 openid 名单', t: 'list', label: '允许操作的主人 openid(逗号分隔,可留空=不校验)', wide: true, def: [] },
408
+ { k: 'groupAdmin.manageGroup', kw: '群管理 默认群 group_openid 目标群', t: 'str', label: '对话内默认管理群 group_openid(web 对话时群工具用它; QQ 群会话自动用当前群)', wide: true, def: '' },
409
+ { k: 'groupAdmin.watchJoinRequests', kw: '入群申请 事件 提醒 订阅', t: 'bool', label: '实时接收"入群申请"事件并自动提醒(勾选后需重启才生效)', def: false },
410
+ { k: 'groupAdmin.notifyInGroup', kw: '入群申请 提醒 群内 通知', t: 'bool', label: '收到入群申请时在该群内发提醒消息', def: true },
411
+ { t: 'custom', render: function (ctx) { return h(GroupAdminPanel, { key: 'ga-' + (ctx.ns || 'main'), ns: ctx.ns || undefined }) } },
412
+ ],
413
+ },
414
+ ]
415
+
416
+ // 分区导航的短名(完整标题太长)
417
+ var GSHORT = { prompt: '① 守则', rate: '② 回复节奏', sticker: '③ 表情包', rules: '④ 小提醒', diag: '⑤ 诊断日志', timers: '⑥ 定时', approval: '⑦ 审批', ask: '⑧ 提问卡片', group: '⑨ 群管理' }
418
+
419
+ // ── 搜索(2026-10-10 主人定):上栏=精确(字面),下栏=模糊/语义 ───────────────
420
+ // 语义侧**程序算**、零依赖、离线即时:字符 bigram + IDF 加权余弦;
421
+ // 文档 = label×2 + kw×2 + hint + 键名(kw 是每个字段手挂的关键词,见 FGROUPS)。
422
+ // 不调模型、不联网 —— 32 个字段的规模,这套查表+加权已经完全够用。
423
+ function sqBigrams(t) {
424
+ var s = String(t || '').toLowerCase().replace(/[\s\-_./:()(),,。、"'“”「」【】\[\]??!!@#*;;]+/g, '')
425
+ var out = {}, i
426
+ for (i = 0; i < s.length - 1; i++) { var g = s.slice(i, i + 2); out[g] = (out[g] || 0) + 1 }
427
+ if (s.length === 1) out[s] = 1
428
+ return out
429
+ }
430
+ var SQ_DOCS = []
431
+ var SQ_IDF = {}
432
+ function sqVec(t) { var g = sqBigrams(t), o = {}, k; for (k in g) o[k] = g[k] * (SQ_IDF[k] || 1); return o }
433
+ function sqCos(a, b) {
434
+ var dot = 0, na = 0, nb = 0, k
435
+ for (k in a) { na += a[k] * a[k]; if (b[k]) dot += a[k] * b[k] }
436
+ for (k in b) nb += b[k] * b[k]
437
+ return (!na || !nb) ? 0 : dot / (Math.sqrt(na) * Math.sqrt(nb))
438
+ }
439
+ ;(function buildSearchIndex() {
440
+ FGROUPS.forEach(function (g) {
441
+ (g.fields || []).forEach(function (fd) {
442
+ if (!fd.k || fd.t === 'note' || fd.t === 'custom') return
443
+ SQ_DOCS.push({ k: fd.k, doc: [fd.label || '', fd.label || '', fd.kw || '', fd.kw || '', fd.hint || '', String(fd.k).replace(/[.\-_]/g, ' ')].join(' ') })
444
+ })
445
+ })
446
+ var df = {}, i, b
447
+ for (i = 0; i < SQ_DOCS.length; i++) {
448
+ var seen = {}, g2 = sqBigrams(SQ_DOCS[i].doc)
449
+ for (b in g2) seen[b] = 1
450
+ for (b in seen) df[b] = (df[b] || 0) + 1
451
+ }
452
+ var N = SQ_DOCS.length || 1
453
+ for (b in df) SQ_IDF[b] = Math.log(1 + N / (1 + df[b])) + 0.2
454
+ SQ_DOCS.forEach(function (d) { d.vec = sqVec(d.doc) })
455
+ })()
456
+ /** 查询 → { 字段key: 相似度 }(0~1) */
457
+ function sqScores(query) {
458
+ var out = {}, i, qv = sqVec(query)
459
+ for (i = 0; i < SQ_DOCS.length; i++) out[SQ_DOCS[i].k] = sqCos(qv, SQ_DOCS[i].vec)
460
+ return out
461
+ }
462
+
463
+ // ── 渲染:一个字段 → 控件(t 决定形态;外观沿用旧版的行/输入框样式)──
464
+ function renderControl(f, val, ctx) {
465
+ var onNum = function (e) { ctx.set(f.k, Number(e.target.value) * (f.sec || 1)) }
466
+ if (f.t === 'bool') {
467
+ return h('input', {
468
+ className: 'qqs-inp', type: 'checkbox', style: inputStyle, checked: !!val,
469
+ onChange: function (e) { ctx.set(f.k, e.target.checked) },
470
+ })
471
+ }
472
+ if (f.t === 'num') {
473
+ return h('input', {
474
+ className: 'qqs-inp', type: 'number', style: inputStyle,
475
+ value: nv(val) / (f.sec || 1), onChange: onNum,
476
+ })
477
+ }
478
+ if (f.t === 'str') {
479
+ return h('input', {
480
+ className: 'qqs-inp', style: f.wide ? wideStyle : inputStyle, value: sv(val),
481
+ onChange: function (e) { ctx.set(f.k, e.target.value) },
482
+ })
483
+ }
484
+ if (f.t === 'area') {
485
+ return h('textarea', {
486
+ className: 'qqs-area', style: { ...fullStyle, minHeight: (f.rows || 3) * 20 },
487
+ value: sv(val), onChange: function (e) { ctx.set(f.k, e.target.value) },
488
+ })
489
+ }
490
+ if (f.t === 'list') {
491
+ // ⚠️ 非受控:受控写法下"输入逗号"会被 join 重建掉(同 RuleEditor 2026-09-14 的坑)
492
+ return h('input', {
493
+ className: 'qqs-inp', style: f.wide ? wideStyle : inputStyle,
494
+ defaultValue: (Array.isArray(val) ? val : []).join(','),
495
+ onChange: function (e) {
496
+ ctx.set(f.k, e.target.value.split(/[,,]/).map(trimS).filter(Boolean))
497
+ },
498
+ })
499
+ }
500
+ if (f.t === 'radio') {
501
+ return h('span', { style: { display: 'inline-flex', gap: 14, flexWrap: 'wrap', alignItems: 'center' } },
502
+ (f.options || []).map(function (o) {
503
+ return h('label', { key: o.v, style: { display: 'inline-flex', gap: 5, alignItems: 'center', fontSize: 12, color: T.text2, cursor: 'pointer' } },
504
+ h('input', { type: 'radio', name: 'qqs-f-' + f.k, checked: String(val) === o.v, onChange: function () { ctx.set(f.k, o.v) } }),
505
+ o.label)
506
+ }))
507
+ }
508
+ if (f.t === 'custom') return f.render ? f.render(ctx) : null
509
+ return null
510
+ }
511
+
512
+ /** 长说明默认折叠成一行,点开看全文(短说明直接显示) */
513
+ function HintBlock(props) {
514
+ var txt = String(props.text || '')
515
+ var [open, setOpen] = useState(false)
516
+ if (!txt) return null
517
+ if (txt.length <= 110) {
518
+ return h('div', { style: { fontSize: 12, color: T.text3, margin: '2px 0 4px', lineHeight: 1.7, whiteSpace: 'pre-wrap' } }, txt)
519
+ }
520
+ return h('div', { style: { margin: '2px 0 4px' } },
521
+ h('span', {
522
+ onClick: function () { setOpen(!open) },
523
+ style: { fontSize: 12, color: T.primary, cursor: 'pointer', userSelect: 'none' },
524
+ }, open ? '▾ 收起说明' : 'ⓘ 说明(点开)'),
525
+ open ? h('div', { style: { fontSize: 12, color: T.text3, marginTop: 3, lineHeight: 1.75, whiteSpace: 'pre-wrap' } }, txt) : null)
526
+ }
527
+
528
+ /** 分组介绍(长的收成一行 + 展开) */
529
+ function GroupDesc(props) {
530
+ var txt = String(props.text || '')
531
+ var [open, setOpen] = useState(false)
532
+ if (!txt) return null
533
+ var st = { fontSize: 12, color: T.text3, lineHeight: 1.75, background: T.cardHi, border: '1px solid ' + T.borderLight, borderRadius: T.radius, padding: '6px 10px', margin: '0 0 8px' }
534
+ if (txt.length <= 120) return h('div', { style: st }, txt)
535
+ return h('div', { style: st },
536
+ open ? txt : (txt.slice(0, 108) + '…'),
537
+ h('span', {
538
+ onClick: function () { setOpen(!open) },
539
+ style: { color: T.primary, cursor: 'pointer', marginLeft: 6, whiteSpace: 'nowrap' },
540
+ }, open ? '收起' : '展开'))
541
+ }
542
+
543
+ function FieldRow(props) {
544
+ var f = props.f, ctx = props.ctx, val = fieldVal(ctx.cfg, f)
545
+ var ctrl = renderControl(f, val, ctx)
546
+ var badge = props.sem ? h('span', { style: { fontSize: 11, color: T.primary, background: T.primaryBg, borderRadius: 999, padding: '0 6px', marginLeft: 6, whiteSpace: 'nowrap' } }, Math.round(props.sem * 100) + '% 匹配') : null
547
+ var lstyle = { fontSize: 13, color: T.text, lineHeight: 1.65 }
548
+ var body
549
+ if (f.t === 'note') return h('div', { style: { fontSize: 12, color: T.text2, margin: '12px 0 2px', lineHeight: 1.7, fontWeight: 600 } }, f.label)
550
+ if (f.t === 'custom') body = h('div', { style: { margin: '8px 0' } }, ctrl)
551
+ else if (f.t === 'bool') {
552
+ body = h('label', { style: { display: 'flex', alignItems: 'flex-start', gap: 8, margin: '6px 0', cursor: 'pointer' } },
553
+ h('span', { style: { paddingTop: 2, flexShrink: 0 } }, ctrl),
554
+ h('span', { style: lstyle }, f.label, badge))
555
+ } else if (f.t === 'num') {
556
+ body = h('label', { style: { display: 'flex', alignItems: 'flex-start', gap: 8, margin: '6px 0' } },
557
+ h('span', { style: Object.assign({}, lstyle, { flex: 1 }) }, f.label, badge),
558
+ ctrl)
559
+ } else if (f.t === 'str' || f.t === 'list') {
560
+ body = h('label', { style: { display: 'flex', alignItems: 'center', gap: 8, margin: '6px 0' } },
561
+ h('span', { style: Object.assign({}, lstyle, { flex: 1 }) }, f.label, badge),
562
+ ctrl)
563
+ } else {
564
+ body = h('div', { style: { margin: '8px 0' } },
565
+ f.label ? h('div', { style: lstyle }, f.label, badge) : null,
566
+ h('div', { style: { marginTop: 5 } }, ctrl))
567
+ }
568
+ return h('div', null, body, h(HintBlock, { text: f.hint }))
569
+ }
570
+
203
571
  function SettingsPage(props) {
204
572
  var close = props && props.close
205
573
  // 多账号: 当前操作账号的 settings 命名空间(主=im-qqbot 不带参, 其它=实例 id)
206
574
  var _acctNs = props && props.acct && props.acct.ns && props.acct.ns !== 'im-qqbot' ? String(props.acct.ns) : ''
207
575
  var [cfg, setCfg] = useState(null)
576
+ var [saved, setSaved] = useState(null) // 已保存快照(2026-10-10: 只提交 diff 用)
208
577
  var [rev, setRev] = useState(undefined)
209
578
  var [msg, setMsg] = useState('')
579
+ var [q, setQ] = useState('') // 搜索词(上栏: 精确)
580
+ var [q2, setQ2] = useState('') // 搜索词(下栏: 模糊/语义)
581
+ var [folded, setFolded] = useState({}) // 分组折叠
210
582
 
583
+ // 读取后只做**必要的类型规整**(渲染安全), 其余原样保留 ⇒ diff 才准
584
+ function tidy(v) {
585
+ var o = cloneCfg(v) || {}
586
+ if (typeof o.groupPrompt !== 'string') o.groupPrompt = DEFAULT_GROUP_PROMPT
587
+ if (o.outboundMode === 'active' || typeof o.outboundMode !== 'string') o.outboundMode = 'adaptive'
588
+ if (o.questionsMode !== 'blocking') o.questionsMode = 'async'
589
+ return o
590
+ }
211
591
  function load() {
212
592
  setMsg('加载中…')
213
593
  fetch(READ + (_acctNs ? '?ns=' + encodeURIComponent(_acctNs) : '')).then(function (r) { return r.json() }).then(function (d) {
214
594
  if (d && d.value) {
215
- var v = d.value
216
- var base = {
217
- behavior: v.behavior || {},
218
- sticker: v.sticker || {},
219
- injectRules: Array.isArray(v.injectRules) ? v.injectRules : [],
220
- imageHint: typeof v.imageHint === 'boolean' ? v.imageHint : undefined,
221
- messageReference: v.messageReference !== false,
222
- diagLog: v.diagLog === true,
223
- schedule: v.schedule && Array.isArray(v.schedule.targets) ? v.schedule : { targets: [] },
224
- // groupPrompt 必须读回来, 否则每次保存都会把它清空成 ''
225
- // undefined(从未设置)→ 显示默认守则; ''(用户明确清空)→ 保持空(无守则)
226
- groupPrompt: typeof v.groupPrompt === 'string' ? v.groupPrompt : DEFAULT_GROUP_PROMPT,
227
- enableApprovals: v.enableApprovals === true,
228
- approvalTimeoutMs: typeof v.approvalTimeoutMs === 'number' ? v.approvalTimeoutMs : 120000,
229
- outboundMode: (v.outboundMode === 'detail' || v.outboundMode === 'passive' || v.outboundMode === 'silent' || v.outboundMode === 'nothink' ? v.outboundMode : 'adaptive'),
230
- groupAdmin: { enabled: v.groupAdmin && v.groupAdmin.enabled === true, owners: Array.isArray(v.groupAdmin && v.groupAdmin.owners) ? v.groupAdmin.owners : [], manageGroup: v.groupAdmin && typeof v.groupAdmin.manageGroup === 'string' ? v.groupAdmin.manageGroup : '', watchJoinRequests: !!(v.groupAdmin && v.groupAdmin.watchJoinRequests), notifyInGroup: v.groupAdmin && v.groupAdmin.notifyInGroup !== false },
231
- }
232
- setCfg(base); setRev(d.revision); setMsg('')
595
+ var v = tidy(d.value)
596
+ setCfg(v); setSaved(cloneCfg(v)); setRev(d.revision); setMsg('')
233
597
  } else { setMsg('读取失败: ' + JSON.stringify(d)) }
234
598
  }).catch(function (e) { setMsg('读取异常: ' + e.message) })
235
599
  }
@@ -237,179 +601,119 @@ window.__ModuleLoader__.load({
237
601
 
238
602
  if (!cfg) return h('div', null, h('p', null, msg || '加载中…'))
239
603
 
240
- var gates = cfg.sticker.gates || {}
241
- var dbc = cfg.behavior.debounce || {} // 延迟聚合(后端未设置时缺省回落默认值)
242
- function setBehavior(p) { setCfg(function (c) { return { ...c, behavior: { ...c.behavior, ...p } } }) }
243
- function setGates(p) { setCfg(function (c) { return { ...c, sticker: { ...c.sticker, gates: { ...(c.sticker.gates || {}), ...p } } } }) }
244
- function setSticker(p) { setCfg(function (c) { return { ...c, sticker: { ...(c.sticker || {}), ...p } } }) }
245
- function setGroupAdmin(p) { setCfg(function (c) { return { ...c, groupAdmin: { ...(c.groupAdmin || { enabled: false, owners: [] }), ...p } } }) }
246
- function setRules(list) { setCfg(function (c) { return { ...c, injectRules: list } }) }
247
-
248
- function addRule() {
249
- var list = (cfg.injectRules || []).slice()
250
- list.push({
251
- id: 'rule-' + Date.now().toString(36),
252
- name: '',
253
- enabled: true,
254
- conditions: { hasImage: false, hasLink: false, contentRegex: '', contentKeywords: [], matchScope: 'any' },
255
- prompt: '',
256
- })
257
- setRules(list)
258
- }
259
- function updRule(i, r) { var list = cfg.injectRules.slice(); list[i] = r; setRules(list) }
260
- function rmRule(id) { setRules(cfg.injectRules.filter(function (r) { return r.id !== id })) }
604
+ function set(path, value) { setCfg(function (c) { return sPath(c, path, value) }) }
605
+ var ctx = { cfg: cfg, set: set, ns: _acctNs || undefined }
606
+ var patch = buildPatch(cfg, saved)
607
+ var dirtyN = Object.keys(patch).length
261
608
 
262
- // gpOverride: 传入字符串 = 用该守则保存(恢复默认用); undefined = 用当前编辑框内容
263
- function doSave(gpOverride) {
264
- setMsg('保存中…')
265
- var patch = {
266
- behavior: cfg.behavior,
267
- sticker: {
268
- gates: cfg.sticker.gates,
269
- autoTagEnabled: cfg.sticker.autoTagEnabled,
270
- visionCli: cfg.sticker.visionCli,
271
- },
272
- injectRules: cfg.injectRules,
273
- // 2026-09-30 修: 这两个开关原来漏写进 patch → 面板勾了/取消了都存不下去(读得到、写不回)
274
- imageHint: cfg.imageHint !== false,
275
- messageReference: cfg.messageReference !== false,
276
- diagLog: cfg.diagLog === true,
277
- // 定时唤醒(④)已并入「定时任务」页编辑; 这里原样带过不丢即可
278
- schedule: cfg.schedule && Array.isArray(cfg.schedule.targets) ? cfg.schedule : { targets: [] },
279
- groupPrompt: typeof gpOverride === 'string' ? gpOverride : (typeof cfg.groupPrompt === 'string' ? cfg.groupPrompt : ''),
280
- enableApprovals: cfg.enableApprovals === true,
281
- approvalTimeoutMs: typeof cfg.approvalTimeoutMs === 'number' ? cfg.approvalTimeoutMs : 120000,
282
- outboundMode: cfg.outboundMode === 'detail' || cfg.outboundMode === 'passive' || cfg.outboundMode === 'silent' || cfg.outboundMode === 'nothink' ? cfg.outboundMode : 'adaptive',
283
- groupAdmin: { enabled: cfg.groupAdmin && cfg.groupAdmin.enabled === true, owners: Array.isArray(cfg.groupAdmin && cfg.groupAdmin.owners) ? cfg.groupAdmin.owners : [], manageGroup: cfg.groupAdmin && typeof cfg.groupAdmin.manageGroup === 'string' ? cfg.groupAdmin.manageGroup : '', watchJoinRequests: !!(cfg.groupAdmin && cfg.groupAdmin.watchJoinRequests), notifyInGroup: cfg.groupAdmin && cfg.groupAdmin.notifyInGroup !== false },
284
- }
609
+ function doSave() {
610
+ if (!dirtyN) { setMsg('没有改动, 无需保存'); return } // 按钮不再 disabled ⇒ 点了一定有回应
611
+ setMsg('保存中…(提交 ' + dirtyN + ' 项)')
285
612
  fetch(UPDATE, {
286
613
  method: 'POST', headers: { 'content-type': 'application/json' },
287
614
  body: JSON.stringify({ patch: patch, expectedRevision: rev, ns: _acctNs || undefined }),
288
615
  }).then(function (r) { return r.json().catch(function () { return null }) }).then(function (d) {
289
616
  if (d && d.value) {
290
- var v2 = d.value
291
- setCfg({
292
- behavior: v2.behavior || {}, sticker: v2.sticker || {},
293
- injectRules: Array.isArray(v2.injectRules) ? v2.injectRules : [],
294
- imageHint: typeof v2.imageHint === 'boolean' ? v2.imageHint : undefined,
295
- messageReference: v2.messageReference !== false,
296
- diagLog: v2.diagLog === true,
297
- schedule: v2.schedule && Array.isArray(v2.schedule.targets) ? v2.schedule : { targets: [] },
298
- groupPrompt: typeof v2.groupPrompt === 'string' ? v2.groupPrompt : (typeof cfg.groupPrompt === 'string' ? cfg.groupPrompt : DEFAULT_GROUP_PROMPT),
299
- enableApprovals: v2.enableApprovals === true,
300
- approvalTimeoutMs: typeof v2.approvalTimeoutMs === 'number' ? v2.approvalTimeoutMs : 120000,
301
- outboundMode: (v2.outboundMode === 'detail' || v2.outboundMode === 'passive' || v2.outboundMode === 'silent' || v2.outboundMode === 'nothink' ? v2.outboundMode : 'adaptive'),
302
- groupAdmin: { enabled: v2.groupAdmin && v2.groupAdmin.enabled === true, owners: Array.isArray(v2.groupAdmin && v2.groupAdmin.owners) ? v2.groupAdmin.owners : [], manageGroup: v2.groupAdmin && typeof v2.groupAdmin.manageGroup === 'string' ? v2.groupAdmin.manageGroup : '', watchJoinRequests: !!(v2.groupAdmin && v2.groupAdmin.watchJoinRequests), notifyInGroup: v2.groupAdmin && v2.groupAdmin.notifyInGroup !== false },
303
- })
304
- setRev(d.revision); setMsg('已保存 ✓(live 生效)')
305
- }
306
- else if (d && d.error) { setMsg('保存失败: ' + d.error) }
617
+ var v2 = tidy(d.value)
618
+ setCfg(v2); setSaved(cloneCfg(v2)); setRev(d.revision)
619
+ setMsg('已保存 ✓(live 生效)')
620
+ } else if (d && d.error) { setMsg('保存失败: ' + d.error) }
307
621
  else { setMsg('保存失败(未知响应)') }
308
622
  }).catch(function (e) { setMsg('保存异常: ' + e.message) })
309
623
  }
310
- function save() { doSave(undefined) }
311
- // 恢复默认守则: 直接把"表情包礼仪"默认文本写回并保存(不需手动粘贴)
312
- function restoreDefault() { doSave(DEFAULT_GROUP_PROMPT) }
313
624
 
314
- var gh = gates.bannedGroups || []
315
- return h('div', { style: { maxWidth: 640 } },
316
- h('h2', null, 'QQ 机器人设置'),
317
- h('p', { style: { fontSize: 12, color: '#888' } }, '改完点「保存」立刻生效(不用重启)。看不懂的项放着别动就行;想恢复"不限制"就把数字填 0、勾选取消、列表清空。'),
318
-
319
- h('div', { style: sectionTitle }, '⓪ 群里她要一直记住的守则'),
320
- h('p', { style: { fontSize: 12, color: '#888' } }, '每轮回复都会带上的一段提醒(所有 QQ 群都生效,不碰你的人格设定)。默认是"表情包礼仪";想让她不主动发图,把这里清空即可(留空=没守则,不会自动填回)。'),
321
- h('textarea', { className: 'qqs-area',
322
- style: { ...fullStyle, minHeight: 90 },
323
- value: sv(cfg.groupPrompt),
324
- onChange: function (e) { setCfg(function (c) { return { ...c, groupPrompt: e.target.value } }) },
325
- }),
326
- h('div', { style: { margin: '0 0 6px' } },
327
- h('button', { className: 'qqs-btn', style: { padding: '2px 10px', fontSize: 12 }, onClick: restoreDefault }, '恢复默认守则(表情包礼仪)')),
625
+ // 搜索:上栏=精确(字面命中名称/说明/键名);下栏=模糊/语义(程序算相似度, 按分排序)
626
+ var qq = q.trim().toLowerCase()
627
+ var qs = q2.trim()
628
+ var sem = qs ? sqScores(qs) : null
629
+ var SEM_MIN = 0.1
630
+ function hitF(f) {
631
+ if (!qq) return true
632
+ if (f.t === 'note' || f.t === 'custom') return false
633
+ return (String(f.label || '') + ' ' + String(f.hint || '') + ' ' + String(f.k || '')).toLowerCase().indexOf(qq) >= 0
634
+ }
635
+ function semOf(f) { return (sem && f.k) ? (sem[f.k] || 0) : 0 }
636
+ function semGroup(g) {
637
+ if (!sem) return 0
638
+ return sqCos(sqVec(qs), sqVec(String(g.title || '') + ' ' + String(g.kw || '') + ' ' + String(g.desc || '')))
639
+ }
640
+ function hitText(t) { return !qq || String(t || '').toLowerCase().indexOf(qq) >= 0 }
641
+ function jump(id) {
642
+ var el = document.getElementById('qqs-g-' + id)
643
+ if (el && el.scrollIntoView) el.scrollIntoView({ block: 'start', behavior: 'smooth' })
644
+ }
328
645
 
329
- h('div', { style: sectionTitle }, '① 多久回一次消息'),
330
- h('p', { style: { fontSize: 12, color: '#888' } }, '控制她回复的速度。全填 0 = 来一条回一条(不限制)。'),
331
- h('div', { style: boxStyle },
332
- NumRow({ label: '群里没人 @ 她时,隔几秒才回一次(0=每条都回)', value: cfg.behavior.freeIntervalSec, onChange: function (v) { setBehavior({ freeIntervalSec: v }) } }),
333
- NumRow({ label: '有人 @ 她时,两次回复至少隔几秒(0=随叫随到)', value: cfg.behavior.mentionIntervalSec, onChange: function (v) { setBehavior({ mentionIntervalSec: v }) } }),
334
- NumRow({ label: '私聊里隔几秒回一次(0=不限制)', value: cfg.behavior.directIntervalSec, onChange: function (v) { setBehavior({ directIntervalSec: v }) } }),
335
- h('div', { style: { fontSize: 12, color: '#666', margin: '10px 0 2px' } }, '出站方式(连发消息 QQ 端丢失时切主动):'), h('div', { style: { display: 'flex', gap: 14, alignItems: 'center', flexWrap: 'wrap' } }, ['adaptive', 'detail', 'passive', 'silent', 'nothink'].map(function (m) { var cur = (cfg.outboundMode || 'adaptive'); if (cur === 'active') cur = 'adaptive'; return h('label', { style: { display: 'inline-flex', gap: 5, alignItems: 'center', fontSize: 12, color: '#333', cursor: 'pointer' } }, h('input', { type: 'radio', name: 'qqs-outbound', checked: cur === m, onChange: function () { setCfg(function (c) { return Object.assign({}, c, { outboundMode: m }) }) } }), m === 'adaptive' ? '适配主动(推荐默认)' : (m === 'detail' ? '详细主动(连工具调用一起推)' : (m === 'passive' ? '被动(只回最后一句)' : (m === 'silent' ? '完全不出站(静默)' : '完全不思考(QQ入站不唤醒,仅设置页)')))) })), h('div', { style: { fontSize: 12, color: '#888' } }, '适配主动=刚收到真人消息时前5条带引用回你, 第6条起自动转独立新消息(连发不被QQ吞); 一段时间没新消息的主动推送(定时等)也走独立消息。被动=始终回你那条(连发约4~5条后被QQ吞)。完全不出站=照常思考但不向QQ发任何回复(AI 可用工具切回)。完全不思考=QQ入站不唤醒AI, 消息只记录(仅本页可开; 唤醒请发 /outmode adaptive)。保存即热更新, 不用重启。'), h('div', { style: { fontSize: 12, color: '#666', margin: '10px 0 2px' } }, '延迟聚合(另一套机制,和上面冷却不冲突): 她收到消息先等一小会儿, 把连发的话攒一起综合回, 免得只回第一句。'),
336
- BoolRow({ label: '开启延迟聚合(不勾=回到来一条回一条)', value: dbc.enabled !== false, onChange: function (v) { setBehavior({ debounce: { ...dbc, enabled: v } }) } }),
337
- NumRow({ label: '对方停口几秒后她才开口(默认3;0=不停顿)', value: dbc.silenceSec != null ? dbc.silenceSec : 3, onChange: function (v) { setBehavior({ debounce: { ...dbc, silenceSec: v } }) } }),
338
- NumRow({ label: '攒满几条立即开口,不等对方停(默认10)', value: dbc.maxMsgs != null ? dbc.maxMsgs : 10, onChange: function (v) { setBehavior({ debounce: { ...dbc, maxMsgs: v } }) } }),
339
- BoolRow({ label: '有人 @ 她时也走延迟(不勾=@到秒回)', value: dbc.mentionDelayed !== false, onChange: function (v) { setBehavior({ debounce: { ...dbc, mentionDelayed: v } }) } }),
340
- h('div', { style: { fontSize: 12, color: '#666', margin: '10px 0 2px' } }, '她正在思考/输出时, 群里又来了新消息怎么办(同宿主「繁忙时的发送行为」那两项):'),
341
- h('div', { style: { display: 'flex', gap: 14, alignItems: 'center', flexWrap: 'wrap' } },
342
- ['queue', 'interject'].map(function (m) {
343
- var cur = dbc.busySendMode === 'interject' ? 'interject' : 'queue'
344
- return h('label', { style: { display: 'inline-flex', gap: 5, alignItems: 'center', fontSize: 12, color: '#333', cursor: 'pointer' } },
345
- h('input', { type: 'radio', name: 'qqs-busy', checked: cur === m, onChange: function () { setBehavior({ debounce: { ...dbc, busySendMode: m } }) } }),
346
- m === 'queue' ? '排队发送(默认: 攒着, 等这轮回完再一起看)' : '插话发送(在这回的轮次之间立刻插进去)')
347
- })),
348
- h('div', { style: { fontSize: 12, color: '#888' } }, '排队=她正忙时消息攒在窗口里, 等这轮回合结束才整批送进去(带"上次回复前群友所发"的提示)。插话=照样先聚合(静默秒数/条数上限都不变, 不会一条条吵醒她), 但不等回合结束, 直接在回合内的轮次之间插进去 —— 每次插话都带一句"先做完当前任务、别被带偏"的轻提示。注意: 若她这一步是纯文字收尾(后面没有工具轮次), 插话会留到下次唤醒才被读到, 效果和排队一样(宿主 inject 的固有语义)。保存即热更新, 不用重启。')),
646
+ var visible = FGROUPS.map(function (g) {
647
+ var fields = (g.fields || []).filter(function (f) {
648
+ var special = (f.t === 'note' || f.t === 'custom')
649
+ if (special) return (!qq && !sem) // 一搜索,note/custom 就不参与
650
+ if (qq && !hitF(f)) return false // 上栏:必须字面命中
651
+ if (sem && semOf(f) < SEM_MIN) return false // 下栏:相似度得够
652
+ return true
653
+ })
654
+ if (sem) fields = fields.slice().sort(function (a, b) { return semOf(b) - semOf(a) })
655
+ var show = (!qq && !sem) || fields.length > 0 || (qq && hitText(g.title + ' ' + g.desc)) || (sem && semGroup(g) >= 0.15)
656
+ if (!show) return null
657
+ var isFolded = !!folded[g.id] && !qq && !sem
658
+ var header = h('div', {
659
+ onClick: function () { setFolded(function (m) { var n = Object.assign({}, m); n[g.id] = !m[g.id]; return n }) },
660
+ style: { display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', padding: '10px 14px', borderBottom: isFolded ? 'none' : '1px solid ' + T.borderLight, userSelect: 'none' },
661
+ },
662
+ h('span', { style: { fontSize: 14, fontWeight: 700, color: T.text } }, g.icon + ' ' + g.title),
663
+ h('span', { style: { flex: 1 } }),
664
+ !qq && (g.fields || []).length > 0 ? h('span', { style: { fontSize: 12, color: T.text3 } }, ((g.fields || []).length) + ' 项') : null,
665
+ h('span', { style: { fontSize: 12, color: T.text3 } }, isFolded ? '▸' : '▾'))
666
+ return h('div', { key: g.id, id: 'qqs-g-' + g.id, style: { background: T.card, border: '1px solid ' + T.border, borderRadius: T.radiusLg, marginBottom: 12, overflow: 'hidden' } },
667
+ header,
668
+ isFolded ? null : h('div', { style: { padding: '10px 14px 14px' } },
669
+ h(GroupDesc, { text: g.desc }),
670
+ fields.map(function (f, i) { return h(FieldRow, { key: (f.k || 'x') + '#' + i, f: f, ctx: ctx, sem: sem ? semOf(f) : 0 }) })))
671
+ }).filter(Boolean)
349
672
 
350
- h('div', { style: sectionTitle }, '② 发表情包的限制(全默认不限制)'),
351
- h('p', { style: { fontSize: 12, color: '#888' } }, '防止她聊天时表情包刷屏;下面两项是"她主动发图"时才用——群里很热闹才发,冷清就憋着。不想管就保持全 0,也别勾总开关。'),
352
- h('div', { style: boxStyle },
353
- BoolRow({ label: '开启表情包限制(不勾=完全不限制)', value: gates.enabled, onChange: function (v) { setGates({ enabled: v }) } }),
354
- NumRow({ label: '她一次回复最多带几张图(0=不限)', value: gates.perTurnMax, onChange: function (v) { setGates({ perTurnMax: v }) } }),
355
- NumRow({ label: '每隔"一小段时间"最多发几张(0=不限)', value: gates.maxPerWindow, onChange: function (v) { setGates({ maxPerWindow: v }) } }),
356
- NumRow({ label: '上面说的"一小段时间"是多长(秒,如600=10分钟)', value: gates.perWindowSec, onChange: function (v) { setGates({ perWindowSec: v }) } }),
357
- NumRow({ label: '每个群一天最多发几张表情包(0=不限)', value: gates.dailyBudgetPerGroup, onChange: function (v) { setGates({ dailyBudgetPerGroup: v }) } }),
358
- NumRow({ label: '同一张图多久内不许重复发(小时,0=不查)', value: gates.dupTTLHours, onChange: function (v) { setGates({ dupTTLHours: v }) } }),
359
- NumRow({ label: '看"最近"多长时间的聊天来判热闹(3600=最近1小时)', value: gates.activityWindowSec, onChange: function (v) { setGates({ activityWindowSec: v }) } }),
360
- NumRow({ label: '最近这段时间群消息达到几条,她才肯主动发图(0=不管冷不冷都发)', value: gates.activityMinMsgs, onChange: function (v) { setGates({ activityMinMsgs: v }) } }),
361
- StrRow({ label: '禁止发图的群(填群ID,逗号分隔;一般不用填)', value: gh.join(','), wide: true, onChange: function (v) { setGates({ bannedGroups: v.split(/[,,]/).map(function (s) { return s.trim() }).filter(Boolean) }) } })),
362
- h('div', { style: boxStyle },
363
- BoolRow({ label: '新图自动后台识图打标(会花视觉额度;不勾=全靠人工手动补)', value: !!cfg.sticker.autoTagEnabled, onChange: function (v) { setSticker({ autoTagEnabled: v }) } }),
364
- StrRow({ label: '视觉引擎命令(高级;留空自动探测)', value: sv(cfg.sticker.visionCli), wide: true, onChange: function (v) { setSticker({ visionCli: v }) } })),
673
+ var chips = h('div', { style: { display: 'flex', gap: 6, flexWrap: 'wrap', margin: '0 0 10px' } },
674
+ FGROUPS.map(function (g) {
675
+ return h('span', {
676
+ key: g.id, onClick: function () { jump(g.id) },
677
+ style: { fontSize: 12, color: T.text2, background: T.cardHi, border: '1px solid ' + T.border, borderRadius: 999, padding: '3px 10px', cursor: 'pointer', whiteSpace: 'nowrap' },
678
+ }, GSHORT[g.id] || (g.icon + ' ' + g.title))
679
+ }))
365
680
 
366
- h('div', { style: sectionTitle }, '③ 自定义小提醒(高级)'),
367
- h('p', { style: { fontSize: 12, color: '#888' } }, '当群友发的消息满足下面条件,就偷偷给机器人加一条要照做的提醒。比如:勾上"含链接",提醒写"引用前先核实"。'),
368
- (cfg.injectRules || []).map(function (r, i) {
369
- return h(RuleEditor, { key: r.id, rule: r, onChange: function (nr) { updRule(i, nr) }, onRemove: rmRule })
681
+ var toolbar = h('div', { style: { margin: '0 0 10px' } },
682
+ h('div', { style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', marginBottom: 6 } },
683
+ h('span', { style: { fontSize: 11, color: T.text3, flexShrink: 0 } }, '精确'),
684
+ h('input', {
685
+ className: 'qqs-inp', placeholder: '🔍 精确搜索:字面匹配 名称 / 说明 / 键名(如"间隔""审批")',
686
+ style: { flex: 1, minWidth: 180, padding: '5px 10px', border: '1px solid ' + T.border, borderRadius: T.radius, background: T.card },
687
+ value: q, onChange: function (e) { setQ(e.target.value) },
370
688
  }),
371
- h('button', { className: 'qqs-btn', onClick: addRule }, '+ 添加一条提醒'),
372
- h('div', { style: boxStyle },
373
- BoolRow({ label: '图片消息自动提示 AI 看图(内置兜底; 不勾=不再注入「请把URL传给识图工具」那条)', value: cfg.imageHint !== false, onChange: function (v) { setCfg(function (c) { return { ...c, imageHint: v } }) } }),
374
- BoolRow({ label: '引用消息(默认开): 入站消息带短消息号(本地台账索引,省token) + 引用消息附原文; AI 用 [rf:短号] 引用对方消息', value: cfg.messageReference !== false, onChange: function (v) { setCfg(function (c) { return { ...c, messageReference: v } }) } })),
375
-
376
- h('div', { style: sectionTitle }, '③.5 诊断日志(排查用)'),
377
- h('p', { style: { fontSize: 12, color: '#888' } }, '默认关。开了才会把排查用的诊断日志写进 {DSH_HOME|~/.dsh} 下的 *.log(单文件 2MB 自动轮转,只留 1 份旧档)。平时别开——它会持续占盘;出问题时再开,排查完可以关掉、并把那些 .log 删掉。'),
378
- BoolRow({ label: '诊断日志落盘(默认关): 写 ~/.dsh/*.log 便于离线排查', value: cfg.diagLog === true, onChange: function (v) { setCfg(function (c) { return Object.assign({}, c, { diagLog: v }) }) } }),
379
- h('div', { style: sectionTitle }, '④ 定时唤醒'),
380
- h('p', { style: { fontSize: 12, color: '#888' } }, '已合并到「定时任务」页(顶部 tab)一起编辑——到点主动开口的群/人分组,与她答应你的定时提醒,都在那边管理。'),
381
-
382
- h('div', { style: sectionTitle }, '⑤ QQ 远程审批(在 QQ 里放行 dsh 权限申请)'),
383
- h('p', { style: { fontSize: 12, color: '#888' } }, '机器人的工具要动"工作区外"的东西时,dsh 会申请权限。开启后审批请求直接发到你的 QQ(私聊/群聊看你从哪发起),回 /approve 验证码 放行、/deny 拒绝——只放行这一次,验证码一次性,只有你能批。'),
384
- h('div', { style: boxStyle },
385
- BoolRow({ label: '开启 QQ 远程审批(不勾=保持默认审批方式)', value: cfg.enableApprovals === true, onChange: function (v) { setCfg(function (c) { return { ...c, enableApprovals: v } }) } }),
386
- NumRow({ label: '审批等待秒数(超时自动拒绝;默认120)', value: Math.round((cfg.approvalTimeoutMs || 120000) / 1000), onChange: function (v) { setCfg(function (c) { return { ...c, approvalTimeoutMs: v * 1000 } }) } })),
387
-
388
- h('div', { style: sectionTitle }, '⑥ 提问卡片(她问人时要不要在旁边干等)'),
389
- h('p', { style: { fontSize: 12, color: '#888' } }, '她调用 ask_user_question 问你或群友时怎么等答案。后台提问=卡片发出去她就能接着聊天/干活, 对方答完之后答案会作为一条"提问卡片·回执"自动送回她的会话(她正忙→插进当前回合不打断; 她闲着→叫醒她接着处理), 超时没答也会收到一条"超时未答"的回执。阻塞等待=旧行为, 她的这一回合一直挂到对方作答或超时, 期间收不了新消息。'),
390
- h('div', { style: boxStyle },
391
- ['async', 'blocking'].map(function (m) {
392
- var cur = cfg.questionsMode === 'blocking' ? 'blocking' : 'async'
393
- return h('label', { style: { display: 'inline-flex', gap: 5, alignItems: 'center', fontSize: 12, color: '#333', cursor: 'pointer', marginRight: 16 } },
394
- h('input', { type: 'radio', name: 'qqs-qmode', checked: cur === m, onChange: function () { setCfg(function (c) { return { ...c, questionsMode: m } }) } }),
395
- m === 'async' ? '后台提问(推荐默认: 不卡她)' : '阻塞等待(旧行为: 她会卡住)')
396
- })),
397
-
398
- h('div', { style: { ...card, margin: '10px 0', padding: '12px 16px' } },
399
- h('div', { style: { fontWeight: 700, fontSize: 13, marginBottom: 2 } }, '⚙ 群管理基础设置'),
400
- h('p', { style: { fontSize: 12, color: T.text3, margin: '0 0 6px' } }, '开启后 QQ 会话里可用群管理工具; 踢人/成员列表等官方未开放能力会在下方 Tab 里提示等待公测。'),
401
- BoolRow({ label: '开启 QQ 群管理(不勾=群管理工具不可用)', value: cfg.groupAdmin && cfg.groupAdmin.enabled === true, onChange: function (v) { setGroupAdmin({ enabled: v }) } }),
402
- StrRow({ label: '允许操作的主人 openid(逗号分隔,可留空=不校验)', value: (cfg.groupAdmin && Array.isArray(cfg.groupAdmin.owners) ? cfg.groupAdmin.owners : []).join(','), wide: true, onChange: function (v) { setGroupAdmin({ owners: v.split(/[,,]/).map(function (s) { return s.trim() }).filter(Boolean) }) } }),
403
- StrRow({ label: '对话内默认管理群 group_openid(web 对话时群工具用它; QQ 群会话自动用当前群)', value: (cfg.groupAdmin && typeof cfg.groupAdmin.manageGroup === 'string' ? cfg.groupAdmin.manageGroup : ''), wide: true, onChange: function (v) { setGroupAdmin({ manageGroup: v.trim() }) } }),
404
- BoolRow({ label: '实时接收"入群申请"事件并自动提醒(勾选后需重启才生效)', value: !!(cfg.groupAdmin && cfg.groupAdmin.watchJoinRequests), onChange: function (v) { setGroupAdmin({ watchJoinRequests: v }) } }),
405
- BoolRow({ label: '收到入群申请时在该群内发提醒消息', value: !(cfg.groupAdmin && cfg.groupAdmin.notifyInGroup === false), onChange: function (v) { setGroupAdmin({ notifyInGroup: v }) } })),
406
- h(GroupAdminPanel, { key: 'ga-' + (_acctNs || 'main'), ns: _acctNs || undefined }),
689
+ q ? h('span', { onClick: function () { setQ('') }, style: { fontSize: 12, color: T.text3, cursor: 'pointer' } }, '清空') : null,
690
+ dirtyN
691
+ ? h('span', { style: { fontSize: 12, color: T.warn, fontWeight: 700, whiteSpace: 'nowrap' } }, '● 有未保存改动(' + dirtyN + ')')
692
+ : h('span', { style: { fontSize: 12, color: T.success, whiteSpace: 'nowrap' } }, '✓ 已同步'),
693
+ msg ? h('span', { style: { fontSize: 12, color: T.text2, whiteSpace: 'nowrap' } }, msg) : null,
694
+ h(SButton, { variant: 'primary', onClick: doSave }, dirtyN ? ('保存(' + dirtyN + ')') : '保存'),
695
+ h(SButton, { variant: 'ghost', onClick: load }, '放弃修改')),
696
+ h('div', { style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' } },
697
+ h('span', { style: { fontSize: 11, color: T.text3, flexShrink: 0 } }, '模糊'),
698
+ h('input', {
699
+ className: 'qqs-inp', placeholder: '🧠 语义搜索:说人话也行("回复太慢""图刷屏""踢人""进群申请")',
700
+ style: { flex: 1, minWidth: 180, padding: '5px 10px', border: '1px solid ' + T.border, borderRadius: T.radius, background: T.card },
701
+ value: q2, onChange: function (e) { setQ2(e.target.value) },
702
+ }),
703
+ q2 ? h('span', { onClick: function () { setQ2('') }, style: { fontSize: 12, color: T.text3, cursor: 'pointer' } }, '清空') : null,
704
+ h('span', { style: { fontSize: 11, color: T.text3, whiteSpace: 'nowrap' } }, '程序算的相似度 · 本地即时')))
407
705
 
408
- h('div', { style: { margin: '12px 0' } },
409
- h('button', { className: 'qqs-btn', style: { marginRight: 8 }, onClick: save }, '保存'),
410
- h('button', { className: 'qqs-btn', onClick: load }, '放弃修改(重新读取)')),
411
- msg ? h('p', { style: { fontSize: 13, color: '#2f9e44' } }, msg) : null,
412
- close ? h('button', { className: 'qqs-btn', onClick: close, style: { float: 'right' } }, '完成') : null)
706
+ return h('div', { style: { maxWidth: 720 } },
707
+ h('h2', { style: { margin: '0 0 6px' } }, 'QQ 机器人设置'),
708
+ h('p', { style: { fontSize: 12, color: T.text3, margin: '0 0 10px' } }, '改完点「保存」立刻生效(不用重启)。看不懂的项放着别动就行;想恢复"不限制"就把数字填 0、勾选取消、列表清空。只提交你改过的字段——没碰的项不会被动。'),
709
+ toolbar,
710
+ chips,
711
+ visible.length ? visible : h(Empty, { icon: '🔍', text: '没有匹配「' + q + '」的设置项' }),
712
+ h('div', { style: { margin: '14px 0', display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' } },
713
+ h(SButton, { variant: 'primary', onClick: doSave }, dirtyN ? ('保存(' + dirtyN + ' 项改动)') : '保存'),
714
+ h(SButton, { variant: 'ghost', onClick: load }, '放弃修改(重新读取)'),
715
+ msg ? h('span', { style: { fontSize: 13, color: T.text2 } }, msg) : null,
716
+ close ? h(SButton, { variant: 'ghost', onClick: close }, '完成') : null))
413
717
  }
414
718
 
415
719
  // ── ⑥ QQ 群管理(单开大卡片; P3+ 设计系统版: 横排 Tab + 状态条 + 表格 + 空状态 + 预留接口位) ──