@goodandready/dsh-image-gen 0.10.30 → 0.10.32
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/anchor-helpers.js +103 -0
- package/lib/client.js +462 -52
- package/lib/fallback-router.js +163 -0
- package/lib/index.js +27 -5
- package/lib/register-tools.js +6 -0
- package/lib/security.js +166 -0
- package/lib/style-matrix-helpers.js +85 -0
- package/lib/tools/anchor.js +129 -0
- package/lib/tools/generation.js +21 -5
- package/lib/tools/style-matrix.js +227 -0
- package/lib/tools/ui-asset.js +238 -0
- package/lib/ui-asset-helpers.js +44 -0
- package/lib/updater.js +9 -19
- package/package.json +2 -2
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// fallback-router.js — Smart Provider Fallback Chain for image generation (#282).
|
|
2
|
+
// Automatically cascades across configured providers on 429, 5xx, timeouts,
|
|
3
|
+
// quota depletion, and network issues without failing the user's turn.
|
|
4
|
+
|
|
5
|
+
import { formatErrorMessage } from './provider-utils.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Checks if an error is strictly a prompt-level safety or client syntax violation
|
|
9
|
+
* that cannot be fixed by switching to a different provider.
|
|
10
|
+
*
|
|
11
|
+
* @param {Error|any} error
|
|
12
|
+
* @returns {boolean} true if fatal prompt/content violation, false if provider-level retryable
|
|
13
|
+
*/
|
|
14
|
+
export function isFatalPromptError(error) {
|
|
15
|
+
const msg = (error?.message || String(error || '')).toLowerCase()
|
|
16
|
+
return (
|
|
17
|
+
msg.includes('content policy') ||
|
|
18
|
+
msg.includes('safety system') ||
|
|
19
|
+
msg.includes('nsfw') ||
|
|
20
|
+
msg.includes('moderation') ||
|
|
21
|
+
msg.includes('prompt is required') ||
|
|
22
|
+
msg.includes('empty prompt') ||
|
|
23
|
+
msg.includes('invalid_prompt') ||
|
|
24
|
+
msg.includes('unsupported image format')
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Checks whether an error is retryable by switching to another provider.
|
|
30
|
+
* Covers 429 (rate limit), 5xx (server error), timeout, payment/quota depletion, and network dropouts.
|
|
31
|
+
*
|
|
32
|
+
* @param {Error|any} error
|
|
33
|
+
* @returns {boolean}
|
|
34
|
+
*/
|
|
35
|
+
export function isRetryableProviderError(error) {
|
|
36
|
+
if (isFatalPromptError(error)) return false
|
|
37
|
+
const msg = (error?.message || String(error || '')).toLowerCase()
|
|
38
|
+
return (
|
|
39
|
+
msg.includes('429') ||
|
|
40
|
+
msg.includes('rate limit') ||
|
|
41
|
+
msg.includes('too many requests') ||
|
|
42
|
+
msg.includes('500') ||
|
|
43
|
+
msg.includes('502') ||
|
|
44
|
+
msg.includes('503') ||
|
|
45
|
+
msg.includes('504') ||
|
|
46
|
+
msg.includes('bad gateway') ||
|
|
47
|
+
msg.includes('service unavailable') ||
|
|
48
|
+
msg.includes('gateway timeout') ||
|
|
49
|
+
msg.includes('timeout') ||
|
|
50
|
+
msg.includes('timed out') ||
|
|
51
|
+
msg.includes('aborted') ||
|
|
52
|
+
msg.includes('402') ||
|
|
53
|
+
msg.includes('payment required') ||
|
|
54
|
+
msg.includes('insufficient_quota') ||
|
|
55
|
+
msg.includes('insufficient credits') ||
|
|
56
|
+
msg.includes('exceeded your current quota') ||
|
|
57
|
+
msg.includes('balance is insufficient') ||
|
|
58
|
+
msg.includes('quota') ||
|
|
59
|
+
msg.includes('credit') ||
|
|
60
|
+
msg.includes('unauthorized') ||
|
|
61
|
+
msg.includes('401') ||
|
|
62
|
+
msg.includes('invalid api key') ||
|
|
63
|
+
msg.includes('econnrefused') ||
|
|
64
|
+
msg.includes('enotfound') ||
|
|
65
|
+
msg.includes('fetch failed') ||
|
|
66
|
+
msg.includes('network')
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Resolves the candidate evaluation order:
|
|
72
|
+
* Primary provider first, followed by explicit fallbackProviders, then remaining known providers.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} primary
|
|
75
|
+
* @param {string[]} [configuredFallbacks=[]]
|
|
76
|
+
* @param {string[]} [allProviders=[]]
|
|
77
|
+
* @returns {string[]}
|
|
78
|
+
*/
|
|
79
|
+
export function resolveFallbackChain(primary, configuredFallbacks = [], allProviders = []) {
|
|
80
|
+
const chain = [primary]
|
|
81
|
+
if (Array.isArray(configuredFallbacks)) {
|
|
82
|
+
for (const p of configuredFallbacks) {
|
|
83
|
+
if (typeof p === 'string' && p.trim() && !chain.includes(p.trim())) {
|
|
84
|
+
chain.push(p.trim())
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (Array.isArray(allProviders)) {
|
|
89
|
+
for (const p of allProviders) {
|
|
90
|
+
if (typeof p === 'string' && p.trim() && !chain.includes(p.trim())) {
|
|
91
|
+
chain.push(p.trim())
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return chain
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Executes image generation across a cascade of providers with full attempt diagnostics.
|
|
100
|
+
*
|
|
101
|
+
* @param {Record<string, (seed: number, prompt: string) => Promise<any>>} generators
|
|
102
|
+
* @param {string[]} chain Ordered list of providers to try
|
|
103
|
+
* @param {number} seed
|
|
104
|
+
* @param {string} prompt
|
|
105
|
+
* @param {object} [options]
|
|
106
|
+
* @returns {Promise<any>} Produced image with attached _fallback metadata
|
|
107
|
+
*/
|
|
108
|
+
export async function executeWithFallback(generators, chain, seed, prompt, options = {}) {
|
|
109
|
+
const attempts = []
|
|
110
|
+
const primaryProvider = chain[0] || 'unknown'
|
|
111
|
+
const logger = options.logger || null
|
|
112
|
+
|
|
113
|
+
for (let i = 0; i < chain.length; i++) {
|
|
114
|
+
const providerKey = chain[i]
|
|
115
|
+
const generator = generators[providerKey]
|
|
116
|
+
if (typeof generator !== 'function') {
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const startTime = Date.now()
|
|
121
|
+
try {
|
|
122
|
+
const produced = await generator(seed, prompt)
|
|
123
|
+
if (produced) {
|
|
124
|
+
const isFallback = providerKey !== primaryProvider
|
|
125
|
+
produced._fallback = {
|
|
126
|
+
triggered: isFallback,
|
|
127
|
+
primaryProvider,
|
|
128
|
+
providerUsed: providerKey,
|
|
129
|
+
attempts: [
|
|
130
|
+
...attempts,
|
|
131
|
+
{ provider: providerKey, durationMs: Date.now() - startTime, success: true },
|
|
132
|
+
],
|
|
133
|
+
}
|
|
134
|
+
if (isFallback && logger && typeof logger.info === 'function') {
|
|
135
|
+
logger.info(`[dsh-image-gen] Fallback triggered: ${primaryProvider} -> ${providerKey}`)
|
|
136
|
+
}
|
|
137
|
+
return produced
|
|
138
|
+
}
|
|
139
|
+
} catch (err) {
|
|
140
|
+
const durationMs = Date.now() - startTime
|
|
141
|
+
const formatted = formatErrorMessage(err, providerKey)
|
|
142
|
+
attempts.push({
|
|
143
|
+
provider: providerKey,
|
|
144
|
+
error: formatted,
|
|
145
|
+
durationMs,
|
|
146
|
+
success: false,
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
if (isFatalPromptError(err)) {
|
|
150
|
+
throw new Error(`Content or policy error on ${providerKey} (not cascading): ${formatted}`)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (logger && typeof logger.warn === 'function') {
|
|
154
|
+
logger.warn(`[dsh-image-gen] Provider ${providerKey} failed: ${formatted}. Trying next candidate...`)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const failureSummary = attempts
|
|
160
|
+
.map((a) => `${a.provider} (${a.durationMs}ms): ${a.error}`)
|
|
161
|
+
.join('; ')
|
|
162
|
+
throw new Error(`Image generation cascade failed on all ${attempts.length} attempted providers: ${failureSummary}`)
|
|
163
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import z from '@deepseek-ai/schemastery'
|
|
16
16
|
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
17
17
|
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
18
|
-
import { enforceSecurePermissions } from './security.js'
|
|
18
|
+
import { enforceSecurePermissions, isTrustedLocalRequest } from './security.js'
|
|
19
19
|
import { saveAndAttachResult } from './attachment-helper.js'
|
|
20
20
|
import {
|
|
21
21
|
historyFile,
|
|
@@ -264,6 +264,10 @@ export const Config = z.object({
|
|
|
264
264
|
.boolean()
|
|
265
265
|
.description('Content-addressed disk caching for identical generations (<50ms retrieval, zero API cost). On by default.')
|
|
266
266
|
.default(true),
|
|
267
|
+
fallbackProviders: z
|
|
268
|
+
.array(z.string())
|
|
269
|
+
.description('Ordered list of fallback providers to cascade to if the primary encounters 429, 5xx, or quota limits.')
|
|
270
|
+
.default([]),
|
|
267
271
|
})
|
|
268
272
|
|
|
269
273
|
/** Keep a file stem safe for the filesystem. */
|
|
@@ -431,6 +435,11 @@ export function apply(ctx, config) {
|
|
|
431
435
|
res.end(JSON.stringify({ error: 'GET only' }))
|
|
432
436
|
return
|
|
433
437
|
}
|
|
438
|
+
if (!isTrustedLocalRequest(req)) {
|
|
439
|
+
res.writeHead(403, { 'Content-Type': 'application/json' })
|
|
440
|
+
res.end(JSON.stringify({ error: 'forbidden' }))
|
|
441
|
+
return
|
|
442
|
+
}
|
|
434
443
|
const query = new URL(req.url ?? '/', 'http://x').searchParams
|
|
435
444
|
const id = query.get('id') ?? ''
|
|
436
445
|
if (!/^sha256:[0-9a-f]{64}$/.test(id)) {
|
|
@@ -504,6 +513,11 @@ export function apply(ctx, config) {
|
|
|
504
513
|
res.end(JSON.stringify({ error: 'GET only' }))
|
|
505
514
|
return
|
|
506
515
|
}
|
|
516
|
+
if (!isTrustedLocalRequest(req)) {
|
|
517
|
+
res.writeHead(403, { 'Content-Type': 'application/json' })
|
|
518
|
+
res.end(JSON.stringify({ error: 'forbidden' }))
|
|
519
|
+
return
|
|
520
|
+
}
|
|
507
521
|
try {
|
|
508
522
|
const u = new URL(req.url, 'http://localhost')
|
|
509
523
|
const provider = u.searchParams.get('provider') || config.provider || 'fal'
|
|
@@ -533,12 +547,20 @@ export function apply(ctx, config) {
|
|
|
533
547
|
res.end(JSON.stringify({ error: 'GET only' }))
|
|
534
548
|
return
|
|
535
549
|
}
|
|
550
|
+
if (!isTrustedLocalRequest(req)) {
|
|
551
|
+
res.writeHead(403, { 'Content-Type': 'application/json' })
|
|
552
|
+
res.end(JSON.stringify({ error: 'forbidden' }))
|
|
553
|
+
return
|
|
554
|
+
}
|
|
536
555
|
const exists = (p) => existsSync(p)
|
|
537
556
|
const entries = await readHistory()
|
|
538
|
-
const withThumbs = filterHistory(entries, exists).map((e) =>
|
|
539
|
-
...e
|
|
540
|
-
|
|
541
|
-
|
|
557
|
+
const withThumbs = filterHistory(entries, exists).map((e) => {
|
|
558
|
+
const { path: _discardPath, ...safeEntry } = e
|
|
559
|
+
return {
|
|
560
|
+
...safeEntry,
|
|
561
|
+
thumbnailUrl: e.attachmentId ? `/dsh-image-gen/image?id=${encodeURIComponent(e.attachmentId)}` : '',
|
|
562
|
+
}
|
|
563
|
+
})
|
|
542
564
|
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
543
565
|
res.end(JSON.stringify(withThumbs))
|
|
544
566
|
},
|
package/lib/register-tools.js
CHANGED
|
@@ -11,6 +11,9 @@ import { registerSketchTools } from './tools/sketch.js'
|
|
|
11
11
|
import { registerSpritesheetTools } from './tools/spritesheet.js'
|
|
12
12
|
import { registerPatternTools } from './tools/pattern.js'
|
|
13
13
|
import { registerResponsiveTools } from './tools/responsive.js'
|
|
14
|
+
import { registerAnchorTools } from './tools/anchor.js'
|
|
15
|
+
import { registerUiAssetTools } from './tools/ui-asset.js'
|
|
16
|
+
import { registerStyleMatrixTools } from './tools/style-matrix.js'
|
|
14
17
|
|
|
15
18
|
/**
|
|
16
19
|
* Registers every image-gen tool on the host tools service.
|
|
@@ -29,4 +32,7 @@ export function registerAllTools(ctx, deps) {
|
|
|
29
32
|
registerSpritesheetTools(ctx, deps)
|
|
30
33
|
registerPatternTools(ctx, deps)
|
|
31
34
|
registerResponsiveTools(ctx, deps)
|
|
35
|
+
registerAnchorTools(ctx, deps)
|
|
36
|
+
registerUiAssetTools(ctx, deps)
|
|
37
|
+
registerStyleMatrixTools(ctx, deps)
|
|
32
38
|
}
|
package/lib/security.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// lib/security.js
|
|
2
2
|
// Secure credential handling, 0600 file permissions, and token masking (#169)
|
|
3
|
+
// Address validation and trusted request guards against DNS rebinding & cross-site leaks (Refs: GitHub #1, #280, #276, #277)
|
|
3
4
|
|
|
4
5
|
import { chmodSync, existsSync } from 'node:fs'
|
|
5
6
|
|
|
@@ -67,3 +68,168 @@ export function enforceSecurePermissions(filePath) {
|
|
|
67
68
|
return false
|
|
68
69
|
}
|
|
69
70
|
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Strict loopback address verification.
|
|
74
|
+
* Supports:
|
|
75
|
+
* - 'localhost' and RFC 6761 reserved '.localhost' TLD (e.g. 'sub.localhost')
|
|
76
|
+
* - IPv6 loopback '::1'
|
|
77
|
+
* - Strict IPv4 loopback 127.0.0.0/8 (anchored ^ and $, valid octets 0..255)
|
|
78
|
+
* - IPv6-mapped IPv4 loopback '::ffff:127.x.x.x'
|
|
79
|
+
*
|
|
80
|
+
* Explicitly rejects prefix-matched hostnames such as 127.0.0.1.evil.com.
|
|
81
|
+
*/
|
|
82
|
+
export function isLoopbackAddress(value) {
|
|
83
|
+
if (!value || typeof value !== 'string') return false
|
|
84
|
+
const address = value.trim().toLowerCase().replace(/^\[|\]$/g, '')
|
|
85
|
+
if (
|
|
86
|
+
address === 'localhost' ||
|
|
87
|
+
address === 'localhost.' ||
|
|
88
|
+
address === '::1' ||
|
|
89
|
+
address.endsWith('.localhost') ||
|
|
90
|
+
address.endsWith('.localhost.')
|
|
91
|
+
) {
|
|
92
|
+
return true
|
|
93
|
+
}
|
|
94
|
+
const ipv4 = address.startsWith('::ffff:') ? address.slice(7) : address
|
|
95
|
+
const m = /^127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ipv4)
|
|
96
|
+
if (!m) return false
|
|
97
|
+
const o = [Number(m[1]), Number(m[2]), Number(m[3])]
|
|
98
|
+
return o.every((n) => n >= 0 && n <= 255)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Strict private LAN address verification.
|
|
103
|
+
* Supports:
|
|
104
|
+
* - 10.0.0.0/8 (10.0.0.0 - 10.255.255.255)
|
|
105
|
+
* - 172.16.0.0/12 (172.16.0.0 - 172.31.255.255)
|
|
106
|
+
* - 192.168.0.0/16 (192.168.0.0 - 192.168.255.255)
|
|
107
|
+
* - 169.254.0.0/16 (link-local)
|
|
108
|
+
* - IPv6-mapped IPv4 equivalents (::ffff:...)
|
|
109
|
+
* - IPv6 ULA (fc00::/7) and link-local (fe80::/10)
|
|
110
|
+
*
|
|
111
|
+
* Explicitly rejects prefix-matched hostnames such as 10.evil.com,
|
|
112
|
+
* 192.168.evil.com, 172.16.evil.com, 10.0.0.1.nip.io.
|
|
113
|
+
*/
|
|
114
|
+
export function isPrivateLanAddress(value) {
|
|
115
|
+
if (!value || typeof value !== 'string') return false
|
|
116
|
+
const address = value.trim().toLowerCase().replace(/^\[|\]$/g, '')
|
|
117
|
+
const ipv4 = address.startsWith('::ffff:') ? address.slice(7) : address
|
|
118
|
+
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ipv4)
|
|
119
|
+
if (m) {
|
|
120
|
+
const o = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])]
|
|
121
|
+
if (o.some((n) => n < 0 || n > 255)) return false
|
|
122
|
+
if (o[0] === 10) return true
|
|
123
|
+
if (o[0] === 172 && o[1] >= 16 && o[1] <= 31) return true
|
|
124
|
+
if (o[0] === 192 && o[1] === 168) return true
|
|
125
|
+
if (o[0] === 169 && o[1] === 254) return true
|
|
126
|
+
return false
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// IPv6 ULA (fc00::/7) or link-local (fe80::/10)
|
|
130
|
+
if (/^(fc|fd)[0-9a-f]{2}:/i.test(address) || /^fe80:/i.test(address)) {
|
|
131
|
+
return true
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return false
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Safely extracts hostname without port or IPv6 brackets from Host header or authority string.
|
|
139
|
+
*/
|
|
140
|
+
export function extractHostName(hostHeader) {
|
|
141
|
+
if (!hostHeader || typeof hostHeader !== 'string') return ''
|
|
142
|
+
const trimmed = hostHeader.trim()
|
|
143
|
+
try {
|
|
144
|
+
const url = new URL(`http://${trimmed}`)
|
|
145
|
+
return url.hostname.replace(/^\[|\]$/g, '')
|
|
146
|
+
} catch {
|
|
147
|
+
return trimmed.replace(/^\[|\]$/g, '').split(':')[0] || ''
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Helper to get header value in case-insensitive manner.
|
|
153
|
+
*/
|
|
154
|
+
function getHeader(req, name) {
|
|
155
|
+
if (!req?.headers) return undefined
|
|
156
|
+
const direct = req.headers[name]
|
|
157
|
+
if (direct !== undefined) return Array.isArray(direct) ? direct[0] : direct
|
|
158
|
+
const lower = req.headers[name.toLowerCase()]
|
|
159
|
+
return Array.isArray(lower) ? lower[0] : lower
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Verifies that an incoming HTTP request originates from and targets a trusted local or private LAN authority.
|
|
164
|
+
* Guards against DNS rebinding, cross-site leaks, and unauthorized external probing.
|
|
165
|
+
*
|
|
166
|
+
* Rules:
|
|
167
|
+
* 1. Client remote address (if present on socket) must be loopback or private LAN.
|
|
168
|
+
* 2. Sec-Fetch-Site (if present) must NOT be 'cross-site'.
|
|
169
|
+
* 3. Host header must be present and its hostname must be loopback or private LAN.
|
|
170
|
+
* 4. Origin header (if present) must have valid http/https protocol, its hostname must be loopback/LAN,
|
|
171
|
+
* and its host authority must match the request's Host header.
|
|
172
|
+
* 5. Referer header (if present) must have valid http/https protocol and its hostname must be loopback/LAN.
|
|
173
|
+
*/
|
|
174
|
+
export function isTrustedLocalRequest(req) {
|
|
175
|
+
if (!req) return false
|
|
176
|
+
|
|
177
|
+
const remote = req.socket?.remoteAddress
|
|
178
|
+
if (remote && !isLoopbackAddress(remote) && !isPrivateLanAddress(remote)) {
|
|
179
|
+
return false
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const site = getHeader(req, 'sec-fetch-site')
|
|
183
|
+
if (site === 'cross-site') {
|
|
184
|
+
return false
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const host = getHeader(req, 'host')
|
|
188
|
+
if (!host || typeof host !== 'string') {
|
|
189
|
+
return false
|
|
190
|
+
}
|
|
191
|
+
const hostName = extractHostName(host)
|
|
192
|
+
if (!isLoopbackAddress(hostName) && !isPrivateLanAddress(hostName)) {
|
|
193
|
+
return false
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const origin = getHeader(req, 'origin')
|
|
197
|
+
if (origin !== undefined) {
|
|
198
|
+
if (typeof origin !== 'string' || origin === '' || origin === 'null') {
|
|
199
|
+
return false
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
const url = new URL(origin)
|
|
203
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
204
|
+
return false
|
|
205
|
+
}
|
|
206
|
+
const originHostName = url.hostname.replace(/^\[|\]$/g, '')
|
|
207
|
+
if (!isLoopbackAddress(originHostName) && !isPrivateLanAddress(originHostName)) {
|
|
208
|
+
return false
|
|
209
|
+
}
|
|
210
|
+
if (url.host.toLowerCase() !== host.toLowerCase()) {
|
|
211
|
+
return false
|
|
212
|
+
}
|
|
213
|
+
} catch {
|
|
214
|
+
return false
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const referer = getHeader(req, 'referer')
|
|
219
|
+
if (referer !== undefined && typeof referer === 'string' && referer !== '') {
|
|
220
|
+
try {
|
|
221
|
+
const url = new URL(referer)
|
|
222
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
223
|
+
return false
|
|
224
|
+
}
|
|
225
|
+
const refererHostName = url.hostname.replace(/^\[|\]$/g, '')
|
|
226
|
+
if (!isLoopbackAddress(refererHostName) && !isPrivateLanAddress(refererHostName)) {
|
|
227
|
+
return false
|
|
228
|
+
}
|
|
229
|
+
} catch {
|
|
230
|
+
return false
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return true
|
|
235
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// style-matrix-helpers.js — Helpers for generate_style_matrix tool (#286).
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_MATRIX_STYLES = [
|
|
4
|
+
'editorial_photo',
|
|
5
|
+
'flat_vector',
|
|
6
|
+
'clay_3d',
|
|
7
|
+
'cyberpunk',
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
export const MATRIX_PRESET_PROMPTS = {
|
|
11
|
+
editorial_photo: 'editorial magazine photograph, 35mm film, soft diffused cinematic lighting, award-winning shot, ultra-detailed',
|
|
12
|
+
flat_vector: 'minimal flat vector art, clean sharp lines, bold solid colors, geometric modern graphic illustration',
|
|
13
|
+
clay_3d: 'charming 3D claymation plasticine sculpt, handcrafted clay texture, stop-motion animation aesthetic, miniature depth of field',
|
|
14
|
+
cyberpunk: 'gritty cyberpunk scene, neon reflections, rain-slicked chrome, dark synthwave palette, high-tech dystopian atmosphere',
|
|
15
|
+
cinematic: 'cinematic still frame, 70mm Panavision anamorphic lens, dramatic moody lighting, atmospheric haze',
|
|
16
|
+
anime: 'vibrant modern anime keyframe, Makoto Shinkai aesthetic, luminous skies, crisp cel shading',
|
|
17
|
+
isometric_3d: 'isometric 3D diorama render, Blender 3D, orthographic perspective, cute stylized textures',
|
|
18
|
+
analog_film: 'vintage 1970s Kodachrome analog film print, authentic grain, faded warm tones, nostalgic vignette',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Normalizes input styles into exactly 4 distinct style strings.
|
|
23
|
+
*
|
|
24
|
+
* @param {string[]|undefined} inputStyles
|
|
25
|
+
* @returns {string[]}
|
|
26
|
+
*/
|
|
27
|
+
export function normalizeMatrixStyles(inputStyles) {
|
|
28
|
+
const result = []
|
|
29
|
+
if (Array.isArray(inputStyles)) {
|
|
30
|
+
for (const s of inputStyles) {
|
|
31
|
+
if (typeof s === 'string' && s.trim()) {
|
|
32
|
+
const clean = s.trim().toLowerCase()
|
|
33
|
+
if (!result.includes(clean)) result.push(clean)
|
|
34
|
+
}
|
|
35
|
+
if (result.length === 4) break
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Backfill up to 4 using defaults
|
|
40
|
+
for (const def of DEFAULT_MATRIX_STYLES) {
|
|
41
|
+
if (result.length >= 4) break
|
|
42
|
+
if (!result.includes(def)) result.push(def)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return result.slice(0, 4)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Builds the full prompt for a matrix quadrant cell.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} basePrompt
|
|
52
|
+
* @param {string} styleName
|
|
53
|
+
* @returns {string}
|
|
54
|
+
*/
|
|
55
|
+
export function buildMatrixCellPrompt(basePrompt, styleName) {
|
|
56
|
+
const cleanBase = String(basePrompt || '').trim()
|
|
57
|
+
const presetDirective = MATRIX_PRESET_PROMPTS[styleName] || (styleName + ' style, aesthetic rendering')
|
|
58
|
+
return cleanBase + ', ' + presetDirective
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Formats a 2x2 markdown presentation grid for chat models.
|
|
63
|
+
*
|
|
64
|
+
* @param {object} params
|
|
65
|
+
* @param {string} params.basePrompt
|
|
66
|
+
* @param {boolean} params.blindMode
|
|
67
|
+
* @param {Array<{id: string, style: string, path: string, url: string, seed: number}>} params.cells
|
|
68
|
+
* @returns {string}
|
|
69
|
+
*/
|
|
70
|
+
export function formatMatrixMarkdown({ basePrompt, blindMode, cells }) {
|
|
71
|
+
const lines = [
|
|
72
|
+
'### Style Matrix (2×2 Benchmark): "' + basePrompt + '"',
|
|
73
|
+
blindMode ? '_Blind Comparison Mode: styles masked until evaluated_' : '',
|
|
74
|
+
'',
|
|
75
|
+
'| Cell | Style Preset | Seed | Path |',
|
|
76
|
+
'| :---: | :--- | :---: | :--- |',
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
for (const c of cells) {
|
|
80
|
+
const styleLabel = blindMode ? ('Option ' + c.id) : ('**' + c.style + '**')
|
|
81
|
+
lines.push('| **' + c.id + '** | ' + styleLabel + ' | `' + c.seed + '` | `' + c.path + '` |')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return lines.filter(Boolean).join('\n')
|
|
85
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// anchor.js — set_style_anchor tool (#283).
|
|
2
|
+
// Manages persistent visual style & character reference anchors in session context.
|
|
3
|
+
|
|
4
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
5
|
+
import {
|
|
6
|
+
setSessionAnchor,
|
|
7
|
+
getSessionAnchor,
|
|
8
|
+
clearSessionAnchor,
|
|
9
|
+
normalizeAnchorStrength,
|
|
10
|
+
} from '../anchor-helpers.js'
|
|
11
|
+
import { toLosslessJson } from '../providers.js'
|
|
12
|
+
|
|
13
|
+
export function registerAnchorTools(ctx, deps) {
|
|
14
|
+
const { live, resolveSource } = deps
|
|
15
|
+
|
|
16
|
+
ctx.effect(() => {
|
|
17
|
+
ctx.tools.register(
|
|
18
|
+
defineTool({
|
|
19
|
+
name: 'set_style_anchor',
|
|
20
|
+
description:
|
|
21
|
+
'Set or clear a persistent visual anchor (character identity or artstyle) for this session. '
|
|
22
|
+
+ 'Subsequent image generations automatically adhere to this reference to maintain visual continuity.',
|
|
23
|
+
parameters: {
|
|
24
|
+
image: {
|
|
25
|
+
type: 'string',
|
|
26
|
+
description: 'URL, local file path, or attachment id (sha256:...) to use as the visual anchor. Required unless clear is true.',
|
|
27
|
+
},
|
|
28
|
+
label: {
|
|
29
|
+
type: 'string',
|
|
30
|
+
description: 'Human-readable descriptor, e.g. "Protagonist Alex" or "Dark Synthwave 3D".',
|
|
31
|
+
},
|
|
32
|
+
mode: {
|
|
33
|
+
type: 'string',
|
|
34
|
+
enum: ['style', 'character'],
|
|
35
|
+
description: 'Anchor mode: "character" preserves facial/physical identity; "style" preserves artistic rendering, lighting, and palette. Default: style.',
|
|
36
|
+
},
|
|
37
|
+
strength: {
|
|
38
|
+
type: 'number',
|
|
39
|
+
description: 'Reference influence strength between 0.1 and 1.0 (default 0.65).',
|
|
40
|
+
},
|
|
41
|
+
clear: {
|
|
42
|
+
type: 'boolean',
|
|
43
|
+
description: 'When true, clears the active anchor for this session instead of setting a new one.',
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
output: {
|
|
47
|
+
schema: {
|
|
48
|
+
type: 'object',
|
|
49
|
+
additionalProperties: true,
|
|
50
|
+
properties: {
|
|
51
|
+
ok: { type: 'boolean' },
|
|
52
|
+
action: { type: 'string' },
|
|
53
|
+
anchor: {
|
|
54
|
+
type: 'object',
|
|
55
|
+
additionalProperties: true,
|
|
56
|
+
properties: {
|
|
57
|
+
image: { type: 'string' },
|
|
58
|
+
label: { type: 'string' },
|
|
59
|
+
mode: { type: 'string' },
|
|
60
|
+
strength: { type: 'number' },
|
|
61
|
+
updatedAt: { type: 'string' },
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
summary: { type: 'string' },
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
render(args, value) {
|
|
68
|
+
return [{ type: 'text', text: value.summary || 'Style anchor updated.' }]
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
isConcurrencySafe: () => true,
|
|
72
|
+
async execute(args, exec) {
|
|
73
|
+
const cfg = live ? live() : {}
|
|
74
|
+
if (cfg.enabled === false) {
|
|
75
|
+
throw new Error('Image generation plugin is disabled in settings.')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const sessionId = exec?.agent?.session?.id || exec?.agent?.session?.header?.id || 'session'
|
|
79
|
+
|
|
80
|
+
if (args.clear) {
|
|
81
|
+
const existed = clearSessionAnchor(sessionId)
|
|
82
|
+
const summary = existed
|
|
83
|
+
? 'Active visual style anchor was cleared for this session.'
|
|
84
|
+
: 'No active visual style anchor was set for this session.'
|
|
85
|
+
return toLosslessJson({
|
|
86
|
+
ok: true,
|
|
87
|
+
action: 'cleared',
|
|
88
|
+
summary,
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!args.image || typeof args.image !== 'string' || !args.image.trim()) {
|
|
93
|
+
throw new Error('Parameter "image" (URL, file path, or sha256 attachment ID) is required to set an anchor.')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const imageRef = args.image.trim()
|
|
97
|
+
const mode = args.mode === 'character' ? 'character' : 'style'
|
|
98
|
+
const strength = normalizeAnchorStrength(args.strength)
|
|
99
|
+
const label = args.label ? String(args.label).trim() : (mode === 'character' ? 'Character Anchor' : 'Style Anchor')
|
|
100
|
+
|
|
101
|
+
// Validate source readability if it is a local path or attachment
|
|
102
|
+
if (resolveSource && (imageRef.startsWith('sha256:') || !imageRef.startsWith('http'))) {
|
|
103
|
+
try {
|
|
104
|
+
await resolveSource(ctx, exec, imageRef)
|
|
105
|
+
} catch (err) {
|
|
106
|
+
// Best effort check; non-blocking if remote URL or future attachment
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const anchor = setSessionAnchor(sessionId, {
|
|
111
|
+
image: imageRef,
|
|
112
|
+
label,
|
|
113
|
+
mode,
|
|
114
|
+
strength,
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
const summary = `Visual anchor "${label}" (${mode} mode, strength: ${strength}) is now active for this session. Subsequent generations will align with this reference.`
|
|
118
|
+
|
|
119
|
+
return toLosslessJson({
|
|
120
|
+
ok: true,
|
|
121
|
+
action: 'set',
|
|
122
|
+
anchor,
|
|
123
|
+
summary,
|
|
124
|
+
})
|
|
125
|
+
},
|
|
126
|
+
}),
|
|
127
|
+
)
|
|
128
|
+
}, 'dsh-image-gen: tool set_style_anchor')
|
|
129
|
+
}
|