dsh-my-config 1.7.2 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -3
- package/client.js +294 -37
- package/index.js +473 -15
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,12 +4,21 @@ DeepSeek Harness 插件:在 Web 侧边栏提供「我的技能 / 我的连接
|
|
|
4
4
|
|
|
5
5
|
| 模块 | 能力 |
|
|
6
6
|
|---|---|
|
|
7
|
-
| 我的技能 | 创建 / 编辑 / 删除 / 导入技能;**ZIP 技能包导入**(多文件包);**外部技能市场**(内置精选市场 + 任意 `github://owner/repo` 仓库 + JSON
|
|
8
|
-
| 我的连接器 | 创建 / 编辑(配置)/ 启停 / 删除 MCP 连接器(streamable-http 与 stdio),通过 `ctx.loader` 动态下发 `@deepseek-ai/dsh-mcp-client`
|
|
9
|
-
| 我的智能体 | 创建 / 编辑 / 删除自定义 Agent
|
|
7
|
+
| 我的技能 | 创建 / 编辑 / 删除 / 导入技能;**ZIP 技能包导入**(多文件包);**外部技能市场**(内置精选市场 + 任意 `github://owner/repo` 仓库 + JSON 索引市场);**全字段编辑**(license / compatibility / metadata / allowed-tools,对齐 Agent Skills 开放规范);**规范校验**(保存/导入时逐字段拦截并定位错误);分组管理 |
|
|
8
|
+
| 我的连接器 | 创建 / 编辑(配置)/ 启停 / 删除 MCP 连接器(streamable-http 与 stdio),通过 `ctx.loader` 动态下发 `@deepseek-ai/dsh-mcp-client` 条目;**连接测试**(MCP 握手 + 工具清单预览);**工具级授权**(白名单拦截 + 自动执行放行,经审批 answerer 实现);**凭据脱敏**(env / headers 掩码显示,防误覆盖);分组管理 |
|
|
9
|
+
| 我的智能体 | 创建 / 编辑 / 删除自定义 Agent 预设;**关联技能**(独立技能目录,精确限定该智能体可见的技能)与**关联连接器**(工具清单注入系统提示词);**模型绑定**(provider/model/reasoningEffort 记录与展示);**工具组白名单**(文件 / 联网 / 终端 / 技能 四组粒度过滤预设组合);分组管理 |
|
|
10
10
|
|
|
11
11
|
三个页面统一布局:搜索框 + 分组筛选 + 刷新 + 新建。
|
|
12
12
|
|
|
13
|
+
## 更新日志
|
|
14
|
+
|
|
15
|
+
### v1.8.0
|
|
16
|
+
|
|
17
|
+
- **技能**:编辑器支持 license / compatibility / metadata / allowed-tools 全字段(对齐 agentskills.io 规范);保存与导入时执行规范校验,错误逐字段定位展示。
|
|
18
|
+
- **连接器**:新增「测试连接」(stdio / streamable-http 真实 MCP 握手,返回工具清单与延迟);工具级授权(允许使用白名单 + 自动执行列表,经 `approval/request` answerer 拦截/放行);env / headers 密钥掩码显示与防误覆盖。
|
|
19
|
+
- **智能体**:预设新增模型绑定(provider / model / reasoningEffort)与工具组白名单(文件 / 联网 / 终端 / 技能)。
|
|
20
|
+
- 已知限制:审批拒绝原因经审计日志留痕(内核 outcome 词表无原因通道);连接器密钥存储仍为本地明文(data.json),请妥善保管该文件。
|
|
21
|
+
|
|
13
22
|
## 安装
|
|
14
23
|
|
|
15
24
|
```bash
|
package/client.js
CHANGED
|
@@ -102,6 +102,18 @@ window.__ModuleLoader__.load({
|
|
|
102
102
|
skillReadonly: '只读',
|
|
103
103
|
skillInvalid: 'frontmatter 无效',
|
|
104
104
|
skillRoot: '存储目录',
|
|
105
|
+
skillLicense: '许可证(可选)',
|
|
106
|
+
skillCompatibility: '兼容性(可选)',
|
|
107
|
+
skillMetadata: '元数据(键值对,可选)',
|
|
108
|
+
skillMetadataKey: '键',
|
|
109
|
+
skillMetadataValue: '值',
|
|
110
|
+
skillMetadataAdd: '添加一行',
|
|
111
|
+
skillAllowedTools: '允许的工具(可选)',
|
|
112
|
+
skillAllowedToolsHint: '实验性功能:写入 frontmatter 的 allowed-tools,空格分隔的工具名',
|
|
113
|
+
skillErrName: '名称须为小写 kebab-case(字母/数字/连字符),不超过 64 字符',
|
|
114
|
+
skillErrDescEmpty: '描述不能为空',
|
|
115
|
+
skillErrDescLong: '描述不能超过 1024 字符',
|
|
116
|
+
skillErrCompatLong: '兼容性说明不能超过 500 字符',
|
|
105
117
|
marketAdd: '添加市场',
|
|
106
118
|
marketName: '市场名称',
|
|
107
119
|
marketUrl: '市场索引地址(JSON)',
|
|
@@ -127,6 +139,16 @@ window.__ModuleLoader__.load({
|
|
|
127
139
|
connectorLive: '运行中',
|
|
128
140
|
connectorStopped: '未加载',
|
|
129
141
|
connectorToolsHint: '连接后工具以 mcp__{serverName}__ 前缀注册',
|
|
142
|
+
connectorTest: '测试连接',
|
|
143
|
+
connectorUntested: '未测试',
|
|
144
|
+
connectorConnected: '已连接 {n} 个工具',
|
|
145
|
+
connectorTestFailed: '失败:{reason}',
|
|
146
|
+
connectorTools: '工具',
|
|
147
|
+
connectorToolsEmpty: '暂无工具清单,请先点击「测试连接」获取。',
|
|
148
|
+
connectorToolAllowed: '允许使用',
|
|
149
|
+
connectorToolAuto: '自动执行',
|
|
150
|
+
connectorApprovalNever: '当前审批策略为 never,自动执行不生效。',
|
|
151
|
+
connectorSecretHidden: '敏感值不可查看,如需修改请重新填写。',
|
|
130
152
|
// 智能体
|
|
131
153
|
expertCreate: '创建智能体',
|
|
132
154
|
expertEdit: '编辑智能体',
|
|
@@ -139,6 +161,19 @@ window.__ModuleLoader__.load({
|
|
|
139
161
|
expertDefault: '默认',
|
|
140
162
|
expertBroken: '加载失败',
|
|
141
163
|
expertHint: '自定义智能体注册为 Agent 预设,新会话可在预设选择器中选用。',
|
|
164
|
+
expertModel: '模型绑定(可选)',
|
|
165
|
+
expertModelProvider: '提供商(如 anthropic)',
|
|
166
|
+
expertModelName: '模型名(如 claude-sonnet-4)',
|
|
167
|
+
expertModelDefault: '跟随默认',
|
|
168
|
+
expertModelHint: '提供商与模型名均填写才生效;留空 = 跟随默认模型。',
|
|
169
|
+
expertModelPartial: '模型绑定须同时填写提供商与模型名,或留空。',
|
|
170
|
+
expertTools: '工具组(可选)',
|
|
171
|
+
expertToolFs: '文件读写检索',
|
|
172
|
+
expertToolWeb: '联网',
|
|
173
|
+
expertToolShell: '终端',
|
|
174
|
+
expertToolSkills: '技能',
|
|
175
|
+
expertToolsHint: '留空 = 全部可用。',
|
|
176
|
+
expertToolsSummary: '工具组',
|
|
142
177
|
// 插件
|
|
143
178
|
pluginInstall: '安装插件',
|
|
144
179
|
pluginSpec: '包名 / Git 地址 / 本地目录',
|
|
@@ -242,6 +277,18 @@ window.__ModuleLoader__.load({
|
|
|
242
277
|
skillReadonly: 'Read-only',
|
|
243
278
|
skillInvalid: 'Invalid frontmatter',
|
|
244
279
|
skillRoot: 'Storage directory',
|
|
280
|
+
skillLicense: 'License (optional)',
|
|
281
|
+
skillCompatibility: 'Compatibility (optional)',
|
|
282
|
+
skillMetadata: 'Metadata (key-value pairs, optional)',
|
|
283
|
+
skillMetadataKey: 'Key',
|
|
284
|
+
skillMetadataValue: 'Value',
|
|
285
|
+
skillMetadataAdd: 'Add row',
|
|
286
|
+
skillAllowedTools: 'Allowed tools (optional)',
|
|
287
|
+
skillAllowedToolsHint: 'Experimental: written to frontmatter as allowed-tools, space-separated tool names',
|
|
288
|
+
skillErrName: 'Name must be lowercase kebab-case (letters/digits/hyphens), max 64 characters',
|
|
289
|
+
skillErrDescEmpty: 'Description is required',
|
|
290
|
+
skillErrDescLong: 'Description must be at most 1024 characters',
|
|
291
|
+
skillErrCompatLong: 'Compatibility must be at most 500 characters',
|
|
245
292
|
marketAdd: 'Add market',
|
|
246
293
|
marketName: 'Market name',
|
|
247
294
|
marketUrl: 'Market index URL (JSON)',
|
|
@@ -266,6 +313,16 @@ window.__ModuleLoader__.load({
|
|
|
266
313
|
connectorLive: 'Live',
|
|
267
314
|
connectorStopped: 'Not loaded',
|
|
268
315
|
connectorToolsHint: 'Tools register with the mcp__{serverName}__ prefix once connected',
|
|
316
|
+
connectorTest: 'Test connection',
|
|
317
|
+
connectorUntested: 'Not tested',
|
|
318
|
+
connectorConnected: 'Connected: {n} tools',
|
|
319
|
+
connectorTestFailed: 'Failed: {reason}',
|
|
320
|
+
connectorTools: 'Tools',
|
|
321
|
+
connectorToolsEmpty: 'No tool list yet. Click "Test connection" to fetch it.',
|
|
322
|
+
connectorToolAllowed: 'Allow',
|
|
323
|
+
connectorToolAuto: 'Auto-run',
|
|
324
|
+
connectorApprovalNever: 'Current approval policy is "never"; auto-run has no effect.',
|
|
325
|
+
connectorSecretHidden: 'Secret values cannot be viewed. Re-enter to change them.',
|
|
269
326
|
expertCreate: 'New agent',
|
|
270
327
|
expertEdit: 'Edit agent',
|
|
271
328
|
expertId: 'ID (lowercase letters/digits/hyphens)',
|
|
@@ -277,6 +334,19 @@ window.__ModuleLoader__.load({
|
|
|
277
334
|
expertDefault: 'Default',
|
|
278
335
|
expertBroken: 'Broken',
|
|
279
336
|
expertHint: 'Custom agents register as agent presets and can be selected for new sessions.',
|
|
337
|
+
expertModel: 'Model binding (optional)',
|
|
338
|
+
expertModelProvider: 'Provider (e.g. anthropic)',
|
|
339
|
+
expertModelName: 'Model (e.g. claude-sonnet-4)',
|
|
340
|
+
expertModelDefault: 'Follow default',
|
|
341
|
+
expertModelHint: 'Both provider and model are required to take effect; empty = follow the default model.',
|
|
342
|
+
expertModelPartial: 'Model binding requires both provider and model, or leave empty.',
|
|
343
|
+
expertTools: 'Tool groups (optional)',
|
|
344
|
+
expertToolFs: 'File read/write & search',
|
|
345
|
+
expertToolWeb: 'Web access',
|
|
346
|
+
expertToolShell: 'Terminal',
|
|
347
|
+
expertToolSkills: 'Skills',
|
|
348
|
+
expertToolsHint: 'Empty = all available.',
|
|
349
|
+
expertToolsSummary: 'Tool groups',
|
|
280
350
|
pluginInstall: 'Install plugin',
|
|
281
351
|
pluginSpec: 'Package name / Git URL / local directory',
|
|
282
352
|
pluginUninstall: 'Uninstall',
|
|
@@ -350,6 +420,7 @@ window.__ModuleLoader__.load({
|
|
|
350
420
|
.mc-empty { color: var(--dsw-alias-label-secondary); padding: 48px 0; text-align: center; }
|
|
351
421
|
.mc-error { color: var(--dsw-alias-state-error-primary); padding: 24px 0; text-align: center; display: flex; flex-direction: column; gap: 8px; align-items: center; }
|
|
352
422
|
.mc-hint { font-size: 12px; color: var(--dsw-alias-label-secondary); line-height: 1.6; }
|
|
423
|
+
.mc-field-error { color: var(--dsw-alias-state-error-primary); font-size: 12px; }
|
|
353
424
|
.mc-section { margin-bottom: 20px; }
|
|
354
425
|
.mc-section-title { font-size: 13px; font-weight: 600; margin: 0 0 10px; color: var(--dsw-alias-label-secondary); }
|
|
355
426
|
.mc-row { display: flex; align-items: center; gap: 10px; padding: 10px 14px; background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l1); border-radius: 10px; margin-bottom: 8px; }
|
|
@@ -450,7 +521,11 @@ window.__ModuleLoader__.load({
|
|
|
450
521
|
clearTimeout(timer);
|
|
451
522
|
}
|
|
452
523
|
const data = await res.json().catch(() => ({ ok: false, message: `HTTP ${res.status} [${url}]` }));
|
|
453
|
-
if (!data.ok)
|
|
524
|
+
if (!data.ok) {
|
|
525
|
+
const err = new Error(data.message || `HTTP ${res.status}`);
|
|
526
|
+
if (Array.isArray(data.errors)) err.fields = data.errors;
|
|
527
|
+
throw err;
|
|
528
|
+
}
|
|
454
529
|
return data;
|
|
455
530
|
}
|
|
456
531
|
|
|
@@ -509,17 +584,51 @@ window.__ModuleLoader__.load({
|
|
|
509
584
|
group: skill?.group ?? '',
|
|
510
585
|
userInvocable: skill?.userInvocable !== false,
|
|
511
586
|
disableModelInvocation: skill?.disableModelInvocation === true,
|
|
587
|
+
license: skill?.license ?? '',
|
|
588
|
+
compatibility: skill?.compatibility ?? '',
|
|
589
|
+
metadata: Object.entries(skill?.metadata ?? {}).map(([key, value]) => ({ key, value: String(value ?? '') })),
|
|
590
|
+
allowedTools: skill?.allowedTools ?? '',
|
|
512
591
|
});
|
|
513
592
|
const [busy, setBusy] = useState(false);
|
|
514
|
-
const [error, setError] = useState(
|
|
515
|
-
const
|
|
593
|
+
const [error, setError] = useState(undefined); // { message, fields? }
|
|
594
|
+
const [errors, setErrors] = useState({}); // 本地校验:字段 → 错误文案
|
|
595
|
+
const validateForm = (f) => {
|
|
596
|
+
const errs = {};
|
|
597
|
+
if (f.name.length > 64 || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(f.name)) errs.name = t('skillErrName');
|
|
598
|
+
if (!f.description.trim()) errs.description = t('skillErrDescEmpty');
|
|
599
|
+
else if (f.description.length > 1024) errs.description = t('skillErrDescLong');
|
|
600
|
+
if (f.compatibility.length > 500) errs.compatibility = t('skillErrCompatLong');
|
|
601
|
+
return errs;
|
|
602
|
+
};
|
|
603
|
+
const set = (key) => (e) => {
|
|
604
|
+
const next = { ...form, [key]: e.target.value };
|
|
605
|
+
setForm(next);
|
|
606
|
+
if (Object.keys(errors).length > 0) setErrors(validateForm(next));
|
|
607
|
+
};
|
|
608
|
+
const setMetadata = (i, key) => (e) => {
|
|
609
|
+
const metadata = form.metadata.map((row, idx) => (idx === i ? { ...row, [key]: e.target.value } : row));
|
|
610
|
+
setForm({ ...form, metadata });
|
|
611
|
+
};
|
|
516
612
|
const submit = async () => {
|
|
517
|
-
|
|
613
|
+
const errs = validateForm(form);
|
|
614
|
+
setErrors(errs);
|
|
615
|
+
if (Object.keys(errs).length > 0) return;
|
|
616
|
+
setBusy(true); setError(undefined);
|
|
518
617
|
try {
|
|
519
|
-
|
|
618
|
+
const metadata = {};
|
|
619
|
+
for (const row of form.metadata) {
|
|
620
|
+
const k = row.key.trim();
|
|
621
|
+
if (k) metadata[k] = row.value;
|
|
622
|
+
}
|
|
623
|
+
await api(isEdit ? 'skills.update' : 'skills.create', { ...form, metadata });
|
|
520
624
|
onSaved();
|
|
521
|
-
} catch (err) { setError(err.message); } finally { setBusy(false); }
|
|
625
|
+
} catch (err) { setError({ message: err.message, fields: err.fields }); } finally { setBusy(false); }
|
|
522
626
|
};
|
|
627
|
+
const renderServerError = (err) => err && h('div', { className: 'mc-diag' },
|
|
628
|
+
err.message,
|
|
629
|
+
(err.fields ?? []).length > 0 && h('ul', { style: { margin: '6px 0 0', paddingLeft: 18 } },
|
|
630
|
+
err.fields.map((f, i) => h('li', { key: i }, (f.field ? f.field + ': ' : '') + (f.message ?? '')))),
|
|
631
|
+
);
|
|
523
632
|
return h(Modal, {
|
|
524
633
|
title: isEdit ? t('skillEdit') : t('skillCreate'), onClose,
|
|
525
634
|
footer: [
|
|
@@ -527,10 +636,28 @@ window.__ModuleLoader__.load({
|
|
|
527
636
|
h(Button, { key: 's', kind: 'primary', disabled: busy || !form.name || !form.description, onClick: submit }, busy ? '…' : t('save')),
|
|
528
637
|
],
|
|
529
638
|
},
|
|
530
|
-
|
|
531
|
-
h(Field, { label: t('skillName') },
|
|
532
|
-
|
|
639
|
+
renderServerError(error),
|
|
640
|
+
h(Field, { label: t('skillName') },
|
|
641
|
+
h('input', { className: 'mc-input', value: form.name, disabled: isEdit, onChange: set('name'), placeholder: 'my-skill', autoFocus: !isEdit }),
|
|
642
|
+
errors.name && h('span', { className: 'mc-field-error' }, errors.name)),
|
|
643
|
+
h(Field, { label: t('skillDescription') },
|
|
644
|
+
h('input', { className: 'mc-input', value: form.description, onChange: set('description') }),
|
|
645
|
+
errors.description && h('span', { className: 'mc-field-error' }, errors.description)),
|
|
533
646
|
h(Field, { label: t('skillWhenToUse') }, h('input', { className: 'mc-input', value: form.whenToUse, onChange: set('whenToUse') })),
|
|
647
|
+
h(Field, { label: t('skillCompatibility') },
|
|
648
|
+
h('input', { className: 'mc-input', value: form.compatibility, onChange: set('compatibility'), placeholder: 'node >= 18, macos' }),
|
|
649
|
+
errors.compatibility && h('span', { className: 'mc-field-error' }, errors.compatibility)),
|
|
650
|
+
h(Field, { label: t('skillLicense') }, h('input', { className: 'mc-input', value: form.license, onChange: set('license'), placeholder: 'MIT' })),
|
|
651
|
+
h(Field, { label: t('skillMetadata') },
|
|
652
|
+
form.metadata.map((row, i) => h('div', { key: i, style: { display: 'flex', gap: 6, alignItems: 'center' } },
|
|
653
|
+
h('input', { className: 'mc-input', style: { flex: 1 }, placeholder: t('skillMetadataKey'), value: row.key, onChange: setMetadata(i, 'key') }),
|
|
654
|
+
h('input', { className: 'mc-input', style: { flex: 2 }, placeholder: t('skillMetadataValue'), value: row.value, onChange: setMetadata(i, 'value') }),
|
|
655
|
+
h(Button, { className: 'link danger', kind: undefined, 'aria-label': t('delete'), onClick: () => setForm({ ...form, metadata: form.metadata.filter((_, idx) => idx !== i) }) }, '✕'),
|
|
656
|
+
)),
|
|
657
|
+
h(Button, { className: 'link', kind: undefined, onClick: () => setForm({ ...form, metadata: [...form.metadata, { key: '', value: '' }] }) }, '+ ' + t('skillMetadataAdd'))),
|
|
658
|
+
h(Field, { label: t('skillAllowedTools') },
|
|
659
|
+
h('input', { className: 'mc-input', value: form.allowedTools, onChange: set('allowedTools'), placeholder: 'tool-a tool-b' }),
|
|
660
|
+
h('span', { className: 'mc-hint' }, t('skillAllowedToolsHint'))),
|
|
534
661
|
h(Field, { label: t('group') }, h('input', { className: 'mc-input', value: form.group, onChange: set('group'), placeholder: t('groupPlaceholder') })),
|
|
535
662
|
h(Field, { label: t('skillContent') }, h('textarea', { className: 'mc-textarea', value: form.content, onChange: set('content'), placeholder: '# ...' })),
|
|
536
663
|
h('label', { className: 'mc-checkbox' }, h('input', { type: 'checkbox', checked: form.userInvocable, onChange: (e) => setForm({ ...form, userInvocable: e.target.checked }) }), t('skillUserInvocable')),
|
|
@@ -636,7 +763,7 @@ window.__ModuleLoader__.load({
|
|
|
636
763
|
setError(undefined);
|
|
637
764
|
if (file.size > 30 * 1024 * 1024) {
|
|
638
765
|
setFileName(''); setContent(''); setZipBase64('');
|
|
639
|
-
setError(t('importTooLarge'));
|
|
766
|
+
setError({ message: t('importTooLarge') });
|
|
640
767
|
return;
|
|
641
768
|
}
|
|
642
769
|
setFileName(file.name);
|
|
@@ -647,12 +774,12 @@ window.__ModuleLoader__.load({
|
|
|
647
774
|
let bin = '';
|
|
648
775
|
for (let i = 0; i < buf.length; i += 0x8000) bin += String.fromCharCode.apply(null, buf.subarray(i, i + 0x8000));
|
|
649
776
|
setZipBase64(btoa(bin));
|
|
650
|
-
} catch { setZipBase64(''); setError(t('importReadFailed')); }
|
|
777
|
+
} catch { setZipBase64(''); setError({ message: t('importReadFailed') }); }
|
|
651
778
|
} else {
|
|
652
779
|
setZipBase64('');
|
|
653
780
|
const reader = new FileReader();
|
|
654
781
|
reader.onload = () => setContent(String(reader.result ?? ''));
|
|
655
|
-
reader.onerror = () => setError(t('importReadFailed'));
|
|
782
|
+
reader.onerror = () => setError({ message: t('importReadFailed') });
|
|
656
783
|
reader.readAsText(file);
|
|
657
784
|
}
|
|
658
785
|
};
|
|
@@ -682,7 +809,7 @@ window.__ModuleLoader__.load({
|
|
|
682
809
|
await api('skills.import', params);
|
|
683
810
|
onImported();
|
|
684
811
|
} catch (err) {
|
|
685
|
-
setError(err.message);
|
|
812
|
+
setError({ message: err.message, fields: err.fields });
|
|
686
813
|
} finally {
|
|
687
814
|
setBusy(false);
|
|
688
815
|
}
|
|
@@ -713,7 +840,10 @@ window.__ModuleLoader__.load({
|
|
|
713
840
|
h('label', { style: { display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' } },
|
|
714
841
|
h('input', { type: 'checkbox', checked: overwrite, onChange: (e) => setOverwrite(e.target.checked) }),
|
|
715
842
|
t('importOverwrite')),
|
|
716
|
-
error && h('div', { className: 'mc-error' },
|
|
843
|
+
error && h('div', { className: 'mc-error' },
|
|
844
|
+
error.message,
|
|
845
|
+
(error.fields ?? []).length > 0 && h('ul', { style: { margin: '6px 0 0', paddingLeft: 18, textAlign: 'left' } },
|
|
846
|
+
error.fields.map((f, i) => h('li', { key: i }, (f.field ? f.field + ': ' : '') + (f.message ?? ''))))),
|
|
717
847
|
);
|
|
718
848
|
}
|
|
719
849
|
|
|
@@ -803,6 +933,8 @@ window.__ModuleLoader__.load({
|
|
|
803
933
|
});
|
|
804
934
|
const [busy, setBusy] = useState(false);
|
|
805
935
|
const [error, setError] = useState('');
|
|
936
|
+
// 掩码值('••••••')不可查看明文;点击眼睛图标仅提示重新填写
|
|
937
|
+
const [secretHint, setSecretHint] = useState('');
|
|
806
938
|
const set = (key) => (e) => setForm({ ...form, [key]: e.target.value });
|
|
807
939
|
const submit = async () => {
|
|
808
940
|
setBusy(true); setError('');
|
|
@@ -811,6 +943,14 @@ window.__ModuleLoader__.load({
|
|
|
811
943
|
onSaved();
|
|
812
944
|
} catch (err) { setError(err.message); } finally { setBusy(false); }
|
|
813
945
|
};
|
|
946
|
+
const secretField = (label, value, key, placeholder) => h(Field, { label },
|
|
947
|
+
h('textarea', { className: 'mc-textarea', style: { minHeight: 72 }, value, onChange: set(key), placeholder }),
|
|
948
|
+
h('div', { style: { display: 'flex', alignItems: 'center', gap: 6 } },
|
|
949
|
+
h(Button, {
|
|
950
|
+
className: 'link', kind: undefined, 'aria-label': label,
|
|
951
|
+
onClick: () => setSecretHint(secretHint === key ? '' : key),
|
|
952
|
+
}, '👁'),
|
|
953
|
+
secretHint === key && h('span', { className: 'mc-hint' }, t('connectorSecretHidden'))));
|
|
814
954
|
return h(Modal, {
|
|
815
955
|
title: isEdit ? t('connectorEdit') : t('connectorCreate'), onClose,
|
|
816
956
|
footer: [
|
|
@@ -829,10 +969,10 @@ window.__ModuleLoader__.load({
|
|
|
829
969
|
),
|
|
830
970
|
),
|
|
831
971
|
form.transport === 'streamable-http' && h(Field, { label: t('connectorUrl') }, h('input', { className: 'mc-input', value: form.url, onChange: set('url'), placeholder: 'https://example.com/mcp' })),
|
|
832
|
-
form.transport === 'streamable-http' &&
|
|
972
|
+
form.transport === 'streamable-http' && secretField(t('connectorHeaders'), form.headers, 'headers', 'Authorization=Bearer …'),
|
|
833
973
|
form.transport === 'stdio' && h(Field, { label: t('connectorCommand') }, h('input', { className: 'mc-input', value: form.command, onChange: set('command'), placeholder: 'npx' })),
|
|
834
974
|
form.transport === 'stdio' && h(Field, { label: t('connectorArgs') }, h('textarea', { className: 'mc-textarea', style: { minHeight: 72 }, value: form.args, onChange: set('args'), placeholder: '-y\n@modelcontextprotocol/server-github' })),
|
|
835
|
-
form.transport === 'stdio' &&
|
|
975
|
+
form.transport === 'stdio' && secretField(t('connectorEnv'), form.env, 'env', 'GITHUB_TOKEN=…'),
|
|
836
976
|
form.transport === 'stdio' && h(Field, { label: t('connectorCwd') }, h('input', { className: 'mc-input', value: form.cwd, onChange: set('cwd') })),
|
|
837
977
|
h(Field, { label: t('group') }, h('input', { className: 'mc-input', value: form.group, onChange: set('group'), placeholder: t('groupPlaceholder') })),
|
|
838
978
|
);
|
|
@@ -846,11 +986,33 @@ window.__ModuleLoader__.load({
|
|
|
846
986
|
const [busyDelete, setBusyDelete] = useState(false);
|
|
847
987
|
const [groupFilter, setGroupFilter] = useState('');
|
|
848
988
|
const [q, setQ] = useState('');
|
|
989
|
+
const [expanded, setExpanded] = useState({});
|
|
990
|
+
// 测试连接结果(易失,不持久):key → { status:'testing' } | { ok:true, latencyMs, tools } | { ok:false, error }
|
|
991
|
+
const [testResults, setTestResults] = useState({});
|
|
849
992
|
|
|
850
993
|
const toggle = async (connector, enabled) => {
|
|
851
994
|
try { await api('connectors.setEnabled', { key: connector.key, enabled }); reload(); }
|
|
852
995
|
catch (err) { toast(err.message, true); }
|
|
853
996
|
};
|
|
997
|
+
const test = async (connector) => {
|
|
998
|
+
const key = connector.key;
|
|
999
|
+
setTestResults((prev) => ({ ...prev, [key]: { status: 'testing' } }));
|
|
1000
|
+
try {
|
|
1001
|
+
const result = await api('connectors.test', { key });
|
|
1002
|
+
setTestResults((prev) => ({ ...prev, [key]: { ok: true, latencyMs: result.latencyMs, tools: result.tools ?? [] } }));
|
|
1003
|
+
} catch (err) {
|
|
1004
|
+
setTestResults((prev) => ({ ...prev, [key]: { ok: false, error: err.message } }));
|
|
1005
|
+
}
|
|
1006
|
+
};
|
|
1007
|
+
const setTools = async (connector, allowed, autoApprove) => {
|
|
1008
|
+
const known = testResults[connector.key]?.tools ?? [];
|
|
1009
|
+
const names = known.map((tool) => tool.name);
|
|
1010
|
+
const mode = allowed.length >= names.length && names.length > 0 ? 'all' : 'whitelist';
|
|
1011
|
+
try {
|
|
1012
|
+
await api('connectors.setTools', { key: connector.key, mode, tools: allowed, autoApprove });
|
|
1013
|
+
reload();
|
|
1014
|
+
} catch (err) { toast(err.message, true); }
|
|
1015
|
+
};
|
|
854
1016
|
const doDelete = async () => {
|
|
855
1017
|
setBusyDelete(true);
|
|
856
1018
|
try { await api('connectors.delete', { key: deleting.key }); toast(t('deleted')); setDeleting(undefined); reload(); }
|
|
@@ -863,6 +1025,8 @@ window.__ModuleLoader__.load({
|
|
|
863
1025
|
const connectors = allConnectors.filter((c) =>
|
|
864
1026
|
(!groupFilter || c.group === groupFilter) &&
|
|
865
1027
|
(!needle || (c.label + ' ' + c.serverName + ' ' + c.key + ' ' + (c.transport ?? '') + ' ' + (c.url || '') + ' ' + (c.command || '')).toLowerCase().includes(needle)));
|
|
1028
|
+
// 审批策略:Host 若暴露则读取(列表级或行级),缺省视为非 never
|
|
1029
|
+
const approvalNever = (state.data?.approvalPolicy ?? '') === 'never';
|
|
866
1030
|
return h('div', null,
|
|
867
1031
|
h('div', { className: 'mc-toolbar' },
|
|
868
1032
|
h('span', { className: 'mc-spacer' }),
|
|
@@ -877,26 +1041,80 @@ window.__ModuleLoader__.load({
|
|
|
877
1041
|
state.status === 'error' && h('div', { className: 'mc-error' }, t('loadFailed') + ': ' + state.error.message, h(Button, { onClick: reload }, t('retry'))),
|
|
878
1042
|
state.status !== 'error' && h('div', { className: 'mc-grid' },
|
|
879
1043
|
connectors.length === 0 && state.status === 'done' && h('div', { className: 'mc-empty' }, t('empty')),
|
|
880
|
-
connectors.map((connector) =>
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
h('div', { className: 'mc-card
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
1044
|
+
connectors.map((connector) => {
|
|
1045
|
+
const result = testResults[connector.key];
|
|
1046
|
+
const testing = result?.status === 'testing';
|
|
1047
|
+
const tools = result?.ok ? (result.tools ?? []) : [];
|
|
1048
|
+
const policy = connector.toolPolicy ?? { mode: 'all', tools: [], autoApprove: [] };
|
|
1049
|
+
const allowedSet = new Set(policy.mode === 'all' ? tools.map((tool) => tool.name) : (policy.tools ?? []));
|
|
1050
|
+
const autoSet = new Set(policy.autoApprove ?? []);
|
|
1051
|
+
const open = expanded[connector.key] === true;
|
|
1052
|
+
const saveTools = (nextAllowed, nextAuto) =>
|
|
1053
|
+
setTools(connector, tools.map((tool) => tool.name).filter((name) => nextAllowed.has(name)), tools.map((tool) => tool.name).filter((name) => nextAuto.has(name)));
|
|
1054
|
+
return h('div', { key: connector.key, className: 'mc-card' },
|
|
1055
|
+
h('div', { className: 'mc-card-title' }, connector.label || connector.serverName,
|
|
1056
|
+
connector.group && h(Badge, { tone: 'info' }, connector.group),
|
|
1057
|
+
h(Badge, { tone: 'brand' }, connector.transport),
|
|
1058
|
+
connector.external
|
|
1059
|
+
? h(Badge, null, t('connectorExternal'))
|
|
1060
|
+
: connector.live
|
|
1061
|
+
? h(Badge, { tone: connector.enabled === false ? undefined : 'success' }, connector.enabled === false ? t('disabled') : t('connectorLive'))
|
|
1062
|
+
: h(Badge, { tone: 'warn' }, t('connectorStopped')),
|
|
1063
|
+
),
|
|
1064
|
+
h('div', { className: 'mc-card-meta' }, 'mcp__' + connector.serverName + '__*'),
|
|
1065
|
+
h('div', { className: 'mc-card-desc' }, connector.transport === 'stdio'
|
|
1066
|
+
? [connector.command, ...(connector.args ?? [])].join(' ')
|
|
1067
|
+
: connector.url),
|
|
1068
|
+
!connector.external && h('div', { className: 'mc-card-actions' },
|
|
1069
|
+
h(Switch, { checked: connector.enabled !== false, onChange: (v) => toggle(connector, v), title: connector.enabled !== false ? t('disable') : t('enable') }),
|
|
1070
|
+
h(Button, { className: 'link', disabled: testing, onClick: () => test(connector) }, testing ? '…' : t('connectorTest')),
|
|
1071
|
+
h(Badge, {
|
|
1072
|
+
tone: result?.ok ? 'success' : result?.ok === false ? 'error' : undefined,
|
|
1073
|
+
title: result?.ok ? result.latencyMs + ' ms' : (result?.error ?? ''),
|
|
1074
|
+
}, testing
|
|
1075
|
+
? '…'
|
|
1076
|
+
: result?.ok
|
|
1077
|
+
? t('connectorConnected').replace('{n}', String(result.tools?.length ?? 0))
|
|
1078
|
+
: result?.ok === false
|
|
1079
|
+
? t('connectorTestFailed').replace('{reason}', (result.error || '').slice(0, 60))
|
|
1080
|
+
: t('connectorUntested')),
|
|
1081
|
+
h('span', { className: 'mc-spacer' }),
|
|
1082
|
+
h(Button, { className: 'link', onClick: () => setExpanded({ ...expanded, [connector.key]: !open }) }, (open ? '▾ ' : '▸ ') + t('connectorTools')),
|
|
1083
|
+
h(Button, { className: 'link', onClick: () => setEditing(connector) }, t('edit')),
|
|
1084
|
+
h(Button, { className: 'link danger', onClick: () => setDeleting(connector) }, t('delete')),
|
|
1085
|
+
),
|
|
1086
|
+
!connector.external && open && h('div', null,
|
|
1087
|
+
tools.length === 0 && h('div', { className: 'mc-row-sub', style: { marginBottom: 6 } }, t('connectorToolsEmpty')),
|
|
1088
|
+
tools.map((tool) => h('div', { key: tool.name, className: 'mc-row', style: { padding: '6px 10px', marginBottom: 6 } },
|
|
1089
|
+
h('div', { className: 'mc-row-main' },
|
|
1090
|
+
h('div', { className: 'mc-row-title' }, tool.name),
|
|
1091
|
+
tool.description && h('div', { className: 'mc-row-sub' }, tool.description),
|
|
1092
|
+
),
|
|
1093
|
+
h('label', { className: 'mc-checkbox', style: { fontSize: 12 } },
|
|
1094
|
+
h('input', {
|
|
1095
|
+
type: 'checkbox', checked: allowedSet.has(tool.name),
|
|
1096
|
+
onChange: (e) => {
|
|
1097
|
+
const nextAllowed = new Set(allowedSet);
|
|
1098
|
+
const nextAuto = new Set(autoSet);
|
|
1099
|
+
if (e.target.checked) nextAllowed.add(tool.name);
|
|
1100
|
+
else { nextAllowed.delete(tool.name); nextAuto.delete(tool.name); }
|
|
1101
|
+
saveTools(nextAllowed, nextAuto);
|
|
1102
|
+
},
|
|
1103
|
+
}), t('connectorToolAllowed')),
|
|
1104
|
+
h('label', { className: 'mc-checkbox', style: { fontSize: 12 } },
|
|
1105
|
+
h('input', {
|
|
1106
|
+
type: 'checkbox', checked: autoSet.has(tool.name), disabled: !allowedSet.has(tool.name),
|
|
1107
|
+
onChange: (e) => {
|
|
1108
|
+
const nextAuto = new Set(autoSet);
|
|
1109
|
+
if (e.target.checked) nextAuto.add(tool.name); else nextAuto.delete(tool.name);
|
|
1110
|
+
saveTools(allowedSet, nextAuto);
|
|
1111
|
+
},
|
|
1112
|
+
}), t('connectorToolAuto')),
|
|
1113
|
+
)),
|
|
1114
|
+
approvalNever && h('div', { className: 'mc-hint' }, t('connectorApprovalNever')),
|
|
1115
|
+
),
|
|
1116
|
+
);
|
|
1117
|
+
}),
|
|
900
1118
|
),
|
|
901
1119
|
editing && h(ConnectorEditModal, { t, connector: editing.key ? editing : undefined, onClose: () => setEditing(undefined), onSaved: () => { setEditing(undefined); toast(t('saved')); reload(); } }),
|
|
902
1120
|
deleting && h(ConfirmModal, { t, title: t('confirmDelete'), message: t('confirmDeleteBody').replace('{name}', deleting.label || deleting.serverName), busy: busyDelete, onClose: () => setDeleting(undefined), onConfirm: doDelete }),
|
|
@@ -957,14 +1175,29 @@ window.__ModuleLoader__.load({
|
|
|
957
1175
|
// undefined(旧数据)= 全部技能;编辑时显式化为全选列表
|
|
958
1176
|
skills: expert?.skills ?? (expert ? allSkills.map((s) => s.name) : []),
|
|
959
1177
|
connectors: expert?.connectors ?? [],
|
|
1178
|
+
// 模型绑定:provider+model 均填写才提交;否则缺省不传(跟随默认)
|
|
1179
|
+
modelProvider: expert?.model?.provider ?? '',
|
|
1180
|
+
modelModel: expert?.model?.model ?? '',
|
|
1181
|
+
modelReasoning: expert?.model?.reasoningEffort ?? '',
|
|
1182
|
+
// 工具组白名单:空数组/缺省 = 全部可用
|
|
1183
|
+
tools: expert?.tools ?? [],
|
|
960
1184
|
});
|
|
961
1185
|
const [busy, setBusy] = useState(false);
|
|
962
1186
|
const [error, setError] = useState('');
|
|
1187
|
+
const modelError = (form.modelProvider && !form.modelModel) || (!form.modelProvider && form.modelModel)
|
|
1188
|
+
? t('expertModelPartial') : '';
|
|
963
1189
|
const set = (key) => (e) => setForm({ ...form, [key]: e.target.value });
|
|
964
1190
|
const submit = async () => {
|
|
1191
|
+
if (modelError) return;
|
|
965
1192
|
setBusy(true); setError('');
|
|
966
1193
|
try {
|
|
967
|
-
|
|
1194
|
+
const { modelProvider, modelModel, modelReasoning, ...rest } = form;
|
|
1195
|
+
const payload = { ...rest };
|
|
1196
|
+
if (modelProvider && modelModel) {
|
|
1197
|
+
payload.model = { provider: modelProvider, model: modelModel };
|
|
1198
|
+
if (modelReasoning) payload.model.reasoningEffort = modelReasoning;
|
|
1199
|
+
}
|
|
1200
|
+
await api(isEdit ? 'experts.update' : 'experts.create', payload);
|
|
968
1201
|
onSaved();
|
|
969
1202
|
} catch (err) { setError(err.message); } finally { setBusy(false); }
|
|
970
1203
|
};
|
|
@@ -981,6 +1214,25 @@ window.__ModuleLoader__.load({
|
|
|
981
1214
|
h(Field, { label: t('expertDescription') }, h('input', { className: 'mc-input', value: form.description, onChange: set('description') })),
|
|
982
1215
|
h(Field, { label: t('group') }, h('input', { className: 'mc-input', value: form.group, onChange: set('group'), placeholder: t('groupPlaceholder') })),
|
|
983
1216
|
h(Field, { label: t('expertPrefix') }, h('textarea', { className: 'mc-textarea', value: form.prefix, onChange: set('prefix'), placeholder: 'You are …' })),
|
|
1217
|
+
h(Field, { label: t('expertModel') },
|
|
1218
|
+
h('div', { style: { display: 'flex', gap: 6, flexWrap: 'wrap' } },
|
|
1219
|
+
h('input', { className: 'mc-input', style: { flex: 1, minWidth: 140 }, placeholder: t('expertModelProvider'), value: form.modelProvider, onChange: set('modelProvider') }),
|
|
1220
|
+
h('input', { className: 'mc-input', style: { flex: 1, minWidth: 140 }, placeholder: t('expertModelName'), value: form.modelModel, onChange: set('modelModel') }),
|
|
1221
|
+
h('select', { className: 'mc-select', value: form.modelReasoning, onChange: set('modelReasoning'), title: 'reasoningEffort' },
|
|
1222
|
+
h('option', { value: '' }, t('expertModelDefault')),
|
|
1223
|
+
['low', 'medium', 'high'].map((v) => h('option', { key: v, value: v }, v)))),
|
|
1224
|
+
h('span', { className: 'mc-hint' }, t('expertModelHint')),
|
|
1225
|
+
modelError && h('span', { className: 'mc-field-error' }, modelError)),
|
|
1226
|
+
h(Field, { label: t('expertTools') },
|
|
1227
|
+
h('div', { style: { display: 'flex', gap: 12, flexWrap: 'wrap' } },
|
|
1228
|
+
[['fs', t('expertToolFs')], ['web', t('expertToolWeb')], ['shell', t('expertToolShell')], ['skills', t('expertToolSkills')]].map(([value, labelText]) =>
|
|
1229
|
+
h('label', { key: value, className: 'mc-checkbox', style: { fontSize: 12 } },
|
|
1230
|
+
h('input', {
|
|
1231
|
+
type: 'checkbox', checked: form.tools.includes(value),
|
|
1232
|
+
onChange: (e) => setForm({ ...form, tools: e.target.checked ? [...form.tools, value] : form.tools.filter((v) => v !== value) }),
|
|
1233
|
+
}),
|
|
1234
|
+
labelText))),
|
|
1235
|
+
h('span', { className: 'mc-hint' }, t('expertToolsHint'))),
|
|
984
1236
|
h(LinkPicker, {
|
|
985
1237
|
t, title: t('expertSkills'), items: allSkills,
|
|
986
1238
|
selected: form.skills.filter((n) => allSkills.some((s) => s.name === n)),
|
|
@@ -1040,9 +1292,14 @@ window.__ModuleLoader__.load({
|
|
|
1040
1292
|
expert.custom ? h(Badge, { tone: 'brand' }, t('expertCustom')) : h(Badge, null, t('expertBuiltin')),
|
|
1041
1293
|
expert.isDefault && h(Badge, { tone: 'success' }, t('expertDefault')),
|
|
1042
1294
|
expert.broken && h(Badge, { tone: 'error' }, t('expertBroken')),
|
|
1295
|
+
expert.model && h(Badge, { tone: 'brand' }, expert.model.provider + '/' + expert.model.model),
|
|
1043
1296
|
),
|
|
1044
1297
|
h('div', { className: 'mc-card-desc' }, expert.description || '—'),
|
|
1045
1298
|
h('div', { className: 'mc-card-meta' }, expert.id + (expert.broken ? ' · ' + expert.broken : '')),
|
|
1299
|
+
expert.custom && (expert.tools ?? []).length > 0 && h('div', { className: 'mc-card-meta' },
|
|
1300
|
+
t('expertToolsSummary') + ': ' + expert.tools
|
|
1301
|
+
.map((v) => ({ fs: t('expertToolFs'), web: t('expertToolWeb'), shell: t('expertToolShell'), skills: t('expertToolSkills') }[v] ?? v))
|
|
1302
|
+
.join(', ')),
|
|
1046
1303
|
expert.custom && (expert.skills !== undefined || expert.connectors !== undefined) && h('div', { className: 'mc-card-meta' },
|
|
1047
1304
|
t('expertLinkedMeta')
|
|
1048
1305
|
.replace('{skills}', expert.skills === undefined ? t('expertSkillsAll') : expert.skills.length)
|
package/index.js
CHANGED
|
@@ -14,6 +14,9 @@ import { promises as fs } from 'node:fs';
|
|
|
14
14
|
import os from 'node:os';
|
|
15
15
|
import { join, resolve, sep, basename } from 'node:path';
|
|
16
16
|
import { inflateRawSync } from 'node:zlib';
|
|
17
|
+
import { spawn } from 'node:child_process';
|
|
18
|
+
import http from 'node:http';
|
|
19
|
+
import https from 'node:https';
|
|
17
20
|
|
|
18
21
|
const DSH_HOME = process.env.DSH_HOME || join(os.homedir(), '.dsh');
|
|
19
22
|
const DATA_DIR = join(DSH_HOME, 'manager-center');
|
|
@@ -24,6 +27,8 @@ const KEBAB = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
|
24
27
|
const SERVER_NAME = /^[A-Za-z0-9_-]{1,32}$/;
|
|
25
28
|
const MCP_PACKAGE = '@deepseek-ai/dsh-mcp-client';
|
|
26
29
|
const CONNECTOR_PREFIX = 'mcm-';
|
|
30
|
+
const MASKED_VALUE = '••••••'; // FR-C3:脱敏掩码(前后端约定字面量)
|
|
31
|
+
const CONNECTOR_TEST_TIMEOUT_MS = 8000; // FR-C1:testConnector 单项总超时
|
|
27
32
|
|
|
28
33
|
/* ───────────────────────── 内置技能市场 ───────────────────────── */
|
|
29
34
|
|
|
@@ -282,9 +287,23 @@ function parseSkillFile(text) {
|
|
|
282
287
|
const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(source);
|
|
283
288
|
if (!m) return { fields: {}, body: source };
|
|
284
289
|
const fields = {};
|
|
285
|
-
|
|
290
|
+
const lines = m[1].split(/\r?\n/);
|
|
291
|
+
for (let i = 0; i < lines.length; i++) {
|
|
292
|
+
const line = lines[i];
|
|
286
293
|
const kv = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
|
|
287
|
-
if (kv)
|
|
294
|
+
if (!kv) continue;
|
|
295
|
+
// metadata 为嵌套键值块:收集其后缩进的子行
|
|
296
|
+
if (kv[1] === 'metadata' && kv[2] === '') {
|
|
297
|
+
const meta = {};
|
|
298
|
+
while (i + 1 < lines.length && /^\s+\S/.test(lines[i + 1])) {
|
|
299
|
+
i++;
|
|
300
|
+
const mk = /^\s+([A-Za-z0-9_-]+):\s*(.*)$/.exec(lines[i]);
|
|
301
|
+
if (mk) meta[mk[1]] = parseYamlScalar(mk[2]);
|
|
302
|
+
}
|
|
303
|
+
fields.metadata = meta;
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
fields[kv[1]] = parseYamlScalar(kv[2]);
|
|
288
307
|
}
|
|
289
308
|
return { fields, body: m[2] };
|
|
290
309
|
}
|
|
@@ -294,12 +313,65 @@ function buildSkillFile(input) {
|
|
|
294
313
|
lines.push(`name: ${yamlScalar(input.name)}`);
|
|
295
314
|
lines.push(`description: ${yamlScalar(input.description)}`);
|
|
296
315
|
if (input.whenToUse) lines.push(`whenToUse: ${yamlScalar(input.whenToUse)}`);
|
|
316
|
+
// 以下四个字段遵循 agentskills.io 规范,有值才写(旧技能缺省字段读为空、不回写)
|
|
317
|
+
if (input.license) lines.push(`license: ${yamlScalar(input.license)}`);
|
|
318
|
+
if (input.compatibility) lines.push(`compatibility: ${yamlScalar(input.compatibility)}`);
|
|
319
|
+
if (input.metadata && typeof input.metadata === 'object' && !Array.isArray(input.metadata)) {
|
|
320
|
+
const keys = Object.keys(input.metadata);
|
|
321
|
+
if (keys.length) {
|
|
322
|
+
lines.push('metadata:');
|
|
323
|
+
for (const key of keys) lines.push(` ${key}: ${yamlScalar(input.metadata[key])}`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (input.allowedTools) lines.push(`allowed-tools: ${yamlScalar(input.allowedTools)}`);
|
|
297
327
|
if (input.disableModelInvocation === true) lines.push('disable-model-invocation: true');
|
|
298
328
|
if (input.userInvocable === false) lines.push('user-invocable: false');
|
|
299
329
|
lines.push('---', '');
|
|
300
330
|
return lines.join('\n') + String(input.content ?? '').replace(/^\s+/, '');
|
|
301
331
|
}
|
|
302
332
|
|
|
333
|
+
/* ───────────────────────── 技能规范校验(FR-S2) ───────────────────────── */
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* 按 agentskills.io 规范校验技能字段。不抛异常,返回 { errors: [{ field, message }] }。
|
|
337
|
+
* field 取值:name / description / compatibility / license / metadata。
|
|
338
|
+
*/
|
|
339
|
+
function validateSkillSpec(p) {
|
|
340
|
+
const errors = [];
|
|
341
|
+
const name = String(p?.name ?? '');
|
|
342
|
+
if (!KEBAB.test(name)) errors.push({ field: 'name', message: '技能名须为小写 kebab-case(如 my-skill)' });
|
|
343
|
+
else if (name.length > 64) errors.push({ field: 'name', message: '技能名长度不能超过 64 个字符' });
|
|
344
|
+
const description = String(p?.description ?? '');
|
|
345
|
+
if (!description) errors.push({ field: 'description', message: 'description 必填' });
|
|
346
|
+
else if (description.length > 1024) errors.push({ field: 'description', message: 'description 长度不能超过 1024 个字符' });
|
|
347
|
+
const compatibility = p?.compatibility == null ? '' : String(p.compatibility);
|
|
348
|
+
if (compatibility.length > 500) errors.push({ field: 'compatibility', message: 'compatibility 长度不能超过 500 个字符' });
|
|
349
|
+
const license = p?.license == null ? '' : String(p.license);
|
|
350
|
+
if (license.length > 128) errors.push({ field: 'license', message: 'license 长度不能超过 128 个字符' });
|
|
351
|
+
if (p?.metadata != null) {
|
|
352
|
+
if (typeof p.metadata !== 'object' || Array.isArray(p.metadata)) {
|
|
353
|
+
errors.push({ field: 'metadata', message: 'metadata 须为键值对对象(map)' });
|
|
354
|
+
} else {
|
|
355
|
+
for (const [key, value] of Object.entries(p.metadata)) {
|
|
356
|
+
if (typeof value !== 'string') {
|
|
357
|
+
errors.push({ field: 'metadata', message: `metadata 键 "${key}" 的值须为字符串` });
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return { errors };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** 校验不过则抛出汇总错误(逐字段列出,供前端定位展示)。error.errors 供路由层透传字段级错误。 */
|
|
366
|
+
function assertSkillSpec(p) {
|
|
367
|
+
const { errors } = validateSkillSpec(p);
|
|
368
|
+
if (errors.length) {
|
|
369
|
+
const error = new Error('技能校验失败:\n' + errors.map((e) => `- ${e.field}:${e.message}`).join('\n'));
|
|
370
|
+
error.errors = errors;
|
|
371
|
+
throw error;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
303
375
|
function underSkillRoot(path) {
|
|
304
376
|
if (typeof path !== 'string' || path === '') return false;
|
|
305
377
|
const rel = resolve(path).toLowerCase();
|
|
@@ -516,6 +588,264 @@ async function removeConnectorEntry(ctx, key) {
|
|
|
516
588
|
if (loaderHas(ctx, id)) await ctx.loader.remove(id);
|
|
517
589
|
}
|
|
518
590
|
|
|
591
|
+
/* ──────────────── FR-C1/C2/C3:脱敏、状态缓存、工具探测、审批策略 ──────────────── */
|
|
592
|
+
|
|
593
|
+
/** 易失状态缓存:testConnector 结果(含失败原因),不持久化,重启即失。 */
|
|
594
|
+
const connectorStatus = new Map();
|
|
595
|
+
|
|
596
|
+
/** 脱敏输出:env/headers 的值一律替换为掩码,并附被掩码的键名列表。结构其余部分不变。 */
|
|
597
|
+
function maskConnectorSecrets(record) {
|
|
598
|
+
const out = { ...record };
|
|
599
|
+
const maskedKeys = [];
|
|
600
|
+
for (const field of ['env', 'headers']) {
|
|
601
|
+
if (out[field] && typeof out[field] === 'object') {
|
|
602
|
+
out[field] = Object.fromEntries(Object.entries(out[field]).map(([k, v]) => {
|
|
603
|
+
maskedKeys.push(k);
|
|
604
|
+
return [k, MASKED_VALUE];
|
|
605
|
+
}));
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
out.maskedKeys = maskedKeys;
|
|
609
|
+
return out;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** 入参脱敏回填:env/headers 中值为掩码的键表示「保持原值」,从旧记录回填明文,不得落盘掩码。 */
|
|
613
|
+
function unmaskConnectorSecrets(input, old) {
|
|
614
|
+
if (!input || typeof input !== 'object') return input;
|
|
615
|
+
const out = { ...input };
|
|
616
|
+
for (const field of ['env', 'headers']) {
|
|
617
|
+
const values = out[field];
|
|
618
|
+
if (!values || typeof values !== 'object') continue;
|
|
619
|
+
const merged = { ...values };
|
|
620
|
+
for (const k of Object.keys(merged)) {
|
|
621
|
+
if (merged[k] === MASKED_VALUE && old?.[field] && old[field][k] !== undefined) {
|
|
622
|
+
merged[k] = old[field][k];
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
out[field] = merged;
|
|
626
|
+
}
|
|
627
|
+
return out;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** 统一 tools/list 结果整形:仅保留 name/description,名称为原始名(不含 mcp__ 前缀)。 */
|
|
631
|
+
function normalizeToolList(list) {
|
|
632
|
+
return (Array.isArray(list) ? list : []).map((t) => ({
|
|
633
|
+
name: String(t?.name ?? ''),
|
|
634
|
+
description: String(t?.description ?? ''),
|
|
635
|
+
})).filter((t) => t.name);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** streamable-http 单次 JSON-RPC POST;兼容 text/event-stream 响应。 */
|
|
639
|
+
function httpJsonRpcPost(urlString, headers, payload, timeoutMs) {
|
|
640
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
641
|
+
let target;
|
|
642
|
+
try { target = new URL(urlString); } catch { rejectPromise(new Error('url 不是合法地址')); return; }
|
|
643
|
+
const lib = target.protocol === 'https:' ? https : http;
|
|
644
|
+
const body = JSON.stringify(payload);
|
|
645
|
+
const req = lib.request(target, {
|
|
646
|
+
method: 'POST',
|
|
647
|
+
headers: {
|
|
648
|
+
'content-type': 'application/json',
|
|
649
|
+
accept: 'application/json, text/event-stream',
|
|
650
|
+
...(headers && typeof headers === 'object' ? headers : {}),
|
|
651
|
+
'content-length': Buffer.byteLength(body),
|
|
652
|
+
},
|
|
653
|
+
timeout: timeoutMs,
|
|
654
|
+
}, (res) => {
|
|
655
|
+
const chunks = [];
|
|
656
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
657
|
+
res.on('end', () => {
|
|
658
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
659
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
660
|
+
rejectPromise(new Error(`HTTP ${res.statusCode}: ${text.slice(0, 200)}`));
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
try {
|
|
664
|
+
if (String(res.headers['content-type'] ?? '').includes('text/event-stream')) {
|
|
665
|
+
const dataLines = text.split(/\r?\n/).filter((l) => l.startsWith('data:')).map((l) => l.slice(5).trim());
|
|
666
|
+
resolvePromise(JSON.parse(dataLines[dataLines.length - 1] ?? ''));
|
|
667
|
+
} else {
|
|
668
|
+
resolvePromise(JSON.parse(text));
|
|
669
|
+
}
|
|
670
|
+
} catch {
|
|
671
|
+
rejectPromise(new Error('响应不是合法 JSON'));
|
|
672
|
+
}
|
|
673
|
+
});
|
|
674
|
+
});
|
|
675
|
+
req.on('timeout', () => req.destroy(new Error('请求超时')));
|
|
676
|
+
req.on('error', (err) => rejectPromise(err instanceof Error ? err : new Error(String(err))));
|
|
677
|
+
req.write(body);
|
|
678
|
+
req.end();
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function jsonRpcErrorMessage(msg) {
|
|
683
|
+
return msg?.error?.message ? String(msg.error.message) : 'JSON-RPC 返回错误';
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** streamable-http 探测:initialize → notifications/initialized → tools/list。 */
|
|
687
|
+
async function probeHttpConnector(record, timeoutMs) {
|
|
688
|
+
const initMsg = {
|
|
689
|
+
jsonrpc: '2.0', id: 1, method: 'initialize',
|
|
690
|
+
params: {
|
|
691
|
+
protocolVersion: '2025-06-18',
|
|
692
|
+
capabilities: {},
|
|
693
|
+
clientInfo: { name: 'dsh-manager-center', version: '1.8.0' },
|
|
694
|
+
},
|
|
695
|
+
};
|
|
696
|
+
const init = await httpJsonRpcPost(record.url, record.headers, initMsg, timeoutMs);
|
|
697
|
+
if (init?.error) throw new Error(jsonRpcErrorMessage(init));
|
|
698
|
+
try {
|
|
699
|
+
await httpJsonRpcPost(record.url, record.headers, { jsonrpc: '2.0', method: 'notifications/initialized' }, timeoutMs);
|
|
700
|
+
} catch { /* initialized 通知失败不阻断(部分服务端不要求) */ }
|
|
701
|
+
const list = await httpJsonRpcPost(record.url, record.headers, { jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} }, timeoutMs);
|
|
702
|
+
if (list?.error) throw new Error(jsonRpcErrorMessage(list));
|
|
703
|
+
return normalizeToolList(list?.result?.tools);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* stdio 传输层探测:对给定子进程跑 initialize → notifications/initialized → tools/list。
|
|
708
|
+
* 读 stdout 按行解析 JSON-RPC 响应;超时/进程退出即失败;结束务必 kill 防泄漏。
|
|
709
|
+
*/
|
|
710
|
+
function probeStdioTransport(child, timeoutMs) {
|
|
711
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
712
|
+
const started = Date.now();
|
|
713
|
+
let settled = false;
|
|
714
|
+
let stdoutBuf = '';
|
|
715
|
+
let forceKillTimer;
|
|
716
|
+
const cleanup = () => {
|
|
717
|
+
clearTimeout(forceKillTimer);
|
|
718
|
+
try { child.stdin.end(); } catch { /* 进程可能已退出 */ }
|
|
719
|
+
child.kill();
|
|
720
|
+
// Windows 上 SIGTERM 可能杀不掉:宽限 500ms 后强制结束,防泄漏
|
|
721
|
+
forceKillTimer = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* ignore */ } }, 500);
|
|
722
|
+
forceKillTimer.unref?.();
|
|
723
|
+
};
|
|
724
|
+
const finish = (err, tools) => {
|
|
725
|
+
if (settled) return;
|
|
726
|
+
settled = true;
|
|
727
|
+
clearTimeout(timer);
|
|
728
|
+
cleanup();
|
|
729
|
+
if (err) rejectPromise(err);
|
|
730
|
+
else resolvePromise({ latencyMs: Date.now() - started, tools });
|
|
731
|
+
};
|
|
732
|
+
const timer = setTimeout(() => finish(new Error('连接超时')), timeoutMs);
|
|
733
|
+
child.on('error', (err) => finish(new Error(`无法启动进程: ${err?.message ?? err}`)));
|
|
734
|
+
child.on('exit', (code, signal) => {
|
|
735
|
+
finish(new Error(`进程已退出(code=${code ?? 'null'}${signal ? `, signal=${signal}` : ''}),未返回工具清单`));
|
|
736
|
+
});
|
|
737
|
+
child.stderr.resume(); // 丢弃 stderr,防管道缓冲顶死
|
|
738
|
+
child.stdout.on('data', (chunk) => {
|
|
739
|
+
stdoutBuf += chunk.toString('utf8');
|
|
740
|
+
let idx;
|
|
741
|
+
while ((idx = stdoutBuf.indexOf('\n')) >= 0) {
|
|
742
|
+
const line = stdoutBuf.slice(0, idx).trim();
|
|
743
|
+
stdoutBuf = stdoutBuf.slice(idx + 1);
|
|
744
|
+
if (!line) continue;
|
|
745
|
+
let msg;
|
|
746
|
+
try { msg = JSON.parse(line); } catch { continue; } // 忽略非 JSON 日志行
|
|
747
|
+
if (msg?.id !== 2 || settled) continue;
|
|
748
|
+
if (msg.error) finish(new Error(jsonRpcErrorMessage(msg)));
|
|
749
|
+
else finish(null, normalizeToolList(msg?.result?.tools));
|
|
750
|
+
}
|
|
751
|
+
});
|
|
752
|
+
const send = (msg) => {
|
|
753
|
+
try { child.stdin.write(JSON.stringify(msg) + '\n'); } catch { /* 进程可能已退出,由 exit 事件收尾 */ }
|
|
754
|
+
};
|
|
755
|
+
send({
|
|
756
|
+
jsonrpc: '2.0', id: 1, method: 'initialize',
|
|
757
|
+
params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'dsh-manager-center', version: '1.8.0' } },
|
|
758
|
+
});
|
|
759
|
+
send({ jsonrpc: '2.0', method: 'notifications/initialized' });
|
|
760
|
+
send({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/** stdio 连接器探测:spawn 子进程(env 合并 process.env),再走统一传输层。绝不同步抛出。 */
|
|
765
|
+
function probeStdioConnector(record, timeoutMs) {
|
|
766
|
+
let child;
|
|
767
|
+
try {
|
|
768
|
+
child = spawn(record.command, Array.isArray(record.args) ? record.args : [], {
|
|
769
|
+
env: { ...process.env, ...(record.env && typeof record.env === 'object' ? record.env : {}) },
|
|
770
|
+
...(record.cwd ? { cwd: record.cwd } : {}),
|
|
771
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
772
|
+
});
|
|
773
|
+
} catch (err) {
|
|
774
|
+
return Promise.reject(new Error(`无法启动进程: ${err?.message ?? err}`));
|
|
775
|
+
}
|
|
776
|
+
return probeStdioTransport(child, timeoutMs);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/* ── FR-C2 审批 answerer:serverName → 工具策略(mode/tools/autoApprove),连接器增删改/启停时刷新 ── */
|
|
780
|
+
|
|
781
|
+
let approvalPolicy = new Map();
|
|
782
|
+
|
|
783
|
+
function refreshApprovalPolicy() {
|
|
784
|
+
const next = new Map();
|
|
785
|
+
for (const c of store.connectors) {
|
|
786
|
+
next.set(c.serverName, {
|
|
787
|
+
enabled: c.enabled !== false,
|
|
788
|
+
mode: c.toolPolicy?.mode === 'whitelist' ? 'whitelist' : 'all',
|
|
789
|
+
tools: Array.isArray(c.toolPolicy?.tools) ? c.toolPolicy.tools.map(String) : [],
|
|
790
|
+
autoApprove: Array.isArray(c.toolPolicy?.autoApprove) ? c.toolPolicy.autoApprove.map(String) : [],
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
approvalPolicy = next;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/** 工具名匹配 mcp__<serverName>__<tool>:返回命中策略与原始工具名。 */
|
|
797
|
+
function matchConnectorTool(toolName) {
|
|
798
|
+
for (const [serverName, policy] of approvalPolicy) {
|
|
799
|
+
const prefix = `mcp__${serverName}__`;
|
|
800
|
+
if (toolName.startsWith(prefix)) return { policy, tool: toolName.slice(prefix.length) };
|
|
801
|
+
}
|
|
802
|
+
return undefined;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* approval/request 瀑布监听(形态抄内核 dsh-acp:ctx.on('approval/request', (request, next) => ...))。
|
|
807
|
+
* 仅裁决本插件创建的连接器工具;其余请求 next() 透传给后续 answerer(如人工审批面板)。
|
|
808
|
+
* outcome 词表(allowed-once/rejected/cancelled/unavailable)无原因通道,
|
|
809
|
+
* 白名单拒绝时把中文原因写入 request.reason,随 approval/asked 审计事件留痕。
|
|
810
|
+
*/
|
|
811
|
+
function managerApprovalAnswerer(request, next) {
|
|
812
|
+
try {
|
|
813
|
+
const toolName = String(request?.toolName ?? '');
|
|
814
|
+
const hit = matchConnectorTool(toolName);
|
|
815
|
+
if (!hit || !hit.policy.enabled) return next();
|
|
816
|
+
const { policy, tool } = hit;
|
|
817
|
+
if (policy.autoApprove.includes(tool)) return 'allowed-once';
|
|
818
|
+
if (policy.mode === 'whitelist' && !policy.tools.includes(tool)) {
|
|
819
|
+
try {
|
|
820
|
+
request.reason = `连接器工具白名单已拦截:「${toolName}」不在允许列表(当前为白名单模式,仅放行 ${policy.tools.length} 个工具)`;
|
|
821
|
+
} catch { /* request 只读时忽略,不影响拒绝语义 */ }
|
|
822
|
+
return 'rejected';
|
|
823
|
+
}
|
|
824
|
+
return next(); // 其余情况交人工审批
|
|
825
|
+
} catch {
|
|
826
|
+
return next(); // answerer 自身故障不得阻断审批链
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* 读取部署级审批策略默认值(dsh-user-approval 的 config.policy,ask|never)。
|
|
832
|
+
* client 据 listConnectors 根部的 approvalPolicy 显示「自动执行不生效」提示;
|
|
833
|
+
* 会话级 override 不影响此提示(提示针对部署默认口径)。
|
|
834
|
+
*/
|
|
835
|
+
function approvalPolicyDefault(ctx) {
|
|
836
|
+
try {
|
|
837
|
+
const configEditor = ctx.get('configEditor');
|
|
838
|
+
if (configEditor) {
|
|
839
|
+
for (const row of configEditor.configuration()) {
|
|
840
|
+
if (row?.entry?.name !== '@deepseek-ai/dsh-user-approval') continue;
|
|
841
|
+
const config = { ...(row?.inherited ?? {}), ...(row?.override ?? {}) };
|
|
842
|
+
if (config.policy === 'ask' || config.policy === 'never') return config.policy;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
} catch { /* configEditor 不可用时按 ask 处理 */ }
|
|
846
|
+
return 'ask';
|
|
847
|
+
}
|
|
848
|
+
|
|
519
849
|
/* ───────────────────────── 智能体(专家)预设 ───────────────────────── */
|
|
520
850
|
|
|
521
851
|
const EXPERT_SKILL_ROOT = join(DATA_DIR, 'expert-skills');
|
|
@@ -533,6 +863,35 @@ function normalizeExpertConnectors(value) {
|
|
|
533
863
|
return [...new Set(value.map((v) => String(v)).filter((k) => valid.has(k)))];
|
|
534
864
|
}
|
|
535
865
|
|
|
866
|
+
/* FR-A1:模型绑定落地 = 存储 + 展示(预设组合内挂 dsh-agent-default-model 不生效,入口点 selectionFor() 读自身 scope)。 */
|
|
867
|
+
/** 规范化模型绑定:provider/model 为非空字符串才生效;否则 undefined(跟随默认)。 */
|
|
868
|
+
function normalizeExpertModel(value) {
|
|
869
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
|
|
870
|
+
const provider = String(value.provider ?? '').trim();
|
|
871
|
+
const model = String(value.model ?? '').trim();
|
|
872
|
+
if (!provider || !model) return undefined;
|
|
873
|
+
const out = { provider, model };
|
|
874
|
+
const reasoningEffort = String(value.reasoningEffort ?? '').trim();
|
|
875
|
+
if (reasoningEffort) out.reasoningEffort = reasoningEffort;
|
|
876
|
+
return out;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/* FR-A2:工具组白名单(组粒度)。 */
|
|
880
|
+
const EXPERT_TOOL_GROUPS = ['fs', 'web', 'shell', 'skills'];
|
|
881
|
+
/** 工具组 → expertPlugins 行 id 映射;persona/compaction 为基础设施,恒保留。 */
|
|
882
|
+
const EXPERT_TOOL_GROUP_ROWS = {
|
|
883
|
+
fs: new Set(['tool-fs', 'tool-fs-search']),
|
|
884
|
+
web: new Set(['tool-web']),
|
|
885
|
+
shell: new Set(['persistent-shell']),
|
|
886
|
+
skills: new Set(['skill-fs', 'tool-skill']),
|
|
887
|
+
};
|
|
888
|
+
|
|
889
|
+
/** 规范化工具组:仅保留合法组名去重;非数组 → undefined(全部可用)。空数组语义 = 全部。 */
|
|
890
|
+
function normalizeExpertTools(value) {
|
|
891
|
+
if (!Array.isArray(value)) return undefined;
|
|
892
|
+
return [...new Set(value.map((v) => String(v)).filter((g) => EXPERT_TOOL_GROUPS.includes(g)))];
|
|
893
|
+
}
|
|
894
|
+
|
|
536
895
|
/** 为智能体重建专属技能链接目录:仅包含所选技能(junction/符号链接,失败退化为复制)。 */
|
|
537
896
|
async function rebuildExpertSkillLinks(expert) {
|
|
538
897
|
const dir = join(EXPERT_SKILL_ROOT, expert.id);
|
|
@@ -631,6 +990,17 @@ function expertPlugins(expert, skillScope) {
|
|
|
631
990
|
{ id: 'tool-skill', name: '@deepseek-ai/dsh-tool-skill' },
|
|
632
991
|
);
|
|
633
992
|
}
|
|
993
|
+
// FR-A2 工具组白名单:未选中的组对应行不输出;persona/compaction 恒保留;空数组/缺省 = 保持现状全部
|
|
994
|
+
const groups = Array.isArray(expert.tools) ? expert.tools.filter((g) => EXPERT_TOOL_GROUPS.includes(g)) : [];
|
|
995
|
+
if (groups.length > 0) {
|
|
996
|
+
const allowed = new Set(groups.flatMap((g) => [...EXPERT_TOOL_GROUP_ROWS[g]]));
|
|
997
|
+
return plugins.filter((plugin) => {
|
|
998
|
+
for (const rows of Object.values(EXPERT_TOOL_GROUP_ROWS)) {
|
|
999
|
+
if (rows.has(plugin.id)) return allowed.has(plugin.id);
|
|
1000
|
+
}
|
|
1001
|
+
return true; // 基础设施行(persona/compaction)不受过滤
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
634
1004
|
return plugins;
|
|
635
1005
|
}
|
|
636
1006
|
|
|
@@ -668,6 +1038,7 @@ function createApi(ctx, diagnostics) {
|
|
|
668
1038
|
}
|
|
669
1039
|
|
|
670
1040
|
async function provisionAll() {
|
|
1041
|
+
refreshApprovalPolicy(); // 启动恢复持久化记录后重建审批策略表
|
|
671
1042
|
for (const record of store.connectors) {
|
|
672
1043
|
try { await provisionConnector(ctx, record); }
|
|
673
1044
|
catch (error) { diagnostics.push(`连接器 ${record.serverName}: ${error?.message ?? error}`); }
|
|
@@ -727,6 +1098,11 @@ function createApi(ctx, diagnostics) {
|
|
|
727
1098
|
return { skills: [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)), skillRoot: SKILL_ROOT };
|
|
728
1099
|
},
|
|
729
1100
|
|
|
1101
|
+
/* FR-S2:规范校验(不抛异常,供前端提交前预检;field 定位供 UI 逐字段展示) */
|
|
1102
|
+
async validateSkillSpec(p) {
|
|
1103
|
+
return validateSkillSpec(p);
|
|
1104
|
+
},
|
|
1105
|
+
|
|
730
1106
|
async getSkill(p) {
|
|
731
1107
|
const name = String(p?.name ?? '');
|
|
732
1108
|
if (!KEBAB.test(name)) throw new Error('技能名须为小写 kebab-case');
|
|
@@ -737,6 +1113,10 @@ function createApi(ctx, diagnostics) {
|
|
|
737
1113
|
name,
|
|
738
1114
|
description: typeof fields.description === 'string' ? fields.description : '',
|
|
739
1115
|
whenToUse: typeof fields.whenToUse === 'string' ? fields.whenToUse : '',
|
|
1116
|
+
license: typeof fields.license === 'string' ? fields.license : '',
|
|
1117
|
+
compatibility: typeof fields.compatibility === 'string' ? fields.compatibility : '',
|
|
1118
|
+
metadata: fields.metadata && typeof fields.metadata === 'object' && !Array.isArray(fields.metadata) ? fields.metadata : {},
|
|
1119
|
+
allowedTools: typeof fields['allowed-tools'] === 'string' ? fields['allowed-tools'] : '',
|
|
740
1120
|
userInvocable: fields['user-invocable'] !== false,
|
|
741
1121
|
disableModelInvocation: fields['disable-model-invocation'] === true,
|
|
742
1122
|
content: body.replace(/^\r?\n/, ''),
|
|
@@ -747,8 +1127,7 @@ function createApi(ctx, diagnostics) {
|
|
|
747
1127
|
|
|
748
1128
|
async createSkill(p) {
|
|
749
1129
|
const name = String(p?.name ?? '');
|
|
750
|
-
|
|
751
|
-
if (!p?.description) throw new Error('description 必填');
|
|
1130
|
+
assertSkillSpec({ ...p, name });
|
|
752
1131
|
if (await skillFileFor(name)) throw new Error(`技能 "${name}" 已存在`);
|
|
753
1132
|
const dir = join(SKILL_ROOT, name);
|
|
754
1133
|
await fs.mkdir(dir, { recursive: true });
|
|
@@ -760,8 +1139,7 @@ function createApi(ctx, diagnostics) {
|
|
|
760
1139
|
|
|
761
1140
|
async updateSkill(p) {
|
|
762
1141
|
const name = String(p?.name ?? '');
|
|
763
|
-
|
|
764
|
-
if (!p?.description) throw new Error('description 必填');
|
|
1142
|
+
assertSkillSpec({ ...p, name });
|
|
765
1143
|
const file = await skillFileFor(name);
|
|
766
1144
|
if (!file) throw new Error(`技能 "${name}" 不存在或不可编辑`);
|
|
767
1145
|
await fs.writeFile(file, buildSkillFile({ ...p, name }));
|
|
@@ -809,7 +1187,19 @@ function createApi(ctx, diagnostics) {
|
|
|
809
1187
|
const ensureAbsent = async (target) => {
|
|
810
1188
|
if (!overwrite && await skillFileFor(target)) throw new Error(`技能 "${target}" 已存在(勾选"覆盖同名技能"可替换)`);
|
|
811
1189
|
};
|
|
1190
|
+
/** 安装路径统一复用规范校验:以 frontmatter 解析字段 + 最终技能名校验,失败抛逐字段汇总错误。 */
|
|
1191
|
+
const assertImportedSkill = (text, target) => {
|
|
1192
|
+
const { fields } = parseSkillFile(text);
|
|
1193
|
+
assertSkillSpec({
|
|
1194
|
+
name: target,
|
|
1195
|
+
description: typeof fields.description === 'string' ? fields.description : '',
|
|
1196
|
+
compatibility: fields.compatibility,
|
|
1197
|
+
license: fields.license,
|
|
1198
|
+
metadata: fields.metadata,
|
|
1199
|
+
});
|
|
1200
|
+
};
|
|
812
1201
|
const writeContent = async (target, text) => {
|
|
1202
|
+
assertImportedSkill(text, target);
|
|
813
1203
|
await ensureAbsent(target);
|
|
814
1204
|
const dir = join(SKILL_ROOT, target);
|
|
815
1205
|
await fs.mkdir(dir, { recursive: true });
|
|
@@ -826,6 +1216,7 @@ function createApi(ctx, diagnostics) {
|
|
|
826
1216
|
const skillMd = files.find((f) => f.rel.toLowerCase() === (root.prefix + 'skill.md').toLowerCase());
|
|
827
1217
|
const text = skillMd.data.toString('utf8');
|
|
828
1218
|
const target = resolveName(text, root.prefix ? root.prefix.slice(0, -1) : fallbackName);
|
|
1219
|
+
assertImportedSkill(text, target);
|
|
829
1220
|
await ensureAbsent(target);
|
|
830
1221
|
const dir = join(SKILL_ROOT, target);
|
|
831
1222
|
if (overwrite) await fs.rm(dir, { recursive: true, force: true }); // 覆盖时清空重建,避免残留旧文件
|
|
@@ -852,7 +1243,9 @@ function createApi(ctx, diagnostics) {
|
|
|
852
1243
|
if (st.isDirectory()) {
|
|
853
1244
|
const f = join(src, 'SKILL.md');
|
|
854
1245
|
if (!await fs.access(f).then(() => true, () => false)) throw new Error('目录中缺少 SKILL.md');
|
|
855
|
-
const
|
|
1246
|
+
const text = await fs.readFile(f, 'utf8');
|
|
1247
|
+
const target = resolveName(text, basename(src));
|
|
1248
|
+
assertImportedSkill(text, target);
|
|
856
1249
|
await ensureAbsent(target);
|
|
857
1250
|
await fs.cp(src, join(SKILL_ROOT, target), { recursive: true });
|
|
858
1251
|
if (typeof p?.group === 'string') await applyGroup('skills', target, p.group);
|
|
@@ -957,7 +1350,8 @@ function createApi(ctx, diagnostics) {
|
|
|
957
1350
|
/* ---- 连接器 ---- */
|
|
958
1351
|
async listConnectors() {
|
|
959
1352
|
const ours = store.connectors.map((record) => ({
|
|
960
|
-
...record,
|
|
1353
|
+
...maskConnectorSecrets(record),
|
|
1354
|
+
status: connectorStatus.get(record.key), // FR-C1:易失测试状态(未测试为 undefined)
|
|
961
1355
|
group: groupOf('connectors', record.key),
|
|
962
1356
|
entryId: connectorEntryId(record),
|
|
963
1357
|
live: loaderHas(ctx, connectorEntryId(record)),
|
|
@@ -984,7 +1378,7 @@ function createApi(ctx, diagnostics) {
|
|
|
984
1378
|
}
|
|
985
1379
|
} catch { /* configEditor 不可用时忽略外部条目 */ }
|
|
986
1380
|
}
|
|
987
|
-
return { connectors: [...ours, ...external] };
|
|
1381
|
+
return { connectors: [...ours, ...external], approvalPolicy: approvalPolicyDefault(ctx) };
|
|
988
1382
|
},
|
|
989
1383
|
|
|
990
1384
|
async createConnector(p) {
|
|
@@ -997,22 +1391,25 @@ function createApi(ctx, diagnostics) {
|
|
|
997
1391
|
if (typeof p?.group === 'string') store.groups.connectors[record.key] = String(p.group).trim() || undefined;
|
|
998
1392
|
if (store.groups.connectors[record.key] === undefined) delete store.groups.connectors[record.key];
|
|
999
1393
|
await save();
|
|
1000
|
-
|
|
1394
|
+
refreshApprovalPolicy();
|
|
1395
|
+
return { connector: { ...maskConnectorSecrets(record), entryId: connectorEntryId(record), group: groupOf('connectors', record.key) } };
|
|
1001
1396
|
},
|
|
1002
1397
|
|
|
1003
1398
|
async updateConnector(p) {
|
|
1004
1399
|
const key = String(p?.key ?? '');
|
|
1005
1400
|
const index = store.connectors.findIndex((c) => c.key === key);
|
|
1006
1401
|
if (index < 0) throw new Error('连接器不存在');
|
|
1007
|
-
|
|
1402
|
+
// FR-C3:掩码值表示「保持原值」,先从旧记录回填明文再规范化
|
|
1403
|
+
const record = normalizeConnector({ ...store.connectors[index], ...unmaskConnectorSecrets(p, store.connectors[index]), key });
|
|
1008
1404
|
if (store.connectors.some((c) => c.key !== key && c.serverName === record.serverName)) {
|
|
1009
1405
|
throw new Error(`serverName "${record.serverName}" 已被占用`);
|
|
1010
1406
|
}
|
|
1011
1407
|
await provisionConnector(ctx, record);
|
|
1012
1408
|
store.connectors[index] = record;
|
|
1409
|
+
refreshApprovalPolicy();
|
|
1013
1410
|
if (typeof p?.group === 'string') await applyGroup('connectors', key, p.group);
|
|
1014
1411
|
else await save();
|
|
1015
|
-
return { connector: { ...record, group: groupOf('connectors', key) } };
|
|
1412
|
+
return { connector: { ...maskConnectorSecrets(record), group: groupOf('connectors', key) } };
|
|
1016
1413
|
},
|
|
1017
1414
|
|
|
1018
1415
|
async deleteConnector(p) {
|
|
@@ -1022,7 +1419,9 @@ function createApi(ctx, diagnostics) {
|
|
|
1022
1419
|
await removeConnectorEntry(ctx, key);
|
|
1023
1420
|
store.connectors.splice(index, 1);
|
|
1024
1421
|
clearGroup('connectors', key);
|
|
1422
|
+
connectorStatus.delete(key);
|
|
1025
1423
|
await save();
|
|
1424
|
+
refreshApprovalPolicy();
|
|
1026
1425
|
return { key };
|
|
1027
1426
|
},
|
|
1028
1427
|
|
|
@@ -1033,7 +1432,41 @@ function createApi(ctx, diagnostics) {
|
|
|
1033
1432
|
record.enabled = p?.enabled !== false;
|
|
1034
1433
|
await provisionConnector(ctx, record);
|
|
1035
1434
|
await save();
|
|
1036
|
-
|
|
1435
|
+
refreshApprovalPolicy();
|
|
1436
|
+
return { connector: maskConnectorSecrets(record) };
|
|
1437
|
+
},
|
|
1438
|
+
|
|
1439
|
+
/* FR-C1:测试连接(streamable-http / stdio),结果只进易失缓存,不持久化。 */
|
|
1440
|
+
async testConnector(p) {
|
|
1441
|
+
const key = String(p?.key ?? '');
|
|
1442
|
+
const record = store.connectors.find((c) => c.key === key);
|
|
1443
|
+
if (!record) throw new Error('连接器不存在');
|
|
1444
|
+
const started = Date.now();
|
|
1445
|
+
let result;
|
|
1446
|
+
try {
|
|
1447
|
+
const probed = record.transport === 'stdio'
|
|
1448
|
+
? await probeStdioConnector(record, CONNECTOR_TEST_TIMEOUT_MS)
|
|
1449
|
+
: { latencyMs: Date.now() - started, tools: await probeHttpConnector(record, CONNECTOR_TEST_TIMEOUT_MS) };
|
|
1450
|
+
result = { ok: true, latencyMs: probed.latencyMs, tools: probed.tools };
|
|
1451
|
+
} catch (error) {
|
|
1452
|
+
result = { ok: false, latencyMs: Date.now() - started, tools: [], error: error?.message ?? String(error) };
|
|
1453
|
+
}
|
|
1454
|
+
connectorStatus.set(key, { ...result, testedAt: Date.now() });
|
|
1455
|
+
return result;
|
|
1456
|
+
},
|
|
1457
|
+
|
|
1458
|
+
/* FR-C2:持久化工具策略(白名单/自动执行);旧记录无 toolPolicy 字段 = 默认 all。 */
|
|
1459
|
+
async setConnectorTools(p) {
|
|
1460
|
+
const key = String(p?.key ?? '');
|
|
1461
|
+
const record = store.connectors.find((c) => c.key === key);
|
|
1462
|
+
if (!record) throw new Error('连接器不存在');
|
|
1463
|
+
const mode = p?.mode === 'whitelist' ? 'whitelist' : 'all';
|
|
1464
|
+
const tools = Array.isArray(p?.tools) ? [...new Set(p.tools.map(String))] : [];
|
|
1465
|
+
const autoApprove = Array.isArray(p?.autoApprove) ? [...new Set(p.autoApprove.map(String))] : [];
|
|
1466
|
+
record.toolPolicy = { mode, tools, autoApprove };
|
|
1467
|
+
await save();
|
|
1468
|
+
refreshApprovalPolicy();
|
|
1469
|
+
return { connector: { ...maskConnectorSecrets(record), group: groupOf('connectors', key) } };
|
|
1037
1470
|
},
|
|
1038
1471
|
|
|
1039
1472
|
/* ---- 智能体(专家) ---- */
|
|
@@ -1053,11 +1486,13 @@ function createApi(ctx, diagnostics) {
|
|
|
1053
1486
|
group: groupOf('experts', preset.id),
|
|
1054
1487
|
skills: custom?.skills,
|
|
1055
1488
|
connectors: custom?.connectors,
|
|
1489
|
+
model: custom?.model, // FR-A1:旧记录无 = 跟随默认
|
|
1490
|
+
tools: custom?.tools,
|
|
1056
1491
|
};
|
|
1057
1492
|
});
|
|
1058
1493
|
for (const expert of store.experts) {
|
|
1059
1494
|
if (!rows.some((r) => r.id === expert.id)) {
|
|
1060
|
-
rows.push({ id: expert.id, name: expert.name, description: expert.description, isDefault: false, custom: true, broken: '注册失败或未注册', group: groupOf('experts', expert.id), skills: expert.skills, connectors: expert.connectors });
|
|
1495
|
+
rows.push({ id: expert.id, name: expert.name, description: expert.description, isDefault: false, custom: true, broken: '注册失败或未注册', group: groupOf('experts', expert.id), skills: expert.skills, connectors: expert.connectors, model: expert.model, tools: expert.tools });
|
|
1061
1496
|
}
|
|
1062
1497
|
}
|
|
1063
1498
|
return { experts: rows };
|
|
@@ -1072,6 +1507,8 @@ function createApi(ctx, diagnostics) {
|
|
|
1072
1507
|
if (!prefix) throw new Error('系统提示词必填');
|
|
1073
1508
|
const skills = normalizeExpertSkills(p?.skills);
|
|
1074
1509
|
const connectors = normalizeExpertConnectors(p?.connectors);
|
|
1510
|
+
const model = normalizeExpertModel(p?.model);
|
|
1511
|
+
const tools = normalizeExpertTools(p?.tools);
|
|
1075
1512
|
const agentPresets = ctx.get('agentPresets');
|
|
1076
1513
|
if (agentPresets) {
|
|
1077
1514
|
const roster = await agentPresets.list().catch(() => []);
|
|
@@ -1081,6 +1518,8 @@ function createApi(ctx, diagnostics) {
|
|
|
1081
1518
|
const expert = { id, name, description: String(p?.description ?? ''), prefix, createdAt: Date.now() };
|
|
1082
1519
|
if (skills !== undefined) expert.skills = skills;
|
|
1083
1520
|
if (connectors !== undefined) expert.connectors = connectors;
|
|
1521
|
+
if (model !== undefined) expert.model = model;
|
|
1522
|
+
if (tools !== undefined) expert.tools = tools;
|
|
1084
1523
|
await registerExpert(expert);
|
|
1085
1524
|
store.experts.push(expert);
|
|
1086
1525
|
if (typeof p?.group === 'string' && String(p.group).trim()) store.groups.experts[id] = String(p.group).trim();
|
|
@@ -1103,6 +1542,17 @@ function createApi(ctx, diagnostics) {
|
|
|
1103
1542
|
};
|
|
1104
1543
|
if (skills !== undefined) expert.skills = skills;
|
|
1105
1544
|
if (connectors !== undefined) expert.connectors = connectors;
|
|
1545
|
+
// FR-A1/A2:入参显式携带 model/tools 键时更新;model 传 null 或 tools 传非数组 = 清除(跟随默认/全部)
|
|
1546
|
+
if (p && 'model' in p) {
|
|
1547
|
+
const model = normalizeExpertModel(p.model);
|
|
1548
|
+
if (model) expert.model = model;
|
|
1549
|
+
else delete expert.model;
|
|
1550
|
+
}
|
|
1551
|
+
if (p && 'tools' in p) {
|
|
1552
|
+
const tools = normalizeExpertTools(p.tools);
|
|
1553
|
+
if (tools) expert.tools = tools; // 空数组也落盘,语义 = 全部可用
|
|
1554
|
+
else delete expert.tools;
|
|
1555
|
+
}
|
|
1106
1556
|
await registerExpert(expert);
|
|
1107
1557
|
store.experts[index] = expert;
|
|
1108
1558
|
if (typeof p?.group === 'string') await applyGroup('experts', id, p.group);
|
|
@@ -1285,6 +1735,7 @@ const API_METHODS = new Map([
|
|
|
1285
1735
|
['skills.get', (api, p) => api.getSkill(p)],
|
|
1286
1736
|
['skills.create', (api, p) => api.createSkill(p)],
|
|
1287
1737
|
['skills.update', (api, p) => api.updateSkill(p)],
|
|
1738
|
+
['skills.validate', (api, p) => api.validateSkillSpec(p)],
|
|
1288
1739
|
['skills.delete', (api, p) => api.deleteSkill(p)],
|
|
1289
1740
|
['skills.import', (api, p) => api.importSkill(p)],
|
|
1290
1741
|
['markets.list', (api) => api.listMarkets()],
|
|
@@ -1297,6 +1748,8 @@ const API_METHODS = new Map([
|
|
|
1297
1748
|
['connectors.update', (api, p) => api.updateConnector(p)],
|
|
1298
1749
|
['connectors.delete', (api, p) => api.deleteConnector(p)],
|
|
1299
1750
|
['connectors.setEnabled', (api, p) => api.setConnectorEnabled(p)],
|
|
1751
|
+
['connectors.test', (api, p) => api.testConnector(p)],
|
|
1752
|
+
['connectors.setTools', (api, p) => api.setConnectorTools(p)],
|
|
1300
1753
|
['experts.list', (api) => api.listExperts()],
|
|
1301
1754
|
['experts.create', (api, p) => api.createExpert(p)],
|
|
1302
1755
|
['experts.update', (api, p) => api.updateExpert(p)],
|
|
@@ -1338,7 +1791,9 @@ function mountRoutes(wctx, api, trustedHosts) {
|
|
|
1338
1791
|
await ensureLoaded();
|
|
1339
1792
|
writeJson(res, 200, { ok: true, ...(await dispatch(api, body?.params ?? {})) }, cors);
|
|
1340
1793
|
} catch (error) {
|
|
1341
|
-
|
|
1794
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1795
|
+
// FR-S2:校验类错误附带字段级 errors,供 client 逐字段红字展示(api() 失败信封透传)
|
|
1796
|
+
writeJson(res, 400, { ok: false, message, ...(Array.isArray(error?.errors) ? { errors: error.errors } : {}) }, cors);
|
|
1342
1797
|
}
|
|
1343
1798
|
},
|
|
1344
1799
|
});
|
|
@@ -1362,6 +1817,9 @@ export function apply(ctx) {
|
|
|
1362
1817
|
|
|
1363
1818
|
ctx.effect(() => () => { void api.disposeAll(); }, 'manager-center: dispose experts');
|
|
1364
1819
|
|
|
1820
|
+
// FR-C2:审批 answerer——本插件连接器的工具自动执行/白名单拦截,其余请求透传给后续 answerer。
|
|
1821
|
+
ctx.effect(() => ctx.on('approval/request', managerApprovalAnswerer), 'manager-center: approval answerer');
|
|
1822
|
+
|
|
1365
1823
|
// Web 路由:优先 webStartup,兼容旧版 webRuntime;非 web 部署不挂载。
|
|
1366
1824
|
// 路由注册挂在「本插件 ctx 的 effect」上:插件卸载/重载时旧路由一定随之下线,
|
|
1367
1825
|
// 避免在 webStartup ctx 上注册导致重复激活后路由泄漏、多个 handler 争抢同一请求。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-my-config",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "My Config (我的配置): manage skills (with external skill markets and zip import), MCP connectors and agent experts from dedicated Web sidebar panels.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|