diff --git a/.changeset/fluffy-masks-visit.md b/.changeset/fluffy-masks-visit.md new file mode 100644 index 00000000000..cd47772c5b3 --- /dev/null +++ b/.changeset/fluffy-masks-visit.md @@ -0,0 +1,5 @@ +--- +"@hashintel/ds-components": patch +--- + +add arrow-right-arrow-left icon diff --git a/.changeset/polite-snakes-send.md b/.changeset/polite-snakes-send.md new file mode 100644 index 00000000000..af8a771d9b8 --- /dev/null +++ b/.changeset/polite-snakes-send.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut-core": patch +--- + +improve and expand instance action schemas diff --git a/.changeset/young-kids-laugh.md b/.changeset/young-kids-laugh.md new file mode 100644 index 00000000000..b822924d16a --- /dev/null +++ b/.changeset/young-kids-laugh.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut": patch +--- + +add AI assistant diff --git a/.claude/skills/fractal-file-structuring/SKILL.md b/.claude/skills/fractal-file-structuring/SKILL.md new file mode 100644 index 00000000000..102c8771a63 --- /dev/null +++ b/.claude/skills/fractal-file-structuring/SKILL.md @@ -0,0 +1,192 @@ +--- +name: fractal-file-structuring +description: "Use when creating, moving, splitting, or organizing TypeScript files and folders. Applies fractal tree file-structuring rules which reduce the cognitive overhead of choosing where to put files and ultimately navigating a codebase (once the structure is established and understood)." +license: MIT +metadata: + triggers: + type: domain + enforcement: suggest + priority: high + keywords: + - TypeScript + - JavaScript + - file structure + - folder structure + - create file + - create folder + - split file + - shared folder + intent-patterns: + - "\\b(create|add|move|split|organize|refactor)\\b.*?\\b(file|folder|directory|module|component|hook|type|helper)\\b" + - "\\b(file|folder|directory)\\b.*?\\b(structure|layout|organization|placement)\\b" +--- + +# Fractal File Structuring + +TypeScript and JavaScript files should be organised in a fractal tree structure. Use this skill when deciding where to create, move, split, or organize files and folders in a TypeScript or JavaScript workspace. + +This guidance is based on HASH's file-structuring approach: https://hash.dev/blog/file-structuring + +## Scope + +Apply this skill to TypeScript and JavaScript source files, including modules, components, hooks, helpers, types, tests, scripts, and entry points. + +## Core Rules + +### Use kebab-case names + +Use kebab-case for all TypeScript and JavaScript file and folder names. + +```text +create-worker-factory.ts +playback-settings-menu.tsx +button.tsx +``` + +Avoid PascalCase, camelCase, and mixed-case file names, even for React components. + +### Do not create index files + +Do not add `index.ts`, `index.tsx`, `index.js`, or `index.jsx` files for folder imports. Prefer explicit file entry points with meaningful names. + +If a subtree needs a public entry point, name that file after the concept it exposes (e.g. `schema.ts`) + +### Treat each file as a mini-library + +A file should expose one or more named exports with a shared semantic purpose. The file name should summarize that purpose (e.g. `users.ts`) + +If a file contains only one main export, prefer naming the file after that export in kebab-case (e.g. `create-user.ts`) + +Avoid default exports unless a framework or external API requires them. + +### Split outgrown files into private subtrees + +When a file becomes too large or contains implementation details worth extracting, create a same-named folder next to it and move private pieces there. + +```text +editor-view.tsx # public mini-library: the component other files import +editor-view/ + panels.tsx # private entry point imported by editor-view.tsx + panels/ + simulate-view.tsx # private to panels.tsx + calculate-timeline-range.ts # private helper used only by editor-view.tsx + create-panel-state.ts # private helper used only by editor-view.tsx +``` + +Only `editor-view.tsx` should import from direct child mini-libraries such as `editor-view/panels.tsx` and `editor-view/calculate-timeline-range.ts`. Only `editor-view/panels.tsx` should import from `editor-view/panels/*.tsx`. Other files should import from `editor-view.tsx`, not from its private subtree. This keeps `editor-view.tsx` as the API boundary and makes `editor-view/` read as its implementation. + +If `editor-view/calculate-timeline-range.ts` grows and needs its own private implementation files, create `editor-view/calculate-timeline-range/`. Only `editor-view/calculate-timeline-range.ts` should import from that deeper subtree. + +```text +editor-view/ + calculate-timeline-range.ts + calculate-timeline-range/ + clamp-time.ts # private to calculate-timeline-range.ts + get-visible-duration.ts # private to calculate-timeline-range.ts +``` + +### Keep private subtrees private + +Do not import directly from another file's implementation folder. + +```typescript +// Avoid: reaches into another file's private subtree +import { SimulateView } from "../editor-view/panels/simulate-view"; + +// Prefer (1): import from a public mini-library (if it is conceptually part of editor-view) +import { EditorView } from "../editor-view"; + +// Prefer (2): move shared code to a shared folder (if it is NOT conceptually part of editor-view) +import { Button } from "../shared/button"; +``` + +If a resource must be available outside the subtree, re-export it from the subtree root only when it is part of that root's public concept. If it is independently useful to sibling branches, move it to an appropriate `shared/` folder instead. + +### Put shared resources at the closest fork + +When multiple sibling branches need the same helper, type, component, constant, or hook, place it in the nearest applicable `shared/` folder. + +```text +editor-view.tsx +editor-view/ + shared/ + duration-label.tsx # used by both panels.tsx and bottom-section.tsx + playback-time.ts # shared formatting/parsing logic for this subtree + panels.tsx # imports from panels/ + panels/ + simulate-view.tsx # private to panels.tsx + bottom-section.tsx # imports from bottom-section/ + bottom-section/ + bottom-bar.tsx # private to bottom-section.tsx +``` + +Place shared files as deep as possible while still covering all current consumers. Do not move something to a high-level shared folder just because it might be reused later. + +Here `editor-view.tsx` imports `./editor-view/panels` and `./editor-view/bottom-section`. `panels.tsx` may import `./panels/simulate-view` and `./shared/duration-label`; `bottom-section.tsx` may import `./bottom-section/bottom-bar` and `./shared/duration-label`. Nothing else should import from `panels/` or `bottom-section/` directly. + +Shared files are mini-libraries too. A shared file can have its own private same-named subtree, and those internals should remain private to that shared file. + +```text +editor-view/ + shared/ + playback-time.ts # public to editor-view/* branches + playback-time/ + parse-playback-time.ts # private to playback-time.ts + format-playback-time.ts # private to playback-time.ts +``` + +If later only `bottom-bar.tsx` uses `duration-label.tsx`, move it beside `bottom-bar.tsx` or under `bottom-bar/`. The folder structure should describe current consumers, not preserve old sharing. + +### Use relative imports within a workspace + +For imports inside the same workspace, use relative paths. Do not introduce workspace-local aliases just to shorten paths. + +Imports from other workspaces should use the package name. + +### Co-locate unit tests + +Place unit tests next to the file they cover. + +```text +foo.ts +foo.test.ts +``` + +If a private extracted file needs direct tests, place those tests next to that extracted file. + +```text +editor-view.tsx +editor-view.test.tsx +editor-view/ + calculate-timeline-range.ts + calculate-timeline-range.test.ts +``` + +Prefer testing through the public mini-library when that gives enough coverage. Add direct tests for private extracted files when the logic is complex enough that tests through the owner would be indirect or brittle. + +### Match the current shape + +Organize files for the code's current relationships, not speculative future reuse. Moving files later is expected and cheaper than adding premature structure now. + +## Decision Checklist + +Before creating a TypeScript or JavaScript file or folder: + +1. Identify the semantic concept the file represents. +2. Name the file or folder in kebab-case. +3. If extracting from an existing file, put private implementation files under a same-named folder. +4. If multiple current branches need the resource, put it in the nearest `shared/` folder. +5. Avoid `index` files and implicit folder imports. +6. Use relative imports within the workspace. +7. Co-locate tests with the file under test. + +## When Unsure + +Choose the location that communicates the file's current consumers and API boundary most clearly: + +- Private implementation detail: place it under the owning file's same-named folder, and import it only from that owner. +- Named mini-library: create a normal named file when the concept has its own purpose and exports a small API for nearby consumers. +- Shared mini-library: place the named file in the closest `shared/` folder when multiple branches need that API. +- Subtree entry point: expose the public API from a named root file, and keep any deeper implementation files private to that root. + +Do not add broad `components`, `hooks`, `utils`, `types`, or `services` folders unless absolutely necessary. If they exist, these folders MUST only be imported from by files called `components.ts`, `hooks.ts`, etc. diff --git a/.claude/skills/skill-rules.json b/.claude/skills/skill-rules.json index 00bde60a200..ebeb6f166aa 100644 --- a/.claude/skills/skill-rules.json +++ b/.claude/skills/skill-rules.json @@ -78,6 +78,36 @@ "blockMessage": "Skill is required to proceed", "skipConditions": {} }, + "fractal-file-structuring": { + "type": "domain", + "enforcement": "suggest", + "priority": "high", + "description": "Use when creating, moving, splitting, or organizing TypeScript files and folders. Applies fractal tree file-structuring rules which reduce the cognitive overhead of choosing where to put files and ultimately navigating a codebase (once the structure is established and understood).", + "promptTriggers": { + "keywords": [ + "TypeScript", + "JavaScript", + "file structure", + "folder structure", + "create file", + "create folder", + "split file", + "shared folder" + ], + "intentPatterns": [ + "\\b(create|add|move|split|organize|refactor)\\b.*?\\b(file|folder|directory|module|component|hook|type|helper)\\b", + "\\b(file|folder|directory)\\b.*?\\b(structure|layout|organization|placement)\\b" + ] + }, + "fileTriggers": { + "include": [], + "exclude": [], + "content": [], + "create-only": false + }, + "blockMessage": "Skill is required to proceed", + "skipConditions": {} + }, "handling-rust-errors": { "type": "domain", "enforcement": "suggest", diff --git a/.gitignore b/.gitignore index 455d2d6f023..69c0f3812ca 100644 --- a/.gitignore +++ b/.gitignore @@ -182,3 +182,4 @@ out/ # Jujutsu .jj/ +.vercel diff --git a/apps/mcp/linear/package.json b/apps/mcp/linear/package.json index f4d219a8a4d..1f4dc74a2e3 100644 --- a/apps/mcp/linear/package.json +++ b/apps/mcp/linear/package.json @@ -23,7 +23,7 @@ "@local/tsconfig": "workspace:*", "@modelcontextprotocol/sdk": "1.26.0", "dotenv-flow": "3.3.0", - "zod": "4.1.12", + "zod": "4.4.3", "zod-to-json-schema": "3.24.6" }, "devDependencies": { diff --git a/apps/mcp/notion/package.json b/apps/mcp/notion/package.json index dbb5256ab0f..20c2aa01313 100644 --- a/apps/mcp/notion/package.json +++ b/apps/mcp/notion/package.json @@ -23,7 +23,7 @@ "@notionhq/client": "5.3.0", "dotenv-flow": "3.3.0", "notion-to-md": "3.1.9", - "zod": "4.1.12", + "zod": "4.4.3", "zod-to-json-schema": "3.24.6" }, "devDependencies": { diff --git a/apps/petrinaut-website/.env.example b/apps/petrinaut-website/.env.example new file mode 100644 index 00000000000..dfb9d72a1b6 --- /dev/null +++ b/apps/petrinaut-website/.env.example @@ -0,0 +1 @@ +OPENAI_API_KEY=sk-xxxx diff --git a/apps/petrinaut-website/README.md b/apps/petrinaut-website/README.md new file mode 100644 index 00000000000..fe771f3f03e --- /dev/null +++ b/apps/petrinaut-website/README.md @@ -0,0 +1,89 @@ +# Petrinaut Website + +A website for demoing Petrinaut (libs/@hashintel/petrinaut). + +A SPA plus a single API function that proxies AI requests to OpenAI. + +## Quickstart + +```sh +cp .env.example .env.local +# add your OPENAI_API_KEY to .env.local, if you want to use the chat feature + +turbo run dev +``` + +The dev server runs at [http://localhost:5173](http://localhost:5173). A plugin in `vite.config.ts` loads the API function. + +In production, the function in the `api` folder is automatically deployed as a Vercel Serverless Function. + +## Environment variables + +| Name | Required | Used by | Notes | +| -------------------- | ---------------- | ---------------- | --------------------------------------------------------- | +| `OPENAI_API_KEY` | for chat to work | `api/chat.ts` | OpenAI key the function uses to call `streamText`. | +| `PETRINAUT_AI_MODEL` | no | `api/chat.ts` | Overrides the default OpenAI model id. | +| `SENTRY_DSN` | no | `vite.config.ts` | Wired into the bundle via `__SENTRY_DSN__` at build time. | + +Local values live in `.env.local`; Vite's `loadEnv` (see [`vite.config.ts`](vite.config.ts)) copies them into `process.env` for both the dev server and the chat function. In production, set these in the Vercel project settings. + +## Testing the API against the built output + +A plain `yarn build && yarn vite preview` only serves the static `dist/` assets - `/api/chat` will 404 because the dev plugin is not loaded by `vite preview`. Use one of the options below to exercise the production code path locally. + +### Option A: `vercel dev` (recommended) + +Closest to the real Vercel runtime. It builds the site, bundles the function, and serves both from a single port using the actual Node runtime + routing layer. + +Requires linking to a Vercel project. If you don't have access, go for Option B (or just use `turbo run dev` instead). + +```sh +cd apps/petrinaut-website + +npx vercel link # first-time setup + +npx vercel dev # builds + serves on http://localhost:3000 +``` + +Notes: + +- `vercel dev` does not read your existing `dist/`; it rebuilds. If you specifically need to inspect the artifact you already produced, use option B (or amend the devCommand in vercel.json to remove the build step). + +### Option B: `vite preview` + a sibling Node API server + +Useful when you want to serve the literal `dist/` artifact you just built and avoid the Vercel CLI. It is two processes, glued together by `preview.proxy`. + +1. Add a proxy entry to `vite.config.ts` (only needed while you are testing this flow): + + ```ts + preview: { + proxy: { "/api": "http://localhost:3001" }, + }, + ``` + +2. Create a throwaway `scripts/preview-api.mjs` that mounts the same handler with `createServerAdapter`: + + ```js + import { createServer } from "node:http"; + import { createServerAdapter } from "@whatwg-node/server"; + import handler from "../api/chat.ts"; + + createServer(createServerAdapter(handler)).listen(3001, () => { + console.log("preview API listening on http://localhost:3001"); + }); + ``` + +3. Run them side by side (Node 22.6+ can execute the TypeScript entry directly with `--experimental-strip-types`): + + ```sh + yarn build + yarn vite preview # :4173 + node --experimental-strip-types scripts/preview-api.mjs # :3001 + ``` + +`/api/chat` requests against `:4173` will be proxied to the local API server, which loads the same handler the deployed function uses. + +## Known caveats + +- **In-memory rate limiting.** [`api/chat.ts`](api/chat.ts) keys rate-limit buckets by the client IP that Vercel's edge writes into `x-forwarded-for` (which Vercel actively prevents the caller from spoofing - see the [request headers docs](https://vercel.com/docs/edge-network/headers/request-headers)). The bucket map lives in module scope, so it resets on cold start and is not shared between concurrent function instances. +- **`vercel-build.sh` deletes the repo-root `.env`.** This is intentional (mise picks it up otherwise), but worth knowing if you run `vercel dev` locally and keep secrets there. diff --git a/apps/petrinaut-website/api/chat.ts b/apps/petrinaut-website/api/chat.ts new file mode 100644 index 00000000000..587e01bce49 --- /dev/null +++ b/apps/petrinaut-website/api/chat.ts @@ -0,0 +1,229 @@ +import { createOpenAI } from "@ai-sdk/openai"; +import { + convertToModelMessages, + createProviderRegistry, + safeValidateUIMessages, + streamText, + type ToolSet, + type UIMessage, +} from "ai"; +import { z } from "zod"; + +import { petrinautAiTools, petrinautAiPrompt } from "@hashintel/petrinaut-core"; + +declare const process: { + env: Record; +}; + +const DEFAULT_MODEL = "gpt-5.5-2026-04-23"; +const RATE_LIMIT_WINDOW_MS = 60_000; +const RATE_LIMIT_MAX_REQUESTS = 20; +const RATE_LIMIT_MAX_TRACKED_CLIENTS = 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; + +const rateLimitBuckets = new Map(); + +const jsonResponse = (body: unknown, init: ResponseInit = {}) => { + 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 = {}, +) => { + // oxlint-disable-next-line no-console + console.error(`[Petrinaut AI] ${reason}`, context); +}; + +const validationErrorBody = ( + error: unknown, +): { error: string; detail?: string } => + process.env.VERCEL_ENV === "production" || !(error instanceof Error) + ? { error: "Invalid chat messages" } + : { error: "Invalid chat messages", detail: error.message }; + +/** + * Resolve the public client IP for rate-limiting. + * + * Vercel's edge overwrites `x-forwarded-for` with the real client IP and + * refuses to forward externally-set values, so the header cannot be spoofed + * by the caller. `x-vercel-forwarded-for` carries the same value but is also + * immune to a custom proxy placed in front of Vercel. + * + * See https://vercel.com/docs/edge-network/headers/request-headers + */ +const resolveClientIp = (request: Request): string | null => { + const forwardedFor = request.headers.get("x-forwarded-for"); + if (forwardedFor) { + const first = forwardedFor.split(",")[0]?.trim(); + if (first) { + return first; + } + } + return request.headers.get("x-vercel-forwarded-for"); +}; + +const checkRateLimit = (clientIp: string): boolean => { + const now = Date.now(); + const current = rateLimitBuckets.get(clientIp); + + if (!current || current.resetAt <= now) { + // The bucket map only grows; on a warm function instance with many unique + // clients it would accumulate indefinitely. When we cross the cap, drop + // every expired bucket in one sweep before inserting the new one. + if (rateLimitBuckets.size >= RATE_LIMIT_MAX_TRACKED_CLIENTS) { + for (const [key, bucket] of rateLimitBuckets) { + if (bucket.resetAt <= now) { + rateLimitBuckets.delete(key); + } + } + if (rateLimitBuckets.size >= RATE_LIMIT_MAX_TRACKED_CLIENTS) { + // If we've somehow hit the client cap, refuse the request. + return false; + } + } + rateLimitBuckets.set(clientIp, { + count: 1, + resetAt: now + RATE_LIMIT_WINDOW_MS, + }); + return true; + } + + if (current.count >= RATE_LIMIT_MAX_REQUESTS) { + return false; + } + + current.count += 1; + return true; +}; + +/** + * API endpoint to proxy requests for AI assistance to OpenAI. + * + * Exported via a default `{ fetch }` object so Vercel's Node.js runtime treats + * this as a Web fetch handler and hands us a `Request`. Without this opt-in, + * the default export is invoked with a Node.js `IncomingMessage`, whose + * `headers` is a plain object (no `.get(...)` method) and would crash + * `resolveClientIp`. + * + * See https://vercel.com/changelog/node-js-vercel-functions-now-support-fetch-web-handlers + */ +const fetch = async (request: Request): Promise => { + if (request.method === "OPTIONS") { + // We'll always serve this same-origin so we don't need any CORS config + return new Response(null, { status: 204 }); + } + + if (request.method !== "POST") { + logChatFailure("Rejected unsupported method", { method: request.method }); + return jsonResponse({ error: "Method not allowed" }, { status: 405 }); + } + + const clientIp = resolveClientIp(request); + if (process.env.VERCEL_ENV === "production" && !clientIp) { + // Vercel's edge always sets x-forwarded-for in production. If it isn't + // present, the request reached us through an unexpected path and we have + // no way to rate-limit it - reject conservatively rather than fail open. + logChatFailure("Rejected production request with no resolvable client IP"); + return jsonResponse( + { error: "Could not determine client IP" }, + { status: 400 }, + ); + } + + if (clientIp && !checkRateLimit(clientIp)) { + logChatFailure("Rejected rate-limited request", { clientIp }); + 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({ + 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"` on a truncated stream. + return result.toUIMessageStreamResponse({ + sendReasoning: true, + onError: (error) => { + logChatFailure("AI response error", { error }); + return error instanceof Error ? error.message : "AI request failed"; + }, + }); +}; + +export default { fetch }; diff --git a/apps/petrinaut-website/package.json b/apps/petrinaut-website/package.json index d50edb48de3..87e230614d2 100644 --- a/apps/petrinaut-website/package.json +++ b/apps/petrinaut-website/package.json @@ -8,9 +8,11 @@ "dev": "vite", "fix:eslint": "oxlint --fix --type-aware --report-unused-disable-directives-severity=error .", "lint:eslint": "oxlint --type-aware --report-unused-disable-directives-severity=error .", - "lint:tsc": "tsgo --noEmit" + "lint:tsc": "tsgo --noEmit", + "preview": "vite preview" }, "dependencies": { + "@ai-sdk/openai": "3.0.63", "@hashintel/ds-components": "workspace:*", "@hashintel/ds-helpers": "workspace:*", "@hashintel/petrinaut": "workspace:*", @@ -18,10 +20,12 @@ "@mantine/hooks": "8.3.5", "@pandacss/dev": "1.11.1", "@sentry/react": "10.22.0", + "ai": "6.0.182", "immer": "10.1.3", "react": "19.2.6", "react-dom": "19.2.6", - "react-icons": "5.5.0" + "react-icons": "5.5.0", + "zod": "4.4.3" }, "devDependencies": { "@rolldown/plugin-babel": "0.2.1", @@ -29,6 +33,7 @@ "@types/react-dom": "19.2.3", "@typescript/native-preview": "7.0.0-dev.20260511.1", "@vitejs/plugin-react": "6.0.1", + "@whatwg-node/server": "0.10.18", "babel-plugin-react-compiler": "1.0.0", "oxlint": "1.63.0", "oxlint-tsgolint": "0.22.1", diff --git a/apps/petrinaut-website/src/main/app.tsx b/apps/petrinaut-website/src/main/app.tsx index b8de524bdad..d4452c45b5d 100644 --- a/apps/petrinaut-website/src/main/app.tsx +++ b/apps/petrinaut-website/src/main/app.tsx @@ -1,10 +1,16 @@ import { produce } from "immer"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { createJsonDocHandle } from "@hashintel/petrinaut-core"; -import { Petrinaut } from "@hashintel/petrinaut/ui"; +import { + DefaultChatTransport, + Petrinaut, + type PetrinautAiChatTransport, + type PetrinautAiMessage, +} from "@hashintel/petrinaut/ui"; import { useSentryFeedbackAction } from "./app/sentry-feedback-button"; +import { useLocalStorageAiMessages } from "./app/use-local-storage-ai-messages"; import { type SDCPNInLocalStorage, useLocalStorageSDCPNs, @@ -59,6 +65,11 @@ const createLocalStorageNetRecord = (params: { const createHandle = (net: SDCPNInLocalStorage): PetrinautDocHandle => createJsonDocHandle({ id: net.id, initial: net.sdcpn }); +const petrinautAiChatTransport: PetrinautAiChatTransport = + new DefaultChatTransport({ + api: "/api/chat", + }); + const getStoredSDCPNsForDisplay = ( storedSDCPNs: Record, ): Record => { @@ -92,13 +103,21 @@ const createActiveHandle = (net: SDCPNInLocalStorage): ActiveHandle => ({ */ export const DevApp = () => { const sentryFeedbackAction = useSentryFeedbackAction(); + const { aiMessagesByNetId, setAiMessagesByNetId } = + useLocalStorageAiMessages(); const { storedSDCPNs, setStoredSDCPNs } = useLocalStorageSDCPNs(); const storedSDCPNsForDisplay = getStoredSDCPNsForDisplay(storedSDCPNs); - const firstNet = Object.values(storedSDCPNsForDisplay)[0] ?? null; + + // Pick the most recently modified net + const mostRecentlyModifiedNet = + Object.values(storedSDCPNsForDisplay).sort( + (a, b) => + new Date(b.lastUpdated).getTime() - new Date(a.lastUpdated).getTime(), + )[0] ?? null; // The net currently selected in the UI. const [currentNetId, setCurrentNetId] = useState( - () => firstNet?.id ?? null, + () => mostRecentlyModifiedNet?.id ?? null, ); // Metadata and persisted SDCPN snapshot for the selected net. @@ -108,7 +127,9 @@ export const DevApp = () => { // Live editable document handle for the selected net only. const [activeHandle, setActiveHandle] = useState(() => - firstNet ? createActiveHandle(firstNet) : null, + mostRecentlyModifiedNet + ? createActiveHandle(mostRecentlyModifiedNet) + : null, ); useEffect(() => { @@ -219,6 +240,35 @@ export const DevApp = () => { ); }; + const aiAssistant = useMemo( + () => ({ + transport: petrinautAiChatTransport, + messages: currentNetId ? aiMessagesByNetId[currentNetId] : undefined, + onMessages: (messages: PetrinautAiMessage[]) => { + if (!currentNetId) { + return; + } + + setAiMessagesByNetId((prev) => ({ + ...prev, + [currentNetId]: messages, + })); + }, + onClearMessages: () => { + if (!currentNetId) { + return; + } + + setAiMessagesByNetId((prev) => { + const next = { ...prev }; + delete next[currentNetId]; + return next; + }); + }, + }), + [aiMessagesByNetId, currentNetId, setAiMessagesByNetId], + ); + if (!currentNet) { return null; } @@ -230,6 +280,7 @@ export const DevApp = () => { return (
; + +export const useLocalStorageAiMessages = () => { + const [aiMessagesByNetId, setAiMessagesByNetId] = + useLocalStorage({ + key: rootLocalStorageKey, + defaultValue: {}, + getInitialValueInEffect: false, + }); + + return { aiMessagesByNetId, setAiMessagesByNetId }; +}; diff --git a/apps/petrinaut-website/tsconfig.json b/apps/petrinaut-website/tsconfig.json index a8ddecb6f7e..32ce2125854 100644 --- a/apps/petrinaut-website/tsconfig.json +++ b/apps/petrinaut-website/tsconfig.json @@ -15,5 +15,5 @@ "skipLibCheck": true, "isolatedModules": true }, - "include": ["src"] + "include": ["api", "src"] } diff --git a/apps/petrinaut-website/vercel.json b/apps/petrinaut-website/vercel.json index b32d74b2e53..55c31b0694f 100644 --- a/apps/petrinaut-website/vercel.json +++ b/apps/petrinaut-website/vercel.json @@ -5,5 +5,11 @@ }, "buildCommand": "./vercel-build.sh", "installCommand": "./vercel-install.sh", - "outputDirectory": "./dist" + "devCommand": "turbo run build && yarn preview", + "outputDirectory": "./dist", + "functions": { + "api/chat.ts": { + "maxDuration": 300 + } + } } diff --git a/apps/petrinaut-website/vite.config.ts b/apps/petrinaut-website/vite.config.ts index be2c57d3852..96fcef1331e 100644 --- a/apps/petrinaut-website/vite.config.ts +++ b/apps/petrinaut-website/vite.config.ts @@ -1,9 +1,59 @@ +import { fileURLToPath } from "node:url"; + import babel from "@rolldown/plugin-babel"; import react, { reactCompilerPreset } from "@vitejs/plugin-react"; -import { defineConfig } from "vite"; +import { createServerAdapter } from "@whatwg-node/server"; +import { defineConfig, loadEnv, type Plugin } from "vite"; + +import type { IncomingMessage, ServerResponse } from "node:http"; + +const appRoot = fileURLToPath(new URL(".", import.meta.url)); + +const loadServerEnv = (mode: string) => { + const env = loadEnv(mode, appRoot, ""); + + for (const [key, value] of Object.entries(env)) { + if (process.env[key] === undefined) { + process.env[key] = value; + } + } +}; + +// Plugin required to serve the chat endpoint in dev. +// In production, it will be bundled and served by Vercel. +const petrinautApiDevPlugin = (): Plugin => ({ + name: "petrinaut-api-dev", + apply: "serve", + configureServer(server) { + // The chat endpoint ships a default `{ fetch }` so Vercel's Node.js + // runtime treats it as a Web fetch handler in production. We mirror the + // same shape here so dev and prod hit the same code path. + const adapter = createServerAdapter(async (request) => { + const { default: api } = (await server.ssrLoadModule("/api/chat.ts")) as { + default: { fetch: (request: Request) => Promise }; + }; + + try { + return await api.fetch(request); + } catch (error) { + server.ssrFixStacktrace(error as Error); + throw error; + } + }); + + server.middlewares.use( + "/api/chat", + (request: IncomingMessage, response: ServerResponse) => { + void adapter(request, response); + }, + ); + }, +}); /** Petrinaut website dev server and production build config. */ -export default defineConfig(() => { +export default defineConfig(({ mode }) => { + loadServerEnv(mode); + const environment = process.env.VITE_VERCEL_ENV ?? "development"; const sentryDsn: string | undefined = process.env.SENTRY_DSN; @@ -18,7 +68,13 @@ export default defineConfig(() => { cssMinify: "esbuild" as const, }, + preview: { + /** vercel dev will provide a PORT to run on */ + port: process.env.PORT ? Number(process.env.PORT) : 4173, + }, + plugins: [ + petrinautApiDevPlugin(), react(), babel({ presets: [ @@ -30,7 +86,6 @@ export default defineConfig(() => { // "Cannot access refs during render". Opt that package out. sources: (filename: string) => !filename.includes("@hashintel/ds-components"), - // @ts-expect-error - panicThreshold is accepted at runtime panicThreshold: "critical_errors", }), ], diff --git a/libs/@hashintel/ds-components/package.json b/libs/@hashintel/ds-components/package.json index 5f30a37b4b3..9566e000bf4 100644 --- a/libs/@hashintel/ds-components/package.json +++ b/libs/@hashintel/ds-components/package.json @@ -115,7 +115,7 @@ "vite-plugin-svgr": "5.2.0", "vitest": "^4.0.16", "vitest-browser-react": "^2.0.2", - "zod": "4.1.12" + "zod": "4.4.3" }, "peerDependencies": { "@ark-ui/react": "^5.26.2", diff --git a/libs/@hashintel/ds-components/src/components/Icon/icon.tsx b/libs/@hashintel/ds-components/src/components/Icon/icon.tsx index 09d35e08097..0a57e06a266 100644 --- a/libs/@hashintel/ds-components/src/components/Icon/icon.tsx +++ b/libs/@hashintel/ds-components/src/components/Icon/icon.tsx @@ -9,6 +9,7 @@ import ArrowDownWideShort from "./svgs/regular/arrow-down-wide-short.svg"; import ArrowDown from "./svgs/regular/arrow-down.svg"; import ArrowLeft from "./svgs/regular/arrow-left.svg"; import ArrowPointer from "./svgs/regular/arrow-pointer.svg"; +import ArrowRightArrowLeft from "./svgs/regular/arrow-right-arrow-left.svg"; import ArrowRightToLine from "./svgs/regular/arrow-right-to-line.svg"; import ArrowRight from "./svgs/regular/arrow-right.svg"; import ArrowRotateLeft from "./svgs/regular/arrow-rotate-left.svg"; @@ -128,6 +129,7 @@ const IconMap = { arrowRight: ArrowRight, arrowUp: ArrowUp, arrowUpRight: ArrowUpRight, + arrowsLeftRight: ArrowRightArrowLeft, asterisk: Asterisk, at: At, barcode: Barcode, diff --git a/libs/@hashintel/ds-components/src/components/Icon/svgs/regular/arrow-right-arrow-left.svg b/libs/@hashintel/ds-components/src/components/Icon/svgs/regular/arrow-right-arrow-left.svg new file mode 100644 index 00000000000..e04f708f8d5 --- /dev/null +++ b/libs/@hashintel/ds-components/src/components/Icon/svgs/regular/arrow-right-arrow-left.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/libs/@hashintel/ds-components/src/preset.ts b/libs/@hashintel/ds-components/src/preset.ts index aef6f5c6e61..73dd3282168 100644 --- a/libs/@hashintel/ds-components/src/preset.ts +++ b/libs/@hashintel/ds-components/src/preset.ts @@ -132,6 +132,14 @@ export function createPreset(options?: PresetOptions) { from: { height: "var(--height)" }, to: { height: "0" }, }, + pulse: { + "0%, 100%": { opacity: "0.25" }, + "50%": { opacity: "1" }, + }, + shimmer: { + from: { backgroundPosition: "200% 0" }, + to: { backgroundPosition: "-200% 0" }, + }, }, textStyles: { xxs: { diff --git a/libs/@hashintel/petrinaut-core/package.json b/libs/@hashintel/petrinaut-core/package.json index b2f1e4b5c7c..72444215926 100644 --- a/libs/@hashintel/petrinaut-core/package.json +++ b/libs/@hashintel/petrinaut-core/package.json @@ -57,10 +57,11 @@ }, "dependencies": { "@babel/standalone": "7.28.5", + "elkjs": "0.11.0", "immer": "10.1.3", "uuid": "14.0.0", "vscode-languageserver-types": "3.17.5", - "zod": "4.1.12" + "zod": "4.4.3" }, "devDependencies": { "@types/babel__standalone": "7.1.9", diff --git a/libs/@hashintel/petrinaut-core/src/action-schemas.ts b/libs/@hashintel/petrinaut-core/src/action-schemas.ts index d2417d0f505..6a940617822 100644 --- a/libs/@hashintel/petrinaut-core/src/action-schemas.ts +++ b/libs/@hashintel/petrinaut-core/src/action-schemas.ts @@ -278,8 +278,12 @@ export const mutationActionInputSchemas = { .strictObject({ parameterId: idSchema }) .meta({ description: "Remove a net-level parameter." }), addScenario: simulationScenarioSchema.meta({ - description: - "Add a simulation scenario. Include scenarioParameters for key user-tunable assumptions, parameterOverrides keyed by existing net-level parameter IDs, and initialState with per-place content keyed by existing place IDs unless advanced code is required. Omit parameterOverrides or use {} when no net-level parameters need overriding.", + description: [ + "Add a simulation scenario.", + "Include `scenarioParameters` for key user-tunable assumptions (reference them in expressions as `scenario.`).", + "`parameterOverrides` keys MUST be existing net-level parameter IDs; omit the field entirely when nothing is overridden.", + "`initialState.content` keys are place IDs when `type` is `per_place`, but place NAMES when `type` is `code` (note the asymmetry).", + ].join(" "), }), updateScenario: z .strictObject({ @@ -291,7 +295,8 @@ export const mutationActionInputSchemas = { .strictObject({ scenarioId: idSchema }) .meta({ description: "Remove a simulation scenario." }), addMetric: simulationMetricSchema.meta({ - description: "Add a simulation metric.", + description: + "Add a simulation metric (a time-series scalar plotted in the simulate view). Note: metric `code` is a plain function body with `state` in scope — do NOT wrap it in `export default Metric(...)` or any other module syntax.", }), updateMetric: z .strictObject({ diff --git a/libs/@hashintel/petrinaut-core/src/actions.test.ts b/libs/@hashintel/petrinaut-core/src/actions.test.ts index eb401f559ce..21f71f2651d 100644 --- a/libs/@hashintel/petrinaut-core/src/actions.test.ts +++ b/libs/@hashintel/petrinaut-core/src/actions.test.ts @@ -39,7 +39,7 @@ describe("Petrinaut core actions", () => { test("adds and updates places", () => { const instance = createInstance(); - instance.addPlace({ + instance.mutations.addPlace({ id: "place-1", name: "Queue", colorId: null, @@ -48,13 +48,13 @@ describe("Petrinaut core actions", () => { x: 0, y: 0, }); - instance.updatePlace({ + instance.mutations.updatePlace({ placeId: "place-1", update: { name: "UpdatedQueue", }, }); - instance.updatePlacePosition({ + instance.mutations.updatePlacePosition({ placeId: "place-1", position: { x: 12, y: 24 }, }); @@ -110,7 +110,7 @@ describe("Petrinaut core actions", () => { ], }); - instance.removePlace({ placeId: "place-1" }); + instance.mutations.removePlace({ placeId: "place-1" }); const definition = instance.definition.get(); expect(definition.places.map((place) => place.id)).toEqual(["place-2"]); @@ -138,13 +138,13 @@ describe("Petrinaut core actions", () => { ], }); - instance.updateArcPlace({ + instance.mutations.updateArcPlace({ transitionId: "transition-1", arcDirection: "input", oldPlaceId: "place-1", newPlaceId: "place-3", }); - instance.updateArcPlace({ + instance.mutations.updateArcPlace({ transitionId: "transition-1", arcDirection: "output", oldPlaceId: "place-2", @@ -174,21 +174,21 @@ describe("Petrinaut core actions", () => { ], }); - instance.addTypeElement({ + instance.mutations.addTypeElement({ typeId: "type-1", element: { elementId: "element-3", name: "Charge", type: "integer" }, }); - instance.updateTypeElement({ + instance.mutations.updateTypeElement({ typeId: "type-1", elementId: "element-1", update: { name: "MassKg" }, }); - instance.moveTypeElement({ + instance.mutations.moveTypeElement({ typeId: "type-1", elementId: "element-3", toIndex: 1, }); - instance.removeTypeElement({ + instance.mutations.removeTypeElement({ typeId: "type-1", elementId: "element-2", }); @@ -232,7 +232,7 @@ describe("Petrinaut core actions", () => { ], }); - instance.deleteItemsByIds({ + instance.mutations.deleteItemsByIds({ items: [ { type: "type", id: "type-1" }, { type: "differentialEquation", id: "equation-1" }, @@ -252,7 +252,7 @@ describe("Petrinaut core actions", () => { readonly: true, }); - instance.addPlace({ + instance.mutations.addPlace({ id: "place-1", name: "Queue", colorId: null, @@ -269,7 +269,7 @@ describe("Petrinaut core actions", () => { const instance = createInstance(); expect(() => - instance.addPlace({ + instance.mutations.addPlace({ id: "", name: "Queue", colorId: null, @@ -286,7 +286,7 @@ describe("Petrinaut core actions", () => { test("validates callback-updated entities", () => { const instance = createInstance(); - instance.addPlace({ + instance.mutations.addPlace({ id: "place-1", name: "Queue", colorId: null, @@ -297,7 +297,7 @@ describe("Petrinaut core actions", () => { }); expect(() => - instance.updatePlace({ + instance.mutations.updatePlace({ placeId: "place-1", update: { name: "", @@ -310,62 +310,65 @@ describe("Petrinaut core actions", () => { const instance = createInstance(); expect(() => - callActionWithUnknownInput(instance.updatePlace, { + callActionWithUnknownInput(instance.mutations.updatePlace, { placeId: "place-1", update: { id: "place-2" }, }), ).toThrow(); expect(() => - callActionWithUnknownInput(instance.updatePlace, { + callActionWithUnknownInput(instance.mutations.updatePlace, { placeId: "place-1", update: { x: 10 }, }), ).toThrow(); expect(() => - callActionWithUnknownInput(instance.updateTransition, { + callActionWithUnknownInput(instance.mutations.updateTransition, { transitionId: "transition-1", update: { inputArcs: [] }, }), ).toThrow(); expect(() => - callActionWithUnknownInput(instance.updateTransition, { + callActionWithUnknownInput(instance.mutations.updateTransition, { transitionId: "transition-1", update: { y: 10 }, }), ).toThrow(); expect(() => - callActionWithUnknownInput(instance.updateType, { + callActionWithUnknownInput(instance.mutations.updateType, { typeId: "type-1", update: { elements: [] }, }), ).toThrow(); expect(() => - callActionWithUnknownInput(instance.updateTypeElement, { + callActionWithUnknownInput(instance.mutations.updateTypeElement, { typeId: "type-1", elementId: "element-1", update: { elementId: "element-2" }, }), ).toThrow(); expect(() => - callActionWithUnknownInput(instance.updateDifferentialEquation, { - equationId: "equation-1", - update: { id: "equation-2" }, - }), + callActionWithUnknownInput( + instance.mutations.updateDifferentialEquation, + { + equationId: "equation-1", + update: { id: "equation-2" }, + }, + ), ).toThrow(); expect(() => - callActionWithUnknownInput(instance.updateParameter, { + callActionWithUnknownInput(instance.mutations.updateParameter, { parameterId: "parameter-1", update: { id: "parameter-2" }, }), ).toThrow(); expect(() => - callActionWithUnknownInput(instance.updateScenario, { + callActionWithUnknownInput(instance.mutations.updateScenario, { scenarioId: "scenario-1", update: { id: "scenario-2" }, }), ).toThrow(); expect(() => - callActionWithUnknownInput(instance.updateMetric, { + callActionWithUnknownInput(instance.mutations.updateMetric, { metricId: "metric-1", update: { id: "metric-2" }, }), @@ -400,7 +403,7 @@ describe("Petrinaut core actions", () => { }); expect(() => - instance.updateArcPlace({ + instance.mutations.updateArcPlace({ transitionId: "transition-1", arcDirection: "input", oldPlaceId: "place-1", @@ -408,13 +411,13 @@ describe("Petrinaut core actions", () => { }), ).toThrow(); expect(() => - instance.addTypeElement({ + instance.mutations.addTypeElement({ typeId: "type-1", element: { elementId: "element-2", name: "", type: "real" }, }), ).toThrow(); expect(() => - instance.moveTypeElement({ + instance.mutations.moveTypeElement({ typeId: "type-1", elementId: "element-1", toIndex: -1, @@ -433,7 +436,7 @@ describe("Petrinaut core actions", () => { const instance = createInstance(); expect(() => - instance.addPlace({ + instance.mutations.addPlace({ id: "place-1", name: "invalid place name", colorId: null, @@ -445,7 +448,7 @@ describe("Petrinaut core actions", () => { ).toThrow(); expect(() => - instance.addTransition({ + instance.mutations.addTransition({ id: "transition-1", name: "Display Name", inputArcs: [], @@ -463,7 +466,7 @@ describe("Petrinaut core actions", () => { const instance = createInstance(); expect(() => - instance.addScenario({ + instance.mutations.addScenario({ id: "scenario-1", name: "Scenario", scenarioParameters: [ @@ -476,7 +479,7 @@ describe("Petrinaut core actions", () => { ).toThrow(); expect(() => - instance.addScenario({ + instance.mutations.addScenario({ id: "scenario-1", name: "Scenario", scenarioParameters: [ diff --git a/libs/@hashintel/petrinaut-core/src/actions.ts b/libs/@hashintel/petrinaut-core/src/actions.ts index 915aca4a330..783fc4a448c 100644 --- a/libs/@hashintel/petrinaut-core/src/actions.ts +++ b/libs/@hashintel/petrinaut-core/src/actions.ts @@ -19,6 +19,43 @@ export type MutationHelperFunctions = { ) => void; }; +/** + * Validate that a single place's reference to a differential equation is + * consistent: the equation must exist, the place must have a colour, and the + * equation's `colorId` must match the place's `colorId`. Throws a descriptive + * error when the invariant is violated; otherwise no-ops. + * + * Mirrors the runtime invariant enforced by + * `core/simulation/engine/build-simulation.ts`, but raises at mutation time so + * AI callers see the failure immediately instead of at simulation build. + */ +function assertPlaceDynamicsReferences( + place: SDCPN["places"][number], + equations: SDCPN["differentialEquations"], +): void { + if (place.differentialEquationId === null) { + return; + } + const equation = equations.find( + (eq) => eq.id === place.differentialEquationId, + ); + if (!equation) { + throw new Error( + `Place \`${place.name}\` references differential equation ID \`${place.differentialEquationId}\` which does not exist.`, + ); + } + if (place.colorId === null) { + throw new Error( + `Place \`${place.name}\` has a differential equation but no \`colorId\`. Set the place's \`colorId\` to match the equation's \`colorId\` (\`${String(equation.colorId)}\`).`, + ); + } + if (equation.colorId !== null && equation.colorId !== place.colorId) { + throw new Error( + `Place \`${place.name}\` (colorId \`${place.colorId}\`) references differential equation \`${equation.name}\` (colorId \`${equation.colorId}\`); the equation's \`colorId\` must match the place's \`colorId\`.`, + ); + } +} + export function createPetrinautActions( mutate: (fn: (sdcpn: SDCPN) => void) => void, ): MutationHelperFunctions { @@ -26,6 +63,7 @@ export function createPetrinautActions( addPlace(place) { const parsedPlace = placeSchema.parse(place); mutate((sdcpn) => { + assertPlaceDynamicsReferences(parsedPlace, sdcpn.differentialEquations); sdcpn.places.push(parsedPlace); }); }, @@ -36,6 +74,7 @@ export function createPetrinautActions( if (place.id === parsed.placeId) { Object.assign(place, parsed.update); placeSchema.parse(place); + assertPlaceDynamicsReferences(place, sdcpn.differentialEquations); break; } } @@ -327,6 +366,11 @@ export function createPetrinautActions( const parsedEquation = differentialEquationSchema.parse(equation); mutate((sdcpn) => { sdcpn.differentialEquations.push(parsedEquation); + for (const place of sdcpn.places) { + if (place.differentialEquationId === parsedEquation.id) { + assertPlaceDynamicsReferences(place, sdcpn.differentialEquations); + } + } }); }, updateDifferentialEquation(input) { @@ -337,6 +381,14 @@ export function createPetrinautActions( if (equation.id === parsed.equationId) { Object.assign(equation, parsed.update); differentialEquationSchema.parse(equation); + for (const place of sdcpn.places) { + if (place.differentialEquationId === equation.id) { + assertPlaceDynamicsReferences( + place, + sdcpn.differentialEquations, + ); + } + } break; } } diff --git a/libs/@hashintel/petrinaut-core/src/ai.test.ts b/libs/@hashintel/petrinaut-core/src/ai.test.ts index 881e3669f52..4f18870df0c 100644 --- a/libs/@hashintel/petrinaut-core/src/ai.test.ts +++ b/libs/@hashintel/petrinaut-core/src/ai.test.ts @@ -1,13 +1,27 @@ import { describe, expect, test } from "vitest"; import { - createPetrinautMutationAiToolCallbacks, + aiCommandActionInputSchemas, + createPetrinautAiWritableCallbacks, petrinautAiToolInputSchemas, petrinautAiTools, } from "./ai"; import { createJsonDocHandle } from "./handle"; import { createPetrinaut } from "./instance"; +const createInstance = () => + createPetrinaut({ + document: createJsonDocHandle({ + initial: { + places: [], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], + }, + }), + }); + describe("Petrinaut AI core exports", () => { test("tool metadata stays aligned with input schemas and has no execute", () => { expect(Object.keys(petrinautAiTools).sort()).toEqual( @@ -22,19 +36,16 @@ describe("Petrinaut AI core exports", () => { } }); + test("AI command schemas are exposed as tools", () => { + for (const name of Object.keys(aiCommandActionInputSchemas)) { + expect(petrinautAiTools).toHaveProperty(name); + } + expect(petrinautAiTools).toHaveProperty("applyAutoLayout"); + }); + test("callback map applies tool inputs to a Petrinaut instance", () => { - const instance = createPetrinaut({ - document: createJsonDocHandle({ - initial: { - places: [], - transitions: [], - types: [], - differentialEquations: [], - parameters: [], - }, - }), - }); - const callbacks = createPetrinautMutationAiToolCallbacks(instance); + const instance = createInstance(); + const callbacks = createPetrinautAiWritableCallbacks(instance); callbacks.addPlace({ id: "place-1", @@ -54,18 +65,8 @@ describe("Petrinaut AI core exports", () => { }); test("callback map validates tool inputs before applying them", () => { - const instance = createPetrinaut({ - document: createJsonDocHandle({ - initial: { - places: [], - transitions: [], - types: [], - differentialEquations: [], - parameters: [], - }, - }), - }); - const callbacks = createPetrinautMutationAiToolCallbacks(instance); + const instance = createInstance(); + const callbacks = createPetrinautAiWritableCallbacks(instance); expect(() => callbacks.addPlace({ @@ -81,4 +82,13 @@ describe("Petrinaut AI core exports", () => { expect(instance.definition.get().places).toEqual([]); }); + + test("AI writable callbacks include applyAutoLayout from commands", async () => { + const instance = createInstance(); + const callbacks = createPetrinautAiWritableCallbacks(instance); + + expect(typeof callbacks.applyAutoLayout).toBe("function"); + const result = await callbacks.applyAutoLayout(); + expect(result.commitCount).toBe(0); + }); }); diff --git a/libs/@hashintel/petrinaut-core/src/ai.ts b/libs/@hashintel/petrinaut-core/src/ai.ts index 2e8731fbdd9..a359febdc7a 100644 --- a/libs/@hashintel/petrinaut-core/src/ai.ts +++ b/libs/@hashintel/petrinaut-core/src/ai.ts @@ -4,7 +4,11 @@ import { mutationActionInputSchemas, type MutationActionName, } from "./action-schemas"; -import { probabilisticSatellitesSDCPN } from "./examples/satellites-launcher"; +import { + aiCommandActionInputSchemas, + type AiCommandActionName, +} from "./command-schemas"; +import { probabilisticSatellitesSDCPN } from "./examples"; import { typedKeys } from "./lib/typed-entries"; import type { Petrinaut } from "./instance"; @@ -23,6 +27,11 @@ export type { MutationActionInput as PetrinautAiMutationToolInput, MutationActionName as PetrinautAiMutationToolName, } from "./action-schemas"; +export { aiCommandActionInputSchemas } from "./command-schemas"; +export type { + AiCommandActionInput as PetrinautAiCommandToolInput, + AiCommandActionName as PetrinautAiCommandToolName, +} from "./command-schemas"; export type PetrinautAiTool = { description: string; @@ -69,26 +78,63 @@ function createToolBundle>( } export const getLatestNetDefinitionToolName = "getLatestNetDefinition"; +export const getNetCompilationErrorsToolName = "getNetCompilationErrors"; +export const setNetTitleToolName = "setNetTitle"; const getLatestNetDefinitionToolInputSchema = z .strictObject({}) - .describe("Get the latest complete Petrinaut SDCPN net definition."); + .describe( + "Get the current Petrinaut net state. Returns `{ title, definition }` where `title` is the user-visible net title and `definition` is the complete SDCPN net definition.", + ); + +const getNetCompilationErrorsToolInputSchema = z + .strictObject({}) + .describe( + "Get the current TypeScript diagnostics for the Petrinaut net code. Use this after the net to check whether the model compiles.", + ); + +export const setNetTitleToolInputSchema = z + .strictObject({ + title: z.string().min(1).max(120).meta({ + description: + "Short human-readable title for the net (sentence case, no quotes, ideally under ~60 characters).", + }), + }) + .describe( + "Set the human-readable title shown for the current Petrinaut net.", + ); export const petrinautAiToolInputSchemas = { ...mutationActionInputSchemas, + ...aiCommandActionInputSchemas, [getLatestNetDefinitionToolName]: getLatestNetDefinitionToolInputSchema, + [getNetCompilationErrorsToolName]: getNetCompilationErrorsToolInputSchema, + [setNetTitleToolName]: setNetTitleToolInputSchema, }; export const petrinautAiMutationTools = createToolBundle( mutationActionInputSchemas, ); +export const petrinautAiCommandTools = createToolBundle( + aiCommandActionInputSchemas, +); + export const petrinautAiTools = { ...petrinautAiMutationTools, + ...petrinautAiCommandTools, [getLatestNetDefinitionToolName]: { description: getSchemaDescription(getLatestNetDefinitionToolInputSchema), inputSchema: getLatestNetDefinitionToolInputSchema, }, + [getNetCompilationErrorsToolName]: { + description: getSchemaDescription(getNetCompilationErrorsToolInputSchema), + inputSchema: getNetCompilationErrorsToolInputSchema, + }, + [setNetTitleToolName]: { + description: getSchemaDescription(setNetTitleToolInputSchema), + inputSchema: setNetTitleToolInputSchema, + }, } satisfies PetrinautAiTools; export type PetrinautAiToolName = keyof typeof petrinautAiTools; @@ -97,23 +143,46 @@ export type PetrinautAiToolInput = z.input< (typeof petrinautAiTools)[Name]["inputSchema"] >; -export type PetrinautMutationAiToolCallbacks = Pick< - Petrinaut, +/** + * Writable tool callbacks exposed to the AI: every mutation, plus the subset + * of commands registered in {@link aiCommandActionInputSchemas}. Read-only + * tools (e.g. `getLatestNetDefinition`) are handled by the dispatcher + * separately and are not part of this bundle. + */ +export type PetrinautAiWritableCallbacks = Pick< + Petrinaut["mutations"], MutationActionName ->; +> & + Pick; -export function createPetrinautMutationAiToolCallbacks( +export function createPetrinautAiWritableCallbacks( instance: Petrinaut, -): PetrinautMutationAiToolCallbacks { - return instance; +): PetrinautAiWritableCallbacks { + const writable: PetrinautAiWritableCallbacks = { + ...instance.mutations, + } as PetrinautAiWritableCallbacks; + for (const name of typedKeys(aiCommandActionInputSchemas)) { + (writable as Record)[name] = instance.commands[name]; + } + return writable; } export const petrinautAiPrompt = `You are an expert assistant for building Stochastic Dynamic Coloured Petri Nets (SDCPNs) in Petrinaut. Use the provided tools to directly modify the current net. The tools use Petrinaut's raw mutation interfaces, so include stable IDs, full entity objects where required, and canvas positions for places and transitions. -You can check the latest complete net definition at any point using the ${getLatestNetDefinitionToolName} tool. Use it before making changes that depend on existing places, transitions, arcs, scenarios, metrics, parameters, or types. +You can check the current net state at any point using the ${getLatestNetDefinitionToolName} tool, which returns \`{ title, definition }\` — the user-visible net title plus the complete SDCPN. Use it before making changes that depend on existing places, transitions, arcs, scenarios, metrics, parameters, or types, and consult the \`title\` when deciding whether the net could use a more descriptive name. +You can check current TypeScript compilation diagnostics at any point using the ${getNetCompilationErrorsToolName} tool. +You can rename the net at any point using the ${setNetTitleToolName} tool. + +Interview first, build second. Before creating a new net (or adding a substantial new subsystem to an existing one), do NOT jump straight to tool calls. Run a brief, focused interview to establish: + +1. Process structure & timing — the key states/places, the events/transitions between them, capacity or routing constraints, and the typical rates/durations (e.g. arrival rate, mean service time, lifetime, retry interval). Flag where stochastic vs. predicate vs. continuous dynamics seem to fit. +2. Observables & metrics — what the user wants to measure once the model runs (throughput, utilisation, latency, queue length, conversion rate, stockouts, infection fraction, …). Each becomes a \`metric\`. +3. Scenarios — the what-if conditions they want to compare (baseline vs. surge, policy A vs. B, parameter sweeps). Each becomes a \`scenario\`, ideally driven by scenario parameters so they can be tweaked between runs. + +Keep it tight: ask 2–4 grouped questions per turn, not a long form. Restate what you already understand so the user only has to fill gaps. If the request is already concrete and well-scoped (e.g. "fix this lambda", "add an arc from X to Y", "rename this place"), skip the interview and act. -When the user's intent, requirements, constraints, or preferred modelling process are ambiguous, ask a concise follow-up question before making changes. If the request is clear, proceed with small, purposeful tool calls. +Escape hatch. Every time you ask questions, explicitly tell the user they can say "make it up", "use sensible defaults", or similar, and you will pick plausible values (with a one-line justification for each major choice) and proceed. Do the same automatically if they reply tersely, with "you decide", or otherwise signal they don't want to specify details. When creating or revising a net: - Prefer small, meaningful mutations rather than replacing unrelated content. @@ -124,7 +193,25 @@ When creating or revising a net: - Use predicate transition lambdas for boolean firing conditions. - Use transition kernels to transform or generate coloured tokens, including stochastic distributions. - Use differential equations only for places whose coloured tokens have continuous dynamics. +- Suggest place visualisations. Once the structure is agreed, proactively propose 1–2 vivid, domain-specific \`visualizerCode\` ideas (e.g. a queue as a stacked bar, satellites as orbit dots, infected population as a heat-dot grid, machines as a row of state-coloured rectangles, inventory as a shelf of boxes) and offer to add them. Default to compact, single-glance SVGs sized for a place node, following the visualizer rules in the code-surface cheatsheet below. - Keep executable code self-contained and readable. +- Title the net. After building or substantially extending a model, check the title returned by \`${getLatestNetDefinitionToolName}\`. If it is \`Untitled\` or an obvious placeholder, call \`${setNetTitleToolName}\` with a concise, descriptive title (sentence case, ideally under ~60 characters). Don't overwrite a user-chosen title without being asked. + +Validate every code-writing change. After any tool call that writes code — lambda, transition kernel, dynamics, visualizer, metric, or scenario code-mode initial state — call ${getNetCompilationErrorsToolName} before continuing and fix any reported diagnostics before relying on the new code. Do not assume a code edit is correct just because the tool call succeeded; mutations only validate the schema, not the runtime contract. + +Place names are part of the code surface: lambdas/kernels read \`input.PlaceName\`, metrics read \`state.places.PlaceName.count\`, and scenario code-mode initial state keys are place names. Renaming a place via \`updatePlace\` requires updating every dependent lambda, kernel, dynamics, metric, visualizer, and scenario in the same batch — otherwise you will silently break references. + +Code-surface cheatsheet (exact shapes expected by the runtime): +- Transition lambda (\`transition.lambdaCode\`): \`export default Lambda((input, parameters) => …)\`. \`input.PlaceName\` is a tuple sized to the input arc weight; tokens are \`{ : number }\`. Inhibitor arcs and uncoloured input places are NOT in \`input\`. Predicate → boolean; stochastic → non-negative finite rate in firings per simulation second (0 disables, Infinity always fires). Must be deterministic. +- Transition kernel (\`transition.transitionKernelCode\`): \`export default TransitionKernel((input, parameters) => …)\`. Return \`{ OutputPlaceName: [token, …] }\` sized to the output arc weight. Include only coloured output places; uncoloured output places are auto-populated. Use \`Distribution.Gaussian(mean, sd)\` / \`Distribution.Uniform(min, max)\` / \`Distribution.Lognormal(mu, sigma)\` for stochastic attributes; chained \`.map(fn)\` on the same distribution shares one draw. Always required (use \`() => ({})\` when no coloured outputs). +- Differential equation (\`differentialEquation.code\`): \`export default Dynamics((tokens, parameters) => …)\`. \`tokens\` is THIS place's tokens only. Return an array of the same length whose entries are \`{ : derivative }\` (i.e. dx/dt, not the new value). The equation's \`colorId\` MUST match every referencing place's \`colorId\`. +- Place visualizer (\`place.visualizerCode\`): \`export default Visualization(({ tokens, parameters }) => )\`. Classic React runtime — do NOT import React, do NOT use \`<>…\` fragments, do NOT use hooks. Convention: return a sized \`\`. +- Metric (\`metric.code\`): a plain function body — NOT a module, no \`export default\`, no wrapper. The only variable in scope is \`state\`. Must \`return\` a finite number. Example: \`return state.places.Infected.count / (state.places.Susceptible.count + state.places.Infected.count + state.places.Recovered.count);\`. \`parameters\` and \`scenario\` are NOT available inside metrics. +- Scenario per_place initial state: \`content\` keys are place IDs; uncoloured values are expressions with \`parameters\` and \`scenario\` in scope; coloured values are \`number[][]\` rows in colour element order. +- Scenario code-mode initial state: function body returning \`{ PlaceName: tokens }\` keyed by NAME (asymmetric with per_place IDs); unknown names are silently dropped. +- Parameter access in any code surface: use \`parameters.\` where \`\` is the parameter's lower_snake_case \`variableName\` value (e.g. \`parameters.crash_threshold\`, never \`parameters.crashThreshold\`). + +Auto-layout policy. Once you've finished adding or restructuring places and transitions, call \`applyAutoLayout\` so the canvas isn't littered with overlapping nodes at the origin. Pass \`askUserFirst: false\` ONLY when the net was empty at the start of the conversation and you built it from scratch. If user-arranged content existed beforehand — even if you only added a few nodes to it — pass \`askUserFirst: true\` and the user will be shown a Yes/No prompt. If they decline, leave the layout alone and continue without retrying unless they ask. After calling tools, do not merely summarize the added or updated items, because the user can already see those changes in the UI. Final text should add extra value: explain important modelling choices, assumptions, how the pieces work together, and useful next checks or questions. diff --git a/libs/@hashintel/petrinaut-core/src/command-schemas.ts b/libs/@hashintel/petrinaut-core/src/command-schemas.ts new file mode 100644 index 00000000000..4cb409ce22e --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/command-schemas.ts @@ -0,0 +1,52 @@ +import { z } from "zod"; + +import { clipboardPayloadSchema } from "./clipboard/types"; + +/** + * AI-callable commands. Keys MUST also appear in + * {@link commandActionInputSchemas}. A command must be added here (with a + * full `meta({ description })`) to be exposed to the AI tool bundle. + */ +export const aiCommandActionInputSchemas = { + applyAutoLayout: z + .strictObject({ + askUserFirst: z.boolean().meta({ + description: [ + "Pass `true` to confirm with the user via a Yes/No prompt before applying.", + "Pass `false` to apply immediately.", + "Use `false` ONLY when you just built this net from scratch in the current conversation (no user-arranged content existed beforehand).", + "Otherwise pass `true` so the user can decline — auto-layout will reposition every node.", + ].join(" "), + }), + }) + .meta({ + description: [ + "Reposition every place and transition using an ELK layered layout.", + "Use immediately after creating a net from scratch.", + "For nets that already contained user-positioned nodes, pass `askUserFirst: true`", + "so the user can confirm before running.", + ].join(" "), + }), +} as const; + +/** + * All commands the host can invoke on the instance. Includes AI-callable + * commands plus host-only ones (e.g. clipboard paste) that are intentionally + * absent from the AI tool surface. + */ +export const commandActionInputSchemas = { + ...aiCommandActionInputSchemas, + applyClipboardPaste: z.strictObject({ + payload: clipboardPayloadSchema, + }), +} as const; + +export type CommandActionName = keyof typeof commandActionInputSchemas; +export type AiCommandActionName = keyof typeof aiCommandActionInputSchemas; + +export type CommandActionInput = z.infer< + (typeof commandActionInputSchemas)[Name] +>; +export type AiCommandActionInput = z.infer< + (typeof aiCommandActionInputSchemas)[Name] +>; diff --git a/libs/@hashintel/petrinaut-core/src/commands.test.ts b/libs/@hashintel/petrinaut-core/src/commands.test.ts new file mode 100644 index 00000000000..af4659e08a0 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/commands.test.ts @@ -0,0 +1,150 @@ +import { describe, expect, test } from "vitest"; + +import { + CLIPBOARD_FORMAT_VERSION, + type ClipboardPayload, +} from "./clipboard/types"; +import { createJsonDocHandle } from "./handle"; +import { createPetrinaut } from "./instance"; + +import type { SDCPN } from "./types/sdcpn"; + +const emptySDCPN: SDCPN = { + places: [], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], +}; + +const createInstance = (initial: SDCPN = emptySDCPN) => + createPetrinaut({ + document: createJsonDocHandle({ + initial: JSON.parse(JSON.stringify(initial)) as SDCPN, + }), + }); + +const buildClipboardPayload = ( + data: Partial = {}, +): ClipboardPayload => ({ + format: "petrinaut-sdcpn", + version: CLIPBOARD_FORMAT_VERSION, + documentId: null, + data: { + places: [], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], + ...data, + }, +}); + +describe("applyClipboardPaste", () => { + test("returns new IDs for pasted places", () => { + const instance = createInstance(); + + const payload = buildClipboardPayload({ + places: [ + { + id: "place-1", + name: "Queue", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + ], + }); + + const { newItemIds } = instance.commands.applyClipboardPaste({ payload }); + const pastedPlace = newItemIds.find((item) => item.type === "place"); + + expect(pastedPlace).toBeDefined(); + expect(instance.definition.get().places).toHaveLength(1); + expect(instance.definition.get().places[0]!.id).toBe(pastedPlace!.id); + }); + + test("throws when the payload fails schema validation", () => { + const instance = createInstance(); + + expect(() => + instance.commands.applyClipboardPaste({ + payload: { + format: "not-petrinaut", + version: CLIPBOARD_FORMAT_VERSION, + documentId: null, + data: { + places: [], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], + }, + } as unknown as ClipboardPayload, + }), + ).toThrow(); + + expect(instance.definition.get().places).toEqual([]); + }); +}); + +describe("applyAutoLayout", () => { + test("no-ops for an empty net", async () => { + const instance = createInstance(); + + const { commitCount } = await instance.commands.applyAutoLayout(); + + expect(commitCount).toBe(0); + }); + + test("repositions places when they have non-zero deltas", async () => { + const instance = createInstance({ + ...emptySDCPN, + places: [ + { + id: "place-1", + name: "Input", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + { + id: "place-2", + name: "Output", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + ], + transitions: [ + { + id: "transition-1", + name: "Move", + inputArcs: [{ placeId: "place-1", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "place-2", weight: 1 }], + lambdaType: "predicate", + lambdaCode: "export default Lambda(() => true);", + transitionKernelCode: "", + x: 0, + y: 0, + }, + ], + }); + + const { commitCount } = await instance.commands.applyAutoLayout(); + + expect(commitCount).toBeGreaterThan(0); + const places = instance.definition.get().places; + expect(places.map((place) => place.id).sort()).toEqual([ + "place-1", + "place-2", + ]); + expect(places.some((place) => place.x !== 0 || place.y !== 0)).toBe(true); + }); +}); diff --git a/libs/@hashintel/petrinaut-core/src/commands.ts b/libs/@hashintel/petrinaut-core/src/commands.ts new file mode 100644 index 00000000000..3a698612a5f --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/commands.ts @@ -0,0 +1,163 @@ +import { + colorSchema, + differentialEquationSchema, + parameterSchema, + placeSchema, + transitionSchema, +} from "./action-schemas"; +import { pastePayloadIntoSDCPN } from "./clipboard/paste"; +import { commandActionInputSchemas } from "./command-schemas"; +import { calculateGraphLayout } from "./layout/calculate-graph-layout"; +import { layoutNodeDimensions } from "./layout/dimensions"; + +import type { ClipboardPayload } from "./clipboard/types"; +import type { SDCPN } from "./types/sdcpn"; + +export type ApplyClipboardPasteResult = { + newItemIds: Array<{ type: string; id: string }>; +}; + +export type ApplyAutoLayoutResult = { + /** Number of place/transition positions actually changed by the run. */ + commitCount: number; +}; + +/** + * Composite operations the host can invoke. These wrap multiple atomic + * mutations in a single `mutate(...)` so they produce one Automerge change + * (and one undo entry). + * + * Commands are intentionally NOT part of `instance.mutations` — the AI tool + * bundle is derived from `mutationActionInputSchemas` and never auto-exposes + * commands. AI-callable commands must be added explicitly to + * `aiCommandActionInputSchemas` and routed through the AI dispatcher. + */ +export type CommandHelperFunctions = { + /** + * Paste a clipboard payload into the document, generating fresh IDs for + * each item and deduplicating names. Returns the IDs of the newly created + * items so the caller can update selection. + */ + applyClipboardPaste: (input: { + payload: ClipboardPayload; + }) => ApplyClipboardPasteResult; + + /** + * Reposition every place and transition using the ELK layered layout. Runs + * unconditionally — the caller is responsible for confirming with the user + * when applicable (e.g. the AI dispatcher prompts via an interactive chat + * widget when `askUserFirst: true`). + */ + applyAutoLayout: () => Promise; +}; + +const validateNewlyPastedItems = ( + sdcpn: SDCPN, + newItemIds: Array<{ type: string; id: string }>, +): void => { + const idsByType = new Map>(); + for (const item of newItemIds) { + let bucket = idsByType.get(item.type); + if (!bucket) { + bucket = new Set(); + idsByType.set(item.type, bucket); + } + bucket.add(item.id); + } + + const placeIds = idsByType.get("place"); + if (placeIds) { + for (const place of sdcpn.places) { + if (placeIds.has(place.id)) { + placeSchema.parse(place); + } + } + } + + const transitionIds = idsByType.get("transition"); + if (transitionIds) { + for (const transition of sdcpn.transitions) { + if (transitionIds.has(transition.id)) { + transitionSchema.parse(transition); + } + } + } + + const typeIds = idsByType.get("type"); + if (typeIds) { + for (const type of sdcpn.types) { + if (typeIds.has(type.id)) { + colorSchema.parse(type); + } + } + } + + const equationIds = idsByType.get("differentialEquation"); + if (equationIds) { + for (const equation of sdcpn.differentialEquations) { + if (equationIds.has(equation.id)) { + differentialEquationSchema.parse(equation); + } + } + } + + const parameterIds = idsByType.get("parameter"); + if (parameterIds) { + for (const parameter of sdcpn.parameters) { + if (parameterIds.has(parameter.id)) { + parameterSchema.parse(parameter); + } + } + } +}; + +export function createPetrinautCommands( + mutate: (fn: (sdcpn: SDCPN) => void) => void, + read: () => SDCPN, +): CommandHelperFunctions { + return { + applyClipboardPaste(input) { + const { payload } = + commandActionInputSchemas.applyClipboardPaste.parse(input); + let newItemIds: Array<{ type: string; id: string }> = []; + mutate((sdcpn) => { + const result = pastePayloadIntoSDCPN(sdcpn, payload); + newItemIds = result.newItemIds; + validateNewlyPastedItems(sdcpn, newItemIds); + }); + return { newItemIds }; + }, + + async applyAutoLayout() { + const sdcpn = read(); + + if (sdcpn.places.length === 0 && sdcpn.transitions.length === 0) { + return { commitCount: 0 }; + } + + const positions = await calculateGraphLayout(sdcpn, layoutNodeDimensions); + + let commitCount = 0; + mutate((draft) => { + for (const place of draft.places) { + const next = positions[place.id]; + if (next && (place.x !== next.x || place.y !== next.y)) { + place.x = next.x; + place.y = next.y; + commitCount += 1; + } + } + for (const transition of draft.transitions) { + const next = positions[transition.id]; + if (next && (transition.x !== next.x || transition.y !== next.y)) { + transition.x = next.x; + transition.y = next.y; + commitCount += 1; + } + } + }); + + return { commitCount }; + }, + }; +} diff --git a/libs/@hashintel/petrinaut-core/src/handle.test.ts b/libs/@hashintel/petrinaut-core/src/handle.test.ts index fcba1f12921..24b5c4755c5 100644 --- a/libs/@hashintel/petrinaut-core/src/handle.test.ts +++ b/libs/@hashintel/petrinaut-core/src/handle.test.ts @@ -68,14 +68,12 @@ describe("createPetrinaut", () => { const seen: SDCPN[] = []; const off = instance.definition.subscribe((value) => seen.push(value)); - instance.mutate((draft) => { - draft.types.push({ - id: "t1", - name: "Color 1", - iconSlug: "circle", - displayColor: "#FF0000", - elements: [], - }); + instance.mutations.addType({ + id: "t1", + name: "Color 1", + iconSlug: "circle", + displayColor: "#FF0000", + elements: [], }); expect(instance.definition.get().types).toHaveLength(1); @@ -92,14 +90,12 @@ describe("createPetrinaut", () => { const seenPatches: number[] = []; instance.patches.subscribe((patches) => seenPatches.push(patches.length)); - instance.mutate((draft) => { - draft.types.push({ - id: "t1", - name: "Color 1", - iconSlug: "circle", - displayColor: "#FF0000", - elements: [], - }); + instance.mutations.addType({ + id: "t1", + name: "Color 1", + iconSlug: "circle", + displayColor: "#FF0000", + elements: [], }); expect(seenPatches.length).toBeGreaterThan(0); @@ -110,14 +106,12 @@ describe("createPetrinaut", () => { const handle = createJsonDocHandle({ initial: empty() }); const instance = createPetrinaut({ document: handle, readonly: true }); - instance.mutate((draft) => { - draft.types.push({ - id: "t1", - name: "Color 1", - iconSlug: "circle", - displayColor: "#FF0000", - elements: [], - }); + instance.mutations.addType({ + id: "t1", + name: "Color 1", + iconSlug: "circle", + displayColor: "#FF0000", + elements: [], }); expect(instance.definition.get().types).toHaveLength(0); diff --git a/libs/@hashintel/petrinaut-core/src/index.ts b/libs/@hashintel/petrinaut-core/src/index.ts index 3c633c68fd3..a01a81e0744 100644 --- a/libs/@hashintel/petrinaut-core/src/index.ts +++ b/libs/@hashintel/petrinaut-core/src/index.ts @@ -19,28 +19,63 @@ export { // --- Instance --- export { createPetrinaut } from "./instance"; -export type { CreatePetrinautConfig, EventStream, Petrinaut } from "./instance"; +export type { + CreatePetrinautConfig, + EventStream, + Petrinaut, + PetrinautCommands, + PetrinautMutations, +} from "./instance"; export { createPetrinautActions } from "./actions"; export type { MutationHelperFunctions } from "./actions"; +export { createPetrinautCommands } from "./commands"; +export type { + ApplyAutoLayoutResult, + ApplyClipboardPasteResult, + CommandHelperFunctions, +} from "./commands"; +export { + aiCommandActionInputSchemas, + commandActionInputSchemas, +} from "./command-schemas"; +export type { + AiCommandActionInput, + AiCommandActionName, + CommandActionInput, + CommandActionName, +} from "./command-schemas"; +export { mutationActionInputSchemas } from "./action-schemas"; +export { + calculateGraphLayout, + layoutNodeDimensions, + type LayoutDimensions, + type NodePosition, +} from "./layout"; // --- AI --- export { colorSchema, - createPetrinautMutationAiToolCallbacks, + createPetrinautAiWritableCallbacks, differentialEquationSchema, getLatestNetDefinitionToolName, + getNetCompilationErrorsToolName, metricSchema, parameterSchema, + petrinautAiCommandTools, petrinautAiMutationTools, petrinautAiPrompt, petrinautAiTools, placeSchema, scenarioSchema, + setNetTitleToolInputSchema, + setNetTitleToolName, transitionSchema, } from "./ai"; export type { + PetrinautAiCommandToolInput, + PetrinautAiCommandToolName, PetrinautAiTool, - PetrinautMutationAiToolCallbacks, + PetrinautAiWritableCallbacks, PetrinautAiToolInput, PetrinautAiMutationToolInput, PetrinautAiMutationToolName, diff --git a/libs/@hashintel/petrinaut-core/src/instance.ts b/libs/@hashintel/petrinaut-core/src/instance.ts index 2113feb5873..f4945d789d6 100644 --- a/libs/@hashintel/petrinaut-core/src/instance.ts +++ b/libs/@hashintel/petrinaut-core/src/instance.ts @@ -2,6 +2,10 @@ import { createPetrinautActions, type MutationHelperFunctions, } from "./actions"; +import { + type CommandHelperFunctions, + createPetrinautCommands, +} from "./commands"; import type { PetrinautDocHandle, @@ -22,15 +26,31 @@ export type EventStream = { subscribe(listener: (event: T) => void): () => void; }; +export type PetrinautMutations = MutationHelperFunctions; +export type PetrinautCommands = CommandHelperFunctions; + /** - * The live document instance. Owns the handle, mutations, and patch stream. + * The live document instance. Owns the handle, mutations, commands, and + * patch stream. + * + * Mutations and commands are namespaced: + * + * - `instance.mutations` — atomic, schema-driven operations keyed by + * `mutationActionInputSchemas`. This is the AI-safe surface; the AI tool + * bundle is derived from these schemas. + * - `instance.commands` — composite host operations (clipboard paste, + * auto-layout). Only the subset registered in `aiCommandActionInputSchemas` + * is exposed to the AI. + * + * There is no top-level `mutate` escape hatch — every write must flow + * through a typed helper so it is schema-validated. * * **Simulation does not live here.** A simulation runs against a frozen SDCPN * snapshot and has no need for the live document. To run one, call * {@link createSimulation} directly with `instance.handle.doc()` (or any other * SDCPN value). The host owns the simulation's lifecycle. */ -export type Petrinaut = MutationHelperFunctions & { +export type Petrinaut = { readonly handle: PetrinautDocHandle; /** Current SDCPN snapshot store. Falls back to an empty SDCPN until the handle is ready. */ @@ -39,8 +59,11 @@ export type Petrinaut = MutationHelperFunctions & { /** Patch event stream. Only fires for handles that produce patches. */ readonly patches: EventStream; - /** Apply a mutation to the document via the underlying handle. No-op if read-only. */ - mutate(this: void, fn: (draft: SDCPN) => void): void; + /** Atomic, schema-driven mutations. */ + readonly mutations: PetrinautMutations; + + /** Composite host operations (clipboard paste, auto-layout, ...). */ + readonly commands: PetrinautCommands; readonly readonly: boolean; @@ -107,20 +130,23 @@ export function createPetrinaut(config: CreatePetrinautConfig): Petrinaut { const definition = createDefinitionStore(handle); const patches = createPatchStream(handle); + const mutate = (fn: (draft: SDCPN) => void) => { if (readonly) { return; } handle.change(fn); }; - const actions = createPetrinautActions(mutate); + + const mutations = createPetrinautActions(mutate); + const commands = createPetrinautCommands(mutate, () => definition.get()); return { - ...actions, handle, definition, patches, - mutate, + mutations, + commands, readonly, dispose() { for (const dispose of disposers) { diff --git a/libs/@hashintel/petrinaut/src/ui/lib/calculate-graph-layout.ts b/libs/@hashintel/petrinaut-core/src/layout/calculate-graph-layout.ts similarity index 87% rename from libs/@hashintel/petrinaut/src/ui/lib/calculate-graph-layout.ts rename to libs/@hashintel/petrinaut-core/src/layout/calculate-graph-layout.ts index c3e10c05515..0a01a4ea817 100644 --- a/libs/@hashintel/petrinaut/src/ui/lib/calculate-graph-layout.ts +++ b/libs/@hashintel/petrinaut-core/src/layout/calculate-graph-layout.ts @@ -1,6 +1,6 @@ import ELK from "elkjs"; -import type { SDCPN } from "@hashintel/petrinaut-core"; +import type { SDCPN } from "../types/sdcpn"; import type { ElkNode } from "elkjs"; /** @@ -26,6 +26,11 @@ export type NodePosition = { y: number; }; +export type LayoutDimensions = { + place: { width: number; height: number }; + transition: { width: number; height: number }; +}; + /** * Calculates the optimal layout positions for nodes in an SDCPN graph using the ELK (Eclipse Layout Kernel) algorithm. * @@ -37,11 +42,8 @@ export type NodePosition = { * `dimensions` should be **stable across the user's visualization choice** * (compact vs. classic). Layout output must not depend on rendering mode — * otherwise toggling `compactNodes` would shift every node and visually - * scramble the graph. Today `runAutoLayout` and the import flow both pass - * whichever rendering dimensions are active, which is a known leak. The - * intended fix is to feed a `layoutNodeDimensions` (per-axis max of compact - * and classic) here instead — see the design note in - * `ui/views/SDCPN/node-dimensions.ts`. + * scramble the graph. Callers should normally pass {@link layoutNodeDimensions} + * from `./dimensions`. * * @param sdcpn - The SDCPN to layout * @param dimensions - Node dimensions for places and transitions; should be @@ -50,10 +52,7 @@ export type NodePosition = { */ export const calculateGraphLayout = async ( sdcpn: SDCPN, - dimensions: { - place: { width: number; height: number }; - transition: { width: number; height: number }; - }, + dimensions: LayoutDimensions, ): Promise> => { if (sdcpn.places.length === 0) { return {}; diff --git a/libs/@hashintel/petrinaut-core/src/layout/dimensions.ts b/libs/@hashintel/petrinaut-core/src/layout/dimensions.ts new file mode 100644 index 00000000000..5e93b6140f1 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/layout/dimensions.ts @@ -0,0 +1,15 @@ +import type { LayoutDimensions } from "./calculate-graph-layout"; + +/** + * Layout-stable node dimensions used by {@link calculateGraphLayout}. + * + * Per-axis maximum of the compact and classic rendering dimensions (see + * `ui/views/SDCPN/node-dimensions.ts`) so auto-layout output is invariant to + * the user's compact/classic visualization choice. Without this, toggling + * `userSettings.compactNodes` after running layout would visually shift every + * node. + */ +export const layoutNodeDimensions: LayoutDimensions = { + place: { width: 180, height: 130 }, + transition: { width: 180, height: 80 }, +}; diff --git a/libs/@hashintel/petrinaut-core/src/layout/index.ts b/libs/@hashintel/petrinaut-core/src/layout/index.ts new file mode 100644 index 00000000000..630c48d4435 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/layout/index.ts @@ -0,0 +1,6 @@ +export { + calculateGraphLayout, + type LayoutDimensions, + type NodePosition, +} from "./calculate-graph-layout"; +export { layoutNodeDimensions } from "./dimensions"; diff --git a/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts b/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts index 0301811ffc4..1f52a74a04b 100644 --- a/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts +++ b/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { displayNameSchema } from "../validation/display-name"; import { entityNameSchema } from "../validation/entity-name"; +import { variableNameSchema } from "../validation/variable-name"; import type { Color, @@ -47,11 +48,12 @@ export const inputArcSchema = z description: "ID of the input place connected to the transition.", }), weight: z.number().positive().meta({ - description: "Number of tokens consumed from the input place.", + description: + "Number of tokens consumed from the input place per firing. For coloured input places this also determines the tuple length the transition's lambda and kernel see at `input.PlaceName` (weight 2 means a 2-token array).", }), type: z.enum(["standard", "inhibitor"]).meta({ description: - "Standard arcs consume tokens from the input place; inhibitor arcs prevent firing when the source place has at least the weight indicated.", + "Standard arcs consume tokens from the input place; inhibitor arcs prevent firing when the source place has at least the weight indicated. Inhibitor arcs do NOT consume tokens and their place is NOT present in the lambda or kernel `input`.", }), }) .meta({ @@ -81,12 +83,20 @@ export const colorElementSchema = z elementId: idSchema.meta({ description: "Stable identifier for this colour element.", }), - name: displayNameSchema.meta({ - description: - "Token attribute name used in lambda, kernel, visualizer, and dynamics code.", - }), + name: displayNameSchema + .check( + z.refine((val) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(val), { + message: + "Element name must be a valid JavaScript identifier (start with a letter, `_`, or `$`; only letters, digits, `_`, `$` allowed).", + }), + ) + .meta({ + description: + "Token attribute identifier used DIRECTLY in code. Lambdas, kernels, dynamics, visualizers, and metrics destructure tokens as `{ }`, so this must be a valid JavaScript identifier (e.g. `machine_damage_ratio`, `x`, `velocity`). Spaces, hyphens, and leading digits will break user code that references the attribute; prefer lower_snake_case for consistency with parameter naming.", + }), type: z.enum(["real", "integer", "boolean"]).meta({ - description: "Primitive token attribute type.", + description: + "Primitive token attribute type. Note: the simulation buffer stores all values as Float64; `integer`/`boolean` are documentation/type-hints only, not enforced at runtime.", }), }) .meta({ @@ -98,23 +108,23 @@ export const placeSchema = z id: idSchema, name: entityNameSchema.meta({ description: - "PascalCase place name. Use concise names that can be referenced by transition code.", + "PascalCase identifier used DIRECTLY in user code: lambdas and kernels reference input/output places as `input.PlaceName` and `{ PlaceName: [...] }`, metrics access them as `state.places.PlaceName.count`, scenario code-mode initial state keys are place names, and visualizer scope is implicitly per-place. Renaming a place breaks every code reference, so rename only when you also update dependent lambda/kernel/dynamics/metric/visualizer/scenario code in the same batch.", }), colorId: idSchema.nullable().meta({ description: - "ID of the token colour/type accepted by this place, or null for uncoloured token counts.", + "ID of the token colour/type accepted by this place, or null for uncoloured token counts. Uncoloured places have no token attributes and do not appear in lambda/kernel `input` objects.", }), dynamicsEnabled: z.boolean().meta({ description: - "Whether tokens in this place are updated by a differential equation during simulation.", + "Whether tokens in this place are updated by a differential equation during simulation. Dynamics only run when this is true AND `differentialEquationId` is set AND `colorId` is set.", }), differentialEquationId: idSchema.nullable().meta({ description: - "ID of the differential equation used for continuous dynamics, or null when dynamics are disabled.", + "ID of the differential equation used for continuous dynamics, or null when dynamics are disabled. The referenced equation's `colorId` MUST match this place's `colorId`.", }), visualizerCode: z.string().optional().meta({ description: - "Optional visualization module code for rendering tokens in this place.", + "Optional module: `export default Visualization(({ tokens, parameters }) => )`. JSX is compiled with React's CLASSIC runtime — do NOT `import React`, do NOT use `<>…` fragments (use `` or explicit elements), and do NOT use hooks; treat it as a pure render. `tokens` is this place's current tokens (only meaningful for coloured places; empty for uncoloured). `parameters` is keyed by each parameter's `variableName` value (lower_snake_case, e.g. `parameters.crash_threshold`). Convention is to return a sized ``.", }), showAsInitialState: z.boolean().optional().meta({ description: @@ -151,12 +161,25 @@ export const transitionSchema = z "Use predicate for boolean enabling logic; use stochastic for rate-based firing.", }), lambdaCode: z.string().meta({ - description: - "JavaScript module code exporting Lambda(...). Predicate lambdas return booleans; stochastic lambdas return rates.", + description: [ + "Module: `export default Lambda((input, parameters) => …)`.", + "`input` is keyed by INPUT PLACE NAME (PascalCase) and the value is a tuple sized to that arc's weight (weight 2 means a 2-token array).", + "Inhibitor arcs and uncoloured input places are NOT present in `input`.", + "Each token is an object keyed by the colour type's element names (e.g. `{ x, y, velocity }`).", + "`parameters` is keyed by each parameter's `variableName` value (lower_snake_case, e.g. `parameters.infection_rate`).", + "Predicate lambdas MUST return a boolean (true = enabled given these tokens, false = disabled).", + "Stochastic lambdas MUST return a non-negative finite number = expected firings per simulation second (0 disables, Infinity always fires).", + "Lambda is called per token combination satisfying arc weights, so it MUST be deterministic — put randomness in the transition kernel, not here.", + ].join(" "), }), transitionKernelCode: z.string().meta({ - description: - "Optional JavaScript module code exporting TransitionKernel(...). Use distributions here to create stochastic output token attributes.", + description: [ + "Module: `export default TransitionKernel((input, parameters) => …)`.", + "`input` and `parameters` have the same shape as the transition's lambda.", + "MUST return an object keyed by OUTPUT PLACE NAME with a tuple sized to that arc's weight. Coloured output places MUST be present; uncoloured output places MUST be omitted (they are auto-populated with empty tokens).", + "Token attribute values can be plain numbers/booleans OR `Distribution.Gaussian(mean, sd)` / `Distribution.Uniform(min, max)` / `Distribution.Lognormal(mu, sigma)`; each distribution is sampled once per token, and chained `.map(fn)` calls on the same distribution share that single sample (useful for deriving multiple attributes from one draw).", + "Always required even when no stochasticity is needed; use `export default TransitionKernel(() => ({}))` when every output place is uncoloured.", + ].join(" "), }), x: z.number().meta({ description: "Horizontal canvas position.", @@ -177,14 +200,16 @@ export const colorSchema = z description: "Human-readable colour/type name.", }), iconSlug: z.string().min(1).meta({ - description: "Icon identifier used by the UI for this colour/type.", + description: + 'Short icon identifier used by the UI for this colour/type. Typical values are `"circle"` or `"square"`; the UI defaults to `"circle"`.', }), displayColor: z.string().min(1).meta({ - description: "CSS colour used by the UI to display this colour/type.", + description: + 'CSS colour string for the UI badge, e.g. `"#1E90FF"` or `"rgb(30,144,255)"`.', }), elements: z.array(colorElementSchema).meta({ description: - "Typed token attributes available on tokens of this colour/type.", + "Typed token attributes available on tokens of this colour/type. Element order matters: coloured initial state in scenario per_place mode supplies `number[][]` rows in this order.", }), }) .meta({ @@ -200,16 +225,21 @@ export const differentialEquationSchema = z }), colorId: idSchema.nullable().meta({ description: - "ID of the colour/type whose token attributes this dynamics function updates.", + "ID of the colour/type whose token attributes this dynamics function updates. MUST match the `colorId` of every place that references this equation via `differentialEquationId`.", }), code: z.string().meta({ - description: - "JavaScript module code exporting Dynamics(...). Return derivatives for each token attribute that changes continuously.", + description: [ + "Module: `export default Dynamics((tokens, parameters) => …)`.", + "`tokens` is THIS place's current tokens only — `Array<{ [elementName]: number }>` — NOT all places' tokens.", + "MUST return an array of the SAME LENGTH where each entry is `{ [elementName]: derivative }` (i.e. dx/dt, NOT the new value).", + "Missing keys default to 0 silently, so return every element your colour type declares.", + "`parameters` is keyed by each parameter's `variableName` value (lower_snake_case, e.g. `parameters.damage_per_second`).", + ].join(" "), }), }) .meta({ description: - "A differential equation for continuous dynamics on coloured tokens.", + "A differential equation for continuous dynamics on coloured tokens. The `colorId` MUST match the colour of every place that references this equation via `differentialEquationId`, and the returned derivative keys MUST cover that colour's elements.", }) satisfies z.ZodType; export const parameterSchema = z @@ -218,16 +248,17 @@ export const parameterSchema = z name: displayNameSchema.meta({ description: "Human-readable parameter name.", }), - variableName: z.string().min(1).meta({ + variableName: variableNameSchema.meta({ description: - "Identifier used by lambda, kernel, visualizer, metric, and dynamics code.", + "lower_snake_case identifier used DIRECTLY in user code as `parameters.` (e.g. `parameters.crash_threshold`, NOT `parameters.crashThreshold`). Must start with a lowercase letter; only `[a-z0-9_]` allowed.", }), type: z.enum(["real", "integer", "boolean"]).meta({ - description: "Primitive parameter type.", + description: + "Primitive parameter type. Note: parameter values are stored numerically (booleans coerce via `Number()`); the type is primarily a documentation/UI hint.", }), defaultValue: z.string().meta({ description: - "Default parameter value as an expression string parsed by the simulator.", + 'Default parameter value as a plain numeric string (e.g. `"3"`, `"0.05"`). Parsed via `Number()` with a `|| 0` fallback, so non-numeric strings silently become 0. Expressions are NOT supported here — use scenario `parameterOverrides` for expressions.', }), }) .meta({ diff --git a/libs/@hashintel/petrinaut-core/src/schemas/metric-schema.ts b/libs/@hashintel/petrinaut-core/src/schemas/metric-schema.ts index d83ef73d869..f9165645aaf 100644 --- a/libs/@hashintel/petrinaut-core/src/schemas/metric-schema.ts +++ b/libs/@hashintel/petrinaut-core/src/schemas/metric-schema.ts @@ -15,8 +15,13 @@ export const metricSchema = z description: "Optional metric summary shown to users.", }), code: z.string().meta({ - description: - "JavaScript function body invoked with state in scope. It must return one number.", + description: [ + "Plain function body (NOT a module — no `export default`, no `Metric(...)` wrapper, no enclosing `function` declaration).", + "The only variable in scope is `state`. The body MUST `return` a finite number — NaN, Infinity, and -Infinity throw and the metric series shows an error.", + "Access places by NAME: `state.places.PlaceName.count` (token count for any place) and `state.places.PlaceName.tokens` (`Array<{ [elementName]: number }>` for coloured places; always `[]` for uncoloured places).", + "`parameters` and `scenario` are NOT available inside metrics.", + "Example: `const i = state.places.Infected.count; const r = state.places.Recovered.count; return (i + r) === 0 ? 0 : i / (i + r);`", + ].join(" "), }), }) .meta({ diff --git a/libs/@hashintel/petrinaut-core/src/schemas/scenario-schema.ts b/libs/@hashintel/petrinaut-core/src/schemas/scenario-schema.ts index cb57aa7e608..1cb5017a610 100644 --- a/libs/@hashintel/petrinaut-core/src/schemas/scenario-schema.ts +++ b/libs/@hashintel/petrinaut-core/src/schemas/scenario-schema.ts @@ -42,30 +42,38 @@ const initialStateSchema = z z.union([z.string(), z.array(z.array(z.number()))]), ) .meta({ - description: - 'Map from place ID to initial tokens for that place. For uncoloured places, use a string expression that evaluates to the initial token count, for example "scenario.population * scenario.initial_ratio". For coloured places, use number[][] token rows.', + description: [ + "Map keyed by place ID (NOT place name).", + 'For uncoloured places, the value is a string expression with `parameters` and `scenario` in scope (e.g. `"scenario.population * (1 - scenario.infected_ratio)"`). The result is `Math.round`ed and clamped to >= 0 (token counts are always non-negative integers).', + "For coloured places, the value is `number[][]` where each inner array supplies element values in the SAME ORDER as the colour type's `elements`. Extra columns throw at compile time; missing columns default to 0.", + "`parameters` in expressions is keyed by each parameter's `variableName` value (lower_snake_case).", + ].join(" "), }), }) .meta({ description: - "Initial state specified place-by-place. Use this for most scenarios. The content keys must be existing place IDs.", + "Initial state specified place-by-place. Use this for most scenarios. The content keys MUST be existing place IDs.", }), z .strictObject({ type: z.literal("code"), content: z.string().meta({ - description: - "Executable code for advanced initial-state setup. It should return the full initial token mapping by place ID.", + description: [ + "Function body (NOT a module — no `export default`, no wrapper) with `parameters` and `scenario` in scope.", + "MUST `return` an object keyed by PLACE NAME (NOT place ID — note the asymmetry with per_place mode, which uses place IDs).", + "Per-place values: a number for uncoloured places (rounded and clamped to >= 0); `Array<{ [elementName]: number }>` for coloured places.", + "Unknown place names in the returned object are silently dropped — typos produce an empty initial state with no error, so verify names exactly match.", + ].join(" "), }), }) .meta({ description: - "Initial state specified by code. Use only when per_place expressions cannot express the setup.", + "Initial state specified by code. Use only when per_place expressions cannot express the setup (e.g. constructing many coloured tokens from a scenario parameter).", }), ]) .meta({ description: - 'Initial token state for a scenario. Prefer type "per_place" with content keyed by place ID; use type "code" only for advanced custom setup.', + 'Initial token state for a scenario. Prefer type "per_place" (content keyed by place ID); use type "code" (content keyed by place NAME) only for advanced custom setup.', }); export const scenarioSchema = z @@ -96,10 +104,17 @@ export const scenarioSchema = z description: "User-tunable parameters available only within this scenario. Add scenario parameters for important scenario variables so users can adjust them without editing net-level parameters or code. Reference them as scenario.identifier in parameterOverrides and initialState expressions.", }), - parameterOverrides: z.record(z.string(), z.string()).default({}).meta({ - description: - 'Map from existing net-level parameter ID to a concrete value or expression for this scenario. Keys must be parameter IDs from the current net. Values may be literals such as "1.5" or expressions using scenario parameters such as "scenario.transmission_multiplier * 0.4". Omit this field or use {} when the scenario does not override any net-level parameters.', - }), + parameterOverrides: z + .record(z.string(), z.string()) + .default({}) + .meta({ + description: [ + "Map from existing net-level parameter ID to a concrete value or expression for this scenario. Keys MUST be parameter IDs from the current net.", + 'Values may be numeric literals such as `"1.5"` or expressions using `scenario` and `parameters`, e.g. `"scenario.transmission_multiplier * 0.4"`.', + "Inside an override expression, `parameters` resolves to net-level DEFAULTS (not other override results) — overrides cannot reference each other.", + 'Omit this field entirely, or use `{}`, when the scenario does not override any net-level parameters. Do NOT emit `""` as a value (it is a no-op at runtime but adds noise).', + ].join(" "), + }), initialState: initialStateSchema, }) .meta({ diff --git a/libs/@hashintel/petrinaut-core/src/validation/variable-name.ts b/libs/@hashintel/petrinaut-core/src/validation/variable-name.ts index 03ca859cf6a..0ef1c92fb64 100644 --- a/libs/@hashintel/petrinaut-core/src/validation/variable-name.ts +++ b/libs/@hashintel/petrinaut-core/src/validation/variable-name.ts @@ -9,7 +9,7 @@ import { z } from "zod"; */ const LOWER_SNAKE_CASE_REGEX = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/; -const variableNameSchema = z +export const variableNameSchema = z .string() .trim() .check( diff --git a/libs/@hashintel/petrinaut-core/vite.config.ts b/libs/@hashintel/petrinaut-core/vite.config.ts index 19e5c35edb5..e5f181722cf 100644 --- a/libs/@hashintel/petrinaut-core/vite.config.ts +++ b/libs/@hashintel/petrinaut-core/vite.config.ts @@ -26,6 +26,7 @@ export default defineConfig(({ command }) => ({ rolldownOptions: { external: [ "@babel/standalone", + "elkjs", "immer", "uuid", "vscode-languageserver-types", diff --git a/libs/@hashintel/petrinaut/package.json b/libs/@hashintel/petrinaut/package.json index 88b478ec7d7..64789b12096 100644 --- a/libs/@hashintel/petrinaut/package.json +++ b/libs/@hashintel/petrinaut/package.json @@ -51,6 +51,7 @@ "test:unit": "vitest" }, "dependencies": { + "@ai-sdk/react": "3.0.184", "@ark-ui/react": "5.26.2", "@babel/standalone": "7.28.5", "@fontsource-variable/inter": "5.2.8", @@ -63,10 +64,11 @@ "@monaco-editor/react": "4.8.0-rc.3", "@tanstack/react-form": "1.29.0", "@xyflow/react": "12.10.1", - "elkjs": "0.11.0", + "ai": "6.0.182", "fuzzysort": "3.1.0", "lodash-es": "4.18.1", "monaco-editor": "0.55.1", + "react-markdown": "10.1.0", "react-resizable-panels": "4.6.5", "uplot": "1.6.32", "use-sync-external-store": "1.6.0", diff --git a/libs/@hashintel/petrinaut/src/react/hooks/index.ts b/libs/@hashintel/petrinaut/src/react/hooks/index.ts index dfbca552c62..69fd67a3a96 100644 --- a/libs/@hashintel/petrinaut/src/react/hooks/index.ts +++ b/libs/@hashintel/petrinaut/src/react/hooks/index.ts @@ -9,7 +9,6 @@ export { useDocumentId, useDocumentState, useIsDocumentReady, - useMutate, usePetrinautDefinition, usePetrinautDefinitionSelector, usePetrinautPatches, @@ -58,6 +57,19 @@ export { // Re-export the existing read-only hook from its current location. export { useIsReadOnly } from "../state/use-is-read-only"; +export { + formatReadOnlyReason, + useReadOnlyReason, + type ReadOnlyReason, +} from "../state/use-read-only-reason"; + +// Mutation + command surfaces. +export { usePetrinautMutations } from "./use-petrinaut-mutations"; +export { usePetrinautCommands } from "./use-petrinaut-commands"; +export type { + PetrinautCommands, + PetrinautMutations, +} from "@hashintel/petrinaut-core"; // Instance access + low-level store adapter. export { usePetrinautInstance } from "../use-petrinaut-instance"; diff --git a/libs/@hashintel/petrinaut/src/react/hooks/use-document.ts b/libs/@hashintel/petrinaut/src/react/hooks/use-document.ts index b92174806d7..89c85c5bcc1 100644 --- a/libs/@hashintel/petrinaut/src/react/hooks/use-document.ts +++ b/libs/@hashintel/petrinaut/src/react/hooks/use-document.ts @@ -30,17 +30,6 @@ export function usePetrinautDefinitionSelector( return selector(usePetrinautDefinition()); } -/** - * Apply a mutation to the document. Reads through the Petrinaut instance - * (must be inside `` / ``). - * - * Returns a stable function reference for the lifetime of the instance. - * In read-only mode the call is a no-op. - */ -export function useMutate(): (fn: (draft: SDCPN) => void) => void { - return usePetrinautInstance().mutate; -} - /** Set the current net's title. */ export function useSetTitle(): (title: string) => void { return use(SDCPNContext).setTitle; diff --git a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.test.tsx b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.test.tsx new file mode 100644 index 00000000000..42ddcb608d1 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.test.tsx @@ -0,0 +1,270 @@ +/** + * @vitest-environment jsdom + */ +import { act, renderHook } from "@testing-library/react"; +import { type ReactNode } from "react"; +import { describe, expect, test } from "vitest"; + +import { + CLIPBOARD_FORMAT_VERSION, + type ClipboardPayload, + createJsonDocHandle, + createPetrinaut, + type Petrinaut, + type SDCPN, +} from "@hashintel/petrinaut-core"; + +import { PetrinautInstanceContext } from "../instance-context"; +import { SimulationContext, type SimulationState } from "../simulation/context"; +import { + EditorContext, + type EditorContextValue, + initialEditorState, +} from "../state/editor-context"; +import { SDCPNContext, type SDCPNContextValue } from "../state/sdcpn-context"; +import { usePetrinautCommands } from "./use-petrinaut-commands"; + +const EMPTY_SDCPN: SDCPN = { + places: [], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], +}; + +const editorContextValue = ( + globalMode: "edit" | "simulate" = "edit", +): EditorContextValue => ({ + ...initialEditorState, + globalMode, + setGlobalMode: () => {}, + setEditionMode: () => {}, + setCursorMode: () => {}, + setLeftSidebarOpen: () => {}, + setLeftSidebarWidth: () => {}, + setPropertiesPanelWidth: () => {}, + setBottomPanelOpen: () => {}, + toggleBottomPanel: () => {}, + setBottomPanelHeight: () => {}, + setActiveBottomPanelTab: () => {}, + isSelected: () => false, + isSelectedConnection: () => false, + isNotSelectedConnection: () => false, + isHoveredConnection: () => false, + isNotHoveredConnection: () => false, + selectedConnections: new Map(), + setSelection: () => {}, + selectItem: () => {}, + toggleItem: () => {}, + clearSelection: () => {}, + setHoveredItem: () => {}, + clearHoveredItem: () => {}, + isHovered: () => false, + setDraggingStateByNodeId: () => {}, + updateDraggingStateByNodeId: () => {}, + resetDraggingState: () => {}, + collapseAllPanels: () => {}, + setTimelineChartType: () => {}, + setTimelineView: () => {}, + setSimulateViewMode: () => {}, + setSimulateDrawer: () => {}, + setSearchOpen: () => {}, + setAiAssistantOpen: () => {}, + toggleAiAssistant: () => {}, + searchInputRef: { current: null }, + triggerPanelAnimation: () => {}, + __reinitialize: () => {}, +}); + +type WrapperOptions = { + sdcpn?: SDCPN; + readonly?: boolean; + globalMode?: "edit" | "simulate"; + simulationState?: SimulationState; +}; + +const createWrapper = (options: WrapperOptions = {}) => { + const { + sdcpn: initialSdcpn = EMPTY_SDCPN, + readonly = false, + globalMode = "edit", + simulationState = "NotRun", + } = options; + + const instance: Petrinaut = createPetrinaut({ + document: createJsonDocHandle({ initial: structuredClone(initialSdcpn) }), + }); + + const sdcpnContextValue: SDCPNContextValue = { + createNewNet: () => {}, + existingNets: [], + loadPetriNet: () => {}, + petriNetId: "test-net", + petriNetDefinition: instance.definition.get(), + readonly, + setTitle: () => {}, + title: "Test", + getItemType: () => null, + }; + + const Wrapper = ({ children }: { children: ReactNode }) => ( + + + Promise.resolve(null), + getAllFrames: () => Promise.resolve([]), + getFramesInRange: () => Promise.resolve([]), + setSelectedScenarioId: () => {}, + setScenarioParameterValue: () => {}, + setInitialMarking: () => {}, + setParameterValue: () => {}, + setDt: () => {}, + setMaxTime: () => {}, + initialize: () => Promise.resolve(), + run: () => {}, + pause: () => {}, + reset: () => {}, + setBackpressure: () => {}, + ack: () => {}, + }} + > + + {children} + + + + + ); + + return { Wrapper, instance }; +}; + +const samplePastePayload: ClipboardPayload = { + format: "petrinaut-sdcpn", + version: CLIPBOARD_FORMAT_VERSION, + documentId: null, + data: { + places: [ + { + id: "p1", + name: "Pasted", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 10, + y: 10, + }, + ], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], + }, +}; + +describe("usePetrinautCommands", () => { + describe("when not readonly", () => { + test("applyClipboardPaste pastes new items and reports IDs", () => { + const { Wrapper, instance } = createWrapper(); + const { result } = renderHook(usePetrinautCommands, { + wrapper: Wrapper, + }); + + let output: ReturnType; + act(() => { + output = result.current.applyClipboardPaste({ + payload: samplePastePayload, + }); + }); + + expect(output!.newItemIds.some((item) => item.type === "place")).toBe( + true, + ); + expect(instance.definition.get().places).toHaveLength(1); + }); + + test("applyAutoLayout returns commitCount=0 on an empty net", async () => { + const { Wrapper } = createWrapper(); + const { result } = renderHook(usePetrinautCommands, { + wrapper: Wrapper, + }); + + const { commitCount } = await result.current.applyAutoLayout(); + expect(commitCount).toBe(0); + }); + }); + + describe("readonly enforcement", () => { + test("applyClipboardPaste is a no-op when host readonly is true", () => { + const { Wrapper, instance } = createWrapper({ readonly: true }); + const { result } = renderHook(usePetrinautCommands, { + wrapper: Wrapper, + }); + + let output: ReturnType; + act(() => { + output = result.current.applyClipboardPaste({ + payload: samplePastePayload, + }); + }); + + expect(output!.newItemIds).toEqual([]); + expect(instance.definition.get().places).toHaveLength(0); + }); + + test("applyClipboardPaste is a no-op when globalMode is simulate", () => { + const { Wrapper, instance } = createWrapper({ globalMode: "simulate" }); + const { result } = renderHook(usePetrinautCommands, { + wrapper: Wrapper, + }); + + let output: ReturnType; + act(() => { + output = result.current.applyClipboardPaste({ + payload: samplePastePayload, + }); + }); + + expect(output!.newItemIds).toEqual([]); + expect(instance.definition.get().places).toHaveLength(0); + }); + + test("applyAutoLayout returns commitCount=0 when readonly", async () => { + const { Wrapper } = createWrapper({ + readonly: true, + sdcpn: { + ...EMPTY_SDCPN, + places: [ + { + id: "p1", + name: "P", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + ], + }, + }); + const { result } = renderHook(usePetrinautCommands, { + wrapper: Wrapper, + }); + + const { commitCount } = await result.current.applyAutoLayout(); + expect(commitCount).toBe(0); + }); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.ts b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.ts new file mode 100644 index 00000000000..f5f7ca01596 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-commands.ts @@ -0,0 +1,41 @@ +import { use } from "react"; + +import { PetrinautInstanceContext } from "../instance-context"; +import { useIsReadOnly } from "../state/use-is-read-only"; + +import type { PetrinautCommands } from "@hashintel/petrinaut-core"; + +/** + * React-facing bundle of composite host commands (clipboard paste, + * auto-layout, ...). + * + * Each command no-ops or returns a default-shaped result when + * {@link useIsReadOnly} returns `true`, matching the behaviour of + * {@link usePetrinautMutations}. Components MUST NOT reach for + * `usePetrinautInstance().commands` directly. + */ +export function usePetrinautCommands(): PetrinautCommands { + const instance = use(PetrinautInstanceContext); + if (!instance) { + throw new Error( + "usePetrinautCommands must be used inside (or ).", + ); + } + const isReadOnly = useIsReadOnly(); + const { commands } = instance; + + return { + applyClipboardPaste(input) { + if (isReadOnly) { + return { newItemIds: [] }; + } + return commands.applyClipboardPaste(input); + }, + async applyAutoLayout() { + if (isReadOnly) { + return { commitCount: 0 }; + } + return commands.applyAutoLayout(); + }, + }; +} diff --git a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.test.tsx b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.test.tsx new file mode 100644 index 00000000000..37fbc352b42 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.test.tsx @@ -0,0 +1,371 @@ +/** + * @vitest-environment jsdom + */ +import { act, renderHook } from "@testing-library/react"; +import { type ReactNode } from "react"; +import { describe, expect, test } from "vitest"; + +import { + createJsonDocHandle, + createPetrinaut, + type Petrinaut, + type SDCPN, +} from "@hashintel/petrinaut-core"; + +import { PetrinautInstanceContext } from "../instance-context"; +import { SimulationContext, type SimulationState } from "../simulation/context"; +import { + EditorContext, + type EditorContextValue, + initialEditorState, +} from "../state/editor-context"; +import { SDCPNContext, type SDCPNContextValue } from "../state/sdcpn-context"; +import { usePetrinautMutations } from "./use-petrinaut-mutations"; + +const EMPTY_SDCPN: SDCPN = { + places: [], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], +}; + +const makeSDCPN = (partial?: Partial): SDCPN => ({ + ...EMPTY_SDCPN, + ...partial, +}); + +const editorContextValue = ( + globalMode: "edit" | "simulate" = "edit", +): EditorContextValue => ({ + ...initialEditorState, + globalMode, + setGlobalMode: () => {}, + setEditionMode: () => {}, + setCursorMode: () => {}, + setLeftSidebarOpen: () => {}, + setLeftSidebarWidth: () => {}, + setPropertiesPanelWidth: () => {}, + setBottomPanelOpen: () => {}, + toggleBottomPanel: () => {}, + setBottomPanelHeight: () => {}, + setActiveBottomPanelTab: () => {}, + isSelected: () => false, + isSelectedConnection: () => false, + isNotSelectedConnection: () => false, + isHoveredConnection: () => false, + isNotHoveredConnection: () => false, + selectedConnections: new Map(), + setSelection: () => {}, + selectItem: () => {}, + toggleItem: () => {}, + clearSelection: () => {}, + setHoveredItem: () => {}, + clearHoveredItem: () => {}, + isHovered: () => false, + setDraggingStateByNodeId: () => {}, + updateDraggingStateByNodeId: () => {}, + resetDraggingState: () => {}, + collapseAllPanels: () => {}, + setTimelineChartType: () => {}, + setTimelineView: () => {}, + setSimulateViewMode: () => {}, + setSimulateDrawer: () => {}, + setSearchOpen: () => {}, + setAiAssistantOpen: () => {}, + toggleAiAssistant: () => {}, + searchInputRef: { current: null }, + triggerPanelAnimation: () => {}, + __reinitialize: () => {}, +}); + +type WrapperOptions = { + sdcpn?: SDCPN; + readonly?: boolean; + globalMode?: "edit" | "simulate"; + simulationState?: SimulationState; +}; + +const createWrapper = (options: WrapperOptions = {}) => { + const { + sdcpn: initialSdcpn = EMPTY_SDCPN, + readonly = false, + globalMode = "edit", + simulationState = "NotRun", + } = options; + + const instance: Petrinaut = createPetrinaut({ + document: createJsonDocHandle({ initial: structuredClone(initialSdcpn) }), + }); + + const sdcpnContextValue: SDCPNContextValue = { + createNewNet: () => {}, + existingNets: [], + loadPetriNet: () => {}, + petriNetId: "test-net", + petriNetDefinition: instance.definition.get(), + readonly, + setTitle: () => {}, + title: "Test", + getItemType: () => null, + }; + + const Wrapper = ({ children }: { children: ReactNode }) => ( + + + Promise.resolve(null), + getAllFrames: () => Promise.resolve([]), + getFramesInRange: () => Promise.resolve([]), + setSelectedScenarioId: () => {}, + setScenarioParameterValue: () => {}, + setInitialMarking: () => {}, + setParameterValue: () => {}, + setDt: () => {}, + setMaxTime: () => {}, + initialize: () => Promise.resolve(), + run: () => {}, + pause: () => {}, + reset: () => {}, + setBackpressure: () => {}, + ack: () => {}, + }} + > + + {children} + + + + + ); + + return { Wrapper, instance }; +}; + +describe("usePetrinautMutations", () => { + describe("when not readonly", () => { + test("addPlace mutates SDCPN", () => { + const { Wrapper, instance } = createWrapper(); + const { result } = renderHook(usePetrinautMutations, { + wrapper: Wrapper, + }); + + act(() => { + result.current.addPlace({ + id: "p1", + name: "Place1", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }); + }); + + const places = instance.definition.get().places; + expect(places).toHaveLength(1); + expect(places[0]!.id).toBe("p1"); + }); + + test("commitNodePositions updates positions", () => { + const sdcpn = makeSDCPN({ + places: [ + { + id: "p1", + name: "P1", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + ], + }); + const { Wrapper, instance } = createWrapper({ sdcpn }); + const { result } = renderHook(usePetrinautMutations, { + wrapper: Wrapper, + }); + + act(() => { + result.current.commitNodePositions({ + commits: [ + { id: "p1", itemType: "place", position: { x: 100, y: 200 } }, + ], + }); + }); + + expect(instance.definition.get().places[0]!.x).toBe(100); + expect(instance.definition.get().places[0]!.y).toBe(200); + }); + }); + + describe("readonly enforcement", () => { + test("mutations no-op when host readonly prop is true", () => { + const { Wrapper, instance } = createWrapper({ readonly: true }); + const { result } = renderHook(usePetrinautMutations, { + wrapper: Wrapper, + }); + + act(() => { + result.current.addPlace({ + id: "p1", + name: "P1", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }); + }); + + expect(instance.definition.get().places).toHaveLength(0); + }); + + test("mutations no-op when globalMode is simulate", () => { + const { Wrapper, instance } = createWrapper({ globalMode: "simulate" }); + const { result } = renderHook(usePetrinautMutations, { + wrapper: Wrapper, + }); + + act(() => { + result.current.addPlace({ + id: "p1", + name: "P1", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }); + }); + + expect(instance.definition.get().places).toHaveLength(0); + }); + + test("mutations no-op when simulation is Running/Paused/Complete", () => { + for (const state of ["Running", "Paused", "Complete"] as const) { + const { Wrapper, instance } = createWrapper({ simulationState: state }); + const { result } = renderHook(usePetrinautMutations, { + wrapper: Wrapper, + }); + + act(() => { + result.current.addPlace({ + id: "p1", + name: "P1", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }); + }); + + expect(instance.definition.get().places).toHaveLength(0); + } + }); + + test("scenario mutations still work in simulate mode", () => { + const { Wrapper, instance } = createWrapper({ globalMode: "simulate" }); + const { result } = renderHook(usePetrinautMutations, { + wrapper: Wrapper, + }); + + act(() => { + result.current.addScenario({ + id: "scenario-1", + name: "Default", + scenarioParameters: [], + parameterOverrides: {}, + initialState: { type: "per_place", content: {} }, + }); + }); + + expect(instance.definition.get().scenarios ?? []).toHaveLength(1); + }); + + test("scenario mutations are blocked by host readonly", () => { + const { Wrapper, instance } = createWrapper({ readonly: true }); + const { result } = renderHook(usePetrinautMutations, { + wrapper: Wrapper, + }); + + act(() => { + result.current.addScenario({ + id: "scenario-1", + name: "Default", + scenarioParameters: [], + parameterOverrides: {}, + initialState: { type: "per_place", content: {} }, + }); + }); + + expect(instance.definition.get().scenarios ?? []).toHaveLength(0); + }); + }); + + describe("cascading deletes", () => { + test("removePlace cascades to remove connected arcs", () => { + const sdcpn = makeSDCPN({ + places: [ + { + id: "p1", + name: "P1", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + { + id: "p2", + name: "P2", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 100, + y: 0, + }, + ], + transitions: [ + { + id: "t1", + name: "T1", + inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "p2", weight: 1 }], + lambdaType: "predicate", + lambdaCode: "", + transitionKernelCode: "", + x: 50, + y: 0, + }, + ], + }); + const { Wrapper, instance } = createWrapper({ sdcpn }); + const { result } = renderHook(usePetrinautMutations, { + wrapper: Wrapper, + }); + + act(() => { + result.current.removePlace({ placeId: "p1" }); + }); + + const updated = instance.definition.get(); + expect(updated.places).toHaveLength(1); + expect(updated.places[0]!.id).toBe("p2"); + expect(updated.transitions[0]!.inputArcs).toHaveLength(0); + }); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.ts b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.ts new file mode 100644 index 00000000000..d2ca622a40b --- /dev/null +++ b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.ts @@ -0,0 +1,87 @@ +import { use } from "react"; + +import { PetrinautInstanceContext } from "../instance-context"; +import { SDCPNContext } from "../state/sdcpn-context"; +import { simulateModeAllowedMutationNames } from "../state/simulate-mode-allowed-mutation-names"; +import { useIsReadOnly } from "../state/use-is-read-only"; + +import type { PetrinautMutations } from "@hashintel/petrinaut-core"; + +/** + * React-facing bundle of atomic SDCPN mutations. + * + * Each helper is wrapped so that: + * + * - Most mutations no-op when {@link useIsReadOnly} returns `true` (host + * `readonly`, simulate mode, or an active simulation). + * - Scenario/metric mutations only check the host `readonly` flag — they + * remain available in simulate mode where the Simulate panel manages them. + * The list lives in {@link simulateModeAllowedMutationNames} so the AI + * tool dispatcher stays in sync. + * + * Components MUST NOT reach for `usePetrinautInstance().mutations` directly; + * the public `usePetrinautInstance()` return type narrows away the mutation + * surface to keep the readonly guard centralised in this hook. + */ +export function usePetrinautMutations(): PetrinautMutations { + const instance = use(PetrinautInstanceContext); + if (!instance) { + throw new Error( + "usePetrinautMutations must be used inside (or ).", + ); + } + const { readonly } = use(SDCPNContext); + const isReadOnly = useIsReadOnly(); + const { mutations } = instance; + + const withReadonlyGuard = ( + name: Name, + ): PetrinautMutations[Name] => { + const allowedInSimulate = simulateModeAllowedMutationNames.has(name); + const target = mutations[name] as (input: never) => void; + const wrapped = ((input: never) => { + if (allowedInSimulate ? readonly : isReadOnly) { + return; + } + target(input); + }) as PetrinautMutations[Name]; + return wrapped; + }; + + return { + addPlace: withReadonlyGuard("addPlace"), + updatePlace: withReadonlyGuard("updatePlace"), + updatePlacePosition: withReadonlyGuard("updatePlacePosition"), + removePlace: withReadonlyGuard("removePlace"), + addTransition: withReadonlyGuard("addTransition"), + updateTransition: withReadonlyGuard("updateTransition"), + updateTransitionPosition: withReadonlyGuard("updateTransitionPosition"), + removeTransition: withReadonlyGuard("removeTransition"), + addArc: withReadonlyGuard("addArc"), + removeArc: withReadonlyGuard("removeArc"), + updateArcWeight: withReadonlyGuard("updateArcWeight"), + updateArcType: withReadonlyGuard("updateArcType"), + updateArcPlace: withReadonlyGuard("updateArcPlace"), + addType: withReadonlyGuard("addType"), + updateType: withReadonlyGuard("updateType"), + removeType: withReadonlyGuard("removeType"), + addTypeElement: withReadonlyGuard("addTypeElement"), + updateTypeElement: withReadonlyGuard("updateTypeElement"), + removeTypeElement: withReadonlyGuard("removeTypeElement"), + moveTypeElement: withReadonlyGuard("moveTypeElement"), + addDifferentialEquation: withReadonlyGuard("addDifferentialEquation"), + updateDifferentialEquation: withReadonlyGuard("updateDifferentialEquation"), + removeDifferentialEquation: withReadonlyGuard("removeDifferentialEquation"), + addParameter: withReadonlyGuard("addParameter"), + updateParameter: withReadonlyGuard("updateParameter"), + removeParameter: withReadonlyGuard("removeParameter"), + addScenario: withReadonlyGuard("addScenario"), + updateScenario: withReadonlyGuard("updateScenario"), + removeScenario: withReadonlyGuard("removeScenario"), + addMetric: withReadonlyGuard("addMetric"), + updateMetric: withReadonlyGuard("updateMetric"), + removeMetric: withReadonlyGuard("removeMetric"), + deleteItemsByIds: withReadonlyGuard("deleteItemsByIds"), + commitNodePositions: withReadonlyGuard("commitNodePositions"), + }; +} diff --git a/libs/@hashintel/petrinaut/src/react/mutation-provider.test.tsx b/libs/@hashintel/petrinaut/src/react/mutation-provider.test.tsx deleted file mode 100644 index a81a1fad9cf..00000000000 --- a/libs/@hashintel/petrinaut/src/react/mutation-provider.test.tsx +++ /dev/null @@ -1,634 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { act, renderHook } from "@testing-library/react"; -import { type ReactNode, use } from "react"; -import { describe, expect, test, vi } from "vitest"; - -import { - createPetrinautActions, - type Petrinaut, - type SDCPN, -} from "@hashintel/petrinaut-core"; - -import { PetrinautInstanceContext } from "./instance-context"; -import { MutationProvider } from "./mutation-provider"; -import { - SimulationContext, - type SimulationContextValue, -} from "./simulation/context"; -import { - EditorContext, - type EditorContextValue, - initialEditorState, -} from "./state/editor-context"; -import { MutationContext } from "./state/mutation-context"; -import { SDCPNContext, type SDCPNContextValue } from "./state/sdcpn-context"; -import { - defaultUserSettings, - UserSettingsContext, - type UserSettingsContextValue, -} from "./state/user-settings-context"; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -const EMPTY_SDCPN: SDCPN = { - places: [], - transitions: [], - types: [], - differentialEquations: [], - parameters: [], -}; - -function makeSDCPN(partial?: Partial): SDCPN { - return { ...EMPTY_SDCPN, ...partial }; -} - -const DEFAULT_SIMULATION: SimulationContextValue = { - state: "NotRun", - error: null, - errorItemId: null, - parameterValues: {}, - initialMarking: {}, - selectedScenarioId: null, - scenarioParameterValues: {}, - compiledScenarioResult: null, - dt: 0.01, - maxTime: null, - totalFrames: 0, - getFrame: () => Promise.resolve(null), - getAllFrames: () => Promise.resolve([]), - getFramesInRange: () => Promise.resolve([]), - setSelectedScenarioId: () => {}, - setScenarioParameterValue: () => {}, - setInitialMarking: () => {}, - setParameterValue: () => {}, - setDt: () => {}, - setMaxTime: () => {}, - initialize: () => Promise.resolve(), - run: () => {}, - pause: () => {}, - reset: () => {}, - setBackpressure: () => {}, - ack: () => {}, -}; - -const DEFAULT_EDITOR: EditorContextValue = { - ...initialEditorState, - setGlobalMode: () => {}, - setEditionMode: () => {}, - setCursorMode: () => {}, - setLeftSidebarOpen: () => {}, - setLeftSidebarWidth: () => {}, - setPropertiesPanelWidth: () => {}, - setBottomPanelOpen: () => {}, - toggleBottomPanel: () => {}, - setBottomPanelHeight: () => {}, - setActiveBottomPanelTab: () => {}, - isSelected: () => false, - isSelectedConnection: () => false, - isNotSelectedConnection: () => false, - isHoveredConnection: () => false, - isNotHoveredConnection: () => false, - selectedConnections: new Map(), - setSelection: () => {}, - selectItem: () => {}, - toggleItem: () => {}, - clearSelection: () => {}, - setHoveredItem: () => {}, - clearHoveredItem: () => {}, - isHovered: () => false, - setDraggingStateByNodeId: () => {}, - updateDraggingStateByNodeId: () => {}, - resetDraggingState: () => {}, - collapseAllPanels: () => {}, - setTimelineChartType: () => {}, - setTimelineView: () => {}, - setSimulateViewMode: () => {}, - setSearchOpen: () => {}, - searchInputRef: { current: null }, - triggerPanelAnimation: () => {}, - __reinitialize: () => {}, -}; - -const DEFAULT_USER_SETTINGS: UserSettingsContextValue = { - ...defaultUserSettings, - setShowAnimations: () => {}, - setKeepPanelsMounted: () => {}, - setCompactNodes: () => {}, - setArcRendering: () => {}, - setIsLeftSidebarOpen: () => {}, - setLeftSidebarWidth: () => {}, - setPropertiesPanelWidth: () => {}, - setIsBottomPanelOpen: () => {}, - setBottomPanelHeight: () => {}, - setActiveBottomPanelTab: () => {}, - setCursorMode: () => {}, - setTimelineChartType: () => {}, - setShowMinimap: () => {}, - setSnapToGrid: () => {}, - setPartialSelection: () => {}, - setUseEntitiesTreeView: () => {}, - updateSubViewSection: () => {}, -}; - -type WrapperOptions = { - sdcpn?: SDCPN; - readonly?: boolean; - globalMode?: "edit" | "simulate"; - simulationState?: SimulationContextValue["state"]; -}; - -/** - * Mounts every context the new bridge {@link MutationProvider} reads — most - * importantly a stub {@link Petrinaut} instance whose `mutate` is the spied - * function under test. - */ -function createWrapper(options: WrapperOptions = {}) { - const { - sdcpn: initialSdcpn = EMPTY_SDCPN, - readonly = false, - globalMode = "edit", - simulationState = "NotRun", - } = options; - - let currentSdcpn = structuredClone(initialSdcpn); - const mutateFn = vi.fn((fn: (sdcpn: SDCPN) => void) => { - const draft = structuredClone(currentSdcpn); - fn(draft); - currentSdcpn = draft; - }); - - const fakeInstance = { - ...createPetrinautActions(mutateFn), - handle: { id: "test-net" }, - definition: { get: () => currentSdcpn, subscribe: () => () => {} }, - patches: { subscribe: () => () => {} }, - mutate: mutateFn, - readonly, - dispose: () => {}, - } as unknown as Petrinaut; - - const sdcpnContextValue: SDCPNContextValue = { - createNewNet: () => {}, - existingNets: [], - loadPetriNet: () => {}, - petriNetId: "test-net", - petriNetDefinition: initialSdcpn, - readonly, - setTitle: () => {}, - title: "Test", - getItemType: () => null, - }; - - const editorContextValue: EditorContextValue = { - ...DEFAULT_EDITOR, - globalMode, - }; - - const simulationContextValue: SimulationContextValue = { - ...DEFAULT_SIMULATION, - state: simulationState, - }; - - const Wrapper = ({ children }: { children: ReactNode }) => ( - - - - - - {children} - - - - - - ); - - return { Wrapper, mutateFn, getSdcpn: () => currentSdcpn }; -} - -function useMutations() { - return use(MutationContext); -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe("MutationProvider (instance bridge)", () => { - describe("when not readonly", () => { - test("addPlace mutates SDCPN", () => { - const { Wrapper, mutateFn, getSdcpn } = createWrapper(); - const { result } = renderHook(useMutations, { wrapper: Wrapper }); - - act(() => { - result.current.addPlace({ - id: "p1", - name: "Place1", - colorId: null, - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }); - }); - - expect(mutateFn).toHaveBeenCalledTimes(1); - expect(getSdcpn().places).toHaveLength(1); - expect(getSdcpn().places[0]!.id).toBe("p1"); - }); - - test("addTransition mutates SDCPN", () => { - const { Wrapper, mutateFn, getSdcpn } = createWrapper(); - const { result } = renderHook(useMutations, { wrapper: Wrapper }); - - act(() => { - result.current.addTransition({ - id: "t1", - name: "Trans1", - inputArcs: [], - outputArcs: [], - lambdaType: "predicate", - lambdaCode: "", - transitionKernelCode: "", - x: 0, - y: 0, - }); - }); - - expect(mutateFn).toHaveBeenCalledTimes(1); - expect(getSdcpn().transitions).toHaveLength(1); - }); - - test("commitNodePositions updates positions", () => { - const sdcpn = makeSDCPN({ - places: [ - { - id: "p1", - name: "P1", - colorId: null, - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }, - ], - transitions: [ - { - id: "t1", - name: "T1", - inputArcs: [], - outputArcs: [], - lambdaType: "predicate", - lambdaCode: "", - transitionKernelCode: "", - x: 0, - y: 0, - }, - ], - }); - const { Wrapper, getSdcpn } = createWrapper({ sdcpn }); - const { result } = renderHook(useMutations, { wrapper: Wrapper }); - - act(() => { - result.current.commitNodePositions({ - commits: [ - { id: "p1", itemType: "place", position: { x: 100, y: 200 } }, - { - id: "t1", - itemType: "transition", - position: { x: 300, y: 400 }, - }, - ], - }); - }); - - expect(getSdcpn().places[0]!.x).toBe(100); - expect(getSdcpn().places[0]!.y).toBe(200); - expect(getSdcpn().transitions[0]!.x).toBe(300); - expect(getSdcpn().transitions[0]!.y).toBe(400); - }); - }); - - describe("readonly enforcement", () => { - test("mutations no-op when readonly prop is true", () => { - const { Wrapper, mutateFn } = createWrapper({ readonly: true }); - const { result } = renderHook(useMutations, { wrapper: Wrapper }); - - act(() => { - result.current.addPlace({ - id: "p1", - name: "P1", - colorId: null, - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }); - }); - - expect(mutateFn).not.toHaveBeenCalled(); - }); - - test("mutations no-op when globalMode is simulate", () => { - const { Wrapper, mutateFn } = createWrapper({ - globalMode: "simulate", - }); - const { result } = renderHook(useMutations, { wrapper: Wrapper }); - - act(() => { - result.current.addPlace({ - id: "p1", - name: "P1", - colorId: null, - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }); - }); - - expect(mutateFn).not.toHaveBeenCalled(); - }); - - test("mutations no-op when simulation is Running", () => { - const { Wrapper, mutateFn } = createWrapper({ - simulationState: "Running", - }); - const { result } = renderHook(useMutations, { wrapper: Wrapper }); - - act(() => { - result.current.addTransition({ - id: "t1", - name: "T1", - inputArcs: [], - outputArcs: [], - lambdaType: "predicate", - lambdaCode: "", - transitionKernelCode: "", - x: 0, - y: 0, - }); - }); - - expect(mutateFn).not.toHaveBeenCalled(); - }); - - test("mutations no-op when simulation is Paused", () => { - const { Wrapper, mutateFn } = createWrapper({ - simulationState: "Paused", - }); - const { result } = renderHook(useMutations, { wrapper: Wrapper }); - - act(() => { - result.current.removeType({ typeId: "type-1" }); - }); - - expect(mutateFn).not.toHaveBeenCalled(); - }); - - test("mutations no-op when simulation is Complete", () => { - const { Wrapper, mutateFn } = createWrapper({ - simulationState: "Complete", - }); - const { result } = renderHook(useMutations, { wrapper: Wrapper }); - - act(() => { - result.current.removeParameter({ parameterId: "param-1" }); - }); - - expect(mutateFn).not.toHaveBeenCalled(); - }); - - test("commitNodePositions no-ops when readonly", () => { - const sdcpn = makeSDCPN({ - places: [ - { - id: "p1", - name: "P1", - colorId: null, - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }, - ], - }); - const { Wrapper, mutateFn } = createWrapper({ - sdcpn, - readonly: true, - }); - const { result } = renderHook(useMutations, { wrapper: Wrapper }); - - act(() => { - result.current.commitNodePositions({ - commits: [ - { id: "p1", itemType: "place", position: { x: 100, y: 200 } }, - ], - }); - }); - - expect(mutateFn).not.toHaveBeenCalled(); - }); - }); - - describe("cascading deletes", () => { - test("removePlace cascades to remove connected arcs", () => { - const sdcpn = makeSDCPN({ - places: [ - { - id: "p1", - name: "P1", - colorId: null, - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }, - { - id: "p2", - name: "P2", - colorId: null, - dynamicsEnabled: false, - differentialEquationId: null, - x: 100, - y: 0, - }, - ], - transitions: [ - { - id: "t1", - name: "T1", - inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], - outputArcs: [{ placeId: "p2", weight: 1 }], - lambdaType: "predicate", - lambdaCode: "", - transitionKernelCode: "", - x: 50, - y: 0, - }, - ], - }); - const { Wrapper, getSdcpn } = createWrapper({ sdcpn }); - const { result } = renderHook(useMutations, { wrapper: Wrapper }); - - act(() => { - result.current.removePlace({ placeId: "p1" }); - }); - - expect(getSdcpn().places).toHaveLength(1); - expect(getSdcpn().places[0]!.id).toBe("p2"); - expect(getSdcpn().transitions[0]!.inputArcs).toHaveLength(0); - expect(getSdcpn().transitions[0]!.outputArcs).toHaveLength(1); - }); - - test("removeType cascades to clear colorId on places and equations", () => { - const sdcpn = makeSDCPN({ - types: [ - { - id: "type-1", - name: "MyType", - iconSlug: "circle", - displayColor: "#f00", - elements: [], - }, - ], - places: [ - { - id: "p1", - name: "P1", - colorId: "type-1", - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }, - ], - differentialEquations: [ - { - id: "eq-1", - name: "Eq1", - colorId: "type-1", - code: "", - }, - ], - }); - const { Wrapper, getSdcpn } = createWrapper({ sdcpn }); - const { result } = renderHook(useMutations, { wrapper: Wrapper }); - - act(() => { - result.current.removeType({ typeId: "type-1" }); - }); - - expect(getSdcpn().types).toHaveLength(0); - expect(getSdcpn().places[0]!.colorId).toBeNull(); - expect(getSdcpn().differentialEquations[0]!.colorId).toBeNull(); - }); - - test("removeDifferentialEquation cascades to clear differentialEquationId on places", () => { - const sdcpn = makeSDCPN({ - differentialEquations: [ - { id: "eq-1", name: "Eq1", colorId: null, code: "" }, - ], - places: [ - { - id: "p1", - name: "P1", - colorId: null, - dynamicsEnabled: true, - differentialEquationId: "eq-1", - x: 0, - y: 0, - }, - ], - }); - const { Wrapper, getSdcpn } = createWrapper({ sdcpn }); - const { result } = renderHook(useMutations, { wrapper: Wrapper }); - - act(() => { - result.current.removeDifferentialEquation({ equationId: "eq-1" }); - }); - - expect(getSdcpn().differentialEquations).toHaveLength(0); - expect(getSdcpn().places[0]!.differentialEquationId).toBeNull(); - }); - - test("deleteItemsByIds handles mixed item types with cascading", () => { - const sdcpn = makeSDCPN({ - places: [ - { - id: "p1", - name: "P1", - colorId: "type-1", - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }, - { - id: "p2", - name: "P2", - colorId: null, - dynamicsEnabled: false, - differentialEquationId: null, - x: 100, - y: 0, - }, - ], - transitions: [ - { - id: "t1", - name: "T1", - inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], - outputArcs: [{ placeId: "p2", weight: 1 }], - lambdaType: "predicate", - lambdaCode: "", - transitionKernelCode: "", - x: 50, - y: 0, - }, - ], - types: [ - { - id: "type-1", - name: "T", - iconSlug: "circle", - displayColor: "#f00", - elements: [], - }, - ], - parameters: [ - { - id: "param-1", - name: "P", - variableName: "p", - type: "real", - defaultValue: "0", - }, - ], - }); - const { Wrapper, getSdcpn } = createWrapper({ sdcpn }); - const { result } = renderHook(useMutations, { wrapper: Wrapper }); - - const items = new Map([ - ["p1", { type: "place" as const, id: "p1" }], - ["type-1", { type: "type" as const, id: "type-1" }], - ["param-1", { type: "parameter" as const, id: "param-1" }], - ]); - - act(() => { - result.current.deleteItemsByIds({ items: Array.from(items.values()) }); - }); - - const final = getSdcpn(); - expect(final.places).toHaveLength(1); - expect(final.places[0]!.id).toBe("p2"); - expect(final.transitions[0]!.inputArcs).toHaveLength(0); - expect(final.types).toHaveLength(0); - expect(final.parameters).toHaveLength(0); - }); - }); -}); diff --git a/libs/@hashintel/petrinaut/src/react/mutation-provider.tsx b/libs/@hashintel/petrinaut/src/react/mutation-provider.tsx deleted file mode 100644 index 9eac7bee2ba..00000000000 --- a/libs/@hashintel/petrinaut/src/react/mutation-provider.tsx +++ /dev/null @@ -1,219 +0,0 @@ -import { use, type ReactNode } from "react"; - -import { - MutationContext, - type MutationContextValue, -} from "./state/mutation-context"; -import { SDCPNContext } from "./state/sdcpn-context"; -import { useIsReadOnly } from "./state/use-is-read-only"; -import { usePetrinautInstance } from "./use-petrinaut-instance"; - -/** - * Provides the mutation context surface, delegating all writes to the Core - * instance's actions. Read-only checks honour the editor mode (which lives in - * `EditorContext`) — only `readonly` blocks scenario mutations. - */ -export const MutationProvider: React.FC<{ children: ReactNode }> = ({ - children, -}) => { - const instance = usePetrinautInstance(); - const { readonly } = use(SDCPNContext); - const isReadOnly = useIsReadOnly(); - - function guardedMutate(callback: () => void): void { - if (isReadOnly) { - return; - } - callback(); - } - - /** - * Scenario CRUD is allowed even in simulate mode (the Simulate panel is - * where scenarios are managed). Only true `readonly` blocks them. - */ - function scenarioMutate(callback: () => void): void { - if (readonly) { - return; - } - callback(); - } - - const value: MutationContextValue = { - addPlace(place) { - guardedMutate(() => { - instance.addPlace(place); - }); - }, - updatePlace(input) { - guardedMutate(() => { - instance.updatePlace(input); - }); - }, - updatePlacePosition(input) { - guardedMutate(() => { - instance.updatePlacePosition(input); - }); - }, - removePlace(input) { - guardedMutate(() => { - instance.removePlace(input); - }); - }, - addTransition(transition) { - guardedMutate(() => { - instance.addTransition(transition); - }); - }, - updateTransition(input) { - guardedMutate(() => { - instance.updateTransition(input); - }); - }, - updateTransitionPosition(input) { - guardedMutate(() => { - instance.updateTransitionPosition(input); - }); - }, - removeTransition(input) { - guardedMutate(() => { - instance.removeTransition(input); - }); - }, - addArc(input) { - guardedMutate(() => { - instance.addArc(input); - }); - }, - removeArc(input) { - guardedMutate(() => { - instance.removeArc(input); - }); - }, - updateArcWeight(input) { - guardedMutate(() => { - instance.updateArcWeight(input); - }); - }, - updateArcType(input) { - guardedMutate(() => { - instance.updateArcType(input); - }); - }, - updateArcPlace(input) { - guardedMutate(() => { - instance.updateArcPlace(input); - }); - }, - addType(type) { - guardedMutate(() => { - instance.addType(type); - }); - }, - updateType(input) { - guardedMutate(() => { - instance.updateType(input); - }); - }, - removeType(input) { - guardedMutate(() => { - instance.removeType(input); - }); - }, - addTypeElement(input) { - guardedMutate(() => { - instance.addTypeElement(input); - }); - }, - updateTypeElement(input) { - guardedMutate(() => { - instance.updateTypeElement(input); - }); - }, - removeTypeElement(input) { - guardedMutate(() => { - instance.removeTypeElement(input); - }); - }, - moveTypeElement(input) { - guardedMutate(() => { - instance.moveTypeElement(input); - }); - }, - addDifferentialEquation(equation) { - guardedMutate(() => { - instance.addDifferentialEquation(equation); - }); - }, - updateDifferentialEquation(input) { - guardedMutate(() => { - instance.updateDifferentialEquation(input); - }); - }, - removeDifferentialEquation(input) { - guardedMutate(() => { - instance.removeDifferentialEquation(input); - }); - }, - addParameter(parameter) { - guardedMutate(() => { - instance.addParameter(parameter); - }); - }, - updateParameter(input) { - guardedMutate(() => { - instance.updateParameter(input); - }); - }, - removeParameter(input) { - guardedMutate(() => { - instance.removeParameter(input); - }); - }, - addScenario(scenario) { - scenarioMutate(() => { - instance.addScenario(scenario); - }); - }, - updateScenario(input) { - scenarioMutate(() => { - instance.updateScenario(input); - }); - }, - removeScenario(input) { - scenarioMutate(() => { - instance.removeScenario(input); - }); - }, - addMetric(metric) { - scenarioMutate(() => { - instance.addMetric(metric); - }); - }, - updateMetric(input) { - scenarioMutate(() => { - instance.updateMetric(input); - }); - }, - removeMetric(input) { - scenarioMutate(() => { - instance.removeMetric(input); - }); - }, - deleteItemsByIds(input) { - guardedMutate(() => { - instance.deleteItemsByIds(input); - }); - }, - commitNodePositions(input) { - guardedMutate(() => { - instance.commitNodePositions(input); - }); - }, - }; - - return ( - - {children} - - ); -}; diff --git a/libs/@hashintel/petrinaut/src/react/petrinaut-provider.tsx b/libs/@hashintel/petrinaut/src/react/petrinaut-provider.tsx index afadfd85d9a..88d65be87bd 100644 --- a/libs/@hashintel/petrinaut/src/react/petrinaut-provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/petrinaut-provider.tsx @@ -9,7 +9,6 @@ import { import { ExperimentsProvider } from "./experiments/provider"; import { PetrinautInstanceContext } from "./instance-context"; import { LanguageClientProvider } from "./lsp/provider"; -import { MutationProvider } from "./mutation-provider"; import { NetManagementContext, type NetManagement, @@ -79,9 +78,7 @@ export const PetrinautProvider: React.FC = ({ - - {children} - + {children} diff --git a/libs/@hashintel/petrinaut/src/react/playback/context.ts b/libs/@hashintel/petrinaut/src/react/playback/context.ts index 57793d69013..249aabc975b 100644 --- a/libs/@hashintel/petrinaut/src/react/playback/context.ts +++ b/libs/@hashintel/petrinaut/src/react/playback/context.ts @@ -126,7 +126,7 @@ const DEFAULT_CONTEXT_VALUE: PlaybackContextValue = { currentFrameIndex: 0, totalFrames: 0, playbackSpeed: 1, - playMode: "computeMax", + playMode: "computeBuffer", isViewOnlyAvailable: false, isComputeAvailable: true, setCurrentViewedFrame: () => {}, diff --git a/libs/@hashintel/petrinaut/src/react/state/editor-context.ts b/libs/@hashintel/petrinaut/src/react/state/editor-context.ts index 42596a818b7..e475cc16bd5 100644 --- a/libs/@hashintel/petrinaut/src/react/state/editor-context.ts +++ b/libs/@hashintel/petrinaut/src/react/state/editor-context.ts @@ -25,6 +25,15 @@ export type TimelineChartType = "run" | "stacked"; export type SimulateViewMode = "scenarios" | "metrics" | "experiments"; +export type SimulateDrawerState = + | { type: "closed" } + | { type: "view-scenario"; scenarioId: string } + | { type: "create-scenario" } + | { type: "view-metric"; metricId: string } + | { type: "create-metric" } + | { type: "view-experiment"; experimentId: string } + | { type: "create-experiment" }; + /** * What is rendered on the simulation timeline chart. * @@ -74,8 +83,10 @@ export type EditorState = { * button in the timeline header) can switch it. */ simulateViewMode: SimulateViewMode; + simulateDrawer: SimulateDrawerState; isPanelAnimating: boolean; isSearchOpen: boolean; + isAiAssistantOpen: boolean; }; /** @@ -123,7 +134,10 @@ export type EditorActions = { setTimelineChartType: (chartType: TimelineChartType) => void; setTimelineView: (view: TimelineView) => void; setSimulateViewMode: (mode: SimulateViewMode) => void; + setSimulateDrawer: (drawer: SimulateDrawerState) => void; setSearchOpen: (isOpen: boolean) => void; + setAiAssistantOpen: (isOpen: boolean) => void; + toggleAiAssistant: () => void; triggerPanelAnimation: () => void; __reinitialize: () => void; }; @@ -152,8 +166,10 @@ export const initialEditorState: EditorState = { timelineChartType: "run", timelineView: { kind: "per-place" }, simulateViewMode: "scenarios", + simulateDrawer: { type: "closed" }, isPanelAnimating: false, isSearchOpen: false, + isAiAssistantOpen: false, }; const DEFAULT_CONTEXT_VALUE: EditorContextValue = { @@ -188,7 +204,10 @@ const DEFAULT_CONTEXT_VALUE: EditorContextValue = { setTimelineChartType: () => {}, setTimelineView: () => {}, setSimulateViewMode: () => {}, + setSimulateDrawer: () => {}, setSearchOpen: () => {}, + setAiAssistantOpen: () => {}, + toggleAiAssistant: () => {}, searchInputRef: createRef(), triggerPanelAnimation: () => {}, __reinitialize: () => {}, diff --git a/libs/@hashintel/petrinaut/src/react/state/editor-provider.tsx b/libs/@hashintel/petrinaut/src/react/state/editor-provider.tsx index cbf16e5daed..896d8b70659 100644 --- a/libs/@hashintel/petrinaut/src/react/state/editor-provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/state/editor-provider.tsx @@ -218,6 +218,8 @@ export const EditorProvider: React.FC = ({ children }) => { setState((prev) => ({ ...prev, timelineView: view })), setSimulateViewMode: (mode) => setState((prev) => ({ ...prev, simulateViewMode: mode })), + setSimulateDrawer: (drawer) => + setState((prev) => ({ ...prev, simulateDrawer: drawer })), setSearchOpen: (isOpen) => { scheduleAnimationEnd(); setState((prev) => { @@ -234,6 +236,13 @@ export const EditorProvider: React.FC = ({ children }) => { }; }); }, + setAiAssistantOpen: (isOpen) => + setState((prev) => ({ ...prev, isAiAssistantOpen: isOpen })), + toggleAiAssistant: () => + setState((prev) => ({ + ...prev, + isAiAssistantOpen: !prev.isAiAssistantOpen, + })), triggerPanelAnimation: () => { scheduleAnimationEnd(); setState((prev) => ({ ...prev, ...animationPatch() })); diff --git a/libs/@hashintel/petrinaut/src/react/state/mutation-context.ts b/libs/@hashintel/petrinaut/src/react/state/mutation-context.ts deleted file mode 100644 index e8b33c411a3..00000000000 --- a/libs/@hashintel/petrinaut/src/react/state/mutation-context.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { createContext } from "react"; - -import type { MutationHelperFunctions } from "@hashintel/petrinaut-core"; - -export type MutationContextValue = MutationHelperFunctions; - -const DEFAULT_CONTEXT_VALUE: MutationContextValue = { - addPlace: () => {}, - updatePlace: () => {}, - updatePlacePosition: () => {}, - removePlace: () => {}, - addTransition: () => {}, - updateTransition: () => {}, - updateTransitionPosition: () => {}, - removeTransition: () => {}, - addArc: () => {}, - removeArc: () => {}, - updateArcWeight: () => {}, - updateArcType: () => {}, - updateArcPlace: () => {}, - addType: () => {}, - updateType: () => {}, - removeType: () => {}, - addTypeElement: () => {}, - updateTypeElement: () => {}, - removeTypeElement: () => {}, - moveTypeElement: () => {}, - addDifferentialEquation: () => {}, - updateDifferentialEquation: () => {}, - removeDifferentialEquation: () => {}, - addParameter: () => {}, - updateParameter: () => {}, - removeParameter: () => {}, - addScenario: () => {}, - updateScenario: () => {}, - removeScenario: () => {}, - addMetric: () => {}, - updateMetric: () => {}, - removeMetric: () => {}, - deleteItemsByIds: () => {}, - commitNodePositions: () => {}, -}; - -export const MutationContext = createContext( - DEFAULT_CONTEXT_VALUE, -); diff --git a/libs/@hashintel/petrinaut/src/react/state/simulate-mode-allowed-mutation-names.ts b/libs/@hashintel/petrinaut/src/react/state/simulate-mode-allowed-mutation-names.ts new file mode 100644 index 00000000000..8800f95d344 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/react/state/simulate-mode-allowed-mutation-names.ts @@ -0,0 +1,18 @@ +import type { PetrinautMutations } from "@hashintel/petrinaut-core"; + +/** + * Mutations remain available in simulate mode. + * Only the host `readonly` flag blocks them — + * neither a `globalMode === "simulate"` switch + * nor an active simulation (Running / Paused / Complete) disables them + */ +export const simulateModeAllowedMutationNames = new Set< + keyof PetrinautMutations +>([ + "addScenario", + "updateScenario", + "removeScenario", + "addMetric", + "updateMetric", + "removeMetric", +]); diff --git a/libs/@hashintel/petrinaut/src/react/state/use-is-read-only.ts b/libs/@hashintel/petrinaut/src/react/state/use-is-read-only.ts index 30b76a6aef3..f0cb8a05334 100644 --- a/libs/@hashintel/petrinaut/src/react/state/use-is-read-only.ts +++ b/libs/@hashintel/petrinaut/src/react/state/use-is-read-only.ts @@ -1,8 +1,4 @@ -import { use } from "react"; - -import { SimulationContext } from "../simulation/context"; -import { EditorContext } from "./editor-context"; -import { SDCPNContext } from "./sdcpn-context"; +import { useReadOnlyReason } from "./use-read-only-reason"; /** * Hook that determines if the editor is in read-only mode. @@ -12,18 +8,7 @@ import { SDCPNContext } from "./sdcpn-context"; * 2. The global mode is "simulate" (user has switched to simulation mode) * 3. A simulation is currently running, paused, or complete * - * When read-only, structural changes to the SDCPN (places, transitions, arcs, etc.) - * are prevented. + * For a structured refusal reason (e.g. for AI tool feedback), use + * {@link useReadOnlyReason} directly. */ -export const useIsReadOnly = (): boolean => { - const { readonly } = use(SDCPNContext); - const { globalMode } = use(EditorContext); - const { state: simulationState } = use(SimulationContext); - - const isSimulationActive = - simulationState === "Running" || - simulationState === "Paused" || - simulationState === "Complete"; - - return readonly || globalMode === "simulate" || isSimulationActive; -}; +export const useIsReadOnly = (): boolean => useReadOnlyReason() !== null; diff --git a/libs/@hashintel/petrinaut/src/react/state/use-read-only-reason.ts b/libs/@hashintel/petrinaut/src/react/state/use-read-only-reason.ts new file mode 100644 index 00000000000..06b7ff75a73 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/react/state/use-read-only-reason.ts @@ -0,0 +1,59 @@ +import { use } from "react"; + +import { SimulationContext } from "../simulation/context"; +import { EditorContext } from "./editor-context"; +import { SDCPNContext } from "./sdcpn-context"; + +/** + * Why the editor currently disallows mutations, or `null` when mutations + * are allowed. + * + * - `host-readonly`: the consumer passed `readonly` to ``. + * - `simulate-mode`: the user has switched to simulate mode. + * - `simulation-active`: a simulation is Running, Paused, or Complete. + */ +export type ReadOnlyReason = + | { kind: "host-readonly" } + | { kind: "simulate-mode" } + | { kind: "simulation-active"; state: "Running" | "Paused" | "Complete" }; + +/** + * Single source of truth for "is the document currently writable" plus a + * structured reason for refusal. UI consumers that only need a boolean can + * use {@link useIsReadOnly}, which collapses this to `reason !== null`. + */ +export const useReadOnlyReason = (): ReadOnlyReason | null => { + const { readonly } = use(SDCPNContext); + const { globalMode } = use(EditorContext); + const { state: simulationState } = use(SimulationContext); + + if (readonly) { + return { kind: "host-readonly" }; + } + if (globalMode === "simulate") { + return { kind: "simulate-mode" }; + } + if ( + simulationState === "Running" || + simulationState === "Paused" || + simulationState === "Complete" + ) { + return { kind: "simulation-active", state: simulationState }; + } + return null; +}; + +/** + * Human-readable explanation for a refusal — used to surface refusal + * feedback to the AI tool dispatcher. + */ +export const formatReadOnlyReason = (reason: ReadOnlyReason): string => { + switch (reason.kind) { + case "host-readonly": + return "This document is read-only; mutations are disabled."; + case "simulate-mode": + return "The editor is in simulate mode. Ask the user to switch reset the simulation before mutating."; + case "simulation-active": + return `A simulation is currently ${reason.state.toLowerCase()}. Ask the user to reset the simulation before mutating.`; + } +}; diff --git a/libs/@hashintel/petrinaut/src/react/use-petrinaut-instance.ts b/libs/@hashintel/petrinaut/src/react/use-petrinaut-instance.ts index d8e973f368f..2047ec65ff1 100644 --- a/libs/@hashintel/petrinaut/src/react/use-petrinaut-instance.ts +++ b/libs/@hashintel/petrinaut/src/react/use-petrinaut-instance.ts @@ -4,7 +4,16 @@ import { PetrinautInstanceContext } from "./instance-context"; import type { Petrinaut } from "@hashintel/petrinaut-core"; -export function usePetrinautInstance(): Petrinaut { +/** + * Public-facing view of the {@link Petrinaut} instance. The mutation and + * command surfaces are intentionally stripped so React components must reach + * them through {@link usePetrinautMutations} / {@link usePetrinautCommands} + * (which apply the read-only guards) rather than calling them directly on + * the raw instance. + */ +export type PetrinautReactInstance = Omit; + +export function usePetrinautInstance(): PetrinautReactInstance { const instance = use(PetrinautInstanceContext); if (!instance) { throw new Error( diff --git a/libs/@hashintel/petrinaut/src/ui/clipboard/clipboard.ts b/libs/@hashintel/petrinaut/src/ui/clipboard/clipboard.ts index ad1bc0bec56..9f9ee010170 100644 --- a/libs/@hashintel/petrinaut/src/ui/clipboard/clipboard.ts +++ b/libs/@hashintel/petrinaut/src/ui/clipboard/clipboard.ts @@ -1,11 +1,12 @@ import { - pastePayloadIntoSDCPN, parseClipboardPayload, serializeSelection, type SDCPN, type SelectionMap, } from "@hashintel/petrinaut-core"; +import type { PetrinautCommands } from "../../react"; + /** * Copy the current selection to the system clipboard. */ @@ -24,12 +25,13 @@ export async function copySelectionToClipboard( } /** - * Read from the system clipboard and paste into the SDCPN. - * Returns the IDs of newly created items (for selection), or null if clipboard - * didn't contain valid petrinaut data. + * Read from the system clipboard and paste into the SDCPN via the typed + * `applyClipboardPaste` command. Returns the IDs of newly created items + * (for selection), or `null` if the clipboard did not contain valid + * petrinaut data. */ export async function pasteFromClipboard( - mutatePetriNetDefinition: (mutateFn: (sdcpn: SDCPN) => void) => void, + applyClipboardPaste: PetrinautCommands["applyClipboardPaste"], ): Promise | null> { let text: string; try { @@ -44,11 +46,6 @@ export async function pasteFromClipboard( return null; } - let newItemIds: Array<{ type: string; id: string }> = []; - mutatePetriNetDefinition((sdcpn) => { - const result = pastePayloadIntoSDCPN(sdcpn, payload); - newItemIds = result.newItemIds; - }); - + const { newItemIds } = applyClipboardPaste({ payload }); return newItemIds; } diff --git a/libs/@hashintel/petrinaut/src/ui/components/ai-assistant-icon.tsx b/libs/@hashintel/petrinaut/src/ui/components/ai-assistant-icon.tsx new file mode 100644 index 00000000000..bf16420cb48 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/components/ai-assistant-icon.tsx @@ -0,0 +1,53 @@ +import type { SVGProps } from "react"; + +type AiAssistantIconProps = SVGProps & { + size?: number | string; + title?: string; +}; + +export const AiAssistantIcon = ({ + size = 15, + title, + ...props +}: AiAssistantIconProps) => ( + + {title && {title}} + + + + + +); diff --git a/libs/@hashintel/petrinaut/src/ui/components/popover.tsx b/libs/@hashintel/petrinaut/src/ui/components/popover.tsx index 64b77659959..88a0f77421e 100644 --- a/libs/@hashintel/petrinaut/src/ui/components/popover.tsx +++ b/libs/@hashintel/petrinaut/src/ui/components/popover.tsx @@ -6,7 +6,7 @@ import { css, cx } from "@hashintel/ds-helpers/css"; import { usePortalContainerRef } from "../../react/state/portal-container-context"; import { Button } from "./button"; -import type { ComponentProps, ReactNode } from "react"; +import type { ComponentProps, HTMLAttributes, ReactNode } from "react"; // -- Styles ------------------------------------------------------------------ @@ -74,7 +74,8 @@ const sectionLabelStyle = css({ const Content = ({ children, className, -}: { + ...props +}: HTMLAttributes & { children: ReactNode; className?: string; }) => { @@ -83,7 +84,7 @@ const Content = ({ return ( - + {children} diff --git a/libs/@hashintel/petrinaut/src/ui/index.ts b/libs/@hashintel/petrinaut/src/ui/index.ts index 2855e2909e7..b54d29297fe 100644 --- a/libs/@hashintel/petrinaut/src/ui/index.ts +++ b/libs/@hashintel/petrinaut/src/ui/index.ts @@ -5,7 +5,13 @@ // `` (`/react`). export { Petrinaut } from "./petrinaut"; -export type { PetrinautProps } from "./petrinaut"; +export type { PetrinautAiMessage } from "./views/Editor/panels/ai-assistant-panel"; +export type { + PetrinautAiAssistant, + PetrinautAiChatTransport, + PetrinautProps, +} from "./petrinaut"; +export { DefaultChatTransport } from "ai"; // SDCPN value-equality check exposed for consumers that need to detect // no-op changes outside the handle (e.g. memoising Storybook stories). diff --git a/libs/@hashintel/petrinaut/src/ui/petrinaut-story-provider.tsx b/libs/@hashintel/petrinaut/src/ui/petrinaut-story-provider.tsx index ba9f237c59e..b9062f10817 100644 --- a/libs/@hashintel/petrinaut/src/ui/petrinaut-story-provider.tsx +++ b/libs/@hashintel/petrinaut/src/ui/petrinaut-story-provider.tsx @@ -7,7 +7,7 @@ import { type SDCPN, } from "@hashintel/petrinaut-core"; -import { Petrinaut } from "./petrinaut"; +import { Petrinaut, type PetrinautAiAssistant } from "./petrinaut"; const emptySDCPN: SDCPN = { places: [], @@ -34,12 +34,14 @@ type HandlesByNetId = Record; * history survives switching between nets. */ export const PetrinautStoryProvider = ({ + aiAssistant, initialTitle = "New Process", initialDefinition = emptySDCPN, hideNetManagementControls = false, readonly = false, children, }: { + aiAssistant?: PetrinautAiAssistant; initialTitle?: string; initialDefinition?: SDCPN; hideNetManagementControls?: boolean; @@ -139,6 +141,7 @@ export const PetrinautStoryProvider = ({ return ( <> ), }; + +export const WithAiAssistant: Story = { + render: () => ( +
+ +
+ ), +}; + +const HandleSpikeRender = ({ + aiAssistant, + initial, + initialTitle, +}: { + aiAssistant?: { + transport: ReturnType; + }; + initial: SDCPN; + initialTitle: string; +}) => { + const handle = useMemo( + () => createJsonDocHandle({ id: "spike-net", initial }), + [initial], + ); + + const [patchLog, setPatchLog] = useState([]); + const [title, setTitle] = useState(initialTitle); + + useEffect(() => { + return handle.subscribe((event) => { + const summary = (event.patches ?? []).map( + (p) => `${p.op} /${p.path.join("/")}`, + ); + setPatchLog((prev) => [...summary, ...prev].slice(0, 12)); + }); + }, [handle]); + + return ( +
+ +
+        {`Last ${patchLog.length} patches (newest first):\n` +
+          (patchLog.length === 0 ? "(no mutations yet)" : patchLog.join("\n"))}
+      
+
+ ); +}; + +export const HandleSpike: Story = { + render: () => ( + + ), +}; + +export const HandleSpikeWithSir: Story = { + render: () => ( + + ), +}; + +export const HandleSpikeWithAi: Story = { + render: () => ( + + ), +}; diff --git a/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx b/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx index 7804ab40422..ebe13e18538 100644 --- a/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx +++ b/libs/@hashintel/petrinaut/src/ui/petrinaut.tsx @@ -19,6 +19,20 @@ import { PetrinautProvider } from "../react/petrinaut-provider"; import { MonacoProvider } from "./monaco/provider"; import { EditorView } from "./views/Editor/editor-view"; +import type { + PetrinautAiMessage, + PetrinautAiTransport, +} from "./views/Editor/panels/ai-assistant-panel"; + +export type PetrinautAiChatTransport = PetrinautAiTransport; + +export type PetrinautAiAssistant = { + messages?: PetrinautAiMessage[]; + onClearMessages?: () => void; + onMessages?: (messages: PetrinautAiMessage[]) => void; + transport: PetrinautAiTransport; +}; + import type { NetManagement } from "../react/net-management-context"; import type { ViewportAction } from "./types/viewport-action"; @@ -31,6 +45,7 @@ export type PetrinautProps = { existingNets?: MinimalNetMetadata[]; createNewNet?: (params: { petriNetDefinition: SDCPN; title: string }) => void; loadPetriNet?: (petriNetId: string) => void; + aiAssistant?: PetrinautAiAssistant; viewportActions?: ViewportAction[]; /** * Optional simulation-worker factory. Provide this when the host bundler @@ -74,6 +89,7 @@ export const Petrinaut: FunctionComponent = ({ existingNets = [], createNewNet = noop, loadPetriNet = noop, + aiAssistant, viewportActions, simulationWorkerFactory, monteCarloWorkerFactory, @@ -104,6 +120,7 @@ export const Petrinaut: FunctionComponent = ({ > diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx index a0fb19c34c3..b59bebe814b 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/bottom-bar.tsx @@ -10,6 +10,7 @@ import { EditorContext, type EditorState, } from "../../../../../react/state/editor-context"; +import { AiAssistantIcon } from "../../../../components/ai-assistant-icon"; import { DiagnosticsIndicator } from "./diagnostics-indicator"; import { SimulationControls } from "./simulation-controls"; import { ToolbarButton } from "./toolbar-button"; @@ -65,11 +66,13 @@ interface BottomBarProps { onEditionModeChange: (mode: EditorEditionMode) => void; cursorMode: CursorMode; onCursorModeChange: (mode: CursorMode) => void; + hasAiAssistant: boolean; } export const BottomBar: React.FC = ({ mode, editionMode, + hasAiAssistant, onEditionModeChange, cursorMode, onCursorModeChange, @@ -79,7 +82,9 @@ export const BottomBar: React.FC = ({ setBottomPanelOpen, setActiveBottomPanelTab, bottomPanelHeight, + isAiAssistantOpen, isPanelAnimating, + toggleAiAssistant, } = use(EditorContext); const { totalDiagnosticsCount } = use(LanguageClientContext); @@ -134,6 +139,24 @@ export const BottomBar: React.FC = ({ cursorMode={cursorMode} onCursorModeChange={onCursorModeChange} /> + {hasAiAssistant && ( + <> + + + + + + )}
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts index 590138d2a91..7b57f43f6ed 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts @@ -1,11 +1,13 @@ import { use, useEffect, useEffectEvent } from "react"; +import { + usePetrinautMutations, + usePetrinautCommands, +} from "../../../../../react"; import { EditorContext } from "../../../../../react/state/editor-context"; -import { MutationContext } from "../../../../../react/state/mutation-context"; import { SDCPNContext } from "../../../../../react/state/sdcpn-context"; import { UndoRedoContext } from "../../../../../react/state/undo-redo-context"; import { useIsReadOnly } from "../../../../../react/state/use-is-read-only"; -import { usePetrinautInstance } from "../../../../../react/use-petrinaut-instance"; import { copySelectionToClipboard, pasteFromClipboard, @@ -36,8 +38,8 @@ export function useKeyboardShortcuts( searchInputRef, } = use(EditorContext); const { petriNetDefinition, petriNetId } = use(SDCPNContext); - const { deleteItemsByIds } = use(MutationContext); - const instance = usePetrinautInstance(); + const { deleteItemsByIds } = usePetrinautMutations(); + const { applyClipboardPaste } = usePetrinautCommands(); const isReadonly = useIsReadOnly(); const handleKeyDown = useEffectEvent((event: KeyboardEvent) => { @@ -111,7 +113,7 @@ export function useKeyboardShortcuts( if (key === "v" && !isReadonly) { event.preventDefault(); - void pasteFromClipboard(instance.mutate).then((newItemIds) => { + void pasteFromClipboard(applyClipboardPaste).then((newItemIds) => { if (newItemIds && newItemIds.length > 0) { setSelection( new Map( diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/components/ai-cta-modal.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/ai-cta-modal.tsx new file mode 100644 index 00000000000..3722f74cc98 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/components/ai-cta-modal.tsx @@ -0,0 +1,211 @@ +import { useRef, useEffect, useState } from "react"; + +import { Button } from "@hashintel/ds-components"; +import { css } from "@hashintel/ds-helpers/css"; + +import { AiAssistantIcon } from "../../../components/ai-assistant-icon"; +import { Input } from "../../../components/input"; + +const aiCtaModalLayerStyle = css({ + position: "absolute", + inset: "0", + zIndex: 20, + display: "flex", + alignItems: "center", + justifyContent: "center", + padding: "8", + pointerEvents: "none", +}); + +const aiCtaModalStyle = css({ + position: "relative", + pointerEvents: "auto", + display: "flex", + flexDirection: "column", + alignItems: "center", + gap: "5", + width: "[min(560px, calc(100% - 48px))]", + padding: "[28px]", + borderRadius: "[24px]", + borderWidth: "thin", + borderStyle: "solid", + borderColor: "blue.a30", + backgroundColor: "white.a95", + boxShadow: + "[0px 20px 60px rgba(15, 23, 42, 0.18), 0px 2px 8px rgba(15, 23, 42, 0.08), inset 0px 1px 0px rgba(255, 255, 255, 0.9)]", + textAlign: "center", + userSelect: "text", + backdropFilter: "[blur(14px)]", +}); + +const aiCtaModalCloseStyle = css({ + position: "absolute", + top: "3", + right: "3", +}); + +const aiCtaModalIconStyle = css({ + display: "flex", + alignItems: "center", + justifyContent: "center", + width: "[56px]", + height: "[56px]", + borderRadius: "2xl", + backgroundColor: "blue.s20", + boxShadow: "[0px 0px 0px 8px rgba(42, 128, 200, 0.08)]", + color: "blue.s90", +}); + +const aiCtaModalCopyStyle = css({ + display: "flex", + flexDirection: "column", + gap: "2", + maxWidth: "[420px]", +}); + +const aiCtaModalTitleStyle = css({ + margin: "0", + color: "neutral.s110", + fontFamily: "[Inter Tight, Inter, sans-serif]", + fontSize: "[24px]", + fontWeight: "semibold", + lineHeight: "[30px]", +}); + +const aiCtaModalFormStyle = css({ + display: "flex", + alignItems: "center", + gap: "2", + width: "full", + padding: "1.5", + borderRadius: "[20px]", + backgroundColor: "neutral.s00", + boxShadow: + "[0px 0px 0px 1px rgba(15, 23, 42, 0.08), 0px 12px 28px rgba(15, 23, 42, 0.12)]", +}); + +const aiCtaModalInputStyle = css({ + flex: "[1]", + minWidth: "[0]", + height: "[48px]", + borderColor: "[transparent]", + backgroundColor: "[transparent]", + boxShadow: "[none]", + fontSize: "base", + _hover: { + borderColor: "[transparent]", + }, + _focus: { + borderColor: "[transparent]", + boxShadow: "[none]", + }, + _active: { + borderColor: "[transparent]", + boxShadow: "[none]", + }, +}); + +export const AiCtaModal = ({ + bottomClearance, + onDismiss, + onSubmit, +}: { + bottomClearance: number; + onDismiss: () => void; + onSubmit: (message: string) => void; +}) => { + const [promptInput, setPromptInput] = useState(""); + + const canSubmit = promptInput.trim().length > 0; + const inputRef = useRef(null); + const formRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + useEffect(() => { + const handlePointerDown = (event: MouseEvent) => { + const target = event.target; + if (!(target instanceof Node)) { + return; + } + if (formRef.current?.contains(target)) { + return; + } + onDismiss(); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onDismiss(); + } + }; + + document.addEventListener("mousedown", handlePointerDown, true); + document.addEventListener("keydown", handleKeyDown); + + return () => { + document.removeEventListener("mousedown", handlePointerDown, true); + document.removeEventListener("keydown", handleKeyDown); + }; + }, [onDismiss]); + + return ( +
+
{ + event.preventDefault(); + const trimmedInput = promptInput.trim(); + if (!trimmedInput) { + return; + } + + onSubmit(trimmedInput); + }} + > +
+ + + ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx index 0ebd706c481..dca0f6bbcdf 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx @@ -1,6 +1,7 @@ import { use, useRef, useState } from "react"; import { css, cx } from "@hashintel/ds-helpers/css"; +import { calculateGraphLayout, type SDCPN } from "@hashintel/petrinaut-core"; import { deploymentPipelineSDCPN, probabilisticSatellitesSDCPN, @@ -10,9 +11,9 @@ import { supplyChainStochasticSDCPN, } from "@hashintel/petrinaut-core/examples"; +import { usePetrinautCommands } from "../../../react"; import { ExperimentsContext } from "../../../react/experiments/context"; import { EditorContext } from "../../../react/state/editor-context"; -import { MutationContext } from "../../../react/state/mutation-context"; import { PortalContainerContext } from "../../../react/state/portal-container-context"; import { SDCPNContext } from "../../../react/state/sdcpn-context"; import { useSelectionCleanup } from "../../../react/state/use-selection-cleanup"; @@ -22,21 +23,22 @@ import { Stack } from "../../components/stack"; import { exportSDCPN } from "../../file-io/export-sdcpn"; import { exportTikZ } from "../../file-io/export-tikz"; import { importSDCPN } from "../../file-io/import-sdcpn"; -import { calculateGraphLayout } from "../../lib/calculate-graph-layout"; import { classicNodeDimensions, compactNodeDimensions, } from "../SDCPN/node-dimensions"; import { SDCPNView } from "../SDCPN/sdcpn-view"; +import { AiCtaModal } from "./components/ai-cta-modal"; import { BottomBar } from "./components/BottomBar/bottom-bar"; import { ImportErrorDialog } from "./components/import-error-dialog"; import { TopBar } from "./components/TopBar/top-bar"; +import { AiAssistantPanel } from "./panels/ai-assistant-panel"; import { BottomPanel } from "./panels/BottomPanel/panel"; import { LeftSideBar } from "./panels/LeftSideBar/panel"; import { PropertiesPanel } from "./panels/PropertiesPanel/panel"; import { SimulateView } from "./panels/SimulateView/simulate-view"; -import { runAutoLayout } from "./run-auto-layout"; +import type { PetrinautAiAssistant } from "../../petrinaut"; import type { ViewportAction } from "../../types/viewport-action"; const relativeTimeFormat = new Intl.RelativeTimeFormat("en", { @@ -93,14 +95,23 @@ const portalContainerStyle = css({ pointerEvents: "none", }); +const isEmptySDCPN = (sdcpn: SDCPN) => + sdcpn.places.length === 0 && + sdcpn.transitions.length === 0 && + sdcpn.types.length === 0 && + sdcpn.parameters.length === 0 && + sdcpn.differentialEquations.length === 0; + /** * EditorView is responsible for the overall editor UI layout and controls. * It relies on sdcpn-store and editor-store for state, and uses SDCPNView for visualization. */ export const EditorView = ({ + aiAssistant, hideNetManagementControls, viewportActions, }: { + aiAssistant?: PetrinautAiAssistant; hideNetManagementControls: boolean; viewportActions?: ViewportAction[]; }) => { @@ -110,14 +121,16 @@ export const EditorView = ({ existingNets, loadPetriNet, petriNetDefinition, + petriNetId, title, setTitle, } = use(SDCPNContext); - const { commitNodePositions } = use(MutationContext); + const { applyAutoLayout } = usePetrinautCommands(); // Get editor context const { globalMode: mode, + isAiAssistantOpen, setGlobalMode, editionMode, setEditionMode, @@ -125,20 +138,20 @@ export const EditorView = ({ setCursorMode, clearSelection, setSimulateViewMode, + setAiAssistantOpen, + isBottomPanelOpen, + bottomPanelHeight, } = use(EditorContext); const { setSelectedExperimentId } = use(ExperimentsContext); + const [pendingAiAssistantMessage, setPendingAiAssistantMessage] = useState< + string | null + >(null); + const [isAiCtaDismissed, setIsAiCtaDismissed] = useState(false); + const { compactNodes } = use(UserSettingsContext); const dims = compactNodes ? compactNodeDimensions : classicNodeDimensions; - async function handleLayout() { - await runAutoLayout({ - sdcpn: petriNetDefinition, - dimensions: dims, - commitNodePositions, - }); - } - const [importError, setImportError] = useState(null); // Clean up stale selections when items are deleted @@ -286,7 +299,7 @@ export const EditorView = ({ { id: "layout", label: "Layout", - onClick: handleLayout, + onClick: applyAutoLayout, }, ...(!hideNetManagementControls ? [ @@ -361,6 +374,12 @@ export const EditorView = ({ const portalContainerRef = useRef(null); + const showEmptyAiHero = + aiAssistant !== undefined && + !isAiAssistantOpen && + !isAiCtaDismissed && + isEmptySDCPN(petriNetDefinition); + return ( @@ -404,6 +423,17 @@ export const EditorView = ({ {/* SDCPN Visualization */} + {showEmptyAiHero && ( + setIsAiCtaDismissed(true)} + onSubmit={(message) => { + setPendingAiAssistantMessage(message); + setAiAssistantOpen(true); + }} + /> + )} + {/* Bottom Panel - Diagnostics, Simulation Settings */} @@ -413,7 +443,20 @@ export const EditorView = ({ onEditionModeChange={setEditionMode} cursorMode={cursorMode} onCursorModeChange={setCursorMode} + hasAiAssistant={aiAssistant !== undefined} /> + + {aiAssistant && ( + + setPendingAiAssistantMessage(null) + } + /> + )} )} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx index 1e9d80e3cbd..80517eea479 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx @@ -4,8 +4,8 @@ import { v4 as uuidv4 } from "uuid"; import { Icon } from "@hashintel/ds-components"; import { DEFAULT_DIFFERENTIAL_EQUATION_CODE } from "@hashintel/petrinaut-core"; +import { usePetrinautMutations } from "../../../../../../react/hooks/use-petrinaut-mutations"; import { EditorContext } from "../../../../../../react/state/editor-context"; -import { MutationContext } from "../../../../../../react/state/mutation-context"; import { SDCPNContext } from "../../../../../../react/state/sdcpn-context"; import { useIsReadOnly } from "../../../../../../react/state/use-is-read-only"; import { Button } from "../../../../../components/button"; @@ -25,7 +25,7 @@ export const DifferentialEquationsSectionHeaderAction: React.FC = () => { const { petriNetDefinition: { types, differentialEquations }, } = use(SDCPNContext); - const { addDifferentialEquation } = use(MutationContext); + const { addDifferentialEquation } = usePetrinautMutations(); const { selectItem } = use(EditorContext); const isReadOnly = useIsReadOnly(); @@ -57,7 +57,7 @@ export const DifferentialEquationsSectionHeaderAction: React.FC = () => { }; const DiffEqRowMenu: React.FC<{ item: { id: string } }> = ({ item }) => { - const { removeDifferentialEquation } = use(MutationContext); + const { removeDifferentialEquation } = usePetrinautMutations(); const isReadOnly = useIsReadOnly(); return ( diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/entities-tree.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/entities-tree.tsx index 7e9e156bb3b..906c2915f2c 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/entities-tree.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/entities-tree.tsx @@ -3,8 +3,8 @@ import { use } from "react"; import { Icon } from "@hashintel/ds-components"; import { css } from "@hashintel/ds-helpers/css"; +import { usePetrinautMutations } from "../../../../../../react"; import { EditorContext } from "../../../../../../react/state/editor-context"; -import { MutationContext } from "../../../../../../react/state/mutation-context"; import { SDCPNContext } from "../../../../../../react/state/sdcpn-context"; import { useIsReadOnly } from "../../../../../../react/state/use-is-read-only"; import { @@ -47,7 +47,7 @@ interface EntityTreeItem { const EntityRowMenu: React.FC<{ item: EntityTreeItem }> = ({ item }) => { const { removeType, removeDifferentialEquation, removeParameter } = - use(MutationContext); + usePetrinautMutations(); const { globalMode } = use(EditorContext); const isReadOnly = useIsReadOnly(); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx index 2a7df00434e..b619dc1fcbc 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx @@ -4,8 +4,8 @@ import { v4 as uuidv4 } from "uuid"; import { Icon } from "@hashintel/ds-components"; import { css } from "@hashintel/ds-helpers/css"; +import { usePetrinautMutations } from "../../../../../../react/hooks/use-petrinaut-mutations"; import { EditorContext } from "../../../../../../react/state/editor-context"; -import { MutationContext } from "../../../../../../react/state/mutation-context"; import { SDCPNContext } from "../../../../../../react/state/sdcpn-context"; import { useIsReadOnly } from "../../../../../../react/state/use-is-read-only"; import { Button } from "../../../../../components/button"; @@ -33,7 +33,7 @@ export const ParametersHeaderAction: React.FC = () => { const { petriNetDefinition: { parameters }, } = use(SDCPNContext); - const { addParameter } = use(MutationContext); + const { addParameter } = usePetrinautMutations(); const { selectItem } = use(EditorContext); const isReadOnly = useIsReadOnly(); @@ -66,7 +66,7 @@ export const ParametersHeaderAction: React.FC = () => { }; const ParameterRowMenu: React.FC<{ item: { id: string } }> = ({ item }) => { - const { removeParameter } = use(MutationContext); + const { removeParameter } = usePetrinautMutations(); const { globalMode } = use(EditorContext); const isReadOnly = useIsReadOnly(); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/types-list.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/types-list.tsx index 494bbaafc2c..836a76c6c20 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/types-list.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/LeftSideBar/subviews/types-list.tsx @@ -2,8 +2,8 @@ import { use } from "react"; import { Icon } from "@hashintel/ds-components"; +import { usePetrinautMutations } from "../../../../../../react/hooks/use-petrinaut-mutations"; import { EditorContext } from "../../../../../../react/state/editor-context"; -import { MutationContext } from "../../../../../../react/state/mutation-context"; import { SDCPNContext } from "../../../../../../react/state/sdcpn-context"; import { useIsReadOnly } from "../../../../../../react/state/use-is-read-only"; import { Button } from "../../../../../components/button"; @@ -67,7 +67,7 @@ export const TypesSectionHeaderAction: React.FC = () => { const { petriNetDefinition: { types }, } = use(SDCPNContext); - const { addType } = use(MutationContext); + const { addType } = usePetrinautMutations(); const { selectItem } = use(EditorContext); const isReadOnly = useIsReadOnly(); @@ -109,7 +109,7 @@ export const TypesSectionHeaderAction: React.FC = () => { }; const TypeRowMenu: React.FC<{ item: { id: string } }> = ({ item }) => { - const { removeType } = use(MutationContext); + const { removeType } = usePetrinautMutations(); const isReadOnly = useIsReadOnly(); return ( diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/arc-properties/main.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/arc-properties/main.tsx index 0b9e17d96d7..37e7ef760b3 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/arc-properties/main.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/arc-properties/main.tsx @@ -16,7 +16,7 @@ import { Select } from "../../../../../components/select"; import { VerticalSubViewsContainer } from "../../../../../components/sub-view/vertical/vertical-sub-views-container"; import { UI_MESSAGES } from "../../../../../constants/ui-messages"; -import type { MutationContextValue } from "../../../../../../react/state/mutation-context"; +import type { PetrinautMutations } from "../../../../../../react"; import type { SubView } from "../../../../../components/sub-view/types"; const containerStyle = css({ @@ -41,9 +41,9 @@ interface ArcPropertiesData { targetName: string; weight: number; type: "standard" | "inhibitor"; - updateArcWeight: MutationContextValue["updateArcWeight"]; - updateArcType: MutationContextValue["updateArcType"]; - removeArc: MutationContextValue["removeArc"]; + updateArcWeight: PetrinautMutations["updateArcWeight"]; + updateArcType: PetrinautMutations["updateArcType"]; + removeArc: PetrinautMutations["removeArc"]; } const ArcPropertiesContext = createContext(null); @@ -166,9 +166,9 @@ const subViews: SubView[] = [arcMainContentSubView]; interface ArcPropertiesProps { arcId: string; petriNetDefinition: SDCPN; - updateArcWeight: MutationContextValue["updateArcWeight"]; - updateArcType: MutationContextValue["updateArcType"]; - removeArc: MutationContextValue["removeArc"]; + updateArcWeight: PetrinautMutations["updateArcWeight"]; + updateArcType: PetrinautMutations["updateArcType"]; + removeArc: PetrinautMutations["removeArc"]; } export const ArcProperties: React.FC = ({ diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/differential-equation-properties/context.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/differential-equation-properties/context.tsx index 14188ab745b..ed20a0d49eb 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/differential-equation-properties/context.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/differential-equation-properties/context.tsx @@ -1,6 +1,6 @@ import { createContext, use } from "react"; -import type { MutationContextValue } from "../../../../../../react/state/mutation-context"; +import type { PetrinautMutations } from "../../../../../../react"; import type { Color, DifferentialEquation, @@ -11,7 +11,7 @@ export interface DiffEqPropertiesContextValue { differentialEquation: DifferentialEquation; types: Color[]; places: Place[]; - updateDifferentialEquation: MutationContextValue["updateDifferentialEquation"]; + updateDifferentialEquation: PetrinautMutations["updateDifferentialEquation"]; } export const DiffEqPropertiesContext = diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/differential-equation-properties/main.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/differential-equation-properties/main.tsx index ed0feca90d8..8629b7b7c61 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/differential-equation-properties/main.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/differential-equation-properties/main.tsx @@ -4,7 +4,7 @@ import { VerticalSubViewsContainer } from "../../../../../components/sub-view/ve import { DiffEqPropertiesContext } from "./context"; import { diffEqMainContentSubView } from "./subviews/main"; -import type { MutationContextValue } from "../../../../../../react/state/mutation-context"; +import type { PetrinautMutations } from "../../../../../../react"; import type { SubView } from "../../../../../components/sub-view/types"; import type { Color, @@ -25,7 +25,7 @@ interface DifferentialEquationPropertiesProps { differentialEquation: DifferentialEquation; types: Color[]; places: Place[]; - updateDifferentialEquation: MutationContextValue["updateDifferentialEquation"]; + updateDifferentialEquation: PetrinautMutations["updateDifferentialEquation"]; } export const DifferentialEquationProperties: React.FC< diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/multi-selection-panel.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/multi-selection-panel.tsx index e30e113e30f..574ad1842ca 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/multi-selection-panel.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/multi-selection-panel.tsx @@ -11,7 +11,7 @@ import { Button } from "../../../../components/button"; import { VerticalSubViewsContainer } from "../../../../components/sub-view/vertical/vertical-sub-views-container"; import { UI_MESSAGES } from "../../../../constants/ui-messages"; -import type { MutationContextValue } from "../../../../../react/state/mutation-context"; +import type { PetrinautMutations } from "../../../../../react"; import type { SubView } from "../../../../components/sub-view/types"; import type { SelectionItem } from "@hashintel/petrinaut-core"; @@ -30,7 +30,7 @@ const summaryStyle = css({ interface MultiSelectionData { items: SelectionItem[]; - deleteItemsByIds: MutationContextValue["deleteItemsByIds"]; + deleteItemsByIds: PetrinautMutations["deleteItemsByIds"]; } const MultiSelectionContext = createContext(null); @@ -110,7 +110,7 @@ const subViews: SubView[] = [multiSelectionMainSubView]; interface MultiSelectionPanelProps { items: SelectionItem[]; - deleteItemsByIds: MutationContextValue["deleteItemsByIds"]; + deleteItemsByIds: PetrinautMutations["deleteItemsByIds"]; } export const MultiSelectionPanel: React.FC = ({ diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/panel.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/panel.tsx index 70e8fdb6f0a..a5fee1f17eb 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/panel.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/panel.tsx @@ -2,8 +2,8 @@ import { use, useCallback, useEffect, useState } from "react"; import { css, cva, cx } from "@hashintel/ds-helpers/css"; +import { usePetrinautMutations } from "../../../../../react"; import { EditorContext } from "../../../../../react/state/editor-context"; -import { MutationContext } from "../../../../../react/state/mutation-context"; import { DEFAULT_PROPERTIES_PANEL_WIDTH } from "../../../../../react/state/panel-defaults"; import { SDCPNContext } from "../../../../../react/state/sdcpn-context"; import { usePanelTarget } from "../../../../../react/state/use-selection"; @@ -82,7 +82,7 @@ export const PropertiesPanel: React.FC = () => { updateDifferentialEquation, updateParameter, deleteItemsByIds, - } = use(MutationContext); + } = usePetrinautMutations(); const panelTarget = usePanelTarget(); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/parameter-properties/context.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/parameter-properties/context.tsx index 6c7b6e40b2c..3b59d8636c4 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/parameter-properties/context.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/parameter-properties/context.tsx @@ -1,11 +1,11 @@ import { createContext, use } from "react"; -import type { MutationContextValue } from "../../../../../../react/state/mutation-context"; +import type { PetrinautMutations } from "../../../../../../react"; import type { Parameter } from "@hashintel/petrinaut-core"; export interface ParameterPropertiesContextValue { parameter: Parameter; - updateParameter: MutationContextValue["updateParameter"]; + updateParameter: PetrinautMutations["updateParameter"]; } export const ParameterPropertiesContext = diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/parameter-properties/main.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/parameter-properties/main.tsx index c8eb4ec7def..536a9c17734 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/parameter-properties/main.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/parameter-properties/main.tsx @@ -4,7 +4,7 @@ import { VerticalSubViewsContainer } from "../../../../../components/sub-view/ve import { ParameterPropertiesContext } from "./context"; import { parameterMainContentSubView } from "./subviews/main"; -import type { MutationContextValue } from "../../../../../../react/state/mutation-context"; +import type { PetrinautMutations } from "../../../../../../react"; import type { SubView } from "../../../../../components/sub-view/types"; import type { Parameter } from "@hashintel/petrinaut-core"; @@ -19,7 +19,7 @@ const subViews: SubView[] = [parameterMainContentSubView]; interface ParameterPropertiesProps { parameter: Parameter; - updateParameter: MutationContextValue["updateParameter"]; + updateParameter: PetrinautMutations["updateParameter"]; } export const ParameterProperties: React.FC = ({ diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/context.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/context.tsx index 9060c794ec1..721f28e6ad3 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/context.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/context.tsx @@ -1,6 +1,6 @@ import { createContext, type ReactNode, use } from "react"; -import type { MutationContextValue } from "../../../../../../react/state/mutation-context"; +import type { PetrinautMutations } from "../../../../../../react"; import type { Color, Place } from "@hashintel/petrinaut-core"; /** @@ -17,7 +17,7 @@ interface PlacePropertiesContextValue { /** Whether the panel is in read-only mode */ isReadOnly: boolean; /** Function to update the place */ - updatePlace: MutationContextValue["updatePlace"]; + updatePlace: PetrinautMutations["updatePlace"]; } const PlacePropertiesContext = @@ -42,7 +42,7 @@ interface PlacePropertiesProviderProps { placeType: Color | null; types: Color[]; isReadOnly: boolean; - updatePlace: MutationContextValue["updatePlace"]; + updatePlace: PetrinautMutations["updatePlace"]; children: ReactNode; } diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/main.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/main.tsx index ca3fadc32d7..f15cb7904ab 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/main.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/main.tsx @@ -7,7 +7,7 @@ import { placeMainContentSubView } from "./subviews/main"; import { placeInitialStateSubView } from "./subviews/place-initial-state/subview"; import { placeVisualizerSubView } from "./subviews/place-visualizer/subview"; -import type { MutationContextValue } from "../../../../../../react/state/mutation-context"; +import type { PetrinautMutations } from "../../../../../../react"; import type { SubView } from "../../../../../components/sub-view/types"; import type { Color, Place } from "@hashintel/petrinaut-core"; @@ -27,7 +27,7 @@ const subViews: SubView[] = [ interface PlacePropertiesProps { place: Place; types: Color[]; - updatePlace: MutationContextValue["updatePlace"]; + updatePlace: PetrinautMutations["updatePlace"]; } export const PlaceProperties: React.FC = ({ diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/main.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/main.tsx index 279abd1e1d8..97a45b19510 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/main.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/main.tsx @@ -4,8 +4,8 @@ import { Checkbox, Icon } from "@hashintel/ds-components"; import { css } from "@hashintel/ds-helpers/css"; import { validateEntityName } from "@hashintel/petrinaut-core"; +import { usePetrinautMutations } from "../../../../../../../react"; import { EditorContext } from "../../../../../../../react/state/editor-context"; -import { MutationContext } from "../../../../../../../react/state/mutation-context"; import { SDCPNContext } from "../../../../../../../react/state/sdcpn-context"; import { Button } from "../../../../../../components/button"; import { Input } from "../../../../../../components/input"; @@ -350,7 +350,7 @@ const PlaceMainContent: React.FC = () => { const DeletePlaceAction: React.FC = () => { const { place, isReadOnly } = usePlacePropertiesContext(); - const { removePlace } = use(MutationContext); + const { removePlace } = usePetrinautMutations(); return ( + ))} + + + ); + + if (!expandable) { + return button; + } + + return ( + + {button} + +
+ {children.map((item, index) => ( + // oxlint-disable-next-line react/no-array-index-key +
+ {item} +
+ ))} +
+
+
+ ); +}; + +const ToolListContent = ({ + onInteractiveToolSubmit, + onSelectToolTarget, + tools, +}: { + onInteractiveToolSubmit?: OnInteractiveToolSubmit; + onSelectToolTarget?: (target: AiToolTarget) => void; + tools: ToolRenderItem[]; +}) => ( + <> + {tools.map((tool) => ( + + ))} + +); + +export const AiAssistantToolList = ({ + onInteractiveToolSubmit, + onSelectToolTarget, + tools, +}: { + onInteractiveToolSubmit?: OnInteractiveToolSubmit; + onSelectToolTarget?: (target: AiToolTarget) => void; + tools: ToolRenderItem[]; +}) => { + const allComplete = tools.every( + (tool) => + tool.state === "output-available" || tool.state === "output-error", + ); + + if (tools.length === 0) { + return null; + } + + if (tools.length === 1) { + return ( +
+ +
+ ); + } + + // Remount when the group transitions between in-progress and complete so + // `defaultOpen` re-initialises (auto-collapse on completion) without + // controlled state fighting user toggles. + return ( + + + + + + {tools.length} changes + + + +
+ +
+
+
+ ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/create-diagnostics-aware-ai-transport.test.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/create-diagnostics-aware-ai-transport.test.ts new file mode 100644 index 00000000000..3d622c2ba63 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/create-diagnostics-aware-ai-transport.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test, vi } from "vitest"; + +import { createDiagnosticsAwareAiTransport } from "./create-diagnostics-aware-ai-transport"; + +import type { PetrinautAiMessage, PetrinautAiTransport } from "./types"; +import type { UIMessageChunk } from "ai"; + +const emptyStream = (): ReadableStream => + new ReadableStream({ + start(controller) { + controller.close(); + }, + }); + +const createFakeTransport = () => { + const sendMessages = vi.fn(() => + Promise.resolve(emptyStream()), + ); + + return { + sendMessages, + transport: { + reconnectToStream: () => Promise.resolve(null), + sendMessages, + } satisfies PetrinautAiTransport, + }; +}; + +const sendOptions = (messages: PetrinautAiMessage[]) => + ({ + abortSignal: undefined, + chatId: "chat-1", + messageId: undefined, + messages, + trigger: "submit-message", + }) satisfies Parameters[0]; + +describe("createDiagnosticsAwareAiTransport", () => { + test("adds transient diagnostics context to completed tool-result sends", async () => { + const { sendMessages, transport } = createFakeTransport(); + const waitForDiagnosticsRefresh = vi.fn(() => Promise.resolve()); + const wrapped = createDiagnosticsAwareAiTransport({ + getDiagnosticsContext: () => + "Current TypeScript diagnostics (1 issue):\n- Transition: Infect lambda: error TS2304 at Ln 1, Col 1: Cannot find name 'x'.", + transport, + waitForDiagnosticsRefresh, + }); + + const messages: PetrinautAiMessage[] = [ + { + id: "assistant-1", + role: "assistant", + parts: [ + { + type: "tool-updateTransition", + state: "output-available", + toolCallId: "tool-1", + input: {}, + output: { applied: true, title: "Updated transition Infect" }, + }, + ], + } as PetrinautAiMessage, + ]; + + await wrapped.sendMessages(sendOptions(messages)); + + expect(waitForDiagnosticsRefresh).toHaveBeenCalledOnce(); + expect(sendMessages).toHaveBeenCalledOnce(); + + const sentMessages = sendMessages.mock.calls[0]![0].messages; + expect(sentMessages).toHaveLength(2); + expect(sentMessages[0]).toBe(messages[0]); + expect(sentMessages[1]?.id).toBe("petrinaut-diagnostics-context"); + expect(sentMessages[1]?.role).toBe("user"); + + const diagnosticsPart = sentMessages[1]?.parts[0]; + expect(diagnosticsPart?.type).toBe("text"); + if (diagnosticsPart?.type !== "text") { + throw new Error("Expected diagnostics context to be a text part."); + } + expect(diagnosticsPart.text).toContain( + "Petrinaut diagnostics context only", + ); + expect(diagnosticsPart.text).toContain("Current TypeScript diagnostics"); + }); + + test("delegates ordinary user-message sends unchanged", async () => { + const { sendMessages, transport } = createFakeTransport(); + const waitForDiagnosticsRefresh = vi.fn(() => Promise.resolve()); + const wrapped = createDiagnosticsAwareAiTransport({ + getDiagnosticsContext: () => "No current TypeScript diagnostics.", + transport, + waitForDiagnosticsRefresh, + }); + const messages: PetrinautAiMessage[] = [ + { + id: "user-1", + role: "user", + parts: [{ type: "text", text: "Explain this net." }], + }, + ]; + + await wrapped.sendMessages(sendOptions(messages)); + + expect(waitForDiagnosticsRefresh).not.toHaveBeenCalled(); + expect(sendMessages.mock.calls[0]![0].messages).toBe(messages); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/create-diagnostics-aware-ai-transport.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/create-diagnostics-aware-ai-transport.ts new file mode 100644 index 00000000000..d44a0ae0c85 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/create-diagnostics-aware-ai-transport.ts @@ -0,0 +1,77 @@ +import type { PetrinautAiMessage, PetrinautAiTransport } from "./types"; +import type { ChatTransport } from "ai"; + +const diagnosticsContextMessageId = "petrinaut-diagnostics-context"; + +const createDiagnosticsContextMessage = ( + diagnosticsContext: string, +): PetrinautAiMessage => + ({ + id: diagnosticsContextMessageId, + role: "user", + parts: [ + { + type: "text", + text: [ + "Petrinaut diagnostics context only; this is not a user request.", + "The following TypeScript diagnostics reflect the current Petrinaut model after client-side tool execution.", + "Use them to decide whether more tool calls are needed before replying to the user.", + "", + diagnosticsContext, + ].join("\n"), + }, + ], + }) as PetrinautAiMessage; + +const lastMessageIsCompleteToolResultMessage = ( + messages: PetrinautAiMessage[], +) => { + const lastMessage = messages.at(-1); + if (!lastMessage || lastMessage.role !== "assistant") { + return false; + } + + const toolParts = lastMessage.parts.filter((part) => + part.type.startsWith("tool-"), + ); + + return ( + toolParts.length > 0 && + toolParts.every( + (part) => + "state" in part && + (part.state === "output-available" || part.state === "output-error"), + ) + ); +}; + +export const createDiagnosticsAwareAiTransport = ({ + getDiagnosticsContext, + transport, + waitForDiagnosticsRefresh, +}: { + getDiagnosticsContext: () => string; + transport: PetrinautAiTransport; + waitForDiagnosticsRefresh: () => Promise; +}): PetrinautAiTransport => { + const wrappedTransport: ChatTransport = { + reconnectToStream: (options) => transport.reconnectToStream(options), + sendMessages: async (options) => { + if (!lastMessageIsCompleteToolResultMessage(options.messages)) { + return transport.sendMessages(options); + } + + await waitForDiagnosticsRefresh(); + + return transport.sendMessages({ + ...options, + messages: [ + ...options.messages, + createDiagnosticsContextMessage(getDiagnosticsContext()), + ], + }); + }, + }; + + return wrappedTransport; +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/create-reasoning-timing-aware-ai-transport.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/create-reasoning-timing-aware-ai-transport.ts new file mode 100644 index 00000000000..2c20c666c70 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/create-reasoning-timing-aware-ai-transport.ts @@ -0,0 +1,103 @@ +import type { PetrinautAiTransport } from "./types"; +import type { UIMessageChunk } from "ai"; + +/** + * Build a fresh `TransformStream` that tags every reasoning chunk with + * Petrinaut timing metadata as it streams in from the model. + * + * The metadata lives under the `petrinaut` namespace inside the standard AI + * SDK `providerMetadata` map. The SDK then merges per-chunk metadata into the + * final `ReasoningUIPart`, which is persisted alongside the rest of the + * message — so the UI can render an accurate elapsed time that survives the + * panel being closed and reopened. + * + * Important: the AI SDK's stream reducer assigns `chunk.providerMetadata` + * onto the part on every `reasoning-delta` (not a merge), so any chunk that + * arrives with provider-supplied metadata (OpenAI emits its own on reasoning + * deltas) will _replace_ whatever we set on `reasoning-start`. To survive, + * we have to re-inject the timing under `petrinaut` on every reasoning + * chunk for the same id — start, every delta, and end. + * + * `TransformStream`s are single-use, so this factory must be called fresh + * per `sendMessages` / `reconnectToStream` call. + */ +const createReasoningTimingTransform = () => { + const startedAtById = new Map(); + + return new TransformStream({ + transform(chunk, controller) { + if (chunk.type === "reasoning-start") { + const startedAt = Date.now(); + startedAtById.set(chunk.id, startedAt); + controller.enqueue({ + ...chunk, + providerMetadata: { + ...chunk.providerMetadata, + petrinaut: { startedAt }, + }, + }); + return; + } + if (chunk.type === "reasoning-delta") { + const startedAt = startedAtById.get(chunk.id); + if (startedAt == null) { + // Should not happen — `reasoning-start` always precedes deltas — + // but be defensive and just pass the chunk through if the upstream + // skipped the start event for some reason. + controller.enqueue(chunk); + return; + } + controller.enqueue({ + ...chunk, + providerMetadata: { + ...chunk.providerMetadata, + petrinaut: { startedAt }, + }, + }); + return; + } + if (chunk.type === "reasoning-end") { + const startedAt = startedAtById.get(chunk.id); + startedAtById.delete(chunk.id); + controller.enqueue({ + ...chunk, + providerMetadata: { + ...chunk.providerMetadata, + petrinaut: { + ...(startedAt != null ? { startedAt } : {}), + finishedAt: Date.now(), + }, + }, + }); + return; + } + controller.enqueue(chunk); + }, + }); +}; + +/** + * Wrap a Petrinaut chat transport so reasoning chunks pick up client-side + * receipt timestamps as they arrive. + * + * This is applied by `AiAssistantPanel` to every consumer-provided transport, + * which means consumers do not have to plumb timing into their own backend. + * The trade-off is that the timestamps reflect when chunks arrived at the + * client rather than when the model emitted them — for SSE streams the gap + * is just network latency, which is small relative to the reasoning durations + * we display. + */ +export const createReasoningTimingAwareAiTransport = ( + transport: PetrinautAiTransport, +): PetrinautAiTransport => ({ + sendMessages: async (options) => { + const stream = await transport.sendMessages(options); + return stream.pipeThrough(createReasoningTimingTransform()); + }, + reconnectToStream: async (options) => { + const stream = await transport.reconnectToStream(options); + return stream + ? stream.pipeThrough(createReasoningTimingTransform()) + : stream; + }, +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/format-diagnostics-for-ai.test.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/format-diagnostics-for-ai.test.ts new file mode 100644 index 00000000000..45e72d55820 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/format-diagnostics-for-ai.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, test } from "vitest"; +import { + type Diagnostic, + DiagnosticSeverity, +} from "vscode-languageserver-types"; + +import { formatDiagnosticsForAi } from "./format-diagnostics-for-ai"; + +import type { SDCPN } from "@hashintel/petrinaut-core"; + +const definition: SDCPN = { + places: [], + transitions: [ + { + id: "transition__infect", + name: "Infect", + inputArcs: [], + outputArcs: [], + lambdaType: "predicate", + lambdaCode: "", + transitionKernelCode: "", + x: 0, + y: 0, + }, + ], + types: [], + differentialEquations: [ + { + id: "de__viral_load", + name: "Viral load", + colorId: null, + code: "", + }, + ], + parameters: [], +}; + +const diagnostic = ( + message: string, + overrides: Partial = {}, +): Diagnostic => ({ + message, + range: { + start: { line: 1, character: 2 }, + end: { line: 1, character: 8 }, + }, + severity: DiagnosticSeverity.Error, + source: "ts", + ...overrides, +}); + +describe("formatDiagnosticsForAi", () => { + test("reports an empty diagnostics state", () => { + expect( + formatDiagnosticsForAi({ + definition, + diagnosticsByUri: new Map(), + }), + ).toBe("No errors detected in your model – everything compiles!"); + }); + + test("formats transition and differential-equation diagnostics", () => { + const diagnosticsByUri = new Map([ + [ + "inmemory://sdcpn/transitions/transition__infect/lambda.ts", + [ + diagnostic("Cannot find name 'infected'.", { + code: 2304, + range: { + start: { line: 3, character: 10 }, + end: { line: 3, character: 18 }, + }, + }), + ], + ], + [ + "inmemory://sdcpn/transitions/transition__infect/kernel.ts", + [ + diagnostic("Type 'string' is not assignable to type 'number'.", { + code: 2322, + severity: DiagnosticSeverity.Warning, + }), + ], + ], + [ + "inmemory://sdcpn/differential-equations/de__viral_load.ts", + [ + diagnostic("Declaration or statement expected.", { + code: 1128, + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 1 }, + }, + }), + ], + ], + ]); + + expect(formatDiagnosticsForAi({ definition, diagnosticsByUri })).toBe( + [ + "Current TypeScript diagnostics (3 issues):", + "- Transition: Infect lambda: error TS2304 at Ln 4, Col 11: Cannot find name 'infected'.", + "- Transition: Infect kernel: warning TS2322 at Ln 2, Col 3: Type 'string' is not assignable to type 'number'.", + "- Differential Equation: Viral load: error TS1128 at Ln 1, Col 1: Declaration or statement expected.", + ].join("\n"), + ); + }); + + test("limits long diagnostics lists", () => { + const diagnosticsByUri = new Map([ + [ + "inmemory://sdcpn/transitions/transition__infect/lambda.ts", + [diagnostic("First"), diagnostic("Second"), diagnostic("Third")], + ], + ]); + + expect( + formatDiagnosticsForAi({ + definition, + diagnosticsByUri, + maxDiagnostics: 2, + }), + ).toContain("... 1 additional diagnostic omitted."); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/format-diagnostics-for-ai.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/format-diagnostics-for-ai.ts new file mode 100644 index 00000000000..ca313681623 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/format-diagnostics-for-ai.ts @@ -0,0 +1,97 @@ +import { DiagnosticSeverity } from "vscode-languageserver-types"; + +import { parseDocumentUri, type SDCPN } from "@hashintel/petrinaut-core"; + +import type { Diagnostic, DocumentUri } from "vscode-languageserver-types"; + +const DEFAULT_MAX_DIAGNOSTICS = 25; + +const diagnosticSeverityLabel = ( + severity: DiagnosticSeverity | undefined, +): string => { + switch (severity) { + case DiagnosticSeverity.Error: + return "error"; + case DiagnosticSeverity.Warning: + return "warning"; + case DiagnosticSeverity.Information: + return "info"; + case DiagnosticSeverity.Hint: + return "hint"; + default: + return "error"; + } +}; + +const getDiagnosticEntityLabel = (uri: DocumentUri, definition: SDCPN) => { + const parsed = parseDocumentUri(uri); + if (!parsed) { + return `Document: ${uri}`; + } + + if (parsed.itemType === "differential-equation") { + const differentialEquation = definition.differentialEquations.find( + (item) => item.id === parsed.itemId, + ); + + return `Differential Equation: ${differentialEquation?.name ?? parsed.itemId}`; + } + + const transition = definition.transitions.find( + (item) => item.id === parsed.itemId, + ); + const transitionName = transition?.name ?? parsed.itemId; + const transitionPart = + parsed.itemType === "transition-lambda" ? "lambda" : "kernel"; + + return `Transition: ${transitionName} ${transitionPart}`; +}; + +export const formatDiagnosticsForAi = ({ + definition, + diagnosticsByUri, + maxDiagnostics = DEFAULT_MAX_DIAGNOSTICS, +}: { + definition: SDCPN; + diagnosticsByUri: Map; + maxDiagnostics?: number; +}): string => { + const diagnostics = Array.from(diagnosticsByUri.entries()).flatMap( + ([uri, uriDiagnostics]) => + uriDiagnostics.map((diagnostic) => ({ diagnostic, uri })), + ); + + if (diagnostics.length === 0) { + return "No errors detected in your model – everything compiles!"; + } + + const shownDiagnostics = diagnostics.slice(0, maxDiagnostics); + const lines = [ + `Current TypeScript diagnostics (${diagnostics.length} issue${ + diagnostics.length === 1 ? "" : "s" + }):`, + ]; + + for (const { diagnostic, uri } of shownDiagnostics) { + const code = diagnostic.code == null ? "" : ` TS${diagnostic.code}`; + const line = diagnostic.range.start.line + 1; + const column = diagnostic.range.start.character + 1; + + lines.push( + `- ${getDiagnosticEntityLabel(uri, definition)}: ${diagnosticSeverityLabel( + diagnostic.severity, + )}${code} at Ln ${line}, Col ${column}: ${diagnostic.message}`, + ); + } + + const omittedDiagnosticsCount = diagnostics.length - shownDiagnostics.length; + if (omittedDiagnosticsCount > 0) { + lines.push( + `... ${omittedDiagnosticsCount} additional diagnostic${ + omittedDiagnosticsCount === 1 ? "" : "s" + } omitted.`, + ); + } + + return lines.join("\n"); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/apply-auto-layout-widget.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/apply-auto-layout-widget.test.tsx new file mode 100644 index 00000000000..4bf09fa10ed --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/apply-auto-layout-widget.test.tsx @@ -0,0 +1,84 @@ +/** + * @vitest-environment jsdom + */ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { applyAutoLayoutInteractiveTool } from "./apply-auto-layout-widget"; + +import type { AiToolOutput } from "../tool-summaries"; + +const Widget = applyAutoLayoutInteractiveTool.Widget; + +afterEach(cleanup); + +describe("ApplyAutoLayoutWidget", () => { + test("invokes submit with applied: true when the user confirms", () => { + const submit = vi.fn<(output: AiToolOutput) => void>(); + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /Yes, auto-layout/i })); + + expect(submit).toHaveBeenCalledTimes(1); + const output: AiToolOutput = submit.mock.calls[0]![0]; + expect(output).toMatchObject({ applied: true }); + }); + + test("invokes submit with applied: false when the user declines", () => { + const submit = vi.fn<(output: AiToolOutput) => void>(); + + render( + , + ); + + fireEvent.click( + screen.getByRole("button", { name: /No, keep current layout/i }), + ); + + expect(submit).toHaveBeenCalledTimes(1); + const output: AiToolOutput = submit.mock.calls[0]![0]; + expect(output).toEqual({ + applied: false, + reason: "User declined auto-layout.", + }); + }); + + test("renders a static summary once submitted", () => { + render( + {}} + state="submitted" + submittedOutput={{ applied: true, title: "Auto-laid out 3 nodes" }} + />, + ); + + expect(screen.getByText("Auto-laid out 3 nodes")).toBeDefined(); + expect( + screen.queryByRole("button", { name: /Yes, auto-layout/i }), + ).toBeNull(); + }); +}); + +describe("applyAutoLayoutInteractiveTool.shouldHandle", () => { + test("returns true only when askUserFirst is explicitly true", () => { + expect( + applyAutoLayoutInteractiveTool.shouldHandle({ askUserFirst: true }), + ).toBe(true); + expect( + applyAutoLayoutInteractiveTool.shouldHandle({ askUserFirst: false }), + ).toBe(false); + expect(applyAutoLayoutInteractiveTool.shouldHandle({})).toBe(false); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/apply-auto-layout-widget.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/apply-auto-layout-widget.tsx new file mode 100644 index 00000000000..b4622cfe4b5 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/apply-auto-layout-widget.tsx @@ -0,0 +1,115 @@ +import { css } from "@hashintel/ds-helpers/css"; +import { + aiCommandActionInputSchemas, + type AiCommandActionInput, + type AiCommandActionName, +} from "@hashintel/petrinaut-core"; + +import { Button } from "../../../../../components/button"; + +import type { AiToolOutput } from "../tool-summaries"; +import type { + InteractiveToolDefinition, + InteractiveToolWidgetProps, +} from "./types"; + +type ApplyAutoLayoutInput = AiCommandActionInput<"applyAutoLayout">; + +const widgetStyle = css({ + display: "flex", + flexDirection: "column", + gap: "2", + padding: "2", + borderRadius: "lg", + borderWidth: "thin", + borderStyle: "solid", + borderColor: "[#bee6ff]", + backgroundColor: "[#eff9ff]", + color: "[#0666c6]", + fontSize: "sm", + fontWeight: "medium", +}); + +const buttonsStyle = css({ + display: "flex", + alignItems: "center", + gap: "2", +}); + +const summaryStyle = css({ + fontSize: "sm", + fontWeight: "medium", + color: "neutral.s100", +}); + +const ApplyAutoLayoutWidget = ({ + state, + submit, + submittedOutput, +}: InteractiveToolWidgetProps) => { + if (state === "submitted") { + const verdict = + submittedOutput?.applied === true + ? submittedOutput.title + : ((submittedOutput as { reason?: string } | undefined)?.reason ?? + "Auto-layout declined."); + return ( +
+ {verdict} +
+ ); + } + + return ( +
+ + Petrinaut AI suggests running auto-layout on the net. This may + reposition places and transitions. + +
+ + +
+
+ ); +}; + +/** + * Interactive descriptor for `applyAutoLayout`. The AI may opt out of the + * confirmation by passing `askUserFirst: false`; we only intercept when it is + * `true`. + */ +export const applyAutoLayoutInteractiveTool: InteractiveToolDefinition< + ApplyAutoLayoutInput, + AiToolOutput +> = { + toolName: "applyAutoLayout" satisfies AiCommandActionName, + shouldHandle: (raw): boolean => { + const parsed = aiCommandActionInputSchemas.applyAutoLayout.safeParse(raw); + return parsed.success && parsed.data.askUserFirst === true; + }, + parseInput: (raw): ApplyAutoLayoutInput => + aiCommandActionInputSchemas.applyAutoLayout.parse(raw), + Widget: ApplyAutoLayoutWidget, +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/registry.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/registry.ts new file mode 100644 index 00000000000..dbec90871c0 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/registry.ts @@ -0,0 +1,36 @@ +import { applyAutoLayoutInteractiveTool } from "./apply-auto-layout-widget"; + +import type { AiToolOutput } from "../tool-summaries"; +import type { InteractiveToolDefinition } from "./types"; + +/** + * Registry of AI tools that require an inline chat widget for user input. + * + * The AI dispatcher consults this map in `onToolCall`: when a tool name has a + * matching descriptor whose {@link InteractiveToolDefinition.shouldHandle} + * returns `true`, the dispatcher stores the call as pending instead of + * invoking the writable callback, and the AI surface renders the registered + * widget. Once the user interacts with the widget, the surface calls the + * dispatcher's `onInteractiveToolSubmit` to commit a tool output to the chat. + */ +export const interactiveTools: Record< + string, + InteractiveToolDefinition +> = { + [applyAutoLayoutInteractiveTool.toolName]: + applyAutoLayoutInteractiveTool as InteractiveToolDefinition< + unknown, + AiToolOutput + >, +}; + +export const getInteractiveTool = ( + toolName: string, + input: unknown, +): InteractiveToolDefinition | undefined => { + const descriptor = interactiveTools[toolName]; + if (!descriptor) { + return undefined; + } + return descriptor.shouldHandle(input) ? descriptor : undefined; +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/types.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/types.ts new file mode 100644 index 00000000000..3d31eabd336 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/interactive-tools/types.ts @@ -0,0 +1,41 @@ +import type { ComponentType } from "react"; + +/** + * Props passed to every interactive tool widget. The widget renders inline in + * the AI chat while a tool call is awaiting human input, then becomes a + * read-only summary once the user submits. + */ +export type InteractiveToolWidgetProps = { + /** Validated input the AI passed to the tool. */ + input: Input; + /** + * Submit a tool output to the chat. After submission, the widget remains + * mounted in `submitted` state with the chosen output visible. + */ + submit: (output: Output) => void; + /** "awaiting" while the user has not yet picked; "submitted" afterwards. */ + state: "awaiting" | "submitted"; + /** Output that was submitted (only set when `state === "submitted"`). */ + submittedOutput?: Output; +}; + +/** + * Descriptor for an AI tool that requires synchronous user input rendered + * inline in the chat. The registry maps tool names to a definition; the panel + * dispatcher defers `onToolCall` for any tool whose `shouldHandle` returns + * `true`, and the surface renders the registered {@link Widget} until the + * user submits. + */ +export type InteractiveToolDefinition = { + toolName: string; + /** + * Whether this tool call should be handled interactively. Lets a single + * tool branch between interactive and non-interactive paths based on its + * input shape (e.g. `applyAutoLayout` is interactive only when + * `askUserFirst: true`). + */ + shouldHandle: (input: unknown) => boolean; + /** Parse the raw input into the widget's typed input. */ + parseInput: (raw: unknown) => Input; + Widget: ComponentType>; +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/tool-summaries.test.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/tool-summaries.test.ts new file mode 100644 index 00000000000..485da294a7a --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/tool-summaries.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "vitest"; + +import { summarizePetrinautAiToolCall } from "./tool-summaries"; + +import type { SDCPN } from "@hashintel/petrinaut-core"; + +const definition: SDCPN = { + differentialEquations: [], + parameters: [], + places: [ + { + colorId: null, + differentialEquationId: null, + dynamicsEnabled: false, + id: "place__buffer", + name: "Buffer", + x: 0, + y: 0, + }, + ], + transitions: [ + { + id: "transition__ship", + inputArcs: [], + lambdaCode: "return true;", + lambdaType: "predicate", + name: "Ship", + outputArcs: [], + transitionKernelCode: "return tokens;", + x: 0, + y: 0, + }, + ], + types: [], +}; + +describe("summarizePetrinautAiToolCall", () => { + test("falls back to existing entity names when updates omit names", () => { + expect( + summarizePetrinautAiToolCall( + { + input: { + placeId: "place__buffer", + update: { dynamicsEnabled: true }, + }, + toolName: "updatePlace", + }, + { definition }, + ).title, + ).toBe("Updated place Buffer"); + + expect( + summarizePetrinautAiToolCall( + { + input: { + position: { x: 10, y: 20 }, + transitionId: "transition__ship", + }, + toolName: "updateTransitionPosition", + }, + { definition }, + ).title, + ).toBe("Moved transition Ship"); + }); + + test("prefers updated names while retaining previous names as detail", () => { + expect( + summarizePetrinautAiToolCall( + { + input: { + placeId: "place__buffer", + update: { name: "Queue" }, + }, + toolName: "updatePlace", + }, + { definition }, + ), + ).toMatchObject({ + detail: "Previous name: Buffer", + title: "Updated place Queue", + }); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/tool-summaries.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/tool-summaries.ts new file mode 100644 index 00000000000..910412af8be --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/tool-summaries.ts @@ -0,0 +1,542 @@ +import { + generateArcId, + type PetrinautAiCommandToolInput, + type PetrinautAiCommandToolName, + type PetrinautAiMutationToolInput, + type PetrinautAiMutationToolName, + type SDCPN, + type SelectionItem, +} from "@hashintel/petrinaut-core"; + +import type { ReadOnlyReason } from "../../../../../react/state/use-read-only-reason"; + +export type AiToolSummary = { + title: string; + detail?: string; + items?: string[]; + target?: AiToolTarget; +}; + +export type AiToolBlockedOutput = { + applied: false; + blocked: ReadOnlyReason["kind"]; + reason: string; +}; + +export type AiToolDeclinedOutput = { + applied: false; + reason: string; +}; + +export type AiToolOutput = + | (AiToolSummary & { applied: true }) + | AiToolBlockedOutput + | AiToolDeclinedOutput; + +export type AiToolTarget = + | { kind: "selection"; item: SelectionItem } + | { kind: "simulateView"; mode: "scenarios" | "metrics"; itemId?: string }; + +export type AiToolSummaryContext = { + definition?: SDCPN; +}; + +export const toPetrinautAiToolOutput = ( + summary: AiToolSummary, +): AiToolOutput => ({ + ...summary, + applied: true, +}); + +export type AiToolAppliedSummary = AiToolSummary & { applied: true }; + +const prettifyToolName = (toolName: string): string => + toolName + .replace(/([A-Z])/g, " $1") + .replace(/^./, (char) => char.toUpperCase()); + +const getName = (value: unknown): string | undefined => { + if (typeof value !== "object" || value === null) { + return undefined; + } + const maybeName = (value as { name?: unknown }).name; + return typeof maybeName === "string" ? maybeName : undefined; +}; + +const findById = ( + items: Item[] | undefined, + id: string, +): Item | undefined => items?.find((item) => item.id === id); + +const entityName = ( + definition: SDCPN | undefined, + target: SelectionItem, +): string | undefined => { + switch (target.type) { + case "place": + return findById(definition?.places, target.id)?.name; + case "transition": + return findById(definition?.transitions, target.id)?.name; + case "type": + return findById(definition?.types, target.id)?.name; + case "differentialEquation": + return findById(definition?.differentialEquations, target.id)?.name; + case "parameter": + return findById(definition?.parameters, target.id)?.name; + case "arc": + return undefined; + } +}; + +const scenarioName = ( + definition: SDCPN | undefined, + scenarioId: string, +): string | undefined => findById(definition?.scenarios, scenarioId)?.name; + +const metricName = ( + definition: SDCPN | undefined, + metricId: string, +): string | undefined => findById(definition?.metrics, metricId)?.name; + +const typeElementName = ( + definition: SDCPN | undefined, + typeId: string, + elementId: string, +): string | undefined => + findById(definition?.types, typeId)?.elements.find( + (element) => element.elementId === elementId, + )?.name; + +const updatedOrExistingName = ({ + definitionName, + id, + update, +}: { + definitionName?: string; + id: string; + update?: unknown; +}): string => getName(update) ?? definitionName ?? id; + +const renameDetail = ({ + definitionName, + update, +}: { + definitionName?: string; + update?: unknown; +}): string | undefined => { + const updatedName = getName(update); + + return updatedName && definitionName && updatedName !== definitionName + ? `Previous name: ${definitionName}` + : undefined; +}; + +const targetName = ( + definition: SDCPN | undefined, + target: SelectionItem, +): string => entityName(definition, target) ?? target.id; + +const itemLabel = ( + definition: SDCPN | undefined, + item: SelectionItem, +): string => { + const typeLabel = + item.type === "differentialEquation" ? "equation" : item.type; + return `${typeLabel}: ${targetName(definition, item)}`; +}; + +const arcEndpointDetail = ( + definition: SDCPN | undefined, + input: { + placeId: string; + transitionId: string; + }, +): string => { + const place = targetName(definition, { type: "place", id: input.placeId }); + const transition = targetName(definition, { + type: "transition", + id: input.transitionId, + }); + + return `${place} <-> ${transition}`; +}; + +const arcTarget = (input: { + arcDirection: "input" | "output"; + placeId: string; + transitionId: string; +}): SelectionItem => ({ + type: "arc", + id: + input.arcDirection === "input" + ? generateArcId({ inputId: input.placeId, outputId: input.transitionId }) + : generateArcId({ inputId: input.transitionId, outputId: input.placeId }), +}); + +const selectionTarget = (item: SelectionItem): AiToolTarget => ({ + kind: "selection", + item, +}); + +export type AiToolCall = + | { + [Name in PetrinautAiMutationToolName]: { + toolName: Name; + input: PetrinautAiMutationToolInput; + }; + }[PetrinautAiMutationToolName] + | { + [Name in PetrinautAiCommandToolName]: { + toolName: Name; + input: PetrinautAiCommandToolInput; + }; + }[PetrinautAiCommandToolName]; + +export type AiToolApplyAutoLayoutSummaryContext = { + commitCount: number; +}; + +export const summarizeApplyAutoLayout = ( + context: AiToolApplyAutoLayoutSummaryContext, +): AiToolSummary => { + const { commitCount } = context; + return { + title: + commitCount === 0 + ? "Auto-layout had no effect" + : `Auto-laid out ${commitCount} node${commitCount === 1 ? "" : "s"}`, + }; +}; + +export const summarizePetrinautAiToolCall = ( + { input, toolName }: AiToolCall, + context: AiToolSummaryContext = {}, +): AiToolSummary => { + const { definition } = context; + + switch (toolName) { + case "addPlace": + return { + title: `Added place ${input.name}`, + target: selectionTarget({ type: "place", id: input.id }), + }; + case "updatePlace": + return { + title: `Updated place ${updatedOrExistingName({ + definitionName: entityName(definition, { + type: "place", + id: input.placeId, + }), + id: input.placeId, + update: input.update, + })}`, + detail: renameDetail({ + definitionName: entityName(definition, { + type: "place", + id: input.placeId, + }), + update: input.update, + }), + target: selectionTarget({ type: "place", id: input.placeId }), + }; + case "updatePlacePosition": + return { + title: `Moved place ${targetName(definition, { + type: "place", + id: input.placeId, + })}`, + target: selectionTarget({ type: "place", id: input.placeId }), + }; + case "removePlace": + return { + title: `Removed place ${targetName(definition, { + type: "place", + id: input.placeId, + })}`, + }; + case "addTransition": + return { + title: `Added transition ${input.name}`, + target: selectionTarget({ type: "transition", id: input.id }), + }; + case "updateTransition": + return { + title: `Updated transition ${updatedOrExistingName({ + definitionName: entityName(definition, { + type: "transition", + id: input.transitionId, + }), + id: input.transitionId, + update: input.update, + })}`, + detail: renameDetail({ + definitionName: entityName(definition, { + type: "transition", + id: input.transitionId, + }), + update: input.update, + }), + target: selectionTarget({ type: "transition", id: input.transitionId }), + }; + case "updateTransitionPosition": + return { + title: `Moved transition ${targetName(definition, { + type: "transition", + id: input.transitionId, + })}`, + target: selectionTarget({ type: "transition", id: input.transitionId }), + }; + case "removeTransition": + return { + title: `Removed transition ${targetName(definition, { + type: "transition", + id: input.transitionId, + })}`, + }; + case "addArc": + return { + title: `Added ${input.arcDirection} arc`, + detail: arcEndpointDetail(definition, input), + target: selectionTarget(arcTarget(input)), + }; + case "removeArc": + return { + title: `Removed ${input.arcDirection} arc`, + detail: arcEndpointDetail(definition, input), + }; + case "updateArcWeight": + return { + title: "Updated arc weight", + detail: `${arcEndpointDetail(definition, input)}: ${input.weight}`, + target: selectionTarget(arcTarget(input)), + }; + case "updateArcType": + return { + title: "Updated input arc type", + detail: `${arcEndpointDetail(definition, input)}: ${input.type}`, + target: selectionTarget(arcTarget({ ...input, arcDirection: "input" })), + }; + case "updateArcPlace": + return { + title: "Updated arc endpoint", + detail: `${targetName(definition, { + type: "place", + id: input.oldPlaceId, + })} -> ${targetName(definition, { + type: "place", + id: input.newPlaceId, + })}`, + }; + case "addType": + return { + title: `Added type ${input.name}`, + target: selectionTarget({ type: "type", id: input.id }), + }; + case "updateType": + return { + title: `Updated type ${updatedOrExistingName({ + definitionName: entityName(definition, { + type: "type", + id: input.typeId, + }), + id: input.typeId, + update: input.update, + })}`, + detail: renameDetail({ + definitionName: entityName(definition, { + type: "type", + id: input.typeId, + }), + update: input.update, + }), + target: selectionTarget({ type: "type", id: input.typeId }), + }; + case "removeType": + return { + title: `Removed type ${targetName(definition, { + type: "type", + id: input.typeId, + })}`, + }; + case "addTypeElement": + return { + title: `Added type element ${input.element.name}`, + detail: input.typeId, + target: selectionTarget({ type: "type", id: input.typeId }), + }; + case "updateTypeElement": + return { + title: `Updated type element ${updatedOrExistingName({ + definitionName: typeElementName( + definition, + input.typeId, + input.elementId, + ), + id: input.elementId, + update: input.update, + })}`, + detail: targetName(definition, { type: "type", id: input.typeId }), + target: selectionTarget({ type: "type", id: input.typeId }), + }; + case "removeTypeElement": + return { + title: `Removed type element ${ + typeElementName(definition, input.typeId, input.elementId) ?? + input.elementId + }`, + detail: targetName(definition, { type: "type", id: input.typeId }), + target: selectionTarget({ type: "type", id: input.typeId }), + }; + case "moveTypeElement": + return { + title: `Moved type element ${ + typeElementName(definition, input.typeId, input.elementId) ?? + input.elementId + }`, + detail: targetName(definition, { type: "type", id: input.typeId }), + target: selectionTarget({ type: "type", id: input.typeId }), + }; + case "addDifferentialEquation": + return { + title: `Added equation ${input.name}`, + target: selectionTarget({ type: "differentialEquation", id: input.id }), + }; + case "updateDifferentialEquation": + return { + title: `Updated equation ${updatedOrExistingName({ + definitionName: entityName(definition, { + type: "differentialEquation", + id: input.equationId, + }), + id: input.equationId, + update: input.update, + })}`, + detail: renameDetail({ + definitionName: entityName(definition, { + type: "differentialEquation", + id: input.equationId, + }), + update: input.update, + }), + target: selectionTarget({ + type: "differentialEquation", + id: input.equationId, + }), + }; + case "removeDifferentialEquation": + return { + title: `Removed equation ${targetName(definition, { + type: "differentialEquation", + id: input.equationId, + })}`, + }; + case "addParameter": + return { + title: `Added parameter ${input.name}`, + target: selectionTarget({ type: "parameter", id: input.id }), + }; + case "updateParameter": + return { + title: `Updated parameter ${updatedOrExistingName({ + definitionName: entityName(definition, { + type: "parameter", + id: input.parameterId, + }), + id: input.parameterId, + update: input.update, + })}`, + detail: renameDetail({ + definitionName: entityName(definition, { + type: "parameter", + id: input.parameterId, + }), + update: input.update, + }), + target: selectionTarget({ type: "parameter", id: input.parameterId }), + }; + case "removeParameter": + return { + title: `Removed parameter ${targetName(definition, { + type: "parameter", + id: input.parameterId, + })}`, + }; + case "addScenario": + return { + title: `Added scenario ${input.name}`, + target: { kind: "simulateView", mode: "scenarios", itemId: input.id }, + }; + case "updateScenario": + return { + title: `Updated scenario ${updatedOrExistingName({ + definitionName: scenarioName(definition, input.scenarioId), + id: input.scenarioId, + update: input.update, + })}`, + detail: renameDetail({ + definitionName: scenarioName(definition, input.scenarioId), + update: input.update, + }), + target: { + kind: "simulateView", + mode: "scenarios", + itemId: input.scenarioId, + }, + }; + case "removeScenario": + return { + title: `Removed scenario ${ + scenarioName(definition, input.scenarioId) ?? input.scenarioId + }`, + }; + case "addMetric": + return { + title: `Added metric ${input.name}`, + target: { kind: "simulateView", mode: "metrics", itemId: input.id }, + }; + case "updateMetric": + return { + title: `Updated metric ${updatedOrExistingName({ + definitionName: metricName(definition, input.metricId), + id: input.metricId, + update: input.update, + })}`, + detail: renameDetail({ + definitionName: metricName(definition, input.metricId), + update: input.update, + }), + target: { + kind: "simulateView", + mode: "metrics", + itemId: input.metricId, + }, + }; + case "removeMetric": + return { + title: `Removed metric ${ + metricName(definition, input.metricId) ?? input.metricId + }`, + }; + case "deleteItemsByIds": + return { + title: `Deleted ${input.items.length} item${ + input.items.length === 1 ? "" : "s" + }`, + items: input.items.map((item) => itemLabel(definition, item)), + }; + case "commitNodePositions": + return { + title: `Moved ${input.commits.length} node${ + input.commits.length === 1 ? "" : "s" + }`, + }; + case "applyAutoLayout": + return { + title: input.askUserFirst + ? "Requested auto-layout (awaiting user confirmation)" + : "Auto-laid out the net", + }; + default: + return { title: prettifyToolName(toolName) }; + } +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/types.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/types.ts new file mode 100644 index 00000000000..e5f4a95a573 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/types.ts @@ -0,0 +1,65 @@ +import type { AiToolOutput } from "./tool-summaries"; +import type { + getLatestNetDefinitionToolName, + getNetCompilationErrorsToolName, + PetrinautAiCommandToolInput, + PetrinautAiCommandToolName, + PetrinautAiMutationToolInput, + PetrinautAiMutationToolName, + PetrinautAiToolInput, + SDCPN, + setNetTitleToolName, +} from "@hashintel/petrinaut-core"; +import type { ChatTransport, UIDataTypes, UIMessage } from "ai"; + +type PetrinautAiUiTools = { + [Name in PetrinautAiMutationToolName]: { + input: PetrinautAiMutationToolInput; + output: AiToolOutput; + }; +} & { + [Name in PetrinautAiCommandToolName]: { + input: PetrinautAiCommandToolInput; + output: AiToolOutput; + }; +} & { + [getLatestNetDefinitionToolName]: { + input: PetrinautAiToolInput; + output: { title: string; definition: SDCPN }; + }; + [getNetCompilationErrorsToolName]: { + input: PetrinautAiToolInput; + output: string; + }; + [setNetTitleToolName]: { + input: PetrinautAiToolInput; + output: AiToolOutput; + }; +}; + +export type PetrinautAiMessage = UIMessage< + unknown, + UIDataTypes, + PetrinautAiUiTools +>; + +export type PetrinautAiTransport = ChatTransport; + +/** + * Provider metadata shape that the Petrinaut server-side transport attaches to + * reasoning chunks so the UI can render an accurate elapsed time even after + * the panel has been closed and reopened. + * + * The keys live under a `petrinaut` namespace inside the standard AI SDK + * `providerMetadata` map. The SDK merges per-chunk metadata into the final + * reasoning part, so reading either timestamp from the persisted message is + * sufficient. + */ +export type PetrinautReasoningMetadata = { + petrinaut?: { + /** ms since epoch when the model started this reasoning summary. */ + startedAt?: number; + /** ms since epoch when the model finished this reasoning summary. */ + finishedAt?: number; + }; +}; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/create-storybook-ai-transport.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/create-storybook-ai-transport.ts new file mode 100644 index 00000000000..f4cab31231a --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/create-storybook-ai-transport.ts @@ -0,0 +1,94 @@ +import type { PetrinautAiMessage } from "./ai-assistant-panel"; +import type { ChatTransport, UIMessageChunk } from "ai"; + +const placeInput = { + id: "place__ai_buffer", + name: "AI Buffer", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + showAsInitialState: true, + x: 180, + y: 140, +}; + +const transitionInput = { + id: "transition__ai_dispatch", + name: "AI Dispatch", + inputArcs: [], + outputArcs: [], + lambdaType: "predicate", + lambdaCode: "export const Lambda = () => true;", + transitionKernelCode: "export const TransitionKernel = () => ({});", + x: 420, + y: 140, +}; + +const chunksForInitialRequest = (): UIMessageChunk[] => [ + { type: "reasoning-start", id: "reasoning-1" }, + { + type: "reasoning-delta", + id: "reasoning-1", + delta: + "Identify the requested process elements, then add a place and transition with stable IDs.", + }, + { type: "reasoning-end", id: "reasoning-1" }, + { + type: "tool-input-available", + toolCallId: "tool-add-place", + toolName: "addPlace", + input: placeInput, + }, + { + type: "tool-input-available", + toolCallId: "tool-add-transition", + toolName: "addTransition", + input: transitionInput, + }, +]; + +const chunksForFollowUpRequest = (): UIMessageChunk[] => [ + { type: "text-start", id: "text-1" }, + { + type: "text-delta", + id: "text-1", + delta: + "I added an AI Buffer place and an AI Dispatch transition. You can select the change summaries to inspect the new items.", + }, + { type: "text-end", id: "text-1" }, +]; + +const streamChunks = ( + chunks: UIMessageChunk[], +): ReadableStream => + new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(chunk); + } + controller.close(); + }, + }); + +const hasToolOutput = (messages: PetrinautAiMessage[]): boolean => + messages.some((message) => + message.parts.some( + (part) => + part.type.startsWith("tool-") && + "state" in part && + part.state === "output-available", + ), + ); + +export const createStorybookAiTransport = + (): ChatTransport => ({ + reconnectToStream: () => Promise.resolve(null), + sendMessages: ({ messages }) => + Promise.resolve( + streamChunks( + hasToolOutput(messages) + ? chunksForFollowUpRequest() + : chunksForInitialRequest(), + ), + ), + }); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/run-auto-layout.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/run-auto-layout.ts deleted file mode 100644 index 225617d3a60..00000000000 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/run-auto-layout.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { calculateGraphLayout } from "../../lib/calculate-graph-layout"; - -import type { MutationContextValue } from "../../../react/state/mutation-context"; -import type { SDCPN } from "@hashintel/petrinaut-core"; - -type NodeDimensions = { - place: { width: number; height: number }; - transition: { width: number; height: number }; -}; - -/** - * Run auto-layout on the current SDCPN and apply the computed positions via - * the Mutation bridge. - * - * This composes the layout primitives in `/ui` so `/react` doesn't have to - * reach for visual dimensions. The mutate side flows through - * {@link MutationContextValue.commitNodePositions} — the same path used by - * drag commits — so read-only / simulate-mode guards apply uniformly. - * - * `dimensions` should be layout-stable (independent of the user's - * `compactNodes` choice) — see the note in `node-dimensions.ts`. - */ -export async function runAutoLayout({ - sdcpn, - dimensions, - commitNodePositions, -}: { - sdcpn: SDCPN; - dimensions: NodeDimensions; - commitNodePositions: MutationContextValue["commitNodePositions"]; -}): Promise { - if (sdcpn.places.length === 0 && sdcpn.transitions.length === 0) { - return; - } - - const positions = await calculateGraphLayout(sdcpn, dimensions); - - const commits: Parameters< - MutationContextValue["commitNodePositions"] - >[0]["commits"] = []; - - for (const place of sdcpn.places) { - const position = positions[place.id]; - if (position && (place.x !== position.x || place.y !== position.y)) { - commits.push({ id: place.id, itemType: "place", position }); - } - } - - for (const transition of sdcpn.transitions) { - const position = positions[transition.id]; - if ( - position && - (transition.x !== position.x || transition.y !== position.y) - ) { - commits.push({ - id: transition.id, - itemType: "transition", - position, - }); - } - } - - if (commits.length > 0) { - commitNodePositions({ commits }); - } -} diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/use-apply-node-changes.ts b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/use-apply-node-changes.ts index f39e22d664e..f015cc88923 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/use-apply-node-changes.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/hooks/use-apply-node-changes.ts @@ -1,7 +1,7 @@ import { use } from "react"; +import { usePetrinautMutations } from "../../../../react/hooks/use-petrinaut-mutations"; import { EditorContext } from "../../../../react/state/editor-context"; -import { MutationContext } from "../../../../react/state/mutation-context"; import { SDCPNContext } from "../../../../react/state/sdcpn-context"; import { UserSettingsContext } from "../../../../react/state/user-settings-context"; import { snapPositionToGrid } from "../../../lib/snap-position-to-grid"; @@ -17,7 +17,7 @@ import type { EdgeChange, NodeChange } from "@xyflow/react"; */ export function useApplyNodeChanges() { const { getItemType } = use(SDCPNContext); - const { commitNodePositions } = use(MutationContext); + const { commitNodePositions } = usePetrinautMutations(); const { updateDraggingStateByNodeId, setSelection } = use(EditorContext); const { snapToGrid } = use(UserSettingsContext); diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/sdcpn-view.tsx b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/sdcpn-view.tsx index 522709d001f..3f7f63dca14 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/sdcpn-view.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/sdcpn-view.tsx @@ -16,8 +16,8 @@ import { generateDefaultLambdaCode, } from "@hashintel/petrinaut-core"; +import { usePetrinautMutations } from "../../../react"; import { EditorContext } from "../../../react/state/editor-context"; -import { MutationContext } from "../../../react/state/mutation-context"; import { SDCPNContext } from "../../../react/state/sdcpn-context"; import { useIsReadOnly } from "../../../react/state/use-is-read-only"; import { UserSettingsContext } from "../../../react/state/user-settings-context"; @@ -95,7 +95,7 @@ export const SDCPNView: React.FC<{ // SDCPN store const { petriNetId } = use(SDCPNContext); - const { addPlace, addTransition, addArc } = use(MutationContext); + const { addPlace, addTransition, addArc } = usePetrinautMutations(); const { editionMode, diff --git a/libs/@hashintel/petrinaut/vite.config.ts b/libs/@hashintel/petrinaut/vite.config.ts index 578a947a075..f18ca3949ae 100644 --- a/libs/@hashintel/petrinaut/vite.config.ts +++ b/libs/@hashintel/petrinaut/vite.config.ts @@ -56,7 +56,6 @@ export default defineConfig(({ command }) => ({ plugins: [ esmExternalRequirePlugin({ external: [ - "elkjs", "react/compiler-runtime", "react/jsx-runtime", "react/jsx-dev-runtime", diff --git a/libs/@local/repo-chores/node/package.json b/libs/@local/repo-chores/node/package.json index 8e07f0343ea..0a326128339 100644 --- a/libs/@local/repo-chores/node/package.json +++ b/libs/@local/repo-chores/node/package.json @@ -28,7 +28,7 @@ "regex-parser": "2.3.1", "tsx": "4.20.6", "typescript": "5.9.3", - "zod": "4.1.12", + "zod": "4.4.3", "zod-to-json-schema": "3.24.6" }, "devDependencies": { diff --git a/package.json b/package.json index ed632981bb5..572315ed43f 100644 --- a/package.json +++ b/package.json @@ -89,7 +89,6 @@ "@playwright/test": "1.58.2", "@tldraw/editor@npm:2.0.0-alpha.12": "patch:@tldraw/editor@npm%3A2.0.0-alpha.12#~/.yarn/patches/@tldraw-editor-npm-2.0.0-alpha.12-ba59bf001c.patch", "@tldraw/tlschema@npm:2.0.0-alpha.12": "patch:@tldraw/tlschema@npm%3A2.0.0-alpha.12#~/.yarn/patches/@tldraw-tlschema-npm-2.0.0-alpha.12-13bf88407b.patch", - "ai": "5.0.97", "blockprotocol@npm:0.0.10": "patch:blockprotocol@npm%3A0.0.12#~/.yarn/patches/blockprotocol-npm-0.0.12-2558a31f0a.patch", "canvas": "3.2.0", "dompurify": "3.4.0", diff --git a/yarn.lock b/yarn.lock index d0e7debb137..862a66c8a6c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12,6 +12,67 @@ __metadata: languageName: node linkType: hard +"@ai-sdk/gateway@npm:3.0.114": + version: 3.0.114 + resolution: "@ai-sdk/gateway@npm:3.0.114" + dependencies: + "@ai-sdk/provider": "npm:3.0.10" + "@ai-sdk/provider-utils": "npm:4.0.27" + "@vercel/oidc": "npm:3.2.0" + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + checksum: 10c0/8a3f426c458c4a5eb8121e0f4a9814765dda29a25dc3efb458aea05abd94ff34fedb3987ab1d97e8f457156222f4149cd116f96cc03382ce2ff215106b6da70d + languageName: node + linkType: hard + +"@ai-sdk/openai@npm:3.0.63": + version: 3.0.63 + resolution: "@ai-sdk/openai@npm:3.0.63" + dependencies: + "@ai-sdk/provider": "npm:3.0.10" + "@ai-sdk/provider-utils": "npm:4.0.27" + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + checksum: 10c0/b68614fa506fd98364ecab7096e34c6bc2797add919213996e8747f4afba3f15bdcae6c74096c202e7cc13fab2fd6c180e64515d76ea1b75b0bd49e3d088b069 + languageName: node + linkType: hard + +"@ai-sdk/provider-utils@npm:4.0.27": + version: 4.0.27 + resolution: "@ai-sdk/provider-utils@npm:4.0.27" + dependencies: + "@ai-sdk/provider": "npm:3.0.10" + "@standard-schema/spec": "npm:^1.1.0" + eventsource-parser: "npm:^3.0.8" + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + checksum: 10c0/2202dedcbe8f58883ef37425111392c6b5b5c2f85dfbbd11b13173e9d349da3d977ba610586c4b5412720e07b09f63ba0e88dbc6e21f043441a67f6d51225020 + languageName: node + linkType: hard + +"@ai-sdk/provider@npm:3.0.10": + version: 3.0.10 + resolution: "@ai-sdk/provider@npm:3.0.10" + dependencies: + json-schema: "npm:^0.4.0" + checksum: 10c0/c9b162165c3fd4684e4f7a1c041db3ad75785c841d904bac577006b7b2299d1b465557b1d32e3f59b6d8536a0ef5741b195aad86fc3e0e1d2a39f077062a83c4 + languageName: node + linkType: hard + +"@ai-sdk/react@npm:3.0.184": + version: 3.0.184 + resolution: "@ai-sdk/react@npm:3.0.184" + dependencies: + "@ai-sdk/provider-utils": "npm:4.0.27" + ai: "npm:6.0.182" + swr: "npm:^2.2.5" + throttleit: "npm:2.1.0" + peerDependencies: + react: ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1 + checksum: 10c0/1d16f5a20b7a6334a09445f19056afc410b601c3da52ff3ad331f2b7da73657c9b7b7106fc64d081636522009b8d42451cec8c33dea2bf29bbe31773a9aa8229 + languageName: node + linkType: hard + "@anthropic-ai/bedrock-sdk@npm:0.26.3": version: 0.26.3 resolution: "@anthropic-ai/bedrock-sdk@npm:0.26.3" @@ -745,7 +806,7 @@ __metadata: shx: "npm:0.4.0" tsx: "npm:4.20.6" typescript: "npm:5.9.3" - zod: "npm:4.1.12" + zod: "npm:4.4.3" zod-to-json-schema: "npm:3.24.6" bin: linear-mcp: ./dist/index.js @@ -770,7 +831,7 @@ __metadata: shx: "npm:0.4.0" tsx: "npm:4.20.6" typescript: "npm:5.9.3" - zod: "npm:4.1.12" + zod: "npm:4.4.3" zod-to-json-schema: "npm:3.24.6" bin: notion-mcp: ./dist/index.js @@ -781,6 +842,7 @@ __metadata: version: 0.0.0-use.local resolution: "@apps/petrinaut-website@workspace:apps/petrinaut-website" dependencies: + "@ai-sdk/openai": "npm:3.0.63" "@hashintel/ds-components": "workspace:*" "@hashintel/ds-helpers": "workspace:*" "@hashintel/petrinaut": "workspace:*" @@ -793,6 +855,8 @@ __metadata: "@types/react-dom": "npm:19.2.3" "@typescript/native-preview": "npm:7.0.0-dev.20260511.1" "@vitejs/plugin-react": "npm:6.0.1" + "@whatwg-node/server": "npm:0.10.18" + ai: "npm:6.0.182" babel-plugin-react-compiler: "npm:1.0.0" immer: "npm:10.1.3" oxlint: "npm:1.63.0" @@ -801,6 +865,7 @@ __metadata: react-dom: "npm:19.2.6" react-icons: "npm:5.5.0" vite: "npm:8.0.12" + zod: "npm:4.4.3" languageName: unknown linkType: soft @@ -7635,7 +7700,7 @@ __metadata: vite-plugin-svgr: "npm:5.2.0" vitest: "npm:^4.0.16" vitest-browser-react: "npm:^2.0.2" - zod: "npm:4.1.12" + zod: "npm:4.4.3" peerDependencies: "@ark-ui/react": ^5.26.2 "@hashintel/ds-helpers": "workspace:^" @@ -7658,6 +7723,7 @@ __metadata: "@types/babel__standalone": "npm:7.1.9" "@types/node": "npm:22.18.13" "@typescript/native-preview": "npm:7.0.0-dev.20260511.1" + elkjs: "npm:0.11.0" immer: "npm:10.1.3" oxlint: "npm:1.63.0" oxlint-tsgolint: "npm:0.22.1" @@ -7668,7 +7734,7 @@ __metadata: vite: "npm:8.0.12" vitest: "npm:4.1.5" vscode-languageserver-types: "npm:3.17.5" - zod: "npm:4.1.12" + zod: "npm:4.4.3" languageName: unknown linkType: soft @@ -7676,6 +7742,7 @@ __metadata: version: 0.0.0-use.local resolution: "@hashintel/petrinaut@workspace:libs/@hashintel/petrinaut" dependencies: + "@ai-sdk/react": "npm:3.0.184" "@ark-ui/react": "npm:5.26.2" "@babel/standalone": "npm:7.28.5" "@fontsource-variable/inter": "npm:5.2.8" @@ -7699,8 +7766,8 @@ __metadata: "@typescript/native-preview": "npm:7.0.0-dev.20260511.1" "@vitejs/plugin-react": "npm:6.0.1" "@xyflow/react": "npm:12.10.1" + ai: "npm:6.0.182" babel-plugin-react-compiler: "npm:1.0.0" - elkjs: "npm:0.11.0" fuzzysort: "npm:3.1.0" jsdom: "npm:24.1.3" lodash-es: "npm:4.18.1" @@ -7709,6 +7776,7 @@ __metadata: oxlint-tsgolint: "npm:0.22.1" react: "npm:19.2.6" react-dom: "npm:19.2.6" + react-markdown: "npm:10.1.0" react-resizable-panels: "npm:4.6.5" rolldown: "npm:1.0.0" rolldown-plugin-dts: "npm:0.25.0" @@ -9408,7 +9476,7 @@ __metadata: regex-parser: "npm:2.3.1" tsx: "npm:4.20.6" typescript: "npm:5.9.3" - zod: "npm:4.1.12" + zod: "npm:4.4.3" zod-to-json-schema: "npm:3.24.6" languageName: unknown linkType: soft @@ -20635,6 +20703,13 @@ __metadata: languageName: node linkType: hard +"@vercel/oidc@npm:3.2.0": + version: 3.2.0 + resolution: "@vercel/oidc@npm:3.2.0" + checksum: 10c0/98318d3236f58c296616c8c2e1655b268c7bf58525bcd985adac7af6d900e05fc610f6f03ce2ff4bdcd3df7885a40c0ca44fdc761f122dcfe15a78c2756b0243 + languageName: node + linkType: hard + "@vitejs/plugin-react-swc@npm:^3.7.2": version: 3.11.0 resolution: "@vitejs/plugin-react-swc@npm:3.11.0" @@ -21337,6 +21412,19 @@ __metadata: languageName: node linkType: hard +"@whatwg-node/server@npm:0.10.18": + version: 0.10.18 + resolution: "@whatwg-node/server@npm:0.10.18" + dependencies: + "@envelop/instrumentation": "npm:^1.0.0" + "@whatwg-node/disposablestack": "npm:^0.0.6" + "@whatwg-node/fetch": "npm:^0.10.13" + "@whatwg-node/promise-helpers": "npm:^1.3.2" + tslib: "npm:^2.6.3" + checksum: 10c0/794c4776c8cb432d2f607f5a36392bec59649bbee6aa116b52866555e4852f00378e92d80702c1aa67cf32abe147b3eb96a999b7352237d64126b6e7e33acc6c + languageName: node + linkType: hard + "@wry/caches@npm:^1.0.0": version: 1.0.1 resolution: "@wry/caches@npm:1.0.1" @@ -22449,6 +22537,20 @@ __metadata: languageName: node linkType: hard +"ai@npm:6.0.182": + version: 6.0.182 + resolution: "ai@npm:6.0.182" + dependencies: + "@ai-sdk/gateway": "npm:3.0.114" + "@ai-sdk/provider": "npm:3.0.10" + "@ai-sdk/provider-utils": "npm:4.0.27" + "@opentelemetry/api": "npm:^1.9.0" + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + checksum: 10c0/6c4e19a9ddc6b7effb1e91feee9f3c4c0217bfacd7bf0a681aa392782720f8f25bacff2cf7ef6d121feca057f667276f2fd59c8759f12d7f1ef254ec39c76e4f + languageName: node + linkType: hard + "ajv-draft-04@npm:~1.0.0": version: 1.0.0 resolution: "ajv-draft-04@npm:1.0.0" @@ -28698,10 +28800,10 @@ __metadata: languageName: node linkType: hard -"eventsource-parser@npm:^3.0.0": - version: 3.0.6 - resolution: "eventsource-parser@npm:3.0.6" - checksum: 10c0/70b8ccec7dac767ef2eca43f355e0979e70415701691382a042a2df8d6a68da6c2fca35363669821f3da876d29c02abe9b232964637c1b6635c940df05ada78a +"eventsource-parser@npm:^3.0.0, eventsource-parser@npm:^3.0.8": + version: 3.0.8 + resolution: "eventsource-parser@npm:3.0.8" + checksum: 10c0/3a73eee85311f33b12fa558381a477c1bdcf8c024a429a9d48f87b043e328c26d24ed280fd7ca92e2fdd4c8c37f749b758420c1533778aaca2beabf895024efa languageName: node linkType: hard @@ -33481,6 +33583,13 @@ __metadata: languageName: node linkType: hard +"json-schema@npm:^0.4.0": + version: 0.4.0 + resolution: "json-schema@npm:0.4.0" + checksum: 10c0/d4a637ec1d83544857c1c163232f3da46912e971d5bf054ba44fdb88f07d8d359a462b4aec46f2745efbc57053365608d88bc1d7b1729f7b4fc3369765639ed3 + languageName: node + linkType: hard + "json-stable-stringify-without-jsonify@npm:^1.0.1": version: 1.0.1 resolution: "json-stable-stringify-without-jsonify@npm:1.0.1" @@ -43597,6 +43706,18 @@ __metadata: languageName: node linkType: hard +"swr@npm:^2.2.5": + version: 2.4.1 + resolution: "swr@npm:2.4.1" + dependencies: + dequal: "npm:^2.0.3" + use-sync-external-store: "npm:^1.6.0" + peerDependencies: + react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 10c0/34d61fb4653ac8875ad24e7c6da37e210b0e90fce0815dc59f013b7554a0bd267e79aac0f8ae5fbf04992e2a1815ee3da581b0dab3ed6ac4c2ce0e82b351320f + languageName: node + linkType: hard + "sylvester@npm:>= 0.0.8": version: 0.0.21 resolution: "sylvester@npm:0.0.21" @@ -43937,6 +44058,13 @@ __metadata: languageName: node linkType: hard +"throttleit@npm:2.1.0": + version: 2.1.0 + resolution: "throttleit@npm:2.1.0" + checksum: 10c0/1696ae849522cea6ba4f4f3beac1f6655d335e51b42d99215e196a718adced0069e48deaaf77f7e89f526ab31de5b5c91016027da182438e6f9280be2f3d5265 + languageName: node + linkType: hard + "through2@npm:^3.0.1": version: 3.0.2 resolution: "through2@npm:3.0.2" @@ -47252,14 +47380,7 @@ __metadata: languageName: node linkType: hard -"zod@npm:4.1.12": - version: 4.1.12 - resolution: "zod@npm:4.1.12" - checksum: 10c0/b64c1feb19e99d77075261eaf613e0b2be4dfcd3551eff65ad8b4f2a079b61e379854d066f7d447491fcf193f45babd8095551a9d47973d30b46b6d8e2c46774 - languageName: node - linkType: hard - -"zod@npm:^3.25 || ^4.0, zod@npm:^3.25.0 || ^4.0.0, zod@npm:^4.0.0, zod@npm:^4.1.5": +"zod@npm:4.4.3, zod@npm:^3.25 || ^4.0, zod@npm:^3.25.0 || ^4.0.0, zod@npm:^4.0.0, zod@npm:^4.1.5": version: 4.4.3 resolution: "zod@npm:4.4.3" checksum: 10c0/7ea31b558e88f9faf44f31dd185e2e1cbf51fed3081787fb96cc2534749b50c0acfc6da7f0922a7353ed092dd358c7d50c28ea96c94d04af64191bd33152eca3