@goodandready/dsh-image-gen 0.11.4 → 0.11.6

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 CHANGED
@@ -50,6 +50,16 @@ window.__ModuleLoader__.load({
50
50
  strokeLinecap: 'round', strokeLinejoin: 'round',
51
51
  }))
52
52
 
53
+
54
+ function resolveTranslator(props, ctx) {
55
+ if (typeof props?.t === 'function') return props.t
56
+ const loc = ctx?.locale || props?.ctx?.locale
57
+ if (loc) {
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
+ }
61
+ return (k, def) => def || k
62
+ }
53
63
  // -------------------------------------------------------------- CSS & Design System
54
64
  function ensureCss() {
55
65
  if (typeof document === 'undefined') return
@@ -2519,6 +2529,23 @@ window.__ModuleLoader__.load({
2519
2529
  'studio.parameters': 'Generation Parameters',
2520
2530
  'studio.generate': 'Generate in Chat',
2521
2531
  'studio.recent': 'Recent Generations',
2532
+ 'studio.prompt': 'Prompt:',
2533
+ 'studio.promptPlaceholder': 'Describe your vision...',
2534
+ 'studio.stylePreset': 'Style Preset:',
2535
+ 'studio.aspectRatio': 'Aspect Ratio:',
2536
+ 'studio.provider': 'Provider:',
2537
+ 'studio.seed': 'Seed (optional):',
2538
+ 'studio.random': 'Random',
2539
+ 'studio.randomizeSeed': 'Randomize seed',
2540
+ 'studio.fullscreen': 'Fullscreen',
2541
+ 'studio.exitFullscreen': 'Exit Fullscreen',
2542
+ 'studio.copyMarkdown': 'Copy Markdown',
2543
+ 'studio.copyHtml': 'Copy HTML',
2544
+ 'studio.download': 'Download',
2545
+ 'studio.emptyCanvas': 'Select an image from the recent strip below or click Generate.',
2546
+ 'studio.copiedCommand': 'Copied generation command to clipboard!',
2547
+ 'studio.copiedSnippet': 'Copied snippet to clipboard!',
2548
+
2522
2549
  'vault.title': 'Asset Browser & Vault',
2523
2550
  'vault.search': 'Search prompt or tags...',
2524
2551
  'vault.loadMore': 'Load more assets',
@@ -2528,6 +2555,19 @@ window.__ModuleLoader__.load({
2528
2555
  'vault.insert': 'Insert in Chat',
2529
2556
  'vault.download': 'Download',
2530
2557
  'vault.delete': 'Delete',
2558
+ 'vault.allProviders': 'All Providers',
2559
+ 'vault.allRatios': 'All Ratios',
2560
+ 'vault.newest': 'Newest',
2561
+ 'vault.oldest': 'Oldest',
2562
+ 'vault.loading': 'Loading...',
2563
+ 'vault.confirmDelete': 'Are you sure you want to delete this asset?',
2564
+ 'vault.deleted': 'Asset deleted',
2565
+ 'vault.deleteFailed': 'Failed to delete asset',
2566
+ 'vault.copiedMarkdown': 'Markdown snippet copied to clipboard!',
2567
+ 'vault.copiedPrompt': 'Prompt & seed copied to clipboard!',
2568
+ 'vault.noPrompt': 'No prompt',
2569
+ 'vault.seed': 'Seed',
2570
+ 'vault.provider': 'Provider',
2531
2571
  }
2532
2572
 
2533
2573
  const zh = {
@@ -2754,6 +2794,23 @@ window.__ModuleLoader__.load({
2754
2794
  'studio.parameters': '生成参数配置',
2755
2795
  'studio.generate': '在对话中生成',
2756
2796
  'studio.recent': '最近生成历史',
2797
+ 'studio.prompt': '提示词:',
2798
+ 'studio.promptPlaceholder': '描述你想创作的画面...',
2799
+ 'studio.stylePreset': '风格预设:',
2800
+ 'studio.aspectRatio': '宽高比:',
2801
+ 'studio.provider': '生成引擎:',
2802
+ 'studio.seed': '随机种子 (可选):',
2803
+ 'studio.random': '随机',
2804
+ 'studio.randomizeSeed': '随机生成种子',
2805
+ 'studio.fullscreen': '全屏',
2806
+ 'studio.exitFullscreen': '退出全屏',
2807
+ 'studio.copyMarkdown': '复制 Markdown',
2808
+ 'studio.copyHtml': '复制 HTML',
2809
+ 'studio.download': '下载图像',
2810
+ 'studio.emptyCanvas': '从下方最近生成列表中选择图像,或点击生成。',
2811
+ 'studio.copiedCommand': '已将生成指令复制到剪贴板!',
2812
+ 'studio.copiedSnippet': '代码片段已复制到剪贴板!',
2813
+
2757
2814
  'vault.title': '图像素材库与浏览器',
2758
2815
  'vault.search': '搜索提示词或标签...',
2759
2816
  'vault.loadMore': '加载更多素材',
@@ -2763,6 +2820,19 @@ window.__ModuleLoader__.load({
2763
2820
  'vault.insert': '插入到当前对话',
2764
2821
  'vault.download': '下载图像',
2765
2822
  'vault.delete': '删除',
2823
+ 'vault.allProviders': '所有引擎',
2824
+ 'vault.allRatios': '所有比例',
2825
+ 'vault.newest': '最新',
2826
+ 'vault.oldest': '最早',
2827
+ 'vault.loading': '加载中...',
2828
+ 'vault.confirmDelete': '确定要删除此素材吗?',
2829
+ 'vault.deleted': '素材已删除',
2830
+ 'vault.deleteFailed': '删除素材失败',
2831
+ 'vault.copiedMarkdown': 'Markdown 片段已复制到剪贴板!',
2832
+ 'vault.copiedPrompt': '提示词与种子已复制到剪贴板!',
2833
+ 'vault.noPrompt': '无提示词',
2834
+ 'vault.seed': '种子',
2835
+ 'vault.provider': '引擎',
2766
2836
  }
2767
2837
 
2768
2838
  // -------------------------------------------------------------- Registration & Apply
@@ -2882,7 +2952,20 @@ window.__ModuleLoader__.load({
2882
2952
  }
2883
2953
 
2884
2954
  function apply(ctx) {
2885
- if (ctx.locale && typeof ctx.locale.define === 'function') {
2955
+ if (ctx.locale && typeof ctx.locale.register === 'function') {
2956
+ try {
2957
+ if (typeof ctx.effect === 'function') {
2958
+ ctx.effect(() => ctx.locale.register(NS, { en, zh }), 'dsh-image-gen: locale')
2959
+ } else {
2960
+ ctx.locale.register(NS, { en, zh })
2961
+ }
2962
+ } catch (_) {
2963
+ try {
2964
+ ctx.locale.register(NS, 'en', en)
2965
+ ctx.locale.register(NS, 'zh', zh)
2966
+ } catch (_) { /* ignore fallback locale register error */ }
2967
+ }
2968
+ } else if (ctx.locale && typeof ctx.locale.define === 'function') {
2886
2969
  ctx.locale.define('en', NS, en)
2887
2970
  ctx.locale.define('zh', NS, zh)
2888
2971
  }
@@ -3068,9 +3151,10 @@ window.__ModuleLoader__.load({
3068
3151
  )
3069
3152
  )
3070
3153
  }
3071
- // -------------------------------------------------------------- Asset Vault View (#159)
3154
+ // -------------------------------------------------------------- Asset Vault View (#159, #304)
3072
3155
  function AssetVaultView(props) {
3073
3156
  const { ctx, onSelectAsset } = props
3157
+ const t = resolveTranslator(props, ctx)
3074
3158
  const [items, setItems] = react.useState([])
3075
3159
  const [loading, setLoading] = react.useState(false)
3076
3160
  const [q, setQ] = react.useState('')
@@ -3115,7 +3199,7 @@ window.__ModuleLoader__.load({
3115
3199
  }, [q, provider, aspect, sort])
3116
3200
 
3117
3201
  const handleDelete = (id) => {
3118
- if (!window.confirm('Are you sure you want to delete this asset?')) return
3202
+ if (!window.confirm(t('vault.confirmDelete') || 'Are you sure you want to delete this asset?')) return
3119
3203
  fetch('/dsh-image-gen/vault?id=' + encodeURIComponent(id), { method: 'DELETE' })
3120
3204
  .then((r) => r.json())
3121
3205
  .then((res) => {
@@ -3124,17 +3208,17 @@ window.__ModuleLoader__.load({
3124
3208
  if (selected && (selected.id === id || selected.attachmentId === id)) {
3125
3209
  setSelected(null)
3126
3210
  }
3127
- showFeedback('Asset deleted')
3211
+ showFeedback(t('vault.deleted') || 'Asset deleted')
3128
3212
  }
3129
3213
  })
3130
- .catch(() => showFeedback('Failed to delete asset'))
3214
+ .catch(() => showFeedback(t('vault.deleteFailed') || 'Failed to delete asset'))
3131
3215
  }
3132
3216
 
3133
3217
  const handleInsert = (item) => {
3134
3218
  const link = '![' + (item.prompt || 'Generated Image') + '](' + (item.url || item.thumbnailUrl) + ')'
3135
3219
  if (typeof navigator !== 'undefined' && navigator.clipboard) {
3136
3220
  navigator.clipboard.writeText(link)
3137
- showFeedback('Markdown snippet copied to clipboard!')
3221
+ showFeedback(t('vault.copiedMarkdown') || 'Markdown snippet copied to clipboard!')
3138
3222
  }
3139
3223
  }
3140
3224
 
@@ -3146,7 +3230,7 @@ window.__ModuleLoader__.load({
3146
3230
  const cmd = '/image ' + (item.prompt || '') + (item.seed !== undefined ? ' --seed ' + item.seed : '')
3147
3231
  if (typeof navigator !== 'undefined' && navigator.clipboard) {
3148
3232
  navigator.clipboard.writeText(cmd)
3149
- showFeedback('Prompt & seed copied to clipboard!')
3233
+ showFeedback(t('vault.copiedPrompt') || 'Prompt & seed copied to clipboard!')
3150
3234
  }
3151
3235
  }
3152
3236
 
@@ -3160,7 +3244,7 @@ window.__ModuleLoader__.load({
3160
3244
  type: 'text',
3161
3245
  className: 'ig-input',
3162
3246
  style: { flex: '1 1 200px', padding: '6px 12px', borderRadius: '6px', border: '1px solid var(--dsw-alias-border-l2)', background: 'var(--dsw-alias-bg-layer-2)', color: 'var(--dsw-alias-label-primary)' },
3163
- placeholder: 'Search prompt or tags...',
3247
+ placeholder: t('vault.search') || 'Search prompt or tags...',
3164
3248
  value: q,
3165
3249
  onChange: (e) => setQ(e.target.value),
3166
3250
  }),
@@ -3172,7 +3256,7 @@ window.__ModuleLoader__.load({
3172
3256
  onChange: (e) => setProvider(e.target.value),
3173
3257
  style: { padding: '6px 10px', borderRadius: '6px', border: '1px solid var(--dsw-alias-border-l2)', background: 'var(--dsw-alias-bg-layer-2)', color: 'var(--dsw-alias-label-primary)' },
3174
3258
  },
3175
- react.createElement('option', { value: 'all' }, 'All Providers'),
3259
+ react.createElement('option', { value: 'all' }, t('vault.allProviders') || 'All Providers'),
3176
3260
  react.createElement('option', { value: 'fal' }, 'FAL.ai'),
3177
3261
  react.createElement('option', { value: 'openai' }, 'OpenAI'),
3178
3262
  react.createElement('option', { value: 'local' }, 'Local (ComfyUI)')
@@ -3185,7 +3269,7 @@ window.__ModuleLoader__.load({
3185
3269
  onChange: (e) => setAspect(e.target.value),
3186
3270
  style: { padding: '6px 10px', borderRadius: '6px', border: '1px solid var(--dsw-alias-border-l2)', background: 'var(--dsw-alias-bg-layer-2)', color: 'var(--dsw-alias-label-primary)' },
3187
3271
  },
3188
- react.createElement('option', { value: 'all' }, 'All Ratios'),
3272
+ react.createElement('option', { value: 'all' }, t('vault.allRatios') || 'All Ratios'),
3189
3273
  react.createElement('option', { value: '1:1' }, '1:1 Square'),
3190
3274
  react.createElement('option', { value: '16:9' }, '16:9 Landscape'),
3191
3275
  react.createElement('option', { value: '9:16' }, '9:16 Portrait'),
@@ -3200,12 +3284,12 @@ window.__ModuleLoader__.load({
3200
3284
  onClick: () => setSort(sort === 'newest' ? 'oldest' : 'newest'),
3201
3285
  style: { border: '1px solid var(--dsw-alias-border-l2)', padding: '6px 10px', borderRadius: '6px', background: 'var(--dsw-alias-bg-layer-2)', color: 'var(--dsw-alias-label-primary)', cursor: 'pointer' },
3202
3286
  },
3203
- sort === 'newest' ? '↓ Newest' : '↑ Oldest'
3287
+ sort === 'newest' ? ('↓ ' + (t('vault.newest') || 'Newest')) : ('↑ ' + (t('vault.oldest') || 'Oldest'))
3204
3288
  )
3205
3289
  ),
3206
3290
  feedback && react.createElement('div', { style: { padding: '6px 12px', borderRadius: '6px', background: 'color-mix(in srgb, var(--dsw-alias-state-brand-primary) 15%, transparent)', color: 'var(--dsw-alias-state-brand-primary)', fontSize: '12px' } }, feedback),
3207
3291
  items.length === 0 && !loading
3208
- ? react.createElement('div', { style: { padding: '32px', textAlign: 'center', color: 'var(--dsw-alias-label-secondary)', fontSize: '13px' } }, 'No assets found in vault.')
3292
+ ? react.createElement('div', { style: { padding: '32px', textAlign: 'center', color: 'var(--dsw-alias-label-secondary)', fontSize: '13px' } }, t('vault.empty') || 'No assets found in vault.')
3209
3293
  : react.createElement(
3210
3294
  'div',
3211
3295
  { className: 'ig-vault-grid' },
@@ -3222,19 +3306,19 @@ window.__ModuleLoader__.load({
3222
3306
  react.createElement(
3223
3307
  'div',
3224
3308
  { className: 'ig-vault-body' },
3225
- react.createElement('div', { className: 'ig-vault-prompt', title: item.prompt }, item.prompt || 'Untitled'),
3309
+ react.createElement('div', { className: 'ig-vault-prompt', title: item.prompt }, item.prompt || (t('vault.noPrompt') || 'Untitled')),
3226
3310
  react.createElement(
3227
3311
  'div',
3228
3312
  { className: 'ig-vault-meta' },
3229
3313
  react.createElement('span', null, item.provider || 'local'),
3230
- item.seed !== undefined && react.createElement('span', null, 'Seed ' + item.seed)
3314
+ item.seed !== undefined && react.createElement('span', null, (t('vault.seed') || 'Seed') + ' ' + item.seed)
3231
3315
  ),
3232
3316
  react.createElement(
3233
3317
  'div',
3234
3318
  { style: { display: 'flex', gap: '4px', marginTop: '6px' } },
3235
- react.createElement('button', { type: 'button', className: 'ig-rev-btn', style: { flex: 1 }, onClick: () => handleReroll(item), title: 'Re-roll with seed' }, '🎲 Re-roll'),
3236
- react.createElement('button', { type: 'button', className: 'ig-rev-btn', style: { flex: 1 }, onClick: () => handleInsert(item), title: 'Insert into chat' }, '💬 Insert'),
3237
- react.createElement('button', { type: 'button', className: 'ig-rev-btn', onClick: () => handleDelete(item.id || item.attachmentId), title: 'Delete' }, '🗑️')
3319
+ react.createElement('button', { type: 'button', className: 'ig-rev-btn', style: { flex: 1 }, onClick: () => handleReroll(item), title: t('vault.reroll') || 'Re-roll with seed' }, '🎲 ' + (t('vault.reroll') || 'Re-roll')),
3320
+ react.createElement('button', { type: 'button', className: 'ig-rev-btn', style: { flex: 1 }, onClick: () => handleInsert(item), title: t('vault.insert') || 'Insert into chat' }, '💬 ' + (t('vault.insert') || 'Insert')),
3321
+ react.createElement('button', { type: 'button', className: 'ig-rev-btn', onClick: () => handleDelete(item.id || item.attachmentId), title: t('vault.delete') || 'Delete' }, '🗑️')
3238
3322
  )
3239
3323
  )
3240
3324
  )
@@ -3253,7 +3337,7 @@ window.__ModuleLoader__.load({
3253
3337
  disabled: loading,
3254
3338
  style: { padding: '8px 20px', borderRadius: '6px', border: '1px solid var(--dsw-alias-border-l2)', background: 'var(--dsw-alias-bg-layer-2)', color: 'var(--dsw-alias-label-primary)', cursor: 'pointer' },
3255
3339
  },
3256
- loading ? 'Loading...' : 'Load more assets'
3340
+ loading ? (t('vault.loading') || 'Loading...') : (t('vault.loadMore') || 'Load more assets')
3257
3341
  )
3258
3342
  ),
3259
3343
  selected &&
@@ -3272,7 +3356,7 @@ window.__ModuleLoader__.load({
3272
3356
  react.createElement(
3273
3357
  'div',
3274
3358
  { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
3275
- react.createElement('span', { style: { fontWeight: '700', fontSize: '14px', color: 'var(--dsw-alias-label-primary)' } }, 'Asset Inspector'),
3359
+ react.createElement('span', { style: { fontWeight: '700', fontSize: '14px', color: 'var(--dsw-alias-label-primary)' } }, t('vault.inspector') || 'Asset Inspector'),
3276
3360
  react.createElement('button', { type: 'button', onClick: () => setSelected(null), style: { background: 'none', border: 'none', cursor: 'pointer', fontSize: '16px', color: 'var(--dsw-alias-label-secondary)' } }, '✕')
3277
3361
  ),
3278
3362
  react.createElement('img', {
@@ -3282,31 +3366,32 @@ window.__ModuleLoader__.load({
3282
3366
  react.createElement(
3283
3367
  'div',
3284
3368
  { style: { display: 'flex', flexWrap: 'wrap', gap: '6px', fontSize: '11px' } },
3285
- selected.seed !== undefined && react.createElement('span', { className: 'ig-badge' }, 'Seed: ' + selected.seed),
3286
- selected.provider && react.createElement('span', { className: 'ig-badge' }, 'Provider: ' + selected.provider),
3369
+ selected.seed !== undefined && react.createElement('span', { className: 'ig-badge' }, (t('vault.seed') || 'Seed') + ': ' + selected.seed),
3370
+ selected.provider && react.createElement('span', { className: 'ig-badge' }, (t('vault.provider') || 'Provider') + ': ' + selected.provider),
3287
3371
  selected.width && react.createElement('span', { className: 'ig-badge' }, selected.width + '×' + selected.height),
3288
3372
  selected.cost ? react.createElement('span', { className: 'ig-badge' }, '$' + Number(selected.cost).toFixed(4)) : null
3289
3373
  ),
3290
- react.createElement('div', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-primary)', background: 'var(--dsw-alias-bg-layer-2)', padding: '10px', borderRadius: '6px', lineHeight: '1.4' } }, selected.prompt || 'No prompt'),
3374
+ react.createElement('div', { style: { fontSize: '12px', color: 'var(--dsw-alias-label-primary)', background: 'var(--dsw-alias-bg-layer-2)', padding: '10px', borderRadius: '6px', lineHeight: '1.4' } }, selected.prompt || (t('vault.noPrompt') || 'No prompt')),
3291
3375
  react.createElement(
3292
3376
  'div',
3293
3377
  { style: { display: 'flex', gap: '8px', justifyContent: 'flex-end', marginTop: '6px' } },
3294
- react.createElement('button', { type: 'button', className: 'ig-tab-btn', onClick: () => handleReroll(selected) }, '🎲 Re-roll'),
3295
- react.createElement('button', { type: 'button', className: 'ig-tab-btn', onClick: () => handleInsert(selected) }, '💬 Insert in Chat'),
3378
+ react.createElement('button', { type: 'button', className: 'ig-tab-btn', onClick: () => handleReroll(selected) }, '🎲 ' + (t('vault.reroll') || 'Re-roll')),
3379
+ react.createElement('button', { type: 'button', className: 'ig-tab-btn', onClick: () => handleInsert(selected) }, '💬 ' + (t('vault.insert') || 'Insert in Chat')),
3296
3380
  react.createElement(
3297
3381
  'a',
3298
3382
  { href: selected.url || selected.thumbnailUrl, download: 'vault-asset-' + (selected.id || 'image') + '.png', className: 'ig-tab-btn', style: { textDecoration: 'none', display: 'inline-flex', alignItems: 'center' } },
3299
- '⬇ Download'
3383
+ '⬇ ' + (t('vault.download') || 'Download')
3300
3384
  ),
3301
- react.createElement('button', { type: 'button', className: 'ig-tab-btn', style: { color: 'var(--dsw-alias-state-error-primary)' }, onClick: () => handleDelete(selected.id || selected.attachmentId) }, '🗑️ Delete')
3385
+ react.createElement('button', { type: 'button', className: 'ig-tab-btn', style: { color: 'var(--dsw-alias-state-error-primary)' }, onClick: () => handleDelete(selected.id || selected.attachmentId) }, '🗑️ ' + (t('vault.delete') || 'Delete'))
3302
3386
  )
3303
3387
  )
3304
3388
  )
3305
3389
  )
3306
3390
  }
3307
- // -------------------------------------------------------------- Fullscreen Image Studio (#158)
3391
+ // -------------------------------------------------------------- Fullscreen Image Studio (#158, #304)
3308
3392
  function ImageStudioView(props) {
3309
3393
  const { ctx } = props
3394
+ const t = resolveTranslator(props, ctx)
3310
3395
  const [viewMode, setViewMode] = react.useState('studio') // 'studio' | 'vault'
3311
3396
  const [isFullscreen, setIsFullscreen] = react.useState(false)
3312
3397
  const [layoutGrid, setLayoutGrid] = react.useState('1x1') // '1x1' | '2x2' | '1x4'
@@ -3350,16 +3435,16 @@ window.__ModuleLoader__.load({
3350
3435
 
3351
3436
  const updatePrompt = (val) => {
3352
3437
  setPrompt(val)
3353
- try { window.localStorage.setItem('dsh_studio_prompt', val) } catch (_) { /* storage unavailable */ }
3438
+ try { window.localStorage.setItem('dsh_studio_prompt', val) } catch (_) { /* ignore storage quota or private mode error */ }
3354
3439
  }
3355
3440
 
3356
- const handleSelectFromVault = (asset) => {
3357
- if (!asset) return
3358
- setActiveImages([asset])
3359
- if (asset.prompt) updatePrompt(asset.prompt)
3360
- if (asset.seed !== undefined) setSeed(String(asset.seed))
3361
- if (asset.provider) setProvider(asset.provider)
3441
+ const handleSelectFromVault = (item) => {
3442
+ if (!item) return
3443
+ setActiveImages([item])
3362
3444
  setViewMode('studio')
3445
+ if (item.prompt) updatePrompt(item.prompt)
3446
+ if (item.provider) setProvider(item.provider)
3447
+ if (item.seed !== undefined) setSeed(String(item.seed))
3363
3448
  }
3364
3449
 
3365
3450
  const handleCopySnippet = (format) => {
@@ -3371,7 +3456,7 @@ window.__ModuleLoader__.load({
3371
3456
  if (format === 'html') snippet = '<img src="' + url + '" alt="' + (first.prompt || 'Artwork') + '" />'
3372
3457
  if (typeof navigator !== 'undefined' && navigator.clipboard) {
3373
3458
  navigator.clipboard.writeText(snippet)
3374
- setStatus('Copied ' + format.toUpperCase() + ' snippet!')
3459
+ setStatus(t('studio.copiedSnippet') || ('Copied ' + format.toUpperCase() + ' snippet!'))
3375
3460
  setTimeout(() => setStatus(''), 2000)
3376
3461
  }
3377
3462
  }
@@ -3389,7 +3474,7 @@ window.__ModuleLoader__.load({
3389
3474
  react.createElement(
3390
3475
  'div',
3391
3476
  { style: { display: 'flex', gap: '8px', alignItems: 'center' } },
3392
- react.createElement('span', { style: { fontWeight: '700', fontSize: '15px', color: 'var(--dsw-alias-label-primary)' } }, '🎨 Image Studio'),
3477
+ react.createElement('span', { style: { fontWeight: '700', fontSize: '15px', color: 'var(--dsw-alias-label-primary)' } }, '🎨 ' + (t('studio.title') || 'Image Studio')),
3393
3478
  react.createElement(
3394
3479
  'button',
3395
3480
  {
@@ -3397,7 +3482,7 @@ window.__ModuleLoader__.load({
3397
3482
  className: 'ig-tab-btn ' + (viewMode === 'studio' ? 'active' : ''),
3398
3483
  onClick: () => setViewMode('studio'),
3399
3484
  },
3400
- 'Studio Canvas'
3485
+ t('studio.canvas') || 'Studio Canvas'
3401
3486
  ),
3402
3487
  react.createElement(
3403
3488
  'button',
@@ -3406,7 +3491,7 @@ window.__ModuleLoader__.load({
3406
3491
  className: 'ig-tab-btn ' + (viewMode === 'vault' ? 'active' : ''),
3407
3492
  onClick: () => setViewMode('vault'),
3408
3493
  },
3409
- '🗃️ Asset Vault'
3494
+ '🗃️ ' + (t('studio.vault') || 'Asset Vault')
3410
3495
  )
3411
3496
  ),
3412
3497
  react.createElement(
@@ -3438,12 +3523,12 @@ window.__ModuleLoader__.load({
3438
3523
  onClick: () => setIsFullscreen(!isFullscreen),
3439
3524
  style: { border: '1px solid var(--dsw-alias-border-l2)' },
3440
3525
  },
3441
- isFullscreen ? '✕ Exit Fullscreen' : '⛶ Fullscreen'
3526
+ isFullscreen ? ('✕ ' + (t('studio.exitFullscreen') || 'Exit Fullscreen')) : ('⛶ ' + (t('studio.fullscreen') || 'Fullscreen'))
3442
3527
  )
3443
3528
  )
3444
3529
  ),
3445
3530
  viewMode === 'vault'
3446
- ? react.createElement(AssetVaultView, { ctx, onSelectAsset: handleSelectFromVault })
3531
+ ? react.createElement(AssetVaultView, { ctx, t, onSelectAsset: handleSelectFromVault })
3447
3532
  : react.createElement(
3448
3533
  react.Fragment,
3449
3534
  null,
@@ -3453,16 +3538,16 @@ window.__ModuleLoader__.load({
3453
3538
  react.createElement(
3454
3539
  'div',
3455
3540
  { className: 'ig-studio-sidebar' },
3456
- react.createElement('span', { style: { fontWeight: '600', fontSize: '13px', color: 'var(--dsw-alias-label-primary)' } }, 'Generation Parameters'),
3541
+ react.createElement('span', { style: { fontWeight: '600', fontSize: '13px', color: 'var(--dsw-alias-label-primary)' } }, t('studio.parameters') || 'Generation Parameters'),
3457
3542
  react.createElement(
3458
3543
  'div',
3459
3544
  null,
3460
- react.createElement('label', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-secondary)', display: 'block', marginBottom: '4px' } }, 'Prompt:'),
3545
+ react.createElement('label', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-secondary)', display: 'block', marginBottom: '4px' } }, t('studio.prompt') || 'Prompt:'),
3461
3546
  react.createElement('textarea', {
3462
3547
  className: 'ig-input',
3463
3548
  rows: 4,
3464
3549
  style: { width: '100%', resize: 'vertical', borderRadius: '6px', padding: '8px', fontSize: '12px' },
3465
- placeholder: 'Describe your vision...',
3550
+ placeholder: t('studio.promptPlaceholder') || 'Describe your vision...',
3466
3551
  value: prompt,
3467
3552
  onChange: (e) => updatePrompt(e.target.value),
3468
3553
  })
@@ -3470,7 +3555,7 @@ window.__ModuleLoader__.load({
3470
3555
  react.createElement(
3471
3556
  'div',
3472
3557
  null,
3473
- react.createElement('label', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-secondary)', display: 'block', marginBottom: '4px' } }, 'Style Preset:'),
3558
+ react.createElement('label', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-secondary)', display: 'block', marginBottom: '4px' } }, t('studio.stylePreset') || 'Style Preset:'),
3474
3559
  react.createElement(
3475
3560
  'div',
3476
3561
  { style: { display: 'flex', flexWrap: 'wrap', gap: '4px' } },
@@ -3495,7 +3580,7 @@ window.__ModuleLoader__.load({
3495
3580
  react.createElement(
3496
3581
  'div',
3497
3582
  null,
3498
- react.createElement('label', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-secondary)', display: 'block', marginBottom: '4px' } }, 'Aspect Ratio:'),
3583
+ react.createElement('label', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-secondary)', display: 'block', marginBottom: '4px' } }, t('studio.aspectRatio') || 'Aspect Ratio:'),
3499
3584
  react.createElement(
3500
3585
  'select',
3501
3586
  {
@@ -3514,7 +3599,7 @@ window.__ModuleLoader__.load({
3514
3599
  react.createElement(
3515
3600
  'div',
3516
3601
  null,
3517
- react.createElement('label', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-secondary)', display: 'block', marginBottom: '4px' } }, 'Provider:'),
3602
+ react.createElement('label', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-secondary)', display: 'block', marginBottom: '4px' } }, t('studio.provider') || 'Provider:'),
3518
3603
  react.createElement(
3519
3604
  'select',
3520
3605
  {
@@ -3532,7 +3617,7 @@ window.__ModuleLoader__.load({
3532
3617
  react.createElement(
3533
3618
  'div',
3534
3619
  null,
3535
- react.createElement('label', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-secondary)', display: 'block', marginBottom: '4px' } }, 'Seed (optional):'),
3620
+ react.createElement('label', { style: { fontSize: '11px', color: 'var(--dsw-alias-label-secondary)', display: 'block', marginBottom: '4px' } }, t('studio.seed') || 'Seed (optional):'),
3536
3621
  react.createElement(
3537
3622
  'div',
3538
3623
  { style: { display: 'flex', gap: '6px' } },
@@ -3540,7 +3625,7 @@ window.__ModuleLoader__.load({
3540
3625
  type: 'number',
3541
3626
  className: 'ig-input',
3542
3627
  style: { flex: 1, padding: '6px', fontSize: '12px' },
3543
- placeholder: 'Random',
3628
+ placeholder: t('studio.random') || 'Random',
3544
3629
  value: seed,
3545
3630
  onChange: (e) => setSeed(e.target.value),
3546
3631
  }),
@@ -3550,7 +3635,7 @@ window.__ModuleLoader__.load({
3550
3635
  type: 'button',
3551
3636
  className: 'ig-tab-btn',
3552
3637
  onClick: () => setSeed(String(Math.floor(Math.random() * 2147483647))),
3553
- title: 'Randomize seed',
3638
+ title: t('studio.randomizeSeed') || 'Randomize seed',
3554
3639
  },
3555
3640
  '🎲'
3556
3641
  )
@@ -3566,12 +3651,12 @@ window.__ModuleLoader__.load({
3566
3651
  const cmd = '/image ' + (prompt || 'Artwork') + (stylePreset !== 'none' ? ' --style ' + stylePreset : '') + ' --aspect ' + aspectRatio + ' --provider ' + provider + (seed ? ' --seed ' + seed : '')
3567
3652
  if (typeof navigator !== 'undefined' && navigator.clipboard) {
3568
3653
  navigator.clipboard.writeText(cmd)
3569
- setStatus('Copied generation command to clipboard!')
3654
+ setStatus(t('studio.copiedCommand') || 'Copied generation command to clipboard!')
3570
3655
  setTimeout(() => setStatus(''), 2500)
3571
3656
  }
3572
3657
  },
3573
3658
  },
3574
- '🎨 Generate in Chat'
3659
+ '🎨 ' + (t('studio.generate') || 'Generate in Chat')
3575
3660
  ),
3576
3661
  status && react.createElement('div', { style: { fontSize: '11px', color: 'var(--dsw-alias-state-brand-primary)', textAlign: 'center' } }, status)
3577
3662
  ),
@@ -3579,7 +3664,7 @@ window.__ModuleLoader__.load({
3579
3664
  'div',
3580
3665
  { className: 'ig-studio-canvas' },
3581
3666
  activeImages.length === 0
3582
- ? react.createElement('div', { style: { color: 'var(--dsw-alias-label-secondary)', fontSize: '13px' } }, 'Select an image from the recent strip below or click Generate.')
3667
+ ? react.createElement('div', { style: { color: 'var(--dsw-alias-label-secondary)', fontSize: '13px' } }, t('studio.emptyCanvas') || 'Select an image from the recent strip below or click Generate.')
3583
3668
  : react.createElement(
3584
3669
  'div',
3585
3670
  {
@@ -3604,12 +3689,12 @@ window.__ModuleLoader__.load({
3604
3689
  react.createElement(
3605
3690
  'div',
3606
3691
  { style: { display: 'flex', gap: '8px', marginTop: '10px' } },
3607
- react.createElement('button', { type: 'button', className: 'ig-tab-btn', onClick: () => handleCopySnippet('markdown') }, 'Copy Markdown'),
3608
- react.createElement('button', { type: 'button', className: 'ig-tab-btn', onClick: () => handleCopySnippet('html') }, 'Copy HTML'),
3692
+ react.createElement('button', { type: 'button', className: 'ig-tab-btn', onClick: () => handleCopySnippet('markdown') }, t('studio.copyMarkdown') || 'Copy Markdown'),
3693
+ react.createElement('button', { type: 'button', className: 'ig-tab-btn', onClick: () => handleCopySnippet('html') }, t('studio.copyHtml') || 'Copy HTML'),
3609
3694
  react.createElement(
3610
3695
  'a',
3611
3696
  { href: activeImages[0].url || activeImages[0].thumbnailUrl, download: 'studio-image.png', className: 'ig-tab-btn', style: { textDecoration: 'none' } },
3612
- '⬇ Download'
3697
+ '⬇ ' + (t('studio.download') || 'Download')
3613
3698
  )
3614
3699
  )
3615
3700
  )
@@ -3617,7 +3702,7 @@ window.__ModuleLoader__.load({
3617
3702
  react.createElement(
3618
3703
  'div',
3619
3704
  { style: { display: 'flex', flexDirection: 'column', gap: '6px', marginTop: '10px' } },
3620
- react.createElement('span', { style: { fontSize: '12px', fontWeight: '600', color: 'var(--dsw-alias-label-secondary)' } }, 'Recent Generations:'),
3705
+ react.createElement('span', { style: { fontSize: '12px', fontWeight: '600', color: 'var(--dsw-alias-label-secondary)' } }, (t('studio.recent') || 'Recent Generations') + ':'),
3621
3706
  react.createElement(
3622
3707
  'div',
3623
3708
  { className: 'ig-studio-strip', style: { display: 'flex', gap: '8px', overflowX: 'auto', padding: '4px 0' } },
@@ -0,0 +1,176 @@
1
+ // commands.js — Top-level slash command /image for direct generation without LLM reasoning (#152)
2
+
3
+ import { makeProviders, resolveStylePreset } from './providers.js'
4
+ import { ASPECT_RATIOS } from './providers/shared-helpers.js'
5
+ import { calculateGenerationCost } from './cost-meter.js'
6
+
7
+ /**
8
+ * Parse raw slash command arguments: /image <prompt> [--flags]
9
+ *
10
+ * @param {string} rawInput
11
+ * @returns {{ prompt: string, flags: Record<string, string>, error?: string }}
12
+ */
13
+ export function parseImageCommandArgs(rawInput) {
14
+ const str = String(rawInput || '').trim()
15
+ if (!str) return { prompt: '', flags: {}, error: 'Missing prompt' }
16
+
17
+ const tokens = []
18
+ const re = /[^\s"']+|"([^"]*)"|'([^']*)'/g
19
+ let match
20
+ while ((match = re.exec(str)) !== null) {
21
+ tokens.push(match[1] || match[2] || match[0])
22
+ }
23
+
24
+ const flags = {}
25
+ const promptWords = []
26
+
27
+ for (let i = 0; i < tokens.length; i++) {
28
+ const t = tokens[i]
29
+ if (t.startsWith('--')) {
30
+ const eqIdx = t.indexOf('=')
31
+ if (eqIdx !== -1) {
32
+ const key = t.slice(2, eqIdx).toLowerCase()
33
+ const val = t.slice(eqIdx + 1)
34
+ flags[key] = val
35
+ } else {
36
+ const key = t.slice(2).toLowerCase()
37
+ if (i + 1 < tokens.length && !tokens[i + 1].startsWith('--')) {
38
+ flags[key] = tokens[i + 1]
39
+ i++
40
+ } else {
41
+ flags[key] = 'true'
42
+ }
43
+ }
44
+ } else {
45
+ promptWords.push(t)
46
+ }
47
+ }
48
+
49
+ const prompt = promptWords.join(' ').trim()
50
+ return { prompt, flags }
51
+ }
52
+
53
+ /**
54
+ * Registers the /image slash command via @deepseek-ai/dsh-commands service.
55
+ *
56
+ * @param {object} ctx - Cordis context
57
+ * @param {object} deps - Shared plugin runtime dependencies
58
+ */
59
+ export function registerImageCommand(ctx, deps) {
60
+ const { live, saveAndAttachResult, resolveApiKey, slugify } = deps
61
+
62
+ ctx.inject(['commands'], (cmdCtx) => {
63
+ const commands = cmdCtx.commands
64
+ if (typeof commands?.register !== 'function') return
65
+
66
+ const executeCommand = async (rawInput, invocation = {}) => {
67
+ const { prompt, flags } = parseImageCommandArgs(rawInput)
68
+ if (!prompt) {
69
+ return {
70
+ kind: 'error',
71
+ text: 'Usage: /image <prompt> [--size=...] [--aspect=...] [--provider=...] [--style=...] [--seed=...]',
72
+ toString() { return this.text },
73
+ }
74
+ }
75
+
76
+ const cfg = typeof live === 'function' ? live() : (deps.config || {})
77
+ const provider = flags.provider || cfg.provider || 'fal'
78
+ const size = flags.size || cfg.defaultSize || 'square'
79
+ const format = flags.format || cfg.defaultFormat || 'png'
80
+ const aspectRatio = flags.aspect || flags.aspect_ratio || cfg.defaultAspectRatio
81
+ const effectiveStyle = flags.style || cfg.stylePreset
82
+ const seed = flags.seed ? Number(flags.seed) : Math.floor(Math.random() * 2147483647)
83
+
84
+ const styleInfo = resolveStylePreset(effectiveStyle, flags.negative_prompt, flags.guidance_scale)
85
+ const effectivePrompt = styleInfo.promptSuffix ? `${prompt}, ${styleInfo.promptSuffix}` : prompt
86
+
87
+ const job = {
88
+ prompt: effectivePrompt,
89
+ size,
90
+ format,
91
+ aspectRatio,
92
+ aspectPixels: aspectRatio ? ASPECT_RATIOS[aspectRatio] : undefined,
93
+ seed,
94
+ signal: invocation?.signal,
95
+ }
96
+
97
+ const pdeps = {
98
+ fetchImpl: fetch,
99
+ resolveKey: (ref) => resolveApiKey(ctx, ref),
100
+ cfg,
101
+ ctx,
102
+ }
103
+
104
+ const providerFactory = deps.makeProviders || makeProviders
105
+ const providers = providerFactory(pdeps, job)
106
+ const generateFn = providers[provider] || providers.fal || providers.custom
107
+ if (typeof generateFn !== 'function') {
108
+ throw new Error(`Unsupported provider: "${provider}". One of: fal, openai, grok, gemini, replicate, local, seedream`)
109
+ }
110
+
111
+ const result = await generateFn(seed, effectivePrompt)
112
+ const safeSlug = typeof slugify === 'function' ? slugify(prompt.slice(0, 32)) : 'image'
113
+ const stem = `${safeSlug}-${Date.now().toString(36)}`
114
+ const name = `${stem}.${format}`
115
+
116
+ const cost = calculateGenerationCost({
117
+ provider,
118
+ model: cfg.model || cfg.customModel || 'default',
119
+ size,
120
+ count: 1,
121
+ })
122
+
123
+ const attached = await saveAndAttachResult(ctx, invocation, cfg, {
124
+ bytes: result.bytes,
125
+ mediaType: result.mediaType || 'image/png',
126
+ name,
127
+ stem,
128
+ prompt,
129
+ size,
130
+ format,
131
+ seed,
132
+ provider,
133
+ model: cfg.model || cfg.customModel || 'default',
134
+ cost,
135
+ sourceUrl: result.sourceUrl,
136
+ deliverAs: cfg.deliverAs || 'link',
137
+ args: { prompt, ...flags },
138
+ action: 'generated via /image command',
139
+ })
140
+
141
+ return {
142
+ kind: 'success',
143
+ text: attached.summary,
144
+ attachment: attached.attachment,
145
+ toString() { return attached.summary },
146
+ }
147
+ }
148
+
149
+ const unregister = commands.register({
150
+ definitionId: '@goodandready/dsh-image-gen',
151
+ name: 'image',
152
+ description: 'Generate an image directly without chat model reasoning (/image <prompt> [--size=...] [--aspect=...] [--provider=...] [--style=...])',
153
+ input: { hint: '<prompt> [--size=...] [--aspect=...] [--provider=...] [--style=...]' },
154
+ handler: async (invocation) => {
155
+ try {
156
+ const raw = invocation?.rawInput || invocation?.text || (typeof invocation === 'string' ? invocation : '')
157
+ return await executeCommand(raw, invocation)
158
+ } catch (err) {
159
+ return {
160
+ kind: 'error',
161
+ text: `Image generation failed: ${err?.message || err}`,
162
+ toString() { return `Image generation failed: ${err?.message || err}` },
163
+ }
164
+ }
165
+ },
166
+ execute: async (rawArgs, invocation) => {
167
+ const res = await executeCommand(rawArgs, invocation)
168
+ return res.text
169
+ },
170
+ })
171
+
172
+ if (typeof ctx.effect === 'function') {
173
+ ctx.effect(() => () => unregister?.(), 'dsh-image-gen: /image slash command')
174
+ }
175
+ })
176
+ }
package/lib/history.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import path from 'node:path'
2
2
  import os from 'node:os'
3
- import { existsSync, unlinkSync } from 'node:fs'
3
+ import { existsSync, unlinkSync, chmodSync, readdirSync, statSync } from 'node:fs'
4
4
  import { readFile, writeFile, mkdir } from 'node:fs/promises'
5
5
  import { enforceSecurePermissions } from './security.js'
6
6
 
@@ -68,6 +68,37 @@ export async function pruneHistory(entries, pruneDays) {
68
68
  return kept
69
69
  }
70
70
 
71
+ /**
72
+ * Repairs permissions on the history directory (0700) and history file (0600),
73
+ * migrating existing installations created under world-readable umasks (#307).
74
+ */
75
+ export function repairHistoryPermissions() {
76
+ try {
77
+ const file = historyFile()
78
+ const dir = path.dirname(file)
79
+ if (existsSync(dir)) {
80
+ try { chmodSync(dir, 0o700) } catch (_err) { /* non-fatal permission repair */ }
81
+ try {
82
+ const files = readdirSync(dir)
83
+ for (const f of files) {
84
+ const p = path.join(dir, f)
85
+ try {
86
+ const st = statSync(p)
87
+ if (st.isDirectory()) {
88
+ chmodSync(p, 0o700)
89
+ } else {
90
+ chmodSync(p, 0o600)
91
+ }
92
+ } catch (_err) { /* skip unreadable entry */ }
93
+ }
94
+ } catch (_err) { /* skip unreadable directory */ }
95
+ }
96
+ if (existsSync(file)) {
97
+ try { chmodSync(file, 0o600) } catch (_err) { /* non-fatal permission repair */ }
98
+ }
99
+ } catch (_err) { /* non-fatal permission repair */ }
100
+ }
101
+
71
102
  export async function readHistory() {
72
103
  try {
73
104
  const raw = await readFile(historyFile(), 'utf-8')
@@ -81,9 +112,12 @@ export async function readHistory() {
81
112
  /** Save history to disk file (atomic overwrite). */
82
113
  export async function writeHistory(entries) {
83
114
  try {
84
- await mkdir(path.dirname(historyFile()), { recursive: true })
85
- await writeFile(historyFile(), JSON.stringify(entries, null, 2))
86
- enforceSecurePermissions(historyFile())
115
+ const file = historyFile()
116
+ const dir = path.dirname(file)
117
+ await mkdir(dir, { recursive: true, mode: 0o700 })
118
+ try { chmodSync(dir, 0o700) } catch (_err) { /* non-fatal permission repair */ }
119
+ await writeFile(file, JSON.stringify(entries, null, 2), { mode: 0o600 })
120
+ enforceSecurePermissions(file)
87
121
  } catch (e) { /* history write failure is non-fatal */ }
88
122
  }
89
123
 
package/lib/index.js CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  import z from '@deepseek-ai/schemastery'
16
16
  import { credentialRef } from '@deepseek-ai/dsh-credentials'
17
- import { readFile } from 'node:fs/promises'
17
+ import { readFile, stat } from 'node:fs/promises'
18
18
  import { isTrustedLocalRequest } from './security.js'
19
19
  import { saveAndAttachResult } from './attachment-helper.js'
20
20
  import {
@@ -27,6 +27,7 @@ import {
27
27
  readHistory,
28
28
  writeHistory,
29
29
  filterHistory,
30
+ repairHistoryPermissions,
30
31
  } from './history.js'
31
32
  import { existsSync } from 'node:fs'
32
33
  import path from 'node:path'
@@ -41,6 +42,7 @@ import {
41
42
  } from './providers.js'
42
43
 
43
44
  import { resolveConversationImage, analyzeImageWithVision } from './resolve-image.js'
45
+ import { registerImageCommand } from './commands.js'
44
46
  import { registerAllTools } from './register-tools.js'
45
47
  import { registerPluginUpdater } from './updater.js'
46
48
  import { registerVaultRoutes } from './vault.js'
@@ -60,6 +62,7 @@ export {
60
62
  readHistory,
61
63
  writeHistory,
62
64
  filterHistory,
65
+ repairHistoryPermissions,
63
66
  }
64
67
 
65
68
  export const name = '@goodandready/dsh-image-gen'
@@ -349,16 +352,60 @@ export function publicConfig(cfg) {
349
352
  /** Read source image for editing: filesystem path or attachment id. */
350
353
  export async function resolveSource(ctx, exec, ref) {
351
354
  if (!ref) return undefined
352
- const sessionCwd = exec.agent?.session?.header?.cwd
355
+ const sessionCwd = exec?.agent?.session?.header?.cwd || process.cwd()
353
356
  if (ref.startsWith('sha256:')) {
354
357
  const stored = await ctx.attachments.readImage({ attachmentId: ref, mediaType: 'image/png', bytes: 0, width: 0, height: 0 })
355
- return { bytes: Buffer.from(stored.data), mediaType: stored.ref?.mediaType || 'image/png' }
358
+ const bytes = Buffer.from(stored.data || stored.bytes || [])
359
+ validateImageSignature(bytes, ref)
360
+ return { bytes, mediaType: stored.ref?.mediaType || 'image/png' }
356
361
  }
357
- const p = path.resolve(sessionCwd || process.cwd(), ref)
358
- const bytes = await readFile(p)
359
- const ext = path.extname(p).toLowerCase()
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()
360
406
  const mediaType = ext === '.jpg' || ext === '.jpeg' ? 'image/jpeg'
361
407
  : ext === '.webp' ? 'image/webp'
408
+ : ext === '.gif' ? 'image/gif'
362
409
  : ext === '.svg' ? 'image/svg+xml'
363
410
  : 'image/png'
364
411
  return { bytes, mediaType }
@@ -581,7 +628,10 @@ export function apply(ctx, rawConfig) {
581
628
  })()
582
629
 
583
630
  for (const path of ['/dsh-image-gen/image', '/dsh-fal-image-gen/image']) {
584
- ctx.effect(() => ctx.webServer.register({
631
+ // Repair history file & directory permissions on startup (#307)
632
+ repairHistoryPermissions()
633
+
634
+ ctx.effect(() => ctx.webServer.register({
585
635
  kind: 'exact',
586
636
  path,
587
637
  handler: imageHandler,
@@ -676,6 +726,16 @@ export function apply(ctx, rawConfig) {
676
726
  }, 'dsh-image-gen: session cleanup')
677
727
 
678
728
  // Tool registrations live in register-tools.js; each tool is a labeled ctx.effect (#216).
729
+ // Top-level /image slash command (#152)
730
+ registerImageCommand(ctx, {
731
+ config: baseConfig,
732
+ live,
733
+ saveAndAttachResult,
734
+ resolveSource,
735
+ slugify,
736
+ resolveApiKey,
737
+ })
738
+
679
739
  registerAllTools(ctx, {
680
740
  config: baseConfig,
681
741
  live,
@@ -65,6 +65,26 @@ export function findLastConversationImage(exec) {
65
65
  /**
66
66
  * Determine MIME type by extension or signature.
67
67
  */
68
+ /**
69
+ * Validate that buffer has a recognized image header (PNG, JPEG, WebP, GIF, SVG).
70
+ */
71
+ export function validateImageSignature(bytes, refName = 'input') {
72
+ if (!bytes || bytes.length < 4) {
73
+ throw new Error(`Invalid image data in ${refName}: file is too small to be a valid image`)
74
+ }
75
+ const isPng = bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47
76
+ const isJpeg = bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF
77
+ const isWebp = bytes.length >= 12 && bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&
78
+ bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50
79
+ const isGif = bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38
80
+ const head = bytes.slice(0, 128).toString('utf8').toLowerCase()
81
+ const isSvg = head.includes('<svg') || (head.includes('<?xml') && head.includes('<svg'))
82
+
83
+ if (!isPng && !isJpeg && !isWebp && !isGif && !isSvg) {
84
+ throw new Error(`Invalid image file format in ${refName}: file does not match any recognized image signature (PNG, JPEG, WebP, GIF, SVG)`)
85
+ }
86
+ }
87
+
68
88
  export function detectMediaType(filePath, buffer) {
69
89
  if (buffer && buffer.length >= 8) {
70
90
  // PNG signature: 89 50 4E 47 0D 0A 1A 0A
@@ -135,15 +155,26 @@ export async function resolveConversationImage(ctx, exec, targetRef) {
135
155
  }
136
156
  }
137
157
 
138
- // 2. Data URI
158
+ // 2. Data URI (enforce pre-allocation bounded check)
139
159
  if (effectiveRef.startsWith('data:')) {
140
160
  const commaIndex = effectiveRef.indexOf(',')
141
161
  if (commaIndex > 0) {
142
162
  const meta = effectiveRef.slice(0, commaIndex)
143
- const data = effectiveRef.slice(commaIndex + 1)
163
+ const b64Data = effectiveRef.slice(commaIndex + 1).trim()
164
+ const approxBytes = Math.ceil((b64Data.length * 3) / 4)
165
+ const MAX_IMAGE_BYTES = 50 * 1024 * 1024
166
+ if (approxBytes > MAX_IMAGE_BYTES) {
167
+ throw new Error(`Data URI payload too large (~${(approxBytes / (1024 * 1024)).toFixed(1)} MB). Maximum allowed size is 50 MB.`)
168
+ }
169
+
144
170
  const mediaMatch = meta.match(/data:([^;]+)/)
145
171
  const mediaType = mediaMatch ? mediaMatch[1] : 'image/png'
146
- const bytes = Buffer.from(data, 'base64')
172
+ const bytes = Buffer.from(b64Data, 'base64')
173
+ if (bytes.length > MAX_IMAGE_BYTES) {
174
+ throw new Error(`Decoded image data too large (${(bytes.length / (1024 * 1024)).toFixed(1)} MB). Maximum allowed size is 50 MB.`)
175
+ }
176
+ validateImageSignature(bytes, 'inline data URI')
177
+
147
178
  return {
148
179
  bytes,
149
180
  mediaType,
@@ -151,25 +182,34 @@ export async function resolveConversationImage(ctx, exec, targetRef) {
151
182
  name: 'inline-image',
152
183
  }
153
184
  }
185
+ throw new Error('Malformed data URI: missing comma separator')
154
186
  }
155
187
 
156
- // 3. Local filesystem resolution
157
- const absPath = path.isAbsolute(effectiveRef)
158
- ? effectiveRef
159
- : path.resolve(sessionCwd, effectiveRef)
188
+ // 3. Local filesystem resolution with strict workspace containment check
189
+ const root = sessionCwd ? path.resolve(sessionCwd) : process.cwd()
190
+ const absPath = path.resolve(root, effectiveRef)
191
+ const rel = path.relative(root, absPath)
192
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
193
+ throw new Error(`Access denied: path "${effectiveRef}" is outside allowed workspace directory "${root}"`)
194
+ }
160
195
 
161
196
  const fileStat = await stat(absPath).catch(() => null)
162
197
  if (!fileStat) {
163
198
  throw new Error(`Image file not found: ${effectiveRef}`)
164
199
  }
200
+ if (!fileStat.isFile()) {
201
+ throw new Error(`Target is not a regular file: ${effectiveRef}`)
202
+ }
165
203
  if (fileStat.size === 0) {
166
204
  throw new Error(`Image file is empty (0 bytes): ${effectiveRef}`)
167
205
  }
168
- if (fileStat.size > 50 * 1024 * 1024) {
206
+ const MAX_IMAGE_BYTES = 50 * 1024 * 1024
207
+ if (fileStat.size > MAX_IMAGE_BYTES) {
169
208
  throw new Error(`Image file too large (${(fileStat.size / (1024 * 1024)).toFixed(1)} MB). Maximum allowed size is 50 MB.`)
170
209
  }
171
210
 
172
211
  const bytes = await readFile(absPath)
212
+ validateImageSignature(bytes, effectiveRef)
173
213
  const mediaType = detectMediaType(absPath, bytes)
174
214
  return {
175
215
  bytes,
@@ -42,6 +42,7 @@ import { trackAndAssertLoopGuard } from '../loop-guard.js'
42
42
  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
+ import { enhancePrompt } from '../prompt-enhancer.js'
45
46
 
46
47
 
47
48
  export function registerGenerationTools(ctx, deps) {
package/lib/vault.js CHANGED
@@ -99,6 +99,37 @@ export function filterVaultEntries(entries, options = {}, existsFn = existsSync)
99
99
  /**
100
100
  * Delete entry and associated disk files (image + sidecar json).
101
101
  */
102
+ export const MAX_VAULT_BODY_BYTES = 64 * 1024 // 64 KiB bounded limit (#306)
103
+
104
+ /**
105
+ * Reads request body with a hard byte ceiling, aborting early with 413 if exceeded.
106
+ */
107
+ export async function readBoundedRequestBody(req, maxBytes = MAX_VAULT_BODY_BYTES) {
108
+ const clHeader = req?.headers && (req.headers['content-length'] || req.headers['Content-Length'])
109
+ if (clHeader !== undefined) {
110
+ const cl = Number(clHeader)
111
+ if (Number.isFinite(cl) && cl > maxBytes) {
112
+ const err = new Error(`Payload Too Large: Content-Length ${cl} exceeds maximum allowed size of ${maxBytes} bytes`)
113
+ err.statusCode = 413
114
+ throw err
115
+ }
116
+ }
117
+
118
+ let received = 0
119
+ const chunks = []
120
+ for await (const chunk of req) {
121
+ received += chunk.length
122
+ if (received > maxBytes) {
123
+ if (typeof req.destroy === 'function') req.destroy()
124
+ const err = new Error(`Payload Too Large: request body exceeded maximum allowed size of ${maxBytes} bytes`)
125
+ err.statusCode = 413
126
+ throw err
127
+ }
128
+ chunks.push(chunk)
129
+ }
130
+ return Buffer.concat(chunks)
131
+ }
132
+
102
133
  export async function deleteVaultEntry(id) {
103
134
  if (!id) return { success: false, error: 'Missing entry id' }
104
135
  const entries = await readHistory()
@@ -157,11 +188,19 @@ export function registerVaultRoutes(ctx) {
157
188
 
158
189
  if (!id) {
159
190
  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 */ }
191
+ const buf = await readBoundedRequestBody(req, MAX_VAULT_BODY_BYTES)
192
+ if (buf && buf.length > 0) {
193
+ const body = JSON.parse(buf.toString('utf-8'))
194
+ id = body?.id
195
+ }
196
+ } catch (err) {
197
+ if (err?.statusCode === 413) {
198
+ res.writeHead(413, { 'Content-Type': 'application/json' })
199
+ res.end(JSON.stringify({ ok: false, error: err.message }))
200
+ return
201
+ }
202
+ /* body parse error falls through to missing id check */
203
+ }
165
204
  }
166
205
 
167
206
  if (!id) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-image-gen",
3
- "version": "0.11.4",
3
+ "version": "0.11.6",
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",
@@ -70,7 +70,8 @@
70
70
  },
71
71
  "scripts": {
72
72
  "test": "node scripts/build-client.mjs && node --check lib/client.js && node --test test/*.test.mjs",
73
- "build:client": "node scripts/build-client.mjs"
73
+ "build:client": "node scripts/build-client.mjs",
74
+ "publish:github": "bash scripts/publish-github.sh"
74
75
  },
75
76
  "publishConfig": {
76
77
  "access": "public"