Skip to content

Commit 04d567b

Browse files
lunelsonclaude
andcommitted
H-6363: Add /ingest route to HASH frontend
Port the discovery pipeline UI from the internal agent-workflows prototype into the main HASH frontend as an /ingest route. Adds: - PDF upload with Ark UI FileUpload + SSE progress streaming - Results view with roster/claims sidebar and PDF page viewer with bbox overlays - Next.js rewrites to proxy /api/ingest/* to Mastra API (port 4111) - Sidebar navigation link under Agents section Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent abf7e60 commit 04d567b

14 files changed

Lines changed: 1326 additions & 0 deletions

File tree

apps/hash-frontend/next.config.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ const apiUrl = process.env.NEXT_PUBLIC_API_ORIGIN ?? "http://localhost:5001";
6464

6565
const apiDomain = new URL(apiUrl).hostname;
6666

67+
// Mastra API origin for ingest pipeline proxy (local dev: port 4111)
68+
const mastraApiOrigin =
69+
process.env.MASTRA_API_ORIGIN ?? "http://localhost:4111";
70+
6771
/**
6872
* @todo: import the page `entityTypeId` from `@local/hash-isomorphic-utils/ontology-types`
6973
* when the `next.config.js` supports imports from modules
@@ -81,6 +85,19 @@ export default withSentryConfig(
8185
{
8286
async rewrites() {
8387
return [
88+
// Ingest pipeline proxy → Mastra API
89+
{
90+
source: "/api/ingest",
91+
destination: `${mastraApiOrigin}/discovery-runs`,
92+
},
93+
{
94+
source: "/api/ingest/:path*",
95+
destination: `${mastraApiOrigin}/discovery-runs/:path*`,
96+
},
97+
{
98+
source: "/api/ingest-fixtures/:path*",
99+
destination: `${mastraApiOrigin}/discovery-fixtures/:path*`,
100+
},
84101
{
85102
source: "/pages",
86103
destination: `/entities?entityTypeIdOrBaseUrl=${pageEntityTypeBaseUrl}`,

apps/hash-frontend/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"codegen": "rimraf './src/**/*.gen.*'; graphql-codegen --config codegen.config.ts",
1212
"dev": "next dev",
1313
"fix:eslint": "eslint --fix .",
14+
"fix:format": "biome format --write",
1415
"lint:eslint": "eslint --report-unused-disable-directives .",
1516
"lint:tsc": "tsc --noEmit",
1617
"start": "next start",
@@ -20,6 +21,7 @@
2021
},
2122
"dependencies": {
2223
"@apollo/client": "3.10.5",
24+
"@ark-ui/react": "5.26.2",
2325
"@blockprotocol/core": "0.1.4",
2426
"@blockprotocol/graph": "workspace:*",
2527
"@blockprotocol/hook": "0.1.8",
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { InfinityLightIcon } from "@hashintel/design-system";
2+
import { Box, Container } from "@mui/material";
3+
import { useRouter } from "next/router";
4+
import { useEffect } from "react";
5+
6+
import type { NextPageWithLayout } from "../shared/layout";
7+
import { getLayoutWithSidebar } from "../shared/layout";
8+
import { WorkersHeader } from "../shared/workers-header";
9+
import { getIngestResultsPath } from "./ingest.page/routing";
10+
import { UploadPanel } from "./ingest.page/upload-panel";
11+
import { shouldFetchResults, useIngestRun } from "./ingest.page/use-ingest-run";
12+
13+
const IngestPage: NextPageWithLayout = () => {
14+
const router = useRouter();
15+
const { state, upload, reset } = useIngestRun();
16+
17+
useEffect(() => {
18+
if (!shouldFetchResults(state)) {
19+
return;
20+
}
21+
void router.push(
22+
getIngestResultsPath({
23+
kind: "run",
24+
runId: state.runStatus.runId,
25+
}),
26+
);
27+
}, [router, state]);
28+
29+
return (
30+
<>
31+
<WorkersHeader
32+
crumbs={[
33+
{
34+
title: "Ingest",
35+
href: "/ingest",
36+
id: "ingest",
37+
},
38+
]}
39+
title={{
40+
Icon: InfinityLightIcon,
41+
text: "Ingest",
42+
}}
43+
subtitle="Upload a PDF to extract entities, claims, and evidence."
44+
/>
45+
<Container>
46+
<Box
47+
sx={{
48+
display: "flex",
49+
alignItems: "center",
50+
justifyContent: "center",
51+
minHeight: 400,
52+
py: 4,
53+
}}
54+
>
55+
<UploadPanel state={state} onUpload={upload} onReset={reset} />
56+
</Box>
57+
</Container>
58+
</>
59+
);
60+
};
61+
62+
IngestPage.getLayout = (page) =>
63+
getLayoutWithSidebar(page, { fullWidth: true });
64+
65+
export default IngestPage;
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* Coordinate transform: PDF-point bbox → CSS percentage positioning.
3+
*
4+
* Overlays are absolutely-positioned <div>s inside a container wrapping the
5+
* page <img>. Percentage-based positioning keeps them responsive.
6+
*/
7+
8+
export interface BboxInput {
9+
x1: number;
10+
y1: number;
11+
x2: number;
12+
y2: number;
13+
}
14+
15+
export interface BboxPercentage {
16+
left: number;
17+
top: number;
18+
width: number;
19+
height: number;
20+
}
21+
22+
export function bboxToPercentage(
23+
bbox: BboxInput,
24+
pdfPageWidth: number,
25+
pdfPageHeight: number,
26+
origin: "BOTTOMLEFT" | "TOPLEFT",
27+
): BboxPercentage {
28+
const left = (bbox.x1 / pdfPageWidth) * 100;
29+
const width = ((bbox.x2 - bbox.x1) / pdfPageWidth) * 100;
30+
const height = ((bbox.y2 - bbox.y1) / pdfPageHeight) * 100;
31+
32+
const top =
33+
origin === "BOTTOMLEFT"
34+
? ((pdfPageHeight - bbox.y2) / pdfPageHeight) * 100
35+
: (bbox.y1 / pdfPageHeight) * 100;
36+
37+
return { left, top, width, height };
38+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/**
2+
* Evidence resolver: selection → highlighted block IDs + target page.
3+
*
4+
* Pure function. No I/O, no React.
5+
*/
6+
import type { Block, ExtractedClaim, RosterEntry } from "./types";
7+
8+
export type Selection =
9+
| { kind: "roster"; entry: RosterEntry }
10+
| { kind: "claim"; claim: ExtractedClaim }
11+
| null;
12+
13+
export interface EvidenceResult {
14+
blockIds: string[];
15+
targetPage: number | null;
16+
}
17+
18+
export function resolveEvidence(
19+
selection: Selection,
20+
blocks: Block[],
21+
): EvidenceResult {
22+
if (!selection) {
23+
return { blockIds: [], targetPage: null };
24+
}
25+
26+
const blockIds =
27+
selection.kind === "roster"
28+
? [...new Set(selection.entry.mentions.map((mention) => mention.blockId))]
29+
: [
30+
...new Set(
31+
selection.claim.evidenceRefs.flatMap((ref) => ref.blockIds),
32+
),
33+
];
34+
35+
let targetPage: number | null = null;
36+
for (const block of blocks) {
37+
if (!blockIds.includes(block.blockId)) {
38+
continue;
39+
}
40+
for (const anchor of block.anchors) {
41+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Anchor union may expand
42+
if (anchor.kind === "file_page_bbox") {
43+
if (targetPage === null || anchor.page < targetPage) {
44+
targetPage = anchor.page;
45+
}
46+
}
47+
}
48+
}
49+
50+
return { blockIds, targetPage };
51+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/**
2+
* Page viewer: PDF page image with bbox overlay highlights.
3+
*/
4+
import { Box, Stack, Typography } from "@mui/material";
5+
import type { FunctionComponent } from "react";
6+
7+
import { Button } from "../../shared/ui/button";
8+
import { bboxToPercentage } from "./bbox-transform";
9+
import type { Block, PageImageManifest } from "./types";
10+
11+
interface PageViewerProps {
12+
pageImages: PageImageManifest[];
13+
blocks: Block[];
14+
highlightedBlockIds: string[];
15+
currentPage: number;
16+
onPageChange: (page: number) => void;
17+
}
18+
19+
export const PageViewer: FunctionComponent<PageViewerProps> = ({
20+
pageImages,
21+
blocks,
22+
highlightedBlockIds,
23+
currentPage,
24+
onPageChange,
25+
}) => {
26+
const totalPages = pageImages.length;
27+
const pageImage = pageImages.find((img) => img.pageNumber === currentPage);
28+
if (!pageImage) {
29+
return null;
30+
}
31+
32+
const visibleBlocks =
33+
highlightedBlockIds.length > 0
34+
? blocks.filter(
35+
(block) =>
36+
highlightedBlockIds.includes(block.blockId) &&
37+
block.anchors.some((anchor) => anchor.page === currentPage),
38+
)
39+
: [];
40+
41+
return (
42+
<Box>
43+
{/* Page navigation */}
44+
<Stack
45+
direction="row"
46+
spacing={1}
47+
alignItems="center"
48+
sx={{ mb: 1, fontSize: "0.875rem" }}
49+
>
50+
<Button
51+
size="small"
52+
variant="secondary"
53+
onClick={() => onPageChange(Math.max(1, currentPage - 1))}
54+
disabled={currentPage <= 1}
55+
>
56+
← Prev
57+
</Button>
58+
<Typography variant="smallTextLabels">
59+
Page {currentPage} / {totalPages}
60+
</Typography>
61+
<Button
62+
size="small"
63+
variant="secondary"
64+
onClick={() => onPageChange(Math.min(totalPages, currentPage + 1))}
65+
disabled={currentPage >= totalPages}
66+
>
67+
Next →
68+
</Button>
69+
{visibleBlocks.length > 0 && (
70+
<Typography
71+
variant="smallTextLabels"
72+
sx={{ color: "blue.70", ml: 1 }}
73+
>
74+
{visibleBlocks.length} highlighted
75+
</Typography>
76+
)}
77+
</Stack>
78+
79+
{/* Page image with bbox overlays */}
80+
<Box
81+
sx={{ position: "relative", display: "inline-block", lineHeight: 0 }}
82+
>
83+
<img
84+
src={pageImage.imageUrl}
85+
alt={`Page ${currentPage}`}
86+
style={{
87+
maxWidth: "100%",
88+
height: "auto",
89+
border: "1px solid",
90+
borderColor: "rgba(0, 0, 0, 0.12)",
91+
}}
92+
/>
93+
94+
{visibleBlocks.map((block) => {
95+
const anchor = block.anchors.find((anc) => anc.page === currentPage);
96+
if (!anchor) {
97+
return null;
98+
}
99+
100+
const pct = bboxToPercentage(
101+
anchor.bbox,
102+
pageImage.pdfPageWidth,
103+
pageImage.pdfPageHeight,
104+
pageImage.bboxOrigin,
105+
);
106+
107+
return (
108+
<Box
109+
key={block.blockId}
110+
title={`[${block.kind}] ${block.text.substring(0, 80)}`}
111+
sx={{
112+
position: "absolute",
113+
left: `${pct.left}%`,
114+
top: `${pct.top}%`,
115+
width: `${pct.width}%`,
116+
height: `${pct.height}%`,
117+
border: "2px solid rgba(59, 130, 246, 0.7)",
118+
backgroundColor: "rgba(59, 130, 246, 0.12)",
119+
pointerEvents: "none",
120+
boxSizing: "border-box",
121+
borderRadius: "2px",
122+
}}
123+
/>
124+
);
125+
})}
126+
</Box>
127+
</Box>
128+
);
129+
};

0 commit comments

Comments
 (0)