@goodandready/dsh-image-gen 0.10.22 → 0.10.24
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 +14 -0
- package/README.ru.md +14 -0
- package/README.zh.md +14 -0
- package/lib/client.js +176 -210
- package/lib/cost-meter.js +4 -2
- package/lib/generation-cache.js +6 -3
- package/lib/index.js +39 -32
- package/lib/loop-guard.js +2 -0
- package/lib/prompt-polisher.js +0 -7
- package/lib/provider-utils.js +302 -0
- package/lib/providers.js +132 -331
- package/lib/register-tools.js +3 -1
- package/lib/resolve-image.js +1 -1
- package/lib/tools/generation.js +9 -9
- package/lib/tools/{processing.js → processing-advanced.js} +4 -269
- package/lib/tools/processing-basic.js +327 -0
- package/lib/updater.js +338 -0
- package/package.json +2 -2
package/lib/cost-meter.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { homedir } from 'node:os'
|
|
5
5
|
import { join } from 'node:path'
|
|
6
6
|
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
7
|
+
import { enforceSecurePermissions } from './security.js'
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Standard pricing table per provider and resolution (USD per generation).
|
|
@@ -87,7 +88,7 @@ export function loadDailySpend() {
|
|
|
87
88
|
if (data && data.date === today) {
|
|
88
89
|
return data
|
|
89
90
|
}
|
|
90
|
-
} catch {}
|
|
91
|
+
} catch { /* spend file unreadable; return zero */ }
|
|
91
92
|
return { date: today, totalSpendUsd: 0, generations: 0 }
|
|
92
93
|
}
|
|
93
94
|
|
|
@@ -107,6 +108,7 @@ export function recordSpend(usdAmount, { ctx, meta = {} } = {}) {
|
|
|
107
108
|
try {
|
|
108
109
|
mkdirSync(join(filePath, '..'), { recursive: true })
|
|
109
110
|
writeFileSync(filePath, JSON.stringify(current, null, 2), 'utf8')
|
|
111
|
+
enforceSecurePermissions(filePath)
|
|
110
112
|
} catch (err) {
|
|
111
113
|
// Non-fatal write failure
|
|
112
114
|
}
|
|
@@ -122,7 +124,7 @@ export function recordSpend(usdAmount, { ctx, meta = {} } = {}) {
|
|
|
122
124
|
meta,
|
|
123
125
|
})
|
|
124
126
|
}
|
|
125
|
-
} catch {}
|
|
127
|
+
} catch { /* cost-meter service unavailable */ }
|
|
126
128
|
|
|
127
129
|
return current
|
|
128
130
|
}
|
package/lib/generation-cache.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { homedir } from 'node:os'
|
|
5
5
|
import { join } from 'node:path'
|
|
6
6
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync, unlinkSync, readdirSync } from 'node:fs'
|
|
7
|
+
import { enforceSecurePermissions } from './security.js'
|
|
7
8
|
|
|
8
9
|
const DEFAULT_CACHE_MAX_BYTES = 500 * 1024 * 1024 // 500 MB
|
|
9
10
|
|
|
@@ -107,6 +108,8 @@ export function setCachedGeneration(hash, { bytes, mediaType = 'image/png', widt
|
|
|
107
108
|
writeFileSync(metaPath, JSON.stringify(cacheMeta, null, 2), 'utf8')
|
|
108
109
|
const binBuf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes)
|
|
109
110
|
writeFileSync(dataPath, binBuf)
|
|
111
|
+
enforceSecurePermissions(metaPath)
|
|
112
|
+
enforceSecurePermissions(dataPath)
|
|
110
113
|
|
|
111
114
|
setToL1(hash, {
|
|
112
115
|
bytes: binBuf,
|
|
@@ -151,7 +154,7 @@ export function pruneCacheToLimit(maxBytes = DEFAULT_CACHE_MAX_BYTES) {
|
|
|
151
154
|
size,
|
|
152
155
|
lastAccessed: meta.lastAccessed || meta.createdAt || 0,
|
|
153
156
|
})
|
|
154
|
-
} catch {}
|
|
157
|
+
} catch { /* entry meta unreadable; skip */ }
|
|
155
158
|
}
|
|
156
159
|
|
|
157
160
|
if (totalSize <= maxBytes) return
|
|
@@ -165,7 +168,7 @@ export function pruneCacheToLimit(maxBytes = DEFAULT_CACHE_MAX_BYTES) {
|
|
|
165
168
|
if (existsSync(entry.metaPath)) unlinkSync(entry.metaPath)
|
|
166
169
|
if (existsSync(entry.dataPath)) unlinkSync(entry.dataPath)
|
|
167
170
|
totalSize -= entry.size
|
|
168
|
-
} catch {}
|
|
171
|
+
} catch { /* entry unlink failed; skip */ }
|
|
169
172
|
}
|
|
170
|
-
} catch {}
|
|
173
|
+
} catch { /* cache dir unreadable; prune skipped */ }
|
|
171
174
|
}
|
package/lib/index.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import z from '@deepseek-ai/schemastery'
|
|
16
16
|
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
17
17
|
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
18
|
+
import { enforceSecurePermissions } from './security.js'
|
|
18
19
|
import { existsSync, unlinkSync } from 'node:fs'
|
|
19
20
|
import os from 'node:os'
|
|
20
21
|
import path from 'node:path'
|
|
@@ -32,13 +33,14 @@ import {
|
|
|
32
33
|
|
|
33
34
|
import { buildDualOutputMarkdown, resolveConversationImage, analyzeImageWithVision } from './resolve-image.js'
|
|
34
35
|
import { registerAllTools } from './register-tools.js'
|
|
36
|
+
import { registerPluginUpdater } from './updater.js'
|
|
35
37
|
|
|
36
38
|
|
|
37
39
|
export { IMAGE_SIZES, OUTPUT_FORMATS, PROVIDER_KEYS, buildSidecar, normalizeMediaType, resolveConversationImage, analyzeImageWithVision }
|
|
38
40
|
|
|
39
41
|
|
|
40
42
|
/**
|
|
41
|
-
*
|
|
43
|
+
* Saves generated asset to workspace, registers with attachments, and returns Dual-Output (#150).
|
|
42
44
|
*/
|
|
43
45
|
|
|
44
46
|
|
|
@@ -126,9 +128,8 @@ export const name = 'dsh-image-gen'
|
|
|
126
128
|
/** Settings namespace the Web card edits. */
|
|
127
129
|
const NS = 'dsh-image-gen'
|
|
128
130
|
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
// под новое имя один раз — молча, при первом запуске после обновления.
|
|
131
|
+
// The plugin was previously named dsh-fal-image-gen. Any settings saved
|
|
132
|
+
// under the legacy namespace are read as fallback and migrated once on startup.
|
|
132
133
|
const LEGACY_NS = 'dsh-fal-image-gen'
|
|
133
134
|
export const inject = ['tools', 'attachments', 'credentials', 'webServer', 'settings', 'llm', 'systemPrompt']
|
|
134
135
|
|
|
@@ -320,7 +321,7 @@ export const Config = z.object({
|
|
|
320
321
|
})
|
|
321
322
|
|
|
322
323
|
/** Keep a file stem safe for the filesystem. */
|
|
323
|
-
/**
|
|
324
|
+
/** Read source image for editing: filesystem path or attachment id. */
|
|
324
325
|
export async function resolveSource(ctx, exec, ref) {
|
|
325
326
|
if (!ref) return undefined
|
|
326
327
|
const sessionCwd = exec.agent?.session?.header?.cwd
|
|
@@ -368,14 +369,14 @@ export async function resolveApiKey(ctx, ref) {
|
|
|
368
369
|
}
|
|
369
370
|
|
|
370
371
|
/**
|
|
371
|
-
*
|
|
372
|
+
* Migrate settings from legacy plugin name.
|
|
372
373
|
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
374
|
+
* Uses raw user layer: default values do not need migration,
|
|
375
|
+
* distinguishable only from user overrides. If settings are already configured
|
|
376
|
+
* under the new namespace, preserve them as newer choices.
|
|
376
377
|
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
378
|
+
* The legacy block is left untouched in settings to avoid unexpected deletions.
|
|
379
|
+
* and causes no conflict with the active namespace.
|
|
379
380
|
*/
|
|
380
381
|
function migrateLegacySettings(sctx, scope) {
|
|
381
382
|
try {
|
|
@@ -387,19 +388,19 @@ function migrateLegacySettings(sctx, scope) {
|
|
|
387
388
|
if (mine && typeof mine === 'object' && Object.keys(mine).length > 0) return
|
|
388
389
|
scope.update(structuredClone(legacy))
|
|
389
390
|
} catch (cannotMigrate) {
|
|
390
|
-
//
|
|
391
|
-
//
|
|
391
|
+
// Settings migration skipped — fallback to default schema configuration,
|
|
392
|
+
// and user may configure settings via UI card. No crash required.
|
|
392
393
|
}
|
|
393
394
|
}
|
|
394
395
|
|
|
395
|
-
/**
|
|
396
|
+
/** History directory: persists across restarts. */
|
|
396
397
|
export function historyFile() {
|
|
397
398
|
return path.join(process.env.DSH_HOME || path.join(os.homedir(), '.dsh'), 'dsh-image-gen', 'history.json')
|
|
398
399
|
}
|
|
399
400
|
|
|
400
|
-
/**
|
|
401
|
-
/**
|
|
402
|
-
/**
|
|
401
|
+
/** Read history from disk file (empty array if not found). */
|
|
402
|
+
/** Find history entry matching seed+prompt if file exists. */
|
|
403
|
+
/** Find history entry matching prompt text if file exists. */
|
|
403
404
|
export function findCachedGeneration(entries, hash) {
|
|
404
405
|
if (!hash || !Array.isArray(entries)) return undefined
|
|
405
406
|
return entries.find((e) => e.cacheHash === hash)
|
|
@@ -414,7 +415,7 @@ export async function findCached(entries, seed, prompt) {
|
|
|
414
415
|
return entries.find((e) => e.seed === seed && e.prompt === prompt)
|
|
415
416
|
}
|
|
416
417
|
|
|
417
|
-
/**
|
|
418
|
+
/** Return cached entry if file exists on disk, otherwise undefined. */
|
|
418
419
|
export async function cachedResult(entry) {
|
|
419
420
|
if (!entry || !entry.path) return undefined
|
|
420
421
|
if (!entry.path || !existsSync(entry.path)) return undefined
|
|
@@ -440,7 +441,7 @@ export async function cachedResult(entry) {
|
|
|
440
441
|
}
|
|
441
442
|
}
|
|
442
443
|
|
|
443
|
-
/**
|
|
444
|
+
/** Prune files and history records older than pruneDays (including sidecars). */
|
|
444
445
|
export async function pruneHistory(entries, pruneDays) {
|
|
445
446
|
if (!pruneDays || pruneDays <= 0) return entries
|
|
446
447
|
const cutoff = Date.now() - pruneDays * 86400000
|
|
@@ -448,7 +449,7 @@ export async function pruneHistory(entries, pruneDays) {
|
|
|
448
449
|
for (const e of entries) {
|
|
449
450
|
const created = e.createdAt ? Date.parse(e.createdAt) : NaN
|
|
450
451
|
if (Number.isFinite(created) && created < cutoff) {
|
|
451
|
-
try { unlinkSync(e.path) } catch (err) { /*
|
|
452
|
+
try { unlinkSync(e.path) } catch (err) { /* file already deleted */ }
|
|
452
453
|
try { unlinkSync(e.path.replace(/\.[^.]+$/, '.json')) } catch (err) { /* sidecar */ }
|
|
453
454
|
continue
|
|
454
455
|
}
|
|
@@ -467,20 +468,21 @@ export async function readHistory() {
|
|
|
467
468
|
}
|
|
468
469
|
}
|
|
469
470
|
|
|
470
|
-
/**
|
|
471
|
+
/** Save history to disk file (atomic overwrite). */
|
|
471
472
|
export async function writeHistory(entries) {
|
|
472
473
|
try {
|
|
473
474
|
await mkdir(path.dirname(historyFile()), { recursive: true })
|
|
474
475
|
await writeFile(historyFile(), JSON.stringify(entries, null, 2))
|
|
475
|
-
|
|
476
|
+
enforceSecurePermissions(historyFile())
|
|
477
|
+
} catch (e) { /* history write failure is non-fatal */ }
|
|
476
478
|
}
|
|
477
479
|
|
|
478
|
-
/**
|
|
480
|
+
/** Filter entries whose files exist on disk; newest first. */
|
|
479
481
|
export function filterHistory(entries, exists) {
|
|
480
482
|
return entries.filter((e) => exists(e.path)).slice(0, 50)
|
|
481
483
|
}
|
|
482
484
|
|
|
483
|
-
/**
|
|
485
|
+
/** Collect text chunks from llm.stream iterator. */
|
|
484
486
|
export async function collectText(iterable) {
|
|
485
487
|
let out = ''
|
|
486
488
|
let sawDelta = false
|
|
@@ -498,7 +500,7 @@ export async function collectText(iterable) {
|
|
|
498
500
|
return out.trim()
|
|
499
501
|
}
|
|
500
502
|
|
|
501
|
-
/**
|
|
503
|
+
/** Expand short prompt via chat model; return original prompt on error. */
|
|
502
504
|
export function buildEnhancePromptSystemMessage(provider, model) {
|
|
503
505
|
const isFlux = String(model || '').toLowerCase().includes('flux') || provider === 'fal'
|
|
504
506
|
if (isFlux) {
|
|
@@ -507,7 +509,7 @@ export function buildEnhancePromptSystemMessage(provider, model) {
|
|
|
507
509
|
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
510
|
}
|
|
509
511
|
|
|
510
|
-
/**
|
|
512
|
+
/** Expand short prompt via chat model; return original prompt on error. */
|
|
511
513
|
export async function enhancePrompt(ctx, cfg, prompt, signal, provider) {
|
|
512
514
|
if (!cfg.enhancePrompt) return { prompt, enhanced: false }
|
|
513
515
|
if (String(prompt).length >= (cfg.enhanceBelowChars || 200)) return { prompt, enhanced: false }
|
|
@@ -617,10 +619,8 @@ export function apply(ctx, config) {
|
|
|
617
619
|
}
|
|
618
620
|
})()
|
|
619
621
|
|
|
620
|
-
//
|
|
621
|
-
//
|
|
622
|
-
// отправленных: сними его — и картинки в истории разговоров перестанут
|
|
623
|
-
// показываться.
|
|
622
|
+
// Two routes, one handler: new route for new messages, legacy route for
|
|
623
|
+
// backward compatibility with conversation history.
|
|
624
624
|
for (const path of ['/dsh-image-gen/image', '/dsh-fal-image-gen/image']) {
|
|
625
625
|
ctx.effect(() => ctx.webServer.register({
|
|
626
626
|
kind: 'exact',
|
|
@@ -629,7 +629,14 @@ export function apply(ctx, config) {
|
|
|
629
629
|
}), `dsh-image-gen: image route ${path}`)
|
|
630
630
|
}
|
|
631
631
|
|
|
632
|
-
|
|
632
|
+
// One-click plugin updater route per DSH standard
|
|
633
|
+
ctx.effect(() => registerPluginUpdater(ctx, {
|
|
634
|
+
endpoint: '/api/dsh-image-gen/update',
|
|
635
|
+
packageName: '@goodandready/dsh-image-gen',
|
|
636
|
+
manifestUrl: new URL('../package.json', import.meta.url),
|
|
637
|
+
}), 'dsh-image-gen: plugin updater route')
|
|
638
|
+
|
|
639
|
+
// Provider connection diagnostics probe
|
|
633
640
|
ctx.effect(() => ctx.webServer.register({
|
|
634
641
|
kind: 'exact',
|
|
635
642
|
path: '/dsh-image-gen/diagnostics/test',
|
|
@@ -658,7 +665,7 @@ export function apply(ctx, config) {
|
|
|
658
665
|
},
|
|
659
666
|
}), 'dsh-image-gen: diagnostics test route')
|
|
660
667
|
|
|
661
|
-
|
|
668
|
+
// Generation history: in-memory list filtered by filesystem existence.
|
|
662
669
|
ctx.effect(() => ctx.webServer.register({
|
|
663
670
|
kind: 'exact',
|
|
664
671
|
path: '/dsh-image-gen/history',
|
package/lib/loop-guard.js
CHANGED
|
@@ -55,6 +55,7 @@ export function trackAndAssertLoopGuard(sessionId, {
|
|
|
55
55
|
/**
|
|
56
56
|
* Resets loop guard counter for a session.
|
|
57
57
|
*/
|
|
58
|
+
/** @internal — test helper; not part of the plugin runtime API */
|
|
58
59
|
export function resetLoopGuard(sessionId) {
|
|
59
60
|
const sid = String(sessionId || 'default_session')
|
|
60
61
|
sessionStates.delete(sid)
|
|
@@ -63,6 +64,7 @@ export function resetLoopGuard(sessionId) {
|
|
|
63
64
|
/**
|
|
64
65
|
* Gets current state for debugging/testing.
|
|
65
66
|
*/
|
|
67
|
+
/** @internal — test helper; not part of the plugin runtime API */
|
|
66
68
|
export function getLoopGuardState(sessionId) {
|
|
67
69
|
const sid = String(sessionId || 'default_session')
|
|
68
70
|
return sessionStates.get(sid) || { count: 0, lastPrompt: '', lastTimestamp: 0 }
|
package/lib/prompt-polisher.js
CHANGED
|
@@ -185,10 +185,3 @@ export function polishPrompt(basePrompt, stylePreset = null, {
|
|
|
185
185
|
}
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
-
export function listCuratedStyles() {
|
|
189
|
-
return Object.values(CURATED_STYLES).map(s => ({
|
|
190
|
-
id: s.id,
|
|
191
|
-
label: s.label,
|
|
192
|
-
guidanceScale: s.guidanceScale,
|
|
193
|
-
}))
|
|
194
|
-
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// lib/provider-utils.js
|
|
2
|
+
// Shared provider utilities and size/format constants extracted from providers.js (#239)
|
|
3
|
+
|
|
4
|
+
export function resolveApiKeyCandidates(ref) {
|
|
5
|
+
if (!ref) return []
|
|
6
|
+
const candidates = [ref]
|
|
7
|
+
if (ref === 'FAL_API_KEY') candidates.push('FAL_KEY')
|
|
8
|
+
else if (ref === 'FAL_KEY') candidates.push('FAL_API_KEY')
|
|
9
|
+
return candidates
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
export const PROVIDER_MAX_COUNTS = {
|
|
14
|
+
fal: 4,
|
|
15
|
+
replicate: 4,
|
|
16
|
+
custom: 10,
|
|
17
|
+
seedream: 10,
|
|
18
|
+
gemini: 4,
|
|
19
|
+
codex: 1,
|
|
20
|
+
grok: 1,
|
|
21
|
+
local: 4,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function normalizeCount(count, max = 4) {
|
|
25
|
+
const n = Math.floor(Number(count))
|
|
26
|
+
if (!Number.isFinite(n)) return 1
|
|
27
|
+
return Math.max(1, Math.min(max, n))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** @internal — test helper for validating provider count limits */
|
|
31
|
+
export function clampProviderCount(provider, count) {
|
|
32
|
+
const max = PROVIDER_MAX_COUNTS[provider] || 4
|
|
33
|
+
return normalizeCount(count, max)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function buildEndpointUrl(baseURL, pathSuffix) {
|
|
37
|
+
const base = String(baseURL || '').trim().replace(/\/+$/, '')
|
|
38
|
+
const suffix = String(pathSuffix || '').trim().replace(/^\/+/, '')
|
|
39
|
+
return `${base}/${suffix}`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** @internal — test helper for abort signal simulation */
|
|
43
|
+
export function createAbortError(message = 'Image generation cancelled') {
|
|
44
|
+
const err = new Error(message)
|
|
45
|
+
err.name = 'AbortError'
|
|
46
|
+
return err
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
/** Fast Laplacian-like sharpness and entropy estimator across image scanlines. */
|
|
51
|
+
export function estimateSharpnessAndVariance(bytes) {
|
|
52
|
+
const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes || [])
|
|
53
|
+
if (!buf || buf.length < 64) {
|
|
54
|
+
return { score: 0, passed: false, isBlank: true, reason: 'Empty or corrupt image buffer' }
|
|
55
|
+
}
|
|
56
|
+
let sum = 0
|
|
57
|
+
let diffSum = 0
|
|
58
|
+
const sampleStep = Math.max(1, Math.floor(buf.length / 4096))
|
|
59
|
+
let count = 0
|
|
60
|
+
for (let i = 8; i < buf.length - sampleStep; i += sampleStep) {
|
|
61
|
+
const v = buf[i]
|
|
62
|
+
const nextV = buf[i + sampleStep]
|
|
63
|
+
sum += v
|
|
64
|
+
diffSum += Math.abs(v - nextV)
|
|
65
|
+
count++
|
|
66
|
+
}
|
|
67
|
+
const avgDiff = count > 0 ? diffSum / count : 0
|
|
68
|
+
if (avgDiff < 2) {
|
|
69
|
+
return { score: 0.1, passed: false, isBlank: true, reason: 'Image appears blank or solid monochrome' }
|
|
70
|
+
}
|
|
71
|
+
const score = Math.min(0.98, Math.max(0.3, +(0.5 + (avgDiff / 255) * 0.5).toFixed(2)))
|
|
72
|
+
return { score, passed: score >= 0.5, isBlank: false, avgDiff: +avgDiff.toFixed(2) }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Adaptive color palette quantizer for SVG vectorization. */
|
|
76
|
+
export function quantizePalette(colorMode = 'color', paletteSize = 16) {
|
|
77
|
+
if (colorMode === 'binary') return ['#000000', '#ffffff']
|
|
78
|
+
if (colorMode === 'grayscale') return ['#000000', '#444444', '#888888', '#cccccc', '#ffffff']
|
|
79
|
+
// Standard vibrant UI vector palette
|
|
80
|
+
return [
|
|
81
|
+
'#000000', '#ffffff', '#e11d48', '#2563eb',
|
|
82
|
+
'#16a34a', '#ca8a04', '#9333ea', '#0891b2',
|
|
83
|
+
'#475569', '#64748b', '#94a3b8', '#cbd5e1'
|
|
84
|
+
].slice(0, Math.max(2, paletteSize))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
/** Format parameters in standard Automatic1111 / ComfyUI text format for drag-and-drop support. */
|
|
89
|
+
export function formatA1111Parameters(metadata = {}) {
|
|
90
|
+
if (typeof metadata === 'string') return metadata
|
|
91
|
+
const prompt = metadata.prompt || ''
|
|
92
|
+
const neg = metadata.negative_prompt || metadata.negativePrompt || ''
|
|
93
|
+
const seed = metadata.seed ?? ''
|
|
94
|
+
const size = metadata.size || (metadata.width && metadata.height ? `${metadata.width}x${metadata.height}` : '1024x1024')
|
|
95
|
+
const model = metadata.model || metadata.provider || ''
|
|
96
|
+
const steps = metadata.steps || 20
|
|
97
|
+
const cfg = metadata.cfg_scale || metadata.guidance_scale || 7
|
|
98
|
+
|
|
99
|
+
let out = prompt
|
|
100
|
+
if (neg) out += `\nNegative prompt: ${neg}`
|
|
101
|
+
out += `\nSteps: ${steps}, Sampler: Euler, CFG scale: ${cfg}, Seed: ${seed}, Size: ${size}, Model: ${model}`
|
|
102
|
+
return out
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
import { createHash } from 'node:crypto'
|
|
107
|
+
|
|
108
|
+
/** Deterministic sha256 hash for image generation caching. */
|
|
109
|
+
export function computeGenerationHash({ provider, model, prompt, seed, size, style }) {
|
|
110
|
+
const norm = [
|
|
111
|
+
String(provider || '').trim().toLowerCase(),
|
|
112
|
+
String(model || '').trim().toLowerCase(),
|
|
113
|
+
String(prompt || '').trim(),
|
|
114
|
+
String(seed ?? ''),
|
|
115
|
+
String(size || '').trim().toLowerCase(),
|
|
116
|
+
String(style || '').trim().toLowerCase(),
|
|
117
|
+
].join('|')
|
|
118
|
+
return createHash('sha256').update(norm).digest('hex')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Image generation provider implementations.
|
|
122
|
+
//
|
|
123
|
+
// A provider receives a generation job and returns finished image bytes. Everything
|
|
124
|
+
// that follows (attachments, workspace files, links, card rendering) is shared
|
|
125
|
+
// across all providers and lives in index.js.
|
|
126
|
+
//
|
|
127
|
+
// Network is injected via fetchImpl and keys via resolveKey, enabling fully isolated unit tests.
|
|
128
|
+
|
|
129
|
+
export const PROVIDER_KEYS = ['fal', 'custom', 'codex', 'grok', 'local', 'seedream', 'gemini', 'replicate']
|
|
130
|
+
|
|
131
|
+
/** Clamp count from tool arguments to range 1..4. */
|
|
132
|
+
/** Provider fallback ordering: primary provider first, followed by PROVIDER_KEYS. */
|
|
133
|
+
export function fallbackOrder(primary) {
|
|
134
|
+
return [primary, ...PROVIDER_KEYS.filter((k) => k !== primary)]
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Iterates through candidate generators, returning the first successful result.
|
|
139
|
+
* Throws an aggregate error if all candidates fail.
|
|
140
|
+
* @param generators - array of (key, seed) => Promise<generated> functions.
|
|
141
|
+
* @param order - candidate key evaluation order.
|
|
142
|
+
*/
|
|
143
|
+
|
|
144
|
+
/** Calculate exponential backoff delay with jitter. */
|
|
145
|
+
export function calculateBackoff(attempt, baseInterval = 500, maxInterval = 5000, jitterFactor = 0.2) {
|
|
146
|
+
const exp = Math.min(maxInterval, baseInterval * Math.pow(1.3, attempt))
|
|
147
|
+
const jitter = exp * jitterFactor * (Math.random() * 2 - 1)
|
|
148
|
+
return Math.max(100, Math.floor(exp + jitter))
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Check if an error is a fatal client error that should NOT be cascaded to other providers. */
|
|
152
|
+
export function isFatalClientError(error) {
|
|
153
|
+
const msg = (error?.message || String(error || '')).toLowerCase()
|
|
154
|
+
return (
|
|
155
|
+
msg.includes('content policy') ||
|
|
156
|
+
msg.includes('safety system') ||
|
|
157
|
+
msg.includes('nsfw') ||
|
|
158
|
+
msg.includes('moderation') ||
|
|
159
|
+
msg.includes('bad request (http 400') ||
|
|
160
|
+
msg.includes('invalid_prompt') ||
|
|
161
|
+
msg.includes('prompt is required') ||
|
|
162
|
+
msg.includes('unsupported image format') ||
|
|
163
|
+
msg.includes('402 payment required') ||
|
|
164
|
+
msg.includes('insufficient_quota') ||
|
|
165
|
+
msg.includes('insufficient credits') ||
|
|
166
|
+
msg.includes('exceeded your current quota') ||
|
|
167
|
+
msg.includes('balance is insufficient')
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Safely extract a human-readable message from any error, object, or response.
|
|
173
|
+
* Completely eliminates "[object Object]" and prevents duplicate provider prefixes (e.g. "codex: codex: ...").
|
|
174
|
+
*/
|
|
175
|
+
export function formatErrorMessage(e, providerKey = '') {
|
|
176
|
+
if (e === null || e === undefined) {
|
|
177
|
+
return providerKey ? `${providerKey}: unknown error` : 'unknown error'
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
let msg = ''
|
|
181
|
+
if (typeof e === 'string') {
|
|
182
|
+
msg = e
|
|
183
|
+
} else if (typeof e === 'object') {
|
|
184
|
+
if (typeof e.message === 'string' && e.message && e.message !== '[object Object]') {
|
|
185
|
+
msg = e.message
|
|
186
|
+
} else if (typeof e.detail === 'string' && e.detail) {
|
|
187
|
+
msg = e.detail
|
|
188
|
+
} else if (e.error) {
|
|
189
|
+
if (typeof e.error === 'string') msg = e.error
|
|
190
|
+
else if (typeof e.error.message === 'string') msg = e.error.message
|
|
191
|
+
else if (typeof e.error.detail === 'string') msg = e.error.detail
|
|
192
|
+
else {
|
|
193
|
+
try { msg = JSON.stringify(e.error) } catch { msg = String(e.error) }
|
|
194
|
+
}
|
|
195
|
+
} else if (typeof e.statusText === 'string' && e.statusText) {
|
|
196
|
+
msg = `HTTP ${e.status || ''} ${e.statusText}`.trim()
|
|
197
|
+
} else if (typeof e.cause === 'string') {
|
|
198
|
+
msg = e.cause
|
|
199
|
+
} else if (e.cause && typeof e.cause.message === 'string') {
|
|
200
|
+
msg = e.cause.message
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (!msg || msg === '[object Object]') {
|
|
204
|
+
try {
|
|
205
|
+
const json = JSON.stringify(e)
|
|
206
|
+
if (json && json !== '{}') {
|
|
207
|
+
msg = json.slice(0, 500)
|
|
208
|
+
}
|
|
209
|
+
} catch { /* response body not JSON; use raw text */ }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (!msg || msg === '[object Object]') {
|
|
213
|
+
const keys = Object.getOwnPropertyNames(e)
|
|
214
|
+
if (keys.length) {
|
|
215
|
+
msg = `error [${keys.join(', ')}]`
|
|
216
|
+
} else {
|
|
217
|
+
msg = String(e)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
} else {
|
|
221
|
+
msg = String(e)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
msg = String(msg || 'unknown error').trim()
|
|
225
|
+
|
|
226
|
+
if (providerKey) {
|
|
227
|
+
const prefixRegex = new RegExp(`^${providerKey}\\s*:\\s*`, 'i')
|
|
228
|
+
while (prefixRegex.test(msg)) {
|
|
229
|
+
msg = msg.replace(prefixRegex, '').trim()
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (!msg || msg === '[object Object]') {
|
|
234
|
+
msg = 'unknown error object'
|
|
235
|
+
} else if (msg.includes('[object Object]')) {
|
|
236
|
+
msg = msg.replace(/\[object Object\]/g, 'unknown error object').trim()
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (providerKey) {
|
|
240
|
+
return `${providerKey}: ${msg}`
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return msg
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function resolveSubscriptionSize(size, aspectPixels, aspectRatio) {
|
|
247
|
+
if (size && SUBSCRIPTION_SIZES[size]) {
|
|
248
|
+
return SUBSCRIPTION_SIZES[size]
|
|
249
|
+
}
|
|
250
|
+
const ratio = String(aspectRatio || '').trim()
|
|
251
|
+
if (ratio === '16:9' || ratio === '3:2' || ratio === '4:3') {
|
|
252
|
+
return '1536x1024'
|
|
253
|
+
}
|
|
254
|
+
if (ratio === '9:16' || ratio === '2:3' || ratio === '3:4') {
|
|
255
|
+
return '1024x1536'
|
|
256
|
+
}
|
|
257
|
+
if (ratio === '1:1') {
|
|
258
|
+
return '1024x1024'
|
|
259
|
+
}
|
|
260
|
+
if (Array.isArray(aspectPixels) && aspectPixels.length === 2) {
|
|
261
|
+
const [w, h] = aspectPixels
|
|
262
|
+
if (w > h) return '1536x1024'
|
|
263
|
+
if (h > w) return '1024x1536'
|
|
264
|
+
return '1024x1024'
|
|
265
|
+
}
|
|
266
|
+
return '1024x1024'
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export const SUBSCRIPTION_SIZES = {
|
|
270
|
+
square_hd: '1024x1024',
|
|
271
|
+
square: '1024x1024',
|
|
272
|
+
portrait_4_3: '1024x1536',
|
|
273
|
+
portrait_16_9: '1024x1536',
|
|
274
|
+
landscape_4_3: '1536x1024',
|
|
275
|
+
landscape_16_9: '1536x1024',
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Image sizes accepted by fal-ai/flux-2/klein (and most FAL flux models). */
|
|
279
|
+
export const IMAGE_SIZES = [
|
|
280
|
+
'square_hd',
|
|
281
|
+
'square',
|
|
282
|
+
'portrait_4_3',
|
|
283
|
+
'portrait_16_9',
|
|
284
|
+
'landscape_4_3',
|
|
285
|
+
'landscape_16_9',
|
|
286
|
+
]
|
|
287
|
+
|
|
288
|
+
/** Output formats accepted by the model. */
|
|
289
|
+
export const OUTPUT_FORMATS = ['png', 'jpeg', 'webp']
|
|
290
|
+
|
|
291
|
+
// Named sizes are a unified abstraction across all providers.
|
|
292
|
+
// FAL accepts named identifiers; OpenAI-compatible gateways require WxH resolution.
|
|
293
|
+
export const SIZE_PIXELS = {
|
|
294
|
+
square_hd: '1024x1024',
|
|
295
|
+
square: '512x512',
|
|
296
|
+
portrait_4_3: '768x1024',
|
|
297
|
+
portrait_16_9: '576x1024',
|
|
298
|
+
landscape_4_3: '1024x768',
|
|
299
|
+
landscape_16_9: '1024x576',
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Normalize a raw key into a FAL `Authorization: Key <key>` value. */
|