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:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent
b8c8d24107
commit
3ccd044527
+130
-3
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user