- 流式查询(SSE 实时展示生成过程) - 三维度数据模型:history(查询)/ words(单词档案)/ roots(词根档案) - 词根拆解与发音拼读训练(词根点击揭晓历史词例) - 词义/同义例句跨查询累积,同义项自动合并 - 历史与词库分页、多维度搜索 - 模型:Qwen3.6-35B-A3B(硅基流动,可 LLM_MODEL 切换)
261 lines
9.7 KiB
JavaScript
261 lines
9.7 KiB
JavaScript
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;
|
||
}
|