dsh-media-skills 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -72,12 +72,14 @@ See [Desktop 0.2 compatibility notes](docs/DESKTOP_0.2.md) for the exact verific
72
72
 
73
73
  ## ⚡ Quick start
74
74
 
75
- Install from the DSH **Plugin Manager** (`github:MJorgin/dsh-media-skills`) or the CLI:
75
+ Install from the DSH **Plugin Manager** (`dsh-media-skills`) or the CLI:
76
76
 
77
77
  ```sh
78
- dsh plugin --profile <name> add github:MJorgin/dsh-media-skills
78
+ dsh plugin --profile <name> add dsh-media-skills
79
79
  ```
80
80
 
81
+ Or straight from GitHub: `dsh plugin --profile <name> add github:MJorgin/dsh-media-skills`.
82
+
81
83
  1. **Keys**:
82
84
  - **Desktop 0.2 / v0.1.6+**: no keys are needed to install — the two skills register at runtime. The default `deepseek-flash` route supports native image chat. Skill keys (`GLM_API_KEY`, `SILICONFLOW_API_KEY`, optional `GEMINI_API_KEY`) go in `~/.dsh/secrets/media-tools.env`; extra model routes are configured in **Settings → Models**.
83
85
  - **v0.1.1-rc.1+**: zero extra keys — paste reading and the vision route run on your agent's existing `DEEPSEEK_API_KEY` (DeepSeek-V4-Flash-Vision-Exp).
package/client.js ADDED
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Browser half of dsh-media-skills: the sidebar footer-action entry and the
3
+ * media panel (生成 / 作品 / 密钥).
4
+ *
5
+ * Hand-authored in the client module-loader format — no build step. React and
6
+ * react/jsx-runtime are baseline modules; everything else is plain DOM.
7
+ *
8
+ * Styling uses the host's own `--dsw-alias-*` tokens, so light/dark follow the
9
+ * app theme instead of shipping a second palette.
10
+ */
11
+ window.__ModuleLoader__.load({
12
+ id: 'dsh-media-skills',
13
+ factory: (require) => {
14
+ var module = { exports: {} }
15
+ var exports = module.exports
16
+ const React = require('react')
17
+ const h = React.createElement
18
+
19
+ const STYLE_ID = 'dsh-media-skills-style'
20
+ const STORE_KEY = 'dsh-media-skills.works'
21
+ const TABS = [['generate', '生成'], ['works', '作品'], ['keys', '密钥']]
22
+
23
+ const CSS = `
24
+ .dshm-anchor { position: relative; display: block; }
25
+ .dshm-entry {
26
+ display: flex; align-items: center; gap: 8px; width: 100%;
27
+ height: 28px; padding: 0 10px; border: 0; border-radius: 14px;
28
+ background: transparent; color: var(--dsw-alias-label-secondary, #61666b);
29
+ font: inherit; font-size: 13px; cursor: pointer; text-align: left;
30
+ }
31
+ .dshm-entry:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,.04)); }
32
+ .dshm-entry[data-open="true"] { background: var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,.04)); color: var(--dsw-alias-label-primary, #0f1115); }
33
+ .dshm-entry svg { flex: none; }
34
+
35
+ .dshm-panel {
36
+ position: absolute; left: calc(100% + 10px); bottom: 0; width: 420px;
37
+ max-height: min(600px, calc(100vh - 40px)); display: flex; flex-direction: column;
38
+ background: var(--dsw-alias-bg-base, #fff); color: var(--dsw-alias-label-primary, #0f1115);
39
+ border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.1)); border-radius: 16px;
40
+ box-shadow: 0 24px 56px -24px rgba(0,0,0,.55); z-index: 40; overflow: hidden;
41
+ font-family: var(--dsw-font-family, -apple-system, "PingFang SC", system-ui, sans-serif);
42
+ }
43
+ .dshm-head { display: flex; align-items: baseline; justify-content: space-between; padding: 16px 18px 0; }
44
+ .dshm-title { font-size: 15px; font-weight: 600; margin: 0; }
45
+ .dshm-quota { font-size: 11.5px; color: var(--dsw-alias-label-tertiary, #81858c); }
46
+ .dshm-tabs { display: flex; gap: 18px; margin: 14px 18px 0; border-bottom: 1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.04)); }
47
+ .dshm-tab {
48
+ font: inherit; font-size: 12.5px; color: var(--dsw-alias-label-tertiary, #81858c);
49
+ background: none; border: 0; padding: 0 0 8px; cursor: pointer; position: relative;
50
+ }
51
+ .dshm-tab[data-on="true"] { color: var(--dsw-alias-label-primary, #0f1115); font-weight: 500; }
52
+ .dshm-tab[data-on="true"]::after {
53
+ content: ""; position: absolute; left: 0; right: 0; bottom: -1px; height: 1.5px;
54
+ background: var(--dsw-alias-label-primary, #0f1115);
55
+ }
56
+ .dshm-body { padding: 14px 18px 16px; display: flex; flex-direction: column; gap: 10px; overflow: auto; }
57
+
58
+ .dshm-field {
59
+ width: 100%; min-height: 58px; resize: none; box-sizing: border-box;
60
+ border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.1)); border-radius: 8px;
61
+ background: var(--dsw-alias-bg-base, #fff); color: inherit;
62
+ font: inherit; font-size: 13px; line-height: 1.55; padding: 9px 11px; outline: none;
63
+ }
64
+ .dshm-field:focus { border-color: var(--dsw-alias-label-tertiary, #81858c); }
65
+ .dshm-field::placeholder { color: var(--dsw-alias-label-caption, #adb2b8); }
66
+
67
+ .dshm-meta { display: flex; align-items: center; gap: 7px; font-size: 11.5px; color: var(--dsw-alias-label-tertiary, #81858c); }
68
+ .dshm-meta b { font-weight: 500; color: var(--dsw-alias-label-secondary, #61666b); }
69
+ .dshm-dot { width: 3px; height: 3px; border-radius: 50%; background: var(--dsw-alias-label-caption, #adb2b8); }
70
+
71
+ .dshm-cta {
72
+ height: 34px; border-radius: 17px; border: 0; cursor: pointer;
73
+ background: var(--dsw-alias-button-primary-fill, #0f1115); color: var(--dsw-alias-label-primary-foreground, #fff);
74
+ font: inherit; font-size: 13px; font-weight: 500;
75
+ }
76
+ .dshm-cta:disabled { background: var(--dsw-alias-bg-layer-2, #f9fafb); color: var(--dsw-alias-label-caption, #adb2b8); cursor: not-allowed; border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.1)); }
77
+
78
+ .dshm-shot { border-radius: 10px; overflow: hidden; background: var(--dsw-alias-bg-layer-2, #f2f3f5); aspect-ratio: 16 / 9; display: flex; align-items: center; justify-content: center; }
79
+ .dshm-shot img { width: 100%; height: 100%; object-fit: cover; display: block; }
80
+ .dshm-note {
81
+ border: 1px dashed var(--dsw-alias-border-l2, rgba(0,0,0,.1)); border-radius: 10px;
82
+ padding: 20px 16px; text-align: center; font-size: 11.5px; line-height: 1.7;
83
+ color: var(--dsw-alias-label-caption, #adb2b8);
84
+ }
85
+ .dshm-note b { display: block; font-size: 12.5px; font-weight: 500; color: var(--dsw-alias-label-secondary, #61666b); margin-bottom: 4px; }
86
+ .dshm-caption { padding-top: 10px; border-top: 1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.04)); }
87
+ .dshm-caption h4 { margin: 0 0 3px; font-size: 12.5px; font-weight: 500; }
88
+ .dshm-caption p { margin: 0; font-size: 11.5px; line-height: 1.6; color: var(--dsw-alias-label-tertiary, #81858c); }
89
+
90
+ .dshm-strip { display: flex; gap: 7px; }
91
+ .dshm-thumb { flex: 1; height: 36px; border-radius: 6px; overflow: hidden; background: var(--dsw-alias-bg-layer-2, #f2f3f5); opacity: .55; border: 0; padding: 0; cursor: pointer; }
92
+ .dshm-thumb[data-on="true"] { opacity: 1; box-shadow: 0 0 0 1.5px var(--dsw-alias-label-primary, #0f1115); }
93
+ .dshm-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
94
+
95
+ .dshm-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
96
+ .dshm-work { text-align: left; background: none; border: 0; padding: 0; cursor: pointer; font: inherit; color: inherit; }
97
+ .dshm-work .im { height: 84px; border-radius: 8px; overflow: hidden; background: var(--dsw-alias-bg-layer-2, #f2f3f5); }
98
+ .dshm-work img { width: 100%; height: 100%; object-fit: cover; display: block; }
99
+ .dshm-work .t { margin-top: 5px; font-size: 11.5px; color: var(--dsw-alias-label-secondary, #61666b); }
100
+ .dshm-work .m { font-size: 10.5px; color: var(--dsw-alias-label-caption, #adb2b8); }
101
+
102
+ .dshm-key { border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.1)); border-radius: 10px; padding: 10px 12px; }
103
+ .dshm-key .n { display: flex; align-items: center; justify-content: space-between; font-size: 12.5px; }
104
+ .dshm-key .s { font-size: 11px; color: var(--dsw-alias-label-tertiary, #81858c); display: flex; align-items: center; gap: 5px; }
105
+ .dshm-key .s::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: #2f7d5c; }
106
+ .dshm-key[data-off="true"] .s::before { background: var(--dsw-alias-label-caption, #adb2b8); }
107
+ .dshm-key .v { margin-top: 5px; font-size: 11.5px; color: var(--dsw-alias-label-caption, #adb2b8); }
108
+ .dshm-hint { font-size: 11.5px; color: var(--dsw-alias-label-tertiary, #81858c); line-height: 1.6; }
109
+ `
110
+
111
+ function ensureStyles() {
112
+ if (document.getElementById(STYLE_ID) !== null) return
113
+ const style = document.createElement('style')
114
+ style.id = STYLE_ID
115
+ style.textContent = CSS
116
+ document.head.appendChild(style)
117
+ }
118
+
119
+ function Icon() {
120
+ return h('svg', { viewBox: '0 0 16 16', width: 15, height: 15, fill: 'none', stroke: 'currentColor', strokeWidth: 1.3, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': 'true' },
121
+ h('rect', { x: 1.8, y: 2.6, width: 12.4, height: 10.8, rx: 1.6 }),
122
+ h('circle', { cx: 5.4, cy: 6.2, r: 1.1 }),
123
+ h('path', { d: 'M2.4 11.6l3.4-3.1 2.5 2.2 2.2-1.9 3.1 2.8' }))
124
+ }
125
+
126
+ async function api(path, init) {
127
+ const response = await fetch(`/media${path}`, { headers: { 'content-type': 'application/json' }, ...init })
128
+ const body = await response.json().catch(() => ({}))
129
+ if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`)
130
+ return body
131
+ }
132
+
133
+ function loadWorks() {
134
+ try {
135
+ const raw = localStorage.getItem(STORE_KEY)
136
+ const parsed = raw === null ? [] : JSON.parse(raw)
137
+ return Array.isArray(parsed) ? parsed.slice(0, 60) : []
138
+ } catch { return [] }
139
+ }
140
+
141
+ function saveWorks(works) {
142
+ try { localStorage.setItem(STORE_KEY, JSON.stringify(works.slice(0, 60))) } catch { /* storage full or blocked */ }
143
+ }
144
+
145
+ function titleOf(prompt) {
146
+ const clean = String(prompt).replace(/\s+/g, ' ').trim()
147
+ return clean.length > 14 ? `${clean.slice(0, 14)}…` : clean
148
+ }
149
+
150
+ function Panel({ onClose }) {
151
+ const [tab, setTab] = React.useState('generate')
152
+ const [status, setStatus] = React.useState(null)
153
+ const [statusError, setStatusError] = React.useState(null)
154
+ const [prompt, setPrompt] = React.useState('')
155
+ const [busy, setBusy] = React.useState(false)
156
+ const [error, setError] = React.useState(null)
157
+ const [current, setCurrent] = React.useState(() => loadWorks()[0] ?? null)
158
+ const [works, setWorks] = React.useState(loadWorks)
159
+
160
+ const refresh = React.useCallback(() => {
161
+ setStatusError(null)
162
+ api('/status').then(setStatus).catch((e) => setStatusError(e.message))
163
+ }, [])
164
+ React.useEffect(refresh, [refresh])
165
+
166
+ const limit = status?.quota?.limit ?? 5
167
+ const used = works.length
168
+ const left = Math.max(0, limit - used)
169
+ const empty = prompt.trim() === ''
170
+
171
+ const generate = React.useCallback(async () => {
172
+ if (busy || empty || left === 0) return
173
+ setBusy(true); setError(null)
174
+ try {
175
+ const size = '16:9'
176
+ const result = await api('/generate', { method: 'POST', body: JSON.stringify({ prompt, size }) })
177
+ const work = {
178
+ id: String(Date.now()),
179
+ title: titleOf(prompt),
180
+ prompt,
181
+ size,
182
+ model: status?.providers?.find((p) => p.configured)?.label ?? '未配置',
183
+ at: Date.now(),
184
+ src: `/media/file?p=${encodeURIComponent(result.file)}`,
185
+ }
186
+ const next = [work, ...works]
187
+ setWorks(next); saveWorks(next); setCurrent(work)
188
+ } catch (e) {
189
+ setError(e.message)
190
+ } finally {
191
+ setBusy(false)
192
+ }
193
+ }, [busy, empty, left, prompt, status, works])
194
+
195
+ const head = h('div', { className: 'dshm-head' },
196
+ h('h3', { className: 'dshm-title' }, '创作'),
197
+ h('span', { className: 'dshm-quota' }, statusError !== null ? '无法连接' : `本次还剩 ${left} 张`))
198
+
199
+ const tabs = h('div', { className: 'dshm-tabs' }, TABS.map(([id, label]) =>
200
+ h('button', { key: id, className: 'dshm-tab', 'data-on': String(tab === id), onClick: () => setTab(id) }, label)))
201
+
202
+ let body
203
+ if (tab === 'generate') {
204
+ body = h('div', { className: 'dshm-body' },
205
+ h('textarea', {
206
+ className: 'dshm-field',
207
+ placeholder: '描述你想要的画面,比如「雨夜街角,霓虹倒影」',
208
+ value: prompt,
209
+ onChange: (e) => setPrompt(e.target.value),
210
+ onKeyDown: (e) => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') generate() },
211
+ }),
212
+ h('div', { className: 'dshm-meta' },
213
+ h('b', null, status?.providers?.find((p) => p.configured)?.label ?? '未配置密钥'),
214
+ h('span', { className: 'dshm-dot' }), h('span', null, '16:9'),
215
+ h('span', { className: 'dshm-dot' }), h('span', null, '1024×576')),
216
+ h('button', { className: 'dshm-cta', disabled: busy || empty || left === 0, onClick: generate },
217
+ busy ? '生成中…' : left === 0 ? '本次配额已用完' : '生成'),
218
+ error !== null
219
+ ? h('div', { className: 'dshm-note' }, h('b', null, '生成失败。'), error,
220
+ h('div', { style: { marginTop: 8 } },
221
+ h('button', { className: 'dshm-cta', style: { height: 28, padding: '0 14px', width: 'auto' }, onClick: generate }, '重试')))
222
+ : current !== null
223
+ ? h('div', null,
224
+ h('div', { className: 'dshm-shot' }, h('img', { src: current.src, alt: current.title })),
225
+ h('div', { className: 'dshm-caption' },
226
+ h('h4', null, current.title),
227
+ h('p', null, `${current.model} · ${current.size} · ${new Date(current.at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}`)))
228
+ : h('div', { className: 'dshm-note' },
229
+ h('b', null, '暂无作品。'),
230
+ '生成的第一张会出现在这里,可以随时回来复用它的参数。'),
231
+ works.length > 0
232
+ ? h('div', { className: 'dshm-strip' }, works.slice(0, 4).map((work) =>
233
+ h('button', { key: work.id, className: 'dshm-thumb', 'data-on': String(current?.id === work.id), onClick: () => setCurrent(work), title: work.title },
234
+ h('img', { src: work.src, alt: work.title }))))
235
+ : null)
236
+ } else if (tab === 'works') {
237
+ body = h('div', { className: 'dshm-body' },
238
+ works.length === 0
239
+ ? h('div', { className: 'dshm-note' }, h('b', null, '暂无作品。'), '生成过的图片会按时间排列在这里。')
240
+ : h('div', { className: 'dshm-grid' }, works.map((work) =>
241
+ h('button', { key: work.id, className: 'dshm-work', onClick: () => { setCurrent(work); setTab('generate') } },
242
+ h('div', { className: 'im' }, h('img', { src: work.src, alt: work.title })),
243
+ h('div', { className: 't' }, work.title),
244
+ h('div', { className: 'm' }, `${new Date(work.at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })} · ${work.size}`)))))
245
+ } else {
246
+ const providers = status?.providers ?? []
247
+ body = h('div', { className: 'dshm-body' },
248
+ providers.length === 0
249
+ ? h('div', { className: 'dshm-note' }, h('b', null, '未配置密钥。'), statusError ?? '读取状态失败。')
250
+ : providers.map((p) => h('div', { key: p.id, className: 'dshm-key', 'data-off': String(!p.configured) },
251
+ h('div', { className: 'n' }, p.label, h('span', { className: 's' }, p.configured ? '已配置' : '未配置')),
252
+ h('div', { className: 'v' }, p.role))),
253
+ h('div', { className: 'dshm-hint' }, '密钥保存在本机凭据服务里,也可以继续用 media-tools.env。'))
254
+ }
255
+
256
+ return h('div', { className: 'dshm-panel', role: 'dialog', 'aria-label': '创作' },
257
+ head, tabs, body)
258
+ }
259
+
260
+ function Entry(props) {
261
+ const [open, setOpen] = React.useState(false)
262
+ const ref = React.useRef(null)
263
+ React.useEffect(() => {
264
+ if (!open) return undefined
265
+ const onDown = (event) => { if (ref.current !== null && !ref.current.contains(event.target)) setOpen(false) }
266
+ const onKey = (event) => { if (event.key === 'Escape') setOpen(false) }
267
+ document.addEventListener('mousedown', onDown)
268
+ document.addEventListener('keydown', onKey)
269
+ return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey) }
270
+ }, [open])
271
+ return h('div', { className: 'dshm-anchor', ref },
272
+ h('button', { className: 'dshm-entry', 'data-open': String(open), title: '创作', 'aria-label': '创作', onClick: () => setOpen((v) => !v) },
273
+ h(Icon, null),
274
+ props?.wide === false ? null : h('span', null, '创作')),
275
+ open ? h(Panel, { onClose: () => setOpen(false) }) : null)
276
+ }
277
+
278
+ function apply(ctx) {
279
+ ensureStyles()
280
+ ctx.slots.inject('sidebar.footer.action', () => ctx.slots.register({
281
+ name: 'sidebar.footer.action',
282
+ id: 'media-desk',
283
+ order: 100,
284
+ }, Entry))
285
+ }
286
+
287
+ exports.name = 'dsh-media-skills'
288
+ exports.apply = apply
289
+ exports.inject = ['slots']
290
+ return module.exports
291
+ },
292
+ })
@@ -12,13 +12,13 @@
12
12
  在 DSH Plugin Manager 中添加:
13
13
 
14
14
  ```text
15
- github:MJorgin/dsh-media-skills
15
+ dsh-media-skills
16
16
  ```
17
17
 
18
18
  或使用 CLI:
19
19
 
20
20
  ```sh
21
- dsh plugin --profile web add github:MJorgin/dsh-media-skills
21
+ dsh plugin --profile web add dsh-media-skills
22
22
  ```
23
23
 
24
24
  请把 `web` 替换成你实际使用的 DSH profile。安装后重启该 profile。
@@ -12,13 +12,13 @@ This guide covers two related but different workflows:
12
12
  Use the DSH Plugin Manager:
13
13
 
14
14
  ```text
15
- github:MJorgin/dsh-media-skills
15
+ dsh-media-skills
16
16
  ```
17
17
 
18
18
  Or use the CLI:
19
19
 
20
20
  ```sh
21
- dsh plugin --profile web add github:MJorgin/dsh-media-skills
21
+ dsh plugin --profile web add dsh-media-skills
22
22
  ```
23
23
 
24
24
  Replace `web` with your DSH profile. Restart the profile after installation.
@@ -41,7 +41,7 @@ zwei kostenlose Skills, ein kostenloses Vision-Modell und das Einfügen von Bild
41
41
  1. Bundle installieren:
42
42
 
43
43
  ```sh
44
- dsh plugin --profile <name> add github:MJorgin/dsh-media-skills
44
+ dsh plugin --profile <name> add dsh-media-skills
45
45
  ```
46
46
 
47
47
  2. **Keys**:
@@ -41,7 +41,7 @@ dos skills gratuitas, un modelo de visión gratuito y lectura de imágenes pegad
41
41
  1. Instala el bundle:
42
42
 
43
43
  ```sh
44
- dsh plugin --profile <name> add github:MJorgin/dsh-media-skills
44
+ dsh plugin --profile <name> add dsh-media-skills
45
45
  ```
46
46
 
47
47
  2. **Claves**:
@@ -41,7 +41,7 @@
41
41
  1. バンドルをインストール:
42
42
 
43
43
  ```sh
44
- dsh plugin --profile <name> add github:MJorgin/dsh-media-skills
44
+ dsh plugin --profile <name> add dsh-media-skills
45
45
  ```
46
46
 
47
47
  2. **キー**:
@@ -41,7 +41,7 @@
41
41
  1. 번들 설치:
42
42
 
43
43
  ```sh
44
- dsh plugin --profile <name> add github:MJorgin/dsh-media-skills
44
+ dsh plugin --profile <name> add dsh-media-skills
45
45
  ```
46
46
 
47
47
  2. **키**:
@@ -41,7 +41,7 @@ duas skills grátis, um modelo de visão grátis e leitura de imagens coladas, s
41
41
  1. Instale o bundle:
42
42
 
43
43
  ```sh
44
- dsh plugin --profile <name> add github:MJorgin/dsh-media-skills
44
+ dsh plugin --profile <name> add dsh-media-skills
45
45
  ```
46
46
 
47
47
  2. **Chaves**:
@@ -41,7 +41,7 @@
41
41
  1. Установите бандл:
42
42
 
43
43
  ```sh
44
- dsh plugin --profile <name> add github:MJorgin/dsh-media-skills
44
+ dsh plugin --profile <name> add dsh-media-skills
45
45
  ```
46
46
 
47
47
  2. **Ключи**:
@@ -73,10 +73,10 @@ DeepSeek Harness 很会推理,但图片能否发送取决于当前模型声明
73
73
 
74
74
  ## ⚡ 快速开始
75
75
 
76
- 在 DSH **插件管理器**里添加 `github:MJorgin/dsh-media-skills`,或命令行安装:
76
+ 在 DSH **插件管理器**里添加 `dsh-media-skills`,或命令行安装:
77
77
 
78
78
  ```sh
79
- dsh plugin --profile <name> add github:MJorgin/dsh-media-skills
79
+ dsh plugin --profile <name> add dsh-media-skills
80
80
  ```
81
81
 
82
82
  1. **Key**:
@@ -62,7 +62,7 @@ DeepSeek Harness 的推理能力十分出色——但純文字模型看不見你
62
62
  ## ⚡ 快速開始
63
63
 
64
64
  ```sh
65
- dsh plugin --profile <name> add github:MJorgin/dsh-media-skills
65
+ dsh plugin --profile <name> add dsh-media-skills
66
66
  ```
67
67
 
68
68
  1. **金鑰**:
package/index.js CHANGED
@@ -18,9 +18,11 @@
18
18
  * @module dsh-media-skills
19
19
  */
20
20
 
21
- import { readFileSync } from 'node:fs'
21
+ import { createReadStream, mkdirSync, readFileSync, statSync } from 'node:fs'
22
22
  import { readFile } from 'node:fs/promises'
23
- import { join } from 'node:path'
23
+ import { homedir } from 'node:os'
24
+ import { dirname, join } from 'node:path'
25
+ import { spawn } from 'node:child_process'
24
26
  import { fileURLToPath } from 'node:url'
25
27
 
26
28
  const PROVIDER_NAME = 'dsh-media-skills'
@@ -93,4 +95,165 @@ export const inject = ['skills']
93
95
  */
94
96
  export function apply(ctx) {
95
97
  ctx.skills.registerProvider(createProvider)
98
+ // Optional: only compositions with a web server get the media routes, so the
99
+ // headless CLI keeps loading this bundle with skills alone. `inject` is
100
+ // absent on the bare harness the package's own checks use.
101
+ if (typeof ctx.inject !== 'function') return
102
+ ctx.inject(['webServer'], (webCtx) => {
103
+ webCtx.effect(() => {
104
+ const handler = createMediaHandler({
105
+ workspaceRoot: () => webCtx.get('workspace')?.root ?? process.cwd(),
106
+ })
107
+ const unregister = webCtx.webServer.register({
108
+ kind: 'prefix',
109
+ path: MEDIA_PREFIX,
110
+ handler,
111
+ })
112
+ return () => unregister()
113
+ }, 'dsh-media-skills: /media routes')
114
+ })
115
+ }
116
+
117
+ /** Route prefix owned by this bundle; never overlaps the kernel's `/api`. */
118
+ const MEDIA_PREFIX = '/media'
119
+
120
+ /** Providers the bundled skills know how to drive. */
121
+ const PROVIDERS = [
122
+ { id: 'sensenova', label: 'SenseNova U1', env: 'SENSENOVA_API_KEY', role: '主用' },
123
+ { id: 'siliconflow', label: 'SiliconFlow Kolors', env: 'SILICONFLOW_API_KEY', role: '备用' },
124
+ { id: 'gemini', label: 'Gemini', env: 'GEMINI_API_KEY', role: '视觉' },
125
+ ]
126
+
127
+ /** Parse `KEY=value` lines out of one secrets file; missing files read as empty. */
128
+ function readSecretFile(file) {
129
+ try {
130
+ const out = {}
131
+ for (const line of readFileSync(file, 'utf8').split(/\r?\n/)) {
132
+ const match = /^([A-Z0-9_]+)=(.*)$/.exec(line.trim())
133
+ if (match !== null) out[match[1]] = match[2].trim()
134
+ }
135
+ return out
136
+ } catch {
137
+ return {}
138
+ }
139
+ }
140
+
141
+ /** The secrets the skills themselves read, in the same precedence order. */
142
+ function secretsOf() {
143
+ const home = process.env.DSH_HOME ?? join(homedir(), '.dsh')
144
+ return {
145
+ ...readSecretFile(join(homedir(), '.codex', 'secrets', 'media-tools.env')),
146
+ ...readSecretFile(join(home, 'secrets', 'media-tools.env')),
147
+ }
148
+ }
149
+
150
+ /** Provider table plus the package version, for the panel's 密钥 tab. */
151
+ export function mediaStatus() {
152
+ const secrets = secretsOf()
153
+ return {
154
+ ok: true,
155
+ version: JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version,
156
+ providers: PROVIDERS.map((provider) => ({
157
+ id: provider.id,
158
+ label: provider.label,
159
+ role: provider.role,
160
+ configured: Boolean(process.env[provider.env] ?? secrets[provider.env]),
161
+ })),
162
+ quota: { limit: 5 },
163
+ }
164
+ }
165
+
166
+ /** Reject cross-site callers: the panel is same-origin, a web page is not. */
167
+ export function originAllowed(req) {
168
+ const host = req.headers.host
169
+ if (typeof host !== 'string' || host === '') return false
170
+ const origin = req.headers.origin ?? req.headers.referer
171
+ if (origin === undefined) return true
172
+ try {
173
+ return new URL(origin).host === host
174
+ } catch {
175
+ return false
176
+ }
177
+ }
178
+
179
+ /** Run the same `generate.py` the media-tools skill runs — one implementation. */
180
+ export function runGenerate({ prompt, size, out, timeoutMs = 180_000 }) {
181
+ const script = fileURLToPath(new URL('./skills/media-tools/scripts/generate.py', import.meta.url))
182
+ return new Promise((resolve) => {
183
+ let stderr = ''
184
+ const child = spawn('python3', [script, prompt, out, size], { cwd: dirname(script) })
185
+ const timer = setTimeout(() => child.kill('SIGTERM'), timeoutMs)
186
+ child.stderr.on('data', (chunk) => { stderr += String(chunk) })
187
+ child.on('error', (error) => { clearTimeout(timer); resolve({ ok: false, message: error.message }) })
188
+ child.on('close', (code) => {
189
+ clearTimeout(timer)
190
+ if (code === 0) resolve({ ok: true, file: out })
191
+ else resolve({ ok: false, message: stderr.trim().split(/\r?\n/).slice(-3).join(' ') || `退出码 ${code}` })
192
+ })
193
+ })
194
+ }
195
+
196
+ /** The `/media` prefix handler: status, generate, and the generated file itself. */
197
+ export function createMediaHandler({ workspaceRoot } = {}) {
198
+ const root = () => (typeof workspaceRoot === 'function' ? workspaceRoot() : process.cwd())
199
+ return async function mediaHandler(req, res) {
200
+ if (!originAllowed(req)) {
201
+ res.writeHead(403, { 'content-type': 'application/json; charset=utf-8' })
202
+ res.end(JSON.stringify({ ok: false, message: 'cross-site request refused' }))
203
+ return
204
+ }
205
+ const url = new URL(req.url ?? '/', 'http://localhost')
206
+ const route = url.pathname.slice(MEDIA_PREFIX.length) || '/'
207
+
208
+ if (route === '/status' && req.method === 'GET') {
209
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
210
+ res.end(JSON.stringify(mediaStatus()))
211
+ return
212
+ }
213
+
214
+ if (route === '/generate' && req.method === 'POST') {
215
+ let body = ''
216
+ for await (const chunk of req) body += String(chunk)
217
+ let parsed = {}
218
+ try { parsed = JSON.parse(body || '{}') } catch { parsed = {} }
219
+ const prompt = String(parsed.prompt ?? '').trim()
220
+ if (prompt === '') {
221
+ res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
222
+ res.end(JSON.stringify({ ok: false, message: '提示词不能为空。' }))
223
+ return
224
+ }
225
+ const size = String(parsed.size ?? '1024x1024')
226
+ const dir = join(root(), 'uploads', 'media')
227
+ mkdirSync(dir, { recursive: true })
228
+ const out = join(dir, `${Date.now()}.png`)
229
+ const result = await runGenerate({ prompt, size, out })
230
+ res.writeHead(result.ok ? 200 : 502, { 'content-type': 'application/json; charset=utf-8' })
231
+ res.end(JSON.stringify(result))
232
+ return
233
+ }
234
+
235
+ if (route === '/file' && req.method === 'GET') {
236
+ const target = url.searchParams.get('p')
237
+ const base = join(root(), 'uploads', 'media')
238
+ // Only ever serve files this plugin wrote: resolved path must sit inside
239
+ // the media directory, so a crafted `?p=` cannot read arbitrary files.
240
+ if (target === null || !target.startsWith(base + '/')) {
241
+ res.writeHead(403, { 'content-type': 'application/json; charset=utf-8' })
242
+ res.end(JSON.stringify({ ok: false, message: 'path outside the media directory' }))
243
+ return
244
+ }
245
+ let size = 0
246
+ try { size = statSync(target).size } catch {
247
+ res.writeHead(404, { 'content-type': 'application/json; charset=utf-8' })
248
+ res.end(JSON.stringify({ ok: false, message: 'file not found' }))
249
+ return
250
+ }
251
+ res.writeHead(200, { 'content-type': 'image/png', 'content-length': size, 'cache-control': 'no-store' })
252
+ createReadStream(target).pipe(res)
253
+ return
254
+ }
255
+
256
+ res.writeHead(404, { 'content-type': 'application/json; charset=utf-8' })
257
+ res.end(JSON.stringify({ ok: false, message: 'not found' }))
258
+ }
96
259
  }
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "dsh-media-skills",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "Free image reading and generation skills for DeepSeek Harness v0.1.6+ (runtime-verified on desktop 0.2.0-rc.2; source-checked against dsh-v0.2.1-alpha.2): visual QA, illustration generation, provider fallback, and no bundled keys.",
5
5
  "type": "module",
6
6
  "main": "index.js",
7
7
  "exports": {
8
- ".": "./index.js"
8
+ ".": "./index.js",
9
+ "./client": "./client.js"
9
10
  },
10
11
  "scripts": {
11
12
  "check:node": "node scripts/check-dsh-manifest.mjs && node scripts/check-dsh-bundle.mjs && node scripts/check-docs-links.mjs && node --check index.js",
@@ -14,6 +15,7 @@
14
15
  },
15
16
  "files": [
16
17
  "index.js",
18
+ "client.js",
17
19
  "cordis.patch.yml",
18
20
  "skills",
19
21
  "scripts",
@@ -49,6 +51,9 @@
49
51
  "dsh": {
50
52
  "bundle": {
51
53
  "patch": "./cordis.patch.yml"
54
+ },
55
+ "client": {
56
+ "platform": "web"
52
57
  }
53
58
  }
54
- }
59
+ }