@goodandready/dsh-image-gen 0.10.21 → 0.10.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -0
- package/README.ru.md +12 -0
- package/README.zh.md +12 -0
- package/lib/client.js +177 -192
- package/lib/index.js +37 -32
- package/lib/providers.js +96 -50
- package/lib/resolve-image.js +1 -1
- package/lib/tools/generation.js +9 -9
- package/lib/updater.js +338 -0
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -32,13 +32,14 @@ import {
|
|
|
32
32
|
|
|
33
33
|
import { buildDualOutputMarkdown, resolveConversationImage, analyzeImageWithVision } from './resolve-image.js'
|
|
34
34
|
import { registerAllTools } from './register-tools.js'
|
|
35
|
+
import { registerPluginUpdater } from './updater.js'
|
|
35
36
|
|
|
36
37
|
|
|
37
38
|
export { IMAGE_SIZES, OUTPUT_FORMATS, PROVIDER_KEYS, buildSidecar, normalizeMediaType, resolveConversationImage, analyzeImageWithVision }
|
|
38
39
|
|
|
39
40
|
|
|
40
41
|
/**
|
|
41
|
-
*
|
|
42
|
+
* Saves generated asset to workspace, registers with attachments, and returns Dual-Output (#150).
|
|
42
43
|
*/
|
|
43
44
|
|
|
44
45
|
|
|
@@ -126,9 +127,8 @@ export const name = 'dsh-image-gen'
|
|
|
126
127
|
/** Settings namespace the Web card edits. */
|
|
127
128
|
const NS = 'dsh-image-gen'
|
|
128
129
|
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
// под новое имя один раз — молча, при первом запуске после обновления.
|
|
130
|
+
// The plugin was previously named dsh-fal-image-gen. Any settings saved
|
|
131
|
+
// under the legacy namespace are read as fallback and migrated once on startup.
|
|
132
132
|
const LEGACY_NS = 'dsh-fal-image-gen'
|
|
133
133
|
export const inject = ['tools', 'attachments', 'credentials', 'webServer', 'settings', 'llm', 'systemPrompt']
|
|
134
134
|
|
|
@@ -320,7 +320,7 @@ export const Config = z.object({
|
|
|
320
320
|
})
|
|
321
321
|
|
|
322
322
|
/** Keep a file stem safe for the filesystem. */
|
|
323
|
-
/**
|
|
323
|
+
/** Read source image for editing: filesystem path or attachment id. */
|
|
324
324
|
export async function resolveSource(ctx, exec, ref) {
|
|
325
325
|
if (!ref) return undefined
|
|
326
326
|
const sessionCwd = exec.agent?.session?.header?.cwd
|
|
@@ -368,14 +368,14 @@ export async function resolveApiKey(ctx, ref) {
|
|
|
368
368
|
}
|
|
369
369
|
|
|
370
370
|
/**
|
|
371
|
-
*
|
|
371
|
+
* Migrate settings from legacy plugin name.
|
|
372
372
|
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
373
|
+
* Uses raw user layer: default values do not need migration,
|
|
374
|
+
* distinguishable only from user overrides. If settings are already configured
|
|
375
|
+
* under the new namespace, preserve them as newer choices.
|
|
376
376
|
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
377
|
+
* The legacy block is left untouched in settings to avoid unexpected deletions.
|
|
378
|
+
* and causes no conflict with the active namespace.
|
|
379
379
|
*/
|
|
380
380
|
function migrateLegacySettings(sctx, scope) {
|
|
381
381
|
try {
|
|
@@ -387,19 +387,19 @@ function migrateLegacySettings(sctx, scope) {
|
|
|
387
387
|
if (mine && typeof mine === 'object' && Object.keys(mine).length > 0) return
|
|
388
388
|
scope.update(structuredClone(legacy))
|
|
389
389
|
} catch (cannotMigrate) {
|
|
390
|
-
//
|
|
391
|
-
//
|
|
390
|
+
// Settings migration skipped — fallback to default schema configuration,
|
|
391
|
+
// and user may configure settings via UI card. No crash required.
|
|
392
392
|
}
|
|
393
393
|
}
|
|
394
394
|
|
|
395
|
-
/**
|
|
395
|
+
/** History directory: persists across restarts. */
|
|
396
396
|
export function historyFile() {
|
|
397
397
|
return path.join(process.env.DSH_HOME || path.join(os.homedir(), '.dsh'), 'dsh-image-gen', 'history.json')
|
|
398
398
|
}
|
|
399
399
|
|
|
400
|
-
/**
|
|
401
|
-
/**
|
|
402
|
-
/**
|
|
400
|
+
/** Read history from disk file (empty array if not found). */
|
|
401
|
+
/** Find history entry matching seed+prompt if file exists. */
|
|
402
|
+
/** Find history entry matching prompt text if file exists. */
|
|
403
403
|
export function findCachedGeneration(entries, hash) {
|
|
404
404
|
if (!hash || !Array.isArray(entries)) return undefined
|
|
405
405
|
return entries.find((e) => e.cacheHash === hash)
|
|
@@ -414,7 +414,7 @@ export async function findCached(entries, seed, prompt) {
|
|
|
414
414
|
return entries.find((e) => e.seed === seed && e.prompt === prompt)
|
|
415
415
|
}
|
|
416
416
|
|
|
417
|
-
/**
|
|
417
|
+
/** Return cached entry if file exists on disk, otherwise undefined. */
|
|
418
418
|
export async function cachedResult(entry) {
|
|
419
419
|
if (!entry || !entry.path) return undefined
|
|
420
420
|
if (!entry.path || !existsSync(entry.path)) return undefined
|
|
@@ -440,7 +440,7 @@ export async function cachedResult(entry) {
|
|
|
440
440
|
}
|
|
441
441
|
}
|
|
442
442
|
|
|
443
|
-
/**
|
|
443
|
+
/** Prune files and history records older than pruneDays (including sidecars). */
|
|
444
444
|
export async function pruneHistory(entries, pruneDays) {
|
|
445
445
|
if (!pruneDays || pruneDays <= 0) return entries
|
|
446
446
|
const cutoff = Date.now() - pruneDays * 86400000
|
|
@@ -448,7 +448,7 @@ export async function pruneHistory(entries, pruneDays) {
|
|
|
448
448
|
for (const e of entries) {
|
|
449
449
|
const created = e.createdAt ? Date.parse(e.createdAt) : NaN
|
|
450
450
|
if (Number.isFinite(created) && created < cutoff) {
|
|
451
|
-
try { unlinkSync(e.path) } catch (err) { /*
|
|
451
|
+
try { unlinkSync(e.path) } catch (err) { /* file already deleted */ }
|
|
452
452
|
try { unlinkSync(e.path.replace(/\.[^.]+$/, '.json')) } catch (err) { /* sidecar */ }
|
|
453
453
|
continue
|
|
454
454
|
}
|
|
@@ -467,20 +467,20 @@ export async function readHistory() {
|
|
|
467
467
|
}
|
|
468
468
|
}
|
|
469
469
|
|
|
470
|
-
/**
|
|
470
|
+
/** Save history to disk file (atomic overwrite). */
|
|
471
471
|
export async function writeHistory(entries) {
|
|
472
472
|
try {
|
|
473
473
|
await mkdir(path.dirname(historyFile()), { recursive: true })
|
|
474
474
|
await writeFile(historyFile(), JSON.stringify(entries, null, 2))
|
|
475
|
-
} catch (e) { /*
|
|
475
|
+
} catch (e) { /* history write failure is non-fatal */ }
|
|
476
476
|
}
|
|
477
477
|
|
|
478
|
-
/**
|
|
478
|
+
/** Filter entries whose files exist on disk; newest first. */
|
|
479
479
|
export function filterHistory(entries, exists) {
|
|
480
480
|
return entries.filter((e) => exists(e.path)).slice(0, 50)
|
|
481
481
|
}
|
|
482
482
|
|
|
483
|
-
/**
|
|
483
|
+
/** Collect text chunks from llm.stream iterator. */
|
|
484
484
|
export async function collectText(iterable) {
|
|
485
485
|
let out = ''
|
|
486
486
|
let sawDelta = false
|
|
@@ -498,7 +498,7 @@ export async function collectText(iterable) {
|
|
|
498
498
|
return out.trim()
|
|
499
499
|
}
|
|
500
500
|
|
|
501
|
-
/**
|
|
501
|
+
/** Expand short prompt via chat model; return original prompt on error. */
|
|
502
502
|
export function buildEnhancePromptSystemMessage(provider, model) {
|
|
503
503
|
const isFlux = String(model || '').toLowerCase().includes('flux') || provider === 'fal'
|
|
504
504
|
if (isFlux) {
|
|
@@ -507,7 +507,7 @@ export function buildEnhancePromptSystemMessage(provider, model) {
|
|
|
507
507
|
return 'You are an expert prompt engineer for Stable Diffusion models. Expand the user prompt into detailed comma-separated descriptive visual tags including subject, composition, studio lighting, materials, and artistic medium. Reply with ONLY the expanded prompt, no commentary.'
|
|
508
508
|
}
|
|
509
509
|
|
|
510
|
-
/**
|
|
510
|
+
/** Expand short prompt via chat model; return original prompt on error. */
|
|
511
511
|
export async function enhancePrompt(ctx, cfg, prompt, signal, provider) {
|
|
512
512
|
if (!cfg.enhancePrompt) return { prompt, enhanced: false }
|
|
513
513
|
if (String(prompt).length >= (cfg.enhanceBelowChars || 200)) return { prompt, enhanced: false }
|
|
@@ -617,10 +617,8 @@ export function apply(ctx, config) {
|
|
|
617
617
|
}
|
|
618
618
|
})()
|
|
619
619
|
|
|
620
|
-
//
|
|
621
|
-
//
|
|
622
|
-
// отправленных: сними его — и картинки в истории разговоров перестанут
|
|
623
|
-
// показываться.
|
|
620
|
+
// Two routes, one handler: new route for new messages, legacy route for
|
|
621
|
+
// backward compatibility with conversation history.
|
|
624
622
|
for (const path of ['/dsh-image-gen/image', '/dsh-fal-image-gen/image']) {
|
|
625
623
|
ctx.effect(() => ctx.webServer.register({
|
|
626
624
|
kind: 'exact',
|
|
@@ -629,7 +627,14 @@ export function apply(ctx, config) {
|
|
|
629
627
|
}), `dsh-image-gen: image route ${path}`)
|
|
630
628
|
}
|
|
631
629
|
|
|
632
|
-
|
|
630
|
+
// One-click plugin updater route per DSH standard
|
|
631
|
+
ctx.effect(() => registerPluginUpdater(ctx, {
|
|
632
|
+
endpoint: '/api/dsh-image-gen/update',
|
|
633
|
+
packageName: '@goodandready/dsh-image-gen',
|
|
634
|
+
manifestUrl: new URL('../package.json', import.meta.url),
|
|
635
|
+
}), 'dsh-image-gen: plugin updater route')
|
|
636
|
+
|
|
637
|
+
// Provider connection diagnostics probe
|
|
633
638
|
ctx.effect(() => ctx.webServer.register({
|
|
634
639
|
kind: 'exact',
|
|
635
640
|
path: '/dsh-image-gen/diagnostics/test',
|
|
@@ -658,7 +663,7 @@ export function apply(ctx, config) {
|
|
|
658
663
|
},
|
|
659
664
|
}), 'dsh-image-gen: diagnostics test route')
|
|
660
665
|
|
|
661
|
-
|
|
666
|
+
// Generation history: in-memory list filtered by filesystem existence.
|
|
662
667
|
ctx.effect(() => ctx.webServer.register({
|
|
663
668
|
kind: 'exact',
|
|
664
669
|
path: '/dsh-image-gen/history',
|
package/lib/providers.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Returns candidate API key names considering known aliases (e.g. FAL_API_KEY <-> FAL_KEY).
|
|
3
3
|
*/
|
|
4
4
|
export function resolveApiKeyCandidates(ref) {
|
|
5
5
|
if (!ref) return []
|
|
@@ -110,28 +110,27 @@ export function computeGenerationHash({ provider, model, prompt, seed, size, sty
|
|
|
110
110
|
return createHash('sha256').update(norm).digest('hex')
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
//
|
|
113
|
+
// Image generation provider implementations.
|
|
114
114
|
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
115
|
+
// A provider receives a generation job and returns finished image bytes. Everything
|
|
116
|
+
// that follows (attachments, workspace files, links, card rendering) is shared
|
|
117
|
+
// across all providers and lives in index.js.
|
|
118
118
|
//
|
|
119
|
-
//
|
|
120
|
-
// провайдера проверяются юнит-тестами без единого реального запроса.
|
|
119
|
+
// Network is injected via fetchImpl and keys via resolveKey, enabling fully isolated unit tests.
|
|
121
120
|
|
|
122
121
|
export const PROVIDER_KEYS = ['fal', 'custom', 'codex', 'grok', 'local', 'seedream', 'gemini', 'replicate']
|
|
123
122
|
|
|
124
|
-
/**
|
|
125
|
-
/**
|
|
123
|
+
/** Clamp count from tool arguments to range 1..4. */
|
|
124
|
+
/** Provider fallback ordering: primary provider first, followed by PROVIDER_KEYS. */
|
|
126
125
|
export function fallbackOrder(primary) {
|
|
127
126
|
return [primary, ...PROVIDER_KEYS.filter((k) => k !== primary)]
|
|
128
127
|
}
|
|
129
128
|
|
|
130
129
|
/**
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
* @param generators -
|
|
134
|
-
* @param order -
|
|
130
|
+
* Iterates through candidate generators, returning the first successful result.
|
|
131
|
+
* Throws an aggregate error if all candidates fail.
|
|
132
|
+
* @param generators - array of (key, seed) => Promise<generated> functions.
|
|
133
|
+
* @param order - candidate key evaluation order.
|
|
135
134
|
*/
|
|
136
135
|
|
|
137
136
|
/** Calculate exponential backoff delay with jitter. */
|
|
@@ -258,12 +257,12 @@ export function normalizeCount(count, max = 4) {
|
|
|
258
257
|
return Math.max(1, Math.min(max, n))
|
|
259
258
|
}
|
|
260
259
|
|
|
261
|
-
//
|
|
262
|
-
//
|
|
260
|
+
// Subscription services use their own aspect ratio taxonomy.
|
|
261
|
+
// Normalize named sizes to standard square, landscape, and portrait equivalents.
|
|
263
262
|
/**
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
263
|
+
* Resolves dimensions for subscription services (codex / grok).
|
|
264
|
+
* Supports named sizes (landscape_16_9, etc.), aspect ratios (16:9, 3:2, 9:16),
|
|
265
|
+
* and pixel dimensions [width, height], preserving requested proportions.
|
|
267
266
|
*/
|
|
268
267
|
export function resolveSubscriptionSize(size, aspectPixels, aspectRatio) {
|
|
269
268
|
if (size && SUBSCRIPTION_SIZES[size]) {
|
|
@@ -310,9 +309,8 @@ export const IMAGE_SIZES = [
|
|
|
310
309
|
/** Output formats accepted by the model. */
|
|
311
310
|
export const OUTPUT_FORMATS = ['png', 'jpeg', 'webp']
|
|
312
311
|
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
// хотят ШxВ — для них перевод.
|
|
312
|
+
// Named sizes are a unified abstraction across all providers.
|
|
313
|
+
// FAL accepts named identifiers; OpenAI-compatible gateways require WxH resolution.
|
|
316
314
|
export const SIZE_PIXELS = {
|
|
317
315
|
square_hd: '1024x1024',
|
|
318
316
|
square: '512x512',
|
|
@@ -342,7 +340,7 @@ export function normalizeMediaType(contentType, fallbackFormat) {
|
|
|
342
340
|
return 'image/png'
|
|
343
341
|
}
|
|
344
342
|
|
|
345
|
-
/**
|
|
343
|
+
/** Generation metadata written to companion sidecar file. */
|
|
346
344
|
export function buildSidecar({
|
|
347
345
|
prompt, size, format, seed, provider, deliverAs,
|
|
348
346
|
width, height, mediaType, attachmentId, url, cost, createdAt = new Date().toISOString(),
|
|
@@ -406,13 +404,13 @@ export async function pollStatus(fetchImpl, statusUrl, key, signal, pollInterval
|
|
|
406
404
|
/**
|
|
407
405
|
* @param deps {{fetchImpl: Function, resolveKey: (ref: string) => Promise<string>, cfg: object}}
|
|
408
406
|
* @param job {{prompt: string, size: string, format: string, seed: number|undefined, signal: AbortSignal}}
|
|
409
|
-
* @returns
|
|
410
|
-
* {bytes, mediaType, width, height, seed, sourceUrl}.
|
|
411
|
-
*
|
|
407
|
+
* @returns providers by key; each yields
|
|
408
|
+
* {bytes, mediaType, width, height, seed, sourceUrl}. Zero width/height
|
|
409
|
+
* lets the attachments service measure image dimensions directly.
|
|
412
410
|
*/
|
|
413
411
|
|
|
414
|
-
/**
|
|
415
|
-
/**
|
|
412
|
+
/** Named size -> [width, height] for local APIs. */
|
|
413
|
+
/** Aspect ratio -> [width, height] (pixels, normalized to 1024 base). */
|
|
416
414
|
export const ASPECT_RATIOS = {
|
|
417
415
|
'1:1': [1024, 1024],
|
|
418
416
|
'16:9': [1344, 768],
|
|
@@ -423,7 +421,7 @@ export const ASPECT_RATIOS = {
|
|
|
423
421
|
'2:3': [768, 1152],
|
|
424
422
|
}
|
|
425
423
|
|
|
426
|
-
/**
|
|
424
|
+
/** Pixel dimensions: aspectPixels (if specified) or mapped from named size. */
|
|
427
425
|
/** Snap a pixel dimension to nearest multiple of 64. */
|
|
428
426
|
export function snapToMultipleOf64(dim, minVal = 256, maxVal = 2048) {
|
|
429
427
|
const n = Math.round(Number(dim) / 64) * 64
|
|
@@ -447,7 +445,7 @@ export function sizeToPixels(size) {
|
|
|
447
445
|
return [w, h]
|
|
448
446
|
}
|
|
449
447
|
|
|
450
|
-
/**
|
|
448
|
+
/** Assemble multipart body for /images/edits (OpenAI-compatible edit). */
|
|
451
449
|
export function buildEditForm({ source, mask, prompt, size, strength }) {
|
|
452
450
|
const form = new FormData()
|
|
453
451
|
form.append('image', new Blob([source.bytes], { type: source.mediaType || 'image/png' }), 'source.png')
|
|
@@ -458,7 +456,7 @@ export function buildEditForm({ source, mask, prompt, size, strength }) {
|
|
|
458
456
|
return form
|
|
459
457
|
}
|
|
460
458
|
|
|
461
|
-
/**
|
|
459
|
+
/** Compare two images: ratio of differing pixels (0..1). */
|
|
462
460
|
export async function pixelDiff(a, b) {
|
|
463
461
|
if (!a || !b) return { error: 'missing image' }
|
|
464
462
|
if (a.length !== b.length) return { error: 'size mismatch', diffRatio: 1 }
|
|
@@ -469,9 +467,9 @@ export async function pixelDiff(a, b) {
|
|
|
469
467
|
return { diffRatio: diff / a.length }
|
|
470
468
|
}
|
|
471
469
|
|
|
472
|
-
/**
|
|
470
|
+
/** Extract detailed ComfyUI node error messages from /history/{pid} response. */
|
|
473
471
|
|
|
474
|
-
/**
|
|
472
|
+
/** Safe attachment persistence with fallback when ctx.attachments is unavailable. */
|
|
475
473
|
export async function saveAttachmentSafe(ctx, { bytes, mediaType, name }) {
|
|
476
474
|
if (ctx && ctx.attachments && typeof ctx.attachments.saveImage === 'function') {
|
|
477
475
|
try {
|
|
@@ -576,20 +574,18 @@ export function makeProviders(deps, job) {
|
|
|
576
574
|
}
|
|
577
575
|
}
|
|
578
576
|
|
|
579
|
-
//
|
|
577
|
+
// OpenAI-compatible image API. Single HTTP request instead of queue polling.
|
|
580
578
|
async function custom(seedArg = seed, promptArg = prompt) {
|
|
581
579
|
const base = String(cfg.customBaseURL || '').replace(/\/+$/, '')
|
|
582
580
|
if (!base) throw new Error('Custom image provider: base URL is not configured (Settings → Image generation)')
|
|
583
581
|
if (!cfg.customModel) throw new Error('Custom image provider: model is not configured')
|
|
584
582
|
|
|
585
|
-
//
|
|
586
|
-
// локальный шлюз. Это законный случай, а не недонастройка.
|
|
583
|
+
// Empty key reference indicates unauthenticated gateway.
|
|
587
584
|
const key = cfg.customKeyEnv ? await resolveKey(cfg.customKeyEnv) : ''
|
|
588
585
|
const headers = { 'Content-Type': 'application/json' }
|
|
589
586
|
if (key) headers.Authorization = `Bearer ${key}`
|
|
590
587
|
|
|
591
|
-
// response_format
|
|
592
|
-
// всё равно приходит либо base64, либо ссылкой — принимаем оба.
|
|
588
|
+
// Omit response_format; modern models return either base64 or URL.
|
|
593
589
|
const endpoint = buildEndpointUrl(base, source ? 'images/edits' : 'images/generations')
|
|
594
590
|
const res = await fetchImpl(endpoint, {
|
|
595
591
|
method: 'POST',
|
|
@@ -653,20 +649,18 @@ export function makeProviders(deps, job) {
|
|
|
653
649
|
}
|
|
654
650
|
}
|
|
655
651
|
|
|
656
|
-
//
|
|
657
|
-
// только запрос и разбор ответа. Токен сюда не попадает вовсе — служба
|
|
658
|
-
// отдаёт готовую картинку, а не ключ доступа.
|
|
652
|
+
// Subscription generation: dsh-subscriptions handles session authentication.
|
|
659
653
|
function subscription(provider) {
|
|
660
654
|
return async function generate(seedArg = seed, promptArg = prompt) {
|
|
661
655
|
if (source) {
|
|
662
|
-
return { ok: false, provider, reason: `${provider}:
|
|
656
|
+
return { ok: false, provider, reason: `${provider}: does not support image editing — use fal, custom, or local` }
|
|
663
657
|
}
|
|
664
658
|
const images = deps.subscriptionImages
|
|
665
659
|
if (!images || typeof images.generate !== 'function') {
|
|
666
660
|
return {
|
|
667
661
|
ok: false,
|
|
668
662
|
provider,
|
|
669
|
-
reason: `${provider}:
|
|
663
|
+
reason: `${provider}: requires dsh-subscriptions plugin to manage session authentication`,
|
|
670
664
|
}
|
|
671
665
|
}
|
|
672
666
|
let produced
|
|
@@ -683,11 +677,11 @@ export function makeProviders(deps, job) {
|
|
|
683
677
|
}
|
|
684
678
|
const first = Array.isArray(produced) ? produced[0] : null
|
|
685
679
|
if (!first || !first.b64_json) {
|
|
686
|
-
return { ok: false, provider, reason: `${provider}:
|
|
680
|
+
return { ok: false, provider, reason: `${provider}: no image returned in response` }
|
|
687
681
|
}
|
|
688
682
|
return {
|
|
689
683
|
bytes: Buffer.from(first.b64_json, 'base64'),
|
|
690
|
-
//
|
|
684
|
+
// Subscription outputs default to PNG media type.
|
|
691
685
|
mediaType: 'image/png',
|
|
692
686
|
width: 0,
|
|
693
687
|
height: 0,
|
|
@@ -698,7 +692,7 @@ export function makeProviders(deps, job) {
|
|
|
698
692
|
}
|
|
699
693
|
}
|
|
700
694
|
|
|
701
|
-
//
|
|
695
|
+
// Local generation: ComfyUI (queue + poll) or Automatic1111 (txt2img).
|
|
702
696
|
async function local(seedArg = seed, promptArg = prompt) {
|
|
703
697
|
const base = String(cfg.localBaseURL || '').replace(/\/+$/, '')
|
|
704
698
|
if (!base) throw new Error('Local image provider: server address is not configured (Settings → Image generation)')
|
|
@@ -747,7 +741,7 @@ export function makeProviders(deps, job) {
|
|
|
747
741
|
}
|
|
748
742
|
}
|
|
749
743
|
|
|
750
|
-
// ComfyUI: submit
|
|
744
|
+
// ComfyUI: submit via /prompt, poll /history/{prompt_id} until completed.
|
|
751
745
|
const promptId = `dsh-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
|
|
752
746
|
const workflow = {
|
|
753
747
|
prompt: {
|
|
@@ -817,7 +811,7 @@ export function makeProviders(deps, job) {
|
|
|
817
811
|
}
|
|
818
812
|
}
|
|
819
813
|
|
|
820
|
-
// Seedream (ByteDance): OpenAI
|
|
814
|
+
// Seedream (ByteDance): OpenAI-compatible images API.
|
|
821
815
|
async function seedream(seedArg = seed, promptArg = prompt) {
|
|
822
816
|
const key = await resolveKey(cfg.seedreamKeyEnv)
|
|
823
817
|
const base = (cfg.seedreamBaseURL || 'https://api.bytedanceapi.com/v1').replace(/\/+$/, '')
|
|
@@ -851,7 +845,7 @@ export function makeProviders(deps, job) {
|
|
|
851
845
|
return { bytes: Buffer.from(await dl.arrayBuffer()), mediaType: normalizeMediaType('image/png', format), width: 0, height: 0, seed: seedArg ?? 0, sourceUrl: item.url }
|
|
852
846
|
}
|
|
853
847
|
|
|
854
|
-
// Gemini (Google):
|
|
848
|
+
// Gemini (Google): generateContent / imagen via GenAI API.
|
|
855
849
|
async function gemini(seedArg = seed, promptArg = prompt) {
|
|
856
850
|
const key = await resolveKey(cfg.geminiKeyEnv)
|
|
857
851
|
const model = cfg.geminiModel || 'gemini-2.0-flash-exp-image-generation'
|
|
@@ -1148,7 +1142,7 @@ export async function blendImagesFal({ fetchImpl, resolveKey, cfg }, { images, w
|
|
|
1148
1142
|
return { bytes, mediaType: 'image/png', width: img.width || 0, height: img.height || 0, sourceUrl: img.url }
|
|
1149
1143
|
}
|
|
1150
1144
|
|
|
1151
|
-
/**
|
|
1145
|
+
/** Direct image editing (inpainting / img2img) via selected provider. */
|
|
1152
1146
|
export async function editImageDirect(deps, job) {
|
|
1153
1147
|
const providers = makeProviders(deps, job)
|
|
1154
1148
|
const providerKey = job.provider || deps.cfg.defaultProvider || 'fal'
|
|
@@ -1157,7 +1151,7 @@ export async function editImageDirect(deps, job) {
|
|
|
1157
1151
|
return fn(job.seed, job.prompt)
|
|
1158
1152
|
}
|
|
1159
1153
|
|
|
1160
|
-
/**
|
|
1154
|
+
/** Direct image variation via selected provider. */
|
|
1161
1155
|
export async function varyImageDirect(deps, job) {
|
|
1162
1156
|
const providers = makeProviders(deps, {
|
|
1163
1157
|
...job,
|
|
@@ -1270,6 +1264,58 @@ export async function testProviderConnection(deps, providerId) {
|
|
|
1270
1264
|
return { ok: false, latencyMs, message: 'dsh-subscriptions plugin not installed' }
|
|
1271
1265
|
}
|
|
1272
1266
|
|
|
1267
|
+
if (p === 'replicate') {
|
|
1268
|
+
const key = await resolveKey(cfg.replicateKeyEnv || 'REPLICATE_API_TOKEN')
|
|
1269
|
+
if (!key) return { ok: false, latencyMs: 0, message: 'Replicate API token not configured' }
|
|
1270
|
+
const res = await fetchImpl('https://api.replicate.com/v1/models', {
|
|
1271
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
1272
|
+
signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(6000) : undefined,
|
|
1273
|
+
})
|
|
1274
|
+
const latencyMs = Date.now() - start
|
|
1275
|
+
if (res.status === 401 || res.status === 403) {
|
|
1276
|
+
return { ok: false, latencyMs, message: `Replicate auth error (HTTP ${res.status})` }
|
|
1277
|
+
}
|
|
1278
|
+
if (res.ok) {
|
|
1279
|
+
return { ok: true, latencyMs, message: 'Replicate API reachable' }
|
|
1280
|
+
}
|
|
1281
|
+
return { ok: false, latencyMs, message: `Replicate returned HTTP ${res.status}` }
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
if (p === 'gemini') {
|
|
1285
|
+
const key = await resolveKey(cfg.geminiKeyEnv || 'GEMINI_API_KEY')
|
|
1286
|
+
if (!key) return { ok: false, latencyMs: 0, message: 'Google Gemini API key not configured' }
|
|
1287
|
+
const model = cfg.geminiModel || 'gemini-2.0-flash-exp-image-generation'
|
|
1288
|
+
const res = await fetchImpl(`https://generativelanguage.googleapis.com/v1beta/models/${model}?key=${encodeURIComponent(key)}`, {
|
|
1289
|
+
signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(6000) : undefined,
|
|
1290
|
+
})
|
|
1291
|
+
const latencyMs = Date.now() - start
|
|
1292
|
+
if (res.status === 400 || res.status === 401 || res.status === 403) {
|
|
1293
|
+
return { ok: false, latencyMs, message: `Gemini API auth error (HTTP ${res.status})` }
|
|
1294
|
+
}
|
|
1295
|
+
if (res.ok) {
|
|
1296
|
+
return { ok: true, latencyMs, message: 'Gemini Vision API operational' }
|
|
1297
|
+
}
|
|
1298
|
+
return { ok: false, latencyMs, message: `Gemini returned HTTP ${res.status}` }
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
if (p === 'seedream') {
|
|
1302
|
+
const url = cfg.seedreamBaseURL || 'https://api.bytedanceapi.com/v1'
|
|
1303
|
+
const key = await resolveKey(cfg.seedreamKeyEnv || 'SEEDREAM_API_KEY')
|
|
1304
|
+
const headers = key ? { Authorization: `Bearer ${key}` } : {}
|
|
1305
|
+
const res = await fetchImpl(`${url.replace(/\/+$/, '')}/models`, {
|
|
1306
|
+
headers,
|
|
1307
|
+
signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(6000) : undefined,
|
|
1308
|
+
}).catch(() => null)
|
|
1309
|
+
const latencyMs = Date.now() - start
|
|
1310
|
+
if (res && (res.ok || res.status === 404 || res.status === 405)) {
|
|
1311
|
+
return { ok: true, latencyMs, message: 'Seedream endpoint reachable' }
|
|
1312
|
+
}
|
|
1313
|
+
if (res && (res.status === 401 || res.status === 403)) {
|
|
1314
|
+
return { ok: false, latencyMs, message: `Seedream auth error (HTTP ${res.status})` }
|
|
1315
|
+
}
|
|
1316
|
+
return { ok: true, latencyMs, message: 'Seedream provider endpoint configured' }
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1273
1319
|
return { ok: true, latencyMs: Date.now() - start, message: `Provider "${p}" configured` }
|
|
1274
1320
|
} catch (err) {
|
|
1275
1321
|
return { ok: false, latencyMs: Date.now() - start, message: err.message || 'Connection timeout' }
|
package/lib/resolve-image.js
CHANGED
|
@@ -230,7 +230,7 @@ export async function analyzeImageWithVision(ctx, exec, source) {
|
|
|
230
230
|
}
|
|
231
231
|
}
|
|
232
232
|
/**
|
|
233
|
-
*
|
|
233
|
+
* Formats structured text report for safe return to LLM (#150 Dual-Output).
|
|
234
234
|
*/
|
|
235
235
|
export function buildDualOutputMarkdown({
|
|
236
236
|
action = 'generated',
|
package/lib/tools/generation.js
CHANGED
|
@@ -255,8 +255,8 @@ export function registerGenerationTools(ctx, deps) {
|
|
|
255
255
|
if (!OUTPUT_FORMATS.includes(format)) {
|
|
256
256
|
throw new Error(`Invalid output_format "${format}". One of: ${OUTPUT_FORMATS.join(', ')}`)
|
|
257
257
|
}
|
|
258
|
-
|
|
259
|
-
|
|
258
|
+
// Subscriptions service is optional: without it, subscription providers
|
|
259
|
+
// gracefully decline while other providers continue functioning.
|
|
260
260
|
let subscriptionImages
|
|
261
261
|
try { subscriptionImages = ctx.get && ctx.get('subscriptionImages') } catch (noService) { subscriptionImages = undefined }
|
|
262
262
|
|
|
@@ -291,9 +291,9 @@ export function registerGenerationTools(ctx, deps) {
|
|
|
291
291
|
{ fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg, subscriptionImages },
|
|
292
292
|
{ prompt: effectivePrompt, size, format, seed: args.seed, signal: exec.signal, negativePrompt: effectivePolishedNegative, guidanceScale: effectiveGuidance, source, mask, strength: args.strength, quality: args.quality, style: args.style, aspectPixels, aspectRatio: args.aspect_ratio },
|
|
293
293
|
)
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
294
|
+
// Subscription providers return {ok:false, reason} instead of throwing:
|
|
295
|
+
// rejection reaches the model as text. Without this guard,
|
|
296
|
+
// execution would proceed with empty bytes.
|
|
297
297
|
const guard = (generated) => { if (generated && generated.ok === false) throw new Error(generated.reason) }
|
|
298
298
|
const one = async (jobSeed, providerKey, promptArg = effectivePrompt) => {
|
|
299
299
|
const gen = await providers[providerKey](jobSeed, promptArg)
|
|
@@ -325,8 +325,8 @@ export function registerGenerationTools(ctx, deps) {
|
|
|
325
325
|
const filePath = path.join(outDir, name)
|
|
326
326
|
await writeFile(filePath, bytes)
|
|
327
327
|
|
|
328
|
-
|
|
329
|
-
|
|
328
|
+
// Sidecar: metadata written alongside image file, making the output
|
|
329
|
+
// directory self-documenting without external database dependencies.
|
|
330
330
|
await writeFile(
|
|
331
331
|
path.join(outDir, `${stem}.json`),
|
|
332
332
|
JSON.stringify(buildSidecar({
|
|
@@ -416,8 +416,8 @@ export function registerGenerationTools(ctx, deps) {
|
|
|
416
416
|
}
|
|
417
417
|
|
|
418
418
|
const seedBase = args.seed ?? Math.floor(Math.random() * 100000)
|
|
419
|
-
// Fallback
|
|
420
|
-
//
|
|
419
|
+
// Fallback cascade: evaluate configured provider, falling back to alternates
|
|
420
|
+
// (fal -> custom -> codex -> grok), accumulating failure diagnostics.
|
|
421
421
|
const order = fallbackOrder(provider)
|
|
422
422
|
const qgEnabled = args.quality_gate ?? cfg.qualityGate
|
|
423
423
|
const generators = Object.fromEntries(PROVIDER_KEYS.map((k) => [
|