From 5f36b1218b128401cc58d1748f979d3b6d385260 Mon Sep 17 00:00:00 2001
From: wujunchen
Date: Tue, 16 Jun 2026 09:37:59 +0800
Subject: [PATCH] feat: provider/lifecycle robustness, onboarding nudge, CI
coverage gate
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
.c8rc.json | 7 +++-
.e2e/cases/product-shell/run.mjs | 4 ++
.github/workflows/ci.yml | 6 +++
README.md | 2 +-
README.zh-CN.md | 2 +-
main.ts | 40 +++++++++++++++---
package.json | 2 +-
src/error-ui.ts | 13 ++++++
src/generation-job-manager.ts | 20 +++++++++
src/i18n-strings.ts | 29 +++++++++++++
src/provider-request.ts | 39 ++++++++++++++++--
src/settings.ts | 5 ++-
src/streaming.ts | 38 +++++++++++++++--
src/test-exports.ts | 10 ++++-
src/types.ts | 6 ++-
src/view.ts | 13 ++++++
tests/direct-prompt.test.js | 6 ++-
tests/direct-providers.test.js | 61 ++++++++++++++++++++++++++++
tests/direct-streaming.test.js | 41 +++++++++++++++++++
tests/generation-job-manager.test.js | 39 ++++++++++++++++++
20 files changed, 360 insertions(+), 23 deletions(-)
diff --git a/.c8rc.json b/.c8rc.json
index d968976..a5bd6e1 100644
--- a/.c8rc.json
+++ b/.c8rc.json
@@ -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
}
diff --git a/.e2e/cases/product-shell/run.mjs b/.e2e/cases/product-shell/run.mjs
index f26138b..23604d6 100644
--- a/.e2e/cases/product-shell/run.mjs
+++ b/.e2e/cases/product-shell/run.mjs
@@ -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, []);
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 2f8b2bc..0980267 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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
diff --git a/README.md b/README.md
index 46d1dbb..54bd9b9 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@
-
+
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 1074cec..8e4973e 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -9,7 +9,7 @@
-
+
diff --git a/main.ts b/main.ts
index bb3092f..7bb0a45 100644
--- a/main.ts
+++ b/main.ts
@@ -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'));
diff --git a/package.json b/package.json
index 032107f..d046c2d 100644
--- a/package.json
+++ b/package.json
@@ -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/",
diff --git a/src/error-ui.ts b/src/error-ui.ts
index c61074c..48f439a 100644
--- a/src/error-ui.ts
+++ b/src/error-ui.ts
@@ -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'), [
{
diff --git a/src/generation-job-manager.ts b/src/generation-job-manager.ts
index 0b272e0..54bc169 100644
--- a/src/generation-job-manager.ts
+++ b/src/generation-job-manager.ts
@@ -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)
)
diff --git a/src/i18n-strings.ts b/src/i18n-strings.ts
index d415c43..fd7ca09 100644
--- a/src/i18n-strings.ts
+++ b/src/i18n-strings.ts
@@ -6,6 +6,7 @@ export const STRINGS: Record> = {
settingsTitle: 'Parallel Reader 设置',
emptyOpenNote: '打开一篇笔记,然后运行命令:',
emptyNoCache: '该笔记尚无对照笔记缓存。运行命令:',
+ emptyNeedsSetup: '尚未配置 AI 服务商。填写 API Key 后即可开始生成。',
commandGenerate: 'Parallel Reader: 为当前笔记生成对照笔记',
displayName: '对照阅读笔记',
ribbonOpen: '打开对照笔记面板',
@@ -60,6 +61,7 @@ export const STRINGS: Record> = {
cancelRequested: '已请求取消生成',
cancelRequestedApiInFlight: '已请求取消生成;当前 API 请求无法立即中断,返回后会丢弃结果。',
actionGenerate: '生成对照笔记',
+ actionSetupProvider: '配置 AI 服务商',
fileMenuGenerate: '生成对照笔记',
fileMenuRegen: '强制重新生成对照笔记',
fileMenuClear: '清除对照笔记缓存',
@@ -89,7 +91,9 @@ export const STRINGS: Record> = {
errorNoticeRateLimit: '触发限流:{error}\n请稍候片刻再重试。',
errorNoticeSchema: '模型返回的内容不符合 JSON 格式。可复制原始输出排查或重试。',
errorNoticeConfig: '配置错误:{error}',
+ errorNoticeNetwork: '网络错误——请检查网络连接后重试。',
errorActionOpenSettings: '打开设置',
+ errorActionRetry: '重试',
errorActionCopyDetails: '复制详情',
errorActionCopyRaw: '复制原始输出',
errorModalTimeoutTitle: 'CLI 超时诊断',
@@ -219,6 +223,7 @@ export const STRINGS: Record> = {
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> = {
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> = {
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 n’a pas de notes parallèles en cache. Lancez :',
+ emptyNeedsSetup: 'Aucun fournisseur IA n’est 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 l’inspecter 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 n’a 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',
diff --git a/src/provider-request.ts b/src/provider-request.ts
index 89b37f8..d606e14 100644
--- a/src/provider-request.ts
+++ b/src/provider-request.ts
@@ -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,39 @@ 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);
}
+const STRUCTURED_OUTPUT_REJECTION_KEYWORDS =
+ /response_format|json_schema|responseJsonSchema|responseMimeType|tools?|tool_choice|unsupported|unrecognized|unknown|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(
diff --git a/src/settings.ts b/src/settings.ts
index 0b17a48..2829b79 100644
--- a/src/settings.ts
+++ b/src/settings.ts
@@ -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: '',
diff --git a/src/streaming.ts b/src/streaming.ts
index b07db1e..883a47a 100644
--- a/src/streaming.ts
+++ b/src/streaming.ts
@@ -26,6 +26,31 @@ function anthropicDelta(json: Record): 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 | 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;
+ };
+ if (json.type === 'error') {
+ return messageFromError(json.error) ?? 'Provider returned a streaming error';
+ }
+ if (json.error) {
+ return messageFromError(json.error) ?? 'Provider returned a streaming error';
+ }
+ return null;
+}
+
export type DeltaExtractor = (json: Record) => string;
export function deltaExtractorForFormat(format: string): DeltaExtractor | null {
@@ -61,13 +86,18 @@ export function parseSseBuffer(buffer: string, extractDelta: DeltaExtractor): {
const data = dataLines.join('\n');
if (data.trim() === '[DONE]') continue;
+ let json: Record;
try {
- const json = JSON.parse(data) as Record;
- const delta = extractDelta(json);
- if (delta) deltas.push(delta);
+ json = JSON.parse(data) as Record;
} 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 };
}
diff --git a/src/test-exports.ts b/src/test-exports.ts
index 32a23a6..541276d 100644
--- a/src/test-exports.ts
+++ b/src/test-exports.ts
@@ -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';
diff --git a/src/types.ts b/src/types.ts
index 929a8a3..f151e30 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -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;
manifest: PluginManifest;
t(key: string, vars?: Record): 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(
diff --git a/src/view.ts b/src/view.ts
index 4f9449d..cfa98f6 100644
--- a/src/view.ts
+++ b/src/view.ts
@@ -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);
diff --git a/tests/direct-prompt.test.js b/tests/direct-prompt.test.js
index 72df5e2..a053742 100644
--- a/tests/direct-prompt.test.js
+++ b/tests/direct-prompt.test.js
@@ -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
diff --git a/tests/direct-providers.test.js b/tests/direct-providers.test.js
index 1d9adb8..e36aa00 100644
--- a/tests/direct-providers.test.js
+++ b/tests/direct-providers.test.js
@@ -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,66 @@ 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,
+ );
+ // 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();
diff --git a/tests/direct-streaming.test.js b/tests/direct-streaming.test.js
index fb3c2c5..51f8632 100644
--- a/tests/direct-streaming.test.js
+++ b/tests/direct-streaming.test.js
@@ -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');
+ assert.strictEqual(
+ streaming.streamErrorMessage({ error: { code: 'insufficient_quota' } }),
+ 'Provider returned a streaming error',
+ );
+ 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();
diff --git a/tests/generation-job-manager.test.js b/tests/generation-job-manager.test.js
index e84ae60..9267685 100644
--- a/tests/generation-job-manager.test.js
+++ b/tests/generation-job-manager.test.js
@@ -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');