返回 oh-my-ppt
gemini.ts
1 import { GoogleGenAI } from '@google/genai'
2 import log from 'electron-log/main.js'
3 import type {
4 ImageGenerationProviderAdapter,
5 ImageGenerationResult,
6 ResolvedImageModelConfig
7 } from '../types'
8 import { readRecord, readString } from './utils'
9 import { resolveConfiguredDefaultImageSize } from './default-size'
10
11 const DEFAULT_MODEL = 'gemini-3.1-flash-image'
12 const LOG_TAG = 'gemini'
13
14 const toErrorMessage = (error: unknown): string =>
15 error instanceof Error ? error.message : String(error)
16
17 const mimeToExtension = (mimeType: string): string => {
18 if (/jpeg/i.test(mimeType)) return '.jpg'
19 if (/webp/i.test(mimeType)) return '.webp'
20 return '.png'
21 }
22
23 const readNumber = (record: Record<string, unknown>, key: string): number | undefined => {
24 const value = Number(record[key])
25 return Number.isFinite(value) ? value : undefined
26 }
27
28 const normalizeAspectRatio = (value: string): string => {
29 const trimmed = value.trim()
30 if (/^\d+:\d+$/.test(trimmed)) return trimmed
31 const match = /^(\d{2,5})\s*[x*]\s*(\d{2,5})$/i.exec(trimmed)
32 if (!match) return ''
33 const width = Number(match[1])
34 const height = Number(match[2])
35 const gcd = (a: number, b: number): number => (b === 0 ? a : gcd(b, a % b))
36 const divisor = gcd(width, height)
37 return `${width / divisor}:${height / divisor}`
38 }
39
40 const parseImageSizeSelection = (value: string): { aspectRatio: string; imageSize: string } => {
41 const [ratioPart, sizePart] = value
42 .split(/[|@]/, 2)
43 .map((part) => part.trim())
44 .filter(Boolean)
45 return {
46 aspectRatio: normalizeAspectRatio(ratioPart || value),
47 imageSize: /^[124]K$/i.test(sizePart || '') ? (sizePart || '').toUpperCase() : ''
48 }
49 }
50
51 const buildGenerationConfig = (
52 config: ResolvedImageModelConfig,
53 input: Parameters<ImageGenerationProviderAdapter['generate']>[1]
54 ): Record<string, unknown> => {
55 const generationConfig = { ...readRecord(config.modelConfig.generationConfig) }
56 const imageConfig = { ...readRecord(generationConfig.imageConfig) }
57 const selectedSize = parseImageSizeSelection(input.size)
58
59 const aspectRatio =
60 readString(config.modelConfig, 'aspectRatio') ||
61 readString(config.modelConfig, 'aspect_ratio') ||
62 selectedSize.aspectRatio
63 if (aspectRatio) imageConfig.aspectRatio = aspectRatio
64
65 if (selectedSize.imageSize) imageConfig.imageSize = selectedSize.imageSize
66
67 const personGeneration =
68 readString(config.modelConfig, 'personGeneration') ||
69 readString(config.modelConfig, 'person_generation')
70 if (personGeneration) imageConfig.personGeneration = personGeneration
71
72 if (Object.keys(imageConfig).length > 0) generationConfig.imageConfig = imageConfig
73 generationConfig.responseModalities = ['TEXT', 'IMAGE']
74
75 const systemInstruction =
76 readString(config.modelConfig, 'systemInstruction') ||
77 readString(config.modelConfig, 'system_instruction')
78 if (systemInstruction) generationConfig.systemInstruction = systemInstruction
79
80 const temperature = readNumber(config.modelConfig, 'temperature')
81 if (temperature !== undefined) generationConfig.temperature = temperature
82
83 if (typeof input.seed === 'number') generationConfig.seed = input.seed
84
85 return generationConfig
86 }
87
88 const buildHttpOptions = (config: ResolvedImageModelConfig): Record<string, unknown> | undefined => {
89 const httpOptions = { ...readRecord(config.modelConfig.httpOptions) }
90 const baseUrl = readString(config.modelConfig, 'baseUrl') || readString(config.modelConfig, 'base_url')
91 const apiVersion = readString(config.modelConfig, 'apiVersion')
92 const timeout = readNumber(config.modelConfig, 'timeout')
93 const headers = readRecord(config.modelConfig.headers)
94
95 if (baseUrl) httpOptions.baseUrl = baseUrl
96 if (apiVersion) httpOptions.apiVersion = apiVersion
97 if (timeout !== undefined) httpOptions.timeout = timeout
98 if (Object.keys(headers).length > 0) httpOptions.headers = headers
99
100 return Object.keys(httpOptions).length > 0 ? httpOptions : undefined
101 }
102
103 const collectGeminiImages = (response: unknown): ImageGenerationResult[] => {
104 const record = readRecord(response)
105 const results: ImageGenerationResult[] = []
106 const candidates = Array.isArray(record.candidates) ? record.candidates : []
107 for (const candidate of candidates) {
108 const content = readRecord(readRecord(candidate).content)
109 const parts = Array.isArray(content.parts) ? content.parts : []
110 for (const part of parts) {
111 const inlineData = readRecord(readRecord(part).inlineData)
112 const data = readString(inlineData, 'data')
113 if (!data) continue
114 const mimeType = readString(inlineData, 'mimeType') || 'image/png'
115 results.push({
116 bytes: Buffer.from(data, 'base64'),
117 mimeType,
118 extension: mimeToExtension(mimeType)
119 })
120 }
121 }
122 return results
123 }
124
125 export const geminiAdapter: ImageGenerationProviderAdapter = {
126 getDefaultSize(config) {
127 const configured = resolveConfiguredDefaultImageSize(config)
128 if (configured) return configured
129 const imageConfig = readRecord(readRecord(config.modelConfig.generationConfig).imageConfig)
130 const aspectRatio = readString(config.modelConfig, 'aspectRatio') || readString(imageConfig, 'aspectRatio')
131 const imageSize = readString(imageConfig, 'imageSize') || '1K'
132 return `${aspectRatio || '16:9'}|${imageSize}`
133 },
134
135 async generate(config, input) {
136 const startedAt = Date.now()
137 const model = readString(config.modelConfig, 'model') || DEFAULT_MODEL
138 const apiKey = readString(config.modelConfig, 'apiKey') || readString(config.modelConfig, 'api_key')
139 const httpOptions = buildHttpOptions(config)
140 if (!apiKey) throw new Error('Gemini 需要 API Key。')
141
142 const generationConfig = buildGenerationConfig(config, input)
143
144 log.info(`[images:${LOG_TAG}] generation start`, {
145 configId: config.id,
146 configName: config.name,
147 model,
148 promptLength: input.prompt.length,
149 size: input.size,
150 baseUrl: readString(config.modelConfig, 'baseUrl') || readString(config.modelConfig, 'base_url') || null,
151 generationConfigKeys: Object.keys(generationConfig).sort()
152 })
153
154 try {
155 const ai = new GoogleGenAI({
156 apiKey,
157 ...(httpOptions ? { httpOptions } : {})
158 })
159 const response = await ai.models.generateContent({
160 model,
161 contents: input.prompt,
162 config: {
163 ...generationConfig,
164 abortSignal: input.signal
165 }
166 })
167 const results = collectGeminiImages(response)
168 if (results.length === 0) throw new Error('Gemini 未返回图片')
169 log.info(`[images:${LOG_TAG}] generation completed`, {
170 model,
171 resultCount: results.length,
172 elapsedMs: Date.now() - startedAt
173 })
174 return results.slice(0, input.count)
175 } catch (error) {
176 log.error(`[images:${LOG_TAG}] generation failed`, {
177 model,
178 message: toErrorMessage(error),
179 elapsedMs: Date.now() - startedAt
180 })
181 throw error
182 }
183 }
184 }
185
185 lines TYPESCRIPT