From cc525ce1e958c25253305d473b1db9a38d106c9e Mon Sep 17 00:00:00 2001 From: mike Date: Mon, 27 Jul 2026 10:10:19 +0800 Subject: [PATCH] Update server.js via upload script - 2026-07-27 10:09:57 --- server.js | 77 +++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 63 insertions(+), 14 deletions(-) diff --git a/server.js b/server.js index bf83198..8f192b6 100644 --- a/server.js +++ b/server.js @@ -175,16 +175,56 @@ async function callDeepSeekWithRetry(apiKey, messages, opts, retries = 1) { } // 从DeepSeek返回的内容里提取JSON——模型有时会用```json代码块包一层,或者前后带几句解释文字, -// 这里做宽松解析:优先找代码块,找不到就找第一个 { 或 [ 到最后一个 } 或 ] 之间的内容。 +// 这里做宽松解析:优先找代码块,找不到就找第一个 { 或 [ 开始的内容。 +// 返回 { data, truncated }:如果完整JSON解析失败(常见原因是撞到了max_tokens,数组还没写完 +// 就被截断了),会尝试抢救出前面已经完整生成的那些顶层对象,丢弃最后一个写了一半的,这样至少 +// 能拿到部分结果,而不是整批因为最后一个字符缺失就全部作废。 function extractJson(content) { const fenced = content.match(/```(?:json)?\s*([\s\S]*?)```/i); const raw = fenced ? fenced[1] : content; const start = raw.search(/[\[{]/); - const endBrace = raw.lastIndexOf('}'); - const endBracket = raw.lastIndexOf(']'); - const end = Math.max(endBrace, endBracket); - if (start === -1 || end === -1 || end < start) throw new Error('AI返回内容里没有找到JSON'); - return JSON.parse(raw.slice(start, end + 1)); + if (start === -1) throw new Error('AI返回内容里没有找到JSON'); + const trimmed = raw.slice(start); + + try { + return { data: JSON.parse(trimmed), truncated: false }; + } catch (e) { + const salvaged = salvageTruncatedJsonArray(trimmed); + if (salvaged !== null) return { data: salvaged, truncated: true }; + throw new Error(`AI返回的内容不是完整的JSON,解析失败:${e.message}`); + } +} + +// 只处理"最外层是数组"的截断抢救:逐字符扫描(跳过字符串内部的引号/转义),每次遇到顶层元素 +// 完整闭合(深度归零)就记一下位置,最后从抢救到的最后一个完整位置那里把数组截断重新拼上 ]。 +function salvageTruncatedJsonArray(text) { + const t = text.trim(); + if (!t.startsWith('[')) return null; + let depth = 0; + let lastCompleteEnd = -1; + let inString = false; + let escapeNext = false; + for (let i = 1; i < t.length; i++) { + const ch = t[i]; + if (inString) { + if (escapeNext) escapeNext = false; + else if (ch === '\\') escapeNext = true; + else if (ch === '"') inString = false; + continue; + } + if (ch === '"') { inString = true; continue; } + if (ch === '{' || ch === '[') depth++; + else if (ch === '}' || ch === ']') { + depth--; + if (depth === 0 && ch === '}') lastCompleteEnd = i; // 顶层数组里某个对象完整闭合了 + } + } + if (lastCompleteEnd === -1) return null; + try { + return JSON.parse(t.slice(0, lastCompleteEnd + 1) + ']'); + } catch (e) { + return null; + } } /* ------------------------------ data file ------------------------------ */ @@ -462,15 +502,24 @@ OCR识别可能有少量错字或格式错乱,请结合上下文合理判断 "name": "型号,比如 POE-532E", "brand": "品牌中文名,从厂家名称/logo文字判断;看不出就填空字符串", "category": "产品大类中文名,从这些里选最接近的:UPS不间断电源 / 光伏逆变器 / 储能电池;不确定就填 UPS不间断电源", - "type": "细分品类中文名。如果是这种带USB/DC/POE多路直流输出口的小型UPS,填 迷你UPS;其他常见值:机架式UPS、高频塔式UPS、工频UPS、模块化UPS、在线互动式UPS、离线式UPS;不确定就留空字符串", + "type": "细分品类中文名。如果是这种带USB/DC/POE多路直流输出口的小型UPS,填 迷你UPS;如果是没有LCD/LED显示、简单的家用小型UPS(型号通常带Offline/后备式字样),填 离线式UPS;如果标注了Line-Interactive/互动式,填 在线互动式UPS;其他常见值:机架式UPS、高频塔式UPS、工频UPS、模块化UPS;不确定就留空字符串", "machineVariant": "标机 或 长机:标准电池容量版本填标机,型号带L后缀或电池容量明显更大的加长版填长机,不确定就填标机", - "powerOrCapacity": "非UPS大类的功率/容量;如果是迷你UPS,这里填输出功率(如 17W);其他情况不确定就填空字符串", - "voltage": "如果是迷你UPS,这里填输入电压,如 100~240Vac/50-60Hz;其他情况填常规电压参数,不确定填空字符串", + "powerOrCapacity": "非UPS大类的功率/容量;如果是迷你UPS,这里填输出功率(如 17W);如果是离线式UPS/在线互动式UPS,这里填Capacity/容量(如 400VA/240W);其他情况不确定就填空字符串", + "voltage": "如果是迷你UPS,这里填输入电压,如 100~240Vac/50-60Hz;如果是离线式UPS/在线互动式UPS,这个字段不用填(用下面的inputVoltage/outputVoltage代替);其他情况填常规电压参数,不确定填空字符串", "ratedPower": "", "ratedPowerUnit": "VA", "powerFactor": "", "outputPortCount": "迷你UPS专属:这个型号有几路输出口,数字字符串,如 4,不适用就填空字符串", "outputPortTypes": "迷你UPS专属:输出口类型数组,只能用这些token:USB、DC、DC1、DC2、DC3、POE、Type-C、DC interface、POE interface;不适用就填空数组", "outputPorts": "迷你UPS专属:数组,每项 {\\"type\\":\\"USB\\",\\"voltage\\":\\"5Vdc\\",\\"current\\":\\"3.0A\\"},和outputPortTypes一一对应;不适用就填空数组", - "batterySpec": "电池数量和容量,如 2200mAh*4,不确定填空字符串", + "inputVoltage": "离线式UPS/在线互动式UPS专属:Input Voltage/输入电压,如 110V/120Vac or 220V/230Vac;不适用就填空字符串", + "inputVoltageRange": "离线式UPS/在线互动式UPS专属:Voltage range/输入电压范围,如 162-268Vac/170-280Vac;不适用就填空字符串", + "inputFrequency": "离线式UPS/在线互动式UPS专属:Frequency/输入频率,如 50/60Hz(Auto sensing);不适用就填空字符串", + "outputVoltage": "离线式UPS/在线互动式UPS专属:Output Voltage/输出电压,如 110V/120Vac or 220V/230Vac;不适用就填空字符串", + "outputVoltageRange": "离线式UPS/在线互动式UPS专属:输出电压范围,如 ±10%;不适用就填空字符串", + "transferTime": "离线式UPS/在线互动式UPS专属:Transfer time/转换时间,如 Typical 2-8ms, 13ms max;不适用就填空字符串", + "waveform": "离线式UPS/在线互动式UPS专属:Wave form/波形,如 Simulated Sine Wave 或 Sine Wave;不适用就填空字符串", + "batteryVoltage": "离线式UPS/在线互动式UPS专属:Battery Voltage/电池电压,如 12V 或 24V;不适用就填空字符串", + "chargeCurrent": "离线式UPS/在线互动式UPS专属:Charge current/充电电流,如 1A;不适用就填空字符串", + "batterySpec": "电池数量和容量/电池型号和数量,如 2200mAh*4 或 12V4.5AH*1,不确定填空字符串", "dimensions": "尺寸(mm),如 175*105*30,不确定填空字符串", "netWeight": "净重(kg),如 0.4,不确定填空字符串", "notes": "其他有用但上面字段装不下的信息,一两句话,没有就填空字符串" @@ -531,7 +580,7 @@ app.post('/api/ai/parse-catalog', requireAuth, aiLimiter, (req, res) => { { role: 'system', content: CATALOG_PARSE_SYSTEM_PROMPT }, { role: 'user', content: userContent }, ], { maxTokens: 8000, temperature: 0.2 }); - const products = extractJson(content); + const { data: products, truncated } = extractJson(content); if (!Array.isArray(products)) throw new Error('AI没有返回预期的数组格式,请重试'); // 双保险:即使AI没完全听话,这里直接强制覆盖,保证和用户预选的一致 const finalProducts = products.map((p) => ({ @@ -540,7 +589,7 @@ app.post('/api/ai/parse-catalog', requireAuth, aiLimiter, (req, res) => { brand: hintBrand || p.brand, type: hintType || p.type, })); - res.json({ products: finalProducts, ocrTextLength: text.length }); + res.json({ products: finalProducts, ocrTextLength: text.length, truncated }); } catch (e) { const hint = /empty response/i.test(e.message) ? '(重试后仍然失败,可能是一次上传的文件太多导致AI输出内容超长;建议减少单次上传的数量,比如分成2-3批,每批3-5张)' @@ -573,11 +622,11 @@ app.post('/api/ai/translate-terms', requireAuth, aiLimiter, async (req, res) => const userPrompt = JSON.stringify(terms); try { - const content = await callDeepSeek(cfg.apiKey, [ + const content = await callDeepSeekWithRetry(cfg.apiKey, [ { role: 'system', content: systemPrompt }, { role: 'user', content: userPrompt }, ], { maxTokens: 2000, temperature: 0.3 }); - const translations = extractJson(content); + const { data: translations } = extractJson(content); res.json({ translations }); } catch (e) { res.status(502).json({ error: e.message });