@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/dto.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 传输对象(DTO):HTTP 端点 wire 类型,前后端共享
|
|
3
|
+
* (浏览器半 import type 消费,构建期零运行时依赖)。
|
|
4
|
+
* @module image-studio/dto
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ParamPresetEntry, PromptPresetEntry, ProviderPresetEntry } from './config.ts'
|
|
8
|
+
import type { GalleryItem } from './gallery/store.ts'
|
|
9
|
+
import type { ImageEndpoint } from './provider/types.ts'
|
|
10
|
+
import type { TaskState } from './task/runner.ts'
|
|
11
|
+
|
|
12
|
+
/** 任务提交请求体。 */
|
|
13
|
+
export interface GenerateRequest {
|
|
14
|
+
/** 提供商预设 id 与 inline 二选一(都传时 inline 优先)。 */
|
|
15
|
+
providerPresetId?: string
|
|
16
|
+
/** inline 提供商(无预设直发;apiKey 走同 preset 凭据通道)。 */
|
|
17
|
+
inlineProvider?: {
|
|
18
|
+
protocol: string
|
|
19
|
+
baseUrl: string
|
|
20
|
+
model: string
|
|
21
|
+
}
|
|
22
|
+
endpoint: ImageEndpoint
|
|
23
|
+
prompt: string
|
|
24
|
+
/** 参数预设 id(先应用,再被 paramSpecs 覆盖)。 */
|
|
25
|
+
paramPresetId?: string
|
|
26
|
+
/** 参数覆盖表(同 ParamSpecMap 形态)。 */
|
|
27
|
+
paramSpecs?: Record<string, { enabled: boolean; value?: unknown }>
|
|
28
|
+
/** 图生图源图(本画廊 imageId;edit 必填 ≥1)。 */
|
|
29
|
+
sourceIds?: string[]
|
|
30
|
+
/** 可选 PNG mask(本画廊 imageId)。 */
|
|
31
|
+
maskId?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 任务记录 wire 视图。 */
|
|
35
|
+
export interface TaskWire {
|
|
36
|
+
id: string
|
|
37
|
+
state: TaskState
|
|
38
|
+
endpoint: ImageEndpoint
|
|
39
|
+
model: string
|
|
40
|
+
promptPreview: string
|
|
41
|
+
createdAt: string
|
|
42
|
+
startedAt: string | null
|
|
43
|
+
finishedAt: string | null
|
|
44
|
+
error: string | null
|
|
45
|
+
/** 成功时指向画廊条目 id。 */
|
|
46
|
+
galleryItemId: string | null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 任务提交应答。 */
|
|
50
|
+
export interface GenerateAccepted {
|
|
51
|
+
taskId: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 画廊删除/查询应答。 */
|
|
55
|
+
export interface GalleryListWire {
|
|
56
|
+
items: GalleryItem[]
|
|
57
|
+
total: number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 凭据状态(describe 投影,永不回传值)。 */
|
|
61
|
+
export interface CredentialStatusWire {
|
|
62
|
+
credentialRef: string
|
|
63
|
+
configured: boolean
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** providers 端点应答:协议 registry + 参数目录(前端表单依据)。 */
|
|
67
|
+
export interface ProvidersWire {
|
|
68
|
+
protocols: Array<{ id: string; label: string; endpoints: ImageEndpoint[] }>
|
|
69
|
+
params: Array<{
|
|
70
|
+
key: string
|
|
71
|
+
kind: string
|
|
72
|
+
values?: readonly string[]
|
|
73
|
+
min?: number
|
|
74
|
+
max?: number
|
|
75
|
+
applies: ImageEndpoint[]
|
|
76
|
+
generation: string
|
|
77
|
+
advanced: boolean
|
|
78
|
+
description: string
|
|
79
|
+
}>
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 预设三组(settings 快照投影)。 */
|
|
83
|
+
export interface PresetsWire {
|
|
84
|
+
promptPresets: PromptPresetEntry[]
|
|
85
|
+
paramPresets: ParamPresetEntry[]
|
|
86
|
+
providerPresets: ProviderPresetEntry[]
|
|
87
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 插件内可识别错误:消息即结构化错误码,端点与任务层按码映射 HTTP 状态。
|
|
3
|
+
* @module image-studio/errors
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** 全部错误码(封闭集合,新增协议/端点行为时按需扩充)。 */
|
|
7
|
+
export const ERROR_CODES = [
|
|
8
|
+
'unknown-protocol',
|
|
9
|
+
'unknown-endpoint',
|
|
10
|
+
'invalid-request',
|
|
11
|
+
'unknown-source',
|
|
12
|
+
'unknown-task',
|
|
13
|
+
'task-not-runnable',
|
|
14
|
+
'credential-unavailable',
|
|
15
|
+
'settings-unavailable',
|
|
16
|
+
'provider-error',
|
|
17
|
+
] as const
|
|
18
|
+
|
|
19
|
+
export type ErrorCode = (typeof ERROR_CODES)[number]
|
|
20
|
+
|
|
21
|
+
export class ImageStudioError extends Error {
|
|
22
|
+
readonly code: ErrorCode
|
|
23
|
+
|
|
24
|
+
constructor(code: ErrorCode, message?: string) {
|
|
25
|
+
super(message ?? code)
|
|
26
|
+
this.name = 'ImageStudioError'
|
|
27
|
+
this.code = code
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 按错误码映射 HTTP 状态(端点边界统一使用)。 */
|
|
32
|
+
export function statusOfCode(code: string): number {
|
|
33
|
+
switch (code) {
|
|
34
|
+
case 'unknown-protocol':
|
|
35
|
+
case 'unknown-endpoint':
|
|
36
|
+
case 'invalid-request':
|
|
37
|
+
case 'unknown-source':
|
|
38
|
+
case 'unknown-task':
|
|
39
|
+
case 'task-not-runnable':
|
|
40
|
+
return 400
|
|
41
|
+
case 'credential-unavailable':
|
|
42
|
+
case 'settings-unavailable':
|
|
43
|
+
return 409
|
|
44
|
+
default:
|
|
45
|
+
return 500
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 画廊存储:元数据 JSONL(追加)+ 图片落盘(原子写)。
|
|
3
|
+
* 元数据记录完整请求参数与提示词(二次编辑回放依据);
|
|
4
|
+
* sourceIds 记录图生图衍生链。落点遵循《插件存储规范》:
|
|
5
|
+
* $DSH_HOME/dsh-plus/image-studio/gallery/{index.jsonl, images/}
|
|
6
|
+
* @module image-studio/gallery/store
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { randomUUID } from 'node:crypto'
|
|
10
|
+
import { appendFile, mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
|
|
13
|
+
import { pluginDataPath } from '@dsh-plus/shared'
|
|
14
|
+
import type { ImageEndpoint } from '../provider/types.ts'
|
|
15
|
+
|
|
16
|
+
/** 画廊条目元数据(DTO 主体,端点原样投影)。 */
|
|
17
|
+
export interface GalleryItem {
|
|
18
|
+
id: string
|
|
19
|
+
createdAt: string
|
|
20
|
+
/** 提供商预设 id(inline 请求时为 null)。 */
|
|
21
|
+
providerPresetId: string | null
|
|
22
|
+
protocol: string
|
|
23
|
+
endpoint: ImageEndpoint
|
|
24
|
+
model: string
|
|
25
|
+
/** 用户输入提示词(原样,非 revised)。 */
|
|
26
|
+
prompt: string
|
|
27
|
+
/** 全部启用的请求参数(请求体快照,含 response_format 等)。 */
|
|
28
|
+
params: Record<string, unknown>
|
|
29
|
+
/** 结果图 id 列表(images/<imageId>.<ext>)。 */
|
|
30
|
+
imageIds: string[]
|
|
31
|
+
/** 每张图的 revised_prompt(与 imageIds 对齐,可为 null)。 */
|
|
32
|
+
revisedPrompts: Array<string | null>
|
|
33
|
+
/** 图生图源图(本画廊内的 imageId);文生图为空。 */
|
|
34
|
+
sourceIds: string[]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 存储层根路径(惰性解析:DSH_HOME 可能在模块加载后才被测试/dev 环境设置)。
|
|
39
|
+
*/
|
|
40
|
+
function galleryRoot(): string {
|
|
41
|
+
return pluginDataPath('image-studio', 'gallery')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function indexFile(): string {
|
|
45
|
+
return join(galleryRoot(), 'index.jsonl')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function imagesDir(): string {
|
|
49
|
+
return join(galleryRoot(), 'images')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 图片扩展名由 MIME 推导(仅白名单格式)。 */
|
|
53
|
+
export function extOfMime(mime: string): string {
|
|
54
|
+
switch (mime) {
|
|
55
|
+
case 'image/jpeg':
|
|
56
|
+
return 'jpg'
|
|
57
|
+
case 'image/webp':
|
|
58
|
+
return 'webp'
|
|
59
|
+
default:
|
|
60
|
+
return 'png'
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 确保目录存在。 */
|
|
65
|
+
async function ensureDirs(): Promise<void> {
|
|
66
|
+
await mkdir(imagesDir(), { recursive: true })
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 解析 JSONL 全量元数据(坏行跳过并告警;空文件返回空表)。 */
|
|
70
|
+
export async function loadGallery(log: (message: string) => void): Promise<GalleryItem[]> {
|
|
71
|
+
let text: string
|
|
72
|
+
try {
|
|
73
|
+
text = await readFile(indexFile(), 'utf8')
|
|
74
|
+
} catch {
|
|
75
|
+
return []
|
|
76
|
+
}
|
|
77
|
+
const items: GalleryItem[] = []
|
|
78
|
+
for (const line of text.split('\n')) {
|
|
79
|
+
if (line.trim().length === 0) continue
|
|
80
|
+
try {
|
|
81
|
+
items.push(JSON.parse(line) as GalleryItem)
|
|
82
|
+
} catch {
|
|
83
|
+
log(`画廊元数据坏行跳过:${line.slice(0, 80)}`)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return items
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 追加一条元数据(JSONL 原子性:单行小写入)。 */
|
|
90
|
+
async function appendIndex(item: GalleryItem): Promise<void> {
|
|
91
|
+
await ensureDirs()
|
|
92
|
+
await appendFile(indexFile(), `${JSON.stringify(item)}\n`, 'utf8')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 图片文件绝对路径。 */
|
|
96
|
+
function imagePathOf(imageId: string, ext: string): string {
|
|
97
|
+
if (!/^[a-f0-9-]{36}$/.test(imageId) || !/^[a-z0-9]{2,4}$/.test(ext)) {
|
|
98
|
+
throw new Error(`非法画廊图片标识:${imageId}.${ext}`)
|
|
99
|
+
}
|
|
100
|
+
return join(imagesDir(), `${imageId}.${ext}`)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 读取图片字节(未知 id 抛错由调用方映射 404)。 */
|
|
104
|
+
export async function readImageBytes(imageId: string): Promise<{ data: Uint8Array; ext: string }> {
|
|
105
|
+
const files = await readdir(imagesDir()).catch(() => [] as string[])
|
|
106
|
+
const hit = files.find((name) => name.startsWith(`${imageId}.`))
|
|
107
|
+
if (hit === undefined) throw new Error(`unknown-image: ${imageId}`)
|
|
108
|
+
const ext = hit.slice(imageId.length + 1)
|
|
109
|
+
const data = await readFile(imagePathOf(imageId, ext))
|
|
110
|
+
return { data: new Uint8Array(data), ext }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 画廊新条目:落图片字节 + 追加元数据(一个事务语义,图片先落)。 */
|
|
114
|
+
export async function saveGalleryItem(input: {
|
|
115
|
+
item: Omit<GalleryItem, 'id' | 'createdAt' | 'imageIds' | 'revisedPrompts'>
|
|
116
|
+
images: Array<{ data: Uint8Array; mime: string; revisedPrompt: string | null }>
|
|
117
|
+
}): Promise<GalleryItem> {
|
|
118
|
+
await ensureDirs()
|
|
119
|
+
const imageIds: string[] = []
|
|
120
|
+
for (const image of input.images) {
|
|
121
|
+
const imageId = randomUUID()
|
|
122
|
+
const ext = extOfMime(image.mime)
|
|
123
|
+
// 原子写:临时文件 + rename,中断不留半截图片。
|
|
124
|
+
const target = imagePathOf(imageId, ext)
|
|
125
|
+
const tmp = `${target}.tmp-${process.pid}-${Date.now()}`
|
|
126
|
+
await writeFile(tmp, image.data)
|
|
127
|
+
await rename(tmp, target)
|
|
128
|
+
imageIds.push(imageId)
|
|
129
|
+
}
|
|
130
|
+
const item: GalleryItem = {
|
|
131
|
+
...input.item,
|
|
132
|
+
id: randomUUID(),
|
|
133
|
+
createdAt: new Date().toISOString(),
|
|
134
|
+
imageIds,
|
|
135
|
+
revisedPrompts: input.images.map((image) => image.revisedPrompt),
|
|
136
|
+
}
|
|
137
|
+
await appendIndex(item)
|
|
138
|
+
return item
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** 删除单条:图片文件与 JSONL 行(重写式删除)。 */
|
|
142
|
+
export async function deleteGalleryItem(itemId: string): Promise<boolean> {
|
|
143
|
+
const items = await loadGallery(() => {})
|
|
144
|
+
const hit = items.find((item) => item.id === itemId)
|
|
145
|
+
if (hit === undefined) return false
|
|
146
|
+
for (const imageId of hit.imageIds) {
|
|
147
|
+
const files = await readdir(imagesDir()).catch(() => [] as string[])
|
|
148
|
+
for (const name of files) {
|
|
149
|
+
if (name.startsWith(`${imageId}.`)) {
|
|
150
|
+
await rm(join(imagesDir(), name), { force: true })
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const kept = items.filter((item) => item.id !== itemId)
|
|
155
|
+
const tmp = `${indexFile()}.tmp-${process.pid}-${Date.now()}`
|
|
156
|
+
await writeFile(
|
|
157
|
+
tmp,
|
|
158
|
+
kept.map((item) => JSON.stringify(item)).join('\n') + (kept.length > 0 ? '\n' : ''),
|
|
159
|
+
'utf8',
|
|
160
|
+
)
|
|
161
|
+
await rename(tmp, indexFile())
|
|
162
|
+
return true
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** 按 imageId 反查所属条目(二次编辑定位元数据用)。 */
|
|
166
|
+
export async function itemOfImage(
|
|
167
|
+
imageId: string,
|
|
168
|
+
log: (message: string) => void,
|
|
169
|
+
): Promise<GalleryItem | null> {
|
|
170
|
+
const items = await loadGallery(log)
|
|
171
|
+
return items.find((item) => item.imageIds.includes(imageId)) ?? null
|
|
172
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh 插件:图像工作室。
|
|
3
|
+
* OpenAI Images 协议文生图/图生图,官方全参数开关化(enabled=false 不进请求),
|
|
4
|
+
* 双预设体系(提示词/参数/提供商),并发生图任务(FIFO + 可配置上限),
|
|
5
|
+
* 画廊持久化(元数据 JSONL + 图片落盘)与二次编辑链(sourceIds)。
|
|
6
|
+
* 服务经 ctx.imageStudio 暴露;前端接口见 docs/README.md「前端接入点」。
|
|
7
|
+
* @module @dsh-plus/image-studio
|
|
8
|
+
*/
|
|
9
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
10
|
+
|
|
11
|
+
import { Config, type ImageStudioConfig } from './config.ts'
|
|
12
|
+
import { ImageStudioService } from './service.ts'
|
|
13
|
+
|
|
14
|
+
export const name = 'dsh-plus-image-studio'
|
|
15
|
+
|
|
16
|
+
export const inject = ['credentials'] as const
|
|
17
|
+
|
|
18
|
+
export { credentialRefNameOf, isValidPresetId } from './credentials.ts'
|
|
19
|
+
export type {
|
|
20
|
+
CredentialStatusWire,
|
|
21
|
+
GalleryListWire,
|
|
22
|
+
GenerateRequest,
|
|
23
|
+
PresetsWire,
|
|
24
|
+
ProvidersWire,
|
|
25
|
+
TaskWire,
|
|
26
|
+
} from './dto.ts'
|
|
27
|
+
export { type ErrorCode, ImageStudioError } from './errors.ts'
|
|
28
|
+
export type { GalleryItem } from './gallery/store.ts'
|
|
29
|
+
export type { ParamEntry } from './params/catalog.ts'
|
|
30
|
+
export { PARAM_CATALOG, paramEntryOf, paramKeysFor } from './params/catalog.ts'
|
|
31
|
+
export type { ParamSpec, ParamSpecMap } from './params/spec.ts'
|
|
32
|
+
export { normalizeParamSpecs, ParamValidationError, validateParamSpecs } from './params/spec.ts'
|
|
33
|
+
export { ImageStudioService } from './service.ts'
|
|
34
|
+
export { Config }
|
|
35
|
+
|
|
36
|
+
declare module '@deepseek-ai/cordis' {
|
|
37
|
+
interface Context {
|
|
38
|
+
imageStudio: ImageStudioService
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function apply(ctx: Context, config: ImageStudioConfig): void {
|
|
43
|
+
ctx.plugin(ImageStudioService, config)
|
|
44
|
+
}
|
package/src/ns.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 官方 Images API 全参数目录(单一事实源)。
|
|
3
|
+
* 覆盖官方全集(generations + edits 通用、edit 专属、legacy 代际);
|
|
4
|
+
* 各中转支持的子集不同(如 PackyAPI 不支持 stream),由用户按需启用,
|
|
5
|
+
* 目录不做子集裁剪。前端表单/校验据此目录动态生成。
|
|
6
|
+
* 纯数据 + 纯函数,零依赖,浏览器半可安全引入。
|
|
7
|
+
* @module image-studio/params/catalog
|
|
8
|
+
*/
|
|
9
|
+
import type { ImageEndpoint } from '../provider/types.ts'
|
|
10
|
+
|
|
11
|
+
/** 参数值类型(校验与前端控件形态的依据)。 */
|
|
12
|
+
export type ParamKind = 'string' | 'integer' | 'enum' | 'boolean'
|
|
13
|
+
|
|
14
|
+
/** 单个参数的目录条目。 */
|
|
15
|
+
export interface ParamEntry {
|
|
16
|
+
/** 请求体字段名(官方 API 参数名)。 */
|
|
17
|
+
key: string
|
|
18
|
+
/** 值类型。 */
|
|
19
|
+
kind: ParamKind
|
|
20
|
+
/** enum 类型时的合法取值。 */
|
|
21
|
+
values?: readonly string[]
|
|
22
|
+
/** integer 时的下界(含)。 */
|
|
23
|
+
min?: number
|
|
24
|
+
/** integer 时的上界(含)。 */
|
|
25
|
+
max?: number
|
|
26
|
+
/** 适用端点(new-array 是目录实现细节,不外泄)。 */
|
|
27
|
+
applies: readonly ImageEndpoint[]
|
|
28
|
+
/** 适用模型代际:current(GPT Image)| dall-e-3 | dall-e-2 | all。 */
|
|
29
|
+
generation: 'current' | 'dall-e-3' | 'dall-e-2' | 'all'
|
|
30
|
+
/** 默认关闭(中转不兼容或旧代际专用,显式启用才发送)。 */
|
|
31
|
+
advanced: boolean
|
|
32
|
+
/** 中文说明(面向用户的简述)。 */
|
|
33
|
+
description: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 目录键序即文档序(prompt/model/n 常用在前)。 */
|
|
37
|
+
const CATALOG_RAW: readonly ParamEntry[] = [
|
|
38
|
+
{
|
|
39
|
+
key: 'model',
|
|
40
|
+
kind: 'string',
|
|
41
|
+
applies: ['generation', 'edit'],
|
|
42
|
+
generation: 'all',
|
|
43
|
+
advanced: false,
|
|
44
|
+
description: '图像模型 id;请求基础来自提供商预设,此处仅作展示与 inline 覆盖',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
key: 'n',
|
|
48
|
+
kind: 'integer',
|
|
49
|
+
min: 1,
|
|
50
|
+
max: 10,
|
|
51
|
+
applies: ['generation', 'edit'],
|
|
52
|
+
generation: 'current',
|
|
53
|
+
advanced: false,
|
|
54
|
+
description: '生成张数(1-10;部分中转仅支持 1)',
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
key: 'size',
|
|
58
|
+
kind: 'string',
|
|
59
|
+
applies: ['generation', 'edit'],
|
|
60
|
+
generation: 'current',
|
|
61
|
+
advanced: false,
|
|
62
|
+
description:
|
|
63
|
+
'尺寸,如 1024x1024 / 1536x1024 / 1024x1536 / auto;宽高需为 16 的倍数、最大边 ≤3840、比例 ≤3:1(依模型限制)',
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
key: 'quality',
|
|
67
|
+
kind: 'enum',
|
|
68
|
+
values: ['auto', 'low', 'medium', 'high'],
|
|
69
|
+
applies: ['generation', 'edit'],
|
|
70
|
+
generation: 'current',
|
|
71
|
+
advanced: false,
|
|
72
|
+
description: '质量档位',
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
key: 'background',
|
|
76
|
+
kind: 'enum',
|
|
77
|
+
values: ['auto', 'transparent', 'opaque'],
|
|
78
|
+
applies: ['generation', 'edit'],
|
|
79
|
+
generation: 'current',
|
|
80
|
+
advanced: false,
|
|
81
|
+
description: '输出背景透明行为(对提示词里的场景背景无影响)',
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
key: 'output_format',
|
|
85
|
+
kind: 'enum',
|
|
86
|
+
values: ['png', 'jpeg', 'webp'],
|
|
87
|
+
applies: ['generation', 'edit'],
|
|
88
|
+
generation: 'current',
|
|
89
|
+
advanced: false,
|
|
90
|
+
description: '输出格式(部分中转不支持 webp)',
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
key: 'output_compression',
|
|
94
|
+
kind: 'integer',
|
|
95
|
+
min: 0,
|
|
96
|
+
max: 100,
|
|
97
|
+
applies: ['generation', 'edit'],
|
|
98
|
+
generation: 'current',
|
|
99
|
+
advanced: true,
|
|
100
|
+
description: '压缩率 0-100,仅 jpeg/webp 有效',
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
key: 'moderation',
|
|
104
|
+
kind: 'enum',
|
|
105
|
+
values: ['auto', 'low'],
|
|
106
|
+
applies: ['generation', 'edit'],
|
|
107
|
+
generation: 'current',
|
|
108
|
+
advanced: true,
|
|
109
|
+
description: '内容审核严格度',
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
key: 'response_format',
|
|
113
|
+
kind: 'enum',
|
|
114
|
+
values: ['url', 'b64_json'],
|
|
115
|
+
applies: ['generation', 'edit'],
|
|
116
|
+
generation: 'all',
|
|
117
|
+
advanced: false,
|
|
118
|
+
description: '返回形式;插件两种都会落盘本地化,画廊不受外链过期影响',
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
key: 'stream',
|
|
122
|
+
kind: 'boolean',
|
|
123
|
+
applies: ['generation', 'edit'],
|
|
124
|
+
generation: 'current',
|
|
125
|
+
advanced: true,
|
|
126
|
+
description: '流式返回(多数中转不支持,默认关闭)',
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
key: 'partial_images',
|
|
130
|
+
kind: 'integer',
|
|
131
|
+
min: 0,
|
|
132
|
+
max: 3,
|
|
133
|
+
applies: ['generation', 'edit'],
|
|
134
|
+
generation: 'current',
|
|
135
|
+
advanced: true,
|
|
136
|
+
description: '流式中间图数量(依赖 stream,默认关闭)',
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
key: 'user',
|
|
140
|
+
kind: 'string',
|
|
141
|
+
applies: ['generation', 'edit'],
|
|
142
|
+
generation: 'all',
|
|
143
|
+
advanced: true,
|
|
144
|
+
description: '终端用户标识(业务归因用)',
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
key: 'input_fidelity',
|
|
148
|
+
kind: 'enum',
|
|
149
|
+
values: ['low', 'high'],
|
|
150
|
+
applies: ['edit'],
|
|
151
|
+
generation: 'current',
|
|
152
|
+
advanced: false,
|
|
153
|
+
description: '图生图输入保真度(high 更保留原图细节,token 消耗增大)',
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
key: 'style',
|
|
157
|
+
kind: 'enum',
|
|
158
|
+
values: ['vivid', 'natural'],
|
|
159
|
+
applies: ['generation'],
|
|
160
|
+
generation: 'dall-e-3',
|
|
161
|
+
advanced: true,
|
|
162
|
+
description: 'dall-e-3 代际风格参数(GPT Image 不需要)',
|
|
163
|
+
},
|
|
164
|
+
] as const
|
|
165
|
+
|
|
166
|
+
/** 只读目录(冻结防运行期篡改)。 */
|
|
167
|
+
export const PARAM_CATALOG: readonly ParamEntry[] = Object.freeze(
|
|
168
|
+
CATALOG_RAW.map((entry) => Object.freeze(entry)),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
/** 按字段名查目录条目。 */
|
|
172
|
+
export function paramEntryOf(key: string): ParamEntry | null {
|
|
173
|
+
return PARAM_CATALOG.find((entry) => entry.key === key) ?? null
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** 某端点下可用的参数键集合(含 advanced)。 */
|
|
177
|
+
export function paramKeysFor(endpoint: ImageEndpoint): readonly string[] {
|
|
178
|
+
return PARAM_CATALOG.filter((entry) => entry.applies.includes(endpoint)).map((entry) => entry.key)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** 官方 API 对指定参数是否本就不发送(由请求基础或输入承载)。 */
|
|
182
|
+
export function isHostParam(key: string): boolean {
|
|
183
|
+
return key === 'model'
|
|
184
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 参数开关/自定义模型(ParamSpec)与归一化裁剪纯函数。
|
|
3
|
+
* 每参数:enabled=false 不进请求(用上游默认值);value 覆盖目录默认。
|
|
4
|
+
* 校验只在系统边界(api.ts)执行;内部基于契约信任。
|
|
5
|
+
* @module image-studio/params/spec
|
|
6
|
+
*/
|
|
7
|
+
import type { ImageEndpoint } from '../provider/types.ts'
|
|
8
|
+
import { paramEntryOf } from './catalog.ts'
|
|
9
|
+
|
|
10
|
+
/** 单参数的开关 + 自定义值。 */
|
|
11
|
+
export interface ParamSpec {
|
|
12
|
+
enabled: boolean
|
|
13
|
+
value?: unknown
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** 端点 → 该端点参数表。 */
|
|
17
|
+
export type ParamSpecMap = Record<string, ParamSpec>
|
|
18
|
+
|
|
19
|
+
/** 归一化失败原因(边界校验用,端点转 invalid-request)。 */
|
|
20
|
+
export class ParamValidationError extends Error {
|
|
21
|
+
readonly key: string
|
|
22
|
+
|
|
23
|
+
constructor(key: string, message: string) {
|
|
24
|
+
super(`参数 ${key} ${message}`)
|
|
25
|
+
this.name = 'ParamValidationError'
|
|
26
|
+
this.key = key
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 校验单个值符合目录条目约束;返回规范值。 */
|
|
31
|
+
export function validateParamValue(key: string, value: unknown): unknown {
|
|
32
|
+
const entry = paramEntryOf(key)
|
|
33
|
+
if (entry === null) throw new ParamValidationError(key, '不在参数目录中')
|
|
34
|
+
switch (entry.kind) {
|
|
35
|
+
case 'string':
|
|
36
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
37
|
+
throw new ParamValidationError(key, '必须是非空字符串')
|
|
38
|
+
}
|
|
39
|
+
return value
|
|
40
|
+
case 'enum':
|
|
41
|
+
if (typeof value !== 'string' || !entry.values?.includes(value)) {
|
|
42
|
+
throw new ParamValidationError(key, `必须是 ${entry.values?.join(' | ') ?? '枚举值'} 之一`)
|
|
43
|
+
}
|
|
44
|
+
return value
|
|
45
|
+
case 'integer': {
|
|
46
|
+
if (typeof value !== 'number' || !Number.isInteger(value)) {
|
|
47
|
+
throw new ParamValidationError(key, '必须是整数')
|
|
48
|
+
}
|
|
49
|
+
if (entry.min !== undefined && value < entry.min) {
|
|
50
|
+
throw new ParamValidationError(key, `不能小于 ${entry.min}`)
|
|
51
|
+
}
|
|
52
|
+
if (entry.max !== undefined && value > entry.max) {
|
|
53
|
+
throw new ParamValidationError(key, `不能大于 ${entry.max}`)
|
|
54
|
+
}
|
|
55
|
+
return value
|
|
56
|
+
}
|
|
57
|
+
case 'boolean':
|
|
58
|
+
if (typeof value !== 'boolean') throw new ParamValidationError(key, '必须是布尔值')
|
|
59
|
+
return value
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 校验并归一化一份参数表:仅保留启用且适用于该端点的参数;
|
|
65
|
+
* model 是请求基础(提供商预设承载),不计入协议参数表。
|
|
66
|
+
* 任何禁用/未知/越界项直接抛 ParamValidationError(边界 fail-loud)。
|
|
67
|
+
*/
|
|
68
|
+
export function normalizeParamSpecs(
|
|
69
|
+
specs: ParamSpecMap,
|
|
70
|
+
endpoint: ImageEndpoint,
|
|
71
|
+
): Record<string, unknown> {
|
|
72
|
+
const out: Record<string, unknown> = {}
|
|
73
|
+
for (const [key, spec] of Object.entries(specs)) {
|
|
74
|
+
if (!spec?.enabled) continue
|
|
75
|
+
if (key === 'model') continue
|
|
76
|
+
const entry = paramEntryOf(key)
|
|
77
|
+
if (entry === null) throw new ParamValidationError(key, '不在参数目录中')
|
|
78
|
+
if (!entry.applies.includes(endpoint)) {
|
|
79
|
+
throw new ParamValidationError(key, `不适用于 ${endpoint} 端点`)
|
|
80
|
+
}
|
|
81
|
+
out[key] = validateParamValue(key, spec.value)
|
|
82
|
+
}
|
|
83
|
+
return out
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 校验预设里的参数表(结构合法但可跨端点;应用时再按端点归一化)。 */
|
|
87
|
+
export function validateParamSpecs(specs: unknown): ParamSpecMap {
|
|
88
|
+
if (specs === null || typeof specs !== 'object' || Array.isArray(specs)) {
|
|
89
|
+
throw new ParamValidationError('(整体)', '必须是对象')
|
|
90
|
+
}
|
|
91
|
+
const out: ParamSpecMap = {}
|
|
92
|
+
for (const [key, raw] of Object.entries(specs as Record<string, unknown>)) {
|
|
93
|
+
if (raw === null || typeof raw !== 'object') {
|
|
94
|
+
throw new ParamValidationError(key, '条目必须是 { enabled, value? }')
|
|
95
|
+
}
|
|
96
|
+
const spec = raw as { enabled?: unknown; value?: unknown }
|
|
97
|
+
if (typeof spec.enabled !== 'boolean') {
|
|
98
|
+
throw new ParamValidationError(key, 'enabled 必须是布尔值')
|
|
99
|
+
}
|
|
100
|
+
const entry = paramEntryOf(key)
|
|
101
|
+
if (entry === null) throw new ParamValidationError(key, '不在参数目录中')
|
|
102
|
+
const item: ParamSpec = { enabled: spec.enabled }
|
|
103
|
+
if (spec.value !== undefined) {
|
|
104
|
+
item.value = validateParamValue(key, spec.value)
|
|
105
|
+
}
|
|
106
|
+
out[key] = item
|
|
107
|
+
}
|
|
108
|
+
return out
|
|
109
|
+
}
|