墨亭读书 - 本地优先的在线电子书阅读器 v2.32.8

一亭墨色,半卷闲书。

支持 EPUB、PDF、DOCX、TXT、Markdown、HTML、FB2、RTF、MOBI、AZW 和 AZW3 的浏览器电子书阅读器,书籍、笔记和进度保存在本地。

朗读

未开始
使用浏览器自带朗读能力,不会把文本发送到云端。
准备中
当前位置
0%

搜索结果

目录

笔记

AI 书童

未准备
选一句想问的话,或直接把问题写在下面。书童会先依据书内上下文回答;开启旁征后可补充模型通识背景。
行距
版宽
首缩
', '' ].filter(Boolean).join('\n'); }, currentChapterMapTitle() { const item = Reader.currentTocItem ? Reader.currentTocItem() : null; const chapter = ($('#reader-chapter')?.textContent || '').trim(); const fallback = I18N.lang() === 'en' ? 'Current Chapter' : '当前章节'; if (item && item.label) return item.label; if (chapter && chapter !== t('reader.current')) return chapter; return fallback; }, async collectChapterMapContext() { const book = Reader.currentBook || {}; const chapterTitle = this.currentChapterMapTitle(); const index = await Reader.buildSearchIndex(); const toc = Array.isArray(Reader.toc) ? Reader.toc : []; const active = Reader.currentTocItem ? Reader.currentTocItem() : null; let entries = []; if (active && Reader.currentEngine === 'pdf' && Number.isFinite(Number(active.page))) { const currentPage = Number(active.page); const next = toc .map(item => Number(item.page)) .filter(page => Number.isFinite(page) && page > currentPage) .sort((a, b) => a - b)[0]; entries = index.filter(entry => { const page = Number(entry.location); return Number.isFinite(page) && page >= currentPage && (!next || page < next); }); } else if (active && active.href) { const href = String(active.href).split('#')[0]; entries = index.filter(entry => { const location = String(entry.location || '').split('#')[0]; return location === href || href.endsWith(location) || location.endsWith(href); }); } else if (active && active.anchor) { entries = index.filter(entry => entry.label === active.label); } if (!entries.length && chapterTitle) { entries = index.filter(entry => String(entry.label || '').trim() === chapterTitle); } if (!entries.length && typeof AIRepair !== 'undefined' && AIRepair.currentText) { const currentText = AIRepair.currentText(); if (currentText) entries = [{ label: chapterTitle, chunk: currentText, location: 'current' }]; } if (!entries.length) entries = index.slice(0, Reader.currentEngine === 'pdf' ? 6 : 2); const maxChars = 52000; const chunks = []; let total = 0; for (const entry of entries) { const text = String(entry.chunk || '').replace(/\s+/g, ' ').trim(); if (!text) continue; const block = `\n\n[${entry.label || chapterTitle}]\n${text}`; if (total + block.length > maxChars) { chunks.push(block.slice(0, Math.max(0, maxChars - total))); break; } chunks.push(block); total += block.length; } return { bookTitle: book.title || '', author: book.author || '', chapterTitle, context: chunks.join('').trim() }; }, buildInfographicMessages(context) { const lang = aiOutputLanguageName(); const book = Reader.currentBook || {}; const chapter = typeof context === 'object' && context ? context : { context }; return [ { role: 'system', content: [ 'You turn one book chapter into compact chapter-map data.', 'Return JSON only. Do not include Markdown, explanations, code fences, or comments.', `Write all visible content in ${lang}.`, 'The image will be one chapter only, not the whole book.', 'Make it look like a chapter outline mind-map: one center topic, 4-6 branch topics, and 2-4 short leaf points under each branch.', 'Schema: {"bookTitle":"string","author":"string","chapterTitle":"string","center":"string","summary":"string","branches":[{"title":"string","points":["string","string","string"]}],"keywords":["string"]}.', 'chapterTitle must include the chapter number/name when available, for example "第6章 战略实施"; center must be the chapter big heading or core topic, not the book title.', 'If the visible chapter title is only a number such as "第6章", infer a concise chapter big heading from the chapter content and put it in center.', 'Use 4-6 branches. Use 2-4 leaf points per branch.', 'Limits: branch title 4-10 Chinese chars or 2-5 English words; leaf point under 16 Chinese chars or 8 English words.', 'Avoid generic labels such as background, content, meaning. Extract concrete chapter logic.' ].join('\n') }, { role: 'user', content: [ `Book title: ${chapter.bookTitle || book.title || ''}`, `Author: ${chapter.author || book.author || ''}`, `Current chapter: ${chapter.chapterTitle || ''}`, 'Create one shareable chapter outline map JSON from this chapter context:', chapter.context || '' ].join('\n\n') } ]; }, extractJsonObject(text) { const clean = String(text || '') .replace(/```json/gi, '') .replace(/```/g, '') .trim(); try { return JSON.parse(clean); } catch (e) {} const start = clean.indexOf('{'); const end = clean.lastIndexOf('}'); if (start >= 0 && end > start) { try { return JSON.parse(clean.slice(start, end + 1)); } catch (e) {} } throw new Error(I18N.lang() === 'en' ? 'AI did not return valid JSON' : 'AI 没有返回有效 JSON'); }, normalizeInfographic(raw) { const book = Reader.currentBook || {}; const bookTitle = this.compactLabel((raw && (raw.bookTitle || raw.book || raw.bookName)) || book.title || (I18N.lang() === 'en' ? 'Book' : '\u672c\u4e66'), 42); const chapterTitle = this.compactLabel((raw && (raw.chapterTitle || raw.chapter || raw.section || raw.title)) || this.currentChapterMapTitle(), 52); const sourceThemes = Array.isArray(raw && raw.branches) ? raw.branches : (Array.isArray(raw && raw.themes) ? raw.themes : (Array.isArray(raw && raw.nodes) ? raw.nodes : [])); const themes = sourceThemes.slice(0, 6).map((theme, idx) => { const rawPoints = Array.isArray(theme && theme.points) ? theme.points : (Array.isArray(theme && theme.children) ? theme.children.map(child => child && (child.title || child.name || child.text || child)) : []); return { title: this.compactLabel((theme && (theme.title || theme.name)) || `${I18N.lang() === 'en' ? 'Branch' : '\u5206\u652f'} ${idx + 1}`, 26), insight: this.compactLabel(theme && (theme.insight || theme.summary || theme.description || ''), 58), points: rawPoints.map(point => this.compactLabel(point && (point.title || point.name || point.text || point), 34)).filter(Boolean).slice(0, 4) }; }).filter(theme => theme.title && theme.points.length); const keywords = Array.isArray(raw && raw.keywords) ? raw.keywords.map(word => this.compactLabel(word, 14)).filter(Boolean).slice(0, 6) : []; return { title: bookTitle, bookTitle, author: this.compactLabel((raw && raw.author) || book.author || '', 24), chapterTitle, center: this.compactLabel((raw && raw.center) || chapterTitle || (I18N.lang() === 'en' ? 'Chapter Focus' : '\u7ae0\u8282\u4e3b\u9898'), 36), summary: this.compactLabel((raw && raw.summary) || '', 220), themes: themes.length ? themes : [{ title: I18N.lang() === 'en' ? 'Core Ideas' : '\u6838\u5fc3\u89c2\u70b9', insight: '', points: [I18N.lang() === 'en' ? 'The model returned too little structure.' : '\u6a21\u578b\u8fd4\u56de\u7684\u7ed3\u6784\u8f83\u5c11\u3002'] }], keywords }; }, wrapCanvasText(ctx, text, maxWidth, maxLines = 3) { const raw = String(text || '').replace(/\s+/g, ' ').trim(); if (!raw) return []; const cjk = /[\u3400-\u9FFF\uF900-\uFAFF]/u.test(raw); const units = cjk ? Array.from(raw) : raw.split(' '); const sep = cjk ? '' : ' '; const lines = []; let line = ''; let truncated = false; for (let i = 0; i < units.length; i++) { const unit = units[i]; const test = line ? line + sep + unit : unit; if (ctx.measureText(test).width <= maxWidth) { line = test; } else { if (line) lines.push(line); line = unit; if (lines.length >= maxLines) { truncated = true; break; } } } if (line && lines.length < maxLines) lines.push(line); if (truncated && lines.length) { let last = lines[lines.length - 1]; while (last.length > 1 && ctx.measureText(last + '…').width > maxWidth) { last = last.slice(0, -1); } lines[lines.length - 1] = last.endsWith('…') ? last : last + '…'; } return lines; }, fillRoundRect(ctx, x, y, width, height, radius) { const r = Math.min(radius, width / 2, height / 2); ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + width, y, x + width, y + height, r); ctx.arcTo(x + width, y + height, x, y + height, r); ctx.arcTo(x, y + height, x, y, r); ctx.arcTo(x, y, x + width, y, r); ctx.closePath(); ctx.fill(); }, drawCanvasText(ctx, text, x, y, maxWidth, lineHeight, maxLines = 3, align = 'left') { const lines = this.wrapCanvasText(ctx, text, maxWidth, maxLines); ctx.textAlign = align; lines.forEach((line, idx) => ctx.fillText(line, x, y + idx * lineHeight)); return y + lines.length * lineHeight; }, async renderInfographicImage(data) { data = this.normalizeInfographic(data || {}); const canvas = document.createElement('canvas'); canvas.width = 1800; canvas.height = 1180; const ctx = canvas.getContext('2d'); const isEn = I18N.lang() === 'en'; const paper = '#fbfaf7'; const ink = '#121827'; const inkSoft = '#4b5563'; const muted = '#6b7280'; const seal = '#a13e2c'; const accent = seal; const accentSoft = 'rgba(161, 62, 44, 0.10)'; const lineInk = '#4c463d'; const textFont = '"Microsoft YaHei", "Noto Sans SC", Arial, sans-serif'; const brandFont = '"Noto Serif SC", "Songti SC", "STSong", serif'; const copy = isEn ? { kicker: 'Chapter Map', generated: 'Generated by AI Companion', keywords: 'Keywords', summary: 'Chapter Notes' } : { kicker: '\u7ae0\u8282\u68b3\u7406\u56fe', generated: 'AI \u4e66\u7ae5\u751f\u6210', keywords: '\u5173\u952e\u8bcd', summary: '\u7ae0\u8282\u8981\u70b9' }; const bookMeta = this.bookSummaryLabel(data); const chapterLabel = this.chapterLabelPart(data.chapterTitle) || this.compactLabel(data.chapterTitle, 24); const chapterHeading = this.chapterHeadingLabel(data); const visibleThemes = data.themes.slice(0, 6).map(theme => ({ ...theme, points: (theme.points || []).filter(Boolean).slice(0, 4) })); const rootNodeX = 420; const centerNodeX = 850; const branchTitleX = 948; const branchNodeX = 1220; const leafX = 1364; const leafEndX = 1712; const branchTitleWidth = branchNodeX - branchTitleX - 36; const leafWidth = leafEndX - leafX - 18; const minMapTop = 332; const branchGap = 22; const footerHeight = 142; const keywordHeight = data.keywords.length ? 44 : 18; const measureLines = (text, maxWidth, font, maxLines = 2) => { ctx.font = font; return this.wrapCanvasText(ctx, text, maxWidth, maxLines); }; const summaryText = data.summary || (isEn ? 'A compact structure map for the current chapter.' : '\u5f53\u524d\u7ae0\u8282\u7684\u7ed3\u6784\u4e0e\u8981\u70b9\u68b3\u7406\u3002'); const summaryLines = measureLines(summaryText, 760, `24px ${textFont}`, 6); const summaryBoxH = Math.max(116, Math.min(210, 58 + summaryLines.length * 30)); const mapTop = Math.max(minMapTop, 196 + summaryBoxH + 42); const branchBlocks = visibleThemes.map(theme => { const titleLines = measureLines(theme.title, branchTitleWidth, `bold 31px ${textFont}`, 2); const pointLines = theme.points.map(point => measureLines(point, leafWidth, `25px ${textFont}`, 1)); const pointCount = Math.max(1, pointLines.length); const leafGap = pointCount >= 4 ? 43 : 48; const titleNeed = titleLines.length * 34 + 46; const leafNeed = (pointCount - 1) * leafGap + 92; return { theme, titleLines, pointLines, pointCount, leafGap, height: Math.max(118, titleNeed, leafNeed) }; }); const totalBranchHeight = branchBlocks.reduce((sum, block) => sum + block.height, 0) + Math.max(0, branchBlocks.length - 1) * branchGap; const mapBottom = mapTop + Math.max(360, totalBranchHeight); canvas.height = Math.max(1180, Math.ceil(mapBottom + keywordHeight + footerHeight + 32)); const fillBackground = () => { ctx.fillStyle = paper; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = 'rgba(76, 70, 61, 0.045)'; for (let y = 0; y < canvas.height; y += 24) ctx.fillRect(0, y, canvas.width, 1); }; fillBackground(); let logo = null; try { logo = await loadImage(BRAND_ASSETS.stampLogo || BRAND_ASSETS.headerLogo); } catch (e) { logo = null; } const drawLogo = (x, y, size) => { if (logo) { ctx.drawImage(logo, x, y, size, size); return; } ctx.fillStyle = seal; this.fillRoundRect(ctx, x, y, size, size, 8); ctx.fillStyle = paper; ctx.font = `${Math.round(size * 0.55)}px Georgia, serif`; ctx.textAlign = 'center'; ctx.fillText('\u4ead', x + size / 2, y + size * 0.68); }; const drawNode = (x, y, radius = 13, filled = false) => { ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2); ctx.fillStyle = filled ? seal : paper; ctx.fill(); ctx.strokeStyle = lineInk; ctx.lineWidth = 4; ctx.stroke(); }; const drawCurve = (fromX, fromY, toX, toY, bend = 0.5) => { const dx = toX - fromX; ctx.beginPath(); ctx.moveTo(fromX, fromY); ctx.bezierCurveTo(fromX + dx * bend, fromY, toX - dx * bend, toY, toX, toY); ctx.stroke(); }; const drawBrandText = (x, y, size = 32) => { ctx.textAlign = 'left'; ctx.font = `${size}px ${brandFont}`; ctx.fillStyle = ink; ctx.fillText('\u58a8', x, y); const moW = ctx.measureText('\u58a8').width + size * 0.08; ctx.fillStyle = seal; ctx.fillText('\u4ead', x + moW, y); const tingW = ctx.measureText('\u4ead').width + size * 0.10; ctx.fillStyle = ink; ctx.fillText('\u8bfb\u4e66', x + moW + tingW, y); ctx.fillStyle = muted; ctx.font = `${Math.round(size * 0.58)}px ${textFont}`; ctx.fillText('TingRead', x, y + size * 0.82); }; const drawLines = (lines, x, baselineY, lineHeight, align = 'left') => { ctx.textAlign = align; lines.forEach((line, idx) => ctx.fillText(line, x, baselineY + idx * lineHeight)); ctx.textAlign = 'left'; return baselineY + lines.length * lineHeight; }; const drawTextBlock = (text, x, baselineY, maxWidth, font, lineHeight = 34, maxLines = 2, align = 'left', withBackground = false) => { ctx.font = font; const lines = this.wrapCanvasText(ctx, text, maxWidth, maxLines); if (!lines.length) return { bottom: baselineY, width: 0, height: 0, lines }; const widths = lines.map(line => ctx.measureText(line).width); const width = Math.min(maxWidth, Math.max(...widths)); const height = lines.length * lineHeight; if (withBackground) { const bgWidth = width + 26; const bgHeight = height + 14; const boxX = align === 'right' ? x - bgWidth + 12 : x - 13; const boxY = baselineY - lineHeight + 6; ctx.fillStyle = 'rgba(251, 250, 247, 0.94)'; this.fillRoundRect(ctx, boxX, boxY, bgWidth, bgHeight, 7); } ctx.fillStyle = ink; drawLines(lines, x, baselineY, lineHeight, align); return { bottom: baselineY + height, width, height, lines }; }; const drawLeafLabel = (lines, text, x, y, maxWidth) => { ctx.font = `25px ${textFont}`; const leafLines = lines && lines.length ? lines : this.wrapCanvasText(ctx, text, maxWidth, 1); const line = leafLines[0] || ''; ctx.fillStyle = inkSoft; ctx.textAlign = 'left'; ctx.fillText(line, x, y - 6); const textEnd = x + ctx.measureText(line).width + 18; if (textEnd < leafEndX - 24) { ctx.beginPath(); ctx.moveTo(textEnd, y); ctx.lineTo(leafEndX, y); ctx.stroke(); } return textEnd; }; drawLogo(72, 56, 58); drawBrandText(148, 78, 32); ctx.fillStyle = accent; ctx.font = `bold 28px ${textFont}`; ctx.textAlign = 'center'; ctx.fillText(copy.kicker, canvas.width / 2, 106); ctx.fillStyle = muted; ctx.font = `22px ${textFont}`; ctx.textAlign = 'right'; ctx.fillText('tingread.cn', 1718, 76); ctx.textAlign = 'left'; ctx.fillStyle = muted; ctx.font = `30px ${textFont}`; this.drawCanvasText(ctx, bookMeta, 72, 154, 840, 36, 2); ctx.fillStyle = accentSoft; this.fillRoundRect(ctx, 72, 196, 820, summaryBoxH, 8); ctx.fillStyle = accent; ctx.font = `24px ${textFont}`; ctx.fillText(copy.summary, 104, 232); ctx.fillStyle = inkSoft; ctx.font = `23px ${textFont}`; drawLines(summaryLines, 104, 270, 30); let cursorY = mapTop; const positionedBlocks = branchBlocks.map(block => { const top = cursorY; const anchorY = top + block.height / 2; const lineY = anchorY + (block.titleLines.length > 1 ? 24 : 18); cursorY += block.height + branchGap; return { ...block, top, anchorY, lineY }; }); const firstLineY = positionedBlocks[0]?.lineY || mapTop + 160; const lastLineY = positionedBlocks[positionedBlocks.length - 1]?.lineY || mapTop + 160; const centerLineY = (firstLineY + lastLineY) / 2; const rootLineY = centerLineY; const rootLabel = this.compactLabel([chapterLabel, chapterHeading].filter(Boolean).join(isEn ? ' ' : ' ') || data.chapterTitle || data.bookTitle || data.title, 64); ctx.strokeStyle = lineInk; ctx.lineWidth = 4; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.font = `32px ${textFont}`; const rootLines = this.wrapCanvasText(ctx, rootLabel, 380, 2); ctx.fillStyle = ink; drawLines(rootLines, 76, rootLineY - 12 - (rootLines.length - 1) * 34, 36); ctx.beginPath(); ctx.moveTo(76, rootLineY + 24); ctx.lineTo(rootNodeX - 18, rootLineY + 24); ctx.stroke(); drawNode(rootNodeX, rootLineY + 24, 14); drawCurve(rootNodeX + 14, rootLineY + 24, centerNodeX - 18, centerLineY + 12, 0.42); const centerText = data.center || chapterHeading || data.chapterTitle; drawTextBlock(centerText, centerNodeX - 42, centerLineY - 10, 360, `bold 36px ${textFont}`, 40, 2, 'right', true); ctx.beginPath(); ctx.moveTo(centerNodeX - 385, centerLineY + 28); ctx.lineTo(centerNodeX - 20, centerLineY + 28); ctx.stroke(); drawNode(centerNodeX, centerLineY + 12, 14); positionedBlocks.forEach((block, idx) => { const { theme, titleLines, pointLines, pointCount, leafGap, lineY } = block; drawCurve(centerNodeX + 16, centerLineY + 12, branchNodeX - 18, lineY, 0.42); const titleBaseline = lineY - 16 - (titleLines.length - 1) * 34; ctx.font = `bold 31px ${textFont}`; const titleWidths = titleLines.map(line => ctx.measureText(line).width); const titleW = Math.min(branchTitleWidth, Math.max(...titleWidths, 0)) + 26; const titleH = titleLines.length * 34 + 14; ctx.fillStyle = 'rgba(251, 250, 247, 0.94)'; this.fillRoundRect(ctx, branchTitleX - 13, titleBaseline - 28, titleW, titleH, 7); ctx.fillStyle = ink; drawLines(titleLines, branchTitleX, titleBaseline, 34); ctx.beginPath(); ctx.moveTo(branchTitleX, lineY + 14); ctx.lineTo(branchNodeX - 20, lineY + 14); ctx.stroke(); drawNode(branchNodeX, lineY, 13); const points = theme.points.slice(0, 4); const firstY = lineY - ((pointCount - 1) * leafGap) / 2; points.forEach((point, pointIdx) => { const leafY = firstY + pointIdx * leafGap; drawCurve(branchNodeX + 14, lineY, leafX - 14, leafY, 0.46); drawLeafLabel(pointLines[pointIdx], point, leafX, leafY, leafWidth); if (idx === positionedBlocks.length - 1 && pointIdx === points.length - 1) drawNode(leafEndX, leafY, 11, true); }); }); const keywordY = mapBottom + 42; if (data.keywords.length) { ctx.fillStyle = muted; ctx.font = `20px ${textFont}`; this.drawCanvasText(ctx, `${copy.keywords}: ${data.keywords.join(' / ')}`, 76, keywordY, 1060, 26, 1); } const footerLineY = canvas.height - 106; ctx.strokeStyle = 'rgba(76, 70, 61, 0.22)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(72, footerLineY - 34); ctx.lineTo(1728, footerLineY - 34); ctx.stroke(); drawLogo(78, footerLineY - 6, 44); drawBrandText(138, footerLineY + 10, 23); ctx.fillStyle = muted; ctx.font = `18px ${textFont}`; ctx.fillText(`tingread.cn \u00b7 ${copy.generated}`, 138, footerLineY + 54); ctx.fillStyle = 'rgba(161, 62, 44, 0.72)'; ctx.font = `18px ${textFont}`; ctx.textAlign = 'right'; ctx.fillText(new Date().toLocaleDateString(uiLocale()), 1722, footerLineY + 50); return canvas.toDataURL('image/png'); }, async renderBookOverviewImage(data) { data = this.normalizeInfographic(data || {}); const canvas = document.createElement('canvas'); canvas.width = 1200; canvas.height = 1800; const ctx = canvas.getContext('2d'); const paper = '#f7f0e4'; const paperSoft = '#fffaf1'; const ink = '#2a2622'; const inkSoft = '#6c6258'; const seal = '#a13e2c'; const rule = 'rgba(91, 72, 52, 0.18)'; const copy = I18N.lang() === 'en' ? { kicker: 'Book Map', summary: 'One-line Overview', keywords: 'Keywords', center: 'Core Map', generated: 'Generated by AI Companion' } : { kicker: '\u672c\u4e66\u8109\u7edc\u56fe', summary: '\u4e00\u53e5\u8bdd\u603b\u89c8', keywords: '\u5173\u952e\u8bcd', center: '\u6838\u5fc3\u8109\u7edc', generated: 'AI \u4e66\u7ae5\u751f\u6210' }; ctx.fillStyle = paper; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = 'rgba(255,255,255,0.34)'; for (let y = 0; y < canvas.height; y += 18) ctx.fillRect(0, y, canvas.width, 1); let logo = null; try { logo = await loadImage(BRAND_ASSETS.stampLogo || BRAND_ASSETS.headerLogo); } catch (e) { logo = null; } if (logo) ctx.drawImage(logo, 76, 70, 62, 62); else { ctx.fillStyle = seal; this.fillRoundRect(ctx, 76, 70, 62, 62, 8); ctx.fillStyle = paperSoft; ctx.font = '34px Georgia, serif'; ctx.textAlign = 'center'; ctx.fillText('\u4ead', 107, 86); } ctx.fillStyle = inkSoft; ctx.font = '22px Arial, "Microsoft YaHei", sans-serif'; ctx.textAlign = 'left'; ctx.fillText('\u58a8\u4ead\u8bfb\u4e66 TingRead', 154, 74); ctx.fillStyle = seal; ctx.font = '24px Arial, "Microsoft YaHei", sans-serif'; ctx.fillText(copy.kicker, 154, 106); ctx.fillStyle = ink; ctx.font = '54px Georgia, "Noto Serif SC", "Microsoft YaHei", serif'; let y = this.drawCanvasText(ctx, data.title, 80, 175, 1040, 66, 2); if (data.author) { ctx.fillStyle = inkSoft; ctx.font = '25px Georgia, "Noto Serif SC", serif'; ctx.fillText(data.author, 82, y + 8); y += 48; } else { y += 18; } ctx.fillStyle = paperSoft; this.fillRoundRect(ctx, 80, y + 20, 1040, 150, 16); ctx.fillStyle = seal; ctx.font = '24px Arial, "Microsoft YaHei", sans-serif'; ctx.fillText(copy.summary, 112, y + 48); ctx.fillStyle = ink; ctx.font = '29px Georgia, "Noto Serif SC", "Microsoft YaHei", serif'; this.drawCanvasText(ctx, data.summary || (I18N.lang() === 'en' ? 'A concise AI-generated reading map.' : '\u7531 AI \u4e66\u7ae5\u6574\u7406\u7684\u4e00\u9875\u5f0f\u9605\u8bfb\u8109\u7edc\u3002'), 112, y + 86, 976, 36, 2); const mapTop = y + 214; const visibleThemes = data.themes.slice(0, 6); const sideCardW = 380; const sideCardH = 214; const centerX = 600; const centerY = mapTop + 385; const centerW = 300; const centerH = 132; const baseLayouts = { 1: [{ x: 410, y: mapTop + 575, w: 380, h: 214 }], 2: [{ x: 86, y: mapTop + 318, w: sideCardW, h: sideCardH }, { x: 734, y: mapTop + 318, w: sideCardW, h: sideCardH }], 3: [{ x: 86, y: mapTop + 142, w: sideCardW, h: sideCardH }, { x: 734, y: mapTop + 142, w: sideCardW, h: sideCardH }, { x: 340, y: mapTop + 642, w: 520, h: 214 }], 4: [{ x: 86, y: mapTop + 42, w: sideCardW, h: sideCardH }, { x: 734, y: mapTop + 42, w: sideCardW, h: sideCardH }, { x: 86, y: mapTop + 562, w: sideCardW, h: sideCardH }, { x: 734, y: mapTop + 562, w: sideCardW, h: sideCardH }], 5: [{ x: 86, y: mapTop + 18, w: sideCardW, h: sideCardH }, { x: 734, y: mapTop + 18, w: sideCardW, h: sideCardH }, { x: 86, y: mapTop + 318, w: sideCardW, h: sideCardH }, { x: 734, y: mapTop + 318, w: sideCardW, h: sideCardH }, { x: 340, y: mapTop + 646, w: 520, h: 214 }], 6: [{ x: 86, y: mapTop + 8, w: sideCardW, h: sideCardH }, { x: 734, y: mapTop + 8, w: sideCardW, h: sideCardH }, { x: 86, y: mapTop + 310, w: sideCardW, h: sideCardH }, { x: 734, y: mapTop + 310, w: sideCardW, h: sideCardH }, { x: 86, y: mapTop + 612, w: sideCardW, h: sideCardH }, { x: 734, y: mapTop + 612, w: sideCardW, h: sideCardH }] }; const layout = baseLayouts[Math.min(6, Math.max(1, visibleThemes.length))] || baseLayouts[5]; ctx.strokeStyle = 'rgba(161, 62, 44, 0.30)'; ctx.lineWidth = 4; layout.slice(0, visibleThemes.length).forEach(pos => { const tx = pos.x + pos.w / 2; const ty = pos.y + pos.h / 2; ctx.beginPath(); ctx.moveTo(centerX, centerY); ctx.bezierCurveTo(centerX + (tx - centerX) * 0.45, centerY, centerX + (tx - centerX) * 0.55, ty, tx, ty); ctx.stroke(); }); const drawBranch = (theme, idx, pos) => { const { x, y: cardY, w, h } = pos; ctx.fillStyle = paperSoft; this.fillRoundRect(ctx, x, cardY, w, h, 18); ctx.strokeStyle = rule; ctx.lineWidth = 2; ctx.stroke(); ctx.fillStyle = seal; ctx.font = '22px Arial, "Microsoft YaHei", sans-serif'; ctx.fillText(String(idx + 1).padStart(2, '0'), x + 28, cardY + 28); ctx.fillStyle = ink; ctx.font = '27px Georgia, "Noto Serif SC", "Microsoft YaHei", serif'; const titleY = this.drawCanvasText(ctx, theme.title, x + 72, cardY + 22, w - 104, 32, 2); if (theme.insight) { ctx.fillStyle = 'rgba(161, 62, 44, 0.11)'; this.fillRoundRect(ctx, x + 28, titleY + 8, w - 56, 34, 17); ctx.fillStyle = seal; ctx.font = '18px Arial, "Microsoft YaHei", sans-serif'; this.drawCanvasText(ctx, theme.insight, x + 46, titleY + 29, w - 92, 22, 1); } ctx.fillStyle = inkSoft; ctx.font = '18px Arial, "Microsoft YaHei", sans-serif'; let pointY = cardY + (theme.insight ? 112 : 94); theme.points.slice(0, 4).forEach(point => { ctx.fillStyle = seal; ctx.fillText('\u2022', x + 34, pointY + 1); ctx.fillStyle = inkSoft; pointY = this.drawCanvasText(ctx, point, x + 58, pointY, w - 92, 23, 1) + 9; }); }; visibleThemes.forEach((theme, idx) => { drawBranch(theme, idx, layout[idx]); }); ctx.fillStyle = 'rgba(161, 62, 44, 0.13)'; this.fillRoundRect(ctx, centerX - centerW / 2, centerY - centerH / 2, centerW, centerH, 34); ctx.strokeStyle = 'rgba(161, 62, 44, 0.50)'; ctx.lineWidth = 3; ctx.stroke(); ctx.fillStyle = seal; ctx.font = '20px Arial, "Microsoft YaHei", sans-serif'; ctx.textAlign = 'center'; ctx.fillText(copy.center, centerX, centerY - 24); ctx.fillStyle = ink; ctx.font = '34px Georgia, "Noto Serif SC", "Microsoft YaHei", serif'; this.drawCanvasText(ctx, data.center || data.title, centerX, centerY + 16, centerW - 48, 40, 2, 'center'); ctx.textAlign = 'left'; const keywordY = Math.min(canvas.height - 300, mapTop + 900); ctx.fillStyle = inkSoft; ctx.font = '22px Arial, "Microsoft YaHei", sans-serif'; ctx.fillText(copy.keywords, 82, keywordY); let chipX = 174; (data.keywords.length ? data.keywords : ['TingRead']).forEach(word => { ctx.font = '21px Arial, "Microsoft YaHei", sans-serif'; const w = Math.min(180, ctx.measureText(word).width + 34); if (chipX + w > 1120) return; ctx.fillStyle = 'rgba(161, 62, 44, 0.10)'; this.fillRoundRect(ctx, chipX, keywordY - 24, w, 42, 21); ctx.fillStyle = seal; ctx.textAlign = 'center'; ctx.fillText(word, chipX + w / 2, keywordY - 15); ctx.textAlign = 'left'; chipX += w + 12; }); ctx.strokeStyle = rule; const footerLineY = canvas.height - 150; ctx.beginPath(); ctx.moveTo(80, footerLineY); ctx.lineTo(1120, footerLineY); ctx.stroke(); if (logo) ctx.drawImage(logo, 80, footerLineY + 28, 54, 54); ctx.fillStyle = ink; ctx.font = '24px Arial, "Microsoft YaHei", sans-serif'; ctx.fillText('\u58a8\u4ead\u8bfb\u4e66 TingRead', 150, footerLineY + 28); ctx.fillStyle = inkSoft; ctx.font = '20px Arial, sans-serif'; ctx.fillText(`tingread.cn \u00b7 ${copy.generated}`, 150, footerLineY + 62); ctx.fillStyle = 'rgba(161, 62, 44, 0.70)'; ctx.font = '18px Arial, "Microsoft YaHei", sans-serif'; ctx.textAlign = 'right'; ctx.fillText(new Date().toLocaleDateString(uiLocale()), 1120, footerLineY + 52); return canvas.toDataURL('image/png'); }, closeInfographicLightbox() { document.querySelectorAll('.ai-infographic-lightbox').forEach(node => node.remove()); document.body.classList.remove('ai-infographic-open'); if (this.infographicLightboxKeydown) { document.removeEventListener('keydown', this.infographicLightboxKeydown); this.infographicLightboxKeydown = null; } }, openInfographicLightbox(dataUrl, data = {}) { this.closeInfographicLightbox(); const overlay = document.createElement('div'); overlay.className = 'ai-infographic-lightbox'; overlay.setAttribute('role', 'dialog'); overlay.setAttribute('aria-modal', 'true'); overlay.setAttribute('aria-label', data.chapterTitle || data.title || t('ai.infographic')); const img = document.createElement('img'); img.src = dataUrl; img.alt = data.chapterTitle || data.title || t('ai.infographic'); const closeBtn = document.createElement('button'); closeBtn.type = 'button'; closeBtn.className = 'ai-infographic-lightbox-close'; closeBtn.setAttribute('aria-label', I18N.lang() === 'en' ? 'Close preview' : '关闭预览'); closeBtn.textContent = '×'; const close = () => this.closeInfographicLightbox(); overlay.addEventListener('click', close); closeBtn.addEventListener('click', close); this.infographicLightboxKeydown = (event) => { if (event.key === 'Escape') close(); }; document.addEventListener('keydown', this.infographicLightboxKeydown); overlay.appendChild(img); overlay.appendChild(closeBtn); document.body.appendChild(overlay); document.body.classList.add('ai-infographic-open'); closeBtn.focus(); }, infographicNoteMarkdown(dataUrl, data = {}) { const alt = String(data.chapterTitle || data.center || data.title || t('ai.infographic')).replace(/[\]\n\r]/g, ' ').trim(); const lines = [ `![${alt}](${dataUrl})`, '', `**${this.bookSummaryLabel(data)}**`, data.chapterTitle ? `**${data.chapterTitle}**` : '', data.summary ? `\n${data.summary}` : '', Array.isArray(data.keywords) && data.keywords.length ? `\n${I18N.lang() === 'en' ? 'Keywords' : '关键词'}:${data.keywords.join(' / ')}` : '' ]; return lines.filter(line => line !== '').join('\n'); }, async saveInfographicAsNote(dataUrl, data = {}) { if (!Reader.currentBook || !dataUrl) return null; const createdAt = Date.now(); const title = data.chapterTitle || data.center || data.title || t('ai.infographic'); const h = { bookId: Reader.currentBook.id, text: `${t('ai.infographic')}\n${title}`, note: this.infographicNoteMarkdown(dataUrl, data), cfi: null, createdAt, source: 'ai-infographic', imageDataUrl: dataUrl, imageAlt: title, infographicData: data }; const id = await Store.addHighlight(h); await Reader.renderNotes(); toast(t('ai.infographicSavedNote'), 2200); return id; }, appendInfographic(dataUrl, data, options = {}) { const { persist = true, noteId = null, ts = Date.now() } = options; const thread = $('#ai-thread'); const node = document.createElement('div'); node.className = 'ai-msg assistant ai-infographic-message'; const preview = document.createElement('div'); preview.className = 'ai-infographic-preview'; preview.tabIndex = 0; preview.setAttribute('role', 'button'); preview.title = I18N.lang() === 'en' ? 'Click to enlarge, click again to close' : '\u70b9\u51fb\u653e\u5927\uff0c\u518d\u70b9\u51fb\u6536\u56de'; const img = document.createElement('img'); img.src = dataUrl; img.alt = data.chapterTitle || data.title || t('ai.infographic'); preview.appendChild(img); const openPreview = () => this.openInfographicLightbox(dataUrl, data); preview.addEventListener('click', openPreview); preview.addEventListener('keydown', (event) => { if (event.key !== 'Enter' && event.key !== ' ') return; event.preventDefault(); openPreview(); }); const actionBar = document.createElement('div'); actionBar.className = 'ai-msg-actions'; const saveBtn = document.createElement('button'); saveBtn.type = 'button'; saveBtn.dataset.aiAction = 'infographic-save'; saveBtn.textContent = t('ai.infographicSave'); saveBtn.addEventListener('click', () => this.downloadInfographic(dataUrl, data)); const opmlBtn = document.createElement('button'); opmlBtn.type = 'button'; opmlBtn.dataset.aiAction = 'infographic-opml'; opmlBtn.textContent = t('ai.infographicExportOpml'); opmlBtn.addEventListener('click', () => this.downloadInfographicOpml(data)); actionBar.appendChild(saveBtn); actionBar.appendChild(opmlBtn); node.appendChild(preview); node.appendChild(actionBar); thread.appendChild(node); thread.scrollTop = thread.scrollHeight; if (persist) { this.messages.push({ role: 'assistant', type: 'infographic', dataUrl, data: data || {}, noteId, ts }); this.saveHistory(); } }, downloadInfographic(dataUrl, title) { const a = document.createElement('a'); a.href = dataUrl; a.download = this.infographicFileName(title); document.body.appendChild(a); a.click(); a.remove(); }, downloadInfographicOpml(data) { const opml = this.infographicToOpml(data || {}); const blob = new Blob([opml], { type: 'text/x-opml;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = this.infographicOpmlFileName(data || {}); document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); toast(t('ai.infographicOpmlSaved'), 1800); }, async generateInfographic() { if (this.infographicGenerating) return; const { provider, endpoint, model, apiKey } = this.currentConfig(); if (!Reader.currentBook) { toast(t('ai.infographicNeedBook')); return; } if (!apiKey) { toast(t('ai.needKey')); this.openSettings(); return; } if (!endpoint) { toast(t('ai.needEndpoint')); this.openSettings(); return; } if (!model) { toast(t('ai.needModel')); this.openSettings(); return; } localStorage.setItem('shufang_ai_provider', provider); this.saveProviderFields(provider); this.infographicGenerating = true; const btn = $('#btn-ai-infographic'); if (btn) btn.setAttribute('disabled', 'disabled'); const loading = this.append('system', t('ai.infographicGenerating'), { persist: false }); try { const chapter = await this.collectChapterMapContext(); if (!chapter || !chapter.context) throw new Error(t('ai.infographicNoContext')); const messages = this.buildInfographicMessages(chapter); let data; try { data = await this.requestAI(messages, { maxTokens: 2200, temperature: 0.18, responseFormat: { type: 'json_object' } }); } catch (err) { const msg = String(err && err.message ? err.message : err); if (!/response_format|json/i.test(msg)) throw err; data = await this.requestAI(messages, { maxTokens: 2200, temperature: 0.18 }); } const answer = data.choices && data.choices[0] && data.choices[0].message ? data.choices[0].message.content : ''; const normalized = this.normalizeInfographic(this.extractJsonObject(answer)); const dataUrl = await this.renderInfographicImage(normalized); loading.remove(); let noteId = null; try { noteId = await this.saveInfographicAsNote(dataUrl, normalized); } catch (noteErr) { console.warn('[AI] infographic note save failed', noteErr); } this.appendInfographic(dataUrl, normalized, { noteId }); this.append('system', t(noteId ? 'ai.infographicReady' : 'ai.infographicReadyNoNote'), { persist: false }); } catch (err) { loading.remove(); const message = err && err.message ? err.message : String(err); this.append('system', t('ai.infographicFailed', { message }), { persist: false }); toast(t('toast.aiFailed'), 3500); } finally { this.infographicGenerating = false; if (btn) btn.removeAttribute('disabled'); } }, async send() { if (this.sending) return; const { provider, endpoint, model, apiKey } = this.currentConfig(); const question = $('#ai-input').value.trim(); if (!apiKey) { toast(t('ai.needKey')); this.openSettings(); return; } if (!endpoint) { toast(t('ai.needEndpoint')); this.openSettings(); return; } if (!model) { toast(t('ai.needModel')); this.openSettings(); return; } if (!question) { toast(t('ai.needQuestion')); $('#ai-input').focus(); return; } localStorage.setItem('shufang_ai_provider', provider); this.saveProviderFields(provider); if (this.historyPromise) { try { await this.historyPromise; } catch (e) { console.warn('[AI] history load failed', e); } } if (typeof LongFormCompanion !== 'undefined') { try { await LongFormCompanion.ensureReady(); } catch (e) { console.warn('[AI] long memory load failed', e); } } this.currentReadingContext = this.collectReadingContext(); const hasLongMemory = typeof LongFormCompanion !== 'undefined' && LongFormCompanion.hasUsableMemory(); const wantsFullBook = !hasLongMemory && this.wantsFullBookContext(question); if (wantsFullBook) { await this.prepareLocalContext(); } const attachFullBook = wantsFullBook && Boolean(this.bookContext) && !this.bookContextTruncated; this.sending = true; const sendBtn = $('#btn-ai-send'); sendBtn.setAttribute('disabled', 'disabled'); this.append('user', question); $('#ai-input').value = ''; const loading = this.append('system', t('ai.thinking')); try { let reducedContext = false; let data; try { data = await this.requestAI(this.buildMessages(question, { attachFullBook }), { maxTokens: 1600 }); } catch (err) { const contextOverflow = Boolean(err && err.contextOverflow) || this.isContextLengthError(err && (err.rawMessage || err.message), err && err.status); if (!contextOverflow) throw err; reducedContext = true; this.currentReadingContext = this.collectReadingContext({ compact: true }); data = await this.requestAI(this.buildMessages(question, { compact: true, attachFullBook: false }), { maxTokens: 1600 }); } const answer = data.choices && data.choices[0] && data.choices[0].message ? data.choices[0].message.content : ''; loading.remove(); if (reducedContext) { this.append('system', t('ai.contextReduced'), { persist: false }); } this.append('assistant', answer || t('ai.emptyAnswer')); } catch (err) { loading.remove(); this.append('system', t('ai.failPrefix', { message: err.message || err })); toast(t('toast.aiFailed'), 3500); } finally { this.sending = false; sendBtn.removeAttribute('disabled'); } }, async warmupBookContext() { if (!Reader.currentBook) { toast(I18N.lang() === 'en' ? 'Open a book first' : '先打开一本书'); return; } if (typeof LongFormCompanion !== 'undefined') { await LongFormCompanion.ensureReady(); const record = LongFormCompanion.getRecord(); if (record && Array.isArray(record.summaries) && record.summaries.length) { const msg = record.status === 'partial' ? t('ai.longMemoryPartialStatus', { done: record.finishedBatches || record.summaries.length, total: record.totalBatches || record.summaries.length }) : t('ai.longMemoryReadyStatus', { count: record.summaries.length }); this.updateStatus(msg); this.append('system', t('ai.longMemoryFallbackReady'), { persist: false }); return; } } await this.prepareLocalContext(); if (!this.bookContext) { toast(I18N.lang() === 'en' ? 'Full-book context is not ready yet' : '全书上下文还没有准备好'); return; } if (this.bookContextTruncated) { if (typeof LongFormCompanion !== 'undefined') { const record = LongFormCompanion.getRecord(); if (record && Array.isArray(record.summaries) && record.summaries.length) { const msg = record.status === 'partial' ? t('ai.longMemoryPartialStatus', { done: record.finishedBatches || record.summaries.length, total: record.totalBatches || record.summaries.length }) : t('ai.longMemoryReadyStatus', { count: record.summaries.length }); this.updateStatus(msg); this.append('system', t('ai.longMemoryFallbackReady'), { persist: false }); return; } await LongFormCompanion.start({ source: 'warmupOverflow' }); return; } toast(t('ai.warmupContextTooLong')); return; } const chars = this.bookContext.length; this.updateStatus(t('ai.localWarmupReady', { k: Math.round(chars / 1000) })); this.append('system', t('ai.localWarmupReadyMessage'), { persist: false }); } }; const TranslationCompare = { active: false, phase: 'idle', sourceText: '', translation: '', anchor: null, location: null, requestToken: 0, layoutToken: 0, savedNoteId: null, lastError: '', tocWasOpen: false, focusWasActive: false, init() { $('#translation-compare-close')?.addEventListener('click', () => this.close()); $('#translation-copy')?.addEventListener('click', () => this.copy()); $('#translation-save-note')?.addEventListener('click', () => this.saveAsNote()); $('#translation-retry')?.addEventListener('click', () => this.retry()); document.addEventListener('keydown', (event) => { if (event.key !== 'Escape' || !this.active) return; if (document.querySelector('.modal-mask.active')) return; event.preventDefault(); this.close(); }); this.syncCopy(); }, syncCopy() { const copy = { kicker: t('translation.kicker'), title: t('translation.title'), source: t('translation.source'), close: t('translation.close'), copy: t('translation.copy'), save: t('translation.saveNote'), retry: t('translation.retry') }; if ($('#translation-compare-kicker')) $('#translation-compare-kicker').textContent = copy.kicker; if ($('#translation-compare-title')) $('#translation-compare-title').textContent = copy.title; if ($('#translation-source-label')) $('#translation-source-label').textContent = copy.source; if ($('#translation-copy')) $('#translation-copy').textContent = copy.copy; if ($('#translation-save-note')) $('#translation-save-note').textContent = copy.save; if ($('#translation-retry')) $('#translation-retry').textContent = copy.retry; const close = $('#translation-compare-close'); if (close) { close.setAttribute('aria-label', copy.close); close.setAttribute('title', copy.close); } const panel = $('#translation-compare-panel'); if (panel) { panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-modal', isMobileReaderViewport() || window.innerWidth <= 1000 ? 'true' : 'false'); panel.setAttribute('aria-labelledby', 'translation-compare-title'); } if (this.phase === 'idle') { this.renderStatus(t('translation.idle')); if ($('#translation-output')) $('#translation-output').textContent = t('translation.placeholder'); } else if (this.phase === 'loading') { this.renderStatus(t('translation.loading')); } else if (this.phase === 'ready') { this.renderStatus(t('translation.ready')); } else if (this.phase === 'error') { this.renderStatus(this.lastError || t('translation.empty')); } }, renderStatus(message) { const status = $('#translation-compare-status'); if (status) status.textContent = String(message || ''); }, setPhase(phase, message = '') { this.phase = phase; const panel = $('#translation-compare-panel'); if (panel) panel.dataset.phase = phase; if (phase === 'error') this.lastError = String(message || t('translation.empty')); else this.lastError = ''; const statusKey = phase === 'loading' ? 'translation.loading' : (phase === 'ready' ? 'translation.ready' : (phase === 'idle' ? 'translation.idle' : '')); this.renderStatus(message || (statusKey ? t(statusKey) : '')); const hasTranslation = phase === 'ready' && Boolean(this.translation); $('#translation-copy')?.toggleAttribute('disabled', !hasTranslation); $('#translation-save-note')?.toggleAttribute('disabled', !hasTranslation); $('#translation-retry')?.toggleAttribute('disabled', phase === 'loading' || !this.sourceText); }, normalizeSource(value) { return String(value || '') .replace(/\u00a0/g, ' ') .replace(/[ \t]+\n/g, '\n') .replace(/\n{3,}/g, '\n\n') .trim(); }, normalizeAnswer(value) { return String(value || '') .trim() .replace(/^```(?:\w+)?\s*/i, '') .replace(/\s*```$/i, '') .trim(); }, targetLanguage() { if (I18N.lang() === 'en') return 'natural English'; if (I18N.lang() === 'zh-Hant') return 'natural Traditional Chinese(繁體中文)'; return '自然、准确的简体中文'; }, buildMessages() { const book = Reader.currentBook || {}; const chapter = Reader.currentTocItem?.(); const target = this.targetLanguage(); return [ { role: 'system', content: [ 'You are a precise literary translation engine for a reading app.', `Translate the supplied passage into ${target}.`, 'Return only the translation. Do not add introductions, commentary, notes, headings, quotation marks, or Markdown fences.', 'Preserve paragraph breaks, names, terminology, numbers, tone, and ambiguity where appropriate.', 'If the passage is already in the target language, rewrite it into clear, natural modern language without changing its meaning.' ].join('\n') }, { role: 'user', content: [ `Book: ${book.title || 'Unknown'}`, `Author: ${book.author || 'Unknown'}`, chapter?.label ? `Chapter: ${chapter.label}` : '', '', 'Passage:', this.sourceText ].filter((line, index) => line || index >= 3).join('\n') } ]; }, resolveLocation(anchor = null) { const nested = anchor && anchor.position && typeof anchor.position === 'object' ? anchor.position : {}; const current = Reader.currentBookmarkPosition?.() || {}; const next = { ...current, ...nested, ...(anchor || {}), type: (anchor && anchor.type) || current.type || Reader.currentEngine }; delete next.position; if (next.type === 'epub' && !next.cfi) next.cfi = getCurrentEpubCfi(); if (next.type === 'pdf' && !next.page) next.page = Reader.pdfPage || 1; return next; }, async refreshReaderLayout(location, token) { await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); if (token !== this.layoutToken || !Reader.currentBook) return; try { if (Reader.currentEngine === 'epub' && Reader.ebook) { await Reader._renderEpub(location?.cfi || getCurrentEpubCfi()); } else if (Reader.currentEngine === 'pdf' && Reader.pdfDoc) { await Reader.renderPdfPage(location?.page || Reader.pdfPage || 1, { savedPosition: location || null }); } else if (Reader.isScrollableDocumentEngine?.()) { if (location) Reader.rememberScrollableDocumentPosition(location); Reader.refreshScrollableDocumentLayout(); } } catch (error) { console.warn('[TranslationCompare] reader layout refresh failed', error); } if (token !== this.layoutToken) return; if (typeof refreshPdfMinimapAroundPanelChange === 'function') refreshPdfMinimapAroundPanelChange(); }, activate(anchor = null) { const reader = $('#reader'); const panel = $('#translation-compare-panel'); if (!reader || !panel) return; if (!this.active) { this.tocWasOpen = Boolean($('#toc-panel')?.classList.contains('open')); this.focusWasActive = isReaderFocusMode(); } this.active = true; this.anchor = anchor || { type: Reader.currentEngine }; this.location = this.resolveLocation(this.anchor); setReaderFocusMode(false); reader.classList.add('translation-compare-open'); panel.classList.add('open'); panel.setAttribute('aria-hidden', 'false'); panel.setAttribute('aria-modal', isMobileReaderViewport() || window.innerWidth <= 1000 ? 'true' : 'false'); closeTocPanel({ force: true }); TocPanelPreference.sync({ ensureOpen: false, refresh: false }); $('#notes-panel')?.classList.remove('open'); $('#ai-panel')?.classList.remove('open'); $('#search-panel')?.classList.remove('open'); $('#search-bar')?.classList.remove('open'); $('#settings-popover')?.classList.remove('open'); $('#tts-panel')?.classList.remove('open'); blurActiveElementInside('#ai-panel'); blurActiveElementInside('#search-bar'); SpeechReader.stop(false); const layoutToken = ++this.layoutToken; this.refreshReaderLayout(this.location, layoutToken); if (typeof ReaderSidePanelLayout !== 'undefined') ReaderSidePanelLayout.sync(); if (typeof syncOpenStates === 'function') syncOpenStates(); }, async translate(text, anchor = null) { const normalized = this.normalizeSource(text); if (!normalized || !Reader.currentBook) return false; const truncated = normalized.length > 20000; this.sourceText = truncated ? normalized.slice(0, 20000) : normalized; this.translation = ''; this.savedNoteId = null; this.activate(anchor); $('#translation-source-text').textContent = this.sourceText; $('#translation-source-preview').open = isMobileReaderViewport() || window.innerWidth <= 1000; $('#translation-output').textContent = t('translation.loading'); this.setPhase('loading'); if (truncated) toast(t('translation.truncated'), 3600); hideSelectionMenu(); return this.request(); }, async request() { if (!this.sourceText) return false; const token = ++this.requestToken; const config = AICompanion.currentConfig(); if (!config.apiKey || !config.endpoint || !config.model) { $('#translation-output').textContent = t('translation.needSettings'); this.setPhase('error', t('translation.needSettings')); AICompanion.openSettings(); return false; } this.translation = ''; $('#translation-output').textContent = t('translation.loading'); this.setPhase('loading'); try { const data = await AICompanion.requestAI(this.buildMessages(), { maxTokens: 3200, temperature: 0.12 }); if (token !== this.requestToken || !this.active) return false; const answer = data?.choices?.[0]?.message?.content || ''; this.translation = this.normalizeAnswer(answer); if (!this.translation) throw new Error(t('translation.empty')); $('#translation-output').textContent = this.translation; this.setPhase('ready'); return true; } catch (error) { if (token !== this.requestToken || !this.active) return false; const message = error?.message || String(error); const errorText = t('translation.error', { message }); $('#translation-output').textContent = errorText; this.setPhase('error', errorText); return false; } }, retry() { if (!this.sourceText || this.phase === 'loading') return false; return this.request(); }, async copy() { if (!this.translation) return false; try { await navigator.clipboard.writeText(this.translation); toast(t('translation.copied')); return true; } catch (error) { toast(t('toast.copyFail')); return false; } }, async saveAsNote() { if (!Reader.currentBook || !this.translation) return false; if (this.savedNoteId) { toast(t('translation.saved')); return true; } const h = { bookId: Reader.currentBook.id, text: this.sourceText, note: this.translation, cfi: this.anchor?.type === 'epub' ? this.anchor.cfi || null : null, location: NoteLocations.capture(this.anchor || {}, this.sourceText), createdAt: Date.now(), source: 'ai-translation', translationTarget: this.targetLanguage() }; const id = await Store.addHighlight(h); h.id = id; this.savedNoteId = id; Stats.recordFirstHighlight(); if (h.cfi) Reader.setEpubHighlightAnnotation(h, true); await Reader.renderNotes(); toast(t('translation.saved')); return true; }, async close({ restore = true } = {}) { if (!this.active && !$('#translation-compare-panel')?.classList.contains('open')) return; const location = restore ? (Reader.currentBookmarkPosition?.() || this.location) : null; const restoreFocus = this.focusWasActive; const restoreTemporaryToc = this.tocWasOpen && !TocPanelPreference.pinned; this.active = false; this.requestToken++; const layoutToken = ++this.layoutToken; $('#reader')?.classList.remove('translation-compare-open'); const panel = $('#translation-compare-panel'); panel?.classList.remove('open'); panel?.setAttribute('aria-hidden', 'true'); if (restore) { if (restoreTemporaryToc) $('#toc-panel')?.classList.add('open'); TocPanelPreference.sync({ ensureOpen: true, refresh: false }); await this.refreshReaderLayout(location || {}, layoutToken); if (restoreFocus && $('#reader')?.classList.contains('active')) setReaderFocusMode(true); } this.tocWasOpen = false; this.focusWasActive = false; if (typeof ReaderSidePanelLayout !== 'undefined') ReaderSidePanelLayout.sync(); if (typeof syncOpenStates === 'function') syncOpenStates(); } }; const LongFormCompanion = { storageKey: 'tingread_longform_memory_v1', cacheType: 'longform-memory', schemaVersion: 2, maxBatchChars: 22000, maxQuestionMemoryChars: 52000, running: false, currentBookId: '', records: new Map(), loadedBookIds: new Set(), loadPromises: new Map(), retrievalIndexes: new Map(), migrationPromise: null, retrievalStopWords: new Set([ '这个', '那个', '这些', '那些', '一个', '一种', '一些', '什么', '怎么', '怎样', '为什么', '是否', '可以', '可能', '应该', '以及', '并且', '但是', '然后', '因为', '所以', '其中', '本书', '全书', '内容', '章节', '读者', '问题', '回答', '当前', '这里', '进行', '时候', 'the', 'and', 'that', 'this', 'with', 'from', 'what', 'when', 'where', 'which', 'about', 'book', 'chapter', 'please', 'explain', 'tell', 'does', 'have', 'into', 'your', 'their' ]), init() { if (!this.migrationPromise) { this.migrationPromise = this.migrateLegacyRecords() .catch(err => console.warn('[LongMemory] legacy migration failed', err)); } return this.migrationPromise; }, onBookOpened(book) { this.currentBookId = book && book.id ? book.id : ''; const bookId = this.currentBookId; this.ensureReady(bookId) .then(record => this.syncStatus(record, bookId)) .catch(err => console.warn('[LongMemory] load failed', err)); this.syncButton(); }, onReaderClosed() { this.currentBookId = ''; this.syncButton(); }, getBookId() { return Reader.currentBook && Reader.currentBook.id ? Reader.currentBook.id : this.currentBookId; }, cacheId(bookId) { return `${this.cacheType}:${bookId}`; }, readLegacyAll() { try { const raw = localStorage.getItem(this.storageKey); if (!raw) return {}; const parsed = JSON.parse(raw); return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}; } catch (e) { console.warn('[LongMemory] legacy data is not valid JSON; keeping it untouched', e); return null; } }, writeLegacyAll(data) { if (!data || !Object.keys(data).length) { try { localStorage.removeItem(this.storageKey); } catch (e) {} return; } try { localStorage.setItem(this.storageKey, JSON.stringify(data)); } catch (e) { console.warn('[LongMemory] legacy fallback save failed', e); } }, normalizeRecord(record, bookId = '') { if (!record || typeof record !== 'object' || Array.isArray(record)) return null; const summaries = Array.isArray(record.summaries) ? record.summaries.filter(summary => summary && typeof summary === 'object' && !Array.isArray(summary)) : []; const totalBatches = Math.max(summaries.length, Number(record.totalBatches) || 0); const finishedBatches = Math.min(totalBatches || summaries.length, Math.max(summaries.length, Number(record.finishedBatches) || 0)); return { ...record, version: this.schemaVersion, bookId: bookId || record.bookId || '', status: String(record.status || (summaries.length ? 'ready' : 'failed')), totalBatches, finishedBatches, summaries, createdAt: Number(record.createdAt) || Date.now(), updatedAt: Number(record.updatedAt) || Date.now() }; }, async migrateLegacyRecords() { const legacy = this.readLegacyAll(); if (legacy === null || !Object.keys(legacy).length) return; if (typeof Store.isPersistent === 'function' && !Store.isPersistent()) return; const remaining = {}; for (const [bookId, rawRecord] of Object.entries(legacy)) { const legacyRecord = this.normalizeRecord(rawRecord, bookId); if (!bookId || !legacyRecord) { remaining[bookId] = rawRecord; continue; } try { const existingRow = await Store.getDerivedCache(this.cacheId(bookId)); const existingRecord = this.normalizeRecord(existingRow && (existingRow.data || existingRow.record), bookId); const chosen = existingRecord && Number(existingRecord.updatedAt) >= Number(legacyRecord.updatedAt) ? existingRecord : legacyRecord; if (chosen === legacyRecord) { await Store.putDerivedCache({ id: this.cacheId(bookId), type: this.cacheType, bookId, data: legacyRecord, updatedAt: legacyRecord.updatedAt }); } this.records.set(bookId, chosen); this.loadedBookIds.add(bookId); } catch (err) { remaining[bookId] = rawRecord; console.warn('[LongMemory] could not migrate record', bookId, err); } } this.writeLegacyAll(remaining); }, async ensureReady(bookId = this.getBookId()) { if (!bookId) return null; if (this.loadedBookIds.has(bookId)) return this.records.get(bookId) || null; if (this.loadPromises.has(bookId)) return this.loadPromises.get(bookId); const promise = (async () => { await this.init(); if (this.loadedBookIds.has(bookId)) return this.records.get(bookId) || null; let record = null; try { const row = await Store.getDerivedCache(this.cacheId(bookId)); record = this.normalizeRecord(row && (row.data || row.record), bookId); if (row && !record) await Store.deleteDerivedCache(this.cacheId(bookId)); } catch (err) { console.warn('[LongMemory] IndexedDB read failed', err); } if (!record) { const legacy = this.readLegacyAll(); record = legacy && this.normalizeRecord(legacy[bookId], bookId); if (record && (typeof Store.isPersistent !== 'function' || Store.isPersistent())) { try { await Store.putDerivedCache({ id: this.cacheId(bookId), type: this.cacheType, bookId, data: record, updatedAt: record.updatedAt }); delete legacy[bookId]; this.writeLegacyAll(legacy); } catch (err) { console.warn('[LongMemory] lazy migration failed', err); } } } this.records.set(bookId, record); this.loadedBookIds.add(bookId); return record; })().finally(() => this.loadPromises.delete(bookId)); this.loadPromises.set(bookId, promise); return promise; }, getRecord(bookId = this.getBookId()) { if (!bookId) return null; if (this.records.has(bookId)) return this.records.get(bookId) || null; const legacy = this.readLegacyAll(); return legacy ? this.normalizeRecord(legacy[bookId], bookId) : null; }, async saveRecord(record, bookId = this.getBookId()) { if (!bookId || !record) return null; const normalized = this.normalizeRecord({ ...record, updatedAt: Date.now() }, bookId); if (!normalized) return null; this.records.set(bookId, normalized); this.loadedBookIds.add(bookId); this.retrievalIndexes.delete(bookId); if (typeof Store.isPersistent === 'function' && !Store.isPersistent()) { const legacy = this.readLegacyAll(); if (legacy === null) throw new Error(t('ai.longMemoryStorageFailed')); legacy[bookId] = normalized; this.writeLegacyAll(legacy); return normalized; } try { await Store.putDerivedCache({ id: this.cacheId(bookId), type: this.cacheType, bookId, data: normalized, updatedAt: normalized.updatedAt }); const legacy = this.readLegacyAll(); if (legacy && Object.prototype.hasOwnProperty.call(legacy, bookId)) { delete legacy[bookId]; this.writeLegacyAll(legacy); } return normalized; } catch (err) { throw new Error(t('ai.longMemoryStorageFailed')); } }, async deleteRecord(bookId = this.getBookId()) { if (!bookId) return; this.records.delete(bookId); this.loadedBookIds.add(bookId); this.retrievalIndexes.delete(bookId); await Store.deleteDerivedCache(this.cacheId(bookId)).catch(err => console.warn('[LongMemory] delete failed', err)); const legacy = this.readLegacyAll(); if (legacy && Object.prototype.hasOwnProperty.call(legacy, bookId)) { delete legacy[bookId]; this.writeLegacyAll(legacy); } }, forgetCachedRecord(bookId) { if (!bookId) return; this.records.delete(bookId); this.loadedBookIds.delete(bookId); this.loadPromises.delete(bookId); this.retrievalIndexes.delete(bookId); }, clearCachedRecords() { this.records.clear(); this.loadedBookIds.clear(); this.loadPromises.clear(); this.retrievalIndexes.clear(); }, hasUsableMemory(bookId = this.getBookId()) { const record = this.getRecord(bookId); return Boolean(record && Array.isArray(record.summaries) && record.summaries.length); }, syncStatus(record = this.getRecord(), bookId = this.getBookId()) { if (!bookId || bookId !== this.getBookId() || !record || !Array.isArray(record.summaries) || !record.summaries.length) return; const message = record.status === 'partial' ? t('ai.longMemoryPartialStatus', { done: record.finishedBatches || record.summaries.length, total: record.totalBatches || record.summaries.length }) : t('ai.longMemoryReadyStatus', { count: record.summaries.length }); AICompanion.updateStatus(message); }, syncButton() { // 长篇记忆不再常驻为按钮,只在预热全书溢出时由确认弹窗触发。 }, formatTokens(value) { const n = Math.max(0, Math.round(Number(value) || 0)); return n.toLocaleString('zh-CN'); }, formatMoney(value) { const n = Math.max(0, Number(value) || 0); if (n >= 10) return n.toFixed(1); if (n >= 1) return n.toFixed(2); return n.toFixed(3); }, estimateTokens(text = '') { const source = String(text || ''); if (!source) return 0; const cjk = (source.match(/[\u3400-\u9FFF]/gu) || []).length; const rest = Math.max(0, source.length - cjk); return Math.ceil(cjk * 0.6 + rest * 0.28); }, priceProfile(config = AICompanion.currentConfig()) { if (!config || config.provider !== 'deepseek') return null; const model = String(config.model || '').toLowerCase(); if (model.includes('pro')) return { inputCnyPerM: 3, outputCnyPerM: 6, label: 'DeepSeek V4 Pro' }; return { inputCnyPerM: 1, outputCnyPerM: 2, label: 'DeepSeek V4 Flash' }; }, estimateForBatches(batches, config = AICompanion.currentConfig()) { const inputTokens = batches.reduce((sum, batch) => sum + this.estimateTokens(batch.text) + 260, 0); const outputTokens = Math.ceil(Math.min(batches.length * 900, Math.max(batches.length * 360, inputTokens * 0.075))); const profile = this.priceProfile(config); if (!profile) { return { inputTokens, outputTokens, profile: null, cny: null, budgetCny: null }; } const cny = (inputTokens / 1000000) * profile.inputCnyPerM + (outputTokens / 1000000) * profile.outputCnyPerM; const totalTokens = inputTokens + outputTokens; const lengthFloorCny = Math.max(1, Math.ceil(totalTokens / 500000) * 0.5); const budgetCny = Math.max(1, lengthFloorCny, cny * 2); return { inputTokens, outputTokens, profile, cny, budgetCny }; }, costFromUsage(usage = {}, config = AICompanion.currentConfig()) { const profile = this.priceProfile(config); if (!profile || !usage) return { cny: 0 }; const inputTokens = Number(usage.prompt_tokens || usage.input_tokens || 0); const outputTokens = Number(usage.completion_tokens || usage.output_tokens || 0); const cny = (inputTokens / 1000000) * profile.inputCnyPerM + (outputTokens / 1000000) * profile.outputCnyPerM; return { cny }; }, buildBatches(index = []) { const batches = []; let current = []; let currentChars = 0; let startLabel = ''; const flush = () => { if (!current.length) return; const endLabel = current[current.length - 1].label || startLabel; batches.push({ id: `part-${batches.length + 1}`, label: startLabel && endLabel && startLabel !== endLabel ? `${startLabel} - ${endLabel}` : (startLabel || endLabel || `Part ${batches.length + 1}`), text: current.map(item => `\n\n[${item.label || item.location || '正文'}]\n${item.text}`).join('').trim(), sourceCount: current.length }); current = []; currentChars = 0; startLabel = ''; }; (index || []).forEach(item => { const text = String(item && item.chunk || '').replace(/\s+/g, ' ').trim(); if (!text) return; const label = String(item.label || item.location || `Part ${batches.length + 1}`).trim(); if (text.length > this.maxBatchChars) { flush(); for (let offset = 0; offset < text.length; offset += this.maxBatchChars) { const slice = text.slice(offset, offset + this.maxBatchChars); batches.push({ id: `part-${batches.length + 1}`, label: `${label} · ${Math.floor(offset / this.maxBatchChars) + 1}`, text: `[${label}]\n${slice}`, sourceCount: 1 }); } return; } if (current.length && currentChars + text.length > this.maxBatchChars) flush(); if (!startLabel) startLabel = label; current.push({ label, text }); currentChars += text.length; }); flush(); return batches; }, async start(options = {}) { if (this.running) return; const { provider, endpoint, model, apiKey } = AICompanion.currentConfig(); if (!Reader.currentBook) { toast(t('ai.longMemoryNeedBook')); return; } if (!apiKey) { toast(t('ai.needKey')); AICompanion.openSettings(); return; } if (!endpoint) { toast(t('ai.needEndpoint')); AICompanion.openSettings(); return; } if (!model) { toast(t('ai.needModel')); AICompanion.openSettings(); return; } AICompanion.updateStatus(t('ai.longMemoryPreparing')); await this.ensureReady(); const index = await Reader.buildSearchIndex(); const batches = this.buildBatches(index); if (!batches.length) { toast(t('ai.longMemoryNoText')); return; } const previous = this.getRecord(); let resumeRecord = null; let startIndex = 0; if (previous && Array.isArray(previous.summaries) && previous.summaries.length) { const canResume = previous.status === 'partial' && previous.totalBatches === batches.length && previous.summaries.length < batches.length; if (canResume) { resumeRecord = previous; startIndex = previous.summaries.length; } else { const rebuild = await openConfirmDialog({ kicker: t('ai.longMemoryConfirmKicker'), title: t('ai.longMemoryConfirmTitle'), message: t('ai.longMemoryRebuildConfirm'), confirmText: t('ai.longMemoryConfirmAction'), cancelText: t('modal.cancel') }); if (!rebuild) return; } } const config = { provider, endpoint, model, apiKey }; const estimate = this.estimateForBatches(batches.slice(startIndex), config); const estimateText = estimate.profile ? t('ai.longMemoryEstimateDeepSeek', { inputTokens: this.formatTokens(estimate.inputTokens), outputTokens: this.formatTokens(estimate.outputTokens), cny: this.formatMoney(estimate.cny), budget: this.formatMoney(estimate.budgetCny) }) : t('ai.longMemoryEstimateUnknown', { inputTokens: this.formatTokens(estimate.inputTokens), outputTokens: this.formatTokens(estimate.outputTokens) }); const fromWarmupOverflow = options && options.source === 'warmupOverflow'; const ok = await openConfirmDialog({ kicker: t('ai.longMemoryConfirmKicker'), title: t(fromWarmupOverflow ? 'ai.longMemoryFallbackTitle' : 'ai.longMemoryConfirmTitle'), message: `${t(fromWarmupOverflow ? 'ai.longMemoryFallbackBody' : 'ai.longMemoryConfirmBody')}\n\n${resumeRecord ? `${t('ai.longMemoryResumeConfirm', { next: startIndex + 1, total: batches.length })}\n\n` : ''}${estimateText}`, confirmText: t('ai.longMemoryConfirmAction'), cancelText: t('modal.cancel') }); if (!ok) return; localStorage.setItem('shufang_ai_provider', provider); AICompanion.saveProviderFields(provider); await this.runBatches(batches, config, estimate, resumeRecord); }, async runBatches(batches, config, estimate, resumeRecord = null) { const book = Reader.currentBook || {}; const record = { version: this.schemaVersion, status: 'running', bookTitle: book.title || '', author: book.author || '', format: book.format || '', totalBatches: batches.length, finishedBatches: resumeRecord && Array.isArray(resumeRecord.summaries) ? resumeRecord.summaries.length : 0, summaries: resumeRecord && Array.isArray(resumeRecord.summaries) ? [...resumeRecord.summaries] : [], estimate, usage: resumeRecord && resumeRecord.usage ? { ...resumeRecord.usage } : { inputTokens: 0, outputTokens: 0, cny: 0 }, createdAt: resumeRecord && resumeRecord.createdAt ? resumeRecord.createdAt : Date.now() }; this.running = true; this.syncButton(); const runStartCny = Number(record.usage.cny || 0); const loading = AICompanion.append('system', t('ai.longMemoryRunning', { done: record.finishedBatches, total: batches.length }), { persist: false }); try { for (let i = record.summaries.length; i < batches.length; i++) { AICompanion.updateStatus(t('ai.longMemoryRunning', { done: i + 1, total: batches.length })); if (loading) loading.textContent = t('ai.longMemoryRunning', { done: i + 1, total: batches.length }); const result = await this.summarizeBatch(batches[i], i, batches.length, config); record.summaries.push(result.summary); record.finishedBatches = i + 1; if (result.usage) { record.usage.inputTokens += Number(result.usage.prompt_tokens || result.usage.input_tokens || 0); record.usage.outputTokens += Number(result.usage.completion_tokens || result.usage.output_tokens || 0); const spent = this.costFromUsage(result.usage, config); record.usage.cny += spent.cny; } await this.saveRecord(record); if (estimate.budgetCny && record.usage.cny - runStartCny > estimate.budgetCny) { record.status = 'partial'; await this.saveRecord(record); const msg = t('ai.longMemoryBudgetPaused'); AICompanion.updateStatus(t('ai.longMemoryPartialStatus', { done: record.finishedBatches, total: batches.length })); AICompanion.append('system', msg, { persist: false }); toast(msg, 4200); return; } if (i % 3 === 2) await yieldToBrowser(); } record.status = 'ready'; record.updatedAt = Date.now(); await this.saveRecord(record); const msg = t('ai.longMemoryReady', { count: record.summaries.length }); AICompanion.updateStatus(msg); AICompanion.append('system', msg, { persist: false }); toast(msg, 2800); } catch (err) { record.status = record.summaries.length ? 'partial' : 'failed'; record.error = err && err.message ? err.message : String(err); try { await this.saveRecord(record); } catch (saveErr) { console.warn('[LongMemory] failed to persist error state', saveErr); } const msg = t('ai.longMemoryFailed', { message: record.error }); AICompanion.updateStatus(msg); AICompanion.append('system', msg, { persist: false }); toast(msg, 4200); } finally { if (loading && loading.parentNode) loading.remove(); this.running = false; this.syncButton(); } }, async summarizeBatch(batch, index, total, config) { const messages = this.buildSummaryMessages(batch, index, total); let data; try { data = await AICompanion.requestAI(messages, { maxTokens: 900, temperature: 0.16, responseFormat: { type: 'json_object' } }); } catch (err) { const msg = String(err && err.message ? err.message : err); if (!/response_format|json/i.test(msg)) throw err; data = await AICompanion.requestAI(messages, { maxTokens: 900, temperature: 0.16 }); } const answer = data.choices && data.choices[0] && data.choices[0].message ? data.choices[0].message.content : ''; const parsed = AICompanion.extractJsonObject(answer); return { summary: this.normalizeSummary(parsed, answer, batch, index, total), usage: data.usage || null }; }, buildSummaryMessages(batch, index, total) { const lang = aiOutputLanguageName(); const book = Reader.currentBook || {}; return [ { role: 'system', content: [ 'You create durable reading memory for a long novel or long ebook.', 'Return JSON only. Do not include Markdown, code fences, comments, or explanations.', `Write visible fields in ${lang}.`, 'Schema: {"summary":"string","people":["name"],"places":["name"],"events":["short event"],"clues":["open clue"],"keywords":["word"]}.', 'Keep it compact and faithful. Do not invent facts beyond the provided text. Preserve names as they appear in the source.', 'Limits: summary under 220 Chinese chars or 120 English words; each array at most 10 items; each item concise.' ].join('\n') }, { role: 'user', content: [ `Book: ${book.title || ''}`, `Author: ${book.author || ''}`, `Part: ${index + 1} / ${total}`, `Label: ${batch.label || ''}`, 'Text:', batch.text ].join('\n\n') } ]; }, normalizeArray(value, max = 10) { return (Array.isArray(value) ? value : []) .map(item => String(item && typeof item === 'object' ? (item.name || item.text || item.value || item.title || '') : item || '').replace(/\s+/g, ' ').trim()) .filter(Boolean) .filter((item, index, list) => list.findIndex(other => other.toLocaleLowerCase() === item.toLocaleLowerCase()) === index) .slice(0, max); }, normalizeSummary(parsed, answer, batch, index, total) { const raw = parsed && typeof parsed === 'object' ? parsed : {}; let summary = String(raw.summary || raw.abstract || raw.content || '').replace(/\s+/g, ' ').trim(); if (!summary) summary = String(answer || '').replace(/```json|```/gi, '').replace(/\s+/g, ' ').trim().slice(0, 360); return { id: batch.id || `part-${index + 1}`, index, total, label: batch.label || `Part ${index + 1}`, sourceCount: batch.sourceCount || 1, summary: summary.slice(0, 520), people: this.normalizeArray(raw.people || raw.persons || raw.characters, 12), places: this.normalizeArray(raw.places || raw.locations || raw.placeNames, 12), events: this.normalizeArray(raw.events || raw.plot || raw.actions, 12), clues: this.normalizeArray(raw.clues || raw.openQuestions || raw.foreshadowing, 8), keywords: this.normalizeArray(raw.keywords || raw.tags, 10) }; }, normalizeRetrievalText(value = '') { let text = String(value || ''); try { text = text.normalize('NFKC'); } catch (e) {} return text .toLocaleLowerCase() .replace(/[^\p{L}\p{N}'-]+/gu, ' ') .replace(/\s+/g, ' ') .trim(); }, stemEnglishToken(token = '') { const word = String(token || '').toLocaleLowerCase(); if (word.length > 5 && word.endsWith('ies')) return `${word.slice(0, -3)}y`; if (word.length > 5 && word.endsWith('ing')) return word.slice(0, -3); if (word.length > 4 && word.endsWith('ed')) return word.slice(0, -2); if (word.length > 4 && word.endsWith('es')) return word.slice(0, -2); if (word.length > 3 && word.endsWith('s')) return word.slice(0, -1); return word; }, tokenizeRetrieval(value = '', { maxTokens = 1800 } = {}) { const normalized = this.normalizeRetrievalText(value); if (!normalized) return []; const tokens = []; const push = token => { const value = String(token || '').trim(); if (!value || this.retrievalStopWords.has(value) || tokens.length >= maxTokens) return; tokens.push(value); }; const cjkRuns = normalized.match(/[\u3400-\u9FFF]+/gu) || []; for (const run of cjkRuns) { if (run.length >= 2 && run.length <= 10) push(run); for (const size of [2, 3]) { if (run.length < size) continue; for (let index = 0; index <= run.length - size && tokens.length < maxTokens; index += 1) { push(run.slice(index, index + size)); } } if (tokens.length >= maxTokens) break; } const latinWords = normalized.match(/[a-z][a-z0-9'-]{1,}/g) || []; for (const word of latinWords) { push(word); const stem = this.stemEnglishToken(word); if (stem !== word) push(stem); if (tokens.length >= maxTokens) break; } const numbers = normalized.match(/\b\d{1,6}\b/g) || []; numbers.forEach(push); return tokens; }, summaryRetrievalText(summary = {}) { const repeat = (value, count) => Array.from({ length: count }, () => String(value || '')).join(' '); const people = (summary.people || []).join(' '); const places = (summary.places || []).join(' '); const keywords = (summary.keywords || []).join(' '); return [ repeat(summary.label, 4), repeat(summary.summary, 2), repeat(people, 4), repeat(places, 4), repeat(keywords, 3), repeat((summary.events || []).join(' '), 2), (summary.clues || []).join(' ') ].filter(Boolean).join(' '); }, buildRetrievalIndex(record, bookId = this.getBookId()) { const summaries = record && Array.isArray(record.summaries) ? record.summaries : []; const signature = `${Number(record && record.updatedAt) || 0}:${summaries.length}:${Number(record && record.finishedBatches) || 0}`; const cached = bookId ? this.retrievalIndexes.get(bookId) : null; if (cached && cached.signature === signature) return cached; const documentFrequency = new Map(); const documents = summaries.map((summary, position) => { const tokens = this.tokenizeRetrieval(this.summaryRetrievalText(summary), { maxTokens: 2400 }); const frequencies = new Map(); tokens.forEach(token => frequencies.set(token, (frequencies.get(token) || 0) + 1)); frequencies.forEach((count, token) => { documentFrequency.set(token, (documentFrequency.get(token) || 0) + 1); }); return { summary, position, frequencies, length: Math.max(1, tokens.length) }; }); const averageLength = documents.length ? documents.reduce((sum, document) => sum + document.length, 0) / documents.length : 1; const index = { signature, documents, documentFrequency, averageLength }; if (bookId) this.retrievalIndexes.set(bookId, index); return index; }, retrievalTokenWeight(token = '') { if (/^[\u3400-\u9FFF]+$/u.test(token)) { if (token.length >= 3) return 1.2; return 0.88; } return token.length >= 6 ? 1.12 : 1; }, scoreRetrievalDocument(document, queryTokens, index, compactQuery = '', chapterNeedle = '') { const totalDocuments = Math.max(1, index.documents.length); const k1 = 1.35; const b = 0.72; let score = 0; queryTokens.forEach(token => { const frequency = document.frequencies.get(token) || 0; if (!frequency) return; const frequencyInDocuments = index.documentFrequency.get(token) || 0; const inverseFrequency = Math.log(1 + (totalDocuments - frequencyInDocuments + 0.5) / (frequencyInDocuments + 0.5)); const lengthNorm = frequency + k1 * (1 - b + b * (document.length / index.averageLength)); score += inverseFrequency * ((frequency * (k1 + 1)) / lengthNorm) * this.retrievalTokenWeight(token); }); const summary = document.summary || {}; const exactBoost = (values, boost) => { (values || []).forEach(value => { const normalized = this.normalizeRetrievalText(value).replace(/\s+/g, ''); if (normalized.length >= 2 && compactQuery.includes(normalized)) score += boost; }); }; exactBoost(summary.people, 18); exactBoost(summary.places, 16); exactBoost(summary.keywords, 7); const label = this.normalizeRetrievalText(summary.label).replace(/\s+/g, ''); if (chapterNeedle && label && (label.includes(chapterNeedle) || chapterNeedle.includes(label))) { score += 28; } return score; }, chapterOrdinals(value = '') { const normalized = this.normalizeRetrievalText(value); const values = []; const pattern = /第\s*(\d{1,7})\s*[章节回篇卷]|(?:chapter|ch)\s*(\d{1,7})/gi; let match; while ((match = pattern.exec(normalized))) { const number = Number(match[1] || match[2]); if (Number.isFinite(number)) values.push(number); } return values; }, chapterSummaryPosition(summaries = [], chapter = '') { const chapterLabel = typeof AICompanion !== 'undefined' && typeof AICompanion.chapterLabelPart === 'function' ? AICompanion.chapterLabelPart(chapter) : ''; const needle = this.normalizeRetrievalText(chapterLabel || chapter).replace(/\s+/g, ''); if (!needle) return -1; const exactPosition = summaries.findIndex(summary => { const label = this.normalizeRetrievalText(summary && summary.label).replace(/\s+/g, ''); return label && (label.includes(needle) || needle.includes(label)); }); if (exactPosition >= 0) return exactPosition; const target = this.chapterOrdinals(chapterLabel || chapter)[0]; if (!Number.isFinite(target)) return -1; return summaries.findIndex(summary => { const ordinals = this.chapterOrdinals(summary && summary.label); if (!ordinals.length) return false; return target >= Math.min(...ordinals) && target <= Math.max(...ordinals); }); }, isOverviewQuestion(question = '') { const text = this.normalizeRetrievalText(question); return /全书|整本|主要内容|核心观点|核心内容|总体|概括|总结|梳理|讲了什么|overview|whole book|main idea|summar|overall/.test(text); }, isWholeBookQuestion(question = '') { const text = this.normalizeRetrievalText(question); return /全书|整本|整部|全篇|总体|整体内容|whole book|entire book|overall book/.test(text); }, evenlySpacedPositions(total, maxItems = 14) { const count = Math.min(Math.max(0, total), Math.max(1, maxItems)); if (!count) return []; if (count === total) return Array.from({ length: total }, (_, index) => index); const positions = new Set(); for (let index = 0; index < count; index += 1) { positions.add(Math.round((index * (total - 1)) / Math.max(1, count - 1))); } return [...positions]; }, selectQuestionSummaries(record, question = '', options = {}) { const summaries = record && Array.isArray(record.summaries) ? record.summaries : []; if (!summaries.length) return []; const selectedText = String(options.selectedText || '').slice(0, 5000); const chapter = String(options.chapter || ''); if (this.isWholeBookQuestion(question) || (this.isOverviewQuestion(question) && !selectedText && !chapter)) { return this.evenlySpacedPositions(summaries.length, 16).map(position => summaries[position]); } const index = this.buildRetrievalIndex(record); const queryText = [question, selectedText, chapter].filter(Boolean).join('\n'); const queryTokens = [...new Set(this.tokenizeRetrieval(queryText, { maxTokens: 260 }))]; const compactQuery = this.normalizeRetrievalText(queryText).replace(/\s+/g, ''); const chapterLabel = typeof AICompanion !== 'undefined' && typeof AICompanion.chapterLabelPart === 'function' ? AICompanion.chapterLabelPart(chapter) : ''; const chapterNeedle = this.normalizeRetrievalText(chapterLabel || chapter).replace(/\s+/g, ''); const chapterPosition = this.chapterSummaryPosition(summaries, chapter); const ranked = index.documents .map(document => ({ ...document, score: this.scoreRetrievalDocument(document, queryTokens, index, compactQuery, chapterNeedle) })) .sort((a, b) => b.score - a.score || a.position - b.position); const strong = ranked.filter(item => item.score >= 1.2).slice(0, 10); const positions = []; const seen = new Set(); const add = position => { if (position < 0 || position >= summaries.length || seen.has(position) || positions.length >= 18) return; seen.add(position); positions.push(position); }; if (chapterPosition >= 0) { add(chapterPosition); add(chapterPosition - 1); add(chapterPosition + 1); } strong.forEach((item, rank) => { add(item.position); if (rank < 6) { add(item.position - 1); add(item.position + 1); } }); if (!positions.length) { this.evenlySpacedPositions(summaries.length, 14).forEach(add); } return positions.sort((a, b) => a - b).map(position => summaries[position]); }, collectUnique(summaries, key, max = 80) { const out = []; const seen = new Set(); summaries.forEach(summary => { (summary[key] || []).forEach(item => { const name = String(item || '').trim(); const lower = name.toLocaleLowerCase(); if (!name || seen.has(lower)) return; seen.add(lower); out.push(name); }); }); return out.slice(0, max); }, composeQuestionMemory(question = '', options = {}) { const record = this.getRecord(); const summaries = record && Array.isArray(record.summaries) ? record.summaries : []; if (!summaries.length) return ''; const selectedText = options.selectedText || ''; const chapter = options.chapter || ''; const maxChars = options.maxChars || this.maxQuestionMemoryChars; const chosen = this.selectQuestionSummaries(record, question, { selectedText, chapter }); const people = this.collectUnique(chosen, 'people', 36); const places = this.collectUnique(chosen, 'places', 36); const lines = [ '这是本书的长篇伴读记忆。回答用户问题时,请优先依据这里的摘要、人物、地点、事件和线索;如果记忆不足,请明确说明并结合当前页原文。', `书名:${record.bookTitle || (Reader.currentBook && Reader.currentBook.title) || ''}`, record.author ? `作者:${record.author}` : '', `记忆状态:${record.status || 'ready'};已梳理 ${record.finishedBatches || summaries.length} / ${record.totalBatches || summaries.length} 段。`, people.length ? `人物表:${people.join('、')}` : '', places.length ? `地点表:${places.join('、')}` : '', '相关章节记忆(本地混合检索):' ].filter(Boolean); for (const item of chosen) { const block = [ `\n[${item.label || `Part ${item.index + 1}`}]`, item.summary ? `摘要:${item.summary}` : '', item.people && item.people.length ? `人物:${item.people.join('、')}` : '', item.places && item.places.length ? `地点:${item.places.join('、')}` : '', item.events && item.events.length ? `事件:${item.events.join(';')}` : '', item.clues && item.clues.length ? `线索:${item.clues.join(';')}` : '' ].filter(Boolean).join('\n'); if (lines.join('\n').length + block.length > maxChars) break; lines.push(block); } return lines.join('\n'); } }; const EntityColorMarker = { storageKey: 'tingread_entity_color_marks_v1', settingsKey: 'tingread_entity_color_settings_v1', maxTermsPerType: 120, maxCustomPairs: 12, maxContextChars: 180000, enabled: false, extracting: false, bookId: '', entities: { people: [], places: [] }, applyTimer: null, settingsModalMode: 'settings', emptyEntities() { return { people: [], places: [] }; }, getBookId() { return Reader.currentBook && Reader.currentBook.id ? Reader.currentBook.id : ''; }, loadAll() { try { return JSON.parse(localStorage.getItem(this.storageKey) || '{}') || {}; } catch (e) { return {}; } }, saveAll(data) { try { localStorage.setItem(this.storageKey, JSON.stringify(data || {})); } catch (e) {} }, defaultSettings() { const dark = Settings.data.theme === 'dark'; return { customSymbols: '', customColor: dark ? '#f0a15a' : '#d46a1f', personColor: dark ? '#55a8df' : '#2f83c5', placeColor: dark ? '#38b987' : '#21996f' }; }, loadSettings() { const defaults = this.defaultSettings(); try { const parsed = JSON.parse(localStorage.getItem(this.settingsKey) || '{}') || {}; return { customSymbols: String(parsed.customSymbols || '').slice(0, 180), customColor: this.sanitizeColor(parsed.customColor) || defaults.customColor, personColor: this.sanitizeColor(parsed.personColor) || defaults.personColor, placeColor: this.sanitizeColor(parsed.placeColor) || defaults.placeColor }; } catch (e) { return defaults; } }, saveSettings(settings) { const defaults = this.defaultSettings(); const next = { customSymbols: String(settings && settings.customSymbols || '').slice(0, 180), customColor: this.sanitizeColor(settings && settings.customColor) || defaults.customColor, personColor: this.sanitizeColor(settings && settings.personColor) || defaults.personColor, placeColor: this.sanitizeColor(settings && settings.placeColor) || defaults.placeColor }; try { localStorage.setItem(this.settingsKey, JSON.stringify(next)); } catch (e) {} return next; }, sanitizeColor(value) { const text = String(value || '').trim(); if (/^#[0-9a-f]{6}$/i.test(text)) return text; if (/^#[0-9a-f]{3}$/i.test(text)) { return '#' + text.slice(1).split('').map(ch => ch + ch).join(''); } return ''; }, openSettingsModal(options = {}) { const mask = $('#entity-settings-mask'); if (!mask) return; this.settingsModalMode = options && options.mode === 'enable' ? 'enable' : 'settings'; const settings = this.loadSettings(); const symbols = $('#entity-symbols-input'); const colorText = $('#entity-color-text'); const colorPicker = $('#entity-color-picker'); const personColorText = $('#entity-person-color-text'); const personColorPicker = $('#entity-person-color-picker'); const placeColorText = $('#entity-place-color-text'); const placeColorPicker = $('#entity-place-color-picker'); const saveBtn = $('#entity-settings-save'); if (symbols) symbols.value = settings.customSymbols; if (colorText) colorText.value = settings.customColor; if (colorPicker) colorPicker.value = settings.customColor; if (personColorText) personColorText.value = settings.personColor; if (personColorPicker) personColorPicker.value = settings.personColor; if (placeColorText) placeColorText.value = settings.placeColor; if (placeColorPicker) placeColorPicker.value = settings.placeColor; if (saveBtn) saveBtn.textContent = this.settingsModalMode === 'enable' ? t('ai.entityColorsConfirmAction') : t('modal.save'); mask.classList.add('active'); setTimeout(() => symbols?.focus(), 30); syncOpenStates(); }, closeSettingsModal() { $('#entity-settings-mask')?.classList.remove('active'); this.settingsModalMode = 'settings'; const saveBtn = $('#entity-settings-save'); if (saveBtn) saveBtn.textContent = t('modal.save'); syncOpenStates(); }, async saveSettingsFromModal() { const mode = this.settingsModalMode || 'settings'; const colorText = $('#entity-color-text'); const colorPicker = $('#entity-color-picker'); const personColorText = $('#entity-person-color-text'); const personColorPicker = $('#entity-person-color-picker'); const placeColorText = $('#entity-place-color-text'); const placeColorPicker = $('#entity-place-color-picker'); const defaults = this.defaultSettings(); const rawColor = this.sanitizeColor(colorText?.value) || this.sanitizeColor(colorPicker?.value) || defaults.customColor; const rawPersonColor = this.sanitizeColor(personColorText?.value) || this.sanitizeColor(personColorPicker?.value) || defaults.personColor; const rawPlaceColor = this.sanitizeColor(placeColorText?.value) || this.sanitizeColor(placeColorPicker?.value) || defaults.placeColor; const settings = this.saveSettings({ customSymbols: $('#entity-symbols-input')?.value || '', customColor: rawColor, personColor: rawPersonColor, placeColor: rawPlaceColor }); if (colorText) colorText.value = settings.customColor; if (colorPicker) colorPicker.value = settings.customColor; if (personColorText) personColorText.value = settings.personColor; if (personColorPicker) personColorPicker.value = settings.personColor; if (placeColorText) placeColorText.value = settings.placeColor; if (placeColorPicker) placeColorPicker.value = settings.placeColor; this.closeSettingsModal(); if (mode === 'enable') { await this.enableAfterSettings(); return; } if (this.enabled) this.scheduleApply(); toast(t('ai.entitySettingsSaved'), 1800); }, resetSettingsFromModal() { const settings = this.saveSettings(this.defaultSettings()); const symbols = $('#entity-symbols-input'); const colorText = $('#entity-color-text'); const colorPicker = $('#entity-color-picker'); const personColorText = $('#entity-person-color-text'); const personColorPicker = $('#entity-person-color-picker'); const placeColorText = $('#entity-place-color-text'); const placeColorPicker = $('#entity-place-color-picker'); if (symbols) symbols.value = settings.customSymbols; if (colorText) colorText.value = settings.customColor; if (colorPicker) colorPicker.value = settings.customColor; if (personColorText) personColorText.value = settings.personColor; if (personColorPicker) personColorPicker.value = settings.personColor; if (placeColorText) placeColorText.value = settings.placeColor; if (placeColorPicker) placeColorPicker.value = settings.placeColor; if (this.enabled) this.scheduleApply(); toast(t('ai.entitySettingsReset'), 1800); }, getRecord(bookId = this.getBookId()) { if (!bookId) return null; const all = this.loadAll(); return all && all[bookId] ? all[bookId] : null; }, saveRecord(record, bookId = this.getBookId()) { if (!bookId) return; const all = this.loadAll(); all[bookId] = { ...(record || {}), updatedAt: Date.now() }; this.saveAll(all); }, onBookOpened(book) { this.bookId = book && book.id ? book.id : ''; const record = this.getRecord(this.bookId); this.entities = this.normalizeEntities(record || {}); this.enabled = Boolean(record && record.enabled); this.syncButton(); if (this.enabled) this.scheduleApply(); else this.clearCurrent(); }, onReaderClosed() { this.clearCurrent(); this.bookId = ''; this.enabled = false; this.entities = this.emptyEntities(); this.syncButton(); }, syncButton() { const btn = $('#btn-ai-entity-colors'); if (!btn) return; btn.classList.toggle('active', Boolean(this.enabled)); btn.setAttribute('aria-pressed', this.enabled ? 'true' : 'false'); btn.title = this.enabled ? t('ai.entityColorsOn') : t('ai.entityColorsTitle'); btn.setAttribute('aria-label', btn.title); btn.disabled = Boolean(this.extracting); }, hasEntities(entities = this.entities) { return Boolean((entities.people && entities.people.length) || (entities.places && entities.places.length)); }, async toggle() { if (this.extracting) return; if (!Reader.currentBook) { toast(t('ai.entityColorsNeedBook')); return; } if (this.enabled) { this.enabled = false; this.saveRecord({ ...this.entities, enabled: false }); this.clearCurrent(); this.syncButton(); toast(t('ai.entityColorsOff'), 1600); return; } this.openSettingsModal({ mode: 'enable' }); }, async enableAfterSettings() { if (this.extracting || !Reader.currentBook) return; const cached = this.normalizeEntities(this.getRecord() || {}); if (this.hasEntities(cached)) { this.entities = cached; this.enabled = true; this.saveRecord({ ...this.entities, enabled: true }); this.scheduleApply(); this.syncButton(); toast(t('ai.entityColorsReady', { people: this.entities.people.length, places: this.entities.places.length }), 2200); return; } this.entities = this.emptyEntities(); this.enabled = true; this.saveRecord({ ...this.entities, enabled: true }); this.scheduleApply(); this.syncButton(); toast(t('ai.entityColorsLocalReady'), 2200); const { endpoint, model, apiKey } = AICompanion.currentConfig(); if (apiKey && endpoint && model) await this.extractAndApply(); }, async extractAndApply() { const { provider, endpoint, model, apiKey } = AICompanion.currentConfig(); if (!apiKey) { toast(t('ai.needKey')); AICompanion.openSettings(); return; } if (!endpoint) { toast(t('ai.needEndpoint')); AICompanion.openSettings(); return; } if (!model) { toast(t('ai.needModel')); AICompanion.openSettings(); return; } const providerName = AICompanion.providerLabel(provider); const ok = await openConfirmDialog({ kicker: t('ai.entityColorsConfirmKicker'), title: t('ai.entityColorsConfirmTitle'), message: `${t('ai.entityColorsConfirm')}\n\n${t('ai.warmupDestination', { provider: providerName, model })}`, confirmText: t('ai.entityColorsConfirmAction'), cancelText: t('modal.cancel') }); if (!ok) return; localStorage.setItem('shufang_ai_provider', provider); AICompanion.saveProviderFields(provider); const btn = $('#btn-ai-entity-colors'); this.extracting = true; this.syncButton(); if (btn) btn.setAttribute('disabled', 'disabled'); AICompanion.updateStatus(t('ai.entityColorsPreparing')); const loading = AICompanion.append('system', t('ai.entityColorsPreparing'), { persist: false }); try { const context = await this.buildExtractionContext(); if (!context) throw new Error(t('ai.entityColorsNoContext')); const messages = this.buildExtractionMessages(context); let data; try { data = await AICompanion.requestAI(messages, { maxTokens: 2200, temperature: 0.12, responseFormat: { type: 'json_object' } }); } catch (err) { const msg = String(err && err.message ? err.message : err); if (!/response_format|json/i.test(msg)) throw err; data = await AICompanion.requestAI(messages, { maxTokens: 2200, temperature: 0.12 }); } const answer = data.choices && data.choices[0] && data.choices[0].message ? data.choices[0].message.content : ''; const parsed = AICompanion.extractJsonObject(answer); const entities = this.normalizeEntities(parsed); if (!this.hasEntities(entities)) { this.entities = this.emptyEntities(); this.enabled = true; this.saveRecord({ enabled: true, people: [], places: [], provider, model }); this.scheduleApply(); loading.remove(); const localReady = t('ai.entityColorsLocalReady'); AICompanion.updateStatus(localReady); AICompanion.append('system', `${localReady}\n${t('ai.entityColorsNoEntities')}`, { persist: false }); toast(localReady, 2400); return; } this.entities = entities; this.enabled = true; this.saveRecord({ enabled: true, people: entities.people, places: entities.places, provider, model }); this.scheduleApply(); loading.remove(); const ready = t('ai.entityColorsReady', { people: entities.people.length, places: entities.places.length }); AICompanion.updateStatus(ready); AICompanion.append('system', ready, { persist: false }); toast(ready, 2400); } catch (err) { loading.remove(); const message = err && err.message ? err.message : String(err); AICompanion.append('system', t('ai.entityColorsFailed', { message }), { persist: false }); toast(t('ai.entityColorsFailed', { message }), 4200); } finally { this.extracting = false; this.syncButton(); if (btn) btn.removeAttribute('disabled'); } }, async buildExtractionContext() { const index = await Reader.buildSearchIndex(); const book = Reader.currentBook || {}; const toc = (Reader.toc || []) .slice(0, 120) .map(item => `${' '.repeat(Math.max(0, (item.depth || 1) - 1))}- ${item.label}`) .join('\n'); const chunks = []; let total = 0; for (const item of index || []) { const text = String(item.chunk || '').replace(/\s+/g, ' ').trim(); if (!text) continue; const block = `\n\n[${item.label || item.location || '正文'}]\n${text}`; if (total + block.length > this.maxContextChars) { chunks.push(block.slice(0, Math.max(0, this.maxContextChars - total))); break; } chunks.push(block); total += block.length; if (total >= this.maxContextChars) break; } const body = chunks.join('').trim(); if (!body) return ''; return [ `书名:${book.title || ''}`, `作者:${book.author || ''}`, toc ? `目录:\n${toc}` : '', `正文:${body}` ].filter(Boolean).join('\n\n'); }, buildExtractionMessages(context) { const lang = aiOutputLanguageName(); return [ { role: 'system', content: [ 'Extract named entities from an ebook for visual reading marks.', 'Return JSON only. Do not include Markdown, code fences, comments, or explanations.', 'Schema: {"people":["name"],"places":["name"]}.', 'people: character names, real people, aliases that appear as names.', 'places: geographic locations and place-like proper nouns, including countries, cities, mountains, islands, sect gates, schools, realms, worlds, venues, and named domains.', 'Do not include generic nouns, chapter titles, emotions, ordinary objects, organizations that are not place-like, or one-character CJK names.', 'Keep exact surface forms from the text. Deduplicate. Prefer 2-8 Chinese characters or concise English names.', 'Return at most 120 people and 120 places.', `Output labels in ${lang} when normalization is needed.` ].join('\n') }, { role: 'user', content: [ 'Extract the people and places that should be color-tinted in this book.', 'Blue will be used for people. Green will be used for places.', context ].join('\n\n') } ]; }, normalizeEntities(raw = {}) { raw = raw && typeof raw === 'object' ? raw : {}; const nested = raw.entities && typeof raw.entities === 'object' ? raw.entities : {}; const peopleSource = [] .concat(raw.people || []) .concat(raw.persons || []) .concat(raw.characters || []) .concat(raw.personNames || []) .concat(nested.people || []) .concat(nested.persons || []); const placeSource = [] .concat(raw.places || []) .concat(raw.locations || []) .concat(raw.placeNames || []) .concat(raw.locationNames || []) .concat(nested.places || []) .concat(nested.locations || []); const people = this.normalizeList(peopleSource).slice(0, this.maxTermsPerType); const peopleSet = new Set(people.map(item => item.toLocaleLowerCase())); const places = this.normalizeList(placeSource) .filter(item => !peopleSet.has(item.toLocaleLowerCase())) .slice(0, this.maxTermsPerType); return { people, places }; }, normalizeList(list) { const seen = new Set(); const out = []; (Array.isArray(list) ? list : []).forEach(item => { const name = this.normalizeName(item && typeof item === 'object' ? (item.name || item.text || item.value) : item); if (!this.isUsefulName(name)) return; const key = name.toLocaleLowerCase(); if (seen.has(key)) return; seen.add(key); out.push(name); }); return out.sort((a, b) => b.length - a.length || a.localeCompare(b)); }, normalizeName(value) { return String(value || '') .replace(/[\u200B-\u200D\uFEFF]/g, '') .replace(/([\u3400-\u9FFF])\s+(?=[\u3400-\u9FFF])/gu, '$1') .replace(/\s+/g, ' ') .replace(/^[\s"'“”‘’《》〈〉「」『』【】()()[\]{}]+|[\s"'“”‘’《》〈〉「」『』【】()()[\]{}.,,。::;;!?!?]+$/g, '') .trim(); }, isUsefulName(name) { const text = String(name || '').trim(); if (!text) return false; if (text.length > 40) return false; if (/^\d+$/u.test(text)) return false; if (/^(正文|目录|作者|读者|系统提示|当前章节|未知|本书|章节|chapter|book|author|reader|unknown)$/iu.test(text)) return false; const compact = text.replace(/\s+/g, ''); const hasCjk = /[\u3400-\u9FFF]/u.test(compact); if (hasCjk) return compact.length >= 2; return compact.length >= 3 && /[A-Za-z]/.test(compact); }, escapeRegExp(value) { return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }, currentColors() { const settings = this.loadSettings(); return Settings.data.theme === 'dark' ? { person: settings.personColor || '#55a8df', place: settings.placeColor || '#38b987', dialogue: '#f0a15a', title: '#f0a15a', bracket: '#f0a15a', custom: settings.customColor || '#f0a15a' } : { person: settings.personColor || '#2f83c5', place: settings.placeColor || '#21996f', dialogue: '#d46a1f', title: '#d46a1f', bracket: '#d46a1f', custom: settings.customColor || '#d46a1f' }; }, currentWeights() { return { person: '600', place: '600', dialogue: '500', title: '500', bracket: '500', custom: '500' }; }, literaryPatterns() { return [ { type: 'title', priority: 40, re: /《[^《》\n]{1,80}》|〈[^〈〉\n]{1,80}〉/gu }, { type: 'dialogue', priority: 30, re: /“[^“”\n]{1,180}”|「[^「」\n]{1,180}」|『[^『』\n]{1,180}』|"[^"\n]{2,180}"/gu }, { type: 'bracket', priority: 20, re: /[((][^()()\n]{1,80}[))]|[【\[][^【】\[\]\n]{1,80}[】\]]|[{{][^{}{}\n]{1,80}[}}]/gu } ].concat(this.customLiteraryPatterns()); }, parseCustomSymbolPairs(value = '') { const source = String(value || '').replace(/[\u200B-\u200D\uFEFF]/g, '').trim(); if (!source) return []; const tokens = source.split(/[\s,,;;、]+/u).map(item => item.trim()).filter(Boolean); const rawTokens = tokens.length === 1 && [...tokens[0]].length > 2 && [...tokens[0]].length % 2 === 0 ? [...tokens[0]].reduce((out, ch, idx, arr) => { if (idx % 2 === 0) out.push(ch + (arr[idx + 1] || '')); return out; }, []) : tokens; const seen = new Set(); const pairs = []; rawTokens.forEach(token => { const chars = [...token].filter(ch => ch && !/\s/u.test(ch)); if (!chars.length) return; const open = chars[0]; const close = chars.length > 1 ? chars[chars.length - 1] : ''; const key = `${open}\u0000${close}`; if (seen.has(key)) return; seen.add(key); pairs.push({ open, close }); }); return pairs.slice(0, this.maxCustomPairs); }, customLiteraryPatterns() { const pairs = this.parseCustomSymbolPairs(this.loadSettings().customSymbols); return pairs.map(pair => { const open = this.escapeRegExp(pair.open); const close = this.escapeRegExp(pair.close); return { type: 'custom', priority: 28, re: pair.close ? new RegExp(`${open}[^\\n]{1,120}?${close}`, 'gu') : new RegExp(open, 'gu') }; }); }, isUsefulLiteraryMatch(type, value) { const text = String(value || '').trim(); if (type === 'custom' && text.length === 1) return true; if (text.length < 3) return false; const inner = text.slice(1, -1).trim(); if (!inner) return false; if (type === 'custom') { if (/^[\d\s.,,。::;;!?!?\-—_/\\]+$/u.test(inner)) return false; return /[\u3400-\u9FFF]|[A-Za-z]/u.test(inner); } if (/^[\d\s.,,。::;;!?!?\-—_/\\]+$/u.test(inner)) return false; if (type === 'bracket') { if (!/[\u3400-\u9FFF]/u.test(inner)) return false; if (/^(第?\d+|[一二三四五六七八九十百千万]+)$/u.test(inner)) return false; } if (type === 'dialogue' && inner.length < 2) return false; return true; }, buildTermMatcher() { const typeByTerm = new Map(); (this.entities.places || []).forEach(name => typeByTerm.set(name.toLocaleLowerCase(), 'place')); (this.entities.people || []).forEach(name => typeByTerm.set(name.toLocaleLowerCase(), 'person')); const terms = [...typeByTerm.keys()] .sort((a, b) => b.length - a.length) .map(term => this.escapeRegExp(term)); if (!terms.length) return null; return { re: new RegExp(terms.join('|'), 'giu'), typeByTerm }; }, scheduleApply() { clearTimeout(this.applyTimer); this.applyTimer = setTimeout(() => { this.applyTimer = null; this.applyCurrent(); }, 60); }, applyCurrent() { if (!this.enabled) { this.clearCurrent(); this.syncButton(); return 0; } const targets = this.currentRoots(); let count = 0; targets.forEach(root => { count += this.applyToRoot(root); }); this.syncButton(); return count; }, currentRoots() { if (!Reader.currentBook) return []; if (Reader.currentEngine === 'epub' && Reader.rendition) { const roots = []; try { (Reader.rendition.getContents() || []).forEach(content => { if (content && content.document) roots.push(content.document.body || content.document.documentElement); }); } catch (e) {} document.querySelectorAll('#epub-viewer iframe').forEach(frame => { try { const doc = frame.contentDocument; if (doc) roots.push(doc.body || doc.documentElement); } catch (e) {} }); return [...new Set(roots.filter(Boolean))]; } if (Reader.currentEngine === 'pdf') return [$('#pdf-viewer')].filter(Boolean); if (TEXT_DOCUMENT_FORMATS.includes(Reader.currentEngine)) return [$('#doc-inner')].filter(Boolean); return []; }, clearCurrent() { this.currentRoots().forEach(root => this.clearRoot(root)); Reader.redrawEpubHighlights(); }, clearRoot(root) { const element = root && root.nodeType === 9 ? (root.body || root.documentElement) : root; if (!element || !element.querySelectorAll) return; element.querySelectorAll('span[data-entity-color-mark="true"]').forEach(span => this.unwrap(span)); }, unwrap(node) { const parent = node && node.parentNode; if (!parent) return; while (node.firstChild) parent.insertBefore(node.firstChild, node); parent.removeChild(node); try { parent.normalize(); } catch (e) {} }, applyToRoot(root) { const element = root && root.nodeType === 9 ? (root.body || root.documentElement) : root; if (!element || !element.querySelectorAll) return 0; if (!this.enabled) { this.clearRoot(element); return 0; } this.clearRoot(element); const matcher = this.buildTermMatcher(); const doc = element.ownerDocument || document; const nodes = []; const walker = doc.createTreeWalker(element, 4, { acceptNode(node) { const parent = node.parentElement; if (!parent || !node.nodeValue || !node.nodeValue.trim()) return 2; if (parent.closest && parent.closest('script,style,textarea,input,select,button,[contenteditable="true"],.tingread-entity-mark,.ai-repair-mark,.epub-highlight')) return 2; return 1; } }); while (walker.nextNode()) nodes.push(walker.currentNode); let count = 0; nodes.forEach(node => { count += this.markTextNode(node, matcher, doc); }); Reader.redrawEpubHighlights(); return count; }, markTextNode(node, matcher, doc) { const text = node && node.nodeValue ? node.nodeValue : ''; if (!text) return 0; const marks = this.collectTextMarks(text, matcher); if (!marks.length) return 0; let cursor = 0; let count = 0; const fragment = doc.createDocumentFragment(); const colors = this.currentColors(); const weights = this.currentWeights(); marks.forEach(mark => { const hit = text.slice(mark.start, mark.end); if (mark.start > cursor) fragment.appendChild(doc.createTextNode(text.slice(cursor, mark.start))); const span = doc.createElement('span'); span.className = `tingread-entity-mark tingread-entity-${mark.type} ${EPUB_ANNOTATION_IGNORE_CLASS}`; span.dataset.entityColorMark = 'true'; span.dataset.entityType = mark.type; span.style.color = colors[mark.type] || colors.place; span.style.fontWeight = weights[mark.type] || '500'; span.textContent = hit; fragment.appendChild(span); cursor = mark.end; count += 1; }); if (!count) return 0; if (cursor < text.length) fragment.appendChild(doc.createTextNode(text.slice(cursor))); node.parentNode.replaceChild(fragment, node); return count; }, collectTextMarks(text, matcher) { const matches = []; this.literaryPatterns().forEach(spec => { spec.re.lastIndex = 0; let match; while ((match = spec.re.exec(text))) { const value = match[0]; if (!value) { spec.re.lastIndex += 1; continue; } if (this.isUsefulLiteraryMatch(spec.type, value)) { matches.push({ start: match.index, end: match.index + value.length, type: spec.type, priority: spec.priority }); } } }); if (matcher && matcher.re) { const { re, typeByTerm } = matcher; re.lastIndex = 0; let match; while ((match = re.exec(text))) { const value = match[0]; if (!value) { re.lastIndex += 1; continue; } matches.push({ start: match.index, end: match.index + value.length, type: typeByTerm.get(value.toLocaleLowerCase()) || 'place', priority: 50 }); } } if (!matches.length) return []; matches.sort((a, b) => a.start - b.start || b.priority - a.priority || (b.end - b.start) - (a.end - a.start)); const selected = []; let cursor = 0; matches.forEach(match => { if (match.start < cursor || match.end <= match.start) return; selected.push(match); cursor = match.end; }); return selected; } }; /* ============================================================ 3.6 免费朗读:浏览器原生 speechSynthesis ============================================================ */ const AIRepair = { storageKey: 'tingread_ai_text_repairs_v1', maxStoredPerBook: 120, getBookId() { return Reader.currentBook && Reader.currentBook.id ? Reader.currentBook.id : ''; }, loadAll() { try { return JSON.parse(localStorage.getItem(this.storageKey) || '{}') || {}; } catch (e) { return {}; } }, saveAll(data) { try { localStorage.setItem(this.storageKey, JSON.stringify(data || {})); } catch (e) {} }, removeBook(bookId) { if (!bookId) return; const all = this.loadAll(); if (!Object.prototype.hasOwnProperty.call(all, bookId)) return; delete all[bookId]; this.saveAll(all); }, list(bookId = this.getBookId()) { if (!bookId) return []; const all = this.loadAll(); return Array.isArray(all[bookId]) ? all[bookId] : []; }, savePatches(patches, bookId = this.getBookId()) { if (!bookId || !Array.isArray(patches) || !patches.length) return []; const all = this.loadAll(); const current = Array.isArray(all[bookId]) ? all[bookId] : []; const seen = new Set(current.map(item => `${item.before}\n${item.after}`)); const additions = []; patches.forEach(item => { const before = String(item.before || '').trim(); const after = String(item.after || '').trim(); if (!before || !after || before === after) return; const key = `${before}\n${after}`; if (seen.has(key)) return; seen.add(key); additions.push({ id: uid(), before, after, confidence: Number(item.confidence) || null, reason: String(item.reason || '').slice(0, 120), createdAt: Date.now(), source: 'ai' }); }); all[bookId] = [...current, ...additions].slice(-this.maxStoredPerBook); this.saveAll(all); return additions; }, applyStored(root = null, options = {}) { const bookId = this.getBookId(); if (!bookId) return 0; const repairs = this.list(bookId) .filter(item => item && item.before && item.after && item.before !== item.after) .slice(-this.maxStoredPerBook); if (!repairs.length) return 0; const targets = root ? [root] : [ document.querySelector('#doc-inner'), document.querySelector('#pdf-viewer'), ...Array.from(document.querySelectorAll('#epub-viewer iframe')).map(frame => { try { return frame.contentDocument && frame.contentDocument.body; } catch (e) { return null; } }) ].filter(Boolean); let changed = 0; targets.forEach(target => { changed += this.applyToTextNodes(target, repairs, options); }); if (changed) Reader.redrawEpubHighlights(); return changed; }, applyToTextNodes(root, repairs, options = {}) { if (!root || !repairs.length) return 0; let changed = 0; const ownerDocument = root.ownerDocument || document; const walker = ownerDocument.createTreeWalker(root, 4, { acceptNode(node) { const parent = node.parentElement; if (!parent || ['SCRIPT', 'STYLE', 'TEXTAREA', 'INPUT'].includes(parent.tagName)) return 2; if (parent.closest && parent.closest('.ai-repair-mark')) return 2; return node.nodeValue && node.nodeValue.trim() ? 1 : 2; } }); const nodes = []; while (walker.nextNode()) nodes.push(walker.currentNode); nodes.forEach(node => { changed += this.replaceTextNode(node, repairs, ownerDocument, options); }); return changed; }, ensureMarkStyle(ownerDocument) { const doc = ownerDocument || document; if (!doc || doc === document || doc.querySelector('style[data-tingread-ai-repair-style]')) return; const style = doc.createElement('style'); style.setAttribute('data-tingread-ai-repair-style', 'true'); style.textContent = ` .ai-repair-mark { border-radius: 2px; background: rgba(166, 74, 50, 0.14); box-shadow: inset 0 -0.11em 0 rgba(166, 74, 50, 0.42); animation: tingreadAiRepairMarkPulse 5.5s ease-out forwards; } @keyframes tingreadAiRepairMarkPulse { 0% { background: rgba(166, 74, 50, 0.22); box-shadow: inset 0 -0.16em 0 rgba(166, 74, 50, 0.58); } 70% { background: rgba(166, 74, 50, 0.11); } 100% { background: transparent; box-shadow: inset 0 -0.09em 0 rgba(166, 74, 50, 0.32); } }`; (doc.head || doc.documentElement).appendChild(style); }, replaceTextNode(node, repairs, ownerDocument, options = {}) { const source = node && node.nodeValue ? node.nodeValue : ''; if (!source) return 0; const usable = repairs .filter(item => item && item.before && item.after && item.before !== item.after && source.includes(item.before)) .sort((a, b) => String(b.before).length - String(a.before).length); if (!usable.length) return 0; const mark = options && options.mark; if (mark) this.ensureMarkStyle(ownerDocument); const fragment = ownerDocument.createDocumentFragment(); let cursor = 0; let count = 0; while (cursor < source.length) { let best = null; usable.forEach(item => { const before = String(item.before); const index = source.indexOf(before, cursor); if (index < 0) return; if (!best || index < best.index || (index === best.index && before.length > best.before.length)) { best = { item, before, index }; } }); if (!best) { fragment.appendChild(ownerDocument.createTextNode(source.slice(cursor))); break; } if (best.index > cursor) { fragment.appendChild(ownerDocument.createTextNode(source.slice(cursor, best.index))); } const after = String(best.item.after); if (mark) { const span = ownerDocument.createElement('span'); span.className = `ai-repair-mark ${EPUB_ANNOTATION_IGNORE_CLASS}`; span.textContent = after; if (best.item.reason) span.title = String(best.item.reason).slice(0, 120); fragment.appendChild(span); } else { fragment.appendChild(ownerDocument.createTextNode(after)); } cursor = best.index + best.before.length; count += 1; } if (count) node.parentNode.replaceChild(fragment, node); return count; }, currentText() { if (typeof SpeechReader !== 'undefined' && SpeechReader.getCurrentText) { return SpeechReader.getCurrentText(); } return ($('#doc-inner')?.innerText || $('#pdf-viewer')?.innerText || '').trim(); }, firstSuspiciousIndex(text) { const source = String(text || ''); const patterns = [ /[\uFFFD\u25a0-\u25ff]/, /[A-Za-z0-9|\\/~^_`'=+<>-]{1,8}\uFFFD/, /\uFFFD[A-Za-z0-9|\\/~^_`'=+<>-]{0,8}/ ]; let found = -1; patterns.forEach(pattern => { const match = source.search(pattern); if (match >= 0 && (found < 0 || match < found)) found = match; }); return found; }, contextSlice(text) { const source = String(text || '').replace(/\s+\n/g, '\n').trim(); if (!source) return ''; const index = this.firstSuspiciousIndex(source); if (index < 0) return source.slice(0, 2400); const start = Math.max(0, index - 900); const end = Math.min(source.length, index + 1500); return source.slice(start, end); }, splitParagraphs(text) { return String(text || '') .replace(/\r\n?/g, '\n') .split(/\n{2,}|\n(?=\s*[\u3000\s]*[\u4e00-\u9fffA-Za-z0-9])/) .map(item => item.replace(/\s+/g, ' ').trim()) .filter(Boolean); }, surroundingParagraphContext(text) { const source = String(text || '').replace(/\s+\n/g, '\n').trim(); if (!source) return ''; const suspiciousIndex = this.firstSuspiciousIndex(source); const paragraphs = this.splitParagraphs(source); if (!paragraphs.length) return this.contextSlice(source); let running = 0; let targetIndex = suspiciousIndex >= 0 ? suspiciousIndex : 0; let paragraphIndex = 0; for (let i = 0; i < paragraphs.length; i++) { const found = source.indexOf(paragraphs[i], running); const start = found >= 0 ? found : running; const end = start + paragraphs[i].length; if (targetIndex >= start && targetIndex <= end) { paragraphIndex = i; break; } running = end; } const start = Math.max(0, paragraphIndex - 2); const end = Math.min(paragraphs.length, paragraphIndex + 3); const context = paragraphs.slice(start, end).join('\n\n'); return context.length > 3200 ? this.contextSlice(context) : context; }, repairContext(text) { const page = String(text || '').replace(/\s+\n/g, '\n').trim(); if (!page) return null; const pageText = page.length > 5200 ? page.slice(0, 5200) : page; const paragraphContext = this.surroundingParagraphContext(page); const focused = this.contextSlice(page); const suspicious = this.suspiciousFragments(page); return { pageText, paragraphContext, focused, suspicious }; }, isSuspiciousText(text) { return /[\uFFFD\u25a0-\u25ff]/.test(text) || /[\u3400-\u9fff](?:\uFFFD|[\u25a0-\u25ff])["']?[A-Za-z0-9]{1,12}(?:\uFFFD|[\u25a0-\u25ff])?[\u3400-\u9fff]/.test(text) || /[\u3400-\u9fff][A-Za-z0-9|\\/~^_`'=+<>-]{2,12}\uFFFD?[\u3400-\u9fff]/.test(text); }, suspiciousFragments(context) { const source = String(context || ''); const pieces = source .split(/\n{1,}|\s{2,}/) .map(s => s.trim()) .filter(Boolean); const fragments = []; for (const piece of pieces) { if (!this.isSuspiciousText(piece)) continue; fragments.push(piece.length > 260 ? piece.slice(0, 260) : piece); if (fragments.length >= 8) break; } if (!fragments.length && this.isSuspiciousText(source)) fragments.push(source.slice(0, 320)); return fragments; }, heuristicPatches(context) { const source = String(context || ''); const patches = []; const seen = new Set(); const add = (before, after, confidence = 0.72, reason = 'local artifact island') => { before = String(before || '').trim(); after = String(after || '').trim(); if (!before || !after || before === after || !source.includes(before)) return; const key = `${before}\n${after}`; if (seen.has(key)) return; seen.add(key); patches.push({ before, after, confidence, reason }); }; const artifactIsland = /([\u3400-\u9fff])((?:\uFFFD|[\u25a0-\u25ff])["']?[A-Za-z]{1,10}(?:\uFFFD|[\u25a0-\u25ff]))(?=[\u3400-\u9fff])/g; for (const match of source.matchAll(artifactIsland)) { add(match[0], `${match[1]},`, 0.78, '乱码岛像丢失的中文标点'); } const compactIsland = /([\u3400-\u9fff,。;:、!?“”‘’])((?:\uFFFD|[\u25a0-\u25ff]){1,4})(?=[\u3400-\u9fff,。;:、!?“”‘’])/g; for (const match of source.matchAll(compactIsland)) { add(match[0], match[1], 0.66, '删除夹在中文里的替换符'); } return patches.slice(0, 8); }, mergePatches(patches, context) { const source = String(context || ''); const seen = new Set(); return (Array.isArray(patches) ? patches : []) .filter(item => item && item.before && item.after && item.before !== item.after && source.includes(item.before)) .filter(item => { const key = `${item.before}\n${item.after}`; if (seen.has(key)) return false; seen.add(key); return true; }) .slice(0, 10); }, buildMessages(repairContext) { const book = Reader.currentBook; const chapter = ($('#reader-chapter')?.textContent || '').trim(); const isEnglish = I18N.lang() === 'en'; const context = typeof repairContext === 'string' ? { pageText: repairContext, paragraphContext: this.surroundingParagraphContext(repairContext), focused: this.contextSlice(repairContext), suspicious: this.suspiciousFragments(repairContext) } : (repairContext || {}); const suspicious = Array.isArray(context.suspicious) ? context.suspicious : this.suspiciousFragments(context.pageText || ''); const system = isEnglish ? [ 'You repair current-page ebook text.', 'Only fix obvious encoding artifacts or high-confidence typos. Do not polish, summarize, modernize, translate, or rewrite normal prose.', 'Use the whole current page and nearby paragraphs as context before deciding a patch.', 'When visible replacement characters, tofu blocks, or short Latin garbage appear inside Chinese prose, attempt a minimal repair instead of returning an empty array.', 'If the lost word is impossible to know, remove the garbage island or replace it with likely Chinese punctuation such as , only when that preserves the sentence.', 'For typos, only return a patch when the surrounding sentence makes the correction nearly certain.', 'Example: "气温上升�\\"bod�门口" -> "气温上升,门口".', 'Return only a JSON array. Each item must be {"before":"exact text from input","after":"minimal repaired text","confidence":0.0-1.0,"reason":"short"} .', 'Only return [] when there is truly no visible artifact to repair.' ].join('\n') : [ '你是电子书当前页文字修复器。', '只修复明显由编码或格式转换造成的乱码,或上下文高度确定的错别字,不润色、不改写、不翻译、不补写正常正文。', '请结合当前页全文、可疑段落前后自然段和可疑片段列表后再判断补丁。', '当中文正文里出现 �、黑块、或很短的英文垃圾碎片时,请尽量给出最小修复,不要轻易返回空数组。', '如果无法确定丢失的具体汉字,优先删除乱码岛,或在不改变句意的情况下补成中文标点,例如逗号。', '错别字只在前后文几乎能确定原字时才返回修复。', '例子:“气温上升�\\"bod�门口” -> “气温上升,门口”。', '只返回 JSON 数组。每一项必须是 {"before":"输入里出现的原样片段","after":"最小修复结果","confidence":0.0-1.0,"reason":"简短原因"}。', '只有在确实没有可见乱码可修时,才返回 []。' ].join('\n'); const user = [ `Book: ${book ? book.title : 'Unknown'}`, `Author: ${book && book.author ? book.author : 'Unknown'}`, `Position: ${chapter || 'Unknown'}`, '', suspicious.length ? `Suspicious fragments:\n${suspicious.map((item, idx) => `${idx + 1}. ${item}`).join('\n')}\n` : '', 'Neighboring paragraphs around the suspicious area:', context.paragraphContext || context.focused || context.pageText || '', '', 'Focused suspicious window:', context.focused || context.paragraphContext || context.pageText || '', '', 'Whole current page text:', context.pageText || '' ].join('\n'); return [ { role: 'system', content: system }, { role: 'user', content: user } ]; }, extractJsonArray(raw) { const text = String(raw || '').trim(); if (!text) return []; const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i); const body = fenced ? fenced[1].trim() : text; const start = body.indexOf('['); const end = body.lastIndexOf(']'); if (start < 0 || end <= start) return []; try { const parsed = JSON.parse(body.slice(start, end + 1)); return Array.isArray(parsed) ? parsed : []; } catch (e) { return []; } }, normalizePatches(rawPatches, context) { const source = String(context || ''); return (Array.isArray(rawPatches) ? rawPatches : []) .map(item => ({ before: String(item && item.before || '').trim(), after: String(item && item.after || '').trim(), confidence: Number(item && item.confidence), reason: String(item && item.reason || '').trim() })) .filter(item => item.before && item.after && item.before !== item.after && item.before.length <= 80 && item.after.length <= 80 && source.includes(item.before) && (!Number.isFinite(item.confidence) || item.confidence >= 0.4) ) .slice(0, 8); }, previewMessage(patches) { return [ t('ai.repairPrompt'), '', ...patches.map((item, idx) => { const confidence = Number.isFinite(item.confidence) ? ` · ${Math.round(item.confidence * 100)}%` : ''; return `${idx + 1}. ${item.before}\n→ ${item.after}${confidence}`; }) ].join('\n'); }, async run() { if (this.running) return; if (!Reader.currentBook) return; const { provider, endpoint, model, apiKey } = AICompanion.currentConfig(); if (!apiKey) { toast(t('ai.needKey')); AICompanion.openSettings(); return; } if (!endpoint) { toast(t('ai.needEndpoint')); AICompanion.openSettings(); return; } if (!model) { toast(t('ai.needModel')); AICompanion.openSettings(); return; } const text = this.currentText(); const repairContext = this.repairContext(text); if (!repairContext || !repairContext.pageText) { toast(t('ai.repairNoText')); return; } const ok = await openConfirmDialog({ kicker: t('ai.repairPromptKicker'), title: t('ai.repairPromptTitle'), message: t('ai.repairPrompt'), confirmText: t('ai.repairPromptAction'), cancelText: t('modal.cancel') }); if (!ok) return; localStorage.setItem('shufang_ai_provider', provider); AICompanion.saveProviderFields(provider); const sourceText = repairContext.pageText; const localPatches = this.heuristicPatches(sourceText); const btn = $('#reader-repair-btn'); this.running = true; if (btn) btn.setAttribute('disabled', 'disabled'); toast(t('ai.repairThinking'), 2400); try { const data = await AICompanion.requestAI(this.buildMessages(repairContext), { maxTokens: 1100, temperature: 0.03 }); const answer = data.choices && data.choices[0] && data.choices[0].message ? data.choices[0].message.content : ''; const patches = this.mergePatches([ ...localPatches, ...this.normalizePatches(this.extractJsonArray(answer), sourceText) ], sourceText); if (!patches.length) { toast(t('ai.repairNoPatch'), 3200); return; } const apply = await openConfirmDialog({ kicker: t('ai.repairPreviewKicker'), title: t('ai.repairPreviewTitle'), message: this.previewMessage(patches), confirmText: t('ai.repairPreviewAction'), cancelText: t('modal.cancel') }); if (!apply) return; const saved = this.savePatches(patches); this.applyStored(null, { mark: true }); if (btn) { btn.classList.add('repaired'); clearTimeout(btn._repairTimer); btn._repairTimer = setTimeout(() => btn.classList.remove('repaired'), 1400); } toast(t('ai.repairApplied', { count: saved.length || patches.length }), 2800); } catch (err) { const patches = this.mergePatches(localPatches, sourceText); if (patches.length) { const apply = await openConfirmDialog({ kicker: t('ai.repairPreviewKicker'), title: t('ai.repairPreviewTitle'), message: this.previewMessage(patches), confirmText: t('ai.repairPreviewAction'), cancelText: t('modal.cancel') }); if (apply) { const saved = this.savePatches(patches); this.applyStored(null, { mark: true }); toast(t('ai.repairApplied', { count: saved.length || patches.length }), 2800); } return; } toast(t('ai.repairFailed', { message: err.message || err }), 4200); } finally { this.running = false; if (btn) btn.removeAttribute('disabled'); } } }; const SpeechReader = { voices: [], chunks: [], index: 0, speaking: false, paused: false, utterance: null, currentTitle: '', statusText: '', readSource: '', readSession: 0, utteranceSerial: 0, init() { const supported = 'speechSynthesis' in window && 'SpeechSynthesisUtterance' in window; if (!supported) { ['#btn-toggle-tts', '#btn-tts-current', '#btn-tts-selection', '#btn-tts-pause', '#btn-tts-stop', '#tts-player-toggle', '#tts-player-stop'].forEach(sel => { const el = document.querySelector(sel); if (el) el.setAttribute('disabled', 'disabled'); }); const player = $('#tts-player'); if (player) player.hidden = true; this.setStatus(t('tts.unsupported')); return; } $('#btn-toggle-tts').addEventListener('click', () => this.toggle()); $('#btn-close-tts').addEventListener('click', () => this.close()); $('#btn-tts-current').addEventListener('click', () => this.read('current')); $('#btn-tts-selection').addEventListener('click', () => this.read('selection')); $('#btn-tts-pause').addEventListener('click', () => this.pauseResume()); $('#btn-tts-stop').addEventListener('click', () => this.stop()); $('#tts-player-toggle')?.addEventListener('click', () => this.pauseResume()); $('#tts-player-stop')?.addEventListener('click', () => this.stop()); $('#tts-rate').addEventListener('input', () => this.refreshUI()); $('#tts-voice').addEventListener('change', () => { try { localStorage.setItem('shufang_tts_voice', $('#tts-voice').value); } catch (e) {} }); this.loadVoices(); window.speechSynthesis.onvoiceschanged = () => this.loadVoices(); this.refreshUI(); this.setStatus(t('tts.idle')); }, toggle() { if (!isReaderFeatureVisible('tts')) { notifyHiddenFeature(); return; } if (typeof TranslationCompare !== 'undefined' && TranslationCompare.active) TranslationCompare.close(); $('#tts-panel').classList.toggle('open'); $('#settings-popover').classList.remove('open'); closeTocPanel(); $('#search-panel').classList.remove('open'); $('#search-bar').classList.remove('open'); $('#notes-panel').classList.remove('open'); $('#ai-panel').classList.remove('open'); blurActiveElementInside('#ai-panel'); blurActiveElementInside('#search-bar'); if ($('#tts-panel').classList.contains('open')) this.loadVoices(); }, close() { $('#tts-panel').classList.remove('open'); }, loadVoices() { if (!('speechSynthesis' in window)) return; this.voices = window.speechSynthesis.getVoices() || []; const select = $('#tts-voice'); if (!select) return; const saved = localStorage.getItem('shufang_tts_voice') || ''; const options = [``].concat( this.voices.map((voice, idx) => { const id = `${voice.name}::${voice.lang}::${idx}`; return ``; }) ); select.innerHTML = options.join(''); const savedExists = saved && this.voices.some((voice, idx) => `${voice.name}::${voice.lang}::${idx}` === saved); select.value = savedExists ? saved : ''; }, selectedVoice(preferredLang = '') { const value = $('#tts-voice') ? $('#tts-voice').value : ''; const preferred = String(preferredLang || '').toLowerCase(); const base = preferred.split('-')[0]; if (value) { const selected = this.voices.find((voice, idx) => `${voice.name}::${voice.lang}::${idx}` === value) || null; if (!selected || !preferred) return selected; const selectedLang = String(selected.lang || '').toLowerCase(); if (selectedLang.startsWith(preferred) || selectedLang.startsWith(base)) return selected; } if (!preferred) return null; return this.voices.find(voice => String(voice.lang || '').toLowerCase().startsWith(preferred)) || this.voices.find(voice => String(voice.lang || '').toLowerCase().startsWith(base)) || null; }, refreshUI() { const rate = Number($('#tts-rate') && $('#tts-rate').value) || 1.5; const rateValue = $('#tts-rate-value'); if (rateValue) rateValue.textContent = rate.toFixed(1) + 'x'; const pauseBtn = $('#btn-tts-pause'); if (pauseBtn) pauseBtn.textContent = this.paused ? t('tts.resume') : t('tts.pause'); this.updatePlayer(); }, setStatus(text) { this.statusText = text || ''; const el = $('#tts-status'); if (el) el.textContent = text; this.updatePlayer(); }, updatePlayer() { const player = $('#tts-player'); if (!player) return; const active = !!(this.speaking || this.paused); player.hidden = !active; if (!active) return; const title = $('#tts-player-title'); const status = $('#tts-player-status'); const toggle = $('#tts-player-toggle'); const icon = $('#tts-player-toggle-icon'); const stop = $('#tts-player-stop'); if (title) title.textContent = this.currentTitle || t('tts.title'); if (status) status.textContent = this.statusText || t('tts.playing', { current: Math.max(1, this.index + 1), total: this.chunks.length || 1 }); const toggleLabel = this.paused ? t('tts.resume') : t('tts.pause'); if (icon) icon.textContent = this.paused ? '▶' : 'Ⅱ'; if (toggle) { toggle.title = toggleLabel; toggle.setAttribute('aria-label', toggleLabel); } if (stop) { stop.title = t('tts.stop'); stop.setAttribute('aria-label', t('tts.stop')); } }, getSelectionText() { const direct = String(window.getSelection && window.getSelection() || '').trim(); if (direct) return direct; if (Reader.currentEngine === 'epub') { try { const contents = Reader.rendition ? Reader.rendition.getContents() : []; for (const content of contents) { const text = String(content.window.getSelection && content.window.getSelection() || '').trim(); if (text) return text; } } catch (e) {} } return ''; }, normalizePageTextPieces(pieces) { const output = []; let last = ''; pieces.forEach(piece => { const text = String(piece || '').replace(/\s+/g, ' ').trim(); if (!text || text === last) return; if (last && (text.includes(last) || last.includes(text))) { if (text.length <= last.length) return; output[output.length - 1] = text; last = text; return; } output.push(text); last = text; }); return output.join('\n').trim(); }, getEpubViewport(content) { const doc = content && content.document; const win = (content && content.window) || (doc && doc.defaultView); const frame = win && win.frameElement; const frameRect = frame ? frame.getBoundingClientRect() : null; const width = Math.max(1, Math.round((frameRect && frameRect.width) || (win && win.innerWidth) || (doc && doc.documentElement && doc.documentElement.clientWidth) || 0)); const height = Math.max(1, Math.round((frameRect && frameRect.height) || (win && win.innerHeight) || (doc && doc.documentElement && doc.documentElement.clientHeight) || 0)); return { width, height }; }, isEpubElementVisible(el, viewport) { if (!el || !viewport) return false; if (el.id === 'tingread-epub-trailing-blank' || el.closest('#tingread-epub-trailing-blank')) return false; let rect; try { rect = el.getBoundingClientRect(); } catch (e) { return false; } if (!rect || (rect.width <= 0 && rect.height <= 0)) return false; const pad = Math.max(12, Math.min(72, viewport.height * 0.06)); return rect.bottom >= -pad && rect.top <= viewport.height + pad && rect.right >= -pad && rect.left <= viewport.width + pad; }, collectVisibleEpubText(doc, viewport, selector, pieces) { if (!doc || !doc.body) return; doc.body.querySelectorAll(selector).forEach(el => { if (!this.isEpubElementVisible(el, viewport)) return; try { const style = doc.defaultView && doc.defaultView.getComputedStyle(el); if (style && (style.display === 'none' || style.visibility === 'hidden')) return; } catch (e) {} const text = (el.innerText || el.textContent || '').trim(); if (text) pieces.push(text); }); }, parseEpubCfi(cfi) { const value = String(cfi || '').trim(); if (!value.startsWith('epubcfi(') || !value.endsWith(')')) return null; const inner = value.slice(8, -1); const bang = inner.indexOf('!'); if (bang < 0) return null; const base = inner.slice(0, bang); const path = inner.slice(bang + 1).split(',')[0] || ''; if (!base || !path) return null; return { base, path }; }, splitEpubCfiPath(path) { return String(path || '') .replace(/^\/+/, '') .split('/') .filter(Boolean); }, buildEpubCfiRange(startCfi, endCfi) { const start = this.parseEpubCfi(startCfi); const end = this.parseEpubCfi(endCfi); if (!start || !end || start.base !== end.base || start.path === end.path) return ''; const startSteps = this.splitEpubCfiPath(start.path); const endSteps = this.splitEpubCfiPath(end.path); let commonLength = 0; while ( commonLength < startSteps.length && commonLength < endSteps.length && startSteps[commonLength] === endSteps[commonLength] ) { commonLength += 1; } const common = '/' + startSteps.slice(0, commonLength).join('/'); const startPart = '/' + startSteps.slice(commonLength).join('/'); const endPart = '/' + endSteps.slice(commonLength).join('/'); if (startPart === '/' || endPart === '/') return ''; return `epubcfi(${start.base}!${common === '/' ? '/' : common},${startPart},${endPart})`; }, getEpubCurrentCfiRangeText() { if (!Reader.rendition || typeof Reader.rendition.getRange !== 'function') return ''; let loc = null; try { loc = Reader.rendition.currentLocation(); } catch (e) { return ''; } const startCfi = loc && loc.start && loc.start.cfi; const endCfi = loc && loc.end && loc.end.cfi; const cfiRange = this.buildEpubCfiRange(startCfi, endCfi); if (!cfiRange) return ''; try { const range = Reader.rendition.getRange(cfiRange); const text = range && typeof range.toString === 'function' ? range.toString().trim() : ''; return text.replace(/\s+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim(); } catch (e) { return ''; } }, getEpubCurrentPageText() { const cfiText = this.getEpubCurrentCfiRangeText(); if (cfiText) return cfiText; const contents = Reader.rendition ? Reader.rendition.getContents() : []; const pieces = []; contents.forEach(content => { const doc = content && content.document; if (!doc || !doc.body) return; const viewport = this.getEpubViewport(content); const localPieces = []; this.collectVisibleEpubText(doc, viewport, 'h1, h2, h3, h4, h5, h6, p, li, blockquote, pre', localPieces); if (!localPieces.length) { this.collectVisibleEpubText(doc, viewport, 'div', localPieces); } pieces.push(...localPieces); }); const visibleText = this.normalizePageTextPieces(pieces); if (visibleText) return visibleText; return ''; }, getCurrentText() { if (!Reader.currentBook) return ''; if (Reader.currentEngine === 'epub' && Reader.rendition) { try { return this.getEpubCurrentPageText(); } catch (e) { return ''; } } if (Reader.currentEngine === 'pdf') { return [...document.querySelectorAll('#pdf-viewer .pdf-text-layer')] .map(layer => layer.innerText.trim()) .filter(Boolean) .join('\n') .trim(); } if (TEXT_DOCUMENT_FORMATS.includes(Reader.currentEngine)) { const viewer = $('#doc-viewer'); const inner = $('#doc-inner'); if (!viewer || !inner) return ''; const viewerRect = viewer.getBoundingClientRect(); const pieces = []; inner.querySelectorAll('p, h1, h2, h3, h4, li, blockquote').forEach(el => { const rect = el.getBoundingClientRect(); const visible = Reader.isScrollableDocumentPaged(viewer) ? rect.right >= viewerRect.left && rect.left <= viewerRect.right : rect.bottom >= viewerRect.top && rect.top <= viewerRect.bottom; if (visible) { const text = el.innerText.trim(); if (text) pieces.push(text); } }); return (pieces.join('\n') || inner.innerText || '').trim(); } return ''; }, readerPositionKey() { if (!Reader.currentBook) return ''; if (Reader.currentEngine === 'epub' && Reader.rendition) { try { const loc = Reader.rendition.currentLocation(); return 'epub:' + (loc?.start?.cfi || loc?.end?.cfi || loc?.start?.href || ''); } catch (e) { return 'epub:unknown'; } } if (Reader.currentEngine === 'pdf') { const visible = Reader.pdfVisiblePages && Reader.pdfVisiblePages.length ? Reader.pdfVisiblePages.join('-') : Reader.pdfPage; return `pdf:${visible || 1}`; } if (TEXT_DOCUMENT_FORMATS.includes(Reader.currentEngine)) { const viewer = $('#doc-viewer'); if (!viewer) return `${Reader.currentEngine}:missing`; return Reader.isScrollableDocumentPaged(viewer) ? `${Reader.currentEngine}:x:${Math.round(viewer.scrollLeft)}` : `${Reader.currentEngine}:y:${Math.round(viewer.scrollTop)}`; } return Reader.currentEngine || ''; }, wait(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }, async waitForReaderPageChange(beforeKey, beforeText = '') { let changed = false; let text = ''; const previousText = String(beforeText || '').trim(); for (let i = 0; i < 16; i++) { await yieldToBrowser(); await this.wait(80); const key = this.readerPositionKey(); text = this.getCurrentText(); if (key && key !== beforeKey) changed = true; if (text.trim() && text.trim() !== previousText) changed = true; if (changed && text.trim()) return { changed: true, text }; } return { changed, text }; }, normalizeSpeechText(text) { return String(text || '') .replace(/\u00a0/g, ' ') .replace(/\b(?:[A-Za-z]\s+){2,}[A-Za-z]\b/g, match => match.replace(/\s+/g, '')) .replace(/\b[A-Z]\s+[A-Z]\b/g, match => match.replace(/\s+/g, '')) .replace(/([\u3400-\u9fff])\s+(?=[\u3400-\u9fff])/g, '$1') .replace(/\s+([,。!?;:、,.!?;:])/g, '$1') .replace(/([(《“‘])\s+/g, '$1') .replace(/\s+/g, ' ') .trim(); }, detectChunkLang(text) { const value = String(text || ''); const latin = (value.match(/[A-Za-z]/g) || []).length; const cjk = (value.match(/[\u3400-\u9fff]/g) || []).length; if (latin >= 2 && latin >= cjk) return 'en-US'; return uiLocale(); }, splitSpeechSegments(text) { const value = String(text || ''); const token = String.raw`(?:[A-Za-z][A-Za-z0-9+#&._'’\/-]*|[0-9]+[A-Za-z][A-Za-z0-9+#&._'’\/-]*)`; const phraseRe = new RegExp(`${token}(?:\\s+${token})*`, 'g'); const segments = []; let last = 0; let match; while ((match = phraseRe.exec(value))) { if (match.index > last) { segments.push({ text: value.slice(last, match.index), lang: this.detectChunkLang(value.slice(last, match.index)) }); } segments.push({ text: match[0], lang: 'en-US' }); last = match.index + match[0].length; } if (last < value.length) { segments.push({ text: value.slice(last), lang: this.detectChunkLang(value.slice(last)) }); } return segments; }, pushSpeechChunk(chunks, text, lang) { let rest = String(text || '').replace(/\s+/g, ' ').trim(); if (!rest) return; if (!/[\u3400-\u9fffA-Za-z0-9]/.test(rest)) { const previous = chunks[chunks.length - 1]; if (previous) previous.text += rest; return; } const targetLang = lang || this.detectChunkLang(rest); const limit = targetLang === 'en-US' ? 700 : 900; while (rest.length > limit) { const punctuationCuts = [' ', '。', '!', '?', ',', ';', '.', '!', '?', ',', ';'] .map(mark => rest.lastIndexOf(mark, limit)) .filter(pos => pos > Math.floor(limit * 0.45)); const cut = punctuationCuts.length ? Math.max(...punctuationCuts) + 1 : limit; chunks.push({ text: rest.slice(0, cut).trim(), lang: targetLang }); rest = rest.slice(cut).trim(); } if (rest) chunks.push({ text: rest, lang: targetLang }); }, splitText(text) { const cleaned = this.normalizeSpeechText(text); if (!cleaned) return []; const parts = cleaned.match(/[^。!?.!?]+[。!?.!?]?/g) || [cleaned]; const chunks = []; for (const part of parts) { this.splitSpeechSegments(part).forEach(segment => { this.pushSpeechChunk(chunks, segment.text, segment.lang); }); } return chunks; }, read(source) { if (!isReaderFeatureVisible('tts')) { notifyHiddenFeature(); return; } if (!('speechSynthesis' in window)) { toast(t('toast.ttsUnsupported')); return; } const text = source === 'selection' ? this.getSelectionText() : this.getCurrentText(); if (!text) { toast(source === 'selection' ? t('toast.ttsNoSelection') : t('toast.ttsNoText')); this.setStatus(source === 'selection' ? t('tts.noSelection') : t('tts.noText')); return; } this.stop(false); const session = ++this.readSession; this.readSource = source; this.chunks = this.splitText(text); if (!this.chunks.length) { toast(t('toast.ttsNoText')); this.setStatus(t('tts.noText')); this.readSource = ''; return; } this.index = 0; this.speaking = true; this.paused = false; this.currentTitle = Reader.currentBook ? Reader.currentBook.title : t('tts.title'); this.speakNext(session); }, async continueToNextPage(session) { if (session !== this.readSession || this.readSource !== 'current' || !this.speaking || !Reader.currentBook) return false; const beforeKey = this.readerPositionKey(); const beforeText = this.getCurrentText(); this.setStatus(t('tts.turningPage')); await Reader.next(); const result = await this.waitForReaderPageChange(beforeKey, beforeText); if (session !== this.readSession || !this.speaking) return true; if (!result.changed || !result.text.trim()) return false; const chunks = this.splitText(result.text); if (!chunks.length) return false; this.chunks = chunks; this.index = 0; this.speakNext(session); return true; }, async speakNext(session = this.readSession) { if (session !== this.readSession) return; if (!this.speaking || this.index >= this.chunks.length) { const continued = await this.continueToNextPage(session); if (!continued && session === this.readSession) { this.finish(this.readSource === 'current' ? t('tts.endOfBook') : t('tts.done')); } return; } const chunk = this.chunks[this.index]; const chunkText = typeof chunk === 'string' ? chunk : chunk.text; const chunkLang = (typeof chunk === 'string' ? '' : chunk.lang) || this.detectChunkLang(chunkText); const utterance = new SpeechSynthesisUtterance(chunkText); const utteranceToken = ++this.utteranceSerial; let settled = false; const settleUtterance = (callback) => { if (settled || this.utterance !== utterance || utteranceToken !== this.utteranceSerial) return; if (!this.speaking || session !== this.readSession) return; settled = true; callback(); }; utterance.rate = Number($('#tts-rate').value) || 1.5; utterance.lang = chunkLang; const voice = this.selectedVoice(chunkLang); if (voice) { utterance.voice = voice; utterance.lang = voice.lang || utterance.lang; } utterance.onend = () => { settleUtterance(() => { this.index += 1; this.speakNext(session); }); }; utterance.onerror = () => { settleUtterance(() => this.finish()); }; this.utterance = utterance; this.setStatus(t('tts.playing', { current: this.index + 1, total: this.chunks.length })); this.refreshUI(); window.speechSynthesis.speak(utterance); }, pauseResume() { if (!('speechSynthesis' in window)) return; if (!this.speaking && !window.speechSynthesis.paused) return; if (window.speechSynthesis.paused) { window.speechSynthesis.resume(); this.paused = false; this.setStatus(t('tts.playing', { current: this.index + 1, total: this.chunks.length || 1 })); } else { window.speechSynthesis.pause(); this.paused = true; this.setStatus(t('tts.paused', { current: this.index + 1, total: this.chunks.length || 1 })); } this.refreshUI(); }, stop(showStatus = true) { this.readSession += 1; this.utteranceSerial += 1; if ('speechSynthesis' in window) { this.speaking = false; this.paused = false; window.speechSynthesis.cancel(); } this.utterance = null; this.chunks = []; this.index = 0; this.currentTitle = ''; this.readSource = ''; if (showStatus) this.setStatus(t('tts.idle')); this.refreshUI(); }, finish(statusText = t('tts.done')) { this.utteranceSerial += 1; this.speaking = false; this.paused = false; this.utterance = null; this.index = 0; this.currentTitle = ''; this.readSource = ''; this.setStatus(statusText); this.refreshUI(); } }; /* ============================================================ 4. 划词高亮系统 ============================================================ */ let pendingSelection = null; let selectionEchoToken = 0; function positionSelectionMenu(menu, x, y) { const w = menu.offsetWidth || 280; const h = menu.offsetHeight || 40; let left = x - w / 2; if (left < 8) left = 8; if (left + w > window.innerWidth - 8) left = window.innerWidth - w - 8; let top = y - h - 8; if (top < 56) top = y + 24; menu.style.left = left + 'px'; menu.style.top = top + 'px'; } function showSelectionMenu(x, y, text, anchor) { const menu = $('#selection-menu'); const notesVisible = isReaderFeatureVisible('notes'); const aiVisible = isReaderFeatureVisible('ai'); menu.querySelectorAll('[data-action="highlight"], [data-action="note"], [data-action="echo"]').forEach(button => { button.hidden = !notesVisible; button.disabled = !notesVisible; }); menu.querySelectorAll('[data-action="ask"], [data-action="translate"]').forEach(button => { button.hidden = !aiVisible; button.disabled = !aiVisible; }); const echoButton = menu.querySelector('[data-action="echo"]'); if (echoButton) { echoButton.hidden = true; echoButton.disabled = true; } menu.classList.add('active'); pendingSelection = { text, anchor, echoMatches: [] }; positionSelectionMenu(menu, x, y); if (notesVisible) prepareSelectionEcho(text, x, y); } function hideSelectionMenu() { $('#selection-menu').classList.remove('active'); pendingSelection = null; } function bindSelectionMenuDismissTarget(target) { if (!target || target.__shufangSelectionDismissBound) return; target.__shufangSelectionDismissBound = true; ['scroll', 'wheel'].forEach(type => { target.addEventListener(type, hideSelectionMenu, { passive: true }); }); } function noteTokens(text) { const normalized = String(text || '') .toLowerCase() .replace(/[^\p{L}\p{N}\u4e00-\u9fff]+/gu, ' ') .trim(); if (!normalized) return new Set(); const tokens = new Set(); const words = normalized.split(/\s+/).filter(Boolean); words.forEach(word => { if (/[\u4e00-\u9fff]/u.test(word)) { const chars = [...word].filter(ch => /\p{L}|\p{N}/u.test(ch)); chars.forEach(ch => tokens.add(ch)); for (let i = 0; i < chars.length - 1; i++) tokens.add(chars[i] + chars[i + 1]); } else if (word.length > 1) { tokens.add(word); } }); return tokens; } function tokenSimilarity(a, b) { if (!a.size || !b.size) return 0; let intersection = 0; a.forEach(token => { if (b.has(token)) intersection++; }); return intersection / Math.sqrt(a.size * b.size); } async function findSelectionEchoes(text) { const query = noteTokens(text); if (query.size < 3) return []; const [books, highlights] = await Promise.all([Store.getBooks(), Store.getAllHighlights()]); const bookById = new Map(books.map(book => [book.id, book])); const selected = String(text || '').trim(); return highlights .map(item => { const quote = String(item.text || '').trim(); const note = String(item.note || '').trim(); const haystack = [quote, note].filter(Boolean).join(' '); if (!haystack || haystack === selected) return null; const score = tokenSimilarity(query, noteTokens(haystack)); if (score < 0.24) return null; const book = bookById.get(item.bookId) || {}; return { item, quote, note, score, book }; }) .filter(Boolean) .sort((a, b) => b.score - a.score || (b.item.createdAt || 0) - (a.item.createdAt || 0)) .slice(0, 4); } async function prepareSelectionEcho(text, x, y) { if (!isReaderFeatureVisible('notes')) return; const token = ++selectionEchoToken; const menu = $('#selection-menu'); const button = menu.querySelector('[data-action="echo"]'); if (!button) return; try { const matches = await findSelectionEchoes(text); if (token !== selectionEchoToken || !pendingSelection || pendingSelection.text !== text) return; pendingSelection.echoMatches = matches; if (matches.length) { button.hidden = false; button.disabled = false; positionSelectionMenu(menu, x, y); } } catch (e) {} } function revealSavedHighlightInNotes(id) { const panel = $('#notes-panel'); const list = $('#notes-list'); if (!panel || !list || !panel.classList.contains('open')) return; requestAnimationFrame(() => { const item = [...list.querySelectorAll('.note-item')].find(node => String(node.dataset.id) === String(id)); if (!item) return; item.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); item.classList.add('just-saved'); clearTimeout(item._justSavedTimer); item._justSavedTimer = setTimeout(() => item.classList.remove('just-saved'), 1200); }); } function openEchoModal(matches = []) { const list = $('#echo-list'); const rows = Array.isArray(matches) ? matches : []; if (!rows.length) { list.innerHTML = `
${escapeHtml(t('echo.empty'))}
`; } else { list.innerHTML = rows.map(({ quote, note, book, item }) => { const title = book && book.title ? book.title : (I18N.lang() === 'en' ? 'Unknown book' : '未知书籍'); const meta = [ t('echo.from', { title }), book && book.author ? book.author : '', item && item.createdAt ? new Date(item.createdAt).toLocaleDateString(uiLocale()) : '' ].filter(Boolean).join(' · '); return `
${escapeHtml(quote)}
${note ? `
${renderMarkdownLite(note)}
` : ''}
${escapeHtml(meta)}
`; }).join(''); } $('#echo-mask').classList.add('active'); syncOpenStates(); } function selectedElementFromSelection(selection) { if (!selection || !selection.anchorNode) return null; const node = selection.anchorNode; return node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement; } function windowReaderSelectionAnchor(selection) { const type = Reader.currentEngine; if (type === 'pdf') { const nodes = [selection?.anchorNode, selection?.focusNode]; for (const node of nodes) { const el = node && node.nodeType === Node.ELEMENT_NODE ? node : node?.parentElement; const page = el?.closest?.('.pdf-page-wrap, .pdf-continuous-page'); const pageNumber = Number(page?.dataset?.page); if (Number.isFinite(pageNumber) && pageNumber > 0) { let context = {}; try { context = NoteLocations.captureTextRange(page.querySelector('.pdf-text-layer') || page, selection.getRangeAt(0)); } catch (_) {} return { type: 'pdf', page: pageNumber, ...context }; } } return { type: 'pdf', page: Reader.pdfPage || 1 }; } if (Reader.isScrollableDocumentEngine?.()) { const position = Reader.currentBookmarkPosition?.() || {}; let context = {}; try { context = NoteLocations.captureTextRange($('#doc-inner'), selection.getRangeAt(0)); } catch (_) {} return { type, position, ...context }; } return { type }; } function readerElementFromSelection(selection) { if (!selection || !selection.rangeCount) return null; const nodes = [selection.anchorNode, selection.focusNode]; for (const node of nodes) { const el = node && node.nodeType === Node.ELEMENT_NODE ? node : node && node.parentElement; const reader = el && el.closest ? el.closest('#reader') : null; if (reader) return reader; } try { const range = selection.getRangeAt(0); const common = range.commonAncestorContainer; const el = common && common.nodeType === Node.ELEMENT_NODE ? common : common && common.parentElement; return el && el.closest ? el.closest('#reader') : null; } catch (err) { return null; } } function selectionMenuPoint(selection, event = null) { try { const range = selection.getRangeAt(0); const rects = [...range.getClientRects()].filter(rect => rect.width || rect.height); const rect = rects[0] || range.getBoundingClientRect(); if (rect && (rect.width || rect.height)) { return { x: rect.left + rect.width / 2, y: rect.top }; } } catch (err) {} const touch = event && event.changedTouches && event.changedTouches[0]; return { x: touch ? touch.clientX : (event && Number.isFinite(event.clientX) ? event.clientX : window.innerWidth / 2), y: touch ? touch.clientY : (event && Number.isFinite(event.clientY) ? event.clientY : window.innerHeight / 2) }; } function showWindowReaderSelectionMenu(event = null) { if (!Reader.currentBook) return; if (Reader.currentEngine === 'epub') return; const targetEl = event && event.target ? (event.target.nodeType === Node.ELEMENT_NODE ? event.target : event.target.parentElement) : null; if (targetEl && (targetEl.closest('#selection-menu') || targetEl.closest('.modal-mask'))) return; const sel = window.getSelection ? window.getSelection() : null; const text = String(sel || '').trim(); if (!sel || text.length < 2) { hideSelectionMenu(); return; } if (!readerElementFromSelection(sel)) return; const point = selectionMenuPoint(sel, event); showSelectionMenu(point.x, point.y, text, windowReaderSelectionAnchor(sel)); } function scheduleWindowReaderSelectionMenu(event = null, delay = 0) { setTimeout(() => showWindowReaderSelectionMenu(event), delay); } function captureReaderSelectionForShortcut() { if (pendingSelection && pendingSelection.text) { return { text: pendingSelection.text, anchor: pendingSelection.anchor || { type: Reader.currentEngine }, echoMatches: pendingSelection.echoMatches || [] }; } if (!Reader.currentBook) return null; if (Reader.currentEngine === 'epub' && Reader.rendition) { try { const contents = Reader.rendition.getContents ? Reader.rendition.getContents() : []; for (const content of contents) { const selection = content.window && content.window.getSelection ? content.window.getSelection() : null; const text = String(selection || '').trim(); if (!text || !selection.rangeCount) continue; let cfi = null; const range = selection.getRangeAt(0); try { if (typeof content.cfiFromRange === 'function') cfi = content.cfiFromRange(range, EPUB_ANNOTATION_IGNORE_CLASS); } catch (err) {} try { if (!cfi && Reader.ebook && typeof Reader.ebook.cfiFromRange === 'function') cfi = Reader.ebook.cfiFromRange(range); } catch (err) {} return { text, anchor: { type: 'epub', cfi } }; } } catch (err) {} } const selection = window.getSelection ? window.getSelection() : null; const text = String(selection || '').trim(); if (!text || !selection.rangeCount) return null; const el = selectedElementFromSelection(selection); if (!el || !el.closest('#reader')) return null; return { text, anchor: windowReaderSelectionAnchor(selection) }; } async function runSelectionAction(action, selection = null) { if (['ask', 'translate'].includes(action) && !isReaderFeatureVisible('ai')) { notifyHiddenFeature(); hideSelectionMenu(); return false; } if (['highlight', 'note', 'echo'].includes(action) && !isReaderFeatureVisible('notes')) { notifyHiddenFeature(); hideSelectionMenu(); return false; } const selected = selection || captureReaderSelectionForShortcut(); if (!selected || !String(selected.text || '').trim()) { toast(t('toast.ttsNoSelection')); return false; } const text = String(selected.text || '').trim(); const anchor = selected.anchor || { type: Reader.currentEngine }; if (action === 'ask') { AICompanion.open(text); hideSelectionMenu(); return true; } if (action === 'translate') { TranslationCompare.translate(text, anchor); hideSelectionMenu(); return true; } if (action === 'echo') { openEchoModal(selected.echoMatches || []); hideSelectionMenu(); return true; } if (action === 'copy') { navigator.clipboard.writeText(text); toast(t('toast.copied')); hideSelectionMenu(); return true; } if (action === 'highlight' || action === 'note') { let note = ''; let highlightText = text; if (action === 'note') { const noteResult = await openNoteModal(text); if (noteResult === null) { hideSelectionMenu(); return false; } if (typeof noteResult === 'string') { note = noteResult; } else { note = String(noteResult.note || '').trim(); highlightText = String(noteResult.quote || text).trim() || text; } } const h = { bookId: Reader.currentBook.id, text: highlightText, note, cfi: anchor.type === 'epub' ? anchor.cfi : null, location: NoteLocations.capture(anchor, text), createdAt: Date.now() }; if (h.cfi) Object.assign(h, await NoteLocations.enrichEpub(h, Reader.ebook)); const id = await Store.addHighlight(h); h.id = id; Stats.recordFirstHighlight(); if (anchor.type === 'epub' && h.cfi && !h.locationUnresolved) Reader.setEpubHighlightAnnotation(h, true); await Reader.renderNotes(); revealSavedHighlightInNotes(id); toast(action === 'note' ? t('toast.noteSaved') : t('toast.highlighted')); hideSelectionMenu(); return true; } return false; } document.getElementById('selection-menu').addEventListener('click', async (e) => { const button = e.target.closest('button[data-action]'); if (!button) return; await runSelectionAction(button.dataset.action, pendingSelection); }); // 在 PDF 文本层和 docx 内部也支持选区 document.addEventListener('mouseup', (e) => { // 不在阅读器里,或在 EPUB 里(EPUB 由 iframe 内部事件处理),跳过 scheduleWindowReaderSelectionMenu(e, 0); }); document.addEventListener('touchend', (e) => { scheduleWindowReaderSelectionMenu(e, 90); }, { passive: true }); document.addEventListener('keyup', (e) => { if (e.key === 'Shift' || e.key.startsWith('Arrow') || e.key === 'Home' || e.key === 'End') { scheduleWindowReaderSelectionMenu(e, 0); } }); document.addEventListener('mousedown', (e) => { if (e.target.closest('#selection-menu')) return; hideSelectionMenu(); }); document.addEventListener('selectionchange', () => { if (!$('#selection-menu').classList.contains('active')) return; const text = String(window.getSelection && window.getSelection() || '').trim(); if (!text) hideSelectionMenu(); }); bindSelectionMenuDismissTarget(document); bindSelectionMenuDismissTarget(window); bindSelectionMenuDismissTarget(document.getElementById('reader')); bindSelectionMenuDismissTarget(document.getElementById('doc-viewer')); bindSelectionMenuDismissTarget(document.getElementById('pdf-viewer')); /* ============================================================ 5. 笔记输入弹窗 ============================================================ */ function quoteLooksLikeBrokenTextLayer(text) { const value = String(text || ''); if (!value) return false; if (/[\uFFFD\u25A1\u25A0]/.test(value)) return true; const cjkCount = (value.match(/[\u3400-\u9FFF]/g) || []).length; if (cjkCount < 8) return false; const caretCount = (value.match(/\^/g) || []).length; const slashBetweenCjk = (value.match(/[\u3400-\u9FFF][\/\\][\u3400-\u9FFF]/g) || []).length; const suspiciousMarks = caretCount + slashBetweenCjk * 2; return suspiciousMarks >= 3 || suspiciousMarks / Math.max(1, cjkCount) > 0.08; } function repairQuoteLocally(text) { let value = String(text || ''); if (!value) return value; value = value .replace(/\r\n?/g, '\n') .replace(/([\u3400-\u9FFF])\^\s*(?=[\u3400-\u9FFF])/g, '$1') .replace(/([\u3400-\u9FFF])\^\s*\^\s*(?=[\u3400-\u9FFF])/g, '$1') .replace(/([\u3400-\u9FFF])(?:\/|\u5E7A)\u52FA(?=[\u3400-\u9FFF])/g, '$1\u7D04') .replace(/([\u3400-\u9FFF])\s+([,。!?;:、])/g, '$1$2') .replace(/([“‘(《「『])\s+/g, '$1') .replace(/\s+([”’)》」』])/g, '$1') .replace(/[ \t]{2,}/g, ' ') .replace(/\n{3,}/g, '\n\n') .trim(); return value; } function normalizeAiQuoteRepairResult(raw) { let text = String(raw || '').trim(); const fenced = text.match(/```(?:text|markdown|md)?\s*([\s\S]*?)```/i); if (fenced) text = fenced[1].trim(); text = text .replace(/^["“”']|["“”']$/g, '') .replace(/^修正(?:后的)?(?:引用|原文)?[::]\s*/i, '') .trim(); return text; } function buildQuoteRepairMessages(quote, originalQuote = '') { const book = Reader.currentBook; const chapter = ($('#reader-chapter')?.textContent || '').trim(); const pageContext = (typeof AIRepair !== 'undefined' && AIRepair.currentText) ? AIRepair.currentText().slice(0, 3600) : ''; const system = [ '你是电子书笔记引用修复器。', '只修复明显由 PDF/EPUB 文字层、编码映射或字体拆字造成的乱码。', '不要润色,不要扩写,不要翻译,不要进行简繁转换,不要改变原句意思。', '如果能从上下文高度确定原字,可以恢复;无法确定时,只清理明显垃圾符号。', '只输出修正后的引用原文,不要解释,不要 Markdown。' ].join('\n'); const user = [ `书名:${book ? book.title : '未知'}`, `作者:${book && book.author ? book.author : '未知'}`, `位置:${chapter || '未知'}`, '', originalQuote && originalQuote !== quote ? `原始选中文本:\n${originalQuote}\n` : '', `当前引用文本:\n${quote}`, '', pageContext ? `当前页可参考上下文:\n${pageContext}` : '' ].join('\n'); return [ { role: 'system', content: system }, { role: 'user', content: user } ]; } async function repairNoteQuoteWithAI(quote, originalQuote = '') { const { endpoint, model, apiKey } = AICompanion.currentConfig(); if (!apiKey) { toast(t('ai.needKey')); AICompanion.openSettings(); return null; } if (!endpoint) { toast(t('ai.needEndpoint')); AICompanion.openSettings(); return null; } if (!model) { toast(t('ai.needModel')); AICompanion.openSettings(); return null; } const data = await AICompanion.requestAI(buildQuoteRepairMessages(quote, originalQuote), { maxTokens: Math.min(900, Math.max(220, Math.ceil(String(quote || '').length * 1.6))), temperature: 0.02 }); const answer = data.choices && data.choices[0] && data.choices[0].message ? data.choices[0].message.content : ''; return normalizeAiQuoteRepairResult(answer); } function openNoteModal(quote) { return new Promise((resolve) => { const mask = $('#modal-mask'); const quoteEl = $('#modal-quote'); const quoteHint = $('#modal-quote-hint'); const repairBtn = $('#modal-repair-quote'); const suspicious = quoteLooksLikeBrokenTextLayer(quote); const localQuote = suspicious ? repairQuoteLocally(quote) : String(quote || '').trim(); const locallyFixed = suspicious && localQuote && localQuote !== String(quote || '').trim(); quoteEl.textContent = localQuote || quote; quoteHint.textContent = t(locallyFixed ? 'modal.quoteLocalFixedHint' : (suspicious ? 'modal.quoteEncodingHint' : 'modal.quoteEditHint')); quoteHint.classList.toggle('is-warning', suspicious); if (repairBtn) { repairBtn.hidden = !suspicious; repairBtn.removeAttribute('disabled'); } $('#modal-textarea').value = ''; mask.classList.add('active'); setTimeout(() => $('#modal-textarea').focus(), 50); const cleanup = () => { mask.classList.remove('active'); $('#modal-save').onclick = null; $('#modal-cancel').onclick = null; if (repairBtn) repairBtn.onclick = null; }; if (repairBtn) { repairBtn.onclick = async () => { const currentQuote = quoteEl.textContent.trim() || String(quote || '').trim(); repairBtn.setAttribute('disabled', 'disabled'); toast(t('toast.quoteRepairThinking'), 1800); try { const repaired = await repairNoteQuoteWithAI(currentQuote, quote); if (repaired && repaired !== currentQuote) { quoteEl.textContent = repaired; quoteHint.textContent = t('modal.quoteLocalFixedHint'); toast(t('toast.quoteRepaired'), 2400); } else if (repaired !== null) { toast(t('toast.quoteRepairNoChange'), 2600); } } catch (err) { toast(t('toast.quoteRepairFailed', { message: err.message || err }), 4200); } finally { repairBtn.removeAttribute('disabled'); } }; } $('#modal-save').onclick = () => { const note = $('#modal-textarea').value.trim(); const editedQuote = quoteEl.textContent.trim() || String(quote || '').trim(); cleanup(); resolve({ note, quote: editedQuote }); }; $('#modal-cancel').onclick = () => { cleanup(); resolve(null); }; }); } /* ============================================================ 6. 导出(Markdown) ============================================================ */ async function exportBookNotes(bookId) { const book = await Store.getBook(bookId); if (!book) return; let highlights = await Store.getHighlights(bookId); if (highlights.length === 0) { toast(t('toast.noNotesBook')); return; } if (book.format === 'epub') { await ensureReaderLibrary('epub'); const active = Reader.currentBook?.id === bookId && Reader.ebook; const ebook = active || ePub(book.data.slice(0)); try { await ebook.ready; highlights = await Promise.all(highlights.map(item => NoteLocations.enrichEpub(item, ebook))); } finally { if (!active) ebook.destroy(); } } highlights = NoteLocations.sort(highlights); let md = `# ${book.title}\n\n`; if (book.author) md += `*${book.author}*\n\n`; md += `> ${t('notes.total', { count: highlights.length })}\n\n`; const groups = new Map(); for (const h of highlights) { const label = NoteLocations.label(h.location) || t('notes.unlocated'); const key = h.location?.chapterKey || (h.location?.page ? `page:${h.location.page}` : 'unlocated'); if (!groups.has(key)) groups.set(key, { label, notes: [] }); groups.get(key).notes.push(h); } for (const group of groups.values()) { md += `## ${group.label.replace(/[\r\n]/g, ' ')}\n\n`; for (const h of group.notes) { if (h.location?.page && h.location?.chapterTitle) md += `*${t('notes.page', { page: h.location.page })}*\n\n`; md += `> ${String(h.text || '').replace(/\n/g, '\n> ')}\n\n`; if (h.note) md += `${h.note}\n\n`; md += `---\n\n`; } } downloadText(md, `${book.title} - 笔记.md`); } async function exportBookAnnotationsPdf(bookId) { const printWin = window.open('', '_blank'); if (!printWin) { toast(t('toast.popupBlocked'), 3500); return; } printWin.document.write('正在生成我的版本...正在生成我的版本...'); const book = await Store.getBook(bookId); if (!book) { printWin.close(); toast(I18N.lang() === 'en' ? 'Book not found; export failed' : '书籍不存在,无法导出'); return; } const highlights = await Store.getHighlights(bookId); const position = normalizePosition(await Store.getPosition(bookId), book.format); const pct = progressPercent(position); const stampAt = Stats.getStamp(bookId); const sorted = highlights.sort((a, b) => a.createdAt - b.createdAt); const noteCount = sorted.filter(h => h.note && h.note.trim()).length; const firstNoteAt = sorted[0] ? sorted[0].createdAt : null; const lastNoteAt = sorted[sorted.length - 1] ? sorted[sorted.length - 1].createdAt : null; const exportedAt = new Date().toLocaleString('zh-CN'); const metaRows = [ ['书名', book.title || '未命名'], ['作者', book.author || '未记录'], ['格式', String(book.format || '').toUpperCase()], ['阅读进度', pct > 0 ? `${pct}%` : '未开始'], ['加入书房', book.addedAt ? formatDate(book.addedAt) : '未记录'], ['已读记录', stampAt ? formatDate(stampAt) : '未记录'], ['划线', `${sorted.length} 条`], ['笔记', `${noteCount} 条`], ['首次评注', firstNoteAt ? new Date(firstNoteAt).toLocaleString('zh-CN') : '暂无'], ['最近评注', lastNoteAt ? new Date(lastNoteAt).toLocaleString('zh-CN') : '暂无'] ]; const annotationHtml = sorted.length ? sorted.map((h, idx) => `
${String(idx + 1).padStart(2, '0')}
${escapeHtml(h.text).replace(/\n/g, '
')}
${h.note ? `
${renderMarkdownLite(h.note)}
` : '
未写笔记
'}
${escapeHtml(new Date(h.createdAt).toLocaleString('zh-CN'))}
`).join('') : `
这本书还没有划线和笔记。先留一页空白,等下一次读到心动处。
`; const coverHtml = book.cover ? `` : `
${escapeHtml(book.title || '书')}
`; const html = ` ${escapeHtml(safeFileName(book.title))} - 我的版本
${coverHtml}
我的版本 · TingRead

${escapeHtml(book.title || '未命名')}

${escapeHtml(book.author || '未记录作者')}
${metaRows.map(([k, v]) => `
${escapeHtml(k)}${escapeHtml(v)}
`).join('')}

评注

${annotationHtml}