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