@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,561 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 机器人设置页的共享编辑块:**工作区 / Agent 预设 / 访问策略**。
|
|
3
|
+
*
|
|
4
|
+
* 三项都与渠道无关,因此 hub 实现一次,渠道页只负责把它们挂到自己的机器人卡片下
|
|
5
|
+
* (和 `ContextEnhancementEditor` / `DeliveryTargetsEditor` 一样的用法)。
|
|
6
|
+
*
|
|
7
|
+
* 三项都会把"什么时候生效"写在描述里,因为它们的生效时机各不相同:
|
|
8
|
+
* 工作区与预设**只对新建会话生效**(已有会话保持原样),访问策略**立即生效**。
|
|
9
|
+
* 设置页最忌讳"看着像生效了其实没生效",所以宁可啰嗦一句。
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-chat/client/bot-shared-settings
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as React from 'react';
|
|
15
|
+
|
|
16
|
+
import { defaultAccessPolicy } from '../shared/access-policy.mjs';
|
|
17
|
+
import { PANEL_SECTIONS, normalizePanelSections } from '../shared/panel-sections.mjs';
|
|
18
|
+
|
|
19
|
+
const h = React.createElement;
|
|
20
|
+
|
|
21
|
+
function translatorOf(translate) {
|
|
22
|
+
return typeof translate === 'function' ? translate : (key) => key;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 统一的卡片外壳(与 hub 其它设置块同形态)。 */
|
|
26
|
+
function Card({ title, description, actions, children }) {
|
|
27
|
+
return h('section', { className: 'dchat-card' },
|
|
28
|
+
h('div', { className: 'dchat-cardHeader' },
|
|
29
|
+
h('div', { className: 'dchat-cardHeading' },
|
|
30
|
+
h('h3', { className: 'dchat-cardTitle' }, title),
|
|
31
|
+
description ? h('p', { className: 'dchat-cardDescription' }, description) : null),
|
|
32
|
+
actions ? h('div', { className: 'dchat-actions' }, actions) : null),
|
|
33
|
+
children);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 保存态与失败的统一处理:busy 时锁住控件,失败把原因留在卡片上。 */
|
|
37
|
+
function useSaver(onSave) {
|
|
38
|
+
const [busy, setBusy] = React.useState(false);
|
|
39
|
+
const [failed, setFailed] = React.useState(null);
|
|
40
|
+
const run = React.useCallback(async (next) => {
|
|
41
|
+
setBusy(true);
|
|
42
|
+
setFailed(null);
|
|
43
|
+
try {
|
|
44
|
+
await onSave(next);
|
|
45
|
+
return true;
|
|
46
|
+
} catch (error) {
|
|
47
|
+
setFailed(error?.message ?? String(error));
|
|
48
|
+
return false;
|
|
49
|
+
} finally {
|
|
50
|
+
setBusy(false);
|
|
51
|
+
}
|
|
52
|
+
}, [onSave]);
|
|
53
|
+
return { busy, failed, run };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 工作区:机器人跑在哪个目录。
|
|
58
|
+
*
|
|
59
|
+
* 输入框 + `datalist` 候选(候选来自这台机器人**用过的**目录,不列全机目录)。
|
|
60
|
+
* 这里保留"保存"按钮而不是即时保存:路径是手打的,打到一半就提交会把设置改成半个路径。
|
|
61
|
+
*/
|
|
62
|
+
export function WorkspaceEditor({ value, options = [], translate, onSave }) {
|
|
63
|
+
const t = translatorOf(translate);
|
|
64
|
+
const [draft, setDraft] = React.useState(value ?? '');
|
|
65
|
+
const { busy, failed, run } = useSaver(onSave);
|
|
66
|
+
const fieldId = React.useId?.() ?? 'dchat-workspace';
|
|
67
|
+
|
|
68
|
+
React.useEffect(() => {
|
|
69
|
+
setDraft(value ?? '');
|
|
70
|
+
}, [value]);
|
|
71
|
+
|
|
72
|
+
const dirty = (draft ?? '').trim() !== (value ?? '');
|
|
73
|
+
return h(Card, {
|
|
74
|
+
title: t('工作区'),
|
|
75
|
+
description: t('机器人跑在哪个目录:能读写哪些文件、用哪份 AGENTS.md。只对新建会话生效。'),
|
|
76
|
+
actions: h('button', {
|
|
77
|
+
type: 'button',
|
|
78
|
+
className: 'dchat-button',
|
|
79
|
+
disabled: busy || !dirty,
|
|
80
|
+
onClick: () => {
|
|
81
|
+
void run(draft.trim());
|
|
82
|
+
},
|
|
83
|
+
}, busy ? t('保存中…') : t('保存')),
|
|
84
|
+
},
|
|
85
|
+
h('div', { className: 'dchat-scopeGrid' },
|
|
86
|
+
h('div', { className: 'dchat-scopeRow' },
|
|
87
|
+
h('label', { className: 'dchat-scopeLabel', htmlFor: fieldId }, t('目录')),
|
|
88
|
+
h('input', {
|
|
89
|
+
id: fieldId,
|
|
90
|
+
className: 'dchat-input',
|
|
91
|
+
list: `${fieldId}-options`,
|
|
92
|
+
value: draft,
|
|
93
|
+
disabled: busy,
|
|
94
|
+
placeholder: '/Users/me/project',
|
|
95
|
+
autoComplete: 'off',
|
|
96
|
+
spellCheck: false,
|
|
97
|
+
onChange: (event) => setDraft(event.target.value),
|
|
98
|
+
}),
|
|
99
|
+
h('datalist', { id: `${fieldId}-options` },
|
|
100
|
+
options.map((path) => h('option', { key: path, value: path }))),
|
|
101
|
+
options.length > 0
|
|
102
|
+
? h('p', { className: 'dchat-cardDescription' }, t('下拉里是这台机器人用过的目录。'))
|
|
103
|
+
: null)),
|
|
104
|
+
failed ? h('p', { className: 'dchat-error', role: 'alert' }, failed) : null);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Agent 预设:用哪套预设。选项少且是枚举,所以选完即存。 */
|
|
108
|
+
export function PresetEditor({ value, options = [], translate, onSave }) {
|
|
109
|
+
const t = translatorOf(translate);
|
|
110
|
+
const { busy, failed, run } = useSaver(onSave);
|
|
111
|
+
return h(Card, {
|
|
112
|
+
title: t('Agent 预设'),
|
|
113
|
+
description: t('这个机器人用哪套 Agent 预设(人设与工具集)。只对新建会话生效。'),
|
|
114
|
+
actions: busy ? h('span', { className: 'dchat-status' }, t('保存中…')) : null,
|
|
115
|
+
},
|
|
116
|
+
options.length === 0
|
|
117
|
+
? h('p', { className: 'dchat-cardDescription' }, t('当前 Host 读不到 Agent Preset 列表。'))
|
|
118
|
+
: h('div', { className: 'dchat-scopeGrid' },
|
|
119
|
+
h('div', { className: 'dchat-scopeRow' },
|
|
120
|
+
h('select', {
|
|
121
|
+
className: 'dchat-select',
|
|
122
|
+
value: value ?? '',
|
|
123
|
+
disabled: busy,
|
|
124
|
+
'aria-label': t('Agent 预设'),
|
|
125
|
+
onChange: (event) => {
|
|
126
|
+
void run(event.target.value || null);
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
h('option', { value: '' }, t('跟随 Host 默认')),
|
|
130
|
+
options.map((row) => h('option', { key: row.id, value: row.id },
|
|
131
|
+
`${row.id}${row.name && row.name !== row.id ? ` · ${row.name}` : ''}`))))),
|
|
132
|
+
failed ? h('p', { className: 'dchat-error', role: 'alert' }, failed) : null);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 机器人默认模型:**还没有会话时**(新聊天、或刚点过「新会话」)用哪个模型。
|
|
137
|
+
*
|
|
138
|
+
* 为什么要这一栏:模型选择在 DSH 里是**会话级**的(`session/selectModel` 必须带 sessionId),
|
|
139
|
+
* 没有会话时无处可写;只能存成机器人级默认、等建会话时应用(与工作区/预设同一条口径)。
|
|
140
|
+
* 两个下拉:模型 + 推理等级(等级依赖所选模型,所以跟着走)。
|
|
141
|
+
*/
|
|
142
|
+
export function ModelEditor({ value, options = [], hostDefault = null, failures = [], translate, onSave }) {
|
|
143
|
+
const t = translatorOf(translate);
|
|
144
|
+
const { busy, failed, run } = useSaver(onSave);
|
|
145
|
+
|
|
146
|
+
const current = value ?? null;
|
|
147
|
+
const selected = current
|
|
148
|
+
? options.find((item) => item.provider === current.provider && item.model === current.model) ?? null
|
|
149
|
+
: null;
|
|
150
|
+
const effortOptions = selected?.efforts ?? [];
|
|
151
|
+
const hostText = hostDefault ? `${hostDefault.provider}/${hostDefault.model}` : null;
|
|
152
|
+
|
|
153
|
+
const save = (patch) => {
|
|
154
|
+
if (patch === null) {
|
|
155
|
+
void run(null);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const provider = patch.provider ?? current?.provider ?? '';
|
|
159
|
+
const model = patch.model ?? current?.model ?? '';
|
|
160
|
+
const reasoningEffort = Object.hasOwn(patch, 'reasoningEffort')
|
|
161
|
+
? patch.reasoningEffort
|
|
162
|
+
: (current?.reasoningEffort ?? null);
|
|
163
|
+
void run({ provider, model, reasoningEffort: reasoningEffort || null });
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const modelSelect = h('div', { className: 'dchat-scopeRow' },
|
|
167
|
+
h('label', { className: 'dchat-scopeLabel' }, t('模型')),
|
|
168
|
+
h('select', {
|
|
169
|
+
className: 'dchat-select',
|
|
170
|
+
value: selected?.value ?? '',
|
|
171
|
+
disabled: busy,
|
|
172
|
+
'aria-label': t('默认模型'),
|
|
173
|
+
onChange: (event) => {
|
|
174
|
+
const next = options.find((item) => item.value === event.target.value);
|
|
175
|
+
if (!next) {
|
|
176
|
+
save(null);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
// 换模型时重置推理等级:等级是模型自己的能力,跨模型沿用会给出不支持的取值。
|
|
180
|
+
save({ provider: next.provider, model: next.model, reasoningEffort: null });
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
h('option', { value: '' }, hostText ? `${t('跟随 Host 默认')}(${hostText})` : t('跟随 Host 默认')),
|
|
184
|
+
options.map((item) => h('option', { key: item.value, value: item.value },
|
|
185
|
+
`${item.value}${item.name && item.name !== item.model ? ` · ${item.name}` : ''}`))));
|
|
186
|
+
|
|
187
|
+
const effortSelect = h('div', { className: 'dchat-scopeRow' },
|
|
188
|
+
h('label', { className: 'dchat-scopeLabel' }, t('推理等级')),
|
|
189
|
+
effortOptions.length === 0
|
|
190
|
+
? h('p', { className: 'dchat-cardDescription' },
|
|
191
|
+
current ? t('这个模型没有可选的推理等级。') : t('先选一个模型。'))
|
|
192
|
+
: h('select', {
|
|
193
|
+
className: 'dchat-select',
|
|
194
|
+
value: current?.reasoningEffort ?? '',
|
|
195
|
+
disabled: busy,
|
|
196
|
+
'aria-label': t('推理等级'),
|
|
197
|
+
onChange: (event) => {
|
|
198
|
+
save({ reasoningEffort: event.target.value || null });
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
h('option', { value: '' }, t('模型默认')),
|
|
202
|
+
effortOptions.map((effort) => h('option', { key: effort.id, value: effort.id },
|
|
203
|
+
`${effort.id}${effort.label && effort.label !== effort.id ? ` · ${effort.label}` : ''}`))));
|
|
204
|
+
|
|
205
|
+
const failureNote = failures.length > 0
|
|
206
|
+
? h('p', { className: 'dchat-cardDescription' }, t('部分 provider 读取失败:')
|
|
207
|
+
+ failures.map((item) => `${item.id || item.name}(${item.message})`).join(';'))
|
|
208
|
+
: null;
|
|
209
|
+
const body = options.length === 0
|
|
210
|
+
? h('p', { className: 'dchat-cardDescription' }, t('当前 Host 读不到模型目录。'))
|
|
211
|
+
: h('div', { className: 'dchat-scopeGrid' }, modelSelect, effortSelect);
|
|
212
|
+
|
|
213
|
+
return h(Card, {
|
|
214
|
+
title: t('默认模型'),
|
|
215
|
+
description: t('还没有会话时用哪个模型:选完对下一条消息新建的会话生效。会话内还能单独改(面板的模型下拉)。'),
|
|
216
|
+
actions: busy ? h('span', { className: 'dchat-status' }, t('保存中…')) : null,
|
|
217
|
+
},
|
|
218
|
+
failureNote,
|
|
219
|
+
body,
|
|
220
|
+
failed ? h('p', { className: 'dchat-error', role: 'alert' }, failed) : null);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** 把策略归一化成编辑器用的草稿(缺字段按"保守方向"填充,与运行期一致)。 */
|
|
224
|
+
function toDraft(value) {
|
|
225
|
+
const base = value ?? defaultAccessPolicy();
|
|
226
|
+
const scopeOf = (scope) => ({
|
|
227
|
+
mode: scope?.mode === 'open' ? 'open' : 'allowlist',
|
|
228
|
+
defaultCanExecuteCommands: scope?.open?.defaultCanExecuteCommands === true,
|
|
229
|
+
// 不在界面上编辑,但必须原样带回去,否则保存一次就把已有例外清空了。
|
|
230
|
+
commandPermissionOverrides: Array.isArray(scope?.open?.commandPermissionOverrides)
|
|
231
|
+
? scope.open.commandPermissionOverrides
|
|
232
|
+
: [],
|
|
233
|
+
users: Array.isArray(scope?.allowlist?.users) ? scope.allowlist.users : [],
|
|
234
|
+
});
|
|
235
|
+
return { direct: scopeOf(base.direct), group: scopeOf(base.group) };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function fromDraft(draft) {
|
|
239
|
+
const scopeOf = (scope) => ({
|
|
240
|
+
mode: scope.mode,
|
|
241
|
+
open: {
|
|
242
|
+
defaultCanExecuteCommands: scope.defaultCanExecuteCommands,
|
|
243
|
+
commandPermissionOverrides: scope.commandPermissionOverrides,
|
|
244
|
+
},
|
|
245
|
+
allowlist: { users: scope.users },
|
|
246
|
+
});
|
|
247
|
+
return { direct: scopeOf(draft.direct), group: scopeOf(draft.group) };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** 一个作用域(私聊 / 群聊)的编辑块。 */
|
|
251
|
+
function ScopeBlock({ scopeKey, label, scope, busy, t, onChange, names = null }) {
|
|
252
|
+
const [entry, setEntry] = React.useState('');
|
|
253
|
+
const inputId = `dchat-policy-${scopeKey}`;
|
|
254
|
+
/** 名单里那串 id 是谁:渠道换来的名字,换不到就只有 id(不编名字)。 */
|
|
255
|
+
const nameOf = (id) => names?.[id] ?? null;
|
|
256
|
+
|
|
257
|
+
const update = (patch) => onChange({ ...scope, ...patch });
|
|
258
|
+
const addUser = () => {
|
|
259
|
+
const id = entry.trim();
|
|
260
|
+
if (!id) return;
|
|
261
|
+
setEntry('');
|
|
262
|
+
if (scope.users.some((user) => user.id === id)) return;
|
|
263
|
+
update({ users: [...scope.users, { id, canExecuteCommands: false }] });
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
/** `allowlist` 模式:逐条名单 + 追加输入。 */
|
|
267
|
+
const allowlist = h(React.Fragment, null,
|
|
268
|
+
scope.users.length > 0
|
|
269
|
+
? h('ul', { className: 'dchat-list' }, scope.users.map((user) => h('li', {
|
|
270
|
+
key: user.id,
|
|
271
|
+
className: 'dchat-listItem',
|
|
272
|
+
},
|
|
273
|
+
// 名字 + id:只显示 id 时,"这条是谁"在设置页上根本认不出来(真机反馈);
|
|
274
|
+
// 但 id 才是判定用的那个值,所以两个都留(与「属主」那一行同一个形态)。
|
|
275
|
+
h('span', { className: 'dchat-policyEntry' },
|
|
276
|
+
nameOf(user.id) ? h('span', { className: 'dchat-policyName' }, nameOf(user.id)) : null,
|
|
277
|
+
h('code', { className: 'dchat-code' }, user.id)),
|
|
278
|
+
h('span', { className: 'dchat-actions' },
|
|
279
|
+
h('label', { className: 'dchat-check' },
|
|
280
|
+
h('input', {
|
|
281
|
+
type: 'checkbox',
|
|
282
|
+
checked: user.canExecuteCommands === true,
|
|
283
|
+
disabled: busy,
|
|
284
|
+
onChange: (event) => update({
|
|
285
|
+
users: scope.users.map((item) => (item.id === user.id
|
|
286
|
+
? { ...item, canExecuteCommands: event.target.checked }
|
|
287
|
+
: item)),
|
|
288
|
+
}),
|
|
289
|
+
}),
|
|
290
|
+
h('span', null, t('可执行命令'))),
|
|
291
|
+
h('button', {
|
|
292
|
+
type: 'button',
|
|
293
|
+
className: 'dchat-button dchat-buttonDanger',
|
|
294
|
+
disabled: busy,
|
|
295
|
+
onClick: () => update({ users: scope.users.filter((item) => item.id !== user.id) }),
|
|
296
|
+
}, t('移除'))))))
|
|
297
|
+
: h('p', { className: 'dchat-cardDescription' }, t('名单为空时只有属主可用。')),
|
|
298
|
+
h('div', { className: 'dchat-actions' },
|
|
299
|
+
h('input', {
|
|
300
|
+
id: inputId,
|
|
301
|
+
className: 'dchat-input',
|
|
302
|
+
value: entry,
|
|
303
|
+
disabled: busy,
|
|
304
|
+
placeholder: t('对方的平台 id,回车添加'),
|
|
305
|
+
autoComplete: 'off',
|
|
306
|
+
spellCheck: false,
|
|
307
|
+
onChange: (event) => setEntry(event.target.value),
|
|
308
|
+
onKeyDown: (event) => {
|
|
309
|
+
if (event.key !== 'Enter') return;
|
|
310
|
+
event.preventDefault();
|
|
311
|
+
addUser();
|
|
312
|
+
},
|
|
313
|
+
}),
|
|
314
|
+
h('button', {
|
|
315
|
+
type: 'button',
|
|
316
|
+
className: 'dchat-button',
|
|
317
|
+
disabled: busy || !entry.trim(),
|
|
318
|
+
onClick: addUser,
|
|
319
|
+
}, t('添加'))));
|
|
320
|
+
|
|
321
|
+
/** `open` 模式:只暴露"默认能不能执行命令"。 */
|
|
322
|
+
const openScope = h('label', { className: 'dchat-check' },
|
|
323
|
+
h('input', {
|
|
324
|
+
type: 'checkbox',
|
|
325
|
+
checked: scope.defaultCanExecuteCommands,
|
|
326
|
+
disabled: busy,
|
|
327
|
+
onChange: (event) => update({ defaultCanExecuteCommands: event.target.checked }),
|
|
328
|
+
}),
|
|
329
|
+
h('span', null, t('允许执行命令')));
|
|
330
|
+
|
|
331
|
+
return h('div', { className: 'dchat-scopeRow' },
|
|
332
|
+
h('div', { className: 'dchat-policyHead' },
|
|
333
|
+
h('label', { className: 'dchat-scopeLabel', htmlFor: inputId }, label),
|
|
334
|
+
h('select', {
|
|
335
|
+
className: 'dchat-select',
|
|
336
|
+
value: scope.mode,
|
|
337
|
+
disabled: busy,
|
|
338
|
+
'aria-label': `${label} ${t('访问模式')}`,
|
|
339
|
+
onChange: (event) => update({ mode: event.target.value }),
|
|
340
|
+
},
|
|
341
|
+
h('option', { value: 'allowlist' }, t('仅名单内可用')),
|
|
342
|
+
h('option', { value: 'open' }, t('任何人可用')))),
|
|
343
|
+
scope.mode === 'open' ? openScope : allowlist);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* 访问策略:谁能跟机器人说话、谁能执行命令。
|
|
348
|
+
*
|
|
349
|
+
* 改一项存一次(策略是开关/名单,没有"改到一半"的中间态)。
|
|
350
|
+
* 校验用的是与 host 拦消息时**同一份** `access-policy.mjs`。
|
|
351
|
+
*
|
|
352
|
+
* @param props - { value, translate, onSave, names?, namesHint? }。
|
|
353
|
+
* `names` 是渠道换回来的「id → 名字」(`names.resolve`);渠道不给就只显示 id。
|
|
354
|
+
* `namesHint` 是"名字为什么没换到"(多为缺权限),有就说明,免得用户以为功能坏了。
|
|
355
|
+
*/
|
|
356
|
+
export function AccessPolicyEditor({ value, translate, onSave, names = null, namesHint = null }) {
|
|
357
|
+
const t = translatorOf(translate);
|
|
358
|
+
const [draft, setDraft] = React.useState(() => toDraft(value));
|
|
359
|
+
const { busy, failed, run } = useSaver(onSave);
|
|
360
|
+
|
|
361
|
+
React.useEffect(() => {
|
|
362
|
+
setDraft(toDraft(value));
|
|
363
|
+
}, [value]);
|
|
364
|
+
|
|
365
|
+
const commit = React.useCallback(async (next) => {
|
|
366
|
+
setDraft(next);
|
|
367
|
+
const saved = await run(fromDraft(next));
|
|
368
|
+
if (!saved) setDraft(toDraft(value)); // 失败回滚到外部真值
|
|
369
|
+
}, [run, value]);
|
|
370
|
+
|
|
371
|
+
return h(Card, {
|
|
372
|
+
title: t('访问策略'),
|
|
373
|
+
description: t('谁能跟机器人说话、谁能执行命令。改动立即生效;属主始终可用。'),
|
|
374
|
+
actions: busy ? h('span', { className: 'dchat-status' }, t('保存中…')) : null,
|
|
375
|
+
},
|
|
376
|
+
h('div', { className: 'dchat-policyGrid' },
|
|
377
|
+
h(ScopeBlock, {
|
|
378
|
+
scopeKey: 'direct', label: t('私聊'), scope: draft.direct, busy, t, names,
|
|
379
|
+
onChange: (next) => { void commit({ ...draft, direct: next }); },
|
|
380
|
+
}),
|
|
381
|
+
h(ScopeBlock, {
|
|
382
|
+
scopeKey: 'group', label: t('群聊'), scope: draft.group, busy, t, names,
|
|
383
|
+
onChange: (next) => { void commit({ ...draft, group: next }); },
|
|
384
|
+
})),
|
|
385
|
+
namesHint ? h('p', { className: 'dchat-cardDescription' }, namesHint.message ?? String(namesHint)) : null,
|
|
386
|
+
failed ? h('p', { className: 'dchat-error', role: 'alert' }, failed) : null);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* 属主:**属主绕过所有访问策略**(消息与命令都不看名单),所以这是权限面,不是普通设置。
|
|
391
|
+
*
|
|
392
|
+
* `owners` 里的 `*` 表示**没有属主**(公开机器人)——不是"人人都是属主"。
|
|
393
|
+
* 设置页只做三件事:看清现在是谁、从"它聊过的会话"里选一个人设为属主、清空回无属主。
|
|
394
|
+
* 保存后渠道会重连一次,属主立刻生效。
|
|
395
|
+
*
|
|
396
|
+
* @param props - {
|
|
397
|
+
* owners: string[], wildcard: boolean, candidates: [{ id, name }],
|
|
398
|
+
* busy?: boolean, translate, onSave(owners: string[]),
|
|
399
|
+
* }。
|
|
400
|
+
* @returns React 元素。
|
|
401
|
+
*/
|
|
402
|
+
export function OwnerEditor({ owners = [], wildcard = false, candidates = [], translate, onSave }) {
|
|
403
|
+
const t = translatorOf(translate);
|
|
404
|
+
const [picked, setPicked] = React.useState('');
|
|
405
|
+
const { busy, failed, run } = useSaver(onSave);
|
|
406
|
+
const nameOf = (id) => candidates.find((item) => item.id === id)?.name ?? null;
|
|
407
|
+
const people = candidates.filter((item) => !owners.includes(item.id));
|
|
408
|
+
|
|
409
|
+
return h(Card, {
|
|
410
|
+
title: t('属主'),
|
|
411
|
+
description: t('属主不需要进白名单:消息与命令都直接放行。这里改完会重连一次,立刻生效。'),
|
|
412
|
+
actions: busy ? h('span', { className: 'dchat-status' }, t('保存中…')) : null,
|
|
413
|
+
},
|
|
414
|
+
h('div', { className: 'dchat-scopeGrid' },
|
|
415
|
+
wildcard || owners.length === 0
|
|
416
|
+
? h('p', { className: 'dchat-cardDescription' }, t('当前没有属主:没有人绕过访问策略,谁能用完全由下面的「访问策略」决定。'))
|
|
417
|
+
: h('ul', { className: 'dchat-list' }, owners.map((id) => h('li', {
|
|
418
|
+
key: id,
|
|
419
|
+
className: 'dchat-listItem',
|
|
420
|
+
},
|
|
421
|
+
// 名字 + id:id 用等宽块(可省略号),避免一行被 35 字符的 open_id 撑爆。
|
|
422
|
+
h('span', null,
|
|
423
|
+
nameOf(id) ? `${nameOf(id)} ` : null,
|
|
424
|
+
h('code', { className: 'dchat-code' }, id)),
|
|
425
|
+
h('span', { className: 'dchat-actions' },
|
|
426
|
+
h('button', {
|
|
427
|
+
type: 'button',
|
|
428
|
+
className: 'dchat-button dchat-buttonDanger',
|
|
429
|
+
disabled: busy,
|
|
430
|
+
onClick: () => {
|
|
431
|
+
void run([...owners.filter((item) => item !== id)]);
|
|
432
|
+
},
|
|
433
|
+
}, t('移除')))))),
|
|
434
|
+
h('div', { className: 'dchat-actions' },
|
|
435
|
+
h('select', {
|
|
436
|
+
className: 'dchat-select',
|
|
437
|
+
value: picked,
|
|
438
|
+
disabled: busy || people.length === 0,
|
|
439
|
+
'aria-label': t('从会话里选一个人设为属主'),
|
|
440
|
+
onChange: (event) => setPicked(event.target.value),
|
|
441
|
+
},
|
|
442
|
+
h('option', { value: '' },
|
|
443
|
+
people.length === 0 ? t('没有可选的会话(先和机器人聊一次)') : t('从会话里选一个人设为属主')),
|
|
444
|
+
people.map((item) => h('option', { key: item.id, value: item.id }, item.name))),
|
|
445
|
+
h('button', {
|
|
446
|
+
type: 'button',
|
|
447
|
+
className: 'dchat-button',
|
|
448
|
+
disabled: busy || !picked,
|
|
449
|
+
onClick: () => {
|
|
450
|
+
setPicked('');
|
|
451
|
+
void run([...owners.filter((item) => item !== '*'), picked]);
|
|
452
|
+
},
|
|
453
|
+
}, t('设为属主')),
|
|
454
|
+
h('button', {
|
|
455
|
+
type: 'button',
|
|
456
|
+
className: 'dchat-button',
|
|
457
|
+
disabled: busy || (wildcard && owners.length === 1),
|
|
458
|
+
title: t('清空后没有人绕过访问策略'),
|
|
459
|
+
onClick: () => {
|
|
460
|
+
void run(['*']);
|
|
461
|
+
},
|
|
462
|
+
}, t('清空(无属主)')))),
|
|
463
|
+
failed ? h('p', { className: 'dchat-error', role: 'alert' }, failed) : null);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** 显示项的中文标签(键交给渠道字典翻译)。 */
|
|
467
|
+
const PANEL_SECTION_LABELS = Object.freeze({
|
|
468
|
+
model: '模型与推理等级',
|
|
469
|
+
session: '会话',
|
|
470
|
+
preset: 'Agent 预设与工作区',
|
|
471
|
+
context: '上下文增强(本会话)',
|
|
472
|
+
policy: '访问策略(本会话)',
|
|
473
|
+
fields: '渠道设置(任务过程展示等)',
|
|
474
|
+
actions: '渠道动作按钮(重连等)',
|
|
475
|
+
commands: '命令按钮(新会话/状态/诊断…)',
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* 控制面板卡片的显示项:**私聊与群聊分开**,逐项开关。
|
|
480
|
+
*
|
|
481
|
+
* 为什么值得做:面板越长越难用——手机上要滑好几屏,而"只想换个模型"的人在群里
|
|
482
|
+
* 并不需要看到访问策略与任务过程展示。这里是纯粹的**显示**配置,关掉不影响任何功能
|
|
483
|
+
* (命令、策略、上下文增强都照旧生效,只是不画在那张卡上)。
|
|
484
|
+
*
|
|
485
|
+
* 选完即存(没有保存按钮,与 `ScopedModeEditor` 同一条理由),失败回滚并就地说明。
|
|
486
|
+
*
|
|
487
|
+
* @param props - {
|
|
488
|
+
* value: { direct: {…}, group: {…} } | null, disabled, saving, error,
|
|
489
|
+
* translate, onSave(next),
|
|
490
|
+
* }。
|
|
491
|
+
* @returns React 元素。
|
|
492
|
+
*/
|
|
493
|
+
export function PanelSectionsEditor({
|
|
494
|
+
value = null, disabled = false, saving = false, error = null, translate, onSave,
|
|
495
|
+
}) {
|
|
496
|
+
const t = translatorOf(translate);
|
|
497
|
+
const sections = PANEL_SECTIONS;
|
|
498
|
+
const scopes = [{ key: 'direct', label: '私聊' }, { key: 'group', label: '群聊' }];
|
|
499
|
+
const [draft, setDraft] = React.useState(() => normalizePanelSections(value));
|
|
500
|
+
const [pending, setPending] = React.useState(null);
|
|
501
|
+
const [failed, setFailed] = React.useState(null);
|
|
502
|
+
const locked = disabled || saving || pending !== null;
|
|
503
|
+
|
|
504
|
+
React.useEffect(() => {
|
|
505
|
+
if (pending !== null) return;
|
|
506
|
+
setDraft((current) => (JSON.stringify(current) === JSON.stringify(normalizePanelSections(value))
|
|
507
|
+
? current
|
|
508
|
+
: normalizePanelSections(value)));
|
|
509
|
+
}, [value, pending]);
|
|
510
|
+
|
|
511
|
+
const toggle = async (scopeKey, sectionId, nextChecked) => {
|
|
512
|
+
if (locked) return;
|
|
513
|
+
const next = {
|
|
514
|
+
...draft,
|
|
515
|
+
[scopeKey]: { ...draft[scopeKey], [sectionId]: nextChecked },
|
|
516
|
+
};
|
|
517
|
+
setDraft(next);
|
|
518
|
+
setFailed(null);
|
|
519
|
+
setPending(`${scopeKey}:${sectionId}`);
|
|
520
|
+
try {
|
|
521
|
+
await onSave(next);
|
|
522
|
+
} catch (cause) {
|
|
523
|
+
setDraft(normalizePanelSections(value));
|
|
524
|
+
setFailed(cause?.message ?? String(cause));
|
|
525
|
+
} finally {
|
|
526
|
+
setPending(null);
|
|
527
|
+
}
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
const header = h('div', { className: 'dchat-panelSectionsHead' },
|
|
531
|
+
h('span', { className: 'dchat-scopeLabel' }, t('显示项')),
|
|
532
|
+
scopes.map((scope) => h('span', {
|
|
533
|
+
key: scope.key, className: 'dchat-scopeLabel',
|
|
534
|
+
}, t(scope.label))));
|
|
535
|
+
|
|
536
|
+
const rows = sections.map((sectionId) => h('div', {
|
|
537
|
+
key: sectionId, className: 'dchat-panelSectionsRow',
|
|
538
|
+
},
|
|
539
|
+
h('span', { className: 'dchat-panelSectionsName' }, t(PANEL_SECTION_LABELS[sectionId])),
|
|
540
|
+
scopes.map((scope) => h('label', {
|
|
541
|
+
key: scope.key,
|
|
542
|
+
className: 'dchat-panelSectionsCheck',
|
|
543
|
+
title: `${t(PANEL_SECTION_LABELS[sectionId])} · ${t(scope.label)}`,
|
|
544
|
+
}, h('input', {
|
|
545
|
+
type: 'checkbox',
|
|
546
|
+
checked: draft[scope.key]?.[sectionId] !== false,
|
|
547
|
+
disabled: locked,
|
|
548
|
+
'aria-label': `${t(PANEL_SECTION_LABELS[sectionId])} · ${t(scope.label)}`,
|
|
549
|
+
onChange: (event) => {
|
|
550
|
+
void toggle(scope.key, sectionId, event.target.checked);
|
|
551
|
+
},
|
|
552
|
+
})))));
|
|
553
|
+
|
|
554
|
+
return h(Card, {
|
|
555
|
+
title: t('控制面板显示项'),
|
|
556
|
+
description: t('只影响 /menu 发出来的那张卡片:关掉的项不显示,功能照旧(私聊与群聊分别设置)。'),
|
|
557
|
+
actions: pending !== null ? h('span', { className: 'dchat-status' }, t('保存中…')) : null,
|
|
558
|
+
},
|
|
559
|
+
h('div', { className: 'dchat-panelSections' }, header, rows),
|
|
560
|
+
failed || error ? h('p', { className: 'dchat-error', role: 'alert' }, failed ?? error) : null);
|
|
561
|
+
}
|