feat: parse and render kb line citations

This commit is contained in:
CREDO23 2026-06-19 17:37:41 +02:00
parent 73dd4e8e3a
commit 5f341bdd2f
4 changed files with 101 additions and 10 deletions

View file

@ -2,9 +2,11 @@
import { useSetAtom } from "jotai";
import { FileText } from "lucide-react";
import { useParams } from "next/navigation";
import type { FC } from "react";
import { useId, useState } from "react";
import { openCitationPanelAtom } from "@/atoms/citation/citation-panel.atom";
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { useCitationMetadata } from "@/components/assistant-ui/citation-metadata-context";
import { CitationPanelContent } from "@/components/citation-panel/citation-panel";
import { Citation } from "@/components/tool-ui/citation";
@ -108,6 +110,50 @@ const NumericChunkCitation: FC<{ chunkId: number }> = ({ chunkId }) => {
);
};
interface LineCitationProps {
documentId: number;
startLine: number;
endLine: number;
}
/**
* Inline citation for a knowledge-base document line range
* (`[citation:d<documentId>#L<start>-<end>]`). Clicking opens the document in
* the editor's read-only source view, scrolled to and highlighting the cited
* lines the same anchor the citation panel uses for chunk citations.
*/
export const LineCitation: FC<LineCitationProps> = ({ documentId, startLine, endLine }) => {
const openEditorPanel = useSetAtom(openEditorPanelAtom);
const params = useParams();
const searchSpaceId = Number(params?.search_space_id);
const label = startLine === endLine ? `L${startLine}` : `L${startLine}-${endLine}`;
const handleClick = () => {
if (!Number.isFinite(searchSpaceId)) return;
openEditorPanel({
documentId,
searchSpaceId,
highlightLines: { start: startLine, end: endLine },
forceSourceView: true,
});
};
return (
<Button
type="button"
variant="ghost"
onClick={handleClick}
className="ml-0.5 inline-flex h-5 min-w-5 items-center justify-center gap-0.5 rounded-md bg-popover px-1.5 text-[11px] font-medium text-popover-foreground/80 align-baseline"
title={`View cited lines ${startLine}${endLine}`}
aria-label={`View cited document lines ${startLine} to ${endLine}`}
>
<FileText className="size-3" />
{label}
</Button>
);
};
import { tryGetHostname } from "@/lib/url";
interface UrlCitationProps {

View file

@ -1,7 +1,7 @@
"use client";
import type { ReactNode } from "react";
import { InlineCitation, UrlCitation } from "@/components/assistant-ui/inline-citation";
import { InlineCitation, LineCitation, UrlCitation } from "@/components/assistant-ui/inline-citation";
import {
type CitationToken,
type CitationUrlMap,
@ -21,6 +21,16 @@ export function renderCitationToken(token: CitationToken, ordinalKey: number): R
if (token.kind === "url") {
return <UrlCitation key={`citation-url-${ordinalKey}`} url={token.url} />;
}
if (token.kind === "line") {
return (
<LineCitation
key={`citation-line-${token.documentId}-${token.startLine}-${ordinalKey}`}
documentId={token.documentId}
startLine={token.startLine}
endLine={token.endLine}
/>
);
}
return (
<InlineCitation
key={`citation-${token.isDocsChunk ? "doc-" : ""}${token.chunkId}-${ordinalKey}`}

View file

@ -3,9 +3,10 @@
import { type Descendant, KEYS } from "platejs";
import { createPlatePlugin, type PlateElementProps } from "platejs/react";
import type { FC } from "react";
import { InlineCitation, UrlCitation } from "@/components/assistant-ui/inline-citation";
import { InlineCitation, LineCitation, UrlCitation } from "@/components/assistant-ui/inline-citation";
import {
CITATION_REGEX,
type CitationToken,
type CitationUrlMap,
parseTextWithCitations,
} from "@/lib/citations/citation-parser";
@ -17,9 +18,12 @@ import {
*/
export type CitationElementNode = {
type: "citation";
kind: "chunk" | "doc" | "url";
kind: "chunk" | "doc" | "url" | "line";
chunkId?: number;
url?: string;
documentId?: number;
startLine?: number;
endLine?: number;
/** Original literal token that produced this citation node. */
rawText: string;
children: [{ text: "" }];
@ -33,11 +37,22 @@ const CitationElement: FC<PlateElementProps<CitationElementNode>> = ({
element,
}) => {
const isUrl = element.kind === "url";
const isLine =
element.kind === "line" &&
element.documentId !== undefined &&
element.startLine !== undefined &&
element.endLine !== undefined;
return (
<span {...attributes} className="inline-flex align-baseline">
<span contentEditable={false}>
{isUrl && element.url ? (
<UrlCitation url={element.url} />
) : isLine ? (
<LineCitation
documentId={element.documentId as number}
startLine={element.startLine as number}
endLine={element.endLine as number}
/>
) : element.chunkId !== undefined ? (
<InlineCitation chunkId={element.chunkId} isDocsChunk={element.kind === "doc"} />
) : null}
@ -97,10 +112,7 @@ function copyMarks(textNode: SlateText): Record<string, unknown> {
return marks;
}
function makeCitationElement(
rawText: string,
segment: { kind: "url"; url: string } | { kind: "chunk"; chunkId: number; isDocsChunk: boolean }
): CitationElementNode {
function makeCitationElement(rawText: string, segment: CitationToken): CitationElementNode {
if (segment.kind === "url") {
return {
type: CITATION_TYPE,
@ -110,6 +122,17 @@ function makeCitationElement(
children: [{ text: "" }],
};
}
if (segment.kind === "line") {
return {
type: CITATION_TYPE,
kind: "line",
documentId: segment.documentId,
startLine: segment.startLine,
endLine: segment.endLine,
rawText,
children: [{ text: "" }],
};
}
return {
type: CITATION_TYPE,
kind: segment.isDocsChunk ? "doc" : "chunk",

View file

@ -18,12 +18,16 @@ import { FENCED_OR_INLINE_CODE } from "@/lib/markdown/code-regions";
* sometimes emit.
*/
export const CITATION_REGEX =
/[[【]\u200B?citation:\s*(https?:\/\/[^\]】\u200B]+|urlcite\d+|(?:doc-)?-?\d+(?:\s*,\s*(?:doc-)?-?\d+)*)\s*\u200B?[\]】]/g;
/[[【]\u200B?citation:\s*(https?:\/\/[^\]】\u200B]+|urlcite\d+|d\d+#L\d+-\d+|(?:doc-)?-?\d+(?:\s*,\s*(?:doc-)?-?\d+)*)\s*\u200B?[\]】]/g;
/** Matches the knowledge-base line-citation form `d<documentId>#L<start>-<end>`. */
const LINE_CITATION_REGEX = /^d(\d+)#L(\d+)-(\d+)$/;
/** A single parsed citation reference. */
export type CitationToken =
| { kind: "url"; url: string }
| { kind: "chunk"; chunkId: number; isDocsChunk: boolean };
| { kind: "chunk"; chunkId: number; isDocsChunk: boolean }
| { kind: "line"; documentId: number; startLine: number; endLine: number };
/** Output of `parseTextWithCitations` — interleaved text + citation tokens. */
export type ParsedSegment = string | CitationToken;
@ -95,7 +99,15 @@ export function parseTextWithCitations(text: string, urlMap: CitationUrlMap): Pa
const captured = match[1];
if (captured.startsWith("http://") || captured.startsWith("https://")) {
const lineMatch = LINE_CITATION_REGEX.exec(captured);
if (lineMatch) {
segments.push({
kind: "line",
documentId: Number.parseInt(lineMatch[1], 10),
startLine: Number.parseInt(lineMatch[2], 10),
endLine: Number.parseInt(lineMatch[3], 10),
});
} else if (captured.startsWith("http://") || captured.startsWith("https://")) {
segments.push({ kind: "url", url: captured.trim() });
} else if (captured.startsWith("urlcite")) {
const url = urlMap.get(captured);