// 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 {renderWords(ck, ci)}; })}

); } const sideList = meView ? bag : entries; // Me词=服务器生词本;同词多义项各自成行(义项即条目) return (
筛词阅读{title || '英文文本'}
{SIFT_LEVELS.map(l => )} {examOpen && window.ExamRunner ? React.createElement(window.ExamRunner, { noteId: realId, onClose: () => setExamOpen(false) }) : null}
{pages.map((chunk, ci) => chunk.map((tx, pi) => renderPara(tx, ci + '-' + pi)))}
{meView ? '我的生词本' : '生词 · ' + level + ' · ' + entries.length + (busy ? '(筛到 ' + done + '/' + pages.length + ' 段…)' : '')}
{busy && !meView && !entries.length ?
正在按「{level}」筛词…
: null} {!busy && sideList.length === 0 &&
{meView ? '还没收词。点右栏 ○ 或正文里的词。' : '这页没有超出该级别的词。'}
} {sideList.map(e => (
{e.w}

{typeof e.d === 'string' ? e.d : String(e.d && (e.d.m || e.d.meaning) || '')}

))}
{fixed ? '已固化 · ' + entries.length + ' 个生词(零成本复用)' : busy ? '正在按「' + level + '」逐段筛词 · ' + done + '/' + pages.length : '全文筛完 · ' + entries.length + ' 个生词'} 生词本 · {bag.length} {bag.length > 0 && }
); } Object.assign(window, { SiftReader });