@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/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,7 @@
1
+ /**
2
+ * settings 命名空间字面量(纯常量,零依赖,浏览器半可安全引入)。
3
+ * 服务端直接以字面量注册(settings 编译期校验命名空间语法,见 config.ts),
4
+ * 浏览器半将其作为 settings.plugin.item keyed 槽位的 key(配置卡片)。
5
+ * @module image-studio/ns
6
+ */
7
+ export const SETTINGS_NS = 'dsh-plus-image-studio'
@@ -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
+ }