Merge pull request #1 from fancive/feat/engineering-improvements

refactor: engineering improvements batch
This commit is contained in:
fancivez 2026-04-26 15:43:18 +08:00 committed by GitHub
commit e0acce7f6d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 617 additions and 276 deletions

View file

@ -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

File diff suppressed because one or more lines are too long

281
main.ts
View file

@ -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
View 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();
}
}

View file

@ -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 {

View file

@ -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)',
},
};

View file

@ -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}`;

View file

@ -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: {

View file

@ -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> = {

View file

@ -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);
}
}

View file

@ -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 ---------- */
/**

View file

@ -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);

View file

@ -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');