From 7b6b6002937361ade45b0ce0c7c7d8fcc93ba296 Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Thu, 14 May 2026 14:00:31 +0100 Subject: [PATCH 1/4] move mutation actions from MutationProvider to core. add Zod schemas. prepare actions for AI assistance --- PETRINAUT_AI_ASSISTANCE_SPEC.md | 113 ++++ .../petrinaut/src/core/action-schemas.ts | 333 ++++++++++ .../petrinaut/src/core/actions.test.ts | 479 ++++++++++++++ libs/@hashintel/petrinaut/src/core/actions.ts | 622 ++++++++++++++++++ libs/@hashintel/petrinaut/src/core/ai.test.ts | 84 +++ libs/@hashintel/petrinaut/src/core/ai.ts | 213 ++++++ .../src/core/clipboard/serialize.test.ts | 41 ++ .../petrinaut/src/core/clipboard/types.ts | 51 +- .../core/file-format/parse-sdcpn-file.test.ts | 19 + .../petrinaut/src/core/file-format/types.ts | 73 +- libs/@hashintel/petrinaut/src/core/index.ts | 24 + .../@hashintel/petrinaut/src/core/instance.ts | 21 +- .../petrinaut/src/core/lib/typed-entries.ts | 47 ++ .../src/core/schemas/entity-schemas.ts | 241 +++++++ .../src/core/schemas/metric-schema.ts | 25 +- .../src/core/schemas/scenario-schema.ts | 103 ++- .../src/core/validation/display-name.ts | 2 +- .../src/core/validation/entity-name.ts | 2 +- libs/@hashintel/petrinaut/src/main.ts | 15 + .../src/react/mutation-provider.test.tsx | 36 +- .../petrinaut/src/react/mutation-provider.tsx | 595 +++++------------ .../src/react/state/mutation-context.ts | 90 +-- .../BottomBar/use-keyboard-shortcuts.ts | 2 +- .../subviews/differential-equations-list.tsx | 2 +- .../LeftSideBar/subviews/entities-tree.tsx | 7 +- .../LeftSideBar/subviews/parameters-list.tsx | 2 +- .../LeftSideBar/subviews/types-list.tsx | 2 +- .../PropertiesPanel/arc-properties/main.tsx | 54 +- .../context.tsx | 6 +- .../differential-equation-properties/main.tsx | 6 +- .../subviews/main.tsx | 52 +- .../PropertiesPanel/multi-selection-panel.tsx | 12 +- .../Editor/panels/PropertiesPanel/panel.tsx | 18 +- .../parameter-properties/context.tsx | 6 +- .../parameter-properties/main.tsx | 6 +- .../parameter-properties/subviews/main.tsx | 15 +- .../place-properties/context.tsx | 5 +- .../PropertiesPanel/place-properties/main.tsx | 3 +- .../place-properties/subviews/main.tsx | 60 +- .../subviews/place-visualizer/subview.tsx | 30 +- .../properties-panel.stories.tsx | 170 +++-- .../transition-properties/context.tsx | 33 +- .../transition-properties/main.tsx | 19 +- .../transition-properties/subviews/main.tsx | 75 +-- .../transition-firing-time/subview.tsx | 23 +- .../subviews/transition-results/subview.tsx | 16 +- .../type-properties/context.tsx | 7 +- .../PropertiesPanel/type-properties/main.tsx | 20 +- .../type-properties/subviews/main.tsx | 70 +- .../SimulateView/view-metric-drawer.tsx | 13 +- .../SimulateView/view-scenario-drawer.tsx | 16 +- .../src/ui/views/Editor/run-auto-layout.ts | 7 +- .../SDCPN/hooks/use-apply-node-changes.ts | 2 +- .../src/ui/views/SDCPN/sdcpn-view.tsx | 14 +- 54 files changed, 3022 insertions(+), 980 deletions(-) create mode 100644 PETRINAUT_AI_ASSISTANCE_SPEC.md create mode 100644 libs/@hashintel/petrinaut/src/core/action-schemas.ts create mode 100644 libs/@hashintel/petrinaut/src/core/actions.test.ts create mode 100644 libs/@hashintel/petrinaut/src/core/actions.ts create mode 100644 libs/@hashintel/petrinaut/src/core/ai.test.ts create mode 100644 libs/@hashintel/petrinaut/src/core/ai.ts create mode 100644 libs/@hashintel/petrinaut/src/core/lib/typed-entries.ts create mode 100644 libs/@hashintel/petrinaut/src/core/schemas/entity-schemas.ts diff --git a/PETRINAUT_AI_ASSISTANCE_SPEC.md b/PETRINAUT_AI_ASSISTANCE_SPEC.md new file mode 100644 index 00000000000..fe492663cf4 --- /dev/null +++ b/PETRINAUT_AI_ASSISTANCE_SPEC.md @@ -0,0 +1,113 @@ +# Petrinaut AI assistance MVP + +# Goals + +1. Implement a simple chat where users can ask an LLM to generate or revise a Petri net. +2. Use the [Figma sidebar chat design](https://www.figma.com/design/EuokCTrNYWhEMBQ7MmGrwJ/Petrinaut?node-id=194-63830&m=dev) +3. Out of scope for MVP: + 1. Chat tabs + 2. Actions + 3. Suggestion review (LLM will just edit the net directly, but we can have a summary of what was changed) + +# Technical components / plan + +Split by which part of Petrinaut they affect. + +## Decisions captured before implementation + +1. The first implementation stage is the `core` stage. +2. Core mutation methods now use object-style inputs everywhere, inferred from Zod action schemas. Do not reintroduce callback-style update functions in the public mutation context. +3. All existing mutation helpers should move into `core` and be available as AI tools, including destructive actions such as remove/delete helpers. For the MVP, tool calls execute directly without suggestion review or confirmation. +4. The prompt generator should inline `probabilisticSatellitesSDCPN` from `src/examples/satellites-launcher.ts`, provided it continues to demonstrate the relevant SDCPN features compactly: stochastic transitions, predicate transitions, transition kernels with distributions, coloured tokens, dynamics, differential equations, parameters, visualizer code, and scenarios. +5. The UI and website stages should consume the exported core AI tool metadata and callback map, rather than redefining tool names or schemas outside `core`. + +# `core (libs/@hashintel/petrinaut)` + +This is where the non-UI logic lives. + +I’ve put the non-UI AI stuff here for now. It could go in an `core/ai` folder. Alternatively we can create another area for it. + +### Core stage status + +The core stage has been implemented. Future stages should build on these exported APIs rather than redefining tool names, schemas, prompts, or callbacks outside `core`. + +1. Core action input schemas live in `core/action-schemas.ts` and are exported for AI use as `petrinautAiToolInputSchemas`. +2. Runtime mutation validation is performed by the core actions using `mutationActionInputSchemas`. +3. AI tool metadata is exported as `petrinautAiTools`. These tools deliberately do not include `execute`, because tool calls are applied client-side to the live Petrinaut instance. +4. Client-side tool execution should use `createPetrinautAiToolCallbacks(instance)`. +5. The prompt generator is exported as `createPetrinautAiPrompt()`. +6. Useful exported types include `PetrinautAiToolName`, `PetrinautAiToolInput`, `PetrinautAiToolCallbacks`, and `PetrinautAiTools`. +7. Update payloads are intentionally lean: + 1. Entity IDs are passed separately from `update`. + 2. Place/transition positions should use `updatePlacePosition`, `updateTransitionPosition`, or `commitNodePositions`. + 3. Arc edits should use `addArc`, `removeArc`, `updateArcWeight`, `updateArcType`, or `updateArcPlace`. + 4. Type element edits should use `addTypeElement`, `updateTypeElement`, `removeTypeElement`, or `moveTypeElement`. + 5. Broad array replacement through `updateTransition({ update: { inputArcs/outputArcs } })` or `updateType({ update: { elements } })` is intentionally not supported. + +### Requirements + +1. Granular actions to modify a Petri net (e.g. `addPlace`, `addTransition`) which are typed using a zod schema with lots of explanation of each, so that LLM tool calls can be generated from the schemas and we don’t have to separately maintain a list of LLM tools. As a byproduct it also improves the `core`. +2. The schemas should match the object-style core action inputs, not higher-level intent inputs. For update helpers, define serializable tool inputs that can be applied by core callbacks without exposing arbitrary functions to the LLM. +3. A set of LLM tools which incorporate the above. These will be exported for use by the server making the tool call and the frontend processing the response. +4. An LLM prompt generator for use in a Petri net-building AI assistant. This should inline `probabilisticSatellitesSDCPN` from the `examples` folder, which compactly demonstrates probabilistic transitions, dynamics, colours, stochastic firing, transition kernels, parameters, visualizer code, and scenarios. + +### Work + +1. Move `addPlace` etc from the `react` area of Petrinaut to the `core`, as methods on the `Petrinaut` instance, using object-style action inputs. +2. Create well-described Zod schemas for each serializable action input, using inferred types where this does not make the existing API worse. Include full documentation of features on appropriate actions (e.g. creating a differential equation explaining dynamics; creating/amending a transition explaining probability distributions in transition kernels, etc). +3. Add a function which generates the LLM tool bundle from the schemas (using [Vercel’s AI SDK structure](https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling#tool-calling) as a target) in `core/ai` + 1. This will have two consumers (see [Chat bot tool usage](https://ai-sdk.dev/docs/ai-sdk-ui/chatbot-tool-usage#client-side-page) in the Vercel AI UI SDK) + 1. The React side will use this as a reference for how to handle tool calls (via `onToolCall`), including getting type inference (see `ui` section below) + 2. The server brokering the LLM calls – currently only `petrinaut-website` – will use this when calling the LLM, alongside the prompt. + 2. Note that because this is a client-side action, these shouldn’t include an `execute` property. But it should make a typed map of tool name to callback available. + 3. We should use a conditional mapped type (or similar) to ensure that the return of the LLM tool bundle generator and callback map remain in sync. +4. Add the LLM prompt generator, for consumption by the server making the call (in the first instance, `petrinaut-website`). +5. Add Zod validation to all granular actions that are now in `core`. This improves the `core` because it provides runtime errors if an input is inconsistent with expectations, including cases where input type safety is weakened by type casting or incoming AI/tool payloads. + 1. Ensure meaningful/nice error messages as feedback + 2. Add tests to check for validation failures when adding an incorrect input + +# **`ui (libs/@hashintel/petrinaut)`** + +### Requirements / Work + +Use the Figma MCP to get reference design details (then translate into PandaCSS / ArkUI / ds-component equivalents) + +1. The button to summon the AI is added to the toolbar – [see component reference in Figma](https://www.figma.com/design/WmnosvOvi4Blw0HK0jh1mG/Design-System?node-id=44988-5621&m=dev) + 1. Note that the button has a blue icon on hover, and when the AI chat is opened should have a gray background. [See reference design within this screen](https://www.figma.com/design/EuokCTrNYWhEMBQ7MmGrwJ/Petrinaut?node-id=194-63830&m=dev). +2. It will open a chat window that appears adjacent to the right-hand side node panel (if open), otherwise flush to the right. [Reference design for the chat window itself here](https://www.figma.com/design/EuokCTrNYWhEMBQ7MmGrwJ/Petrinaut?node-id=194-63837&m=dev) +3. Agent output should be streamed and formatted as Markdown. +4. Reasoning stream parts should also be streamed and then collapsed when complete. [See reference design for chat parts here.](https://www.figma.com/design/EuokCTrNYWhEMBQ7MmGrwJ/Petrinaut?node-id=430-47402&m=dev) This displays both the collapsed view (”Reasoning”) and expanded (”Understanding prompt requirements”). +5. All UI should use existing conventions within Petrinaut (e.g. related to usage of PandaCSS and ArkUI, and `ds-components` where possible). +6. The UI app uses React compiler and should avoid `useMemo`. `useEffect` should be used sparingly, if at all. +7. The chat window should use the current editor `Petrinaut` instance/handle so tool calls mutate the live document. It may create a callback map with `createPetrinautAiToolCallbacks(instance)`, but must not create an isolated JSON document unless building an explicit preview/review mode. +8. Completed tool calls should be shown as ‘Added Node X’ style things, [see reference design here](https://www.figma.com/design/EuokCTrNYWhEMBQ7MmGrwJ/Petrinaut?node-id=556-247310&m=dev). If an agent does multiple things, these should be expandable lists. Clicking on an individual item (e.g. a node) should select it in the UI. +9. It can [infer types](https://ai-sdk.dev/docs/ai-sdk-ui/chatbot#type-inference-for-tools) from the tools exported by `core`. +10. The chat should use Vercel AI SDK’s [useChat](https://ai-sdk.dev/docs/ai-sdk-ui/chatbot). It will accept a prop which provides the [transport](https://ai-sdk.dev/docs/ai-sdk-ui/transport), which should conform to Vercel’s `DefaultChatTransport` interface. Whether this prop is available or not determines whether the AI button/feature is available at all. It can be a prop to the entrypoint `petrinaut.tsx` component and then passed directly to `EditorView`. +11. Add appropriate unit tests which test meaningful behaviour, using appropriate mocks for the AI call (e.g. checking that the right chat elements are rendered when the model is thinking, has called a tool, etc). + +### UI handoff notes + +1. Use `petrinautAiTools` for tool typing and `createPetrinautAiToolCallbacks(instance)` for client-side tool execution. +2. Tool calls should apply to the live Petrinaut instance via `onToolCall`, not on the server. +3. Tool-call UI should map tool names to concise human-readable summaries, such as “Added place X”, “Updated arc weight”, or “Removed type Y”. +4. Completed tool summaries should select or focus affected entities where the tool input identifies one. For non-entity or batch mutations, show a generic mutation summary. +5. Destructive tools are allowed for the MVP because there is no suggestion review. The UI should rely on existing document history/undo rather than building an AI-specific review flow. +6. Feature availability should be controlled by the optional chat transport prop. If no transport is provided, hide or disable the AI entry point. + +# `apps/petrinaut-website` + +1. Add the Vercel AI SDK in a server endpoint for the chat window to consume, following the requirements of the Vercel AI SDK (i.e. endpoint accepting messages/sendMessages and streaming response back). `petrinaut-website` is currently a Vite app, so this may require adding an appropriate Vercel/serverless function rather than a Next.js server action. See reference implementation [here](https://ai-sdk.dev/docs/ai-sdk-ui/chatbot-tool-usage#api-route). +2. Set up a provider registry with only OpenAI for now – [see Vercel docs](https://ai-sdk.dev/docs/ai-sdk-core/provider-management) +3. Add an `OPENAI_API_KEY` environment variable. NOT PUBLIC! Should not be exposed to the browser. +4. Use GPT-5.5 model, `gpt-5.5-2026-04-23`, as the default model, but keep the model configurable via server-side environment/configuration. +5. Import `petrinautAiTools` and `createPetrinautAiPrompt` from Petrinaut; do not duplicate tool schemas or prompt text in the website. +6. The endpoint streams model output and tool-call parts but does not execute Petrinaut mutations server-side. The browser applies tool calls to the live editor instance through the UI `onToolCall` path. +7. Protect the endpoint before exposing it publicly: at minimum add request size limits, origin/CORS checks, and rate limiting. +8. Add the prop created for the chat transport to Petrinaut in the demo website. + +## Known non-goals for the next two phases + +1. Do not add suggestion review or approval flows for MVP; tool calls edit the live net directly. +2. Do not add chat tabs. +3. Do not add AI-specific undo/redo UX beyond existing Petrinaut document history. +4. Do not persist chat history server-side unless explicitly scoped later. diff --git a/libs/@hashintel/petrinaut/src/core/action-schemas.ts b/libs/@hashintel/petrinaut/src/core/action-schemas.ts new file mode 100644 index 00000000000..835b27ae4bc --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/action-schemas.ts @@ -0,0 +1,333 @@ +import { z } from "zod"; + +import type { SelectionItem } from "./types/selection"; +import { + arcDirectionSchema, + colorElementSchema, + colorSchema, + differentialEquationSchema, + idSchema, + nodePositionCommitSchema, + parameterSchema, + placeSchema, + positionSchema, + transitionSchema, +} from "./schemas/entity-schemas"; +import { metricSchema as simulationMetricSchema } from "./schemas/metric-schema"; +import { scenarioSchema as simulationScenarioSchema } from "./schemas/scenario-schema"; + +export { + arcDirectionSchema, + colorElementSchema, + colorSchema, + differentialEquationSchema, + idSchema, + nodePositionCommitSchema, + parameterSchema, + placeSchema, + positionSchema, + transitionSchema, +} from "./schemas/entity-schemas"; +export { + metricSchema as simulationMetricSchema, + type MetricSchema, +} from "./schemas/metric-schema"; +export { + scenarioParameterSchema, + scenarioSchema as simulationScenarioSchema, + type ScenarioSchema, +} from "./schemas/scenario-schema"; +export { + simulationMetricSchema as metricSchema, + simulationScenarioSchema as scenarioSchema, +}; + +export const placeUpdateSchema = placeSchema + .omit({ id: true, x: true, y: true }) + .partial() + .meta({ + description: + "Fields to assign to an existing place. Omitted fields are left unchanged.", + }); + +export const transitionUpdateSchema = transitionSchema + .omit({ id: true, inputArcs: true, outputArcs: true, x: true, y: true }) + .partial() + .meta({ + description: + "Fields to assign to an existing transition. Omitted fields are left unchanged.", + }); + +export const colorUpdateSchema = colorSchema + .omit({ id: true, elements: true }) + .partial() + .meta({ + description: + "Fields to assign to an existing colour/type. Omitted fields are left unchanged.", + }); + +export const colorElementUpdateSchema = colorElementSchema + .omit({ elementId: true }) + .partial() + .meta({ + description: + "Fields to assign to an existing colour/type element. Omitted fields are left unchanged.", + }); + +export const differentialEquationUpdateSchema = differentialEquationSchema + .omit({ id: true }) + .partial() + .meta({ + description: + "Fields to assign to an existing differential equation. Omitted fields are left unchanged.", + }); + +export const parameterUpdateSchema = parameterSchema + .omit({ id: true }) + .partial() + .meta({ + description: + "Fields to assign to an existing parameter. Omitted fields are left unchanged.", + }); + +export const scenarioUpdateSchema = simulationScenarioSchema + .omit({ id: true }) + .partial() + .meta({ + description: + "Fields to assign to an existing scenario. Omitted fields are left unchanged.", + }); + +export const metricUpdateSchema = simulationMetricSchema + .omit({ id: true }) + .partial() + .meta({ + description: + "Fields to assign to an existing metric. Omitted fields are left unchanged.", + }); + +export const itemTypeAndIdSchema = z + .discriminatedUnion("type", [ + z.strictObject({ type: z.literal("place"), id: idSchema }), + z.strictObject({ type: z.literal("transition"), id: idSchema }), + z.strictObject({ type: z.literal("arc"), id: idSchema }), + z.strictObject({ type: z.literal("type"), id: idSchema }), + z.strictObject({ type: z.literal("differentialEquation"), id: idSchema }), + z.strictObject({ type: z.literal("parameter"), id: idSchema }), + ]) + .meta({ + description: + "An item to delete. Arc IDs use Petrinaut's generated arc ID format.", + }) satisfies z.ZodType; + +export const mutationActionInputSchemas = { + addPlace: placeSchema.meta({ + description: "Add a place that stores tokens in the SDCPN.", + }), + updatePlace: z + .strictObject({ + placeId: idSchema, + update: placeUpdateSchema, + }) + .meta({ description: "Update fields on an existing place." }), + updatePlacePosition: z + .strictObject({ + placeId: idSchema, + position: positionSchema, + }) + .meta({ description: "Update an existing place's canvas position." }), + removePlace: z + .strictObject({ placeId: idSchema }) + .meta({ description: "Remove a place and any arcs connected to it." }), + addTransition: transitionSchema.meta({ + description: "Add a transition with firing logic and arcs.", + }), + updateTransition: z + .strictObject({ + transitionId: idSchema, + update: transitionUpdateSchema, + }) + .meta({ + description: + "Update a transition's properties, arcs, or executable code.", + }), + updateTransitionPosition: z + .strictObject({ + transitionId: idSchema, + position: positionSchema, + }) + .meta({ description: "Update an existing transition's canvas position." }), + removeTransition: z + .strictObject({ transitionId: idSchema }) + .meta({ description: "Remove a transition." }), + addArc: z + .strictObject({ + transitionId: idSchema, + arcDirection: arcDirectionSchema, + placeId: idSchema, + weight: z.number().positive().meta({ + description: "Token multiplicity for the arc.", + }), + }) + .meta({ description: "Add an input or output arc to a transition." }), + removeArc: z + .strictObject({ + transitionId: idSchema, + arcDirection: arcDirectionSchema, + placeId: idSchema, + }) + .meta({ description: "Remove an input or output arc from a transition." }), + updateArcWeight: z + .strictObject({ + transitionId: idSchema, + arcDirection: arcDirectionSchema, + placeId: idSchema, + weight: z.number().positive().meta({ + description: "Replacement token multiplicity for the arc.", + }), + }) + .meta({ description: "Update the token weight on an existing arc." }), + updateArcType: z + .strictObject({ + transitionId: idSchema, + placeId: idSchema, + type: z.enum(["standard", "inhibitor"]).meta({ + description: "Replacement input arc type.", + }), + }) + .meta({ description: "Update an existing input arc's type." }), + updateArcPlace: z + .strictObject({ + transitionId: idSchema, + arcDirection: arcDirectionSchema, + oldPlaceId: idSchema.meta({ + description: "Current place ID used by the arc.", + }), + newPlaceId: idSchema.meta({ + description: "Replacement place ID for the arc.", + }), + }) + .meta({ description: "Update the place endpoint on an existing arc." }), + addType: colorSchema.meta({ + description: "Add a coloured-token type.", + }), + updateType: z + .strictObject({ + typeId: idSchema, + update: colorUpdateSchema, + }) + .meta({ description: "Update fields on an existing colour/type." }), + removeType: z.strictObject({ typeId: idSchema }).meta({ + description: + "Remove a colour/type and clear references from places and dynamics.", + }), + addTypeElement: z + .strictObject({ + typeId: idSchema, + element: colorElementSchema, + }) + .meta({ description: "Add an element to a coloured-token type." }), + updateTypeElement: z + .strictObject({ + typeId: idSchema, + elementId: idSchema, + update: colorElementUpdateSchema, + }) + .meta({ description: "Update fields on an existing type element." }), + removeTypeElement: z + .strictObject({ + typeId: idSchema, + elementId: idSchema, + }) + .meta({ description: "Remove an element from a coloured-token type." }), + moveTypeElement: z + .strictObject({ + typeId: idSchema, + elementId: idSchema, + toIndex: z.number().int().nonnegative().meta({ + description: "Destination index for the element within the type.", + }), + }) + .meta({ description: "Move an element within a coloured-token type." }), + addDifferentialEquation: differentialEquationSchema.meta({ + description: "Add continuous dynamics for a coloured-token type.", + }), + updateDifferentialEquation: z + .strictObject({ + equationId: idSchema, + update: differentialEquationUpdateSchema, + }) + .meta({ + description: "Update fields on an existing differential equation.", + }), + removeDifferentialEquation: z.strictObject({ equationId: idSchema }).meta({ + description: + "Remove a differential equation and clear references from places.", + }), + addParameter: parameterSchema.meta({ + description: "Add a net-level parameter available to SDCPN code.", + }), + updateParameter: z + .strictObject({ + parameterId: idSchema, + update: parameterUpdateSchema, + }) + .meta({ description: "Update fields on an existing parameter." }), + removeParameter: z + .strictObject({ parameterId: idSchema }) + .meta({ description: "Remove a net-level parameter." }), + addScenario: simulationScenarioSchema.meta({ + description: "Add a simulation scenario.", + }), + updateScenario: z + .strictObject({ + scenarioId: idSchema, + update: scenarioUpdateSchema, + }) + .meta({ description: "Update fields on an existing scenario." }), + removeScenario: z + .strictObject({ scenarioId: idSchema }) + .meta({ description: "Remove a simulation scenario." }), + addMetric: simulationMetricSchema.meta({ + description: "Add a simulation metric.", + }), + updateMetric: z + .strictObject({ + metricId: idSchema, + update: metricUpdateSchema, + }) + .meta({ description: "Update fields on an existing metric." }), + removeMetric: z + .strictObject({ metricId: idSchema }) + .meta({ description: "Remove a simulation metric." }), + deleteItemsByIds: z + .strictObject({ + items: z.array(itemTypeAndIdSchema).meta({ + description: "Items to delete in one mutation.", + }), + }) + .meta({ description: "Delete selected SDCPN items by ID." }), + commitNodePositions: z + .strictObject({ + commits: z.array(nodePositionCommitSchema).meta({ + description: "Node positions to commit.", + }), + }) + .meta({ description: "Commit multiple place/transition positions." }), +} as const; + +export type PlaceInput = z.infer; +export type TransitionInput = z.infer; +export type ColorInput = z.infer; +export type DifferentialEquationInput = z.infer< + typeof differentialEquationSchema +>; +export type ParameterInput = z.infer; +export type ScenarioInput = z.infer; +export type MetricInput = z.infer; +export type NodePositionCommitInput = z.infer; + +export type MutationActionName = keyof typeof mutationActionInputSchemas; +export type MutationActionInput = z.infer< + (typeof mutationActionInputSchemas)[Name] +>; diff --git a/libs/@hashintel/petrinaut/src/core/actions.test.ts b/libs/@hashintel/petrinaut/src/core/actions.test.ts new file mode 100644 index 00000000000..38c2cf66934 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/actions.test.ts @@ -0,0 +1,479 @@ +import { describe, expect, test } from "vitest"; + +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: structuredClone(initial) }), + }); + +const callActionWithUnknownInput = ( + action: (input: Input) => void, + input: unknown, +): void => { + action(input as Input); +}; + +describe("Petrinaut core actions", () => { + test("adds and updates places", () => { + const instance = createInstance(); + + instance.addPlace({ + id: "place-1", + name: "Queue", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }); + instance.updatePlace({ + placeId: "place-1", + update: { + name: "UpdatedQueue", + }, + }); + instance.updatePlacePosition({ + placeId: "place-1", + position: { x: 12, y: 24 }, + }); + + expect(instance.definition.get().places).toEqual([ + { + id: "place-1", + name: "UpdatedQueue", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 12, + y: 24, + }, + ]); + }); + + test("removing a place also removes connected arcs", () => { + 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: 100, + 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: 50, + y: 0, + }, + ], + }); + + instance.removePlace({ placeId: "place-1" }); + + const definition = instance.definition.get(); + expect(definition.places.map((place) => place.id)).toEqual(["place-2"]); + expect(definition.transitions[0]!.inputArcs).toEqual([]); + expect(definition.transitions[0]!.outputArcs).toEqual([ + { placeId: "place-2", weight: 1 }, + ]); + }); + + test("updates arc endpoints granularly", () => { + const instance = createInstance({ + ...emptySDCPN, + 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: 50, + y: 0, + }, + ], + }); + + instance.updateArcPlace({ + transitionId: "transition-1", + arcDirection: "input", + oldPlaceId: "place-1", + newPlaceId: "place-3", + }); + instance.updateArcPlace({ + transitionId: "transition-1", + arcDirection: "output", + oldPlaceId: "place-2", + newPlaceId: "place-4", + }); + + expect(instance.definition.get().transitions[0]).toMatchObject({ + inputArcs: [{ placeId: "place-3", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "place-4", weight: 1 }], + }); + }); + + test("adds, updates, removes, and moves type elements granularly", () => { + const instance = createInstance({ + ...emptySDCPN, + types: [ + { + id: "type-1", + name: "Particle", + iconSlug: "circle", + displayColor: "#34a0fa", + elements: [ + { elementId: "element-1", name: "Mass", type: "real" }, + { elementId: "element-2", name: "Velocity", type: "real" }, + ], + }, + ], + }); + + instance.addTypeElement({ + typeId: "type-1", + element: { elementId: "element-3", name: "Charge", type: "integer" }, + }); + instance.updateTypeElement({ + typeId: "type-1", + elementId: "element-1", + update: { name: "MassKg" }, + }); + instance.moveTypeElement({ + typeId: "type-1", + elementId: "element-3", + toIndex: 1, + }); + instance.removeTypeElement({ + typeId: "type-1", + elementId: "element-2", + }); + + expect(instance.definition.get().types[0]!.elements).toEqual([ + { elementId: "element-1", name: "MassKg", type: "real" }, + { elementId: "element-3", name: "Charge", type: "integer" }, + ]); + }); + + test("deleteItemsByIds removes referenced types and equations", () => { + const instance = createInstance({ + ...emptySDCPN, + places: [ + { + id: "place-1", + name: "Dynamic", + colorId: "type-1", + dynamicsEnabled: true, + differentialEquationId: "equation-1", + x: 0, + y: 0, + }, + ], + types: [ + { + id: "type-1", + name: "Particle", + iconSlug: "circle", + displayColor: "#34a0fa", + elements: [], + }, + ], + differentialEquations: [ + { + id: "equation-1", + name: "Motion", + colorId: "type-1", + code: "export default Dynamics(() => []);", + }, + ], + }); + + instance.deleteItemsByIds({ + items: [ + { type: "type", id: "type-1" }, + { type: "differentialEquation", id: "equation-1" }, + ], + }); + + const definition = instance.definition.get(); + expect(definition.types).toEqual([]); + expect(definition.differentialEquations).toEqual([]); + expect(definition.places[0]!.colorId).toBeNull(); + expect(definition.places[0]!.differentialEquationId).toBeNull(); + }); + + test("does not mutate readonly instances", () => { + const instance = createPetrinaut({ + document: createJsonDocHandle({ initial: structuredClone(emptySDCPN) }), + readonly: true, + }); + + instance.addPlace({ + id: "place-1", + name: "Queue", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }); + + expect(instance.definition.get().places).toEqual([]); + }); + + test("validates add action inputs before mutating", () => { + const instance = createInstance(); + + expect(() => + instance.addPlace({ + id: "", + name: "Queue", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }), + ).toThrow(); + + expect(instance.definition.get().places).toEqual([]); + }); + + test("validates callback-updated entities", () => { + const instance = createInstance(); + + instance.addPlace({ + id: "place-1", + name: "Queue", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }); + + expect(() => + instance.updatePlace({ + placeId: "place-1", + update: { + name: "", + }, + }), + ).toThrow(); + }); + + test("rejects over-wide update action payloads", () => { + const instance = createInstance(); + + expect(() => + callActionWithUnknownInput(instance.updatePlace, { + placeId: "place-1", + update: { id: "place-2" }, + }), + ).toThrow(); + expect(() => + callActionWithUnknownInput(instance.updatePlace, { + placeId: "place-1", + update: { x: 10 }, + }), + ).toThrow(); + expect(() => + callActionWithUnknownInput(instance.updateTransition, { + transitionId: "transition-1", + update: { inputArcs: [] }, + }), + ).toThrow(); + expect(() => + callActionWithUnknownInput(instance.updateTransition, { + transitionId: "transition-1", + update: { y: 10 }, + }), + ).toThrow(); + expect(() => + callActionWithUnknownInput(instance.updateType, { + typeId: "type-1", + update: { elements: [] }, + }), + ).toThrow(); + expect(() => + callActionWithUnknownInput(instance.updateTypeElement, { + typeId: "type-1", + elementId: "element-1", + update: { elementId: "element-2" }, + }), + ).toThrow(); + expect(() => + callActionWithUnknownInput(instance.updateDifferentialEquation, { + equationId: "equation-1", + update: { id: "equation-2" }, + }), + ).toThrow(); + expect(() => + callActionWithUnknownInput(instance.updateParameter, { + parameterId: "parameter-1", + update: { id: "parameter-2" }, + }), + ).toThrow(); + expect(() => + callActionWithUnknownInput(instance.updateScenario, { + scenarioId: "scenario-1", + update: { id: "scenario-2" }, + }), + ).toThrow(); + expect(() => + callActionWithUnknownInput(instance.updateMetric, { + metricId: "metric-1", + update: { id: "metric-2" }, + }), + ).toThrow(); + }); + + test("validates granular arc and type element action inputs", () => { + const instance = createInstance({ + ...emptySDCPN, + transitions: [ + { + id: "transition-1", + name: "Move", + inputArcs: [{ placeId: "place-1", weight: 1, type: "standard" }], + outputArcs: [], + lambdaType: "predicate", + lambdaCode: "export default Lambda(() => true);", + transitionKernelCode: "", + x: 50, + y: 0, + }, + ], + types: [ + { + id: "type-1", + name: "Particle", + iconSlug: "circle", + displayColor: "#34a0fa", + elements: [{ elementId: "element-1", name: "Mass", type: "real" }], + }, + ], + }); + + expect(() => + instance.updateArcPlace({ + transitionId: "transition-1", + arcDirection: "input", + oldPlaceId: "place-1", + newPlaceId: "", + }), + ).toThrow(); + expect(() => + instance.addTypeElement({ + typeId: "type-1", + element: { elementId: "element-2", name: "", type: "real" }, + }), + ).toThrow(); + expect(() => + instance.moveTypeElement({ + typeId: "type-1", + elementId: "element-1", + toIndex: -1, + }), + ).toThrow(); + + expect(instance.definition.get().transitions[0]!.inputArcs).toEqual([ + { placeId: "place-1", weight: 1, type: "standard" }, + ]); + expect(instance.definition.get().types[0]!.elements).toEqual([ + { elementId: "element-1", name: "Mass", type: "real" }, + ]); + }); + + test("reuses existing name validation rules for action inputs", () => { + const instance = createInstance(); + + expect(() => + instance.addPlace({ + id: "place-1", + name: "invalid place name", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }), + ).toThrow(); + + expect(() => + instance.addTransition({ + id: "transition-1", + name: "Display Name", + inputArcs: [], + outputArcs: [], + lambdaType: "predicate", + lambdaCode: "", + transitionKernelCode: "", + x: 0, + y: 0, + }), + ).not.toThrow(); + }); + + test("preserves scenario-specific validation in action inputs", () => { + const instance = createInstance(); + + expect(() => + instance.addScenario({ + id: "scenario-1", + name: "Scenario", + scenarioParameters: [ + { type: "real", identifier: "launch_rate", default: 1 }, + { type: "integer", identifier: "launch_rate", default: 2 }, + ], + parameterOverrides: {}, + initialState: { type: "per_place", content: {} }, + }), + ).toThrow(); + + expect(() => + instance.addScenario({ + id: "scenario-1", + name: "Scenario", + scenarioParameters: [ + { type: "real", identifier: "LaunchRate", default: 1 }, + ], + parameterOverrides: {}, + initialState: { type: "per_place", content: {} }, + }), + ).toThrow(); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/core/actions.ts b/libs/@hashintel/petrinaut/src/core/actions.ts new file mode 100644 index 00000000000..b3feef4e73a --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/actions.ts @@ -0,0 +1,622 @@ +import { generateArcId } from "./arc-id"; +import { + colorSchema, + differentialEquationSchema, + metricSchema, + parameterSchema, + mutationActionInputSchemas, + placeSchema, + scenarioSchema, + transitionSchema, + type MutationActionInput, +} from "./action-schemas"; +import type { SDCPN } from "./types/sdcpn"; + +export type MutationHelperFunctions = { + [Name in keyof typeof mutationActionInputSchemas]: ( + input: MutationActionInput, + ) => void; +}; + +export function createPetrinautActions( + mutate: (fn: (sdcpn: SDCPN) => void) => void, +): MutationHelperFunctions { + return { + addPlace(place) { + const parsedPlace = placeSchema.parse(place); + mutate((sdcpn) => { + sdcpn.places.push(parsedPlace); + }); + }, + updatePlace(input) { + const parsed = mutationActionInputSchemas.updatePlace.parse(input); + mutate((sdcpn) => { + for (const place of sdcpn.places) { + if (place.id === parsed.placeId) { + Object.assign(place, parsed.update); + placeSchema.parse(place); + break; + } + } + }); + }, + updatePlacePosition(input) { + const parsed = + mutationActionInputSchemas.updatePlacePosition.parse(input); + mutate((sdcpn) => { + for (const place of sdcpn.places) { + if (place.id === parsed.placeId) { + place.x = parsed.position.x; + place.y = parsed.position.y; + break; + } + } + }); + }, + removePlace(input) { + const { placeId: parsedPlaceId } = + mutationActionInputSchemas.removePlace.parse(input); + mutate((sdcpn) => { + for (const [placeIndex, place] of sdcpn.places.entries()) { + if (place.id === parsedPlaceId) { + sdcpn.places.splice(placeIndex, 1); + + for (const transition of sdcpn.transitions) { + for (let i = transition.inputArcs.length - 1; i >= 0; i--) { + if (transition.inputArcs[i]!.placeId === parsedPlaceId) { + transition.inputArcs.splice(i, 1); + } + } + for (let i = transition.outputArcs.length - 1; i >= 0; i--) { + if (transition.outputArcs[i]!.placeId === parsedPlaceId) { + transition.outputArcs.splice(i, 1); + } + } + } + break; + } + } + }); + }, + addTransition(transition) { + const parsedTransition = transitionSchema.parse(transition); + mutate((sdcpn) => { + sdcpn.transitions.push(parsedTransition); + }); + }, + updateTransition(input) { + const parsed = mutationActionInputSchemas.updateTransition.parse(input); + mutate((sdcpn) => { + for (const transition of sdcpn.transitions) { + if (transition.id === parsed.transitionId) { + Object.assign(transition, parsed.update); + transitionSchema.parse(transition); + break; + } + } + }); + }, + updateTransitionPosition(input) { + const parsed = + mutationActionInputSchemas.updateTransitionPosition.parse(input); + mutate((sdcpn) => { + for (const transition of sdcpn.transitions) { + if (transition.id === parsed.transitionId) { + transition.x = parsed.position.x; + transition.y = parsed.position.y; + break; + } + } + }); + }, + removeTransition(input) { + const { transitionId: parsedTransitionId } = + mutationActionInputSchemas.removeTransition.parse(input); + mutate((sdcpn) => { + for (const [index, transition] of sdcpn.transitions.entries()) { + if (transition.id === parsedTransitionId) { + sdcpn.transitions.splice(index, 1); + break; + } + } + }); + }, + addArc(input) { + const parsed = mutationActionInputSchemas.addArc.parse(input); + mutate((sdcpn) => { + for (const transition of sdcpn.transitions) { + if (transition.id === parsed.transitionId) { + if (parsed.arcDirection === "input") { + transition.inputArcs.push({ + type: "standard", + placeId: parsed.placeId, + weight: parsed.weight, + }); + } else { + transition.outputArcs.push({ + placeId: parsed.placeId, + weight: parsed.weight, + }); + } + break; + } + } + }); + }, + removeArc(input) { + const parsed = mutationActionInputSchemas.removeArc.parse(input); + mutate((sdcpn) => { + for (const transition of sdcpn.transitions) { + if (transition.id === parsed.transitionId) { + for (const [index, arc] of transition[ + parsed.arcDirection === "input" ? "inputArcs" : "outputArcs" + ].entries()) { + if (arc.placeId === parsed.placeId) { + transition[ + parsed.arcDirection === "input" ? "inputArcs" : "outputArcs" + ].splice(index, 1); + break; + } + } + break; + } + } + }); + }, + updateArcWeight(input) { + const parsed = mutationActionInputSchemas.updateArcWeight.parse(input); + mutate((sdcpn) => { + for (const transition of sdcpn.transitions) { + if (transition.id === parsed.transitionId) { + for (const arc of transition[ + parsed.arcDirection === "input" ? "inputArcs" : "outputArcs" + ]) { + if (arc.placeId === parsed.placeId) { + arc.weight = parsed.weight; + break; + } + } + break; + } + } + }); + }, + updateArcType(input) { + const parsed = mutationActionInputSchemas.updateArcType.parse(input); + mutate((sdcpn) => { + for (const transition of sdcpn.transitions) { + if (transition.id === parsed.transitionId) { + for (const arc of transition.inputArcs) { + if (arc.placeId === parsed.placeId) { + arc.type = parsed.type; + break; + } + } + break; + } + } + }); + }, + updateArcPlace(input) { + const parsed = mutationActionInputSchemas.updateArcPlace.parse(input); + mutate((sdcpn) => { + for (const transition of sdcpn.transitions) { + if (transition.id === parsed.transitionId) { + for (const arc of transition[ + parsed.arcDirection === "input" ? "inputArcs" : "outputArcs" + ]) { + if (arc.placeId === parsed.oldPlaceId) { + arc.placeId = parsed.newPlaceId; + break; + } + } + break; + } + } + }); + }, + addType(type) { + const parsedType = colorSchema.parse(type); + mutate((sdcpn) => { + sdcpn.types.push(parsedType); + }); + }, + updateType(input) { + const parsed = mutationActionInputSchemas.updateType.parse(input); + mutate((sdcpn) => { + for (const type of sdcpn.types) { + if (type.id === parsed.typeId) { + Object.assign(type, parsed.update); + colorSchema.parse(type); + break; + } + } + }); + }, + addTypeElement(input) { + const parsed = mutationActionInputSchemas.addTypeElement.parse(input); + mutate((sdcpn) => { + for (const type of sdcpn.types) { + if (type.id === parsed.typeId) { + type.elements.push(parsed.element); + colorSchema.parse(type); + break; + } + } + }); + }, + updateTypeElement(input) { + const parsed = mutationActionInputSchemas.updateTypeElement.parse(input); + mutate((sdcpn) => { + for (const type of sdcpn.types) { + if (type.id === parsed.typeId) { + for (const element of type.elements) { + if (element.elementId === parsed.elementId) { + Object.assign(element, parsed.update); + colorSchema.parse(type); + break; + } + } + break; + } + } + }); + }, + removeTypeElement(input) { + const parsed = mutationActionInputSchemas.removeTypeElement.parse(input); + mutate((sdcpn) => { + for (const type of sdcpn.types) { + if (type.id === parsed.typeId) { + for (const [index, element] of type.elements.entries()) { + if (element.elementId === parsed.elementId) { + type.elements.splice(index, 1); + colorSchema.parse(type); + break; + } + } + break; + } + } + }); + }, + moveTypeElement(input) { + const parsed = mutationActionInputSchemas.moveTypeElement.parse(input); + mutate((sdcpn) => { + for (const type of sdcpn.types) { + if (type.id === parsed.typeId) { + const fromIndex = type.elements.findIndex( + (element) => element.elementId === parsed.elementId, + ); + if (fromIndex === -1) { + break; + } + const [element] = type.elements.splice(fromIndex, 1); + if (element) { + type.elements.splice(parsed.toIndex, 0, element); + colorSchema.parse(type); + } + break; + } + } + }); + }, + removeType(input) { + const { typeId: parsedTypeId } = + mutationActionInputSchemas.removeType.parse(input); + mutate((sdcpn) => { + for (const [index, type] of sdcpn.types.entries()) { + if (type.id === parsedTypeId) { + sdcpn.types.splice(index, 1); + break; + } + } + for (const place of sdcpn.places) { + if (place.colorId === parsedTypeId) { + place.colorId = null; + } + } + for (const equation of sdcpn.differentialEquations) { + if (equation.colorId === parsedTypeId) { + equation.colorId = ""; + } + } + }); + }, + addDifferentialEquation(equation) { + const parsedEquation = differentialEquationSchema.parse(equation); + mutate((sdcpn) => { + sdcpn.differentialEquations.push(parsedEquation); + }); + }, + updateDifferentialEquation(input) { + const parsed = + mutationActionInputSchemas.updateDifferentialEquation.parse(input); + mutate((sdcpn) => { + for (const equation of sdcpn.differentialEquations) { + if (equation.id === parsed.equationId) { + Object.assign(equation, parsed.update); + differentialEquationSchema.parse(equation); + break; + } + } + }); + }, + removeDifferentialEquation(input) { + const { equationId: parsedEquationId } = + mutationActionInputSchemas.removeDifferentialEquation.parse({ + ...input, + }); + mutate((sdcpn) => { + for (const [index, equation] of sdcpn.differentialEquations.entries()) { + if (equation.id === parsedEquationId) { + sdcpn.differentialEquations.splice(index, 1); + break; + } + } + for (const place of sdcpn.places) { + if (place.differentialEquationId === parsedEquationId) { + place.differentialEquationId = null; + } + } + }); + }, + addParameter(parameter) { + const parsedParameter = parameterSchema.parse(parameter); + mutate((sdcpn) => { + sdcpn.parameters.push(parsedParameter); + }); + }, + updateParameter(input) { + const parsed = mutationActionInputSchemas.updateParameter.parse(input); + mutate((sdcpn) => { + for (const parameter of sdcpn.parameters) { + if (parameter.id === parsed.parameterId) { + Object.assign(parameter, parsed.update); + parameterSchema.parse(parameter); + break; + } + } + }); + }, + removeParameter(input) { + const { parameterId: parsedParameterId } = + mutationActionInputSchemas.removeParameter.parse(input); + mutate((sdcpn) => { + for (const [index, parameter] of sdcpn.parameters.entries()) { + if (parameter.id === parsedParameterId) { + sdcpn.parameters.splice(index, 1); + break; + } + } + }); + }, + addScenario(scenario) { + const parsedScenario = scenarioSchema.parse(scenario); + mutate((sdcpn) => { + const scenarios = sdcpn.scenarios ?? []; + scenarios.push(parsedScenario); + // eslint-disable-next-line no-param-reassign -- mutating draft inside immer/structuredClone + sdcpn.scenarios = scenarios; + }); + }, + updateScenario(input) { + const parsed = mutationActionInputSchemas.updateScenario.parse(input); + mutate((sdcpn) => { + for (const scenario of sdcpn.scenarios ?? []) { + if (scenario.id === parsed.scenarioId) { + Object.assign(scenario, parsed.update); + scenarioSchema.parse(scenario); + break; + } + } + }); + }, + removeScenario(input) { + const { scenarioId: parsedScenarioId } = + mutationActionInputSchemas.removeScenario.parse(input); + mutate((sdcpn) => { + const scenarios = sdcpn.scenarios; + if (!scenarios) { + return; + } + for (const [index, scenario] of scenarios.entries()) { + if (scenario.id === parsedScenarioId) { + scenarios.splice(index, 1); + break; + } + } + }); + }, + addMetric(metric) { + const parsedMetric = metricSchema.parse(metric); + mutate((sdcpn) => { + const metrics = sdcpn.metrics ?? []; + metrics.push(parsedMetric); + // eslint-disable-next-line no-param-reassign -- mutating draft inside immer/structuredClone + sdcpn.metrics = metrics; + }); + }, + updateMetric(input) { + const parsed = mutationActionInputSchemas.updateMetric.parse(input); + mutate((sdcpn) => { + for (const metric of sdcpn.metrics ?? []) { + if (metric.id === parsed.metricId) { + Object.assign(metric, parsed.update); + metricSchema.parse(metric); + break; + } + } + }); + }, + removeMetric(input) { + const { metricId: parsedMetricId } = + mutationActionInputSchemas.removeMetric.parse(input); + mutate((sdcpn) => { + const metrics = sdcpn.metrics; + if (!metrics) { + return; + } + for (const [index, metric] of metrics.entries()) { + if (metric.id === parsedMetricId) { + metrics.splice(index, 1); + break; + } + } + }); + }, + deleteItemsByIds(input) { + const parsedItems = + mutationActionInputSchemas.deleteItemsByIds.parse(input).items; + mutate((sdcpn) => { + const placeIds = new Set(); + const transitionIds = new Set(); + const arcIds = new Set(); + const typeIds = new Set(); + const equationIds = new Set(); + const parameterIds = new Set(); + + for (const item of parsedItems) { + const { id } = item; + switch (item.type) { + case "place": + placeIds.add(id); + break; + case "transition": + transitionIds.add(id); + break; + case "arc": + arcIds.add(id); + break; + case "type": + typeIds.add(id); + break; + case "differentialEquation": + equationIds.add(id); + break; + case "parameter": + parameterIds.add(id); + break; + } + } + + const hasCanvasDeletes = + placeIds.size > 0 || transitionIds.size > 0 || arcIds.size > 0; + + if (hasCanvasDeletes) { + for (let i = sdcpn.transitions.length - 1; i >= 0; i--) { + const transition = sdcpn.transitions[i]!; + if (transitionIds.has(transition.id)) { + sdcpn.transitions.splice(i, 1); + continue; + } + + for ( + let inputArcIndex = transition.inputArcs.length - 1; + inputArcIndex >= 0; + inputArcIndex-- + ) { + const inputArc = transition.inputArcs[inputArcIndex]!; + const arcId = generateArcId({ + inputId: inputArc.placeId, + outputId: transition.id, + }); + + if (arcIds.has(arcId) || placeIds.has(inputArc.placeId)) { + transition.inputArcs.splice(inputArcIndex, 1); + } + } + + for ( + let outputArcIndex = transition.outputArcs.length - 1; + outputArcIndex >= 0; + outputArcIndex-- + ) { + const outputArc = transition.outputArcs[outputArcIndex]!; + const arcId = generateArcId({ + inputId: transition.id, + outputId: outputArc.placeId, + }); + + if (arcIds.has(arcId) || placeIds.has(outputArc.placeId)) { + transition.outputArcs.splice(outputArcIndex, 1); + } + } + } + + for (let i = sdcpn.places.length - 1; i >= 0; i--) { + if (placeIds.has(sdcpn.places[i]!.id)) { + sdcpn.places.splice(i, 1); + } + } + } + + if (typeIds.size > 0) { + for (let i = sdcpn.types.length - 1; i >= 0; i--) { + if (typeIds.has(sdcpn.types[i]!.id)) { + sdcpn.types.splice(i, 1); + } + } + for (const place of sdcpn.places) { + if (place.colorId && typeIds.has(place.colorId)) { + place.colorId = null; + } + } + for (const equation of sdcpn.differentialEquations) { + if (typeIds.has(equation.colorId)) { + equation.colorId = ""; + } + } + } + + if (equationIds.size > 0) { + for (let i = sdcpn.differentialEquations.length - 1; i >= 0; i--) { + if (equationIds.has(sdcpn.differentialEquations[i]!.id)) { + sdcpn.differentialEquations.splice(i, 1); + } + } + for (const place of sdcpn.places) { + if ( + place.differentialEquationId && + equationIds.has(place.differentialEquationId) + ) { + place.differentialEquationId = null; + } + } + } + + if (parameterIds.size > 0) { + for (let i = sdcpn.parameters.length - 1; i >= 0; i--) { + if (parameterIds.has(sdcpn.parameters[i]!.id)) { + sdcpn.parameters.splice(i, 1); + } + } + } + }); + }, + commitNodePositions(input) { + const { commits: parsedCommits } = + mutationActionInputSchemas.commitNodePositions.parse(input); + mutate((sdcpn) => { + for (const { id, itemType, position } of parsedCommits) { + if (itemType === "place") { + for (const place of sdcpn.places) { + if (place.id === id) { + place.x = position.x; + place.y = position.y; + break; + } + } + } else { + for (const transition of sdcpn.transitions) { + if (transition.id === id) { + transition.x = position.x; + transition.y = position.y; + break; + } + } + } + } + }); + }, + }; +} diff --git a/libs/@hashintel/petrinaut/src/core/ai.test.ts b/libs/@hashintel/petrinaut/src/core/ai.test.ts new file mode 100644 index 00000000000..ffe1a95fb21 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/ai.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from "vitest"; + +import { + createPetrinautAiToolCallbacks, + petrinautAiToolInputSchemas, + petrinautAiTools, +} from "./ai"; +import { createJsonDocHandle } from "./handle"; +import { createPetrinaut } from "./instance"; + +describe("Petrinaut AI core exports", () => { + test("tool metadata stays aligned with input schemas and has no execute", () => { + expect(Object.keys(petrinautAiTools).sort()).toEqual( + Object.keys(petrinautAiToolInputSchemas).sort(), + ); + + for (const tool of Object.values(petrinautAiTools)) { + expect(tool.description.length).toBeGreaterThan(0); + expect(tool.inputSchema).toBeDefined(); + expect(tool.description).toBe(tool.inputSchema.description); + expect("execute" in tool).toBe(false); + } + }); + + test("callback map applies tool inputs to a Petrinaut instance", () => { + const instance = createPetrinaut({ + document: createJsonDocHandle({ + initial: { + places: [], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], + }, + }), + }); + const callbacks = createPetrinautAiToolCallbacks(instance); + + callbacks.addPlace({ + id: "place-1", + name: "Queue", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }); + callbacks.updatePlace({ + placeId: "place-1", + update: { name: "UpdatedQueue" }, + }); + + expect(instance.definition.get().places[0]!.name).toBe("UpdatedQueue"); + }); + + test("callback map validates tool inputs before applying them", () => { + const instance = createPetrinaut({ + document: createJsonDocHandle({ + initial: { + places: [], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], + }, + }), + }); + const callbacks = createPetrinautAiToolCallbacks(instance); + + expect(() => + callbacks.addPlace({ + id: "", + name: "Queue", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }), + ).toThrow(); + + expect(instance.definition.get().places).toEqual([]); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/core/ai.ts b/libs/@hashintel/petrinaut/src/core/ai.ts new file mode 100644 index 00000000000..fd03ee481ab --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/ai.ts @@ -0,0 +1,213 @@ +import type { z } from "zod"; + +import { probabilisticSatellitesSDCPN } from "../examples/satellites-launcher"; +import { + mutationActionInputSchemas, + type MutationActionInput, + type MutationActionName, +} from "./action-schemas"; +import type { Petrinaut } from "./instance"; +import { typedKeys } from "./lib/typed-entries"; + +export { + colorSchema, + differentialEquationSchema, + metricSchema, + parameterSchema, + mutationActionInputSchemas as petrinautAiToolInputSchemas, + placeSchema, + scenarioSchema, + transitionSchema, +} from "./action-schemas"; +export type { + MutationActionInput as PetrinautAiToolInput, + MutationActionName as PetrinautAiToolName, +} from "./action-schemas"; + +export type PetrinautAiTool = { + description: string; + inputSchema: InputSchema; +}; + +export type PetrinautAiTools = { + [Name in MutationActionName]: PetrinautAiTool< + (typeof mutationActionInputSchemas)[Name] + >; +}; + +const getSchemaDescription = (schema: z.ZodType): string => { + if (!schema.description) { + throw new Error("Petrinaut AI tool schemas must have descriptions"); + } + return schema.description; +}; + +function createToolBundle>( + schemas: InputSchemas, +): { + [Name in keyof InputSchemas]: PetrinautAiTool; +} { + const tools = {} as { + [Name in keyof InputSchemas]: PetrinautAiTool; + }; + + const setTool = ( + name: Name, + inputSchema: InputSchemas[Name], + ) => { + tools[name] = { + description: getSchemaDescription(inputSchema), + inputSchema, + }; + }; + + for (const name of typedKeys(schemas)) { + setTool(name, schemas[name]); + } + + return tools; +} + +export const petrinautAiTools = createToolBundle( + mutationActionInputSchemas, +) satisfies PetrinautAiTools; + +export type PetrinautAiToolCallbacks = { + [Name in MutationActionName]: (input: MutationActionInput) => void; +}; + +export function createPetrinautAiToolCallbacks( + instance: Petrinaut, +): PetrinautAiToolCallbacks { + return { + addPlace(input) { + instance.addPlace(input); + }, + updatePlace(input) { + instance.updatePlace(input); + }, + updatePlacePosition(input) { + instance.updatePlacePosition(input); + }, + removePlace(input) { + instance.removePlace(input); + }, + addTransition(input) { + instance.addTransition(input); + }, + updateTransition(input) { + instance.updateTransition(input); + }, + updateTransitionPosition(input) { + instance.updateTransitionPosition(input); + }, + removeTransition(input) { + instance.removeTransition(input); + }, + addArc(input) { + instance.addArc(input); + }, + removeArc(input) { + instance.removeArc(input); + }, + updateArcWeight(input) { + instance.updateArcWeight(input); + }, + updateArcType(input) { + instance.updateArcType(input); + }, + updateArcPlace(input) { + instance.updateArcPlace(input); + }, + addType(input) { + instance.addType(input); + }, + updateType(input) { + instance.updateType(input); + }, + removeType(input) { + instance.removeType(input); + }, + addTypeElement(input) { + instance.addTypeElement(input); + }, + updateTypeElement(input) { + instance.updateTypeElement(input); + }, + removeTypeElement(input) { + instance.removeTypeElement(input); + }, + moveTypeElement(input) { + instance.moveTypeElement(input); + }, + addDifferentialEquation(input) { + instance.addDifferentialEquation(input); + }, + updateDifferentialEquation(input) { + instance.updateDifferentialEquation(input); + }, + removeDifferentialEquation(input) { + instance.removeDifferentialEquation(input); + }, + addParameter(input) { + instance.addParameter(input); + }, + updateParameter(input) { + instance.updateParameter(input); + }, + removeParameter(input) { + instance.removeParameter(input); + }, + addScenario(input) { + instance.addScenario(input); + }, + updateScenario(input) { + instance.updateScenario(input); + }, + removeScenario(input) { + instance.removeScenario(input); + }, + addMetric(input) { + instance.addMetric(input); + }, + updateMetric(input) { + instance.updateMetric(input); + }, + removeMetric(input) { + instance.removeMetric(input); + }, + deleteItemsByIds(input) { + instance.deleteItemsByIds(input); + }, + commitNodePositions(input) { + instance.commitNodePositions(input); + }, + }; +} + +export function createPetrinautAiPrompt(): string { + const example = JSON.stringify(probabilisticSatellitesSDCPN, null, 2); + + return `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. + +When creating or revising a net: +- Prefer small, meaningful mutations rather than replacing unrelated content. +- Use coloured-token types when tokens need attributes. +- Use parameters for values the user may want to tune. +- Use stochastic transition lambdas for rate-based firing. +- 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. +- Keep executable code self-contained and readable. +- Summarize the changes after calling tools. + +You can ask the user follow-up questions to clarify their intent before making any changes. + +Here is a compact example Petrinaut document demonstrating coloured tokens, stochastic and predicate transitions, transition kernels with distributions, continuous dynamics, parameters, visualizer code, and scenarios: + +\`\`\`json +${example} +\`\`\``; +} diff --git a/libs/@hashintel/petrinaut/src/core/clipboard/serialize.test.ts b/libs/@hashintel/petrinaut/src/core/clipboard/serialize.test.ts index 80dd919ffa1..80b2c4ad3f3 100644 --- a/libs/@hashintel/petrinaut/src/core/clipboard/serialize.test.ts +++ b/libs/@hashintel/petrinaut/src/core/clipboard/serialize.test.ts @@ -296,6 +296,47 @@ describe("parseClipboardPayload", () => { expect(parseClipboardPayload(json)).not.toBeNull(); }); + it("keeps clipboard-specific relaxed names and input arc defaults", () => { + const json = JSON.stringify({ + format: "petrinaut-sdcpn", + version: CLIPBOARD_FORMAT_VERSION, + documentId: null, + data: { + places: [ + { + id: "place-1", + name: "Place 1", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + ], + transitions: [ + { + id: "transition-1", + name: "Transition 1", + inputArcs: [{ placeId: "place-1", weight: 1 }], + outputArcs: [], + lambdaType: "predicate", + lambdaCode: "", + transitionKernelCode: "", + x: 0, + y: 0, + }, + ], + types: [], + differentialEquations: [], + parameters: [], + }, + }); + + const parsed = parseClipboardPayload(json); + expect(parsed).not.toBeNull(); + expect(parsed?.data.transitions[0]?.inputArcs[0]?.type).toBe("standard"); + }); + it("returns null when version is not a number", () => { const json = JSON.stringify({ format: "petrinaut-sdcpn", diff --git a/libs/@hashintel/petrinaut/src/core/clipboard/types.ts b/libs/@hashintel/petrinaut/src/core/clipboard/types.ts index 59667c2aa40..e286744cfe5 100644 --- a/libs/@hashintel/petrinaut/src/core/clipboard/types.ts +++ b/libs/@hashintel/petrinaut/src/core/clipboard/types.ts @@ -1,68 +1,73 @@ import { z } from "zod"; +import { + colorElementSchema as currentColorElementSchema, + colorSchema as currentColorSchema, + differentialEquationSchema as currentDifferentialEquationSchema, + inputArcSchema as currentInputArcSchema, + outputArcSchema as currentOutputArcSchema, + parameterSchema as currentParameterSchema, + placeSchema as currentPlaceSchema, + transitionSchema as currentTransitionSchema, +} from "../schemas/entity-schemas"; + export const CLIPBOARD_FORMAT_VERSION = 1; +const clipboardPlaceShape = currentPlaceSchema.omit({ + showAsInitialState: true, +}).shape; + const inputArcSchema = z.object({ - placeId: z.string(), - weight: z.number(), + ...currentInputArcSchema.shape, type: z.enum(["standard", "inhibitor"]).optional().default("standard"), }); const outputArcSchema = z.object({ - placeId: z.string(), - weight: z.number(), + ...currentOutputArcSchema.shape, }); +/* + * Clipboard payloads represent an in-memory selected subgraph rather than a + * full import/export file: positions and visual type fields are required, but + * scenarios/metrics are intentionally excluded. + */ const placeSchema = z.object({ + ...clipboardPlaceShape, id: z.string(), name: z.string(), - colorId: z.string().nullable(), - dynamicsEnabled: z.boolean(), - differentialEquationId: z.string().nullable(), - visualizerCode: z.string().optional(), - x: z.number(), - y: z.number(), }); const transitionSchema = z.object({ + ...currentTransitionSchema.shape, id: z.string(), name: z.string(), inputArcs: z.array(inputArcSchema), outputArcs: z.array(outputArcSchema), - lambdaType: z.enum(["predicate", "stochastic"]), - lambdaCode: z.string(), - transitionKernelCode: z.string(), - x: z.number(), - y: z.number(), }); const colorElementSchema = z.object({ + ...currentColorElementSchema.shape, elementId: z.string(), name: z.string(), - type: z.enum(["real", "integer", "boolean"]), }); const colorSchema = z.object({ + ...currentColorSchema.shape, id: z.string(), name: z.string(), - iconSlug: z.string(), - displayColor: z.string(), elements: z.array(colorElementSchema), }); const differentialEquationSchema = z.object({ + ...currentDifferentialEquationSchema.shape, id: z.string(), name: z.string(), - colorId: z.string(), - code: z.string(), }); const parameterSchema = z.object({ + ...currentParameterSchema.shape, id: z.string(), name: z.string(), - variableName: z.string(), - type: z.enum(["real", "integer", "boolean"]), - defaultValue: z.string(), }); export const clipboardPayloadSchema = z.object({ diff --git a/libs/@hashintel/petrinaut/src/core/file-format/parse-sdcpn-file.test.ts b/libs/@hashintel/petrinaut/src/core/file-format/parse-sdcpn-file.test.ts index a70657a59d8..1f81dbc4b93 100644 --- a/libs/@hashintel/petrinaut/src/core/file-format/parse-sdcpn-file.test.ts +++ b/libs/@hashintel/petrinaut/src/core/file-format/parse-sdcpn-file.test.ts @@ -61,6 +61,25 @@ describe("parseSDCPNFile", () => { expect(result.sdcpn.differentialEquations).toEqual([]); }); + it("preserves relaxed scenario and metric import defaults", () => { + const result = parseSDCPNFile({ + version: 1, + meta: { generator: "Petrinaut" }, + ...minimalSDCPN, + scenarios: [{ id: "scenario-1", name: "Scenario One" }], + metrics: [{ id: "metric-1", name: "Metric One" }], + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.sdcpn.scenarios?.[0]).toMatchObject({ + scenarioParameters: [], + parameterOverrides: {}, + initialState: { type: "per_place", content: {} }, + }); + expect(result.sdcpn.metrics?.[0]?.code).toBe(""); + }); + it("strips version and meta from the returned sdcpn", () => { const result = parseSDCPNFile({ version: 1, diff --git a/libs/@hashintel/petrinaut/src/core/file-format/types.ts b/libs/@hashintel/petrinaut/src/core/file-format/types.ts index 2cc9b682218..bc2ac806d23 100644 --- a/libs/@hashintel/petrinaut/src/core/file-format/types.ts +++ b/libs/@hashintel/petrinaut/src/core/file-format/types.ts @@ -1,49 +1,63 @@ import { z } from "zod"; +import { + colorElementSchema as currentColorElementSchema, + colorSchema as currentColorSchema, + differentialEquationSchema as currentDifferentialEquationSchema, + inputArcSchema as currentInputArcSchema, + outputArcSchema as currentOutputArcSchema, + parameterSchema as currentParameterSchema, + placeSchema as currentPlaceSchema, + transitionSchema as currentTransitionSchema, +} from "../schemas/entity-schemas"; +import { + scenarioParameterSchema as currentScenarioParameterSchema, + scenarioSchema as currentScenarioSchema, +} from "../schemas/scenario-schema"; +import { metricSchema as currentMetricSchema } from "../schemas/metric-schema"; + export const SDCPN_FILE_FORMAT_VERSION = 1; +/* + * File import intentionally stays more permissive than current runtime/action + * schemas: older files may omit visual fields and input arc type, and imported + * display names may predate current UI validation rules. + */ const inputArcSchema = z.object({ - placeId: z.string(), - weight: z.number(), + ...currentInputArcSchema.shape, type: z.enum(["standard", "inhibitor"]).optional().default("standard"), }); const outputArcSchema = z.object({ - placeId: z.string(), - weight: z.number(), + ...currentOutputArcSchema.shape, }); const placeSchema = z.object({ + ...currentPlaceSchema.shape, id: z.string(), name: z.string(), - colorId: z.string().nullable(), - dynamicsEnabled: z.boolean(), - differentialEquationId: z.string().nullable(), - visualizerCode: z.string().optional(), - showAsInitialState: z.boolean().optional(), x: z.number().optional(), y: z.number().optional(), }); const transitionSchema = z.object({ + ...currentTransitionSchema.shape, id: z.string(), name: z.string(), inputArcs: z.array(inputArcSchema), outputArcs: z.array(outputArcSchema), - lambdaType: z.enum(["predicate", "stochastic"]), - lambdaCode: z.string(), - transitionKernelCode: z.string(), x: z.number().optional(), y: z.number().optional(), }); const colorElementSchema = z.object({ + ...currentColorElementSchema.shape, elementId: z.string(), name: z.string(), - type: z.enum(["real", "integer", "boolean"]), }); const colorSchema = z.object({ + ...currentColorSchema.shape, id: z.string(), name: z.string(), iconSlug: z.string().optional(), @@ -52,53 +66,38 @@ const colorSchema = z.object({ }); const differentialEquationSchema = z.object({ + ...currentDifferentialEquationSchema.shape, id: z.string(), name: z.string(), - colorId: z.string(), - code: z.string(), }); const parameterSchema = z.object({ + ...currentParameterSchema.shape, id: z.string(), name: z.string(), - variableName: z.string(), - type: z.enum(["real", "integer", "boolean"]), - defaultValue: z.string(), }); const scenarioParameterSchema = z.object({ - type: z.enum(["real", "integer", "boolean", "ratio"]), + ...currentScenarioParameterSchema.shape, identifier: z.string(), - default: z.number(), }); -const initialStateSchema = z.discriminatedUnion("type", [ - z.object({ - type: z.literal("per_place"), - content: z.record( - z.string(), - z.union([z.string(), z.array(z.array(z.number()))]), - ), - }), - z.object({ - type: z.literal("code"), - content: z.string(), - }), -]); - const scenarioSchema = z.object({ + ...currentScenarioSchema.shape, id: z.string(), name: z.string(), - description: z.string().optional(), scenarioParameters: z.array(scenarioParameterSchema).default([]), parameterOverrides: z.record(z.string(), z.string()).default({}), - initialState: initialStateSchema.default({ type: "per_place", content: {} }), + initialState: currentScenarioSchema.shape.initialState.default({ + type: "per_place", + content: {}, + }), }); const metricSchema = z.object({ + ...currentMetricSchema.shape, id: z.string(), name: z.string(), - description: z.string().optional(), code: z.string().default(""), }); diff --git a/libs/@hashintel/petrinaut/src/core/index.ts b/libs/@hashintel/petrinaut/src/core/index.ts index 0083cc24aac..21ab379c035 100644 --- a/libs/@hashintel/petrinaut/src/core/index.ts +++ b/libs/@hashintel/petrinaut/src/core/index.ts @@ -26,6 +26,30 @@ export type { EventStream, Petrinaut, } from "./instance"; +export { createPetrinautActions } from "./actions"; +export type { MutationHelperFunctions } from "./actions"; + +// --- AI --- +export { + colorSchema, + createPetrinautAiPrompt, + createPetrinautAiToolCallbacks, + differentialEquationSchema, + metricSchema, + parameterSchema, + petrinautAiToolInputSchemas, + petrinautAiTools, + placeSchema, + scenarioSchema, + transitionSchema, +} from "./ai"; +export type { + PetrinautAiTool, + PetrinautAiToolCallbacks, + PetrinautAiToolInput, + PetrinautAiToolName, + PetrinautAiTools, +} from "./ai"; // --- Simulation --- export { diff --git a/libs/@hashintel/petrinaut/src/core/instance.ts b/libs/@hashintel/petrinaut/src/core/instance.ts index 46c8f9a6132..44be432fb08 100644 --- a/libs/@hashintel/petrinaut/src/core/instance.ts +++ b/libs/@hashintel/petrinaut/src/core/instance.ts @@ -3,6 +3,10 @@ import type { PetrinautPatch, ReadableStore, } from "./handle"; +import { + createPetrinautActions, + type MutationHelperFunctions, +} from "./actions"; import type { SDCPN } from "./types/sdcpn"; const EMPTY_SDCPN: SDCPN = { @@ -25,7 +29,7 @@ export type EventStream = { * {@link createSimulation} directly with `instance.handle.doc()` (or any other * SDCPN value). The host owns the simulation's lifecycle. */ -export type Petrinaut = { +export type Petrinaut = MutationHelperFunctions & { readonly handle: PetrinautDocHandle; /** Current SDCPN snapshot store. Falls back to an empty SDCPN until the handle is ready. */ @@ -102,17 +106,20 @@ 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); return { + ...actions, handle, definition, patches, - mutate(fn) { - if (readonly) { - return; - } - handle.change(fn); - }, + mutate, readonly, dispose() { for (const dispose of disposers) { diff --git a/libs/@hashintel/petrinaut/src/core/lib/typed-entries.ts b/libs/@hashintel/petrinaut/src/core/lib/typed-entries.ts new file mode 100644 index 00000000000..da7d0f63daf --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/lib/typed-entries.ts @@ -0,0 +1,47 @@ +/** + * @file vendored in from @local/advanced-types to avoid a runtime dependency (typedX) on a private in-repo package. + */ + +type TupleEntry< + T extends readonly unknown[], + I extends unknown[] = [], + R = never, +> = T extends readonly [infer Head, ...infer Tail] + ? TupleEntry + : R; + +type ObjectEntry> = T extends object + ? { [K in keyof T]: [K, Required[K]] }[keyof T] extends infer E + ? E extends [infer K, infer V] + ? K extends string + ? [K, V] + : K extends number + ? [`${K}`, V] + : never + : never + : never + : never; + +// Source: https://dev.to/harry0000/a-bit-convenient-typescript-type-definitions-for-objectentries-d6g +export type Entry> = T extends readonly [ + unknown, + ...unknown[], +] + ? TupleEntry + : T extends ReadonlyArray + ? [`${number}`, U] + : ObjectEntry; + +/** `Object.entries` analogue which returns a well-typed array. */ +export function typedEntries>( + object: T, +): ReadonlyArray> { + return Object.entries(object) as unknown as ReadonlyArray>; +} + +/** `Object.keys` analogue which returns a well-typed array. */ +export const typedKeys = >( + object: T, +): Entry[0][] => { + return Object.keys(object) as Entry[0][]; +}; diff --git a/libs/@hashintel/petrinaut/src/core/schemas/entity-schemas.ts b/libs/@hashintel/petrinaut/src/core/schemas/entity-schemas.ts new file mode 100644 index 00000000000..9918ae77a68 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/core/schemas/entity-schemas.ts @@ -0,0 +1,241 @@ +import { z } from "zod"; + +import type { + Color, + DifferentialEquation, + Parameter, + Place, + Transition, +} from "../types/sdcpn"; +import { displayNameSchema } from "../validation/display-name"; +import { entityNameSchema } from "../validation/entity-name"; + +export const idSchema = z.string().min(1).meta({ + description: + "Stable identifier for an SDCPN entity. Use unique IDs within the net.", +}); + +export const positionSchema = z + .strictObject({ + x: z.number().meta({ + description: "Horizontal canvas position.", + }), + y: z.number().meta({ + description: "Vertical canvas position.", + }), + }) + .meta({ + description: "Canvas position for a place or transition.", + }); + +export const nodePositionCommitSchema = z + .strictObject({ + id: idSchema, + itemType: z.enum(["place", "transition"]).meta({ + description: "Whether the positioned node is a place or transition.", + }), + position: positionSchema, + }) + .meta({ + description: "A pending canvas-position update for one node.", + }); + +export const inputArcSchema = z + .strictObject({ + placeId: idSchema.meta({ + description: "ID of the input place connected to the transition.", + }), + weight: z.number().positive().meta({ + description: "Number of tokens consumed from the input place.", + }), + 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.", + }), + }) + .meta({ + description: "Input arc from a place into a transition.", + }); + +export const outputArcSchema = z + .strictObject({ + placeId: idSchema.meta({ + description: "ID of the output place connected from the transition.", + }), + weight: z.number().positive().meta({ + description: "Number of tokens produced into the output place.", + }), + }) + .meta({ + description: "Output arc from a transition into a place.", + }); + +export const arcDirectionSchema = z.enum(["input", "output"]).meta({ + description: + "Whether the arc connects a place into a transition or a transition out to a place.", +}); + +export const colorElementSchema = z + .strictObject({ + 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.", + }), + type: z.enum(["real", "integer", "boolean"]).meta({ + description: "Primitive token attribute type.", + }), + }) + .meta({ + description: "One typed attribute on a coloured token.", + }); + +export const placeSchema = z + .strictObject({ + id: idSchema, + name: entityNameSchema.meta({ + description: + "PascalCase place name. Use concise names that can be referenced by transition code.", + }), + colorId: idSchema.nullable().meta({ + description: + "ID of the token colour/type accepted by this place, or null for uncoloured token counts.", + }), + dynamicsEnabled: z.boolean().meta({ + description: + "Whether tokens in this place are updated by a differential equation during simulation.", + }), + differentialEquationId: idSchema.nullable().meta({ + description: + "ID of the differential equation used for continuous dynamics, or null when dynamics are disabled.", + }), + visualizerCode: z.string().optional().meta({ + description: + "Optional visualization module code for rendering tokens in this place.", + }), + showAsInitialState: z.boolean().optional().meta({ + description: + "Optional UI hint to show this place in the initial-state view.", + }), + x: z.number().meta({ + description: "Horizontal canvas position.", + }), + y: z.number().meta({ + description: "Vertical canvas position.", + }), + }) + .meta({ + description: + "A Petri net place. Places store tokens and may optionally use colours and continuous dynamics.", + }) satisfies z.ZodType; + +export const transitionSchema = z + .strictObject({ + id: idSchema, + name: displayNameSchema.meta({ + description: "Human-readable transition name.", + }), + inputArcs: z.array(inputArcSchema).meta({ + description: + "Input arcs that gate and consume tokens for this transition.", + }), + outputArcs: z.array(outputArcSchema).meta({ + description: + "Output arcs that receive tokens after this transition fires.", + }), + lambdaType: z.enum(["predicate", "stochastic"]).meta({ + description: + "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.", + }), + transitionKernelCode: z.string().meta({ + description: + "Optional JavaScript module code exporting TransitionKernel(...). Use distributions here to create stochastic output token attributes.", + }), + x: z.number().meta({ + description: "Horizontal canvas position.", + }), + y: z.number().meta({ + description: "Vertical canvas position.", + }), + }) + .meta({ + description: + "A Petri net transition. Transitions connect places and define firing logic.", + }) satisfies z.ZodType; + +export const colorSchema = z + .strictObject({ + id: idSchema, + name: displayNameSchema.meta({ + description: "Human-readable colour/type name.", + }), + iconSlug: z.string().min(1).meta({ + description: "Icon identifier used by the UI for this colour/type.", + }), + displayColor: z.string().min(1).meta({ + description: "CSS colour used by the UI to display this colour/type.", + }), + elements: z.array(colorElementSchema).meta({ + description: + "Typed token attributes available on tokens of this colour/type.", + }), + }) + .meta({ + description: + "A coloured-token type. Coloured places store token objects with these attributes.", + }) satisfies z.ZodType; + +export const differentialEquationSchema = z + .strictObject({ + id: idSchema, + name: displayNameSchema.meta({ + description: "Human-readable dynamics name.", + }), + colorId: idSchema.meta({ + description: + "ID of the colour/type whose token attributes this dynamics function updates.", + }), + code: z.string().meta({ + description: + "JavaScript module code exporting Dynamics(...). Return derivatives for each token attribute that changes continuously.", + }), + }) + .meta({ + description: + "A differential equation for continuous dynamics on coloured tokens.", + }) satisfies z.ZodType; + +export const parameterSchema = z + .strictObject({ + id: idSchema, + name: displayNameSchema.meta({ + description: "Human-readable parameter name.", + }), + variableName: z.string().min(1).meta({ + description: + "Identifier used by lambda, kernel, visualizer, metric, and dynamics code.", + }), + type: z.enum(["real", "integer", "boolean"]).meta({ + description: "Primitive parameter type.", + }), + defaultValue: z.string().meta({ + description: + "Default parameter value as an expression string parsed by the simulator.", + }), + }) + .meta({ + description: + "A net-level parameter available to executable SDCPN code and scenarios.", + }) satisfies z.ZodType; + +export type PlaceSchema = typeof placeSchema; +export type TransitionSchema = typeof transitionSchema; +export type ColorSchema = typeof colorSchema; +export type DifferentialEquationSchema = typeof differentialEquationSchema; +export type ParameterSchema = typeof parameterSchema; diff --git a/libs/@hashintel/petrinaut/src/core/schemas/metric-schema.ts b/libs/@hashintel/petrinaut/src/core/schemas/metric-schema.ts index bd5acb6d5f6..92b67289366 100644 --- a/libs/@hashintel/petrinaut/src/core/schemas/metric-schema.ts +++ b/libs/@hashintel/petrinaut/src/core/schemas/metric-schema.ts @@ -1,12 +1,25 @@ import { z } from "zod"; import type { Metric } from "../types/sdcpn"; +import { displayNameSchema } from "../validation/display-name"; +import { idSchema } from "./entity-schemas"; -export const metricSchema = z.object({ - id: z.string().min(1), - name: z.string().min(1, "Metric name is required"), - description: z.string().optional(), - code: z.string(), -}) satisfies z.ZodType; +export const metricSchema = z + .strictObject({ + id: idSchema, + name: displayNameSchema.meta({ + description: "Human-readable metric name.", + }), + description: z.string().optional().meta({ + 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.", + }), + }) + .meta({ + description: "A simulation metric plotted over time.", + }) satisfies z.ZodType; export type MetricSchema = typeof metricSchema; diff --git a/libs/@hashintel/petrinaut/src/core/schemas/scenario-schema.ts b/libs/@hashintel/petrinaut/src/core/schemas/scenario-schema.ts index 07b4f6e0d82..02fe9f816f0 100644 --- a/libs/@hashintel/petrinaut/src/core/schemas/scenario-schema.ts +++ b/libs/@hashintel/petrinaut/src/core/schemas/scenario-schema.ts @@ -1,51 +1,88 @@ import { z } from "zod"; import type { Scenario } from "../types/sdcpn"; +import { idSchema } from "./entity-schemas"; +import { displayNameSchema } from "../validation/display-name"; const SNAKE_CASE_RE = /^[a-z][a-z0-9_]*$/; -export const scenarioParameterSchema = z.object({ - type: z.enum(["real", "integer", "boolean", "ratio"]), - identifier: z - .string() - .min(1, "Identifier cannot be empty") - .regex(SNAKE_CASE_RE, "Identifier must be snake_case"), - default: z.number(), -}); - -export const scenarioSchema = z.object({ - id: z.string().min(1), - name: z.string().min(1, "Scenario name is required"), - description: z.string().optional(), - scenarioParameters: z - .array(scenarioParameterSchema) - .superRefine((params, ctx) => { - const seen = new Set(); - for (const [index, p] of params.entries()) { - if (seen.has(p.identifier)) { - ctx.addIssue({ - code: "custom", - path: [index, "identifier"], - message: `Duplicate identifier "${p.identifier}"`, - }); - } - seen.add(p.identifier); - } +export const scenarioParameterSchema = z + .strictObject({ + type: z.enum(["real", "integer", "boolean", "ratio"]).meta({ + description: "Primitive scenario parameter type.", + }), + identifier: z + .string() + .min(1, "Identifier cannot be empty") + .regex(SNAKE_CASE_RE, "Identifier must be snake_case") + .meta({ + description: + "Scenario-scoped identifier, referenced as scenario.identifier in overrides. Must be in snake_case.", + }), + default: z.number().meta({ + description: "Default numeric value for this scenario parameter.", }), - parameterOverrides: z.record(z.string(), z.string()), - initialState: z.discriminatedUnion("type", [ - z.object({ + }) + .meta({ + description: "A parameter scoped to one scenario.", + }); + +const initialStateSchema = z + .discriminatedUnion("type", [ + z.strictObject({ type: z.literal("per_place"), content: z.record( z.string(), z.union([z.string(), z.array(z.array(z.number()))]), ), }), - z.object({ + z.strictObject({ type: z.literal("code"), content: z.string(), }), - ]), -}) satisfies z.ZodType; + ]) + .meta({ + description: + "Initial token state for a scenario, either per-place values or executable code.", + }); + +export const scenarioSchema = z + .strictObject({ + id: idSchema, + name: displayNameSchema.meta({ + description: "Human-readable scenario name.", + }), + description: z.string().optional().meta({ + description: "Optional scenario summary shown to users.", + }), + scenarioParameters: z + .array(scenarioParameterSchema) + .superRefine((params, ctx) => { + const seen = new Set(); + for (const [index, p] of params.entries()) { + if (seen.has(p.identifier)) { + ctx.addIssue({ + code: "custom", + path: [index, "identifier"], + message: `Duplicate identifier "${p.identifier}"`, + }); + } + seen.add(p.identifier); + } + }) + .meta({ + description: + "Parameters available only within this scenario and its overrides.", + }), + parameterOverrides: z.record(z.string(), z.string()).meta({ + description: + "Map from net-level parameter ID to concrete value or scenario parameter expression.", + }), + initialState: initialStateSchema, + }) + .meta({ + description: + "A reusable simulation scenario with parameter overrides and initial token state.", + }) satisfies z.ZodType; export type ScenarioSchema = typeof scenarioSchema; diff --git a/libs/@hashintel/petrinaut/src/core/validation/display-name.ts b/libs/@hashintel/petrinaut/src/core/validation/display-name.ts index c04c6b49c7c..ff1f4fef5fd 100644 --- a/libs/@hashintel/petrinaut/src/core/validation/display-name.ts +++ b/libs/@hashintel/petrinaut/src/core/validation/display-name.ts @@ -8,7 +8,7 @@ import { z } from "zod"; * Valid: "Quality Check", "Start Production", "My Transition 2" * Invalid: "", " " */ -const displayNameSchema = z +export const displayNameSchema = z .string() .trim() .check( diff --git a/libs/@hashintel/petrinaut/src/core/validation/entity-name.ts b/libs/@hashintel/petrinaut/src/core/validation/entity-name.ts index 7e470fe5b54..8174a253e51 100644 --- a/libs/@hashintel/petrinaut/src/core/validation/entity-name.ts +++ b/libs/@hashintel/petrinaut/src/core/validation/entity-name.ts @@ -9,7 +9,7 @@ import { z } from "zod"; */ const PASCAL_CASE_REGEX = /^[A-Z][a-zA-Z]*\d*$/; -const entityNameSchema = z +export const entityNameSchema = z .string() .trim() .check( diff --git a/libs/@hashintel/petrinaut/src/main.ts b/libs/@hashintel/petrinaut/src/main.ts index 5be8888a443..2ac3a3959dd 100644 --- a/libs/@hashintel/petrinaut/src/main.ts +++ b/libs/@hashintel/petrinaut/src/main.ts @@ -39,6 +39,21 @@ export type { EventStream, Petrinaut as PetrinautInstance, } from "./core/instance"; +export { createPetrinautActions } from "./core/actions"; +export type { MutationHelperFunctions } from "./core/actions"; +export { + createPetrinautAiPrompt, + createPetrinautAiToolCallbacks, + petrinautAiToolInputSchemas, + petrinautAiTools, +} from "./core/ai"; +export type { + PetrinautAiTool, + PetrinautAiToolCallbacks, + PetrinautAiToolInput, + PetrinautAiToolName, + PetrinautAiTools, +} from "./core/ai"; export { createSimulation, createWorkerTransport, diff --git a/libs/@hashintel/petrinaut/src/react/mutation-provider.test.tsx b/libs/@hashintel/petrinaut/src/react/mutation-provider.test.tsx index c286b3ca385..054dbe3ae59 100644 --- a/libs/@hashintel/petrinaut/src/react/mutation-provider.test.tsx +++ b/libs/@hashintel/petrinaut/src/react/mutation-provider.test.tsx @@ -5,6 +5,7 @@ import { act, renderHook } from "@testing-library/react"; import { type ReactNode, use } from "react"; import { describe, expect, test, vi } from "vitest"; +import { createPetrinautActions } from "../core/actions"; import type { Petrinaut } from "../core/instance"; import type { SDCPN } from "../core/types/sdcpn"; import { @@ -158,6 +159,7 @@ function createWrapper(options: WrapperOptions = {}) { }); const fakeInstance = { + ...createPetrinautActions(mutateFn), handle: { id: "test-net" }, definition: { get: () => currentSdcpn, subscribe: () => () => {} }, patches: { subscribe: () => () => {} }, @@ -289,10 +291,16 @@ describe("MutationProvider (instance bridge)", () => { const { result } = renderHook(useMutations, { wrapper: Wrapper }); act(() => { - result.current.commitNodePositions([ - { id: "p1", itemType: "place", position: { x: 100, y: 200 } }, - { id: "t1", itemType: "transition", position: { x: 300, y: 400 } }, - ]); + 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); @@ -373,7 +381,7 @@ describe("MutationProvider (instance bridge)", () => { const { result } = renderHook(useMutations, { wrapper: Wrapper }); act(() => { - result.current.removeType("type-1"); + result.current.removeType({ typeId: "type-1" }); }); expect(mutateFn).not.toHaveBeenCalled(); @@ -386,7 +394,7 @@ describe("MutationProvider (instance bridge)", () => { const { result } = renderHook(useMutations, { wrapper: Wrapper }); act(() => { - result.current.removeParameter("param-1"); + result.current.removeParameter({ parameterId: "param-1" }); }); expect(mutateFn).not.toHaveBeenCalled(); @@ -413,9 +421,11 @@ describe("MutationProvider (instance bridge)", () => { const { result } = renderHook(useMutations, { wrapper: Wrapper }); act(() => { - result.current.commitNodePositions([ - { id: "p1", itemType: "place", position: { x: 100, y: 200 } }, - ]); + result.current.commitNodePositions({ + commits: [ + { id: "p1", itemType: "place", position: { x: 100, y: 200 } }, + ], + }); }); expect(mutateFn).not.toHaveBeenCalled(); @@ -463,7 +473,7 @@ describe("MutationProvider (instance bridge)", () => { const { result } = renderHook(useMutations, { wrapper: Wrapper }); act(() => { - result.current.removePlace("p1"); + result.current.removePlace({ placeId: "p1" }); }); expect(getSdcpn().places).toHaveLength(1); @@ -507,7 +517,7 @@ describe("MutationProvider (instance bridge)", () => { const { result } = renderHook(useMutations, { wrapper: Wrapper }); act(() => { - result.current.removeType("type-1"); + result.current.removeType({ typeId: "type-1" }); }); expect(getSdcpn().types).toHaveLength(0); @@ -536,7 +546,7 @@ describe("MutationProvider (instance bridge)", () => { const { result } = renderHook(useMutations, { wrapper: Wrapper }); act(() => { - result.current.removeDifferentialEquation("eq-1"); + result.current.removeDifferentialEquation({ equationId: "eq-1" }); }); expect(getSdcpn().differentialEquations).toHaveLength(0); @@ -607,7 +617,7 @@ describe("MutationProvider (instance bridge)", () => { ]); act(() => { - result.current.deleteItemsByIds(items); + result.current.deleteItemsByIds({ items: Array.from(items.values()) }); }); const final = getSdcpn(); diff --git a/libs/@hashintel/petrinaut/src/react/mutation-provider.tsx b/libs/@hashintel/petrinaut/src/react/mutation-provider.tsx index 021b0b28167..9eac7bee2ba 100644 --- a/libs/@hashintel/petrinaut/src/react/mutation-provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/mutation-provider.tsx @@ -1,7 +1,5 @@ import { use, type ReactNode } from "react"; -import { generateArcId } from "../core/arc-id"; -import type { SDCPN } from "../core/types/sdcpn"; import { MutationContext, type MutationContextValue, @@ -11,10 +9,9 @@ import { useIsReadOnly } from "./state/use-is-read-only"; import { usePetrinautInstance } from "./use-petrinaut-instance"; /** - * Bridge: provides the legacy {@link MutationContext} surface, delegating all - * writes to the Core instance's `mutate`. Read-only checks honour the editor - * mode (which lives in `EditorContext`) — only `readonly` blocks scenario - * mutations. + * 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, @@ -23,481 +20,193 @@ export const MutationProvider: React.FC<{ children: ReactNode }> = ({ const { readonly } = use(SDCPNContext); const isReadOnly = useIsReadOnly(); - function guardedMutate(fn: (sdcpn: SDCPN) => void): void { + function guardedMutate(callback: () => void): void { if (isReadOnly) { return; } - instance.mutate(fn); + callback(); } /** * Scenario CRUD is allowed even in simulate mode (the Simulate panel is * where scenarios are managed). Only true `readonly` blocks them. */ - function scenarioMutate(fn: (sdcpn: SDCPN) => void): void { + function scenarioMutate(callback: () => void): void { if (readonly) { return; } - instance.mutate(fn); + callback(); } const value: MutationContextValue = { addPlace(place) { - guardedMutate((sdcpn) => { - sdcpn.places.push(place); - }); - }, - updatePlace(placeId, updateFn) { - guardedMutate((sdcpn) => { - for (const place of sdcpn.places) { - if (place.id === placeId) { - updateFn(place); - break; - } - } - }); - }, - updatePlacePosition(placeId, position) { - guardedMutate((sdcpn) => { - for (const place of sdcpn.places) { - if (place.id === placeId) { - place.x = position.x; - place.y = position.y; - break; - } - } - }); - }, - removePlace(placeId) { - guardedMutate((sdcpn) => { - for (const [placeIndex, place] of sdcpn.places.entries()) { - if (place.id === placeId) { - sdcpn.places.splice(placeIndex, 1); - - // Iterate backwards to avoid skipping entries when splicing - for (const transition of sdcpn.transitions) { - for (let i = transition.inputArcs.length - 1; i >= 0; i--) { - if (transition.inputArcs[i]!.placeId === placeId) { - transition.inputArcs.splice(i, 1); - } - } - for (let i = transition.outputArcs.length - 1; i >= 0; i--) { - if (transition.outputArcs[i]!.placeId === placeId) { - transition.outputArcs.splice(i, 1); - } - } - } - break; - } - } + 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((sdcpn) => { - sdcpn.transitions.push(transition); - }); - }, - updateTransition(transitionId, updateFn) { - guardedMutate((sdcpn) => { - for (const transition of sdcpn.transitions) { - if (transition.id === transitionId) { - updateFn(transition); - break; - } - } - }); - }, - updateTransitionPosition(transitionId, position) { - guardedMutate((sdcpn) => { - for (const transition of sdcpn.transitions) { - if (transition.id === transitionId) { - transition.x = position.x; - transition.y = position.y; - break; - } - } - }); - }, - removeTransition(transitionId) { - guardedMutate((sdcpn) => { - for (const [index, transition] of sdcpn.transitions.entries()) { - if (transition.id === transitionId) { - sdcpn.transitions.splice(index, 1); - break; - } - } - }); - }, - addArc(transitionId, arcDirection, placeId, weight) { - guardedMutate((sdcpn) => { - for (const transition of sdcpn.transitions) { - if (transition.id === transitionId) { - if (arcDirection === "input") { - transition["inputArcs"].push({ - type: "standard", - placeId, - weight, - }); - } else { - transition["outputArcs"].push({ placeId, weight }); - } - break; - } - } - }); - }, - removeArc(transitionId, arcDirection, placeId) { - guardedMutate((sdcpn) => { - for (const transition of sdcpn.transitions) { - if (transition.id === transitionId) { - for (const [index, arc] of transition[ - arcDirection === "input" ? "inputArcs" : "outputArcs" - ].entries()) { - if (arc.placeId === placeId) { - transition[ - arcDirection === "input" ? "inputArcs" : "outputArcs" - ].splice(index, 1); - break; - } - } - break; - } - } - }); - }, - updateArcWeight(transitionId, arcDirection, placeId, weight) { - guardedMutate((sdcpn) => { - for (const transition of sdcpn.transitions) { - if (transition.id === transitionId) { - for (const arc of transition[ - arcDirection === "input" ? "inputArcs" : "outputArcs" - ]) { - if (arc.placeId === placeId) { - arc.weight = weight; - break; - } - } - break; - } - } - }); - }, - updateArcType(transitionId, placeId, type) { - guardedMutate((sdcpn) => { - for (const transition of sdcpn.transitions) { - if (transition.id === transitionId) { - for (const arc of transition["inputArcs"]) { - if (arc.placeId === placeId) { - arc.type = type; - break; - } - } - break; - } - } + 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((sdcpn) => { - sdcpn.types.push(type); - }); - }, - updateType(typeId, updateFn) { - guardedMutate((sdcpn) => { - for (const type of sdcpn.types) { - if (type.id === typeId) { - updateFn(type); - break; - } - } - }); - }, - removeType(typeId) { - guardedMutate((sdcpn) => { - for (const [index, type] of sdcpn.types.entries()) { - if (type.id === typeId) { - sdcpn.types.splice(index, 1); - break; - } - } - for (const place of sdcpn.places) { - if (place.colorId === typeId) { - place.colorId = null; - } - } - for (const equation of sdcpn.differentialEquations) { - if (equation.colorId === typeId) { - equation.colorId = ""; - } - } + 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((sdcpn) => { - sdcpn.differentialEquations.push(equation); - }); - }, - updateDifferentialEquation(equationId, updateFn) { - guardedMutate((sdcpn) => { - for (const equation of sdcpn.differentialEquations) { - if (equation.id === equationId) { - updateFn(equation); - break; - } - } - }); - }, - removeDifferentialEquation(equationId) { - guardedMutate((sdcpn) => { - for (const [index, equation] of sdcpn.differentialEquations.entries()) { - if (equation.id === equationId) { - sdcpn.differentialEquations.splice(index, 1); - break; - } - } - for (const place of sdcpn.places) { - if (place.differentialEquationId === equationId) { - place.differentialEquationId = null; - } - } + guardedMutate(() => { + instance.addDifferentialEquation(equation); + }); + }, + updateDifferentialEquation(input) { + guardedMutate(() => { + instance.updateDifferentialEquation(input); + }); + }, + removeDifferentialEquation(input) { + guardedMutate(() => { + instance.removeDifferentialEquation(input); }); }, addParameter(parameter) { - guardedMutate((sdcpn) => { - sdcpn.parameters.push(parameter); + guardedMutate(() => { + instance.addParameter(parameter); }); }, - updateParameter(parameterId, updateFn) { - guardedMutate((sdcpn) => { - for (const parameter of sdcpn.parameters) { - if (parameter.id === parameterId) { - updateFn(parameter); - break; - } - } + updateParameter(input) { + guardedMutate(() => { + instance.updateParameter(input); }); }, - removeParameter(parameterId) { - guardedMutate((sdcpn) => { - for (const [index, parameter] of sdcpn.parameters.entries()) { - if (parameter.id === parameterId) { - sdcpn.parameters.splice(index, 1); - break; - } - } + removeParameter(input) { + guardedMutate(() => { + instance.removeParameter(input); }); }, addScenario(scenario) { - scenarioMutate((sdcpn) => { - const scenarios = sdcpn.scenarios ?? []; - scenarios.push(scenario); - // eslint-disable-next-line no-param-reassign -- mutating draft inside immer/structuredClone - sdcpn.scenarios = scenarios; - }); - }, - updateScenario(scenarioId, updateFn) { - scenarioMutate((sdcpn) => { - for (const scenario of sdcpn.scenarios ?? []) { - if (scenario.id === scenarioId) { - updateFn(scenario); - break; - } - } - }); - }, - removeScenario(scenarioId) { - scenarioMutate((sdcpn) => { - const scenarios = sdcpn.scenarios; - if (!scenarios) { - return; - } - for (const [index, scenario] of scenarios.entries()) { - if (scenario.id === scenarioId) { - scenarios.splice(index, 1); - break; - } - } + scenarioMutate(() => { + instance.addScenario(scenario); + }); + }, + updateScenario(input) { + scenarioMutate(() => { + instance.updateScenario(input); + }); + }, + removeScenario(input) { + scenarioMutate(() => { + instance.removeScenario(input); }); }, addMetric(metric) { - scenarioMutate((sdcpn) => { - const metrics = sdcpn.metrics ?? []; - metrics.push(metric); - // eslint-disable-next-line no-param-reassign -- mutating draft inside immer/structuredClone - sdcpn.metrics = metrics; - }); - }, - updateMetric(metricId, updateFn) { - scenarioMutate((sdcpn) => { - for (const metric of sdcpn.metrics ?? []) { - if (metric.id === metricId) { - updateFn(metric); - break; - } - } - }); - }, - removeMetric(metricId) { - scenarioMutate((sdcpn) => { - const metrics = sdcpn.metrics; - if (!metrics) { - return; - } - for (const [index, metric] of metrics.entries()) { - if (metric.id === metricId) { - metrics.splice(index, 1); - break; - } - } - }); - }, - deleteItemsByIds(items) { - guardedMutate((sdcpn) => { - const placeIds = new Set(); - const transitionIds = new Set(); - const arcIds = new Set(); - const typeIds = new Set(); - const equationIds = new Set(); - const parameterIds = new Set(); - - for (const [id, item] of items) { - switch (item.type) { - case "place": - placeIds.add(id); - break; - case "transition": - transitionIds.add(id); - break; - case "arc": - arcIds.add(id); - break; - case "type": - typeIds.add(id); - break; - case "differentialEquation": - equationIds.add(id); - break; - case "parameter": - parameterIds.add(id); - break; - } - } - - const hasCanvasDeletes = - placeIds.size > 0 || transitionIds.size > 0 || arcIds.size > 0; - - if (hasCanvasDeletes) { - for (let i = sdcpn.transitions.length - 1; i >= 0; i--) { - const transition = sdcpn.transitions[i]!; - if (transitionIds.has(transition.id)) { - sdcpn.transitions.splice(i, 1); - continue; - } - - for ( - let inputArcIndex = transition.inputArcs.length - 1; - inputArcIndex >= 0; - inputArcIndex-- - ) { - const inputArc = transition.inputArcs[inputArcIndex]!; - const arcId = generateArcId({ - inputId: inputArc.placeId, - outputId: transition.id, - }); - - if (arcIds.has(arcId) || placeIds.has(inputArc.placeId)) { - transition.inputArcs.splice(inputArcIndex, 1); - } - } - - for ( - let outputArcIndex = transition.outputArcs.length - 1; - outputArcIndex >= 0; - outputArcIndex-- - ) { - const outputArc = transition.outputArcs[outputArcIndex]!; - const arcId = generateArcId({ - inputId: transition.id, - outputId: outputArc.placeId, - }); - - if (arcIds.has(arcId) || placeIds.has(outputArc.placeId)) { - transition.outputArcs.splice(outputArcIndex, 1); - } - } - } - - for (let i = sdcpn.places.length - 1; i >= 0; i--) { - if (placeIds.has(sdcpn.places[i]!.id)) { - sdcpn.places.splice(i, 1); - } - } - } - - if (typeIds.size > 0) { - for (let i = sdcpn.types.length - 1; i >= 0; i--) { - if (typeIds.has(sdcpn.types[i]!.id)) { - sdcpn.types.splice(i, 1); - } - } - for (const place of sdcpn.places) { - if (place.colorId && typeIds.has(place.colorId)) { - place.colorId = null; - } - } - for (const equation of sdcpn.differentialEquations) { - if (typeIds.has(equation.colorId)) { - equation.colorId = ""; - } - } - } - - if (equationIds.size > 0) { - for (let i = sdcpn.differentialEquations.length - 1; i >= 0; i--) { - if (equationIds.has(sdcpn.differentialEquations[i]!.id)) { - sdcpn.differentialEquations.splice(i, 1); - } - } - for (const place of sdcpn.places) { - if ( - place.differentialEquationId && - equationIds.has(place.differentialEquationId) - ) { - place.differentialEquationId = null; - } - } - } - - if (parameterIds.size > 0) { - for (let i = sdcpn.parameters.length - 1; i >= 0; i--) { - if (parameterIds.has(sdcpn.parameters[i]!.id)) { - sdcpn.parameters.splice(i, 1); - } - } - } - }); - }, - commitNodePositions(commits) { - guardedMutate((sdcpn) => { - for (const { id, itemType, position } of commits) { - if (itemType === "place") { - for (const place of sdcpn.places) { - if (place.id === id) { - place.x = position.x; - place.y = position.y; - break; - } - } - } else { - for (const transition of sdcpn.transitions) { - if (transition.id === id) { - transition.x = position.x; - transition.y = position.y; - break; - } - } - } - } + 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); }); }, }; diff --git a/libs/@hashintel/petrinaut/src/react/state/mutation-context.ts b/libs/@hashintel/petrinaut/src/react/state/mutation-context.ts index 21a41554cac..74b4e357d11 100644 --- a/libs/@hashintel/petrinaut/src/react/state/mutation-context.ts +++ b/libs/@hashintel/petrinaut/src/react/state/mutation-context.ts @@ -1,89 +1,6 @@ import { createContext } from "react"; -import type { - Color, - DifferentialEquation, - Metric, - Parameter, - Place, - Scenario, - Transition, -} from "../../core/types/sdcpn"; -import type { SelectionMap } from "../../core/types/selection"; - -export type MutationHelperFunctions = { - addPlace: (place: Place) => void; - updatePlace: (placeId: string, updateFn: (place: Place) => void) => void; - updatePlacePosition: ( - placeId: string, - position: { x: number; y: number }, - ) => void; - removePlace: (placeId: string) => void; - addTransition: (transition: Transition) => void; - updateTransition: ( - transitionId: string, - updateFn: (transition: Transition) => void, - ) => void; - updateTransitionPosition: ( - transitionId: string, - position: { x: number; y: number }, - ) => void; - removeTransition: (transitionId: string) => void; - addArc: ( - transitionId: string, - arcDirection: "input" | "output", - placeId: string, - weight: number, - ) => void; - removeArc: ( - transitionId: string, - arcDirection: "input" | "output", - placeId: string, - ) => void; - updateArcWeight: ( - transitionId: string, - arcDirection: "input" | "output", - placeId: string, - weight: number, - ) => void; - updateArcType: ( - transitionId: string, - placeId: string, - type: "standard" | "inhibitor", - ) => void; - addType: (type: Color) => void; - updateType: (typeId: string, updateFn: (type: Color) => void) => void; - removeType: (typeId: string) => void; - addDifferentialEquation: (equation: DifferentialEquation) => void; - updateDifferentialEquation: ( - equationId: string, - updateFn: (equation: DifferentialEquation) => void, - ) => void; - removeDifferentialEquation: (equationId: string) => void; - addParameter: (parameter: Parameter) => void; - updateParameter: ( - parameterId: string, - updateFn: (parameter: Parameter) => void, - ) => void; - removeParameter: (parameterId: string) => void; - addScenario: (scenario: Scenario) => void; - updateScenario: ( - scenarioId: string, - updateFn: (scenario: Scenario) => void, - ) => void; - removeScenario: (scenarioId: string) => void; - addMetric: (metric: Metric) => void; - updateMetric: (metricId: string, updateFn: (metric: Metric) => void) => void; - removeMetric: (metricId: string) => void; - deleteItemsByIds: (items: SelectionMap) => void; - commitNodePositions: ( - commits: Array<{ - id: string; - itemType: "place" | "transition"; - position: { x: number; y: number }; - }>, - ) => void; -}; +import type { MutationHelperFunctions } from "../../core/actions"; export type MutationContextValue = MutationHelperFunctions; @@ -100,9 +17,14 @@ const DEFAULT_CONTEXT_VALUE: MutationContextValue = { removeArc: () => {}, updateArcWeight: () => {}, updateArcType: () => {}, + updateArcPlace: () => {}, addType: () => {}, updateType: () => {}, removeType: () => {}, + addTypeElement: () => {}, + updateTypeElement: () => {}, + removeTypeElement: () => {}, + moveTypeElement: () => {}, addDifferentialEquation: () => {}, updateDifferentialEquation: () => {}, removeDifferentialEquation: () => {}, 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 188e39744ad..3b3d905f723 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 @@ -150,7 +150,7 @@ export function useKeyboardShortcuts( hasSelection ) { event.preventDefault(); - deleteItemsByIds(selection); + deleteItemsByIds({ items: Array.from(selection.values()) }); clearSelection(); return; } 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 c93ce936a84..b0ee7e2e6e9 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 @@ -67,7 +67,7 @@ const DiffEqRowMenu: React.FC<{ item: { id: string } }> = ({ item }) => { icon: , destructive: true, disabled: isReadOnly, - onClick: () => removeDifferentialEquation(item.id), + onClick: () => removeDifferentialEquation({ equationId: item.id }), }, ]} /> 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 35eafdd3fec..4ac6c3539cc 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 @@ -66,9 +66,10 @@ const EntityRowMenu: React.FC<{ item: EntityTreeItem }> = ({ item }) => { } const deleteActions: Partial void>> = { - type: () => removeType(item.id), - differentialEquation: () => removeDifferentialEquation(item.id), - parameter: () => removeParameter(item.id), + type: () => removeType({ typeId: item.id }), + differentialEquation: () => + removeDifferentialEquation({ equationId: item.id }), + parameter: () => removeParameter({ parameterId: item.id }), }; const deleteAction = deleteActions[type]; 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 40ed10a95f8..3c8d04dd647 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 @@ -81,7 +81,7 @@ const ParameterRowMenu: React.FC<{ item: { id: string } }> = ({ item }) => { icon: , destructive: true, disabled: isReadOnly, - onClick: () => removeParameter(item.id), + onClick: () => removeParameter({ parameterId: item.id }), }, ]} /> 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 513553d8908..e9e6400c658 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 @@ -119,7 +119,7 @@ const TypeRowMenu: React.FC<{ item: { id: string } }> = ({ item }) => { icon: , destructive: true, disabled: isReadOnly, - onClick: () => removeType(item.id), + onClick: () => removeType({ typeId: item.id }), }, ]} /> 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 e1331afc190..0b5d5e454c4 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 @@ -14,6 +14,7 @@ import { UI_MESSAGES } from "../../../../../constants/ui-messages"; import type { SDCPN } from "../../../../../../core/types/sdcpn"; import { EditorContext } from "../../../../../../react/state/editor-context"; import { parseArcId } from "../../../../../../core/types/selection"; +import type { MutationContextValue } from "../../../../../../react/state/mutation-context"; import { useIsReadOnly } from "../../../../../../react/state/use-is-read-only"; const containerStyle = css({ @@ -38,22 +39,9 @@ interface ArcPropertiesData { targetName: string; weight: number; type: "standard" | "inhibitor"; - updateArcWeight: ( - transitionId: string, - arcDirection: "input" | "output", - placeId: string, - weight: number, - ) => void; - updateArcType: ( - transitionId: string, - placeId: string, - type: "standard" | "inhibitor", - ) => void; - removeArc: ( - transitionId: string, - arcDirection: "input" | "output", - placeId: string, - ) => void; + updateArcWeight: MutationContextValue["updateArcWeight"]; + updateArcType: MutationContextValue["updateArcType"]; + removeArc: MutationContextValue["removeArc"]; } const ArcPropertiesContext = createContext(null); @@ -95,11 +83,11 @@ const ArcMainContent: React.FC = () => { { - updateDifferentialEquation( - differentialEquation.id, - (existingEquation) => { - existingEquation.name = event.target.value; - }, - ); + updateDifferentialEquation({ + equationId: differentialEquation.id, + update: { name: event.target.value }, + }); }} disabled={isReadOnly} tooltip={isReadOnly ? UI_MESSAGES.READ_ONLY_MODE : undefined} @@ -262,12 +256,10 @@ const DiffEqMainContent: React.FC = () => { value={differentialEquation.code} height="100%" onChange={(newCode) => { - updateDifferentialEquation( - differentialEquation.id, - (existingEquation) => { - existingEquation.code = newCode ?? ""; - }, - ); + updateDifferentialEquation({ + equationId: differentialEquation.id, + update: { code: newCode ?? "" }, + }); }} options={{ readOnly: isReadOnly }} tooltip={isReadOnly ? UI_MESSAGES.READ_ONLY_MODE : undefined} @@ -308,14 +300,14 @@ const DiffEqCodeAction: React.FC = () => { (tp) => tp.id === differentialEquation.colorId, ); - updateDifferentialEquation( - differentialEquation.id, - (existingEquation) => { - existingEquation.code = equationType + updateDifferentialEquation({ + equationId: differentialEquation.id, + update: { + code: equationType ? generateDefaultDifferentialEquationCode(equationType) - : DEFAULT_DIFFERENTIAL_EQUATION_CODE; + : DEFAULT_DIFFERENTIAL_EQUATION_CODE, }, - ); + }); }, }, { 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 8f545412475..8a5570e08e2 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 @@ -9,10 +9,8 @@ import type { SubView } from "../../../../components/sub-view/types"; import { VerticalSubViewsContainer } from "../../../../components/sub-view/vertical/vertical-sub-views-container"; import { UI_MESSAGES } from "../../../../constants/ui-messages"; import { EditorContext } from "../../../../../react/state/editor-context"; -import type { - SelectionItem, - SelectionMap, -} from "../../../../../core/types/selection"; +import type { SelectionItem } from "../../../../../core/types/selection"; +import type { MutationContextValue } from "../../../../../react/state/mutation-context"; import { useIsReadOnly } from "../../../../../react/state/use-is-read-only"; const containerStyle = css({ @@ -30,7 +28,7 @@ const summaryStyle = css({ interface MultiSelectionData { items: SelectionItem[]; - deleteItemsByIds: (items: SelectionMap) => void; + deleteItemsByIds: MutationContextValue["deleteItemsByIds"]; } const MultiSelectionContext = createContext(null); @@ -87,7 +85,7 @@ const DeleteSelectionAction: React.FC = () => { iconName="trash" disabled={isReadOnly} onClick={() => { - deleteItemsByIds(new Map(items.map((item) => [item.id, item]))); + deleteItemsByIds({ items }); clearSelection(); }} tooltip={isReadOnly ? UI_MESSAGES.READ_ONLY_MODE : "Delete selected"} @@ -110,7 +108,7 @@ const subViews: SubView[] = [multiSelectionMainSubView]; interface MultiSelectionPanelProps { items: SelectionItem[]; - deleteItemsByIds: (items: SelectionMap) => void; + deleteItemsByIds: MutationContextValue["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 e4fd59570da..9fec06d9757 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 @@ -71,8 +71,13 @@ export const PropertiesPanel: React.FC = () => { updateTransition, updateArcWeight, updateArcType, + updateArcPlace, removeArc, updateType, + addTypeElement, + updateTypeElement, + removeTypeElement, + moveTypeElement, updateDifferentialEquation, updateParameter, deleteItemsByIds, @@ -134,6 +139,8 @@ export const PropertiesPanel: React.FC = () => { types={petriNetDefinition.types} onArcWeightUpdate={updateArcWeight} updateTransition={updateTransition} + updateArcPlace={updateArcPlace} + removeArc={removeArc} /> ); } @@ -158,7 +165,16 @@ export const PropertiesPanel: React.FC = () => { (type) => type.id === item.id, ); if (typeData) { - content = ; + content = ( + + ); } break; } 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 2be272a73c6..81797d932cb 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,13 +1,11 @@ import { createContext, use } from "react"; import type { Parameter } from "../../../../../../core/types/sdcpn"; +import type { MutationContextValue } from "../../../../../../react/state/mutation-context"; export interface ParameterPropertiesContextValue { parameter: Parameter; - updateParameter: ( - parameterId: string, - updateFn: (parameter: Parameter) => void, - ) => void; + updateParameter: MutationContextValue["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 9fc302d65d5..a03c293b646 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 @@ -3,6 +3,7 @@ import { css } from "@hashintel/ds-helpers/css"; import type { SubView } from "../../../../../components/sub-view/types"; import { VerticalSubViewsContainer } from "../../../../../components/sub-view/vertical/vertical-sub-views-container"; import type { Parameter } from "../../../../../../core/types/sdcpn"; +import type { MutationContextValue } from "../../../../../../react/state/mutation-context"; import { ParameterPropertiesContext } from "./context"; import { parameterMainContentSubView } from "./subviews/main"; @@ -17,10 +18,7 @@ const subViews: SubView[] = [parameterMainContentSubView]; interface ParameterPropertiesProps { parameter: Parameter; - updateParameter: ( - parameterId: string, - updateFn: (parameter: Parameter) => void, - ) => void; + updateParameter: MutationContextValue["updateParameter"]; } export const ParameterProperties: React.FC = ({ diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/parameter-properties/subviews/main.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/parameter-properties/subviews/main.tsx index cd902adaa52..a54cfde3e0b 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/parameter-properties/subviews/main.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/parameter-properties/subviews/main.tsx @@ -28,16 +28,18 @@ const ParameterMainContent: React.FC = () => { }, [parameter.id, parameter.variableName]); const handleUpdateName = (event: React.ChangeEvent) => { - updateParameter(parameter.id, (existingParameter) => { - existingParameter.name = event.target.value; + updateParameter({ + parameterId: parameter.id, + update: { name: event.target.value }, }); }; const handleUpdateDefaultValue = ( event: React.ChangeEvent, ) => { - updateParameter(parameter.id, (existingParameter) => { - existingParameter.defaultValue = event.target.value; + updateParameter({ + parameterId: parameter.id, + update: { defaultValue: event.target.value }, }); }; @@ -71,8 +73,9 @@ const ParameterMainContent: React.FC = () => { setVarNameError(null); if (result.name !== parameter.variableName) { - updateParameter(parameter.id, (existingParameter) => { - existingParameter.variableName = result.name; + updateParameter({ + parameterId: parameter.id, + update: { variableName: result.name }, }); } }} 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 0869e8a6fb7..a45cb4c5af4 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,7 @@ import { createContext, type ReactNode, use } from "react"; import type { Color, Place } from "../../../../../../core/types/sdcpn"; +import type { MutationContextValue } from "../../../../../../react/state/mutation-context"; /** * Context for providing place-specific data to subview components @@ -16,7 +17,7 @@ interface PlacePropertiesContextValue { /** Whether the panel is in read-only mode */ isReadOnly: boolean; /** Function to update the place */ - updatePlace: (placeId: string, updateFn: (place: Place) => void) => void; + updatePlace: MutationContextValue["updatePlace"]; } const PlacePropertiesContext = @@ -41,7 +42,7 @@ interface PlacePropertiesProviderProps { placeType: Color | null; types: Color[]; isReadOnly: boolean; - updatePlace: (placeId: string, updateFn: (place: Place) => void) => void; + updatePlace: MutationContextValue["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 acceb5024c8..7990b65c7c3 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 @@ -3,6 +3,7 @@ import { css } from "@hashintel/ds-helpers/css"; import type { SubView } from "../../../../../components/sub-view/types"; import { VerticalSubViewsContainer } from "../../../../../components/sub-view/vertical/vertical-sub-views-container"; import type { Color, Place } from "../../../../../../core/types/sdcpn"; +import type { MutationContextValue } from "../../../../../../react/state/mutation-context"; import { useIsReadOnly } from "../../../../../../react/state/use-is-read-only"; import { PlacePropertiesProvider } from "./context"; import { placeMainContentSubView } from "./subviews/main"; @@ -25,7 +26,7 @@ const subViews: SubView[] = [ interface PlacePropertiesProps { place: Place; types: Color[]; - updatePlace: (placeId: string, updateFn: (place: Place) => void) => void; + updatePlace: MutationContextValue["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 84acef02615..05ba5286938 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 @@ -98,8 +98,9 @@ const PlaceMainContent: React.FC = () => { setNameError(null); if (result.name !== place.name) { - updatePlace(place.id, (existingPlace) => { - existingPlace.name = result.name; + updatePlace({ + placeId: place.id, + update: { name: result.name }, }); } }; @@ -147,12 +148,15 @@ const PlaceMainContent: React.FC = () => { value={place.colorId ?? ""} onValueChange={(value) => { const newType = value === "" ? null : value; - updatePlace(place.id, (existingPlace) => { - existingPlace.colorId = newType; - // Disable dynamics if type is being set to null - if (newType === null && existingPlace.dynamicsEnabled) { - existingPlace.dynamicsEnabled = false; - } + updatePlace({ + placeId: place.id, + update: { + colorId: newType, + dynamicsEnabled: + newType === null && place.dynamicsEnabled + ? false + : place.dynamicsEnabled, + }, }); }} options={[ @@ -241,18 +245,24 @@ const PlaceMainContent: React.FC = () => { : undefined } onCheckedChange={(checked) => { - updatePlace(place.id, (existingPlace) => { - existingPlace.dynamicsEnabled = checked; - if (checked) { - // Auto-select first available diff eq if none selected or previous no longer exists - const currentIsValid = availableDiffEqs.some( - (eq) => eq.id === existingPlace.differentialEquationId, - ); - if (!currentIsValid && availableDiffEqs.length > 0) { - existingPlace.differentialEquationId = - availableDiffEqs[0]!.id; - } + const update: { + dynamicsEnabled: boolean; + differentialEquationId?: string | null; + } = { dynamicsEnabled: checked }; + + if (checked) { + // Auto-select first available diff eq if none selected or previous no longer exists + const currentIsValid = availableDiffEqs.some( + (eq) => eq.id === place.differentialEquationId, + ); + if (!currentIsValid && availableDiffEqs.length > 0) { + update.differentialEquationId = availableDiffEqs[0]!.id; } + } + + updatePlace({ + placeId: place.id, + update, }); }} /> @@ -275,8 +285,9 @@ const PlaceMainContent: React.FC = () => { { - updateType(type.id, (existingType) => { - existingType.name = event.target.value; + updateType({ + typeId: type.id, + update: { name: event.target.value }, }); }} disabled={isDisabled} @@ -258,8 +259,9 @@ const TypeMainContent: React.FC = () => { { - updateType(type.id, (existingType) => { - existingType.displayColor = color; + updateType({ + typeId: type.id, + update: { displayColor: color }, }); }} disabled={isDisabled} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/view-metric-drawer.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/view-metric-drawer.tsx index 5cfc4510fbc..7f8b97eb119 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/view-metric-drawer.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/view-metric-drawer.tsx @@ -128,10 +128,13 @@ const ViewMetricContent = ({ if (!result.success) { return; } - updateMetric(metric.id, (draft) => { - draft.name = result.data.name; - draft.description = result.data.description; - draft.code = result.data.code; + updateMetric({ + metricId: metric.id, + update: { + name: result.data.name, + description: result.data.description, + code: result.data.code, + }, }); onClose(); }, @@ -156,7 +159,7 @@ const ViewMetricContent = ({ const metricSessionId = useMetricLspSession(values.code); const handleDelete = () => { - removeMetric(metric.id); + removeMetric({ metricId: metric.id }); onClose(); }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/view-scenario-drawer.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/view-scenario-drawer.tsx index 82a93916532..d4b12a1a336 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/view-scenario-drawer.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/view-scenario-drawer.tsx @@ -155,13 +155,15 @@ const ViewScenarioContent = ({ if (!result.success) { return; } - updateScenario(scenario.id, (draft) => { - // Replace each field on the immer/structuredClone draft. - draft.name = result.data.name; - draft.description = result.data.description; - draft.scenarioParameters = result.data.scenarioParameters; - draft.parameterOverrides = result.data.parameterOverrides; - draft.initialState = result.data.initialState; + updateScenario({ + scenarioId: scenario.id, + update: { + name: result.data.name, + description: result.data.description, + scenarioParameters: result.data.scenarioParameters, + parameterOverrides: result.data.parameterOverrides, + initialState: result.data.initialState, + }, }); onClose(); }, 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 index 0eb8e18c761..f10c0765002 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/run-auto-layout.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/run-auto-layout.ts @@ -34,8 +34,9 @@ export async function runAutoLayout({ const positions = await calculateGraphLayout(sdcpn, dimensions); - const commits: Parameters[0] = - []; + const commits: Parameters< + MutationContextValue["commitNodePositions"] + >[0]["commits"] = []; for (const place of sdcpn.places) { const position = positions[place.id]; @@ -59,6 +60,6 @@ export async function runAutoLayout({ } if (commits.length > 0) { - commitNodePositions(commits); + 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 e552822d125..d8e6be5aea1 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 @@ -139,7 +139,7 @@ export function useApplyNodeChanges() { } if (commits.length > 0) { - commitNodePositions(commits); + commitNodePositions({ commits }); } } }; 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 032de322226..e0c96648f77 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/sdcpn-view.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/sdcpn-view.tsx @@ -200,13 +200,23 @@ export const SDCPNView: React.FC<{ // Determine direction: place->transition or transition->place if (sourceNode.type === "place" && targetNode.type === "transition") { // Input arc: place to transition - addArc(target, "input", source, 1); + addArc({ + transitionId: target, + arcDirection: "input", + placeId: source, + weight: 1, + }); } else if ( sourceNode.type === "transition" && targetNode.type === "place" ) { // Output arc: transition to place - addArc(source, "output", target, 1); + addArc({ + transitionId: source, + arcDirection: "output", + placeId: target, + weight: 1, + }); } } From 0f5a57d19fa0ecba22cc90186c46f1eb814fd001 Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Fri, 15 May 2026 19:07:14 +0100 Subject: [PATCH 2/4] core cleanup / type changes --- .../petrinaut/src/core/action-schemas.ts | 3 +- libs/@hashintel/petrinaut/src/core/ai.test.ts | 10 +- libs/@hashintel/petrinaut/src/core/ai.ts | 173 +++++------------- libs/@hashintel/petrinaut/src/core/index.ts | 12 +- .../@hashintel/petrinaut/src/core/instance.ts | 6 + .../src/core/schemas/scenario-schema.ts | 61 ++++-- libs/@hashintel/petrinaut/src/main.ts | 13 -- 7 files changed, 109 insertions(+), 169 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/core/action-schemas.ts b/libs/@hashintel/petrinaut/src/core/action-schemas.ts index 835b27ae4bc..f5856d85cee 100644 --- a/libs/@hashintel/petrinaut/src/core/action-schemas.ts +++ b/libs/@hashintel/petrinaut/src/core/action-schemas.ts @@ -277,7 +277,8 @@ export const mutationActionInputSchemas = { .strictObject({ parameterId: idSchema }) .meta({ description: "Remove a net-level parameter." }), addScenario: simulationScenarioSchema.meta({ - description: "Add a simulation scenario.", + 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.", }), updateScenario: z .strictObject({ diff --git a/libs/@hashintel/petrinaut/src/core/ai.test.ts b/libs/@hashintel/petrinaut/src/core/ai.test.ts index ffe1a95fb21..f5f929b84d1 100644 --- a/libs/@hashintel/petrinaut/src/core/ai.test.ts +++ b/libs/@hashintel/petrinaut/src/core/ai.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from "vitest"; import { - createPetrinautAiToolCallbacks, - petrinautAiToolInputSchemas, + createPetrinautMutationAiToolCallbacks, + petrinautAiMutationToolInputSchemas, petrinautAiTools, } from "./ai"; import { createJsonDocHandle } from "./handle"; @@ -11,7 +11,7 @@ import { createPetrinaut } from "./instance"; describe("Petrinaut AI core exports", () => { test("tool metadata stays aligned with input schemas and has no execute", () => { expect(Object.keys(petrinautAiTools).sort()).toEqual( - Object.keys(petrinautAiToolInputSchemas).sort(), + Object.keys(petrinautAiMutationToolInputSchemas).sort(), ); for (const tool of Object.values(petrinautAiTools)) { @@ -34,7 +34,7 @@ describe("Petrinaut AI core exports", () => { }, }), }); - const callbacks = createPetrinautAiToolCallbacks(instance); + const callbacks = createPetrinautMutationAiToolCallbacks(instance); callbacks.addPlace({ id: "place-1", @@ -65,7 +65,7 @@ describe("Petrinaut AI core exports", () => { }, }), }); - const callbacks = createPetrinautAiToolCallbacks(instance); + const callbacks = createPetrinautMutationAiToolCallbacks(instance); expect(() => callbacks.addPlace({ diff --git a/libs/@hashintel/petrinaut/src/core/ai.ts b/libs/@hashintel/petrinaut/src/core/ai.ts index fd03ee481ab..10570468ea7 100644 --- a/libs/@hashintel/petrinaut/src/core/ai.ts +++ b/libs/@hashintel/petrinaut/src/core/ai.ts @@ -1,9 +1,8 @@ -import type { z } from "zod"; +import { z } from "zod"; import { probabilisticSatellitesSDCPN } from "../examples/satellites-launcher"; import { mutationActionInputSchemas, - type MutationActionInput, type MutationActionName, } from "./action-schemas"; import type { Petrinaut } from "./instance"; @@ -14,14 +13,14 @@ export { differentialEquationSchema, metricSchema, parameterSchema, - mutationActionInputSchemas as petrinautAiToolInputSchemas, + mutationActionInputSchemas, placeSchema, scenarioSchema, transitionSchema, } from "./action-schemas"; export type { - MutationActionInput as PetrinautAiToolInput, - MutationActionName as PetrinautAiToolName, + MutationActionInput as PetrinautAiMutationToolInput, + MutationActionName as PetrinautAiMutationToolName, } from "./action-schemas"; export type PetrinautAiTool = { @@ -30,8 +29,8 @@ export type PetrinautAiTool = { }; export type PetrinautAiTools = { - [Name in MutationActionName]: PetrinautAiTool< - (typeof mutationActionInputSchemas)[Name] + [Name in keyof typeof petrinautAiMutationToolInputSchemas]: PetrinautAiTool< + (typeof petrinautAiMutationToolInputSchemas)[Name] >; }; @@ -68,146 +67,68 @@ function createToolBundle>( return tools; } -export const petrinautAiTools = createToolBundle( - mutationActionInputSchemas, -) satisfies PetrinautAiTools; +export const getLatestNetDefinitionToolName = "getLatestNetDefinition"; -export type PetrinautAiToolCallbacks = { - [Name in MutationActionName]: (input: MutationActionInput) => void; +const getLatestNetDefinitionToolInputSchema = z + .strictObject({}) + .describe("Get the latest complete Petrinaut SDCPN net definition."); + +export const petrinautAiMutationToolInputSchemas = { + ...mutationActionInputSchemas, + [getLatestNetDefinitionToolName]: getLatestNetDefinitionToolInputSchema, }; -export function createPetrinautAiToolCallbacks( +export const petrinautAiMutationTools = createToolBundle( + mutationActionInputSchemas, +); + +export const petrinautAiTools = { + ...petrinautAiMutationTools, + [getLatestNetDefinitionToolName]: { + description: getSchemaDescription(getLatestNetDefinitionToolInputSchema), + inputSchema: getLatestNetDefinitionToolInputSchema, + }, +} satisfies PetrinautAiTools; + +export type PetrinautAiToolName = keyof typeof petrinautAiTools; + +export type PetrinautAiToolInput = z.input< + (typeof petrinautAiTools)[Name]["inputSchema"] +>; + +export type PetrinautMutationAiToolCallbacks = Pick< + Petrinaut, + MutationActionName +>; + +export function createPetrinautMutationAiToolCallbacks( instance: Petrinaut, -): PetrinautAiToolCallbacks { - return { - addPlace(input) { - instance.addPlace(input); - }, - updatePlace(input) { - instance.updatePlace(input); - }, - updatePlacePosition(input) { - instance.updatePlacePosition(input); - }, - removePlace(input) { - instance.removePlace(input); - }, - addTransition(input) { - instance.addTransition(input); - }, - updateTransition(input) { - instance.updateTransition(input); - }, - updateTransitionPosition(input) { - instance.updateTransitionPosition(input); - }, - removeTransition(input) { - instance.removeTransition(input); - }, - addArc(input) { - instance.addArc(input); - }, - removeArc(input) { - instance.removeArc(input); - }, - updateArcWeight(input) { - instance.updateArcWeight(input); - }, - updateArcType(input) { - instance.updateArcType(input); - }, - updateArcPlace(input) { - instance.updateArcPlace(input); - }, - addType(input) { - instance.addType(input); - }, - updateType(input) { - instance.updateType(input); - }, - removeType(input) { - instance.removeType(input); - }, - addTypeElement(input) { - instance.addTypeElement(input); - }, - updateTypeElement(input) { - instance.updateTypeElement(input); - }, - removeTypeElement(input) { - instance.removeTypeElement(input); - }, - moveTypeElement(input) { - instance.moveTypeElement(input); - }, - addDifferentialEquation(input) { - instance.addDifferentialEquation(input); - }, - updateDifferentialEquation(input) { - instance.updateDifferentialEquation(input); - }, - removeDifferentialEquation(input) { - instance.removeDifferentialEquation(input); - }, - addParameter(input) { - instance.addParameter(input); - }, - updateParameter(input) { - instance.updateParameter(input); - }, - removeParameter(input) { - instance.removeParameter(input); - }, - addScenario(input) { - instance.addScenario(input); - }, - updateScenario(input) { - instance.updateScenario(input); - }, - removeScenario(input) { - instance.removeScenario(input); - }, - addMetric(input) { - instance.addMetric(input); - }, - updateMetric(input) { - instance.updateMetric(input); - }, - removeMetric(input) { - instance.removeMetric(input); - }, - deleteItemsByIds(input) { - instance.deleteItemsByIds(input); - }, - commitNodePositions(input) { - instance.commitNodePositions(input); - }, - }; +): PetrinautMutationAiToolCallbacks { + return instance; } -export function createPetrinautAiPrompt(): string { - const example = JSON.stringify(probabilisticSatellitesSDCPN, null, 2); - - return `You are an expert assistant for building Stochastic Dynamic Coloured Petri Nets (SDCPNs) in Petrinaut. +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. + +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. When creating or revising a net: - Prefer small, meaningful mutations rather than replacing unrelated content. - Use coloured-token types when tokens need attributes. - Use parameters for values the user may want to tune. +- When adding scenarios, prefer scenario parameters for key assumptions the user may want to modify between runs. Reference them as scenario.identifier in parameter overrides and initial-state expressions. - Use stochastic transition lambdas for rate-based firing. - 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. - Keep executable code self-contained and readable. -- Summarize the changes after calling tools. -You can ask the user follow-up questions to clarify their intent before making any changes. +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. Here is a compact example Petrinaut document demonstrating coloured tokens, stochastic and predicate transitions, transition kernels with distributions, continuous dynamics, parameters, visualizer code, and scenarios: \`\`\`json -${example} +${JSON.stringify(probabilisticSatellitesSDCPN, null, 2)} \`\`\``; -} diff --git a/libs/@hashintel/petrinaut/src/core/index.ts b/libs/@hashintel/petrinaut/src/core/index.ts index 21ab379c035..4f4171ae481 100644 --- a/libs/@hashintel/petrinaut/src/core/index.ts +++ b/libs/@hashintel/petrinaut/src/core/index.ts @@ -32,12 +32,14 @@ export type { MutationHelperFunctions } from "./actions"; // --- AI --- export { colorSchema, - createPetrinautAiPrompt, - createPetrinautAiToolCallbacks, + createPetrinautMutationAiToolCallbacks, differentialEquationSchema, + getLatestNetDefinitionToolName, metricSchema, parameterSchema, - petrinautAiToolInputSchemas, + petrinautAiMutationToolInputSchemas, + petrinautAiMutationTools, + petrinautAiPrompt, petrinautAiTools, placeSchema, scenarioSchema, @@ -45,8 +47,10 @@ export { } from "./ai"; export type { PetrinautAiTool, - PetrinautAiToolCallbacks, + PetrinautMutationAiToolCallbacks, PetrinautAiToolInput, + PetrinautAiMutationToolInput, + PetrinautAiMutationToolName, PetrinautAiToolName, PetrinautAiTools, } from "./ai"; diff --git a/libs/@hashintel/petrinaut/src/core/instance.ts b/libs/@hashintel/petrinaut/src/core/instance.ts index 44be432fb08..2ed201186d9 100644 --- a/libs/@hashintel/petrinaut/src/core/instance.ts +++ b/libs/@hashintel/petrinaut/src/core/instance.ts @@ -62,6 +62,12 @@ function createDefinitionStore( } }); + if (typeof window !== "undefined") { + window.addEventListener("beforeunload", () => { + unsubscribe(); + }); + } + return { get: () => handle.doc() ?? EMPTY_SDCPN, subscribe(listener) { diff --git a/libs/@hashintel/petrinaut/src/core/schemas/scenario-schema.ts b/libs/@hashintel/petrinaut/src/core/schemas/scenario-schema.ts index 02fe9f816f0..6eb3f00313e 100644 --- a/libs/@hashintel/petrinaut/src/core/schemas/scenario-schema.ts +++ b/libs/@hashintel/petrinaut/src/core/schemas/scenario-schema.ts @@ -9,7 +9,8 @@ const SNAKE_CASE_RE = /^[a-z][a-z0-9_]*$/; export const scenarioParameterSchema = z .strictObject({ type: z.enum(["real", "integer", "boolean", "ratio"]).meta({ - description: "Primitive scenario parameter type.", + description: + "Primitive type for a user-tunable scenario variable. Use ratio for 0-1 proportions, integer for counts, real for rates or other continuous values, and boolean for switches.", }), identifier: z .string() @@ -17,33 +18,53 @@ export const scenarioParameterSchema = z .regex(SNAKE_CASE_RE, "Identifier must be snake_case") .meta({ description: - "Scenario-scoped identifier, referenced as scenario.identifier in overrides. Must be in snake_case.", + "Scenario-scoped identifier for a user-tunable variable. Reference it as scenario.identifier in parameterOverrides and initialState expressions. Must be snake_case.", }), default: z.number().meta({ - description: "Default numeric value for this scenario parameter.", + description: + "Default numeric value for this scenario parameter, shown to the user before they adjust the scenario.", }), }) .meta({ - description: "A parameter scoped to one scenario.", + description: + "A user-tunable variable scoped to one scenario. Prefer scenario parameters for key assumptions the user may want to modify between simulation runs, such as population size, initial infected ratio, intervention strength, or stress-test severity.", }); const initialStateSchema = z .discriminatedUnion("type", [ - z.strictObject({ - type: z.literal("per_place"), - content: z.record( - z.string(), - z.union([z.string(), z.array(z.array(z.number()))]), - ), - }), - z.strictObject({ - type: z.literal("code"), - content: z.string(), - }), + z + .strictObject({ + type: z.literal("per_place"), + content: z + .record( + z.string(), + 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.', + }), + }) + .meta({ + description: + "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.", + }), + }) + .meta({ + description: + "Initial state specified by code. Use only when per_place expressions cannot express the setup.", + }), ]) .meta({ description: - "Initial token state for a scenario, either per-place values or executable code.", + 'Initial token state for a scenario. Prefer type "per_place" with content keyed by place ID; use type "code" only for advanced custom setup.', }); export const scenarioSchema = z @@ -72,17 +93,17 @@ export const scenarioSchema = z }) .meta({ description: - "Parameters available only within this scenario and its overrides.", + "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()).meta({ + parameterOverrides: z.record(z.string(), z.string()).default({}).meta({ description: - "Map from net-level parameter ID to concrete value or scenario parameter expression.", + '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.', }), initialState: initialStateSchema, }) .meta({ description: - "A reusable simulation scenario with parameter overrides and initial token state.", + "A reusable simulation scenario with user-tunable scenarioParameters, overrides for existing net-level parameters, and an initial token state. Prefer adding scenario parameters for key assumptions the user may want to modify between runs.", }) satisfies z.ZodType; export type ScenarioSchema = typeof scenarioSchema; diff --git a/libs/@hashintel/petrinaut/src/main.ts b/libs/@hashintel/petrinaut/src/main.ts index 2ac3a3959dd..8405cd50bd7 100644 --- a/libs/@hashintel/petrinaut/src/main.ts +++ b/libs/@hashintel/petrinaut/src/main.ts @@ -41,19 +41,6 @@ export type { } from "./core/instance"; export { createPetrinautActions } from "./core/actions"; export type { MutationHelperFunctions } from "./core/actions"; -export { - createPetrinautAiPrompt, - createPetrinautAiToolCallbacks, - petrinautAiToolInputSchemas, - petrinautAiTools, -} from "./core/ai"; -export type { - PetrinautAiTool, - PetrinautAiToolCallbacks, - PetrinautAiToolInput, - PetrinautAiToolName, - PetrinautAiTools, -} from "./core/ai"; export { createSimulation, createWorkerTransport, From 5c94a8d9110fd061edd884054de70116c144e0d9 Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Fri, 15 May 2026 19:19:34 +0100 Subject: [PATCH 3/4] untrack file --- PETRINAUT_AI_ASSISTANCE_SPEC.md | 113 -------------------------------- 1 file changed, 113 deletions(-) delete mode 100644 PETRINAUT_AI_ASSISTANCE_SPEC.md diff --git a/PETRINAUT_AI_ASSISTANCE_SPEC.md b/PETRINAUT_AI_ASSISTANCE_SPEC.md deleted file mode 100644 index fe492663cf4..00000000000 --- a/PETRINAUT_AI_ASSISTANCE_SPEC.md +++ /dev/null @@ -1,113 +0,0 @@ -# Petrinaut AI assistance MVP - -# Goals - -1. Implement a simple chat where users can ask an LLM to generate or revise a Petri net. -2. Use the [Figma sidebar chat design](https://www.figma.com/design/EuokCTrNYWhEMBQ7MmGrwJ/Petrinaut?node-id=194-63830&m=dev) -3. Out of scope for MVP: - 1. Chat tabs - 2. Actions - 3. Suggestion review (LLM will just edit the net directly, but we can have a summary of what was changed) - -# Technical components / plan - -Split by which part of Petrinaut they affect. - -## Decisions captured before implementation - -1. The first implementation stage is the `core` stage. -2. Core mutation methods now use object-style inputs everywhere, inferred from Zod action schemas. Do not reintroduce callback-style update functions in the public mutation context. -3. All existing mutation helpers should move into `core` and be available as AI tools, including destructive actions such as remove/delete helpers. For the MVP, tool calls execute directly without suggestion review or confirmation. -4. The prompt generator should inline `probabilisticSatellitesSDCPN` from `src/examples/satellites-launcher.ts`, provided it continues to demonstrate the relevant SDCPN features compactly: stochastic transitions, predicate transitions, transition kernels with distributions, coloured tokens, dynamics, differential equations, parameters, visualizer code, and scenarios. -5. The UI and website stages should consume the exported core AI tool metadata and callback map, rather than redefining tool names or schemas outside `core`. - -# `core (libs/@hashintel/petrinaut)` - -This is where the non-UI logic lives. - -I’ve put the non-UI AI stuff here for now. It could go in an `core/ai` folder. Alternatively we can create another area for it. - -### Core stage status - -The core stage has been implemented. Future stages should build on these exported APIs rather than redefining tool names, schemas, prompts, or callbacks outside `core`. - -1. Core action input schemas live in `core/action-schemas.ts` and are exported for AI use as `petrinautAiToolInputSchemas`. -2. Runtime mutation validation is performed by the core actions using `mutationActionInputSchemas`. -3. AI tool metadata is exported as `petrinautAiTools`. These tools deliberately do not include `execute`, because tool calls are applied client-side to the live Petrinaut instance. -4. Client-side tool execution should use `createPetrinautAiToolCallbacks(instance)`. -5. The prompt generator is exported as `createPetrinautAiPrompt()`. -6. Useful exported types include `PetrinautAiToolName`, `PetrinautAiToolInput`, `PetrinautAiToolCallbacks`, and `PetrinautAiTools`. -7. Update payloads are intentionally lean: - 1. Entity IDs are passed separately from `update`. - 2. Place/transition positions should use `updatePlacePosition`, `updateTransitionPosition`, or `commitNodePositions`. - 3. Arc edits should use `addArc`, `removeArc`, `updateArcWeight`, `updateArcType`, or `updateArcPlace`. - 4. Type element edits should use `addTypeElement`, `updateTypeElement`, `removeTypeElement`, or `moveTypeElement`. - 5. Broad array replacement through `updateTransition({ update: { inputArcs/outputArcs } })` or `updateType({ update: { elements } })` is intentionally not supported. - -### Requirements - -1. Granular actions to modify a Petri net (e.g. `addPlace`, `addTransition`) which are typed using a zod schema with lots of explanation of each, so that LLM tool calls can be generated from the schemas and we don’t have to separately maintain a list of LLM tools. As a byproduct it also improves the `core`. -2. The schemas should match the object-style core action inputs, not higher-level intent inputs. For update helpers, define serializable tool inputs that can be applied by core callbacks without exposing arbitrary functions to the LLM. -3. A set of LLM tools which incorporate the above. These will be exported for use by the server making the tool call and the frontend processing the response. -4. An LLM prompt generator for use in a Petri net-building AI assistant. This should inline `probabilisticSatellitesSDCPN` from the `examples` folder, which compactly demonstrates probabilistic transitions, dynamics, colours, stochastic firing, transition kernels, parameters, visualizer code, and scenarios. - -### Work - -1. Move `addPlace` etc from the `react` area of Petrinaut to the `core`, as methods on the `Petrinaut` instance, using object-style action inputs. -2. Create well-described Zod schemas for each serializable action input, using inferred types where this does not make the existing API worse. Include full documentation of features on appropriate actions (e.g. creating a differential equation explaining dynamics; creating/amending a transition explaining probability distributions in transition kernels, etc). -3. Add a function which generates the LLM tool bundle from the schemas (using [Vercel’s AI SDK structure](https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling#tool-calling) as a target) in `core/ai` - 1. This will have two consumers (see [Chat bot tool usage](https://ai-sdk.dev/docs/ai-sdk-ui/chatbot-tool-usage#client-side-page) in the Vercel AI UI SDK) - 1. The React side will use this as a reference for how to handle tool calls (via `onToolCall`), including getting type inference (see `ui` section below) - 2. The server brokering the LLM calls – currently only `petrinaut-website` – will use this when calling the LLM, alongside the prompt. - 2. Note that because this is a client-side action, these shouldn’t include an `execute` property. But it should make a typed map of tool name to callback available. - 3. We should use a conditional mapped type (or similar) to ensure that the return of the LLM tool bundle generator and callback map remain in sync. -4. Add the LLM prompt generator, for consumption by the server making the call (in the first instance, `petrinaut-website`). -5. Add Zod validation to all granular actions that are now in `core`. This improves the `core` because it provides runtime errors if an input is inconsistent with expectations, including cases where input type safety is weakened by type casting or incoming AI/tool payloads. - 1. Ensure meaningful/nice error messages as feedback - 2. Add tests to check for validation failures when adding an incorrect input - -# **`ui (libs/@hashintel/petrinaut)`** - -### Requirements / Work - -Use the Figma MCP to get reference design details (then translate into PandaCSS / ArkUI / ds-component equivalents) - -1. The button to summon the AI is added to the toolbar – [see component reference in Figma](https://www.figma.com/design/WmnosvOvi4Blw0HK0jh1mG/Design-System?node-id=44988-5621&m=dev) - 1. Note that the button has a blue icon on hover, and when the AI chat is opened should have a gray background. [See reference design within this screen](https://www.figma.com/design/EuokCTrNYWhEMBQ7MmGrwJ/Petrinaut?node-id=194-63830&m=dev). -2. It will open a chat window that appears adjacent to the right-hand side node panel (if open), otherwise flush to the right. [Reference design for the chat window itself here](https://www.figma.com/design/EuokCTrNYWhEMBQ7MmGrwJ/Petrinaut?node-id=194-63837&m=dev) -3. Agent output should be streamed and formatted as Markdown. -4. Reasoning stream parts should also be streamed and then collapsed when complete. [See reference design for chat parts here.](https://www.figma.com/design/EuokCTrNYWhEMBQ7MmGrwJ/Petrinaut?node-id=430-47402&m=dev) This displays both the collapsed view (”Reasoning”) and expanded (”Understanding prompt requirements”). -5. All UI should use existing conventions within Petrinaut (e.g. related to usage of PandaCSS and ArkUI, and `ds-components` where possible). -6. The UI app uses React compiler and should avoid `useMemo`. `useEffect` should be used sparingly, if at all. -7. The chat window should use the current editor `Petrinaut` instance/handle so tool calls mutate the live document. It may create a callback map with `createPetrinautAiToolCallbacks(instance)`, but must not create an isolated JSON document unless building an explicit preview/review mode. -8. Completed tool calls should be shown as ‘Added Node X’ style things, [see reference design here](https://www.figma.com/design/EuokCTrNYWhEMBQ7MmGrwJ/Petrinaut?node-id=556-247310&m=dev). If an agent does multiple things, these should be expandable lists. Clicking on an individual item (e.g. a node) should select it in the UI. -9. It can [infer types](https://ai-sdk.dev/docs/ai-sdk-ui/chatbot#type-inference-for-tools) from the tools exported by `core`. -10. The chat should use Vercel AI SDK’s [useChat](https://ai-sdk.dev/docs/ai-sdk-ui/chatbot). It will accept a prop which provides the [transport](https://ai-sdk.dev/docs/ai-sdk-ui/transport), which should conform to Vercel’s `DefaultChatTransport` interface. Whether this prop is available or not determines whether the AI button/feature is available at all. It can be a prop to the entrypoint `petrinaut.tsx` component and then passed directly to `EditorView`. -11. Add appropriate unit tests which test meaningful behaviour, using appropriate mocks for the AI call (e.g. checking that the right chat elements are rendered when the model is thinking, has called a tool, etc). - -### UI handoff notes - -1. Use `petrinautAiTools` for tool typing and `createPetrinautAiToolCallbacks(instance)` for client-side tool execution. -2. Tool calls should apply to the live Petrinaut instance via `onToolCall`, not on the server. -3. Tool-call UI should map tool names to concise human-readable summaries, such as “Added place X”, “Updated arc weight”, or “Removed type Y”. -4. Completed tool summaries should select or focus affected entities where the tool input identifies one. For non-entity or batch mutations, show a generic mutation summary. -5. Destructive tools are allowed for the MVP because there is no suggestion review. The UI should rely on existing document history/undo rather than building an AI-specific review flow. -6. Feature availability should be controlled by the optional chat transport prop. If no transport is provided, hide or disable the AI entry point. - -# `apps/petrinaut-website` - -1. Add the Vercel AI SDK in a server endpoint for the chat window to consume, following the requirements of the Vercel AI SDK (i.e. endpoint accepting messages/sendMessages and streaming response back). `petrinaut-website` is currently a Vite app, so this may require adding an appropriate Vercel/serverless function rather than a Next.js server action. See reference implementation [here](https://ai-sdk.dev/docs/ai-sdk-ui/chatbot-tool-usage#api-route). -2. Set up a provider registry with only OpenAI for now – [see Vercel docs](https://ai-sdk.dev/docs/ai-sdk-core/provider-management) -3. Add an `OPENAI_API_KEY` environment variable. NOT PUBLIC! Should not be exposed to the browser. -4. Use GPT-5.5 model, `gpt-5.5-2026-04-23`, as the default model, but keep the model configurable via server-side environment/configuration. -5. Import `petrinautAiTools` and `createPetrinautAiPrompt` from Petrinaut; do not duplicate tool schemas or prompt text in the website. -6. The endpoint streams model output and tool-call parts but does not execute Petrinaut mutations server-side. The browser applies tool calls to the live editor instance through the UI `onToolCall` path. -7. Protect the endpoint before exposing it publicly: at minimum add request size limits, origin/CORS checks, and rate limiting. -8. Add the prop created for the chat transport to Petrinaut in the demo website. - -## Known non-goals for the next two phases - -1. Do not add suggestion review or approval flows for MVP; tool calls edit the live net directly. -2. Do not add chat tabs. -3. Do not add AI-specific undo/redo UX beyond existing Petrinaut document history. -4. Do not persist chat history server-side unless explicitly scoped later. From a2406d2f698722c8c71f54c6e26671c40e96dc4f Mon Sep 17 00:00:00 2001 From: Ciaran Morinan Date: Fri, 15 May 2026 19:35:07 +0100 Subject: [PATCH 4/4] remove stray change --- libs/@hashintel/petrinaut/src/core/instance.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/core/instance.ts b/libs/@hashintel/petrinaut/src/core/instance.ts index 2ed201186d9..44be432fb08 100644 --- a/libs/@hashintel/petrinaut/src/core/instance.ts +++ b/libs/@hashintel/petrinaut/src/core/instance.ts @@ -62,12 +62,6 @@ function createDefinitionStore( } }); - if (typeof window !== "undefined") { - window.addEventListener("beforeunload", () => { - unsubscribe(); - }); - } - return { get: () => handle.doc() ?? EMPTY_SDCPN, subscribe(listener) {