mirror of
https://github.com/fancive/obsidian-parallel-reader.git
synced 2026-07-22 06:53:43 +00:00
feat(error-ui): structured CliProcessError + ErrorKind-driven UI dispatch
Replaces the single long-Notice failure UX with a kind-aware dispatcher
so users get actionable buttons instead of a wall of text.
- CliProcessError extends Error with a typed CliErrorDetails payload
(reason / pid / elapsed / idle / bytes / tails / exitCode / signal /
timeoutMs / idleTimeoutMs). All five runCli failure paths now throw
the typed error; message format is preserved so existing classifier
regex and tests still match.
- classifyGenerationError short-circuits on details.reason for the
deterministic cases (wall/idle-timeout → timeout, spawn/startup →
config, streams-unavailable → unknown). exit-nonzero falls through
so stderr-derived auth/rate-limit hints still classify correctly.
Duck-typed check avoids cli ↔ generation-job-manager circular import.
- New error-ui.ts dispatches by ErrorKind:
timeout (with details) → TimeoutDiagnosticsModal showing cmd, pid,
elapsed, idle, bytes, redacted stderr/stdout tail, plus copy /
open-settings / close buttons.
timeout (no details, e.g. API streaming) → actionable Notice.
auth/config → Notice + "Open Settings" CTA.
rate-limit → Notice + "Copy details".
schema → Notice + "Copy raw output" CTA.
unknown → unchanged legacy short Notice.
- main.ts handleGenerationError delegates to dispatcher; new
openPluginSettings() helper uses Obsidian's app.setting.openTabById.
- 21 i18n strings (zh + en) for all new UI labels and the modal copy.
- styles.css adds error notice + diagnostics modal styling.
- 7 new test assertions cover typed details on every failure path and
classifyGenerationError respecting structured reasons.
Change-Id: I8af46d375d92ba26d14180ffbeb6c3a6dedd89f5
This commit is contained in:
parent
89464608aa
commit
d738fc120a
8 changed files with 537 additions and 21 deletions
26
main.ts
26
main.ts
|
|
@ -18,6 +18,7 @@ import {
|
|||
import { shouldConfirmRegenerate } from './src/cache';
|
||||
import { CacheManager } from './src/cache-manager';
|
||||
import { resolveCardAnchors } from './src/cards';
|
||||
import { showGenerationError } from './src/error-ui';
|
||||
import { cancellationNoticeKey, summarizeDocument } from './src/generation';
|
||||
import {
|
||||
classifyGenerationError,
|
||||
|
|
@ -603,7 +604,30 @@ class ParallelReaderPlugin extends Plugin {
|
|||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.error(e);
|
||||
if (view && this.viewIsShowingFile(view, file)) view.renderError(file, msg);
|
||||
new Notice(this.t('generationFailed', { kind: kind === 'unknown' ? '' : ` (${kind})`, error: msg }));
|
||||
showGenerationError(
|
||||
{
|
||||
app: this.app,
|
||||
settings: this.settings,
|
||||
openSettings: () => this.openPluginSettings(),
|
||||
},
|
||||
kind,
|
||||
e,
|
||||
msg,
|
||||
);
|
||||
}
|
||||
|
||||
private openPluginSettings(): 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 } })
|
||||
.setting;
|
||||
if (!setting) return;
|
||||
try {
|
||||
setting.open();
|
||||
setting.openTabById(this.manifest.id);
|
||||
} catch (err: unknown) {
|
||||
console.warn('[parallel-reader] failed to open settings tab', err);
|
||||
}
|
||||
}
|
||||
|
||||
async runBatchForFolder() {
|
||||
|
|
|
|||
113
src/cli.ts
113
src/cli.ts
|
|
@ -59,6 +59,42 @@ export interface RunCliOptions {
|
|||
debug?: boolean;
|
||||
}
|
||||
|
||||
export type CliFailureReason =
|
||||
| 'wall-timeout'
|
||||
| 'idle-timeout'
|
||||
| 'exit-nonzero'
|
||||
| 'streams-unavailable'
|
||||
| 'spawn-failure'
|
||||
| 'startup-error';
|
||||
|
||||
export interface CliErrorDetails {
|
||||
reason: CliFailureReason;
|
||||
cmd: string;
|
||||
pid: number | null;
|
||||
elapsedMs: number;
|
||||
idleMs: number | null;
|
||||
stdoutBytes: number;
|
||||
stderrBytes: number;
|
||||
/** Already secret-redacted, capped to DIAG_STDERR_TAIL_CHARS. */
|
||||
stderrTail: string;
|
||||
/** Already secret-redacted, capped to DIAG_STDOUT_TAIL_CHARS. */
|
||||
stdoutTail: string;
|
||||
exitCode: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
timeoutMs: number;
|
||||
idleTimeoutMs: number;
|
||||
}
|
||||
|
||||
export class CliProcessError extends Error {
|
||||
readonly name = 'CliProcessError';
|
||||
readonly details: CliErrorDetails;
|
||||
|
||||
constructor(message: string, details: CliErrorDetails) {
|
||||
super(message);
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
const DIAG_STDERR_TAIL_CHARS = 800;
|
||||
const DIAG_STDOUT_TAIL_CHARS = 200;
|
||||
|
||||
|
|
@ -73,7 +109,7 @@ function redactSecrets(text: string): string {
|
|||
return text
|
||||
.replace(/\b(Bearer)\s+[\w.\-+/=]{6,}/gi, `$1 ${REDACT}`)
|
||||
.replace(/\b(x-api-key|x-goog-api-key|api[_-]?key|authorization)\s*[:=]\s*[\w.\-+/=]{6,}/gi, `$1: ${REDACT}`)
|
||||
.replace(/\b(sk-[A-Za-z0-9_\-]{12,})/g, REDACT);
|
||||
.replace(/\b(sk-[A-Za-z0-9_-]{12,})/g, REDACT);
|
||||
}
|
||||
|
||||
function utf8ByteLength(text: string): number {
|
||||
|
|
@ -156,7 +192,23 @@ export function runCli(
|
|||
} catch (e: unknown) {
|
||||
const message = `Failed to start ${cmd}: ${e instanceof Error ? e.message : String(e)}`;
|
||||
if (debug) console.warn('[parallel-reader] cli spawn failed', { cmd, error: message });
|
||||
return reject(new Error(message));
|
||||
return reject(
|
||||
new CliProcessError(message, {
|
||||
reason: 'spawn-failure',
|
||||
cmd,
|
||||
pid: null,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
idleMs: null,
|
||||
stdoutBytes: 0,
|
||||
stderrBytes: 0,
|
||||
stdoutTail: '',
|
||||
stderrTail: '',
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
timeoutMs,
|
||||
idleTimeoutMs,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (debug) {
|
||||
|
|
@ -172,17 +224,32 @@ export function runCli(
|
|||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
const buildDiagSuffix = (idleMs: number): string => {
|
||||
const elapsed = Date.now() - startedAt;
|
||||
const stderrBytes = utf8ByteLength(stderr);
|
||||
const stdoutBytes = utf8ByteLength(stdout);
|
||||
const stderrTail = redactSecrets(tail(stderr, DIAG_STDERR_TAIL_CHARS));
|
||||
const stdoutTail = redactSecrets(tail(stdout, DIAG_STDOUT_TAIL_CHARS));
|
||||
const collectDetails = (
|
||||
reason: CliFailureReason,
|
||||
idleMs: number,
|
||||
extras: { exitCode?: number | null; signal?: NodeJS.Signals | null } = {},
|
||||
): CliErrorDetails => ({
|
||||
reason,
|
||||
cmd,
|
||||
pid: child?.pid ?? null,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
idleMs,
|
||||
stdoutBytes: utf8ByteLength(stdout),
|
||||
stderrBytes: utf8ByteLength(stderr),
|
||||
stdoutTail: redactSecrets(tail(stdout, DIAG_STDOUT_TAIL_CHARS)),
|
||||
stderrTail: redactSecrets(tail(stderr, DIAG_STDERR_TAIL_CHARS)),
|
||||
exitCode: extras.exitCode ?? null,
|
||||
signal: extras.signal ?? null,
|
||||
timeoutMs,
|
||||
idleTimeoutMs,
|
||||
});
|
||||
|
||||
const buildDiagSuffix = (details: CliErrorDetails): string => {
|
||||
let suffix =
|
||||
` [pid=${child?.pid ?? 'unknown'} elapsed=${elapsed}ms idle=${idleMs}ms ` +
|
||||
`stdout=${stdoutBytes}B stderr=${stderrBytes}B]`;
|
||||
if (stderrTail) suffix += `\n--- stderr tail ---\n${stderrTail}`;
|
||||
if (stdoutTail) suffix += `\n--- stdout tail ---\n${stdoutTail}`;
|
||||
` [pid=${details.pid ?? 'unknown'} elapsed=${details.elapsedMs}ms idle=${details.idleMs ?? 0}ms ` +
|
||||
`stdout=${details.stdoutBytes}B stderr=${details.stderrBytes}B]`;
|
||||
if (details.stderrTail) suffix += `\n--- stderr tail ---\n${details.stderrTail}`;
|
||||
if (details.stdoutTail) suffix += `\n--- stdout tail ---\n${details.stdoutTail}`;
|
||||
return suffix;
|
||||
};
|
||||
|
||||
|
|
@ -193,7 +260,8 @@ export function runCli(
|
|||
console.warn('[parallel-reader] failed to kill timed-out CLI process', e);
|
||||
}
|
||||
const idleMs = Date.now() - lastActivityAt;
|
||||
fail(new Error(`CLI timed out (${timeoutMs}ms)${buildDiagSuffix(idleMs)}`));
|
||||
const details = collectDetails('wall-timeout', idleMs, { signal: 'SIGKILL' });
|
||||
fail(new CliProcessError(`CLI timed out (${timeoutMs}ms)${buildDiagSuffix(details)}`, details));
|
||||
}, timeoutMs);
|
||||
|
||||
const armIdleTimer = () => {
|
||||
|
|
@ -206,7 +274,8 @@ export function runCli(
|
|||
} catch (e: unknown) {
|
||||
console.warn('[parallel-reader] failed to kill idle-timed-out CLI process', e);
|
||||
}
|
||||
fail(new Error(`CLI idle timeout (${idleTimeoutMs}ms)${buildDiagSuffix(idleMs)}`));
|
||||
const details = collectDetails('idle-timeout', idleMs, { signal: 'SIGKILL' });
|
||||
fail(new CliProcessError(`CLI idle timeout (${idleTimeoutMs}ms)${buildDiagSuffix(details)}`, details));
|
||||
}, idleTimeoutMs);
|
||||
};
|
||||
armIdleTimer();
|
||||
|
|
@ -223,7 +292,7 @@ export function runCli(
|
|||
}
|
||||
|
||||
if (!child.stdout || !child.stderr || !child.stdin) {
|
||||
fail(new Error('CLI process streams are unavailable'));
|
||||
fail(new CliProcessError('CLI process streams are unavailable', collectDetails('streams-unavailable', 0)));
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -246,12 +315,20 @@ export function runCli(
|
|||
noteActivity();
|
||||
});
|
||||
child.on('error', (e) => {
|
||||
fail(new Error(`CLI startup error: ${e.message}. Try setting an absolute CLI path.`));
|
||||
const idleMs = Date.now() - lastActivityAt;
|
||||
fail(
|
||||
new CliProcessError(
|
||||
`CLI startup error: ${e.message}. Try setting an absolute CLI path.`,
|
||||
collectDetails('startup-error', idleMs),
|
||||
),
|
||||
);
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
child.on('close', (code, signal) => {
|
||||
if (settled) return;
|
||||
if (code !== 0) {
|
||||
return fail(new Error(`CLI exited with code ${code}\nstderr:\n${stderr.slice(0, 1000)}`));
|
||||
const idleMs = Date.now() - lastActivityAt;
|
||||
const details = collectDetails('exit-nonzero', idleMs, { exitCode: code, signal });
|
||||
return fail(new CliProcessError(`CLI exited with code ${code}\nstderr:\n${stderr.slice(0, 1000)}`, details));
|
||||
}
|
||||
succeed({ stdout, stderr });
|
||||
});
|
||||
|
|
|
|||
198
src/error-ui.ts
Normal file
198
src/error-ui.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
'use strict';
|
||||
|
||||
import { type App, Modal, Notice, Setting } from 'obsidian';
|
||||
import type { CliErrorDetails } from './cli';
|
||||
import { translate } from './i18n';
|
||||
import type { ErrorKind, PluginSettings } from './types';
|
||||
import { copyToClipboard } from './ui-helpers';
|
||||
|
||||
export interface GenerationErrorContext {
|
||||
app: App;
|
||||
settings: PluginSettings;
|
||||
/** Open the plugin settings tab (best-effort; falls back to no-op if unavailable). */
|
||||
openSettings: () => void;
|
||||
}
|
||||
|
||||
interface ActionableNoticeAction {
|
||||
label: string;
|
||||
primary?: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function showActionableNotice(message: string, actions: ActionableNoticeAction[], durationMs = 12000): Notice {
|
||||
const notice = new Notice('', durationMs);
|
||||
const root = notice.noticeEl;
|
||||
root.addClass('parallel-reader-error-notice');
|
||||
root.createDiv({ cls: 'parallel-reader-error-notice-message', text: message });
|
||||
if (actions.length > 0) {
|
||||
const buttonRow = root.createDiv({ cls: 'parallel-reader-error-notice-actions' });
|
||||
for (const action of actions) {
|
||||
const btn = buttonRow.createEl('button', {
|
||||
cls: action.primary ? 'parallel-reader-error-notice-button mod-cta' : 'parallel-reader-error-notice-button',
|
||||
text: action.label,
|
||||
attr: { type: 'button' },
|
||||
});
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
try {
|
||||
action.onClick();
|
||||
} catch (err: unknown) {
|
||||
console.error('[parallel-reader] error notice action failed', err);
|
||||
} finally {
|
||||
notice.hide();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
return notice;
|
||||
}
|
||||
|
||||
class TimeoutDiagnosticsModal extends Modal {
|
||||
private readonly settings: PluginSettings;
|
||||
private readonly details: CliErrorDetails;
|
||||
private readonly fullMessage: string;
|
||||
private readonly onOpenSettings: () => void;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
settings: PluginSettings,
|
||||
details: CliErrorDetails,
|
||||
fullMessage: string,
|
||||
onOpenSettings: () => void,
|
||||
) {
|
||||
super(app);
|
||||
this.settings = settings;
|
||||
this.details = details;
|
||||
this.fullMessage = fullMessage;
|
||||
this.onOpenSettings = onOpenSettings;
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
contentEl.addClass('parallel-reader-error-modal');
|
||||
contentEl.createEl('h2', { text: translate(this.settings, 'errorModalTimeoutTitle') });
|
||||
|
||||
const reasonKey = this.details.reason === 'idle-timeout' ? 'errorModalReasonIdle' : 'errorModalReasonWall';
|
||||
contentEl.createEl('p', { text: translate(this.settings, reasonKey) });
|
||||
|
||||
const grid = contentEl.createEl('div', { cls: 'parallel-reader-error-modal-grid' });
|
||||
const addRow = (label: string, value: string) => {
|
||||
const row = grid.createDiv({ cls: 'parallel-reader-error-modal-row' });
|
||||
row.createSpan({ cls: 'parallel-reader-error-modal-label', text: label });
|
||||
row.createSpan({ cls: 'parallel-reader-error-modal-value', text: value });
|
||||
};
|
||||
addRow(translate(this.settings, 'errorModalFieldCmd'), this.details.cmd);
|
||||
addRow(translate(this.settings, 'errorModalFieldPid'), String(this.details.pid ?? '—'));
|
||||
addRow(translate(this.settings, 'errorModalFieldElapsed'), `${this.details.elapsedMs}ms`);
|
||||
addRow(
|
||||
translate(this.settings, 'errorModalFieldIdle'),
|
||||
this.details.idleMs == null ? '—' : `${this.details.idleMs}ms`,
|
||||
);
|
||||
addRow(
|
||||
translate(this.settings, 'errorModalFieldBytes'),
|
||||
`stdout ${this.details.stdoutBytes}B / stderr ${this.details.stderrBytes}B`,
|
||||
);
|
||||
|
||||
if (this.details.stderrTail) {
|
||||
contentEl.createEl('h3', { text: translate(this.settings, 'errorModalStderrTail') });
|
||||
contentEl.createEl('pre', { cls: 'parallel-reader-error-modal-tail', text: this.details.stderrTail });
|
||||
} else if (this.details.stdoutTail) {
|
||||
contentEl.createEl('h3', { text: translate(this.settings, 'errorModalStdoutTail') });
|
||||
contentEl.createEl('pre', { cls: 'parallel-reader-error-modal-tail', text: this.details.stdoutTail });
|
||||
} else {
|
||||
contentEl.createEl('p', {
|
||||
cls: 'parallel-reader-error-modal-empty',
|
||||
text: translate(this.settings, 'errorModalNoOutput'),
|
||||
});
|
||||
}
|
||||
|
||||
new Setting(contentEl)
|
||||
.addButton((b) =>
|
||||
b.setButtonText(translate(this.settings, 'errorModalActionCopy')).onClick(() => {
|
||||
void copyToClipboard(this.fullMessage, translate(this.settings, 'errorModalCopySuccess'));
|
||||
}),
|
||||
)
|
||||
.addButton((b) =>
|
||||
b
|
||||
.setButtonText(translate(this.settings, 'errorModalActionOpenSettings'))
|
||||
.setCta()
|
||||
.onClick(() => {
|
||||
this.onOpenSettings();
|
||||
this.close();
|
||||
}),
|
||||
)
|
||||
.addButton((b) => b.setButtonText(translate(this.settings, 'errorModalActionClose')).onClick(() => this.close()));
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.contentEl.empty();
|
||||
}
|
||||
}
|
||||
|
||||
export function showGenerationError(
|
||||
ctx: GenerationErrorContext,
|
||||
kind: ErrorKind,
|
||||
error: unknown,
|
||||
message: string,
|
||||
): void {
|
||||
const tr = (k: string, vars?: Record<string, string | number>) => translate(ctx.settings, k, vars);
|
||||
|
||||
if (kind === 'timeout') {
|
||||
const details = (error as { details?: CliErrorDetails }).details;
|
||||
if (details) {
|
||||
// Open a modal so users can read the stderr tail at leisure and copy it.
|
||||
new TimeoutDiagnosticsModal(ctx.app, ctx.settings, details, message, ctx.openSettings).open();
|
||||
return;
|
||||
}
|
||||
// API streaming timeout: no structured details, show actionable notice with raw message tail.
|
||||
showActionableNotice(tr('errorNoticeTimeout'), [
|
||||
{
|
||||
label: tr('errorActionCopyDetails'),
|
||||
onClick: () => void copyToClipboard(message, tr('errorModalCopySuccess')),
|
||||
},
|
||||
{ label: tr('errorActionOpenSettings'), primary: true, onClick: ctx.openSettings },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (kind === 'auth') {
|
||||
showActionableNotice(tr('errorNoticeAuth'), [
|
||||
{ label: tr('errorActionOpenSettings'), primary: true, onClick: ctx.openSettings },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (kind === 'rate-limit') {
|
||||
showActionableNotice(tr('errorNoticeRateLimit', { error: message }), [
|
||||
{
|
||||
label: tr('errorActionCopyDetails'),
|
||||
onClick: () => void copyToClipboard(message, tr('errorModalCopySuccess')),
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (kind === 'schema') {
|
||||
showActionableNotice(tr('errorNoticeSchema'), [
|
||||
{
|
||||
label: tr('errorActionCopyRaw'),
|
||||
primary: true,
|
||||
onClick: () => void copyToClipboard(message, tr('errorModalCopySuccess')),
|
||||
},
|
||||
{ label: tr('errorActionOpenSettings'), onClick: ctx.openSettings },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (kind === 'config') {
|
||||
showActionableNotice(tr('errorNoticeConfig', { error: message }), [
|
||||
{ label: tr('errorActionOpenSettings'), primary: true, onClick: ctx.openSettings },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Unknown / fallback: keep the legacy short Notice with kind tag, no buttons.
|
||||
new Notice(tr('generationFailed', { kind: kind === 'unknown' ? '' : ` (${kind})`, error: message }));
|
||||
}
|
||||
|
|
@ -195,6 +195,24 @@ export function classifyGenerationError(error: unknown): ErrorKind {
|
|||
if (error instanceof GenerationJobCancelledError) return 'cancelled';
|
||||
const errObj = error as { code?: string; message?: string } | null;
|
||||
if (errObj?.code === 'cancelled') return 'cancelled';
|
||||
|
||||
// Structured CLI error short-circuits message-regex matching for the deterministic cases.
|
||||
// Duck-typed to avoid circular import with ./cli.
|
||||
const details = (error as { details?: { reason?: string } } | null)?.details;
|
||||
if (details && typeof details.reason === 'string') {
|
||||
switch (details.reason) {
|
||||
case 'wall-timeout':
|
||||
case 'idle-timeout':
|
||||
return 'timeout';
|
||||
case 'spawn-failure':
|
||||
case 'startup-error':
|
||||
return 'config';
|
||||
case 'streams-unavailable':
|
||||
return 'unknown';
|
||||
// exit-nonzero falls through: stderr might carry auth/rate-limit/schema info.
|
||||
}
|
||||
}
|
||||
|
||||
const message = String(errObj?.message || error);
|
||||
if (/api key|unauthorized|401|403|认证|权限/i.test(message)) return 'auth';
|
||||
if (/timeout|超时|timed out/i.test(message)) return 'timeout';
|
||||
|
|
|
|||
|
|
@ -84,6 +84,31 @@ export const STRINGS: Record<string, Record<string, string>> = {
|
|||
emptyNote: '笔记为空',
|
||||
longNoteTruncated: '笔记较长:仅发送前 {count} 个字符给模型',
|
||||
generatingNotice: '对照阅读:让 LLM 读全文并自适应切段…',
|
||||
errorNoticeTimeout: '生成超时。可能是网络挂死、CLI 启动失败或模型响应慢。',
|
||||
errorNoticeAuth: 'API key 缺失或无效。请到设置中检查后重试。',
|
||||
errorNoticeRateLimit: '触发限流:{error}\n请稍候片刻再重试。',
|
||||
errorNoticeSchema: '模型返回的内容不符合 JSON 格式。可复制原始输出排查或重试。',
|
||||
errorNoticeConfig: '配置错误:{error}',
|
||||
errorActionOpenSettings: '打开设置',
|
||||
errorActionCopyDetails: '复制详情',
|
||||
errorActionCopyRaw: '复制原始输出',
|
||||
errorModalTimeoutTitle: 'CLI 超时诊断',
|
||||
errorModalReasonWall:
|
||||
'CLI 在墙钟超时之前没有完成。建议先看 stderr 是否有线索,再考虑增大 CLI 超时或换用 API 后端。',
|
||||
errorModalReasonIdle:
|
||||
'CLI 在静默超时窗口内没有任何输出,已被终止。常见原因:网络挂死、模型在思考但 CLI 不流式输出。',
|
||||
errorModalFieldCmd: '命令',
|
||||
errorModalFieldPid: '进程 PID',
|
||||
errorModalFieldElapsed: '已耗时',
|
||||
errorModalFieldIdle: '最后活动后静默',
|
||||
errorModalFieldBytes: '已收字节',
|
||||
errorModalStderrTail: 'stderr 末尾(已脱敏)',
|
||||
errorModalStdoutTail: 'stdout 末尾(已脱敏)',
|
||||
errorModalNoOutput: 'CLI 在被终止前没有产生任何输出 —— 通常意味着启动后立即挂死(网络/认证)。',
|
||||
errorModalActionCopy: '复制完整诊断',
|
||||
errorModalActionOpenSettings: '打开设置',
|
||||
errorModalActionClose: '关闭',
|
||||
errorModalCopySuccess: '已复制到剪贴板',
|
||||
noCardsReturned: 'LLM 未返回任何 card',
|
||||
generationDone: '对照笔记生成完成:{count} 段{suffix}',
|
||||
unanchoredSuffix: '(⚠ {count} 段 anchor 未匹配)',
|
||||
|
|
@ -272,6 +297,32 @@ export const STRINGS: Record<string, Record<string, string>> = {
|
|||
emptyNote: 'The note is empty',
|
||||
longNoteTruncated: 'Long note: only the first {count} characters will be sent to the model',
|
||||
generatingNotice: 'Parallel Reader: asking the LLM to read and segment the full note...',
|
||||
errorNoticeTimeout: 'Generation timed out. Possible network hang, CLI startup failure, or slow model.',
|
||||
errorNoticeAuth: 'API key missing or invalid. Check Settings and retry.',
|
||||
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}',
|
||||
errorActionOpenSettings: 'Open Settings',
|
||||
errorActionCopyDetails: 'Copy details',
|
||||
errorActionCopyRaw: 'Copy raw output',
|
||||
errorModalTimeoutTitle: 'CLI timeout diagnostics',
|
||||
errorModalReasonWall:
|
||||
'The CLI did not finish before the wall-clock timeout. Inspect stderr below, then consider raising the CLI timeout or switching to the API backend.',
|
||||
errorModalReasonIdle:
|
||||
'The CLI produced no output during the idle-timeout window and was terminated. Common causes: network hang, or model thinking with a non-streaming CLI.',
|
||||
errorModalFieldCmd: 'Command',
|
||||
errorModalFieldPid: 'PID',
|
||||
errorModalFieldElapsed: 'Elapsed',
|
||||
errorModalFieldIdle: 'Idle since last activity',
|
||||
errorModalFieldBytes: 'Bytes received',
|
||||
errorModalStderrTail: 'stderr tail (redacted)',
|
||||
errorModalStdoutTail: 'stdout tail (redacted)',
|
||||
errorModalNoOutput:
|
||||
'The CLI produced no output before being killed — typically a startup-time hang (network/auth).',
|
||||
errorModalActionCopy: 'Copy full diagnostics',
|
||||
errorModalActionOpenSettings: 'Open Settings',
|
||||
errorModalActionClose: 'Close',
|
||||
errorModalCopySuccess: 'Copied to clipboard',
|
||||
noCardsReturned: 'LLM returned no cards',
|
||||
generationDone: 'Generated {count} sections{suffix}',
|
||||
unanchoredSuffix: ' (⚠ {count} anchors unmatched)',
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export {
|
|||
export { serializeCacheFile, shouldConfirmRegenerate, touchCacheEntry } from './cache';
|
||||
export { CacheManager } from './cache-manager';
|
||||
export { activeIndexAfterCardDelete, removeCardAt, updateCardAt } from './cards';
|
||||
export { resolveCliPath, runCli, summarizeViaClaudeCode, summarizeViaCodex } from './cli';
|
||||
export { CliProcessError, resolveCliPath, runCli, summarizeViaClaudeCode, summarizeViaCodex } from './cli';
|
||||
export { cancellationNoticeKey, summarizeDocument } from './generation';
|
||||
export {
|
||||
classifyGenerationError,
|
||||
|
|
|
|||
57
styles.css
57
styles.css
|
|
@ -473,3 +473,60 @@
|
|||
color: var(--text-faint);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
/* ---- Generation error UI: actionable notice + diagnostics modal ---- */
|
||||
|
||||
.parallel-reader-error-notice .parallel-reader-error-notice-message {
|
||||
white-space: pre-wrap;
|
||||
margin-bottom: 0.6em;
|
||||
}
|
||||
|
||||
.parallel-reader-error-notice .parallel-reader-error-notice-actions {
|
||||
display: flex;
|
||||
gap: 0.4em;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.parallel-reader-error-notice .parallel-reader-error-notice-button {
|
||||
padding: 0.25em 0.7em;
|
||||
font-size: 0.85em;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.parallel-reader-error-modal .parallel-reader-error-modal-grid {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
column-gap: 0.8em;
|
||||
row-gap: 0.2em;
|
||||
font-size: 0.85em;
|
||||
margin: 0.6em 0;
|
||||
}
|
||||
|
||||
.parallel-reader-error-modal .parallel-reader-error-modal-row {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.parallel-reader-error-modal .parallel-reader-error-modal-label {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.parallel-reader-error-modal .parallel-reader-error-modal-value {
|
||||
font-family: var(--font-monospace);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.parallel-reader-error-modal .parallel-reader-error-modal-tail {
|
||||
background: var(--background-secondary);
|
||||
padding: 0.6em 0.8em;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8em;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.parallel-reader-error-modal .parallel-reader-error-modal-empty {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,6 +66,13 @@ async function testRunCliEdgeCases() {
|
|||
assert.match(err.message, /stderr=\d+B/, 'timeout error carries stderr byte count');
|
||||
assert.match(err.message, /stderr tail/, 'timeout error includes stderr tail');
|
||||
assert.match(err.message, /boom: connection reset/, 'timeout error preserves last stderr line');
|
||||
// Structured CliProcessError details (P0 #2: error UI dispatch needs typed access).
|
||||
assert.ok(err instanceof t.CliProcessError, 'wall-timeout throws CliProcessError');
|
||||
assert.strictEqual(err.details.reason, 'wall-timeout', 'wall-timeout reason set');
|
||||
assert.strictEqual(err.details.pid, 4242, 'details.pid populated');
|
||||
assert.ok(err.details.stderrBytes >= 'boom: connection reset'.length, 'details.stderrBytes utf-8 byte count');
|
||||
assert.match(err.details.stderrTail, /boom: connection reset/, 'details.stderrTail preserved');
|
||||
assert.strictEqual(err.details.timeoutMs, 20, 'details.timeoutMs reflects wall timeout');
|
||||
return true;
|
||||
},
|
||||
'runCli rejects on timeout with rich diagnostics',
|
||||
|
|
@ -80,6 +87,9 @@ async function testRunCliEdgeCases() {
|
|||
(err) => {
|
||||
assert.match(err.message, /CLI idle timeout \(15ms\)/, 'idle timeout error message');
|
||||
assert.match(err.message, /pid=5252/, 'idle timeout carries pid');
|
||||
assert.ok(err instanceof t.CliProcessError, 'idle-timeout throws CliProcessError');
|
||||
assert.strictEqual(err.details.reason, 'idle-timeout', 'idle-timeout reason set');
|
||||
assert.strictEqual(err.details.idleTimeoutMs, 15, 'details.idleTimeoutMs reflects idle window');
|
||||
return true;
|
||||
},
|
||||
'runCli idle timeout fires when no output',
|
||||
|
|
@ -132,10 +142,35 @@ async function testRunCliEdgeCases() {
|
|||
|
||||
await assert.rejects(
|
||||
() => t.runCli('fake', [], '', 100, undefined, () => ({ stdout: null, stderr: null, stdin: null })),
|
||||
/CLI process streams are unavailable/,
|
||||
(err) => {
|
||||
assert.match(err.message, /CLI process streams are unavailable/, 'streams-unavailable message preserved');
|
||||
assert.ok(err instanceof t.CliProcessError, 'streams-unavailable throws CliProcessError');
|
||||
assert.strictEqual(err.details.reason, 'streams-unavailable', 'streams-unavailable reason set');
|
||||
return true;
|
||||
},
|
||||
'runCli reports unavailable stdio streams',
|
||||
);
|
||||
|
||||
// Non-zero exit still throws a typed CliProcessError so dispatcher can inspect stderr.
|
||||
const exitChild = createFakeChild();
|
||||
exitChild.pid = 7373;
|
||||
const exitPromise = t.runCli('fake', [], '', 1000, undefined, () => exitChild);
|
||||
setImmediate(() => {
|
||||
exitChild.stderr.emit('data', Buffer.from('error: invalid api key'));
|
||||
exitChild.emit('close', 2, null);
|
||||
});
|
||||
await assert.rejects(
|
||||
() => exitPromise,
|
||||
(err) => {
|
||||
assert.ok(err instanceof t.CliProcessError, 'exit-nonzero throws CliProcessError');
|
||||
assert.strictEqual(err.details.reason, 'exit-nonzero', 'exit-nonzero reason set');
|
||||
assert.strictEqual(err.details.exitCode, 2, 'details.exitCode populated');
|
||||
assert.match(err.details.stderrTail, /invalid api key/, 'details.stderrTail captures auth hint');
|
||||
return true;
|
||||
},
|
||||
'runCli exposes non-zero exit code in structured details',
|
||||
);
|
||||
|
||||
// Diagnostic suffix must redact secrets that show up in stderr (Bearer tokens, sk- keys, etc.)
|
||||
const secretChild = createFakeChild();
|
||||
secretChild.pid = 6363;
|
||||
|
|
@ -433,6 +468,62 @@ assert.strictEqual(t.classifyGenerationError(new Error('API key 未设置')), 'a
|
|||
assert.strictEqual(t.classifyGenerationError(new Error('API key is not set')), 'auth');
|
||||
assert.strictEqual(t.classifyGenerationError(new Error('CLI timed out (120000ms)')), 'timeout');
|
||||
assert.strictEqual(t.classifyGenerationError(new Error('CLI 超时 (120000ms)')), 'timeout');
|
||||
|
||||
// Structured CliProcessError short-circuits message-regex matching for deterministic reasons.
|
||||
const wallTimeout = new t.CliProcessError('whatever message text', {
|
||||
reason: 'wall-timeout',
|
||||
cmd: 'claude',
|
||||
pid: 1,
|
||||
elapsedMs: 0,
|
||||
idleMs: 0,
|
||||
stdoutBytes: 0,
|
||||
stderrBytes: 0,
|
||||
stdoutTail: '',
|
||||
stderrTail: '',
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
timeoutMs: 0,
|
||||
idleTimeoutMs: 0,
|
||||
});
|
||||
assert.strictEqual(t.classifyGenerationError(wallTimeout), 'timeout', 'wall-timeout details → timeout kind');
|
||||
const spawnFailure = new t.CliProcessError('Failed to start claude', {
|
||||
reason: 'spawn-failure',
|
||||
cmd: 'claude',
|
||||
pid: null,
|
||||
elapsedMs: 0,
|
||||
idleMs: null,
|
||||
stdoutBytes: 0,
|
||||
stderrBytes: 0,
|
||||
stdoutTail: '',
|
||||
stderrTail: '',
|
||||
exitCode: null,
|
||||
signal: null,
|
||||
timeoutMs: 0,
|
||||
idleTimeoutMs: 0,
|
||||
});
|
||||
assert.strictEqual(t.classifyGenerationError(spawnFailure), 'config', 'spawn-failure details → config kind');
|
||||
// exit-nonzero falls through so stderr-derived auth/rate-limit messages can still classify.
|
||||
const exitWithAuthStderr = new t.CliProcessError('CLI exited with code 1\nstderr:\nerror: API key invalid', {
|
||||
reason: 'exit-nonzero',
|
||||
cmd: 'claude',
|
||||
pid: 1,
|
||||
elapsedMs: 0,
|
||||
idleMs: 0,
|
||||
stdoutBytes: 0,
|
||||
stderrBytes: 0,
|
||||
stdoutTail: '',
|
||||
stderrTail: '',
|
||||
exitCode: 1,
|
||||
signal: null,
|
||||
timeoutMs: 0,
|
||||
idleTimeoutMs: 0,
|
||||
});
|
||||
assert.strictEqual(
|
||||
t.classifyGenerationError(exitWithAuthStderr),
|
||||
'auth',
|
||||
'exit-nonzero falls through so message-regex still detects auth',
|
||||
);
|
||||
|
||||
assert.strictEqual(t.classifyGenerationError(new Error('API returned HTTP 429')), 'rate-limit');
|
||||
assert.strictEqual(t.classifyGenerationError(new Error('json_schema validation failed')), 'schema');
|
||||
assert.strictEqual(t.classifyGenerationError(new Error('LLM 返回非 JSON')), 'schema');
|
||||
|
|
|
|||
Loading…
Reference in a new issue