feat: 内置阅读器、批注笔记与 AI 助手,发布 1.3.0

新增 PDF/EPUB/MOBI/AZW 内置阅读器,PDF 分段读取支持超大文件,
批注、读书与画布笔记、封面生成与本地导入。AI 助手支持三种协议、
图像上下文与安全 Markdown 渲染,上下文范围改为 选中/当前页/全文,
页面与全文无需选中文本即可发送,全文会提示可能超出模型限制。

便携版输出目录固定为 PeopleLib-windows-x64,不再随版本号变化,
避免升级后 data/ 被遗留在旧目录。

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
lofyer
2026-08-03 12:13:02 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent b8c8d24107
commit 3ccd044527
307 changed files with 98477 additions and 1148 deletions
+130 -3
View File
@@ -2,15 +2,43 @@ $('minBtn').onclick = () => window.api.minimize();
$('maxBtn').onclick = () => window.api.maximize();
$('closeBtn').onclick = () => window.api.close();
let uiTheme = 'dark';
function applyUiTheme(value) {
uiTheme = value === 'light' ? 'light' : 'dark';
document.documentElement.dataset.uiTheme = uiTheme;
const label = uiTheme === 'light' ? '切换到暗色主题' : '切换到明亮主题';
$('uiThemeBtn').title = label;
$('uiThemeBtn').setAttribute('aria-label', label);
}
$('uiThemeBtn').onclick = async () => {
const next = uiTheme === 'dark' ? 'light' : 'dark';
applyUiTheme(next);
const result = await window.api.ui.setTheme(next);
if (!result || !result.ok) applyUiTheme(uiTheme === 'dark' ? 'light' : 'dark');
};
async function initUiTheme() {
const result = await window.api.ui.getTheme();
applyUiTheme(result && result.ok ? result.data : 'dark');
const unsubscribe = window.api.ui.onThemeChanged(applyUiTheme);
if (typeof unsubscribe === 'function') {
window.addEventListener('beforeunload', unsubscribe, { once: true });
}
}
initUiTheme();
let currentTab = 'library';
function switchTab(tab) {
currentTab = tab;
document.querySelectorAll('.tab').forEach((t) => t.classList.toggle('active', t.dataset.tab === tab));
$('libraryTab').classList.toggle('hidden', tab !== 'library');
$('notesTab').classList.toggle('hidden', tab !== 'notes');
$('browseTab').classList.toggle('hidden', tab !== 'browse');
$('settingsTab').classList.toggle('hidden', tab !== 'settings');
if (tab === 'library') Library.refresh(true);
if (tab === 'notes') Notes.refresh();
}
document.querySelectorAll('.tab').forEach((t) => {
@@ -19,6 +47,17 @@ document.querySelectorAll('.tab').forEach((t) => {
Browse.init();
Library.init();
Notes.init();
if (window.api.reader && window.api.reader.onNotesChanged) {
const unsubscribeNotes = window.api.reader.onNotesChanged(() => {
Notes.markDirty();
if (currentTab === 'notes') Notes.refresh(true);
});
if (typeof unsubscribeNotes === 'function') {
window.addEventListener('beforeunload', unsubscribeNotes, { once: true });
}
}
const sortSelect = $('sortSelect');
sortSelect.value = Library.getSortMode();
@@ -60,9 +99,9 @@ $('zlibLoginBtn').onclick = async () => {
const r = await openModal('Z-Library 登录', `
<p style="margin-bottom:8px;">使用 Z-Library 账号登录(保存在本地 userData 目录)</p>
<div style="display:flex;flex-direction:column;gap:8px;">
<input id="zlibEmail" type="email" placeholder="邮箱" style="padding:8px;background:#222;border:1px solid #444;color:#eee;border-radius:4px;" />
<input id="zlibPassword" type="password" placeholder="密码" style="padding:8px;background:#222;border:1px solid #444;color:#eee;border-radius:4px;" />
<div id="zlibErr" style="color:#f66;font-size:12px;min-height:16px;"></div>
<input id="zlibEmail" class="modal-input" type="email" placeholder="邮箱" />
<input id="zlibPassword" class="modal-input" type="password" placeholder="密码" />
<div id="zlibErr" class="note-form-error"></div>
</div>
`, async () => {
const email = $('zlibEmail').value.trim();
@@ -180,6 +219,94 @@ $('semanticKeyClearBtn').onclick = async () => {
refreshSemanticKeyStatus();
const AI_PROTOCOL_INFO = {
anthropic: {
label: 'Anthropic',
baseUrl: 'https://api.anthropic.com/v1',
model: 'claude-sonnet-4-5',
hint: 'Anthropic 原生 Messages API,图像使用 base64 source 格式。'
},
'openai-responses': {
label: 'OpenAI Responses',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4.1-mini',
hint: 'OpenAI 原生 Responses API,使用 /v1/responses。'
},
'chat-completions': {
label: 'OpenAI 兼容',
baseUrl: 'https://api.openai.com/v1',
model: 'gpt-4o-mini',
hint: 'Chat Completions API,适用于 DeepSeek、Kimi、硅基流动、Ollama 等兼容服务。'
}
};
function syncAiProtocolUi() {
const info = AI_PROTOCOL_INFO[$('aiProtocol').value] || AI_PROTOCOL_INFO['chat-completions'];
$('aiBaseUrl').placeholder = info.baseUrl;
$('aiModel').placeholder = info.model;
$('aiProtocolHint').textContent = info.hint;
}
$('aiProtocol').onchange = syncAiProtocolUi;
async function refreshAiStatus() {
const r = await window.api.ai.status();
const s = r.ok && r.data ? r.data : null;
if (!s) { $('aiStatus').textContent = '状态读取失败'; return; }
$('aiProtocol').value = s.protocol || 'chat-completions';
$('aiBaseUrl').value = s.baseUrl || '';
$('aiModel').value = s.model || '';
$('aiVision').checked = !!s.vision;
syncAiProtocolUi();
const ready = s.ready === undefined ? (s.hasKey || s.isLocal) : !!s.ready;
const protocol = AI_PROTOCOL_INFO[s.protocol] || AI_PROTOCOL_INFO['chat-completions'];
if (ready) {
$('aiStatus').textContent = `已就绪 · ${protocol.label} · ${s.model}${s.vision ? ' · 支持图像' : ''}${s.hasKey ? (s.persistent ? 'Key 已加密存储)' : 'Key 仅本次运行有效)') : '(本地模型,无需 Key'}`;
} else if (s.modelConfigured) {
$('aiStatus').textContent = s.keyState === 'unreadable'
? `模型已配置 · ${protocol.label} · ${s.model} · 已保存的 API Key 无法读取,请重新输入`
: `模型已配置 · ${protocol.label} · ${s.model} · 尚缺 API Key`;
} else {
$('aiStatus').textContent = '尚未保存模型配置:请填写接口地址与模型名称';
}
$('aiKey').placeholder = s.hasKey
? '已保存,留空表示不修改'
: (s.keyState === 'unreadable'
? '原 Key 无法读取,请重新输入'
: (s.isLocal ? '本地模型可留空' : '必填(仅本地模型可留空)'));
$('aiClearBtn').classList.toggle('hidden', !s.hasKey);
}
$('aiSaveBtn').onclick = async () => {
const btn = $('aiSaveBtn');
const keyInput = $('aiKey');
const cfg = {
protocol: $('aiProtocol').value,
baseUrl: $('aiBaseUrl').value.trim(),
model: $('aiModel').value.trim(),
vision: $('aiVision').checked
};
// 留空表示不改动已存的 Key,避免用户只改模型名就把 Key 清掉
if (keyInput.value.trim()) cfg.apiKey = keyInput.value.trim();
const r = await window.api.ai.save(cfg);
keyInput.value = '';
btn.textContent = r.ok ? '已保存 ✓' : '保存失败';
btn.title = r.ok ? '' : (r.error || '');
if (!r.ok) await confirmModal('保存失败', r.error || '请检查接口地址与模型名称');
await refreshAiStatus();
setTimeout(() => { btn.textContent = '保存'; }, 1500);
};
$('aiClearBtn').onclick = async () => {
const ok = await confirmModal('清除 AI 配置', '确定清除接口地址、模型与 API Key 吗?');
if (!ok) return;
await window.api.ai.clear();
$('aiKey').value = '';
await refreshAiStatus();
};
refreshAiStatus();
async function runUpdateCheck(silent) {
const statusEl = $('updateStatus');
const btn = $('checkUpdateBtn');