dsh-media-skills 0.5.2 → 0.5.3
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/client.js +120 -12
- package/index.js +46 -4
- package/package.json +1 -1
- package/skills/media-tools/SKILL.md +17 -1
- package/skills/media-tools/scripts/generate.py +68 -10
package/client.js
CHANGED
|
@@ -65,6 +65,43 @@ window.__ModuleLoader__.load({
|
|
|
65
65
|
.dshm-row b { font-weight: 500; color: var(--dsw-alias-label-secondary, #61666b); }
|
|
66
66
|
.dshm-dot { width: 3px; height: 3px; border-radius: 50%; background: var(--dsw-alias-label-caption, #adb2b8); }
|
|
67
67
|
|
|
68
|
+
.dshm-pickers { display: flex; align-items: center; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
|
|
69
|
+
.dshm-pickwrap { position: relative; display: inline-block; }
|
|
70
|
+
.dshm-pick {
|
|
71
|
+
height: 28px; padding: 0 11px; border-radius: 14px; cursor: pointer;
|
|
72
|
+
border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.12));
|
|
73
|
+
background: var(--dsw-alias-bg-base, #fff); color: var(--dsw-alias-label-secondary, #61666b);
|
|
74
|
+
font: inherit; font-size: 12.5px; display: inline-flex; align-items: center; gap: 7px;
|
|
75
|
+
}
|
|
76
|
+
.dshm-pick:hover { border-color: var(--dsw-alias-label-caption, #adb2b8); }
|
|
77
|
+
.dshm-pick .car { width: 6px; height: 6px; border-right: 1.3px solid currentColor; border-bottom: 1.3px solid currentColor; transform: rotate(45deg) translate(-1px, -1px); opacity: .7; }
|
|
78
|
+
.dshm-menu {
|
|
79
|
+
position: absolute; left: 0; top: 34px; z-index: 30; min-width: 252px; display: grid; padding: 4px;
|
|
80
|
+
background: var(--dsw-alias-bg-elevated, #fff); border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.12));
|
|
81
|
+
border-radius: 10px; box-shadow: 0 10px 28px rgba(0,0,0,.14);
|
|
82
|
+
}
|
|
83
|
+
.dshm-menu button {
|
|
84
|
+
border: 0; background: none; font: inherit; font-size: 13px; color: inherit; cursor: pointer;
|
|
85
|
+
padding: 7px 10px; border-radius: 7px; text-align: left; display: flex; justify-content: space-between; gap: 14px;
|
|
86
|
+
white-space: nowrap;
|
|
87
|
+
}
|
|
88
|
+
.dshm-menu button:hover { background: var(--dsw-alias-bg-layer-2, #f2f3f5); }
|
|
89
|
+
.dshm-menu button[data-on="true"] { font-weight: 500; }
|
|
90
|
+
.dshm-menu button:disabled { color: var(--dsw-alias-label-caption, #adb2b8); cursor: not-allowed; }
|
|
91
|
+
.dshm-menu button:disabled:hover { background: none; }
|
|
92
|
+
.dshm-menu .off { font-size: 11.5px; color: var(--dsw-alias-label-caption, #adb2b8); }
|
|
93
|
+
.dshm-size { font-size: 12px; color: var(--dsw-alias-label-caption, #adb2b8); }
|
|
94
|
+
|
|
95
|
+
.dshm-shield { display: flex; gap: 10px; align-items: flex-start; max-width: 680px; margin: 20px 0 0;
|
|
96
|
+
font-size: 12.5px; line-height: 1.65; color: var(--dsw-alias-label-tertiary, #81858c); }
|
|
97
|
+
.dshm-shield svg { flex: none; margin-top: 2px; color: var(--dsw-alias-label-tertiary, #81858c); }
|
|
98
|
+
.dshm-keywrap { position: relative; }
|
|
99
|
+
.dshm-keywrap .dshm-input { padding-right: 38px; }
|
|
100
|
+
.dshm-eye { position: absolute; right: 3px; top: 50%; transform: translateY(-50%); width: 26px; height: 26px;
|
|
101
|
+
display: flex; align-items: center; justify-content: center; border: 0; border-radius: 6px; padding: 0;
|
|
102
|
+
background: none; color: var(--dsw-alias-label-tertiary, #81858c); cursor: pointer; }
|
|
103
|
+
.dshm-eye:hover { color: var(--dsw-alias-label-primary, #0f1115); background: var(--dsw-alias-bg-layer-2, #f2f3f5); }
|
|
104
|
+
|
|
68
105
|
.dshm-cta {
|
|
69
106
|
margin-top: 16px; height: 36px; border-radius: 18px; border: 0; cursor: pointer; width: 100%;
|
|
70
107
|
background: var(--dsw-alias-button-primary-fill, #0f1115); color: var(--dsw-alias-label-primary-foreground, #fff);
|
|
@@ -176,13 +213,54 @@ window.__ModuleLoader__.load({
|
|
|
176
213
|
return clean.length > 18 ? `${clean.slice(0, 18)}…` : clean
|
|
177
214
|
}
|
|
178
215
|
|
|
216
|
+
const RATIOS = [['1:1', '1024x1024'], ['16:9', '1024x576'], ['9:16', '576x1024'], ['4:3', '1024x768'], ['3:4', '768x1024']]
|
|
217
|
+
|
|
218
|
+
function ShieldIcon() {
|
|
219
|
+
return h('svg', { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round' },
|
|
220
|
+
h('path', { d: 'M12 3l7 3v5.6c0 4.4-3 8.1-7 9.4-4-1.3-7-5-7-9.4V6l7-3z' }),
|
|
221
|
+
h('path', { d: 'M9.2 12.3l2 2 3.6-3.9' }))
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function EyeIcon({ open }) {
|
|
225
|
+
const box = { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round' }
|
|
226
|
+
return open
|
|
227
|
+
? h('svg', box, h('path', { d: 'M2 12s3.6-6.5 10-6.5S22 12 22 12s-3.6 6.5-10 6.5S2 12 2 12z' }), h('circle', { cx: 12, cy: 12, r: 2.6 }))
|
|
228
|
+
: h('svg', box, h('path', { d: 'M3 3l18 18' }), h('path', { d: 'M10.6 6.1A11 11 0 0 1 12 6c6.4 0 10 6 10 6a17.5 17.5 0 0 1-3.2 4' }), h('path', { d: 'M6.4 7.9A17.3 17.3 0 0 0 2 12s3.6 6 10 6a10.9 10.9 0 0 0 4.3-.9' }))
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** A small pill that opens a menu — the panel's only interactive control. */
|
|
232
|
+
function Picker({ value, options, onPick }) {
|
|
233
|
+
const [open, setOpen] = React.useState(false)
|
|
234
|
+
React.useEffect(() => {
|
|
235
|
+
if (!open) return undefined
|
|
236
|
+
const close = () => setOpen(false)
|
|
237
|
+
document.addEventListener('mousedown', close)
|
|
238
|
+
return () => document.removeEventListener('mousedown', close)
|
|
239
|
+
}, [open])
|
|
240
|
+
return h('div', { className: 'dshm-pickwrap', onMouseDown: (e) => e.stopPropagation() },
|
|
241
|
+
h('button', { className: 'dshm-pick', type: 'button', onClick: () => setOpen((v) => !v) },
|
|
242
|
+
h('span', null, value), h('i', { className: 'car' })),
|
|
243
|
+
open
|
|
244
|
+
? h('div', { className: 'dshm-menu' }, options.map((option) =>
|
|
245
|
+
h('button', {
|
|
246
|
+
key: option.id, type: 'button', disabled: option.disabled === true,
|
|
247
|
+
'data-on': String(option.current === true),
|
|
248
|
+
onClick: () => { setOpen(false); onPick(option) },
|
|
249
|
+
},
|
|
250
|
+
h('span', null, option.label),
|
|
251
|
+
option.hint ? h('span', { className: 'off' }, option.hint) : null)))
|
|
252
|
+
: null)
|
|
253
|
+
}
|
|
254
|
+
|
|
179
255
|
function KeysTab({ status, onSaved }) {
|
|
180
256
|
const providers = status?.providers ?? []
|
|
181
257
|
const [drafts, setDrafts] = React.useState({})
|
|
182
258
|
const [saving, setSaving] = React.useState(null)
|
|
183
259
|
const [error, setError] = React.useState(null)
|
|
184
260
|
const [adding, setAdding] = React.useState(false)
|
|
261
|
+
const [reveal, setReveal] = React.useState({})
|
|
185
262
|
const [form, setForm] = React.useState({ label: '', baseUrl: '', model: '', key: '' })
|
|
263
|
+
const toggleReveal = (id) => setReveal((all) => ({ ...all, [id]: all[id] !== true }))
|
|
186
264
|
|
|
187
265
|
const draftOf = (provider) => ({
|
|
188
266
|
baseUrl: provider.baseUrl ?? '',
|
|
@@ -213,6 +291,8 @@ window.__ModuleLoader__.load({
|
|
|
213
291
|
}
|
|
214
292
|
|
|
215
293
|
return h('div', null,
|
|
294
|
+
h('div', { className: 'dshm-shield' }, h(ShieldIcon),
|
|
295
|
+
h('span', null, 'Key 只写进本机 ~/.dsh/secrets/media-tools.env(权限 600),不会上传,也不会用于生成以外的用途。')),
|
|
216
296
|
h('div', { className: 'dshm-keys' },
|
|
217
297
|
providers.map((provider) => {
|
|
218
298
|
const draft = draftOf(provider)
|
|
@@ -226,7 +306,10 @@ window.__ModuleLoader__.load({
|
|
|
226
306
|
h('div', null, h('label', { className: 'dshm-lab' }, '模型'),
|
|
227
307
|
h('input', { className: 'dshm-input', value: draft.model, onChange: (e) => edit(provider.id, { model: e.target.value }) }))),
|
|
228
308
|
h('div', null, h('label', { className: 'dshm-lab' }, provider.configured ? 'API Key(留空表示不改)' : 'API Key'),
|
|
229
|
-
h('
|
|
309
|
+
h('div', { className: 'dshm-keywrap' },
|
|
310
|
+
h('input', { className: 'dshm-input', type: reveal[provider.id] === true ? 'text' : 'password', placeholder: provider.configured ? '••••••••' : '粘贴你的 Key', value: draft.key, onChange: (e) => edit(provider.id, { key: e.target.value }) }),
|
|
311
|
+
h('button', { className: 'dshm-eye', type: 'button', title: reveal[provider.id] === true ? '隐藏' : '显示', onClick: () => toggleReveal(provider.id) },
|
|
312
|
+
h(EyeIcon, { open: reveal[provider.id] === true })))),
|
|
230
313
|
h('div', { className: 'dshm-kfoot' },
|
|
231
314
|
provider.docs
|
|
232
315
|
? h('a', { href: provider.docs, target: '_blank', rel: 'noreferrer' }, '获取 Key ↗')
|
|
@@ -245,14 +328,16 @@ window.__ModuleLoader__.load({
|
|
|
245
328
|
h('div', null, h('label', { className: 'dshm-lab' }, '地址'),
|
|
246
329
|
h('input', { className: 'dshm-input', placeholder: 'https://api.example.com/v1', value: form.baseUrl, onChange: (e) => setForm({ ...form, baseUrl: e.target.value }) })),
|
|
247
330
|
h('div', null, h('label', { className: 'dshm-lab' }, 'API Key'),
|
|
248
|
-
h('
|
|
331
|
+
h('div', { className: 'dshm-keywrap' },
|
|
332
|
+
h('input', { className: 'dshm-input', type: reveal.custom === true ? 'text' : 'password', value: form.key, onChange: (e) => setForm({ ...form, key: e.target.value }) }),
|
|
333
|
+
h('button', { className: 'dshm-eye', type: 'button', title: reveal.custom === true ? '隐藏' : '显示', onClick: () => toggleReveal('custom') },
|
|
334
|
+
h(EyeIcon, { open: reveal.custom === true })))),
|
|
249
335
|
h('div', { className: 'dshm-kfoot' },
|
|
250
336
|
h('a', { href: '#', onClick: (e) => { e.preventDefault(); setAdding(false) } }, '取消'),
|
|
251
337
|
h('button', { className: 'dshm-save', disabled: saving === 'custom', onClick: addCustom },
|
|
252
338
|
saving === 'custom' ? '保存中…' : '添加')))
|
|
253
339
|
: h('button', { className: 'dshm-addrow', onClick: () => setAdding(true) }, '+ 添加自定义提供方')),
|
|
254
|
-
error !== null ? h('div', { className: 'dshm-err' }, error) : null
|
|
255
|
-
h('div', { className: 'dshm-hint' }, 'Key 写进本机 ~/.dsh/secrets/media-tools.env(权限 600),技能和面板共用同一份。'))
|
|
340
|
+
error !== null ? h('div', { className: 'dshm-err' }, error) : null)
|
|
256
341
|
}
|
|
257
342
|
|
|
258
343
|
function MediaPage() {
|
|
@@ -264,6 +349,8 @@ window.__ModuleLoader__.load({
|
|
|
264
349
|
const [error, setError] = React.useState(null)
|
|
265
350
|
const [works, setWorks] = React.useState(loadWorks)
|
|
266
351
|
const [current, setCurrent] = React.useState(() => loadWorks()[0] ?? null)
|
|
352
|
+
const [providerId, setProviderId] = React.useState('')
|
|
353
|
+
const [ratio, setRatio] = React.useState('16:9')
|
|
267
354
|
|
|
268
355
|
const refresh = React.useCallback(() => {
|
|
269
356
|
setStatusError(null)
|
|
@@ -277,17 +364,21 @@ window.__ModuleLoader__.load({
|
|
|
277
364
|
const usedToday = works.filter((work) => new Date(work.at).toDateString() === today).length
|
|
278
365
|
const left = Math.max(0, limit - usedToday)
|
|
279
366
|
const empty = prompt.trim() === ''
|
|
280
|
-
const
|
|
367
|
+
const imageProviders = (status?.providers ?? []).filter((provider) => String(provider.role ?? '').startsWith('生图'))
|
|
368
|
+
const active = imageProviders.find((provider) => provider.id === providerId && provider.configured)
|
|
369
|
+
?? imageProviders.find((provider) => provider.configured)
|
|
370
|
+
?? null
|
|
371
|
+
const size = (RATIOS.find(([id]) => id === ratio) ?? RATIOS[1])[1]
|
|
372
|
+
const sizeLabel = size.replace('x', '×')
|
|
281
373
|
|
|
282
374
|
const generate = React.useCallback(async () => {
|
|
283
375
|
if (busy || empty || left === 0) return
|
|
284
376
|
setBusy(true); setError(null)
|
|
285
377
|
try {
|
|
286
|
-
const
|
|
287
|
-
const result = await api('/generate', { method: 'POST', body: JSON.stringify({ prompt, size }) })
|
|
378
|
+
const result = await api('/generate', { method: 'POST', body: JSON.stringify({ prompt, size, provider: active?.id ?? '' }) })
|
|
288
379
|
const work = {
|
|
289
380
|
id: String(Date.now()), title: titleOf(prompt), prompt, size,
|
|
290
|
-
model, at: Date.now(),
|
|
381
|
+
model: result.model ?? active?.label ?? '未知模型', at: Date.now(),
|
|
291
382
|
src: `/media/file?p=${encodeURIComponent(result.file)}`,
|
|
292
383
|
}
|
|
293
384
|
const next = [work, ...works]
|
|
@@ -297,7 +388,7 @@ window.__ModuleLoader__.load({
|
|
|
297
388
|
} finally {
|
|
298
389
|
setBusy(false)
|
|
299
390
|
}
|
|
300
|
-
}, [busy, empty, left, prompt, works,
|
|
391
|
+
}, [busy, empty, left, prompt, works, active, size])
|
|
301
392
|
|
|
302
393
|
const head = h('div', { className: 'dshm-head' },
|
|
303
394
|
h('div', null,
|
|
@@ -334,9 +425,26 @@ window.__ModuleLoader__.load({
|
|
|
334
425
|
onChange: (e) => setPrompt(e.target.value),
|
|
335
426
|
onKeyDown: (e) => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') generate() },
|
|
336
427
|
}),
|
|
337
|
-
h('div', { className: 'dshm-
|
|
338
|
-
h(
|
|
339
|
-
|
|
428
|
+
h('div', { className: 'dshm-pickers' },
|
|
429
|
+
h(Picker, {
|
|
430
|
+
value: active !== null ? active.label : '未配置模型',
|
|
431
|
+
options: imageProviders.length === 0
|
|
432
|
+
? [{ id: 'none', label: '先去「生图 Key」配一个', disabled: true }]
|
|
433
|
+
: imageProviders.map((provider) => ({
|
|
434
|
+
id: provider.id,
|
|
435
|
+
label: provider.label,
|
|
436
|
+
current: active !== null && active.id === provider.id,
|
|
437
|
+
hint: provider.configured ? provider.model : '未配置',
|
|
438
|
+
disabled: !provider.configured,
|
|
439
|
+
})),
|
|
440
|
+
onPick: (option) => setProviderId(option.id),
|
|
441
|
+
}),
|
|
442
|
+
h(Picker, {
|
|
443
|
+
value: ratio,
|
|
444
|
+
options: RATIOS.map(([id, px]) => ({ id, label: id, current: id === ratio, hint: px.replace('x', '×') })),
|
|
445
|
+
onPick: (option) => setRatio(option.id),
|
|
446
|
+
}),
|
|
447
|
+
h('span', { className: 'dshm-size' }, sizeLabel)),
|
|
340
448
|
h('button', { className: 'dshm-cta', disabled: busy || empty || left === 0, onClick: generate },
|
|
341
449
|
busy ? '生成中…' : left === 0 ? '本次配额已用完' : '生成'),
|
|
342
450
|
h('div', { className: 'dshm-hint' }, '⌘⏎ 直接生成。结果只写进本机工作区,不会上传。')),
|
package/index.js
CHANGED
|
@@ -121,7 +121,7 @@ const MEDIA_PREFIX = '/media'
|
|
|
121
121
|
const PROVIDERS = [
|
|
122
122
|
{
|
|
123
123
|
id: 'sensenova', label: 'SenseNova U1 Fast', env: 'SENSENOVA_API_KEY', role: '生图 · 主用',
|
|
124
|
-
baseUrl: 'https://
|
|
124
|
+
baseUrl: 'https://token.sensenova.ai/v1', model: 'sensenova-u1-fast',
|
|
125
125
|
docs: 'https://platform.sensenova.cn',
|
|
126
126
|
},
|
|
127
127
|
{
|
|
@@ -305,12 +305,45 @@ export function originAllowed(req) {
|
|
|
305
305
|
}
|
|
306
306
|
}
|
|
307
307
|
|
|
308
|
+
/** Resolve one provider id into the env generate.py needs; throws a readable reason. */
|
|
309
|
+
export function resolveProvider(id) {
|
|
310
|
+
const settings = readSettings()
|
|
311
|
+
const status = mediaStatus()
|
|
312
|
+
const candidates = status.providers.filter((provider) => provider.role.startsWith('生图'))
|
|
313
|
+
const wanted = candidates.find((provider) => provider.id === id)
|
|
314
|
+
const target = wanted ?? candidates.find((provider) => provider.configured)
|
|
315
|
+
if (target === undefined) throw new Error('还没有可用的生图 Key,先去「生图 Key」配置一个。')
|
|
316
|
+
if (!target.configured) throw new Error(`${target.label} 还没配置 Key。`)
|
|
317
|
+
if (target.custom) {
|
|
318
|
+
const row = (Array.isArray(settings.custom) ? settings.custom : []).find((item) => item.id === target.id)
|
|
319
|
+
if (row === undefined || row.key === undefined || row.key === '') throw new Error(`${target.label} 还没配置 Key。`)
|
|
320
|
+
return {
|
|
321
|
+
label: target.label,
|
|
322
|
+
model: target.model,
|
|
323
|
+
env: {
|
|
324
|
+
MEDIA_API_URL: joinEndpoint(row.baseUrl),
|
|
325
|
+
MEDIA_API_KEY: row.key,
|
|
326
|
+
MEDIA_MODEL: target.model,
|
|
327
|
+
},
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return { label: target.label, model: target.model, env: { MEDIA_PROVIDER: target.id, MEDIA_MODEL: target.model } }
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Append the images path unless the user already pasted a full endpoint. */
|
|
334
|
+
export function joinEndpoint(baseUrl) {
|
|
335
|
+
const trimmed = String(baseUrl ?? '').trim().replace(/\/+$/, '')
|
|
336
|
+
if (trimmed === '') return ''
|
|
337
|
+
if (/\/images\/generations$/.test(trimmed)) return trimmed
|
|
338
|
+
return `${trimmed}/images/generations`
|
|
339
|
+
}
|
|
340
|
+
|
|
308
341
|
/** Run the same `generate.py` the media-tools skill runs — one implementation. */
|
|
309
|
-
export function runGenerate({ prompt, size, out, timeoutMs = 180_000 }) {
|
|
342
|
+
export function runGenerate({ prompt, size, out, env = {}, timeoutMs = 180_000 }) {
|
|
310
343
|
const script = fileURLToPath(new URL('./skills/media-tools/scripts/generate.py', import.meta.url))
|
|
311
344
|
return new Promise((resolve) => {
|
|
312
345
|
let stderr = ''
|
|
313
|
-
const child = spawn('python3', [script, prompt, out, size], { cwd: dirname(script) })
|
|
346
|
+
const child = spawn('python3', [script, prompt, out, size], { cwd: dirname(script), env: { ...process.env, ...env } })
|
|
314
347
|
const timer = setTimeout(() => child.kill('SIGTERM'), timeoutMs)
|
|
315
348
|
child.stderr.on('data', (chunk) => { stderr += String(chunk) })
|
|
316
349
|
child.on('error', (error) => { clearTimeout(timer); resolve({ ok: false, message: error.message }) })
|
|
@@ -390,10 +423,19 @@ export function createMediaHandler({ workspaceRoot } = {}) {
|
|
|
390
423
|
return
|
|
391
424
|
}
|
|
392
425
|
const size = String(parsed.size ?? '1024x1024')
|
|
426
|
+
let provider
|
|
427
|
+
try {
|
|
428
|
+
provider = resolveProvider(String(parsed.provider ?? ''))
|
|
429
|
+
} catch (error) {
|
|
430
|
+
res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
|
|
431
|
+
res.end(JSON.stringify({ ok: false, message: error.message }))
|
|
432
|
+
return
|
|
433
|
+
}
|
|
393
434
|
const dir = join(root(), 'uploads', 'media')
|
|
394
435
|
mkdirSync(dir, { recursive: true })
|
|
395
436
|
const out = join(dir, `${Date.now()}.png`)
|
|
396
|
-
const result = await runGenerate({ prompt, size, out })
|
|
437
|
+
const result = await runGenerate({ prompt, size, out, env: provider.env })
|
|
438
|
+
if (result.ok) result.model = provider.label
|
|
397
439
|
res.writeHead(result.ok ? 200 : 502, { 'content-type': 'application/json; charset=utf-8' })
|
|
398
440
|
res.end(JSON.stringify(result))
|
|
399
441
|
return
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-media-skills",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.3",
|
|
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",
|
|
@@ -5,7 +5,7 @@ description: "图片生成。用户要求生成图片、插画、头像、封面
|
|
|
5
5
|
|
|
6
6
|
# Media Tools(图片生成)
|
|
7
7
|
|
|
8
|
-
通过用户自行配置的服务商生成图片素材。优先使用 SenseNova U1 Fast;未配置 SenseNova key 时,使用 SiliconFlow Kolors
|
|
8
|
+
通过用户自行配置的服务商生成图片素材。优先使用 SenseNova U1 Fast;未配置 SenseNova key 时,使用 SiliconFlow Kolors。面板里配置的自定义提供方(任意 OpenAI 兼容 `/images/generations`)也能用。
|
|
9
9
|
|
|
10
10
|
## 用法
|
|
11
11
|
|
|
@@ -27,6 +27,22 @@ python3 scripts/generate.py "简洁科技感产品发布会背景,深蓝渐变
|
|
|
27
27
|
- SiliconFlow 会把第三参数作为 `image_size` 传给 Kolors。
|
|
28
28
|
- 缺省尺寸为 `1024x1024`。
|
|
29
29
|
|
|
30
|
+
## 选择服务商
|
|
31
|
+
|
|
32
|
+
脚本默认按 key 是否存在自动挑。要指定服务商,用环境变量:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
MEDIA_PROVIDER=siliconflow python3 scripts/generate.py "..." out.png 16:9 # 指定内置服务商
|
|
36
|
+
MEDIA_MODEL=other-model python3 scripts/generate.py "..." out.png 16:9 # 换模型 ID
|
|
37
|
+
|
|
38
|
+
# 任意 OpenAI 兼容端点(面板里的「自定义提供方」走的就是这条)
|
|
39
|
+
MEDIA_API_URL=https://api.example.com/v1/images/generations \
|
|
40
|
+
MEDIA_API_KEY=sk-... MEDIA_MODEL=my-model \
|
|
41
|
+
python3 scripts/generate.py "..." out.png 1024x1024
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
自定义端点按 OpenAI 兼容格式发送 `{"model","prompt","size","n"}`,接受 `data[0].url`、`data[0].b64_json`、`images[0].url` 三种返回。
|
|
45
|
+
|
|
30
46
|
## 密钥来源
|
|
31
47
|
|
|
32
48
|
按顺序读取:
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Generate images via SenseNova U1 Fast or
|
|
2
|
+
"""Generate images via SenseNova U1 Fast, SiliconFlow Kolors, or any custom endpoint.
|
|
3
3
|
|
|
4
4
|
Provider selection:
|
|
5
|
-
1.
|
|
6
|
-
2.
|
|
5
|
+
1. MEDIA_API_URL + MEDIA_API_KEY set -> that OpenAI-compatible /images/generations endpoint.
|
|
6
|
+
2. MEDIA_PROVIDER=sensenova|siliconflow -> that built-in, its own key.
|
|
7
|
+
3. Neither set -> SENSENOVA_API_KEY wins, else SILICONFLOW_API_KEY.
|
|
8
|
+
MEDIA_MODEL overrides the model id in every case.
|
|
7
9
|
"""
|
|
8
10
|
import json, os, sys, urllib.request
|
|
9
11
|
from pathlib import Path
|
|
@@ -69,8 +71,8 @@ def sensenova_size(size):
|
|
|
69
71
|
except (ValueError, ZeroDivisionError):
|
|
70
72
|
return "2048x2048"
|
|
71
73
|
|
|
72
|
-
def generate_sensenova(prompt, out, size):
|
|
73
|
-
body = {"model": SENSENOVA_MODEL, "prompt": prompt, "size": sensenova_size(size), "n": 1}
|
|
74
|
+
def generate_sensenova(prompt, out, size, model=None):
|
|
75
|
+
body = {"model": model or SENSENOVA_MODEL, "prompt": prompt, "size": sensenova_size(size), "n": 1}
|
|
74
76
|
req = urllib.request.Request(SENSENOVA_URL,
|
|
75
77
|
data=json.dumps(body).encode(),
|
|
76
78
|
headers={"Content-Type": "application/json", "Authorization": "Bearer " + load_key("SENSENOVA_API_KEY")}, method="POST")
|
|
@@ -80,8 +82,8 @@ def generate_sensenova(prompt, out, size):
|
|
|
80
82
|
urllib.request.urlretrieve(url, out)
|
|
81
83
|
print("done:", out)
|
|
82
84
|
|
|
83
|
-
def generate_siliconflow(prompt, out, size):
|
|
84
|
-
body = {"model": SILICONFLOW_MODEL, "prompt": prompt, "image_size": size, "batch_size": 1}
|
|
85
|
+
def generate_siliconflow(prompt, out, size, model=None):
|
|
86
|
+
body = {"model": model or SILICONFLOW_MODEL, "prompt": prompt, "image_size": size, "batch_size": 1}
|
|
85
87
|
req = urllib.request.Request(SILICONFLOW_URL,
|
|
86
88
|
data=json.dumps(body).encode(),
|
|
87
89
|
headers={"Content-Type": "application/json", "Authorization": "Bearer " + load_key("SILICONFLOW_API_KEY")}, method="POST")
|
|
@@ -91,15 +93,71 @@ def generate_siliconflow(prompt, out, size):
|
|
|
91
93
|
urllib.request.urlretrieve(url, out)
|
|
92
94
|
print("done:", out)
|
|
93
95
|
|
|
96
|
+
def ratio_to_size(size):
|
|
97
|
+
"""Turn `16:9` shorthand into a WxH string; pass WxH through untouched."""
|
|
98
|
+
size = (size or "1024x1024").strip().lower()
|
|
99
|
+
if ":" not in size:
|
|
100
|
+
return size
|
|
101
|
+
try:
|
|
102
|
+
wr, hr = map(int, size.split(":"))
|
|
103
|
+
except ValueError:
|
|
104
|
+
return "1024x1024"
|
|
105
|
+
long_side = 1024
|
|
106
|
+
if wr >= hr:
|
|
107
|
+
return f"{long_side}x{max(64, round(long_side * hr / wr))}"
|
|
108
|
+
return f"{max(64, round(long_side * wr / hr))}x{long_side}"
|
|
109
|
+
|
|
110
|
+
def pick_image_url(payload):
|
|
111
|
+
"""Accept the shapes OpenAI-compatible image endpoints return."""
|
|
112
|
+
for container in ("data", "images", "output"):
|
|
113
|
+
items = payload.get(container) if isinstance(payload, dict) else None
|
|
114
|
+
if isinstance(items, list) and items and isinstance(items[0], dict):
|
|
115
|
+
first = items[0]
|
|
116
|
+
if first.get("url"):
|
|
117
|
+
return first["url"]
|
|
118
|
+
if first.get("b64_json"):
|
|
119
|
+
return "data:image/png;base64," + first["b64_json"]
|
|
120
|
+
raise SystemExit("接口没有返回图片地址:" + json.dumps(payload, ensure_ascii=False)[:300])
|
|
121
|
+
|
|
122
|
+
def generate_custom(prompt, out, size):
|
|
123
|
+
"""Any OpenAI-compatible /images/generations endpoint the panel configured."""
|
|
124
|
+
url = os.environ.get("MEDIA_API_URL", "").strip()
|
|
125
|
+
key = os.environ.get("MEDIA_API_KEY", "").strip()
|
|
126
|
+
model = os.environ.get("MEDIA_MODEL", "").strip()
|
|
127
|
+
if not url or not key:
|
|
128
|
+
sys.exit("自定义提供方缺少地址或 Key,请在面板的「生图 Key」里补上。")
|
|
129
|
+
body = {"model": model, "prompt": prompt, "size": ratio_to_size(size), "n": 1}
|
|
130
|
+
req = urllib.request.Request(url,
|
|
131
|
+
data=json.dumps(body).encode(),
|
|
132
|
+
headers={"Content-Type": "application/json", "Authorization": "Bearer " + key}, method="POST")
|
|
133
|
+
with urllib.request.urlopen(req, timeout=300) as r:
|
|
134
|
+
data = json.load(r)
|
|
135
|
+
target = pick_image_url(data)
|
|
136
|
+
if target.startswith("data:"):
|
|
137
|
+
import base64
|
|
138
|
+
Path(out).write_bytes(base64.b64decode(target.split(",", 1)[1]))
|
|
139
|
+
else:
|
|
140
|
+
urllib.request.urlretrieve(target, out)
|
|
141
|
+
print("done:", out)
|
|
142
|
+
|
|
94
143
|
def main():
|
|
95
144
|
if len(sys.argv) < 3:
|
|
96
145
|
sys.exit("用法: generate.py <prompt> <out.png> [size=1024x1024]")
|
|
97
146
|
prompt, out = sys.argv[1], sys.argv[2]
|
|
98
147
|
size = sys.argv[3] if len(sys.argv) > 3 else "1024x1024"
|
|
99
|
-
|
|
100
|
-
|
|
148
|
+
provider = os.environ.get("MEDIA_PROVIDER", "").strip()
|
|
149
|
+
model = os.environ.get("MEDIA_MODEL", "").strip() or None
|
|
150
|
+
|
|
151
|
+
if os.environ.get("MEDIA_API_URL"):
|
|
152
|
+
generate_custom(prompt, out, size)
|
|
153
|
+
elif provider == "siliconflow":
|
|
154
|
+
generate_siliconflow(prompt, out, size, model)
|
|
155
|
+
elif provider == "sensenova":
|
|
156
|
+
generate_sensenova(prompt, out, size, model)
|
|
157
|
+
elif get_key("SENSENOVA_API_KEY"):
|
|
158
|
+
generate_sensenova(prompt, out, size, model)
|
|
101
159
|
elif get_key("SILICONFLOW_API_KEY"):
|
|
102
|
-
generate_siliconflow(prompt, out, size)
|
|
160
|
+
generate_siliconflow(prompt, out, size, model)
|
|
103
161
|
else:
|
|
104
162
|
sys.exit("缺少图片生成 Key:请配置 SENSENOVA_API_KEY 或 SILICONFLOW_API_KEY(环境变量或 ~/.dsh/secrets/media-tools.env)")
|
|
105
163
|
|