dsh-roost 0.0.0-stage → 0.3.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.
@@ -0,0 +1,563 @@
1
+ // dsh-roost · 网页半边:侧栏「Roost」入口 + 整页面板
2
+ //
3
+ // 只读写宿主半边的 /roost/api/* 接口,不直接碰会话和 QQ。
4
+ //
5
+ // 设计取向:这是一块**仪表盘**,不是营销页。
6
+ // · 首屏回答一个问题:「我卡在哪一步」
7
+ // · 用一条竖直的「归巢路径」把六步串起来,节点本身就是进度条
8
+ // · 颜色只用来表达状态,装饰一律不加
9
+ // · 视觉沿用 DSH 的主题变量(--dsw-alias-*),浅色深色自动跟随
10
+
11
+ window.__ModuleLoader__.load({
12
+ id: 'dsh-roost',
13
+ factory: (require) => {
14
+ var module = { exports: {} }
15
+ var exports = module.exports
16
+
17
+ const React = require('react')
18
+ const { useState, useEffect, useCallback, useRef } = React
19
+ const h = React.createElement
20
+ const inject = ['slots']
21
+ const PANEL_ID = 'roost'
22
+ const API = '/roost/api'
23
+
24
+ /* ---------------- 请求 ---------------- */
25
+
26
+ const get = (p) =>
27
+ fetch(API + p, { cache: 'no-store' })
28
+ .then((r) => r.json())
29
+ .catch((e) => ({ ok: false, message: '连不上插件:' + e.message }))
30
+
31
+ const post = (p, body) =>
32
+ fetch(API + p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body || {}) })
33
+ .then((r) => r.json())
34
+ .catch((e) => ({ ok: false, message: '连不上插件:' + e.message }))
35
+
36
+ const clone = (o) => JSON.parse(JSON.stringify(o))
37
+ const setPath = (obj, path, value) => {
38
+ const next = clone(obj)
39
+ let cur = next
40
+ const ks = path.split('.')
41
+ for (let i = 0; i < ks.length - 1; i++) cur = cur[ks[i]] = cur[ks[i]] || {}
42
+ cur[ks[ks.length - 1]] = value
43
+ return next
44
+ }
45
+ const getPath = (obj, path) => path.split('.').reduce((o, k) => (o == null ? undefined : o[k]), obj)
46
+ const ago = (t) => {
47
+ if (!t) return '—'
48
+ const s = Math.max(0, Math.round((Date.now() - t) / 1000))
49
+ if (s < 60) return s + ' 秒前'
50
+ if (s < 3600) return Math.round(s / 60) + ' 分钟前'
51
+ return Math.round(s / 3600) + ' 小时前'
52
+ }
53
+
54
+ /* ---------------- 设计令牌 ---------------- */
55
+
56
+ const MONO = 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace'
57
+ const C = {
58
+ text: 'var(--dsw-alias-text-primary, #1f2328)',
59
+ dim: 'var(--dsw-alias-text-secondary, #6b7280)',
60
+ line: 'var(--dsw-alias-border, #e5e7eb)',
61
+ card: 'var(--dsw-alias-bg-elevated, transparent)',
62
+ input: 'var(--dsw-alias-bg-input, transparent)',
63
+ accent: 'var(--dsw-alias-accent, #2563eb)',
64
+ ok: '#12855a',
65
+ warn: '#b45309',
66
+ bad: '#b42318',
67
+ }
68
+
69
+ const S = {
70
+ wrap: { padding: '22px 24px 40px', color: C.text, fontSize: 13, lineHeight: 1.62, maxWidth: 760 },
71
+ h1: { fontSize: 17, fontWeight: 650, margin: 0, letterSpacing: '-0.01em' },
72
+ sub: { color: C.dim, fontSize: 12, marginTop: 3 },
73
+ card: { border: `1px solid ${C.line}`, borderRadius: 10, padding: '14px 16px', marginBottom: 12, background: C.card },
74
+ row: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, padding: '6px 0' },
75
+ input: {
76
+ width: '100%', boxSizing: 'border-box', padding: '7px 10px', borderRadius: 7,
77
+ border: `1px solid ${C.line}`, background: C.input, color: 'inherit', fontSize: 13, fontFamily: 'inherit',
78
+ },
79
+ btn: {
80
+ padding: '5px 12px', borderRadius: 7, border: `1px solid ${C.line}`, background: C.card,
81
+ color: 'inherit', cursor: 'pointer', fontSize: 12, fontFamily: 'inherit', whiteSpace: 'nowrap',
82
+ },
83
+ btnPrimary: {
84
+ padding: '7px 16px', borderRadius: 7, border: '1px solid transparent', background: C.accent,
85
+ color: '#fff', cursor: 'pointer', fontSize: 12.5, fontWeight: 550, fontFamily: 'inherit',
86
+ },
87
+ tabs: { display: 'flex', gap: 2, margin: '18px 0 16px', borderBottom: `1px solid ${C.line}` },
88
+ mono: { fontFamily: MONO, fontSize: 11.5 },
89
+ muted: { color: C.dim, fontSize: 12 },
90
+ }
91
+
92
+ /* ---------------- 全局样式(动画与节点) ---------------- */
93
+
94
+ function useStyle() {
95
+ useEffect(() => {
96
+ if (document.getElementById('roost-style')) return
97
+ const el = document.createElement('style')
98
+ el.id = 'roost-style'
99
+ el.textContent = `
100
+ .roost-node{position:relative;flex:0 0 26px;height:26px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-family:${MONO};font-size:11px;font-weight:600;transition:transform .18s cubic-bezier(.2,.8,.2,1),background .18s,border-color .18s}
101
+ .roost-node.done{background:${C.ok};border:1px solid ${C.ok};color:#fff}
102
+ .roost-node.todo{border:1px dashed ${C.line};color:${C.dim};background:transparent}
103
+ .roost-node.current{border:1px solid ${C.warn};color:${C.warn};background:transparent}
104
+ .roost-node.bad{border:1px solid ${C.bad};color:${C.bad};background:transparent}
105
+ .roost-node.current::after{content:'';position:absolute;inset:-4px;border-radius:50%;border:1px solid ${C.warn};opacity:.45;animation:roost-breathe 2.4s cubic-bezier(.4,0,.6,1) infinite}
106
+ @keyframes roost-breathe{0%{transform:scale(.92);opacity:.5}50%{transform:scale(1.12);opacity:0}100%{transform:scale(.92);opacity:0}}
107
+ .roost-step{display:flex;gap:14px;padding:0 0 20px;position:relative}
108
+ .roost-step:last-child{padding-bottom:2px}
109
+ .roost-rail{position:absolute;left:12.5px;top:28px;bottom:2px;width:1px;background:${C.line}}
110
+ .roost-step:last-child .roost-rail{display:none}
111
+ .roost-step.done .roost-rail{background:${C.ok};opacity:.35}
112
+ .roost-body{flex:1;min-width:0;padding-top:2px}
113
+ .roost-title{font-weight:600;font-size:13px}
114
+ .roost-desc{color:${C.dim};font-size:12px;margin-top:2px}
115
+ .roost-fade{animation:roost-in .22s cubic-bezier(.2,.8,.2,1)}
116
+ @keyframes roost-in{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:none}}
117
+ @media (prefers-reduced-motion: reduce){.roost-node.current::after{animation:none}.roost-fade{animation:none}}
118
+ `
119
+ document.head.appendChild(el)
120
+ }, [])
121
+ }
122
+
123
+ /* ---------------- 控件 ---------------- */
124
+
125
+ const Field = ({ label, desc, children }) =>
126
+ h('label', { style: { display: 'block', marginBottom: 14 } },
127
+ h('div', { style: { marginBottom: 5, fontWeight: 550, fontSize: 12.5 } }, label),
128
+ children,
129
+ desc ? h('div', { style: { ...S.muted, marginTop: 5 } }, desc) : null,
130
+ )
131
+
132
+ const Tab = ({ id, cur, onClick, children }) =>
133
+ h('button', {
134
+ type: 'button', onClick: () => onClick(id),
135
+ style: {
136
+ padding: '8px 14px', border: 'none',
137
+ borderBottom: cur === id ? `2px solid ${C.accent}` : '2px solid transparent',
138
+ background: 'transparent', color: 'inherit', cursor: 'pointer',
139
+ fontSize: 13, fontWeight: cur === id ? 600 : 400, marginBottom: -1, fontFamily: 'inherit',
140
+ },
141
+ }, children)
142
+
143
+ /** 路径上的一个节点 */
144
+ const Step = ({ n, state, title, desc, children }) =>
145
+ h('div', { className: 'roost-step ' + state },
146
+ h('div', { className: 'roost-rail' }),
147
+ h('div', { className: 'roost-node ' + state }, state === 'done' ? '✓' : state === 'bad' ? '!' : n),
148
+ h('div', { className: 'roost-body' },
149
+ h('div', { style: { display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12 } },
150
+ h('div', { style: { minWidth: 0 } },
151
+ h('div', { className: 'roost-title' }, title),
152
+ desc ? h('div', { className: 'roost-desc' }, desc) : null,
153
+ ),
154
+ h('div', { style: { display: 'flex', gap: 6, alignItems: 'center', flexShrink: 0 } }, children),
155
+ ),
156
+ ),
157
+ )
158
+
159
+ /* ---------------- 一键流程 ---------------- */
160
+
161
+ function SetupTab({ st, reload, toast, busy, run }) {
162
+ const [appId, setAppId] = useState('')
163
+ const [secret, setSecret] = useState('')
164
+
165
+ // 六步状态全部从 st 推出来,不做本地状态机——刷新就是真相
166
+ const s = {
167
+ install: st.installed ? 'done' : 'current',
168
+ bot: st.hasBot ? 'done' : st.installed ? 'current' : 'todo',
169
+ start: st.running ? 'done' : st.hasBot ? 'current' : 'todo',
170
+ plugin: st.pluginInstalled ? 'done' : st.running ? 'current' : 'todo',
171
+ configure: st.pluginConfigured ? 'done' : st.pluginInstalled ? 'current' : 'todo',
172
+ ready: st.pluginConfigured ? 'done' : 'todo',
173
+ }
174
+
175
+ const copy = (t) => {
176
+ try {
177
+ navigator.clipboard?.writeText(t)
178
+ toast('已复制')
179
+ } catch {
180
+ toast('复制失败,手动选一下吧')
181
+ }
182
+ }
183
+
184
+ return h('div', null,
185
+
186
+ h('div', { style: { ...S.card, display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 } },
187
+ h('div', null,
188
+ h('div', { style: { fontWeight: 600 } }, st.pluginConfigured ? '链路已就绪' : '还没配好'),
189
+ h('div', { style: S.muted }, st.pluginConfigured
190
+ ? '群里说一句话试试。没反应就看下面的「常见卡点」。'
191
+ : '点右边的按钮,能自动做的都会做掉。'),
192
+ ),
193
+ h('button', {
194
+ style: { ...S.btnPrimary, opacity: busy ? 0.6 : 1 },
195
+ disabled: Boolean(busy),
196
+ onClick: () => run({ appId, secret }),
197
+ }, busy ? busy + '…' : st.pluginConfigured ? '重新检查' : '一键就绪'),
198
+ ),
199
+
200
+ h('div', { style: { padding: '4px 2px 0' } },
201
+
202
+ h(Step, {
203
+ n: 1, state: s.install, title: '装 Roost',
204
+ desc: st.installed ? `已装 v${st.version || '?'}` : '下载解压就行——Roost 零依赖,不用 npm install',
205
+ },
206
+ st.installed
207
+ ? h('button', { style: S.btn, onClick: () => copy(st.dir), title: st.dir }, '复制路径')
208
+ : h('button', { style: S.btn, disabled: Boolean(busy), onClick: () => run({ only: 'install' }) }, '安装'),
209
+ ),
210
+ st.installed ? h('div', { style: { ...S.mono, ...S.muted, marginTop: -12, marginBottom: 14, paddingLeft: 40 } }, st.dir) : null,
211
+
212
+ h(Step, {
213
+ n: 2, state: s.bot, title: '填 AppID / AppSecret',
214
+ desc: '开放平台 → 你的机器人 → 开发设置。这一步没法自动,只能你填',
215
+ }),
216
+ st.hasBot
217
+ ? h('div', { style: { ...S.mono, ...S.muted, marginTop: -12, marginBottom: 14, paddingLeft: 40 } },
218
+ '已配置:' + st.bots.map((b) => `${b.name}(${b.appId || '无 AppID'})`).join('、'))
219
+ : h('div', { style: { marginTop: -8, marginBottom: 18, paddingLeft: 40, maxWidth: 420 } },
220
+ h('input', { style: { ...S.input, marginBottom: 8 }, placeholder: 'AppID', value: appId, onChange: (e) => setAppId(e.target.value) }),
221
+ h('input', { style: { ...S.input, marginBottom: 8 }, placeholder: 'AppSecret', type: 'password', value: secret, onChange: (e) => setSecret(e.target.value) }),
222
+ h('button', {
223
+ style: S.btn, disabled: !appId || !secret || Boolean(busy),
224
+ onClick: async () => {
225
+ const r = await post('/setup/bot', { name: 'main', appId, secret })
226
+ toast(r.ok ? '已写入配置' : r.message)
227
+ reload()
228
+ },
229
+ }, '保存'),
230
+ ),
231
+
232
+ h(Step, {
233
+ n: 3, state: s.start, title: '启动 Roost',
234
+ desc: st.running ? '在跑,面板已就绪' : '启动后它的面板在本机 8080',
235
+ },
236
+ st.running
237
+ ? h('button', { style: S.btn, onClick: () => window.open(st.webui, '_blank', 'noopener') }, '打开面板')
238
+ : h('button', { style: S.btn, disabled: !st.hasBot || Boolean(busy), onClick: () => run({ only: 'start' }) }, '启动'),
239
+ ),
240
+
241
+ h(Step, {
242
+ n: 4, state: s.plugin, title: '装 ai-chat 插件',
243
+ desc: st.pluginInstalled ? '已装' : 'Roost 那边负责收发 QQ 消息的插件',
244
+ },
245
+ st.pluginInstalled
246
+ ? null
247
+ : h('button', { style: S.btn, disabled: !st.running || Boolean(busy), onClick: () => run({ only: 'plugin' }) }, '安装'),
248
+ ),
249
+
250
+ h(Step, {
251
+ n: 5, state: s.configure, title: '切到 DSH 适配器',
252
+ desc: st.pluginConfigured
253
+ ? `ai-chat 已经指向 ${st.pluginConfig?.dshUrl || st.dshUrl}`
254
+ : '把 ai-chat 的「交给谁去想」改成 DSH,并填好回话地址',
255
+ },
256
+ st.pluginConfigured
257
+ ? null
258
+ : h('button', { style: S.btn, disabled: !st.pluginInstalled || Boolean(busy), onClick: () => run({ only: 'configure' }) }, '配置'),
259
+ ),
260
+
261
+ h(Step, {
262
+ n: 6, state: s.ready, title: '在群里说句话',
263
+ desc: st.pluginConfigured
264
+ ? '还差两件手工的事:开放平台开「接收所有消息」,机器人先进群'
265
+ : '前面五步走完就能试了',
266
+ },
267
+ st.pluginConfigured
268
+ ? h('button', { style: S.btn, onClick: () => window.open('https://q.qq.com', '_blank', 'noopener') }, '开放平台')
269
+ : null,
270
+ ),
271
+ ),
272
+
273
+ h('div', { style: { ...S.card, marginTop: 6 } },
274
+ h('div', { style: { fontWeight: 600, marginBottom: 6 } }, '常见卡点'),
275
+ h('ul', { style: { margin: 0, paddingLeft: 18, ...S.muted } },
276
+ h('li', null, '启动后马上退出:多半是 AppID/AppSecret 填错了,或 8080 被占'),
277
+ h('li', null, '群里没反应:开放平台没开「接收所有消息」,或机器人不在群里'),
278
+ h('li', null, '有反应但答非所问:ai-chat 的「什么时候接话」被设成了「只在被 @ 时」'),
279
+ ),
280
+ ),
281
+ )
282
+ }
283
+
284
+ /* ---------------- 状态 ---------------- */
285
+
286
+ function StatusTab({ st, reload }) {
287
+ const perms = st.permissions || {}
288
+ const toolable = ['owner', 'admin', 'member'].filter((k) => perms[k] === 'all')
289
+ const rows = [
290
+ ['监听', st.listening ? `127.0.0.1:${st.port}` : '未启动', st.listening],
291
+ ['共享密钥', st.tokenSet ? '已设置' : '未设置(仅本机可访问)', st.tokenSet],
292
+ ['已连 QQ 会话', `${st.chats} 个`, st.chats > 0],
293
+ ['正在等回复', `${st.waiting} 个`, null],
294
+ ['能用本地工具', toolable.length ? toolable.join(' / ') : '只有主人', null],
295
+ ]
296
+
297
+ return h('div', null,
298
+ h('div', { style: S.card }, rows.map(([k, v, ok]) =>
299
+ h('div', { key: k, style: S.row },
300
+ h('span', { style: S.muted }, k),
301
+ h('span', { style: { display: 'flex', alignItems: 'center', gap: 6 } },
302
+ ok === null ? null : h('i', { style: { width: 6, height: 6, borderRadius: 9, background: ok ? C.ok : C.dim } }),
303
+ v,
304
+ ),
305
+ ),
306
+ )),
307
+
308
+ st.lastTurn
309
+ ? h('div', { style: S.card },
310
+ h('div', { style: { fontWeight: 600, marginBottom: 6 } }, '最近一轮'),
311
+ h('div', { style: S.muted },
312
+ `${st.lastTurn.chatId.slice(0, 8)} · ${st.lastTurn.bucket || '?'}/${st.lastTurn.mode || '?'} · `
313
+ + `${st.lastTurn.ok ? `回了 ${st.lastTurn.len} 字` : `失败:${st.lastTurn.error}`} · ${st.lastTurn.ms}ms · ${ago(st.lastTurn.at)}`),
314
+ )
315
+ : null,
316
+
317
+ h('div', { style: { display: 'flex', gap: 8 } },
318
+ h('button', { style: S.btn, onClick: reload }, '刷新'),
319
+ ),
320
+
321
+ h('div', { style: { ...S.muted, marginTop: 14 } },
322
+ 'ai-chat 的「DSH 地址」要填 ',
323
+ h('code', { style: S.mono }, `http://127.0.0.1:${st.port}`),
324
+ ',两边端口一致才连得上。'),
325
+ )
326
+ }
327
+
328
+ /* ---------------- 会话 ---------------- */
329
+
330
+ function ChatsTab({ chats, reload, toast }) {
331
+ if (!chats.length) {
332
+ return h('div', { style: S.card },
333
+ h('div', { style: { fontWeight: 600, marginBottom: 4 } }, '还没有会话'),
334
+ h('div', { style: S.muted }, '群里说第一句话时会自动建一个 DSH 会话。每个 QQ 会话对应一个,上下文互不串。'))
335
+ }
336
+ return h('div', null, chats.map((c) =>
337
+ h('div', { key: c.chatId, style: S.card },
338
+ h('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 12 } },
339
+ h('div', { style: { minWidth: 0 } },
340
+ h('div', { style: { fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis' } }, c.title || c.chatId),
341
+ h('div', { style: { ...S.mono, ...S.muted, marginTop: 3 } }, `${c.chatId} · ${c.preset}`),
342
+ h('div', { style: S.muted }, `最后活跃 ${ago(c.at)} · DSH 会话 ${String(c.sessionId).slice(-8)}`),
343
+ ),
344
+ h('button', {
345
+ style: S.btn,
346
+ onClick: async () => {
347
+ const r = await post('/chats/clear', { chatId: c.chatId })
348
+ toast(r.ok ? '已清掉映射' : r.message)
349
+ reload()
350
+ },
351
+ }, '清空'),
352
+ ),
353
+ ),
354
+ ))
355
+ }
356
+
357
+ /* ---------------- 设置 ---------------- */
358
+
359
+ function ConfigTab({ config, save, toast }) {
360
+ const [draft, setDraft] = useState(config)
361
+ useEffect(() => setDraft(config), [config])
362
+ const set = (p, v) => setDraft((d) => setPath(d, p, v))
363
+ const val = (p) => getPath(draft, p)
364
+
365
+ return h('div', null,
366
+ h('div', { style: S.card },
367
+ h('div', { style: { fontWeight: 600, marginBottom: 10 } }, '连接'),
368
+ h(Field, { label: '监听端口', desc: 'ai-chat 的「DSH 地址」要指向这个端口' },
369
+ h('input', { style: S.input, value: val('server.port') ?? '', onChange: (e) => set('server.port', Number(e.target.value) || 0) })),
370
+ h(Field, { label: '共享密钥', desc: '请求要带 X-Roost-Token 头。留空表示不校验(只绑本机时可行)' },
371
+ h('input', { style: S.input, value: val('server.token') ?? '', onChange: (e) => set('server.token', e.target.value), placeholder: '留空则不校验' })),
372
+ ),
373
+
374
+ h('div', { style: S.card },
375
+ h('div', { style: { fontWeight: 600, marginBottom: 4 } }, '权限'),
376
+ h('div', { style: { ...S.muted, marginBottom: 12 } },
377
+ '默认只有主人能用本地工具。权限按每一条消息判定——同一个群里主人问完、路人接着问,待遇不一样。'),
378
+ [
379
+ ['owner', '主人'], ['admin', '管理员'], ['member', '普通成员'], ['unknown', '身份不明(兜底)'],
380
+ ].map(([k, label]) =>
381
+ h('div', { key: k, style: S.row },
382
+ h('div', null,
383
+ h('div', { style: { fontWeight: 500 } }, label),
384
+ k === 'unknown' ? h('div', { style: S.muted }, '拿不到权限信息时用这一档,不建议放开') : null),
385
+ h('select', {
386
+ style: { ...S.input, width: 124 },
387
+ value: getPath(draft, 'permissions.' + k) ?? (k === 'owner' ? 'all' : 'chat'),
388
+ onChange: (e) => set('permissions.' + k, e.target.value),
389
+ },
390
+ h('option', { value: 'all' }, '全部工具'),
391
+ h('option', { value: 'chat' }, '只能聊天'),
392
+ ),
393
+ ),
394
+ ),
395
+ ),
396
+
397
+ h('div', { style: S.card },
398
+ h('div', { style: { fontWeight: 600, marginBottom: 10 } }, '行为'),
399
+ h(Field, { label: '一轮最多等多久(秒)', desc: '要小于 ai-chat 那边的 180 秒,才能赶在它超时前回话' },
400
+ h('input', { style: S.input, value: val('chat.turnTimeoutSec') ?? '', onChange: (e) => set('chat.turnTimeoutSec', Number(e.target.value) || 0) })),
401
+ ),
402
+
403
+ h('div', { style: S.card },
404
+ h('div', { style: { fontWeight: 600, marginBottom: 10 } }, '人格'),
405
+ h(Field, { label: '名字' },
406
+ h('input', { style: S.input, value: val('persona.name') ?? '', onChange: (e) => set('persona.name', e.target.value) })),
407
+ h(Field, { label: '人设' },
408
+ h('textarea', { style: { ...S.input, minHeight: 72, resize: 'vertical' }, value: val('persona.text') ?? '', onChange: (e) => set('persona.text', e.target.value) })),
409
+ h(Field, { label: '私聊追加' },
410
+ h('input', { style: S.input, value: val('persona.rulesPrivate') ?? '', onChange: (e) => set('persona.rulesPrivate', e.target.value) })),
411
+ h(Field, { label: '群聊追加' },
412
+ h('input', { style: S.input, value: val('persona.rulesSocial') ?? '', onChange: (e) => set('persona.rulesSocial', e.target.value) })),
413
+ ),
414
+
415
+ h('div', { style: { display: 'flex', gap: 8 } },
416
+ h('button', { style: S.btnPrimary, onClick: () => save(draft) }, '保存'),
417
+ h('button', { style: S.btn, onClick: () => { setDraft(clone(config)); toast('已撤销') } }, '撤销'),
418
+ ),
419
+ )
420
+ }
421
+
422
+ /* ---------------- 整页 ---------------- */
423
+
424
+ function RoostPage() {
425
+ useStyle()
426
+ const [tab, setTab] = useState('setup')
427
+ const [setupSt, setSetupSt] = useState({})
428
+ const [st, setSt] = useState({})
429
+ const [chats, setChats] = useState([])
430
+ const [config, setConfig] = useState(null)
431
+ const [toastMsg, setToastMsg] = useState('')
432
+ const [busy, setBusy] = useState('')
433
+ const timer = useRef(null)
434
+
435
+ const toast = useCallback((m) => {
436
+ setToastMsg(m)
437
+ clearTimeout(timer.current)
438
+ timer.current = setTimeout(() => setToastMsg(''), 2600)
439
+ }, [])
440
+
441
+ const loadSetup = useCallback(() => {
442
+ get('/setup').then((r) => setSetupSt(r.ok === false ? {} : r))
443
+ }, [])
444
+ const loadStatus = useCallback(() => {
445
+ get('/status').then((r) => setSt(r.ok === false ? {} : r))
446
+ }, [])
447
+ const loadChats = useCallback(() => get('/chats').then((r) => setChats(r.chats || [])), [])
448
+ const loadConfig = useCallback(() => get('/config').then((r) => setConfig(r.config || null)), [])
449
+
450
+ const reload = useCallback(() => {
451
+ loadSetup()
452
+ loadStatus()
453
+ }, [loadSetup, loadStatus])
454
+
455
+ useEffect(() => {
456
+ reload()
457
+ const t = setInterval(reload, 4000)
458
+ return () => clearInterval(t)
459
+ }, [reload])
460
+
461
+ useEffect(() => {
462
+ if (tab === 'chats') loadChats()
463
+ if (tab === 'config' && !config) loadConfig()
464
+ }, [tab, config, loadChats, loadConfig])
465
+
466
+ /** 跑一键流程:能自动做的依次做,卡在需要人填的地方就停 */
467
+ const run = useCallback(async ({ only, appId, secret } = {}) => {
468
+ const s = await get('/setup')
469
+ if (s.ok === false) return toast(s.message || '读不到状态')
470
+
471
+ const step = async (label, path, body) => {
472
+ setBusy(label)
473
+ const r = await post(path, body)
474
+ toast(r.ok ? r.message || label + '完成' : r.message || label + '失败')
475
+ setBusy('')
476
+ reload()
477
+ return r.ok
478
+ }
479
+
480
+ if (only === 'install') return step('安装', '/setup/install')
481
+ if (only === 'start') return step('启动', '/setup/start')
482
+ if (only === 'plugin') return step('安装插件', '/setup/plugin')
483
+ if (only === 'configure') return step('配置', '/setup/configure')
484
+
485
+ // 全部
486
+ if (!s.installed && !(await step('安装', '/setup/install'))) return
487
+
488
+ if (!s.hasBot) {
489
+ if (!appId || !secret) {
490
+ toast('先填 AppID 和 AppSecret(开放平台拿)')
491
+ return
492
+ }
493
+ if (!(await step('写入配置', '/setup/bot', { name: 'main', appId, secret }))) return
494
+ }
495
+
496
+ if (!s.running && !(await step('启动', '/setup/start'))) return
497
+ if (!s.pluginInstalled && !(await step('安装插件', '/setup/plugin'))) return
498
+ if (!s.pluginConfigured && !(await step('配置', '/setup/configure'))) return
499
+
500
+ toast('全部就绪,去群里说句话试试')
501
+ reload()
502
+ }, [reload, toast])
503
+
504
+ const save = async (draft) => {
505
+ const r = await post('/config', draft)
506
+ if (r.ok) {
507
+ setConfig(r.config)
508
+ loadStatus()
509
+ toast(r.message || '已保存')
510
+ } else {
511
+ toast(r.message || '保存失败')
512
+ }
513
+ }
514
+
515
+ return h('div', { style: S.wrap },
516
+ h('div', { style: S.h1 }, 'Roost'),
517
+ h('div', { style: S.sub }, 'QQ 消息从这里交给 DSH 会话回答。'),
518
+
519
+ h('div', { style: S.tabs },
520
+ h(Tab, { id: 'setup', cur: tab, onClick: setTab }, '接入'),
521
+ h(Tab, { id: 'status', cur: tab, onClick: setTab }, '状态'),
522
+ h(Tab, { id: 'chats', cur: tab, onClick: setTab }, '会话'),
523
+ h(Tab, { id: 'config', cur: tab, onClick: setTab }, '设置'),
524
+ ),
525
+
526
+ h('div', { className: 'roost-fade', key: tab },
527
+ tab === 'setup' ? h(SetupTab, { st: setupSt, reload, toast, busy, run }) : null,
528
+ tab === 'status' ? h(StatusTab, { st, reload: loadStatus }) : null,
529
+ tab === 'chats' ? h(ChatsTab, { chats, reload: loadChats, toast }) : null,
530
+ tab === 'config' ? (config ? h(ConfigTab, { config, save, toast }) : h('div', { style: S.muted }, '读取配置…')) : null,
531
+ ),
532
+
533
+ toastMsg ? h('div', { style: { marginTop: 14, ...S.muted } }, toastMsg) : null,
534
+ )
535
+ }
536
+
537
+ /* ---------------- 侧栏图标 ---------------- */
538
+
539
+ const RoostIcon = () =>
540
+ h('svg', { width: 16, height: 16, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': 'true' },
541
+ h('path', { d: 'M2 10.5c1.6 2 3.6 3 6 3s4.4-1 6-3', stroke: 'currentColor', strokeWidth: 1.3, strokeLinecap: 'round' }),
542
+ h('path', { d: 'M3.2 9.2c1.2 1 2.9 1.6 4.8 1.6s3.6-.6 4.8-1.6', stroke: 'currentColor', strokeWidth: 1.3, strokeLinecap: 'round', opacity: 0.55 }),
543
+ h('circle', { cx: 8, cy: 5.6, r: 1.9, stroke: 'currentColor', strokeWidth: 1.3 }),
544
+ )
545
+
546
+ /* ---------------- 挂载 ---------------- */
547
+
548
+ function apply(ctx) {
549
+ const slots = ctx.get('slots')
550
+ if (slots === undefined) return
551
+ slots.inject('main', () => slots.register({ name: 'main', key: PANEL_ID }, RoostPage))
552
+ slots.inject('sidebar.panellist', () =>
553
+ slots.register({ name: 'sidebar.panellist', id: PANEL_ID, order: 20, label: () => 'Roost' }, RoostIcon),
554
+ )
555
+ }
556
+
557
+ exports.apply = apply
558
+ exports.inject = inject
559
+ exports.RoostPage = RoostPage
560
+ exports.RoostIcon = RoostIcon
561
+ return module.exports
562
+ },
563
+ })
@@ -0,0 +1,102 @@
1
+ # dsh-roost —— DSH bundle patch(插件自带的挂载声明)
2
+ #
3
+ # 1) roost:插件本体(宿主半边 src/host.js + 网页半边 client/client.js)
4
+ # 2) 两个 agent preset,只给本插件建的 QQ 会话用:
5
+ # - roost-private:私聊
6
+ # - roost-social :群聊
7
+ #
8
+ # 两档的差别**只有人格文案**——工具是一样的。
9
+ # 群聊要不要给本地工具由 config.json 的 chat.toolsInGroup 控制:
10
+ # 开着(默认):群里也能读写文件、执行命令
11
+ # 关掉 :插件用 tools.restrict 把 bash/fs/web 收走,群聊只剩聊天
12
+ #
13
+ # 人格正文由插件按配置动态注入(systemPrompt.context),
14
+ # 这里的 prefix 只放不随配置变的骨架。
15
+
16
+ - insert:
17
+ - id: roost
18
+ name: dsh-roost
19
+
20
+ - id: preset-roost-private
21
+ name: '@deepseek-ai/dsh-agent-preset'
22
+ config:
23
+ id: roost-private
24
+ name: Roost · 私聊
25
+ description: 在 QQ 上和主人私聊时用
26
+ order: 60
27
+ plugins:
28
+ - id: persona
29
+ name: '@deepseek-ai/dsh-persona'
30
+ config:
31
+ prefix: |-
32
+ 你在用 QQ 和人私聊。回答简短、口语化,通常两三句话。
33
+ 不要说「作为 AI」「作为一个语言模型」这类话。
34
+ # 人格独占系统提示词:不带 DSH 那一大段编程助手身份和工具指南,
35
+ # 否则一轮要好几分钟。工具照样挂着,模型看工具定义就会用。
36
+ complete: true
37
+ - id: time-context
38
+ name: '@deepseek-ai/dsh-time-context'
39
+ - id: tool-fs
40
+ name: '@deepseek-ai/dsh-tool-fs'
41
+ - id: tool-bash
42
+ name: '@deepseek-ai/dsh-tool-bash'
43
+ disabled: !!js process.platform === 'win32'
44
+ - id: tool-pwsh
45
+ name: '@deepseek-ai/dsh-tool-pwsh'
46
+ disabled: !!js process.platform !== 'win32'
47
+ - id: compaction
48
+ name: cordis:group
49
+ group: true
50
+ isolate:
51
+ compaction: true
52
+ toolResultPruner: true
53
+ config:
54
+ - id: compaction-basic
55
+ name: '@deepseek-ai/dsh-compaction-basic'
56
+ - id: tool-result-pruner
57
+ name: '@deepseek-ai/dsh-compaction-tool-result-pruner'
58
+ config:
59
+ thresholdChars: 8192
60
+ headChars: 4096
61
+ tailChars: 1024
62
+
63
+ - id: preset-roost-social
64
+ name: '@deepseek-ai/dsh-agent-preset'
65
+ config:
66
+ id: roost-social
67
+ name: Roost · 群聊
68
+ description: 在 QQ 群里说话时用(本地工具由 toolsInGroup 控制)
69
+ order: 61
70
+ plugins:
71
+ - id: persona
72
+ name: '@deepseek-ai/dsh-persona'
73
+ config:
74
+ prefix: |-
75
+ 你在一个 QQ 群里说话。说话要短,别刷屏。
76
+ 不要说「作为 AI」「作为一个语言模型」这类话。
77
+ complete: true
78
+ - id: time-context
79
+ name: '@deepseek-ai/dsh-time-context'
80
+ - id: tool-fs
81
+ name: '@deepseek-ai/dsh-tool-fs'
82
+ - id: tool-bash
83
+ name: '@deepseek-ai/dsh-tool-bash'
84
+ disabled: !!js process.platform === 'win32'
85
+ - id: tool-pwsh
86
+ name: '@deepseek-ai/dsh-tool-pwsh'
87
+ disabled: !!js process.platform !== 'win32'
88
+ - id: compaction
89
+ name: cordis:group
90
+ group: true
91
+ isolate:
92
+ compaction: true
93
+ toolResultPruner: true
94
+ config:
95
+ - id: compaction-basic
96
+ name: '@deepseek-ai/dsh-compaction-basic'
97
+ - id: tool-result-pruner
98
+ name: '@deepseek-ai/dsh-compaction-tool-result-pruner'
99
+ config:
100
+ thresholdChars: 8192
101
+ headChars: 4096
102
+ tailChars: 1024