dsh-draw 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/LICENSE +201 -0
- package/README.es.md +194 -0
- package/README.hi.md +194 -0
- package/README.md +194 -0
- package/README.pt.md +194 -0
- package/README.zh.md +194 -0
- package/SECURITY.md +39 -0
- package/THIRD_PARTY_NOTICES.md +21 -0
- package/cordis.patch.yml +48 -0
- package/lib/client.js +4787 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +1429 -0
- package/lib/typert.host.js +26 -0
- package/lib/types/client/DrawResultCard.d.ts +58 -0
- package/lib/types/client/DrawResultCard.d.ts.map +1 -0
- package/lib/types/client/DrawResultCard.js +48 -0
- package/lib/types/client/DrawSettingsTab.d.ts +31 -0
- package/lib/types/client/DrawSettingsTab.d.ts.map +1 -0
- package/lib/types/client/DrawSettingsTab.js +129 -0
- package/lib/types/client/index.d.ts +35 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/index.js +98 -0
- package/lib/types/client/locales.d.ts +14 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/locales.js +57 -0
- package/lib/types/client/present.d.ts +80 -0
- package/lib/types/client/present.d.ts.map +1 -0
- package/lib/types/client/present.js +86 -0
- package/lib/types/client/remote.d.ts +268 -0
- package/lib/types/client/remote.d.ts.map +1 -0
- package/lib/types/client/remote.js +15 -0
- package/lib/types/client/styles.d.ts +11 -0
- package/lib/types/client/styles.d.ts.map +1 -0
- package/lib/types/client/styles.js +43 -0
- package/lib/types/config.d.ts +160 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/config.js +230 -0
- package/lib/types/drawer.d.ts +114 -0
- package/lib/types/drawer.d.ts.map +1 -0
- package/lib/types/drawer.js +138 -0
- package/lib/types/engine.d.ts +58 -0
- package/lib/types/engine.d.ts.map +1 -0
- package/lib/types/engine.js +135 -0
- package/lib/types/http.d.ts +89 -0
- package/lib/types/http.d.ts.map +1 -0
- package/lib/types/http.js +127 -0
- package/lib/types/index.d.ts +43 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index.js +78 -0
- package/lib/types/quota.d.ts +69 -0
- package/lib/types/quota.d.ts.map +1 -0
- package/lib/types/quota.js +56 -0
- package/lib/types/router.d.ts +141 -0
- package/lib/types/router.d.ts.map +1 -0
- package/lib/types/router.js +207 -0
- package/lib/types/sanitize.d.ts +40 -0
- package/lib/types/sanitize.d.ts.map +1 -0
- package/lib/types/sanitize.js +103 -0
- package/lib/types/service.d.ts +59 -0
- package/lib/types/service.d.ts.map +1 -0
- package/lib/types/service.js +131 -0
- package/lib/types/session-events.d.ts +66 -0
- package/lib/types/session-events.d.ts.map +1 -0
- package/lib/types/session-events.js +32 -0
- package/lib/types/tool.d.ts +30 -0
- package/lib/types/tool.d.ts.map +1 -0
- package/lib/types/tool.js +131 -0
- package/lib/types/translate.d.ts +64 -0
- package/lib/types/translate.d.ts.map +1 -0
- package/lib/types/translate.js +56 -0
- package/lib/types/typert.host.d.ts +250 -0
- package/lib/types/typert.host.d.ts.map +1 -0
- package/lib/types/typert.host.js +23 -0
- package/lib/types/version.d.ts +10 -0
- package/lib/types/version.d.ts.map +1 -0
- package/lib/types/version.js +9 -0
- package/lib/types/wire.d.ts +699 -0
- package/lib/types/wire.d.ts.map +1 -0
- package/lib/types/wire.js +273 -0
- package/lib/wire-Cc4JZ3jR.js +4370 -0
- package/package.json +179 -0
- package/src/client/DrawResultCard.tsx +100 -0
- package/src/client/DrawSettingsTab.tsx +159 -0
- package/src/client/index.ts +123 -0
- package/src/client/locales.ts +84 -0
- package/src/client/present.ts +137 -0
- package/src/client/remote.ts +44 -0
- package/src/client/styles.ts +44 -0
- package/src/config.ts +358 -0
- package/src/drawer.ts +234 -0
- package/src/engine.ts +182 -0
- package/src/http.ts +161 -0
- package/src/index.ts +93 -0
- package/src/quota.ts +98 -0
- package/src/router.ts +309 -0
- package/src/sanitize.ts +113 -0
- package/src/service.ts +169 -0
- package/src/session-events.ts +70 -0
- package/src/tool.ts +145 -0
- package/src/translate.ts +101 -0
- package/src/typert.host.ts +25 -0
- package/src/version.ts +10 -0
- package/src/wire.ts +417 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure presentation projections for the dsh-draw browser half: the tool
|
|
3
|
+
* result value and the settings snapshot are folded into view models that the
|
|
4
|
+
* React components render without further logic. Everything here is a pure
|
|
5
|
+
* function of its input, so tests pin the projections without a DOM.
|
|
6
|
+
*
|
|
7
|
+
* @module dsh-draw/client/present
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
|
11
|
+
import type { DrawStatusSnapshot } from '../wire.ts'
|
|
12
|
+
|
|
13
|
+
/** One image of the presented result card. */
|
|
14
|
+
export interface PresentedImage {
|
|
15
|
+
attachmentId: string
|
|
16
|
+
name: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The presented result card model. */
|
|
20
|
+
export interface PresentedDrawResult {
|
|
21
|
+
/** Engine id that produced the images. */
|
|
22
|
+
engine: string
|
|
23
|
+
/** Engine model name. */
|
|
24
|
+
model: string
|
|
25
|
+
/** Whether an earlier engine failed first. */
|
|
26
|
+
fallbackUsed: boolean
|
|
27
|
+
/** Image names in result order. */
|
|
28
|
+
images: readonly PresentedImage[]
|
|
29
|
+
/** Quota line: generations and bytes used. */
|
|
30
|
+
quota: { generations: number; bytes: number }
|
|
31
|
+
/** Effective limits. */
|
|
32
|
+
limits: { maxGenerations: number; maxBytes: number }
|
|
33
|
+
/** Original tool args (the regenerate input); undefined while unknown. */
|
|
34
|
+
args: Record<string, unknown> | undefined
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Project one settled `image_generate` tool block onto the card model.
|
|
39
|
+
*
|
|
40
|
+
* @param block - the frozen tool-call block (running or settled).
|
|
41
|
+
* @returns the presented model, or `undefined` when the block is not a settled
|
|
42
|
+
* image_generate result.
|
|
43
|
+
*/
|
|
44
|
+
export function presentDrawResult(block: ToolCallBlock): PresentedDrawResult | undefined {
|
|
45
|
+
if (!('kind' in block) || block.kind !== 'tool-result' || block.isError) return undefined
|
|
46
|
+
if (block.call?.name !== 'image_generate') return undefined
|
|
47
|
+
const value = (block.meta as { engine?: unknown; model?: unknown; fallbackUsed?: unknown; images?: unknown; quota?: unknown; limits?: unknown } | undefined) ?? {}
|
|
48
|
+
const images: PresentedImage[] = []
|
|
49
|
+
if (Array.isArray(value.images)) {
|
|
50
|
+
for (const raw of value.images) {
|
|
51
|
+
const image = raw as { attachmentId?: unknown; name?: unknown }
|
|
52
|
+
if (typeof image?.attachmentId === 'string') {
|
|
53
|
+
images.push({ attachmentId: image.attachmentId, name: typeof image.name === 'string' ? image.name : 'image' })
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const quota = (value.quota ?? {}) as { generations?: unknown; bytes?: unknown }
|
|
58
|
+
const limits = (value.limits ?? {}) as { maxGenerations?: unknown; maxBytes?: unknown }
|
|
59
|
+
let args: Record<string, unknown> | undefined
|
|
60
|
+
if (block.call?.argsRaw !== undefined && block.call.argsRaw !== '') {
|
|
61
|
+
try {
|
|
62
|
+
const parsed = JSON.parse(block.call.argsRaw) as unknown
|
|
63
|
+
args = typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : undefined
|
|
64
|
+
} catch {
|
|
65
|
+
args = undefined
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
engine: typeof value.engine === 'string' ? value.engine : 'unknown',
|
|
70
|
+
model: typeof value.model === 'string' ? value.model : 'unknown',
|
|
71
|
+
fallbackUsed: value.fallbackUsed === true,
|
|
72
|
+
images,
|
|
73
|
+
quota: {
|
|
74
|
+
generations: typeof quota.generations === 'number' ? quota.generations : 0,
|
|
75
|
+
bytes: typeof quota.bytes === 'number' ? quota.bytes : 0,
|
|
76
|
+
},
|
|
77
|
+
limits: {
|
|
78
|
+
maxGenerations: typeof limits.maxGenerations === 'number' ? limits.maxGenerations : 0,
|
|
79
|
+
maxBytes: typeof limits.maxBytes === 'number' ? limits.maxBytes : 0,
|
|
80
|
+
},
|
|
81
|
+
args,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** One engine row of the presented settings panel. */
|
|
86
|
+
export interface PresentedEngineRow {
|
|
87
|
+
id: string
|
|
88
|
+
model: string
|
|
89
|
+
baseUrl: string
|
|
90
|
+
apiKeyRef: string
|
|
91
|
+
enabled: boolean
|
|
92
|
+
preferred: boolean
|
|
93
|
+
credentialConfigured: boolean
|
|
94
|
+
credentialSource?: string
|
|
95
|
+
credentialWritable: boolean
|
|
96
|
+
consecutiveFailures: number
|
|
97
|
+
coolingDown: boolean
|
|
98
|
+
lastError: string | null
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The presented settings snapshot. */
|
|
102
|
+
export interface PresentedDrawPanel {
|
|
103
|
+
pluginVersion: string
|
|
104
|
+
engines: readonly PresentedEngineRow[]
|
|
105
|
+
quota: { maxGenerationsPerSession: number; maxBytesPerSession: number }
|
|
106
|
+
requestTimeoutMs: number
|
|
107
|
+
maxImagesPerCall: number
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Project the panel snapshot onto the settings row model.
|
|
112
|
+
*
|
|
113
|
+
* @param snapshot - the `draw/status` wire snapshot.
|
|
114
|
+
* @returns the presented panel model.
|
|
115
|
+
*/
|
|
116
|
+
export function presentDrawPanel(snapshot: DrawStatusSnapshot): PresentedDrawPanel {
|
|
117
|
+
return {
|
|
118
|
+
pluginVersion: snapshot.pluginVersion,
|
|
119
|
+
engines: snapshot.engines.map(engine => ({
|
|
120
|
+
id: engine.id,
|
|
121
|
+
model: engine.model,
|
|
122
|
+
baseUrl: engine.baseUrl,
|
|
123
|
+
apiKeyRef: engine.apiKeyRef,
|
|
124
|
+
enabled: engine.enabled,
|
|
125
|
+
preferred: engine.preferred,
|
|
126
|
+
credentialConfigured: engine.credential.configured,
|
|
127
|
+
...(engine.credential.source !== undefined ? { credentialSource: engine.credential.source } : {}),
|
|
128
|
+
credentialWritable: engine.credential.writable,
|
|
129
|
+
consecutiveFailures: engine.health.consecutiveFailures,
|
|
130
|
+
coolingDown: engine.health.cooldownUntil !== null && engine.health.cooldownUntil > Date.now(),
|
|
131
|
+
lastError: engine.health.lastError,
|
|
132
|
+
})),
|
|
133
|
+
quota: { maxGenerationsPerSession: snapshot.quota.maxGenerationsPerSession, maxBytesPerSession: snapshot.quota.maxBytesPerSession },
|
|
134
|
+
requestTimeoutMs: snapshot.requestTimeoutMs,
|
|
135
|
+
maxImagesPerCall: snapshot.maxImagesPerCall,
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client-side Remote face of the `draw` namespace: the hand-written
|
|
3
|
+
* `TypertRemoteContribution` mounted through `ctx.remote.$mount`, plus the
|
|
4
|
+
* declaration merging that types `ctx.remote.draw`. The descriptor list is
|
|
5
|
+
* shared with the host `./typert` manifest (`../wire.ts`), so the two faces
|
|
6
|
+
* can never drift.
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-draw/client/remote
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { RemoteResult, TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol'
|
|
12
|
+
import { DRAW_INVOCATIONS } from '../wire.ts'
|
|
13
|
+
import type { CredentialActionResult, DrawProbeResult, DrawRegenerateResult, DrawStatusSnapshot } from '../wire.ts'
|
|
14
|
+
|
|
15
|
+
declare module '@deepseek-ai/dsh-typert-protocol' {
|
|
16
|
+
interface TypertRemoteNamespace$draw {
|
|
17
|
+
/** Read the panel snapshot (engines, health, credentials, quota). */
|
|
18
|
+
status: () => Promise<RemoteResult<DrawStatusSnapshot>>
|
|
19
|
+
/** Probe one engine's connectivity. */
|
|
20
|
+
probe: (engineId: string) => Promise<RemoteResult<DrawProbeResult>>
|
|
21
|
+
/** Store one API key under the engine's credential reference. */
|
|
22
|
+
setCredential: (engineId: string, value: string) => Promise<RemoteResult<CredentialActionResult>>
|
|
23
|
+
/** Remove a stored API key. */
|
|
24
|
+
unsetCredential: (engineId: string) => Promise<RemoteResult<CredentialActionResult>>
|
|
25
|
+
/** Re-run a generation from the result card (full drawer path). */
|
|
26
|
+
regenerate: (sessionId: string, args: Record<string, unknown>) => Promise<RemoteResult<DrawRegenerateResult>>
|
|
27
|
+
}
|
|
28
|
+
interface TypertRemoteMap {
|
|
29
|
+
'draw/status': () => Promise<RemoteResult<DrawStatusSnapshot>>
|
|
30
|
+
'draw/probe': (engineId: string) => Promise<RemoteResult<DrawProbeResult>>
|
|
31
|
+
'draw/setCredential': (engineId: string, value: string) => Promise<RemoteResult<CredentialActionResult>>
|
|
32
|
+
'draw/unsetCredential': (engineId: string) => Promise<RemoteResult<CredentialActionResult>>
|
|
33
|
+
'draw/regenerate': (sessionId: string, args: Record<string, unknown>) => Promise<RemoteResult<DrawRegenerateResult>>
|
|
34
|
+
}
|
|
35
|
+
interface TypertRemoteNamespaceMap {
|
|
36
|
+
draw: TypertRemoteNamespace$draw
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The client Remote contribution for the `draw` namespace. */
|
|
41
|
+
export const DRAW_REMOTE = Object.freeze({
|
|
42
|
+
package: 'dsh-draw',
|
|
43
|
+
descriptors: DRAW_INVOCATIONS,
|
|
44
|
+
} satisfies TypertRemoteContribution)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scoped inline stylesheet for the dsh-draw browser half. Standalone client
|
|
3
|
+
* bundles cannot use the in-repo CSS-module pipeline, so the styles live in
|
|
4
|
+
* one scoped `style` element; every class carries the `dshdraw-` prefix and
|
|
5
|
+
* the root node is the only position the stylesheet may leak from.
|
|
6
|
+
*
|
|
7
|
+
* @module dsh-draw/client/styles
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const STYLE_ID = 'dsh-draw-client-styles'
|
|
11
|
+
|
|
12
|
+
/** Install the scoped stylesheet once; returns the removal disposer. */
|
|
13
|
+
export function installDrawStyles(): () => void {
|
|
14
|
+
if (document.getElementById(STYLE_ID) !== null) return () => undefined
|
|
15
|
+
const style = document.createElement('style')
|
|
16
|
+
style.id = STYLE_ID
|
|
17
|
+
style.textContent = `
|
|
18
|
+
.dshdraw-card { display: flex; flex-direction: column; gap: 8px; padding: 4px 0; }
|
|
19
|
+
.dshdraw-grid { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
20
|
+
.dshdraw-figure { display: flex; flex-direction: column; gap: 4px; max-width: 320px; }
|
|
21
|
+
.dshdraw-figure img { max-width: 100%; border-radius: 6px; border: 1px solid var(--dsh-border, #d0d7de); }
|
|
22
|
+
.dshdraw-figure figcaption { font-size: 11px; opacity: 0.75; overflow-wrap: anywhere; }
|
|
23
|
+
.dshdraw-meta { font-size: 12px; opacity: 0.85; display: flex; flex-wrap: wrap; gap: 12px; }
|
|
24
|
+
.dshdraw-actions { display: flex; gap: 8px; }
|
|
25
|
+
.dshdraw-button {
|
|
26
|
+
border: 1px solid var(--dsh-border, #d0d7de); border-radius: 6px; background: transparent;
|
|
27
|
+
padding: 3px 10px; font-size: 12px; cursor: pointer; color: inherit;
|
|
28
|
+
}
|
|
29
|
+
.dshdraw-button:hover { background: var(--dsh-hover, rgba(0, 0, 0, 0.05)); }
|
|
30
|
+
.dshdraw-panel { display: flex; flex-direction: column; gap: 14px; }
|
|
31
|
+
.dshdraw-engine { border: 1px solid var(--dsh-border, #d0d7de); border-radius: 8px; padding: 10px 12px; display: flex; flex-direction: column; gap: 6px; }
|
|
32
|
+
.dshdraw-engine h4 { margin: 0; }
|
|
33
|
+
.dshdraw-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 12px; }
|
|
34
|
+
.dshdraw-row label { opacity: 0.8; min-width: 110px; }
|
|
35
|
+
.dshdraw-badge { border-radius: 999px; padding: 1px 8px; font-size: 11px; border: 1px solid var(--dsh-border, #d0d7de); }
|
|
36
|
+
.dshdraw-badge.ok { color: #1a7f37; border-color: #1a7f37; }
|
|
37
|
+
.dshdraw-badge.warn { color: #9a6700; border-color: #9a6700; }
|
|
38
|
+
.dshdraw-input { flex: 1; min-width: 180px; border: 1px solid var(--dsh-border, #d0d7de); border-radius: 6px; padding: 4px 8px; font-size: 12px; background: transparent; color: inherit; }
|
|
39
|
+
`
|
|
40
|
+
document.head.append(style)
|
|
41
|
+
return () => {
|
|
42
|
+
style.remove()
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin configuration for `dsh-draw` and its explicit resolve step.
|
|
3
|
+
* `resolveConfig` re-judges every default and bound so programmatic
|
|
4
|
+
* construction that bypasses Schemastery normalization still fails loud
|
|
5
|
+
* instead of running with hidden defaults (the explicit-resolve contract).
|
|
6
|
+
*
|
|
7
|
+
* @module dsh-draw/config
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import z from '@deepseek-ai/schemastery'
|
|
11
|
+
import type { ImageMediaType } from '@deepseek-ai/dsh-attachment'
|
|
12
|
+
|
|
13
|
+
/** Default per-generation HTTP timeout in milliseconds. */
|
|
14
|
+
export const DEFAULT_REQUEST_TIMEOUT_MS = 120_000
|
|
15
|
+
|
|
16
|
+
/** Ceiling for one generation call: engines can take minutes per image. */
|
|
17
|
+
export const MAX_REQUEST_TIMEOUT_MS = 600_000
|
|
18
|
+
|
|
19
|
+
/** Default cap on images a single call may produce. */
|
|
20
|
+
export const DEFAULT_MAX_IMAGES_PER_CALL = 4
|
|
21
|
+
|
|
22
|
+
/** Ceiling on images a single call may produce. */
|
|
23
|
+
export const MAX_IMAGES_PER_CALL = 10
|
|
24
|
+
|
|
25
|
+
/** Default prompt length cap (characters). */
|
|
26
|
+
export const DEFAULT_MAX_PROMPT_LENGTH = 4_000
|
|
27
|
+
|
|
28
|
+
/** Ceiling on the prompt length cap. */
|
|
29
|
+
export const MAX_PROMPT_LENGTH = 32_000
|
|
30
|
+
|
|
31
|
+
/** Default per-session generation-call cap. */
|
|
32
|
+
export const DEFAULT_MAX_GENERATIONS_PER_SESSION = 200
|
|
33
|
+
|
|
34
|
+
/** Ceiling on the per-session generation-call cap. */
|
|
35
|
+
export const MAX_GENERATIONS_PER_SESSION = 100_000
|
|
36
|
+
|
|
37
|
+
/** Default per-session image-byte cap (200 MiB). */
|
|
38
|
+
export const DEFAULT_MAX_BYTES_PER_SESSION = 200 * 1024 * 1024
|
|
39
|
+
|
|
40
|
+
/** Ceiling on the per-session image-byte cap (4 GiB). */
|
|
41
|
+
export const MAX_BYTES_PER_SESSION = 4 * 1024 * 1024 * 1024
|
|
42
|
+
|
|
43
|
+
/** Default consecutive failures before an engine enters cooldown. */
|
|
44
|
+
export const DEFAULT_FAILURE_THRESHOLD = 2
|
|
45
|
+
|
|
46
|
+
/** Ceiling on the consecutive-failure threshold. */
|
|
47
|
+
export const MAX_FAILURE_THRESHOLD = 10
|
|
48
|
+
|
|
49
|
+
/** Default engine cooldown after the failure threshold trips (ms). */
|
|
50
|
+
export const DEFAULT_COOLDOWN_MS = 60_000
|
|
51
|
+
|
|
52
|
+
/** Ceiling on the engine cooldown. */
|
|
53
|
+
export const MAX_COOLDOWN_MS = 3_600_000
|
|
54
|
+
|
|
55
|
+
/** Standard size vocabulary mapped per engine through {@link EngineConfig.sizeMap}. */
|
|
56
|
+
export type StandardSize = 'square' | 'landscape' | 'portrait' | 'auto'
|
|
57
|
+
|
|
58
|
+
/** Per-engine mapping from the standard size vocabulary to concrete engine sizes. */
|
|
59
|
+
export interface EngineSizeMap {
|
|
60
|
+
/** Square composition (the default). */
|
|
61
|
+
square: string
|
|
62
|
+
/** Wider-than-tall composition. */
|
|
63
|
+
landscape: string
|
|
64
|
+
/** Taller-than-wide composition. */
|
|
65
|
+
portrait: string
|
|
66
|
+
/** Engine-chosen composition; may alias one of the concrete sizes. */
|
|
67
|
+
auto: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Whether generated images arrive as base64 payloads or downloadable URLs. */
|
|
71
|
+
export type EngineResponseFormat = 'b64_json' | 'url'
|
|
72
|
+
|
|
73
|
+
/** One OpenAI-compatible image-generation endpoint the router may use. */
|
|
74
|
+
export interface EngineConfig {
|
|
75
|
+
/** Stable engine id used in tool args, results, and the settings panel. */
|
|
76
|
+
id: string
|
|
77
|
+
/** OpenAI-compatible API root, e.g. `https://api.openai.com/v1`; must not embed credentials. */
|
|
78
|
+
baseUrl: string
|
|
79
|
+
/** Engine model name sent in the request body, e.g. `gpt-image-1`. */
|
|
80
|
+
model: string
|
|
81
|
+
/** Credential reference (environment-variable name) the API key resolves from; never a literal key. */
|
|
82
|
+
apiKeyRef: string
|
|
83
|
+
/** Whether the router may use this engine (default true). */
|
|
84
|
+
enabled?: boolean
|
|
85
|
+
/** Standard-size → concrete-size translation (default: the OpenAI sizes). */
|
|
86
|
+
sizeMap?: EngineSizeMap
|
|
87
|
+
/** Whether the engine accepts the `quality` request field (default false). */
|
|
88
|
+
qualitySupported?: boolean
|
|
89
|
+
/** Whether the engine accepts the `style` request field (default false). */
|
|
90
|
+
styleSupported?: boolean
|
|
91
|
+
/** How generated bytes arrive: base64 in the response or a URL to download (default `b64_json`). */
|
|
92
|
+
responseFormat?: EngineResponseFormat
|
|
93
|
+
/** Declared media type of the bytes (default `image/png`). */
|
|
94
|
+
imageMediaType?: ImageMediaType
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Plugin configuration. */
|
|
98
|
+
export interface Config {
|
|
99
|
+
/** Ordered engine chain; the router walks it top-down and falls back on failure. */
|
|
100
|
+
engines?: EngineConfig[]
|
|
101
|
+
/** Engine id the router prefers; defaults to the first configured engine. Must name a configured engine. */
|
|
102
|
+
defaultEngine?: string
|
|
103
|
+
/** Per-generation HTTP timeout in milliseconds (default 120000). */
|
|
104
|
+
requestTimeoutMs?: number
|
|
105
|
+
/** Cap on images one call may produce (default 4). */
|
|
106
|
+
maxImagesPerCall?: number
|
|
107
|
+
/** Prompt length cap in characters (default 4000). */
|
|
108
|
+
maxPromptLength?: number
|
|
109
|
+
/** Per-session generation-call cap (default 200). */
|
|
110
|
+
maxGenerationsPerSession?: number
|
|
111
|
+
/** Per-session generated-image byte cap (default 209715200). */
|
|
112
|
+
maxBytesPerSession?: number
|
|
113
|
+
/** Consecutive failures before an engine enters cooldown (default 2). */
|
|
114
|
+
failureThreshold?: number
|
|
115
|
+
/** Engine cooldown in milliseconds after the failure threshold trips (default 60000). */
|
|
116
|
+
cooldownMs?: number
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The two shipped engine presets: OpenAI Images and Zhipu CogView. */
|
|
120
|
+
export const DEFAULT_ENGINES: ReadonlyArray<Required<EngineConfig>> = Object.freeze([
|
|
121
|
+
Object.freeze({
|
|
122
|
+
id: 'openai',
|
|
123
|
+
baseUrl: 'https://api.openai.com/v1',
|
|
124
|
+
model: 'gpt-image-1',
|
|
125
|
+
apiKeyRef: 'OPENAI_API_KEY',
|
|
126
|
+
enabled: true,
|
|
127
|
+
sizeMap: Object.freeze({ square: '1024x1024', landscape: '1536x1024', portrait: '1024x1536', auto: 'auto' }),
|
|
128
|
+
qualitySupported: true,
|
|
129
|
+
styleSupported: true,
|
|
130
|
+
responseFormat: 'b64_json',
|
|
131
|
+
imageMediaType: 'image/png',
|
|
132
|
+
}),
|
|
133
|
+
Object.freeze({
|
|
134
|
+
id: 'cogview',
|
|
135
|
+
baseUrl: 'https://open.bigmodel.cn/api/paas/v4',
|
|
136
|
+
model: 'cogview-3-flash',
|
|
137
|
+
apiKeyRef: 'ZHIPU_API_KEY',
|
|
138
|
+
enabled: true,
|
|
139
|
+
sizeMap: Object.freeze({ square: '1024x1024', landscape: '1344x768', portrait: '768x1344', auto: '1024x1024' }),
|
|
140
|
+
qualitySupported: false,
|
|
141
|
+
styleSupported: false,
|
|
142
|
+
responseFormat: 'url',
|
|
143
|
+
imageMediaType: 'image/png',
|
|
144
|
+
}),
|
|
145
|
+
])
|
|
146
|
+
|
|
147
|
+
/** Default size map for user-configured engines (the OpenAI vocabulary). */
|
|
148
|
+
export const DEFAULT_SIZE_MAP: Readonly<EngineSizeMap> = Object.freeze({
|
|
149
|
+
square: '1024x1024',
|
|
150
|
+
landscape: '1536x1024',
|
|
151
|
+
portrait: '1024x1536',
|
|
152
|
+
auto: 'auto',
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
/** Fully resolved per-engine configuration. */
|
|
156
|
+
export interface ResolvedEngineConfig {
|
|
157
|
+
/** Stable engine id. */
|
|
158
|
+
id: string
|
|
159
|
+
/** OpenAI-compatible API root without credentials. */
|
|
160
|
+
baseUrl: string
|
|
161
|
+
/** Engine model name. */
|
|
162
|
+
model: string
|
|
163
|
+
/** Credential reference the API key resolves from. */
|
|
164
|
+
apiKeyRef: string
|
|
165
|
+
/** Whether the router may use this engine. */
|
|
166
|
+
enabled: boolean
|
|
167
|
+
/** Standard-size → concrete-size translation. */
|
|
168
|
+
sizeMap: EngineSizeMap
|
|
169
|
+
/** Whether the engine accepts the `quality` request field. */
|
|
170
|
+
qualitySupported: boolean
|
|
171
|
+
/** Whether the engine accepts the `style` request field. */
|
|
172
|
+
styleSupported: boolean
|
|
173
|
+
/** How generated bytes arrive. */
|
|
174
|
+
responseFormat: EngineResponseFormat
|
|
175
|
+
/** Declared media type of the bytes. */
|
|
176
|
+
imageMediaType: ImageMediaType
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Fully resolved configuration captured at plugin load. */
|
|
180
|
+
export interface ResolvedConfig {
|
|
181
|
+
/** Ordered engine chain. */
|
|
182
|
+
engines: readonly ResolvedEngineConfig[]
|
|
183
|
+
/** Engine id the router prefers. */
|
|
184
|
+
defaultEngine: string
|
|
185
|
+
/** Per-generation HTTP timeout in milliseconds. */
|
|
186
|
+
requestTimeoutMs: number
|
|
187
|
+
/** Cap on images one call may produce. */
|
|
188
|
+
maxImagesPerCall: number
|
|
189
|
+
/** Prompt length cap in characters. */
|
|
190
|
+
maxPromptLength: number
|
|
191
|
+
/** Per-session generation-call cap. */
|
|
192
|
+
maxGenerationsPerSession: number
|
|
193
|
+
/** Per-session generated-image byte cap. */
|
|
194
|
+
maxBytesPerSession: number
|
|
195
|
+
/** Consecutive failures before an engine enters cooldown. */
|
|
196
|
+
failureThreshold: number
|
|
197
|
+
/** Engine cooldown in milliseconds after the failure threshold trips. */
|
|
198
|
+
cooldownMs: number
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Schemastery schema for loader-validated configuration. */
|
|
202
|
+
export const Config: z<Config> = z.object({
|
|
203
|
+
engines: z.array(z.object({
|
|
204
|
+
id: z.string().min(1).max(64),
|
|
205
|
+
baseUrl: z.string().min(1).max(2048),
|
|
206
|
+
model: z.string().min(1).max(128),
|
|
207
|
+
apiKeyRef: z.string().min(1).max(128),
|
|
208
|
+
enabled: z.boolean(),
|
|
209
|
+
sizeMap: z.object({
|
|
210
|
+
square: z.string().min(1).max(64),
|
|
211
|
+
landscape: z.string().min(1).max(64),
|
|
212
|
+
portrait: z.string().min(1).max(64),
|
|
213
|
+
auto: z.string().min(1).max(64),
|
|
214
|
+
}),
|
|
215
|
+
qualitySupported: z.boolean(),
|
|
216
|
+
styleSupported: z.boolean(),
|
|
217
|
+
responseFormat: z.union(['b64_json', 'url'] as const),
|
|
218
|
+
imageMediaType: z.union(['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const),
|
|
219
|
+
})).default([...DEFAULT_ENGINES]),
|
|
220
|
+
defaultEngine: z.string().min(1).max(64).default('openai'),
|
|
221
|
+
requestTimeoutMs: z.number().min(1_000).max(MAX_REQUEST_TIMEOUT_MS).default(DEFAULT_REQUEST_TIMEOUT_MS),
|
|
222
|
+
maxImagesPerCall: z.number().min(1).max(MAX_IMAGES_PER_CALL).default(DEFAULT_MAX_IMAGES_PER_CALL),
|
|
223
|
+
maxPromptLength: z.number().min(1).max(MAX_PROMPT_LENGTH).default(DEFAULT_MAX_PROMPT_LENGTH),
|
|
224
|
+
maxGenerationsPerSession: z.number().min(1).max(MAX_GENERATIONS_PER_SESSION).default(DEFAULT_MAX_GENERATIONS_PER_SESSION),
|
|
225
|
+
maxBytesPerSession: z.number().min(1024 * 1024).max(MAX_BYTES_PER_SESSION).default(DEFAULT_MAX_BYTES_PER_SESSION),
|
|
226
|
+
failureThreshold: z.number().min(1).max(MAX_FAILURE_THRESHOLD).default(DEFAULT_FAILURE_THRESHOLD),
|
|
227
|
+
cooldownMs: z.number().min(1_000).max(MAX_COOLDOWN_MS).default(DEFAULT_COOLDOWN_MS),
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
const ENGINE_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/u
|
|
231
|
+
|
|
232
|
+
/** Credential references are environment-variable names (POSIX identifiers). */
|
|
233
|
+
const API_KEY_REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/u
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Resolve raw config to the runtime policy, re-validating defaults and bounds
|
|
237
|
+
* plus the cross-field facts a per-field schema cannot see (duplicate engine
|
|
238
|
+
* ids, credential-bearing base URLs, an unknown default engine).
|
|
239
|
+
*
|
|
240
|
+
* @param config - raw loader config; `undefined` for a bare row.
|
|
241
|
+
* @returns the frozen resolved config.
|
|
242
|
+
* @throws on any invalid value (fail-loud at load).
|
|
243
|
+
*/
|
|
244
|
+
export function resolveConfig(config: Config | undefined): ResolvedConfig {
|
|
245
|
+
const rawEngines: readonly EngineConfig[] = config?.engines ?? DEFAULT_ENGINES
|
|
246
|
+
const engines: ResolvedEngineConfig[] = []
|
|
247
|
+
const seen = new Set<string>()
|
|
248
|
+
for (const engine of rawEngines) {
|
|
249
|
+
const id = typeof engine?.id === 'string' ? engine.id : ''
|
|
250
|
+
if (!ENGINE_ID_PATTERN.test(id)) {
|
|
251
|
+
throw new Error(`dsh-draw: engine id ${JSON.stringify(id)} must match ${ENGINE_ID_PATTERN}`)
|
|
252
|
+
}
|
|
253
|
+
if (seen.has(id)) throw new Error(`dsh-draw: duplicate engine id ${JSON.stringify(id)}`)
|
|
254
|
+
seen.add(id)
|
|
255
|
+
const baseUrl = engine.baseUrl ?? ''
|
|
256
|
+
let parsed: URL
|
|
257
|
+
try {
|
|
258
|
+
parsed = new URL(baseUrl)
|
|
259
|
+
} catch {
|
|
260
|
+
throw new Error(`dsh-draw: engine "${id}" baseUrl ${JSON.stringify(baseUrl)} is not a valid URL`)
|
|
261
|
+
}
|
|
262
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
263
|
+
throw new Error(`dsh-draw: engine "${id}" baseUrl must use http or https`)
|
|
264
|
+
}
|
|
265
|
+
if (parsed.username !== '' || parsed.password !== '') {
|
|
266
|
+
throw new Error(`dsh-draw: engine "${id}" baseUrl must not embed credentials — use the apiKeyRef credential reference instead`)
|
|
267
|
+
}
|
|
268
|
+
const model = typeof engine.model === 'string' && engine.model.length > 0 && engine.model.length <= 128 ? engine.model : ''
|
|
269
|
+
if (model === '') throw new Error(`dsh-draw: engine "${id}" model must be a non-empty string of at most 128 characters`)
|
|
270
|
+
const apiKeyRef = typeof engine.apiKeyRef === 'string' && API_KEY_REF_PATTERN.test(engine.apiKeyRef) ? engine.apiKeyRef : ''
|
|
271
|
+
if (apiKeyRef === '') throw new Error(`dsh-draw: engine "${id}" apiKeyRef ${JSON.stringify(engine.apiKeyRef)} must be an environment-variable name`)
|
|
272
|
+
const sizeMap = engine.sizeMap === undefined ? DEFAULT_SIZE_MAP : engine.sizeMap
|
|
273
|
+
for (const key of ['square', 'landscape', 'portrait', 'auto'] as const) {
|
|
274
|
+
const value = sizeMap[key]
|
|
275
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > 64) {
|
|
276
|
+
throw new Error(`dsh-draw: engine "${id}" sizeMap.${key} must be a non-empty string of at most 64 characters`)
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
const enabled = engine.enabled ?? true
|
|
280
|
+
if (typeof enabled !== 'boolean') throw new TypeError(`dsh-draw: engine "${id}" enabled must be a boolean`)
|
|
281
|
+
const qualitySupported = engine.qualitySupported ?? false
|
|
282
|
+
if (typeof qualitySupported !== 'boolean') throw new TypeError(`dsh-draw: engine "${id}" qualitySupported must be a boolean`)
|
|
283
|
+
const styleSupported = engine.styleSupported ?? false
|
|
284
|
+
if (typeof styleSupported !== 'boolean') throw new TypeError(`dsh-draw: engine "${id}" styleSupported must be a boolean`)
|
|
285
|
+
const responseFormat = engine.responseFormat ?? 'b64_json'
|
|
286
|
+
if (responseFormat !== 'b64_json' && responseFormat !== 'url') {
|
|
287
|
+
throw new Error(`dsh-draw: engine "${id}" responseFormat must be "b64_json" or "url"`)
|
|
288
|
+
}
|
|
289
|
+
const imageMediaType = engine.imageMediaType ?? 'image/png'
|
|
290
|
+
if (imageMediaType !== 'image/png' && imageMediaType !== 'image/jpeg' && imageMediaType !== 'image/webp' && imageMediaType !== 'image/gif') {
|
|
291
|
+
throw new Error(`dsh-draw: engine "${id}" imageMediaType ${JSON.stringify(imageMediaType)} is not a supported raster type`)
|
|
292
|
+
}
|
|
293
|
+
engines.push({
|
|
294
|
+
id,
|
|
295
|
+
baseUrl: baseUrl.replace(/\/+$/u, ''),
|
|
296
|
+
model,
|
|
297
|
+
apiKeyRef,
|
|
298
|
+
enabled,
|
|
299
|
+
sizeMap: Object.freeze({ ...sizeMap }),
|
|
300
|
+
qualitySupported,
|
|
301
|
+
styleSupported,
|
|
302
|
+
responseFormat,
|
|
303
|
+
imageMediaType,
|
|
304
|
+
})
|
|
305
|
+
}
|
|
306
|
+
if (engines.length === 0) throw new Error('dsh-draw: at least one engine must be configured')
|
|
307
|
+
|
|
308
|
+
const defaultEngine = config?.defaultEngine ?? engines[0]!.id
|
|
309
|
+
if (!seen.has(defaultEngine)) {
|
|
310
|
+
throw new Error(`dsh-draw: defaultEngine ${JSON.stringify(defaultEngine)} does not name a configured engine`)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const requestTimeoutMs = config?.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS
|
|
314
|
+
if (!Number.isFinite(requestTimeoutMs) || requestTimeoutMs < 1_000 || requestTimeoutMs > MAX_REQUEST_TIMEOUT_MS) {
|
|
315
|
+
throw new Error(`dsh-draw: config.requestTimeoutMs must be a finite number between 1000 and ${MAX_REQUEST_TIMEOUT_MS}`)
|
|
316
|
+
}
|
|
317
|
+
const maxImagesPerCall = config?.maxImagesPerCall ?? DEFAULT_MAX_IMAGES_PER_CALL
|
|
318
|
+
if (!Number.isInteger(maxImagesPerCall) || maxImagesPerCall < 1 || maxImagesPerCall > MAX_IMAGES_PER_CALL) {
|
|
319
|
+
throw new Error(`dsh-draw: config.maxImagesPerCall must be an integer between 1 and ${MAX_IMAGES_PER_CALL}`)
|
|
320
|
+
}
|
|
321
|
+
const maxPromptLength = config?.maxPromptLength ?? DEFAULT_MAX_PROMPT_LENGTH
|
|
322
|
+
if (!Number.isInteger(maxPromptLength) || maxPromptLength < 1 || maxPromptLength > MAX_PROMPT_LENGTH) {
|
|
323
|
+
throw new Error(`dsh-draw: config.maxPromptLength must be an integer between 1 and ${MAX_PROMPT_LENGTH}`)
|
|
324
|
+
}
|
|
325
|
+
const maxGenerationsPerSession = config?.maxGenerationsPerSession ?? DEFAULT_MAX_GENERATIONS_PER_SESSION
|
|
326
|
+
if (!Number.isInteger(maxGenerationsPerSession) || maxGenerationsPerSession < 1 || maxGenerationsPerSession > MAX_GENERATIONS_PER_SESSION) {
|
|
327
|
+
throw new Error(`dsh-draw: config.maxGenerationsPerSession must be an integer between 1 and ${MAX_GENERATIONS_PER_SESSION}`)
|
|
328
|
+
}
|
|
329
|
+
const maxBytesPerSession = config?.maxBytesPerSession ?? DEFAULT_MAX_BYTES_PER_SESSION
|
|
330
|
+
if (!Number.isInteger(maxBytesPerSession) || maxBytesPerSession < 1024 * 1024 || maxBytesPerSession > MAX_BYTES_PER_SESSION) {
|
|
331
|
+
throw new Error(`dsh-draw: config.maxBytesPerSession must be an integer between 1048576 and ${MAX_BYTES_PER_SESSION}`)
|
|
332
|
+
}
|
|
333
|
+
const failureThreshold = config?.failureThreshold ?? DEFAULT_FAILURE_THRESHOLD
|
|
334
|
+
if (!Number.isInteger(failureThreshold) || failureThreshold < 1 || failureThreshold > MAX_FAILURE_THRESHOLD) {
|
|
335
|
+
throw new Error(`dsh-draw: config.failureThreshold must be an integer between 1 and ${MAX_FAILURE_THRESHOLD}`)
|
|
336
|
+
}
|
|
337
|
+
const cooldownMs = config?.cooldownMs ?? DEFAULT_COOLDOWN_MS
|
|
338
|
+
if (!Number.isInteger(cooldownMs) || cooldownMs < 1_000 || cooldownMs > MAX_COOLDOWN_MS) {
|
|
339
|
+
throw new Error(`dsh-draw: config.cooldownMs must be an integer between 1000 and ${MAX_COOLDOWN_MS}`)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return Object.freeze({
|
|
343
|
+
engines: Object.freeze(engines),
|
|
344
|
+
defaultEngine,
|
|
345
|
+
requestTimeoutMs,
|
|
346
|
+
maxImagesPerCall,
|
|
347
|
+
maxPromptLength,
|
|
348
|
+
maxGenerationsPerSession,
|
|
349
|
+
maxBytesPerSession,
|
|
350
|
+
failureThreshold,
|
|
351
|
+
cooldownMs,
|
|
352
|
+
})
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Engine id by which an engine is addressed, or `undefined` for an unknown id. */
|
|
356
|
+
export function engineById(config: ResolvedConfig, id: string): ResolvedEngineConfig | undefined {
|
|
357
|
+
return config.engines.find(engine => engine.id === id)
|
|
358
|
+
}
|