dsh-plugin-dynamic-competition-four-images 1.1.0 → 1.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 +40 -49
- package/client.js +441 -0
- package/cordis.patch.yml +3 -12
- package/icon.svg +13 -0
- package/index.js +244 -259
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +13 -3
- /package/{.dsh/skills → skills}/dynamic-competition-analysis-four-images-model/SKILL.md +0 -0
- /package/{.dsh/skills → skills}/dynamic-competition-analysis-four-images-model/examples/example_a.json +0 -0
- /package/{.dsh/skills → skills}/dynamic-competition-analysis-four-images-model/examples/example_b.json +0 -0
- /package/{.dsh/skills → skills}/dynamic-competition-analysis-four-images-model/reference/four-images-model-governance.md +0 -0
- /package/{.dsh/skills → skills}/dynamic-competition-analysis-four-images-model/references/full-spec.md +0 -0
- /package/{.dsh/skills → skills}/dynamic-competition-analysis-four-images-model/scripts/dynamic_competition_skill.py +0 -0
package/README.md
CHANGED
|
@@ -2,86 +2,77 @@
|
|
|
2
2
|
|
|
3
3
|
基于动态竞争理论构建的 DSH(DeepSeek Harness)插件,经过以「四象模型」为框架的改进,提供六维动态竞争评分能力。
|
|
4
4
|
|
|
5
|
-
**自 1.
|
|
5
|
+
**自 1.2.0 起:插件全局安装,插件内的技能由用户按工作区选择性添加**(设置 → 插件 → 四象模型),技能只写入被选择的工作区,不会成为全局技能。
|
|
6
6
|
|
|
7
7
|
## ✨ 核心能力
|
|
8
8
|
|
|
9
9
|
- 六维动态竞争评分(主 / 环 / 位 / 行 / 才 / 心)。
|
|
10
10
|
- AMC 张力分析、多点克制逻辑与十三项自检机制。
|
|
11
|
-
-
|
|
11
|
+
- 技能正文:包内 `skills/dynamic-competition-analysis-four-images-model/SKILL.md`。
|
|
12
12
|
|
|
13
|
-
##
|
|
14
|
-
|
|
15
|
-
DSH 的技能来自两级目录,本插件只使用工作区一级:
|
|
16
|
-
|
|
17
|
-
| 层级 | 目录 | 来源标记 | 本插件的做法 |
|
|
18
|
-
|---|---|---|---|
|
|
19
|
-
| 全局技能 | `<DSH_HOME>/skills`(如 `~/.dsh/skills`) | `user-dsh` | **不再写入**,也不注册全局技能 |
|
|
20
|
-
| 工作区技能 | `<项目根>/.dsh/skills` 或 `<项目根>/.agents/skills` | `project-dsh` / `project-agents` | 技能本体随包发布于此;插件也只在配置的工作区提供它 |
|
|
21
|
-
|
|
22
|
-
项目根 = 最近的含 `.git` 的祖先目录;没有 `.git` 时取会话工作目录。
|
|
23
|
-
|
|
24
|
-
## 🚀 安装与配置
|
|
13
|
+
## 🚀 安装
|
|
25
14
|
|
|
26
15
|
### 方式一:通过包名安装(推荐)
|
|
27
16
|
|
|
28
|
-
1. 在 DSH 插件页面点击 **「+ 添加插件」**,输入包名 `dsh-plugin-dynamic-competition-four-images
|
|
29
|
-
2.
|
|
30
|
-
|
|
31
|
-
```yaml
|
|
32
|
-
- id: dynamic-competition-four-images
|
|
33
|
-
config:
|
|
34
|
-
workspaces:
|
|
35
|
-
- D:\Path\To\Your\Workspace # 绝对路径;可写多条
|
|
36
|
-
# - D:\Another\Workspace
|
|
37
|
-
```
|
|
38
|
-
|
|
39
|
-
3. 重启 DSH 生效。未配置 `workspaces` 时插件不提供技能(工作区里自带的 `.dsh/skills` 副本仍会被 DSH 原生发现)。
|
|
17
|
+
1. 在 DSH 插件页面点击 **「+ 添加插件」**,输入包名 `dsh-plugin-dynamic-competition-four-images`,安装并启用。
|
|
18
|
+
2. 退出并重启 DSH(宿主半边的技能桥在启动时注册)。
|
|
19
|
+
3. 安装插件**不会**把技能放进任何工作区,需要按下一节手动添加。
|
|
40
20
|
|
|
41
21
|
### 方式二:通过自定义插件市场安装
|
|
42
22
|
|
|
43
|
-
1.
|
|
44
|
-
2. 进入「发现」页面,添加索引源 URL:
|
|
23
|
+
1. 开启「自定义插件市场」,添加索引源 URL:
|
|
45
24
|
`https://rootandsource.github.io/dsh-plugin-dynamic-competition-four-images/plugin-index/index.json`
|
|
46
|
-
|
|
25
|
+
2. 搜索「动态竞争」或「四象」,点击卡片安装并启用,然后重启 DSH。
|
|
47
26
|
|
|
48
|
-
|
|
27
|
+
## 🧩 把技能添加到工作区(UI)
|
|
49
28
|
|
|
50
|
-
|
|
51
|
-
|---|---|---|---|
|
|
52
|
-
| `workspaces` | `string[]` | `[]` | 允许使用该技能的工作区绝对路径。会话工作目录位于其中时,插件才提供技能;相对路径与空值会被忽略。 |
|
|
29
|
+
打开 **设置 → 插件 → 四象模型** 页签:
|
|
53
30
|
|
|
54
|
-
|
|
31
|
+
1. 在「目标工作区」里选择一个已有工作区,或点「选择其他目录…」挑一个目录;
|
|
32
|
+
- 状态徽标会显示:未安装 / 有更新可用 / 已是最新。
|
|
33
|
+
2. 点 **「添加到工作区」**。
|
|
34
|
+
3. 成功后会提示写入的文件数,以及安装位置:
|
|
35
|
+
`<工作区>/.dsh/skills/dynamic-competition-analysis-four-images-model/`。
|
|
55
36
|
|
|
56
|
-
|
|
37
|
+
写入后由 DSH 原生 filesystem provider 以 source `project-dsh` 发现,即成为该工作区的工作区技能;其它工作区与全局技能列表都不受影响。
|
|
57
38
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
39
|
+
- **更新**:插件升级后再次点击「添加到工作区」,只会覆盖有变化的文件(内容一致的会跳过)。
|
|
40
|
+
- **移除**:删除该工作区里的 `.dsh/skills/dynamic-competition-analysis-four-images-model/` 目录即可。
|
|
41
|
+
|
|
42
|
+
## 🔁 从旧版(全局技能)迁移
|
|
61
43
|
|
|
62
|
-
|
|
44
|
+
1.1.x 及更早版本把技能复制到 `~/.dsh/skills/`,或按工作区路径自动提供技能。升级到 1.2.0 后:
|
|
63
45
|
|
|
64
|
-
|
|
46
|
+
- 删除旧全局副本(如果还在):
|
|
47
|
+
```powershell
|
|
48
|
+
Remove-Item -Recurse -Force "$env:USERPROFILE\.dsh\skills\dynamic-competition-analysis-four-images-model"
|
|
49
|
+
```
|
|
50
|
+
- 旧版 `workspaces` 配置项已废弃,`cordis.patch.yml` 里针对本插件的 `config.workspaces` 可以删掉。
|
|
51
|
+
- 需要在某个工作区使用技能时,用上面的 UI 重新添加。
|
|
65
52
|
|
|
66
|
-
|
|
53
|
+
## 🧪 校验与开发
|
|
67
54
|
|
|
68
55
|
```powershell
|
|
69
|
-
node
|
|
70
|
-
npm pack --dry-run
|
|
56
|
+
node test/skill-installer.test.mjs # 受限沙箱下请直接运行单文件(node --test test/ 会因 spawn EPERM 失败)
|
|
57
|
+
npm pack --dry-run # 确认 index.js / client.js / skills / locale / icon 随包发布
|
|
71
58
|
```
|
|
72
59
|
|
|
73
|
-
|
|
60
|
+
测试覆盖:frontmatter 解析、技能发现(目录名与 name 一致)、工作区路径校验、文件复制/状态判定(缺失、过期、最新、保留工作区额外文件)、桥路由的 loopback 与只读校验、错误码。
|
|
74
61
|
|
|
75
62
|
## 📁 目录结构
|
|
76
63
|
|
|
77
64
|
```
|
|
78
65
|
dsh-plugin-dynamic-competition-four-images/
|
|
79
|
-
├── index.js #
|
|
80
|
-
├──
|
|
81
|
-
├──
|
|
82
|
-
├──
|
|
66
|
+
├── index.js # 宿主:技能发现 + loopback 桥(describe / install)
|
|
67
|
+
├── client.js # 客户端:设置 → 插件 → 四象模型 页签
|
|
68
|
+
├── cordis.patch.yml # bundle 行
|
|
69
|
+
├── locale/{zh,en}.json # 插件显示名与描述
|
|
70
|
+
├── icon.svg # 插件图标
|
|
71
|
+
├── package.json # dsh.bundle.patch + dsh.client
|
|
72
|
+
├── test/skill-installer.test.mjs
|
|
73
|
+
├── release/ # 打包产物(当前 + 历史版本 tarball)
|
|
83
74
|
├── plugin-index/ # 自定义插件市场索引
|
|
84
|
-
|
|
75
|
+
├── skills/dynamic-competition-analysis-four-images-model/
|
|
85
76
|
├── SKILL.md
|
|
86
77
|
├── reference/four-images-model-governance.md
|
|
87
78
|
├── references/full-spec.md
|
package/client.js
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-plugin-dynamic-competition-four-images — Client 半边
|
|
3
|
+
*
|
|
4
|
+
* 在「设置 → 插件」里增加一个页签:列出插件内的技能,允许用户把它们
|
|
5
|
+
* 添加到指定工作区(写入 <workspace>/.dsh/skills/<name>/)。数据层走宿主
|
|
6
|
+
* 的 loopback 桥(见 index.js),不依赖任何 @Remote 命名空间。
|
|
7
|
+
*
|
|
8
|
+
* 本文件由 dsh.client 加载,注册工厂 id 必须等于包名。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
window.__ModuleLoader__.load({
|
|
12
|
+
id: 'dsh-plugin-dynamic-competition-four-images',
|
|
13
|
+
factory(require) {
|
|
14
|
+
const React = require('react')
|
|
15
|
+
const h = React.createElement
|
|
16
|
+
|
|
17
|
+
const NS = 'dsh-plugin-dynamic-competition-four-images'
|
|
18
|
+
const BRIDGE = '/api/dsh-plugin-dynamic-competition-four-images'
|
|
19
|
+
const SLOT = 'settings.plugins.tab'
|
|
20
|
+
|
|
21
|
+
const ZH = {
|
|
22
|
+
'tab.label': '四象模型',
|
|
23
|
+
title: '四象模型分析技能',
|
|
24
|
+
lead: '把插件内携带的技能安装到指定工作区。安装后技能写入该工作区的 .dsh/skills/,只在该工作区生效,不会成为全局技能。',
|
|
25
|
+
'workspace.label': '目标工作区',
|
|
26
|
+
'workspace.placeholder': '选择工作区…',
|
|
27
|
+
'workspace.pick': '选择其他目录…',
|
|
28
|
+
'workspace.picking': '选择中…',
|
|
29
|
+
'workspace.none': '还没有选择工作区',
|
|
30
|
+
'workspace.custom': '自定义目录',
|
|
31
|
+
install: '添加到工作区',
|
|
32
|
+
installing: '正在添加…',
|
|
33
|
+
'skills.empty': '插件内没有找到可用技能。',
|
|
34
|
+
'status.unselected': '未选择工作区',
|
|
35
|
+
'status.missing': '未安装',
|
|
36
|
+
'status.outdated': '有更新可用',
|
|
37
|
+
'status.up-to-date': '已是最新',
|
|
38
|
+
'result.installed': '已添加到工作区',
|
|
39
|
+
'result.updated': '已更新',
|
|
40
|
+
'result.unchanged': '内容一致,无需更新',
|
|
41
|
+
'result.written': '写入 {written} 个文件,共 {total} 个',
|
|
42
|
+
'result.target': '安装位置:{path}',
|
|
43
|
+
'error.prefix': '操作失败:',
|
|
44
|
+
'error.describe': '无法读取插件技能列表',
|
|
45
|
+
'error.noWorkspace': '请先选择一个工作区',
|
|
46
|
+
'error.bridge': '宿主桥不可用,请重启 DSH 后重试',
|
|
47
|
+
version: '版本 {version}',
|
|
48
|
+
note: '重复添加只会覆盖有变化的文件;删除工作区里的该目录即可移除技能。',
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const EN = {
|
|
52
|
+
'tab.label': 'Four Images',
|
|
53
|
+
title: 'Four Images analysis skill',
|
|
54
|
+
lead: 'Install the skill shipped with this plugin into a workspace. It is written to that workspace’s .dsh/skills/ and stays scoped to that workspace; it never becomes a global skill.',
|
|
55
|
+
'workspace.label': 'Target workspace',
|
|
56
|
+
'workspace.placeholder': 'Choose a workspace…',
|
|
57
|
+
'workspace.pick': 'Choose another folder…',
|
|
58
|
+
'workspace.picking': 'Choosing…',
|
|
59
|
+
'workspace.none': 'No workspace selected yet',
|
|
60
|
+
'workspace.custom': 'Custom folder',
|
|
61
|
+
install: 'Add to workspace',
|
|
62
|
+
installing: 'Adding…',
|
|
63
|
+
'skills.empty': 'No skill found inside this plugin.',
|
|
64
|
+
'status.unselected': 'No workspace selected',
|
|
65
|
+
'status.missing': 'Not installed',
|
|
66
|
+
'status.outdated': 'Update available',
|
|
67
|
+
'status.up-to-date': 'Up to date',
|
|
68
|
+
'result.installed': 'Added to the workspace',
|
|
69
|
+
'result.updated': 'Updated',
|
|
70
|
+
'result.unchanged': 'Already up to date',
|
|
71
|
+
'result.written': '{written} of {total} files written',
|
|
72
|
+
'result.target': 'Installed at {path}',
|
|
73
|
+
'error.prefix': 'Failed: ',
|
|
74
|
+
'error.describe': 'Could not read the skill list',
|
|
75
|
+
'error.noWorkspace': 'Choose a workspace first',
|
|
76
|
+
'error.bridge': 'Host bridge unavailable; restart DSH and retry',
|
|
77
|
+
version: 'Version {version}',
|
|
78
|
+
note: 'Re-adding only overwrites files that changed; delete that folder in the workspace to remove the skill.',
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const STATUS_TONE = {
|
|
82
|
+
missing: 'var(--dsw-alias-state-warn-primary)',
|
|
83
|
+
outdated: 'var(--dsw-alias-state-warn-primary)',
|
|
84
|
+
'up-to-date': 'var(--dsw-alias-state-success-primary)',
|
|
85
|
+
unselected: 'var(--dsw-alias-state-idle-primary)',
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function format(template, params) {
|
|
89
|
+
return template.replace(/\{(\w+)\}/g, (whole, key) => (params && key in params ? String(params[key]) : whole))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 调宿主桥;返回 value,失败抛错。 */
|
|
93
|
+
async function call(op, body) {
|
|
94
|
+
const response = await fetch(`${BRIDGE}/${op}`, {
|
|
95
|
+
method: 'POST',
|
|
96
|
+
headers: { 'content-type': 'application/json' },
|
|
97
|
+
body: JSON.stringify(body ?? {}),
|
|
98
|
+
})
|
|
99
|
+
let payload
|
|
100
|
+
try {
|
|
101
|
+
payload = await response.json()
|
|
102
|
+
} catch {
|
|
103
|
+
throw new Error(`HTTP ${response.status}`)
|
|
104
|
+
}
|
|
105
|
+
if (payload === null || typeof payload !== 'object') throw new Error(`HTTP ${response.status}`)
|
|
106
|
+
if (payload.ok !== true) throw new Error(payload.message || payload.code || `HTTP ${response.status}`)
|
|
107
|
+
return payload.value
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function workspaceList(snapshot) {
|
|
111
|
+
const raw = snapshot && (snapshot.workspaces ?? snapshot.items ?? snapshot)
|
|
112
|
+
const list = []
|
|
113
|
+
if (!Array.isArray(raw)) return list
|
|
114
|
+
for (const item of raw) {
|
|
115
|
+
if (item === null || typeof item !== 'object') continue
|
|
116
|
+
const path = typeof item.path === 'string' ? item.path : undefined
|
|
117
|
+
if (path === undefined || path === '') continue
|
|
118
|
+
const title = typeof item.title === 'string' && item.title !== '' ? item.title : path
|
|
119
|
+
list.push({ id: String(item.workspaceId ?? item.id ?? path), title, path })
|
|
120
|
+
}
|
|
121
|
+
return list
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const cardStyle = {
|
|
125
|
+
border: '1px solid var(--dsw-alias-border-l1)',
|
|
126
|
+
borderRadius: 12,
|
|
127
|
+
background: 'var(--dsw-alias-bg-layer-1)',
|
|
128
|
+
padding: '16px 18px',
|
|
129
|
+
display: 'grid',
|
|
130
|
+
gap: 14,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const labelStyle = {
|
|
134
|
+
display: 'block',
|
|
135
|
+
fontSize: 12,
|
|
136
|
+
fontWeight: 600,
|
|
137
|
+
letterSpacing: '0.02em',
|
|
138
|
+
color: 'var(--dsw-alias-label-secondary)',
|
|
139
|
+
marginBottom: 6,
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const boxStyle = {
|
|
143
|
+
width: '100%',
|
|
144
|
+
boxSizing: 'border-box',
|
|
145
|
+
minHeight: 34,
|
|
146
|
+
padding: '6px 10px',
|
|
147
|
+
borderRadius: 8,
|
|
148
|
+
border: '1px solid var(--dsw-alias-border-l2)',
|
|
149
|
+
background: 'var(--dsw-alias-bg-base)',
|
|
150
|
+
color: 'var(--dsw-alias-label-primary)',
|
|
151
|
+
font: 'inherit',
|
|
152
|
+
fontSize: 13,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function buttonStyle(primary, disabled) {
|
|
156
|
+
return {
|
|
157
|
+
minHeight: 34,
|
|
158
|
+
padding: '6px 14px',
|
|
159
|
+
borderRadius: 8,
|
|
160
|
+
border: primary ? '1px solid transparent' : '1px solid var(--dsw-alias-border-l2)',
|
|
161
|
+
background: primary ? 'var(--dsw-alias-brand-primary)' : 'var(--dsw-alias-bg-base)',
|
|
162
|
+
color: primary ? 'var(--dsw-alias-bg-base)' : 'var(--dsw-alias-label-primary)',
|
|
163
|
+
font: 'inherit',
|
|
164
|
+
fontSize: 13,
|
|
165
|
+
fontWeight: 600,
|
|
166
|
+
cursor: disabled ? 'default' : 'pointer',
|
|
167
|
+
opacity: disabled ? 0.55 : 1,
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function badgeStyle(tone) {
|
|
172
|
+
return {
|
|
173
|
+
display: 'inline-flex',
|
|
174
|
+
alignItems: 'center',
|
|
175
|
+
gap: 6,
|
|
176
|
+
padding: '2px 8px',
|
|
177
|
+
borderRadius: 999,
|
|
178
|
+
border: `1px solid ${tone}`,
|
|
179
|
+
color: tone,
|
|
180
|
+
fontSize: 12,
|
|
181
|
+
fontWeight: 600,
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
name: NS,
|
|
187
|
+
inject: ['slots'],
|
|
188
|
+
apply(ctx) {
|
|
189
|
+
const getLocale = () => {
|
|
190
|
+
try {
|
|
191
|
+
return typeof ctx.get === 'function' ? ctx.get('locale') : undefined
|
|
192
|
+
} catch {
|
|
193
|
+
return undefined
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const localeFace = () => {
|
|
197
|
+
const live = getLocale()
|
|
198
|
+
return live !== null && live !== undefined && typeof live.bind === 'function' ? live : null
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// locale 服务是异步 install 的:apply 时可能还没就绪,所以每次用时再取,
|
|
202
|
+
// 词典只注册一次(HMR 重复注册会被运行时拒绝,这里静默忽略)。
|
|
203
|
+
let dictsRegistered = false
|
|
204
|
+
const ensureDicts = () => {
|
|
205
|
+
if (dictsRegistered) return
|
|
206
|
+
const live = localeFace()
|
|
207
|
+
if (live === null) return
|
|
208
|
+
dictsRegistered = true
|
|
209
|
+
try {
|
|
210
|
+
ctx.effect(() => live.register(NS, { zh: ZH, en: EN }))
|
|
211
|
+
} catch {
|
|
212
|
+
// 已有词典(例如插件热更新)时保持现状
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const translate = (key, params) => {
|
|
217
|
+
const live = localeFace()
|
|
218
|
+
if (live !== null) {
|
|
219
|
+
try {
|
|
220
|
+
return format(live.bind(NS)(key, params), params)
|
|
221
|
+
} catch {
|
|
222
|
+
// 绑定失败时退回内置中文
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return format(ZH[key] ?? key, params)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const uiWorkspace = () => {
|
|
229
|
+
try {
|
|
230
|
+
return typeof ctx.get === 'function' ? ctx.get('uiWorkspace') : undefined
|
|
231
|
+
} catch {
|
|
232
|
+
return undefined
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
ensureDicts()
|
|
237
|
+
|
|
238
|
+
function Installer(props) {
|
|
239
|
+
const t = typeof props?.t === 'function' ? (key, params) => format(props.t(key, params), params) : translate
|
|
240
|
+
const snapshot =
|
|
241
|
+
props !== null && typeof props?.useWorkspaces === 'function' ? props.useWorkspaces((value) => value) : undefined
|
|
242
|
+
const workspaces = workspaceList(snapshot)
|
|
243
|
+
|
|
244
|
+
const [path, setPath] = React.useState('')
|
|
245
|
+
const [skills, setSkills] = React.useState([])
|
|
246
|
+
const [version, setVersion] = React.useState('')
|
|
247
|
+
const [error, setError] = React.useState(null)
|
|
248
|
+
const [busy, setBusy] = React.useState(false)
|
|
249
|
+
const [picking, setPicking] = React.useState(false)
|
|
250
|
+
const [notice, setNotice] = React.useState(null)
|
|
251
|
+
|
|
252
|
+
const refresh = React.useCallback(async (workspacePath) => {
|
|
253
|
+
try {
|
|
254
|
+
const value = await call('describe', workspacePath === '' ? {} : { workspacePath })
|
|
255
|
+
setSkills(Array.isArray(value.skills) ? value.skills : [])
|
|
256
|
+
setVersion(typeof value.version === 'string' ? value.version : '')
|
|
257
|
+
setError(null)
|
|
258
|
+
} catch (failure) {
|
|
259
|
+
setSkills([])
|
|
260
|
+
setError(`${t('error.describe')}:${failure instanceof Error ? failure.message : String(failure)}`)
|
|
261
|
+
}
|
|
262
|
+
}, [t])
|
|
263
|
+
|
|
264
|
+
React.useEffect(() => {
|
|
265
|
+
let cancelled = false
|
|
266
|
+
refresh(path).catch(() => {
|
|
267
|
+
if (!cancelled) setError(t('error.bridge'))
|
|
268
|
+
})
|
|
269
|
+
return () => {
|
|
270
|
+
cancelled = true
|
|
271
|
+
}
|
|
272
|
+
}, [path, refresh, t])
|
|
273
|
+
|
|
274
|
+
const pickDirectory = async () => {
|
|
275
|
+
const service = uiWorkspace()
|
|
276
|
+
if (service === undefined || typeof service.pickDirectory !== 'function') return
|
|
277
|
+
setPicking(true)
|
|
278
|
+
try {
|
|
279
|
+
const picked = await service.pickDirectory()
|
|
280
|
+
if (typeof picked === 'string' && picked !== '') {
|
|
281
|
+
setPath(picked)
|
|
282
|
+
setNotice(null)
|
|
283
|
+
}
|
|
284
|
+
} catch (failure) {
|
|
285
|
+
setNotice({ kind: 'error', text: `${t('error.prefix')}${failure instanceof Error ? failure.message : String(failure)}` })
|
|
286
|
+
} finally {
|
|
287
|
+
setPicking(false)
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const install = async () => {
|
|
292
|
+
if (path === '') {
|
|
293
|
+
setNotice({ kind: 'error', text: t('error.noWorkspace') })
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
setBusy(true)
|
|
297
|
+
setNotice(null)
|
|
298
|
+
try {
|
|
299
|
+
const value = await call('install', { workspacePath: path })
|
|
300
|
+
const tone = value.status === 'unchanged' ? 'warn' : 'ok'
|
|
301
|
+
const head = t(`result.${value.status}`)
|
|
302
|
+
const detail = format(t('result.written'), { written: value.written, total: value.total })
|
|
303
|
+
setNotice({ kind: tone, text: `${head} · ${detail}`, target: format(t('result.target'), { path: value.targetPath }) })
|
|
304
|
+
await refresh(path)
|
|
305
|
+
} catch (failure) {
|
|
306
|
+
setNotice({ kind: 'error', text: `${t('error.prefix')}${failure instanceof Error ? failure.message : String(failure)}` })
|
|
307
|
+
} finally {
|
|
308
|
+
setBusy(false)
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const options = workspaces.map((workspace) => ({ value: workspace.path, label: `${workspace.title} — ${workspace.path}` }))
|
|
313
|
+
if (path !== '' && !options.some((option) => option.value === path)) {
|
|
314
|
+
options.push({ value: path, label: `${t('workspace.custom')} — ${path}` })
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const noticeTone =
|
|
318
|
+
notice === null
|
|
319
|
+
? 'var(--dsw-alias-label-secondary)'
|
|
320
|
+
: notice.kind === 'error'
|
|
321
|
+
? 'var(--dsw-alias-state-error-primary)'
|
|
322
|
+
: notice.kind === 'warn'
|
|
323
|
+
? 'var(--dsw-alias-state-warn-primary)'
|
|
324
|
+
: 'var(--dsw-alias-state-success-primary)'
|
|
325
|
+
|
|
326
|
+
return h('div', { style: { display: 'grid', gap: 16, padding: '8px 0', maxWidth: 760, color: 'var(--dsw-alias-label-primary)' } }, [
|
|
327
|
+
h('div', { key: 'head' }, [
|
|
328
|
+
h('div', { key: 'title', style: { fontSize: 16, fontWeight: 600 } }, t('title')),
|
|
329
|
+
h(
|
|
330
|
+
'p',
|
|
331
|
+
{ key: 'lead', style: { margin: '6px 0 0', fontSize: 13, lineHeight: 1.6, color: 'var(--dsw-alias-label-secondary)' } },
|
|
332
|
+
t('lead'),
|
|
333
|
+
),
|
|
334
|
+
version === ''
|
|
335
|
+
? null
|
|
336
|
+
: h(
|
|
337
|
+
'p',
|
|
338
|
+
{ key: 'version', style: { margin: '4px 0 0', fontSize: 12, color: 'var(--dsw-alias-state-idle-primary)' } },
|
|
339
|
+
format(t('version'), { version }),
|
|
340
|
+
),
|
|
341
|
+
]),
|
|
342
|
+
|
|
343
|
+
h('div', { key: 'card', style: cardStyle }, [
|
|
344
|
+
h('div', { key: 'skills', style: { display: 'grid', gap: 8 } }, [
|
|
345
|
+
skills.length === 0
|
|
346
|
+
? h('div', { key: 'empty', style: { fontSize: 13, color: 'var(--dsw-alias-label-secondary)' } }, error ?? t('skills.empty'))
|
|
347
|
+
: null,
|
|
348
|
+
...skills.map((skill) =>
|
|
349
|
+
h('div', { key: skill.name, style: { display: 'grid', gap: 4 } }, [
|
|
350
|
+
h('div', { key: 'line', style: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' } }, [
|
|
351
|
+
h('code', { key: 'name', style: { fontSize: 13, fontWeight: 600 } }, skill.name),
|
|
352
|
+
h('span', { key: 'badge', style: badgeStyle(STATUS_TONE[skill.status] ?? STATUS_TONE.unselected) }, t(`status.${skill.status}`)),
|
|
353
|
+
]),
|
|
354
|
+
h(
|
|
355
|
+
'div',
|
|
356
|
+
{ key: 'desc', style: { fontSize: 12, lineHeight: 1.6, color: 'var(--dsw-alias-label-secondary)' } },
|
|
357
|
+
skill.description,
|
|
358
|
+
),
|
|
359
|
+
]),
|
|
360
|
+
),
|
|
361
|
+
]),
|
|
362
|
+
|
|
363
|
+
h('div', { key: 'picker', style: { display: 'grid', gap: 8 } }, [
|
|
364
|
+
h('label', { key: 'label', style: labelStyle, htmlFor: `${NS}-workspace` }, t('workspace.label')),
|
|
365
|
+
h('div', { key: 'row', style: { display: 'flex', gap: 8, flexWrap: 'wrap' } }, [
|
|
366
|
+
h(
|
|
367
|
+
'select',
|
|
368
|
+
{
|
|
369
|
+
key: 'select',
|
|
370
|
+
id: `${NS}-workspace`,
|
|
371
|
+
value: path,
|
|
372
|
+
style: { ...boxStyle, flex: '1 1 260px' },
|
|
373
|
+
onChange: (event) => {
|
|
374
|
+
setPath(event.target.value)
|
|
375
|
+
setNotice(null)
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
[
|
|
379
|
+
h('option', { key: 'placeholder', value: '' }, t('workspace.placeholder')),
|
|
380
|
+
...options.map((option) => h('option', { key: option.value, value: option.value }, option.label)),
|
|
381
|
+
],
|
|
382
|
+
),
|
|
383
|
+
h(
|
|
384
|
+
'button',
|
|
385
|
+
{ key: 'pick', type: 'button', style: buttonStyle(false, picking), disabled: picking, onClick: pickDirectory },
|
|
386
|
+
picking ? t('workspace.picking') : t('workspace.pick'),
|
|
387
|
+
),
|
|
388
|
+
]),
|
|
389
|
+
path === ''
|
|
390
|
+
? h('div', { key: 'none', style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)' } }, t('workspace.none'))
|
|
391
|
+
: h('div', { key: 'path', style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)', wordBreak: 'break-all' } }, path),
|
|
392
|
+
]),
|
|
393
|
+
|
|
394
|
+
h('div', { key: 'actions', style: { display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' } }, [
|
|
395
|
+
h(
|
|
396
|
+
'button',
|
|
397
|
+
{
|
|
398
|
+
key: 'install',
|
|
399
|
+
type: 'button',
|
|
400
|
+
style: buttonStyle(true, busy || path === '' || skills.length === 0),
|
|
401
|
+
disabled: busy || path === '' || skills.length === 0,
|
|
402
|
+
onClick: install,
|
|
403
|
+
},
|
|
404
|
+
busy ? t('installing') : t('install'),
|
|
405
|
+
),
|
|
406
|
+
notice === null
|
|
407
|
+
? null
|
|
408
|
+
: h(
|
|
409
|
+
'div',
|
|
410
|
+
{ key: 'notice', style: { fontSize: 12, lineHeight: 1.6, color: noticeTone, display: 'grid', gap: 2 } }, [
|
|
411
|
+
h('div', { key: 'text' }, notice.text),
|
|
412
|
+
notice.target === undefined ? null : h('div', { key: 'target', style: { color: 'var(--dsw-alias-label-secondary)', wordBreak: 'break-all' } }, notice.target),
|
|
413
|
+
],
|
|
414
|
+
),
|
|
415
|
+
]),
|
|
416
|
+
|
|
417
|
+
h(
|
|
418
|
+
'p',
|
|
419
|
+
{ key: 'note', style: { margin: 0, fontSize: 12, lineHeight: 1.6, color: 'var(--dsw-alias-label-secondary)' } },
|
|
420
|
+
t('note'),
|
|
421
|
+
),
|
|
422
|
+
]),
|
|
423
|
+
])
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
ctx.slots.inject(SLOT, () => {
|
|
427
|
+
// 设置面板打开时 locale 一定已就绪:补充注册词典,并按现状决定 locale 席位
|
|
428
|
+
ensureDicts()
|
|
429
|
+
const registration = {
|
|
430
|
+
name: SLOT,
|
|
431
|
+
id: 'dynamic-competition-four-images',
|
|
432
|
+
order: 40,
|
|
433
|
+
label: () => translate('tab.label'),
|
|
434
|
+
...(localeFace() === null ? {} : { locale: NS }),
|
|
435
|
+
}
|
|
436
|
+
return ctx.slots.register(registration, Installer)
|
|
437
|
+
})
|
|
438
|
+
},
|
|
439
|
+
}
|
|
440
|
+
},
|
|
441
|
+
})
|
package/cordis.patch.yml
CHANGED
|
@@ -1,17 +1,8 @@
|
|
|
1
1
|
# dsh-plugin-dynamic-competition-four-images bundle patch.
|
|
2
2
|
#
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
# 在 profile 的 cordis.patch.yml 里覆盖本行的 config 即可启用指定工作区:
|
|
7
|
-
#
|
|
8
|
-
# - id: dynamic-competition-four-images
|
|
9
|
-
# config:
|
|
10
|
-
# workspaces:
|
|
11
|
-
# - D:\Path\To\Your\Workspace
|
|
12
|
-
#
|
|
3
|
+
# 插件本身全局安装;技能不会自动出现在任何工作区。
|
|
4
|
+
# 用户在「设置 → 插件 → 四象模型」页签里选择目标工作区,点击“添加到工作区”,
|
|
5
|
+
# 宿主才把技能写入 <workspace>/.dsh/skills/<name>/。
|
|
13
6
|
- insert:
|
|
14
7
|
- id: dynamic-competition-four-images
|
|
15
8
|
name: dsh-plugin-dynamic-competition-four-images
|
|
16
|
-
config:
|
|
17
|
-
workspaces: []
|
package/icon.svg
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Four Images">
|
|
2
|
+
<rect width="64" height="64" rx="14" fill="#0f172a"/>
|
|
3
|
+
<g fill="none" stroke="#e6edf7" stroke-width="2" opacity="0.85">
|
|
4
|
+
<rect x="14" y="14" width="16" height="16" rx="4"/>
|
|
5
|
+
<rect x="34" y="14" width="16" height="16" rx="4"/>
|
|
6
|
+
<rect x="14" y="34" width="16" height="16" rx="4"/>
|
|
7
|
+
<rect x="34" y="34" width="16" height="16" rx="4"/>
|
|
8
|
+
</g>
|
|
9
|
+
<path d="M22 22h.01" stroke="#7dd3fc" stroke-width="8" stroke-linecap="round"/>
|
|
10
|
+
<path d="M42 22h.01" stroke="#fbbf24" stroke-width="8" stroke-linecap="round"/>
|
|
11
|
+
<path d="M22 42h.01" stroke="#f87171" stroke-width="8" stroke-linecap="round"/>
|
|
12
|
+
<path d="M42 42h.01" stroke="#34d399" stroke-width="8" stroke-linecap="round"/>
|
|
13
|
+
</svg>
|
package/index.js
CHANGED
|
@@ -1,332 +1,317 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-plugin-dynamic-competition-four-images
|
|
2
|
+
* dsh-plugin-dynamic-competition-four-images — 宿主半边
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 插件本身安装在 DSH profile(全局)。它不注册任何全局技能,也不自动把技能
|
|
5
|
+
* 注入工作区;只提供两个 loopback 桥接口,供 Client 半边的设置页使用:
|
|
5
6
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* 工作区中的会话由 DSH 原生 filesystem provider 以 source `project-dsh` 发现;
|
|
9
|
-
* 2. 本插件按 `config.workspaces` 指定的工作区提供同一份技能,供未把技能文件
|
|
10
|
-
* 放进工作区、但已安装本插件的场景使用。
|
|
7
|
+
* POST /api/dsh-plugin-dynamic-competition-four-images/describe
|
|
8
|
+
* POST /api/dsh-plugin-dynamic-competition-four-images/install
|
|
11
9
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* `install` 把包内 `.dsh/skills/<name>/` 复制到用户选择的工作区
|
|
11
|
+
* `<workspace>/.dsh/skills/<name>/`,此后由 DSH 原生 filesystem provider 以
|
|
12
|
+
* source `project-dsh` 发现,即成为该工作区的工作区技能。
|
|
14
13
|
*
|
|
15
14
|
* @module dsh-plugin-dynamic-competition-four-images
|
|
16
15
|
*/
|
|
17
16
|
|
|
18
|
-
import { readFile, stat } from 'node:fs/promises'
|
|
19
|
-
import { dirname, isAbsolute, join,
|
|
17
|
+
import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises'
|
|
18
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
20
19
|
import { fileURLToPath } from 'node:url'
|
|
21
20
|
|
|
22
21
|
/** Cordis 插件名。 */
|
|
23
22
|
export const name = 'dynamic-competition-four-images'
|
|
24
23
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
const SKILLS_ROOT = fileURLToPath(new URL('./.dsh/skills/', import.meta.url))
|
|
31
|
-
const SKILL_DIR = join(SKILLS_ROOT, SKILL_NAME)
|
|
32
|
-
const SKILL_FILE = join(SKILL_DIR, 'SKILL.md')
|
|
33
|
-
const PROVIDER_NAME = 'dynamic-competition-four-images'
|
|
34
|
-
/** 工作区技能的优先级:低于原生 project-dsh(100),与 bundled(600)区分。 */
|
|
35
|
-
const PROJECT_SKILL_RANK = 700
|
|
36
|
-
/** 以工作区技能来源上报,使界面把它当工作区技能而非全局技能。 */
|
|
37
|
-
const SKILL_SOURCE = 'project-dsh'
|
|
24
|
+
const PACKAGE_ROOT = fileURLToPath(new URL('./', import.meta.url))
|
|
25
|
+
/** 包内技能源目录:随包发布的技能本体(普通目录,避免依赖 .dsh 路径)。 */
|
|
26
|
+
export const SKILLS_ROOT = join(PACKAGE_ROOT, 'skills')
|
|
27
|
+
const BRIDGE_PREFIX = '/api/dsh-plugin-dynamic-competition-four-images'
|
|
28
|
+
const MAX_JSON_BODY_BYTES = 64 * 1024
|
|
38
29
|
const NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
|
39
30
|
|
|
40
|
-
/**
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const { workspaces, ignored } = normalizeWorkspaces(config)
|
|
47
|
-
if (ignored.length > 0) {
|
|
48
|
-
warn(ctx, `忽略 ${ignored.length} 条无效的 workspaces 配置(只接受非空绝对路径):${ignored.join(',')}`)
|
|
49
|
-
}
|
|
50
|
-
if (workspaces.length === 0) {
|
|
51
|
-
warn(ctx, '未配置 workspaces,本插件不提供技能;工作区 .dsh/skills 下的副本仍由 DSH 原生发现。')
|
|
52
|
-
return
|
|
31
|
+
/** 业务错误:桥接口把它翻译成 4xx + code。 */
|
|
32
|
+
export class BridgeError extends Error {
|
|
33
|
+
constructor(code, message) {
|
|
34
|
+
super(message)
|
|
35
|
+
this.name = 'BridgeError'
|
|
36
|
+
this.code = code
|
|
53
37
|
}
|
|
38
|
+
}
|
|
54
39
|
|
|
55
|
-
|
|
56
|
-
|
|
40
|
+
/** 注册 loopback 路由;webServer 缺席时静默不启用。 */
|
|
41
|
+
export function apply(ctx) {
|
|
42
|
+
ctx.inject(['webServer'], (sctx) => {
|
|
43
|
+
sctx.effect(() => {
|
|
44
|
+
const disposers = createBridgeRoutes().map((route) => sctx.webServer.register(route))
|
|
45
|
+
return () => {
|
|
46
|
+
for (const dispose of disposers) dispose()
|
|
47
|
+
}
|
|
48
|
+
}, 'dynamic-competition-four-images: skill bridge')
|
|
49
|
+
})
|
|
50
|
+
}
|
|
57
51
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
52
|
+
/** 组出桥路由(供 webServer.register 与单测使用)。 */
|
|
53
|
+
export function createBridgeRoutes(options = {}) {
|
|
54
|
+
const skillsRoot = options.skillsRoot ?? SKILLS_ROOT
|
|
55
|
+
return [
|
|
56
|
+
defineRoute('/describe', async (body) => {
|
|
57
|
+
const skills = await readSkills(skillsRoot)
|
|
58
|
+
let workspacePath
|
|
59
|
+
if (typeof body?.workspacePath === 'string' && body.workspacePath.trim() !== '') {
|
|
60
|
+
workspacePath = await resolveWorkspace(body.workspacePath)
|
|
61
|
+
}
|
|
62
|
+
const list = []
|
|
63
|
+
for (const skill of skills) {
|
|
64
|
+
list.push({
|
|
65
|
+
name: skill.name,
|
|
66
|
+
description: skill.description,
|
|
67
|
+
status: workspacePath === undefined ? 'unselected' : await skillStatus(skill, workspacePath),
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
return { version: await packageVersion(), workspacePath: workspacePath ?? null, skills: list }
|
|
71
|
+
}),
|
|
62
72
|
|
|
63
|
-
async
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
73
|
+
defineRoute('/install', async (body) => {
|
|
74
|
+
const workspacePath = await resolveWorkspace(body?.workspacePath)
|
|
75
|
+
const skills = await readSkills(skillsRoot)
|
|
76
|
+
const wanted = typeof body?.skillName === 'string' ? body.skillName : skills[0]?.name
|
|
77
|
+
const skill = skills.find((entry) => entry.name === wanted)
|
|
78
|
+
if (skill === undefined) {
|
|
79
|
+
throw new BridgeError('skill-not-found', `插件内没有名为 ${String(wanted)} 的技能`)
|
|
80
|
+
}
|
|
81
|
+
const before = await skillStatus(skill, workspacePath)
|
|
82
|
+
const copied = await installSkill(skill, workspacePath)
|
|
83
|
+
return {
|
|
84
|
+
skillName: skill.name,
|
|
85
|
+
workspacePath,
|
|
86
|
+
targetPath: copied.target,
|
|
87
|
+
status: copied.written === 0 ? 'unchanged' : before === 'missing' ? 'installed' : 'updated',
|
|
88
|
+
written: copied.written,
|
|
89
|
+
unchanged: copied.unchanged,
|
|
90
|
+
total: copied.total,
|
|
91
|
+
}
|
|
92
|
+
}),
|
|
93
|
+
]
|
|
70
94
|
}
|
|
71
95
|
|
|
72
|
-
/**
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
*/
|
|
77
|
-
export function normalizeWorkspaces(config) {
|
|
78
|
-
const raw = config?.workspaces
|
|
79
|
-
const entries = Array.isArray(raw) ? raw : []
|
|
80
|
-
const workspaces = []
|
|
81
|
-
const ignored = []
|
|
82
|
-
const seen = new Set()
|
|
96
|
+
/** 读取包内全部技能(目录名必须与 frontmatter name 一致)。 */
|
|
97
|
+
export async function readSkills(root = SKILLS_ROOT) {
|
|
98
|
+
const entries = await readdir(root, { withFileTypes: true }).catch(() => [])
|
|
99
|
+
const skills = []
|
|
83
100
|
for (const entry of entries) {
|
|
84
|
-
if (
|
|
85
|
-
|
|
101
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
|
|
102
|
+
const directory = join(root, entry.name)
|
|
103
|
+
let raw
|
|
104
|
+
try {
|
|
105
|
+
raw = await readFile(join(directory, 'SKILL.md'), 'utf8')
|
|
106
|
+
} catch {
|
|
86
107
|
continue
|
|
87
108
|
}
|
|
88
|
-
const
|
|
89
|
-
if (
|
|
90
|
-
|
|
91
|
-
continue
|
|
92
|
-
}
|
|
93
|
-
const resolved = resolve(trimmed)
|
|
94
|
-
const key = normalizeForCompare(resolved)
|
|
95
|
-
if (seen.has(key)) continue
|
|
96
|
-
seen.add(key)
|
|
97
|
-
workspaces.push(resolved)
|
|
109
|
+
const parsed = parseSkillMarkdown(raw)
|
|
110
|
+
if (parsed === undefined || parsed.name !== entry.name) continue
|
|
111
|
+
skills.push({ name: parsed.name, description: parsed.description, directory })
|
|
98
112
|
}
|
|
99
|
-
return
|
|
113
|
+
return skills.sort((left, right) => left.name.localeCompare(right.name))
|
|
100
114
|
}
|
|
101
115
|
|
|
102
|
-
/**
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
* @param {string} outer
|
|
106
|
-
*/
|
|
107
|
-
export function isInside(inner, outer) {
|
|
108
|
-
const from = normalizeForCompare(resolve(outer))
|
|
109
|
-
const to = normalizeForCompare(resolve(inner))
|
|
110
|
-
const rel = relative(from, to)
|
|
111
|
-
if (rel === '') return true
|
|
112
|
-
if (rel === '..' || rel.startsWith(`..${sep}`)) return false
|
|
113
|
-
return !isAbsolute(rel)
|
|
116
|
+
/** 目标工作区里该技能的状态:missing / outdated / up-to-date。 */
|
|
117
|
+
export async function skillStatus(skill, workspacePath) {
|
|
118
|
+
return skillStatusAt(skill, targetDirectory(skill.name, workspacePath))
|
|
114
119
|
}
|
|
115
120
|
|
|
116
|
-
/**
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const skillName = scalarField(data.name)
|
|
126
|
-
const description = scalarField(data.description)
|
|
127
|
-
const whenToUse = scalarField(data.whenToUse)
|
|
128
|
-
if (skillName === undefined || description === undefined) return undefined
|
|
129
|
-
if (!NAME_PATTERN.test(skillName)) return undefined
|
|
130
|
-
|
|
131
|
-
const disableModelInvocation = parseBooleanFlag(data['disable-model-invocation'])
|
|
132
|
-
const userInvocable = parseBooleanFlag(data['user-invocable'])
|
|
133
|
-
// 缺省合法(undefined),非法拼写按 DSH 约定整条丢弃。
|
|
134
|
-
if (disableModelInvocation === null || userInvocable === null) return undefined
|
|
135
|
-
|
|
136
|
-
return {
|
|
137
|
-
name: skillName,
|
|
138
|
-
description,
|
|
139
|
-
...(whenToUse === undefined ? {} : { whenToUse }),
|
|
140
|
-
invocation: {
|
|
141
|
-
modelInvocable: !(disableModelInvocation ?? false),
|
|
142
|
-
userInvocable: userInvocable ?? true,
|
|
143
|
-
},
|
|
144
|
-
content: parts.body.trim(),
|
|
121
|
+
/** 目标目录里该技能的状态(测试与复用入口)。 */
|
|
122
|
+
export async function skillStatusAt(skill, target) {
|
|
123
|
+
const exists = await directoryExists(target)
|
|
124
|
+
if (!exists) return 'missing'
|
|
125
|
+
let differing = 0
|
|
126
|
+
for (const file of await walkFiles(skill.directory)) {
|
|
127
|
+
const source = await readFile(file.absolute).catch(() => undefined)
|
|
128
|
+
const current = await readFile(join(target, file.relative)).catch(() => undefined)
|
|
129
|
+
if (source === undefined || current === undefined || !source.equals(current)) differing += 1
|
|
145
130
|
}
|
|
131
|
+
return differing === 0 ? 'up-to-date' : 'outdated'
|
|
146
132
|
}
|
|
147
133
|
|
|
148
|
-
/**
|
|
149
|
-
export
|
|
150
|
-
|
|
151
|
-
/** 读取技能文件;缺失或不可读时返回 undefined。 */
|
|
152
|
-
async function loadSkill(signal) {
|
|
153
|
-
signal?.throwIfAborted()
|
|
154
|
-
let raw
|
|
155
|
-
try {
|
|
156
|
-
raw = await readFile(SKILL_FILE, 'utf8')
|
|
157
|
-
} catch {
|
|
158
|
-
return undefined
|
|
159
|
-
}
|
|
160
|
-
signal?.throwIfAborted()
|
|
161
|
-
return parseSkill(raw)
|
|
134
|
+
/** 把技能复制进工作区;返回写入/未变/总数。 */
|
|
135
|
+
export async function installSkill(skill, workspacePath) {
|
|
136
|
+
return copySkillTo(skill, targetDirectory(skill.name, workspacePath))
|
|
162
137
|
}
|
|
163
138
|
|
|
164
|
-
/**
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
139
|
+
/** 把技能复制到目标目录(测试与复用入口);只覆盖有变化的文件。 */
|
|
140
|
+
export async function copySkillTo(skill, target) {
|
|
141
|
+
const files = await walkFiles(skill.directory)
|
|
142
|
+
let written = 0
|
|
143
|
+
let unchanged = 0
|
|
144
|
+
for (const file of files) {
|
|
145
|
+
const destination = join(target, file.relative)
|
|
146
|
+
const source = await readFile(file.absolute)
|
|
147
|
+
const current = await readFile(destination).catch(() => undefined)
|
|
148
|
+
if (current !== undefined && current.equals(source)) {
|
|
149
|
+
unchanged += 1
|
|
150
|
+
continue
|
|
151
|
+
}
|
|
152
|
+
await mkdir(dirname(destination), { recursive: true })
|
|
153
|
+
await writeFile(destination, source)
|
|
154
|
+
written += 1
|
|
180
155
|
}
|
|
181
|
-
return
|
|
156
|
+
return { target, written, unchanged, total: files.length }
|
|
182
157
|
}
|
|
183
158
|
|
|
184
|
-
/**
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
const skillsRoot = join(root, '.dsh', 'skills')
|
|
188
|
-
return (
|
|
189
|
-
(await fileExists(join(skillsRoot, SKILL_NAME, 'SKILL.md'))) ||
|
|
190
|
-
(await fileExists(join(skillsRoot, `${SKILL_NAME}.md`)))
|
|
191
|
-
)
|
|
159
|
+
/** 工作区技能目标目录:<workspace>/.dsh/skills/<name>。 */
|
|
160
|
+
function targetDirectory(skillName, workspacePath) {
|
|
161
|
+
return join(workspacePath, '.dsh', 'skills', skillName)
|
|
192
162
|
}
|
|
193
163
|
|
|
194
|
-
/**
|
|
195
|
-
async function
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
164
|
+
/** 校验并规范化用户给出的工作区路径。 */
|
|
165
|
+
export async function resolveWorkspace(input) {
|
|
166
|
+
if (typeof input !== 'string' || input.trim() === '') {
|
|
167
|
+
throw new BridgeError('workspace-required', '缺少工作区路径')
|
|
168
|
+
}
|
|
169
|
+
const trimmed = input.trim()
|
|
170
|
+
if (!isAbsolute(trimmed)) {
|
|
171
|
+
throw new BridgeError('workspace-not-absolute', '工作区路径必须是绝对路径')
|
|
172
|
+
}
|
|
173
|
+
const path = resolve(trimmed)
|
|
174
|
+
const info = await stat(path).catch(() => undefined)
|
|
175
|
+
if (info === undefined || !info.isDirectory()) {
|
|
176
|
+
throw new BridgeError('workspace-not-found', '工作区目录不存在或不是目录')
|
|
202
177
|
}
|
|
178
|
+
return path
|
|
203
179
|
}
|
|
204
180
|
|
|
205
|
-
async function
|
|
181
|
+
async function packageVersion() {
|
|
206
182
|
try {
|
|
207
|
-
await
|
|
208
|
-
|
|
183
|
+
const raw = await readFile(join(PACKAGE_ROOT, 'package.json'), 'utf8')
|
|
184
|
+
const parsed = JSON.parse(raw)
|
|
185
|
+
return typeof parsed.version === 'string' ? parsed.version : '0.0.0'
|
|
209
186
|
} catch {
|
|
210
|
-
return
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
function toCandidate(skill) {
|
|
215
|
-
return {
|
|
216
|
-
name: skill.name,
|
|
217
|
-
description: skill.description,
|
|
218
|
-
...(skill.whenToUse === undefined ? {} : { whenToUse: skill.whenToUse }),
|
|
219
|
-
invocation: skill.invocation,
|
|
220
|
-
provider: PROVIDER_NAME,
|
|
221
|
-
source: SKILL_SOURCE,
|
|
222
|
-
resourceBase: { kind: 'directory', path: SKILL_DIR },
|
|
223
|
-
rank: PROJECT_SKILL_RANK,
|
|
224
|
-
locator: { skillFile: SKILL_FILE },
|
|
225
|
-
path: SKILL_FILE,
|
|
187
|
+
return '0.0.0'
|
|
226
188
|
}
|
|
227
189
|
}
|
|
228
190
|
|
|
229
|
-
function
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
content: skill.content,
|
|
191
|
+
async function walkFiles(directory, prefix = '') {
|
|
192
|
+
const entries = await readdir(join(directory, prefix), { withFileTypes: true })
|
|
193
|
+
const files = []
|
|
194
|
+
for (const entry of entries) {
|
|
195
|
+
const relative = prefix === '' ? entry.name : `${prefix}/${entry.name}`
|
|
196
|
+
if (entry.isDirectory()) {
|
|
197
|
+
files.push(...(await walkFiles(directory, relative)))
|
|
198
|
+
continue
|
|
199
|
+
}
|
|
200
|
+
if (entry.isFile() || entry.isSymbolicLink()) files.push({ relative, absolute: join(directory, relative) })
|
|
240
201
|
}
|
|
202
|
+
return files
|
|
241
203
|
}
|
|
242
204
|
|
|
243
|
-
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
/** 解析 YAML 风格布尔值;缺省返回 undefined(调用方决定默认值),非法拼写返回 null。 */
|
|
247
|
-
function parseBooleanFlag(value) {
|
|
248
|
-
if (value === undefined) return undefined
|
|
249
|
-
const normalized = String(value).trim().toLowerCase()
|
|
250
|
-
if (BOOLEAN_TRUE.has(normalized)) return true
|
|
251
|
-
if (BOOLEAN_FALSE.has(normalized)) return false
|
|
252
|
-
return null
|
|
205
|
+
async function directoryExists(path) {
|
|
206
|
+
const info = await stat(path).catch(() => undefined)
|
|
207
|
+
return info !== undefined && info.isDirectory()
|
|
253
208
|
}
|
|
254
209
|
|
|
255
|
-
|
|
256
|
-
|
|
210
|
+
/** loopback 守卫:只接受本机同源 POST。 */
|
|
211
|
+
export function isLoopbackRequest(request) {
|
|
212
|
+
const address = request?.socket?.remoteAddress
|
|
213
|
+
if (address !== '127.0.0.1' && address !== '::1' && address !== '::ffff:127.0.0.1') return false
|
|
214
|
+
const host = request.headers?.host
|
|
215
|
+
if (typeof host !== 'string') return false
|
|
216
|
+
let hostUrl
|
|
217
|
+
try {
|
|
218
|
+
hostUrl = new URL(`http://${host}`)
|
|
219
|
+
} catch {
|
|
220
|
+
return false
|
|
221
|
+
}
|
|
222
|
+
if (hostUrl.hostname !== '127.0.0.1' && hostUrl.hostname !== 'localhost' && hostUrl.hostname !== '[::1]') return false
|
|
223
|
+
if (request.headers['sec-fetch-site'] === 'cross-site') return false
|
|
224
|
+
const origin = request.headers.origin
|
|
225
|
+
if (origin === undefined) return true
|
|
226
|
+
try {
|
|
227
|
+
return new URL(origin).host === hostUrl.host
|
|
228
|
+
} catch {
|
|
229
|
+
return false
|
|
230
|
+
}
|
|
257
231
|
}
|
|
258
232
|
|
|
259
|
-
/**
|
|
260
|
-
function
|
|
233
|
+
/** 解析技能 Markdown 的 frontmatter(本插件只需 name/description)。 */
|
|
234
|
+
export function parseSkillMarkdown(raw) {
|
|
261
235
|
const text = raw.replace(/^\uFEFF/, '')
|
|
262
236
|
const lines = text.split(/\r?\n/)
|
|
263
237
|
if (lines[0]?.trim() !== '---') return undefined
|
|
238
|
+
let end = -1
|
|
264
239
|
for (let index = 1; index < lines.length; index += 1) {
|
|
265
|
-
if (lines[index].trim()
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
body: lines.slice(index + 1).join('\n'),
|
|
240
|
+
if (lines[index].trim() === '---') {
|
|
241
|
+
end = index
|
|
242
|
+
break
|
|
269
243
|
}
|
|
270
244
|
}
|
|
271
|
-
return undefined
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
/** 只解析本技能需要的 YAML 子集:标量、引号、块标量,其余行忽略。 */
|
|
275
|
-
function parseFrontmatter(lines) {
|
|
245
|
+
if (end < 0) return undefined
|
|
276
246
|
const data = {}
|
|
277
|
-
let index =
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
const match = /^([A-Za-z0-9_-]+):(.*)$/.exec(line)
|
|
285
|
-
if (match === null) {
|
|
286
|
-
index += 1
|
|
287
|
-
continue
|
|
288
|
-
}
|
|
289
|
-
const key = match[1]
|
|
290
|
-
const rest = match[2].trim()
|
|
291
|
-
if (rest === '|' || rest === '>' || rest === '|-' || rest === '>-' || rest === '|+' || rest === '>+') {
|
|
292
|
-
const block = []
|
|
293
|
-
index += 1
|
|
294
|
-
while (index < lines.length && (lines[index].startsWith(' ') || lines[index].startsWith('\t'))) {
|
|
295
|
-
block.push(lines[index].trim())
|
|
296
|
-
index += 1
|
|
297
|
-
}
|
|
298
|
-
data[key] = rest.startsWith('|') ? block.join('\n') : block.join(' ').replace(/\s+/g, ' ')
|
|
299
|
-
continue
|
|
300
|
-
}
|
|
301
|
-
data[key] = unquote(rest)
|
|
302
|
-
index += 1
|
|
247
|
+
for (let index = 1; index < end; index += 1) {
|
|
248
|
+
const match = /^([A-Za-z0-9_-]+):(.*)$/.exec(lines[index])
|
|
249
|
+
if (match === null) continue
|
|
250
|
+
let value = match[2].trim()
|
|
251
|
+
if (value.startsWith("'") && value.endsWith("'") && value.length >= 2) value = value.slice(1, -1).replaceAll("''", "'")
|
|
252
|
+
else if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) value = value.slice(1, -1)
|
|
253
|
+
data[match[1]] = value
|
|
303
254
|
}
|
|
304
|
-
|
|
255
|
+
const skillName = typeof data.name === 'string' ? data.name.trim() : ''
|
|
256
|
+
const description = typeof data.description === 'string' ? data.description.trim() : ''
|
|
257
|
+
if (skillName === '' || description === '' || !NAME_PATTERN.test(skillName)) return undefined
|
|
258
|
+
return { name: skillName, description }
|
|
305
259
|
}
|
|
306
260
|
|
|
307
|
-
function
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
261
|
+
function defineRoute(operation, handle) {
|
|
262
|
+
return {
|
|
263
|
+
kind: 'exact',
|
|
264
|
+
path: `${BRIDGE_PREFIX}${operation}`,
|
|
265
|
+
handler: async (req, res) => {
|
|
266
|
+
if (!isLoopbackRequest(req)) {
|
|
267
|
+
writeJson(res, 403, { ok: false, code: 'forbidden', message: 'loopback requests only' })
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
if (req.method !== 'POST') {
|
|
271
|
+
writeJson(res, 405, { ok: false, code: 'method-not-allowed', message: 'POST only' })
|
|
272
|
+
return
|
|
273
|
+
}
|
|
274
|
+
let body
|
|
275
|
+
try {
|
|
276
|
+
body = await readJsonBody(req)
|
|
277
|
+
} catch {
|
|
278
|
+
writeJson(res, 400, { ok: false, code: 'body-too-large', message: 'JSON body too large' })
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
if (body === undefined) {
|
|
282
|
+
writeJson(res, 400, { ok: false, code: 'bad-json', message: 'JSON body required' })
|
|
283
|
+
return
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
writeJson(res, 200, { ok: true, value: await handle(body ?? {}) })
|
|
287
|
+
} catch (error) {
|
|
288
|
+
const known = error instanceof BridgeError
|
|
289
|
+
writeJson(res, known ? 400 : 500, {
|
|
290
|
+
ok: false,
|
|
291
|
+
code: known ? error.code : 'internal',
|
|
292
|
+
message: error instanceof Error ? error.message : String(error),
|
|
293
|
+
})
|
|
294
|
+
}
|
|
295
|
+
},
|
|
315
296
|
}
|
|
316
|
-
return value
|
|
317
297
|
}
|
|
318
298
|
|
|
319
|
-
function
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
return trimmed === '' ? undefined : trimmed
|
|
299
|
+
function writeJson(res, status, body) {
|
|
300
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' })
|
|
301
|
+
res.end(JSON.stringify(body))
|
|
323
302
|
}
|
|
324
303
|
|
|
325
|
-
function
|
|
304
|
+
async function readJsonBody(req) {
|
|
305
|
+
const chunks = []
|
|
306
|
+
let size = 0
|
|
307
|
+
for await (const chunk of req) {
|
|
308
|
+
size += chunk.length
|
|
309
|
+
if (size > MAX_JSON_BODY_BYTES) throw new Error('body too large')
|
|
310
|
+
chunks.push(chunk)
|
|
311
|
+
}
|
|
326
312
|
try {
|
|
327
|
-
|
|
328
|
-
logger?.warn?.(message)
|
|
313
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
329
314
|
} catch {
|
|
330
|
-
|
|
315
|
+
return undefined
|
|
331
316
|
}
|
|
332
317
|
}
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,18 +1,24 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-dynamic-competition-four-images",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "动态竞争·四象模型技能插件:插件全局安装,技能在「设置 → 插件」里按工作区选择性添加,只写入你选择的工作区。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./index.js",
|
|
9
|
+
"./client": "./client.js",
|
|
9
10
|
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
11
|
+
"./locale/*.json": "./locale/*.json",
|
|
12
|
+
"./icon": "./icon.svg",
|
|
10
13
|
"./package.json": "./package.json"
|
|
11
14
|
},
|
|
12
15
|
"files": [
|
|
13
16
|
"index.js",
|
|
17
|
+
"client.js",
|
|
14
18
|
"cordis.patch.yml",
|
|
15
|
-
"
|
|
19
|
+
"skills",
|
|
20
|
+
"locale/*.json",
|
|
21
|
+
"icon.svg",
|
|
16
22
|
"README.md",
|
|
17
23
|
"LICENSE"
|
|
18
24
|
],
|
|
@@ -34,6 +40,10 @@
|
|
|
34
40
|
"dsh": {
|
|
35
41
|
"bundle": {
|
|
36
42
|
"patch": "./cordis.patch.yml"
|
|
43
|
+
},
|
|
44
|
+
"client": {
|
|
45
|
+
"platform": "web",
|
|
46
|
+
"immediately": true
|
|
37
47
|
}
|
|
38
48
|
},
|
|
39
49
|
"author": "",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|