@dsh-plus/image-studio 0.1.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/LICENSE +21 -0
- package/lib/client.js +129 -0
- package/lib/index.d.ts +321 -0
- package/lib/index.js +1304 -0
- package/package.json +60 -0
- package/src/api.ts +213 -0
- package/src/client/api.ts +89 -0
- package/src/client/card.tsx +14 -0
- package/src/client/client.ts +75 -0
- package/src/client/i18n.ts +20 -0
- package/src/client/section.tsx +14 -0
- package/src/client/styles.ts +33 -0
- package/src/config.ts +90 -0
- package/src/credentials.ts +22 -0
- package/src/dto.ts +87 -0
- package/src/errors.ts +47 -0
- package/src/gallery/store.ts +172 -0
- package/src/index.ts +44 -0
- package/src/ns.ts +7 -0
- package/src/params/catalog.ts +184 -0
- package/src/params/spec.ts +109 -0
- package/src/provider/openai-images.ts +266 -0
- package/src/provider/registry.ts +54 -0
- package/src/provider/types.ts +86 -0
- package/src/service.ts +458 -0
- package/src/task/runner.ts +200 -0
package/src/service.ts
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* image-studio 服务主体:
|
|
3
|
+
* - settings installSection(三组预设 + 并发/超时/代理/画廊上限);
|
|
4
|
+
* - TaskRunner 并发生图:提交即排队,成功自动入画廊(元数据 + 图片落盘);
|
|
5
|
+
* - 提供商预设按 id 即时 resolve credentials(不缓存);
|
|
6
|
+
* - 端点:generate/tasks/gallery/images/credentials/providers(api.ts 注册)。
|
|
7
|
+
* 红线:开发测试仅指向本地 mock,严禁真实调用产生费用。
|
|
8
|
+
* @module image-studio/service
|
|
9
|
+
*/
|
|
10
|
+
import { type Context, Service } from '@deepseek-ai/cordis'
|
|
11
|
+
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
12
|
+
import type {} from '@deepseek-ai/dsh-settings'
|
|
13
|
+
import { registerImageStudioApi } from './api.ts'
|
|
14
|
+
import {
|
|
15
|
+
type ImageStudioConfig,
|
|
16
|
+
type ParamPresetEntry,
|
|
17
|
+
type PromptPresetEntry,
|
|
18
|
+
type ProviderPresetEntry,
|
|
19
|
+
SETTINGS_NS,
|
|
20
|
+
} from './config.ts'
|
|
21
|
+
import { credentialRefNameOf, isValidPresetId } from './credentials.ts'
|
|
22
|
+
import type { GenerateRequest, TaskWire } from './dto.ts'
|
|
23
|
+
import { ImageStudioError } from './errors.ts'
|
|
24
|
+
import type { GalleryItem } from './gallery/store.ts'
|
|
25
|
+
import {
|
|
26
|
+
loadGallery,
|
|
27
|
+
readImageBytes,
|
|
28
|
+
deleteGalleryItem as removeGalleryItem,
|
|
29
|
+
saveGalleryItem,
|
|
30
|
+
} from './gallery/store.ts'
|
|
31
|
+
import type { ParamSpecMap } from './params/spec.ts'
|
|
32
|
+
import { normalizeParamSpecs, validateParamSpecs } from './params/spec.ts'
|
|
33
|
+
import { createProvider } from './provider/registry.ts'
|
|
34
|
+
import type { ImageEndpoint, InputImage, ProviderTarget } from './provider/types.ts'
|
|
35
|
+
import type { TaskRecord } from './task/runner.ts'
|
|
36
|
+
import { TaskRunner } from './task/runner.ts'
|
|
37
|
+
|
|
38
|
+
interface SettingsLike {
|
|
39
|
+
installSection(
|
|
40
|
+
ownerCtx: Context,
|
|
41
|
+
ns: string,
|
|
42
|
+
schema: unknown,
|
|
43
|
+
base: unknown,
|
|
44
|
+
hooks: { setSource(source: () => unknown): void; onChange(): void },
|
|
45
|
+
): void
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** credentials seam 最小面(真实服务经 inject 探测获取)。 */
|
|
49
|
+
interface CredentialsFace {
|
|
50
|
+
resolve(ref: unknown): Promise<{ value: string; source: string } | undefined>
|
|
51
|
+
describe(ref: unknown): Promise<{ configured: boolean }>
|
|
52
|
+
set(ref: unknown, value: string): Promise<void>
|
|
53
|
+
unset(ref: unknown): Promise<void>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 提交时的请求摘要(任务快照)。 */
|
|
57
|
+
interface TaskSnapshot {
|
|
58
|
+
endpoint: ImageEndpoint
|
|
59
|
+
model: string
|
|
60
|
+
prompt: string
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 单任务成功产物(TaskRunner result 槽)。 */
|
|
64
|
+
interface TaskOutcome {
|
|
65
|
+
galleryItemId: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class ImageStudioService extends Service {
|
|
69
|
+
private current: () => ImageStudioConfig
|
|
70
|
+
private readonly runner: TaskRunner<TaskSnapshot>
|
|
71
|
+
private readonly log: (message: string) => void
|
|
72
|
+
|
|
73
|
+
constructor(ctx: Context, config: ImageStudioConfig) {
|
|
74
|
+
super(ctx, 'imageStudio')
|
|
75
|
+
this.current = () => config
|
|
76
|
+
this.log = (message) => ctx.logger('image-studio').warn(message)
|
|
77
|
+
this.runner = new TaskRunner<TaskSnapshot>({ maxConcurrent: config.maxConcurrent })
|
|
78
|
+
ctx.inject(['settings'], (settingsCtx) => {
|
|
79
|
+
this.settingsRef = settingsCtx.settings as unknown as SettingsLike
|
|
80
|
+
settingsCtx.settings.installSection(ctx, SETTINGS_NS, configSchemaRef, config, {
|
|
81
|
+
setSource: (source) => {
|
|
82
|
+
this.current = source as () => ImageStudioConfig
|
|
83
|
+
this.applyConfig()
|
|
84
|
+
},
|
|
85
|
+
onChange: () => {},
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
ctx.inject(['webServer'], (webCtx) => {
|
|
89
|
+
registerImageStudioApi(webCtx as Context, this)
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 配置热更:并发上限即时生效(进行中任务不受影响)。 */
|
|
94
|
+
private applyConfig(): void {
|
|
95
|
+
this.runner.reconfigure(this.current().maxConcurrent)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---------- 预设读取 ----------
|
|
99
|
+
|
|
100
|
+
private promptPresets(): PromptPresetEntry[] {
|
|
101
|
+
return this.current().promptPresets
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private paramPresets(): ParamPresetEntry[] {
|
|
105
|
+
return this.current().paramPresets as ParamPresetEntry[]
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private providerPresets(): ProviderPresetEntry[] {
|
|
109
|
+
return this.current().providerPresets as ProviderPresetEntry[]
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 预设快照(端点投影)。 */
|
|
113
|
+
presets(): {
|
|
114
|
+
promptPresets: PromptPresetEntry[]
|
|
115
|
+
paramPresets: ParamPresetEntry[]
|
|
116
|
+
providerPresets: ProviderPresetEntry[]
|
|
117
|
+
} {
|
|
118
|
+
return {
|
|
119
|
+
promptPresets: this.promptPresets(),
|
|
120
|
+
paramPresets: this.paramPresets(),
|
|
121
|
+
providerPresets: this.providerPresets(),
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------- 凭据 ----------
|
|
126
|
+
|
|
127
|
+
/** describe 单个提供商预设凭据(不回传值)。 */
|
|
128
|
+
async credentialStatus(
|
|
129
|
+
presetId: string,
|
|
130
|
+
): Promise<{ credentialRef: string; configured: boolean }> {
|
|
131
|
+
const refName = credentialRefNameOf(presetId)
|
|
132
|
+
const credentials = await this.credentialsSeam()
|
|
133
|
+
if (credentials === null) return { credentialRef: refName, configured: false }
|
|
134
|
+
const info = await credentials.describe(credentialRef(refName))
|
|
135
|
+
return { credentialRef: refName, configured: info.configured }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** set/unset 凭据(端点写通道)。 */
|
|
139
|
+
async setCredential(presetId: string, value: string): Promise<void> {
|
|
140
|
+
const credentials = await this.requireCredentials()
|
|
141
|
+
await credentials.set(credentialRef(credentialRefNameOf(presetId)), value)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async unsetCredential(presetId: string): Promise<void> {
|
|
145
|
+
const credentials = await this.requireCredentials()
|
|
146
|
+
await credentials.unset(credentialRef(credentialRefNameOf(presetId)))
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** credentials seam 可选探测(缺席返回 null)。 */
|
|
150
|
+
private async credentialsSeam(): Promise<CredentialsFace | null> {
|
|
151
|
+
const found = (this.ctx as unknown as { get?(key: 'credentials'): unknown }).get?.(
|
|
152
|
+
'credentials',
|
|
153
|
+
)
|
|
154
|
+
return found === undefined || found === null ? null : (found as CredentialsFace)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private async requireCredentials(): Promise<CredentialsFace> {
|
|
158
|
+
const seam = await this.credentialsSeam()
|
|
159
|
+
if (seam === null) throw new ImageStudioError('credential-unavailable')
|
|
160
|
+
return seam
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---------- 生图任务 ----------
|
|
164
|
+
|
|
165
|
+
/** 提交生图任务:校验 → 入队 → 返回 taskId。 */
|
|
166
|
+
async submitGenerate(request: GenerateRequest): Promise<string> {
|
|
167
|
+
const snapshot = await this.prepareTask(request)
|
|
168
|
+
const summary: TaskSnapshot = {
|
|
169
|
+
endpoint: snapshot.endpoint,
|
|
170
|
+
model: snapshot.target.model,
|
|
171
|
+
prompt: snapshot.prompt,
|
|
172
|
+
}
|
|
173
|
+
const record = this.runner.submit(summary, (signal) => this.executeTask(snapshot, signal))
|
|
174
|
+
return record.id
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** 单任务查询(wire 投影)。 */
|
|
178
|
+
taskWire(id: string): TaskWire | null {
|
|
179
|
+
const record = this.runner.get(id)
|
|
180
|
+
return record === null ? null : this.toTaskWire(record)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** 任务列表(最新在前)。 */
|
|
184
|
+
taskWires(): TaskWire[] {
|
|
185
|
+
return this.runner
|
|
186
|
+
.list()
|
|
187
|
+
.reverse()
|
|
188
|
+
.map((record) => this.toTaskWire(record))
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** 取消任务。 */
|
|
192
|
+
cancelTask(id: string): boolean {
|
|
193
|
+
return this.runner.cancel(id)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private toTaskWire(record: TaskRecord<TaskSnapshot>): TaskWire {
|
|
197
|
+
const outcome = record.result as TaskOutcome | null
|
|
198
|
+
return {
|
|
199
|
+
id: record.id,
|
|
200
|
+
state: record.state,
|
|
201
|
+
endpoint: record.snapshot.endpoint,
|
|
202
|
+
model: record.snapshot.model,
|
|
203
|
+
promptPreview: record.snapshot.prompt.slice(0, 120),
|
|
204
|
+
createdAt: record.createdAt,
|
|
205
|
+
startedAt: record.startedAt,
|
|
206
|
+
finishedAt: record.finishedAt,
|
|
207
|
+
error: record.error,
|
|
208
|
+
galleryItemId:
|
|
209
|
+
record.state === 'succeeded' && outcome !== null ? outcome.galleryItemId : null,
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* 任务准备(边界校验集中地):提供商预设/inline → target;
|
|
215
|
+
* 参数预设 + 覆盖 → 归一化参数表;sourceIds → 输入图字节。
|
|
216
|
+
*/
|
|
217
|
+
private async prepareTask(request: GenerateRequest): Promise<{
|
|
218
|
+
target: ProviderTarget
|
|
219
|
+
endpoint: ImageEndpoint
|
|
220
|
+
prompt: string
|
|
221
|
+
n: number
|
|
222
|
+
images: InputImage[]
|
|
223
|
+
mask: InputImage | null
|
|
224
|
+
params: Record<string, unknown>
|
|
225
|
+
sourceIds: string[]
|
|
226
|
+
}> {
|
|
227
|
+
const target = await this.resolveTarget(request)
|
|
228
|
+
const endpoint = request.endpoint
|
|
229
|
+
if (endpoint !== 'generation' && endpoint !== 'edit') {
|
|
230
|
+
throw new ImageStudioError('invalid-request', `未知端点:${String(endpoint)}`)
|
|
231
|
+
}
|
|
232
|
+
if (typeof request.prompt !== 'string' || request.prompt.trim().length === 0) {
|
|
233
|
+
throw new ImageStudioError('invalid-request', 'prompt 不能为空')
|
|
234
|
+
}
|
|
235
|
+
const params = await this.resolveParams(request, endpoint)
|
|
236
|
+
const nValue = params.n
|
|
237
|
+
const n = typeof nValue === 'number' ? nValue : 1
|
|
238
|
+
const sourceIds = request.sourceIds ?? []
|
|
239
|
+
const images = await this.loadSourceImages(sourceIds, endpoint)
|
|
240
|
+
const mask = request.maskId !== undefined ? await this.loadMask(request.maskId) : null
|
|
241
|
+
return {
|
|
242
|
+
target,
|
|
243
|
+
endpoint,
|
|
244
|
+
prompt: request.prompt,
|
|
245
|
+
n,
|
|
246
|
+
images,
|
|
247
|
+
mask,
|
|
248
|
+
params: stripHostParams(params),
|
|
249
|
+
sourceIds,
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** 提供商预设 / inline → ProviderTarget(凭据即时 resolve)。 */
|
|
254
|
+
private async resolveTarget(request: GenerateRequest): Promise<ProviderTarget> {
|
|
255
|
+
const config = this.current()
|
|
256
|
+
const inline = request.inlineProvider
|
|
257
|
+
let protocol: string
|
|
258
|
+
let baseUrl: string
|
|
259
|
+
let model: string
|
|
260
|
+
let extraHeaders: Record<string, string> = {}
|
|
261
|
+
let refName: string
|
|
262
|
+
if (inline !== undefined) {
|
|
263
|
+
protocol = inline.protocol
|
|
264
|
+
baseUrl = inline.baseUrl
|
|
265
|
+
model = inline.model
|
|
266
|
+
// inline 无独立凭据:复用 providerPresetId 引用或拒绝。
|
|
267
|
+
if (request.providerPresetId === undefined) {
|
|
268
|
+
throw new ImageStudioError(
|
|
269
|
+
'invalid-request',
|
|
270
|
+
'inline 提供商必须同时给出 providerPresetId 以定位凭据',
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
refName = credentialRefNameOf(request.providerPresetId)
|
|
274
|
+
} else if (request.providerPresetId !== undefined) {
|
|
275
|
+
const preset = this.providerPresets().find((item) => item.id === request.providerPresetId)
|
|
276
|
+
if (preset === undefined) {
|
|
277
|
+
throw new ImageStudioError('invalid-request', `未知提供商预设:${request.providerPresetId}`)
|
|
278
|
+
}
|
|
279
|
+
protocol = preset.protocol
|
|
280
|
+
baseUrl = preset.baseUrl
|
|
281
|
+
model = preset.model
|
|
282
|
+
extraHeaders = preset.extraHeaders ?? {}
|
|
283
|
+
refName = isValidPresetId(preset.id)
|
|
284
|
+
? credentialRefNameOf(preset.id)
|
|
285
|
+
: credentialRefNameOf(preset.credentialRef)
|
|
286
|
+
} else {
|
|
287
|
+
throw new ImageStudioError('invalid-request', '必须提供 providerPresetId 或 inlineProvider')
|
|
288
|
+
}
|
|
289
|
+
const apiKey = await this.resolveApiKey(refName)
|
|
290
|
+
const target: ProviderTarget & { presetId?: string } = {
|
|
291
|
+
protocol,
|
|
292
|
+
baseUrl,
|
|
293
|
+
model,
|
|
294
|
+
apiKey,
|
|
295
|
+
extraHeaders,
|
|
296
|
+
proxy: config.proxy,
|
|
297
|
+
timeoutMs: config.requestTimeoutMs,
|
|
298
|
+
}
|
|
299
|
+
// 预设 id 旁路携带(executeTask 落画廊时补记 providerPresetId)。
|
|
300
|
+
target.presetId =
|
|
301
|
+
inline !== undefined ? (request.providerPresetId ?? null) : (request.providerPresetId ?? null)
|
|
302
|
+
return target
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** 凭据解析(每次操作即时 resolve,不缓存)。 */
|
|
306
|
+
private async resolveApiKey(refName: string): Promise<string> {
|
|
307
|
+
const credentials = await this.requireCredentials()
|
|
308
|
+
const resolved = await credentials.resolve(credentialRef(refName))
|
|
309
|
+
if (resolved === undefined) {
|
|
310
|
+
throw new ImageStudioError('credential-unavailable', `凭据未配置:${refName}`)
|
|
311
|
+
}
|
|
312
|
+
return resolved.value
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** 参数预设 + 覆盖合并 → 端点归一化(model 由 target 承载)。 */
|
|
316
|
+
private async resolveParams(
|
|
317
|
+
request: GenerateRequest,
|
|
318
|
+
endpoint: ImageEndpoint,
|
|
319
|
+
): Promise<Record<string, unknown>> {
|
|
320
|
+
let specs: ParamSpecMap = {}
|
|
321
|
+
if (request.paramPresetId !== undefined) {
|
|
322
|
+
const preset = this.paramPresets().find((item) => item.id === request.paramPresetId)
|
|
323
|
+
if (preset === undefined) {
|
|
324
|
+
throw new ImageStudioError('invalid-request', `未知参数预设:${request.paramPresetId}`)
|
|
325
|
+
}
|
|
326
|
+
specs = validateParamSpecs(preset.paramSpecs)
|
|
327
|
+
}
|
|
328
|
+
if (request.paramSpecs !== undefined) {
|
|
329
|
+
specs = { ...specs, ...validateParamSpecs(request.paramSpecs) }
|
|
330
|
+
}
|
|
331
|
+
const normalized = normalizeParamSpecs(specs, endpoint)
|
|
332
|
+
return { model: this.modelOf(request), ...normalized }
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** 模型优先级:inline > 提供商预设(参数表中的 model 仅为展示,不参与)。 */
|
|
336
|
+
private modelOf(request: GenerateRequest): string {
|
|
337
|
+
return request.inlineProvider?.model ?? this.providerModelOf(request.providerPresetId)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private providerModelOf(presetId: string | undefined): string {
|
|
341
|
+
if (presetId === undefined) return ''
|
|
342
|
+
return this.providerPresets().find((item) => item.id === presetId)?.model ?? ''
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** 源图字节加载(edit ≥1 张;单图上限 50MB 对齐官方)。 */
|
|
346
|
+
private async loadSourceImages(
|
|
347
|
+
sourceIds: string[],
|
|
348
|
+
endpoint: ImageEndpoint,
|
|
349
|
+
): Promise<InputImage[]> {
|
|
350
|
+
if (endpoint === 'generation') return []
|
|
351
|
+
if (sourceIds.length === 0) {
|
|
352
|
+
throw new ImageStudioError('invalid-request', 'edit 端点必须提供至少一张源图(sourceIds)')
|
|
353
|
+
}
|
|
354
|
+
if (sourceIds.length > 16) {
|
|
355
|
+
throw new ImageStudioError('invalid-request', '源图最多 16 张(官方 GPT Image 限制)')
|
|
356
|
+
}
|
|
357
|
+
const images: InputImage[] = []
|
|
358
|
+
for (const imageId of sourceIds) {
|
|
359
|
+
images.push(await this.loadOneImage(imageId))
|
|
360
|
+
}
|
|
361
|
+
return images
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
private async loadMask(maskId: string): Promise<InputImage> {
|
|
365
|
+
return this.loadOneImage(maskId)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
private async loadOneImage(imageId: string): Promise<InputImage> {
|
|
369
|
+
try {
|
|
370
|
+
const { data, ext } = await readImageBytes(imageId)
|
|
371
|
+
return { data, mime: ext === 'jpg' ? 'image/jpeg' : `image/${ext}` }
|
|
372
|
+
} catch {
|
|
373
|
+
throw new ImageStudioError('unknown-source', `源图不存在:${imageId}`)
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** 任务体:调协议适配器 → 成功入画廊。失败向上抛给 TaskRunner 记录。 */
|
|
378
|
+
private async executeTask(
|
|
379
|
+
snapshot: Awaited<ReturnType<ImageStudioService['prepareTask']>>,
|
|
380
|
+
signal: AbortSignal,
|
|
381
|
+
): Promise<TaskOutcome> {
|
|
382
|
+
const provider = createProvider(snapshot.target.protocol)
|
|
383
|
+
if (!provider.endpoints.includes(snapshot.endpoint)) {
|
|
384
|
+
throw new ImageStudioError(
|
|
385
|
+
'unknown-endpoint',
|
|
386
|
+
`协议 ${provider.id} 不支持端点 ${snapshot.endpoint}`,
|
|
387
|
+
)
|
|
388
|
+
}
|
|
389
|
+
const result = await provider.generate(
|
|
390
|
+
{
|
|
391
|
+
target: snapshot.target,
|
|
392
|
+
endpoint: snapshot.endpoint,
|
|
393
|
+
prompt: snapshot.prompt,
|
|
394
|
+
n: snapshot.n,
|
|
395
|
+
images: snapshot.images,
|
|
396
|
+
mask: snapshot.mask,
|
|
397
|
+
params: snapshot.params,
|
|
398
|
+
},
|
|
399
|
+
signal,
|
|
400
|
+
)
|
|
401
|
+
const item = await saveGalleryItem({
|
|
402
|
+
item: {
|
|
403
|
+
providerPresetId: this.presetIdOf(snapshot),
|
|
404
|
+
protocol: snapshot.target.protocol,
|
|
405
|
+
endpoint: snapshot.endpoint,
|
|
406
|
+
model: snapshot.target.model,
|
|
407
|
+
prompt: snapshot.prompt,
|
|
408
|
+
params: snapshot.params,
|
|
409
|
+
sourceIds: snapshot.sourceIds,
|
|
410
|
+
},
|
|
411
|
+
images: result.images.map((image) => ({
|
|
412
|
+
data: image.data,
|
|
413
|
+
mime: image.mime,
|
|
414
|
+
revisedPrompt: image.revisedPrompt,
|
|
415
|
+
})),
|
|
416
|
+
})
|
|
417
|
+
return { galleryItemId: item.id }
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** 提交时上下文里的预设 id(executeTask 无原请求,经 target 旁路携带)。 */
|
|
421
|
+
private presetIdOf(snapshot: { target: ProviderTarget }): string | null {
|
|
422
|
+
return (snapshot.target as ProviderTarget & { presetId?: string }).presetId ?? null
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ---------- 画廊 ----------
|
|
426
|
+
|
|
427
|
+
/** 画廊列表(全量;分页/过滤由端点层做)。 */
|
|
428
|
+
async gallery(): Promise<GalleryItem[]> {
|
|
429
|
+
return loadGallery(this.log)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** 删除画廊条目(图片 + 元数据)。 */
|
|
433
|
+
async deleteGalleryItem(itemId: string): Promise<boolean> {
|
|
434
|
+
return removeGalleryItem(itemId)
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** 读取图片字节(端点直投流)。 */
|
|
438
|
+
async imageBytes(imageId: string): Promise<{ data: Uint8Array; ext: string } | null> {
|
|
439
|
+
try {
|
|
440
|
+
return await readImageBytes(imageId)
|
|
441
|
+
} catch {
|
|
442
|
+
return null
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
dispose(): void {
|
|
447
|
+
// 进行中任务随进程终止(AbortController 无需逐一 abort——进程退出即断)。
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** 静态 schema 引用(installSection 用;避免循环 import config → service)。 */
|
|
452
|
+
import { Config as configSchemaRef } from './config.ts'
|
|
453
|
+
|
|
454
|
+
/** 剔除 host 承载参数(model 不进协议参数表——openai-images 请求体单独拼)。 */
|
|
455
|
+
function stripHostParams(params: Record<string, unknown>): Record<string, unknown> {
|
|
456
|
+
const { model: _model, ...rest } = params
|
|
457
|
+
return rest
|
|
458
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 生图任务并发执行器:FIFO 队列 + 可配置并发上限(0 = 不限)。
|
|
3
|
+
* 与业务解耦:任务体是注入的异步函数,本模块只管调度与状态;
|
|
4
|
+
* 纯调度逻辑可单测(不触网)。
|
|
5
|
+
* @module image-studio/task/runner
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** 任务状态。 */
|
|
9
|
+
export type TaskState = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled'
|
|
10
|
+
|
|
11
|
+
/** 任务记录(调度器视图)。 */
|
|
12
|
+
export interface TaskRecord<S> {
|
|
13
|
+
id: string
|
|
14
|
+
state: TaskState
|
|
15
|
+
/** 排队/运行中的业务快照(提交时的请求摘要)。 */
|
|
16
|
+
snapshot: S
|
|
17
|
+
/** 成功结果(succeeded 时存在)。 */
|
|
18
|
+
result: unknown | null
|
|
19
|
+
/** 失败信息(failed/cancelled 时存在)。 */
|
|
20
|
+
error: string | null
|
|
21
|
+
createdAt: string
|
|
22
|
+
startedAt: string | null
|
|
23
|
+
finishedAt: string | null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface TaskEntry<S> {
|
|
27
|
+
record: TaskRecord<S>
|
|
28
|
+
run: (signal: AbortSignal) => Promise<unknown>
|
|
29
|
+
controller: AbortController
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface RunnerOptions {
|
|
33
|
+
/** 并发上限(0 = 不限)。 */
|
|
34
|
+
maxConcurrent: number
|
|
35
|
+
/** 任务 id 生成器(缺省自增)。 */
|
|
36
|
+
nextId?: () => string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 并发任务执行器。
|
|
41
|
+
* - submit 入队即返回记录引用(状态经 record 可变字段即时可见);
|
|
42
|
+
* - 每次完成自动拉起下一段(pump 链式,无定时器);
|
|
43
|
+
* - cancel 对 queued 置 cancelled、对 running 发 abort(任务体自行响应)。
|
|
44
|
+
*/
|
|
45
|
+
export class TaskRunner<S> {
|
|
46
|
+
private readonly entries = new Map<string, TaskEntry<S>>()
|
|
47
|
+
private readonly order: string[] = []
|
|
48
|
+
private maxConcurrent: number
|
|
49
|
+
private readonly nextId: () => string
|
|
50
|
+
/** 完结任务保留条数(防 Map 膨胀;由调用方配置)。 */
|
|
51
|
+
private readonly keepFinished: number
|
|
52
|
+
|
|
53
|
+
constructor(options: RunnerOptions, keepFinished = 200) {
|
|
54
|
+
this.maxConcurrent = Math.max(0, Math.floor(options.maxConcurrent))
|
|
55
|
+
this.nextId = options.nextId ?? defaultIdCounter()
|
|
56
|
+
this.keepFinished = keepFinished
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 并发上限热更(配置热生效入口;进行中任务不受影响)。 */
|
|
60
|
+
reconfigure(maxConcurrent: number): void {
|
|
61
|
+
this.maxConcurrent = Math.max(0, Math.floor(maxConcurrent))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 提交任务:返回记录(后续状态经同一引用轮询)。 */
|
|
65
|
+
submit(snapshot: S, run: (signal: AbortSignal) => Promise<unknown>): TaskRecord<S> {
|
|
66
|
+
const controller = new AbortController()
|
|
67
|
+
const record: TaskRecord<S> = {
|
|
68
|
+
id: this.nextId(),
|
|
69
|
+
state: 'queued',
|
|
70
|
+
snapshot,
|
|
71
|
+
result: null,
|
|
72
|
+
error: null,
|
|
73
|
+
createdAt: new Date().toISOString(),
|
|
74
|
+
startedAt: null,
|
|
75
|
+
finishedAt: null,
|
|
76
|
+
}
|
|
77
|
+
this.entries.set(record.id, { record, run, controller })
|
|
78
|
+
this.order.push(record.id)
|
|
79
|
+
void this.pump()
|
|
80
|
+
return record
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 单任务记录查询。 */
|
|
84
|
+
get(id: string): TaskRecord<S> | null {
|
|
85
|
+
return this.entries.get(id)?.record ?? null
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 全部记录(完结在前端按需过滤)。 */
|
|
89
|
+
list(): TaskRecord<S>[] {
|
|
90
|
+
return this.order
|
|
91
|
+
.map((id) => this.entries.get(id)?.record)
|
|
92
|
+
.filter((record): record is TaskRecord<S> => record !== undefined)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 取消:queued 直接置 cancelled;running 发 abort(异步生效)。 */
|
|
96
|
+
cancel(id: string): boolean {
|
|
97
|
+
const entry = this.entries.get(id)
|
|
98
|
+
if (entry === undefined) return false
|
|
99
|
+
if (entry.record.state === 'queued') {
|
|
100
|
+
entry.record.state = 'cancelled'
|
|
101
|
+
entry.record.finishedAt = new Date().toISOString()
|
|
102
|
+
this.prune()
|
|
103
|
+
return true
|
|
104
|
+
}
|
|
105
|
+
if (entry.record.state === 'running') {
|
|
106
|
+
entry.controller.abort()
|
|
107
|
+
return true
|
|
108
|
+
}
|
|
109
|
+
return false
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 活跃数(queued + running)。 */
|
|
113
|
+
get activeCount(): number {
|
|
114
|
+
let count = 0
|
|
115
|
+
for (const entry of this.entries.values()) {
|
|
116
|
+
if (entry.record.state === 'queued' || entry.record.state === 'running') count += 1
|
|
117
|
+
}
|
|
118
|
+
return count
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 拉起可运行任务至并发上限。 */
|
|
122
|
+
private async pump(): Promise<void> {
|
|
123
|
+
for (;;) {
|
|
124
|
+
const capacity =
|
|
125
|
+
this.maxConcurrent === 0
|
|
126
|
+
? Number.POSITIVE_INFINITY
|
|
127
|
+
: this.maxConcurrent - this.runningCount()
|
|
128
|
+
if (capacity <= 0) return
|
|
129
|
+
const next = this.order
|
|
130
|
+
.map((id) => this.entries.get(id))
|
|
131
|
+
.find((entry) => entry !== undefined && entry.record.state === 'queued')
|
|
132
|
+
if (next === undefined) return
|
|
133
|
+
this.start(next)
|
|
134
|
+
if (this.maxConcurrent !== 0) return
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private start(entry: TaskEntry<S>): void {
|
|
139
|
+
entry.record.state = 'running'
|
|
140
|
+
entry.record.startedAt = new Date().toISOString()
|
|
141
|
+
const signal = entry.controller.signal
|
|
142
|
+
void entry
|
|
143
|
+
.run(signal)
|
|
144
|
+
.then((result) => {
|
|
145
|
+
// abort 后 gate 仍可能正常 resolve:以 signal.aborted 为准判失败。
|
|
146
|
+
if (signal.aborted) {
|
|
147
|
+
entry.record.state = 'failed'
|
|
148
|
+
entry.record.error = '任务已取消'
|
|
149
|
+
entry.record.finishedAt = new Date().toISOString()
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
if (entry.record.state !== 'running') return
|
|
153
|
+
entry.record.state = 'succeeded'
|
|
154
|
+
entry.record.result = result
|
|
155
|
+
entry.record.finishedAt = new Date().toISOString()
|
|
156
|
+
})
|
|
157
|
+
.catch((error: unknown) => {
|
|
158
|
+
if (entry.record.state === 'cancelled') return
|
|
159
|
+
entry.record.state = 'failed'
|
|
160
|
+
entry.record.error = error instanceof Error ? error.message : String(error)
|
|
161
|
+
entry.record.finishedAt = new Date().toISOString()
|
|
162
|
+
})
|
|
163
|
+
.finally(() => {
|
|
164
|
+
this.prune()
|
|
165
|
+
void this.pump()
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private runningCount(): number {
|
|
170
|
+
let count = 0
|
|
171
|
+
for (const entry of this.entries.values()) {
|
|
172
|
+
if (entry.record.state === 'running') count += 1
|
|
173
|
+
}
|
|
174
|
+
return count
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** 清理最旧完结条目(保持 Map 上限)。 */
|
|
178
|
+
private prune(): void {
|
|
179
|
+
const finished = this.order.filter((id) => {
|
|
180
|
+
const state = this.entries.get(id)?.record.state
|
|
181
|
+
return state === 'succeeded' || state === 'failed' || state === 'cancelled'
|
|
182
|
+
})
|
|
183
|
+
const overflow = finished.length - this.keepFinished
|
|
184
|
+
if (overflow <= 0) return
|
|
185
|
+
for (const id of finished.slice(0, overflow)) {
|
|
186
|
+
this.entries.delete(id)
|
|
187
|
+
const index = this.order.indexOf(id)
|
|
188
|
+
if (index >= 0) this.order.splice(index, 1)
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** 缺省 id 计数器。 */
|
|
194
|
+
function defaultIdCounter(): () => string {
|
|
195
|
+
let counter = 0
|
|
196
|
+
return () => {
|
|
197
|
+
counter += 1
|
|
198
|
+
return `task-${counter}`
|
|
199
|
+
}
|
|
200
|
+
}
|