Merge pull request #1619 from CREDO23/feat-run-citations

[Feat] Cite scraper runs as verifiable sources in chat
This commit is contained in:
Rohan Verma 2026-07-22 14:53:45 -07:00 committed by GitHub
commit ca4f231577
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 339 additions and 30 deletions

View file

@ -1,14 +1,19 @@
import { atom } from "jotai";
import { atom, type Getter, type Setter } from "jotai";
import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right-panel.atom";
/** The source the citation panel is showing: a KB chunk or a scraper run. */
export type CitationTarget =
| { kind: "chunk"; chunkId: number }
| { kind: "run"; runId: string };
interface CitationPanelState {
isOpen: boolean;
chunkId: number | null;
target: CitationTarget | null;
}
const initialState: CitationPanelState = {
isOpen: false,
chunkId: null,
target: null,
};
export const citationPanelAtom = atom<CitationPanelState>(initialState);
@ -17,16 +22,21 @@ export const citationPanelOpenAtom = atom((get) => get(citationPanelAtom).isOpen
const preCitationCollapsedAtom = atom<boolean | null>(null);
export const openCitationPanelAtom = atom(null, (get, set, payload: { chunkId: number }) => {
function openWithTarget(get: Getter, set: Setter, target: CitationTarget) {
if (!get(citationPanelAtom).isOpen) {
set(preCitationCollapsedAtom, get(rightPanelCollapsedAtom));
}
set(citationPanelAtom, {
isOpen: true,
chunkId: payload.chunkId,
});
set(citationPanelAtom, { isOpen: true, target });
set(rightPanelTabAtom, "citation");
set(rightPanelCollapsedAtom, false);
}
export const openCitationPanelAtom = atom(null, (get, set, payload: { chunkId: number }) => {
openWithTarget(get, set, { kind: "chunk", chunkId: payload.chunkId });
});
export const openRunCitationPanelAtom = atom(null, (get, set, payload: { runId: string }) => {
openWithTarget(get, set, { kind: "run", runId: payload.runId });
});
export const closeCitationPanelAtom = atom(null, (get, set) => {

View file

@ -2,6 +2,7 @@
import type { ReactNode } from "react";
import { InlineCitation, UrlCitation } from "@/components/assistant-ui/inline-citation";
import { RunCitation } from "@/components/citations/run-citation";
import {
type CitationToken,
type CitationUrlMap,
@ -21,6 +22,9 @@ export function renderCitationToken(token: CitationToken, ordinalKey: number): R
if (token.kind === "url") {
return <UrlCitation key={`citation-url-${ordinalKey}`} url={token.url} />;
}
if (token.kind === "run") {
return <RunCitation key={`citation-run-${token.runId}-${ordinalKey}`} runId={token.runId} />;
}
return (
<InlineCitation
key={`citation-${token.isDocsChunk ? "doc-" : ""}${token.chunkId}-${ordinalKey}`}

View file

@ -0,0 +1,47 @@
"use client";
import { XIcon } from "lucide-react";
import { useParams } from "next/navigation";
import type { FC } from "react";
import { RunDetail } from "@/app/dashboard/[workspace_id]/playground/components/run-detail";
import { Button } from "@/components/ui/button";
/** Right-panel viewer for a cited scraper run. `runId` is the `run_<uuid>` handle. */
export const RunCitationPanelContent: FC<{ runId: string; onClose?: () => void }> = ({
runId,
onClose,
}) => {
const params = useParams();
const rawWorkspaceId = Array.isArray(params?.workspace_id)
? params.workspace_id[0]
: params?.workspace_id;
const workspaceId = Number(rawWorkspaceId);
const scraperRunId = runId.replace(/^run_/, "");
return (
<>
<div className="shrink-0 flex h-12 items-center justify-between px-3 border-b">
<h2 className="select-none text-lg font-semibold">Scraper run</h2>
{onClose && (
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="h-8 w-8 rounded-full shrink-0 text-muted-foreground hover:text-accent-foreground"
>
<XIcon className="h-4 w-4" />
<span className="sr-only">Close run panel</span>
</Button>
)}
</div>
<div className="flex-1 overflow-y-auto">
{Number.isFinite(workspaceId) ? (
<RunDetail workspaceId={workspaceId} runId={scraperRunId} />
) : (
<p className="p-4 text-sm text-muted-foreground">Open a workspace to view this run.</p>
)}
</div>
</>
);
};

View file

@ -0,0 +1,31 @@
"use client";
import { useSetAtom } from "jotai";
import { Database } from "lucide-react";
import type { FC } from "react";
import { openRunCitationPanelAtom } from "@/atoms/citation/citation-panel.atom";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
/** Inline citation badge for a scraper run; opens the run in the citation panel. */
export const RunCitation: FC<{ runId: string }> = ({ runId }) => {
const openRunPanel = useSetAtom(openRunCitationPanelAtom);
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
onClick={() => openRunPanel({ runId })}
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"
aria-label="See where this came from"
>
<Database className="size-3" />
Source
</Button>
</TooltipTrigger>
<TooltipContent>See where this came from</TooltipContent>
</Tooltip>
);
};

View file

@ -35,6 +35,14 @@ const CitationPanelContent = dynamic(
{ ssr: false, loading: () => null }
);
const RunCitationPanelContent = dynamic(
() =>
import("@/components/citations/run-citation-panel").then((m) => ({
default: m.RunCitationPanelContent,
})),
{ ssr: false, loading: () => null }
);
const HitlEditPanelContent = dynamic(
() =>
import("@/features/chat-messages/hitl").then((m) => ({
@ -89,7 +97,7 @@ export function RightPanelToggleButton({
? !!editorState.memoryScope
: !!editorState.localFilePath);
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
const citationOpen = citationState.isOpen && citationState.chunkId != null;
const citationOpen = citationState.isOpen && citationState.target != null;
const hasContent = reportOpen || editorOpen || hitlEditOpen || citationOpen || artifactsOpen;
const label = collapsed ? "Expand panel" : "Collapse panel";
@ -141,7 +149,7 @@ export function RightPanelExpandButton() {
? !!editorState.memoryScope
: !!editorState.localFilePath);
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
const citationOpen = citationState.isOpen && citationState.chunkId != null;
const citationOpen = citationState.isOpen && citationState.target != null;
const hasContent = reportOpen || editorOpen || hitlEditOpen || citationOpen || artifactsOpen;
if (!collapsed || !hasContent) return null;
@ -212,7 +220,7 @@ export function RightPanel({ showTopBorder = false }: RightPanelProps) {
? !!editorState.memoryScope
: !!editorState.localFilePath);
const hitlEditOpen = hitlEditState.isOpen && !!hitlEditState.onSave;
const citationOpen = citationState.isOpen && citationState.chunkId != null;
const citationOpen = citationState.isOpen && citationState.target != null;
useEffect(() => {
if (!reportOpen && !editorOpen && !hitlEditOpen && !citationOpen && !artifactsOpen) return;
@ -306,9 +314,19 @@ export function RightPanel({ showTopBorder = false }: RightPanelProps) {
/>
</div>
)}
{effectiveTab === "citation" && citationOpen && citationState.chunkId != null && (
{effectiveTab === "citation" && citationOpen && citationState.target && (
<div className="h-full flex flex-col">
<CitationPanelContent chunkId={citationState.chunkId} onClose={closeCitation} />
{citationState.target.kind === "run" ? (
<RunCitationPanelContent
runId={citationState.target.runId}
onClose={closeCitation}
/>
) : (
<CitationPanelContent
chunkId={citationState.target.chunkId}
onClose={closeCitation}
/>
)}
</div>
)}
{effectiveTab === "artifacts" && artifactsOpen && (

View file

@ -0,0 +1,35 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { parseTextWithCitations } from "./citation-parser";
// Run with: pnpm exec tsx --test lib/citations/citation-parser.test.ts
const NO_URLS = new Map<string, string>();
test("parses a scraper-run handle into a run token", () => {
const segments = parseTextWithCitations(
"Price is $19 [citation:run_1b2c3d4e-5f60-7081-9abc-def012345678].",
NO_URLS
);
assert.deepEqual(segments, [
"Price is $19 ",
{ kind: "run", runId: "run_1b2c3d4e-5f60-7081-9abc-def012345678" },
".",
]);
});
test("run handles do not collide with numeric chunk citations", () => {
const segments = parseTextWithCitations(
"chunk [citation:42] vs run [citation:run_ab-12].",
NO_URLS
);
assert.deepEqual(segments, [
"chunk ",
{ kind: "chunk", chunkId: 42, isDocsChunk: false },
" vs run ",
{ kind: "run", runId: "run_ab-12" },
".",
]);
});

View file

@ -11,18 +11,20 @@ import { FENCED_OR_INLINE_CODE } from "@/lib/markdown/code-regions";
/**
* Matches `[citation:...]` with numeric IDs (incl. negative, doc- prefix,
* comma-separated), URL-based IDs from live web search, or `urlciteN`
* placeholders produced by `preprocessCitationMarkdown`.
* comma-separated), URL-based IDs from live web search, `urlciteN`
* placeholders produced by `preprocessCitationMarkdown`, or a scraper-run
* handle (`run_<uuid>`).
*
* Also matches Chinese brackets and zero-width spaces that LLMs
* 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+|run_[0-9a-fA-F-]+|(?:doc-)?-?\d+(?:\s*,\s*(?:doc-)?-?\d+)*)\s*\u200B?[\]】]/g;
/** A single parsed citation reference. */
export type CitationToken =
| { kind: "url"; url: string }
| { kind: "run"; runId: string }
| { kind: "chunk"; chunkId: number; isDocsChunk: boolean };
/** Output of `parseTextWithCitations` — interleaved text + citation tokens. */
@ -102,6 +104,8 @@ export function parseTextWithCitations(text: string, urlMap: CitationUrlMap): Pa
if (url) {
segments.push({ kind: "url", url });
}
} else if (captured.startsWith("run_")) {
segments.push({ kind: "run", runId: captured.trim() });
} else {
const rawIds = captured.split(",").map((s) => s.trim());
for (const rawId of rawIds) {