dsh-my-config 1.7.1 → 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 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
@@ -1,6 +1,6 @@
1
1
  /**
2
- * 管理中心 —— Web 客户端半区。
3
- * 注册侧边栏面板「管理中心」与 main 面板,包含技能 / 连接器 / 智能体 / 插件四个管理页。
2
+ * 我的配置 —— Web 客户端半区。
3
+ * 注册侧边栏面板「我的配置」与 main 面板,包含技能 / 连接器 / 智能体三个管理页。
4
4
  * Host 操作走同源 POST /manager-center/api;插件管理走 ctx.get('remote.pluginManager')。
5
5
  */
6
6
  window.__ModuleLoader__.load({
@@ -16,8 +16,8 @@ window.__ModuleLoader__.load({
16
16
  /* ───────────────────────── 文案 ───────────────────────── */
17
17
 
18
18
  const zh = {
19
- panel: '管理中心',
20
- title: '管理中心',
19
+ panel: '我的配置',
20
+ title: '我的配置',
21
21
  intro: '集中管理技能、连接器、智能体与插件',
22
22
  tabSkills: '技能',
23
23
  tabConnectors: '连接器',
@@ -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 地址 / 本地目录',
@@ -157,8 +192,8 @@ window.__ModuleLoader__.load({
157
192
  };
158
193
 
159
194
  const en = {
160
- panel: 'Manager',
161
- title: 'Manager Center',
195
+ panel: 'My Config',
196
+ title: 'My Config',
162
197
  intro: 'Manage skills, connectors, agents and plugins in one place',
163
198
  tabSkills: 'Skills',
164
199
  tabConnectors: 'Connectors',
@@ -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) throw new Error(data.message || `HTTP ${res.status}`);
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 set = (key) => (e) => setForm({ ...form, [key]: e.target.value });
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
- setBusy(true); setError('');
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
- await api(isEdit ? 'skills.update' : 'skills.create', form);
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
- error && h('div', { className: 'mc-diag' }, error),
531
- h(Field, { label: t('skillName') }, h('input', { className: 'mc-input', value: form.name, disabled: isEdit, onChange: set('name'), placeholder: 'my-skill', autoFocus: !isEdit })),
532
- h(Field, { label: t('skillDescription') }, h('input', { className: 'mc-input', value: form.description, onChange: set('description') })),
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' }, 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' && h(Field, { label: t('connectorHeaders') }, h('textarea', { className: 'mc-textarea', style: { minHeight: 72 }, value: form.headers, onChange: set('headers'), placeholder: 'Authorization=Bearer …' })),
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' && h(Field, { label: t('connectorEnv') }, h('textarea', { className: 'mc-textarea', style: { minHeight: 72 }, value: form.env, onChange: set('env'), placeholder: 'GITHUB_TOKEN=…' })),
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) => h('div', { key: connector.key, className: 'mc-card' },
881
- h('div', { className: 'mc-card-title' }, connector.label || connector.serverName,
882
- connector.group && h(Badge, { tone: 'info' }, connector.group),
883
- h(Badge, { tone: 'brand' }, connector.transport),
884
- connector.external
885
- ? h(Badge, null, t('connectorExternal'))
886
- : connector.live
887
- ? h(Badge, { tone: connector.enabled === false ? undefined : 'success' }, connector.enabled === false ? t('disabled') : t('connectorLive'))
888
- : h(Badge, { tone: 'warn' }, t('connectorStopped')),
889
- ),
890
- h('div', { className: 'mc-card-meta' }, 'mcp__' + connector.serverName + '__*'),
891
- h('div', { className: 'mc-card-desc' }, connector.transport === 'stdio'
892
- ? [connector.command, ...(connector.args ?? [])].join(' ')
893
- : connector.url),
894
- !connector.external && h('div', { className: 'mc-card-actions' },
895
- h(Switch, { checked: connector.enabled !== false, onChange: (v) => toggle(connector, v), title: connector.enabled !== false ? t('disable') : t('enable') }),
896
- h(Button, { className: 'link', onClick: () => setEditing(connector) }, t('edit')),
897
- h(Button, { className: 'link danger', onClick: () => setDeleting(connector) }, t('delete')),
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
- await api(isEdit ? 'experts.update' : 'experts.create', form);
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)