-
- Sources
-
+
+
+ Sources
+
+ {hasLibrarySources && !isNarrow ? (
+
+ ) : null}
+
- {sources.length === 0 ? (
+ {workspaceSources.length === 0 ? (
) : (
- {sources.map((source) => (
+ {workspaceSources.map((source) => (
{
)
expect(result.current.chat.isSending).toBe(false)
})
+
+ it("blocks chat until Official Library demo sources are explicitly added", async () => {
+ const librarySource = makeSource({
+ id: "demo-spacex-s1",
+ kind: "demo",
+ demoSourceId: "demo-spacex-s1",
+ officialLibrary: {
+ librarySourceId: "financial-spacex-s1",
+ categoryId: "financial-reports",
+ sourceUrl: "https://example.com/spacex-s1.pdf",
+ },
+ })
+ mocks.fetchChatThreads.mockResolvedValue([])
+ mocks.sendChatMessage.mockResolvedValue({
+ threadId: "thread_1",
+ messages: [
+ {
+ id: "message_assistant",
+ role: "assistant",
+ content: "Answer",
+ },
+ ],
+ })
+
+ const { result } = renderWorkspaceChatWorkflow({
+ initialChatThreads: [],
+ initialChatMessages: [],
+ sources: [librarySource],
+ })
+
+ await act(async () => {
+ await result.current.handleChatSend("Summarize it")
+ })
+
+ expect(mocks.materializeDemoSources).not.toHaveBeenCalled()
+ expect(mocks.sendChatMessage).not.toHaveBeenCalled()
+ expect(result.current.chat.error).toBe(
+ "Add a ready source before asking questions.",
+ )
+ })
})
function renderWorkspaceChatWorkflow(input: {
diff --git a/src/components/workspace-chat-workflow.ts b/src/components/workspace-chat-workflow.ts
index d50747f..405516f 100644
--- a/src/components/workspace-chat-workflow.ts
+++ b/src/components/workspace-chat-workflow.ts
@@ -38,6 +38,7 @@ type WorkspaceChatWorkflow = {
readonly handleArchiveChatThread: (threadId: string) => Promise
readonly handleChatSend: (text: string) => Promise
readonly handleCreateChatThread: () => Promise
+ readonly handleRefreshActiveChatThread: () => Promise
readonly handleSelectChatThread: (threadId: string) => void
readonly isCreatingThread: boolean
readonly loadingThreadId: string | null
@@ -214,6 +215,29 @@ export function useWorkspaceChatWorkflow({
}
}
+ async function handleRefreshActiveChatThread(): Promise {
+ const threadId = chat.threadId
+ if (!threadId) return
+
+ try {
+ const fresh = await workspaceClient.fetchChatThread(threadId)
+ if (!fresh.thread || !Array.isArray(fresh.messages)) return
+ const messages = fresh.messages
+
+ void mutateSWR(
+ workspaceClientCache.getChatThreadKey(threadId),
+ fresh,
+ { revalidate: false },
+ )
+ setChat((current) => {
+ if (current.threadId !== fresh.requestedThreadId) return current
+ return { ...current, messages: [...messages] }
+ })
+ } catch {
+ // Materialization can still succeed even if the current thread refresh fails.
+ }
+ }
+
async function handleChatSend(text: string): Promise {
const demoSourceIds = getMaterializableDemoSourceIds(sources)
if (demoSourceIds.length > 0) {
@@ -224,19 +248,7 @@ export function useWorkspaceChatWorkflow({
const materializedSources =
await workspaceClient.materializeDemoSources({ demoSourceIds })
onSourcesMaterialized?.(demoSourceIds, materializedSources)
- if (chat.threadId) {
- try {
- const fresh = await workspaceClient.fetchChatThread(chat.threadId)
- setChat((current) => {
- if (current.threadId !== fresh.requestedThreadId) return current
- if (!fresh.thread || !Array.isArray(fresh.messages))
- return current
- return { ...current, messages: [...fresh.messages] }
- })
- } catch {
- // stale citations until page reload — materialization succeeded
- }
- }
+ await handleRefreshActiveChatThread()
} catch {
setChat((current) => ({
...current,
@@ -248,6 +260,16 @@ export function useWorkspaceChatWorkflow({
return
}
}
+ if (!hasQueryableReadySource(sources)) {
+ setChat((current) => ({
+ ...current,
+ isSending: false,
+ isLoading: false,
+ pendingStatusText: null,
+ error: "Add a ready source before asking questions.",
+ }))
+ return
+ }
optimisticMessageSequence.current += 1
const optimisticId = `pending-${optimisticMessageSequence.current}`
@@ -314,6 +336,7 @@ export function useWorkspaceChatWorkflow({
handleArchiveChatThread,
handleChatSend,
handleCreateChatThread,
+ handleRefreshActiveChatThread,
handleSelectChatThread,
isCreatingThread,
loadingThreadId,
@@ -325,12 +348,25 @@ function getMaterializableDemoSourceIds(
): string[] {
const demoSourceIds = sources
.filter((source) => source.kind === "demo")
+ .filter((source) => source.officialLibrary === undefined)
.filter((source) => !source.excludedFromQuery)
.map((source) => source.demoSourceId ?? source.id)
return Array.from(new Set(demoSourceIds))
}
+function hasQueryableReadySource(sources: readonly SourceView[]): boolean {
+ return sources.some(
+ (source) =>
+ source.status === "ready" &&
+ !isUnmaterializedOfficialLibrarySource(source),
+ )
+}
+
+function isUnmaterializedOfficialLibrarySource(source: SourceView): boolean {
+ return source.kind === "demo" && source.officialLibrary !== undefined
+}
+
function fetchChatThreadByKey([
,
threadId,
diff --git a/src/components/workspace-shell-layout.tsx b/src/components/workspace-shell-layout.tsx
index 1afc5fb..6108d9b 100644
--- a/src/components/workspace-shell-layout.tsx
+++ b/src/components/workspace-shell-layout.tsx
@@ -10,6 +10,7 @@ import {
import { ChatPanel } from "@/components/chat-panel"
import { ChunksPanel } from "@/components/chunks-panel"
import { MobileTabBar } from "@/components/mobile-tab-bar"
+import { OfficialLibraryPanel } from "@/components/official-library-panel"
import { SourcesPanel } from "@/components/sources-panel"
import { TopNav } from "@/components/top-nav"
import { useWorkspaceResizeHandleWorkflow } from "@/components/workspace-resize-handle-workflow"
@@ -21,11 +22,13 @@ import type {
} from "@/domains/chat/types"
import type { ParsedChunkView } from "@/domains/chunks/types"
import type {
+ OfficialLibrarySourceView,
SourceOriginalFileView,
SourceView,
} from "@/domains/sources/types"
export type PanelId = "sources" | "content" | "chat"
+export type ContentView = "chunks" | "library"
type DesktopPanelKey = keyof typeof workspaceShellState.minimumDesktopPanelWidths
type DesktopSidePanelKey = Exclude
@@ -52,6 +55,7 @@ type WorkspaceChatState = {
}
export type WorkspaceShellLayoutProps = {
+ readonly addingLibrarySourceIds: readonly string[]
readonly archivingSourceIds: readonly string[]
readonly archivingThreadIds: readonly string[]
readonly chat: WorkspaceChatState
@@ -62,6 +66,7 @@ export type WorkspaceShellLayoutProps = {
readonly focusedChunk: FocusedChunkState
readonly hasMessages: boolean
readonly hasMoreSelectedChunks: boolean
+ readonly contentView: ContentView
readonly isCreatingThread: boolean
readonly isGuest: boolean
readonly isSelectedAllChunksLoading: boolean
@@ -78,6 +83,7 @@ export type WorkspaceShellLayoutProps = {
readonly selectedSourceTitle: string | null
readonly sourceTitlesByDocumentId: Readonly>
readonly sources: readonly SourceView[]
+ readonly officialLibrarySources: readonly OfficialLibrarySourceView[]
readonly user: WorkspaceShellUser | undefined
readonly onArchiveChatThread: (threadId: string) => void | Promise
readonly onArchiveSource: (sourceId: string) => void | Promise
@@ -106,7 +112,11 @@ export type WorkspaceShellLayoutProps = {
readonly onLoadAllChunks: () => void
readonly onLoadMoreChunks: () => void
readonly onLoginClick: () => void
+ readonly onLibraryOpen: () => void
readonly onMobilePanelChange: (panel: PanelId) => void
+ readonly onOfficialLibrarySourceAdd: (
+ demoSourceId: string,
+ ) => void | Promise
readonly onSelectChatThread: (threadId: string) => void
readonly onSourceSelected: (sourceId: string | null) => void
readonly onSourceUploaded: (source: SourceView) => void
@@ -117,6 +127,8 @@ export function WorkspaceShellLayout(
props: WorkspaceShellLayoutProps,
): ReactElement {
const { onDesktopLayoutElementChange } = props
+ const addingLibrarySourceIds = props.addingLibrarySourceIds ?? []
+ const officialLibrarySources = props.officialLibrarySources ?? []
const isSourcesPanelCollapsed =
props.desktopPanelWidths.sources <=
workspaceShellState.desktopSidePanelCompactThreshold
@@ -175,6 +187,8 @@ export function WorkspaceShellLayout(
) : (
)}
@@ -213,24 +232,35 @@ export function WorkspaceShellLayout(
width: `${props.desktopPanelWidths.chunks}px`,
}}
>
-
+ {props.contentView === "library" ? (
+
+ ) : (
+
+ )}
{
@@ -312,7 +344,15 @@ export function WorkspaceShellLayout(
}}
onToggleIncluded={props.isGuest ? undefined : props.onToggleIncluded}
onArchiveSource={props.isGuest ? undefined : props.onArchiveSource}
+ onOfficialLibrarySourceAdd={
+ props.isGuest ? undefined : props.onOfficialLibrarySourceAdd
+ }
+ onLibraryOpen={() => {
+ props.onLibraryOpen()
+ props.onMobilePanelChange("content")
+ }}
archivingSourceIds={[...props.archivingSourceIds]}
+ addingLibrarySourceIds={[...addingLibrarySourceIds]}
onLoginClick={props.isGuest ? props.onLoginClick : undefined}
/>
@@ -324,22 +364,33 @@ export function WorkspaceShellLayout(
props.mobilePanel === "content" ? "flex flex-col" : "hidden"
}`}
>
- {
).toBeTruthy();
});
+ it("lets guests open the Official Library from the sources panel", async () => {
+ const user = userEvent.setup();
+
+ render(
+ React.createElement(C, {
+ isGuest: true,
+ loginUrl: "/login",
+ sources: [],
+ officialLibrarySources: [
+ {
+ librarySourceId: "stem-transformers",
+ categoryId: "stem-books",
+ categoryLabel: "STEM books",
+ title: "Transformers.pdf",
+ sourceUrl: "https://example.com/transformers.pdf",
+ mimeType: "application/pdf",
+ status: "ready",
+ demoSourceId: "demo-transformers",
+ },
+ ],
+ }),
+ );
+
+ const desktopSourcesPanel = within(
+ screen.getByTestId("desktop-sources-panel"),
+ );
+ await user.click(
+ desktopSourcesPanel.getByRole("button", { name: "Open library" }),
+ );
+
+ const desktopLibraryPanel = within(
+ within(screen.getByTestId("desktop-chunks-panel")).getByTestId(
+ "official-library-panel",
+ ),
+ );
+ expect(desktopLibraryPanel.getByRole("heading", { name: "Library" }))
+ .toBeTruthy();
+ expect(
+ desktopLibraryPanel.getByRole("button", { name: "Open STEM books" }),
+ ).toBeTruthy();
+ expect(window.location.href).not.toContain("/login");
+ });
+
it("shows the first ready document chunks on workspace load", async () => {
const fetch = vi.fn
(async (input) => {
const url = getRequestURL(input);
@@ -251,7 +294,6 @@ describe("WorkspaceShell", () => {
return Response.json({ message: "Unexpected request" }, { status: 404 });
});
vi.stubGlobal("fetch", fetch);
- const user = userEvent.setup();
render(
React.createElement(C, {
@@ -287,12 +329,13 @@ describe("WorkspaceShell", () => {
}),
);
- const desktopChatPanel = within(screen.getByTestId("desktop-chat-panel"));
- await user.click(
- desktopChatPanel.getByRole("button", {
+ const citationButton = await findStableConnectedElement(() => {
+ const desktopChatPanel = within(screen.getByTestId("desktop-chat-panel"));
+ return desktopChatPanel.getByRole("button", {
name: "Open source demo.pdf · Demo citation",
- }),
- );
+ });
+ });
+ fireEvent.click(citationButton);
await waitFor(() => {
const topRow = screen
@@ -337,7 +380,6 @@ describe("WorkspaceShell", () => {
return Response.json({ message: "Unexpected request" }, { status: 404 });
});
vi.stubGlobal("fetch", fetch);
- const user = userEvent.setup();
render(
React.createElement(C, {
@@ -373,12 +415,13 @@ describe("WorkspaceShell", () => {
}),
);
- const mobileChatPanel = within(document.getElementById("panel-chat")!);
- await user.click(
- mobileChatPanel.getByRole("button", {
+ const citationButton = await findStableConnectedElement(() => {
+ const mobileChatPanel = within(document.getElementById("panel-chat")!);
+ return mobileChatPanel.getByRole("button", {
name: "Open source demo.pdf · Demo citation",
- }),
- );
+ });
+ });
+ fireEvent.click(citationButton);
await waitFor(() => {
const topRow = document
@@ -484,7 +527,9 @@ describe("WorkspaceShell", () => {
});
await user.click(sendButton);
- const firstCitation = await desktopChatPanel.findByText("doc.pdf · First");
+ const firstCitation = await desktopChatPanel.findByRole("button", {
+ name: "Open source doc.pdf · First",
+ });
await user.click(firstCitation);
await waitFor(() => {
@@ -502,13 +547,13 @@ describe("WorkspaceShell", () => {
});
expect(countFetches(fetch, "/api/sources/source_1/chunks")).toBe(1);
- const secondCitation = desktopChatPanel
- .getByText("doc.pdf · Second")
- .closest("button");
+ const secondCitation = desktopChatPanel.getByRole("button", {
+ name: "Open source doc.pdf · Second",
+ }) as HTMLButtonElement;
await waitFor(() => {
- expect(secondCitation?.disabled).toBe(false);
+ expect(secondCitation.disabled).toBe(false);
});
- await user.click(secondCitation!);
+ await user.click(secondCitation);
await waitFor(() => {
const topRow = screen
@@ -983,6 +1028,174 @@ describe("WorkspaceShell", () => {
expect(desktopSourcesPanel.queryByText("No sources yet.")).toBeNull();
});
+ it("refreshes the active chat after adding an Official Library source", async () => {
+ const fetch = vi.fn(async (input, init) => {
+ const request = input instanceof Request
+ ? input
+ : new Request(new URL(String(input), "http://localhost").toString(), init);
+ const path = getRequestPath(request);
+
+ if (path === "/api/demo-sources/materialize" && request.method === "POST") {
+ return Response.json({
+ sources: [
+ {
+ id: "source_spacex",
+ kind: "workspace",
+ title: "spacex-s1.pdf",
+ status: "ready",
+ mimeType: "application/pdf",
+ documentId: "doc_user_copy",
+ chunkCount: 1,
+ },
+ ],
+ });
+ }
+
+ if (path === "/api/chat/threads/thread_1") {
+ return Response.json({
+ thread: {
+ id: "thread_1",
+ title: "Current chat",
+ createdAt: "2026-05-07T00:00:00.000Z",
+ updatedAt: "2026-05-07T00:00:00.000Z",
+ },
+ messages: [
+ {
+ id: "assistant_refreshed",
+ role: "assistant",
+ content: "Refreshed materialized answer.",
+ citations: [
+ {
+ content: "User-copy cited section",
+ chunkType: "text",
+ score: 0.91,
+ source: {
+ documentId: "doc_user_copy",
+ sourceFileName: "spacex-s1.pdf",
+ sectionPath: "Overview",
+ },
+ },
+ ],
+ },
+ ],
+ });
+ }
+
+ if (path === "/api/sources/source_spacex/chunks") {
+ return Response.json({
+ chunks: [
+ {
+ chunkId: "source_spacex:chunk_1",
+ documentId: "doc_user_copy",
+ sectionPath: "Overview",
+ type: "text",
+ content: "User-copy cited section",
+ sourceTitle: "spacex-s1.pdf",
+ },
+ ],
+ });
+ }
+
+ return Response.json({ message: "Unexpected request" }, { status: 404 });
+ });
+ vi.stubGlobal("fetch", fetch);
+ const user = userEvent.setup();
+
+ render(
+ React.createElement(C, {
+ sources: [
+ {
+ id: "demo-spacex-s1",
+ kind: "demo",
+ demoSourceId: "demo-spacex-s1",
+ title: "spacex-s1.pdf",
+ status: "ready",
+ mimeType: "application/pdf",
+ documentId: "demo-doc-spacex-s1",
+ officialLibrary: {
+ librarySourceId: "financial-spacex-s1",
+ categoryId: "financial-reports",
+ sourceUrl: "https://example.com/spacex-s1.pdf",
+ },
+ },
+ ],
+ chatThreads: [
+ {
+ id: "thread_1",
+ title: "Current chat",
+ createdAt: "2026-05-07T00:00:00.000Z",
+ updatedAt: "2026-05-07T00:00:00.000Z",
+ },
+ ],
+ activeChatThreadId: "thread_1",
+ chatMessages: [
+ {
+ id: "assistant_seeded",
+ role: "assistant",
+ content: "Seeded canonical answer.",
+ citations: [
+ {
+ content: "Canonical cited section",
+ chunkType: "text",
+ score: 0.91,
+ source: {
+ documentId: "demo-doc-spacex-s1",
+ sourceFileName: "spacex-s1.pdf",
+ sectionPath: "Overview",
+ },
+ },
+ ],
+ },
+ ],
+ }),
+ );
+
+ const desktopSourcesPanel = within(
+ screen.getByTestId("desktop-sources-panel"),
+ );
+ await user.click(desktopSourcesPanel.getByRole("button", { name: "Open library" }));
+
+ const desktopLibraryPanel = within(
+ within(screen.getByTestId("desktop-chunks-panel")).getByTestId(
+ "official-library-panel",
+ ),
+ );
+ expect(desktopLibraryPanel.getByRole("heading", { name: "Library" }))
+ .toBeTruthy();
+ await user.click(
+ desktopLibraryPanel.getByRole("button", {
+ name: "Open Financial Reports",
+ }),
+ );
+ await user.click(
+ desktopLibraryPanel.getByRole("button", {
+ name: "Add spacex-s1.pdf to sources",
+ }),
+ );
+
+ const desktopChatPanel = within(screen.getByTestId("desktop-chat-panel"));
+ await desktopChatPanel.findByText("Refreshed materialized answer.");
+ expect(desktopChatPanel.queryByText("Seeded canonical answer.")).toBeNull();
+
+ await user.click(
+ desktopChatPanel.getByRole("button", {
+ name: "Open source spacex-s1.pdf · Overview",
+ }),
+ );
+
+ await waitFor(() => {
+ const topRow = screen
+ .getByTestId("desktop-chunks-panel")
+ .querySelector('[data-index="0"]');
+
+ expect(topRow?.getAttribute("data-chunk-id")).toBe(
+ "source_spacex:chunk_1",
+ );
+ expect(topRow?.getAttribute("data-focused-chunk")).toBe("true");
+ });
+ expect(countFetches(fetch, "/api/chat/threads/thread_1")).toBe(1);
+ });
+
it("uses cached chat data when reopening a previously loaded thread", async () => {
const fetch = vi.fn(async (input) => {
const path = getRequestPath(input);
@@ -1093,6 +1306,24 @@ describe("WorkspaceShell", () => {
});
});
+function findStableConnectedElement(
+ getElement: () => HTMLElement,
+): Promise {
+ let previousElement: HTMLElement | null = null;
+
+ return waitFor(() => {
+ const element = getElement();
+ expect(element.isConnected).toBe(true);
+
+ if (element !== previousElement) {
+ previousElement = element;
+ throw new Error("Element is still settling.");
+ }
+
+ return element;
+ });
+}
+
function getRequestPath(input: RequestInfo | URL): string {
return getRequestURL(input).pathname;
}
diff --git a/src/components/workspace-shell.tsx b/src/components/workspace-shell.tsx
index 9e82794..da9edcd 100644
--- a/src/components/workspace-shell.tsx
+++ b/src/components/workspace-shell.tsx
@@ -5,6 +5,7 @@ import type { ReactElement } from "react"
import { SWRConfig } from "swr"
import {
WorkspaceShellLayout,
+ type ContentView,
type PanelId,
} from "@/components/workspace-shell-layout"
import { useWorkspaceDesktopPanels } from "@/components/workspace-desktop-panels"
@@ -18,7 +19,10 @@ import type {
ChatThreadView,
} from "@/domains/chat/types"
import type { ParsedChunkView } from "@/domains/chunks/types"
-import type { SourceView } from "@/domains/sources/types"
+import type {
+ OfficialLibrarySourceView,
+ SourceView,
+} from "@/domains/sources/types"
export type { PanelId } from "@/components/workspace-shell-layout"
@@ -38,6 +42,7 @@ export type WorkspaceShellProps = {
namespace: string
}
sources?: SourceView[]
+ officialLibrarySources?: OfficialLibrarySourceView[]
chatThreads?: ChatThreadView[]
activeChatThreadId?: string | null
chatMessages?: ChatMessageView[]
@@ -66,6 +71,7 @@ export function WorkspaceShell(props: WorkspaceShellProps): ReactElement {
function WorkspaceShellContent({
user,
sources: initialSources,
+ officialLibrarySources,
chatThreads: initialChatThreads,
activeChatThreadId,
chatMessages: initialChatMessages,
@@ -77,6 +83,7 @@ function WorkspaceShellContent({
const [mobilePanel, setMobilePanel] = useState(
isGuest ? "content" : "chat",
)
+ const [contentView, setContentView] = useState("chunks")
const sourceWorkflow = useWorkspaceSourceWorkflow({
initialSources: initialSources ?? [],
isGuest,
@@ -85,7 +92,7 @@ function WorkspaceShellContent({
fetchChunks: workspaceClient.fetchChunks,
initialPrefetchedChunksBySourceId:
initialPrefetchedChunksBySourceId ?? undefined,
- onSelectSource: sourceWorkflow.setSelectedSourceId,
+ onSelectSource: handleCitationSourceSelected,
selectedSourceId: sourceWorkflow.selectedSourceId,
sources: sourceWorkflow.sources,
})
@@ -114,15 +121,37 @@ function WorkspaceShellContent({
const selectedSourceTitle = citationFocus.selectedSource?.title ?? null
+ function handleCitationSourceSelected(sourceId: string | null): void {
+ setContentView("chunks")
+ sourceWorkflow.setSelectedSourceId(sourceId)
+ }
+
function handleSourceSelected(sourceId: string | null): void {
+ setContentView("chunks")
citationFocus.handleSourceSelected(sourceId)
}
+ async function handleOfficialLibrarySourceAdd(
+ demoSourceId: string,
+ ): Promise {
+ const didMaterialize =
+ await sourceWorkflow.handleOfficialLibrarySourceAdd(demoSourceId)
+ if (didMaterialize) {
+ setContentView("chunks")
+ await chatWorkflow.handleRefreshActiveChatThread()
+ }
+ }
+
+ function handleLibraryOpen(): void {
+ setContentView("library")
+ }
+
const hasMessages = chatWorkflow.chat.messages.length > 0
return (
diff --git a/src/components/workspace-source-state.test.ts b/src/components/workspace-source-state.test.ts
index 7f1596e..ea5ca00 100644
--- a/src/components/workspace-source-state.test.ts
+++ b/src/components/workspace-source-state.test.ts
@@ -28,6 +28,36 @@ describe("workspaceSourceState", () => {
);
});
+ it("does not select unmaterialized Official Library rows as the initial source", () => {
+ const sources: readonly SourceView[] = [
+ {
+ id: "demo-spacex-s1",
+ kind: "demo",
+ demoSourceId: "demo-spacex-s1",
+ title: "spacex-s1.pdf",
+ status: "ready",
+ mimeType: "application/pdf",
+ excludedFromQuery: false,
+ officialLibrary: {
+ librarySourceId: "financial-spacex-s1",
+ categoryId: "financial-reports",
+ sourceUrl: "https://example.com/spacex-s1.pdf",
+ },
+ },
+ {
+ id: "source_ready",
+ title: "ready.pdf",
+ status: "ready",
+ mimeType: "application/pdf",
+ excludedFromQuery: false,
+ },
+ ];
+
+ expect(workspaceSourceState.getInitialSelectedSourceId(sources)).toBe(
+ "source_ready",
+ );
+ });
+
it("applies source query exclusions without mutating the source list", () => {
const sources: readonly SourceView[] = [
{
diff --git a/src/components/workspace-source-state.ts b/src/components/workspace-source-state.ts
index d4f82d1..c63bf01 100644
--- a/src/components/workspace-source-state.ts
+++ b/src/components/workspace-source-state.ts
@@ -50,7 +50,7 @@ function getInitialSelectedSourceId(sources: readonly SourceView[]): string | nu
}
function getFirstReadySourceId(sources: readonly SourceView[]): string | null {
- return sources.find((source) => source.status === "ready")?.id ?? null
+ return sources.find(isQueryableReadySource)?.id ?? null
}
function getResolvedSelectedSourceId(
@@ -58,11 +58,17 @@ function getResolvedSelectedSourceId(
selectedSourceId: string | null,
): string | null {
const selectedSource = sources.find((source) => source.id === selectedSourceId)
- if (selectedSource?.status === "ready") return selectedSource.id
+ if (selectedSource && isQueryableReadySource(selectedSource)) {
+ return selectedSource.id
+ }
return getFirstReadySourceId(sources)
}
+function isQueryableReadySource(source: SourceView): boolean {
+ return source.status === "ready" && source.officialLibrary === undefined
+}
+
function applyQueryExclusions(
sources: readonly SourceView[],
sourceExclusionById: SourceExclusionState,
diff --git a/src/components/workspace-source-workflow.test.ts b/src/components/workspace-source-workflow.test.ts
index 563f5f0..239107e 100644
--- a/src/components/workspace-source-workflow.test.ts
+++ b/src/components/workspace-source-workflow.test.ts
@@ -9,16 +9,19 @@ import type { SourceView } from "@/domains/sources/types"
const mocks = vi.hoisted(() => ({
archiveSource: vi.fn(),
fetchSources: vi.fn(),
+ materializeDemoSources: vi.fn(),
}))
vi.mock("@/domains/workspace/client", () => ({
workspaceClient: {
keys: {
archiveSource: "archive-source",
+ materializeDemoSources: "/api/demo-sources/materialize",
sources: "/api/sources",
},
archiveSource: mocks.archiveSource,
fetchSources: mocks.fetchSources,
+ materializeDemoSources: mocks.materializeDemoSources,
},
}))
@@ -115,6 +118,94 @@ describe("useWorkspaceSourceWorkflow", () => {
documentId: "document_1",
})
})
+
+ it("materializes one Official Library source through the workflow", async () => {
+ const demoSource = makeSource({
+ id: "demo-spacex-s1",
+ kind: "demo",
+ demoSourceId: "demo-spacex-s1",
+ title: "spacex-s1.pdf",
+ officialLibrary: {
+ librarySourceId: "financial-spacex-s1",
+ categoryId: "financial-reports",
+ sourceUrl: "https://example.com/spacex-s1.pdf",
+ },
+ })
+ const materializedSource = makeSource({
+ id: "source_spacex",
+ kind: "workspace",
+ title: "spacex-s1.pdf",
+ documentId: "doc_spacex",
+ })
+ mocks.fetchSources.mockResolvedValue([demoSource])
+ mocks.materializeDemoSources.mockResolvedValue([materializedSource])
+
+ const { result } = renderWorkspaceSourceWorkflow({
+ initialSources: [demoSource],
+ isGuest: false,
+ })
+
+ await act(async () => {
+ await expect(
+ result.current.handleOfficialLibrarySourceAdd("demo-spacex-s1"),
+ ).resolves.toBe(true)
+ })
+
+ expect(mocks.materializeDemoSources).toHaveBeenCalledWith({
+ demoSourceIds: ["demo-spacex-s1"],
+ })
+ expect(result.current.sources.map((source) => source.id)).toEqual([
+ "source_spacex",
+ ])
+ expect(result.current.selectedSourceId).toBe("source_spacex")
+ })
+
+ it("does not count unmaterialized Official Library sources as chat-ready", () => {
+ const librarySource = makeSource({
+ id: "demo-spacex-s1",
+ kind: "demo",
+ demoSourceId: "demo-spacex-s1",
+ title: "spacex-s1.pdf",
+ officialLibrary: {
+ librarySourceId: "financial-spacex-s1",
+ categoryId: "financial-reports",
+ sourceUrl: "https://example.com/spacex-s1.pdf",
+ },
+ })
+
+ const { result } = renderWorkspaceSourceWorkflow({
+ initialSources: [librarySource],
+ isGuest: false,
+ })
+
+ expect(result.current.readySourceCount).toBe(0)
+ })
+
+ it("reports failed Official Library materialization without changing sources", async () => {
+ const demoSource = makeSource({
+ id: "demo-spacex-s1",
+ kind: "demo",
+ demoSourceId: "demo-spacex-s1",
+ title: "spacex-s1.pdf",
+ })
+ mocks.fetchSources.mockResolvedValue([demoSource])
+ mocks.materializeDemoSources.mockRejectedValue(new Error("Bad gateway"))
+
+ const { result } = renderWorkspaceSourceWorkflow({
+ initialSources: [demoSource],
+ isGuest: false,
+ })
+
+ await act(async () => {
+ await expect(
+ result.current.handleOfficialLibrarySourceAdd("demo-spacex-s1"),
+ ).resolves.toBe(false)
+ })
+
+ expect(result.current.sources.map((source) => source.id)).toEqual([
+ "demo-spacex-s1",
+ ])
+ })
})
function renderWorkspaceSourceWorkflow(input: {
diff --git a/src/components/workspace-source-workflow.ts b/src/components/workspace-source-workflow.ts
index b31380b..47a9822 100644
--- a/src/components/workspace-source-workflow.ts
+++ b/src/components/workspace-source-workflow.ts
@@ -15,8 +15,10 @@ type WorkspaceSourceWorkflowInput = {
}
type WorkspaceSourceWorkflow = {
+ readonly addingLibrarySourceIds: string[]
readonly archivingSourceIds: string[]
readonly handleArchiveSource: (sourceId: string) => Promise
+ readonly handleOfficialLibrarySourceAdd: (demoSourceId: string) => Promise
readonly handleSelectedSourceChange: (sourceId: string | null) => void
readonly handleSourcesMaterialized: (
demoSourceIds: readonly string[],
@@ -33,6 +35,7 @@ type WorkspaceSourceWorkflow = {
const sourcesSWRKey = workspaceClient.keys.sources
const archiveSourceSWRKey = workspaceClient.keys.archiveSource
+const materializeDemoSourceSWRKey = workspaceClient.keys.materializeDemoSources
export function useWorkspaceSourceWorkflow({
initialSources = [],
@@ -49,6 +52,9 @@ export function useWorkspaceSourceWorkflow({
Record
>({})
const [archivingSourceIds, setArchivingSourceIds] = useState([])
+ const [addingLibrarySourceIds, setAddingLibrarySourceIds] = useState(
+ [],
+ )
const shouldRefreshSourcesOnMount =
!isGuest && workspaceClientCache.hasPendingSources(initialSourceRows)
const { data: serverSources, mutate: mutateSources } = useSWR(
@@ -81,13 +87,15 @@ export function useWorkspaceSourceWorkflow({
),
[sources],
)
- const readySourceCount = sources.filter(
- (source) => source.status === "ready",
- ).length
+ const readySourceCount = sources.filter(isQueryableReadySource).length
const { trigger: archiveSource } = useSWRMutation(
archiveSourceSWRKey,
archiveSourceMutation,
)
+ const { trigger: materializeDemoSources } = useSWRMutation(
+ materializeDemoSourceSWRKey,
+ materializeDemoSourcesMutation,
+ )
function handleSourceUploaded(source: SourceView): void {
void mutateSources(
@@ -115,8 +123,11 @@ export function useWorkspaceSourceWorkflow({
{ revalidate: false },
)
setSelectedSourceId((current) => {
- if (!current || !materializedDemoSourceIdSet.has(current)) return current
- return materializedSources[0]?.id ?? current
+ if (!current || materializedDemoSourceIdSet.has(current)) {
+ return materializedSources[0]?.id ?? current
+ }
+
+ return current
})
}
@@ -167,9 +178,31 @@ export function useWorkspaceSourceWorkflow({
}
}
+ async function handleOfficialLibrarySourceAdd(
+ demoSourceId: string,
+ ): Promise {
+ setAddingLibrarySourceIds((current) =>
+ workspaceSourceState.addPendingId(current, demoSourceId),
+ )
+ try {
+ const materializedSources = await materializeDemoSources([demoSourceId])
+ handleSourcesMaterialized([demoSourceId], materializedSources)
+ return true
+ } catch {
+ // Keep the library source visible when materialization fails.
+ return false
+ } finally {
+ setAddingLibrarySourceIds((current) =>
+ workspaceSourceState.removePendingId(current, demoSourceId),
+ )
+ }
+ }
+
return {
+ addingLibrarySourceIds,
archivingSourceIds,
handleArchiveSource,
+ handleOfficialLibrarySourceAdd,
handleSelectedSourceChange,
handleSourcesMaterialized,
handleSourceUploaded,
@@ -182,9 +215,28 @@ export function useWorkspaceSourceWorkflow({
}
}
+function isQueryableReadySource(source: SourceView): boolean {
+ if (source.status !== "ready") return false
+
+ return !isUnmaterializedOfficialLibrarySource(source)
+}
+
+function isUnmaterializedOfficialLibrarySource(source: SourceView): boolean {
+ return source.kind === "demo" && source.officialLibrary !== undefined
+}
+
function archiveSourceMutation(
_key: string,
{ arg: sourceId }: { readonly arg: string },
): ReturnType {
return workspaceClient.archiveSource(sourceId)
}
+
+function materializeDemoSourcesMutation(
+ _key: string,
+ { arg: demoSourceIds }: { readonly arg: readonly string[] },
+): ReturnType {
+ return workspaceClient.materializeDemoSources({
+ demoSourceIds: [...demoSourceIds],
+ })
+}
diff --git a/src/domains/chat/contracts.ts b/src/domains/chat/contracts.ts
index eaf5f57..b349b97 100644
--- a/src/domains/chat/contracts.ts
+++ b/src/domains/chat/contracts.ts
@@ -9,6 +9,7 @@ import type {
ChatArtifactView,
ChatCitationView,
} from "@/domains/chat/types"
+import type { HardenMediaAssetUrls } from "./media-asset-hardening"
import type { LoadSourceAssetUrls } from "./media-assets"
export type RetrievalClient = {
@@ -66,6 +67,7 @@ export type AnswerQuestionInput = {
retrieval: RetrievalClient
generateAnswer: GenerateAnswer
loadSourceAssetUrls?: LoadSourceAssetUrls
+ hardenMediaAssetUrls?: HardenMediaAssetUrls
messages: readonly ChatHistoryMessage[]
}
diff --git a/src/domains/chat/diagram.test.ts b/src/domains/chat/diagram.test.ts
new file mode 100644
index 0000000..b699551
--- /dev/null
+++ b/src/domains/chat/diagram.test.ts
@@ -0,0 +1,148 @@
+import { generateObject } from "ai"
+import { afterEach, describe, expect, it, vi } from "vitest"
+
+import {
+ buildChatDiagramPrompt,
+ generateChatDiagramSpec,
+ parseChatDiagramRequestBody,
+ retrieveAntvChartSkills,
+} from "./diagram"
+
+vi.mock("ai", () => ({
+ generateObject: vi.fn(),
+}))
+
+describe("parseChatDiagramRequestBody", () => {
+ it("accepts trimmed answer content", () => {
+ expect(parseChatDiagramRequestBody({ answer: " Revenue was 42. " }))
+ .toEqual({
+ ok: true,
+ value: {
+ answer: "Revenue was 42.",
+ },
+ })
+ })
+
+ it("rejects empty answer content", () => {
+ expect(parseChatDiagramRequestBody({ answer: " " })).toEqual({
+ ok: false,
+ status: 400,
+ message: "Answer content is required before creating a diagram.",
+ })
+ })
+})
+
+describe("buildChatDiagramPrompt", () => {
+ it("uses the bundled AntV chart visualization skill index without allowing fabricated data", () => {
+ const skills = retrieveAntvChartSkills(
+ "bar chart category comparison",
+ 5,
+ )
+ const prompt = buildChatDiagramPrompt("Cloud revenue was 42.")
+
+ expect(skills[0]?.id).toBe("__info__g2")
+ expect(
+ skills.some(
+ (skill): boolean =>
+ skill.tags.includes("bar") ||
+ skill.title.toLowerCase().includes("bar") ||
+ skill.description.toLowerCase().includes("bar"),
+ ),
+ ).toBe(true)
+ expect(prompt).toContain("@antv/chart-visualization-skills")
+ expect(prompt).toContain("Skill: __info__g2")
+ expect(prompt).toContain("AntV")
+ expect(prompt).toContain("source exactly to \"chart-visualization-skills\"")
+ expect(prompt).toContain("Preserve negative values")
+ expect(prompt).toContain("core message")
+ expect(prompt).toContain("Do not fabricate data")
+ expect(prompt).toContain("Cloud revenue was 42.")
+ })
+})
+
+describe("generateChatDiagramSpec", () => {
+ afterEach(() => {
+ delete process.env.AI_GATEWAY_API_KEY
+ vi.mocked(generateObject).mockReset()
+ })
+
+ it("generates an AntV-compatible chart spec", async () => {
+ process.env.AI_GATEWAY_API_KEY = "test_gateway_key"
+ vi.mocked(generateObject).mockResolvedValue({
+ object: {
+ type: "bar",
+ source: "chart-visualization-skills",
+ title: "Revenue by Segment",
+ data: [
+ { category: "Cloud", value: 42 },
+ { category: "Ads", value: 28 },
+ ],
+ },
+ } as Awaited>)
+
+ const spec = await generateChatDiagramSpec({
+ answer: "Cloud revenue was 42 and Ads revenue was 28.",
+ })
+
+ expect(generateObject).toHaveBeenCalledWith({
+ model: "google/gemini-3-flash",
+ schema: expect.any(Object),
+ prompt: expect.stringContaining("Cloud revenue was 42"),
+ })
+ expect(spec).toEqual({
+ type: "bar",
+ source: "chart-visualization-skills",
+ title: "Revenue by Segment",
+ axisXTitle: undefined,
+ axisYTitle: undefined,
+ data: [
+ { category: "Cloud", time: undefined, value: 42 },
+ { category: "Ads", time: undefined, value: 28 },
+ ],
+ })
+ })
+
+ it("normalizes sparse chart specs into no-diagram responses", async () => {
+ process.env.AI_GATEWAY_API_KEY = "test_gateway_key"
+ vi.mocked(generateObject).mockResolvedValue({
+ object: {
+ type: "bar",
+ source: "chart-visualization-skills",
+ title: "Only one number",
+ data: [{ category: "Cloud", value: 42 }],
+ },
+ } as Awaited>)
+
+ await expect(
+ generateChatDiagramSpec({ answer: "Cloud revenue was 42." }),
+ ).resolves.toEqual({
+ type: "none",
+ reason: "The answer did not contain enough concrete data for a chart.",
+ })
+ })
+
+ it("rejects pie charts with non-positive values", async () => {
+ process.env.AI_GATEWAY_API_KEY = "test_gateway_key"
+ vi.mocked(generateObject).mockResolvedValue({
+ object: {
+ type: "pie",
+ source: "chart-visualization-skills",
+ title: "Mixed Profit Share",
+ data: [
+ { category: "Loss", value: -5 },
+ { category: "Gain", value: 10 },
+ ],
+ },
+ } as Awaited>)
+
+ await expect(
+ generateChatDiagramSpec({
+ answer: "Loss was -5 and gain was 10.",
+ }),
+ ).resolves.toEqual({
+ type: "none",
+ reason:
+ "The answer did not contain positive part-to-whole data for a pie chart.",
+ })
+ })
+})
diff --git a/src/domains/chat/diagram.ts b/src/domains/chat/diagram.ts
new file mode 100644
index 0000000..f2d9327
--- /dev/null
+++ b/src/domains/chat/diagram.ts
@@ -0,0 +1,394 @@
+import { generateObject } from "ai"
+import g2SkillIndex from "@antv/chart-visualization-skills/dist/index/g2.index.json"
+import type { Skill } from "@antv/chart-visualization-skills"
+import { z } from "zod"
+
+import { CHAT_MODEL } from "@/lib/ai"
+import { summarizeUnknownError } from "@/lib/format-log-value"
+import { logger } from "@/lib/logger"
+
+const MAX_ANSWER_CHARS = 12_000
+const MAX_REASON_CHARS = 240
+const ANTV_CHART_LIBRARY = "g2"
+const ANTV_CHART_SKILL_TOP_K = 5
+const MAX_ANTV_SKILL_QUERY_CHARS = 500
+const MAX_ANTV_SKILL_CONTENT_CHARS = 2_400
+const MAX_ANTV_SKILL_CONTEXT_CHARS = 16_000
+const ANTV_CHART_SEARCH_STOP_WORDS = new Set([
+ "the",
+ "and",
+ "for",
+ "with",
+ "from",
+ "that",
+ "this",
+ "into",
+ "chart",
+ "charts",
+ "visualization",
+ "data",
+ "value",
+ "values",
+ "answer",
+ "content",
+])
+
+type AntvChartSkillInfo = {
+ readonly name?: string
+ readonly description?: string
+ readonly constraintsContent?: string
+}
+
+type AntvChartSkillIndex = {
+ readonly info?: AntvChartSkillInfo
+ readonly skills: readonly Skill[]
+}
+
+type IndexedAntvChartSkill = {
+ readonly skill: Skill
+ readonly tokenWeights: ReadonlyMap
+}
+
+const antvG2SkillIndex = g2SkillIndex as AntvChartSkillIndex
+const indexedAntvG2Skills = buildAntvChartSkillSearchIndex(
+ antvG2SkillIndex.skills,
+)
+
+const noDiagramSchema = z.object({
+ type: z.literal("none"),
+ reason: z.string().min(1).max(MAX_REASON_CHARS),
+})
+
+const chartDiagramSchema = z.object({
+ type: z.enum(["bar", "column", "line", "pie"]),
+ source: z.literal("chart-visualization-skills"),
+ title: z.string().min(1).max(120),
+ axisXTitle: z.string().min(1).max(80).optional(),
+ axisYTitle: z.string().min(1).max(80).optional(),
+ data: z
+ .array(
+ z.object({
+ category: z.string().min(1).max(80).optional(),
+ time: z.string().min(1).max(80).optional(),
+ value: z.number(),
+ }),
+ )
+ .min(2)
+ .max(12),
+})
+
+export const chatDiagramSpecSchema = z.union([
+ noDiagramSchema,
+ chartDiagramSchema,
+])
+
+export type ChatDiagramSpec = z.infer
+export type ChatDiagramChartSpec = z.infer
+
+export type ParseChatDiagramRequestResult =
+ | {
+ readonly ok: true
+ readonly value: {
+ readonly answer: string
+ }
+ }
+ | {
+ readonly ok: false
+ readonly status: 400
+ readonly message: string
+ }
+
+export function parseChatDiagramRequestBody(
+ body: unknown,
+): ParseChatDiagramRequestResult {
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
+ return {
+ ok: false,
+ status: 400,
+ message: "Answer content is required before creating a diagram.",
+ }
+ }
+
+ const answer = (body as { readonly answer?: unknown }).answer
+ if (typeof answer !== "string" || answer.trim().length === 0) {
+ return {
+ ok: false,
+ status: 400,
+ message: "Answer content is required before creating a diagram.",
+ }
+ }
+
+ return {
+ ok: true,
+ value: {
+ answer: answer.trim().slice(0, MAX_ANSWER_CHARS),
+ },
+ }
+}
+
+export async function generateChatDiagramSpec(input: {
+ readonly answer: string
+}): Promise {
+ if (!process.env.AI_GATEWAY_API_KEY) {
+ throw new Error(
+ "AI_GATEWAY_API_KEY environment variable is required. Set it in your .env.local file.",
+ )
+ }
+
+ const prompt = buildChatDiagramPrompt(input.answer)
+ logger.info("chat-diagram: llm request", {
+ model: CHAT_MODEL,
+ promptCharLength: prompt.length,
+ })
+ const response = await generateObject({
+ model: CHAT_MODEL,
+ schema: chatDiagramSpecSchema,
+ prompt,
+ })
+ const spec = normalizeChatDiagramSpec(response.object)
+ logger.info("chat-diagram: llm response", {
+ model: CHAT_MODEL,
+ type: spec.type,
+ dataPointCount: spec.type === "none" ? 0 : spec.data.length,
+ })
+ return spec
+}
+
+export function buildChatDiagramPrompt(answer: string): string {
+ const antvSkillContext = getAntvChartSkillContext(answer)
+
+ return [
+ "You are responsible for turning Notebook answer content into one visualization opportunity.",
+ "Use the AntV chart visualization skills retrieved from @antv/chart-visualization-skills.",
+ "",
+ "Workflow:",
+ "1. Detect whether the answer contains explicit, concrete data suitable for a chart.",
+ "2. Extract clean structured data from the answer without changing meaning.",
+ "3. Select the simplest appropriate chart type.",
+ "4. Return one JSON object matching the requested schema.",
+ "",
+ "Chart selection:",
+ "- For trends over time, use line.",
+ "- For comparisons across categories, use bar or column.",
+ "- For part-to-whole relationships, use pie only when there are few categories.",
+ "",
+ "Rules:",
+ "- Do not create a chart when there is no explicit concrete data.",
+ "- Do not fabricate data, fill missing values, infer hidden numbers, or change units.",
+ "- Use type none with a short reason when a diagram should not be created.",
+ '- For charts, set source exactly to "chart-visualization-skills".',
+ "- Write a concise title that summarizes the chart's core message, not a generic chart type.",
+ "- For bar, column, and pie data, use category + value.",
+ "- For line data, use time + value.",
+ "- Preserve negative values for bar, column, and line charts when the answer explicitly contains them.",
+ "- Use pie only for positive part-to-whole values; do not use pie for negative or mixed-sign data.",
+ "- Select only one chart: the one with the highest information value.",
+ "- Output JSON only, with no explanations and no Markdown.",
+ "",
+ "AntV chart visualization skill context:",
+ antvSkillContext,
+ "",
+ "Answer content:",
+ answer,
+ ].join("\n")
+}
+
+export function getAntvChartSkillContext(answer: string): string {
+ try {
+ const skills = retrieveAntvChartSkills(
+ buildAntvSkillQuery(answer),
+ ANTV_CHART_SKILL_TOP_K,
+ )
+ const context = formatAntvChartSkills(skills)
+ return context.length > 0
+ ? context.slice(0, MAX_ANTV_SKILL_CONTEXT_CHARS)
+ : "No AntV chart visualization skill content was returned."
+ } catch (error) {
+ logger.warn("chat-diagram: AntV skill retrieval failed", {
+ error: summarizeUnknownError(error),
+ })
+ return [
+ "AntV chart visualization skill retrieval failed.",
+ "Continue with the explicit chart-selection and no-fabrication rules above.",
+ ].join("\n")
+ }
+}
+
+export function retrieveAntvChartSkills(
+ query: string,
+ topK: number = ANTV_CHART_SKILL_TOP_K,
+): readonly Skill[] {
+ const queryTokens = tokenizeAntvChartSearchText(query)
+ const rankedSkills = indexedAntvG2Skills
+ .map((indexedSkill) => ({
+ skill: indexedSkill.skill,
+ score: scoreAntvChartSkill(indexedSkill, queryTokens),
+ }))
+ .filter((rankedSkill): boolean => rankedSkill.score > 0)
+ .sort((left, right): number => right.score - left.score)
+ .slice(0, topK)
+ .map((rankedSkill): Skill => rankedSkill.skill)
+
+ const infoSkill = buildAntvChartInfoSkill(antvG2SkillIndex.info)
+ return infoSkill ? [infoSkill, ...rankedSkills] : rankedSkills
+}
+
+function buildAntvSkillQuery(answer: string): string {
+ const normalizedAnswer = answer.replace(/\s+/gu, " ").trim()
+ return [
+ "g2 chart visualization bar column line pie comparison trend part-to-whole",
+ normalizedAnswer.slice(0, MAX_ANTV_SKILL_QUERY_CHARS),
+ ]
+ .filter((part): part is string => part.length > 0)
+ .join(" ")
+}
+
+function formatAntvChartSkills(skills: readonly Skill[]): string {
+ return skills
+ .map((skill): string => {
+ const content = skill.content?.trim()
+ const summary = [
+ `Skill: ${skill.id}`,
+ skill.title ? `Title: ${skill.title}` : null,
+ skill.description ? `Description: ${skill.description}` : null,
+ content
+ ? `Content:\n${content.slice(0, MAX_ANTV_SKILL_CONTENT_CHARS)}`
+ : null,
+ ]
+ .filter((line): line is string => Boolean(line))
+ .join("\n")
+ return summary
+ })
+ .filter((entry): boolean => entry.length > 0)
+ .join("\n\n---\n\n")
+}
+
+function buildAntvChartSkillSearchIndex(
+ skills: readonly Skill[],
+): readonly IndexedAntvChartSkill[] {
+ return skills.map((skill): IndexedAntvChartSkill => ({
+ skill,
+ tokenWeights: createAntvChartSkillTokenWeights(skill),
+ }))
+}
+
+function createAntvChartSkillTokenWeights(
+ skill: Skill,
+): ReadonlyMap {
+ const tokenWeights = new Map()
+ addWeightedAntvChartTokens(tokenWeights, skill.id, 8)
+ addWeightedAntvChartTokens(tokenWeights, skill.title ?? "", 8)
+ addWeightedAntvChartTokens(tokenWeights, skill.tags.join(" "), 7)
+ addWeightedAntvChartTokens(tokenWeights, skill.category, 5)
+ addWeightedAntvChartTokens(tokenWeights, skill.subcategory, 5)
+ addWeightedAntvChartTokens(tokenWeights, skill.description ?? "", 3)
+ addWeightedAntvChartTokens(tokenWeights, skill.use_cases.join(" "), 2)
+ addWeightedAntvChartTokens(
+ tokenWeights,
+ skill.content?.slice(0, 2_000) ?? "",
+ 1,
+ )
+ return tokenWeights
+}
+
+function addWeightedAntvChartTokens(
+ tokenWeights: Map,
+ text: string,
+ weight: number,
+): void {
+ for (const token of tokenizeAntvChartSearchText(text)) {
+ tokenWeights.set(token, (tokenWeights.get(token) ?? 0) + weight)
+ }
+}
+
+function tokenizeAntvChartSearchText(text: string): readonly string[] {
+ const matches = text.toLowerCase().match(/[a-z0-9]+|[\p{Script=Han}]+/gu)
+ return (matches ?? []).filter(
+ (token): boolean =>
+ token.length > 1 && !ANTV_CHART_SEARCH_STOP_WORDS.has(token),
+ )
+}
+
+function scoreAntvChartSkill(
+ indexedSkill: IndexedAntvChartSkill,
+ queryTokens: readonly string[],
+): number {
+ return queryTokens.reduce((score, token): number => {
+ return score + (indexedSkill.tokenWeights.get(token) ?? 0)
+ }, 0)
+}
+
+function buildAntvChartInfoSkill(
+ info: AntvChartSkillInfo | undefined,
+): Skill | undefined {
+ if (!info) {
+ return undefined
+ }
+
+ return {
+ id: `__info__${ANTV_CHART_LIBRARY}`,
+ title: info.name ?? "AntV G2",
+ description: info.description ?? "AntV G2 chart visualization constraints.",
+ library: ANTV_CHART_LIBRARY,
+ version: "",
+ category: "__info__",
+ subcategory: "",
+ tags: [],
+ difficulty: "",
+ use_cases: [],
+ anti_patterns: [],
+ related: [],
+ content: info.constraintsContent,
+ }
+}
+
+function normalizeChatDiagramSpec(spec: ChatDiagramSpec): ChatDiagramSpec {
+ if (spec.type === "none") {
+ return {
+ type: "none",
+ reason: spec.reason.trim().slice(0, MAX_REASON_CHARS),
+ }
+ }
+
+ const data = spec.data
+ .map((datum) => ({
+ ...datum,
+ category: normalizeLabel(datum.category),
+ time: normalizeLabel(datum.time),
+ }))
+ .filter((datum) => Number.isFinite(datum.value))
+ .filter((datum): boolean =>
+ spec.type === "line"
+ ? Boolean(datum.time)
+ : Boolean(datum.category),
+ )
+ .slice(0, 12)
+
+ if (data.length < 2) {
+ return {
+ type: "none",
+ reason: "The answer did not contain enough concrete data for a chart.",
+ }
+ }
+
+ if (spec.type === "pie" && data.some((datum): boolean => datum.value <= 0)) {
+ return {
+ type: "none",
+ reason:
+ "The answer did not contain positive part-to-whole data for a pie chart.",
+ }
+ }
+
+ return {
+ ...spec,
+ title: spec.title.trim(),
+ axisXTitle: normalizeLabel(spec.axisXTitle),
+ axisYTitle: normalizeLabel(spec.axisYTitle),
+ data,
+ }
+}
+
+function normalizeLabel(value: string | undefined): string | undefined {
+ const normalized = value?.replace(/\s+/gu, " ").trim()
+ return normalized && normalized.length > 0 ? normalized : undefined
+}
diff --git a/src/domains/chat/index.test.ts b/src/domains/chat/index.test.ts
index e5066e7..7b11a42 100644
--- a/src/domains/chat/index.test.ts
+++ b/src/domains/chat/index.test.ts
@@ -9,7 +9,9 @@ import {
generateAgenticOutputManifest,
parseChatRequestBody,
} from "."
+import type { HardenMediaAssetUrlsInput } from "./media-asset-hardening"
import type { Source } from "@/infrastructure/db/schema"
+import type { ChatArtifactView } from "@/domains/chat/types"
const loggerMock = vi.hoisted(() => ({
info: vi.fn(),
@@ -286,8 +288,11 @@ describe("answerQuestionWithRetrieval", () => {
});
it("passes retrieved image asset URLs to the answer prompt and citations", async () => {
+ const upstreamAssetUrl =
+ "https://knowhere-storage.example/results/job_1/images/image-9-Night%20Rocket%20Launch.jpg?AWSAccessKeyId=test";
const result = makeRetrievalResult({
chunkType: "image",
+ assetUrl: upstreamAssetUrl,
source: {
documentId: "doc_spacex",
sourceFileName: "document-generated.pdf",
@@ -311,9 +316,7 @@ describe("answerQuestionWithRetrieval", () => {
targetContent: "image",
purpose: "Find visual rocket launch chunks.",
});
- return makeHarnessRunResult(
- "Use this launch photo. https://blob.example/images/image-9-Night%20Rocket%20Launch.jpg",
- );
+ return makeHarnessRunResult(`Use this launch photo. ${upstreamAssetUrl}`);
});
const loadSourceAssetUrls = vi.fn().mockResolvedValue({
"images/image-9-Night Rocket Launch.jpg":
@@ -350,6 +353,7 @@ describe("answerQuestionWithRetrieval", () => {
dataType: 3,
});
expect(answer.answer).toBe("Use this launch photo.");
+ expect(answer.answer).not.toContain("knowhere-storage.example");
expect(answer.citations).toEqual([
{
...result,
@@ -363,6 +367,173 @@ describe("answerQuestionWithRetrieval", () => {
]);
});
+ it("hardens citation and artifact asset URLs before returning the answer", async () => {
+ const rawAssetUrl =
+ "https://knowhere-storage.example/results/job_1/images/id-front.jpg?AWSAccessKeyId=test";
+ const hardenedAssetUrl =
+ "https://blob.example/workspaces/workspace_1/chat-assets/source-source_identity/id-front.jpg";
+ const retrieval = {
+ query: vi.fn().mockResolvedValue({
+ results: [
+ makeRetrievalResult({
+ chunkType: "image",
+ assetUrl: rawAssetUrl,
+ source: {
+ documentId: "doc_identity",
+ sourceFileName: "document-generated.pdf",
+ sectionPath: "images/id-front.jpg",
+ },
+ }),
+ ],
+ evidenceText: "Identity image evidence.",
+ referencedChunks: [],
+ namespace: "notebook-workspace",
+ query: "identity front image",
+ routerUsed: "workflow_single_step",
+ answerText: null,
+ }),
+ };
+ const generateAnswer = vi.fn(async ({ searchSources }) => {
+ await searchSources({
+ query: "identity front image",
+ targetContent: "image",
+ });
+ return {
+ manifest: {
+ text: `Use this image. ${rawAssetUrl}`,
+ citations: [],
+ artifacts: [
+ {
+ type: "image",
+ ref: "asset:r1:result:1",
+ display: true,
+ reason: "Requested identity image",
+ },
+ ],
+ unresolved: [],
+ },
+ trace: {
+ ...makeHarnessRunResult("").trace,
+ finalized: true,
+ ledger: {
+ retrievalCount: 1,
+ evidenceText: ["Identity image evidence."],
+ stopReasons: [],
+ failureReasons: [],
+ decisionTraces: [],
+ chunks: [
+ {
+ ref: "r1:result:1",
+ kind: "result",
+ content: "",
+ contentPreview: "",
+ chunkType: "image",
+ score: 0.9,
+ assetUrl: rawAssetUrl,
+ assetRef: "asset:r1:result:1",
+ source: {
+ documentId: "doc_identity",
+ sourceFileName: "document-generated.pdf",
+ sectionPath: "images/id-front.jpg",
+ },
+ },
+ ],
+ assets: [
+ {
+ ref: "asset:r1:result:1",
+ chunkRef: "r1:result:1",
+ type: "image",
+ assetUrl: rawAssetUrl,
+ label: "document-generated.pdf / id front / image",
+ source: {
+ documentId: "doc_identity",
+ sourceFileName: "document-generated.pdf",
+ sectionPath: "images/id-front.jpg",
+ },
+ },
+ ],
+ },
+ },
+ } satisfies HarnessRunResult;
+ });
+ const hardenMediaAssetUrls = vi.fn(
+ async ({
+ results,
+ artifacts,
+ }: HardenMediaAssetUrlsInput): Promise<{
+ results: RetrievalResult[]
+ artifacts?: ChatArtifactView[]
+ }> => ({
+ results: results.map((result): RetrievalResult => ({
+ ...result,
+ assetUrl:
+ result.assetUrl === rawAssetUrl ? hardenedAssetUrl : result.assetUrl,
+ })),
+ artifacts: artifacts?.map((artifact): ChatArtifactView => ({
+ ...artifact,
+ assetUrl:
+ artifact.assetUrl === rawAssetUrl
+ ? hardenedAssetUrl
+ : artifact.assetUrl,
+ citation: artifact.citation
+ ? {
+ ...artifact.citation,
+ assetUrl:
+ artifact.citation.assetUrl === rawAssetUrl
+ ? hardenedAssetUrl
+ : artifact.citation.assetUrl,
+ }
+ : undefined,
+ })),
+ }),
+ );
+
+ const answer = await Effect.runPromise(
+ answerQuestionWithRetrieval({
+ question: "Show me the identity image.",
+ namespace: "notebook-workspace",
+ sources: [
+ makeSource({
+ id: "source_identity",
+ title: "identity.pdf",
+ knowhereDocumentId: "doc_identity",
+ }),
+ ],
+ excludedSourceIds: [],
+ retrieval,
+ generateAnswer,
+ hardenMediaAssetUrls,
+ messages: [],
+ }),
+ );
+
+ expect(hardenMediaAssetUrls).toHaveBeenCalledWith({
+ results: [
+ expect.objectContaining({
+ assetUrl: rawAssetUrl,
+ source: expect.objectContaining({
+ sourceFileName: "identity.pdf",
+ }),
+ }),
+ ],
+ artifacts: [
+ expect.objectContaining({
+ assetUrl: rawAssetUrl,
+ citation: expect.objectContaining({ assetUrl: rawAssetUrl }),
+ }),
+ ],
+ });
+ expect(answer.answer).toBe("Use this image.");
+ expect(answer.answer).not.toContain("knowhere-storage.example");
+ expect(answer.citations.map((citation) => citation.assetUrl)).toEqual([
+ hardenedAssetUrl,
+ ]);
+ expect(answer.artifacts?.map((artifact) => artifact.assetUrl)).toEqual([
+ hardenedAssetUrl,
+ ]);
+ expect(answer.artifacts?.[0]?.citation?.assetUrl).toBe(hardenedAssetUrl);
+ });
+
it("returns only harness-selected artifacts when retrieval has extra media candidates", async () => {
const frontAssetUrl = "https://blob.example/images/id-front.jpg";
const backAssetUrl = "https://blob.example/images/id-back.jpg";
diff --git a/src/domains/chat/index.ts b/src/domains/chat/index.ts
index d9070f7..9dc0f6c 100644
--- a/src/domains/chat/index.ts
+++ b/src/domains/chat/index.ts
@@ -218,7 +218,7 @@ export const answerQuestionWithRetrieval = (
}
}
- const results = yield* Effect.tryPromise(() =>
+ const enrichedResults = yield* Effect.tryPromise(() =>
enrichRetrievalResultsWithAssetUrls({
results: useNotebookSourceTitles(rawResults, input.sources),
sources: input.sources,
@@ -226,21 +226,35 @@ export const answerQuestionWithRetrieval = (
evidenceText: formatRetrievalEvidenceText(retrievalResponses),
}),
)
+ const artifacts = toChatArtifactViewsFromHarness(generatedAnswer, input.sources)
+ const hardenedMedia = yield* Effect.tryPromise(() =>
+ hardenAnswerMediaAssetUrls({
+ input,
+ results: enrichedResults,
+ artifacts,
+ }),
+ )
const answer = sanitizeGeneratedAnswer({
answer: generatedAnswer.manifest.text,
- results,
+ results: getGeneratedAnswerSanitizerResults({
+ rawResults,
+ enrichedResults,
+ hardenedResults: hardenedMedia.results,
+ artifacts,
+ hardenedArtifacts: hardenedMedia.artifacts,
+ }),
})
- const citationResults = results
- const artifacts = toChatArtifactViewsFromHarness(generatedAnswer, input.sources)
+ const citationResults = hardenedMedia.results
+ const displayArtifacts = hardenedMedia.artifacts ?? []
logger.info("chat-agent: answer complete", {
answerLength: answer.length,
citationCount: citationResults.length,
- artifactCount: artifacts?.length ?? 0,
+ artifactCount: displayArtifacts.length,
})
return {
answer,
citations: toChatCitationViews(citationResults, answer),
- artifacts: artifacts ?? [],
+ artifacts: displayArtifacts,
}
})
@@ -382,6 +396,114 @@ function normalizeHarnessSource(
}
}
+type AnswerMediaAssetHardeningInput = {
+ readonly input: AnswerQuestionInput
+ readonly results: readonly RetrievalResult[]
+ readonly artifacts?: readonly ChatArtifactView[]
+}
+
+async function hardenAnswerMediaAssetUrls({
+ input,
+ results,
+ artifacts,
+}: AnswerMediaAssetHardeningInput): Promise<{
+ readonly results: RetrievalResult[]
+ readonly artifacts?: ChatArtifactView[]
+}> {
+ if (!input.hardenMediaAssetUrls) {
+ return {
+ results: [...results],
+ ...(artifacts ? { artifacts: [...artifacts] } : {}),
+ }
+ }
+
+ try {
+ const hardened = await input.hardenMediaAssetUrls({ results, artifacts })
+ const hardenedArtifacts = hardened.artifacts ?? artifacts
+ return {
+ results: hardened.results,
+ ...(hardenedArtifacts ? { artifacts: [...hardenedArtifacts] } : {}),
+ }
+ } catch (error) {
+ logger.warn("chat-agent: media asset hardening failed; using raw URLs", {
+ error: formatUnknownError(error),
+ })
+ return {
+ results: [...results],
+ ...(artifacts ? { artifacts: [...artifacts] } : {}),
+ }
+ }
+}
+
+type GeneratedAnswerSanitizerResultsInput = {
+ readonly rawResults: readonly RetrievalResult[]
+ readonly enrichedResults: readonly RetrievalResult[]
+ readonly hardenedResults: readonly RetrievalResult[]
+ readonly artifacts?: readonly ChatArtifactView[]
+ readonly hardenedArtifacts?: readonly ChatArtifactView[]
+}
+
+function getGeneratedAnswerSanitizerResults({
+ rawResults,
+ enrichedResults,
+ hardenedResults,
+ artifacts,
+ hardenedArtifacts,
+}: GeneratedAnswerSanitizerResultsInput): RetrievalResult[] {
+ return [
+ ...rawResults,
+ ...enrichedResults,
+ ...hardenedResults,
+ ...toArtifactSanitizerResults(artifacts),
+ ...toArtifactSanitizerResults(hardenedArtifacts),
+ ]
+}
+
+function toArtifactSanitizerResults(
+ artifacts: readonly ChatArtifactView[] | undefined,
+): RetrievalResult[] {
+ return (artifacts ?? []).flatMap((artifact): RetrievalResult[] => {
+ const results: RetrievalResult[] = []
+ if (artifact.assetUrl) {
+ results.push(
+ toArtifactSanitizerResult({
+ assetUrl: artifact.assetUrl,
+ artifact,
+ citation: artifact.citation,
+ }),
+ )
+ }
+ if (artifact.citation?.assetUrl) {
+ results.push(
+ toArtifactSanitizerResult({
+ assetUrl: artifact.citation.assetUrl,
+ artifact,
+ citation: artifact.citation,
+ }),
+ )
+ }
+ return results
+ })
+}
+
+function toArtifactSanitizerResult(input: {
+ readonly assetUrl: string
+ readonly artifact: ChatArtifactView
+ readonly citation?: ChatCitationView
+}): RetrievalResult {
+ return {
+ content: input.citation?.content ?? "",
+ chunkType: input.citation?.chunkType ?? input.artifact.type,
+ score: input.citation?.score ?? null,
+ assetUrl: input.assetUrl,
+ source: {
+ documentId: input.citation?.source.documentId ?? undefined,
+ sourceFileName: input.citation?.source.sourceFileName ?? undefined,
+ sectionPath: input.citation?.source.sectionPath ?? undefined,
+ },
+ }
+}
+
type GeneratedAnswerSanitizerInput = {
readonly answer: string
readonly results: readonly RetrievalResult[]
@@ -451,6 +573,11 @@ function redactRawUrls(value: string): string {
return value.replace(RAW_URL_PATTERN, REDACTED_MEDIA_URL)
}
+function formatUnknownError(error: unknown): string {
+ if (error instanceof Error) return error.message
+ return String(error)
+}
+
function buildRetrievalQueryParams(input: {
readonly input: AgenticRetrievalQuery
readonly fallbackQuestion: string
diff --git a/src/domains/chat/media-asset-hardening.test.ts b/src/domains/chat/media-asset-hardening.test.ts
new file mode 100644
index 0000000..70cf562
--- /dev/null
+++ b/src/domains/chat/media-asset-hardening.test.ts
@@ -0,0 +1,296 @@
+import { afterEach, describe, expect, it, vi } from "vitest"
+import type { RetrievalResult } from "@ontos-ai/knowhere-sdk"
+
+import type { Source } from "@/infrastructure/db/schema"
+import {
+ hardenChatMediaAssetUrls,
+ type ChatMediaAssetBlobStore,
+ type FetchChatMediaAsset,
+} from "./media-asset-hardening"
+
+const loggerMock = vi.hoisted(() => ({
+ warn: vi.fn(),
+}))
+
+vi.mock("@/lib/logger", () => ({
+ logger: {
+ warn: loggerMock.warn,
+ },
+}))
+
+afterEach(() => {
+ vi.clearAllMocks()
+ delete process.env.KNOWHERE_BASE_URL
+})
+
+describe("hardenChatMediaAssetUrls", () => {
+ it("copies upstream absolute asset URLs into Notebook chat assets", async () => {
+ const rawAssetUrl =
+ "https://knowhere-storage.example/results/job_1/images/image-6-%E6%83%85%E6%84%9F%E5%88%86%E7%B1%BB%E6%A8%A1%E5%9E%8B.jpg?AWSAccessKeyId=test&Signature=secret"
+ const blobStore = makeBlobStore(
+ "https://blob.example/workspaces/workspace_1/chat-assets/source-source_1/image-6.jpg",
+ )
+ const fetchAsset = makeFetchAsset("image-bytes", "image/jpeg")
+
+ const result = await hardenChatMediaAssetUrls({
+ workspaceId: "workspace_1",
+ sources: [
+ makeSource({
+ id: "source_1",
+ knowhereDocumentId: "doc_model",
+ }),
+ ],
+ results: [
+ makeRetrievalResult({
+ chunkType: "image",
+ assetUrl: rawAssetUrl,
+ source: {
+ documentId: "doc_model",
+ sourceFileName: "model.pdf",
+ sectionPath: "Root",
+ },
+ }),
+ ],
+ blobStore,
+ fetchAsset,
+ })
+
+ expect(fetchAsset).toHaveBeenCalledWith(rawAssetUrl)
+ expect(blobStore.put).toHaveBeenCalledWith(
+ expect.stringMatching(
+ /^workspaces\/workspace_1\/chat-assets\/source-source_1\/[a-f0-9]{24}-image-6\.jpg$/,
+ ),
+ expect.any(Buffer),
+ {
+ access: "public",
+ allowOverwrite: true,
+ contentType: "image/jpeg",
+ multipart: true,
+ },
+ )
+ expect(result.results[0]?.assetUrl).toBe(
+ "https://blob.example/workspaces/workspace_1/chat-assets/source-source_1/image-6.jpg",
+ )
+ })
+
+ it("uses an existing parsed asset URL before fetching the upstream URL", async () => {
+ const rawAssetUrl =
+ "https://knowhere-storage.example/results/job_1/images/id-front.jpg?AWSAccessKeyId=test"
+ const parsedAssetUrl =
+ "https://blob.example/workspaces/workspace_1/sources/source_identity/parsed-result/images/id-front.jpg"
+ const blobStore = makeBlobStore("https://blob.example/should-not-upload.jpg")
+ const fetchAsset = makeFetchAsset("should-not-fetch", "image/jpeg")
+ const loadSourceAssetUrls = vi.fn().mockResolvedValue({
+ "images/id-front.jpg": parsedAssetUrl,
+ })
+
+ const result = await hardenChatMediaAssetUrls({
+ workspaceId: "workspace_1",
+ sources: [
+ makeSource({
+ id: "source_identity",
+ knowhereDocumentId: "doc_identity",
+ }),
+ ],
+ results: [
+ makeRetrievalResult({
+ chunkType: "image",
+ assetUrl: rawAssetUrl,
+ source: {
+ documentId: "doc_identity",
+ sourceFileName: "identity.pdf",
+ sectionPath: "images/id-front.jpg",
+ },
+ }),
+ ],
+ loadSourceAssetUrls,
+ blobStore,
+ fetchAsset,
+ })
+
+ expect(loadSourceAssetUrls).toHaveBeenCalledWith(
+ expect.objectContaining({ id: "source_identity" }),
+ )
+ expect(fetchAsset).not.toHaveBeenCalled()
+ expect(blobStore.put).not.toHaveBeenCalled()
+ expect(result.results[0]?.assetUrl).toBe(parsedAssetUrl)
+ })
+
+ it("fetches demo asset routes from the upstream demo API", async () => {
+ process.env.KNOWHERE_BASE_URL = "https://knowhere.example"
+ const demoAssetUrl =
+ "/api/demo-sources/demo_source_1/assets/images/demo%20chart.png"
+ const blobStore = makeBlobStore(
+ "https://blob.example/workspaces/workspace_1/chat-assets/demo-demo_source_1/demo-chart.png",
+ )
+ const fetchAsset = makeFetchAsset("demo-image", "image/png")
+
+ const result = await hardenChatMediaAssetUrls({
+ workspaceId: "workspace_1",
+ sources: [],
+ results: [
+ makeRetrievalResult({
+ chunkType: "image",
+ assetUrl: demoAssetUrl,
+ source: {
+ documentId: "demo_doc",
+ sourceFileName: "demo.pdf",
+ sectionPath: "images/demo chart.png",
+ },
+ }),
+ ],
+ blobStore,
+ fetchAsset,
+ })
+
+ expect(fetchAsset).toHaveBeenCalledWith(
+ "https://knowhere.example/api/v1/demo/sources/demo_source_1/assets/images/demo%20chart.png",
+ )
+ expect(fetchAsset).not.toHaveBeenCalledWith(demoAssetUrl)
+ expect(blobStore.put).toHaveBeenCalledWith(
+ expect.stringContaining("/chat-assets/demo-demo_source_1/"),
+ expect.any(Buffer),
+ expect.objectContaining({ contentType: "image/png" }),
+ )
+ expect(result.results[0]?.assetUrl).toBe(
+ "https://blob.example/workspaces/workspace_1/chat-assets/demo-demo_source_1/demo-chart.png",
+ )
+ })
+
+ it("falls back to the raw URL when hardening fails", async () => {
+ const rawAssetUrl =
+ "https://knowhere-storage.example/results/job_1/tables/table-1.html?AWSAccessKeyId=test"
+ const blobStore = makeBlobStore("https://blob.example/should-not-exist.html")
+ const fetchAsset: FetchChatMediaAsset = vi
+ .fn()
+ .mockRejectedValue(new Error("expired URL"))
+
+ const result = await hardenChatMediaAssetUrls({
+ workspaceId: "workspace_1",
+ sources: [],
+ results: [
+ makeRetrievalResult({
+ chunkType: "table",
+ assetUrl: rawAssetUrl,
+ }),
+ ],
+ blobStore,
+ fetchAsset,
+ })
+
+ expect(result.results[0]?.assetUrl).toBe(rawAssetUrl)
+ expect(blobStore.put).not.toHaveBeenCalled()
+ expect(loggerMock.warn).toHaveBeenCalledWith(
+ "chat-agent: media asset hardening failed; keeping raw URL",
+ expect.objectContaining({
+ assetUrl:
+ "https://knowhere-storage.example/results/job_1/tables/table-1.html",
+ error: "expired URL",
+ }),
+ )
+ })
+
+ it("rewrites artifact asset URLs and nested citation asset URLs", async () => {
+ const rawAssetUrl =
+ "https://knowhere-storage.example/results/job_1/images/front.jpg?AWSAccessKeyId=test"
+ const blobAssetUrl =
+ "https://blob.example/workspaces/workspace_1/chat-assets/source-source_identity/front.jpg"
+ const blobStore = makeBlobStore(blobAssetUrl)
+ const fetchAsset = makeFetchAsset("front-image", "image/jpeg")
+
+ const result = await hardenChatMediaAssetUrls({
+ workspaceId: "workspace_1",
+ sources: [
+ makeSource({
+ id: "source_identity",
+ knowhereDocumentId: "doc_identity",
+ }),
+ ],
+ results: [],
+ artifacts: [
+ {
+ type: "image",
+ ref: "asset:r1:result:1",
+ assetUrl: rawAssetUrl,
+ label: "identity.pdf / front / image",
+ citation: {
+ chunkType: "image",
+ score: 0.9,
+ assetUrl: rawAssetUrl,
+ source: {
+ documentId: "doc_identity",
+ sourceFileName: "identity.pdf",
+ sectionPath: "images/front.jpg",
+ },
+ },
+ },
+ ],
+ blobStore,
+ fetchAsset,
+ })
+
+ const [artifact] = result.artifacts ?? []
+ expect(fetchAsset).toHaveBeenCalledTimes(1)
+ expect(artifact?.assetUrl).toBe(blobAssetUrl)
+ expect(artifact?.citation?.assetUrl).toBe(blobAssetUrl)
+ })
+})
+
+function makeFetchAsset(
+ body: string,
+ contentType: string,
+): FetchChatMediaAsset {
+ return vi.fn().mockResolvedValue(
+ new Response(Buffer.from(body), {
+ status: 200,
+ headers: {
+ "content-type": contentType,
+ },
+ }),
+ )
+}
+
+function makeBlobStore(url: string): ChatMediaAssetBlobStore {
+ return {
+ put: vi.fn().mockResolvedValue({ url }),
+ }
+}
+
+function makeRetrievalResult(
+ overrides: Partial = {},
+): RetrievalResult {
+ return {
+ content: "Asset evidence",
+ chunkType: "text",
+ score: 0.9,
+ source: {
+ documentId: "doc_1",
+ sourceFileName: "source.pdf",
+ sectionPath: "Root",
+ },
+ ...overrides,
+ }
+}
+
+function makeSource(overrides: Partial = {}): Source {
+ return {
+ id: "source_1",
+ workspaceId: "workspace_1",
+ title: "source.pdf",
+ mimeType: "application/pdf",
+ sizeBytes: 100,
+ status: "ready",
+ failureReason: null,
+ knowhereJobId: "job_123",
+ knowhereDocumentId: "doc_1",
+ stagedBlobPathname: null,
+ stagedBlobUrl: null,
+ originalBlobPathname: null,
+ originalBlobUrl: null,
+ demoKey: null,
+ createdAt: new Date("2026-05-06T00:00:00Z"),
+ updatedAt: new Date("2026-05-06T00:00:00Z"),
+ deletedAt: null,
+ ...overrides,
+ }
+}
diff --git a/src/domains/chat/media-asset-hardening.ts b/src/domains/chat/media-asset-hardening.ts
new file mode 100644
index 0000000..e0a813e
--- /dev/null
+++ b/src/domains/chat/media-asset-hardening.ts
@@ -0,0 +1,546 @@
+import path from "node:path"
+import { createHash } from "node:crypto"
+import { put } from "@vercel/blob"
+import type { RetrievalResult } from "@ontos-ai/knowhere-sdk"
+
+import type {
+ ChatArtifactView,
+ ChatCitationView,
+} from "@/domains/chat/types"
+import type { Source } from "@/infrastructure/db/schema"
+import { knowhereDemoApi } from "@/integrations/knowhere-demo"
+import { logger } from "@/lib/logger"
+import type { LoadSourceAssetUrls } from "./media-assets"
+import { resolveAssetUrlFromReferenceText } from "./media-assets"
+
+export type HardenMediaAssetUrlsInput = {
+ readonly results: readonly RetrievalResult[]
+ readonly artifacts?: readonly ChatArtifactView[]
+}
+
+export type HardenMediaAssetUrlsResult = {
+ readonly results: RetrievalResult[]
+ readonly artifacts?: ChatArtifactView[]
+}
+
+export type HardenMediaAssetUrls = (
+ input: HardenMediaAssetUrlsInput,
+) => Promise
+
+export type ChatMediaAssetBlobStore = {
+ readonly put: (
+ pathname: string,
+ body: Buffer,
+ options: ChatMediaAssetBlobPutOptions,
+ ) => Promise<{ readonly url: string }>
+}
+
+export type ChatMediaAssetBlobPutOptions = {
+ readonly access?: "public"
+ readonly allowOverwrite?: boolean
+ readonly contentType: string
+ readonly multipart?: boolean
+}
+
+export type FetchChatMediaAsset = (url: string) => Promise
+
+export type HardenChatMediaAssetUrlsForWorkspaceInput =
+ HardenMediaAssetUrlsInput & {
+ readonly workspaceId: string
+ readonly sources: readonly Source[]
+ readonly loadSourceAssetUrls?: LoadSourceAssetUrls
+ readonly blobStore?: ChatMediaAssetBlobStore
+ readonly fetchAsset?: FetchChatMediaAsset
+ }
+
+type AssetReferenceSource = ChatCitationView["source"]
+
+type AssetUrlReference = {
+ readonly assetUrl: string
+ readonly source?: AssetReferenceSource
+ readonly content?: string
+}
+
+type AssetFetchRequest = {
+ readonly fetchUrl: string
+ readonly canonicalKey: string
+ readonly sourceSegment: string
+ readonly suggestedFileName: string
+}
+
+type HardeningContext = {
+ readonly workspaceId: string
+ readonly sourcesByDocumentId: ReadonlyMap
+ readonly loadSourceAssetUrls?: LoadSourceAssetUrls
+ readonly assetUrlsBySourceId: Map<
+ string,
+ Promise>>
+ >
+ readonly hardenedAssetUrlByKey: Map>
+ readonly blobStore: ChatMediaAssetBlobStore
+ readonly fetchAsset: FetchChatMediaAsset
+}
+
+type DemoAssetRoute = {
+ readonly demoSourceId: string
+ readonly encodedAssetPath: string
+ readonly decodedAssetPath: string
+}
+
+const chatAssetsDirectoryName = "chat-assets"
+const parsedResultDirectoryName = "parsed-result"
+const fallbackContentType = "application/octet-stream"
+const defaultFetchAsset: FetchChatMediaAsset = (url) => fetch(url)
+const defaultBlobStore: ChatMediaAssetBlobStore = {
+ put: (pathname, body, options) =>
+ put(pathname, body, {
+ access: options.access ?? "public",
+ allowOverwrite: options.allowOverwrite,
+ contentType: options.contentType,
+ multipart: options.multipart,
+ }),
+}
+
+export async function hardenChatMediaAssetUrls({
+ results,
+ artifacts,
+ workspaceId,
+ sources,
+ loadSourceAssetUrls,
+ blobStore = defaultBlobStore,
+ fetchAsset = defaultFetchAsset,
+}: HardenChatMediaAssetUrlsForWorkspaceInput): Promise {
+ const context: HardeningContext = {
+ workspaceId,
+ sourcesByDocumentId: createSourcesByDocumentId(sources),
+ loadSourceAssetUrls,
+ assetUrlsBySourceId: new Map(),
+ hardenedAssetUrlByKey: new Map(),
+ blobStore,
+ fetchAsset,
+ }
+
+ const hardenedResults = await Promise.all(
+ results.map((result): Promise =>
+ hardenRetrievalResult(result, context),
+ ),
+ )
+ const hardenedArtifacts = artifacts
+ ? await Promise.all(
+ artifacts.map((artifact): Promise =>
+ hardenArtifact(artifact, context),
+ ),
+ )
+ : undefined
+
+ return {
+ results: hardenedResults,
+ ...(hardenedArtifacts ? { artifacts: hardenedArtifacts } : {}),
+ }
+}
+
+async function hardenRetrievalResult(
+ result: RetrievalResult,
+ context: HardeningContext,
+): Promise {
+ const assetUrl = getTrimmedString(result.assetUrl)
+ if (!assetUrl) return result
+
+ const hardenedAssetUrl = await hardenAssetUrl(
+ {
+ assetUrl,
+ source: result.source,
+ content: result.content,
+ },
+ context,
+ )
+ if (hardenedAssetUrl === result.assetUrl) return result
+
+ return {
+ ...result,
+ assetUrl: hardenedAssetUrl,
+ }
+}
+
+async function hardenArtifact(
+ artifact: ChatArtifactView,
+ context: HardeningContext,
+): Promise {
+ const citation = artifact.citation
+ ? await hardenCitation(artifact.citation, context)
+ : undefined
+ const assetUrl = getTrimmedString(artifact.assetUrl)
+ if (!assetUrl) {
+ return citation && citation !== artifact.citation
+ ? { ...artifact, citation }
+ : artifact
+ }
+
+ const hardenedAssetUrl = await hardenAssetUrl(
+ {
+ assetUrl,
+ source: artifact.citation?.source,
+ content: artifact.label,
+ },
+ context,
+ )
+ const hasAssetUrlChange = hardenedAssetUrl !== artifact.assetUrl
+ const hasCitationChange = citation && citation !== artifact.citation
+ if (!hasAssetUrlChange && !hasCitationChange) return artifact
+
+ return {
+ ...artifact,
+ assetUrl: hardenedAssetUrl,
+ ...(citation ? { citation } : {}),
+ }
+}
+
+async function hardenCitation(
+ citation: ChatCitationView,
+ context: HardeningContext,
+): Promise {
+ const assetUrl = getTrimmedString(citation.assetUrl)
+ if (!assetUrl) return citation
+
+ const hardenedAssetUrl = await hardenAssetUrl(
+ {
+ assetUrl,
+ source: citation.source,
+ content: citation.content,
+ },
+ context,
+ )
+ if (hardenedAssetUrl === citation.assetUrl) return citation
+
+ return {
+ ...citation,
+ assetUrl: hardenedAssetUrl,
+ }
+}
+
+async function hardenAssetUrl(
+ reference: AssetUrlReference,
+ context: HardeningContext,
+): Promise {
+ if (isNotebookOwnedAssetUrl(reference.assetUrl)) {
+ return reference.assetUrl
+ }
+
+ const parsedAssetUrl = await resolveParsedAssetUrl(reference, context)
+ if (parsedAssetUrl) return parsedAssetUrl
+
+ const fetchRequest = resolveAssetFetchRequest(reference.assetUrl)
+ if (!fetchRequest) return reference.assetUrl
+
+ const source = resolveSourceForReference(reference, context)
+ const sourceSegment = source
+ ? `source-${toSafePathSegment(source.id)}`
+ : fetchRequest.sourceSegment
+ const hardeningKey = [
+ context.workspaceId,
+ source?.id ?? reference.source?.documentId ?? "",
+ fetchRequest.canonicalKey,
+ ].join("\0")
+ const cached = context.hardenedAssetUrlByKey.get(hardeningKey)
+ if (cached) return cached
+
+ const hardenedAssetUrl = copyAssetToBlob({
+ reference,
+ fetchRequest,
+ context,
+ sourceSegment,
+ hardeningKey,
+ })
+ context.hardenedAssetUrlByKey.set(hardeningKey, hardenedAssetUrl)
+ return hardenedAssetUrl
+}
+
+async function resolveParsedAssetUrl(
+ reference: AssetUrlReference,
+ context: HardeningContext,
+): Promise {
+ const source = resolveSourceForReference(reference, context)
+ if (!source || !context.loadSourceAssetUrls) return null
+
+ const assetUrlsByFilePath = await getCachedSourceAssetUrls(source, context)
+ return resolveAssetUrlFromReferenceText({
+ values: [
+ reference.source?.sectionPath,
+ reference.content,
+ getAssetUrlPathname(reference.assetUrl),
+ ],
+ assetUrlsByFilePath,
+ })
+}
+
+async function getCachedSourceAssetUrls(
+ source: Source,
+ context: HardeningContext,
+): Promise>> {
+ const cached = context.assetUrlsBySourceId.get(source.id)
+ if (cached) return cached
+
+ const loaded = context.loadSourceAssetUrls
+ ? context.loadSourceAssetUrls(source).catch((error: unknown) => {
+ logger.warn("chat-agent: failed to load parsed asset map", {
+ sourceId: source.id,
+ error: formatUnknownError(error),
+ })
+ return {}
+ })
+ : Promise.resolve({})
+ context.assetUrlsBySourceId.set(source.id, loaded)
+ return loaded
+}
+
+async function copyAssetToBlob(input: {
+ readonly reference: AssetUrlReference
+ readonly fetchRequest: AssetFetchRequest
+ readonly context: HardeningContext
+ readonly sourceSegment: string
+ readonly hardeningKey: string
+}): Promise {
+ try {
+ const response = await input.context.fetchAsset(input.fetchRequest.fetchUrl)
+ if (!response.ok) {
+ logger.warn("chat-agent: media asset hardening fetch failed", {
+ assetUrl: redactAssetUrl(input.reference.assetUrl),
+ status: response.status,
+ })
+ return input.reference.assetUrl
+ }
+
+ const body = Buffer.from(await response.arrayBuffer())
+ const contentType = normalizeContentType(
+ response.headers.get("content-type"),
+ input.fetchRequest.suggestedFileName,
+ )
+ const blobPathname = getChatAssetBlobPathname({
+ workspaceId: input.context.workspaceId,
+ sourceSegment: input.sourceSegment,
+ hardeningKey: input.hardeningKey,
+ suggestedFileName: input.fetchRequest.suggestedFileName,
+ contentType,
+ })
+ const blob = await input.context.blobStore.put(blobPathname, body, {
+ access: "public",
+ allowOverwrite: true,
+ contentType,
+ multipart: true,
+ })
+ return blob.url
+ } catch (error) {
+ logger.warn("chat-agent: media asset hardening failed; keeping raw URL", {
+ assetUrl: redactAssetUrl(input.reference.assetUrl),
+ error: formatUnknownError(error),
+ })
+ return input.reference.assetUrl
+ }
+}
+
+function resolveAssetFetchRequest(assetUrl: string): AssetFetchRequest | null {
+ const demoAsset = parseDemoAssetRoute(assetUrl)
+ if (demoAsset) {
+ return {
+ fetchUrl: knowhereDemoApi.resolveApiURL(
+ `/api/v1/demo/sources/${encodeURIComponent(
+ demoAsset.demoSourceId,
+ )}/assets/${demoAsset.encodedAssetPath}`,
+ ),
+ canonicalKey: `demo:${demoAsset.demoSourceId}:${demoAsset.decodedAssetPath}`,
+ sourceSegment: `demo-${toSafePathSegment(demoAsset.demoSourceId)}`,
+ suggestedFileName: getPathBasename(demoAsset.decodedAssetPath),
+ }
+ }
+
+ const absoluteUrl = parseAbsoluteHttpUrl(assetUrl)
+ if (!absoluteUrl) return null
+
+ return {
+ fetchUrl: assetUrl,
+ canonicalKey: `url:${absoluteUrl.origin}${absoluteUrl.pathname}`,
+ sourceSegment: `external-${hashText(absoluteUrl.origin).slice(0, 16)}`,
+ suggestedFileName: getPathBasename(absoluteUrl.pathname),
+ }
+}
+
+function parseDemoAssetRoute(assetUrl: string): DemoAssetRoute | null {
+ const pathname = getAssetUrlPathname(assetUrl)
+ const match = /^\/api\/demo-sources\/([^/]+)\/assets\/(.+)$/.exec(pathname)
+ const encodedDemoSourceId = match?.[1]
+ const encodedAssetPath = match?.[2]
+ if (!encodedDemoSourceId || !encodedAssetPath) return null
+
+ const demoSourceId = decodeUrlComponent(encodedDemoSourceId)
+ const assetPathSegments = encodedAssetPath
+ .split("/")
+ .map(decodeUrlComponent)
+ .filter((segment): boolean => segment.length > 0)
+ if (!demoSourceId || assetPathSegments.length === 0) return null
+
+ return {
+ demoSourceId,
+ encodedAssetPath: assetPathSegments.map(encodeURIComponent).join("/"),
+ decodedAssetPath: assetPathSegments.join("/"),
+ }
+}
+
+function isNotebookOwnedAssetUrl(assetUrl: string): boolean {
+ const pathname = getAssetUrlPathname(assetUrl).toLowerCase()
+ if (
+ pathname.includes(`/${parsedResultDirectoryName}/`) ||
+ pathname.includes(`/${chatAssetsDirectoryName}/`)
+ ) {
+ return true
+ }
+
+ const absoluteUrl = parseAbsoluteHttpUrl(assetUrl)
+ const hostname = absoluteUrl?.hostname.toLowerCase()
+ return hostname?.endsWith(".blob.vercel-storage.com") === true
+}
+
+function getChatAssetBlobPathname(input: {
+ readonly workspaceId: string
+ readonly sourceSegment: string
+ readonly hardeningKey: string
+ readonly suggestedFileName: string
+ readonly contentType: string
+}): string {
+ const hash = hashText(input.hardeningKey).slice(0, 24)
+ const fileName = toSafeFileName(input.suggestedFileName, input.contentType)
+ return [
+ "workspaces",
+ toSafePathSegment(input.workspaceId),
+ chatAssetsDirectoryName,
+ input.sourceSegment,
+ `${hash}-${fileName}`,
+ ].join("/")
+}
+
+function normalizeContentType(
+ value: string | null,
+ fileName: string,
+): string {
+ const normalized = value?.replace(/\s+/g, " ").trim()
+ if (normalized) return normalized
+ return getContentTypeForPath(fileName)
+}
+
+function getContentTypeForPath(filePath: string): string {
+ const extension = path.extname(filePath).toLowerCase()
+ if (extension === ".jpg" || extension === ".jpeg") return "image/jpeg"
+ if (extension === ".png") return "image/png"
+ if (extension === ".gif") return "image/gif"
+ if (extension === ".webp") return "image/webp"
+ if (extension === ".svg") return "image/svg+xml"
+ if (extension === ".html" || extension === ".htm") {
+ return "text/html; charset=utf-8"
+ }
+ if (extension === ".csv") return "text/csv; charset=utf-8"
+ if (extension === ".pdf") return "application/pdf"
+ return fallbackContentType
+}
+
+function getExtensionForContentType(contentType: string): string {
+ const normalized = contentType.split(";")[0]?.trim().toLowerCase()
+ if (normalized === "image/jpeg") return ".jpg"
+ if (normalized === "image/png") return ".png"
+ if (normalized === "image/gif") return ".gif"
+ if (normalized === "image/webp") return ".webp"
+ if (normalized === "image/svg+xml") return ".svg"
+ if (normalized === "text/html") return ".html"
+ if (normalized === "text/csv") return ".csv"
+ if (normalized === "application/pdf") return ".pdf"
+ return ".bin"
+}
+
+function toSafeFileName(fileName: string, contentType: string): string {
+ const extension = getSafeFileExtension(fileName, contentType)
+ const stem = path.basename(fileName, path.extname(fileName))
+ const safeStem = toSafePathSegment(stem)
+ return `${safeStem}${extension}`
+}
+
+function getSafeFileExtension(fileName: string, contentType: string): string {
+ const extension = path.extname(fileName).toLowerCase()
+ if (/^\.[a-z0-9]{1,12}$/.test(extension)) return extension
+ return getExtensionForContentType(contentType)
+}
+
+function getPathBasename(value: string): string {
+ const decodedPath = decodeUrlComponent(value)
+ const basename = decodedPath.replaceAll("\\", "/").split("/").pop()
+ return basename && basename.trim().length > 0 ? basename : "asset"
+}
+
+function getAssetUrlPathname(assetUrl: string): string {
+ try {
+ return new URL(assetUrl, "http://notebook.local").pathname
+ } catch {
+ return assetUrl.split("?")[0] ?? assetUrl
+ }
+}
+
+function parseAbsoluteHttpUrl(assetUrl: string): URL | null {
+ try {
+ const url = new URL(assetUrl)
+ return url.protocol === "http:" || url.protocol === "https:" ? url : null
+ } catch {
+ return null
+ }
+}
+
+function resolveSourceForReference(
+ reference: AssetUrlReference,
+ context: HardeningContext,
+): Source | undefined {
+ const documentId = getTrimmedString(reference.source?.documentId)
+ return documentId ? context.sourcesByDocumentId.get(documentId) : undefined
+}
+
+function createSourcesByDocumentId(
+ sources: readonly Source[],
+): ReadonlyMap {
+ return new Map(
+ sources.flatMap((source): readonly [string, Source][] =>
+ source.knowhereDocumentId ? [[source.knowhereDocumentId, source]] : [],
+ ),
+ )
+}
+
+function toSafePathSegment(value: string): string {
+ const decoded = decodeUrlComponent(value)
+ const normalized = decoded
+ .replace(/[^A-Za-z0-9._-]+/g, "-")
+ .replace(/^-+|-+$/g, "")
+ .slice(0, 80)
+ return normalized || hashText(value).slice(0, 16)
+}
+
+function hashText(value: string): string {
+ return createHash("sha256").update(value).digest("hex")
+}
+
+function decodeUrlComponent(value: string): string {
+ try {
+ return decodeURIComponent(value)
+ } catch {
+ return value
+ }
+}
+
+function redactAssetUrl(assetUrl: string): string {
+ const absoluteUrl = parseAbsoluteHttpUrl(assetUrl)
+ if (absoluteUrl) return `${absoluteUrl.origin}${absoluteUrl.pathname}`
+ return getAssetUrlPathname(assetUrl)
+}
+
+function formatUnknownError(error: unknown): string {
+ if (error instanceof Error) return error.message
+ return String(error)
+}
+
+function getTrimmedString(value: string | null | undefined): string | null {
+ const trimmedValue = value?.trim() ?? ""
+ return trimmedValue.length > 0 ? trimmedValue : null
+}
diff --git a/src/domains/chat/media-assets.test.ts b/src/domains/chat/media-assets.test.ts
index ceded86..f79338b 100644
--- a/src/domains/chat/media-assets.test.ts
+++ b/src/domains/chat/media-assets.test.ts
@@ -42,6 +42,39 @@ describe("chat media assets", () => {
)
})
+ it("prefers Notebook parsed asset URLs over existing upstream asset URLs", async () => {
+ const loadSourceAssetUrls = vi.fn().mockResolvedValue({
+ "images/image-6-情感分类模型.jpg":
+ "https://blob.example/workspaces/workspace_1/sources/source_doc/parsed-result/images/image-6-model.jpg",
+ })
+
+ const [result] = await enrichRetrievalResultsWithAssetUrls({
+ results: [
+ makeRetrievalResult({
+ chunkType: "image",
+ assetUrl:
+ "https://knowhere-storage.example/results/job_1/images/image-6-%E6%83%85%E6%84%9F%E5%88%86%E7%B1%BB%E6%A8%A1%E5%9E%8B.jpg?AWSAccessKeyId=test",
+ source: {
+ documentId: "doc_model",
+ sourceFileName: "model.pdf",
+ sectionPath: "images/image-6-情感分类模型.jpg",
+ },
+ }),
+ ],
+ sources: [
+ makeSource({
+ id: "source_doc",
+ knowhereDocumentId: "doc_model",
+ }),
+ ],
+ loadSourceAssetUrls,
+ })
+
+ expect(result?.assetUrl).toBe(
+ "https://blob.example/workspaces/workspace_1/sources/source_doc/parsed-result/images/image-6-model.jpg",
+ )
+ })
+
it("adds image citation results for asset filenames that only appear in evidence text", async () => {
const loadSourceAssetUrls = vi.fn().mockResolvedValue({
"images/image-6-中华人民共和国居民身份证.jpg":
diff --git a/src/domains/chat/media-assets.ts b/src/domains/chat/media-assets.ts
index e87c655..85984cc 100644
--- a/src/domains/chat/media-assets.ts
+++ b/src/domains/chat/media-assets.ts
@@ -261,10 +261,7 @@ function addAssetCitationResults(
const seenAssetUrls = new Set()
const output: RetrievalResult[] = []
- if (existingAssetUrl) {
- seenAssetUrls.add(existingAssetUrl)
- output.push(result)
- } else if (resultMatches.length > 0) {
+ if (resultMatches.length > 0) {
const [firstMatch, ...remainingMatches] = resultMatches
seenAssetUrls.add(firstMatch.assetUrl)
output.push(toAssetResult(result, firstMatch))
@@ -273,6 +270,9 @@ function addAssetCitationResults(
seenAssetUrls.add(match.assetUrl)
output.push(toAssetResult(result, match))
}
+ } else if (existingAssetUrl) {
+ seenAssetUrls.add(existingAssetUrl)
+ output.push(result)
} else {
output.push(result)
}
@@ -378,6 +378,23 @@ function resolveAssetReferenceMatchesFromText(
)
}
+export function resolveAssetUrlFromReferenceText(input: {
+ readonly values: readonly (string | null | undefined)[]
+ readonly assetUrlsByFilePath: Readonly>
+}): string | null {
+ const normalizedHaystacks = input.values.flatMap((value): string[] => {
+ const normalized = normalizeAssetLookupText(value)
+ return normalized ? [normalized] : []
+ })
+ if (normalizedHaystacks.length === 0) return null
+
+ const [match] = resolveAssetReferenceMatchesFromHaystacks(
+ normalizedHaystacks,
+ input.assetUrlsByFilePath,
+ )
+ return match?.assetUrl ?? null
+}
+
function resolveAssetReferenceMatchesFromHaystacks(
normalizedHaystacks: readonly string[],
assetUrlsByFilePath: Readonly>,
diff --git a/src/domains/chat/prompt-templates.ts b/src/domains/chat/prompt-templates.ts
new file mode 100644
index 0000000..7b92d74
--- /dev/null
+++ b/src/domains/chat/prompt-templates.ts
@@ -0,0 +1,40 @@
+export type ChatPromptTemplate = {
+ readonly id: string
+ readonly title: string
+ readonly prompt: string
+}
+
+export const chatPromptTemplates: readonly ChatPromptTemplate[] = [
+ {
+ id: "ipo-prospectus-risk-mining",
+ title: "IPO Prospectus Risk Mining",
+ prompt: [
+ "You are a risk analyst specializing in IPO pricing. I have uploaded the prospectus of [Company Name].",
+ "Please complete the following tasks:",
+ '1. Extract all risk items from the "Risk Factors" section and categorize them into: Market Risk/Operational Risk/Legal and Compliance Risk/Technical Risk/Competitive Risk.',
+ '2. Identify which risk items use hedging language such as "may", "might", or "could", and which use more definitive language such as "will" or "has". Provide the results in a structured format.',
+ ].join("\n"),
+ },
+ {
+ id: "earnings-call-transcript-analysis",
+ title: "Earnings Call Transcript Analysis",
+ prompt: [
+ "You are a sell-side research analyst preparing a post earnings flash note. I have uploaded the earnings release and earnings call transcript of [Company Name].",
+ "Please complete the following tasks:",
+ "1. Extract the management's original wording on the following topics: Revenue guidance/Gross margin pressure/Specific business line.",
+ "2. Identify analyst questions that management sidestepped or shifted away from.",
+ "3. Extract all forward-looking statements that contain specific numbers, and organize them into a guidance tracking table.",
+ ].join("\n"),
+ },
+ {
+ id: "research-paper-method-comparison",
+ title: "Research Paper Method Comparison",
+ prompt: [
+ "You are a PhD researcher writing a paper in [Research Area]. I have uploaded recent top conference and journal papers in this area.",
+ "Please analyze the papers and produce the following:",
+ "1. Extract the three core elements for each paper: Dataset/Evaluation metrics/Model architecture. Present the results in a comparison table.",
+ '2. Identify the unresolved issues repeatedly mentioned in the "Limitations" or "Future Work" sections across the papers, and present them as a list.',
+ "3. Identify emerging technical terms appearing in the papers, assess whether they indicate a new research trend, and output a list of trend keywords.",
+ ].join("\n"),
+ },
+] as const
diff --git a/src/domains/chat/route-answer.ts b/src/domains/chat/route-answer.ts
index 58763da..03ddb73 100644
--- a/src/domains/chat/route-answer.ts
+++ b/src/domains/chat/route-answer.ts
@@ -4,6 +4,7 @@ import {
generateAgenticOutputManifest,
parseChatRequestBody,
} from "@/domains/chat"
+import { hardenChatMediaAssetUrls } from "@/domains/chat/media-asset-hardening"
import {
handleChatTurn,
type ChatTurnError,
@@ -58,6 +59,8 @@ const answerChatEffect = (input: AnswerChatInput) =>
const sources = yield* Effect.tryPromise(() =>
reconcileSourcesForWorkspace(workspace, client),
)
+ const loadSourceAssetUrls = (source: (typeof sources)[number]) =>
+ sourceService.getParseAssetUrls(workspace.id, source.id)
const result: Either.Either =
yield* Effect.tryPromise(() =>
@@ -69,8 +72,15 @@ const answerChatEffect = (input: AnswerChatInput) =>
excludedSourceIds: body.value.excludedSourceIds,
retrieval: client.retrieval,
generateAnswer: generateAgenticOutputManifest,
- loadSourceAssetUrls: (source) =>
- sourceService.getParseAssetUrls(workspace.id, source.id),
+ loadSourceAssetUrls,
+ hardenMediaAssetUrls: ({ results, artifacts }) =>
+ hardenChatMediaAssetUrls({
+ workspaceId: workspace.id,
+ sources,
+ results,
+ artifacts,
+ loadSourceAssetUrls,
+ }),
repository: chatTurnPersistence.createRepository(),
}),
).pipe(
diff --git a/src/domains/chat/service.ts b/src/domains/chat/service.ts
index ae256bd..dea33e8 100644
--- a/src/domains/chat/service.ts
+++ b/src/domains/chat/service.ts
@@ -67,6 +67,7 @@ type ChatTurnInput = {
retrieval: RetrievalClient
generateAnswer: GenerateAnswer
loadSourceAssetUrls?: AnswerQuestionInput["loadSourceAssetUrls"]
+ hardenMediaAssetUrls?: AnswerQuestionInput["hardenMediaAssetUrls"]
repository: ChatRepository
}
@@ -124,6 +125,7 @@ export const handleChatTurnEffect = (input: ChatTurnInput) =>
retrieval: input.retrieval,
generateAnswer: input.generateAnswer,
loadSourceAssetUrls: input.loadSourceAssetUrls,
+ hardenMediaAssetUrls: input.hardenMediaAssetUrls,
messages: chatHistoryMessages,
}).pipe(Effect.catchAllCause(Effect.die))
diff --git a/src/domains/demo/view.ts b/src/domains/demo/view.ts
index 719cdc7..47cafd0 100644
--- a/src/domains/demo/view.ts
+++ b/src/domains/demo/view.ts
@@ -29,6 +29,15 @@ function toSourceView(source: DemoSource): SourceView {
sizeBytes: source.originalFile.sizeBytes,
canDownload: source.originalFile.canDownload,
},
+ ...(source.officialLibrary
+ ? {
+ officialLibrary: {
+ librarySourceId: source.officialLibrary.librarySourceId,
+ categoryId: source.officialLibrary.categoryId,
+ sourceUrl: source.officialLibrary.sourceUrl,
+ },
+ }
+ : {}),
chunkCount: source.chunkCount,
}
}
diff --git a/src/domains/sources/route-service.test.ts b/src/domains/sources/route-service.test.ts
index 650fce1..68e50bd 100644
--- a/src/domains/sources/route-service.test.ts
+++ b/src/domains/sources/route-service.test.ts
@@ -502,10 +502,12 @@ describe("source route service", () => {
});
const emptyDemoCatalog: DemoCatalog = {
+ officialLibrary: { categories: [], sources: [] },
sources: [],
};
const demoCatalog: DemoCatalog = {
+ officialLibrary: { categories: [], sources: [] },
sources: [
{
demoSourceId: "demo-tsla-q4-2025",
diff --git a/src/domains/sources/types.ts b/src/domains/sources/types.ts
index c5643c1..12ffe66 100644
--- a/src/domains/sources/types.ts
+++ b/src/domains/sources/types.ts
@@ -9,6 +9,24 @@ export type SourceOriginalFileView = {
export type SourceKind = "workspace" | "demo"
+export type SourceOfficialLibraryView = {
+ readonly librarySourceId: string
+ readonly categoryId: string
+ readonly sourceUrl: string
+}
+
+export type OfficialLibrarySourceView = {
+ readonly librarySourceId: string
+ readonly categoryId: string
+ readonly categoryLabel: string
+ readonly title: string
+ readonly sourceUrl: string
+ readonly mimeType: string
+ readonly status: "ready" | "planned"
+ readonly demoSourceId?: string
+ readonly chunkCount?: number
+}
+
/**
* Sources sidebar row. Metadata-only, per the MVP persistence rule.
*/
@@ -24,6 +42,8 @@ export type SourceView = {
readonly documentId?: string
/** Public Blob URL for original-file preview and download. */
readonly originalFile?: SourceOriginalFileView
+ /** Official Library metadata when this row is an API-owned catalog item. */
+ readonly officialLibrary?: SourceOfficialLibraryView
/** Count from the Knowhere chunks API, not a local aggregate. */
readonly chunkCount?: number
/** User opt-out for this query session. Drives excludeDocumentIds. */
diff --git a/src/domains/workspace/client.ts b/src/domains/workspace/client.ts
index 1cea55a..487240d 100644
--- a/src/domains/workspace/client.ts
+++ b/src/domains/workspace/client.ts
@@ -1,3 +1,4 @@
+import type { ChatDiagramSpec } from "@/domains/chat/diagram"
import type {
ChatMessageView,
ChatThreadView,
@@ -9,6 +10,7 @@ import { workspaceRouteClient } from "./route-client"
const workspaceClientKeys = {
sources: "/api/sources",
chatThreads: "/api/chat/threads",
+ chatDiagram: "/api/chat/diagram",
chat: "/api/chat",
materializeDemoSources: "/api/demo-sources/materialize",
archiveSource: "archive-source",
@@ -63,6 +65,15 @@ type ChatMessageResponse = {
message?: string
}
+type ChatDiagramRequest = {
+ answer: string
+}
+
+type ChatDiagramResponse = {
+ diagram?: ChatDiagramSpec
+ message?: string
+}
+
type ArchiveResponse = {
id?: string
archived?: boolean
@@ -76,6 +87,7 @@ export const workspaceClient = {
fetchChatThreads,
fetchChatThread,
createChatThread,
+ createChatDiagram,
sendChatMessage,
materializeDemoSources,
archiveSource,
@@ -143,6 +155,15 @@ function createChatThread(): Promise {
)
}
+function createChatDiagram(
+ input: ChatDiagramRequest,
+): Promise {
+ return workspaceRouteClient.postJson(
+ workspaceClientKeys.chatDiagram,
+ input,
+ )
+}
+
function sendChatMessage(
input: ChatMessageRequest,
): Promise {
diff --git a/src/domains/workspace/initial-state.test.ts b/src/domains/workspace/initial-state.test.ts
index 7773eb7..02d3914 100644
--- a/src/domains/workspace/initial-state.test.ts
+++ b/src/domains/workspace/initial-state.test.ts
@@ -54,9 +54,36 @@ describe("loadWorkspaceShellInitialState", () => {
sizeBytes: 1024,
canDownload: false,
},
+ officialLibrary: {
+ librarySourceId: "financial-tsla-q4-2025",
+ categoryId: "financial-reports",
+ sourceUrl: "https://example.com/tsla-q4-2025.pdf",
+ },
chunkCount: 70,
},
])
+ expect(state.officialLibrarySources).toEqual([
+ {
+ librarySourceId: "financial-tsla-q4-2025",
+ categoryId: "financial-reports",
+ categoryLabel: "Financial reports",
+ title: "TSLA-Q4-2025-Update.pdf",
+ sourceUrl: "https://example.com/tsla-q4-2025.pdf",
+ mimeType: "application/pdf",
+ status: "ready",
+ demoSourceId: "demo-tsla-q4-2025",
+ chunkCount: 70,
+ },
+ {
+ librarySourceId: "stem-transformers",
+ categoryId: "stem-books",
+ categoryLabel: "STEM books",
+ title: "Transformers.pdf",
+ sourceUrl: "https://example.com/transformers.pdf",
+ mimeType: "application/pdf",
+ status: "planned",
+ },
+ ])
expect(state.chatMessages).toEqual([
{
id: "demo-example-1-user",
@@ -431,6 +458,42 @@ function createDependencies(
function makeDemoCatalog(): DemoCatalog {
return {
+ officialLibrary: {
+ categories: [
+ {
+ categoryId: "financial-reports",
+ label: "Financial reports",
+ description: "Company filings.",
+ },
+ {
+ categoryId: "stem-books",
+ label: "STEM books",
+ description: "Course materials.",
+ },
+ ],
+ sources: [
+ {
+ librarySourceId: "financial-tsla-q4-2025",
+ categoryId: "financial-reports",
+ title: "TSLA-Q4-2025-Update.pdf",
+ sourceUrl: "https://example.com/tsla-q4-2025.pdf",
+ mimeType: "application/pdf",
+ status: "ready",
+ demoSourceId: "demo-tsla-q4-2025",
+ canonicalDocumentId: "demo-doc-tsla-q4-2025",
+ sizeBytes: 1024,
+ chunkCount: 70,
+ },
+ {
+ librarySourceId: "stem-transformers",
+ categoryId: "stem-books",
+ title: "Transformers.pdf",
+ sourceUrl: "https://example.com/transformers.pdf",
+ mimeType: "application/pdf",
+ status: "planned",
+ },
+ ],
+ },
sources: [
{
demoSourceId: "demo-tsla-q4-2025",
@@ -446,6 +509,15 @@ function makeDemoCatalog(): DemoCatalog {
sizeBytes: 1024,
canDownload: false,
},
+ officialLibrary: {
+ librarySourceId: "financial-tsla-q4-2025",
+ categoryId: "financial-reports",
+ title: "TSLA-Q4-2025-Update.pdf",
+ sourceUrl: "https://example.com/tsla-q4-2025.pdf",
+ mimeType: "application/pdf",
+ status: "ready",
+ demoSourceId: "demo-tsla-q4-2025",
+ },
examples: [
{
id: "demo-example-1",
diff --git a/src/domains/workspace/initial-state.ts b/src/domains/workspace/initial-state.ts
index 6497df1..02836aa 100644
--- a/src/domains/workspace/initial-state.ts
+++ b/src/domains/workspace/initial-state.ts
@@ -17,7 +17,10 @@ import { sourceService } from "@/domains/sources/service"
import { startBackgroundReconciliation } from "@/domains/sources/background-reconcile"
import { sourceWorkflowRuntime } from "@/domains/sources/workflow-runtime"
-import type { SourceView } from "@/domains/sources/types"
+import type {
+ OfficialLibrarySourceView,
+ SourceView,
+} from "@/domains/sources/types"
import { toSourceView } from "@/domains/sources/view"
import type { AuthUser } from "@/infrastructure/auth"
import type {
@@ -38,6 +41,7 @@ type WorkspaceShellInitialState = {
readonly initialPrefetchedChunksBySourceId?: Record
readonly isGuest?: boolean
readonly loginUrl?: string
+ readonly officialLibrarySources?: OfficialLibrarySourceView[]
readonly sources?: SourceView[]
readonly user?: {
readonly id: string
@@ -189,6 +193,7 @@ export const loadWorkspaceShellInitialStateEffect = (
return {
isGuest: true,
+ officialLibrarySources: toOfficialLibrarySourceViews(demoCatalog),
sources: demoCatalog.sources.map(demoView.toSourceView),
chatMessages: demoView.toChatMessages(demoCatalog),
dashboardUrl: resolveDashboardUrl(),
@@ -330,6 +335,7 @@ export const loadWorkspaceShellInitialStateEffect = (
),
),
],
+ officialLibrarySources: toOfficialLibrarySourceViews(demoCatalog),
chatThreads: chatThreads.map(toChatThreadView),
activeChatThreadId: activeChatThread?.id ?? null,
chatMessages,
@@ -353,3 +359,25 @@ export async function loadWorkspaceShellInitialState(
function resolveDashboardUrl(): string | undefined {
return process.env.DASHBOARD_ORIGIN
}
+
+function toOfficialLibrarySourceViews(
+ catalog: DemoCatalog,
+): OfficialLibrarySourceView[] {
+ const categoryLabelById = new Map(
+ catalog.officialLibrary.categories.map((category) => [
+ category.categoryId,
+ category.label,
+ ]),
+ )
+ return catalog.officialLibrary.sources.map((source) => ({
+ librarySourceId: source.librarySourceId,
+ categoryId: source.categoryId,
+ categoryLabel: categoryLabelById.get(source.categoryId) ?? source.categoryId,
+ title: source.title,
+ sourceUrl: source.sourceUrl,
+ mimeType: source.mimeType,
+ status: source.status,
+ ...(source.demoSourceId ? { demoSourceId: source.demoSourceId } : {}),
+ ...(source.chunkCount !== undefined ? { chunkCount: source.chunkCount } : {}),
+ }))
+}
diff --git a/src/integrations/knowhere-demo.test.ts b/src/integrations/knowhere-demo.test.ts
index a6d5a9a..7926818 100644
--- a/src/integrations/knowhere-demo.test.ts
+++ b/src/integrations/knowhere-demo.test.ts
@@ -46,6 +46,10 @@ describe("knowhereDemoApi", () => {
await expect(knowhereDemoApi.fetchCatalog()).resolves.toEqual({
sources: [],
+ officialLibrary: {
+ categories: [],
+ sources: [],
+ },
})
expect(nextCacheMocks.cacheLife).toHaveBeenCalledWith("max")
@@ -101,6 +105,79 @@ describe("knowhereDemoApi", () => {
"demo-tsla-q4-2025",
)
})
+
+ it("maps Official Library metadata from the demo catalog", async () => {
+ globalThis.fetch = vi.fn().mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ sources: [
+ {
+ demo_source_id: "demo-spacex-s1",
+ canonical_document_id: "demo-doc-spacex-s1",
+ title: "spacex-s1.pdf",
+ mime_type: "application/pdf",
+ size_bytes: 7441414,
+ status: "ready",
+ chunk_count: 922,
+ original_file: {
+ url: "/api/v1/demo/sources/demo-spacex-s1/original",
+ mime_type: "application/pdf",
+ size_bytes: 7441414,
+ can_download: false,
+ },
+ official_library: {
+ library_source_id: "financial-spacex-s1",
+ category_id: "financial-reports",
+ title: "spacex-s1.pdf",
+ source_url: "https://data.olivierroy.dev/spacex-s1.pdf",
+ mime_type: "application/pdf",
+ status: "ready",
+ demo_source_id: "demo-spacex-s1",
+ },
+ examples: [],
+ },
+ ],
+ official_library: {
+ categories: [
+ {
+ category_id: "financial-reports",
+ label: "Financial reports",
+ description: "Company filings.",
+ },
+ ],
+ sources: [
+ {
+ library_source_id: "financial-spacex-s1",
+ category_id: "financial-reports",
+ title: "spacex-s1.pdf",
+ source_url: "https://data.olivierroy.dev/spacex-s1.pdf",
+ mime_type: "application/pdf",
+ status: "ready",
+ demo_source_id: "demo-spacex-s1",
+ canonical_document_id: "demo-doc-spacex-s1",
+ size_bytes: 7441414,
+ chunk_count: 922,
+ },
+ ],
+ },
+ }),
+ { status: 200, headers: { "content-type": "application/json" } },
+ ),
+ )
+
+ const catalog = await knowhereDemoApi.fetchCatalog()
+
+ expect(catalog.sources[0]?.officialLibrary).toMatchObject({
+ librarySourceId: "financial-spacex-s1",
+ categoryId: "financial-reports",
+ demoSourceId: "demo-spacex-s1",
+ })
+ expect(catalog.officialLibrary.sources[0]).toMatchObject({
+ librarySourceId: "financial-spacex-s1",
+ status: "ready",
+ chunkCount: 922,
+ })
+ })
})
function restoreEnv(key: string, value: string | undefined): void {
diff --git a/src/integrations/knowhere-demo.ts b/src/integrations/knowhere-demo.ts
index 6953d0f..8507dab 100644
--- a/src/integrations/knowhere-demo.ts
+++ b/src/integrations/knowhere-demo.ts
@@ -39,11 +39,37 @@ export type DemoSource = {
readonly sizeBytes: number
readonly canDownload: boolean
}
+ readonly officialLibrary?: OfficialLibrarySource
readonly examples: readonly DemoExample[]
}
export type DemoCatalog = {
readonly sources: readonly DemoSource[]
+ readonly officialLibrary: OfficialLibraryCatalog
+}
+
+export type OfficialLibraryCategory = {
+ readonly categoryId: string
+ readonly label: string
+ readonly description: string
+}
+
+export type OfficialLibrarySource = {
+ readonly librarySourceId: string
+ readonly categoryId: string
+ readonly title: string
+ readonly sourceUrl: string
+ readonly mimeType: string
+ readonly status: "ready" | "planned"
+ readonly demoSourceId?: string
+ readonly canonicalDocumentId?: string
+ readonly sizeBytes?: number
+ readonly chunkCount?: number
+}
+
+export type OfficialLibraryCatalog = {
+ readonly categories: readonly OfficialLibraryCategory[]
+ readonly sources: readonly OfficialLibrarySource[]
}
export type DemoChunk = {
@@ -91,6 +117,7 @@ export type MaterializedDemoSource = {
type DemoCatalogResponse = {
readonly sources?: readonly DemoSourceResponse[]
+ readonly official_library?: OfficialLibraryCatalogResponse
}
type DemoSourceResponse = {
@@ -102,6 +129,7 @@ type DemoSourceResponse = {
readonly status?: unknown
readonly chunk_count?: unknown
readonly original_file?: DemoOriginalFileResponse
+ readonly official_library?: OfficialLibrarySourceResponse
readonly examples?: readonly DemoExampleResponse[]
}
@@ -161,6 +189,30 @@ type DemoChunkResponse = {
readonly asset_url?: unknown
}
+type OfficialLibraryCatalogResponse = {
+ readonly categories?: readonly OfficialLibraryCategoryResponse[]
+ readonly sources?: readonly OfficialLibrarySourceResponse[]
+}
+
+type OfficialLibraryCategoryResponse = {
+ readonly category_id?: unknown
+ readonly label?: unknown
+ readonly description?: unknown
+}
+
+type OfficialLibrarySourceResponse = {
+ readonly library_source_id?: unknown
+ readonly category_id?: unknown
+ readonly title?: unknown
+ readonly source_url?: unknown
+ readonly mime_type?: unknown
+ readonly status?: unknown
+ readonly demo_source_id?: unknown
+ readonly canonical_document_id?: unknown
+ readonly size_bytes?: unknown
+ readonly chunk_count?: unknown
+}
+
type MaterializeResponse = {
readonly sources?: readonly MaterializedDemoSourceResponse[]
}
@@ -178,7 +230,10 @@ type MaterializedDemoSourceResponse = {
const DEFAULT_KNOWHERE_BASE_URL = "https://api.knowhereto.ai"
-const emptyCatalog: DemoCatalog = { sources: [] }
+const emptyCatalog: DemoCatalog = {
+ sources: [],
+ officialLibrary: { categories: [], sources: [] },
+}
// ---------------------------------------------------------------------------
// Effect core
@@ -195,6 +250,7 @@ const fetchCatalogEffect = Effect.fn("knowhereDemo.fetchCatalog")(function* () {
)) as DemoCatalogResponse
return {
sources: (body.sources ?? []).map(toDemoSource),
+ officialLibrary: toOfficialLibraryCatalog(body.official_library),
}
})
@@ -357,6 +413,9 @@ function assertOkEffect(
}
function toDemoSource(source: DemoSourceResponse): DemoSource {
+ const officialLibrary = source.official_library
+ ? toOfficialLibrarySource(source.official_library)
+ : undefined
return {
demoSourceId: requireString(source.demo_source_id),
canonicalDocumentId: requireString(source.canonical_document_id),
@@ -366,6 +425,7 @@ function toDemoSource(source: DemoSourceResponse): DemoSource {
status: "ready",
chunkCount: requireNumber(source.chunk_count),
originalFile: toOriginalFile(source.original_file),
+ ...(officialLibrary ? { officialLibrary } : {}),
examples: (source.examples ?? []).map(toDemoExample),
}
}
@@ -451,6 +511,50 @@ function toMaterializedDemoSource(
}
}
+function toOfficialLibraryCatalog(
+ input: OfficialLibraryCatalogResponse | undefined,
+): OfficialLibraryCatalog {
+ const officialLibrary = input ?? {}
+ return {
+ categories: (officialLibrary.categories ?? []).map(
+ toOfficialLibraryCategory,
+ ),
+ sources: (officialLibrary.sources ?? []).map(toOfficialLibrarySource),
+ }
+}
+
+function toOfficialLibraryCategory(
+ category: OfficialLibraryCategoryResponse,
+): OfficialLibraryCategory {
+ return {
+ categoryId: requireString(category.category_id),
+ label: requireString(category.label),
+ description: requireString(category.description),
+ }
+}
+
+function toOfficialLibrarySource(
+ source: OfficialLibrarySourceResponse,
+): OfficialLibrarySource {
+ const status = requireString(source.status)
+ const demoSourceId = optionalString(source.demo_source_id)
+ const canonicalDocumentId = optionalString(source.canonical_document_id)
+ const sizeBytes = optionalNumber(source.size_bytes)
+ const chunkCount = optionalNumber(source.chunk_count)
+ return {
+ librarySourceId: requireString(source.library_source_id),
+ categoryId: requireString(source.category_id),
+ title: requireString(source.title),
+ sourceUrl: requireString(source.source_url),
+ mimeType: requireString(source.mime_type),
+ status: status === "ready" ? "ready" : "planned",
+ ...(demoSourceId ? { demoSourceId } : {}),
+ ...(canonicalDocumentId ? { canonicalDocumentId } : {}),
+ ...(sizeBytes !== undefined ? { sizeBytes } : {}),
+ ...(chunkCount !== undefined ? { chunkCount } : {}),
+ }
+}
+
function toOriginalFile(
input: DemoOriginalFileResponse | undefined,
): DemoSource["originalFile"] {
@@ -488,6 +592,10 @@ function requireNumber(value: unknown): number {
throw new Error("Expected finite number from Knowhere demo API.")
}
+function optionalNumber(value: unknown): number | undefined {
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined
+}
+
function toRecord(value: unknown): Readonly> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return {}