@dickpy/dsh-imagegen 1.2.3 → 1.3.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/README.md +12 -11
- package/lib/client.js +2030 -903
- package/lib/client.js.map +1 -1
- package/lib/index.js +662 -117
- package/package.json +2 -2
- package/src/agent-image-tools.ts +132 -30
- package/src/client/ImageGenPanel.tsx +55 -19
- package/src/client/SettingsCard.tsx +505 -196
- package/src/client/channels-form.ts +263 -0
- package/src/client/image-toolview.tsx +19 -12
- package/src/client/locales.ts +110 -0
- package/src/client/settings-card.module.css +487 -0
- package/src/client/settings-scope.ts +56 -8
- package/src/engine.ts +44 -30
- package/src/gallery-store.ts +6 -0
- package/src/generation-runtime.ts +32 -5
- package/src/history-store.ts +6 -0
- package/src/image-format.ts +11 -0
- package/src/index.ts +153 -47
- package/src/model-catalog.ts +98 -0
- package/src/presets.ts +63 -0
- package/src/protocol.ts +78 -5
- package/src/routes.ts +192 -24
package/src/engine.ts
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type { GeneratedImage, GenerateRequest, GenerateResult } from './protocol.ts'
|
|
12
|
+
import { detectImageMime } from './image-format.ts'
|
|
13
|
+
import { modelFamily } from './model-catalog.ts'
|
|
12
14
|
|
|
13
15
|
/** The upstream credentials the panel's settings card configures. */
|
|
14
16
|
export interface UpstreamConfig {
|
|
@@ -42,12 +44,21 @@ const MAX_EDIT_IMAGE_BYTES = 10 * 1024 * 1024
|
|
|
42
44
|
/** Sizes dall-e-3 accepts; anything else falls back to its square default. */
|
|
43
45
|
const DALLE3_SIZES = new Set(['1024x1024', '1792x1024', '1024x1792'])
|
|
44
46
|
|
|
47
|
+
/** The wire model id for a request: `upstream` (host-filled alias mapping)
|
|
48
|
+
* wins, then the alias, then the family default. */
|
|
49
|
+
function wireModel(request: GenerateRequest): string {
|
|
50
|
+
const upstream = request.upstream?.trim()
|
|
51
|
+
if (upstream !== undefined && upstream !== '') return upstream
|
|
52
|
+
const alias = request.model.trim()
|
|
53
|
+
return alias === '' ? 'gpt-image-2' : alias
|
|
54
|
+
}
|
|
55
|
+
|
|
45
56
|
/** Whether the model is an xAI Grok Imagine model (grok-imagine-image,
|
|
46
57
|
* grok-imagine-image-2.0, …). Grok Imagine speaks JSON on both endpoints
|
|
47
58
|
* and exposes its own aspect-ratio / response-format knobs instead of the
|
|
48
59
|
* OpenAI size/quality/detail passthrough. */
|
|
49
60
|
function isGrokImagine(model: string): boolean {
|
|
50
|
-
return
|
|
61
|
+
return modelFamily(model) === 'grok'
|
|
51
62
|
}
|
|
52
63
|
|
|
53
64
|
/** Whether the model belongs to the Google Nano Banana family (nanobanana2 /
|
|
@@ -56,23 +67,29 @@ function isGrokImagine(model: string): boolean {
|
|
|
56
67
|
* aspect_ratio / image_size vocabulary instead of the OpenAI size/quality
|
|
57
68
|
* passthrough. */
|
|
58
69
|
function isNanoBanana(model: string): boolean {
|
|
59
|
-
|
|
60
|
-
return (
|
|
61
|
-
model === 'gemini-3-pro-image' || model === 'gemini-3-pro-image-preview' ||
|
|
62
|
-
model === 'gemini-3.1-flash-image' || model === 'gemini-3.1-flash-image-preview' ||
|
|
63
|
-
model === 'gemini-3.1-flash-lite-image' ||
|
|
64
|
-
model === 'gemini-2.5-flash-image'
|
|
65
|
-
)
|
|
70
|
+
return modelFamily(model) === 'nanobanana'
|
|
66
71
|
}
|
|
67
72
|
|
|
68
73
|
/** Whether the model belongs to the ByteDance Seedream family (seedream-5.0-pro,
|
|
69
74
|
* seedream-5.0, seedream-4.x, doubao-seedream-…). OpenAI-compatible gateways
|
|
70
75
|
* serve Seedream through a unified generate-and-edit architecture:
|
|
71
|
-
* generation AND editing both go to /images/generations
|
|
72
|
-
* a JSON URL / data-URL array
|
|
73
|
-
* `size` carries the aspect ratio (or exact pixels). */
|
|
76
|
+
* generation AND editing both go to /images/generations and reference images
|
|
77
|
+
* are a JSON URL / data-URL array. */
|
|
74
78
|
function isSeedream(model: string): boolean {
|
|
75
|
-
return
|
|
79
|
+
return modelFamily(model) === 'seedream'
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Whether this is the official Volcengine Ark model naming convention. */
|
|
83
|
+
function isVolcSeedream(model: string): boolean {
|
|
84
|
+
return /^doubao-seedream(?:-|$)/i.test(model.trim())
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Volcengine uses `size` for the output tier, not the panel's aspect ratio. */
|
|
88
|
+
function seedreamSize(quality: string): string {
|
|
89
|
+
// Seedream 5.0 Pro currently caps at 2K; keep 4K requests valid by
|
|
90
|
+
// degrading them to the highest supported tier instead of sending 4K.
|
|
91
|
+
if (quality === '1k') return '1K'
|
|
92
|
+
return '2K'
|
|
76
93
|
}
|
|
77
94
|
|
|
78
95
|
/** The panel's aspect ratios mapped to the closest OpenAI pixel size
|
|
@@ -165,7 +182,7 @@ function effectiveParams(request: GenerateRequest): {
|
|
|
165
182
|
resolution?: string
|
|
166
183
|
response_format?: string
|
|
167
184
|
} {
|
|
168
|
-
const model =
|
|
185
|
+
const model = wireModel(request)
|
|
169
186
|
// dall-e-3 has no quality/detail knobs and only produces one image.
|
|
170
187
|
if (model === 'dall-e-3') {
|
|
171
188
|
const pixel = OPENAI_SIZE_BY_RATIO[request.size]
|
|
@@ -206,20 +223,15 @@ function effectiveParams(request: GenerateRequest): {
|
|
|
206
223
|
response_format: 'b64_json',
|
|
207
224
|
}
|
|
208
225
|
}
|
|
209
|
-
// ByteDance Seedream:
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
226
|
+
// ByteDance Seedream: the official Volcengine Ark API uses `size` for the
|
|
227
|
+
// resolution tier (1K / 2K), not the panel's aspect-ratio value. It returns
|
|
228
|
+
// temporary URLs, so ask Ark for URL output and let the host download it.
|
|
229
|
+
// Other compatible gateways retain the base64 response fallback.
|
|
213
230
|
if (isSeedream(model)) {
|
|
214
231
|
return {
|
|
215
232
|
model,
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
: {},
|
|
219
|
-
...request.quality !== '' && request.quality !== 'auto'
|
|
220
|
-
? { resolution: request.quality === '4k' ? '2K' : request.quality.toUpperCase() }
|
|
221
|
-
: {},
|
|
222
|
-
response_format: 'b64_json',
|
|
233
|
+
size: seedreamSize(request.quality),
|
|
234
|
+
response_format: isVolcSeedream(model) ? 'url' : 'b64_json',
|
|
223
235
|
}
|
|
224
236
|
}
|
|
225
237
|
// OpenAI-compatible endpoints: nearest pixel size, clarity tiers mapped to
|
|
@@ -238,7 +250,7 @@ function effectiveParams(request: GenerateRequest): {
|
|
|
238
250
|
|
|
239
251
|
/** How many single-image requests to issue for the requested image count. */
|
|
240
252
|
function effectiveCount(request: GenerateRequest): number {
|
|
241
|
-
const model =
|
|
253
|
+
const model = wireModel(request)
|
|
242
254
|
if (model === 'dall-e-3') return 1
|
|
243
255
|
return clampCount(request.n)
|
|
244
256
|
}
|
|
@@ -250,7 +262,8 @@ async function normalizeItem(
|
|
|
250
262
|
): Promise<{ b64: string; mime: string; revisedPrompt?: string }> {
|
|
251
263
|
const revisedPrompt = typeof item.revised_prompt === 'string' ? item.revised_prompt : undefined
|
|
252
264
|
if (typeof item.b64_json === 'string') {
|
|
253
|
-
|
|
265
|
+
const b64 = bareBase64(item.b64_json)
|
|
266
|
+
return { b64, mime: detectImageMime(Buffer.from(b64, 'base64')) ?? 'image/png', revisedPrompt }
|
|
254
267
|
}
|
|
255
268
|
if (typeof item.url !== 'string' || item.url === '') {
|
|
256
269
|
throw new ImageGenError('upstream image item has neither b64_json nor url')
|
|
@@ -259,7 +272,7 @@ async function normalizeItem(
|
|
|
259
272
|
if (url.startsWith('data:')) {
|
|
260
273
|
const parsed = parseDataUrl(url)
|
|
261
274
|
if (parsed === undefined) throw new ImageGenError('upstream returned a malformed data: url')
|
|
262
|
-
return { b64: parsed.base64, mime: parsed.mime, revisedPrompt }
|
|
275
|
+
return { b64: parsed.base64, mime: detectImageMime(Buffer.from(parsed.base64, 'base64')) ?? parsed.mime, revisedPrompt }
|
|
263
276
|
}
|
|
264
277
|
const budget = requestSignal(undefined, IMAGE_FETCH_TIMEOUT_MS)
|
|
265
278
|
let response: Response
|
|
@@ -280,9 +293,10 @@ async function normalizeItem(
|
|
|
280
293
|
}
|
|
281
294
|
const buffer = Buffer.from(await response.arrayBuffer())
|
|
282
295
|
const contentType = response.headers.get('content-type')
|
|
283
|
-
const mime =
|
|
296
|
+
const mime = detectImageMime(buffer)
|
|
297
|
+
?? (contentType !== null && contentType !== ''
|
|
284
298
|
? contentType.split(';')[0]!.trim()
|
|
285
|
-
: mimeOfExtension(url) ?? 'image/png'
|
|
299
|
+
: mimeOfExtension(url) ?? 'image/png')
|
|
286
300
|
return { b64: buffer.toString('base64'), mime, revisedPrompt }
|
|
287
301
|
}
|
|
288
302
|
|
|
@@ -347,7 +361,7 @@ async function requestOneImage(
|
|
|
347
361
|
image: [request.image],
|
|
348
362
|
...params.size !== undefined ? { size: params.size } : {},
|
|
349
363
|
...params.resolution !== undefined ? { resolution: params.resolution } : {},
|
|
350
|
-
response_format: 'b64_json',
|
|
364
|
+
response_format: isVolcSeedream(params.model) ? 'url' : 'b64_json',
|
|
351
365
|
})
|
|
352
366
|
} else {
|
|
353
367
|
const form = new FormData()
|
package/src/gallery-store.ts
CHANGED
|
@@ -61,6 +61,8 @@ interface StoredEntry {
|
|
|
61
61
|
hash?: string
|
|
62
62
|
refName?: string
|
|
63
63
|
tags?: string[]
|
|
64
|
+
channelId?: string
|
|
65
|
+
channel?: string
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
/** The index.json shape. */
|
|
@@ -173,6 +175,8 @@ function toWire(entry: StoredEntry): HistoryEntry {
|
|
|
173
175
|
})),
|
|
174
176
|
...entry.refName === undefined ? {} : { refName: entry.refName },
|
|
175
177
|
...entry.tags === undefined ? {} : { tags: entry.tags },
|
|
178
|
+
...entry.channel === undefined ? {} : { channel: entry.channel },
|
|
179
|
+
...entry.channelId === undefined ? {} : { channelId: entry.channelId },
|
|
176
180
|
}
|
|
177
181
|
}
|
|
178
182
|
|
|
@@ -225,6 +229,8 @@ export async function appendGallery(input: HistoryEntryInput): Promise<GalleryAp
|
|
|
225
229
|
images: storedImages,
|
|
226
230
|
...hash === undefined ? {} : { hash },
|
|
227
231
|
...input.refName === undefined ? {} : { refName: input.refName },
|
|
232
|
+
...input.channelId === undefined ? {} : { channelId: input.channelId },
|
|
233
|
+
...input.channel === undefined ? {} : { channel: input.channel },
|
|
228
234
|
}
|
|
229
235
|
const merged = [entry, ...await readIndex()]
|
|
230
236
|
await writeIndex(merged)
|
|
@@ -2,13 +2,30 @@
|
|
|
2
2
|
* Shared host-side generation runtime. Both the browser routes and Agent tools
|
|
3
3
|
* submit to this one queue so persisted history and cancellation semantics stay
|
|
4
4
|
* identical regardless of where a request originated.
|
|
5
|
+
*
|
|
6
|
+
* Requests carry a channel id (host-filled by the route/tool resolution); the
|
|
7
|
+
* runtime picks that channel's upstream credentials, otherwise the default
|
|
8
|
+
* channel, and records a channel snapshot on the history entry so usage
|
|
9
|
+
* counters and filters survive channel deletion.
|
|
5
10
|
*/
|
|
6
11
|
|
|
7
12
|
import { randomUUID } from 'node:crypto'
|
|
8
|
-
import { generateImage, type UpstreamConfig } from './engine.ts'
|
|
13
|
+
import { generateImage, ImageGenError, type UpstreamConfig } from './engine.ts'
|
|
9
14
|
import { appendHistory } from './history-store.ts'
|
|
10
|
-
import type { GenerateRequest, GenerateResult, HistoryEntry, HistoryEntryInput } from './protocol.ts'
|
|
11
15
|
import { GenerationTaskQueue } from './task-queue.ts'
|
|
16
|
+
import type { ChannelConfig, GenerateRequest, GenerateResult, HistoryEntry, HistoryEntryInput } from './protocol.ts'
|
|
17
|
+
|
|
18
|
+
/** A channel with its resolved API key (the settings doc holds the key
|
|
19
|
+
* separately so redacted reads never expose it). */
|
|
20
|
+
export interface RuntimeChannel extends ChannelConfig {
|
|
21
|
+
apiKey: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The resolved channels view the runtime picks upstream credentials from. */
|
|
25
|
+
export interface ChannelsView {
|
|
26
|
+
channels: RuntimeChannel[]
|
|
27
|
+
defaultChannelId: string
|
|
28
|
+
}
|
|
12
29
|
|
|
13
30
|
export interface HistorySink {
|
|
14
31
|
append(entry: HistoryEntryInput): Promise<HistoryEntry[]>
|
|
@@ -18,14 +35,22 @@ export class ImageGenerationRuntime {
|
|
|
18
35
|
readonly queue: GenerationTaskQueue
|
|
19
36
|
|
|
20
37
|
constructor(
|
|
21
|
-
private readonly resolve: () =>
|
|
38
|
+
private readonly resolve: () => ChannelsView,
|
|
22
39
|
private readonly history: HistorySink = { append: appendHistory },
|
|
23
40
|
) {
|
|
24
41
|
this.queue = new GenerationTaskQueue((request, signal) => this.run(request, signal))
|
|
25
42
|
}
|
|
26
43
|
|
|
27
44
|
async run(request: GenerateRequest, signal?: AbortSignal): Promise<GenerateResult> {
|
|
28
|
-
const
|
|
45
|
+
const view = this.resolve()
|
|
46
|
+
const channel = view.channels.find(candidate => candidate.id === request.channelId)
|
|
47
|
+
?? view.channels.find(candidate => candidate.id === view.defaultChannelId)
|
|
48
|
+
?? view.channels[0]
|
|
49
|
+
if (channel === undefined) {
|
|
50
|
+
throw new ImageGenError('尚未配置任何渠道:请先在「设置 → 插件 → AI 生图」添加渠道并填写 API 地址与密钥', 'no-channels')
|
|
51
|
+
}
|
|
52
|
+
const upstream: UpstreamConfig = { apiUrl: channel.apiUrl, apiKey: channel.apiKey }
|
|
53
|
+
const result = await generateImage(upstream, request, { signal })
|
|
29
54
|
try {
|
|
30
55
|
const history = await this.history.append({
|
|
31
56
|
id: randomUUID(),
|
|
@@ -39,10 +64,12 @@ export class ImageGenerationRuntime {
|
|
|
39
64
|
n: request.n,
|
|
40
65
|
images: result.images,
|
|
41
66
|
...request.refName === undefined ? {} : { refName: request.refName },
|
|
67
|
+
...request.channelId === undefined ? {} : { channelId: request.channelId },
|
|
68
|
+
...request.channel === undefined ? {} : { channel: request.channel },
|
|
42
69
|
})
|
|
43
70
|
return { ...result, history }
|
|
44
71
|
} catch (error) {
|
|
45
72
|
return { ...result, historyError: error instanceof Error ? error.message : String(error) }
|
|
46
73
|
}
|
|
47
74
|
}
|
|
48
|
-
}
|
|
75
|
+
}
|
package/src/history-store.ts
CHANGED
|
@@ -47,6 +47,8 @@ interface StoredEntry {
|
|
|
47
47
|
n: number
|
|
48
48
|
images: StoredImage[]
|
|
49
49
|
refName?: string
|
|
50
|
+
channelId?: string
|
|
51
|
+
channel?: string
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
/** The index.json shape. */
|
|
@@ -151,6 +153,8 @@ function toWire(entry: StoredEntry): HistoryEntry {
|
|
|
151
153
|
...image.revisedPrompt === undefined ? {} : { revisedPrompt: image.revisedPrompt },
|
|
152
154
|
})),
|
|
153
155
|
...entry.refName === undefined ? {} : { refName: entry.refName },
|
|
156
|
+
...entry.channel === undefined ? {} : { channel: entry.channel },
|
|
157
|
+
...entry.channelId === undefined ? {} : { channelId: entry.channelId },
|
|
154
158
|
}
|
|
155
159
|
}
|
|
156
160
|
|
|
@@ -193,6 +197,8 @@ export async function appendHistory(input: HistoryEntryInput): Promise<HistoryEn
|
|
|
193
197
|
n: input.n,
|
|
194
198
|
images: storedImages,
|
|
195
199
|
...input.refName === undefined ? {} : { refName: input.refName },
|
|
200
|
+
...input.channelId === undefined ? {} : { channelId: input.channelId },
|
|
201
|
+
...input.channel === undefined ? {} : { channel: input.channel },
|
|
196
202
|
}
|
|
197
203
|
const merged = [entry, ...await readIndex()]
|
|
198
204
|
const kept = merged.slice(0, HISTORY_MAX)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Detect the supported raster format from its encoded bytes. */
|
|
2
|
+
export type SupportedImageMime = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif'
|
|
3
|
+
|
|
4
|
+
export function detectImageMime(data: Uint8Array): SupportedImageMime | undefined {
|
|
5
|
+
const startsWith = (...bytes: number[]): boolean => bytes.every((value, index) => data[index] === value)
|
|
6
|
+
if (startsWith(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) return 'image/png'
|
|
7
|
+
if (startsWith(0xff, 0xd8, 0xff)) return 'image/jpeg'
|
|
8
|
+
if (startsWith(0x47, 0x49, 0x46, 0x38, 0x37, 0x61) || startsWith(0x47, 0x49, 0x46, 0x38, 0x39, 0x61)) return 'image/gif'
|
|
9
|
+
if (startsWith(0x52, 0x49, 0x46, 0x46) && data[8] === 0x57 && data[9] === 0x45 && data[10] === 0x42 && data[11] === 0x50) return 'image/webp'
|
|
10
|
+
return undefined
|
|
11
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-imagegen — host half. Mounts the plugin's settings section (
|
|
3
|
-
*
|
|
4
|
-
* (loopback-only settings bridge +
|
|
5
|
-
* key host-side), and a
|
|
6
|
-
* (./client) renders the sidebar
|
|
2
|
+
* dsh-imagegen — host half. Mounts the plugin's settings section (channels
|
|
3
|
+
* with per-channel model catalogs on the host settings seam), the
|
|
4
|
+
* /api/dsh-imagegen route family (loopback-only settings bridge + presets /
|
|
5
|
+
* usage / image-generation proxy that keeps every API key host-side), and a
|
|
6
|
+
* system-prompt announcement. The browser half (./client) renders the sidebar
|
|
7
|
+
* entry and the split-pane generation studio.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
import type { Context } from '@deepseek-ai/cordis'
|
|
@@ -15,11 +16,11 @@ import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
|
15
16
|
import type {} from '@deepseek-ai/dsh-system-prompt'
|
|
16
17
|
import type {} from '@deepseek-ai/dsh-tools'
|
|
17
18
|
import type {} from '@deepseek-ai/dsh-attachment'
|
|
18
|
-
import { IMAGEGEN_SETTINGS_NAMESPACE } from './protocol.ts'
|
|
19
|
+
import { IMAGEGEN_SETTINGS_NAMESPACE, type ChannelConfig, type ModelMapping } from './protocol.ts'
|
|
19
20
|
import { makeRoutes, type SettingsSeam } from './routes.ts'
|
|
20
|
-
import { ImageGenerationRuntime } from './generation-runtime.ts'
|
|
21
|
+
import { ImageGenerationRuntime, type ChannelsView, type RuntimeChannel } from './generation-runtime.ts'
|
|
21
22
|
import { registerAgentImageTools } from './agent-image-tools.ts'
|
|
22
|
-
import {
|
|
23
|
+
import { presetById } from './presets.ts'
|
|
23
24
|
|
|
24
25
|
/** Stable cordis plugin name. */
|
|
25
26
|
export const name = 'imagegen'
|
|
@@ -40,7 +41,15 @@ export { checkForUpdate, clearUpdateCache, compareVersions, CURRENT_VERSION, ins
|
|
|
40
41
|
/** The branded settings namespace of this plugin (the card edits it). */
|
|
41
42
|
export const ImageGenSettingsNamespace = settingsNamespace(IMAGEGEN_SETTINGS_NAMESPACE)
|
|
42
43
|
|
|
43
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* Plugin config, validated by the same-named schemastery schema.
|
|
46
|
+
*
|
|
47
|
+
* Channels own the endpoint + model catalog. The API key of each channel lives
|
|
48
|
+
* in `channelSecrets` (a secret dict keyed by channel id) instead of inside the
|
|
49
|
+
* channel objects — dsh-settings redaction supports dict/array containers, but
|
|
50
|
+
* path ops cannot reach inside arrays, so a whole-array write must never carry
|
|
51
|
+
* secrets it would clobber.
|
|
52
|
+
*/
|
|
44
53
|
export interface Config {
|
|
45
54
|
/** Master switch for the plugin (routes, prompt section). */
|
|
46
55
|
enabled?: boolean
|
|
@@ -48,30 +57,49 @@ export interface Config {
|
|
|
48
57
|
announceToAgent?: boolean
|
|
49
58
|
/** Allow Agents to submit and retrieve image-generation tasks. */
|
|
50
59
|
allowAgentImageGeneration?: boolean
|
|
51
|
-
/**
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
|
|
60
|
+
/** Configured channels (each: name, endpoint, model catalog). */
|
|
61
|
+
channels?: ChannelConfig[]
|
|
62
|
+
/** Per-channel API keys, keyed by channel id. */
|
|
63
|
+
channelSecrets?: Record<string, string>
|
|
64
|
+
/** Channel used when a request does not name one. */
|
|
65
|
+
defaultChannelId?: string
|
|
57
66
|
/** Optional OpenAI-compatible chat endpoint for prompt enhancement. */
|
|
58
67
|
promptApiUrl?: string
|
|
59
68
|
/** Optional secret for the prompt enhancement endpoint. */
|
|
60
69
|
promptApiKey?: string
|
|
61
70
|
/** Chat model used to expand short image prompts. */
|
|
62
71
|
promptModel?: string
|
|
72
|
+
/* ----- deprecated legacy single-endpoint fields (migrated to channels) ----- */
|
|
73
|
+
/** Legacy base URL; synthesized into the default channel on upgrade. */
|
|
74
|
+
apiUrl?: string
|
|
75
|
+
/** Legacy secret; migrated into channelSecrets on upgrade. */
|
|
76
|
+
apiKey?: string
|
|
77
|
+
/** Legacy allow-list; migrated into the default channel's catalog. */
|
|
78
|
+
imageModels?: string[]
|
|
63
79
|
}
|
|
64
80
|
|
|
65
81
|
export const Config: z<Config> = z.object({
|
|
66
82
|
enabled: z.boolean().default(true),
|
|
67
83
|
announceToAgent: z.boolean().default(true),
|
|
68
84
|
allowAgentImageGeneration: z.boolean().default(true),
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
85
|
+
channels: z.array(z.object({
|
|
86
|
+
id: z.string(),
|
|
87
|
+
preset: z.string().default(''),
|
|
88
|
+
name: z.string().default(''),
|
|
89
|
+
apiUrl: z.string().default(''),
|
|
90
|
+
models: z.array(z.object({
|
|
91
|
+
alias: z.string(),
|
|
92
|
+
id: z.string(),
|
|
93
|
+
})).default([]),
|
|
94
|
+
})).default([]),
|
|
95
|
+
channelSecrets: z.dict(z.string().role('secret')).default({}),
|
|
96
|
+
defaultChannelId: z.string().default(''),
|
|
72
97
|
promptApiUrl: z.string().default(''),
|
|
73
98
|
promptApiKey: z.string().role('secret').default(''),
|
|
74
99
|
promptModel: z.string().default(''),
|
|
100
|
+
apiUrl: z.string().default(''),
|
|
101
|
+
apiKey: z.string().role('secret').default(''),
|
|
102
|
+
imageModels: z.array(z.string()).default([]),
|
|
75
103
|
})
|
|
76
104
|
|
|
77
105
|
/** Schema defaults, re-read for hand-built contexts (the loader applies them normally). */
|
|
@@ -83,21 +111,61 @@ const DEFAULT_ALLOW_AGENT_IMAGE_GENERATION = true
|
|
|
83
111
|
const SECTION_ORDER = 150
|
|
84
112
|
|
|
85
113
|
/** Model-facing announcement: plugin presence, capabilities, and limits. */
|
|
86
|
-
export const IMAGEGEN_GUIDANCE = '本机已安装 dsh-imagegen 插件(DSH AI 生图):侧边栏「AI
|
|
114
|
+
export const IMAGEGEN_GUIDANCE = '本机已安装 dsh-imagegen 插件(DSH AI 生图):侧边栏「AI 生图」入口。能力:通过「渠道」对接 OpenAI 兼容图像生成 API(每个渠道 = 一个 API 端点 + 各自的模型目录),支持文生图(/images/generations)与图生图(/images/edits,上传参考图,grok-imagine 模型按官方 JSON image_url 协议发送,nanobanana 系列按 aspect_ratio / image_size 参数协议发送;seedream 系列统一走 /images/generations,参考图以 JSON image 数组发送)。API 地址与密钥在 GUI 设置中按渠道配置,密钥仅存于本机设置文档;生成请求由本地宿主代理转发,结果以 base64 返回面板,可预览与下载。模型只能使用用户在各渠道配置目录中的模型;模型出现在 /models 中不等于其网关原生支持生图协议,遇到 Qwen、Gemini 等非 OpenAI 生图协议时应如实说明上游兼容性。可一键把满意的图片加入「画廊」。内置「提示词模板库」(面板提示词框左下角「模板库」按钮):打包 awesome-gpt-image-2 的数百条提示词案例,可搜索、筛选与复用。Agent 可直接调用 `generate_image` 提交文生图,也可用 `edit_image` 图生图;默认保持工具调用等待直到任务完成,完成图片显示在工具调用对应的左侧结果区域,模型收到状态和附件引用,不会额外伪造用户消息。若明确需要后台执行,可传 `wait_for_completion: false`,之后再用 `get_image_generation_task` 查询;不要反复轮询。限制:生成消耗上游 API 额度;图片内容由上游模型生成,可能不符合预期或包含不适宜内容;api_key 以明文存储在设置文档中;参考图会发送至所配置的 API 服务;模板库在线刷新与参考图首次加载需要访问 vibeui.top。用户提到「生图 / 绘画 / 生成图片 / 文生图 / 图生图 / 画廊 / 提示词模板」时即指本插件,请据此协作。'
|
|
115
|
+
|
|
116
|
+
/** Append the live channel × model table so an Agent can honor user choices. */
|
|
117
|
+
function guidanceFor(channels: RuntimeChannel[], defaultChannelId: string): string {
|
|
118
|
+
if (channels.length === 0) {
|
|
119
|
+
return `${IMAGEGEN_GUIDANCE} 尚未配置任何渠道:请先在「设置 → 插件 → AI 生图」添加渠道并填写 API 地址与密钥。`
|
|
120
|
+
}
|
|
121
|
+
const table = channels.map(channel => {
|
|
122
|
+
const aliases = channel.models.map(model => model.alias).join('、')
|
|
123
|
+
const mark = channel.id === defaultChannelId ? '(默认渠道)' : ''
|
|
124
|
+
const key = channel.apiKey === '' ? '(未填密钥)' : ''
|
|
125
|
+
const models = channel.models.length === 0 ? '未配置模型' : `可用模型:${aliases}`
|
|
126
|
+
return `渠道「${channel.name}」${mark}[${channel.apiUrl}] ${models}${key}`
|
|
127
|
+
}).join(';')
|
|
128
|
+
return `${IMAGEGEN_GUIDANCE} 当前渠道与模型:${table}。用户指定模型名时取该模型所属渠道(多渠道同名用默认渠道);未指定模型时若仅一个可用模型可直接生成,若有多个应先询问用户选择「渠道 + 模型」。`
|
|
129
|
+
}
|
|
87
130
|
|
|
88
|
-
/**
|
|
89
|
-
function
|
|
90
|
-
|
|
131
|
+
/** Normalize raw channel entries into the wire shape (schema-adjacent guard). */
|
|
132
|
+
function normalizeChannels(value: unknown): ChannelConfig[] {
|
|
133
|
+
if (!Array.isArray(value)) return []
|
|
134
|
+
const out: ChannelConfig[] = []
|
|
135
|
+
for (const item of value) {
|
|
136
|
+
if (item === null || typeof item !== 'object') continue
|
|
137
|
+
const raw = item as Record<string, unknown>
|
|
138
|
+
const id = typeof raw.id === 'string' ? raw.id.trim() : ''
|
|
139
|
+
if (id === '') continue
|
|
140
|
+
const models: ModelMapping[] = []
|
|
141
|
+
if (Array.isArray(raw.models)) {
|
|
142
|
+
for (const entry of raw.models) {
|
|
143
|
+
if (entry === null || typeof entry !== 'object') continue
|
|
144
|
+
const record = entry as Record<string, unknown>
|
|
145
|
+
const alias = typeof record.alias === 'string' ? record.alias.trim() : ''
|
|
146
|
+
const upstream = typeof record.id === 'string' ? record.id.trim() : ''
|
|
147
|
+
if (alias === '') continue
|
|
148
|
+
models.push({ alias, id: upstream === '' ? alias : upstream })
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
out.push({
|
|
152
|
+
id,
|
|
153
|
+
preset: typeof raw.preset === 'string' ? raw.preset : '',
|
|
154
|
+
name: typeof raw.name === 'string' ? raw.name.trim() : '',
|
|
155
|
+
apiUrl: typeof raw.apiUrl === 'string' ? raw.apiUrl.trim() : '',
|
|
156
|
+
models,
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
return out
|
|
91
160
|
}
|
|
92
161
|
|
|
93
|
-
/** Effective config (schema defaults applied). */
|
|
94
|
-
interface EffectiveConfig {
|
|
162
|
+
/** Effective config (schema defaults applied + legacy migration). */
|
|
163
|
+
export interface EffectiveConfig {
|
|
95
164
|
enabled: boolean
|
|
96
165
|
announceToAgent: boolean
|
|
97
166
|
allowAgentImageGeneration: boolean
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
imageModels: string[]
|
|
167
|
+
channels: RuntimeChannel[]
|
|
168
|
+
defaultChannelId: string
|
|
101
169
|
promptApiUrl: string
|
|
102
170
|
promptApiKey: string
|
|
103
171
|
promptModel: string
|
|
@@ -113,35 +181,67 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
113
181
|
// service is attached, the composition entry otherwise.
|
|
114
182
|
let current: () => Config = () => config ?? {}
|
|
115
183
|
const resolve = (): EffectiveConfig => {
|
|
116
|
-
const value = current()
|
|
184
|
+
const value = current() ?? {}
|
|
185
|
+
let channels = normalizeChannels(value.channels)
|
|
186
|
+
// Settings scopes are deep-frozen by the host. Legacy migration adds the
|
|
187
|
+
// synthesized default-channel secret, so always work on a detached copy.
|
|
188
|
+
const secrets: Record<string, string> = { ...(value.channelSecrets ?? {}) }
|
|
189
|
+
// Legacy single-endpoint migration: no channels yet → synthesize the
|
|
190
|
+
// default channel from the old flat fields so upgrades never break.
|
|
191
|
+
if (channels.length === 0) {
|
|
192
|
+
const legacyUrl = typeof value.apiUrl === 'string' ? value.apiUrl.trim() : ''
|
|
193
|
+
const legacyModels: ModelMapping[] = Array.isArray(value.imageModels)
|
|
194
|
+
? value.imageModels
|
|
195
|
+
.filter((model): model is string => typeof model === 'string' && model.trim() !== '')
|
|
196
|
+
.map(model => ({ alias: model.trim(), id: model.trim() }))
|
|
197
|
+
: []
|
|
198
|
+
if (legacyUrl !== '' || legacyModels.length > 0) {
|
|
199
|
+
channels = [{ id: 'default', preset: '', name: '默认渠道', apiUrl: legacyUrl, models: legacyModels }]
|
|
200
|
+
const legacyKey = typeof value.apiKey === 'string' ? value.apiKey.trim() : ''
|
|
201
|
+
if (legacyKey !== '') secrets['default'] = legacyKey
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const named = channels.map(channel => ({
|
|
205
|
+
...channel,
|
|
206
|
+
name: channel.name === '' ? (presetById(channel.preset)?.name ?? '未命名渠道') : channel.name,
|
|
207
|
+
}))
|
|
208
|
+
const defaultChannelId = typeof value.defaultChannelId === 'string' && named.some(channel => channel.id === value.defaultChannelId)
|
|
209
|
+
? value.defaultChannelId
|
|
210
|
+
: named[0]?.id ?? ''
|
|
117
211
|
return {
|
|
118
212
|
enabled: value.enabled ?? DEFAULT_ENABLED,
|
|
119
213
|
announceToAgent: value.announceToAgent ?? DEFAULT_ANNOUNCE,
|
|
120
214
|
allowAgentImageGeneration: value.allowAgentImageGeneration ?? DEFAULT_ALLOW_AGENT_IMAGE_GENERATION,
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
215
|
+
channels: named.map(channel => ({
|
|
216
|
+
...channel,
|
|
217
|
+
apiKey: typeof secrets[channel.id] === 'string' ? secrets[channel.id] : '',
|
|
218
|
+
})),
|
|
219
|
+
defaultChannelId,
|
|
220
|
+
promptApiUrl: typeof value.promptApiUrl === 'string' ? value.promptApiUrl.trim() : '',
|
|
221
|
+
promptApiKey: typeof value.promptApiKey === 'string' ? value.promptApiKey.trim() : '',
|
|
222
|
+
promptModel: typeof value.promptModel === 'string' ? value.promptModel.trim() : '',
|
|
127
223
|
}
|
|
128
224
|
}
|
|
129
225
|
|
|
226
|
+
// Transient helper used by several mount points below: resolve the shared
|
|
227
|
+
// channel view once per access; the runtime then picks per-request creds.
|
|
228
|
+
const channelsView = (): ChannelsView => {
|
|
229
|
+
const value = resolve()
|
|
230
|
+
return { channels: value.channels, defaultChannelId: value.defaultChannelId }
|
|
231
|
+
}
|
|
232
|
+
|
|
130
233
|
// Browser endpoints and Agent tools share the exact same serial queue. This
|
|
131
234
|
// keeps image persistence, cancellation, and retries coherent across both
|
|
132
235
|
// entry points; Agent tools wait for their task result by default and render
|
|
133
236
|
// images in the tool result instead of injecting a synthetic user message.
|
|
134
|
-
const runtime = new ImageGenerationRuntime(
|
|
135
|
-
const value = resolve()
|
|
136
|
-
return { apiUrl: value.apiUrl, apiKey: value.apiKey }
|
|
137
|
-
})
|
|
237
|
+
const runtime = new ImageGenerationRuntime(channelsView)
|
|
138
238
|
|
|
139
239
|
// The route family mounts once, gated on the settings seam (the bridge
|
|
140
240
|
// serves it; without the seam there is nothing to expose). Route handlers
|
|
141
241
|
// read resolve() per request, so config edits apply live. The settings
|
|
142
242
|
// bridge deliberately keeps serving while the plugin is disabled — it is
|
|
143
243
|
// how the user re-enables the plugin from the settings card.
|
|
144
|
-
ctx.inject(['settings'], (sctx) => {
|
|
244
|
+
ctx.inject(['settings', 'attachments'], (sctx) => {
|
|
145
245
|
const seam = sctx.get('settings') as unknown as SettingsSeam
|
|
146
246
|
sctx.effect(
|
|
147
247
|
() => {
|
|
@@ -149,17 +249,24 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
149
249
|
settings: seam,
|
|
150
250
|
resolve: () => {
|
|
151
251
|
const value = resolve()
|
|
152
|
-
|
|
252
|
+
const channel = value.channels.find(candidate => candidate.id === value.defaultChannelId) ?? value.channels[0]
|
|
253
|
+
return { apiUrl: channel?.apiUrl ?? '', apiKey: channel?.apiKey ?? '' }
|
|
153
254
|
},
|
|
255
|
+
resolveChannels: channelsView,
|
|
154
256
|
resolvePrompt: () => {
|
|
155
257
|
const value = resolve()
|
|
258
|
+
const channel = value.channels.find(candidate => candidate.id === value.defaultChannelId) ?? value.channels[0]
|
|
156
259
|
return {
|
|
157
|
-
apiUrl: value.promptApiUrl
|
|
158
|
-
apiKey: value.promptApiKey
|
|
260
|
+
apiUrl: value.promptApiUrl !== '' ? value.promptApiUrl : (channel?.apiUrl ?? ''),
|
|
261
|
+
apiKey: value.promptApiKey !== '' ? value.promptApiKey : (channel?.apiKey ?? ''),
|
|
159
262
|
model: value.promptModel,
|
|
160
263
|
}
|
|
161
264
|
},
|
|
162
|
-
resolveImageModels: () =>
|
|
265
|
+
resolveImageModels: () => {
|
|
266
|
+
const value = resolve()
|
|
267
|
+
return [...new Set(value.channels.flatMap(channel => channel.models.map(model => model.alias)))]
|
|
268
|
+
},
|
|
269
|
+
attachments: sctx.attachments,
|
|
163
270
|
runtime,
|
|
164
271
|
})
|
|
165
272
|
const disposers = routes.map(route => ctx.webServer.register(route))
|
|
@@ -175,9 +282,8 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
175
282
|
return {
|
|
176
283
|
enabled: value.enabled,
|
|
177
284
|
allowAgentImageGeneration: value.allowAgentImageGeneration,
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
imageModels: value.imageModels,
|
|
285
|
+
channels: value.channels,
|
|
286
|
+
defaultChannelId: value.defaultChannelId,
|
|
181
287
|
}
|
|
182
288
|
}), 'dsh-imagegen: agent image tools')
|
|
183
289
|
})
|
|
@@ -194,7 +300,7 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
194
300
|
disposeSection = ctx.systemPrompt.section({
|
|
195
301
|
name: 'plugin:dsh-imagegen',
|
|
196
302
|
order: SECTION_ORDER,
|
|
197
|
-
text: guidanceFor(value.
|
|
303
|
+
text: guidanceFor(value.channels, value.defaultChannelId),
|
|
198
304
|
})
|
|
199
305
|
}
|
|
200
306
|
|