@dickpy/dsh-imagegen 1.0.20 → 1.2.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 +46 -13
- package/docs/images/agent-chat-edit.png +0 -0
- package/docs/images/agent-chat-generate.png +0 -0
- package/docs/images/multi-model-comparison.png +0 -0
- package/lib/client.js +1677 -538
- package/lib/client.js.map +1 -1
- package/lib/index.js +877 -57
- package/package.json +4 -1
- package/src/agent-image-tools.ts +289 -0
- package/src/client/ImageGenPanel.tsx +333 -46
- package/src/client/SettingsCard.tsx +293 -23
- package/src/client/api.ts +37 -1
- package/src/client/locales.ts +150 -2
- package/src/client/panel.module.css +129 -1
- package/src/client/settings-card.module.css +222 -0
- package/src/client/settings-form.ts +12 -0
- package/src/client/settings-scope.ts +24 -1
- package/src/engine.ts +32 -4
- package/src/gallery-store.ts +15 -1
- package/src/generation-runtime.ts +48 -0
- package/src/image-models.ts +19 -0
- package/src/index.ts +72 -3
- package/src/prompt-enhancer.ts +79 -0
- package/src/protocol.ts +37 -2
- package/src/routes.ts +142 -40
- package/src/task-queue.ts +103 -0
|
@@ -102,6 +102,18 @@ export function textField(field: string): FieldSpec {
|
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
/** A newline/comma-separated model list, persisted as a normalized string array. */
|
|
106
|
+
export function stringListField(field: string): FieldSpec {
|
|
107
|
+
return {
|
|
108
|
+
field,
|
|
109
|
+
format: value => Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string').join('\n') : '',
|
|
110
|
+
parse: (text) => {
|
|
111
|
+
const values = [...new Set(text.split(/[\n,]/).map(item => item.trim()).filter(Boolean))]
|
|
112
|
+
return values.length === 0 ? { kind: 'clear' } : { kind: 'set', value: values }
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
105
117
|
/** A boolean field, edited through true/false draft text. */
|
|
106
118
|
export function booleanField(field: string): FieldSpec {
|
|
107
119
|
return {
|
|
@@ -20,8 +20,13 @@ import { SETTINGS_API } from '../protocol.ts'
|
|
|
20
20
|
export interface ImageGenConfig {
|
|
21
21
|
enabled?: boolean
|
|
22
22
|
announceToAgent?: boolean
|
|
23
|
+
allowAgentImageGeneration?: boolean
|
|
23
24
|
apiUrl?: string
|
|
24
25
|
apiKey?: string
|
|
26
|
+
imageModels?: string[]
|
|
27
|
+
promptApiUrl?: string
|
|
28
|
+
promptApiKey?: string
|
|
29
|
+
promptModel?: string
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
/** Wire shape of one namespace view from the bridge. */
|
|
@@ -79,6 +84,8 @@ class BridgeScopeController<T> implements SettingsScope<T> {
|
|
|
79
84
|
private readonly store: SnapshotStore<SettingsScopeSnapshot<T>>
|
|
80
85
|
/** Whether the namespace currently holds a stored secret (e.g. apiKey). */
|
|
81
86
|
private readonly keySet: SnapshotStore<boolean>
|
|
87
|
+
/** Individual secret presence bits, keyed by the settings field name. */
|
|
88
|
+
private readonly secretSets: SnapshotStore<Record<string, boolean>>
|
|
82
89
|
private tail: Promise<void> = Promise.resolve()
|
|
83
90
|
private disposed = false
|
|
84
91
|
|
|
@@ -96,6 +103,7 @@ class BridgeScopeController<T> implements SettingsScope<T> {
|
|
|
96
103
|
mode: 'host',
|
|
97
104
|
})
|
|
98
105
|
this.keySet = createSnapshotStore(false)
|
|
106
|
+
this.secretSets = createSnapshotStore({})
|
|
99
107
|
}
|
|
100
108
|
|
|
101
109
|
getSnapshot(): SettingsScopeSnapshot<T> {
|
|
@@ -112,6 +120,16 @@ class BridgeScopeController<T> implements SettingsScope<T> {
|
|
|
112
120
|
return this.keySet.subscribe(listener)
|
|
113
121
|
}
|
|
114
122
|
|
|
123
|
+
/** Whether a specific secret field currently has a stored value. */
|
|
124
|
+
getSecretSetSnapshot(field: string): boolean {
|
|
125
|
+
return this.secretSets.getSnapshot()[field] === true
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Observe changes to individual secret-field presence bits. */
|
|
129
|
+
subscribeSecretSets(listener: () => void): () => void {
|
|
130
|
+
return this.secretSets.subscribe(listener)
|
|
131
|
+
}
|
|
132
|
+
|
|
115
133
|
subscribe(listener: () => void): () => void {
|
|
116
134
|
return this.store.subscribe(listener)
|
|
117
135
|
}
|
|
@@ -164,6 +182,7 @@ class BridgeScopeController<T> implements SettingsScope<T> {
|
|
|
164
182
|
draft.writable = writable === true
|
|
165
183
|
})
|
|
166
184
|
this.keySet.set(false)
|
|
185
|
+
this.secretSets.set({})
|
|
167
186
|
return
|
|
168
187
|
}
|
|
169
188
|
this.accept(view, writable)
|
|
@@ -200,7 +219,9 @@ class BridgeScopeController<T> implements SettingsScope<T> {
|
|
|
200
219
|
// card binds without a narrowing decoder.
|
|
201
220
|
draft.value = view.value as T
|
|
202
221
|
})
|
|
203
|
-
|
|
222
|
+
const secretSets = Object.fromEntries((view.secrets ?? []).map(secret => [secret.path.join('.'), secret.set]))
|
|
223
|
+
this.keySet.set(Object.values(secretSets).some(Boolean))
|
|
224
|
+
this.secretSets.set(secretSets)
|
|
204
225
|
}
|
|
205
226
|
}
|
|
206
227
|
|
|
@@ -210,6 +231,8 @@ export interface ImageGenScope extends SettingsScope<ImageGenConfig> {
|
|
|
210
231
|
load(): Promise<void>
|
|
211
232
|
getKeySetSnapshot(): boolean
|
|
212
233
|
subscribeKeySet(listener: () => void): () => void
|
|
234
|
+
getSecretSetSnapshot(field: string): boolean
|
|
235
|
+
subscribeSecretSets(listener: () => void): () => void
|
|
213
236
|
}
|
|
214
237
|
|
|
215
238
|
/**
|
package/src/engine.ts
CHANGED
|
@@ -69,6 +69,27 @@ const GROK_ASPECT_ALIASES: Readonly<Record<string, string>> = {
|
|
|
69
69
|
'21:9': '20:9',
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* One request-scoped timeout that is cleared as soon as its fetch settles.
|
|
74
|
+
* AbortSignal.timeout() cannot be disposed early; using it inside a long-lived
|
|
75
|
+
* task queue leaves an otherwise idle Node process holding every timeout.
|
|
76
|
+
*/
|
|
77
|
+
function requestSignal(source: AbortSignal | undefined, timeoutMs: number): { signal: AbortSignal; dispose: () => void } {
|
|
78
|
+
const controller = new AbortController()
|
|
79
|
+
const abortFromSource = () => { controller.abort(source?.reason) }
|
|
80
|
+
if (source?.aborted === true) abortFromSource()
|
|
81
|
+
else source?.addEventListener('abort', abortFromSource, { once: true })
|
|
82
|
+
const timeout = setTimeout(() => { controller.abort(new DOMException('The operation timed out.', 'TimeoutError')) }, timeoutMs)
|
|
83
|
+
timeout.unref()
|
|
84
|
+
return {
|
|
85
|
+
signal: controller.signal,
|
|
86
|
+
dispose: () => {
|
|
87
|
+
clearTimeout(timeout)
|
|
88
|
+
source?.removeEventListener('abort', abortFromSource)
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
72
93
|
/** Content-type extension hints for URL-fetched images. */
|
|
73
94
|
function mimeOfExtension(path: string): string | undefined {
|
|
74
95
|
const match = /\.([a-z0-9]+)$/i.exec(path)
|
|
@@ -181,16 +202,19 @@ async function normalizeItem(
|
|
|
181
202
|
if (parsed === undefined) throw new ImageGenError('upstream returned a malformed data: url')
|
|
182
203
|
return { b64: parsed.base64, mime: parsed.mime, revisedPrompt }
|
|
183
204
|
}
|
|
205
|
+
const budget = requestSignal(undefined, IMAGE_FETCH_TIMEOUT_MS)
|
|
184
206
|
let response: Response
|
|
185
207
|
try {
|
|
186
208
|
response = await fetch(url, {
|
|
187
209
|
headers: {
|
|
188
210
|
...upstream.apiKey === '' ? {} : { authorization: `Bearer ${upstream.apiKey}` },
|
|
189
211
|
},
|
|
190
|
-
signal:
|
|
212
|
+
signal: budget.signal,
|
|
191
213
|
})
|
|
192
214
|
} catch (error) {
|
|
193
215
|
throw new ImageGenError(`failed to fetch the generated image url: ${error instanceof Error ? error.message : String(error)}`)
|
|
216
|
+
} finally {
|
|
217
|
+
budget.dispose()
|
|
194
218
|
}
|
|
195
219
|
if (!response.ok) {
|
|
196
220
|
throw new ImageGenError(`failed to fetch the generated image url: HTTP ${response.status}`)
|
|
@@ -212,6 +236,7 @@ async function requestOneImage(
|
|
|
212
236
|
upstream: UpstreamConfig,
|
|
213
237
|
request: GenerateRequest,
|
|
214
238
|
params: ReturnType<typeof effectiveParams>,
|
|
239
|
+
signal?: AbortSignal,
|
|
215
240
|
): Promise<GeneratedImage[]> {
|
|
216
241
|
const headers: Record<string, string> = {
|
|
217
242
|
authorization: `Bearer ${upstream.apiKey.trim()}`,
|
|
@@ -258,13 +283,14 @@ async function requestOneImage(
|
|
|
258
283
|
body = JSON.stringify({ prompt: request.prompt, ...params } as Record<string, unknown>)
|
|
259
284
|
}
|
|
260
285
|
|
|
286
|
+
const budget = requestSignal(signal, UPSTREAM_TIMEOUT_MS)
|
|
261
287
|
let response: Response
|
|
262
288
|
try {
|
|
263
289
|
response = await fetch(`${baseUrl}/images/${request.mode === 'edit' ? 'edits' : 'generations'}`, {
|
|
264
290
|
method: 'POST',
|
|
265
291
|
headers,
|
|
266
292
|
body,
|
|
267
|
-
signal:
|
|
293
|
+
signal: budget.signal,
|
|
268
294
|
})
|
|
269
295
|
} catch (error) {
|
|
270
296
|
const message = error instanceof Error ? error.message : String(error)
|
|
@@ -272,6 +298,8 @@ async function requestOneImage(
|
|
|
272
298
|
throw new ImageGenError('上游接口响应超时(240 秒)', 'upstream-timeout')
|
|
273
299
|
}
|
|
274
300
|
throw new ImageGenError(`无法连接上游接口:${message}`, 'upstream-unreachable')
|
|
301
|
+
} finally {
|
|
302
|
+
budget.dispose()
|
|
275
303
|
}
|
|
276
304
|
|
|
277
305
|
let payload: unknown
|
|
@@ -312,14 +340,14 @@ async function requestOneImage(
|
|
|
312
340
|
* parameter is never sent, because Responses-API-based gateways reject it as
|
|
313
341
|
* `tools[0].n`), then the results are flattened in order.
|
|
314
342
|
*/
|
|
315
|
-
export async function generateImage(upstream: UpstreamConfig, request: GenerateRequest): Promise<GenerateResult> {
|
|
343
|
+
export async function generateImage(upstream: UpstreamConfig, request: GenerateRequest, options: { signal?: AbortSignal } = {}): Promise<GenerateResult> {
|
|
316
344
|
const baseUrl = upstream.apiUrl.trim().replace(/\/+$/, '')
|
|
317
345
|
if (baseUrl === '') throw new ImageGenError('api_url 未配置:请先在「设置 → 插件 → 可配置」中填写', 'config-missing')
|
|
318
346
|
if (upstream.apiKey.trim() === '') throw new ImageGenError('api_key 未配置:请先在「设置 → 插件 → 可配置」中填写', 'config-missing')
|
|
319
347
|
const params = effectiveParams(request)
|
|
320
348
|
const count = effectiveCount(request)
|
|
321
349
|
const batches = await Promise.all(
|
|
322
|
-
Array.from({ length: count }, () => requestOneImage(baseUrl, upstream, request, params)),
|
|
350
|
+
Array.from({ length: count }, () => requestOneImage(baseUrl, upstream, request, params, options.signal)),
|
|
323
351
|
)
|
|
324
352
|
return { images: batches.flat() }
|
|
325
353
|
}
|
package/src/gallery-store.ts
CHANGED
|
@@ -60,6 +60,7 @@ interface StoredEntry {
|
|
|
60
60
|
images: StoredImage[]
|
|
61
61
|
hash?: string
|
|
62
62
|
refName?: string
|
|
63
|
+
tags?: string[]
|
|
63
64
|
}
|
|
64
65
|
|
|
65
66
|
/** The index.json shape. */
|
|
@@ -171,6 +172,7 @@ function toWire(entry: StoredEntry): HistoryEntry {
|
|
|
171
172
|
...image.revisedPrompt === undefined ? {} : { revisedPrompt: image.revisedPrompt },
|
|
172
173
|
})),
|
|
173
174
|
...entry.refName === undefined ? {} : { refName: entry.refName },
|
|
175
|
+
...entry.tags === undefined ? {} : { tags: entry.tags },
|
|
174
176
|
}
|
|
175
177
|
}
|
|
176
178
|
|
|
@@ -242,6 +244,18 @@ export async function removeGallery(id: string): Promise<HistoryEntry[]> {
|
|
|
242
244
|
})
|
|
243
245
|
}
|
|
244
246
|
|
|
247
|
+
/** Replace the user-managed labels for one gallery entry. */
|
|
248
|
+
export async function updateGalleryTags(id: string, tags: string[]): Promise<HistoryEntry[]> {
|
|
249
|
+
return mutateGallery(async () => {
|
|
250
|
+
const normalized = [...new Set(tags.map(tag => tag.trim()).filter(Boolean))].slice(0, 20)
|
|
251
|
+
const entries = await readIndex()
|
|
252
|
+
const target = entries.find(entry => entry.id === id)
|
|
253
|
+
if (target !== undefined) target.tags = normalized
|
|
254
|
+
await writeIndex(entries)
|
|
255
|
+
return entries.map(toWire)
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
|
|
245
259
|
/** Remove every entry (and all image files). */
|
|
246
260
|
export async function clearGallery(): Promise<HistoryEntry[]> {
|
|
247
261
|
return mutateGallery(async () => {
|
|
@@ -263,4 +277,4 @@ export async function readGalleryImage(file: string): Promise<{ data: Buffer; mi
|
|
|
263
277
|
} catch {
|
|
264
278
|
return undefined
|
|
265
279
|
}
|
|
266
|
-
}
|
|
280
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared host-side generation runtime. Both the browser routes and Agent tools
|
|
3
|
+
* submit to this one queue so persisted history and cancellation semantics stay
|
|
4
|
+
* identical regardless of where a request originated.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { randomUUID } from 'node:crypto'
|
|
8
|
+
import { generateImage, type UpstreamConfig } from './engine.ts'
|
|
9
|
+
import { appendHistory } from './history-store.ts'
|
|
10
|
+
import type { GenerateRequest, GenerateResult, HistoryEntry, HistoryEntryInput } from './protocol.ts'
|
|
11
|
+
import { GenerationTaskQueue } from './task-queue.ts'
|
|
12
|
+
|
|
13
|
+
export interface HistorySink {
|
|
14
|
+
append(entry: HistoryEntryInput): Promise<HistoryEntry[]>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class ImageGenerationRuntime {
|
|
18
|
+
readonly queue: GenerationTaskQueue
|
|
19
|
+
|
|
20
|
+
constructor(
|
|
21
|
+
private readonly resolve: () => UpstreamConfig,
|
|
22
|
+
private readonly history: HistorySink = { append: appendHistory },
|
|
23
|
+
) {
|
|
24
|
+
this.queue = new GenerationTaskQueue((request, signal) => this.run(request, signal))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async run(request: GenerateRequest, signal?: AbortSignal): Promise<GenerateResult> {
|
|
28
|
+
const result = await generateImage(this.resolve(), request, { signal })
|
|
29
|
+
try {
|
|
30
|
+
const history = await this.history.append({
|
|
31
|
+
id: randomUUID(),
|
|
32
|
+
createdAt: Date.now(),
|
|
33
|
+
mode: request.mode,
|
|
34
|
+
model: request.model,
|
|
35
|
+
prompt: request.prompt,
|
|
36
|
+
size: request.size,
|
|
37
|
+
quality: request.quality,
|
|
38
|
+
detail: request.detail,
|
|
39
|
+
n: request.n,
|
|
40
|
+
images: result.images,
|
|
41
|
+
...request.refName === undefined ? {} : { refName: request.refName },
|
|
42
|
+
})
|
|
43
|
+
return { ...result, history }
|
|
44
|
+
} catch (error) {
|
|
45
|
+
return { ...result, historyError: error instanceof Error ? error.message : String(error) }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image-model configuration shared by the host, panel, and Agent tools.
|
|
3
|
+
* `/models` exposes candidates only: the configured list is the explicit
|
|
4
|
+
* allow-list because OpenAI-compatible gateways rarely advertise modalities.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_IMAGE_MODELS = ['gpt-image-2', 'grok-imagine-image'] as const
|
|
8
|
+
|
|
9
|
+
/** Normalize user-entered model identifiers and retain a usable legacy default. */
|
|
10
|
+
export function normalizeImageModels(value: unknown): string[] {
|
|
11
|
+
const candidates = Array.isArray(value) ? value : []
|
|
12
|
+
const unique = new Set<string>()
|
|
13
|
+
for (const candidate of candidates) {
|
|
14
|
+
if (typeof candidate !== 'string') continue
|
|
15
|
+
const model = candidate.trim()
|
|
16
|
+
if (model !== '') unique.add(model)
|
|
17
|
+
}
|
|
18
|
+
return unique.size > 0 ? [...unique] : [...DEFAULT_IMAGE_MODELS]
|
|
19
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -13,8 +13,13 @@ import z from 'schemastery'
|
|
|
13
13
|
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
14
14
|
// Type-only: pulls the systemPrompt Context merge (announcement section).
|
|
15
15
|
import type {} from '@deepseek-ai/dsh-system-prompt'
|
|
16
|
+
import type {} from '@deepseek-ai/dsh-tools'
|
|
17
|
+
import type {} from '@deepseek-ai/dsh-attachment'
|
|
16
18
|
import { IMAGEGEN_SETTINGS_NAMESPACE } from './protocol.ts'
|
|
17
19
|
import { makeRoutes, type SettingsSeam } from './routes.ts'
|
|
20
|
+
import { ImageGenerationRuntime } from './generation-runtime.ts'
|
|
21
|
+
import { registerAgentImageTools } from './agent-image-tools.ts'
|
|
22
|
+
import { DEFAULT_IMAGE_MODELS, normalizeImageModels } from './image-models.ts'
|
|
18
23
|
|
|
19
24
|
/** Stable cordis plugin name. */
|
|
20
25
|
export const name = 'imagegen'
|
|
@@ -26,7 +31,9 @@ export const inject = ['webServer', 'systemPrompt']
|
|
|
26
31
|
// contract only requires name / inject / Config / apply.
|
|
27
32
|
export { makeRoutes } from './routes.ts'
|
|
28
33
|
export { generateImage, ImageGenError } from './engine.ts'
|
|
29
|
-
export {
|
|
34
|
+
export { ImageGenerationRuntime } from './generation-runtime.ts'
|
|
35
|
+
export { registerAgentImageTools } from './agent-image-tools.ts'
|
|
36
|
+
export { appendGallery, clearGallery, listGallery, readGalleryImage, removeGallery, updateGalleryTags } from './gallery-store.ts'
|
|
30
37
|
export { listTemplates, readTemplateImage, refreshTemplates, clearTemplateMemo } from './templates-store.ts'
|
|
31
38
|
export { checkForUpdate, clearUpdateCache, compareVersions, CURRENT_VERSION, installUpdate, profileFromProcess } from './updater.ts'
|
|
32
39
|
|
|
@@ -39,35 +46,61 @@ export interface Config {
|
|
|
39
46
|
enabled?: boolean
|
|
40
47
|
/** Announce the plugin in every agent's system prompt. */
|
|
41
48
|
announceToAgent?: boolean
|
|
49
|
+
/** Allow Agents to submit and retrieve image-generation tasks. */
|
|
50
|
+
allowAgentImageGeneration?: boolean
|
|
42
51
|
/** Base URL of the OpenAI-compatible endpoint, e.g. https://api.openai.com/v1 */
|
|
43
52
|
apiUrl?: string
|
|
44
53
|
/** Bearer API key (stored as a secret field on the settings seam). */
|
|
45
54
|
apiKey?: string
|
|
55
|
+
/** Explicit allow-list of image models selected for this API endpoint. */
|
|
56
|
+
imageModels?: string[]
|
|
57
|
+
/** Optional OpenAI-compatible chat endpoint for prompt enhancement. */
|
|
58
|
+
promptApiUrl?: string
|
|
59
|
+
/** Optional secret for the prompt enhancement endpoint. */
|
|
60
|
+
promptApiKey?: string
|
|
61
|
+
/** Chat model used to expand short image prompts. */
|
|
62
|
+
promptModel?: string
|
|
46
63
|
}
|
|
47
64
|
|
|
48
65
|
export const Config: z<Config> = z.object({
|
|
49
66
|
enabled: z.boolean().default(true),
|
|
50
67
|
announceToAgent: z.boolean().default(true),
|
|
68
|
+
allowAgentImageGeneration: z.boolean().default(true),
|
|
51
69
|
apiUrl: z.string().default(''),
|
|
52
70
|
apiKey: z.string().role('secret').default(''),
|
|
71
|
+
imageModels: z.array(z.string()).default([...DEFAULT_IMAGE_MODELS]),
|
|
72
|
+
promptApiUrl: z.string().default(''),
|
|
73
|
+
promptApiKey: z.string().role('secret').default(''),
|
|
74
|
+
promptModel: z.string().default(''),
|
|
53
75
|
})
|
|
54
76
|
|
|
55
77
|
/** Schema defaults, re-read for hand-built contexts (the loader applies them normally). */
|
|
56
78
|
const DEFAULT_ENABLED = true
|
|
57
79
|
const DEFAULT_ANNOUNCE = true
|
|
80
|
+
const DEFAULT_ALLOW_AGENT_IMAGE_GENERATION = true
|
|
58
81
|
|
|
59
82
|
/** Order of the announcement section within the tool-guidance band. */
|
|
60
83
|
const SECTION_ORDER = 150
|
|
61
84
|
|
|
62
85
|
/** Model-facing announcement: plugin presence, capabilities, and limits. */
|
|
63
|
-
export const IMAGEGEN_GUIDANCE = '本机已安装 dsh-imagegen 插件(DSH AI 生图):侧边栏「AI 生图」入口。能力:对接 OpenAI 兼容图像生成 API
|
|
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` 图生图;任务后台异步执行,完成后插件会自动唤醒原对话,并以可直接查看和复用的图片附件回贴结果,因此不要反复轮询。仅在用户明确要求进度或需要恢复任务时,才使用 `get_image_generation_task` 查询状态。限制:生成消耗上游 API 额度;图片内容由上游模型生成,可能不符合预期或包含不适宜内容;api_key 以明文存储在设置文档中;参考图会发送至所配置的 API 服务;模板库在线刷新与参考图首次加载需要访问 vibeui.top。用户提到「生图 / 绘画 / 生成图片 / 文生图 / 图生图 / 画廊 / 提示词模板」时即指本插件,请据此协作。'
|
|
87
|
+
|
|
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 必须使用该精确名称;未指定时使用列表中的第一个。`
|
|
91
|
+
}
|
|
64
92
|
|
|
65
93
|
/** Effective config (schema defaults applied). */
|
|
66
94
|
interface EffectiveConfig {
|
|
67
95
|
enabled: boolean
|
|
68
96
|
announceToAgent: boolean
|
|
97
|
+
allowAgentImageGeneration: boolean
|
|
69
98
|
apiUrl: string
|
|
70
99
|
apiKey: string
|
|
100
|
+
imageModels: string[]
|
|
101
|
+
promptApiUrl: string
|
|
102
|
+
promptApiKey: string
|
|
103
|
+
promptModel: string
|
|
71
104
|
}
|
|
72
105
|
|
|
73
106
|
/**
|
|
@@ -84,11 +117,24 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
84
117
|
return {
|
|
85
118
|
enabled: value.enabled ?? DEFAULT_ENABLED,
|
|
86
119
|
announceToAgent: value.announceToAgent ?? DEFAULT_ANNOUNCE,
|
|
120
|
+
allowAgentImageGeneration: value.allowAgentImageGeneration ?? DEFAULT_ALLOW_AGENT_IMAGE_GENERATION,
|
|
87
121
|
apiUrl: value.apiUrl ?? '',
|
|
88
122
|
apiKey: value.apiKey ?? '',
|
|
123
|
+
imageModels: normalizeImageModels(value.imageModels),
|
|
124
|
+
promptApiUrl: value.promptApiUrl ?? '',
|
|
125
|
+
promptApiKey: value.promptApiKey ?? '',
|
|
126
|
+
promptModel: value.promptModel ?? '',
|
|
89
127
|
}
|
|
90
128
|
}
|
|
91
129
|
|
|
130
|
+
// Browser endpoints and Agent tools share the exact same serial queue. This
|
|
131
|
+
// keeps image persistence, cancellation, and retries coherent across both
|
|
132
|
+
// entry points while a tool call itself returns immediately with a task id.
|
|
133
|
+
const runtime = new ImageGenerationRuntime(() => {
|
|
134
|
+
const value = resolve()
|
|
135
|
+
return { apiUrl: value.apiUrl, apiKey: value.apiKey }
|
|
136
|
+
})
|
|
137
|
+
|
|
92
138
|
// The route family mounts once, gated on the settings seam (the bridge
|
|
93
139
|
// serves it; without the seam there is nothing to expose). Route handlers
|
|
94
140
|
// read resolve() per request, so config edits apply live. The settings
|
|
@@ -104,6 +150,16 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
104
150
|
const value = resolve()
|
|
105
151
|
return { apiUrl: value.apiUrl, apiKey: value.apiKey }
|
|
106
152
|
},
|
|
153
|
+
resolvePrompt: () => {
|
|
154
|
+
const value = resolve()
|
|
155
|
+
return {
|
|
156
|
+
apiUrl: value.promptApiUrl.trim() || value.apiUrl,
|
|
157
|
+
apiKey: value.promptApiKey.trim() || value.apiKey,
|
|
158
|
+
model: value.promptModel,
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
resolveImageModels: () => resolve().imageModels,
|
|
162
|
+
runtime,
|
|
107
163
|
})
|
|
108
164
|
const disposers = routes.map(route => ctx.webServer.register(route))
|
|
109
165
|
return () => { for (const dispose of disposers) dispose() }
|
|
@@ -112,6 +168,19 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
112
168
|
)
|
|
113
169
|
})
|
|
114
170
|
|
|
171
|
+
ctx.inject(['tools', 'attachments'], (tctx) => {
|
|
172
|
+
tctx.effect(() => registerAgentImageTools(tctx, runtime, () => {
|
|
173
|
+
const value = resolve()
|
|
174
|
+
return {
|
|
175
|
+
enabled: value.enabled,
|
|
176
|
+
allowAgentImageGeneration: value.allowAgentImageGeneration,
|
|
177
|
+
apiUrl: value.apiUrl,
|
|
178
|
+
apiKey: value.apiKey,
|
|
179
|
+
imageModels: value.imageModels,
|
|
180
|
+
}
|
|
181
|
+
}), 'dsh-imagegen: agent image tools')
|
|
182
|
+
})
|
|
183
|
+
|
|
115
184
|
// System-prompt announcement (toggled by settings changes).
|
|
116
185
|
let disposeSection: (() => void) | undefined
|
|
117
186
|
const sync = (): void => {
|
|
@@ -124,7 +193,7 @@ export function apply(ctx: Context, config?: Config): void {
|
|
|
124
193
|
disposeSection = ctx.systemPrompt.section({
|
|
125
194
|
name: 'plugin:dsh-imagegen',
|
|
126
195
|
order: SECTION_ORDER,
|
|
127
|
-
text:
|
|
196
|
+
text: guidanceFor(value.imageModels),
|
|
128
197
|
})
|
|
129
198
|
}
|
|
130
199
|
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** OpenAI-compatible chat helpers used by the optional prompt-enhancement UI. */
|
|
2
|
+
|
|
3
|
+
export interface PromptModelConfig {
|
|
4
|
+
apiUrl: string
|
|
5
|
+
apiKey: string
|
|
6
|
+
model: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Credentials shared by OpenAI-compatible `/models` discovery. */
|
|
10
|
+
export interface ModelListConfig {
|
|
11
|
+
apiUrl: string
|
|
12
|
+
apiKey: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function endpoint(base: string, suffix: string): string {
|
|
16
|
+
return `${base.replace(/\/+$/, '')}${suffix}`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function headers(apiKey: string): HeadersInit {
|
|
20
|
+
return {
|
|
21
|
+
'content-type': 'application/json',
|
|
22
|
+
...apiKey.trim() === '' ? {} : { authorization: `Bearer ${apiKey.trim()}` },
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function responseJson(response: Response): Promise<Record<string, unknown>> {
|
|
27
|
+
const body: unknown = await response.json().catch(() => undefined)
|
|
28
|
+
if (!response.ok || body === undefined || body === null || typeof body !== 'object') {
|
|
29
|
+
const message = body !== null && typeof body === 'object' && typeof (body as { error?: { message?: unknown } }).error?.message === 'string'
|
|
30
|
+
? (body as { error: { message: string } }).error.message
|
|
31
|
+
: `HTTP ${response.status}`
|
|
32
|
+
throw new Error(message)
|
|
33
|
+
}
|
|
34
|
+
return body as Record<string, unknown>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** List candidates exposed by an OpenAI-compatible endpoint. */
|
|
38
|
+
export async function listOpenAIModels(config: ModelListConfig): Promise<string[]> {
|
|
39
|
+
if (config.apiUrl.trim() === '') throw new Error('API URL is required')
|
|
40
|
+
const response = await fetch(endpoint(config.apiUrl, '/models'), { headers: headers(config.apiKey) })
|
|
41
|
+
const body = await responseJson(response)
|
|
42
|
+
const data = Array.isArray(body.data) ? body.data : []
|
|
43
|
+
return [...new Set(data
|
|
44
|
+
.flatMap(item => item !== null && typeof item === 'object' && typeof (item as { id?: unknown }).id === 'string' ? [(item as { id: string }).id.trim()] : [])
|
|
45
|
+
.filter(Boolean))]
|
|
46
|
+
.sort((a, b) => a.localeCompare(b))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** List chat models exposed by an OpenAI-compatible endpoint. */
|
|
50
|
+
export async function listPromptModels(config: PromptModelConfig): Promise<string[]> {
|
|
51
|
+
return listOpenAIModels(config)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Expand a concise image request into a production-ready image prompt. */
|
|
55
|
+
export async function enhancePrompt(config: PromptModelConfig, prompt: string): Promise<string> {
|
|
56
|
+
if (config.apiUrl.trim() === '' || config.model.trim() === '') throw new Error('prompt enhancement model is not configured')
|
|
57
|
+
const response = await fetch(endpoint(config.apiUrl, '/chat/completions'), {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
headers: headers(config.apiKey),
|
|
60
|
+
body: JSON.stringify({
|
|
61
|
+
model: config.model.trim(),
|
|
62
|
+
temperature: 0.7,
|
|
63
|
+
messages: [
|
|
64
|
+
{
|
|
65
|
+
role: 'system',
|
|
66
|
+
content: 'You are an expert image-prompt editor. Expand the user request into one vivid, specific image-generation prompt. Preserve intent and language. Add only useful visual detail: subject, composition, lighting, materials, color, camera/style and quality. Return only the finished prompt, with no preface or markdown.',
|
|
67
|
+
},
|
|
68
|
+
{ role: 'user', content: prompt },
|
|
69
|
+
],
|
|
70
|
+
}),
|
|
71
|
+
})
|
|
72
|
+
const body = await responseJson(response)
|
|
73
|
+
const choices = Array.isArray(body.choices) ? body.choices : []
|
|
74
|
+
const content = choices[0] !== null && typeof choices[0] === 'object'
|
|
75
|
+
? (choices[0] as { message?: { content?: unknown } }).message?.content
|
|
76
|
+
: undefined
|
|
77
|
+
if (typeof content !== 'string' || content.trim() === '') throw new Error('chat model returned an empty prompt')
|
|
78
|
+
return content.trim()
|
|
79
|
+
}
|
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.0
|
|
11
|
+
export const PLUGIN_VERSION = '1.2.0'
|
|
12
12
|
|
|
13
13
|
/** Same-origin route family (loopback-only, mirroring the dsh-ssh fence). */
|
|
14
14
|
export const SETTINGS_API = {
|
|
@@ -19,6 +19,25 @@ export const SETTINGS_API = {
|
|
|
19
19
|
/** The image-generation proxy route. */
|
|
20
20
|
export const GENERATE_API = '/api/dsh-imagegen/generate'
|
|
21
21
|
|
|
22
|
+
/** Host-mediated OpenAI-compatible prompt enhancement endpoints. */
|
|
23
|
+
export const PROMPT_ENHANCE_API = {
|
|
24
|
+
models: '/api/dsh-imagegen/prompt-enhance/models',
|
|
25
|
+
enhance: '/api/dsh-imagegen/prompt-enhance',
|
|
26
|
+
} as const
|
|
27
|
+
|
|
28
|
+
/** Host-mediated candidate discovery for the configured image API. */
|
|
29
|
+
export const IMAGE_MODEL_API = {
|
|
30
|
+
models: '/api/dsh-imagegen/image-models',
|
|
31
|
+
} as const
|
|
32
|
+
|
|
33
|
+
/** Host-resident generation queue endpoints. */
|
|
34
|
+
export const TASK_API = {
|
|
35
|
+
submit: '/api/dsh-imagegen/tasks/submit',
|
|
36
|
+
list: '/api/dsh-imagegen/tasks/list',
|
|
37
|
+
cancel: '/api/dsh-imagegen/tasks/cancel',
|
|
38
|
+
retry: '/api/dsh-imagegen/tasks/retry',
|
|
39
|
+
} as const
|
|
40
|
+
|
|
22
41
|
/** Host-mediated GitHub Release update routes. */
|
|
23
42
|
export const UPDATE_API = {
|
|
24
43
|
check: '/api/dsh-imagegen/update/check',
|
|
@@ -49,6 +68,7 @@ export const GALLERY_API = {
|
|
|
49
68
|
append: '/api/dsh-imagegen/gallery/append',
|
|
50
69
|
remove: '/api/dsh-imagegen/gallery/remove',
|
|
51
70
|
clear: '/api/dsh-imagegen/gallery/clear',
|
|
71
|
+
tags: '/api/dsh-imagegen/gallery/tags',
|
|
52
72
|
image: '/api/dsh-imagegen/gallery/image',
|
|
53
73
|
} as const
|
|
54
74
|
|
|
@@ -122,7 +142,7 @@ export interface GenerateRequest {
|
|
|
122
142
|
mode: GenerateMode
|
|
123
143
|
/** Upstream model name, e.g. gpt-image-2. */
|
|
124
144
|
model: string
|
|
125
|
-
/** The prompt
|
|
145
|
+
/** The prompt. Upstream providers may impose their own length limits. */
|
|
126
146
|
prompt: string
|
|
127
147
|
/** Canvas size as an aspect ratio: 'auto' or e.g. '1:1' / '16:9' / '21:9'.
|
|
128
148
|
* The host maps it onto each model's own vocabulary (aspect_ratio for Grok,
|
|
@@ -165,6 +185,19 @@ export interface GenerateResult {
|
|
|
165
185
|
historyError?: string
|
|
166
186
|
}
|
|
167
187
|
|
|
188
|
+
export type GenerationTaskStatus = 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'
|
|
189
|
+
|
|
190
|
+
export interface GenerationTask {
|
|
191
|
+
id: string
|
|
192
|
+
request: GenerateRequest
|
|
193
|
+
status: GenerationTaskStatus
|
|
194
|
+
createdAt: number
|
|
195
|
+
startedAt?: number
|
|
196
|
+
finishedAt?: number
|
|
197
|
+
result?: GenerateResult
|
|
198
|
+
error?: string
|
|
199
|
+
}
|
|
200
|
+
|
|
168
201
|
/** GitHub Release update information shown by the client. */
|
|
169
202
|
export interface UpdateInfo {
|
|
170
203
|
currentVersion: string
|
|
@@ -198,6 +231,8 @@ export interface HistoryEntry {
|
|
|
198
231
|
images: HistoryImageRef[]
|
|
199
232
|
/** Reference-image filename (edit mode), kept for display only. */
|
|
200
233
|
refName?: string
|
|
234
|
+
/** User-managed gallery labels (unused by history entries). */
|
|
235
|
+
tags?: string[]
|
|
201
236
|
}
|
|
202
237
|
|
|
203
238
|
/** A history entry the client submits for persistence (images still carry base64). */
|