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 = ` +
${highlight(ex.en || '', word)}
+
${escapeHtml(ex.zh || '')}
`; + } + return li; +} + +function senseBlock(title, examples, word, pathPrefix, editable) { + const block = document.createElement('div'); + block.className = 'sense-block'; + const titleEl = document.createElement('div'); + titleEl.className = 'sense-title'; + if (editable) { + titleEl.innerHTML = ``; + titleEl.querySelector('input').dataset.path = pathPrefix + '.sense'; + } else { + titleEl.textContent = title; + } + block.appendChild(titleEl); + + const ul = document.createElement('ul'); + ul.className = 'example-list'; + examples.forEach((ex, i) => { + ul.appendChild(exampleLi(ex, word, `${pathPrefix}.examples.${i}`, editable)); + }); + block.appendChild(ul); + + if (editable) { + const addBtn = document.createElement('button'); + addBtn.className = 'btn small ghost'; + addBtn.textContent = '+ 添加例句'; + addBtn.addEventListener('click', () => { + const idx = ul.querySelectorAll('li').length; + ul.appendChild(exampleLi({ en: '', zh: '' }, word, `${pathPrefix}.examples.${idx}`, true)); + }); + block.appendChild(addBtn); + + if (pathPrefix.includes('other_senses')) { + const delSense = document.createElement('button'); + delSense.className = 'btn small danger'; + delSense.style.marginLeft = '8px'; + delSense.textContent = '删除此释义'; + delSense.addEventListener('click', () => block.remove()); + block.appendChild(delSense); + } + } + return block; +} + +/* ---------- 词根拆解渲染 ---------- */ +function rootToLine(r) { + return [r.part, r.type, r.phonetic, r.meaning].map((s) => (s || '').trim()).join(' | '); +} + +function renderRoots(container, w, wp, editable) { + const block = document.createElement('div'); + block.className = 'sense-block roots-block'; + + const label = document.createElement('div'); + label.style.cssText = 'font-size:12px;color:var(--muted);margin-bottom:4px'; + label.textContent = '🧩 词根拆解(点击读出,练习拼读)'; + block.appendChild(label); + + if (editable) { + const ta = document.createElement('textarea'); + ta.className = 'roots-edit'; + ta.dataset.roots = `${wp}.roots`; + ta.rows = Math.max(2, (w.roots || []).length); + ta.placeholder = '每行一个部分:部分 | 类型 | 音标 | 含义,如:ana- | 前缀 | /əˈnæ/ | 向上、全面'; + ta.value = (w.roots || []).map(rootToLine).join('\n'); + block.appendChild(ta); + } else if ((w.roots || []).length) { + const row = document.createElement('div'); + row.className = 'roots-row'; + const panel = document.createElement('div'); + panel.className = 'root-panel hidden'; + (w.roots || []).forEach((r) => { + const chip = document.createElement('span'); + chip.className = 'root-chip'; + chip.title = '点击查看该词根在已学单词中的体现'; + chip.innerHTML = ` + ${escapeHtml(r.part || '')} + ${escapeHtml(r.phonetic || '')} + ${escapeHtml(r.meaning || '')} + ${escapeHtml(r.type || '')} + `; + // 🔊 只负责发音 + chip.querySelector('.root-speak').addEventListener('click', (e) => { + e.stopPropagation(); + speak((r.part || '').replace(/[-•·]/g, '')); + }); + // 点击词根:展开/收起该词根的历史词例面板 + chip.addEventListener('click', () => toggleRootPanel(panel, r.part, w.word)); + row.appendChild(chip); + }); + block.appendChild(row); + block.appendChild(panel); + } else { + block.appendChild(Object.assign(document.createElement('div'), { textContent: '(无拆解数据)', className: 'root-phon' })); + } + container.appendChild(block); +} + +/* 点击词根:按需加载该词根在已学单词中的全部体现(选择性触发) */ +function normalizeRootKey(part) { + return String(part || '').replace(/[^a-zA-Z]/g, '').toLowerCase(); +} + +async function toggleRootPanel(panel, part, currentWord) { + if (!panel.classList.contains('hidden')) { + panel.classList.add('hidden'); + panel.innerHTML = ''; + return; + } + panel.classList.remove('hidden'); + panel.innerHTML = '
加载词根历史中…
'; + const key = normalizeRootKey(part); + try { + const res = await fetch(`/api/roots/${encodeURIComponent(key)}`); + if (!res.ok) throw new Error('无历史记录'); + const r = await res.json(); + panel.innerHTML = ''; + + const title = document.createElement('div'); + title.className = 'root-panel-title'; + let titleHtml = `🔍 词根 ${escapeHtml(r.display || part)}`; + if (r.phonetic) titleHtml += ` ${escapeHtml(r.phonetic)}`; + title.innerHTML = titleHtml; + panel.appendChild(title); + + const hint = document.createElement('div'); + hint.className = 'root-panel-note'; + hint.textContent = '先回想它在各词中是什么意思,点击单词揭晓 👇'; + panel.appendChild(hint); + + // 含义默认隐藏:点击单词才揭晓(先猜后看,帮助大脑抽象词根义) + const row = document.createElement('div'); + row.className = 'root-group-words'; + (r.words || []).forEach((e) => { + const b = document.createElement('button'); + b.className = 'root-word'; + b.title = '点击揭晓含义并发音'; + b.innerHTML = `${escapeHtml(e.word)} ${escapeHtml(e.phonetic || '')} ${escapeHtml(e.meaning || '(未标注)')}${(e.word_senses || []).length ? '📌 词义:' + e.word_senses.map((s) => escapeHtml(s)).join(';') + '' : ''}`; + b.addEventListener('click', () => { + const revealed = b.classList.toggle('revealed'); + if (revealed) speak(e.word); + }); + row.appendChild(b); + }); + panel.appendChild(row); + + + const others = (r.words || []).filter((e) => e.word !== currentWord).length; + const note = document.createElement('div'); + note.className = 'root-panel-note'; + note.textContent = + others > 0 + ? `除当前单词外,还有 ${others} 个已学单词包含此词根` + : '目前只有当前单词包含此词根,继续查询积累吧~'; + panel.appendChild(note); + } catch (err) { + panel.innerHTML = `
暂无该词根的历史记录
`; + } +} + +function wordCard(w, idx, editable) { + const card = document.createElement('div'); + card.className = 'word-card' + (editable ? ' editing' : ''); + const wp = `words.${idx}`; + + const head = document.createElement('div'); + head.className = 'word-head'; + if (editable) { + head.innerHTML = ` + + + `; + head.querySelector('.speak').addEventListener('click', () => { + speak(head.querySelector(`[data-path="${wp}.word"]`).value); + }); + } else { + head.innerHTML = ` + ${escapeHtml(w.word || '')} + ${escapeHtml(w.phonetic || '')} + `; + head.querySelector('.speak-btn').addEventListener('click', () => speak(w.word)); + } + card.appendChild(head); + + const body = document.createElement('div'); + body.className = 'word-body'; + + // 词根拆解(发音拼读训练) + renderRoots(body, w, wp, editable); + + // 当前语境释义 + const ctxBlock = document.createElement('div'); + ctxBlock.className = 'sense-block'; + const ctxTitle = document.createElement('div'); + ctxTitle.className = 'sense-title'; + if (editable) { + ctxTitle.innerHTML = ``; + const lbl = document.createElement('div'); + lbl.style.cssText = 'font-size:12px;color:var(--muted);margin-bottom:4px'; + lbl.textContent = '📌 原句语境中的释义'; + ctxBlock.appendChild(lbl); + } else { + ctxTitle.textContent = '📌 原句语境中的释义:' + (w.context_sense || ''); + } + ctxBlock.appendChild(ctxTitle); + + const ctxUl = document.createElement('ul'); + ctxUl.className = 'example-list'; + (w.context_examples || []).forEach((ex, i) => { + ctxUl.appendChild(exampleLi(ex, w.word, `${wp}.context_examples.${i}`, editable)); + }); + ctxBlock.appendChild(ctxUl); + if (editable) { + const addBtn = document.createElement('button'); + addBtn.className = 'btn small ghost'; + addBtn.textContent = '+ 添加例句'; + addBtn.addEventListener('click', () => { + const n = ctxUl.querySelectorAll('li').length; + ctxUl.appendChild(exampleLi({ en: '', zh: '' }, w.word, `${wp}.context_examples.${n}`, true)); + }); + ctxBlock.appendChild(addBtn); + } + body.appendChild(ctxBlock); + + // 其他释义 + (w.other_senses || []).forEach((s, si) => { + body.appendChild(senseBlock(s.sense || '', s.examples || [], w.word, `${wp}.other_senses.${si}`, editable)); + }); + + if (editable) { + const addSense = document.createElement('button'); + addSense.className = 'btn small secondary'; + addSense.textContent = '+ 添加其他释义'; + addSense.addEventListener('click', () => { + const si = (w.other_senses || []).length; + w.other_senses = w.other_senses || []; + w.other_senses.push({ sense: '', examples: [{ en: '', zh: '' }] }); + body.insertBefore( + senseBlock('', [{ en: '', zh: '' }], w.word, `${wp}.other_senses.${body.querySelectorAll('.sense-block').length - 1}`, true), + addSense + ); + }); + body.appendChild(addSense); + } + + card.appendChild(body); + return card; +} + +function renderResult(container, record, editable, showSentence) { + container.innerHTML = ''; // 先清空,避免多次查询结果叠加 + if (showSentence) { + const sc = document.createElement('div'); + sc.className = 'card sentence-card'; + const tr = record.result?.sentence_translation || ''; + sc.innerHTML = ` + +
${escapeHtml(record.sentence || '')}
+ ${tr ? '
' + escapeHtml(tr) + '
' : ''}`; + container.appendChild(sc); + } + (record.result?.words || []).forEach((w, i) => { + container.appendChild(wordCard(w, i, editable)); + }); +} + +/* ---------- 历史记录 ---------- */ +const PAGE_SIZE = 10; +let historyPage = 1; +let wordLibPage = 1; +let historySearch = ''; +let wordLibSearch = ''; + +/* 搜索输入(300ms 防抖,输入变化重置到第 1 页) */ +function bindSearch(inputId, get, set, reload) { + let timer; + $(inputId).addEventListener('input', (e) => { + clearTimeout(timer); + timer = setTimeout(() => { + if (get() === e.target.value.trim()) return; + set(e.target.value.trim()); + reload(); + }, 300); + }); +} +bindSearch( + '#history-search', + () => historySearch, + (v) => { historySearch = v; historyPage = 1; }, + loadHistory +); +bindSearch( + '#word-lib-search', + () => wordLibSearch, + (v) => { wordLibSearch = v; wordLibPage = 1; }, + loadWordLib +); + +/* 通用分页控件 */ +function renderPager(container, data, onPage) { + container.innerHTML = ''; + const info = document.createElement('span'); + info.className = 'pager-info'; + info.textContent = `共 ${data.total} 条 · 第 ${data.page}/${data.total_pages} 页`; + container.appendChild(info); + + const prev = mkBtn('‹ 上一页', 'btn ghost small', () => onPage(data.page - 1)); + prev.disabled = data.page <= 1; + const next = mkBtn('下一页 ›', 'btn ghost small', () => onPage(data.page + 1)); + next.disabled = data.page >= data.total_pages; + container.appendChild(prev); + container.appendChild(next); +} + +async function loadHistory() { + const q = historySearch ? `&q=${encodeURIComponent(historySearch)}` : ''; + const res = await fetch(`/api/history?page=${historyPage}&size=${PAGE_SIZE}${q}`); + const data = await res.json(); + const list = data.items; + const box = $('#history-list'); + box.innerHTML = ''; + if (list.length === 0) { + box.innerHTML = historySearch + ? '
没有匹配的查询记录
' + : '
暂无查询记录,去查询页试试吧~
'; + renderPager($('#history-pager'), data, () => {}); + return; + } + list.forEach((r) => { + const item = document.createElement('div'); + item.className = 'history-item'; + item.innerHTML = ` +
${escapeHtml(r.sentence)}
+
+
${r.words.map(escapeHtml).join('、')}
+
${escapeHtml(r.created_at)}
+
`; + item.addEventListener('click', () => openDetail(r.id)); + box.appendChild(item); + }); + renderPager($('#history-pager'), data, (p) => { + historyPage = p; + loadHistory(); + }); +} + +async function openDetail(id) { + const res = await fetch(`/api/history/${id}`); + if (!res.ok) return alert('加载记录失败'); + currentRecord = await res.json(); + editing = false; + $('#detail-area').classList.remove('hidden'); + renderDetail(); + $('#detail-area').scrollIntoView({ behavior: 'smooth' }); +} + +/* ---------- 我的词库(按单词维度) ---------- */ +async function loadWordLib() { + const q = wordLibSearch ? `&q=${encodeURIComponent(wordLibSearch)}` : ''; + const res = await fetch(`/api/words?page=${wordLibPage}&size=${PAGE_SIZE}${q}`); + const data = await res.json(); + const list = data.items; + const box = $('#word-lib'); + box.innerHTML = ''; + if (list.length === 0) { + box.innerHTML = wordLibSearch + ? '
没有匹配的单词
' + : '
词库还是空的,去查询页添加单词吧~
'; + renderPager($('#word-lib-pager'), data, () => {}); + return; + } + list.forEach((r) => { + const item = document.createElement('div'); + item.className = 'word-lib-item'; + item.innerHTML = ` +
+ ${escapeHtml(r.word)} + ${escapeHtml(r.phonetic || '')} +
+
${r.sense_count} 个释义 · 出现 ${r.occurrence_count} 次
`; + item.addEventListener('click', () => openWordDetail(r.word)); + box.appendChild(item); + }); + renderPager($('#word-lib-pager'), data, (p) => { + wordLibPage = p; + loadWordLib(); + }); +} + +async function openWordDetail(word) { + const res = await fetch(`/api/words/${encodeURIComponent(word)}`); + if (!res.ok) return alert('加载失败'); + const w = await res.json(); + const area = $('#word-detail-area'); + area.classList.remove('hidden'); + area.innerHTML = ''; + + const toolbar = document.createElement('div'); + toolbar.className = 'detail-toolbar'; + toolbar.appendChild(mkBtn('← 返回词库列表', 'btn ghost small', () => area.classList.add('hidden'))); + area.appendChild(toolbar); + + const card = document.createElement('div'); + card.className = 'word-card'; + const head = document.createElement('div'); + head.className = 'word-head'; + head.innerHTML = ` + ${escapeHtml(w.word)} + ${escapeHtml(w.phonetic || '')} + `; + head.querySelector('.speak-btn').addEventListener('click', () => speak(w.word)); + card.appendChild(head); + + const body = document.createElement('div'); + body.className = 'word-body'; + renderRoots(body, w, 'words', false); + + (w.senses || []).forEach((s, si) => { + const block = document.createElement('div'); + block.className = 'sense-block'; + const title = document.createElement('div'); + title.className = 'sense-title'; + title.textContent = `${si + 1}. ${s.sense}`; + block.appendChild(title); + + const ul = document.createElement('ul'); + ul.className = 'example-list'; + (s.examples || []).forEach((e) => { + const li = document.createElement('li'); + li.innerHTML = ` +
${e.from_sentence ? '📌 ' : ''}${highlight(e.en, w.word)}
+
${escapeHtml(e.zh || '')}
`; + ul.appendChild(li); + }); + block.appendChild(ul); + body.appendChild(block); + }); + + card.appendChild(body); + area.appendChild(card); + area.scrollIntoView({ behavior: 'smooth' }); +} + + +function renderDetail() { + const area = $('#detail-area'); + area.classList.remove('hidden'); + area.innerHTML = ''; + + // 工具栏 + const toolbar = document.createElement('div'); + toolbar.className = 'detail-toolbar'; + const backBtn = mkBtn('← 返回列表', 'btn ghost small', () => { + area.classList.add('hidden'); + currentRecord = null; + loadHistory(); + }); + toolbar.appendChild(backBtn); + + if (!editing) { + toolbar.appendChild(mkBtn('✏️ 编辑', 'btn primary small', () => { + editing = true; + renderDetail(); + })); + toolbar.appendChild(mkBtn('🗑 删除', 'btn danger small', async () => { + if (!confirm('确定删除这条记录?')) return; + await fetch(`/api/history/${currentRecord.id}`, { method: 'DELETE' }); + area.classList.add('hidden'); + currentRecord = null; + loadHistory(); + })); + } else { + toolbar.appendChild(mkBtn('💾 保存', 'btn primary small', saveDetail)); + toolbar.appendChild(mkBtn('✖ 取消', 'btn ghost small', () => { + editing = false; + openDetail(currentRecord.id); + })); + } + area.appendChild(toolbar); + + // 原句 + const sc = document.createElement('div'); + sc.className = 'card sentence-card' + (editing ? ' editing' : ''); + if (editing) { + sc.innerHTML = ` + + + + `; + } else { + const tr = currentRecord.result?.sentence_translation || ''; + sc.innerHTML = ` + +
${escapeHtml(currentRecord.sentence)}
+ ${tr ? '
' + escapeHtml(tr) + '
' : ''}`; + } + area.appendChild(sc); + + // 单词结果 + const resultBox = document.createElement('div'); + renderResult(resultBox, currentRecord, editing, false); + while (resultBox.firstChild) area.appendChild(resultBox.firstChild); +} + +function mkBtn(text, cls, onClick) { + const b = document.createElement('button'); + b.className = cls; + b.textContent = text; + b.addEventListener('click', onClick); + return b; +} + +/* 按 data-path 从 DOM 收集值,写回对象 */ +function collectFromDOM(rootEl, obj) { + rootEl.querySelectorAll('[data-path]').forEach((el) => { + const path = el.dataset.path; + if (!path) return; + setByPath(obj, path.split('.'), el.value); + }); +} + +function setByPath(obj, parts, value) { + let cur = obj; + for (let i = 0; i < parts.length - 1; i++) { + const p = parts[i]; + const next = parts[i + 1]; + if (cur[p] == null) cur[p] = /^\d+$/.test(next) ? [] : {}; + cur = cur[p]; + } + cur[parts[parts.length - 1]] = value; +} + + +async function saveDetail() { + collectFromDOM($('#detail-area'), currentRecord); + + // 清理:other_senses / examples 数组中可能因删除留下空洞或需要按 DOM 顺序重排 + normalizeArrays(currentRecord); + + const res = await fetch(`/api/history/${currentRecord.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sentence: currentRecord.sentence, + words: currentRecord.words, + result: currentRecord.result, + }), + }); + if (!res.ok) return alert('保存失败'); + currentRecord = await res.json(); + editing = false; + renderDetail(); + alert('已保存 ✓'); +} + +/* 按 DOM 顺序重建可变长数组(例句、其他释义),去掉已删除项 */ +function normalizeArrays(record) { + const area = $('#detail-area'); + record.result.words = record.result.words.map((w, wi) => { + const out = { + word: w.word, + phonetic: w.phonetic, + context_sense: w.context_sense, + context_examples: [], + other_senses: [], + }; + // 词根拆解:从行格式文本解析(部分 | 类型 | 音标 | 含义) + const rootsTa = area.querySelector(`textarea[data-roots="words.${wi}.roots"]`); + out.roots = rootsTa + ? rootsTa.value + .split('\n') + .map((l) => l.trim()) + .filter(Boolean) + .map((line) => { + const [part, type, phonetic, meaning] = line.split('|').map((s) => s.trim()); + return { part, type, phonetic, meaning }; + }) + : w.roots || []; + // 语境例句:按 DOM 中 li 顺序 + const ctxLis = area.querySelectorAll( + `[data-path^="words.${wi}.context_examples."][data-path$=".en"]` + ); + ctxLis.forEach((el) => { + const idx = el.dataset.path.match(/\.(\d+)\.en$/)[1]; + const li = el.closest('li'); + out.context_examples.push({ + en: el.value, + zh: li.querySelector(`[data-path="words.${wi}.context_examples.${idx}.zh"]`)?.value || '', + }); + }); + // 其他释义:按 sense 输入框 DOM 顺序 + const senseEls = [...area.querySelectorAll(`[data-path^="words.${wi}.other_senses."][data-path$=".sense"]`)]; + senseEls.forEach((el, si) => { + const block = el.closest('.sense-block'); + const examples = []; + block.querySelectorAll('li').forEach((li) => { + examples.push({ + en: li.querySelector('textarea[data-path$=".en"]')?.value || '', + zh: li.querySelector('textarea[data-path$=".zh"]')?.value || '', + }); + }); + out.other_senses.push({ sense: el.value, examples }); + }); + return out; + }); +} + +/* ---------- 工具 ---------- */ +function escapeHtml(s) { + return String(s ?? '') + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); +} + +function highlight(text, word) { + const safe = escapeHtml(text); + if (!word) return safe; + try { + const re = new RegExp(`\\b${word.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}\\w*`, 'gi'); + return safe.replace(re, (m) => `${m}`); + } catch { + return safe; + } +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..074ddb0 --- /dev/null +++ b/public/index.html @@ -0,0 +1,58 @@ + + + + + + 查词学英语 + + + +
+

📖 查词学英语

+ +
+ + +
+
+ + + +
+ 输入句子后自动分词 +
+ + + +
+
+
+ + +
+
+

历史查询

+ +
+
+
+
+

我的词库

+ +
+
+
+ + +
+ + + + diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..7690cfc --- /dev/null +++ b/public/style.css @@ -0,0 +1,344 @@ +:root { + --bg: #f4f6fa; + --card: #ffffff; + --primary: #3b6ef5; + --primary-dark: #2c55c7; + --text: #1f2733; + --muted: #6b7686; + --border: #e2e7ef; + --tag-bg: #eaf0ff; + --danger: #e5484d; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: -apple-system, "PingFang SC", "Segoe UI", sans-serif; + background: var(--bg); + color: var(--text); +} + +header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 24px; + background: var(--card); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 10; +} + +header h1 { font-size: 20px; margin: 0; } + +.nav-btn { + border: 1px solid var(--border); + background: transparent; + padding: 8px 18px; + border-radius: 8px; + cursor: pointer; + font-size: 14px; + margin-left: 8px; +} +.nav-btn.active { + background: var(--primary); + color: #fff; + border-color: var(--primary); +} + +.view { max-width: 860px; margin: 0 auto; padding: 20px 16px 60px; } +.hidden { display: none !important; } + +.card { + background: var(--card); + border: 1px solid var(--border); + border-radius: 12px; + padding: 20px; + margin-bottom: 20px; +} + +label { display: block; font-size: 14px; font-weight: 600; margin: 12px 0 6px; } +label:first-child { margin-top: 0; } + +textarea, input[type="text"] { + width: 100%; + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px 12px; + font-size: 15px; + font-family: inherit; + resize: vertical; +} +textarea:focus, input:focus { outline: 2px solid var(--primary); border-color: transparent; } + +.word-input-row { display: flex; gap: 8px; } +.word-input-row input { flex: 1; } + +.btn { + border: none; + border-radius: 8px; + padding: 10px 18px; + font-size: 14px; + cursor: pointer; + white-space: nowrap; +} +.btn.primary { background: var(--primary); color: #fff; margin-top: 16px; width: 100%; font-size: 15px; } +.btn.primary:hover { background: var(--primary-dark); } +.btn.primary:disabled { background: #a9befa; cursor: wait; } +.btn.secondary { background: var(--tag-bg); color: var(--primary); } +.btn.ghost { background: transparent; border: 1px solid var(--border); } +.btn.danger { background: transparent; border: 1px solid var(--danger); color: var(--danger); } +.btn.small { padding: 6px 12px; font-size: 13px; } + +.word-tags { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; min-height: 10px; } +.word-tag { + display: inline-flex; + align-items: center; + gap: 6px; + background: var(--tag-bg); + color: var(--primary); + padding: 4px 12px; + border-radius: 999px; + font-size: 14px; +} +.word-tag button { + border: none; + background: none; + color: var(--primary); + cursor: pointer; + font-size: 14px; + padding: 0; +} + +.status { margin-top: 12px; font-size: 14px; } +.status.error { color: var(--danger); } +.status.info { color: var(--muted); } + +/* 单词结果卡片 */ +.word-card { border: 1px solid var(--border); border-radius: 12px; margin-bottom: 20px; overflow: hidden; } +.word-card .word-head { + background: linear-gradient(135deg, #3b6ef5, #6a8dff); + color: #fff; + padding: 18px 20px; + display: flex; + align-items: center; + gap: 14px; + flex-wrap: wrap; +} +.word-head .word-title { font-size: 24px; font-weight: 700; } +.word-head .phonetic { opacity: 0.9; font-size: 16px; } +.speak-btn { + border: none; + background: rgba(255,255,255,0.2); + color: #fff; + border-radius: 999px; + width: 34px; height: 34px; + cursor: pointer; + font-size: 15px; +} +.speak-btn:hover { background: rgba(255,255,255,0.35); } + +.word-body { padding: 16px 20px; } +.sense-block { margin-bottom: 20px; } +.sense-block:last-child { margin-bottom: 0; } +.sense-title { + font-size: 15px; + font-weight: 700; + color: var(--primary); + margin-bottom: 8px; + padding-bottom: 6px; + border-bottom: 2px solid var(--tag-bg); +} +.example-list { margin: 0; padding: 0; list-style: none; } +.example-list li { padding: 8px 0; border-bottom: 1px dashed var(--border); } +.example-list li:last-child { border-bottom: none; } +.example-en { font-size: 15px; } +.example-en b { color: var(--primary); } +.example-zh { font-size: 13px; color: var(--muted); margin-top: 2px; } + +/* 历史 */ +.history-item { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + padding: 12px 4px; + border-bottom: 1px solid var(--border); + cursor: pointer; +} +.history-item:hover { background: #f8faff; } +.history-sentence { flex: 1; font-size: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.history-meta { font-size: 12px; color: var(--muted); white-space: nowrap; } +.history-empty { color: var(--muted); text-align: center; padding: 30px 0; } + +/* 编辑态 */ +.editing textarea, .editing input[type="text"] { font-size: 14px; background: #fbfcff; } +.detail-toolbar { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; } +/* 固定在顶部的原句卡(header 高度约 61px) */ +.sentence-card { + position: sticky; + top: 62px; + z-index: 5; + box-shadow: 0 2px 10px rgba(31, 39, 51, 0.08); +} +.sentence-en { font-size: 15px; font-weight: 600; } +.sentence-zh { font-size: 13px; color: var(--muted); margin-top: 6px; } +.back-btn { margin-bottom: 12px; } + +/* AI 流式生成窗口 */ +.stream-box { + margin-top: 14px; + border: 1px solid var(--border); + border-radius: 10px; + background: #0f1622; + overflow: auto; + max-height: 180px; + min-height: 80px; +} +.stream-box .stream-label { + position: sticky; + top: 0; + background: #0f1622; + color: #7ee787; + font-size: 12px; + padding: 6px 12px 4px; + border-bottom: 1px solid #26303f; +} +.stream-box pre { + margin: 0; + padding: 8px 12px 12px; + color: #9fb3c8; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-all; +} + +/* 句子分词选词区 */ +.word-tokens { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 12px; + border: 1px dashed var(--border); + border-radius: 10px; + background: #fafbfe; + min-height: 20px; +} +.tokens-empty { color: var(--muted); font-size: 13px; } +.word-token { + border: 1px solid var(--border); + background: #fff; + color: var(--text); + border-radius: 999px; + padding: 5px 14px; + font-size: 14px; + cursor: pointer; + transition: all 0.12s ease; +} +.word-token:hover { border-color: var(--primary); color: var(--primary); } +.word-token.selected { + background: var(--primary); + border-color: var(--primary); + color: #fff; +} +.word-token.selected::after { content: " ✓"; } + +/* 词根拆解 */ +.roots-row { display: flex; flex-wrap: wrap; gap: 8px; } +.root-chip { + display: inline-flex; + align-items: baseline; + gap: 6px; + background: #fff7e6; + border: 1px solid #f2dfb8; + border-radius: 8px; + padding: 6px 10px; + cursor: pointer; + line-height: 1.3; +} +.root-chip:hover { border-color: #e5a53b; } +.root-part { font-size: 15px; font-weight: 700; color: #b06f0e; } +.root-phon { font-size: 12px; color: var(--muted); font-family: ui-monospace, Menlo, monospace; } +.root-mean { font-size: 12px; color: var(--text); } +.root-type { font-size: 11px; color: #fff; background: #e5a53b; border-radius: 4px; padding: 1px 5px; align-self: center; } +.roots-edit { font-family: ui-monospace, Menlo, monospace; font-size: 13px; } + +/* 我的词库 */ +.word-lib-item { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + padding: 10px 4px; + border-bottom: 1px solid var(--border); + cursor: pointer; +} +.word-lib-item:hover { background: #fffaf0; } +.word-lib-main { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; } + +/* 词根历史面板(点击词根芯片触发) */ +.root-panel { + margin-top: 10px; + background: #fffaf0; + border: 1px solid #f2dfb8; + border-radius: 10px; + padding: 12px 14px; +} +.root-panel-title { font-size: 14px; font-weight: 600; margin-bottom: 8px; } +.root-panel-loading { font-size: 13px; color: var(--muted); } +.root-group { margin-bottom: 10px; } +.root-group:last-of-type { margin-bottom: 4px; } +.root-group-label { font-size: 12px; color: #b06f0e; font-weight: 600; margin-bottom: 4px; } +.root-group-words { display: flex; flex-wrap: wrap; gap: 6px; } +.root-word { + border: 1px solid var(--border); + background: #fff; + border-radius: 999px; + padding: 4px 12px; + font-size: 13px; + cursor: pointer; + display: inline-flex; + align-items: baseline; + gap: 6px; +} +.root-word:hover { border-color: #e5a53b; background: #fff7e6; } +.root-panel-note { font-size: 12px; color: var(--muted); margin-top: 6px; } +.root-speak { border: none; background: none; cursor: pointer; padding: 0 2px; align-self: center; } + +/* 词根面板:含义点击揭晓 */ +.root-word .root-word-mean { + display: none; + font-size: 12px; + color: #b06f0e; + font-weight: 600; +} +.root-word.revealed { background: #fff7e6; border-color: #e5a53b; } +.root-word.revealed .root-word-mean { display: inline; } + +/* 揭晓区:词根含义 + 单词词义 */ +.root-word-senses { + display: block; + font-size: 11px; + color: var(--muted); + font-weight: 400; + margin-top: 2px; +} + +/* 分页 */ +.pager { + display: flex; + align-items: center; + gap: 8px; + margin-top: 12px; +} +.pager-info { font-size: 13px; color: var(--muted); margin-right: auto; } +.pager .btn[disabled] { opacity: 0.4; cursor: default; } + +/* 搜索框 */ +.search-box { + margin: 10px 0 4px; +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..4dd76a2 --- /dev/null +++ b/server.js @@ -0,0 +1,170 @@ +import express from 'express'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + saveRecord, + listRecords, + getRecord, + updateRecord, + deleteRecord, + getWord, + listWords, + upsertWordData, + getRoot, +} from './db.js'; +import { queryWords, queryWordsStream } from './llm.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const app = express(); +app.use(express.json({ limit: '2mb' })); +app.use(express.static(path.join(__dirname, 'public'))); + +// 发起查询 +app.post('/api/query', async (req, res) => { + const sentence = (req.body.sentence || '').trim(); + const words = Array.isArray(req.body.words) + ? req.body.words.map((w) => String(w).trim()).filter(Boolean) + : []; + + if (!sentence) return res.status(400).json({ error: '请提供英语句子' }); + if (words.length === 0) + return res.status(400).json({ error: '请提供至少一个要查询的单词' }); + if (words.length > 5) + return res.status(400).json({ error: '一次最多查询 5 个单词' }); + + try { + const { raw, parsed } = await queryWords(sentence, words); + const record = saveRecord({ + sentence, + words, + result: parsed, + raw_result: raw, + }); + res.json(record); + } catch (err) { + console.error('查询失败:', err.message); + res.status(502).json({ error: `查询失败:${err.message}` }); + } +}); + + +// 流式查询:SSE 增量推送 LLM 原始输出,完成后推送已保存的记录 +app.post('/api/query/stream', async (req, res) => { + const sentence = (req.body.sentence || '').trim(); + const words = Array.isArray(req.body.words) + ? req.body.words.map((w) => String(w).trim()).filter(Boolean) + : []; + + // 校验失败直接返回普通 JSON 错误 + if (!sentence) return res.status(400).json({ error: '请提供英语句子' }); + if (words.length === 0) + return res.status(400).json({ error: '请提供至少一个要查询的单词' }); + if (words.length > 5) + return res.status(400).json({ error: '一次最多查询 5 个单词' }); + + res.set({ + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + res.flushHeaders(); + + const send = (obj) => res.write(`data: ${JSON.stringify(obj)}\n\n`); + + try { + // 查询已在词库中的单词:把已有释义带给 LLM,用于同义项合并 + const existingWords = words + .map((w) => getWord(w)) + .filter(Boolean) + .map((r) => ({ word: r.word, senses: r.senses.map((s) => s.sense), roots: r.roots })); + + const { raw, parsed } = await queryWordsStream(sentence, words, (delta) => { + send({ type: 'delta', text: delta }); + }, existingWords); + + // 按单词维度并入词库(同义项合并、原句计入该单词记录) + for (const w of parsed.words || []) { + try { + upsertWordData(w, sentence, parsed.sentence_translation || ''); + } catch (e) { + console.error('词库更新失败:', e.message); + } + } + + const record = saveRecord({ + sentence, + words, + result: parsed, + raw_result: raw, + }); + send({ type: 'done', record }); + } catch (err) { + console.error('流式查询失败:', err.message); + send({ type: 'error', message: `查询失败:${err.message}` }); + } finally { + res.end(); + } +}); +// 词库列表 +app.get('/api/words', (req, res) => { + const { page, size } = pagerParams(req); + res.json(listWords(page, size, req.query.q || '')); +}); + +// 单词详情(含全部释义与例句) +app.get('/api/words/:word', (req, res) => { + const record = getWord(req.params.word.toLowerCase()); + if (!record) return res.status(404).json({ error: '词库中不存在该单词' }); + res.json(record); +}); + +// 词根详情(该词根下的所有学过的词,按含义变体并列) +app.get('/api/roots/:root', (req, res) => { + const record = getRoot(req.params.root.toLowerCase()); + if (!record) return res.status(404).json({ error: '词根库中不存在' }); + res.json(record); +}); + +// 历史列表 +app.get('/api/history', (req, res) => { + const { page, size } = pagerParams(req); + res.json(listRecords(page, size, req.query.q || '')); +}); + +// 历史详情 +app.get('/api/history/:id', (req, res) => { + const record = getRecord(req.params.id); + if (!record) return res.status(404).json({ error: '记录不存在' }); + res.json(record); +}); + +// 编辑保存 +app.put('/api/history/:id', (req, res) => { + const { sentence, words, result } = req.body; + if (!sentence || !Array.isArray(words) || !result) { + return res.status(400).json({ error: '字段不完整' }); + } + const record = updateRecord(req.params.id, { sentence, words, result }); + if (!record) return res.status(404).json({ error: '记录不存在' }); + res.json(record); +}); + +// 删除 +app.delete('/api/history/:id', (req, res) => { + const ok = deleteRecord(req.params.id); + if (!ok) return res.status(404).json({ error: '记录不存在' }); + res.json({ ok: true }); +}); + +// 分页参数解析(默认第 1 页、每页 10 条,上限 50) +function pagerParams(req) { + const page = Math.max(1, parseInt(req.query.page) || 1); + const size = Math.min(50, Math.max(1, parseInt(req.query.size) || 10)); + return { page, size }; +} + +const PORT = process.env.PORT || 3000; +app.listen(PORT, () => { + console.log(`✅ 服务已启动: http://localhost:${PORT}`); +});