Initial commit: 查词学英语 MVP

- 流式查询(SSE 实时展示生成过程)
- 三维度数据模型:history(查询)/ words(单词档案)/ roots(词根档案)
- 词根拆解与发音拼读训练(词根点击揭晓历史词例)
- 词义/同义例句跨查询累积,同义项自动合并
- 历史与词库分页、多维度搜索
- 模型:Qwen3.6-35B-A3B(硅基流动,可 LLM_MODEL 切换)
This commit is contained in:
2026-08-29 08:44:43 +08:00
commit 37b75c2d21
11 changed files with 3160 additions and 0 deletions
+260
View File
@@ -0,0 +1,260 @@
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;
}