@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.
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Model catalog: protocol-family detection and capability annotation shared by
3
+ * the engine, the settings card, and the panel. Known id patterns map onto the
4
+ * OpenAI-compatible image protocol families this plugin shapes requests for;
5
+ * anything unrecognized falls back to the generic OpenAI protocol (best
6
+ * effort) and is flagged as unknown so the UI can warn without blocking.
7
+ *
8
+ * Framework-free (pure data + regex), safe for the client bundle to inline.
9
+ */
10
+
11
+ export type ModelFamily = 'gpt-image' | 'dall-e' | 'grok' | 'nanobanana' | 'seedream' | 'unknown'
12
+
13
+ /** Capability/identity annotation for one model id. */
14
+ export interface ModelCatalogEntry {
15
+ /** Family the request is shaped for. */
16
+ family: ModelFamily
17
+ /** Short badge label (en). */
18
+ label: string
19
+ /** Short badge label (zh). */
20
+ labelZh: string
21
+ /** Whether the family is known (false = best-effort OpenAI protocol). */
22
+ known: boolean
23
+ /** Whether the family supports image-to-image edits. */
24
+ supportsEdit: boolean
25
+ /** Whether the family natively takes aspect-ratio dials. */
26
+ supportsAspectRatio: boolean
27
+ /** Quality tiers the family interprets natively. */
28
+ qualityTiers: string[]
29
+ }
30
+
31
+ const ENTRIES: Record<Exclude<ModelFamily, 'unknown'>, Omit<ModelCatalogEntry, 'family'>> = {
32
+ 'gpt-image': {
33
+ label: 'gpt-image',
34
+ labelZh: 'GPT 图像',
35
+ known: true,
36
+ supportsEdit: true,
37
+ supportsAspectRatio: false,
38
+ qualityTiers: ['1K', '2K', '4K'],
39
+ },
40
+ 'dall-e': {
41
+ label: 'DALL·E',
42
+ labelZh: 'DALL·E',
43
+ known: true,
44
+ supportsEdit: true,
45
+ supportsAspectRatio: false,
46
+ qualityTiers: ['auto'],
47
+ },
48
+ grok: {
49
+ label: 'grok',
50
+ labelZh: 'Grok',
51
+ known: true,
52
+ supportsEdit: true,
53
+ supportsAspectRatio: true,
54
+ qualityTiers: ['1K', '2K'],
55
+ },
56
+ nanobanana: {
57
+ label: 'nanobanana',
58
+ labelZh: 'Nano Banana',
59
+ known: true,
60
+ supportsEdit: true,
61
+ supportsAspectRatio: true,
62
+ qualityTiers: ['1K', '2K', '4K'],
63
+ },
64
+ seedream: {
65
+ label: 'seedream',
66
+ labelZh: 'Seedream',
67
+ known: true,
68
+ supportsEdit: true,
69
+ supportsAspectRatio: true,
70
+ qualityTiers: ['1K', '2K'],
71
+ },
72
+ }
73
+
74
+ /** Official Gemini image ids served by Nano Banana gateways. */
75
+ const NANOBANANA_GEMINI_IDS = new Set([
76
+ 'gemini-3-pro-image',
77
+ 'gemini-3-pro-image-preview',
78
+ 'gemini-3.1-flash-image',
79
+ 'gemini-3.1-flash-image-preview',
80
+ 'gemini-3.1-flash-lite-image',
81
+ 'gemini-2.5-flash-image',
82
+ ])
83
+
84
+ /** Classify one upstream model id into its request-shaping family. */
85
+ export function describeModel(model: string): ModelCatalogEntry {
86
+ const id = model.trim()
87
+ if (/^gpt-image/i.test(id)) return { family: 'gpt-image', ...ENTRIES['gpt-image'] }
88
+ if (/^dall-e/i.test(id)) return { family: 'dall-e', ...ENTRIES['dall-e'] }
89
+ if (/^grok-imagine(?:-|$)/.test(id)) return { family: 'grok', ...ENTRIES.grok }
90
+ if (/^nanobanana/i.test(id) || NANOBANANA_GEMINI_IDS.has(id)) return { family: 'nanobanana', ...ENTRIES.nanobanana }
91
+ if (/^(?:doubao-)?seedream/i.test(id)) return { family: 'seedream', ...ENTRIES.seedream }
92
+ return { family: 'unknown', label: 'unknown', labelZh: '未知协议', known: false, supportsEdit: true, supportsAspectRatio: false, qualityTiers: [] }
93
+ }
94
+
95
+ /** The family a model id routes its request through. */
96
+ export function modelFamily(model: string): ModelFamily {
97
+ return describeModel(model).family
98
+ }
package/src/presets.ts ADDED
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Built-in provider catalog (presets). A preset is an official or well-known
3
+ * OpenAI-compatible endpoint with its known model list, so the user only fills
4
+ * in the API key. The list ships with the package and is served to the
5
+ * settings card through a host route, so it can later be refreshed online like
6
+ * the template library.
7
+ *
8
+ * Framework-free (pure data), safe for the host routes to serve directly.
9
+ */
10
+
11
+ import type { ModelMapping } from './protocol.ts'
12
+
13
+ /** One built-in provider the settings card can instantiate a channel from. */
14
+ export interface PresetProvider {
15
+ /** Stable preset id stored on channels created from it ('' = custom). */
16
+ id: string
17
+ /** Display name shown in the picker (also the channel's default name). */
18
+ name: string
19
+ /** Official base URL prefilled into the channel. */
20
+ apiUrl: string
21
+ /** One-line description shown in the picker. */
22
+ hint: string
23
+ /** Known model list prefilled into the channel's model catalog. */
24
+ models: ModelMapping[]
25
+ }
26
+
27
+ export const IMAGE_PRESETS: PresetProvider[] = [
28
+ {
29
+ id: 'volc-ark-seedream',
30
+ name: '字节 · 火山方舟(Seedream)',
31
+ apiUrl: 'https://ark.cn-beijing.volces.com/api/v3',
32
+ hint: '字节跳动官方 Seedream 文生图/图生图入口',
33
+ models: [
34
+ { alias: 'seedream-5.0-pro', id: 'seedream-5.0-pro' },
35
+ { alias: 'seedream-5.0', id: 'seedream-5.0' },
36
+ { alias: 'seedream-4.0', id: 'seedream-4.0' },
37
+ ],
38
+ },
39
+ {
40
+ id: 'openai-official',
41
+ name: 'OpenAI 官方',
42
+ apiUrl: 'https://api.openai.com/v1',
43
+ hint: 'OpenAI 官方接口:gpt-image-2 / dall-e-3',
44
+ models: [
45
+ { alias: 'gpt-image-2', id: 'gpt-image-2' },
46
+ { alias: 'dall-e-3', id: 'dall-e-3' },
47
+ ],
48
+ },
49
+ {
50
+ id: 'xai-grok',
51
+ name: 'xAI(Grok)',
52
+ apiUrl: 'https://api.x.ai/v1',
53
+ hint: 'xAI 官方接口:Grok Imagine 系列',
54
+ models: [
55
+ { alias: 'grok-imagine-image', id: 'grok-imagine-image' },
56
+ ],
57
+ },
58
+ ]
59
+
60
+ /** Look up one built-in provider by id. */
61
+ export function presetById(id: string): PresetProvider | undefined {
62
+ return IMAGE_PRESETS.find(preset => preset.id === id)
63
+ }
package/src/protocol.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  export const IMAGEGEN_SETTINGS_NAMESPACE = 'dsh-imagegen'
9
9
 
10
10
  /** Published package version shared by the host updater and the client UI. */
11
- export const PLUGIN_VERSION = '1.2.2'
11
+ export const PLUGIN_VERSION = '1.3.0'
12
12
 
13
13
  /** Same-origin route family (loopback-only, mirroring the dsh-ssh fence). */
14
14
  export const SETTINGS_API = {
@@ -30,6 +30,19 @@ export const IMAGE_MODEL_API = {
30
30
  models: '/api/dsh-imagegen/image-models',
31
31
  } as const
32
32
 
33
+ /** Host-served built-in provider catalog (channels the user can instantiate). */
34
+ export const PRESETS_API = '/api/dsh-imagegen/presets' as const
35
+
36
+ /** Loopback-only image reader for Agent tool-result previews. */
37
+ export const AGENT_IMAGE_API = '/api/dsh-imagegen/agent-image' as const
38
+
39
+ /**
40
+ * Host-computed per-channel usage counters (generation-count badges in the
41
+ * settings card): entries are tallied from the persisted history and gallery
42
+ * by channel + model alias.
43
+ */
44
+ export const USAGE_API = '/api/dsh-imagegen/usage' as const
45
+
33
46
  /** Host-resident generation queue endpoints. */
34
47
  export const TASK_API = {
35
48
  submit: '/api/dsh-imagegen/tasks/submit',
@@ -136,20 +149,27 @@ export interface TemplateRefreshResult {
136
149
  /** Generation modes. */
137
150
  export type GenerateMode = 'text' | 'edit'
138
151
 
139
- /** A client 鈫?host generate request (what the panel collects). */
152
+ /** A client host generate request (what the panel collects). */
140
153
  export interface GenerateRequest {
141
154
  /** text-to-image (images/generations) or image-to-image (images/edits). */
142
155
  mode: GenerateMode
143
- /** Upstream model name, e.g. gpt-image-2. */
156
+ /**
157
+ * User-facing model name (an alias from the channel's model catalog). The
158
+ * host maps it onto the configured channel and fills `upstream` with the
159
+ * real id before the engine sees it.
160
+ */
144
161
  model: string
145
162
  /** The prompt. Upstream providers may impose their own length limits. */
146
163
  prompt: string
147
164
  /** Canvas size as an aspect ratio: 'auto' or e.g. '1:1' / '16:9' / '21:9'.
148
- * The host maps it onto each model's own vocabulary (aspect_ratio for Grok,
149
- * the closest pixel size for OpenAI-compatible endpoints). */
165
+ * The host maps it onto each model's own vocabulary (aspect_ratio for Grok
166
+ * and Nano Banana, resolution-tier size for Seedream, the closest pixel size for
167
+ * OpenAI-compatible endpoints). */
150
168
  size: string
151
169
  /** Clarity tier: 'auto' | '1k' | '2k' | '4k'. The host maps it onto the
152
- * model's own vocabulary (resolution for Grok, quality for OpenAI). */
170
+ * model's own vocabulary (resolution for Grok, image_size for Nano Banana,
171
+ * and size for Seedream,
172
+ * Nano Banana, quality for OpenAI). */
153
173
  quality: string
154
174
  /** Number of images, 1-4. */
155
175
  n: number
@@ -163,6 +183,14 @@ export interface GenerateRequest {
163
183
  image?: string
164
184
  /** Original reference-image name, retained in the history entry. */
165
185
  refName?: string
186
+ /** Channel this request targets (the host falls back to the default when
187
+ * absent, and re-routes by model alias when the alias lives elsewhere). */
188
+ channelId?: string
189
+ /** Channel display name snapshot, kept on the history entry (host-filled). */
190
+ channel?: string
191
+ /** Upstream model id actually sent to the gateway (host-filled from the
192
+ * alias mapping; defaults to `model` when absent). */
193
+ upstream?: string
166
194
  }
167
195
 
168
196
  /** One generated image, normalized host-side to base64 so the browser never
@@ -185,6 +213,45 @@ export interface GenerateResult {
185
213
  historyError?: string
186
214
  }
187
215
 
216
+ /**
217
+ * One model mapping in a channel's catalog: the display alias the user, the
218
+ * panel, and the Agent see, and the upstream model id actually sent to the
219
+ * gateway. The alias defaults to the upstream id but can be renamed freely.
220
+ */
221
+ export interface ModelMapping {
222
+ /** User-facing model name (defaults to the upstream id). */
223
+ alias: string
224
+ /** Upstream model id sent to the gateway. */
225
+ id: string
226
+ }
227
+
228
+ /**
229
+ * One configured image channel (provider). Secrets never live here — the API
230
+ * key is stored at `channelSecrets.<channelId>` in the settings document so
231
+ * whole-array writes can never clobber keys the user did not re-enter.
232
+ */
233
+ export interface ChannelConfig {
234
+ /** Stable channel id (the channelSecrets dict is keyed by it). */
235
+ id: string
236
+ /** Preset provider id this channel was created from ('' = custom). */
237
+ preset: string
238
+ /** Display name shown in the list, the panel, and Agent guidance. */
239
+ name: string
240
+ /** OpenAI-compatible base URL. */
241
+ apiUrl: string
242
+ /** The channel's model catalog (alias → upstream id). */
243
+ models: ModelMapping[]
244
+ }
245
+
246
+ /** One built-in provider as the settings card consumes it. */
247
+ export interface PresetProviderView {
248
+ id: string
249
+ name: string
250
+ apiUrl: string
251
+ hint: string
252
+ models: ModelMapping[]
253
+ }
254
+
188
255
  export type GenerationTaskStatus = 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'
189
256
 
190
257
  export interface GenerationTask {
@@ -233,6 +300,10 @@ export interface HistoryEntry {
233
300
  refName?: string
234
301
  /** User-managed gallery labels (unused by history entries). */
235
302
  tags?: string[]
303
+ /** Channel id snapshot (usage counters key by it for new entries). */
304
+ channelId?: string
305
+ /** Channel display name snapshot (survives channel deletion). */
306
+ channel?: string
236
307
  }
237
308
 
238
309
  /** A history entry the client submits for persistence (images still carry base64). */
@@ -248,4 +319,8 @@ export interface HistoryEntryInput {
248
319
  n: number
249
320
  images: GeneratedImage[]
250
321
  refName?: string
322
+ /** Channel id snapshot, tallied by the usage endpoint. */
323
+ channelId?: string
324
+ /** Channel display name snapshot (survives channel deletion). */
325
+ channel?: string
251
326
  }
package/src/routes.ts CHANGED
@@ -8,16 +8,18 @@
8
8
  import type { IncomingMessage, ServerResponse } from 'node:http'
9
9
  import { randomUUID } from 'node:crypto'
10
10
  import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
11
+ import type { ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment'
11
12
  import { SettingsConflictError, settingsNamespace, type SettingsDescriptor } from '@deepseek-ai/dsh-settings'
12
13
  import type { UpstreamConfig } from './engine.ts'
13
14
  import { enhancePrompt, listOpenAIModels, listPromptModels, type PromptModelConfig } from './prompt-enhancer.ts'
14
15
  import { normalizeImageModels } from './image-models.ts'
15
- import { ImageGenerationRuntime } from './generation-runtime.ts'
16
+ import { ImageGenerationRuntime, type ChannelsView } from './generation-runtime.ts'
16
17
  import { appendHistory, clearHistory, listHistory, readHistoryImage, removeHistory } from './history-store.ts'
17
18
  import { appendGallery, clearGallery, listGallery, readGalleryImage, removeGallery, updateGalleryTags } from './gallery-store.ts'
18
19
  import { listTemplates, readTemplateImage, refreshTemplates } from './templates-store.ts'
19
20
  import { checkForUpdate, CURRENT_VERSION, installUpdate } from './updater.ts'
20
- import { GALLERY_API, GENERATE_API, HISTORY_API, IMAGEGEN_SETTINGS_NAMESPACE, IMAGE_MODEL_API, PROMPT_ENHANCE_API, SETTINGS_API, TASK_API, TEMPLATES_API, UPDATE_API, type GeneratedImage, type GenerateRequest, type HistoryEntry, type HistoryEntryInput, type TemplateListResult, type TemplateRefreshResult } from './protocol.ts'
21
+ import { IMAGE_PRESETS } from './presets.ts'
22
+ import { AGENT_IMAGE_API, GALLERY_API, GENERATE_API, HISTORY_API, IMAGEGEN_SETTINGS_NAMESPACE, IMAGE_MODEL_API, PRESETS_API, PROMPT_ENHANCE_API, SETTINGS_API, TASK_API, TEMPLATES_API, UPDATE_API, USAGE_API, type GeneratedImage, type GenerateRequest, type HistoryEntry, type HistoryEntryInput, type ModelMapping, type PresetProviderView, type TemplateListResult, type TemplateRefreshResult } from './protocol.ts'
21
23
 
22
24
  /** Cap on JSON request bodies (settings ops and generate payloads are small). */
23
25
  const MAX_JSON_BODY_BYTES = 24 * 1024 * 1024
@@ -36,12 +38,18 @@ export interface SettingsSeam {
36
38
  export interface ImageGenRoutesDeps {
37
39
  /** The settings seam (namespace storage). */
38
40
  settings: SettingsSeam
39
- /** Resolve the current upstream config (composition entry + settings). */
41
+ /** Resolve the current upstream config (legacy single-endpoint path). */
40
42
  resolve: () => UpstreamConfig
43
+ /** Resolve the current channel view (the channel-aware path). */
44
+ resolveChannels?: () => ChannelsView
41
45
  /** Resolve the optional chat-model configuration for prompt enhancement. */
42
46
  resolvePrompt?: () => PromptModelConfig
43
- /** Models explicitly selected for this image API endpoint. */
47
+ /** Models explicitly selected for this image API endpoint (legacy path). */
44
48
  resolveImageModels?: () => string[]
49
+ /** Host attachment storage used by Agent tool-result previews. */
50
+ attachments?: {
51
+ readImage: (ref: ImageAttachmentRef) => Promise<{ ref: ImageAttachmentRef; data: Uint8Array }>
52
+ }
45
53
  /** Overrideable history backend, primarily for host integration tests. */
46
54
  history?: {
47
55
  list: () => Promise<HistoryEntry[]>
@@ -135,6 +143,7 @@ function parseGenerateRequest(body: Record<string, unknown>): GenerateRequest |
135
143
  detail: typeof body.detail === 'string' ? body.detail : '',
136
144
  ...typeof body.image === 'string' && body.image !== '' ? { image: body.image } : {},
137
145
  ...typeof body.refName === 'string' && body.refName !== '' ? { refName: body.refName } : {},
146
+ ...typeof body.channelId === 'string' && body.channelId !== '' ? { channelId: body.channelId } : {},
138
147
  }
139
148
  }
140
149
 
@@ -172,6 +181,8 @@ function parseHistoryEntryInput(body: Record<string, unknown>): HistoryEntryInpu
172
181
  n: entry.n,
173
182
  images,
174
183
  ...typeof entry.refName === 'string' ? { refName: entry.refName } : {},
184
+ ...typeof entry.channelId === 'string' ? { channelId: entry.channelId } : {},
185
+ ...typeof entry.channel === 'string' ? { channel: entry.channel } : {},
175
186
  }
176
187
  }
177
188
 
@@ -188,6 +199,38 @@ function imageFileFrom(rawUrl: string | undefined, basePath: string): string | u
188
199
  return decodeURIComponent(pathname.slice(basePath.length + 1))
189
200
  }
190
201
 
202
+ /** Parse the durable image reference carried by an Agent tool-result view. */
203
+ function agentImageRefFrom(rawUrl: string | undefined): ImageAttachmentRef | undefined {
204
+ if (rawUrl === undefined) return undefined
205
+ let url: URL
206
+ try {
207
+ url = new URL(rawUrl, 'http://localhost')
208
+ } catch {
209
+ return undefined
210
+ }
211
+ if (url.pathname !== AGENT_IMAGE_API) return undefined
212
+ const attachmentId = url.searchParams.get('attachment_id') ?? ''
213
+ const mediaType = url.searchParams.get('media_type') ?? ''
214
+ const bytes = Number(url.searchParams.get('bytes'))
215
+ const width = Number(url.searchParams.get('width'))
216
+ const height = Number(url.searchParams.get('height'))
217
+ if (attachmentId === '' || !isImageMediaType(mediaType)
218
+ || !Number.isSafeInteger(bytes) || bytes < 1
219
+ || !Number.isSafeInteger(width) || width < 1
220
+ || !Number.isSafeInteger(height) || height < 1) return undefined
221
+ return {
222
+ attachmentId: attachmentId as ImageAttachmentRef['attachmentId'],
223
+ mediaType,
224
+ bytes,
225
+ width,
226
+ height,
227
+ }
228
+ }
229
+
230
+ function isImageMediaType(value: string): value is ImageMediaType {
231
+ return value === 'image/png' || value === 'image/jpeg' || value === 'image/webp' || value === 'image/gif'
232
+ }
233
+
191
234
  /** Project one settings descriptor onto the bridge wire view. */
192
235
  function toView(descriptor: SettingsDescriptor): Record<string, unknown> {
193
236
  return {
@@ -240,15 +283,51 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
240
283
  }
241
284
  const resolvePrompt = deps.resolvePrompt ?? (() => ({ apiUrl: '', apiKey: '', model: '' }))
242
285
  const resolveImageModels = deps.resolveImageModels ?? (() => normalizeImageModels(undefined))
243
- const parseConfiguredRequest = (body: Record<string, unknown>): GenerateRequest | undefined => {
244
- const request = parseGenerateRequest(body)
245
- if (request === undefined) return undefined
246
- const models = normalizeImageModels(resolveImageModels())
247
- const model = request.model.trim() === '' ? models[0] : request.model.trim()
248
- if (!models.includes(model)) throw new Error(`image model "${model}" is not configured; choose one of: ${models.join(', ')}`)
249
- return { ...request, model }
286
+
287
+ /** The current channel view: the channel-aware resolver, or a synthesized
288
+ * single default channel from the legacy flat upstream config (tests and
289
+ * older hosts). */
290
+ const channelViewOf = (): ChannelsView => {
291
+ if (deps.resolveChannels !== undefined) return deps.resolveChannels()
292
+ const upstream = deps.resolve()
293
+ const models: ModelMapping[] = normalizeImageModels(resolveImageModels()).map(id => ({ alias: id, id }))
294
+ if (upstream.apiUrl.trim() === '' && models.length === 0) return { channels: [], defaultChannelId: '' }
295
+ return {
296
+ channels: [{ id: 'default', preset: '', name: '默认渠道', apiUrl: upstream.apiUrl, apiKey: upstream.apiKey, models }],
297
+ defaultChannelId: 'default',
298
+ }
299
+ }
300
+ const runtime = deps.runtime ?? new ImageGenerationRuntime(channelViewOf, history)
301
+
302
+ /** Resolve an alias (or the channel fallback) into a concrete generation
303
+ * request: picks the channel (explicit then default), maps alias → upstream
304
+ * id, and fills the channel snapshot kept on history entries. */
305
+ const resolveChannelRequest = (request: GenerateRequest): { ok: true; request: GenerateRequest } | { ok: false; code: string; message: string } => {
306
+ const view = channelViewOf()
307
+ if (view.channels.length === 0) {
308
+ return { ok: false, code: 'no-channels', message: '尚未配置任何渠道:请先在「设置 → 插件 → AI 生图」添加渠道并填写 API 地址与密钥' }
309
+ }
310
+ const explicit = view.channels.find(candidate => candidate.id === request.channelId)
311
+ const defaults = view.channels.find(candidate => candidate.id === view.defaultChannelId) ?? view.channels[0]
312
+ const target = explicit ?? defaults
313
+ const asked = request.model.trim()
314
+ if (asked === '') {
315
+ const alias = target?.models[0]?.alias ?? ''
316
+ if (alias === '') {
317
+ return { ok: false, code: 'no-models', message: `渠道「${target?.name ?? ''}」尚未配置模型,请先在设置中添加` }
318
+ }
319
+ const mapping = target!.models.find(model => model.alias === alias)!
320
+ return { ok: true, request: { ...request, model: alias, upstream: mapping.id, channelId: target!.id, channel: target!.name } }
321
+ }
322
+ const hosting = view.channels.filter(channel => channel.models.some(model => model.alias === asked))
323
+ if (hosting.length === 0) {
324
+ const available = [...new Set(view.channels.flatMap(channel => channel.models.map(model => model.alias)))]
325
+ return { ok: false, code: 'image-model-not-configured', message: `模型「${asked}」未在任一渠道配置;可用模型:${available.join('、') || '(无)'}` }
326
+ }
327
+ const picked = target !== undefined && target.models.some(model => model.alias === asked) ? target : hosting[0]!
328
+ const mapping = picked.models.find(model => model.alias === asked)!
329
+ return { ok: true, request: { ...request, model: asked, upstream: mapping.id, channelId: picked.id, channel: picked.name } }
250
330
  }
251
- const runtime = deps.runtime ?? new ImageGenerationRuntime(deps.resolve, history)
252
331
  const guard = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
253
332
  if (!isLoopbackRequest(req)) {
254
333
  writeJson(res, 403, { error: 'forbidden: loopback-only' })
@@ -262,19 +341,104 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
262
341
  }
263
342
 
264
343
  return [
344
+ // ------------------------------------ Agent tool-result image (prefix)
345
+ ...(deps.attachments === undefined ? [] : [{
346
+ kind: 'prefix' as const,
347
+ path: AGENT_IMAGE_API,
348
+ handler: async (req: IncomingMessage, res: ServerResponse) => {
349
+ if (!isLoopbackRequest(req)) {
350
+ writeJson(res, 403, { error: 'forbidden: loopback-only' })
351
+ return
352
+ }
353
+ if (req.method !== 'GET') {
354
+ writeJson(res, 405, { error: `method not allowed: ${req.method}` })
355
+ return
356
+ }
357
+ const ref = agentImageRefFrom(req.url)
358
+ if (ref === undefined) {
359
+ writeJson(res, 400, { error: 'invalid image reference' })
360
+ return
361
+ }
362
+ try {
363
+ const stored = await deps.attachments!.readImage(ref)
364
+ res.writeHead(200, {
365
+ 'content-type': stored.ref.mediaType,
366
+ 'content-length': stored.data.byteLength,
367
+ 'cache-control': 'private, max-age=3600',
368
+ })
369
+ res.end(Buffer.from(stored.data))
370
+ } catch {
371
+ // Do not expose attachment-store details through the browser route.
372
+ writeJson(res, 404, { error: 'image attachment not found' })
373
+ }
374
+ },
375
+ } satisfies WebRoute]),
265
376
  // -------------------------------------------- image model discovery
377
+ // Accepts optional temporary per-channel credentials so the settings card
378
+ // can probe the endpoint the user is *typing* without saving first:
379
+ // { channelId?, apiUrl?, apiKey? } — the channel's stored values are the
380
+ // fallback, and the body's apiUrl/apiKey override them for this call.
266
381
  {
267
382
  kind: 'exact',
268
383
  path: IMAGE_MODEL_API.models,
269
384
  handler: async (req, res) => {
270
385
  if (!guard(req, res, 'POST')) return
386
+ const body = await readJsonBody(req)
387
+ const view = channelViewOf()
388
+ const stored = view.channels.find(candidate => candidate.id === (typeof body?.channelId === 'string' ? body.channelId : undefined))
389
+ ?? view.channels.find(candidate => candidate.id === view.defaultChannelId)
390
+ ?? view.channels[0]
391
+ const upstream: UpstreamConfig = {
392
+ apiUrl: typeof body?.apiUrl === 'string' && body.apiUrl.trim() !== '' ? body.apiUrl.trim() : (stored?.apiUrl ?? ''),
393
+ apiKey: typeof body?.apiKey === 'string' && body.apiKey.trim() !== '' ? body.apiKey.trim() : (stored?.apiKey ?? ''),
394
+ }
271
395
  try {
272
- writeJson(res, 200, { ok: true, models: await listOpenAIModels(deps.resolve()) })
396
+ writeJson(res, 200, { ok: true, models: await listOpenAIModels(upstream) })
273
397
  } catch (error) {
274
398
  writeJson(res, 200, { ok: false, code: 'image-models-failed', message: messageOf(error) })
275
399
  }
276
400
  },
277
401
  },
402
+ // ---------------------------------------------------------- presets
403
+ {
404
+ kind: 'exact',
405
+ path: PRESETS_API,
406
+ handler: async (req, res) => {
407
+ if (!guard(req, res, 'POST')) return
408
+ const presets: PresetProviderView[] = IMAGE_PRESETS.map(preset => ({
409
+ id: preset.id,
410
+ name: preset.name,
411
+ apiUrl: preset.apiUrl,
412
+ hint: preset.hint,
413
+ models: preset.models,
414
+ }))
415
+ writeJson(res, 200, { ok: true, presets })
416
+ },
417
+ },
418
+ // ----------------------------------------------------------- usage
419
+ {
420
+ kind: 'exact',
421
+ path: USAGE_API,
422
+ handler: async (req, res) => {
423
+ if (!guard(req, res, 'POST')) return
424
+ try {
425
+ const entries = [...await history.list(), ...await gallery.list()]
426
+ // byChannel[channelId | 'name:<name>' | ''] → { alias: count }.
427
+ const byChannel: Record<string, Record<string, number>> = {}
428
+ const totals: Record<string, number> = {}
429
+ for (const entry of entries) {
430
+ const channelKey = entry.channelId !== undefined ? entry.channelId : (entry.channel !== undefined ? `name:${entry.channel}` : '')
431
+ const alias = entry.model
432
+ const bucket = byChannel[channelKey] ?? (byChannel[channelKey] = {})
433
+ bucket[alias] = (bucket[alias] ?? 0) + 1
434
+ totals[alias] = (totals[alias] ?? 0) + 1
435
+ }
436
+ writeJson(res, 200, { ok: true, usage: { byChannel, totals } })
437
+ } catch (error) {
438
+ writeJson(res, 200, { ok: false, code: 'usage-failed', message: messageOf(error) })
439
+ }
440
+ },
441
+ },
278
442
  // ----------------------------------------------- prompt enhancement
279
443
  {
280
444
  kind: 'exact',
@@ -362,18 +526,18 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
362
526
  handler: async (req, res) => {
363
527
  if (!guard(req, res, 'POST')) return
364
528
  const body = await readJsonBody(req)
365
- if (body === undefined) {
366
- writeJson(res, 200, { ok: false, code: 'bad-request', message: 'unreadable JSON body' })
529
+ const parsed = body === undefined ? undefined : parseGenerateRequest(body)
530
+ if (parsed === undefined) {
531
+ writeJson(res, 200, { ok: false, code: 'bad-request', message: 'prompt is required' })
367
532
  return
368
533
  }
369
- let request: GenerateRequest | undefined
370
- try { request = parseConfiguredRequest(body) } catch (error) { writeJson(res, 200, { ok: false, code: 'image-model-not-configured', message: messageOf(error) }); return }
371
- if (request === undefined) {
372
- writeJson(res, 200, { ok: false, code: 'bad-request', message: 'prompt is required' })
534
+ const resolved = resolveChannelRequest(parsed)
535
+ if (!resolved.ok) {
536
+ writeJson(res, 200, { ok: false, code: resolved.code, message: resolved.message })
373
537
  return
374
538
  }
375
539
  try {
376
- writeJson(res, 200, { ok: true, ...await runtime.run(request) })
540
+ writeJson(res, 200, { ok: true, ...await runtime.run(resolved.request) })
377
541
  } catch (error) {
378
542
  const message = error instanceof Error ? error.message : String(error)
379
543
  const code = error instanceof Error && 'code' in error && typeof (error as { code?: unknown }).code === 'string'
@@ -389,10 +553,14 @@ export function makeRoutes(deps: ImageGenRoutesDeps): WebRoute[] {
389
553
  handler: async (req, res) => {
390
554
  if (!guard(req, res, 'POST')) return
391
555
  const body = await readJsonBody(req)
392
- let request: GenerateRequest | undefined
393
- try { request = body === undefined ? undefined : parseConfiguredRequest(body) } catch (error) { writeJson(res, 200, { ok: false, code: 'image-model-not-configured', message: messageOf(error) }); return }
394
- if (request === undefined) { writeJson(res, 200, { ok: false, code: 'bad-request', message: 'prompt is required' }); return }
395
- writeJson(res, 200, { ok: true, task: runtime.queue.submit(request) })
556
+ const parsed = body === undefined ? undefined : parseGenerateRequest(body)
557
+ if (parsed === undefined) { writeJson(res, 200, { ok: false, code: 'bad-request', message: 'prompt is required' }); return }
558
+ const resolved = resolveChannelRequest(parsed)
559
+ if (!resolved.ok) {
560
+ writeJson(res, 200, { ok: false, code: resolved.code, message: resolved.message })
561
+ return
562
+ }
563
+ writeJson(res, 200, { ok: true, task: runtime.queue.submit(resolved.request) })
396
564
  },
397
565
  },
398
566
  {