fix: salvage truncated LLM JSON and make errors copyable

- Add repairTruncatedCardsJson to recover complete cards when output
  is cut off mid-card (e.g. token limit reached)
- Log raw LLM response to console on parse failure for debugging
- Make error panel text selectable and add "copy error" button

Change-Id: I4e76121138888234d42f84f3695cbbfd6beba886
This commit is contained in:
wujunchen 2026-04-27 11:56:40 +08:00
parent 5f12113fcd
commit abce923f14
8 changed files with 119 additions and 25 deletions

44
main.js

File diff suppressed because one or more lines are too long

View file

@ -41,7 +41,7 @@ import {
supportsStreaming,
tokenLimitFieldForOpenAiChat,
} from './src/providers';
import { extractJson, normalizeCardsPayload } from './src/schema';
import { extractJson, normalizeCardsPayload, repairTruncatedCardsJson } from './src/schema';
import { createRafThrottledHandler, visibleTopProbeY } from './src/scroll';
import {
CACHE_SCHEMA_VERSION,
@ -787,6 +787,7 @@ export const __test = {
createRafThrottledHandler,
createBatchRunState,
extractJson,
repairTruncatedCardsJson,
findLineForAnchor,
folderPathsForTarget,
createBatchStats,

View file

@ -196,6 +196,7 @@ export async function summarizeViaClaudeCode(
}
if (!resultText) {
console.warn('[parallel-reader] claude CLI returned no result. Full stdout:', stdout);
throw new Error('claude CLI returned no result. Output:\n' + stdout.slice(0, 500));
}

View file

@ -31,6 +31,7 @@ export const STRINGS: Record<string, Record<string, string>> = {
loadingGenerating: '对照阅读:让 LLM 读全文并自适应切段...',
loadingSubtitle: '可以继续阅读原文,生成完成后会自动刷新右侧卡片。',
errorTitle: '生成失败',
actionCopyError: '复制错误信息',
emptyCard: '(未生成)',
anchorMismatch: 'anchor 匹配失败,无法滚动联动',
menuCopyMarkdown: '复制 Markdown',
@ -189,6 +190,7 @@ export const STRINGS: Record<string, Record<string, string>> = {
loadingGenerating: 'Parallel Reader: asking the LLM to read and segment the full note...',
loadingSubtitle: 'You can keep reading. Cards will refresh when generation finishes.',
errorTitle: 'Generation failed',
actionCopyError: 'Copy error',
emptyCard: '(not generated)',
anchorMismatch: 'Anchor did not match; scroll sync is unavailable',
menuCopyMarkdown: 'Copy Markdown',

View file

@ -76,12 +76,54 @@ export function extractJson(text: string): string {
return raw;
}
/**
* Attempt to salvage cards from truncated JSON.
* When the LLM output is cut off mid-card (e.g. token limit), the outer
* `{"cards":[...]}` never closes. This function locates the `"cards":[`
* array, finds every complete `{...}` object inside it (skipping the
* truncated tail), and reconstructs valid JSON.
*/
export function repairTruncatedCardsJson(text: string): string | null {
const match = text.match(/\{\s*"cards"\s*:\s*\[/);
if (!match || match.index === undefined) return null;
const afterArrayOpen = match.index + match[0].length;
const cardCandidates = collectJsonObjectCandidates(text.slice(afterArrayOpen));
const validCards: string[] = [];
for (const c of cardCandidates) {
try {
JSON.parse(c);
validCards.push(c);
} catch (_) {
/* skip malformed card */
}
}
if (validCards.length === 0) return null;
return '{"cards":[' + validCards.join(',') + ']}';
}
export function parseCardsJson(text: string, settings?: PluginSettings | null): RawCard[] {
const jsonText = extractJson(text);
let parsed;
try {
parsed = JSON.parse(jsonText);
} catch (_e) {
// Attempt to salvage complete cards from truncated output
const repaired = repairTruncatedCardsJson(text);
if (repaired) {
try {
parsed = JSON.parse(repaired);
console.warn(
'[parallel-reader] LLM output was truncated; salvaged',
(parsed as { cards?: unknown[] }).cards?.length ?? 0,
'complete cards. Consider increasing max tokens.',
);
return normalizeCardsPayload(parsed);
} catch (_) {
/* repair failed, fall through to error */
}
}
console.warn('[parallel-reader] LLM returned non-JSON. Raw response:', text);
throw new Error(
translate(settings || null, 'errorLlmNonJson', {
excerpt: (text || '').slice(0, 500),

View file

@ -199,14 +199,25 @@ export class ParallelReaderView extends ItemView {
if (this.errorMessage) {
const state = container.createDiv({ cls: 'parallel-reader-state parallel-reader-error' });
state.createEl('div', { text: this.plugin.t('errorTitle'), cls: 'parallel-reader-state-title' });
state.createEl('div', { text: this.errorMessage, cls: 'parallel-reader-state-subtitle' });
state.createEl('div', {
text: this.errorMessage,
cls: 'parallel-reader-state-subtitle parallel-reader-selectable',
});
const actions = state.createDiv({ cls: 'parallel-reader-error-actions' });
addTextButton(
state,
actions,
'refresh-cw',
this.plugin.t('actionRegenerate'),
() => this.plugin.runForFile(this.sourceFile, true),
'parallel-reader-text-button',
);
addTextButton(
actions,
'copy',
this.plugin.t('actionCopyError'),
() => copyToClipboard(this.errorMessage, this.plugin.t('actionCopyError')),
'parallel-reader-text-button',
);
return;
}

View file

@ -288,6 +288,13 @@
line-height: 1.6;
}
.parallel-reader-selectable {
user-select: text;
-webkit-user-select: text;
white-space: pre-wrap;
word-break: break-word;
}
.parallel-reader-error {
border-color: var(--text-error, #e06c75);
}
@ -296,6 +303,12 @@
color: var(--text-error, #e06c75);
}
.parallel-reader-error-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
.parallel-reader-spinner {
width: 18px;
height: 18px;

View file

@ -83,6 +83,30 @@ assert.strictEqual(extractJson('not json at all'), 'not json at all', 'non-JSON
const nestedBraces = '{"cards":[{"title":"test {with} braces","anchor":"a","gist":"g","bullets":[]}]}';
assert.deepStrictEqual(JSON.parse(extractJson(nestedBraces)).cards[0].title, 'test {with} braces');
// ── schema.ts: repairTruncatedCardsJson ──
const { repairTruncatedCardsJson } = t;
assert.strictEqual(repairTruncatedCardsJson('not json'), null, 'non-cards text returns null');
assert.strictEqual(repairTruncatedCardsJson(''), null, 'empty string returns null');
assert.strictEqual(repairTruncatedCardsJson('{"cards":['), null, 'no complete cards returns null');
const truncJson = '{"cards":[{"title":"A","anchor":"a","gist":"g","bullets":["b"]},{"title":"B","anchor":"x","gist":"trunc';
const repaired = repairTruncatedCardsJson(truncJson);
assert.ok(repaired, 'truncated JSON is repaired');
const repairedParsed = JSON.parse(repaired);
assert.strictEqual(repairedParsed.cards.length, 1, 'only complete card is kept');
assert.strictEqual(repairedParsed.cards[0].title, 'A', 'salvaged card has correct title');
const twoComplete = '{"cards":[{"title":"A","anchor":"a","gist":"g","bullets":["b"]},{"title":"B","anchor":"a2","gist":"g2","bullets":["c"]},{"title":"C","anch';
const repairedTwo = JSON.parse(repairTruncatedCardsJson(twoComplete));
assert.strictEqual(repairedTwo.cards.length, 2, 'two complete cards salvaged from three');
// repairTruncatedCardsJson + normalizeCardsPayload integration
const salvagedCards = normalizeCardsPayload(JSON.parse(repairTruncatedCardsJson(truncJson)));
assert.strictEqual(salvagedCards.length, 1, 'repair + normalize produces valid cards');
assert.strictEqual(salvagedCards[0].title, 'A', 'salvaged card has correct fields');
// ── schema.ts: normalizeCardsPayload ──
assert.deepStrictEqual(normalizeCardsPayload(null), [], 'null payload returns empty array');