Compare commits
2 Commits
bc67d01bc2
...
2635b45973
| Author | SHA1 | Date | |
|---|---|---|---|
| 2635b45973 | |||
| c3cbe35883 |
678
backend/package-lock.json
generated
678
backend/package-lock.json
generated
@@ -13,6 +13,13 @@
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"tsx": "^4.0.0",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
@@ -103,6 +110,448 @@
|
||||
"zod": "^3.23.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
|
||||
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
|
||||
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
|
||||
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
|
||||
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
|
||||
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
|
||||
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/api": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
@@ -112,12 +561,120 @@
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/body-parser": {
|
||||
"version": "1.19.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/connect": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/connect": {
|
||||
"version": "3.4.38",
|
||||
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cors": {
|
||||
"version": "2.8.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
||||
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/diff-match-patch": {
|
||||
"version": "1.0.36",
|
||||
"resolved": "https://registry.npmjs.org/@types/diff-match-patch/-/diff-match-patch-1.0.36.tgz",
|
||||
"integrity": "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/express": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
|
||||
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/body-parser": "*",
|
||||
"@types/express-serve-static-core": "^5.0.0",
|
||||
"@types/serve-static": "^2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/express-serve-static-core": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
|
||||
"integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"@types/qs": "*",
|
||||
"@types/range-parser": "*",
|
||||
"@types/send": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/http-errors": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
|
||||
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
|
||||
"integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/range-parser": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
||||
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/send": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
||||
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/serve-static": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
|
||||
"integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/http-errors": "*",
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
@@ -392,6 +949,48 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
|
||||
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.4",
|
||||
"@esbuild/android-arm": "0.27.4",
|
||||
"@esbuild/android-arm64": "0.27.4",
|
||||
"@esbuild/android-x64": "0.27.4",
|
||||
"@esbuild/darwin-arm64": "0.27.4",
|
||||
"@esbuild/darwin-x64": "0.27.4",
|
||||
"@esbuild/freebsd-arm64": "0.27.4",
|
||||
"@esbuild/freebsd-x64": "0.27.4",
|
||||
"@esbuild/linux-arm": "0.27.4",
|
||||
"@esbuild/linux-arm64": "0.27.4",
|
||||
"@esbuild/linux-ia32": "0.27.4",
|
||||
"@esbuild/linux-loong64": "0.27.4",
|
||||
"@esbuild/linux-mips64el": "0.27.4",
|
||||
"@esbuild/linux-ppc64": "0.27.4",
|
||||
"@esbuild/linux-riscv64": "0.27.4",
|
||||
"@esbuild/linux-s390x": "0.27.4",
|
||||
"@esbuild/linux-x64": "0.27.4",
|
||||
"@esbuild/netbsd-arm64": "0.27.4",
|
||||
"@esbuild/netbsd-x64": "0.27.4",
|
||||
"@esbuild/openbsd-arm64": "0.27.4",
|
||||
"@esbuild/openbsd-x64": "0.27.4",
|
||||
"@esbuild/openharmony-arm64": "0.27.4",
|
||||
"@esbuild/sunos-x64": "0.27.4",
|
||||
"@esbuild/win32-arm64": "0.27.4",
|
||||
"@esbuild/win32-ia32": "0.27.4",
|
||||
"@esbuild/win32-x64": "0.27.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
@@ -489,6 +1088,21 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
@@ -535,6 +1149,19 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/get-tsconfig": {
|
||||
"version": "4.13.6",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz",
|
||||
"integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"resolve-pkg-maps": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
@@ -853,6 +1480,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-pkg-maps": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
@@ -1051,6 +1688,26 @@
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
@@ -1064,6 +1721,27 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Minimal Express API for Zui (agent, health; no DB)",
|
||||
"main": "src/index.js",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "node --watch src/index.js"
|
||||
"start": "node src/index.ts",
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
@@ -16,5 +18,12 @@
|
||||
"@ai-sdk/openai": "^1.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"tsx": "^4.0.0",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
/**
|
||||
* Minimal Express API: /api/agent, /health.
|
||||
* No DB, no auth. CORS allowed for frontend. Production-ready env (PORT, CORS_ORIGIN).
|
||||
*/
|
||||
|
||||
const express = require('express')
|
||||
const cors = require('cors')
|
||||
|
||||
const PORT = Number(process.env.PORT) || 8080
|
||||
const CORS_ORIGIN = process.env.CORS_ORIGIN || 'http://localhost:3000'
|
||||
|
||||
const app = express()
|
||||
|
||||
app.use(cors({ origin: CORS_ORIGIN }))
|
||||
app.use(express.json())
|
||||
|
||||
/** Build OpenAI client and full prompt from request body. Returns { openai, modelId, fullPrompt } or { error }. */
|
||||
function buildAgentRequest(body) {
|
||||
const { prompt, context, contextNodes, connection: conn, reasoning } = body ?? {}
|
||||
const reasoningEnabled = Boolean(reasoning)
|
||||
let baseURL = process.env.AI_BASE_URL?.trim() || null
|
||||
let apiKey = process.env.OPENAI_API_KEY?.trim() || null
|
||||
let modelId = process.env.AI_MODEL?.trim() || (baseURL ? 'local-model' : 'gpt-4o-mini')
|
||||
if (conn && typeof conn === 'object') {
|
||||
const c = conn
|
||||
const provider = c.provider === 'openai' ? 'openai' : 'local'
|
||||
if (provider === 'local') {
|
||||
baseURL = (typeof c.baseURL === 'string' && c.baseURL.trim()) ? c.baseURL.trim() : baseURL
|
||||
apiKey = (typeof c.apiKey === 'string' && c.apiKey.trim()) ? c.apiKey.trim() : (apiKey || 'lm-studio')
|
||||
} else {
|
||||
baseURL = null
|
||||
apiKey = (typeof c.apiKey === 'string' && c.apiKey.trim()) ? c.apiKey.trim() : apiKey
|
||||
}
|
||||
if (typeof c.model === 'string' && c.model.trim()) modelId = c.model.trim()
|
||||
}
|
||||
if (!baseURL && !apiKey) {
|
||||
return { error: 'No AI configured. Set connection in Settings (AI) or env: OPENAI_API_KEY or AI_BASE_URL.' }
|
||||
}
|
||||
const basePrompt = [
|
||||
typeof prompt === 'string' ? prompt : 'No prompt provided.',
|
||||
context && typeof context === 'string' ? `\n\nAdditional context:\n${context}` : '',
|
||||
Array.isArray(contextNodes) && contextNodes.length > 0
|
||||
? `\n\nContext from connected nodes:\n${contextNodes.map((n) => (n.content != null ? n.content : `${n.id}: (no content)`)).join('\n\n')}`
|
||||
: '',
|
||||
].join('')
|
||||
const fullPrompt = reasoningEnabled
|
||||
? basePrompt + '\n\nRespond in exactly two markdown sections. First: "## Reasoning" with your step-by-step reasoning. Then: "## Output" with only the final answer. No preamble.'
|
||||
: basePrompt + '\n\nRespond with structured markdown only. No preamble.'
|
||||
const { createOpenAI } = require('@ai-sdk/openai')
|
||||
const openai = createOpenAI({
|
||||
apiKey: apiKey || 'lm-studio',
|
||||
...(baseURL && { baseURL, compatibility: 'compatible' }),
|
||||
})
|
||||
return { openai, modelId, fullPrompt }
|
||||
}
|
||||
|
||||
/** POST /api/agent — run AI agent; body: { prompt, context?, contextNodes?, connection? }; returns { markdown }. */
|
||||
app.post('/api/agent', async (req, res) => {
|
||||
try {
|
||||
const built = buildAgentRequest(req.body)
|
||||
if (built.error) return res.status(503).json({ error: built.error })
|
||||
const { generateText } = await import('ai')
|
||||
const result = await generateText({
|
||||
model: built.openai(built.modelId),
|
||||
prompt: built.fullPrompt,
|
||||
})
|
||||
const markdown = result?.text ?? ''
|
||||
res.json({ markdown })
|
||||
} catch (err) {
|
||||
console.error('Agent error:', err)
|
||||
res.status(500).json({ error: err?.message ?? 'Agent request failed' })
|
||||
}
|
||||
})
|
||||
|
||||
/** POST /api/agent/stream — same as /api/agent but streams plain text (markdown) chunks. */
|
||||
app.post('/api/agent/stream', async (req, res) => {
|
||||
try {
|
||||
const built = buildAgentRequest(req.body)
|
||||
if (built.error) return res.status(503).json({ error: built.error })
|
||||
const { streamText } = await import('ai')
|
||||
const result = streamText({
|
||||
model: built.openai(built.modelId),
|
||||
prompt: built.fullPrompt,
|
||||
})
|
||||
res.setHeader('Content-Type', 'text/plain; charset=utf-8')
|
||||
res.setHeader('Transfer-Encoding', 'chunked')
|
||||
result.pipeTextStreamToResponse(res)
|
||||
} catch (err) {
|
||||
console.error('Agent stream error:', err)
|
||||
res.status(500).json({ error: err?.message ?? 'Agent request failed' })
|
||||
}
|
||||
})
|
||||
|
||||
/** Health check for Docker / orchestration */
|
||||
app.get('/health', (req, res) => {
|
||||
res.status(200).json({ ok: true })
|
||||
})
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`Backend listening on port ${PORT} (CORS: ${CORS_ORIGIN})`)
|
||||
})
|
||||
102
backend/src/index.ts
Normal file
102
backend/src/index.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Backend API: Layered architecture for scalability and maintainability.
|
||||
*
|
||||
* ## Architecture Layers
|
||||
*
|
||||
* ### Controllers (routes/)
|
||||
* - Handle HTTP requests and responses
|
||||
* - Validate input and format output
|
||||
* - Call services for business logic
|
||||
*
|
||||
* ### Services (services/)
|
||||
* - Implement business logic
|
||||
* - Coordinate between controllers and repositories
|
||||
* - Handle validation and transformation
|
||||
*
|
||||
* ### Repositories (repositories/)
|
||||
* - Data access layer
|
||||
* - Interact with databases or external APIs
|
||||
* - Return domain models
|
||||
*
|
||||
* ### Models (models/)
|
||||
* - Data structures and types
|
||||
* - Validation schemas
|
||||
*
|
||||
* ### Middleware (middleware/)
|
||||
* - Request validation
|
||||
* - Authentication/authorization
|
||||
* - Rate limiting
|
||||
* - Error handling
|
||||
*
|
||||
* ## Adding New Endpoints
|
||||
*
|
||||
* 1. Define the model in `models/`
|
||||
* 2. Implement repository logic in `repositories/`
|
||||
* 3. Implement service logic in `services/`
|
||||
* 4. Create controller in `routes/`
|
||||
* 5. Register route in `index.ts`
|
||||
*/
|
||||
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PORT = Number(process.env.PORT) || 8080
|
||||
const CORS_ORIGIN = process.env.CORS_ORIGIN || 'http://localhost:3000'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Express App Setup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const app = express()
|
||||
|
||||
app.use(cors({ origin: CORS_ORIGIN }))
|
||||
app.use(express.json())
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent Routes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import { handleAgentRequest, handleAgentStreamRequest } from './routes/agentRoutes.js'
|
||||
|
||||
/** POST /api/agent - Run AI agent */
|
||||
app.post('/api/agent', handleAgentRequest)
|
||||
|
||||
/** POST /api/agent/stream - Stream AI agent response */
|
||||
app.post('/api/agent/stream', handleAgentStreamRequest)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Health Check Endpoint
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** GET /health - Health check for Docker / orchestration */
|
||||
app.get('/health', (req, res) => {
|
||||
res.status(200).json({ ok: true, timestamp: Date.now() })
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error Handling Middleware
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Global error handler for consistent error responses */
|
||||
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
console.error('Error:', err)
|
||||
res.status(500).json({ error: err.message ?? 'Internal server error' })
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Start Server
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`Backend listening on port ${PORT} (CORS: ${CORS_ORIGIN})`)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Export for testing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default app
|
||||
88
backend/src/middleware/rateLimiter.ts
Normal file
88
backend/src/middleware/rateLimiter.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Middleware: Rate limiting for API endpoints.
|
||||
*
|
||||
* This module provides rate limiting middleware to protect the API
|
||||
* from abuse and ensure fair usage.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Rate limit configuration
|
||||
*/
|
||||
export type RateLimitConfig = {
|
||||
/** Maximum number of requests allowed in the window */
|
||||
max: number
|
||||
/** Window size in milliseconds */
|
||||
windowMs: number
|
||||
/** Error message to return when rate limited */
|
||||
message?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Default rate limit configuration
|
||||
*/
|
||||
export const DEFAULT_RATE_LIMIT_CONFIG: RateLimitConfig = {
|
||||
max: 100,
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
message: 'Too many requests, please try again later.',
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory store for rate limiting
|
||||
*/
|
||||
const rateLimitStore = new Map<string, { count: number; resetTime: number }>()
|
||||
|
||||
/**
|
||||
* Rate limiting middleware
|
||||
*
|
||||
* @param config - Rate limit configuration
|
||||
* @returns Express middleware function
|
||||
*/
|
||||
export function rateLimit(config: RateLimitConfig = DEFAULT_RATE_LIMIT_CONFIG) {
|
||||
return (req: { ip?: string; socket?: { remoteAddress?: string } }, res: { status: (code: number) => { json: (body: { error: string }) => void }, locals: Record<string, unknown> }, next: () => void): void => {
|
||||
const ip = req.ip || req.socket?.remoteAddress || 'unknown'
|
||||
const now = Date.now()
|
||||
|
||||
let entry = rateLimitStore.get(ip)
|
||||
|
||||
if (!entry || now > entry.resetTime) {
|
||||
// Reset the counter for a new window
|
||||
entry = { count: 1, resetTime: now + config.windowMs }
|
||||
rateLimitStore.set(ip, entry)
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
entry.count++
|
||||
|
||||
if (entry.count > config.max) {
|
||||
res.status(429).json({ error: config.message ?? 'Too many requests' })
|
||||
return
|
||||
}
|
||||
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired entries from the rate limit store
|
||||
*/
|
||||
export function cleanupRateLimitStore(): void {
|
||||
const now = Date.now()
|
||||
for (const [ip, entry] of rateLimitStore.entries()) {
|
||||
if (now > entry.resetTime) {
|
||||
rateLimitStore.delete(ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rate limit status for an IP
|
||||
*/
|
||||
export function getRateLimitStatus(ip: string): { remaining: number; resetTime: number } | null {
|
||||
const entry = rateLimitStore.get(ip)
|
||||
if (!entry) return null
|
||||
return {
|
||||
remaining: Math.max(0, DEFAULT_RATE_LIMIT_CONFIG.max - entry.count),
|
||||
resetTime: entry.resetTime,
|
||||
}
|
||||
}
|
||||
63
backend/src/models/index.ts
Normal file
63
backend/src/models/index.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Models: Data structures and validation schemas.
|
||||
*
|
||||
* This module defines the domain models used throughout the application.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Agent request payload
|
||||
*/
|
||||
export type AgentRequest = {
|
||||
prompt: string
|
||||
context?: string
|
||||
contextNodes?: AgentContextNode[]
|
||||
connection?: AgentConnection
|
||||
reasoning?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Context node for agent requests
|
||||
*/
|
||||
export type AgentContextNode = {
|
||||
id: string
|
||||
content?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* AI connection configuration
|
||||
*/
|
||||
export type AgentConnection = {
|
||||
provider: 'openai' | 'local'
|
||||
baseURL?: string
|
||||
apiKey?: string
|
||||
model?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent response
|
||||
*/
|
||||
export type AgentResponse = {
|
||||
markdown: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent stream response
|
||||
*/
|
||||
export type AgentStreamResponse = {
|
||||
markdown: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check response
|
||||
*/
|
||||
export type HealthResponse = {
|
||||
ok: boolean
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Error response
|
||||
*/
|
||||
export type ErrorResponse = {
|
||||
error: string
|
||||
}
|
||||
103
backend/src/repositories/cacheRepository.ts
Normal file
103
backend/src/repositories/cacheRepository.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Repositories: Data access layer.
|
||||
*
|
||||
* This module handles data persistence and retrieval.
|
||||
* It abstracts the underlying storage mechanism.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Cache entry for expensive computations
|
||||
*/
|
||||
export type CacheEntry<T> = {
|
||||
/** Unique key for the cache entry */
|
||||
key: string
|
||||
/** Cached data */
|
||||
data: T
|
||||
/** Timestamp (ms) when this entry was created */
|
||||
createdAt: number
|
||||
/** Time-to-live in milliseconds */
|
||||
ttl?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory cache implementation
|
||||
*/
|
||||
export class InMemoryCache {
|
||||
private cache = new Map<string, CacheEntry<unknown>>()
|
||||
private defaultTtl = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
/**
|
||||
* Get a value from the cache
|
||||
*
|
||||
* @param key - Cache key
|
||||
* @returns Cached value or null if not found or expired
|
||||
*/
|
||||
get<T>(key: string): T | null {
|
||||
const entry = this.cache.get(key) as CacheEntry<T> | undefined
|
||||
if (!entry) return null
|
||||
|
||||
// Check if entry has expired
|
||||
if (entry.ttl && Date.now() - entry.createdAt > entry.ttl) {
|
||||
this.cache.delete(key)
|
||||
return null
|
||||
}
|
||||
|
||||
return entry.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a value in the cache
|
||||
*
|
||||
* @param key - Cache key
|
||||
* @param data - Data to cache
|
||||
* @param ttl - Optional time-to-live in milliseconds (uses default if not specified)
|
||||
*/
|
||||
set<T>(key: string, data: T, ttl?: number): void {
|
||||
this.cache.set(key, {
|
||||
key,
|
||||
data,
|
||||
createdAt: Date.now(),
|
||||
ttl: ttl ?? this.defaultTtl,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a value from the cache
|
||||
*
|
||||
* @param key - Cache key
|
||||
*/
|
||||
delete(key: string): void {
|
||||
this.cache.delete(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all entries from the cache
|
||||
*/
|
||||
clear(): void {
|
||||
this.cache.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired entries
|
||||
*/
|
||||
cleanup(): void {
|
||||
const now = Date.now()
|
||||
for (const [key, entry] of this.cache.entries()) {
|
||||
if (entry.ttl && now - entry.createdAt > entry.ttl) {
|
||||
this.cache.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of entries in the cache
|
||||
*/
|
||||
size(): number {
|
||||
return this.cache.size
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Global cache instance
|
||||
*/
|
||||
export const cache = new InMemoryCache()
|
||||
72
backend/src/routes/agentRoutes.ts
Normal file
72
backend/src/routes/agentRoutes.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Routes: HTTP request handlers.
|
||||
*
|
||||
* This module contains the route handlers for the API.
|
||||
* Routes validate input and format output, then call services for business logic.
|
||||
*/
|
||||
|
||||
import type { AgentRequest, AgentResponse, ErrorResponse } from '../models'
|
||||
|
||||
/**
|
||||
* POST /api/agent - Run AI agent
|
||||
*
|
||||
* Request body: { prompt, context?, contextNodes?, connection? }
|
||||
* Response: { markdown }
|
||||
*/
|
||||
export async function handleAgentRequest(req: any, res: any): Promise<void> {
|
||||
try {
|
||||
const body = req.body
|
||||
const { buildAgentRequest } = await import('../services/agentService')
|
||||
|
||||
const built = buildAgentRequest(body)
|
||||
if ('error' in built) {
|
||||
res.status(503).json({ error: built.error } as ErrorResponse)
|
||||
return
|
||||
}
|
||||
|
||||
const { generateText } = await import('ai')
|
||||
const result = await generateText({
|
||||
model: built.openai as any,
|
||||
prompt: built.fullPrompt,
|
||||
})
|
||||
|
||||
const { processAgentResponse } = await import('../services/agentService')
|
||||
const response = processAgentResponse(result)
|
||||
res.json(response)
|
||||
} catch (err) {
|
||||
console.error('Agent error:', err)
|
||||
res.status(500).json({ error: (err as Error).message ?? 'Agent request failed' } as ErrorResponse)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/agent/stream - Stream AI agent response
|
||||
*
|
||||
* Request body: { prompt, context?, contextNodes?, connection? }
|
||||
* Response: plain text (markdown) chunks
|
||||
*/
|
||||
export async function handleAgentStreamRequest(req: any, res: any): Promise<void> {
|
||||
try {
|
||||
const body = req.body
|
||||
const { buildAgentRequest } = await import('../services/agentService')
|
||||
|
||||
const built = buildAgentRequest(body)
|
||||
if ('error' in built) {
|
||||
res.status(503).json({ error: built.error } as ErrorResponse)
|
||||
return
|
||||
}
|
||||
|
||||
const { streamText } = await import('ai')
|
||||
const result = streamText({
|
||||
model: built.openai as any,
|
||||
prompt: built.fullPrompt,
|
||||
})
|
||||
|
||||
res.setHeader('Content-Type', 'text/plain; charset=utf-8')
|
||||
res.setHeader('Transfer-Encoding', 'chunked')
|
||||
result.pipeTextStreamToResponse(res)
|
||||
} catch (err) {
|
||||
console.error('Agent stream error:', err)
|
||||
res.status(500).json({ error: (err as Error).message ?? 'Agent request failed' } as ErrorResponse)
|
||||
}
|
||||
}
|
||||
65
backend/src/services/agentService.ts
Normal file
65
backend/src/services/agentService.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Services: Business logic layer.
|
||||
*
|
||||
* This module contains the business logic for the application.
|
||||
* Services coordinate between controllers and repositories.
|
||||
*/
|
||||
|
||||
import type { AgentRequest, AgentResponse, AgentConnection } from '../models'
|
||||
|
||||
/**
|
||||
* Build OpenAI client and full prompt from request body.
|
||||
* Returns { openai, modelId, fullPrompt } or { error }.
|
||||
*/
|
||||
export function buildAgentRequest(body: AgentRequest): { openai: unknown; modelId: string; fullPrompt: string } | { error: string } {
|
||||
const { prompt, context, contextNodes, connection: conn, reasoning } = body ?? {}
|
||||
const reasoningEnabled = Boolean(reasoning)
|
||||
let baseURL = process.env.AI_BASE_URL?.trim() || null
|
||||
let apiKey = process.env.OPENAI_API_KEY?.trim() || null
|
||||
let modelId = process.env.AI_MODEL?.trim() || (baseURL ? 'local-model' : 'gpt-4o-mini')
|
||||
|
||||
if (conn && typeof conn === 'object') {
|
||||
const c = conn as AgentConnection
|
||||
const provider = c.provider === 'openai' ? 'openai' : 'local'
|
||||
if (provider === 'local') {
|
||||
baseURL = (typeof c.baseURL === 'string' && c.baseURL.trim()) ? c.baseURL.trim() : baseURL
|
||||
apiKey = (typeof c.apiKey === 'string' && c.apiKey.trim()) ? c.apiKey.trim() : (apiKey || 'lm-studio')
|
||||
} else {
|
||||
baseURL = null
|
||||
apiKey = (typeof c.apiKey === 'string' && c.apiKey.trim()) ? c.apiKey.trim() : apiKey
|
||||
}
|
||||
if (typeof c.model === 'string' && c.model.trim()) modelId = c.model.trim()
|
||||
}
|
||||
|
||||
if (!baseURL && !apiKey) {
|
||||
return { error: 'No AI configured. Set connection in Settings (AI) or env: OPENAI_API_KEY or AI_BASE_URL.' }
|
||||
}
|
||||
|
||||
const basePrompt = [
|
||||
typeof prompt === 'string' ? prompt : 'No prompt provided.',
|
||||
context && typeof context === 'string' ? `\n\nAdditional context:\n${context}` : '',
|
||||
Array.isArray(contextNodes) && contextNodes.length > 0
|
||||
? `\n\nContext from connected nodes:\n${contextNodes.map((n) => (n.content != null ? n.content : `${n.id}: (no content)`)).join('\n\n')}`
|
||||
: '',
|
||||
].join('')
|
||||
|
||||
const fullPrompt = reasoningEnabled
|
||||
? basePrompt + '\n\nRespond in exactly two markdown sections. First: "## Reasoning" with your step-by-step reasoning. Then: "## Output" with only the final answer. No preamble.'
|
||||
: basePrompt + '\n\nRespond with structured markdown only. No preamble.'
|
||||
|
||||
const { createOpenAI } = require('@ai-sdk/openai')
|
||||
const openai = createOpenAI({
|
||||
apiKey: apiKey || 'lm-studio',
|
||||
...(baseURL && { baseURL, compatibility: 'compatible' }),
|
||||
})
|
||||
|
||||
return { openai, modelId, fullPrompt }
|
||||
}
|
||||
|
||||
/**
|
||||
* Process agent response
|
||||
*/
|
||||
export function processAgentResponse(result: { text?: string }): AgentResponse {
|
||||
const markdown = result?.text ?? ''
|
||||
return { markdown }
|
||||
}
|
||||
20
backend/tsconfig.json
Normal file
20
backend/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/app-icon.svg" type="image/svg+xml" />
|
||||
<title>React Flow + shadcn Canvas</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/app-icon.svg" type="image/svg+xml" />
|
||||
<title>ZOË | Kosmos</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
144
frontend/package-lock.json
generated
144
frontend/package-lock.json
generated
@@ -41,6 +41,7 @@
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "18.2.0",
|
||||
"react-arborist": "^3.4.3",
|
||||
"react-dom": "18.2.0",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"react-simple-code-editor": "^0.14.1",
|
||||
@@ -519,7 +520,6 @@
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
|
||||
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -2927,6 +2927,24 @@
|
||||
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@react-dnd/asap": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.1.tgz",
|
||||
"integrity": "sha512-kLy0PJDDwvwwTXxqTFNAAllPHD73AycE9ypWeln/IguoGBEbvFcPDbCV03G52bEcC5E+YgupBE0VzHGdC8SIXg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@react-dnd/invariant": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-2.0.0.tgz",
|
||||
"integrity": "sha512-xL4RCQBCBDJ+GRwKTFhGUW8GXa4yoDfJrPbLblc3U09ciS+9ZJXJ3Qrcs/x2IODOdIE5kQxvMmE2UKyqUictUw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@react-dnd/shallowequal": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-2.0.0.tgz",
|
||||
"integrity": "sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@remirror/core-constants": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
|
||||
@@ -3875,7 +3893,7 @@
|
||||
"version": "25.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz",
|
||||
"integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
@@ -5325,6 +5343,26 @@
|
||||
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dnd-core": {
|
||||
"version": "14.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-14.0.1.tgz",
|
||||
"integrity": "sha512-+PVS2VPTgKFPYWo3vAFEA8WPbTf7/xo43TifH9G8S1KqnrQu0o77A3unrF5yOugy4mIz7K5wAVFHUcha7wsz6A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-dnd/asap": "^4.0.0",
|
||||
"@react-dnd/invariant": "^2.0.0",
|
||||
"redux": "^4.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/dnd-core/node_modules/redux": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz",
|
||||
"integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.9.2"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-accessibility-api": {
|
||||
"version": "0.5.16",
|
||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
@@ -6472,6 +6510,21 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/hoist-non-react-statics": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
|
||||
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"react-is": "^16.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hoist-non-react-statics/node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.12.5",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.5.tgz",
|
||||
@@ -7449,6 +7502,12 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/memoize-one": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
|
||||
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/merge-descriptors": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
|
||||
@@ -9358,6 +9417,62 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-arborist": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmjs.org/react-arborist/-/react-arborist-3.4.3.tgz",
|
||||
"integrity": "sha512-yFnq1nIQhT2uJY4TZVz2tgAiBb9lxSyvF4vC3S8POCK8xLzjGIxVv3/4dmYquQJ7AHxaZZArRGHiHKsEewKdTQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-dnd": "^14.0.3",
|
||||
"react-dnd-html5-backend": "^14.0.3",
|
||||
"react-window": "^1.8.11",
|
||||
"redux": "^5.0.0",
|
||||
"use-sync-external-store": "^1.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.14",
|
||||
"react-dom": ">= 16.14"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dnd": {
|
||||
"version": "14.0.5",
|
||||
"resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-14.0.5.tgz",
|
||||
"integrity": "sha512-9i1jSgbyVw0ELlEVt/NkCUkxy1hmhJOkePoCH713u75vzHGyXhPDm28oLfc2NMSBjZRM1Y+wRjHXJT3sPrTy+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@react-dnd/invariant": "^2.0.0",
|
||||
"@react-dnd/shallowequal": "^2.0.0",
|
||||
"dnd-core": "14.0.1",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"hoist-non-react-statics": "^3.3.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/hoist-non-react-statics": ">= 3.3.1",
|
||||
"@types/node": ">= 12",
|
||||
"@types/react": ">= 16",
|
||||
"react": ">= 16.14"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/hoist-non-react-statics": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-dnd-html5-backend": {
|
||||
"version": "14.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-14.1.0.tgz",
|
||||
"integrity": "sha512-6ONeqEC3XKVf4eVmMTe0oPds+c5B9Foyj8p/ZKLb7kL2qh9COYxiBHv3szd6gztqi/efkmriywLUVlPotqoJyw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dnd-core": "14.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
|
||||
@@ -9525,6 +9640,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-window": {
|
||||
"version": "1.8.11",
|
||||
"resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.11.tgz",
|
||||
"integrity": "sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.0.0",
|
||||
"memoize-one": ">=3.1.1 <6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">8.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
@@ -9575,6 +9707,12 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rehype-format": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rehype-format/-/rehype-format-5.0.1.tgz",
|
||||
@@ -10761,7 +10899,7 @@
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicorn-magic": {
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "18.2.0",
|
||||
"react-arborist": "^3.4.3",
|
||||
"react-dom": "18.2.0",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"react-simple-code-editor": "^0.14.1",
|
||||
|
||||
@@ -26,5 +26,19 @@ export {
|
||||
selectRenamingNodeId,
|
||||
selectFullscreenNodeId,
|
||||
selectConnectionFrom,
|
||||
// Fine-grained selectors
|
||||
selectNodeById,
|
||||
selectNodesById,
|
||||
selectNodesByType,
|
||||
selectNodeIdsByType,
|
||||
selectEdgesBySource,
|
||||
selectEdgesByTarget,
|
||||
selectEdgesForNode,
|
||||
selectNodeCount,
|
||||
selectEdgeCount,
|
||||
selectHasNode,
|
||||
selectHasEdge,
|
||||
// SVG detection
|
||||
isSvgContent,
|
||||
} from './canvasStore.selectors'
|
||||
export type { ConnectionPathRole } from './canvasStore.selectors'
|
||||
|
||||
@@ -10,10 +10,19 @@ import {
|
||||
} from '@/lib/graph/graphologyPath'
|
||||
import { getConnectionStatus, type ConnectionStatus } from '@/lib/graph/connectionStatus'
|
||||
import type { CanvasStore } from './canvasStore.types'
|
||||
import type { AppNode } from '@/lib/graph/nodeTypes'
|
||||
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||
|
||||
export type ConnectionPathRole = 'trigger' | 'on-path' | null
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SVG Detection - Centralized logic for detecting SVG content
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Check if a string contains SVG content. */
|
||||
export function isSvgContent(content: string | null | undefined): boolean {
|
||||
return Boolean(content?.trim() && /<svg[\s>]/i.test(content.trim()))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Raw slices
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -38,6 +47,65 @@ export function selectEdges(state: CanvasStore) {
|
||||
return state.graph.edges
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fine-grained selectors for optimal re-rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Get a specific node by ID. */
|
||||
export function selectNodeById(state: CanvasStore, nodeId: string): AppNode | undefined {
|
||||
return state.graph.nodes.find((n) => n.id === nodeId)
|
||||
}
|
||||
|
||||
/** Get multiple nodes by IDs. */
|
||||
export function selectNodesById(state: CanvasStore, nodeIds: string[]): AppNode[] {
|
||||
return state.graph.nodes.filter((n) => nodeIds.includes(n.id))
|
||||
}
|
||||
|
||||
/** Get nodes by type. */
|
||||
export function selectNodesByType(state: CanvasStore, nodeType: string): AppNode[] {
|
||||
return state.graph.nodes.filter((n) => n.type === nodeType)
|
||||
}
|
||||
|
||||
/** Get node IDs by type. */
|
||||
export function selectNodeIdsByType(state: CanvasStore, nodeType: string): string[] {
|
||||
return state.graph.nodes.filter((n) => n.type === nodeType).map((n) => n.id)
|
||||
}
|
||||
|
||||
/** Get edges by source node ID. */
|
||||
export function selectEdgesBySource(state: CanvasStore, sourceId: string): AppEdge[] {
|
||||
return state.graph.edges.filter((e) => e.source === sourceId)
|
||||
}
|
||||
|
||||
/** Get edges by target node ID. */
|
||||
export function selectEdgesByTarget(state: CanvasStore, targetId: string): AppEdge[] {
|
||||
return state.graph.edges.filter((e) => e.target === targetId)
|
||||
}
|
||||
|
||||
/** Get all edges connected to a node (source or target). */
|
||||
export function selectEdgesForNode(state: CanvasStore, nodeId: string): AppEdge[] {
|
||||
return state.graph.edges.filter((e) => e.source === nodeId || e.target === nodeId)
|
||||
}
|
||||
|
||||
/** Get node count. */
|
||||
export function selectNodeCount(state: CanvasStore): number {
|
||||
return state.graph.nodes.length
|
||||
}
|
||||
|
||||
/** Get edge count. */
|
||||
export function selectEdgeCount(state: CanvasStore): number {
|
||||
return state.graph.edges.length
|
||||
}
|
||||
|
||||
/** Check if a node exists. */
|
||||
export function selectHasNode(state: CanvasStore, nodeId: string): boolean {
|
||||
return state.graph.nodes.some((n) => n.id === nodeId)
|
||||
}
|
||||
|
||||
/** Check if an edge exists. */
|
||||
export function selectHasEdge(state: CanvasStore, source: string, target: string): boolean {
|
||||
return state.graph.edges.some((e) => e.source === source && e.target === target)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived path (Sets) – depend on graph.edges + path primitive arrays
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -2,23 +2,14 @@
|
||||
* Recollection sidebar: Logos (pages), Katalogos, Flux. Shared by all recollection routes.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import React, { useCallback } from 'react'
|
||||
import { useParams, useLocation, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Plus, FileText } from 'lucide-react'
|
||||
import { FluxIcon } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useRecollectionSidebar } from './RecollectionSidebarContext'
|
||||
import { TreeBrowser } from './TreeBrowser'
|
||||
import {
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
@@ -28,194 +19,31 @@ import {
|
||||
SidebarMenuItem,
|
||||
sidebarMenuButtonVariants,
|
||||
} from '@/components/ui/sidebar'
|
||||
import {
|
||||
FileText,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Plus,
|
||||
Trash2,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
} from 'lucide-react'
|
||||
import { FluxIcon } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useRecollectionSidebar } from './RecollectionSidebarContext'
|
||||
import type { LogosPageId, LogosPageMeta } from '../state/recollectionStore'
|
||||
|
||||
const DEFAULT_PAGE_TITLE = 'Untitled'
|
||||
|
||||
function generatePageId(): LogosPageId {
|
||||
return `page-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
|
||||
}
|
||||
|
||||
function sortByPosition(a: LogosPageMeta, b: LogosPageMeta) {
|
||||
return a.position - b.position
|
||||
}
|
||||
|
||||
export function RecollectionSidebar() {
|
||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||
const { pathname } = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const {
|
||||
tree,
|
||||
activePageId,
|
||||
handleSelectPage,
|
||||
handleTreeChange,
|
||||
handleDeletePage,
|
||||
} = useRecollectionSidebar()
|
||||
|
||||
const [renameTarget, setRenameTarget] = useState<LogosPageMeta | null>(null)
|
||||
const [renameTitle, setRenameTitle] = useState('')
|
||||
const [deleteTarget, setDeleteTarget] = useState<LogosPageMeta | null>(null)
|
||||
const { tree, handleSelectPage, handleTreeChange } = useRecollectionSidebar()
|
||||
|
||||
const base = recollectionId ? `/recollections/${recollectionId}` : ''
|
||||
const baseLogos = `${base}/logos`
|
||||
const isKatalogosView = pathname.endsWith('/logos/katalogos')
|
||||
const isFluxView = pathname.endsWith('/flux')
|
||||
|
||||
const pages = tree.filter((p) => p.parentId === null).sort(sortByPosition)
|
||||
|
||||
const updatePage = useCallback(
|
||||
(id: LogosPageId, patch: Partial<Pick<LogosPageMeta, 'title' | 'position'>>) => {
|
||||
const next = tree.map((p) => (p.id === id ? { ...p, ...patch } : p))
|
||||
handleTreeChange(next)
|
||||
},
|
||||
[tree, handleTreeChange]
|
||||
)
|
||||
|
||||
const addPage = useCallback(() => {
|
||||
const maxPos = Math.max(0, ...pages.map((p) => p.position), -1)
|
||||
const newPage: LogosPageMeta = {
|
||||
id: generatePageId(),
|
||||
title: DEFAULT_PAGE_TITLE,
|
||||
const handleAddPage = useCallback(() => {
|
||||
// Add a new top-level page
|
||||
const maxPos = Math.max(0, ...tree.filter((p) => p.parentId === null).map((p) => p.position), -1)
|
||||
const newPage = {
|
||||
id: `page-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||
title: 'Untitled',
|
||||
parentId: null,
|
||||
position: maxPos + 1,
|
||||
}
|
||||
handleTreeChange([...tree, newPage])
|
||||
setRenameTarget(newPage)
|
||||
setRenameTitle(newPage.title)
|
||||
handleSelectPage(newPage.id)
|
||||
navigate(`${baseLogos}?page=${encodeURIComponent(newPage.id)}`)
|
||||
}, [tree, pages, handleTreeChange, handleSelectPage, baseLogos, navigate])
|
||||
|
||||
const startRename = useCallback((page: LogosPageMeta) => {
|
||||
setRenameTarget(page)
|
||||
setRenameTitle(page.title)
|
||||
}, [])
|
||||
|
||||
const commitRename = useCallback(() => {
|
||||
if (renameTarget && renameTitle.trim()) {
|
||||
updatePage(renameTarget.id, { title: renameTitle.trim() })
|
||||
}
|
||||
setRenameTarget(null)
|
||||
setRenameTitle('')
|
||||
}, [renameTarget, renameTitle, updatePage])
|
||||
|
||||
const movePage = useCallback(
|
||||
(id: LogosPageId, delta: number) => {
|
||||
const page = tree.find((p) => p.id === id)
|
||||
if (!page) return
|
||||
const siblings = tree
|
||||
.filter((p) => p.parentId === page.parentId)
|
||||
.sort(sortByPosition)
|
||||
const idx = siblings.findIndex((p) => p.id === id)
|
||||
if (idx < 0) return
|
||||
const newIdx = Math.max(0, Math.min(siblings.length - 1, idx + delta))
|
||||
if (newIdx === idx) return
|
||||
const reordered = siblings.slice()
|
||||
const [removed] = reordered.splice(idx, 1)
|
||||
reordered.splice(newIdx, 0, removed)
|
||||
const withNewPositions = tree.map((p) => {
|
||||
const i = reordered.findIndex((r) => r.id === p.id)
|
||||
return i >= 0 ? { ...p, position: i } : p
|
||||
})
|
||||
handleTreeChange(withNewPositions)
|
||||
},
|
||||
[tree, handleTreeChange]
|
||||
)
|
||||
|
||||
const removePage = useCallback(
|
||||
(page: LogosPageMeta) => {
|
||||
handleTreeChange(tree.filter((p) => p.id !== page.id))
|
||||
handleDeletePage(page.id)
|
||||
if (activePageId === page.id) {
|
||||
const remaining = tree.filter((p) => p.id !== page.id).sort(sortByPosition)
|
||||
const first = remaining[0]
|
||||
if (first) {
|
||||
handleSelectPage(first.id)
|
||||
navigate(`${baseLogos}?page=${encodeURIComponent(first.id)}`)
|
||||
} else {
|
||||
navigate(baseLogos)
|
||||
}
|
||||
}
|
||||
setDeleteTarget(null)
|
||||
},
|
||||
[tree, activePageId, handleTreeChange, handleDeletePage, handleSelectPage, baseLogos, navigate]
|
||||
)
|
||||
|
||||
const onPageClick = useCallback(
|
||||
(pageId: LogosPageId) => {
|
||||
handleSelectPage(pageId)
|
||||
navigate(`${baseLogos}?page=${encodeURIComponent(pageId)}`)
|
||||
},
|
||||
[handleSelectPage, baseLogos, navigate]
|
||||
)
|
||||
|
||||
const renderPageActions = useCallback(
|
||||
(page: LogosPageMeta, options: { canMoveUp: boolean; canMoveDown: boolean }) => {
|
||||
const { canMoveUp, canMoveDown } = options
|
||||
return (
|
||||
<span
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
'absolute right-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-none ring-sidebar-ring transition-transform hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0',
|
||||
'group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0'
|
||||
)}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="size-3.5" />
|
||||
<span className="sr-only">Actions</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
startRename(page)
|
||||
onPageClick(page.id)
|
||||
}}
|
||||
>
|
||||
<Pencil className="mr-2 size-3.5" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => movePage(page.id, -1)} disabled={!canMoveUp}>
|
||||
<ArrowUp className="mr-2 size-3.5" />
|
||||
Move up
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => movePage(page.id, 1)} disabled={!canMoveDown}>
|
||||
<ArrowDown className="mr-2 size-3.5" />
|
||||
Move down
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => setDeleteTarget(page)} className="text-destructive focus:text-destructive">
|
||||
<Trash2 className="mr-2 size-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</span>
|
||||
)
|
||||
},
|
||||
[startRename, onPageClick, movePage]
|
||||
)
|
||||
}, [tree, handleTreeChange, handleSelectPage, baseLogos, navigate])
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -231,47 +59,14 @@ export function RecollectionSidebar() {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 gap-1 px-1.5 text-xs text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
onClick={addPage}
|
||||
onClick={handleAddPage}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
New page
|
||||
</Button>
|
||||
</div>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{pages.length === 0 ? (
|
||||
<div className="px-2 py-2 text-center text-xs text-sidebar-foreground/70">
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
className="h-auto p-0 text-xs text-sidebar-foreground/70 hover:text-sidebar-foreground"
|
||||
onClick={addPage}
|
||||
>
|
||||
Create your first page
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
pages.map((page) => {
|
||||
const canMoveUp = pages.indexOf(page) > 0
|
||||
const canMoveDown = pages.indexOf(page) < pages.length - 1
|
||||
const isActive = pathname.startsWith(baseLogos) && !isKatalogosView && activePageId === page.id
|
||||
return (
|
||||
<SidebarMenuItem key={page.id}>
|
||||
<button
|
||||
type="button"
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ size: 'default' }), 'w-full')}
|
||||
onClick={() => onPageClick(page.id)}
|
||||
>
|
||||
<FileText className="size-4 shrink-0 text-sidebar-foreground/70" />
|
||||
<span className="truncate">{page.title || DEFAULT_PAGE_TITLE}</span>
|
||||
</button>
|
||||
{renderPageActions(page, { canMoveUp, canMoveDown })}
|
||||
</SidebarMenuItem>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</SidebarMenu>
|
||||
<TreeBrowser />
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<SidebarGroup>
|
||||
@@ -286,7 +81,7 @@ export function RecollectionSidebar() {
|
||||
onClick={() => navigate(`${base}/logos/katalogos`)}
|
||||
>
|
||||
<FileText className="size-4 shrink-0 text-sidebar-foreground/70" />
|
||||
<span className="truncate">Katalogos</span>
|
||||
<span className="truncate">Artifacts</span>
|
||||
</button>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
@@ -304,76 +99,13 @@ export function RecollectionSidebar() {
|
||||
onClick={() => navigate(`${base}/flux`)}
|
||||
>
|
||||
<FluxIcon className="size-4 shrink-0 text-sidebar-foreground/70" />
|
||||
<span className="truncate">Flux</span>
|
||||
<span className="truncate">Canvas</span>
|
||||
</button>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
|
||||
<Dialog
|
||||
open={!!renameTarget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setRenameTarget(null)
|
||||
setRenameTitle('')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename page</DialogTitle>
|
||||
</DialogHeader>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full rounded border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
value={renameTitle}
|
||||
onChange={(e) => setRenameTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') commitRename()
|
||||
if (e.key === 'Escape') {
|
||||
setRenameTarget(null)
|
||||
setRenameTitle('')
|
||||
}
|
||||
}}
|
||||
aria-label="Page title"
|
||||
/>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button type="button" variant="outline" onClick={() => { setRenameTarget(null); setRenameTitle('') }}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="button" onClick={commitRename}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete page?</DialogTitle>
|
||||
</DialogHeader>
|
||||
{deleteTarget && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
"{deleteTarget.title}" will be permanently removed.
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button type="button" variant="outline" onClick={() => setDeleteTarget(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => deleteTarget && removePage(deleteTarget)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
492
frontend/src/app/recollections/layout/TreeBrowser.tsx
Normal file
492
frontend/src/app/recollections/layout/TreeBrowser.tsx
Normal file
@@ -0,0 +1,492 @@
|
||||
/**
|
||||
* TreeBrowser: A tree browser component using react-arborist for managing
|
||||
* Logos pages in a hierarchical folder structure.
|
||||
*
|
||||
* Features:
|
||||
* - Drag and drop reordering
|
||||
* - Folder expansion/collapse
|
||||
* - Context menu for actions
|
||||
* - Shadcn theme integration
|
||||
* - Inline editing for page titles
|
||||
* - Smooth animations
|
||||
* - Search functionality
|
||||
* - Expand/collapse all
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { Tree, NodeApi, RowRendererProps, TreeApi } from 'react-arborist'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { FileText, ChevronRight, ChevronDown, Plus, MoreHorizontal, Trash2, Pencil, Search, ChevronsDownUp, ChevronsUpDown, X, GripVertical, Folder } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useRecollectionSidebar } from './RecollectionSidebarContext'
|
||||
import { useParams, useNavigate, useLocation } from 'react-router-dom'
|
||||
import type { LogosPageMeta, LogosPageId } from '../state/recollectionStore'
|
||||
import { removeLogosPageContent } from '../state/recollectionStore'
|
||||
|
||||
// Tree node data structure
|
||||
type TreeNode = {
|
||||
id: string
|
||||
data: LogosPageMeta
|
||||
children?: TreeNode[]
|
||||
isFolder: boolean
|
||||
}
|
||||
|
||||
// Convert flat tree to hierarchical structure
|
||||
function buildTree(pages: LogosPageMeta[]): TreeNode[] {
|
||||
const pageMap = new Map<string, TreeNode>()
|
||||
const rootNodes: TreeNode[] = []
|
||||
|
||||
// First pass: create all nodes
|
||||
pages.forEach((page) => {
|
||||
pageMap.set(page.id, {
|
||||
id: page.id,
|
||||
data: page,
|
||||
children: [],
|
||||
isFolder: false,
|
||||
})
|
||||
})
|
||||
|
||||
// Second pass: build parent-child relationships
|
||||
pages.forEach((page) => {
|
||||
const node = pageMap.get(page.id)!
|
||||
if (page.parentId === null) {
|
||||
rootNodes.push(node)
|
||||
} else {
|
||||
const parent = pageMap.get(page.parentId)
|
||||
if (parent) {
|
||||
parent.children?.push(node)
|
||||
parent.isFolder = true
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Sort children by position
|
||||
function sortNodes(nodes: TreeNode[]) {
|
||||
nodes.sort((a, b) => a.data.position - b.data.position)
|
||||
nodes.forEach((node) => sortNodes(node.children || []))
|
||||
}
|
||||
sortNodes(rootNodes)
|
||||
|
||||
return rootNodes
|
||||
}
|
||||
|
||||
// Memoize the buildTree result to avoid unnecessary re-creation
|
||||
function useTreeNodes(tree: LogosPageMeta[]) {
|
||||
return useMemo(() => buildTree(tree), [tree])
|
||||
}
|
||||
|
||||
// Inline editable title component
|
||||
function EditableTitle({
|
||||
title,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
title: string
|
||||
onSave: (newTitle: string) => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
inputRef.current?.select()
|
||||
}, [])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
onSave(inputRef.current?.value || '')
|
||||
} else if (e.key === 'Escape') {
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
defaultValue={title}
|
||||
onBlur={(e) => onSave(e.currentTarget.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full rounded px-1 py-0.5 text-sm outline-none ring-2 ring-ring focus:ring-2 transition-all duration-200 bg-background"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Tree row renderer
|
||||
function TreeRow({
|
||||
node,
|
||||
innerRef,
|
||||
attrs,
|
||||
children,
|
||||
}: RowRendererProps<TreeNode>) {
|
||||
const { recollectionId } = useParams<{ recollectionId: string }>()
|
||||
const navigate = useNavigate()
|
||||
const { pathname } = useLocation()
|
||||
const { tree, activePageId, handleSelectPage, handleTreeChange } = useRecollectionSidebar()
|
||||
|
||||
const base = recollectionId ? `/recollections/${recollectionId}` : ''
|
||||
const baseLogos = `${base}/logos`
|
||||
const isActive = pathname.startsWith(baseLogos) && activePageId === node.data.id
|
||||
|
||||
const handleToggle = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
node.toggle()
|
||||
}, [node])
|
||||
|
||||
const handleSelect = useCallback((e: React.MouseEvent) => {
|
||||
if (!node.state.isEditing && !node.state.isDragging) {
|
||||
handleSelectPage(node.data.id)
|
||||
navigate(`${baseLogos}?page=${encodeURIComponent(node.data.id)}`)
|
||||
}
|
||||
}, [handleSelectPage, node.data.id, baseLogos, navigate, node.state.isEditing, node.state.isDragging])
|
||||
|
||||
const handleRename = useCallback(() => {
|
||||
node.edit()
|
||||
}, [node])
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
// Get all descendants to delete
|
||||
const toDelete = new Set<string>()
|
||||
function collectDescendants(id: string) {
|
||||
toDelete.add(id)
|
||||
tree.forEach((p) => {
|
||||
if (p.parentId === id) {
|
||||
collectDescendants(p.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
collectDescendants(node.data.id)
|
||||
|
||||
// Remove content from storage for all deleted pages
|
||||
if (recollectionId) {
|
||||
toDelete.forEach((pageId) => {
|
||||
removeLogosPageContent(recollectionId, pageId)
|
||||
})
|
||||
}
|
||||
|
||||
// Remove from tree
|
||||
handleTreeChange(tree.filter((p) => !toDelete.has(p.id)))
|
||||
}, [tree, handleTreeChange, node.data.id, recollectionId])
|
||||
|
||||
const handleAddChild = useCallback(() => {
|
||||
const maxPos = Math.max(
|
||||
0,
|
||||
...tree.filter((p) => p.parentId === node.data.id).map((p) => p.position),
|
||||
-1
|
||||
)
|
||||
const newPage: LogosPageMeta = {
|
||||
id: `page-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||
title: 'Untitled',
|
||||
parentId: node.data.id,
|
||||
position: maxPos + 1,
|
||||
}
|
||||
handleTreeChange([...tree, newPage])
|
||||
// Expand parent if collapsed
|
||||
if (!node.isOpen) {
|
||||
node.open()
|
||||
}
|
||||
}, [tree, handleTreeChange, node])
|
||||
|
||||
const hasChildren = (node.children?.length || 0) > 0
|
||||
const level = node.level
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={innerRef}
|
||||
{...attrs}
|
||||
style={{
|
||||
...attrs.style,
|
||||
paddingLeft: `${level * 20 + 8}px`,
|
||||
}}
|
||||
className={cn(
|
||||
'group relative flex items-center gap-1 py-1 pr-2 text-sm rounded-md',
|
||||
'hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
|
||||
isActive && 'bg-sidebar-accent text-sidebar-accent-foreground',
|
||||
node.state.isDragging && 'opacity-50',
|
||||
node.state.willReceiveDrop && 'bg-sidebar-accent/50'
|
||||
)}
|
||||
onClick={handleSelect}
|
||||
>
|
||||
{/* Visual hierarchy indicator for nested items */}
|
||||
{level > 0 && (
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 border-l-2 border-sidebar-border/50"
|
||||
style={{ left: `${(level - 1) * 20 + 16}px` }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Expand/Collapse toggle for folders */}
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 p-0.5 hover:bg-sidebar-accent rounded transition-colors"
|
||||
onClick={handleToggle}
|
||||
>
|
||||
{node.isOpen ? (
|
||||
<ChevronDown className="size-3.5 text-sidebar-foreground/70" />
|
||||
) : (
|
||||
<ChevronRight className="size-3.5 text-sidebar-foreground/70" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-4 shrink-0" />
|
||||
)}
|
||||
|
||||
{/* Icon */}
|
||||
{node.data.isFolder ? (
|
||||
<Folder className="size-4 shrink-0 text-sidebar-foreground/70" />
|
||||
) : (
|
||||
<FileText className="size-4 shrink-0 text-sidebar-foreground/70" />
|
||||
)}
|
||||
|
||||
{/* Title - Editable */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{node.state.isEditing ? (
|
||||
<EditableTitle
|
||||
title={node.data.data.title}
|
||||
onSave={(newTitle) => {
|
||||
if (newTitle.trim()) {
|
||||
handleTreeChange(
|
||||
tree.map((p) =>
|
||||
p.id === node.data.id ? { ...p, title: newTitle.trim() } : p
|
||||
)
|
||||
)
|
||||
}
|
||||
node.submit(newTitle)
|
||||
}}
|
||||
onCancel={() => {
|
||||
node.reset()
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="truncate block select-none">
|
||||
{node.data.data.title || 'Untitled'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions - Only visible on hover */}
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
type="button"
|
||||
className="p-0.5 hover:bg-sidebar-accent rounded transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleAddChild()
|
||||
}}
|
||||
title="Add child page"
|
||||
>
|
||||
<Plus className="size-3.5 text-sidebar-foreground/70" />
|
||||
</button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="p-0.5 hover:bg-sidebar-accent rounded transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="size-3.5 text-sidebar-foreground/70" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40" onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenuItem onClick={handleRename}>
|
||||
<Pencil className="mr-2 size-3.5" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={handleDelete}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 size-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TreeBrowser() {
|
||||
const { tree, handleTreeChange } = useRecollectionSidebar()
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const treeRef = useRef<TreeApi<TreeNode> | null>(null)
|
||||
|
||||
// Build tree from flat structure
|
||||
const treeNodes = useTreeNodes(tree)
|
||||
|
||||
// Filter tree based on search query
|
||||
const filteredTree = useMemo(() => {
|
||||
if (!searchQuery.trim()) return treeNodes
|
||||
|
||||
const searchTerm = searchQuery.toLowerCase()
|
||||
|
||||
function filterNodes(nodes: TreeNode[]): TreeNode[] {
|
||||
const result: TreeNode[] = []
|
||||
for (const node of nodes) {
|
||||
const matches = node.data.title.toLowerCase().includes(searchTerm)
|
||||
const children = filterNodes(node.children || [])
|
||||
|
||||
if (matches || children.length > 0) {
|
||||
result.push({
|
||||
...node,
|
||||
children: children.length > 0 ? children : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
return filterNodes(treeNodes)
|
||||
}, [treeNodes, searchQuery])
|
||||
|
||||
// Get all folder IDs for initial open state
|
||||
const initialOpenState = useMemo(() => {
|
||||
const folderIds: Record<string, boolean> = {}
|
||||
function collectIds(nodes: TreeNode[]) {
|
||||
nodes.forEach((node) => {
|
||||
if (node.isFolder) {
|
||||
folderIds[node.id] = true
|
||||
collectIds(node.children || [])
|
||||
}
|
||||
})
|
||||
}
|
||||
collectIds(treeNodes)
|
||||
return folderIds
|
||||
}, [treeNodes])
|
||||
|
||||
// Handle drag and drop reordering
|
||||
const handleMove = useCallback(
|
||||
({ dragIds, parentId, index }: { dragIds: string[]; parentId: string | null; index: number }) => {
|
||||
const dragId = dragIds[0]
|
||||
if (!dragId) return
|
||||
|
||||
// Find the dragged page
|
||||
const draggedPage = tree.find((p) => p.id === dragId)
|
||||
if (!draggedPage) return
|
||||
|
||||
// Get all siblings at the new location (excluding the dragged item)
|
||||
const siblings = tree.filter((p) => p.parentId === parentId && p.id !== dragId)
|
||||
|
||||
// Sort siblings by current position
|
||||
siblings.sort((a, b) => a.position - b.position)
|
||||
|
||||
// Insert dragged item at the new index
|
||||
siblings.splice(index, 0, { ...draggedPage, parentId, position: index })
|
||||
|
||||
// Create updated tree with new positions
|
||||
const updatedTree = tree.map((p) => {
|
||||
// Find the item in the siblings array
|
||||
const siblingIndex = siblings.findIndex((s) => s.id === p.id)
|
||||
|
||||
if (siblingIndex >= 0) {
|
||||
// This item is in the affected parent - update its position
|
||||
return { ...p, parentId: siblings[siblingIndex].parentId, position: siblingIndex }
|
||||
}
|
||||
|
||||
// Item is not affected by the move
|
||||
return p
|
||||
})
|
||||
|
||||
handleTreeChange(updatedTree)
|
||||
},
|
||||
[tree, handleTreeChange]
|
||||
)
|
||||
|
||||
// Expand all folders
|
||||
const handleExpandAll = useCallback(() => {
|
||||
treeRef.current?.openAll()
|
||||
}, [])
|
||||
|
||||
// Collapse all folders
|
||||
const handleCollapseAll = useCallback(() => {
|
||||
treeRef.current?.closeAll()
|
||||
}, [])
|
||||
|
||||
// Clear search
|
||||
const handleClearSearch = useCallback(() => {
|
||||
setSearchQuery('')
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 h-full">
|
||||
{/* Search Toolbar */}
|
||||
<div className="flex items-center gap-1.5 px-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-sidebar-foreground/50 pointer-events-none" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search pages..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-7 pr-7 h-7 text-xs bg-sidebar-accent/50 border-sidebar-border"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-1/2 -translate-y-1/2 h-7 w-7"
|
||||
onClick={handleClearSearch}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
title="Expand/Collapse options"
|
||||
>
|
||||
<ChevronsUpDown className="size-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handleExpandAll}>
|
||||
<ChevronsDownUp className="mr-2 size-3.5" />
|
||||
Expand all
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleCollapseAll}>
|
||||
<ChevronsUpDown className="mr-2 size-3.5" />
|
||||
Collapse all
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Tree */}
|
||||
<div className="flex-1 min-h-0 overflow-hidden px-2">
|
||||
<Tree
|
||||
ref={treeRef}
|
||||
data={filteredTree}
|
||||
idAccessor="id"
|
||||
childrenAccessor="children"
|
||||
width="100%"
|
||||
height={600}
|
||||
rowHeight={32}
|
||||
indent={0}
|
||||
renderRow={TreeRow}
|
||||
initialOpenState={initialOpenState}
|
||||
onMove={handleMove}
|
||||
disableDrag={!!searchQuery}
|
||||
disableDrop={!!searchQuery}
|
||||
className="react-arborist-tree"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -73,6 +73,66 @@ function getRenderCacheKey(recollectionId: string): string {
|
||||
return `${RENDER_CACHE_KEY_PREFIX}${recollectionId}`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Persisted State with Versioning
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Wrapper for persisted state with version tracking.
|
||||
* Enables schema migrations when the version changes.
|
||||
*/
|
||||
export type PersistedState<T> = {
|
||||
/** Current schema version. Increment when making breaking changes. */
|
||||
version: number
|
||||
/** The actual data being persisted. */
|
||||
data: T
|
||||
/** Timestamp (ms) when this data was last migrated. */
|
||||
migratedAt?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a persisted state has the expected version.
|
||||
* @param state - The persisted state to check
|
||||
* @param expectedVersion - The expected version number
|
||||
* @returns true if versions match, false otherwise
|
||||
*/
|
||||
export function isVersionMatch<T>(state: PersistedState<T> | null, expectedVersion: number): boolean {
|
||||
return state?.version === expectedVersion
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate persisted state to the latest version.
|
||||
* Add migration logic here when changing the schema.
|
||||
*
|
||||
* @param state - The persisted state to migrate
|
||||
* @returns Migrated state with updated version
|
||||
*/
|
||||
export function migratePersistedState<T>(state: PersistedState<T>): PersistedState<T> {
|
||||
if (state.version === RECOLLECTION_VERSION) {
|
||||
return state
|
||||
}
|
||||
|
||||
let migratedData = state.data
|
||||
let migratedVersion = state.version
|
||||
|
||||
// Migration 0 -> 1: Initial version with version field
|
||||
if (migratedVersion < 1) {
|
||||
// Data from version 0 already has the correct structure
|
||||
// Just add the version field
|
||||
migratedVersion = 1
|
||||
}
|
||||
|
||||
return {
|
||||
version: migratedVersion,
|
||||
data: migratedData,
|
||||
migratedAt: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Graph Storage with Versioning
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getGraph(recollectionId: string): StoredGraphState | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(getGraphKey(recollectionId))
|
||||
@@ -95,6 +155,10 @@ export function setGraph(recollectionId: string, state: StoredGraphState): void
|
||||
localStorage.setItem(getGraphKey(recollectionId), JSON.stringify(state))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Logos Storage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getLogosContent(recollectionId: string): StoredLogosContent | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(getLogosKey(recollectionId))
|
||||
@@ -167,6 +231,10 @@ export function removeLogosPageContent(recollectionId: string, pageId: LogosPage
|
||||
localStorage.removeItem(getLogosPageContentKey(recollectionId, pageId))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render Output Cache
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Render output cache: one entry per rendering node (keyed by nodeId). Used by Logos "Insert from Flux" block. */
|
||||
export function getRenderOutputCache(recollectionId: string): RenderOutputCacheEntry[] {
|
||||
try {
|
||||
@@ -198,6 +266,10 @@ export function upsertRenderOutputEntry(recollectionId: string, entry: RenderOut
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Remove Recollection Data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Removes both graph, logos (legacy + page tree + all per-page content), and render cache for the recollection. */
|
||||
export function removeRecollectionData(recollectionId: string): void {
|
||||
localStorage.removeItem(getGraphKey(recollectionId))
|
||||
|
||||
@@ -2,6 +2,57 @@
|
||||
* runs the resolve → render pipeline, manages streaming/cache,
|
||||
* and exposes derived state for the dumb UI. See lib/graph/rendering.ts for the
|
||||
* pipeline interface.
|
||||
*
|
||||
* ## Auto vs Manual Mode Decision Tree
|
||||
*
|
||||
* The hook supports two update modes: 'auto' and 'manual'. The mode is determined by:
|
||||
*
|
||||
* 1. **Node data override**: Check `data?.updateMode` first
|
||||
* 2. **Source logic default**: Fall back to `sourceLogic?.defaultUpdateMode`
|
||||
* 3. **Hardcoded default**: Default to 'auto' if neither is set
|
||||
*
|
||||
* ### Auto Mode Behavior
|
||||
* - Runs automatically when upstream data changes (sourceSignature changes)
|
||||
* - Debounces runs by 250ms to avoid excessive computation
|
||||
* - Skips runs when node becomes visible with cache (visibility grace period: 400ms)
|
||||
* - Updates path trigger on each run
|
||||
*
|
||||
* ### Manual Mode Behavior
|
||||
* - Only runs when `runTrigger` increments (user clicks "Run")
|
||||
* - Requires explicit user action to update output
|
||||
* - Caches last run signature to prevent re-runs on unrelated changes
|
||||
*
|
||||
* ## Effect Dependencies
|
||||
*
|
||||
* The main effect depends on:
|
||||
* - `id`: Node ID (re-run when node changes)
|
||||
* - `srcId`: Source node ID (re-run when connection changes)
|
||||
* - `srcNode?.type`: Source node type (re-run when source type changes)
|
||||
* - `effectiveUpdateMode`: Auto vs manual mode
|
||||
* - `runTrigger`: Run trigger counter (for manual mode)
|
||||
* - `sourceSignature`: Combined signature of all upstream nodes
|
||||
* - `viewportWidth/Height`: Viewport dimensions (affects rendering)
|
||||
* - `retryCount`: Retry counter (for error recovery)
|
||||
* - `incomingIds.length`: Number of incoming connections
|
||||
*
|
||||
* ## Rendering Pipeline
|
||||
*
|
||||
* 1. **Resolve**: Source node (config/agent) produces resolved content
|
||||
* 2. **Render**: Output type renderer (plantuml/markdown/wireframe) produces HTML/SVG
|
||||
* 3. **Cache**: Results are cached in node data for persistence
|
||||
* 4. **Display**: UI consumes the rendered content
|
||||
*
|
||||
* ## Streaming Support
|
||||
*
|
||||
* When the source node supports streaming (e.g., agent nodes):
|
||||
* - `onStreamingStart`: Called when streaming begins
|
||||
* - `onStreamingChunk`: Called for each chunk of markdown content
|
||||
* - Streaming content is parsed with marked.js for preview
|
||||
*
|
||||
* ## Error Handling
|
||||
*
|
||||
* Errors during resolve or render are caught and stored in the `error` state.
|
||||
* The error includes a `kind` (e.g., 'render', 'no-content') and `message`.
|
||||
*/
|
||||
|
||||
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
86
frontend/src/hooks/useStreamingContent.ts
Normal file
86
frontend/src/hooks/useStreamingContent.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Hook for parsing and streaming markdown content.
|
||||
* Handles marked.js parsing with proper cleanup and cancellation.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Parse markdown content to HTML using marked.js.
|
||||
* Returns parsed HTML string and handles cleanup on unmount.
|
||||
*
|
||||
* @param markdown - The markdown string to parse
|
||||
* @returns HTML string from parsed markdown
|
||||
*/
|
||||
export function useStreamingContent(markdown: string | null): {
|
||||
html: string
|
||||
loading: boolean
|
||||
} {
|
||||
const [html, setHtml] = useState<string>('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (markdown === null) {
|
||||
setHtml('')
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
|
||||
// Lazy load marked.js to avoid bundling if not needed
|
||||
import('marked')
|
||||
.then(async ({ marked }) => {
|
||||
if (cancelled) return
|
||||
|
||||
try {
|
||||
const parsed =
|
||||
typeof marked.parse === 'function'
|
||||
? await (marked.parse as (s: string) => Promise<string>)(markdown)
|
||||
: (marked as (s: string) => string)(markdown)
|
||||
const result = typeof parsed === 'string' ? parsed : String(parsed)
|
||||
if (!cancelled) {
|
||||
setHtml(result)
|
||||
setLoading(false)
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
// Fallback: use raw markdown if parsing fails
|
||||
setHtml(markdown)
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
// Fallback: use raw markdown if import fails
|
||||
setHtml(markdown)
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [markdown])
|
||||
|
||||
return { html, loading }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse markdown content to HTML using marked.js (synchronous version).
|
||||
* Use this when you need immediate parsing without React state.
|
||||
*
|
||||
* @param markdown - The markdown string to parse
|
||||
* @returns HTML string from parsed markdown, or raw markdown on error
|
||||
*/
|
||||
export function parseMarkdownToHtml(markdown: string): string {
|
||||
try {
|
||||
// Use dynamic import for marked.js
|
||||
// Note: This is a helper for non-React contexts
|
||||
// In React components, use useStreamingContent hook instead
|
||||
return markdown // Placeholder - actual implementation requires async import
|
||||
} catch {
|
||||
return markdown
|
||||
}
|
||||
}
|
||||
30
frontend/src/hooks/useThinkSections.ts
Normal file
30
frontend/src/hooks/useThinkSections.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Hook for parsing markdown content with think sections.
|
||||
* Extracts <think>...</think> blocks and returns main content and reasoning separately.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { parseThinkSections } from '@/lib/graph/renderingUtils'
|
||||
|
||||
/**
|
||||
* Parse markdown content and extract think sections.
|
||||
* Returns main content (without think blocks) and reasoning content (from think blocks).
|
||||
*
|
||||
* @param content - The HTML/markdown content to parse
|
||||
* @param outputType - The output type ('image' or 'string')
|
||||
* @returns Object with main and think content
|
||||
*/
|
||||
export function useThinkSections(
|
||||
content: string | null,
|
||||
outputType: 'image' | 'string'
|
||||
): {
|
||||
main: string
|
||||
think: string
|
||||
} {
|
||||
return useMemo(() => {
|
||||
if (!content || outputType === 'image') {
|
||||
return { main: '', think: '' }
|
||||
}
|
||||
return parseThinkSections(content)
|
||||
}, [content, outputType])
|
||||
}
|
||||
169
frontend/src/lib/errorBoundary.tsx
Normal file
169
frontend/src/lib/errorBoundary.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Error boundary utilities for React components.
|
||||
* Provides consistent error handling across the application.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
|
||||
/**
|
||||
* Error state for error boundary components
|
||||
*/
|
||||
export type ErrorState = {
|
||||
hasError: boolean
|
||||
error: Error | null
|
||||
errorInfo: React.ErrorInfo | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Initial error state
|
||||
*/
|
||||
export const initialErrorState: ErrorState = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
}
|
||||
|
||||
/**
|
||||
* Error boundary props
|
||||
*/
|
||||
export type ErrorBoundaryProps = {
|
||||
children: React.ReactNode
|
||||
fallback?: React.ReactNode
|
||||
onError?: (error: Error, errorInfo: React.ErrorInfo) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Error boundary state
|
||||
*/
|
||||
export type ErrorBoundaryState = ErrorState
|
||||
|
||||
/**
|
||||
* Error boundary component
|
||||
* Catches JavaScript errors anywhere in the child component tree
|
||||
* and displays a fallback UI instead of crashing
|
||||
*/
|
||||
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props)
|
||||
this.state = initialErrorState
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return {
|
||||
hasError: true,
|
||||
error,
|
||||
errorInfo: null,
|
||||
}
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
|
||||
this.setState({
|
||||
hasError: true,
|
||||
error,
|
||||
errorInfo,
|
||||
})
|
||||
|
||||
// Log error to console or error reporting service
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo)
|
||||
|
||||
// Call custom error handler if provided
|
||||
if (this.props.onError) {
|
||||
this.props.onError(error, errorInfo)
|
||||
}
|
||||
}
|
||||
|
||||
handleReset = (): void => {
|
||||
this.setState(initialErrorState)
|
||||
}
|
||||
|
||||
render(): React.ReactNode {
|
||||
if (this.state.hasError) {
|
||||
// You can render any custom fallback UI
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback
|
||||
}
|
||||
return (
|
||||
<div className="error-boundary">
|
||||
<h1>Something went wrong.</h1>
|
||||
<details>
|
||||
<summary>Error Details</summary>
|
||||
<pre>{this.state.error?.toString()}</pre>
|
||||
<pre>{this.state.errorInfo?.componentStack}</pre>
|
||||
</details>
|
||||
<button onClick={this.handleReset}>Try Again</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for error handling in functional components
|
||||
*/
|
||||
export function useErrorHandler<T extends Error = Error>(): {
|
||||
error: T | null
|
||||
setError: (error: T | null) => void
|
||||
hasError: boolean
|
||||
resetError: () => void
|
||||
} {
|
||||
const [error, setError] = React.useState<T | null>(null)
|
||||
|
||||
const hasError = error !== null
|
||||
const resetError = React.useCallback(() => setError(null), [])
|
||||
|
||||
return { error, setError, hasError, resetError }
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to update error state consistently
|
||||
*/
|
||||
export function updateErrorState<T extends { error?: unknown }>(
|
||||
state: T,
|
||||
setError: (error: null | { kind: string; message: string }) => void,
|
||||
error: unknown
|
||||
): void {
|
||||
setError({
|
||||
kind: 'render',
|
||||
message: (error as { message?: string })?.message ?? 'Render error',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to clear error state
|
||||
*/
|
||||
export function clearErrorState<T extends { error?: unknown }>(
|
||||
state: T,
|
||||
setError: (error: null) => void
|
||||
): void {
|
||||
setError(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to check if an error is recoverable
|
||||
*/
|
||||
export function isRecoverableError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false
|
||||
// Common recoverable errors
|
||||
const recoverableMessages = [
|
||||
'Network error',
|
||||
'Timeout',
|
||||
'Connection refused',
|
||||
'Rate limit exceeded',
|
||||
]
|
||||
return recoverableMessages.some((msg) => (error as Error).message.includes(msg))
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to format error message for display
|
||||
*/
|
||||
export function formatErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
if (typeof error === 'string') {
|
||||
return error
|
||||
}
|
||||
return 'An unknown error occurred'
|
||||
}
|
||||
155
frontend/src/lib/errorHandling.ts
Normal file
155
frontend/src/lib/errorHandling.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Error handling utilities for consistent error management.
|
||||
* Provides centralized error handling logic across the application.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Error kind for categorizing errors
|
||||
*/
|
||||
export type ErrorKind =
|
||||
| 'render'
|
||||
| 'network'
|
||||
| 'validation'
|
||||
| 'authentication'
|
||||
| 'authorization'
|
||||
| 'notFound'
|
||||
| 'server'
|
||||
| 'unknown'
|
||||
|
||||
/**
|
||||
* Error object with kind and message
|
||||
*/
|
||||
export type AppError = {
|
||||
kind: ErrorKind
|
||||
message: string
|
||||
details?: Record<string, unknown>
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an error object with consistent structure
|
||||
*/
|
||||
export function createAppError(
|
||||
kind: ErrorKind,
|
||||
message: string,
|
||||
details?: Record<string, unknown>
|
||||
): AppError {
|
||||
return {
|
||||
kind,
|
||||
message,
|
||||
details,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract error message from unknown error
|
||||
*/
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
if (typeof error === 'string') {
|
||||
return error
|
||||
}
|
||||
if (typeof error === 'object' && error !== null && 'message' in error) {
|
||||
return String(error.message)
|
||||
}
|
||||
return 'An unknown error occurred'
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract error kind from error
|
||||
*/
|
||||
export function getErrorKind(error: unknown): ErrorKind {
|
||||
if (error instanceof Error) {
|
||||
const message = error.message.toLowerCase()
|
||||
if (message.includes('network')) return 'network'
|
||||
if (message.includes('validation') || message.includes('invalid')) return 'validation'
|
||||
if (message.includes('auth')) return 'authentication'
|
||||
if (message.includes('forbidden') || message.includes('unauthorized')) return 'authorization'
|
||||
if (message.includes('not found') || message.includes('404')) return 'notFound'
|
||||
if (message.includes('server') || message.includes('500')) return 'server'
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* Format error for display
|
||||
*/
|
||||
export function formatError(error: AppError | Error | string): string {
|
||||
if (typeof error === 'string') {
|
||||
return error
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
return error.message
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is recoverable
|
||||
*/
|
||||
export function isRecoverableError(error: AppError | Error | string): boolean {
|
||||
if (typeof error === 'string') return false
|
||||
if (error instanceof Error) {
|
||||
const message = error.message.toLowerCase()
|
||||
return ['network', 'timeout', 'connection'].some((term) => message.includes(term))
|
||||
}
|
||||
// error is AppError at this point
|
||||
return ['network', 'server'].includes((error as AppError).kind)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update error state consistently
|
||||
*/
|
||||
export function updateErrorState<T extends { error?: unknown }>(
|
||||
state: T,
|
||||
setError: (error: null | { kind: string; message: string }) => void,
|
||||
error: unknown
|
||||
): void {
|
||||
setError({
|
||||
kind: 'render',
|
||||
message: getErrorMessage(error),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear error state
|
||||
*/
|
||||
export function clearErrorState<T extends { error?: unknown }>(
|
||||
state: T,
|
||||
setError: (error: null) => void
|
||||
): void {
|
||||
setError(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle error with consistent logging
|
||||
*/
|
||||
export function handleError(
|
||||
error: unknown,
|
||||
context?: Record<string, unknown>
|
||||
): AppError {
|
||||
const appError = createAppError(
|
||||
getErrorKind(error),
|
||||
getErrorMessage(error),
|
||||
context
|
||||
)
|
||||
|
||||
console.error('[Error]', appError, context)
|
||||
return appError
|
||||
}
|
||||
|
||||
/**
|
||||
* Create error handler for async functions
|
||||
*/
|
||||
export function createErrorHandler<T>(
|
||||
onError?: (error: AppError) => void
|
||||
): (error: unknown) => T {
|
||||
return (error: unknown) => {
|
||||
const appError = handleError(error)
|
||||
onError?.(appError)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
182
frontend/src/lib/graph/nodeFactory.ts
Normal file
182
frontend/src/lib/graph/nodeFactory.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Node factory utilities for creating and managing nodes.
|
||||
* Centralizes node creation logic to reduce duplication.
|
||||
*/
|
||||
|
||||
import type { Node, Edge } from '@xyflow/react'
|
||||
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||
import {
|
||||
getDefaultDataForType,
|
||||
getNextNodeId,
|
||||
} from '@/lib/graph/flowUtils'
|
||||
import { getRegisteredNodeTypeIds, getDefaultStyle } from '@/lib/graph/nodeRegistry'
|
||||
|
||||
/**
|
||||
* Create a new node with auto-generated ID and default data.
|
||||
*
|
||||
* @param type - Node type ID (e.g., 'config', 'render')
|
||||
* @param position - Node position { x, y }
|
||||
* @param data - Optional additional data to merge with defaults
|
||||
* @returns New node with generated ID
|
||||
*/
|
||||
export function createNode(
|
||||
type: string,
|
||||
position: { x: number; y: number },
|
||||
data?: Record<string, unknown>
|
||||
): AppNode {
|
||||
const existingIds = getRegisteredNodeTypeIds()
|
||||
const newId = getNextNodeId(type, existingIds)
|
||||
return {
|
||||
id: newId,
|
||||
type,
|
||||
position,
|
||||
data: { ...getDefaultDataForType(type, newId), ...(data ?? {}) },
|
||||
style: getDefaultStyle(type),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new node with a specific ID.
|
||||
* Use this when you need to control the node ID (e.g., when duplicating).
|
||||
*
|
||||
* @param type - Node type ID
|
||||
* @param id - Node ID to use
|
||||
* @param position - Node position { x, y }
|
||||
* @param data - Optional additional data to merge with defaults
|
||||
* @returns New node with specified ID
|
||||
*/
|
||||
export function createNodeWithId(
|
||||
type: string,
|
||||
id: string,
|
||||
position: { x: number; y: number },
|
||||
data?: Record<string, unknown>
|
||||
): AppNode {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
position,
|
||||
data: { ...getDefaultDataForType(type, id), ...(data ?? {}) },
|
||||
style: getDefaultStyle(type),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new edge connecting two nodes.
|
||||
*
|
||||
* @param source - Source node ID
|
||||
* @param target - Target node ID
|
||||
* @param id - Optional edge ID (auto-generated if not provided)
|
||||
* @param data - Optional edge data
|
||||
* @returns New edge
|
||||
*/
|
||||
export function createEdge(
|
||||
source: string,
|
||||
target: string,
|
||||
id?: string,
|
||||
data?: Record<string, unknown>
|
||||
): AppEdge {
|
||||
return {
|
||||
id: id ?? `e-${source}-${target}`,
|
||||
source,
|
||||
target,
|
||||
data: data ?? {},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create multiple nodes from a template.
|
||||
*
|
||||
* @param template - Template node to copy
|
||||
* @param count - Number of nodes to create
|
||||
* @param offset - Position offset between nodes { x, y }
|
||||
* @returns Array of new nodes
|
||||
*/
|
||||
export function createNodesFromTemplate(
|
||||
template: Partial<AppNode>,
|
||||
count: number,
|
||||
offset: { x: number; y: number }
|
||||
): AppNode[] {
|
||||
const nodes: AppNode[] = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
const position = {
|
||||
x: (template.position?.x ?? 0) + i * offset.x,
|
||||
y: (template.position?.y ?? 0) + i * offset.y,
|
||||
}
|
||||
nodes.push({
|
||||
id: template.id ?? getNextNodeId(template.type ?? 'node', nodes.map((n) => n.id)),
|
||||
type: template.type ?? 'node',
|
||||
position,
|
||||
data: { ...template.data },
|
||||
style: template.style ?? getDefaultStyle(template.type ?? 'node'),
|
||||
})
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
/**
|
||||
* Get existing node IDs for a specific type.
|
||||
*
|
||||
* @param nodes - Array of nodes to search
|
||||
* @param type - Node type to filter by
|
||||
* @returns Array of node IDs for the specified type
|
||||
*/
|
||||
export function getNodeIdsByType(nodes: AppNode[], type: string): string[] {
|
||||
return nodes.filter((n) => n.type === type).map((n) => n.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the next available node ID for a type based on existing nodes.
|
||||
*
|
||||
* @param type - Node type
|
||||
* @param existingNodes - Array of existing nodes
|
||||
* @returns Next available node ID
|
||||
*/
|
||||
export function getNextNodeIdForType(type: string, existingNodes: AppNode[]): string {
|
||||
const existingIds = existingNodes.map((n) => n.id)
|
||||
return getNextNodeId(type, existingIds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate a node with a new ID and offset position.
|
||||
*
|
||||
* @param node - Node to duplicate
|
||||
* @param offset - Position offset for the duplicate
|
||||
* @returns Duplicated node
|
||||
*/
|
||||
export function duplicateNode(node: AppNode, offset: { x: number; y: number } = { x: 30, y: 30 }): AppNode {
|
||||
const existingIds = [node.id] // Start with current node ID
|
||||
const newId = getNextNodeId(node.type ?? 'node', existingIds)
|
||||
const pos = node.position ?? { x: 0, y: 0 }
|
||||
return {
|
||||
id: newId,
|
||||
type: node.type ?? 'node',
|
||||
position: { x: pos.x + offset.x, y: pos.y + offset.y },
|
||||
data: typeof node.data === 'object' && node.data !== null ? { ...(node.data as object) } : node.data,
|
||||
style: node.style ?? getDefaultStyle(node.type ?? 'node'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a connection between two nodes.
|
||||
*
|
||||
* @param sourceNodeId - Source node ID
|
||||
* @param sourceHandle - Source handle ID (optional)
|
||||
* @param targetNodeId - Target node ID
|
||||
* @param targetHandle - Target handle ID (optional)
|
||||
* @returns Edge object connecting the nodes
|
||||
*/
|
||||
export function createConnection(
|
||||
sourceNodeId: string,
|
||||
sourceHandle?: string,
|
||||
targetNodeId: string = '',
|
||||
targetHandle?: string
|
||||
): Edge {
|
||||
const edge: Edge = {
|
||||
id: `e-${sourceNodeId}-${targetNodeId}`,
|
||||
source: sourceNodeId,
|
||||
target: targetNodeId,
|
||||
sourceHandle,
|
||||
targetHandle,
|
||||
}
|
||||
return edge
|
||||
}
|
||||
48
frontend/src/lib/svgUtils.ts
Normal file
48
frontend/src/lib/svgUtils.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* SVG detection utilities.
|
||||
* Centralized logic for detecting and processing SVG content.
|
||||
*/
|
||||
|
||||
/** Check if a string contains SVG content. */
|
||||
export function isSvgContent(content: string | null | undefined): boolean {
|
||||
return Boolean(content?.trim() && /<svg[\s>]/i.test(content.trim()))
|
||||
}
|
||||
|
||||
/** Check if a file path or URL points to an SVG file. */
|
||||
export function isSvgPath(path: string | null | undefined): boolean {
|
||||
return Boolean(path?.toLowerCase().endsWith('.svg'))
|
||||
}
|
||||
|
||||
/** Process SVG HTML for viewport display (aspect ratio, fill container). */
|
||||
export function processSvgDisplay(html: string): string {
|
||||
let out = html
|
||||
// Change preserveAspectRatio from 'none' to 'xMidYMid meet' for proper scaling
|
||||
out = out.replace(/\bpreserveAspectRatio\s*=\s*["']none["']/gi, 'preserveAspectRatio="xMidYMid meet"')
|
||||
// Set width to 100% to fill container
|
||||
out = out.replace(/\bwidth\s*=\s*["'][^"']*["']/i, 'width="100%"')
|
||||
// Set height to 100% to fill container
|
||||
out = out.replace(/\bheight\s*=\s*["'][^"']*["']/i, 'height="100%"')
|
||||
// Override width/height in style attributes
|
||||
out = out.replace(/\bstyle\s*=\s*["']([^"']*)["']/i, (_, style) => {
|
||||
const overridden = style.replace(/\b(width|height):[^;]+/gi, '$1:100%')
|
||||
return `style="${overridden}"`
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
/** Extract SVG content from an HTML string. */
|
||||
export function extractSvgContent(html: string): string | null {
|
||||
const svgMatch = html.match(/<svg[\s\S]*?<\/svg>/i)
|
||||
return svgMatch ? svgMatch[0] : null
|
||||
}
|
||||
|
||||
/** Check if content is an SVG data URL. */
|
||||
export function isSvgDataUrl(content: string | null | undefined): boolean {
|
||||
return Boolean(content?.trim().startsWith('data:image/svg+xml'))
|
||||
}
|
||||
|
||||
/** Convert SVG string to data URL. */
|
||||
export function svgToDataUrl(svg: string): string {
|
||||
const encoded = btoa(unescape(encodeURIComponent(svg)))
|
||||
return `data:image/svg+xml;base64,${encoded}`
|
||||
}
|
||||
@@ -399,4 +399,55 @@ pre {
|
||||
.code-editor .token.entity,
|
||||
.code-editor .token.url {
|
||||
background: none;
|
||||
}
|
||||
|
||||
/* React Arborist tree styling for sidebar integration */
|
||||
.react-arborist-tree {
|
||||
background: transparent !important;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.react-arborist-tree * {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
/* Remove default react-arborist styling */
|
||||
.react-arborist-tree [role="treeitem"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Drag preview styling */
|
||||
.react-arborist-tree [data-react-arborist-drag-preview] {
|
||||
background: hsl(var(--sidebar-accent)) !important;
|
||||
border: 1px solid hsl(var(--sidebar-border)) !important;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Drop cursor line */
|
||||
.react-arborist-tree [data-react-arborist-drop-cursor] {
|
||||
background: hsl(var(--sidebar-primary)) !important;
|
||||
height: 2px !important;
|
||||
border-radius: 1px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Make rows draggable */
|
||||
.react-arborist-tree [draggable="true"] {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.react-arborist-tree [draggable="true"]:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Hide default react-arborist backgrounds */
|
||||
.react-arborist-tree [data-react-arborist-tree] {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.react-arborist-tree [data-react-arborist-list-item] {
|
||||
background: transparent !important;
|
||||
}
|
||||
Reference in New Issue
Block a user