// Sift.jsx — 筛词阅读 v2(第十八轮):左文右词的页内工作台(不弹窗) // 级别:小学/初中/高中 + Me词(我的生词本视图)。筛词与释义都由服务端 LLM 批量完成。 const { useState: useStateSF, useEffect: useEffectSF, useMemo: useMemoSF } = React; const SIFT_LEVELS = ['小学', '初中', '高中', '四级', '六级', '托福']; function SiftReader({ text, title, noteId, onAdopt, onClose }) { const [fixed, setFixed] = useStateSF(null); // §23 固化命中:直接用,零 API const [examOpen, setExamOpen] = useStateSF(false); // 同卷模考(§25 执行器) const pages = useMemoSF(() => { const paras = String(text || '').split('\n').filter(x => x.trim()); const out = []; let buf = []; let n = 0; paras.forEach(p => { buf.push(p); n += p.length; if (n > 1300) { out.push(buf); buf = []; n = 0; } }); if (buf.length) out.push(buf); return out.length ? out : [['(这一页还没有正文)']]; }, [text]); const [level, setLevel] = useStateSF('小学'); // §34 默认小学;红线只画当前级别的筛词结果(换级别红线跟着换) const [done, setDone] = useStateSF(0); // 已筛到第几段(连续滚动,后台逐段筛) const realId = (typeof window.poporRealId === 'function' && noteId) ? window.poporRealId(noteId) : noteId; const [meView, setMeView] = useStateSF(false); // Me词:我的生词本视图 const [cache, setCache] = useStateSF({}); // {pg|level: [{w,d}]} const [busy, setBusy] = useStateSF(false); const [bag, setBag] = useStateSF([]); // 收下的词(服务器 vocab.json,追加式义项) useEffectSF(() => { fetch((window.POPOR_API_BASE || '') + '/api/vocab').then(r => r.json()) .then(x => x && setBag((x.items || []).map(e => ({ w: e.w, d: e.d })))).catch(() => {}); }, []); const [flash, setFlash] = useStateSF(''); // 合并所有已筛段的词(去重,保留先出现的释义) const entries = useMemoSF(() => { const low = String(text || '').toLowerCase(); const srt = l => l.slice().sort((a, b) => { const ia = low.indexOf(String(a.w || '').toLowerCase()); const ib = low.indexOf(String(b.w || '').toLowerCase()); return (ia < 0 ? 1e9 : ia) - (ib < 0 ? 1e9 : ib); }); // 铁律:词表顺序 = 原文顺序(不信模型,信 indexOf) if (fixed) return srt(fixed); const seen = {}; const out = []; for (let i = 0; i < pages.length; i++) { (cache[i + '|' + level] || []).forEach(e => { const w = String(e.w).toLowerCase(); if (!seen[w]) { seen[w] = 1; out.push({ w, d: e.d }); } }); } return srt(out); }, [cache, level, pages, fixed, text]); async function llm(prompt, body) { const r = await fetch((window.POPOR_API_BASE || '') + '/api/skill/text', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt, text: body }), }); const d = await r.json().catch(() => ({})); if (!d || d.status !== 'ok') throw new Error('引擎未响应'); return d.text; } useEffectSF(() => { let dead = false; setFixed(null); setDone(0); setBusy(true); (async () => { // 先查固化:这卷这级别筛过 = 直接用,不再走任何 API(§23) if (/^note_/.test(String(realId || ''))) { try { const cd = await fetch((window.POPOR_API_BASE || '') + '/api/notes/' + realId + '/sift?level=' + encodeURIComponent(level)).then(r => r.json()); if (!dead && cd && cd.entries && cd.entries.length) { setFixed(cd.entries); setBusy(false); return; } } catch (e) {} } for (let i = 0; i < pages.length; i++) { if (dead) return; if (!cache[i + '|' + level]) { try { const t = await llm('从下面英文里筛出超过中国「' + level + '」词汇水平的**单词和短语**(动词短语、固定搭配、习语都要选,短语照原文小写;学过' + level + '就不选)。只输出 JSON 数组,严格按原文出现顺序,最多 26 个:[{"w":"单词小写原形","d":"词性·中文释义(≤14字)"}]', pages[i].join('\n')); let arr = []; try { arr = JSON.parse(t.slice(t.indexOf('['), t.lastIndexOf(']') + 1)); } catch (e) {} if (dead) return; let out = (arr || []).filter(x => x && x.w); // ConDict 优先:C 栏有语境释义就用词典的;AI 生成的回写喂库(Popor 读者养 ConDict) try { const dr = await fetch('https://condict.satest.com.cn/lookup_batch_v2', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ words: out.map(x => x.w) }), }).then(r => r.json()); const res = (dr && dr.results) || {}; const wb = {}; out = out.map(x => { const info = res[x.w] || res[String(x.w).toLowerCase()]; // 修:C 栏个别释义是嵌套对象(老工具写入的结构格式),只收字符串,其余回落 AI 释义 if (info && info.from_c && typeof info.meaning === 'string' && info.meaning) { const pm = info.meaning.match(/^\{['"]m['"]:\s*['"]([^'"]*)['"]/); // 洗 Python 字典字符串形态 const clean = pm ? pm[1] : info.meaning; if (clean) return { w: x.w, d: clean }; } wb[x.w] = x.d.replace(/^[^·]*·/, ''); // 回写去掉词性前缀,存干净释义 return x; }); if (Object.keys(wb).length) { fetch('https://condict.satest.com.cn/submit_meanings_batch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ meanings: {}, word_fallback: wb }), }).catch(() => {}); } } catch (e) { /* 词典不在线就全用 AI 的,静默回落 */ } setCache(c => Object.assign({}, c, { [i + '|' + level]: out })); } catch (e) { if (dead) return; setCache(c => Object.assign({}, c, { [i + '|' + level]: [] })); } } if (!dead) setDone(i + 1); } if (!dead) setBusy(false); })(); return () => { dead = true; }; }, [level]); // 全文筛完 → 自动固化(下一个人零成本) useEffectSF(() => { if (busy || fixed || !entries.length || !/^note_/.test(String(realId || ''))) return; if (done < pages.length) return; fetch((window.POPOR_API_BASE || '') + '/api/notes/' + realId + '/sift', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ level, entries }), }).catch(() => {}); }, [busy, done]); const hardSet = useMemoSF(() => new Set(entries.map(e => String(e.w).toLowerCase())), [entries]); function hitWord(lw) { return hardSet.has(lw) || (lw.endsWith('s') && hardSet.has(lw.slice(0, -1))) || (lw.endsWith('ed') && hardSet.has(lw.slice(0, -2))); } function baseOf(lw) { if (hardSet.has(lw)) return lw; if (lw.endsWith('s') && hardSet.has(lw.slice(0, -1))) return lw.slice(0, -1); if (lw.endsWith('ed') && hardSet.has(lw.slice(0, -2))) return lw.slice(0, -2); return lw; } function toggle(e) { const has = bag.some(x => x.w === e.w && x.d === e.d); setBag(b => has ? b.filter(x => !(x.w === e.w && x.d === e.d)) : b.concat([{ w: e.w, d: e.d }])); fetch((window.POPOR_API_BASE || '') + '/api/vocab/' + (has ? 'remove' : 'add'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ w: e.w, d: e.d, src: title || '' }), }).catch(() => {}); } function jumpList(w) { setFlash(w); setTimeout(() => setFlash(''), 1400); const el = document.getElementById('sfw-' + w); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); } // §38 短语划线:词表里的多词条目(动词短语/习语)此前从未被划中——渲染只按单词切。 // 现在两层:先整段按短语(大小写不敏感、词间任意空白)切出命中,剩余部分再按单词划。 const phraseSet = useMemoSF(() => new Set(entries.map(e => String(e.w).toLowerCase()).filter(w => w.indexOf(' ') >= 0)), [entries]); const phraseRe = useMemoSF(() => { const arr = Array.from(phraseSet); if (!arr.length) return null; const esc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/ /g, '\\s+'); return new RegExp('(' + arr.sort((a, b) => b.length - a.length).map(esc).join('|') + ')', 'gi'); }, [phraseSet]); function renderWords(tx, kp) { const parts = String(tx).split(/(\b[A-Za-z][A-Za-z'-]*\b)/g); return parts.map((w, i) => { const lw = w.toLowerCase(); if (!/^[a-z]/.test(lw) || !hitWord(lw)) return w; const base = baseOf(lw); return b.w === base) ? ' got' : '')} onClick={() => jumpList(base)}>{w}; }); } function renderPara(tx, pi) { const chunks = phraseRe ? String(tx).split(phraseRe) : [tx]; return (
{chunks.map((ck, ci) => {
const norm = String(ck || '').toLowerCase().replace(/\s+/g, ' ').trim();
if (phraseRe && phraseSet.has(norm)) {
return b.w === norm) ? ' got' : '')} onClick={() => jumpList(norm)}>{ck};
}
return
{typeof e.d === 'string' ? e.d : String(e.d && (e.d.m || e.d.meaning) || '')}