@sidleo3/dsh-chat 0.0.4
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/client/bot-list.js +243 -0
- package/client/bot-settings.js +175 -0
- package/client/bot-shared-settings.js +561 -0
- package/client/chat-ui.js +134 -0
- package/client/context-enhancement.js +435 -0
- package/client/delivery-targets.js +334 -0
- package/client/diagnostics.js +160 -0
- package/client/i18n.js +371 -0
- package/client/index.js +77 -0
- package/client/list-order.js +144 -0
- package/client/rpc.js +52 -0
- package/client/scoped-mode-editor.js +111 -0
- package/client/section.js +250 -0
- package/client/session-badges.js +263 -0
- package/client/styles.js +960 -0
- package/client/version-panel.js +97 -0
- package/cordis.patch.yml +5 -0
- package/host/bot-model.mjs +53 -0
- package/host/bot-settings.mjs +247 -0
- package/host/channel-registry.mjs +237 -0
- package/host/commands.mjs +857 -0
- package/host/deferred.mjs +291 -0
- package/host/delivery.mjs +377 -0
- package/host/file-log.mjs +169 -0
- package/host/guidance.mjs +73 -0
- package/host/index.mjs +7 -0
- package/host/interactions.mjs +330 -0
- package/host/json-store.mjs +144 -0
- package/host/log-tail.mjs +63 -0
- package/host/panel.mjs +1012 -0
- package/host/paths.mjs +50 -0
- package/host/plugin.mjs +873 -0
- package/host/prompt-context.mjs +70 -0
- package/host/rpc.mjs +147 -0
- package/host/session-keys.mjs +25 -0
- package/host/session-store.mjs +187 -0
- package/host/sessions.mjs +1348 -0
- package/host/tools.mjs +283 -0
- package/lib/client.js +4431 -0
- package/lib/index.js +5676 -0
- package/package.json +63 -0
- package/shared/access-policy.mjs +263 -0
- package/shared/channel-rail.mjs +156 -0
- package/shared/context-enhancement.mjs +415 -0
- package/shared/contract.mjs +120 -0
- package/shared/panel-sections.mjs +76 -0
- package/shared/reply-reference.mjs +115 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 主动投递目标编辑器(hub 提供的共享组件)。
|
|
3
|
+
*
|
|
4
|
+
* 渠道页把它挂在每个机器人卡片下:目标清单、候选收编、以及"发一条测试消息"。
|
|
5
|
+
* 数据一律经 hub 控制端点(`delivery.list/save/remove/send`),渠道不必实现任何
|
|
6
|
+
* 投递 UI,也不需要知道目标长什么样。
|
|
7
|
+
*
|
|
8
|
+
* 文案走 hub 自己的命名空间(默认用 `chatUi.translate`),渠道页因此不必再抄一份字符串。
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-chat/client/delivery-targets
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as React from 'react';
|
|
14
|
+
|
|
15
|
+
const h = React.createElement;
|
|
16
|
+
|
|
17
|
+
/** 候选默认展示条数:再多了就折叠,避免一屏全是候选。 */
|
|
18
|
+
const CANDIDATE_PREVIEW = 5;
|
|
19
|
+
/** 超过这个条数才出现过滤框。 */
|
|
20
|
+
const FILTER_THRESHOLD = 6;
|
|
21
|
+
|
|
22
|
+
function translatorOf(translate, chatUi) {
|
|
23
|
+
if (typeof translate === 'function') return translate;
|
|
24
|
+
if (typeof chatUi?.translate === 'function') return chatUi.translate;
|
|
25
|
+
return (key) => key;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** 一个目标的一行:名称、类型、路由与操作(候选可保存,已保存可改名/删除)。 */
|
|
29
|
+
function TargetRow({
|
|
30
|
+
target, busy, confirming, renaming, renameDraft, translate,
|
|
31
|
+
onSave, onAskRemove, onCancel, onRemove, onStartRename, onRenameDraft, onSubmitRename, onCancelRename,
|
|
32
|
+
}) {
|
|
33
|
+
const t = translate;
|
|
34
|
+
const route = Object.entries(target.route ?? {})
|
|
35
|
+
.map(([key, value]) => `${key}=${value}`).join(' · ');
|
|
36
|
+
const kindLabel = target.kind === 'group' ? t('群聊') : t('私聊');
|
|
37
|
+
|
|
38
|
+
// 改名态:目标名字来自平台(微信没有昵称、飞书缺权限时只有掩码 id),
|
|
39
|
+
// 一排认不出的 id 里挑不出要发给谁,所以允许自己起名(留空 = 回到自动名字)。
|
|
40
|
+
if (renaming) {
|
|
41
|
+
return h('div', { className: 'dchat-listItem dchat-deliveryRow' },
|
|
42
|
+
h('div', { className: 'dchat-deliveryMeta' },
|
|
43
|
+
h('input', {
|
|
44
|
+
className: 'dchat-input',
|
|
45
|
+
value: renameDraft,
|
|
46
|
+
placeholder: t('留空则用自动识别的名字'),
|
|
47
|
+
autoComplete: 'off',
|
|
48
|
+
spellCheck: false,
|
|
49
|
+
'aria-label': t('自定义名称'),
|
|
50
|
+
onChange: (event) => onRenameDraft(event.target.value),
|
|
51
|
+
onKeyDown: (event) => {
|
|
52
|
+
if (event.key === 'Enter') onSubmitRename();
|
|
53
|
+
if (event.key === 'Escape') onCancelRename();
|
|
54
|
+
},
|
|
55
|
+
}),
|
|
56
|
+
h('small', null, `${kindLabel} · ${route}`)),
|
|
57
|
+
h('div', { className: 'dchat-actions' },
|
|
58
|
+
h('button', {
|
|
59
|
+
key: 'submit',
|
|
60
|
+
type: 'button',
|
|
61
|
+
className: 'dchat-button dchat-buttonPrimary',
|
|
62
|
+
disabled: busy,
|
|
63
|
+
onClick: onSubmitRename,
|
|
64
|
+
}, busy ? t('保存中…') : t('保存')),
|
|
65
|
+
h('button', {
|
|
66
|
+
key: 'cancel',
|
|
67
|
+
type: 'button',
|
|
68
|
+
className: 'dchat-button',
|
|
69
|
+
disabled: busy,
|
|
70
|
+
onClick: onCancelRename,
|
|
71
|
+
}, t('取消'))));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const actions = target.discovered
|
|
75
|
+
? [h('button', {
|
|
76
|
+
key: 'save',
|
|
77
|
+
type: 'button',
|
|
78
|
+
className: 'dchat-button',
|
|
79
|
+
disabled: busy,
|
|
80
|
+
onClick: () => onSave(target),
|
|
81
|
+
}, t('保存为投递目标'))]
|
|
82
|
+
: (confirming
|
|
83
|
+
? [
|
|
84
|
+
h('button', {
|
|
85
|
+
key: 'confirm',
|
|
86
|
+
type: 'button',
|
|
87
|
+
className: 'dchat-button dchat-buttonDangerSolid',
|
|
88
|
+
disabled: busy,
|
|
89
|
+
onClick: () => onRemove(target),
|
|
90
|
+
}, t('确认删除')),
|
|
91
|
+
h('button', {
|
|
92
|
+
key: 'cancel',
|
|
93
|
+
type: 'button',
|
|
94
|
+
className: 'dchat-button',
|
|
95
|
+
disabled: busy,
|
|
96
|
+
onClick: onCancel,
|
|
97
|
+
}, t('取消')),
|
|
98
|
+
]
|
|
99
|
+
: [
|
|
100
|
+
h('button', {
|
|
101
|
+
key: 'rename',
|
|
102
|
+
type: 'button',
|
|
103
|
+
className: 'dchat-button',
|
|
104
|
+
disabled: busy,
|
|
105
|
+
onClick: () => onStartRename(target),
|
|
106
|
+
}, t('重命名')),
|
|
107
|
+
h('button', {
|
|
108
|
+
key: 'remove',
|
|
109
|
+
type: 'button',
|
|
110
|
+
className: 'dchat-button dchat-buttonDanger',
|
|
111
|
+
disabled: busy,
|
|
112
|
+
onClick: onAskRemove,
|
|
113
|
+
}, t('删除')),
|
|
114
|
+
]);
|
|
115
|
+
|
|
116
|
+
return h('div', { className: 'dchat-listItem dchat-deliveryRow' },
|
|
117
|
+
h('div', { className: 'dchat-deliveryMeta' },
|
|
118
|
+
h('strong', null, target.name || target.id),
|
|
119
|
+
// 只留一行身份:`route` 里已经带了 openId/chatId,再挂一个 `p2p_…` 原始 id
|
|
120
|
+
// 就是同一个东西的第二种写法,只会让人怀疑"这是两个不同的目标"。
|
|
121
|
+
h('small', null, `${kindLabel} · ${route}`)),
|
|
122
|
+
h('div', { className: 'dchat-actions' },
|
|
123
|
+
target.discovered ? h('span', { className: 'dchat-status' }, t('候选')) : null,
|
|
124
|
+
...actions));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 投递目标编辑器。
|
|
129
|
+
*
|
|
130
|
+
* @param props - { chatUi, connection, channelId, botId, translate? }。
|
|
131
|
+
* @returns React 元素。
|
|
132
|
+
*/
|
|
133
|
+
export function DeliveryTargetsEditor({ chatUi, connection, channelId, botId, translate }) {
|
|
134
|
+
const t = translatorOf(translate, chatUi);
|
|
135
|
+
const { Panel } = chatUi.components;
|
|
136
|
+
const [state, setState] = React.useState({ phase: 'loading', targets: [], canSend: false });
|
|
137
|
+
const [error, setError] = React.useState(null);
|
|
138
|
+
const [notice, setNotice] = React.useState(null);
|
|
139
|
+
const [busyId, setBusyId] = React.useState(null);
|
|
140
|
+
const [confirmingId, setConfirmingId] = React.useState(null);
|
|
141
|
+
/** 正在改名的目标 id 与草稿(空草稿 = 取消自定义,回到自动名字)。 */
|
|
142
|
+
const [renamingId, setRenamingId] = React.useState(null);
|
|
143
|
+
const [renameDraft, setRenameDraft] = React.useState('');
|
|
144
|
+
const [draft, setDraft] = React.useState('');
|
|
145
|
+
const [sendTo, setSendTo] = React.useState('');
|
|
146
|
+
/** 目标一多就要有过滤与折叠,否则 8 个群排下来既找不到也没法扫。 */
|
|
147
|
+
const [filter, setFilter] = React.useState('');
|
|
148
|
+
const [showAllCandidates, setShowAllCandidates] = React.useState(false);
|
|
149
|
+
|
|
150
|
+
const call = React.useCallback(async (method, payload) => {
|
|
151
|
+
const result = await chatUi.callControlRpc(connection, method, payload);
|
|
152
|
+
return chatUi.unwrapRpc(result);
|
|
153
|
+
}, [chatUi, connection]);
|
|
154
|
+
|
|
155
|
+
const load = React.useCallback(async () => {
|
|
156
|
+
try {
|
|
157
|
+
const value = await call('delivery.list', { channelId, botId });
|
|
158
|
+
const targets = value.targets ?? [];
|
|
159
|
+
setState({ phase: 'ready', targets, canSend: value.canSend === true });
|
|
160
|
+
setSendTo((current) => {
|
|
161
|
+
const savedIds = targets.filter((target) => !target.discovered).map((target) => target.id);
|
|
162
|
+
return savedIds.includes(current) ? current : (savedIds[0] ?? '');
|
|
163
|
+
});
|
|
164
|
+
} catch (cause) {
|
|
165
|
+
setState((current) => ({ ...current, phase: 'error' }));
|
|
166
|
+
setError(cause.message);
|
|
167
|
+
}
|
|
168
|
+
}, [call, channelId, botId]);
|
|
169
|
+
|
|
170
|
+
React.useEffect(() => { void load(); }, [load]);
|
|
171
|
+
|
|
172
|
+
/** 统一处理"忙 → 调用 → 刷新 → 提示/报错",失败必须留在界面上而不是静默。 */
|
|
173
|
+
const run = React.useCallback(async (key, method, payload, message) => {
|
|
174
|
+
setBusyId(key);
|
|
175
|
+
setError(null);
|
|
176
|
+
setNotice(null);
|
|
177
|
+
try {
|
|
178
|
+
const value = await call(method, payload);
|
|
179
|
+
await load();
|
|
180
|
+
setNotice(typeof message === 'function' ? message(value) : message);
|
|
181
|
+
return value;
|
|
182
|
+
} catch (cause) {
|
|
183
|
+
setError(cause.message);
|
|
184
|
+
return null;
|
|
185
|
+
} finally {
|
|
186
|
+
setBusyId(null);
|
|
187
|
+
}
|
|
188
|
+
}, [call, load]);
|
|
189
|
+
|
|
190
|
+
const saved = state.targets.filter((target) => !target.discovered);
|
|
191
|
+
const candidates = state.targets.filter((target) => target.discovered);
|
|
192
|
+
const ready = state.phase === 'ready' && state.canSend === true;
|
|
193
|
+
const query = filter.trim().toLowerCase();
|
|
194
|
+
const matches = (target) => !query
|
|
195
|
+
|| `${target.name ?? ''} ${target.id} ${JSON.stringify(target.route ?? {})}`.toLowerCase().includes(query);
|
|
196
|
+
const savedShown = saved.filter(matches);
|
|
197
|
+
const candidatesShown = candidates.filter(matches);
|
|
198
|
+
const visibleCandidates = showAllCandidates
|
|
199
|
+
? candidatesShown
|
|
200
|
+
: candidatesShown.slice(0, CANDIDATE_PREVIEW);
|
|
201
|
+
|
|
202
|
+
/** 一行目标;已保存与候选共用。 */
|
|
203
|
+
const renderRow = (target) => h(TargetRow, {
|
|
204
|
+
key: target.id,
|
|
205
|
+
target,
|
|
206
|
+
busy: busyId === target.id,
|
|
207
|
+
confirming: confirmingId === target.id,
|
|
208
|
+
renaming: renamingId === target.id,
|
|
209
|
+
renameDraft: renamingId === target.id ? renameDraft : '',
|
|
210
|
+
translate: t,
|
|
211
|
+
onSave: (item) => {
|
|
212
|
+
void run(item.id, 'delivery.save', {
|
|
213
|
+
channelId,
|
|
214
|
+
botId,
|
|
215
|
+
target: { id: item.id, name: item.name, kind: item.kind, route: item.route },
|
|
216
|
+
}, () => t('已保存,现在可以主动发消息了。'));
|
|
217
|
+
},
|
|
218
|
+
onStartRename: (item) => {
|
|
219
|
+
setConfirmingId(null);
|
|
220
|
+
setRenamingId(item.id);
|
|
221
|
+
// 预填当前名字,便于微调;用户想恢复自动名字就把内容清空后保存。
|
|
222
|
+
setRenameDraft(item.renamed ? item.name : '');
|
|
223
|
+
},
|
|
224
|
+
onRenameDraft: setRenameDraft,
|
|
225
|
+
onCancelRename: () => setRenamingId(null),
|
|
226
|
+
onSubmitRename: () => {
|
|
227
|
+
const id = renamingId;
|
|
228
|
+
setRenamingId(null);
|
|
229
|
+
void run(id, 'delivery.target.rename', {
|
|
230
|
+
channelId, botId, targetId: id, name: renameDraft.trim(),
|
|
231
|
+
}, () => (renameDraft.trim() ? t('已改名。') : t('已恢复自动名字。')));
|
|
232
|
+
},
|
|
233
|
+
onAskRemove: () => setConfirmingId(target.id),
|
|
234
|
+
onCancel: () => setConfirmingId(null),
|
|
235
|
+
onRemove: (item) => {
|
|
236
|
+
setConfirmingId(null);
|
|
237
|
+
void run(item.id, 'delivery.remove', { channelId, botId, targetId: item.id },
|
|
238
|
+
() => t('已删除。'));
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
const groupTitle = (text) => h('p', { className: 'dchat-groupTitle' }, text);
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
return h(Panel, {
|
|
245
|
+
title: t('主动投递'),
|
|
246
|
+
description: t('让定时任务或 agent 把结果直接发到指定会话。'),
|
|
247
|
+
},
|
|
248
|
+
error ? h('p', { className: 'dchat-error', role: 'alert' }, error) : null,
|
|
249
|
+
notice ? h('p', { className: 'dchat-notice', role: 'status' }, notice) : null,
|
|
250
|
+
state.phase === 'loading' ? h('p', { className: 'dchat-cardDescription' }, t('读取中…')) : null,
|
|
251
|
+
state.phase === 'ready' && state.canSend === false
|
|
252
|
+
? h('p', { className: 'dchat-cardDescription' }, t('当前渠道不支持主动投递。'))
|
|
253
|
+
: null,
|
|
254
|
+
// 目标多到需要找的时候才出现过滤框,平时不占地方。
|
|
255
|
+
state.targets.length > FILTER_THRESHOLD
|
|
256
|
+
? h('div', { className: 'dchat-actions' },
|
|
257
|
+
h('input', {
|
|
258
|
+
className: 'dchat-input',
|
|
259
|
+
value: filter,
|
|
260
|
+
placeholder: t('按名字或 id 过滤'),
|
|
261
|
+
autoComplete: 'off',
|
|
262
|
+
spellCheck: false,
|
|
263
|
+
onChange: (event) => setFilter(event.target.value),
|
|
264
|
+
}))
|
|
265
|
+
: null,
|
|
266
|
+
savedShown.length > 0
|
|
267
|
+
? h(React.Fragment, null,
|
|
268
|
+
groupTitle(t('已保存')),
|
|
269
|
+
h('div', { className: 'dchat-list' }, savedShown.map(renderRow)))
|
|
270
|
+
: null,
|
|
271
|
+
candidatesShown.length > 0
|
|
272
|
+
? h(React.Fragment, null,
|
|
273
|
+
groupTitle(`${t('可添加的候选')}(${candidatesShown.length})`),
|
|
274
|
+
h('div', { className: 'dchat-list' }, visibleCandidates.map(renderRow)),
|
|
275
|
+
candidatesShown.length > CANDIDATE_PREVIEW
|
|
276
|
+
? h('button', {
|
|
277
|
+
type: 'button',
|
|
278
|
+
className: 'dchat-button dchat-buttonLink',
|
|
279
|
+
onClick: () => setShowAllCandidates((value) => !value),
|
|
280
|
+
}, showAllCandidates
|
|
281
|
+
? t('收起')
|
|
282
|
+
: `${t('展开全部')}(${candidatesShown.length})`)
|
|
283
|
+
: null)
|
|
284
|
+
: null,
|
|
285
|
+
state.targets.length > 0 && savedShown.length === 0 && candidatesShown.length === 0
|
|
286
|
+
? h('p', { className: 'dchat-cardDescription' }, t('没有匹配的目标。'))
|
|
287
|
+
: null,
|
|
288
|
+
/**
|
|
289
|
+
* 「怎么添加」常驻说明:**只要没有候选就显示**。
|
|
290
|
+
* 之前只在"一个目标都没有"时显示,于是有 1 个已保存目标、又没有候选时,
|
|
291
|
+
* 整张卡既看不到可添加项、也看不到为什么——用户只能说"没有添加入口"。
|
|
292
|
+
*/
|
|
293
|
+
ready && candidates.length === 0
|
|
294
|
+
? h('p', { className: 'dchat-cardDescription' },
|
|
295
|
+
saved.length === 0
|
|
296
|
+
? t('还没有可添加的会话:在群里 @ 一次机器人,或与它私聊一次,会话就会出现在这里,保存后即可主动投递。')
|
|
297
|
+
: t('没有可添加的会话:在群里 @ 一次机器人,或与它私聊一次,该会话就会出现在这里。'))
|
|
298
|
+
: null,
|
|
299
|
+
candidates.length > 0
|
|
300
|
+
? h('p', { className: 'dchat-cardDescription' },
|
|
301
|
+
t('上面标「候选」的会话还不能主动投递,点「保存为投递目标」后才行。'))
|
|
302
|
+
: null,
|
|
303
|
+
saved.length > 0
|
|
304
|
+
? h('div', { className: 'dchat-deliverySend' },
|
|
305
|
+
h('label', { className: 'dchat-scopeLabel', htmlFor: `dchat-delivery-${botId}` },
|
|
306
|
+
t('发一条测试消息')),
|
|
307
|
+
h('div', { className: 'dchat-actions' },
|
|
308
|
+
h('select', {
|
|
309
|
+
className: 'dchat-select',
|
|
310
|
+
value: sendTo,
|
|
311
|
+
'aria-label': t('选择目标'),
|
|
312
|
+
onChange: (event) => setSendTo(event.target.value),
|
|
313
|
+
}, saved.map((target) => h('option', {
|
|
314
|
+
key: target.id, value: target.id,
|
|
315
|
+
}, `${target.name || target.id}(${target.kind === 'group' ? t('群聊') : t('私聊')})`))),
|
|
316
|
+
h('button', {
|
|
317
|
+
type: 'button',
|
|
318
|
+
className: 'dchat-button dchat-buttonPrimary',
|
|
319
|
+
disabled: busyId === 'send' || !draft.trim() || !sendTo,
|
|
320
|
+
onClick: () => {
|
|
321
|
+
void run('send', 'delivery.send', { channelId, botId, targetId: sendTo, text: draft })
|
|
322
|
+
.then((value) => { if (value !== null) setDraft(''); });
|
|
323
|
+
},
|
|
324
|
+
}, busyId === 'send' ? t('发送中…') : t('发送'))),
|
|
325
|
+
h('textarea', {
|
|
326
|
+
id: `dchat-delivery-${botId}`,
|
|
327
|
+
className: 'dchat-textarea',
|
|
328
|
+
rows: 2,
|
|
329
|
+
placeholder: t('测试消息内容'),
|
|
330
|
+
value: draft,
|
|
331
|
+
onChange: (event) => setDraft(event.target.value),
|
|
332
|
+
}))
|
|
333
|
+
: null);
|
|
334
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「诊断」面板:出故障时的自助现场。
|
|
3
|
+
*
|
|
4
|
+
* 为什么要有它:这个项目的排查一直在"用户描述现象 → 我去读日志"之间来回,而日志就在
|
|
5
|
+
* `~/.dsh/integrations/dsh-chat/logs/`。把**每台机器人的连接状态、最近错误、日志尾部**
|
|
6
|
+
* 直接摆进设置页,用户自己就能看明白"是没连上、还是缺权限、还是呈现层发不出去"。
|
|
7
|
+
*
|
|
8
|
+
* 数据全部来自 hub 控制端点的 `diagnostics.read`(host 侧读文件;浏览器不猜路径、不碰文件系统)。
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-chat/client/diagnostics
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import * as React from 'react';
|
|
14
|
+
|
|
15
|
+
const h = React.createElement;
|
|
16
|
+
|
|
17
|
+
const STATE_TEXT = Object.freeze({
|
|
18
|
+
running: '运行中',
|
|
19
|
+
starting: '启动中',
|
|
20
|
+
stopped: '已停止',
|
|
21
|
+
failed: '启动失败',
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
function formatSize(bytes) {
|
|
25
|
+
if (typeof bytes !== 'number' || bytes <= 0) return '0 B';
|
|
26
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
27
|
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function formatTime(iso) {
|
|
31
|
+
if (typeof iso !== 'string' || !iso) return '—';
|
|
32
|
+
const date = new Date(iso);
|
|
33
|
+
if (Number.isNaN(date.getTime())) return iso;
|
|
34
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
35
|
+
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 诊断面板。
|
|
40
|
+
*
|
|
41
|
+
* @param props - { connection, chatUi, translate, t }。
|
|
42
|
+
* @returns React 元素。
|
|
43
|
+
*/
|
|
44
|
+
export function DiagnosticsPanel(props) {
|
|
45
|
+
const { connection, chatUi, translate, t: frameworkT } = props;
|
|
46
|
+
const t = typeof translate === 'function' ? translate
|
|
47
|
+
: (typeof frameworkT === 'function' ? frameworkT : (key) => key);
|
|
48
|
+
const [state, setState] = React.useState({ loading: true, error: null, info: null });
|
|
49
|
+
/** 展开的日志文件路径(一次只看一个,免得面板被日志压满)。 */
|
|
50
|
+
const [openLog, setOpenLog] = React.useState(null);
|
|
51
|
+
|
|
52
|
+
const load = React.useCallback(() => {
|
|
53
|
+
setState((current) => ({ ...current, loading: true, error: null }));
|
|
54
|
+
chatUi.callControlRpc(connection, 'diagnostics.read', {})
|
|
55
|
+
.then((result) => {
|
|
56
|
+
setState({ loading: false, error: null, info: chatUi.unwrapRpc(result) });
|
|
57
|
+
})
|
|
58
|
+
.catch((error) => {
|
|
59
|
+
setState({ loading: false, error: error?.message ?? String(error), info: null });
|
|
60
|
+
});
|
|
61
|
+
}, [chatUi, connection]);
|
|
62
|
+
|
|
63
|
+
React.useEffect(() => {
|
|
64
|
+
load();
|
|
65
|
+
}, [load]);
|
|
66
|
+
|
|
67
|
+
const Panel = chatUi.components.Panel;
|
|
68
|
+
const StatusPill = chatUi.components.StatusPill;
|
|
69
|
+
const info = state.info;
|
|
70
|
+
|
|
71
|
+
const statusLabel = (channel) => {
|
|
72
|
+
if (channel.status === 'running') return t('渠道已就绪');
|
|
73
|
+
if (channel.status === 'failed') return t('渠道启动失败');
|
|
74
|
+
if (channel.status === 'stopped') return t('渠道已停止');
|
|
75
|
+
return t('渠道正在启动');
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const botRow = (bot) => h('li', { key: bot.id, className: 'dchat-diagBot' },
|
|
79
|
+
// 一行放不下就整块换行:id 会省略号收缩,右侧元信息不拆字。
|
|
80
|
+
h('div', { className: 'dchat-listItem dchat-diagRow' },
|
|
81
|
+
h('span', { className: 'dchat-code' }, bot.id),
|
|
82
|
+
h('span', { className: 'dchat-diagMeta' },
|
|
83
|
+
`${STATE_TEXT[bot.state] ? t(STATE_TEXT[bot.state]) : (bot.state ?? '—')}`
|
|
84
|
+
+ ` · ${bot.connected ? t('已连接') : t('未连接')}`
|
|
85
|
+
+ ` · ${t('已处理')} ${bot.handled ?? 0}`
|
|
86
|
+
+ (bot.lastHandledAt ? ` · ${formatTime(bot.lastHandledAt)}` : ''))),
|
|
87
|
+
// 失败必须可见:连接错误、最近一次处理错误、名字拿不到(多为缺权限,带开通链接)。
|
|
88
|
+
bot.errorMessage
|
|
89
|
+
? h('p', { className: 'dchat-error', role: 'alert' }, bot.errorMessage)
|
|
90
|
+
: null,
|
|
91
|
+
bot.lastError
|
|
92
|
+
? h('p', { className: 'dchat-error', role: 'alert' }, `${t('最近一次错误')}:${bot.lastError}`)
|
|
93
|
+
: null,
|
|
94
|
+
bot.nameHint
|
|
95
|
+
? h('p', { className: 'dchat-cardDescription' },
|
|
96
|
+
`${bot.nameHint.message} `,
|
|
97
|
+
bot.nameHint.url
|
|
98
|
+
? h('a', { href: bot.nameHint.url, target: '_blank', rel: 'noreferrer' }, t('去开通权限'))
|
|
99
|
+
: null)
|
|
100
|
+
: null);
|
|
101
|
+
|
|
102
|
+
return h(Panel, {
|
|
103
|
+
title: t('诊断'),
|
|
104
|
+
description: t('出故障时先看这里:连接状态、最近错误、日志尾部。'),
|
|
105
|
+
actions: h('button', {
|
|
106
|
+
type: 'button',
|
|
107
|
+
className: 'dchat-button',
|
|
108
|
+
onClick: load,
|
|
109
|
+
disabled: state.loading,
|
|
110
|
+
}, state.loading ? t('读取中…') : t('重新读取')),
|
|
111
|
+
},
|
|
112
|
+
state.error
|
|
113
|
+
? h('p', { className: 'dchat-error' }, `${t('读取失败')}:${state.error}`)
|
|
114
|
+
: null,
|
|
115
|
+
...(info?.channels ?? []).map((channel) => h('div', {
|
|
116
|
+
key: channel.id, className: 'dchat-diagSection',
|
|
117
|
+
},
|
|
118
|
+
h('div', { className: 'dchat-diagHead' },
|
|
119
|
+
h('span', { className: 'dchat-groupTitle' }, `${channel.label} · ${channel.version ?? '—'}`),
|
|
120
|
+
h(StatusPill, { status: channel.status, label: statusLabel(channel) })),
|
|
121
|
+
channel.statusError
|
|
122
|
+
? h('p', { className: 'dchat-error', role: 'alert' }, channel.statusError)
|
|
123
|
+
: null,
|
|
124
|
+
channel.bots.length === 0
|
|
125
|
+
? h('p', { className: 'dchat-cardDescription' }, t('这台渠道下还没有机器人。'))
|
|
126
|
+
: h('ul', { className: 'dchat-list' }, ...channel.bots.map(botRow)))),
|
|
127
|
+
info
|
|
128
|
+
? h('ul', { className: 'dchat-list' },
|
|
129
|
+
h('li', { className: 'dchat-listItem' },
|
|
130
|
+
h('span', null, t('数据目录')),
|
|
131
|
+
h('code', { className: 'dchat-code' }, info.dataDir ?? '—')),
|
|
132
|
+
h('li', { className: 'dchat-listItem' },
|
|
133
|
+
h('span', null, t('日志目录')),
|
|
134
|
+
h('code', { className: 'dchat-code' }, info.logDir ?? '—')))
|
|
135
|
+
: null,
|
|
136
|
+
...(info?.logs ?? []).map((log) => {
|
|
137
|
+
const name = String(log.path ?? '').split('/').pop();
|
|
138
|
+
const open = openLog === log.path;
|
|
139
|
+
return h('div', { key: log.path, className: 'dchat-diagSection' },
|
|
140
|
+
// 与机器人行同构:文件名 + 大小时间一行,展开按钮单独一行(窄栏下挤不下)。
|
|
141
|
+
h('div', { className: 'dchat-diagBot' },
|
|
142
|
+
h('div', { className: 'dchat-listItem dchat-diagRow' },
|
|
143
|
+
h('span', { className: 'dchat-code' }, name),
|
|
144
|
+
h('span', { className: 'dchat-diagMeta' },
|
|
145
|
+
log.exists
|
|
146
|
+
? `${formatSize(log.size)} · ${formatTime(log.modifiedAt)}`
|
|
147
|
+
: t('还没有日志'))),
|
|
148
|
+
h('div', { className: 'dchat-actions' },
|
|
149
|
+
h('button', {
|
|
150
|
+
type: 'button',
|
|
151
|
+
className: 'dchat-button dchat-buttonLink',
|
|
152
|
+
disabled: !log.exists,
|
|
153
|
+
'aria-expanded': open,
|
|
154
|
+
onClick: () => setOpenLog(open ? null : log.path),
|
|
155
|
+
}, open ? t('收起') : t('看最后 40 行')))),
|
|
156
|
+
open
|
|
157
|
+
? h('pre', { className: 'dchat-code dchat-codeBlock dchat-logTail' }, log.lines.join('\n'))
|
|
158
|
+
: null);
|
|
159
|
+
}));
|
|
160
|
+
}
|