const fs = require('node:fs'); const path = require('node:path'); const crypto = require('node:crypto'); const { getAiLogsDir, getGeneratedImagesDir } = require('../utils/paths.cjs'); const AI_REQUEST_TIMEOUT_MS = 300000; const ANALYTICS_ENDPOINT = 'https://analytics.agnet.top/track'; const ANALYTICS_PROJECT_NAME = 'yibiao-client'; function trimBaseUrl(baseUrl) { return (baseUrl || 'https://api.openai.com/v1').replace(/\/+$/, ''); } function createRequestId() { return `${new Date().toISOString().replace(/[:.]/g, '-')}-${crypto.randomUUID()}`; } function isResponseFormatUnsupported(message) { const normalized = String(message || '').toLowerCase(); return normalized.includes('response_format') && [ 'not supported', 'does not support', 'not support', 'unsupported', 'unknown parameter', 'invalid parameter', 'must be', ].some((marker) => normalized.includes(marker)); } function writeAiLog(app, config, payload) { if (!config.developer_mode) { return; } const logsDir = getAiLogsDir(app); fs.mkdirSync(logsDir, { recursive: true }); const fileName = `${payload.request_id}.json`; fs.writeFileSync(path.join(logsDir, fileName), JSON.stringify(payload, null, 2), 'utf-8'); } function responseMeta(response) { if (!response) { return null; } const headers = {}; response.headers?.forEach?.((value, key) => { const normalizedKey = String(key || '').toLowerCase(); if (['authorization', 'cookie', 'set-cookie', 'x-api-key'].includes(normalizedKey)) { return; } headers[normalizedKey] = value; }); return { status: response.status, status_text: response.statusText, headers, }; } function normalizeAiError(error, fallbackMessage) { if (error?.name === 'AbortError') { return `AI 请求超时(${AI_REQUEST_TIMEOUT_MS / 1000} 秒)`; } return error?.message || String(error || '') || fallbackMessage; } function createHeaders(apiKey) { return { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}`, }; } function trackAiRequest(app, config, payload) { const imageConfig = config.image_model || {}; const body = { projectName: ANALYTICS_PROJECT_NAME, event: 'ai_request', version: typeof app?.getVersion === 'function' ? app.getVersion() : '', platform: process.platform, arch: process.arch, client_id: config.analytics_client_id || '', client_created_at: config.analytics_created_at || '', ai_request_type: payload.ai_request_type || '', text_model_name: payload.ai_request_type === 'text' ? config.model_name || '' : '', image_model_name: payload.ai_request_type === 'image' ? imageConfig.model_name || '' : '', }; void Promise.resolve() .then(() => fetch(ANALYTICS_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), })) .catch(() => undefined); } function imageExtensionFromMime(mimeType) { const normalized = String(mimeType || '').toLowerCase(); if (normalized.includes('jpeg') || normalized.includes('jpg')) return 'jpg'; if (normalized.includes('webp')) return 'webp'; if (normalized.includes('gif')) return 'gif'; if (normalized.includes('bmp')) return 'bmp'; return 'png'; } function getImageModelAvailability(config) { const imageConfig = config.image_model || {}; if (imageConfig.status !== 'available') { return { available: false, status: imageConfig.status || 'untested', message: '生图模型未测试可用' }; } if (!imageConfig.api_key) { return { available: false, status: 'unavailable', message: '请先填写生图模型 API Key' }; } if (!imageConfig.model_name) { return { available: false, status: 'unavailable', message: '请先填写生图模型名称' }; } return { available: true, status: 'available', message: '生图模型可用' }; } function normalizeImagePrompt(request) { const prompt = String(request.prompt || '').trim(); if (!prompt) { throw new Error('生图提示词为空'); } const styleHint = request.style === 'realistic_photo' ? '画面采用专业实景照片风格,真实、克制、适合投标技术方案插图。' : '画面采用工程项目图示风格,结构清晰、专业克制、适合投标技术方案插图。'; return `${prompt}\n\n${styleHint}\n避免出现品牌标识、水印、夸张营销元素和无关文字。`; } function safeImageResponse(data) { return { ...data, data: Array.isArray(data?.data) ? data.data.map((item) => ({ ...item, b64_json: item.b64_json ? '[base64 omitted]' : item.b64_json })) : data?.data, candidates: Array.isArray(data?.candidates) ? '[candidates omitted]' : data?.candidates, }; } async function downloadImage(url) { const response = await fetch(url); await ensureOk(response, '图片下载失败'); return { buffer: Buffer.from(await response.arrayBuffer()), mime_type: response.headers.get('content-type') || 'image/png', }; } function saveGeneratedImage(app, image) { const imagesDir = getGeneratedImagesDir(app); fs.mkdirSync(imagesDir, { recursive: true }); const extension = imageExtensionFromMime(image.mime_type); const fileName = `${new Date().toISOString().replace(/[:.]/g, '-')}-${crypto.randomUUID()}.${extension}`; const filePath = path.join(imagesDir, fileName); fs.writeFileSync(filePath, image.buffer); return { asset_url: `yibiao-asset://generated-images/${encodeURIComponent(fileName)}`, file_path: filePath, mime_type: image.mime_type, }; } async function ensureOk(response, fallbackMessage) { if (response.ok) { return; } let detail = ''; try { const body = await response.json(); detail = body.error?.message || body.message || ''; } catch { detail = await response.text().catch(() => ''); } throw new Error(detail || fallbackMessage); } function extractJsonContent(content) { const normalized = String(content || '').trim(); if (!normalized.startsWith('```')) { return normalized; } const lines = normalized.split(/\r?\n/); const firstLine = (lines[0] || '').trim().toLowerCase(); const lastLine = (lines[lines.length - 1] || '').trim(); if ((firstLine === '```' || firstLine === '```json') && lastLine.startsWith('```')) { return lines.slice(1, -1).join('\n').trim(); } return normalized; } function extractFencedJsonBlocks(content) { const blocks = []; const normalized = String(content || '').trim(); const fenceRegex = /```(?:json)?\s*([\s\S]*?)```/gi; let match = fenceRegex.exec(normalized); while (match) { const block = String(match[1] || '').trim(); if (block) { blocks.push(block); } match = fenceRegex.exec(normalized); } return blocks; } function extractBalancedJsonCandidates(content) { const text = String(content || ''); const candidates = []; for (let start = 0; start < text.length; start += 1) { const firstChar = text[start]; if (firstChar !== '{' && firstChar !== '[') { continue; } const stack = [firstChar]; let inString = false; let escaped = false; for (let index = start + 1; index < text.length; index += 1) { const char = text[index]; if (inString) { if (escaped) { escaped = false; } else if (char === '\\') { escaped = true; } else if (char === '"') { inString = false; } continue; } if (char === '"') { inString = true; continue; } if (char === '{' || char === '[') { stack.push(char); continue; } if (char === '}' || char === ']') { const expectedOpen = char === '}' ? '{' : '['; if (stack[stack.length - 1] !== expectedOpen) { break; } stack.pop(); if (!stack.length) { const candidate = text.slice(start, index + 1).trim(); if (candidate) { candidates.push(candidate); } start = index; break; } } } } return candidates; } function parseJsonContent(content) { const normalized = String(content || '').replace(/^\uFEFF/, '').trim(); const candidates = [ normalized, extractJsonContent(normalized), ...extractFencedJsonBlocks(normalized), ].filter(Boolean); const withBalancedCandidates = []; for (const candidate of candidates) { withBalancedCandidates.push(candidate); withBalancedCandidates.push(...extractBalancedJsonCandidates(candidate)); } const uniqueCandidates = [...new Set(withBalancedCandidates.map((item) => item.trim()).filter(Boolean))]; let lastError = null; for (const candidate of uniqueCandidates) { try { return JSON.parse(candidate); } catch (error) { lastError = error; } } throw lastError || new Error('AI 返回内容为空,无法解析 JSON'); } function formatJsonIssues(error) { if (error instanceof SyntaxError) { return [`JSON 语法错误:${error.message}`]; } return [error?.message || String(error || '字段校验失败')]; } function buildJsonRepairMessages(invalidContent, issues, targetDescription) { const issueLines = (issues || []).map((item, index) => `${index + 1}. ${item}`).join('\n'); return [ { role: 'system', content: `你是一个严格的 JSON 修复助手。请根据给出的原始内容和校验问题,修复现有结果。 要求: 1. 优先在原结果基础上做最小必要修改,不要整体重写 2. 尽量保留原有结构、字段值、节点顺序和已生成内容 3. 若缺少必填字段,应结合现有上下文补齐合理内容,不要用空字符串敷衍 4. 若存在多余说明、代码块包裹、字段名错误、children 结构不规范或顶层包裹错误,应修正为合法 JSON 5. 只返回修复后的完整 JSON,不要输出任何解释`, }, { role: 'user', content: `目标结果类型:${targetDescription}` }, { role: 'user', content: `当前校验问题:\n${issueLines}` }, { role: 'user', content: `待修复内容:\n\`\`\`json\n${String(invalidContent || '').slice(0, 60000)}\n\`\`\``, }, { role: 'user', content: '请在保留原有正确内容的前提下,仅修复上述问题,并返回完整 JSON。', }, ]; } async function emitProgress(progressCallback, message) { if (!progressCallback) { return; } await Promise.resolve(progressCallback(message)); } async function repairJsonResponse(app, config, invalidContent, issues, temperature, responseFormat, progressCallback, progressLabel, repairMessagesBuilder) { await emitProgress(progressCallback, `${progressLabel}格式校验失败,正在基于当前结果进行修复。`); return chatWithConfig(app, config, { messages: repairMessagesBuilder ? repairMessagesBuilder({ invalidContent, issues, progressLabel }) : buildJsonRepairMessages(invalidContent, issues, progressLabel), temperature, response_format: responseFormat, }); } async function collectJsonResponseWithConfig(app, config, request) { const maxRetries = request.max_retries ?? 2; const totalAttempts = maxRetries + 1; const temperature = request.temperature ?? 0.7; const responseFormat = request.response_format || { type: 'json_object' }; const progressLabel = request.progressLabel || 'JSON结果'; const failureMessage = request.failureMessage || '模型返回的 JSON 数据格式无效'; let lastError = null; for (let attempt = 0; attempt < totalAttempts; attempt += 1) { const content = await chatWithConfig(app, config, { messages: request.messages, temperature, response_format: responseFormat, }); try { const parsed = parseJsonContent(content); const normalized = request.normalizer ? request.normalizer(parsed) : parsed; if (request.validator) { request.validator(normalized); } return normalized; } catch (error) { lastError = error; const issues = formatJsonIssues(error); try { const repairedContent = await repairJsonResponse( app, config, content, issues, temperature, responseFormat, request.progressCallback, progressLabel, request.repairMessagesBuilder, ); const repairedParsed = parseJsonContent(repairedContent); const repairedNormalized = request.normalizer ? request.normalizer(repairedParsed) : repairedParsed; if (request.validator) { request.validator(repairedNormalized); } return repairedNormalized; } catch (repairError) { lastError = repairError; if (attempt === maxRetries) { await emitProgress(request.progressCallback, `${progressLabel}连续 ${totalAttempts} 次校验失败。`); throw new Error(failureMessage); } await emitProgress(request.progressCallback, `${progressLabel}第 ${attempt + 1}/${totalAttempts} 次校验失败,正在重试。`); } } } throw new Error(lastError?.message || failureMessage); } function createChatRequestBody(config, request, options = {}) { const body = { model: config.model_name, messages: request.messages, temperature: request.temperature ?? 0.3, }; if (request.response_format && !options.omitResponseFormat) { body.response_format = request.response_format; } if (options.stream) { body.stream = true; } return body; } async function fetchChatCompletion(app, config, body) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), AI_REQUEST_TIMEOUT_MS); try { trackAiRequest(app, config, { ai_request_type: 'text' }); return await fetch(`${trimBaseUrl(config.base_url)}/chat/completions`, { method: 'POST', headers: createHeaders(config.api_key), body: JSON.stringify(body), signal: controller.signal, }); } finally { clearTimeout(timer); } } async function chatWithConfig(app, config, request) { if (!config.api_key) { throw new Error('请先在设置中配置文本模型 API Key'); } if (!config.model_name) { throw new Error('请先在设置中配置文本模型名称'); } const requestId = createRequestId(); let requestBody = createChatRequestBody(config, request); let responseData = null; let errorMessage = ''; try { writeAiLog(app, config, { request_id: requestId, type: 'chat-pending', url: `${trimBaseUrl(config.base_url)}/chat/completions`, request: requestBody, status: 'pending', created_at: new Date().toISOString(), }); let response = await fetchChatCompletion(app, config, requestBody); if (!response.ok && request.response_format) { const detail = await response.text().catch(() => ''); if (isResponseFormatUnsupported(detail)) { requestBody = createChatRequestBody(config, request, { omitResponseFormat: true }); response = await fetchChatCompletion(app, config, requestBody); } else { throw new Error(detail || 'AI 请求失败'); } } await ensureOk(response, 'AI 请求失败'); responseData = await response.json(); const content = responseData.choices?.[0]?.message?.content || ''; writeAiLog(app, config, { request_id: requestId, type: 'chat', url: `${trimBaseUrl(config.base_url)}/chat/completions`, request: requestBody, response: responseData, content, created_at: new Date().toISOString(), }); return content; } catch (error) { errorMessage = error.name === 'AbortError' ? `AI 请求超时(${AI_REQUEST_TIMEOUT_MS / 1000} 秒)` : error.message; writeAiLog(app, config, { request_id: requestId, type: 'chat-error', url: `${trimBaseUrl(config.base_url)}/chat/completions`, request: requestBody, response: responseData, error: errorMessage, created_at: new Date().toISOString(), }); throw new Error(errorMessage || 'AI 请求失败'); } } async function streamChatWithConfig(app, config, request, onEvent) { if (!config.api_key) { throw new Error('请先在设置中配置文本模型 API Key'); } if (!config.model_name) { throw new Error('请先在设置中配置文本模型名称'); } const requestId = createRequestId(); let requestBody = createChatRequestBody(config, request, { stream: true }); const rawEvents = []; const contentParts = []; const startedAt = Date.now(); let response = null; let responseMetadata = null; let phase = 'request'; let ignoredSseLineCount = 0; let lastIgnoredSseLine = ''; function streamStats() { const partialContent = contentParts.join(''); return { phase, elapsed_ms: Date.now() - startedAt, raw_event_count: rawEvents.length, ignored_sse_line_count: ignoredSseLineCount, last_ignored_sse_line: lastIgnoredSseLine, partial_content_chars: partialContent.length, partial_content_tail: partialContent.slice(-2000), response_meta: responseMetadata, }; } writeAiLog(app, config, { request_id: requestId, type: 'stream-pending', url: `${trimBaseUrl(config.base_url)}/chat/completions`, request: requestBody, status: 'pending', diagnostics: streamStats(), created_at: new Date().toISOString(), }); try { phase = 'fetching-response'; response = await fetchChatCompletion(app, config, requestBody); responseMetadata = responseMeta(response); phase = 'checking-response-status'; if (!response.ok && request.response_format) { const detail = await response.text().catch(() => ''); if (isResponseFormatUnsupported(detail)) { phase = 'retrying-without-response-format'; requestBody = createChatRequestBody(config, request, { stream: true, omitResponseFormat: true }); response = await fetchChatCompletion(app, config, requestBody); responseMetadata = responseMeta(response); } else { throw new Error(detail || 'AI 流式请求失败'); } } await ensureOk(response, 'AI 流式请求失败'); phase = 'stream-open'; writeAiLog(app, config, { request_id: requestId, type: 'stream-open', url: `${trimBaseUrl(config.base_url)}/chat/completions`, request: requestBody, response_meta: responseMetadata, diagnostics: streamStats(), created_at: new Date().toISOString(), }); const decoder = new TextDecoder('utf-8'); let buffer = ''; const emitLine = (line) => { if (!line.startsWith('data:')) { return; } const payload = line.slice(5).trim(); if (!payload || payload === '[DONE]') { return; } try { const data = JSON.parse(payload); rawEvents.push(data); const chunk = data.choices?.[0]?.delta?.content || ''; if (chunk) { contentParts.push(chunk); onEvent({ type: 'chunk', chunk }); } } catch { ignoredSseLineCount += 1; lastIgnoredSseLine = payload.slice(0, 1000); // 忽略供应商偶发的非 JSON SSE 行,避免中断已返回内容。 } }; if (!response.body) { throw new Error('AI 流式响应体为空'); } phase = 'reading-stream'; for await (const chunk of response.body) { buffer += decoder.decode(chunk, { stream: true }); const lines = buffer.split(/\r?\n/); buffer = lines.pop() || ''; lines.forEach((line) => emitLine(line.trim())); } phase = 'flushing-stream-buffer'; buffer.split(/\r?\n/).forEach((line) => emitLine(line.trim())); phase = 'done'; writeAiLog(app, config, { request_id: requestId, type: 'stream', url: `${trimBaseUrl(config.base_url)}/chat/completions`, request: requestBody, response: rawEvents, response_meta: responseMetadata, diagnostics: streamStats(), content: contentParts.join(''), created_at: new Date().toISOString(), }); onEvent({ type: 'done' }); } catch (error) { const message = normalizeAiError(error, 'AI 流式请求失败'); writeAiLog(app, config, { request_id: requestId, type: 'stream-error', url: `${trimBaseUrl(config.base_url)}/chat/completions`, request: requestBody, response: rawEvents, response_meta: responseMetadata, error: message, diagnostics: streamStats(), created_at: new Date().toISOString(), }); throw new Error(message); } } async function testVolcengineImageModel(app, config) { const imageConfig = config.image_model || {}; if (!imageConfig.api_key) { throw new Error('请先填写火山方舟 API Key'); } if (!imageConfig.model_name) { throw new Error('请先填写火山方舟生图模型名称'); } trackAiRequest(app, config, { ai_request_type: 'image' }); const response = await fetch(`${trimBaseUrl(imageConfig.base_url || 'https://ark.cn-beijing.volces.com/api/v3')}/images/generations`, { method: 'POST', headers: createHeaders(imageConfig.api_key), body: JSON.stringify({ model: imageConfig.model_name, prompt: 'a simple blue dot on a white background', size: '2048x2048', response_format: 'url', }), }); try { await ensureOk(response, '火山方舟生图测试失败'); } catch (error) { const message = error.message || ''; if (message.includes('does not exist') || message.includes('do not have access')) { throw new Error(`火山方舟生图模型不可用,请确认模型名称或推理接入点 ID 已开通并可访问。原始错误:${message}`); } throw error; } const data = await response.json(); const imageUrl = data.data?.[0]?.url || ''; return { success: true, message: imageUrl ? `测试成功:已生成图片 ${imageUrl}` : '测试成功:已返回生图结果', image_url: imageUrl, }; } async function testGoogleImageModel(app, config) { const imageConfig = config.image_model || {}; if (!imageConfig.api_key) { throw new Error('请先填写 Google AI Studio API Key'); } if (!imageConfig.model_name) { throw new Error('请先填写 Google 生图模型名称'); } trackAiRequest(app, config, { ai_request_type: 'image' }); const response = await fetch(`${trimBaseUrl(imageConfig.base_url || 'https://generativelanguage.googleapis.com/v1beta')}/models/${encodeURIComponent(imageConfig.model_name)}:generateContent`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-goog-api-key': imageConfig.api_key, }, body: JSON.stringify({ contents: [ { role: 'user', parts: [{ text: 'Create a simple blue dot on a white background.' }], }, ], generationConfig: { responseModalities: ['TEXT', 'IMAGE'], }, }), }); await ensureOk(response, 'Google AI Studio 生图测试失败'); const data = await response.json(); const parts = data.candidates?.[0]?.content?.parts || []; const text = parts.find((part) => part.text)?.text || ''; const imagePart = parts.find((part) => part.inlineData?.data || part.inline_data?.data); const inlineData = imagePart?.inlineData || imagePart?.inline_data; return { success: true, message: inlineData?.data ? `测试成功:已返回图片${text ? `,${text}` : ''}` : `测试成功:${text || '已返回生成结果'}`, image_data: inlineData?.data || '', mime_type: inlineData?.mimeType || inlineData?.mime_type || 'image/png', }; } async function generateVolcengineImage(app, config, request) { const imageConfig = config.image_model || {}; const requestId = createRequestId(); const requestBody = { model: imageConfig.model_name, prompt: normalizeImagePrompt(request), size: request.size || '2048x2048', response_format: 'url', }; let responseData = null; try { writeAiLog(app, config, { request_id: requestId, type: 'image-pending', provider: 'volcengine', url: `${trimBaseUrl(imageConfig.base_url || 'https://ark.cn-beijing.volces.com/api/v3')}/images/generations`, request: requestBody, status: 'pending', created_at: new Date().toISOString(), }); trackAiRequest(app, config, { ai_request_type: 'image' }); const response = await fetch(`${trimBaseUrl(imageConfig.base_url || 'https://ark.cn-beijing.volces.com/api/v3')}/images/generations`, { method: 'POST', headers: createHeaders(imageConfig.api_key), body: JSON.stringify(requestBody), }); await ensureOk(response, '火山方舟生图失败'); responseData = await response.json(); const item = responseData.data?.[0] || {}; const image = item.b64_json ? { buffer: Buffer.from(item.b64_json, 'base64'), mime_type: 'image/png' } : item.url ? await downloadImage(item.url) : null; if (!image) { throw new Error('火山方舟生图未返回图片数据'); } const saved = saveGeneratedImage(app, image); writeAiLog(app, config, { request_id: requestId, type: 'image', provider: 'volcengine', request: requestBody, response: safeImageResponse(responseData), result: saved, created_at: new Date().toISOString(), }); return { success: true, title: request.title || '', ...saved }; } catch (error) { writeAiLog(app, config, { request_id: requestId, type: 'image-error', provider: 'volcengine', request: requestBody, response: responseData ? safeImageResponse(responseData) : null, error: error.message, created_at: new Date().toISOString(), }); throw error; } } async function generateGoogleImage(app, config, request) { const imageConfig = config.image_model || {}; const requestId = createRequestId(); const requestBody = { contents: [ { role: 'user', parts: [{ text: normalizeImagePrompt(request) }], }, ], generationConfig: { responseModalities: ['TEXT', 'IMAGE'], }, }; let responseData = null; try { writeAiLog(app, config, { request_id: requestId, type: 'image-pending', provider: 'google-ai-studio', url: `${trimBaseUrl(imageConfig.base_url || 'https://generativelanguage.googleapis.com/v1beta')}/models/${encodeURIComponent(imageConfig.model_name)}:generateContent`, request: requestBody, status: 'pending', created_at: new Date().toISOString(), }); trackAiRequest(app, config, { ai_request_type: 'image' }); const response = await fetch(`${trimBaseUrl(imageConfig.base_url || 'https://generativelanguage.googleapis.com/v1beta')}/models/${encodeURIComponent(imageConfig.model_name)}:generateContent`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-goog-api-key': imageConfig.api_key, }, body: JSON.stringify(requestBody), }); await ensureOk(response, 'Google AI Studio 生图失败'); responseData = await response.json(); const parts = responseData.candidates?.[0]?.content?.parts || []; const imagePart = parts.find((part) => part.inlineData?.data || part.inline_data?.data); const inlineData = imagePart?.inlineData || imagePart?.inline_data; if (!inlineData?.data) { throw new Error('Google AI Studio 生图未返回图片数据'); } const saved = saveGeneratedImage(app, { buffer: Buffer.from(inlineData.data, 'base64'), mime_type: inlineData.mimeType || inlineData.mime_type || 'image/png', }); writeAiLog(app, config, { request_id: requestId, type: 'image', provider: 'google-ai-studio', request: requestBody, response: safeImageResponse(responseData), result: saved, created_at: new Date().toISOString(), }); return { success: true, title: request.title || '', ...saved }; } catch (error) { writeAiLog(app, config, { request_id: requestId, type: 'image-error', provider: 'google-ai-studio', request: requestBody, response: responseData ? safeImageResponse(responseData) : null, error: error.message, created_at: new Date().toISOString(), }); throw error; } } async function generateImageWithConfig(app, config, request) { const availability = getImageModelAvailability(config); if (!availability.available) { throw new Error(availability.message); } if (config.image_model?.provider === 'volcengine') { return generateVolcengineImage(app, config, request); } if (config.image_model?.provider === 'google-ai-studio') { return generateGoogleImage(app, config, request); } throw new Error('当前生图服务商暂不支持正文配图'); } function createAiService({ app, configStore }) { return { async chat(request) { const config = configStore.load(); return chatWithConfig(app, config, request); }, async requestJson(request) { const config = configStore.load(); return collectJsonResponseWithConfig(app, config, request); }, async collectJsonResponse(request) { const config = configStore.load(); return collectJsonResponseWithConfig(app, config, request); }, async streamChat(request, onEvent) { const config = configStore.load(); return streamChatWithConfig(app, config, request, onEvent); }, async testImageModel(config) { const currentConfig = configStore.load(); const trackedConfig = { ...config, analytics_client_id: config.analytics_client_id || currentConfig.analytics_client_id, analytics_created_at: config.analytics_created_at || currentConfig.analytics_created_at, }; if (trackedConfig.image_model?.provider === 'volcengine') { return testVolcengineImageModel(app, trackedConfig); } if (trackedConfig.image_model?.provider === 'google-ai-studio') { return testGoogleImageModel(app, trackedConfig); } throw new Error('当前服务商暂不支持测试'); }, getImageModelAvailability() { return getImageModelAvailability(configStore.load()); }, isDeveloperMode() { return Boolean(configStore.load()?.developer_mode); }, async generateImage(request) { const config = configStore.load(); return generateImageWithConfig(app, config, request); }, async listModels(configOverride) { const config = configOverride || configStore.load(); if (!config.api_key) { return { success: false, message: '请先填写文本模型 API Key', models: [] }; } const response = await fetch(`${trimBaseUrl(config.base_url)}/models`, { method: 'GET', headers: createHeaders(config.api_key), }); await ensureOk(response, '获取模型列表失败'); const data = await response.json(); return { success: true, message: '模型列表已更新', models: Array.isArray(data.data) ? data.data.map((item) => item.id).filter(Boolean) : [], }; }, }; } module.exports = { createAiService, };