Merge remote-tracking branch 'upstream/dev' into feat/api-key

This commit is contained in:
Anish Sarkar 2026-06-20 10:50:03 +05:30
commit fd31ac34fd
61 changed files with 1984 additions and 435 deletions

View file

@ -270,6 +270,12 @@ button {
contain-intrinsic-size: 0 40px;
}
/* Monaco whole-line highlight for a cited source span (Phase E). */
.citation-line-highlight {
background-color: color-mix(in srgb, var(--primary) 16%, transparent);
box-shadow: inset 2px 0 0 0 var(--primary);
}
@source "../node_modules/@llamaindex/chat-ui/**/*.{ts,tsx}";
@source "../node_modules/streamdown/dist/*.js";
@source "../node_modules/@streamdown/code/dist/*.js";

View file

@ -1,6 +1,11 @@
import { atom } from "jotai";
import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right-panel.atom";
export interface EditorLineRange {
start: number;
end: number;
}
interface EditorPanelState {
isOpen: boolean;
kind: "document" | "local_file" | "memory";
@ -9,6 +14,10 @@ interface EditorPanelState {
searchSpaceId: number | null;
memoryScope: "user" | "team" | null;
title: string | null;
// Citation line anchor: when set, the editor opens the raw source view
// scrolled to and highlighting this 1-based inclusive line range.
highlightLines: EditorLineRange | null;
forceSourceView: boolean;
}
const initialState: EditorPanelState = {
@ -19,6 +28,8 @@ const initialState: EditorPanelState = {
searchSpaceId: null,
memoryScope: null,
title: null,
highlightLines: null,
forceSourceView: false,
};
export const editorPanelAtom = atom<EditorPanelState>(initialState);
@ -33,7 +44,14 @@ export const openEditorPanelAtom = atom(
get,
set,
payload:
| { documentId: number; searchSpaceId: number; title?: string; kind?: "document" }
| {
documentId: number;
searchSpaceId: number;
title?: string;
kind?: "document";
highlightLines?: EditorLineRange | null;
forceSourceView?: boolean;
}
| {
kind: "local_file";
localFilePath: string;
@ -59,6 +77,8 @@ export const openEditorPanelAtom = atom(
searchSpaceId: payload.searchSpaceId ?? null,
memoryScope: null,
title: payload.title ?? null,
highlightLines: null,
forceSourceView: false,
});
set(rightPanelTabAtom, "editor");
set(rightPanelCollapsedAtom, false);
@ -73,6 +93,8 @@ export const openEditorPanelAtom = atom(
searchSpaceId: payload.searchSpaceId ?? null,
memoryScope: payload.memoryScope,
title: payload.title ?? null,
highlightLines: null,
forceSourceView: false,
});
set(rightPanelTabAtom, "editor");
set(rightPanelCollapsedAtom, false);
@ -86,6 +108,8 @@ export const openEditorPanelAtom = atom(
searchSpaceId: payload.searchSpaceId,
memoryScope: null,
title: payload.title ?? null,
highlightLines: payload.highlightLines ?? null,
forceSourceView: payload.forceSourceView ?? false,
});
set(rightPanelTabAtom, "editor");
set(rightPanelCollapsedAtom, false);

View file

@ -27,8 +27,8 @@ export interface ChatViewportProps {
export const ChatViewport: FC<ChatViewportProps> = ({ children, footer }) => (
<ThreadPrimitive.Viewport
turnAnchor="top"
autoScroll={false}
scrollToBottomOnRunStart={false}
autoScroll
scrollToBottomOnRunStart
scrollToBottomOnInitialize
scrollToBottomOnThreadSwitch
className="aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 scroll-smooth"

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

@ -110,7 +110,7 @@ const MarkdownTextImpl = () => {
return (
<CitationUrlMapContext.Provider value={urlMapRef}>
<MarkdownTextPrimitive
smooth={false}
smooth
remarkPlugins={[remarkGfm, [remarkMath, { singleDollarTextMath: false }]]}
rehypePlugins={[rehypeKatex]}
className="aui-md"

View file

@ -1577,7 +1577,7 @@ const ComposerAction: FC<ComposerActionProps> = ({
<span>Select a model</span>
</div>
)}
<div className="flex items-center gap-2">
<div className="ml-auto flex min-w-0 shrink-0 items-center gap-2">
<ChatHeader
searchSpaceId={searchSpaceId}
className="h-9 max-w-[44vw] px-2 sm:max-w-[220px] sm:px-3"
@ -1600,7 +1600,7 @@ const ComposerAction: FC<ComposerActionProps> = ({
variant="default"
size="icon"
className={cn(
"aui-composer-send size-9 rounded-full",
"aui-composer-send size-9 shrink-0 rounded-full",
isSendDisabled && "cursor-not-allowed opacity-50"
)}
aria-label="Send message"
@ -1617,7 +1617,7 @@ const ComposerAction: FC<ComposerActionProps> = ({
type="button"
variant="default"
size="icon"
className="aui-composer-cancel size-9 rounded-full"
className="aui-composer-cancel size-9 shrink-0 rounded-full"
aria-label="Stop generating"
>
<SquareIcon className="aui-composer-cancel-icon size-3.5 fill-current" />

View file

@ -46,6 +46,13 @@ export const CitationPanelContent: FC<CitationPanelContentProps> = ({
const cited = useMemo(() => data?.chunks.find((c) => c.id === chunkId) ?? null, [data, chunkId]);
const citedLineLabel = useMemo(() => {
const start = data?.cited_start_line;
const end = data?.cited_end_line;
if (start == null || end == null) return null;
return start === end ? `Line ${start}` : `Lines ${start}${end}`;
}, [data?.cited_start_line, data?.cited_end_line]);
const totalChunks = data?.total_chunks ?? data?.chunks.length ?? 0;
const startIndex = data?.chunk_start_index ?? 0;
const hasMoreAbove = startIndex > 0;
@ -75,10 +82,15 @@ export const CitationPanelContent: FC<CitationPanelContentProps> = ({
const handleOpenFullDocument = () => {
if (!data) return;
const hasLineAnchor = data.cited_start_line != null && data.cited_end_line != null;
openEditorPanel({
documentId: data.id,
searchSpaceId: data.search_space_id,
title: data.title,
highlightLines: hasLineAnchor
? { start: data.cited_start_line as number, end: data.cited_end_line as number }
: null,
forceSourceView: hasLineAnchor,
});
};
@ -110,6 +122,7 @@ export const CitationPanelContent: FC<CitationPanelContentProps> = ({
</p>
</div>
<div className="flex items-center gap-3 shrink-0 text-[11px] text-muted-foreground">
{citedLineLabel && <span>{citedLineLabel}</span>}
{totalChunks > 0 && <span>{totalChunks} chunks</span>}
{!isLoading && !error && data && (
<Button
@ -172,7 +185,9 @@ export const CitationPanelContent: FC<CitationPanelContentProps> = ({
Chunk #{chunk.id}
</span>
{isCited && (
<span className="text-[11px] font-semibold text-primary">Cited chunk</span>
<span className="text-[11px] font-semibold text-primary">
{citedLineLabel ? `Cited chunk · ${citedLineLabel}` : "Cited chunk"}
</span>
)}
</div>
<div className="text-sm">

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

@ -149,6 +149,8 @@ export function EditorPanelContent({
searchSpaceId,
title,
onClose,
highlightLines = null,
forceSourceView = false,
}: {
kind?: "document" | "local_file" | "memory";
documentId?: number;
@ -157,6 +159,8 @@ export function EditorPanelContent({
searchSpaceId?: number;
title: string | null;
onClose?: () => void;
highlightLines?: { start: number; end: number } | null;
forceSourceView?: boolean;
}) {
const electronAPI = useElectronAPI();
const [editorDoc, setEditorDoc] = useState<EditorContent | null>(null);
@ -205,7 +209,7 @@ export function EditorPanelContent({
const isLargeDocument = docSizeBytes > plateMaxBytes || docLineCount > plateMaxLines;
const viewerMode: ViewerMode = isMemoryMode
? "plate"
: editorDoc?.viewer_mode === "monaco" || isLargeDocument
: editorDoc?.viewer_mode === "monaco" || isLargeDocument || forceSourceView
? "monaco"
: "plate";
@ -828,6 +832,7 @@ export function EditorPanelContent({
value={editorDoc.source_markdown}
readOnly
onChange={() => {}}
highlightLines={highlightLines}
/>
</div>
</div>
@ -918,6 +923,8 @@ function DesktopEditorPanel() {
searchSpaceId={panelState.searchSpaceId ?? undefined}
title={panelState.title}
onClose={closePanel}
highlightLines={panelState.highlightLines}
forceSourceView={panelState.forceSourceView}
/>
</div>
);
@ -957,6 +964,8 @@ function MobileEditorDrawer() {
memoryScope={panelState.memoryScope ?? undefined}
searchSpaceId={panelState.searchSpaceId ?? undefined}
title={panelState.title}
highlightLines={panelState.highlightLines}
forceSourceView={panelState.forceSourceView}
/>
</div>
</DrawerContent>

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

@ -2,7 +2,7 @@
import dynamic from "next/dynamic";
import { useTheme } from "next-themes";
import { useEffect, useRef } from "react";
import { useCallback, useEffect, useRef } from "react";
import { Spinner } from "@/components/ui/spinner";
const MonacoEditor = dynamic(() => import("@monaco-editor/react"), {
@ -17,6 +17,8 @@ interface SourceCodeEditorProps {
readOnly?: boolean;
fontSize?: number;
onSave?: () => Promise<void> | void;
/** 1-based inclusive line range to reveal and highlight (e.g. a citation). */
highlightLines?: { start: number; end: number } | null;
}
export function SourceCodeEditor({
@ -27,10 +29,45 @@ export function SourceCodeEditor({
readOnly = false,
fontSize = 12,
onSave,
highlightLines = null,
}: SourceCodeEditorProps) {
const { resolvedTheme } = useTheme();
const onSaveRef = useRef(onSave);
const monacoRef = useRef<any>(null);
const editorRef = useRef<any>(null);
const decorationsRef = useRef<any>(null);
const highlightLinesRef = useRef(highlightLines);
highlightLinesRef.current = highlightLines;
const applyHighlight = useCallback(() => {
const editor = editorRef.current;
const monaco = monacoRef.current;
if (!editor || !monaco) return;
if (decorationsRef.current) {
decorationsRef.current.clear();
decorationsRef.current = null;
}
const range = highlightLinesRef.current;
if (!range) return;
const lineCount = editor.getModel()?.getLineCount() ?? range.end;
const start = Math.min(Math.max(1, Math.floor(range.start)), lineCount);
const end = Math.min(Math.max(start, Math.floor(range.end)), lineCount);
try {
decorationsRef.current = editor.createDecorationsCollection([
{
range: new monaco.Range(start, 1, end, 1),
options: { isWholeLine: true, className: "citation-line-highlight" },
},
]);
} catch {
// Decoration failure must not block the reveal below.
}
editor.revealLinesInCenter(start, end, monaco.editor.ScrollType.Immediate);
}, []);
useEffect(() => {
applyHighlight();
}, [applyHighlight, highlightLines?.start, highlightLines?.end]);
const normalizedModelPath = (() => {
const raw = (path || "local-file.txt").trim();
const withLeadingSlash = raw.startsWith("/") ? raw : `/${raw}`;
@ -104,7 +141,16 @@ export function SourceCodeEditor({
}}
onMount={(editor, monaco) => {
monacoRef.current = monaco;
editorRef.current = editor;
applySidebarTheme(monaco);
// Reveal now, then once more after the first layout settles:
// the panel slide-in animation means the editor often has no
// usable viewport height on the initial frame.
applyHighlight();
const layoutSub = editor.onDidLayoutChange(() => {
applyHighlight();
layoutSub.dispose();
});
if (!isManualSaveEnabled) return;
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => {
void onSaveRef.current?.();

View file

@ -12,6 +12,7 @@ import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { closeHitlEditPanelAtom, hitlEditPanelAtom } from "@/features/chat-messages/hitl";
import { useMediaQuery } from "@/hooks/use-media-query";
import { cn } from "@/lib/utils";
import { DocumentsSidebar } from "../sidebar";
@ -196,6 +197,9 @@ export function RightPanel({
const citationState = useAtomValue(citationPanelAtom);
const closeCitation = useSetAtom(closeCitationPanelAtom);
const [collapsed, setCollapsed] = useAtom(rightPanelCollapsedAtom);
// Desktop-only surface; mobile uses the dedicated Mobile* drawers. Without
// this guard both render together and two editors fight over one model.
const isDesktop = useMediaQuery("(min-width: 1024px)");
const documentsOpen = documentsPanel?.open ?? false;
const reportOpen = reportState.isOpen && !!reportState.reportId;
@ -267,7 +271,7 @@ export function RightPanel({
<CollapseButton onClick={() => setCollapsed(true)} />
) : null;
if (!isVisible) return null;
if (!isVisible || !isDesktop) return null;
return (
<aside
@ -308,6 +312,8 @@ export function RightPanel({
searchSpaceId={editorState.searchSpaceId ?? undefined}
title={editorState.title}
onClose={closeEditor}
highlightLines={editorState.highlightLines}
forceSourceView={editorState.forceSourceView}
/>
</div>
)}

View file

@ -11,13 +11,13 @@ interface ChatHeaderProps {
export function ChatHeader({ searchSpaceId, className, onChatModelSelected }: ChatHeaderProps) {
return (
<div className="flex items-center gap-2">
<div className="flex min-w-0 shrink-0 items-center gap-2">
<ModelSelector
searchSpaceId={searchSpaceId}
className={className}
onChatModelSelected={onChatModelSelected}
/>
<ImageModelSelector searchSpaceId={searchSpaceId} className={className} />
<ImageModelSelector searchSpaceId={searchSpaceId} className={className} mobileIconOnly />
</div>
);
}

View file

@ -33,6 +33,7 @@ import { providerDisplay } from "../settings/model-connections/provider-metadata
interface ImageModelSelectorProps {
searchSpaceId: number;
className?: string;
mobileIconOnly?: boolean;
}
type ImageModel = ModelRead & {
@ -95,7 +96,11 @@ function groupedModels(models: ImageModel[]) {
}, {});
}
export function ImageModelSelector({ searchSpaceId, className }: ImageModelSelectorProps) {
export function ImageModelSelector({
searchSpaceId,
className,
mobileIconOnly = false,
}: ImageModelSelectorProps) {
const router = useRouter();
const isMobile = useIsMobile();
const [open, setOpen] = useState(false);
@ -126,6 +131,7 @@ export function ImageModelSelector({ searchSpaceId, className }: ImageModelSelec
const groups = useMemo(() => groupedModels(visibleImageModels), [visibleImageModels]);
const loading = globalLoading || connectionsLoading;
const hasSearchQuery = search.trim().length > 0;
const showIconOnlyTrigger = isMobile && mobileIconOnly;
function handleOpenChange(nextOpen: boolean) {
if (!nextOpen) setSearch("");
@ -252,12 +258,14 @@ export function ImageModelSelector({ searchSpaceId, className }: ImageModelSelec
type="button"
variant="ghost"
size="sm"
aria-label="Select image model"
className={cn(
"h-8 min-w-0 gap-2 rounded-md px-3 text-muted-foreground transition-colors",
"select-none",
"hover:bg-foreground/10 hover:text-foreground",
"data-[state=open]:bg-foreground/10 data-[state=open]:text-foreground",
className
className,
showIconOnlyTrigger && "h-9 w-auto shrink-0 justify-center gap-1 px-2"
)}
>
{selected ? (
@ -265,9 +273,11 @@ export function ImageModelSelector({ searchSpaceId, className }: ImageModelSelec
) : (
<ImagePlus className="size-4 shrink-0" />
)}
<span className="min-w-0 flex-1 truncate text-sm">
{selected ? modelName(selected) : "Auto"}
</span>
{showIconOnlyTrigger ? null : (
<span className="min-w-0 flex-1 truncate text-sm">
{selected ? modelName(selected) : "Auto"}
</span>
)}
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
</Button>
);

View file

@ -131,6 +131,7 @@ export function ModelSelector({
const groups = useMemo(() => groupedModels(visibleChatModels), [visibleChatModels]);
const loading = globalLoading || connectionsLoading;
const hasSearchQuery = search.trim().length > 0;
const showIconOnlyTrigger = isMobile;
function handleOpenChange(nextOpen: boolean) {
if (!nextOpen) setSearch("");
@ -276,15 +277,18 @@ export function ModelSelector({
"select-none",
"hover:bg-foreground/10 hover:text-foreground",
"data-[state=open]:bg-foreground/10 data-[state=open]:text-foreground",
className
className,
showIconOnlyTrigger && "h-9 w-auto shrink-0 justify-center gap-1 px-2"
)}
>
{selected
? getProviderIcon(selected.provider, { className: "size-4 shrink-0" })
: getProviderIcon(AUTO_PROVIDER_ICON_KEY, { className: "size-4 shrink-0" })}
<span className="min-w-0 flex-1 truncate text-sm">
{selected ? modelName(selected) : "Auto"}
</span>
{showIconOnlyTrigger ? null : (
<span className="min-w-0 flex-1 truncate text-sm">
{selected ? modelName(selected) : "Auto"}
</span>
)}
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
</Button>
);

View file

@ -70,10 +70,15 @@ export const documentWithChunks = document.extend({
id: z.number(),
content: z.string(),
created_at: z.string(),
start_char: z.number().nullable().optional(),
end_char: z.number().nullable().optional(),
})
),
total_chunks: z.number().optional().default(0),
chunk_start_index: z.number().optional().default(0),
// 1-based inclusive line range of the cited chunk within source_markdown.
cited_start_line: z.number().nullable().optional(),
cited_end_line: z.number().nullable().optional(),
});
/**

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);