From c3cbe358833027d707c67dfa43f0a3137f78c2ce Mon Sep 17 00:00:00 2001 From: dtoro Date: Fri, 20 Mar 2026 00:04:46 +0100 Subject: [PATCH] fIx: smaller nits --- backend/package-lock.json | 678 ++++++++++++++++++ backend/package.json | 15 +- backend/src/index.js | 101 --- backend/src/index.ts | 102 +++ backend/src/middleware/rateLimiter.ts | 88 +++ backend/src/models/index.ts | 63 ++ backend/src/repositories/cacheRepository.ts | 103 +++ backend/src/routes/agentRoutes.ts | 72 ++ backend/src/services/agentService.ts | 65 ++ backend/tsconfig.json | 20 + frontend/index.html | 25 +- frontend/src/app/canvas/canvasStore.index.ts | 14 + .../src/app/canvas/canvasStore.selectors.ts | 70 +- .../recollections/state/recollectionStore.ts | 72 ++ .../nodes/render/useRenderingNodeState.ts | 51 ++ frontend/src/hooks/useStreamingContent.ts | 86 +++ frontend/src/hooks/useThinkSections.ts | 30 + frontend/src/lib/errorBoundary.tsx | 169 +++++ frontend/src/lib/errorHandling.ts | 155 ++++ frontend/src/lib/graph/nodeFactory.ts | 182 +++++ frontend/src/lib/svgUtils.ts | 48 ++ 21 files changed, 2093 insertions(+), 116 deletions(-) delete mode 100644 backend/src/index.js create mode 100644 backend/src/index.ts create mode 100644 backend/src/middleware/rateLimiter.ts create mode 100644 backend/src/models/index.ts create mode 100644 backend/src/repositories/cacheRepository.ts create mode 100644 backend/src/routes/agentRoutes.ts create mode 100644 backend/src/services/agentService.ts create mode 100644 backend/tsconfig.json create mode 100644 frontend/src/hooks/useStreamingContent.ts create mode 100644 frontend/src/hooks/useThinkSections.ts create mode 100644 frontend/src/lib/errorBoundary.tsx create mode 100644 frontend/src/lib/errorHandling.ts create mode 100644 frontend/src/lib/graph/nodeFactory.ts create mode 100644 frontend/src/lib/svgUtils.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index eefd13a..ba26e85 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index 855ea82..0bd6bec 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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" } } diff --git a/backend/src/index.js b/backend/src/index.js deleted file mode 100644 index a09da7b..0000000 --- a/backend/src/index.js +++ /dev/null @@ -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})`) -}) diff --git a/backend/src/index.ts b/backend/src/index.ts new file mode 100644 index 0000000..bafb249 --- /dev/null +++ b/backend/src/index.ts @@ -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 diff --git a/backend/src/middleware/rateLimiter.ts b/backend/src/middleware/rateLimiter.ts new file mode 100644 index 0000000..2e5a6b5 --- /dev/null +++ b/backend/src/middleware/rateLimiter.ts @@ -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() + +/** + * 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 }, 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, + } +} diff --git a/backend/src/models/index.ts b/backend/src/models/index.ts new file mode 100644 index 0000000..4b647d7 --- /dev/null +++ b/backend/src/models/index.ts @@ -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 +} diff --git a/backend/src/repositories/cacheRepository.ts b/backend/src/repositories/cacheRepository.ts new file mode 100644 index 0000000..273290c --- /dev/null +++ b/backend/src/repositories/cacheRepository.ts @@ -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 = { + /** 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>() + 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(key: string): T | null { + const entry = this.cache.get(key) as CacheEntry | 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(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() diff --git a/backend/src/routes/agentRoutes.ts b/backend/src/routes/agentRoutes.ts new file mode 100644 index 0000000..e28300a --- /dev/null +++ b/backend/src/routes/agentRoutes.ts @@ -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 { + 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 { + 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) + } +} diff --git a/backend/src/services/agentService.ts b/backend/src/services/agentService.ts new file mode 100644 index 0000000..ea93afe --- /dev/null +++ b/backend/src/services/agentService.ts @@ -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 } +} diff --git a/backend/tsconfig.json b/backend/tsconfig.json new file mode 100644 index 0000000..5536ffa --- /dev/null +++ b/backend/tsconfig.json @@ -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"] +} diff --git a/frontend/index.html b/frontend/index.html index ad50dff..975e130 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,13 +1,16 @@ - - - - - React Flow + shadcn Canvas - - -
- - - + + + + + + ZOË | Kosmos + + + +
+ + + + \ No newline at end of file diff --git a/frontend/src/app/canvas/canvasStore.index.ts b/frontend/src/app/canvas/canvasStore.index.ts index 1eaf3d5..a2d0cb9 100644 --- a/frontend/src/app/canvas/canvasStore.index.ts +++ b/frontend/src/app/canvas/canvasStore.index.ts @@ -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' diff --git a/frontend/src/app/canvas/canvasStore.selectors.ts b/frontend/src/app/canvas/canvasStore.selectors.ts index 58fd327..f556162 100644 --- a/frontend/src/app/canvas/canvasStore.selectors.ts +++ b/frontend/src/app/canvas/canvasStore.selectors.ts @@ -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() && /]/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 // --------------------------------------------------------------------------- diff --git a/frontend/src/app/recollections/state/recollectionStore.ts b/frontend/src/app/recollections/state/recollectionStore.ts index eb0d07d..cbf6d75 100644 --- a/frontend/src/app/recollections/state/recollectionStore.ts +++ b/frontend/src/app/recollections/state/recollectionStore.ts @@ -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 = { + /** 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(state: PersistedState | 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(state: PersistedState): PersistedState { + 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)) diff --git a/frontend/src/components/nodes/render/useRenderingNodeState.ts b/frontend/src/components/nodes/render/useRenderingNodeState.ts index e67e2cd..ad9b4ed 100644 --- a/frontend/src/components/nodes/render/useRenderingNodeState.ts +++ b/frontend/src/components/nodes/render/useRenderingNodeState.ts @@ -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' diff --git a/frontend/src/hooks/useStreamingContent.ts b/frontend/src/hooks/useStreamingContent.ts new file mode 100644 index 0000000..ed5c849 --- /dev/null +++ b/frontend/src/hooks/useStreamingContent.ts @@ -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('') + 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)(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 + } +} diff --git a/frontend/src/hooks/useThinkSections.ts b/frontend/src/hooks/useThinkSections.ts new file mode 100644 index 0000000..c22ef4d --- /dev/null +++ b/frontend/src/hooks/useThinkSections.ts @@ -0,0 +1,30 @@ +/** + * Hook for parsing markdown content with think sections. + * Extracts ... 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]) +} diff --git a/frontend/src/lib/errorBoundary.tsx b/frontend/src/lib/errorBoundary.tsx new file mode 100644 index 0000000..c37e101 --- /dev/null +++ b/frontend/src/lib/errorBoundary.tsx @@ -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 { + 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 ( +
+

Something went wrong.

+
+ Error Details +
{this.state.error?.toString()}
+
{this.state.errorInfo?.componentStack}
+
+ +
+ ) + } + + return this.props.children + } +} + +/** + * Hook for error handling in functional components + */ +export function useErrorHandler(): { + error: T | null + setError: (error: T | null) => void + hasError: boolean + resetError: () => void +} { + const [error, setError] = React.useState(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( + 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( + 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' +} diff --git a/frontend/src/lib/errorHandling.ts b/frontend/src/lib/errorHandling.ts new file mode 100644 index 0000000..4fae0e0 --- /dev/null +++ b/frontend/src/lib/errorHandling.ts @@ -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 + timestamp: number +} + +/** + * Create an error object with consistent structure + */ +export function createAppError( + kind: ErrorKind, + message: string, + details?: Record +): 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( + 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( + state: T, + setError: (error: null) => void +): void { + setError(null) +} + +/** + * Handle error with consistent logging + */ +export function handleError( + error: unknown, + context?: Record +): 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( + onError?: (error: AppError) => void +): (error: unknown) => T { + return (error: unknown) => { + const appError = handleError(error) + onError?.(appError) + throw error + } +} diff --git a/frontend/src/lib/graph/nodeFactory.ts b/frontend/src/lib/graph/nodeFactory.ts new file mode 100644 index 0000000..fbad420 --- /dev/null +++ b/frontend/src/lib/graph/nodeFactory.ts @@ -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 +): 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 +): 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 +): 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, + 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 +} diff --git a/frontend/src/lib/svgUtils.ts b/frontend/src/lib/svgUtils.ts new file mode 100644 index 0000000..9560851 --- /dev/null +++ b/frontend/src/lib/svgUtils.ts @@ -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() && /]/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(//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}` +}