Merge chat-panel: AI chat with OpenRouter, tool execution, model selector

This commit is contained in:
Danila Poyarkov 2026-03-01 11:55:57 +03:00
commit bf8636c677
16 changed files with 1421 additions and 82 deletions

197
bun.lock
View file

@ -5,8 +5,12 @@
"": {
"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:*",
"@openrouter/ai-sdk-provider": "^2.2.3",
"@tailwindcss/vite": "^4.2.1",
"@tanstack/vue-table": "^8.21.3",
"@tauri-apps/api": "^2",
@ -14,13 +18,17 @@
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-opener": "^2",
"@vueuse/core": "^14.2.1",
"ai": "^6.0.105",
"canvaskit-wasm": "^0.40.0",
"culori": "^4.0.2",
"dedent": "^1.7.1",
"fflate": "^0.8.2",
"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",
},
"devDependencies": {
@ -35,6 +43,7 @@
"jscpd": "^4.0.8",
"oxfmt": "^0.35.0",
"oxlint": "^1.50.0",
"tw-animate-css": "^1.4.0",
"typescript": "~5.8.3",
"unplugin-icons": "^23.0.1",
"unplugin-vue-components": "^31.0.0",
@ -74,6 +83,18 @@
},
},
"packages": {
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.59", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.16", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MbtheWHgEFV/8HL1Z6E3hOAsmP73zZlNFg0F0nJAD0Adnjp4J/plqNK00Y896d+dWTw+r0OXzyov9/2wCFjH0Q=="],
"@ai-sdk/openai": ["@ai-sdk/openai@3.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.16" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bcYjT3/58i/C0DN3AnrjiGsAb0kYivZLWWUtgTjsBurHSht/LTEy+w3dw5XQe3FmZwX7Z/mUQCiA3wB/5Kf7ow=="],
"@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
"@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=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
@ -186,6 +207,10 @@
"@open-pencil/core": ["@open-pencil/core@workspace:packages/core"],
"@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.2.3", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-NovC+BaCfEeJwhToDrs8JeDYXXlJdEyz7lcxkjtyePSE4eoAKik872SyDK0MzXKcz8MRkv7XlNhPI6zz4TQp0g=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.35.0", "", { "os": "android", "cpu": "arm" }, "sha512-BaRKlM3DyG81y/xWTsE6gZiv89F/3pHe2BqX2H4JbiB8HNVlWWtplzgATAE5IDSdwChdeuWLDTQzJ92Lglw3ZA=="],
"@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.35.0", "", { "os": "android", "cpu": "arm64" }, "sha512-/O+EbuAJYs6nde/anv+aID6uHsGQApyE9JtYBo/79KyU8e6RBN3DMbT0ix97y1SOnCglurmL2iZ+hlohjP2PnQ=="],
@ -316,6 +341,8 @@
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@swc/helpers": ["@swc/helpers@0.5.19", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA=="],
"@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="],
@ -392,12 +419,24 @@
"@types/culori": ["@types/culori@4.0.1", "", {}, "sha512-43M51r/22CjhbOXyGT361GZ9vncSVQ39u62x5eJdBQFviI8zWp2X5jzqg7k4M6PVgDQAClpy2bUe2dtwEgEDVQ=="],
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
"@types/katex": ["@types/katex@0.16.8", "", {}, "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg=="],
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/node": ["@types/node@25.3.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q=="],
"@types/sarif": ["@types/sarif@2.1.7", "", {}, "sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="],
"@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260228.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260228.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260228.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260228.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260228.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260228.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260228.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260228.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-kEUE54Shr4VfD1aVlxBkT9EVRkdSklINPxcUdFDwWQYh+uNRfgM/KmGUapbhCnoaat8ALRv30X6r6Eu3WiEhGg=="],
@ -416,6 +455,10 @@
"@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=="],
"@vue/compiler-core": ["@vue/compiler-core@3.5.29", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/shared": "3.5.29", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw=="],
@ -448,6 +491,8 @@
"agentfmt": ["agentfmt@0.1.3", "", { "dependencies": { "picocolors": "^1.1.1" } }, "sha512-BVSZr8ovigxjYewuBx42Ep4RJj00HURaQYUQ+id41sgb7r0PMUSEWsnD/+h96xj6t6v2WVyPuGPH0K6RtjftKQ=="],
"ai": ["ai@6.0.105", "", { "dependencies": { "@ai-sdk/gateway": "3.0.59", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.16", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rp+exWtZS3J0DDvZIfetpKCIg7D3cCsvBPoFN3I67IDTs9aoBZDbpecoIkmNLT+U9RBkoEial3OGHRvme23HCw=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
@ -474,6 +519,10 @@
"canvaskit-wasm": ["canvaskit-wasm@0.40.0", "", { "dependencies": { "@webgpu/types": "0.1.21" } }, "sha512-Od2o+ZmoEw9PBdN/yCGvzfu0WVqlufBPEWNG452wY7E9aT8RBE+ChpZF526doOlg7zumO4iCS+RAeht4P0Gbpw=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
"character-parser": ["character-parser@2.2.0", "", { "dependencies": { "is-regex": "^1.0.3" } }, "sha512-+UqJQjFEFaTAs3bNsF2j2kEN1baG/zghZbdqoYEDxGZtJo9LBzl1A+m0D4n3qKx8N2FNv8/Xp6yV9mQmBuptaw=="],
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
@ -498,10 +547,20 @@
"culori": ["culori@4.0.2", "", {}, "sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
"dedent": ["dedent@1.7.1", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg=="],
"defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
"doctypes": ["doctypes@1.1.0", "", {}, "sha512-LLBi6pEqS6Do3EKQ3J0NqHWV5hhb78Pi8vvESYwyOy2c31ZEZVdtitdzsQsKb7878PEERhzUk0ftqGhG6Mz+pQ=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
@ -522,10 +581,14 @@
"esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
"escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
"eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
"execa": ["execa@4.1.0", "", { "dependencies": { "cross-spawn": "^7.0.0", "get-stream": "^5.0.0", "human-signals": "^1.1.1", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.0", "onetime": "^5.1.0", "signal-exit": "^3.0.2", "strip-final-newline": "^2.0.0" } }, "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA=="],
"exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="],
@ -534,12 +597,16 @@
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
"fault": ["fault@2.0.1", "", { "dependencies": { "format": "^0.2.0" } }, "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"format": ["format@0.2.2", "", {}, "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww=="],
"fs-extra": ["fs-extra@11.3.3", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
@ -548,6 +615,8 @@
"fzstd": ["fzstd@0.1.1", "", {}, "sha512-dkuVSOKKwh3eas5VkJy1AW1vFpet8TA/fGmVA5krThl8YcOVE/8ZIoEA1+U1vEn5ckxxhLirSdY837azmbaNHA=="],
"get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
@ -598,10 +667,14 @@
"jscpd-sarif-reporter": ["jscpd-sarif-reporter@4.0.6", "", { "dependencies": { "colors": "^1.4.0", "fs-extra": "^11.2.0", "node-sarif-builder": "^3.4.0" } }, "sha512-b9Sm3IPZ3+m8Lwa4gZa+4/LhDhlc/ZLEsLXKSOy1DANQ6kx0ueqZT+fUHWEdQ6m0o3+RIVIa7DmvLSojQD05ng=="],
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
"jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="],
"jstransformer": ["jstransformer@1.0.0", "", { "dependencies": { "is-promise": "^2.0.0", "promise": "^7.0.1" } }, "sha512-C9YK3Rf8q6VAPDCCU9fnqo3mAfOH6vUGnMcP4AQAYIEpWtfGLpwOTmZ+igtdK5y+VvI2n3CyYSzy4Qh34eq24A=="],
"katex": ["katex@0.16.33", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-q3N5u+1sY9Bu7T4nlXoiRBXWfwSefNGoKeOwekV+gw0cAXQlz2Ww6BLcmBxVDeXBMUDQv6fK5bcNaJLxob3ZQA=="],
"lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="],
@ -628,22 +701,120 @@
"local-pkg": ["local-pkg@1.1.2", "", { "dependencies": { "mlly": "^1.7.4", "pkg-types": "^2.3.0", "quansync": "^0.2.11" } }, "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A=="],
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"markdown-table": ["markdown-table@2.0.0", "", { "dependencies": { "repeat-string": "^1.0.0" } }, "sha512-Ezda85ToJUBhM6WGaG6veasyym+Tbs3cMAw/ZhOPqXiYsr0jgocBV3j3nx+4lk47plLlIqjwuTm/ywVI+zjJ/A=="],
"marked": ["marked@17.0.3", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-jt1v2ObpyOKR8p4XaUJVk3YWRJ5n+i4+rjQopxvV32rSndTJXvIzuUdWWIy/1pFQMkQmvTXawzDNqOH/CUmx6A=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="],
"mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="],
"mdast-util-frontmatter": ["mdast-util-frontmatter@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "escape-string-regexp": "^5.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-extension-frontmatter": "^2.0.0" } }, "sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA=="],
"mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="],
"mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="],
"mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="],
"mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="],
"mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="],
"mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="],
"mdast-util-math": ["mdast-util-math@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "longest-streak": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.1.0", "unist-util-remove-position": "^5.0.0" } }, "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w=="],
"mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
"mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
"micromark-extension-cjk-friendly": ["micromark-extension-cjk-friendly@2.0.1", "", { "dependencies": { "devlop": "^1.1.0", "micromark-extension-cjk-friendly-util": "3.0.1", "micromark-util-chunked": "^2.0.1", "micromark-util-resolve-all": "^2.0.1", "micromark-util-symbol": "^2.0.1" }, "peerDependencies": { "micromark": "^4.0.0", "micromark-util-types": "^2.0.0" }, "optionalPeers": ["micromark-util-types"] }, "sha512-OkzoYVTL1ChbvQ8Cc1ayTIz7paFQz8iS9oIYmewncweUSwmWR+hkJF9spJ1lxB90XldJl26A1F4IkPOKS3bDXw=="],
"micromark-extension-cjk-friendly-gfm-strikethrough": ["micromark-extension-cjk-friendly-gfm-strikethrough@2.0.1", "", { "dependencies": { "devlop": "^1.1.0", "get-east-asian-width": "^1.4.0", "micromark-extension-cjk-friendly-util": "3.0.1", "micromark-util-character": "^2.1.1", "micromark-util-chunked": "^2.0.1", "micromark-util-resolve-all": "^2.0.1", "micromark-util-symbol": "^2.0.1" }, "peerDependencies": { "micromark": "^4.0.0", "micromark-util-types": "^2.0.0" }, "optionalPeers": ["micromark-util-types"] }, "sha512-wVC0zwjJNqQeX+bb07YTPu/CvSAyCTafyYb7sMhX1r62/Lw5M/df3JyYaANyp8g15c1ypJRFSsookTqA1IDsUg=="],
"micromark-extension-cjk-friendly-util": ["micromark-extension-cjk-friendly-util@3.0.1", "", { "dependencies": { "get-east-asian-width": "^1.4.0", "micromark-util-character": "^2.1.1", "micromark-util-symbol": "^2.0.1" } }, "sha512-GcbXqTTHOsiZHyF753oIddP/J2eH8j9zpyQPhkof6B2JNxfEJabnQqxbCgzJNuNes0Y2jTNJ3LiYPSXr6eJA8w=="],
"micromark-extension-frontmatter": ["micromark-extension-frontmatter@2.0.0", "", { "dependencies": { "fault": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg=="],
"micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="],
"micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="],
"micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="],
"micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="],
"micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="],
"micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="],
"micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="],
"micromark-extension-math": ["micromark-extension-math@3.1.0", "", { "dependencies": { "@types/katex": "^0.16.0", "devlop": "^1.0.0", "katex": "^0.16.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg=="],
"micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
"micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
"micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="],
"micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
"micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
"micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
"micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
"micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
"micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
"micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="],
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
"micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
"micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
"micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
"micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
"mlly": ["mlly@1.8.0", "", { "dependencies": { "acorn": "^8.15.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.1" } }, "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"node-sarif-builder": ["node-sarif-builder@3.4.0", "", { "dependencies": { "@types/sarif": "^2.1.7", "fs-extra": "^11.1.1" } }, "sha512-tGnJW6OKRii9u/b2WiUViTJS+h7Apxx17qsMUjsUeNDiMMX5ZFf8F8Fcz7PAQ6omvOxHZtvDTmOYKJQwmfpjeg=="],
@ -750,6 +921,8 @@
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"swrv": ["swrv@1.1.0", "", { "peerDependencies": { "vue": ">=3.2.26 < 4" } }, "sha512-pjllRDr2s0iTwiE5Isvip51dZGR7GjLH1gCSVyE8bQnbAx6xackXsFdojau+1O5u98yHF5V73HQGOFxKUXO9gQ=="],
"tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="],
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
@ -766,12 +939,24 @@
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
"unist-util-remove-position": ["unist-util-remove-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q=="],
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
"unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
"universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="],
"unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="],
@ -782,6 +967,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=="],
"void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="],
@ -790,6 +977,8 @@
"vue-demi": ["vue-demi@0.14.10", "", { "peerDependencies": { "@vue/composition-api": "^1.0.0-rc.1", "vue": "^3.0.0-0 || ^2.6.0" }, "optionalPeers": ["@vue/composition-api"], "bin": { "vue-demi-fix": "bin/vue-demi-fix.js", "vue-demi-switch": "bin/vue-demi-switch.js" } }, "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg=="],
"vue-stream-markdown": ["vue-stream-markdown@0.6.0", "", { "dependencies": { "@floating-ui/dom": "^1.7.5", "@vueuse/core": "^14.2.1", "marked": "^17.0.3", "mdast-util-from-markdown": "^2.0.3", "mdast-util-frontmatter": "^2.0.1", "mdast-util-gfm": "^3.1.0", "mdast-util-math": "^3.0.0", "mdast-util-to-markdown": "^2.1.2", "micromark-extension-cjk-friendly": "^2.0.1", "micromark-extension-cjk-friendly-gfm-strikethrough": "^2.0.1", "micromark-extension-frontmatter": "^2.0.0", "micromark-extension-gfm": "^3.0.0", "micromark-extension-math": "^3.1.0", "micromark-util-types": "^2.0.2" }, "peerDependencies": { "beautiful-mermaid": ">=0.1.0", "katex": ">=0.16.20", "mermaid": ">=11", "shiki": ">=3.15.0", "vue": ">=3.0.0" }, "optionalPeers": ["beautiful-mermaid", "katex", "mermaid", "shiki"] }, "sha512-RLuI5zt5czh6dkTA0/XkkMv+ApVDQXSEGUs5TgEPc0bEhhcalXItu/IxZZU0/Kf0xJ3zSMawF7PRsxoMLc88Yg=="],
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
@ -800,6 +989,10 @@
"yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
@ -814,6 +1007,10 @@
"is-expression/acorn": ["acorn@7.4.1", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A=="],
"katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
"mdast-util-gfm-table/markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],

14
components.d.ts vendored
View file

@ -11,10 +11,16 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ApiKeySetup: typeof import('./src/components/chat/ApiKeySetup.vue')['default']
APIKeySetup: typeof import('./src/components/chat/APIKeySetup.vue')['default']
AppearanceSection: typeof import('./src/components/properties/AppearanceSection.vue')['default']
CanvasContextMenu: typeof import('./src/components/CanvasContextMenu.vue')['default']
ChatInput: typeof import('./src/components/chat/ChatInput.vue')['default']
ChatMessage: typeof import('./src/components/chat/ChatMessage.vue')['default']
ChatPanel: typeof import('./src/components/ChatPanel.vue')['default']
ColorInput: typeof import('./src/components/ColorInput.vue')['default']
ColorPicker: typeof import('./src/components/ColorPicker.vue')['default']
DesignPanel: typeof import('./src/components/DesignPanel.vue')['default']
EditorCanvas: typeof import('./src/components/EditorCanvas.vue')['default']
EffectsSection: typeof import('./src/components/properties/EffectsSection.vue')['default']
ExportSection: typeof import('./src/components/properties/ExportSection.vue')['default']
@ -34,6 +40,7 @@ declare module 'vue' {
IconLucideBaseline: typeof import('~icons/lucide/baseline')['default']
IconLucideBlend: typeof import('~icons/lucide/blend')['default']
IconLucideBold: typeof import('~icons/lucide/bold')['default']
IconLucideBot: typeof import('~icons/lucide/bot')['default']
IconLucideCheck: typeof import('~icons/lucide/check')['default']
IconLucideChevronDown: typeof import('~icons/lucide/chevron-down')['default']
IconLucideChevronRight: typeof import('~icons/lucide/chevron-right')['default']
@ -47,15 +54,22 @@ declare module 'vue' {
IconLucideHash: typeof import('~icons/lucide/hash')['default']
IconLucideImage: typeof import('~icons/lucide/image')['default']
IconLucideItalic: typeof import('~icons/lucide/italic')['default']
IconLucideKeyRound: typeof import('~icons/lucide/key-round')['default']
IconLucideLink: typeof import('~icons/lucide/link')['default']
IconLucideLoaderCircle: typeof import('~icons/lucide/loader-circle')['default']
IconLucideMessageCircle: typeof import('~icons/lucide/message-circle')['default']
IconLucidePlus: typeof import('~icons/lucide/plus')['default']
IconLucideRadius: typeof import('~icons/lucide/radius')['default']
IconLucideRotateCcw: typeof import('~icons/lucide/rotate-ccw')['default']
IconLucideRotateCw: typeof import('~icons/lucide/rotate-cw')['default']
IconLucideSearch: typeof import('~icons/lucide/search')['default']
IconLucideSend: typeof import('~icons/lucide/send')['default']
IconLucideSettings2: typeof import('~icons/lucide/settings2')['default']
IconLucideSparkles: typeof import('~icons/lucide/sparkles')['default']
IconLucideSquare: typeof import('~icons/lucide/square')['default']
IconLucideStrikethrough: typeof import('~icons/lucide/strikethrough')['default']
IconLucideToggleLeft: typeof import('~icons/lucide/toggle-left')['default']
IconLucideTriangleAlert: typeof import('~icons/lucide/triangle-alert')['default']
IconLucideType: typeof import('~icons/lucide/type')['default']
IconLucideUnderline: typeof import('~icons/lucide/underline')['default']
IconLucideUnlink: typeof import('~icons/lucide/unlink')['default']

View file

@ -23,8 +23,12 @@
"open-pencil": "bun packages/cli/src/index.ts"
},
"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:*",
"@openrouter/ai-sdk-provider": "^2.2.3",
"@tailwindcss/vite": "^4.2.1",
"@tanstack/vue-table": "^8.21.3",
"@tauri-apps/api": "^2",
@ -32,13 +36,17 @@
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-opener": "^2",
"@vueuse/core": "^14.2.1",
"ai": "^6.0.105",
"canvaskit-wasm": "^0.40.0",
"culori": "^4.0.2",
"dedent": "^1.7.1",
"fflate": "^0.8.2",
"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"
},
"devDependencies": {
@ -53,6 +61,7 @@
"jscpd": "^4.0.8",
"oxfmt": "^0.35.0",
"oxlint": "^1.50.0",
"tw-animate-css": "^1.4.0",
"typescript": "~5.8.3",
"unplugin-icons": "^23.0.1",
"unplugin-vue-components": "^31.0.0",

View file

@ -72,3 +72,33 @@ export const RULER_MAJOR_TOLERANCE = 0.01
export const TEXT_SELECTION_COLOR = { r: 0.26, g: 0.52, b: 0.96, a: 0.3 }
export const TEXT_CARET_COLOR = { r: 0, g: 0, b: 0, a: 1 }
export const TEXT_CARET_WIDTH = 1
export interface ModelOption {
id: string
name: string
provider: string
tag?: string
}
export const AI_MODELS: ModelOption[] = [
// Best for design: vision + frontend + tool calling (WebDev Arena #1, DesignBench, SWE-bench 79.6%)
{ id: 'anthropic/claude-sonnet-4.6', name: 'Claude Sonnet 4.6', provider: 'Anthropic', tag: 'Best for design' },
{ id: 'anthropic/claude-opus-4.6', name: 'Claude Opus 4.6', provider: 'Anthropic', tag: 'Smartest' },
// 76.8% SWE-bench, vision + UI-to-code specialist
{ id: 'moonshotai/kimi-k2.5', name: 'Kimi K2.5', provider: 'Moonshot', tag: 'Vision + code' },
// 1M context, multimodal (text+image+audio+video), 78% SWE-bench
{ id: 'google/gemini-3.1-pro-preview', name: 'Gemini 3.1 Pro', provider: 'Google', tag: '1M context' },
// 80% SWE-bench, 400K context, agentic coding
{ id: 'openai/gpt-5.3-codex', name: 'GPT-5.3 Codex', provider: 'OpenAI' },
// Fast & cheap
{ id: 'google/gemini-3-flash-preview', name: 'Gemini 3 Flash', provider: 'Google', tag: 'Fast' },
{ id: 'deepseek/deepseek-v3.2', name: 'DeepSeek V3.2', provider: 'DeepSeek', tag: 'Cheap' },
{ id: 'qwen/qwen3.5-flash-02-23', name: 'Qwen 3.5 Flash', provider: 'Qwen', tag: 'Cheap' },
// Free (with tool calling)
{ id: 'qwen/qwen3-coder:free', name: 'Qwen3 Coder', provider: 'Qwen', tag: 'Free' },
{ id: 'openai/gpt-oss-120b:free', name: 'GPT-OSS 120B', provider: 'OpenAI', tag: 'Free' },
]
export const DEFAULT_AI_MODEL = AI_MODELS[0].id

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

@ -0,0 +1,310 @@
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 rgbaSchema = 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 hexSchema = v.pipe(
v.string(),
v.regex(/^#?[0-9a-fA-F]{6,8}$/),
v.description('Hex color like #ff0000 or #ff000080')
)
const colorInputSchema = v.union([rgbaSchema, hexSchema])
type ColorInput = v.InferOutput<typeof colorInputSchema>
function resolveColor(input: ColorInput) {
if (typeof input === 'string') {
const h = input.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
}
}
return { ...input, a: input.a ?? 1 }
}
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: colorInputSchema
})
),
execute: async ({ id, color }) => {
const c = resolveColor(color)
store.updateNodeWithUndo(
id,
{ fills: [{ type: 'SOLID', color: c, opacity: 1, visible: true }] },
'Set fill'
)
return { id, color: c }
}
}),
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: colorInputSchema,
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 }) => {
const c = resolveColor(color)
store.updateNodeWithUndo(
id,
{ strokes: [{ color: c, weight, opacity: 1, visible: true, align }] },
'Set stroke'
)
return { id, color: c, 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

@ -1,4 +1,5 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@theme {
--color-panel: #2a2a2a;

View file

@ -0,0 +1,96 @@
<script setup lang="ts">
import { ScrollAreaRoot, ScrollAreaScrollbar, ScrollAreaThumb, ScrollAreaViewport } from 'reka-ui'
import { computed, markRaw, nextTick, ref, watch } from 'vue'
import APIKeySetup from '@/components/chat/APIKeySetup.vue'
import ChatInput from '@/components/chat/ChatInput.vue'
import ChatMessage from '@/components/chat/ChatMessage.vue'
import { useAIChat } from '@/composables/use-chat'
import type { Chat } from '@ai-sdk/vue'
import type { UIMessage } from 'ai'
const { isConfigured, ensureChat } = useAIChat()
const chat = ref<Chat<UIMessage> | null>(null)
const messagesEnd = ref<HTMLDivElement>()
const messages = computed(() => chat.value?.messages ?? [])
const status = computed(() => chat.value?.status ?? 'ready')
function scrollToBottom() {
nextTick(() => {
messagesEnd.value?.scrollIntoView({ behavior: 'smooth', block: 'end' })
})
}
watch(messages, scrollToBottom, { deep: true })
function handleSubmit(text: string) {
if (!chat.value) {
const c = ensureChat()
if (c) chat.value = markRaw(c)
}
chat.value?.sendMessage({ text }).catch(() => {})
}
function handleStop() {
chat.value?.stop()
}
</script>
<template>
<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
<APIKeySetup v-if="!isConfigured" />
<template v-else>
<ScrollAreaRoot class="min-h-0 flex-1">
<ScrollAreaViewport class="h-full px-3 py-3 [&>div]:h-full">
<!-- Empty state -->
<div
v-if="messages.length === 0"
class="flex h-full flex-col items-center justify-center gap-3 text-muted"
>
<icon-lucide-message-circle class="size-8 opacity-50" />
<p class="text-xs">Describe what you want to create or change.</p>
</div>
<!-- Messages -->
<div v-else class="flex flex-col gap-3">
<ChatMessage v-for="msg in messages" :key="msg.id" :message="msg" />
<!-- Typing indicator -->
<div v-if="status === 'submitted'" class="flex gap-2">
<div
class="flex size-6 shrink-0 items-center justify-center rounded-full bg-muted/20 text-[10px] font-bold text-muted"
>
AI
</div>
<div class="flex items-center gap-1 py-2">
<span
class="size-1.5 animate-bounce rounded-full bg-muted"
style="animation-delay: 0ms"
/>
<span
class="size-1.5 animate-bounce rounded-full bg-muted"
style="animation-delay: 150ms"
/>
<span
class="size-1.5 animate-bounce rounded-full bg-muted"
style="animation-delay: 300ms"
/>
</div>
</div>
<div ref="messagesEnd" />
</div>
</ScrollAreaViewport>
<ScrollAreaScrollbar orientation="vertical" class="flex w-1.5 touch-none select-none p-px">
<ScrollAreaThumb class="relative flex-1 rounded-full bg-muted/30" />
</ScrollAreaScrollbar>
</ScrollAreaRoot>
<ChatInput :status="status" @submit="handleSubmit" @stop="handleStop" />
</template>
</div>
</template>

View file

@ -0,0 +1,85 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useEditorStore } from '@/stores/editor'
import VariablesDialog from './VariablesDialog.vue'
import AppearanceSection from './properties/AppearanceSection.vue'
import EffectsSection from './properties/EffectsSection.vue'
import ExportSection from './properties/ExportSection.vue'
import FillSection from './properties/FillSection.vue'
import LayoutSection from './properties/LayoutSection.vue'
import PageSection from './properties/PageSection.vue'
import PositionSection from './properties/PositionSection.vue'
import StrokeSection from './properties/StrokeSection.vue'
import TypographySection from './properties/TypographySection.vue'
import VariablesSection from './properties/VariablesSection.vue'
const store = useEditorStore()
const variablesOpen = ref(false)
const node = computed(() => store.selectedNode.value)
const multiCount = computed(() => store.selectedNodes.value.length)
const isComponentType = computed(() => {
const t = node.value?.type
return t === 'COMPONENT' || t === 'COMPONENT_SET' || t === 'INSTANCE'
})
</script>
<template>
<!-- Multi-select summary -->
<div v-if="multiCount > 1" class="flex-1 overflow-y-auto pb-4">
<div class="flex items-center gap-1.5 border-b border-border px-3 py-2">
<span class="text-[11px] text-muted">Mixed</span>
<span class="text-xs font-semibold">{{ multiCount }} layers</span>
</div>
<AppearanceSection />
</div>
<!-- Single selection -->
<div v-else-if="node" class="flex-1 overflow-y-auto pb-4">
<!-- Node header -->
<div class="flex items-center gap-1.5 border-b border-border px-3 py-2">
<span class="text-[11px]" :class="isComponentType ? 'text-[#9747ff]' : 'text-muted'">{{
node.type
}}</span>
<span class="text-xs font-semibold">{{ node.name }}</span>
</div>
<!-- Component actions -->
<div
v-if="node.type === 'INSTANCE'"
class="flex flex-col gap-1 border-b border-border px-3 py-2"
>
<button
class="rounded bg-[#9747ff]/10 px-2 py-1 text-left text-[11px] text-[#9747ff] hover:bg-[#9747ff]/20"
@click="store.goToMainComponent()"
>
Go to Main Component
</button>
<button
class="rounded px-2 py-1 text-left text-[11px] text-muted hover:bg-hover"
@click="store.detachInstance()"
>
Detach Instance
</button>
</div>
<PositionSection />
<LayoutSection />
<AppearanceSection />
<TypographySection v-if="node.type === 'TEXT'" />
<FillSection />
<StrokeSection />
<EffectsSection />
<ExportSection />
</div>
<div v-else class="flex-1 overflow-y-auto pb-4">
<PageSection />
<VariablesSection @open-dialog="variablesOpen = true" />
</div>
<VariablesDialog v-model:open="variablesOpen" />
</template>

View file

@ -1,29 +1,14 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
import { useAIChat } from '@/composables/use-chat'
import { useEditorStore } from '@/stores/editor'
import VariablesDialog from './VariablesDialog.vue'
import AppearanceSection from './properties/AppearanceSection.vue'
import EffectsSection from './properties/EffectsSection.vue'
import ExportSection from './properties/ExportSection.vue'
import FillSection from './properties/FillSection.vue'
import LayoutSection from './properties/LayoutSection.vue'
import PageSection from './properties/PageSection.vue'
import PositionSection from './properties/PositionSection.vue'
import StrokeSection from './properties/StrokeSection.vue'
import TypographySection from './properties/TypographySection.vue'
import VariablesSection from './properties/VariablesSection.vue'
import ChatPanel from './ChatPanel.vue'
import DesignPanel from './DesignPanel.vue'
const store = useEditorStore()
const variablesOpen = ref(false)
const node = computed(() => store.selectedNode.value)
const multiCount = computed(() => store.selectedNodes.value.length)
const isComponentType = computed(() => {
const t = node.value?.type
return t === 'COMPONENT' || t === 'COMPONENT_SET' || t === 'INSTANCE'
})
const { activeTab } = useAIChat(store)
</script>
<template>
@ -31,71 +16,46 @@ const isComponentType = computed(() => {
class="flex min-w-0 flex-1 flex-col overflow-hidden border-l border-border bg-panel"
style="contain: paint layout style"
>
<!-- Tabs -->
<div class="flex h-10 shrink-0 items-center gap-1 border-b border-border px-2">
<button class="rounded px-2.5 py-1 text-xs font-semibold text-surface">Design</button>
<button class="rounded px-2.5 py-1 text-xs text-muted">Prototype</button>
<span
class="ml-auto cursor-pointer rounded px-1.5 py-0.5 text-[11px] text-muted hover:bg-hover"
>
{{ Math.round(store.state.zoom * 100) }}%
</span>
</div>
<!-- Multi-select summary -->
<div v-if="multiCount > 1" class="flex-1 overflow-y-auto pb-4">
<div class="flex items-center gap-1.5 border-b border-border px-3 py-2">
<span class="text-[11px] text-muted">Mixed</span>
<span class="text-xs font-semibold">{{ multiCount }} layers</span>
</div>
<AppearanceSection />
</div>
<!-- Single selection -->
<div v-else-if="node" class="flex-1 overflow-y-auto pb-4">
<!-- Node header -->
<div class="flex items-center gap-1.5 border-b border-border px-3 py-2">
<span class="text-[11px]" :class="isComponentType ? 'text-[#9747ff]' : 'text-muted'">{{
node.type
}}</span>
<span class="text-xs font-semibold">{{ node.name }}</span>
</div>
<!-- Component actions -->
<div
v-if="node.type === 'INSTANCE'"
class="flex flex-col gap-1 border-b border-border px-3 py-2"
>
<button
class="rounded bg-[#9747ff]/10 px-2 py-1 text-left text-[11px] text-[#9747ff] hover:bg-[#9747ff]/20"
@click="store.goToMainComponent()"
<TabsRoot v-model="activeTab" class="flex min-h-0 flex-1 flex-col">
<TabsList class="flex h-10 shrink-0 items-center gap-1 border-b border-border px-2">
<TabsTrigger
value="design"
class="rounded px-2.5 py-1 text-xs text-muted hover:text-surface data-[state=active]:font-semibold data-[state=active]:text-surface"
>
Go to Main Component
</button>
<button
class="rounded px-2 py-1 text-left text-[11px] text-muted hover:bg-hover"
@click="store.detachInstance()"
Design
</TabsTrigger>
<TabsTrigger
value="ai"
class="flex items-center gap-1 rounded px-2.5 py-1 text-xs text-muted hover:text-surface data-[state=active]:font-semibold data-[state=active]:text-surface"
>
Detach Instance
</button>
</div>
<icon-lucide-sparkles class="size-3" />
AI
</TabsTrigger>
<span
v-if="activeTab === 'design'"
class="ml-auto cursor-pointer rounded px-1.5 py-0.5 text-[11px] text-muted hover:bg-hover"
>
{{ Math.round(store.state.zoom * 100) }}%
</span>
</TabsList>
<PositionSection />
<LayoutSection />
<AppearanceSection />
<TypographySection v-if="node.type === 'TEXT'" />
<FillSection />
<StrokeSection />
<EffectsSection />
<TabsContent
value="design"
class="flex min-h-0 flex-1 flex-col"
:force-mount="true"
:hidden="activeTab !== 'design'"
>
<DesignPanel />
</TabsContent>
<ExportSection />
</div>
<div v-else class="flex-1 overflow-y-auto pb-4">
<PageSection />
<VariablesSection @open-dialog="variablesOpen = true" />
</div>
<VariablesDialog v-model:open="variablesOpen" />
<TabsContent
value="ai"
class="flex min-h-0 flex-1 flex-col"
:force-mount="true"
:hidden="activeTab !== 'ai'"
>
<ChatPanel />
</TabsContent>
</TabsRoot>
</aside>
</template>

View file

@ -0,0 +1,43 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useAIChat } from '@/composables/use-chat'
const { apiKey } = useAIChat()
const input = ref('')
function save() {
apiKey.value = input.value.trim()
input.value = ''
}
</script>
<template>
<div class="flex flex-1 flex-col items-center justify-center gap-4 px-4">
<icon-lucide-key-round class="size-8 text-muted" />
<p class="text-center text-xs text-muted">Enter your OpenRouter API key to start chatting.</p>
<form class="flex w-full gap-1.5" @submit.prevent="save">
<input
v-model="input"
type="password"
placeholder="sk-or-…"
class="min-w-0 flex-1 rounded border border-border bg-input px-2 py-1 text-xs text-surface outline-none focus:border-accent"
/>
<button
type="submit"
class="shrink-0 rounded bg-accent px-2.5 py-1 text-xs font-medium text-white hover:bg-accent/90"
:disabled="!input.trim()"
>
Save
</button>
</form>
<a
href="https://openrouter.ai/keys"
target="_blank"
class="text-[10px] text-muted underline hover:text-surface"
>
Get an API key
</a>
</div>
</template>

View file

@ -0,0 +1,140 @@
<script setup lang="ts">
import {
SelectContent,
SelectItem,
SelectItemText,
SelectPortal,
SelectRoot,
SelectTrigger,
SelectViewport,
TooltipContent,
TooltipPortal,
TooltipProvider,
TooltipRoot,
TooltipTrigger
} from 'reka-ui'
import { computed, ref } from 'vue'
import { MODELS, useAIChat } from '@/composables/use-chat'
const { modelId } = useAIChat()
const props = defineProps<{
status: 'ready' | 'submitted' | 'streaming' | 'error'
}>()
const emit = defineEmits<{
submit: [text: string]
stop: []
}>()
const input = ref('')
const isStreaming = computed(() => props.status === 'streaming' || props.status === 'submitted')
const selectedModelName = computed(
() => MODELS.find((m) => m.id === modelId.value)?.name ?? modelId.value
)
function handleSubmit(e: Event) {
e.preventDefault()
const text = input.value.trim()
if (!text) return
emit('submit', text)
input.value = ''
}
</script>
<template>
<TooltipProvider>
<div class="shrink-0 border-t border-border px-3 py-2">
<!-- Model selector -->
<div class="mb-1.5 flex items-center">
<SelectRoot v-model="modelId">
<SelectTrigger
class="flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] text-muted hover:bg-hover hover:text-surface"
>
<icon-lucide-bot class="size-3" />
{{ selectedModelName }}
<icon-lucide-chevron-down class="size-2.5" />
</SelectTrigger>
<SelectPortal>
<SelectContent
position="popper"
side="top"
:side-offset="4"
class="z-50 max-h-60 overflow-y-auto rounded-lg border border-border bg-panel p-1 shadow-lg"
>
<SelectViewport>
<SelectItem
v-for="model in MODELS"
:key="model.id"
:value="model.id"
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-[11px] text-surface outline-none data-[highlighted]:bg-hover"
>
<SelectItemText class="flex-1">{{ model.name }}</SelectItemText>
<span
v-if="model.tag"
class="rounded bg-accent/10 px-1 py-px text-[9px] text-accent"
>
{{ model.tag }}
</span>
</SelectItem>
</SelectViewport>
</SelectContent>
</SelectPortal>
</SelectRoot>
</div>
<!-- Input form -->
<form class="flex gap-1.5" @submit="handleSubmit">
<input
v-model="input"
type="text"
placeholder="Describe a change…"
class="min-w-0 flex-1 rounded border border-border bg-input px-2.5 py-1.5 text-xs text-surface outline-none placeholder:text-muted focus:border-accent"
:disabled="status === 'submitted'"
/>
<TooltipRoot v-if="isStreaming">
<TooltipTrigger as-child>
<button
type="button"
class="shrink-0 rounded border border-border px-2 py-1.5 text-xs text-muted hover:bg-hover"
@click="emit('stop')"
>
<icon-lucide-square class="size-3" />
</button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent
side="top"
:side-offset="4"
class="rounded bg-surface px-2 py-1 text-[10px] text-canvas"
>
Stop generating
</TooltipContent>
</TooltipPortal>
</TooltipRoot>
<TooltipRoot v-else>
<TooltipTrigger as-child>
<button
type="submit"
class="shrink-0 rounded bg-accent px-2.5 py-1.5 text-xs font-medium text-white hover:bg-accent/90"
:disabled="!input.trim()"
>
<icon-lucide-send class="size-3" />
</button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent
side="top"
:side-offset="4"
class="rounded bg-surface px-2 py-1 text-[10px] text-canvas"
>
Send message
</TooltipContent>
</TooltipPortal>
</TooltipRoot>
</form>
</div>
</TooltipProvider>
</template>

View file

@ -0,0 +1,133 @@
<script setup lang="ts">
import { CollapsibleContent, CollapsibleRoot, CollapsibleTrigger } from 'reka-ui'
import { Markdown } from 'vue-stream-markdown'
import 'vue-stream-markdown/index.css'
import type { UIMessage } from 'ai'
const { message } = defineProps<{ message: UIMessage }>()
function getTextContent(msg: UIMessage): string {
return msg.parts
.filter((p): p is { type: 'text'; text: string } => p.type === 'text')
.map((p) => p.text)
.join('')
}
interface ToolPart {
type: string
toolCallId: string
state: string
input?: unknown
output?: unknown
errorText?: string
}
function isToolPart(part: unknown): part is ToolPart {
return (
typeof part === 'object' &&
part !== null &&
'type' in part &&
typeof (part as ToolPart).type === 'string' &&
(part as ToolPart).type.startsWith('tool-')
)
}
function getToolParts(msg: UIMessage): ToolPart[] {
return msg.parts.filter(isToolPart)
}
function toolName(part: ToolPart): string {
return part.type.replace(/^tool-/, '')
}
function toolDisplayName(part: ToolPart): string {
return toolName(part)
.replace(/_/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase())
}
function toolState(part: ToolPart): 'pending' | 'done' | 'error' {
if (part.state === 'error') return 'error'
if (part.state === 'output-available') return 'done'
return 'pending'
}
</script>
<template>
<div :class="message.role === 'user' ? 'flex justify-end' : ''">
<div class="min-w-0 space-y-1.5" :class="message.role === 'user' ? 'max-w-[85%]' : ''">
<!-- Tool timeline -->
<div
v-if="message.role === 'assistant' && getToolParts(message).length > 0"
class="space-y-0.5 rounded-lg border border-border bg-canvas p-2"
>
<CollapsibleRoot v-for="tool in getToolParts(message)" :key="tool.toolCallId">
<CollapsibleTrigger
class="flex w-full items-center gap-2 rounded px-1 py-0.5 hover:bg-hover"
>
<div
class="flex size-4 items-center justify-center rounded-full"
:class="{
'bg-accent/20 text-accent': toolState(tool) === 'pending',
'bg-green-500/20 text-green-400': toolState(tool) === 'done',
'bg-red-500/20 text-red-400': toolState(tool) === 'error'
}"
>
<icon-lucide-loader-circle
v-if="toolState(tool) === 'pending'"
class="size-3 animate-spin"
/>
<icon-lucide-check v-else-if="toolState(tool) === 'done'" class="size-3" />
<icon-lucide-triangle-alert v-else class="size-3" />
</div>
<span class="text-[11px] text-surface">
{{ toolDisplayName(tool) }}
</span>
<span class="text-[10px] text-muted">
{{
toolState(tool) === 'pending'
? 'Running…'
: toolState(tool) === 'done'
? 'Done'
: 'Error'
}}
</span>
<icon-lucide-chevron-down
v-if="toolState(tool) !== 'pending'"
class="ml-auto size-3 text-muted transition-transform [[data-state=open]>&]:rotate-180"
/>
</CollapsibleTrigger>
<CollapsibleContent
v-if="toolState(tool) !== 'pending'"
class="overflow-hidden text-[10px] data-[state=closed]:collapsible-up data-[state=open]:collapsible-down"
>
<pre class="mt-1 overflow-x-auto rounded bg-input p-2 text-muted">{{
tool.state === 'error' && tool.errorText
? tool.errorText
: JSON.stringify(tool.output, null, 2)
}}</pre>
</CollapsibleContent>
</CollapsibleRoot>
</div>
<!-- Text bubble -->
<div
v-if="getTextContent(message)"
class="rounded-xl px-3 py-2 text-xs leading-relaxed"
:class="
message.role === 'user'
? 'whitespace-pre-wrap rounded-br-md bg-accent text-white'
: 'rounded-tl-md bg-hover text-surface'
"
>
<Markdown
v-if="message.role === 'assistant'"
:content="getTextContent(message)"
class="chat-markdown"
/>
<template v-else>{{ getTextContent(message) }}</template>
</div>
</div>
</div>
</template>

111
src/composables/use-chat.ts Normal file
View file

@ -0,0 +1,111 @@
import { Chat } from '@ai-sdk/vue'
import { DEFAULT_AI_MODEL } from '@open-pencil/core'
import { createOpenRouter } from '@openrouter/ai-sdk-provider'
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'
export type { ModelOption } from '@open-pencil/core'
const API_KEY_STORAGE = 'open-pencil:openrouter-api-key'
const MODEL_STORAGE = 'open-pencil:model'
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)
} else {
localStorage.removeItem(API_KEY_STORAGE)
}
})
watch(modelId, (id) => {
localStorage.setItem(MODEL_STORAGE, id)
})
const isConfigured = computed(() => apiKey.value.length > 0)
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- test-only mock transports don't implement full generics
let overrideTransport: (() => any) | null = null
let chat: Chat<UIMessage> | null = null
function createTransport() {
if (overrideTransport) return overrideTransport()
const openrouter = createOpenRouter({
apiKey: apiKey.value,
headers: {
'X-OpenRouter-Title': 'OpenPencil',
'HTTP-Referer': 'https://github.com/dannote/open-pencil'
}
})
const tools = editorStore ? createAITools(editorStore) : {}
const agent = new ToolLoopAgent({
model: openrouter(modelId.value),
instructions: SYSTEM_PROMPT,
tools
})
return new DirectChatTransport({ agent })
}
function ensureChat(): Chat<UIMessage> | null {
if (!apiKey.value) return null
if (!chat) {
chat = new Chat<UIMessage>({
transport: createTransport()
})
}
return chat
}
function resetChat() {
chat = null
}
if (typeof window !== 'undefined') {
window.__OPEN_PENCIL_SET_TRANSPORT__ = (factory) => {
overrideTransport = factory
}
}
export function useAIChat(store?: EditorStore) {
if (store) {
editorStore = store
}
return {
apiKey,
modelId,
activeTab,
isConfigured,
ensureChat,
resetChat
}
}

View file

@ -1,5 +1,6 @@
import { useEventListener } from '@vueuse/core'
import { useAIChat } from '@/composables/use-chat'
import { TOOL_SHORTCUTS } from '@/stores/editor'
import { openFileDialog } from './use-menu'
@ -11,6 +12,7 @@ function isEditing(e: Event) {
}
export function useKeyboard(store: EditorStore) {
const { activeTab } = useAIChat()
useEventListener(window, 'copy', (e: ClipboardEvent) => {
if (isEditing(e)) return
e.preventDefault()
@ -79,6 +81,11 @@ export function useKeyboard(store: EditorStore) {
}
if (e.metaKey || e.ctrlKey) {
if (e.code === 'KeyJ') {
e.preventDefault()
activeTab.value = activeTab.value === 'ai' ? 'design' : 'ai'
return
}
if (e.key === 'z' && !e.shiftKey) {
e.preventDefault()
store.undoAction()

2
src/global.d.ts vendored
View file

@ -20,4 +20,6 @@ interface Window {
blob(): Promise<Blob>
}[]
>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
__OPEN_PENCIL_SET_TRANSPORT__?(factory: () => any): void
}

View file

@ -0,0 +1,201 @@
import { expect, test, type Page } from '@playwright/test'
import { CanvasHelper } from '../helpers/canvas'
const USE_REAL_LLM = process.env.TEST_REAL_LLM === '1'
const OPENROUTER_KEY = process.env.OPENROUTER_API_KEY ?? ''
let page: Page
let canvas: CanvasHelper
test.describe.configure({ mode: 'serial' })
test.beforeAll(async ({ browser }) => {
page = await browser.newPage()
await page.goto('/')
canvas = new CanvasHelper(page)
await canvas.waitForInit()
if (!USE_REAL_LLM) {
await injectMockTransport(page)
}
})
test.afterAll(async () => {
await page.close()
})
async function injectMockTransport(page: Page) {
await page.evaluate(() => {
const setTransport = window.__OPEN_PENCIL_SET_TRANSPORT__
if (!setTransport) throw new Error('Transport override not available')
let msgCounter = 0
setTransport(() => ({
async sendMessages({
messages,
}: {
messages: Array<{ role: string; parts: Array<{ type: string; text?: string }> }>
}) {
const lastUser = [...messages].reverse().find((m) => m.role === 'user')
const text = lastUser?.parts?.find((p) => p.type === 'text')?.text ?? ''
const msgId = `mock-msg-${++msgCounter}`
const wantsTool = text.toLowerCase().includes('frame') || text.toLowerCase().includes('rectangle')
return new ReadableStream({
start(controller) {
controller.enqueue({ type: 'start', messageId: msgId })
if (wantsTool) {
const toolCallId = `call-${msgId}`
controller.enqueue({
type: 'tool-input-start',
toolCallId,
toolName: 'create_shape',
})
controller.enqueue({
type: 'tool-input-delta',
toolCallId,
inputTextDelta: '{"type":"FRAME","x":100,"y":100,"width":200,"height":150,"name":"Card"}',
})
controller.enqueue({
type: 'tool-input-available',
toolCallId,
toolName: 'create_shape',
input: { type: 'FRAME', x: 100, y: 100, width: 200, height: 150, name: 'Card' },
})
controller.enqueue({
type: 'tool-output-available',
toolCallId,
toolName: 'create_shape',
output: { id: '0:99', type: 'FRAME', x: 100, y: 100, width: 200, height: 150, name: 'Card' },
})
}
const words = wantsTool
? ['Created', 'a', 'frame', 'called', '"Card".']
: `I'll help you with: "${text}". Here's a mock response.`.split(' ')
controller.enqueue({ type: 'text-start', id: 'text-1' })
for (const word of words) {
controller.enqueue({ type: 'text-delta', id: 'text-1', delta: word + ' ' })
}
controller.enqueue({ type: 'text-end', id: 'text-1' })
controller.enqueue({ type: 'finish', finishReason: 'stop' })
controller.close()
},
})
},
async reconnectToStream() {
return null
},
}))
})
}
function chatTab() {
return page.getByRole('tab', { name: 'AI' })
}
function designTab() {
return page.getByRole('tab', { name: 'Design' })
}
function chatInput() {
return page.locator('input[placeholder="Describe a change…"]')
}
function apiKeyInput() {
return page.locator('input[placeholder="sk-or-…"]')
}
test('⌘J switches to AI tab', async () => {
await designTab().waitFor()
await page.keyboard.press('Meta+j')
await expect(chatTab()).toHaveAttribute('data-state', 'active')
})
test('⌘J switches back to Design tab', async () => {
await page.keyboard.press('Meta+j')
await expect(designTab()).toHaveAttribute('data-state', 'active')
})
test('clicking AI tab shows API key setup when no key set', async () => {
await chatTab().click()
await expect(apiKeyInput()).toBeVisible()
await expect(page.getByText('Enter your OpenRouter API key')).toBeVisible()
})
test('saving API key shows chat interface', async () => {
const key = USE_REAL_LLM ? OPENROUTER_KEY : 'sk-or-test-key-12345'
await apiKeyInput().fill(key)
await page.locator('button:has-text("Save")').click()
await expect(chatInput()).toBeVisible()
await expect(page.getByText('Describe what you want to create or change.')).toBeVisible()
})
test('empty input has disabled send button', async () => {
const sendButton = page.locator('button[type="submit"]')
await expect(sendButton).toBeDisabled()
})
test('typing enables send button', async () => {
await chatInput().fill('Make a red rectangle')
const sendButton = page.locator('button[type="submit"]')
await expect(sendButton).toBeEnabled()
})
test('Enter submits message and clears input', async () => {
await chatInput().fill('Hello there')
await chatInput().press('Enter')
await expect(page.getByText('Hello there', { exact: true })).toBeVisible({ timeout: 5000 })
await expect(chatInput()).toHaveValue('')
})
test('assistant responds', async () => {
if (USE_REAL_LLM) {
await expect(
page.locator('.chat-markdown, [class*="rounded-tl-md"]').first(),
).toBeVisible({ timeout: 30000 })
} else {
await expect(page.getByText('mock response', { exact: false })).toBeVisible({ timeout: 5000 })
}
})
test('model selector is visible and clickable', async () => {
const trigger = page.getByRole('combobox')
await expect(trigger).toBeVisible()
await trigger.click()
await expect(page.getByRole('option', { name: /Claude Sonnet 4\.6/ })).toBeVisible()
await expect(page.getByText('Best for design')).toBeVisible()
await expect(page.getByText('Free').first()).toBeVisible()
await page.keyboard.press('Escape')
})
test('tool calls render in assistant message', async () => {
await chatInput().fill('Create a frame')
await chatInput().press('Enter')
if (USE_REAL_LLM) {
await expect(
page.locator('.chat-markdown, [class*="rounded-tl-md"]').first(),
).toBeVisible({ timeout: 30000 })
} else {
await expect(page.getByText('Create Shape')).toBeVisible({ timeout: 5000 })
await expect(page.getByText('Done')).toBeVisible()
await expect(page.getByText('Created a frame', { exact: false })).toBeVisible()
}
})
test('switching tabs preserves chat', async () => {
await designTab().click()
await expect(designTab()).toHaveAttribute('data-state', 'active')
await chatTab().click()
await expect(page.getByText('Hello there', { exact: true })).toBeVisible()
})