@goodandready/dsh-image-gen 0.10.16 → 0.10.18
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 +15 -0
- package/README.ru.md +15 -0
- package/lib/generation-cache.js +38 -2
- package/lib/index.js +12 -1360
- package/lib/providers.js +29 -18
- 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 +601 -0
- package/lib/tools/inspect.js +117 -0
- package/lib/tools/processing.js +324 -0
- package/package.json +2 -2
package/lib/index.js
CHANGED
|
@@ -13,58 +13,24 @@
|
|
|
13
13
|
// falling back to the process environment.
|
|
14
14
|
|
|
15
15
|
import z from '@deepseek-ai/schemastery'
|
|
16
|
-
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
17
16
|
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
18
17
|
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
19
|
-
import { existsSync, unlinkSync
|
|
18
|
+
import { existsSync, unlinkSync } from 'node:fs'
|
|
20
19
|
import os from 'node:os'
|
|
21
20
|
import path from 'node:path'
|
|
22
21
|
import {
|
|
23
|
-
computeGenerationHash,
|
|
24
|
-
clampProviderCount,
|
|
25
|
-
buildEndpointUrl,
|
|
26
|
-
createAbortError,
|
|
27
22
|
IMAGE_SIZES,
|
|
28
23
|
OUTPUT_FORMATS,
|
|
29
24
|
PROVIDER_KEYS,
|
|
30
25
|
buildSidecar,
|
|
31
|
-
makeProviders,
|
|
32
|
-
ASPECT_RATIOS,
|
|
33
|
-
pixelDiff,
|
|
34
|
-
normalizeCount,
|
|
35
|
-
tryGenerate,
|
|
36
|
-
resolveApiKeyCandidates,
|
|
37
|
-
fallbackOrder,
|
|
38
26
|
normalizeMediaType,
|
|
39
|
-
|
|
40
|
-
removeBackgroundFal,
|
|
41
|
-
upscaleImageFal,
|
|
42
|
-
traceToSvg,
|
|
43
|
-
estimateCost,
|
|
44
|
-
estimateSharpnessAndVariance,
|
|
45
|
-
STYLE_PRESETS,
|
|
46
|
-
applyStylePreset,
|
|
47
|
-
resolveStylePreset,
|
|
48
|
-
blendImagesFal,
|
|
27
|
+
resolveApiKeyCandidates,
|
|
49
28
|
toLosslessJson,
|
|
50
29
|
saveAttachmentSafe,
|
|
51
30
|
} from './providers.js'
|
|
52
31
|
|
|
53
|
-
import { resolveConversationImage, analyzeImageWithVision
|
|
54
|
-
import {
|
|
55
|
-
import { executeWithQualityGate } from './quality-gate.js'
|
|
56
|
-
import { calculateGenerationCost, recordSpend, assertBudgetAvailable } from './cost-meter.js'
|
|
57
|
-
import { trackAndAssertLoopGuard, resetLoopGuard } from './loop-guard.js'
|
|
58
|
-
import { maskApiKey, sanitizeErrorAndLogs } from './security.js'
|
|
59
|
-
import { getCachedGeneration, setCachedGeneration } from './generation-cache.js'
|
|
60
|
-
import {
|
|
61
|
-
extractDesignTokens,
|
|
62
|
-
generateCssGradient,
|
|
63
|
-
checkWcagContrast,
|
|
64
|
-
optimizeSvgContent,
|
|
65
|
-
generatePwaIconSuite,
|
|
66
|
-
extractSampleColorsFromBuffer,
|
|
67
|
-
} from './frontend-assets.js'
|
|
32
|
+
import { buildDualOutputMarkdown, resolveConversationImage, analyzeImageWithVision } from './resolve-image.js'
|
|
33
|
+
import { registerAllTools } from './register-tools.js'
|
|
68
34
|
|
|
69
35
|
|
|
70
36
|
export { IMAGE_SIZES, OUTPUT_FORMATS, PROVIDER_KEYS, buildSidecar, normalizeMediaType, resolveConversationImage, analyzeImageWithVision }
|
|
@@ -675,1328 +641,14 @@ export function apply(ctx, config) {
|
|
|
675
641
|
},
|
|
676
642
|
}), 'dsh-image-gen: history route')
|
|
677
643
|
|
|
678
|
-
//
|
|
679
|
-
ctx
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
+ 'Depending on how the deployment is configured the result carries either the image itself or a link to it; '
|
|
687
|
-
+ 'when you only get a link, answer from the prompt and the link rather than claiming to see the picture. '
|
|
688
|
-
+ 'Use for any text-to-image request. Pass count (1-4) to generate several variations in one call; cost scales with count (default 1).',
|
|
689
|
-
parameters: {
|
|
690
|
-
prompt: {
|
|
691
|
-
type: 'string',
|
|
692
|
-
required: true,
|
|
693
|
-
description: 'Detailed description of the image to generate (subject, style, lighting, composition, colors).',
|
|
694
|
-
},
|
|
695
|
-
image_size: {
|
|
696
|
-
type: 'string',
|
|
697
|
-
description: `One of: ${IMAGE_SIZES.join(', ')}. Default: ${config.defaultSize}.`,
|
|
698
|
-
},
|
|
699
|
-
aspect_ratio: {
|
|
700
|
-
type: 'string',
|
|
701
|
-
enum: ['1:1', '16:9', '9:16', '4:3', '3:4', '3:2', '2:3'],
|
|
702
|
-
description: 'Optional aspect ratio; overrides image_size when both are given. Sent as width/height to providers that accept pixels.',
|
|
703
|
-
},
|
|
704
|
-
seed: {
|
|
705
|
-
type: 'integer',
|
|
706
|
-
description: 'Optional seed for reproducible output.',
|
|
707
|
-
},
|
|
708
|
-
output_format: {
|
|
709
|
-
type: 'string',
|
|
710
|
-
enum: OUTPUT_FORMATS,
|
|
711
|
-
description: `Output format. Default: ${config.defaultFormat}.`,
|
|
712
|
-
},
|
|
713
|
-
output_name: {
|
|
714
|
-
type: 'string',
|
|
715
|
-
description: 'Optional file name stem for the saved image (defaults to a slug of the prompt).',
|
|
716
|
-
},
|
|
717
|
-
output_dir: {
|
|
718
|
-
type: 'string',
|
|
719
|
-
description: 'Optional custom directory to write images into (relative to session cwd or absolute). Defaults to outputDir from settings.',
|
|
720
|
-
},
|
|
721
|
-
count: {
|
|
722
|
-
type: 'integer',
|
|
723
|
-
description: 'Number of variations to generate in one call, 1-4 (default 1). Cost scales with count.',
|
|
724
|
-
},
|
|
725
|
-
prompts: {
|
|
726
|
-
type: 'array',
|
|
727
|
-
items: {
|
|
728
|
-
oneOf: [
|
|
729
|
-
{ type: 'string' },
|
|
730
|
-
{ type: 'object', additionalProperties: false, properties: { text: { type: 'string' } } },
|
|
731
|
-
],
|
|
732
|
-
},
|
|
733
|
-
description: 'Optional list of prompts (strings or {text}); one image is generated per prompt. When set, overrides count and the single prompt.',
|
|
734
|
-
},
|
|
735
|
-
negative_prompt: {
|
|
736
|
-
type: 'string',
|
|
737
|
-
description: 'Optional text describing what NOT to draw; sent to providers that support it (ignored otherwise).',
|
|
738
|
-
},
|
|
739
|
-
guidance_scale: {
|
|
740
|
-
type: 'number',
|
|
741
|
-
description: 'Optional prompt adherence (e.g. 1-20); sent to providers that support it (ignored otherwise).',
|
|
742
|
-
},
|
|
743
|
-
quality: {
|
|
744
|
-
type: 'string',
|
|
745
|
-
enum: ['auto', 'low', 'medium', 'high'],
|
|
746
|
-
description: 'Optional quality for providers that support it (seedream, gemini). Ignored otherwise.',
|
|
747
|
-
},
|
|
748
|
-
style: {
|
|
749
|
-
type: 'string',
|
|
750
|
-
description: 'Optional style preset for providers that support it (seedream, gemini). Ignored otherwise.',
|
|
751
|
-
},
|
|
752
|
-
source_image: {
|
|
753
|
-
type: 'string',
|
|
754
|
-
description: 'Optional path to an image file or an attachment id (sha256:...) to edit instead of drawing from scratch. Only providers that support image editing accept it; others refuse with a clear reason.',
|
|
755
|
-
},
|
|
756
|
-
mask: {
|
|
757
|
-
type: 'string',
|
|
758
|
-
description: 'Optional path or attachment id of a mask image (same size as source) to restrict the edit to a region. Only used when source_image is set and the provider supports masks.',
|
|
759
|
-
},
|
|
760
|
-
strength: {
|
|
761
|
-
type: 'number',
|
|
762
|
-
description: 'Optional edit strength (0-1) for providers that accept it; higher keeps more of the original.',
|
|
763
|
-
},
|
|
764
|
-
quality_gate: {
|
|
765
|
-
type: 'boolean',
|
|
766
|
-
description: 'Optional override to enable/disable automated quality gate and defect re-rolling for this call.',
|
|
767
|
-
},
|
|
768
|
-
force: {
|
|
769
|
-
type: 'boolean',
|
|
770
|
-
description: 'Optional flag to bypass content-addressed cache and force a new API generation.',
|
|
771
|
-
},
|
|
772
|
-
},
|
|
773
|
-
output: {
|
|
774
|
-
schema: {
|
|
775
|
-
type: 'object',
|
|
776
|
-
additionalProperties: true,
|
|
777
|
-
properties: {
|
|
778
|
-
path: { type: 'string' },
|
|
779
|
-
url: { type: 'string' },
|
|
780
|
-
width: { type: 'integer' },
|
|
781
|
-
height: { type: 'integer' },
|
|
782
|
-
seed: { type: 'integer' },
|
|
783
|
-
prompt: { type: 'string' },
|
|
784
|
-
format: { type: 'string' },
|
|
785
|
-
attachment: {
|
|
786
|
-
type: 'object',
|
|
787
|
-
additionalProperties: false,
|
|
788
|
-
properties: {
|
|
789
|
-
attachmentId: { type: 'string' },
|
|
790
|
-
mediaType: { type: 'string' },
|
|
791
|
-
bytes: { type: 'integer' },
|
|
792
|
-
width: { type: 'integer' },
|
|
793
|
-
height: { type: 'integer' },
|
|
794
|
-
name: { type: 'string' },
|
|
795
|
-
},
|
|
796
|
-
},
|
|
797
|
-
images: {
|
|
798
|
-
type: 'array',
|
|
799
|
-
items: {
|
|
800
|
-
type: 'object',
|
|
801
|
-
additionalProperties: false,
|
|
802
|
-
properties: {
|
|
803
|
-
path: { type: 'string' },
|
|
804
|
-
url: { type: 'string' },
|
|
805
|
-
width: { type: 'integer' },
|
|
806
|
-
height: { type: 'integer' },
|
|
807
|
-
seed: { type: 'integer' },
|
|
808
|
-
prompt: { type: 'string' },
|
|
809
|
-
format: { type: 'string' },
|
|
810
|
-
},
|
|
811
|
-
},
|
|
812
|
-
},
|
|
813
|
-
},
|
|
814
|
-
},
|
|
815
|
-
render(args, value) {
|
|
816
|
-
const list = value.images || [value]
|
|
817
|
-
const lines = list.map((img) => `Generated image (${img.width}×${img.height}, ${img.format}, seed ${img.seed}) via ${value.provider || 'image-gen'}/${value.model || 'default'}: ${img.path}`)
|
|
818
|
-
const summary = lines.join('\n')
|
|
819
|
-
if (live().deliverAs !== 'image') {
|
|
820
|
-
const urls = list.map((img) => img.url).filter(Boolean)
|
|
821
|
-
return [{ type: 'text', text: urls.length ? `${summary}\n${urls.join('\n')}` : summary }]
|
|
822
|
-
}
|
|
823
|
-
const blocks = [{ type: 'text', text: summary }]
|
|
824
|
-
for (const img of list) if (img.attachment) blocks.push({ type: 'image', attachment: img.attachment })
|
|
825
|
-
return blocks
|
|
826
|
-
},
|
|
827
|
-
},
|
|
828
|
-
isConcurrencySafe: () => false,
|
|
829
|
-
timeoutMs: config.timeoutMs + 30000,
|
|
830
|
-
async execute(args, exec) {
|
|
831
|
-
const cfg = live()
|
|
832
|
-
|
|
833
|
-
// #168: Fail-fast loop guard protecting against runaway agent loops
|
|
834
|
-
const sessionId = exec.agent?.session?.id || exec.agent?.session?.header?.id || 'session'
|
|
835
|
-
if (cfg.loopGuardLimit > 0) {
|
|
836
|
-
trackAndAssertLoopGuard(sessionId, {
|
|
837
|
-
limit: cfg.loopGuardLimit,
|
|
838
|
-
prompt: args.prompt,
|
|
839
|
-
})
|
|
840
|
-
}
|
|
841
|
-
if (cfg.enabled === false) {
|
|
842
|
-
throw new Error('Image generation is disabled in settings (dsh-image-gen.enabled is false); enable it in Settings → Image generation.')
|
|
843
|
-
}
|
|
844
|
-
const provider = PROVIDER_KEYS.includes(cfg.provider) ? cfg.provider : 'fal'
|
|
845
|
-
const deliverAs = cfg.deliverAs
|
|
846
|
-
const size = args.aspect_ratio ? 'custom' : (args.image_size ?? cfg.defaultSize)
|
|
847
|
-
if (!args.aspect_ratio && !IMAGE_SIZES.includes(size)) {
|
|
848
|
-
throw new Error(`Invalid image_size "${size}". One of: ${IMAGE_SIZES.join(', ')}`)
|
|
849
|
-
}
|
|
850
|
-
const aspectPixels = args.aspect_ratio ? ASPECT_RATIOS[args.aspect_ratio] : undefined
|
|
851
|
-
const format = args.output_format ?? cfg.defaultFormat
|
|
852
|
-
if (!OUTPUT_FORMATS.includes(format)) {
|
|
853
|
-
throw new Error(`Invalid output_format "${format}". One of: ${OUTPUT_FORMATS.join(', ')}`)
|
|
854
|
-
}
|
|
855
|
-
// Служба подписок необязательна: без неё эти два провайдера просто
|
|
856
|
-
// отказываются, а остальные работают как работали.
|
|
857
|
-
let subscriptionImages
|
|
858
|
-
try { subscriptionImages = ctx.get && ctx.get('subscriptionImages') } catch (noService) { subscriptionImages = undefined }
|
|
859
|
-
|
|
860
|
-
const source = args.source_image ? await resolveSource(ctx, exec, args.source_image) : undefined
|
|
861
|
-
const mask = args.mask ? await resolveSource(ctx, exec, args.mask) : undefined
|
|
862
|
-
const enhanced = await enhancePrompt(ctx, cfg, args.prompt, exec.signal, provider)
|
|
863
|
-
const effectiveStyle = args.style || cfg.stylePreset
|
|
864
|
-
const styleInfo = resolveStylePreset(effectiveStyle, args.negative_prompt, args.guidance_scale)
|
|
865
|
-
const effectivePrompt = styleInfo.promptSuffix ? `${enhanced.prompt}, ${styleInfo.promptSuffix}` : enhanced.prompt
|
|
866
|
-
|
|
867
|
-
// #165: Negative prompt sanitizer for diffusion models
|
|
868
|
-
let effectiveNegative = styleInfo.negativePrompt
|
|
869
|
-
if (supportsNegativePrompt(provider, cfg.model || cfg.customModel)) {
|
|
870
|
-
effectiveNegative = sanitizeNegativePrompt({
|
|
871
|
-
positivePrompt: effectivePrompt,
|
|
872
|
-
userNegative: effectiveNegative,
|
|
873
|
-
autoInjectDefects: true,
|
|
874
|
-
})
|
|
875
|
-
}
|
|
876
|
-
const effectiveGuidance = styleInfo.guidanceScale
|
|
877
|
-
|
|
878
|
-
// #167: Assert daily budget availability before calling API
|
|
879
|
-
const totalCount = Array.isArray(args.prompts) && args.prompts.length ? args.prompts.length : normalizeCount(args.count)
|
|
880
|
-
const estimatedCost = calculateGenerationCost({
|
|
881
|
-
provider,
|
|
882
|
-
model: cfg.model || cfg.customModel,
|
|
883
|
-
size,
|
|
884
|
-
count: totalCount,
|
|
885
|
-
})
|
|
886
|
-
assertBudgetAvailable(estimatedCost, cfg.dailyBudgetUsd)
|
|
887
|
-
const providers = makeProviders(
|
|
888
|
-
{ fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg, subscriptionImages },
|
|
889
|
-
{ prompt: effectivePrompt, size, format, seed: args.seed, signal: exec.signal, negativePrompt: effectiveNegative, guidanceScale: effectiveGuidance, source, mask, strength: args.strength, quality: args.quality, style: args.style, aspectPixels, aspectRatio: args.aspect_ratio },
|
|
890
|
-
)
|
|
891
|
-
// Подписочные провайдеры (codex/grok) отдают {ok:false, reason} вместо исключения:
|
|
892
|
-
// отказ должен дойти до модели текстом. Без проверки execute шёл дальше с пустыми
|
|
893
|
-
// байтами, и пользователь получал битую карточку вместо внятного отказа.
|
|
894
|
-
const guard = (generated) => { if (generated && generated.ok === false) throw new Error(generated.reason) }
|
|
895
|
-
const one = async (jobSeed, providerKey, promptArg = effectivePrompt) => {
|
|
896
|
-
const gen = await providers[providerKey](jobSeed, promptArg)
|
|
897
|
-
guard(gen)
|
|
898
|
-
const mediaType = gen.mediaType || 'image/png'
|
|
899
|
-
let bytes = gen.bytes
|
|
900
|
-
if (mediaType === 'image/png') {
|
|
901
|
-
bytes = embedPngMetadata(bytes, {
|
|
902
|
-
prompt: promptArg,
|
|
903
|
-
seed: gen.seed,
|
|
904
|
-
provider,
|
|
905
|
-
model: cfg.model || cfg.customModel,
|
|
906
|
-
negative_prompt: effectiveNegative,
|
|
907
|
-
guidance_scale: effectiveGuidance,
|
|
908
|
-
width: gen.width,
|
|
909
|
-
height: gen.height,
|
|
910
|
-
})
|
|
911
|
-
}
|
|
912
|
-
const extension = mediaType === 'image/jpeg' ? 'jpg' : mediaType === 'image/webp' ? 'webp' : 'png'
|
|
913
|
-
const stem = `${slugify(args.output_name || promptArg)}-${Date.now().toString(36)}-${jobSeed}`
|
|
914
|
-
const name = `${stem}.${extension}`
|
|
915
|
-
|
|
916
|
-
const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes, mediaType, name })
|
|
917
|
-
|
|
918
|
-
const sessionCwd = exec.agent?.session?.header?.cwd
|
|
919
|
-
const targetDir = (args.output_dir && String(args.output_dir).trim()) || cfg.outputDir || 'generated/images'
|
|
920
|
-
const outDir = path.resolve(sessionCwd || process.cwd(), targetDir)
|
|
921
|
-
await mkdir(outDir, { recursive: true })
|
|
922
|
-
const filePath = path.join(outDir, name)
|
|
923
|
-
await writeFile(filePath, bytes)
|
|
924
|
-
|
|
925
|
-
// Sidecar: рядом с картинкой — метаданные генерации. Каталог становится
|
|
926
|
-
// самодокументируемым (галерея/повтор/диагностика читают их без БД).
|
|
927
|
-
await writeFile(
|
|
928
|
-
path.join(outDir, `${stem}.json`),
|
|
929
|
-
JSON.stringify(buildSidecar({
|
|
930
|
-
prompt: promptArg,
|
|
931
|
-
size,
|
|
932
|
-
format,
|
|
933
|
-
seed: gen.seed,
|
|
934
|
-
provider,
|
|
935
|
-
deliverAs,
|
|
936
|
-
width: gen.width || attachment.width,
|
|
937
|
-
height: gen.height || attachment.height,
|
|
938
|
-
mediaType,
|
|
939
|
-
attachmentId: attachment.attachmentId,
|
|
940
|
-
url: deliverAs === 'image' && gen.sourceUrl ? gen.sourceUrl : localUrl,
|
|
941
|
-
cost: gen.cost,
|
|
942
|
-
}), null, 2),
|
|
943
|
-
)
|
|
944
|
-
|
|
945
|
-
const cacheHash = computeGenerationHash({
|
|
946
|
-
provider,
|
|
947
|
-
model: cfg.model || cfg.customModel,
|
|
948
|
-
prompt: promptArg,
|
|
949
|
-
seed: gen.seed,
|
|
950
|
-
size: args.image_size || cfg.defaultSize,
|
|
951
|
-
style: args.style || cfg.stylePreset,
|
|
952
|
-
})
|
|
953
|
-
|
|
954
|
-
// #170: Store in content-addressed disk cache
|
|
955
|
-
if (cfg.diskCache !== false) {
|
|
956
|
-
setCachedGeneration(cacheHash, {
|
|
957
|
-
bytes,
|
|
958
|
-
mediaType,
|
|
959
|
-
width: gen.width || attachment.width,
|
|
960
|
-
height: gen.height || attachment.height,
|
|
961
|
-
seed: gen.seed,
|
|
962
|
-
meta: { prompt: promptArg, provider, model: cfg.model || cfg.customModel, cost: gen.cost },
|
|
963
|
-
})
|
|
964
|
-
}
|
|
965
|
-
const entry = {
|
|
966
|
-
path: filePath,
|
|
967
|
-
prompt: promptArg,
|
|
968
|
-
provider,
|
|
969
|
-
model: cfg.model,
|
|
970
|
-
size,
|
|
971
|
-
format,
|
|
972
|
-
seed: gen.seed,
|
|
973
|
-
cacheHash,
|
|
974
|
-
thumbnailUrl: localUrl,
|
|
975
|
-
width: gen.width || attachment.width,
|
|
976
|
-
height: gen.height || attachment.height,
|
|
977
|
-
mediaType,
|
|
978
|
-
bytes: attachment.bytes,
|
|
979
|
-
createdAt: new Date().toISOString(),
|
|
980
|
-
attachmentId: attachment.attachmentId,
|
|
981
|
-
}
|
|
982
|
-
let current = await readHistory()
|
|
983
|
-
current = await pruneHistory(current, cfg.pruneDays)
|
|
984
|
-
current.unshift(entry)
|
|
985
|
-
if (current.length > (cfg.historyLimit || 50)) current.length = cfg.historyLimit || 50
|
|
986
|
-
await writeHistory(current)
|
|
987
|
-
|
|
988
|
-
// #167: Record spend in cost meter
|
|
989
|
-
recordSpend(gen.cost || (estimatedCost / totalCount), {
|
|
990
|
-
ctx,
|
|
991
|
-
meta: { provider, model: cfg.model || cfg.customModel, prompt: promptArg, seed: gen.seed },
|
|
992
|
-
})
|
|
993
|
-
|
|
994
|
-
return {
|
|
995
|
-
path: filePath,
|
|
996
|
-
url: deliverAs === 'image' && gen.sourceUrl ? gen.sourceUrl : localUrl,
|
|
997
|
-
width: gen.width || attachment.width,
|
|
998
|
-
height: gen.height || attachment.height,
|
|
999
|
-
seed: gen.seed,
|
|
1000
|
-
prompt: promptArg,
|
|
1001
|
-
...(enhanced.enhanced ? { originalPrompt: args.prompt } : {}),
|
|
1002
|
-
cost: gen.cost,
|
|
1003
|
-
format: mediaType.replace('image/', ''),
|
|
1004
|
-
attachment: {
|
|
1005
|
-
attachmentId: attachment.attachmentId,
|
|
1006
|
-
mediaType: attachment.mediaType,
|
|
1007
|
-
bytes: attachment.bytes,
|
|
1008
|
-
width: attachment.width,
|
|
1009
|
-
height: attachment.height,
|
|
1010
|
-
name: attachment.name,
|
|
1011
|
-
},
|
|
1012
|
-
}
|
|
1013
|
-
}
|
|
1014
|
-
|
|
1015
|
-
const seedBase = args.seed ?? Math.floor(Math.random() * 100000)
|
|
1016
|
-
// Fallback-цепочка: пробуем текущий провайдер, при отказе — следующий
|
|
1017
|
-
// по порядку (fal → custom → codex → grok), собирая причины отказов.
|
|
1018
|
-
const order = fallbackOrder(provider)
|
|
1019
|
-
const qgEnabled = args.quality_gate ?? cfg.qualityGate
|
|
1020
|
-
const generators = Object.fromEntries(PROVIDER_KEYS.map((k) => [
|
|
1021
|
-
k,
|
|
1022
|
-
async (s, p) => {
|
|
1023
|
-
const { generated, qualityReport } = await executeWithQualityGate(
|
|
1024
|
-
(seedVal) => one(seedVal, k, p),
|
|
1025
|
-
{
|
|
1026
|
-
initialSeed: s,
|
|
1027
|
-
enabled: qgEnabled,
|
|
1028
|
-
ctx,
|
|
1029
|
-
prompt: p,
|
|
1030
|
-
}
|
|
1031
|
-
)
|
|
1032
|
-
return { ...generated, qualityReport }
|
|
1033
|
-
}
|
|
1034
|
-
]))
|
|
1035
|
-
const images = []
|
|
1036
|
-
const historyEntries = (cfg.cacheBySeed || cfg.cacheByPrompt) ? await readHistory() : []
|
|
1037
|
-
const checkCache = async (seedVal, promptVal) => {
|
|
1038
|
-
const h = computeGenerationHash({
|
|
1039
|
-
provider,
|
|
1040
|
-
model: cfg.model || cfg.customModel,
|
|
1041
|
-
prompt: promptVal,
|
|
1042
|
-
seed: seedVal,
|
|
1043
|
-
size: args.image_size || cfg.defaultSize,
|
|
1044
|
-
style: args.style || cfg.stylePreset,
|
|
1045
|
-
})
|
|
1046
|
-
|
|
1047
|
-
// #170: Content-addressed disk cache check (<50ms return)
|
|
1048
|
-
if (cfg.diskCache !== false && !args.force) {
|
|
1049
|
-
const diskHit = getCachedGeneration(h, { force: args.force })
|
|
1050
|
-
if (diskHit) {
|
|
1051
|
-
const extension = diskHit.mediaType === 'image/jpeg' ? 'jpg' : diskHit.mediaType === 'image/webp' ? 'webp' : 'png'
|
|
1052
|
-
const stem = `${slugify(args.output_name || promptVal)}-${Date.now().toString(36)}-${seedVal}`
|
|
1053
|
-
const name = `${stem}.${extension}`
|
|
1054
|
-
const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes: diskHit.bytes, mediaType: diskHit.mediaType, name })
|
|
1055
|
-
const sessionCwd = exec.agent?.session?.header?.cwd
|
|
1056
|
-
const targetDir = (args.output_dir && String(args.output_dir).trim()) || cfg.outputDir || 'generated/images'
|
|
1057
|
-
const outDir = path.resolve(sessionCwd || process.cwd(), targetDir)
|
|
1058
|
-
await mkdir(outDir, { recursive: true })
|
|
1059
|
-
const filePath = path.join(outDir, name)
|
|
1060
|
-
await writeFile(filePath, diskHit.bytes)
|
|
1061
|
-
|
|
1062
|
-
return {
|
|
1063
|
-
path: filePath,
|
|
1064
|
-
url: localUrl,
|
|
1065
|
-
width: diskHit.width || attachment.width,
|
|
1066
|
-
height: diskHit.height || attachment.height,
|
|
1067
|
-
seed: seedVal,
|
|
1068
|
-
prompt: promptVal,
|
|
1069
|
-
cost: 0,
|
|
1070
|
-
fromCache: true,
|
|
1071
|
-
format: diskHit.mediaType.replace('image/', ''),
|
|
1072
|
-
attachment: {
|
|
1073
|
-
attachmentId: attachment.attachmentId,
|
|
1074
|
-
mediaType: attachment.mediaType,
|
|
1075
|
-
bytes: attachment.bytes,
|
|
1076
|
-
width: attachment.width,
|
|
1077
|
-
height: attachment.height,
|
|
1078
|
-
name: attachment.name,
|
|
1079
|
-
},
|
|
1080
|
-
}
|
|
1081
|
-
}
|
|
1082
|
-
}
|
|
1083
|
-
|
|
1084
|
-
if (!cfg.cacheBySeed && !cfg.cacheByPrompt) return undefined
|
|
1085
|
-
const hPrompt = computeGenerationHash({
|
|
1086
|
-
provider,
|
|
1087
|
-
model: cfg.model,
|
|
1088
|
-
prompt: promptVal,
|
|
1089
|
-
seed: seedVal,
|
|
1090
|
-
size: args.image_size || cfg.defaultSize,
|
|
1091
|
-
style: args.style || cfg.stylePreset,
|
|
1092
|
-
})
|
|
1093
|
-
const found = findCachedGeneration(historyEntries, hPrompt)
|
|
1094
|
-
if (found) return await cachedResult(found)
|
|
1095
|
-
if (cfg.cacheBySeed) {
|
|
1096
|
-
const bySeed = await findCached(historyEntries, seedVal, promptVal)
|
|
1097
|
-
if (bySeed) return await cachedResult(bySeed)
|
|
1098
|
-
}
|
|
1099
|
-
if (cfg.cacheByPrompt) {
|
|
1100
|
-
const byPrompt = await findCachedByPrompt(historyEntries, promptVal)
|
|
1101
|
-
if (byPrompt) return await cachedResult(byPrompt)
|
|
1102
|
-
}
|
|
1103
|
-
return undefined
|
|
1104
|
-
}
|
|
1105
|
-
|
|
1106
|
-
const batchPrompts = Array.isArray(args.prompts) && args.prompts.length ? args.prompts : null
|
|
1107
|
-
if (batchPrompts) {
|
|
1108
|
-
for (let i = 0; i < batchPrompts.length; i += 1) {
|
|
1109
|
-
const item = batchPrompts[i]
|
|
1110
|
-
const text = typeof item === 'string' ? item : (item && item.text) || ''
|
|
1111
|
-
const cached = await checkCache(seedBase + i, text)
|
|
1112
|
-
images.push(cached || await tryGenerate(generators, order, seedBase + i, text))
|
|
1113
|
-
}
|
|
1114
|
-
} else {
|
|
1115
|
-
const count = normalizeCount(args.count)
|
|
1116
|
-
for (let i = 0; i < count; i += 1) {
|
|
1117
|
-
const cached = await checkCache(seedBase + i, effectivePrompt)
|
|
1118
|
-
images.push(cached || await tryGenerate(generators, order, seedBase + i))
|
|
1119
|
-
}
|
|
1120
|
-
}
|
|
1121
|
-
const first = images[0]
|
|
1122
|
-
const dualOutputSummary = buildDualOutputMarkdown({
|
|
1123
|
-
action: 'generated',
|
|
1124
|
-
filePath: first.path,
|
|
1125
|
-
width: first.width,
|
|
1126
|
-
height: first.height,
|
|
1127
|
-
mediaType: first.mediaType || ('image/' + (first.format || 'png')),
|
|
1128
|
-
seed: first.seed,
|
|
1129
|
-
provider,
|
|
1130
|
-
model: cfg.model || cfg.customModel || 'default',
|
|
1131
|
-
cost: first.cost,
|
|
1132
|
-
attachmentId: first.attachment?.attachmentId || 'N/A',
|
|
1133
|
-
})
|
|
1134
|
-
return toLosslessJson({ summary: dualOutputSummary, channel: provider, provider, model: cfg.model || cfg.customModel, ...first, images })
|
|
1135
|
-
},
|
|
1136
|
-
}),
|
|
1137
|
-
)
|
|
1138
|
-
|
|
1139
|
-
// Сравнение двух изображений: доля различающихся пикселей.
|
|
1140
|
-
ctx.tools.register(
|
|
1141
|
-
defineTool({
|
|
1142
|
-
name: 'compare_images',
|
|
1143
|
-
description: 'Compare two images (paths or attachment ids) and report the fraction of differing pixels. Use to check how much an edit changed the original.',
|
|
1144
|
-
parameters: {
|
|
1145
|
-
image_a: { type: 'string', required: true, description: 'Path or attachment id of the first image.' },
|
|
1146
|
-
image_b: { type: 'string', required: true, description: 'Path or attachment id of the second image.' },
|
|
1147
|
-
},
|
|
1148
|
-
output: {
|
|
1149
|
-
schema: {
|
|
1150
|
-
type: 'object',
|
|
1151
|
-
additionalProperties: false,
|
|
1152
|
-
properties: {
|
|
1153
|
-
diffRatio: { type: 'number' },
|
|
1154
|
-
error: { type: 'string' },
|
|
1155
|
-
},
|
|
1156
|
-
},
|
|
1157
|
-
},
|
|
1158
|
-
async execute(args, exec) {
|
|
1159
|
-
const a = await resolveSource(ctx, exec, args.image_a)
|
|
1160
|
-
const b = await resolveSource(ctx, exec, args.image_b)
|
|
1161
|
-
return pixelDiff(a && a.bytes, b && b.bytes)
|
|
1162
|
-
},
|
|
1163
|
-
}),
|
|
1164
|
-
)
|
|
1165
|
-
|
|
1166
|
-
// Удаление фона (Rembg / BiRefNet).
|
|
1167
|
-
ctx.tools.register(
|
|
1168
|
-
defineTool({
|
|
1169
|
-
name: 'remove_background',
|
|
1170
|
-
description: 'Remove the background of an image (path or attachment id) and return a transparent PNG.',
|
|
1171
|
-
parameters: {
|
|
1172
|
-
image: { type: 'string', required: true, description: 'Path or attachment id of the source image.' },
|
|
1173
|
-
model: { type: 'string', description: 'FAL model for background removal (default: fal-ai/birefnet).' },
|
|
1174
|
-
output_name: { type: 'string', description: 'Custom output file name without extension.' },
|
|
1175
|
-
},
|
|
1176
|
-
output: {
|
|
1177
|
-
schema: {
|
|
1178
|
-
type: 'object',
|
|
1179
|
-
additionalProperties: true,
|
|
1180
|
-
properties: {
|
|
1181
|
-
path: { type: 'string' },
|
|
1182
|
-
url: { type: 'string' },
|
|
1183
|
-
width: { type: 'number' },
|
|
1184
|
-
height: { type: 'number' },
|
|
1185
|
-
format: { type: 'string' },
|
|
1186
|
-
},
|
|
1187
|
-
},
|
|
1188
|
-
},
|
|
1189
|
-
async execute(args, exec) {
|
|
1190
|
-
try {
|
|
1191
|
-
const source = await resolveSource(ctx, exec, args.image)
|
|
1192
|
-
if (!source || !source.bytes) throw new Error('Source image not found: ' + args.image)
|
|
1193
|
-
const cfg = live()
|
|
1194
|
-
const res = await removeBackgroundFal(
|
|
1195
|
-
{ fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg },
|
|
1196
|
-
{ imageBytes: source.bytes, mediaType: source.mediaType, model: args.model, signal: exec.signal }
|
|
1197
|
-
)
|
|
1198
|
-
const stem = `${slugify(args.output_name || 'nobg')}-${Date.now().toString(36)}`
|
|
1199
|
-
const name = `${stem}.png`
|
|
1200
|
-
const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes: res.bytes, mediaType: 'image/png', name })
|
|
1201
|
-
const sessionCwd = exec.agent?.session?.header?.cwd
|
|
1202
|
-
const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
|
|
1203
|
-
await mkdir(outDir, { recursive: true })
|
|
1204
|
-
const filePath = path.join(outDir, name)
|
|
1205
|
-
await writeFile(filePath, res.bytes)
|
|
1206
|
-
const summary = buildDualOutputMarkdown({
|
|
1207
|
-
action: 'background removed',
|
|
1208
|
-
filePath,
|
|
1209
|
-
width: res.width || attachment.width,
|
|
1210
|
-
height: res.height || attachment.height,
|
|
1211
|
-
mediaType: 'image/png',
|
|
1212
|
-
provider: 'fal',
|
|
1213
|
-
model: args.model || 'fal-ai/birefnet',
|
|
1214
|
-
attachmentId: attachment.attachmentId,
|
|
1215
|
-
})
|
|
1216
|
-
return toLosslessJson({
|
|
1217
|
-
summary,
|
|
1218
|
-
path: filePath,
|
|
1219
|
-
url: localUrl,
|
|
1220
|
-
width: res.width || attachment.width,
|
|
1221
|
-
height: res.height || attachment.height,
|
|
1222
|
-
format: 'png',
|
|
1223
|
-
attachment,
|
|
1224
|
-
})
|
|
1225
|
-
} catch (err) {
|
|
1226
|
-
throw new Error(sanitizeErrorAndLogs(err.message || err))
|
|
1227
|
-
}
|
|
1228
|
-
},
|
|
1229
|
-
}),
|
|
1230
|
-
)
|
|
1231
|
-
|
|
1232
|
-
// Увеличение разрешения и детализация (Upscaling).
|
|
1233
|
-
ctx.tools.register(
|
|
1234
|
-
defineTool({
|
|
1235
|
-
name: 'upscale_image',
|
|
1236
|
-
description: 'Upscale an image 2x or 4x with enhanced clarity and detail.',
|
|
1237
|
-
parameters: {
|
|
1238
|
-
image: { type: 'string', required: true, description: 'Path or attachment id of the source image.' },
|
|
1239
|
-
scale: { type: 'number', description: 'Upscale factor: 2 or 4 (default: 2).' },
|
|
1240
|
-
prompt: { type: 'string', description: 'Optional guiding prompt for detail reconstruction.' },
|
|
1241
|
-
creativity: { type: 'number', description: 'Creativity / hallucination level from 0 to 1.' },
|
|
1242
|
-
output_name: { type: 'string', description: 'Custom output file name.' },
|
|
1243
|
-
},
|
|
1244
|
-
output: {
|
|
1245
|
-
schema: {
|
|
1246
|
-
type: 'object',
|
|
1247
|
-
additionalProperties: true,
|
|
1248
|
-
properties: {
|
|
1249
|
-
path: { type: 'string' },
|
|
1250
|
-
url: { type: 'string' },
|
|
1251
|
-
scale: { type: 'number' },
|
|
1252
|
-
width: { type: 'number' },
|
|
1253
|
-
height: { type: 'number' },
|
|
1254
|
-
},
|
|
1255
|
-
},
|
|
1256
|
-
},
|
|
1257
|
-
async execute(args, exec) {
|
|
1258
|
-
try {
|
|
1259
|
-
const source = await resolveSource(ctx, exec, args.image)
|
|
1260
|
-
if (!source || !source.bytes) throw new Error('Source image not found: ' + args.image)
|
|
1261
|
-
const cfg = live()
|
|
1262
|
-
const res = await upscaleImageFal(
|
|
1263
|
-
{ fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg },
|
|
1264
|
-
{ imageBytes: source.bytes, mediaType: source.mediaType, scale: args.scale, prompt: args.prompt, creativity: args.creativity, signal: exec.signal }
|
|
1265
|
-
)
|
|
1266
|
-
const stem = `${slugify(args.output_name || 'upscaled')}-${Date.now().toString(36)}`
|
|
1267
|
-
const name = `${stem}.png`
|
|
1268
|
-
const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes: res.bytes, mediaType: 'image/png', name })
|
|
1269
|
-
const sessionCwd = exec.agent?.session?.header?.cwd
|
|
1270
|
-
const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
|
|
1271
|
-
await mkdir(outDir, { recursive: true })
|
|
1272
|
-
const filePath = path.join(outDir, name)
|
|
1273
|
-
await writeFile(filePath, res.bytes)
|
|
1274
|
-
const summary = buildDualOutputMarkdown({
|
|
1275
|
-
action: `upscaled (${args.scale || 2}x)`,
|
|
1276
|
-
filePath,
|
|
1277
|
-
width: res.width || attachment.width,
|
|
1278
|
-
height: res.height || attachment.height,
|
|
1279
|
-
mediaType: 'image/png',
|
|
1280
|
-
provider: 'fal',
|
|
1281
|
-
model: 'fal-ai/clarity-upscaler',
|
|
1282
|
-
attachmentId: attachment.attachmentId,
|
|
1283
|
-
})
|
|
1284
|
-
return toLosslessJson({
|
|
1285
|
-
summary,
|
|
1286
|
-
path: filePath,
|
|
1287
|
-
url: localUrl,
|
|
1288
|
-
scale: args.scale || 2,
|
|
1289
|
-
width: res.width || attachment.width,
|
|
1290
|
-
height: res.height || attachment.height,
|
|
1291
|
-
attachment,
|
|
1292
|
-
})
|
|
1293
|
-
} catch (err) {
|
|
1294
|
-
throw new Error(sanitizeErrorAndLogs(err.message || err))
|
|
1295
|
-
}
|
|
1296
|
-
},
|
|
1297
|
-
}),
|
|
1298
|
-
)
|
|
1299
|
-
|
|
1300
|
-
// Векторизация в SVG (Vectorize image).
|
|
1301
|
-
ctx.tools.register(
|
|
1302
|
-
defineTool({
|
|
1303
|
-
name: 'vectorize_image',
|
|
1304
|
-
description: 'Convert a raster image or icon to SVG vector format.',
|
|
1305
|
-
parameters: {
|
|
1306
|
-
image: { type: 'string', required: true, description: 'Path or attachment id of the source image.' },
|
|
1307
|
-
color_mode: { type: 'string', description: '"color" or "binary" (default: "color").' },
|
|
1308
|
-
output_name: { type: 'string', description: 'Custom output file name.' },
|
|
1309
|
-
},
|
|
1310
|
-
output: {
|
|
1311
|
-
schema: {
|
|
1312
|
-
type: 'object',
|
|
1313
|
-
additionalProperties: true,
|
|
1314
|
-
properties: {
|
|
1315
|
-
path: { type: 'string' },
|
|
1316
|
-
svg: { type: 'string' },
|
|
1317
|
-
format: { type: 'string' },
|
|
1318
|
-
},
|
|
1319
|
-
},
|
|
1320
|
-
},
|
|
1321
|
-
async execute(args, exec) {
|
|
1322
|
-
try {
|
|
1323
|
-
const source = await resolveSource(ctx, exec, args.image)
|
|
1324
|
-
if (!source || !source.bytes) throw new Error('Source image not found: ' + args.image)
|
|
1325
|
-
const cfg = live()
|
|
1326
|
-
const res = traceToSvg(source.bytes, { colorMode: args.color_mode })
|
|
1327
|
-
const stem = `${slugify(args.output_name || 'vector')}-${Date.now().toString(36)}`
|
|
1328
|
-
const name = `${stem}.svg`
|
|
1329
|
-
const sessionCwd = exec.agent?.session?.header?.cwd
|
|
1330
|
-
const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
|
|
1331
|
-
await mkdir(outDir, { recursive: true })
|
|
1332
|
-
const filePath = path.join(outDir, name)
|
|
1333
|
-
await writeFile(filePath, res.bytes)
|
|
1334
|
-
|
|
1335
|
-
let attachment = null
|
|
1336
|
-
let localUrl = ''
|
|
1337
|
-
if (ctx.attachments?.saveImage) {
|
|
1338
|
-
attachment = await ctx.attachments.saveImage({
|
|
1339
|
-
data: new Uint8Array(res.bytes),
|
|
1340
|
-
mediaType: 'image/svg+xml',
|
|
1341
|
-
name,
|
|
1342
|
-
})
|
|
1343
|
-
localUrl = '/dsh-image-gen/image?id=' + encodeURIComponent(attachment.attachmentId)
|
|
1344
|
-
}
|
|
1345
|
-
|
|
1346
|
-
const summary = `### Vectorized SVG Export\n- **File**: ${filePath}\n- **Format**: SVG (XML vector)\n- **Color mode**: ${args.color_mode || 'color'}\n- **Palette size**: ${res.palette ? res.palette.length : 16} colors\n`
|
|
1347
|
-
return toLosslessJson({
|
|
1348
|
-
summary,
|
|
1349
|
-
path: filePath,
|
|
1350
|
-
url: localUrl,
|
|
1351
|
-
svg: res.svg,
|
|
1352
|
-
format: 'svg',
|
|
1353
|
-
attachment,
|
|
1354
|
-
})
|
|
1355
|
-
} catch (err) {
|
|
1356
|
-
throw new Error(sanitizeErrorAndLogs(err.message || err))
|
|
1357
|
-
}
|
|
1358
|
-
},
|
|
1359
|
-
}),
|
|
1360
|
-
)
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
// Смешивание изображений (Blend images).
|
|
1364
|
-
ctx.tools.register(
|
|
1365
|
-
defineTool({
|
|
1366
|
-
name: 'blend_images',
|
|
1367
|
-
description: 'Blend multiple images (paths or attachment ids) into a coherent new composition.',
|
|
1368
|
-
parameters: {
|
|
1369
|
-
images: { type: 'array', items: { type: 'string' }, required: true, description: 'List of image paths or attachment ids to blend.' },
|
|
1370
|
-
weights: { type: 'array', items: { type: 'number' }, description: 'Weights for each image (optional).' },
|
|
1371
|
-
prompt: { type: 'string', description: 'Guiding prompt for the blend.' },
|
|
1372
|
-
output_name: { type: 'string', description: 'Custom output file name.' },
|
|
1373
|
-
},
|
|
1374
|
-
output: {
|
|
1375
|
-
schema: {
|
|
1376
|
-
type: 'object',
|
|
1377
|
-
additionalProperties: true,
|
|
1378
|
-
properties: {
|
|
1379
|
-
path: { type: 'string' },
|
|
1380
|
-
url: { type: 'string' },
|
|
1381
|
-
width: { type: 'number' },
|
|
1382
|
-
height: { type: 'number' },
|
|
1383
|
-
},
|
|
1384
|
-
},
|
|
1385
|
-
},
|
|
1386
|
-
async execute(args, exec) {
|
|
1387
|
-
try {
|
|
1388
|
-
const cfg = live()
|
|
1389
|
-
trackAndAssertLoopGuard(exec?.agent?.session?.id || 'default', { limit: cfg.loopGuardLimit })
|
|
1390
|
-
assertBudgetAvailable(0.05, cfg.dailyBudgetUsd)
|
|
1391
|
-
const refs = Array.isArray(args.images) ? args.images : [args.images].filter(Boolean)
|
|
1392
|
-
const sources = []
|
|
1393
|
-
for (const r of refs) {
|
|
1394
|
-
const s = await resolveSource(ctx, exec, r)
|
|
1395
|
-
if (s && s.bytes) sources.push(s)
|
|
1396
|
-
}
|
|
1397
|
-
if (sources.length === 0) throw new Error('No valid images found to blend')
|
|
1398
|
-
const res = await blendImagesFal(
|
|
1399
|
-
{ fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg },
|
|
1400
|
-
{ images: sources, weights: args.weights, prompt: args.prompt, signal: exec.signal }
|
|
1401
|
-
)
|
|
1402
|
-
const stem = `${slugify(args.output_name || 'blended')}-${Date.now().toString(36)}`
|
|
1403
|
-
const name = `${stem}.png`
|
|
1404
|
-
const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes: res.bytes, mediaType: 'image/png', name })
|
|
1405
|
-
const sessionCwd = exec.agent?.session?.header?.cwd
|
|
1406
|
-
const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
|
|
1407
|
-
await mkdir(outDir, { recursive: true })
|
|
1408
|
-
const filePath = path.join(outDir, name)
|
|
1409
|
-
await writeFile(filePath, res.bytes)
|
|
1410
|
-
const summary = buildDualOutputMarkdown({
|
|
1411
|
-
action: 'blended composition',
|
|
1412
|
-
filePath,
|
|
1413
|
-
width: res.width || attachment.width,
|
|
1414
|
-
height: res.height || attachment.height,
|
|
1415
|
-
mediaType: 'image/png',
|
|
1416
|
-
provider: 'fal',
|
|
1417
|
-
model: 'fal-ai/image-blend',
|
|
1418
|
-
attachmentId: attachment.attachmentId,
|
|
1419
|
-
})
|
|
1420
|
-
return toLosslessJson({
|
|
1421
|
-
summary,
|
|
1422
|
-
path: filePath,
|
|
1423
|
-
url: localUrl,
|
|
1424
|
-
width: res.width || attachment.width,
|
|
1425
|
-
height: res.height || attachment.height,
|
|
1426
|
-
attachment,
|
|
1427
|
-
})
|
|
1428
|
-
} catch (err) {
|
|
1429
|
-
throw new Error(sanitizeErrorAndLogs(err.message || err))
|
|
1430
|
-
}
|
|
1431
|
-
},
|
|
1432
|
-
}),
|
|
1433
|
-
)
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
// Пакетная генерация под разные пропорции (generate_image_pack).
|
|
1437
|
-
ctx.tools.register(
|
|
1438
|
-
defineTool({
|
|
1439
|
-
name: 'generate_image_pack',
|
|
1440
|
-
description: 'Generate a multi-aspect ratio pack of the same image (e.g. 1:1, 16:9, 9:16) for different platforms.',
|
|
1441
|
-
parameters: {
|
|
1442
|
-
prompt: { type: 'string', required: true, description: 'Text prompt for image generation.' },
|
|
1443
|
-
aspect_ratios: { type: 'array', items: { type: 'string' }, description: 'Target ratios (default: ["1:1", "16:9", "9:16"]).' },
|
|
1444
|
-
output_name: { type: 'string', description: 'Base file name for the pack.' },
|
|
1445
|
-
},
|
|
1446
|
-
output: {
|
|
1447
|
-
schema: {
|
|
1448
|
-
type: 'object',
|
|
1449
|
-
additionalProperties: true,
|
|
1450
|
-
properties: {
|
|
1451
|
-
images: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
|
1452
|
-
count: { type: 'number' },
|
|
1453
|
-
warnings: { type: 'array', items: { type: 'string' } },
|
|
1454
|
-
},
|
|
1455
|
-
},
|
|
1456
|
-
},
|
|
1457
|
-
async execute(args, exec) {
|
|
1458
|
-
const ratios = Array.isArray(args.aspect_ratios) && args.aspect_ratios.length ? args.aspect_ratios : ['1:1', '16:9', '9:16']
|
|
1459
|
-
const cfg = live()
|
|
1460
|
-
const generateTool = ctx.tools.get('generate_image')
|
|
1461
|
-
const results = []
|
|
1462
|
-
const warnings = []
|
|
1463
|
-
const seedBase = Math.floor(Math.random() * 100000)
|
|
1464
|
-
|
|
1465
|
-
for (let i = 0; i < ratios.length; i += 1) {
|
|
1466
|
-
const ratio = ratios[i]
|
|
1467
|
-
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'
|
|
1468
|
-
const name = `${slugify(args.output_name || 'pack')}-${ratio.replace(':', 'x')}`
|
|
1469
|
-
try {
|
|
1470
|
-
const genResult = await generateTool.execute({
|
|
1471
|
-
prompt: args.prompt,
|
|
1472
|
-
image_size: sizeName,
|
|
1473
|
-
seed: seedBase,
|
|
1474
|
-
output_name: name,
|
|
1475
|
-
}, exec)
|
|
1476
|
-
results.push({ ratio, ...genResult })
|
|
1477
|
-
} catch (err) {
|
|
1478
|
-
warnings.push(`Ratio ${ratio} failed: ${err?.message || String(err)}`)
|
|
1479
|
-
}
|
|
1480
|
-
}
|
|
1481
|
-
|
|
1482
|
-
if (results.length === 0 && warnings.length > 0) {
|
|
1483
|
-
throw new Error(`All aspect ratio generations failed in pack: ${warnings.join('; ')}`)
|
|
1484
|
-
}
|
|
1485
|
-
|
|
1486
|
-
return toLosslessJson({
|
|
1487
|
-
images: results,
|
|
1488
|
-
count: results.length,
|
|
1489
|
-
...(warnings.length ? { warnings } : {}),
|
|
1490
|
-
})
|
|
1491
|
-
},
|
|
1492
|
-
}),
|
|
1493
|
-
)
|
|
1494
|
-
|
|
1495
|
-
// Редактирование существующего изображения (Inpainting / Edits) (#142, #144, #145, #150).
|
|
1496
|
-
ctx.tools.register(
|
|
1497
|
-
defineTool({
|
|
1498
|
-
name: 'edit_image',
|
|
1499
|
-
description:
|
|
1500
|
-
'Edit an existing image using inpainting, targeted modification, or object replacement. '
|
|
1501
|
-
+ 'Describe the desired changes in prompt. '
|
|
1502
|
-
+ 'If image is omitted or "latest", automatically resolves the most recent image from the conversation session (#144). '
|
|
1503
|
-
+ 'Leverages @goodandready/dsh-vision-bridge for pre-edit composition analysis (#145). '
|
|
1504
|
-
+ 'Optionally accepts mask (path or attachment id) and strength (0.1-1.0). Returns safe dual-output (#150).',
|
|
1505
|
-
parameters: {
|
|
1506
|
-
prompt: {
|
|
1507
|
-
type: 'string',
|
|
1508
|
-
required: true,
|
|
1509
|
-
description: 'Detailed description of the modification or edit to perform on the image.',
|
|
1510
|
-
},
|
|
1511
|
-
image: {
|
|
1512
|
-
type: 'string',
|
|
1513
|
-
description: 'Path or attachment id of the source image. Defaults to "latest" to auto-resolve from session history.',
|
|
1514
|
-
},
|
|
1515
|
-
mask: {
|
|
1516
|
-
type: 'string',
|
|
1517
|
-
description: 'Optional path or attachment id of the inpaint mask image.',
|
|
1518
|
-
},
|
|
1519
|
-
strength: {
|
|
1520
|
-
type: 'number',
|
|
1521
|
-
description: 'Denoising / edit strength between 0.1 and 1.0 (default 0.75 for edits, 0.9 for mask inpainting).',
|
|
1522
|
-
},
|
|
1523
|
-
output_name: {
|
|
1524
|
-
type: 'string',
|
|
1525
|
-
description: 'Optional file name stem for the saved edited image.',
|
|
1526
|
-
},
|
|
1527
|
-
output_dir: {
|
|
1528
|
-
type: 'string',
|
|
1529
|
-
description: 'Optional custom directory to write edited image into.',
|
|
1530
|
-
},
|
|
1531
|
-
},
|
|
1532
|
-
output: {
|
|
1533
|
-
schema: {
|
|
1534
|
-
type: 'object',
|
|
1535
|
-
additionalProperties: true,
|
|
1536
|
-
properties: {
|
|
1537
|
-
summary: { type: 'string' },
|
|
1538
|
-
path: { type: 'string' },
|
|
1539
|
-
url: { type: 'string' },
|
|
1540
|
-
width: { type: 'number' },
|
|
1541
|
-
height: { type: 'number' },
|
|
1542
|
-
seed: { type: 'number' },
|
|
1543
|
-
provider: { type: 'string' },
|
|
1544
|
-
cost: { type: 'number' },
|
|
1545
|
-
format: { type: 'string' },
|
|
1546
|
-
attachment: { type: 'object', additionalProperties: true },
|
|
1547
|
-
visionAnalysis: { type: 'string' },
|
|
1548
|
-
},
|
|
1549
|
-
},
|
|
1550
|
-
},
|
|
1551
|
-
async execute(args, exec) {
|
|
1552
|
-
try {
|
|
1553
|
-
trackAndAssertLoopGuard(exec?.agent?.session?.id || 'default', { limit: cfg.loopGuardLimit })
|
|
1554
|
-
const estimatedCostEdit = calculateGenerationCost({
|
|
1555
|
-
provider: cfg.provider || 'fal',
|
|
1556
|
-
model: cfg.model,
|
|
1557
|
-
size: cfg.defaultSize || 'square_hd',
|
|
1558
|
-
count: 1,
|
|
1559
|
-
})
|
|
1560
|
-
assertBudgetAvailable(estimatedCostEdit, cfg.dailyBudgetUsd)
|
|
1561
|
-
const source = await resolveConversationImage(ctx, exec, args.image)
|
|
1562
|
-
const mask = args.mask ? await resolveSource(ctx, exec, args.mask) : undefined
|
|
1563
|
-
const vision = await analyzeImageWithVision(ctx, exec, source)
|
|
1564
|
-
|
|
1565
|
-
let effectivePrompt = args.prompt
|
|
1566
|
-
if (vision.available && vision.summary) {
|
|
1567
|
-
effectivePrompt = `${args.prompt} (Context: modifying ${vision.summary})`
|
|
1568
|
-
}
|
|
1569
|
-
|
|
1570
|
-
const provider = cfg.provider || 'fal'
|
|
1571
|
-
const deps = {
|
|
1572
|
-
fetchImpl: ctx.http ? (url, opt) => ctx.http(url, opt) : fetch,
|
|
1573
|
-
resolveKey: (ref) => resolveApiKey(ctx, ref),
|
|
1574
|
-
cfg,
|
|
1575
|
-
}
|
|
1576
|
-
|
|
1577
|
-
const seed = Math.floor(Math.random() * 100000)
|
|
1578
|
-
const strength = args.strength ?? (mask ? 0.85 : 0.65)
|
|
1579
|
-
const format = cfg.defaultFormat || 'png'
|
|
1580
|
-
const size = cfg.defaultSize || 'square_hd'
|
|
1581
|
-
|
|
1582
|
-
const job = {
|
|
1583
|
-
prompt: effectivePrompt,
|
|
1584
|
-
size,
|
|
1585
|
-
format,
|
|
1586
|
-
seed,
|
|
1587
|
-
source,
|
|
1588
|
-
mask,
|
|
1589
|
-
strength,
|
|
1590
|
-
provider,
|
|
1591
|
-
}
|
|
1592
|
-
|
|
1593
|
-
const providers = makeProviders(deps, job)
|
|
1594
|
-
const fn = providers[provider] || providers.fal || providers.custom
|
|
1595
|
-
const gen = await fn(seed, effectivePrompt)
|
|
1596
|
-
|
|
1597
|
-
const stem = args.output_name ? slugify(args.output_name) : `${source.name}-edit-${seed}`
|
|
1598
|
-
const name = `${stem}.${format}`
|
|
1599
|
-
|
|
1600
|
-
const result = await saveAndAttachResult(ctx, exec, cfg, {
|
|
1601
|
-
bytes: gen.bytes,
|
|
1602
|
-
mediaType: gen.mediaType || 'image/png',
|
|
1603
|
-
name,
|
|
1604
|
-
stem,
|
|
1605
|
-
prompt: args.prompt,
|
|
1606
|
-
size,
|
|
1607
|
-
format,
|
|
1608
|
-
seed: gen.seed ?? seed,
|
|
1609
|
-
provider,
|
|
1610
|
-
model: cfg.model,
|
|
1611
|
-
cost: gen.cost,
|
|
1612
|
-
sourceUrl: gen.sourceUrl,
|
|
1613
|
-
deliverAs: cfg.deliverAs || 'both',
|
|
1614
|
-
args,
|
|
1615
|
-
action: 'edited',
|
|
1616
|
-
})
|
|
1617
|
-
|
|
1618
|
-
if (vision.summary) {
|
|
1619
|
-
result.visionAnalysis = vision.summary
|
|
1620
|
-
}
|
|
1621
|
-
return toLosslessJson(result)
|
|
1622
|
-
} catch (err) {
|
|
1623
|
-
throw new Error(sanitizeErrorAndLogs(err.message || err))
|
|
1624
|
-
}
|
|
1625
|
-
},
|
|
1626
|
-
}),
|
|
1627
|
-
)
|
|
1628
|
-
|
|
1629
|
-
// Генерация вариаций существующего изображения (#143, #144, #145, #150).
|
|
1630
|
-
ctx.tools.register(
|
|
1631
|
-
defineTool({
|
|
1632
|
-
name: 'vary_image',
|
|
1633
|
-
description:
|
|
1634
|
-
'Generate subtle or creative variations of an existing image while preserving overall composition and structure. '
|
|
1635
|
-
+ 'Pass variation_strength between 0.1 (subtle difference) and 0.9 (strong reinterpretation, default 0.35). '
|
|
1636
|
-
+ 'If image is omitted or "latest", automatically resolves the most recent image from the conversation session (#144). '
|
|
1637
|
-
+ 'Returns safe dual-output for text-only LLMs (#150).',
|
|
1638
|
-
parameters: {
|
|
1639
|
-
image: {
|
|
1640
|
-
type: 'string',
|
|
1641
|
-
description: 'Path or attachment id of the source image. Defaults to "latest" to auto-resolve from session history.',
|
|
1642
|
-
},
|
|
1643
|
-
prompt: {
|
|
1644
|
-
type: 'string',
|
|
1645
|
-
description: 'Optional prompt to steer the variation towards a specific mood, lighting, or style.',
|
|
1646
|
-
},
|
|
1647
|
-
variation_strength: {
|
|
1648
|
-
type: 'number',
|
|
1649
|
-
description: 'Strength of variation between 0.1 and 0.9. Default: 0.35.',
|
|
1650
|
-
},
|
|
1651
|
-
count: {
|
|
1652
|
-
type: 'integer',
|
|
1653
|
-
description: 'Number of variations to produce (1-4, default 1).',
|
|
1654
|
-
},
|
|
1655
|
-
output_name: {
|
|
1656
|
-
type: 'string',
|
|
1657
|
-
description: 'Optional file name stem for the saved variations.',
|
|
1658
|
-
},
|
|
1659
|
-
output_dir: {
|
|
1660
|
-
type: 'string',
|
|
1661
|
-
description: 'Optional custom directory to write variations into.',
|
|
1662
|
-
},
|
|
1663
|
-
},
|
|
1664
|
-
output: {
|
|
1665
|
-
schema: {
|
|
1666
|
-
type: 'object',
|
|
1667
|
-
additionalProperties: true,
|
|
1668
|
-
properties: {
|
|
1669
|
-
summary: { type: 'string' },
|
|
1670
|
-
path: { type: 'string' },
|
|
1671
|
-
url: { type: 'string' },
|
|
1672
|
-
width: { type: 'number' },
|
|
1673
|
-
height: { type: 'number' },
|
|
1674
|
-
seed: { type: 'number' },
|
|
1675
|
-
provider: { type: 'string' },
|
|
1676
|
-
cost: { type: 'number' },
|
|
1677
|
-
format: { type: 'string' },
|
|
1678
|
-
attachment: { type: 'object', additionalProperties: true },
|
|
1679
|
-
variations: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
|
1680
|
-
},
|
|
1681
|
-
},
|
|
1682
|
-
},
|
|
1683
|
-
async execute(args, exec) {
|
|
1684
|
-
try {
|
|
1685
|
-
trackAndAssertLoopGuard(exec?.agent?.session?.id || 'default', { limit: cfg.loopGuardLimit })
|
|
1686
|
-
const count = normalizeCount(args.count || 1)
|
|
1687
|
-
const estimatedCostVary = calculateGenerationCost({
|
|
1688
|
-
provider: cfg.provider || 'fal',
|
|
1689
|
-
model: cfg.model,
|
|
1690
|
-
size: cfg.defaultSize || 'square_hd',
|
|
1691
|
-
count,
|
|
1692
|
-
})
|
|
1693
|
-
assertBudgetAvailable(estimatedCostVary, cfg.dailyBudgetUsd)
|
|
1694
|
-
const source = await resolveConversationImage(ctx, exec, args.image)
|
|
1695
|
-
const vision = await analyzeImageWithVision(ctx, exec, source)
|
|
1696
|
-
|
|
1697
|
-
const provider = cfg.provider || 'fal'
|
|
1698
|
-
const deps = {
|
|
1699
|
-
fetchImpl: ctx.http ? (url, opt) => ctx.http(url, opt) : fetch,
|
|
1700
|
-
resolveKey: (ref) => resolveApiKey(ctx, ref),
|
|
1701
|
-
cfg,
|
|
1702
|
-
}
|
|
1703
|
-
const variationStrength = Math.max(0.05, Math.min(0.95, args.variation_strength ?? 0.35))
|
|
1704
|
-
const baseSeed = Math.floor(Math.random() * 100000)
|
|
1705
|
-
const format = cfg.defaultFormat || 'png'
|
|
1706
|
-
const size = cfg.defaultSize || 'square_hd'
|
|
1707
|
-
|
|
1708
|
-
const promptText = args.prompt || (vision.available ? `variation of ${vision.summary}` : 'high fidelity variation preserving composition')
|
|
1709
|
-
|
|
1710
|
-
const results = []
|
|
1711
|
-
for (let i = 0; i < count; i++) {
|
|
1712
|
-
const currentSeed = baseSeed + i
|
|
1713
|
-
const job = {
|
|
1714
|
-
prompt: promptText,
|
|
1715
|
-
size,
|
|
1716
|
-
format,
|
|
1717
|
-
seed: currentSeed,
|
|
1718
|
-
source,
|
|
1719
|
-
strength: variationStrength,
|
|
1720
|
-
provider,
|
|
1721
|
-
}
|
|
1722
|
-
|
|
1723
|
-
const providers = makeProviders(deps, job)
|
|
1724
|
-
const fn = providers[provider] || providers.fal || providers.custom
|
|
1725
|
-
const gen = await fn(currentSeed, promptText)
|
|
1726
|
-
|
|
1727
|
-
const stem = args.output_name
|
|
1728
|
-
? `${slugify(args.output_name)}-var-${i + 1}`
|
|
1729
|
-
: `${source.name}-var-${currentSeed}`
|
|
1730
|
-
const name = `${stem}.${format}`
|
|
1731
|
-
|
|
1732
|
-
const item = await saveAndAttachResult(ctx, exec, cfg, {
|
|
1733
|
-
bytes: gen.bytes,
|
|
1734
|
-
mediaType: gen.mediaType || 'image/png',
|
|
1735
|
-
name,
|
|
1736
|
-
stem,
|
|
1737
|
-
prompt: promptText,
|
|
1738
|
-
size,
|
|
1739
|
-
format,
|
|
1740
|
-
seed: gen.seed ?? currentSeed,
|
|
1741
|
-
provider,
|
|
1742
|
-
model: cfg.model,
|
|
1743
|
-
cost: gen.cost,
|
|
1744
|
-
sourceUrl: gen.sourceUrl,
|
|
1745
|
-
deliverAs: cfg.deliverAs || 'both',
|
|
1746
|
-
args,
|
|
1747
|
-
action: `variation (${variationStrength})`,
|
|
1748
|
-
})
|
|
1749
|
-
results.push(item)
|
|
1750
|
-
}
|
|
1751
|
-
|
|
1752
|
-
const first = results[0]
|
|
1753
|
-
return toLosslessJson({
|
|
1754
|
-
...first,
|
|
1755
|
-
variations: results.map((r) => ({ path: r.path, attachmentId: r.attachment?.attachmentId, seed: r.seed })),
|
|
1756
|
-
})
|
|
1757
|
-
} catch (err) {
|
|
1758
|
-
throw new Error(sanitizeErrorAndLogs(err.message || err))
|
|
1759
|
-
}
|
|
1760
|
-
},
|
|
1761
|
-
}),
|
|
1762
|
-
)
|
|
1763
|
-
|
|
1764
|
-
// Проверка качества изображения (Inspect Image Quality).
|
|
1765
|
-
ctx.tools.register(
|
|
1766
|
-
defineTool({
|
|
1767
|
-
name: 'inspect_image_quality',
|
|
1768
|
-
description: 'Inspect a generated image for quality, artifacts, and presence of expected elements.',
|
|
1769
|
-
parameters: {
|
|
1770
|
-
image: { type: 'string', required: true, description: 'Path or attachment id of the image.' },
|
|
1771
|
-
expected_elements: { type: 'string', description: 'What elements should be present and verified.' },
|
|
1772
|
-
},
|
|
1773
|
-
output: {
|
|
1774
|
-
schema: {
|
|
1775
|
-
type: 'object',
|
|
1776
|
-
additionalProperties: false,
|
|
1777
|
-
properties: {
|
|
1778
|
-
qualityScore: { type: 'number' },
|
|
1779
|
-
passed: { type: 'boolean' },
|
|
1780
|
-
notes: { type: 'string' },
|
|
1781
|
-
},
|
|
1782
|
-
},
|
|
1783
|
-
},
|
|
1784
|
-
async execute(args, exec) {
|
|
1785
|
-
const source = await resolveSource(ctx, exec, args.image)
|
|
1786
|
-
if (!source || !source.bytes) throw new Error('Image not found: ' + args.image)
|
|
1787
|
-
const analysis = estimateSharpnessAndVariance(source.bytes)
|
|
1788
|
-
const elementNote = args.expected_elements ? ` Verified presence of: ${args.expected_elements}.` : ''
|
|
1789
|
-
return {
|
|
1790
|
-
qualityScore: analysis.score,
|
|
1791
|
-
passed: analysis.passed,
|
|
1792
|
-
notes: analysis.isBlank ? `Defect detected: ${analysis.reason}` : `Image clarity and variance verified (score ${analysis.score}).${elementNote}`,
|
|
1793
|
-
}
|
|
1794
|
-
},
|
|
1795
|
-
}),
|
|
1796
|
-
)
|
|
1797
|
-
|
|
1798
|
-
// #172: Генератор дизайн-токенов и CSS/Tailwind палитры по концепту
|
|
1799
|
-
ctx.tools.register(
|
|
1800
|
-
defineTool({
|
|
1801
|
-
name: 'extract_design_tokens',
|
|
1802
|
-
description: 'Extract design tokens (CSS variables, Tailwind colors config, W3C tokens JSON) from an image or palette.',
|
|
1803
|
-
parameters: {
|
|
1804
|
-
image: { type: 'string', description: 'Path or attachment ID of the image to extract colors from.' },
|
|
1805
|
-
colors: { type: 'array', items: { type: 'string' }, description: 'Optional list of hex colors if already known.' },
|
|
1806
|
-
prefix: { type: 'string', description: 'Token naming prefix (default: color).' },
|
|
1807
|
-
},
|
|
1808
|
-
output: {
|
|
1809
|
-
schema: {
|
|
1810
|
-
type: 'object',
|
|
1811
|
-
additionalProperties: true,
|
|
1812
|
-
properties: {
|
|
1813
|
-
tokens: { type: 'object', additionalProperties: true },
|
|
1814
|
-
cssVariables: { type: 'string' },
|
|
1815
|
-
tailwindSnippet: { type: 'string' },
|
|
1816
|
-
w3cTokens: { type: 'object', additionalProperties: true },
|
|
1817
|
-
},
|
|
1818
|
-
},
|
|
1819
|
-
},
|
|
1820
|
-
async execute(args, exec) {
|
|
1821
|
-
let colors = args.colors
|
|
1822
|
-
if (!colors || colors.length === 0) {
|
|
1823
|
-
if (args.image) {
|
|
1824
|
-
const source = await resolveSource(ctx, exec, args.image)
|
|
1825
|
-
if (!source || !source.bytes) {
|
|
1826
|
-
throw new Error('Image not found or unreadable: ' + args.image)
|
|
1827
|
-
}
|
|
1828
|
-
colors = extractSampleColorsFromBuffer(source.bytes)
|
|
1829
|
-
}
|
|
1830
|
-
}
|
|
1831
|
-
return toLosslessJson(extractDesignTokens(colors, { prefix: args.prefix }))
|
|
1832
|
-
},
|
|
1833
|
-
}),
|
|
1834
|
-
)
|
|
1835
|
-
|
|
1836
|
-
// #174: Конвертер фоновых изображений в легковесные CSS Mesh Gradients
|
|
1837
|
-
ctx.tools.register(
|
|
1838
|
-
defineTool({
|
|
1839
|
-
name: 'image_to_css_gradient',
|
|
1840
|
-
description: 'Convert an image background or color palette into a lightweight pure CSS gradient (< 1KB).',
|
|
1841
|
-
parameters: {
|
|
1842
|
-
image: { type: 'string', description: 'Path or attachment ID of the image.' },
|
|
1843
|
-
colors: { type: 'array', items: { type: 'string' }, description: 'Optional explicit hex colors for the gradient.' },
|
|
1844
|
-
type: { type: 'string', enum: ['mesh', 'linear', 'radial'], description: 'Gradient type: mesh, linear, or radial (default: mesh).' },
|
|
1845
|
-
},
|
|
1846
|
-
output: {
|
|
1847
|
-
schema: {
|
|
1848
|
-
type: 'object',
|
|
1849
|
-
additionalProperties: true,
|
|
1850
|
-
properties: {
|
|
1851
|
-
type: { type: 'string' },
|
|
1852
|
-
fallbackColor: { type: 'string' },
|
|
1853
|
-
gradientCss: { type: 'string' },
|
|
1854
|
-
completeStyle: { type: 'string' },
|
|
1855
|
-
byteSize: { type: 'number' },
|
|
1856
|
-
},
|
|
1857
|
-
},
|
|
1858
|
-
},
|
|
1859
|
-
async execute(args, exec) {
|
|
1860
|
-
let colors = args.colors
|
|
1861
|
-
if (!colors || colors.length === 0) {
|
|
1862
|
-
if (args.image) {
|
|
1863
|
-
const source = await resolveSource(ctx, exec, args.image)
|
|
1864
|
-
if (!source || !source.bytes) {
|
|
1865
|
-
throw new Error('Image not found or unreadable: ' + args.image)
|
|
1866
|
-
}
|
|
1867
|
-
colors = extractSampleColorsFromBuffer(source.bytes)
|
|
1868
|
-
}
|
|
1869
|
-
}
|
|
1870
|
-
return toLosslessJson(generateCssGradient(colors, args.type || 'mesh'))
|
|
1871
|
-
},
|
|
1872
|
-
}),
|
|
1873
|
-
)
|
|
1874
|
-
|
|
1875
|
-
// #175: Проверка контрастности фона под текст по WCAG 2.1
|
|
1876
|
-
ctx.tools.register(
|
|
1877
|
-
defineTool({
|
|
1878
|
-
name: 'check_image_contrast',
|
|
1879
|
-
description: 'Check image background contrast ratio against text color according to WCAG 2.1 AA/AAA standards and suggest scrim.',
|
|
1880
|
-
parameters: {
|
|
1881
|
-
image: { type: 'string', description: 'Path or attachment ID of the image.' },
|
|
1882
|
-
colors: { type: 'array', items: { type: 'string' }, description: 'Optional list of background hex colors.' },
|
|
1883
|
-
text_color: { type: 'string', description: 'Text hex color to test against (default: #ffffff).' },
|
|
1884
|
-
},
|
|
1885
|
-
output: {
|
|
1886
|
-
schema: {
|
|
1887
|
-
type: 'object',
|
|
1888
|
-
additionalProperties: true,
|
|
1889
|
-
properties: {
|
|
1890
|
-
textHex: { type: 'string' },
|
|
1891
|
-
minContrastRatio: { type: 'number' },
|
|
1892
|
-
passedAA: { type: 'boolean' },
|
|
1893
|
-
passedAALarge: { type: 'boolean' },
|
|
1894
|
-
passedAAA: { type: 'boolean' },
|
|
1895
|
-
recommendation: { type: 'string' },
|
|
1896
|
-
suggestedScrimCss: { type: 'string' },
|
|
1897
|
-
details: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
|
1898
|
-
},
|
|
1899
|
-
},
|
|
1900
|
-
},
|
|
1901
|
-
async execute(args, exec) {
|
|
1902
|
-
let colors = args.colors
|
|
1903
|
-
if (!colors || colors.length === 0) {
|
|
1904
|
-
if (args.image) {
|
|
1905
|
-
const source = await resolveSource(ctx, exec, args.image)
|
|
1906
|
-
if (!source || !source.bytes) {
|
|
1907
|
-
throw new Error('Image not found or unreadable: ' + args.image)
|
|
1908
|
-
}
|
|
1909
|
-
colors = extractSampleColorsFromBuffer(source.bytes)
|
|
1910
|
-
}
|
|
1911
|
-
}
|
|
1912
|
-
return toLosslessJson(checkWcagContrast(colors, args.text_color || '#ffffff'))
|
|
1913
|
-
},
|
|
1914
|
-
}),
|
|
1915
|
-
)
|
|
1916
|
-
|
|
1917
|
-
// #176: Интеллектуальная постобработка, очистка и оптимизация SVG
|
|
1918
|
-
ctx.tools.register(
|
|
1919
|
-
defineTool({
|
|
1920
|
-
name: 'optimize_vector_svg',
|
|
1921
|
-
description: 'Clean, sanitize, and minify SVG content, normalize viewBox, and export React TSX component.',
|
|
1922
|
-
parameters: {
|
|
1923
|
-
svg_content: { type: 'string', description: 'Raw SVG markup string to optimize.' },
|
|
1924
|
-
image: { type: 'string', description: 'Path or attachment ID of an SVG file if not passing raw string.' },
|
|
1925
|
-
component_name: { type: 'string', description: 'React component name for TSX export (default: VectorIcon).' },
|
|
1926
|
-
},
|
|
1927
|
-
output: {
|
|
1928
|
-
schema: {
|
|
1929
|
-
type: 'object',
|
|
1930
|
-
additionalProperties: true,
|
|
1931
|
-
properties: {
|
|
1932
|
-
svg: { type: 'string' },
|
|
1933
|
-
viewBox: { type: 'string' },
|
|
1934
|
-
originalSize: { type: 'number' },
|
|
1935
|
-
optimizedSize: { type: 'number' },
|
|
1936
|
-
savedBytes: { type: 'number' },
|
|
1937
|
-
reductionPercent: { type: 'number' },
|
|
1938
|
-
reactTsx: { type: 'string' },
|
|
1939
|
-
},
|
|
1940
|
-
},
|
|
1941
|
-
},
|
|
1942
|
-
async execute(args, exec) {
|
|
1943
|
-
let svgStr = args.svg_content
|
|
1944
|
-
if (!svgStr && args.image) {
|
|
1945
|
-
const source = await resolveSource(ctx, exec, args.image)
|
|
1946
|
-
if (source && source.bytes) {
|
|
1947
|
-
svgStr = source.bytes.toString('utf8')
|
|
1948
|
-
}
|
|
1949
|
-
}
|
|
1950
|
-
if (!svgStr) {
|
|
1951
|
-
throw new Error('Either svg_content or image must be provided')
|
|
1952
|
-
}
|
|
1953
|
-
return optimizeSvgContent(svgStr, { componentName: args.component_name })
|
|
1954
|
-
},
|
|
1955
|
-
}),
|
|
1956
|
-
)
|
|
1957
|
-
|
|
1958
|
-
// #190: Генератор полного комплекта фавиконок, иконок приложений и PWA-манифеста
|
|
1959
|
-
ctx.tools.register(
|
|
1960
|
-
defineTool({
|
|
1961
|
-
name: 'generate_pwa_icon_suite',
|
|
1962
|
-
description: 'Generate standard PWA icons specification, HTML meta tags, and web app manifest.json.',
|
|
1963
|
-
parameters: {
|
|
1964
|
-
image: { type: 'string', description: 'Source icon image path or attachment ID.' },
|
|
1965
|
-
name: { type: 'string', description: 'App name (e.g. My Application).' },
|
|
1966
|
-
short_name: { type: 'string', description: 'App short name (e.g. MyApp).' },
|
|
1967
|
-
theme_color: { type: 'string', description: 'Theme color hex (e.g. #0f172a).' },
|
|
1968
|
-
background_color: { type: 'string', description: 'Background color hex (e.g. #ffffff).' },
|
|
1969
|
-
icons_dir: { type: 'string', description: 'Directory for icons relative to webroot (default: icons).' },
|
|
1970
|
-
},
|
|
1971
|
-
output: {
|
|
1972
|
-
schema: {
|
|
1973
|
-
type: 'object',
|
|
1974
|
-
additionalProperties: true,
|
|
1975
|
-
properties: {
|
|
1976
|
-
name: { type: 'string' },
|
|
1977
|
-
shortName: { type: 'string' },
|
|
1978
|
-
themeColor: { type: 'string' },
|
|
1979
|
-
backgroundColor: { type: 'string' },
|
|
1980
|
-
icons: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
|
1981
|
-
manifestJson: { type: 'string' },
|
|
1982
|
-
htmlHeadSnippet: { type: 'string' },
|
|
1983
|
-
},
|
|
1984
|
-
},
|
|
1985
|
-
},
|
|
1986
|
-
async execute(args, exec) {
|
|
1987
|
-
const res = await generatePwaIconSuite({
|
|
1988
|
-
name: args.name || 'App',
|
|
1989
|
-
shortName: args.short_name || args.name || 'App',
|
|
1990
|
-
themeColor: args.theme_color || '#0f172a',
|
|
1991
|
-
backgroundColor: args.background_color || '#ffffff',
|
|
1992
|
-
iconsDir: args.icons_dir || 'icons',
|
|
1993
|
-
sourcePath: args.image || null,
|
|
1994
|
-
})
|
|
1995
|
-
return toLosslessJson(res)
|
|
1996
|
-
},
|
|
1997
|
-
}),
|
|
1998
|
-
)
|
|
1999
|
-
|
|
644
|
+
// Tool registrations live in register-tools.js; each tool is a labeled ctx.effect (#216).
|
|
645
|
+
registerAllTools(ctx, {
|
|
646
|
+
config,
|
|
647
|
+
live,
|
|
648
|
+
saveAndAttachResult,
|
|
649
|
+
resolveSource,
|
|
650
|
+
slugify,
|
|
651
|
+
resolveApiKey,
|
|
2000
652
|
})
|
|
2001
653
|
|
|
2002
654
|
}
|