@goodandready/dsh-image-gen 0.11.26 → 0.11.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/attachment-helper.js +25 -0
- package/lib/client.js +179 -39
- package/lib/index.js +4 -0
- package/lib/live-progress.js +278 -0
- package/lib/providers/backends/custom.js +7 -1
- package/lib/providers/backends/fal.js +7 -3
- package/lib/providers/backends/local.js +137 -48
- package/lib/providers/shared-helpers.js +63 -1
- package/lib/register-tools.js +19 -1
- package/lib/tools/editing.js +17 -0
- package/lib/tools/generation.js +7 -2
- package/lib/tools/style-matrix.js +9 -1
- package/lib/tools/theme-pair.js +19 -1
- package/package.json +1 -1
package/lib/attachment-helper.js
CHANGED
|
@@ -112,3 +112,28 @@ export function renderToolOutput(value) {
|
|
|
112
112
|
}
|
|
113
113
|
return blocks
|
|
114
114
|
}
|
|
115
|
+
|
|
116
|
+
export function buildPresentationMeta(value) {
|
|
117
|
+
if (!value || typeof value !== 'object') return null
|
|
118
|
+
const meta = {
|
|
119
|
+
url: value.url,
|
|
120
|
+
path: value.path,
|
|
121
|
+
width: value.width,
|
|
122
|
+
height: value.height,
|
|
123
|
+
seed: value.seed,
|
|
124
|
+
prompt: value.prompt,
|
|
125
|
+
provider: value.provider,
|
|
126
|
+
model: value.model,
|
|
127
|
+
attachment: value.attachment,
|
|
128
|
+
attachmentId: value.attachment?.attachmentId || value.attachmentId,
|
|
129
|
+
}
|
|
130
|
+
if (value.images) meta.images = value.images
|
|
131
|
+
if (value.cells) meta.cells = value.cells
|
|
132
|
+
if (value.base_prompt) meta.base_prompt = value.base_prompt
|
|
133
|
+
if (value.blind_mode !== undefined) meta.blind_mode = value.blind_mode
|
|
134
|
+
if (value.light) meta.light = value.light
|
|
135
|
+
if (value.dark) meta.dark = value.dark
|
|
136
|
+
if (value.html_snippet) meta.html_snippet = value.html_snippet
|
|
137
|
+
if (value.css_snippet) meta.css_snippet = value.css_snippet
|
|
138
|
+
return meta
|
|
139
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -145,11 +145,15 @@ window.__ModuleLoader__.load({
|
|
|
145
145
|
.ig-progress-meta{display:flex;justify-content:space-between;align-items:center;font-size:11px;color:var(--dsw-alias-label-secondary);font-weight:500}
|
|
146
146
|
.ig-progress-track{height:6px;width:100%;background:var(--dsw-alias-bg-layer-1);border-radius:3px;overflow:hidden;position:relative}
|
|
147
147
|
.ig-progress-fill{height:100%;background:var(--dsw-alias-state-brand);border-radius:3px;transition:width 0.25s ease-in-out}
|
|
148
|
+
.ig-progress-indeterminate{width:40%;animation:ig-indeterminate-slide 1.5s infinite ease-in-out}
|
|
149
|
+
@keyframes ig-indeterminate-slide{0%{transform:translateX(-100%)}50%{transform:translateX(150%)}100%{transform:translateX(300%)}}
|
|
148
150
|
|
|
149
151
|
.ig-draft-overlay{position:absolute;bottom:8px;left:8px;padding:3px 8px;border-radius:4px;background:color-mix(in srgb, var(--dsw-alias-bg-layer-1) 80%, transparent);font-size:11px;font-weight:600;color:var(--dsw-alias-label-primary);display:flex;align-items:center;gap:6px;backdrop-filter:blur(4px)}
|
|
150
152
|
|
|
151
153
|
@media (prefers-reduced-motion: reduce){
|
|
152
154
|
.ig-draft-img,.ig-final-img{transition:none !important}
|
|
155
|
+
.ig-progress-fill{transition:none !important;animation:none !important}
|
|
156
|
+
.ig-progress-indeterminate{animation:none !important;width:100%;opacity:0.6}
|
|
153
157
|
}
|
|
154
158
|
|
|
155
159
|
/* Asset Vault & Studio (#158, #159) */
|
|
@@ -1369,17 +1373,53 @@ window.__ModuleLoader__.load({
|
|
|
1369
1373
|
}
|
|
1370
1374
|
|
|
1371
1375
|
// -------------------------------------------------------------- Toolview Component
|
|
1372
|
-
const
|
|
1376
|
+
const HTTP_IMAGE_URL_RE = /https?:\/\/[^\s)]+\.(png|jpg|jpeg|webp|gif)(\?[^\s)]*)?/i
|
|
1377
|
+
const RELATIVE_IMAGE_URL_RE = /(\/dsh-image-gen\/image\?[^\s)"'<>]+)/i
|
|
1378
|
+
const MARKDOWN_IMAGE_RE = /!\[.*?\]\(((\/|https?:\/\/)[^\s)]+)\)/i
|
|
1379
|
+
const MARKDOWN_LINK_RE = /\[.*?\]\(((\/|https?:\/\/)[^\s)]+)\)/i
|
|
1373
1380
|
|
|
1374
1381
|
function readResult(block) {
|
|
1375
1382
|
if (!block) return { text: '', url: '', attachment: null }
|
|
1376
1383
|
const text = block.output || block.text || ''
|
|
1377
|
-
const
|
|
1378
|
-
|
|
1384
|
+
const meta = block.presentationMeta || block.meta || block.data || block.result || null
|
|
1385
|
+
|
|
1386
|
+
let attachment = block.attachment
|
|
1387
|
+
|| (Array.isArray(block.attachments) && block.attachments[0])
|
|
1388
|
+
|| meta?.attachment
|
|
1389
|
+
|| null
|
|
1390
|
+
|
|
1391
|
+
let url = meta?.url || block.url || ''
|
|
1392
|
+
|
|
1393
|
+
if (!url && attachment) {
|
|
1394
|
+
url = attachmentImageUrl(attachment)
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
let cleanText = text
|
|
1398
|
+
if (!url && text) {
|
|
1399
|
+
const mdImg = text.match(MARKDOWN_IMAGE_RE)
|
|
1400
|
+
const relUrl = text.match(RELATIVE_IMAGE_URL_RE)
|
|
1401
|
+
const httpUrl = text.match(HTTP_IMAGE_URL_RE)
|
|
1402
|
+
const mdLink = text.match(MARKDOWN_LINK_RE)
|
|
1403
|
+
|
|
1404
|
+
if (mdImg) {
|
|
1405
|
+
url = mdImg[1]
|
|
1406
|
+
cleanText = text.replace(mdImg[0], '').trim()
|
|
1407
|
+
} else if (relUrl) {
|
|
1408
|
+
url = relUrl[1]
|
|
1409
|
+
cleanText = text.replace(relUrl[0], '').trim()
|
|
1410
|
+
} else if (httpUrl) {
|
|
1411
|
+
url = httpUrl[0]
|
|
1412
|
+
cleanText = text.replace(httpUrl[0], '').trim()
|
|
1413
|
+
} else if (mdLink) {
|
|
1414
|
+
url = mdLink[1]
|
|
1415
|
+
cleanText = text.replace(mdLink[0], '').trim()
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1379
1419
|
return {
|
|
1380
1420
|
attachment,
|
|
1381
|
-
url
|
|
1382
|
-
text:
|
|
1421
|
+
url,
|
|
1422
|
+
text: cleanText,
|
|
1383
1423
|
}
|
|
1384
1424
|
}
|
|
1385
1425
|
|
|
@@ -1586,6 +1626,7 @@ window.__ModuleLoader__.load({
|
|
|
1586
1626
|
const draftSrc = block && (block.draftUrl || block.previewUrl || '')
|
|
1587
1627
|
body.push(react.createElement(ProgressiveImagePreview, {
|
|
1588
1628
|
key: 'prog-img',
|
|
1629
|
+
callId: block && (block.id || block.callId || block.toolCallId || ''),
|
|
1589
1630
|
src: displaySrc,
|
|
1590
1631
|
draftSrc,
|
|
1591
1632
|
isRunning: running,
|
|
@@ -1694,8 +1735,9 @@ window.__ModuleLoader__.load({
|
|
|
1694
1735
|
react,
|
|
1695
1736
|
t,
|
|
1696
1737
|
parsed,
|
|
1697
|
-
imgUrl: parsed.
|
|
1698
|
-
targetRef: parsed.
|
|
1738
|
+
imgUrl: parsed.url || (parsed.attachment ? attachmentImageUrl(parsed.attachment) : ''),
|
|
1739
|
+
targetRef: (parsed.attachment && (parsed.attachment.attachmentId || parsed.attachment.id)) || parsed.url || 'latest',
|
|
1740
|
+
sendActionPrompt,
|
|
1699
1741
|
onClose: () => setInpaintOpen(false),
|
|
1700
1742
|
}) : null
|
|
1701
1743
|
|
|
@@ -1761,23 +1803,76 @@ window.__ModuleLoader__.load({
|
|
|
1761
1803
|
current.seed !== undefined ? `seed: ${current.seed}` : (current.timestamp ? new Date(current.timestamp).toLocaleTimeString() : '')
|
|
1762
1804
|
)
|
|
1763
1805
|
)
|
|
1764
|
-
} // -------------------------------------------------------------- Progressive Draft & Live Progress Preview (#147, #329)
|
|
1806
|
+
} // -------------------------------------------------------------- Progressive Draft & Live Progress Preview (#147, #329, #379)
|
|
1765
1807
|
function ProgressiveImagePreview(props) {
|
|
1766
1808
|
const src = props.src
|
|
1767
|
-
const draftSrc = props.draftSrc
|
|
1768
1809
|
const isRunning = props.isRunning
|
|
1769
|
-
const
|
|
1770
|
-
const step = props.step
|
|
1771
|
-
const stage = props.stage || (progress !== undefined && progress < 100 ? 'Rendering...' : 'Complete')
|
|
1810
|
+
const callId = props.callId
|
|
1772
1811
|
const alt = props.alt || 'Generated image'
|
|
1773
1812
|
const t = props.t || ((k) => k)
|
|
1774
1813
|
|
|
1775
1814
|
const [loaded, setLoaded] = react.useState(false)
|
|
1815
|
+
const [liveState, setLiveState] = react.useState({
|
|
1816
|
+
progress: undefined,
|
|
1817
|
+
step: undefined,
|
|
1818
|
+
stage: undefined,
|
|
1819
|
+
draftSrc: '',
|
|
1820
|
+
})
|
|
1776
1821
|
|
|
1777
1822
|
react.useEffect(() => {
|
|
1778
1823
|
setLoaded(false)
|
|
1779
1824
|
}, [src])
|
|
1780
1825
|
|
|
1826
|
+
// Subscribe to live events transport when running and callId is present (#379)
|
|
1827
|
+
react.useEffect(() => {
|
|
1828
|
+
const EventSourceImpl = typeof window !== 'undefined' ? window.EventSource : undefined
|
|
1829
|
+
if (!isRunning || !callId || !EventSourceImpl) {
|
|
1830
|
+
return
|
|
1831
|
+
}
|
|
1832
|
+
let es
|
|
1833
|
+
try {
|
|
1834
|
+
es = new EventSourceImpl('/dsh-image-gen/live-events?callId=' + encodeURIComponent(callId))
|
|
1835
|
+
es.onmessage = (e) => {
|
|
1836
|
+
try {
|
|
1837
|
+
const data = JSON.parse(e.data)
|
|
1838
|
+
if (!data) return
|
|
1839
|
+
setLiveState((prev) => ({
|
|
1840
|
+
progress: typeof data.progress === 'number' ? Math.min(100, Math.max(0, data.progress)) : prev.progress,
|
|
1841
|
+
step: data.step !== undefined ? data.step : prev.step,
|
|
1842
|
+
stage: data.stage !== undefined ? data.stage : prev.stage,
|
|
1843
|
+
draftSrc: data.draftUrl || prev.draftSrc,
|
|
1844
|
+
}))
|
|
1845
|
+
} catch (_err) {
|
|
1846
|
+
// Ignore event parsing errors
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
es.onerror = () => {
|
|
1850
|
+
// EventSource handles retries or terminates on close
|
|
1851
|
+
}
|
|
1852
|
+
} catch (_err) {
|
|
1853
|
+
// Ignore EventSource creation errors
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
return () => {
|
|
1857
|
+
if (es) {
|
|
1858
|
+
try {
|
|
1859
|
+
es.close()
|
|
1860
|
+
} catch (_err) {
|
|
1861
|
+
// Ignore EventSource close errors
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
}, [isRunning, callId])
|
|
1866
|
+
|
|
1867
|
+
const draftSrc = liveState.draftSrc || props.draftSrc
|
|
1868
|
+
const progress = liveState.progress !== undefined
|
|
1869
|
+
? liveState.progress
|
|
1870
|
+
: (typeof props.progress === 'number' ? Math.min(100, Math.max(0, props.progress)) : undefined)
|
|
1871
|
+
const step = liveState.step !== undefined ? liveState.step : props.step
|
|
1872
|
+
const stage = liveState.stage
|
|
1873
|
+
|| props.stage
|
|
1874
|
+
|| (progress !== undefined && progress < 100 ? (t('card.rendering') || 'Rendering...') : (isRunning ? (t('card.generating') || 'Generating...') : 'Complete'))
|
|
1875
|
+
|
|
1781
1876
|
const showProgress = isRunning || (progress !== undefined && progress < 100)
|
|
1782
1877
|
|
|
1783
1878
|
const progressElement = showProgress ? react.createElement(
|
|
@@ -1793,8 +1888,8 @@ window.__ModuleLoader__.load({
|
|
|
1793
1888
|
'div',
|
|
1794
1889
|
{ className: 'ig-progress-track' },
|
|
1795
1890
|
react.createElement('div', {
|
|
1796
|
-
className: 'ig-progress-fill',
|
|
1797
|
-
style:
|
|
1891
|
+
className: 'ig-progress-fill' + (progress === undefined ? ' ig-progress-indeterminate' : ''),
|
|
1892
|
+
style: progress !== undefined ? { width: progress + '%' } : undefined,
|
|
1798
1893
|
})
|
|
1799
1894
|
)
|
|
1800
1895
|
) : null
|
|
@@ -1842,10 +1937,17 @@ window.__ModuleLoader__.load({
|
|
|
1842
1937
|
progressElement
|
|
1843
1938
|
)
|
|
1844
1939
|
}
|
|
1845
|
-
// 105-inpaint-canvas.js — Interactive in-chat canvas drawing overlay for inpainting (#285, #301, #328).
|
|
1940
|
+
// 105-inpaint-canvas.js — Interactive in-chat canvas drawing overlay for inpainting (#285, #301, #328, #380).
|
|
1941
|
+
|
|
1942
|
+
function buildEditInstruction(opts) {
|
|
1943
|
+
const p = (opts && opts.prompt ? opts.prompt.trim() : '') || 'inpaint masked region'
|
|
1944
|
+
const img = (opts && opts.image) || 'latest'
|
|
1945
|
+
const m = (opts && opts.mask) || ''
|
|
1946
|
+
return 'Use edit_image with prompt: "' + p + '", image: "' + img + '", mask: "' + m + '"'
|
|
1947
|
+
}
|
|
1846
1948
|
|
|
1847
1949
|
function InpaintCanvas(props) {
|
|
1848
|
-
const { imgUrl, targetRef, parsed, onClose, t } = props
|
|
1950
|
+
const { imgUrl, targetRef, parsed, onClose, sendActionPrompt, t } = props
|
|
1849
1951
|
const canvasRef = react.useRef(null)
|
|
1850
1952
|
const isDrawingRef = react.useRef(false)
|
|
1851
1953
|
const undoStackRef = react.useRef([])
|
|
@@ -1856,8 +1958,12 @@ window.__ModuleLoader__.load({
|
|
|
1856
1958
|
const [inpaintPrompt, setInpaintPrompt] = react.useState('')
|
|
1857
1959
|
const [canvasDims, setCanvasDims] = react.useState({ width: 512, height: 512, ratio: '1/1' })
|
|
1858
1960
|
|
|
1859
|
-
const effectiveImgUrl = imgUrl || parsed?.
|
|
1860
|
-
const effectiveTargetRef = targetRef
|
|
1961
|
+
const effectiveImgUrl = imgUrl || (parsed?.attachment ? attachmentImageUrl(parsed.attachment) : (parsed?.url || ''))
|
|
1962
|
+
const effectiveTargetRef = targetRef
|
|
1963
|
+
|| (parsed?.attachment && (parsed.attachment.attachmentId || parsed.attachment.id))
|
|
1964
|
+
|| parsed?.attachmentId
|
|
1965
|
+
|| parsed?.url
|
|
1966
|
+
|| 'latest'
|
|
1861
1967
|
|
|
1862
1968
|
const initCanvas = react.useCallback(() => {
|
|
1863
1969
|
const canvas = canvasRef.current
|
|
@@ -1877,11 +1983,8 @@ window.__ModuleLoader__.load({
|
|
|
1877
1983
|
if (e.target && e.target.naturalWidth && e.target.naturalHeight) {
|
|
1878
1984
|
const nw = e.target.naturalWidth
|
|
1879
1985
|
const nh = e.target.naturalHeight
|
|
1880
|
-
//
|
|
1881
|
-
|
|
1882
|
-
const cw = Math.round(nw * scale)
|
|
1883
|
-
const ch = Math.round(nh * scale)
|
|
1884
|
-
setCanvasDims({ width: cw, height: ch, ratio: `${cw}/${ch}` })
|
|
1986
|
+
// Preserve 1:1 intrinsic source dimensions for accurate mask generation (#380)
|
|
1987
|
+
setCanvasDims({ width: nw, height: nh, ratio: `${nw}/${nh}` })
|
|
1885
1988
|
}
|
|
1886
1989
|
}
|
|
1887
1990
|
|
|
@@ -1889,11 +1992,12 @@ window.__ModuleLoader__.load({
|
|
|
1889
1992
|
const rect = canvas.getBoundingClientRect()
|
|
1890
1993
|
const clientX = e.touches ? e.touches[0].clientX : e.clientX
|
|
1891
1994
|
const clientY = e.touches ? e.touches[0].clientY : e.clientY
|
|
1892
|
-
const scaleX = canvas.width / rect.width
|
|
1893
|
-
const scaleY = canvas.height / rect.height
|
|
1995
|
+
const scaleX = canvas.width / (rect.width || canvas.width)
|
|
1996
|
+
const scaleY = canvas.height / (rect.height || canvas.height)
|
|
1894
1997
|
return {
|
|
1895
1998
|
x: (clientX - rect.left) * scaleX,
|
|
1896
1999
|
y: (clientY - rect.top) * scaleY,
|
|
2000
|
+
scale: (scaleX + scaleY) / 2,
|
|
1897
2001
|
}
|
|
1898
2002
|
}
|
|
1899
2003
|
|
|
@@ -1905,16 +2009,17 @@ window.__ModuleLoader__.load({
|
|
|
1905
2009
|
if (!ctx) return
|
|
1906
2010
|
isDrawingRef.current = true
|
|
1907
2011
|
const pos = getPos(e, canvas)
|
|
2012
|
+
const actualBrush = brushSize * (pos.scale || 1)
|
|
1908
2013
|
ctx.beginPath()
|
|
1909
2014
|
ctx.moveTo(pos.x, pos.y)
|
|
1910
|
-
ctx.lineWidth =
|
|
2015
|
+
ctx.lineWidth = actualBrush
|
|
1911
2016
|
ctx.lineCap = 'round'
|
|
1912
2017
|
ctx.lineJoin = 'round'
|
|
1913
2018
|
const color = toolMode === 'eraser' ? 'black' : 'white'
|
|
1914
2019
|
ctx.strokeStyle = color
|
|
1915
2020
|
ctx.fillStyle = color
|
|
1916
2021
|
|
|
1917
|
-
ctx.arc(pos.x, pos.y,
|
|
2022
|
+
ctx.arc(pos.x, pos.y, actualBrush / 2, 0, Math.PI * 2)
|
|
1918
2023
|
ctx.fill()
|
|
1919
2024
|
ctx.beginPath()
|
|
1920
2025
|
ctx.moveTo(pos.x, pos.y)
|
|
@@ -1929,25 +2034,26 @@ window.__ModuleLoader__.load({
|
|
|
1929
2034
|
const ctx = canvas.getContext('2d')
|
|
1930
2035
|
if (!ctx) return
|
|
1931
2036
|
const pos = getPos(e, canvas)
|
|
1932
|
-
|
|
2037
|
+
const actualBrush = brushSize * (pos.scale || 1)
|
|
2038
|
+
ctx.lineWidth = actualBrush
|
|
1933
2039
|
ctx.lineCap = 'round'
|
|
1934
2040
|
ctx.lineJoin = 'round'
|
|
1935
2041
|
const color = toolMode === 'eraser' ? 'black' : 'white'
|
|
1936
2042
|
ctx.strokeStyle = color
|
|
1937
2043
|
ctx.lineTo(pos.x, pos.y)
|
|
1938
2044
|
ctx.stroke()
|
|
1939
|
-
|
|
2045
|
+
ctx.beginPath()
|
|
2046
|
+
ctx.moveTo(pos.x, pos.y)
|
|
1940
2047
|
}
|
|
1941
2048
|
|
|
1942
|
-
function stopDraw(
|
|
2049
|
+
function stopDraw() {
|
|
1943
2050
|
if (!isDrawingRef.current) return
|
|
1944
2051
|
isDrawingRef.current = false
|
|
1945
2052
|
const canvas = canvasRef.current
|
|
1946
2053
|
if (!canvas) return
|
|
1947
2054
|
const ctx = canvas.getContext('2d')
|
|
1948
2055
|
if (!ctx) return
|
|
1949
|
-
ctx.
|
|
1950
|
-
|
|
2056
|
+
ctx.beginPath()
|
|
1951
2057
|
if (undoStackRef.current.length >= 15) {
|
|
1952
2058
|
undoStackRef.current.shift()
|
|
1953
2059
|
}
|
|
@@ -2000,7 +2106,15 @@ window.__ModuleLoader__.load({
|
|
|
2000
2106
|
if (!canvas) return
|
|
2001
2107
|
const maskDataUrl = canvas.toDataURL('image/png')
|
|
2002
2108
|
const promptText = inpaintPrompt.trim() || 'inpaint masked region'
|
|
2003
|
-
const instruction =
|
|
2109
|
+
const instruction = buildEditInstruction({
|
|
2110
|
+
prompt: promptText,
|
|
2111
|
+
image: effectiveTargetRef,
|
|
2112
|
+
mask: maskDataUrl,
|
|
2113
|
+
})
|
|
2114
|
+
|
|
2115
|
+
if (typeof sendActionPrompt === 'function') {
|
|
2116
|
+
sendActionPrompt(instruction)
|
|
2117
|
+
}
|
|
2004
2118
|
|
|
2005
2119
|
if (typeof navigator !== 'undefined' && navigator.clipboard) {
|
|
2006
2120
|
navigator.clipboard.writeText(instruction)
|
|
@@ -2188,7 +2302,18 @@ window.__ModuleLoader__.load({
|
|
|
2188
2302
|
const t = props.t || ((k) => k)
|
|
2189
2303
|
const block = props.block || {}
|
|
2190
2304
|
const parsed = react.useMemo(() => {
|
|
2305
|
+
const meta = block.presentationMeta || block.meta || block.data || block.result
|
|
2306
|
+
if (meta && Array.isArray(meta.cells) && meta.cells.length) {
|
|
2307
|
+
return meta
|
|
2308
|
+
}
|
|
2191
2309
|
const raw = block.output || block.text || ''
|
|
2310
|
+
const tagMatch = raw.match(/<dsh-image-gen-matrix>([\s\S]*?)<\/dsh-image-gen-matrix>/)
|
|
2311
|
+
if (tagMatch) {
|
|
2312
|
+
const fromTag = tryParseJsonObject(tagMatch[1])
|
|
2313
|
+
if (fromTag && Array.isArray(fromTag.cells) && fromTag.cells.length) {
|
|
2314
|
+
return fromTag
|
|
2315
|
+
}
|
|
2316
|
+
}
|
|
2192
2317
|
const obj = tryParseJsonObject(raw)
|
|
2193
2318
|
return obj || { base_prompt: '', blind_mode: false, cells: [] }
|
|
2194
2319
|
}, [block])
|
|
@@ -2307,12 +2432,27 @@ window.__ModuleLoader__.load({
|
|
|
2307
2432
|
let args = {}
|
|
2308
2433
|
try { if (raw) args = JSON.parse(raw) } catch (_) { /* invalid json */ }
|
|
2309
2434
|
|
|
2310
|
-
let data =
|
|
2311
|
-
|
|
2312
|
-
const out = block && (block.output || block.
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2435
|
+
let data = block && (block.presentationMeta || block.meta || block.data || block.result)
|
|
2436
|
+
if (!data || (!data.light && !data.dark)) {
|
|
2437
|
+
const out = block && (block.output || block.text || '')
|
|
2438
|
+
const tagMatch = typeof out === 'string' ? out.match(/<dsh-image-gen-theme-pair>([\s\S]*?)<\/dsh-image-gen-theme-pair>/) : null
|
|
2439
|
+
if (tagMatch) {
|
|
2440
|
+
try {
|
|
2441
|
+
data = JSON.parse(tagMatch[1])
|
|
2442
|
+
} catch (_err) {
|
|
2443
|
+
// Malformed XML payload
|
|
2444
|
+
}
|
|
2445
|
+
}
|
|
2446
|
+
if (!data || (!data.light && !data.dark)) {
|
|
2447
|
+
try {
|
|
2448
|
+
if (typeof out === 'object') data = out
|
|
2449
|
+
else if (typeof out === 'string') data = JSON.parse(out)
|
|
2450
|
+
} catch (_err) {
|
|
2451
|
+
// Malformed fallback output
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
data = data || {}
|
|
2316
2456
|
|
|
2317
2457
|
const light = data.light || {}
|
|
2318
2458
|
const dark = data.dark || {}
|
package/lib/index.js
CHANGED
|
@@ -44,6 +44,7 @@ import { registerImageCommand } from './commands.js'
|
|
|
44
44
|
import { registerAllTools } from './register-tools.js'
|
|
45
45
|
import { registerPluginUpdater } from './updater.js'
|
|
46
46
|
import { registerVaultRoutes } from './vault.js'
|
|
47
|
+
import { registerLiveProgressRoutes } from './live-progress.js'
|
|
47
48
|
import { registerSettingsRoutes } from './settings-route.js'
|
|
48
49
|
import { clearAllAnchors } from './anchor-helpers.js'
|
|
49
50
|
import { resetLoopGuard, getLoopGuardState } from './loop-guard.js'
|
|
@@ -418,6 +419,9 @@ export function apply(ctx, rawConfig) {
|
|
|
418
419
|
// Vault route (#159)
|
|
419
420
|
registerVaultRoutes(ctx)
|
|
420
421
|
|
|
422
|
+
// Live events route (#379)
|
|
423
|
+
registerLiveProgressRoutes(ctx)
|
|
424
|
+
|
|
421
425
|
// Reset loop guard when user sends a new message (#370)
|
|
422
426
|
if (typeof ctx.on === 'function') {
|
|
423
427
|
ctx.on('chat/message', (session) => {
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// lib/live-progress.js — Live progress event hub & presentation transport (#147, #329, #379)
|
|
2
|
+
|
|
3
|
+
import { isTrustedLocalRequest } from './security.js'
|
|
4
|
+
|
|
5
|
+
const activeSessions = new Map()
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Creates or resets a live progress session for a tool call.
|
|
9
|
+
*
|
|
10
|
+
* @param {string} callId
|
|
11
|
+
* @param {{ signal?: AbortSignal, onProgress?: Function }} [options]
|
|
12
|
+
* @returns {object}
|
|
13
|
+
*/
|
|
14
|
+
export function createLiveProgressSession(callId, options = {}) {
|
|
15
|
+
if (!callId) return null
|
|
16
|
+
const existing = activeSessions.get(callId)
|
|
17
|
+
if (existing) {
|
|
18
|
+
existing.listeners.clear()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const session = {
|
|
22
|
+
callId,
|
|
23
|
+
stage: 'Starting...',
|
|
24
|
+
step: undefined,
|
|
25
|
+
progress: undefined,
|
|
26
|
+
draftUrl: undefined,
|
|
27
|
+
done: false,
|
|
28
|
+
error: undefined,
|
|
29
|
+
timestamp: Date.now(),
|
|
30
|
+
listeners: new Set(),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (options.signal) {
|
|
34
|
+
options.signal.addEventListener(
|
|
35
|
+
'abort',
|
|
36
|
+
() => {
|
|
37
|
+
abortLiveProgress(callId, 'Execution aborted')
|
|
38
|
+
},
|
|
39
|
+
{ once: true },
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
activeSessions.set(callId, session)
|
|
44
|
+
return session
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Publishes an incremental update to a live progress session.
|
|
49
|
+
*
|
|
50
|
+
* @param {string} callId
|
|
51
|
+
* @param {{ stage?: string, step?: string, progress?: number, draftUrl?: string }} update
|
|
52
|
+
*/
|
|
53
|
+
export function publishLiveProgress(callId, update = {}) {
|
|
54
|
+
if (!callId) return
|
|
55
|
+
let session = activeSessions.get(callId)
|
|
56
|
+
if (!session) {
|
|
57
|
+
session = createLiveProgressSession(callId)
|
|
58
|
+
}
|
|
59
|
+
if (session.done) return
|
|
60
|
+
|
|
61
|
+
if (update.stage !== undefined) session.stage = update.stage
|
|
62
|
+
if (update.step !== undefined) session.step = update.step
|
|
63
|
+
if (update.progress !== undefined) {
|
|
64
|
+
session.progress = typeof update.progress === 'number'
|
|
65
|
+
? Math.min(100, Math.max(0, Math.round(update.progress)))
|
|
66
|
+
: undefined
|
|
67
|
+
}
|
|
68
|
+
if (update.draftUrl !== undefined) session.draftUrl = update.draftUrl
|
|
69
|
+
session.timestamp = Date.now()
|
|
70
|
+
|
|
71
|
+
const payload = {
|
|
72
|
+
callId,
|
|
73
|
+
stage: session.stage,
|
|
74
|
+
step: session.step,
|
|
75
|
+
progress: session.progress,
|
|
76
|
+
draftUrl: session.draftUrl,
|
|
77
|
+
done: false,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
for (const listener of session.listeners) {
|
|
81
|
+
try {
|
|
82
|
+
listener(payload)
|
|
83
|
+
} catch (_err) {
|
|
84
|
+
// Listener notification failure ignored
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Marks a session as complete and notifies subscribers.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} callId
|
|
93
|
+
* @param {object} [finalData]
|
|
94
|
+
*/
|
|
95
|
+
export function completeLiveProgress(callId, finalData = {}) {
|
|
96
|
+
if (!callId) return
|
|
97
|
+
const session = activeSessions.get(callId)
|
|
98
|
+
if (!session || session.done) return
|
|
99
|
+
|
|
100
|
+
session.done = true
|
|
101
|
+
session.stage = 'Complete'
|
|
102
|
+
session.progress = 100
|
|
103
|
+
session.timestamp = Date.now()
|
|
104
|
+
|
|
105
|
+
const payload = {
|
|
106
|
+
callId,
|
|
107
|
+
stage: 'Complete',
|
|
108
|
+
progress: 100,
|
|
109
|
+
draftUrl: session.draftUrl,
|
|
110
|
+
done: true,
|
|
111
|
+
...finalData,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
for (const listener of session.listeners) {
|
|
115
|
+
try {
|
|
116
|
+
listener(payload)
|
|
117
|
+
} catch (_err) {
|
|
118
|
+
// Listener notification failure ignored
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
session.listeners.clear()
|
|
122
|
+
|
|
123
|
+
// Retain briefly for trailing SSE disconnects, then purge
|
|
124
|
+
setTimeout(() => {
|
|
125
|
+
activeSessions.delete(callId)
|
|
126
|
+
}, 15000)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Marks a session as aborted or errored.
|
|
131
|
+
*
|
|
132
|
+
* @param {string} callId
|
|
133
|
+
* @param {string} [reason]
|
|
134
|
+
*/
|
|
135
|
+
export function abortLiveProgress(callId, reason = 'Cancelled') {
|
|
136
|
+
if (!callId) return
|
|
137
|
+
const session = activeSessions.get(callId)
|
|
138
|
+
if (!session || session.done) return
|
|
139
|
+
|
|
140
|
+
session.done = true
|
|
141
|
+
session.error = reason
|
|
142
|
+
session.timestamp = Date.now()
|
|
143
|
+
|
|
144
|
+
const payload = {
|
|
145
|
+
callId,
|
|
146
|
+
error: reason,
|
|
147
|
+
done: true,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
for (const listener of session.listeners) {
|
|
151
|
+
try {
|
|
152
|
+
listener(payload)
|
|
153
|
+
} catch (_err) {
|
|
154
|
+
// Listener notification failure ignored
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
session.listeners.clear()
|
|
158
|
+
|
|
159
|
+
setTimeout(() => {
|
|
160
|
+
activeSessions.delete(callId)
|
|
161
|
+
}, 10000)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Returns current snapshot of a live progress session.
|
|
166
|
+
*
|
|
167
|
+
* @param {string} callId
|
|
168
|
+
* @returns {object|null}
|
|
169
|
+
*/
|
|
170
|
+
export function getLiveProgress(callId) {
|
|
171
|
+
if (!callId) return null
|
|
172
|
+
const session = activeSessions.get(callId)
|
|
173
|
+
if (!session) return null
|
|
174
|
+
return {
|
|
175
|
+
callId: session.callId,
|
|
176
|
+
stage: session.stage,
|
|
177
|
+
step: session.step,
|
|
178
|
+
progress: session.progress,
|
|
179
|
+
draftUrl: session.draftUrl,
|
|
180
|
+
done: session.done,
|
|
181
|
+
error: session.error,
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Subscribes a listener to progress events for a callId.
|
|
187
|
+
* Calls listener with current state immediately if present.
|
|
188
|
+
*
|
|
189
|
+
* @param {string} callId
|
|
190
|
+
* @param {Function} listener
|
|
191
|
+
* @returns {Function} unsubscribe
|
|
192
|
+
*/
|
|
193
|
+
export function subscribeLiveProgress(callId, listener) {
|
|
194
|
+
if (!callId || typeof listener !== 'function') return () => {}
|
|
195
|
+
let session = activeSessions.get(callId)
|
|
196
|
+
if (!session) {
|
|
197
|
+
session = createLiveProgressSession(callId)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
session.listeners.add(listener)
|
|
201
|
+
|
|
202
|
+
// Initial event replay
|
|
203
|
+
listener({
|
|
204
|
+
callId: session.callId,
|
|
205
|
+
stage: session.stage,
|
|
206
|
+
step: session.step,
|
|
207
|
+
progress: session.progress,
|
|
208
|
+
draftUrl: session.draftUrl,
|
|
209
|
+
done: session.done,
|
|
210
|
+
error: session.error,
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
return () => {
|
|
214
|
+
session.listeners.delete(listener)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Registers the /dsh-image-gen/live-events SSE route on DSH web server.
|
|
220
|
+
*
|
|
221
|
+
* @param {object} ctx cordis context
|
|
222
|
+
*/
|
|
223
|
+
export function registerLiveProgressRoutes(ctx) {
|
|
224
|
+
ctx.effect(() => ctx.webServer.register({
|
|
225
|
+
kind: 'exact',
|
|
226
|
+
path: '/dsh-image-gen/live-events',
|
|
227
|
+
handler: async (req, res) => {
|
|
228
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
229
|
+
res.writeHead(405, { 'Content-Type': 'application/json' })
|
|
230
|
+
res.end(JSON.stringify({ error: 'GET only' }))
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
if (!isTrustedLocalRequest(req)) {
|
|
234
|
+
res.writeHead(403, { 'Content-Type': 'application/json' })
|
|
235
|
+
res.end(JSON.stringify({ error: 'forbidden' }))
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const u = new URL(req.url, 'http://localhost')
|
|
240
|
+
const callId = u.searchParams.get('callId') || ''
|
|
241
|
+
if (!callId) {
|
|
242
|
+
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
243
|
+
res.end(JSON.stringify({ error: 'callId query parameter required' }))
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
res.writeHead(200, {
|
|
248
|
+
'Content-Type': 'text/event-stream',
|
|
249
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
250
|
+
'Connection': 'keep-alive',
|
|
251
|
+
'Access-Control-Allow-Origin': '*',
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
res.write(': connected\n\n')
|
|
255
|
+
|
|
256
|
+
const unsubscribe = subscribeLiveProgress(callId, (data) => {
|
|
257
|
+
try {
|
|
258
|
+
res.write(`data: ${JSON.stringify(data)}\n\n`)
|
|
259
|
+
if (data.done) {
|
|
260
|
+
setTimeout(() => {
|
|
261
|
+
try {
|
|
262
|
+
res.end()
|
|
263
|
+
} catch (_err) {
|
|
264
|
+
// Response end failure ignored
|
|
265
|
+
}
|
|
266
|
+
}, 300)
|
|
267
|
+
}
|
|
268
|
+
} catch (_err) {
|
|
269
|
+
// SSE write failure ignored
|
|
270
|
+
}
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
req.on('close', () => {
|
|
274
|
+
unsubscribe()
|
|
275
|
+
})
|
|
276
|
+
},
|
|
277
|
+
}), 'dsh-image-gen: live events route')
|
|
278
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
normalizeMediaType,
|
|
3
3
|
buildEditForm,
|
|
4
|
+
normalizeMaskAlpha,
|
|
4
5
|
SIZE_PIXELS,
|
|
5
6
|
formatErrorMessage,
|
|
6
7
|
buildEndpointUrl,
|
|
@@ -32,6 +33,11 @@ export function createCustomGenerator(deps, job) {
|
|
|
32
33
|
if (key) headers.Authorization = `Bearer ${key}`
|
|
33
34
|
|
|
34
35
|
// Omit response_format; modern models return either base64 or URL.
|
|
36
|
+
let effectiveMask = mask
|
|
37
|
+
if (isEdit && mask && mask.bytes) {
|
|
38
|
+
const normBytes = await normalizeMaskAlpha(mask.bytes)
|
|
39
|
+
effectiveMask = { ...mask, bytes: normBytes, mediaType: 'image/png' }
|
|
40
|
+
}
|
|
35
41
|
const endpoint = buildEndpointUrl(base, isEdit ? 'images/edits' : 'images/generations')
|
|
36
42
|
const res = await fetchImpl(endpoint, {
|
|
37
43
|
method: 'POST',
|
|
@@ -40,7 +46,7 @@ export function createCustomGenerator(deps, job) {
|
|
|
40
46
|
? buildEditForm({
|
|
41
47
|
model: cfg.customModel,
|
|
42
48
|
source,
|
|
43
|
-
mask,
|
|
49
|
+
mask: effectiveMask,
|
|
44
50
|
prompt: promptArg,
|
|
45
51
|
size: cfg.customSize || (aspectPixels ? aspectPixels.join('x') : SIZE_PIXELS[size]) || size,
|
|
46
52
|
strength,
|
|
@@ -9,11 +9,11 @@ import {
|
|
|
9
9
|
* @param {fetchImpl: Function, resolveKey: Function, cfg: object} deps
|
|
10
10
|
* @param {prompt?: string, size?: string, format?: string, seed?: number, signal?: AbortSignal,
|
|
11
11
|
* negativePrompt?: string, guidanceScale?: number, source?: object, mask?: object, strength?: number,
|
|
12
|
-
* quality?: string, style?: string, aspectPixels?: number[], aspectRatio?: string} job
|
|
12
|
+
* quality?: string, style?: string, aspectPixels?: number[], aspectRatio?: string, onProgress?: Function, callId?: string} job
|
|
13
13
|
*/
|
|
14
14
|
export function createFalGenerator(deps, job) {
|
|
15
15
|
const { fetchImpl, resolveKey, cfg } = deps
|
|
16
|
-
const { prompt, size, format, seed, signal, negativePrompt, guidanceScale, source, mask, strength, quality, style, aspectPixels, aspectRatio } = job
|
|
16
|
+
const { prompt, size, format, seed, signal, negativePrompt, guidanceScale, source, mask, strength, quality, style, aspectPixels, aspectRatio, onProgress } = job
|
|
17
17
|
|
|
18
18
|
async function fal(seedArg = seed, promptArg = prompt) {
|
|
19
19
|
const key = await resolveKey(cfg.apiKeyEnv)
|
|
@@ -36,9 +36,13 @@ export function createFalGenerator(deps, job) {
|
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
if (typeof onProgress === 'function') {
|
|
40
|
+
onProgress({ stage: 'Submitting job...', progress: undefined })
|
|
41
|
+
}
|
|
42
|
+
|
|
39
43
|
const submit = await submitJob(fetchImpl, cfg.baseURL, targetModel, key, body, signal)
|
|
40
44
|
const statusUrl = submit.status_url || `${cfg.baseURL}/${cfg.model}/requests/${submit.request_id}/status`
|
|
41
|
-
const statusBody = await pollStatus(fetchImpl, statusUrl, key, signal, cfg.pollIntervalMs, cfg.timeoutMs)
|
|
45
|
+
const statusBody = await pollStatus(fetchImpl, statusUrl, key, signal, cfg.pollIntervalMs, cfg.timeoutMs, onProgress)
|
|
42
46
|
|
|
43
47
|
const resultRes = await fetchImpl(statusBody.response_url, {
|
|
44
48
|
headers: { Authorization: falAuthHeader(key) },
|
|
@@ -10,15 +10,24 @@ import {
|
|
|
10
10
|
buildDefaultComfyWorkflow,
|
|
11
11
|
} from '../../comfy-workflow-helpers.js'
|
|
12
12
|
|
|
13
|
+
function resolveWebSocket() {
|
|
14
|
+
if (typeof window !== 'undefined' && window.WebSocket) return window.WebSocket
|
|
15
|
+
try {
|
|
16
|
+
return (new Function('return typeof WebSocket !== "undefined" ? WebSocket : undefined'))()
|
|
17
|
+
} catch (_) {
|
|
18
|
+
return undefined
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
13
22
|
/**
|
|
14
23
|
* @param {fetchImpl: Function, resolveKey: Function, cfg: object} deps
|
|
15
24
|
* @param {prompt?: string, size?: string, format?: string, seed?: number, signal?: AbortSignal,
|
|
16
25
|
* negativePrompt?: string, guidanceScale?: number, source?: object, mask?: object, strength?: number,
|
|
17
|
-
* quality?: string, style?: string, aspectPixels?: number[], aspectRatio?: string} job
|
|
26
|
+
* quality?: string, style?: string, aspectPixels?: number[], aspectRatio?: string, onProgress?: Function, callId?: string} job
|
|
18
27
|
*/
|
|
19
28
|
export function createLocalGenerator(deps, job) {
|
|
20
29
|
const { fetchImpl, resolveKey, cfg } = deps
|
|
21
|
-
const { prompt, size, format, seed, signal, negativePrompt, guidanceScale, source, mask, strength, quality, style, aspectPixels, aspectRatio } = job
|
|
30
|
+
const { prompt, size, format, seed, signal, negativePrompt, guidanceScale, source, mask, strength, quality, style, aspectPixels, aspectRatio, onProgress } = job
|
|
22
31
|
|
|
23
32
|
async function local(seedArg = seed, promptArg = prompt) {
|
|
24
33
|
const base = String(cfg.localBaseURL || '').replace(/\/+$/, '')
|
|
@@ -34,6 +43,9 @@ export function createLocalGenerator(deps, job) {
|
|
|
34
43
|
}
|
|
35
44
|
|
|
36
45
|
if (kind === 'a1111') {
|
|
46
|
+
if (typeof onProgress === 'function') {
|
|
47
|
+
onProgress({ stage: 'Generating...', progress: undefined })
|
|
48
|
+
}
|
|
37
49
|
const isImg2Img = Boolean(source && source.bytes)
|
|
38
50
|
const endpoint = `${base}${isImg2Img ? '/sdapi/v1/img2img' : '/sdapi/v1/txt2img'}`
|
|
39
51
|
const body = {
|
|
@@ -114,61 +126,138 @@ export function createLocalGenerator(deps, job) {
|
|
|
114
126
|
}
|
|
115
127
|
}
|
|
116
128
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
129
|
+
let ws
|
|
130
|
+
let wsClosed = false
|
|
131
|
+
const closeWs = () => {
|
|
132
|
+
if (!wsClosed && ws) {
|
|
133
|
+
wsClosed = true
|
|
134
|
+
try {
|
|
135
|
+
ws.close()
|
|
136
|
+
} catch (_err) {
|
|
137
|
+
// Socket close failure ignored
|
|
138
|
+
}
|
|
139
|
+
}
|
|
125
140
|
}
|
|
126
|
-
const submitData = await submit.json().catch(() => ({}))
|
|
127
|
-
const pid = submitData.prompt_id
|
|
128
|
-
if (!pid) throw new Error('Local ComfyUI did not return a prompt_id')
|
|
129
141
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const cDelay = calculateBackoff(attempt - 1, cfg.pollIntervalMs || 1000, 5000)
|
|
137
|
-
await new Promise((resolve) => {
|
|
138
|
-
const timer = setTimeout(resolve, cDelay)
|
|
142
|
+
try {
|
|
143
|
+
const WebSocketClass = resolveWebSocket()
|
|
144
|
+
if (WebSocketClass) {
|
|
145
|
+
const wsUrl = `${base.replace(/^http/, 'ws')}/ws?clientId=${encodeURIComponent(promptId)}`
|
|
146
|
+
try {
|
|
147
|
+
ws = new WebSocketClass(wsUrl)
|
|
139
148
|
if (signal) {
|
|
140
|
-
signal.addEventListener('abort',
|
|
149
|
+
signal.addEventListener('abort', closeWs, { once: true })
|
|
141
150
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
151
|
+
ws.onmessage = (event) => {
|
|
152
|
+
if (wsClosed) return
|
|
153
|
+
try {
|
|
154
|
+
if (typeof event.data === 'string') {
|
|
155
|
+
const msg = JSON.parse(event.data)
|
|
156
|
+
if (msg.type === 'progress') {
|
|
157
|
+
const { value, max } = msg.data || {}
|
|
158
|
+
const progress = (max > 0) ? Math.min(100, Math.max(0, Math.round((value / max) * 100))) : undefined
|
|
159
|
+
if (typeof onProgress === 'function') {
|
|
160
|
+
onProgress({ stage: 'Rendering...', step: `${value}/${max}`, progress })
|
|
161
|
+
}
|
|
162
|
+
} else if (msg.type === 'executing') {
|
|
163
|
+
const node = msg.data?.node
|
|
164
|
+
if (typeof onProgress === 'function') {
|
|
165
|
+
onProgress({ stage: node ? `Executing node ${node}` : 'Finishing...' })
|
|
166
|
+
}
|
|
167
|
+
} else if (msg.type === 'status') {
|
|
168
|
+
const q = msg.data?.status?.exec_info?.queue_remaining
|
|
169
|
+
if (q !== undefined && q > 0 && typeof onProgress === 'function') {
|
|
170
|
+
onProgress({ stage: `In queue (${q} remaining)` })
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
} else if (event.data) {
|
|
174
|
+
const rawBuf = Buffer.isBuffer(event.data) ? event.data : Buffer.from(event.data)
|
|
175
|
+
let imgBuf = rawBuf
|
|
176
|
+
if (rawBuf.length > 8 && (rawBuf[0] === 0 || rawBuf[0] === 1 || rawBuf[0] === 2)) {
|
|
177
|
+
imgBuf = rawBuf.subarray(8)
|
|
178
|
+
}
|
|
179
|
+
if (imgBuf.length > 4) {
|
|
180
|
+
const isPng = imgBuf[0] === 0x89 && imgBuf[1] === 0x50
|
|
181
|
+
const mime = isPng ? 'image/png' : 'image/jpeg'
|
|
182
|
+
const draftUrl = `data:${mime};base64,${imgBuf.toString('base64')}`
|
|
183
|
+
if (typeof onProgress === 'function') {
|
|
184
|
+
onProgress({ draftUrl })
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
} catch (_err) {
|
|
189
|
+
// Frame parse failure ignored
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
ws.onerror = () => {}
|
|
193
|
+
ws.onclose = () => {}
|
|
194
|
+
} catch (_err) {
|
|
195
|
+
// Websocket handshake failure ignored
|
|
153
196
|
}
|
|
154
197
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
198
|
+
|
|
199
|
+
if (typeof onProgress === 'function') {
|
|
200
|
+
onProgress({ stage: 'Submitting prompt...', progress: undefined })
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const submit = await fetchImpl(`${base}/prompt`, {
|
|
204
|
+
method: 'POST',
|
|
205
|
+
headers: { 'Content-Type': 'application/json' },
|
|
206
|
+
body: JSON.stringify({ prompt: workflowGraph, client_id: promptId }),
|
|
207
|
+
signal,
|
|
208
|
+
})
|
|
209
|
+
if (!submit.ok) {
|
|
210
|
+
throw new Error(`Local ComfyUI submit failed (HTTP ${submit.status}): ${String(await submit.text().catch(() => '')).slice(0, 300)}`)
|
|
211
|
+
}
|
|
212
|
+
const submitData = await submit.json().catch(() => ({}))
|
|
213
|
+
const pid = submitData.prompt_id
|
|
214
|
+
if (!pid) throw new Error('Local ComfyUI did not return a prompt_id')
|
|
215
|
+
|
|
216
|
+
const deadline = Date.now() + cfg.timeoutMs
|
|
217
|
+
let attempt = 0
|
|
218
|
+
for (;;) {
|
|
219
|
+
if (signal?.aborted) throw new Error('Local ComfyUI generation cancelled')
|
|
220
|
+
if (Date.now() > deadline) throw new Error(`Local ComfyUI timed out after ${cfg.timeoutMs} ms`)
|
|
221
|
+
if (attempt > 0) {
|
|
222
|
+
const cDelay = calculateBackoff(attempt - 1, cfg.pollIntervalMs || 1000, 5000)
|
|
223
|
+
await new Promise((resolve) => {
|
|
224
|
+
const timer = setTimeout(resolve, cDelay)
|
|
225
|
+
if (signal) {
|
|
226
|
+
signal.addEventListener('abort', () => { clearTimeout(timer); resolve() }, { once: true })
|
|
227
|
+
}
|
|
228
|
+
})
|
|
229
|
+
}
|
|
230
|
+
attempt++
|
|
231
|
+
const hist = await fetchImpl(`${base}/history/${pid}`, { signal })
|
|
232
|
+
if (!hist.ok) continue
|
|
233
|
+
const histData = await hist.json().catch(() => ({}))
|
|
234
|
+
const entry = histData[pid]
|
|
235
|
+
if (entry) {
|
|
236
|
+
const comfyErr = extractComfyNodeErrors(entry)
|
|
237
|
+
if (comfyErr) {
|
|
238
|
+
throw new Error(`Local ComfyUI execution failed: ${comfyErr}`)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (entry && entry.outputs) {
|
|
242
|
+
const outputs = entry.outputs
|
|
243
|
+
const img = Object.values(outputs).flatMap((o) => o.images || []).find((i) => i && i.filename)
|
|
244
|
+
if (img) {
|
|
245
|
+
const dl = await fetchImpl(`${base}/view?filename=${encodeURIComponent(img.filename)}&subfolder=${encodeURIComponent(img.subfolder || '')}&type=${encodeURIComponent(img.type || 'output')}`, { signal })
|
|
246
|
+
if (!dl.ok) throw new Error(`Local ComfyUI download failed (HTTP ${dl.status})`)
|
|
247
|
+
return {
|
|
248
|
+
bytes: Buffer.from(await dl.arrayBuffer()),
|
|
249
|
+
mediaType: normalizeMediaType('image/png', format),
|
|
250
|
+
width,
|
|
251
|
+
height,
|
|
252
|
+
seed: seedArg ?? 0,
|
|
253
|
+
sourceUrl: '',
|
|
254
|
+
}
|
|
168
255
|
}
|
|
169
256
|
}
|
|
170
257
|
}
|
|
258
|
+
} finally {
|
|
259
|
+
closeWs()
|
|
171
260
|
}
|
|
172
261
|
}
|
|
173
262
|
return local
|
|
174
|
-
}
|
|
263
|
+
}
|
|
@@ -115,7 +115,7 @@ export async function submitJob(fetchImpl, baseURL, model, key, body, signal) {
|
|
|
115
115
|
return data
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
export async function pollStatus(fetchImpl, statusUrl, key, signal, pollIntervalMs = 500, timeoutMs = 180000) {
|
|
118
|
+
export async function pollStatus(fetchImpl, statusUrl, key, signal, pollIntervalMs = 500, timeoutMs = 180000, onProgress) {
|
|
119
119
|
const deadline = Date.now() + timeoutMs
|
|
120
120
|
let attempt = 0
|
|
121
121
|
for (;;) {
|
|
@@ -138,6 +138,21 @@ export async function pollStatus(fetchImpl, statusUrl, key, signal, pollInterval
|
|
|
138
138
|
const res = await fetchImpl(statusUrl, { headers: { Authorization: falAuthHeader(key) }, signal })
|
|
139
139
|
const data = await res.json().catch(() => ({}))
|
|
140
140
|
const status = data.status
|
|
141
|
+
if (typeof onProgress === 'function') {
|
|
142
|
+
if (status === 'IN_QUEUE') {
|
|
143
|
+
const pos = data.queue_position
|
|
144
|
+
onProgress({
|
|
145
|
+
stage: pos !== undefined ? `In queue (#${pos})` : 'In queue',
|
|
146
|
+
progress: undefined, // honest status: do NOT simulate fake percentage
|
|
147
|
+
})
|
|
148
|
+
} else if (status === 'IN_PROGRESS') {
|
|
149
|
+
const rawProg = typeof data.progress === 'number' ? Math.round(data.progress * 100) : undefined
|
|
150
|
+
onProgress({
|
|
151
|
+
stage: 'Generating...',
|
|
152
|
+
progress: rawProg,
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
}
|
|
141
156
|
if (status === 'COMPLETED') return data
|
|
142
157
|
if (status === 'ERROR' || data.error || data.detail) {
|
|
143
158
|
throw new Error(`FAL generation failed: ${JSON.stringify(data).slice(0, 600)}`)
|
|
@@ -148,6 +163,53 @@ export async function pollStatus(fetchImpl, statusUrl, key, signal, pollInterval
|
|
|
148
163
|
}
|
|
149
164
|
}
|
|
150
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Normalizes mask bytes for OpenAI/Custom inpainting where transparent alpha (0) indicates edit region.
|
|
168
|
+
* If the incoming mask has an alpha channel with transparent areas, it is preserved.
|
|
169
|
+
* If the incoming mask is opaque black & white (e.g. from Canvas), white pixels are converted to transparent (alpha 0)
|
|
170
|
+
* and black pixels to opaque (alpha 255).
|
|
171
|
+
*
|
|
172
|
+
* @param {Buffer|Uint8Array} maskBytes
|
|
173
|
+
* @returns {Promise<Buffer>}
|
|
174
|
+
*/
|
|
175
|
+
export async function normalizeMaskAlpha(maskBytes) {
|
|
176
|
+
if (!maskBytes || maskBytes.length === 0) return maskBytes
|
|
177
|
+
try {
|
|
178
|
+
const img = sharp(maskBytes)
|
|
179
|
+
const { data, info } = await img.raw().ensureAlpha().toBuffer({ resolveWithObject: true })
|
|
180
|
+
const { width, height, channels } = info
|
|
181
|
+
|
|
182
|
+
let hasTransparency = false
|
|
183
|
+
for (let i = 3; i < data.length; i += channels) {
|
|
184
|
+
if (data[i] < 250) {
|
|
185
|
+
hasTransparency = true
|
|
186
|
+
break
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (hasTransparency) {
|
|
191
|
+
return await sharp(data, { raw: { width, height, channels } }).png().toBuffer()
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const out = Buffer.from(data)
|
|
195
|
+
for (let i = 0; i < out.length; i += channels) {
|
|
196
|
+
const r = out[i]
|
|
197
|
+
const g = out[i + 1]
|
|
198
|
+
const b = out[i + 2]
|
|
199
|
+
const lum = 0.299 * r + 0.587 * g + 0.114 * b
|
|
200
|
+
if (lum > 128) {
|
|
201
|
+
out[i + 3] = 0
|
|
202
|
+
} else {
|
|
203
|
+
out[i + 3] = 255
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return await sharp(out, { raw: { width, height, channels } }).png().toBuffer()
|
|
208
|
+
} catch (_) {
|
|
209
|
+
return maskBytes
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
151
213
|
export function buildEditForm({ source, mask, prompt, size, strength, model }) {
|
|
152
214
|
const form = new FormData()
|
|
153
215
|
if (model) form.append('model', model)
|
package/lib/register-tools.js
CHANGED
|
@@ -19,6 +19,12 @@ import { registerThemePairTools } from './tools/theme-pair.js'
|
|
|
19
19
|
import { registerCharacterSheetTools } from './tools/character-sheet.js'
|
|
20
20
|
import { registerVisionOcrTools } from './tools/vision-ocr.js'
|
|
21
21
|
import { registerDiagramTools } from './tools/diagram.js'
|
|
22
|
+
import {
|
|
23
|
+
createLiveProgressSession,
|
|
24
|
+
publishLiveProgress,
|
|
25
|
+
completeLiveProgress,
|
|
26
|
+
abortLiveProgress,
|
|
27
|
+
} from './live-progress.js'
|
|
22
28
|
|
|
23
29
|
/**
|
|
24
30
|
* Registers every image-gen tool on the host tools service according to toolsetProfile (#337, #384).
|
|
@@ -43,7 +49,19 @@ export function registerAllTools(ctx, deps) {
|
|
|
43
49
|
if (currentCfg && currentCfg.enabled === false) {
|
|
44
50
|
throw new Error('Image generation is disabled in settings (dsh-image-gen.enabled is false); enable it in Settings → Image generation.')
|
|
45
51
|
}
|
|
46
|
-
|
|
52
|
+
const callId = exec?.callId || exec?.rootCallId
|
|
53
|
+
if (callId) {
|
|
54
|
+
createLiveProgressSession(callId, { signal: exec.signal })
|
|
55
|
+
exec.onProgress = (update) => publishLiveProgress(callId, update)
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const res = await originalExecute(args, exec)
|
|
59
|
+
if (callId) completeLiveProgress(callId)
|
|
60
|
+
return res
|
|
61
|
+
} catch (err) {
|
|
62
|
+
if (callId) abortLiveProgress(callId, err.message)
|
|
63
|
+
throw err
|
|
64
|
+
}
|
|
47
65
|
},
|
|
48
66
|
}
|
|
49
67
|
const res = originalToolsRegister(wrappedDef)
|
package/lib/tools/editing.js
CHANGED
|
@@ -98,6 +98,21 @@ export function registerEditingTools(ctx, deps, options = {}) {
|
|
|
98
98
|
const source = await resolveConversationImage(ctx, exec, args.image)
|
|
99
99
|
const maskRef = args.mask_image || args.mask
|
|
100
100
|
const mask = maskRef ? await resolveSource(ctx, exec, maskRef) : undefined
|
|
101
|
+
if (mask && mask.bytes && source && source.bytes) {
|
|
102
|
+
try {
|
|
103
|
+
const sharpMod = await import('sharp')
|
|
104
|
+
const sharp = sharpMod.default || sharpMod
|
|
105
|
+
const srcMeta = await sharp(source.bytes).metadata()
|
|
106
|
+
const maskMeta = await sharp(mask.bytes).metadata()
|
|
107
|
+
if (srcMeta.width && srcMeta.height && (maskMeta.width !== srcMeta.width || maskMeta.height !== srcMeta.height)) {
|
|
108
|
+
const resized = await sharp(mask.bytes).resize(srcMeta.width, srcMeta.height, { fit: 'fill' }).png().toBuffer()
|
|
109
|
+
mask.bytes = resized
|
|
110
|
+
mask.mediaType = 'image/png'
|
|
111
|
+
}
|
|
112
|
+
} catch (_err) {
|
|
113
|
+
// Mask dimension normalization failure ignored
|
|
114
|
+
}
|
|
115
|
+
}
|
|
101
116
|
const vision = await analyzeImageWithVision(ctx, exec, source)
|
|
102
117
|
|
|
103
118
|
let effectivePrompt = args.prompt
|
|
@@ -127,6 +142,8 @@ export function registerEditingTools(ctx, deps, options = {}) {
|
|
|
127
142
|
strength,
|
|
128
143
|
provider,
|
|
129
144
|
signal: exec?.signal,
|
|
145
|
+
onProgress: exec?.onProgress,
|
|
146
|
+
callId: exec?.callId,
|
|
130
147
|
}
|
|
131
148
|
|
|
132
149
|
const gen = await editImageDirect(deps, job)
|
package/lib/tools/generation.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { buildPresentationMeta } from '../attachment-helper.js'
|
|
1
2
|
import { registerGenerationPackTool } from './generation-pack.js'
|
|
2
3
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
3
4
|
import path from 'node:path'
|
|
@@ -282,12 +283,16 @@ export function registerGenerationTools(ctx, deps, options = {}) {
|
|
|
282
283
|
const summary = lines.join('\n')
|
|
283
284
|
if (live().deliverAs !== 'image') {
|
|
284
285
|
const urls = list.map((img) => img.url).filter(Boolean)
|
|
285
|
-
|
|
286
|
+
const mdLinks = list.map((img) => ``).join('\n')
|
|
287
|
+
return [{ type: 'text', text: urls.length ? `${summary}\n${mdLinks}` : summary }]
|
|
286
288
|
}
|
|
287
289
|
const blocks = [{ type: 'text', text: summary }]
|
|
288
290
|
for (const img of list) if (img.attachment) blocks.push({ type: 'image', attachment: img.attachment })
|
|
289
291
|
return blocks
|
|
290
292
|
},
|
|
293
|
+
presentationMeta(args, value) {
|
|
294
|
+
return buildPresentationMeta(value)
|
|
295
|
+
},
|
|
291
296
|
},
|
|
292
297
|
isConcurrencySafe: () => false,
|
|
293
298
|
timeoutMs: (config?.timeoutMs || 180000) + 30000,
|
|
@@ -334,7 +339,7 @@ export function registerGenerationTools(ctx, deps, options = {}) {
|
|
|
334
339
|
assertBudgetAvailable(estimatedCost, cfg.dailyBudgetUsd)
|
|
335
340
|
const providers = makeProviders(
|
|
336
341
|
{ fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg, subscriptionImages },
|
|
337
|
-
{ prompt: effectivePrompt, size, format, seed: args.seed, signal: exec.signal, negativePrompt: effectiveNegative, guidanceScale: effectiveGuidance, source, mask, strength: args.strength, quality: args.quality, style: args.style, aspectPixels, aspectRatio: args.aspect_ratio, faceReference: resolvedFaceRef },
|
|
342
|
+
{ prompt: effectivePrompt, size, format, seed: args.seed, signal: exec.signal, negativePrompt: effectiveNegative, guidanceScale: effectiveGuidance, source, mask, strength: args.strength, quality: args.quality, style: args.style, aspectPixels, aspectRatio: args.aspect_ratio, faceReference: resolvedFaceRef, onProgress: exec?.onProgress, callId: exec?.callId },
|
|
338
343
|
)
|
|
339
344
|
// Subscription providers return {ok:false, reason} instead of throwing:
|
|
340
345
|
// rejection reaches the model as text. Without this guard,
|
|
@@ -107,7 +107,15 @@ export function registerStyleMatrixTools(ctx, deps) {
|
|
|
107
107
|
blindMode: value.blind_mode,
|
|
108
108
|
cells: value.cells || [],
|
|
109
109
|
})
|
|
110
|
-
|
|
110
|
+
const metaTag = `<dsh-image-gen-matrix>${JSON.stringify({ cells: value.cells, base_prompt: value.base_prompt, blind_mode: value.blind_mode })}</dsh-image-gen-matrix>`
|
|
111
|
+
return [{ type: 'text', text: `${summary}\n\n${metaTag}` }]
|
|
112
|
+
},
|
|
113
|
+
presentationMeta(args, value) {
|
|
114
|
+
return {
|
|
115
|
+
base_prompt: value.base_prompt,
|
|
116
|
+
blind_mode: value.blind_mode,
|
|
117
|
+
cells: value.cells || [],
|
|
118
|
+
}
|
|
111
119
|
},
|
|
112
120
|
},
|
|
113
121
|
isConcurrencySafe: () => false,
|
package/lib/tools/theme-pair.js
CHANGED
|
@@ -99,7 +99,25 @@ export function registerThemePairTools(ctx, deps) {
|
|
|
99
99
|
},
|
|
100
100
|
},
|
|
101
101
|
render(_args, value) {
|
|
102
|
-
|
|
102
|
+
const blocks = renderToolOutput(value)
|
|
103
|
+
const metaTag = `<dsh-image-gen-theme-pair>${JSON.stringify({
|
|
104
|
+
light: value.light,
|
|
105
|
+
dark: value.dark,
|
|
106
|
+
html_snippet: value.html_snippet,
|
|
107
|
+
css_snippet: value.css_snippet,
|
|
108
|
+
})}</dsh-image-gen-theme-pair>`
|
|
109
|
+
if (blocks[0] && blocks[0].type === 'text') {
|
|
110
|
+
blocks[0].text += `\n\n${metaTag}`
|
|
111
|
+
}
|
|
112
|
+
return blocks
|
|
113
|
+
},
|
|
114
|
+
presentationMeta(_args, value) {
|
|
115
|
+
return {
|
|
116
|
+
light: value.light,
|
|
117
|
+
dark: value.dark,
|
|
118
|
+
html_snippet: value.html_snippet,
|
|
119
|
+
css_snippet: value.css_snippet,
|
|
120
|
+
}
|
|
103
121
|
},
|
|
104
122
|
},
|
|
105
123
|
isConcurrencySafe: () => false,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-image-gen",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.27",
|
|
4
4
|
"description": "Image generation for DeepSeek Harness: a generate_image tool with pluggable providers \u2014 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",
|