@dickpy/dsh-imagegen 1.2.2 → 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/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,52 @@ 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 /^grok-imagine(?:-|$)/.test(model)
61
+ return modelFamily(model) === 'grok'
62
+ }
63
+
64
+ /** Whether the model belongs to the Google Nano Banana family (nanobanana2 /
65
+ * nanobanana2-lite / nanobanana-pro, plus the official Gemini image IDs the
66
+ * gateways expose). OpenAI-compatible gateways serve these with their own
67
+ * aspect_ratio / image_size vocabulary instead of the OpenAI size/quality
68
+ * passthrough. */
69
+ function isNanoBanana(model: string): boolean {
70
+ return modelFamily(model) === 'nanobanana'
71
+ }
72
+
73
+ /** Whether the model belongs to the ByteDance Seedream family (seedream-5.0-pro,
74
+ * seedream-5.0, seedream-4.x, doubao-seedream-…). OpenAI-compatible gateways
75
+ * serve Seedream through a unified generate-and-edit architecture:
76
+ * generation AND editing both go to /images/generations and reference images
77
+ * are a JSON URL / data-URL array. */
78
+ function isSeedream(model: string): boolean {
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'
51
93
  }
52
94
 
53
95
  /** The panel's aspect ratios mapped to the closest OpenAI pixel size
@@ -136,10 +178,11 @@ function effectiveParams(request: GenerateRequest): {
136
178
  quality?: string
137
179
  detail?: string
138
180
  aspect_ratio?: string
181
+ image_size?: string
139
182
  resolution?: string
140
183
  response_format?: string
141
184
  } {
142
- const model = request.model.trim() === '' ? 'gpt-image-2' : request.model.trim()
185
+ const model = wireModel(request)
143
186
  // dall-e-3 has no quality/detail knobs and only produces one image.
144
187
  if (model === 'dall-e-3') {
145
188
  const pixel = OPENAI_SIZE_BY_RATIO[request.size]
@@ -163,6 +206,34 @@ function effectiveParams(request: GenerateRequest): {
163
206
  response_format: 'b64_json',
164
207
  }
165
208
  }
209
+ // Google Nano Banana: the panel's aspect ratios are sent as-is (the family
210
+ // documents 1:1 … 21:9 natively), the clarity tiers become image_size
211
+ // (1K / 2K / 4K — Gen 1 and 2-Lite are 1K-only upstream, but which gateway
212
+ // rejects higher tiers is its own call), and base64 output keeps any signed
213
+ // result URLs from expiring before the host downloads them.
214
+ if (isNanoBanana(model)) {
215
+ return {
216
+ model,
217
+ ...request.size !== '' && request.size !== 'auto'
218
+ ? { aspect_ratio: request.size }
219
+ : {},
220
+ ...request.quality !== '' && request.quality !== 'auto'
221
+ ? { image_size: request.quality.toUpperCase() }
222
+ : {},
223
+ response_format: 'b64_json',
224
+ }
225
+ }
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.
230
+ if (isSeedream(model)) {
231
+ return {
232
+ model,
233
+ size: seedreamSize(request.quality),
234
+ response_format: isVolcSeedream(model) ? 'url' : 'b64_json',
235
+ }
236
+ }
166
237
  // OpenAI-compatible endpoints: nearest pixel size, clarity tiers mapped to
167
238
  // the quality levels (1k→low / 2k→medium / 4k→high), detail passthrough.
168
239
  return {
@@ -179,7 +250,7 @@ function effectiveParams(request: GenerateRequest): {
179
250
 
180
251
  /** How many single-image requests to issue for the requested image count. */
181
252
  function effectiveCount(request: GenerateRequest): number {
182
- const model = request.model.trim() === '' ? 'gpt-image-2' : request.model.trim()
253
+ const model = wireModel(request)
183
254
  if (model === 'dall-e-3') return 1
184
255
  return clampCount(request.n)
185
256
  }
@@ -191,7 +262,8 @@ async function normalizeItem(
191
262
  ): Promise<{ b64: string; mime: string; revisedPrompt?: string }> {
192
263
  const revisedPrompt = typeof item.revised_prompt === 'string' ? item.revised_prompt : undefined
193
264
  if (typeof item.b64_json === 'string') {
194
- return { b64: bareBase64(item.b64_json), mime: 'image/png', revisedPrompt }
265
+ const b64 = bareBase64(item.b64_json)
266
+ return { b64, mime: detectImageMime(Buffer.from(b64, 'base64')) ?? 'image/png', revisedPrompt }
195
267
  }
196
268
  if (typeof item.url !== 'string' || item.url === '') {
197
269
  throw new ImageGenError('upstream image item has neither b64_json nor url')
@@ -200,7 +272,7 @@ async function normalizeItem(
200
272
  if (url.startsWith('data:')) {
201
273
  const parsed = parseDataUrl(url)
202
274
  if (parsed === undefined) throw new ImageGenError('upstream returned a malformed data: url')
203
- return { b64: parsed.base64, mime: parsed.mime, revisedPrompt }
275
+ return { b64: parsed.base64, mime: detectImageMime(Buffer.from(parsed.base64, 'base64')) ?? parsed.mime, revisedPrompt }
204
276
  }
205
277
  const budget = requestSignal(undefined, IMAGE_FETCH_TIMEOUT_MS)
206
278
  let response: Response
@@ -221,9 +293,10 @@ async function normalizeItem(
221
293
  }
222
294
  const buffer = Buffer.from(await response.arrayBuffer())
223
295
  const contentType = response.headers.get('content-type')
224
- const mime = contentType !== null && contentType !== ''
296
+ const mime = detectImageMime(buffer)
297
+ ?? (contentType !== null && contentType !== ''
225
298
  ? contentType.split(';')[0]!.trim()
226
- : mimeOfExtension(url) ?? 'image/png'
299
+ : mimeOfExtension(url) ?? 'image/png')
227
300
  return { b64: buffer.toString('base64'), mime, revisedPrompt }
228
301
  }
229
302
 
@@ -268,6 +341,28 @@ async function requestOneImage(
268
341
  ...params.aspect_ratio !== undefined ? { aspect_ratio: params.aspect_ratio } : {},
269
342
  response_format: 'b64_json',
270
343
  })
344
+ } else if (isNanoBanana(params.model)) {
345
+ // Nano Banana OpenAI-compatible gateways accept the standard multipart
346
+ // edit upload, with the family's own aspect_ratio / image_size knobs.
347
+ const form = new FormData()
348
+ form.append('image', new Blob([bytes], { type: parsed.mime }), `reference.${extensionOf(parsed.mime)}`)
349
+ form.append('prompt', request.prompt)
350
+ form.append('model', params.model)
351
+ if (params.aspect_ratio !== undefined) form.append('aspect_ratio', params.aspect_ratio)
352
+ if (params.image_size !== undefined) form.append('image_size', params.image_size)
353
+ body = form
354
+ } else if (isSeedream(params.model)) {
355
+ // Seedream unifies generation and editing on /images/generations; the
356
+ // reference image is a JSON URL / data-URL array, never multipart.
357
+ headers['content-type'] = 'application/json'
358
+ body = JSON.stringify({
359
+ model: params.model,
360
+ prompt: request.prompt,
361
+ image: [request.image],
362
+ ...params.size !== undefined ? { size: params.size } : {},
363
+ ...params.resolution !== undefined ? { resolution: params.resolution } : {},
364
+ response_format: isVolcSeedream(params.model) ? 'url' : 'b64_json',
365
+ })
271
366
  } else {
272
367
  const form = new FormData()
273
368
  form.append('image', new Blob([bytes], { type: parsed.mime }), `reference.${extensionOf(parsed.mime)}`)
@@ -286,7 +381,11 @@ async function requestOneImage(
286
381
  const budget = requestSignal(signal, UPSTREAM_TIMEOUT_MS)
287
382
  let response: Response
288
383
  try {
289
- response = await fetch(`${baseUrl}/images/${request.mode === 'edit' ? 'edits' : 'generations'}`, {
384
+ // Seedream has no /images/edits endpoint: both modes hit generations.
385
+ const endpoint = request.mode === 'edit' && !isSeedream(params.model)
386
+ ? '/images/edits'
387
+ : '/images/generations'
388
+ response = await fetch(`${baseUrl}${endpoint}`, {
290
389
  method: 'POST',
291
390
  headers,
292
391
  body,
@@ -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: () => UpstreamConfig,
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 result = await generateImage(this.resolve(), request, { signal })
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
+ }
@@ -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
+ }
@@ -4,7 +4,7 @@
4
4
  * allow-list because OpenAI-compatible gateways rarely advertise modalities.
5
5
  */
6
6
 
7
- export const DEFAULT_IMAGE_MODELS = ['gpt-image-2', 'grok-imagine-image'] as const
7
+ export const DEFAULT_IMAGE_MODELS = ['gpt-image-2', 'grok-imagine-image', 'nanobanana2', 'nanobanana2-lite', 'nanobanana-pro', 'seedream-5.0-pro'] as const
8
8
 
9
9
  /** Normalize user-entered model identifiers and retain a usable legacy default. */
10
10
  export function normalizeImageModels(value: unknown): string[] {
package/src/index.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  /**
2
- * dsh-imagegen — host half. Mounts the plugin's settings section (api_url /
3
- * api_key on the host settings seam), the /api/dsh-imagegen route family
4
- * (loopback-only settings bridge + image-generation proxy that keeps the API
5
- * key host-side), and a system-prompt announcement. The browser half
6
- * (./client) renders the sidebar entry and the split-pane generation studio.
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 { DEFAULT_IMAGE_MODELS, normalizeImageModels } from './image-models.ts'
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
- /** Plugin config, validated by the same-named schemastery schema. */
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
- /** Base URL of the OpenAI-compatible endpoint, e.g. https://api.openai.com/v1 */
52
- apiUrl?: string
53
- /** Bearer API key (stored as a secret field on the settings seam). */
54
- apiKey?: string
55
- /** Explicit allow-list of image models selected for this API endpoint. */
56
- imageModels?: string[]
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
- apiUrl: z.string().default(''),
70
- apiKey: z.string().role('secret').default(''),
71
- imageModels: z.array(z.string()).default([...DEFAULT_IMAGE_MODELS]),
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 生图」入口。能力:对接 OpenAI 兼容图像生成 API,模型由用户在「设置 插件 AI 生图」中检测或手动配置的生图模型列表决定;支持文生图(/images/generations)与图生图(/images/edits,上传参考图,grok-imagine 模型按官方 JSON image_url 协议发送)。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。用户提到「生图 / 绘画 / 生成图片 / 文生图 / 图生图 / 画廊 / 提示词模板」时即指本插件,请据此协作。'
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
- /** Add the live allow-list so an Agent can honor a user's model choice. */
89
- function guidanceFor(imageModels: string[]): string {
90
- return `${IMAGEGEN_GUIDANCE} 当前允许调用的生图模型:${imageModels.join('、')}。用户指定其中某个模型时,工具参数 model 必须使用该精确名称;未指定时使用列表中的第一个。`
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
- apiUrl: string
99
- apiKey: string
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
- apiUrl: value.apiUrl ?? '',
122
- apiKey: value.apiKey ?? '',
123
- imageModels: normalizeImageModels(value.imageModels),
124
- promptApiUrl: value.promptApiUrl ?? '',
125
- promptApiKey: value.promptApiKey ?? '',
126
- promptModel: value.promptModel ?? '',
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
- return { apiUrl: value.apiUrl, apiKey: value.apiKey }
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.trim() || value.apiUrl,
158
- apiKey: value.promptApiKey.trim() || value.apiKey,
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: () => resolve().imageModels,
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
- apiUrl: value.apiUrl,
179
- apiKey: value.apiKey,
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.imageModels),
303
+ text: guidanceFor(value.channels, value.defaultChannelId),
198
304
  })
199
305
  }
200
306