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