mirror of
https://github.com/fancive/obsidian-parallel-reader.git
synced 2026-07-22 06:53:43 +00:00
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:
parent
5f12113fcd
commit
abce923f14
8 changed files with 119 additions and 25 deletions
44
main.js
44
main.js
File diff suppressed because one or more lines are too long
3
main.ts
3
main.ts
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
15
src/view.ts
15
src/view.ts
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
13
styles.css
13
styles.css
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue