From 11d6f71eb32b87d0d4449e6cf4753601cd9d05e2 Mon Sep 17 00:00:00 2001 From: suguanYang Date: Thu, 20 Aug 2026 16:09:45 +0800 Subject: [PATCH 1/3] feat: render demo seed answers with citation chips Map catalog page metadata onto demo chat citations and keep [[cite:n]] in the answer so seed Tesla/SpaceX turns match live {title}/pN chips. Co-authored-by: Cursor --- src/domains/demo/view.test.ts | 104 +++++++++++++++++++++++++ src/domains/demo/view.ts | 19 ++++- src/integrations/knowhere-demo.test.ts | 62 +++++++++++++++ src/integrations/knowhere-demo.ts | 15 ++++ 4 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 src/domains/demo/view.test.ts diff --git a/src/domains/demo/view.test.ts b/src/domains/demo/view.test.ts new file mode 100644 index 00000000..04d2c7c4 --- /dev/null +++ b/src/domains/demo/view.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest" + +import { demoView } from "@/domains/demo/view" +import type { DemoCatalog, DemoCitation, DemoSource } from "@/integrations/knowhere-demo" + +describe("demoView.toChatMessages", () => { + it("embeds cite markers and page metadata so demo answers render title/pN chips", () => { + const messages = demoView.toChatMessages( + makeCatalog({ + answer: + "Tesla entered an agreement to invest about $2 billion in xAI. [[cite:1]]", + citations: [makeCitation({ pageCitationPageNumber: 12 })], + }), + ) + const assistant = messages.find((message) => message.role === "assistant") + + expect(assistant?.content).toContain("[[cite:1]]") + expect(assistant?.citations).toEqual([ + expect.objectContaining({ + pageCitationPageNumber: 12, + pageCitationAssetUrl: + "/api/demo-sources/demo-tsla-q4-2025/assets/page_citation_assets/page-12.png", + source: expect.objectContaining({ + sourceFileName: "TSLA-Q4-2025-Update.pdf", + sectionPath: "TSLA-Q4-2025-Update.pdf/OTHER UPDATES", + }), + }), + ]) + }) + + it("appends cite markers when the catalog answer still omits them", () => { + const messages = demoView.toChatMessages( + makeCatalog({ + answer: "Tesla entered an agreement to invest about $2 billion in xAI.", + citations: [makeCitation({ pageCitationPageNumber: 12 })], + }), + ) + const assistant = messages.find((message) => message.role === "assistant") + + expect(assistant?.content).toBe( + "Tesla entered an agreement to invest about $2 billion in xAI. [[cite:1]]", + ) + }) +}) + +function makeCatalog(input: { + readonly answer: string + readonly citations: readonly DemoCitation[] +}): DemoCatalog { + return { + officialLibrary: { categories: [], sources: [] }, + sources: [makeDemoSource(input)], + } +} + +function makeDemoSource(input: { + readonly answer: string + readonly citations: readonly DemoCitation[] +}): DemoSource { + return { + demoSourceId: "demo-tsla-q4-2025", + canonicalDocumentId: "demo-doc-tsla-q4-2025", + title: "TSLA-Q4-2025-Update.pdf", + mimeType: "application/pdf", + sizeBytes: 1024, + status: "ready", + chunkCount: 71, + originalFile: { + url: "/api/v1/demo/sources/demo-tsla-q4-2025/original", + mimeType: "application/pdf", + sizeBytes: 1024, + canDownload: false, + }, + examples: [ + { + id: "demo-tsla-q4-2025-xai", + question: "What does the document say about Tesla's xAI investment?", + answer: input.answer, + citations: input.citations, + }, + ], + } +} + +function makeCitation( + overrides: Partial = {}, +): DemoCitation { + return { + demoSourceId: "demo-tsla-q4-2025", + canonicalDocumentId: "demo-doc-tsla-q4-2025", + canonicalChunkId: "demo-tsla-q4-2025:chunk", + chunkId: "chunk", + chunkType: "page", + content: "Tesla entered into an agreement to invest approximately", + pageCitationAssetUrl: + "/api/demo-sources/demo-tsla-q4-2025/assets/page_citation_assets/page-12.png", + source: { + documentId: "demo-doc-tsla-q4-2025", + sourceFileName: "TSLA-Q4-2025-Update.pdf", + sectionPath: "TSLA-Q4-2025-Update.pdf/OTHER UPDATES", + }, + ...overrides, + } +} diff --git a/src/domains/demo/view.ts b/src/domains/demo/view.ts index 0b9a39b9..fa35f9e3 100644 --- a/src/domains/demo/view.ts +++ b/src/domains/demo/view.ts @@ -51,7 +51,7 @@ function toChatMessages(catalog: DemoCatalog): ChatMessageView[] { { id: `${example.id}-assistant`, role: "assistant", - content: example.answer, + content: withCitationMarkers(example.answer, example.citations.length), citations: example.citations.map((citation) => ({ chunkType: citation.chunkType, score: 0.95, @@ -59,6 +59,12 @@ function toChatMessages(catalog: DemoCatalog): ChatMessageView[] { ...(citation.description ? { description: citation.description } : {}), + ...(citation.pageCitationPageNumber + ? { pageCitationPageNumber: citation.pageCitationPageNumber } + : {}), + ...(citation.pageCitationAssetUrl + ? { pageCitationAssetUrl: citation.pageCitationAssetUrl } + : {}), source: { documentId: citation.canonicalDocumentId, sourceFileName: citation.source.sourceFileName, @@ -87,3 +93,14 @@ function toParsedChunkView( sourceTitle: source.title, }) } + +const citeMarkerPattern = /\[\[cite:\d+\]\]/ + +function withCitationMarkers(answer: string, citationCount: number): string { + if (citationCount < 1 || citeMarkerPattern.test(answer)) return answer + const markers = Array.from( + { length: citationCount }, + (_, index) => `[[cite:${index + 1}]]`, + ).join(" ") + return `${answer.trimEnd()} ${markers}` +} diff --git a/src/integrations/knowhere-demo.test.ts b/src/integrations/knowhere-demo.test.ts index fe4f7e91..be474594 100644 --- a/src/integrations/knowhere-demo.test.ts +++ b/src/integrations/knowhere-demo.test.ts @@ -252,6 +252,68 @@ describe("knowhereDemoApi", () => { chunkCount: 922, }) }) + + it("maps demo example citation page metadata onto Notebook citation fields", async () => { + globalThis.fetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + sources: [ + { + demo_source_id: "demo-tsla-q4-2025", + canonical_document_id: "demo-doc-tsla-q4-2025", + title: "TSLA-Q4-2025-Update.pdf", + mime_type: "application/pdf", + size_bytes: 1024, + status: "ready", + chunk_count: 71, + original_file: { + url: "/api/v1/demo/sources/demo-tsla-q4-2025/original", + mime_type: "application/pdf", + size_bytes: 1024, + can_download: false, + }, + examples: [ + { + id: "demo-tsla-q4-2025-xai", + question: "What does the document say about Tesla's xAI investment?", + answer: + "Tesla entered an agreement to invest about $2 billion. [[cite:1]]", + citations: [ + { + demo_source_id: "demo-tsla-q4-2025", + canonical_document_id: "demo-doc-tsla-q4-2025", + canonical_chunk_id: "demo-tsla-q4-2025:chunk", + chunk_id: "chunk", + chunk_type: "page", + content: "Tesla entered into an agreement", + page_citation_page_number: 12, + page_citation_asset_url: + "/api/v1/demo/sources/demo-tsla-q4-2025/assets/page_citation_assets/page-12.png", + source: { + document_id: "demo-doc-tsla-q4-2025", + source_file_name: "TSLA-Q4-2025-Update.pdf", + section_path: "TSLA-Q4-2025-Update.pdf/OTHER UPDATES", + }, + }, + ], + }, + ], + }, + ], + official_library: { categories: [], sources: [] }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ) + + const catalog = await knowhereDemoApi.fetchCatalog() + + expect(catalog.sources[0]?.examples[0]?.citations[0]).toMatchObject({ + pageCitationPageNumber: 12, + pageCitationAssetUrl: + "/api/demo-sources/demo-tsla-q4-2025/assets/page_citation_assets/page-12.png", + }) + }) }) function restoreEnv(key: string, value: string | undefined): void { diff --git a/src/integrations/knowhere-demo.ts b/src/integrations/knowhere-demo.ts index f4342a59..a63dcacf 100644 --- a/src/integrations/knowhere-demo.ts +++ b/src/integrations/knowhere-demo.ts @@ -11,6 +11,8 @@ export type DemoCitation = { readonly chunkType: string readonly content: string readonly description?: string + readonly pageCitationPageNumber?: number + readonly pageCitationAssetUrl?: string readonly source: { readonly documentId: string readonly sourceFileName: string @@ -155,6 +157,8 @@ type DemoCitationResponse = { readonly chunk_type?: unknown readonly content?: unknown readonly description?: unknown + readonly page_citation_page_number?: unknown + readonly page_citation_asset_url?: unknown readonly source?: { readonly document_id?: unknown readonly source_file_name?: unknown @@ -442,6 +446,13 @@ function toDemoExample(example: DemoExampleResponse): DemoExample { function toDemoCitation(citation: DemoCitationResponse): DemoCitation { const source = citation.source ?? {} const description = optionalString(citation.description) + const pageCitationPageNumber = optionalPositiveNumber( + citation.page_citation_page_number, + ) + const pageCitationAssetUrl = toDemoAssetUrl( + requireString(citation.demo_source_id), + optionalString(citation.page_citation_asset_url), + ) return { demoSourceId: requireString(citation.demo_source_id), canonicalDocumentId: requireString(citation.canonical_document_id), @@ -450,6 +461,10 @@ function toDemoCitation(citation: DemoCitationResponse): DemoCitation { chunkType: requireString(citation.chunk_type), content: requireString(citation.content), ...(description ? { description } : {}), + ...(pageCitationPageNumber !== undefined + ? { pageCitationPageNumber } + : {}), + ...(pageCitationAssetUrl ? { pageCitationAssetUrl } : {}), source: { documentId: requireString(source.document_id), sourceFileName: requireString(source.source_file_name), From 2692fbcd22c17e3f99773f329eb476ff7c045c9f Mon Sep 17 00:00:00 2001 From: suguanYang Date: Fri, 21 Aug 2026 00:32:42 +0800 Subject: [PATCH 2/3] feat: map demo seed citations through the live chat citation path Reuse toChatCitationViews so demo Q&A chips get the same title/pN page numbers as normal retrieval answers. Co-authored-by: Cursor --- src/domains/demo/view.test.ts | 40 ++++++++++- src/domains/demo/view.ts | 92 +++++++++++++++++--------- src/integrations/knowhere-demo.test.ts | 2 + src/integrations/knowhere-demo.ts | 23 ++++++- 4 files changed, 120 insertions(+), 37 deletions(-) diff --git a/src/domains/demo/view.test.ts b/src/domains/demo/view.test.ts index 04d2c7c4..593ff12b 100644 --- a/src/domains/demo/view.test.ts +++ b/src/domains/demo/view.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from "vitest" +import { chatCitationModel } from "@/components/chat-citation-model" import { demoView } from "@/domains/demo/view" import type { DemoCatalog, DemoCitation, DemoSource } from "@/integrations/knowhere-demo" describe("demoView.toChatMessages", () => { - it("embeds cite markers and page metadata so demo answers render title/pN chips", () => { + it("uses the live citation mapper so demo answers render title/pN chips", () => { const messages = demoView.toChatMessages( makeCatalog({ answer: @@ -26,6 +27,39 @@ describe("demoView.toChatMessages", () => { }), }), ]) + expect( + chatCitationModel.getCitationChipLabel(assistant!.citations![0]!, {}), + ).toBe("TSLA-Q4-2025-Update.pdf/p12") + }) + + it("resolves page chips from catalog page_nums when the explicit page field is omitted", () => { + const messages = demoView.toChatMessages( + makeCatalog({ + title: "spacex-s1.pdf", + answer: + "The filing says SpaceX operates about 9,600 Starlink satellites. [[cite:1]]", + citations: [ + makeCitation({ + demoSourceId: "demo-spacex-s1", + canonicalDocumentId: "demo-doc-spacex-s1", + pageCitationPageNumber: undefined, + pageCitationAssetUrl: undefined, + pageNums: [28], + source: { + documentId: "demo-doc-spacex-s1", + sourceFileName: "spacex-s1.pdf", + sectionPath: "spacex-s1.pdf/Root", + }, + }), + ], + }), + ) + const assistant = messages.find((message) => message.role === "assistant") + + expect(assistant?.citations?.[0]?.pageCitationPageNumber).toBe(28) + expect( + chatCitationModel.getCitationChipLabel(assistant!.citations![0]!, {}), + ).toBe("spacex-s1.pdf/p28") }) it("appends cite markers when the catalog answer still omits them", () => { @@ -44,6 +78,7 @@ describe("demoView.toChatMessages", () => { }) function makeCatalog(input: { + readonly title?: string readonly answer: string readonly citations: readonly DemoCitation[] }): DemoCatalog { @@ -54,13 +89,14 @@ function makeCatalog(input: { } function makeDemoSource(input: { + readonly title?: string readonly answer: string readonly citations: readonly DemoCitation[] }): DemoSource { return { demoSourceId: "demo-tsla-q4-2025", canonicalDocumentId: "demo-doc-tsla-q4-2025", - title: "TSLA-Q4-2025-Update.pdf", + title: input.title ?? "TSLA-Q4-2025-Update.pdf", mimeType: "application/pdf", sizeBytes: 1024, status: "ready", diff --git a/src/domains/demo/view.ts b/src/domains/demo/view.ts index fa35f9e3..129c7fe9 100644 --- a/src/domains/demo/view.ts +++ b/src/domains/demo/view.ts @@ -1,10 +1,13 @@ -import { parsedChunkNormalization } from "@/domains/chunks/normalization" +import { toChatCitationViews } from "@/domains/chat/citations" +import type { PageCitationAssetRetrievalResult } from "@/domains/chat/page-citation-assets" import type { ChatMessageView } from "@/domains/chat/types" +import { parsedChunkNormalization } from "@/domains/chunks/normalization" import type { ParsedChunkView } from "@/domains/chunks/types" import { demoOriginalFile } from "@/domains/demo/original-file" import type { SourceView } from "@/domains/sources/types" import type { DemoCatalog, + DemoCitation, DemoChunk, DemoSource, } from "@/integrations/knowhere-demo" @@ -42,40 +45,65 @@ function toSourceView(source: DemoSource): SourceView { function toChatMessages(catalog: DemoCatalog): ChatMessageView[] { return catalog.sources.flatMap((source) => - source.examples.flatMap((example): ChatMessageView[] => [ - { - id: `${example.id}-user`, - role: "user", - content: example.question, - }, - { - id: `${example.id}-assistant`, - role: "assistant", - content: withCitationMarkers(example.answer, example.citations.length), - citations: example.citations.map((citation) => ({ - chunkType: citation.chunkType, - score: 0.95, - content: citation.content, - ...(citation.description - ? { description: citation.description } - : {}), - ...(citation.pageCitationPageNumber - ? { pageCitationPageNumber: citation.pageCitationPageNumber } - : {}), - ...(citation.pageCitationAssetUrl - ? { pageCitationAssetUrl: citation.pageCitationAssetUrl } - : {}), - source: { - documentId: citation.canonicalDocumentId, - sourceFileName: citation.source.sourceFileName, - sectionPath: citation.source.sectionPath, - }, - })), - }, - ]), + source.examples.flatMap((example): ChatMessageView[] => { + const answer = withCitationMarkers( + example.answer, + example.citations.length, + ) + return [ + { + id: `${example.id}-user`, + role: "user", + content: example.question, + }, + { + id: `${example.id}-assistant`, + role: "assistant", + content: answer, + citations: toDemoChatCitationViews(example.citations, answer), + }, + ] + }), ) } +function toDemoChatCitationViews( + citations: readonly DemoCitation[], + answer: string, +) { + return toChatCitationViews( + citations.map(toDemoRetrievalResult), + answer, + ).map((citation, index) => { + const description = citations[index]?.description + return description ? { ...citation, description } : citation + }) +} + +function toDemoRetrievalResult( + citation: DemoCitation, +): PageCitationAssetRetrievalResult { + return { + content: citation.content, + chunkType: citation.chunkType, + score: 0.95, + ...(citation.pageCitationAssetUrl + ? { pageCitationAssetUrl: citation.pageCitationAssetUrl } + : {}), + ...(citation.pageCitationPageNumber + ? { pageCitationPageNumber: citation.pageCitationPageNumber } + : {}), + ...(citation.pageNums && citation.pageNums.length > 0 + ? { metadata: { page_nums: [...citation.pageNums] } } + : {}), + source: { + documentId: citation.canonicalDocumentId, + sourceFileName: citation.source.sourceFileName, + sectionPath: citation.source.sectionPath, + }, + } +} + function toParsedChunkView( source: SourceView, chunk: DemoChunk, diff --git a/src/integrations/knowhere-demo.test.ts b/src/integrations/knowhere-demo.test.ts index be474594..aa560418 100644 --- a/src/integrations/knowhere-demo.test.ts +++ b/src/integrations/knowhere-demo.test.ts @@ -287,6 +287,7 @@ describe("knowhereDemoApi", () => { chunk_type: "page", content: "Tesla entered into an agreement", page_citation_page_number: 12, + page_nums: [12], page_citation_asset_url: "/api/v1/demo/sources/demo-tsla-q4-2025/assets/page_citation_assets/page-12.png", source: { @@ -310,6 +311,7 @@ describe("knowhereDemoApi", () => { expect(catalog.sources[0]?.examples[0]?.citations[0]).toMatchObject({ pageCitationPageNumber: 12, + pageNums: [12], pageCitationAssetUrl: "/api/demo-sources/demo-tsla-q4-2025/assets/page_citation_assets/page-12.png", }) diff --git a/src/integrations/knowhere-demo.ts b/src/integrations/knowhere-demo.ts index a63dcacf..83c56eee 100644 --- a/src/integrations/knowhere-demo.ts +++ b/src/integrations/knowhere-demo.ts @@ -13,6 +13,7 @@ export type DemoCitation = { readonly description?: string readonly pageCitationPageNumber?: number readonly pageCitationAssetUrl?: string + readonly pageNums?: readonly number[] readonly source: { readonly documentId: string readonly sourceFileName: string @@ -159,6 +160,7 @@ type DemoCitationResponse = { readonly description?: unknown readonly page_citation_page_number?: unknown readonly page_citation_asset_url?: unknown + readonly page_nums?: unknown readonly source?: { readonly document_id?: unknown readonly source_file_name?: unknown @@ -446,9 +448,9 @@ function toDemoExample(example: DemoExampleResponse): DemoExample { function toDemoCitation(citation: DemoCitationResponse): DemoCitation { const source = citation.source ?? {} const description = optionalString(citation.description) - const pageCitationPageNumber = optionalPositiveNumber( - citation.page_citation_page_number, - ) + const pageNums = toPositiveIntegers(citation.page_nums) + const pageCitationPageNumber = + optionalPositiveInteger(citation.page_citation_page_number) ?? pageNums[0] const pageCitationAssetUrl = toDemoAssetUrl( requireString(citation.demo_source_id), optionalString(citation.page_citation_asset_url), @@ -465,6 +467,7 @@ function toDemoCitation(citation: DemoCitationResponse): DemoCitation { ? { pageCitationPageNumber } : {}), ...(pageCitationAssetUrl ? { pageCitationAssetUrl } : {}), + ...(pageNums.length > 0 ? { pageNums } : {}), source: { documentId: requireString(source.document_id), sourceFileName: requireString(source.source_file_name), @@ -688,6 +691,20 @@ function optionalPositiveNumber(value: unknown): number | undefined { : undefined } +function optionalPositiveInteger(value: unknown): number | undefined { + return typeof value === "number" && Number.isSafeInteger(value) && value > 0 + ? value + : undefined +} + +function toPositiveIntegers(value: unknown): readonly number[] { + if (!Array.isArray(value)) return [] + return value.flatMap((item) => { + const page = optionalPositiveInteger(item) + return page === undefined ? [] : [page] + }) +} + function toDemoAssetUrl( demoSourceId: string, assetUrl: string | undefined, From 0ef6314e4b4e8ca5cf884260c7d28cbbd696a6b6 Mon Sep 17 00:00:00 2001 From: suguanYang Date: Fri, 21 Aug 2026 00:45:40 +0800 Subject: [PATCH 3/3] test: expect guest demo seed answers to include cite markers withCitationMarkers appends [[cite:n]] when the catalog answer omits them so chips can render on guest demo chat. Co-authored-by: Cursor --- src/domains/workspace/initial-state.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domains/workspace/initial-state.test.ts b/src/domains/workspace/initial-state.test.ts index 9fc14505..0ed05e23 100644 --- a/src/domains/workspace/initial-state.test.ts +++ b/src/domains/workspace/initial-state.test.ts @@ -85,7 +85,7 @@ describe("loadWorkspaceShellInitialState", () => { { id: "demo-example-1-assistant", role: "assistant", - content: "Tesla delivered higher revenue.", + content: "Tesla delivered higher revenue. [[cite:1]]", citations: [ { chunkType: "text",