@goodandready/dsh-image-gen 0.10.17 → 0.10.19
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 +26 -4
- package/README.ru.md +26 -5
- package/README.zh.md +18 -0
- package/lib/client.js +1461 -0
- package/lib/generation-cache.js +38 -2
- package/lib/index.js +39 -1
- package/lib/prompt-polisher.js +194 -0
- package/lib/providers.js +113 -18
- package/lib/tools/editing.js +8 -3
- package/lib/tools/generation.js +48 -12
- package/lib/tools/processing.js +172 -0
- package/package.json +1 -1
package/lib/generation-cache.js
CHANGED
|
@@ -7,6 +7,27 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync, unlinkSyn
|
|
|
7
7
|
|
|
8
8
|
const DEFAULT_CACHE_MAX_BYTES = 500 * 1024 * 1024 // 500 MB
|
|
9
9
|
|
|
10
|
+
// In-Memory L1 LRU cache (up to 32 entries) for sub-millisecond retrieval
|
|
11
|
+
const L1_CACHE = new Map()
|
|
12
|
+
const L1_MAX_ENTRIES = 32
|
|
13
|
+
|
|
14
|
+
function getFromL1(hash) {
|
|
15
|
+
if (!L1_CACHE.has(hash)) return null
|
|
16
|
+
const entry = L1_CACHE.get(hash)
|
|
17
|
+
L1_CACHE.delete(hash)
|
|
18
|
+
L1_CACHE.set(hash, entry) // refresh LRU order
|
|
19
|
+
return entry
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function setToL1(hash, entry) {
|
|
23
|
+
if (L1_CACHE.has(hash)) L1_CACHE.delete(hash)
|
|
24
|
+
else if (L1_CACHE.size >= L1_MAX_ENTRIES) {
|
|
25
|
+
const oldestKey = L1_CACHE.keys().next().value
|
|
26
|
+
if (oldestKey) L1_CACHE.delete(oldestKey)
|
|
27
|
+
}
|
|
28
|
+
L1_CACHE.set(hash, entry)
|
|
29
|
+
}
|
|
30
|
+
|
|
10
31
|
function getCacheDir() {
|
|
11
32
|
const base = process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
12
33
|
return join(base, 'cache', 'image-gen')
|
|
@@ -23,6 +44,9 @@ function getCacheDir() {
|
|
|
23
44
|
export function getCachedGeneration(hash, { force = false } = {}) {
|
|
24
45
|
if (force || !hash) return null
|
|
25
46
|
|
|
47
|
+
const l1Hit = getFromL1(hash)
|
|
48
|
+
if (l1Hit) return l1Hit
|
|
49
|
+
|
|
26
50
|
const dir = getCacheDir()
|
|
27
51
|
const metaPath = join(dir, `${hash}.json`)
|
|
28
52
|
const dataPath = join(dir, `${hash}.bin`)
|
|
@@ -41,7 +65,7 @@ export function getCachedGeneration(hash, { force = false } = {}) {
|
|
|
41
65
|
// Non-fatal touch failure: still return valid cache entry
|
|
42
66
|
}
|
|
43
67
|
|
|
44
|
-
|
|
68
|
+
const cachedEntry = {
|
|
45
69
|
bytes,
|
|
46
70
|
mediaType: meta.mediaType || 'image/png',
|
|
47
71
|
width: meta.width,
|
|
@@ -49,6 +73,8 @@ export function getCachedGeneration(hash, { force = false } = {}) {
|
|
|
49
73
|
seed: meta.seed,
|
|
50
74
|
meta,
|
|
51
75
|
}
|
|
76
|
+
setToL1(hash, cachedEntry)
|
|
77
|
+
return cachedEntry
|
|
52
78
|
} catch {
|
|
53
79
|
return null
|
|
54
80
|
}
|
|
@@ -79,7 +105,17 @@ export function setCachedGeneration(hash, { bytes, mediaType = 'image/png', widt
|
|
|
79
105
|
}
|
|
80
106
|
|
|
81
107
|
writeFileSync(metaPath, JSON.stringify(cacheMeta, null, 2), 'utf8')
|
|
82
|
-
|
|
108
|
+
const binBuf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes)
|
|
109
|
+
writeFileSync(dataPath, binBuf)
|
|
110
|
+
|
|
111
|
+
setToL1(hash, {
|
|
112
|
+
bytes: binBuf,
|
|
113
|
+
mediaType,
|
|
114
|
+
width,
|
|
115
|
+
height,
|
|
116
|
+
seed,
|
|
117
|
+
meta: cacheMeta,
|
|
118
|
+
})
|
|
83
119
|
|
|
84
120
|
pruneCacheToLimit(maxBytes)
|
|
85
121
|
} catch {
|
package/lib/index.js
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
resolveApiKeyCandidates,
|
|
28
28
|
toLosslessJson,
|
|
29
29
|
saveAttachmentSafe,
|
|
30
|
+
testProviderConnection,
|
|
30
31
|
} from './providers.js'
|
|
31
32
|
|
|
32
33
|
import { buildDualOutputMarkdown, resolveConversationImage, analyzeImageWithVision } from './resolve-image.js'
|
|
@@ -276,6 +277,14 @@ export const Config = z.object({
|
|
|
276
277
|
.string()
|
|
277
278
|
.description('Model used to enhance the prompt. Empty means the same model that leads the conversation.')
|
|
278
279
|
.default(''),
|
|
280
|
+
autoEnhancePrompt: z
|
|
281
|
+
.boolean()
|
|
282
|
+
.description('Enrich prompts with photography, lighting, and composition quality tokens.')
|
|
283
|
+
.default(false),
|
|
284
|
+
defaultStylePreset: z
|
|
285
|
+
.string()
|
|
286
|
+
.description('Default style preset applied to generations (e.g. cinematic, anime, photorealistic).')
|
|
287
|
+
.default('none'),
|
|
279
288
|
enhanceBelowChars: z
|
|
280
289
|
.number()
|
|
281
290
|
.description('Only enhance prompts shorter than this many characters.')
|
|
@@ -620,7 +629,36 @@ export function apply(ctx, config) {
|
|
|
620
629
|
}), `dsh-image-gen: image route ${path}`)
|
|
621
630
|
}
|
|
622
631
|
|
|
623
|
-
//
|
|
632
|
+
// Диагностика подключения к провайдерам
|
|
633
|
+
ctx.effect(() => ctx.webServer.register({
|
|
634
|
+
kind: 'exact',
|
|
635
|
+
path: '/dsh-image-gen/diagnostics/test',
|
|
636
|
+
handler: async (req, res) => {
|
|
637
|
+
if (req.method !== 'GET') {
|
|
638
|
+
res.writeHead(405, { 'Content-Type': 'application/json' })
|
|
639
|
+
res.end(JSON.stringify({ error: 'GET only' }))
|
|
640
|
+
return
|
|
641
|
+
}
|
|
642
|
+
try {
|
|
643
|
+
const u = new URL(req.url, 'http://localhost')
|
|
644
|
+
const provider = u.searchParams.get('provider') || config.provider || 'fal'
|
|
645
|
+
const deps = {
|
|
646
|
+
fetchImpl: fetch,
|
|
647
|
+
resolveKey: (ref) => resolveApiKey(ctx, ref),
|
|
648
|
+
cfg: (typeof getConfig === "function") ? getConfig() : config,
|
|
649
|
+
ctx,
|
|
650
|
+
}
|
|
651
|
+
const result = await testProviderConnection(deps, provider)
|
|
652
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
653
|
+
res.end(JSON.stringify({ provider, ...result }))
|
|
654
|
+
} catch (err) {
|
|
655
|
+
res.writeHead(500, { 'Content-Type': 'application/json' })
|
|
656
|
+
res.end(JSON.stringify({ ok: false, error: err.message }))
|
|
657
|
+
}
|
|
658
|
+
},
|
|
659
|
+
}), 'dsh-image-gen: diagnostics test route')
|
|
660
|
+
|
|
661
|
+
// История генераций: in-memory список, отфильтрованный по существованию файлов.
|
|
624
662
|
ctx.effect(() => ctx.webServer.register({
|
|
625
663
|
kind: 'exact',
|
|
626
664
|
path: '/dsh-image-gen/history',
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file lib/prompt-polisher.js
|
|
3
|
+
* Curated artistic/photographic style presets, smart prompt enrichment, and palette harmonization.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const CURATED_STYLES = {
|
|
7
|
+
cinematic: {
|
|
8
|
+
id: 'cinematic',
|
|
9
|
+
label: 'Cinematic Film',
|
|
10
|
+
promptSuffix: 'cinematic 35mm film still, anamorphic lighting, shallow depth of field, color graded, photorealistic details',
|
|
11
|
+
negativePrompt: 'cartoon, illustration, 3d render, oversaturated, blurry, distorted, amateur',
|
|
12
|
+
guidanceScale: 6.5,
|
|
13
|
+
},
|
|
14
|
+
photorealistic: {
|
|
15
|
+
id: 'photorealistic',
|
|
16
|
+
label: 'Photorealistic Studio',
|
|
17
|
+
promptSuffix: 'photorealistic portraiture, 85mm lens, natural studio lighting, micro-textures, 8k resolution, award-winning photography',
|
|
18
|
+
negativePrompt: 'illustration, drawing, anime, 3d render, plastic skin, CGI, doll-like',
|
|
19
|
+
guidanceScale: 7.0,
|
|
20
|
+
},
|
|
21
|
+
anime: {
|
|
22
|
+
id: 'anime',
|
|
23
|
+
label: 'Anime Masterpiece',
|
|
24
|
+
promptSuffix: 'clean anime aesthetic, sharp clean lineart, vibrant cel shading, detailed background scenery, Makoto Shinkai style',
|
|
25
|
+
negativePrompt: 'photorealistic, realistic photo, noisy, deformed, 3d model, lowres',
|
|
26
|
+
guidanceScale: 7.0,
|
|
27
|
+
},
|
|
28
|
+
minimalist_vector: {
|
|
29
|
+
id: 'minimalist_vector',
|
|
30
|
+
label: 'Minimalist Vector',
|
|
31
|
+
promptSuffix: 'minimalist vector art, clean sharp outlines, flat color fills, modern graphic design, Bauhaus aesthetics, SVG vector style',
|
|
32
|
+
negativePrompt: 'photorealistic, heavy gradients, 3d render, noisy texture, clutter, photorealism',
|
|
33
|
+
guidanceScale: 7.5,
|
|
34
|
+
},
|
|
35
|
+
isometric_3d: {
|
|
36
|
+
id: 'isometric_3d',
|
|
37
|
+
label: 'Isometric 3D',
|
|
38
|
+
promptSuffix: 'isometric 3D render, ambient occlusion, miniature diorama, Octane render, smooth clay surface, soft global illumination',
|
|
39
|
+
negativePrompt: 'flat, 2d, hand-drawn sketch, chaotic background, realistic photo',
|
|
40
|
+
guidanceScale: 7.0,
|
|
41
|
+
},
|
|
42
|
+
analog_film: {
|
|
43
|
+
id: 'analog_film',
|
|
44
|
+
label: 'Analog 35mm Film',
|
|
45
|
+
promptSuffix: 'vintage 35mm photograph, Kodak Portra 400 color science, authentic film grain, subtle halation, nostalgic muted tones',
|
|
46
|
+
negativePrompt: 'digital CGI, harsh sharpening, plastic, 3d render, oversaturated digital look',
|
|
47
|
+
guidanceScale: 6.5,
|
|
48
|
+
},
|
|
49
|
+
cyberpunk: {
|
|
50
|
+
id: 'cyberpunk',
|
|
51
|
+
label: 'Cyberpunk Neon',
|
|
52
|
+
promptSuffix: 'cyberpunk aesthetic, high-tech dark city, neon volumetric lighting, rain-slicked reflective surfaces, moody atmosphere',
|
|
53
|
+
negativePrompt: 'daylight, sunshine, pastoral, cartoon, pastel colors, washed out',
|
|
54
|
+
guidanceScale: 7.5,
|
|
55
|
+
},
|
|
56
|
+
pixel_art: {
|
|
57
|
+
id: 'pixel_art',
|
|
58
|
+
label: '16-bit Retro Pixel Art',
|
|
59
|
+
promptSuffix: '16-bit retro pixel art, crisp pixel grid, authentic dithered shading, nostalgic arcade palette, master pixel artwork',
|
|
60
|
+
negativePrompt: 'smooth vector, 3d render, blurry, modern photography, anti-aliased gradients',
|
|
61
|
+
guidanceScale: 8.0,
|
|
62
|
+
},
|
|
63
|
+
oil_painting: {
|
|
64
|
+
id: 'oil_painting',
|
|
65
|
+
label: 'Classical Oil Painting',
|
|
66
|
+
promptSuffix: 'classical fine art oil painting, impasto brushstrokes, textured canvas, chiaroscuro lighting, museum masterpiece',
|
|
67
|
+
negativePrompt: 'photograph, digital vector, 3d CGI, plastic, flat anime',
|
|
68
|
+
guidanceScale: 7.0,
|
|
69
|
+
},
|
|
70
|
+
claymation: {
|
|
71
|
+
id: 'claymation',
|
|
72
|
+
label: 'Claymation Stop-Motion',
|
|
73
|
+
promptSuffix: 'handcrafted claymation stop-motion aesthetic, tactile plasticine clay texture, studio macro softbox lighting, Aardman inspired',
|
|
74
|
+
negativePrompt: 'digital CGI, flat vector, photographic humans, harsh synthetic gloss',
|
|
75
|
+
guidanceScale: 7.5,
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Normalizes a style identifier or alias into a canonical style key.
|
|
81
|
+
*/
|
|
82
|
+
export function normalizeStyleKey(key) {
|
|
83
|
+
if (!key) return null
|
|
84
|
+
const cleaned = String(key).trim().toLowerCase().replace(/[-\s]/g, '_')
|
|
85
|
+
if (CURATED_STYLES[cleaned]) return cleaned
|
|
86
|
+
// Alias mapping
|
|
87
|
+
if (cleaned === 'isometric') return 'isometric_3d'
|
|
88
|
+
if (cleaned === 'vector' || cleaned === 'minimalist') return 'minimalist_vector'
|
|
89
|
+
if (cleaned === 'film' || cleaned === 'analog') return 'analog_film'
|
|
90
|
+
if (cleaned === 'photo' || cleaned === 'realism') return 'photorealistic'
|
|
91
|
+
return cleaned
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Smart prompt enrichment: adds photographic & lighting cues if requested.
|
|
96
|
+
*/
|
|
97
|
+
export function enrichWithQualityCues(prompt, styleKey = null) {
|
|
98
|
+
if (!prompt || typeof prompt !== 'string') return prompt
|
|
99
|
+
let p = prompt.trim()
|
|
100
|
+
const lower = p.toLowerCase()
|
|
101
|
+
|
|
102
|
+
// Only append if not already containing lighting/detail tokens
|
|
103
|
+
const hasLighting = lower.includes('lighting') || lower.includes('light') || lower.includes('illumination')
|
|
104
|
+
const hasDetail = lower.includes('detailed') || lower.includes('highres') || lower.includes('sharp') || lower.includes('resolution')
|
|
105
|
+
|
|
106
|
+
const additions = []
|
|
107
|
+
if (!hasLighting) additions.push('balanced natural lighting')
|
|
108
|
+
if (!hasDetail && styleKey !== 'minimalist_vector' && styleKey !== 'pixel_art') additions.push('high visual clarity')
|
|
109
|
+
|
|
110
|
+
if (additions.length > 0) {
|
|
111
|
+
p = p.endsWith('.') ? `${p} ${additions.join(', ')}.` : `${p}, ${additions.join(', ')}`
|
|
112
|
+
}
|
|
113
|
+
return p
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Polish and enrich a prompt with styles, auto-enhancements, and optional palette constraints.
|
|
118
|
+
*
|
|
119
|
+
* @param {string} basePrompt
|
|
120
|
+
* @param {string|null} stylePreset
|
|
121
|
+
* @param {object} [options]
|
|
122
|
+
* @param {boolean} [options.autoEnhance=false]
|
|
123
|
+
* @param {string[]} [options.paletteColors=null]
|
|
124
|
+
* @param {string} [options.existingNegative]
|
|
125
|
+
* @param {number} [options.existingGuidance]
|
|
126
|
+
* @returns {{ prompt: string, negativePrompt?: string, guidanceScale?: number, styleApplied?: string }}
|
|
127
|
+
*/
|
|
128
|
+
export function polishPrompt(basePrompt, stylePreset = null, {
|
|
129
|
+
autoEnhance = false,
|
|
130
|
+
paletteColors = null,
|
|
131
|
+
existingNegative = '',
|
|
132
|
+
existingGuidance = undefined,
|
|
133
|
+
} = {}) {
|
|
134
|
+
let prompt = String(basePrompt || '').trim()
|
|
135
|
+
let negative = existingNegative ? String(existingNegative).trim() : ''
|
|
136
|
+
let guidance = existingGuidance
|
|
137
|
+
let styleApplied = null
|
|
138
|
+
|
|
139
|
+
const key = normalizeStyleKey(stylePreset)
|
|
140
|
+
const preset = key ? CURATED_STYLES[key] : null
|
|
141
|
+
|
|
142
|
+
if (preset) {
|
|
143
|
+
styleApplied = preset.id
|
|
144
|
+
if (preset.promptSuffix && !prompt.toLowerCase().includes(preset.promptSuffix.toLowerCase())) {
|
|
145
|
+
prompt = prompt.endsWith('.') ? `${prompt} ${preset.promptSuffix}.` : `${prompt}, ${preset.promptSuffix}`
|
|
146
|
+
}
|
|
147
|
+
if (preset.negativePrompt) {
|
|
148
|
+
negative = [negative, preset.negativePrompt].filter(Boolean).join(', ')
|
|
149
|
+
}
|
|
150
|
+
if (guidance === undefined && preset.guidanceScale !== undefined) {
|
|
151
|
+
guidance = preset.guidanceScale
|
|
152
|
+
}
|
|
153
|
+
} else if (stylePreset && typeof stylePreset === 'string' && stylePreset.trim() && stylePreset !== 'none') {
|
|
154
|
+
// Custom style suffix string
|
|
155
|
+
const customSuffix = stylePreset.trim()
|
|
156
|
+
if (!prompt.toLowerCase().includes(customSuffix.toLowerCase())) {
|
|
157
|
+
prompt = `${prompt}, in ${customSuffix} style`
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Auto-enhance prompt if toggled
|
|
162
|
+
if (autoEnhance) {
|
|
163
|
+
prompt = enrichWithQualityCues(prompt, styleApplied)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Palette constraint injection
|
|
167
|
+
if (Array.isArray(paletteColors) && paletteColors.length > 0) {
|
|
168
|
+
const validHex = paletteColors
|
|
169
|
+
.map(c => String(c).trim().toUpperCase())
|
|
170
|
+
.filter(c => /^#([0-9A-F]{3}|[0-9A-F]{6})$/i.test(c))
|
|
171
|
+
|
|
172
|
+
if (validHex.length > 0) {
|
|
173
|
+
const paletteDirective = `chromatic palette strictly dominated by ${validHex.join(', ')}, harmonious color grading`
|
|
174
|
+
prompt = `${prompt}, ${paletteDirective}`
|
|
175
|
+
const paletteNeg = 'clashing foreign colors, dissonant hues, out-of-palette tints'
|
|
176
|
+
negative = [negative, paletteNeg].filter(Boolean).join(', ')
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
prompt,
|
|
182
|
+
negativePrompt: negative || undefined,
|
|
183
|
+
guidanceScale: guidance,
|
|
184
|
+
styleApplied: styleApplied || undefined,
|
|
185
|
+
}
|
|
186
|
+
}
|
|
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
|
+
}
|
package/lib/providers.js
CHANGED
|
@@ -152,7 +152,12 @@ export function isFatalClientError(error) {
|
|
|
152
152
|
msg.includes('bad request (http 400') ||
|
|
153
153
|
msg.includes('invalid_prompt') ||
|
|
154
154
|
msg.includes('prompt is required') ||
|
|
155
|
-
msg.includes('unsupported image format')
|
|
155
|
+
msg.includes('unsupported image format') ||
|
|
156
|
+
msg.includes('402 payment required') ||
|
|
157
|
+
msg.includes('insufficient_quota') ||
|
|
158
|
+
msg.includes('insufficient credits') ||
|
|
159
|
+
msg.includes('exceeded your current quota') ||
|
|
160
|
+
msg.includes('balance is insufficient')
|
|
156
161
|
)
|
|
157
162
|
}
|
|
158
163
|
|
|
@@ -371,16 +376,19 @@ export async function pollStatus(fetchImpl, statusUrl, key, signal, pollInterval
|
|
|
371
376
|
for (;;) {
|
|
372
377
|
if (signal?.aborted) throw new Error('FAL generation cancelled')
|
|
373
378
|
if (Date.now() > deadline) throw new Error(`FAL generation timed out after ${timeoutMs} ms`)
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
379
|
+
if (attempt > 0) {
|
|
380
|
+
const delay = calculateBackoff(attempt - 1, pollIntervalMs, 5000)
|
|
381
|
+
await new Promise((resolve) => {
|
|
382
|
+
const timer = setTimeout(resolve, delay)
|
|
383
|
+
if (signal) {
|
|
384
|
+
signal.addEventListener('abort', () => {
|
|
385
|
+
clearTimeout(timer)
|
|
386
|
+
resolve()
|
|
387
|
+
}, { once: true })
|
|
388
|
+
}
|
|
389
|
+
})
|
|
390
|
+
}
|
|
391
|
+
attempt++
|
|
384
392
|
if (signal?.aborted) throw new Error('FAL generation cancelled')
|
|
385
393
|
const res = await fetchImpl(statusUrl, { headers: { Authorization: falAuthHeader(key) }, signal })
|
|
386
394
|
const data = await res.json().catch(() => ({}))
|
|
@@ -770,13 +778,16 @@ export function makeProviders(deps, job) {
|
|
|
770
778
|
for (;;) {
|
|
771
779
|
if (signal?.aborted) throw new Error('Local ComfyUI generation cancelled')
|
|
772
780
|
if (Date.now() > deadline) throw new Error(`Local ComfyUI timed out after ${cfg.timeoutMs} ms`)
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
781
|
+
if (attempt > 0) {
|
|
782
|
+
const cDelay = calculateBackoff(attempt - 1, cfg.pollIntervalMs || 1000, 5000)
|
|
783
|
+
await new Promise((resolve) => {
|
|
784
|
+
const timer = setTimeout(resolve, cDelay)
|
|
785
|
+
if (signal) {
|
|
786
|
+
signal.addEventListener('abort', () => { clearTimeout(timer); resolve() }, { once: true })
|
|
787
|
+
}
|
|
788
|
+
})
|
|
789
|
+
}
|
|
790
|
+
attempt++
|
|
780
791
|
const hist = await fetchImpl(`${base}/history/${pid}`, { signal })
|
|
781
792
|
if (!hist.ok) continue
|
|
782
793
|
const histData = await hist.json().catch(() => ({}))
|
|
@@ -1180,3 +1191,87 @@ export function toLosslessJson(val) {
|
|
|
1180
1191
|
}
|
|
1181
1192
|
return null
|
|
1182
1193
|
}
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
/**
|
|
1197
|
+
* Diagnostic ping/connection test for a given provider.
|
|
1198
|
+
* Returns { ok: boolean, latencyMs: number, message: string }.
|
|
1199
|
+
*/
|
|
1200
|
+
export async function testProviderConnection(deps, providerId) {
|
|
1201
|
+
const { fetchImpl = fetch, resolveKey, cfg } = deps
|
|
1202
|
+
const start = Date.now()
|
|
1203
|
+
const p = (providerId || cfg.defaultProvider || 'fal').toLowerCase()
|
|
1204
|
+
|
|
1205
|
+
try {
|
|
1206
|
+
if (p === 'fal') {
|
|
1207
|
+
const key = await resolveKey(cfg.apiKeyEnv || 'FAL_KEY')
|
|
1208
|
+
if (!key) return { ok: false, latencyMs: 0, message: 'FAL API key not configured' }
|
|
1209
|
+
const res = await fetchImpl('https://queue.fal.run/fal-ai/fast-sdxl/status', {
|
|
1210
|
+
headers: { Authorization: falAuthHeader(key) },
|
|
1211
|
+
signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(6000) : undefined,
|
|
1212
|
+
})
|
|
1213
|
+
const latencyMs = Date.now() - start
|
|
1214
|
+
if (res.status === 401 || res.status === 403) {
|
|
1215
|
+
return { ok: false, latencyMs, message: `Fal auth failed (HTTP ${res.status})` }
|
|
1216
|
+
}
|
|
1217
|
+
return { ok: true, latencyMs, message: 'Fal.ai queue reachable' }
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
if (p === 'comfyui') {
|
|
1221
|
+
const url = cfg.comfyuiUrl || cfg.localBaseURL || 'http://127.0.0.1:8188'
|
|
1222
|
+
const res = await fetchImpl(`${url.replace(/\/+$/, '')}/system_stats`, {
|
|
1223
|
+
signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(5000) : undefined,
|
|
1224
|
+
})
|
|
1225
|
+
const latencyMs = Date.now() - start
|
|
1226
|
+
if (res.ok) {
|
|
1227
|
+
const data = await res.json().catch(() => ({}))
|
|
1228
|
+
const vram = data?.devices?.[0]?.vram_free ? ` (${Math.round(data.devices[0].vram_free / 1048576)} MB free VRAM)` : ''
|
|
1229
|
+
return { ok: true, latencyMs, message: `ComfyUI operational${vram}` }
|
|
1230
|
+
}
|
|
1231
|
+
return { ok: false, latencyMs, message: `ComfyUI returned HTTP ${res.status}` }
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
if (p === 'a1111') {
|
|
1235
|
+
const url = cfg.a1111Url || cfg.localBaseURL || 'http://127.0.0.1:7860'
|
|
1236
|
+
const res = await fetchImpl(`${url.replace(/\/+$/, '')}/sdapi/v1/samplers`, {
|
|
1237
|
+
signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(5000) : undefined,
|
|
1238
|
+
})
|
|
1239
|
+
const latencyMs = Date.now() - start
|
|
1240
|
+
if (res.ok) {
|
|
1241
|
+
return { ok: true, latencyMs, message: 'Automatic1111 operational' }
|
|
1242
|
+
}
|
|
1243
|
+
return { ok: false, latencyMs, message: `A1111 returned HTTP ${res.status}` }
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
if (p === 'custom') {
|
|
1247
|
+
const url = cfg.customEndpoint || cfg.customBaseURL || 'https://api.openai.com/v1'
|
|
1248
|
+
const key = await resolveKey(cfg.customApiKeyEnv || cfg.customKeyEnv || 'OPENAI_API_KEY')
|
|
1249
|
+
const headers = key ? { Authorization: `Bearer ${key}` } : {}
|
|
1250
|
+
const res = await fetchImpl(`${url.replace(/\/+$/, '')}/models`, {
|
|
1251
|
+
headers,
|
|
1252
|
+
signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(6000) : undefined,
|
|
1253
|
+
})
|
|
1254
|
+
const latencyMs = Date.now() - start
|
|
1255
|
+
if (res.status === 401 || res.status === 403) {
|
|
1256
|
+
return { ok: false, latencyMs, message: `Custom endpoint auth error (HTTP ${res.status})` }
|
|
1257
|
+
}
|
|
1258
|
+
if (res.ok || res.status === 404 || res.status === 405) {
|
|
1259
|
+
return { ok: true, latencyMs, message: `Custom gateway reachable (HTTP ${res.status})` }
|
|
1260
|
+
}
|
|
1261
|
+
return { ok: false, latencyMs, message: `Custom endpoint returned HTTP ${res.status}` }
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
if (p === 'subscription' || p === 'chatgpt' || p === 'grok' || p === 'codex') {
|
|
1265
|
+
const hasSub = Boolean(deps.ctx?.subscriptions)
|
|
1266
|
+
const latencyMs = Date.now() - start
|
|
1267
|
+
if (hasSub) {
|
|
1268
|
+
return { ok: true, latencyMs, message: 'Subscription bridge available' }
|
|
1269
|
+
}
|
|
1270
|
+
return { ok: false, latencyMs, message: 'dsh-subscriptions plugin not installed' }
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
return { ok: true, latencyMs: Date.now() - start, message: `Provider "${p}" configured` }
|
|
1274
|
+
} catch (err) {
|
|
1275
|
+
return { ok: false, latencyMs: Date.now() - start, message: err.message || 'Connection timeout' }
|
|
1276
|
+
}
|
|
1277
|
+
}
|
package/lib/tools/editing.js
CHANGED
|
@@ -75,6 +75,10 @@ export function registerEditingTools(ctx, deps) {
|
|
|
75
75
|
},
|
|
76
76
|
mask: {
|
|
77
77
|
type: 'string',
|
|
78
|
+
},
|
|
79
|
+
mask_image: {
|
|
80
|
+
type: 'string',
|
|
81
|
+
description: 'Optional path or attachment id of the inpaint mask image (alias for mask).',
|
|
78
82
|
description: 'Optional path or attachment id of the inpaint mask image.',
|
|
79
83
|
},
|
|
80
84
|
strength: {
|
|
@@ -120,7 +124,8 @@ export function registerEditingTools(ctx, deps) {
|
|
|
120
124
|
})
|
|
121
125
|
assertBudgetAvailable(estimatedCostEdit, cfg.dailyBudgetUsd)
|
|
122
126
|
const source = await resolveConversationImage(ctx, exec, args.image)
|
|
123
|
-
const
|
|
127
|
+
const maskRef = args.mask_image || args.mask
|
|
128
|
+
const mask = maskRef ? await resolveSource(ctx, exec, maskRef) : undefined
|
|
124
129
|
const vision = await analyzeImageWithVision(ctx, exec, source)
|
|
125
130
|
|
|
126
131
|
let effectivePrompt = args.prompt
|
|
@@ -130,7 +135,7 @@ export function registerEditingTools(ctx, deps) {
|
|
|
130
135
|
|
|
131
136
|
const provider = cfg.provider || 'fal'
|
|
132
137
|
const deps = {
|
|
133
|
-
fetchImpl:
|
|
138
|
+
fetchImpl: fetch,
|
|
134
139
|
resolveKey: (ref) => resolveApiKey(ctx, ref),
|
|
135
140
|
cfg,
|
|
136
141
|
}
|
|
@@ -257,7 +262,7 @@ export function registerEditingTools(ctx, deps) {
|
|
|
257
262
|
|
|
258
263
|
const provider = cfg.provider || 'fal'
|
|
259
264
|
const deps = {
|
|
260
|
-
fetchImpl:
|
|
265
|
+
fetchImpl: fetch,
|
|
261
266
|
resolveKey: (ref) => resolveApiKey(ctx, ref),
|
|
262
267
|
cfg,
|
|
263
268
|
}
|
package/lib/tools/generation.js
CHANGED
|
@@ -1,3 +1,20 @@
|
|
|
1
|
+
import { polishPrompt } from '../prompt-polisher.js'
|
|
2
|
+
|
|
3
|
+
/** Run an array of async task functions with a concurrency cap */
|
|
4
|
+
async function asyncPool(tasks, concurrency = 3) {
|
|
5
|
+
const results = new Array(tasks.length)
|
|
6
|
+
let nextIdx = 0
|
|
7
|
+
async function worker() {
|
|
8
|
+
while (nextIdx < tasks.length) {
|
|
9
|
+
const idx = nextIdx++
|
|
10
|
+
results[idx] = await tasks[idx]()
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
const workers = Array.from({ length: Math.min(concurrency, tasks.length) }, () => worker())
|
|
14
|
+
await Promise.all(workers)
|
|
15
|
+
return results
|
|
16
|
+
}
|
|
17
|
+
|
|
1
18
|
// generation — image-gen tools (generate_image, generate_image_pack). Extracted from apply() (#216).
|
|
2
19
|
|
|
3
20
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
@@ -95,6 +112,15 @@ export function registerGenerationTools(ctx, deps) {
|
|
|
95
112
|
type: 'string',
|
|
96
113
|
description: 'Optional custom directory to write images into (relative to session cwd or absolute). Defaults to outputDir from settings.',
|
|
97
114
|
},
|
|
115
|
+
style_preset: {
|
|
116
|
+
type: 'string',
|
|
117
|
+
description: 'Optional style preset: cinematic, photorealistic, anime, minimalist_vector, isometric_3d, analog_film, cyberpunk, claymation.',
|
|
118
|
+
},
|
|
119
|
+
palette_colors: {
|
|
120
|
+
type: 'array',
|
|
121
|
+
items: { type: 'string' },
|
|
122
|
+
description: 'Optional array of hex color codes (#1e1b4b) to constrain and harmonize the color palette.',
|
|
123
|
+
},
|
|
98
124
|
count: {
|
|
99
125
|
type: 'integer',
|
|
100
126
|
description: 'Number of variations to generate in one call, 1-4 (default 1). Cost scales with count.',
|
|
@@ -263,7 +289,7 @@ export function registerGenerationTools(ctx, deps) {
|
|
|
263
289
|
assertBudgetAvailable(estimatedCost, cfg.dailyBudgetUsd)
|
|
264
290
|
const providers = makeProviders(
|
|
265
291
|
{ fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg, subscriptionImages },
|
|
266
|
-
{ prompt: effectivePrompt, size, format, seed: args.seed, signal: exec.signal, negativePrompt:
|
|
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 },
|
|
267
293
|
)
|
|
268
294
|
// Подписочные провайдеры (codex/grok) отдают {ok:false, reason} вместо исключения:
|
|
269
295
|
// отказ должен дойти до модели текстом. Без проверки execute шёл дальше с пустыми
|
|
@@ -482,18 +508,21 @@ export function registerGenerationTools(ctx, deps) {
|
|
|
482
508
|
|
|
483
509
|
const batchPrompts = Array.isArray(args.prompts) && args.prompts.length ? args.prompts : null
|
|
484
510
|
if (batchPrompts) {
|
|
485
|
-
|
|
486
|
-
const item = batchPrompts[i]
|
|
511
|
+
const tasks = batchPrompts.map((item, i) => async () => {
|
|
487
512
|
const text = typeof item === 'string' ? item : (item && item.text) || ''
|
|
488
513
|
const cached = await checkCache(seedBase + i, text)
|
|
489
|
-
|
|
490
|
-
}
|
|
514
|
+
return cached || await tryGenerate(generators, order, seedBase + i, text)
|
|
515
|
+
})
|
|
516
|
+
const parallelResults = await asyncPool(tasks, 3)
|
|
517
|
+
images.push(...parallelResults)
|
|
491
518
|
} else {
|
|
492
519
|
const count = normalizeCount(args.count)
|
|
493
|
-
|
|
520
|
+
const tasks = Array.from({ length: count }, (_, i) => async () => {
|
|
494
521
|
const cached = await checkCache(seedBase + i, effectivePrompt)
|
|
495
|
-
|
|
496
|
-
}
|
|
522
|
+
return cached || await tryGenerate(generators, order, seedBase + i)
|
|
523
|
+
})
|
|
524
|
+
const parallelResults = await asyncPool(tasks, 3)
|
|
525
|
+
images.push(...parallelResults)
|
|
497
526
|
}
|
|
498
527
|
const first = images[0]
|
|
499
528
|
const dualOutputSummary = buildDualOutputMarkdown({
|
|
@@ -542,8 +571,7 @@ export function registerGenerationTools(ctx, deps) {
|
|
|
542
571
|
const warnings = []
|
|
543
572
|
const seedBase = Math.floor(Math.random() * 100000)
|
|
544
573
|
|
|
545
|
-
|
|
546
|
-
const ratio = ratios[i]
|
|
574
|
+
const packTasks = ratios.map((ratio) => async () => {
|
|
547
575
|
const sizeName = ratio === '16:9' ? 'landscape_16_9' : ratio === '9:16' ? 'portrait_16_9' : ratio === '4:3' ? 'landscape_4_3' : ratio === '3:4' ? 'portrait_4_3' : 'square_hd'
|
|
548
576
|
const name = `${slugify(args.output_name || 'pack')}-${ratio.replace(':', 'x')}`
|
|
549
577
|
try {
|
|
@@ -553,9 +581,17 @@ export function registerGenerationTools(ctx, deps) {
|
|
|
553
581
|
seed: seedBase,
|
|
554
582
|
output_name: name,
|
|
555
583
|
}, exec)
|
|
556
|
-
|
|
584
|
+
return { success: true, ratio, genResult }
|
|
557
585
|
} catch (err) {
|
|
558
|
-
|
|
586
|
+
return { success: false, ratio, error: err?.message || String(err) }
|
|
587
|
+
}
|
|
588
|
+
})
|
|
589
|
+
const settledPack = await Promise.all(packTasks.map((t) => t()))
|
|
590
|
+
for (const item of settledPack) {
|
|
591
|
+
if (item.success) {
|
|
592
|
+
results.push({ ratio: item.ratio, ...item.genResult })
|
|
593
|
+
} else {
|
|
594
|
+
warnings.push(`Ratio ${item.ratio} failed: ${item.error}`)
|
|
559
595
|
}
|
|
560
596
|
}
|
|
561
597
|
|