@goodandready/dsh-image-gen 0.8.7 → 0.8.9

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/lib/client.js CHANGED
@@ -18,6 +18,24 @@ window.__ModuleLoader__.load({
18
18
  let react_jsx_runtime = require('react/jsx-runtime')
19
19
  let runtime = require('@deepseek-ai/dsh-client-runtime/client')
20
20
 
21
+ // Ядровый шеврон раскрытия; в урезанной сборке — запасной SVG той же формы.
22
+ let ChevronIcon = null
23
+ try {
24
+ const primitives = require('@deepseek-ai/dsh-client-ui-primitives')
25
+ ChevronIcon = primitives && primitives.IconChevronDownOutline14
26
+ } catch (noPrimitives) {
27
+ ChevronIcon = null
28
+ }
29
+ const ChevronIconNode = ChevronIcon || react.createElement('svg', {
30
+ width: 14, height: 14, viewBox: '0 0 14 14', 'aria-hidden': true,
31
+ style: { display: 'block' },
32
+ }, react.createElement('path', {
33
+ d: 'M3 5.5 L7 9.5 L11 5.5',
34
+ fill: 'none', stroke: 'currentColor', strokeWidth: 1.5,
35
+ strokeLinecap: 'round', strokeLinejoin: 'round',
36
+ }))
37
+
38
+
21
39
  // ---------------------------------------------------------------- css
22
40
  const css =
23
41
  '.fal_card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;list-style:none}' +
@@ -55,7 +73,12 @@ window.__ModuleLoader__.load({
55
73
  '.fal_input:disabled,.fal_select:disabled{opacity:.6}' +
56
74
  '.fal_hint{color:var(--dsw-alias-label-secondary);margin:0;font-size:12px}' +
57
75
  '.fal_invalid{color:var(--dsw-alias-state-error-primary);margin:0;font-size:12px}' +
58
- '.fal_sep{height:1px;background:var(--dsw-alias-border-l2);margin:2px 0 0}'
76
+ '.fal_sep{height:1px;background:var(--dsw-alias-border-l2);margin:2px 0 0}' +
77
+ '.fal_gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:10px;margin-top:12px}' +
78
+ '.fal_galleryItem{display:flex;flex-direction:column;gap:4px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;overflow:hidden}' +
79
+ '.fal_galleryImg{width:100%;height:90px;object-fit:cover;display:block}' +
80
+ '.fal_galleryMeta{font-size:11px;color:var(--dsw-alias-label-secondary);padding:4px 6px}' +
81
+ '.fal_galleryEmpty{color:var(--dsw-alias-label-secondary);font-size:12px;margin-top:12px}'
59
82
  const tagId = 'dsh-image-gen/settings-card.module.css'
60
83
  if (typeof document !== 'undefined' && document.querySelector('style[data-plugin-css=' + JSON.stringify(tagId) + ']') === null) {
61
84
  const tag = document.createElement('style')
@@ -92,6 +115,11 @@ window.__ModuleLoader__.load({
92
115
  save: 'fal_save',
93
116
  select: 'fal_select',
94
117
  sep: 'fal_sep',
118
+ gallery: 'fal_gallery',
119
+ galleryItem: 'fal_galleryItem',
120
+ galleryImg: 'fal_galleryImg',
121
+ galleryMeta: 'fal_galleryMeta',
122
+ galleryEmpty: 'fal_galleryEmpty',
95
123
  }
96
124
 
97
125
  // -------------------------------------------------------------- chrome
@@ -134,7 +162,7 @@ window.__ModuleLoader__.load({
134
162
  ],
135
163
  }),
136
164
  state.dirty ? react_jsx_runtime.jsx('span', { className: cssDefault.pending, children: props.t('settings.unsaved') }) : null,
137
- react_jsx_runtime.jsx('span', { className: cssDefault.chevron + (open ? ' ' + cssDefault.chevronOpen : ''), children: '\u25be' }),
165
+ react_jsx_runtime.jsx('span', { className: cssDefault.chevron + (open ? ' ' + cssDefault.chevronOpen : ''), children: ChevronIconNode }),
138
166
  ],
139
167
  }),
140
168
  open
@@ -424,18 +452,18 @@ window.__ModuleLoader__.load({
424
452
  /** Field specs in display order. */
425
453
  const FIELDS = [
426
454
  { field: 'provider', spec: selectField('provider', PROVIDERS), kind: 'select', options: PROVIDERS, labelKey: 'f.provider', hintKey: 'f.providerHint', providerNeeds: { fal: 'ключ FAL', custom: 'адрес + ключ', codex: 'подписка ChatGPT', grok: 'подписка Grok', local: 'адрес сервера' } },
427
- { field: 'model', spec: textField('model'), kind: 'text', labelKey: 'f.model', hintKey: 'f.modelHint', placeholderKey: 'p.model' },
428
- { field: 'apiKeyEnv', spec: textField('apiKeyEnv'), kind: 'text', labelKey: 'f.apiKeyEnv', hintKey: 'f.apiKeyEnvHint', placeholderKey: 'p.apiKeyEnv' },
429
- { field: 'baseURL', spec: textField('baseURL'), kind: 'text', labelKey: 'f.baseURL', hintKey: 'f.baseURLHint', placeholderKey: 'p.baseURL' },
455
+ { field: 'model', spec: textField('model'), kind: 'text', labelKey: 'f.model', hintKey: 'f.modelHint', placeholderKey: 'p.model', when: ['fal'] },
456
+ { field: 'apiKeyEnv', spec: textField('apiKeyEnv'), kind: 'text', labelKey: 'f.apiKeyEnv', hintKey: 'f.apiKeyEnvHint', placeholderKey: 'p.apiKeyEnv', when: ['fal'] },
457
+ { field: 'baseURL', spec: textField('baseURL'), kind: 'text', labelKey: 'f.baseURL', hintKey: 'f.baseURLHint', placeholderKey: 'p.baseURL', when: ['fal'] },
430
458
  { field: 'defaultSize', spec: selectField('defaultSize', IMAGE_SIZES), kind: 'select', options: IMAGE_SIZES, labelKey: 'f.defaultSize', hintKey: 'f.defaultSizeHint', sizePixels: SIZE_PIXELS },
431
459
  { field: 'defaultFormat', spec: selectField('defaultFormat', OUTPUT_FORMATS), kind: 'select', options: OUTPUT_FORMATS, labelKey: 'f.defaultFormat', hintKey: 'f.defaultFormatHint' },
432
- { field: 'pollIntervalMs', spec: numberField('pollIntervalMs'), kind: 'number', labelKey: 'f.pollIntervalMs', hintKey: 'f.pollIntervalMsHint', placeholderKey: 'p.pollIntervalMs' },
433
- { field: 'timeoutMs', spec: numberField('timeoutMs'), kind: 'number', labelKey: 'f.timeoutMs', hintKey: 'f.timeoutMsHint', placeholderKey: 'p.timeoutMs' },
460
+ { field: 'pollIntervalMs', spec: numberField('pollIntervalMs'), kind: 'number', labelKey: 'f.pollIntervalMs', hintKey: 'f.pollIntervalMsHint', placeholderKey: 'p.pollIntervalMs', when: ['fal'] },
461
+ { field: 'timeoutMs', spec: numberField('timeoutMs'), kind: 'number', labelKey: 'f.timeoutMs', hintKey: 'f.timeoutMsHint', placeholderKey: 'p.timeoutMs', when: ['fal'] },
434
462
  { field: 'deliverAs', spec: selectField('deliverAs', DELIVERY_MODES), kind: 'select', options: DELIVERY_MODES, labelKey: 'f.deliverAs', hintKey: 'f.deliverAsHint' },
435
- { field: 'customBaseURL', spec: textField('customBaseURL'), kind: 'text', labelKey: 'f.customBaseURL', hintKey: 'f.customBaseURLHint', placeholderKey: 'p.customBaseURL' },
436
- { field: 'customModel', spec: textField('customModel'), kind: 'text', labelKey: 'f.customModel', hintKey: 'f.customModelHint', placeholderKey: 'p.customModel' },
437
- { field: 'customKeyEnv', spec: textField('customKeyEnv'), kind: 'text', labelKey: 'f.customKeyEnv', hintKey: 'f.customKeyEnvHint', placeholderKey: 'p.customKeyEnv' },
438
- { field: 'customSize', spec: textField('customSize'), kind: 'text', labelKey: 'f.customSize', hintKey: 'f.customSizeHint', placeholderKey: 'p.customSize' },
463
+ { field: 'customBaseURL', spec: textField('customBaseURL'), kind: 'text', labelKey: 'f.customBaseURL', hintKey: 'f.customBaseURLHint', placeholderKey: 'p.customBaseURL', when: ['custom'] },
464
+ { field: 'customModel', spec: textField('customModel'), kind: 'text', labelKey: 'f.customModel', hintKey: 'f.customModelHint', placeholderKey: 'p.customModel', when: ['custom'] },
465
+ { field: 'customKeyEnv', spec: textField('customKeyEnv'), kind: 'text', labelKey: 'f.customKeyEnv', hintKey: 'f.customKeyEnvHint', placeholderKey: 'p.customKeyEnv', when: ['custom'] },
466
+ { field: 'customSize', spec: textField('customSize'), kind: 'text', labelKey: 'f.customSize', hintKey: 'f.customSizeHint', placeholderKey: 'p.customSize', when: ['custom'] },
439
467
  { field: 'outputDir', spec: textField('outputDir'), kind: 'text', labelKey: 'f.outputDir', hintKey: 'f.outputDirHint', placeholderKey: 'p.outputDir' },
440
468
  ]
441
469
 
@@ -465,10 +493,68 @@ window.__ModuleLoader__.load({
465
493
  }
466
494
 
467
495
  /** Render the plugin settings card. */
496
+ /** Галерея истории генераций: тянет /history и рендерит миниатюры. */
497
+ function HistoryGallery(props) {
498
+ const [entries, setEntries] = react.useState([])
499
+ const [loading, setLoading] = react.useState(true)
500
+ const [filter, setFilter] = react.useState('')
501
+ react.useEffect(() => {
502
+ let cancelled = false
503
+ fetch('/dsh-image-gen/history')
504
+ .then((r) => r.json())
505
+ .then((data) => { if (!cancelled) { setEntries(Array.isArray(data) ? data : []); setLoading(false) } })
506
+ .catch(() => { if (!cancelled) setLoading(false) })
507
+ return () => { cancelled = true }
508
+ }, [])
509
+ const t = props.t
510
+ if (loading) return react_jsx_runtime.jsx('p', { className: cssDefault.galleryEmpty, children: t('history.loading') })
511
+ if (!entries.length) return react_jsx_runtime.jsx('p', { className: cssDefault.galleryEmpty, children: t('history.empty') })
512
+ const providers = [...new Set(entries.map((e) => e.provider).filter(Boolean))]
513
+ const filtered = filter ? entries.filter((e) => e.provider === filter) : entries
514
+ return react_jsx_runtime.jsxs('div', {
515
+ className: cssDefault.gallery,
516
+ children: [
517
+ react_jsx_runtime.jsx('select', {
518
+ className: cssDefault.select,
519
+ value: filter,
520
+ onChange: (e) => setFilter(e.target.value),
521
+ children: [
522
+ react_jsx_runtime.jsx('option', { value: '', children: t('history.all') }),
523
+ providers.map((p) => react_jsx_runtime.jsx('option', { value: p, children: p }, p)),
524
+ ],
525
+ }),
526
+ filtered.map((e) =>
527
+ react_jsx_runtime.jsxs('a', {
528
+ className: cssDefault.galleryItem,
529
+ key: e.path,
530
+ href: e.thumbnailUrl || '#',
531
+ target: '_blank',
532
+ rel: 'noreferrer',
533
+ children: [
534
+ e.thumbnailUrl ? react_jsx_runtime.jsx('img', { className: cssDefault.galleryImg, src: e.thumbnailUrl, alt: e.prompt || '', loading: 'lazy' }) : null,
535
+ react_jsx_runtime.jsx('div', { className: cssDefault.galleryMeta, children: `${e.provider || ''} · ${e.size || ''}` }),
536
+ e.seed !== undefined ? react_jsx_runtime.jsx('button', {
537
+ type: 'button',
538
+ className: 'fal-repeat',
539
+ onClick: (ev) => {
540
+ ev.preventDefault()
541
+ ev.stopPropagation()
542
+ try { navigator.clipboard.writeText(String(e.seed)) } catch (err) { /* нет доступа к буферу */ }
543
+ },
544
+ }, `seed ${e.seed}`) : null,
545
+ ],
546
+ }),
547
+ ),
548
+ ],
549
+ })
550
+ }
551
+
468
552
  function FalSettingsCard(props) {
469
553
  const { t } = props
470
554
  const state = props.useFalSettingsCard((snapshot) => snapshot)
471
555
  const disabled = !state.writable
556
+ const currentProvider = (state.provider && state.provider.text) || 'fal'
557
+ const visibleFields = FIELDS.filter((entry) => !entry.when || entry.when.includes(currentProvider))
472
558
  const fieldProps = {
473
559
  overriddenLabel: t('settings.overridden'),
474
560
  resetLabel: t('settings.reset'),
@@ -484,7 +570,7 @@ window.__ModuleLoader__.load({
484
570
  onDiscard: props.discard,
485
571
  children: [
486
572
  react_jsx_runtime.jsx('p', { className: cssDefault.hint, children: t('settings.credentialHint') }),
487
- FIELDS.map((entry) =>
573
+ visibleFields.map((entry) =>
488
574
  react_jsx_runtime.jsx(
489
575
  Field,
490
576
  {
@@ -498,6 +584,9 @@ window.__ModuleLoader__.load({
498
584
  entry.field,
499
585
  ),
500
586
  ),
587
+ react_jsx_runtime.jsx('div', { className: cssDefault.sep, key: 'hsep' }),
588
+ react_jsx_runtime.jsx('p', { className: cssDefault.hint, key: 'hlabel', children: t('history.title') }),
589
+ react_jsx_runtime.jsx(HistoryGallery, { t, key: 'history' }),
501
590
  ],
502
591
  })
503
592
  }
@@ -583,6 +672,10 @@ window.__ModuleLoader__.load({
583
672
  'p.customKeyEnv': 'Example: OPENAI_API_KEY',
584
673
  'p.customSize': 'Example: 1024x1024',
585
674
  'p.outputDir': 'Example: generated/images',
675
+ 'history.title': 'Generation history',
676
+ 'history.all': 'All providers',
677
+ 'history.empty': 'No generations yet',
678
+ 'history.loading': 'Loading…',
586
679
  }
587
680
 
588
681
  const ru = {
@@ -642,6 +735,10 @@ window.__ModuleLoader__.load({
642
735
  'p.customKeyEnv': 'Пример: OPENAI_API_KEY',
643
736
  'p.customSize': 'Пример: 1024x1024',
644
737
  'p.outputDir': 'Пример: generated/images',
738
+ 'history.title': 'История генераций',
739
+ 'history.all': 'Все провайдеры',
740
+ 'history.empty': 'Ещё нет генераций',
741
+ 'history.loading': 'Загрузка…',
645
742
  }
646
743
 
647
744
  // ── карточка результата generate_image ────────────────────────────────
@@ -701,6 +798,8 @@ window.__ModuleLoader__.load({
701
798
  const running = !('kind' in (block || {}))
702
799
  const failed = block && (block.isError || block.error !== undefined)
703
800
  const parsed = readResult(block)
801
+ const [editing, setEditing] = react.useState(false)
802
+ const [editText, setEditText] = react.useState('')
704
803
  let prompt = ''
705
804
  try {
706
805
  const raw = (block && (block.call ? block.call.argsRaw : block.argsRaw)) || ''
@@ -805,6 +904,43 @@ window.__ModuleLoader__.load({
805
904
  onClick: repeat,
806
905
  }, 'Повторить генерацию'))
807
906
  }
907
+ if (!running && !failed) {
908
+ if (editing) {
909
+ body.push(react.createElement('input', {
910
+ key: 'editinput',
911
+ type: 'text',
912
+ className: 'fal-input',
913
+ value: editText,
914
+ placeholder: prompt || 'Новый промпт',
915
+ onChange: (e) => setEditText(e.target.value),
916
+ }))
917
+ body.push(react.createElement('button', {
918
+ key: 'editsend',
919
+ type: 'button',
920
+ className: 'fal-repeat',
921
+ onClick: async () => {
922
+ const text = (editText || prompt || '').trim()
923
+ if (!text) return
924
+ try {
925
+ const sessions = props.sessions
926
+ const current = sessions && sessions.list && sessions.list.current
927
+ const binding = current && sessions.binding(current)
928
+ const session = binding && binding.session
929
+ if (session && session.prompt) await session.prompt([{ type: 'text', text }], 'queue')
930
+ } catch (e) { /* контур сам покажет */ }
931
+ setEditing(false)
932
+ setEditText('')
933
+ },
934
+ }, 'Отправить'))
935
+ } else {
936
+ body.push(react.createElement('button', {
937
+ key: 'edit',
938
+ type: 'button',
939
+ className: 'fal-repeat',
940
+ onClick: () => { setEditText(prompt || ''); setEditing(true) },
941
+ }, 'Изменить промпт'))
942
+ }
943
+ }
808
944
  if (!running && !failed && downloadUrl) {
809
945
  body.push(react.createElement('a', {
810
946
  key: 'download',
package/lib/index.js CHANGED
@@ -16,6 +16,7 @@ import z from '@deepseek-ai/schemastery'
16
16
  import { defineTool } from '@deepseek-ai/dsh-tools'
17
17
  import { credentialRef } from '@deepseek-ai/dsh-credentials'
18
18
  import { mkdir, readFile, writeFile } from 'node:fs/promises'
19
+ import os from 'node:os'
19
20
  import path from 'node:path'
20
21
  import {
21
22
  IMAGE_SIZES,
@@ -23,6 +24,8 @@ import {
23
24
  PROVIDER_KEYS,
24
25
  buildSidecar,
25
26
  makeProviders,
27
+ ASPECT_RATIOS,
28
+ pixelDiff,
26
29
  normalizeCount,
27
30
  tryGenerate,
28
31
  fallbackOrder,
@@ -128,6 +131,22 @@ export const Config = z.object({
128
131
  .number()
129
132
  .description('provider=local: CFG scale.')
130
133
  .default(7),
134
+ seedreamKeyEnv: z
135
+ .string()
136
+ .description('provider=seedream: credential reference / env var holding the API key.')
137
+ .default('SEEDREAM_API_KEY'),
138
+ seedreamModel: z
139
+ .string()
140
+ .description('provider=seedream: model id, e.g. seedream-4.0.')
141
+ .default('seedream-4.0'),
142
+ geminiKeyEnv: z
143
+ .string()
144
+ .description('provider=gemini: credential reference / env var holding the Google API key.')
145
+ .default('GEMINI_API_KEY'),
146
+ geminiModel: z
147
+ .string()
148
+ .description('provider=gemini: model id, e.g. gemini-2.0-flash-exp-image-generation.')
149
+ .default('gemini-2.0-flash-exp-image-generation'),
131
150
  outputDir: z
132
151
  .string()
133
152
  .description('Where generated images are saved. A relative path resolves against the session working directory; an absolute path is used as given.')
@@ -136,6 +155,10 @@ export const Config = z.object({
136
155
  .number()
137
156
  .description('How many recent generations to keep in the in-memory history list.')
138
157
  .default(50),
158
+ pruneDays: z
159
+ .number()
160
+ .description('Delete generated files and history entries older than this many days. 0 (default) disables pruning.')
161
+ .default(0),
139
162
  enhancePrompt: z
140
163
  .boolean()
141
164
  .description('Expand a short prompt into a detailed one through the chat model before generating. Off by default.')
@@ -148,6 +171,18 @@ export const Config = z.object({
148
171
  .number()
149
172
  .description('Only enhance prompts shorter than this many characters.')
150
173
  .default(200),
174
+ stylePreset: z
175
+ .string()
176
+ .description('Optional style suffix appended to the prompt before generation. Empty (default) means no style is applied and the prompt is used as-is.')
177
+ .default(''),
178
+ cacheBySeed: z
179
+ .boolean()
180
+ .description('If the same seed+prompt was already generated (present in history), return the cached result instead of generating again. Off by default.')
181
+ .default(false),
182
+ cacheByPrompt: z
183
+ .boolean()
184
+ .description('If the same prompt was already generated (present in history), return the cached result instead of generating again. Off by default.')
185
+ .default(false),
151
186
  })
152
187
 
153
188
  /** Keep a file stem safe for the filesystem. */
@@ -214,8 +249,73 @@ function migrateLegacySettings(sctx, scope) {
214
249
  }
215
250
  }
216
251
 
217
- /** In-memory история генераций (пополняется в execute, где доступен cwd). */
218
- const history = []
252
+ /** Каталог истории: общий для всех устройств, переживает рестарт. */
253
+ export function historyFile() {
254
+ return path.join(process.env.DSH_HOME || path.join(os.homedir(), '.dsh'), 'dsh-image-gen', 'history.json')
255
+ }
256
+
257
+ /** Прочитать историю из файла (пусто, если файла нет). */
258
+ /** Найти запись истории по seed+prompt, если файл ещё существует. */
259
+ /** Найти запись истории по промпту (без учёта seed), если файл существует. */
260
+ export async function findCachedByPrompt(entries, prompt) {
261
+ return entries.find((e) => e.prompt === prompt)
262
+ }
263
+
264
+ export async function findCached(entries, seed, prompt) {
265
+ if (seed === undefined) return undefined
266
+ return entries.find((e) => e.seed === seed && e.prompt === prompt)
267
+ }
268
+
269
+ /** Вернуть запись из кэша, если файл существует; иначе undefined. */
270
+ export async function cachedResult(entry) {
271
+ if (!entry || !entry.path) return undefined
272
+ try { require('node:fs').accessSync(entry.path) } catch (e) { return undefined }
273
+ return {
274
+ path: entry.path,
275
+ url: entry.attachmentId ? `/dsh-image-gen/image?id=${encodeURIComponent(entry.attachmentId)}` : '',
276
+ width: 0,
277
+ height: 0,
278
+ seed: entry.seed,
279
+ prompt: entry.prompt,
280
+ format: 'png',
281
+ cached: true,
282
+ attachment: entry.attachmentId ? { attachmentId: entry.attachmentId, mediaType: 'image/png', bytes: 0, width: 0, height: 0, name: '' } : undefined,
283
+ }
284
+ }
285
+
286
+ /** Удалить файлы и записи истории старше pruneDays дней. */
287
+ export async function pruneHistory(entries, pruneDays) {
288
+ if (!pruneDays || pruneDays <= 0) return entries
289
+ const cutoff = Date.now() - pruneDays * 86400000
290
+ const kept = []
291
+ for (const e of entries) {
292
+ const created = e.createdAt ? Date.parse(e.createdAt) : NaN
293
+ if (Number.isFinite(created) && created < cutoff) {
294
+ try { require('node:fs').unlinkSync(e.path) } catch (err) { /* файл уже удалён */ }
295
+ continue
296
+ }
297
+ kept.push(e)
298
+ }
299
+ return kept
300
+ }
301
+
302
+ export async function readHistory() {
303
+ try {
304
+ const raw = await readFile(historyFile(), 'utf-8')
305
+ const parsed = JSON.parse(raw)
306
+ return Array.isArray(parsed) ? parsed : []
307
+ } catch (e) {
308
+ return []
309
+ }
310
+ }
311
+
312
+ /** Записать историю в файл (перезапись целиком). */
313
+ export async function writeHistory(entries) {
314
+ try {
315
+ await mkdir(path.dirname(historyFile()), { recursive: true })
316
+ await writeFile(historyFile(), JSON.stringify(entries, null, 2))
317
+ } catch (e) { /* история не критична */ }
318
+ }
219
319
 
220
320
  /** Отфильтровать записи, чьи файлы ещё существуют; новые первыми. */
221
321
  export function filterHistory(entries, exists) {
@@ -349,8 +449,13 @@ export function apply(ctx, config) {
349
449
  return
350
450
  }
351
451
  const exists = (p) => { try { return require('node:fs').existsSync(p) } catch { return false } }
452
+ const entries = await readHistory()
453
+ const withThumbs = filterHistory(entries, exists).map((e) => ({
454
+ ...e,
455
+ thumbnailUrl: e.attachmentId ? `/dsh-image-gen/image?id=${encodeURIComponent(e.attachmentId)}` : '',
456
+ }))
352
457
  res.writeHead(200, { 'Content-Type': 'application/json' })
353
- res.end(JSON.stringify(filterHistory(history, exists)))
458
+ res.end(JSON.stringify(withThumbs))
354
459
  },
355
460
  }), 'dsh-image-gen: history route')
356
461
 
@@ -373,6 +478,11 @@ export function apply(ctx, config) {
373
478
  type: 'string',
374
479
  description: `One of: ${IMAGE_SIZES.join(', ')}. Default: ${config.defaultSize}.`,
375
480
  },
481
+ aspect_ratio: {
482
+ type: 'string',
483
+ enum: ['1:1', '16:9', '9:16', '4:3', '3:4'],
484
+ description: 'Optional aspect ratio; overrides image_size when both are given. Sent as width/height to providers that accept pixels.',
485
+ },
376
486
  seed: {
377
487
  type: 'integer',
378
488
  description: 'Optional seed for reproducible output.',
@@ -390,6 +500,16 @@ export function apply(ctx, config) {
390
500
  type: 'integer',
391
501
  description: 'Number of variations to generate in one call, 1-4 (default 1). Cost scales with count.',
392
502
  },
503
+ prompts: {
504
+ type: 'array',
505
+ items: {
506
+ oneOf: [
507
+ { type: 'string' },
508
+ { type: 'object', additionalProperties: false, properties: { text: { type: 'string' } } },
509
+ ],
510
+ },
511
+ description: 'Optional list of prompts (strings or {text}); one image is generated per prompt. When set, overrides count and the single prompt.',
512
+ },
393
513
  negative_prompt: {
394
514
  type: 'string',
395
515
  description: 'Optional text describing what NOT to draw; sent to providers that support it (ignored otherwise).',
@@ -398,6 +518,15 @@ export function apply(ctx, config) {
398
518
  type: 'number',
399
519
  description: 'Optional prompt adherence (e.g. 1-20); sent to providers that support it (ignored otherwise).',
400
520
  },
521
+ quality: {
522
+ type: 'string',
523
+ enum: ['low', 'medium', 'high'],
524
+ description: 'Optional quality for providers that support it (seedream, gemini). Ignored otherwise.',
525
+ },
526
+ style: {
527
+ type: 'string',
528
+ description: 'Optional style preset for providers that support it (seedream, gemini). Ignored otherwise.',
529
+ },
401
530
  source_image: {
402
531
  type: 'string',
403
532
  description: 'Optional path to an image file or an attachment id (sha256:...) to edit instead of drawing from scratch. Only providers that support image editing accept it; others refuse with a clear reason.',
@@ -472,10 +601,11 @@ export function apply(ctx, config) {
472
601
  const cfg = live()
473
602
  const provider = PROVIDER_KEYS.includes(cfg.provider) ? cfg.provider : 'fal'
474
603
  const deliverAs = cfg.deliverAs
475
- const size = args.image_size ?? cfg.defaultSize
476
- if (!IMAGE_SIZES.includes(size)) {
604
+ const size = args.aspect_ratio ? 'custom' : (args.image_size ?? cfg.defaultSize)
605
+ if (!args.aspect_ratio && !IMAGE_SIZES.includes(size)) {
477
606
  throw new Error(`Invalid image_size "${size}". One of: ${IMAGE_SIZES.join(', ')}`)
478
607
  }
608
+ const aspectPixels = args.aspect_ratio ? ASPECT_RATIOS[args.aspect_ratio] : undefined
479
609
  const format = args.output_format ?? cfg.defaultFormat
480
610
  if (!OUTPUT_FORMATS.includes(format)) {
481
611
  throw new Error(`Invalid output_format "${format}". One of: ${OUTPUT_FORMATS.join(', ')}`)
@@ -488,22 +618,23 @@ export function apply(ctx, config) {
488
618
  const source = args.source_image ? await resolveSource(ctx, exec, args.source_image) : undefined
489
619
  const mask = args.mask ? await resolveSource(ctx, exec, args.mask) : undefined
490
620
  const enhanced = await enhancePrompt(ctx, cfg, args.prompt, exec.signal)
491
- const effectivePrompt = enhanced.prompt
621
+ const styleSuffix = cfg.stylePreset ? `, ${cfg.stylePreset}` : ''
622
+ const effectivePrompt = enhanced.prompt + styleSuffix
492
623
  const providers = makeProviders(
493
624
  { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg, subscriptionImages },
494
- { prompt: effectivePrompt, size, format, seed: args.seed, signal: exec.signal, negativePrompt: args.negative_prompt, guidanceScale: args.guidance_scale, source, mask, strength: args.strength },
625
+ { prompt: effectivePrompt, size, format, seed: args.seed, signal: exec.signal, negativePrompt: args.negative_prompt, guidanceScale: args.guidance_scale, source, mask, strength: args.strength, quality: args.quality, style: args.style, aspectPixels },
495
626
  )
496
627
  // Подписочные провайдеры (codex/grok) отдают {ok:false, reason} вместо исключения:
497
628
  // отказ должен дойти до модели текстом. Без проверки execute шёл дальше с пустыми
498
629
  // байтами, и пользователь получал битую карточку вместо внятного отказа.
499
630
  const guard = (generated) => { if (generated && generated.ok === false) throw new Error(generated.reason) }
500
- const one = async (jobSeed, providerKey) => {
501
- const gen = await providers[providerKey](jobSeed)
631
+ const one = async (jobSeed, providerKey, promptArg = effectivePrompt) => {
632
+ const gen = await providers[providerKey](jobSeed, promptArg)
502
633
  guard(gen)
503
634
  const bytes = gen.bytes
504
635
  const mediaType = gen.mediaType
505
636
  const extension = mediaType === 'image/jpeg' ? 'jpg' : mediaType === 'image/webp' ? 'webp' : 'png'
506
- const stem = `${slugify(args.output_name || effectivePrompt)}-${Date.now().toString(36)}-${jobSeed}`
637
+ const stem = `${slugify(args.output_name || promptArg)}-${Date.now().toString(36)}-${jobSeed}`
507
638
  const name = `${stem}.${extension}`
508
639
 
509
640
  const attachment = await ctx.attachments.saveImage({
@@ -529,7 +660,7 @@ export function apply(ctx, config) {
529
660
  await writeFile(
530
661
  path.join(outDir, `${stem}.json`),
531
662
  JSON.stringify(buildSidecar({
532
- prompt: effectivePrompt,
663
+ prompt: promptArg,
533
664
  size,
534
665
  format,
535
666
  seed: gen.seed,
@@ -544,17 +675,21 @@ export function apply(ctx, config) {
544
675
  }), null, 2),
545
676
  )
546
677
 
547
- history.unshift({
678
+ const entry = {
548
679
  path: filePath,
549
- prompt: effectivePrompt,
680
+ prompt: promptArg,
550
681
  provider,
551
682
  size,
552
683
  format,
553
684
  seed: gen.seed,
554
685
  createdAt: new Date().toISOString(),
555
686
  attachmentId: attachment.attachmentId,
556
- })
557
- if (history.length > (cfg.historyLimit || 50)) history.length = cfg.historyLimit || 50
687
+ }
688
+ let current = await readHistory()
689
+ current = await pruneHistory(current, cfg.pruneDays)
690
+ current.unshift(entry)
691
+ if (current.length > (cfg.historyLimit || 50)) current.length = cfg.historyLimit || 50
692
+ await writeHistory(current)
558
693
 
559
694
  return {
560
695
  path: filePath,
@@ -562,7 +697,7 @@ export function apply(ctx, config) {
562
697
  width: gen.width || attachment.width,
563
698
  height: gen.height || attachment.height,
564
699
  seed: gen.seed,
565
- prompt: effectivePrompt,
700
+ prompt: promptArg,
566
701
  originalPrompt: enhanced.enhanced ? args.prompt : undefined,
567
702
  cost: gen.cost,
568
703
  format: mediaType.replace('image/', ''),
@@ -577,19 +712,58 @@ export function apply(ctx, config) {
577
712
  }
578
713
  }
579
714
 
580
- const count = normalizeCount(args.count)
581
715
  const seedBase = args.seed ?? Math.floor(Math.random() * 100000)
582
716
  // Fallback-цепочка: пробуем текущий провайдер, при отказе — следующий
583
717
  // по порядку (fal → custom → codex → grok), собирая причины отказов.
584
718
  const order = fallbackOrder(provider)
585
- const generators = Object.fromEntries(PROVIDER_KEYS.map((k) => [k, (s) => one(s, k)]))
719
+ const generators = Object.fromEntries(PROVIDER_KEYS.map((k) => [k, (s, p) => one(s, k, p)]))
586
720
  const images = []
587
- for (let i = 0; i < count; i += 1) {
588
- images.push(await tryGenerate(generators, order, seedBase + i))
721
+ const historyEntries = (cfg.cacheBySeed || cfg.cacheByPrompt) ? await readHistory() : []
722
+ const batchPrompts = Array.isArray(args.prompts) && args.prompts.length ? args.prompts : null
723
+ if (batchPrompts) {
724
+ for (let i = 0; i < batchPrompts.length; i += 1) {
725
+ const item = batchPrompts[i]
726
+ const text = typeof item === 'string' ? item : (item && item.text) || ''
727
+ const cached = cfg.cacheBySeed ? await cachedResult(findCached(historyEntries, seedBase + i, text)) : (cfg.cacheByPrompt ? await cachedResult(findCachedByPrompt(historyEntries, text)) : undefined)
728
+ images.push(cached || await tryGenerate(generators, order, seedBase + i, text))
729
+ }
730
+ } else {
731
+ const count = normalizeCount(args.count)
732
+ for (let i = 0; i < count; i += 1) {
733
+ const cached = cfg.cacheBySeed ? await cachedResult(findCached(historyEntries, seedBase + i, effectivePrompt)) : (cfg.cacheByPrompt ? await cachedResult(findCachedByPrompt(historyEntries, effectivePrompt)) : undefined)
734
+ images.push(cached || await tryGenerate(generators, order, seedBase + i))
735
+ }
589
736
  }
590
737
  const first = images[0]
591
738
  return { ...first, images }
592
739
  },
593
740
  }),
594
741
  )
742
+
743
+ // Сравнение двух изображений: доля различающихся пикселей.
744
+ ctx.tools.register(
745
+ defineTool({
746
+ name: 'compare_images',
747
+ description: 'Compare two images (paths or attachment ids) and report the fraction of differing pixels. Use to check how much an edit changed the original.',
748
+ parameters: {
749
+ image_a: { type: 'string', required: true, description: 'Path or attachment id of the first image.' },
750
+ image_b: { type: 'string', required: true, description: 'Path or attachment id of the second image.' },
751
+ },
752
+ output: {
753
+ schema: {
754
+ type: 'object',
755
+ additionalProperties: false,
756
+ properties: {
757
+ diffRatio: { type: 'number' },
758
+ error: { type: 'string' },
759
+ },
760
+ },
761
+ },
762
+ async execute(args, exec) {
763
+ const a = await resolveSource(ctx, exec, args.image_a)
764
+ const b = await resolveSource(ctx, exec, args.image_b)
765
+ return pixelDiff(a && a.bytes, b && b.bytes)
766
+ },
767
+ }),
768
+ )
595
769
  }
package/lib/providers.js CHANGED
@@ -7,7 +7,7 @@
7
7
  // Сеть приходит параметром (fetchImpl), ключ — через resolveKey, поэтому оба
8
8
  // провайдера проверяются юнит-тестами без единого реального запроса.
9
9
 
10
- export const PROVIDER_KEYS = ['fal', 'custom', 'codex', 'grok', 'local']
10
+ export const PROVIDER_KEYS = ['fal', 'custom', 'codex', 'grok', 'local', 'seedream', 'gemini']
11
11
 
12
12
  /** Привести count из аргумента инструмента к диапазону 1..4. */
13
13
  /** Порядок провайдеров для fallback: основной первым, остальные по PROVIDER_KEYS. */
@@ -21,11 +21,11 @@ export function fallbackOrder(primary) {
21
21
  * @param generators - массив функций (key, seed) => Promise<generated>.
22
22
  * @param order - порядок ключей, длина = generators.length.
23
23
  */
24
- export async function tryGenerate(generators, order, seed) {
24
+ export async function tryGenerate(generators, order, seed, promptArg) {
25
25
  const refusals = []
26
26
  for (const key of order) {
27
27
  try {
28
- const produced = await generators[key](seed)
28
+ const produced = await generators[key](seed, promptArg)
29
29
  return produced
30
30
  } catch (e) {
31
31
  refusals.push(`${key}: ${e.message || String(e)}`)
@@ -159,6 +159,21 @@ export async function pollStatus(fetchImpl, statusUrl, key, signal, pollInterval
159
159
  */
160
160
 
161
161
  /** Именованный размер -> [width, height] для локальных API. */
162
+ /** Соотношения сторон -> [w,h] (в пикселях, по базовой стороне 1024). */
163
+ export const ASPECT_RATIOS = {
164
+ '1:1': [1024, 1024],
165
+ '16:9': [1344, 768],
166
+ '9:16': [768, 1344],
167
+ '4:3': [1152, 896],
168
+ '3:4': [896, 1152],
169
+ }
170
+
171
+ /** Размер в пикселях: aspectPixels (если задан) или из именованного размера. */
172
+ export function pxSize(aspectPixels, size) {
173
+ if (aspectPixels) return aspectPixels
174
+ return sizeToPixels(size)
175
+ }
176
+
162
177
  export function sizeToPixels(size) {
163
178
  const px = SIZE_PIXELS[size]
164
179
  if (!px) return [1024, 1024]
@@ -177,13 +192,24 @@ export function buildEditForm({ source, mask, prompt, size, strength }) {
177
192
  return form
178
193
  }
179
194
 
195
+ /** Сравнить два изображения: доля различающихся пикселей (0..1). */
196
+ export async function pixelDiff(a, b) {
197
+ if (!a || !b) return { error: 'missing image' }
198
+ if (a.length !== b.length) return { error: 'size mismatch', diffRatio: 1 }
199
+ let diff = 0
200
+ for (let i = 0; i < a.length; i += 1) {
201
+ if (a[i] !== b[i]) diff += 1
202
+ }
203
+ return { diffRatio: diff / a.length }
204
+ }
205
+
180
206
  export function makeProviders(deps, job) {
181
207
  const { fetchImpl, resolveKey, cfg } = deps
182
- const { prompt, size, format, seed, signal, negativePrompt, guidanceScale, source, mask, strength } = job
208
+ const { prompt, size, format, seed, signal, negativePrompt, guidanceScale, source, mask, strength, quality, style, aspectPixels } = job
183
209
 
184
- async function fal(seedArg = seed) {
210
+ async function fal(seedArg = seed, promptArg = prompt) {
185
211
  const key = await resolveKey(cfg.apiKeyEnv)
186
- const body = { prompt, image_size: size, num_images: 1 }
212
+ const body = { prompt: promptArg, image_size: size, num_images: 1 }
187
213
  if (seedArg !== undefined) body.seed = seedArg
188
214
  if (negativePrompt !== undefined) body.negative_prompt = negativePrompt
189
215
  if (guidanceScale !== undefined) body.guidance_scale = guidanceScale
@@ -218,7 +244,7 @@ export function makeProviders(deps, job) {
218
244
  }
219
245
 
220
246
  // Любой OpenAI-совместимый API картинок. Один запрос вместо очереди FAL.
221
- async function custom(seedArg = seed) {
247
+ async function custom(seedArg = seed, promptArg = prompt) {
222
248
  const base = String(cfg.customBaseURL || '').replace(/\/+$/, '')
223
249
  if (!base) throw new Error('Custom image provider: base URL is not configured (Settings → Image generation)')
224
250
  if (!cfg.customModel) throw new Error('Custom image provider: model is not configured')
@@ -236,12 +262,12 @@ export function makeProviders(deps, job) {
236
262
  method: 'POST',
237
263
  headers,
238
264
  body: source
239
- ? buildEditForm({ source, mask, prompt, size: cfg.customSize || SIZE_PIXELS[size] || size, strength })
265
+ ? buildEditForm({ source, mask, prompt: promptArg, size: cfg.customSize || (aspectPixels ? aspectPixels.join('x') : SIZE_PIXELS[size]) || size, strength })
240
266
  : JSON.stringify({
241
267
  model: cfg.customModel,
242
- prompt,
268
+ prompt: promptArg,
243
269
  n: 1,
244
- size: cfg.customSize || SIZE_PIXELS[size] || size,
270
+ size: cfg.customSize || (aspectPixels ? aspectPixels.join('x') : SIZE_PIXELS[size]) || size,
245
271
  ...(negativePrompt !== undefined ? { negative_prompt: negativePrompt } : {}),
246
272
  ...(guidanceScale !== undefined ? { guidance_scale: guidanceScale } : {}),
247
273
  }),
@@ -291,7 +317,7 @@ export function makeProviders(deps, job) {
291
317
  // только запрос и разбор ответа. Токен сюда не попадает вовсе — служба
292
318
  // отдаёт готовую картинку, а не ключ доступа.
293
319
  function subscription(provider) {
294
- return async function generate(seedArg = seed) {
320
+ return async function generate(seedArg = seed, promptArg = prompt) {
295
321
  if (source) {
296
322
  return { ok: false, provider, reason: `${provider}: не умеет править изображения — используйте fal, custom или local` }
297
323
  }
@@ -307,7 +333,7 @@ export function makeProviders(deps, job) {
307
333
  try {
308
334
  produced = await images.generate({
309
335
  provider,
310
- prompt,
336
+ prompt: promptArg,
311
337
  size: SUBSCRIPTION_SIZES[size] || '1024x1024',
312
338
  quality: cfg.subscriptionQuality || undefined,
313
339
  signal,
@@ -333,15 +359,15 @@ export function makeProviders(deps, job) {
333
359
  }
334
360
 
335
361
  // Локальная генерация: ComfyUI (очередь + опрос) или Automatic1111 (txt2img).
336
- async function local(seedArg = seed) {
362
+ async function local(seedArg = seed, promptArg = prompt) {
337
363
  const base = String(cfg.localBaseURL || '').replace(/\/+$/, '')
338
364
  if (!base) throw new Error('Local image provider: server address is not configured (Settings → Image generation)')
339
- const [width, height] = sizeToPixels(size)
365
+ const [width, height] = pxSize(aspectPixels, size)
340
366
  const kind = cfg.localKind === 'a1111' ? 'a1111' : 'comfyui'
341
367
 
342
368
  if (kind === 'a1111') {
343
369
  const body = {
344
- prompt,
370
+ prompt: promptArg,
345
371
  negative_prompt: negativePrompt,
346
372
  width,
347
373
  height,
@@ -379,7 +405,7 @@ export function makeProviders(deps, job) {
379
405
  '3': { class_type: 'KSampler', inputs: { seed: seedArg ?? 0, steps: cfg.localSteps ?? 20, cfg: cfg.localCfg ?? 7, sampler_name: 'euler', scheduler: 'normal', denoise: 1, model: ['4', 0], positive: ['6', 0], negative: ['7', 0], latent_image: ['5', 0] } },
380
406
  '4': { class_type: 'CheckpointLoaderSimple', inputs: { ckpt_name: cfg.localModel || 'v1-5-pruned-emaonly.safetensors' } },
381
407
  '5': { class_type: 'EmptyLatentImage', inputs: { width, height, batch_size: 1 } },
382
- '6': { class_type: 'CLIPTextEncode', inputs: { text: prompt, clip: ['4', 1] } },
408
+ '6': { class_type: 'CLIPTextEncode', inputs: { text: promptArg, clip: ['4', 1] } },
383
409
  '7': { class_type: 'CLIPTextEncode', inputs: { text: negativePrompt || '', clip: ['4', 1] } },
384
410
  '8': { class_type: 'VAEDecode', inputs: { samples: ['3', 0], vae: ['4', 2] } },
385
411
  '9': { class_type: 'SaveImage', inputs: { filename_prefix: 'dsh', images: ['8', 0] } },
@@ -429,5 +455,62 @@ export function makeProviders(deps, job) {
429
455
  }
430
456
  }
431
457
 
432
- return { fal, custom, codex: subscription('codex'), grok: subscription('grok'), local }
458
+ // Seedream (ByteDance): OpenAI-совместимый API картинок.
459
+ async function seedream(seedArg = seed, promptArg = prompt) {
460
+ const key = await resolveKey(cfg.seedreamKeyEnv)
461
+ const base = 'https://api.bytedanceapi.com/v1'
462
+ const res = await fetchImpl(`${base}/images/generations`, {
463
+ method: 'POST',
464
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
465
+ body: JSON.stringify({
466
+ model: cfg.seedreamModel || 'seedream-4.0',
467
+ prompt: promptArg,
468
+ n: 1,
469
+ size: (aspectPixels ? aspectPixels.join('x') : SIZE_PIXELS[size]) || size,
470
+ ...(negativePrompt !== undefined ? { negative_prompt: negativePrompt } : {}),
471
+ ...(quality !== undefined ? { quality } : {}),
472
+ ...(style !== undefined ? { style } : {}),
473
+ }),
474
+ signal,
475
+ })
476
+ const data = await res.json().catch(() => ({}))
477
+ if (!res.ok) {
478
+ const detail = data?.error?.message || JSON.stringify(data).slice(0, 600)
479
+ throw new Error(`Seedream failed (HTTP ${res.status}): ${detail}`)
480
+ }
481
+ const item = data?.data?.[0]
482
+ if (!item) throw new Error('Seedream returned no images')
483
+ if (item.b64_json) {
484
+ return { bytes: Buffer.from(item.b64_json, 'base64'), mediaType: normalizeMediaType('image/png', format), width: 0, height: 0, seed: seedArg ?? 0, sourceUrl: '' }
485
+ }
486
+ if (!item.url) throw new Error('Seedream returned neither b64_json nor url')
487
+ const dl = await fetchImpl(item.url, { signal })
488
+ if (!dl.ok) throw new Error(`Seedream download failed (HTTP ${dl.status})`)
489
+ return { bytes: Buffer.from(await dl.arrayBuffer()), mediaType: normalizeMediaType('image/png', format), width: 0, height: 0, seed: seedArg ?? 0, sourceUrl: item.url }
490
+ }
491
+
492
+ // Gemini (Google): images.generate через GenAI API.
493
+ async function gemini(seedArg = seed, promptArg = prompt) {
494
+ const key = await resolveKey(cfg.geminiKeyEnv)
495
+ const model = cfg.geminiModel || 'gemini-2.0-flash-exp-image-generation'
496
+ const res = await fetchImpl(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${encodeURIComponent(key)}`, {
497
+ method: 'POST',
498
+ headers: { 'Content-Type': 'application/json' },
499
+ body: JSON.stringify({
500
+ contents: [{ parts: [{ text: promptArg }] }],
501
+ generationConfig: { responseModalities: ['IMAGE'], ...(quality !== undefined ? { imageConfig: { imageQuality: quality } } : {}), ...(style !== undefined ? { imageConfig: { imageStyle: style } } : {}) },
502
+ }),
503
+ signal,
504
+ })
505
+ const data = await res.json().catch(() => ({}))
506
+ if (!res.ok) {
507
+ const detail = data?.error?.message || JSON.stringify(data).slice(0, 600)
508
+ throw new Error(`Gemini failed (HTTP ${res.status}): ${detail}`)
509
+ }
510
+ const part = data?.candidates?.[0]?.content?.parts?.find((p) => p.inlineData?.data)
511
+ if (!part) throw new Error('Gemini returned no image')
512
+ return { bytes: Buffer.from(part.inlineData.data, 'base64'), mediaType: normalizeMediaType(part.inlineData.mimeType || 'image/png', format), width: 0, height: 0, seed: seedArg ?? 0, sourceUrl: '' }
513
+ }
514
+
515
+ return { fal, custom, codex: subscription('codex'), grok: subscription('grok'), local, seedream, gemini }
433
516
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-image-gen",
3
- "version": "0.8.7",
3
+ "version": "0.8.9",
4
4
  "description": "Image generation for DeepSeek Harness: a generate_image tool with pluggable providers — the FAL queue, any OpenAI-compatible images API, or a ChatGPT/Grok subscription with no API key at all. The picture is shown inline in the conversation; the model receives either a link (works with any chat model) or the image itself (needs dsh-vision-bridge or a vision-capable model).",
5
5
  "keywords": [
6
6
  "deepseek-harness",