@goodandready/dsh-image-gen 0.11.41 → 0.11.42
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/index.js +67 -0
- package/lib/lineage.js +188 -0
- package/lib/register-tools.js +8 -0
- package/lib/tools/lineage.js +83 -0
- package/lib/tools/variant-selection.js +132 -0
- package/lib/variant-selection.js +170 -0
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -54,6 +54,8 @@ import { getCapabilityRegistry, filterModelsByCapabilities, recommendModelForTas
|
|
|
54
54
|
import { getAllProjectProfiles, saveCustomProfile, deleteCustomProfile } from './project-profiles.js'
|
|
55
55
|
import { getStagingSession, reviewStagedEdit, createStagingSession } from './inpaint-staging.js'
|
|
56
56
|
import { enqueueBatch, getBatchQueue, getBatchProgress, cancelBatch, retryFailedBatchItems, reconcileQueueOnStartup } from './batch-queue.js'
|
|
57
|
+
import { createVariantSession, selectVariantWinner, getVariantSession } from './variant-selection.js'
|
|
58
|
+
import { getImageLineage, getReplayPayload } from './lineage.js'
|
|
57
59
|
import { resetLoopGuard, getLoopGuardState } from './loop-guard.js'
|
|
58
60
|
import { Config, plainConfig, volatileConfig, publicConfig, ensureConfig } from './config-schema.js'
|
|
59
61
|
import { resolveSource } from './resolve-source.js'
|
|
@@ -583,6 +585,71 @@ export function apply(ctx, rawConfig) {
|
|
|
583
585
|
},
|
|
584
586
|
}), 'dsh-image-gen: batch queue route')
|
|
585
587
|
|
|
588
|
+
// Variant comparison route (#438)
|
|
589
|
+
ctx.effect(() => ctx.webServer.register({
|
|
590
|
+
kind: 'exact',
|
|
591
|
+
path: '/dsh-image-gen/variants',
|
|
592
|
+
handler: async (req, res) => {
|
|
593
|
+
const url = new URL(req.url, 'http://localhost')
|
|
594
|
+
if (req.method === 'GET') {
|
|
595
|
+
const selectionId = url.searchParams.get('selectionId')
|
|
596
|
+
const session = selectionId ? getVariantSession(selectionId) : null
|
|
597
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
598
|
+
res.end(JSON.stringify({ ok: Boolean(session), session }))
|
|
599
|
+
return
|
|
600
|
+
}
|
|
601
|
+
if (req.method === 'POST') {
|
|
602
|
+
try {
|
|
603
|
+
const body = await readBoundedRequestBody(req, 1024 * 1024)
|
|
604
|
+
const data = JSON.parse(body.toString('utf8'))
|
|
605
|
+
let result
|
|
606
|
+
if (data.action === 'select' || data.winnerIndex !== undefined || data.winnerPath) {
|
|
607
|
+
result = selectVariantWinner(data.selectionId, data)
|
|
608
|
+
} else {
|
|
609
|
+
result = { ok: true, session: createVariantSession(data) }
|
|
610
|
+
}
|
|
611
|
+
res.writeHead(result.ok !== false ? 200 : 400, { 'Content-Type': 'application/json' })
|
|
612
|
+
res.end(JSON.stringify(result))
|
|
613
|
+
} catch (err) {
|
|
614
|
+
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
615
|
+
res.end(JSON.stringify({ ok: false, error: err.message }))
|
|
616
|
+
}
|
|
617
|
+
return
|
|
618
|
+
}
|
|
619
|
+
res.writeHead(405, { 'Content-Type': 'application/json' })
|
|
620
|
+
res.end(JSON.stringify({ error: 'GET or POST only' }))
|
|
621
|
+
},
|
|
622
|
+
}), 'dsh-image-gen: variants route')
|
|
623
|
+
|
|
624
|
+
// Image lineage route (#439)
|
|
625
|
+
ctx.effect(() => ctx.webServer.register({
|
|
626
|
+
kind: 'exact',
|
|
627
|
+
path: '/dsh-image-gen/lineage',
|
|
628
|
+
handler: async (req, res) => {
|
|
629
|
+
if (req.method !== 'GET') {
|
|
630
|
+
res.writeHead(405, { 'Content-Type': 'application/json' })
|
|
631
|
+
res.end(JSON.stringify({ error: 'GET only' }))
|
|
632
|
+
return
|
|
633
|
+
}
|
|
634
|
+
const url = new URL(req.url, 'http://localhost')
|
|
635
|
+
const revisionId = url.searchParams.get('revisionId')
|
|
636
|
+
if (!revisionId) {
|
|
637
|
+
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
638
|
+
res.end(JSON.stringify({ ok: false, error: 'revisionId parameter required' }))
|
|
639
|
+
return
|
|
640
|
+
}
|
|
641
|
+
try {
|
|
642
|
+
const entries = await readHistory()
|
|
643
|
+
const lineage = getImageLineage(revisionId, entries)
|
|
644
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
645
|
+
res.end(JSON.stringify({ ok: Boolean(lineage), lineage }))
|
|
646
|
+
} catch (err) {
|
|
647
|
+
res.writeHead(500, { 'Content-Type': 'application/json' })
|
|
648
|
+
res.end(JSON.stringify({ ok: false, error: err.message }))
|
|
649
|
+
}
|
|
650
|
+
},
|
|
651
|
+
}), 'dsh-image-gen: lineage route')
|
|
652
|
+
|
|
586
653
|
registerLiveProgressRoutes(ctx)
|
|
587
654
|
|
|
588
655
|
// Reset loop guard when user sends a new message (#370)
|
package/lib/lineage.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image Lineage & Version Branching (#439)
|
|
3
|
+
* Traverses parent-child relationships across revisions, provides DAG queries,
|
|
4
|
+
* branching support, and sanitized parameter replay payloads.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Sanitizes parameters to remove credentials and internal server paths.
|
|
9
|
+
*/
|
|
10
|
+
export function sanitizeLineageParams(params = {}) {
|
|
11
|
+
if (!params || typeof params !== 'object') return {}
|
|
12
|
+
const sanitized = {}
|
|
13
|
+
for (const [key, val] of Object.entries(params)) {
|
|
14
|
+
const lowerKey = key.toLowerCase()
|
|
15
|
+
// Exclude API keys and secrets
|
|
16
|
+
if (
|
|
17
|
+
lowerKey.includes('key') ||
|
|
18
|
+
lowerKey.includes('secret') ||
|
|
19
|
+
lowerKey.includes('token') ||
|
|
20
|
+
lowerKey.includes('auth') ||
|
|
21
|
+
lowerKey.includes('password')
|
|
22
|
+
) {
|
|
23
|
+
continue
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (typeof val === 'string') {
|
|
27
|
+
// Clean host absolute paths (e.g. /mnt/external/... -> filename)
|
|
28
|
+
if (val.startsWith('/mnt/') || val.startsWith('/home/') || val.includes(':\\')) {
|
|
29
|
+
sanitized[key] = val.split(/[/\\]/).pop()
|
|
30
|
+
} else {
|
|
31
|
+
sanitized[key] = val
|
|
32
|
+
}
|
|
33
|
+
} else if (typeof val === 'object' && val !== null) {
|
|
34
|
+
sanitized[key] = sanitizeLineageParams(val)
|
|
35
|
+
} else {
|
|
36
|
+
sanitized[key] = val
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return sanitized
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Normalizes history entry into standard lineage node.
|
|
44
|
+
*/
|
|
45
|
+
export function normalizeLineageNode(entry, idx = 0) {
|
|
46
|
+
if (!entry) return null
|
|
47
|
+
const revisionId = entry.revisionId || entry.id || entry.attachmentId || `rev_${entry.timestamp || idx}`
|
|
48
|
+
const parentId = entry.parentId || entry.parentImageId || null
|
|
49
|
+
const branchId = entry.branchId || 'main'
|
|
50
|
+
const operation = entry.operation || (parentId ? 'edit' : 'generate')
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
revisionId: String(revisionId),
|
|
54
|
+
parentId: parentId ? String(parentId) : null,
|
|
55
|
+
branchId: String(branchId),
|
|
56
|
+
operation,
|
|
57
|
+
timestamp: entry.timestamp || new Date().toISOString(),
|
|
58
|
+
prompt: entry.prompt || '',
|
|
59
|
+
seed: entry.seed !== undefined ? entry.seed : null,
|
|
60
|
+
provider: entry.provider || 'unknown',
|
|
61
|
+
model: entry.model || 'unknown',
|
|
62
|
+
path: entry.path || entry.url || '',
|
|
63
|
+
appliedProfile: entry.appliedProfile || null,
|
|
64
|
+
params: sanitizeLineageParams(entry.params || {
|
|
65
|
+
size: entry.size || entry.image_size,
|
|
66
|
+
aspectRatio: entry.aspectRatio || entry.aspect_ratio,
|
|
67
|
+
format: entry.format,
|
|
68
|
+
}),
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Builds Lineage DAG map from array of history entries.
|
|
74
|
+
*/
|
|
75
|
+
export function buildLineageDAG(historyEntries = []) {
|
|
76
|
+
const nodes = new Map()
|
|
77
|
+
const childrenMap = new Map()
|
|
78
|
+
|
|
79
|
+
for (let i = 0; i < historyEntries.length; i++) {
|
|
80
|
+
const node = normalizeLineageNode(historyEntries[i], i)
|
|
81
|
+
if (node) {
|
|
82
|
+
nodes.set(node.revisionId, node)
|
|
83
|
+
// Also index by path or attachmentId for backward compatibility
|
|
84
|
+
if (historyEntries[i].path) {
|
|
85
|
+
nodes.set(historyEntries[i].path, node)
|
|
86
|
+
}
|
|
87
|
+
if (historyEntries[i].attachmentId) {
|
|
88
|
+
nodes.set(historyEntries[i].attachmentId, node)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Build children relationships
|
|
94
|
+
for (const node of new Set(nodes.values())) {
|
|
95
|
+
if (node.parentId) {
|
|
96
|
+
const parent = nodes.get(node.parentId)
|
|
97
|
+
const parentKey = parent ? parent.revisionId : node.parentId
|
|
98
|
+
if (!childrenMap.has(parentKey)) {
|
|
99
|
+
childrenMap.set(parentKey, [])
|
|
100
|
+
}
|
|
101
|
+
childrenMap.get(parentKey).push(node)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { nodes, childrenMap }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Retrieves the full ancestry chain and child branches for a revision.
|
|
110
|
+
*/
|
|
111
|
+
export function getImageLineage(targetId, historyEntries = []) {
|
|
112
|
+
if (!targetId) return null
|
|
113
|
+
const { nodes, childrenMap } = buildLineageDAG(historyEntries)
|
|
114
|
+
|
|
115
|
+
const targetNode = nodes.get(String(targetId))
|
|
116
|
+
if (!targetNode) return null
|
|
117
|
+
|
|
118
|
+
// 1. Ancestry chain (upward traversal from target to root)
|
|
119
|
+
const ancestry = []
|
|
120
|
+
let curr = targetNode
|
|
121
|
+
const visited = new Set()
|
|
122
|
+
|
|
123
|
+
while (curr && curr.parentId && !visited.has(curr.revisionId)) {
|
|
124
|
+
visited.add(curr.revisionId)
|
|
125
|
+
const parent = nodes.get(curr.parentId)
|
|
126
|
+
if (parent) {
|
|
127
|
+
ancestry.unshift(parent)
|
|
128
|
+
curr = parent
|
|
129
|
+
} else {
|
|
130
|
+
// Parent exists outside current history limit
|
|
131
|
+
ancestry.unshift({
|
|
132
|
+
revisionId: curr.parentId,
|
|
133
|
+
parentId: null,
|
|
134
|
+
branchId: 'unknown',
|
|
135
|
+
operation: 'unknown',
|
|
136
|
+
prompt: '[Historical ancestor not in current window]',
|
|
137
|
+
missing: true,
|
|
138
|
+
})
|
|
139
|
+
break
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 2. Direct children and branches
|
|
144
|
+
const children = childrenMap.get(targetNode.revisionId) || []
|
|
145
|
+
const branches = [...new Set(children.map((c) => c.branchId))]
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
target: targetNode,
|
|
149
|
+
ancestry,
|
|
150
|
+
children,
|
|
151
|
+
branches,
|
|
152
|
+
depth: ancestry.length,
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Produces a sanitized replay payload for re-running generation from a specific revision.
|
|
158
|
+
*/
|
|
159
|
+
export function getReplayPayload(revisionId, historyEntries = []) {
|
|
160
|
+
const lineage = getImageLineage(revisionId, historyEntries)
|
|
161
|
+
if (!lineage) {
|
|
162
|
+
return { ok: false, error: `Revision "${revisionId}" not found in history.` }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const node = lineage.target
|
|
166
|
+
const toolName = node.operation === 'edit' ? 'edit_image' : 'generate_image'
|
|
167
|
+
|
|
168
|
+
const replayParams = {
|
|
169
|
+
prompt: node.prompt,
|
|
170
|
+
...(node.seed !== null ? { seed: node.seed } : {}),
|
|
171
|
+
...node.params,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (node.parentId && node.operation === 'edit') {
|
|
175
|
+
replayParams.image = node.parentId
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
ok: true,
|
|
180
|
+
revisionId: node.revisionId,
|
|
181
|
+
tool: toolName,
|
|
182
|
+
provider: node.provider,
|
|
183
|
+
model: node.model,
|
|
184
|
+
params: replayParams,
|
|
185
|
+
reproducibilityNotes:
|
|
186
|
+
'Cloud models may exhibit minor pixel variations across runs with identical seed; ComfyUI/local models provide deterministic outputs when models and workflow match.',
|
|
187
|
+
}
|
|
188
|
+
}
|
package/lib/register-tools.js
CHANGED
|
@@ -24,6 +24,8 @@ import { registerCapabilityTools } from './tools/capabilities.js'
|
|
|
24
24
|
import { registerProjectProfileTools } from './tools/project-profiles.js'
|
|
25
25
|
import { registerInpaintStagingTools } from './tools/inpaint-staging.js'
|
|
26
26
|
import { registerBatchQueueTools } from './tools/batch-queue.js'
|
|
27
|
+
import { registerVariantTools } from './tools/variant-selection.js'
|
|
28
|
+
import { registerLineageTools } from './tools/lineage.js'
|
|
27
29
|
import {
|
|
28
30
|
createLiveProgressSession,
|
|
29
31
|
publishLiveProgress,
|
|
@@ -165,6 +167,12 @@ export function registerAllTools(ctx, deps) {
|
|
|
165
167
|
if (Boolean(sets.queue)) {
|
|
166
168
|
registerBatchQueueTools(targetCtx, deps)
|
|
167
169
|
}
|
|
170
|
+
if (Boolean(sets.variants)) {
|
|
171
|
+
registerVariantTools(targetCtx, deps)
|
|
172
|
+
}
|
|
173
|
+
if (Boolean(sets.lineage)) {
|
|
174
|
+
registerLineageTools(targetCtx, deps)
|
|
175
|
+
}
|
|
168
176
|
}
|
|
169
177
|
|
|
170
178
|
function dispose() {
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getImageLineage,
|
|
3
|
+
getReplayPayload,
|
|
4
|
+
} from '../lineage.js'
|
|
5
|
+
import { readHistory } from '../history.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Registers lineage inspection and replay tools (#439).
|
|
9
|
+
*/
|
|
10
|
+
export function registerLineageTools(ctx, deps = {}) {
|
|
11
|
+
ctx.tools.register({
|
|
12
|
+
name: 'inspect_image_lineage',
|
|
13
|
+
description: 'Inspects generation and edit history ancestry DAG for an image revision, showing parents, branches, and prompts.',
|
|
14
|
+
parameters: {
|
|
15
|
+
type: 'object',
|
|
16
|
+
additionalProperties: false,
|
|
17
|
+
properties: {
|
|
18
|
+
revisionId: {
|
|
19
|
+
type: 'string',
|
|
20
|
+
description: 'Revision ID, attachment ID, or image file path to inspect.',
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
required: ['revisionId'],
|
|
24
|
+
},
|
|
25
|
+
output: {
|
|
26
|
+
render(args, value) {
|
|
27
|
+
if (!value?.target) return [{ type: 'text', text: `No lineage found for "${args.revisionId}".` }]
|
|
28
|
+
const t = value.target
|
|
29
|
+
const ancestryLines = (value.ancestry || []).map((a, i) => ` [Parent ${i + 1}] (${a.operation}) ${a.prompt || a.revisionId}`).join('\n')
|
|
30
|
+
const childrenLines = (value.children || []).map((c) => ` [Child] (${c.operation}) ${c.prompt || c.revisionId}`).join('\n')
|
|
31
|
+
|
|
32
|
+
return [
|
|
33
|
+
{
|
|
34
|
+
type: 'text',
|
|
35
|
+
text: `Lineage for Revision: [${t.revisionId}]\n` +
|
|
36
|
+
`Branch: ${t.branchId} | Operation: ${t.operation}\n` +
|
|
37
|
+
`Prompt: "${t.prompt}"\n` +
|
|
38
|
+
(ancestryLines ? `Ancestry (${value.depth}):\n${ancestryLines}\n` : 'Root generation (no parents)\n') +
|
|
39
|
+
(childrenLines ? `Children:\n${childrenLines}` : 'Leaf revision (no children yet)'),
|
|
40
|
+
},
|
|
41
|
+
]
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
async execute(args) {
|
|
45
|
+
const entries = typeof deps.readHistory === 'function' ? await deps.readHistory() : await readHistory()
|
|
46
|
+
return getImageLineage(args.revisionId, entries)
|
|
47
|
+
},
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
ctx.tools.register({
|
|
51
|
+
name: 'get_image_replay_payload',
|
|
52
|
+
description: 'Extracts sanitized parameter payload from a past image revision to replay or branch generation without credential leaks.',
|
|
53
|
+
parameters: {
|
|
54
|
+
type: 'object',
|
|
55
|
+
additionalProperties: false,
|
|
56
|
+
properties: {
|
|
57
|
+
revisionId: {
|
|
58
|
+
type: 'string',
|
|
59
|
+
description: 'Revision ID to extract replay configuration from.',
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
required: ['revisionId'],
|
|
63
|
+
},
|
|
64
|
+
output: {
|
|
65
|
+
render(args, value) {
|
|
66
|
+
if (!value?.ok) return [{ type: 'text', text: `Replay extraction failed: ${value?.error}` }]
|
|
67
|
+
return [
|
|
68
|
+
{
|
|
69
|
+
type: 'text',
|
|
70
|
+
text: `Replay Payload for [${value.revisionId}]:\n` +
|
|
71
|
+
`Tool: ${value.tool} (${value.provider}/${value.model})\n` +
|
|
72
|
+
`Parameters: ${JSON.stringify(value.params, null, 2)}\n` +
|
|
73
|
+
`Notes: ${value.reproducibilityNotes}`,
|
|
74
|
+
},
|
|
75
|
+
]
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
async execute(args) {
|
|
79
|
+
const entries = typeof deps.readHistory === 'function' ? await deps.readHistory() : await readHistory()
|
|
80
|
+
return getReplayPayload(args.revisionId, entries)
|
|
81
|
+
},
|
|
82
|
+
})
|
|
83
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createVariantSession,
|
|
3
|
+
selectVariantWinner,
|
|
4
|
+
getVariantSession,
|
|
5
|
+
} from '../variant-selection.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Registers variant comparison and winner selection tools (#438).
|
|
9
|
+
*/
|
|
10
|
+
export function registerVariantTools(ctx, deps = {}) {
|
|
11
|
+
ctx.tools.register({
|
|
12
|
+
name: 'evaluate_image_variants',
|
|
13
|
+
description: 'Sets up a side-by-side comparison session for multiple image options with structured criteria.',
|
|
14
|
+
parameters: {
|
|
15
|
+
type: 'object',
|
|
16
|
+
additionalProperties: false,
|
|
17
|
+
properties: {
|
|
18
|
+
name: {
|
|
19
|
+
type: 'string',
|
|
20
|
+
description: 'Title for the evaluation session.',
|
|
21
|
+
},
|
|
22
|
+
variants: {
|
|
23
|
+
type: 'array',
|
|
24
|
+
items: {
|
|
25
|
+
type: 'object',
|
|
26
|
+
additionalProperties: true,
|
|
27
|
+
properties: {
|
|
28
|
+
path: { type: 'string' },
|
|
29
|
+
label: { type: 'string' },
|
|
30
|
+
seed: { type: 'number' },
|
|
31
|
+
prompt: { type: 'string' },
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
description: 'Array of at least 2 image variants to evaluate.',
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
required: ['variants'],
|
|
38
|
+
},
|
|
39
|
+
output: {
|
|
40
|
+
render(args, value) {
|
|
41
|
+
return [
|
|
42
|
+
{
|
|
43
|
+
type: 'text',
|
|
44
|
+
text: `Variant Session Created: [${value?.selectionId}] "${value?.name}"\n` +
|
|
45
|
+
`Variants (${value?.variants?.length}):\n` +
|
|
46
|
+
(value?.variants || [])
|
|
47
|
+
.map((v) => ` #${v.index}: ${v.label} (seed: ${v.seed || 'none'}) - ${v.path}`)
|
|
48
|
+
.join('\n'),
|
|
49
|
+
},
|
|
50
|
+
]
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
async execute(args) {
|
|
54
|
+
const storagePath = deps.variantStoragePath || null
|
|
55
|
+
return createVariantSession(
|
|
56
|
+
{
|
|
57
|
+
name: args.name,
|
|
58
|
+
variants: args.variants,
|
|
59
|
+
},
|
|
60
|
+
storagePath
|
|
61
|
+
)
|
|
62
|
+
},
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
ctx.tools.register({
|
|
66
|
+
name: 'select_variant_winner',
|
|
67
|
+
description: 'Designates the winning variant, tags discards/refinements, and prepares continuation payload for next pipeline step.',
|
|
68
|
+
parameters: {
|
|
69
|
+
type: 'object',
|
|
70
|
+
additionalProperties: false,
|
|
71
|
+
properties: {
|
|
72
|
+
selectionId: {
|
|
73
|
+
type: 'string',
|
|
74
|
+
description: 'Evaluation session ID.',
|
|
75
|
+
},
|
|
76
|
+
winnerIndex: {
|
|
77
|
+
type: 'number',
|
|
78
|
+
description: 'Index of winning variant (0-based).',
|
|
79
|
+
},
|
|
80
|
+
winnerPath: {
|
|
81
|
+
type: 'string',
|
|
82
|
+
description: 'Path of winning variant (alternative to winnerIndex).',
|
|
83
|
+
},
|
|
84
|
+
refineIndexes: {
|
|
85
|
+
type: 'array',
|
|
86
|
+
items: { type: 'number' },
|
|
87
|
+
description: 'Optional indexes of variants kept for refinement.',
|
|
88
|
+
},
|
|
89
|
+
nextAction: {
|
|
90
|
+
type: 'string',
|
|
91
|
+
enum: ['none', 'upscale', 'edit', 'remix', 'vectorize'],
|
|
92
|
+
description: 'Next action to execute with the winner.',
|
|
93
|
+
},
|
|
94
|
+
actionParams: {
|
|
95
|
+
type: 'object',
|
|
96
|
+
additionalProperties: true,
|
|
97
|
+
description: 'Parameters for the next action.',
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
required: ['selectionId'],
|
|
101
|
+
},
|
|
102
|
+
output: {
|
|
103
|
+
render(args, value) {
|
|
104
|
+
if (!value?.ok) return [{ type: 'text', text: `Selection failed: ${value?.error}` }]
|
|
105
|
+
const w = value.winner
|
|
106
|
+
return [
|
|
107
|
+
{
|
|
108
|
+
type: 'text',
|
|
109
|
+
text: `Selected Winner: Option #${w.index} (${w.label})\n` +
|
|
110
|
+
`Path: ${w.path}\n` +
|
|
111
|
+
`Next Action: ${value.continuationPayload?.action || 'None'}\n` +
|
|
112
|
+
(value.continuationPayload ? `Continuation Ready: ${JSON.stringify(value.continuationPayload.params)}` : ''),
|
|
113
|
+
},
|
|
114
|
+
]
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
async execute(args) {
|
|
118
|
+
const storagePath = deps.variantStoragePath || null
|
|
119
|
+
return selectVariantWinner(
|
|
120
|
+
args.selectionId,
|
|
121
|
+
{
|
|
122
|
+
winnerIndex: args.winnerIndex,
|
|
123
|
+
winnerPath: args.winnerPath,
|
|
124
|
+
refineIndexes: args.refineIndexes,
|
|
125
|
+
nextAction: args.nextAction,
|
|
126
|
+
actionParams: args.actionParams,
|
|
127
|
+
},
|
|
128
|
+
storagePath
|
|
129
|
+
)
|
|
130
|
+
},
|
|
131
|
+
})
|
|
132
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Variant Comparison & Winner Selection Workflow (#438)
|
|
7
|
+
* Manages side-by-side variant comparisons, winner/refine/discard tagging,
|
|
8
|
+
* and continuation payload construction for downstream editing or upscaling.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export function getVariantStoragePath(customPath) {
|
|
12
|
+
if (customPath) return customPath
|
|
13
|
+
if (process.env.DSH_VARIANT_STORAGE_PATH) return process.env.DSH_VARIANT_STORAGE_PATH
|
|
14
|
+
const home = os.homedir() || '/tmp'
|
|
15
|
+
return path.join(home, '.dsh', 'dsh-image-gen', 'variant-selections.json')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function loadVariantSessions(storagePath) {
|
|
19
|
+
const filePath = getVariantStoragePath(storagePath)
|
|
20
|
+
try {
|
|
21
|
+
if (!fs.existsSync(filePath)) return []
|
|
22
|
+
const raw = fs.readFileSync(filePath, 'utf8')
|
|
23
|
+
const parsed = JSON.parse(raw)
|
|
24
|
+
return Array.isArray(parsed) ? parsed : []
|
|
25
|
+
} catch (err) {
|
|
26
|
+
return []
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function saveVariantSession(session, storagePath) {
|
|
31
|
+
const filePath = getVariantStoragePath(storagePath)
|
|
32
|
+
const dir = path.dirname(filePath)
|
|
33
|
+
if (!fs.existsSync(dir)) {
|
|
34
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const existing = loadVariantSessions(storagePath)
|
|
38
|
+
const index = existing.findIndex((s) => s.selectionId === session.selectionId)
|
|
39
|
+
if (index >= 0) {
|
|
40
|
+
existing[index] = session
|
|
41
|
+
} else {
|
|
42
|
+
existing.push(session)
|
|
43
|
+
}
|
|
44
|
+
fs.writeFileSync(filePath, JSON.stringify(existing, null, 2), 'utf8')
|
|
45
|
+
return session
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function getVariantSession(selectionId, storagePath) {
|
|
49
|
+
const sessions = loadVariantSessions(storagePath)
|
|
50
|
+
return sessions.find((s) => s.selectionId === selectionId) || null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Creates a new variant evaluation session.
|
|
55
|
+
*/
|
|
56
|
+
export function createVariantSession(params, storagePath) {
|
|
57
|
+
const {
|
|
58
|
+
name = 'Variant Shootout',
|
|
59
|
+
variants = [],
|
|
60
|
+
criteria = {},
|
|
61
|
+
} = params
|
|
62
|
+
|
|
63
|
+
if (!Array.isArray(variants) || variants.length < 2) {
|
|
64
|
+
throw new Error('Variant evaluation requires at least 2 image variants to compare.')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const selectionId = `vs_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
|
|
68
|
+
const preparedVariants = variants.map((v, idx) => ({
|
|
69
|
+
index: idx,
|
|
70
|
+
id: v.id || `v_${idx + 1}`,
|
|
71
|
+
path: v.path || v.url || '',
|
|
72
|
+
label: v.label || `Option ${idx + 1}`,
|
|
73
|
+
seed: v.seed !== undefined ? v.seed : null,
|
|
74
|
+
prompt: v.prompt || '',
|
|
75
|
+
params: v.params || {},
|
|
76
|
+
status: 'undecided', // 'undecided' | 'winner' | 'refine' | 'discard'
|
|
77
|
+
}))
|
|
78
|
+
|
|
79
|
+
const session = {
|
|
80
|
+
selectionId,
|
|
81
|
+
name,
|
|
82
|
+
status: 'evaluating', // 'evaluating' | 'decided' | 'discarded'
|
|
83
|
+
createdAt: new Date().toISOString(),
|
|
84
|
+
updatedAt: new Date().toISOString(),
|
|
85
|
+
criteria,
|
|
86
|
+
winnerIndex: null,
|
|
87
|
+
continuationPayload: null,
|
|
88
|
+
variants: preparedVariants,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return saveVariantSession(session, storagePath)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Selects the winning variant and constructs continuation payload for next step.
|
|
96
|
+
*/
|
|
97
|
+
export function selectVariantWinner(selectionId, options = {}, storagePath) {
|
|
98
|
+
const session = getVariantSession(selectionId, storagePath)
|
|
99
|
+
if (!session) {
|
|
100
|
+
return { ok: false, error: `Variant session "${selectionId}" not found.` }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const {
|
|
104
|
+
winnerIndex,
|
|
105
|
+
winnerPath,
|
|
106
|
+
refineIndexes = [],
|
|
107
|
+
nextAction = 'none',
|
|
108
|
+
actionParams = {},
|
|
109
|
+
} = options
|
|
110
|
+
|
|
111
|
+
// Find winner
|
|
112
|
+
let winner = null
|
|
113
|
+
if (typeof winnerIndex === 'number' && session.variants[winnerIndex]) {
|
|
114
|
+
winner = session.variants[winnerIndex]
|
|
115
|
+
} else if (winnerPath) {
|
|
116
|
+
winner = session.variants.find((v) => v.path === winnerPath) || null
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!winner) {
|
|
120
|
+
return { ok: false, error: 'Valid winnerIndex or winnerPath must be specified.' }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Update statuses
|
|
124
|
+
for (const v of session.variants) {
|
|
125
|
+
if (v.id === winner.id) {
|
|
126
|
+
v.status = 'winner'
|
|
127
|
+
} else if (refineIndexes.includes(v.index)) {
|
|
128
|
+
v.status = 'refine'
|
|
129
|
+
} else {
|
|
130
|
+
v.status = 'discard'
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
session.winnerIndex = winner.index
|
|
135
|
+
session.status = 'decided'
|
|
136
|
+
session.updatedAt = new Date().toISOString()
|
|
137
|
+
|
|
138
|
+
// Construct continuation payload
|
|
139
|
+
let continuationPayload = null
|
|
140
|
+
if (nextAction && nextAction !== 'none') {
|
|
141
|
+
continuationPayload = {
|
|
142
|
+
action: nextAction,
|
|
143
|
+
sourceImage: winner.path,
|
|
144
|
+
parentImageId: winner.path,
|
|
145
|
+
inheritedPrompt: winner.prompt,
|
|
146
|
+
inheritedSeed: winner.seed,
|
|
147
|
+
params: {
|
|
148
|
+
...winner.params,
|
|
149
|
+
...actionParams,
|
|
150
|
+
image: winner.path,
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
session.continuationPayload = continuationPayload
|
|
156
|
+
saveVariantSession(session, storagePath)
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
ok: true,
|
|
160
|
+
selectionId,
|
|
161
|
+
status: 'decided',
|
|
162
|
+
winner: {
|
|
163
|
+
index: winner.index,
|
|
164
|
+
id: winner.id,
|
|
165
|
+
path: winner.path,
|
|
166
|
+
label: winner.label,
|
|
167
|
+
},
|
|
168
|
+
continuationPayload,
|
|
169
|
+
}
|
|
170
|
+
}
|
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.42",
|
|
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",
|