dsh-qq-link 1.4.8 → 1.5.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.
package/lib/client.js CHANGED
@@ -1,689 +1,701 @@
1
- // DSH「通到 QQ」· 网页半边
2
- //
3
- // 在 **DSH 设置** 左侧栏加一项「通到 QQ」。1.5.0 起分六个页签:
4
- // 总览 / 绑定 / 消息 / 空间 / 门槛 / 更新
5
- // 绑定是核心:一行 = 一个聊天(私聊 或 群)→ 一间 DSH 会话,各回各的,不串。
6
- //
7
- // ⚠ 这里不碰会话、不发消息,只是往宿主那几条 /dsh-qq-bridge/* 路由上读写。
8
-
9
- window.__ModuleLoader__.load({
10
- id: 'dsh-qq-link',
11
- factory: (require) => {
12
- var module = { exports: {} }
13
- var exports = module.exports
14
-
15
- const React = require('react')
16
- const inject = ['slots']
17
-
18
- const B = '/dsh-qq-bridge'
19
- const get = (p) => fetch(B + p, { cache: 'no-store' }).then((r) => r.json()).catch(() => null)
20
- const post = (p, body) =>
21
- fetch(B + p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body || {}) })
22
- .then((r) => r.json())
23
- .catch(() => null)
24
-
25
- const h = React.createElement
26
-
27
- const S = {
28
- wrap: {
29
- padding: '12px 14px 24px',
30
- fontSize: 13,
31
- lineHeight: 1.7,
32
- background: 'var(--dsw-alias-bg-layer-1, rgba(255,255,255,.92))',
33
- border: '1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.08))',
34
- borderRadius: 12,
35
- },
36
- h: { fontSize: 14, fontWeight: 600, margin: '16px 0 8px' },
37
- dim: { opacity: 0.62, fontSize: 12 },
38
- card: {
39
- border: '1px solid var(--dsw-alias-border-l2)',
40
- borderRadius: 8,
41
- padding: '10px 12px',
42
- marginBottom: 10,
43
- background: 'var(--dsw-alias-bg-layer-1)',
44
- },
45
- row: { display: 'flex', gap: 10, alignItems: 'flex-start', marginBottom: 8, flexWrap: 'wrap' },
46
- lab: { minWidth: 190, paddingTop: 3, fontSize: 12.5 },
47
- ctl: { flex: '1 1 260px', minWidth: 220 },
48
- hint: { opacity: 0.6, fontSize: 11.5, marginTop: 2 },
49
- input: {
50
- border: '1px solid var(--dsw-alias-border-l2)',
51
- borderRadius: 6,
52
- padding: '4px 8px',
53
- background: 'transparent',
54
- color: 'inherit',
55
- fontSize: 12.5,
56
- width: '100%',
57
- boxSizing: 'border-box',
58
- },
59
- small: {
60
- border: '1px solid var(--dsw-alias-border-l2)',
61
- borderRadius: 6,
62
- padding: '4px 8px',
63
- background: 'transparent',
64
- color: 'inherit',
65
- fontSize: 12.5,
66
- width: 140,
67
- boxSizing: 'border-box',
68
- },
69
- wide: {
70
- border: '1px solid var(--dsw-alias-border-l2)',
71
- borderRadius: 6,
72
- padding: '4px 8px',
73
- background: 'transparent',
74
- color: 'inherit',
75
- fontSize: 12.5,
76
- width: '100%',
77
- boxSizing: 'border-box',
78
- },
79
- ok: { color: 'var(--dsw-alias-state-business-primary)', fontSize: 12 },
80
- 已同步: { margin: '6px 0 10px', fontSize: 12, color: 'var(--dsw-alias-state-business-primary)' },
81
- 未保存: { margin: '6px 0 10px', fontSize: 12, color: '#c0392b', fontWeight: 600 },
82
- 悬浮保存条: {
83
- position: 'sticky',
84
- bottom: 0,
85
- marginTop: 12,
86
- display: 'flex',
87
- alignItems: 'center',
88
- gap: 12,
89
- flexWrap: 'wrap',
90
- padding: '8px 12px',
91
- borderRadius: 10,
92
- border: '1px solid var(--dsw-alias-border-l2)',
93
- background: 'var(--dsw-alias-bg-layer-1, rgba(255,255,255,.95))',
94
- boxShadow: '0 -6px 18px rgba(0,0,0,.18)',
95
- zIndex: 5,
96
- },
97
- btn: {
98
- border: '1px solid var(--dsw-alias-border-l2)',
99
- borderRadius: 6,
100
- padding: '4px 12px',
101
- background: 'transparent',
102
- color: 'inherit',
103
- fontSize: 12,
104
- cursor: 'pointer',
105
- },
106
- btnOn: {
107
- border: '1px solid var(--dsw-alias-state-business-primary)',
108
- borderRadius: 6,
109
- padding: '4px 12px',
110
- background: 'var(--dsw-alias-state-business-primary)',
111
- color: '#fff',
112
- fontSize: 12,
113
- cursor: 'pointer',
114
- },
115
- tabBar: { display: 'flex', gap: 6, flexWrap: 'wrap', margin: '10px 0 12px' },
116
- 日志行: { fontFamily: 'ui-monospace, Consolas, monospace', fontSize: 11.5, whiteSpace: 'pre-wrap' },
117
- 表头: { display: 'grid', gridTemplateColumns: '70px 130px 1fr 56px 64px 40px', gap: 6, alignItems: 'center', marginBottom: 4 },
118
- 表格行: { display: 'grid', gridTemplateColumns: '70px 130px 1fr 56px 64px 40px', gap: 6, alignItems: 'center', marginBottom: 6 },
119
- 表头字: { fontSize: 11.5, opacity: 0.65 },
120
- 空间表头: { display: 'grid', gridTemplateColumns: '120px 140px 1fr 56px', gap: 6, alignItems: 'center', marginBottom: 4 },
121
- 空间行: { display: 'grid', gridTemplateColumns: '120px 140px 1fr 56px', gap: 6, alignItems: 'center', marginBottom: 6 },
122
- }
123
-
124
- const 提示 = (文本) => h('div', { style: S.hint }, 文本)
125
- const 行 = (标签, 控件, 说明) =>
126
- h('div', { style: S.row }, h('div', { style: S.lab }, 标签), h('div', { style: S.ctl }, 控件, 说明 ? 提示(说明) : null))
127
- const 按钮 = (文字, onClick, 亮, 关) =>
128
- h('button', { type: 'button', style: 亮 ? S.btnOn : S.btn, onClick, disabled: !!关, title: 文字 }, 文字)
129
- const 勾 = (值, onChange) => h('input', { type: 'checkbox', checked: !!值, onChange: (e) => onChange(e.target.checked) })
130
- const 短id = (id) => {
131
- const s = String(id)
132
- return s.length <= 8 ? s : s.slice(-8)
133
- }
134
-
135
- const 页签们 = ['总览', '绑定', '消息', '空间', '门槛', '更新']
136
-
137
- function Section() {
138
- const [态, 设态] = React.useState(null)
139
- const [脏, 设脏] = React.useState(false)
140
- const [忙, 设忙] = React.useState(false)
141
- const [说, 设说] = React.useState('')
142
- const [更新, 设更新] = React.useState(null)
143
- const [更新忙, 设更新忙] = React.useState(false)
144
- const [页, 设页] = React.useState('总览')
145
- const [asr, 设asr] = React.useState(null)
146
- const [asr忙, 设asr忙] = React.useState(false)
147
- const [人设行, 设人设行] = React.useState(null) // 哪一行展开了「单独的人设」
148
- const [人设忙, 设人设忙] = React.useState(false)
149
- const [人设说, 设人设说] = React.useState('')
150
-
151
- React.useEffect(() => {
152
- 拉()
153
- get('/update').then((r) => r && 设更新(r)).catch(() => {})
154
- get('/asr-status').then((r) => r && 设asr(r)).catch(() => {})
155
- }, [])
156
- // 装依赖的时候每 2 秒看一眼进度(装完就停)
157
- React.useEffect(() => {
158
- if (!asr || !asr.进行中) return undefined
159
- const t = setInterval(() => {
160
- get('/asr-status').then((r) => { if (r) 设asr(r) }).catch(() => {})
161
- }, 2000)
162
- return () => clearInterval(t)
163
- }, [asr && asr.进行中])
164
-
165
- const 拉 = async () => {
166
- const s = await get('/state')
167
- if (s) 设态(s)
168
- }
169
- // ⭐ 1.1.2:人设「现在就生效」—— 不用等 QQ 那边来一条消息
170
- const 人设现在生效 = async () => {
171
- 设人设忙(true)
172
- 设人设说('')
173
- const r = await post('/persona-apply', {})
174
- 设人设忙(false)
175
- if (!r || !r.ok) {
176
- 设人设说('✗ ' + ((r && r.说) || '没反应'))
177
- return
178
- }
179
- const 行们 = Array.isArray(r.结果们) ? r.结果们 : []
180
- if (!行们.length) {
181
- 设人设说('还没有绑定任何聊天')
182
- return
183
- }
184
- const 不顺 = 行们.filter((x) => !x.生效)
185
- 设人设说((r.生效 ? '✓ ' + r.生效 + ' 间会话生效' : '⌀ 没有生效的会话')
186
- + (不顺.length ? ';' + 不顺.map((x) => x.聊天 + ':' + x.说).join(';') : ''))
187
- }
188
- const 改 = (k, v) => {
189
- 设态((旧) => (旧 ? { ...旧, 配置: { ...(旧.配置 || {}), [k]: v } } : 旧))
190
- 设脏(true)
191
- }
192
- // —— 绑定:整份数组替换(宿主那边会再规整一遍、并挡掉重复)——
193
- const 绑定们 = () => {
194
- const a = 态 && 态.配置 && 态.配置.绑定
195
- return Array.isArray(a) ? a : []
196
- }
197
- const 设绑定 = (下一份) => 改('绑定', 下一份)
198
- const 改绑定 = (i, 补) => 设绑定(绑定们().map((x, j) => (j === i ? { ...x, ...补 } : x)))
199
- const 加绑定 = () => 设绑定([...绑定们(), { 类型: '群', 号码: '', 会话: '', 称呼: '', 门槛: false }])
200
- const 删绑定 = (i) => 设绑定(绑定们().filter((_, j) => j !== i))
201
-
202
- const 存 = async () => {
203
- 设忙(true)
204
- const s = await post('/save', (态 && 态.配置) || {})
205
- 设忙(false)
206
- if (s && s.ok) {
207
- 设态(s)
208
- 设脏(false)
209
- 设说(s.说 || '保存好了')
210
- } else {
211
- 设说((s && s.说) || '保存失败')
212
- }
213
- }
214
- const 装语音 = async () => {
215
- 设asr忙(true)
216
- const r = await post('/asr-install', {})
217
- 设说((r && r.说) || '开始了')
218
- 设asr忙(false)
219
- const s2 = await get('/asr-status')
220
- if (s2) 设asr(s2)
221
- }
222
- const 填语音命令 = async () => {
223
- const r = await get('/asr-status')
224
- if (r) 设asr(r)
225
- if (r && r.就绪 && r.命令) {
226
- 改('语音转写', true)
227
- 改('转写命令', r.命令)
228
- 设说(String(配置.语音模型目录 || '').trim()
229
- ? '命令已填好(记得点保存)'
230
- : '命令填好了,但**还没填「语音模型目录」** —— 转写会报「没给模型目录」,填完再点一次这个按钮')
231
- } else if (r && !r.就绪) 设说('还没装好,先点「自动装依赖」')
232
- else 设说('读不到状态')
233
- }
234
-
235
- const 试一句 = async () => {
236
- 设忙(true)
237
- const s = await post('/test-send', {})
238
- 设忙(false)
239
- 设说((s && s.说) || '没反应')
240
- await 拉()
241
- }
242
-
243
- if (!态) return h('div', { style: S.wrap }, h('div', { style: S.h }, '通到 QQ'), h('div', { style: S.dim }, '读取中…'))
244
-
245
- const 配置 = 态.配置 || {}
246
- const 目标们 = Array.isArray(态.活着的会话) ? 态.活着的会话 : []
247
- const NapCat = 态.NapCat状态 || null
248
- const 日志 = Array.isArray(态.日志) ? 态.日志 : []
249
- const 绑定 = 绑定们()
250
-
251
- const 会话选项 = (值, onChange) =>
252
- h('select', { style: S.wide, value: 值 || '', onChange: (e) => onChange(e.target.value) },
253
- h('option', { value: '' }, '(选一间会话)'),
254
- ...目标们.map((s) => h('option', { value: s.id }, (s.标题 || '…' + 短id(s.id)) + ' · ' + 短id(s.id))),
255
- 值 && !目标们.some((s) => s.id === 值)
256
- ? h('option', { value: 值 }, '(当前:' + 短id(值) + ')')
257
- : null)
258
-
259
- const 页签 = h('div', { style: S.tabBar },
260
- ...页签们.map((名) => 按钮(名, () => 设页(名), 页 === 名, false)))
261
-
262
- // ───────────── 总览 ─────────────
263
- const 总览页 = h('div', null,
264
- h('div', { style: S.card },
265
- h('div', { style: { ...S.h, margin: '0 0 8px' } }, '开关'),
266
- 行('总开关', 勾(配置.总开关, (v) => 改('总开关', v)), '关着 = 一句都不发(安全默认)。'),
267
- 行('试运行', 勾(配置.试运行, (v) => 改('试运行', v)), '开着只记账、不真发到 QQ。想先看记录就开着它。'),
268
- 行('手机只收「人话」(不发代码/路径)', 勾(配置.只发标记内, (v) => 改('只发标记内', v)), '开着时:只有它写在 [QQ] 这里 [/QQ] 中间的话会发到手机;代码、文件路径、分析过程都留在电脑上,不往手机上推。开之前先让它知道这个写法 —— 开着时转进去的消息会自动提醒它。'),
269
- 行('只回 QQ 叫醒的那一轮', 勾(配置.只回QQ唤醒, (v) => 改('只回QQ唤醒', v)), '开着时:你在 QQ 问一句它回一句;你坐在 DSH 界面里自己聊的,不推到手机。'),
270
- 行('自动触发的也发(主动搭话 / 定时任务)', 勾(配置.自动触发也发, (v) => 改('自动触发也发', v)), '上面那个开着的时候,主动搭话、到点刷 B 站这类「它自己起的头」要不要也推到手机。关着 = 只有 QQ 叫醒的那几轮才发(跟以前一样);开着 = 那几种也发。⚠ 你自己在窗口里打的字,两种情况都不会发。'),
271
- 行('正文里叫这些名字也算点名', h('input', {
272
- style: S.wide,
273
- value: String(配置.点名关键词 || ''),
274
- placeholder: '留空 = 只认 @ 和引用',
275
- onChange: (e) => 改('点名关键词', e.target.value),
276
- }), '群里有人直接说「让它帮我干活」「去问问它」,既没 @ 也没引用 —— 出现这些词就当点名,直接进来,不再靠概率掷骰子。多个用逗号或空格隔开;留空就是关着(跟以前一样)。'),
277
- ),
278
- h('div', { style: S.card },
279
- h('div', { style: { ...S.h, margin: '0 0 8px' } }, '现在的绑定(' + 绑定.length + ' 条)'),
280
- 绑定.length === 0
281
- ? h('div', { style: S.dim }, '还没有绑定。去「绑定」页签加一条:一个私聊 / 一个群 → 一间会话。')
282
- : h('div', null,
283
- ...绑定.map((x, i) => h('div', { key: i, style: { marginBottom: 4 } },
284
- (x.类型 === '群' ? '群 ' : '私聊 ') + String(x.号码 || '?')
285
- + ' → ' + (x.会话 ? 短id(x.会话) : '(没选会话)')
286
- + (x.称呼 ? ' 称呼:' + x.称呼 : '')
287
- + (x.类型 === '群' && x.门槛 ? ' 门槛:开' : '')))),
288
- h('div', { style: { marginTop: 6 } }, 按钮('去绑定', () => 设页('绑定'))),
289
- ),
290
- h('div', { style: S.card },
291
- h('div', { style: { ...S.h, margin: '0 0 8px' } }, '自己动手'),
292
- h('div', null,
293
- 按钮('保存设置', 存, true, 忙),
294
- h('span', { style: S.dim }, ' '),
295
- 按钮('测试一句', 试一句, false, 忙),
296
- h('span', { style: S.dim }, ' '),
297
- 按钮('刷新', 拉, false, 忙)),
298
- h('div', { style: { ...S.dim, marginTop: 4 } }, '「测试一句」会真的发一句去 QQ(绕过试运行),用来确认整条路通没通。'),
299
- 说 ? h('div', { style: { ...S.ok, marginTop: 6 } }, 说) : null,
300
- ),
301
- h('div', { style: S.card },
302
- h('div', { style: { ...S.h, margin: '0 0 8px' } }, '最近 ' + 日志.length + ' 条记录(新的在最下面)'),
303
- h('div', { style: { marginTop: 6, maxHeight: 220, overflowY: 'auto' } },
304
- 日志.length === 0
305
- ? h('div', { style: S.dim }, '还没有记录。')
306
- : 日志.map((条, i) => h('div', { key: i, style: S.日志行 }, 条.时间 + ' ' + 条.文本))),
307
- ),
308
- )
309
-
310
- // ───────────── 绑定 ─────────────
311
- const 绑定页 = h('div', null,
312
- h('div', { style: S.card },
313
- h('div', { style: { ...S.h, margin: '0 0 8px' } }, '人设(选填)'),
314
- h('div', { style: S.dim },
315
- '给聊天带上一个「它是谁」。下面每一行也可以单独写一份 —— 那一行就用自己的,没写的用这里这份。'),
316
- h('div', { style: { marginTop: 6 } }, 勾(配置.人设开, (v) => 改('人设开', v)), ' ', '给聊天带上人设'),
317
- h('textarea', {
318
- style: { ...S.inp, display: 'block', width: '100%', height: 88, marginTop: 6, resize: 'vertical', fontFamily: 'inherit', lineHeight: 1.5, boxSizing: 'border-box' },
319
- value: String(配置.人设 || ''),
320
- placeholder: '比如:你叫小满,二十出头,说话短、爱开玩笑,偶尔会怼人,但心是软的。',
321
- onChange: (e) => 改('人设', e.target.value),
322
- }),
323
- h('div', { style: { ...S.dim, marginTop: 4 } },
324
- '几句话就够,别写太长 —— 太长会把它每一轮的注意力都拽到这些字上。'),
325
- h('div', { style: { marginTop: 6 } },
326
- 按钮('现在就生效', 人设现在生效, false, 人设忙),
327
- h('span', { style: S.dim }, ' 改完不用等 QQ 来信,直接挂上去(那间会话得开着)')),
328
- 人设说 ? h('div', { style: { ...S.dim, marginTop: 4 } }, 人设说) : null,
329
- ),
330
- h('div', { style: S.card },
331
- h('div', { style: { ...S.h, margin: '0 0 8px' } }, '绑定(一行 = 一个聊天 → 一间会话)'),
332
- h('div', { style: S.dim },
333
- 'QQ 私聊就填你自己的号;群就填群号。哪一行说的话,就只回那一行的聊天 —— 多群也不会串。'
334
- + '同一间会话可以绑多条(比如私聊 + 一个群都指向同一间):她说什么,这几边都会收到。'),
335
- h('div', { style: { ...S.表头, marginTop: 10 } },
336
- h('div', { style: S.表头字 }, '类型'),
337
- h('div', { style: S.表头字 }, '号码'),
338
- h('div', { style: S.表头字 }, '称呼'),
339
- h('div', { style: S.表头字 }, '门槛'),
340
- h('div', { style: S.表头字 }, '人设'),
341
- h('div', { style: S.表头字 }, '')),
342
- 绑定.length === 0
343
- ? h('div', { style: { ...S.dim, margin: '8px 0' } }, '还没有 —— 点下面的「+ 加一条」')
344
- : h('div', null, ...绑定.map((x, i) => h(React.Fragment, { key: i },
345
- h('div', { style: S.表格行 },
346
- h('select', { style: S.wide, value: x.类型 || '群', onChange: (e) => 改绑定(i, { 类型: e.target.value }) },
347
- h('option', { value: '私聊' }, '私聊'),
348
- h('option', { value: '群' }, '群')),
349
- h('input', {
350
- style: S.wide, value: String(x.号码 || ''), placeholder: x.类型 === '私聊' ? 'QQ 号' : '群号',
351
- onChange: (e) => 改绑定(i, { 号码: e.target.value.trim() }),
352
- }),
353
- h('input', {
354
- style: S.wide, value: String(x.称呼 || ''),
355
- placeholder: x.类型 === '私聊' ? '对方' : '群里的人',
356
- onChange: (e) => 改绑定(i, { 称呼: e.target.value }),
357
- }),
358
- h('div', { style: { textAlign: 'center' } },
359
- 勾(x.门槛, (v) => 改绑定(i, { 门槛: v }))),
360
- h('div', { style: { textAlign: 'center' } },
361
- 按钮(String(x.人设 || '').trim() ? '✓ 单独' : '单独', () => 设人设行(人设行 === i ? null : i), false, false)),
362
- h('div', null, 按钮('删', () => 删绑定(i), false, false))),
363
- // ⭐ 1.1.0:会话单独占一整行,宽度给够
364
- h('div', { style: { display: 'grid', gridTemplateColumns: '70px 1fr', gap: 6, alignItems: 'center', margin: '0 0 6px' } },
365
- h('div', { style: S.表头字 }, '会话'),
366
- 会话选项(x.会话, (v) => 改绑定(i, { 会话: v }))),
367
- // ⭐ 1.1.8:这一行自己的开关 —— 不想理就把「启用」取消,不用删掉
368
- h('div', { style: { display: 'flex', gap: 16, alignItems: 'center', margin: '0 0 10px', paddingLeft: 2 } },
369
- h('span', { style: { display: 'flex', gap: 4, alignItems: 'center' } },
370
- 勾(x.开 !== false, (v) => 改绑定(i, { 开: v })),
371
- h('span', { style: S.dim }, '启用(关掉 = 这个聊天不收了,也不发)')),
372
- x.类型 === '群'
373
- ? h('span', { style: { display: 'flex', gap: 4, alignItems: 'center' } },
374
- 勾(x.只听点名 == null ? !配置.群里听所有人 : Boolean(x.只听点名), (v) => 改绑定(i, { 只听点名: v })),
375
- h('span', { style: S.dim }, '只听 @ 我的'))
376
- : null),
377
- i === 人设行
378
- ? h('div', { style: { margin: '0 0 8px', padding: '6px 8px', background: 'rgba(127,127,127,.06)', borderRadius: 4 } },
379
- h('div', { style: { ...S.dim, marginBottom: 4 } }, '这一行单独用的人设(留空 = 用上面那份)'),
380
- h('textarea', {
381
- style: { ...S.inp, display: 'block', width: '100%', height: 72, resize: 'vertical', fontFamily: 'inherit', lineHeight: 1.5, boxSizing: 'border-box' },
382
- value: String(x.人设 || ''),
383
- placeholder: '比如:这个群里你话少,只接一两句,爱用「嗯」。',
384
- onChange: (e) => 改绑定(i, { 人设: e.target.value }),
385
- }),
386
- h('div', { style: { marginTop: 4 } },
387
- 按钮('清空', () => 改绑定(i, { 人设: '' }), false, false),
388
- h('span', { style: S.dim }, ' '),
389
- 按钮('收起', () => 设人设行(null), false, false)))
390
- : null,
391
- // ⭐ 1.4.3:这个群里「不听点名也直接收」的 QQ 号。
392
- // 「只听点名」一开,群里**所有人**(包括自己)都要点名才收;这里列出来的号例外。
393
- x.类型 === '群' && (x.只听点名 == null ? !配置.群里听所有人 : Boolean(x.只听点名))
394
- ? h('div', { style: { margin: '0 0 8px' } },
395
- h('div', { style: { ...S.dim, marginBottom: 3 } },
396
- '不受点名限制的 QQ(逗号或空格分隔,留空 = 群里全都要点名)'),
397
- h('input', {
398
- style: { ...S.inp, display: 'block', width: '100%', boxSizing: 'border-box' },
399
- value: String(x.点名例外 || ''),
400
- placeholder: '例如 3274796953',
401
- onChange: (e) => 改绑定(i, { 点名例外: e.target.value }),
402
- }))
403
- : null))),
404
- h('div', { style: { marginTop: 8 } },
405
- 按钮('+ 加一条', 加绑定, false, false),
406
- h('span', { style: S.dim }, ' '),
407
- 按钮('刷新会话清单', 拉, false, 忙)),
408
- h('div', { style: { ...S.dim, marginTop: 6 } },
409
- '「门槛」只有群有用:开了之后,那个群里的消息会先让它判断「是不是在跟我说话」,不是就停掉这一轮。'
410
- + '私聊永远不判(你自己私聊,本来就是在跟它说话)。'),
411
- ),
412
- h('div', { style: S.card },
413
- h('div', { style: { ...S.h, margin: '0 0 8px' } }, '群里怎么回(对所有群生效)'),
414
- h('div', { style: S.dim },
415
- '群里人多、话密的时候,一句一句回就像刷屏 —— 下面这几个数管的就是这个。@、引用 不受限制。'),
416
- 行('两条之间至少隔(秒)', h('input', { type: 'number', min: 0, max: 300, style: S.small, value: String(配置.回复冷却秒 ?? 5), onChange: (e) => 改('回复冷却秒', Number(e.target.value)) }), '单位秒。回完一次要隔这么久才回下一条;这段时间里群里新的话先攒着,到点一起回。默认 5。'),
417
- 行('一分钟最多回几条', h('input', { type: 'number', min: 1, max: 120, style: S.small, value: String(配置.每分钟最多条 ?? 4), onChange: (e) => 改('每分钟最多条', Number(e.target.value)) }), '一分钟里最多送进去几条。撞上了就等下一分钟再发 —— 是等,话不会丢。默认 4。'),
418
- 行('一次最多攒几条', h('input', { type: 'number', min: 1, max: 50, style: S.small, value: String(配置.合并最多条 ?? 8), onChange: (e) => 改('合并最多条', Number(e.target.value)) }), '群里几个人说的话先合成一条(每句前面带「谁说的」),攒够这么多就立刻送进去,不用干等窗口。默认 8。'),
419
- 行('私聊进来的话标来源', h('select', { style: S.small, value: String(配置.私聊署名 || '关'), onChange: (e) => 改('私聊署名', e.target.value) },
420
- h('option', { value: '关' }, '不标'), h('option', { value: '前缀' }, '[QQ] 前缀'), h('option', { value: '署名' }, '署名(对方:…)')),
421
- '默认不标。同一个会话里手机和电脑交替说话时,标一下才分得清哪句是从 QQ 来的。'),
422
- 行('@ 我的一定回', h('input', { type: 'checkbox', checked: 配置.点名必回 !== false, onChange: (e) => 改('点名必回', Boolean(e.target.checked)) }), '关掉之后,「概率」连 @ / 引用 它的话也一起管。默认开:冲它来的就一定回。'),
423
- 行('不是每句都接(概率 %)', h('input', { type: 'number', min: 0, max: 100, style: S.small, value: String(配置.回复概率 ?? 100), onChange: (e) => 改('回复概率', Number(e.target.value)) }), '100 = 每批都接(默认)。调小 = 群里闲聊有时就不理;没掷中的那批**根本不送进来**,不占上下文、也不花 token。'),
424
- h('div', { style: { ...S.dim, marginTop: 4 } }, '「连着发的几条攒成一条」在「消息」页,默认 1 秒。'),
425
- ),
426
- )
427
-
428
- // ───────────── 消息 ─────────────
429
- const 消息页 = h('div', null,
430
- h('div', { style: S.card },
431
- h('div', { style: { ...S.h, margin: '0 0 8px' } }, '连到哪个 QQ 后端'),
432
- 行('接口地址', h('input', { style: S.wide, value: String(配置.NapCat || ''), onChange: (e) => 改('NapCat', e.target.value) }), 'NapCat / SnowLuma 的 HTTP 地址,一般是 http://127.0.0.1:3000。'),
433
- 行('接口令牌', h('input', { type: 'password', style: S.small, value: String(配置.令牌 || ''), onChange: (e) => 改('令牌', e.target.value) }), '后端开了 HTTP 鉴权才填(access_token);留空 = 不鉴权。'),
434
- ),
435
- h('div', { style: S.card },
436
- h('div', { style: { ...S.h, margin: '0 0 8px' } }, '发出去的样子'),
437
- 行('长回复拆成几条发', 勾(配置.长回复拆段, (v) => 改('长回复拆段', v)), '开着(默认):按空行拆成几条、像真人一条条发。关掉:一整段只发一条,不会刷屏。'),
438
- 行('按句子拆(不是按空行)', 勾(配置.按句拆, (v) => 改('按句拆', v)), '开着:一个句号/问号/感叹号算一条,更像人一条条打字。括号里的标点不切。默认关(关着就还是按空行拆)。'),
439
- 行('每段之间隔(毫秒)', h('input', { type: 'number', min: 0, max: 5000, style: S.small, value: String(配置.拆段间隔毫秒 ?? 450), onChange: (e) => 改('拆段间隔毫秒', Number(e.target.value)) }), '只在「拆成几条」开着时有用。默认 450。'),
440
- ),
441
- h('div', { style: S.card },
442
- h('div', { style: { ...S.h, margin: '0 0 8px' } }, '收进来的样子'),
443
- 行('连着发的几条攒成一条', h('input', { type: 'number', min: 0, max: 60, style: S.small, value: String(配置.合并窗口秒 ?? 3), onChange: (e) => 改('合并窗口秒', Number(e.target.value)) }), '单位秒。0 = 不攒(来一条转一条,会一条一条回)。默认 3:同一个人连着发的(比如一次发一串图)合成一条再转进去,它只回一次。'),
444
- 行('最多攒多久', h('input', { type: 'number', min: 3, max: 300, style: S.small, value: String(配置.合并最多秒 ?? 20), onChange: (e) => 改('合并最多秒', Number(e.target.value)) }), '单位秒。有人一直发个不停时,到点也先转进来,不会无限等。默认 20。'),
445
- 行('多久拉一次新消息(毫秒)', h('input', { type: 'number', min: 800, max: 30000, style: S.small, value: String(配置.轮询间隔毫秒 ?? 2000), onChange: (e) => 改('轮询间隔毫秒', Number(e.target.value)) }), '默认 2000(2 秒)。调小 = 消息进来更快,但请求 QQ 机器人更勤;改完要重启 DSH。'),
446
- ),
447
- h('div', { style: S.card },
448
- h('div', { style: { ...S.h, margin: '0 0 8px' } }, '图片'),
449
- 行('收图(QQ → 它)', 勾(配置.收图, (v) => 改('收图', v)), '你在 QQ 里发的图也转给它看。模型看得见图就直接看;纯文本模型只知道「有几张图、存在哪」。'),
450
- 行('发图(它 → QQ)', 勾(配置.发图, (v) => 改('发图', v)), '它这一轮里发的图也发到 QQ。'),
451
- 行('一条最多收几张', h('input', { type: 'number', min: 1, max: 10, style: S.small, value: String(配置.收图最多 ?? 4), onChange: (e) => 改('收图最多', Number(e.target.value)) }), '多出来的不转。一条最多 10 张。'),
452
- 行('单张最大(MB)', h('input', { type: 'number', min: 1, max: 30, style: S.small, value: String(配置.图上限MB ?? 5), onChange: (e) => 改('图上限MB', Number(e.target.value)) }), '超过就跳过(两个方向都按这个)。'),
453
- 行('图片根目录', h('input', { style: S.wide, placeholder: '(留空就行:自动用会话所在的工作区)', value: String(配置.图片根目录 || ''), onChange: (e) => 改('图片根目录', e.target.value) }), '一般不用填。它正文里写 ![名字](相对路径) 时先按这个目录找。'),
454
- h('div', { style: S.dim }, '收进来的图会存一份到 %USERPROFILE%\\.dsh\\qq-bridge-inbox\\,文字里会带上路径。'),
455
- ),
456
- h('div', { style: S.card },
457
- h('div', { style: { ...S.h, margin: '0 0 8px' } }, '文件'),
458
- 行('发文件(它 → QQ)', 勾(配置.发文件, (v) => 改('发文件', v)), '它这一轮正文里写了 [报告](D:\\x\\报告.docx) 这种链接,就把那个文件传到 QQ。图片那种 ![名](路径) 不算文件。'),
459
- 行('单个文件最大(MB)', h('input', { type: 'number', min: 1, max: 2048, style: S.small, value: String(配置.文件上限MB ?? 50), onChange: (e) => 改('文件上限MB', Number(e.target.value)) }), '超过就只发一句话说明,不硬传。QQ 自己也有单文件上限,太大那边会拒。'),
460
- 行('只往私聊发(群里不发文件)', 勾(配置.文件仅私聊 !== false, (v) => 改('文件仅私聊', v)), '默认开。大群里发文件容易出问题(群文件权限、刷屏、审核),私聊最稳。关掉之后群里也会传。'),
461
- h('div', { style: S.dim }, '路径带空格就用尖括号:[报告](<D:\\我的 文档\\报告.docx>)。网页链接不会被当成文件。'),
462
- ),
463
- h('div', { style: S.card },
464
- h('div', { style: { ...S.h, margin: '0 0 8px' } }, '语音转写(QQ 语音条 → 文字)'),
465
- 行('转写语音', 勾(配置.语音转写, (v) => 改('语音转写', v)), '开着时,QQ 里的语音条会交给下面那条命令转成文字再转给它;关着就只记一个 [语音]。'),
466
- // —— 一键装依赖 ——
467
- h('div', { style: { ...S.row, alignItems: 'center' } },
468
- h('div', { style: S.lab }, '语音识别环境'),
469
- h('div', { style: S.ctl },
470
- h('div', null,
471
- 按钮(asr忙 ? '…' : (asr && asr.就绪 ? '重装 / 修一下' : '自动装依赖'), 装语音, !(asr && asr.就绪), asr忙 || Boolean(asr && asr.进行中)),
472
- h('span', { style: S.dim }, ' '),
473
- 按钮('把命令填好', 填语音命令, false, false)),
474
- h('div', { style: S.hint },
475
- asr && asr.就绪 && asr.模型就绪
476
- ? '环境 + 语音模型都就绪:' + String(asr.python || '')
477
- : asr && asr.就绪
478
- ? '环境装好了,但语音模型还没下来 —— 再点一次「自动装依赖」会接着下'
479
- : '还没装。点「自动装依赖」会建一个独立环境、装 sherpa-onnx + numpy(几十 MB,第一次要几分钟)。'),
480
- // 进度
481
- asr && asr.步骤 && asr.步骤.length
482
- ? h('div', { style: { marginTop: 8 } },
483
- ...asr.步骤.map((x, i) => h('div', { key: i, style: { fontSize: 12 } },
484
- (x.态 === '完成' ? '✓ ' : x.态 === '失败' ? '✗ ' : '⋯ ') + x.名 + (x.附 ? '(' + x.附 + ')' : ''))))
485
- : null,
486
- // —— 下载进度条(模型 230MB,得让人看见下了多少)——
487
- asr && asr.下载
488
- ? h('div', { style: { marginTop: 8 } },
489
- h('div', { style: { height: 8, borderRadius: 6, background: 'rgba(128,128,128,.25)', overflow: 'hidden' } },
490
- h('div', {
491
- style: {
492
- height: '100%', width: String(asr.下载.百分比 || 0) + '%',
493
- background: 'var(--dsw-alias-state-business-primary)', transition: 'width .4s',
494
- },
495
- })),
496
- h('div', { style: { ...S.dim, marginTop: 4 } },
497
- (asr.下载.文件 ? '正在下 ' + asr.下载.文件 + ':' : '')
498
- + '已下 ' + Math.round((asr.下载.已收 || 0) / 1048576) + 'MB'
499
- + (asr.下载.总 ? ' / ' + Math.round(asr.下载.总 / 1048576) + 'MB' : '')
500
- + '(' + String(asr.下载.百分比 || 0) + '%)'
501
- + (asr.下载.速度KB ? ' 约 ' + (asr.下载.速度KB > 1024 ? (asr.下载.速度KB / 1024).toFixed(1) + 'MB/s' : asr.下载.速度KB + 'KB/s') : '')))
502
- : null,
503
- asr && asr.进行中 ? h('div', { style: { ...S.dim, marginTop: 4 } }, '正在装…已 ' + String(asr.用时秒 || 0) + ' 秒(每 2 秒刷新一次)') : null,
504
- asr && asr.失败 ? h('div', { style: { color: '#c0392b', fontSize: 12, marginTop: 4 } }, '✗ ' + asr.失败) : null,
505
- asr && asr.日志
506
- ? h('div', { style: { marginTop: 8 } },
507
- h('div', { style: S.dim }, '安装日志(最后几行)'),
508
- h('div', { style: { ...S.日志行, maxHeight: 160, overflowY: 'auto', whiteSpace: 'pre-wrap', marginTop: 4 } }, String(asr.日志)))
509
- : null)),
510
- 行('转写命令', h('input', {
511
- style: S.wide,
512
- placeholder: 'python D:\\asr_cli.py "{file}"',
513
- value: String(配置.转写命令 || ''),
514
- onChange: (e) => 改('转写命令', e.target.value),
515
- }), '模板里必须有 {file}(会换成音频路径,一般是 wav)。命令往 stdout 打一行文字就算成功;非 0 退出码当失败。'),
516
- 行('语音模型目录', h('input', {
517
- style: S.wide,
518
- placeholder: '(装依赖时会自动填好,不用手填)',
519
- value: String(配置.语音模型目录 || ''),
520
- onChange: (e) => 改('语音模型目录', e.target.value),
521
- }), '一般不用管:点「自动装依赖」会把语音模型一起下下来、路径自动写好。只有你想换成自己已有的模型(比如 whisper)时才需要改。'),
522
- 行('超时(秒)', h('input', { type: 'number', min: 5, max: 600, style: S.small, value: String(配置.转写超时秒 ?? 60), onChange: (e) => 改('转写超时秒', Number(e.target.value)) }), '超过就杀掉命令、按失败处理(默认 60 秒)。'),
523
- h('div', { style: S.dim },
524
- '插件自己不带语音模型(不然包会变成几百 MB)。仓库里给了一个现成接法:tools/asr_cli.py(本机 SenseVoice);你有 whisper 或云端接口的话,写一条「读 {file}、打一行字」的命令接上就行。'),
525
- ),
526
- )
527
-
528
- // ───────────── 门槛 ─────────────
529
- const 门槛页 = h('div', null,
530
- h('div', { style: S.card },
531
- h('div', { style: { ...S.h, margin: '0 0 8px' } }, '回复门槛'),
532
- h('div', { style: S.dim },
533
- '没有「总开关」——直接去「绑定」页签,给想开的那一行打勾就行;一个群开、别的群照常。'
534
- + '私聊永远不判(你自己私聊本来就是在跟它说话)。'),
535
- h('div', { style: { ...S.dim, marginTop: 6 } },
536
- '开了之后:那个群的 QQ 消息照样转进来,但会先让它自己判断「这条是不是在跟我说话」——不是就当场把这一轮停掉,什么都不回。'),
537
- 行('判定不接时:真把这一轮停掉', 勾(配置.门槛真停轮, (v) => 改('门槛真停轮', v)), '关(默认):只把这轮的话拦住、不发到 QQ,界面上什么异常都没有。开:真的停掉这一轮(省 token),但界面上会显示一次 `Error: tool call aborted` —— 那是「停轮」的表现,不是故障。'),
538
- h('div', { style: { ...S.dim, marginTop: 6 } },
539
- '⚠ 给某个群打开门槛那一刻,那一间会话的下一次请求会全量重算一遍(因为它多了一个工具);关着的时候没有影响。'),
540
- h('div', { style: { ...S.dim, marginTop: 8, fontWeight: 600 } }, '群里听谁的'),
541
- 行('群里也听别人的话', 勾(配置.群里听所有人, (v) => 改('群里听所有人', v)),
542
- '关(默认):群里**只把点了名的**话转进来(@ 你、或引用你说的话)—— 省额度,也不会被别人的闲聊牵着走。'
543
- + '开:群里所有人说的话都转进来,交给上面的「门槛」去判断要不要接。'),
544
- // ⭐ 1.4.1:被点名之后,同一个群后面紧跟着的几句也放进来(不然接话接不上前文)
545
- 行('点名之后接着听几条', h('input', { type: 'number', min: 0, max: 50, style: S.small, value: String(配置.点名后接听条 ?? 5), onChange: (e) => 改('点名后接听条', Number(e.target.value)) }),
546
- '被 @ 或者引用之后,同一个群里紧接着的几句话也会转进来,接够这么多条就停,回到「只听点名」。默认 5;填 0 = 不接(只有点名的那一条进得来)。'),
547
- 行('接着听多少秒', h('input', { type: 'number', min: 0, max: 600, style: S.small, value: String(配置.点名后接听秒 ?? 120), onChange: (e) => 改('点名后接听秒', Number(e.target.value)) }),
548
- '单位秒。过了这么久也停,就算条数还没接完。默认 120。'),
549
- ),
550
- )
551
-
552
- // ───────────── 空间 ─────────────
553
- const 空间定时 = () => {
554
- const a = 配置.空间定时发
555
- return Array.isArray(a) ? a : []
556
- }
557
- const 设空间定时 = (下一份) => 改('空间定时发', 下一份)
558
- const 改空间定时 = (i, 补) => 设空间定时(空间定时().map((x, j) => (j === i ? { ...x, ...补 } : x)))
559
- const 加空间定时 = () => 设空间定时([...空间定时(), { 时: '09:30', 权: 1, 开: true }].slice(0, 12))
560
- const 删空间定时 = (i) => 设空间定时(空间定时().filter((_, j) => j !== i))
561
-
562
- const 空间页 = h('div', null,
563
- h('div', { style: S.card },
564
- h('div', { style: { ...S.h, margin: '0 0 8px' } }, '开关'),
565
- 行('允许发 QQ 空间动态', 勾(配置.允许发空间动态, (v) => 改('允许发空间动态', v)), '默认关。关着的时候那几个工具还在,但只会说一句「没开」,不会真的发出去。看说说、看好友动态不受这个开关影响。'),
566
- h('div', { style: S.dim }, '空间走的是「消息」页那个接口地址和令牌,不用另填。'),
567
- ),
568
- h('div', { style: S.card },
569
- h('div', { style: { ...S.h, margin: '0 0 8px' } }, '定时发说说'),
570
- h('div', { style: S.dim }, '到点了会往绑定的会话里递一句提醒,说什么由它自己写 —— 这里只定时间。'),
571
- h('div', { style: { ...S.空间表头, marginTop: 10 } },
572
- h('div', { style: S.表头字 }, '时间'),
573
- h('div', { style: S.表头字 }, '可见范围'),
574
- h('div', { style: S.表头字 }, '启用'),
575
- h('div', { style: S.表头字 }, '')),
576
- 空间定时().length === 0
577
- ? h('div', { style: { ...S.dim, margin: '8px 0' } }, '还没有 —— 点下面的「+ 加一条」')
578
- : h('div', null, ...空间定时().map((x, i) => h('div', { key: i, style: S.空间行 },
579
- h('input', {
580
- type: 'time', style: S.wide, value: String(x.时 || '09:30'),
581
- onChange: (e) => 改空间定时(i, { 时: e.target.value }),
582
- }),
583
- h('select', {
584
- style: S.wide, value: String(x.权 || 1),
585
- onChange: (e) => 改空间定时(i, { 权: Number(e.target.value) }),
586
- },
587
- h('option', { value: '1' }, '所有人'),
588
- h('option', { value: '4' }, '好友'),
589
- h('option', { value: '64' }, '仅自己')),
590
- h('div', { style: { textAlign: 'center' } }, 勾(x.开 !== false, (v) => 改空间定时(i, { 开: v }))),
591
- h('div', null, 按钮('删', () => 删空间定时(i), false, false))))),
592
- h('div', { style: { marginTop: 8 } },
593
- 按钮('+ 加一条', 加空间定时, false, false),
594
- h('span', { style: S.dim }, ' 最多 12 条')),
595
- ),
596
- h('div', { style: S.card },
597
- h('div', { style: { ...S.h, margin: '0 0 8px' } }, '定时看一圈'),
598
- 行('多久看一圈(分钟)', h('input', { type: 'number', min: 5, max: 1440, style: S.small, value: String(配置.空间看圈分 ?? 30), onChange: (e) => 改('空间看圈分', Number(e.target.value)) }), '5~1440,默认 30。'),
599
- 行('开着', 勾(配置.空间看圈开, (v) => 改('空间看圈开', v)), '开着才会定时去拉好友动态;关着什么都不做。'),
600
- ),
601
- h('div', { style: S.card },
602
- h('div', { style: { ...S.h, margin: '0 0 8px' } }, '点赞 / 评论'),
603
- 行('自动点赞', 勾(配置.空间点赞开, (v) => 改('空间点赞开', v)), '看一圈的时候,最近几天里没点过的会顺手点一下。每次最多 3 条、一天最多 20 条。'),
604
- 行('允许评论', 勾(配置.空间评论开, (v) => 改('空间评论开', v)), '看一圈的时候把新动态递进会话,评不评、评什么由它自己决定 —— 这里不替你写评论内容。'),
605
- ),
606
- )
607
-
608
- // ───────────── 更新 ─────────────
609
- const 查更新 = async () => {
610
- 设更新忙(true)
611
- const r = await get('/update?refresh=1')
612
- if (r) 设更新(r)
613
- 设更新忙(false)
614
- 设说(!r ? '查不到(网络)' : r.hasUpdate ? '有新版本 v' + r.latest : r.ok ? '已经是最新版' : '查不到:' + (r.error || '网络'))
615
- }
616
- // ⭐ 2026-11-01:查到新版就一键装(宿主的 pluginManager.installBundle)
617
- const 一键更新 = async () => {
618
- 设更新忙(true)
619
- 设说('正在装…')
620
- try {
621
- const r = await post('/install', {})
622
- 设说(r && r.ok ? '已经开始装了 —— 装完重启(或重载)一次 dsh 才生效' : '没装成:' + ((r && r.error) || '不知道原因'))
623
- } catch (e) {
624
- 设说('没装成:' + ((e && e.message) || e))
625
- }
626
- 设更新忙(false)
627
- }
628
- const 更新页 = h('div', null,
629
- h('div', { style: S.card },
630
- h('div', { style: { ...S.h, margin: '0 0 8px' } }, '检查更新'),
631
- h('div', { style: S.dim },
632
- !更新 ? '正在看有没有新版…'
633
- : 更新.hasUpdate ? '仓库里已经有 v' + 更新.latest + ' 了'
634
- : 更新.ok ? '已经是最新版' : '暂时查不到(' + (更新.error || '网络') + ')—— 这不代表没有问题'),
635
- h('div', { style: { marginTop: 6 } }, 按钮(更新忙 ? '查…' : '检查更新', 查更新, false, 更新忙)),
636
- 更新 && 更新.hasUpdate
637
- ? h('div', { style: { marginTop: 8 } },
638
- (更新 && 更新.canInstall)
639
- ? 按钮(更新忙 ? '装…' : '一键更新到 v' + 更新.latest, 一键更新, false, 更新忙)
640
- : h('div', { style: S.dim }, '这是本地开发副本(link: 进来的),请手动更新。'),
641
- h('div', { style: S.dim }, '装完重启(或重载)一次 dsh 才生效。'))
642
- : null,
643
- ),
644
- )
645
-
646
- return h(
647
- 'div',
648
- { style: S.wrap },
649
- h('div', { style: S.h }, '通到 QQ'),
650
- h('div', { style: S.dim },
651
- '它在 DSH 里说完一整轮,就把那句原文发到 QQ;你在 QQ 里说的话也会转进来。'
652
- + '版本 ' + String(态.版本 || '?') + '|配置文件:' + String(态.配置文件 || '')),
653
- NapCat && !NapCat.错
654
- ? h('div', { style: S.已同步 }, '✓ 连着的 QQ:' + NapCat.昵称 + '(' + NapCat.用户 + ')')
655
- : h('div', { style: S.未保存 }, '⚠ 连不上 QQ 机器人(NapCat / SnowLuma)' + (NapCat && NapCat.错 ? ':' + NapCat.错 : '')),
656
- 页签,
657
- h('div', { style: 脏 ? S.未保存 : S.已同步 },
658
- 脏 ? '⚠ 有改动还没保存 —— 改完点「保存设置」(底下也会浮一条出来)' : '设置已同步 ✓'),
659
- 页 === '总览' ? 总览页 : null,
660
- 页 === '绑定' ? 绑定页 : null,
661
- 页 === '消息' ? 消息页 : null,
662
- 页 === '空间' ? 空间页 : null,
663
- 页 === '门槛' ? 门槛页 : null,
664
- 页 === '更新' ? 更新页 : null,
665
- 脏
666
- ? h('div', { style: S.悬浮保存条 },
667
- h('span', { style: { fontWeight: 600, color: '#c0392b' } }, '⚠ 有改动还没保存'),
668
- h('span', { style: S.dim }, '改完点这里就行'),
669
- 按钮('保存设置', 存, true, 忙))
670
- : null,
671
- )
672
- }
673
-
674
- function apply(ctx) {
675
- const slots = ctx.get('slots')
676
- if (slots === undefined) return
677
- slots.inject('settings.section', () =>
678
- slots.register(
679
- { name: 'settings.section', id: 'qq-bridge', order: 54, label: () => '通到 QQ' },
680
- Section,
681
- ),
682
- )
683
- }
684
-
685
- exports.apply = apply
686
- exports.inject = inject
687
- return module.exports
688
- },
689
- })
1
+ // DSH「通到 QQ」· 网页半边
2
+ //
3
+ // 在 **DSH 设置** 左侧栏加一项「通到 QQ」。1.5.0 起分六个页签:
4
+ // 总览 / 绑定 / 消息 / 空间 / 门槛 / 更新
5
+ // 绑定是核心:一行 = 一个聊天(私聊 或 群)→ 一间 DSH 会话,各回各的,不串。
6
+ //
7
+ // ⚠ 这里不碰会话、不发消息,只是往宿主那几条 /dsh-qq-bridge/* 路由上读写。
8
+
9
+ window.__ModuleLoader__.load({
10
+ id: 'dsh-qq-link',
11
+ factory: (require) => {
12
+ var module = { exports: {} }
13
+ var exports = module.exports
14
+
15
+ const React = require('react')
16
+ const inject = ['slots']
17
+
18
+ const B = '/dsh-qq-bridge'
19
+ const get = (p) => fetch(B + p, { cache: 'no-store' }).then((r) => r.json()).catch(() => null)
20
+ const post = (p, body) =>
21
+ fetch(B + p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body || {}) })
22
+ .then((r) => r.json())
23
+ .catch(() => null)
24
+
25
+ const h = React.createElement
26
+
27
+ const S = {
28
+ wrap: {
29
+ padding: '12px 14px 24px',
30
+ fontSize: 13,
31
+ lineHeight: 1.7,
32
+ background: 'var(--dsw-alias-bg-layer-1, rgba(255,255,255,.92))',
33
+ border: '1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.08))',
34
+ borderRadius: 12,
35
+ },
36
+ h: { fontSize: 14, fontWeight: 600, margin: '16px 0 8px' },
37
+ dim: { opacity: 0.62, fontSize: 12 },
38
+ card: {
39
+ border: '1px solid var(--dsw-alias-border-l2)',
40
+ borderRadius: 8,
41
+ padding: '10px 12px',
42
+ marginBottom: 10,
43
+ background: 'var(--dsw-alias-bg-layer-1)',
44
+ },
45
+ row: { display: 'flex', gap: 10, alignItems: 'flex-start', marginBottom: 8, flexWrap: 'wrap' },
46
+ lab: { minWidth: 190, paddingTop: 3, fontSize: 12.5 },
47
+ ctl: { flex: '1 1 260px', minWidth: 220 },
48
+ hint: { opacity: 0.6, fontSize: 11.5, marginTop: 2 },
49
+ input: {
50
+ border: '1px solid var(--dsw-alias-border-l2)',
51
+ borderRadius: 6,
52
+ padding: '4px 8px',
53
+ background: 'transparent',
54
+ color: 'inherit',
55
+ fontSize: 12.5,
56
+ width: '100%',
57
+ boxSizing: 'border-box',
58
+ },
59
+ small: {
60
+ border: '1px solid var(--dsw-alias-border-l2)',
61
+ borderRadius: 6,
62
+ padding: '4px 8px',
63
+ background: 'transparent',
64
+ color: 'inherit',
65
+ fontSize: 12.5,
66
+ width: 140,
67
+ boxSizing: 'border-box',
68
+ },
69
+ wide: {
70
+ border: '1px solid var(--dsw-alias-border-l2)',
71
+ borderRadius: 6,
72
+ padding: '4px 8px',
73
+ background: 'transparent',
74
+ color: 'inherit',
75
+ fontSize: 12.5,
76
+ width: '100%',
77
+ boxSizing: 'border-box',
78
+ },
79
+ ok: { color: 'var(--dsw-alias-state-business-primary)', fontSize: 12 },
80
+ 已同步: { margin: '6px 0 10px', fontSize: 12, color: 'var(--dsw-alias-state-business-primary)' },
81
+ 未保存: { margin: '6px 0 10px', fontSize: 12, color: '#c0392b', fontWeight: 600 },
82
+ 悬浮保存条: {
83
+ position: 'sticky',
84
+ bottom: 0,
85
+ marginTop: 12,
86
+ display: 'flex',
87
+ alignItems: 'center',
88
+ gap: 12,
89
+ flexWrap: 'wrap',
90
+ padding: '8px 12px',
91
+ borderRadius: 10,
92
+ border: '1px solid var(--dsw-alias-border-l2)',
93
+ background: 'var(--dsw-alias-bg-layer-1, rgba(255,255,255,.95))',
94
+ boxShadow: '0 -6px 18px rgba(0,0,0,.18)',
95
+ zIndex: 5,
96
+ },
97
+ btn: {
98
+ border: '1px solid var(--dsw-alias-border-l2)',
99
+ borderRadius: 6,
100
+ padding: '4px 12px',
101
+ background: 'transparent',
102
+ color: 'inherit',
103
+ fontSize: 12,
104
+ cursor: 'pointer',
105
+ },
106
+ btnOn: {
107
+ border: '1px solid var(--dsw-alias-state-business-primary)',
108
+ borderRadius: 6,
109
+ padding: '4px 12px',
110
+ background: 'var(--dsw-alias-state-business-primary)',
111
+ color: '#fff',
112
+ fontSize: 12,
113
+ cursor: 'pointer',
114
+ },
115
+ tabBar: { display: 'flex', gap: 6, flexWrap: 'wrap', margin: '10px 0 12px' },
116
+ 日志行: { fontFamily: 'ui-monospace, Consolas, monospace', fontSize: 11.5, whiteSpace: 'pre-wrap' },
117
+ 表头: { display: 'grid', gridTemplateColumns: '70px 130px 1fr 56px 64px 40px', gap: 6, alignItems: 'center', marginBottom: 4 },
118
+ 表格行: { display: 'grid', gridTemplateColumns: '70px 130px 1fr 56px 64px 40px', gap: 6, alignItems: 'center', marginBottom: 6 },
119
+ 表头字: { fontSize: 11.5, opacity: 0.65 },
120
+ 空间表头: { display: 'grid', gridTemplateColumns: '120px 140px 1fr 56px', gap: 6, alignItems: 'center', marginBottom: 4 },
121
+ 空间行: { display: 'grid', gridTemplateColumns: '120px 140px 1fr 56px', gap: 6, alignItems: 'center', marginBottom: 6 },
122
+ }
123
+
124
+ const 提示 = (文本) => h('div', { style: S.hint }, 文本)
125
+ const 行 = (标签, 控件, 说明) =>
126
+ h('div', { style: S.row }, h('div', { style: S.lab }, 标签), h('div', { style: S.ctl }, 控件, 说明 ? 提示(说明) : null))
127
+ const 按钮 = (文字, onClick, 亮, 关) =>
128
+ h('button', { type: 'button', style: 亮 ? S.btnOn : S.btn, onClick, disabled: !!关, title: 文字 }, 文字)
129
+ const 勾 = (值, onChange) => h('input', { type: 'checkbox', checked: !!值, onChange: (e) => onChange(e.target.checked) })
130
+ const 短id = (id) => {
131
+ const s = String(id)
132
+ return s.length <= 8 ? s : s.slice(-8)
133
+ }
134
+
135
+ const 页签们 = ['总览', '绑定', '消息', '空间', '门槛', '更新']
136
+
137
+ function Section() {
138
+ const [态, 设态] = React.useState(null)
139
+ const [脏, 设脏] = React.useState(false)
140
+ const [忙, 设忙] = React.useState(false)
141
+ const [说, 设说] = React.useState('')
142
+ const [更新, 设更新] = React.useState(null)
143
+ const [更新忙, 设更新忙] = React.useState(false)
144
+ const [页, 设页] = React.useState('总览')
145
+ const [asr, 设asr] = React.useState(null)
146
+ const [asr忙, 设asr忙] = React.useState(false)
147
+ const [人设行, 设人设行] = React.useState(null) // 哪一行展开了「单独的人设」
148
+ const [人设忙, 设人设忙] = React.useState(false)
149
+ const [人设说, 设人设说] = React.useState('')
150
+
151
+ React.useEffect(() => {
152
+ 拉()
153
+ get('/update').then((r) => r && 设更新(r)).catch(() => {})
154
+ get('/asr-status').then((r) => r && 设asr(r)).catch(() => {})
155
+ }, [])
156
+ // 装依赖的时候每 2 秒看一眼进度(装完就停)
157
+ React.useEffect(() => {
158
+ if (!asr || !asr.进行中) return undefined
159
+ const t = setInterval(() => {
160
+ get('/asr-status').then((r) => { if (r) 设asr(r) }).catch(() => {})
161
+ }, 2000)
162
+ return () => clearInterval(t)
163
+ }, [asr && asr.进行中])
164
+
165
+ const 拉 = async () => {
166
+ const s = await get('/state')
167
+ if (s) 设态(s)
168
+ }
169
+ // ⭐ 1.1.2:人设「现在就生效」—— 不用等 QQ 那边来一条消息
170
+ const 人设现在生效 = async () => {
171
+ 设人设忙(true)
172
+ 设人设说('')
173
+ const r = await post('/persona-apply', {})
174
+ 设人设忙(false)
175
+ if (!r || !r.ok) {
176
+ 设人设说('✗ ' + ((r && r.说) || '没反应'))
177
+ return
178
+ }
179
+ const 行们 = Array.isArray(r.结果们) ? r.结果们 : []
180
+ if (!行们.length) {
181
+ 设人设说('还没有绑定任何聊天')
182
+ return
183
+ }
184
+ const 不顺 = 行们.filter((x) => !x.生效)
185
+ 设人设说((r.生效 ? '✓ ' + r.生效 + ' 间会话生效' : '⌀ 没有生效的会话')
186
+ + (不顺.length ? ';' + 不顺.map((x) => x.聊天 + ':' + x.说).join(';') : ''))
187
+ }
188
+ const 改 = (k, v) => {
189
+ 设态((旧) => (旧 ? { ...旧, 配置: { ...(旧.配置 || {}), [k]: v } } : 旧))
190
+ 设脏(true)
191
+ }
192
+ // —— 绑定:整份数组替换(宿主那边会再规整一遍、并挡掉重复)——
193
+ const 绑定们 = () => {
194
+ const a = 态 && 态.配置 && 态.配置.绑定
195
+ return Array.isArray(a) ? a : []
196
+ }
197
+ const 设绑定 = (下一份) => 改('绑定', 下一份)
198
+ const 改绑定 = (i, 补) => 设绑定(绑定们().map((x, j) => (j === i ? { ...x, ...补 } : x)))
199
+ const 加绑定 = () => 设绑定([...绑定们(), { 类型: '群', 号码: '', 会话: '', 称呼: '', 门槛: false }])
200
+ const 删绑定 = (i) => 设绑定(绑定们().filter((_, j) => j !== i))
201
+
202
+ const 存 = async () => {
203
+ 设忙(true)
204
+ const s = await post('/save', (态 && 态.配置) || {})
205
+ 设忙(false)
206
+ if (s && s.ok) {
207
+ 设态(s)
208
+ 设脏(false)
209
+ 设说(s.说 || '保存好了')
210
+ } else {
211
+ 设说((s && s.说) || '保存失败')
212
+ }
213
+ }
214
+ const 装语音 = async () => {
215
+ 设asr忙(true)
216
+ const r = await post('/asr-install', {})
217
+ 设说((r && r.说) || '开始了')
218
+ 设asr忙(false)
219
+ const s2 = await get('/asr-status')
220
+ if (s2) 设asr(s2)
221
+ }
222
+ const 填语音命令 = async () => {
223
+ const r = await get('/asr-status')
224
+ if (r) 设asr(r)
225
+ if (r && r.就绪 && r.命令) {
226
+ 改('语音转写', true)
227
+ 改('转写命令', r.命令)
228
+ 设说(String(配置.语音模型目录 || '').trim()
229
+ ? '命令已填好(记得点保存)'
230
+ : '命令填好了,但**还没填「语音模型目录」** —— 转写会报「没给模型目录」,填完再点一次这个按钮')
231
+ } else if (r && !r.就绪) 设说('还没装好,先点「自动装依赖」')
232
+ else 设说('读不到状态')
233
+ }
234
+
235
+ const 试一句 = async () => {
236
+ 设忙(true)
237
+ const s = await post('/test-send', {})
238
+ 设忙(false)
239
+ 设说((s && s.说) || '没反应')
240
+ await 拉()
241
+ }
242
+
243
+ if (!态) return h('div', { style: S.wrap }, h('div', { style: S.h }, '通到 QQ'), h('div', { style: S.dim }, '读取中…'))
244
+
245
+ const 配置 = 态.配置 || {}
246
+ const 目标们 = Array.isArray(态.活着的会话) ? 态.活着的会话 : []
247
+ const NapCat = 态.NapCat状态 || null
248
+ const 日志 = Array.isArray(态.日志) ? 态.日志 : []
249
+ const 绑定 = 绑定们()
250
+
251
+ const 会话选项 = (值, onChange) =>
252
+ h('select', { style: S.wide, value: 值 || '', onChange: (e) => onChange(e.target.value) },
253
+ h('option', { value: '' }, '(选一间会话)'),
254
+ ...目标们.map((s) => h('option', { value: s.id }, (s.标题 || '…' + 短id(s.id)) + ' · ' + 短id(s.id))),
255
+ 值 && !目标们.some((s) => s.id === 值)
256
+ ? h('option', { value: 值 }, '(当前:' + 短id(值) + ')')
257
+ : null)
258
+
259
+ const 页签 = h('div', { style: S.tabBar },
260
+ ...页签们.map((名) => 按钮(名, () => 设页(名), 页 === 名, false)))
261
+
262
+ // ───────────── 总览 ─────────────
263
+ const 总览页 = h('div', null,
264
+ h('div', { style: S.card },
265
+ h('div', { style: { ...S.h, margin: '0 0 8px' } }, '开关'),
266
+ 行('总开关', 勾(配置.总开关, (v) => 改('总开关', v)), '关着 = 一句都不发(安全默认)。'),
267
+ 行('试运行', 勾(配置.试运行, (v) => 改('试运行', v)), '开着只记账、不真发到 QQ。想先看记录就开着它。'),
268
+ 行('手机只收「人话」(不发代码/路径)', 勾(配置.只发标记内, (v) => 改('只发标记内', v)), '开着时:只有它写在 [QQ] 这里 [/QQ] 中间的话会发到手机;代码、文件路径、分析过程都留在电脑上,不往手机上推。开之前先让它知道这个写法 —— 开着时转进去的消息会自动提醒它。'),
269
+ 行('只回 QQ 叫醒的那一轮', 勾(配置.只回QQ唤醒, (v) => 改('只回QQ唤醒', v)), '开着时:你在 QQ 问一句它回一句;你坐在 DSH 界面里自己聊的,不推到手机。'),
270
+ 行('自动触发的也发(主动搭话 / 定时任务)', 勾(配置.自动触发也发, (v) => 改('自动触发也发', v)), '上面那个开着的时候,主动搭话、到点刷 B 站这类「它自己起的头」要不要也推到手机。关着 = 只有 QQ 叫醒的那几轮才发(跟以前一样);开着 = 那几种也发。⚠ 你自己在窗口里打的字,两种情况都不会发。'),
271
+ 行('正文里叫这些名字也算点名', h('input', {
272
+ style: S.wide,
273
+ value: String(配置.点名关键词 || ''),
274
+ placeholder: '留空 = 只认 @ 和引用',
275
+ onChange: (e) => 改('点名关键词', e.target.value),
276
+ }), '群里有人直接说「让它帮我干活」「去问问它」,既没 @ 也没引用 —— 出现这些词就当点名,直接进来,不再靠概率掷骰子。多个用逗号或空格隔开;留空就是关着(跟以前一样)。'),
277
+ // ⭐ 1.5.0(issue #29):戳一戳这类通知 —— 它们不是消息,拉历史拉不到,只能让 OneBot 主动推
278
+ 行('收「戳一戳」这类通知', 勾(配置.收通知, (v) => 改('收通知', v)),
279
+ '「戳一戳」不是消息,轮询历史永远拉不到,只能让 OneBot 主动上报(HTTP 上报)。'
280
+ + '开之前先把 NapCat / SnowLuma 的「HTTP 上报」地址指到本机这个插件:'
281
+ + 'http://127.0.0.1:<DSH 端口>' + B + '/notice —— 详见 README「收戳一戳」那一节。'
282
+ + '⚠ 上报会把所有事件都推过来,这里**只认 notice**,消息照旧走轮询,不会重复。'),
283
+ 行('上报口令(选填)', h('input', {
284
+ style: S.wide,
285
+ value: String(配置.通知口令 || ''),
286
+ placeholder: '留空 = 不要口令',
287
+ onChange: (e) => 改('通知口令', e.target.value),
288
+ }), '填了就要求上报地址带 ?token=这个口令;留空 = 谁都能往这个地址推(只在本机用的话无所谓)。'),
289
+ ),
290
+ h('div', { style: S.card },
291
+ h('div', { style: { ...S.h, margin: '0 0 8px' } }, '现在的绑定(' + 绑定.length + ' 条)'),
292
+ 绑定.length === 0
293
+ ? h('div', { style: S.dim }, '还没有绑定。去「绑定」页签加一条:一个私聊 / 一个群 → 一间会话。')
294
+ : h('div', null,
295
+ ...绑定.map((x, i) => h('div', { key: i, style: { marginBottom: 4 } },
296
+ (x.类型 === '群' ? '群 ' : '私聊 ') + String(x.号码 || '?')
297
+ + ' → ' + (x.会话 ? 短id(x.会话) : '(没选会话)')
298
+ + (x.称呼 ? ' 称呼:' + x.称呼 : '')
299
+ + (x.类型 === '群' && x.门槛 ? ' 门槛:开' : '')))),
300
+ h('div', { style: { marginTop: 6 } }, 按钮('去绑定', () => 设页('绑定'))),
301
+ ),
302
+ h('div', { style: S.card },
303
+ h('div', { style: { ...S.h, margin: '0 0 8px' } }, '自己动手'),
304
+ h('div', null,
305
+ 按钮('保存设置', 存, true, 忙),
306
+ h('span', { style: S.dim }, ' '),
307
+ 按钮('测试一句', 试一句, false, 忙),
308
+ h('span', { style: S.dim }, ' '),
309
+ 按钮('刷新', 拉, false, 忙)),
310
+ h('div', { style: { ...S.dim, marginTop: 4 } }, '「测试一句」会真的发一句去 QQ(绕过试运行),用来确认整条路通没通。'),
311
+ 说 ? h('div', { style: { ...S.ok, marginTop: 6 } }, 说) : null,
312
+ ),
313
+ h('div', { style: S.card },
314
+ h('div', { style: { ...S.h, margin: '0 0 8px' } }, '最近 ' + 日志.length + ' 条记录(新的在最下面)'),
315
+ h('div', { style: { marginTop: 6, maxHeight: 220, overflowY: 'auto' } },
316
+ 日志.length === 0
317
+ ? h('div', { style: S.dim }, '还没有记录。')
318
+ : 日志.map((条, i) => h('div', { key: i, style: S.日志行 }, 条.时间 + ' ' + 条.文本))),
319
+ ),
320
+ )
321
+
322
+ // ───────────── 绑定 ─────────────
323
+ const 绑定页 = h('div', null,
324
+ h('div', { style: S.card },
325
+ h('div', { style: { ...S.h, margin: '0 0 8px' } }, '人设(选填)'),
326
+ h('div', { style: S.dim },
327
+ '给聊天带上一个「它是谁」。下面每一行也可以单独写一份 —— 那一行就用自己的,没写的用这里这份。'),
328
+ h('div', { style: { marginTop: 6 } }, 勾(配置.人设开, (v) => 改('人设开', v)), ' ', '给聊天带上人设'),
329
+ h('textarea', {
330
+ style: { ...S.inp, display: 'block', width: '100%', height: 88, marginTop: 6, resize: 'vertical', fontFamily: 'inherit', lineHeight: 1.5, boxSizing: 'border-box' },
331
+ value: String(配置.人设 || ''),
332
+ placeholder: '比如:你叫小满,二十出头,说话短、爱开玩笑,偶尔会怼人,但心是软的。',
333
+ onChange: (e) => 改('人设', e.target.value),
334
+ }),
335
+ h('div', { style: { ...S.dim, marginTop: 4 } },
336
+ '几句话就够,别写太长 —— 太长会把它每一轮的注意力都拽到这些字上。'),
337
+ h('div', { style: { marginTop: 6 } },
338
+ 按钮('现在就生效', 人设现在生效, false, 人设忙),
339
+ h('span', { style: S.dim }, ' 改完不用等 QQ 来信,直接挂上去(那间会话得开着)')),
340
+ 人设说 ? h('div', { style: { ...S.dim, marginTop: 4 } }, 人设说) : null,
341
+ ),
342
+ h('div', { style: S.card },
343
+ h('div', { style: { ...S.h, margin: '0 0 8px' } }, '绑定(一行 = 一个聊天 → 一间会话)'),
344
+ h('div', { style: S.dim },
345
+ 'QQ 私聊就填你自己的号;群就填群号。哪一行说的话,就只回那一行的聊天 —— 多群也不会串。'
346
+ + '同一间会话可以绑多条(比如私聊 + 一个群都指向同一间):她说什么,这几边都会收到。'),
347
+ h('div', { style: { ...S.表头, marginTop: 10 } },
348
+ h('div', { style: S.表头字 }, '类型'),
349
+ h('div', { style: S.表头字 }, '号码'),
350
+ h('div', { style: S.表头字 }, '称呼'),
351
+ h('div', { style: S.表头字 }, '门槛'),
352
+ h('div', { style: S.表头字 }, '人设'),
353
+ h('div', { style: S.表头字 }, '')),
354
+ 绑定.length === 0
355
+ ? h('div', { style: { ...S.dim, margin: '8px 0' } }, '还没有 —— 点下面的「+ 加一条」')
356
+ : h('div', null, ...绑定.map((x, i) => h(React.Fragment, { key: i },
357
+ h('div', { style: S.表格行 },
358
+ h('select', { style: S.wide, value: x.类型 || '群', onChange: (e) => 改绑定(i, { 类型: e.target.value }) },
359
+ h('option', { value: '私聊' }, '私聊'),
360
+ h('option', { value: '群' }, '群')),
361
+ h('input', {
362
+ style: S.wide, value: String(x.号码 || ''), placeholder: x.类型 === '私聊' ? 'QQ 号' : '群号',
363
+ onChange: (e) => 改绑定(i, { 号码: e.target.value.trim() }),
364
+ }),
365
+ h('input', {
366
+ style: S.wide, value: String(x.称呼 || ''),
367
+ placeholder: x.类型 === '私聊' ? '对方' : '群里的人',
368
+ onChange: (e) => 改绑定(i, { 称呼: e.target.value }),
369
+ }),
370
+ h('div', { style: { textAlign: 'center' } },
371
+ 勾(x.门槛, (v) => 改绑定(i, { 门槛: v }))),
372
+ h('div', { style: { textAlign: 'center' } },
373
+ 按钮(String(x.人设 || '').trim() ? '✓ 单独' : '单独', () => 设人设行(人设行 === i ? null : i), false, false)),
374
+ h('div', null, 按钮('删', () => 删绑定(i), false, false))),
375
+ // ⭐ 1.1.0:会话单独占一整行,宽度给够
376
+ h('div', { style: { display: 'grid', gridTemplateColumns: '70px 1fr', gap: 6, alignItems: 'center', margin: '0 0 6px' } },
377
+ h('div', { style: S.表头字 }, '会话'),
378
+ 会话选项(x.会话, (v) => 改绑定(i, { 会话: v }))),
379
+ // ⭐ 1.1.8:这一行自己的开关 —— 不想理就把「启用」取消,不用删掉
380
+ h('div', { style: { display: 'flex', gap: 16, alignItems: 'center', margin: '0 0 10px', paddingLeft: 2 } },
381
+ h('span', { style: { display: 'flex', gap: 4, alignItems: 'center' } },
382
+ 勾(x.开 !== false, (v) => 改绑定(i, { 开: v })),
383
+ h('span', { style: S.dim }, '启用(关掉 = 这个聊天不收了,也不发)')),
384
+ x.类型 === '群'
385
+ ? h('span', { style: { display: 'flex', gap: 4, alignItems: 'center' } },
386
+ 勾(x.只听点名 == null ? !配置.群里听所有人 : Boolean(x.只听点名), (v) => 改绑定(i, { 只听点名: v })),
387
+ h('span', { style: S.dim }, '只听 @ 我的'))
388
+ : null),
389
+ i === 人设行
390
+ ? h('div', { style: { margin: '0 0 8px', padding: '6px 8px', background: 'rgba(127,127,127,.06)', borderRadius: 4 } },
391
+ h('div', { style: { ...S.dim, marginBottom: 4 } }, '这一行单独用的人设(留空 = 用上面那份)'),
392
+ h('textarea', {
393
+ style: { ...S.inp, display: 'block', width: '100%', height: 72, resize: 'vertical', fontFamily: 'inherit', lineHeight: 1.5, boxSizing: 'border-box' },
394
+ value: String(x.人设 || ''),
395
+ placeholder: '比如:这个群里你话少,只接一两句,爱用「嗯」。',
396
+ onChange: (e) => 改绑定(i, { 人设: e.target.value }),
397
+ }),
398
+ h('div', { style: { marginTop: 4 } },
399
+ 按钮('清空', () => 改绑定(i, { 人设: '' }), false, false),
400
+ h('span', { style: S.dim }, ' '),
401
+ 按钮('收起', () => 设人设行(null), false, false)))
402
+ : null,
403
+ // ⭐ 1.4.3:这个群里「不听点名也直接收」的 QQ 号。
404
+ // 「只听点名」一开,群里**所有人**(包括自己)都要点名才收;这里列出来的号例外。
405
+ x.类型 === '群' && (x.只听点名 == null ? !配置.群里听所有人 : Boolean(x.只听点名))
406
+ ? h('div', { style: { margin: '0 0 8px' } },
407
+ h('div', { style: { ...S.dim, marginBottom: 3 } },
408
+ '不受点名限制的 QQ(逗号或空格分隔,留空 = 群里全都要点名)'),
409
+ h('input', {
410
+ style: { ...S.inp, display: 'block', width: '100%', boxSizing: 'border-box' },
411
+ value: String(x.点名例外 || ''),
412
+ placeholder: '例如 3274796953',
413
+ onChange: (e) => 改绑定(i, { 点名例外: e.target.value }),
414
+ }))
415
+ : null))),
416
+ h('div', { style: { marginTop: 8 } },
417
+ 按钮('+ 加一条', 加绑定, false, false),
418
+ h('span', { style: S.dim }, ' '),
419
+ 按钮('刷新会话清单', 拉, false, 忙)),
420
+ h('div', { style: { ...S.dim, marginTop: 6 } },
421
+ '「门槛」只有群有用:开了之后,那个群里的消息会先让它判断「是不是在跟我说话」,不是就停掉这一轮。'
422
+ + '私聊永远不判(你自己私聊,本来就是在跟它说话)。'),
423
+ ),
424
+ h('div', { style: S.card },
425
+ h('div', { style: { ...S.h, margin: '0 0 8px' } }, '群里怎么回(对所有群生效)'),
426
+ h('div', { style: S.dim },
427
+ '群里人多、话密的时候,一句一句回就像刷屏 —— 下面这几个数管的就是这个。@、引用 不受限制。'),
428
+ 行('两条之间至少隔(秒)', h('input', { type: 'number', min: 0, max: 300, style: S.small, value: String(配置.回复冷却秒 ?? 5), onChange: (e) => 改('回复冷却秒', Number(e.target.value)) }), '单位秒。回完一次要隔这么久才回下一条;这段时间里群里新的话先攒着,到点一起回。默认 5。'),
429
+ 行('一分钟最多回几条', h('input', { type: 'number', min: 1, max: 120, style: S.small, value: String(配置.每分钟最多条 ?? 4), onChange: (e) => 改('每分钟最多条', Number(e.target.value)) }), '一分钟里最多送进去几条。撞上了就等下一分钟再发 —— 是等,话不会丢。默认 4。'),
430
+ 行('一次最多攒几条', h('input', { type: 'number', min: 1, max: 50, style: S.small, value: String(配置.合并最多条 ?? 8), onChange: (e) => 改('合并最多条', Number(e.target.value)) }), '群里几个人说的话先合成一条(每句前面带「谁说的」),攒够这么多就立刻送进去,不用干等窗口。默认 8。'),
431
+ 行('私聊进来的话标来源', h('select', { style: S.small, value: String(配置.私聊署名 || '关'), onChange: (e) => 改('私聊署名', e.target.value) },
432
+ h('option', { value: '关' }, '不标'), h('option', { value: '前缀' }, '[QQ] 前缀'), h('option', { value: '署名' }, '署名(对方:…)')),
433
+ '默认不标。同一个会话里手机和电脑交替说话时,标一下才分得清哪句是从 QQ 来的。'),
434
+ 行('@ 我的一定回', h('input', { type: 'checkbox', checked: 配置.点名必回 !== false, onChange: (e) => 改('点名必回', Boolean(e.target.checked)) }), '关掉之后,「概率」连 @ / 引用 它的话也一起管。默认开:冲它来的就一定回。'),
435
+ 行('不是每句都接(概率 %)', h('input', { type: 'number', min: 0, max: 100, style: S.small, value: String(配置.回复概率 ?? 100), onChange: (e) => 改('回复概率', Number(e.target.value)) }), '100 = 每批都接(默认)。调小 = 群里闲聊有时就不理;没掷中的那批**根本不送进来**,不占上下文、也不花 token。'),
436
+ h('div', { style: { ...S.dim, marginTop: 4 } }, '「连着发的几条攒成一条」在「消息」页,默认 1 秒。'),
437
+ ),
438
+ )
439
+
440
+ // ───────────── 消息 ─────────────
441
+ const 消息页 = h('div', null,
442
+ h('div', { style: S.card },
443
+ h('div', { style: { ...S.h, margin: '0 0 8px' } }, '连到哪个 QQ 后端'),
444
+ 行('接口地址', h('input', { style: S.wide, value: String(配置.NapCat || ''), onChange: (e) => 改('NapCat', e.target.value) }), 'NapCat / SnowLuma 的 HTTP 地址,一般是 http://127.0.0.1:3000。'),
445
+ 行('接口令牌', h('input', { type: 'password', style: S.small, value: String(配置.令牌 || ''), onChange: (e) => 改('令牌', e.target.value) }), '后端开了 HTTP 鉴权才填(access_token);留空 = 不鉴权。'),
446
+ ),
447
+ h('div', { style: S.card },
448
+ h('div', { style: { ...S.h, margin: '0 0 8px' } }, '发出去的样子'),
449
+ 行('长回复拆成几条发', 勾(配置.长回复拆段, (v) => 改('长回复拆段', v)), '开着(默认):按空行拆成几条、像真人一条条发。关掉:一整段只发一条,不会刷屏。'),
450
+ 行('按句子拆(不是按空行)', 勾(配置.按句拆, (v) => 改('按句拆', v)), '开着:一个句号/问号/感叹号算一条,更像人一条条打字。括号里的标点不切。默认关(关着就还是按空行拆)。'),
451
+ 行('每段之间隔(毫秒)', h('input', { type: 'number', min: 0, max: 5000, style: S.small, value: String(配置.拆段间隔毫秒 ?? 450), onChange: (e) => 改('拆段间隔毫秒', Number(e.target.value)) }), '只在「拆成几条」开着时有用。默认 450。'),
452
+ ),
453
+ h('div', { style: S.card },
454
+ h('div', { style: { ...S.h, margin: '0 0 8px' } }, '收进来的样子'),
455
+ 行('连着发的几条攒成一条', h('input', { type: 'number', min: 0, max: 60, style: S.small, value: String(配置.合并窗口秒 ?? 3), onChange: (e) => 改('合并窗口秒', Number(e.target.value)) }), '单位秒。0 = 不攒(来一条转一条,会一条一条回)。默认 3:同一个人连着发的(比如一次发一串图)合成一条再转进去,它只回一次。'),
456
+ 行('最多攒多久', h('input', { type: 'number', min: 3, max: 300, style: S.small, value: String(配置.合并最多秒 ?? 20), onChange: (e) => 改('合并最多秒', Number(e.target.value)) }), '单位秒。有人一直发个不停时,到点也先转进来,不会无限等。默认 20。'),
457
+ 行('多久拉一次新消息(毫秒)', h('input', { type: 'number', min: 800, max: 30000, style: S.small, value: String(配置.轮询间隔毫秒 ?? 2000), onChange: (e) => 改('轮询间隔毫秒', Number(e.target.value)) }), '默认 2000(2 秒)。调小 = 消息进来更快,但请求 QQ 机器人更勤;改完要重启 DSH。'),
458
+ ),
459
+ h('div', { style: S.card },
460
+ h('div', { style: { ...S.h, margin: '0 0 8px' } }, '图片'),
461
+ 行('收图(QQ → 它)', 勾(配置.收图, (v) => 改('收图', v)), '你在 QQ 里发的图也转给它看。模型看得见图就直接看;纯文本模型只知道「有几张图、存在哪」。'),
462
+ 行('发图(它 → QQ)', 勾(配置.发图, (v) => 改('发图', v)), '它这一轮里发的图也发到 QQ。'),
463
+ 行('一条最多收几张', h('input', { type: 'number', min: 1, max: 10, style: S.small, value: String(配置.收图最多 ?? 4), onChange: (e) => 改('收图最多', Number(e.target.value)) }), '多出来的不转。一条最多 10 张。'),
464
+ 行('单张最大(MB)', h('input', { type: 'number', min: 1, max: 30, style: S.small, value: String(配置.图上限MB ?? 5), onChange: (e) => 改('图上限MB', Number(e.target.value)) }), '超过就跳过(两个方向都按这个)。'),
465
+ 行('图片根目录', h('input', { style: S.wide, placeholder: '(留空就行:自动用会话所在的工作区)', value: String(配置.图片根目录 || ''), onChange: (e) => 改('图片根目录', e.target.value) }), '一般不用填。它正文里写 ![名字](相对路径) 时先按这个目录找。'),
466
+ h('div', { style: S.dim }, '收进来的图会存一份到 %USERPROFILE%\\.dsh\\qq-bridge-inbox\\,文字里会带上路径。'),
467
+ ),
468
+ h('div', { style: S.card },
469
+ h('div', { style: { ...S.h, margin: '0 0 8px' } }, '文件'),
470
+ 行('发文件(它 → QQ)', 勾(配置.发文件, (v) => 改('发文件', v)), '它这一轮正文里写了 [报告](D:\\x\\报告.docx) 这种链接,就把那个文件传到 QQ。图片那种 ![名](路径) 不算文件。'),
471
+ 行('单个文件最大(MB)', h('input', { type: 'number', min: 1, max: 2048, style: S.small, value: String(配置.文件上限MB ?? 50), onChange: (e) => 改('文件上限MB', Number(e.target.value)) }), '超过就只发一句话说明,不硬传。QQ 自己也有单文件上限,太大那边会拒。'),
472
+ 行('只往私聊发(群里不发文件)', 勾(配置.文件仅私聊 !== false, (v) => 改('文件仅私聊', v)), '默认开。大群里发文件容易出问题(群文件权限、刷屏、审核),私聊最稳。关掉之后群里也会传。'),
473
+ h('div', { style: S.dim }, '路径带空格就用尖括号:[报告](<D:\\我的 文档\\报告.docx>)。网页链接不会被当成文件。'),
474
+ ),
475
+ h('div', { style: S.card },
476
+ h('div', { style: { ...S.h, margin: '0 0 8px' } }, '语音转写(QQ 语音条 → 文字)'),
477
+ 行('转写语音', 勾(配置.语音转写, (v) => 改('语音转写', v)), '开着时,QQ 里的语音条会交给下面那条命令转成文字再转给它;关着就只记一个 [语音]。'),
478
+ // —— 一键装依赖 ——
479
+ h('div', { style: { ...S.row, alignItems: 'center' } },
480
+ h('div', { style: S.lab }, '语音识别环境'),
481
+ h('div', { style: S.ctl },
482
+ h('div', null,
483
+ 按钮(asr忙 ? '…' : (asr && asr.就绪 ? '重装 / 修一下' : '自动装依赖'), 装语音, !(asr && asr.就绪), asr忙 || Boolean(asr && asr.进行中)),
484
+ h('span', { style: S.dim }, ' '),
485
+ 按钮('把命令填好', 填语音命令, false, false)),
486
+ h('div', { style: S.hint },
487
+ asr && asr.就绪 && asr.模型就绪
488
+ ? '环境 + 语音模型都就绪:' + String(asr.python || '')
489
+ : asr && asr.就绪
490
+ ? '环境装好了,但语音模型还没下来 —— 再点一次「自动装依赖」会接着下'
491
+ : '还没装。点「自动装依赖」会建一个独立环境、装 sherpa-onnx + numpy(几十 MB,第一次要几分钟)。'),
492
+ // 进度
493
+ asr && asr.步骤 && asr.步骤.length
494
+ ? h('div', { style: { marginTop: 8 } },
495
+ ...asr.步骤.map((x, i) => h('div', { key: i, style: { fontSize: 12 } },
496
+ (x.态 === '完成' ? '✓ ' : x.态 === '失败' ? '✗ ' : '⋯ ') + x.名 + (x.附 ? '(' + x.附 + ')' : ''))))
497
+ : null,
498
+ // —— 下载进度条(模型 230MB,得让人看见下了多少)——
499
+ asr && asr.下载
500
+ ? h('div', { style: { marginTop: 8 } },
501
+ h('div', { style: { height: 8, borderRadius: 6, background: 'rgba(128,128,128,.25)', overflow: 'hidden' } },
502
+ h('div', {
503
+ style: {
504
+ height: '100%', width: String(asr.下载.百分比 || 0) + '%',
505
+ background: 'var(--dsw-alias-state-business-primary)', transition: 'width .4s',
506
+ },
507
+ })),
508
+ h('div', { style: { ...S.dim, marginTop: 4 } },
509
+ (asr.下载.文件 ? '正在下 ' + asr.下载.文件 + ':' : '')
510
+ + '已下 ' + Math.round((asr.下载.已收 || 0) / 1048576) + 'MB'
511
+ + (asr.下载.总 ? ' / ' + Math.round(asr.下载.总 / 1048576) + 'MB' : '')
512
+ + '(' + String(asr.下载.百分比 || 0) + '%)'
513
+ + (asr.下载.速度KB ? ' 约 ' + (asr.下载.速度KB > 1024 ? (asr.下载.速度KB / 1024).toFixed(1) + 'MB/s' : asr.下载.速度KB + 'KB/s') : '')))
514
+ : null,
515
+ asr && asr.进行中 ? h('div', { style: { ...S.dim, marginTop: 4 } }, '正在装…已 ' + String(asr.用时秒 || 0) + ' 秒(每 2 秒刷新一次)') : null,
516
+ asr && asr.失败 ? h('div', { style: { color: '#c0392b', fontSize: 12, marginTop: 4 } }, '✗ ' + asr.失败) : null,
517
+ asr && asr.日志
518
+ ? h('div', { style: { marginTop: 8 } },
519
+ h('div', { style: S.dim }, '安装日志(最后几行)'),
520
+ h('div', { style: { ...S.日志行, maxHeight: 160, overflowY: 'auto', whiteSpace: 'pre-wrap', marginTop: 4 } }, String(asr.日志)))
521
+ : null)),
522
+ 行('转写命令', h('input', {
523
+ style: S.wide,
524
+ placeholder: 'python D:\\asr_cli.py "{file}"',
525
+ value: String(配置.转写命令 || ''),
526
+ onChange: (e) => 改('转写命令', e.target.value),
527
+ }), '模板里必须有 {file}(会换成音频路径,一般是 wav)。命令往 stdout 打一行文字就算成功;非 0 退出码当失败。'),
528
+ 行('语音模型目录', h('input', {
529
+ style: S.wide,
530
+ placeholder: '(装依赖时会自动填好,不用手填)',
531
+ value: String(配置.语音模型目录 || ''),
532
+ onChange: (e) => 改('语音模型目录', e.target.value),
533
+ }), '一般不用管:点「自动装依赖」会把语音模型一起下下来、路径自动写好。只有你想换成自己已有的模型(比如 whisper)时才需要改。'),
534
+ 行('超时(秒)', h('input', { type: 'number', min: 5, max: 600, style: S.small, value: String(配置.转写超时秒 ?? 60), onChange: (e) => 改('转写超时秒', Number(e.target.value)) }), '超过就杀掉命令、按失败处理(默认 60 秒)。'),
535
+ h('div', { style: S.dim },
536
+ '插件自己不带语音模型(不然包会变成几百 MB)。仓库里给了一个现成接法:tools/asr_cli.py(本机 SenseVoice);你有 whisper 或云端接口的话,写一条「读 {file}、打一行字」的命令接上就行。'),
537
+ ),
538
+ )
539
+
540
+ // ───────────── 门槛 ─────────────
541
+ const 门槛页 = h('div', null,
542
+ h('div', { style: S.card },
543
+ h('div', { style: { ...S.h, margin: '0 0 8px' } }, '回复门槛'),
544
+ h('div', { style: S.dim },
545
+ '没有「总开关」——直接去「绑定」页签,给想开的那一行打勾就行;一个群开、别的群照常。'
546
+ + '私聊永远不判(你自己私聊本来就是在跟它说话)。'),
547
+ h('div', { style: { ...S.dim, marginTop: 6 } },
548
+ '开了之后:那个群的 QQ 消息照样转进来,但会先让它自己判断「这条是不是在跟我说话」——不是就当场把这一轮停掉,什么都不回。'),
549
+ 行('判定不接时:真把这一轮停掉', 勾(配置.门槛真停轮, (v) => 改('门槛真停轮', v)), '关(默认):只把这轮的话拦住、不发到 QQ,界面上什么异常都没有。开:真的停掉这一轮(省 token),但界面上会显示一次 `Error: tool call aborted` —— 那是「停轮」的表现,不是故障。'),
550
+ h('div', { style: { ...S.dim, marginTop: 6 } },
551
+ '⚠ 给某个群打开门槛那一刻,那一间会话的下一次请求会全量重算一遍(因为它多了一个工具);关着的时候没有影响。'),
552
+ h('div', { style: { ...S.dim, marginTop: 8, fontWeight: 600 } }, '群里听谁的'),
553
+ 行('群里也听别人的话', 勾(配置.群里听所有人, (v) => 改('群里听所有人', v)),
554
+ '关(默认):群里**只把点了名的**话转进来(@ 你、或引用你说的话)—— 省额度,也不会被别人的闲聊牵着走。'
555
+ + '开:群里所有人说的话都转进来,交给上面的「门槛」去判断要不要接。'),
556
+ // ⭐ 1.4.1:被点名之后,同一个群后面紧跟着的几句也放进来(不然接话接不上前文)
557
+ 行('点名之后接着听几条', h('input', { type: 'number', min: 0, max: 50, style: S.small, value: String(配置.点名后接听条 ?? 5), onChange: (e) => 改('点名后接听条', Number(e.target.value)) }),
558
+ '被 @ 或者引用之后,同一个群里紧接着的几句话也会转进来,接够这么多条就停,回到「只听点名」。默认 5;填 0 = 不接(只有点名的那一条进得来)。'),
559
+ 行('接着听多少秒', h('input', { type: 'number', min: 0, max: 600, style: S.small, value: String(配置.点名后接听秒 ?? 120), onChange: (e) => 改('点名后接听秒', Number(e.target.value)) }),
560
+ '单位秒。过了这么久也停,就算条数还没接完。默认 120。'),
561
+ ),
562
+ )
563
+
564
+ // ───────────── 空间 ─────────────
565
+ const 空间定时 = () => {
566
+ const a = 配置.空间定时发
567
+ return Array.isArray(a) ? a : []
568
+ }
569
+ const 设空间定时 = (下一份) => 改('空间定时发', 下一份)
570
+ const 改空间定时 = (i, 补) => 设空间定时(空间定时().map((x, j) => (j === i ? { ...x, ...补 } : x)))
571
+ const 加空间定时 = () => 设空间定时([...空间定时(), { 时: '09:30', 权: 1, 开: true }].slice(0, 12))
572
+ const 删空间定时 = (i) => 设空间定时(空间定时().filter((_, j) => j !== i))
573
+
574
+ const 空间页 = h('div', null,
575
+ h('div', { style: S.card },
576
+ h('div', { style: { ...S.h, margin: '0 0 8px' } }, '开关'),
577
+ 行('允许发 QQ 空间动态', 勾(配置.允许发空间动态, (v) => 改('允许发空间动态', v)), '默认关。关着的时候那几个工具还在,但只会说一句「没开」,不会真的发出去。看说说、看好友动态不受这个开关影响。'),
578
+ h('div', { style: S.dim }, '空间走的是「消息」页那个接口地址和令牌,不用另填。'),
579
+ ),
580
+ h('div', { style: S.card },
581
+ h('div', { style: { ...S.h, margin: '0 0 8px' } }, '定时发说说'),
582
+ h('div', { style: S.dim }, '到点了会往绑定的会话里递一句提醒,说什么由它自己写 —— 这里只定时间。'),
583
+ h('div', { style: { ...S.空间表头, marginTop: 10 } },
584
+ h('div', { style: S.表头字 }, '时间'),
585
+ h('div', { style: S.表头字 }, '可见范围'),
586
+ h('div', { style: S.表头字 }, '启用'),
587
+ h('div', { style: S.表头字 }, '')),
588
+ 空间定时().length === 0
589
+ ? h('div', { style: { ...S.dim, margin: '8px 0' } }, '还没有 —— 点下面的「+ 加一条」')
590
+ : h('div', null, ...空间定时().map((x, i) => h('div', { key: i, style: S.空间行 },
591
+ h('input', {
592
+ type: 'time', style: S.wide, value: String(x.时 || '09:30'),
593
+ onChange: (e) => 改空间定时(i, { 时: e.target.value }),
594
+ }),
595
+ h('select', {
596
+ style: S.wide, value: String(x.权 || 1),
597
+ onChange: (e) => 改空间定时(i, { 权: Number(e.target.value) }),
598
+ },
599
+ h('option', { value: '1' }, '所有人'),
600
+ h('option', { value: '4' }, '好友'),
601
+ h('option', { value: '64' }, '仅自己')),
602
+ h('div', { style: { textAlign: 'center' } }, 勾(x.开 !== false, (v) => 改空间定时(i, { 开: v }))),
603
+ h('div', null, 按钮('删', () => 删空间定时(i), false, false))))),
604
+ h('div', { style: { marginTop: 8 } },
605
+ 按钮('+ 加一条', 加空间定时, false, false),
606
+ h('span', { style: S.dim }, ' 最多 12 条')),
607
+ ),
608
+ h('div', { style: S.card },
609
+ h('div', { style: { ...S.h, margin: '0 0 8px' } }, '定时看一圈'),
610
+ 行('多久看一圈(分钟)', h('input', { type: 'number', min: 5, max: 1440, style: S.small, value: String(配置.空间看圈分 ?? 30), onChange: (e) => 改('空间看圈分', Number(e.target.value)) }), '5~1440,默认 30。'),
611
+ 行('开着', 勾(配置.空间看圈开, (v) => 改('空间看圈开', v)), '开着才会定时去拉好友动态;关着什么都不做。'),
612
+ ),
613
+ h('div', { style: S.card },
614
+ h('div', { style: { ...S.h, margin: '0 0 8px' } }, '点赞 / 评论'),
615
+ 行('自动点赞', 勾(配置.空间点赞开, (v) => 改('空间点赞开', v)), '看一圈的时候,最近几天里没点过的会顺手点一下。每次最多 3 条、一天最多 20 条。'),
616
+ 行('允许评论', 勾(配置.空间评论开, (v) => 改('空间评论开', v)), '看一圈的时候把新动态递进会话,评不评、评什么由它自己决定 —— 这里不替你写评论内容。'),
617
+ ),
618
+ )
619
+
620
+ // ───────────── 更新 ─────────────
621
+ const 查更新 = async () => {
622
+ 设更新忙(true)
623
+ const r = await get('/update?refresh=1')
624
+ if (r) 设更新(r)
625
+ 设更新忙(false)
626
+ 设说(!r ? '查不到(网络)' : r.hasUpdate ? '有新版本 v' + r.latest : r.ok ? '已经是最新版' : '查不到:' + (r.error || '网络'))
627
+ }
628
+ // ⭐ 2026-11-01:查到新版就一键装(宿主的 pluginManager.installBundle)
629
+ const 一键更新 = async () => {
630
+ 设更新忙(true)
631
+ 设说('正在装…')
632
+ try {
633
+ const r = await post('/install', {})
634
+ 设说(r && r.ok ? '已经开始装了 —— 装完重启(或重载)一次 dsh 才生效' : '没装成:' + ((r && r.error) || '不知道原因'))
635
+ } catch (e) {
636
+ 设说('没装成:' + ((e && e.message) || e))
637
+ }
638
+ 设更新忙(false)
639
+ }
640
+ const 更新页 = h('div', null,
641
+ h('div', { style: S.card },
642
+ h('div', { style: { ...S.h, margin: '0 0 8px' } }, '检查更新'),
643
+ h('div', { style: S.dim },
644
+ !更新 ? '正在看有没有新版…'
645
+ : 更新.hasUpdate ? '仓库里已经有 v' + 更新.latest + ' 了'
646
+ : 更新.ok ? '已经是最新版' : '暂时查不到(' + (更新.error || '网络') + ')—— 这不代表没有问题'),
647
+ h('div', { style: { marginTop: 6 } }, 按钮(更新忙 ? '查…' : '检查更新', 查更新, false, 更新忙)),
648
+ 更新 && 更新.hasUpdate
649
+ ? h('div', { style: { marginTop: 8 } },
650
+ (更新 && 更新.canInstall)
651
+ ? 按钮(更新忙 ? '装…' : '一键更新到 v' + 更新.latest, 一键更新, false, 更新忙)
652
+ : h('div', { style: S.dim }, '这是本地开发副本(link: 进来的),请手动更新。'),
653
+ h('div', { style: S.dim }, '装完重启(或重载)一次 dsh 才生效。'))
654
+ : null,
655
+ ),
656
+ )
657
+
658
+ return h(
659
+ 'div',
660
+ { style: S.wrap },
661
+ h('div', { style: S.h }, '通到 QQ'),
662
+ h('div', { style: S.dim },
663
+ '它在 DSH 里说完一整轮,就把那句原文发到 QQ;你在 QQ 里说的话也会转进来。'
664
+ + '版本 ' + String(态.版本 || '?') + '|配置文件:' + String(态.配置文件 || '')),
665
+ NapCat && !NapCat.错
666
+ ? h('div', { style: S.已同步 }, '✓ 连着的 QQ:' + NapCat.昵称 + '(' + NapCat.用户 + ')')
667
+ : h('div', { style: S.未保存 }, '⚠ 连不上 QQ 机器人(NapCat / SnowLuma)' + (NapCat && NapCat.错 ? ':' + NapCat.错 : '')),
668
+ 页签,
669
+ h('div', { style: 脏 ? S.未保存 : S.已同步 },
670
+ 脏 ? '⚠ 有改动还没保存 —— 改完点「保存设置」(底下也会浮一条出来)' : '设置已同步 ✓'),
671
+ 页 === '总览' ? 总览页 : null,
672
+ 页 === '绑定' ? 绑定页 : null,
673
+ 页 === '消息' ? 消息页 : null,
674
+ 页 === '空间' ? 空间页 : null,
675
+ 页 === '门槛' ? 门槛页 : null,
676
+ 页 === '更新' ? 更新页 : null,
677
+ 脏
678
+ ? h('div', { style: S.悬浮保存条 },
679
+ h('span', { style: { fontWeight: 600, color: '#c0392b' } }, '⚠ 有改动还没保存'),
680
+ h('span', { style: S.dim }, '改完点这里就行'),
681
+ 按钮('保存设置', 存, true, 忙))
682
+ : null,
683
+ )
684
+ }
685
+
686
+ function apply(ctx) {
687
+ const slots = ctx.get('slots')
688
+ if (slots === undefined) return
689
+ slots.inject('settings.section', () =>
690
+ slots.register(
691
+ { name: 'settings.section', id: 'qq-bridge', order: 54, label: () => '通到 QQ' },
692
+ Section,
693
+ ),
694
+ )
695
+ }
696
+
697
+ exports.apply = apply
698
+ exports.inject = inject
699
+ return module.exports
700
+ },
701
+ })