返回 oh-my-ppt
openai-chat-completions.ts
根目录 / src / main / agent-runtime / provider / image / providers / openai-chat-completions.ts
1 import log from 'electron-log/main.js'
2 import type {
3 ImageGenerationProviderAdapter,
4 ImageGenerationResult,
5 ResolvedImageModelConfig
6 } from '../types'
7 import { collectImageResults, joinUrl, readRecord, readString } from './utils'
8 import { resolveConfiguredDefaultImageSize } from './default-size'
9
10 const DEFAULT_BASE_URL = 'https://api.openai.com/v1'
11 const DEFAULT_MODEL = 'gpt-image-1'
12 const LOG_TAG = 'openai-chat-completions'
13
14 type ChatMessage = {
15 role: 'system' | 'user'
16 content: string
17 }
18
19 const toErrorMessage = (error: unknown): string =>
20 error instanceof Error ? error.message : String(error)
21
22 const resolveBaseUrl = (config: ResolvedImageModelConfig): string =>
23 (readString(config.modelConfig, 'baseUrl') || DEFAULT_BASE_URL).replace(/\/+$/, '')
24
25 const buildEndpoint = (config: ResolvedImageModelConfig): string => {
26 const baseUrl = readString(config.modelConfig, 'baseUrl') || DEFAULT_BASE_URL
27 const normalized = baseUrl.replace(/\/+$/, '')
28 if (/\/chat\/completions$/i.test(normalized)) return normalized
29 try {
30 const url = new URL(normalized)
31 const path = url.pathname.replace(/\/+$/, '')
32 if (!path) return joinUrl(normalized, '/v1/chat/completions')
33 } catch {
34 // Keep the fallback path below for non-standard but still fetchable base URLs.
35 }
36 return joinUrl(normalized, '/chat/completions')
37 }
38
39 const trimResponseText = (text: string): string => text.replace(/\s+/g, ' ').trim().slice(0, 500)
40
41 const readChatJsonResponse = async (response: Response): Promise<unknown> => {
42 const contentType = response.headers.get('content-type') || ''
43 const text = await response.text()
44 const preview = trimResponseText(text)
45 if (!response.ok) {
46 throw new Error(
47 `OpenAI Chat Completions failed (${response.status}, ${contentType || 'unknown content-type'}): ${
48 preview || 'empty response'
49 }`
50 )
51 }
52 try {
53 return JSON.parse(text)
54 } catch {
55 throw new Error(
56 `OpenAI Chat Completions returned invalid JSON (${response.status}, ${
57 contentType || 'unknown content-type'
58 }): ${preview || 'empty response'}`
59 )
60 }
61 }
62
63 const collectMarkdownImageUrls = (content: string): string[] => {
64 const urls: string[] = []
65 for (const match of content.matchAll(/!\[[^\]]*]\(([^)]+)\)/g)) {
66 const url = match[1]?.trim()
67 if (url) urls.push(url)
68 }
69 for (const match of content.matchAll(/data:image\/[^;,]+;base64,[A-Za-z0-9+/=]+/g)) {
70 urls.push(match[0])
71 }
72 const trimmed = content.trim()
73 if (/^https?:\/\//i.test(trimmed) || /^data:image\//i.test(trimmed)) {
74 urls.push(trimmed)
75 }
76 return urls
77 }
78
79 const pushCandidateFromImageUrl = (value: unknown, candidates: unknown[]): boolean => {
80 if (typeof value === 'string' && value.trim()) {
81 candidates.push(value.trim())
82 return true
83 }
84 const record = readRecord(value)
85 const url = readString(record, 'url')
86 if (url) {
87 candidates.push(url)
88 return true
89 }
90 return false
91 }
92
93 const collectImageCandidates = (value: unknown, candidates: unknown[], depth = 0): void => {
94 if (depth > 8 || value == null) return
95
96 if (typeof value === 'string') {
97 candidates.push(...collectMarkdownImageUrls(value))
98 return
99 }
100
101 if (Array.isArray(value)) {
102 for (const item of value) collectImageCandidates(item, candidates, depth + 1)
103 return
104 }
105
106 const record = readRecord(value)
107 if (Object.keys(record).length === 0) return
108
109 if (pushCandidateFromImageUrl(record.image_url, candidates)) return
110
111 const url = readString(record, 'url')
112 const b64Json = readString(record, 'b64_json')
113 const base64 = readString(record, 'base64')
114 const data = readString(record, 'data')
115 if (url) candidates.push(url)
116 if (b64Json) candidates.push(b64Json)
117 if (base64) candidates.push(base64)
118 if (/^data:image\//i.test(data)) candidates.push(data)
119
120 for (const key of [
121 'content',
122 'additional_kwargs',
123 'response_metadata',
124 'tool_calls',
125 'choices',
126 'message',
127 'output',
128 'images',
129 'results'
130 ]) {
131 if (key in record) collectImageCandidates(record[key], candidates, depth + 1)
132 }
133 }
134
135 const collectMessageImageResults = async (
136 payload: unknown,
137 signal?: AbortSignal
138 ): Promise<ImageGenerationResult[]> => {
139 const candidates: unknown[] = []
140 collectImageCandidates(payload, candidates)
141 return collectImageResults({ data: candidates }, signal)
142 }
143
144 const buildMessages = (config: ResolvedImageModelConfig, prompt: string): ChatMessage[] => {
145 const systemPrompt = readString(config.modelConfig, 'systemPrompt')
146 return [
147 ...(systemPrompt ? [{ role: 'system' as const, content: systemPrompt }] : []),
148 { role: 'user', content: prompt }
149 ]
150 }
151
152 export const openAiChatCompletionsAdapter: ImageGenerationProviderAdapter = {
153 getDefaultSize(config) {
154 return resolveConfiguredDefaultImageSize(config) || 'auto'
155 },
156
157 async generate(config, input) {
158 const startedAt = Date.now()
159 const baseUrl = resolveBaseUrl(config)
160 const endpoint = buildEndpoint(config)
161 const model = readString(config.modelConfig, 'model') || DEFAULT_MODEL
162 const apiKey = readString(config.modelConfig, 'apiKey') || readString(config.modelConfig, 'api_key')
163 if (!model) throw new Error('OpenAI-compatible Chat Completions image model is required')
164 if (!apiKey) throw new Error('OpenAI-compatible API key is required')
165
166 const headers = readRecord(config.modelConfig.headers) as Record<string, string>
167 const modelKwargs = readRecord(config.modelConfig.modelKwargs)
168 const systemPrompt = readString(config.modelConfig, 'systemPrompt')
169 const messages = buildMessages(config, input.prompt)
170 const count = Math.max(1, input.count)
171
172 log.info(`[images:${LOG_TAG}] generation start`, {
173 configId: config.id,
174 configName: config.name,
175 model,
176 baseUrl,
177 endpoint,
178 count,
179 promptLength: input.prompt.length,
180 hasSystemPrompt: Boolean(systemPrompt),
181 modelKwargsKeys: Object.keys(modelKwargs).sort()
182 })
183
184 try {
185 const response = await fetch(endpoint, {
186 method: 'POST',
187 signal: input.signal,
188 headers: {
189 accept: 'application/json',
190 authorization: `Bearer ${apiKey}`,
191 'content-type': 'application/json',
192 ...headers
193 },
194 body: JSON.stringify({
195 model,
196 messages,
197 n: count,
198 stream: false,
199 ...modelKwargs
200 })
201 })
202 const payload = await readChatJsonResponse(response)
203 const responseRecord = readRecord(payload)
204 log.info(`[images:${LOG_TAG}] request completed`, {
205 model,
206 status: response.status,
207 choiceCount: Array.isArray(responseRecord.choices) ? responseRecord.choices.length : 0,
208 responseKeys: Object.keys(responseRecord).sort(),
209 elapsedMs: Date.now() - startedAt
210 })
211 const results = await collectMessageImageResults(payload, input.signal)
212 if (results.length === 0) {
213 throw new Error('OpenAI-compatible Chat Completions returned no images')
214 }
215 log.info(`[images:${LOG_TAG}] generation completed`, {
216 model,
217 resultCount: results.length,
218 elapsedMs: Date.now() - startedAt
219 })
220 return results.slice(0, count)
221 } catch (error) {
222 log.error(`[images:${LOG_TAG}] generation failed`, {
223 model,
224 message: toErrorMessage(error),
225 elapsedMs: Date.now() - startedAt
226 })
227 throw error
228 }
229 }
230 }
231
231 lines TYPESCRIPT