fix(kb-ui): make the object reader readable — reflow, window, jump-to
The reader dumped the whole server slice verbatim: a ~445k-char doc rendered as a 16,558px wall of one-source-line-per-line ragged text, and the outline panel showed 8 blank rows (the API's sections carry heading:null — they're mechanical 60k-char chunks, not headings). - Reflow: off the citation path, parse the extracted markdown/PDF text into real blocks — drop form-feeds, split on blank lines, join soft-wrapped lines into paragraphs, lift markdown headings + multi-line bullet lists. Cap prose width at 68ch. The citation-highlight path still renders the raw slice verbatim so character offsets stay exact. - Window every load: initial 8k chars, "Read more" +12k, section jump loads a bounded window from the section's start (server honours start/end precisely). Object height 16,558 → ~5,315px; Read more/citations verified. - Outline: blank heading:null rows now render as "Part N" jump points and the panel is titled "Jump to" (vs "Outline") when there are no real headings; aria-current on the active row. Verified live on dev-knowledge.sky-ai.com. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,16 @@ import {
|
|||||||
isImageMime,
|
isImageMime,
|
||||||
} from "./_internal";
|
} from "./_internal";
|
||||||
|
|
||||||
|
// The reader loads a bounded window and reveals more on demand — a long
|
||||||
|
// document (this one is ~445k chars) must never render as a single wall.
|
||||||
|
const INITIAL_CHARS = 8000;
|
||||||
|
const MORE_CHARS = 12000;
|
||||||
|
|
||||||
|
/** A section's display label — real heading, else a mechanical "Part N". */
|
||||||
|
function sectionLabel(s: OutlineSection, index: number): string {
|
||||||
|
return s.heading?.trim() || `Part ${index + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- ObjectList -----------------------------------------------------------
|
// ---- ObjectList -----------------------------------------------------------
|
||||||
|
|
||||||
export interface ObjectListProps {
|
export interface ObjectListProps {
|
||||||
@@ -222,20 +232,21 @@ export const OutlineNav: FC<{
|
|||||||
}> = ({ sections, activeId, onSelect, className }) => {
|
}> = ({ sections, activeId, onSelect, className }) => {
|
||||||
if (sections.length === 0) return null;
|
if (sections.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<nav className={cn("flex flex-col gap-0.5", className)} aria-label="Document outline">
|
<nav className={cn("flex flex-col gap-0.5", className)} aria-label="Document sections">
|
||||||
{sections.map((s) => (
|
{sections.map((s, i) => (
|
||||||
<button
|
<button
|
||||||
key={s.id}
|
key={s.id}
|
||||||
type="button"
|
type="button"
|
||||||
data-action="knowledge-outline-section"
|
data-action="knowledge-outline-section"
|
||||||
onClick={() => onSelect?.(s)}
|
onClick={() => onSelect?.(s)}
|
||||||
|
aria-current={activeId === s.id ? "true" : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
"truncate rounded-md px-2 py-1 text-left text-sm transition hover:bg-muted",
|
"truncate rounded-md px-2 py-1 text-left text-sm transition hover:bg-muted",
|
||||||
s.level > 1 && "pl-4 text-xs",
|
s.level > 1 && "pl-4 text-xs",
|
||||||
activeId === s.id ? "bg-muted font-medium text-foreground" : "text-muted-foreground",
|
activeId === s.id ? "bg-muted font-medium text-foreground" : "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{s.heading}
|
{sectionLabel(s, i)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
@@ -282,17 +293,21 @@ export const TextReader: FC<TextReaderProps> = ({ outline, readText, highlight,
|
|||||||
[readText],
|
[readText],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Initial + reactive loads: an explicit outline selection wins (so clicking a
|
// Initial + reactive loads, always bounded to a window so a long document
|
||||||
// section works even on a citation-opened view), then the highlight span,
|
// never renders as one wall. An explicit section selection wins (jump to its
|
||||||
// else the head of the document.
|
// start), then a citation highlight span, else the head of the document.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeSection) {
|
if (activeSection) {
|
||||||
void load({ section: activeSection });
|
const sec = outline.outline.find((s) => s.id === activeSection);
|
||||||
|
void load(
|
||||||
|
sec
|
||||||
|
? { start: sec.start, end: Math.min(sec.end, sec.start + INITIAL_CHARS) }
|
||||||
|
: { section: activeSection },
|
||||||
|
);
|
||||||
} else if (highlight) {
|
} else if (highlight) {
|
||||||
const sec = outline.outline.find((s) => highlight.start >= s.start && highlight.end <= s.end);
|
void load({ start: Math.max(0, highlight.start - 400), end: highlight.end + 1200 });
|
||||||
void load(sec ? { section: sec.id } : { start: Math.max(0, highlight.start - 200), end: highlight.end + 200 });
|
|
||||||
} else {
|
} else {
|
||||||
void load();
|
void load({ start: 0, end: INITIAL_CHARS });
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [highlight?.start, highlight?.end, activeSection, outline.object_id]);
|
}, [highlight?.start, highlight?.end, activeSection, outline.object_id]);
|
||||||
@@ -305,7 +320,7 @@ export const TextReader: FC<TextReaderProps> = ({ outline, readText, highlight,
|
|||||||
if (loadingMore || !slice?.range) return;
|
if (loadingMore || !slice?.range) return;
|
||||||
setLoadingMore(true);
|
setLoadingMore(true);
|
||||||
try {
|
try {
|
||||||
const next = await readText({ start: slice.range.end });
|
const next = await readText({ start: slice.range.end, end: slice.range.end + MORE_CHARS });
|
||||||
setSlice((prev) =>
|
setSlice((prev) =>
|
||||||
prev && prev.range && next.range
|
prev && prev.range && next.range
|
||||||
? { ...next, text: (prev.text ?? "") + (next.text ?? ""), range: { start: prev.range.start, end: next.range.end } }
|
? { ...next, text: (prev.text ?? "") + (next.text ?? ""), range: { start: prev.range.start, end: next.range.end } }
|
||||||
@@ -346,8 +361,15 @@ export const TextReader: FC<TextReaderProps> = ({ outline, readText, highlight,
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<article className="whitespace-pre-wrap break-words text-sm leading-relaxed text-foreground">
|
<article className="max-w-[68ch] break-words text-sm leading-relaxed text-foreground">
|
||||||
{renderWithHighlight(slice, highlight, markRef)}
|
{highlight ? (
|
||||||
|
// Citation landing: exact character offsets must be preserved so the
|
||||||
|
// mark lands on the cited span, so render the raw slice verbatim.
|
||||||
|
<div className="whitespace-pre-wrap break-words">{renderWithHighlight(slice, highlight, markRef)}</div>
|
||||||
|
) : (
|
||||||
|
// Reading: reflow the soft-wrapped source into real paragraphs.
|
||||||
|
renderReadable(slice.text ?? "")
|
||||||
|
)}
|
||||||
</article>
|
</article>
|
||||||
{error && <p className="mt-2 text-xs text-destructive">{error}</p>}
|
{error && <p className="mt-2 text-xs text-destructive">{error}</p>}
|
||||||
{canReadMore && (
|
{canReadMore && (
|
||||||
@@ -366,6 +388,53 @@ export const TextReader: FC<TextReaderProps> = ({ outline, readText, highlight,
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Reflow extracted text (markdown / PDF dumps) into readable blocks. The source
|
||||||
|
// hard-wraps prose with single newlines and separates paragraphs with blank
|
||||||
|
// lines; rendered verbatim that's a ragged one-line-per-line wall. Here: drop
|
||||||
|
// page-break form-feeds, split on blank lines, join soft-wrapped lines within a
|
||||||
|
// block, and lift markdown headings / bullets. Offset-independent, so it's only
|
||||||
|
// used off the citation path (which needs verbatim offsets).
|
||||||
|
const HEADING_RE = /^(#{1,6})\s+(.*)$/;
|
||||||
|
const BULLET_RE = /^\s*([-*•]|\d+[.)])\s+/;
|
||||||
|
|
||||||
|
function renderReadable(raw: string): ReactNode {
|
||||||
|
const text = raw.replace(/\r\n?/g, "\n").replace(/\f/g, "\n\n");
|
||||||
|
const blocks = text.split(/\n[ \t]*\n+/);
|
||||||
|
const out: ReactNode[] = [];
|
||||||
|
blocks.forEach((block, bi) => {
|
||||||
|
const lines = block.split("\n").map((l) => l.trimEnd()).filter((l) => l.trim() !== "");
|
||||||
|
if (lines.length === 0) return;
|
||||||
|
|
||||||
|
const h = lines.length === 1 ? HEADING_RE.exec(lines[0]) : null;
|
||||||
|
if (h) {
|
||||||
|
const level = h[1].length;
|
||||||
|
out.push(
|
||||||
|
<p key={bi} className={cn("mb-1 mt-4 font-semibold text-foreground first:mt-0", level <= 2 ? "text-base" : "text-sm")}>
|
||||||
|
{h[2]}
|
||||||
|
</p>,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A real list needs 2+ marker lines; a lone "1. Introduction" is a heading
|
||||||
|
// or TOC line, not a one-item bullet.
|
||||||
|
if (lines.length >= 2 && lines.every((l) => BULLET_RE.test(l))) {
|
||||||
|
out.push(
|
||||||
|
<ul key={bi} className="my-2 list-disc space-y-1 pl-5">
|
||||||
|
{lines.map((l, li) => (
|
||||||
|
<li key={li}>{l.replace(BULLET_RE, "")}</li>
|
||||||
|
))}
|
||||||
|
</ul>,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const para = lines.join(" ").replace(/[ \t]+/g, " ").trim();
|
||||||
|
if (para) out.push(<p key={bi} className="my-2 first:mt-0">{para}</p>);
|
||||||
|
});
|
||||||
|
return out.length > 0 ? out : raw;
|
||||||
|
}
|
||||||
|
|
||||||
function renderWithHighlight(
|
function renderWithHighlight(
|
||||||
slice: TextSlice,
|
slice: TextSlice,
|
||||||
highlight: { start: number; end: number } | undefined,
|
highlight: { start: number; end: number } | undefined,
|
||||||
@@ -527,7 +596,9 @@ export const ObjectViewer: FC<ObjectViewerProps> = ({
|
|||||||
<aside className="flex flex-col gap-4">
|
<aside className="flex flex-col gap-4">
|
||||||
{outline.outline.length > 0 && (
|
{outline.outline.length > 0 && (
|
||||||
<div className="rounded-xl border border-border bg-card p-3">
|
<div className="rounded-xl border border-border bg-card p-3">
|
||||||
<h3 className="mb-2 px-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">Outline</h3>
|
<h3 className="mb-2 px-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
{outline.outline.some((s) => s.heading?.trim()) ? "Outline" : "Jump to"}
|
||||||
|
</h3>
|
||||||
<OutlineNav sections={outline.outline} activeId={activeSection} onSelect={(s) => setActiveSection(s.id)} />
|
<OutlineNav sections={outline.outline} activeId={activeSection} onSelect={(s) => setActiveSection(s.id)} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user