From 37b75c2d210cd77bd189f5c9cbae5560eecbdac5 Mon Sep 17 00:00:00 2001 From: cat_shark <1716967236@qq.com> Date: Sat, 29 Aug 2026 08:44:43 +0800 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20=E6=9F=A5=E8=AF=8D=E5=AD=A6?= =?UTF-8?q?=E8=8B=B1=E8=AF=AD=20MVP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 流式查询(SSE 实时展示生成过程) - 三维度数据模型:history(查询)/ words(单词档案)/ roots(词根档案) - 词根拆解与发音拼读训练(词根点击揭晓历史词例) - 词义/同义例句跨查询累积,同义项自动合并 - 历史与词库分页、多维度搜索 - 模型:Qwen3.6-35B-A3B(硅基流动,可 LLM_MODEL 切换) --- .gitignore | 5 + README.md | 34 ++ SCHEMA.md | 173 +++++++++ db.js | 383 +++++++++++++++++++ llm.js | 260 +++++++++++++ package-lock.json | 908 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 18 + public/app.js | 807 ++++++++++++++++++++++++++++++++++++++++ public/index.html | 58 +++ public/style.css | 344 ++++++++++++++++++ server.js | 170 +++++++++ 11 files changed, 3160 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 SCHEMA.md create mode 100644 db.js create mode 100644 llm.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 public/app.js create mode 100644 public/index.html create mode 100644 public/style.css create mode 100644 server.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c6dbce5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +data/ +npm-debug.log* +.DS_Store +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..90d12eb --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +# 查词学英语(MVP) + +粘贴英语句子、查询其中单词,AI 生成简单易懂的例句帮助学习。基于硅基流动 DeepSeek 模型。 + +## 启动 + +```bash +npm install +npm start # 打开 http://localhost:3000 +``` + +API Key 默认内置在 `llm.js`,可通过环境变量覆盖: + +```bash +SILICONFLOW_API_KEY=sk-xxx npm start +``` + +## 功能 + +- **查询**:粘贴句子 → 添加要查的单词(最多 5 个)→ 调用 LLM 返回: + - 单词的美式音标 + 点击 🔊 朗读(浏览器 TTS) + - 原句语境释义 + 5 条同义例句 + - 其他释义各 5 条例句(例句词汇控制在简单水平,附中文翻译) +- **历史记录**:所有查询自动保存(SQLite,`data/app.db`),可查看、**编辑任意内容**(原句/音标/释义/例句,支持增删例句和释义)、删除。 + +## 技术 + +- 后端:Node.js + Express + better-sqlite3 +- 前端:原生 HTML/CSS/JS(无构建步骤) +- LLM:硅基流动 默认 `Qwen/Qwen3.6-35B-A3B`(已关闭 thinking),可用 `LLM_MODEL` 环境变量切换 + +## 数据库结构 + +详见 [SCHEMA.md](SCHEMA.md)(history / words / roots 三张表及合并逻辑)。 diff --git a/SCHEMA.md b/SCHEMA.md new file mode 100644 index 0000000..257997c --- /dev/null +++ b/SCHEMA.md @@ -0,0 +1,173 @@ +# 数据库表结构文档 + +> 数据库:SQLite(`data/app.db`,WAL 模式) +> 最后更新:2026-08-28(词根维度 + 搜索功能版本) +> 表结构维护方式:启动时 `CREATE TABLE IF NOT EXISTS` 自动建表 + 代码内增量迁移(`ALTER TABLE`),无需手工执行 SQL + +## 总览 + +系统按三个维度存储学习数据: + +| 表 | 维度 | 说明 | +|---|---|---| +| `history` | 查询次数 | 每次查询的原句与完整结果快照 | +| `words` | 单词 | 跨查询累积的单词档案(释义、词根拆解、原句记录) | +| `roots` | 词根 | 跨单词累积的词根档案(哪些学过的词包含它、各词中体现的含义) | + +### 维度间关系 + +``` +history (一次查询) + │ 查询完成后自动触发 + ▼ +words (每词一行) ──写入时同步──▶ roots (每词根一行) +``` + +- `words.word` 唯一;`roots.root`(规范化键)唯一 +- 同一单词在 `roots` 下仅保留一条映射(重新拆解时旧映射自动摘除) + +--- + +## 1. history — 查询历史(按次记录) + +| 列 | 类型 | 约束 | 说明 | +|---|---|---|---| +| `id` | INTEGER | PK, AUTOINCREMENT | 记录 ID | +| `sentence` | TEXT | NOT NULL | 用户粘贴的原句 | +| `words` | TEXT | NOT NULL | 查询的单词列表,JSON 数组:`["analysis", "analyze"]` | +| `result` | TEXT | NOT NULL | LLM 结构化结果快照,见下方 JSON 结构 | +| `raw_result` | TEXT | 可空 | LLM 原始输出(排查用) | +| `created_at` / `updated_at` | TEXT | NOT NULL | 本地时间,写入/最后编辑时间 | + +### `result` JSON 结构 + +```json +{ + "sentence_translation": "整个原句的中文翻译", + "words": [ + { + "word": "analysis", + "phonetic": "/əˈnæləsɪs/", + "roots": [ + { "part": "ana-", "type": "前缀", "phonetic": "/əˈnæ/", "meaning": "向上、全面" } + ], + "context_sense_id": 0, + "context_sense": "n. 分析;解析", + "context_examples": [ + { "en": "English sentence.", "zh": "中文翻译" } + ], + "other_senses": [ + { + "sense_id": -1, + "sense": "n. 化验", + "examples": [ { "en": "...", "zh": "..." } ] + } + ] + } + ] +} +``` + +- `context_sense_id`:本次语境命中的词库已有释义编号(0 起),`-1` 表示新释义 +- `roots[].type`:前缀 / 词根 / 后缀 / 音节 +- 已编辑的历史记录中 `roots` 支持手工维护(编辑界面按 `部分 | 类型 | 音标 | 含义` 行格式) + +--- + +## 2. words — 词库(按单词维度,跨查询累积) + +| 列 | 类型 | 约束 | 说明 | +|---|---|---|---| +| `id` | INTEGER | PK, AUTOINCREMENT | | +| `word` | TEXT | NOT NULL, **UNIQUE**(自动索引) | 单词小写形式 | +| `phonetic` | TEXT | | 整词美式音标 | +| `roots` | TEXT | NOT NULL, 默认 `'[]'` | 词根拆解,JSON:`[{part, type, phonetic, meaning}]` | +| `senses` | TEXT | NOT NULL, 默认 `'[]'` | 释义累积,见下方 | +| `root_keys` | TEXT | NOT NULL, 默认 `'[]'` | 该词当前映射到的词根规范化键,如 `["ana","naly","sis"]` | +| `search_text` | TEXT | NOT NULL, 默认 `''` | 检索文本(见下) | +| `created_at` / `updated_at` | TEXT | NOT NULL | | + +### `senses` JSON 结构 + +```json +[ + { + "sense": "n. 分析;解析", + "examples": [ + { "en": "原句本身", "zh": "原句翻译", "from_sentence": true }, + { "en": "AI 生成的同义例句", "zh": "翻译" } + ] + } +] +``` + +- 同义义项合并规则:新查询语境命中已有义项(`context_sense_id`)→ 追加"原句 + 本次生成的同义例句";新语境 → 新增义项 +- 例句按英文原文去重;`from_sentence: true` 标记来自真实原句(前端以 📌 展示) + +### `search_text`(检索列) + +内容 = `单词 + 各义项标题 + 词根 part/规范化键/含义`,统一小写。 +**刻意不含例句正文**,避免例句里的无关词(如 "company" 含 "any")干扰检索。由 `buildWordSearchText()` 生成,在写入与启动迁移时刷新。 + +--- + +## 3. roots — 词根库(按词根维度,跨单词累积) + +| 列 | 类型 | 约束 | 说明 | +|---|---|---|---| +| `id` | INTEGER | PK, AUTOINCREMENT | | +| `root` | TEXT | NOT NULL, **UNIQUE**(自动索引) | 规范化键:去连字符/非字母、小写,如 `lysis` | +| `display` | TEXT | NOT NULL | 展示形(保留连字符),如 `-tion`,以最近出现为准 | +| `type` | TEXT | | 前缀 / 词根 / 后缀 / 音节 | +| `phonetic` | TEXT | | 词根发音(IPA) | +| `words` | TEXT | NOT NULL, 默认 `'[]'` | 包含该词根的已学词,见下方 | +| `created_at` / `updated_at` | TEXT | NOT NULL | | + +### `words` JSON 结构 + +```json +[ + { + "word": "analysis", + "phonetic": "/əˈnæləsɪs/", + "meaning": "松开、解开", + "word_senses": ["n. 分析;解析", "n. 分解;剖析"] + } +] +``` + +- `meaning`:该词根**在此词中**体现的含义——刻意不做全局统一,多个词的含义变体并列展示,供学习者自行抽象 +- `word_senses`:该单词自身的释义列表(词根面板点击单词揭晓时一并展示) +- 同一单词重复查询时条目整体覆盖;单词重新拆解后,从不再包含它的旧词根下自动摘除(词根下无词时整行删除) + +--- + +## 4. 合并/维护逻辑(写入路径) + +查询完成(流式返回 done 前)按顺序执行: + +1. `upsertWordData(wordResult, sentence, sentenceTranslation)` + - 释义合并:`context_sense_id` 命中 → 并入原句 + 同义例句;否则新增义项 + - `other_senses`:文案相同 → 例句并入已有义项;否则新增 + - 词根同步:为 `roots` 中每个部分调用 `upsertRootData`,并按 `root_keys` 清理旧映射 + - 刷新 `search_text` +2. `saveRecord(...)` 写入 `history` + +## 5. 启动时自动迁移 + +- `words.root_keys`、`words.search_text`:缺失时 `ALTER TABLE` 补列 +- `search_text` 每次启动对全表回填 +- 历史版本说明:v1 仅 `history` 表;"按单词维度"版本引入 `words`(破坏性变更,经确认清除旧数据);当前版本追加 `roots` + +## 6. 相关 API + +| 方法 | 路径 | 说明 | +|---|---|---| +| GET | `/api/history?page=&size=&q=` | 历史分页列表,`q` 搜原句/单词 | +| GET | `/api/history/:id` | 历史详情 | +| PUT | `/api/history/:id` | 编辑保存 | +| DELETE | `/api/history/:id` | 删除 | +| GET | `/api/words?page=&size=&q=` | 词库分页列表,`q` 搜单词/释义/词根(不含例句) | +| GET | `/api/words/:word` | 单词档案 | +| GET | `/api/roots/:root` | 词根档案(键为规范化形式) | +| POST | `/api/query/stream` | 流式查询(写入全部三表) | diff --git a/db.js b/db.js new file mode 100644 index 0000000..b8f14b0 --- /dev/null +++ b/db.js @@ -0,0 +1,383 @@ +import Database from 'better-sqlite3'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const dataDir = path.join(__dirname, 'data'); +if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true }); + +const db = new Database(path.join(dataDir, 'app.db')); +db.pragma('journal_mode = WAL'); + +// 破坏性变更:重建表结构(旧数据已清除) +db.exec(` +CREATE TABLE IF NOT EXISTS history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sentence TEXT NOT NULL, + words TEXT NOT NULL, -- 查询的单词,JSON 数组 + result TEXT NOT NULL, -- LLM 结构化结果,JSON(含词根拆解) + raw_result TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')), + updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')) +); + +-- 词库(按单词维度维护,跨句子累积) +CREATE TABLE IF NOT EXISTS words ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + word TEXT NOT NULL UNIQUE, + phonetic TEXT, -- 整词音标 + roots TEXT NOT NULL DEFAULT '[]', + -- 词根拆解: [{part, type:前缀|词根|后缀|音节, phonetic, meaning}] + senses TEXT NOT NULL DEFAULT '[]', + -- 释义(同义项合并): [{sense, examples: [{en, zh, from_sentence}]}] + created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')), + updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')) +); + +-- 词根库(按词根维度维护,跨单词累积) +CREATE TABLE IF NOT EXISTS roots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + root TEXT NOT NULL UNIQUE, -- 规范化键:去连字符/空格、小写,如 'lysis' + display TEXT NOT NULL, -- 展示形(如 "lysis"、"-tion"),以最近出现为准 + type TEXT, -- 前缀 / 词根 / 后缀 / 音节 + phonetic TEXT, -- 发音 + words TEXT NOT NULL DEFAULT '[]', + -- 包含该词根的词: [{word, phonetic, meaning}] + -- meaning = 该词中体现的词根含义(允许不同词里有差异,展示时并列,帮助大脑抽象) + created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')), + updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')) +); +`); + +// 迁移:words 表补充 root_keys、search_text 列 +// search_text = 单词 + 义项标题 + 词根拆解(不含例句正文,避免例句里的无关词干扰检索) +function buildWordSearchText(word, senses, roots) { + return [ + word, + ...(senses || []).map((s) => s.sense), + ...(roots || []).flatMap((r) => [r.part, normalizeRootKey(r.part), r.meaning]), + ] + .filter(Boolean) + .join(' ') + .toLowerCase(); +} +{ + const cols = db.prepare("PRAGMA table_info(words)").all().map((c) => c.name); + if (!cols.includes('root_keys')) { + db.exec("ALTER TABLE words ADD COLUMN root_keys TEXT NOT NULL DEFAULT '[]'"); + } + if (!cols.includes('search_text')) { + db.exec("ALTER TABLE words ADD COLUMN search_text TEXT NOT NULL DEFAULT ''"); + } + // 回填历史数据的 search_text + const rows = db.prepare('SELECT word, senses, roots FROM words').all(); + const upd = db.prepare('UPDATE words SET search_text = ? WHERE word = ?'); + for (const r of rows) { + upd.run( + buildWordSearchText( + r.word, + JSON.parse(r.senses), + JSON.parse(r.roots) + ), + r.word + ); + } +} + +const stmts = { + // ---- history ---- + insert: db.prepare( + 'INSERT INTO history (sentence, words, result, raw_result) VALUES (?, ?, ?, ?)' + ), + get: db.prepare('SELECT * FROM history WHERE id = ?'), + update: db.prepare( + `UPDATE history SET sentence = @sentence, words = @words, result = @result, + updated_at = datetime('now', 'localtime') WHERE id = @id` + ), + del: db.prepare('DELETE FROM history WHERE id = ?'), + + // ---- words(词库)---- + getWord: db.prepare('SELECT * FROM words WHERE word = ?'), + insertWord: db.prepare( + `INSERT INTO words (word, phonetic, roots, senses, root_keys, search_text) VALUES (@word, @phonetic, @roots, @senses, @root_keys, @search_text)` + ), + updateWord: db.prepare( + `UPDATE words SET phonetic = @phonetic, roots = @roots, senses = @senses, + root_keys = @root_keys, search_text = @search_text, + updated_at = datetime('now', 'localtime') WHERE id = @id` + ), + + // ---- roots(词根库)---- + getRoot: db.prepare('SELECT * FROM roots WHERE root = ?'), + insertRoot: db.prepare( + `INSERT INTO roots (root, display, type, phonetic, words) VALUES (@root, @display, @type, @phonetic, @words)` + ), + updateRoot: db.prepare( + `UPDATE roots SET display = @display, type = @type, phonetic = @phonetic, words = @words, + updated_at = datetime('now', 'localtime') WHERE id = @id` + ), + delRoot: db.prepare('DELETE FROM roots WHERE id = ?'), +}; + +/* ---------- history ---------- */ + +export function saveRecord({ sentence, words, result, raw_result }) { + const info = stmts.insert.run( + sentence, + JSON.stringify(words), + JSON.stringify(result), + raw_result ?? null + ); + return getRecord(info.lastInsertRowid); +} + +/* 搜索:LIKE 通配符转义(% _ 视为普通字符) */ +function likeTerm(q) { + return '%' + String(q || '').replace(/[%_\\]/g, '\\$&') + '%'; +} + +function pagedQuery({ baseCols, from, orderBy = 'id DESC', whereCols, page, size, q, postProcess }) { + const hasSearch = q && String(q).trim(); + const where = hasSearch + ? 'WHERE ' + whereCols.map((c) => `${c} LIKE @q ESCAPE '\\'`).join(' OR ') + : ''; + const total = db + .prepare(`SELECT COUNT(*) AS n FROM ${from} ${where}`) + .get(hasSearch ? { q: likeTerm(q) } : {}).n; + const items = db + .prepare( + `SELECT ${baseCols} FROM ${from} ${where} ORDER BY ${orderBy} LIMIT @limit OFFSET @offset` + ) + .all({ + ...(hasSearch ? { q: likeTerm(q) } : {}), + limit: size, + offset: (page - 1) * size, + }) + .map(postProcess); + return { + items, + total, + q: hasSearch ? String(q).trim() : '', + page, + size, + total_pages: Math.max(1, Math.ceil(total / size)), + }; +} + +export function listRecords(page = 1, size = 10, q = '') { + return pagedQuery({ + baseCols: 'id, sentence, words, created_at, updated_at', + from: 'history', + whereCols: ['sentence', 'words'], // 匹配原句或查询的单词 + page, + size, + q, + postProcess: (r) => ({ ...r, words: JSON.parse(r.words) }), + }); +} + +export function getRecord(id) { + const r = stmts.get.get(id); + if (!r) return null; + return { ...r, words: JSON.parse(r.words), result: JSON.parse(r.result) }; +} + +export function updateRecord(id, { sentence, words, result }) { + const info = stmts.update.run({ + id, + sentence, + words: JSON.stringify(words), + result: JSON.stringify(result), + }); + if (info.changes === 0) return null; + return getRecord(id); +} + +export function deleteRecord(id) { + const info = stmts.del.run(id); + return info.changes > 0; +} + +/* ---------- words(词库)---------- */ + +export function getWord(word) { + const r = stmts.getWord.get(word); + if (!r) return null; + return { ...r, roots: JSON.parse(r.roots), senses: JSON.parse(r.senses) }; +} + +export function listWords(page = 1, size = 10, q = '') { + return pagedQuery({ + baseCols: 'word, phonetic, roots, senses, created_at, updated_at', + from: 'words', + orderBy: 'updated_at DESC', + whereCols: ['search_text'], // 匹配单词、释义或词根(不含例句正文) + page, + size, + q, + postProcess: (r) => { + const senses = JSON.parse(r.senses); + const occurrences = senses.reduce( + (n, s) => n + (s.examples || []).filter((e) => e.from_sentence).length, + 0 + ); + return { + ...r, + roots: JSON.parse(r.roots), + senses, + sense_count: senses.length, + occurrence_count: occurrences, + }; + }, + }); +} + +/** + * 按"单词维度"合并查询结果: + * - 词根拆解:首次创建时写入,之后保留(除非原来为空) + * - 原句语境对应已有释义(context_sense_id 有效)→ 该释义下追加"来自原句"的记录 + * - 语境为新释义 → 新增义项;其他新释义也并入(按释义文案去重) + */ +export function upsertWordData(w, sentence, sentenceTranslation) { + if (!w?.word) return null; + const existing = stmts.getWord.get(w.word); + let senses = existing ? JSON.parse(existing.senses) : []; + // 原句记录 + const occ = { + en: sentence, + zh: sentenceTranslation || '', + from_sentence: true, + }; + + // 查询时生成的同义例句:与原句一起并入词库 + const generated = (w.context_examples || []) + .map((e) => ({ en: (e.en || '').trim(), zh: (e.zh || '').trim() })) + .filter((e) => e.en); + + // 按英文句子去重(原句与例句可重复,避免同一句重复堆积) + const addUnique = (list, ex) => { + if (!ex.en) return; + if (!list.some((x) => x.en === ex.en)) list.push(ex); + }; + + const ctxId = w.context_sense_id; + if ( + typeof ctxId === 'number' && + Number.isInteger(ctxId) && + ctxId >= 0 && + ctxId < senses.length + ) { + // 语境对应已有释义:原句 + 本次生成的同义例句一起并入 + const s = senses[ctxId]; + s.examples = s.examples || []; + addUnique(s.examples, occ); + generated.forEach((ex) => addUnique(s.examples, ex)); + if (w.context_sense) s.sense = w.context_sense; // 释义文案以最新为准 + } else if (w.context_sense) { + senses.push({ sense: w.context_sense, examples: [occ, ...generated] }); + } + + // 并入其他释义:已有同文案释义 → 例句并入;否则新增释义 + for (const os of w.other_senses || []) { + if (!os?.sense) continue; + const osExamples = (os.examples || []) + .map((e) => ({ en: (e.en || '').trim(), zh: (e.zh || '').trim() })) + .filter((e) => e.en); + const matched = senses.find((s) => s.sense === os.sense); + if (matched) { + matched.examples = matched.examples || []; + osExamples.forEach((ex) => addUnique(matched.examples, ex)); + } else { + senses.push({ sense: os.sense, examples: osExamples }); + } + } + + // 词根拆解:优先用最新结果;新词必写,旧词仅在原来为空时补写 + + // 词根拆解:优先用最新结果;新词必写,旧词仅在原来为空时补写 + const oldRoots = existing ? JSON.parse(existing.roots) : []; + const roots = w.roots?.length ? w.roots : oldRoots; + + // ---- 词根维度同步 ---- + const newKeys = [...new Set(roots.map((r) => normalizeRootKey(r.part)).filter(Boolean))]; + if (existing) { + // 单词重新拆解后,从不再包含的旧词根下摘除该词,避免幽灵条目 + const oldKeys = JSON.parse(existing.root_keys || '[]'); + for (const k of oldKeys) { + if (newKeys.includes(k)) continue; + const r = stmts.getRoot.get(k); + if (!r) continue; + const remaining = JSON.parse(r.words).filter((e) => e.word !== w.word); + if (remaining.length === 0) stmts.delRoot.run(r.id); + else stmts.updateRoot.run({ ...r, words: JSON.stringify(remaining) }); + } + } + for (const r of roots) { + // 传入该词的全部释义,词根面板点击单词时一并揭晓 + upsertRootData(r, w.word, w.phonetic || '', senses.map((s) => s.sense)); + } + + const row = { + word: w.word, + phonetic: w.phonetic || existing?.phonetic || '', + roots: JSON.stringify(roots), + senses: JSON.stringify(senses), + root_keys: JSON.stringify(newKeys), + search_text: buildWordSearchText(w.word, senses, roots), + id: existing?.id, + }; + if (existing) stmts.updateWord.run(row); + else stmts.insertWord.run(row); + return getWord(w.word); +} + +/* ---------- roots(词根库)---------- */ + +export function normalizeRootKey(part) { + return String(part || '').replace(/[^a-zA-Z]/g, '').toLowerCase(); +} + +export function getRoot(rootKey) { + const r = stmts.getRoot.get(rootKey); + if (!r) return null; + return { ...r, words: JSON.parse(r.words) }; +} + +/** + * 按词根维度累积:同一词根下追加包含它的单词(含该词中体现的含义)。 + * 含义不强行统一——不同词中的含义变体并列展示,让学习者自行抽象。 + */ +export function upsertRootData( + { part, type, phonetic, meaning }, + word, + wordPhonetic, + wordSenses = [] +) { + const key = normalizeRootKey(part); + if (!key) return null; + const existing = stmts.getRoot.get(key); + let words = existing ? JSON.parse(existing.words) : []; + // word_senses: 该单词自身的释义列表(词根面板中点击单词时一并揭晓) + const entry = { + word, + phonetic: wordPhonetic || '', + meaning: meaning || '', + word_senses: (wordSenses || []).filter(Boolean), + }; + const idx = words.findIndex((e) => e.word === word); + if (idx !== -1) words[idx] = entry; // 同一单词以最新拆解覆盖 + else words.push(entry); + + const row = { + id: existing?.id, + root: key, + display: part, + type: type || existing?.type || '', + phonetic: phonetic || existing?.phonetic || '', + words: JSON.stringify(words), + }; + if (existing) stmts.updateRoot.run(row); + else stmts.insertRoot.run(row); + return getRoot(key); +} diff --git a/llm.js b/llm.js new file mode 100644 index 0000000..ebb2a28 --- /dev/null +++ b/llm.js @@ -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; +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..253e1d8 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,908 @@ +{ + "name": "learnlanguage", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "learnlanguage", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "better-sqlite3": "^13.0.3", + "express": "^5.2.1" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/better-sqlite3": { + "version": "13.0.3", + "resolved": "https://registry.npmmirror.com/better-sqlite3/-/better-sqlite3-13.0.3.tgz", + "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.0.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmmirror.com/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmmirror.com/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/node-addon-api": { + "version": "8.9.2", + "resolved": "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-8.9.2.tgz", + "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmmirror.com/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..0f7c9d1 --- /dev/null +++ b/package.json @@ -0,0 +1,18 @@ +{ + "name": "learnlanguage", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "start": "node server.js" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "better-sqlite3": "^13.0.3", + "express": "^5.2.1" + }, + "type": "module" +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..6485032 --- /dev/null +++ b/public/app.js @@ -0,0 +1,807 @@ +/* global state */ +let selectedWords = []; +let currentRecord = null; // 正在查看/编辑的历史记录 +let editing = false; + +const $ = (sel) => document.querySelector(sel); + +/* ---------- 视图切换 ---------- */ +function switchView(view) { + $('#view-query').classList.toggle('hidden', view !== 'query'); + $('#view-history').classList.toggle('hidden', view !== 'history'); + $('#nav-query').classList.toggle('active', view === 'query'); + $('#nav-history').classList.toggle('active', view === 'history'); +} + +$('#nav-query').addEventListener('click', () => switchView('query')); +$('#nav-history').addEventListener('click', () => { + switchView('history'); + currentRecord = null; + $('#detail-area').classList.add('hidden'); + loadHistory(); + loadWordLib(); +}); + +/* ---------- 单词标签 ---------- */ +/* ---------- 句子分词 + 点击选词 ---------- */ +function tokenizeSentence(text) { + const seen = new Set(); + const tokens = []; + for (const m of text.matchAll(/[A-Za-z]+(?:['\u2019][A-Za-z]+)*|[A-Za-z]+/g)) { + const w = m[0].toLowerCase(); + if (seen.has(w)) continue; // 只按出现顺序去重,不过滤虚词 + seen.add(w); + tokens.push(w); + } + return tokens; +} + +function renderTokens() { + const box = $('#word-tokens'); + const sentence = $('#sentence').value; + const tokens = tokenizeSentence(sentence); + box.innerHTML = ''; + + // 清理已不在句中的选中词 + selectedWords = selectedWords.filter((w) => tokens.includes(w)); + + if (tokens.length === 0) { + box.innerHTML = '输入句子后自动分词'; + return; + } + + tokens.forEach((w) => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'word-token' + (selectedWords.includes(w) ? ' selected' : ''); + btn.textContent = w; + btn.addEventListener('click', () => { + const idx = selectedWords.indexOf(w); + if (idx !== -1) { + selectedWords.splice(idx, 1); + } else if (selectedWords.length >= 5) { + setStatus('一次最多选择 5 个单词', 'error'); + return; + } else { + selectedWords.push(w); + setStatus(''); + } + renderTokens(); + }); + box.appendChild(btn); + }); +} + +let tokenTimer; +$('#sentence').addEventListener('input', () => { + clearTimeout(tokenTimer); + tokenTimer = setTimeout(renderTokens, 250); // 输入停顿后自动分词 +}); +$('#sentence').addEventListener('blur', renderTokens); + +function setStatus(msg, type = 'info') { + const el = $('#query-status'); + el.textContent = msg || ''; + el.className = `status ${type}`; + if (!msg) el.classList.add('hidden'); + else el.classList.remove('hidden'); +} + +/* ---------- 发音(浏览器 TTS) ---------- */ +function speak(text) { + const u = new SpeechSynthesisUtterance(text); + u.lang = 'en-US'; + u.rate = 0.9; + speechSynthesis.cancel(); + speechSynthesis.speak(u); +} + +/* ---------- 查询 ---------- */ +$('#query-btn').addEventListener('click', async () => { + const sentence = $('#sentence').value.trim(); + if (!sentence) return setStatus('请先粘贴英语句子', 'error'); + if (selectedWords.length === 0) return setStatus('请先点击分词列表选择要查询的单词', 'error'); + + const btn = $('#query-btn'); + btn.disabled = true; + const streamText = $('#stream-text'); + streamText.textContent = ''; + $('#stream-box').classList.remove('hidden'); + setStatus('正在连接 AI…', 'info'); + try { + const res = await fetch('/api/query/stream', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sentence, words: selectedWords }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || '查询失败'); + } + + setStatus('AI 生成中,请稍候…'); + let record = null; + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buf = ''; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + const events = buf.split('\n\n'); + buf = events.pop(); // 最后一段可能不完整,留到下一轮 + for (const evt of events) { + const line = evt.trim(); + if (!line.startsWith('data:')) continue; + const msg = JSON.parse(line.slice(5)); + if (msg.type === 'delta') { + appendStreamText(msg.text); + } else if (msg.type === 'done') { + record = msg.record; + } else if (msg.type === 'error') { + throw new Error(msg.message); + } + } + } + if (!record) throw new Error('生成中断,未返回结果'); + setStatus('查询完成,已保存到历史记录 ✓'); + historyPage = 1; // 新记录在最前,重置到第 1 页 + wordLibPage = 1; + currentRecord = record; + editing = false; + renderResult($('#result-area'), record, false, true); + } catch (err) { + setStatus(err.message, 'error'); + } finally { + btn.disabled = false; + $('#stream-box').classList.add('hidden'); + } +}); + +/* 流式输出窗口:追加文本并自动滚到底部 */ +function appendStreamText(text) { + const pre = $('#stream-text'); + pre.textContent += text; + pre.parentElement.scrollTop = pre.parentElement.scrollHeight; +} + +/* ---------- 结果渲染 ---------- */ +function exampleLi(ex, word, pathPrefix, editable) { + const li = document.createElement('li'); + if (editable) { + li.className = 'editing'; + li.innerHTML = ` + + + `; + li.querySelector('.ex-del').addEventListener('click', () => li.remove()); + } else { + li.innerHTML = ` +