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:
parent
4032108be0
commit
f06631489f
8
bun.lock
8
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=="],
|
||||
|
|
|
|||
|
|
@ -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
301
src/ai/tools.ts
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 0–1.
|
||||
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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue