@goodandready/dsh-image-gen 0.10.15 → 0.10.17
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 +16 -0
- package/README.ru.md +16 -0
- package/lib/index.js +33 -1410
- package/lib/providers.js +61 -1
- package/lib/register-tools.js +22 -0
- package/lib/tools/editing.js +325 -0
- package/lib/tools/frontend.js +258 -0
- package/lib/tools/generation.js +575 -0
- package/lib/tools/inspect.js +117 -0
- package/lib/tools/processing.js +324 -0
- package/package.json +2 -2
package/lib/providers.js
CHANGED
|
@@ -461,6 +461,60 @@ export async function pixelDiff(a, b) {
|
|
|
461
461
|
return { diffRatio: diff / a.length }
|
|
462
462
|
}
|
|
463
463
|
|
|
464
|
+
/** Извлечение детальных сообщений об ошибках нод ComfyUI из ответа /history/{pid}. */
|
|
465
|
+
|
|
466
|
+
/** Безопасное сохранение вложения с fallback'ом при отсутствии ctx.attachments */
|
|
467
|
+
export async function saveAttachmentSafe(ctx, { bytes, mediaType, name }) {
|
|
468
|
+
if (ctx && ctx.attachments && typeof ctx.attachments.saveImage === 'function') {
|
|
469
|
+
try {
|
|
470
|
+
const att = await ctx.attachments.saveImage({
|
|
471
|
+
data: new Uint8Array(bytes),
|
|
472
|
+
mediaType,
|
|
473
|
+
name,
|
|
474
|
+
})
|
|
475
|
+
if (att && att.attachmentId) {
|
|
476
|
+
const localUrl = '/dsh-image-gen/image?id=' + encodeURIComponent(att.attachmentId)
|
|
477
|
+
+ '&mt=' + encodeURIComponent(att.mediaType || mediaType)
|
|
478
|
+
+ '&b=' + encodeURIComponent(String(att.bytes || bytes.length))
|
|
479
|
+
+ '&w=' + encodeURIComponent(String(att.width || 0))
|
|
480
|
+
+ '&h=' + encodeURIComponent(String(att.height || 0))
|
|
481
|
+
return { attachment: att, localUrl }
|
|
482
|
+
}
|
|
483
|
+
} catch (err) {
|
|
484
|
+
// Fallback below if attachment service fails
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
return {
|
|
488
|
+
attachment: {
|
|
489
|
+
attachmentId: '',
|
|
490
|
+
mediaType,
|
|
491
|
+
bytes: bytes.length,
|
|
492
|
+
width: 0,
|
|
493
|
+
height: 0,
|
|
494
|
+
name,
|
|
495
|
+
},
|
|
496
|
+
localUrl: '',
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
export function extractComfyNodeErrors(entry) {
|
|
501
|
+
if (!entry || typeof entry !== 'object') return ''
|
|
502
|
+
const status = entry.status
|
|
503
|
+
if (status && status.status_str === 'error') {
|
|
504
|
+
const msgs = status.messages || []
|
|
505
|
+
const errList = []
|
|
506
|
+
for (const m of msgs) {
|
|
507
|
+
if (Array.isArray(m) && m[0] === 'execution_error') {
|
|
508
|
+
const d = m[1] || {}
|
|
509
|
+
errList.push(`node ${d.node_id || 'unknown'} (${d.node_type || ''}): ${d.exception_message || d.exception_type || 'execution failed'}`)
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
if (errList.length) return errList.join('; ')
|
|
513
|
+
return status.status_str || 'execution error'
|
|
514
|
+
}
|
|
515
|
+
return ''
|
|
516
|
+
}
|
|
517
|
+
|
|
464
518
|
export function makeProviders(deps, job) {
|
|
465
519
|
const { fetchImpl, resolveKey, cfg } = deps
|
|
466
520
|
const { prompt, size, format, seed, signal, negativePrompt, guidanceScale, source, mask, strength, quality, style, aspectPixels, aspectRatio } = job
|
|
@@ -553,7 +607,7 @@ export function makeProviders(deps, job) {
|
|
|
553
607
|
throw new Error(`Custom image API has no provisioned channel for "${cfg.customModel}" (HTTP 503); check gateway channel and routing configuration`)
|
|
554
608
|
}
|
|
555
609
|
}
|
|
556
|
-
const detail = data?.error
|
|
610
|
+
const detail = formatErrorMessage(data?.error || data)
|
|
557
611
|
throw new Error(`Image API failed (HTTP ${res.status}): ${detail}`)
|
|
558
612
|
}
|
|
559
613
|
const item = data?.data?.[0]
|
|
@@ -727,6 +781,12 @@ export function makeProviders(deps, job) {
|
|
|
727
781
|
if (!hist.ok) continue
|
|
728
782
|
const histData = await hist.json().catch(() => ({}))
|
|
729
783
|
const entry = histData[pid]
|
|
784
|
+
if (entry) {
|
|
785
|
+
const comfyErr = extractComfyNodeErrors(entry)
|
|
786
|
+
if (comfyErr) {
|
|
787
|
+
throw new Error(`Local ComfyUI execution failed: ${comfyErr}`)
|
|
788
|
+
}
|
|
789
|
+
}
|
|
730
790
|
if (entry && entry.outputs) {
|
|
731
791
|
const outputs = entry.outputs
|
|
732
792
|
const img = Object.values(outputs).flatMap((o) => o.images || []).find((i) => i && i.filename)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// register-tools.js — orchestrates all image-gen tool registrations (#216).
|
|
2
|
+
// Each tool lives in lib/tools/* and is registered inside a labeled ctx.effect.
|
|
3
|
+
|
|
4
|
+
import { registerGenerationTools } from './tools/generation.js'
|
|
5
|
+
import { registerProcessingTools } from './tools/processing.js'
|
|
6
|
+
import { registerEditingTools } from './tools/editing.js'
|
|
7
|
+
import { registerInspectTools } from './tools/inspect.js'
|
|
8
|
+
import { registerFrontendTools } from './tools/frontend.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Registers every image-gen tool on the host tools service.
|
|
12
|
+
*
|
|
13
|
+
* @param {object} ctx cordis context
|
|
14
|
+
* @param {object} deps shared runtime from apply()
|
|
15
|
+
*/
|
|
16
|
+
export function registerAllTools(ctx, deps) {
|
|
17
|
+
registerGenerationTools(ctx, deps)
|
|
18
|
+
registerProcessingTools(ctx, deps)
|
|
19
|
+
registerEditingTools(ctx, deps)
|
|
20
|
+
registerInspectTools(ctx, deps)
|
|
21
|
+
registerFrontendTools(ctx, deps)
|
|
22
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
// editing — image-gen tools (edit_image, vary_image). Extracted from apply() (#216).
|
|
2
|
+
|
|
3
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { mkdir, writeFile } from 'node:fs/promises'
|
|
6
|
+
import {
|
|
7
|
+
computeGenerationHash,
|
|
8
|
+
IMAGE_SIZES,
|
|
9
|
+
OUTPUT_FORMATS,
|
|
10
|
+
PROVIDER_KEYS,
|
|
11
|
+
buildSidecar,
|
|
12
|
+
makeProviders,
|
|
13
|
+
ASPECT_RATIOS,
|
|
14
|
+
pixelDiff,
|
|
15
|
+
normalizeCount,
|
|
16
|
+
tryGenerate,
|
|
17
|
+
fallbackOrder,
|
|
18
|
+
embedPngMetadata,
|
|
19
|
+
removeBackgroundFal,
|
|
20
|
+
upscaleImageFal,
|
|
21
|
+
traceToSvg,
|
|
22
|
+
estimateCost,
|
|
23
|
+
estimateSharpnessAndVariance,
|
|
24
|
+
applyStylePreset,
|
|
25
|
+
resolveStylePreset,
|
|
26
|
+
blendImagesFal,
|
|
27
|
+
toLosslessJson,
|
|
28
|
+
saveAttachmentSafe,
|
|
29
|
+
} from '../providers.js'
|
|
30
|
+
import { resolveConversationImage, analyzeImageWithVision, buildDualOutputMarkdown } from '../resolve-image.js'
|
|
31
|
+
import { sanitizeNegativePrompt, supportsNegativePrompt } from '../negative-sanitizer.js'
|
|
32
|
+
import { executeWithQualityGate } from '../quality-gate.js'
|
|
33
|
+
import { calculateGenerationCost, recordSpend, assertBudgetAvailable } from '../cost-meter.js'
|
|
34
|
+
import { trackAndAssertLoopGuard } from '../loop-guard.js'
|
|
35
|
+
import { sanitizeErrorAndLogs } from '../security.js'
|
|
36
|
+
import { getCachedGeneration, setCachedGeneration } from '../generation-cache.js'
|
|
37
|
+
import {
|
|
38
|
+
extractDesignTokens,
|
|
39
|
+
generateCssGradient,
|
|
40
|
+
checkWcagContrast,
|
|
41
|
+
optimizeSvgContent,
|
|
42
|
+
generatePwaIconSuite,
|
|
43
|
+
extractSampleColorsFromBuffer,
|
|
44
|
+
} from '../frontend-assets.js'
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
export function registerEditingTools(ctx, deps) {
|
|
48
|
+
const {
|
|
49
|
+
config,
|
|
50
|
+
live,
|
|
51
|
+
saveAndAttachResult,
|
|
52
|
+
resolveSource,
|
|
53
|
+
slugify,
|
|
54
|
+
resolveApiKey,
|
|
55
|
+
} = deps
|
|
56
|
+
ctx.effect(() => {
|
|
57
|
+
ctx.tools.register(
|
|
58
|
+
defineTool({
|
|
59
|
+
name: 'edit_image',
|
|
60
|
+
description:
|
|
61
|
+
'Edit an existing image using inpainting, targeted modification, or object replacement. '
|
|
62
|
+
+ 'Describe the desired changes in prompt. '
|
|
63
|
+
+ 'If image is omitted or "latest", automatically resolves the most recent image from the conversation session (#144). '
|
|
64
|
+
+ 'Leverages @goodandready/dsh-vision-bridge for pre-edit composition analysis (#145). '
|
|
65
|
+
+ 'Optionally accepts mask (path or attachment id) and strength (0.1-1.0). Returns safe dual-output (#150).',
|
|
66
|
+
parameters: {
|
|
67
|
+
prompt: {
|
|
68
|
+
type: 'string',
|
|
69
|
+
required: true,
|
|
70
|
+
description: 'Detailed description of the modification or edit to perform on the image.',
|
|
71
|
+
},
|
|
72
|
+
image: {
|
|
73
|
+
type: 'string',
|
|
74
|
+
description: 'Path or attachment id of the source image. Defaults to "latest" to auto-resolve from session history.',
|
|
75
|
+
},
|
|
76
|
+
mask: {
|
|
77
|
+
type: 'string',
|
|
78
|
+
description: 'Optional path or attachment id of the inpaint mask image.',
|
|
79
|
+
},
|
|
80
|
+
strength: {
|
|
81
|
+
type: 'number',
|
|
82
|
+
description: 'Denoising / edit strength between 0.1 and 1.0 (default 0.75 for edits, 0.9 for mask inpainting).',
|
|
83
|
+
},
|
|
84
|
+
output_name: {
|
|
85
|
+
type: 'string',
|
|
86
|
+
description: 'Optional file name stem for the saved edited image.',
|
|
87
|
+
},
|
|
88
|
+
output_dir: {
|
|
89
|
+
type: 'string',
|
|
90
|
+
description: 'Optional custom directory to write edited image into.',
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
output: {
|
|
94
|
+
schema: {
|
|
95
|
+
type: 'object',
|
|
96
|
+
additionalProperties: true,
|
|
97
|
+
properties: {
|
|
98
|
+
summary: { type: 'string' },
|
|
99
|
+
path: { type: 'string' },
|
|
100
|
+
url: { type: 'string' },
|
|
101
|
+
width: { type: 'number' },
|
|
102
|
+
height: { type: 'number' },
|
|
103
|
+
seed: { type: 'number' },
|
|
104
|
+
provider: { type: 'string' },
|
|
105
|
+
cost: { type: 'number' },
|
|
106
|
+
format: { type: 'string' },
|
|
107
|
+
attachment: { type: 'object', additionalProperties: true },
|
|
108
|
+
visionAnalysis: { type: 'string' },
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
async execute(args, exec) {
|
|
113
|
+
try {
|
|
114
|
+
trackAndAssertLoopGuard(exec?.agent?.session?.id || 'default', { limit: cfg.loopGuardLimit })
|
|
115
|
+
const estimatedCostEdit = calculateGenerationCost({
|
|
116
|
+
provider: cfg.provider || 'fal',
|
|
117
|
+
model: cfg.model,
|
|
118
|
+
size: cfg.defaultSize || 'square_hd',
|
|
119
|
+
count: 1,
|
|
120
|
+
})
|
|
121
|
+
assertBudgetAvailable(estimatedCostEdit, cfg.dailyBudgetUsd)
|
|
122
|
+
const source = await resolveConversationImage(ctx, exec, args.image)
|
|
123
|
+
const mask = args.mask ? await resolveSource(ctx, exec, args.mask) : undefined
|
|
124
|
+
const vision = await analyzeImageWithVision(ctx, exec, source)
|
|
125
|
+
|
|
126
|
+
let effectivePrompt = args.prompt
|
|
127
|
+
if (vision.available && vision.summary) {
|
|
128
|
+
effectivePrompt = `${args.prompt} (Context: modifying ${vision.summary})`
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const provider = cfg.provider || 'fal'
|
|
132
|
+
const deps = {
|
|
133
|
+
fetchImpl: ctx.http ? (url, opt) => ctx.http(url, opt) : fetch,
|
|
134
|
+
resolveKey: (ref) => resolveApiKey(ctx, ref),
|
|
135
|
+
cfg,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const seed = Math.floor(Math.random() * 100000)
|
|
139
|
+
const strength = args.strength ?? (mask ? 0.85 : 0.65)
|
|
140
|
+
const format = cfg.defaultFormat || 'png'
|
|
141
|
+
const size = cfg.defaultSize || 'square_hd'
|
|
142
|
+
|
|
143
|
+
const job = {
|
|
144
|
+
prompt: effectivePrompt,
|
|
145
|
+
size,
|
|
146
|
+
format,
|
|
147
|
+
seed,
|
|
148
|
+
source,
|
|
149
|
+
mask,
|
|
150
|
+
strength,
|
|
151
|
+
provider,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const providers = makeProviders(deps, job)
|
|
155
|
+
const fn = providers[provider] || providers.fal || providers.custom
|
|
156
|
+
const gen = await fn(seed, effectivePrompt)
|
|
157
|
+
|
|
158
|
+
const stem = args.output_name ? slugify(args.output_name) : `${source.name}-edit-${seed}`
|
|
159
|
+
const name = `${stem}.${format}`
|
|
160
|
+
|
|
161
|
+
const result = await saveAndAttachResult(ctx, exec, cfg, {
|
|
162
|
+
bytes: gen.bytes,
|
|
163
|
+
mediaType: gen.mediaType || 'image/png',
|
|
164
|
+
name,
|
|
165
|
+
stem,
|
|
166
|
+
prompt: args.prompt,
|
|
167
|
+
size,
|
|
168
|
+
format,
|
|
169
|
+
seed: gen.seed ?? seed,
|
|
170
|
+
provider,
|
|
171
|
+
model: cfg.model,
|
|
172
|
+
cost: gen.cost,
|
|
173
|
+
sourceUrl: gen.sourceUrl,
|
|
174
|
+
deliverAs: cfg.deliverAs || 'both',
|
|
175
|
+
args,
|
|
176
|
+
action: 'edited',
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
if (vision.summary) {
|
|
180
|
+
result.visionAnalysis = vision.summary
|
|
181
|
+
}
|
|
182
|
+
return toLosslessJson(result)
|
|
183
|
+
} catch (err) {
|
|
184
|
+
throw new Error(sanitizeErrorAndLogs(err.message || err))
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
}),
|
|
188
|
+
)
|
|
189
|
+
}, 'dsh-image-gen: tool edit_image')
|
|
190
|
+
ctx.effect(() => {
|
|
191
|
+
ctx.tools.register(
|
|
192
|
+
defineTool({
|
|
193
|
+
name: 'vary_image',
|
|
194
|
+
description:
|
|
195
|
+
'Generate subtle or creative variations of an existing image while preserving overall composition and structure. '
|
|
196
|
+
+ 'Pass variation_strength between 0.1 (subtle difference) and 0.9 (strong reinterpretation, default 0.35). '
|
|
197
|
+
+ 'If image is omitted or "latest", automatically resolves the most recent image from the conversation session (#144). '
|
|
198
|
+
+ 'Returns safe dual-output for text-only LLMs (#150).',
|
|
199
|
+
parameters: {
|
|
200
|
+
image: {
|
|
201
|
+
type: 'string',
|
|
202
|
+
description: 'Path or attachment id of the source image. Defaults to "latest" to auto-resolve from session history.',
|
|
203
|
+
},
|
|
204
|
+
prompt: {
|
|
205
|
+
type: 'string',
|
|
206
|
+
description: 'Optional prompt to steer the variation towards a specific mood, lighting, or style.',
|
|
207
|
+
},
|
|
208
|
+
variation_strength: {
|
|
209
|
+
type: 'number',
|
|
210
|
+
description: 'Strength of variation between 0.1 and 0.9. Default: 0.35.',
|
|
211
|
+
},
|
|
212
|
+
count: {
|
|
213
|
+
type: 'integer',
|
|
214
|
+
description: 'Number of variations to produce (1-4, default 1).',
|
|
215
|
+
},
|
|
216
|
+
output_name: {
|
|
217
|
+
type: 'string',
|
|
218
|
+
description: 'Optional file name stem for the saved variations.',
|
|
219
|
+
},
|
|
220
|
+
output_dir: {
|
|
221
|
+
type: 'string',
|
|
222
|
+
description: 'Optional custom directory to write variations into.',
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
output: {
|
|
226
|
+
schema: {
|
|
227
|
+
type: 'object',
|
|
228
|
+
additionalProperties: true,
|
|
229
|
+
properties: {
|
|
230
|
+
summary: { type: 'string' },
|
|
231
|
+
path: { type: 'string' },
|
|
232
|
+
url: { type: 'string' },
|
|
233
|
+
width: { type: 'number' },
|
|
234
|
+
height: { type: 'number' },
|
|
235
|
+
seed: { type: 'number' },
|
|
236
|
+
provider: { type: 'string' },
|
|
237
|
+
cost: { type: 'number' },
|
|
238
|
+
format: { type: 'string' },
|
|
239
|
+
attachment: { type: 'object', additionalProperties: true },
|
|
240
|
+
variations: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
async execute(args, exec) {
|
|
245
|
+
try {
|
|
246
|
+
trackAndAssertLoopGuard(exec?.agent?.session?.id || 'default', { limit: cfg.loopGuardLimit })
|
|
247
|
+
const count = normalizeCount(args.count || 1)
|
|
248
|
+
const estimatedCostVary = calculateGenerationCost({
|
|
249
|
+
provider: cfg.provider || 'fal',
|
|
250
|
+
model: cfg.model,
|
|
251
|
+
size: cfg.defaultSize || 'square_hd',
|
|
252
|
+
count,
|
|
253
|
+
})
|
|
254
|
+
assertBudgetAvailable(estimatedCostVary, cfg.dailyBudgetUsd)
|
|
255
|
+
const source = await resolveConversationImage(ctx, exec, args.image)
|
|
256
|
+
const vision = await analyzeImageWithVision(ctx, exec, source)
|
|
257
|
+
|
|
258
|
+
const provider = cfg.provider || 'fal'
|
|
259
|
+
const deps = {
|
|
260
|
+
fetchImpl: ctx.http ? (url, opt) => ctx.http(url, opt) : fetch,
|
|
261
|
+
resolveKey: (ref) => resolveApiKey(ctx, ref),
|
|
262
|
+
cfg,
|
|
263
|
+
}
|
|
264
|
+
const variationStrength = Math.max(0.05, Math.min(0.95, args.variation_strength ?? 0.35))
|
|
265
|
+
const baseSeed = Math.floor(Math.random() * 100000)
|
|
266
|
+
const format = cfg.defaultFormat || 'png'
|
|
267
|
+
const size = cfg.defaultSize || 'square_hd'
|
|
268
|
+
|
|
269
|
+
const promptText = args.prompt || (vision.available ? `variation of ${vision.summary}` : 'high fidelity variation preserving composition')
|
|
270
|
+
|
|
271
|
+
const results = []
|
|
272
|
+
for (let i = 0; i < count; i++) {
|
|
273
|
+
const currentSeed = baseSeed + i
|
|
274
|
+
const job = {
|
|
275
|
+
prompt: promptText,
|
|
276
|
+
size,
|
|
277
|
+
format,
|
|
278
|
+
seed: currentSeed,
|
|
279
|
+
source,
|
|
280
|
+
strength: variationStrength,
|
|
281
|
+
provider,
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const providers = makeProviders(deps, job)
|
|
285
|
+
const fn = providers[provider] || providers.fal || providers.custom
|
|
286
|
+
const gen = await fn(currentSeed, promptText)
|
|
287
|
+
|
|
288
|
+
const stem = args.output_name
|
|
289
|
+
? `${slugify(args.output_name)}-var-${i + 1}`
|
|
290
|
+
: `${source.name}-var-${currentSeed}`
|
|
291
|
+
const name = `${stem}.${format}`
|
|
292
|
+
|
|
293
|
+
const item = await saveAndAttachResult(ctx, exec, cfg, {
|
|
294
|
+
bytes: gen.bytes,
|
|
295
|
+
mediaType: gen.mediaType || 'image/png',
|
|
296
|
+
name,
|
|
297
|
+
stem,
|
|
298
|
+
prompt: promptText,
|
|
299
|
+
size,
|
|
300
|
+
format,
|
|
301
|
+
seed: gen.seed ?? currentSeed,
|
|
302
|
+
provider,
|
|
303
|
+
model: cfg.model,
|
|
304
|
+
cost: gen.cost,
|
|
305
|
+
sourceUrl: gen.sourceUrl,
|
|
306
|
+
deliverAs: cfg.deliverAs || 'both',
|
|
307
|
+
args,
|
|
308
|
+
action: `variation (${variationStrength})`,
|
|
309
|
+
})
|
|
310
|
+
results.push(item)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const first = results[0]
|
|
314
|
+
return toLosslessJson({
|
|
315
|
+
...first,
|
|
316
|
+
variations: results.map((r) => ({ path: r.path, attachmentId: r.attachment?.attachmentId, seed: r.seed })),
|
|
317
|
+
})
|
|
318
|
+
} catch (err) {
|
|
319
|
+
throw new Error(sanitizeErrorAndLogs(err.message || err))
|
|
320
|
+
}
|
|
321
|
+
},
|
|
322
|
+
}),
|
|
323
|
+
)
|
|
324
|
+
}, 'dsh-image-gen: tool vary_image')
|
|
325
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// frontend — image-gen tools (extract_design_tokens, image_to_css_gradient, check_image_contrast, optimize_vector_svg, generate_pwa_icon_suite). Extracted from apply() (#216).
|
|
2
|
+
|
|
3
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { mkdir, writeFile } from 'node:fs/promises'
|
|
6
|
+
import {
|
|
7
|
+
computeGenerationHash,
|
|
8
|
+
IMAGE_SIZES,
|
|
9
|
+
OUTPUT_FORMATS,
|
|
10
|
+
PROVIDER_KEYS,
|
|
11
|
+
buildSidecar,
|
|
12
|
+
makeProviders,
|
|
13
|
+
ASPECT_RATIOS,
|
|
14
|
+
pixelDiff,
|
|
15
|
+
normalizeCount,
|
|
16
|
+
tryGenerate,
|
|
17
|
+
fallbackOrder,
|
|
18
|
+
embedPngMetadata,
|
|
19
|
+
removeBackgroundFal,
|
|
20
|
+
upscaleImageFal,
|
|
21
|
+
traceToSvg,
|
|
22
|
+
estimateCost,
|
|
23
|
+
estimateSharpnessAndVariance,
|
|
24
|
+
applyStylePreset,
|
|
25
|
+
resolveStylePreset,
|
|
26
|
+
blendImagesFal,
|
|
27
|
+
toLosslessJson,
|
|
28
|
+
saveAttachmentSafe,
|
|
29
|
+
} from '../providers.js'
|
|
30
|
+
import { resolveConversationImage, analyzeImageWithVision, buildDualOutputMarkdown } from '../resolve-image.js'
|
|
31
|
+
import { sanitizeNegativePrompt, supportsNegativePrompt } from '../negative-sanitizer.js'
|
|
32
|
+
import { executeWithQualityGate } from '../quality-gate.js'
|
|
33
|
+
import { calculateGenerationCost, recordSpend, assertBudgetAvailable } from '../cost-meter.js'
|
|
34
|
+
import { trackAndAssertLoopGuard } from '../loop-guard.js'
|
|
35
|
+
import { sanitizeErrorAndLogs } from '../security.js'
|
|
36
|
+
import { getCachedGeneration, setCachedGeneration } from '../generation-cache.js'
|
|
37
|
+
import {
|
|
38
|
+
extractDesignTokens,
|
|
39
|
+
generateCssGradient,
|
|
40
|
+
checkWcagContrast,
|
|
41
|
+
optimizeSvgContent,
|
|
42
|
+
generatePwaIconSuite,
|
|
43
|
+
extractSampleColorsFromBuffer,
|
|
44
|
+
} from '../frontend-assets.js'
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
export function registerFrontendTools(ctx, deps) {
|
|
48
|
+
const {
|
|
49
|
+
config,
|
|
50
|
+
live,
|
|
51
|
+
saveAndAttachResult,
|
|
52
|
+
resolveSource,
|
|
53
|
+
slugify,
|
|
54
|
+
resolveApiKey,
|
|
55
|
+
} = deps
|
|
56
|
+
ctx.effect(() => {
|
|
57
|
+
ctx.tools.register(
|
|
58
|
+
defineTool({
|
|
59
|
+
name: 'extract_design_tokens',
|
|
60
|
+
description: 'Extract design tokens (CSS variables, Tailwind colors config, W3C tokens JSON) from an image or palette.',
|
|
61
|
+
parameters: {
|
|
62
|
+
image: { type: 'string', description: 'Path or attachment ID of the image to extract colors from.' },
|
|
63
|
+
colors: { type: 'array', items: { type: 'string' }, description: 'Optional list of hex colors if already known.' },
|
|
64
|
+
prefix: { type: 'string', description: 'Token naming prefix (default: color).' },
|
|
65
|
+
},
|
|
66
|
+
output: {
|
|
67
|
+
schema: {
|
|
68
|
+
type: 'object',
|
|
69
|
+
additionalProperties: true,
|
|
70
|
+
properties: {
|
|
71
|
+
tokens: { type: 'object', additionalProperties: true },
|
|
72
|
+
cssVariables: { type: 'string' },
|
|
73
|
+
tailwindSnippet: { type: 'string' },
|
|
74
|
+
w3cTokens: { type: 'object', additionalProperties: true },
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
async execute(args, exec) {
|
|
79
|
+
let colors = args.colors
|
|
80
|
+
if (!colors || colors.length === 0) {
|
|
81
|
+
if (args.image) {
|
|
82
|
+
const source = await resolveSource(ctx, exec, args.image)
|
|
83
|
+
if (!source || !source.bytes) {
|
|
84
|
+
throw new Error('Image not found or unreadable: ' + args.image)
|
|
85
|
+
}
|
|
86
|
+
colors = extractSampleColorsFromBuffer(source.bytes)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return toLosslessJson(extractDesignTokens(colors, { prefix: args.prefix }))
|
|
90
|
+
},
|
|
91
|
+
}),
|
|
92
|
+
)
|
|
93
|
+
}, 'dsh-image-gen: tool extract_design_tokens')
|
|
94
|
+
ctx.effect(() => {
|
|
95
|
+
ctx.tools.register(
|
|
96
|
+
defineTool({
|
|
97
|
+
name: 'image_to_css_gradient',
|
|
98
|
+
description: 'Convert an image background or color palette into a lightweight pure CSS gradient (< 1KB).',
|
|
99
|
+
parameters: {
|
|
100
|
+
image: { type: 'string', description: 'Path or attachment ID of the image.' },
|
|
101
|
+
colors: { type: 'array', items: { type: 'string' }, description: 'Optional explicit hex colors for the gradient.' },
|
|
102
|
+
type: { type: 'string', enum: ['mesh', 'linear', 'radial'], description: 'Gradient type: mesh, linear, or radial (default: mesh).' },
|
|
103
|
+
},
|
|
104
|
+
output: {
|
|
105
|
+
schema: {
|
|
106
|
+
type: 'object',
|
|
107
|
+
additionalProperties: true,
|
|
108
|
+
properties: {
|
|
109
|
+
type: { type: 'string' },
|
|
110
|
+
fallbackColor: { type: 'string' },
|
|
111
|
+
gradientCss: { type: 'string' },
|
|
112
|
+
completeStyle: { type: 'string' },
|
|
113
|
+
byteSize: { type: 'number' },
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
async execute(args, exec) {
|
|
118
|
+
let colors = args.colors
|
|
119
|
+
if (!colors || colors.length === 0) {
|
|
120
|
+
if (args.image) {
|
|
121
|
+
const source = await resolveSource(ctx, exec, args.image)
|
|
122
|
+
if (!source || !source.bytes) {
|
|
123
|
+
throw new Error('Image not found or unreadable: ' + args.image)
|
|
124
|
+
}
|
|
125
|
+
colors = extractSampleColorsFromBuffer(source.bytes)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return toLosslessJson(generateCssGradient(colors, args.type || 'mesh'))
|
|
129
|
+
},
|
|
130
|
+
}),
|
|
131
|
+
)
|
|
132
|
+
}, 'dsh-image-gen: tool image_to_css_gradient')
|
|
133
|
+
ctx.effect(() => {
|
|
134
|
+
ctx.tools.register(
|
|
135
|
+
defineTool({
|
|
136
|
+
name: 'check_image_contrast',
|
|
137
|
+
description: 'Check image background contrast ratio against text color according to WCAG 2.1 AA/AAA standards and suggest scrim.',
|
|
138
|
+
parameters: {
|
|
139
|
+
image: { type: 'string', description: 'Path or attachment ID of the image.' },
|
|
140
|
+
colors: { type: 'array', items: { type: 'string' }, description: 'Optional list of background hex colors.' },
|
|
141
|
+
text_color: { type: 'string', description: 'Text hex color to test against (default: #ffffff).' },
|
|
142
|
+
},
|
|
143
|
+
output: {
|
|
144
|
+
schema: {
|
|
145
|
+
type: 'object',
|
|
146
|
+
additionalProperties: true,
|
|
147
|
+
properties: {
|
|
148
|
+
textHex: { type: 'string' },
|
|
149
|
+
minContrastRatio: { type: 'number' },
|
|
150
|
+
passedAA: { type: 'boolean' },
|
|
151
|
+
passedAALarge: { type: 'boolean' },
|
|
152
|
+
passedAAA: { type: 'boolean' },
|
|
153
|
+
recommendation: { type: 'string' },
|
|
154
|
+
suggestedScrimCss: { type: 'string' },
|
|
155
|
+
details: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
async execute(args, exec) {
|
|
160
|
+
let colors = args.colors
|
|
161
|
+
if (!colors || colors.length === 0) {
|
|
162
|
+
if (args.image) {
|
|
163
|
+
const source = await resolveSource(ctx, exec, args.image)
|
|
164
|
+
if (!source || !source.bytes) {
|
|
165
|
+
throw new Error('Image not found or unreadable: ' + args.image)
|
|
166
|
+
}
|
|
167
|
+
colors = extractSampleColorsFromBuffer(source.bytes)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return toLosslessJson(checkWcagContrast(colors, args.text_color || '#ffffff'))
|
|
171
|
+
},
|
|
172
|
+
}),
|
|
173
|
+
)
|
|
174
|
+
}, 'dsh-image-gen: tool check_image_contrast')
|
|
175
|
+
ctx.effect(() => {
|
|
176
|
+
ctx.tools.register(
|
|
177
|
+
defineTool({
|
|
178
|
+
name: 'optimize_vector_svg',
|
|
179
|
+
description: 'Clean, sanitize, and minify SVG content, normalize viewBox, and export React TSX component.',
|
|
180
|
+
parameters: {
|
|
181
|
+
svg_content: { type: 'string', description: 'Raw SVG markup string to optimize.' },
|
|
182
|
+
image: { type: 'string', description: 'Path or attachment ID of an SVG file if not passing raw string.' },
|
|
183
|
+
component_name: { type: 'string', description: 'React component name for TSX export (default: VectorIcon).' },
|
|
184
|
+
},
|
|
185
|
+
output: {
|
|
186
|
+
schema: {
|
|
187
|
+
type: 'object',
|
|
188
|
+
additionalProperties: true,
|
|
189
|
+
properties: {
|
|
190
|
+
svg: { type: 'string' },
|
|
191
|
+
viewBox: { type: 'string' },
|
|
192
|
+
originalSize: { type: 'number' },
|
|
193
|
+
optimizedSize: { type: 'number' },
|
|
194
|
+
savedBytes: { type: 'number' },
|
|
195
|
+
reductionPercent: { type: 'number' },
|
|
196
|
+
reactTsx: { type: 'string' },
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
async execute(args, exec) {
|
|
201
|
+
let svgStr = args.svg_content
|
|
202
|
+
if (!svgStr && args.image) {
|
|
203
|
+
const source = await resolveSource(ctx, exec, args.image)
|
|
204
|
+
if (source && source.bytes) {
|
|
205
|
+
svgStr = source.bytes.toString('utf8')
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (!svgStr) {
|
|
209
|
+
throw new Error('Either svg_content or image must be provided')
|
|
210
|
+
}
|
|
211
|
+
return optimizeSvgContent(svgStr, { componentName: args.component_name })
|
|
212
|
+
},
|
|
213
|
+
}),
|
|
214
|
+
)
|
|
215
|
+
}, 'dsh-image-gen: tool optimize_vector_svg')
|
|
216
|
+
ctx.effect(() => {
|
|
217
|
+
ctx.tools.register(
|
|
218
|
+
defineTool({
|
|
219
|
+
name: 'generate_pwa_icon_suite',
|
|
220
|
+
description: 'Generate standard PWA icons specification, HTML meta tags, and web app manifest.json.',
|
|
221
|
+
parameters: {
|
|
222
|
+
image: { type: 'string', description: 'Source icon image path or attachment ID.' },
|
|
223
|
+
name: { type: 'string', description: 'App name (e.g. My Application).' },
|
|
224
|
+
short_name: { type: 'string', description: 'App short name (e.g. MyApp).' },
|
|
225
|
+
theme_color: { type: 'string', description: 'Theme color hex (e.g. #0f172a).' },
|
|
226
|
+
background_color: { type: 'string', description: 'Background color hex (e.g. #ffffff).' },
|
|
227
|
+
icons_dir: { type: 'string', description: 'Directory for icons relative to webroot (default: icons).' },
|
|
228
|
+
},
|
|
229
|
+
output: {
|
|
230
|
+
schema: {
|
|
231
|
+
type: 'object',
|
|
232
|
+
additionalProperties: true,
|
|
233
|
+
properties: {
|
|
234
|
+
name: { type: 'string' },
|
|
235
|
+
shortName: { type: 'string' },
|
|
236
|
+
themeColor: { type: 'string' },
|
|
237
|
+
backgroundColor: { type: 'string' },
|
|
238
|
+
icons: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
|
239
|
+
manifestJson: { type: 'string' },
|
|
240
|
+
htmlHeadSnippet: { type: 'string' },
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
async execute(args, exec) {
|
|
245
|
+
const res = await generatePwaIconSuite({
|
|
246
|
+
name: args.name || 'App',
|
|
247
|
+
shortName: args.short_name || args.name || 'App',
|
|
248
|
+
themeColor: args.theme_color || '#0f172a',
|
|
249
|
+
backgroundColor: args.background_color || '#ffffff',
|
|
250
|
+
iconsDir: args.icons_dir || 'icons',
|
|
251
|
+
sourcePath: args.image || null,
|
|
252
|
+
})
|
|
253
|
+
return toLosslessJson(res)
|
|
254
|
+
},
|
|
255
|
+
}),
|
|
256
|
+
)
|
|
257
|
+
}, 'dsh-image-gen: tool generate_pwa_icon_suite')
|
|
258
|
+
}
|