Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 41 additions & 23 deletions apps/hash-frontend/instrumentation-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,29 +10,47 @@ import {
SENTRY_ENVIRONMENT,
SENTRY_REPLAYS_SESSION_SAMPLE_RATE,
} from "./src/lib/public-env";
import { installIframeErrorReporter } from "./src/pages/processes/shared/iframe-error-reporter";

Sentry.init({
dsn: SENTRY_DSN,
enabled: !!SENTRY_DSN,
environment: SENTRY_ENVIRONMENT,
integrations: [
Sentry.browserApiErrorsIntegration(),
Sentry.browserProfilingIntegration(),
Sentry.browserSessionIntegration(),
Sentry.browserTracingIntegration(),
Sentry.graphqlClientIntegration({
endpoints: [/\/graphql$/],
}),
Sentry.replayIntegration(),
],
release: buildStamp,
replaysOnErrorSampleRate: 1,
replaysSessionSampleRate: parseFloat(
SENTRY_REPLAYS_SESSION_SAMPLE_RATE ?? "0",
),
sendDefaultPii: true,
tracePropagationTargets: ["localhost", /^https:\/\/(?:.*\.)?hash\.ai/],
tracesSampleRate: isProduction ? 1.0 : 0,
});
/**
* The Petrinaut embed page (`/processes/<uuid>/embed`) runs inside a
* sandboxed null-origin iframe with `connect-src 'self'`. Sentry's
* transport would be blocked by CSP and the resulting events would lack
* the host's authenticated-user context anyway. Instead we install a
* tiny reporter that forwards errors to the host over the postMessage
* bridge, and the host's Sentry SDK captures them with iframe-specific
* tags.
*/
const isPetrinautEmbedDocument =
typeof window !== "undefined" &&
/^\/processes\/[^/]+\/embed/.test(window.location.pathname);

if (isPetrinautEmbedDocument) {
installIframeErrorReporter();
} else {
Sentry.init({
dsn: SENTRY_DSN,
enabled: !!SENTRY_DSN,
environment: SENTRY_ENVIRONMENT,
integrations: [
Sentry.browserApiErrorsIntegration(),
Sentry.browserProfilingIntegration(),
Sentry.browserSessionIntegration(),
Sentry.browserTracingIntegration(),
Sentry.graphqlClientIntegration({
endpoints: [/\/graphql$/],
}),
Sentry.replayIntegration(),
],
release: buildStamp,
replaysOnErrorSampleRate: 1,
replaysSessionSampleRate: parseFloat(
SENTRY_REPLAYS_SESSION_SAMPLE_RATE ?? "0",
),
sendDefaultPii: true,
tracePropagationTargets: ["localhost", /^https:\/\/(?:.*\.)?hash\.ai/],
tracesSampleRate: isProduction ? 1.0 : 0,
});
}

export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
17 changes: 17 additions & 0 deletions apps/hash-frontend/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,22 @@ export default withSentryConfig(
},
],
},
{
/**
* Self-hosted fonts referenced from `globals.scss` (Inter, Open
* Sauce Two, Apercu, IBM Plex, …). The Petrinaut embed route is
* loaded into a sandboxed null-origin iframe; the browser's
* anonymous-CORS rule for font fetches treats the same-host
* request as cross-origin and rejects it without these headers.
*/
source: "/fonts/:path*",
headers: [
{
key: "access-control-allow-origin",
value: "*",
},
],
},
];
},
pageExtensions: ["page.tsx", "page.ts", "page.jsx", "page.jsx", "api.ts"],
Expand All @@ -165,6 +181,7 @@ export default withSentryConfig(
"@hashintel/block-design-system",
"@hashintel/design-system",
"@hashintel/petrinaut",
"@hashintel/petrinaut-core",
"@hashintel/ds-components",
"@hashintel/ds-helpers",
"@hashintel/type-editor",
Expand Down
6 changes: 5 additions & 1 deletion apps/hash-frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"start:test:healthcheck": "wait-on --timeout 600000 http://localhost:3000"
},
"dependencies": {
"@ai-sdk/openai": "3.0.63",
"@apollo/client": "3.10.5",
"@blockprotocol/core": "0.1.4",
"@blockprotocol/graph": "workspace:*",
Expand All @@ -39,6 +40,7 @@
"@hashintel/ds-components": "workspace:*",
"@hashintel/ds-helpers": "workspace:*",
"@hashintel/petrinaut": "workspace:*",
"@hashintel/petrinaut-core": "workspace:*",
"@hashintel/query-editor": "workspace:*",
"@hashintel/type-editor": "workspace:*",
"@lit-labs/react": "1.2.1",
Expand Down Expand Up @@ -68,6 +70,7 @@
"@tldraw/tlvalidate": "2.0.0-alpha.12",
"@types/prismjs": "1.26.5",
"@vercel/edge-config": "0.4.1",
"ai": "6.0.182",
"axios": "1.16.1",
"buffer": "6.0.3",
"clsx": "2.1.1",
Expand Down Expand Up @@ -128,7 +131,8 @@
"url-regex-safe": "4.0.0",
"use-font-face-observer": "1.3.0",
"uuid": "14.0.0",
"web-worker": "1.4.1"
"web-worker": "1.4.1",
"zod": "4.4.3"
},
"devDependencies": {
"@graphql-codegen/cli": "^6.2.1",
Expand Down
216 changes: 216 additions & 0 deletions apps/hash-frontend/src/app/api/petrinaut-ai-chat/route.api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import { createOpenAI } from "@ai-sdk/openai";
import {
convertToModelMessages,
createProviderRegistry,
safeValidateUIMessages,
streamText,
type ToolSet,
type UIMessage,
} from "ai";
import { z } from "zod";

import { petrinautAiPrompt, petrinautAiTools } from "@hashintel/petrinaut-core";

import { oryKratosClient } from "../../../pages/shared/ory-kratos";

/**
* This endpoint is an App Router Route Handler β€” *not* a Pages Router API
* route β€” specifically so it can stream. Pages Router API routes buffer the
* entire response before flushing it (a documented Next.js limitation), which
* defeated the token-by-token streaming the AI assistant relies on.
*
* `force-dynamic` keeps Next from trying to cache/optimise the handler.
*/
export const dynamic = "force-dynamic";

const DEFAULT_MODEL = "gpt-5.5-2026-04-23";

/**
* Per-authenticated user rate limit.
*/
const RATE_LIMIT_WINDOW_MS = 30_000;
const RATE_LIMIT_MAX_REQUESTS = 10;
const RATE_LIMIT_MAX_TRACKED_USERS = 10_000;

const requestSchema = z.object({
id: z.string().optional(),
messages: z.unknown(),
});

const petrinautAiValidationTools = Object.fromEntries(
Object.entries(petrinautAiTools).map(([toolName, aiTool]) => [
toolName,
{
description: aiTool.description,
inputSchema: aiTool.inputSchema,
outputSchema: z.unknown(),
},
]),
) satisfies ToolSet;

/**
* In-memory token buckets keyed by the authenticated user's Ory identity id.
*
* This is not a reliable global limit when this endpoint is deployed as a serverless function.
*
* @todo move this into the Node API or elsewhere for proper rate limiting
*/
const rateLimitBuckets = new Map<string, { count: number; resetAt: number }>();

const jsonResponse = (body: unknown, init: ResponseInit = {}): Response => {
const headers = new Headers(init.headers);
headers.set("content-type", "application/json");
return new Response(JSON.stringify(body), { ...init, headers });
};

const logChatFailure = (
reason: string,
context: Record<string, unknown> = {},
) => {
// eslint-disable-next-line no-console
console.error(`[Petrinaut AI] ${reason}`, context);

Check notice

Code scanning / Semgrep PRO

Semgrep Finding: javascript.lang.security.audit.unsafe-formatstring.unsafe-formatstring Note

Detected string concatenation with a non-literal variable in a util.format / console.log function. If an attacker injects a format specifier in the string, it will forge the log message. Try to use constant values for the format string.
};

const validationErrorBody = (
error: unknown,
): { error: string; detail?: string } =>
process.env.NODE_ENV === "production" || !(error instanceof Error)
? { error: "Invalid chat messages" }
: { error: "Invalid chat messages", detail: error.message };

const checkRateLimit = (userId: string): boolean => {
const now = Date.now();
const current = rateLimitBuckets.get(userId);

if (!current || current.resetAt <= now) {
// The bucket map only grows; when it crosses the cap, drop every expired
// bucket in one sweep before inserting the new one.
if (rateLimitBuckets.size >= RATE_LIMIT_MAX_TRACKED_USERS) {
for (const [key, bucket] of rateLimitBuckets) {
if (bucket.resetAt <= now) {
rateLimitBuckets.delete(key);
}
}
if (rateLimitBuckets.size >= RATE_LIMIT_MAX_TRACKED_USERS) {
return false;
}
}
rateLimitBuckets.set(userId, {
count: 1,
resetAt: now + RATE_LIMIT_WINDOW_MS,
});
return true;
}

if (current.count >= RATE_LIMIT_MAX_REQUESTS) {
return false;
}

current.count += 1;
return true;
};

const resolveUserId = async (cookie: string | null): Promise<string | null> => {
if (!cookie) {
return null;
}
try {
const { data } = await oryKratosClient.toSession({ cookie });
return data.identity?.id ?? null;
} catch {
return null;
}
};

/**
* Route Handler that proxies the Petrinaut AI assistant's chat requests to
* OpenAI.
*
* The assistant runs inside a sandboxed null-origin iframe (see
* `processes/[uuid]/embed`) which cannot reach this route directly β€” its
* requests are relayed by the host page (`process-editor.tsx`) over the
* postMessage bridge and fetched here with the user's session cookie.
*/
export const POST = async (request: Request): Promise<Response> => {
const userId = await resolveUserId(request.headers.get("cookie"));
if (!userId) {
return jsonResponse({ error: "Not authenticated" }, { status: 401 });
}

if (!checkRateLimit(userId)) {
logChatFailure("Rejected rate-limited request", { userId });
return jsonResponse({ error: "Rate limit exceeded" }, { status: 429 });
}

const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
logChatFailure("Missing OpenAI API key");
return jsonResponse(
{ error: "OPENAI_API_KEY is not configured" },
{ status: 500 },
);
}

let body: unknown;
try {
body = await request.json();
} catch (error) {
logChatFailure("Rejected invalid JSON", { error });
return jsonResponse({ error: "Invalid JSON" }, { status: 400 });
}

const parsed = requestSchema.safeParse(body);
if (!parsed.success) {
logChatFailure("Rejected invalid chat request", { error: parsed.error });
return jsonResponse({ error: "Invalid chat request" }, { status: 400 });
}

const validatedMessages = await safeValidateUIMessages<UIMessage>({
messages: parsed.data.messages,
tools: petrinautAiValidationTools,
});

if (!validatedMessages.success) {
logChatFailure("Rejected invalid chat messages", {
error: validatedMessages.error,
});
return jsonResponse(validationErrorBody(validatedMessages.error), {
status: 400,
});
}

const openai = createOpenAI({ apiKey });
const registry = createProviderRegistry({ openai });
const modelId = process.env.PETRINAUT_AI_MODEL ?? DEFAULT_MODEL;

const result = streamText({
model: registry.languageModel(`openai:${modelId}`),
system: petrinautAiPrompt,
messages: await convertToModelMessages(validatedMessages.data, {
tools: petrinautAiTools,
}),
tools: petrinautAiTools,
providerOptions: {
openai: {
reasoningEffort: "medium",
reasoningSummary: "auto",
textVerbosity: "medium",
},
},
onError: ({ error }) => {
logChatFailure("AI stream error", { error });
},
});

// `streamText`'s own `onError` only logs server-side β€” the
// `toUIMessageStreamResponse` `onError` is what propagates a visible error
// chunk to the client so `useChat` can surface a failure instead of just
// quietly transitioning the status back to `"ready"`.
return result.toUIMessageStreamResponse({
sendReasoning: true,
onError: (error) => {
logChatFailure("AI response error", { error });
return error instanceof Error ? error.message : "AI request failed";
},
});
};
Loading
Loading