From 5f341bdd2fa35b0184f5522f6fc2d5543b945f28 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 19 Jun 2026 17:37:41 +0200 Subject: [PATCH] feat: parse and render kb line citations --- .../assistant-ui/inline-citation.tsx | 46 +++++++++++++++++++ .../citations/citation-renderer.tsx | 12 ++++- .../editor/plugins/citation-kit.tsx | 35 +++++++++++--- .../lib/citations/citation-parser.ts | 18 ++++++-- 4 files changed, 101 insertions(+), 10 deletions(-) diff --git a/surfsense_web/components/assistant-ui/inline-citation.tsx b/surfsense_web/components/assistant-ui/inline-citation.tsx index 59a10739c..28f5212ae 100644 --- a/surfsense_web/components/assistant-ui/inline-citation.tsx +++ b/surfsense_web/components/assistant-ui/inline-citation.tsx @@ -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#L-]`). 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 = ({ 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 ( + + ); +}; + import { tryGetHostname } from "@/lib/url"; interface UrlCitationProps { diff --git a/surfsense_web/components/citations/citation-renderer.tsx b/surfsense_web/components/citations/citation-renderer.tsx index f2de4b27d..b0ab13f84 100644 --- a/surfsense_web/components/citations/citation-renderer.tsx +++ b/surfsense_web/components/citations/citation-renderer.tsx @@ -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 ; } + if (token.kind === "line") { + return ( + + ); + } return ( > = ({ element, }) => { const isUrl = element.kind === "url"; + const isLine = + element.kind === "line" && + element.documentId !== undefined && + element.startLine !== undefined && + element.endLine !== undefined; return ( {isUrl && element.url ? ( + ) : isLine ? ( + ) : element.chunkId !== undefined ? ( ) : null} @@ -97,10 +112,7 @@ function copyMarks(textNode: SlateText): Record { 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", diff --git a/surfsense_web/lib/citations/citation-parser.ts b/surfsense_web/lib/citations/citation-parser.ts index 533c644c2..0d320956f 100644 --- a/surfsense_web/lib/citations/citation-parser.ts +++ b/surfsense_web/lib/citations/citation-parser.ts @@ -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#L-`. */ +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);