diff --git a/next.config.ts b/next.config.ts
index 490c3cc..e294c70 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -14,6 +14,9 @@ const nextConfig: NextConfig = {
"notebook.127.0.0.1.nip.io",
"dashboard.127.0.0.1.nip.io",
],
+ turbopack: {
+ root: process.cwd(),
+ },
};
export default nextConfig;
diff --git a/package.json b/package.json
index 8449b3b..ee4221c 100644
--- a/package.json
+++ b/package.json
@@ -24,7 +24,7 @@
"@antv/chart-visualization-skills": "0.1.3",
"@effect/platform": "^0.96.1",
"@neondatabase/serverless": "^1.1.0",
- "@ontos-ai/knowhere-sdk": "^0.6.0",
+ "@ontos-ai/knowhere-sdk": "^0.10.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 484f2fd..66cd259 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -21,8 +21,8 @@ importers:
specifier: ^1.1.0
version: 1.1.0
'@ontos-ai/knowhere-sdk':
- specifier: ^0.6.0
- version: 0.6.0
+ specifier: ^0.10.0
+ version: 0.10.0
'@radix-ui/react-alert-dialog':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -1409,9 +1409,9 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'}
- '@ontos-ai/knowhere-sdk@0.6.0':
- resolution: {integrity: sha512-YqDgg+BKwGp7NepD50aquhEOmUgdDAQxpoyttqh0d+4tWjOgkSo74vEGiJVxt8v+LAcA/5CmxPeghX1Hx0v3wQ==}
- engines: {node: '>=20.19.0', npm: '>=10.0.0'}
+ '@ontos-ai/knowhere-sdk@0.10.0':
+ resolution: {integrity: sha512-wb5wNnnp4YvdlZ6w2UOQ2gw/9anGsmn2hw2JD+uCya4VUyXK78HKMi1LCI6zNnLeZHyGAm0q6+d2wlqDXZ6haQ==}
+ engines: {node: '>=20.19.0', npm: '>=10.0.0', pnpm: '>=9.0.0'}
'@open-draft/deferred-promise@2.2.0':
resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==}
@@ -6397,7 +6397,7 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {}
- '@ontos-ai/knowhere-sdk@0.6.0':
+ '@ontos-ai/knowhere-sdk@0.10.0':
dependencies:
axios: 1.16.0
jszip: 3.10.1
diff --git a/src/app/api/sources/[sourceId]/chunks/route.test.ts b/src/app/api/sources/[sourceId]/chunks/route.test.ts
index 6074abf..524a657 100644
--- a/src/app/api/sources/[sourceId]/chunks/route.test.ts
+++ b/src/app/api/sources/[sourceId]/chunks/route.test.ts
@@ -2,14 +2,19 @@ import { NextRequest } from "next/server"
import { beforeEach, describe, expect, it, vi } from "vitest"
const mocks = vi.hoisted(() => ({
+ blobGet: vi.fn(),
+ blobPut: vi.fn(),
+ deleteBlob: vi.fn(),
ensureApiKeyForWorkspace: vi.fn(),
ensureWorkspace: vi.fn(),
fetchDemoChunkPage: vi.fn(),
findSourceInWorkspace: vi.fn(),
getCurrentUser: vi.fn(),
getSourceParseAssetUrls: vi.fn(),
+ localizeRemoteDocument: vi.fn(),
makeKnowhereClient: vi.fn(),
requireUser: vi.fn(),
+ updateSourceRevisionKey: vi.fn(),
}))
vi.mock("next/headers", () => ({
@@ -36,10 +41,18 @@ vi.mock("@/integrations/knowhere", () => ({
makeKnowhereClient: mocks.makeKnowhereClient,
}))
+vi.mock("@vercel/blob", () => ({
+ del: mocks.deleteBlob,
+ get: mocks.blobGet,
+ put: mocks.blobPut,
+}))
+
vi.mock("@/domains/sources/service", () => ({
sourceService: {
findInWorkspace: mocks.findSourceInWorkspace,
getParseAssetUrls: mocks.getSourceParseAssetUrls,
+ localizeRemoteDocument: mocks.localizeRemoteDocument,
+ updateSourceRevisionKey: mocks.updateSourceRevisionKey,
},
}))
@@ -54,6 +67,11 @@ import { GET } from "./route"
describe("GET /api/sources/[sourceId]/chunks", () => {
beforeEach(() => {
vi.clearAllMocks()
+ mocks.blobGet.mockResolvedValue(null)
+ mocks.blobPut.mockImplementation(async (pathname: string) => ({
+ url: `https://blob.example/${pathname}`,
+ }))
+ mocks.updateSourceRevisionKey.mockResolvedValue(null)
})
it("serves API-owned demo chunks for anonymous canonical demo sources", async () => {
@@ -502,7 +520,48 @@ describe("GET /api/sources/[sourceId]/chunks", () => {
})
})
- it("does not load chunks from unlocalized remote source ids", async () => {
+ it("materializes a remote source id on open before loading chunks", async () => {
+ const knowhereClient = {
+ documents: {
+ list: vi.fn(async () => ({
+ documents: [
+ {
+ documentId: "doc_remote",
+ namespace: "default",
+ status: "active",
+ currentJobResultId: "job_result_1",
+ sourceFileName: "remote.pdf",
+ documentMetadata: {
+ mimeType: "application/pdf",
+ },
+ },
+ ],
+ })),
+ listChunks: vi.fn(async () => ({
+ documentId: "doc_remote",
+ jobResultId: "job_result_1",
+ chunks: [
+ {
+ id: "dchk_remote",
+ chunkId: "parser_remote",
+ chunkType: "text",
+ content: "Remote chunk",
+ sectionPath: "Summary",
+ sourceChunkPath: "Default_Root/remote.pdf/Summary",
+ filePath: null,
+ metadata: {},
+ sortOrder: 0,
+ },
+ ],
+ pagination: {
+ page: 1,
+ pageSize: 1,
+ total: 1,
+ totalPages: 1,
+ },
+ })),
+ },
+ }
mocks.getCurrentUser.mockResolvedValue({
id: "user_1",
email: null,
@@ -515,6 +574,27 @@ describe("GET /api/sources/[sourceId]/chunks", () => {
createdAt: new Date("2026-05-10T00:00:00.000Z"),
})
mocks.fetchDemoChunkPage.mockRejectedValue(new Error("not a demo"))
+ mocks.ensureApiKeyForWorkspace.mockResolvedValue("jwt_123")
+ mocks.makeKnowhereClient.mockReturnValue(knowhereClient)
+ mocks.localizeRemoteDocument.mockResolvedValue({
+ id: "00000000-0000-0000-0000-000000000009",
+ workspaceId: "workspace_1",
+ title: "remote.pdf",
+ mimeType: "application/pdf",
+ sizeBytes: 0,
+ status: "ready",
+ failureReason: null,
+ knowhereJobId: "job_result_1",
+ knowhereDocumentId: "doc_remote",
+ stagedBlobPathname: null,
+ stagedBlobUrl: null,
+ originalBlobPathname: null,
+ originalBlobUrl: null,
+ demoKey: null,
+ createdAt: new Date("2026-05-10T00:00:00.000Z"),
+ updatedAt: new Date("2026-05-10T00:00:00.000Z"),
+ deletedAt: null,
+ })
const response = await GET(
new NextRequest(
@@ -527,12 +607,46 @@ describe("GET /api/sources/[sourceId]/chunks", () => {
},
)
- await expect(response.json()).resolves.toEqual({
- message: "Source not found.",
+ await expect(response.json()).resolves.toMatchObject({
+ chunks: [
+ {
+ chunkId: "dchk_remote",
+ parserChunkId: "parser_remote",
+ documentId: "doc_remote",
+ sourceTitle: "remote.pdf",
+ },
+ ],
+ pagination: {
+ page: 1,
+ pageSize: 1,
+ total: 1,
+ },
})
- expect(response.status).toBe(404)
+ expect(response.status).toBe(200)
expect(mocks.findSourceInWorkspace).not.toHaveBeenCalled()
- expect(mocks.ensureApiKeyForWorkspace).not.toHaveBeenCalled()
- expect(mocks.makeKnowhereClient).not.toHaveBeenCalled()
+ expect(mocks.ensureApiKeyForWorkspace).toHaveBeenCalledWith(
+ "workspace_1",
+ "session=abc",
+ )
+ expect(mocks.localizeRemoteDocument).toHaveBeenCalledWith(
+ "workspace_1",
+ {
+ documentId: "doc_remote",
+ namespace: "default",
+ status: "ready",
+ title: "remote.pdf",
+ mimeType: "application/pdf",
+ sizeBytes: undefined,
+ revisionKey: "job_result_1",
+ },
+ )
+ expect(knowhereClient.documents.listChunks).toHaveBeenCalledWith(
+ "doc_remote",
+ {
+ page: 1,
+ pageSize: 1,
+ includeAssetUrls: true,
+ },
+ )
})
})
diff --git a/src/app/api/sources/[sourceId]/route.test.ts b/src/app/api/sources/[sourceId]/route.test.ts
index b82d620..6f82574 100644
--- a/src/app/api/sources/[sourceId]/route.test.ts
+++ b/src/app/api/sources/[sourceId]/route.test.ts
@@ -14,7 +14,9 @@ const mocks = vi.hoisted(() => {
hideDemoSource: vi.fn(),
makeKnowhereClient: vi.fn(),
requireUser: vi.fn(),
+ retrySourceToKnowhere: vi.fn(),
softDeleteSource: vi.fn(),
+ startBackgroundReconciliation: vi.fn(),
};
});
@@ -46,10 +48,15 @@ vi.mock("@/integrations/knowhere", () => ({
makeKnowhereClient: mocks.makeKnowhereClient,
}));
+vi.mock("@/domains/sources/background-reconcile", () => ({
+ startBackgroundReconciliation: mocks.startBackgroundReconciliation,
+}));
+
vi.mock("@/domains/sources/service", () => ({
sourceService: {
findInWorkspace: mocks.findSourceInWorkspace,
hideDemoSource: mocks.hideDemoSource,
+ retrySourceToKnowhere: mocks.retrySourceToKnowhere,
softDelete: mocks.softDeleteSource,
},
}));
@@ -255,4 +262,124 @@ describe("PATCH /api/sources/[sourceId]", () => {
expect(mocks.ensureApiKeyForWorkspace).not.toHaveBeenCalled();
expect(mocks.archive).not.toHaveBeenCalled();
});
+
+ it("retries a failed source and starts background reconciliation", async () => {
+ mocks.requireUser.mockResolvedValue({ id: "user_1" });
+ mocks.ensureWorkspace.mockResolvedValue({ id: "workspace_1" });
+ mocks.findSourceInWorkspace.mockResolvedValue({
+ id: "source_1",
+ workspaceId: "workspace_1",
+ title: "lecture.pdf",
+ mimeType: "application/pdf",
+ sizeBytes: 5,
+ status: "failed",
+ failureReason: "Knowhere upload failed.",
+ knowhereJobId: null,
+ knowhereDocumentId: null,
+ stagedBlobPathname: null,
+ stagedBlobUrl: null,
+ originalBlobPathname: "source-uploads/upload_1/document.pdf",
+ originalBlobUrl:
+ "https://store.public.blob.vercel-storage.com/source-uploads/upload_1/document.pdf",
+ demoKey: null,
+ createdAt: new Date("2026-05-10T00:00:00Z"),
+ updatedAt: new Date("2026-05-10T00:00:00Z"),
+ deletedAt: null,
+ });
+ mocks.ensureApiKeyForWorkspace.mockResolvedValue("jwt_123");
+ const knowhereClient = {
+ jobs: {
+ create: vi.fn(),
+ get: vi.fn(),
+ upload: vi.fn(),
+ },
+ documents: {
+ archive: mocks.archive,
+ },
+ };
+ mocks.makeKnowhereClient.mockReturnValue(knowhereClient);
+ mocks.retrySourceToKnowhere.mockResolvedValue({
+ id: "source_1",
+ workspaceId: "workspace_1",
+ title: "lecture.pdf",
+ mimeType: "application/pdf",
+ sizeBytes: 5,
+ status: "parsing",
+ failureReason: null,
+ knowhereJobId: "job_retry",
+ knowhereDocumentId: null,
+ stagedBlobPathname: null,
+ stagedBlobUrl: null,
+ originalBlobPathname: "source-uploads/upload_1/document.pdf",
+ originalBlobUrl:
+ "https://store.public.blob.vercel-storage.com/source-uploads/upload_1/document.pdf",
+ demoKey: null,
+ createdAt: new Date("2026-05-10T00:00:00Z"),
+ updatedAt: new Date("2026-05-10T00:00:00Z"),
+ deletedAt: null,
+ });
+ mocks.startBackgroundReconciliation.mockResolvedValue(undefined);
+
+ const response = await PATCH(
+ new NextRequest("http://localhost:3001/api/sources/source_1", {
+ method: "PATCH",
+ body: JSON.stringify({ retry: true }),
+ }),
+ { params: Promise.resolve({ sourceId: "source_1" }) },
+ );
+
+ await expect(response.json()).resolves.toEqual({
+ source: {
+ id: "source_1",
+ kind: "workspace",
+ title: "lecture.pdf",
+ mimeType: "application/pdf",
+ status: "parsing",
+ documentId: undefined,
+ originalFile: {
+ url: "https://store.public.blob.vercel-storage.com/source-uploads/upload_1/document.pdf",
+ mimeType: "application/pdf",
+ sizeBytes: 5,
+ },
+ },
+ });
+ expect(response.status).toBe(200);
+ expect(mocks.retrySourceToKnowhere).toHaveBeenCalledWith(
+ { id: "workspace_1" },
+ expect.objectContaining({ id: "source_1", status: "failed" }),
+ knowhereClient,
+ );
+ expect(mocks.startBackgroundReconciliation).toHaveBeenCalledWith(
+ "workspace_1",
+ "source_1",
+ "jwt_123",
+ );
+ });
+
+ it("rejects retry requests for failed rows without a saved original Blob", async () => {
+ mocks.requireUser.mockResolvedValue({ id: "user_1" });
+ mocks.ensureWorkspace.mockResolvedValue({ id: "workspace_1" });
+ mocks.findSourceInWorkspace.mockResolvedValue({
+ id: "source_1",
+ status: "failed",
+ originalBlobPathname: null,
+ originalBlobUrl: null,
+ });
+
+ const response = await PATCH(
+ new NextRequest("http://localhost:3001/api/sources/source_1", {
+ method: "PATCH",
+ body: JSON.stringify({ retry: true }),
+ }),
+ { params: Promise.resolve({ sourceId: "source_1" }) },
+ );
+
+ await expect(response.json()).resolves.toEqual({
+ message:
+ "This source cannot be retried because its original file is unavailable.",
+ });
+ expect(response.status).toBe(409);
+ expect(mocks.ensureApiKeyForWorkspace).not.toHaveBeenCalled();
+ expect(mocks.retrySourceToKnowhere).not.toHaveBeenCalled();
+ });
});
diff --git a/src/app/api/sources/[sourceId]/route.ts b/src/app/api/sources/[sourceId]/route.ts
index 9d30567..9d05d81 100644
--- a/src/app/api/sources/[sourceId]/route.ts
+++ b/src/app/api/sources/[sourceId]/route.ts
@@ -19,18 +19,19 @@ export async function PATCH(
): Promise {
const { sourceId } = await context.params
const routeContext = await nextRouteContext.read()
- const archiveRequest = await sourceRouteRequest.readArchiveSource({
+ const mutationRequest = await sourceRouteRequest.readSourceMutation({
cookieHeader: routeContext.cookieHeader,
request,
sourceId,
})
- if (!archiveRequest.ok) {
- return nextRouteResponse.toNextResponse(archiveRequest.result)
+ if (!mutationRequest.ok) {
+ return nextRouteResponse.toNextResponse(mutationRequest.result)
}
- const result = await sourceRouteService.archiveSource({
- ...archiveRequest.input,
- })
+ const result =
+ mutationRequest.mutation.kind === "archive"
+ ? await sourceRouteService.archiveSource(mutationRequest.mutation.input)
+ : await sourceRouteService.retrySource(mutationRequest.mutation.input)
return nextRouteResponse.toNextResponse(result)
}
diff --git a/src/app/api/sources/reconcile/route.ts b/src/app/api/sources/reconcile/route.ts
index c28bfa4..1f51bac 100644
--- a/src/app/api/sources/reconcile/route.ts
+++ b/src/app/api/sources/reconcile/route.ts
@@ -1,50 +1,27 @@
import { serve } from "@upstash/workflow/nextjs"
-import { reconcileSourcesForWorkspace } from "@/domains/sources/reconcile"
-import { makeKnowhereClient } from "@/integrations/knowhere"
-import { logger } from "@/lib/logger"
-
-type ReconcilePayload = {
- readonly workspaceId: string
- readonly sourceId: string
- readonly apiKey: string
-}
-
-const MAX_POLL_ATTEMPTS = 60
-const INITIAL_DELAY_S = 3
-const MAX_DELAY_S = 30
-
-export const { POST } = serve(async (context) => {
- const { workspaceId, sourceId, apiKey } = context.requestPayload
- const workspace = { id: workspaceId }
- const client = makeKnowhereClient(apiKey)
- let delay = INITIAL_DELAY_S
-
- for (let attempt = 0; attempt < MAX_POLL_ATTEMPTS; attempt++) {
- const resolved = await context.run(`poll-${attempt}`, async () => {
- const sources = await reconcileSourcesForWorkspace(workspace, client)
- const source = sources.find((s) => s.id === sourceId)
- if (!source || source.status !== "parsing") {
- return { done: true, status: source?.status ?? "gone" } as const
- }
- return { done: false } as const
+import { sourceReconcileRouteWorkflow } from "@/domains/sources/source-reconcile-route-workflow"
+
+type ReconcilePayload = Parameters<
+ typeof sourceReconcileRouteWorkflow.normalizeReconcilePayload
+>[0]
+
+export const { POST } = serve(
+ async (context) => {
+ const payload = sourceReconcileRouteWorkflow.normalizeReconcilePayload(
+ context.requestPayload,
+ )
+ await sourceReconcileRouteWorkflow.runPollAndMirrorWorkflow({
+ context,
+ payload,
})
-
- if (resolved.done) {
- logger.info("workflow: source resolved", {
- sourceId,
- status: resolved.status,
- attempts: attempt + 1,
- })
- return
- }
-
- await context.sleep(`wait-${attempt}`, delay)
- delay = Math.min(Math.round(delay * 1.5), MAX_DELAY_S)
- }
-
- logger.error("workflow: exhausted poll attempts", {
- sourceId,
- maxAttempts: MAX_POLL_ATTEMPTS,
- })
-})
+ },
+ {
+ failureFunction: async ({ context, failResponse }) => {
+ await sourceReconcileRouteWorkflow.markSourceFailedAfterWorkflowFailure(
+ context.requestPayload,
+ failResponse,
+ )
+ },
+ },
+)
diff --git a/src/components/chunks-panel.tsx b/src/components/chunks-panel.tsx
index 41aaf32..4a686a7 100644
--- a/src/components/chunks-panel.tsx
+++ b/src/components/chunks-panel.tsx
@@ -401,6 +401,7 @@ export function ChunksPanel({
hasOriginalFile ? handleChunkSelected : undefined
}
onReferenceClick={requestChunkFocus}
+ selectedSourceFile={selectedSourceFile}
/>
))}
@@ -1129,6 +1130,7 @@ function VirtualChunkRow({
measureElement,
onChunkClick,
onReferenceClick,
+ selectedSourceFile,
}: {
virtualItem: VirtualItem;
chunk: ParsedChunkView | undefined;
@@ -1137,6 +1139,7 @@ function VirtualChunkRow({
measureElement: (node: HTMLDivElement | null) => void;
onChunkClick?: (chunk: ParsedChunkView) => void;
onReferenceClick: (chunkId: string) => void;
+ selectedSourceFile: SourceOriginalFileView | null;
}): ReactNode {
if (!chunk) {
return null;
@@ -1163,6 +1166,7 @@ function VirtualChunkRow({
isOriginalPreviewAvailable={isOriginalPreviewAvailable}
onChunkClick={onChunkClick}
onReferenceClick={onReferenceClick}
+ sourceOriginalFile={selectedSourceFile}
/>
);
diff --git a/src/components/parsed-chunk-card.test.ts b/src/components/parsed-chunk-card.test.ts
index b269c08..2e34ab7 100644
--- a/src/components/parsed-chunk-card.test.ts
+++ b/src/components/parsed-chunk-card.test.ts
@@ -182,4 +182,33 @@ describe("ParsedChunkCard", () => {
expect(table.innerHTML).not.toContain("script");
expect(table.innerHTML).not.toContain("onclick");
});
+
+ it("renders the original image file when an image chunk has no asset URL", () => {
+ render(
+ React.createElement(ParsedChunkCard, {
+ chunk: {
+ chunkId: "image_1",
+ type: "image",
+ content: "Logo summary",
+ summary: "A black and white logo.",
+ sourceTitle: "logo.png",
+ },
+ isFocused: false,
+ onReferenceClick: vi.fn(),
+ sourceOriginalFile: {
+ url: "https://blob.example/source-uploads/logo.png",
+ mimeType: "image/png",
+ },
+ }),
+ );
+
+ const image = screen.getByRole("img", {
+ name: "A black and white logo.",
+ });
+
+ expect(image.getAttribute("src")).toBe(
+ "https://blob.example/source-uploads/logo.png",
+ );
+ expect(screen.queryByText("Image content is not available in this view.")).toBeNull();
+ });
});
diff --git a/src/components/parsed-chunk-card.tsx b/src/components/parsed-chunk-card.tsx
index 386d596..53178f5 100644
--- a/src/components/parsed-chunk-card.tsx
+++ b/src/components/parsed-chunk-card.tsx
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { parsedChunkCardModel } from "@/components/parsed-chunk-card-model";
import type { ParsedChunkView } from "@/domains/chunks/types";
+import type { SourceOriginalFileView } from "@/domains/sources/types";
import { cn } from "@/lib/utils";
const keywordPanelClassName =
@@ -25,12 +26,14 @@ export function ParsedChunkCard({
isOriginalPreviewAvailable = false,
onChunkClick,
onReferenceClick,
+ sourceOriginalFile = null,
}: {
readonly chunk: ParsedChunkView;
readonly isFocused: boolean;
readonly isOriginalPreviewAvailable?: boolean;
readonly onChunkClick?: (chunk: ParsedChunkView) => void;
readonly onReferenceClick: (chunkId: string) => void;
+ readonly sourceOriginalFile?: SourceOriginalFileView | null;
}): ReactNode {
if (chunk.type === "image") {
return (
@@ -40,6 +43,7 @@ export function ParsedChunkCard({
isFocused={isFocused}
isOriginalPreviewAvailable={isOriginalPreviewAvailable}
onChunkClick={onChunkClick}
+ sourceOriginalFile={sourceOriginalFile}
/>
);
@@ -362,12 +366,16 @@ function ImageChunkCard({
isFocused,
isOriginalPreviewAvailable,
onChunkClick,
+ sourceOriginalFile,
}: {
readonly chunk: ParsedChunkView;
readonly isFocused: boolean;
readonly isOriginalPreviewAvailable: boolean;
readonly onChunkClick?: (chunk: ParsedChunkView) => void;
+ readonly sourceOriginalFile: SourceOriginalFileView | null;
}): ReactNode {
+ const imageAssetUrl = getImageChunkAssetUrl(chunk, sourceOriginalFile);
+
return (
- {chunk.assetUrl ? (
+ {imageAssetUrl ? (
{/* eslint-disable-next-line @next/next/no-img-element -- Parsed artifact dimensions are not known before render. */}
@@ -407,6 +415,16 @@ function ImageChunkCard({
);
}
+function getImageChunkAssetUrl(
+ chunk: ParsedChunkView,
+ sourceOriginalFile: SourceOriginalFileView | null,
+): string | null {
+ if (chunk.assetUrl) return chunk.assetUrl;
+ if (!sourceOriginalFile?.mimeType.startsWith("image/")) return null;
+
+ return sourceOriginalFile.url;
+}
+
function renderTextChunkContent(
chunk: ParsedChunkView,
onReferenceClick: (chunkId: string) => void,
diff --git a/src/components/source-row.test.ts b/src/components/source-row.test.ts
index 9faa567..26e413f 100644
--- a/src/components/source-row.test.ts
+++ b/src/components/source-row.test.ts
@@ -75,6 +75,99 @@ describe("SourceRow", () => {
.toBeTruthy();
});
+ it("shows failed source retry with a brief error message", () => {
+ const onRetryClick = vi.fn();
+ const onSelect = vi.fn();
+
+ render(
+ React.createElement(SourceRow, {
+ isArchiving: false,
+ isSelected: false,
+ onRetryClick,
+ onSelect,
+ source: {
+ id: "source_1",
+ mimeType: "application/pdf",
+ title: "lecture.pdf",
+ status: "failed",
+ failureMessage:
+ "Too many concurrent requests (2/2 active). Please retry after 30 seconds.",
+ originalFile: {
+ url: "https://store.public.blob.vercel-storage.com/source-uploads/upload_1/document.pdf",
+ mimeType: "application/pdf",
+ },
+ },
+ }),
+ );
+
+ fireEvent.click(
+ screen.getByRole("button", {
+ name: "Retry lecture.pdf processing",
+ }),
+ );
+
+ expect(
+ screen.getByText(
+ "Too many concurrent requests (2/2 active). Please retry after 30 seconds.",
+ ),
+ ).toBeTruthy();
+ expect(onRetryClick).toHaveBeenCalledWith("source_1");
+ expect(onSelect).not.toHaveBeenCalled();
+ });
+
+ it("shows failed source retry loading locally", () => {
+ render(
+ React.createElement(SourceRow, {
+ isArchiving: false,
+ isRetrying: true,
+ isSelected: false,
+ onRetryClick: vi.fn(),
+ onSelect: vi.fn(),
+ source: {
+ id: "source_1",
+ mimeType: "application/pdf",
+ title: "lecture.pdf",
+ status: "failed",
+ originalFile: {
+ url: "https://store.public.blob.vercel-storage.com/source-uploads/upload_1/document.pdf",
+ mimeType: "application/pdf",
+ },
+ },
+ }),
+ );
+
+ const retryButton = screen.getByRole("button", {
+ name: "Retry lecture.pdf processing",
+ });
+
+ expect((retryButton as HTMLButtonElement).disabled).toBe(true);
+ expect(within(retryButton).getByRole("status", { name: "Loading" }))
+ .toBeTruthy();
+ });
+
+ it("hides retry when a failed source has no saved original file", () => {
+ render(
+ React.createElement(SourceRow, {
+ isArchiving: false,
+ isSelected: false,
+ onRetryClick: vi.fn(),
+ onSelect: vi.fn(),
+ source: {
+ id: "source_1",
+ mimeType: "application/pdf",
+ title: "legacy.pdf",
+ status: "failed",
+ },
+ }),
+ );
+
+ expect(
+ screen.queryByRole("button", {
+ name: "Retry legacy.pdf processing",
+ }),
+ ).toBeNull();
+ });
+
it("links ready sources to the document chunk tree route", () => {
const onSelect = vi.fn();
diff --git a/src/components/source-row.tsx b/src/components/source-row.tsx
index 48be677..abfe8cb 100644
--- a/src/components/source-row.tsx
+++ b/src/components/source-row.tsx
@@ -2,7 +2,7 @@
import type { ReactElement } from "react";
import Link from "next/link";
-import { FileText, ListTree, Plus, Trash2 } from "lucide-react";
+import { FileText, ListTree, Plus, RotateCcw, Trash2 } from "lucide-react";
import { Checkbox } from "@/components/ui/checkbox";
import { Spinner } from "@/components/ui/spinner";
@@ -13,9 +13,11 @@ export type SourceRowProps = {
readonly isArchiving: boolean;
readonly isAdding?: boolean;
readonly isNarrow?: boolean;
+ readonly isRetrying?: boolean;
readonly isSelected: boolean;
readonly onAddClick?: (sourceId: string) => void;
readonly onArchiveClick?: (sourceId: string) => void;
+ readonly onRetryClick?: (sourceId: string) => void;
readonly onSelect: () => void;
readonly onToggleIncluded?: (sourceId: string, included: boolean) => void;
readonly source: SourceView;
@@ -30,13 +32,17 @@ export function SourceRow({
onSelect,
onToggleIncluded,
onArchiveClick,
+ onRetryClick,
chunkTreeHref,
isArchiving,
+ isRetrying = false,
}: SourceRowProps): ReactElement {
const isReady = source.status === "ready";
const isBusy = source.status === "uploading" || source.status === "parsing";
const isFailed = source.status === "failed";
+ const canRetry = isFailed && source.originalFile !== undefined;
const isLibrarySource = source.officialLibrary !== undefined;
+ const isRemoteSource = source.kind === "remote";
const iconBg = fileIconTint(source.title);
@@ -57,7 +63,7 @@ export function SourceRow({
>
onToggleIncluded?.(source.id, checked === true)
}
@@ -96,15 +102,18 @@ export function SourceRow({
}`}
>
{isReady
- ? `${isLibrarySource ? "Official Library" : "Processed"} · ${
- source.chunkCount ?? 0
- } chunks`
+ ? `${getReadySourceLabel(source)} · ${source.chunkCount ?? 0} chunks`
: source.status === "parsing"
? "Preparing"
: source.status === "uploading"
? "Uploading"
: "Failed"}
+ {isFailed && source.failureMessage ? (
+
+ {source.failureMessage}
+
+ ) : null}
@@ -138,6 +147,26 @@ export function SourceRow({
{isNarrow ? null : "Add"}
)}
+ {canRetry && onRetryClick ? (
+
+ ) : null}
{onArchiveClick && (