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 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('input', { className: 'dshm-input', type: 'password', placeholder: provider.configured ? '••••••••' : '粘贴你的 Key', value: draft.key, onChange: (e) => edit(provider.id, { key: e.target.value }) })),
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('input', { className: 'dshm-input', type: 'password', value: form.key, onChange: (e) => setForm({ ...form, key: e.target.value }) })),
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 model = status?.providers?.find((p) => p.configured)?.label ?? '未配置密钥'
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 size = '16:9'
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, model])
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-row' },
338
- h('b', null, model), h('span', { className: 'dshm-dot' }), h('span', null, '16:9'),
339
- h('span', { className: 'dshm-dot' }), h('span', null, '1024×576')),
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://api.sensenova.cn/v1', model: 'sense-nova-u1-fast',
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.2",
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 SiliconFlow Kolors.
2
+ """Generate images via SenseNova U1 Fast, SiliconFlow Kolors, or any custom endpoint.
3
3
 
4
4
  Provider selection:
5
- 1. If SENSENOVA_API_KEY is set, use SenseNova U1 Fast (https://token.sensenova.ai).
6
- 2. Otherwise, if SILICONFLOW_API_KEY is set, use SiliconFlow Kolors.
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
- if get_key("SENSENOVA_API_KEY"):
100
- generate_sensenova(prompt, out, size)
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