@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/README.md +24 -11
- package/docs/images/community-qq.png +0 -0
- package/lib/client.js +2040 -906
- package/lib/client.js.map +1 -1
- package/lib/index.js +710 -109
- package/package.json +2 -2
- package/src/agent-image-tools.ts +132 -30
- package/src/client/ImageGenPanel.tsx +56 -20
- 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 +112 -2
- package/src/client/settings-card.module.css +487 -0
- package/src/client/settings-scope.ts +56 -8
- package/src/engine.ts +107 -8
- 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/image-models.ts +1 -1
- package/src/index.ts +153 -47
- package/src/model-catalog.ts +98 -0
- package/src/presets.ts +63 -0
- package/src/protocol.ts +81 -6
- package/src/routes.ts +192 -24
|
@@ -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.
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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,
|
|
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 {
|
|
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 (
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
if (
|
|
249
|
-
|
|
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(
|
|
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
|
-
|
|
366
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
{
|