@goodandready/dsh-image-gen 0.11.23 → 0.11.25
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/attachment-helper.js +5 -1
- package/lib/client.js +97 -7
- package/lib/commands.js +9 -3
- package/lib/config-schema.js +50 -2
- package/lib/generation-helpers.js +16 -10
- package/lib/history.js +55 -5
- package/lib/index.js +93 -42
- package/lib/provider-utils.js +2 -0
- package/lib/providers/backends/custom.js +17 -4
- package/lib/providers/backends/gemini.js +45 -4
- package/lib/providers/shared-helpers.js +64 -2
- package/lib/providers.js +21 -29
- package/lib/register-tools.js +106 -37
- package/lib/settings-route.js +54 -4
- package/lib/tools/generation.js +52 -28
- package/lib/vault.js +19 -17
- package/package.json +1 -1
package/lib/attachment-helper.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
2
|
import { mkdir, writeFile } from 'node:fs/promises'
|
|
3
|
-
import { saveAttachmentSafe, buildSidecar, toLosslessJson } from './providers.js'
|
|
3
|
+
import { saveAttachmentSafe, buildSidecar, toLosslessJson, ensureImageFormat } from './providers.js'
|
|
4
4
|
import { recordSpend } from './cost-meter.js'
|
|
5
5
|
import { buildDualOutputMarkdown } from './resolve-image.js'
|
|
6
6
|
|
|
@@ -21,6 +21,10 @@ export async function saveAndAttachResult(ctx, exec, cfg, {
|
|
|
21
21
|
args = {},
|
|
22
22
|
action = 'generated',
|
|
23
23
|
}) {
|
|
24
|
+
const targetFormat = format || (mediaType ? mediaType.replace('image/', '') : undefined)
|
|
25
|
+
const formatted = await ensureImageFormat(bytes, targetFormat, { fallbackMime: mediaType })
|
|
26
|
+
bytes = formatted.bytes
|
|
27
|
+
mediaType = formatted.mediaType
|
|
24
28
|
const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes, mediaType, name })
|
|
25
29
|
|
|
26
30
|
const sessionCwd = exec?.agent?.session?.header?.cwd
|
package/lib/client.js
CHANGED
|
@@ -264,6 +264,40 @@ window.__ModuleLoader__.load({
|
|
|
264
264
|
}
|
|
265
265
|
}
|
|
266
266
|
|
|
267
|
+
function arrayField(field) {
|
|
268
|
+
return {
|
|
269
|
+
field,
|
|
270
|
+
format: (value) => (Array.isArray(value) ? value.join(', ') : typeof value === 'string' ? value : ''),
|
|
271
|
+
parse: (text) => {
|
|
272
|
+
if (text === '') return { kind: 'clear' }
|
|
273
|
+
const items = text.split(',').map((s) => s.trim()).filter(Boolean)
|
|
274
|
+
return { kind: 'set', value: items }
|
|
275
|
+
},
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function jsonField(field) {
|
|
280
|
+
return {
|
|
281
|
+
field,
|
|
282
|
+
format: (value) => {
|
|
283
|
+
if (typeof value === 'string') return value
|
|
284
|
+
if (value && typeof value === 'object') {
|
|
285
|
+
try { return JSON.stringify(value, null, 2) } catch { return '' }
|
|
286
|
+
}
|
|
287
|
+
return ''
|
|
288
|
+
},
|
|
289
|
+
parse: (text) => {
|
|
290
|
+
if (text === '') return { kind: 'clear' }
|
|
291
|
+
try {
|
|
292
|
+
JSON.parse(text)
|
|
293
|
+
return { kind: 'set', value: text }
|
|
294
|
+
} catch (_) {
|
|
295
|
+
return void 0
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
267
301
|
const PROVIDERS = ['fal', 'custom', 'codex', 'grok', 'local', 'seedream', 'gemini', 'replicate']
|
|
268
302
|
const IMAGE_SIZES = ['landscape_4_3', 'landscape_16_9', 'portrait_4_3', 'portrait_16_9', 'square', 'square_hd']
|
|
269
303
|
const OUTPUT_FORMATS = ['png', 'jpeg', 'webp']
|
|
@@ -328,10 +362,11 @@ window.__ModuleLoader__.load({
|
|
|
328
362
|
// Backward-compat aliases for tests
|
|
329
363
|
{ field: 'localComfyUrl', spec: textField('localComfyUrl'), kind: 'text', labelKey: 'f.localComfyUrl', hintKey: 'f.localComfyUrlHint', placeholderKey: 'p.localComfyUrl', when: ['local'], tab: 'provider' },
|
|
330
364
|
{ field: 'localA1111Url', spec: textField('localA1111Url'), kind: 'text', labelKey: 'f.localA1111Url', hintKey: 'f.localA1111UrlHint', placeholderKey: 'p.localA1111Url', when: ['local'], tab: 'provider' },
|
|
365
|
+
{ field: 'comfyWorkflowJson', spec: jsonField('comfyWorkflowJson'), kind: 'text', labelKey: 'f.comfyWorkflowJson', hintKey: 'f.comfyWorkflowJsonHint', placeholderKey: 'p.comfyWorkflowJson', when: ['local'], tab: 'provider' },
|
|
331
366
|
|
|
332
367
|
// Provider: Codex / Grok
|
|
333
368
|
{ field: 'subscriptionQuality', spec: selectField('subscriptionQuality', ['', 'low', 'medium', 'high']), kind: 'select', options: ['', 'low', 'medium', 'high'], labelKey: 'f.subscriptionQuality', hintKey: 'f.subscriptionQualityHint', when: ['codex', 'grok'], tab: 'provider' },
|
|
334
|
-
{ field: 'fallbackProviders', spec:
|
|
369
|
+
{ field: 'fallbackProviders', spec: arrayField('fallbackProviders'), kind: 'text', labelKey: 'f.fallbackProviders', hintKey: 'f.fallbackProvidersHint', placeholderKey: 'p.fallbackProviders', tab: 'provider' },
|
|
335
370
|
|
|
336
371
|
// Enhancer & Style
|
|
337
372
|
{ field: 'autoEnhancePrompt', spec: booleanField('autoEnhancePrompt'), kind: 'select', options: ['true', 'false'], labelKey: 'f.autoEnhancePrompt', hintKey: 'f.autoEnhancePromptHint', tab: 'enhancer' },
|
|
@@ -2548,6 +2583,9 @@ window.__ModuleLoader__.load({
|
|
|
2548
2583
|
'f.localA1111Url': 'Local A1111 URL (Legacy)',
|
|
2549
2584
|
'f.localA1111UrlHint': 'Direct Automatic1111 endpoint URL.',
|
|
2550
2585
|
'p.localA1111Url': 'http://127.0.0.1:7860',
|
|
2586
|
+
'f.comfyWorkflowJson': 'ComfyUI Workflow JSON',
|
|
2587
|
+
'f.comfyWorkflowJsonHint': 'Custom ComfyUI API workflow graph (JSON) with {{prompt}} placeholders.',
|
|
2588
|
+
'p.comfyWorkflowJson': '{"3": {"class_type": "KSampler", ...}}',
|
|
2551
2589
|
|
|
2552
2590
|
'f.subscriptionQuality': 'Subscription Quality',
|
|
2553
2591
|
'f.subscriptionQualityHint': 'Target quality profile when using Codex or Grok subscriptions.',
|
|
@@ -2824,6 +2862,9 @@ window.__ModuleLoader__.load({
|
|
|
2824
2862
|
'f.localA1111Url': '本地 A1111 地址 (Legacy)',
|
|
2825
2863
|
'f.localA1111UrlHint': '直接指向 Automatic1111 的服务地址。',
|
|
2826
2864
|
'p.localA1111Url': 'http://127.0.0.1:7860',
|
|
2865
|
+
'f.comfyWorkflowJson': 'ComfyUI 工作流 JSON',
|
|
2866
|
+
'f.comfyWorkflowJsonHint': '包含 {{prompt}} 等占位符的自定义 ComfyUI API 工作流 JSON。',
|
|
2867
|
+
'p.comfyWorkflowJson': '{"3": {"class_type": "KSampler", ...}}',
|
|
2827
2868
|
|
|
2828
2869
|
'f.subscriptionQuality': '订阅质量',
|
|
2829
2870
|
'f.subscriptionQualityHint': 'Codex 或 Grok 订阅通道的质量档位。',
|
|
@@ -3009,31 +3050,80 @@ window.__ModuleLoader__.load({
|
|
|
3009
3050
|
const target = activeTarget || resolveActiveScope(ctx)
|
|
3010
3051
|
const snap = target?.getSnapshot ? target.getSnapshot() : null
|
|
3011
3052
|
if (snap?.status === 'ready' && snap.writable && target.set) return target.set(key, val)
|
|
3053
|
+
const prevValue = httpSnapshot.value
|
|
3012
3054
|
httpSnapshot = { ...httpSnapshot, value: { ...httpSnapshot.value, [key]: val } }
|
|
3013
3055
|
notify()
|
|
3014
3056
|
try {
|
|
3015
|
-
await fetch('/dsh-image-gen/config', {
|
|
3057
|
+
const res = await fetch('/dsh-image-gen/config', {
|
|
3016
3058
|
method: 'PUT',
|
|
3017
3059
|
headers: { 'Content-Type': 'application/json' },
|
|
3018
3060
|
body: JSON.stringify({ [key]: val }),
|
|
3019
3061
|
})
|
|
3020
|
-
|
|
3062
|
+
if (!res.ok) {
|
|
3063
|
+
let errText = `Failed to update setting (HTTP ${res.status})`
|
|
3064
|
+
try {
|
|
3065
|
+
const errData = await res.json()
|
|
3066
|
+
if (errData?.error) errText = errData.error
|
|
3067
|
+
} catch { /* ignore */ }
|
|
3068
|
+
httpSnapshot = { ...httpSnapshot, value: prevValue }
|
|
3069
|
+
notify()
|
|
3070
|
+
throw new Error(errText)
|
|
3071
|
+
}
|
|
3072
|
+
const data = await res.json()
|
|
3073
|
+
if (!data.ok) {
|
|
3074
|
+
httpSnapshot = { ...httpSnapshot, value: prevValue }
|
|
3075
|
+
notify()
|
|
3076
|
+
throw new Error(data.error || 'Failed to update setting')
|
|
3077
|
+
}
|
|
3078
|
+
if (data.config) {
|
|
3079
|
+
httpSnapshot = { status: 'ready', writable: true, value: data.config }
|
|
3080
|
+
notify()
|
|
3081
|
+
}
|
|
3082
|
+
return data
|
|
3083
|
+
} catch (err) {
|
|
3084
|
+
httpSnapshot = { ...httpSnapshot, value: prevValue }
|
|
3085
|
+
notify()
|
|
3086
|
+
throw err
|
|
3087
|
+
}
|
|
3021
3088
|
},
|
|
3022
3089
|
async delete(key) {
|
|
3023
3090
|
const target = activeTarget || resolveActiveScope(ctx)
|
|
3024
3091
|
const snap = target?.getSnapshot ? target.getSnapshot() : null
|
|
3025
|
-
if (snap?.status === 'ready' && snap.writable && target.delete
|
|
3092
|
+
if (snap?.status === 'ready' && snap.writable && (target.delete || target.reset)) {
|
|
3093
|
+
if (target.delete) return target.delete(key)
|
|
3094
|
+
if (target.reset) return target.reset(key)
|
|
3095
|
+
}
|
|
3096
|
+
const prevValue = httpSnapshot.value
|
|
3026
3097
|
const nextVal = { ...httpSnapshot.value }
|
|
3027
3098
|
delete nextVal[key]
|
|
3028
3099
|
httpSnapshot = { ...httpSnapshot, value: nextVal }
|
|
3029
3100
|
notify()
|
|
3030
3101
|
try {
|
|
3031
|
-
await fetch('/dsh-image-gen/config', {
|
|
3102
|
+
const res = await fetch('/dsh-image-gen/config', {
|
|
3032
3103
|
method: 'PUT',
|
|
3033
3104
|
headers: { 'Content-Type': 'application/json' },
|
|
3034
|
-
body: JSON.stringify({ [key]
|
|
3105
|
+
body: JSON.stringify({ resetFields: [key] }),
|
|
3035
3106
|
})
|
|
3036
|
-
|
|
3107
|
+
if (!res.ok) {
|
|
3108
|
+
httpSnapshot = { ...httpSnapshot, value: prevValue }
|
|
3109
|
+
notify()
|
|
3110
|
+
throw new Error(`Failed to reset setting (HTTP ${res.status})`)
|
|
3111
|
+
}
|
|
3112
|
+
const data = await res.json()
|
|
3113
|
+
if (!data.ok) {
|
|
3114
|
+
httpSnapshot = { ...httpSnapshot, value: prevValue }
|
|
3115
|
+
notify()
|
|
3116
|
+
throw new Error(data.error || 'Failed to reset setting')
|
|
3117
|
+
}
|
|
3118
|
+
if (data.config) {
|
|
3119
|
+
httpSnapshot = { status: 'ready', writable: true, value: data.config }
|
|
3120
|
+
notify()
|
|
3121
|
+
}
|
|
3122
|
+
} catch (err) {
|
|
3123
|
+
httpSnapshot = { ...httpSnapshot, value: prevValue }
|
|
3124
|
+
notify()
|
|
3125
|
+
throw err
|
|
3126
|
+
}
|
|
3037
3127
|
},
|
|
3038
3128
|
subscribe(fn) {
|
|
3039
3129
|
listeners.add(fn)
|
package/lib/commands.js
CHANGED
|
@@ -85,11 +85,14 @@ export function registerImageCommand(ctx, deps) {
|
|
|
85
85
|
const size = flags.size || cfg.defaultSize || 'square'
|
|
86
86
|
const format = flags.format || cfg.defaultFormat || 'png'
|
|
87
87
|
const aspectRatio = flags.aspect || flags.aspect_ratio || cfg.defaultAspectRatio
|
|
88
|
-
const
|
|
88
|
+
const effectivePreset = (flags.style_preset !== undefined && flags.style_preset !== null && flags.style_preset !== '')
|
|
89
|
+
? flags.style_preset
|
|
90
|
+
: (flags.style || cfg.stylePreset || cfg.defaultStylePreset)
|
|
89
91
|
const seed = flags.seed ? Number(flags.seed) : Math.floor(Math.random() * 2147483647)
|
|
90
92
|
|
|
91
|
-
const styleInfo = resolveStylePreset(
|
|
92
|
-
const effectivePrompt =
|
|
93
|
+
const styleInfo = resolveStylePreset(effectivePreset, flags.negative_prompt, flags.guidance_scale)
|
|
94
|
+
const effectivePrompt = styleInfo.promptSuffix ? `${prompt}, ${styleInfo.promptSuffix}` : prompt
|
|
95
|
+
const effectiveNegative = styleInfo.negativePrompt || flags.negative_prompt || cfg.negativePrompt
|
|
93
96
|
|
|
94
97
|
const job = {
|
|
95
98
|
prompt: effectivePrompt,
|
|
@@ -99,6 +102,9 @@ export function registerImageCommand(ctx, deps) {
|
|
|
99
102
|
aspectPixels: aspectRatio ? ASPECT_RATIOS[aspectRatio] : undefined,
|
|
100
103
|
seed,
|
|
101
104
|
signal: invocation?.signal,
|
|
105
|
+
negativePrompt: effectiveNegative,
|
|
106
|
+
guidanceScale: styleInfo.guidanceScale ?? flags.guidance_scale,
|
|
107
|
+
style: flags.style,
|
|
102
108
|
}
|
|
103
109
|
|
|
104
110
|
const pdeps = {
|
package/lib/config-schema.js
CHANGED
|
@@ -176,8 +176,8 @@ export const Config = z.object({
|
|
|
176
176
|
.volatile(),
|
|
177
177
|
geminiModel: z
|
|
178
178
|
.string()
|
|
179
|
-
.description('Google Gemini
|
|
180
|
-
.default('
|
|
179
|
+
.description('Google Gemini image generation model identifier.')
|
|
180
|
+
.default('gemini-2.0-flash-exp-image-generation')
|
|
181
181
|
.volatile(),
|
|
182
182
|
geminiKeyEnv: z
|
|
183
183
|
.string()
|
|
@@ -215,6 +215,34 @@ export const Config = z.object({
|
|
|
215
215
|
.description('CFG scale for local generation.')
|
|
216
216
|
.default(7.0)
|
|
217
217
|
.volatile(),
|
|
218
|
+
comfyWorkflowJson: z
|
|
219
|
+
.string()
|
|
220
|
+
.description('Custom ComfyUI API workflow JSON template with {{prompt}}, {{seed}}, etc. placeholders.')
|
|
221
|
+
.default('')
|
|
222
|
+
.volatile(),
|
|
223
|
+
localComfyUrl: z
|
|
224
|
+
.string()
|
|
225
|
+
.description('Legacy alias for ComfyUI URL.')
|
|
226
|
+
.default('')
|
|
227
|
+
.volatile(),
|
|
228
|
+
localA1111Url: z
|
|
229
|
+
.string()
|
|
230
|
+
.description('Legacy alias for A1111 URL.')
|
|
231
|
+
.default('')
|
|
232
|
+
.volatile(),
|
|
233
|
+
enableLlmEnhancer: z
|
|
234
|
+
.boolean()
|
|
235
|
+
.description('Legacy alias for enhancePrompt.')
|
|
236
|
+
.default(false)
|
|
237
|
+
.volatile(),
|
|
238
|
+
cacheTtlDays: z
|
|
239
|
+
.number()
|
|
240
|
+
.min(0)
|
|
241
|
+
.max(3650)
|
|
242
|
+
.step(1)
|
|
243
|
+
.description('Legacy alias for pruneDays.')
|
|
244
|
+
.default(0)
|
|
245
|
+
.volatile(),
|
|
218
246
|
subscriptionQuality: z
|
|
219
247
|
.string()
|
|
220
248
|
.description('Image quality tier for subscription accounts: "standard" or "hd".')
|
|
@@ -354,6 +382,26 @@ export function sanitizeRaw(raw) {
|
|
|
354
382
|
if (cleaned.localKind === 'automatic1111') {
|
|
355
383
|
cleaned.localKind = 'a1111'
|
|
356
384
|
}
|
|
385
|
+
// Coerce string fallbackProviders to array (#369)
|
|
386
|
+
if (typeof cleaned.fallbackProviders === 'string') {
|
|
387
|
+
cleaned.fallbackProviders = cleaned.fallbackProviders
|
|
388
|
+
.split(',')
|
|
389
|
+
.map((s) => s.trim())
|
|
390
|
+
.filter(Boolean)
|
|
391
|
+
}
|
|
392
|
+
// Migrate legacy fields (#369)
|
|
393
|
+
if (cleaned.localComfyUrl && !cleaned.localBaseURL && cleaned.localKind === 'comfyui') {
|
|
394
|
+
cleaned.localBaseURL = cleaned.localComfyUrl
|
|
395
|
+
}
|
|
396
|
+
if (cleaned.localA1111Url && !cleaned.localBaseURL && cleaned.localKind === 'a1111') {
|
|
397
|
+
cleaned.localBaseURL = cleaned.localA1111Url
|
|
398
|
+
}
|
|
399
|
+
if (typeof cleaned.enableLlmEnhancer === 'boolean' && cleaned.enhancePrompt === undefined) {
|
|
400
|
+
cleaned.enhancePrompt = cleaned.enableLlmEnhancer
|
|
401
|
+
}
|
|
402
|
+
if (typeof cleaned.cacheTtlDays === 'number' && cleaned.cacheTtlDays > 0 && cleaned.pruneDays === undefined) {
|
|
403
|
+
cleaned.pruneDays = cleaned.cacheTtlDays
|
|
404
|
+
}
|
|
357
405
|
return cleaned
|
|
358
406
|
}
|
|
359
407
|
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
embedPngMetadata,
|
|
14
14
|
resolveStylePreset,
|
|
15
15
|
saveAttachmentSafe,
|
|
16
|
+
ensureImageFormat,
|
|
16
17
|
} from './providers.js'
|
|
17
18
|
import { enhancePrompt } from './prompt-enhancer.js'
|
|
18
19
|
import { buildFaceConsistencyPrompt, getSessionAnchor, applyAnchorPromptHints } from './anchor-helpers.js'
|
|
@@ -23,6 +24,7 @@ import {
|
|
|
23
24
|
readHistory,
|
|
24
25
|
writeHistory,
|
|
25
26
|
pruneHistory,
|
|
27
|
+
appendHistoryEntry,
|
|
26
28
|
} from './history.js'
|
|
27
29
|
|
|
28
30
|
/** Run an array of async task functions with a concurrency cap */
|
|
@@ -62,8 +64,10 @@ export function validateGenerationParams(cfg, args) {
|
|
|
62
64
|
/** Prepares enhanced prompt, style hints, facial identity, anchors, and sanitized negative prompt */
|
|
63
65
|
export async function prepareGenerationPrompt({ ctx, cfg, args, exec, sessionId, provider, resolveSource }) {
|
|
64
66
|
const enhanced = await enhancePrompt(ctx, cfg, args.prompt, exec.signal, provider)
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
+
const candidatePreset = (args.style_preset !== undefined && args.style_preset !== null && args.style_preset !== '')
|
|
68
|
+
? args.style_preset
|
|
69
|
+
: (cfg.stylePreset || cfg.defaultStylePreset)
|
|
70
|
+
const styleInfo = resolveStylePreset(candidatePreset, args.negative_prompt, args.guidance_scale)
|
|
67
71
|
let effectivePrompt = styleInfo.promptSuffix ? `${enhanced.prompt}, ${styleInfo.promptSuffix}` : enhanced.prompt
|
|
68
72
|
let effectiveNegative = styleInfo.negativePrompt
|
|
69
73
|
const activeAnchor = getSessionAnchor(sessionId)
|
|
@@ -132,8 +136,10 @@ export async function saveGeneratedImageArtifact({
|
|
|
132
136
|
estimatedCost,
|
|
133
137
|
totalCount,
|
|
134
138
|
}) {
|
|
135
|
-
const
|
|
136
|
-
|
|
139
|
+
const targetFormat = format || (args.output_format || cfg.defaultFormat)
|
|
140
|
+
const formatted = await ensureImageFormat(gen.bytes, targetFormat, { fallbackMime: gen.mediaType })
|
|
141
|
+
let bytes = formatted.bytes
|
|
142
|
+
const mediaType = formatted.mediaType || 'image/png'
|
|
137
143
|
if (mediaType === 'image/png') {
|
|
138
144
|
bytes = embedPngMetadata(bytes, {
|
|
139
145
|
prompt: promptArg,
|
|
@@ -189,7 +195,10 @@ export async function saveGeneratedImageArtifact({
|
|
|
189
195
|
prompt: promptArg,
|
|
190
196
|
seed: gen.seed,
|
|
191
197
|
size: args.aspect_ratio ? 'custom' : (args.image_size || cfg.defaultSize),
|
|
192
|
-
style: args.style
|
|
198
|
+
style: args.style,
|
|
199
|
+
stylePreset: (args.style_preset !== undefined && args.style_preset !== null && args.style_preset !== '')
|
|
200
|
+
? args.style_preset
|
|
201
|
+
: (cfg.stylePreset || cfg.defaultStylePreset),
|
|
193
202
|
format: format || args.output_format || cfg.defaultFormat || 'png',
|
|
194
203
|
aspectRatio: args.aspect_ratio || cfg.defaultAspectRatio,
|
|
195
204
|
negativePrompt: args.negative_prompt || cfg.negativePrompt,
|
|
@@ -229,13 +238,10 @@ export async function saveGeneratedImageArtifact({
|
|
|
229
238
|
attachmentId: attachment.attachmentId,
|
|
230
239
|
}
|
|
231
240
|
// Save to history (#385: historyLimit=0 skips history retention; bounds guard against negative values)
|
|
241
|
+
// #365: Atomic transaction with file lock prevents read-modify-write race during concurrent batches
|
|
232
242
|
const historyLimit = typeof cfg.historyLimit === 'number' ? Math.max(0, Math.floor(cfg.historyLimit)) : 50
|
|
233
243
|
if (historyLimit > 0) {
|
|
234
|
-
|
|
235
|
-
current = await pruneHistory(current, cfg.pruneDays)
|
|
236
|
-
current.unshift(entry)
|
|
237
|
-
if (current.length > historyLimit) current.length = historyLimit
|
|
238
|
-
await writeHistory(current)
|
|
244
|
+
await appendHistoryEntry(entry, { pruneDays: cfg.pruneDays, historyLimit })
|
|
239
245
|
}
|
|
240
246
|
|
|
241
247
|
// #167: Record spend in cost meter
|
package/lib/history.js
CHANGED
|
@@ -1,9 +1,30 @@
|
|
|
1
1
|
import path from 'node:path'
|
|
2
2
|
import os from 'node:os'
|
|
3
3
|
import { existsSync, unlinkSync, chmodSync, readdirSync, statSync } from 'node:fs'
|
|
4
|
-
import { readFile,
|
|
4
|
+
import { readFile, mkdir } from 'node:fs/promises'
|
|
5
|
+
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
|
|
5
6
|
import { enforceSecurePermissions } from './security.js'
|
|
6
7
|
|
|
8
|
+
let inMemoryLockQueue = Promise.resolve()
|
|
9
|
+
|
|
10
|
+
/** Execute an operation with in-process queue + cross-process file lock (#365) */
|
|
11
|
+
export async function withHistoryLock(operation, options = { waitMs: 5000 }) {
|
|
12
|
+
const file = historyFile()
|
|
13
|
+
const dir = path.dirname(file)
|
|
14
|
+
try {
|
|
15
|
+
await mkdir(dir, { recursive: true, mode: 0o700 })
|
|
16
|
+
try { chmodSync(dir, 0o700) } catch (_err) { /* non-fatal permission repair */ }
|
|
17
|
+
} catch (_err) { /* non-fatal mkdir */ }
|
|
18
|
+
|
|
19
|
+
const runWithLocks = async () => {
|
|
20
|
+
return await withFileLock(file, operation, options)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const next = inMemoryLockQueue.then(runWithLocks, runWithLocks)
|
|
24
|
+
inMemoryLockQueue = next.catch(() => {})
|
|
25
|
+
return await next
|
|
26
|
+
}
|
|
27
|
+
|
|
7
28
|
export function historyFile() {
|
|
8
29
|
return path.join(process.env.DSH_HOME || path.join(os.homedir(), '.dsh'), 'dsh-image-gen', 'history.json')
|
|
9
30
|
}
|
|
@@ -104,21 +125,50 @@ export async function readHistory() {
|
|
|
104
125
|
const raw = await readFile(historyFile(), 'utf-8')
|
|
105
126
|
const parsed = JSON.parse(raw)
|
|
106
127
|
return Array.isArray(parsed) ? parsed : []
|
|
107
|
-
} catch (
|
|
128
|
+
} catch (_e) {
|
|
108
129
|
return []
|
|
109
130
|
}
|
|
110
131
|
}
|
|
111
132
|
|
|
112
|
-
/** Save history to disk file (atomic overwrite). */
|
|
133
|
+
/** Save history to disk file (atomic overwrite with 0600 mode via dsh-atomic-write). */
|
|
113
134
|
export async function writeHistory(entries) {
|
|
114
135
|
try {
|
|
115
136
|
const file = historyFile()
|
|
116
137
|
const dir = path.dirname(file)
|
|
117
138
|
await mkdir(dir, { recursive: true, mode: 0o700 })
|
|
118
139
|
try { chmodSync(dir, 0o700) } catch (_err) { /* non-fatal permission repair */ }
|
|
119
|
-
await
|
|
140
|
+
await writeFileAtomic(file, JSON.stringify(entries, null, 2), {
|
|
141
|
+
mode: 0o600,
|
|
142
|
+
dirMode: 0o700,
|
|
143
|
+
})
|
|
120
144
|
enforceSecurePermissions(file)
|
|
121
|
-
} catch (
|
|
145
|
+
} catch (_e) { /* history write failure is non-fatal */ }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Atomically append an entry to history, running read -> prune -> unshift -> write
|
|
150
|
+
* under an exclusive lock transaction (#365).
|
|
151
|
+
*/
|
|
152
|
+
export async function appendHistoryEntry(entry, { pruneDays = 0, historyLimit = 50 } = {}) {
|
|
153
|
+
if (!entry || historyLimit <= 0) return []
|
|
154
|
+
return await withHistoryLock(async () => {
|
|
155
|
+
let current = await readHistory()
|
|
156
|
+
if (pruneDays > 0) {
|
|
157
|
+
current = await pruneHistory(current, pruneDays)
|
|
158
|
+
}
|
|
159
|
+
const existingIdx = current.findIndex(
|
|
160
|
+
(e) => (entry.attachmentId && e.attachmentId === entry.attachmentId) || (entry.path && e.path === entry.path)
|
|
161
|
+
)
|
|
162
|
+
if (existingIdx !== -1) {
|
|
163
|
+
current.splice(existingIdx, 1)
|
|
164
|
+
}
|
|
165
|
+
current.unshift(entry)
|
|
166
|
+
if (current.length > historyLimit) {
|
|
167
|
+
current.length = historyLimit
|
|
168
|
+
}
|
|
169
|
+
await writeHistory(current)
|
|
170
|
+
return current
|
|
171
|
+
})
|
|
122
172
|
}
|
|
123
173
|
|
|
124
174
|
/** Filter entries whose files exist on disk; newest first. */
|
package/lib/index.js
CHANGED
|
@@ -114,6 +114,74 @@ export { buildEnhancePromptSystemMessage } from './prompt-enhancer.js'
|
|
|
114
114
|
* Canonical implementation lives in prompt-enhancer.js; re-exported for backwards compat. */
|
|
115
115
|
export { enhancePrompt } from './prompt-enhancer.js'
|
|
116
116
|
|
|
117
|
+
export function createSettingsAdapter(svc, { ns = NS, getLive = () => ({}), setLive = () => {} } = {}) {
|
|
118
|
+
if (!svc) return undefined
|
|
119
|
+
if (typeof svc.replace === 'function' || typeof svc.update === 'function' || typeof svc.mutate === 'function') {
|
|
120
|
+
const getRevision = () => {
|
|
121
|
+
try {
|
|
122
|
+
return svc.describe?.().find((row) => row.ns === ns)?.revision
|
|
123
|
+
} catch (_) {
|
|
124
|
+
return undefined
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
get: () => getLive(),
|
|
129
|
+
replace: async (next) => {
|
|
130
|
+
const parsed = plainConfig(Config(structuredClone(plainConfig(next))))
|
|
131
|
+
const payload = volatileConfig(parsed)
|
|
132
|
+
if (typeof svc.replace === 'function') {
|
|
133
|
+
await svc.replace(ns, payload, getRevision())
|
|
134
|
+
} else if (typeof svc.update === 'function') {
|
|
135
|
+
await svc.update(ns, payload, getRevision())
|
|
136
|
+
} else if (typeof svc.mutate === 'function') {
|
|
137
|
+
await svc.mutate(ns, payload, getRevision())
|
|
138
|
+
} else {
|
|
139
|
+
throw new Error('Settings service has no replace, update, or mutate method')
|
|
140
|
+
}
|
|
141
|
+
setLive(parsed)
|
|
142
|
+
return parsed
|
|
143
|
+
},
|
|
144
|
+
update: async (patch) => {
|
|
145
|
+
const merged = plainConfig(Config({ ...publicConfig(getLive()), ...plainConfig(patch) }))
|
|
146
|
+
const payload = volatileConfig(merged)
|
|
147
|
+
if (typeof svc.update === 'function') {
|
|
148
|
+
await svc.update(ns, payload, getRevision())
|
|
149
|
+
} else if (typeof svc.replace === 'function') {
|
|
150
|
+
await svc.replace(ns, payload, getRevision())
|
|
151
|
+
} else if (typeof svc.mutate === 'function') {
|
|
152
|
+
await svc.mutate(ns, payload, getRevision())
|
|
153
|
+
} else {
|
|
154
|
+
throw new Error('Settings service has no update, replace, or mutate method')
|
|
155
|
+
}
|
|
156
|
+
setLive(merged)
|
|
157
|
+
return merged
|
|
158
|
+
},
|
|
159
|
+
delete: async (key) => {
|
|
160
|
+
const base = structuredClone(plainConfig(getLive()) || {})
|
|
161
|
+
delete base[key]
|
|
162
|
+
const merged = plainConfig(Config(base))
|
|
163
|
+
const payload = volatileConfig(merged)
|
|
164
|
+
if (typeof svc.replace === 'function') {
|
|
165
|
+
await svc.replace(ns, payload, getRevision())
|
|
166
|
+
} else if (typeof svc.update === 'function') {
|
|
167
|
+
await svc.update(ns, payload, getRevision())
|
|
168
|
+
} else if (typeof svc.mutate === 'function') {
|
|
169
|
+
await svc.mutate(ns, payload, getRevision())
|
|
170
|
+
} else {
|
|
171
|
+
throw new Error('Settings service has no replace, update, or mutate method')
|
|
172
|
+
}
|
|
173
|
+
setLive(merged)
|
|
174
|
+
return merged
|
|
175
|
+
},
|
|
176
|
+
watch: (cb) => {
|
|
177
|
+
if (typeof svc.watch === 'function') return svc.watch(cb)
|
|
178
|
+
return () => {}
|
|
179
|
+
},
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return undefined
|
|
183
|
+
}
|
|
184
|
+
|
|
117
185
|
export function apply(ctx, rawConfig) {
|
|
118
186
|
const config = ensureConfig(rawConfig)
|
|
119
187
|
const baseConfig = plainConfig(config)
|
|
@@ -130,50 +198,18 @@ export function apply(ctx, rawConfig) {
|
|
|
130
198
|
|
|
131
199
|
let settingsApi
|
|
132
200
|
let currentLiveConfig = null
|
|
201
|
+
let toolController = null
|
|
133
202
|
let getConfig = () => currentLiveConfig || config
|
|
134
203
|
const live = () => plainConfig(getConfig() ?? {})
|
|
135
204
|
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
} catch (_) {
|
|
143
|
-
return undefined
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
return {
|
|
147
|
-
get: () => live(),
|
|
148
|
-
replace: async (next) => {
|
|
149
|
-
const parsed = plainConfig(Config(structuredClone(plainConfig(next))))
|
|
150
|
-
currentLiveConfig = parsed
|
|
151
|
-
const payload = volatileConfig(parsed)
|
|
152
|
-
if (typeof svc.replace === 'function') {
|
|
153
|
-
await svc.replace(NS, payload, getRevision())
|
|
154
|
-
} else if (typeof svc.update === 'function') {
|
|
155
|
-
await svc.update(NS, payload, getRevision())
|
|
156
|
-
}
|
|
157
|
-
return parsed
|
|
158
|
-
},
|
|
159
|
-
update: async (patch) => {
|
|
160
|
-
const merged = plainConfig(Config({ ...publicConfig(live()), ...plainConfig(patch) }))
|
|
161
|
-
currentLiveConfig = merged
|
|
162
|
-
const payload = volatileConfig(merged)
|
|
163
|
-
if (typeof svc.update === 'function') {
|
|
164
|
-
await svc.update(NS, payload, getRevision())
|
|
165
|
-
} else if (typeof svc.replace === 'function') {
|
|
166
|
-
await svc.replace(NS, payload, getRevision())
|
|
167
|
-
}
|
|
168
|
-
return merged
|
|
169
|
-
},
|
|
170
|
-
watch: (cb) => {
|
|
171
|
-
if (typeof svc.watch === 'function') return svc.watch(cb)
|
|
172
|
-
return () => {}
|
|
173
|
-
},
|
|
205
|
+
const onConfigUpdated = () => {
|
|
206
|
+
try {
|
|
207
|
+
toolController?.sync?.()
|
|
208
|
+
} catch (err) {
|
|
209
|
+
if (typeof ctx.logger?.warn === 'function') {
|
|
210
|
+
ctx.logger.warn(`[dsh-image-gen] reactive tool sync failed: ${err?.message || err}`)
|
|
174
211
|
}
|
|
175
212
|
}
|
|
176
|
-
return undefined
|
|
177
213
|
}
|
|
178
214
|
|
|
179
215
|
let settingsSvc = null
|
|
@@ -194,12 +230,20 @@ export function apply(ctx, rawConfig) {
|
|
|
194
230
|
if (!next) return false
|
|
195
231
|
currentLiveConfig = next
|
|
196
232
|
getConfig = () => currentLiveConfig || config
|
|
233
|
+
onConfigUpdated()
|
|
197
234
|
return true
|
|
198
235
|
}
|
|
199
236
|
|
|
200
237
|
ctx.inject(['settings'], (sctx) => {
|
|
201
238
|
settingsSvc = sctx.settings
|
|
202
|
-
settingsApi = createSettingsAdapter(sctx.settings
|
|
239
|
+
settingsApi = createSettingsAdapter(sctx.settings, {
|
|
240
|
+
ns: NS,
|
|
241
|
+
getLive: live,
|
|
242
|
+
setLive: (c) => {
|
|
243
|
+
currentLiveConfig = c
|
|
244
|
+
onConfigUpdated()
|
|
245
|
+
},
|
|
246
|
+
})
|
|
203
247
|
adoptServed()
|
|
204
248
|
sctx.effect(() => () => {
|
|
205
249
|
getConfig = () => config
|
|
@@ -291,7 +335,10 @@ export function apply(ctx, rawConfig) {
|
|
|
291
335
|
registerSettingsRoutes(ctx, {
|
|
292
336
|
live,
|
|
293
337
|
getSettingsApi: () => settingsApi,
|
|
294
|
-
setLiveConfig: (c) => {
|
|
338
|
+
setLiveConfig: (c) => {
|
|
339
|
+
currentLiveConfig = c
|
|
340
|
+
onConfigUpdated()
|
|
341
|
+
},
|
|
295
342
|
})
|
|
296
343
|
|
|
297
344
|
// One-click plugin updater route per DSH standard
|
|
@@ -398,7 +445,7 @@ export function apply(ctx, rawConfig) {
|
|
|
398
445
|
resolveApiKey,
|
|
399
446
|
})
|
|
400
447
|
|
|
401
|
-
registerAllTools(ctx, {
|
|
448
|
+
toolController = registerAllTools(ctx, {
|
|
402
449
|
config: baseConfig,
|
|
403
450
|
live,
|
|
404
451
|
saveAndAttachResult,
|
|
@@ -406,4 +453,8 @@ export function apply(ctx, rawConfig) {
|
|
|
406
453
|
slugify,
|
|
407
454
|
resolveApiKey,
|
|
408
455
|
})
|
|
456
|
+
|
|
457
|
+
ctx.effect(() => () => {
|
|
458
|
+
toolController?.dispose?.()
|
|
459
|
+
}, 'dsh-image-gen: tool registration cleanup')
|
|
409
460
|
}
|
package/lib/provider-utils.js
CHANGED
|
@@ -117,6 +117,7 @@ export function computeGenerationHash({
|
|
|
117
117
|
seed,
|
|
118
118
|
size,
|
|
119
119
|
style,
|
|
120
|
+
stylePreset,
|
|
120
121
|
format,
|
|
121
122
|
aspectRatio,
|
|
122
123
|
negativePrompt,
|
|
@@ -144,6 +145,7 @@ export function computeGenerationHash({
|
|
|
144
145
|
String(seed ?? ''),
|
|
145
146
|
String(size || '').trim().toLowerCase(),
|
|
146
147
|
String(style || '').trim().toLowerCase(),
|
|
148
|
+
String(stylePreset || '').trim().toLowerCase(),
|
|
147
149
|
String(format || '').trim().toLowerCase(),
|
|
148
150
|
String(aspectRatio || '').trim().toLowerCase(),
|
|
149
151
|
String(negativePrompt || '').trim(),
|