@goodandready/dsh-image-gen 0.10.23 → 0.10.24

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 CHANGED
@@ -41,6 +41,13 @@
41
41
 
42
42
  ---
43
43
 
44
+ ## 🚀 Updates v0.10.24: Quality Block — Publication Boundary, Settings Status, Theme Colors & Module Split (#235–#242)
45
+ * **Publication Boundary**: Added `.gitattributes` with `export-ignore` for `AGENTS.md`, `index.md`, `docs/`, and `.gitea/` so GitHub source archives and `git archive` no longer ship internal workflow files.
46
+ * **Settings Status Fallback**: Corrected provider/config status handling in the Settings Card so partial/unknown states no longer show a false “unavailable”.
47
+ * **Design-System Alignment**: Renamed residual `fal-` CSS class prefixes to the `ig-` system and bound colors to DSH theme variables via `var(--dsw-alias-*, <fallback>)`.
48
+ * **Sensitive File Hardening**: History, cache meta/data, and spend-meter writes now enforce `0600` permissions after write.
49
+ * **Dead Code Removal & Module Split**: Removed unused `listCuratedStyles`; split oversized server modules — `providers.js` utils extracted to `provider-utils.js`, `processing.js` into `processing-basic.js` + `processing-advanced.js`. Client file split is tracked separately (#244).
50
+
44
51
  ## 🚀 Updates v0.10.23: In-App Auto-Updater, Settings Synchronization, Active Probes & Language Purity (#232)
45
52
  * **One-Click In-App Auto-Updater**: Direct 1-click update support inside the plugin Settings Card (`UpdaterSection`) backed by `/api/dsh-image-gen/update`. Fetches the npm registry for release manifests, verifies semver differences, and securely triggers `dsh plugin add @goodandready/dsh-image-gen@latest` restricted to loopback and private LAN connections.
46
53
  * **1:1 Settings Synchronization**: Fully synchronized host `Config` schema in `lib/index.js` with client UI fields (`lib/client.js`), exposing `autoEnhancePrompt` and `defaultStylePreset` under the `✨ Enhancer` tab.
package/README.ru.md CHANGED
@@ -34,6 +34,13 @@
34
34
  </div>
35
35
 
36
36
  ---
37
+ ## 🚀 Обновления v0.10.24: Блок качества — публикационная граница, статусы настроек, цвета темы и разбиение модулей (#235–#242)
38
+ * **Публикационная граница**: добавлен `.gitattributes` c `export-ignore` для `AGENTS.md`, `index.md`, `docs/` и `.gitea/` — архивы GitHub source и `git archive` больше не содержат внутренние файлы workflow.
39
+ * **Статусы настроек**: исправлен fallback статуса провайдера/конфигурации в карточке настроек — промежуточные/неизвестные состояния больше не показываются ложным «unavailable».
40
+ * **Выравнивание с дизайн-системой**: остаточные CSS-классы `fal-` переименованы в префикс `ig-`; цвета привязаны к переменным темы DSH через `var(--dsw-alias-*, <fallback>)`.
41
+ * **Защита чувствительных файлов**: запись history, cache meta/data и spend-meter после записи получает права `0600`.
42
+ * **Удаление мёртвого кода и разбиение модулей**: удалён неиспользуемый `listCuratedStyles`; укрупнённые server-модули разнесены — утилиты `providers.js` → `provider-utils.js`, `processing.js` → `processing-basic.js` + `processing-advanced.js`. Разбиение client отложено в #244.
43
+
37
44
  ## 🚀 Обновления v0.10.23: Встроенное автообновление, синхронизация настроек, диагностические пробы и чистота языка (#232)
38
45
  * **Встроенное обновление в 1 клик**: Прямая проверка и обновление плагина прямо из карточки настроек (`UpdaterSection`) через эндпоинт `/api/dsh-image-gen/update`. Опрашивает реестр npm, сравнивает semver-версии и безопасно запускает установку через `dsh plugin add` с защитой по loopback и доверенной локальной сети (RFC 1918).
39
46
  * **Синхронизация настроек 1:1**: Полное выравнивание схемы хоста `Config` в `lib/index.js` и полей UI `FIELDS` в `lib/client.js` — во вкладку «✨ Промпт и стили» добавлены тумблеры `autoEnhancePrompt` и выпадающий список `defaultStylePreset`.
package/README.zh.md CHANGED
@@ -47,6 +47,13 @@
47
47
 
48
48
  ---
49
49
 
50
+ ## 🚀 更新 v0.10.24: 质量加固 — 发布边界、设置状态、主题颜色与模块拆分 (#235–#242)
51
+ * **发布边界**: 新增 `.gitattributes`,对 `AGENTS.md`、`index.md`、`docs/`、`.gitea/` 设置 `export-ignore`,GitHub source 归档与 `git archive` 不再包含内部工作流文件。
52
+ * **设置状态回退**: 修正设置卡片中的提供方/配置状态处理,部分或未知状态不再误显示为 “unavailable”。
53
+ * **对齐设计系统**: 将残留的 `fal-` CSS 类前缀改为 `ig-`,并以 `var(--dsw-alias-*, <fallback>)` 绑定 DSH 主题变量。
54
+ * **敏感文件加固**: 历史记录、缓存 meta/data 与花费计量写入后强制 `0600` 权限。
55
+ * **死代码清理与模块拆分**: 删除未使用的 `listCuratedStyles`;拆分过大的服务端模块 — `providers.js` 工具函数抽到 `provider-utils.js`,`processing.js` 拆为 `processing-basic.js` + `processing-advanced.js`。客户端拆分见 #244。
56
+
50
57
  ## 🚀 更新 v0.10.23: 应用内一键更新、设置同步、真实探针与语言规范 (#232)
51
58
  * **应用内一键自动更新**: 设置卡片内新增「插件更新」模块(`UpdaterSection`),配合 `/api/dsh-image-gen/update` 接口。自动查询 npm 官方注册表,对比语义化版本号,并在本地回环或私有局域网安全授权下执行 `dsh plugin add @goodandready/dsh-image-gen@latest`。
52
59
  * **1:1 核心配置与界面设置同步**: 严格对齐 `lib/index.js` 宿主 `Config` 与 `lib/client.js` 界面字段,在「✨ 提示词增强」标签页下完整呈现 `autoEnhancePrompt`(智能自动增强提示词)与 `defaultStylePreset`(默认风格预设)。
package/lib/client.js CHANGED
@@ -22,7 +22,7 @@ window.__ModuleLoader__.load({
22
22
  try {
23
23
  runtime = require(id)
24
24
  if (runtime && typeof runtime.createSnapshotStore === 'function') break
25
- } catch (_) {}
25
+ } catch (_) { /* module not available in this build; try next */ }
26
26
  }
27
27
 
28
28
  const NS = 'dsh-image-gen'
@@ -99,23 +99,23 @@ window.__ModuleLoader__.load({
99
99
  .ig-btn:hover:not(:disabled){background:var(--dsw-alias-bg-layer-4, var(--dsw-alias-bg-layer-2));border-color:var(--dsw-alias-label-dimmed, var(--dsw-alias-border-l2))}
100
100
  .ig-btn-primary{background:var(--dsw-alias-label-primary);color:var(--dsw-alias-bg-layer-3);border-color:transparent}
101
101
  .ig-btn-primary:hover:not(:disabled){opacity:0.9}
102
- .ig-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:rgba(239,68,68,0.3)}
103
- .ig-btn-danger:hover:not(:disabled){background:rgba(239,68,68,0.12);border-color:rgba(239,68,68,0.5)}
102
+ .ig-btn-danger{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary)}
103
+ .ig-btn-danger:hover:not(:disabled){background:var(--dsw-alias-state-error-bg,rgba(239,68,68,0.12));border-color:var(--dsw-alias-state-error-primary)}
104
104
  .ig-btn-disabled{opacity:0.5;cursor:not-allowed}
105
105
 
106
106
  .ig-reset{font-size:11px;color:var(--dsw-alias-state-brand-primary);background:none;border:none;cursor:pointer;padding:0;text-decoration:underline}
107
107
  .ig-reset:hover{opacity:0.8}
108
108
 
109
- .ig-alert-ok{padding:10px 14px;border-radius:8px;background:rgba(16,185,129,0.1);color:var(--dsw-alias-state-success-primary);font-size:13px}
110
- .ig-alert-bad{padding:10px 14px;border-radius:8px;background:rgba(239,68,68,0.1);color:var(--dsw-alias-state-error-primary);font-size:13px}
109
+ .ig-alert-ok{padding:10px 14px;border-radius:8px;background:var(--dsw-alias-state-success-bg,rgba(16,185,129,0.1));color:var(--dsw-alias-state-success-primary);font-size:13px}
110
+ .ig-alert-bad{padding:10px 14px;border-radius:8px;background:var(--dsw-alias-state-error-bg,rgba(239,68,68,0.1));color:var(--dsw-alias-state-error-primary);font-size:13px}
111
111
  .ig-alert-err{padding:10px 14px;border-radius:8px;background:rgba(239,68,68,0.1);color:var(--dsw-alias-state-error-primary);font-size:13px}
112
112
  .ig-banner-warning{padding:12px 16px;border-radius:8px;background:rgba(245,158,11,0.12);border:1px solid var(--dsw-alias-state-warning-primary);color:var(--dsw-alias-state-warning-primary);font-size:13px;display:flex;align-items:center;gap:10px;font-weight:500}
113
113
 
114
114
  /* Backward-compatibility alias styles for toolview */
115
115
  .fal_head{font-size:13px;font-weight:600;margin-bottom:8px;color:var(--dsw-alias-label-primary)}
116
- .fal-prompt{font-size:13px;padding:8px 12px;background:var(--dsw-alias-bg-layer-2);border-radius:6px;border:1px solid var(--dsw-alias-border-l2);margin-bottom:8px;word-break:break-word}
117
- .fal-meta{font-size:11px;color:var(--dsw-alias-label-secondary);margin-top:6px}
118
- .fal-err{padding:10px;border-radius:6px;background:rgba(239,68,68,0.1);color:var(--dsw-alias-state-error-primary);font-size:12px}
116
+ .ig-prompt{font-size:13px;padding:8px 12px;background:var(--dsw-alias-bg-layer-2);border-radius:6px;border:1px solid var(--dsw-alias-border-l2);margin-bottom:8px;word-break:break-word}
117
+ .ig-meta{font-size:11px;color:var(--dsw-alias-label-secondary);margin-top:6px}
118
+ .ig-err{padding:10px;border-radius:6px;background:var(--dsw-alias-state-error-bg,rgba(239,68,68,0.1));color:var(--dsw-alias-state-error-primary);font-size:12px}
119
119
  `
120
120
  document.head.appendChild(style)
121
121
  }
@@ -328,7 +328,7 @@ window.__ModuleLoader__.load({
328
328
  }
329
329
  }
330
330
  shell() {
331
- const snapshot = (this.scope && this.scope.getSnapshot && this.scope.getSnapshot()) || { status: 'ready', writable: true }
331
+ const snapshot = (this.scope && this.scope.getSnapshot && this.scope.getSnapshot()) || { status: 'unavailable', writable: false }
332
332
  const plan = this.plan()
333
333
  return {
334
334
  available: snapshot.status === 'ready',
@@ -826,9 +826,9 @@ window.__ModuleLoader__.load({
826
826
  left: 0,
827
827
  right: 0,
828
828
  padding: '4px 6px',
829
- background: 'linear-gradient(transparent, rgba(0,0,0,0.8))',
829
+ background: 'linear-gradient(transparent, var(--dsw-alias-overlay-scrim,rgba(0,0,0,0.8)))',
830
830
  fontSize: '10px',
831
- color: '#fff',
831
+ color: 'var(--dsw-alias-label-on-overlay,#fff)',
832
832
  whiteSpace: 'nowrap',
833
833
  overflow: 'hidden',
834
834
  textOverflow: 'ellipsis',
@@ -846,7 +846,7 @@ window.__ModuleLoader__.load({
846
846
  style: {
847
847
  position: 'fixed',
848
848
  inset: 0,
849
- background: 'rgba(0,0,0,0.7)',
849
+ background: 'var(--dsw-alias-overlay-backdrop,rgba(0,0,0,0.7))',
850
850
  zIndex: 10000,
851
851
  display: 'flex',
852
852
  alignItems: 'center',
@@ -889,7 +889,7 @@ window.__ModuleLoader__.load({
889
889
  ),
890
890
  react.createElement('img', {
891
891
  src: selected.thumbnailUrl || (selected.attachmentId ? '/dsh-image-gen/image?id=' + encodeURIComponent(selected.attachmentId) : ''),
892
- style: { width: '100%', borderRadius: '8px', maxHeight: '340px', objectFit: 'contain', background: '#0a0a0c' },
892
+ style: { width: '100%', borderRadius: '8px', maxHeight: '340px', objectFit: 'contain', background: 'var(--dsw-alias-bg-layer-1,#0a0a0c)' },
893
893
  }),
894
894
  react.createElement(
895
895
  'div',
@@ -1002,7 +1002,7 @@ window.__ModuleLoader__.load({
1002
1002
  background: 'var(--dsw-alias-bg-layer-3)',
1003
1003
  border: '1px solid var(--dsw-alias-border-l2)',
1004
1004
  borderRadius: '12px',
1005
- boxShadow: '0 8px 32px rgba(0,0,0,0.4)',
1005
+ boxShadow: 'var(--dsw-alias-shadow-overlay,0 8px 32px rgba(0,0,0,0.4))',
1006
1006
  zIndex: 9999,
1007
1007
  padding: '16px',
1008
1008
  overflowY: 'auto',
@@ -1038,7 +1038,7 @@ window.__ModuleLoader__.load({
1038
1038
 
1039
1039
  const state = (typeof props.useFalSettingsCard === 'function'
1040
1040
  ? props.useFalSettingsCard((s) => s)
1041
- : null) || props.state || { available: true, writable: true, provider: { text: 'fal' } }
1041
+ : null) || props.state || { available: false, writable: false, provider: { text: 'fal' } }
1042
1042
 
1043
1043
  const disabled = !state.writable
1044
1044
  const currentProvider = (state.provider && state.provider.text) || 'fal'
@@ -1299,7 +1299,7 @@ window.__ModuleLoader__.load({
1299
1299
  args = JSON.parse(raw)
1300
1300
  prompt = args.prompt || ''
1301
1301
  }
1302
- } catch (_) {}
1302
+ } catch (_) { /* malformed tool args; use empty prompt */ }
1303
1303
 
1304
1304
  const [copied, setCopied] = react.useState(false)
1305
1305
 
@@ -1310,7 +1310,7 @@ window.__ModuleLoader__.load({
1310
1310
  const binding = current && sessions.binding(current)
1311
1311
  const session = binding && binding.session
1312
1312
  if (session && session.prompt) await session.prompt([{ type: 'text', text }], 'queue')
1313
- } catch (_) {}
1313
+ } catch (_) { /* session prompt delivery failed; non-fatal */ }
1314
1314
  }
1315
1315
 
1316
1316
  const onCopyPrompt = () => {
@@ -1344,10 +1344,10 @@ window.__ModuleLoader__.load({
1344
1344
 
1345
1345
  const body = []
1346
1346
  if (prompt) {
1347
- body.push(react.createElement('div', { className: 'fal-prompt', key: 'p' }, prompt))
1347
+ body.push(react.createElement('div', { className: 'ig-prompt', key: 'p' }, prompt))
1348
1348
  }
1349
1349
  if (failed) {
1350
- body.push(react.createElement('div', { className: 'fal-err', key: 'e' }, parsed.text || t('card.unknownError')))
1350
+ body.push(react.createElement('div', { className: 'ig-err', key: 'e' }, parsed.text || t('card.unknownError')))
1351
1351
  } else if (!parsed.attachment && parsed.url) {
1352
1352
  body.push(react.createElement('img', {
1353
1353
  key: 'i',
@@ -2008,7 +2008,7 @@ window.__ModuleLoader__.load({
2008
2008
  console.warn('[dsh-image-gen] native sidebar registration failed', e)
2009
2009
  }
2010
2010
  })
2011
- } catch (e) {}
2011
+ } catch (e) { /* native sidebar unavailable */ }
2012
2012
 
2013
2013
  try {
2014
2014
  ctx.inject(['betterSidebar'], (sctx) => {
@@ -2026,7 +2026,7 @@ window.__ModuleLoader__.load({
2026
2026
  console.warn('[dsh-image-gen] betterSidebar registerTab failed', e)
2027
2027
  }
2028
2028
  })
2029
- } catch (e) {}
2029
+ } catch (e) { /* betterSidebar unavailable */ }
2030
2030
  }
2031
2031
 
2032
2032
  // Conversation header utilities chip (quick-access gallery button in chat header)
package/lib/cost-meter.js CHANGED
@@ -4,6 +4,7 @@
4
4
  import { homedir } from 'node:os'
5
5
  import { join } from 'node:path'
6
6
  import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
7
+ import { enforceSecurePermissions } from './security.js'
7
8
 
8
9
  /**
9
10
  * Standard pricing table per provider and resolution (USD per generation).
@@ -87,7 +88,7 @@ export function loadDailySpend() {
87
88
  if (data && data.date === today) {
88
89
  return data
89
90
  }
90
- } catch {}
91
+ } catch { /* spend file unreadable; return zero */ }
91
92
  return { date: today, totalSpendUsd: 0, generations: 0 }
92
93
  }
93
94
 
@@ -107,6 +108,7 @@ export function recordSpend(usdAmount, { ctx, meta = {} } = {}) {
107
108
  try {
108
109
  mkdirSync(join(filePath, '..'), { recursive: true })
109
110
  writeFileSync(filePath, JSON.stringify(current, null, 2), 'utf8')
111
+ enforceSecurePermissions(filePath)
110
112
  } catch (err) {
111
113
  // Non-fatal write failure
112
114
  }
@@ -122,7 +124,7 @@ export function recordSpend(usdAmount, { ctx, meta = {} } = {}) {
122
124
  meta,
123
125
  })
124
126
  }
125
- } catch {}
127
+ } catch { /* cost-meter service unavailable */ }
126
128
 
127
129
  return current
128
130
  }
@@ -4,6 +4,7 @@
4
4
  import { homedir } from 'node:os'
5
5
  import { join } from 'node:path'
6
6
  import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync, unlinkSync, readdirSync } from 'node:fs'
7
+ import { enforceSecurePermissions } from './security.js'
7
8
 
8
9
  const DEFAULT_CACHE_MAX_BYTES = 500 * 1024 * 1024 // 500 MB
9
10
 
@@ -107,6 +108,8 @@ export function setCachedGeneration(hash, { bytes, mediaType = 'image/png', widt
107
108
  writeFileSync(metaPath, JSON.stringify(cacheMeta, null, 2), 'utf8')
108
109
  const binBuf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes)
109
110
  writeFileSync(dataPath, binBuf)
111
+ enforceSecurePermissions(metaPath)
112
+ enforceSecurePermissions(dataPath)
110
113
 
111
114
  setToL1(hash, {
112
115
  bytes: binBuf,
@@ -151,7 +154,7 @@ export function pruneCacheToLimit(maxBytes = DEFAULT_CACHE_MAX_BYTES) {
151
154
  size,
152
155
  lastAccessed: meta.lastAccessed || meta.createdAt || 0,
153
156
  })
154
- } catch {}
157
+ } catch { /* entry meta unreadable; skip */ }
155
158
  }
156
159
 
157
160
  if (totalSize <= maxBytes) return
@@ -165,7 +168,7 @@ export function pruneCacheToLimit(maxBytes = DEFAULT_CACHE_MAX_BYTES) {
165
168
  if (existsSync(entry.metaPath)) unlinkSync(entry.metaPath)
166
169
  if (existsSync(entry.dataPath)) unlinkSync(entry.dataPath)
167
170
  totalSize -= entry.size
168
- } catch {}
171
+ } catch { /* entry unlink failed; skip */ }
169
172
  }
170
- } catch {}
173
+ } catch { /* cache dir unreadable; prune skipped */ }
171
174
  }
package/lib/index.js CHANGED
@@ -15,6 +15,7 @@
15
15
  import z from '@deepseek-ai/schemastery'
16
16
  import { credentialRef } from '@deepseek-ai/dsh-credentials'
17
17
  import { mkdir, readFile, writeFile } from 'node:fs/promises'
18
+ import { enforceSecurePermissions } from './security.js'
18
19
  import { existsSync, unlinkSync } from 'node:fs'
19
20
  import os from 'node:os'
20
21
  import path from 'node:path'
@@ -472,6 +473,7 @@ export async function writeHistory(entries) {
472
473
  try {
473
474
  await mkdir(path.dirname(historyFile()), { recursive: true })
474
475
  await writeFile(historyFile(), JSON.stringify(entries, null, 2))
476
+ enforceSecurePermissions(historyFile())
475
477
  } catch (e) { /* history write failure is non-fatal */ }
476
478
  }
477
479
 
package/lib/loop-guard.js CHANGED
@@ -55,6 +55,7 @@ export function trackAndAssertLoopGuard(sessionId, {
55
55
  /**
56
56
  * Resets loop guard counter for a session.
57
57
  */
58
+ /** @internal — test helper; not part of the plugin runtime API */
58
59
  export function resetLoopGuard(sessionId) {
59
60
  const sid = String(sessionId || 'default_session')
60
61
  sessionStates.delete(sid)
@@ -63,6 +64,7 @@ export function resetLoopGuard(sessionId) {
63
64
  /**
64
65
  * Gets current state for debugging/testing.
65
66
  */
67
+ /** @internal — test helper; not part of the plugin runtime API */
66
68
  export function getLoopGuardState(sessionId) {
67
69
  const sid = String(sessionId || 'default_session')
68
70
  return sessionStates.get(sid) || { count: 0, lastPrompt: '', lastTimestamp: 0 }
@@ -185,10 +185,3 @@ export function polishPrompt(basePrompt, stylePreset = null, {
185
185
  }
186
186
  }
187
187
 
188
- export function listCuratedStyles() {
189
- return Object.values(CURATED_STYLES).map(s => ({
190
- id: s.id,
191
- label: s.label,
192
- guidanceScale: s.guidanceScale,
193
- }))
194
- }
@@ -0,0 +1,302 @@
1
+ // lib/provider-utils.js
2
+ // Shared provider utilities and size/format constants extracted from providers.js (#239)
3
+
4
+ export function resolveApiKeyCandidates(ref) {
5
+ if (!ref) return []
6
+ const candidates = [ref]
7
+ if (ref === 'FAL_API_KEY') candidates.push('FAL_KEY')
8
+ else if (ref === 'FAL_KEY') candidates.push('FAL_API_KEY')
9
+ return candidates
10
+ }
11
+
12
+
13
+ export const PROVIDER_MAX_COUNTS = {
14
+ fal: 4,
15
+ replicate: 4,
16
+ custom: 10,
17
+ seedream: 10,
18
+ gemini: 4,
19
+ codex: 1,
20
+ grok: 1,
21
+ local: 4,
22
+ }
23
+
24
+ export function normalizeCount(count, max = 4) {
25
+ const n = Math.floor(Number(count))
26
+ if (!Number.isFinite(n)) return 1
27
+ return Math.max(1, Math.min(max, n))
28
+ }
29
+
30
+ /** @internal — test helper for validating provider count limits */
31
+ export function clampProviderCount(provider, count) {
32
+ const max = PROVIDER_MAX_COUNTS[provider] || 4
33
+ return normalizeCount(count, max)
34
+ }
35
+
36
+ export function buildEndpointUrl(baseURL, pathSuffix) {
37
+ const base = String(baseURL || '').trim().replace(/\/+$/, '')
38
+ const suffix = String(pathSuffix || '').trim().replace(/^\/+/, '')
39
+ return `${base}/${suffix}`
40
+ }
41
+
42
+ /** @internal — test helper for abort signal simulation */
43
+ export function createAbortError(message = 'Image generation cancelled') {
44
+ const err = new Error(message)
45
+ err.name = 'AbortError'
46
+ return err
47
+ }
48
+
49
+
50
+ /** Fast Laplacian-like sharpness and entropy estimator across image scanlines. */
51
+ export function estimateSharpnessAndVariance(bytes) {
52
+ const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes || [])
53
+ if (!buf || buf.length < 64) {
54
+ return { score: 0, passed: false, isBlank: true, reason: 'Empty or corrupt image buffer' }
55
+ }
56
+ let sum = 0
57
+ let diffSum = 0
58
+ const sampleStep = Math.max(1, Math.floor(buf.length / 4096))
59
+ let count = 0
60
+ for (let i = 8; i < buf.length - sampleStep; i += sampleStep) {
61
+ const v = buf[i]
62
+ const nextV = buf[i + sampleStep]
63
+ sum += v
64
+ diffSum += Math.abs(v - nextV)
65
+ count++
66
+ }
67
+ const avgDiff = count > 0 ? diffSum / count : 0
68
+ if (avgDiff < 2) {
69
+ return { score: 0.1, passed: false, isBlank: true, reason: 'Image appears blank or solid monochrome' }
70
+ }
71
+ const score = Math.min(0.98, Math.max(0.3, +(0.5 + (avgDiff / 255) * 0.5).toFixed(2)))
72
+ return { score, passed: score >= 0.5, isBlank: false, avgDiff: +avgDiff.toFixed(2) }
73
+ }
74
+
75
+ /** Adaptive color palette quantizer for SVG vectorization. */
76
+ export function quantizePalette(colorMode = 'color', paletteSize = 16) {
77
+ if (colorMode === 'binary') return ['#000000', '#ffffff']
78
+ if (colorMode === 'grayscale') return ['#000000', '#444444', '#888888', '#cccccc', '#ffffff']
79
+ // Standard vibrant UI vector palette
80
+ return [
81
+ '#000000', '#ffffff', '#e11d48', '#2563eb',
82
+ '#16a34a', '#ca8a04', '#9333ea', '#0891b2',
83
+ '#475569', '#64748b', '#94a3b8', '#cbd5e1'
84
+ ].slice(0, Math.max(2, paletteSize))
85
+ }
86
+
87
+
88
+ /** Format parameters in standard Automatic1111 / ComfyUI text format for drag-and-drop support. */
89
+ export function formatA1111Parameters(metadata = {}) {
90
+ if (typeof metadata === 'string') return metadata
91
+ const prompt = metadata.prompt || ''
92
+ const neg = metadata.negative_prompt || metadata.negativePrompt || ''
93
+ const seed = metadata.seed ?? ''
94
+ const size = metadata.size || (metadata.width && metadata.height ? `${metadata.width}x${metadata.height}` : '1024x1024')
95
+ const model = metadata.model || metadata.provider || ''
96
+ const steps = metadata.steps || 20
97
+ const cfg = metadata.cfg_scale || metadata.guidance_scale || 7
98
+
99
+ let out = prompt
100
+ if (neg) out += `\nNegative prompt: ${neg}`
101
+ out += `\nSteps: ${steps}, Sampler: Euler, CFG scale: ${cfg}, Seed: ${seed}, Size: ${size}, Model: ${model}`
102
+ return out
103
+ }
104
+
105
+
106
+ import { createHash } from 'node:crypto'
107
+
108
+ /** Deterministic sha256 hash for image generation caching. */
109
+ export function computeGenerationHash({ provider, model, prompt, seed, size, style }) {
110
+ const norm = [
111
+ String(provider || '').trim().toLowerCase(),
112
+ String(model || '').trim().toLowerCase(),
113
+ String(prompt || '').trim(),
114
+ String(seed ?? ''),
115
+ String(size || '').trim().toLowerCase(),
116
+ String(style || '').trim().toLowerCase(),
117
+ ].join('|')
118
+ return createHash('sha256').update(norm).digest('hex')
119
+ }
120
+
121
+ // Image generation provider implementations.
122
+ //
123
+ // A provider receives a generation job and returns finished image bytes. Everything
124
+ // that follows (attachments, workspace files, links, card rendering) is shared
125
+ // across all providers and lives in index.js.
126
+ //
127
+ // Network is injected via fetchImpl and keys via resolveKey, enabling fully isolated unit tests.
128
+
129
+ export const PROVIDER_KEYS = ['fal', 'custom', 'codex', 'grok', 'local', 'seedream', 'gemini', 'replicate']
130
+
131
+ /** Clamp count from tool arguments to range 1..4. */
132
+ /** Provider fallback ordering: primary provider first, followed by PROVIDER_KEYS. */
133
+ export function fallbackOrder(primary) {
134
+ return [primary, ...PROVIDER_KEYS.filter((k) => k !== primary)]
135
+ }
136
+
137
+ /**
138
+ * Iterates through candidate generators, returning the first successful result.
139
+ * Throws an aggregate error if all candidates fail.
140
+ * @param generators - array of (key, seed) => Promise<generated> functions.
141
+ * @param order - candidate key evaluation order.
142
+ */
143
+
144
+ /** Calculate exponential backoff delay with jitter. */
145
+ export function calculateBackoff(attempt, baseInterval = 500, maxInterval = 5000, jitterFactor = 0.2) {
146
+ const exp = Math.min(maxInterval, baseInterval * Math.pow(1.3, attempt))
147
+ const jitter = exp * jitterFactor * (Math.random() * 2 - 1)
148
+ return Math.max(100, Math.floor(exp + jitter))
149
+ }
150
+
151
+ /** Check if an error is a fatal client error that should NOT be cascaded to other providers. */
152
+ export function isFatalClientError(error) {
153
+ const msg = (error?.message || String(error || '')).toLowerCase()
154
+ return (
155
+ msg.includes('content policy') ||
156
+ msg.includes('safety system') ||
157
+ msg.includes('nsfw') ||
158
+ msg.includes('moderation') ||
159
+ msg.includes('bad request (http 400') ||
160
+ msg.includes('invalid_prompt') ||
161
+ msg.includes('prompt is required') ||
162
+ msg.includes('unsupported image format') ||
163
+ msg.includes('402 payment required') ||
164
+ msg.includes('insufficient_quota') ||
165
+ msg.includes('insufficient credits') ||
166
+ msg.includes('exceeded your current quota') ||
167
+ msg.includes('balance is insufficient')
168
+ )
169
+ }
170
+
171
+ /**
172
+ * Safely extract a human-readable message from any error, object, or response.
173
+ * Completely eliminates "[object Object]" and prevents duplicate provider prefixes (e.g. "codex: codex: ...").
174
+ */
175
+ export function formatErrorMessage(e, providerKey = '') {
176
+ if (e === null || e === undefined) {
177
+ return providerKey ? `${providerKey}: unknown error` : 'unknown error'
178
+ }
179
+
180
+ let msg = ''
181
+ if (typeof e === 'string') {
182
+ msg = e
183
+ } else if (typeof e === 'object') {
184
+ if (typeof e.message === 'string' && e.message && e.message !== '[object Object]') {
185
+ msg = e.message
186
+ } else if (typeof e.detail === 'string' && e.detail) {
187
+ msg = e.detail
188
+ } else if (e.error) {
189
+ if (typeof e.error === 'string') msg = e.error
190
+ else if (typeof e.error.message === 'string') msg = e.error.message
191
+ else if (typeof e.error.detail === 'string') msg = e.error.detail
192
+ else {
193
+ try { msg = JSON.stringify(e.error) } catch { msg = String(e.error) }
194
+ }
195
+ } else if (typeof e.statusText === 'string' && e.statusText) {
196
+ msg = `HTTP ${e.status || ''} ${e.statusText}`.trim()
197
+ } else if (typeof e.cause === 'string') {
198
+ msg = e.cause
199
+ } else if (e.cause && typeof e.cause.message === 'string') {
200
+ msg = e.cause.message
201
+ }
202
+
203
+ if (!msg || msg === '[object Object]') {
204
+ try {
205
+ const json = JSON.stringify(e)
206
+ if (json && json !== '{}') {
207
+ msg = json.slice(0, 500)
208
+ }
209
+ } catch { /* response body not JSON; use raw text */ }
210
+ }
211
+
212
+ if (!msg || msg === '[object Object]') {
213
+ const keys = Object.getOwnPropertyNames(e)
214
+ if (keys.length) {
215
+ msg = `error [${keys.join(', ')}]`
216
+ } else {
217
+ msg = String(e)
218
+ }
219
+ }
220
+ } else {
221
+ msg = String(e)
222
+ }
223
+
224
+ msg = String(msg || 'unknown error').trim()
225
+
226
+ if (providerKey) {
227
+ const prefixRegex = new RegExp(`^${providerKey}\\s*:\\s*`, 'i')
228
+ while (prefixRegex.test(msg)) {
229
+ msg = msg.replace(prefixRegex, '').trim()
230
+ }
231
+ }
232
+
233
+ if (!msg || msg === '[object Object]') {
234
+ msg = 'unknown error object'
235
+ } else if (msg.includes('[object Object]')) {
236
+ msg = msg.replace(/\[object Object\]/g, 'unknown error object').trim()
237
+ }
238
+
239
+ if (providerKey) {
240
+ return `${providerKey}: ${msg}`
241
+ }
242
+
243
+ return msg
244
+ }
245
+
246
+ export function resolveSubscriptionSize(size, aspectPixels, aspectRatio) {
247
+ if (size && SUBSCRIPTION_SIZES[size]) {
248
+ return SUBSCRIPTION_SIZES[size]
249
+ }
250
+ const ratio = String(aspectRatio || '').trim()
251
+ if (ratio === '16:9' || ratio === '3:2' || ratio === '4:3') {
252
+ return '1536x1024'
253
+ }
254
+ if (ratio === '9:16' || ratio === '2:3' || ratio === '3:4') {
255
+ return '1024x1536'
256
+ }
257
+ if (ratio === '1:1') {
258
+ return '1024x1024'
259
+ }
260
+ if (Array.isArray(aspectPixels) && aspectPixels.length === 2) {
261
+ const [w, h] = aspectPixels
262
+ if (w > h) return '1536x1024'
263
+ if (h > w) return '1024x1536'
264
+ return '1024x1024'
265
+ }
266
+ return '1024x1024'
267
+ }
268
+
269
+ export const SUBSCRIPTION_SIZES = {
270
+ square_hd: '1024x1024',
271
+ square: '1024x1024',
272
+ portrait_4_3: '1024x1536',
273
+ portrait_16_9: '1024x1536',
274
+ landscape_4_3: '1536x1024',
275
+ landscape_16_9: '1536x1024',
276
+ }
277
+
278
+ /** Image sizes accepted by fal-ai/flux-2/klein (and most FAL flux models). */
279
+ export const IMAGE_SIZES = [
280
+ 'square_hd',
281
+ 'square',
282
+ 'portrait_4_3',
283
+ 'portrait_16_9',
284
+ 'landscape_4_3',
285
+ 'landscape_16_9',
286
+ ]
287
+
288
+ /** Output formats accepted by the model. */
289
+ export const OUTPUT_FORMATS = ['png', 'jpeg', 'webp']
290
+
291
+ // Named sizes are a unified abstraction across all providers.
292
+ // FAL accepts named identifiers; OpenAI-compatible gateways require WxH resolution.
293
+ export const SIZE_PIXELS = {
294
+ square_hd: '1024x1024',
295
+ square: '512x512',
296
+ portrait_4_3: '768x1024',
297
+ portrait_16_9: '576x1024',
298
+ landscape_4_3: '1024x768',
299
+ landscape_16_9: '1024x576',
300
+ }
301
+
302
+ /** Normalize a raw key into a FAL `Authorization: Key <key>` value. */