@goodandready/dsh-image-gen 0.11.42 → 0.12.0

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
@@ -60,10 +60,11 @@ window.__ModuleLoader__.load({
60
60
  return (k, def) => def || k
61
61
  }
62
62
 
63
- async function fetchJson(path, options = {}) {
63
+ async function fetchWithTimeout(path, options = {}) {
64
64
  const { timeoutMs = 15000, signal, ...rest } = options
65
65
  let timer = null
66
66
  let sig = signal
67
+ let removeCallerAbort = null
67
68
 
68
69
  if (!sig) {
69
70
  if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function') {
@@ -76,10 +77,38 @@ window.__ModuleLoader__.load({
76
77
  sig = ctrl.signal
77
78
  }
78
79
  } else if (typeof AbortSignal !== 'undefined' && typeof AbortSignal.any === 'function') {
79
- const timeoutSig = typeof AbortSignal.timeout === 'function' ? AbortSignal.timeout(timeoutMs) : null
80
+ const timeoutSig = typeof AbortSignal.timeout === 'function'
81
+ ? AbortSignal.timeout(timeoutMs)
82
+ : (() => {
83
+ const ctrl = new AbortController()
84
+ timer = setTimeout(() => {
85
+ try { ctrl.abort(new Error('Request timeout')) } catch (_) {}
86
+ }, timeoutMs)
87
+ return ctrl.signal
88
+ })()
80
89
  if (timeoutSig) {
81
90
  sig = AbortSignal.any([signal, timeoutSig])
82
91
  }
92
+ } else if (typeof AbortController !== 'undefined') {
93
+ // Fallback for environments lacking AbortSignal.any when caller provides signal
94
+ const ctrl = new AbortController()
95
+ if (signal.aborted) {
96
+ try { ctrl.abort(signal.reason) } catch (_) { ctrl.abort() }
97
+ } else {
98
+ const onCallerAbort = () => {
99
+ try { ctrl.abort(signal.reason) } catch (_) { ctrl.abort() }
100
+ }
101
+ if (typeof signal.addEventListener === 'function') {
102
+ signal.addEventListener('abort', onCallerAbort, { once: true })
103
+ removeCallerAbort = () => {
104
+ try { signal.removeEventListener('abort', onCallerAbort) } catch (_) {}
105
+ }
106
+ }
107
+ }
108
+ timer = setTimeout(() => {
109
+ try { ctrl.abort(new Error('Request timeout')) } catch (_) {}
110
+ }, timeoutMs)
111
+ sig = ctrl.signal
83
112
  }
84
113
 
85
114
  try {
@@ -87,8 +116,10 @@ window.__ModuleLoader__.load({
87
116
  return res
88
117
  } finally {
89
118
  if (timer) clearTimeout(timer)
119
+ if (removeCallerAbort) removeCallerAbort()
90
120
  }
91
121
  }
122
+ const fetchJson = fetchWithTimeout
92
123
  // -------------------------------------------------------------- CSS & Design System
93
124
  function ensureCss() {
94
125
  if (typeof document === 'undefined') return
@@ -2877,6 +2908,9 @@ window.__ModuleLoader__.load({
2877
2908
  'vault.search': 'Search prompt or tags...',
2878
2909
  'vault.seed': 'Seed',
2879
2910
  'vault.title': 'Asset Browser & Vault',
2911
+ 'vault.exportDesign': '🎨 OpenDesign',
2912
+ 'vault.exportDesignCopied': 'Figma/Penpot snippet & Data-URL copied to clipboard!',
2913
+ 'vault.exportDesignFailed': 'Failed to export design snippet',
2880
2914
  'f.comfyTemplate': 'ComfyUI Workflow Template',
2881
2915
  'f.comfyTemplateHint': 'Pre-vetted workflow template (e.g. SDXL txt2img, img2img, InstantID face transfer, RealESRGAN upscaler).',
2882
2916
  'f.activeProjectProfile': 'Active Project Profile',
@@ -3188,6 +3222,9 @@ window.__ModuleLoader__.load({
3188
3222
  'vault.search': '搜索提示词或标签...',
3189
3223
  'vault.seed': '种子',
3190
3224
  'vault.title': '图像素材库与浏览器',
3225
+ 'vault.exportDesign': '🎨 OpenDesign',
3226
+ 'vault.exportDesignCopied': 'Figma/Penpot 片段与 Data-URL 已复制到剪贴板!',
3227
+ 'vault.exportDesignFailed': '导出设计片段失败',
3191
3228
  'f.comfyTemplate': 'ComfyUI 工作流模板',
3192
3229
  'f.comfyTemplateHint': '经过验证的预设工作流模板(例如 SDXL 文生图、图生图、InstantID 人脸迁移、RealESRGAN 放大器)。',
3193
3230
  'f.activeProjectProfile': '活动项目视觉配置',
@@ -3650,6 +3687,24 @@ window.__ModuleLoader__.load({
3650
3687
  }
3651
3688
  }
3652
3689
 
3690
+ const handleExportDesign = (item) => {
3691
+ const assetId = item.id || item.attachmentId
3692
+ fetchJson('/dsh-image-gen/design/export?image=' + encodeURIComponent(assetId) + '&name=' + encodeURIComponent(item.prompt || 'asset'))
3693
+ .then((r) => r.json())
3694
+ .then((data) => {
3695
+ if (data && data.ok && data.export) {
3696
+ const snippet = data.export.figmaHtml || data.export.dataUrl
3697
+ if (typeof navigator !== 'undefined' && navigator.clipboard) {
3698
+ navigator.clipboard.writeText(snippet)
3699
+ showFeedback(t('vault.exportDesignCopied') || 'Figma/Penpot snippet & Data-URL copied to clipboard!')
3700
+ }
3701
+ } else {
3702
+ showFeedback(t('vault.exportDesignFailed') || 'Failed to export design snippet')
3703
+ }
3704
+ })
3705
+ .catch(() => showFeedback(t('vault.exportDesignFailed') || 'Failed to export design snippet'))
3706
+ }
3707
+
3653
3708
  return react.createElement(
3654
3709
  'div',
3655
3710
  { className: 'ig-vault-container', style: { display: 'flex', flexDirection: 'column', gap: '14px', width: '100%' } },
@@ -3793,6 +3848,7 @@ window.__ModuleLoader__.load({
3793
3848
  { style: { display: 'flex', gap: '8px', justifyContent: 'flex-end', marginTop: '6px' } },
3794
3849
  react.createElement('button', { type: 'button', className: 'ig-tab-btn', onClick: () => handleReroll(selected) }, '🎲 ' + (t('vault.reroll') || 'Re-roll')),
3795
3850
  react.createElement('button', { type: 'button', className: 'ig-tab-btn', onClick: () => handleInsert(selected) }, '💬 ' + (t('vault.insert') || 'Insert in Chat')),
3851
+ react.createElement('button', { type: 'button', className: 'ig-tab-btn', onClick: () => handleExportDesign(selected), title: 'Export for Figma / Penpot / OpenDesign' }, t('vault.exportDesign') || '🎨 OpenDesign'),
3796
3852
  react.createElement(
3797
3853
  'a',
3798
3854
  { href: selected.url || selected.thumbnailUrl, download: 'vault-asset-' + (selected.id || 'image') + '.png', className: 'ig-tab-btn', style: { textDecoration: 'none', display: 'inline-flex', alignItems: 'center' } },
@@ -0,0 +1,205 @@
1
+ // @ts-check
2
+ import path from 'node:path'
3
+ import fs from 'node:fs/promises'
4
+ import { isTrustedLocalRequest, resolveInside } from './security.js'
5
+
6
+ /**
7
+ * Format image buffer or SVG string into targeted design clipboard payloads.
8
+ *
9
+ * @param {object} params
10
+ * @param {Buffer} params.buffer Image bytes
11
+ * @param {string} [params.mimeType] MIME type (image/png, image/jpeg, image/svg+xml, etc.)
12
+ * @param {string} [params.name] Asset name or filename
13
+ * @param {number} [params.width] Width in px
14
+ * @param {number} [params.height] Height in px
15
+ * @param {string} [params.target] 'all' | 'opendesign' | 'clipboard_svg' | 'clipboard_dataurl' | 'penpot_snippet' | 'figma_html'
16
+ * @returns {object} Formatted design payloads
17
+ */
18
+ export function formatDesignExportPayload({
19
+ buffer,
20
+ mimeType = 'image/png',
21
+ name = 'design-asset',
22
+ width = 1024,
23
+ height = 1024,
24
+ target = 'all',
25
+ }) {
26
+ const isSvg = mimeType.includes('svg') || (buffer && buffer.subarray(0, 100).toString('utf8').includes('<svg'))
27
+ const base64Data = buffer ? buffer.toString('base64') : ''
28
+ const effectiveMime = isSvg ? 'image/svg+xml' : (mimeType || 'image/png')
29
+ const dataUrl = `data:${effectiveMime};base64,${base64Data}`
30
+ const svgMarkup = isSvg && buffer ? buffer.toString('utf8') : ''
31
+
32
+ const safeName = String(name || 'asset').replace(/[^a-zA-Z0-9_-]/g, '_')
33
+
34
+ // HTML clipboard snippet compatible with Figma / Penpot / browser paste
35
+ const figmaHtml = isSvg && svgMarkup
36
+ ? svgMarkup
37
+ : `<img src="${dataUrl}" alt="${safeName}" width="${width}" height="${height}" style="max-width:100%;height:auto;" />`
38
+
39
+ // Penpot-friendly snippet (SVG wrapper or embedded image object)
40
+ const penpotSnippet = isSvg && svgMarkup
41
+ ? svgMarkup
42
+ : `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><image href="${dataUrl}" width="${width}" height="${height}"/></svg>`
43
+
44
+ // CSS snippet
45
+ const cssBackground = `background-image: url("${dataUrl}");\nbackground-size: contain;\nbackground-repeat: no-repeat;`
46
+
47
+ return {
48
+ name: safeName,
49
+ mimeType: effectiveMime,
50
+ isSvg,
51
+ width,
52
+ height,
53
+ dataUrl,
54
+ svgMarkup: svgMarkup || null,
55
+ figmaHtml,
56
+ penpotSnippet,
57
+ cssBackground,
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Push an asset directly into an OpenDesign project folder if the daemon / project directory exists.
63
+ *
64
+ * @param {object} params
65
+ * @param {Buffer} params.buffer Asset bytes
66
+ * @param {string} params.filename Target filename (e.g. hero-illustration.png)
67
+ * @param {string} params.projectId OpenDesign project id or name
68
+ * @param {string} [params.openDesignRoot] Path to .od/projects directory
69
+ * @returns {Promise<{ ok: boolean, savedPath?: string, error?: string }>}
70
+ */
71
+ export async function pushAssetToOpenDesignProject({
72
+ buffer,
73
+ filename,
74
+ projectId,
75
+ openDesignRoot = path.join(process.env.HOME || '/home/vadim', 'open-design', '.od', 'projects'),
76
+ }) {
77
+ if (!buffer || !buffer.length) {
78
+ return { ok: false, error: 'Empty buffer provided for design asset export' }
79
+ }
80
+ if (!projectId) {
81
+ return { ok: false, error: 'Missing projectId for OpenDesign export' }
82
+ }
83
+
84
+ const safeProjectId = path.basename(projectId)
85
+ const safeFilename = path.basename(filename || `asset-${Date.now()}.png`)
86
+ const projectDir = path.join(openDesignRoot, safeProjectId)
87
+
88
+ try {
89
+ const stats = await fs.stat(projectDir)
90
+ if (!stats.isDirectory()) {
91
+ return { ok: false, error: `OpenDesign project path is not a directory: ${projectDir}` }
92
+ }
93
+ } catch (err) {
94
+ return { ok: false, error: `OpenDesign project not found on disk: ${safeProjectId} (${err?.message || err})` }
95
+ }
96
+
97
+ const targetPath = resolveInside(projectDir, safeFilename)
98
+ try {
99
+ await fs.writeFile(targetPath, buffer)
100
+ return {
101
+ ok: true,
102
+ savedPath: targetPath,
103
+ filename: safeFilename,
104
+ bytes: buffer.length,
105
+ projectId: safeProjectId,
106
+ }
107
+ } catch (err) {
108
+ return { ok: false, error: `Failed to write file to OpenDesign project: ${err?.message || err}` }
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Register design export routes on DSH web server.
114
+ *
115
+ * @param {object} ctx Cordis context
116
+ * @param {object} deps Dependencies ({ resolveSource })
117
+ */
118
+ export function registerDesignExportRoutes(ctx, deps) {
119
+ const { resolveSource } = deps
120
+
121
+ ctx.effect(() => {
122
+ ctx.webServer.register({
123
+ kind: 'exact',
124
+ path: '/dsh-image-gen/design/export',
125
+ handler: async (req, res) => {
126
+ if (!isTrustedLocalRequest(req)) {
127
+ res.writeHead(403, { 'Content-Type': 'application/json' })
128
+ res.end(JSON.stringify({ ok: false, error: 'forbidden: local trusted requests only' }))
129
+ return
130
+ }
131
+
132
+ if (req.method === 'GET' || req.method === 'POST') {
133
+ try {
134
+ const url = new URL(req.url, 'http://localhost')
135
+ let image = url.searchParams.get('image') || url.searchParams.get('id')
136
+ let target = url.searchParams.get('target') || 'all'
137
+ let projectId = url.searchParams.get('projectId') || url.searchParams.get('project') || ''
138
+ let name = url.searchParams.get('name') || 'exported-asset'
139
+
140
+ if (req.method === 'POST') {
141
+ let bodyStr = ''
142
+ for await (const chunk of req) bodyStr += chunk
143
+ if (bodyStr) {
144
+ try {
145
+ const parsed = JSON.parse(bodyStr)
146
+ if (parsed.image) image = parsed.image
147
+ if (parsed.target) target = parsed.target
148
+ if (parsed.projectId) projectId = parsed.projectId
149
+ if (parsed.name) name = parsed.name
150
+ } catch (_) { /* ignore JSON parse error */ }
151
+ }
152
+ }
153
+
154
+ if (!image) {
155
+ res.writeHead(400, { 'Content-Type': 'application/json' })
156
+ res.end(JSON.stringify({ ok: false, error: 'Missing image or id parameter' }))
157
+ return
158
+ }
159
+
160
+ const source = await resolveSource(ctx, null, image)
161
+ if (!source || !source.bytes) {
162
+ res.writeHead(404, { 'Content-Type': 'application/json' })
163
+ res.end(JSON.stringify({ ok: false, error: `Image source not found: ${image}` }))
164
+ return
165
+ }
166
+
167
+ const formatted = formatDesignExportPayload({
168
+ buffer: source.bytes,
169
+ mimeType: source.mediaType || 'image/png',
170
+ name,
171
+ width: source.width || 1024,
172
+ height: source.height || 1024,
173
+ target,
174
+ })
175
+
176
+ let openDesignResult = null
177
+ if (projectId) {
178
+ const ext = formatted.isSvg ? '.svg' : '.png'
179
+ const fn = name.endsWith(ext) ? name : `${name}${ext}`
180
+ openDesignResult = await pushAssetToOpenDesignProject({
181
+ buffer: source.bytes,
182
+ filename: fn,
183
+ projectId,
184
+ })
185
+ }
186
+
187
+ res.writeHead(200, { 'Content-Type': 'application/json' })
188
+ res.end(JSON.stringify({
189
+ ok: true,
190
+ export: formatted,
191
+ openDesign: openDesignResult,
192
+ }))
193
+ } catch (err) {
194
+ res.writeHead(500, { 'Content-Type': 'application/json' })
195
+ res.end(JSON.stringify({ ok: false, error: err?.message || String(err) }))
196
+ }
197
+ return
198
+ }
199
+
200
+ res.writeHead(405, { 'Content-Type': 'application/json' })
201
+ res.end(JSON.stringify({ ok: false, error: 'Method Not Allowed' }))
202
+ },
203
+ })
204
+ }, 'dsh-image-gen: route /dsh-image-gen/design/export')
205
+ }
package/lib/index.js CHANGED
@@ -56,6 +56,7 @@ import { getStagingSession, reviewStagedEdit, createStagingSession } from './inp
56
56
  import { enqueueBatch, getBatchQueue, getBatchProgress, cancelBatch, retryFailedBatchItems, reconcileQueueOnStartup } from './batch-queue.js'
57
57
  import { createVariantSession, selectVariantWinner, getVariantSession } from './variant-selection.js'
58
58
  import { getImageLineage, getReplayPayload } from './lineage.js'
59
+ import { registerDesignExportRoutes } from './design-export.js'
59
60
  import { resetLoopGuard, getLoopGuardState } from './loop-guard.js'
60
61
  import { Config, plainConfig, volatileConfig, publicConfig, ensureConfig } from './config-schema.js'
61
62
  import { resolveSource } from './resolve-source.js'
@@ -650,6 +651,9 @@ export function apply(ctx, rawConfig) {
650
651
  },
651
652
  }), 'dsh-image-gen: lineage route')
652
653
 
654
+ // Design export route (#446)
655
+ registerDesignExportRoutes(ctx, { resolveSource })
656
+
653
657
  registerLiveProgressRoutes(ctx)
654
658
 
655
659
  // Reset loop guard when user sends a new message (#370)
@@ -26,6 +26,7 @@ import { registerInpaintStagingTools } from './tools/inpaint-staging.js'
26
26
  import { registerBatchQueueTools } from './tools/batch-queue.js'
27
27
  import { registerVariantTools } from './tools/variant-selection.js'
28
28
  import { registerLineageTools } from './tools/lineage.js'
29
+ import { registerDesignExportTools } from './tools/design-export.js'
29
30
  import {
30
31
  createLiveProgressSession,
31
32
  publishLiveProgress,
@@ -173,6 +174,9 @@ export function registerAllTools(ctx, deps) {
173
174
  if (Boolean(sets.lineage)) {
174
175
  registerLineageTools(targetCtx, deps)
175
176
  }
177
+ if (Boolean(sets.designExport || sets.design_export)) {
178
+ registerDesignExportTools(targetCtx, deps)
179
+ }
176
180
  }
177
181
 
178
182
  function dispose() {
@@ -0,0 +1,93 @@
1
+ import { renderToolOutput } from "../attachment-helper.js"
2
+ // design-export tool (#446) — export_design_asset for Figma / Penpot / OpenDesign
3
+
4
+ import { defineTool } from '@deepseek-ai/dsh-tools'
5
+ import { toLosslessJson } from '../providers.js'
6
+ import { formatDesignExportPayload, pushAssetToOpenDesignProject } from '../design-export.js'
7
+
8
+ export function registerDesignExportTools(ctx, deps) {
9
+ const { resolveSource } = deps
10
+
11
+ ctx.effect(() => {
12
+ ctx.tools.register(
13
+ defineTool({
14
+ name: 'export_design_asset',
15
+ description:
16
+ 'Export a generated image, SVG stencil, or asset into design-friendly clipboard snippets '
17
+ + '(Figma HTML img/SVG markup, Penpot SVG snippet, CSS background Data-URL) '
18
+ + 'or push it directly into a local OpenDesign project folder (#446).',
19
+ parameters: {
20
+ image: {
21
+ type: 'string',
22
+ required: true,
23
+ description: 'Path or attachment ID of the image to export.',
24
+ },
25
+ target: {
26
+ type: 'string',
27
+ enum: ['all', 'opendesign', 'clipboard_svg', 'clipboard_dataurl', 'penpot_snippet', 'figma_html'],
28
+ description: 'Target export format or destination (default: all).',
29
+ },
30
+ project_id: {
31
+ type: 'string',
32
+ description: 'Optional OpenDesign project ID to directly save the file into the project artifacts directory.',
33
+ },
34
+ name: {
35
+ type: 'string',
36
+ description: 'Optional stem or filename for the exported design asset.',
37
+ },
38
+ },
39
+ output: {
40
+ render: (_args, value) => renderToolOutput(value),
41
+ schema: {
42
+ type: 'object',
43
+ additionalProperties: true,
44
+ properties: {
45
+ name: { type: 'string' },
46
+ mimeType: { type: 'string' },
47
+ isSvg: { type: 'boolean' },
48
+ width: { type: 'number' },
49
+ height: { type: 'number' },
50
+ dataUrl: { type: 'string' },
51
+ svgMarkup: { type: 'string' },
52
+ figmaHtml: { type: 'string' },
53
+ penpotSnippet: { type: 'string' },
54
+ cssBackground: { type: 'string' },
55
+ openDesign: { type: 'object', additionalProperties: true },
56
+ },
57
+ },
58
+ },
59
+ async execute(args, exec) {
60
+ const source = await resolveSource(ctx, exec, args.image)
61
+ if (!source || !source.bytes) {
62
+ throw new Error(`Image not found or unreadable: ${args.image}`)
63
+ }
64
+
65
+ const payload = formatDesignExportPayload({
66
+ buffer: source.bytes,
67
+ mimeType: source.mediaType || 'image/png',
68
+ name: args.name || 'exported-asset',
69
+ width: source.width || 1024,
70
+ height: source.height || 1024,
71
+ target: args.target || 'all',
72
+ })
73
+
74
+ let openDesign = null
75
+ if (args.project_id) {
76
+ const ext = payload.isSvg ? '.svg' : '.png'
77
+ const filename = (args.name || 'asset').endsWith(ext) ? args.name : `${args.name || 'asset'}${ext}`
78
+ openDesign = await pushAssetToOpenDesignProject({
79
+ buffer: source.bytes,
80
+ filename,
81
+ projectId: args.project_id,
82
+ })
83
+ }
84
+
85
+ return toLosslessJson({
86
+ ...payload,
87
+ ...(openDesign ? { openDesign } : {}),
88
+ })
89
+ },
90
+ }),
91
+ )
92
+ })
93
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-image-gen",
3
- "version": "0.11.42",
3
+ "version": "0.12.0",
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",