返回 oh-my-ppt
jimeng-v4.ts
根目录 / src / main / agent-runtime / provider / image / providers / jimeng-v4.ts
1 import crypto from 'crypto'
2 import log from 'electron-log/main.js'
3 import type {
4 ImageGenerationProviderAdapter,
5 ImageGenerationResult,
6 ResolvedImageModelConfig
7 } from '../types'
8 import { collectImageResults, readJsonResponse, readRecord, readString } from './utils'
9 import { resolveConfiguredDefaultImageSize } from './default-size'
10
11 // https://www.volcengine.com/docs/85621/1817045?lang=zh
12 const DEFAULT_ENDPOINT = 'https://visual.volcengineapi.com'
13 const DEFAULT_REQ_KEY = 'jimeng_t2i_v40'
14 const DEFAULT_VERSION = '2022-08-31'
15 const DEFAULT_REGION = 'cn-north-1'
16 const DEFAULT_SERVICE = 'cv'
17 const LOG_TAG = 'jimeng-v4'
18 const LABEL = '即梦 4.0'
19
20 const JIMENG_V4_SIZE_MAP: Record<string, { width: number; height: number }> = {
21 '1:1': { width: 2048, height: 2048 },
22 '16:9': { width: 2560, height: 1440 },
23 '9:16': { width: 1440, height: 2560 },
24 '4:3': { width: 2304, height: 1728 },
25 '3:4': { width: 1728, height: 2304 }
26 }
27
28 const encodeQuery = (value: string): string =>
29 encodeURIComponent(value).replace(/[!'()*]/g, (char) =>
30 `%${char.charCodeAt(0).toString(16).toUpperCase()}`
31 )
32
33 const canonicalQuery = (params: Record<string, string>): string =>
34 Object.entries(params)
35 .sort(([a], [b]) => a.localeCompare(b))
36 .map(([key, value]) => `${encodeQuery(key)}=${encodeQuery(value)}`)
37 .join('&')
38
39 const sha256Hex = (value: string): string =>
40 crypto.createHash('sha256').update(value, 'utf8').digest('hex')
41
42 const hmac = (key: Buffer | string, value: string): Buffer =>
43 crypto.createHmac('sha256', key).update(value, 'utf8').digest()
44
45 const hmacHex = (key: Buffer | string, value: string): string =>
46 crypto.createHmac('sha256', key).update(value, 'utf8').digest('hex')
47
48 const utcDate = (): { longDate: string; shortDate: string } => {
49 const iso = new Date().toISOString().replace(/[:-]|\.\d{3}/g, '')
50 return {
51 longDate: iso,
52 shortDate: iso.slice(0, 8)
53 }
54 }
55
56 const parseCredentials = (
57 config: ResolvedImageModelConfig
58 ): { accessKeyId: string; secretAccessKey: string; sessionToken?: string } => {
59 const accessKeyId = readString(config.modelConfig, 'accessKeyId')
60 const secretAccessKey = readString(config.modelConfig, 'secretKey')
61 const sessionToken = readString(config.modelConfig, 'sessionToken') || undefined
62 if (!accessKeyId || !secretAccessKey) {
63 throw new Error(`${LABEL} 需要 Access Key ID 和 Secret Key。`)
64 }
65 return { accessKeyId, secretAccessKey, sessionToken }
66 }
67
68 const signHeaders = ({
69 accessKeyId,
70 secretAccessKey,
71 sessionToken,
72 host,
73 query,
74 body,
75 region,
76 service
77 }: {
78 accessKeyId: string
79 secretAccessKey: string
80 sessionToken?: string
81 host: string
82 query: string
83 body: string
84 region: string
85 service: string
86 }): Record<string, string> => {
87 const { longDate, shortDate } = utcDate()
88 const payloadHash = sha256Hex(body)
89 const signedHeaders = 'content-type;host;x-content-sha256;x-date'
90 const canonicalHeaders = [
91 'content-type:application/json',
92 `host:${host}`,
93 `x-content-sha256:${payloadHash}`,
94 `x-date:${longDate}`
95 ].join('\n')
96 const canonicalRequest = [
97 'POST',
98 '/',
99 query,
100 `${canonicalHeaders}\n`,
101 signedHeaders,
102 payloadHash
103 ].join('\n')
104 const credentialScope = `${shortDate}/${region}/${service}/request`
105 const stringToSign = [
106 'HMAC-SHA256',
107 longDate,
108 credentialScope,
109 sha256Hex(canonicalRequest)
110 ].join('\n')
111 const signingKey = hmac(hmac(hmac(hmac(secretAccessKey, shortDate), region), service), 'request')
112 const signature = hmacHex(signingKey, stringToSign)
113
114 return {
115 authorization: `HMAC-SHA256 Credential=${accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,
116 'content-type': 'application/json',
117 'x-content-sha256': payloadHash,
118 'x-date': longDate,
119 ...(sessionToken ? { 'x-security-token': sessionToken } : {})
120 }
121 }
122
123 const resolveEndpoint = (config: ResolvedImageModelConfig): URL => {
124 const endpoint =
125 readString(config.modelConfig, 'endpoint') || DEFAULT_ENDPOINT
126 return new URL(endpoint)
127 }
128
129 const resolveReqKey = (config: ResolvedImageModelConfig): string =>
130 readString(config.modelConfig, 'reqKey') || DEFAULT_REQ_KEY
131
132 const resolveVersion = (config: ResolvedImageModelConfig): string =>
133 readString(config.modelConfig, 'version') || DEFAULT_VERSION
134
135 const parseDimensionSize = (value: string): { width: number; height: number } | null => {
136 const match = /^\s*(\d{2,5})\s*[x*]\s*(\d{2,5})\s*$/i.exec(value)
137 if (!match) return null
138 const width = Number(match[1])
139 const height = Number(match[2])
140 if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return null
141 return { width: Math.floor(width), height: Math.floor(height) }
142 }
143
144 const resolveSize = (
145 config: ResolvedImageModelConfig,
146 inputSize: string
147 ): { width?: number; height?: number; size?: number } => {
148 const width = Number(config.modelConfig.width)
149 const height = Number(config.modelConfig.height)
150 if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) {
151 return { width: Math.floor(width), height: Math.floor(height) }
152 }
153 const size = Number(config.modelConfig.size)
154 if (Number.isFinite(size) && size > 0) {
155 return { size: Math.floor(size) }
156 }
157 const explicitDimension = parseDimensionSize(inputSize)
158 if (explicitDimension) return explicitDimension
159 return JIMENG_V4_SIZE_MAP[inputSize] || JIMENG_V4_SIZE_MAP['1:1']
160 }
161
162 const resolveForceSingle = (config: ResolvedImageModelConfig, count: number): boolean => {
163 const value = config.modelConfig.forceSingle ?? config.modelConfig.force_single
164 if (typeof value === 'boolean') return value
165 if (typeof value === 'string') {
166 const normalized = value.trim().toLowerCase()
167 if (normalized === 'true') return true
168 if (normalized === 'false') return false
169 }
170 return count <= 1
171 }
172
173 const postSignedJson = async ({
174 config,
175 action,
176 body,
177 signal
178 }: {
179 config: ResolvedImageModelConfig
180 action: string
181 body: Record<string, unknown>
182 signal?: AbortSignal
183 }): Promise<unknown> => {
184 const endpoint = resolveEndpoint(config)
185 const version = resolveVersion(config)
186 const query = canonicalQuery({
187 Action: action,
188 Version: version
189 })
190 endpoint.search = query
191 const bodyText = JSON.stringify(body)
192 const credentials = parseCredentials(config)
193 const headers = signHeaders({
194 ...credentials,
195 host: endpoint.host,
196 query,
197 body: bodyText,
198 region: readString(config.modelConfig, 'region') || DEFAULT_REGION,
199 service: readString(config.modelConfig, 'service') || DEFAULT_SERVICE
200 })
201 const startedAt = Date.now()
202 log.info(`[images:${LOG_TAG}] request start`, {
203 action,
204 version,
205 endpoint: endpoint.origin,
206 bodyKeys: Object.keys(body).sort()
207 })
208 const response = await fetch(endpoint, {
209 method: 'POST',
210 signal,
211 headers,
212 body: bodyText
213 })
214 log.info(`[images:${LOG_TAG}] request end`, {
215 action,
216 status: response.status,
217 ok: response.ok,
218 elapsedMs: Date.now() - startedAt
219 })
220 return readJsonResponse(response)
221 }
222
223 const assertSuccess = (payload: unknown, context: string): Record<string, unknown> => {
224 const record = readRecord(payload)
225 const code = Number(record.code)
226 if (code !== 10000) {
227 const message = readString(record, 'message') || readString(record, 'msg') || `${context} failed`
228 log.warn(`[images:${LOG_TAG}] api returned non-success`, {
229 context,
230 code,
231 message,
232 payloadKeys: Object.keys(record).sort()
233 })
234 throw new Error(message)
235 }
236 return record
237 }
238
239 const collectJimengImages = async (
240 payload: unknown,
241 signal: AbortSignal | undefined
242 ): Promise<ImageGenerationResult[]> => {
243 const data = readRecord(readRecord(payload).data)
244 const normalized: Array<Record<string, string>> = []
245 const imageUrls = Array.isArray(data.image_urls) ? data.image_urls : []
246 for (const url of imageUrls) {
247 if (typeof url === 'string' && url.trim()) normalized.push({ url: url.trim() })
248 }
249 const binaryData = Array.isArray(data.binary_data_base64)
250 ? data.binary_data_base64
251 : typeof data.binary_data_base64 === 'string'
252 ? [data.binary_data_base64]
253 : []
254 log.info(`[images:${LOG_TAG}] collect image payload`, {
255 imageUrlCount: imageUrls.filter((url) => typeof url === 'string' && url.trim()).length,
256 binaryDataCount: binaryData.filter((base64) => typeof base64 === 'string' && base64.trim())
257 .length
258 })
259 for (const base64 of binaryData) {
260 if (typeof base64 === 'string' && base64.trim()) normalized.push({ base64: base64.trim() })
261 }
262 return collectImageResults({ data: normalized }, signal)
263 }
264
265 const toErrorMessage = (error: unknown): string =>
266 error instanceof Error ? error.message : String(error)
267
268 export const jimengV4Adapter: ImageGenerationProviderAdapter = {
269 getDefaultSize(config) {
270 return resolveConfiguredDefaultImageSize(config) || '2560x1440'
271 },
272
273 async generate(config, input) {
274 const startedAt = Date.now()
275 const reqKey = resolveReqKey(config)
276 const requestBody = readRecord(config.modelConfig.requestBody)
277 const resultJson = readRecord(config.modelConfig.resultJson)
278 const { width, height, size } = resolveSize(config, input.size)
279 const forceSingle = resolveForceSingle(config, input.count)
280 const results: ImageGenerationResult[] = []
281 const maxPolls = Number(config.modelConfig.maxPolls || 60)
282 const intervalMs = Number(config.modelConfig.pollIntervalMs || 2000)
283 log.info(`[images:${LOG_TAG}] generation start`, {
284 configId: config.id,
285 configName: config.name,
286 reqKey,
287 inputSize: input.size,
288 width,
289 height,
290 size,
291 forceSingle,
292 count: input.count,
293 promptLength: input.prompt.length,
294 hasSeed: typeof input.seed === 'number',
295 maxPolls,
296 intervalMs,
297 requestBodyKeys: Object.keys(requestBody).sort(),
298 resultJsonKeys: Object.keys(resultJson).sort()
299 })
300
301 try {
302 for (let index = 0; index < input.count; index += 1) {
303 const imageStartedAt = Date.now()
304 log.info(`[images:${LOG_TAG}] submit task`, {
305 imageIndex: index + 1,
306 total: input.count,
307 reqKey,
308 width,
309 height,
310 size,
311 forceSingle
312 })
313 const submitPayload = assertSuccess(
314 await postSignedJson({
315 config,
316 action: 'CVSync2AsyncSubmitTask',
317 signal: input.signal,
318 body: {
319 req_key: reqKey,
320 prompt: input.prompt,
321 seed: typeof input.seed === 'number' ? input.seed : -1,
322 ...(width && height ? { width, height } : {}),
323 ...(size ? { size } : {}),
324 force_single: forceSingle,
325 ...requestBody
326 }
327 }),
328 `${LABEL} task submit`
329 )
330 const taskId = readString(readRecord(submitPayload.data), 'task_id')
331 if (!taskId) throw new Error(`${LABEL} 未返回 task_id`)
332 log.info(`[images:${LOG_TAG}] task submitted`, {
333 imageIndex: index + 1,
334 taskId,
335 elapsedMs: Date.now() - imageStartedAt
336 })
337
338 let lastStatus = ''
339 for (let poll = 0; poll < maxPolls; poll += 1) {
340 if (input.signal?.aborted) throw new Error('Image generation cancelled')
341 await new Promise((resolve) => setTimeout(resolve, intervalMs))
342 const queryPayload = assertSuccess(
343 await postSignedJson({
344 config,
345 action: 'CVSync2AsyncGetResult',
346 signal: input.signal,
347 body: {
348 req_key: reqKey,
349 task_id: taskId,
350 req_json: JSON.stringify({
351 return_url: true,
352 ...resultJson
353 })
354 }
355 }),
356 `${LABEL} task query`
357 )
358 const data = readRecord(queryPayload.data)
359 const status = readString(data, 'status')
360 if (status !== lastStatus || poll === 0 || (poll + 1) % 5 === 0) {
361 log.info(`[images:${LOG_TAG}] poll task`, {
362 imageIndex: index + 1,
363 taskId,
364 poll: poll + 1,
365 maxPolls,
366 status: status || 'unknown',
367 elapsedMs: Date.now() - imageStartedAt
368 })
369 }
370 lastStatus = status
371 if (status === 'done') {
372 const images = await collectJimengImages(queryPayload, input.signal)
373 if (images.length === 0) throw new Error(`${LABEL} 未返回图片`)
374 results.push(...images)
375 log.info(`[images:${LOG_TAG}] task completed`, {
376 imageIndex: index + 1,
377 taskId,
378 imageCount: images.length,
379 elapsedMs: Date.now() - imageStartedAt
380 })
381 break
382 }
383 if (status === 'not_found' || status === 'expired') {
384 throw new Error(`${LABEL} 任务状态异常:${status}`)
385 }
386 }
387
388 if (results.length <= index) {
389 log.warn(`[images:${LOG_TAG}] task timed out`, {
390 imageIndex: index + 1,
391 taskId,
392 maxPolls,
393 elapsedMs: Date.now() - imageStartedAt
394 })
395 throw new Error(`${LABEL} 生图超时`)
396 }
397 if (results.length >= input.count) break
398 }
399
400 log.info(`[images:${LOG_TAG}] generation completed`, {
401 requestedCount: input.count,
402 resultCount: results.length,
403 elapsedMs: Date.now() - startedAt
404 })
405 return results.slice(0, input.count)
406 } catch (error) {
407 log.error(`[images:${LOG_TAG}] generation failed`, {
408 message: toErrorMessage(error),
409 resultCount: results.length,
410 elapsedMs: Date.now() - startedAt
411 })
412 throw error
413 }
414 }
415 }
416
416 lines TYPESCRIPT