From f06631489f814a3230cc2a02fa70fb4472aac0de Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sun, 1 Mar 2026 11:45:27 +0300 Subject: [PATCH] Wire AI tools to editor store with valibot schemas 10 tools: create_shape, set_fill, set_stroke, update_node, set_layout, delete_node, select_nodes, get_page_tree, get_selection, rename_node. Uses @ai-sdk/valibot for schema integration with ToolLoopAgent. --- bun.lock | 8 + package.json | 2 + src/ai/tools.ts | 301 +++++++++++++++++++++++++++++ src/components/PropertiesPanel.vue | 2 +- src/composables/use-chat.ts | 22 ++- 5 files changed, 332 insertions(+), 3 deletions(-) create mode 100644 src/ai/tools.ts diff --git a/bun.lock b/bun.lock index d2a6f0e94..5265a2434 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "open-pencil-app", "dependencies": { "@ai-sdk/openai": "^3.0.37", + "@ai-sdk/valibot": "^2.0.17", "@ai-sdk/vue": "^3.0.105", "@open-pencil/cli": "workspace:*", "@open-pencil/core": "workspace:*", @@ -25,6 +26,7 @@ "fzstd": "^0.1.1", "reka-ui": "^2.8.2", "tailwindcss": "^4.2.1", + "valibot": "^1.2.0", "vue": "^3.5.29", "vue-stream-markdown": "^0.6.0", "yoga-layout": "^3.2.1", @@ -87,6 +89,8 @@ "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.16", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kBvDqNkt5EwlzF9FujmNhhtl8FYg3e8FO8P5uneKliqfRThWemzBj+wfYr7ZCymAQhTRnwSSz1/SOqhOAwmx9g=="], + "@ai-sdk/valibot": ["@ai-sdk/valibot@2.0.17", "", { "dependencies": { "@ai-sdk/provider-utils": "4.0.16" }, "peerDependencies": { "@valibot/to-json-schema": "^1.3.0", "valibot": "^1.1.0" } }, "sha512-4/kEvGE7JaDGPIMVyrEq1by483kiSpbzhsHoQfi3K5ScbEs1dHXdnWr8P9eGyFBHyQi0uNPTqjnQGa2K4FSWsg=="], + "@ai-sdk/vue": ["@ai-sdk/vue@3.0.105", "", { "dependencies": { "@ai-sdk/provider-utils": "4.0.16", "ai": "6.0.105", "swrv": "^1.0.4" }, "peerDependencies": { "vue": "^3.3.4" } }, "sha512-qdf4G6p8lubBrDKw9u6gG7KJft+J7A/xqk0WRsWw+KAJkG+TgziaK4a+/5amuhOQnvnQL/NFlmQHfYnHzM2KMA=="], "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], @@ -429,6 +433,8 @@ "@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260228.1", "", { "os": "win32", "cpu": "x64" }, "sha512-9Wq1d0ZbrJcF6NoODw4+SRNdcZtKMmm2w32Wlo+r6ZIziBF5HFWvJcwGXNE0pbFpX6H/Gs8dhLaDKZU2dLoWOA=="], + "@valibot/to-json-schema": ["@valibot/to-json-schema@1.5.0", "", { "peerDependencies": { "valibot": "^1.2.0" } }, "sha512-GE7DmSr1C2UCWPiV0upRH6mv0cCPsqYGs819fb6srCS1tWhyXrkGGe+zxUiwzn/L1BOfADH4sNjY/YHCuP8phQ=="], + "@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="], "@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.4", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.2" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0", "vue": "^3.2.25" } }, "sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ=="], @@ -745,6 +751,8 @@ "unplugin-vue-components": ["unplugin-vue-components@31.0.0", "", { "dependencies": { "chokidar": "^5.0.0", "local-pkg": "^1.1.2", "magic-string": "^0.30.21", "mlly": "^1.8.0", "obug": "^2.1.1", "picomatch": "^4.0.3", "tinyglobby": "^0.2.15", "unplugin": "^2.3.11", "unplugin-utils": "^0.3.1" }, "peerDependencies": { "@nuxt/kit": "^3.2.2 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@nuxt/kit"] }, "sha512-4ULwfTZTLuWJ7+S9P7TrcStYLsSRkk6vy2jt/WTfgUEUb0nW9//xxmrfhyHUEVpZ2UKRRwfRb8Yy15PDbVZf+Q=="], + "valibot": ["valibot@1.2.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg=="], + "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], "vue": ["vue@3.5.29", "", { "dependencies": { "@vue/compiler-dom": "3.5.29", "@vue/compiler-sfc": "3.5.29", "@vue/runtime-dom": "3.5.29", "@vue/server-renderer": "3.5.29", "@vue/shared": "3.5.29" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA=="], diff --git a/package.json b/package.json index a9564931d..eb4551cb1 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ }, "dependencies": { "@ai-sdk/openai": "^3.0.37", + "@ai-sdk/valibot": "^2.0.17", "@ai-sdk/vue": "^3.0.105", "@open-pencil/cli": "workspace:*", "@open-pencil/core": "workspace:*", @@ -42,6 +43,7 @@ "fzstd": "^0.1.1", "reka-ui": "^2.8.2", "tailwindcss": "^4.2.1", + "valibot": "^1.2.0", "vue": "^3.5.29", "vue-stream-markdown": "^0.6.0", "yoga-layout": "^3.2.1" diff --git a/src/ai/tools.ts b/src/ai/tools.ts new file mode 100644 index 000000000..25d078467 --- /dev/null +++ b/src/ai/tools.ts @@ -0,0 +1,301 @@ +import { valibotSchema } from '@ai-sdk/valibot' +import { tool } from 'ai' +import * as v from 'valibot' + +import type { EditorStore } from '@/stores/editor' + +const nodeTypeSchema = v.picklist([ + 'FRAME', + 'RECTANGLE', + 'ELLIPSE', + 'TEXT', + 'LINE', + 'STAR', + 'POLYGON', + 'SECTION' +]) + +const colorSchema = v.object({ + r: v.pipe(v.number(), v.minValue(0), v.maxValue(1)), + g: v.pipe(v.number(), v.minValue(0), v.maxValue(1)), + b: v.pipe(v.number(), v.minValue(0), v.maxValue(1)), + a: v.optional(v.pipe(v.number(), v.minValue(0), v.maxValue(1)), 1) +}) + +const hexColorSchema = v.pipe( + v.string(), + v.regex(/^#?[0-9a-fA-F]{6,8}$/), + v.transform((hex) => { + const h = hex.replace('#', '') + return { + r: parseInt(h.slice(0, 2), 16) / 255, + g: parseInt(h.slice(2, 4), 16) / 255, + b: parseInt(h.slice(4, 6), 16) / 255, + a: h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1 + } + }) +) + +const fillColorSchema = v.union([colorSchema, hexColorSchema]) + +export function createAITools(store: EditorStore) { + return { + create_shape: tool({ + description: + 'Create a shape on the canvas. Use FRAME for containers/cards, RECTANGLE for solid blocks, ELLIPSE for circles, TEXT for labels, SECTION for page sections.', + inputSchema: valibotSchema( + v.object({ + type: nodeTypeSchema, + x: v.pipe(v.number(), v.description('X position in canvas coordinates')), + y: v.pipe(v.number(), v.description('Y position in canvas coordinates')), + width: v.pipe(v.number(), v.minValue(1), v.description('Width in pixels')), + height: v.pipe(v.number(), v.minValue(1), v.description('Height in pixels')), + name: v.optional(v.pipe(v.string(), v.description('Node name shown in layers panel'))), + parent_id: v.optional(v.pipe(v.string(), v.description('Parent node ID to nest inside'))) + }) + ), + execute: async ({ type, x, y, width, height, name, parent_id }) => { + const id = store.createShape(type, x, y, width, height, parent_id) + if (name) store.renameNode(id, name) + store.select([id]) + return { id, type, x, y, width, height, name: name ?? type.toLowerCase() } + } + }), + + set_fill: tool({ + description: 'Set the fill color of a node. Accepts hex (#ff0000) or RGBA object.', + inputSchema: valibotSchema( + v.object({ + id: v.pipe(v.string(), v.description('Node ID')), + color: fillColorSchema + }) + ), + execute: async ({ id, color }) => { + store.updateNodeWithUndo( + id, + { fills: [{ type: 'SOLID', color, opacity: 1, visible: true }] }, + 'Set fill' + ) + return { id, color } + } + }), + + set_stroke: tool({ + description: 'Set the stroke (border) of a node.', + inputSchema: valibotSchema( + v.object({ + id: v.pipe(v.string(), v.description('Node ID')), + color: fillColorSchema, + weight: v.optional(v.pipe(v.number(), v.minValue(0.1)), 1), + align: v.optional(v.picklist(['INSIDE', 'CENTER', 'OUTSIDE']), 'INSIDE') + }) + ), + execute: async ({ id, color, weight, align }) => { + store.updateNodeWithUndo( + id, + { strokes: [{ color, weight, opacity: 1, visible: true, align }] }, + 'Set stroke' + ) + return { id, color, weight } + } + }), + + update_node: tool({ + description: + 'Update properties of an existing node: position, size, opacity, corner radius, visibility, text content, font.', + inputSchema: valibotSchema( + v.object({ + id: v.pipe(v.string(), v.description('Node ID')), + x: v.optional(v.number()), + y: v.optional(v.number()), + width: v.optional(v.pipe(v.number(), v.minValue(1))), + height: v.optional(v.pipe(v.number(), v.minValue(1))), + opacity: v.optional(v.pipe(v.number(), v.minValue(0), v.maxValue(1))), + corner_radius: v.optional(v.pipe(v.number(), v.minValue(0))), + visible: v.optional(v.boolean()), + text: v.optional(v.pipe(v.string(), v.description('Text content (for TEXT nodes)'))), + font_size: v.optional(v.pipe(v.number(), v.minValue(1))), + font_weight: v.optional(v.number()), + name: v.optional(v.string()) + }) + ), + execute: async ({ id, corner_radius, font_size, font_weight, name, ...rest }) => { + const changes: Record = { ...rest } + if (corner_radius !== undefined) changes.cornerRadius = corner_radius + if (font_size !== undefined) changes.fontSize = font_size + if (font_weight !== undefined) changes.fontWeight = font_weight + store.updateNodeWithUndo(id, changes, 'Update node') + if (name !== undefined) store.renameNode(id, name) + return { id, updated: Object.keys(changes) } + } + }), + + set_layout: tool({ + description: + 'Set auto-layout (flexbox) on a frame. Direction, alignment, spacing, and padding.', + inputSchema: valibotSchema( + v.object({ + id: v.pipe(v.string(), v.description('Frame node ID')), + direction: v.picklist(['HORIZONTAL', 'VERTICAL']), + spacing: v.optional( + v.pipe(v.number(), v.minValue(0), v.description('Gap between items')), + 0 + ), + padding: v.optional( + v.pipe(v.number(), v.minValue(0), v.description('Equal padding on all sides')) + ), + padding_horizontal: v.optional(v.pipe(v.number(), v.minValue(0))), + padding_vertical: v.optional(v.pipe(v.number(), v.minValue(0))), + align: v.optional( + v.pipe( + v.picklist(['MIN', 'CENTER', 'MAX', 'SPACE_BETWEEN']), + v.description('Primary axis alignment') + ), + 'MIN' + ), + counter_align: v.optional( + v.pipe( + v.picklist(['MIN', 'CENTER', 'MAX', 'STRETCH']), + v.description('Cross axis alignment') + ), + 'MIN' + ) + }) + ), + execute: async ({ + id, + direction, + spacing, + padding, + padding_horizontal, + padding_vertical, + align, + counter_align + }) => { + store.setLayoutMode(id, direction) + const ph = padding_horizontal ?? padding ?? 0 + const pv = padding_vertical ?? padding ?? 0 + store.updateNodeWithUndo( + id, + { + itemSpacing: spacing, + primaryAxisAlign: align, + counterAxisAlign: counter_align, + paddingLeft: ph, + paddingRight: ph, + paddingTop: pv, + paddingBottom: pv + }, + 'Set layout' + ) + return { id, direction, spacing } + } + }), + + delete_node: tool({ + description: 'Delete a node by ID.', + inputSchema: valibotSchema( + v.object({ + id: v.pipe(v.string(), v.description('Node ID to delete')) + }) + ), + execute: async ({ id }) => { + store.select([id]) + store.deleteSelected() + return { deleted: id } + } + }), + + select_nodes: tool({ + description: 'Select one or more nodes by ID.', + inputSchema: valibotSchema( + v.object({ + ids: v.pipe(v.array(v.string()), v.minLength(1), v.description('Node IDs to select')) + }) + ), + execute: async ({ ids }) => { + store.select(ids) + return { selected: ids } + } + }), + + get_page_tree: tool({ + description: + 'Get the node tree of the current page. Returns all nodes with their hierarchy, types, positions, and sizes.', + inputSchema: valibotSchema(v.object({})), + execute: async () => { + const graph = store.graph + const pageId = store.state.currentPageId + const page = graph.getNode(pageId) + if (!page) return { error: 'No current page' } + + function nodeToJSON(id: string): object | null { + const node = graph.getNode(id) + if (!node) return null + const children = graph + .getChildren(id) + .map((c) => nodeToJSON(c.id)) + .filter(Boolean) + return { + id: node.id, + type: node.type, + name: node.name, + x: node.x, + y: node.y, + width: node.width, + height: node.height, + ...(children.length > 0 ? { children } : {}), + ...(node.fills.length > 0 ? { fills: node.fills } : {}), + ...(node.text ? { text: node.text } : {}) + } + } + + return { + page: page.name, + children: graph + .getChildren(pageId) + .map((c) => nodeToJSON(c.id)) + .filter(Boolean) + } + } + }), + + get_selection: tool({ + description: 'Get details about currently selected nodes.', + inputSchema: valibotSchema(v.object({})), + execute: async () => { + const nodes = store.selectedNodes.value + if (nodes.length === 0) return { selection: [] } + return { + selection: nodes.map((n) => ({ + id: n.id, + type: n.type, + name: n.name, + x: n.x, + y: n.y, + width: n.width, + height: n.height, + fills: n.fills, + text: n.text || undefined + })) + } + } + }), + + rename_node: tool({ + description: 'Rename a node in the layers panel.', + inputSchema: valibotSchema( + v.object({ + id: v.string(), + name: v.string() + }) + ), + execute: async ({ id, name }) => { + store.renameNode(id, name) + return { id, name } + } + }) + } +} + +export type AITools = ReturnType diff --git a/src/components/PropertiesPanel.vue b/src/components/PropertiesPanel.vue index 0a7ad2506..dafdefd27 100644 --- a/src/components/PropertiesPanel.vue +++ b/src/components/PropertiesPanel.vue @@ -8,7 +8,7 @@ import ChatPanel from './ChatPanel.vue' import DesignPanel from './DesignPanel.vue' const store = useEditorStore() -const { activeTab } = useAIChat() +const { activeTab } = useAIChat(store)