| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993 |
- 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,
- };
|