@goodandready/dsh-image-gen 0.11.6 → 0.11.8
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 +6 -0
- package/README.ru.md +6 -0
- package/README.zh.md +6 -0
- package/lib/client.js +0 -1
- package/lib/commands.js +2 -2
- package/lib/config-schema.js +302 -0
- package/lib/index.js +53 -410
- package/lib/providers.js +1 -1
- package/lib/resolve-source.js +67 -0
- package/lib/settings-route.js +49 -38
- package/lib/tools/frontend.js +1 -0
- package/lib/tools/generation.js +10 -1
- package/lib/tools/processing-advanced.js +1 -0
- package/lib/vault.js +3 -2
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -167,6 +167,12 @@
|
|
|
167
167
|
* **Cache & History Consistency**: Ensured content-addressed disk cache hits synchronize seamlessly with generation history and sidecar metadata.
|
|
168
168
|
* **Test Suite Expansion**: Added `test/batch4-hardening.test.mjs`, expanding test coverage to **128 automated unit tests (100% pass)**.
|
|
169
169
|
|
|
170
|
+
### 🚀 What's New in v0.11.8
|
|
171
|
+
* **Bounded Config Payloads (#313)**: `PUT /dsh-image-gen/config` now uses bounded request streaming (`readBoundedRequestBody`) with a 64 KB cap, returning HTTP 413 `Payload Too Large` on oversized bodies to prevent memory exhaustion DoS.
|
|
172
|
+
* **Build Parity Gate (#316)**: `scripts/build-client.mjs` supports `--check` mode, enforcing client distribution parity in `npm test`.
|
|
173
|
+
* **Architecture Decomposition (#317)**: `lib/index.js` simplified into a lightweight facade (<400 lines) with dedicated `lib/config-schema.js` and `lib/resolve-source.js`.
|
|
174
|
+
* **Runtime & Permissions Polish (#318, #319)**: Idempotent startup permissions repair and removal of legacy `loc.get` fallback in favor of standard Cordis translation runtime.
|
|
175
|
+
|
|
170
176
|
### 🚀 What's New in v0.10.15
|
|
171
177
|
* **Loop Guard Zero-Limit Bypass (#212)**: Fixed an edge case where setting `loopGuardLimit: 0` (configured to disable protection) enforced a limit of 1 due to `Math.max(1, limit)`. Setting limit to 0 now properly bypasses loop checks.
|
|
172
178
|
* **Security Hardening (Token Masking)**: Added automatic pattern masking for Replicate API tokens (`r8_...`) and Google Gemini API keys (`AIza...`) in `sanitizeErrorAndLogs`.
|
package/README.ru.md
CHANGED
|
@@ -34,6 +34,12 @@
|
|
|
34
34
|
</div>
|
|
35
35
|
|
|
36
36
|
---
|
|
37
|
+
## 🚀 Обновления v0.11.8: Защита конфигурационных маршрутов, паритет сборки и модульная архитектура
|
|
38
|
+
* **Ограничение размера тела настроек (#313)**: Маршрут `PUT /dsh-image-gen/config` защищён лимитом 64 КБ с возвратом HTTP 413 `Payload Too Large` при превышении, исключая DoS ядра через исчерпание кучи.
|
|
39
|
+
* **Контроль паритета сборки клиента (#316)**: В `scripts/build-client.mjs` добавлен режим `--check`, встроенный в `npm test` для предотвращения рассинхронизации `lib/client.js` с фрагментами `src/client/*`.
|
|
40
|
+
* **Декомпозиция архитектуры (#317)**: `lib/index.js` сокращён до тонкого фасада (<400 строк); схема конфигурации вынесена в `lib/config-schema.js`, а резолвер источников — в `lib/resolve-source.js`.
|
|
41
|
+
* **Оптимизация инициализации и клиента (#318, #319)**: Устранено дублирование проверки прав истории при старте плагина; удалён устаревший fallback `loc.get` в пользу штатного API Cordis.
|
|
42
|
+
|
|
37
43
|
## 🚀 Обновления v0.10.26: Архитектурная декомпозиция и чистота репозитория (#235, #238, #239)
|
|
38
44
|
* **Архитектурная декомпозиция модулей (#239)**: Все серверные модули в `lib/` приведены к проектному стандарту <= 600 строк: генерация пакетов вынесена в `lib/tools/generation-pack.js` (74 строки, `generation.js` снижен до 547 строк), кэш и история — в `lib/history.js` (93 строки), вложения и сайдкары — в `lib/attachment-helper.js` (82 строки, `index.js` снижен до 557 строк). Клиентский код полностью разбит на 15 модулей в `src/client/*` (каждый <= 337 строк).
|
|
39
45
|
* **Чистота репозитория (#235)**: Служебные файлы агентов (`AGENTS.md`, `index.md`) сняты с git-отслеживания и защищены правилами в `.gitignore` и `.gitattributes`.
|
package/README.zh.md
CHANGED
|
@@ -122,3 +122,9 @@ MIT © [GooDAnDReaDY](https://github.com/GooDAnDReaDY)
|
|
|
122
122
|
* **`generate_style_matrix` (#286)**: 2x2 风格矩阵探索与双盲对比,并发生成 4 种美学预设切片并支持一键应用。
|
|
123
123
|
* **`对话内局部重绘画布` (#285)**: 在消息卡片上直接唤起交互式笔刷画布,支持选区涂抹与即时 Inpainting 局部重绘。
|
|
124
124
|
* **`智能服务商自动故障转移链` (#282)**: 主服务商 429、5xx、超时或额度耗尽时自动无缝切换备选渠道。
|
|
125
|
+
|
|
126
|
+
## 🛡️ 安全性
|
|
127
|
+
* **配置请求大小限制 (#313)**:`PUT /dsh-image-gen/config` 限制请求体最大 64 KB,超出返回 HTTP 413,防止内存耗尽 DoS。
|
|
128
|
+
* **客户端构建校验 (#316)**:`scripts/build-client.mjs` 支持 `--check` 模式,确保构建一致性。
|
|
129
|
+
* **架构解耦优化 (#317)**:`lib/index.js` 精简为轻量级门面(<400 行),拆分 `config-schema.js` 与 `resolve-source.js`。
|
|
130
|
+
* **启动与运行时清理 (#318, #319)**:优化历史记录权限修复,移除废弃的 `loc.get` 调用。
|
package/lib/client.js
CHANGED
|
@@ -56,7 +56,6 @@ window.__ModuleLoader__.load({
|
|
|
56
56
|
const loc = ctx?.locale || props?.ctx?.locale
|
|
57
57
|
if (loc) {
|
|
58
58
|
if (typeof loc.t === 'function') return (k, def) => loc.t(NS + '.' + k) || loc.t(k) || def || k
|
|
59
|
-
if (typeof loc.get === 'function') return (k, def) => loc.get(NS + '.' + k) || loc.get(k) || def || k
|
|
60
59
|
}
|
|
61
60
|
return (k, def) => def || k
|
|
62
61
|
}
|
package/lib/commands.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// commands.js — Top-level slash command /image for direct generation without LLM reasoning (#152)
|
|
2
2
|
|
|
3
|
-
import { makeProviders, resolveStylePreset } from './providers.js'
|
|
3
|
+
import { makeProviders, resolveStylePreset, applyStylePreset } from './providers.js'
|
|
4
4
|
import { ASPECT_RATIOS } from './providers/shared-helpers.js'
|
|
5
5
|
import { calculateGenerationCost } from './cost-meter.js'
|
|
6
6
|
|
|
@@ -82,7 +82,7 @@ export function registerImageCommand(ctx, deps) {
|
|
|
82
82
|
const seed = flags.seed ? Number(flags.seed) : Math.floor(Math.random() * 2147483647)
|
|
83
83
|
|
|
84
84
|
const styleInfo = resolveStylePreset(effectiveStyle, flags.negative_prompt, flags.guidance_scale)
|
|
85
|
-
const effectivePrompt =
|
|
85
|
+
const effectivePrompt = applyStylePreset(prompt, effectiveStyle)
|
|
86
86
|
|
|
87
87
|
const job = {
|
|
88
88
|
prompt: effectivePrompt,
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// lib/config-schema.js — Zod configuration schema and normalization helpers (#317)
|
|
2
|
+
|
|
3
|
+
import z from '@deepseek-ai/schemastery'
|
|
4
|
+
import { IMAGE_SIZES, OUTPUT_FORMATS, PROVIDER_KEYS } from './providers.js'
|
|
5
|
+
|
|
6
|
+
// Polyfill for .volatile() schema annotation if runtime schemastery lacks it (#295)
|
|
7
|
+
if (typeof z.prototype?.volatile !== 'function') {
|
|
8
|
+
z.prototype.volatile = function volatile() {
|
|
9
|
+
if (this.meta && this.meta.volatile) throw new TypeError('volatile schema is already wrapped')
|
|
10
|
+
return typeof this.extra === 'function' ? this.extra('volatile', true) : this
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const Config = z.object({
|
|
15
|
+
enabled: z
|
|
16
|
+
.boolean()
|
|
17
|
+
.description('Master switch for image generation. When false, tools refuse to execute.')
|
|
18
|
+
.default(true)
|
|
19
|
+
.volatile(),
|
|
20
|
+
provider: z
|
|
21
|
+
.string()
|
|
22
|
+
.description(`Which provider generates the image. One of: ${PROVIDER_KEYS.join(', ')}. `
|
|
23
|
+
+ '"fal" uses the FAL queue below; "custom" uses the OpenAI-compatible API configured under it.')
|
|
24
|
+
.default('fal')
|
|
25
|
+
.volatile(),
|
|
26
|
+
model: z
|
|
27
|
+
.string()
|
|
28
|
+
.description('FAL model id, called as {baseURL}/{model}.')
|
|
29
|
+
.default('fal-ai/flux-2/klein/9b')
|
|
30
|
+
.volatile(),
|
|
31
|
+
apiKeyEnv: z
|
|
32
|
+
.string()
|
|
33
|
+
.role('credential-ref')
|
|
34
|
+
.description('Credential reference / env var holding the FAL API key (the "Key " auth prefix is added automatically when missing).')
|
|
35
|
+
.default('FAL_API_KEY')
|
|
36
|
+
.volatile(),
|
|
37
|
+
baseURL: z
|
|
38
|
+
.string()
|
|
39
|
+
.description('FAL queue base URL.')
|
|
40
|
+
.default('https://queue.fal.run')
|
|
41
|
+
.volatile(),
|
|
42
|
+
defaultSize: z
|
|
43
|
+
.string()
|
|
44
|
+
.description(`Default image size when the tool call omits image_size. One of: ${IMAGE_SIZES.join(', ')}.`)
|
|
45
|
+
.default('landscape_4_3')
|
|
46
|
+
.volatile(),
|
|
47
|
+
defaultFormat: z
|
|
48
|
+
.string()
|
|
49
|
+
.description(`Default image format when the tool call omits output_format. One of: ${OUTPUT_FORMATS.join(', ')}.`)
|
|
50
|
+
.default('png')
|
|
51
|
+
.volatile(),
|
|
52
|
+
defaultAspectRatio: z
|
|
53
|
+
.string()
|
|
54
|
+
.description('Default aspect ratio (square, landscape_16_9, portrait_9_16, etc.) used when prompt does not specify dimensions.')
|
|
55
|
+
.default('square')
|
|
56
|
+
.volatile(),
|
|
57
|
+
deliverAs: z
|
|
58
|
+
.string()
|
|
59
|
+
.description('How generated images are returned: "link" keeps them on disk and embeds an inline preview link; "attachment" embeds full image bytes directly in the chat.')
|
|
60
|
+
.default('link')
|
|
61
|
+
.volatile(),
|
|
62
|
+
outputDir: z
|
|
63
|
+
.string()
|
|
64
|
+
.description('Directory relative to workspace where generated images are saved.')
|
|
65
|
+
.default('generated-images')
|
|
66
|
+
.volatile(),
|
|
67
|
+
pollIntervalMs: z
|
|
68
|
+
.number()
|
|
69
|
+
.description('Polling interval for FAL queue status in milliseconds.')
|
|
70
|
+
.default(500)
|
|
71
|
+
.volatile(),
|
|
72
|
+
timeoutMs: z
|
|
73
|
+
.number()
|
|
74
|
+
.description('Hard timeout for image generation in milliseconds.')
|
|
75
|
+
.default(180000)
|
|
76
|
+
.volatile(),
|
|
77
|
+
customBaseURL: z
|
|
78
|
+
.string()
|
|
79
|
+
.description('Base URL for OpenAI-compatible images API (provider=custom), e.g. https://api.openai.com/v1.')
|
|
80
|
+
.default('https://api.openai.com/v1')
|
|
81
|
+
.volatile(),
|
|
82
|
+
customModel: z
|
|
83
|
+
.string()
|
|
84
|
+
.description('Model name for OpenAI-compatible images API, e.g. dall-e-3 or flux-schnell.')
|
|
85
|
+
.default('dall-e-3')
|
|
86
|
+
.volatile(),
|
|
87
|
+
customKeyEnv: z
|
|
88
|
+
.string()
|
|
89
|
+
.role('credential-ref')
|
|
90
|
+
.description('Credential reference / env var holding the custom provider API key.')
|
|
91
|
+
.default('CUSTOM_IMAGE_API_KEY')
|
|
92
|
+
.volatile(),
|
|
93
|
+
customSize: z
|
|
94
|
+
.string()
|
|
95
|
+
.description('Image size sent to custom API, e.g. 1024x1024.')
|
|
96
|
+
.default('1024x1024')
|
|
97
|
+
.volatile(),
|
|
98
|
+
replicateModel: z
|
|
99
|
+
.string()
|
|
100
|
+
.description('Replicate model identifier in format owner/model or owner/model:version.')
|
|
101
|
+
.default('black-forest-labs/flux-schnell')
|
|
102
|
+
.volatile(),
|
|
103
|
+
replicateKeyEnv: z
|
|
104
|
+
.string()
|
|
105
|
+
.role('credential-ref')
|
|
106
|
+
.description('Credential reference / env var holding the Replicate API token.')
|
|
107
|
+
.default('REPLICATE_API_TOKEN')
|
|
108
|
+
.volatile(),
|
|
109
|
+
grokKeyEnv: z
|
|
110
|
+
.string()
|
|
111
|
+
.role('credential-ref')
|
|
112
|
+
.description('Credential reference / env var holding the xAI Grok API key.')
|
|
113
|
+
.default('XAI_API_KEY')
|
|
114
|
+
.volatile(),
|
|
115
|
+
chatgptKeyEnv: z
|
|
116
|
+
.string()
|
|
117
|
+
.role('credential-ref')
|
|
118
|
+
.description('Credential reference / env var holding the OpenAI ChatGPT API key.')
|
|
119
|
+
.default('OPENAI_API_KEY')
|
|
120
|
+
.volatile(),
|
|
121
|
+
seedreamModel: z
|
|
122
|
+
.string()
|
|
123
|
+
.description('Volcengine SeaDream model deployment endpoint/ID.')
|
|
124
|
+
.default('doubao-seedream-3.0-t2i')
|
|
125
|
+
.volatile(),
|
|
126
|
+
seedreamKeyEnv: z
|
|
127
|
+
.string()
|
|
128
|
+
.role('credential-ref')
|
|
129
|
+
.description('Credential reference / env var holding the Volcengine SeaDream API key.')
|
|
130
|
+
.default('SEEDREAM_API_KEY')
|
|
131
|
+
.volatile(),
|
|
132
|
+
seedreamBaseURL: z
|
|
133
|
+
.string()
|
|
134
|
+
.description('Base URL for Volcengine SeaDream API.')
|
|
135
|
+
.default('https://ark.cn-beijing.volces.com/api/v3')
|
|
136
|
+
.volatile(),
|
|
137
|
+
geminiModel: z
|
|
138
|
+
.string()
|
|
139
|
+
.description('Google Gemini Imagen model identifier.')
|
|
140
|
+
.default('imagen-3.0-generate-002')
|
|
141
|
+
.volatile(),
|
|
142
|
+
geminiKeyEnv: z
|
|
143
|
+
.string()
|
|
144
|
+
.role('credential-ref')
|
|
145
|
+
.description('Credential reference / env var holding the Google Gemini API key.')
|
|
146
|
+
.default('GEMINI_API_KEY')
|
|
147
|
+
.volatile(),
|
|
148
|
+
localKind: z
|
|
149
|
+
.string()
|
|
150
|
+
.description('Local generation backend kind: "automatic1111" (WebUI) or "comfyui".')
|
|
151
|
+
.default('automatic1111')
|
|
152
|
+
.volatile(),
|
|
153
|
+
localBaseURL: z
|
|
154
|
+
.string()
|
|
155
|
+
.description('Base URL for local backend (e.g., http://127.0.0.1:7860 or http://127.0.0.1:8188).')
|
|
156
|
+
.default('http://127.0.0.1:7860')
|
|
157
|
+
.volatile(),
|
|
158
|
+
localModel: z
|
|
159
|
+
.string()
|
|
160
|
+
.description('Model checkpoint name for local generation.')
|
|
161
|
+
.default('v1-5-pruned-emaonly.safetensors')
|
|
162
|
+
.volatile(),
|
|
163
|
+
localSteps: z
|
|
164
|
+
.number()
|
|
165
|
+
.description('Sampling steps for local generation.')
|
|
166
|
+
.default(20)
|
|
167
|
+
.volatile(),
|
|
168
|
+
localCfg: z
|
|
169
|
+
.number()
|
|
170
|
+
.description('CFG scale for local generation.')
|
|
171
|
+
.default(7.0)
|
|
172
|
+
.volatile(),
|
|
173
|
+
subscriptionQuality: z
|
|
174
|
+
.string()
|
|
175
|
+
.description('Image quality tier for subscription accounts: "standard" or "hd".')
|
|
176
|
+
.default('standard')
|
|
177
|
+
.volatile(),
|
|
178
|
+
autoEnhancePrompt: z
|
|
179
|
+
.boolean()
|
|
180
|
+
.description('Automatically enhance short prompts using LLM before generation.')
|
|
181
|
+
.default(false)
|
|
182
|
+
.volatile(),
|
|
183
|
+
defaultStylePreset: z
|
|
184
|
+
.string()
|
|
185
|
+
.description('Default visual style applied to generated images (e.g. photo, anime, digital-art, cinematic).')
|
|
186
|
+
.default('none')
|
|
187
|
+
.volatile(),
|
|
188
|
+
enhancePrompt: z
|
|
189
|
+
.boolean()
|
|
190
|
+
.description('When true, short or vague prompts are expanded by the chat model before image generation.')
|
|
191
|
+
.default(false)
|
|
192
|
+
.volatile(),
|
|
193
|
+
enhanceModel: z
|
|
194
|
+
.string()
|
|
195
|
+
.description('Model name to use for prompt enhancement (empty uses active chat model).')
|
|
196
|
+
.default('')
|
|
197
|
+
.volatile(),
|
|
198
|
+
enhanceBelowChars: z
|
|
199
|
+
.number()
|
|
200
|
+
.description('Prompts shorter than this character count trigger prompt enhancement.')
|
|
201
|
+
.default(40)
|
|
202
|
+
.volatile(),
|
|
203
|
+
stylePreset: z
|
|
204
|
+
.string()
|
|
205
|
+
.description('Default style preset applied to prompts (e.g., "photographic", "anime", "cinematic"). "none" disables.')
|
|
206
|
+
.default('none')
|
|
207
|
+
.volatile(),
|
|
208
|
+
cacheBySeed: z
|
|
209
|
+
.boolean()
|
|
210
|
+
.description('When true, identical prompt+seed requests reuse previous generation from disk cache.')
|
|
211
|
+
.default(true)
|
|
212
|
+
.volatile(),
|
|
213
|
+
cacheByPrompt: z
|
|
214
|
+
.boolean()
|
|
215
|
+
.description('When true and seed is random, identical prompts return cached result instead of re-generating.')
|
|
216
|
+
.default(false)
|
|
217
|
+
.volatile(),
|
|
218
|
+
historyLimit: z
|
|
219
|
+
.number()
|
|
220
|
+
.description('Maximum number of generation history records to retain in ~/.dsh/image-gen-history.json.')
|
|
221
|
+
.default(500)
|
|
222
|
+
.volatile(),
|
|
223
|
+
pruneDays: z
|
|
224
|
+
.number()
|
|
225
|
+
.description('Prune history records older than this many days (0 = disable time-based pruning).')
|
|
226
|
+
.default(30)
|
|
227
|
+
.volatile(),
|
|
228
|
+
qualityGate: z
|
|
229
|
+
.boolean()
|
|
230
|
+
.description('Automatically inspect generated image sharpness and detect blank/corrupt outputs, retrying once if needed.')
|
|
231
|
+
.default(true)
|
|
232
|
+
.volatile(),
|
|
233
|
+
dailyBudgetUsd: z
|
|
234
|
+
.number()
|
|
235
|
+
.description('Daily image generation spending budget in USD. 0 (default) disables limit enforcement.')
|
|
236
|
+
.default(0)
|
|
237
|
+
.volatile(),
|
|
238
|
+
loopGuardLimit: z
|
|
239
|
+
.number()
|
|
240
|
+
.description('Maximum consecutive image generations per session without user interaction. 0 disables protection.')
|
|
241
|
+
.default(3)
|
|
242
|
+
.volatile(),
|
|
243
|
+
diskCache: z
|
|
244
|
+
.boolean()
|
|
245
|
+
.description('Content-addressed disk caching for identical generations (<50ms retrieval, zero API cost). On by default.')
|
|
246
|
+
.default(true)
|
|
247
|
+
.volatile(),
|
|
248
|
+
fallbackProviders: z
|
|
249
|
+
.array(z.string())
|
|
250
|
+
.description('Ordered list of fallback providers to cascade to if the primary encounters 429, 5xx, or quota limits.')
|
|
251
|
+
.default([])
|
|
252
|
+
.volatile(),
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
export function isVolatileRef(value) {
|
|
256
|
+
return !!value && typeof value === 'object' && !Array.isArray(value) && typeof value.get === 'function'
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function plainConfig(cfg) {
|
|
260
|
+
if (isVolatileRef(cfg)) return plainConfig(cfg.get())
|
|
261
|
+
if (!cfg || typeof cfg !== 'object') return cfg
|
|
262
|
+
const out = {}
|
|
263
|
+
for (const key of Object.keys(cfg)) {
|
|
264
|
+
const value = cfg[key]
|
|
265
|
+
out[key] = isVolatileRef(value) ? value.get() : value
|
|
266
|
+
}
|
|
267
|
+
return out
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function volatileConfig(cfg) {
|
|
271
|
+
const plain = plainConfig(cfg)
|
|
272
|
+
if (!plain || typeof plain !== 'object') return plain
|
|
273
|
+
const out = {}
|
|
274
|
+
for (const [key, field] of Object.entries(Config.dict || {})) {
|
|
275
|
+
if (field?.meta?.volatile && Object.hasOwn(plain, key)) {
|
|
276
|
+
out[key] = plain[key]
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return out
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function publicConfig(cfg) {
|
|
283
|
+
return plainConfig(cfg)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function sanitizeRaw(raw) {
|
|
287
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {}
|
|
288
|
+
const cleaned = {}
|
|
289
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
290
|
+
if (v && typeof v === 'object' && !Array.isArray(v) && typeof v.get !== 'function' && Object.keys(v).length === 0) {
|
|
291
|
+
continue
|
|
292
|
+
}
|
|
293
|
+
cleaned[k] = v
|
|
294
|
+
}
|
|
295
|
+
return cleaned
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function ensureConfig(raw) {
|
|
299
|
+
const cleaned = sanitizeRaw(raw)
|
|
300
|
+
const plain = plainConfig(cleaned)
|
|
301
|
+
return Config(plain)
|
|
302
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -12,9 +12,7 @@
|
|
|
12
12
|
// Credentials, or $DSH_HOME/.credentials.yaml) under the configured reference,
|
|
13
13
|
// falling back to the process environment.
|
|
14
14
|
|
|
15
|
-
import z from '@deepseek-ai/schemastery'
|
|
16
15
|
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
17
|
-
import { readFile, stat } from 'node:fs/promises'
|
|
18
16
|
import { isTrustedLocalRequest } from './security.js'
|
|
19
17
|
import { saveAndAttachResult } from './attachment-helper.js'
|
|
20
18
|
import {
|
|
@@ -41,7 +39,7 @@ import {
|
|
|
41
39
|
testProviderConnection,
|
|
42
40
|
} from './providers.js'
|
|
43
41
|
|
|
44
|
-
import { resolveConversationImage, analyzeImageWithVision } from './resolve-image.js'
|
|
42
|
+
import { resolveConversationImage, analyzeImageWithVision, validateImageSignature } from './resolve-image.js'
|
|
45
43
|
import { registerImageCommand } from './commands.js'
|
|
46
44
|
import { registerAllTools } from './register-tools.js'
|
|
47
45
|
import { registerPluginUpdater } from './updater.js'
|
|
@@ -49,6 +47,8 @@ import { registerVaultRoutes } from './vault.js'
|
|
|
49
47
|
import { registerSettingsRoutes } from './settings-route.js'
|
|
50
48
|
import { clearAllAnchors } from './anchor-helpers.js'
|
|
51
49
|
import { resetLoopGuard, getLoopGuardState } from './loop-guard.js'
|
|
50
|
+
import { Config, plainConfig, volatileConfig, publicConfig, ensureConfig } from './config-schema.js'
|
|
51
|
+
import { resolveSource } from './resolve-source.js'
|
|
52
52
|
|
|
53
53
|
export { IMAGE_SIZES, OUTPUT_FORMATS, PROVIDER_KEYS, buildSidecar, normalizeMediaType, resolveConversationImage, analyzeImageWithVision }
|
|
54
54
|
export { saveAndAttachResult }
|
|
@@ -65,6 +65,9 @@ export {
|
|
|
65
65
|
repairHistoryPermissions,
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
export { Config, plainConfig, volatileConfig, publicConfig, ensureConfig }
|
|
69
|
+
export { resolveSource }
|
|
70
|
+
|
|
68
71
|
export const name = '@goodandready/dsh-image-gen'
|
|
69
72
|
|
|
70
73
|
/** Settings namespace the Web card edits. */
|
|
@@ -75,346 +78,10 @@ const NS = 'dsh-image-gen'
|
|
|
75
78
|
const LEGACY_NS = 'dsh-fal-image-gen'
|
|
76
79
|
export const inject = ['tools', 'attachments', 'credentials', 'webServer', 'settings', 'llm', 'systemPrompt']
|
|
77
80
|
|
|
78
|
-
// Polyfill for .volatile() schema annotation if runtime schemastery lacks it (#295)
|
|
79
|
-
if (typeof z.prototype?.volatile !== 'function') {
|
|
80
|
-
z.prototype.volatile = function volatile() {
|
|
81
|
-
if (this.meta && this.meta.volatile) throw new TypeError('volatile schema is already wrapped')
|
|
82
|
-
return typeof this.extra === 'function' ? this.extra('volatile', true) : this
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export const Config = z.object({
|
|
87
|
-
enabled: z
|
|
88
|
-
.boolean()
|
|
89
|
-
.description('Master switch for image generation. When false, tools refuse to execute.')
|
|
90
|
-
.default(true)
|
|
91
|
-
.volatile(),
|
|
92
|
-
provider: z
|
|
93
|
-
.string()
|
|
94
|
-
.description(`Which provider generates the image. One of: ${PROVIDER_KEYS.join(', ')}. `
|
|
95
|
-
+ '"fal" uses the FAL queue below; "custom" uses the OpenAI-compatible API configured under it.')
|
|
96
|
-
.default('fal')
|
|
97
|
-
.volatile(),
|
|
98
|
-
model: z
|
|
99
|
-
.string()
|
|
100
|
-
.description('FAL model id, called as {baseURL}/{model}.')
|
|
101
|
-
.default('fal-ai/flux-2/klein/9b')
|
|
102
|
-
.volatile(),
|
|
103
|
-
apiKeyEnv: z
|
|
104
|
-
.string()
|
|
105
|
-
.role('credential-ref')
|
|
106
|
-
.description('Credential reference / env var holding the FAL API key (the "Key " auth prefix is added automatically when missing).')
|
|
107
|
-
.default('FAL_API_KEY')
|
|
108
|
-
.volatile(),
|
|
109
|
-
baseURL: z
|
|
110
|
-
.string()
|
|
111
|
-
.description('FAL queue base URL.')
|
|
112
|
-
.default('https://queue.fal.run')
|
|
113
|
-
.volatile(),
|
|
114
|
-
defaultSize: z
|
|
115
|
-
.string()
|
|
116
|
-
.description(`Default image size when the tool call omits image_size. One of: ${IMAGE_SIZES.join(', ')}.`)
|
|
117
|
-
.default('landscape_4_3')
|
|
118
|
-
.volatile(),
|
|
119
|
-
defaultFormat: z
|
|
120
|
-
.string()
|
|
121
|
-
.description(`Default output format. One of: ${OUTPUT_FORMATS.join(', ')}.`)
|
|
122
|
-
.default('png')
|
|
123
|
-
.volatile(),
|
|
124
|
-
pollIntervalMs: z
|
|
125
|
-
.number()
|
|
126
|
-
.description('Status polling interval in milliseconds.')
|
|
127
|
-
.default(2000)
|
|
128
|
-
.volatile(),
|
|
129
|
-
timeoutMs: z
|
|
130
|
-
.number()
|
|
131
|
-
.description('Total generation timeout in milliseconds (submit + poll + download).')
|
|
132
|
-
.default(180000)
|
|
133
|
-
.volatile(),
|
|
134
|
-
deliverAs: z
|
|
135
|
-
.string()
|
|
136
|
-
.description(
|
|
137
|
-
'How the finished image reaches the conversation. '
|
|
138
|
-
+ '"link": the tool returns a link and the card renders the picture from it — the chat model only ever sees text, so this works with any model. '
|
|
139
|
-
+ '"image": the tool returns the image itself — the picture is part of the result, which a text-only chat model cannot read, so this mode needs dsh-vision-bridge (or a vision-capable chat model).'
|
|
140
|
-
)
|
|
141
|
-
.default('link')
|
|
142
|
-
.volatile(),
|
|
143
|
-
customBaseURL: z
|
|
144
|
-
.string()
|
|
145
|
-
.description('provider=custom: API root without a trailing slash, e.g. https://api.openai.com/v1. '
|
|
146
|
-
+ 'The request goes to {customBaseURL}/images/generations.')
|
|
147
|
-
.default('')
|
|
148
|
-
.volatile(),
|
|
149
|
-
customModel: z
|
|
150
|
-
.string()
|
|
151
|
-
.description('provider=custom: model id, e.g. gpt-image-1.')
|
|
152
|
-
.default('')
|
|
153
|
-
.volatile(),
|
|
154
|
-
customKeyEnv: z
|
|
155
|
-
.string()
|
|
156
|
-
.role('credential-ref')
|
|
157
|
-
.description('provider=custom: credential reference / env var holding the API key. '
|
|
158
|
-
+ 'Empty means no authorization header, for gateways that need none.')
|
|
159
|
-
.default('OPENAI_API_KEY')
|
|
160
|
-
.volatile(),
|
|
161
|
-
|
|
162
|
-
replicateModel: z
|
|
163
|
-
.string()
|
|
164
|
-
.description('provider=replicate: model identifier on Replicate.')
|
|
165
|
-
.default('black-forest-labs/flux-schnell')
|
|
166
|
-
.volatile(),
|
|
167
|
-
replicateKeyEnv: z
|
|
168
|
-
.string()
|
|
169
|
-
.role('credential-ref')
|
|
170
|
-
.description('provider=replicate: credential reference / env var holding API token.')
|
|
171
|
-
.default('REPLICATE_API_TOKEN')
|
|
172
|
-
.volatile(),
|
|
173
|
-
|
|
174
|
-
subscriptionQuality: z
|
|
175
|
-
.string()
|
|
176
|
-
.description('provider=codex or grok: quality asked of the subscription — low, medium, high or empty '
|
|
177
|
-
+ 'for the provider default.')
|
|
178
|
-
.default('')
|
|
179
|
-
.volatile(),
|
|
180
|
-
customSize: z
|
|
181
|
-
.string()
|
|
182
|
-
.description('provider=custom: fixed size sent to the API, e.g. 1024x1024. '
|
|
183
|
-
+ 'Empty means the named size is translated automatically — set this only for an API picky about sizes.')
|
|
184
|
-
.default('')
|
|
185
|
-
.volatile(),
|
|
186
|
-
localKind: z
|
|
187
|
-
.string()
|
|
188
|
-
.description('provider=local: which local API to use — "comfyui" or "a1111".')
|
|
189
|
-
.default('comfyui')
|
|
190
|
-
.volatile(),
|
|
191
|
-
localBaseURL: z
|
|
192
|
-
.string()
|
|
193
|
-
.description('provider=local: server address, e.g. http://127.0.0.1:8188 (ComfyUI) or http://127.0.0.1:7860 (A1111).')
|
|
194
|
-
.default('')
|
|
195
|
-
.volatile(),
|
|
196
|
-
localModel: z
|
|
197
|
-
.string()
|
|
198
|
-
.description('provider=local: model id (A1111) or workflow/schema name (ComfyUI). Empty means the server default.')
|
|
199
|
-
.default('')
|
|
200
|
-
.volatile(),
|
|
201
|
-
localSteps: z
|
|
202
|
-
.number()
|
|
203
|
-
.description('provider=local: sampling steps.')
|
|
204
|
-
.default(20)
|
|
205
|
-
.volatile(),
|
|
206
|
-
localCfg: z
|
|
207
|
-
.number()
|
|
208
|
-
.description('provider=local: CFG scale.')
|
|
209
|
-
.default(7)
|
|
210
|
-
.volatile(),
|
|
211
|
-
seedreamBaseURL: z
|
|
212
|
-
.string()
|
|
213
|
-
.description('provider=seedream: base URL, e.g. https://api.bytedanceapi.com/v1 or Volcengine Ark.')
|
|
214
|
-
.default('https://api.bytedanceapi.com/v1')
|
|
215
|
-
.volatile(),
|
|
216
|
-
seedreamKeyEnv: z
|
|
217
|
-
.string()
|
|
218
|
-
.role('credential-ref')
|
|
219
|
-
.description('provider=seedream: credential reference / env var holding the API key.')
|
|
220
|
-
.default('SEEDREAM_API_KEY')
|
|
221
|
-
.volatile(),
|
|
222
|
-
seedreamModel: z
|
|
223
|
-
.string()
|
|
224
|
-
.description('provider=seedream: model id, e.g. seedream-4.0.')
|
|
225
|
-
.default('seedream-4.0')
|
|
226
|
-
.volatile(),
|
|
227
|
-
geminiKeyEnv: z
|
|
228
|
-
.string()
|
|
229
|
-
.role('credential-ref')
|
|
230
|
-
.description('provider=gemini: credential reference / env var holding the Google API key.')
|
|
231
|
-
.default('GEMINI_API_KEY')
|
|
232
|
-
.volatile(),
|
|
233
|
-
geminiModel: z
|
|
234
|
-
.string()
|
|
235
|
-
.description('provider=gemini: model id, e.g. gemini-2.0-flash-exp-image-generation.')
|
|
236
|
-
.default('gemini-2.0-flash-exp-image-generation')
|
|
237
|
-
.volatile(),
|
|
238
|
-
outputDir: z
|
|
239
|
-
.string()
|
|
240
|
-
.description('Where generated images are saved. A relative path resolves against the session working directory; an absolute path is used as given.')
|
|
241
|
-
.default('generated/images')
|
|
242
|
-
.volatile(),
|
|
243
|
-
historyLimit: z
|
|
244
|
-
.number()
|
|
245
|
-
.description('How many recent generations to keep in the in-memory history list.')
|
|
246
|
-
.default(50)
|
|
247
|
-
.volatile(),
|
|
248
|
-
pruneDays: z
|
|
249
|
-
.number()
|
|
250
|
-
.description('Delete generated files and history entries older than this many days. 0 (default) disables pruning.')
|
|
251
|
-
.default(0)
|
|
252
|
-
.volatile(),
|
|
253
|
-
enhancePrompt: z
|
|
254
|
-
.boolean()
|
|
255
|
-
.description('Expand a short prompt into a detailed one through the chat model before generating. Off by default.')
|
|
256
|
-
.default(false)
|
|
257
|
-
.volatile(),
|
|
258
|
-
enhanceModel: z
|
|
259
|
-
.string()
|
|
260
|
-
.description('Model used to enhance the prompt. Empty means the same model that leads the conversation.')
|
|
261
|
-
.default('')
|
|
262
|
-
.volatile(),
|
|
263
|
-
autoEnhancePrompt: z
|
|
264
|
-
.boolean()
|
|
265
|
-
.description('Enrich prompts with photography, lighting, and composition quality tokens.')
|
|
266
|
-
.default(false)
|
|
267
|
-
.volatile(),
|
|
268
|
-
defaultStylePreset: z
|
|
269
|
-
.string()
|
|
270
|
-
.description('Default style preset applied to generations (e.g. cinematic, anime, photorealistic).')
|
|
271
|
-
.default('none')
|
|
272
|
-
.volatile(),
|
|
273
|
-
enhanceBelowChars: z
|
|
274
|
-
.number()
|
|
275
|
-
.description('Only enhance prompts shorter than this many characters.')
|
|
276
|
-
.default(200)
|
|
277
|
-
.volatile(),
|
|
278
|
-
stylePreset: z
|
|
279
|
-
.string()
|
|
280
|
-
.description('Optional style suffix appended to the prompt before generation. Empty (default) means no style is applied and the prompt is used as-is.')
|
|
281
|
-
.default('')
|
|
282
|
-
.volatile(),
|
|
283
|
-
cacheBySeed: z
|
|
284
|
-
.boolean()
|
|
285
|
-
.description('If the same seed+prompt was already generated (present in history), return the cached result instead of generating again. Off by default.')
|
|
286
|
-
.default(false)
|
|
287
|
-
.volatile(),
|
|
288
|
-
cacheByPrompt: z
|
|
289
|
-
.boolean()
|
|
290
|
-
.description('If the same prompt was already generated (present in history), return the cached result instead of generating again. Off by default.')
|
|
291
|
-
.default(false)
|
|
292
|
-
.volatile(),
|
|
293
|
-
qualityGate: z
|
|
294
|
-
.boolean()
|
|
295
|
-
.description('Automatic quality gate with silent re-roll for blank or defective frames. On by default.')
|
|
296
|
-
.default(true)
|
|
297
|
-
.volatile(),
|
|
298
|
-
dailyBudgetUsd: z
|
|
299
|
-
.number()
|
|
300
|
-
.description('Daily image generation spending budget in USD. 0 (default) disables limit enforcement.')
|
|
301
|
-
.default(0)
|
|
302
|
-
.volatile(),
|
|
303
|
-
loopGuardLimit: z
|
|
304
|
-
.number()
|
|
305
|
-
.description('Maximum consecutive image generations per session without user interaction. 0 disables protection.')
|
|
306
|
-
.default(3)
|
|
307
|
-
.volatile(),
|
|
308
|
-
diskCache: z
|
|
309
|
-
.boolean()
|
|
310
|
-
.description('Content-addressed disk caching for identical generations (<50ms retrieval, zero API cost). On by default.')
|
|
311
|
-
.default(true)
|
|
312
|
-
.volatile(),
|
|
313
|
-
fallbackProviders: z
|
|
314
|
-
.array(z.string())
|
|
315
|
-
.description('Ordered list of fallback providers to cascade to if the primary encounters 429, 5xx, or quota limits.')
|
|
316
|
-
.default([])
|
|
317
|
-
.volatile(),
|
|
318
|
-
})
|
|
319
|
-
|
|
320
|
-
function isVolatileRef(value) {
|
|
321
|
-
return !!value && typeof value === 'object' && !Array.isArray(value) && typeof value.get === 'function'
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
export function plainConfig(cfg) {
|
|
325
|
-
if (isVolatileRef(cfg)) return plainConfig(cfg.get())
|
|
326
|
-
if (!cfg || typeof cfg !== 'object') return cfg
|
|
327
|
-
const out = {}
|
|
328
|
-
for (const key of Object.keys(cfg)) {
|
|
329
|
-
const value = cfg[key]
|
|
330
|
-
out[key] = isVolatileRef(value) ? value.get() : value
|
|
331
|
-
}
|
|
332
|
-
return out
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
export function volatileConfig(cfg) {
|
|
336
|
-
const plain = plainConfig(cfg)
|
|
337
|
-
if (!plain || typeof plain !== 'object') return plain
|
|
338
|
-
const out = {}
|
|
339
|
-
for (const [key, field] of Object.entries(Config.dict || {})) {
|
|
340
|
-
if (field?.meta?.volatile && Object.hasOwn(plain, key)) {
|
|
341
|
-
out[key] = plain[key]
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
return out
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
export function publicConfig(cfg) {
|
|
348
|
-
return plainConfig(cfg)
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
/** Keep a file stem safe for the filesystem. */
|
|
352
|
-
/** Read source image for editing: filesystem path or attachment id. */
|
|
353
|
-
export async function resolveSource(ctx, exec, ref) {
|
|
354
|
-
if (!ref) return undefined
|
|
355
|
-
const sessionCwd = exec?.agent?.session?.header?.cwd || process.cwd()
|
|
356
|
-
if (ref.startsWith('sha256:')) {
|
|
357
|
-
const stored = await ctx.attachments.readImage({ attachmentId: ref, mediaType: 'image/png', bytes: 0, width: 0, height: 0 })
|
|
358
|
-
const bytes = Buffer.from(stored.data || stored.bytes || [])
|
|
359
|
-
validateImageSignature(bytes, ref)
|
|
360
|
-
return { bytes, mediaType: stored.ref?.mediaType || 'image/png' }
|
|
361
|
-
}
|
|
362
|
-
if (ref.startsWith('data:')) {
|
|
363
|
-
const commaIndex = ref.indexOf(',')
|
|
364
|
-
if (commaIndex > 0) {
|
|
365
|
-
const meta = ref.slice(0, commaIndex)
|
|
366
|
-
const data = ref.slice(commaIndex + 1).trim()
|
|
367
|
-
const approxBytes = Math.ceil((data.length * 3) / 4)
|
|
368
|
-
if (approxBytes > 50 * 1024 * 1024) {
|
|
369
|
-
throw new Error('Data URI payload too large. Maximum allowed size is 50 MB.')
|
|
370
|
-
}
|
|
371
|
-
const bytes = Buffer.from(data, 'base64')
|
|
372
|
-
if (bytes.length > 50 * 1024 * 1024) {
|
|
373
|
-
throw new Error('Decoded image data too large. Maximum allowed size is 50 MB.')
|
|
374
|
-
}
|
|
375
|
-
validateImageSignature(bytes, 'inline data URI')
|
|
376
|
-
const mediaMatch = meta.match(/data:([^;]+)/)
|
|
377
|
-
return { bytes, mediaType: mediaMatch ? mediaMatch[1] : 'image/png' }
|
|
378
|
-
}
|
|
379
|
-
throw new Error('Malformed data URI')
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
const root = path.resolve(sessionCwd)
|
|
383
|
-
const target = path.resolve(root, ref)
|
|
384
|
-
const rel = path.relative(root, target)
|
|
385
|
-
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
386
|
-
throw new Error(`Access denied: path "${ref}" is outside allowed workspace directory "${root}"`)
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
const fileStat = await stat(target).catch(() => null)
|
|
390
|
-
if (!fileStat) {
|
|
391
|
-
throw new Error(`Source image not found: ${ref}`)
|
|
392
|
-
}
|
|
393
|
-
if (!fileStat.isFile()) {
|
|
394
|
-
throw new Error(`Target is not a regular file: ${ref}`)
|
|
395
|
-
}
|
|
396
|
-
if (fileStat.size === 0) {
|
|
397
|
-
throw new Error(`Image file is empty (0 bytes): ${ref}`)
|
|
398
|
-
}
|
|
399
|
-
if (fileStat.size > 50 * 1024 * 1024) {
|
|
400
|
-
throw new Error(`Source image too large (${(fileStat.size / (1024 * 1024)).toFixed(1)} MB). Maximum allowed size is 50 MB.`)
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
const bytes = await readFile(target)
|
|
404
|
-
validateImageSignature(bytes, ref)
|
|
405
|
-
const ext = path.extname(target).toLowerCase()
|
|
406
|
-
const mediaType = ext === '.jpg' || ext === '.jpeg' ? 'image/jpeg'
|
|
407
|
-
: ext === '.webp' ? 'image/webp'
|
|
408
|
-
: ext === '.gif' ? 'image/gif'
|
|
409
|
-
: ext === '.svg' ? 'image/svg+xml'
|
|
410
|
-
: 'image/png'
|
|
411
|
-
return { bytes, mediaType }
|
|
412
|
-
}
|
|
413
|
-
|
|
414
81
|
export function slugify(input) {
|
|
415
82
|
const stem = String(input ?? '')
|
|
416
83
|
.toLowerCase()
|
|
417
|
-
.replace(/[^a-z0-9
|
|
84
|
+
.replace(/[^a-z0-9Ѐ-ӿ]+/gi, '-')
|
|
418
85
|
.replace(/^-+|-+$/g, '')
|
|
419
86
|
.slice(0, 48)
|
|
420
87
|
return stem || 'image'
|
|
@@ -460,36 +127,14 @@ function migrateLegacySettings(sctx, scope) {
|
|
|
460
127
|
* Canonical implementation lives in prompt-enhancer.js; re-exported for backwards compat. */
|
|
461
128
|
export { collectText } from './prompt-enhancer.js'
|
|
462
129
|
|
|
463
|
-
|
|
464
130
|
/** Expand short prompt via chat model; return original prompt on error.
|
|
465
131
|
* Canonical implementation lives in prompt-enhancer.js; re-exported for backwards compat. */
|
|
466
132
|
export { buildEnhancePromptSystemMessage } from './prompt-enhancer.js'
|
|
467
133
|
|
|
468
|
-
|
|
469
134
|
/** Expand short prompt via chat model; return original prompt on error.
|
|
470
135
|
* Canonical implementation lives in prompt-enhancer.js; re-exported for backwards compat. */
|
|
471
136
|
export { enhancePrompt } from './prompt-enhancer.js'
|
|
472
137
|
|
|
473
|
-
|
|
474
|
-
function sanitizeRaw(raw) {
|
|
475
|
-
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}
|
|
476
|
-
const cleaned = {}
|
|
477
|
-
for (const [k, v] of Object.entries(raw)) {
|
|
478
|
-
if (v && typeof v === "object" && !Array.isArray(v) && typeof v.get !== "function" && Object.keys(v).length === 0) {
|
|
479
|
-
continue
|
|
480
|
-
}
|
|
481
|
-
cleaned[k] = v
|
|
482
|
-
}
|
|
483
|
-
return cleaned
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
export function ensureConfig(raw) {
|
|
487
|
-
const cleaned = sanitizeRaw(raw)
|
|
488
|
-
const plain = plainConfig(cleaned)
|
|
489
|
-
return Config(plain)
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
|
|
493
138
|
export function apply(ctx, rawConfig) {
|
|
494
139
|
const config = ensureConfig(rawConfig)
|
|
495
140
|
const baseConfig = plainConfig(config)
|
|
@@ -577,61 +222,59 @@ export function apply(ctx, rawConfig) {
|
|
|
577
222
|
})
|
|
578
223
|
|
|
579
224
|
// Serve the stored image so the tool card can show it inline.
|
|
580
|
-
const imageHandler = (
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
}
|
|
225
|
+
const imageHandler = async (req, res) => {
|
|
226
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
227
|
+
res.writeHead(405, { 'Content-Type': 'application/json' })
|
|
228
|
+
res.end(JSON.stringify({ error: 'GET only' }))
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
if (!isTrustedLocalRequest(req)) {
|
|
232
|
+
res.writeHead(403, { 'Content-Type': 'application/json' })
|
|
233
|
+
res.end(JSON.stringify({ error: 'forbidden' }))
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
const query = new URL(req.url ?? '/', 'http://x').searchParams
|
|
237
|
+
const id = query.get('id') ?? ''
|
|
238
|
+
if (!/^sha256:[0-9a-f]{64}$/.test(id)) {
|
|
239
|
+
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
240
|
+
res.end(JSON.stringify({ error: 'bad attachment id' }))
|
|
241
|
+
return
|
|
242
|
+
}
|
|
599
243
|
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
244
|
+
const mediaType = query.get('mediaType') ?? 'image/png'
|
|
245
|
+
const bytes = Number(query.get('bytes') ?? 0)
|
|
246
|
+
const width = Number(query.get('width') ?? 0)
|
|
247
|
+
const height = Number(query.get('height') ?? 0)
|
|
604
248
|
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
}
|
|
622
|
-
res.end(Buffer.isBuffer(stored.data) ? stored.data : Buffer.from(stored.data))
|
|
623
|
-
} catch (_) {
|
|
624
|
-
res.writeHead(404, { 'Content-Type': 'application/json' })
|
|
625
|
-
res.end(JSON.stringify({ error: 'attachment not found' }))
|
|
249
|
+
try {
|
|
250
|
+
const stored = await ctx.attachments.readImage({
|
|
251
|
+
attachmentId: id,
|
|
252
|
+
mediaType,
|
|
253
|
+
bytes,
|
|
254
|
+
width,
|
|
255
|
+
height,
|
|
256
|
+
})
|
|
257
|
+
res.writeHead(200, {
|
|
258
|
+
'Content-Type': stored.ref?.mediaType || mediaType,
|
|
259
|
+
'Content-Length': stored.data.byteLength,
|
|
260
|
+
'Cache-Control': 'public, max-age=31536000, immutable',
|
|
261
|
+
})
|
|
262
|
+
if (req.method === 'HEAD') {
|
|
263
|
+
res.end()
|
|
264
|
+
return
|
|
626
265
|
}
|
|
266
|
+
res.end(Buffer.isBuffer(stored.data) ? stored.data : Buffer.from(stored.data))
|
|
267
|
+
} catch (_) {
|
|
268
|
+
res.writeHead(404, { 'Content-Type': 'application/json' })
|
|
269
|
+
res.end(JSON.stringify({ error: 'attachment not found' }))
|
|
627
270
|
}
|
|
628
|
-
}
|
|
271
|
+
}
|
|
629
272
|
|
|
630
|
-
|
|
631
|
-
// Repair history file & directory permissions on startup (#307)
|
|
273
|
+
// Repair history file & directory permissions once on startup (#307, #318)
|
|
632
274
|
repairHistoryPermissions()
|
|
633
275
|
|
|
634
|
-
|
|
276
|
+
for (const path of ['/dsh-image-gen/image', '/dsh-fal-image-gen/image']) {
|
|
277
|
+
ctx.effect(() => ctx.webServer.register({
|
|
635
278
|
kind: 'exact',
|
|
636
279
|
path,
|
|
637
280
|
handler: imageHandler,
|
package/lib/providers.js
CHANGED
|
@@ -347,7 +347,7 @@ export function resolveStylePreset(styleKeyOrText, existingNegative, existingGui
|
|
|
347
347
|
export function applyStylePreset(prompt, styleKeyOrText) {
|
|
348
348
|
if (!styleKeyOrText) return prompt
|
|
349
349
|
const res = resolveStylePreset(styleKeyOrText)
|
|
350
|
-
return `${prompt}, ${res.promptSuffix}`
|
|
350
|
+
return res.promptSuffix ? `${prompt}, ${res.promptSuffix}` : prompt
|
|
351
351
|
}
|
|
352
352
|
|
|
353
353
|
export async function blendImagesFal({ fetchImpl, resolveKey, cfg }, { images, weights, prompt, model = 'fal-ai/flux/dev/image-to-image', signal }) {
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// lib/resolve-source.js — Safe image source resolution for image tools (#317)
|
|
2
|
+
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { readFile, stat } from 'node:fs/promises'
|
|
5
|
+
import { validateImageSignature } from './resolve-image.js'
|
|
6
|
+
|
|
7
|
+
/** Read source image for editing: filesystem path or attachment id. */
|
|
8
|
+
export async function resolveSource(ctx, exec, ref) {
|
|
9
|
+
if (!ref) return undefined
|
|
10
|
+
const sessionCwd = exec?.agent?.session?.header?.cwd || process.cwd()
|
|
11
|
+
if (ref.startsWith('sha256:')) {
|
|
12
|
+
const stored = await ctx.attachments.readImage({ attachmentId: ref, mediaType: 'image/png', bytes: 0, width: 0, height: 0 })
|
|
13
|
+
const bytes = Buffer.from(stored.data || stored.bytes || [])
|
|
14
|
+
validateImageSignature(bytes, ref)
|
|
15
|
+
return { bytes, mediaType: stored.ref?.mediaType || 'image/png' }
|
|
16
|
+
}
|
|
17
|
+
if (ref.startsWith('data:')) {
|
|
18
|
+
const commaIndex = ref.indexOf(',')
|
|
19
|
+
if (commaIndex > 0) {
|
|
20
|
+
const meta = ref.slice(0, commaIndex)
|
|
21
|
+
const data = ref.slice(commaIndex + 1).trim()
|
|
22
|
+
const approxBytes = Math.ceil((data.length * 3) / 4)
|
|
23
|
+
if (approxBytes > 50 * 1024 * 1024) {
|
|
24
|
+
throw new Error('Data URI payload too large. Maximum allowed size is 50 MB.')
|
|
25
|
+
}
|
|
26
|
+
const bytes = Buffer.from(data, 'base64')
|
|
27
|
+
if (bytes.length > 50 * 1024 * 1024) {
|
|
28
|
+
throw new Error('Decoded image data too large. Maximum allowed size is 50 MB.')
|
|
29
|
+
}
|
|
30
|
+
validateImageSignature(bytes, 'inline data URI')
|
|
31
|
+
const mediaMatch = meta.match(/data:([^;]+)/)
|
|
32
|
+
return { bytes, mediaType: mediaMatch ? mediaMatch[1] : 'image/png' }
|
|
33
|
+
}
|
|
34
|
+
throw new Error('Malformed data URI')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const root = path.resolve(sessionCwd)
|
|
38
|
+
const target = path.resolve(root, ref)
|
|
39
|
+
const rel = path.relative(root, target)
|
|
40
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
41
|
+
throw new Error(`Access denied: path "${ref}" is outside allowed workspace directory "${root}"`)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const fileStat = await stat(target).catch(() => null)
|
|
45
|
+
if (!fileStat) {
|
|
46
|
+
throw new Error(`Source image not found: ${ref}`)
|
|
47
|
+
}
|
|
48
|
+
if (!fileStat.isFile()) {
|
|
49
|
+
throw new Error(`Target is not a regular file: ${ref}`)
|
|
50
|
+
}
|
|
51
|
+
if (fileStat.size === 0) {
|
|
52
|
+
throw new Error(`Image file is empty (0 bytes): ${ref}`)
|
|
53
|
+
}
|
|
54
|
+
if (fileStat.size > 50 * 1024 * 1024) {
|
|
55
|
+
throw new Error(`Source image too large (${(fileStat.size / (1024 * 1024)).toFixed(1)} MB). Maximum allowed size is 50 MB.`)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const bytes = await readFile(target)
|
|
59
|
+
validateImageSignature(bytes, ref)
|
|
60
|
+
const ext = path.extname(target).toLowerCase()
|
|
61
|
+
const mediaType = ext === '.jpg' || ext === '.jpeg' ? 'image/jpeg'
|
|
62
|
+
: ext === '.webp' ? 'image/webp'
|
|
63
|
+
: ext === '.gif' ? 'image/gif'
|
|
64
|
+
: ext === '.svg' ? 'image/svg+xml'
|
|
65
|
+
: 'image/png'
|
|
66
|
+
return { bytes, mediaType }
|
|
67
|
+
}
|
package/lib/settings-route.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// Provides HTTP read/write fallback when DSH core is accessed over network.
|
|
4
4
|
|
|
5
5
|
import { isTrustedLocalRequest } from './security.js'
|
|
6
|
+
import { readBoundedRequestBody } from './vault.js'
|
|
6
7
|
import { publicConfig, plainConfig, Config } from './index.js'
|
|
7
8
|
|
|
8
9
|
export function registerSettingsRoutes(ctx, { live, getSettingsApi, setLiveConfig }) {
|
|
@@ -61,52 +62,62 @@ export function registerSettingsRoutes(ctx, { live, getSettingsApi, setLiveConfi
|
|
|
61
62
|
return
|
|
62
63
|
}
|
|
63
64
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
72
|
-
res.end(JSON.stringify({ ok: false, error: 'invalid JSON' }))
|
|
65
|
+
let buf
|
|
66
|
+
try {
|
|
67
|
+
buf = await readBoundedRequestBody(req, 64 * 1024)
|
|
68
|
+
} catch (err) {
|
|
69
|
+
if (err?.statusCode === 413) {
|
|
70
|
+
res.writeHead(413, { 'Content-Type': 'application/json' })
|
|
71
|
+
res.end(JSON.stringify({ ok: false, error: err.message }))
|
|
73
72
|
return
|
|
74
73
|
}
|
|
74
|
+
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
75
|
+
res.end(JSON.stringify({ ok: false, error: err?.message || 'body read failed' }))
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let payload
|
|
80
|
+
try {
|
|
81
|
+
payload = JSON.parse(buf.toString('utf8') || '{}')
|
|
82
|
+
} catch {
|
|
83
|
+
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
84
|
+
res.end(JSON.stringify({ ok: false, error: 'invalid JSON' }))
|
|
85
|
+
return
|
|
86
|
+
}
|
|
75
87
|
|
|
76
|
-
|
|
77
|
-
|
|
88
|
+
const raw = payload && typeof payload.config === 'object' && payload.config !== null ? payload.config : payload
|
|
89
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
90
|
+
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
91
|
+
res.end(JSON.stringify({ ok: false, error: 'body must be a JSON object' }))
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const allowedKeys = new Set(Object.keys(Config.dict || {}))
|
|
96
|
+
for (const k of Object.keys(raw)) {
|
|
97
|
+
if (!allowedKeys.has(k)) {
|
|
78
98
|
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
79
|
-
res.end(JSON.stringify({ ok: false, error:
|
|
99
|
+
res.end(JSON.stringify({ ok: false, error: `unknown config field: ${k}` }))
|
|
80
100
|
return
|
|
81
101
|
}
|
|
102
|
+
}
|
|
82
103
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
104
|
+
try {
|
|
105
|
+
const base = plainConfig(live()) || {}
|
|
106
|
+
const merged = Config({ ...base, ...raw })
|
|
107
|
+
const settingsApi = getSettingsApi ? getSettingsApi() : null
|
|
108
|
+
if (settingsApi && typeof settingsApi.replace === 'function') {
|
|
109
|
+
await settingsApi.replace(merged)
|
|
110
|
+
} else if (settingsApi && typeof settingsApi.update === 'function') {
|
|
111
|
+
await settingsApi.update(raw)
|
|
112
|
+
} else if (typeof setLiveConfig === 'function') {
|
|
113
|
+
setLiveConfig(merged)
|
|
90
114
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
await settingsApi.replace(merged)
|
|
98
|
-
} else if (settingsApi && typeof settingsApi.update === 'function') {
|
|
99
|
-
await settingsApi.update(raw)
|
|
100
|
-
} else if (typeof setLiveConfig === 'function') {
|
|
101
|
-
setLiveConfig(merged)
|
|
102
|
-
}
|
|
103
|
-
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
104
|
-
res.end(JSON.stringify({ ok: true, config: publicConfig(merged) }))
|
|
105
|
-
} catch (err) {
|
|
106
|
-
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
107
|
-
res.end(JSON.stringify({ ok: false, error: String(err?.message || err) }))
|
|
108
|
-
}
|
|
109
|
-
})
|
|
115
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
116
|
+
res.end(JSON.stringify({ ok: true, config: publicConfig(merged) }))
|
|
117
|
+
} catch (err) {
|
|
118
|
+
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
119
|
+
res.end(JSON.stringify({ ok: false, error: String(err?.message || err) }))
|
|
120
|
+
}
|
|
110
121
|
},
|
|
111
122
|
}), 'dsh-image-gen: config route')
|
|
112
123
|
}
|
package/lib/tools/frontend.js
CHANGED
package/lib/tools/generation.js
CHANGED
|
@@ -43,6 +43,15 @@ import { getCachedGeneration, setCachedGeneration } from '../generation-cache.js
|
|
|
43
43
|
import { resolveFallbackChain, executeWithFallback } from '../fallback-router.js'
|
|
44
44
|
import { getSessionAnchor, applyAnchorPromptHints } from '../anchor-helpers.js'
|
|
45
45
|
import { enhancePrompt } from '../prompt-enhancer.js'
|
|
46
|
+
import {
|
|
47
|
+
readHistory,
|
|
48
|
+
writeHistory,
|
|
49
|
+
pruneHistory,
|
|
50
|
+
findCachedGeneration,
|
|
51
|
+
findCached,
|
|
52
|
+
findCachedByPrompt,
|
|
53
|
+
cachedResult,
|
|
54
|
+
} from '../history.js'
|
|
46
55
|
|
|
47
56
|
|
|
48
57
|
export function registerGenerationTools(ctx, deps) {
|
|
@@ -286,7 +295,7 @@ export function registerGenerationTools(ctx, deps) {
|
|
|
286
295
|
assertBudgetAvailable(estimatedCost, cfg.dailyBudgetUsd)
|
|
287
296
|
const providers = makeProviders(
|
|
288
297
|
{ fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg, subscriptionImages },
|
|
289
|
-
{ prompt: effectivePrompt, size, format, seed: args.seed, signal: exec.signal, negativePrompt:
|
|
298
|
+
{ 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 },
|
|
290
299
|
)
|
|
291
300
|
// Subscription providers return {ok:false, reason} instead of throwing:
|
|
292
301
|
// rejection reaches the model as text. Without this guard,
|
package/lib/vault.js
CHANGED
|
@@ -118,14 +118,15 @@ export async function readBoundedRequestBody(req, maxBytes = MAX_VAULT_BODY_BYTE
|
|
|
118
118
|
let received = 0
|
|
119
119
|
const chunks = []
|
|
120
120
|
for await (const chunk of req) {
|
|
121
|
-
|
|
121
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
122
|
+
received += buf.length
|
|
122
123
|
if (received > maxBytes) {
|
|
123
124
|
if (typeof req.destroy === 'function') req.destroy()
|
|
124
125
|
const err = new Error(`Payload Too Large: request body exceeded maximum allowed size of ${maxBytes} bytes`)
|
|
125
126
|
err.statusCode = 413
|
|
126
127
|
throw err
|
|
127
128
|
}
|
|
128
|
-
chunks.push(
|
|
129
|
+
chunks.push(buf)
|
|
129
130
|
}
|
|
130
131
|
return Buffer.concat(chunks)
|
|
131
132
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-image-gen",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.8",
|
|
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",
|
|
@@ -69,9 +69,10 @@
|
|
|
69
69
|
"@deepseek-ai/schemastery": "^3.18.1"
|
|
70
70
|
},
|
|
71
71
|
"scripts": {
|
|
72
|
-
"test": "node scripts/build-client.mjs && node --check lib/client.js && node --test test/*.test.mjs",
|
|
72
|
+
"test": "node scripts/build-client.mjs --check && node --check lib/client.js && node --test test/*.test.mjs",
|
|
73
73
|
"build:client": "node scripts/build-client.mjs",
|
|
74
|
-
"publish:github": "bash scripts/publish-github.sh"
|
|
74
|
+
"publish:github": "bash scripts/publish-github.sh",
|
|
75
|
+
"build:client:check": "node scripts/build-client.mjs --check"
|
|
75
76
|
},
|
|
76
77
|
"publishConfig": {
|
|
77
78
|
"access": "public"
|