@omdp/dsh-connector 0.1.2 → 0.2.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 +26 -6
- package/client.js +411 -38
- package/index.js +299 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -2,12 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## Overview
|
|
4
4
|
|
|
5
|
-
把 **MCP
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
把 **MCP 服务器**、**用户 Skills** 的管理和 **魔搭(ModelScope)市场浏览**
|
|
6
|
+
合并到 DSH Web UI 的同一个设置页(设置页标签:**Connector**)。适合需要在
|
|
7
|
+
DSH 里频繁增删改 MCP server / skills、又不想手改 `cordis.patch.yml` 的用户。
|
|
8
8
|
|
|
9
9
|
- **MCP**:读取/编辑 `profiles/web/cordis.patch.yml` 中的 `mcp-*` 块(结构化表单)。保存后**重启 `dsh` 生效**。
|
|
10
10
|
- **Skills**:列出/查看/编辑/删除 `~/.dsh/skills` 下的 `SKILL.md`。保存**即时生效**(filesystem provider 自动重新发现)。
|
|
11
|
+
- **市场探索(0.2.0 新增)**:只读浏览魔搭社区 [Skills 中心](https://modelscope.cn/skills) 与 [MCP 广场](https://modelscope.cn/mcp)(匿名 OpenAPI,无需密钥)。
|
|
12
|
+
- 列表/详情:名称、作者、分类、下载/浏览数、认证标识(Hosted 官方托管 / 已认证)
|
|
13
|
+
- **一键复制** skill 安装命令(`npx / curl / modelscope` 三种)与 MCP 配置片段(`server_config` 的 `mcpServers` JSON)
|
|
14
|
+
- **Skill 更新提示**:把本地 skill 关联市场条目(写入 frontmatter 的 `source`/`sourceUpdated`)后,「检查更新」比对市场 `file_last_modified` 标出"有更新/最新"
|
|
15
|
+
- **零落盘**:市场数据只存在 DSH 进程内存(30 分钟 TTL 缓存),重启即清,从不写文件
|
|
16
|
+
- MCP 为部署模式、无版本概念,不提供更新提示(仅浏览与复制配置)
|
|
11
17
|
|
|
12
18
|
设计上复用官方两款参考插件的方式:
|
|
13
19
|
- 设置页槽位注册方式参照 [`dsh-mcp-manager`](https://github.com/hyqhyq3/dsh-mcp-manager)(`settings.section` + Package 私有 HTTP API)。
|
|
@@ -133,6 +139,13 @@ pnpm remove @omdp/dsh-connector
|
|
|
133
139
|
2. **Skills** 区:
|
|
134
140
|
- 列出 `~/.dsh/skills` 下的用户技能
|
|
135
141
|
- 「编辑」改 frontmatter 与正文;「删除」移除目录;「+ 新建」创建
|
|
142
|
+
- 「检查更新」:对已关联市场来源的 skill 比对魔搭更新时间,显示"有更新/最新"徽标
|
|
143
|
+
3. **市场探索** 区:
|
|
144
|
+
- MCP 市场:输入关键词搜索魔搭 MCP 广场;条目显示作者/浏览数与"已配置"徽标;展开详情看
|
|
145
|
+
Hosted / 认证 / 环境变量 / 配置变体,**「复制配置」** 一键复制 `mcpServers` JSON 片段
|
|
146
|
+
- Skills 市场:搜索魔搭技能中心;展开详情看三条安装命令(逐个**复制**,自行执行),
|
|
147
|
+
「记录来源」把本地技能关联到该市场条目(写 frontmatter `source`/`sourceUpdated`)
|
|
148
|
+
- 全部数据经 host 30 分钟内存缓存代理,仅本机内存,不写盘
|
|
136
149
|
|
|
137
150
|
## 工作原理
|
|
138
151
|
|
|
@@ -149,6 +162,9 @@ pnpm remove @omdp/dsh-connector
|
|
|
149
162
|
- 保存时按 `dsh-mcp-client` 的契约**校验**:`transport` 只能是 `stdio`/`streamable-http`;`serverName` 必须匹配 `[A-Za-z0-9_-]{1,32}`;stdio 的 `command` 必须是单个词且能在 PATH 中找到(或为绝对路径);streamable-http 的 `url` 必须是合法 http(s)(`!!js` 表达式除外);命令/URL/参数中不允许控制字符。任何一项不合法,保存会被拒绝(HTTP 400)并提示原因,**不会写入** `cordis.patch.yml`——坏配置永远到不了下次启动。
|
|
150
163
|
- MCP 块解析为结构化提取,复杂嵌套 YAML(如多 env 变量)在表单里以单字段呈现;极复杂配置请直接在 `cordis.patch.yml` 编辑。
|
|
151
164
|
- 不桥接 MCP 的 resources/prompts,只管理 server 配置。
|
|
165
|
+
- **市场只读**:不做 MCP 部署、不做 skill 安装(MCP 部署/部分 skill 安装不是简单改配置)。安装命令/配置片段请复制后自行执行。
|
|
166
|
+
- **版本提示仅限 skill**:魔搭 MCP 是部署模式、无版本概念,不提供更新提示;skill 的"有更新"以市场 `file_last_modified` 对比本地 `sourceUpdated` 判定(需先「记录来源」)。
|
|
167
|
+
- 市场依赖 `modelscope.cn` 可达性;不可达时接口返回 502 提示,不影响 MCP/skill 本地管理。
|
|
152
168
|
|
|
153
169
|
## Troubleshooting
|
|
154
170
|
|
|
@@ -202,9 +218,10 @@ MIT License。安全问题请通过 GitHub Issues 私密报告(https://github.
|
|
|
202
218
|
| 数据 | 访问方式 | 说明 |
|
|
203
219
|
|---|---|---|
|
|
204
220
|
| `profiles/web/cordis.patch.yml` | **读写** | MCP 块的结构化编辑(保留 `!!js`/env 原样) |
|
|
205
|
-
| `~/.dsh/skills/**/SKILL.md` | **读写** |
|
|
221
|
+
| `~/.dsh/skills/**/SKILL.md` | **读写** | 用户技能文件的查看/编辑/删除/新建;「记录来源」会写 `source`/`sourceUpdated` frontmatter |
|
|
206
222
|
| `~/.dsh/settings.yaml` 等 | 只读 | 不主动读写 |
|
|
207
|
-
| HTTP `/connector/api/*` | 本机监听 | 与 DSH GUI
|
|
223
|
+
| HTTP `/connector/api/*` | 本机监听 | 与 DSH GUI 同源,无额外鉴权 |
|
|
224
|
+
| 魔搭 `modelscope.cn/openapi/v1` | **只读外部** | 市场浏览代理(匿名);结果仅存进程内存(30 分钟 TTL),**不写文件、不落盘** |
|
|
208
225
|
| 环境变量 | 只读引用 | 只读 `process.env.*`,不持久化 |
|
|
209
226
|
|
|
210
227
|
**不收集**:无遥测、无外部上报、无用户数据离开本机。
|
|
@@ -222,5 +239,8 @@ MIT License。安全问题请通过 GitHub Issues 私密报告(https://github.
|
|
|
222
239
|
| DSH 小更新/补丁 | ✅ 不会崩 |
|
|
223
240
|
| DSH 大版本(`webServer` API 变化) | ✅ DSH 不崩;connector 需适配更新 |
|
|
224
241
|
| yaml 版本 | ✅ 独立 npm 包,不受 DSH 更新影响 |
|
|
242
|
+
| 魔搭市场不可达 | ✅ 市场接口报 502,本地 MCP/skill 管理不受影响 |
|
|
225
243
|
|
|
226
|
-
**最后验证**:DSH `0.1.0-rc.8`(2026-08-20
|
|
244
|
+
**最后验证**:DSH `0.1.0-rc.8`(2026-08-20);0.2.0 市场功能以 `node --check` +
|
|
245
|
+
真实 HTTP 集成测试通过(11 项:skills/mcp 列表与详情、证书/Hosted 标识、安装命令、
|
|
246
|
+
记录来源回写、更新判定),未改动 DSH 实例。
|
package/client.js
CHANGED
|
@@ -4,14 +4,21 @@
|
|
|
4
4
|
* Installed-package client bundle: the harness serves this file at
|
|
5
5
|
* /plugins/<id>/client.js and expects a single window.__ModuleLoader__.load
|
|
6
6
|
* handoff whose factory receives `require`. We register one unified settings
|
|
7
|
-
* tab ("Connector")
|
|
7
|
+
* tab ("Connector") with three panes:
|
|
8
8
|
* - MCP servers: read/edit the mcp-* block of cordis.patch.yml
|
|
9
9
|
* - user skills: list/view/edit/remove SKILL.md files under ~/.dsh/skills
|
|
10
|
+
* - marketplace: read-only ModelScope Skills Center + MCP Plaza browser
|
|
11
|
+
* (30-min in-process cache on the host, nothing on disk)
|
|
10
12
|
*
|
|
11
13
|
* It talks to the host half over the Package-private HTTP API the host mounts
|
|
12
14
|
* on the DSH GUI webserver (/connector/api/*), exactly like
|
|
13
15
|
* dsh-mcp-manager's client does with fetch — not the dynamic-only host.call.
|
|
14
16
|
*
|
|
17
|
+
* Visual language follows the DSH theme tokens (--dsw-alias-*): surfaces use
|
|
18
|
+
* layer-1/2 backgrounds, borders use border-l1/l2, accents use brand-primary,
|
|
19
|
+
* and badges reuse the state success/warn colors so the panel blends into the
|
|
20
|
+
* GUI in both light and dark themes.
|
|
21
|
+
*
|
|
15
22
|
* @module @omdp/dsh-connector/client
|
|
16
23
|
*/
|
|
17
24
|
|
|
@@ -25,29 +32,59 @@ window.__ModuleLoader__.load({
|
|
|
25
32
|
var createElement = react.createElement
|
|
26
33
|
|
|
27
34
|
var css =
|
|
28
|
-
|
|
29
|
-
'.
|
|
35
|
+
/* layout */
|
|
36
|
+
'.pm_root{display:flex;flex-direction:column;gap:18px;padding:0 24px 28px}' +
|
|
37
|
+
'.pm_tabs{display:flex;gap:4px;padding:2px 24px 0;border-bottom:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.22));margin:0 -24px 4px}' +
|
|
38
|
+
'.pm_tab{cursor:pointer;font:inherit;font-size:13px;padding:8px 14px 10px;border:none;background:transparent;color:var(--dsw-alias-label-secondary,#9aa);border-bottom:2px solid transparent;margin-bottom:-1px}' +
|
|
39
|
+
'.pm_tab:hover{color:var(--dsw-alias-label-primary,#ddd)}' +
|
|
40
|
+
'.pm_tab.active{color:var(--dsw-alias-label-primary,#f4f4f4);border-bottom-color:var(--dsw-alias-brand-primary,#5b8cff);font-weight:600}' +
|
|
30
41
|
'.pm_section{display:flex;flex-direction:column;gap:12px}' +
|
|
31
|
-
'.pm_sectionHead{font-weight:600;
|
|
32
|
-
'.
|
|
33
|
-
|
|
34
|
-
'.
|
|
35
|
-
'.
|
|
36
|
-
'.
|
|
37
|
-
'.
|
|
38
|
-
'.
|
|
39
|
-
'.
|
|
40
|
-
|
|
41
|
-
'.
|
|
42
|
-
'.
|
|
43
|
-
'.
|
|
42
|
+
'.pm_sectionHead{font-size:13px;font-weight:600;letter-spacing:.02em;color:var(--dsw-alias-label-primary,#eee);display:flex;align-items:center;gap:8px}' +
|
|
43
|
+
'.pm_sectionHead .pm_hint{margin-left:0}' +
|
|
44
|
+
/* cards */
|
|
45
|
+
'.pm_card{background:var(--dsw-alias-bg-layer-1,rgba(255,255,255,.035));border:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.22));border-radius:12px;padding:12px 14px;display:flex;flex-direction:column;gap:8px;transition:background .15s,border-color .15s}' +
|
|
46
|
+
'.pm_card:hover{background:var(--dsw-alias-bg-layer-2,rgba(255,255,255,.06));border-color:var(--dsw-alias-border-l2,rgba(128,128,128,.4))}' +
|
|
47
|
+
'.pm_cardHead{display:flex;align-items:center;gap:10px;flex-wrap:wrap}' +
|
|
48
|
+
'.pm_name{font-weight:600;font-size:14px;color:var(--dsw-alias-label-primary,#eee)}' +
|
|
49
|
+
'.pm_meta{font-size:12px;color:var(--dsw-alias-label-secondary,#9aa);word-break:break-all;line-height:1.5}' +
|
|
50
|
+
'.pm_desc{font-size:12.5px;color:var(--dsw-alias-label-secondary,#aab);line-height:1.6;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}' +
|
|
51
|
+
/* badges */
|
|
52
|
+
'.pm_badge{display:inline-flex;align-items:center;gap:4px;font-size:11px;padding:2px 9px;border-radius:999px;border:1px solid currentColor;line-height:1.6;white-space:nowrap}' +
|
|
53
|
+
'.pm_badge.muted{color:var(--dsw-alias-label-secondary,#9aa)}' +
|
|
54
|
+
'.pm_badge.ok{color:var(--dsw-alias-state-success-primary,#4ade80)}' +
|
|
55
|
+
'.pm_badge.warn{color:var(--dsw-alias-state-warn-primary,#fbbf24)}' +
|
|
56
|
+
'.pm_badge.brand{color:var(--dsw-alias-brand-primary,#7aa2ff);border-color:var(--dsw-alias-brand-primary,#7aa2ff)}' +
|
|
57
|
+
'.pm_badge.err{color:var(--dsw-alias-state-error-primary,#f87171)}' +
|
|
58
|
+
/* buttons */
|
|
59
|
+
'.pm_actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap}' +
|
|
60
|
+
'.pm_btn{cursor:pointer;font:inherit;font-size:12px;padding:5px 12px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.4));background:transparent;color:inherit;transition:background .12s}' +
|
|
61
|
+
'.pm_btn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2,rgba(128,128,128,.14))}' +
|
|
62
|
+
'.pm_btn:disabled{opacity:.45;cursor:default}' +
|
|
63
|
+
'.pm_btn.primary{border-color:transparent;background:var(--dsw-alias-brand-primary,#5b8cff);color:#fff}' +
|
|
64
|
+
'.pm_btn.primary:hover:not(:disabled){filter:brightness(1.08)}' +
|
|
65
|
+
'.pm_btn.danger{color:var(--dsw-alias-state-error-primary,#f87171);border-color:currentColor}' +
|
|
66
|
+
'.pm_btn.ghost{border-style:dashed}' +
|
|
67
|
+
/* form */
|
|
44
68
|
'.pm_form{display:grid;grid-template-columns:1fr 1fr;gap:10px}' +
|
|
45
|
-
'.pm_form label{display:flex;flex-direction:column;gap:4px;font-size:12px}' +
|
|
46
|
-
'.pm_form input,.pm_form textarea,.pm_form select{font:inherit;font-size:13px;padding:6px
|
|
69
|
+
'.pm_form label{display:flex;flex-direction:column;gap:4px;font-size:12px;color:var(--dsw-alias-label-secondary,#9aa)}' +
|
|
70
|
+
'.pm_form input,.pm_form textarea,.pm_form select{font:inherit;font-size:13px;padding:6px 9px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.35));background:var(--dsw-alias-bg-layer-1,rgba(255,255,255,.03));color:inherit}' +
|
|
71
|
+
'.pm_form input:focus,.pm_form textarea:focus,.pm_form select:focus{outline:2px solid var(--dsw-alias-brand-primary,rgba(91,140,255,.5));outline-offset:0;border-color:transparent}' +
|
|
47
72
|
'.pm_form .wide{grid-column:1 / -1}' +
|
|
48
|
-
'.
|
|
49
|
-
|
|
50
|
-
'.
|
|
73
|
+
'.pm_textarea{font-family:ui-monospace,Menlo,Consolas,monospace;min-height:140px}' +
|
|
74
|
+
/* market */
|
|
75
|
+
'.pm_search{display:flex;gap:8px}' +
|
|
76
|
+
'.pm_search input{flex:1;font:inherit;font-size:13px;padding:7px 11px;border-radius:9px;border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.35));background:var(--dsw-alias-bg-layer-1,rgba(255,255,255,.03));color:inherit}' +
|
|
77
|
+
'.pm_search input:focus{outline:2px solid var(--dsw-alias-brand-primary,rgba(91,140,255,.5));border-color:transparent}' +
|
|
78
|
+
'.pm_groupHead{font-size:12px;font-weight:600;color:var(--dsw-alias-label-secondary,#9aa);text-transform:uppercase;letter-spacing:.08em;margin:8px 0 2px}' +
|
|
79
|
+
'.pm_code{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:11.5px;line-height:1.55;background:var(--dsw-alias-bg-base,rgba(0,0,0,.25));border:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.2));border-radius:8px;padding:8px 10px;overflow:auto;max-height:220px;white-space:pre-wrap;word-break:break-all;color:var(--dsw-alias-label-secondary,#9aa)}' +
|
|
80
|
+
'.pm_envRow{display:flex;gap:8px;align-items:baseline;font-size:12px;color:var(--dsw-alias-label-secondary,#9aa)}' +
|
|
81
|
+
'.pm_envRow code{color:var(--dsw-alias-label-primary,#ddd)}' +
|
|
82
|
+
'.pm_spinner{display:inline-block;width:12px;height:12px;border:2px solid var(--dsw-alias-border-l2,rgba(128,128,128,.4));border-top-color:var(--dsw-alias-brand-primary,#5b8cff);border-radius:50%;animation:pm_spin .7s linear infinite;vertical-align:-2px}' +
|
|
83
|
+
'@keyframes pm_spin{to{transform:rotate(360deg)}}' +
|
|
84
|
+
'.pm_err{color:var(--dsw-alias-state-error-primary,#f87171);font-size:12px}' +
|
|
85
|
+
'.pm_ok{color:var(--dsw-alias-state-success-primary,#4ade80);font-size:12px}' +
|
|
86
|
+
'.pm_empty{font-size:12px;color:var(--dsw-alias-label-secondary,#888);padding:10px 2px}' +
|
|
87
|
+
'.pm_btnrow{display:flex;gap:8px;align-items:center;flex-wrap:wrap}'
|
|
51
88
|
|
|
52
89
|
if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css="@omdp/dsh-connector/section"]') === null) {
|
|
53
90
|
var tag = document.createElement('style')
|
|
@@ -66,6 +103,45 @@ window.__ModuleLoader__.load({
|
|
|
66
103
|
})
|
|
67
104
|
}
|
|
68
105
|
|
|
106
|
+
// Clipboard with a fallback: 127.0.0.1 is a secure context so
|
|
107
|
+
// navigator.clipboard normally works; execCommand covers edge cases.
|
|
108
|
+
function copyText(text, done) {
|
|
109
|
+
function fallback() {
|
|
110
|
+
try {
|
|
111
|
+
var ta = document.createElement('textarea')
|
|
112
|
+
ta.value = text
|
|
113
|
+
ta.style.position = 'fixed'
|
|
114
|
+
ta.style.opacity = '0'
|
|
115
|
+
document.body.appendChild(ta)
|
|
116
|
+
ta.focus()
|
|
117
|
+
ta.select()
|
|
118
|
+
var ok = document.execCommand('copy')
|
|
119
|
+
document.body.removeChild(ta)
|
|
120
|
+
done(ok === true)
|
|
121
|
+
} catch (e) {
|
|
122
|
+
done(false)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
126
|
+
navigator.clipboard.writeText(text).then(function () { done(true) }, fallback)
|
|
127
|
+
} else {
|
|
128
|
+
fallback()
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function useCopied() {
|
|
133
|
+
var _a = react.useState('')
|
|
134
|
+
var copied = _a[0]
|
|
135
|
+
var setCopied = _a[1]
|
|
136
|
+
function copy(text) {
|
|
137
|
+
copyText(text, function (ok) {
|
|
138
|
+
setCopied(ok ? '已复制 ✓' : '复制失败')
|
|
139
|
+
setTimeout(function () { setCopied('') }, 1600)
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
return { copied: copied, copy: copy }
|
|
143
|
+
}
|
|
144
|
+
|
|
69
145
|
// Server ids must stay kebab-case under the fixed `mcp-` prefix: the host
|
|
70
146
|
// parser and dsh-mcp-client both key on it, so the prefix is not optional.
|
|
71
147
|
var MCP_ID_RE = /^mcp-[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
@@ -91,6 +167,10 @@ window.__ModuleLoader__.load({
|
|
|
91
167
|
)
|
|
92
168
|
}
|
|
93
169
|
|
|
170
|
+
function badge(text, kind) {
|
|
171
|
+
return createElement('span', { className: 'pm_badge ' + kind }, text)
|
|
172
|
+
}
|
|
173
|
+
|
|
94
174
|
function ManageTab() {
|
|
95
175
|
var _a = react.useState('mcp')
|
|
96
176
|
var tab = _a[0]
|
|
@@ -122,16 +202,21 @@ window.__ModuleLoader__.load({
|
|
|
122
202
|
|
|
123
203
|
return createElement('div', { className: 'pm_root' },
|
|
124
204
|
createElement('div', { className: 'pm_tabs' },
|
|
125
|
-
createElement('button', { className: '
|
|
126
|
-
createElement('button', { className: '
|
|
205
|
+
createElement('button', { className: 'pm_tab' + (tab === 'mcp' ? ' active' : ''), onClick: function () { setTab('mcp') } }, 'MCP 服务器'),
|
|
206
|
+
createElement('button', { className: 'pm_tab' + (tab === 'skills' ? ' active' : ''), onClick: function () { setTab('skills') } }, 'Skills 技能'),
|
|
207
|
+
createElement('button', { className: 'pm_tab' + (tab === 'market' ? ' active' : ''), onClick: function () { setTab('market') } }, '市场探索'),
|
|
127
208
|
),
|
|
128
209
|
error ? createElement('div', { className: 'pm_err' }, error) : null,
|
|
129
210
|
tab === 'mcp'
|
|
130
211
|
? createElement(McpPane, { mcp: mcp, busy: busy, onChanged: refresh })
|
|
131
|
-
:
|
|
212
|
+
: tab === 'skills'
|
|
213
|
+
? createElement(SkillPane, { skills: skills, busy: busy, onChanged: refresh })
|
|
214
|
+
: createElement(MarketPane, { skills: skills, onChanged: refresh }),
|
|
132
215
|
)
|
|
133
216
|
}
|
|
134
217
|
|
|
218
|
+
/* ─────────────────────────── MCP servers ─────────────────────────── */
|
|
219
|
+
|
|
135
220
|
function McpPane(props) {
|
|
136
221
|
var _a = react.useState(null)
|
|
137
222
|
var editing = _a[0]
|
|
@@ -163,10 +248,10 @@ window.__ModuleLoader__.load({
|
|
|
163
248
|
}
|
|
164
249
|
|
|
165
250
|
var rows = props.mcp.servers.map(function (s) {
|
|
166
|
-
return createElement('div', { className: '
|
|
167
|
-
createElement('div', { className: '
|
|
251
|
+
return createElement('div', { className: 'pm_card', key: s.id },
|
|
252
|
+
createElement('div', { className: 'pm_cardHead' },
|
|
168
253
|
createElement('span', { className: 'pm_name' }, s.name || s.id),
|
|
169
|
-
|
|
254
|
+
badge(s.transport || 'stdio', 'muted'),
|
|
170
255
|
),
|
|
171
256
|
createElement('div', { className: 'pm_meta' }, s.url ? 'url: ' + s.url : (s.command ? 'cmd: ' + s.command : '')),
|
|
172
257
|
createElement('div', { className: 'pm_actions' },
|
|
@@ -177,13 +262,16 @@ window.__ModuleLoader__.load({
|
|
|
177
262
|
})
|
|
178
263
|
|
|
179
264
|
return createElement('div', { className: 'pm_section' },
|
|
180
|
-
createElement('div', { className: 'pm_sectionHead' },
|
|
265
|
+
createElement('div', { className: 'pm_sectionHead' },
|
|
266
|
+
'MCP 服务器 MCP Servers',
|
|
267
|
+
props.busy ? createElement('span', { className: 'pm_spinner' }) : null,
|
|
268
|
+
),
|
|
181
269
|
err ? createElement('div', { className: 'pm_err' }, err) : null,
|
|
182
|
-
createElement('div', { className: '
|
|
270
|
+
createElement('div', { className: 'pm_meta' },
|
|
183
271
|
props.mcp.exists ? '编辑 profiles/web/cordis.patch.yml 中的 mcp-* 块。保存后重启 dsh 生效。Edit the mcp-* block in cordis.patch.yml; restart dsh to apply.' : '未找到 cordis.patch.yml。cordis.patch.yml not found.'),
|
|
184
272
|
rows,
|
|
185
273
|
editing === null
|
|
186
|
-
? createElement('button', { className: 'pm_btn
|
|
274
|
+
? createElement('button', { className: 'pm_btn pm_btn ghost', onClick: function () { setEditing({ id: 'mcp-new', customId: '', name: '', transport: 'stdio', serverName: '', url: '', command: '', args: '' }) } }, '+ 添加 MCP 服务器 Add MCP Server')
|
|
187
275
|
: createElement(ServerForm, { value: editing, onSave: save, onCancel: function () { setEditing(null) } }),
|
|
188
276
|
)
|
|
189
277
|
}
|
|
@@ -272,7 +360,7 @@ window.__ModuleLoader__.load({
|
|
|
272
360
|
}
|
|
273
361
|
}
|
|
274
362
|
|
|
275
|
-
return createElement('div', { className: '
|
|
363
|
+
return createElement('div', { className: 'pm_card' },
|
|
276
364
|
err ? createElement('div', { className: 'pm_err' }, err) : null,
|
|
277
365
|
createElement('div', { className: 'pm_form' },
|
|
278
366
|
field('名称 (serverName) Name', v.serverName, set('serverName')),
|
|
@@ -298,6 +386,8 @@ window.__ModuleLoader__.load({
|
|
|
298
386
|
)
|
|
299
387
|
}
|
|
300
388
|
|
|
389
|
+
/* ───────────────────────────── Skills ────────────────────────────── */
|
|
390
|
+
|
|
301
391
|
function SkillPane(props) {
|
|
302
392
|
var _a = react.useState(null)
|
|
303
393
|
var editing = _a[0]
|
|
@@ -305,6 +395,12 @@ window.__ModuleLoader__.load({
|
|
|
305
395
|
var _b = react.useState('')
|
|
306
396
|
var err = _b[0]
|
|
307
397
|
var setErr = _b[1]
|
|
398
|
+
var _c = react.useState(null)
|
|
399
|
+
var updates = _c[0]
|
|
400
|
+
var setUpdates = _c[1]
|
|
401
|
+
var _d = react.useState(false)
|
|
402
|
+
var checking = _d[0]
|
|
403
|
+
var setChecking = _d[1]
|
|
308
404
|
|
|
309
405
|
function open(name) {
|
|
310
406
|
api('/skills/' + encodeURIComponent(name), { method: 'GET' }).then(function (skill) {
|
|
@@ -328,11 +424,22 @@ window.__ModuleLoader__.load({
|
|
|
328
424
|
props.onChanged()
|
|
329
425
|
})
|
|
330
426
|
}
|
|
427
|
+
function checkUpdates() {
|
|
428
|
+
setChecking(true)
|
|
429
|
+
setErr('')
|
|
430
|
+
api('/skills/check-updates', { method: 'GET' }).then(function (r) {
|
|
431
|
+
if (r && r.error) { setErr(r.error); setUpdates(null); return }
|
|
432
|
+
setUpdates(r.items || [])
|
|
433
|
+
}).finally(function () { setChecking(false) })
|
|
434
|
+
}
|
|
331
435
|
|
|
332
436
|
var rows = props.skills.map(function (s) {
|
|
333
|
-
|
|
334
|
-
|
|
437
|
+
var update = updates ? updates.find(function (u) { return u.name === s.name }) : null
|
|
438
|
+
return createElement('div', { className: 'pm_card', key: s.name },
|
|
439
|
+
createElement('div', { className: 'pm_cardHead' },
|
|
335
440
|
createElement('span', { className: 'pm_name' }, s.name),
|
|
441
|
+
s.source ? badge('来源 ' + s.source, 'muted') : null,
|
|
442
|
+
update ? (update.updateAvailable ? badge('有更新', 'warn') : badge('最新', 'ok')) : null,
|
|
336
443
|
),
|
|
337
444
|
createElement('div', { className: 'pm_meta' }, s.description || ''),
|
|
338
445
|
createElement('div', { className: 'pm_actions' },
|
|
@@ -343,12 +450,18 @@ window.__ModuleLoader__.load({
|
|
|
343
450
|
})
|
|
344
451
|
|
|
345
452
|
return createElement('div', { className: 'pm_section' },
|
|
346
|
-
createElement('div', { className: 'pm_sectionHead' },
|
|
453
|
+
createElement('div', { className: 'pm_sectionHead' },
|
|
454
|
+
'用户 Skills (~/.dsh/skills) User Skills',
|
|
455
|
+
props.busy ? createElement('span', { className: 'pm_spinner' }) : null,
|
|
456
|
+
createElement('span', { className: 'pm_actions', style: { marginLeft: 'auto' } },
|
|
457
|
+
createElement('button', { className: 'pm_btn', disabled: checking, onClick: checkUpdates }, checking ? '检查中…' : '检查更新'),
|
|
458
|
+
),
|
|
459
|
+
),
|
|
347
460
|
err ? createElement('div', { className: 'pm_err' }, err) : null,
|
|
348
|
-
createElement('div', { className: '
|
|
461
|
+
createElement('div', { className: 'pm_meta' }, '读写用户技能目录,保存即生效(skill catalog 自动刷新)。带来源的 skill 可与魔搭市场比对更新时间。Read/write user skills; saved changes take effect immediately.'),
|
|
349
462
|
rows,
|
|
350
463
|
editing === null
|
|
351
|
-
? createElement('button', { className: 'pm_btn
|
|
464
|
+
? createElement('button', { className: 'pm_btn pm_btn ghost', onClick: function () { setEditing({ name: '', description: '', content: '' }) } }, '+ 新建 Skill New Skill')
|
|
352
465
|
: createElement(SkillForm, { value: editing, onSave: save, onCancel: function () { setEditing(null) } }),
|
|
353
466
|
)
|
|
354
467
|
}
|
|
@@ -370,7 +483,7 @@ window.__ModuleLoader__.load({
|
|
|
370
483
|
props.onSave(Object.assign({}, v, { name: name }))
|
|
371
484
|
}
|
|
372
485
|
|
|
373
|
-
return createElement('div', { className: '
|
|
486
|
+
return createElement('div', { className: 'pm_card' },
|
|
374
487
|
err ? createElement('div', { className: 'pm_err' }, err) : null,
|
|
375
488
|
createElement('div', { className: 'pm_form' },
|
|
376
489
|
field('名称 (kebab-case) Name', v.name, set('name')),
|
|
@@ -387,6 +500,266 @@ window.__ModuleLoader__.load({
|
|
|
387
500
|
)
|
|
388
501
|
}
|
|
389
502
|
|
|
503
|
+
/* ─────────────────────────── marketplace ─────────────────────────── */
|
|
504
|
+
|
|
505
|
+
function MarketPane(props) {
|
|
506
|
+
var _a = react.useState('')
|
|
507
|
+
var mcpSearch = _a[0]
|
|
508
|
+
var setMcpSearch = _a[1]
|
|
509
|
+
var _b = react.useState(null)
|
|
510
|
+
var mcpResult = _b[0]
|
|
511
|
+
var setMcpResult = _b[1]
|
|
512
|
+
var _c = react.useState(false)
|
|
513
|
+
var mcpBusy = _c[0]
|
|
514
|
+
var setMcpBusy = _c[1]
|
|
515
|
+
var _d = react.useState({})
|
|
516
|
+
var mcpDetail = _d[0]
|
|
517
|
+
var setMcpDetail = _d[1]
|
|
518
|
+
var _e = react.useState('')
|
|
519
|
+
var skillSearch = _e[0]
|
|
520
|
+
var setSkillSearch = _e[1]
|
|
521
|
+
var _f = react.useState(null)
|
|
522
|
+
var skillResult = _f[0]
|
|
523
|
+
var setSkillResult = _f[1]
|
|
524
|
+
var _g = react.useState(false)
|
|
525
|
+
var skillBusy = _g[0]
|
|
526
|
+
var setSkillBusy = _g[1]
|
|
527
|
+
var _h = react.useState({})
|
|
528
|
+
var skillDetail = _h[0]
|
|
529
|
+
var setSkillDetail = _h[1]
|
|
530
|
+
var _i = react.useState('')
|
|
531
|
+
var err = _i[0]
|
|
532
|
+
var setErr = _i[1]
|
|
533
|
+
|
|
534
|
+
function findMcp() {
|
|
535
|
+
setMcpBusy(true)
|
|
536
|
+
setErr('')
|
|
537
|
+
api('/market/mcp?search=' + encodeURIComponent(mcpSearch.trim()), { method: 'GET' }).then(function (r) {
|
|
538
|
+
if (r && r.error) { setErr(r.error); setMcpResult(null); return }
|
|
539
|
+
setMcpResult(r)
|
|
540
|
+
}).finally(function () { setMcpBusy(false) })
|
|
541
|
+
}
|
|
542
|
+
function findSkills() {
|
|
543
|
+
setSkillBusy(true)
|
|
544
|
+
setErr('')
|
|
545
|
+
api('/market/skills?search=' + encodeURIComponent(skillSearch.trim()), { method: 'GET' }).then(function (r) {
|
|
546
|
+
if (r && r.error) { setErr(r.error); setSkillResult(null); return }
|
|
547
|
+
setSkillResult(r)
|
|
548
|
+
}).finally(function () { setSkillBusy(false) })
|
|
549
|
+
}
|
|
550
|
+
function openMcp(id) {
|
|
551
|
+
api('/market/mcp/' + encodeURIComponent(id), { method: 'GET' }).then(function (r) {
|
|
552
|
+
if (r && r.error) { setErr(r.error); return }
|
|
553
|
+
var next = Object.assign({}, mcpDetail)
|
|
554
|
+
next[id] = r.item
|
|
555
|
+
setMcpDetail(next)
|
|
556
|
+
})
|
|
557
|
+
}
|
|
558
|
+
function openSkill(id) {
|
|
559
|
+
api('/market/skills/' + encodeURIComponent(id), { method: 'GET' }).then(function (r) {
|
|
560
|
+
if (r && r.error) { setErr(r.error); return }
|
|
561
|
+
var next = Object.assign({}, skillDetail)
|
|
562
|
+
next[id] = r.item
|
|
563
|
+
setSkillDetail(next)
|
|
564
|
+
})
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
var mcpRows = mcpResult && mcpResult.items
|
|
568
|
+
? mcpResult.items.map(function (s) {
|
|
569
|
+
var d = mcpDetail[s.id]
|
|
570
|
+
return createElement(McpMarketItem, {
|
|
571
|
+
key: s.id, item: s, detail: d,
|
|
572
|
+
onOpen: function () { if (!d) openMcp(s.id) },
|
|
573
|
+
})
|
|
574
|
+
})
|
|
575
|
+
: []
|
|
576
|
+
|
|
577
|
+
var skillRows = skillResult && skillResult.items
|
|
578
|
+
? skillResult.items.map(function (s) {
|
|
579
|
+
var d = skillDetail[s.id]
|
|
580
|
+
return createElement(SkillMarketItem, {
|
|
581
|
+
key: s.id, item: s, detail: d, skills: props.skills,
|
|
582
|
+
onOpen: function () { if (!d) openSkill(s.id) },
|
|
583
|
+
onChanged: props.onChanged,
|
|
584
|
+
})
|
|
585
|
+
})
|
|
586
|
+
: []
|
|
587
|
+
|
|
588
|
+
return createElement('div', { className: 'pm_section' },
|
|
589
|
+
createElement('div', { className: 'pm_sectionHead' }, '市场探索 Marketplace'),
|
|
590
|
+
createElement('div', { className: 'pm_meta' },
|
|
591
|
+
'浏览魔搭社区(ModelScope)技能中心与 MCP 广场。列表数据缓存在 DSH 进程内存(30 分钟),不写任何文件;安装/部署请复制命令自行执行。Browse ModelScope Skills Center & MCP Plaza; cached in-process only, nothing saved to disk.'),
|
|
592
|
+
err ? createElement('div', { className: 'pm_err' }, err) : null,
|
|
593
|
+
|
|
594
|
+
createElement('div', { className: 'pm_groupHead' }, 'MCP 市场 MCP Plaza'),
|
|
595
|
+
createElement('div', { className: 'pm_search' },
|
|
596
|
+
createElement('input', {
|
|
597
|
+
placeholder: '搜索 MCP 服务,如 map / github / fetch Search…',
|
|
598
|
+
value: mcpSearch,
|
|
599
|
+
onChange: function (e) { setMcpSearch(e.target.value) },
|
|
600
|
+
onKeyDown: function (e) { if (e.key === 'Enter') findMcp() },
|
|
601
|
+
}),
|
|
602
|
+
createElement('button', { className: 'pm_btn primary', disabled: mcpBusy, onClick: findMcp }, mcpBusy ? '搜索中…' : '搜索'),
|
|
603
|
+
),
|
|
604
|
+
mcpRows.length ? mcpRows : (mcpResult ? createElement('div', { className: 'pm_empty' }, '没有匹配的 MCP 服务。No matching MCP servers.') : null),
|
|
605
|
+
|
|
606
|
+
createElement('div', { className: 'pm_groupHead', style: { marginTop: '18px' } }, 'Skills 市场 Skills Center'),
|
|
607
|
+
createElement('div', { className: 'pm_search' },
|
|
608
|
+
createElement('input', {
|
|
609
|
+
placeholder: '搜索技能,如 code-review / deploy Search…',
|
|
610
|
+
value: skillSearch,
|
|
611
|
+
onChange: function (e) { setSkillSearch(e.target.value) },
|
|
612
|
+
onKeyDown: function (e) { if (e.key === 'Enter') findSkills() },
|
|
613
|
+
}),
|
|
614
|
+
createElement('button', { className: 'pm_btn primary', disabled: skillBusy, onClick: findSkills }, skillBusy ? '搜索中…' : '搜索'),
|
|
615
|
+
),
|
|
616
|
+
skillRows.length ? skillRows : (skillResult ? createElement('div', { className: 'pm_empty' }, '没有匹配的技能。No matching skills.') : null),
|
|
617
|
+
)
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// One MCP market entry with a lazy detail panel.
|
|
621
|
+
function McpMarketItem(props) {
|
|
622
|
+
var item = props.item
|
|
623
|
+
var d = props.detail
|
|
624
|
+
var _a = react.useState(null)
|
|
625
|
+
var variant = _a[0]
|
|
626
|
+
var setVariant = _a[1]
|
|
627
|
+
var copied = useCopied()
|
|
628
|
+
var configs = d && Array.isArray(d.serverConfig) ? d.serverConfig : []
|
|
629
|
+
var cfgText = ''
|
|
630
|
+
if (configs.length) {
|
|
631
|
+
var sel = variant !== null && variant < configs.length ? configs[variant] : configs[0]
|
|
632
|
+
cfgText = JSON.stringify(sel, null, 2)
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
return createElement('div', { className: 'pm_card', key: item.id },
|
|
636
|
+
createElement('div', { className: 'pm_cardHead' },
|
|
637
|
+
createElement('span', { className: 'pm_name' }, item.name || item.id),
|
|
638
|
+
item.configured ? badge('已配置', 'ok') : null,
|
|
639
|
+
badge(item.id, 'muted'),
|
|
640
|
+
),
|
|
641
|
+
createElement('div', { className: 'pm_desc' }, item.description || ''),
|
|
642
|
+
createElement('div', { className: 'pm_meta' },
|
|
643
|
+
'by ' + (item.publisher || '—'),
|
|
644
|
+
item.views !== undefined ? ' · ' + item.views + ' 浏览' : '',
|
|
645
|
+
),
|
|
646
|
+
createElement('div', { className: 'pm_actions' },
|
|
647
|
+
createElement('button', { className: 'pm_btn', onClick: props.onOpen }, d ? '收起' : '详情'),
|
|
648
|
+
d && configs.length
|
|
649
|
+
? createElement('button', { className: 'pm_btn primary', onClick: function () { copied.copy(cfgText) } },
|
|
650
|
+
copied.copied || '复制配置 Copy config')
|
|
651
|
+
: null,
|
|
652
|
+
),
|
|
653
|
+
d ? createElement('div', { className: 'pm_section', style: { marginTop: '4px' } },
|
|
654
|
+
createElement('div', { className: 'pm_actions' },
|
|
655
|
+
d.hosted ? badge('Hosted 官方托管', 'brand') : null,
|
|
656
|
+
d.verified ? badge('已认证 Verified', 'brand') : null,
|
|
657
|
+
d.stars !== undefined && d.stars !== null ? badge('★ ' + d.stars, 'muted') : null,
|
|
658
|
+
(d.categories && d.categories.length) ? badge(d.categories.slice(0, 3).join(' / '), 'muted') : null,
|
|
659
|
+
),
|
|
660
|
+
(d.envSchema && d.envSchema.length)
|
|
661
|
+
? createElement('div', { className: 'pm_meta' },
|
|
662
|
+
'环境变量 Env vars: ',
|
|
663
|
+
d.envSchema.map(function (ev) {
|
|
664
|
+
return createElement('span', { key: ev.key, className: 'pm_envRow' },
|
|
665
|
+
createElement('code', null, ev.key),
|
|
666
|
+
ev.required ? badge('必填', 'warn') : null,
|
|
667
|
+
)
|
|
668
|
+
}),
|
|
669
|
+
)
|
|
670
|
+
: null,
|
|
671
|
+
configs.length
|
|
672
|
+
? createElement('div', { className: 'pm_section' },
|
|
673
|
+
configs.length > 1
|
|
674
|
+
? selectField('配置变体 Config variant', variant === null ? 0 : variant, configs.map(function (_, i) { return 'variant ' + (i + 1) }), function (i) { setVariant(Number(i)) })
|
|
675
|
+
: null,
|
|
676
|
+
createElement('div', { className: 'pm_code' }, cfgText),
|
|
677
|
+
)
|
|
678
|
+
: null,
|
|
679
|
+
d.readme ? createElement('div', { className: 'pm_meta' }, d.readme.slice(0, 240) + (d.readme.length > 240 ? ' …' : '')) : null,
|
|
680
|
+
d.source ? createElement('div', { className: 'pm_meta' }, '来源: ' + d.source) : null,
|
|
681
|
+
) : null,
|
|
682
|
+
)
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// One market skill entry with install commands and local "record source".
|
|
686
|
+
function SkillMarketItem(props) {
|
|
687
|
+
var item = props.item
|
|
688
|
+
var d = props.detail
|
|
689
|
+
var copied = useCopied()
|
|
690
|
+
var _a = react.useState('')
|
|
691
|
+
var linkErr = _a[0]
|
|
692
|
+
var setLinkErr = _a[1]
|
|
693
|
+
var _b = react.useState('')
|
|
694
|
+
var linkOk = _b[0]
|
|
695
|
+
var setLinkOk = _b[1]
|
|
696
|
+
var _c = react.useState(null)
|
|
697
|
+
var target = _c[0]
|
|
698
|
+
var setTarget = _c[1]
|
|
699
|
+
|
|
700
|
+
var alreadyLinked = props.skills.some(function (s) { return s.source === item.id })
|
|
701
|
+
var installs = d && Array.isArray(d.installCommand) ? d.installCommand : []
|
|
702
|
+
|
|
703
|
+
function recordSource() {
|
|
704
|
+
setLinkErr('')
|
|
705
|
+
setLinkOk('')
|
|
706
|
+
if (!target) { setLinkErr('请先选择一个本地技能 Please pick a local skill'); return }
|
|
707
|
+
api('/skills/' + encodeURIComponent(target) + '/source', {
|
|
708
|
+
method: 'POST',
|
|
709
|
+
body: JSON.stringify({ marketId: item.id, modified: d ? d.fileModified : undefined }),
|
|
710
|
+
}).then(function (r) {
|
|
711
|
+
if (r && r.error) { setLinkErr(r.error); return }
|
|
712
|
+
setLinkOk('已关联 ' + target + ' ✓')
|
|
713
|
+
props.onChanged()
|
|
714
|
+
})
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
return createElement('div', { className: 'pm_card', key: item.id },
|
|
718
|
+
createElement('div', { className: 'pm_cardHead' },
|
|
719
|
+
createElement('span', { className: 'pm_name' }, item.name || item.id),
|
|
720
|
+
item.installed || alreadyLinked ? badge('已安装', 'ok') : null,
|
|
721
|
+
item.developer ? badge('by ' + item.developer, 'muted') : null,
|
|
722
|
+
item.category ? badge(item.category, 'muted') : null,
|
|
723
|
+
),
|
|
724
|
+
createElement('div', { className: 'pm_desc' }, item.description || ''),
|
|
725
|
+
createElement('div', { className: 'pm_meta' },
|
|
726
|
+
item.downloads !== undefined ? item.downloads + ' 下载' : '',
|
|
727
|
+
item.views !== undefined ? ' · ' + item.views + ' 浏览' : '',
|
|
728
|
+
item.license ? ' · ' + item.license : '',
|
|
729
|
+
),
|
|
730
|
+
createElement('div', { className: 'pm_actions' },
|
|
731
|
+
createElement('button', { className: 'pm_btn', onClick: props.onOpen }, d ? '收起' : '详情'),
|
|
732
|
+
d && d.updateAvailable ? badge('有更新', 'warn') : null,
|
|
733
|
+
),
|
|
734
|
+
d ? createElement('div', { className: 'pm_section', style: { marginTop: '4px' } },
|
|
735
|
+
createElement('div', { className: 'pm_meta' },
|
|
736
|
+
'市场更新时间 Market modified: ' + (d.fileModified || '—'),
|
|
737
|
+
d.localSkill ? ' · 本地记录: ' + d.localSkill.sourceUpdated : '',
|
|
738
|
+
),
|
|
739
|
+
installs.length
|
|
740
|
+
? createElement('div', { className: 'pm_section' },
|
|
741
|
+
createElement('div', { className: 'pm_meta' }, '安装命令 Install command(复制后自行执行):'),
|
|
742
|
+
installs.map(function (cmd, i) {
|
|
743
|
+
return createElement('div', { key: i, className: 'pm_btnrow' },
|
|
744
|
+
createElement('div', { className: 'pm_code', style: { flex: 1 } }, cmd),
|
|
745
|
+
createElement('button', { className: 'pm_btn', onClick: function () { copied.copy(cmd) } }, copied.copied || '复制 Copy'),
|
|
746
|
+
)
|
|
747
|
+
}),
|
|
748
|
+
)
|
|
749
|
+
: null,
|
|
750
|
+
alreadyLinked
|
|
751
|
+
? createElement('div', { className: 'pm_ok' }, '已关联到本地技能 ✓ Linked to a local skill.')
|
|
752
|
+
: createElement('div', { className: 'pm_btnrow' },
|
|
753
|
+
selectField('关联本地技能 Link to local skill', target || '', props.skills.map(function (s) { return s.name }), setTarget),
|
|
754
|
+
createElement('button', { className: 'pm_btn', onClick: recordSource }, '记录来源 Record source'),
|
|
755
|
+
),
|
|
756
|
+
linkErr ? createElement('div', { className: 'pm_err' }, linkErr) : null,
|
|
757
|
+
linkOk ? createElement('div', { className: 'pm_ok' }, linkOk) : null,
|
|
758
|
+
d.source ? createElement('div', { className: 'pm_meta' }, '来源: ' + d.source) : null,
|
|
759
|
+
) : null,
|
|
760
|
+
)
|
|
761
|
+
}
|
|
762
|
+
|
|
390
763
|
function apply(ctx) {
|
|
391
764
|
var slots = ctx.get('slots')
|
|
392
765
|
if (slots === undefined) return
|
|
@@ -401,4 +774,4 @@ window.__ModuleLoader__.load({
|
|
|
401
774
|
exports.apply = apply
|
|
402
775
|
return module.exports
|
|
403
776
|
},
|
|
404
|
-
})
|
|
777
|
+
})
|
package/index.js
CHANGED
|
@@ -348,6 +348,10 @@ function serializeSkill(skill) {
|
|
|
348
348
|
if (skill.whenToUse !== undefined) fm.whenToUse = skill.whenToUse
|
|
349
349
|
if (skill.modelInvocable !== undefined) fm.modelInvocable = skill.modelInvocable
|
|
350
350
|
if (skill.userInvocable !== undefined) fm.userInvocable = skill.userInvocable
|
|
351
|
+
// Marketplace provenance: set by "record source" in the UI, consumed by the
|
|
352
|
+
// update check (compares market file_last_modified against sourceUpdated).
|
|
353
|
+
if (skill.source !== undefined) fm.source = skill.source
|
|
354
|
+
if (skill.sourceUpdated !== undefined) fm.sourceUpdated = skill.sourceUpdated
|
|
351
355
|
const head = '---\n' + stringifyYaml(fm) + '---\n'
|
|
352
356
|
return head + (skill.content || '')
|
|
353
357
|
}
|
|
@@ -369,6 +373,9 @@ async function readUserSkill(home, name) {
|
|
|
369
373
|
whenToUse: fm.data.whenToUse,
|
|
370
374
|
modelInvocable: fm.data.modelInvocable,
|
|
371
375
|
userInvocable: fm.data.userInvocable,
|
|
376
|
+
// Marketplace provenance survives read so edits keep it unless replaced.
|
|
377
|
+
source: fm.data.source,
|
|
378
|
+
sourceUpdated: fm.data.sourceUpdated,
|
|
372
379
|
content: fm.body,
|
|
373
380
|
}
|
|
374
381
|
}
|
|
@@ -386,7 +393,7 @@ async function listUserSkills() {
|
|
|
386
393
|
for (const entry of entries) {
|
|
387
394
|
if (!entry.isDirectory()) continue
|
|
388
395
|
const skill = await readUserSkill(resolveHome(), entry.name)
|
|
389
|
-
if (skill) out.push({ name: skill.name, description: skill.description || '' })
|
|
396
|
+
if (skill) out.push({ name: skill.name, description: skill.description || '', source: skill.source, sourceUpdated: skill.sourceUpdated })
|
|
390
397
|
}
|
|
391
398
|
return out
|
|
392
399
|
}
|
|
@@ -400,9 +407,18 @@ async function saveUserSkill(home, skill) {
|
|
|
400
407
|
if (skill.content && /(^|\n)\s*---\s*(\n|$)/.test(skill.content)) {
|
|
401
408
|
throw new Error('skill content must not contain a standalone "---" line')
|
|
402
409
|
}
|
|
410
|
+
// Keep marketplace provenance when an edit does not carry it: the editor
|
|
411
|
+
// round-trips content only, so dropping source here would silently sever
|
|
412
|
+
// the update-check link on every save.
|
|
413
|
+
const existing = await readUserSkill(home, skill.name).catch(() => undefined)
|
|
414
|
+
const merged = {
|
|
415
|
+
...skill,
|
|
416
|
+
source: skill.source !== undefined ? skill.source : existing?.source,
|
|
417
|
+
sourceUpdated: skill.sourceUpdated !== undefined ? skill.sourceUpdated : existing?.sourceUpdated,
|
|
418
|
+
}
|
|
403
419
|
const dir = join(skillsRoot(), skill.name)
|
|
404
420
|
const file = join(dir, 'SKILL.md')
|
|
405
|
-
const serialized = serializeSkill(
|
|
421
|
+
const serialized = serializeSkill(merged)
|
|
406
422
|
// Round-trip check: the file we are about to write must parse back into a
|
|
407
423
|
// valid skill (name + at least one closing delimiter). If not, refuse.
|
|
408
424
|
if (!parseFrontmatter(serialized)) {
|
|
@@ -418,6 +434,247 @@ async function removeUserSkill(home, name) {
|
|
|
418
434
|
await rm(join(skillsRoot(), name), { recursive: true, force: true })
|
|
419
435
|
}
|
|
420
436
|
|
|
437
|
+
/* ──────────────────── ModelScope marketplace proxy ───────────────────────
|
|
438
|
+
* Read-only browsing of the ModelScope Skills Center and MCP Plaza. All
|
|
439
|
+
* market data lives in a 30-minute in-process cache — nothing is written to
|
|
440
|
+
* disk, no files ever accumulate, and a restart clears it. Install / deploy
|
|
441
|
+
* are NOT performed: the UI only lists entries and copies install commands /
|
|
442
|
+
* server config for the user to run themselves. Browsing uses anonymous
|
|
443
|
+
* endpoints; no MODELSCOPE_API_KEY is required.
|
|
444
|
+
* ───────────────────────────────────────────────────────────────────────── */
|
|
445
|
+
|
|
446
|
+
const MARKET_BASE = process.env.DSH_CONNECTOR_MARKET ?? 'https://modelscope.cn/openapi/v1'
|
|
447
|
+
const MARKET_TTL = 30 * 60 * 1000 // 30 minutes in-process cache
|
|
448
|
+
const marketCache = new Map()
|
|
449
|
+
|
|
450
|
+
async function marketFetch(key, path, init) {
|
|
451
|
+
const hit = marketCache.get(key)
|
|
452
|
+
if (hit !== undefined && Date.now() - hit.at < MARKET_TTL) return hit.value
|
|
453
|
+
const res = await fetch(MARKET_BASE + path, {
|
|
454
|
+
...init,
|
|
455
|
+
headers: { accept: 'application/json', ...(init?.headers ?? {}) },
|
|
456
|
+
signal: AbortSignal.timeout(15000),
|
|
457
|
+
})
|
|
458
|
+
if (!res.ok) throw new Error(`market returned HTTP ${res.status}`)
|
|
459
|
+
const data = await res.json()
|
|
460
|
+
marketCache.set(key, { at: Date.now(), value: data })
|
|
461
|
+
return data
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function marketError(res, error) {
|
|
465
|
+
return json(res, 502, { error: `market unavailable: ${String(error?.message ?? error)}` })
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Skill ids come back as `@author/name` and appear in URLs verbatim. Only
|
|
469
|
+
// accept the safe alphabet so a crafted id can never escape the path.
|
|
470
|
+
const MARKET_ID = /^@?[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+$/
|
|
471
|
+
|
|
472
|
+
// GET /api/market/skills?search=&category=&page=
|
|
473
|
+
async function marketSkillsList(req, res, params) {
|
|
474
|
+
const search = params.get('search')?.trim()
|
|
475
|
+
const category = params.get('category')?.trim()
|
|
476
|
+
const page = Math.max(1, Number(params.get('page')) || 1)
|
|
477
|
+
const qs = new URLSearchParams({ page_number: String(page), page_size: '20' })
|
|
478
|
+
if (search) qs.set('search', search)
|
|
479
|
+
if (category) qs.set('filter.category', category)
|
|
480
|
+
let data
|
|
481
|
+
try {
|
|
482
|
+
data = await marketFetch(`skills?${qs}`, `/skills?${qs}`)
|
|
483
|
+
} catch (error) {
|
|
484
|
+
return marketError(res, error)
|
|
485
|
+
}
|
|
486
|
+
const local = await listUserSkills().catch(() => [])
|
|
487
|
+
const items = (data?.data?.skills ?? []).map((s) => ({
|
|
488
|
+
id: s.id,
|
|
489
|
+
name: s.display_name,
|
|
490
|
+
description: s.description,
|
|
491
|
+
developer: s.developer,
|
|
492
|
+
category: s.category,
|
|
493
|
+
license: s.license,
|
|
494
|
+
views: s.view_count,
|
|
495
|
+
downloads: s.downloads,
|
|
496
|
+
source: s.source_url,
|
|
497
|
+
logo: s.logo_url,
|
|
498
|
+
installed: local.some((l) => l.source === s.id),
|
|
499
|
+
}))
|
|
500
|
+
return json(res, 200, { ok: true, items })
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// GET /api/market/skills/:id (id is `@author/name`, may contain a slash)
|
|
504
|
+
async function marketSkillDetail(res, id) {
|
|
505
|
+
let data
|
|
506
|
+
try {
|
|
507
|
+
data = await marketFetch(`skills/${id}`, `/skills/${id}`)
|
|
508
|
+
} catch (error) {
|
|
509
|
+
return marketError(res, error)
|
|
510
|
+
}
|
|
511
|
+
const d = data?.data ?? {}
|
|
512
|
+
const local = (await listUserSkills().catch(() => [])).find((l) => l.source === id)
|
|
513
|
+
let updateAvailable = false
|
|
514
|
+
if (local && d.file_last_modified && local.sourceUpdated) {
|
|
515
|
+
updateAvailable = new Date(d.file_last_modified).getTime() > new Date(local.sourceUpdated).getTime()
|
|
516
|
+
}
|
|
517
|
+
return json(res, 200, {
|
|
518
|
+
ok: true,
|
|
519
|
+
item: {
|
|
520
|
+
id: d.id,
|
|
521
|
+
name: d.display_name,
|
|
522
|
+
description: d.description,
|
|
523
|
+
owner: d.owner || d.developer,
|
|
524
|
+
developer: d.developer,
|
|
525
|
+
category: d.category,
|
|
526
|
+
license: d.license,
|
|
527
|
+
tags: Array.isArray(d.tags) ? d.tags.filter((t) => !t.startsWith('category:') && !t.startsWith('developer:') && !t.startsWith('custom_tag:')) : [],
|
|
528
|
+
views: d.view_count,
|
|
529
|
+
downloads: d.downloads,
|
|
530
|
+
source: d.source_url,
|
|
531
|
+
logo: d.logo_url,
|
|
532
|
+
private: d.private === true,
|
|
533
|
+
installCommand: Array.isArray(d.install_command) ? d.install_command : [],
|
|
534
|
+
fileModified: d.file_last_modified ?? null,
|
|
535
|
+
lastModified: d.last_modified ?? null,
|
|
536
|
+
updateAvailable,
|
|
537
|
+
localSkill: local ? { name: local.name, sourceUpdated: local.sourceUpdated ?? null } : null,
|
|
538
|
+
},
|
|
539
|
+
})
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// GET /api/market/mcp?search=&page=
|
|
543
|
+
async function marketMcpList(req, res, params) {
|
|
544
|
+
const search = params.get('search')?.trim()
|
|
545
|
+
const page = Math.max(1, Number(params.get('page')) || 1)
|
|
546
|
+
const body = { page_number: page, page_size: 20 }
|
|
547
|
+
if (search) body.search = search
|
|
548
|
+
const key = `mcp?${JSON.stringify(body)}`
|
|
549
|
+
let data
|
|
550
|
+
try {
|
|
551
|
+
data = await marketFetch(key, '/mcp/servers', {
|
|
552
|
+
method: 'PUT',
|
|
553
|
+
headers: { 'content-type': 'application/json' },
|
|
554
|
+
body: JSON.stringify(body),
|
|
555
|
+
})
|
|
556
|
+
} catch (error) {
|
|
557
|
+
return marketError(res, error)
|
|
558
|
+
}
|
|
559
|
+
// "configured" = already present in the local patch's mcp-* block (matched
|
|
560
|
+
// by the server's own id/args/url text appearing in a local entry).
|
|
561
|
+
let localMatches = []
|
|
562
|
+
try {
|
|
563
|
+
const text = await readFile(patchPath(), 'utf8')
|
|
564
|
+
const found = findMcpBlock(text)
|
|
565
|
+
if (found) {
|
|
566
|
+
const configured = parseMcpServers(found.blockText).map((s) => (s.command + ' ' + (Array.isArray(s.args) ? s.args.join(' ') : s.args || '') + ' ' + (s.url || '')).toLowerCase())
|
|
567
|
+
localMatches = configured.filter(Boolean)
|
|
568
|
+
}
|
|
569
|
+
} catch {
|
|
570
|
+
// patch missing/unreadable: no configured markers, market still works
|
|
571
|
+
}
|
|
572
|
+
const items = (data?.data?.mcp_server_list ?? []).map((s) => ({
|
|
573
|
+
id: s.id,
|
|
574
|
+
name: s.chinese_name || s.name,
|
|
575
|
+
description: s.description,
|
|
576
|
+
publisher: s.publisher,
|
|
577
|
+
tags: s.tags,
|
|
578
|
+
logo: s.logo_url,
|
|
579
|
+
views: s.view_count,
|
|
580
|
+
categories: s.categories,
|
|
581
|
+
configured: localMatches.some((local) => local.includes(s.id.toLowerCase())),
|
|
582
|
+
}))
|
|
583
|
+
return json(res, 200, { ok: true, items })
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Reduce a JSONSchema env block to its variable list (never ship the raw
|
|
587
|
+
// schema wholesale — it can be large and contains no browsing value).
|
|
588
|
+
function envSchemaSummary(schema) {
|
|
589
|
+
if (!schema || typeof schema !== 'object' || !schema.properties || typeof schema.properties !== 'object') return undefined
|
|
590
|
+
const required = Array.isArray(schema.required) ? schema.required : []
|
|
591
|
+
return Object.entries(schema.properties).map(([key, v]) => ({
|
|
592
|
+
key,
|
|
593
|
+
required: required.includes(key),
|
|
594
|
+
hint: typeof v === 'object' && v ? (v.title || v.description || '') : '',
|
|
595
|
+
}))
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// GET /api/market/mcp/:id
|
|
599
|
+
async function marketMcpDetail(res, id) {
|
|
600
|
+
let data
|
|
601
|
+
try {
|
|
602
|
+
data = await marketFetch(`mcp/${id}`, `/mcp/servers/${id}`)
|
|
603
|
+
} catch (error) {
|
|
604
|
+
return marketError(res, error)
|
|
605
|
+
}
|
|
606
|
+
const d = data?.data ?? {}
|
|
607
|
+
return json(res, 200, {
|
|
608
|
+
ok: true,
|
|
609
|
+
item: {
|
|
610
|
+
id: d.id,
|
|
611
|
+
name: d.chinese_name || d.name,
|
|
612
|
+
description: d.description,
|
|
613
|
+
author: d.author,
|
|
614
|
+
publisher: d.publisher,
|
|
615
|
+
owner: d.owner,
|
|
616
|
+
hosted: d.is_hosted === true, // "Hosted" tick on the plaza page
|
|
617
|
+
verified: d.is_verified === true, // certification tick
|
|
618
|
+
stars: d.github_stars,
|
|
619
|
+
source: d.source_url,
|
|
620
|
+
logo: d.logo_url,
|
|
621
|
+
categories: d.categories,
|
|
622
|
+
tags: d.tags,
|
|
623
|
+
envSchema: envSchemaSummary(d.env_schema),
|
|
624
|
+
// server_config holds ready-to-paste { mcpServers: {...} } variants.
|
|
625
|
+
serverConfig: Array.isArray(d.server_config) ? d.server_config : undefined,
|
|
626
|
+
readme: typeof d.readme === 'string' ? d.readme.slice(0, 2000) : '',
|
|
627
|
+
},
|
|
628
|
+
})
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// GET /api/skills/check-updates
|
|
632
|
+
// For every installed skill that carries a `source`, pull the market detail
|
|
633
|
+
// (cache-backed) and compare its file_last_modified against the locally
|
|
634
|
+
// recorded sourceUpdated. No files are touched.
|
|
635
|
+
async function checkSkillUpdates(res) {
|
|
636
|
+
const local = (await listUserSkills().catch(() => [])).filter((s) => s.source)
|
|
637
|
+
const out = []
|
|
638
|
+
for (const skill of local) {
|
|
639
|
+
const id = skill.source
|
|
640
|
+
let marketModified = null
|
|
641
|
+
let updateAvailable = false
|
|
642
|
+
try {
|
|
643
|
+
const data = await marketFetch(`skills/${id}`, `/skills/${id}`)
|
|
644
|
+
marketModified = data?.data?.file_last_modified ?? null
|
|
645
|
+
if (marketModified && skill.sourceUpdated) {
|
|
646
|
+
updateAvailable = new Date(marketModified).getTime() > new Date(skill.sourceUpdated).getTime()
|
|
647
|
+
}
|
|
648
|
+
} catch {
|
|
649
|
+
// market unreachable: report the entry without an update verdict
|
|
650
|
+
}
|
|
651
|
+
out.push({
|
|
652
|
+
name: skill.name,
|
|
653
|
+
source: id,
|
|
654
|
+
sourceUpdated: skill.sourceUpdated ?? null,
|
|
655
|
+
marketModified,
|
|
656
|
+
updateAvailable,
|
|
657
|
+
})
|
|
658
|
+
}
|
|
659
|
+
return json(res, 200, { ok: true, items: out })
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// POST /api/skills/:name/source { marketId, modified }
|
|
663
|
+
// Links an installed skill to its marketplace entry by writing `source` and
|
|
664
|
+
// `sourceUpdated` into that skill's own frontmatter — the only disk write in
|
|
665
|
+
// the whole market feature, and it happens only on an explicit user action.
|
|
666
|
+
async function recordSkillSource(res, name, body) {
|
|
667
|
+
if (!SKILL_NAME.test(name)) return json(res, 400, { error: 'invalid skill name' })
|
|
668
|
+
const marketId = typeof body?.marketId === 'string' && MARKET_ID.test(body.marketId) ? body.marketId : null
|
|
669
|
+
if (!marketId) return json(res, 400, { error: 'invalid marketId (expect @author/name)' })
|
|
670
|
+
const skill = await readUserSkill(resolveHome(), name)
|
|
671
|
+
if (!skill) return json(res, 404, { error: 'skill not found' })
|
|
672
|
+
skill.source = marketId
|
|
673
|
+
skill.sourceUpdated = typeof body.modified === 'string' && body.modified ? body.modified : new Date().toISOString().slice(0, 19) + 'Z'
|
|
674
|
+
await saveUserSkill(resolveHome(), skill)
|
|
675
|
+
return json(res, 200, { ok: true, source: marketId })
|
|
676
|
+
}
|
|
677
|
+
|
|
421
678
|
/* ─────────────────────── MCP server validation ─────────────────────────
|
|
422
679
|
* The authoritative gate between the UI and the patch file: every field the
|
|
423
680
|
* user can type is checked against the exact contract dsh-mcp-client enforces
|
|
@@ -619,6 +876,46 @@ export function apply(ctx) {
|
|
|
619
876
|
return json(res, 200, { ok: true, path: written.path })
|
|
620
877
|
}
|
|
621
878
|
|
|
879
|
+
// GET /api/skills/check-updates — update hints for sourced skills
|
|
880
|
+
if (req.method === 'GET' && path === API_PREFIX + '/skills/check-updates') {
|
|
881
|
+
return checkSkillUpdates(res)
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// POST /api/skills/:name/source — link an installed skill to a market id
|
|
885
|
+
const srcMatch = path.match(new RegExp('^' + API_PREFIX + '/skills/([^/]+)/source$'))
|
|
886
|
+
if (req.method === 'POST' && srcMatch) {
|
|
887
|
+
const body = await readBody(req)
|
|
888
|
+
return recordSkillSource(res, decodeURIComponent(srcMatch[1]), body)
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/* ── marketplace (read-only ModelScope proxy) ── */
|
|
892
|
+
|
|
893
|
+
// GET /api/market/skills?search=&category=&page=
|
|
894
|
+
if (req.method === 'GET' && path === API_PREFIX + '/market/skills') {
|
|
895
|
+
return marketSkillsList(req, res, new URL(req.url, 'http://localhost').searchParams)
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// GET /api/market/skills/:id (id `@author/name` contains a slash)
|
|
899
|
+
const mktSkill = path.match(new RegExp('^' + API_PREFIX + '/market/skills/(.+)$'))
|
|
900
|
+
if (req.method === 'GET' && mktSkill) {
|
|
901
|
+
const id = decodeURIComponent(mktSkill[1])
|
|
902
|
+
if (!MARKET_ID.test(id)) return json(res, 400, { error: 'invalid market id' })
|
|
903
|
+
return marketSkillDetail(res, id)
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// GET /api/market/mcp?search=&page=
|
|
907
|
+
if (req.method === 'GET' && path === API_PREFIX + '/market/mcp') {
|
|
908
|
+
return marketMcpList(req, res, new URL(req.url, 'http://localhost').searchParams)
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// GET /api/market/mcp/:id
|
|
912
|
+
const mktMcp = path.match(new RegExp('^' + API_PREFIX + '/market/mcp/(.+)$'))
|
|
913
|
+
if (req.method === 'GET' && mktMcp) {
|
|
914
|
+
const id = decodeURIComponent(mktMcp[1])
|
|
915
|
+
if (!MARKET_ID.test(id)) return json(res, 400, { error: 'invalid market id' })
|
|
916
|
+
return marketMcpDetail(res, id)
|
|
917
|
+
}
|
|
918
|
+
|
|
622
919
|
const skMatch = path.match(new RegExp('^' + API_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '/skills/([^/]+)$'))
|
|
623
920
|
|
|
624
921
|
// GET /api/skills/:name — get one
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@omdp/dsh-connector",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Unified DeepSeek Harness connector: edit MCP servers (cordis.patch.yml)
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Unified DeepSeek Harness connector: edit MCP servers (cordis.patch.yml), user skills (~/.dsh/skills) and browse the ModelScope MCP/Skills marketplaces from one Web UI settings page.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"exports": {
|