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,
|
||||
} 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 -----------------------------------------------------------
|
||||
|
||||
export interface ObjectListProps {
|
||||
@@ -222,20 +232,21 @@ export const OutlineNav: FC<{
|
||||
}> = ({ sections, activeId, onSelect, className }) => {
|
||||
if (sections.length === 0) return null;
|
||||
return (
|
||||
<nav className={cn("flex flex-col gap-0.5", className)} aria-label="Document outline">
|
||||
{sections.map((s) => (
|
||||
<nav className={cn("flex flex-col gap-0.5", className)} aria-label="Document sections">
|
||||
{sections.map((s, i) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
data-action="knowledge-outline-section"
|
||||
onClick={() => onSelect?.(s)}
|
||||
aria-current={activeId === s.id ? "true" : undefined}
|
||||
className={cn(
|
||||
"truncate rounded-md px-2 py-1 text-left text-sm transition hover:bg-muted",
|
||||
s.level > 1 && "pl-4 text-xs",
|
||||
activeId === s.id ? "bg-muted font-medium text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{s.heading}
|
||||
{sectionLabel(s, i)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
@@ -282,17 +293,21 @@ export const TextReader: FC<TextReaderProps> = ({ outline, readText, highlight,
|
||||
[readText],
|
||||
);
|
||||
|
||||
// Initial + reactive loads: an explicit outline selection wins (so clicking a
|
||||
// section works even on a citation-opened view), then the highlight span,
|
||||
// else the head of the document.
|
||||
// Initial + reactive loads, always bounded to a window so a long document
|
||||
// never renders as one wall. An explicit section selection wins (jump to its
|
||||
// start), then a citation highlight span, else the head of the document.
|
||||
useEffect(() => {
|
||||
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) {
|
||||
const sec = outline.outline.find((s) => highlight.start >= s.start && highlight.end <= s.end);
|
||||
void load(sec ? { section: sec.id } : { start: Math.max(0, highlight.start - 200), end: highlight.end + 200 });
|
||||
void load({ start: Math.max(0, highlight.start - 400), end: highlight.end + 1200 });
|
||||
} else {
|
||||
void load();
|
||||
void load({ start: 0, end: INITIAL_CHARS });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [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;
|
||||
setLoadingMore(true);
|
||||
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) =>
|
||||
prev && prev.range && next.range
|
||||
? { ...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 (
|
||||
<div className={className}>
|
||||
<article className="whitespace-pre-wrap break-words text-sm leading-relaxed text-foreground">
|
||||
{renderWithHighlight(slice, highlight, markRef)}
|
||||
<article className="max-w-[68ch] break-words text-sm leading-relaxed text-foreground">
|
||||
{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>
|
||||
{error && <p className="mt-2 text-xs text-destructive">{error}</p>}
|
||||
{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(
|
||||
slice: TextSlice,
|
||||
highlight: { start: number; end: number } | undefined,
|
||||
@@ -527,7 +596,9 @@ export const ObjectViewer: FC<ObjectViewerProps> = ({
|
||||
<aside className="flex flex-col gap-4">
|
||||
{outline.outline.length > 0 && (
|
||||
<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)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user