Compare commits

..

5 commits
1.0.23 ... main

Author SHA1 Message Date
wujunchen
6519633459 chore: bump version to 1.0.24
Change-Id: I6ada16f42f4f76781e25783493fbf6da88a640c1
2026-06-16 10:08:56 +08:00
wujunchen
2278129f03 Merge branch 'feat/robustness-onboarding-ci'
Provider/lifecycle robustness, first-run onboarding nudge, CI coverage gate, and
codex-review fixes. All gates green (typecheck, lint, obsidian strict review, 28
test files, branch coverage 100%, e2e gate).

Change-Id: Id47ba4d2bb8b6c847eb7563459d416ddfa1935f0
2026-06-16 10:08:09 +08:00
wujunchen
7986f1c308 fix: address codex review — drop false-positive fallback keywords, tighten stream-error detection
Two valid findings from an opposite-model (Codex) review of this branch:

- provider-request: drop bare `unknown`/`unrecognized` from the structured-output
  rejection keywords. They false-positive on model-name errors ("unknown model",
  "unrecognized model ID" from Ollama/Bedrock), triggering a wasted fallback
  retry. The specific feature tokens + bare `schema` still cover real structured-
  output rejections, so true-positive detection is unaffected.
- streaming: streamErrorMessage's OpenAI-style `{error:{...}}` branch now only
  treats the payload as an error when it carries a message/type, so a stray empty
  `error: {}` (or code-only object) in a normal chunk no longer aborts the stream.
  The Anthropic `{type:'error'}` branch still always signals an error.

Declined: Codex's suggestion to convert streaming's HTTP>=400 to ProviderApiError
+ add fallback — the streaming path always sends `structured:false`, so there is
no structured request to fall back from.

Tests updated/added: model-name 400s do not retry; empty/code-only error objects
do not throw.

Change-Id: I413d633c8ce7036365f97e2d8e1287c111e0902f
2026-06-16 10:05:04 +08:00
wujunchen
5f36b1218b feat: provider/lifecycle robustness, onboarding nudge, CI coverage gate
Assessment-driven batch of 10 verified, low-risk improvements. All gates green
(typecheck, biome, obsidian lint + strict review, 28 test files, branch
coverage 100%, e2e gate).

Correctness:
- provider-request: decide structured-output fallback on HTTP status via a new
  ProviderApiError (status+body) instead of pattern-matching the i18n-translated
  message — previously only en/zh matched, so fr/de/es/ja/ko users hit silent
  permanent failures when a provider rejected json_schema.
- streaming: surface in-stream provider error payloads ({type:'error'} /
  {error:{}}) by throwing, instead of swallowing them and later misreporting a
  transient overload/quota error as "non-JSON LLM output". Note: detection runs
  outside the JSON.parse try/catch so the throw is not swallowed.
- main: throwIfCancelled before cacheManager.put so a cancelled job cannot
  poison the cache.
- generation-job-manager: add cancelAll(); onunload now cancels in-flight jobs
  (aborting streaming HTTP + SIGKILL-ing CLI children), not just queued waiters.

Onboarding / UX:
- settings: DEFAULT_SETTINGS.promptLanguage 'zh' -> 'auto' so new non-Chinese
  users get source-language summaries by default (existing users unaffected).
- view/main/types: first-run "Set up AI provider" CTA in the empty state when no
  credential is configured (PluginHost.openSettings + isCredentialConfigured).
- error-ui/generation-job-manager/types: new 'network' ErrorKind with an
  actionable notice + Retry for offline/connection failures.
- i18n-strings: 4 new keys across all 7 locales (parity test enforced).

CI / docs:
- .c8rc.json + package.json: branch-coverage gate (check-coverage, branches=100,
  degenerate metrics disabled) and preserve c8's exit code in the coverage script.
- ci.yml: run coverage gate and strict obsidian review in CI.
- README: fix Obsidian version badge 1.4.0 -> 1.8.7 (matches manifest).
- e2e product-shell DOM shim: add createSpan (was missing; real Obsidian has it).

Tests: cover the status-based fallback (incl. non-English locale), in-stream
error throwing, cancelAll, and network classification.

Change-Id: Ic619098aa7cdf3dc1c444be4bb8a445550eadf55
2026-06-16 09:37:59 +08:00
wujunchen
c45f7987e3 fix: avoid stringifying locale objects
Change-Id: Ic9557acc384d5a23b3cbaaae9fea2678e9afd661
2026-05-20 13:23:56 +08:00
24 changed files with 383 additions and 29 deletions

View file

@ -12,5 +12,10 @@
"excludeAfterRemap": true,
"reporter": ["text-summary", "text", "html", "json-summary"],
"reportsDirectory": "coverage",
"tempDirectory": "coverage/.tmp"
"tempDirectory": "coverage/.tmp",
"check-coverage": true,
"branches": 100,
"statements": 0,
"lines": 0,
"functions": 0
}

View file

@ -98,6 +98,10 @@ class FakeElement {
return this.createEl('div', options);
}
createSpan(options = {}) {
return this.createEl('span', options);
}
addEventListener(type, handler) {
if (typeof handler !== 'function') return;
if (!this._listeners.has(type)) this._listeners.set(type, []);

View file

@ -27,6 +27,12 @@ jobs:
- name: Lint (Obsidian review rules)
run: npm run lint:obsidian
- name: Lint (Obsidian strict review)
run: npm run lint:obsidian:review:all
- name: Coverage (branch threshold)
run: npm run coverage
- name: E2E gate
run: timeout 600 npm run e2e

View file

@ -9,7 +9,7 @@
<a href="https://github.com/fancive/obsidian-parallel-reader/releases"><img src="https://img.shields.io/github/downloads/fancive/obsidian-parallel-reader/total?style=flat-square&color=4c1" alt="Total downloads"></a>
<a href="https://github.com/fancive/obsidian-parallel-reader/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/fancive/obsidian-parallel-reader/ci.yml?style=flat-square&label=CI" alt="CI status"></a>
<a href="./LICENSE"><img src="https://img.shields.io/github/license/fancive/obsidian-parallel-reader?style=flat-square" alt="License"></a>
<img src="https://img.shields.io/badge/Obsidian-%E2%89%A5%201.4.0-7c3aed?style=flat-square&logo=obsidian&logoColor=white" alt="Obsidian 1.4+">
<img src="https://img.shields.io/badge/Obsidian-%E2%89%A5%201.8.7-7c3aed?style=flat-square&logo=obsidian&logoColor=white" alt="Obsidian 1.8.7+">
</p>
<p align="center">

View file

@ -9,7 +9,7 @@
<a href="https://github.com/fancive/obsidian-parallel-reader/releases"><img src="https://img.shields.io/github/downloads/fancive/obsidian-parallel-reader/total?style=flat-square&color=4c1" alt="累计下载"></a>
<a href="https://github.com/fancive/obsidian-parallel-reader/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/fancive/obsidian-parallel-reader/ci.yml?style=flat-square&label=CI" alt="CI 状态"></a>
<a href="./LICENSE"><img src="https://img.shields.io/github/license/fancive/obsidian-parallel-reader?style=flat-square" alt="开源协议"></a>
<img src="https://img.shields.io/badge/Obsidian-%E2%89%A5%201.4.0-7c3aed?style=flat-square&logo=obsidian&logoColor=white" alt="Obsidian 1.4+">
<img src="https://img.shields.io/badge/Obsidian-%E2%89%A5%201.8.7-7c3aed?style=flat-square&logo=obsidian&logoColor=white" alt="Obsidian 1.8.7+">
</p>
<p align="center">

40
main.ts
View file

@ -30,7 +30,14 @@ import { translate } from './src/i18n';
import { cardsToMarkdown } from './src/markdown';
import { confirmRegenerateEditedCards } from './src/modal';
import { createRafThrottledHandler, visibleTopProbeY } from './src/scroll';
import { cacheEntryMatches, DEFAULT_SETTINGS, normalizeSettings } from './src/settings';
import {
cacheEntryMatches,
DEFAULT_SETTINGS,
getApiAuthType,
getApiKey,
isApiBackend,
normalizeSettings,
} from './src/settings';
import { ParallelReaderSettingTab } from './src/settings-tab';
import type { StreamProgress } from './src/streaming';
import type {
@ -177,7 +184,7 @@ class ParallelReaderPlugin extends Plugin {
}
onunload() {
if (this.jobs) this.jobs.cancelAllWaiters();
if (this.jobs) this.jobs.cancelAll();
this.flushSettingsSave().catch((e: unknown) => console.error('[parallel-reader] flush settings on unload', e));
this.flushCacheSave().catch((e: unknown) => console.error('[parallel-reader] flush cache on unload', e));
}
@ -555,6 +562,9 @@ class ParallelReaderPlugin extends Plugin {
}
const rawCards = sections.map((s) => ({ title: s.title, anchor: s.anchor, gist: s.gist, bullets: s.bullets }));
job.setPhase('saving');
// Check BEFORE persisting: a job cancelled during the generate→disk window must not
// poison the cache (a later force=false run would otherwise serve the cancelled output).
job.throwIfCancelled();
await this.cacheManager.put(file.path, content, rawCards, this.settings);
job.throwIfCancelled();
if (view && shouldRender) view.loadFor(file, sections, false);
@ -568,7 +578,7 @@ class ParallelReaderPlugin extends Plugin {
outcome = 'generated';
})
.catch((e: unknown) => {
this.handleGenerationError(e, file, view);
this.handleGenerationError(e, file, view, force);
if (e instanceof GenerationJobAlreadyRunningError) outcome = 'already-running';
else if (e instanceof GenerationJobCancelledError) outcome = 'cancelled';
else outcome = 'error';
@ -590,7 +600,7 @@ class ParallelReaderPlugin extends Plugin {
};
}
private handleGenerationError(e: unknown, file: TFile, view: ParallelReaderView | null) {
private handleGenerationError(e: unknown, file: TFile, view: ParallelReaderView | null, force = false) {
if (e instanceof GenerationJobAlreadyRunningError) {
new Notice(this.t('generationAlreadyRunning'));
return;
@ -608,7 +618,8 @@ class ParallelReaderPlugin extends Plugin {
{
app: this.app,
settings: this.settings,
openSettings: () => this.openPluginSettings(),
openSettings: () => this.openSettings(),
onRetry: () => void this.runForFile(file, force),
},
kind,
e,
@ -616,7 +627,7 @@ class ParallelReaderPlugin extends Plugin {
);
}
private openPluginSettings(): void {
openSettings(): void {
// Obsidian doesn't expose a typed API for opening a specific plugin tab; use the documented
// (but technically internal) setting/openTabById path with safe fallbacks.
const setting = (this.app as unknown as { setting?: { open: () => void; openTabById: (id: string) => void } })
@ -630,6 +641,23 @@ class ParallelReaderPlugin extends Plugin {
}
}
/**
* True when the active backend has a usable credential: an API key (direct or via
* env var) for API backends, or any keyless/local provider (auth type 'none').
* CLI backends authenticate out-of-band (claude/codex login), so they are treated
* as configured. Used to show a first-run setup nudge instead of a dead-end.
*/
isCredentialConfigured(): boolean {
if (!isApiBackend(this.settings.backend)) return true;
try {
if (getApiAuthType(this.settings) === 'none') return true;
return !!getApiKey(this.settings);
} catch {
// Settings resolution can throw for half-configured custom providers — treat as not configured.
return false;
}
}
async runBatchForFolder() {
if (this.activeBatch) {
new Notice(this.t('batchAlreadyRunning'));

View file

@ -1,7 +1,7 @@
{
"id": "parallel-reader",
"name": "Parallel Reader",
"version": "1.0.23",
"version": "1.0.24",
"minAppVersion": "1.8.7",
"description": "AI-powered split-view reading: source note on the left, LLM-generated summary cards on the right with scroll-sync highlighting.",
"author": "lancivez",

View file

@ -7,7 +7,7 @@
"build": "node esbuild.config.mjs production",
"dev": "node esbuild.config.mjs watch",
"typecheck": "tsc --noEmit",
"coverage": "c8 node scripts/run-tests.mjs --all; rm -rf .test-bundles",
"coverage": "c8 node scripts/run-tests.mjs --all; status=$?; rm -rf .test-bundles; exit $status",
"lint": "biome check main.ts src/ tests/ scripts/ .e2e/scripts/",
"lint:fix": "biome check --write main.ts src/ tests/ scripts/ .e2e/scripts/",
"lint:obsidian": "eslint main.ts src/",

View file

@ -11,6 +11,8 @@ interface GenerationErrorContext {
settings: PluginSettings;
/** Open the plugin settings tab (best-effort; falls back to no-op if unavailable). */
openSettings: () => void;
/** Re-run the generation that failed (used by retryable error kinds such as network). */
onRetry?: () => void;
}
interface ActionableNoticeAction {
@ -205,6 +207,17 @@ export function showGenerationError(
return;
}
if (kind === 'network') {
const actions: ActionableNoticeAction[] = [];
if (ctx.onRetry) actions.push({ label: tr('errorActionRetry'), primary: true, onClick: ctx.onRetry });
actions.push({
label: tr('errorActionCopyDetails'),
onClick: () => void copyToClipboard(message, tr('errorModalCopySuccess')),
});
showActionableNotice(tr('errorNoticeNetwork'), actions);
return;
}
if (kind === 'schema') {
showActionableNotice(tr('errorNoticeSchema'), [
{

View file

@ -189,6 +189,20 @@ export class GenerationJobManager {
if (!job) return false;
return job.cancel();
}
/**
* Cancel every in-flight job firing each job's cancel handlers, which abort the
* streaming HTTP request and SIGKILL any CLI child process and reject all queued
* waiters. Used on plugin unload so nothing keeps running after teardown. Returns
* the total number of jobs and waiters cancelled.
*/
cancelAll(): number {
let cancelled = 0;
for (const job of this.jobs.values()) {
if (job.cancel()) cancelled++;
}
return cancelled + this.cancelAllWaiters();
}
}
export function classifyGenerationError(error: unknown): ErrorKind {
@ -216,6 +230,12 @@ export function classifyGenerationError(error: unknown): ErrorKind {
if (/api key|unauthorized|401|403|认证|权限/i.test(message)) return 'auth';
if (/timeout|超时|timed out/i.test(message)) return 'timeout';
if (/429|rate limit|too many requests/i.test(message)) return 'rate-limit';
if (
/ECONNREFUSED|ENOTFOUND|ENETUNREACH|EAI_AGAIN|ECONNRESET|EHOSTUNREACH|Failed to fetch|NetworkError|net::ERR_|fetch failed/i.test(
message,
)
)
return 'network';
if (
/非 JSON|非预期输出|没有返回结果|non-JSON|unexpected output|no result|json_schema|schema|structured/i.test(message)
)

View file

@ -6,6 +6,7 @@ export const STRINGS: Record<string, Record<string, string>> = {
settingsTitle: 'Parallel Reader 设置',
emptyOpenNote: '打开一篇笔记,然后运行命令:',
emptyNoCache: '该笔记尚无对照笔记缓存。运行命令:',
emptyNeedsSetup: '尚未配置 AI 服务商。填写 API Key 后即可开始生成。',
commandGenerate: 'Parallel Reader: 为当前笔记生成对照笔记',
displayName: '对照阅读笔记',
ribbonOpen: '打开对照笔记面板',
@ -60,6 +61,7 @@ export const STRINGS: Record<string, Record<string, string>> = {
cancelRequested: '已请求取消生成',
cancelRequestedApiInFlight: '已请求取消生成;当前 API 请求无法立即中断,返回后会丢弃结果。',
actionGenerate: '生成对照笔记',
actionSetupProvider: '配置 AI 服务商',
fileMenuGenerate: '生成对照笔记',
fileMenuRegen: '强制重新生成对照笔记',
fileMenuClear: '清除对照笔记缓存',
@ -89,7 +91,9 @@ export const STRINGS: Record<string, Record<string, string>> = {
errorNoticeRateLimit: '触发限流:{error}\n请稍候片刻再重试。',
errorNoticeSchema: '模型返回的内容不符合 JSON 格式。可复制原始输出排查或重试。',
errorNoticeConfig: '配置错误:{error}',
errorNoticeNetwork: '网络错误——请检查网络连接后重试。',
errorActionOpenSettings: '打开设置',
errorActionRetry: '重试',
errorActionCopyDetails: '复制详情',
errorActionCopyRaw: '复制原始输出',
errorModalTimeoutTitle: 'CLI 超时诊断',
@ -219,6 +223,7 @@ export const STRINGS: Record<string, Record<string, string>> = {
settingsTitle: 'Parallel Reader Settings',
emptyOpenNote: 'Open a note, then run:',
emptyNoCache: 'This note has no cached parallel notes. Run:',
emptyNeedsSetup: 'No AI provider is configured yet. Add an API key to start generating.',
commandGenerate: 'Parallel Reader: Generate notes for current note',
displayName: 'Parallel Reader',
ribbonOpen: 'Open Parallel Reader pane',
@ -274,6 +279,7 @@ export const STRINGS: Record<string, Record<string, string>> = {
cancelRequestedApiInFlight:
'Cancel requested. The in-flight API request cannot be aborted immediately; its result will be ignored.',
actionGenerate: 'Generate parallel notes',
actionSetupProvider: 'Set up AI provider',
fileMenuGenerate: 'Generate parallel notes',
fileMenuRegen: 'Regenerate parallel notes',
fileMenuClear: 'Clear parallel-note cache',
@ -304,7 +310,9 @@ export const STRINGS: Record<string, Record<string, string>> = {
errorNoticeRateLimit: 'Rate-limited: {error}\nWait a moment and retry.',
errorNoticeSchema: 'Model returned non-JSON output. Copy the raw output to inspect or retry.',
errorNoticeConfig: 'Config error: {error}',
errorNoticeNetwork: 'Network error — check your connection and try again.',
errorActionOpenSettings: 'Open Settings',
errorActionRetry: 'Retry',
errorActionCopyDetails: 'Copy details',
errorActionCopyRaw: 'Copy raw output',
errorModalTimeoutTitle: 'CLI timeout diagnostics',
@ -455,6 +463,7 @@ defineLocale('ja', {
settingsTitle: 'Parallel Reader 設定',
emptyOpenNote: 'ノートを開いてから実行:',
emptyNoCache: 'このノートにはキャッシュ済みの並列ノートがありません。実行:',
emptyNeedsSetup: 'AI プロバイダーがまだ設定されていません。API キーを追加して生成を開始してください。',
commandGenerate: 'Parallel Reader: 現在のノートの並列ノートを生成',
displayName: 'Parallel Reader',
ribbonOpen: 'Parallel Reader ペインを開く',
@ -504,6 +513,7 @@ defineLocale('ja', {
noCancelableJob: 'キャンセル可能な生成ジョブはありません',
cancelRequested: 'キャンセルを要求しました',
actionGenerate: '並列ノートを生成',
actionSetupProvider: 'AI プロバイダーを設定',
fileMenuGenerate: '並列ノートを生成',
fileMenuRegen: '並列ノートを強制再生成',
fileMenuClear: '並列ノートのキャッシュを削除',
@ -535,7 +545,9 @@ defineLocale('ja', {
errorNoticeRateLimit: 'レート制限: {error}\n少し待ってから再試行してください。',
errorNoticeSchema: 'モデルが JSON 以外の出力を返しました。生出力をコピーして確認するか再試行してください。',
errorNoticeConfig: '設定エラー: {error}',
errorNoticeNetwork: 'ネットワークエラー — 接続を確認して再試行してください。',
errorActionOpenSettings: '設定を開く',
errorActionRetry: '再試行',
errorActionCopyDetails: '詳細をコピー',
errorActionCopyRaw: '生出力をコピー',
noCardsReturned: 'LLM はカードを返しませんでした',
@ -682,6 +694,7 @@ defineLocale('ko', {
settingsTitle: 'Parallel Reader 설정',
emptyOpenNote: '노트를 연 다음 실행:',
emptyNoCache: '이 노트에는 캐시된 병렬 노트가 없습니다. 실행:',
emptyNeedsSetup: 'AI 제공업체가 아직 설정되지 않았습니다. API 키를 추가하여 생성을 시작하세요.',
commandGenerate: 'Parallel Reader: 현재 노트의 병렬 노트 생성',
displayName: 'Parallel Reader',
ribbonOpen: 'Parallel Reader 패널 열기',
@ -731,6 +744,7 @@ defineLocale('ko', {
noCancelableJob: '취소할 수 있는 생성 작업이 없습니다',
cancelRequested: '취소를 요청했습니다',
actionGenerate: '병렬 노트 생성',
actionSetupProvider: 'AI 제공업체 설정',
fileMenuGenerate: '병렬 노트 생성',
fileMenuRegen: '병렬 노트 강제 재생성',
fileMenuClear: '병렬 노트 캐시 지우기',
@ -761,7 +775,9 @@ defineLocale('ko', {
errorNoticeRateLimit: '요청 제한: {error}\n잠시 후 다시 시도하세요.',
errorNoticeSchema: '모델이 JSON이 아닌 출력을 반환했습니다. 원본 출력을 복사해 확인하거나 다시 시도하세요.',
errorNoticeConfig: '설정 오류: {error}',
errorNoticeNetwork: '네트워크 오류 — 연결을 확인하고 다시 시도하세요.',
errorActionOpenSettings: '설정 열기',
errorActionRetry: '다시 시도',
errorActionCopyDetails: '세부 정보 복사',
errorActionCopyRaw: '원본 출력 복사',
noCardsReturned: 'LLM이 카드를 반환하지 않았습니다',
@ -907,6 +923,7 @@ defineLocale('fr', {
settingsTitle: 'Paramètres de Parallel Reader',
emptyOpenNote: 'Ouvrez une note, puis lancez :',
emptyNoCache: 'Cette note na pas de notes parallèles en cache. Lancez :',
emptyNeedsSetup: 'Aucun fournisseur IA nest encore configuré. Ajoutez une clé API pour commencer à générer.',
commandGenerate: 'Parallel Reader : générer les notes de la note actuelle',
displayName: 'Parallel Reader',
ribbonOpen: 'Ouvrir le panneau Parallel Reader',
@ -956,6 +973,7 @@ defineLocale('fr', {
noCancelableJob: 'Aucune génération annulable',
cancelRequested: 'Annulation demandée',
actionGenerate: 'Générer les notes parallèles',
actionSetupProvider: 'Configurer le fournisseur IA',
fileMenuGenerate: 'Générer les notes parallèles',
fileMenuRegen: 'Forcer la regénération des notes parallèles',
fileMenuClear: 'Effacer le cache des notes parallèles',
@ -986,7 +1004,9 @@ defineLocale('fr', {
errorNoticeRateLimit: 'Limite de débit atteinte : {error}\nPatientez puis réessayez.',
errorNoticeSchema: 'Le modèle a renvoyé une sortie non JSON. Copiez la sortie brute pour linspecter ou réessayez.',
errorNoticeConfig: 'Erreur de configuration : {error}',
errorNoticeNetwork: 'Erreur réseau — vérifiez votre connexion et réessayez.',
errorActionOpenSettings: 'Ouvrir les paramètres',
errorActionRetry: 'Réessayer',
errorActionCopyDetails: 'Copier les détails',
errorActionCopyRaw: 'Copier la sortie brute',
noCardsReturned: 'Le LLM na renvoyé aucune carte',
@ -1141,6 +1161,8 @@ defineLocale('de', {
settingsTitle: 'Parallel Reader Einstellungen',
emptyOpenNote: 'Öffnen Sie eine Notiz und führen Sie dann aus:',
emptyNoCache: 'Diese Notiz hat keine zwischengespeicherten Parallelnotizen. Ausführen:',
emptyNeedsSetup:
'Es ist noch kein KI-Anbieter konfiguriert. Fügen Sie einen API-Schlüssel hinzu, um mit der Erzeugung zu beginnen.',
commandGenerate: 'Parallel Reader: Notizen für aktuelle Notiz erzeugen',
displayName: 'Parallel Reader',
ribbonOpen: 'Parallel Reader Bereich öffnen',
@ -1190,6 +1212,7 @@ defineLocale('de', {
noCancelableJob: 'Keine abbrechbare Erzeugung',
cancelRequested: 'Abbruch angefordert',
actionGenerate: 'Parallelnotizen erzeugen',
actionSetupProvider: 'KI-Anbieter einrichten',
fileMenuGenerate: 'Parallelnotizen erzeugen',
fileMenuRegen: 'Parallelnotizen erzwungen neu erzeugen',
fileMenuClear: 'Parallelnotizen-Cache löschen',
@ -1222,7 +1245,9 @@ defineLocale('de', {
errorNoticeSchema:
'Das Modell hat keine JSON-Ausgabe zurückgegeben. Rohdaten kopieren und prüfen oder erneut versuchen.',
errorNoticeConfig: 'Konfigurationsfehler: {error}',
errorNoticeNetwork: 'Netzwerkfehler — Verbindung prüfen und erneut versuchen.',
errorActionOpenSettings: 'Einstellungen öffnen',
errorActionRetry: 'Erneut versuchen',
errorActionCopyDetails: 'Details kopieren',
errorActionCopyRaw: 'Rohdaten kopieren',
noCardsReturned: 'LLM hat keine Karten zurückgegeben',
@ -1377,6 +1402,7 @@ defineLocale('es', {
settingsTitle: 'Configuración de Parallel Reader',
emptyOpenNote: 'Abre una nota y luego ejecuta:',
emptyNoCache: 'Esta nota no tiene notas paralelas en caché. Ejecuta:',
emptyNeedsSetup: 'Todavía no hay ningún proveedor de IA configurado. Añade una clave API para empezar a generar.',
commandGenerate: 'Parallel Reader: generar notas para la nota actual',
displayName: 'Parallel Reader',
ribbonOpen: 'Abrir panel de Parallel Reader',
@ -1426,6 +1452,7 @@ defineLocale('es', {
noCancelableJob: 'No hay generación cancelable',
cancelRequested: 'Cancelación solicitada',
actionGenerate: 'Generar notas paralelas',
actionSetupProvider: 'Configurar proveedor de IA',
fileMenuGenerate: 'Generar notas paralelas',
fileMenuRegen: 'Forzar regeneración de notas paralelas',
fileMenuClear: 'Borrar caché de notas paralelas',
@ -1457,7 +1484,9 @@ defineLocale('es', {
errorNoticeSchema:
'El modelo devolvió una salida que no es JSON. Copia la salida bruta para inspeccionarla o vuelve a intentar.',
errorNoticeConfig: 'Error de configuración: {error}',
errorNoticeNetwork: 'Error de red — comprueba tu conexión y vuelve a intentarlo.',
errorActionOpenSettings: 'Abrir configuración',
errorActionRetry: 'Reintentar',
errorActionCopyDetails: 'Copiar detalles',
errorActionCopyRaw: 'Copiar salida bruta',
noCardsReturned: 'El LLM no devolvió tarjetas',

View file

@ -6,10 +6,10 @@ import type { PluginSettings } from './types';
export { LOCALE_OVERRIDES, STRINGS } from './i18n-strings';
function supportedBaseLanguage(value: unknown): string | null {
const base = String(value || '')
.trim()
.toLowerCase()
.split(/[-_]/)[0];
if (typeof value !== 'string') {
return null;
}
const base = value.trim().toLowerCase().split(/[-_]/)[0];
return base && STRINGS[base] ? base : null;
}

View file

@ -11,6 +11,22 @@ export type RequestUrlFunction = (params: {
throw?: boolean;
}) => Promise<{ status: number; json: unknown; text: string }>;
/**
* Thrown when a provider responds with HTTP >= 400. Carries the raw status code
* and response body so callers can make locale-independent decisions (e.g. the
* structured-output fallback) instead of pattern-matching a translated message.
*/
export class ProviderApiError extends Error {
readonly status: number;
readonly body: string;
constructor(message: string, status: number, body: string) {
super(message);
this.name = 'ProviderApiError';
this.status = status;
this.body = body;
}
}
export function endpointUrl(baseUrl: string, suffixes: string[]) {
const base = baseUrl.replace(/\/+$/, '');
for (const suffix of suffixes) {
@ -65,24 +81,43 @@ export async function requestJsonBody(
}
if (resp.status >= 400) {
throw new Error(
throw new ProviderApiError(
translate(settings || null, 'errorProviderApiStatus', {
label,
status: resp.status,
excerpt: (resp.text || '').slice(0, 500),
}),
resp.status,
resp.text || '',
);
}
return responseJson(resp, label, settings);
}
// Bare `unknown`/`unrecognized` were intentionally dropped: they false-positive on
// model-name errors ("unknown model", "unrecognized model ID") and trigger a wasted
// fallback retry. The specific feature tokens + bare `schema` cover real structured-
// output rejections (e.g. "Unknown field: responseSchema" still matches `schema`).
const STRUCTURED_OUTPUT_REJECTION_KEYWORDS =
/response_format|json_schema|responseJsonSchema|responseMimeType|tools?|tool_choice|unsupported|schema/i;
const STRUCTURED_OUTPUT_FALLBACK_STATUSES = new Set([400, 404, 422]);
export function shouldRetryWithoutStructuredOutput(error: unknown): boolean {
// Preferred path: decide on the locale-independent HTTP status + raw provider body.
// The error message is i18n-translated, so matching it would only work for the two
// languages whose templates happen to contain the English/Chinese status phrasing.
if (error instanceof ProviderApiError) {
if (!STRUCTURED_OUTPUT_FALLBACK_STATUSES.has(error.status)) return false;
return (
STRUCTURED_OUTPUT_REJECTION_KEYWORDS.test(error.body) || STRUCTURED_OUTPUT_REJECTION_KEYWORDS.test(error.message)
);
}
// Fallback for errors without a status (e.g. wrapped transport failures): keep the
// legacy English/Chinese template match so existing behavior is preserved.
const message = error instanceof Error ? error.message : String(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(
message,
);
return STRUCTURED_OUTPUT_REJECTION_KEYWORDS.test(message);
}
export async function requestJsonBodyWithStructuredFallback(

View file

@ -49,7 +49,10 @@ export const DEFAULT_SETTINGS: PluginSettings = {
apiMaxTokens: 4096,
maxDocChars: MAX_DOC_CHARS,
maxCacheEntries: DEFAULT_MAX_CACHE_ENTRIES,
promptLanguage: 'zh',
// 'auto' = match the source document's main language, so a new user reading an
// English (or any non-Chinese) note gets summaries in that language by default
// instead of forced Chinese. Existing users keep whatever they have persisted.
promptLanguage: 'auto',
minCards: 5,
maxCards: 15,
customSystemPrompt: '',

View file

@ -26,6 +26,32 @@ function anthropicDelta(json: Record<string, unknown>): string {
return '';
}
/**
* Detect a provider error delivered as an SSE payload inside an HTTP 200 stream.
* These bypass the `response.status >= 400` guard, so without this they would be
* extracted as empty deltas and later misreported as "non-JSON LLM output".
* Anthropic: { type: 'error', error: { type, message } }
* OpenAI-compatible: { error: { message, type, code } }
* Returns a human-readable error message for an error payload, or null otherwise.
*/
export function streamErrorMessage(json: Record<string, unknown>): string | null {
const messageFromError = (value: unknown): string | null => {
if (!value || typeof value !== 'object') return null;
const err = value as { message?: unknown; type?: unknown };
if (typeof err.message === 'string' && err.message) return err.message;
if (typeof err.type === 'string' && err.type) return err.type;
return null;
};
// Anthropic: an explicit error event is an error even if its details are sparse.
if (json.type === 'error') {
return messageFromError(json.error) ?? 'Provider returned a streaming error';
}
// OpenAI-compatible: { error: { message, type, code } }. Only treat it as an error
// when it actually carries a message/type, so a stray empty `error: {}` (or a
// code-only object) in an otherwise-normal chunk does not abort the stream.
return messageFromError(json.error);
}
export type DeltaExtractor = (json: Record<string, unknown>) => string;
export function deltaExtractorForFormat(format: string): DeltaExtractor | null {
@ -61,13 +87,18 @@ export function parseSseBuffer(buffer: string, extractDelta: DeltaExtractor): {
const data = dataLines.join('\n');
if (data.trim() === '[DONE]') continue;
let json: Record<string, unknown>;
try {
const json = JSON.parse(data) as Record<string, unknown>;
const delta = extractDelta(json);
if (delta) deltas.push(delta);
json = JSON.parse(data) as Record<string, unknown>;
} catch {
// skip non-JSON SSE lines
continue; // skip non-JSON SSE lines (keep-alives, partial frames)
}
// Provider errors arrive as a 200-status SSE payload — surface them instead of
// swallowing them, so a transient overload/quota error is not misreported downstream.
const errorMessage = streamErrorMessage(json);
if (errorMessage) throw new Error(errorMessage);
const delta = extractDelta(json);
if (delta) deltas.push(delta);
}
return { deltas, rest };
}

View file

@ -33,6 +33,14 @@ export { translate } from './i18n';
export { cardsToMarkdown } from './markdown';
export { activeSectionLine, nextCardIndex } from './navigation';
export { buildPrompts } from './prompt';
export {
endpointUrl,
ProviderApiError,
requestJsonBody,
requestJsonBodyWithStructuredFallback,
responseJson,
shouldRetryWithoutStructuredOutput,
} from './provider-request';
export {
buildAnthropicMessagesBody,
buildGeminiBody,
@ -58,7 +66,7 @@ export {
normalizeStreamingTimeoutMs,
pruneCacheEntries,
} from './settings';
export { deltaExtractorForFormat, parseSseBuffer } from './streaming';
export { deltaExtractorForFormat, parseSseBuffer, streamErrorMessage } from './streaming';
export { addIconButton, addTextButton, copyToClipboard } from './ui-helpers';
export { folderPathsForTarget } from './vault';
export { ParallelReaderView } from './view';

View file

@ -96,7 +96,7 @@ export type GenerationPhase =
| 'done'
| 'cancelled';
export type ErrorKind = 'auth' | 'timeout' | 'rate-limit' | 'schema' | 'config' | 'cancelled' | 'unknown';
export type ErrorKind = 'auth' | 'timeout' | 'rate-limit' | 'network' | 'schema' | 'config' | 'cancelled' | 'unknown';
export interface RunForFileOptions {
rethrowErrors?: boolean;
@ -159,6 +159,10 @@ export interface PluginHost {
cache: Record<string, CacheEntry>;
manifest: PluginManifest;
t(key: string, vars?: Record<string, string | number>): string;
/** Open the plugin's settings tab (best-effort; no-op if the API is unavailable). */
openSettings(): void;
/** True if a usable credential is configured for the current backend (API key, env var, or keyless local provider). */
isCredentialConfigured(): boolean;
isGeneratingFile(file: TFile | null): boolean;
cancelGenerationForFile(file: TFile | null): boolean;
runForFile(

View file

@ -76,6 +76,18 @@ export class ParallelReaderView extends ItemView {
hint.createEl('h3', { text: this.plugin.t('appTitle') });
hint.createEl('p', { text: this.plugin.t('emptyOpenNote') });
hint.createEl('code', { text: this.plugin.t('commandGenerate') });
this.appendSetupNudge(hint);
}
/**
* When no credential is configured, append a "set up your AI provider" call-to-action
* so a first-run user does not hit a dead-end (Generate immediate API-key error).
*/
private appendSetupNudge(parent: HTMLElement): boolean {
if (this.plugin.isCredentialConfigured()) return false;
parent.createEl('p', { cls: 'parallel-reader-setup-hint', text: this.plugin.t('emptyNeedsSetup') });
addTextButton(parent, 'settings', this.plugin.t('actionSetupProvider'), () => this.plugin.openSettings());
return true;
}
focusSummaryPane() {
@ -157,6 +169,7 @@ export class ParallelReaderView extends ItemView {
hint.createEl('h3', { text: file.basename });
hint.createEl('p', { text: this.plugin.t('emptyNoCache') });
hint.createEl('code', { text: this.plugin.t('commandGenerate') });
this.appendSetupNudge(hint);
addTextButton(hint, null, this.plugin.t('actionGenerate'), () => {
if (this.plugin.isGeneratingFile(file)) return;
void this.plugin.runForFile(file, false);

View file

@ -35,6 +35,7 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup')
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'es' }), 'es');
assert.strictEqual(i18n.resolveUiLanguage(null), 'en');
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: 'auto' }), 'en');
assert.strictEqual(i18n.resolveUiLanguage({ uiLanguage: { toString: () => 'ja' } }), 'en');
const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
const setNavigatorLanguage = (language) => {

View file

@ -104,9 +104,11 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup')
const wsCustom = prompt.buildPrompts('doc', { ...base, customSystemPrompt: ' ' });
assert.ok(wsCustom.system.includes('long-form reading'), 'default prompt used when custom is whitespace-only');
// ── buildPrompts: invalid promptLanguage falls back ──
// ── buildPrompts: invalid promptLanguage falls back to the default ('auto') ──
const badLang = prompt.buildPrompts('doc', { ...base, promptLanguage: 'xyz' });
assert.ok(badLang.system.includes('中文'), 'invalid promptLanguage falls back to zh default');
const autoFallback = prompt.buildPrompts('doc', { ...base, promptLanguage: 'auto' });
assert.strictEqual(badLang.system, autoFallback.system, 'invalid promptLanguage falls back to the default (auto)');
assert.ok(badLang.system.includes('main language'), 'fallback uses the auto language instruction');
// ── buildPrompts: card count normalization ──
// 0 is falsy so || picks DEFAULT (5), then Math.max(1, 5) = 5

View file

@ -4,6 +4,7 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup')
try {
const generation = await requireBundledModule('src/generation.ts');
const providerParsers = await requireBundledModule('src/provider-parsers.ts');
const providerRequest = await requireBundledModule('src/provider-request.ts');
// ── generation.ts ──
assert.strictEqual(
@ -88,6 +89,76 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup')
);
assert.strictEqual(providerParsers.cardsFromAnthropicToolUse({}), null);
// ── provider-request: structured-output fallback keyed on HTTP status, not localized text ──
const { ProviderApiError, shouldRetryWithoutStructuredOutput, requestJsonBodyWithStructuredFallback } =
providerRequest;
// 400 + body keyword ⇒ retry, regardless of the (i18n-translated) message language.
assert.strictEqual(
shouldRetryWithoutStructuredOutput(
new ProviderApiError('英語以外のエラーメッセージ', 400, 'response_format is not supported'),
),
true,
'status-400 + body keyword ⇒ retry even when message is non-English/Chinese',
);
assert.strictEqual(
shouldRetryWithoutStructuredOutput(
new ProviderApiError("L'API a renvoyé une erreur", 422, 'json_schema unsupported'),
),
true,
'status-422 + body keyword ⇒ retry for a French-localized message',
);
// 5xx is a server error, never a structured-output problem ⇒ do not retry.
assert.strictEqual(
shouldRetryWithoutStructuredOutput(new ProviderApiError('server error', 500, 'response_format')),
false,
);
// 400 without any schema-related keyword ⇒ do not retry.
assert.strictEqual(
shouldRetryWithoutStructuredOutput(new ProviderApiError('bad request', 400, 'missing required field: model')),
false,
);
// Model-name errors ("unknown model" / "unrecognized model ID") must NOT trigger a
// wasted fallback retry — bare unknown/unrecognized keywords were dropped for this.
assert.strictEqual(
shouldRetryWithoutStructuredOutput(new ProviderApiError('bad request', 400, 'unknown model: gpt-5')),
false,
);
assert.strictEqual(
shouldRetryWithoutStructuredOutput(new ProviderApiError('bad request', 400, 'unrecognized model ID: x')),
false,
);
// Legacy path: a plain Error carrying the English template still works (back-compat).
assert.strictEqual(
shouldRetryWithoutStructuredOutput(new Error('OpenAI API returned HTTP 400: response_format not supported')),
true,
);
assert.strictEqual(shouldRetryWithoutStructuredOutput(new Error('some unrelated error')), false);
// End-to-end: a 400 returned alongside a NON-English locale still triggers the fallback body.
{
const calls = [];
const fakeRequestUrl = async ({ body }) => {
calls.push(JSON.parse(body));
if (calls.length === 1) {
return { status: 400, json: null, text: 'response_format is not supported by this model' };
}
return { status: 200, json: { ok: true }, text: '' };
};
const result = await requestJsonBodyWithStructuredFallback(
fakeRequestUrl,
'OpenAI-compatible Chat',
'https://example.test',
{},
{ model: 'x', response_format: { type: 'json_schema' } },
{ model: 'x' },
{ uiLanguage: 'fr' }, // localized error message must NOT block the fallback
);
assert.deepStrictEqual(result, { ok: true });
assert.strictEqual(calls.length, 2, 'should retry exactly once without structured output');
assert.ok(!('response_format' in calls[1]), 'fallback body omits response_format');
}
console.log('direct providers tests passed');
} finally {
cleanup();

View file

@ -74,6 +74,47 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup')
const empty = streaming.parseSseBuffer('', openAiExtractor);
assert.deepStrictEqual(empty.deltas, []);
// ── streamErrorMessage: provider errors delivered as a 200-status SSE payload ──
assert.strictEqual(
streaming.streamErrorMessage({ type: 'error', error: { type: 'overloaded_error', message: 'Overloaded' } }),
'Overloaded',
);
assert.strictEqual(
streaming.streamErrorMessage({ type: 'error', error: { type: 'overloaded_error' } }),
'overloaded_error',
);
assert.strictEqual(streaming.streamErrorMessage({ type: 'error' }), 'Provider returned a streaming error');
assert.strictEqual(streaming.streamErrorMessage({ error: { message: 'Quota exceeded' } }), 'Quota exceeded');
// OpenAI-style error object without a message/type is NOT treated as an error
// (avoids aborting a normal chunk that carries a stray empty/code-only `error`).
assert.strictEqual(streaming.streamErrorMessage({ error: { code: 'insufficient_quota' } }), null);
assert.strictEqual(streaming.streamErrorMessage({ error: {} }), null);
assert.strictEqual(streaming.streamErrorMessage({ choices: [{ delta: { content: 'hi' } }] }), null);
assert.strictEqual(streaming.streamErrorMessage({ type: 'content_block_delta', delta: { text: 'x' } }), null);
assert.strictEqual(streaming.streamErrorMessage({ error: null }), null);
// ── parseSseBuffer: surface in-stream provider errors instead of swallowing them ──
assert.throws(
() =>
streaming.parseSseBuffer(
'data: {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}\n\n',
anthropicExtractor,
),
/Overloaded/,
'anthropic in-stream error must throw, not be swallowed as a non-JSON line',
);
assert.throws(
() => streaming.parseSseBuffer('data: {"error":{"message":"Rate limit reached"}}\n\n', openAiExtractor),
/Rate limit reached/,
'openai-compatible in-stream error must throw',
);
// Normal deltas through the same parse path still work.
const okAfterError = streaming.parseSseBuffer(
'data: {"choices":[{"delta":{"content":"fine"}}]}\n\n',
openAiExtractor,
);
assert.deepStrictEqual(okAfterError.deltas, ['fine']);
// ── streamingRequestUrl ──
function trackedSignal() {
const controller = new AbortController();

View file

@ -166,6 +166,38 @@ async function testCancelAllWaiters() {
assert.strictEqual(manager.waitingCount(), 0);
}
async function testCancelAll() {
const manager = new GenerationJobManager(1);
let release;
const blocker = new Promise((r) => {
release = r;
});
let runningCancelHook = false;
const running = manager.start('run.md', async (job) => {
job.onCancel(() => {
runningCancelHook = true;
});
await blocker;
job.throwIfCancelled();
return 'run';
});
const queued = manager.start('q.md', async () => 'q');
await new Promise((r) => setTimeout(r, 5));
assert.strictEqual(manager.isRunning('run.md'), true, 'one running');
assert.strictEqual(manager.waitingCount(), 1, 'one queued');
const cancelled = manager.cancelAll();
assert.strictEqual(cancelled, 2, 'cancelAll cancels the running job and drains the queued waiter');
assert.strictEqual(runningCancelHook, true, 'running job cancel handler fired (aborts in-flight HTTP/CLI)');
release();
await assert.rejects(running, GenerationJobCancelledError, 'cancelled running job rejects');
await assert.rejects(queued, GenerationJobCancelledError, 'queued job rejects');
assert.strictEqual(manager.waitingCount(), 0);
}
async function testCancelSignalsRunnerAndCleansUp() {
const manager = new GenerationJobManager();
let cancelHookCalled = false;
@ -190,6 +222,12 @@ function testErrorClassification() {
assert.strictEqual(classifyGenerationError(new Error('API key 未设置')), 'auth');
assert.strictEqual(classifyGenerationError(new Error('CLI 超时 (120000ms)')), 'timeout');
assert.strictEqual(classifyGenerationError(new Error('OpenAI API 429: rate limit')), 'rate-limit');
assert.strictEqual(
classifyGenerationError(new Error('request to https://api failed, reason: ECONNREFUSED')),
'network',
);
assert.strictEqual(classifyGenerationError(new Error('Failed to fetch')), 'network');
assert.strictEqual(classifyGenerationError(new Error('getaddrinfo ENOTFOUND api.openai.com')), 'network');
assert.strictEqual(classifyGenerationError(new Error('LLM 返回非 JSON')), 'schema');
assert.strictEqual(classifyGenerationError(new Error('Model 未设置')), 'config');
assert.strictEqual(classifyGenerationError(new Error('something else')), 'unknown');
@ -200,6 +238,7 @@ function testErrorClassification() {
await testGlobalConcurrencyLimit();
await testNoOverbookingDuringRelease();
await testCancelAllWaiters();
await testCancelAll();
await testCancelSignalsRunnerAndCleansUp();
testErrorClassification();
console.log('generation job manager tests passed');

View file

@ -22,5 +22,6 @@
"1.0.20": "1.8.7",
"1.0.21": "1.8.7",
"1.0.22": "1.8.7",
"1.0.23": "1.8.7"
"1.0.23": "1.8.7",
"1.0.24": "1.8.7"
}