const API_KEY = process.env.SILICONFLOW_API_KEY || 'sk-fyouaqowxseljgjwqrlkvbikczlnjtpdgswhvocoqfgvivds'; const API_URL = 'https://api.siliconflow.cn/v1/chat/completions'; const MODEL = process.env.LLM_MODEL || 'Qwen/Qwen3.6-35B-A3B'; // 流式请求的空闲超时:90 秒未收到任何新数据才判定连接卡死(总时长不限) const IDLE_TIMEOUT_MS = 90_000; function buildPrompt(sentence, words, existingWords = []) { const wordList = words.join('、'); // 已有词库记录:让模型把新句子的语境对齐到已有释义,避免同一义项重复建档 const existingInfo = existingWords .map( (e) => `单词 "${e.word}" 已有记录:\n释义(按编号):\n${e.senses .map((s, i) => ` ${i}. ${s}`) .join('\n')}${e.roots?.length ? `\n已有词根拆解:\n${e.roots.map((r) => ` ${r.part} [${r.type}] ${r.phonetic} — ${r.meaning}`).join('\n')}` : ''}` ) .join('\n\n'); return `你是一位面向中国英语学习者的词汇老师,擅长词源与拼读教学。 原句:${sentence} 请查询以下单词:${wordList} ${existingInfo ? '\n\n' + existingInfo : ''} 对每个单词返回: 1. 词根拆解(roots):把单词拆分为前缀/词根/后缀,每个部分给出: - part: 该部分原文(如 "ana-"、"lysis"、"-tion",前缀带尾连字符、后缀带头连字符) - type: 前缀 / 词根 / 后缀 - phonetic: 该部分单独的发音(美式 IPA,如 "/əˈnæ/") - meaning: 该部分的中文含义 发音规则:各部分发音连起来应接近整词发音,便于通过发音训练拼写。 若单词无明显词根词缀结构(多为短词),按音节拆分(type 填"音节"),音节同样标注发音。 2. 在原句语境中的释义(context_sense) 3. 该释义的 5 条例句(context_examples),例句必须使用相同词性、相近含义 4. 其他常见释义(other_senses),每个释义包含 5 条例句 5. 整个原句的中文翻译(sentence_translation) 若上方提供了某单词的已有释义记录: - context_sense_id:原句语境对应的已有释义编号(从 0 开始);对应不上任何已有释义时填 -1 - other_senses 只返回已有记录中不存在的新释义(文案应与已有释义明显不同);没有新释义时返回空数组 [] - 若提供了已有词根拆解,roots 原样沿用已有记录(保持同一单词的拆解稳定,便于跨单词对比词根);确有错误才修正 严格要求: - 所有例句必须使用简单、常见的词汇(不超过初中词汇水平),句子简短(10-18 个单词),让学习者容易理解 - 每条例句附中文翻译 - 音标使用美式音标(IPA) - 释义用中文解释 - context_sense 若对应已有编号,文案与已有记录保持一致 只输出 JSON,不要输出任何其他文字或代码块标记。JSON 格式: { "sentence_translation": "整个原句的中文翻译", "words": [ { "word": "单词", "phonetic": "/美式音标/", "roots": [ {"part": "ana-", "type": "前缀", "phonetic": "/əˈnæ/", "meaning": "向上、全面"} ], "context_sense_id": -1, "context_sense": "在原句语境中的中文释义(注明词性)", "context_examples": [ {"en": "English example sentence.", "zh": "中文翻译"} ], "other_senses": [ { "sense_id": -1, "sense": "另一种中文释义(注明词性)", "examples": [ {"en": "English example sentence.", "zh": "中文翻译"} ] } ] } ] }`; } async function callApiOnce(sentence, words, existingWords = []) { const res = await fetch(API_URL, { method: 'POST', headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ model: MODEL, messages: [ { role: 'system', content: '你是严谨的 JSON 输出机器,只输出合法 JSON,不要 markdown 代码块。', }, { role: 'user', content: buildPrompt(sentence, words, existingWords) }, ], temperature: 0.3, max_tokens: 8000, enable_thinking: false, }), signal: AbortSignal.timeout(120_000), // 网络级超时,避免请求挂起 }); if (!res.ok) { const text = await res.text().catch(() => ''); const err = new Error(`LLM API 错误 ${res.status}: ${text.slice(0, 300)}`); err.noRetry = true; // HTTP 业务错误(如鉴权/参数)不重试 throw err; } const data = await res.json(); const raw = data.choices?.[0]?.message?.content ?? ''; return { raw, parsed: parseResult(raw) }; } function describeError(err) { // undici 的网络错误真实原因在 err.cause 中(如 ECONNRESET / ETIMEDOUT / ENOTFOUND) const cause = err?.cause?.code || err?.cause?.message; let msg; if (err?.name === 'TimeoutError' || err?.name === 'AbortError') { msg = '请求超时或中断'; } else if (err?.abortReason?.message || err?.message) { msg = err.abortReason?.message || err.message; } else { msg = String(err); } if (cause && !msg.includes(String(cause))) msg += ` (原因: ${cause})`; // 返回新 Error:某些错误(如 DOMException)的 message 是只读 getter,不能直接赋值 const e = new Error(msg); e.noRetry = !!err?.noRetry; return e; } export async function queryWords(sentence, words, existingWords = []) { const maxAttempts = 3; let lastErr; for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { return await callApiOnce(sentence, words, existingWords); } catch (err) { lastErr = err; if (err.noRetry || attempt === maxAttempts) { throw describeError(err); } // 仅对网络类瞬时错误重试,指数退避 const waitMs = 1500 * attempt; console.warn(`网络错误(第 ${attempt} 次),${waitMs}ms 后重试: ${describeError(err)}`); await new Promise((r) => setTimeout(r, waitMs)); } } throw lastErr; } // 流式查询:通过 onDelta 回调实时输出增量文本 export async function queryWordsStream(sentence, words, onDelta, existingWords = []) { const maxAttempts = 3; let lastErr; for (let attempt = 1; attempt <= maxAttempts; attempt++) { let emitted = false; // 空闲超时:只要数据持续到达就不限总时长;90 秒无新数据才判定卡死 const controller = new AbortController(); let idleTimer; const resetIdle = () => { clearTimeout(idleTimer); idleTimer = setTimeout( () => controller.abort(new Error('90 秒未收到新数据,判定为连接卡死')), IDLE_TIMEOUT_MS ); }; resetIdle(); try { const res = await fetch(API_URL, { method: 'POST', headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ model: MODEL, messages: [ { role: 'system', content: '你是严谨的 JSON 输出机器,只输出合法 JSON,不要 markdown 代码块。', }, { role: 'user', content: buildPrompt(sentence, words, existingWords) }, ], temperature: 0.3, max_tokens: 8000, enable_thinking: false, stream: true, }), signal: controller.signal, }); if (!res.ok) { const text = await res.text().catch(() => ''); const err = new Error(`LLM API 错误 ${res.status}: ${text.slice(0, 300)}`); err.noRetry = true; throw err; } const decoder = new TextDecoder(); let raw = ''; let buf = ''; for await (const chunk of res.body) { resetIdle(); // 数据持续到达就续期空闲计时器 buf += decoder.decode(chunk, { stream: true }); const lines = buf.split('\n'); buf = lines.pop(); // 最后一段可能不完整,留到下一轮 for (const line of lines) { const t = line.trim(); if (!t.startsWith('data:')) continue; const payload = t.slice(5).trim(); if (payload === '[DONE]') continue; try { const j = JSON.parse(payload); const delta = j.choices?.[0]?.delta?.content || ''; if (delta) { emitted = true; // 已输出过增量,失败后不能重试(避免内容重复) raw += delta; onDelta(delta); } } catch { /* 忽略不完整行 */ } } } clearTimeout(idleTimer); return { raw, parsed: parseResult(raw) }; } catch (err) { clearTimeout(idleTimer); lastErr = err; // 已经向用户输出过内容后失败,重试会导致文本重复,只能报错 if (err.noRetry || emitted || attempt === maxAttempts) { throw describeError(err); } const waitMs = 1500 * attempt; console.warn(`网络错误(第 ${attempt} 次),${waitMs}ms 后重试: ${describeError(err)}`); await new Promise((r) => setTimeout(r, waitMs)); } } throw lastErr; } export function parseResult(raw) { let text = raw.trim(); // 去掉可能存在的 markdown 代码块包裹 const m = text.match(/```(?:json)?\s*([\s\S]*?)```/); if (m) text = m[1].trim(); // 截取第一个 { 到最后一个 } const start = text.indexOf('{'); const end = text.lastIndexOf('}'); if (start === -1 || end === -1) throw new Error('LLM 返回中未找到 JSON'); const parsed = JSON.parse(text.slice(start, end + 1)); if (!Array.isArray(parsed.words) || parsed.words.length === 0) { throw new Error('LLM 返回的 JSON 缺少 words 数组'); } return parsed; }