refactor: split prompt and add localized UI

Move prompt and anchor matching into dedicated modules, add Chinese/English UI translation support, and cover cache fingerprint and translation behavior with tests.

Change-Id: Ieadc2197cba31cf750b83e2893909127da8ecca5
This commit is contained in:
wujunchen 2026-04-25 15:15:26 +08:00
parent 1fada9f472
commit 08f510c65c
8 changed files with 592 additions and 293 deletions

View file

@ -16,6 +16,7 @@ Inspired by the reading workflow demo in [this Bilibili video](https://www.bilib
- **Vault lifecycle aware** — cached summaries follow Markdown file renames and are removed when the source note is deleted.
- **Configurable input limit** — default 20,000 characters, adjustable for long-context models and included in the cache fingerprint.
- **Prompt controls** — choose summary language, card count range, and an optional custom system prompt. Prompt settings are included in the cache fingerprint.
- **Localized UI** — plugin commands, settings, pane text, and notices support Chinese and English.
- **Multiple model access paths** — Claude Code CLI, Codex CLI, direct provider APIs, and OpenAI/Anthropic-compatible proxies. The API backend follows the same lightweight `provider/model + base URL + protocol` idea as OpenClaw.
## Installation
@ -130,6 +131,9 @@ The plugin keeps `main.js` as the generated Obsidian runtime bundle. Edit `main.
| File | Responsibility |
|------|----------------|
| `main.ts` | Obsidian lifecycle, commands, right-pane view, settings tab orchestration |
| `src/anchor.ts` | Anchor-to-line matching and whitespace-normalized fallback |
| `src/i18n.ts` | Chinese/English UI strings and translation helper |
| `src/prompt.ts` | Prompt construction, language controls, and custom prompt templating |
| `src/settings.ts` | Defaults, provider presets, settings normalization, cache fingerprinting and pruning |
| `src/schema.ts` | JSON extraction, card payload normalization, structured-output schemas |
| `src/providers.ts` | API provider request/response adapters |

50
main.js

File diff suppressed because one or more lines are too long

424
main.ts
View file

@ -4,7 +4,10 @@ import { spawn } from 'child_process';
import os from 'os';
import path from 'path';
import fs from 'fs';
import { findLineForAnchor } from './src/anchor';
import { translate } from './src/i18n';
import { cardToMarkdown, cardToPlain, cardsToMarkdown } from './src/markdown';
import { buildPrompts } from './src/prompt';
import {
extractJson,
normalizeCardsPayload,
@ -32,8 +35,8 @@ import {
CACHE_SCHEMA_VERSION,
DEFAULT_SETTINGS,
DEFAULT_MAX_CACHE_ENTRIES,
MAX_DOC_CHARS,
PROMPT_LANGUAGES,
UI_LANGUAGES,
applyApiProviderPreset,
cacheEntryMatches,
generationFingerprint,
@ -47,149 +50,6 @@ import {
const VIEW_TYPE_PARALLEL = 'parallel-reader-view';
/* ---------- Document preparation & anchor resolution ---------- */
function promptLanguageInstruction(language) {
if (language === 'en') return 'Write title, gist, and bullets in English.';
if (language === 'auto') return 'Write title, gist, and bullets in the main language of the source document.';
return '用中文输出 title、gist 和 bullets。';
}
function promptSchemaExample(language) {
if (language === 'en') {
return `{"cards":[
{"title":"U-shaped gains","anchor":"Who benefits from AI? Overall, it shifts the score from one to seven","gist":"AI productivity gains form a U shape, with both ends benefiting most","bullets":["Top-paid software managers benefit by accelerating existing work","Low-paid workers use AI to create new side income","Middle-layer specialists gain less because prompt precision is hard to trust","Average reported benefit is 5.1/7, with 42% describing gains as unclear"]}
]}`;
}
return `{"cards":[
{"title":"U 型收益曲线","anchor":"那谁又会被 AI 所受益?整体来看,它把整个分数变成了一分到七分","gist":"AI 生产力收益呈 U 型,两端受益最大、中间层塌陷","bullets":["最高薪岗位(软件管理)通过加速既有工作受益最大","最低薪岗位(外卖员、园艺工)用 AI 开副业创造新收入","中间层科学家、律师收益最少,部分因对 prompt 精度信任不足","全体均分 5.1/742% 报告收益模糊"]}
]}`;
}
function renderPromptTemplate(template, vars) {
return String(template || '').replace(/\{([a-zA-Z0-9_]+)\}/g, (match, key) => {
return Object.prototype.hasOwnProperty.call(vars, key) ? String(vars[key]) : match;
});
}
function buildPrompts(content, settings) {
const maxDocChars = Number(settings.maxDocChars) || MAX_DOC_CHARS;
const promptLanguage = PROMPT_LANGUAGES[settings.promptLanguage] ? settings.promptLanguage : DEFAULT_SETTINGS.promptLanguage;
const minCards = Math.max(1, Number(settings.minCards) || DEFAULT_SETTINGS.minCards);
const maxCards = Math.max(minCards, Number(settings.maxCards) || DEFAULT_SETTINGS.maxCards);
const languageInstruction = promptLanguageInstruction(promptLanguage);
const doc = content.length > maxDocChars
? content.slice(0, maxDocChars) + (promptLanguage === 'en' ? '\n\n[Document truncated]' : '\n\n[文档过长,已截断]')
: content;
const schema = '{"cards":[{"title":"...","anchor":"...","gist":"...","bullets":["...","..."]}]}';
const example = promptSchemaExample(promptLanguage);
const templateVars = { minCards, maxCards, languageInstruction, schema, example };
const customSystem = renderPromptTemplate(settings.customSystemPrompt, templateVars).trim();
const defaultSystem = `你是一个长文阅读摘要助手。阅读全文后,把文章切成 ${minCards}-${maxCards} 个"自然主题单元"——不必对应 markdown heading以"一个完整论点或话题"为单位自行判断粒度:短章节合并、长章节拆分。
** + bulletbullet gist **
- ${languageInstruction}
- title: 3-10 "背景""介绍"
- anchor: 该单元开头的**** 1:1 40-80 //
- gist: ****20-40 bullets
- bullets: **3-6 ** bullet 20-50 gist bullets
- anchor exact substring match
- anchor "综上所述"
- gist bullets gist bullets
- bullet "首先""其次"
- JSON markdown fence tool call
${schema}
${example}`;
const system = customSystem
? `${customSystem}
- ${minCards}-${maxCards} cards
- ${languageInstruction}
- anchor exact substring match
- JSON markdown fence tool call
- JSON shape: ${schema}`
: defaultSystem;
const user = promptLanguage === 'en'
? `Source document:\n\n${doc}`
: `以下是需要处理的文档全文:\n\n${doc}`;
return { system, user };
}
function findLineForAnchor(content, anchor) {
if (!anchor) return -1;
const normalize = s => s.replace(/\s+/g, ' ').trim();
const normalizeWithMap = s => {
const chars = [];
const map = [];
let pendingWhitespace = false;
for (let i = 0; i < s.length; i++) {
const c = s[i];
if (/\s/.test(c)) {
pendingWhitespace = chars.length > 0;
continue;
}
if (pendingWhitespace) {
chars.push(' ');
map.push(i);
pendingWhitespace = false;
}
chars.push(c);
map.push(i);
}
return { text: chars.join(''), map };
};
const tryAt = needle => {
if (!needle) return -1;
const idx = content.indexOf(needle);
if (idx === -1) return -1;
let line = 0;
for (let i = 0; i < idx; i++) if (content[i] === '\n') line++;
return line;
};
let line = tryAt(anchor);
if (line >= 0) return line;
// Fallback 1: trim trailing whitespace variants
line = tryAt(anchor.trim());
if (line >= 0) return line;
// Fallback 2: progressively shorter prefix (LLM may paraphrase the tail)
for (const len of [60, 40, 25, 15]) {
const prefix = anchor.trim().slice(0, len);
line = tryAt(prefix);
if (line >= 0) return line;
}
// Fallback 3: whitespace-normalized search (costlier, rarely needed)
const normDoc = normalizeWithMap(content);
const normAnchor = normalize(anchor).slice(0, 30);
if (!normAnchor) return -1;
const normIdx = normDoc.text.indexOf(normAnchor);
if (normIdx === -1) return -1;
const originalIdx = normDoc.map[normIdx];
if (originalIdx == null) return -1;
let l = 0;
for (let j = 0; j < originalIdx; j++) if (content[j] === '\n') l++;
return l;
}
/* CLI discovery: Obsidian launched from GUI doesn't inherit shell PATH */
function resolveCliPath(name, override) {
if (override && override.trim()) return override.trim();
@ -244,14 +104,14 @@ function runCli(cmd, args, stdinText, timeoutMs, job?) {
},
});
} catch (e) {
return reject(new Error(`无法启动 ${cmd}: ${e.message}`));
return reject(new Error(`Failed to start ${cmd}: ${e.message}`));
}
let stdout = '';
let stderr = '';
timer = setTimeout(() => {
try { child.kill('SIGKILL'); } catch (_) {}
fail(new Error(`CLI 超时 (${timeoutMs}ms)`));
fail(new Error(`CLI timed out (${timeoutMs}ms)`));
}, timeoutMs);
if (job) {
job.onCancel(() => {
@ -263,12 +123,12 @@ function runCli(cmd, args, stdinText, timeoutMs, job?) {
child.stdout.on('data', d => { stdout += d.toString('utf8'); });
child.stderr.on('data', d => { stderr += d.toString('utf8'); });
child.on('error', e => {
fail(new Error(`CLI 启动错误: ${e.message}(尝试在设置里填绝对路径)`));
fail(new Error(`CLI startup error: ${e.message}. Try setting an absolute CLI path.`));
});
child.on('close', code => {
if (settled) return;
if (code !== 0) {
return fail(new Error(`CLI 退出码 ${code}\nstderr:\n${stderr.slice(0, 1000)}`));
return fail(new Error(`CLI exited with code ${code}\nstderr:\n${stderr.slice(0, 1000)}`));
}
succeed({ stdout, stderr });
});
@ -303,7 +163,7 @@ async function summarizeViaClaudeCode(system, user, settings, job) {
// claude -p --output-format json returns {"type":"result", ..., "result":"..."}
let envelope;
try { envelope = JSON.parse(stdout); } catch (e) {
throw new Error('claude CLI 输出非 JSON envelope\n' + stdout.slice(0, 500));
throw new Error('claude CLI returned a non-JSON envelope:\n' + stdout.slice(0, 500));
}
const resultText = envelope.result || envelope.content || '';
return parseCardsJson(resultText);
@ -311,7 +171,7 @@ async function summarizeViaClaudeCode(system, user, settings, job) {
async function summarizeViaCodex(system, user, settings, job) {
const cmd = resolveCliPath('codex', settings.cliPath);
const combined = `<<SYSTEM>>\n${system}\n<<USER>>\n${user}\n\n直接输出 JSON不要任何解释。`;
const combined = `<<SYSTEM>>\n${system}\n<<USER>>\n${user}\n\nOutput JSON directly with no explanation.`;
const args = ['exec', '--skip-git-repo-check', '-'];
const { stdout } = await runCli(cmd, args, combined, settings.cliTimeoutMs, job);
return parseCardsJson(stdout);
@ -331,7 +191,7 @@ async function testBackend(settings) {
if (isApiBackend(settings.backend)) {
return testApiBackend(requestUrl, settings);
}
throw new Error('未知 backend' + settings.backend);
throw new Error('Unknown backend: ' + settings.backend);
}
async function summarizeDocument(content, settings, job) {
@ -388,7 +248,7 @@ function addIconButton(parent, icon, title, onClick) {
await onClick();
} catch (err) {
console.error(err);
new Notice(`${title}失败:` + (err.message || err));
new Notice(`${title} failed: ` + (err.message || err));
}
});
return button;
@ -408,7 +268,7 @@ function addTextButton(parent, icon, label, onClick, cls) {
await onClick();
} catch (err) {
console.error(err);
new Notice(`${label}失败:` + (err.message || err));
new Notice(`${label} failed: ` + (err.message || err));
}
});
return button;
@ -419,7 +279,7 @@ async function copyToClipboard(text, successMsg) {
await navigator.clipboard.writeText(text);
new Notice(successMsg);
} catch (e) {
new Notice('复制失败:' + (e.message || e));
new Notice('Copy failed: ' + (e.message || e));
}
}
@ -447,7 +307,7 @@ class ParallelReaderView extends ItemView {
}
getViewType() { return VIEW_TYPE_PARALLEL; }
getDisplayText() { return '对照阅读笔记'; }
getDisplayText() { return this.plugin.t('displayName'); }
getIcon() { return 'book-open'; }
async onOpen() {
@ -468,9 +328,9 @@ class ParallelReaderView extends ItemView {
const container = this.containerEl.children[1];
container.empty();
const hint = container.createDiv({ cls: 'parallel-reader-empty' });
hint.createEl('h3', { text: '对照阅读笔记' });
hint.createEl('p', { text: '打开一篇笔记,然后运行命令:' });
hint.createEl('code', { text: 'Parallel Reader: 为当前笔记生成对照笔记' });
hint.createEl('h3', { text: this.plugin.t('appTitle') });
hint.createEl('p', { text: this.plugin.t('emptyOpenNote') });
hint.createEl('code', { text: this.plugin.t('commandGenerate') });
}
async loadFor(file, sections, stale) {
@ -486,7 +346,7 @@ class ParallelReaderView extends ItemView {
this.sourceFile = file;
this.sections = [];
this.stale = false;
this.loadingMessage = message || '正在生成对照笔记...';
this.loadingMessage = message || this.plugin.t('loadingDefault');
this.errorMessage = '';
this.render();
}
@ -496,7 +356,7 @@ class ParallelReaderView extends ItemView {
this.sections = [];
this.stale = false;
this.loadingMessage = '';
this.errorMessage = message || '生成失败';
this.errorMessage = message || this.plugin.t('errorTitle');
this.render();
}
@ -510,8 +370,8 @@ class ParallelReaderView extends ItemView {
container.empty();
const hint = container.createDiv({ cls: 'parallel-reader-empty' });
hint.createEl('h3', { text: file.basename });
hint.createEl('p', { text: '该笔记尚无对照笔记缓存。运行命令:' });
hint.createEl('code', { text: 'Parallel Reader: 为当前笔记生成对照笔记' });
hint.createEl('p', { text: this.plugin.t('emptyNoCache') });
hint.createEl('code', { text: this.plugin.t('commandGenerate') });
}
render() {
@ -524,21 +384,21 @@ class ParallelReaderView extends ItemView {
const actions = headerRow.createDiv({ cls: 'parallel-reader-actions' });
if (this.sourceFile) {
if (this.plugin.isGeneratingFile(this.sourceFile)) {
addIconButton(actions, 'square', '取消生成', () => this.plugin.cancelGenerationForFile(this.sourceFile));
addIconButton(actions, 'square', this.plugin.t('actionCancel'), () => this.plugin.cancelGenerationForFile(this.sourceFile));
} else {
addIconButton(actions, 'refresh-cw', '重新生成', () => this.plugin.runForFile(this.sourceFile, true));
addIconButton(actions, 'refresh-cw', this.plugin.t('actionRegenerate'), () => this.plugin.runForFile(this.sourceFile, true));
}
addIconButton(actions, 'copy', '复制全部 Markdown', () => this.plugin.copyCurrentViewMarkdown());
addIconButton(actions, 'download', '导出到 Vault', () => this.exportToVault());
addIconButton(actions, 'copy', this.plugin.t('actionCopyAll'), () => this.plugin.copyCurrentViewMarkdown());
addIconButton(actions, 'download', this.plugin.t('actionExport'), () => this.exportToVault());
}
if (this.stale) {
const banner = container.createDiv({ cls: 'parallel-reader-stale-banner' });
banner.createSpan({ text: '源笔记或生成配置已修改,当前是旧缓存。' });
banner.createSpan({ text: this.plugin.t('staleBanner') });
addTextButton(
banner,
'refresh-cw',
'重新生成',
this.plugin.t('actionRegenerate'),
() => this.plugin.runForFile(this.sourceFile, true),
'parallel-reader-stale-button'
);
@ -548,18 +408,18 @@ class ParallelReaderView extends ItemView {
const state = container.createDiv({ cls: 'parallel-reader-state parallel-reader-loading' });
state.createDiv({ cls: 'parallel-reader-spinner' });
state.createEl('div', { text: this.loadingMessage, cls: 'parallel-reader-state-title' });
state.createEl('div', { text: '可以继续阅读原文,生成完成后会自动刷新右侧卡片。', cls: 'parallel-reader-state-subtitle' });
state.createEl('div', { text: this.plugin.t('loadingSubtitle'), cls: 'parallel-reader-state-subtitle' });
return;
}
if (this.errorMessage) {
const state = container.createDiv({ cls: 'parallel-reader-state parallel-reader-error' });
state.createEl('div', { text: '生成失败', cls: 'parallel-reader-state-title' });
state.createEl('div', { text: this.plugin.t('errorTitle'), cls: 'parallel-reader-state-title' });
state.createEl('div', { text: this.errorMessage, cls: 'parallel-reader-state-subtitle' });
addTextButton(
state,
'refresh-cw',
'重新生成',
this.plugin.t('actionRegenerate'),
() => this.plugin.runForFile(this.sourceFile, true),
'parallel-reader-text-button'
);
@ -578,7 +438,7 @@ class ParallelReaderView extends ItemView {
const title = card.createEl('div', { cls: 'parallel-reader-card-title' });
title.createSpan({ text: s.title });
if (s.startLine < 0) {
title.createEl('span', { text: ' ⚠', cls: 'parallel-reader-warn', title: 'anchor 匹配失败,无法滚动联动' });
title.createEl('span', { text: ' ⚠', cls: 'parallel-reader-warn', title: this.plugin.t('anchorMismatch') });
}
// --- Gist (rendered as markdown so inline bold/code/links work) ---
@ -598,7 +458,7 @@ class ParallelReaderView extends ItemView {
bulletsEl.setText(md);
});
} else if (!s.gist) {
card.createEl('div', { cls: 'parallel-reader-empty-li', text: '(未生成)' });
card.createEl('div', { cls: 'parallel-reader-empty-li', text: this.plugin.t('emptyCard') });
}
// Left click → jump to source line
@ -616,17 +476,17 @@ class ParallelReaderView extends ItemView {
card.addEventListener('contextmenu', (e) => {
e.preventDefault();
const menu = new Menu();
menu.addItem(it => it.setTitle('复制 Markdown').setIcon('copy')
.onClick(() => copyToClipboard(cardToMarkdown(s), '已复制 Markdown')));
menu.addItem(it => it.setTitle('复制纯文本').setIcon('clipboard-copy')
.onClick(() => copyToClipboard(cardToPlain(s), '已复制纯文本')));
menu.addItem(it => it.setTitle(this.plugin.t('menuCopyMarkdown')).setIcon('copy')
.onClick(() => copyToClipboard(cardToMarkdown(s), this.plugin.t('copiedMarkdown'))));
menu.addItem(it => it.setTitle(this.plugin.t('menuCopyPlain')).setIcon('clipboard-copy')
.onClick(() => copyToClipboard(cardToPlain(s), this.plugin.t('copiedPlain'))));
if (s.anchor) {
menu.addItem(it => it.setTitle('复制 anchor 引用').setIcon('quote-glyph')
.onClick(() => copyToClipboard(s.anchor, '已复制引用原文')));
menu.addItem(it => it.setTitle(this.plugin.t('menuCopyAnchor')).setIcon('quote-glyph')
.onClick(() => copyToClipboard(s.anchor, this.plugin.t('copiedAnchor'))));
}
menu.addSeparator();
if (s.startLine >= 0) {
menu.addItem(it => it.setTitle('跳转到原文').setIcon('arrow-right')
menu.addItem(it => it.setTitle(this.plugin.t('menuJumpSource')).setIcon('arrow-right')
.onClick(() => this.plugin.scrollEditorToLine(s.startLine, this.sourceFile)));
}
menu.showAtMouseEvent(e);
@ -651,7 +511,7 @@ class ParallelReaderView extends ItemView {
async exportToVault() {
if (!this.sourceFile) return;
const folder = this.plugin.settings.exportFolder.replace(/\/$/, '');
const name = `${this.sourceFile.basename} - 对照笔记.md`;
const name = `${this.sourceFile.basename} - ${this.plugin.t('displayName')}.md`;
const targetPath = `${folder}/${name}`;
const markdown = [
@ -661,7 +521,7 @@ class ParallelReaderView extends ItemView {
'tool: parallel-reader',
'---',
'',
cardsToMarkdown(`${this.sourceFile.basename} · 对照笔记`, this.sections),
cardsToMarkdown(`${this.sourceFile.basename} · ${this.plugin.t('displayName')}`, this.sections),
'',
].join('\n');
@ -678,7 +538,7 @@ class ParallelReaderView extends ItemView {
} else {
await app.vault.create(targetPath, markdown);
}
new Notice(`已导出 → ${targetPath}`);
new Notice(this.plugin.t('exported', { path: targetPath }));
}
}
@ -691,11 +551,15 @@ class ParallelReaderPlugin extends Plugin {
_scrollDispose: (() => void) | null;
_settingsSaveTimer: ReturnType<typeof setTimeout> | null;
t(key, vars?) {
return translate(this.settings || DEFAULT_SETTINGS, key, vars);
}
async onload() {
await this.loadSettings();
this.jobs = new GenerationJobManager();
this.addRibbonIcon('book-open', '打开对照笔记面板', async () => {
this.addRibbonIcon('book-open', this.t('ribbonOpen'), async () => {
const active = this.getActiveView();
await this.ensureView();
if (active?.file) await this.syncViewToFile(active.file);
@ -705,19 +569,19 @@ class ParallelReaderPlugin extends Plugin {
this.addCommand({
id: 'parallel-reader-run',
name: '为当前笔记生成对照笔记(缓存优先)',
name: this.t('cmdRun'),
callback: () => this.runForActiveFile(false),
});
this.addCommand({
id: 'parallel-reader-regen',
name: '强制重新生成(绕过缓存)',
name: this.t('cmdRegen'),
callback: () => this.runForActiveFile(true),
});
this.addCommand({
id: 'parallel-reader-open-view',
name: '打开对照笔记面板',
name: this.t('cmdOpenView'),
callback: async () => {
const active = this.getActiveView();
await this.ensureView();
@ -727,40 +591,40 @@ class ParallelReaderPlugin extends Plugin {
this.addCommand({
id: 'parallel-reader-export-current',
name: '导出当前对照笔记到 Vault',
name: this.t('cmdExport'),
callback: () => this.exportCurrentView(),
});
this.addCommand({
id: 'parallel-reader-copy-current-markdown',
name: '复制当前对照笔记 Markdown',
name: this.t('cmdCopyMarkdown'),
callback: () => this.copyCurrentViewMarkdown(),
});
this.addCommand({
id: 'parallel-reader-cancel-current',
name: '取消当前对照笔记生成',
name: this.t('cmdCancel'),
callback: () => this.cancelActiveGeneration(),
});
this.addCommand({
id: 'parallel-reader-clear-current',
name: '清除当前笔记的缓存',
name: this.t('cmdClearCurrent'),
callback: async () => {
const active = this.getActiveView();
if (!active?.file) return new Notice('没有当前笔记');
if (!active?.file) return new Notice(this.t('noCurrentNote'));
await this.cacheDelete(active.file.path);
new Notice('已清除缓存:' + active.file.basename);
new Notice(this.t('cacheClearedFile', { name: active.file.basename }));
},
});
this.addCommand({
id: 'parallel-reader-clear-all',
name: '清除所有缓存',
name: this.t('cmdClearAll'),
callback: async () => {
const n = Object.keys(this.cache).length;
await this.cacheClear();
new Notice(`已清除 ${n} 条缓存`);
new Notice(this.t('cacheClearedAll', { count: n }));
},
});
@ -942,11 +806,11 @@ class ParallelReaderPlugin extends Plugin {
cancelGenerationForFile(file) {
if (!file || !file.path) {
new Notice('当前没有可取消的生成任务');
new Notice(this.t('noCancelableJob'));
return false;
}
const cancelled = this.jobs.cancel(file.path);
new Notice(cancelled ? '已请求取消生成' : '当前没有可取消的生成任务');
new Notice(cancelled ? this.t('cancelRequested') : this.t('noCancelableJob'));
return cancelled;
}
@ -964,27 +828,27 @@ class ParallelReaderPlugin extends Plugin {
if (active?.file && this.cancelGenerationForFile(active.file)) return;
const view = this.app.workspace.getLeavesOfType(VIEW_TYPE_PARALLEL)[0]?.view as ParallelReaderView | undefined;
if (view?.sourceFile) this.cancelGenerationForFile(view.sourceFile);
else new Notice('当前没有可取消的生成任务');
else new Notice(this.t('noCancelableJob'));
}
addFileMenuItems(menu, file) {
if (!(file instanceof TFile) || !file.path.endsWith('.md')) return;
menu.addSeparator();
menu.addItem(it => it
.setTitle('生成对照笔记')
.setTitle(this.t('fileMenuGenerate'))
.setIcon('book-open')
.onClick(() => this.runForFile(file, false)));
menu.addItem(it => it
.setTitle('强制重新生成对照笔记')
.setTitle(this.t('fileMenuRegen'))
.setIcon('refresh-cw')
.onClick(() => this.runForFile(file, true)));
if (this.cacheGet(file.path)) {
menu.addItem(it => it
.setTitle('清除对照笔记缓存')
.setTitle(this.t('fileMenuClear'))
.setIcon('trash')
.onClick(async () => {
await this.cacheDelete(file.path);
new Notice('已清除缓存:' + file.basename);
new Notice(this.t('cacheClearedFile', { name: file.basename }));
}));
}
}
@ -1037,7 +901,7 @@ class ParallelReaderPlugin extends Plugin {
if (active?.file) await this.syncViewToFile(active.file);
}
if (!view.sourceFile || !view.sections.length) {
new Notice('当前没有可导出的对照笔记');
new Notice(this.t('noExportContent'));
return;
}
await view.exportToVault();
@ -1050,19 +914,19 @@ class ParallelReaderPlugin extends Plugin {
if (active?.file) await this.syncViewToFile(active.file);
}
if (!view.sourceFile || !view.sections.length) {
new Notice('当前没有可复制的对照笔记');
new Notice(this.t('noCopyContent'));
return;
}
await copyToClipboard(
cardsToMarkdown(`${view.sourceFile.basename} · 对照笔记`, view.sections),
'已复制全部 Markdown'
cardsToMarkdown(`${view.sourceFile.basename} · ${this.t('displayName')}`, view.sections),
this.t('copiedAllMarkdown')
);
}
async runForActiveFile(force) {
const mdView = this.getActiveView();
if (!mdView || !mdView.file) {
new Notice('先打开一篇笔记');
new Notice(this.t('openNoteFirst'));
return;
}
return this.runForFile(mdView.file, force);
@ -1070,12 +934,12 @@ class ParallelReaderPlugin extends Plugin {
async runForFile(file, force) {
if (!file) {
new Notice('先打开一篇笔记');
new Notice(this.t('openNoteFirst'));
return;
}
const runningKey = file.path;
if (this.jobs.isRunning(runningKey)) {
new Notice('该笔记正在生成对照笔记');
new Notice(this.t('alreadyGenerating'));
return;
}
@ -1085,7 +949,7 @@ class ParallelReaderPlugin extends Plugin {
const content = await this.app.vault.read(file);
job.throwIfCancelled();
if (!content.trim()) {
new Notice('笔记为空');
new Notice(this.t('emptyNote'));
return;
}
@ -1104,18 +968,18 @@ class ParallelReaderPlugin extends Plugin {
}
}
await view.renderLoading(file, '对照阅读:让 LLM 读全文并自适应切段...');
await view.renderLoading(file, this.t('loadingGenerating'));
const maxDocChars = Number(this.settings.maxDocChars) || DEFAULT_SETTINGS.maxDocChars;
if (content.length > maxDocChars) {
new Notice(`笔记较长:仅发送前 ${maxDocChars} 个字符给模型`);
new Notice(this.t('longNoteTruncated', { count: maxDocChars }));
}
new Notice(`对照阅读:让 LLM 读全文并自适应切段…`);
new Notice(this.t('generatingNotice'));
job.setPhase('generating');
const sections = await summarizeDocument(content, this.settings, job);
job.throwIfCancelled();
if (sections.length === 0) {
new Notice('LLM 未返回任何 card');
new Notice(this.t('noCardsReturned'));
return;
}
// Persist raw cards (without computed startLine — re-resolve on load, in case source was renamed/edited)
@ -1133,21 +997,27 @@ class ParallelReaderPlugin extends Plugin {
await view.loadFor(file, sections, false);
}
const unanchored = sections.filter(s => s.startLine < 0).length;
new Notice(`对照笔记生成完成:${sections.length}${unanchored ? `(⚠ ${unanchored} 段 anchor 未匹配)` : ''}`);
new Notice(this.t('generationDone', {
count: sections.length,
suffix: unanchored ? this.t('unanchoredSuffix', { count: unanchored }) : '',
}));
}).catch(async e => {
if (e instanceof GenerationJobAlreadyRunningError) {
new Notice(e.message);
return;
}
if (e instanceof GenerationJobCancelledError) {
if (this.viewIsShowingFile(view, file)) await view.renderError(file, '生成已取消');
new Notice('已取消生成');
if (this.viewIsShowingFile(view, file)) await view.renderError(file, this.t('cancelledError'));
new Notice(this.t('cancelled'));
return;
}
const kind = classifyGenerationError(e);
console.error(e);
if (this.viewIsShowingFile(view, file)) await view.renderError(file, e.message || String(e));
new Notice(`生成失败${kind === 'unknown' ? '' : `${kind}`}` + (e.message || e));
new Notice(this.t('generationFailed', {
kind: kind === 'unknown' ? '' : ` (${kind})`,
error: e.message || e,
}));
});
}
@ -1275,7 +1145,7 @@ class ParallelReaderPlugin extends Plugin {
if (active) leaf = active.leaf;
}
if (!leaf) {
new Notice('找不到源笔记对应的编辑器窗口');
new Notice(this.t('noEditor'));
return;
}
@ -1308,12 +1178,29 @@ class ParallelReaderSettingTab extends PluginSettingTab {
display() {
const { containerEl } = this;
const tr = (key, vars?) => this.plugin.t(key, vars);
containerEl.empty();
containerEl.createEl('h2', { text: 'Parallel Reader 设置' });
containerEl.createEl('h2', { text: tr('settingsTitle') });
new Setting(containerEl)
.setName('Backend')
.setDesc('生成 bullet 的后端CLI 复用本机登录API 支持 OpenAI/Anthropic/Gemini 及兼容代理')
.setName(tr('settingUiLanguageName'))
.setDesc(tr('settingUiLanguageDesc'))
.addDropdown(d => {
for (const [id, label] of Object.entries(UI_LANGUAGES)) {
d.addOption(id, label);
}
return d
.setValue(this.plugin.settings.uiLanguage || DEFAULT_SETTINGS.uiLanguage)
.onChange(async v => {
this.plugin.settings.uiLanguage = v;
await this.plugin.saveSettings();
this.display();
});
});
new Setting(containerEl)
.setName(tr('settingBackendName'))
.setDesc(tr('settingBackendDesc'))
.addDropdown(d => d
.addOption('claude-code', 'Claude Code CLI')
.addOption('codex', 'Codex CLI')
@ -1333,10 +1220,10 @@ class ParallelReaderSettingTab extends PluginSettingTab {
if (!apiBackend) {
new Setting(containerEl)
.setName('CLI 路径(可选)')
.setDesc('留空则自动探测常见位置Obsidian GUI 启动时不继承 shell PATH必要时填绝对路径')
.setName(tr('settingCliPathName'))
.setDesc(tr('settingCliPathDesc'))
.addText(t => t
.setPlaceholder('例:/Users/you/bin/codex')
.setPlaceholder(tr('settingCliPathPlaceholder'))
.setValue(this.plugin.settings.cliPath)
.onChange(async v => {
this.plugin.settings.cliPath = v.trim();
@ -1344,7 +1231,7 @@ class ParallelReaderSettingTab extends PluginSettingTab {
}));
new Setting(containerEl)
.setName('CLI 超时 (ms)')
.setName(tr('settingCliTimeoutName'))
.addText(t => t
.setValue(String(this.plugin.settings.cliTimeoutMs))
.onChange(async v => {
@ -1355,12 +1242,12 @@ class ParallelReaderSettingTab extends PluginSettingTab {
}
}));
} else {
containerEl.createEl('h3', { text: 'API Provider' });
containerEl.createEl('h3', { text: tr('apiProviderHeader') });
const preset = getApiPreset(this.plugin.settings);
new Setting(containerEl)
.setName('Provider preset')
.setDesc('参考 OpenClaw 的 provider/model 思路preset 只负责协议、base URL 和认证默认值')
.setName(tr('settingProviderPresetName'))
.setDesc(tr('settingProviderPresetDesc'))
.addDropdown(d => {
for (const [id, entry] of Object.entries(API_PROVIDER_PRESETS)) {
d.addOption(id, (entry as any).label);
@ -1375,8 +1262,8 @@ class ParallelReaderSettingTab extends PluginSettingTab {
});
new Setting(containerEl)
.setName('API format')
.setDesc('不同 provider 的 wire protocolOpenAI-compatible 代理通常选 Chat Completions')
.setName(tr('settingApiFormatName'))
.setDesc(tr('settingApiFormatDesc'))
.addDropdown(d => {
for (const [id, entry] of Object.entries(API_FORMATS)) {
d.addOption(id, (entry as any).label);
@ -1391,8 +1278,8 @@ class ParallelReaderSettingTab extends PluginSettingTab {
});
new Setting(containerEl)
.setName('Base URL')
.setDesc('填 provider 根地址,不要附加 /chat/completions留空时使用 preset 默认值')
.setName(tr('settingBaseUrlName'))
.setDesc(tr('settingBaseUrlDesc'))
.addText(t => t
.setPlaceholder(
(this.plugin.settings.apiProvider || '').startsWith('custom-')
@ -1406,8 +1293,8 @@ class ParallelReaderSettingTab extends PluginSettingTab {
}));
new Setting(containerEl)
.setName('API Key')
.setDesc('本地 Ollama/LM Studio 可留空;其他 provider 通常需要 key')
.setName(tr('settingApiKeyName'))
.setDesc(tr('settingApiKeyDesc'))
.addText(t => {
t.inputEl.type = 'password';
return t
@ -1420,8 +1307,8 @@ class ParallelReaderSettingTab extends PluginSettingTab {
});
new Setting(containerEl)
.setName('API Key 环境变量')
.setDesc('可选Obsidian GUI 不一定继承 shell 环境,直接填 API Key 更稳定')
.setName(tr('settingApiKeyEnvName'))
.setDesc(tr('settingApiKeyEnvDesc'))
.addText(t => t
.setPlaceholder(preset.envVar || 'OPENAI_API_KEY')
.setValue(this.plugin.settings.apiKeyEnvVar)
@ -1431,8 +1318,8 @@ class ParallelReaderSettingTab extends PluginSettingTab {
}));
new Setting(containerEl)
.setName('认证方式')
.setDesc('Auto 使用 provider preset自定义代理可按需要改成 Bearer、x-api-key 或 none')
.setName(tr('settingAuthTypeName'))
.setDesc(tr('settingAuthTypeDesc'))
.addDropdown(d => {
for (const [id, label] of Object.entries(API_AUTH_TYPES)) {
d.addOption(id, label);
@ -1446,8 +1333,8 @@ class ParallelReaderSettingTab extends PluginSettingTab {
});
new Setting(containerEl)
.setName('额外 headers')
.setDesc('可选。支持 JSON 对象或每行 `Header: value`,用于 Cloudflare AI Gateway 等代理')
.setName(tr('settingHeadersName'))
.setDesc(tr('settingHeadersDesc'))
.addTextArea(t => t
.setPlaceholder('cf-aig-authorization: Bearer ...')
.setValue(this.plugin.settings.apiHeaders)
@ -1457,7 +1344,7 @@ class ParallelReaderSettingTab extends PluginSettingTab {
}));
new Setting(containerEl)
.setName('最大输出 tokens')
.setName(tr('settingMaxTokensName'))
.addText(t => t
.setValue(String(this.plugin.settings.apiMaxTokens))
.onChange(async v => {
@ -1470,10 +1357,10 @@ class ParallelReaderSettingTab extends PluginSettingTab {
}
new Setting(containerEl)
.setName('Model')
.setName(tr('settingModelName'))
.setDesc(apiBackend
? 'API 调用的模型 ID支持 OpenClaw 风格 provider/model若 provider 前缀匹配当前 preset 会自动剥离'
: 'Claude Code 下会传 --modelCodex 下通常忽略(用 Codex 默认配置)')
? tr('settingModelDescApi')
: tr('settingModelDescCli'))
.addText(t => t
.setPlaceholder(apiBackend ? (getApiPreset(this.plugin.settings).model || 'model-id') : DEFAULT_SETTINGS.model)
.setValue(this.plugin.settings.model)
@ -1483,8 +1370,8 @@ class ParallelReaderSettingTab extends PluginSettingTab {
}));
new Setting(containerEl)
.setName('最大输入字符数')
.setDesc('超过该长度会截断后再发送给模型;长上下文模型可适当调大')
.setName(tr('settingMaxInputName'))
.setDesc(tr('settingMaxInputDesc'))
.addText(t => t
.setValue(String(this.plugin.settings.maxDocChars || DEFAULT_SETTINGS.maxDocChars))
.onChange(async v => {
@ -1495,11 +1382,11 @@ class ParallelReaderSettingTab extends PluginSettingTab {
}
}));
containerEl.createEl('h3', { text: 'Prompt' });
containerEl.createEl('h3', { text: tr('promptHeader') });
new Setting(containerEl)
.setName('输出语言')
.setDesc('控制 title/gist/bullets 的语言anchor 始终逐字复制原文')
.setName(tr('settingPromptLanguageName'))
.setDesc(tr('settingPromptLanguageDesc'))
.addDropdown(d => {
for (const [id, label] of Object.entries(PROMPT_LANGUAGES)) {
d.addOption(id, label);
@ -1513,8 +1400,8 @@ class ParallelReaderSettingTab extends PluginSettingTab {
});
new Setting(containerEl)
.setName('卡片数量范围')
.setDesc('模型会在这个范围内自适应切段')
.setName(tr('settingCardRangeName'))
.setDesc(tr('settingCardRangeDesc'))
.addText(t => t
.setPlaceholder('min')
.setValue(String(this.plugin.settings.minCards || DEFAULT_SETTINGS.minCards))
@ -1538,12 +1425,12 @@ class ParallelReaderSettingTab extends PluginSettingTab {
}));
new Setting(containerEl)
.setName('自定义 system prompt')
.setDesc('可选。支持变量:{minCards}、{maxCards}、{languageInstruction}、{schema}、{example}')
.setName(tr('settingCustomPromptName'))
.setDesc(tr('settingCustomPromptDesc'))
.addTextArea(t => {
t.inputEl.rows = 8;
return t
.setPlaceholder('留空使用内置 prompt')
.setPlaceholder(tr('settingCustomPromptPlaceholder'))
.setValue(this.plugin.settings.customSystemPrompt || '')
.onChange(async v => {
this.plugin.settings.customSystemPrompt = v;
@ -1552,8 +1439,8 @@ class ParallelReaderSettingTab extends PluginSettingTab {
});
new Setting(containerEl)
.setName('测试当前后端')
.setDesc(apiBackend ? '会发起一次最小 LLM 请求验证 API 设置' : '调用 `<cli> --version` 验证 spawn 能找到二进制')
.setName(tr('settingTestBackendName'))
.setDesc(apiBackend ? tr('settingTestBackendDescApi') : tr('settingTestBackendDescCli'))
.addButton(b => b
.setButtonText('Test')
.onClick(async () => {
@ -1561,13 +1448,13 @@ class ParallelReaderSettingTab extends PluginSettingTab {
const result = await testBackend(this.plugin.settings);
new Notice(`${result.slice(0, 180)}`, 8000);
} catch (e) {
new Notice(`✗ 后端测试失败:${e.message}`, 10000);
new Notice(tr('backendTestFailed', { error: e.message }), 10000);
}
}));
new Setting(containerEl)
.setName('导出文件夹')
.setDesc('对照笔记生成位置(相对 Vault 根)')
.setName(tr('settingExportFolderName'))
.setDesc(tr('settingExportFolderDesc'))
.addText(t => t
.setValue(this.plugin.settings.exportFolder)
.onChange(async v => {
@ -1575,11 +1462,11 @@ class ParallelReaderSettingTab extends PluginSettingTab {
this.plugin.saveSettingsDebounced();
}));
containerEl.createEl('h3', { text: '缓存' });
containerEl.createEl('h3', { text: tr('cacheHeader') });
new Setting(containerEl)
.setName('最大缓存篇数')
.setDesc('超过上限后按最近访问时间淘汰最旧的笔记缓存;缓存保存在插件目录的 cache.json')
.setName(tr('settingMaxCacheName'))
.setDesc(tr('settingMaxCacheDesc'))
.addText(t => {
t.setValue(String(this.plugin.settings.maxCacheEntries || DEFAULT_MAX_CACHE_ENTRIES));
const commit = async () => {
@ -1588,7 +1475,7 @@ class ParallelReaderSettingTab extends PluginSettingTab {
this.plugin.settings.maxCacheEntries = n;
await this.plugin.saveSettings();
const removed = await this.plugin.pruneCacheIfNeeded();
if (removed.length > 0) new Notice(`已淘汰 ${removed.length} 条旧缓存`);
if (removed.length > 0) new Notice(tr('cachePruned', { count: removed.length }));
this.display();
}
};
@ -1601,15 +1488,15 @@ class ParallelReaderSettingTab extends PluginSettingTab {
const cacheCount = Object.keys(this.plugin.cache).length;
new Setting(containerEl)
.setName(`已缓存笔记:${cacheCount}`)
.setDesc('缓存以源笔记 SHA1 + 生成配置指纹作为失效键,源笔记或模型配置修改后会显示 stale 提示')
.setName(tr('cachedNotesName', { count: cacheCount }))
.setDesc(tr('cachedNotesDesc'))
.addButton(b => b
.setButtonText('清除所有缓存')
.setButtonText(tr('clearAllCacheButton'))
.setWarning()
.onClick(async () => {
const n = Object.keys(this.plugin.cache).length;
await this.plugin.cacheClear();
new Notice(`已清除 ${n} 条缓存`);
new Notice(tr('cacheClearedAll', { count: n }));
this.display();
}));
}
@ -1635,5 +1522,6 @@ export const __test = {
normalizeCardsPayload,
pruneCacheEntries,
summarizeViaApi,
translate,
tokenLimitFieldForOpenAiChat,
};

57
src/anchor.ts Normal file
View file

@ -0,0 +1,57 @@
'use strict';
export function findLineForAnchor(content, anchor) {
if (!anchor) return -1;
const normalize = s => s.replace(/\s+/g, ' ').trim();
const normalizeWithMap = s => {
const chars = [];
const map = [];
let pendingWhitespace = false;
for (let i = 0; i < s.length; i++) {
const c = s[i];
if (/\s/.test(c)) {
pendingWhitespace = chars.length > 0;
continue;
}
if (pendingWhitespace) {
chars.push(' ');
map.push(i);
pendingWhitespace = false;
}
chars.push(c);
map.push(i);
}
return { text: chars.join(''), map };
};
const tryAt = needle => {
if (!needle) return -1;
const idx = content.indexOf(needle);
if (idx === -1) return -1;
let line = 0;
for (let i = 0; i < idx; i++) if (content[i] === '\n') line++;
return line;
};
let line = tryAt(anchor);
if (line >= 0) return line;
line = tryAt(anchor.trim());
if (line >= 0) return line;
for (const len of [60, 40, 25, 15]) {
const prefix = anchor.trim().slice(0, len);
line = tryAt(prefix);
if (line >= 0) return line;
}
const normDoc = normalizeWithMap(content);
const normAnchor = normalize(anchor).slice(0, 30);
if (!normAnchor) return -1;
const normIdx = normDoc.text.indexOf(normAnchor);
if (normIdx === -1) return -1;
const originalIdx = normDoc.map[normIdx];
if (originalIdx == null) return -1;
let l = 0;
for (let j = 0; j < originalIdx; j++) if (content[j] === '\n') l++;
return l;
}

244
src/i18n.ts Normal file
View file

@ -0,0 +1,244 @@
'use strict';
export const STRINGS = {
zh: {
appTitle: '对照阅读笔记',
settingsTitle: 'Parallel Reader 设置',
emptyOpenNote: '打开一篇笔记,然后运行命令:',
emptyNoCache: '该笔记尚无对照笔记缓存。运行命令:',
commandGenerate: 'Parallel Reader: 为当前笔记生成对照笔记',
displayName: '对照阅读笔记',
ribbonOpen: '打开对照笔记面板',
cmdRun: '为当前笔记生成对照笔记(缓存优先)',
cmdRegen: '强制重新生成(绕过缓存)',
cmdOpenView: '打开对照笔记面板',
cmdExport: '导出当前对照笔记到 Vault',
cmdCopyMarkdown: '复制当前对照笔记 Markdown',
cmdCancel: '取消当前对照笔记生成',
cmdClearCurrent: '清除当前笔记的缓存',
cmdClearAll: '清除所有缓存',
actionCancel: '取消生成',
actionRegenerate: '重新生成',
actionCopyAll: '复制全部 Markdown',
actionExport: '导出到 Vault',
staleBanner: '源笔记或生成配置已修改,当前是旧缓存。',
loadingDefault: '正在生成对照笔记...',
loadingGenerating: '对照阅读:让 LLM 读全文并自适应切段...',
loadingSubtitle: '可以继续阅读原文,生成完成后会自动刷新右侧卡片。',
errorTitle: '生成失败',
emptyCard: '(未生成)',
anchorMismatch: 'anchor 匹配失败,无法滚动联动',
menuCopyMarkdown: '复制 Markdown',
menuCopyPlain: '复制纯文本',
menuCopyAnchor: '复制 anchor 引用',
menuJumpSource: '跳转到原文',
copiedMarkdown: '已复制 Markdown',
copiedPlain: '已复制纯文本',
copiedAnchor: '已复制引用原文',
copiedAllMarkdown: '已复制全部 Markdown',
copyFailed: '复制失败:{error}',
actionFailed: '{label}失败:{error}',
exported: '已导出 → {path}',
noCurrentNote: '没有当前笔记',
cacheClearedFile: '已清除缓存:{name}',
cacheClearedAll: '已清除 {count} 条缓存',
noCancelableJob: '当前没有可取消的生成任务',
cancelRequested: '已请求取消生成',
fileMenuGenerate: '生成对照笔记',
fileMenuRegen: '强制重新生成对照笔记',
fileMenuClear: '清除对照笔记缓存',
noExportContent: '当前没有可导出的对照笔记',
noCopyContent: '当前没有可复制的对照笔记',
openNoteFirst: '先打开一篇笔记',
alreadyGenerating: '该笔记正在生成对照笔记',
emptyNote: '笔记为空',
longNoteTruncated: '笔记较长:仅发送前 {count} 个字符给模型',
generatingNotice: '对照阅读:让 LLM 读全文并自适应切段…',
noCardsReturned: 'LLM 未返回任何 card',
generationDone: '对照笔记生成完成:{count} 段{suffix}',
unanchoredSuffix: '(⚠ {count} 段 anchor 未匹配)',
cancelled: '已取消生成',
cancelledError: '生成已取消',
generationFailed: '生成失败{kind}{error}',
noEditor: '找不到源笔记对应的编辑器窗口',
settingUiLanguageName: '界面语言',
settingUiLanguageDesc: '控制插件界面、命令和提示文案Auto 跟随 Obsidian/系统语言',
settingBackendName: 'Backend',
settingBackendDesc: '生成 bullet 的后端CLI 复用本机登录API 支持 OpenAI/Anthropic/Gemini 及兼容代理',
settingCliPathName: 'CLI 路径(可选)',
settingCliPathDesc: '留空则自动探测常见位置Obsidian GUI 启动时不继承 shell PATH必要时填绝对路径',
settingCliPathPlaceholder: '例:/Users/you/bin/codex',
settingCliTimeoutName: 'CLI 超时 (ms)',
apiProviderHeader: 'API Provider',
settingProviderPresetName: 'Provider preset',
settingProviderPresetDesc: '参考 OpenClaw 的 provider/model 思路preset 只负责协议、base URL 和认证默认值',
settingApiFormatName: 'API format',
settingApiFormatDesc: '不同 provider 的 wire protocolOpenAI-compatible 代理通常选 Chat Completions',
settingBaseUrlName: 'Base URL',
settingBaseUrlDesc: '填 provider 根地址,不要附加 /chat/completions留空时使用 preset 默认值',
settingApiKeyName: 'API Key',
settingApiKeyDesc: '本地 Ollama/LM Studio 可留空;其他 provider 通常需要 key',
settingApiKeyEnvName: 'API Key 环境变量',
settingApiKeyEnvDesc: '可选Obsidian GUI 不一定继承 shell 环境,直接填 API Key 更稳定',
settingAuthTypeName: '认证方式',
settingAuthTypeDesc: 'Auto 使用 provider preset自定义代理可按需要改成 Bearer、x-api-key 或 none',
settingHeadersName: '额外 headers',
settingHeadersDesc: '可选。支持 JSON 对象或每行 `Header: value`,用于 Cloudflare AI Gateway 等代理',
settingMaxTokensName: '最大输出 tokens',
settingModelName: 'Model',
settingModelDescApi: 'API 调用的模型 ID支持 OpenClaw 风格 provider/model若 provider 前缀匹配当前 preset 会自动剥离',
settingModelDescCli: 'Claude Code 下会传 --modelCodex 下通常忽略(用 Codex 默认配置)',
settingMaxInputName: '最大输入字符数',
settingMaxInputDesc: '超过该长度会截断后再发送给模型;长上下文模型可适当调大',
promptHeader: 'Prompt',
settingPromptLanguageName: '输出语言',
settingPromptLanguageDesc: '控制 title/gist/bullets 的语言anchor 始终逐字复制原文',
settingCardRangeName: '卡片数量范围',
settingCardRangeDesc: '模型会在这个范围内自适应切段',
settingCustomPromptName: '自定义 system prompt',
settingCustomPromptDesc: '可选。支持变量:{minCards}、{maxCards}、{languageInstruction}、{schema}、{example}',
settingCustomPromptPlaceholder: '留空使用内置 prompt',
settingTestBackendName: '测试当前后端',
settingTestBackendDescApi: '会发起一次最小 LLM 请求验证 API 设置',
settingTestBackendDescCli: '调用 `<cli> --version` 验证 spawn 能找到二进制',
backendTestFailed: '✗ 后端测试失败:{error}',
settingExportFolderName: '导出文件夹',
settingExportFolderDesc: '对照笔记生成位置(相对 Vault 根)',
cacheHeader: '缓存',
settingMaxCacheName: '最大缓存篇数',
settingMaxCacheDesc: '超过上限后按最近访问时间淘汰最旧的笔记缓存;缓存保存在插件目录的 cache.json',
cachePruned: '已淘汰 {count} 条旧缓存',
cachedNotesName: '已缓存笔记:{count} 篇',
cachedNotesDesc: '缓存以源笔记 SHA1 + 生成配置指纹作为失效键,源笔记或模型配置修改后会显示 stale 提示',
clearAllCacheButton: '清除所有缓存',
},
en: {
appTitle: 'Parallel Reader',
settingsTitle: 'Parallel Reader Settings',
emptyOpenNote: 'Open a note, then run:',
emptyNoCache: 'This note has no cached parallel notes. Run:',
commandGenerate: 'Parallel Reader: Generate notes for current note',
displayName: 'Parallel Reader',
ribbonOpen: 'Open Parallel Reader pane',
cmdRun: 'Generate parallel notes for current note (cache first)',
cmdRegen: 'Regenerate parallel notes (bypass cache)',
cmdOpenView: 'Open Parallel Reader pane',
cmdExport: 'Export current parallel notes to Vault',
cmdCopyMarkdown: 'Copy current parallel notes as Markdown',
cmdCancel: 'Cancel current parallel-note generation',
cmdClearCurrent: 'Clear cache for current note',
cmdClearAll: 'Clear all caches',
actionCancel: 'Cancel generation',
actionRegenerate: 'Regenerate',
actionCopyAll: 'Copy all Markdown',
actionExport: 'Export to Vault',
staleBanner: 'The source note or generation settings changed. This is stale cache.',
loadingDefault: 'Generating parallel notes...',
loadingGenerating: 'Parallel Reader: asking the LLM to read and segment the full note...',
loadingSubtitle: 'You can keep reading. Cards will refresh when generation finishes.',
errorTitle: 'Generation failed',
emptyCard: '(not generated)',
anchorMismatch: 'Anchor did not match; scroll sync is unavailable',
menuCopyMarkdown: 'Copy Markdown',
menuCopyPlain: 'Copy plain text',
menuCopyAnchor: 'Copy anchor quote',
menuJumpSource: 'Jump to source',
copiedMarkdown: 'Copied Markdown',
copiedPlain: 'Copied plain text',
copiedAnchor: 'Copied anchor quote',
copiedAllMarkdown: 'Copied all Markdown',
copyFailed: 'Copy failed: {error}',
actionFailed: '{label} failed: {error}',
exported: 'Exported → {path}',
noCurrentNote: 'No current note',
cacheClearedFile: 'Cleared cache: {name}',
cacheClearedAll: 'Cleared {count} cache entries',
noCancelableJob: 'No cancellable generation job',
cancelRequested: 'Cancel requested',
fileMenuGenerate: 'Generate parallel notes',
fileMenuRegen: 'Regenerate parallel notes',
fileMenuClear: 'Clear parallel-note cache',
noExportContent: 'No parallel notes to export',
noCopyContent: 'No parallel notes to copy',
openNoteFirst: 'Open a note first',
alreadyGenerating: 'This note is already being generated',
emptyNote: 'The note is empty',
longNoteTruncated: 'Long note: only the first {count} characters will be sent to the model',
generatingNotice: 'Parallel Reader: asking the LLM to read and segment the full note...',
noCardsReturned: 'LLM returned no cards',
generationDone: 'Generated {count} sections{suffix}',
unanchoredSuffix: ' (⚠ {count} anchors unmatched)',
cancelled: 'Generation cancelled',
cancelledError: 'Generation cancelled',
generationFailed: 'Generation failed{kind}: {error}',
noEditor: 'Could not find the source note editor',
settingUiLanguageName: 'UI language',
settingUiLanguageDesc: 'Controls plugin UI, commands, and notices. Auto follows Obsidian/system language.',
settingBackendName: 'Backend',
settingBackendDesc: 'Backend for generating bullets: CLI reuses local login; API supports OpenAI, Anthropic, Gemini, and compatible proxies.',
settingCliPathName: 'CLI path (optional)',
settingCliPathDesc: 'Leave blank to auto-detect common paths. Obsidian launched from the GUI may not inherit shell PATH.',
settingCliPathPlaceholder: 'Example: /Users/you/bin/codex',
settingCliTimeoutName: 'CLI timeout (ms)',
apiProviderHeader: 'API Provider',
settingProviderPresetName: 'Provider preset',
settingProviderPresetDesc: 'OpenClaw-style provider/model setup: presets define protocol, base URL, and auth defaults.',
settingApiFormatName: 'API format',
settingApiFormatDesc: 'Wire protocol for the provider. OpenAI-compatible proxies usually use Chat Completions.',
settingBaseUrlName: 'Base URL',
settingBaseUrlDesc: 'Provider root URL, without /chat/completions. Blank uses the preset default.',
settingApiKeyName: 'API Key',
settingApiKeyDesc: 'Can be blank for local Ollama/LM Studio; most hosted providers require a key.',
settingApiKeyEnvName: 'API key env var',
settingApiKeyEnvDesc: 'Optional. Obsidian GUI may not inherit shell env; direct API Key is usually more reliable.',
settingAuthTypeName: 'Auth type',
settingAuthTypeDesc: 'Auto uses provider preset. Custom proxies can use Bearer, x-api-key, or none.',
settingHeadersName: 'Extra headers',
settingHeadersDesc: 'Optional. JSON object or one `Header: value` per line, useful for Cloudflare AI Gateway.',
settingMaxTokensName: 'Max output tokens',
settingModelName: 'Model',
settingModelDescApi: 'Model ID for API calls. Supports OpenClaw-style provider/model; matching provider prefixes are stripped.',
settingModelDescCli: 'Passed as --model for Claude Code. Usually ignored by Codex, which uses its default config.',
settingMaxInputName: 'Max input characters',
settingMaxInputDesc: 'Longer notes are truncated before sending to the model. Raise this for long-context models.',
promptHeader: 'Prompt',
settingPromptLanguageName: 'Output language',
settingPromptLanguageDesc: 'Controls title/gist/bullets language. Anchor is always copied verbatim from source.',
settingCardRangeName: 'Card count range',
settingCardRangeDesc: 'The model adapts segmentation within this range.',
settingCustomPromptName: 'Custom system prompt',
settingCustomPromptDesc: 'Optional. Variables: {minCards}, {maxCards}, {languageInstruction}, {schema}, {example}',
settingCustomPromptPlaceholder: 'Leave blank to use built-in prompt',
settingTestBackendName: 'Test current backend',
settingTestBackendDescApi: 'Sends a minimal LLM request to validate API settings.',
settingTestBackendDescCli: 'Runs `<cli> --version` to verify the binary can be spawned.',
backendTestFailed: '✗ Backend test failed: {error}',
settingExportFolderName: 'Export folder',
settingExportFolderDesc: 'Parallel-note output location, relative to the Vault root.',
cacheHeader: 'Cache',
settingMaxCacheName: 'Max cached notes',
settingMaxCacheDesc: 'Prunes least-recently accessed note caches above this limit. Cache is stored in plugin cache.json.',
cachePruned: 'Pruned {count} old cache entries',
cachedNotesName: 'Cached notes: {count}',
cachedNotesDesc: 'Cache is invalidated by source SHA1 and generation settings fingerprint.',
clearAllCacheButton: 'Clear all caches',
},
};
export function resolveUiLanguage(settings) {
const configured = settings && settings.uiLanguage;
if (configured === 'zh' || configured === 'en') return configured;
const nav = typeof navigator !== 'undefined' ? navigator : null;
const language = String(nav?.language || '').toLowerCase();
return language.startsWith('zh') ? 'zh' : 'en';
}
export function translate(settings, key, vars?) {
const lang = resolveUiLanguage(settings);
const table = STRINGS[lang] || STRINGS.en;
const fallback = STRINGS.en[key] || STRINGS.zh[key] || key;
const template = table[key] || fallback;
return String(template).replace(/\{([a-zA-Z0-9_]+)\}/g, (match, name) => {
return vars && Object.prototype.hasOwnProperty.call(vars, name) ? String(vars[name]) : match;
});
}

89
src/prompt.ts Normal file
View file

@ -0,0 +1,89 @@
'use strict';
import {
DEFAULT_SETTINGS,
MAX_DOC_CHARS,
PROMPT_LANGUAGES,
} from './settings';
export function promptLanguageInstruction(language) {
if (language === 'en') return 'Write title, gist, and bullets in English.';
if (language === 'auto') return 'Write title, gist, and bullets in the main language of the source document.';
return '用中文输出 title、gist 和 bullets。';
}
export function promptSchemaExample(language) {
if (language === 'en') {
return `{"cards":[
{"title":"U-shaped gains","anchor":"Who benefits from AI? Overall, it shifts the score from one to seven","gist":"AI productivity gains form a U shape, with both ends benefiting most","bullets":["Top-paid software managers benefit by accelerating existing work","Low-paid workers use AI to create new side income","Middle-layer specialists gain less because prompt precision is hard to trust","Average reported benefit is 5.1/7, with 42% describing gains as unclear"]}
]}`;
}
return `{"cards":[
{"title":"U 型收益曲线","anchor":"那谁又会被 AI 所受益?整体来看,它把整个分数变成了一分到七分","gist":"AI 生产力收益呈 U 型,两端受益最大、中间层塌陷","bullets":["最高薪岗位(软件管理)通过加速既有工作受益最大","最低薪岗位(外卖员、园艺工)用 AI 开副业创造新收入","中间层科学家、律师收益最少,部分因对 prompt 精度信任不足","全体均分 5.1/742% 报告收益模糊"]}
]}`;
}
export function renderPromptTemplate(template, vars) {
return String(template || '').replace(/\{([a-zA-Z0-9_]+)\}/g, (match, key) => {
return Object.prototype.hasOwnProperty.call(vars, key) ? String(vars[key]) : match;
});
}
export function buildPrompts(content, settings) {
const maxDocChars = Number(settings.maxDocChars) || MAX_DOC_CHARS;
const promptLanguage = PROMPT_LANGUAGES[settings.promptLanguage] ? settings.promptLanguage : DEFAULT_SETTINGS.promptLanguage;
const minCards = Math.max(1, Number(settings.minCards) || DEFAULT_SETTINGS.minCards);
const maxCards = Math.max(minCards, Number(settings.maxCards) || DEFAULT_SETTINGS.maxCards);
const languageInstruction = promptLanguageInstruction(promptLanguage);
const doc = content.length > maxDocChars
? content.slice(0, maxDocChars) + (promptLanguage === 'en' ? '\n\n[Document truncated]' : '\n\n[文档过长,已截断]')
: content;
const schema = '{"cards":[{"title":"...","anchor":"...","gist":"...","bullets":["...","..."]}]}';
const example = promptSchemaExample(promptLanguage);
const templateVars = { minCards, maxCards, languageInstruction, schema, example };
const customSystem = renderPromptTemplate(settings.customSystemPrompt, templateVars).trim();
const defaultSystem = `你是一个长文阅读摘要助手。阅读全文后,把文章切成 ${minCards}-${maxCards} 个"自然主题单元"——不必对应 markdown heading以"一个完整论点或话题"为单位自行判断粒度:短章节合并、长章节拆分。
** + bulletbullet gist **
- ${languageInstruction}
- title: 3-10 "背景""介绍"
- anchor: 该单元开头的**** 1:1 40-80 //
- gist: ****20-40 bullets
- bullets: **3-6 ** bullet 20-50 gist bullets
- anchor exact substring match
- anchor "综上所述"
- gist bullets gist bullets
- bullet "首先""其次"
- JSON markdown fence tool call
${schema}
${example}`;
const system = customSystem
? `${customSystem}
- ${minCards}-${maxCards} cards
- ${languageInstruction}
- anchor exact substring match
- JSON markdown fence tool call
- JSON shape: ${schema}`
: defaultSystem;
const user = promptLanguage === 'en'
? `Source document:\n\n${doc}`
: `以下是需要处理的文档全文:\n\n${doc}`;
return { system, user };
}

View file

@ -11,8 +11,14 @@ export const PROMPT_LANGUAGES = {
en: 'English',
auto: 'Auto-detect',
};
export const UI_LANGUAGES = {
auto: 'Auto',
zh: '中文',
en: 'English',
};
export const DEFAULT_SETTINGS = {
uiLanguage: 'auto',
backend: 'claude-code',
cliPath: '',
apiProvider: 'anthropic',
@ -312,6 +318,7 @@ export function applyApiProviderPreset(settings, providerId) {
}
export function normalizeSettings(settings) {
if (!UI_LANGUAGES[settings.uiLanguage]) settings.uiLanguage = DEFAULT_SETTINGS.uiLanguage;
if (!settings.apiProvider || !API_PROVIDER_PRESETS[settings.apiProvider]) {
settings.apiProvider = 'anthropic';
}

View file

@ -47,6 +47,7 @@ assert.strictEqual(typeof t.findLineForAnchor, 'function');
assert.strictEqual(typeof t.generationFingerprint, 'function');
assert.strictEqual(typeof t.GenerationJobManager, 'function');
assert.strictEqual(typeof t.pruneCacheEntries, 'function');
assert.strictEqual(typeof t.translate, 'function');
const baseSettings = {
backend: 'api',
@ -84,6 +85,11 @@ assert.notStrictEqual(
t.generationFingerprint({ ...baseSettings, customSystemPrompt: 'custom prompt' }),
'cache fingerprint should change when custom prompt changes'
);
assert.strictEqual(
t.generationFingerprint(baseSettings),
t.generationFingerprint({ ...baseSettings, uiLanguage: 'en' }),
'cache fingerprint should not change when UI language changes'
);
const contentHash = crypto.createHash('sha1').update('hello', 'utf8').digest('hex');
assert.strictEqual(
@ -152,6 +158,10 @@ assert.ok(customPrompt.system.includes('Make 1-2 cards.'));
assert.ok(customPrompt.system.includes('不可覆盖的输出契约'));
assert.ok(customPrompt.system.includes('JSON shape'));
assert.strictEqual(t.translate({ uiLanguage: 'en' }, 'cmdOpenView'), 'Open Parallel Reader pane');
assert.strictEqual(t.translate({ uiLanguage: 'zh' }, 'cmdOpenView'), '打开对照笔记面板');
assert.strictEqual(t.translate({ uiLanguage: 'en' }, 'cacheClearedAll', { count: 3 }), 'Cleared 3 cache entries');
const noisyJson = '说明文字 {"cards":[{"title":"A","anchor":"保留 { 花括号 } 字符","gist":"G","bullets":["B"]}]} trailing';
const extracted = t.extractJson(noisyJson);
assert.deepStrictEqual(JSON.parse(extracted).cards[0].bullets, ['B']);