diff --git a/apps/code/package.json b/apps/code/package.json index e6849dc66..0c2ec39f0 100644 --- a/apps/code/package.json +++ b/apps/code/package.json @@ -111,7 +111,7 @@ "@posthog/host-router": "workspace:*", "@posthog/host-trpc": "workspace:*", "@posthog/platform": "workspace:*", - "@posthog/quill": "0.3.0-beta.1", + "@posthog/quill": "catalog:", "@posthog/shared": "workspace:*", "@posthog/ui": "workspace:*", "@posthog/workspace-client": "workspace:*", diff --git a/apps/code/src/main/trpc/router.ts b/apps/code/src/main/trpc/router.ts index 62cd7e9dd..59877266c 100644 --- a/apps/code/src/main/trpc/router.ts +++ b/apps/code/src/main/trpc/router.ts @@ -4,6 +4,7 @@ import { analyticsRouter } from "@posthog/host-router/routers/analytics.router"; import { archiveRouter } from "@posthog/host-router/routers/archive.router"; import { authRouter } from "@posthog/host-router/routers/auth.router"; import { canvasGenRouter } from "@posthog/host-router/routers/canvas-gen.router"; +import { canvasTemplatesRouter } from "@posthog/host-router/routers/canvas-templates.router"; import { cloudTaskRouter } from "@posthog/host-router/routers/cloud-task.router"; import { connectivityRouter } from "@posthog/host-router/routers/connectivity.router"; import { contextMenuRouter } from "@posthog/host-router/routers/context-menu.router"; @@ -50,6 +51,7 @@ export const trpcRouter = router({ archive: archiveRouter, auth: authRouter, canvasGen: canvasGenRouter, + canvasTemplates: canvasTemplatesRouter, dashboards: dashboardsRouter, cloudTask: cloudTaskRouter, connectivity: connectivityRouter, diff --git a/biome.jsonc b/biome.jsonc index 279624e3b..2e9ab9744 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -345,7 +345,8 @@ "!@posthog/workspace-client/client", "!@posthog/platform", "!@posthog/platform/*", - "!@posthog/quill" + "!@posthog/quill", + "!@posthog/quill-charts" ], "message": "ui must run in any JS environment." } diff --git a/package.json b/package.json index 3d5ac2516..75c5c775a 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,8 @@ "@posthog/cli" ], "overrides": { - "zod": "4.3.6" + "zod": "4.3.6", + "@posthog/quill>@base-ui/react": "^1.3.0" } } } diff --git a/packages/core/package.json b/packages/core/package.json index 99d84fffe..08c3b9795 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -16,6 +16,7 @@ "clean": "node ../../scripts/rimraf.mjs .turbo" }, "dependencies": { + "@json-render/core": "^0.19.0", "@modelcontextprotocol/ext-apps": "^1.1.2", "@modelcontextprotocol/sdk": "^1.12.1", "@pierre/diffs": "^1.1.21", diff --git a/packages/core/src/canvas/canvas.module.ts b/packages/core/src/canvas/canvas.module.ts index 6704d2e9a..3bf91e2a6 100644 --- a/packages/core/src/canvas/canvas.module.ts +++ b/packages/core/src/canvas/canvas.module.ts @@ -1,7 +1,12 @@ import { ContainerModule } from "inversify"; +import { CanvasTemplatesService } from "./canvasTemplatesService"; import { DashboardQueryService } from "./dashboardQueryService"; import { DashboardsService } from "./dashboardsService"; -import { DASHBOARD_QUERY_SERVICE, DASHBOARDS_SERVICE } from "./identifiers"; +import { + CANVAS_TEMPLATES_SERVICE, + DASHBOARD_QUERY_SERVICE, + DASHBOARDS_SERVICE, +} from "./identifiers"; // Host-agnostic canvas services (dashboards + their HogQL refresh). They only // need AuthService + fetch, so they live in @posthog/core and any host (desktop, @@ -12,4 +17,9 @@ export const canvasCoreModule = new ContainerModule(({ bind }) => { bind(DashboardsService).toSelf().inSingletonScope(); bind(DASHBOARDS_SERVICE).toService(DashboardsService); + + // Canvas templates: host-agnostic (pure prompt strings), no deps. The + // host-router canvas-templates router and CanvasGenService resolve it by token. + bind(CanvasTemplatesService).toSelf().inSingletonScope(); + bind(CANVAS_TEMPLATES_SERVICE).toService(CanvasTemplatesService); }); diff --git a/packages/core/src/canvas/canvasSchema.ts b/packages/core/src/canvas/canvasSchema.ts new file mode 100644 index 000000000..d11febfc6 --- /dev/null +++ b/packages/core/src/canvas/canvasSchema.ts @@ -0,0 +1,84 @@ +import { defineSchema } from "@json-render/core"; + +// Core-only mirror of `@json-render/react`'s element-tree `schema`. That schema +// is itself pure `@json-render/core` (no React runtime), but it's only reachable +// via the React package's entrypoint — importing it would drag React into +// @posthog/core (forbidden: core must stay host-agnostic). So we re-declare the +// identical schema here, in core, where BOTH the main-process services (to +// generate the agent system prompt via `catalog.prompt()`) and the renderer can +// use it. +// +// IMPORTANT: keep this in lockstep with `@json-render/react`'s `schema` +// (pinned at @json-render/* 0.19.0). If that package's schema changes, mirror it. +export const canvasSchema = defineSchema( + (s) => ({ + // What the AI-generated SPEC looks like. + spec: s.object({ + root: s.string(), + // Optional initial state model the UI reads/writes (form fields, toggles). + state: s.any(), + elements: s.record( + s.object({ + type: s.ref("catalog.components"), + props: s.propsOf("catalog.components"), + children: s.array(s.string()), + visible: s.any(), + // Event → action bindings (e.g. { "click": { "action": "setState", … } }). + on: s.any(), + }), + ), + }), + // What the CATALOG must provide. + catalog: s.object({ + components: s.map({ + props: s.zod(), + slots: s.array(s.string()), + description: s.string(), + example: s.any(), + }), + actions: s.map({ + params: s.zod(), + description: s.string(), + }), + }), + }), + { + builtInActions: [ + { + name: "setState", + description: + "Update a value in the state model at the given statePath. Params: { statePath: string, value: any }", + }, + { + name: "pushState", + description: + 'Append an item to an array in state. Params: { statePath: string, value: any, clearStatePath?: string }. Value can contain {"$state":"/path"} refs and "$id" for auto IDs.', + }, + { + name: "removeState", + description: + "Remove an item from an array in state by index. Params: { statePath: string, index: number }", + }, + { + name: "validateForm", + description: + "Validate all registered form fields and write the result to state. Params: { statePath?: string }. Defaults to /formValidation. Result: { valid: boolean, errors: Record }.", + }, + ], + // NOTE: the canvas renderer now resolves json-render's DECLARATIVE dynamic + // features — a top-level `state` model, `{$state}` reads, `{$bindState}` + // two-way form bindings, `visible` conditions, and `on`/actions (the four + // built-ins: setState/pushState/removeState/validateForm). It does NOT yet + // resolve `repeat`/`{$item}`/`{$index}`; those still degrade to empty. We + // drop the upstream default rules (which assume the full feature set) and + // keep only the always-applicable guidance; the per-template rules + // (canvasTemplates.ts) spell out exactly which dynamic features are allowed. + defaultRules: [ + "CRITICAL INTEGRITY CHECK: Before outputting ANY element that references children, you MUST have already output (or will output) each child as its own element. If an element has children: ['a', 'b'], then elements 'a' and 'b' MUST exist. A missing child element causes that entire branch of the UI to be invisible.", + "SELF-CHECK: After generating all elements, mentally walk the tree from root. Every key in every children array must resolve to a defined element. If you find a gap, output the missing element immediately.", + "Design with visual hierarchy: use container components to group content, heading components for section titles, and proper spacing. ONLY use components from the AVAILABLE COMPONENTS list.", + "For data-rich UIs, use multi-column layout components if available. For forms and single-column content, use vertical layout components. ONLY use components from the AVAILABLE COMPONENTS list.", + "Always include realistic, professional-looking sample content written directly into each element's props. For a landing page, write real headlines and body copy; for a list, output each item as its own element. Never leave content empty.", + ], + }, +); diff --git a/packages/core/src/canvas/canvasTemplates.ts b/packages/core/src/canvas/canvasTemplates.ts new file mode 100644 index 000000000..2a642a6f1 --- /dev/null +++ b/packages/core/src/canvas/canvasTemplates.ts @@ -0,0 +1,164 @@ +import { + ALL_CANVAS_COMPONENTS, + type CanvasComponentName, + canvasCatalogFor, +} from "./componentCatalog"; +import type { CanvasSuggestion } from "./templateSchemas"; + +// Per-template allow-lists (open question resolved: one shared contract, +// per-template allow-list). Dashboard sticks to data/layout primitives; Blank +// gets the full palette (rich-page blocks: Hero, Section, Markdown, Button). +const DASHBOARD_COMPONENTS: CanvasComponentName[] = [ + "Page", + "Grid", + "Card", + "Heading", + "Text", + "Stat", + "Table", + "BarList", + "LineChart", + "BarChart", + "Sparkline", + "PieChart", + "Progress", + "Badge", + "Button", + "TextInput", + "Checkbox", + "Divider", +]; + +// Rules that apply to EVERY canvas template, regardless of its purpose. +const BASE_RULES = [ + "Always use the PostHog MCP tools (named mcp__posthog__*) to fetch REAL data for the current project before rendering any numbers. Never fabricate metrics.", + "Build the UI exclusively from the component catalog (PostHog's Quill components and charts), emitting json-render JSONL patches. Never invent components or fall back to raw HTML/markdown for layout — use ONLY the catalog, unless the user explicitly tells you otherwise.", + "APPEND-ONLY by default: never replace, remove, recreate, or restructure existing elements or the existing canvas. Only ADD new elements (append children, add new sections). Emit additive patches only — do NOT re-emit or overwrite the whole spec. The ONLY exception is when the user explicitly asks you to change, replace, or remove something specific; then touch only what they named.", + 'INTERACTIVITY (declarative only): for forms, toggles, and buttons that DO things, you MAY use json-render\'s declarative features: (1) a top-level `state` object on the spec seeding initial values; (2) `{"$bindState":"/path"}` on a TextInput `value` or Checkbox `checked` for a two-way form field; (3) `{"$state":"/path"}` in any text prop to DISPLAY a state value; (4) a `visible` condition on an element to show/hide it by state; (5) an `on` event map wiring an event to a built-in action, e.g. `"on": { "click": { "action": "setState", "params": { "statePath": "/submitted", "value": true } } }`. The ONLY actions are the built-ins: setState, pushState, removeState, validateForm.', + 'STILL UNSUPPORTED — never emit these (they render as empty): `repeat`, `{"$item":…}`, `{"$bindItem":…}`, `{"$index":…}`, any custom/non-built-in action name, raw HTML, or `