diff --git a/README.md b/README.md index c695702..04befbe 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,8 @@ bun install bun run dev ``` +**npm peerDependency note:** `@dnd-kit/core` and `@dnd-kit/sortable` may print a peer warning with **React 19.1.0** because their `peerDependencies` still expect React 18.x. They are confirmed to work here on React 19. You can ignore the warning, run `npm install --legacy-peer-deps` if install fails, and rely on **`skipLibCheck: true`** in `tsconfig.json` (already set) if third-party types complain. No extra `suppressHydrationWarning` is required for dnd-kit. + ### Tauri (optional) ```bash diff --git a/package-lock.json b/package-lock.json index b3491f3..e7b5ac5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,18 @@ "name": "pengine", "version": "0.1.0", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", + "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-menubar": "^1.1.16", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-slider": "^1.3.6", "@tailwindcss/vite": "^4.2.2", "@tauri-apps/api": "^2", + "@tauri-apps/plugin-dialog": "^2", "@tauri-apps/plugin-opener": "^2", "qrcode.react": "^4.2.0", "react": "^19.1.0", @@ -20,13 +29,20 @@ "zustand": "^5.0.12" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@playwright/test": "^1.55.0", "@tauri-apps/cli": "^2", "@types/node": "^22.19.17", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", + "eslint": "^10.2.0", + "eslint-plugin-react-hooks": "^7.0.1", + "husky": "^9.1.7", + "lint-staged": "^16.4.0", + "prettier": "^3.8.1", "typescript": "~5.8.3", + "typescript-eslint": "^8.58.1", "vite": "^7.0.4" } }, @@ -275,6 +291,74 @@ "node": ">=6.9.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/modifiers": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz", + "integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", @@ -689,6 +773,134 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz", + "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, "node_modules/@floating-ui/core": { "version": "1.7.5", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", @@ -727,6 +939,72 @@ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "license": "MIT", @@ -778,12 +1056,49 @@ "node": ">=18" } }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", "license": "MIT" }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz", + "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", @@ -807,6 +1122,36 @@ } } }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", @@ -1169,49 +1514,156 @@ } } }, - "node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", + "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true + }, + "@types/react-dom": { + "optional": true } } }, - "node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", - "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "node_modules/@radix-ui/react-select": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true + }, + "@types/react-dom": { + "optional": true } } }, - "node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", - "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "node_modules/@radix-ui/react-slider": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz", + "integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-effect-event": "0.0.2", - "@radix-ui/react-use-layout-effect": "1.1.1" - }, - "peerDependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, @@ -1272,6 +1724,21 @@ } } }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-rect": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", @@ -1308,6 +1775,29 @@ } } }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/rect": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", @@ -2120,6 +2610,15 @@ "node": ">= 10" } }, + "node_modules/@tauri-apps/plugin-dialog": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.0.tgz", + "integrity": "sha512-4nS/hfGMGCXiAS3LtVjH9AgsSAPJeG/7R+q8agTFqytjnMa4Zq95Bq8WzVDkckpanX+yyRHXnRtrKXkANKDHvw==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.10.1" + } + }, "node_modules/@tauri-apps/plugin-opener": { "version": "2.5.3", "license": "MIT OR Apache-2.0", @@ -2164,10 +2663,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.19.17", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", @@ -2197,219 +2710,875 @@ "@types/react": "^19.2.0" } }, - "node_modules/@vitejs/plugin-react": { - "version": "4.7.0", + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz", + "integrity": "sha512-aC2qc5thQahutKjP+cl8cgN9DWe3ZUqVko30CMSZHnFEHyhOYoZSzkGtAI2mcwZ38xeImDucI4dnqsHiOYuuCw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.28.0", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.27", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.17.0" + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/type-utils": "8.58.2", + "@typescript-eslint/utils": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" }, "engines": { - "node": "^14.18.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + "@typescript-eslint/parser": "^8.58.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/aria-hidden": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", - "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, "license": "MIT", - "dependencies": { - "tslib": "^2.0.0" - }, "engines": { - "node": ">=10" + "node": ">= 4" } }, - "node_modules/baseline-browser-mapping": { - "version": "2.10.16", + "node_modules/@typescript-eslint/parser": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.2.tgz", + "integrity": "sha512-/Zb/xaIDfxeJnvishjGdcR4jmr7S+bda8PKNhRGdljDM+elXhlvN0FyPSsMnLmJUrVG9aPO6dof80wjMawsASg==", "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", + "debug": "^4.4.3" }, "engines": { - "node": ">=6.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/browserslist": { - "version": "4.28.2", + "node_modules/@typescript-eslint/project-service": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.2.tgz", + "integrity": "sha512-Cq6UfpZZk15+r87BkIh5rDpi38W4b+Sjnb8wQCPPDDweS/LRCFjCyViEbzHk5Ck3f2QDfgmlxqSa7S7clDtlfg==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], "license": "MIT", - "peer": true, "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" - }, - "bin": { - "browserslist": "cli.js" + "@typescript-eslint/tsconfig-utils": "^8.58.2", + "@typescript-eslint/types": "^8.58.2", + "debug": "^4.4.3" }, "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/caniuse-lite": { - "version": "1.0.30001787", + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.2.tgz", + "integrity": "sha512-SgmyvDPexWETQek+qzZnrG6844IaO02UVyOLhI4wpo82dpZJY9+6YZCKAMFzXb7qhx37mFK1QcPQ18tud+vo6Q==", "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } }, - "node_modules/convert-source-map": { - "version": "2.0.0", + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.2.tgz", + "integrity": "sha512-3SR+RukipDvkkKp/d0jP0dyzuls3DbGmwDpVEc5wqk5f38KFThakqAAO0XMirWAE+kT00oTauTbzMFGPoAzB0A==", "dev": true, - "license": "MIT" - }, - "node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", "license": "MIT", "engines": { - "node": ">=18" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/csstype": { - "version": "3.2.3", - "devOptional": true, - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", + "node_modules/@typescript-eslint/type-utils": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.2.tgz", + "integrity": "sha512-Z7EloNR/B389FvabdGeTo2XMs4W9TjtPiO9DAsmT0yom0bwlPyRjkJ1uCdW1DvrrrYP50AJZ9Xc3sByZA9+dcg==", "dev": true, "license": "MIT", "dependencies": { - "ms": "^2.1.3" + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/utils": "8.58.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" }, "engines": { - "node": ">=6.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", + "node_modules/@typescript-eslint/types": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.2.tgz", + "integrity": "sha512-9TukXyATBQf/Jq9AMQXfvurk+G5R2MwfqQGDR2GzGz28HvY/lXNKGhkY+6IOubwcquikWk5cjlgPvD2uAA7htQ==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=8" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/detect-node-es": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", - "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", - "license": "MIT" - }, - "node_modules/electron-to-chromium": { - "version": "1.5.334", + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.2.tgz", + "integrity": "sha512-ELGuoofuhhoCvNbQjFFiobFcGgcDCEm0ThWdmO4Z0UzLqPXS3KFvnEZ+SHewwOYHjM09tkzOWXNTv9u6Gqtyuw==", "dev": true, - "license": "ISC" - }, - "node_modules/enhanced-resolve": { - "version": "5.20.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", - "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", "license": "MIT", "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.0" + "@typescript-eslint/project-service": "8.58.2", + "@typescript-eslint/tsconfig-utils": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/visitor-keys": "8.58.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.2.tgz", + "integrity": "sha512-QZfjHNEzPY8+l0+fIXMvuQ2sJlplB4zgDZvA+NmvZsZv3EQwOcc1DuIU1VJUTWZ/RKouBMhDyNaBMx4sWvrzRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.2", + "@typescript-eslint/types": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.2.tgz", + "integrity": "sha512-f1WO2Lx8a9t8DARmcWAUPJbu0G20bJlj8L4z72K00TMeJAoyLr/tHhI/pzYBLrR4dXWkcxO1cWYZEOX8DKHTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.16", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001787", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "devOptional": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.334", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" }, "engines": { "node": ">=10.13.0" } }, - "node_modules/esbuild": { - "version": "0.27.7", - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.1.tgz", + "integrity": "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.5.5", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" }, "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.13.0 || >=24" }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/escalade": { - "version": "3.2.0", + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, - "license": "MIT", + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, "engines": { - "node": ">=6" + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "license": "MIT", @@ -2425,6 +3594,57 @@ } } }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "license": "MIT", @@ -2444,6 +3664,19 @@ "node": ">=6.9.0" } }, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -2453,12 +3686,124 @@ "node": ">=6" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -2484,6 +3829,27 @@ "node": ">=6" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "dev": true, @@ -2495,6 +3861,30 @@ "node": ">=6" } }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -2744,6 +4134,101 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lint-staged": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.4.0.tgz", + "integrity": "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^14.0.3", + "listr2": "^9.0.5", + "picomatch": "^4.0.3", + "string-argv": "^0.3.2", + "tinyexec": "^1.0.4", + "yaml": "^2.8.2" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=20.17" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + } + }, + "node_modules/listr2": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", + "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^5.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "dev": true, @@ -2758,7 +4243,36 @@ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/ms": { @@ -2782,11 +4296,104 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/node-releases": { "version": "2.0.37", "dev": true, "license": "MIT" }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/picocolors": { "version": "1.1.1", "license": "ISC" @@ -2794,7 +4401,6 @@ "node_modules/picomatch": { "version": "4.0.4", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2875,6 +4481,42 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qrcode.react": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", @@ -3018,6 +4660,30 @@ } } }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, "node_modules/rollup": { "version": "4.60.1", "license": "MIT", @@ -3078,6 +4744,59 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slice-ansi": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "license": "BSD-3-Clause", @@ -3085,6 +4804,49 @@ "node": ">=0.10.0" } }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, + "node_modules/string-width": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", + "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/tailwindcss": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", @@ -3104,6 +4866,16 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "license": "MIT", @@ -3118,16 +4890,43 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/typescript": { "version": "5.8.3", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3136,6 +4935,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.58.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.2.tgz", + "integrity": "sha512-V8iSng9mRbdZjl54VJ9NKr6ZB+dW0J3TzRXRGcSbLIej9jV86ZRtlYeTKDR/QLxXykocJ5icNzbsl2+5TzIvcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.58.2", + "@typescript-eslint/parser": "8.58.2", + "@typescript-eslint/typescript-estree": "8.58.2", + "@typescript-eslint/utils": "8.58.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -3172,6 +4995,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/use-callback-ref": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", @@ -3288,11 +5121,126 @@ } } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/yallist": { "version": "3.1.1", "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "devOptional": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, "node_modules/zustand": { "version": "5.0.12", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", diff --git a/package.json b/package.json index dfa1d09..f4bb1c3 100644 --- a/package.json +++ b/package.json @@ -30,9 +30,14 @@ ] }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/modifiers": "^9.0.0", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-menubar": "^1.1.16", "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slider": "^1.3.6", "@tailwindcss/vite": "^4.2.2", "@tauri-apps/api": "^2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0926ead..f7f00d4 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -37,6 +37,7 @@ tauri-plugin-dialog = "2" zip = { version = "2", default-features = false, features = ["deflate"] } futures = "0.3.31" regex = "1" +tempfile = "3" [target.'cfg(target_os = "macos")'.dependencies] security-framework = "3" @@ -49,6 +50,5 @@ keyring = { version = "3", default-features = false, features = ["sync-secret-se keyring = { version = "3", default-features = false, features = ["windows-native"] } [dev-dependencies] -tempfile = "3" ctor = "0.2" diff --git a/src-tauri/src/app.rs b/src-tauri/src/app.rs index c573926..fa9959f 100644 --- a/src-tauri/src/app.rs +++ b/src-tauri/src/app.rs @@ -1,5 +1,6 @@ use crate::infrastructure::http_server; use crate::modules::bot::{commands, repository, service as bot_service}; +use crate::modules::cron::{repository as cron_repository, scheduler as cron_scheduler}; use crate::modules::mcp::service as mcp_service; use crate::modules::secure_store; use crate::shared::state::{AppState, ConnectionData}; @@ -79,6 +80,30 @@ pub fn run() { app.manage(shared_state.clone()); + // Load persisted cron jobs + last-known Telegram chat id before the scheduler spins up, + // so a scheduled job can fire on its first tick after restart. + { + let cron_state = shared_state.clone(); + tauri::async_runtime::block_on(async move { + match cron_repository::load(&cron_state.cron_path) { + Ok(file) => { + *cron_state.cron_jobs.write().await = file.jobs; + *cron_state.last_chat_id.write().await = file.last_chat_id; + } + Err(e) => { + cron_state + .emit_log("cron", &format!("load cron.json failed: {e}")) + .await; + } + } + }); + } + + let scheduler_state = shared_state.clone(); + tauri::async_runtime::spawn(async move { + cron_scheduler::run(scheduler_state).await; + }); + // Connect MCP stdio servers in the background so window + HTTP API are not blocked by // slow starters (Podman containers, `npx`, etc.). The registry stays empty until connect // finishes; early Telegram turns simply omit tools until then. diff --git a/src-tauri/src/infrastructure/http_server.rs b/src-tauri/src/infrastructure/http_server.rs index e67d6d4..169096d 100644 --- a/src-tauri/src/infrastructure/http_server.rs +++ b/src-tauri/src/infrastructure/http_server.rs @@ -1,5 +1,9 @@ use crate::infrastructure::bot_lifecycle; -use crate::modules::bot::{repository, service as bot_service}; +use crate::modules::bot::{agent as bot_agent, repository, service as bot_service}; +use crate::modules::cron::{ + repository as cron_repository, scheduler as cron_scheduler, service as cron_service, + types::{CronFile, CronJob, Schedule}, +}; use crate::modules::mcp::service as mcp_service; use crate::modules::ollama::service as ollama_service; use crate::modules::secure_store::{self, SecureStoreError}; @@ -21,11 +25,16 @@ use std::collections::HashMap; use std::convert::Infallible; use std::io::ErrorKind; use std::time::Duration; +use tokio::task; +use tokio::time::timeout; use tokio_stream::{Stream, StreamExt}; use tower_http::cors::{Any, CorsLayer}; pub const DEFAULT_PORT: u16 = 21516; +/// Matches dashboard `testCronJob` default timeout (120s). +const CRON_TEST_AGENT_TIMEOUT: Duration = Duration::from_secs(120); + #[derive(Deserialize)] pub struct ConnectRequest { pub bot_token: String, @@ -142,6 +151,7 @@ pub async fn start_server(state: AppState) { ) .route("/v1/skills", get(handle_skills_list)) .route("/v1/skills", post(handle_skills_add)) + .route("/v1/skills/order", put(handle_skills_set_order)) .route("/v1/skills/{slug}", delete(handle_skills_delete)) .route("/v1/skills/{slug}/enabled", put(handle_skills_set_enabled)) .route( @@ -153,6 +163,12 @@ pub async fn start_server(state: AppState) { "/v1/skills/clawhub/install", post(handle_skills_clawhub_install), ) + .route("/v1/cron", get(handle_cron_list)) + .route("/v1/cron", post(handle_cron_create)) + .route("/v1/cron/{id}", put(handle_cron_update)) + .route("/v1/cron/{id}", delete(handle_cron_delete)) + .route("/v1/cron/{id}/enabled", put(handle_cron_set_enabled)) + .route("/v1/cron/{id}/test", post(handle_cron_test)) .layer(cors) .with_state(state.clone()); @@ -1739,6 +1755,11 @@ pub struct SetSkillEnabledBody { pub enabled: bool, } +#[derive(Deserialize)] +pub struct SkillOrderBody { + pub slugs: Vec, +} + async fn handle_skills_list(State(state): State) -> Json { let skills = skills_service::list_skills(&state.store_path); let custom_dir = skills_service::custom_skills_dir(&state.store_path) @@ -1747,6 +1768,18 @@ async fn handle_skills_list(State(state): State) -> Json, + Json(body): Json, +) -> Result<(StatusCode, Json), (StatusCode, Json)> { + skills_service::set_skill_slug_order(&state.store_path, &body.slugs) + .map_err(|e| (StatusCode::BAD_REQUEST, Json(ErrorResponse { error: e })))?; + state + .emit_log("skills", "skill display order updated") + .await; + Ok((StatusCode::OK, Json(serde_json::json!({ "ok": true })))) +} + async fn handle_skills_add( State(state): State, Json(body): Json, @@ -1865,6 +1898,311 @@ async fn handle_skills_set_enabled( Ok((StatusCode::OK, Json(serde_json::json!({ "ok": true })))) } +// ── Cron jobs ──────────────────────────────────────────────────────── + +#[derive(Serialize)] +pub struct CronListResponse { + pub jobs: Vec, + pub last_chat_id: Option, +} + +#[derive(Deserialize)] +pub struct CronCreateBody { + pub name: String, + pub instruction: String, + #[serde(default)] + pub condition: String, + #[serde(default)] + pub skill_slugs: Vec, + pub schedule: Schedule, + #[serde(default = "default_true_serde")] + pub enabled: bool, +} + +#[derive(Deserialize)] +pub struct CronUpdateBody { + pub name: String, + pub instruction: String, + #[serde(default)] + pub condition: String, + #[serde(default)] + pub skill_slugs: Vec, + pub schedule: Schedule, + pub enabled: bool, +} + +#[derive(Deserialize)] +pub struct CronSetEnabledBody { + pub enabled: bool, +} + +#[derive(Serialize)] +pub struct CronTestResponse { + pub reply: String, + pub condition_met: bool, + /// True when the same reply was delivered to the last-known Telegram chat. + pub telegram_sent: bool, + /// Set when delivery was attempted but failed (e.g. bot disconnected). + #[serde(skip_serializing_if = "Option::is_none")] + pub telegram_error: Option, +} + +async fn persist_cron(state: &AppState) -> Result<(), String> { + let _guard = state.cron_save_mutex.lock().await; + let jobs = state.cron_jobs.read().await.clone(); + let last_chat_id = *state.last_chat_id.read().await; + let file = CronFile { jobs, last_chat_id }; + let path = state.cron_path.clone(); + task::spawn_blocking(move || cron_repository::save(&path, &file)) + .await + .map_err(|e| format!("cron persist task: {e}"))? +} + +fn bad_request(msg: impl Into) -> (StatusCode, Json) { + ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { error: msg.into() }), + ) +} + +fn not_found(msg: impl Into) -> (StatusCode, Json) { + ( + StatusCode::NOT_FOUND, + Json(ErrorResponse { error: msg.into() }), + ) +} + +fn internal(msg: impl Into) -> (StatusCode, Json) { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { error: msg.into() }), + ) +} + +fn gateway_timeout(msg: impl Into) -> (StatusCode, Json) { + ( + StatusCode::GATEWAY_TIMEOUT, + Json(ErrorResponse { error: msg.into() }), + ) +} + +async fn handle_cron_list(State(state): State) -> Json { + let jobs = state.cron_jobs.read().await.clone(); + let last_chat_id = *state.last_chat_id.read().await; + Json(CronListResponse { jobs, last_chat_id }) +} + +async fn handle_cron_create( + State(state): State, + Json(body): Json, +) -> Result<(StatusCode, Json), (StatusCode, Json)> { + cron_service::validate(&body.name, &body.instruction, &body.schedule).map_err(bad_request)?; + let skill_slugs = + skills_service::canonicalize_skill_slug_list(&state.store_path, &body.skill_slugs); + let job = CronJob { + id: cron_service::new_job_id(), + name: body.name.trim().to_string(), + instruction: body.instruction.trim().to_string(), + condition: body.condition.trim().to_string(), + skill_slugs, + schedule: body.schedule, + enabled: body.enabled, + created_at: Utc::now(), + last_run_at: None, + }; + let job_id = job.id.clone(); + { + let mut jobs = state.cron_jobs.write().await; + jobs.push(job.clone()); + } + if let Err(e) = persist_cron(&state).await { + let mut jobs = state.cron_jobs.write().await; + jobs.retain(|j| j.id != job_id); + return Err(internal(e)); + } + state.cron_notify.notify_waiters(); + state + .emit_log("cron", &format!("job '{}' created ({})", job.name, job.id)) + .await; + Ok((StatusCode::OK, Json(job))) +} + +async fn handle_cron_update( + State(state): State, + Path(id): Path, + Json(body): Json, +) -> Result<(StatusCode, Json), (StatusCode, Json)> { + cron_service::validate(&body.name, &body.instruction, &body.schedule).map_err(bad_request)?; + let skill_slugs = + skills_service::canonicalize_skill_slug_list(&state.store_path, &body.skill_slugs); + let (backup, updated) = { + let mut jobs = state.cron_jobs.write().await; + let Some(job) = jobs.iter_mut().find(|j| j.id == id) else { + return Err(not_found(format!("cron job '{id}' not found"))); + }; + let backup = job.clone(); + job.name = body.name.trim().to_string(); + job.instruction = body.instruction.trim().to_string(); + job.condition = body.condition.trim().to_string(); + job.skill_slugs = skill_slugs; + job.schedule = body.schedule; + job.enabled = body.enabled; + (backup, job.clone()) + }; + if let Err(e) = persist_cron(&state).await { + let mut jobs = state.cron_jobs.write().await; + if let Some(j) = jobs.iter_mut().find(|j| j.id == id) { + *j = backup; + } + return Err(internal(e)); + } + state.cron_notify.notify_waiters(); + state + .emit_log("cron", &format!("job '{}' updated ({id})", updated.name)) + .await; + Ok((StatusCode::OK, Json(updated))) +} + +async fn handle_cron_delete( + State(state): State, + Path(id): Path, +) -> Result<(StatusCode, Json), (StatusCode, Json)> { + let removed: CronJob = { + let mut jobs = state.cron_jobs.write().await; + let Some(pos) = jobs.iter().position(|j| j.id == id) else { + return Err(not_found(format!("cron job '{id}' not found"))); + }; + jobs.remove(pos) + }; + if let Err(e) = persist_cron(&state).await { + let mut jobs = state.cron_jobs.write().await; + jobs.push(removed); + return Err(internal(e)); + } + state.cron_notify.notify_waiters(); + state.emit_log("cron", &format!("job '{id}' deleted")).await; + Ok((StatusCode::OK, Json(serde_json::json!({ "ok": true })))) +} + +async fn handle_cron_set_enabled( + State(state): State, + Path(id): Path, + Json(body): Json, +) -> Result<(StatusCode, Json), (StatusCode, Json)> { + let prev_enabled = { + let mut jobs = state.cron_jobs.write().await; + let Some(job) = jobs.iter_mut().find(|j| j.id == id) else { + return Err(not_found(format!("cron job '{id}' not found"))); + }; + let prev = job.enabled; + job.enabled = body.enabled; + prev + }; + if let Err(e) = persist_cron(&state).await { + let mut jobs = state.cron_jobs.write().await; + if let Some(j) = jobs.iter_mut().find(|j| j.id == id) { + j.enabled = prev_enabled; + } + return Err(internal(e)); + } + state.cron_notify.notify_waiters(); + state + .emit_log( + "cron", + &format!( + "job '{id}' {}", + if body.enabled { "enabled" } else { "disabled" } + ), + ) + .await; + Ok((StatusCode::OK, Json(serde_json::json!({ "ok": true })))) +} + +async fn handle_cron_test( + State(state): State, + Path(id): Path, +) -> Result<(StatusCode, Json), (StatusCode, Json)> { + let job = { + let jobs = state.cron_jobs.read().await; + jobs.iter() + .find(|j| j.id == id) + .cloned() + .ok_or_else(|| not_found(format!("cron job '{id}' not found")))? + }; + let prompt = cron_service::compose_prompt(&job); + state + .emit_log("cron", &format!("test run for '{}' ({})", job.name, job.id)) + .await; + let skills_filter = if job.skill_slugs.is_empty() { + None + } else { + Some(job.skill_slugs.as_slice()) + }; + let turn = match timeout( + CRON_TEST_AGENT_TIMEOUT, + bot_agent::run_system_turn(&state, &prompt, skills_filter), + ) + .await + { + Ok(Ok(turn)) => turn, + Ok(Err(e)) => return Err(internal(format!("agent error: {e}"))), + Err(_elapsed) => { + state + .emit_log( + "cron", + &format!( + "test run for '{}' ({}) timed out after {}s", + job.name, + job.id, + CRON_TEST_AGENT_TIMEOUT.as_secs() + ), + ) + .await; + return Err(gateway_timeout(format!( + "agent timed out after {}s", + CRON_TEST_AGENT_TIMEOUT.as_secs() + ))); + } + }; + let reply = turn.text; + let condition_met = !turn.suppress_telegram_reply + && !reply.trim().is_empty() + && !cron_service::is_no_message_reply(&reply); + + let (telegram_sent, telegram_error) = if turn.suppress_telegram_reply + || reply.trim().is_empty() + || cron_service::is_no_message_reply(&reply) + { + (false, None) + } else { + match cron_scheduler::send_to_last_chat(&state, &reply).await { + Ok(()) => { + state + .emit_log("cron", "test run: reply sent to Telegram") + .await; + (true, None) + } + Err(e) => { + let msg = e.clone(); + state + .emit_log("cron", &format!("test run: Telegram send failed: {e}")) + .await; + (false, Some(msg)) + } + } + }; + + Ok(( + StatusCode::OK, + Json(CronTestResponse { + reply, + condition_met, + telegram_sent, + telegram_error, + }), + )) +} + async fn handle_logs_sse( State(state): State, ) -> Sse>> { diff --git a/src-tauri/src/modules/bot/agent.rs b/src-tauri/src/modules/bot/agent.rs index 6278912..c1ea4b8 100644 --- a/src-tauri/src/modules/bot/agent.rs +++ b/src-tauri/src/modules/bot/agent.rs @@ -310,6 +310,34 @@ impl TurnResult { // ── Public entry point ───────────────────────────────────────────── +/// Run one model turn for a system-originated prompt (e.g. cron job). +/// Bypasses memory-session routing so scheduled tasks never land in diary/session +/// logs, and never trigger session start/stop keywords. +/// +/// `skills_slug_filter`: when [`Some`] and non-empty, only those skills are included in the +/// system prompt; when [`None`] or empty slice, all enabled skills are included. +pub async fn run_system_turn( + state: &AppState, + prompt: &str, + skills_slug_filter: Option<&[String]>, +) -> Result { + let think = decide_think(None, prompt).enabled(); + let result = run_model_turn(state, prompt, think, skills_slug_filter).await?; + let body = result.text.trim(); + if !body.is_empty() { + let tag = match result.source { + ReplySource::Tool => "tool", + ReplySource::Model => "model", + }; + state + .emit_log("reply", &format!("[cron:{tag}] {body}")) + .await; + } else { + state.emit_log("reply", "[cron] (empty reply)").await; + } + Ok(result) +} + pub async fn run_turn(state: &AppState, user_message: &str) -> Result { let (think_override, user_message) = parse_think_override(user_message); @@ -333,7 +361,7 @@ pub async fn run_turn(state: &AppState, user_message: &str) -> Result String { +async fn build_system_prompt( + state: &AppState, + user_message: &str, + has_tools: bool, + has_memory: bool, + skills_slug_filter: Option<&[String]>, +) -> String { if !has_tools { return format!("{PENGINE_OUTPUT_CONTRACT_LEAD}Answer concisely."); } @@ -570,7 +604,17 @@ async fn build_system_prompt(state: &AppState, has_tools: bool, has_memory: bool String::new() }; - let skills_raw = skills::skills_prompt_hint(&state.store_path); + let weather_directive = if skills::user_message_suggests_weather(user_message) { + "\n\n**This turn is weather-related:** Follow **skill:weather** only (wttr.in / Open-Meteo via **`fetch`**). Do not cite or prioritize government-portal skills (e.g. oesterreich.gv.at) for forecasts or current conditions unless the user explicitly asked for public administration, forms, or law." + } else { + "" + }; + + let skills_raw = skills::skills_prompt_hint_for_turn( + &state.store_path, + Some(user_message), + skills_slug_filter, + ); let skills_cap = *state.skills_hint_max_bytes.read().await as usize; let (skills_hint, skills_truncated) = skills::limit_skills_hint_bytes(skills_raw, skills_cap); if skills_truncated { @@ -591,7 +635,7 @@ async fn build_system_prompt(state: &AppState, has_tools: bool, has_memory: bool After tool results, answer immediately. Be concise. \ `brave_web_search` is only in the tool list when the user asked to search the open web (e.g. “search the internet”, “suche im Internet”, “suche nach …”) or a skill’s `requires` matches this turn — otherwise prefer **`fetch`** on any `http(s)` URL you have (including from the user). \ At most one `brave_web_search` per user message when it is available. \ - After an allowed search, the host may auto-`fetch` several top result URLs — use those excerpts and end with **Quellen** listing every source URL.{fs_hint}{mem_hint}{skills_hint}" + After an allowed search, the host may auto-`fetch` several top result URLs — use those excerpts and end with **Quellen** listing every source URL.{fs_hint}{mem_hint}{weather_directive}{skills_hint}" ) } @@ -599,6 +643,7 @@ async fn run_model_turn( state: &AppState, user_message: &str, think: bool, + skills_slug_filter: Option<&[String]>, ) -> Result { let model = match state.preferred_ollama_model.read().await.clone() { Some(m) => m, @@ -655,7 +700,14 @@ async fn run_model_turn( .await; state.record_tool_selection_ms(tool_ctx.select_ms).await; - let system = build_system_prompt(state, has_tools, has_memory).await; + let system = build_system_prompt( + state, + user_message, + has_tools, + has_memory, + skills_slug_filter, + ) + .await; // Order matters for Ollama KV-cache reuse across turns: system message // first, user second. Changing fragment order would invalidate the cached @@ -915,6 +967,12 @@ async fn run_model_turn( // Phase 2: summarize tool results if model didn't answer inline. if !tool_results.is_empty() { + state + .emit_log( + "run", + "agent: summarizing tool results (follow-up model step)", + ) + .await; let mut data = String::new(); for (name, content) in &tool_results { data.push_str(&format!("--- {name} ---\n{content}\n")); diff --git a/src-tauri/src/modules/bot/service.rs b/src-tauri/src/modules/bot/service.rs index ee5ba79..92c6ba8 100644 --- a/src-tauri/src/modules/bot/service.rs +++ b/src-tauri/src/modules/bot/service.rs @@ -1,4 +1,5 @@ use crate::modules::bot::agent; +use crate::modules::cron::{repository as cron_repository, types::CronFile}; use crate::shared::state::AppState; use crate::shared::text::split_by_chars; use std::sync::Arc; @@ -111,6 +112,8 @@ async fn text_handler(bot: Bot, msg: Message, state: AppState) -> ResponseResult .emit_log("msg", &format!("from {}: {}", user_label(&msg), incoming)) .await; + remember_chat_id(&state, msg.chat.id).await; + // Telegram's `typing` action lasts ~5 seconds. Refresh it every 4s in a // background task while the agent runs so the user sees a continuous // "writing…" indicator no matter how long the tool calls take. @@ -157,6 +160,29 @@ async fn text_handler(bot: Bot, msg: Message, state: AppState) -> ResponseResult Ok(()) } +/// Persist the most recent chat id so the cron scheduler has somewhere to deliver +/// proactive messages. Writes `cron.json` only when the id changed. +async fn remember_chat_id(state: &AppState, chat_id: ChatId) { + let new_id = chat_id.0; + { + let mut guard = state.last_chat_id.write().await; + if *guard == Some(new_id) { + return; + } + *guard = Some(new_id); + } + let snapshot = state.cron_jobs.read().await.clone(); + let file = CronFile { + jobs: snapshot, + last_chat_id: Some(new_id), + }; + if let Err(e) = cron_repository::save(&state.cron_path, &file) { + state + .emit_log("cron", &format!("save last_chat_id failed: {e}")) + .await; + } +} + /// Send `text` to `chat_id`, splitting into Telegram-sized chunks if needed. /// Send failures are logged (not propagated) so one bad chunk doesn't abort /// the handler and leave the user with no reply at all. diff --git a/src-tauri/src/modules/cron/mod.rs b/src-tauri/src/modules/cron/mod.rs new file mode 100644 index 0000000..62bfab1 --- /dev/null +++ b/src-tauri/src/modules/cron/mod.rs @@ -0,0 +1,4 @@ +pub mod repository; +pub mod scheduler; +pub mod service; +pub mod types; diff --git a/src-tauri/src/modules/cron/repository.rs b/src-tauri/src/modules/cron/repository.rs new file mode 100644 index 0000000..351f30f --- /dev/null +++ b/src-tauri/src/modules/cron/repository.rs @@ -0,0 +1,63 @@ +use super::types::CronFile; +use std::fs::File; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +static CRON_REPO_MUTEX: Mutex<()> = Mutex::new(()); + +/// `$APP_DATA/cron.json`, next to `connection.json` / `mcp.json`. +pub fn cron_path(store_path: &Path) -> PathBuf { + store_path + .parent() + .map(|p| p.join("cron.json")) + .unwrap_or_else(|| PathBuf::from("cron.json")) +} + +pub fn load(path: &Path) -> Result { + let _guard = CRON_REPO_MUTEX + .lock() + .map_err(|_| "cron repository lock poisoned".to_string())?; + if !path.exists() { + return Ok(CronFile::default()); + } + let raw = std::fs::read_to_string(path).map_err(|e| format!("read cron.json: {e}"))?; + if raw.trim().is_empty() { + return Ok(CronFile::default()); + } + serde_json::from_str(&raw).map_err(|e| format!("parse cron.json: {e}")) +} + +pub fn save(path: &Path, file: &CronFile) -> Result<(), String> { + let _guard = CRON_REPO_MUTEX + .lock() + .map_err(|_| "cron repository lock poisoned".to_string())?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("create parent dirs for cron.json: {e}"))?; + } + let parent = path + .parent() + .ok_or_else(|| "cron.json path has no parent directory".to_string())?; + let pretty = + serde_json::to_string_pretty(file).map_err(|e| format!("encode cron.json: {e}"))?; + + let mut tmp = tempfile::NamedTempFile::new_in(parent) + .map_err(|e| format!("temp file for cron.json: {e}"))?; + tmp.write_all(pretty.as_bytes()) + .map_err(|e| format!("write temp cron.json: {e}"))?; + tmp.flush() + .map_err(|e| format!("flush temp cron.json: {e}"))?; + tmp.as_file() + .sync_all() + .map_err(|e| format!("sync temp cron.json: {e}"))?; + tmp.persist(path) + .map_err(|e| format!("replace cron.json: {}", e.error))?; + + if let Some(dir) = path.parent() { + let d = File::open(dir).map_err(|e| format!("open cron.json parent dir: {e}"))?; + d.sync_all() + .map_err(|e| format!("fsync cron.json parent dir: {e}"))?; + } + Ok(()) +} diff --git a/src-tauri/src/modules/cron/scheduler.rs b/src-tauri/src/modules/cron/scheduler.rs new file mode 100644 index 0000000..bde4c38 --- /dev/null +++ b/src-tauri/src/modules/cron/scheduler.rs @@ -0,0 +1,219 @@ +use super::repository; +use super::service; +use super::types::{CronFile, CronJob}; +use crate::modules::bot::agent; +use crate::shared::state::AppState; +use crate::shared::text::split_by_chars; +use futures::FutureExt; +use std::sync::atomic::Ordering; +use std::time::Duration as StdDuration; +use teloxide::prelude::*; +use teloxide::types::ChatId; +use tokio::task; + +/// Wake-up cadence for the scheduler. The loop also resumes on `state.cron_notify` +/// so CRUD / enable / test operations take effect immediately instead of waiting +/// for the next tick. +const TICK_INTERVAL: StdDuration = StdDuration::from_secs(30); + +/// Telegram chunk budget (mirrors bot/service.rs). +const TELEGRAM_CHUNK_BUDGET: usize = 2000; + +pub async fn run(state: AppState) { + state.emit_log("cron", "scheduler started").await; + loop { + tokio::select! { + _ = tokio::time::sleep(TICK_INTERVAL) => {} + _ = state.cron_notify.notified() => {} + _ = state.shutdown_notify.notified() => { + state.emit_log("cron", "scheduler stopping").await; + return; + } + } + tick(&state).await; + } +} + +async fn tick(state: &AppState) { + let now = chrono::Utc::now(); + let due: Vec = { + let jobs = state.cron_jobs.read().await; + jobs.iter() + .filter(|j| { + // For never-run jobs, `created_at` anchors the first-interval wait so a + // brand-new "every 10 min" job fires 10 minutes after creation, not instantly. + let reference = j.last_run_at.or(Some(j.created_at)); + j.enabled && service::is_due(&j.schedule, reference, now) + }) + .cloned() + .collect() + }; + if due.is_empty() { + return; + } + + let chat_known = state.last_chat_id.read().await.is_some(); + if chat_known { + state.cron_no_chat_warned.store(false, Ordering::Relaxed); + } else { + let first_episode = !state.cron_no_chat_warned.swap(true, Ordering::Relaxed); + if first_episode { + state + .emit_log( + "cron", + &format!( + "{} job(s) due but no Telegram chat known yet — send any message to the bot first", + due.len() + ), + ) + .await; + } + return; + } + + for job in due { + if state.shutdown_notify.notified().now_or_never().is_some() { + break; + } + execute_job(state, job).await; + } +} + +/// Runs a job through the agent and (when the condition is met) delivers the +/// reply to the last-known Telegram chat. +pub async fn execute_job(state: &AppState, job: CronJob) { + state + .emit_log("cron", &format!("running '{}' ({})", job.name, job.id)) + .await; + + let prompt = service::compose_prompt(&job); + let skills_filter = if job.skill_slugs.is_empty() { + None + } else { + Some(job.skill_slugs.as_slice()) + }; + let result = tokio::select! { + _ = state.shutdown_notify.notified() => { + state + .emit_log( + "cron", + &format!("'{}' — cancelled before agent run (shutdown)", job.name), + ) + .await; + return; + } + r = agent::run_system_turn(state, &prompt, skills_filter) => r, + }; + + match result { + Ok(turn) => { + if turn.suppress_telegram_reply { + state + .emit_log( + "cron", + &format!("'{}' — reply suppressed; not sending to Telegram", job.name), + ) + .await; + } else if turn.text.trim().is_empty() { + state + .emit_log( + "cron", + &format!( + "'{}' — model returned an empty reply (not treated as ); nothing sent — check inference/transport and Ollama logs, or use Test in Dashboard", + job.name + ), + ) + .await; + } else if service::is_no_message_reply(&turn.text) { + state + .emit_log( + "cron", + &format!( + "'{}' — scheduled output is (condition not met); not sending to Telegram", + job.name + ), + ) + .await; + } else { + tokio::select! { + _ = state.shutdown_notify.notified() => { + state + .emit_log( + "cron", + &format!( + "'{}' — cancelled before Telegram send (shutdown)", + job.name + ), + ) + .await; + return; + } + r = send_to_last_chat(state, &turn.text) => match r { + Ok(()) => { + state + .emit_log("cron", &format!("'{}' sent reply", job.name)) + .await; + } + Err(e) => { + state + .emit_log("cron", &format!("'{}' send failed: {e}", job.name)) + .await; + } + }, + } + } + if let Err(e) = mark_ran(state, &job.id).await { + state + .emit_log("cron", &format!("persist last_run_at failed: {e}")) + .await; + } + } + Err(e) => { + state + .emit_log("cron", &format!("'{}' agent error: {e}", job.name)) + .await; + } + } +} + +async fn mark_ran(state: &AppState, job_id: &str) -> Result<(), String> { + let _guard = state.cron_save_mutex.lock().await; + let snapshot = { + let mut jobs = state.cron_jobs.write().await; + if let Some(j) = jobs.iter_mut().find(|j| j.id == job_id) { + j.last_run_at = Some(chrono::Utc::now()); + } + jobs.clone() + }; + let last_chat_id = *state.last_chat_id.read().await; + let file = CronFile { + jobs: snapshot, + last_chat_id, + }; + let path = state.cron_path.clone(); + task::spawn_blocking(move || repository::save(&path, &file)) + .await + .map_err(|e| format!("cron mark_ran task: {e}"))? +} + +pub async fn send_to_last_chat(state: &AppState, text: &str) -> Result<(), String> { + let chat_id = + state.last_chat_id.read().await.ok_or_else(|| { + "no known Telegram chat — send any message to the bot first".to_string() + })?; + let token = { + let g = state.connection.lock().await; + g.as_ref() + .ok_or_else(|| "Telegram bot is not connected".to_string())? + .bot_token + .clone() + }; + let bot = Bot::new(token); + let chunks = split_by_chars(text, TELEGRAM_CHUNK_BUDGET); + for chunk in chunks { + bot.send_message(ChatId(chat_id), chunk) + .await + .map_err(|e| format!("telegram send: {e}"))?; + } + Ok(()) +} diff --git a/src-tauri/src/modules/cron/service.rs b/src-tauri/src/modules/cron/service.rs new file mode 100644 index 0000000..87aa6a7 --- /dev/null +++ b/src-tauri/src/modules/cron/service.rs @@ -0,0 +1,242 @@ +use super::types::{CronJob, Schedule}; +use chrono::{DateTime, Duration, Local, LocalResult, TimeZone, Utc}; + +/// Sentinel the model is asked to emit when a job's condition isn't met. +/// Kept lowercase + punctuated so common phrasings ("no message", "NO-MESSAGE") +/// also suppress delivery. +pub const NO_MESSAGE_SENTINEL: &str = ""; + +pub fn new_job_id() -> String { + let ts = Utc::now().timestamp_millis(); + let rand = fastrand::u64(..); + format!("job-{ts:x}{rand:012x}") +} + +pub fn validate(name: &str, instruction: &str, schedule: &Schedule) -> Result<(), String> { + if name.trim().is_empty() { + return Err("name is required".into()); + } + if instruction.trim().is_empty() { + return Err("instruction is required".into()); + } + match schedule { + Schedule::EveryMinutes { minutes } => { + if *minutes < 1 { + return Err("minutes must be at least 1".into()); + } + // one week upper bound keeps scheduler math bounded + if *minutes > 60 * 24 * 7 { + return Err("minutes must be at most 10080 (one week)".into()); + } + } + Schedule::DailyAt { hour, minute } => { + if *hour >= 24 { + return Err("hour must be 0-23".into()); + } + if *minute >= 60 { + return Err("minute must be 0-59".into()); + } + } + } + Ok(()) +} + +pub fn compose_prompt(job: &CronJob) -> String { + let instruction = job.instruction.trim(); + let condition = job.condition.trim(); + if condition.is_empty() { + format!( + "[Scheduled task '{name}'] {instruction}\n\nReply with a concise message for the user.", + name = job.name + ) + } else { + format!( + "[Scheduled task '{name}'] {instruction}\n\nCondition for sending a message to the user: {condition}\n\nIf the condition is NOT satisfied, reply with exactly \"{NO_MESSAGE_SENTINEL}\" and nothing else. Otherwise, respond with a short message the user will receive.", + name = job.name + ) + } +} + +/// True when the scheduler should skip delivery for this reply (explicit sentinel only). +pub fn is_no_message_reply(text: &str) -> bool { + let t = text.trim().trim_matches(|c: char| c == '"' || c == '\''); + if t.is_empty() { + return false; + } + let t_lower = t.to_ascii_lowercase(); + t_lower == NO_MESSAGE_SENTINEL + || t_lower == "no-message" + || t_lower == "no message" + || t_lower == "" + || t_lower == "no_message" +} + +fn next_due_in_wall_clock_tz( + schedule: &Schedule, + last_run: Option>, + now: DateTime, + wall: &Tz, +) -> DateTime { + match schedule { + Schedule::EveryMinutes { minutes } => match last_run { + Some(t) => t + Duration::minutes(*minutes as i64), + None => now, + }, + Schedule::DailyAt { hour, minute } => { + let h = *hour as u32; + let m = *minute as u32; + let now_wall = now.with_timezone(wall); + let today_wall = now_wall.date_naive(); + let today_due_naive = today_wall + .and_hms_opt(h, m, 0) + .expect("valid hours from validate()"); + let today_due_wall = match wall.from_local_datetime(&today_due_naive) { + LocalResult::Single(dt) => dt, + LocalResult::Ambiguous(earliest, _) => earliest, + LocalResult::None => { + let adjusted = today_due_naive + Duration::hours(1); + wall.from_local_datetime(&adjusted) + .single() + .expect("adjusted daily_at time should exist in wall clock TZ") + } + }; + let today_due = today_due_wall.with_timezone(&Utc); + let already_ran_today = last_run.is_some_and(|t| t >= today_due); + if already_ran_today { + let next_naive = today_wall + Duration::days(1); + let next_due_naive = next_naive + .and_hms_opt(h, m, 0) + .expect("valid hours from validate()"); + let next_due_wall = match wall.from_local_datetime(&next_due_naive) { + LocalResult::Single(dt) => dt, + LocalResult::Ambiguous(earliest, _) => earliest, + LocalResult::None => { + let adjusted = next_due_naive + Duration::hours(1); + wall.from_local_datetime(&adjusted) + .single() + .expect("adjusted next daily_at should exist") + } + }; + next_due_wall.with_timezone(&Utc) + } else { + today_due + } + } + } +} + +/// Next time this schedule should fire, given the last time it ran. +pub fn next_due( + schedule: &Schedule, + last_run: Option>, + now: DateTime, +) -> DateTime { + next_due_in_wall_clock_tz(schedule, last_run, now, &Local) +} + +pub fn is_due(schedule: &Schedule, last_run: Option>, now: DateTime) -> bool { + now >= next_due(schedule, last_run, now) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn dt(y: i32, m: u32, d: u32, h: u32, mi: u32) -> DateTime { + Utc.with_ymd_and_hms(y, m, d, h, mi, 0).unwrap() + } + + /// `DailyAt` schedule uses wall-clock hour/minute in the given timezone (`Utc` in tests). + fn is_due_daily_utc( + schedule: &Schedule, + last_run: Option>, + now: DateTime, + ) -> bool { + now >= next_due_in_wall_clock_tz(schedule, last_run, now, &Utc) + } + + #[test] + fn every_minutes_fires_on_first_tick() { + let s = Schedule::EveryMinutes { minutes: 10 }; + assert!(is_due(&s, None, dt(2026, 4, 18, 12, 0))); + } + + #[test] + fn every_minutes_waits_interval_when_created_at_is_reference() { + let s = Schedule::EveryMinutes { minutes: 10 }; + let created = dt(2026, 4, 18, 12, 0); + assert!(!is_due(&s, Some(created), dt(2026, 4, 18, 12, 5))); + assert!(is_due(&s, Some(created), dt(2026, 4, 18, 12, 10))); + } + + #[test] + fn every_minutes_respects_interval() { + let s = Schedule::EveryMinutes { minutes: 10 }; + let last = dt(2026, 4, 18, 12, 0); + assert!(!is_due(&s, Some(last), dt(2026, 4, 18, 12, 5))); + assert!(is_due(&s, Some(last), dt(2026, 4, 18, 12, 10))); + } + + #[test] + fn daily_at_waits_until_target_time() { + let s = Schedule::DailyAt { hour: 9, minute: 0 }; + assert!(!is_due_daily_utc(&s, None, dt(2026, 4, 18, 8, 0))); + assert!(is_due_daily_utc(&s, None, dt(2026, 4, 18, 9, 0))); + } + + #[test] + fn daily_at_rolls_to_tomorrow_after_firing() { + let s = Schedule::DailyAt { hour: 9, minute: 0 }; + let last = dt(2026, 4, 18, 9, 0); + assert!(!is_due_daily_utc(&s, Some(last), dt(2026, 4, 18, 23, 59))); + assert!(is_due_daily_utc(&s, Some(last), dt(2026, 4, 19, 9, 0))); + } + + #[test] + fn sentinel_matches_common_phrasings() { + assert!(is_no_message_reply("")); + assert!(is_no_message_reply(" \"\" ")); + assert!(is_no_message_reply("no-message")); + assert!(is_no_message_reply("NO_MESSAGE")); + assert!(!is_no_message_reply("")); + assert!(!is_no_message_reply(" ")); + assert!(!is_no_message_reply("price is $46000")); + } + + #[test] + fn compose_prompt_with_condition_mentions_sentinel() { + let job = CronJob { + id: "x".into(), + name: "btc".into(), + instruction: "fetch bitcoin price".into(), + condition: "price above 45000".into(), + skill_slugs: vec![], + schedule: Schedule::EveryMinutes { minutes: 60 }, + enabled: true, + created_at: Utc::now(), + last_run_at: None, + }; + let out = compose_prompt(&job); + assert!(out.contains("fetch bitcoin price")); + assert!(out.contains("price above 45000")); + assert!(out.contains(NO_MESSAGE_SENTINEL)); + } + + #[test] + fn validate_rejects_zero_minutes() { + assert!(validate("n", "do thing", &Schedule::EveryMinutes { minutes: 0 }).is_err()); + } + + #[test] + fn validate_rejects_bad_hour() { + assert!(validate( + "n", + "do thing", + &Schedule::DailyAt { + hour: 24, + minute: 0 + } + ) + .is_err()); + } +} diff --git a/src-tauri/src/modules/cron/types.rs b/src-tauri/src/modules/cron/types.rs new file mode 100644 index 0000000..1b3e82b --- /dev/null +++ b/src-tauri/src/modules/cron/types.rs @@ -0,0 +1,47 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// When a cron job fires. Kept intentionally small: `EveryMinutes` covers "every 10 +/// minutes / hourly / every 6 hours", `DailyAt` covers "once a day at HH:MM" in the +/// host's local timezone (the machine running the scheduler). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Schedule { + EveryMinutes { minutes: u32 }, + DailyAt { hour: u8, minute: u8 }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CronJob { + pub id: String, + pub name: String, + pub instruction: String, + #[serde(default)] + pub condition: String, + /// If non-empty, only these skills are injected into the system prompt for this job. + /// Empty means all enabled skills (default). + #[serde(default)] + pub skill_slugs: Vec, + pub schedule: Schedule, + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default = "Utc::now")] + pub created_at: DateTime, + #[serde(default)] + pub last_run_at: Option>, +} + +fn default_true() -> bool { + true +} + +/// Root shape of `cron.json`. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct CronFile { + #[serde(default)] + pub jobs: Vec, + /// Chat id of the most recent Telegram message, used as the delivery target + /// for scheduled jobs. Persisted so restarts still have somewhere to send to. + #[serde(default)] + pub last_chat_id: Option, +} diff --git a/src-tauri/src/modules/mcp/native.rs b/src-tauri/src/modules/mcp/native.rs index 8a19c3d..d834f37 100644 --- a/src-tauri/src/modules/mcp/native.rs +++ b/src-tauri/src/modules/mcp/native.rs @@ -11,9 +11,13 @@ const MAX_SIDES: u64 = 1_000_000; /// Server key / native id used in `mcp.json` for the built-in tool manager. pub const TOOL_MANAGER_ID: &str = "tool_manager"; +/// Server key / native id used in `mcp.json` for the built-in cron manager. +pub const CRON_MANAGER_ID: &str = "cron_manager"; + enum NativeKind { Dice, ToolManager(AppState), + CronManager(AppState), } pub struct NativeProvider { @@ -30,6 +34,7 @@ impl NativeProvider { match &self.kind { NativeKind::Dice => handle_dice(tool_name, args), NativeKind::ToolManager(state) => handle_tool_manager(tool_name, args, state).await, + NativeKind::CronManager(state) => handle_cron_manager(tool_name, args, state).await, } } } @@ -342,6 +347,151 @@ async fn run_tool_mutation( Ok(()) } +// ── Cron Manager ──────────────────────────────────────────────────── + +pub fn cron_manager_named(server_key: &str, state: AppState) -> NativeProvider { + NativeProvider { + server_name: server_key.to_string(), + tools: vec![{ + let mut t = ToolDef { + server_name: server_key.to_string(), + name: "manage_crons".to_string(), + description: Some( + "Manage scheduled cron jobs the user defined in the dashboard. \ + Use action 'list' to see every job (id, name, schedule, enabled, last_run_at). \ + Use action 'enable' or 'disable' with a job_id to turn one job on or off. \ + This tool never creates or deletes jobs — the user does that in the dashboard. \ + Call it when the user asks to list, pause, resume, stop, or start a scheduled task." + .to_string(), + ), + input_schema: json!({ + "type": "object", + "required": ["action"], + "properties": { + "action": { + "type": "string", + "enum": ["list", "enable", "disable"], + "description": "'list' returns every job; 'enable'/'disable' toggle one job" + }, + "job_id": { + "type": "string", + "description": "Required for 'enable' and 'disable'. Use the exact id from the 'list' output." + } + } + }), + direct_return: false, + category: None, + risk: super::types::ToolRisk::Low, + }; + super::tool_metadata::apply(&mut t); + t + }], + kind: NativeKind::CronManager(state), + } +} + +async fn handle_cron_manager( + _tool_name: &str, + args: &Value, + state: &AppState, +) -> Result { + let action = args + .get("action") + .and_then(|v| v.as_str()) + .ok_or("missing 'action' parameter")?; + + match action { + "list" => Ok(format_cron_list(state).await), + "enable" => { + let job_id = args + .get("job_id") + .and_then(|v| v.as_str()) + .ok_or("missing 'job_id' for enable")?; + set_cron_enabled(state, job_id, true).await + } + "disable" => { + let job_id = args + .get("job_id") + .and_then(|v| v.as_str()) + .ok_or("missing 'job_id' for disable")?; + set_cron_enabled(state, job_id, false).await + } + _ => Err(format!("unknown action: {action}")), + } +} + +async fn format_cron_list(state: &AppState) -> String { + let jobs = state.cron_jobs.read().await.clone(); + if jobs.is_empty() { + return "No cron jobs configured. Add one from the Dashboard → Cron Jobs panel." + .to_string(); + } + let mut lines = Vec::with_capacity(jobs.len()); + for j in &jobs { + let schedule = match &j.schedule { + crate::modules::cron::types::Schedule::EveryMinutes { minutes } => { + format!("every {minutes} min") + } + crate::modules::cron::types::Schedule::DailyAt { hour, minute } => { + format!("daily at {hour:02}:{minute:02} (local)") + } + }; + let last = j + .last_run_at + .map(|t| t.format("%Y-%m-%d %H:%M UTC").to_string()) + .unwrap_or_else(|| "never".to_string()); + let status = if j.enabled { "enabled" } else { "disabled" }; + let skills = if j.skill_slugs.is_empty() { + String::new() + } else { + format!(" — skills: {}", j.skill_slugs.join(", ")) + }; + lines.push(format!( + "- {name} (id: {id}, {schedule}) [{status}] — last_run: {last}{skills}", + name = j.name, + id = j.id, + )); + } + format!("Cron jobs:\n{}", lines.join("\n")) +} + +async fn set_cron_enabled(state: &AppState, job_id: &str, enabled: bool) -> Result { + let _save_guard = state.cron_save_mutex.lock().await; + let updated = { + let mut jobs = state.cron_jobs.write().await; + let Some(job) = jobs.iter_mut().find(|j| j.id == job_id) else { + return Err(format!("unknown job_id: {job_id}")); + }; + if job.enabled == enabled { + let verb = if enabled { "enabled" } else { "disabled" }; + return Ok(format!("Job '{}' is already {verb}.", job.name)); + } + job.enabled = enabled; + job.clone() + }; + let snapshot = state.cron_jobs.read().await.clone(); + let last_chat_id = *state.last_chat_id.read().await; + let file = crate::modules::cron::types::CronFile { + jobs: snapshot, + last_chat_id, + }; + let path = state.cron_path.clone(); + let save_result = + tokio::task::spawn_blocking(move || crate::modules::cron::repository::save(&path, &file)) + .await + .map_err(|e| format!("cron save task: {e}"))?; + if let Err(e) = save_result { + let mut jobs = state.cron_jobs.write().await; + if let Some(j) = jobs.iter_mut().find(|j| j.id == job_id) { + j.enabled = !enabled; + } + return Err(e); + } + state.cron_notify.notify_waiters(); + let verb = if enabled { "enabled" } else { "disabled" }; + Ok(format!("Job '{}' {verb}.", updated.name)) +} + // ── Registry ──────────────────────────────────────────────────────── /// Resolve `id` from `mcp.json` (`type: native`) into a provider under `server_key`. @@ -357,6 +507,10 @@ pub fn native_for( let state = app_state.ok_or_else(|| format!("{TOOL_MANAGER_ID} requires AppState"))?; Ok(tool_manager_named(server_key, state.clone())) } + CRON_MANAGER_ID => { + let state = app_state.ok_or_else(|| format!("{CRON_MANAGER_ID} requires AppState"))?; + Ok(cron_manager_named(server_key, state.clone())) + } _ => Err(format!("unknown native id: {id}")), } } diff --git a/src-tauri/src/modules/mcp/service.rs b/src-tauri/src/modules/mcp/service.rs index a23e645..8c748de 100644 --- a/src-tauri/src/modules/mcp/service.rs +++ b/src-tauri/src/modules/mcp/service.rs @@ -269,6 +269,10 @@ fn default_config_value() -> serde_json::Value { "tool_manager": { "type": "native", "id": "tool_manager" + }, + "cron_manager": { + "type": "native", + "id": "cron_manager" } } }) @@ -526,18 +530,19 @@ pub async fn rebuild_registry_into_state( } } - // Ensure tool_manager is always present (auto-add for existing configs). - if !cfg.servers.contains_key(native::TOOL_MANAGER_ID) { - cfg.servers.insert( - native::TOOL_MANAGER_ID.to_string(), - ServerEntry::Native { - id: native::TOOL_MANAGER_ID.to_string(), - }, - ); + // Ensure built-in native tools are always present (auto-add for existing configs). + let mut inserted_any = false; + for id in [native::TOOL_MANAGER_ID, native::CRON_MANAGER_ID] { + if !cfg.servers.contains_key(id) { + cfg.servers + .insert(id.to_string(), ServerEntry::Native { id: id.to_string() }); + inserted_any = true; + } + } + if inserted_any { if let Err(e) = save_config(&state.mcp_config_path, &cfg) { log::warn!( - "failed to save mcp.json after auto-inserting native server {:?}: {} (path={})", - native::TOOL_MANAGER_ID, + "failed to save mcp.json after auto-inserting native servers: {} (path={})", e, state.mcp_config_path.display() ); diff --git a/src-tauri/src/modules/mod.rs b/src-tauri/src/modules/mod.rs index b994517..9aaa732 100644 --- a/src-tauri/src/modules/mod.rs +++ b/src-tauri/src/modules/mod.rs @@ -1,4 +1,5 @@ pub mod bot; +pub mod cron; pub mod keywords; pub mod mcp; pub mod memory; diff --git a/src-tauri/src/modules/skills/service.rs b/src-tauri/src/modules/skills/service.rs index 3e4625f..d3972e6 100644 --- a/src-tauri/src/modules/skills/service.rs +++ b/src-tauri/src/modules/skills/service.rs @@ -30,6 +30,107 @@ const MAX_ZIP_BYTES: usize = 1024 * 1024; /// Disabled-slug registry lives next to the custom skills dir. const DISABLED_FILE: &str = ".disabled.json"; +/// Dashboard drag-and-drop order for the Skills list (also system-prompt hint order). +const SKILL_ORDER_FILE: &str = ".skill_order.json"; + +fn skill_order_path(store_path: &Path) -> PathBuf { + custom_skills_dir(store_path).join(SKILL_ORDER_FILE) +} + +fn read_skill_order_slugs(store_path: &Path) -> Vec { + let path = skill_order_path(store_path); + let Ok(raw) = std::fs::read_to_string(&path) else { + return Vec::new(); + }; + serde_json::from_str::>(&raw).unwrap_or_default() +} + +fn apply_user_skill_order(skills: &mut [Skill], store_path: &Path) { + let order = read_skill_order_slugs(store_path); + if order.is_empty() { + return; + } + let order_index: HashMap = order + .iter() + .enumerate() + .map(|(i, s)| (s.to_lowercase(), i)) + .collect(); + skills.sort_by(|a, b| { + let ia = order_index.get(&a.slug.to_lowercase()).copied(); + let ib = order_index.get(&b.slug.to_lowercase()).copied(); + match (ia, ib) { + (Some(i), Some(j)) => i.cmp(&j), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => a.slug.cmp(&b.slug), + } + }); +} + +/// Bundled + custom skills, alphabetically sorted — does not apply `.skill_order.json`. +pub(crate) fn gather_skills_sorted(store_path: &Path) -> Vec { + let disabled = read_disabled_set(store_path); + let mut out: Vec = Vec::new(); + + if let Some(dir) = bundled_skills_dir() { + out.extend(read_dir_skills(&dir, SkillOrigin::Bundled)); + } + + let custom = custom_skills_dir(store_path); + if custom.is_dir() { + for skill in read_dir_skills(&custom, SkillOrigin::Custom) { + if let Some(i) = out.iter().position(|s| s.slug == skill.slug) { + out.remove(i); + } + out.push(skill); + } + } + + for skill in &mut out { + skill.enabled = !disabled.contains(&skill.slug); + } + + out.sort_by(|a, b| a.slug.cmp(&b.slug)); + out +} + +/// Persists dashboard order; merges skills missing from `requested` at the end (A–Z). +pub fn set_skill_slug_order(store_path: &Path, requested: &[String]) -> Result<(), String> { + let skills = gather_skills_sorted(store_path); + let by_lower: HashMap = skills + .iter() + .map(|s| (s.slug.to_lowercase(), s.slug.clone())) + .collect(); + let mut seen = HashSet::::new(); + let mut out: Vec = Vec::new(); + for r in requested { + let t = r.trim(); + if t.is_empty() { + continue; + } + let k = t.to_lowercase(); + if let Some(canon) = by_lower.get(&k) { + if seen.insert(k) { + out.push(canon.clone()); + } + } + } + let mut missing: Vec = skills + .iter() + .filter(|s| !seen.contains(&s.slug.to_lowercase())) + .map(|s| s.slug.clone()) + .collect(); + missing.sort(); + out.extend(missing); + + let dir = custom_skills_dir(store_path); + std::fs::create_dir_all(&dir).map_err(|e| format!("create skills dir: {e}"))?; + let json = + serde_json::to_string_pretty(&out).map_err(|e| format!("encode skill order: {e}"))?; + std::fs::write(skill_order_path(store_path), json) + .map_err(|e| format!("write {}: {e}", SKILL_ORDER_FILE)) +} + /// `$APP_DATA/skills/`. Created on demand. pub fn custom_skills_dir(store_path: &Path) -> PathBuf { store_path @@ -120,21 +221,169 @@ const SKILL_MANDATORY_HINT_CAP: usize = 1200; const SKILL_HINT_INTRO: &str = "\n\nSkills: follow each recipe exactly — \ it lists WHICH URL and HOW MANY calls. Stop when you can answer; \ don't probe alternate hosts. Unless a skill’s **`mandatory.md`** says otherwise, prefer **`fetch`** whenever you have a concrete URL; use **`brave_web_search`** when the recipe lists it in `requires` (and this turn matches that skill), when **`mandatory.md`** orders it, or when the user explicitly asked to search the open web.\n\ +**Weather, forecasts, temperature, precipitation:** use **skill:weather** (wttr.in / Open-Meteo) as the only recipe — never government-portal or “.gv.at” skills for those topics.\n\ Portal- or government-specific skills you install yourself apply **only** when the user is clearly asking about that jurisdiction’s government, law, official forms, or public administration — \ -not for recipes, hobbies, general knowledge, software, or unrelated chit-chat. If the topic does not match the skill’s scope, ignore that recipe entirely."; +not for recipes, hobbies, general knowledge, software, weather, or unrelated chit-chat. If the topic does not match the skill’s scope, ignore that recipe entirely."; + +/// True when the user (or cron) message is clearly about weather / forecast. +pub fn user_message_suggests_weather(user_message: &str) -> bool { + const NEEDLES: &[&str] = &[ + "wetter", + "weather", + "forecast", + "vorhersage", + "regenwahrscheinlichkeit", + "temperatur", + "gewitter", + "schnee", + "hagel", + "wind", + "niederschlag", + "wttr", + "bewölkt", + "bewoelkt", + "regen", + "luftdruck", + "hitze", + "kühl", + "kuehl", + "eisregen", + ]; + NEEDLES + .iter() + .any(|n| user_message_needle_match(user_message, n)) +} + +/// Default “only when talking about AT public administration” needles for known portal skill slugs. +pub fn default_hint_needles_for_slug(slug: &str) -> Option<&'static [&'static str]> { + let s = slug.to_lowercase(); + let portal = s == "austria-gv-data" + || s == "austrian-gv" + || s == "austrian-gv-data" + || s.contains("austria-gv") + || s.contains("austrian-gv") + || s.contains("oesterreich-gv") + || (s.contains("oesterreich") && s.contains("gv")) + || (s.contains("austria") && s.contains("gv") && s.contains("data")); + if !portal { + return None; + } + Some(&[ + "oesterreich.gv", + ".gv.at", + "oesterreich", + "bundesrecht", + "verwaltung", + "behörde", + "behoerde", + "formular", + "bürgerservice", + "buergerservice", + "e-government", + "egov", + "ministerium", + "amt", + "landesregierung", + "gemeinde", + "bescheid", + "verordnung", + "österreich", + ]) +} + +/// Whether `skill` may appear in the skills system-prompt fragment for this turn. +/// `cron_pins_skills` is true when the caller already restricted to an explicit slug list (cron). +fn skill_passes_hint_gate( + skill: &Skill, + user_message: Option<&str>, + cron_pins_skills: bool, +) -> bool { + if cron_pins_skills { + return true; + } + if !skill.hint_allow_substrings.is_empty() { + return match user_message { + None => true, + Some(m) if m.trim().is_empty() => true, + Some(m) => skill + .hint_allow_substrings + .iter() + .any(|n| user_message_needle_match(m, n)), + }; + } + if let Some(needles) = default_hint_needles_for_slug(&skill.slug) { + return match user_message { + None => true, + Some(m) if m.trim().is_empty() => true, + Some(m) => needles.iter().any(|n| user_message_needle_match(m, n)), + }; + } + true +} /// Build a system-prompt fragment describing the enabled skills so the agent /// knows when/how to invoke fetch tools for each. Returns `""` if there are /// none enabled. -pub fn skills_prompt_hint(store_path: &Path) -> String { - let skills: Vec = list_skills(store_path) +/// +/// When `user_message` is [`Some`] and matches [`user_message_suggests_weather`], the +/// **weather** skill block is moved to the top so it survives aggressive byte caps +/// and is not buried after alphabetically earlier skills (e.g. government portals). +/// +/// When `slug_filter` is [`Some`] and non-empty, only those enabled skills are included +/// (e.g. cron jobs with a pinned skill list). +/// Note: `slug_filter == Some(&[])` is intentionally treated like `None` (no filter); pass +/// `Some(non_empty_slice)` to pin skills or `None` to disable filtering. +pub fn skills_prompt_hint_for_turn( + store_path: &Path, + user_message: Option<&str>, + slug_filter: Option<&[String]>, +) -> String { + let mut skills: Vec = list_skills(store_path) .into_iter() .filter(|s| s.enabled) .collect(); + let filtered_run = if let Some(want) = slug_filter { + if want.is_empty() { + false + } else { + let set: HashSet = want.iter().map(|s| s.to_lowercase()).collect(); + skills.retain(|s| set.contains(&s.slug.to_lowercase())); + let pos: HashMap = want + .iter() + .enumerate() + .map(|(i, s)| (s.to_lowercase(), i)) + .collect(); + skills.sort_by_key(|s| { + pos.get(&s.slug.to_lowercase()) + .copied() + .unwrap_or(usize::MAX) + }); + true + } + } else { + false + }; + if !filtered_run { + skills.retain(|s| skill_passes_hint_gate(s, user_message, false)); + } if skills.is_empty() { return String::new(); } + if let Some(msg) = user_message { + if !filtered_run && user_message_suggests_weather(msg) { + let (mut w, rest): (Vec, Vec) = skills + .into_iter() + .partition(|s| s.slug.eq_ignore_ascii_case("weather")); + w.sort_by(|a, b| a.slug.cmp(&b.slug)); + let mut rest = rest; + rest.sort_by(|a, b| a.slug.cmp(&b.slug)); + skills = w.into_iter().chain(rest).collect(); + } + } let mut out = String::from(SKILL_HINT_INTRO); + if filtered_run { + out.push_str("\n\n**(This scheduled run)** Use only the skills listed below; ignore other installed skills for this task."); + } for s in &skills { let trimmed = s.body.trim(); let body = truncate_for_prompt(trimmed, SKILL_HINT_BODY_CAP); @@ -156,6 +405,35 @@ pub fn skills_prompt_hint(store_path: &Path) -> String { out } +/// Same as [`skills_prompt_hint_for_turn`] without per-turn ordering (tests, callers without context). +pub fn skills_prompt_hint(store_path: &Path) -> String { + skills_prompt_hint_for_turn(store_path, None, None) +} + +/// Deduplicate `requested` and keep only slugs that exist on disk (bundled or custom). +/// Preserves first-seen order using canonical slug spelling from on-disk skills. +pub fn canonicalize_skill_slug_list(store_path: &Path, requested: &[String]) -> Vec { + let by_lower: HashMap = gather_skills_sorted(store_path) + .into_iter() + .map(|s| (s.slug.to_lowercase(), s.slug)) + .collect(); + let mut seen = HashSet::::new(); + let mut out = Vec::new(); + for r in requested { + let t = r.trim(); + if t.is_empty() { + continue; + } + let key = t.to_lowercase(); + if let Some(canonical) = by_lower.get(&key).cloned() { + if seen.insert(key) { + out.push(canonical); + } + } + } + out +} + /// If the skills hint exceeds `max` bytes, truncate with the same rules as per-skill bodies. pub fn limit_skills_hint_bytes(s: String, max: usize) -> (String, bool) { if s.len() <= max { @@ -183,30 +461,11 @@ fn truncate_for_prompt(s: &str, max: usize) -> String { } /// List every discoverable skill. Custom skills shadow bundled ones with the same slug. +/// Order follows the Skills dashboard (`.skill_order.json` under the custom skills dir). pub fn list_skills(store_path: &Path) -> Vec { - let disabled = read_disabled_set(store_path); - let mut out: Vec = Vec::new(); - - if let Some(dir) = bundled_skills_dir() { - out.extend(read_dir_skills(&dir, SkillOrigin::Bundled)); - } - - let custom = custom_skills_dir(store_path); - if custom.is_dir() { - for skill in read_dir_skills(&custom, SkillOrigin::Custom) { - if let Some(i) = out.iter().position(|s| s.slug == skill.slug) { - out.remove(i); - } - out.push(skill); - } - } - - for skill in &mut out { - skill.enabled = !disabled.contains(&skill.slug); - } - - out.sort_by(|a, b| a.slug.cmp(&b.slug)); - out + let mut v = gather_skills_sorted(store_path); + apply_user_skill_order(&mut v, store_path); + v } fn read_dir_skills(dir: &Path, origin: SkillOrigin) -> Vec { @@ -280,6 +539,7 @@ pub fn parse_skill(slug: &str, raw: &str, origin: SkillOrigin) -> Result bool { { return false; } + if !skill_passes_hint_gate(skill, Some(user_message), false) { + return false; + } let u = user_message.to_lowercase(); let u_fold = german_ascii_fold(&u); for sub in &skill.brave_allow_substrings { @@ -1038,4 +1301,132 @@ mod tests { let s = list.iter().find(|s| s.slug == "y").unwrap(); assert_eq!(s.mandatory_markdown.as_deref(), Some("first")); } + + #[test] + fn user_message_suggests_weather_german_and_not_admin() { + assert!(user_message_suggests_weather( + "wie ist das wetter in der Breitenau" + )); + assert!(!user_message_suggests_weather( + "Formular auf oesterreich.gv.at runterladen" + )); + } + + #[test] + fn skills_hint_orders_weather_before_alphabetically_earlier_slugs() { + let tmp = tempdir().unwrap(); + let fake_store = tmp.path().join("connection.json"); + let other = "---\nname: AAA\ndescription: o\ntags: []\n---\n\naaa\n"; + let weather_md = "---\nname: weather\ndescription: w\ntags: []\n---\n\nww\n"; + write_custom_skill(&fake_store, "arch-other", other, None).unwrap(); + write_custom_skill(&fake_store, "weather", weather_md, None).unwrap(); + let hint = skills_prompt_hint_for_turn(&fake_store, Some("Wetter in Wien"), None); + let pos_w = hint.find("── skill:weather").expect("weather block"); + let pos_a = hint.find("── skill:arch-other").expect("arch-other block"); + assert!( + pos_w < pos_a, + "weather should precede alphabetically earlier slugs:\n{hint}" + ); + } + + #[test] + fn skills_hint_respects_slug_filter() { + let tmp = tempdir().unwrap(); + let fake_store = tmp.path().join("connection.json"); + let a = "---\nname: A\ndescription: a\ntags: []\n---\n\nAAA\n"; + let b = "---\nname: B\ndescription: b\ntags: []\n---\n\nBBB\n"; + write_custom_skill(&fake_store, "skill-a", a, None).unwrap(); + write_custom_skill(&fake_store, "skill-b", b, None).unwrap(); + let filter = vec!["skill-b".to_string()]; + let hint = skills_prompt_hint_for_turn(&fake_store, None, Some(&filter)); + assert!(hint.contains("BBB"), "hint={hint}"); + assert!(!hint.contains("AAA"), "hint={hint}"); + assert!( + hint.contains("This scheduled run"), + "expected cron banner when filtered:\n{hint}" + ); + } + + #[test] + fn canonicalize_skill_slug_list_dedupes_and_matches_case() { + let tmp = tempdir().unwrap(); + let fake_store = tmp.path().join("connection.json"); + let md = "---\nname: Z\ndescription: d\ntags: []\n---\n\nz\n"; + write_custom_skill(&fake_store, "my_skill", md, None).unwrap(); + let out = canonicalize_skill_slug_list( + &fake_store, + &["MY_SKILL".into(), "nope".into(), "my_skill".into()], + ); + assert_eq!(out, vec!["my_skill"]); + } + + #[test] + fn portal_skill_hint_gated_without_admin_keywords() { + let tmp = tempdir().unwrap(); + let fake_store = tmp.path().join("connection.json"); + let gv = "---\nname: G\ndescription: d\ntags: []\n---\n\nGVONLY\n"; + write_custom_skill(&fake_store, "austria-gv-data", gv, None).unwrap(); + let hint = skills_prompt_hint_for_turn(&fake_store, Some("wie ist das wetter"), None); + assert!(!hint.contains("GVONLY"), "hint={hint}"); + let hint2 = + skills_prompt_hint_for_turn(&fake_store, Some("Formular auf oesterreich.gv.at"), None); + assert!(hint2.contains("GVONLY"), "hint={hint2}"); + } + + #[test] + fn cron_slug_filter_includes_portal_skill_without_keywords() { + let tmp = tempdir().unwrap(); + let fake_store = tmp.path().join("connection.json"); + let gv = "---\nname: G\ndescription: d\ntags: []\n---\n\nGVONLY\n"; + write_custom_skill(&fake_store, "austria-gv-data", gv, None).unwrap(); + let f = vec!["austria-gv-data".to_string()]; + let hint = skills_prompt_hint_for_turn(&fake_store, Some("weather only"), Some(&f)); + assert!(hint.contains("GVONLY"), "hint={hint}"); + } + + #[test] + fn skills_hint_follows_slug_filter_order() { + let tmp = tempdir().unwrap(); + let fake_store = tmp.path().join("connection.json"); + let a = "---\nname: A\ndescription: d\ntags: []\n---\n\nFIRST\n"; + let b = "---\nname: B\ndescription: d\ntags: []\n---\n\nSECOND\n"; + write_custom_skill(&fake_store, "skill-a", a, None).unwrap(); + write_custom_skill(&fake_store, "skill-b", b, None).unwrap(); + let order = vec!["skill-b".into(), "skill-a".into()]; + let hint = skills_prompt_hint_for_turn(&fake_store, None, Some(&order)); + let p_first = hint.find("FIRST").expect("FIRST"); + let p_second = hint.find("SECOND").expect("SECOND"); + assert!( + p_second < p_first, + "expected skill-b (SECOND) before skill-a (FIRST):\n{hint}" + ); + } + + #[test] + fn hint_allow_substrings_in_frontmatter_gates_skill() { + let tmp = tempdir().unwrap(); + let fake_store = tmp.path().join("connection.json"); + let md = "---\nname: X\ndescription: d\ntags: []\nhint_allow_substrings: [zebra]\n---\n\nZ_BODY\n"; + write_custom_skill(&fake_store, "gated-x", md, None).unwrap(); + assert!(!skills_prompt_hint_for_turn(&fake_store, Some("hello"), None).contains("Z_BODY")); + assert!( + skills_prompt_hint_for_turn(&fake_store, Some("zebra facts"), None).contains("Z_BODY") + ); + } + + #[test] + fn skill_order_file_changes_list_order() { + let tmp = tempdir().unwrap(); + let fake_store = tmp.path().join("connection.json"); + let a = "---\nname: A\ndescription: d\ntags: []\n---\n\na\n"; + let b = "---\nname: B\ndescription: d\ntags: []\n---\n\nb\n"; + write_custom_skill(&fake_store, "skill-a", a, None).unwrap(); + write_custom_skill(&fake_store, "skill-b", b, None).unwrap(); + let alpha = list_skills(&fake_store); + assert_eq!(alpha[0].slug, "skill-a"); + set_skill_slug_order(&fake_store, &["skill-b".into(), "skill-a".into()]).unwrap(); + let re = list_skills(&fake_store); + assert_eq!(re[0].slug, "skill-b"); + assert_eq!(re[1].slug, "skill-a"); + } } diff --git a/src-tauri/src/modules/skills/types.rs b/src-tauri/src/modules/skills/types.rs index 0338780..a37866b 100644 --- a/src-tauri/src/modules/skills/types.rs +++ b/src-tauri/src/modules/skills/types.rs @@ -37,6 +37,12 @@ pub struct Skill { /// `tests/skills_brave_gate.rs`). #[serde(default)] pub brave_allow_substrings: Vec, + /// If non-empty, this skill is only injected into the system prompt when the user (or cron) + /// message matches at least one substring (same matching rules as `brave_allow_substrings`). + /// Cron jobs that **pin** skills ignore this gate. Slugs matching Austria’s `*.gv.at` portal + /// skills get a built-in default keyword list when this is empty. + #[serde(default)] + pub hint_allow_substrings: Vec, #[serde(default)] pub origin: SkillOrigin, /// Optional extra rules from `mandatory.md` next to `SKILL.md` (also returned for custom skills in the API). diff --git a/src-tauri/src/shared/state.rs b/src-tauri/src/shared/state.rs index f9cba00..d137fe5 100644 --- a/src-tauri/src/shared/state.rs +++ b/src-tauri/src/shared/state.rs @@ -1,8 +1,10 @@ +use crate::modules::cron::types::CronJob; use crate::modules::mcp::registry::ToolRegistry; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::fmt; use std::path::PathBuf; +use std::sync::atomic::AtomicBool; use std::sync::Arc; use tauri::Emitter; use tokio::sync::{Mutex, Notify, RwLock}; @@ -90,11 +92,24 @@ pub struct AppState { pub tool_ctx_latency_ms: Arc>>, /// Max UTF-8 bytes for the combined skills system-prompt fragment (dashboard / `user_settings.json`). pub skills_hint_max_bytes: Arc>, + /// `$APP_DATA/cron.json` — scheduled jobs + last-known Telegram chat id. + pub cron_path: PathBuf, + pub cron_jobs: Arc>>, + /// Wake the scheduler immediately after CRUD / enable / test operations. + pub cron_notify: Arc, + /// Last Telegram chat id that sent a message to the bot. Scheduled cron jobs + /// push their replies here. Persisted inside `cron.json`. + pub last_chat_id: Arc>>, + /// Rate-limit scheduler logs when jobs are due but `last_chat_id` is still unknown. + pub cron_no_chat_warned: Arc, + /// Serializes `cron.json` snapshots + disk writes with HTTP / scheduler / MCP callers. + pub cron_save_mutex: Arc>, } impl AppState { pub fn new(store_path: PathBuf, mcp_config_path: PathBuf, mcp_config_source: String) -> Self { let skills_cap = crate::shared::user_settings::load_skills_hint_max_bytes(&store_path); + let cron_path = crate::modules::cron::repository::cron_path(&store_path); let (log_tx, _) = tokio::sync::broadcast::channel(256); Self { connection: Arc::new(Mutex::new(None)), @@ -115,6 +130,12 @@ impl AppState { recent_tool_names: Arc::new(Mutex::new(Vec::new())), tool_ctx_latency_ms: Arc::new(Mutex::new(Vec::new())), skills_hint_max_bytes: Arc::new(RwLock::new(skills_cap)), + cron_path, + cron_jobs: Arc::new(RwLock::new(Vec::new())), + cron_notify: Arc::new(Notify::new()), + last_chat_id: Arc::new(RwLock::new(None)), + cron_no_chat_warned: Arc::new(AtomicBool::new(false)), + cron_save_mutex: Arc::new(Mutex::new(())), } } diff --git a/src-tauri/tauri.macos.conf.json b/src-tauri/tauri.macos.conf.json new file mode 100644 index 0000000..cbc1532 --- /dev/null +++ b/src-tauri/tauri.macos.conf.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "build": { + "beforeBundleCommand": "rm -f target/release/bundle/macos/*.dmg target/release/bundle/dmg/*.dmg" + } +} diff --git a/src-tauri/tests/skills_brave_gate.rs b/src-tauri/tests/skills_brave_gate.rs index a059ee0..0818aa6 100644 --- a/src-tauri/tests/skills_brave_gate.rs +++ b/src-tauri/tests/skills_brave_gate.rs @@ -44,3 +44,16 @@ fn brave_not_enabled_by_generic_news_tag() { "gameinformer news" )); } + +#[test] +fn brave_blocked_for_portal_skill_slug_without_admin_keywords() { + let tmp = tempdir().unwrap(); + let store = tmp.path().join("connection.json"); + let md = "---\nname: t\ndescription: d\ntags: []\nrequires: [brave_web_search]\nbrave_allow_substrings: [oesterreich]\n---\n\nbody\n"; + write_custom_skill(&store, "austria-gv-data", md, None).unwrap(); + assert!(!allow_brave_web_search_for_message(&store, "hello random")); + assert!(allow_brave_web_search_for_message( + &store, + "Infos von oesterreich.gv" + )); +} diff --git a/src/modules/cron/api/index.ts b/src/modules/cron/api/index.ts new file mode 100644 index 0000000..aa7eb4b --- /dev/null +++ b/src/modules/cron/api/index.ts @@ -0,0 +1,137 @@ +import { fetchErrorMessage, PENGINE_API_BASE } from "../../../shared/api/config"; +import type { CronDraft, CronJob, CronListResponse, CronTestResponse } from "../types"; + +function makeTimeoutSignal(timeoutMs: number): { signal: AbortSignal; cleanup: () => void } { + if (typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function") { + return { signal: AbortSignal.timeout(timeoutMs), cleanup: () => {} }; + } + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + return { signal: controller.signal, cleanup: () => clearTimeout(timer) }; +} + +async function parseApiError(resp: Response): Promise { + const raw = await resp.text(); + try { + const body = JSON.parse(raw) as { error?: string }; + return body.error?.trim() || raw.trim() || `HTTP ${resp.status}`; + } catch { + return raw.trim() || `HTTP ${resp.status}`; + } +} + +export async function fetchCronJobs(timeoutMs = 5000): Promise { + const { signal, cleanup } = makeTimeoutSignal(timeoutMs); + try { + const resp = await fetch(`${PENGINE_API_BASE}/v1/cron`, { signal }); + if (!resp.ok) return null; + return (await resp.json()) as CronListResponse; + } catch { + return null; + } finally { + cleanup(); + } +} + +export async function createCronJob( + draft: CronDraft, + timeoutMs = 5000, +): Promise<{ ok: boolean; job?: CronJob; error?: string }> { + const { signal, cleanup } = makeTimeoutSignal(timeoutMs); + try { + const resp = await fetch(`${PENGINE_API_BASE}/v1/cron`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(draft), + signal, + }); + if (resp.ok) return { ok: true, job: (await resp.json()) as CronJob }; + return { ok: false, error: await parseApiError(resp) }; + } catch (e) { + return { ok: false, error: fetchErrorMessage(e) }; + } finally { + cleanup(); + } +} + +export async function updateCronJob( + id: string, + draft: CronDraft, + timeoutMs = 5000, +): Promise<{ ok: boolean; job?: CronJob; error?: string }> { + const { signal, cleanup } = makeTimeoutSignal(timeoutMs); + try { + const resp = await fetch(`${PENGINE_API_BASE}/v1/cron/${encodeURIComponent(id)}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(draft), + signal, + }); + if (resp.ok) return { ok: true, job: (await resp.json()) as CronJob }; + return { ok: false, error: await parseApiError(resp) }; + } catch (e) { + return { ok: false, error: fetchErrorMessage(e) }; + } finally { + cleanup(); + } +} + +export async function deleteCronJob( + id: string, + timeoutMs = 5000, +): Promise<{ ok: boolean; error?: string }> { + const { signal, cleanup } = makeTimeoutSignal(timeoutMs); + try { + const resp = await fetch(`${PENGINE_API_BASE}/v1/cron/${encodeURIComponent(id)}`, { + method: "DELETE", + signal, + }); + if (resp.ok) return { ok: true }; + return { ok: false, error: await parseApiError(resp) }; + } catch (e) { + return { ok: false, error: fetchErrorMessage(e) }; + } finally { + cleanup(); + } +} + +export async function setCronJobEnabled( + id: string, + enabled: boolean, + timeoutMs = 5000, +): Promise<{ ok: boolean; error?: string }> { + const { signal, cleanup } = makeTimeoutSignal(timeoutMs); + try { + const resp = await fetch(`${PENGINE_API_BASE}/v1/cron/${encodeURIComponent(id)}/enabled`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled }), + signal, + }); + if (resp.ok) return { ok: true }; + return { ok: false, error: await parseApiError(resp) }; + } catch (e) { + return { ok: false, error: fetchErrorMessage(e) }; + } finally { + cleanup(); + } +} + +export async function testCronJob( + id: string, + timeoutMs = 120_000, +): Promise<{ ok: boolean; result?: CronTestResponse; error?: string }> { + const { signal, cleanup } = makeTimeoutSignal(timeoutMs); + try { + const resp = await fetch(`${PENGINE_API_BASE}/v1/cron/${encodeURIComponent(id)}/test`, { + method: "POST", + signal, + }); + if (resp.ok) return { ok: true, result: (await resp.json()) as CronTestResponse }; + return { ok: false, error: await parseApiError(resp) }; + } catch (e) { + return { ok: false, error: fetchErrorMessage(e) }; + } finally { + cleanup(); + } +} diff --git a/src/modules/cron/components/CronDailyLocalTimePicker.tsx b/src/modules/cron/components/CronDailyLocalTimePicker.tsx new file mode 100644 index 0000000..f4b549a --- /dev/null +++ b/src/modules/cron/components/CronDailyLocalTimePicker.tsx @@ -0,0 +1,117 @@ +import * as Select from "@radix-ui/react-select"; +import { useMemo } from "react"; + +const HOURS = Array.from({ length: 24 }, (_, i) => i); +const MINUTES = Array.from({ length: 60 }, (_, i) => i); + +const triggerClass = + "inline-flex h-8 w-[4.25rem] shrink-0 items-center justify-between gap-1 rounded-md border border-white/10 bg-black/30 px-2 font-mono text-[11px] text-white outline-none focus:border-cyan-300/40 data-[state=open]:border-cyan-300/40"; + +const contentClass = + "z-[100] max-h-52 overflow-hidden rounded-md border border-white/12 bg-[#1a1a22] shadow-xl"; + +const viewportClass = "max-h-52 p-0.5"; + +const itemClass = + "relative flex cursor-pointer select-none items-center rounded px-2 py-1.5 font-mono text-[11px] text-white/90 outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-40 data-[highlighted]:bg-cyan-300/15 data-[highlighted]:text-cyan-100"; + +function pad2(n: number): string { + return String(n).padStart(2, "0"); +} + +function SelectChevron() { + return ( + + {"\u25BC"} + + ); +} + +export type CronDailyLocalTimePickerProps = { + hour: number; + minute: number; + onChange: (hour: number, minute: number) => void; + disabled?: boolean; +}; + +export function CronDailyLocalTimePicker({ + hour, + minute, + onChange, + disabled, +}: CronDailyLocalTimePickerProps) { + const timeZone = useMemo(() => { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone; + } catch { + return "local"; + } + }, []); + + return ( +
+
+ { + const h = Number.parseInt(v, 10); + if (Number.isInteger(h)) onChange(h, minute); + }} + disabled={disabled} + > + + + + + + + + + + {HOURS.map((h) => ( + + {pad2(h)} + + ))} + + + + + + : + + { + const m = Number.parseInt(v, 10); + if (Number.isInteger(m)) onChange(hour, m); + }} + disabled={disabled} + > + + + + + + + + + + {MINUTES.map((m) => ( + + {pad2(m)} + + ))} + + + + + + {timeZone} +
+

+ Runs once per day at this clock time on this device (same timezone as the system clock). +

+
+ ); +} diff --git a/src/modules/cron/components/CronFormPinnedSkills.tsx b/src/modules/cron/components/CronFormPinnedSkills.tsx new file mode 100644 index 0000000..681e024 --- /dev/null +++ b/src/modules/cron/components/CronFormPinnedSkills.tsx @@ -0,0 +1,191 @@ +import { + closestCenter, + defaultDropAnimation, + DndContext, + DragOverlay, + type DragEndEvent, + type DragStartEvent, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { restrictToVerticalAxis } from "@dnd-kit/modifiers"; +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { useCallback, useState } from "react"; +import type { Skill } from "../../skills/types"; + +type SortableCronSkillSlugRowProps = { + slug: string; + metaName: string; + onRemove: () => void; +}; + +function SortableCronSkillSlugRow({ slug, metaName, onRemove }: SortableCronSkillSlugRowProps) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: slug, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + ...(isDragging ? { opacity: 0, pointerEvents: "none" as const } : {}), + }; + + return ( +
+ + {slug} + {metaName} + +
+ ); +} + +export type CronFormPinnedSkillsProps = { + skillsCatalog: Skill[]; + skillSlugs: string[]; + onSkillSlugsChange: (skillSlugs: string[]) => void; +}; + +export function CronFormPinnedSkills({ + skillsCatalog, + skillSlugs, + onSkillSlugsChange, +}: CronFormPinnedSkillsProps) { + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + + const [activeSlug, setActiveSlug] = useState(null); + + const handleDragStart = useCallback((event: DragStartEvent) => { + setActiveSlug(String(event.active.id)); + }, []); + + const clearDrag = useCallback(() => { + setActiveSlug(null); + }, []); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + clearDrag(); + const { active, over } = event; + if (!over || active.id === over.id) return; + const oldIndex = skillSlugs.findIndex((s) => s === active.id); + const newIndex = skillSlugs.findIndex((s) => s === over.id); + if (oldIndex < 0 || newIndex < 0) return; + onSkillSlugsChange(arrayMove(skillSlugs, oldIndex, newIndex)); + }, + [clearDrag, onSkillSlugsChange, skillSlugs], + ); + + return ( +
+ + Skills (optional) + +

+ Check recipes below to pin them for this job. Leave all unchecked to use every enabled skill + (default). Drag the :: handle in the ordered list to + set prompt order (top = first in the model context). +

+ {skillSlugs.length > 0 && ( + + +
+ {skillSlugs.map((slug) => { + const meta = skillsCatalog.find((s) => s.slug === slug); + return ( + onSkillSlugsChange(skillSlugs.filter((x) => x !== slug))} + /> + ); + })} +
+
+ + {activeSlug ? ( +
+ :: + {activeSlug} + + {skillsCatalog.find((s) => s.slug === activeSlug)?.name ?? ""} + +
+ ) : null} +
+
+ )} +
+ {[...skillsCatalog] + .filter((s) => s.enabled) + .sort((a, b) => a.slug.localeCompare(b.slug)) + .map((s) => ( + + ))} +
+
+ ); +} diff --git a/src/modules/cron/components/CronJobCard.tsx b/src/modules/cron/components/CronJobCard.tsx new file mode 100644 index 0000000..b3eebc2 --- /dev/null +++ b/src/modules/cron/components/CronJobCard.tsx @@ -0,0 +1,138 @@ +import type { Skill } from "../../skills/types"; +import { formatSchedule, type CronJob, type CronTestResponse } from "../types"; + +function formatLastRun(iso: string | null): string { + if (!iso) return "never"; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + return d.toLocaleString(); +} + +export type CronJobCardProps = { + job: CronJob; + testResult: CronTestResponse | undefined; + busy: boolean; + toggleDisabled: boolean; + testing: boolean; + deleting: boolean; + skillsCatalog: Skill[] | null; + onToggle: () => void; + onTest: () => void; + onEdit: () => void; + onDelete: () => void; +}; + +export function CronJobCard({ + job, + testResult, + busy, + toggleDisabled, + testing, + deleting, + skillsCatalog, + onToggle, + onTest, + onEdit, + onDelete, +}: CronJobCardProps) { + return ( +
+
+
+

{job.name}

+

+ {formatSchedule(job.schedule)} · last run: {formatLastRun(job.last_run_at)} +

+
+
+ + + + +
+
+ +

+ {job.instruction} +

+ {job.skill_slugs.length > 0 && ( +

+ Skills: {job.skill_slugs.join(", ")} +

+ )} + {skillsCatalog && + job.skill_slugs.some( + (slug) => !skillsCatalog.some((s) => s.slug === slug && s.enabled), + ) && ( +

+ Some selected skills are disabled or removed — this job may receive no skill hints until + you fix the list. +

+ )} + {job.condition && ( +

+ if: {job.condition} +

+ )} + + {testResult && ( +
+

+ Test result ·{" "} + {testResult.condition_met ? ( + condition met + ) : ( + condition not met — no message + )} + {testResult.condition_met && testResult.reply.trim().length > 0 && ( + <> + {" · "} + {testResult.telegram_sent ? ( + Telegram sent + ) : testResult.telegram_error ? ( + + Telegram failed: {testResult.telegram_error} + + ) : ( + Telegram not sent + )} + + )} +

+

+ {testResult.reply || "(empty reply)"} +

+
+ )} +
+ ); +} diff --git a/src/modules/cron/components/CronPanel.tsx b/src/modules/cron/components/CronPanel.tsx new file mode 100644 index 0000000..6d78956 --- /dev/null +++ b/src/modules/cron/components/CronPanel.tsx @@ -0,0 +1,488 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { + createCronJob, + deleteCronJob, + fetchCronJobs, + setCronJobEnabled, + testCronJob, + updateCronJob, +} from "../api"; +import { fetchSkills } from "../../skills/api"; +import type { Skill } from "../../skills/types"; +import { type CronDraft, type CronJob, type CronTestResponse, type Schedule } from "../types"; +import { CronDailyLocalTimePicker } from "./CronDailyLocalTimePicker"; +import { CronFormPinnedSkills } from "./CronFormPinnedSkills"; +import { CronJobCard } from "./CronJobCard"; + +const MINUTES_PRESETS = [10, 30, 60, 180, 360, 720, 1440] as const; + +function emptyDraft(): CronDraft { + return { + name: "", + instruction: "", + condition: "", + skill_slugs: [], + schedule: { kind: "every_minutes", minutes: 60 }, + enabled: true, + }; +} + +function jobToDraft(job: CronJob): CronDraft { + return { + name: job.name, + instruction: job.instruction, + condition: job.condition, + skill_slugs: job.skill_slugs ?? [], + schedule: job.schedule, + enabled: job.enabled, + }; +} + +function normalizeCronJob(job: CronJob): CronJob { + return { + ...job, + skill_slugs: Array.isArray(job.skill_slugs) ? job.skill_slugs : [], + }; +} + +export function CronPanel() { + const [jobs, setJobs] = useState(null); + const [lastChatId, setLastChatId] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + + const [showForm, setShowForm] = useState(false); + const [editingId, setEditingId] = useState(null); + const [draft, setDraft] = useState(emptyDraft); + const [formError, setFormError] = useState(null); + const [saving, setSaving] = useState(false); + + const [togglingIds, setTogglingIds] = useState>(() => new Set()); + const [testingIds, setTestingIds] = useState>(() => new Set()); + const [testResults, setTestResults] = useState>({}); + const [deletingIds, setDeletingIds] = useState>(() => new Set()); + const [skillsCatalog, setSkillsCatalog] = useState(null); + + const cancelledRef = useRef(false); + const fetchSeqRef = useRef(0); + const editingIdRef = useRef(editingId); + const showFormRef = useRef(showForm); + + useEffect(() => { + editingIdRef.current = editingId; + showFormRef.current = showForm; + }, [editingId, showForm]); + + const load = useCallback(async () => { + const seq = ++fetchSeqRef.current; + const resp = await fetchCronJobs(); + if (cancelledRef.current || seq !== fetchSeqRef.current) return; + setLoading(false); + if (resp) { + setJobs(resp.jobs.map(normalizeCronJob)); + setLastChatId(resp.last_chat_id); + setError(null); + } else { + setError("Could not load cron jobs"); + } + }, []); + + useEffect(() => { + cancelledRef.current = false; + void load(); + return () => { + cancelledRef.current = true; + }; + }, [load]); + + useEffect(() => { + let cancelled = false; + void (async () => { + const r = await fetchSkills(8000); + if (cancelled || !r) return; + setSkillsCatalog(r.skills); + })(); + return () => { + cancelled = true; + }; + }, []); + + const openAdd = () => { + setShowForm(true); + setEditingId(null); + setDraft(emptyDraft()); + setFormError(null); + }; + + const openEdit = (job: CronJob) => { + setShowForm(true); + setEditingId(job.id); + setDraft(jobToDraft(job)); + setFormError(null); + }; + + const closeForm = () => { + setShowForm(false); + setEditingId(null); + setFormError(null); + }; + + const handleSave = async () => { + const trimmedName = draft.name.trim(); + const trimmedInstruction = draft.instruction.trim(); + if (!trimmedName) { + setFormError("Name is required"); + return; + } + if (!trimmedInstruction) { + setFormError("Instruction is required"); + return; + } + if (draft.schedule.kind === "every_minutes") { + const m = draft.schedule.minutes; + if (!Number.isInteger(m) || m < 1 || m > 10080) { + setFormError("Minutes must be between 1 and 10080"); + return; + } + } else { + const { hour, minute } = draft.schedule; + if ( + !Number.isInteger(hour) || + !Number.isInteger(minute) || + hour < 0 || + hour > 23 || + minute < 0 || + minute > 59 + ) { + setFormError("Time must be HH:MM in 24-hour local time"); + return; + } + } + + const sessionEditingId = editingIdRef.current; + setSaving(true); + setFormError(null); + const payload: CronDraft = { + ...draft, + name: trimmedName, + instruction: trimmedInstruction, + condition: draft.condition.trim(), + }; + const result = sessionEditingId + ? await updateCronJob(sessionEditingId, payload) + : await createCronJob(payload); + setSaving(false); + if (!showFormRef.current || editingIdRef.current !== sessionEditingId) { + return; + } + if (!result.ok) { + setFormError(result.error ?? "Could not save cron job"); + return; + } + setNotice(sessionEditingId ? "Cron job updated" : "Cron job created"); + closeForm(); + await load(); + }; + + const handleDelete = async (job: CronJob) => { + if (!window.confirm(`Delete cron job "${job.name}"?`)) return; + setDeletingIds((s) => new Set(s).add(job.id)); + setError(null); + const result = await deleteCronJob(job.id); + setDeletingIds((s) => { + const n = new Set(s); + n.delete(job.id); + return n; + }); + if (!result.ok) { + setError(result.error ?? "Could not delete cron job"); + return; + } + setNotice(`Deleted "${job.name}"`); + setTestResults((prev) => { + const next = { ...prev }; + delete next[job.id]; + return next; + }); + await load(); + }; + + const handleToggle = async (job: CronJob) => { + const next = !job.enabled; + setTogglingIds((s) => new Set(s).add(job.id)); + setJobs((prev) => + prev ? prev.map((j) => (j.id === job.id ? { ...j, enabled: next } : j)) : prev, + ); + const result = await setCronJobEnabled(job.id, next); + setTogglingIds((s) => { + const n = new Set(s); + n.delete(job.id); + return n; + }); + if (!result.ok) { + setError(result.error ?? "Could not update cron job"); + void load(); + } + }; + + const handleTest = async (job: CronJob) => { + setTestingIds((s) => new Set(s).add(job.id)); + setError(null); + const result = await testCronJob(job.id); + setTestingIds((s) => { + const n = new Set(s); + n.delete(job.id); + return n; + }); + if (!result.ok || !result.result) { + setError(result.error ?? "Test run failed"); + return; + } + setTestResults((prev) => ({ ...prev, [job.id]: result.result! })); + }; + + const setScheduleKind = (kind: Schedule["kind"]) => { + if (kind === draft.schedule.kind) return; + setDraft((prev) => ({ + ...prev, + schedule: + kind === "every_minutes" + ? { kind: "every_minutes", minutes: 60 } + : { kind: "daily_at", hour: 9, minute: 0 }, + })); + }; + + const setEveryMinutes = (minutes: number) => { + setDraft((prev) => ({ ...prev, schedule: { kind: "every_minutes", minutes } })); + }; + + const setDailyAt = (hour: number, minute: number) => { + setDraft((prev) => ({ ...prev, schedule: { kind: "daily_at", hour, minute } })); + }; + + return ( +
+
+

Cron Jobs

+ +
+ +

+ Delivery target:{" "} + {lastChatId == null ? ( + not set — send any message to the bot first + ) : ( + chat {lastChatId} + )} +

+

+ Test runs the agent and, if there is a message to + send, delivers it to the chat above (same as a scheduled run). Requires a known chat and a + connected bot. +

+ + {notice && ( +

+ {notice} +

+ )} + {error && ( +

+ {error} +

+ )} + + {showForm && ( +
+
+ + +