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.
This commit is contained in:
Danila Poyarkov 2026-03-01 11:45:27 +03:00
parent 4032108be0
commit f06631489f
5 changed files with 332 additions and 3 deletions

View file

@ -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=="],

View file

@ -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"

301
src/ai/tools.ts Normal file
View file

@ -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<string, unknown> = { ...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<typeof createAITools>

View file

@ -8,7 +8,7 @@ import ChatPanel from './ChatPanel.vue'
import DesignPanel from './DesignPanel.vue'
const store = useEditorStore()
const { activeTab } = useAIChat()
const { activeTab } = useAIChat(store)
</script>
<template>

View file

@ -5,6 +5,9 @@ import { DirectChatTransport, ToolLoopAgent } from 'ai'
import dedent from 'dedent'
import { computed, ref, watch } from 'vue'
import { createAITools } from '@/ai/tools'
import type { EditorStore } from '@/stores/editor'
import type { UIMessage } from 'ai'
export { AI_MODELS as MODELS } from '@open-pencil/core'
@ -17,12 +20,21 @@ const SYSTEM_PROMPT = dedent`
You are a design assistant inside OpenPencil, a Figma-like design editor.
Help users create and modify designs. Be concise and direct.
When describing changes, use specific design terminology.
Available node types: FRAME (containers/cards), RECTANGLE, ELLIPSE, TEXT, LINE, STAR, POLYGON, SECTION.
Colors can be hex strings (#ff0000) or RGBA objects with values 01.
Coordinates use canvas space (0, 0) is the top-left of the page.
Always use tools to make changes. After creating nodes, briefly describe what you did.
When the user asks to create a layout, use create_shape with FRAME, then set_layout for auto-layout.
`
const apiKey = ref(localStorage.getItem(API_KEY_STORAGE) ?? '')
const modelId = ref(localStorage.getItem(MODEL_STORAGE) ?? DEFAULT_AI_MODEL)
const activeTab = ref<'design' | 'ai'>('design')
let editorStore: EditorStore | null = null
watch(apiKey, (key) => {
if (key) {
localStorage.setItem(API_KEY_STORAGE, key)
@ -53,9 +65,12 @@ function createTransport() {
}
})
const tools = editorStore ? createAITools(editorStore) : {}
const agent = new ToolLoopAgent({
model: openrouter(modelId.value),
instructions: SYSTEM_PROMPT
instructions: SYSTEM_PROMPT,
tools
})
return new DirectChatTransport({ agent })
@ -81,7 +96,10 @@ if (typeof window !== 'undefined') {
}
}
export function useAIChat() {
export function useAIChat(store?: EditorStore) {
if (store) {
editorStore = store
}
return {
apiKey,
modelId,