mirror of
https://github.com/fancive/obsidian-parallel-reader.git
synced 2026-07-22 06:53:43 +00:00
Merge pull request #1 from fancive/feat/engineering-improvements
refactor: engineering improvements batch
This commit is contained in:
commit
e0acce7f6d
13 changed files with 617 additions and 276 deletions
|
|
@ -8,12 +8,12 @@
|
|||
"rules": {
|
||||
"recommended": true,
|
||||
"suspicious": {
|
||||
"noExplicitAny": "warn",
|
||||
"noExplicitAny": "error",
|
||||
"noRedundantUseStrict": "off",
|
||||
"noImplicitAnyLet": "off"
|
||||
},
|
||||
"style": {
|
||||
"noNonNullAssertion": "off",
|
||||
"noNonNullAssertion": "warn",
|
||||
"useConst": "warn",
|
||||
"useNodejsImportProtocol": "off",
|
||||
"useTemplate": "off"
|
||||
|
|
|
|||
64
main.js
64
main.js
File diff suppressed because one or more lines are too long
281
main.ts
281
main.ts
|
|
@ -1,7 +1,8 @@
|
|||
'use strict';
|
||||
import { MarkdownView, Notice, Plugin, requestUrl, TFile } from 'obsidian';
|
||||
import { MarkdownView, Modal, Notice, Plugin, requestUrl, TFile } from 'obsidian';
|
||||
import { findLineForAnchor } from './src/anchor';
|
||||
import { serializeCacheFile, shouldConfirmRegenerate, touchCacheEntry } from './src/cache';
|
||||
import { CacheManager } from './src/cache-manager';
|
||||
import { activeIndexAfterCardDelete, removeCardAt, updateCardAt } from './src/cards';
|
||||
import { resolveCliPath, summarizeViaClaudeCode, summarizeViaCodex } from './src/cli';
|
||||
import {
|
||||
|
|
@ -30,11 +31,9 @@ import { createRafThrottledHandler, visibleTopProbeY } from './src/scroll';
|
|||
import {
|
||||
CACHE_SCHEMA_VERSION,
|
||||
cacheEntryMatches,
|
||||
DEFAULT_MAX_CACHE_ENTRIES,
|
||||
DEFAULT_SETTINGS,
|
||||
generationFingerprint,
|
||||
getApiBaseUrl,
|
||||
hashContent,
|
||||
isApiBackend,
|
||||
modelForApi,
|
||||
normalizeSettings,
|
||||
|
|
@ -42,7 +41,15 @@ import {
|
|||
} from './src/settings';
|
||||
import { ParallelReaderSettingTab } from './src/settings-tab';
|
||||
import { deltaExtractorForFormat, parseSseBuffer, type StreamProgress } from './src/streaming';
|
||||
import type { CacheEntry, PluginSettings, RawCard, ResolvedCard } from './src/types';
|
||||
import type {
|
||||
CacheEntry,
|
||||
ObsidianEditorWithCm,
|
||||
ObsidianMenu,
|
||||
ObsidianMenuItem,
|
||||
PluginSettings,
|
||||
RawCard,
|
||||
ResolvedCard,
|
||||
} from './src/types';
|
||||
import { addIconButton, addTextButton, copyToClipboard } from './src/ui-helpers';
|
||||
import { folderPathsForTarget } from './src/vault';
|
||||
import { ParallelReaderView, VIEW_TYPE_PARALLEL } from './src/view';
|
||||
|
|
@ -106,12 +113,14 @@ function cancellationNoticeKey(settings: PluginSettings | null, job: GenerationJ
|
|||
|
||||
class ParallelReaderPlugin extends Plugin {
|
||||
settings!: PluginSettings;
|
||||
cache!: Record<string, CacheEntry>;
|
||||
cacheManager!: CacheManager;
|
||||
jobs!: GenerationJobManager;
|
||||
_scrollDispose: (() => void) | null = null;
|
||||
_settingsSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
_cacheSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
_cacheDirty = false;
|
||||
|
||||
get cache(): Record<string, CacheEntry> {
|
||||
return this.cacheManager.cache;
|
||||
}
|
||||
|
||||
t(key: string, vars?: Record<string, string | number>) {
|
||||
return translate(this.settings || DEFAULT_SETTINGS, key, vars);
|
||||
|
|
@ -120,8 +129,6 @@ 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();
|
||||
|
|
@ -171,7 +178,7 @@ class ParallelReaderPlugin extends Plugin {
|
|||
callback: async () => {
|
||||
const active = this.getActiveView();
|
||||
if (!active?.file) return new Notice(this.t('noCurrentNote'));
|
||||
await this.cacheDelete(active.file.path);
|
||||
await this.cacheManager.delete(active.file.path);
|
||||
new Notice(this.t('cacheClearedFile', { name: active.file.basename }));
|
||||
},
|
||||
});
|
||||
|
|
@ -179,8 +186,8 @@ class ParallelReaderPlugin extends Plugin {
|
|||
id: 'parallel-reader-clear-all',
|
||||
name: this.t('cmdClearAll'),
|
||||
callback: async () => {
|
||||
const n = Object.keys(this.cache).length;
|
||||
await this.cacheClear();
|
||||
const n = Object.keys(this.cacheManager.cache).length;
|
||||
await this.cacheManager.clear();
|
||||
new Notice(this.t('cacheClearedAll', { count: n }));
|
||||
},
|
||||
});
|
||||
|
|
@ -201,6 +208,11 @@ class ParallelReaderPlugin extends Plugin {
|
|||
name: this.t('cmdCardJump'),
|
||||
callback: () => this.jumpActiveCard(),
|
||||
});
|
||||
this.addCommand({
|
||||
id: 'parallel-reader-batch-generate',
|
||||
name: this.t('cmdBatchGenerate'),
|
||||
callback: () => this.runBatchForFolder(),
|
||||
});
|
||||
|
||||
this.addSettingTab(new ParallelReaderSettingTab(this.app, this));
|
||||
|
||||
|
|
@ -228,7 +240,13 @@ class ParallelReaderPlugin extends Plugin {
|
|||
const data = (await this.loadData()) || {};
|
||||
const settingsBlob = data.settings || {};
|
||||
this.settings = normalizeSettings(Object.assign({}, DEFAULT_SETTINGS, settingsBlob));
|
||||
await this.loadCache();
|
||||
this.cacheManager = new CacheManager(
|
||||
this.app.vault.adapter,
|
||||
this.app.vault.configDir || '.obsidian',
|
||||
this.manifest?.id || 'parallel-reader',
|
||||
() => this.settings,
|
||||
);
|
||||
await this.cacheManager.load();
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
|
|
@ -254,139 +272,25 @@ class ParallelReaderPlugin extends Plugin {
|
|||
await this.saveSettings();
|
||||
}
|
||||
|
||||
/* ---------- Cache persistence ---------- */
|
||||
|
||||
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() {
|
||||
const configDir = this.app.vault.configDir || '.obsidian';
|
||||
const pluginId = this.manifest?.id || 'parallel-reader';
|
||||
return `${configDir}/plugins/${pluginId}/cache.json`;
|
||||
}
|
||||
|
||||
async ensurePluginDataDir() {
|
||||
const adapter = this.app.vault.adapter;
|
||||
const configDir = this.app.vault.configDir || '.obsidian';
|
||||
const pluginId = this.manifest?.id || 'parallel-reader';
|
||||
const dir = `${configDir}/plugins/${pluginId}`;
|
||||
try {
|
||||
if (typeof adapter.exists === 'function' && (await adapter.exists(dir))) return;
|
||||
await adapter.mkdir(dir);
|
||||
} catch (_) {
|
||||
/* ignore race */
|
||||
}
|
||||
}
|
||||
|
||||
async readCacheFile() {
|
||||
const adapter = this.app.vault.adapter;
|
||||
try {
|
||||
const raw = await adapter.read(this.cacheFilePath());
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === 'object' && parsed.entries && typeof parsed.entries === 'object')
|
||||
return parsed.entries;
|
||||
} catch (e: unknown) {
|
||||
const message = String((e as Error)?.message || e || '');
|
||||
if (!/not found|does not exist|ENOENT/i.test(message))
|
||||
console.warn('[parallel-reader] failed to read cache.json', e);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async writeCacheFile() {
|
||||
await this.ensurePluginDataDir();
|
||||
await this.app.vault.adapter.write(this.cacheFilePath(), serializeCacheFile(this.cache));
|
||||
}
|
||||
|
||||
async loadCache() {
|
||||
this.cache = await this.readCacheFile();
|
||||
const pruned = this.pruneCache();
|
||||
if (pruned.length > 0) await this.writeCacheFile();
|
||||
}
|
||||
|
||||
pruneCache() {
|
||||
return pruneCacheEntries(this.cache, this.settings?.maxCacheEntries || DEFAULT_MAX_CACHE_ENTRIES);
|
||||
}
|
||||
async pruneCacheIfNeeded() {
|
||||
const removed = this.pruneCache();
|
||||
if (removed.length > 0) await this.saveCache();
|
||||
return removed;
|
||||
}
|
||||
cacheGet(filePath: string) {
|
||||
return this.cache[filePath] || null;
|
||||
}
|
||||
/* ---------- Cache delegation ---------- */
|
||||
|
||||
async cacheTouch(filePath: string) {
|
||||
const entry = touchCacheEntry(this.cache[filePath] || null);
|
||||
if (!entry) return null;
|
||||
this.scheduleCacheSave();
|
||||
return entry;
|
||||
return this.cacheManager.touch(filePath);
|
||||
}
|
||||
|
||||
async cachePut(filePath: string, content: string, cards: RawCard[], settings: PluginSettings) {
|
||||
const now = new Date().toISOString();
|
||||
this.cache[filePath] = {
|
||||
schemaVersion: CACHE_SCHEMA_VERSION,
|
||||
contentHash: hashContent(content),
|
||||
settingsHash: generationFingerprint(settings || this.settings),
|
||||
cards,
|
||||
generatedAt: now,
|
||||
lastAccessedAt: now,
|
||||
};
|
||||
await this.saveCache();
|
||||
scheduleCacheSave(delayMs = 5000) {
|
||||
this.cacheManager.scheduleSave(delayMs);
|
||||
}
|
||||
async flushCacheSave() {
|
||||
await this.cacheManager.flush();
|
||||
}
|
||||
|
||||
async cacheReplaceCards(filePath: string, cards: ResolvedCard[]) {
|
||||
const entry = this.cache[filePath];
|
||||
if (!entry) return false;
|
||||
const now = new Date().toISOString();
|
||||
entry.cards = (cards || []).map((card: ResolvedCard) => ({
|
||||
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: string) {
|
||||
if (this.cache[filePath]) {
|
||||
delete this.cache[filePath];
|
||||
await this.saveCache();
|
||||
}
|
||||
return this.cacheManager.replaceCards(filePath, cards);
|
||||
}
|
||||
async cacheClear() {
|
||||
this.cache = {};
|
||||
await this.saveCache();
|
||||
return this.cacheManager.clear();
|
||||
}
|
||||
async pruneCacheIfNeeded() {
|
||||
return this.cacheManager.pruneIfNeeded();
|
||||
}
|
||||
|
||||
/* ---------- View management ---------- */
|
||||
|
|
@ -465,29 +369,29 @@ class ParallelReaderPlugin extends Plugin {
|
|||
return true;
|
||||
}
|
||||
|
||||
addFileMenuItems(menu: any, file: any) {
|
||||
addFileMenuItems(menu: ObsidianMenu, file: unknown) {
|
||||
if (!(file instanceof TFile) || !file.path.endsWith('.md')) return;
|
||||
menu.addSeparator();
|
||||
menu.addItem((it: any) =>
|
||||
menu.addItem((it: ObsidianMenuItem) =>
|
||||
it
|
||||
.setTitle(this.t('fileMenuGenerate'))
|
||||
.setIcon('book-open')
|
||||
.onClick(() => this.runForFile(file, false)),
|
||||
);
|
||||
menu.addItem((it: any) =>
|
||||
menu.addItem((it: ObsidianMenuItem) =>
|
||||
it
|
||||
.setTitle(this.t('fileMenuRegen'))
|
||||
.setIcon('refresh-cw')
|
||||
.onClick(() => this.runForFile(file, true)),
|
||||
);
|
||||
if (this.cacheGet(file.path)) {
|
||||
menu.addItem((it: any) =>
|
||||
if (this.cacheManager.get(file.path)) {
|
||||
menu.addItem((it: ObsidianMenuItem) =>
|
||||
it
|
||||
.setTitle(this.t('fileMenuClear'))
|
||||
.setIcon('trash')
|
||||
.onClick(async () => {
|
||||
await this.cacheDelete(file.path);
|
||||
new Notice(this.t('cacheClearedFile', { name: file.basename }));
|
||||
await this.cacheManager.delete(file.path);
|
||||
new Notice(this.t('cacheClearedFile', { name: (file as TFile).basename }));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
@ -499,19 +403,16 @@ class ParallelReaderPlugin extends Plugin {
|
|||
const isMarkdown = file.path.endsWith('.md');
|
||||
if (!wasMarkdown && !isMarkdown) return;
|
||||
if (wasMarkdown && !isMarkdown) {
|
||||
if (this.cache[oldPath]) {
|
||||
delete this.cache[oldPath];
|
||||
await this.saveCache();
|
||||
}
|
||||
await this.cacheManager.delete(oldPath);
|
||||
const view = this.getParallelView();
|
||||
if (view && view.sourceFile?.path === oldPath) view.renderEmpty();
|
||||
return;
|
||||
}
|
||||
if (!wasMarkdown) return;
|
||||
if (this.cache[oldPath]) {
|
||||
this.cache[file.path] = this.cache[oldPath];
|
||||
delete this.cache[oldPath];
|
||||
await this.saveCache();
|
||||
if (this.cacheManager.cache[oldPath]) {
|
||||
this.cacheManager.cache[file.path] = this.cacheManager.cache[oldPath];
|
||||
delete this.cacheManager.cache[oldPath];
|
||||
await this.cacheManager.save();
|
||||
}
|
||||
const view = this.getParallelView();
|
||||
if (view?.sourceFile && (view.sourceFile.path === oldPath || view.sourceFile.path === file.path)) {
|
||||
|
|
@ -522,10 +423,7 @@ class ParallelReaderPlugin extends Plugin {
|
|||
|
||||
async handleFileDelete(file: TFile) {
|
||||
if (!(file instanceof TFile)) return;
|
||||
if (this.cache[file.path]) {
|
||||
delete this.cache[file.path];
|
||||
await this.saveCache();
|
||||
}
|
||||
await this.cacheManager.delete(file.path);
|
||||
const view = this.getParallelView();
|
||||
if (view?.sourceFile?.path === file.path) view.renderEmpty();
|
||||
}
|
||||
|
|
@ -579,7 +477,7 @@ class ParallelReaderPlugin extends Plugin {
|
|||
new Notice(this.t('alreadyGenerating'));
|
||||
return;
|
||||
}
|
||||
if (shouldConfirmRegenerate(this.cacheGet(file.path), force) && !this.confirmRegenerateEditedCards()) {
|
||||
if (shouldConfirmRegenerate(this.cacheManager.get(file.path), force) && !this.confirmRegenerateEditedCards()) {
|
||||
new Notice(this.t('regenerateCancelled'));
|
||||
return;
|
||||
}
|
||||
|
|
@ -600,8 +498,8 @@ class ParallelReaderPlugin extends Plugin {
|
|||
|
||||
job.setPhase('cache-check');
|
||||
if (!force) {
|
||||
const entry = this.cacheGet(file.path);
|
||||
if (cacheEntryMatches(entry, content, this.settings)) {
|
||||
const entry = this.cacheManager.get(file.path);
|
||||
if (entry && cacheEntryMatches(entry, content, this.settings)) {
|
||||
await this.cacheTouch(file.path);
|
||||
if (this.activeFileStillMatches(file))
|
||||
await view.loadFor(file, this.resolveCardAnchors(content, entry.cards), false);
|
||||
|
|
@ -630,7 +528,7 @@ class ParallelReaderPlugin extends Plugin {
|
|||
}
|
||||
const rawCards = sections.map((s) => ({ title: s.title, anchor: s.anchor, gist: s.gist, bullets: s.bullets }));
|
||||
job.setPhase('saving');
|
||||
await this.cachePut(file.path, content, rawCards, this.settings);
|
||||
await this.cacheManager.put(file.path, content, rawCards, this.settings);
|
||||
job.throwIfCancelled();
|
||||
if (this.viewIsShowingFile(view, file)) await view.loadFor(file, sections, false);
|
||||
const unanchored = sections.filter((s) => s.startLine < 0).length;
|
||||
|
|
@ -658,6 +556,57 @@ class ParallelReaderPlugin extends Plugin {
|
|||
});
|
||||
}
|
||||
|
||||
async runBatchForFolder() {
|
||||
const plugin = this;
|
||||
const folderPath = await new Promise<string | null>((resolve) => {
|
||||
class FolderPromptModal extends Modal {
|
||||
private input!: HTMLInputElement;
|
||||
onOpen() {
|
||||
this.contentEl.createEl('p', { text: plugin.t('batchSelectFolder') });
|
||||
this.input = this.contentEl.createEl('input', { type: 'text' });
|
||||
this.input.placeholder = plugin.t('batchFolderPrompt');
|
||||
this.input.style.width = '100%';
|
||||
this.input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
resolve(this.input.value.trim());
|
||||
this.close();
|
||||
}
|
||||
});
|
||||
this.contentEl.createEl('button', { text: 'OK' }).addEventListener('click', () => {
|
||||
resolve(this.input.value.trim());
|
||||
this.close();
|
||||
});
|
||||
}
|
||||
onClose() {
|
||||
resolve(null);
|
||||
}
|
||||
}
|
||||
new FolderPromptModal(plugin.app).open();
|
||||
});
|
||||
if (folderPath === null) return;
|
||||
const allFiles = this.app.vault.getMarkdownFiles().filter((f) => {
|
||||
if (folderPath === '') return !f.path.includes('/');
|
||||
return f.parent?.path === folderPath;
|
||||
});
|
||||
if (allFiles.length === 0) {
|
||||
new Notice(this.t('batchNoMarkdown'));
|
||||
return;
|
||||
}
|
||||
let skipped = 0;
|
||||
for (let i = 0; i < allFiles.length; i++) {
|
||||
const file = allFiles[i];
|
||||
new Notice(this.t('batchProgress', { current: i + 1, total: allFiles.length }));
|
||||
const content = await this.app.vault.read(file);
|
||||
const entry = this.cacheManager.get(file.path);
|
||||
if (entry && cacheEntryMatches(entry, content, this.settings)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
await this.runForFile(file, false);
|
||||
}
|
||||
new Notice(this.t('batchDone', { total: allFiles.length, skipped }));
|
||||
}
|
||||
|
||||
resolveCardAnchors(content: string, rawCards: RawCard[]) {
|
||||
const resolved: ResolvedCard[] = (rawCards || []).map((c: RawCard) => ({
|
||||
title: c.title,
|
||||
|
|
@ -684,7 +633,7 @@ class ParallelReaderPlugin extends Plugin {
|
|||
if (leaves.length === 0) return;
|
||||
const view = leaves[0].view as ParallelReaderView;
|
||||
if (!view || !this.activeFileStillMatches(file)) return;
|
||||
const entry = this.cacheGet(file.path);
|
||||
const entry = this.cacheManager.get(file.path);
|
||||
if (!entry) {
|
||||
await view.loadFor(file, [], false);
|
||||
view.renderEmptyWithHint(file);
|
||||
|
|
@ -705,7 +654,7 @@ class ParallelReaderPlugin extends Plugin {
|
|||
const mdView = this.getActiveView();
|
||||
if (!mdView) return;
|
||||
const editor = mdView.editor;
|
||||
const cm = editor && (editor as any).cm;
|
||||
const cm = editor && (editor as unknown as ObsidianEditorWithCm).cm;
|
||||
const scrollDom = cm?.scrollDOM ? cm.scrollDOM : mdView.contentEl.querySelector('.cm-scroller');
|
||||
if (!scrollDom) return;
|
||||
const handler = createRafThrottledHandler(() => this.handleEditorScroll(mdView));
|
||||
|
|
@ -720,7 +669,7 @@ class ParallelReaderPlugin extends Plugin {
|
|||
const view = this.getParallelView();
|
||||
if (!view || !mdView.file || view.sourceFile?.path !== mdView.file.path) return;
|
||||
const editor = mdView.editor;
|
||||
const cm = editor && (editor as any).cm;
|
||||
const cm = editor && (editor as unknown as ObsidianEditorWithCm).cm;
|
||||
if (!cm?.scrollDOM) return;
|
||||
const rect = cm.scrollDOM.getBoundingClientRect();
|
||||
const topY = visibleTopProbeY(rect);
|
||||
|
|
@ -744,7 +693,7 @@ class ParallelReaderPlugin extends Plugin {
|
|||
findLeafForFile(file: TFile | null) {
|
||||
if (!file) return null;
|
||||
for (const leaf of this.app.workspace.getLeavesOfType('markdown')) {
|
||||
const v = leaf.view as any;
|
||||
const v = leaf.view as { file?: TFile };
|
||||
if (v?.file && v.file.path === file.path) return leaf;
|
||||
}
|
||||
return null;
|
||||
|
|
|
|||
160
src/cache-manager.ts
Normal file
160
src/cache-manager.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
'use strict';
|
||||
|
||||
import type { DataAdapter } from 'obsidian';
|
||||
import { serializeCacheFile, touchCacheEntry } from './cache';
|
||||
import {
|
||||
CACHE_SCHEMA_VERSION,
|
||||
DEFAULT_MAX_CACHE_ENTRIES,
|
||||
generationFingerprint,
|
||||
hashContent,
|
||||
pruneCacheEntries,
|
||||
} from './settings';
|
||||
import type { CacheEntry, PluginSettings, RawCard, ResolvedCard } from './types';
|
||||
|
||||
export class CacheManager {
|
||||
cache: Record<string, CacheEntry> = {};
|
||||
private _timer: ReturnType<typeof setTimeout> | null = null;
|
||||
private _dirty = false;
|
||||
|
||||
constructor(
|
||||
private readonly adapter: DataAdapter,
|
||||
private readonly configDir: string,
|
||||
private readonly pluginId: string,
|
||||
private readonly getSettings: () => PluginSettings,
|
||||
) {}
|
||||
|
||||
filePath(): string {
|
||||
return `${this.configDir}/plugins/${this.pluginId}/cache.json`;
|
||||
}
|
||||
|
||||
async ensureDir(): Promise<void> {
|
||||
const dir = `${this.configDir}/plugins/${this.pluginId}`;
|
||||
try {
|
||||
if (typeof this.adapter.exists === 'function' && (await this.adapter.exists(dir))) return;
|
||||
await this.adapter.mkdir(dir);
|
||||
} catch (_) {
|
||||
/* ignore race */
|
||||
}
|
||||
}
|
||||
|
||||
async readFile(): Promise<Record<string, CacheEntry>> {
|
||||
try {
|
||||
const raw = await this.adapter.read(this.filePath());
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === 'object' && parsed.entries && typeof parsed.entries === 'object')
|
||||
return parsed.entries;
|
||||
} catch (e: unknown) {
|
||||
const message = String((e as Error)?.message || e || '');
|
||||
if (!/not found|does not exist|ENOENT/i.test(message))
|
||||
console.warn('[parallel-reader] failed to read cache.json', e);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async writeFile(): Promise<void> {
|
||||
await this.ensureDir();
|
||||
await this.adapter.write(this.filePath(), serializeCacheFile(this.cache));
|
||||
}
|
||||
|
||||
async load(): Promise<void> {
|
||||
this.cache = await this.readFile();
|
||||
const pruned = this.prune();
|
||||
if (pruned.length > 0) await this.writeFile();
|
||||
}
|
||||
|
||||
prune(): string[] {
|
||||
const settings = this.getSettings();
|
||||
return pruneCacheEntries(this.cache, settings?.maxCacheEntries || DEFAULT_MAX_CACHE_ENTRIES);
|
||||
}
|
||||
|
||||
async pruneIfNeeded(): Promise<string[]> {
|
||||
const removed = this.prune();
|
||||
if (removed.length > 0) await this.save();
|
||||
return removed;
|
||||
}
|
||||
|
||||
async save(): Promise<void> {
|
||||
if (this._timer) {
|
||||
clearTimeout(this._timer);
|
||||
this._timer = null;
|
||||
}
|
||||
this.prune();
|
||||
await this.writeFile();
|
||||
this._dirty = false;
|
||||
}
|
||||
|
||||
scheduleSave(delayMs = 5000): void {
|
||||
this._dirty = true;
|
||||
if (this._timer) return;
|
||||
this._timer = setTimeout(() => {
|
||||
this._timer = null;
|
||||
if (!this._dirty) return;
|
||||
this.save().catch((e) => console.error('[parallel-reader] failed to save cache', e));
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
if (this._timer) {
|
||||
clearTimeout(this._timer);
|
||||
this._timer = null;
|
||||
}
|
||||
if (!this._dirty) return;
|
||||
await this.save();
|
||||
}
|
||||
|
||||
get(filePath: string): CacheEntry | null {
|
||||
return this.cache[filePath] || null;
|
||||
}
|
||||
|
||||
async touch(filePath: string): Promise<CacheEntry | null> {
|
||||
const entry = touchCacheEntry(this.cache[filePath] || null);
|
||||
if (!entry) return null;
|
||||
this.cache[filePath] = entry;
|
||||
this.scheduleSave();
|
||||
return entry;
|
||||
}
|
||||
|
||||
async put(filePath: string, content: string, cards: RawCard[], settings: PluginSettings): Promise<void> {
|
||||
const now = new Date().toISOString();
|
||||
this.cache[filePath] = {
|
||||
schemaVersion: CACHE_SCHEMA_VERSION,
|
||||
contentHash: hashContent(content),
|
||||
settingsHash: generationFingerprint(settings || this.getSettings()),
|
||||
cards,
|
||||
generatedAt: now,
|
||||
lastAccessedAt: now,
|
||||
};
|
||||
await this.save();
|
||||
}
|
||||
|
||||
async replaceCards(filePath: string, cards: ResolvedCard[]): Promise<boolean> {
|
||||
const entry = this.cache[filePath];
|
||||
if (!entry) return false;
|
||||
const now = new Date().toISOString();
|
||||
this.cache[filePath] = {
|
||||
...entry,
|
||||
cards: (cards || []).map((card: ResolvedCard) => ({
|
||||
title: card.title,
|
||||
anchor: card.anchor,
|
||||
gist: card.gist,
|
||||
bullets: card.bullets || [],
|
||||
})),
|
||||
updatedAt: now,
|
||||
lastAccessedAt: now,
|
||||
};
|
||||
await this.save();
|
||||
return true;
|
||||
}
|
||||
|
||||
async delete(filePath: string): Promise<void> {
|
||||
if (this.cache[filePath]) {
|
||||
delete this.cache[filePath];
|
||||
await this.save();
|
||||
}
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.cache = {};
|
||||
await this.save();
|
||||
}
|
||||
}
|
||||
|
|
@ -4,8 +4,7 @@ import type { CacheEntry } from './types';
|
|||
|
||||
export function touchCacheEntry(entry: CacheEntry | null, now?: string): CacheEntry | null {
|
||||
if (!entry) return null;
|
||||
entry.lastAccessedAt = now || new Date().toISOString();
|
||||
return entry;
|
||||
return { ...entry, lastAccessedAt: now || new Date().toISOString() };
|
||||
}
|
||||
|
||||
export function serializeCacheFile(entries: Record<string, CacheEntry>): string {
|
||||
|
|
|
|||
12
src/i18n.ts
12
src/i18n.ts
|
|
@ -145,6 +145,12 @@ export const STRINGS: Record<string, Record<string, string>> = {
|
|||
clearAllCacheButton: '清除所有缓存',
|
||||
settingStreamingName: '流式输出',
|
||||
settingStreamingDesc: '启用后生成时实时显示 LLM 输出进度(仅 OpenAI Chat 和 Anthropic 格式支持)',
|
||||
cmdBatchGenerate: '批量生成对照笔记(当前文件夹)',
|
||||
batchSelectFolder: '请输入要批量处理的文件夹路径(留空为 Vault 根目录)',
|
||||
batchFolderPrompt: '文件夹路径',
|
||||
batchNoMarkdown: '该文件夹下没有 Markdown 文件',
|
||||
batchProgress: '批量生成:{current}/{total}',
|
||||
batchDone: '批量生成完成:共处理 {total} 篇,跳过缓存 {skipped} 篇',
|
||||
},
|
||||
en: {
|
||||
appTitle: 'Parallel Reader',
|
||||
|
|
@ -295,6 +301,12 @@ export const STRINGS: Record<string, Record<string, string>> = {
|
|||
settingStreamingName: 'Streaming output',
|
||||
settingStreamingDesc:
|
||||
'Show real-time LLM output progress during generation (OpenAI Chat and Anthropic formats only)',
|
||||
cmdBatchGenerate: 'Batch generate parallel notes (current folder)',
|
||||
batchSelectFolder: 'Enter the folder path to batch-process (leave blank for Vault root)',
|
||||
batchFolderPrompt: 'Folder path',
|
||||
batchNoMarkdown: 'No Markdown files found in that folder',
|
||||
batchProgress: 'Batch generating: {current}/{total}',
|
||||
batchDone: 'Batch complete: processed {total}, skipped {skipped} (cached)',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
180
src/providers.ts
180
src/providers.ts
|
|
@ -22,6 +22,58 @@ import {
|
|||
import { deltaExtractorForFormat, type StreamProgress, streamingFetch } from './streaming';
|
||||
import type { PluginSettings, RawCard } from './types';
|
||||
|
||||
/* ---------- Typed request/response shapes ---------- */
|
||||
|
||||
type RequestUrlFunction = (params: {
|
||||
url: string;
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
body: string;
|
||||
throw?: boolean;
|
||||
}) => Promise<{ status: number; json: unknown; text: string }>;
|
||||
|
||||
interface AnthropicMessagesBody {
|
||||
model: string;
|
||||
max_tokens: number;
|
||||
system: string;
|
||||
messages: Array<{ role: string; content: string }>;
|
||||
tools?: unknown[];
|
||||
tool_choice?: { type: string; name: string };
|
||||
stream?: boolean;
|
||||
}
|
||||
|
||||
interface OpenAiChatBody {
|
||||
model: string;
|
||||
messages: Array<{ role: string; content: string }>;
|
||||
response_format?: unknown;
|
||||
stream?: boolean;
|
||||
[tokenField: string]: unknown;
|
||||
}
|
||||
|
||||
interface OpenAiResponsesBody {
|
||||
model: string;
|
||||
instructions: string;
|
||||
input: string;
|
||||
max_output_tokens: number;
|
||||
text?: unknown;
|
||||
stream?: boolean;
|
||||
}
|
||||
|
||||
interface GeminiGenerationConfig {
|
||||
temperature: number;
|
||||
maxOutputTokens: number;
|
||||
responseMimeType?: string;
|
||||
responseJsonSchema?: unknown;
|
||||
}
|
||||
|
||||
interface GeminiBody {
|
||||
systemInstruction: { parts: Array<{ text: string }> };
|
||||
contents: Array<{ role: string; parts: Array<{ text: string }> }>;
|
||||
generationConfig: GeminiGenerationConfig;
|
||||
}
|
||||
|
||||
/* ---------- Helpers ---------- */
|
||||
|
||||
function endpointUrl(baseUrl: string, suffixes: string[]) {
|
||||
const base = baseUrl.replace(/\/+$/, '');
|
||||
for (const suffix of suffixes) {
|
||||
|
|
@ -37,8 +89,8 @@ function parseApiHeaders(raw: string, settings?: PluginSettings | null): Record<
|
|||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch (e: any) {
|
||||
throw new Error(translate(settings || null, 'errorCustomHeadersJsonParse', { error: e.message }));
|
||||
} catch (e: unknown) {
|
||||
throw new Error(translate(settings || null, 'errorCustomHeadersJsonParse', { error: (e as Error).message }));
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error(translate(settings || null, 'errorCustomHeadersJsonObject'));
|
||||
|
|
@ -90,10 +142,14 @@ function buildApiHeaders(settings: PluginSettings, extra?: Record<string, string
|
|||
};
|
||||
}
|
||||
|
||||
function responseJson(resp: any, label: string, settings?: PluginSettings | null) {
|
||||
if (resp.json && typeof resp.json === 'object') return resp.json;
|
||||
function responseJson(
|
||||
resp: { json: unknown; text: string },
|
||||
label: string,
|
||||
settings?: PluginSettings | null,
|
||||
): Record<string, unknown> {
|
||||
if (resp.json && typeof resp.json === 'object') return resp.json as Record<string, unknown>;
|
||||
try {
|
||||
return JSON.parse(resp.text || '{}');
|
||||
return JSON.parse(resp.text || '{}') as Record<string, unknown>;
|
||||
} catch (_) {
|
||||
throw new Error(
|
||||
translate(settings || null, 'errorProviderNonJson', {
|
||||
|
|
@ -105,14 +161,14 @@ function responseJson(resp: any, label: string, settings?: PluginSettings | null
|
|||
}
|
||||
|
||||
async function requestJsonBody(
|
||||
requestUrlImpl: any,
|
||||
requestUrlImpl: RequestUrlFunction,
|
||||
label: string,
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
body: any,
|
||||
body: unknown,
|
||||
settings?: PluginSettings | null,
|
||||
) {
|
||||
let resp: any;
|
||||
): Promise<Record<string, unknown>> {
|
||||
let resp: { status: number; json: unknown; text: string };
|
||||
try {
|
||||
resp = await requestUrlImpl({
|
||||
url,
|
||||
|
|
@ -121,11 +177,11 @@ async function requestJsonBody(
|
|||
body: JSON.stringify(body),
|
||||
throw: false,
|
||||
});
|
||||
} catch (e: any) {
|
||||
} catch (e: unknown) {
|
||||
throw new Error(
|
||||
translate(settings || null, 'errorProviderRequestFailed', {
|
||||
label,
|
||||
error: e.message || e,
|
||||
error: (e as Error).message || String(e),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
@ -142,8 +198,8 @@ async function requestJsonBody(
|
|||
return responseJson(resp, label, settings);
|
||||
}
|
||||
|
||||
function shouldRetryWithoutStructuredOutput(error: any) {
|
||||
const message = String(error?.message ? error.message : error);
|
||||
function shouldRetryWithoutStructuredOutput(error: unknown): boolean {
|
||||
const message = String((error as Error)?.message ?? error);
|
||||
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(
|
||||
|
|
@ -152,14 +208,14 @@ function shouldRetryWithoutStructuredOutput(error: any) {
|
|||
}
|
||||
|
||||
async function requestJsonBodyWithStructuredFallback(
|
||||
requestUrlImpl: any,
|
||||
requestUrlImpl: RequestUrlFunction,
|
||||
label: string,
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
structuredBody: any,
|
||||
fallbackBody: any,
|
||||
structuredBody: unknown,
|
||||
fallbackBody: unknown,
|
||||
settings?: PluginSettings | null,
|
||||
) {
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
return await requestJsonBody(requestUrlImpl, label, url, headers, structuredBody, settings);
|
||||
} catch (e) {
|
||||
|
|
@ -169,27 +225,31 @@ async function requestJsonBodyWithStructuredFallback(
|
|||
}
|
||||
}
|
||||
|
||||
function textFromContent(content: any): string {
|
||||
function textFromContent(content: unknown): string {
|
||||
if (typeof content === 'string') return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((part) => {
|
||||
if (typeof part === 'string') return part;
|
||||
if (part && typeof part === 'object') return part.text || part.output_text || '';
|
||||
if (part && typeof part === 'object') {
|
||||
const p = part as Record<string, unknown>;
|
||||
return typeof p.text === 'string' ? p.text : typeof p.output_text === 'string' ? p.output_text : '';
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
if (content && typeof content === 'object') {
|
||||
return content.text || content.output_text || '';
|
||||
const c = content as Record<string, unknown>;
|
||||
return typeof c.text === 'string' ? c.text : typeof c.output_text === 'string' ? c.output_text : '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function textFromOpenAiResponses(json: any): string {
|
||||
function textFromOpenAiResponses(json: Record<string, unknown>): string {
|
||||
if (typeof json.output_text === 'string') return json.output_text;
|
||||
const parts: string[] = [];
|
||||
const walk = (value: any) => {
|
||||
const walk = (value: unknown) => {
|
||||
if (!value) return;
|
||||
if (typeof value === 'string') return;
|
||||
if (Array.isArray(value)) {
|
||||
|
|
@ -197,11 +257,12 @@ function textFromOpenAiResponses(json: any): string {
|
|||
return;
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
if (typeof value.text === 'string') parts.push(value.text);
|
||||
if (typeof value.output_text === 'string') parts.push(value.output_text);
|
||||
if (value.type === 'output_text' && typeof value.content === 'string') parts.push(value.content);
|
||||
if (value.content) walk(value.content);
|
||||
if (value.output) walk(value.output);
|
||||
const v = value as Record<string, unknown>;
|
||||
if (typeof v.text === 'string') parts.push(v.text);
|
||||
if (typeof v.output_text === 'string') parts.push(v.output_text);
|
||||
if (v.type === 'output_text' && typeof v.content === 'string') parts.push(v.content);
|
||||
if (v.content) walk(v.content);
|
||||
if (v.output) walk(v.output);
|
||||
}
|
||||
};
|
||||
walk(json.output);
|
||||
|
|
@ -219,9 +280,9 @@ export function buildAnthropicMessagesBody(
|
|||
user: string,
|
||||
settings: PluginSettings,
|
||||
options?: { structured?: boolean },
|
||||
) {
|
||||
): AnthropicMessagesBody {
|
||||
const structured = !options || options.structured !== false;
|
||||
const body: any = {
|
||||
const body: AnthropicMessagesBody = {
|
||||
model: modelForApi(settings),
|
||||
max_tokens: Number(settings.apiMaxTokens) || 4096,
|
||||
system,
|
||||
|
|
@ -239,9 +300,9 @@ export function buildOpenAiChatBody(
|
|||
user: string,
|
||||
settings: PluginSettings,
|
||||
options?: { structured?: boolean },
|
||||
) {
|
||||
): OpenAiChatBody {
|
||||
const structured = !options || options.structured !== false;
|
||||
const body: any = {
|
||||
const body: OpenAiChatBody = {
|
||||
model: modelForApi(settings),
|
||||
messages: [
|
||||
{ role: 'system', content: system },
|
||||
|
|
@ -260,9 +321,9 @@ export function buildOpenAiResponsesBody(
|
|||
user: string,
|
||||
settings: PluginSettings,
|
||||
options?: { structured?: boolean },
|
||||
) {
|
||||
): OpenAiResponsesBody {
|
||||
const structured = !options || options.structured !== false;
|
||||
const body: any = {
|
||||
const body: OpenAiResponsesBody = {
|
||||
model: modelForApi(settings),
|
||||
instructions: system,
|
||||
input: user,
|
||||
|
|
@ -279,9 +340,9 @@ export function buildGeminiBody(
|
|||
user: string,
|
||||
settings: PluginSettings,
|
||||
options?: { structured?: boolean },
|
||||
) {
|
||||
): GeminiBody {
|
||||
const structured = !options || options.structured !== false;
|
||||
const generationConfig: any = {
|
||||
const generationConfig: GeminiGenerationConfig = {
|
||||
temperature: 0,
|
||||
maxOutputTokens: Number(settings.apiMaxTokens) || 4096,
|
||||
};
|
||||
|
|
@ -296,17 +357,18 @@ export function buildGeminiBody(
|
|||
};
|
||||
}
|
||||
|
||||
function cardsFromAnthropicToolUse(json: any, settings?: PluginSettings | null) {
|
||||
const content = Array.isArray(json?.content) ? json.content : [];
|
||||
const block = content.find((c: any) => c && c.type === 'tool_use' && c.name === ANTHROPIC_CARD_TOOL_NAME);
|
||||
function cardsFromAnthropicToolUse(json: Record<string, unknown>, settings?: PluginSettings | null) {
|
||||
const content = Array.isArray(json?.content) ? (json.content as Array<Record<string, unknown>>) : [];
|
||||
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, settings);
|
||||
if (block.input && typeof block.input === 'object') return normalizeCardsPayload(block.input);
|
||||
if (block.input && typeof block.input === 'object')
|
||||
return normalizeCardsPayload(block.input as Record<string, unknown>);
|
||||
return [];
|
||||
}
|
||||
|
||||
async function summarizeViaAnthropicMessages(
|
||||
requestUrlImpl: any,
|
||||
requestUrlImpl: RequestUrlFunction,
|
||||
system: string,
|
||||
user: string,
|
||||
settings: PluginSettings,
|
||||
|
|
@ -325,14 +387,20 @@ async function summarizeViaAnthropicMessages(
|
|||
const toolCards = cardsFromAnthropicToolUse(json, settings);
|
||||
if (toolCards) return toolCards;
|
||||
|
||||
const text = (json.content || [])
|
||||
.map((c: any) => textFromContent(c))
|
||||
const contentBlocks = Array.isArray(json.content) ? (json.content as Array<unknown>) : [];
|
||||
const text = contentBlocks
|
||||
.map((c) => textFromContent(c))
|
||||
.join('')
|
||||
.trim();
|
||||
return parseCardsJson(text, settings);
|
||||
}
|
||||
|
||||
async function summarizeViaOpenAiChat(requestUrlImpl: any, system: string, user: string, settings: PluginSettings) {
|
||||
async function summarizeViaOpenAiChat(
|
||||
requestUrlImpl: RequestUrlFunction,
|
||||
system: string,
|
||||
user: string,
|
||||
settings: PluginSettings,
|
||||
) {
|
||||
const url = endpointUrl(getApiBaseUrl(settings), ['/chat/completions']);
|
||||
const json = await requestJsonBodyWithStructuredFallback(
|
||||
requestUrlImpl,
|
||||
|
|
@ -343,13 +411,15 @@ async function summarizeViaOpenAiChat(requestUrlImpl: any, system: string, user:
|
|||
buildOpenAiChatBody(system, user, settings, { structured: false }),
|
||||
settings,
|
||||
);
|
||||
const choice = (json.choices || [])[0] || {};
|
||||
const text = textFromContent(choice.message?.content || choice.text || '').trim();
|
||||
const choices = Array.isArray(json.choices) ? (json.choices as Array<Record<string, unknown>>) : [];
|
||||
const choice = choices[0] || {};
|
||||
const message = choice.message as Record<string, unknown> | undefined;
|
||||
const text = textFromContent(message?.content ?? choice.text ?? '').trim();
|
||||
return parseCardsJson(text, settings);
|
||||
}
|
||||
|
||||
async function summarizeViaOpenAiResponses(
|
||||
requestUrlImpl: any,
|
||||
requestUrlImpl: RequestUrlFunction,
|
||||
system: string,
|
||||
user: string,
|
||||
settings: PluginSettings,
|
||||
|
|
@ -368,7 +438,7 @@ async function summarizeViaOpenAiResponses(
|
|||
}
|
||||
|
||||
async function summarizeViaGoogleGenerativeAi(
|
||||
requestUrlImpl: any,
|
||||
requestUrlImpl: RequestUrlFunction,
|
||||
system: string,
|
||||
user: string,
|
||||
settings: PluginSettings,
|
||||
|
|
@ -388,18 +458,20 @@ async function summarizeViaGoogleGenerativeAi(
|
|||
buildGeminiBody(system, user, settings, { structured: false }),
|
||||
settings,
|
||||
);
|
||||
const candidate = (json.candidates || [])[0] || {};
|
||||
const parts = candidate.content?.parts || [];
|
||||
const candidates = Array.isArray(json.candidates) ? (json.candidates as Array<Record<string, unknown>>) : [];
|
||||
const candidate = candidates[0] || {};
|
||||
const contentObj = candidate.content as Record<string, unknown> | undefined;
|
||||
const parts = Array.isArray(contentObj?.parts) ? (contentObj.parts as Array<unknown>) : [];
|
||||
const text = parts
|
||||
.map((p: any) => textFromContent(p))
|
||||
.map((p) => textFromContent(p))
|
||||
.join('')
|
||||
.trim();
|
||||
return parseCardsJson(text, settings);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Obsidian's requestUrl is not compatible with fetch
|
||||
// Obsidian's requestUrl is not directly compatible with fetch — we accept it as a typed callback
|
||||
export async function summarizeViaApi(
|
||||
requestUrlImpl: any,
|
||||
requestUrlImpl: RequestUrlFunction,
|
||||
system: string,
|
||||
user: string,
|
||||
settings: PluginSettings,
|
||||
|
|
@ -473,8 +545,8 @@ export async function summarizeViaApiStreaming(
|
|||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Obsidian's requestUrl is not compatible with fetch
|
||||
export async function testApiBackend(requestUrlImpl: any, settings: PluginSettings): Promise<string> {
|
||||
// Obsidian's requestUrl is not directly compatible with fetch — we accept it as a typed callback
|
||||
export async function testApiBackend(requestUrlImpl: RequestUrlFunction, settings: PluginSettings): Promise<string> {
|
||||
await summarizeViaApi(requestUrlImpl, '只输出 JSON:{"cards":[]}', '连通性测试:请原样输出 {"cards":[]}', settings);
|
||||
const format = getApiFormat(settings);
|
||||
return `${getApiPreset(settings).label} / ${API_FORMATS[format].label}`;
|
||||
|
|
|
|||
|
|
@ -105,8 +105,16 @@ export function normalizeCardsPayload(parsed: { cards?: unknown[] }): RawCard[]
|
|||
}));
|
||||
}
|
||||
|
||||
export function cardOutputSchema(strict: boolean) {
|
||||
const cardSchema: any = {
|
||||
interface JsonSchema {
|
||||
type: string;
|
||||
properties?: Record<string, unknown>;
|
||||
items?: unknown;
|
||||
required?: string[];
|
||||
additionalProperties?: boolean;
|
||||
}
|
||||
|
||||
export function cardOutputSchema(strict: boolean): JsonSchema {
|
||||
const cardSchema: JsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
title: { type: 'string' },
|
||||
|
|
@ -119,7 +127,7 @@ export function cardOutputSchema(strict: boolean) {
|
|||
},
|
||||
required: ['title', 'anchor', 'gist', 'bullets'],
|
||||
};
|
||||
const rootSchema: any = {
|
||||
const rootSchema: JsonSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
cards: {
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ export const DEFAULT_SETTINGS: PluginSettings = {
|
|||
exportFolder: 'Reading/Articles',
|
||||
cliTimeoutMs: 120000,
|
||||
streaming: true,
|
||||
streamingTimeoutMs: 120000,
|
||||
};
|
||||
|
||||
export const API_FORMATS: Record<string, ApiFormat> = {
|
||||
|
|
|
|||
|
|
@ -70,24 +70,15 @@ export interface StreamProgress {
|
|||
done: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a streaming fetch with SSE parsing.
|
||||
* Uses the native Fetch API (available in Electron/Obsidian).
|
||||
* Returns the full accumulated text when done.
|
||||
*/
|
||||
export async function streamingFetch(
|
||||
async function doStreamingFetch(
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
body: unknown,
|
||||
extractDelta: DeltaExtractor,
|
||||
onProgress?: (progress: StreamProgress) => void,
|
||||
signal?: AbortSignal,
|
||||
settings?: PluginSettings | null,
|
||||
onProgress: ((progress: StreamProgress) => void) | undefined,
|
||||
signal: AbortSignal,
|
||||
settings: PluginSettings | null | undefined,
|
||||
): Promise<string> {
|
||||
if (typeof globalThis.fetch !== 'function') {
|
||||
throw new Error('Streaming requires fetch API');
|
||||
}
|
||||
|
||||
const response = await globalThis.fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
|
|
@ -138,3 +129,50 @@ export async function streamingFetch(
|
|||
onProgress?.({ accumulated, done: true });
|
||||
return accumulated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a streaming fetch with SSE parsing and configurable timeout.
|
||||
* Uses the native Fetch API (available in Electron/Obsidian).
|
||||
* Returns the full accumulated text when done.
|
||||
*/
|
||||
export async function streamingFetch(
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
body: unknown,
|
||||
extractDelta: DeltaExtractor,
|
||||
onProgress?: (progress: StreamProgress) => void,
|
||||
signal?: AbortSignal,
|
||||
settings?: PluginSettings | null,
|
||||
): Promise<string> {
|
||||
if (typeof globalThis.fetch !== 'function') {
|
||||
throw new Error('Streaming requires fetch API');
|
||||
}
|
||||
|
||||
const timeoutMs = settings?.streamingTimeoutMs ?? 120000;
|
||||
const timeoutController = new AbortController();
|
||||
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
timeoutController.abort();
|
||||
} else {
|
||||
signal.addEventListener('abort', () => timeoutController.abort(), { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
timeoutController.abort();
|
||||
reject(new Error(`Streaming timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
return await Promise.race([
|
||||
doStreamingFetch(url, headers, body, extractDelta, onProgress, timeoutController.signal, settings),
|
||||
timeoutPromise,
|
||||
]);
|
||||
} finally {
|
||||
if (timeoutId !== null) clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
32
src/types.ts
32
src/types.ts
|
|
@ -66,6 +66,7 @@ export interface PluginSettings {
|
|||
exportFolder: string;
|
||||
cliTimeoutMs: number;
|
||||
streaming: boolean;
|
||||
streamingTimeoutMs: number;
|
||||
}
|
||||
|
||||
/* ---------- Provider types ---------- */
|
||||
|
|
@ -109,6 +110,37 @@ export interface PromptPair {
|
|||
user: string;
|
||||
}
|
||||
|
||||
/* ---------- Obsidian internal API types ---------- */
|
||||
|
||||
/** Minimal CodeMirror 6 EditorView shape used for scroll synchronization. */
|
||||
export interface CmEditorView {
|
||||
scrollDOM: HTMLElement;
|
||||
state: {
|
||||
doc: {
|
||||
lineAt(pos: number): { number: number };
|
||||
};
|
||||
};
|
||||
posAtCoords(coords: { x: number; y: number }): number | null;
|
||||
}
|
||||
|
||||
/** Obsidian Editor with optional CodeMirror 6 view attached at `.cm`. */
|
||||
export interface ObsidianEditorWithCm {
|
||||
cm?: CmEditorView;
|
||||
}
|
||||
|
||||
/** Minimal Obsidian MenuItem builder API used in file-menu callbacks. */
|
||||
export interface ObsidianMenuItem {
|
||||
setTitle(title: string): this;
|
||||
setIcon(icon: string): this;
|
||||
onClick(callback: () => unknown): this;
|
||||
}
|
||||
|
||||
/** Minimal Obsidian Menu API used to build context-menu entries. */
|
||||
export interface ObsidianMenu {
|
||||
addSeparator(): void;
|
||||
addItem(cb: (item: ObsidianMenuItem) => void): void;
|
||||
}
|
||||
|
||||
/* ---------- Plugin host interface ---------- */
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -281,8 +281,10 @@ 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 touchedEntry = t.touchCacheEntry(untouchedCacheEntry, '2024-01-05T00:00:00.000Z');
|
||||
assert.notStrictEqual(touchedEntry, untouchedCacheEntry, 'touchCacheEntry returns new object');
|
||||
assert.strictEqual(touchedEntry.lastAccessedAt, '2024-01-05T00:00:00.000Z', 'touched entry has updated timestamp');
|
||||
assert.strictEqual(untouchedCacheEntry.lastAccessedAt, undefined, 'original entry not mutated');
|
||||
assert.strictEqual(t.shouldConfirmRegenerate({ updatedAt: '2024-01-05T00:00:00.000Z' }, true), true);
|
||||
assert.strictEqual(t.shouldConfirmRegenerate({ updatedAt: '2024-01-05T00:00:00.000Z' }, false), false);
|
||||
assert.strictEqual(t.shouldConfirmRegenerate({ generatedAt: '2024-01-01T00:00:00.000Z' }, true), false);
|
||||
|
|
|
|||
|
|
@ -90,8 +90,9 @@ assert.deepStrictEqual(
|
|||
|
||||
assert.strictEqual(t.touchCacheEntry(null), null, 'touchCacheEntry on null returns null');
|
||||
const entry = { generatedAt: '2024-01-01T00:00:00.000Z' };
|
||||
t.touchCacheEntry(entry, '2024-06-01T00:00:00.000Z');
|
||||
assert.strictEqual(entry.lastAccessedAt, '2024-06-01T00:00:00.000Z', 'touchCacheEntry sets lastAccessedAt');
|
||||
const touched = t.touchCacheEntry(entry, '2024-06-01T00:00:00.000Z');
|
||||
assert.strictEqual(touched.lastAccessedAt, '2024-06-01T00:00:00.000Z', 'touchCacheEntry sets lastAccessedAt on returned entry');
|
||||
assert.strictEqual(entry.lastAccessedAt, undefined, 'touchCacheEntry does not mutate original entry');
|
||||
|
||||
const serialized = t.serializeCacheFile({ 'a.md': { cards: [] } });
|
||||
const parsed = JSON.parse(serialized);
|
||||
|
|
@ -455,4 +456,71 @@ const anthSse = t.parseSseBuffer(
|
|||
);
|
||||
assert.deepStrictEqual(anthSse.deltas, ['token'], 'Anthropic SSE extracts text delta');
|
||||
|
||||
// parseSseBuffer: partial line (no trailing newline → stays in rest)
|
||||
const ssePartial = t.parseSseBuffer('data: {"choices":[{"delta":{"content":"ok"}}]}', openaiExtract);
|
||||
assert.deepStrictEqual(ssePartial.deltas, [], 'incomplete line yields no deltas');
|
||||
assert.ok(ssePartial.rest.includes('"ok"'), 'partial line kept in rest');
|
||||
|
||||
// parseSseBuffer: multi-event in one chunk
|
||||
const sseMulti = t.parseSseBuffer(
|
||||
'data: {"choices":[{"delta":{"content":"a"}}]}\ndata: {"choices":[{"delta":{"content":"b"}}]}\ndata: {"choices":[{"delta":{"content":"c"}}]}\n',
|
||||
openaiExtract,
|
||||
);
|
||||
assert.deepStrictEqual(sseMulti.deltas, ['a', 'b', 'c'], 'three events in one chunk');
|
||||
assert.strictEqual(sseMulti.rest, '', 'nothing left in rest when chunk ends with newline');
|
||||
|
||||
// parseSseBuffer: non-data lines are skipped
|
||||
const sseMixed = t.parseSseBuffer(
|
||||
': comment\nevent: ping\ndata: {"choices":[{"delta":{"content":"x"}}]}\n',
|
||||
openaiExtract,
|
||||
);
|
||||
assert.deepStrictEqual(sseMixed.deltas, ['x'], 'comment and event lines ignored');
|
||||
|
||||
// parseSseBuffer: malformed JSON is silently skipped
|
||||
const sseBad = t.parseSseBuffer('data: not_json\ndata: {"choices":[{"delta":{"content":"y"}}]}\n', openaiExtract);
|
||||
assert.deepStrictEqual(sseBad.deltas, ['y'], 'bad JSON line skipped, good line extracted');
|
||||
|
||||
// i18n: additional edge cases
|
||||
assert.strictEqual(
|
||||
t.translate({ uiLanguage: 'en' }, 'generationDone', { count: 3, suffix: '' }),
|
||||
'Generated 3 sections',
|
||||
'variable interpolation with multiple vars',
|
||||
);
|
||||
assert.strictEqual(
|
||||
t.translate({ uiLanguage: 'zh' }, 'appTitle'),
|
||||
'对照阅读笔记',
|
||||
'zh translation for appTitle',
|
||||
);
|
||||
// missing key fallback chain: should return the key itself
|
||||
assert.strictEqual(t.translate({ uiLanguage: 'en' }, '__no_such_key__'), '__no_such_key__', 'missing key returns key');
|
||||
|
||||
// ── cli.ts: resolveCliPath ──
|
||||
|
||||
// Override path: should return the trimmed override immediately
|
||||
assert.strictEqual(t.resolveCliPath('claude', ' /usr/bin/claude '), '/usr/bin/claude', 'trims override path');
|
||||
assert.strictEqual(t.resolveCliPath('claude', '/custom/path'), '/custom/path', 'returns custom path unchanged');
|
||||
|
||||
// Empty override: falls back to filesystem search; mock fs.existsSync to control behavior
|
||||
const fsModule = require('fs');
|
||||
const origExistsSync = fsModule.existsSync;
|
||||
|
||||
// Mock existsSync to pretend a specific path exists
|
||||
const mockPath = require('path').join(require('os').homedir(), '.local', 'bin', 'claude');
|
||||
fsModule.existsSync = (p) => p === mockPath;
|
||||
try {
|
||||
const resolved = t.resolveCliPath('claude', '');
|
||||
assert.strictEqual(resolved, mockPath, 'resolveCliPath finds binary in mocked ~/.local/bin');
|
||||
} finally {
|
||||
fsModule.existsSync = origExistsSync;
|
||||
}
|
||||
|
||||
// No match: falls back to bare name
|
||||
fsModule.existsSync = () => false;
|
||||
try {
|
||||
const fallback = t.resolveCliPath('claude', '');
|
||||
assert.strictEqual(fallback, 'claude', 'resolveCliPath falls back to bare name when not found');
|
||||
} finally {
|
||||
fsModule.existsSync = origExistsSync;
|
||||
}
|
||||
|
||||
console.log('modules tests passed');
|
||||
|
|
|
|||
Loading…
Reference in a new issue