aiService.cjs 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993
  1. const fs = require('node:fs');
  2. const path = require('node:path');
  3. const crypto = require('node:crypto');
  4. const { getAiLogsDir, getGeneratedImagesDir } = require('../utils/paths.cjs');
  5. const AI_REQUEST_TIMEOUT_MS = 300000;
  6. const ANALYTICS_ENDPOINT = 'https://analytics.agnet.top/track';
  7. const ANALYTICS_PROJECT_NAME = 'yibiao-client';
  8. function trimBaseUrl(baseUrl) {
  9. return (baseUrl || 'https://api.openai.com/v1').replace(/\/+$/, '');
  10. }
  11. function createRequestId() {
  12. return `${new Date().toISOString().replace(/[:.]/g, '-')}-${crypto.randomUUID()}`;
  13. }
  14. function isResponseFormatUnsupported(message) {
  15. const normalized = String(message || '').toLowerCase();
  16. return normalized.includes('response_format') && [
  17. 'not supported',
  18. 'does not support',
  19. 'not support',
  20. 'unsupported',
  21. 'unknown parameter',
  22. 'invalid parameter',
  23. 'must be',
  24. ].some((marker) => normalized.includes(marker));
  25. }
  26. function writeAiLog(app, config, payload) {
  27. if (!config.developer_mode) {
  28. return;
  29. }
  30. const logsDir = getAiLogsDir(app);
  31. fs.mkdirSync(logsDir, { recursive: true });
  32. const fileName = `${payload.request_id}.json`;
  33. fs.writeFileSync(path.join(logsDir, fileName), JSON.stringify(payload, null, 2), 'utf-8');
  34. }
  35. function responseMeta(response) {
  36. if (!response) {
  37. return null;
  38. }
  39. const headers = {};
  40. response.headers?.forEach?.((value, key) => {
  41. const normalizedKey = String(key || '').toLowerCase();
  42. if (['authorization', 'cookie', 'set-cookie', 'x-api-key'].includes(normalizedKey)) {
  43. return;
  44. }
  45. headers[normalizedKey] = value;
  46. });
  47. return {
  48. status: response.status,
  49. status_text: response.statusText,
  50. headers,
  51. };
  52. }
  53. function normalizeAiError(error, fallbackMessage) {
  54. if (error?.name === 'AbortError') {
  55. return `AI 请求超时(${AI_REQUEST_TIMEOUT_MS / 1000} 秒)`;
  56. }
  57. return error?.message || String(error || '') || fallbackMessage;
  58. }
  59. function createHeaders(apiKey) {
  60. return {
  61. 'Content-Type': 'application/json',
  62. Authorization: `Bearer ${apiKey}`,
  63. };
  64. }
  65. function trackAiRequest(app, config, payload) {
  66. const imageConfig = config.image_model || {};
  67. const body = {
  68. projectName: ANALYTICS_PROJECT_NAME,
  69. event: 'ai_request',
  70. version: typeof app?.getVersion === 'function' ? app.getVersion() : '',
  71. platform: process.platform,
  72. arch: process.arch,
  73. client_id: config.analytics_client_id || '',
  74. client_created_at: config.analytics_created_at || '',
  75. ai_request_type: payload.ai_request_type || '',
  76. text_model_name: payload.ai_request_type === 'text' ? config.model_name || '' : '',
  77. image_model_name: payload.ai_request_type === 'image' ? imageConfig.model_name || '' : '',
  78. };
  79. void Promise.resolve()
  80. .then(() => fetch(ANALYTICS_ENDPOINT, {
  81. method: 'POST',
  82. headers: { 'Content-Type': 'application/json' },
  83. body: JSON.stringify(body),
  84. }))
  85. .catch(() => undefined);
  86. }
  87. function imageExtensionFromMime(mimeType) {
  88. const normalized = String(mimeType || '').toLowerCase();
  89. if (normalized.includes('jpeg') || normalized.includes('jpg')) return 'jpg';
  90. if (normalized.includes('webp')) return 'webp';
  91. if (normalized.includes('gif')) return 'gif';
  92. if (normalized.includes('bmp')) return 'bmp';
  93. return 'png';
  94. }
  95. function getImageModelAvailability(config) {
  96. const imageConfig = config.image_model || {};
  97. if (imageConfig.status !== 'available') {
  98. return { available: false, status: imageConfig.status || 'untested', message: '生图模型未测试可用' };
  99. }
  100. if (!imageConfig.api_key) {
  101. return { available: false, status: 'unavailable', message: '请先填写生图模型 API Key' };
  102. }
  103. if (!imageConfig.model_name) {
  104. return { available: false, status: 'unavailable', message: '请先填写生图模型名称' };
  105. }
  106. return { available: true, status: 'available', message: '生图模型可用' };
  107. }
  108. function normalizeImagePrompt(request) {
  109. const prompt = String(request.prompt || '').trim();
  110. if (!prompt) {
  111. throw new Error('生图提示词为空');
  112. }
  113. const styleHint = request.style === 'realistic_photo'
  114. ? '画面采用专业实景照片风格,真实、克制、适合投标技术方案插图。'
  115. : '画面采用工程项目图示风格,结构清晰、专业克制、适合投标技术方案插图。';
  116. return `${prompt}\n\n${styleHint}\n避免出现品牌标识、水印、夸张营销元素和无关文字。`;
  117. }
  118. function safeImageResponse(data) {
  119. return {
  120. ...data,
  121. data: Array.isArray(data?.data)
  122. ? data.data.map((item) => ({ ...item, b64_json: item.b64_json ? '[base64 omitted]' : item.b64_json }))
  123. : data?.data,
  124. candidates: Array.isArray(data?.candidates) ? '[candidates omitted]' : data?.candidates,
  125. };
  126. }
  127. async function downloadImage(url) {
  128. const response = await fetch(url);
  129. await ensureOk(response, '图片下载失败');
  130. return {
  131. buffer: Buffer.from(await response.arrayBuffer()),
  132. mime_type: response.headers.get('content-type') || 'image/png',
  133. };
  134. }
  135. function saveGeneratedImage(app, image) {
  136. const imagesDir = getGeneratedImagesDir(app);
  137. fs.mkdirSync(imagesDir, { recursive: true });
  138. const extension = imageExtensionFromMime(image.mime_type);
  139. const fileName = `${new Date().toISOString().replace(/[:.]/g, '-')}-${crypto.randomUUID()}.${extension}`;
  140. const filePath = path.join(imagesDir, fileName);
  141. fs.writeFileSync(filePath, image.buffer);
  142. return {
  143. asset_url: `yibiao-asset://generated-images/${encodeURIComponent(fileName)}`,
  144. file_path: filePath,
  145. mime_type: image.mime_type,
  146. };
  147. }
  148. async function ensureOk(response, fallbackMessage) {
  149. if (response.ok) {
  150. return;
  151. }
  152. let detail = '';
  153. try {
  154. const body = await response.json();
  155. detail = body.error?.message || body.message || '';
  156. } catch {
  157. detail = await response.text().catch(() => '');
  158. }
  159. throw new Error(detail || fallbackMessage);
  160. }
  161. function extractJsonContent(content) {
  162. const normalized = String(content || '').trim();
  163. if (!normalized.startsWith('```')) {
  164. return normalized;
  165. }
  166. const lines = normalized.split(/\r?\n/);
  167. const firstLine = (lines[0] || '').trim().toLowerCase();
  168. const lastLine = (lines[lines.length - 1] || '').trim();
  169. if ((firstLine === '```' || firstLine === '```json') && lastLine.startsWith('```')) {
  170. return lines.slice(1, -1).join('\n').trim();
  171. }
  172. return normalized;
  173. }
  174. function extractFencedJsonBlocks(content) {
  175. const blocks = [];
  176. const normalized = String(content || '').trim();
  177. const fenceRegex = /```(?:json)?\s*([\s\S]*?)```/gi;
  178. let match = fenceRegex.exec(normalized);
  179. while (match) {
  180. const block = String(match[1] || '').trim();
  181. if (block) {
  182. blocks.push(block);
  183. }
  184. match = fenceRegex.exec(normalized);
  185. }
  186. return blocks;
  187. }
  188. function extractBalancedJsonCandidates(content) {
  189. const text = String(content || '');
  190. const candidates = [];
  191. for (let start = 0; start < text.length; start += 1) {
  192. const firstChar = text[start];
  193. if (firstChar !== '{' && firstChar !== '[') {
  194. continue;
  195. }
  196. const stack = [firstChar];
  197. let inString = false;
  198. let escaped = false;
  199. for (let index = start + 1; index < text.length; index += 1) {
  200. const char = text[index];
  201. if (inString) {
  202. if (escaped) {
  203. escaped = false;
  204. } else if (char === '\\') {
  205. escaped = true;
  206. } else if (char === '"') {
  207. inString = false;
  208. }
  209. continue;
  210. }
  211. if (char === '"') {
  212. inString = true;
  213. continue;
  214. }
  215. if (char === '{' || char === '[') {
  216. stack.push(char);
  217. continue;
  218. }
  219. if (char === '}' || char === ']') {
  220. const expectedOpen = char === '}' ? '{' : '[';
  221. if (stack[stack.length - 1] !== expectedOpen) {
  222. break;
  223. }
  224. stack.pop();
  225. if (!stack.length) {
  226. const candidate = text.slice(start, index + 1).trim();
  227. if (candidate) {
  228. candidates.push(candidate);
  229. }
  230. start = index;
  231. break;
  232. }
  233. }
  234. }
  235. }
  236. return candidates;
  237. }
  238. function parseJsonContent(content) {
  239. const normalized = String(content || '').replace(/^\uFEFF/, '').trim();
  240. const candidates = [
  241. normalized,
  242. extractJsonContent(normalized),
  243. ...extractFencedJsonBlocks(normalized),
  244. ].filter(Boolean);
  245. const withBalancedCandidates = [];
  246. for (const candidate of candidates) {
  247. withBalancedCandidates.push(candidate);
  248. withBalancedCandidates.push(...extractBalancedJsonCandidates(candidate));
  249. }
  250. const uniqueCandidates = [...new Set(withBalancedCandidates.map((item) => item.trim()).filter(Boolean))];
  251. let lastError = null;
  252. for (const candidate of uniqueCandidates) {
  253. try {
  254. return JSON.parse(candidate);
  255. } catch (error) {
  256. lastError = error;
  257. }
  258. }
  259. throw lastError || new Error('AI 返回内容为空,无法解析 JSON');
  260. }
  261. function formatJsonIssues(error) {
  262. if (error instanceof SyntaxError) {
  263. return [`JSON 语法错误:${error.message}`];
  264. }
  265. return [error?.message || String(error || '字段校验失败')];
  266. }
  267. function buildJsonRepairMessages(invalidContent, issues, targetDescription) {
  268. const issueLines = (issues || []).map((item, index) => `${index + 1}. ${item}`).join('\n');
  269. return [
  270. {
  271. role: 'system',
  272. content: `你是一个严格的 JSON 修复助手。请根据给出的原始内容和校验问题,修复现有结果。
  273. 要求:
  274. 1. 优先在原结果基础上做最小必要修改,不要整体重写
  275. 2. 尽量保留原有结构、字段值、节点顺序和已生成内容
  276. 3. 若缺少必填字段,应结合现有上下文补齐合理内容,不要用空字符串敷衍
  277. 4. 若存在多余说明、代码块包裹、字段名错误、children 结构不规范或顶层包裹错误,应修正为合法 JSON
  278. 5. 只返回修复后的完整 JSON,不要输出任何解释`,
  279. },
  280. { role: 'user', content: `目标结果类型:${targetDescription}` },
  281. { role: 'user', content: `当前校验问题:\n${issueLines}` },
  282. {
  283. role: 'user',
  284. content: `待修复内容:\n\`\`\`json\n${String(invalidContent || '').slice(0, 60000)}\n\`\`\``,
  285. },
  286. {
  287. role: 'user',
  288. content: '请在保留原有正确内容的前提下,仅修复上述问题,并返回完整 JSON。',
  289. },
  290. ];
  291. }
  292. async function emitProgress(progressCallback, message) {
  293. if (!progressCallback) {
  294. return;
  295. }
  296. await Promise.resolve(progressCallback(message));
  297. }
  298. async function repairJsonResponse(app, config, invalidContent, issues, temperature, responseFormat, progressCallback, progressLabel, repairMessagesBuilder) {
  299. await emitProgress(progressCallback, `${progressLabel}格式校验失败,正在基于当前结果进行修复。`);
  300. return chatWithConfig(app, config, {
  301. messages: repairMessagesBuilder
  302. ? repairMessagesBuilder({ invalidContent, issues, progressLabel })
  303. : buildJsonRepairMessages(invalidContent, issues, progressLabel),
  304. temperature,
  305. response_format: responseFormat,
  306. });
  307. }
  308. async function collectJsonResponseWithConfig(app, config, request) {
  309. const maxRetries = request.max_retries ?? 2;
  310. const totalAttempts = maxRetries + 1;
  311. const temperature = request.temperature ?? 0.7;
  312. const responseFormat = request.response_format || { type: 'json_object' };
  313. const progressLabel = request.progressLabel || 'JSON结果';
  314. const failureMessage = request.failureMessage || '模型返回的 JSON 数据格式无效';
  315. let lastError = null;
  316. for (let attempt = 0; attempt < totalAttempts; attempt += 1) {
  317. const content = await chatWithConfig(app, config, {
  318. messages: request.messages,
  319. temperature,
  320. response_format: responseFormat,
  321. });
  322. try {
  323. const parsed = parseJsonContent(content);
  324. const normalized = request.normalizer ? request.normalizer(parsed) : parsed;
  325. if (request.validator) {
  326. request.validator(normalized);
  327. }
  328. return normalized;
  329. } catch (error) {
  330. lastError = error;
  331. const issues = formatJsonIssues(error);
  332. try {
  333. const repairedContent = await repairJsonResponse(
  334. app,
  335. config,
  336. content,
  337. issues,
  338. temperature,
  339. responseFormat,
  340. request.progressCallback,
  341. progressLabel,
  342. request.repairMessagesBuilder,
  343. );
  344. const repairedParsed = parseJsonContent(repairedContent);
  345. const repairedNormalized = request.normalizer ? request.normalizer(repairedParsed) : repairedParsed;
  346. if (request.validator) {
  347. request.validator(repairedNormalized);
  348. }
  349. return repairedNormalized;
  350. } catch (repairError) {
  351. lastError = repairError;
  352. if (attempt === maxRetries) {
  353. await emitProgress(request.progressCallback, `${progressLabel}连续 ${totalAttempts} 次校验失败。`);
  354. throw new Error(failureMessage);
  355. }
  356. await emitProgress(request.progressCallback, `${progressLabel}第 ${attempt + 1}/${totalAttempts} 次校验失败,正在重试。`);
  357. }
  358. }
  359. }
  360. throw new Error(lastError?.message || failureMessage);
  361. }
  362. function createChatRequestBody(config, request, options = {}) {
  363. const body = {
  364. model: config.model_name,
  365. messages: request.messages,
  366. temperature: request.temperature ?? 0.3,
  367. };
  368. if (request.response_format && !options.omitResponseFormat) {
  369. body.response_format = request.response_format;
  370. }
  371. if (options.stream) {
  372. body.stream = true;
  373. }
  374. return body;
  375. }
  376. async function fetchChatCompletion(app, config, body) {
  377. const controller = new AbortController();
  378. const timer = setTimeout(() => controller.abort(), AI_REQUEST_TIMEOUT_MS);
  379. try {
  380. trackAiRequest(app, config, { ai_request_type: 'text' });
  381. return await fetch(`${trimBaseUrl(config.base_url)}/chat/completions`, {
  382. method: 'POST',
  383. headers: createHeaders(config.api_key),
  384. body: JSON.stringify(body),
  385. signal: controller.signal,
  386. });
  387. } finally {
  388. clearTimeout(timer);
  389. }
  390. }
  391. async function chatWithConfig(app, config, request) {
  392. if (!config.api_key) {
  393. throw new Error('请先在设置中配置文本模型 API Key');
  394. }
  395. if (!config.model_name) {
  396. throw new Error('请先在设置中配置文本模型名称');
  397. }
  398. const requestId = createRequestId();
  399. let requestBody = createChatRequestBody(config, request);
  400. let responseData = null;
  401. let errorMessage = '';
  402. try {
  403. writeAiLog(app, config, {
  404. request_id: requestId,
  405. type: 'chat-pending',
  406. url: `${trimBaseUrl(config.base_url)}/chat/completions`,
  407. request: requestBody,
  408. status: 'pending',
  409. created_at: new Date().toISOString(),
  410. });
  411. let response = await fetchChatCompletion(app, config, requestBody);
  412. if (!response.ok && request.response_format) {
  413. const detail = await response.text().catch(() => '');
  414. if (isResponseFormatUnsupported(detail)) {
  415. requestBody = createChatRequestBody(config, request, { omitResponseFormat: true });
  416. response = await fetchChatCompletion(app, config, requestBody);
  417. } else {
  418. throw new Error(detail || 'AI 请求失败');
  419. }
  420. }
  421. await ensureOk(response, 'AI 请求失败');
  422. responseData = await response.json();
  423. const content = responseData.choices?.[0]?.message?.content || '';
  424. writeAiLog(app, config, {
  425. request_id: requestId,
  426. type: 'chat',
  427. url: `${trimBaseUrl(config.base_url)}/chat/completions`,
  428. request: requestBody,
  429. response: responseData,
  430. content,
  431. created_at: new Date().toISOString(),
  432. });
  433. return content;
  434. } catch (error) {
  435. errorMessage = error.name === 'AbortError' ? `AI 请求超时(${AI_REQUEST_TIMEOUT_MS / 1000} 秒)` : error.message;
  436. writeAiLog(app, config, {
  437. request_id: requestId,
  438. type: 'chat-error',
  439. url: `${trimBaseUrl(config.base_url)}/chat/completions`,
  440. request: requestBody,
  441. response: responseData,
  442. error: errorMessage,
  443. created_at: new Date().toISOString(),
  444. });
  445. throw new Error(errorMessage || 'AI 请求失败');
  446. }
  447. }
  448. async function streamChatWithConfig(app, config, request, onEvent) {
  449. if (!config.api_key) {
  450. throw new Error('请先在设置中配置文本模型 API Key');
  451. }
  452. if (!config.model_name) {
  453. throw new Error('请先在设置中配置文本模型名称');
  454. }
  455. const requestId = createRequestId();
  456. let requestBody = createChatRequestBody(config, request, { stream: true });
  457. const rawEvents = [];
  458. const contentParts = [];
  459. const startedAt = Date.now();
  460. let response = null;
  461. let responseMetadata = null;
  462. let phase = 'request';
  463. let ignoredSseLineCount = 0;
  464. let lastIgnoredSseLine = '';
  465. function streamStats() {
  466. const partialContent = contentParts.join('');
  467. return {
  468. phase,
  469. elapsed_ms: Date.now() - startedAt,
  470. raw_event_count: rawEvents.length,
  471. ignored_sse_line_count: ignoredSseLineCount,
  472. last_ignored_sse_line: lastIgnoredSseLine,
  473. partial_content_chars: partialContent.length,
  474. partial_content_tail: partialContent.slice(-2000),
  475. response_meta: responseMetadata,
  476. };
  477. }
  478. writeAiLog(app, config, {
  479. request_id: requestId,
  480. type: 'stream-pending',
  481. url: `${trimBaseUrl(config.base_url)}/chat/completions`,
  482. request: requestBody,
  483. status: 'pending',
  484. diagnostics: streamStats(),
  485. created_at: new Date().toISOString(),
  486. });
  487. try {
  488. phase = 'fetching-response';
  489. response = await fetchChatCompletion(app, config, requestBody);
  490. responseMetadata = responseMeta(response);
  491. phase = 'checking-response-status';
  492. if (!response.ok && request.response_format) {
  493. const detail = await response.text().catch(() => '');
  494. if (isResponseFormatUnsupported(detail)) {
  495. phase = 'retrying-without-response-format';
  496. requestBody = createChatRequestBody(config, request, { stream: true, omitResponseFormat: true });
  497. response = await fetchChatCompletion(app, config, requestBody);
  498. responseMetadata = responseMeta(response);
  499. } else {
  500. throw new Error(detail || 'AI 流式请求失败');
  501. }
  502. }
  503. await ensureOk(response, 'AI 流式请求失败');
  504. phase = 'stream-open';
  505. writeAiLog(app, config, {
  506. request_id: requestId,
  507. type: 'stream-open',
  508. url: `${trimBaseUrl(config.base_url)}/chat/completions`,
  509. request: requestBody,
  510. response_meta: responseMetadata,
  511. diagnostics: streamStats(),
  512. created_at: new Date().toISOString(),
  513. });
  514. const decoder = new TextDecoder('utf-8');
  515. let buffer = '';
  516. const emitLine = (line) => {
  517. if (!line.startsWith('data:')) {
  518. return;
  519. }
  520. const payload = line.slice(5).trim();
  521. if (!payload || payload === '[DONE]') {
  522. return;
  523. }
  524. try {
  525. const data = JSON.parse(payload);
  526. rawEvents.push(data);
  527. const chunk = data.choices?.[0]?.delta?.content || '';
  528. if (chunk) {
  529. contentParts.push(chunk);
  530. onEvent({ type: 'chunk', chunk });
  531. }
  532. } catch {
  533. ignoredSseLineCount += 1;
  534. lastIgnoredSseLine = payload.slice(0, 1000);
  535. // 忽略供应商偶发的非 JSON SSE 行,避免中断已返回内容。
  536. }
  537. };
  538. if (!response.body) {
  539. throw new Error('AI 流式响应体为空');
  540. }
  541. phase = 'reading-stream';
  542. for await (const chunk of response.body) {
  543. buffer += decoder.decode(chunk, { stream: true });
  544. const lines = buffer.split(/\r?\n/);
  545. buffer = lines.pop() || '';
  546. lines.forEach((line) => emitLine(line.trim()));
  547. }
  548. phase = 'flushing-stream-buffer';
  549. buffer.split(/\r?\n/).forEach((line) => emitLine(line.trim()));
  550. phase = 'done';
  551. writeAiLog(app, config, {
  552. request_id: requestId,
  553. type: 'stream',
  554. url: `${trimBaseUrl(config.base_url)}/chat/completions`,
  555. request: requestBody,
  556. response: rawEvents,
  557. response_meta: responseMetadata,
  558. diagnostics: streamStats(),
  559. content: contentParts.join(''),
  560. created_at: new Date().toISOString(),
  561. });
  562. onEvent({ type: 'done' });
  563. } catch (error) {
  564. const message = normalizeAiError(error, 'AI 流式请求失败');
  565. writeAiLog(app, config, {
  566. request_id: requestId,
  567. type: 'stream-error',
  568. url: `${trimBaseUrl(config.base_url)}/chat/completions`,
  569. request: requestBody,
  570. response: rawEvents,
  571. response_meta: responseMetadata,
  572. error: message,
  573. diagnostics: streamStats(),
  574. created_at: new Date().toISOString(),
  575. });
  576. throw new Error(message);
  577. }
  578. }
  579. async function testVolcengineImageModel(app, config) {
  580. const imageConfig = config.image_model || {};
  581. if (!imageConfig.api_key) {
  582. throw new Error('请先填写火山方舟 API Key');
  583. }
  584. if (!imageConfig.model_name) {
  585. throw new Error('请先填写火山方舟生图模型名称');
  586. }
  587. trackAiRequest(app, config, { ai_request_type: 'image' });
  588. const response = await fetch(`${trimBaseUrl(imageConfig.base_url || 'https://ark.cn-beijing.volces.com/api/v3')}/images/generations`, {
  589. method: 'POST',
  590. headers: createHeaders(imageConfig.api_key),
  591. body: JSON.stringify({
  592. model: imageConfig.model_name,
  593. prompt: 'a simple blue dot on a white background',
  594. size: '2048x2048',
  595. response_format: 'url',
  596. }),
  597. });
  598. try {
  599. await ensureOk(response, '火山方舟生图测试失败');
  600. } catch (error) {
  601. const message = error.message || '';
  602. if (message.includes('does not exist') || message.includes('do not have access')) {
  603. throw new Error(`火山方舟生图模型不可用,请确认模型名称或推理接入点 ID 已开通并可访问。原始错误:${message}`);
  604. }
  605. throw error;
  606. }
  607. const data = await response.json();
  608. const imageUrl = data.data?.[0]?.url || '';
  609. return {
  610. success: true,
  611. message: imageUrl ? `测试成功:已生成图片 ${imageUrl}` : '测试成功:已返回生图结果',
  612. image_url: imageUrl,
  613. };
  614. }
  615. async function testGoogleImageModel(app, config) {
  616. const imageConfig = config.image_model || {};
  617. if (!imageConfig.api_key) {
  618. throw new Error('请先填写 Google AI Studio API Key');
  619. }
  620. if (!imageConfig.model_name) {
  621. throw new Error('请先填写 Google 生图模型名称');
  622. }
  623. trackAiRequest(app, config, { ai_request_type: 'image' });
  624. const response = await fetch(`${trimBaseUrl(imageConfig.base_url || 'https://generativelanguage.googleapis.com/v1beta')}/models/${encodeURIComponent(imageConfig.model_name)}:generateContent`, {
  625. method: 'POST',
  626. headers: {
  627. 'Content-Type': 'application/json',
  628. 'x-goog-api-key': imageConfig.api_key,
  629. },
  630. body: JSON.stringify({
  631. contents: [
  632. {
  633. role: 'user',
  634. parts: [{ text: 'Create a simple blue dot on a white background.' }],
  635. },
  636. ],
  637. generationConfig: {
  638. responseModalities: ['TEXT', 'IMAGE'],
  639. },
  640. }),
  641. });
  642. await ensureOk(response, 'Google AI Studio 生图测试失败');
  643. const data = await response.json();
  644. const parts = data.candidates?.[0]?.content?.parts || [];
  645. const text = parts.find((part) => part.text)?.text || '';
  646. const imagePart = parts.find((part) => part.inlineData?.data || part.inline_data?.data);
  647. const inlineData = imagePart?.inlineData || imagePart?.inline_data;
  648. return {
  649. success: true,
  650. message: inlineData?.data ? `测试成功:已返回图片${text ? `,${text}` : ''}` : `测试成功:${text || '已返回生成结果'}`,
  651. image_data: inlineData?.data || '',
  652. mime_type: inlineData?.mimeType || inlineData?.mime_type || 'image/png',
  653. };
  654. }
  655. async function generateVolcengineImage(app, config, request) {
  656. const imageConfig = config.image_model || {};
  657. const requestId = createRequestId();
  658. const requestBody = {
  659. model: imageConfig.model_name,
  660. prompt: normalizeImagePrompt(request),
  661. size: request.size || '2048x2048',
  662. response_format: 'url',
  663. };
  664. let responseData = null;
  665. try {
  666. writeAiLog(app, config, {
  667. request_id: requestId,
  668. type: 'image-pending',
  669. provider: 'volcengine',
  670. url: `${trimBaseUrl(imageConfig.base_url || 'https://ark.cn-beijing.volces.com/api/v3')}/images/generations`,
  671. request: requestBody,
  672. status: 'pending',
  673. created_at: new Date().toISOString(),
  674. });
  675. trackAiRequest(app, config, { ai_request_type: 'image' });
  676. const response = await fetch(`${trimBaseUrl(imageConfig.base_url || 'https://ark.cn-beijing.volces.com/api/v3')}/images/generations`, {
  677. method: 'POST',
  678. headers: createHeaders(imageConfig.api_key),
  679. body: JSON.stringify(requestBody),
  680. });
  681. await ensureOk(response, '火山方舟生图失败');
  682. responseData = await response.json();
  683. const item = responseData.data?.[0] || {};
  684. const image = item.b64_json
  685. ? { buffer: Buffer.from(item.b64_json, 'base64'), mime_type: 'image/png' }
  686. : item.url
  687. ? await downloadImage(item.url)
  688. : null;
  689. if (!image) {
  690. throw new Error('火山方舟生图未返回图片数据');
  691. }
  692. const saved = saveGeneratedImage(app, image);
  693. writeAiLog(app, config, {
  694. request_id: requestId,
  695. type: 'image',
  696. provider: 'volcengine',
  697. request: requestBody,
  698. response: safeImageResponse(responseData),
  699. result: saved,
  700. created_at: new Date().toISOString(),
  701. });
  702. return { success: true, title: request.title || '', ...saved };
  703. } catch (error) {
  704. writeAiLog(app, config, {
  705. request_id: requestId,
  706. type: 'image-error',
  707. provider: 'volcengine',
  708. request: requestBody,
  709. response: responseData ? safeImageResponse(responseData) : null,
  710. error: error.message,
  711. created_at: new Date().toISOString(),
  712. });
  713. throw error;
  714. }
  715. }
  716. async function generateGoogleImage(app, config, request) {
  717. const imageConfig = config.image_model || {};
  718. const requestId = createRequestId();
  719. const requestBody = {
  720. contents: [
  721. {
  722. role: 'user',
  723. parts: [{ text: normalizeImagePrompt(request) }],
  724. },
  725. ],
  726. generationConfig: {
  727. responseModalities: ['TEXT', 'IMAGE'],
  728. },
  729. };
  730. let responseData = null;
  731. try {
  732. writeAiLog(app, config, {
  733. request_id: requestId,
  734. type: 'image-pending',
  735. provider: 'google-ai-studio',
  736. url: `${trimBaseUrl(imageConfig.base_url || 'https://generativelanguage.googleapis.com/v1beta')}/models/${encodeURIComponent(imageConfig.model_name)}:generateContent`,
  737. request: requestBody,
  738. status: 'pending',
  739. created_at: new Date().toISOString(),
  740. });
  741. trackAiRequest(app, config, { ai_request_type: 'image' });
  742. const response = await fetch(`${trimBaseUrl(imageConfig.base_url || 'https://generativelanguage.googleapis.com/v1beta')}/models/${encodeURIComponent(imageConfig.model_name)}:generateContent`, {
  743. method: 'POST',
  744. headers: {
  745. 'Content-Type': 'application/json',
  746. 'x-goog-api-key': imageConfig.api_key,
  747. },
  748. body: JSON.stringify(requestBody),
  749. });
  750. await ensureOk(response, 'Google AI Studio 生图失败');
  751. responseData = await response.json();
  752. const parts = responseData.candidates?.[0]?.content?.parts || [];
  753. const imagePart = parts.find((part) => part.inlineData?.data || part.inline_data?.data);
  754. const inlineData = imagePart?.inlineData || imagePart?.inline_data;
  755. if (!inlineData?.data) {
  756. throw new Error('Google AI Studio 生图未返回图片数据');
  757. }
  758. const saved = saveGeneratedImage(app, {
  759. buffer: Buffer.from(inlineData.data, 'base64'),
  760. mime_type: inlineData.mimeType || inlineData.mime_type || 'image/png',
  761. });
  762. writeAiLog(app, config, {
  763. request_id: requestId,
  764. type: 'image',
  765. provider: 'google-ai-studio',
  766. request: requestBody,
  767. response: safeImageResponse(responseData),
  768. result: saved,
  769. created_at: new Date().toISOString(),
  770. });
  771. return { success: true, title: request.title || '', ...saved };
  772. } catch (error) {
  773. writeAiLog(app, config, {
  774. request_id: requestId,
  775. type: 'image-error',
  776. provider: 'google-ai-studio',
  777. request: requestBody,
  778. response: responseData ? safeImageResponse(responseData) : null,
  779. error: error.message,
  780. created_at: new Date().toISOString(),
  781. });
  782. throw error;
  783. }
  784. }
  785. async function generateImageWithConfig(app, config, request) {
  786. const availability = getImageModelAvailability(config);
  787. if (!availability.available) {
  788. throw new Error(availability.message);
  789. }
  790. if (config.image_model?.provider === 'volcengine') {
  791. return generateVolcengineImage(app, config, request);
  792. }
  793. if (config.image_model?.provider === 'google-ai-studio') {
  794. return generateGoogleImage(app, config, request);
  795. }
  796. throw new Error('当前生图服务商暂不支持正文配图');
  797. }
  798. function createAiService({ app, configStore }) {
  799. return {
  800. async chat(request) {
  801. const config = configStore.load();
  802. return chatWithConfig(app, config, request);
  803. },
  804. async requestJson(request) {
  805. const config = configStore.load();
  806. return collectJsonResponseWithConfig(app, config, request);
  807. },
  808. async collectJsonResponse(request) {
  809. const config = configStore.load();
  810. return collectJsonResponseWithConfig(app, config, request);
  811. },
  812. async streamChat(request, onEvent) {
  813. const config = configStore.load();
  814. return streamChatWithConfig(app, config, request, onEvent);
  815. },
  816. async testImageModel(config) {
  817. const currentConfig = configStore.load();
  818. const trackedConfig = {
  819. ...config,
  820. analytics_client_id: config.analytics_client_id || currentConfig.analytics_client_id,
  821. analytics_created_at: config.analytics_created_at || currentConfig.analytics_created_at,
  822. };
  823. if (trackedConfig.image_model?.provider === 'volcengine') {
  824. return testVolcengineImageModel(app, trackedConfig);
  825. }
  826. if (trackedConfig.image_model?.provider === 'google-ai-studio') {
  827. return testGoogleImageModel(app, trackedConfig);
  828. }
  829. throw new Error('当前服务商暂不支持测试');
  830. },
  831. getImageModelAvailability() {
  832. return getImageModelAvailability(configStore.load());
  833. },
  834. isDeveloperMode() {
  835. return Boolean(configStore.load()?.developer_mode);
  836. },
  837. async generateImage(request) {
  838. const config = configStore.load();
  839. return generateImageWithConfig(app, config, request);
  840. },
  841. async listModels(configOverride) {
  842. const config = configOverride || configStore.load();
  843. if (!config.api_key) {
  844. return { success: false, message: '请先填写文本模型 API Key', models: [] };
  845. }
  846. const response = await fetch(`${trimBaseUrl(config.base_url)}/models`, {
  847. method: 'GET',
  848. headers: createHeaders(config.api_key),
  849. });
  850. await ensureOk(response, '获取模型列表失败');
  851. const data = await response.json();
  852. return {
  853. success: true,
  854. message: '模型列表已更新',
  855. models: Array.isArray(data.data) ? data.data.map((item) => item.id).filter(Boolean) : [],
  856. };
  857. },
  858. };
  859. }
  860. module.exports = {
  861. createAiService,
  862. };