mirror of
https://github.com/fancive/obsidian-parallel-reader.git
synced 2026-07-22 06:53:43 +00:00
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
This commit is contained in:
parent
c45f7987e3
commit
5f36b1218b
20 changed files with 360 additions and 23 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, []);
|
||||
|
|
|
|||
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
40
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'));
|
||||
|
|
|
|||
|
|
@ -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/",
|
||||
|
|
|
|||
|
|
@ -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'), [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 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',
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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: '',
|
||||
|
|
|
|||
|
|
@ -26,6 +26,31 @@ 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;
|
||||
};
|
||||
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, unknown>) => 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<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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
13
src/view.ts
13
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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue