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 +4 -2
- package/client.js +292 -0
- package/docs/SETUP_VISION.md +2 -2
- package/docs/SETUP_VISION_EN.md +2 -2
- package/docs/lang/README_DE.md +1 -1
- package/docs/lang/README_ES.md +1 -1
- package/docs/lang/README_JA.md +1 -1
- package/docs/lang/README_KO.md +1 -1
- package/docs/lang/README_PT.md +1 -1
- package/docs/lang/README_RU.md +1 -1
- package/docs/lang/README_ZH.md +2 -2
- package/docs/lang/README_ZH_TW.md +1 -1
- package/index.js +165 -2
- package/package.json +8 -3
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** (`
|
|
75
|
+
Install from the DSH **Plugin Manager** (`dsh-media-skills`) or the CLI:
|
|
76
76
|
|
|
77
77
|
```sh
|
|
78
|
-
dsh plugin --profile <name> add
|
|
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
|
+
})
|
package/docs/SETUP_VISION.md
CHANGED
|
@@ -12,13 +12,13 @@
|
|
|
12
12
|
在 DSH Plugin Manager 中添加:
|
|
13
13
|
|
|
14
14
|
```text
|
|
15
|
-
|
|
15
|
+
dsh-media-skills
|
|
16
16
|
```
|
|
17
17
|
|
|
18
18
|
或使用 CLI:
|
|
19
19
|
|
|
20
20
|
```sh
|
|
21
|
-
dsh plugin --profile web add
|
|
21
|
+
dsh plugin --profile web add dsh-media-skills
|
|
22
22
|
```
|
|
23
23
|
|
|
24
24
|
请把 `web` 替换成你实际使用的 DSH profile。安装后重启该 profile。
|
package/docs/SETUP_VISION_EN.md
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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.
|
package/docs/lang/README_DE.md
CHANGED
|
@@ -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
|
|
44
|
+
dsh plugin --profile <name> add dsh-media-skills
|
|
45
45
|
```
|
|
46
46
|
|
|
47
47
|
2. **Keys**:
|
package/docs/lang/README_ES.md
CHANGED
|
@@ -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
|
|
44
|
+
dsh plugin --profile <name> add dsh-media-skills
|
|
45
45
|
```
|
|
46
46
|
|
|
47
47
|
2. **Claves**:
|
package/docs/lang/README_JA.md
CHANGED
package/docs/lang/README_KO.md
CHANGED
package/docs/lang/README_PT.md
CHANGED
|
@@ -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
|
|
44
|
+
dsh plugin --profile <name> add dsh-media-skills
|
|
45
45
|
```
|
|
46
46
|
|
|
47
47
|
2. **Chaves**:
|
package/docs/lang/README_RU.md
CHANGED
package/docs/lang/README_ZH.md
CHANGED
|
@@ -73,10 +73,10 @@ DeepSeek Harness 很会推理,但图片能否发送取决于当前模型声明
|
|
|
73
73
|
|
|
74
74
|
## ⚡ 快速开始
|
|
75
75
|
|
|
76
|
-
在 DSH **插件管理器**里添加 `
|
|
76
|
+
在 DSH **插件管理器**里添加 `dsh-media-skills`,或命令行安装:
|
|
77
77
|
|
|
78
78
|
```sh
|
|
79
|
-
dsh plugin --profile <name> add
|
|
79
|
+
dsh plugin --profile <name> add dsh-media-skills
|
|
80
80
|
```
|
|
81
81
|
|
|
82
82
|
1. **Key**:
|
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 {
|
|
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.
|
|
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
|
+
}
|