Files
learnLanguage/llm.js
T
cat-shark 37b75c2d21 Initial commit: 查词学英语 MVP
- 流式查询(SSE 实时展示生成过程)
- 三维度数据模型:history(查询)/ words(单词档案)/ roots(词根档案)
- 词根拆解与发音拼读训练(词根点击揭晓历史词例)
- 词义/同义例句跨查询累积,同义项自动合并
- 历史与词库分页、多维度搜索
- 模型:Qwen3.6-35B-A3B(硅基流动,可 LLM_MODEL 切换)
2026-08-29 08:44:43 +08:00

261 lines
9.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}