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