@goodandready/dsh-image-gen 0.8.8 → 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
@@ -24,6 +24,8 @@ import {
24
24
  PROVIDER_KEYS,
25
25
  buildSidecar,
26
26
  makeProviders,
27
+ ASPECT_RATIOS,
28
+ pixelDiff,
27
29
  normalizeCount,
28
30
  tryGenerate,
29
31
  fallbackOrder,
@@ -153,6 +155,10 @@ export const Config = z.object({
153
155
  .number()
154
156
  .description('How many recent generations to keep in the in-memory history list.')
155
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),
156
162
  enhancePrompt: z
157
163
  .boolean()
158
164
  .description('Expand a short prompt into a detailed one through the chat model before generating. Off by default.')
@@ -173,6 +179,10 @@ export const Config = z.object({
173
179
  .boolean()
174
180
  .description('If the same seed+prompt was already generated (present in history), return the cached result instead of generating again. Off by default.')
175
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),
176
186
  })
177
187
 
178
188
  /** Keep a file stem safe for the filesystem. */
@@ -246,6 +256,11 @@ export function historyFile() {
246
256
 
247
257
  /** Прочитать историю из файла (пусто, если файла нет). */
248
258
  /** Найти запись истории по seed+prompt, если файл ещё существует. */
259
+ /** Найти запись истории по промпту (без учёта seed), если файл существует. */
260
+ export async function findCachedByPrompt(entries, prompt) {
261
+ return entries.find((e) => e.prompt === prompt)
262
+ }
263
+
249
264
  export async function findCached(entries, seed, prompt) {
250
265
  if (seed === undefined) return undefined
251
266
  return entries.find((e) => e.seed === seed && e.prompt === prompt)
@@ -268,6 +283,22 @@ export async function cachedResult(entry) {
268
283
  }
269
284
  }
270
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
+
271
302
  export async function readHistory() {
272
303
  try {
273
304
  const raw = await readFile(historyFile(), 'utf-8')
@@ -447,6 +478,11 @@ export function apply(ctx, config) {
447
478
  type: 'string',
448
479
  description: `One of: ${IMAGE_SIZES.join(', ')}. Default: ${config.defaultSize}.`,
449
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
+ },
450
486
  seed: {
451
487
  type: 'integer',
452
488
  description: 'Optional seed for reproducible output.',
@@ -466,8 +502,13 @@ export function apply(ctx, config) {
466
502
  },
467
503
  prompts: {
468
504
  type: 'array',
469
- items: { type: 'string' },
470
- description: 'Optional list of prompts; one image is generated per prompt. When set, overrides count and the single prompt.',
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.',
471
512
  },
472
513
  negative_prompt: {
473
514
  type: 'string',
@@ -482,6 +523,10 @@ export function apply(ctx, config) {
482
523
  enum: ['low', 'medium', 'high'],
483
524
  description: 'Optional quality for providers that support it (seedream, gemini). Ignored otherwise.',
484
525
  },
526
+ style: {
527
+ type: 'string',
528
+ description: 'Optional style preset for providers that support it (seedream, gemini). Ignored otherwise.',
529
+ },
485
530
  source_image: {
486
531
  type: 'string',
487
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.',
@@ -556,10 +601,11 @@ export function apply(ctx, config) {
556
601
  const cfg = live()
557
602
  const provider = PROVIDER_KEYS.includes(cfg.provider) ? cfg.provider : 'fal'
558
603
  const deliverAs = cfg.deliverAs
559
- const size = args.image_size ?? cfg.defaultSize
560
- 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)) {
561
606
  throw new Error(`Invalid image_size "${size}". One of: ${IMAGE_SIZES.join(', ')}`)
562
607
  }
608
+ const aspectPixels = args.aspect_ratio ? ASPECT_RATIOS[args.aspect_ratio] : undefined
563
609
  const format = args.output_format ?? cfg.defaultFormat
564
610
  if (!OUTPUT_FORMATS.includes(format)) {
565
611
  throw new Error(`Invalid output_format "${format}". One of: ${OUTPUT_FORMATS.join(', ')}`)
@@ -576,7 +622,7 @@ export function apply(ctx, config) {
576
622
  const effectivePrompt = enhanced.prompt + styleSuffix
577
623
  const providers = makeProviders(
578
624
  { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg, subscriptionImages },
579
- { 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 },
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 },
580
626
  )
581
627
  // Подписочные провайдеры (codex/grok) отдают {ok:false, reason} вместо исключения:
582
628
  // отказ должен дойти до модели текстом. Без проверки execute шёл дальше с пустыми
@@ -639,7 +685,8 @@ export function apply(ctx, config) {
639
685
  createdAt: new Date().toISOString(),
640
686
  attachmentId: attachment.attachmentId,
641
687
  }
642
- const current = await readHistory()
688
+ let current = await readHistory()
689
+ current = await pruneHistory(current, cfg.pruneDays)
643
690
  current.unshift(entry)
644
691
  if (current.length > (cfg.historyLimit || 50)) current.length = cfg.historyLimit || 50
645
692
  await writeHistory(current)
@@ -671,17 +718,19 @@ export function apply(ctx, config) {
671
718
  const order = fallbackOrder(provider)
672
719
  const generators = Object.fromEntries(PROVIDER_KEYS.map((k) => [k, (s, p) => one(s, k, p)]))
673
720
  const images = []
674
- const historyEntries = cfg.cacheBySeed ? await readHistory() : []
721
+ const historyEntries = (cfg.cacheBySeed || cfg.cacheByPrompt) ? await readHistory() : []
675
722
  const batchPrompts = Array.isArray(args.prompts) && args.prompts.length ? args.prompts : null
676
723
  if (batchPrompts) {
677
724
  for (let i = 0; i < batchPrompts.length; i += 1) {
678
- const cached = cfg.cacheBySeed ? await cachedResult(findCached(historyEntries, seedBase + i, batchPrompts[i])) : undefined
679
- images.push(cached || await tryGenerate(generators, order, seedBase + i, batchPrompts[i]))
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))
680
729
  }
681
730
  } else {
682
731
  const count = normalizeCount(args.count)
683
732
  for (let i = 0; i < count; i += 1) {
684
- const cached = cfg.cacheBySeed ? await cachedResult(findCached(historyEntries, seedBase + i, effectivePrompt)) : undefined
733
+ const cached = cfg.cacheBySeed ? await cachedResult(findCached(historyEntries, seedBase + i, effectivePrompt)) : (cfg.cacheByPrompt ? await cachedResult(findCachedByPrompt(historyEntries, effectivePrompt)) : undefined)
685
734
  images.push(cached || await tryGenerate(generators, order, seedBase + i))
686
735
  }
687
736
  }
@@ -690,4 +739,31 @@ export function apply(ctx, config) {
690
739
  },
691
740
  }),
692
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
+ )
693
769
  }
package/lib/providers.js CHANGED
@@ -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,9 +192,20 @@ 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, quality } = job
208
+ const { prompt, size, format, seed, signal, negativePrompt, guidanceScale, source, mask, strength, quality, style, aspectPixels } = job
183
209
 
184
210
  async function fal(seedArg = seed, promptArg = prompt) {
185
211
  const key = await resolveKey(cfg.apiKeyEnv)
@@ -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: promptArg, 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
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
  }),
@@ -336,7 +362,7 @@ export function makeProviders(deps, job) {
336
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') {
@@ -440,9 +466,10 @@ export function makeProviders(deps, job) {
440
466
  model: cfg.seedreamModel || 'seedream-4.0',
441
467
  prompt: promptArg,
442
468
  n: 1,
443
- size: SIZE_PIXELS[size] || size,
469
+ size: (aspectPixels ? aspectPixels.join('x') : SIZE_PIXELS[size]) || size,
444
470
  ...(negativePrompt !== undefined ? { negative_prompt: negativePrompt } : {}),
445
471
  ...(quality !== undefined ? { quality } : {}),
472
+ ...(style !== undefined ? { style } : {}),
446
473
  }),
447
474
  signal,
448
475
  })
@@ -471,7 +498,7 @@ export function makeProviders(deps, job) {
471
498
  headers: { 'Content-Type': 'application/json' },
472
499
  body: JSON.stringify({
473
500
  contents: [{ parts: [{ text: promptArg }] }],
474
- generationConfig: { responseModalities: ['IMAGE'], ...(quality !== undefined ? { imageConfig: { imageQuality: quality } } : {}) },
501
+ generationConfig: { responseModalities: ['IMAGE'], ...(quality !== undefined ? { imageConfig: { imageQuality: quality } } : {}), ...(style !== undefined ? { imageConfig: { imageStyle: style } } : {}) },
475
502
  }),
476
503
  signal,
477
504
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-image-gen",
3
- "version": "0.8.8",
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",