mirror of
https://github.com/fancive/obsidian-parallel-reader.git
synced 2026-07-22 06:53:43 +00:00
feat: improve reader cache and card UX
Change-Id: Ie1b5c5ba1a6538b701af2591effd1845dca2f670
This commit is contained in:
parent
08f510c65c
commit
5c2c97fdbe
16 changed files with 868 additions and 102 deletions
12
README.md
12
README.md
|
|
@ -100,16 +100,21 @@ Then in the plugin settings, paste `/Users/you/bin/codex` (or `~/bin/claude`) in
|
|||
| `取消当前对照笔记生成` | Mark the active generation job as cancelled |
|
||||
| `清除当前笔记的缓存` | Drop the cache entry for the active note |
|
||||
| `清除所有缓存` | Wipe all cached summaries |
|
||||
| `聚焦上一张摘要卡片` / `Focus previous summary card` | Move the active summary card upward |
|
||||
| `聚焦下一张摘要卡片` / `Focus next summary card` | Move the active summary card downward |
|
||||
| `跳转到当前摘要卡片原文` / `Jump current summary card to source` | Jump the source editor to the active card |
|
||||
|
||||
## Interaction
|
||||
|
||||
| Action | Effect |
|
||||
|--------|--------|
|
||||
| Left-click a card body | Scroll source editor to that section |
|
||||
| Right-click a card | Context menu: Copy Markdown / Copy plain text / Copy anchor / Jump |
|
||||
| Right-click a card | Context menu: Copy Markdown / Copy plain text / Copy anchor / Jump / Edit / Delete card |
|
||||
| Header icon buttons | Regenerate or cancel / copy all Markdown / export to Vault |
|
||||
| File context menu | Generate / force regenerate / clear cache for a Markdown file |
|
||||
| Ribbon icon | Open the comparison pane for the active note |
|
||||
| `Alt+↑` / `Alt+↓` | Move between summary cards |
|
||||
| `Enter` in the summary pane | Jump to the active card's source line |
|
||||
| Drag to select text | Normal text selection (does not trigger jump) |
|
||||
| Scroll source editor | Active card gets highlighted on the right |
|
||||
|
||||
|
|
@ -132,10 +137,15 @@ The plugin keeps `main.js` as the generated Obsidian runtime bundle. Edit `main.
|
|||
|------|----------------|
|
||||
| `main.ts` | Obsidian lifecycle, commands, right-pane view, settings tab orchestration |
|
||||
| `src/anchor.ts` | Anchor-to-line matching and whitespace-normalized fallback |
|
||||
| `src/cache.ts` | Cache entry touch semantics and compact cache-file serialization |
|
||||
| `src/cards.ts` | Card list edit/delete helpers |
|
||||
| `src/i18n.ts` | Chinese/English UI strings and translation helper |
|
||||
| `src/navigation.ts` | Summary-card keyboard navigation helpers |
|
||||
| `src/prompt.ts` | Prompt construction, language controls, and custom prompt templating |
|
||||
| `src/settings.ts` | Defaults, provider presets, settings normalization, cache fingerprinting and pruning |
|
||||
| `src/vault.ts` | Vault path normalization and recursive folder creation |
|
||||
| `src/schema.ts` | JSON extraction, card payload normalization, structured-output schemas |
|
||||
| `src/scroll.ts` | Scroll-sync requestAnimationFrame throttling helper |
|
||||
| `src/providers.ts` | API provider request/response adapters |
|
||||
| `src/generation-job-manager.ts` | Per-file generation state, cancellation, and error classification |
|
||||
| `src/markdown.ts` | Card-to-Markdown/plain-text serialization |
|
||||
|
|
|
|||
89
main.js
89
main.js
File diff suppressed because one or more lines are too long
293
main.ts
293
main.ts
|
|
@ -1,18 +1,23 @@
|
|||
'use strict';
|
||||
import { Plugin, ItemView, PluginSettingTab, Setting, Notice, MarkdownView, TFile, Menu, MarkdownRenderer, requestUrl, setIcon } from 'obsidian';
|
||||
import { Plugin, ItemView, PluginSettingTab, Setting, Notice, MarkdownView, TFile, Menu, Modal, MarkdownRenderer, requestUrl, setIcon } from 'obsidian';
|
||||
import { spawn } from 'child_process';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { findLineForAnchor } from './src/anchor';
|
||||
import { serializeCacheFile, touchCacheEntry } from './src/cache';
|
||||
import { activeIndexAfterCardDelete, removeCardAt, updateCardAt } from './src/cards';
|
||||
import { translate } from './src/i18n';
|
||||
import { cardToMarkdown, cardToPlain, cardsToMarkdown } from './src/markdown';
|
||||
import { activeSectionLine, nextCardIndex } from './src/navigation';
|
||||
import { buildPrompts } from './src/prompt';
|
||||
import { ensureVaultFolder, folderPathsForTarget, normalizeVaultPath } from './src/vault';
|
||||
import {
|
||||
extractJson,
|
||||
normalizeCardsPayload,
|
||||
parseCardsJson,
|
||||
} from './src/schema';
|
||||
import { createRafThrottledHandler } from './src/scroll';
|
||||
import {
|
||||
buildAnthropicMessagesBody,
|
||||
buildGeminiBody,
|
||||
|
|
@ -40,10 +45,12 @@ import {
|
|||
applyApiProviderPreset,
|
||||
cacheEntryMatches,
|
||||
generationFingerprint,
|
||||
getApiBaseUrl,
|
||||
getApiFormat,
|
||||
getApiPreset,
|
||||
hashContent,
|
||||
isApiBackend,
|
||||
modelForApi,
|
||||
normalizeSettings,
|
||||
pruneCacheEntries,
|
||||
} from './src/settings';
|
||||
|
|
@ -166,7 +173,7 @@ async function summarizeViaClaudeCode(system, user, settings, job) {
|
|||
throw new Error('claude CLI returned a non-JSON envelope:\n' + stdout.slice(0, 500));
|
||||
}
|
||||
const resultText = envelope.result || envelope.content || '';
|
||||
return parseCardsJson(resultText);
|
||||
return parseCardsJson(resultText, settings);
|
||||
}
|
||||
|
||||
async function summarizeViaCodex(system, user, settings, job) {
|
||||
|
|
@ -174,7 +181,7 @@ async function summarizeViaCodex(system, user, settings, job) {
|
|||
const combined = `<<SYSTEM>>\n${system}\n<<USER>>\n${user}\n\nOutput JSON directly with no explanation.`;
|
||||
const args = ['exec', '--skip-git-repo-check', '-'];
|
||||
const { stdout } = await runCli(cmd, args, combined, settings.cliTimeoutMs, job);
|
||||
return parseCardsJson(stdout);
|
||||
return parseCardsJson(stdout, settings);
|
||||
}
|
||||
|
||||
async function testBackend(settings) {
|
||||
|
|
@ -283,6 +290,64 @@ async function copyToClipboard(text, successMsg) {
|
|||
}
|
||||
}
|
||||
|
||||
function cancellationNoticeKey(settings, job) {
|
||||
if (job?.phase === 'generating' && isApiBackend(settings?.backend)) {
|
||||
return 'cancelRequestedApiInFlight';
|
||||
}
|
||||
return 'cancelRequested';
|
||||
}
|
||||
|
||||
class CardEditModal extends Modal {
|
||||
plugin: ParallelReaderPlugin;
|
||||
card: any;
|
||||
onSave: (patch: any) => void | Promise<void>;
|
||||
|
||||
constructor(app, plugin, card, onSave) {
|
||||
super(app);
|
||||
this.plugin = plugin;
|
||||
this.card = card || {};
|
||||
this.onSave = onSave;
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
contentEl.createEl('h2', { text: this.plugin.t('editCardTitle') });
|
||||
|
||||
const titleInput = this.createLabeledInput(contentEl, this.plugin.t('editCardTitleField'), this.card.title || '');
|
||||
const gistInput = this.createLabeledTextarea(contentEl, this.plugin.t('editCardGistField'), this.card.gist || '', 3);
|
||||
const bulletsInput = this.createLabeledTextarea(contentEl, this.plugin.t('editCardBulletsField'), (this.card.bullets || []).join('\n'), 8);
|
||||
|
||||
const actions = contentEl.createDiv({ cls: 'parallel-reader-modal-actions' });
|
||||
addTextButton(actions, null, this.plugin.t('editCardCancel'), () => this.close(), 'parallel-reader-text-button');
|
||||
addTextButton(actions, null, this.plugin.t('editCardSave'), async () => {
|
||||
await this.onSave({
|
||||
title: titleInput.value.trim() || this.card.title || '',
|
||||
gist: gistInput.value.trim(),
|
||||
bullets: bulletsInput.value.split(/\r?\n/).map(line => line.trim()).filter(Boolean),
|
||||
});
|
||||
this.close();
|
||||
}, 'parallel-reader-text-button');
|
||||
}
|
||||
|
||||
createLabeledInput(parent, label, value) {
|
||||
const wrapper = parent.createDiv({ cls: 'parallel-reader-modal-field' });
|
||||
wrapper.createEl('label', { text: label });
|
||||
const input = wrapper.createEl('input', { attr: { type: 'text' } });
|
||||
input.value = value;
|
||||
return input;
|
||||
}
|
||||
|
||||
createLabeledTextarea(parent, label, value, rows) {
|
||||
const wrapper = parent.createDiv({ cls: 'parallel-reader-modal-field' });
|
||||
wrapper.createEl('label', { text: label });
|
||||
const textarea = wrapper.createEl('textarea');
|
||||
textarea.rows = rows;
|
||||
textarea.value = value;
|
||||
return textarea;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Right-pane view ---------- */
|
||||
|
||||
class ParallelReaderView extends ItemView {
|
||||
|
|
@ -310,14 +375,20 @@ class ParallelReaderView extends ItemView {
|
|||
getDisplayText() { return this.plugin.t('displayName'); }
|
||||
getIcon() { return 'book-open'; }
|
||||
|
||||
async onOpen() {
|
||||
onOpen() {
|
||||
const container = this.containerEl.children[1];
|
||||
container.empty();
|
||||
container.addClass('parallel-reader-container');
|
||||
container.setAttr('tabindex', '0');
|
||||
container.addEventListener('keydown', e => this.handleKeydown(e as KeyboardEvent));
|
||||
this.renderEmpty();
|
||||
this.focusSummaryPane();
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async onClose() {}
|
||||
onClose() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
renderEmpty() {
|
||||
this.sourceFile = null;
|
||||
|
|
@ -333,6 +404,13 @@ class ParallelReaderView extends ItemView {
|
|||
hint.createEl('code', { text: this.plugin.t('commandGenerate') });
|
||||
}
|
||||
|
||||
focusSummaryPane() {
|
||||
const container = this.containerEl.children[1] as HTMLElement;
|
||||
if (!container || typeof container.focus !== 'function') return false;
|
||||
container.focus({ preventScroll: true });
|
||||
return true;
|
||||
}
|
||||
|
||||
async loadFor(file, sections, stale) {
|
||||
this.sourceFile = file;
|
||||
this.sections = sections;
|
||||
|
|
@ -489,11 +567,19 @@ class ParallelReaderView extends ItemView {
|
|||
menu.addItem(it => it.setTitle(this.plugin.t('menuJumpSource')).setIcon('arrow-right')
|
||||
.onClick(() => this.plugin.scrollEditorToLine(s.startLine, this.sourceFile)));
|
||||
}
|
||||
menu.addSeparator();
|
||||
menu.addItem(it => it.setTitle(this.plugin.t('menuEditCard')).setIcon('pencil')
|
||||
.onClick(() => this.openEditCardModal(i)));
|
||||
menu.addItem(it => it.setTitle(this.plugin.t('menuDeleteCard')).setIcon('trash')
|
||||
.onClick(() => this.deleteCard(i)));
|
||||
menu.showAtMouseEvent(e);
|
||||
});
|
||||
|
||||
this.cards.push(card);
|
||||
});
|
||||
if (this.activeIdx >= 0 && this.cards[this.activeIdx]) {
|
||||
this.cards[this.activeIdx].addClass('is-active');
|
||||
}
|
||||
}
|
||||
|
||||
setActiveSection(idx) {
|
||||
|
|
@ -508,9 +594,70 @@ class ParallelReaderView extends ItemView {
|
|||
}
|
||||
}
|
||||
|
||||
moveActiveSection(delta) {
|
||||
const nextIdx = nextCardIndex(this.activeIdx, this.sections.length, delta);
|
||||
this.setActiveSection(nextIdx);
|
||||
this.focusSummaryPane();
|
||||
return nextIdx;
|
||||
}
|
||||
|
||||
jumpToActiveSection() {
|
||||
const line = activeSectionLine(this.sections, this.activeIdx);
|
||||
if (line < 0 || !this.sourceFile) return -1;
|
||||
this.plugin.scrollEditorToLine(line, this.sourceFile);
|
||||
return line;
|
||||
}
|
||||
|
||||
handleKeydown(e: KeyboardEvent) {
|
||||
if (e.altKey && e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
this.moveActiveSection(-1);
|
||||
return;
|
||||
}
|
||||
if (e.altKey && e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
this.moveActiveSection(1);
|
||||
return;
|
||||
}
|
||||
if (!e.altKey && !e.metaKey && !e.ctrlKey && !e.shiftKey && e.key === 'Enter') {
|
||||
const line = this.jumpToActiveSection();
|
||||
if (line >= 0) e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
async deleteCard(index) {
|
||||
if (!this.sourceFile) return false;
|
||||
const nextSections = removeCardAt(this.sections, index);
|
||||
if (nextSections.length === this.sections.length) return false;
|
||||
const previousLength = this.sections.length;
|
||||
this.sections = nextSections;
|
||||
this.activeIdx = activeIndexAfterCardDelete(index, previousLength, this.activeIdx);
|
||||
await this.plugin.cacheReplaceCards(this.sourceFile.path, nextSections);
|
||||
this.render();
|
||||
new Notice(this.plugin.t('cardDeleted'));
|
||||
return true;
|
||||
}
|
||||
|
||||
openEditCardModal(index) {
|
||||
if (!this.sourceFile || !this.sections[index]) return false;
|
||||
new CardEditModal(this.app, this.plugin, this.sections[index], patch => this.updateCard(index, patch)).open();
|
||||
return true;
|
||||
}
|
||||
|
||||
async updateCard(index, patch) {
|
||||
if (!this.sourceFile) return false;
|
||||
const nextSections = updateCardAt(this.sections, index, patch);
|
||||
if (nextSections.length !== this.sections.length) return false;
|
||||
this.sections = nextSections;
|
||||
await this.plugin.cacheReplaceCards(this.sourceFile.path, nextSections);
|
||||
this.render();
|
||||
new Notice(this.plugin.t('cardSaved'));
|
||||
return true;
|
||||
}
|
||||
|
||||
async exportToVault() {
|
||||
if (!this.sourceFile) return;
|
||||
const folder = this.plugin.settings.exportFolder.replace(/\/$/, '');
|
||||
const folder = normalizeVaultPath(this.plugin.settings.exportFolder);
|
||||
const name = `${this.sourceFile.basename} - ${this.plugin.t('displayName')}.md`;
|
||||
const targetPath = `${folder}/${name}`;
|
||||
|
||||
|
|
@ -526,11 +673,7 @@ class ParallelReaderView extends ItemView {
|
|||
].join('\n');
|
||||
|
||||
const app = this.plugin.app;
|
||||
// Ensure folder exists
|
||||
const folderTF = app.vault.getAbstractFileByPath(folder);
|
||||
if (!folderTF) {
|
||||
try { await app.vault.createFolder(folder); } catch (e) { /* exists race */ }
|
||||
}
|
||||
await ensureVaultFolder(app, folder);
|
||||
|
||||
const existing = app.vault.getAbstractFileByPath(targetPath);
|
||||
if (existing instanceof TFile) {
|
||||
|
|
@ -550,6 +693,8 @@ class ParallelReaderPlugin extends Plugin {
|
|||
jobs: GenerationJobManager;
|
||||
_scrollDispose: (() => void) | null;
|
||||
_settingsSaveTimer: ReturnType<typeof setTimeout> | null;
|
||||
_cacheSaveTimer: ReturnType<typeof setTimeout> | null;
|
||||
_cacheDirty: boolean;
|
||||
|
||||
t(key, vars?) {
|
||||
return translate(this.settings || DEFAULT_SETTINGS, key, vars);
|
||||
|
|
@ -558,6 +703,8 @@ class ParallelReaderPlugin extends Plugin {
|
|||
async onload() {
|
||||
await this.loadSettings();
|
||||
this.jobs = new GenerationJobManager();
|
||||
this._cacheSaveTimer = null;
|
||||
this._cacheDirty = false;
|
||||
|
||||
this.addRibbonIcon('book-open', this.t('ribbonOpen'), async () => {
|
||||
const active = this.getActiveView();
|
||||
|
|
@ -628,6 +775,26 @@ class ParallelReaderPlugin extends Plugin {
|
|||
},
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: 'parallel-reader-card-prev',
|
||||
name: this.t('cmdCardPrev'),
|
||||
hotkeys: [{ modifiers: ['Alt'], key: 'ArrowUp' }],
|
||||
callback: () => this.moveActiveCard(-1),
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: 'parallel-reader-card-next',
|
||||
name: this.t('cmdCardNext'),
|
||||
hotkeys: [{ modifiers: ['Alt'], key: 'ArrowDown' }],
|
||||
callback: () => this.moveActiveCard(1),
|
||||
});
|
||||
|
||||
this.addCommand({
|
||||
id: 'parallel-reader-card-jump',
|
||||
name: this.t('cmdCardJump'),
|
||||
callback: () => this.jumpActiveCard(),
|
||||
});
|
||||
|
||||
this.addSettingTab(new ParallelReaderSettingTab(this.app, this));
|
||||
|
||||
this.registerEvent(
|
||||
|
|
@ -652,6 +819,7 @@ class ParallelReaderPlugin extends Plugin {
|
|||
|
||||
async onunload() {
|
||||
await this.flushSettingsSave();
|
||||
await this.flushCacheSave();
|
||||
this.app.workspace.detachLeavesOfType(VIEW_TYPE_PARALLEL);
|
||||
}
|
||||
|
||||
|
|
@ -686,8 +854,32 @@ class ParallelReaderPlugin extends Plugin {
|
|||
}
|
||||
|
||||
async saveCache() {
|
||||
if (this._cacheSaveTimer) {
|
||||
clearTimeout(this._cacheSaveTimer);
|
||||
this._cacheSaveTimer = null;
|
||||
}
|
||||
this.pruneCache();
|
||||
await this.writeCacheFile();
|
||||
this._cacheDirty = false;
|
||||
}
|
||||
|
||||
scheduleCacheSave(delayMs = 5000) {
|
||||
this._cacheDirty = true;
|
||||
if (this._cacheSaveTimer) return;
|
||||
this._cacheSaveTimer = setTimeout(() => {
|
||||
this._cacheSaveTimer = null;
|
||||
if (!this._cacheDirty) return;
|
||||
this.saveCache().catch(e => console.error('[parallel-reader] failed to save cache', e));
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
async flushCacheSave() {
|
||||
if (this._cacheSaveTimer) {
|
||||
clearTimeout(this._cacheSaveTimer);
|
||||
this._cacheSaveTimer = null;
|
||||
}
|
||||
if (!this._cacheDirty) return;
|
||||
await this.saveCache();
|
||||
}
|
||||
|
||||
cacheFilePath() {
|
||||
|
|
@ -730,10 +922,7 @@ class ParallelReaderPlugin extends Plugin {
|
|||
await this.ensurePluginDataDir();
|
||||
await this.app.vault.adapter.write(
|
||||
this.cacheFilePath(),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
entries: this.cache,
|
||||
}, null, 2)
|
||||
serializeCacheFile(this.cache)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -750,13 +939,18 @@ class ParallelReaderPlugin extends Plugin {
|
|||
|
||||
async pruneCacheIfNeeded() {
|
||||
const removed = this.pruneCache();
|
||||
if (removed.length > 0) await this.writeCacheFile();
|
||||
if (removed.length > 0) await this.saveCache();
|
||||
return removed;
|
||||
}
|
||||
|
||||
cacheGet(filePath) {
|
||||
const entry = this.cache[filePath] || null;
|
||||
if (entry) entry.lastAccessedAt = new Date().toISOString();
|
||||
return this.cache[filePath] || null;
|
||||
}
|
||||
|
||||
async cacheTouch(filePath) {
|
||||
const entry = touchCacheEntry(this.cache[filePath] || null);
|
||||
if (!entry) return null;
|
||||
this.scheduleCacheSave();
|
||||
return entry;
|
||||
}
|
||||
|
||||
|
|
@ -773,6 +967,22 @@ class ParallelReaderPlugin extends Plugin {
|
|||
await this.saveCache();
|
||||
}
|
||||
|
||||
async cacheReplaceCards(filePath, cards) {
|
||||
const entry = this.cache[filePath];
|
||||
if (!entry) return false;
|
||||
const now = new Date().toISOString();
|
||||
entry.cards = (cards || []).map(card => ({
|
||||
title: card.title,
|
||||
anchor: card.anchor,
|
||||
gist: card.gist,
|
||||
bullets: card.bullets || [],
|
||||
}));
|
||||
entry.updatedAt = now;
|
||||
entry.lastAccessedAt = now;
|
||||
await this.saveCache();
|
||||
return true;
|
||||
}
|
||||
|
||||
async cacheDelete(filePath) {
|
||||
if (this.cache[filePath]) {
|
||||
delete this.cache[filePath];
|
||||
|
|
@ -800,6 +1010,28 @@ class ParallelReaderPlugin extends Plugin {
|
|||
return this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
}
|
||||
|
||||
getParallelView() {
|
||||
return this.app.workspace.getLeavesOfType(VIEW_TYPE_PARALLEL)[0]?.view as ParallelReaderView | undefined;
|
||||
}
|
||||
|
||||
moveActiveCard(delta) {
|
||||
const view = this.getParallelView();
|
||||
if (!view || !view.sections.length) {
|
||||
new Notice(this.t('noActiveCard'));
|
||||
return -1;
|
||||
}
|
||||
return view.moveActiveSection(delta);
|
||||
}
|
||||
|
||||
jumpActiveCard() {
|
||||
const view = this.getParallelView();
|
||||
if (!view || view.jumpToActiveSection() < 0) {
|
||||
new Notice(this.t('noActiveCard'));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
isGeneratingFile(file) {
|
||||
return !!file && !!file.path && this.jobs.isRunning(file.path);
|
||||
}
|
||||
|
|
@ -809,8 +1041,10 @@ class ParallelReaderPlugin extends Plugin {
|
|||
new Notice(this.t('noCancelableJob'));
|
||||
return false;
|
||||
}
|
||||
const job = this.jobs.get(file.path);
|
||||
const noticeKey = cancellationNoticeKey(this.settings, job);
|
||||
const cancelled = this.jobs.cancel(file.path);
|
||||
new Notice(cancelled ? this.t('cancelRequested') : this.t('noCancelableJob'));
|
||||
new Notice(cancelled ? this.t(noticeKey) : this.t('noCancelableJob'));
|
||||
return cancelled;
|
||||
}
|
||||
|
||||
|
|
@ -961,6 +1195,7 @@ class ParallelReaderPlugin extends Plugin {
|
|||
if (!force) {
|
||||
const entry = this.cacheGet(file.path);
|
||||
if (cacheEntryMatches(entry, content, this.settings)) {
|
||||
await this.cacheTouch(file.path);
|
||||
if (this.activeFileStillMatches(file)) {
|
||||
await view.loadFor(file, this.resolveCardAnchors(content, entry.cards), false);
|
||||
}
|
||||
|
|
@ -1059,6 +1294,7 @@ class ParallelReaderPlugin extends Plugin {
|
|||
const content = await this.app.vault.read(file);
|
||||
if (!this.activeFileStillMatches(file)) return;
|
||||
const stale = !cacheEntryMatches(entry, content, this.settings);
|
||||
await this.cacheTouch(file.path);
|
||||
const resolved = this.resolveCardAnchors(content, entry.cards);
|
||||
await view.loadFor(file, resolved, stale);
|
||||
}
|
||||
|
|
@ -1079,9 +1315,12 @@ class ParallelReaderPlugin extends Plugin {
|
|||
const scrollDom = cm && cm.scrollDOM ? cm.scrollDOM : mdView.contentEl.querySelector('.cm-scroller');
|
||||
if (!scrollDom) return;
|
||||
|
||||
const handler = () => this.handleEditorScroll(mdView);
|
||||
const handler = createRafThrottledHandler(() => this.handleEditorScroll(mdView));
|
||||
scrollDom.addEventListener('scroll', handler, { passive: true });
|
||||
this._scrollDispose = () => scrollDom.removeEventListener('scroll', handler);
|
||||
this._scrollDispose = () => {
|
||||
handler.cancel();
|
||||
scrollDom.removeEventListener('scroll', handler);
|
||||
};
|
||||
}
|
||||
|
||||
handleEditorScroll(mdView) {
|
||||
|
|
@ -1508,6 +1747,8 @@ export const __test = {
|
|||
GenerationJobAlreadyRunningError,
|
||||
GenerationJobCancelledError,
|
||||
GenerationJobManager,
|
||||
activeIndexAfterCardDelete,
|
||||
activeSectionLine,
|
||||
buildAnthropicMessagesBody,
|
||||
buildGeminiBody,
|
||||
buildOpenAiChatBody,
|
||||
|
|
@ -1515,13 +1756,23 @@ export const __test = {
|
|||
buildPrompts,
|
||||
cardsToMarkdown,
|
||||
cacheEntryMatches,
|
||||
cancellationNoticeKey,
|
||||
classifyGenerationError,
|
||||
createRafThrottledHandler,
|
||||
extractJson,
|
||||
findLineForAnchor,
|
||||
folderPathsForTarget,
|
||||
generationFingerprint,
|
||||
getApiBaseUrl,
|
||||
modelForApi,
|
||||
normalizeCardsPayload,
|
||||
nextCardIndex,
|
||||
pruneCacheEntries,
|
||||
removeCardAt,
|
||||
serializeCacheFile,
|
||||
summarizeViaApi,
|
||||
touchCacheEntry,
|
||||
translate,
|
||||
tokenLimitFieldForOpenAiChat,
|
||||
updateCardAt,
|
||||
};
|
||||
|
|
|
|||
14
src/cache.ts
Normal file
14
src/cache.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
'use strict';
|
||||
|
||||
export function touchCacheEntry(entry, now?) {
|
||||
if (!entry) return null;
|
||||
entry.lastAccessedAt = now || new Date().toISOString();
|
||||
return entry;
|
||||
}
|
||||
|
||||
export function serializeCacheFile(entries) {
|
||||
return JSON.stringify({
|
||||
version: 1,
|
||||
entries: entries || {},
|
||||
});
|
||||
}
|
||||
26
src/cards.ts
Normal file
26
src/cards.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
'use strict';
|
||||
|
||||
export function removeCardAt(cards, index) {
|
||||
const next = Array.isArray(cards) ? cards.slice() : [];
|
||||
if (!Number.isInteger(index) || index < 0 || index >= next.length) return next;
|
||||
next.splice(index, 1);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function activeIndexAfterCardDelete(deleteIndex, previousLength, activeIdx) {
|
||||
if (!Number.isInteger(deleteIndex) || !Number.isInteger(previousLength) || previousLength <= 0) return activeIdx;
|
||||
if (!Number.isInteger(activeIdx) || activeIdx < 0) return activeIdx;
|
||||
if (deleteIndex < 0 || deleteIndex >= previousLength) return activeIdx;
|
||||
const nextLength = previousLength - 1;
|
||||
if (nextLength <= 0) return -1;
|
||||
if (deleteIndex < activeIdx) return Math.max(0, activeIdx - 1);
|
||||
if (deleteIndex === activeIdx) return Math.min(activeIdx, nextLength - 1);
|
||||
return activeIdx;
|
||||
}
|
||||
|
||||
export function updateCardAt(cards, index, patch) {
|
||||
const next = Array.isArray(cards) ? cards.slice() : [];
|
||||
if (!Number.isInteger(index) || index < 0 || index >= next.length) return next;
|
||||
next[index] = Object.assign({}, next[index], patch || {});
|
||||
return next;
|
||||
}
|
||||
54
src/i18n.ts
54
src/i18n.ts
|
|
@ -17,6 +17,9 @@ export const STRINGS = {
|
|||
cmdCancel: '取消当前对照笔记生成',
|
||||
cmdClearCurrent: '清除当前笔记的缓存',
|
||||
cmdClearAll: '清除所有缓存',
|
||||
cmdCardPrev: '聚焦上一张摘要卡片',
|
||||
cmdCardNext: '聚焦下一张摘要卡片',
|
||||
cmdCardJump: '跳转到当前摘要卡片原文',
|
||||
actionCancel: '取消生成',
|
||||
actionRegenerate: '重新生成',
|
||||
actionCopyAll: '复制全部 Markdown',
|
||||
|
|
@ -32,6 +35,8 @@ export const STRINGS = {
|
|||
menuCopyPlain: '复制纯文本',
|
||||
menuCopyAnchor: '复制 anchor 引用',
|
||||
menuJumpSource: '跳转到原文',
|
||||
menuEditCard: '编辑此卡片',
|
||||
menuDeleteCard: '删除此卡片',
|
||||
copiedMarkdown: '已复制 Markdown',
|
||||
copiedPlain: '已复制纯文本',
|
||||
copiedAnchor: '已复制引用原文',
|
||||
|
|
@ -44,11 +49,21 @@ export const STRINGS = {
|
|||
cacheClearedAll: '已清除 {count} 条缓存',
|
||||
noCancelableJob: '当前没有可取消的生成任务',
|
||||
cancelRequested: '已请求取消生成',
|
||||
cancelRequestedApiInFlight: '已请求取消生成;当前 API 请求无法立即中断,返回后会丢弃结果。',
|
||||
fileMenuGenerate: '生成对照笔记',
|
||||
fileMenuRegen: '强制重新生成对照笔记',
|
||||
fileMenuClear: '清除对照笔记缓存',
|
||||
noExportContent: '当前没有可导出的对照笔记',
|
||||
noCopyContent: '当前没有可复制的对照笔记',
|
||||
noActiveCard: '当前没有可跳转的摘要卡片',
|
||||
cardDeleted: '已删除此卡片',
|
||||
cardSaved: '已保存此卡片',
|
||||
editCardTitle: '编辑摘要卡片',
|
||||
editCardTitleField: '标题',
|
||||
editCardGistField: '领读',
|
||||
editCardBulletsField: '要点(每行一条)',
|
||||
editCardCancel: '取消',
|
||||
editCardSave: '保存',
|
||||
openNoteFirst: '先打开一篇笔记',
|
||||
alreadyGenerating: '该笔记正在生成对照笔记',
|
||||
emptyNote: '笔记为空',
|
||||
|
|
@ -60,6 +75,18 @@ export const STRINGS = {
|
|||
cancelled: '已取消生成',
|
||||
cancelledError: '生成已取消',
|
||||
generationFailed: '生成失败{kind}:{error}',
|
||||
errorCustomProviderBaseUrlRequired: '自定义 provider 需要填写 API Base URL。',
|
||||
errorApiBaseUrlMissing: 'API Base URL 未设置。请在设置里选择 provider 或填写自定义 base URL。',
|
||||
errorModelMissing: 'Model 未设置。请在设置里填写模型 ID。',
|
||||
errorApiKeyMissing: 'API key 未设置。请在设置里填写 API Key{hint}。',
|
||||
errorApiKeyEnvHint: ' 或环境变量 {envVar}',
|
||||
errorLlmNonJson: 'LLM 返回非 JSON:\n{excerpt}',
|
||||
errorCustomHeadersJsonParse: '自定义 headers JSON 解析失败:{error}',
|
||||
errorCustomHeadersJsonObject: '自定义 headers JSON 必须是对象',
|
||||
errorCustomHeadersLineFormat: '自定义 headers 每行格式应为 `Header-Name: value`',
|
||||
errorProviderNonJson: '{label} 返回非 JSON:\n{excerpt}',
|
||||
errorProviderRequestFailed: '{label} 请求失败:{error}',
|
||||
errorProviderApiStatus: '{label} API 返回 HTTP {status}:{excerpt}',
|
||||
noEditor: '找不到源笔记对应的编辑器窗口',
|
||||
settingUiLanguageName: '界面语言',
|
||||
settingUiLanguageDesc: '控制插件界面、命令和提示文案;Auto 跟随 Obsidian/系统语言',
|
||||
|
|
@ -128,6 +155,9 @@ export const STRINGS = {
|
|||
cmdCancel: 'Cancel current parallel-note generation',
|
||||
cmdClearCurrent: 'Clear cache for current note',
|
||||
cmdClearAll: 'Clear all caches',
|
||||
cmdCardPrev: 'Focus previous summary card',
|
||||
cmdCardNext: 'Focus next summary card',
|
||||
cmdCardJump: 'Jump current summary card to source',
|
||||
actionCancel: 'Cancel generation',
|
||||
actionRegenerate: 'Regenerate',
|
||||
actionCopyAll: 'Copy all Markdown',
|
||||
|
|
@ -143,6 +173,8 @@ export const STRINGS = {
|
|||
menuCopyPlain: 'Copy plain text',
|
||||
menuCopyAnchor: 'Copy anchor quote',
|
||||
menuJumpSource: 'Jump to source',
|
||||
menuEditCard: 'Edit this card',
|
||||
menuDeleteCard: 'Delete this card',
|
||||
copiedMarkdown: 'Copied Markdown',
|
||||
copiedPlain: 'Copied plain text',
|
||||
copiedAnchor: 'Copied anchor quote',
|
||||
|
|
@ -155,11 +187,21 @@ export const STRINGS = {
|
|||
cacheClearedAll: 'Cleared {count} cache entries',
|
||||
noCancelableJob: 'No cancellable generation job',
|
||||
cancelRequested: 'Cancel requested',
|
||||
cancelRequestedApiInFlight: 'Cancel requested. The in-flight API request cannot be aborted immediately; its result will be ignored.',
|
||||
fileMenuGenerate: 'Generate parallel notes',
|
||||
fileMenuRegen: 'Regenerate parallel notes',
|
||||
fileMenuClear: 'Clear parallel-note cache',
|
||||
noExportContent: 'No parallel notes to export',
|
||||
noCopyContent: 'No parallel notes to copy',
|
||||
noActiveCard: 'No active summary card to jump',
|
||||
cardDeleted: 'Deleted this card',
|
||||
cardSaved: 'Saved this card',
|
||||
editCardTitle: 'Edit summary card',
|
||||
editCardTitleField: 'Title',
|
||||
editCardGistField: 'Gist',
|
||||
editCardBulletsField: 'Bullets (one per line)',
|
||||
editCardCancel: 'Cancel',
|
||||
editCardSave: 'Save',
|
||||
openNoteFirst: 'Open a note first',
|
||||
alreadyGenerating: 'This note is already being generated',
|
||||
emptyNote: 'The note is empty',
|
||||
|
|
@ -171,6 +213,18 @@ export const STRINGS = {
|
|||
cancelled: 'Generation cancelled',
|
||||
cancelledError: 'Generation cancelled',
|
||||
generationFailed: 'Generation failed{kind}: {error}',
|
||||
errorCustomProviderBaseUrlRequired: 'Custom provider requires an API Base URL.',
|
||||
errorApiBaseUrlMissing: 'API Base URL is not set. Choose a provider or enter a custom base URL in settings.',
|
||||
errorModelMissing: 'Model is not set. Enter a model ID in settings.',
|
||||
errorApiKeyMissing: 'API key is not set. Enter an API Key in settings{hint}.',
|
||||
errorApiKeyEnvHint: ' or set environment variable {envVar}',
|
||||
errorLlmNonJson: 'LLM returned non-JSON:\n{excerpt}',
|
||||
errorCustomHeadersJsonParse: 'Custom headers JSON parse failed: {error}',
|
||||
errorCustomHeadersJsonObject: 'Custom headers JSON must be an object',
|
||||
errorCustomHeadersLineFormat: 'Custom headers lines must use `Header-Name: value`',
|
||||
errorProviderNonJson: '{label} returned non-JSON:\n{excerpt}',
|
||||
errorProviderRequestFailed: '{label} request failed: {error}',
|
||||
errorProviderApiStatus: '{label} API returned HTTP {status}: {excerpt}',
|
||||
noEditor: 'Could not find the source note editor',
|
||||
settingUiLanguageName: 'UI language',
|
||||
settingUiLanguageDesc: 'Controls plugin UI, commands, and notices. Auto follows Obsidian/system language.',
|
||||
|
|
|
|||
15
src/navigation.ts
Normal file
15
src/navigation.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
'use strict';
|
||||
|
||||
export function nextCardIndex(activeIdx, cardCount, delta) {
|
||||
if (!Number.isFinite(cardCount) || cardCount <= 0) return -1;
|
||||
const count = Math.floor(cardCount);
|
||||
const direction = delta < 0 ? -1 : 1;
|
||||
if (activeIdx < 0 || activeIdx >= count) return direction < 0 ? count - 1 : 0;
|
||||
return Math.min(count - 1, Math.max(0, activeIdx + direction));
|
||||
}
|
||||
|
||||
export function activeSectionLine(sections, activeIdx) {
|
||||
if (!Array.isArray(sections) || activeIdx < 0 || activeIdx >= sections.length) return -1;
|
||||
const line = Number(sections[activeIdx]?.startLine);
|
||||
return Number.isFinite(line) && line >= 0 ? line : -1;
|
||||
}
|
||||
|
|
@ -29,22 +29,37 @@ export function renderPromptTemplate(template, vars) {
|
|||
});
|
||||
}
|
||||
|
||||
export function buildPrompts(content, settings) {
|
||||
const maxDocChars = Number(settings.maxDocChars) || MAX_DOC_CHARS;
|
||||
const promptLanguage = PROMPT_LANGUAGES[settings.promptLanguage] ? settings.promptLanguage : DEFAULT_SETTINGS.promptLanguage;
|
||||
const minCards = Math.max(1, Number(settings.minCards) || DEFAULT_SETTINGS.minCards);
|
||||
const maxCards = Math.max(minCards, Number(settings.maxCards) || DEFAULT_SETTINGS.maxCards);
|
||||
const languageInstruction = promptLanguageInstruction(promptLanguage);
|
||||
const doc = content.length > maxDocChars
|
||||
? content.slice(0, maxDocChars) + (promptLanguage === 'en' ? '\n\n[Document truncated]' : '\n\n[文档过长,已截断]')
|
||||
: content;
|
||||
function defaultSystemPrompt(language, minCards, maxCards, languageInstruction, schema, example) {
|
||||
if (language === 'en') {
|
||||
return `You are a long-form reading summary assistant. After reading the full document, split it into ${minCards}-${maxCards} natural topic units. They do not need to match markdown headings; use a complete argument or topic as the unit, merging short sections and splitting long ones when needed.
|
||||
|
||||
const schema = '{"cards":[{"title":"...","anchor":"...","gist":"...","bullets":["...","..."]}]}';
|
||||
const example = promptSchemaExample(promptLanguage);
|
||||
const templateVars = { minCards, maxCards, languageInstruction, schema, example };
|
||||
const customSystem = renderPromptTemplate(settings.customSystemPrompt, templateVars).trim();
|
||||
Each card has one guiding sentence plus several bullets. Bullets carry details; gist is the lead-in.
|
||||
|
||||
const defaultSystem = `你是一个长文阅读摘要助手。阅读全文后,把文章切成 ${minCards}-${maxCards} 个"自然主题单元"——不必对应 markdown heading,以"一个完整论点或话题"为单位自行判断粒度:短章节合并、长章节拆分。
|
||||
Language:
|
||||
- ${languageInstruction}
|
||||
|
||||
For each unit, output:
|
||||
|
||||
- title: a concise 3-10 word heading that clearly states what the section is about; avoid vague labels like "Background" or "Introduction"
|
||||
- anchor: a verbatim quote from the start of this unit, copied 1:1 from the source, 40-80 characters where possible, preserving punctuation, spaces, and line breaks; it is only used internally for positioning
|
||||
- gist: one guiding sentence, 20-40 words, stating the core claim or conclusion
|
||||
- bullets: 3-6 supporting bullets, each 20-50 words, carrying data, comparisons, mechanisms, examples, or counterintuitive observations. Gist and bullets must not repeat each other.
|
||||
|
||||
Rules:
|
||||
- anchor must exact-substring-match the source. Never paraphrase, translate, summarize, or alter it.
|
||||
- Choose the earliest sufficiently distinctive quote for each unit; avoid generic phrases.
|
||||
- Every card must include both gist and bullets.
|
||||
- Each bullet must be a standalone assertion; avoid sequencing phrases such as "first" or "then".
|
||||
- Output strict JSON only: no markdown fence, no explanation, no tool call.
|
||||
|
||||
Output shape:
|
||||
${schema}
|
||||
|
||||
Example:
|
||||
${example}`;
|
||||
}
|
||||
|
||||
return `你是一个长文阅读摘要助手。阅读全文后,把文章切成 ${minCards}-${maxCards} 个"自然主题单元"——不必对应 markdown heading,以"一个完整论点或话题"为单位自行判断粒度:短章节合并、长章节拆分。
|
||||
|
||||
**每张卡片的结构:一句话领读 + 若干条 bullet。bullet 承载细节,gist 是一句话导读。**
|
||||
|
||||
|
|
@ -70,16 +85,46 @@ ${schema}
|
|||
|
||||
示例:
|
||||
${example}`;
|
||||
}
|
||||
|
||||
const system = customSystem
|
||||
? `${customSystem}
|
||||
|
||||
不可覆盖的输出契约:
|
||||
function systemPromptContract(language, minCards, maxCards, languageInstruction, schema) {
|
||||
if (language === 'en') {
|
||||
return `Non-overridable output contract:
|
||||
- Must output ${minCards}-${maxCards} cards.
|
||||
- ${languageInstruction}
|
||||
- anchor must be copied verbatim from the source and exact-substring-match the source.
|
||||
- Output strict JSON only: no markdown fence, no explanation, no tool call.
|
||||
- JSON shape: ${schema}`;
|
||||
}
|
||||
return `不可覆盖的输出契约:
|
||||
- 必须输出 ${minCards}-${maxCards} 张 cards。
|
||||
- ${languageInstruction}
|
||||
- anchor 必须从原文逐字复制,必须能在原文 exact substring match 找到。
|
||||
- 严格只输出 JSON,无 markdown fence、无解释、无 tool call。
|
||||
- JSON shape: ${schema}`
|
||||
- JSON shape: ${schema}`;
|
||||
}
|
||||
|
||||
export function buildPrompts(content, settings) {
|
||||
const maxDocChars = Number(settings.maxDocChars) || MAX_DOC_CHARS;
|
||||
const promptLanguage = PROMPT_LANGUAGES[settings.promptLanguage] ? settings.promptLanguage : DEFAULT_SETTINGS.promptLanguage;
|
||||
const minCards = Math.max(1, Number(settings.minCards) || DEFAULT_SETTINGS.minCards);
|
||||
const maxCards = Math.max(minCards, Number(settings.maxCards) || DEFAULT_SETTINGS.maxCards);
|
||||
const languageInstruction = promptLanguageInstruction(promptLanguage);
|
||||
const doc = content.length > maxDocChars
|
||||
? content.slice(0, maxDocChars) + (promptLanguage === 'en' ? '\n\n[Document truncated]' : '\n\n[文档过长,已截断]')
|
||||
: content;
|
||||
|
||||
const schema = '{"cards":[{"title":"...","anchor":"...","gist":"...","bullets":["...","..."]}]}';
|
||||
const example = promptSchemaExample(promptLanguage);
|
||||
const templateVars = { minCards, maxCards, languageInstruction, schema, example };
|
||||
const customSystem = renderPromptTemplate(settings.customSystemPrompt, templateVars).trim();
|
||||
const contract = systemPromptContract(promptLanguage, minCards, maxCards, languageInstruction, schema);
|
||||
const defaultSystem = defaultSystemPrompt(promptLanguage, minCards, maxCards, languageInstruction, schema, example);
|
||||
|
||||
const system = customSystem
|
||||
? `${customSystem}
|
||||
|
||||
${contract}`
|
||||
: defaultSystem;
|
||||
|
||||
const user = promptLanguage === 'en'
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
getApiPreset,
|
||||
modelForApi,
|
||||
} from './settings';
|
||||
import { translate } from './i18n';
|
||||
import {
|
||||
ANTHROPIC_CARD_TOOL_NAME,
|
||||
anthropicCardTool,
|
||||
|
|
@ -27,7 +28,7 @@ function endpointUrl(baseUrl, suffixes) {
|
|||
return base + suffixes[0];
|
||||
}
|
||||
|
||||
function parseApiHeaders(raw) {
|
||||
function parseApiHeaders(raw, settings?) {
|
||||
const text = (raw || '').trim();
|
||||
if (!text) return {};
|
||||
if (text.startsWith('{')) {
|
||||
|
|
@ -35,10 +36,10 @@ function parseApiHeaders(raw) {
|
|||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch (e) {
|
||||
throw new Error('自定义 headers JSON 解析失败:' + e.message);
|
||||
throw new Error(translate(settings, 'errorCustomHeadersJsonParse', { error: e.message }));
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('自定义 headers JSON 必须是对象');
|
||||
throw new Error(translate(settings, 'errorCustomHeadersJsonObject'));
|
||||
}
|
||||
const headers = {};
|
||||
for (const [k, v] of Object.entries(parsed)) {
|
||||
|
|
@ -53,7 +54,7 @@ function parseApiHeaders(raw) {
|
|||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const idx = trimmed.indexOf(':');
|
||||
if (idx <= 0) {
|
||||
throw new Error('自定义 headers 每行格式应为 `Header-Name: value`');
|
||||
throw new Error(translate(settings, 'errorCustomHeadersLineFormat'));
|
||||
}
|
||||
const key = trimmed.slice(0, idx).trim();
|
||||
const value = trimmed.slice(idx + 1).trim();
|
||||
|
|
@ -68,8 +69,8 @@ function authHeaders(settings) {
|
|||
const key = getApiKey(settings);
|
||||
if (!key) {
|
||||
const envVar = (settings.apiKeyEnvVar || getApiPreset(settings).envVar || '').trim();
|
||||
const hint = envVar ? ` 或环境变量 ${envVar}` : '';
|
||||
throw new Error(`API key 未设置。请在设置里填写 API Key${hint}。`);
|
||||
const hint = envVar ? translate(settings, 'errorApiKeyEnvHint', { envVar }) : '';
|
||||
throw new Error(translate(settings, 'errorApiKeyMissing', { hint }));
|
||||
}
|
||||
if (authType === 'bearer') return { authorization: `Bearer ${key}` };
|
||||
if (authType === 'x-api-key') return { 'x-api-key': key };
|
||||
|
|
@ -83,20 +84,23 @@ function buildApiHeaders(settings, extra?) {
|
|||
'content-type': 'application/json',
|
||||
...authHeaders(settings),
|
||||
...(extra || {}),
|
||||
...parseApiHeaders(settings.apiHeaders),
|
||||
...parseApiHeaders(settings.apiHeaders, settings),
|
||||
};
|
||||
}
|
||||
|
||||
function responseJson(resp, label) {
|
||||
function responseJson(resp, label, settings?) {
|
||||
if (resp.json && typeof resp.json === 'object') return resp.json;
|
||||
try {
|
||||
return JSON.parse(resp.text || '{}');
|
||||
} catch (_) {
|
||||
throw new Error(`${label} 返回非 JSON:\n${(resp.text || '').slice(0, 500)}`);
|
||||
throw new Error(translate(settings, 'errorProviderNonJson', {
|
||||
label,
|
||||
excerpt: (resp.text || '').slice(0, 500),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async function requestJsonBody(requestUrlImpl, label, url, headers, body) {
|
||||
async function requestJsonBody(requestUrlImpl, label, url, headers, body, settings?) {
|
||||
let resp;
|
||||
try {
|
||||
resp = await requestUrlImpl({
|
||||
|
|
@ -107,28 +111,35 @@ async function requestJsonBody(requestUrlImpl, label, url, headers, body) {
|
|||
throw: false,
|
||||
});
|
||||
} catch (e) {
|
||||
throw new Error(`${label} 请求失败:` + (e.message || e));
|
||||
throw new Error(translate(settings, 'errorProviderRequestFailed', {
|
||||
label,
|
||||
error: e.message || e,
|
||||
}));
|
||||
}
|
||||
|
||||
if (resp.status >= 400) {
|
||||
throw new Error(`${label} API ${resp.status}: ${(resp.text || '').slice(0, 500)}`);
|
||||
throw new Error(translate(settings, 'errorProviderApiStatus', {
|
||||
label,
|
||||
status: resp.status,
|
||||
excerpt: (resp.text || '').slice(0, 500),
|
||||
}));
|
||||
}
|
||||
return responseJson(resp, label);
|
||||
return responseJson(resp, label, settings);
|
||||
}
|
||||
|
||||
function shouldRetryWithoutStructuredOutput(error) {
|
||||
const message = String(error && error.message ? error.message : error);
|
||||
if (!/API (400|404|422):/.test(message)) return false;
|
||||
if (!/(?:API (?:400|404|422):|API returned HTTP (?:400|404|422)|API 返回 HTTP (?:400|404|422))/.test(message)) return false;
|
||||
return /response_format|json_schema|responseJsonSchema|responseMimeType|tools?|tool_choice|unsupported|unrecognized|unknown|schema/i.test(message);
|
||||
}
|
||||
|
||||
async function requestJsonBodyWithStructuredFallback(requestUrlImpl, label, url, headers, structuredBody, fallbackBody) {
|
||||
async function requestJsonBodyWithStructuredFallback(requestUrlImpl, label, url, headers, structuredBody, fallbackBody, settings?) {
|
||||
try {
|
||||
return await requestJsonBody(requestUrlImpl, label, url, headers, structuredBody);
|
||||
return await requestJsonBody(requestUrlImpl, label, url, headers, structuredBody, settings);
|
||||
} catch (e) {
|
||||
if (!fallbackBody || !shouldRetryWithoutStructuredOutput(e)) throw e;
|
||||
console.warn(`[parallel-reader] ${label} structured output rejected; retrying without structured output`, e);
|
||||
return requestJsonBody(requestUrlImpl, label + ' fallback', url, headers, fallbackBody);
|
||||
return requestJsonBody(requestUrlImpl, label + ' fallback', url, headers, fallbackBody, settings);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -237,11 +248,11 @@ export function buildGeminiBody(system, user, settings, options?) {
|
|||
};
|
||||
}
|
||||
|
||||
function cardsFromAnthropicToolUse(json) {
|
||||
function cardsFromAnthropicToolUse(json, settings?) {
|
||||
const content = Array.isArray(json && json.content) ? json.content : [];
|
||||
const block = content.find(c => c && c.type === 'tool_use' && c.name === ANTHROPIC_CARD_TOOL_NAME);
|
||||
if (!block) return null;
|
||||
if (typeof block.input === 'string') return parseCardsJson(block.input);
|
||||
if (typeof block.input === 'string') return parseCardsJson(block.input, settings);
|
||||
if (block.input && typeof block.input === 'object') return normalizeCardsPayload(block.input);
|
||||
return [];
|
||||
}
|
||||
|
|
@ -254,14 +265,15 @@ async function summarizeViaAnthropicMessages(requestUrlImpl, system, user, setti
|
|||
url,
|
||||
buildApiHeaders(settings, { 'anthropic-version': '2023-06-01' }),
|
||||
buildAnthropicMessagesBody(system, user, settings),
|
||||
buildAnthropicMessagesBody(system, user, settings, { structured: false })
|
||||
buildAnthropicMessagesBody(system, user, settings, { structured: false }),
|
||||
settings
|
||||
);
|
||||
|
||||
const toolCards = cardsFromAnthropicToolUse(json);
|
||||
const toolCards = cardsFromAnthropicToolUse(json, settings);
|
||||
if (toolCards) return toolCards;
|
||||
|
||||
const text = (json.content || []).map(c => textFromContent(c)).join('').trim();
|
||||
return parseCardsJson(text);
|
||||
return parseCardsJson(text, settings);
|
||||
}
|
||||
|
||||
async function summarizeViaOpenAiChat(requestUrlImpl, system, user, settings) {
|
||||
|
|
@ -272,11 +284,12 @@ async function summarizeViaOpenAiChat(requestUrlImpl, system, user, settings) {
|
|||
url,
|
||||
buildApiHeaders(settings),
|
||||
buildOpenAiChatBody(system, user, settings),
|
||||
buildOpenAiChatBody(system, user, settings, { structured: false })
|
||||
buildOpenAiChatBody(system, user, settings, { structured: false }),
|
||||
settings
|
||||
);
|
||||
const choice = (json.choices || [])[0] || {};
|
||||
const text = textFromContent(choice.message?.content || choice.text || '').trim();
|
||||
return parseCardsJson(text);
|
||||
return parseCardsJson(text, settings);
|
||||
}
|
||||
|
||||
async function summarizeViaOpenAiResponses(requestUrlImpl, system, user, settings) {
|
||||
|
|
@ -287,9 +300,10 @@ async function summarizeViaOpenAiResponses(requestUrlImpl, system, user, setting
|
|||
url,
|
||||
buildApiHeaders(settings),
|
||||
buildOpenAiResponsesBody(system, user, settings),
|
||||
buildOpenAiResponsesBody(system, user, settings, { structured: false })
|
||||
buildOpenAiResponsesBody(system, user, settings, { structured: false }),
|
||||
settings
|
||||
);
|
||||
return parseCardsJson(textFromOpenAiResponses(json).trim());
|
||||
return parseCardsJson(textFromOpenAiResponses(json).trim(), settings);
|
||||
}
|
||||
|
||||
async function summarizeViaGoogleGenerativeAi(requestUrlImpl, system, user, settings) {
|
||||
|
|
@ -305,12 +319,13 @@ async function summarizeViaGoogleGenerativeAi(requestUrlImpl, system, user, sett
|
|||
url,
|
||||
headers,
|
||||
buildGeminiBody(system, user, settings),
|
||||
buildGeminiBody(system, user, settings, { structured: false })
|
||||
buildGeminiBody(system, user, settings, { structured: false }),
|
||||
settings
|
||||
);
|
||||
const candidate = (json.candidates || [])[0] || {};
|
||||
const parts = candidate.content?.parts || [];
|
||||
const text = parts.map(p => textFromContent(p)).join('').trim();
|
||||
return parseCardsJson(text);
|
||||
return parseCardsJson(text, settings);
|
||||
}
|
||||
|
||||
export async function summarizeViaApi(requestUrlImpl, system, user, settings) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
'use strict';
|
||||
|
||||
import { translate } from './i18n';
|
||||
|
||||
export const ANTHROPIC_CARD_TOOL_NAME = 'record_parallel_reader_cards';
|
||||
|
||||
export function collectJsonObjectCandidates(raw) {
|
||||
|
|
@ -58,13 +60,15 @@ export function extractJson(text) {
|
|||
return raw;
|
||||
}
|
||||
|
||||
export function parseCardsJson(text) {
|
||||
export function parseCardsJson(text, settings?) {
|
||||
const jsonText = extractJson(text);
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(jsonText);
|
||||
} catch (e) {
|
||||
throw new Error('LLM 返回非 JSON:\n' + (text || '').slice(0, 500));
|
||||
throw new Error(translate(settings, 'errorLlmNonJson', {
|
||||
excerpt: (text || '').slice(0, 500),
|
||||
}));
|
||||
}
|
||||
return normalizeCardsPayload(parsed);
|
||||
}
|
||||
|
|
|
|||
40
src/scroll.ts
Normal file
40
src/scroll.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
'use strict';
|
||||
|
||||
export type RafThrottledHandler = (() => void) & { cancel: () => void };
|
||||
|
||||
function defaultSchedule(callback: FrameRequestCallback): number {
|
||||
if (typeof requestAnimationFrame === 'function') return requestAnimationFrame(callback);
|
||||
return setTimeout(() => callback(Date.now()), 16) as unknown as number;
|
||||
}
|
||||
|
||||
function defaultCancel(frameId: number) {
|
||||
if (typeof cancelAnimationFrame === 'function') {
|
||||
cancelAnimationFrame(frameId);
|
||||
return;
|
||||
}
|
||||
clearTimeout(frameId as unknown as ReturnType<typeof setTimeout>);
|
||||
}
|
||||
|
||||
export function createRafThrottledHandler(
|
||||
callback: () => void,
|
||||
schedule: (callback: FrameRequestCallback) => number = defaultSchedule,
|
||||
cancel: (frameId: number) => void = defaultCancel,
|
||||
): RafThrottledHandler {
|
||||
let frameId: number | null = null;
|
||||
|
||||
const handler = (() => {
|
||||
if (frameId !== null) return;
|
||||
frameId = schedule(() => {
|
||||
frameId = null;
|
||||
callback();
|
||||
});
|
||||
}) as RafThrottledHandler;
|
||||
|
||||
handler.cancel = () => {
|
||||
if (frameId === null) return;
|
||||
cancel(frameId);
|
||||
frameId = null;
|
||||
};
|
||||
|
||||
return handler;
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
'use strict';
|
||||
|
||||
import crypto from 'crypto';
|
||||
import { translate } from './i18n';
|
||||
|
||||
export const MAX_DOC_CHARS = 20000;
|
||||
export const PROMPT_VERSION = 2;
|
||||
|
|
@ -254,11 +255,11 @@ export function getApiBaseUrl(settings) {
|
|||
const explicit = (settings.apiBaseUrl || '').trim();
|
||||
if (explicit) return explicit.replace(/\/+$/, '');
|
||||
if ((settings.apiProvider || '').startsWith('custom-')) {
|
||||
throw new Error('自定义 provider 需要填写 API Base URL。');
|
||||
throw new Error(translate(settings, 'errorCustomProviderBaseUrlRequired'));
|
||||
}
|
||||
const base = (preset.baseUrl || API_FORMATS[format].defaultBaseUrl || '').trim();
|
||||
if (!base) {
|
||||
throw new Error('API Base URL 未设置。请在设置里选择 provider 或填写自定义 base URL。');
|
||||
throw new Error(translate(settings, 'errorApiBaseUrlMissing'));
|
||||
}
|
||||
return base.replace(/\/+$/, '');
|
||||
}
|
||||
|
|
@ -284,7 +285,7 @@ export function getApiKey(settings) {
|
|||
export function modelForApi(settings) {
|
||||
const raw = (settings.model || '').trim();
|
||||
if (!raw) {
|
||||
throw new Error('Model 未设置。请在设置里填写模型 ID。');
|
||||
throw new Error(translate(settings, 'errorModelMissing'));
|
||||
}
|
||||
const preset = getApiPreset(settings);
|
||||
const prefixes = [settings.apiProvider, preset.modelPrefix]
|
||||
|
|
|
|||
27
src/vault.ts
Normal file
27
src/vault.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
'use strict';
|
||||
|
||||
export function normalizeVaultPath(path) {
|
||||
return String(path || '')
|
||||
.split('/')
|
||||
.map(part => part.trim())
|
||||
.filter(Boolean)
|
||||
.join('/');
|
||||
}
|
||||
|
||||
export function folderPathsForTarget(folderPath) {
|
||||
const normalized = normalizeVaultPath(folderPath);
|
||||
if (!normalized) return [];
|
||||
const parts = normalized.split('/');
|
||||
return parts.map((_, idx) => parts.slice(0, idx + 1).join('/'));
|
||||
}
|
||||
|
||||
export async function ensureVaultFolder(app, folderPath) {
|
||||
for (const folder of folderPathsForTarget(folderPath)) {
|
||||
if (app.vault.getAbstractFileByPath(folder)) continue;
|
||||
try {
|
||||
await app.vault.createFolder(folder);
|
||||
} catch (e) {
|
||||
if (!app.vault.getAbstractFileByPath(folder)) throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
25
styles.css
25
styles.css
|
|
@ -310,6 +310,31 @@
|
|||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.parallel-reader-modal-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.parallel-reader-modal-field label {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.parallel-reader-modal-field input,
|
||||
.parallel-reader-modal-field textarea {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.parallel-reader-modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.parallel-reader-empty {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ Module._load = function load(request, parent, isMain) {
|
|||
class MarkdownView {}
|
||||
class TFile {}
|
||||
class Menu {}
|
||||
class Modal {}
|
||||
return {
|
||||
Plugin,
|
||||
ItemView,
|
||||
|
|
@ -21,6 +22,7 @@ Module._load = function load(request, parent, isMain) {
|
|||
MarkdownView,
|
||||
TFile,
|
||||
Menu,
|
||||
Modal,
|
||||
MarkdownRenderer: { render: async () => {} },
|
||||
requestUrl: async () => ({ status: 200, json: {}, text: '{}' }),
|
||||
setIcon: () => {},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
const assert = require('assert');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const Module = require('module');
|
||||
|
||||
const originalLoad = Module._load;
|
||||
|
|
@ -18,6 +20,7 @@ Module._load = function load(request, parent, isMain) {
|
|||
class MarkdownView {}
|
||||
class TFile {}
|
||||
class Menu {}
|
||||
class Modal {}
|
||||
return {
|
||||
Plugin,
|
||||
ItemView,
|
||||
|
|
@ -27,6 +30,7 @@ Module._load = function load(request, parent, isMain) {
|
|||
MarkdownView,
|
||||
TFile,
|
||||
Menu,
|
||||
Modal,
|
||||
MarkdownRenderer: { render: async () => {} },
|
||||
requestUrl: async () => ({ status: 200, json: {}, text: '{}' }),
|
||||
setIcon: () => {},
|
||||
|
|
@ -39,15 +43,38 @@ const plugin = require('../main.js');
|
|||
const t = plugin.__test;
|
||||
|
||||
assert.ok(t, 'test helpers should be exported');
|
||||
const mainSource = fs.readFileSync(path.join(__dirname, '..', 'main.ts'), 'utf8');
|
||||
assert.ok(!/\basync\s+onOpen\s*\(/.test(mainSource), 'ParallelReaderView.onOpen should not be async without await');
|
||||
assert.ok(!/\basync\s+onClose\s*\(\)\s*\{\s*\}/.test(mainSource), 'empty onClose should not be async');
|
||||
assert.ok(/focusSummaryPane\s*\(\)/.test(mainSource), 'summary pane should expose a focus helper');
|
||||
assert.ok(/\.focus\(\{\s*preventScroll:\s*true\s*\}\)/.test(mainSource), 'summary pane focus should not scroll the page');
|
||||
assert.ok(/moveActiveSection[\s\S]*focusSummaryPane/.test(mainSource), 'card navigation should focus the summary pane');
|
||||
assert.ok(/scheduleCacheSave\s*\(/.test(mainSource), 'cache touch should use a debounced cache save path');
|
||||
assert.ok(/flushCacheSave\s*\(/.test(mainSource), 'pending cache touches should be flushable');
|
||||
assert.ok(/onunload[\s\S]*flushCacheSave/.test(mainSource), 'plugin unload should flush pending cache touches');
|
||||
assert.ok(/cacheTouch[\s\S]*scheduleCacheSave/.test(mainSource), 'cacheTouch should schedule a cache save');
|
||||
assert.ok(!/cacheTouch[\s\S]{0,220}await this\.saveCache/.test(mainSource), 'cacheTouch should not synchronously write cache.json');
|
||||
assert.strictEqual(typeof t.cardsToMarkdown, 'function');
|
||||
assert.strictEqual(typeof t.cancellationNoticeKey, 'function');
|
||||
assert.strictEqual(typeof t.buildPrompts, 'function');
|
||||
assert.strictEqual(typeof t.buildOpenAiChatBody, 'function');
|
||||
assert.strictEqual(typeof t.extractJson, 'function');
|
||||
assert.strictEqual(typeof t.findLineForAnchor, 'function');
|
||||
assert.strictEqual(typeof t.folderPathsForTarget, 'function');
|
||||
assert.strictEqual(typeof t.getApiBaseUrl, 'function');
|
||||
assert.strictEqual(typeof t.generationFingerprint, 'function');
|
||||
assert.strictEqual(typeof t.GenerationJobManager, 'function');
|
||||
assert.strictEqual(typeof t.modelForApi, 'function');
|
||||
assert.strictEqual(typeof t.activeSectionLine, 'function');
|
||||
assert.strictEqual(typeof t.touchCacheEntry, 'function');
|
||||
assert.strictEqual(typeof t.nextCardIndex, 'function');
|
||||
assert.strictEqual(typeof t.pruneCacheEntries, 'function');
|
||||
assert.strictEqual(typeof t.removeCardAt, 'function');
|
||||
assert.strictEqual(typeof t.activeIndexAfterCardDelete, 'function');
|
||||
assert.strictEqual(typeof t.createRafThrottledHandler, 'function');
|
||||
assert.strictEqual(typeof t.serializeCacheFile, 'function');
|
||||
assert.strictEqual(typeof t.translate, 'function');
|
||||
assert.strictEqual(typeof t.updateCardAt, 'function');
|
||||
|
||||
const baseSettings = {
|
||||
backend: 'api',
|
||||
|
|
@ -90,6 +117,36 @@ assert.strictEqual(
|
|||
t.generationFingerprint({ ...baseSettings, uiLanguage: 'en' }),
|
||||
'cache fingerprint should not change when UI language changes'
|
||||
);
|
||||
assert.strictEqual(
|
||||
t.cancellationNoticeKey({ backend: 'api' }, { phase: 'generating' }),
|
||||
'cancelRequestedApiInFlight',
|
||||
'API cancellation should explain that the in-flight network request cannot be aborted'
|
||||
);
|
||||
assert.strictEqual(
|
||||
t.cancellationNoticeKey({ backend: 'claude-code' }, { phase: 'generating' }),
|
||||
'cancelRequested',
|
||||
'CLI cancellation can use the generic cancellation notice'
|
||||
);
|
||||
assert.strictEqual(
|
||||
t.cancellationNoticeKey({ backend: 'api' }, { phase: 'reading' }),
|
||||
'cancelRequested',
|
||||
'API cancellation outside the request phase can use the generic notice'
|
||||
);
|
||||
assert.throws(
|
||||
() => t.modelForApi({ ...baseSettings, model: '', uiLanguage: 'en' }),
|
||||
/Model is not set/,
|
||||
'settings errors should respect English UI mode'
|
||||
);
|
||||
assert.throws(
|
||||
() => t.getApiBaseUrl({
|
||||
...baseSettings,
|
||||
apiProvider: 'custom-openai-compatible',
|
||||
apiBaseUrl: '',
|
||||
uiLanguage: 'en',
|
||||
}),
|
||||
/Custom provider requires an API Base URL/,
|
||||
'custom provider base URL errors should respect English UI mode'
|
||||
);
|
||||
|
||||
const contentHash = crypto.createHash('sha1').update('hello', 'utf8').digest('hex');
|
||||
assert.strictEqual(
|
||||
|
|
@ -145,6 +202,8 @@ const englishPrompt = t.buildPrompts('Hello world', {
|
|||
});
|
||||
assert.ok(englishPrompt.system.includes('2-4'));
|
||||
assert.ok(englishPrompt.system.includes('Write title, gist, and bullets in English.'));
|
||||
assert.ok(englishPrompt.system.includes('You are a long-form reading summary assistant.'));
|
||||
assert.ok(!englishPrompt.system.includes('你是一个长文阅读摘要助手'));
|
||||
assert.ok(englishPrompt.user.startsWith('Source document:'));
|
||||
|
||||
const customPrompt = t.buildPrompts('Hello world', {
|
||||
|
|
@ -161,6 +220,61 @@ assert.ok(customPrompt.system.includes('JSON shape'));
|
|||
assert.strictEqual(t.translate({ uiLanguage: 'en' }, 'cmdOpenView'), 'Open Parallel Reader pane');
|
||||
assert.strictEqual(t.translate({ uiLanguage: 'zh' }, 'cmdOpenView'), '打开对照笔记面板');
|
||||
assert.strictEqual(t.translate({ uiLanguage: 'en' }, 'cacheClearedAll', { count: 3 }), 'Cleared 3 cache entries');
|
||||
assert.strictEqual(t.translate({ uiLanguage: 'en' }, 'cmdCardNext'), 'Focus next summary card');
|
||||
|
||||
assert.strictEqual(t.nextCardIndex(-1, 3, 1), 0);
|
||||
assert.strictEqual(t.nextCardIndex(-1, 3, -1), 2);
|
||||
assert.strictEqual(t.nextCardIndex(0, 3, 1), 1);
|
||||
assert.strictEqual(t.nextCardIndex(2, 3, 1), 2);
|
||||
assert.strictEqual(t.nextCardIndex(0, 3, -1), 0);
|
||||
assert.strictEqual(t.nextCardIndex(0, 0, 1), -1);
|
||||
assert.strictEqual(t.activeSectionLine([{ startLine: 3 }], 0), 3);
|
||||
assert.strictEqual(t.activeSectionLine([{ startLine: -1 }], 0), -1);
|
||||
assert.strictEqual(t.activeSectionLine([{ startLine: 3 }], 1), -1);
|
||||
const scheduledFrames = [];
|
||||
let throttledCalls = 0;
|
||||
const throttled = t.createRafThrottledHandler(() => { throttledCalls += 1; }, cb => {
|
||||
scheduledFrames.push(cb);
|
||||
return scheduledFrames.length;
|
||||
});
|
||||
throttled();
|
||||
throttled();
|
||||
throttled();
|
||||
assert.strictEqual(scheduledFrames.length, 1, 'scroll handler should be scheduled at most once per frame');
|
||||
assert.strictEqual(throttledCalls, 0, 'throttled handler should not run synchronously');
|
||||
scheduledFrames.shift()(0);
|
||||
assert.strictEqual(throttledCalls, 1);
|
||||
throttled();
|
||||
assert.strictEqual(scheduledFrames.length, 1, 'scroll handler should be schedulable after the frame runs');
|
||||
const cardList = [{ title: 'A' }, { title: 'B' }, { title: 'C' }];
|
||||
assert.deepStrictEqual(t.removeCardAt(cardList, 1), [{ title: 'A' }, { title: 'C' }]);
|
||||
assert.deepStrictEqual(t.removeCardAt(cardList, -1), cardList);
|
||||
assert.deepStrictEqual(t.removeCardAt(cardList, 3), cardList);
|
||||
assert.notStrictEqual(t.removeCardAt(cardList, 3), cardList);
|
||||
assert.strictEqual(t.activeIndexAfterCardDelete(1, 3, 1), 1, 'deleting active card should select the next card');
|
||||
assert.strictEqual(t.activeIndexAfterCardDelete(2, 3, 2), 1, 'deleting the last active card should select the previous card');
|
||||
assert.strictEqual(t.activeIndexAfterCardDelete(1, 3, 2), 1, 'deleting before active card should shift active index left');
|
||||
assert.strictEqual(t.activeIndexAfterCardDelete(2, 3, 0), 0, 'deleting after active card should keep active index');
|
||||
assert.deepStrictEqual(
|
||||
t.updateCardAt(cardList, 1, { title: 'B2', gist: 'G', bullets: ['x'] }),
|
||||
[{ title: 'A' }, { title: 'B2', gist: 'G', bullets: ['x'] }, { title: 'C' }]
|
||||
);
|
||||
assert.deepStrictEqual(t.updateCardAt(cardList, -1, { title: 'X' }), cardList);
|
||||
assert.deepStrictEqual(t.updateCardAt(cardList, 3, { title: 'X' }), cardList);
|
||||
assert.notStrictEqual(t.updateCardAt(cardList, 3, { title: 'X' }), cardList);
|
||||
assert.deepStrictEqual(t.folderPathsForTarget('Reading/Articles'), ['Reading', 'Reading/Articles']);
|
||||
assert.deepStrictEqual(t.folderPathsForTarget('/Reading//Articles/'), ['Reading', 'Reading/Articles']);
|
||||
assert.deepStrictEqual(t.folderPathsForTarget('Reading'), ['Reading']);
|
||||
assert.deepStrictEqual(t.folderPathsForTarget(''), []);
|
||||
const untouchedCacheEntry = { generatedAt: '2024-01-01T00:00:00.000Z' };
|
||||
assert.strictEqual(t.touchCacheEntry(null), null);
|
||||
assert.strictEqual(t.touchCacheEntry(untouchedCacheEntry, '2024-01-05T00:00:00.000Z'), untouchedCacheEntry);
|
||||
assert.strictEqual(untouchedCacheEntry.lastAccessedAt, '2024-01-05T00:00:00.000Z');
|
||||
const serializedCache = t.serializeCacheFile({
|
||||
'note.md': { generatedAt: '2024-01-01T00:00:00.000Z', cards: [{ title: 'A' }] },
|
||||
});
|
||||
assert.strictEqual(serializedCache.includes('\n'), false, 'cache.json should be compact');
|
||||
assert.deepStrictEqual(JSON.parse(serializedCache).entries['note.md'].cards, [{ title: 'A' }]);
|
||||
|
||||
const noisyJson = '说明文字 {"cards":[{"title":"A","anchor":"保留 { 花括号 } 字符","gist":"G","bullets":["B"]}]} trailing';
|
||||
const extracted = t.extractJson(noisyJson);
|
||||
|
|
@ -248,6 +362,90 @@ async function testOpenAiStructuredFallback() {
|
|||
assert.deepStrictEqual(cards, [{ title: 'T', anchor: 'A', gist: 'G', bullets: ['B'] }]);
|
||||
}
|
||||
|
||||
async function testProviderMissingApiKeyUsesEnglishUi() {
|
||||
await assert.rejects(
|
||||
() => t.summarizeViaApi(async () => {
|
||||
throw new Error('request should not be sent');
|
||||
}, 'system JSON', 'user', {
|
||||
...baseSettings,
|
||||
apiKey: '',
|
||||
apiKeyEnvVar: '',
|
||||
uiLanguage: 'en',
|
||||
}),
|
||||
/API key is not set/
|
||||
);
|
||||
}
|
||||
|
||||
async function testProviderHeaderJsonErrorUsesEnglishUi() {
|
||||
await assert.rejects(
|
||||
() => t.summarizeViaApi(async () => {
|
||||
throw new Error('request should not be sent');
|
||||
}, 'system JSON', 'user', {
|
||||
...baseSettings,
|
||||
apiHeaders: '{"x":',
|
||||
uiLanguage: 'en',
|
||||
}),
|
||||
/Custom headers JSON parse failed/
|
||||
);
|
||||
}
|
||||
|
||||
async function testProviderResponseNonJsonUsesEnglishUi() {
|
||||
await assert.rejects(
|
||||
() => t.summarizeViaApi(async () => ({
|
||||
status: 200,
|
||||
text: '<html>not json</html>',
|
||||
}), 'system JSON', 'user', {
|
||||
...baseSettings,
|
||||
uiLanguage: 'en',
|
||||
}),
|
||||
/OpenAI-compatible Chat returned non-JSON/
|
||||
);
|
||||
}
|
||||
|
||||
async function testProviderRequestFailureUsesEnglishUi() {
|
||||
await assert.rejects(
|
||||
() => t.summarizeViaApi(async () => {
|
||||
throw new Error('network down');
|
||||
}, 'system JSON', 'user', {
|
||||
...baseSettings,
|
||||
uiLanguage: 'en',
|
||||
}),
|
||||
/OpenAI-compatible Chat request failed: network down/
|
||||
);
|
||||
}
|
||||
|
||||
async function testProviderApiStatusErrorUsesEnglishUi() {
|
||||
await assert.rejects(
|
||||
() => t.summarizeViaApi(async () => ({
|
||||
status: 500,
|
||||
text: 'upstream exploded',
|
||||
}), 'system JSON', 'user', {
|
||||
...baseSettings,
|
||||
uiLanguage: 'en',
|
||||
}),
|
||||
/OpenAI-compatible Chat API returned HTTP 500: upstream exploded/
|
||||
);
|
||||
}
|
||||
|
||||
async function testSchemaNonJsonErrorUsesEnglishUi() {
|
||||
await assert.rejects(
|
||||
() => t.summarizeViaApi(async () => ({
|
||||
status: 200,
|
||||
json: {
|
||||
choices: [{
|
||||
message: {
|
||||
content: 'not json',
|
||||
},
|
||||
}],
|
||||
},
|
||||
}), 'system JSON', 'user', {
|
||||
...baseSettings,
|
||||
uiLanguage: 'en',
|
||||
}),
|
||||
/LLM returned non-JSON/
|
||||
);
|
||||
}
|
||||
|
||||
async function testAnthropicToolUseParsing() {
|
||||
const requestUrlImpl = async req => {
|
||||
const body = JSON.parse(req.body);
|
||||
|
|
@ -280,6 +478,12 @@ async function testAnthropicToolUseParsing() {
|
|||
|
||||
(async () => {
|
||||
await testOpenAiStructuredFallback();
|
||||
await testProviderMissingApiKeyUsesEnglishUi();
|
||||
await testProviderHeaderJsonErrorUsesEnglishUi();
|
||||
await testProviderResponseNonJsonUsesEnglishUi();
|
||||
await testProviderRequestFailureUsesEnglishUi();
|
||||
await testProviderApiStatusErrorUsesEnglishUi();
|
||||
await testSchemaNonJsonErrorUsesEnglishUi();
|
||||
await testAnthropicToolUseParsing();
|
||||
console.log('tests passed');
|
||||
})().catch(e => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue