@goodandready/dsh-image-gen 0.10.35 → 0.11.1
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/lib/client.js +994 -161
- package/lib/comfy-workflow-helpers.js +161 -0
- package/lib/fallback-router.js +25 -3
- package/lib/index.js +259 -138
- package/lib/provider-utils.js +0 -2
- package/lib/providers/backends/local.js +33 -11
- package/lib/providers.js +9 -30
- package/lib/register-tools.js +3 -1
- package/lib/settings-route.js +112 -0
- package/lib/theme-pair-helpers.js +83 -0
- package/lib/tools/anchor.js +1 -2
- package/lib/tools/editing.js +2 -34
- package/lib/tools/frontend.js +2 -35
- package/lib/tools/generation-pack.js +2 -4
- package/lib/tools/generation.js +2 -23
- package/lib/tools/inspect.js +6 -45
- package/lib/tools/pattern.js +10 -0
- package/lib/tools/processing-advanced.js +0 -32
- package/lib/tools/processing-basic.js +2 -25
- package/lib/tools/style-matrix.js +1 -2
- package/lib/tools/theme-pair.js +250 -0
- package/lib/tools/ui-asset.js +1 -1
- package/lib/updater.js +0 -9
- package/lib/vault.js +183 -0
- package/package.json +7 -1
|
@@ -7,48 +7,16 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
|
7
7
|
import path from 'node:path'
|
|
8
8
|
import { mkdir, writeFile } from 'node:fs/promises'
|
|
9
9
|
import {
|
|
10
|
-
computeGenerationHash,
|
|
11
|
-
IMAGE_SIZES,
|
|
12
|
-
tryGenerate,
|
|
13
|
-
fallbackOrder,
|
|
14
|
-
embedPngMetadata,
|
|
15
|
-
removeBackgroundFal,
|
|
16
|
-
upscaleImageFal,
|
|
17
|
-
traceToSvg,
|
|
18
|
-
estimateCost,
|
|
19
|
-
estimateSharpnessAndVariance,
|
|
20
|
-
applyStylePreset,
|
|
21
|
-
resolveStylePreset,
|
|
22
|
-
blendImagesFal,
|
|
23
10
|
toLosslessJson,
|
|
24
11
|
saveAttachmentSafe,
|
|
25
12
|
} from '../providers.js'
|
|
26
|
-
import { resolveConversationImage, analyzeImageWithVision, buildDualOutputMarkdown } from '../resolve-image.js'
|
|
27
|
-
import { sanitizeNegativePrompt, supportsNegativePrompt } from '../negative-sanitizer.js'
|
|
28
|
-
import { executeWithQualityGate } from '../quality-gate.js'
|
|
29
|
-
import { calculateGenerationCost, recordSpend, assertBudgetAvailable } from '../cost-meter.js'
|
|
30
|
-
import { trackAndAssertLoopGuard } from '../loop-guard.js'
|
|
31
13
|
import { sanitizeErrorAndLogs } from '../security.js'
|
|
32
|
-
import { getCachedGeneration, setCachedGeneration } from '../generation-cache.js'
|
|
33
|
-
import {
|
|
34
|
-
extractDesignTokens,
|
|
35
|
-
generateCssGradient,
|
|
36
|
-
checkWcagContrast,
|
|
37
|
-
optimizeSvgContent,
|
|
38
|
-
generatePwaIconSuite,
|
|
39
|
-
extractSampleColorsFromBuffer,
|
|
40
|
-
} from '../frontend-assets.js'
|
|
41
|
-
|
|
42
|
-
|
|
43
14
|
|
|
44
15
|
export function registerProcessingAdvancedTools(ctx, deps) {
|
|
45
16
|
const {
|
|
46
|
-
config,
|
|
47
17
|
live,
|
|
48
|
-
saveAndAttachResult,
|
|
49
18
|
resolveSource,
|
|
50
19
|
slugify,
|
|
51
|
-
resolveApiKey,
|
|
52
20
|
} = deps
|
|
53
21
|
|
|
54
22
|
ctx.effect(() => {
|
|
@@ -7,44 +7,21 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
|
7
7
|
import path from 'node:path'
|
|
8
8
|
import { mkdir, writeFile } from 'node:fs/promises'
|
|
9
9
|
import {
|
|
10
|
-
computeGenerationHash,
|
|
11
|
-
IMAGE_SIZES,
|
|
12
|
-
tryGenerate,
|
|
13
|
-
fallbackOrder,
|
|
14
|
-
embedPngMetadata,
|
|
15
10
|
removeBackgroundFal,
|
|
16
11
|
upscaleImageFal,
|
|
17
12
|
traceToSvg,
|
|
18
|
-
estimateCost,
|
|
19
|
-
estimateSharpnessAndVariance,
|
|
20
|
-
applyStylePreset,
|
|
21
|
-
resolveStylePreset,
|
|
22
13
|
blendImagesFal,
|
|
23
14
|
toLosslessJson,
|
|
24
15
|
saveAttachmentSafe,
|
|
25
16
|
} from '../providers.js'
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
28
|
-
import { executeWithQualityGate } from '../quality-gate.js'
|
|
29
|
-
import { calculateGenerationCost, recordSpend, assertBudgetAvailable } from '../cost-meter.js'
|
|
17
|
+
import { buildDualOutputMarkdown } from '../resolve-image.js'
|
|
18
|
+
import { assertBudgetAvailable } from '../cost-meter.js'
|
|
30
19
|
import { trackAndAssertLoopGuard } from '../loop-guard.js'
|
|
31
20
|
import { sanitizeErrorAndLogs } from '../security.js'
|
|
32
|
-
import { getCachedGeneration, setCachedGeneration } from '../generation-cache.js'
|
|
33
|
-
import {
|
|
34
|
-
extractDesignTokens,
|
|
35
|
-
generateCssGradient,
|
|
36
|
-
checkWcagContrast,
|
|
37
|
-
optimizeSvgContent,
|
|
38
|
-
generatePwaIconSuite,
|
|
39
|
-
extractSampleColorsFromBuffer,
|
|
40
|
-
} from '../frontend-assets.js'
|
|
41
|
-
|
|
42
21
|
|
|
43
22
|
export function registerProcessingTools(ctx, deps) {
|
|
44
23
|
const {
|
|
45
|
-
config,
|
|
46
24
|
live,
|
|
47
|
-
saveAndAttachResult,
|
|
48
25
|
resolveSource,
|
|
49
26
|
slugify,
|
|
50
27
|
resolveApiKey,
|
|
@@ -14,7 +14,6 @@ import {
|
|
|
14
14
|
} from '../providers.js'
|
|
15
15
|
import { resolveFallbackChain, executeWithFallback } from '../fallback-router.js'
|
|
16
16
|
import {
|
|
17
|
-
DEFAULT_MATRIX_STYLES,
|
|
18
17
|
normalizeMatrixStyles,
|
|
19
18
|
buildMatrixCellPrompt,
|
|
20
19
|
formatMatrixMarkdown,
|
|
@@ -127,7 +126,7 @@ export function registerStyleMatrixTools(ctx, deps) {
|
|
|
127
126
|
|
|
128
127
|
const provider = PROVIDER_KEYS.includes(cfg.provider) ? cfg.provider : 'fal'
|
|
129
128
|
let subscriptionImages
|
|
130
|
-
try { subscriptionImages = ctx.get && ctx.get('subscriptionImages') } catch
|
|
129
|
+
try { subscriptionImages = ctx.get && ctx.get('subscriptionImages') } catch { subscriptionImages = undefined }
|
|
131
130
|
|
|
132
131
|
const chain = resolveFallbackChain(provider, cfg.fallbackProviders, PROVIDER_KEYS)
|
|
133
132
|
const quadrantIds = ['A', 'B', 'C', 'D']
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// theme-pair.js — generate_theme_pair tool (#189).
|
|
2
|
+
// Synchronized dual generation of light and dark theme visuals with adaptive CSS snippets.
|
|
3
|
+
|
|
4
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { mkdir, writeFile } from 'node:fs/promises'
|
|
7
|
+
import {
|
|
8
|
+
ASPECT_RATIOS,
|
|
9
|
+
buildSidecar,
|
|
10
|
+
makeProviders,
|
|
11
|
+
toLosslessJson,
|
|
12
|
+
saveAttachmentSafe,
|
|
13
|
+
} from '../providers.js'
|
|
14
|
+
import { resolveFallbackChain, executeWithFallback } from '../fallback-router.js'
|
|
15
|
+
import { renderToolOutput } from '../attachment-helper.js'
|
|
16
|
+
import {
|
|
17
|
+
THEME_STYLES,
|
|
18
|
+
buildThemePairPrompts,
|
|
19
|
+
buildThemePairSnippet,
|
|
20
|
+
} from '../theme-pair-helpers.js'
|
|
21
|
+
|
|
22
|
+
export function registerThemePairTools(ctx, deps) {
|
|
23
|
+
const {
|
|
24
|
+
live,
|
|
25
|
+
slugify,
|
|
26
|
+
resolveApiKey,
|
|
27
|
+
} = deps
|
|
28
|
+
|
|
29
|
+
ctx.effect(() => {
|
|
30
|
+
ctx.tools.register(
|
|
31
|
+
defineTool({
|
|
32
|
+
name: 'generate_theme_pair',
|
|
33
|
+
description:
|
|
34
|
+
'Generate synchronized dual illustrations or UI graphics designed specifically for light and dark application themes. '
|
|
35
|
+
+ 'Produces both light (#FFFFFF clean backdrop) and dark (#0F172A obsidian backdrop with luminous accents) variants with matched composition, plus ready-to-use HTML/CSS responsive markup.',
|
|
36
|
+
parameters: {
|
|
37
|
+
prompt: {
|
|
38
|
+
type: 'string',
|
|
39
|
+
required: true,
|
|
40
|
+
description: 'Core subject or illustration motif, e.g. "cloud server network topology" or "productivity dashboard banner".',
|
|
41
|
+
},
|
|
42
|
+
aspect_ratio: {
|
|
43
|
+
type: 'string',
|
|
44
|
+
enum: Object.keys(ASPECT_RATIOS),
|
|
45
|
+
description: 'Aspect ratio for both variants: "1:1", "16:9", "4:3", etc. (default: "16:9").',
|
|
46
|
+
},
|
|
47
|
+
style: {
|
|
48
|
+
type: 'string',
|
|
49
|
+
enum: THEME_STYLES,
|
|
50
|
+
description: 'Visual style aesthetic (default: "minimalist").',
|
|
51
|
+
},
|
|
52
|
+
dark_contrast_boost: {
|
|
53
|
+
type: 'number',
|
|
54
|
+
description: 'Multiplier for glow and highlight contrast in the dark variant (default: 1.2).',
|
|
55
|
+
},
|
|
56
|
+
output_name: {
|
|
57
|
+
type: 'string',
|
|
58
|
+
description: 'Base filename prefix for the pair (default: auto-slugified from prompt).',
|
|
59
|
+
},
|
|
60
|
+
seed: {
|
|
61
|
+
type: 'integer',
|
|
62
|
+
description: 'Base random seed to synchronize composition between variants.',
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
output: {
|
|
66
|
+
schema: {
|
|
67
|
+
type: 'object',
|
|
68
|
+
additionalProperties: true,
|
|
69
|
+
properties: {
|
|
70
|
+
summary: { type: 'string' },
|
|
71
|
+
prompt: { type: 'string' },
|
|
72
|
+
style: { type: 'string' },
|
|
73
|
+
light: {
|
|
74
|
+
type: 'object',
|
|
75
|
+
additionalProperties: true,
|
|
76
|
+
properties: {
|
|
77
|
+
path: { type: 'string' },
|
|
78
|
+
url: { type: 'string' },
|
|
79
|
+
attachment: { type: 'object', additionalProperties: true },
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
dark: {
|
|
83
|
+
type: 'object',
|
|
84
|
+
additionalProperties: true,
|
|
85
|
+
properties: {
|
|
86
|
+
path: { type: 'string' },
|
|
87
|
+
url: { type: 'string' },
|
|
88
|
+
attachment: { type: 'object', additionalProperties: true },
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
html_snippet: { type: 'string' },
|
|
92
|
+
css_snippet: { type: 'string' },
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
render(_args, value) {
|
|
96
|
+
return renderToolOutput(value)
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
isConcurrencySafe: () => false,
|
|
100
|
+
async execute(args, exec) {
|
|
101
|
+
const cfg = live()
|
|
102
|
+
if (cfg.enabled === false) {
|
|
103
|
+
throw new Error('Image generation is disabled in settings.')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const prompt = String(args.prompt || '').trim()
|
|
107
|
+
if (!prompt) {
|
|
108
|
+
throw new Error('Prompt is required for generate_theme_pair')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const style = THEME_STYLES.includes(args.style) ? args.style : 'minimalist'
|
|
112
|
+
const aspectRatio = args.aspect_ratio || '16:9'
|
|
113
|
+
const aspectPixels = ASPECT_RATIOS[aspectRatio] || [1344, 768]
|
|
114
|
+
const darkContrastBoost = typeof args.dark_contrast_boost === 'number' ? args.dark_contrast_boost : 1.2
|
|
115
|
+
|
|
116
|
+
const { lightPrompt, darkPrompt } = buildThemePairPrompts({
|
|
117
|
+
prompt,
|
|
118
|
+
style,
|
|
119
|
+
darkContrastBoost,
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
const primaryProvider = cfg.provider || 'fal'
|
|
123
|
+
const chain = resolveFallbackChain(primaryProvider, cfg.fallbackChain)
|
|
124
|
+
const baseSeed = typeof args.seed === 'number' ? args.seed : Math.floor(Math.random() * 1000000)
|
|
125
|
+
|
|
126
|
+
const sessionCwd = exec.agent?.session?.header?.cwd
|
|
127
|
+
const targetDir = cfg.outputDir || 'generated/images'
|
|
128
|
+
const outDir = path.resolve(sessionCwd || process.cwd(), targetDir)
|
|
129
|
+
await mkdir(outDir, { recursive: true })
|
|
130
|
+
|
|
131
|
+
const slug = args.output_name ? slugify(args.output_name) : slugify(prompt).slice(0, 36)
|
|
132
|
+
const ts = Date.now().toString(36)
|
|
133
|
+
|
|
134
|
+
// 1. Generate Light theme variant
|
|
135
|
+
const lightJob = {
|
|
136
|
+
prompt: lightPrompt,
|
|
137
|
+
format: 'png',
|
|
138
|
+
aspectPixels,
|
|
139
|
+
aspectRatio,
|
|
140
|
+
seed: baseSeed,
|
|
141
|
+
signal: exec.signal,
|
|
142
|
+
}
|
|
143
|
+
const lightGen = await executeWithFallback({
|
|
144
|
+
chain,
|
|
145
|
+
liveConfig: cfg,
|
|
146
|
+
makeProvidersFn: (deps, job) => makeProviders(deps, job),
|
|
147
|
+
resolveApiKeyFn: resolveApiKey,
|
|
148
|
+
job: lightJob,
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
const lightFilename = `${slug}-light-${ts}.png`
|
|
152
|
+
const lightPath = path.join(outDir, lightFilename)
|
|
153
|
+
await writeFile(lightPath, lightGen.bytes)
|
|
154
|
+
|
|
155
|
+
const lightAttachment = await saveAttachmentSafe(ctx, {
|
|
156
|
+
bytes: lightGen.bytes,
|
|
157
|
+
mediaType: 'image/png',
|
|
158
|
+
filename: lightFilename,
|
|
159
|
+
width: lightGen.width || aspectPixels[0],
|
|
160
|
+
height: lightGen.height || aspectPixels[1],
|
|
161
|
+
})
|
|
162
|
+
const lightUrl = lightAttachment.attachmentId ? `/dsh-image-gen/image?id=${encodeURIComponent(lightAttachment.attachmentId)}` : ''
|
|
163
|
+
|
|
164
|
+
// 2. Generate Dark theme variant (using matched seed)
|
|
165
|
+
const darkJob = {
|
|
166
|
+
prompt: darkPrompt,
|
|
167
|
+
format: 'png',
|
|
168
|
+
aspectPixels,
|
|
169
|
+
aspectRatio,
|
|
170
|
+
seed: baseSeed,
|
|
171
|
+
signal: exec.signal,
|
|
172
|
+
}
|
|
173
|
+
const darkGen = await executeWithFallback({
|
|
174
|
+
chain,
|
|
175
|
+
liveConfig: cfg,
|
|
176
|
+
makeProvidersFn: (deps, job) => makeProviders(deps, job),
|
|
177
|
+
resolveApiKeyFn: resolveApiKey,
|
|
178
|
+
job: darkJob,
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
const darkFilename = `${slug}-dark-${ts}.png`
|
|
182
|
+
const darkPath = path.join(outDir, darkFilename)
|
|
183
|
+
await writeFile(darkPath, darkGen.bytes)
|
|
184
|
+
|
|
185
|
+
const darkAttachment = await saveAttachmentSafe(ctx, {
|
|
186
|
+
bytes: darkGen.bytes,
|
|
187
|
+
mediaType: 'image/png',
|
|
188
|
+
filename: darkFilename,
|
|
189
|
+
width: darkGen.width || aspectPixels[0],
|
|
190
|
+
height: darkGen.height || aspectPixels[1],
|
|
191
|
+
})
|
|
192
|
+
const darkUrl = darkAttachment.attachmentId ? `/dsh-image-gen/image?id=${encodeURIComponent(darkAttachment.attachmentId)}` : ''
|
|
193
|
+
|
|
194
|
+
// Sidecars
|
|
195
|
+
await writeFile(
|
|
196
|
+
path.join(outDir, `${slug}-pair-${ts}.json`),
|
|
197
|
+
JSON.stringify(buildSidecar({
|
|
198
|
+
prompt,
|
|
199
|
+
style,
|
|
200
|
+
aspectRatio,
|
|
201
|
+
size: `${aspectPixels[0]}x${aspectPixels[1]}`,
|
|
202
|
+
format: 'png',
|
|
203
|
+
seed: baseSeed,
|
|
204
|
+
light: { path: lightPath, url: lightUrl, attachmentId: lightAttachment.attachmentId },
|
|
205
|
+
dark: { path: darkPath, url: darkUrl, attachmentId: darkAttachment.attachmentId },
|
|
206
|
+
cost: (lightGen.cost || 0) + (darkGen.cost || 0),
|
|
207
|
+
}), null, 2),
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
const { html, css } = buildThemePairSnippet({
|
|
211
|
+
lightSrc: lightFilename,
|
|
212
|
+
darkSrc: darkFilename,
|
|
213
|
+
alt: prompt,
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
const summary = `Generated Theme Pair (${style}, ${aspectRatio}):\n`
|
|
217
|
+
+ `- Light: ${lightFilename} (seed: ${baseSeed})\n`
|
|
218
|
+
+ `- Dark: ${darkFilename} (seed: ${baseSeed})\n\n`
|
|
219
|
+
+ `\`\`\`html\n${html}\n\`\`\``
|
|
220
|
+
|
|
221
|
+
return toLosslessJson({
|
|
222
|
+
summary,
|
|
223
|
+
prompt,
|
|
224
|
+
style,
|
|
225
|
+
aspect_ratio: aspectRatio,
|
|
226
|
+
light: {
|
|
227
|
+
path: lightPath,
|
|
228
|
+
url: lightUrl,
|
|
229
|
+
filename: lightFilename,
|
|
230
|
+
width: lightGen.width || aspectPixels[0],
|
|
231
|
+
height: lightGen.height || aspectPixels[1],
|
|
232
|
+
attachment: lightAttachment,
|
|
233
|
+
},
|
|
234
|
+
dark: {
|
|
235
|
+
path: darkPath,
|
|
236
|
+
url: darkUrl,
|
|
237
|
+
filename: darkFilename,
|
|
238
|
+
width: darkGen.width || aspectPixels[0],
|
|
239
|
+
height: darkGen.height || aspectPixels[1],
|
|
240
|
+
attachment: darkAttachment,
|
|
241
|
+
},
|
|
242
|
+
html_snippet: html,
|
|
243
|
+
css_snippet: css,
|
|
244
|
+
attachment: lightAttachment,
|
|
245
|
+
})
|
|
246
|
+
},
|
|
247
|
+
}),
|
|
248
|
+
)
|
|
249
|
+
}, 'dsh-image-gen: tool generate_theme_pair')
|
|
250
|
+
}
|
package/lib/tools/ui-asset.js
CHANGED
|
@@ -123,7 +123,7 @@ export function registerUiAssetTools(ctx, deps) {
|
|
|
123
123
|
|
|
124
124
|
const provider = PROVIDER_KEYS.includes(cfg.provider) ? cfg.provider : 'fal'
|
|
125
125
|
let subscriptionImages
|
|
126
|
-
try { subscriptionImages = ctx.get && ctx.get('subscriptionImages') } catch
|
|
126
|
+
try { subscriptionImages = ctx.get && ctx.get('subscriptionImages') } catch { subscriptionImages = undefined }
|
|
127
127
|
|
|
128
128
|
const providers = makeProviders(
|
|
129
129
|
{ fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg, subscriptionImages },
|
package/lib/updater.js
CHANGED
|
@@ -3,7 +3,6 @@ import { existsSync, readFileSync } from 'node:fs'
|
|
|
3
3
|
import { readFile } from 'node:fs/promises'
|
|
4
4
|
import { homedir } from 'node:os'
|
|
5
5
|
import { basename, dirname, isAbsolute, resolve } from 'node:path'
|
|
6
|
-
import { fileURLToPath } from 'node:url'
|
|
7
6
|
import { isLoopbackAddress, isPrivateLanAddress, extractHostName } from './security.js'
|
|
8
7
|
|
|
9
8
|
/**
|
|
@@ -21,14 +20,6 @@ function header(request, name) {
|
|
|
21
20
|
return Array.isArray(value) ? value[0] : value
|
|
22
21
|
}
|
|
23
22
|
|
|
24
|
-
function isLoopback(value) {
|
|
25
|
-
return isLoopbackAddress(value)
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function isPrivateLan(value) {
|
|
29
|
-
return isPrivateLanAddress(value)
|
|
30
|
-
}
|
|
31
|
-
|
|
32
23
|
export function isTrustedUpdateRequest(request) {
|
|
33
24
|
if (header(request, UPDATE_HEADER) !== '1') return false
|
|
34
25
|
const remote = request.socket?.remoteAddress
|
package/lib/vault.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { existsSync, unlinkSync } from 'node:fs'
|
|
2
|
+
import { readHistory, writeHistory } from './history.js'
|
|
3
|
+
import { isTrustedLocalRequest } from './security.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Match image dimensions against an aspect ratio label or ratio value.
|
|
7
|
+
*/
|
|
8
|
+
export function matchAspectRatio(width, height, targetAspect) {
|
|
9
|
+
if (!targetAspect || targetAspect === 'any' || targetAspect === 'all') return true
|
|
10
|
+
if (!width || !height) return false
|
|
11
|
+
const ratio = width / height
|
|
12
|
+
|
|
13
|
+
switch (targetAspect) {
|
|
14
|
+
case '1:1':
|
|
15
|
+
case 'square':
|
|
16
|
+
return Math.abs(ratio - 1.0) < 0.1
|
|
17
|
+
case '16:9':
|
|
18
|
+
case 'landscape':
|
|
19
|
+
return Math.abs(ratio - (16 / 9)) < 0.15
|
|
20
|
+
case '9:16':
|
|
21
|
+
case 'portrait':
|
|
22
|
+
return Math.abs(ratio - (9 / 16)) < 0.15
|
|
23
|
+
case '4:3':
|
|
24
|
+
return Math.abs(ratio - (4 / 3)) < 0.15
|
|
25
|
+
case '3:4':
|
|
26
|
+
return Math.abs(ratio - (3 / 4)) < 0.15
|
|
27
|
+
case '21:9':
|
|
28
|
+
case 'ultrawide':
|
|
29
|
+
return Math.abs(ratio - (21 / 9)) < 0.2
|
|
30
|
+
default:
|
|
31
|
+
return true
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Filter, sort, and paginate vault entries with path sanitization.
|
|
37
|
+
*/
|
|
38
|
+
export function filterVaultEntries(entries, options = {}, existsFn = existsSync) {
|
|
39
|
+
const {
|
|
40
|
+
q = '',
|
|
41
|
+
provider = '',
|
|
42
|
+
aspect = '',
|
|
43
|
+
sort = 'newest',
|
|
44
|
+
offset = 0,
|
|
45
|
+
limit = 30,
|
|
46
|
+
} = options
|
|
47
|
+
|
|
48
|
+
const safeOffset = Math.max(0, Number.parseInt(offset, 10) || 0)
|
|
49
|
+
const safeLimit = Math.min(100, Math.max(1, Number.parseInt(limit, 10) || 30))
|
|
50
|
+
const cleanQ = String(q).trim().toLowerCase()
|
|
51
|
+
const cleanProvider = String(provider).trim().toLowerCase()
|
|
52
|
+
|
|
53
|
+
const existing = (Array.isArray(entries) ? entries : []).filter((e) => e && e.path && existsFn(e.path))
|
|
54
|
+
|
|
55
|
+
const filtered = existing.filter((e) => {
|
|
56
|
+
if (cleanQ && !String(e.prompt || '').toLowerCase().includes(cleanQ)) {
|
|
57
|
+
return false
|
|
58
|
+
}
|
|
59
|
+
if (cleanProvider && String(e.provider || '').toLowerCase() !== cleanProvider) {
|
|
60
|
+
return false
|
|
61
|
+
}
|
|
62
|
+
if (aspect && !matchAspectRatio(e.width, e.height, aspect)) {
|
|
63
|
+
return false
|
|
64
|
+
}
|
|
65
|
+
return true
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
filtered.sort((a, b) => {
|
|
69
|
+
const timeA = a.createdAt ? Date.parse(a.createdAt) : 0
|
|
70
|
+
const timeB = b.createdAt ? Date.parse(b.createdAt) : 0
|
|
71
|
+
if (sort === 'oldest') {
|
|
72
|
+
return timeA - timeB
|
|
73
|
+
}
|
|
74
|
+
return timeB - timeA
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const total = filtered.length
|
|
78
|
+
const sliced = filtered.slice(safeOffset, safeOffset + safeLimit)
|
|
79
|
+
|
|
80
|
+
const items = sliced.map((e) => {
|
|
81
|
+
const { path: _discardPath, ...safeEntry } = e
|
|
82
|
+
return {
|
|
83
|
+
id: e.id || e.attachmentId || String(e.createdAt || Math.random()),
|
|
84
|
+
...safeEntry,
|
|
85
|
+
thumbnailUrl: e.thumbnailUrl || (e.attachmentId ? `/dsh-image-gen/image?id=${encodeURIComponent(e.attachmentId)}` : ''),
|
|
86
|
+
url: e.attachmentId ? `/dsh-image-gen/image?id=${encodeURIComponent(e.attachmentId)}` : '',
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
items,
|
|
92
|
+
total,
|
|
93
|
+
offset: safeOffset,
|
|
94
|
+
limit: safeLimit,
|
|
95
|
+
hasMore: safeOffset + safeLimit < total,
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Delete entry and associated disk files (image + sidecar json).
|
|
101
|
+
*/
|
|
102
|
+
export async function deleteVaultEntry(id) {
|
|
103
|
+
if (!id) return { success: false, error: 'Missing entry id' }
|
|
104
|
+
const entries = await readHistory()
|
|
105
|
+
const idx = entries.findIndex((e) => e.id === id || e.attachmentId === id)
|
|
106
|
+
if (idx === -1) {
|
|
107
|
+
return { success: false, error: 'Vault entry not found' }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const [entry] = entries.splice(idx, 1)
|
|
111
|
+
if (entry && entry.path) {
|
|
112
|
+
try {
|
|
113
|
+
unlinkSync(entry.path)
|
|
114
|
+
} catch { /* ignore missing file */ }
|
|
115
|
+
try {
|
|
116
|
+
unlinkSync(entry.path.replace(/\.[^.]+$/, '.json'))
|
|
117
|
+
} catch { /* ignore missing sidecar */ }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
await writeHistory(entries)
|
|
121
|
+
return { success: true, id }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Register vault HTTP routes on DSH webServer.
|
|
126
|
+
*/
|
|
127
|
+
export function registerVaultRoutes(ctx) {
|
|
128
|
+
ctx.effect(() => ctx.webServer.register({
|
|
129
|
+
kind: 'exact',
|
|
130
|
+
path: '/dsh-image-gen/vault',
|
|
131
|
+
handler: async (req, res) => {
|
|
132
|
+
if (!isTrustedLocalRequest(req)) {
|
|
133
|
+
res.writeHead(403, { 'Content-Type': 'application/json' })
|
|
134
|
+
res.end(JSON.stringify({ error: 'forbidden' }))
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (req.method === 'GET') {
|
|
139
|
+
const u = new URL(req.url, 'http://localhost')
|
|
140
|
+
const q = u.searchParams.get('q') || ''
|
|
141
|
+
const provider = u.searchParams.get('provider') || ''
|
|
142
|
+
const aspect = u.searchParams.get('aspect') || ''
|
|
143
|
+
const sort = u.searchParams.get('sort') || 'newest'
|
|
144
|
+
const offset = u.searchParams.get('offset') || '0'
|
|
145
|
+
const limit = u.searchParams.get('limit') || '30'
|
|
146
|
+
|
|
147
|
+
const entries = await readHistory()
|
|
148
|
+
const result = filterVaultEntries(entries, { q, provider, aspect, sort, offset, limit })
|
|
149
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
150
|
+
res.end(JSON.stringify({ ok: true, ...result }))
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (req.method === 'DELETE') {
|
|
155
|
+
const u = new URL(req.url, 'http://localhost')
|
|
156
|
+
let id = u.searchParams.get('id')
|
|
157
|
+
|
|
158
|
+
if (!id) {
|
|
159
|
+
try {
|
|
160
|
+
const chunks = []
|
|
161
|
+
for await (const chunk of req) chunks.push(chunk)
|
|
162
|
+
const body = JSON.parse(Buffer.concat(chunks).toString('utf-8'))
|
|
163
|
+
id = body.id
|
|
164
|
+
} catch { /* body parse error */ }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (!id) {
|
|
168
|
+
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
169
|
+
res.end(JSON.stringify({ ok: false, error: 'Missing id parameter' }))
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const result = await deleteVaultEntry(id)
|
|
174
|
+
res.writeHead(result.success ? 200 : 404, { 'Content-Type': 'application/json' })
|
|
175
|
+
res.end(JSON.stringify({ ok: result.success, ...result }))
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
res.writeHead(405, { 'Content-Type': 'application/json' })
|
|
180
|
+
res.end(JSON.stringify({ error: 'Method not allowed' }))
|
|
181
|
+
},
|
|
182
|
+
}), 'dsh-image-gen: vault route')
|
|
183
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-image-gen",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"description": "Image generation for DeepSeek Harness: a generate_image tool with pluggable providers — the FAL queue, any OpenAI-compatible images API, or a ChatGPT/Grok subscription with no API key at all. The picture is shown inline in the conversation; the model receives either a link (works with any chat model) or the image itself (needs dsh-vision-bridge or a vision-capable model).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
|
@@ -71,5 +71,11 @@
|
|
|
71
71
|
"scripts": {
|
|
72
72
|
"test": "node scripts/build-client.mjs && node --check lib/client.js && node --test test/*.test.mjs",
|
|
73
73
|
"build:client": "node scripts/build-client.mjs"
|
|
74
|
+
},
|
|
75
|
+
"publishConfig": {
|
|
76
|
+
"access": "public"
|
|
77
|
+
},
|
|
78
|
+
"devDependencies": {
|
|
79
|
+
"@deepseek-ai/schemastery": "^3.18.1"
|
|
74
80
|
}
|
|
75
81
|
}
|