feat: enhance layout engine, edge styles, and editor header

This commit is contained in:
SysVis AI 2025-12-28 19:33:50 +07:00
parent 6c0adaae2c
commit b5b2261efb
42 changed files with 2754 additions and 871 deletions

View file

@ -1,8 +1,13 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<meta name="theme-color" content="#0B1221" />
<meta name="description" content="AI-Powered System Design Visualizer - Transform ideas into beautiful, interactive flowcharts" />
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/icon-192.png" />
<title>SysVis.AI - System Design Visualizer</title> <title>SysVis.AI - System Design Visualizer</title>
<!-- Fonts --> <!-- Fonts -->
<link <link

346
package-lock.json generated
View file

@ -13,15 +13,21 @@
"@mlc-ai/web-llm": "^0.2.80", "@mlc-ai/web-llm": "^0.2.80",
"@monaco-editor/react": "^4.7.0", "@monaco-editor/react": "^4.7.0",
"@types/dagre": "^0.7.53", "@types/dagre": "^0.7.53",
"@types/randomcolor": "^0.5.9",
"@xyflow/react": "^12.10.0", "@xyflow/react": "^12.10.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dagre": "^0.8.5", "dagre": "^0.8.5",
"html-to-image": "^1.11.13", "html-to-image": "^1.11.13",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"mermaid": "^11.12.2", "mermaid": "^11.12.2",
"randomcolor": "^0.6.2",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-router-dom": "^7.11.0", "react-router-dom": "^7.11.0",
"y-indexeddb": "^9.0.12",
"y-webrtc": "^10.3.0",
"yjs": "^13.6.28",
"zundo": "^2.3.0",
"zustand": "^5.0.9" "zustand": "^5.0.9"
}, },
"devDependencies": { "devDependencies": {
@ -3156,6 +3162,12 @@
"undici-types": "~7.16.0" "undici-types": "~7.16.0"
} }
}, },
"node_modules/@types/randomcolor": {
"version": "0.5.9",
"resolved": "https://registry.npmjs.org/@types/randomcolor/-/randomcolor-0.5.9.tgz",
"integrity": "sha512-k58cfpkK15AKn1m+oRd9nh5BnuiowhbyvBBdAzcddtARMr3xRzP0VlFaAKovSG6N6Knx08EicjPlOMzDejerrQ==",
"license": "MIT"
},
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.2.7", "version": "19.2.7",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
@ -3781,6 +3793,26 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.9.11", "version": "2.9.11",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz",
@ -3867,6 +3899,30 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
} }
}, },
"node_modules/buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"node_modules/callsites": { "node_modules/callsites": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@ -4661,7 +4717,6 @@
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
@ -4807,6 +4862,12 @@
"url": "https://github.com/fb55/entities?sponsor=1" "url": "https://github.com/fb55/entities?sponsor=1"
} }
}, },
"node_modules/err-code": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/err-code/-/err-code-3.0.1.tgz",
"integrity": "sha512-GiaH0KJUewYok+eeY05IIgjtAe4Yltygk9Wqp1V5yVWLdhf0hYZchRjNIT9bb0mSwRcIusT3cx7PJUf3zEIfUA==",
"license": "MIT"
},
"node_modules/es-define-property": { "node_modules/es-define-property": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@ -5259,6 +5320,12 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/get-browser-rtc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/get-browser-rtc/-/get-browser-rtc-1.1.0.tgz",
"integrity": "sha512-MghbMJ61EJrRsDe7w1Bvqt3ZsBuqhce5nrn/XAwgwOXhcsz53/ltdxOse1h/8eKXj5slzxdsz56g5rzOFSGwfQ==",
"license": "MIT"
},
"node_modules/glob-parent": { "node_modules/glob-parent": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@ -5468,6 +5535,26 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@ -5515,6 +5602,12 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/internmap": { "node_modules/internmap": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
@ -5571,6 +5664,16 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/isomorphic.js": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz",
"integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==",
"license": "MIT",
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
}
},
"node_modules/jest-diff": { "node_modules/jest-diff": {
"version": "30.2.0", "version": "30.2.0",
"resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz",
@ -5972,6 +6075,27 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/lib0": {
"version": "0.2.116",
"resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.116.tgz",
"integrity": "sha512-4zsosjzmt33rx5XjmFVYUAeLNh+BTeDTiwGdLt4muxiir2btsc60Nal0EvkvDRizg+pnlK1q+BtYi7M+d4eStw==",
"license": "MIT",
"dependencies": {
"isomorphic.js": "^0.2.4"
},
"bin": {
"0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js",
"0gentesthtml": "bin/gentesthtml.js",
"0serve": "bin/0serve.js"
},
"engines": {
"node": ">=16"
},
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
}
},
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.30.2", "version": "1.30.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
@ -6495,7 +6619,6 @@
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
@ -6966,6 +7089,41 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
"integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "^5.1.0"
}
},
"node_modules/randomcolor": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/randomcolor/-/randomcolor-0.6.2.tgz",
"integrity": "sha512-Mn6TbyYpFgwFuQ8KJKqf3bqqY9O1y37/0jgSK/61PUxV4QfIMv0+K2ioq8DfOjkBslcjwSzRfIDEXfzA9aCx7A==",
"license": "CC0"
},
"node_modules/react": { "node_modules/react": {
"version": "19.2.3", "version": "19.2.3",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
@ -7044,6 +7202,20 @@
"react-dom": ">=18" "react-dom": ">=18"
} }
}, },
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/redent": { "node_modules/redent": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
@ -7161,6 +7333,26 @@
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": { "node_modules/safer-buffer": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@ -7309,6 +7501,35 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/simple-peer": {
"version": "9.11.1",
"resolved": "https://registry.npmjs.org/simple-peer/-/simple-peer-9.11.1.tgz",
"integrity": "sha512-D1SaWpOW8afq1CZGWB8xTfrT3FekjQmPValrqncJMX7QFl8YwhrPTZvMCANLtgBwwdS+7zURyqxDDEmY558tTw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"buffer": "^6.0.3",
"debug": "^4.3.2",
"err-code": "^3.0.1",
"get-browser-rtc": "^1.1.0",
"queue-microtask": "^1.2.3",
"randombytes": "^2.1.0",
"readable-stream": "^3.6.0"
}
},
"node_modules/slash": { "node_modules/slash": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
@ -7378,6 +7599,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/strip-indent": { "node_modules/strip-indent": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
@ -7733,6 +7963,12 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
} }
}, },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/uuid": { "node_modules/uuid": {
"version": "11.1.0", "version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
@ -8043,7 +8279,7 @@
"version": "8.18.3", "version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10.0.0" "node": ">=10.0.0"
@ -8078,6 +8314,73 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/y-indexeddb": {
"version": "9.0.12",
"resolved": "https://registry.npmjs.org/y-indexeddb/-/y-indexeddb-9.0.12.tgz",
"integrity": "sha512-9oCFRSPPzBK7/w5vOkJBaVCQZKHXB/v6SIT+WYhnJxlEC61juqG0hBrAf+y3gmSMLFLwICNH9nQ53uscuse6Hg==",
"license": "MIT",
"dependencies": {
"lib0": "^0.2.74"
},
"engines": {
"node": ">=16.0.0",
"npm": ">=8.0.0"
},
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
},
"peerDependencies": {
"yjs": "^13.0.0"
}
},
"node_modules/y-protocols": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz",
"integrity": "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==",
"license": "MIT",
"dependencies": {
"lib0": "^0.2.85"
},
"engines": {
"node": ">=16.0.0",
"npm": ">=8.0.0"
},
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
},
"peerDependencies": {
"yjs": "^13.0.0"
}
},
"node_modules/y-webrtc": {
"version": "10.3.0",
"resolved": "https://registry.npmjs.org/y-webrtc/-/y-webrtc-10.3.0.tgz",
"integrity": "sha512-KalJr7dCgUgyVFxoG3CQYbpS0O2qybegD0vI4bYnYHI0MOwoVbucED3RZ5f2o1a5HZb1qEssUKS0H/Upc6p1lA==",
"license": "MIT",
"dependencies": {
"lib0": "^0.2.42",
"simple-peer": "^9.11.0",
"y-protocols": "^1.0.6"
},
"bin": {
"y-webrtc-signaling": "bin/server.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
},
"optionalDependencies": {
"ws": "^8.14.2"
},
"peerDependencies": {
"yjs": "^13.6.8"
}
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@ -8085,6 +8388,24 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/yjs": {
"version": "13.6.28",
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.28.tgz",
"integrity": "sha512-EgnDOXs8+hBVm6mq3/S89Kiwzh5JRbn7w2wXwbrMRyKy/8dOFsLvuIfC+x19ZdtaDc0tA9rQmdZzbqqNHG44wA==",
"license": "MIT",
"peer": true,
"dependencies": {
"lib0": "^0.2.99"
},
"engines": {
"node": ">=16.0.0",
"npm": ">=8.0.0"
},
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
}
},
"node_modules/yocto-queue": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
@ -8122,11 +8443,30 @@
"zod": "^3.25.0 || ^4.0.0" "zod": "^3.25.0 || ^4.0.0"
} }
}, },
"node_modules/zundo": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/zundo/-/zundo-2.3.0.tgz",
"integrity": "sha512-4GXYxXA17SIKYhVbWHdSEU04P697IMyVGXrC2TnzoyohEAWytFNOKqOp5gTGvaW93F/PM5Y0evbGtOPF0PWQwQ==",
"license": "MIT",
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/charkour"
},
"peerDependencies": {
"zustand": "^4.3.0 || ^5.0.0"
},
"peerDependenciesMeta": {
"zustand": {
"optional": false
}
}
},
"node_modules/zustand": { "node_modules/zustand": {
"version": "5.0.9", "version": "5.0.9",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz",
"integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==", "integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12.20.0" "node": ">=12.20.0"
}, },

View file

@ -31,15 +31,21 @@
"@mlc-ai/web-llm": "^0.2.80", "@mlc-ai/web-llm": "^0.2.80",
"@monaco-editor/react": "^4.7.0", "@monaco-editor/react": "^4.7.0",
"@types/dagre": "^0.7.53", "@types/dagre": "^0.7.53",
"@types/randomcolor": "^0.5.9",
"@xyflow/react": "^12.10.0", "@xyflow/react": "^12.10.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dagre": "^0.8.5", "dagre": "^0.8.5",
"html-to-image": "^1.11.13", "html-to-image": "^1.11.13",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"mermaid": "^11.12.2", "mermaid": "^11.12.2",
"randomcolor": "^0.6.2",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-router-dom": "^7.11.0", "react-router-dom": "^7.11.0",
"y-indexeddb": "^9.0.12",
"y-webrtc": "^10.3.0",
"yjs": "^13.6.28",
"zundo": "^2.3.0",
"zustand": "^5.0.9" "zustand": "^5.0.9"
}, },
"devDependencies": { "devDependencies": {

25
public/manifest.json Normal file
View file

@ -0,0 +1,25 @@
{
"name": "SysVis.AI - System Design Visualizer",
"short_name": "SysVis.AI",
"description": "AI-Powered System Design Visualizer - Transform ideas into beautiful, interactive flowcharts",
"start_url": "/",
"display": "standalone",
"background_color": "#0B1221",
"theme_color": "#0B1221",
"orientation": "any",
"icons": [
{
"src": "/vite.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
}
],
"categories": [
"productivity",
"developer tools",
"design"
],
"scope": "/",
"lang": "en"
}

View file

@ -3,7 +3,18 @@ import { Dashboard } from './pages/Dashboard';
import { Editor } from './pages/Editor'; import { Editor } from './pages/Editor';
import { History } from './pages/History'; import { History } from './pages/History';
import { useEffect } from 'react';
import { usePluginStore } from './store/pluginStore';
function App() { function App() {
const { registerPlugin } = usePluginStore();
useEffect(() => {
// Register Plugins
// StatsPlugin removed
}, [registerPlugin]);
return ( return (
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>

View file

@ -1,47 +1,54 @@
import { useState, useCallback, useEffect, useRef } from 'react'; import { useState, useCallback, useEffect, useRef } from 'react';
import Editor from '@monaco-editor/react'; import { useStore } from 'zustand';
import { useFlowStore } from '../store'; import { useFlowStore } from '../store';
import { useDiagramStore } from '../store/diagramStore';
import { parseMermaid, detectInputType } from '../lib/mermaidParser'; import { parseMermaid, detectInputType } from '../lib/mermaidParser';
import { getLayoutedElements } from '../lib/layoutEngine'; import { getLayoutedElements } from '../lib/layoutEngine';
import { interpretText, suggestFix } from '../lib/aiService'; import { interpretText } from '../lib/aiService';
import { import { MonacoWrapper } from './editor/MonacoWrapper';
Loader2, Zap, Trash2, FileText, Lightbulb, import { EditorToolbar } from './editor/EditorToolbar';
AlertCircle
} from 'lucide-react';
const SAMPLE_MERMAID = `flowchart TD
subgraph AI [AI Director]
A1[Analyze Trends]
A2[Generate Script]
A3[Create Draft]
end
subgraph Team [Intern Team]
B1[Fine-tune Ideas]
B2[Edit Content]
B3[Review & Approve]
end
A1 --> A2 --> A3
A3 --> B1
B1 --> B2 --> B3`;
export function CodeEditor() { export function CodeEditor() {
const [code, setCode] = useState<string>(''); // ... (store access)
// Use global mermaidCode from store for persistence across panel toggles
const {
setNodes, setEdges, setLoading, setError, setSourceCode, isLoading,
ollamaUrl, modelName, aiMode, onlineProvider, apiKey, theme,
mermaidCode: code, setMermaidCode: setCode
} = useFlowStore();
const [inputType, setInputType] = useState<'mermaid' | 'natural'>('mermaid'); const [inputType, setInputType] = useState<'mermaid' | 'natural'>('mermaid');
const [syntaxErrors, setSyntaxErrors] = useState<{ line: number; message: string }[]>([]); const [syntaxErrors, setSyntaxErrors] = useState<{ line: number; message: string }[]>([]);
const [highlightedLine, setHighlightedLine] = useState<number | null>(null);
// ... (rest of logic up to handleTemplateSelect)
// We need to inject the ShapePicker logic.
// Since I cannot match the exact lines perfectly without context, I will replace the component return and adding imports/states.
// But replace_file_content with 'context' is better. I will try to target specific blocks.
// I will use replace_file_content to add imports first.
// Then add state.
// Then add handleShapeSelect.
// Then update return.
// Wait, I am doing all of this in one tool call? Use separate blocks if possible?
// The previous tool calls are queued. This is the 5th tool call in this turn? No, 5th.
// I will replace the imports first in this call.
// Actually, I'll do a MultiReplace for CodeEditor.
// Use any for editor refs since OnMount type isn't reliably exported // Use any for editor refs since OnMount type isn't reliably exported
// We'll keep these refs here to handle the node selection highlighting logic
const editorRef = useRef<any>(null); const editorRef = useRef<any>(null);
const monacoRef = useRef<any>(null); const monacoRef = useRef<any>(null);
const decorationsRef = useRef<string[]>([]); const decorationsRef = useRef<string[]>([]);
const {
setNodes, setEdges, setLoading, setError, setSourceCode, isLoading,
ollamaUrl, modelName, aiMode, onlineProvider, apiKey, nodes, setSelectedNode, theme
} = useFlowStore();
// Listen for node click events from the canvas (bidirectional highlighting) // Listen for node click events from the canvas (bidirectional highlighting)
useEffect(() => { useEffect(() => {
const handleNodeSelected = (event: CustomEvent<{ nodeId: string; label: string }>) => { const handleNodeSelected = (event: CustomEvent<{ nodeId: string; label: string }>) => {
@ -59,8 +66,6 @@ export function CodeEditor() {
); );
if (lineIndex !== -1) { if (lineIndex !== -1) {
setHighlightedLine(lineIndex + 1);
// Scroll to and highlight the line // Scroll to and highlight the line
editorRef.current.revealLineInCenter(lineIndex + 1); editorRef.current.revealLineInCenter(lineIndex + 1);
@ -82,7 +87,6 @@ export function CodeEditor() {
setTimeout(() => { setTimeout(() => {
if (editorRef.current) { if (editorRef.current) {
decorationsRef.current = editorRef.current.deltaDecorations(decorationsRef.current, []); decorationsRef.current = editorRef.current.deltaDecorations(decorationsRef.current, []);
setHighlightedLine(null);
} }
}, 3000); }, 3000);
} }
@ -97,7 +101,7 @@ export function CodeEditor() {
const newCode = value || ''; const newCode = value || '';
setCode(newCode); setCode(newCode);
if (newCode.trim()) setInputType(detectInputType(newCode)); if (newCode.trim()) setInputType(detectInputType(newCode));
}, [inputType]); }, [setCode]);
const handleGenerate = useCallback(async () => { const handleGenerate = useCallback(async () => {
if (!code.trim()) return; if (!code.trim()) return;
@ -114,21 +118,51 @@ export function CodeEditor() {
const result = await interpretText(code, ollamaUrl, modelName, aiMode, onlineProvider, apiKey); const result = await interpretText(code, ollamaUrl, modelName, aiMode, onlineProvider, apiKey);
if (!result.success || !result.mermaidCode) throw new Error(result.error || 'Interpretation failed'); if (!result.success || !result.mermaidCode) throw new Error(result.error || 'Interpretation failed');
mermaidCode = result.mermaidCode; mermaidCode = result.mermaidCode;
metadata = result.metadata;
} }
setSourceCode(mermaidCode); setSourceCode(mermaidCode);
const { nodes: parsedNodes, edges: parsedEdges } = await parseMermaid(mermaidCode);
if (metadata) { // First attempt to parse
parsedNodes.forEach(node => { try {
const label = (node.data.label as string) || ''; const { nodes: parsedNodes, edges: parsedEdges } = await parseMermaid(mermaidCode);
if (label && metadata && metadata[label]) node.data.metadata = metadata[label]; const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(parsedNodes, parsedEdges);
}); setNodes(layoutedNodes);
setEdges(layoutedEdges);
if (metadata) {
parsedNodes.forEach(node => {
const label = (node.data.label as string) || '';
if (label && metadata && metadata[label]) node.data.metadata = metadata[label];
});
}
} catch (initialError) {
// If parsing fails, try to Auto-Fix if we have an API key
const errorMessage = initialError instanceof Error ? initialError.message : 'Error processing code';
if (onlineProvider === 'gemini' && apiKey) {
console.log('Parsing failed, attempting Auto-Fix...');
try {
const { fixDiagram } = await import('../lib/aiService');
const fixedCode = await fixDiagram(mermaidCode, apiKey, errorMessage);
if (fixedCode && fixedCode !== mermaidCode) {
setCode(fixedCode);
// Retry parsing with fixed code
const { nodes: newNodes, edges: newEdges } = await parseMermaid(fixedCode);
const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(newNodes, newEdges);
setNodes(layoutedNodes);
setEdges(layoutedEdges);
return; // Success!
}
} catch (fixError) {
console.warn('Auto-Fix failed:', fixError);
// Fall through to throw original error
}
}
throw initialError;
} }
const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(parsedNodes, parsedEdges);
setNodes(layoutedNodes);
setEdges(layoutedEdges);
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Error processing code'; const errorMessage = error instanceof Error ? error.message : 'Error processing code';
setError(errorMessage); setError(errorMessage);
@ -141,59 +175,19 @@ export function CodeEditor() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [code, inputType, ollamaUrl, modelName, aiMode, onlineProvider, apiKey, setNodes, setEdges, setLoading, setError, setSourceCode]); }, [code, inputType, ollamaUrl, modelName, aiMode, onlineProvider, apiKey, setNodes, setEdges, setLoading, setError, setSourceCode, setCode]);
const [suggestion, setSuggestion] = useState<string | null>(null);
const handleSuggest = useCallback(async () => {
if (!code.trim()) return;
setLoading(true);
setError(null);
setSuggestion(null);
try {
const result = await suggestFix(code, ollamaUrl, modelName, aiMode, onlineProvider, apiKey);
if (result.success && result.mermaidCode) {
setCode(result.mermaidCode);
setSuggestion(result.explanation || 'Code improved');
setTimeout(() => setSuggestion(null), 5000);
} else {
throw new Error(result.error || 'Could not get suggestion');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Suggestion failed');
} finally {
setLoading(false);
}
}, [code, ollamaUrl, modelName, aiMode, onlineProvider, apiKey, setLoading, setError]);
// Click on node ID in code to highlight on canvas
const handleEditorClick = useCallback(() => {
if (!editorRef.current) return;
const position = editorRef.current.getPosition(); // Handle Editor mount
if (!position) return; const handleEditorMount = useCallback((editor: any, monaco: any) => {
editorRef.current = editor;
monacoRef.current = monaco;
const line = editorRef.current.getModel()?.getLineContent(position.lineNumber); // Define themes
if (!line) return; monaco.editor.defineTheme('architect-dark', {
// Extract node ID from line (e.g., "A1[Label]" -> find node with label "Label")
const labelMatch = line.match(/\[([^\]]+)\]/) || line.match(/\(([^)]+)\)/) || line.match(/\{([^}]+)\}/);
if (labelMatch) {
const label = labelMatch[1];
const matchingNode = nodes.find(n => (n.data.label as string)?.includes(label));
if (matchingNode) {
setSelectedNode(matchingNode);
}
}
}, [nodes, setSelectedNode]);
// Define themes once on mount, but update selection on theme change
useEffect(() => {
if (!monacoRef.current) return;
// Define Dark Theme
monacoRef.current.editor.defineTheme('architect-dark', {
base: 'vs-dark', base: 'vs-dark',
inherit: true, inherit: true,
rules: [ rules: [
@ -211,162 +205,66 @@ export function CodeEditor() {
'editor.lineHighlightBackground': '#1e293b', 'editor.lineHighlightBackground': '#1e293b',
'editor.selectionBackground': '#334155', 'editor.selectionBackground': '#334155',
'editorCursor.foreground': '#60a5fa', 'editorCursor.foreground': '#60a5fa',
'editor.lineHighlightBorder': '#00000000', // No border for line highlight
} }
}); });
// Define Light Theme monaco.editor.defineTheme('architect-light', {
monacoRef.current.editor.defineTheme('architect-light', {
base: 'vs', base: 'vs',
inherit: true, inherit: true,
rules: [ rules: [
{ token: 'keyword', foreground: '2563eb', fontStyle: 'bold' }, // Blue-600 { token: 'keyword', foreground: '2563eb', fontStyle: 'bold' },
{ token: 'comment', foreground: '94a3b8', fontStyle: 'italic' }, // Slate-400 { token: 'comment', foreground: '94a3b8', fontStyle: 'italic' },
{ token: 'string', foreground: '059669' }, // Emerald-600 { token: 'string', foreground: '059669' },
{ token: 'number', foreground: 'd97706' }, // Amber-600 { token: 'number', foreground: 'd97706' },
{ token: 'type', foreground: '9333ea' }, // Purple-600 { token: 'type', foreground: '9333ea' },
], ],
colors: { colors: {
'editor.background': '#f8fafc', // Slate-50 'editor.background': '#f8fafc',
'editor.foreground': '#334155', // Slate-700 'editor.foreground': '#334155',
'editorLineNumber.foreground': '#cbd5e1', // Slate-300 'editorLineNumber.foreground': '#cbd5e1',
'editorLineNumber.activeForeground': '#2563eb', // Blue-600 'editorLineNumber.activeForeground': '#2563eb',
'editor.lineHighlightBackground': '#f1f5f9', // Slate-100 'editor.lineHighlightBackground': '#f1f5f9',
'editor.selectionBackground': '#e2e8f0', // Slate-200 'editor.selectionBackground': '#e2e8f0',
'editorCursor.foreground': '#2563eb', // Blue-600 'editorCursor.foreground': '#2563eb',
'editor.lineHighlightBorder': '#00000000',
} }
}); });
monacoRef.current.editor.setTheme(theme === 'dark' ? 'architect-dark' : 'architect-light'); monaco.editor.setTheme(theme === 'dark' ? 'architect-dark' : 'architect-light');
}, [theme]); }, [theme]);
const handleEditorMount = useCallback((editor: any, monaco: any) => { // Update theme when it changes
editorRef.current = editor; useEffect(() => {
monacoRef.current = monaco; if (monacoRef.current) {
// Initial theme set handled by useEffect monacoRef.current.editor.setTheme(theme === 'dark' ? 'architect-dark' : 'architect-light');
}, []); }
}, [theme]);
return ( return (
<div className="h-full flex flex-col gap-4 animate-slide-up"> <div className="h-full flex flex-col gap-4 animate-slide-up relative">
{/* Editor Container with Badges */} <MonacoWrapper
<div className={`flex-1 rounded-2xl overflow-hidden border relative group shadow-inner transition-colors ${theme === 'dark' ? 'bg-[#0B1221] border-white/5' : 'bg-slate-50 border-slate-200' code={code}
}`}> onChange={handleCodeChange}
{/* Internal Badges */} onMount={handleEditorMount}
<div className="absolute top-4 left-4 z-10 pointer-events-none"> theme={(theme === 'light' || theme === 'dark') ? theme : 'dark'}
<span className="text-[10px] font-black uppercase tracking-widest px-3 py-1.5 rounded-lg bg-blue-500/10 text-blue-400 border border-blue-500/20 backdrop-blur-md"> syntaxErrors={syntaxErrors}
Mermaid setCode={setCode}
</span> />
</div>
<div className="absolute top-4 right-4 z-10">
<span className="text-[10px] font-black uppercase tracking-widest px-3 py-1.5 rounded-lg bg-white/5 text-slate-400 border border-white/10 backdrop-blur-md">
Manual
</span>
</div>
<Editor
height="100%"
defaultLanguage="markdown"
// theme prop is controlled by monaco.editor.setTheme
options={{
minimap: { enabled: false },
fontSize: 13,
lineNumbers: 'on',
padding: { top: 50, bottom: 20 },
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontLigatures: true,
renderLineHighlight: 'all',
scrollbar: {
vertical: 'visible',
horizontal: 'visible',
useShadows: false,
verticalSliderSize: 6,
horizontalSliderSize: 6
},
lineHeight: 1.7,
cursorSmoothCaretAnimation: 'on',
smoothScrolling: true,
contextmenu: false,
fixedOverflowWidgets: true,
wordWrap: 'on',
glyphMargin: true,
}}
value={code}
onChange={handleCodeChange}
onMount={handleEditorMount}
/>
{/* Floating Action Buttons */} <EditorToolbar
<div className="absolute top-4 right-24 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity z-20"> handleGenerate={handleGenerate}
<button isLoading={isLoading}
onClick={() => setCode(SAMPLE_MERMAID)} hasCode={!!code.trim()}
className="w-8 h-8 rounded-lg bg-white/5 border border-white/10 flex items-center justify-center hover:bg-white/10 text-slate-400 hover:text-white transition-all backdrop-blur-md shadow-lg"
title="Load Sample"
>
<FileText className="w-3.5 h-3.5" /> />
</button>
<button
onClick={() => setCode('')}
className="w-8 h-8 rounded-lg bg-white/5 border border-white/10 flex items-center justify-center hover:bg-red-500/20 text-slate-400 hover:text-red-400 transition-all backdrop-blur-md shadow-lg"
title="Clear"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
{/* Syntax Errors */}
{syntaxErrors.length > 0 && (
<div className="absolute bottom-4 left-4 right-4 p-3 bg-red-500/10 border border-red-500/30 rounded-xl animate-fade-in">
<div className="flex items-start gap-2">
<AlertCircle className="w-4 h-4 text-red-400 shrink-0 mt-0.5" />
<div>
<p className="text-[10px] font-bold text-red-400 uppercase tracking-wider">Syntax Error</p>
<p className="text-[11px] text-red-300 mt-1">{syntaxErrors[0].message}</p>
</div>
</div>
</div>
)}
{/* AI Suggestion Toast */}
{suggestion && (
<div className="absolute bottom-4 left-4 right-4 p-4 bg-blue-500/10 border border-blue-500/30 rounded-xl animate-fade-in z-20">
<div className="flex items-center gap-3">
<Lightbulb className="w-4 h-4 text-blue-400 shrink-0" />
<p className="text-[11px] text-blue-200 font-medium leading-relaxed">{suggestion}</p>
</div>
</div>
)}
</div>
{/* Action Buttons */}
<div className="flex gap-4 pt-2">
<button
onClick={handleSuggest}
disabled={!code.trim() || isLoading}
className="flex-1 py-4 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-slate-300 transition-all active:scale-[0.98] flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed group"
>
{isLoading ? (
<Loader2 className="w-4 h-4 animate-spin text-slate-400" />
) : (
<>
<Lightbulb className="w-4 h-4 text-slate-400 group-hover:text-yellow-400 transition-colors" />
<span className="text-[11px] font-black uppercase tracking-wider">AI Fix</span>
</>
)}
</button>
<button
onClick={handleGenerate}
disabled={!code.trim() || isLoading}
className="flex-[1.5] py-4 rounded-xl bg-gradient-to-r from-indigo-600 to-violet-600 hover:from-indigo-500 hover:to-violet-500 text-white shadow-lg shadow-indigo-900/30 transition-all active:scale-[0.98] flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? (
<Loader2 className="w-4 h-4 animate-spin text-white/70" />
) : (
<>
<Zap className="w-4 h-4 text-white" />
<span className="text-[11px] font-black uppercase tracking-wider">Visualize</span>
</>
)}
</button>
</div>
</div> </div>
); );
} }

View file

@ -9,22 +9,31 @@ import {
SelectionMode, SelectionMode,
} from '@xyflow/react'; } from '@xyflow/react';
import { useFlowStore } from '../store'; import { useFlowStore } from '../store';
import { useMobileDetect } from '../hooks/useMobileDetect';
import { nodeTypes } from './nodes/CustomNodes'; import { nodeTypes } from './nodes/CustomNodes';
import { edgeTypes, EdgeDefs } from './edges/AnimatedEdge'; import { edgeTypes, EdgeDefs } from './edges/AnimatedEdge';
import { import {
Spline, Minus, Plus, Maximize, Map, Wand2, Spline, Minus, Plus, Maximize, Map, Wand2,
RotateCcw, Download, Command, Hand, MousePointer2, Settings2, ChevronDown Hand, MousePointer2, Settings2, ChevronDown
} from 'lucide-react'; } from 'lucide-react';
import { getLayoutedElements } from '../lib/layoutEngine'; import { getLayoutedElements } from '../lib/layoutEngine';
export function FlowCanvas() { export function FlowCanvas() {
const { const {
nodes, edges, onNodesChange, onEdgesChange, onConnect, nodes, edges, onNodesChange, onEdgesChange, onConnect,
setSelectedNode, edgeStyle, setEdgeStyle, theme, activeFilters, setSelectedNode, edgeStyle, setEdgeStyle, theme, activeFilters,
setNodes, setEdges setNodes, setEdges, focusMode
} = useFlowStore(); } = useFlowStore();
const { zoomIn, zoomOut, fitView, getViewport } = useReactFlow(); const { isMobile } = useMobileDetect();
const { zoomIn, zoomOut, fitView } = useReactFlow();
const [showToolkit, setShowToolkit] = useState(false); // New State const [showToolkit, setShowToolkit] = useState(false); // New State
// Track cursor - Removed for single user mode
// const handleMouseMove = useCallback((e: React.MouseEvent) => { ... }, []);
const [showMiniMap, setShowMiniMap] = useState(true); const [showMiniMap, setShowMiniMap] = useState(true);
const [isSelectionMode, setIsSelectionMode] = useState(false); // false = Pan, true = Select const [isSelectionMode, setIsSelectionMode] = useState(false); // false = Pan, true = Select
@ -71,9 +80,35 @@ export function FlowCanvas() {
// Filter nodes based on active filters // Filter nodes based on active filters
const filteredNodes = useMemo(() => { const filteredNodes = useMemo(() => {
return nodes.map(node => { return nodes.map(node => {
if (node.type === 'group') return node; if (node.type === 'group') {
const category = (node.data?.category as string) || 'filter-other';
// Group is visible if Groups are enabled AND its specific category is enabled
const isGroupEnabled = activeFilters.includes('filter-group');
const isCategoryEnabled = activeFilters.includes(category);
const category = (node.data?.category as string) || (node.type === 'database' ? 'filter-db' : node.type === 'client' ? 'filter-client' : 'filter-server'); return {
...node,
hidden: !isGroupEnabled || !isCategoryEnabled
};
}
let category = (node.data?.category as string);
if (!category) {
switch (node.type) {
case 'client':
category = 'filter-client';
break;
case 'database':
category = 'filter-db';
break;
case 'server':
category = 'filter-server';
break;
default:
category = 'filter-other'; // Start, End, Process, Decision, etc.
}
}
return { return {
...node, ...node,
@ -157,6 +192,7 @@ export function FlowCanvas() {
return ( return (
<div className="w-full h-full relative bg-void"> <div className="w-full h-full relative bg-void">
<EdgeDefs /> <EdgeDefs />
<ReactFlow <ReactFlow
nodes={filteredNodes} nodes={filteredNodes}
@ -172,6 +208,8 @@ export function FlowCanvas() {
fitViewOptions={{ padding: 0.2, maxZoom: 1.5 }} fitViewOptions={{ padding: 0.2, maxZoom: 1.5 }}
minZoom={0.1} minZoom={0.1}
maxZoom={4} maxZoom={4}
panOnScroll={!isMobile} // Disable on mobile to prevent scroll conflicts
zoomOnPinch={true} // Enable pinch-to-zoom on mobile
panOnDrag={!isSelectionMode} panOnDrag={!isSelectionMode}
selectionOnDrag={isSelectionMode} selectionOnDrag={isSelectionMode}
selectionMode={SelectionMode.Partial} selectionMode={SelectionMode.Partial}
@ -194,7 +232,7 @@ export function FlowCanvas() {
/> />
{/* Control Panel - Top Right (Unified Toolkit) */} {/* Control Panel - Top Right (Unified Toolkit) */}
<Panel position="top-right" className="!m-4 flex flex-col items-end gap-3 z-50"> <Panel position="top-right" className={`!m-4 !mr-6 flex flex-col items-end gap-3 z-50 transition-all duration-300 ${focusMode ? '!mt-20' : ''}`}>
<div className="relative"> <div className="relative">
<button <button
onClick={() => setShowToolkit(!showToolkit)} onClick={() => setShowToolkit(!showToolkit)}
@ -287,9 +325,9 @@ export function FlowCanvas() {
</Panel> </Panel>
{/* MiniMap Container - Bottom Right */} {/* MiniMap Container - Bottom Right (Hidden on Mobile) */}
<Panel position="bottom-right" className="!m-4 z-40"> <Panel position="bottom-right" className="!m-4 z-40">
{showMiniMap && nodes.length > 0 && ( {showMiniMap && nodes.length > 0 && !isMobile && (
<div className="w-52 h-36 rounded-xl border border-slate-200 dark:border-white/10 bg-white/50 dark:bg-slate-900/50 backdrop-blur-md shadow-xl overflow-hidden animate-in fade-in slide-in-from-bottom-4 duration-300"> <div className="w-52 h-36 rounded-xl border border-slate-200 dark:border-white/10 bg-white/50 dark:bg-slate-900/50 backdrop-blur-md shadow-xl overflow-hidden animate-in fade-in slide-in-from-bottom-4 duration-300">
<MiniMap <MiniMap
nodeColor={miniMapNodeColor} nodeColor={miniMapNodeColor}
@ -322,40 +360,11 @@ export function FlowCanvas() {
</Panel> </Panel>
)} )}
{/* Command Palette Hint - Top Center */} {/* Command Palette Hint - Top Center (Desktop only) */}
<Panel position="top-center" className="!mt-6">
<div className="flex items-center gap-2 px-3 py-1.5 bg-white/60 dark:bg-surface/60 backdrop-blur-md border border-black/5 dark:border-white/10 rounded-lg text-slate-500 dark:text-tertiary hover:text-slate-800 dark:hover:text-secondary transition-colors cursor-pointer opacity-60 hover:opacity-100">
<Command className="w-3 h-3" />
<span className="text-[9px] font-bold uppercase tracking-wider">K</span>
<span className="text-[9px] text-slate-400 dark:text-slate-500">Quick Actions</span>
</div>
</Panel>
</ReactFlow> </ReactFlow>
</div> </div>
); );
} }
// Control Button Component
interface ControlButtonProps {
icon: React.ComponentType<{ className?: string }>;
onClick: () => void;
title: string;
highlight?: boolean;
}
function ControlButton({ icon: Icon, onClick, title, highlight }: ControlButtonProps) {
return (
<button
onClick={(e) => { e.stopPropagation(); onClick(); }}
className={`
w-10 h-10 flex items-center justify-center
text-secondary hover:text-primary hover:bg-blue-500/10
transition-all
${highlight ? 'text-blue-500' : ''}
`}
title={title}
>
<Icon className="w-4 h-4" />
</button>
);
}

View file

@ -1,4 +1,4 @@
import { useState, useCallback } from 'react'; import { useState, useCallback, useRef } from 'react';
import { useFlowStore } from '../store'; import { useFlowStore } from '../store';
import { analyzeImage, analyzeSVG } from '../lib/aiService'; import { analyzeImage, analyzeSVG } from '../lib/aiService';
import { parseMermaid } from '../lib/mermaidParser'; import { parseMermaid } from '../lib/mermaidParser';
@ -10,6 +10,7 @@ export function ImageUpload() {
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [fileType, setFileType] = useState<'image' | 'svg' | null>(null); const [fileType, setFileType] = useState<'image' | 'svg' | null>(null);
const [svgContent, setSvgContent] = useState<string>(''); const [svgContent, setSvgContent] = useState<string>('');
const fileInputRef = useRef<HTMLInputElement>(null);
const { const {
setNodes, setEdges, setLoading, setError, setSourceCode, isLoading, setNodes, setEdges, setLoading, setError, setSourceCode, isLoading,
@ -65,10 +66,26 @@ export function ImageUpload() {
}, [handleFile]); }, [handleFile]);
const handleGenerate = useCallback(async () => { const handleGenerate = useCallback(async () => {
if (aiMode === 'offline' && !ollamaUrl) { // Validate AI configuration before processing
setError('Please configure Ollama URL in settings'); if (aiMode === 'offline') {
if (!ollamaUrl) {
setError('Please configure Ollama URL in Settings → Local mode');
return;
}
} else if (aiMode === 'online') {
if (onlineProvider !== 'ollama-cloud' && !apiKey) {
setError(`Please enter your ${onlineProvider === 'gemini' ? 'Google Gemini' : 'OpenAI'} API key in Settings → Cloud mode`);
return;
}
if (onlineProvider === 'ollama-cloud' && !ollamaUrl) {
setError('Please configure the Remote Ollama endpoint in Settings → Cloud mode');
return;
}
} else if (aiMode === 'browser') {
setError('Browser mode does not support image analysis yet. Please use Cloud mode with an API key in Settings.');
return; return;
} }
setLoading(true); setLoading(true);
setError(null); setError(null);
@ -81,8 +98,7 @@ export function ImageUpload() {
modelName, modelName,
aiMode, aiMode,
onlineProvider, onlineProvider,
apiKey, apiKey
generationComplexity
); );
} else if (preview) { } else if (preview) {
result = await analyzeImage( result = await analyzeImage(
@ -130,7 +146,7 @@ export function ImageUpload() {
onDrop={handleDrop} onDrop={handleDrop}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onClick={() => !preview && document.getElementById('image-input')?.click()} onClick={() => !preview && fileInputRef.current?.click()}
className={`flex-1 relative rounded-2xl flex flex-col items-center justify-center cursor-pointer transition-all border border-dashed className={`flex-1 relative rounded-2xl flex flex-col items-center justify-center cursor-pointer transition-all border border-dashed
${isDragging ${isDragging
? 'bg-blue-500/10 border-blue-500/50 scale-[1.01]' ? 'bg-blue-500/10 border-blue-500/50 scale-[1.01]'
@ -138,7 +154,7 @@ export function ImageUpload() {
}`} }`}
> >
<input <input
id="image-input" ref={fileInputRef}
type="file" type="file"
accept=".jpg,.jpeg,.png,.webp,.svg" accept=".jpg,.jpeg,.png,.webp,.svg"
onChange={handleFileInput} onChange={handleFileInput}

View file

@ -1,4 +1,4 @@
import { useState, useCallback } from 'react'; import { useCallback } from 'react';
import { ImageUpload } from './ImageUpload'; import { ImageUpload } from './ImageUpload';
import { CodeEditor } from './CodeEditor'; import { CodeEditor } from './CodeEditor';
import { Image, Code, MessageSquare, Loader2, Zap } from 'lucide-react'; import { Image, Code, MessageSquare, Loader2, Zap } from 'lucide-react';
@ -10,20 +10,39 @@ import { getLayoutedElements } from '../lib/layoutEngine';
type Tab = 'image' | 'code' | 'describe'; type Tab = 'image' | 'code' | 'describe';
export function InputPanel() { export function InputPanel() {
const [activeTab, setActiveTab] = useState<Tab>('image');
const [description, setDescription] = useState('');
const { const {
setNodes, setEdges, setLoading, setError, setNodes, setEdges, setLoading, setError,
setSourceCode, isLoading, ollamaUrl, modelName, setSourceCode, isLoading, ollamaUrl, modelName,
aiMode, onlineProvider, apiKey, aiMode, onlineProvider, apiKey,
generationComplexity, setGenerationComplexity generationComplexity, setGenerationComplexity,
// Use global state for input persistence
inputDescription: description, setInputDescription: setDescription,
inputActiveTab: activeTab, setInputActiveTab: setActiveTab,
// Actions for auto-sync and save
setMermaidCode, saveDiagram
} = useFlowStore(); } = useFlowStore();
const handleTextGenerate = useCallback(async () => { const handleTextGenerate = useCallback(async () => {
if (!description.trim()) return; if (!description.trim()) return;
if (aiMode === 'offline' && !ollamaUrl) {
setError('Please configure Ollama URL in settings'); // Validate AI configuration before processing
return; if (aiMode === 'offline') {
if (!ollamaUrl) {
setError('Please configure Ollama URL in Settings → Local mode');
return;
}
} else if (aiMode === 'online') {
if (onlineProvider !== 'ollama-cloud' && !apiKey) {
setError(`Please enter your ${onlineProvider === 'gemini' ? 'Google Gemini' : 'OpenAI'} API key in Settings → Cloud mode`);
return;
}
if (onlineProvider === 'ollama-cloud' && !ollamaUrl) {
setError('Please configure the Remote Ollama endpoint in Settings → Cloud mode');
return;
}
} else if (aiMode === 'browser') {
// Browser mode is okay for text - just needs model loaded
} }
setLoading(true); setLoading(true);
@ -44,7 +63,10 @@ export function InputPanel() {
throw new Error(result.error || 'Could not interpret flow from description'); throw new Error(result.error || 'Could not interpret flow from description');
} }
// Sync with global Mermaid code store so it appears in the Code tab
setMermaidCode(result.mermaidCode);
setSourceCode(result.mermaidCode); setSourceCode(result.mermaidCode);
const { nodes: parsedNodes, edges: parsedEdges } = await parseMermaid(result.mermaidCode); const { nodes: parsedNodes, edges: parsedEdges } = await parseMermaid(result.mermaidCode);
if (result.metadata) { if (result.metadata) {
@ -59,12 +81,18 @@ export function InputPanel() {
const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(parsedNodes, parsedEdges); const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(parsedNodes, parsedEdges);
setNodes(layoutedNodes); setNodes(layoutedNodes);
setEdges(layoutedEdges); setEdges(layoutedEdges);
// Auto-save the generated diagram
// Generate a name based on the description or a timestamp
const autoSaveName = description.split(' ').slice(0, 4).join(' ') || `Auto-Save ${new Date().toLocaleTimeString()}`;
saveDiagram(autoSaveName);
} catch (error) { } catch (error) {
setError(error instanceof Error ? error.message : 'Failed to process description'); setError(error instanceof Error ? error.message : 'Failed to process description');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [description, ollamaUrl, modelName, aiMode, onlineProvider, apiKey, generationComplexity, setNodes, setEdges, setLoading, setError, setSourceCode]); }, [description, ollamaUrl, modelName, aiMode, onlineProvider, apiKey, generationComplexity, setNodes, setEdges, setLoading, setError, setSourceCode, setMermaidCode, saveDiagram]);
const tabs = [ const tabs = [
{ id: 'image' as Tab, icon: Image, label: 'Upload' }, { id: 'image' as Tab, icon: Image, label: 'Upload' },

View file

@ -1,19 +1,33 @@
import { useState } from 'react'; import { useState } from 'react';
import { Eye, Server, Database, Smartphone } from 'lucide-react'; import { Eye, Server, Database, Smartphone, Layers, BoxSelect } from 'lucide-react';
import { useFlowStore } from '../store'; import { useFlowStore } from '../store';
const filters = [ const filters = [
{ id: 'filter-client', label: 'Client', icon: Smartphone, color: '#a855f7' }, { id: 'filter-client', label: 'Client', icon: Smartphone, color: '#a855f7' },
{ id: 'filter-server', label: 'Server', icon: Server, color: '#3b82f6' }, { id: 'filter-server', label: 'Server', icon: Server, color: '#3b82f6' },
{ id: 'filter-db', label: 'Database', icon: Database, color: '#10b981' }, { id: 'filter-db', label: 'Database', icon: Database, color: '#10b981' },
{ id: 'filter-group', label: 'Groups', icon: BoxSelect, color: '#94a3b8' },
{ id: 'filter-other', label: 'Flow / Other', icon: Layers, color: '#f59e0b' },
]; ];
export default function InteractiveLegend() { export default function InteractiveLegend() {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const { focusMode, activeFilters, toggleFilter } = useFlowStore(); const { focusMode, activeFilters, toggleFilter, nodes } = useFlowStore();
if (focusMode) return null; if (focusMode) return null;
// Calculate available categories from current nodes
const availableCategories = new Set(nodes.map(node => node.data.category));
// Always include 'filter-group' if there are groups, or maybe check node type
if (nodes.some(n => n.type === 'group')) {
availableCategories.add('filter-group');
}
const visibleFilters = filters.filter(f => availableCategories.has(f.id));
if (visibleFilters.length === 0) return null;
return ( return (
<div className="absolute bottom-20 left-4 z-50"> <div className="absolute bottom-20 left-4 z-50">
{/* Toggle Button */} {/* Toggle Button */}
@ -35,7 +49,7 @@ export default function InteractiveLegend() {
Legend Filters Legend Filters
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
{filters.map(f => { {visibleFilters.map(f => {
const isActive = activeFilters.includes(f.id); const isActive = activeFilters.includes(f.id);
return ( return (
<button <button

View file

@ -4,7 +4,6 @@ import {
FileText, Activity, Zap, Cpu, Wifi, BarChart3 FileText, Activity, Zap, Cpu, Wifi, BarChart3
} from 'lucide-react'; } from 'lucide-react';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { VisualOrganizerPanel } from './VisualOrganizerPanel';
import { SmartGuide } from './SmartGuide'; import { SmartGuide } from './SmartGuide';
export function NodeDetailsPanel() { export function NodeDetailsPanel() {
@ -38,8 +37,6 @@ export function NodeDetailsPanel() {
<div className="w-full h-px bg-gradient-to-r from-transparent via-blue-500/20 to-transparent" /> <div className="w-full h-px bg-gradient-to-r from-transparent via-blue-500/20 to-transparent" />
</div> </div>
<VisualOrganizerPanel />
<div className="mt-auto"> <div className="mt-auto">
<SmartGuide /> <SmartGuide />
</div> </div>
@ -102,6 +99,12 @@ export function NodeDetailsPanel() {
className="w-full bg-white dark:bg-black/20 border border-slate-200 dark:border-white/10 rounded-xl p-3 text-sm font-semibold outline-none focus:border-blue-500/50 transition-all text-slate-800 dark:text-primary cursor-pointer appearance-none" className="w-full bg-white dark:bg-black/20 border border-slate-200 dark:border-white/10 rounded-xl p-3 text-sm font-semibold outline-none focus:border-blue-500/50 transition-all text-slate-800 dark:text-primary cursor-pointer appearance-none"
> >
<option value="default">Default</option> <option value="default">Default</option>
<option value="group">Group / Container</option>
<option value="ai">AI Director / Agent</option>
<option value="team">Human Team</option>
<option value="platform">External Platform</option>
<option value="data">Data / Analytics</option>
<option value="tech">Tech Infrastructure</option>
<option value="start">Start</option> <option value="start">Start</option>
<option value="end">End</option> <option value="end">End</option>
<option value="decision">Decision</option> <option value="decision">Decision</option>

View file

@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { Cpu, X, Database, Globe, ShieldCheck, ChevronDown, RefreshCw, Zap, Download, Eye } from 'lucide-react'; import { Cpu, X, Database, Globe, ShieldCheck, ChevronDown, RefreshCw, Zap, Download, Eye, Smartphone } from 'lucide-react';
import { useFlowStore } from '../store'; import { useFlowStore } from '../store';
import { useMobileDetect } from '../hooks/useMobileDetect';
import { webLlmService } from '../lib/webLlmService'; import { webLlmService } from '../lib/webLlmService';
import type { WebLlmProgress } from '../lib/webLlmService'; import type { WebLlmProgress } from '../lib/webLlmService';
import { visionService } from '../lib/visionService'; import { visionService } from '../lib/visionService';
@ -25,6 +26,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
aiMode, setAiMode, aiMode, setAiMode,
onlineProvider, setOnlineProvider onlineProvider, setOnlineProvider
} = useFlowStore(); } = useFlowStore();
const { isMobile } = useMobileDetect();
const [systemStatus, setSystemStatus] = useState<'online' | 'offline'>('offline'); const [systemStatus, setSystemStatus] = useState<'online' | 'offline'>('offline');
const [isVerifying, setIsVerifying] = useState(false); const [isVerifying, setIsVerifying] = useState(false);
@ -41,6 +43,10 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
const [isVisionLoading, setIsVisionLoading] = useState(false); const [isVisionLoading, setIsVisionLoading] = useState(false);
const [isVisionReady, setIsVisionReady] = useState(false); const [isVisionReady, setIsVisionReady] = useState(false);
// API Key Verification State
const [keyVerificationStatus, setKeyVerificationStatus] = useState<'idle' | 'checking' | 'valid' | 'invalid'>('idle');
const [keyVerificationError, setKeyVerificationError] = useState<string | null>(null);
const checkBrowserStatus = useCallback(() => { const checkBrowserStatus = useCallback(() => {
const llmStatus = webLlmService.getStatus(); const llmStatus = webLlmService.getStatus();
setIsBrowserReady(llmStatus.isReady); setIsBrowserReady(llmStatus.isReady);
@ -114,12 +120,59 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
if (onlineProvider === 'ollama-cloud') { if (onlineProvider === 'ollama-cloud') {
await fetchModels(); await fetchModels();
} else { } else {
const isKeyValid = apiKey.length > 20 && (apiKey.startsWith('sk-') || apiKey.startsWith('AIza') || apiKey.length > 30); // Just check format, actual verification is done with verify button
setSystemStatus(isKeyValid ? 'online' : 'offline'); const isKeyFormatValid = apiKey.length > 20 && (apiKey.startsWith('sk-') || apiKey.startsWith('AIza') || apiKey.length > 30);
setSystemStatus(isKeyFormatValid && keyVerificationStatus === 'valid' ? 'online' : 'offline');
} }
} }
setIsVerifying(false); setIsVerifying(false);
}, [aiMode, fetchModels, apiKey, onlineProvider, checkBrowserStatus]); }, [aiMode, fetchModels, apiKey, onlineProvider, checkBrowserStatus, keyVerificationStatus]);
// Verify API key by making a test request
const verifyApiKey = async () => {
if (!apiKey || apiKey.length < 10) {
setKeyVerificationStatus('invalid');
setKeyVerificationError('API key is too short');
return;
}
setKeyVerificationStatus('checking');
setKeyVerificationError(null);
try {
if (onlineProvider === 'openai') {
// Test OpenAI key with models list endpoint (lightweight)
const response = await fetch('https://api.openai.com/v1/models', {
method: 'GET',
headers: { 'Authorization': `Bearer ${apiKey}` }
});
if (response.ok) {
setKeyVerificationStatus('valid');
setSystemStatus('online');
} else {
const error = await response.json().catch(() => ({}));
setKeyVerificationStatus('invalid');
setKeyVerificationError(error.error?.message || `Error: ${response.status}`);
}
} else if (onlineProvider === 'gemini') {
// Test Gemini key with a minimal request
const response = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`
);
if (response.ok) {
setKeyVerificationStatus('valid');
setSystemStatus('online');
} else {
const error = await response.json().catch(() => ({}));
setKeyVerificationStatus('invalid');
setKeyVerificationError(error.error?.message || `Error: ${response.status}`);
}
}
} catch (error) {
setKeyVerificationStatus('invalid');
setKeyVerificationError(error instanceof Error ? error.message : 'Connection failed');
}
};
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
@ -134,7 +187,12 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
return ( return (
<> <>
<div className="fixed inset-0 z-[9998] bg-black/40 backdrop-blur-sm animate-fade-in" onClick={onClose} /> <div className="fixed inset-0 z-[9998] bg-black/40 backdrop-blur-sm animate-fade-in" onClick={onClose} />
<div className="fixed top-24 right-12 w-96 floating-glass p-8 rounded-[2rem] z-[9999] animate-slide-up titanium-border shadow-2xl flex flex-col gap-6"> <div className={`fixed z-[9999] flex flex-col gap-4
${isMobile
? 'left-4 right-4 top-16 max-h-[80vh] rounded-[2rem]'
: 'top-24 right-12 w-96 rounded-[2rem]'
} floating-glass p-6 titanium-border shadow-2xl overflow-hidden`}
>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-blue-500/10 flex items-center justify-center border border-blue-500/20"> <div className="w-10 h-10 rounded-xl bg-blue-500/10 flex items-center justify-center border border-blue-500/20">
@ -145,12 +203,21 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
<p className="text-[10px] text-tertiary font-medium uppercase tracking-wider">Configuration</p> <p className="text-[10px] text-tertiary font-medium uppercase tracking-wider">Configuration</p>
</div> </div>
</div> </div>
<button onClick={onClose} className="p-2 hover:bg-white/5 rounded-full transition-colors group"> <div className="flex items-center gap-2">
<X className="w-5 h-5 text-tertiary group-hover:text-primary transition-colors" /> {isMobile && (
</button> <div className="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-amber-500/10 border border-amber-500/20">
<Smartphone className="w-3 h-3 text-amber-400" />
<span className="text-[9px] font-bold text-amber-400 uppercase tracking-wider">Lite</span>
</div>
)}
<button onClick={onClose} className="p-2 hover:bg-white/5 rounded-full transition-colors group">
<X className="w-5 h-5 text-tertiary group-hover:text-primary transition-colors" />
</button>
</div>
</div> </div>
<div className="space-y-6"> {/* Scrollable Content Area */}
<div className={`${isMobile ? 'flex-1 overflow-y-auto' : ''} space-y-6`}>
{/* Mode Selection */} {/* Mode Selection */}
<div className="flex items-center gap-1 p-1 bg-black/20 rounded-xl border border-white/5"> <div className="flex items-center gap-1 p-1 bg-black/20 rounded-xl border border-white/5">
<button <button
@ -190,6 +257,18 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
{aiMode === 'browser' && ( {aiMode === 'browser' && (
<div className="space-y-4 animate-fade-in max-h-[60vh] overflow-y-auto pr-2 custom-scrollbar"> <div className="space-y-4 animate-fade-in max-h-[60vh] overflow-y-auto pr-2 custom-scrollbar">
{/* Mobile Warning */}
{isMobile && (
<div className="p-3 rounded-xl bg-amber-500/10 border border-amber-500/20">
<div className="flex items-start gap-2">
<Smartphone className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
<div>
<p className="text-[10px] font-bold text-amber-300 uppercase tracking-wider">Performance Warning</p>
<p className="text-[9px] text-amber-400/70 mt-1 leading-relaxed">Browser AI requires 1GB+ download and may drain battery. Cloud mode is recommended for mobile.</p>
</div>
</div>
</div>
)}
{/* Neural Engine Card */} {/* Neural Engine Card */}
<div className="p-4 rounded-xl bg-violet-500/5 border border-violet-500/10"> <div className="p-4 rounded-xl bg-violet-500/5 border border-violet-500/10">
<div className="flex items-center gap-3 mb-3"> <div className="flex items-center gap-3 mb-3">
@ -358,9 +437,9 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
<label className="text-[10px] font-bold uppercase tracking-wider text-tertiary pl-1">AI Engine</label> <label className="text-[10px] font-bold uppercase tracking-wider text-tertiary pl-1">AI Engine</label>
<div className="grid grid-cols-1 gap-2"> <div className="grid grid-cols-1 gap-2">
{[ {[
{ id: 'openai', label: 'OpenAI (GPT-4o)', color: 'bg-green-500' }, { id: 'openai', label: 'OpenAI (GPT-4o)', color: 'bg-green-500', apiUrl: 'https://platform.openai.com/api-keys' },
{ id: 'gemini', label: 'Google Gemini Pro', color: 'bg-blue-500' }, { id: 'gemini', label: 'Google Gemini Pro', color: 'bg-blue-500', apiUrl: 'https://aistudio.google.com/apikey' },
{ id: 'ollama-cloud', label: 'Remote Ollama', color: 'bg-orange-500' } { id: 'ollama-cloud', label: 'Remote Ollama', color: 'bg-orange-500', apiUrl: null }
].map(p => ( ].map(p => (
<button <button
key={p.id} key={p.id}
@ -386,13 +465,64 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
<label className="text-[10px] font-bold uppercase tracking-wider text-tertiary pl-1"> <label className="text-[10px] font-bold uppercase tracking-wider text-tertiary pl-1">
{onlineProvider === 'ollama-cloud' ? 'API Endpoint' : 'Secret Key'} {onlineProvider === 'ollama-cloud' ? 'API Endpoint' : 'Secret Key'}
</label> </label>
<input <div className="flex gap-2">
type={onlineProvider === 'ollama-cloud' ? 'text' : 'password'} <input
value={onlineProvider === 'ollama-cloud' ? ollamaUrl : apiKey} type={onlineProvider === 'ollama-cloud' ? 'text' : 'password'}
onChange={(e) => onlineProvider === 'ollama-cloud' ? setOllamaUrl(e.target.value) : setApiKey(e.target.value)} value={onlineProvider === 'ollama-cloud' ? ollamaUrl : apiKey}
className="w-full bg-black/20 border border-white/10 rounded-xl p-3 text-[12px] outline-none focus:border-blue-500/50 font-mono transition-all text-primary placeholder:text-slate-600" onChange={(e) => {
placeholder={onlineProvider === 'ollama-cloud' ? 'https://api.example.com' : 'sk-...'} if (onlineProvider === 'ollama-cloud') {
/> setOllamaUrl(e.target.value);
} else {
setApiKey(e.target.value);
setKeyVerificationStatus('idle');
}
}}
className="flex-1 bg-black/20 border border-white/10 rounded-xl p-3 text-[12px] outline-none focus:border-blue-500/50 font-mono transition-all text-primary placeholder:text-slate-600"
placeholder={onlineProvider === 'ollama-cloud' ? 'https://api.example.com' : 'sk-...'}
/>
{onlineProvider !== 'ollama-cloud' && (
<button
onClick={verifyApiKey}
disabled={keyVerificationStatus === 'checking' || !apiKey}
className={`px-4 rounded-xl border transition-all flex items-center justify-center min-w-[80px] text-[10px] font-bold uppercase tracking-wider
${keyVerificationStatus === 'valid'
? 'bg-emerald-500/20 border-emerald-500/30 text-emerald-400'
: keyVerificationStatus === 'invalid'
? 'bg-red-500/20 border-red-500/30 text-red-400'
: 'bg-white/5 border-white/10 text-slate-400 hover:bg-white/10 hover:text-white'}`}
>
{keyVerificationStatus === 'checking' ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : keyVerificationStatus === 'valid' ? (
<> Valid</>
) : keyVerificationStatus === 'invalid' ? (
<> Invalid</>
) : (
'Verify'
)}
</button>
)}
</div>
{keyVerificationStatus === 'invalid' && keyVerificationError && (
<p className="text-[9px] text-red-400 pl-1">{keyVerificationError}</p>
)}
{keyVerificationStatus === 'valid' && (
<p className="text-[9px] text-emerald-400 pl-1">API key verified successfully!</p>
)}
{/* Get API Key Link */}
{onlineProvider !== 'ollama-cloud' && (
<a
href={onlineProvider === 'openai'
? 'https://platform.openai.com/api-keys'
: 'https://aistudio.google.com/apikey'}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-[10px] text-blue-400 hover:text-blue-300 transition-colors pl-1 mt-1"
>
<Globe className="w-3 h-3" />
Get {onlineProvider === 'openai' ? 'OpenAI' : 'Google Gemini'} API Key
</a>
)}
</div> </div>
</div> </div>
)} )}

View file

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Lightbulb, MousePointer2, Keyboard, Layout } from 'lucide-react'; import { Lightbulb, MousePointer2, Keyboard, Layout } from 'lucide-react';
const TIPS = [ const TIPS = [

View file

@ -0,0 +1,142 @@
import React, { useState } from 'react';
import { Button } from './ui/Button';
import { Card } from './ui/Card';
import { useVisualOrganizer } from '../hooks/useVisualOrganizer';
import { useDiagramStore } from '../store';
import { Sparkles, Wand2, Scan, CheckCircle2, X } from 'lucide-react';
import type { LayoutSuggestion } from '../types/visualOrganization';
export const VisualOrganizerFAB: React.FC = () => {
const { analyzeLayout, generateSuggestions, applySuggestion } = useVisualOrganizer();
const { nodes, edges, setNodes, setEdges } = useDiagramStore();
// UI States
const [isOpen, setIsOpen] = useState(false);
const [status, setStatus] = useState<'idle' | 'analyzing' | 'ready' | 'applied'>('idle');
const [bestSuggestion, setBestSuggestion] = useState<LayoutSuggestion | null>(null);
const [snapshot, setSnapshot] = useState<{ nodes: any[], edges: any[] } | null>(null);
const handleAIOrganize = async () => {
setStatus('analyzing');
setIsOpen(true);
analyzeLayout();
try {
await new Promise(resolve => setTimeout(resolve, 1500));
const results = await generateSuggestions();
if (results.length > 0) {
setBestSuggestion(results[0]);
setStatus('ready');
} else {
setStatus('idle');
// Could show a generic "No suggestions" toast here
setIsOpen(false);
}
} catch (error) {
console.error(error);
setStatus('idle');
setIsOpen(false);
}
};
const handleApply = () => {
if (!bestSuggestion) return;
setSnapshot({ nodes: [...nodes], edges: [...edges] });
applySuggestion(bestSuggestion);
setStatus('applied');
};
const handleUndo = () => {
if (snapshot) {
setNodes(snapshot.nodes);
setEdges(snapshot.edges);
setSnapshot(null);
setStatus('ready');
}
};
const handleClose = () => {
setIsOpen(false);
if (status === 'applied') {
setStatus('idle');
setSnapshot(null);
setBestSuggestion(null);
}
};
// If closed, show just the FAB
if (!isOpen) {
return (
<button
onClick={handleAIOrganize}
className="w-12 h-12 rounded-full bg-white dark:bg-slate-800 border border-indigo-100 dark:border-indigo-900/30 shadow-lg hover:shadow-indigo-500/20 flex items-center justify-center group transition-all hover:scale-110 active:scale-95"
title="AI Visual Organizer"
>
<Sparkles className="w-5 h-5 text-indigo-500 group-hover:rotate-12 transition-transform" />
</button>
);
}
// Expanded State (Floating Card)
return (
<div className="relative animate-in slide-in-from-bottom-4 fade-in duration-300">
<Card className="w-80 p-0 overflow-hidden shadow-2xl border-indigo-100 dark:border-indigo-900/30">
{/* Header */}
<div className="px-4 py-3 bg-slate-50 dark:bg-slate-900/50 border-b border-slate-100 dark:border-white/5 flex items-center justify-between">
<div className="flex items-center gap-2">
<Wand2 className="w-4 h-4 text-indigo-500" />
<span className="text-xs font-bold uppercase tracking-wider text-slate-600 dark:text-slate-300">Organizer</span>
</div>
<button onClick={handleClose} className="text-slate-400 hover:text-slate-600 dark:hover:text-white">
<X className="w-4 h-4" />
</button>
</div>
{/* Content */}
<div className="p-5">
{status === 'analyzing' && (
<div className="flex flex-col items-center py-4 space-y-4">
<div className="relative w-16 h-16">
<div className="absolute inset-0 border-4 border-indigo-100 dark:border-indigo-900/30 rounded-full"></div>
<div className="absolute inset-0 border-4 border-t-indigo-500 rounded-full animate-spin"></div>
<div className="absolute inset-0 flex items-center justify-center">
<Scan className="w-6 h-6 text-indigo-500 animate-pulse" />
</div>
</div>
<p className="text-xs font-medium text-slate-500 animate-pulse">Analyzing structure...</p>
</div>
)}
{status === 'ready' && bestSuggestion && (
<div className="space-y-4">
<div className="text-center">
<CheckCircle2 className="w-8 h-8 text-green-500 mx-auto mb-2" />
<h4 className="font-bold text-sm text-slate-800 dark:text-white">Optimization Ready</h4>
<p className="text-xs text-slate-500 mt-1">{bestSuggestion.description}</p>
</div>
<div className="grid grid-cols-2 gap-2">
<Button size="sm" variant="secondary" onClick={handleClose}>Cancel</Button>
<Button size="sm" onClick={handleApply} className="bg-indigo-600 hover:bg-indigo-500 text-white">Apply</Button>
</div>
</div>
)}
{status === 'applied' && (
<div className="space-y-4">
<div className="text-center">
<Sparkles className="w-8 h-8 text-indigo-500 mx-auto mb-2" />
<h4 className="font-bold text-sm text-slate-800 dark:text-white">Clean & Organized!</h4>
</div>
<div className="grid grid-cols-2 gap-2">
<Button size="sm" variant="secondary" onClick={handleUndo}>Undo</Button>
<Button size="sm" onClick={handleClose} className="bg-slate-900 text-white dark:bg-white dark:text-slate-900">Done</Button>
</div>
</div>
)}
</div>
</Card>
</div>
);
};

View file

@ -3,194 +3,192 @@ import { Button } from './ui/Button';
import { Card } from './ui/Card'; import { Card } from './ui/Card';
import { useVisualOrganizer } from '../hooks/useVisualOrganizer'; import { useVisualOrganizer } from '../hooks/useVisualOrganizer';
import { useDiagramStore } from '../store'; import { useDiagramStore } from '../store';
import { Sparkles, Wand2, Layout, Scan, CheckCircle2, RotateCcw } from 'lucide-react';
import type { LayoutSuggestion } from '../types/visualOrganization'; import type { LayoutSuggestion } from '../types/visualOrganization';
export const VisualOrganizerPanel: React.FC = () => { export const VisualOrganizerPanel: React.FC = () => {
const { analyzeLayout, generateSuggestions, applySuggestion, getPresets } = useVisualOrganizer(); const { analyzeLayout, generateSuggestions, applySuggestion } = useVisualOrganizer();
const { nodes, edges, setNodes, setEdges } = useDiagramStore(); // Needed for snapshotting const { nodes, edges, setNodes, setEdges } = useDiagramStore();
const [suggestions, setSuggestions] = useState<LayoutSuggestion[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [analysis, setAnalysis] = useState<any>(null);
const [snapshotHistory, setSnapshotHistory] = useState<Array<{ id: string, timestamp: number, nodes: any[], edges: any[], name: string }>>([]);
const [previewState, setPreviewState] = useState<{
originalNodes: any[];
originalEdges: any[];
suggestionId: string;
} | null>(null);
const takeSnapshot = (name: string) => { // UI States
setSnapshotHistory(prev => [ const [status, setStatus] = useState<'idle' | 'analyzing' | 'ready' | 'applied'>('idle');
{ id: Math.random().toString(36).substr(2, 9), timestamp: Date.now(), nodes: [...nodes], edges: [...edges], name }, const [bestSuggestion, setBestSuggestion] = useState<LayoutSuggestion | null>(null);
...prev.slice(0, 4) // Keep last 5 const [snapshot, setSnapshot] = useState<{ nodes: any[], edges: any[] } | null>(null);
]);
};
const restoreSnapshot = (snapshot: any) => { // AI Organize Handler
setNodes(snapshot.nodes); const handleAIOrganize = async () => {
setEdges(snapshot.edges); setStatus('analyzing');
};
const handleAnalyze = () => { // 1. Analyze (Simulate brief delay for effect)
const result = analyzeLayout(); analyzeLayout();
setAnalysis(result);
};
const handleGenerateSuggestions = async () => { // 2. Generate Suggestions
setIsLoading(true);
try { try {
const result = await generateSuggestions(); // Artificial delay for "Scanning" animation effect
setSuggestions(result); await new Promise(resolve => setTimeout(resolve, 1500));
const results = await generateSuggestions();
// Pick best suggestion (or default to first non-current)
if (results.length > 0) {
setBestSuggestion(results[0]);
setStatus('ready');
} else {
// Fallback if no suggestions
setStatus('idle');
}
} catch (error) { } catch (error) {
console.error('Failed to generate suggestions:', error); console.error(error);
} finally { setStatus('idle');
setIsLoading(false);
} }
}; };
const handlePreviewSuggestion = (suggestion: LayoutSuggestion) => { const handleApply = () => {
// Save current state if (!bestSuggestion) return;
if (!previewState) {
setPreviewState({ // Take snapshot before applying
originalNodes: [...nodes], setSnapshot({ nodes: [...nodes], edges: [...edges] });
originalEdges: [...edges],
suggestionId: suggestion.id applySuggestion(bestSuggestion);
}); setStatus('applied');
}
// Apply suggestion
applySuggestion(suggestion);
}; };
const handleConfirmPreview = (suggestion: LayoutSuggestion) => { const handleUndo = () => {
takeSnapshot(`Before ${suggestion.title}`); if (snapshot) {
setPreviewState(null); setNodes(snapshot.nodes);
setSuggestions(suggestions.filter(s => s.id !== suggestion.id)); setEdges(snapshot.edges);
setSnapshot(null);
setStatus('ready');
}
}; };
const handleCancelPreview = () => { const handleReset = () => {
if (previewState) { setStatus('idle');
setNodes(previewState.originalNodes); setSnapshot(null);
setEdges(previewState.originalEdges); setBestSuggestion(null);
setPreviewState(null);
}
}; };
return ( return (
<div className="visual-organizer-panel"> <div className="visual-organizer-panel w-full">
<Card className="mb-4"> <Card className="p-0 overflow-hidden border-none shadow-none bg-transparent">
<h3 className="text-lg font-semibold mb-2">Visual Organizer</h3>
<div className="flex gap-2">
<Button onClick={handleAnalyze} variant="secondary">
Analyze Layout
</Button>
<Button onClick={handleGenerateSuggestions} disabled={isLoading}>
{isLoading ? 'Generating...' : 'Get Suggestions'}
</Button>
</div>
</Card>
{analysis && ( {/* IDLE STATE: Main AI Button */}
<Card className="mb-4"> {status === 'idle' && (
<h4 className="font-medium mb-2">Layout Analysis</h4> <div className="flex flex-col items-center justify-center p-8 text-center space-y-6">
<div className="text-sm"> <div className="relative group cursor-pointer" onClick={handleAIOrganize}>
<p>Nodes: {analysis.metrics.nodeCount}</p> <div className="absolute inset-0 bg-blue-500 rounded-full blur-xl opacity-20 group-hover:opacity-40 transition-opacity duration-500 animate-pulse"></div>
<p>Edges: {analysis.metrics.edgeCount}</p> <div className="relative w-24 h-24 bg-gradient-to-br from-blue-600 to-indigo-600 rounded-2xl flex items-center justify-center shadow-2xl transform group-hover:scale-105 transition-all duration-300 border border-white/20">
<p>Issues: {analysis.issues.length}</p> <Sparkles className="w-10 h-10 text-white animate-pulse" />
<p>Strengths: {analysis.strengths.length}</p>
</div>
</Card>
)}
<Card className="mb-4">
<h4 className="font-medium mb-2">Quick Layout Presets</h4>
<div className="grid grid-cols-2 gap-2">
{getPresets().map(preset => (
<Button
key={preset.id}
variant="secondary"
size="sm"
onClick={() => handlePreviewSuggestion(preset)}
className="h-auto flex flex-col items-start p-3 text-left"
>
<span className="font-bold text-xs mb-1">{preset.title}</span>
<span className="text-[10px] text-gray-500 font-normal leading-tight">{preset.description}</span>
</Button>
))}
</div>
</Card>
{suggestions.length > 0 && (
<Card>
<h4 className="font-medium mb-2">AI Suggestions</h4>
<div className="space-y-2">
{suggestions.map((suggestion) => {
const isPreviewing = previewState?.suggestionId === suggestion.id;
const isOtherPreviewing = previewState !== null && !isPreviewing;
if (isOtherPreviewing) return null; // Hide other suggestions while previewing
return (
<div key={suggestion.id} className={`border rounded p-2 ${isPreviewing ? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/20' : ''}`}>
<h5 className="font-medium">{suggestion.title}</h5>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">{suggestion.description}</p>
<div className="flex gap-2 mt-2">
{!isPreviewing ? (
<>
<Button
size="sm"
onClick={() => handlePreviewSuggestion(suggestion)}
>
Preview
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => setSuggestions(suggestions.filter(s => s.id !== suggestion.id))}
>
Dismiss
</Button>
</>
) : (
<>
<Button
size="sm"
onClick={() => handleConfirmPreview(suggestion)}
className="bg-green-600 hover:bg-green-700"
>
Confirm
</Button>
<Button
size="sm"
variant="danger"
onClick={handleCancelPreview}
>
Revert
</Button>
</>
)}
</div>
</div>
);
})}
</div>
</Card>
)}
{snapshotHistory.length > 0 && (
<Card>
<h4 className="font-medium mb-2">History & Comparison</h4>
<div className="space-y-1">
{snapshotHistory.map(snap => (
<div key={snap.id} className="flex items-center justify-between text-xs p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded">
<span>{snap.name}</span>
<Button size="sm" variant="ghost" onClick={() => restoreSnapshot(snap)} className="h-6 px-2">
Restore
</Button>
</div> </div>
))} <div className="absolute -bottom-2 -right-2 bg-white dark:bg-slate-800 p-2 rounded-full shadow-lg border border-slate-100 dark:border-slate-700">
<Wand2 className="w-4 h-4 text-purple-500" />
</div>
</div>
<div className="space-y-2">
<h3 className="text-xl font-display font-bold text-slate-800 dark:text-white">AI Visual Organizer</h3>
<p className="text-sm text-slate-500 dark:text-slate-400 max-w-[200px] mx-auto leading-relaxed">
Click to instantly analyze and reorganize your flow for maximum clarity.
</p>
</div>
<Button
onClick={handleAIOrganize}
className="bg-slate-900 dark:bg-white text-white dark:text-slate-900 hover:bg-slate-800 hover:dark:bg-slate-100 px-8 py-6 rounded-xl shadow-xl hover:shadow-2xl transition-all font-bold tracking-wide"
>
<Scan className="w-4 h-4 mr-2" />
Start Organization
</Button>
</div> </div>
</Card> )}
)}
{/* ANALYZING STATE: Scanning Animation */}
{status === 'analyzing' && (
<div className="flex flex-col items-center justify-center p-12 text-center space-y-6 animate-in fade-in zoom-in duration-300">
<div className="relative w-20 h-20">
<div className="absolute inset-0 border-4 border-blue-500/20 rounded-full"></div>
<div className="absolute inset-0 border-4 border-t-blue-500 rounded-full animate-spin"></div>
<div className="absolute inset-0 flex items-center justify-center">
<Scan className="w-8 h-8 text-blue-500 animate-pulse" />
</div>
</div>
<h3 className="text-lg font-bold text-blue-600 dark:text-blue-400 animate-pulse">
Analyzing Layout Logic...
</h3>
</div>
)}
{/* READY STATE: Suggestion Found */}
{status === 'ready' && bestSuggestion && (
<div className="flex flex-col p-6 space-y-6 animate-in slide-in-from-bottom-4 duration-500">
<div className="flex items-center gap-3 text-green-600 dark:text-green-400 mb-2">
<CheckCircle2 className="w-6 h-6" />
<span className="font-bold text-lg">Optimization Found!</span>
</div>
<div className="p-4 bg-slate-50 dark:bg-white/5 rounded-xl border border-slate-200 dark:border-white/10">
<div className="flex items-center gap-3 mb-2">
<Layout className="w-5 h-5 text-indigo-500" />
<h4 className="font-bold">{bestSuggestion.title}</h4>
</div>
<p className="text-sm text-slate-600 dark:text-slate-400 leading-relaxed">
{bestSuggestion.description}
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<Button
onClick={handleReset}
variant="secondary"
className="h-12"
>
Cancel
</Button>
<Button
onClick={handleApply}
className="h-12 bg-indigo-600 hover:bg-indigo-500 text-white shadow-lg hover:shadow-indigo-500/25"
>
<Wand2 className="w-4 h-4 mr-2" />
Apply Magic
</Button>
</div>
</div>
)}
{/* APPLIED STATE: Success & Undo */}
{status === 'applied' && (
<div className="flex flex-col items-center justify-center p-8 text-center space-y-6 animate-in zoom-in duration-300">
<div className="w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mb-2">
<CheckCircle2 className="w-8 h-8 text-green-600 dark:text-green-400" />
</div>
<div>
<h3 className="text-xl font-bold text-slate-800 dark:text-white mb-2">Beautifully Organized!</h3>
<p className="text-sm text-slate-500 dark:text-slate-400">
Your graph has been transformed.
</p>
</div>
<div className="flex gap-3 w-full">
<Button
onClick={handleUndo}
variant="secondary"
className="flex-1 h-12 border-slate-200 dark:border-white/10 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/10 dark:hover:text-red-400 transition-colors"
>
<RotateCcw className="w-4 h-4 mr-2" />
Undo
</Button>
<Button
onClick={handleReset} // Goes back to idle
className="flex-1 h-12"
>
Done
</Button>
</div>
</div>
)}
</Card>
</div> </div>
); );
}; };

View file

@ -1,12 +1,14 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { import {
Edit3, Code, Download, Zap, Sun, Moon, Maximize2, Minimize2, Settings, Save, Edit3, Code, Download, Zap, Sun, Moon, Maximize2, Minimize2, Settings, Save,
ChevronDown, FileCode, ImageIcon, FileText, Frame ChevronDown, FileCode, ImageIcon, FileText, Frame, Cloud, Server, Cpu, RotateCcw, RotateCw
} from 'lucide-react'; } from 'lucide-react';
import { useFlowStore } from '../../store'; import { useStore } from 'zustand';
import { useFlowStore, useDiagramStore } from '../../store';
import { useMobileDetect } from '../../hooks/useMobileDetect';
import { import {
exportToPng, exportToJpg, exportToSvg, exportToPng, exportToJpg, exportToSvg,
exportToTxt, downloadMermaid exportToTxt, downloadMermaid, exportToJson
} from '../../lib/exportUtils'; } from '../../lib/exportUtils';
import { useState } from 'react'; import { useState } from 'react';
import { SettingsModal } from '../Settings'; import { SettingsModal } from '../Settings';
@ -17,23 +19,30 @@ export function EditorHeader() {
rightPanelOpen, setRightPanelOpen, rightPanelOpen, setRightPanelOpen,
theme, toggleTheme, theme, toggleTheme,
focusMode, setFocusMode, focusMode, setFocusMode,
saveDiagram saveDiagram,
aiMode, onlineProvider
} = useFlowStore(); } = useFlowStore();
const { isMobile } = useMobileDetect();
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
const [isSaving, setIsSaving] = useState(false); const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
const [showExportMenu, setShowExportMenu] = useState(false); const [showExportMenu, setShowExportMenu] = useState(false);
const handleSave = () => { const handleSave = () => {
setIsSaving(true);
const name = prompt('Enter diagram name:', `Diagram ${new Date().toLocaleDateString()}`); const name = prompt('Enter diagram name:', `Diagram ${new Date().toLocaleDateString()}`);
if (name) { if (name) {
setSaveStatus('saving');
saveDiagram(name); saveDiagram(name);
// Show success state
setTimeout(() => {
setSaveStatus('saved');
setTimeout(() => setSaveStatus('idle'), 2000);
}, 600);
} }
setTimeout(() => setIsSaving(false), 800);
}; };
const handleExport = async (format: 'png' | 'jpg' | 'svg' | 'txt' | 'code') => { const handleExport = async (format: 'png' | 'jpg' | 'svg' | 'txt' | 'code' | 'json') => {
setShowExportMenu(false); setShowExportMenu(false);
const viewport = document.querySelector('.react-flow__viewport') as HTMLElement; const viewport = document.querySelector('.react-flow__viewport') as HTMLElement;
@ -46,7 +55,7 @@ export function EditorHeader() {
if (viewport) await exportToJpg(viewport); if (viewport) await exportToJpg(viewport);
break; break;
case 'svg': case 'svg':
exportToSvg(nodes, edges); if (viewport) await exportToSvg(viewport);
break; break;
case 'txt': case 'txt':
exportToTxt(nodes, edges); exportToTxt(nodes, edges);
@ -54,6 +63,9 @@ export function EditorHeader() {
case 'code': case 'code':
downloadMermaid(nodes, edges); downloadMermaid(nodes, edges);
break; break;
case 'json':
exportToJson(nodes, edges);
break;
} }
} catch (error) { } catch (error) {
console.error('Export failed:', error); console.error('Export failed:', error);
@ -67,37 +79,86 @@ export function EditorHeader() {
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center shadow-lg shadow-blue-500/20 group-hover:scale-110 transition-all duration-500"> <div className="w-7 h-7 rounded-lg bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center shadow-lg shadow-blue-500/20 group-hover:scale-110 transition-all duration-500">
<Zap className="w-3.5 h-3.5 text-white fill-white/20" /> <Zap className="w-3.5 h-3.5 text-white fill-white/20" />
</div> </div>
<span className="text-sm font-black tracking-tight text-slate-800 dark:text-primary">SystemArchitect</span> <span className="text-sm font-black tracking-tight text-slate-800 dark:text-primary hidden sm:inline">SystemArchitect</span>
</Link> </Link>
{/* Panel toggles - hidden on mobile */}
<div className="h-4 w-px bg-slate-200 dark:bg-white/10 mx-2"></div> <div className="h-4 w-px bg-slate-200 dark:bg-white/10 mx-2"></div>
<div className="flex items-center gap-1.5 bg-slate-100 dark:bg-slate-800/50 p-1 rounded-xl border border-black/5 dark:border-white/5">
{/* History Controls */}
<div className="flex items-center gap-1">
<button <button
onClick={() => setLeftPanelOpen(!leftPanelOpen)} onClick={() => useDiagramStore.temporal.getState().undo()}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider transition-all ${leftPanelOpen disabled={!useStore(useDiagramStore.temporal, (state: any) => state.pastStates.length > 0)}
? 'bg-white dark:bg-blue-600/15 text-blue-600 dark:text-blue-500 shadow-sm dark:shadow-none border border-black/5 dark:border-blue-500/20' className="p-1.5 rounded-lg hover:bg-black/5 dark:hover:bg-white/10 text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-all disabled:opacity-30 disabled:cursor-not-allowed"
: 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 hover:bg-black/5 dark:hover:bg-white/5 border border-transparent'}`} title="Undo"
title="Toggle Input Panel"
> >
<Edit3 className="w-3.5 h-3.5" /> <RotateCcw className="w-4 h-4" />
<span className="hidden sm:inline">Input</span>
</button> </button>
<button <button
onClick={() => setRightPanelOpen(!rightPanelOpen)} onClick={() => useDiagramStore.temporal.getState().redo()}
disabled={nodes.length === 0} disabled={!useStore(useDiagramStore.temporal, (state: any) => state.futureStates.length > 0)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider transition-all ${nodes.length === 0 className="p-1.5 rounded-lg hover:bg-black/5 dark:hover:bg-white/10 text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-all disabled:opacity-30 disabled:cursor-not-allowed"
? 'opacity-30 cursor-not-allowed text-slate-400 dark:text-slate-600' title="Redo"
: (rightPanelOpen
? 'bg-white dark:bg-blue-600/15 text-blue-600 dark:text-blue-500 shadow-sm dark:shadow-none border border-black/5 dark:border-blue-500/20'
: 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 hover:bg-black/5 dark:hover:bg-white/5 border border-transparent')}`}
title="Toggle Code Panel"
> >
<Code className="w-3.5 h-3.5" /> <RotateCw className="w-4 h-4" />
<span className="hidden sm:inline">Code</span>
</button> </button>
</div> </div>
{!isMobile && (
<>
<div className="h-4 w-px bg-slate-200 dark:bg-white/10 mx-2"></div>
<div className="flex items-center gap-1.5 bg-slate-100 dark:bg-slate-800/50 p-1 rounded-xl border border-black/5 dark:border-white/5">
<button
onClick={() => setLeftPanelOpen(!leftPanelOpen)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider transition-all ${leftPanelOpen
? 'bg-white dark:bg-blue-600/15 text-blue-600 dark:text-blue-500 shadow-sm dark:shadow-none border border-black/5 dark:border-blue-500/20'
: 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 hover:bg-black/5 dark:hover:bg-white/5 border border-transparent'}`}
title="Toggle Input Panel"
>
<Edit3 className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Input</span>
</button>
<button
onClick={() => setRightPanelOpen(!rightPanelOpen)}
disabled={nodes.length === 0}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider transition-all ${nodes.length === 0
? 'opacity-30 cursor-not-allowed text-slate-400 dark:text-slate-600'
: (rightPanelOpen
? 'bg-white dark:bg-blue-600/15 text-blue-600 dark:text-blue-500 shadow-sm dark:shadow-none border border-black/5 dark:border-blue-500/20'
: 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 hover:bg-black/5 dark:hover:bg-white/5 border border-transparent')}`}
title="Toggle Code Panel"
>
<Code className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Code</span>
</button>
</div>
</>
)}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* AI Mode Status - Desktop Only */}
{!isMobile && (
<div className="hidden md:flex items-center gap-2 px-3 py-1.5 rounded-full bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-white/5 mr-2">
{aiMode === 'offline' ? (
<>
<Server className="w-3 h-3 text-blue-500" />
<span className="text-[10px] font-bold text-slate-500 dark:text-slate-400">Local</span>
</>
) : aiMode === 'browser' ? (
<>
<Cpu className="w-3 h-3 text-purple-500" />
<span className="text-[10px] font-bold text-slate-500 dark:text-slate-400">Browser</span>
</>
) : (
<>
<Cloud className="w-3 h-3 text-emerald-500" />
<span className="text-[10px] font-bold text-slate-500 dark:text-slate-400">Cloud</span>
</>
)}
</div>
)}
<button <button
onClick={() => setFocusMode(!focusMode)} onClick={() => setFocusMode(!focusMode)}
className={`w-8 h-8 flex items-center justify-center rounded-lg transition-all ${focusMode ? 'text-blue-600 dark:text-blue-500 bg-blue-50 dark:bg-blue-500/10' : 'text-slate-500 dark:text-secondary hover:text-slate-800 dark:hover:text-primary hover:bg-black/5 dark:hover:bg-void'}`} className={`w-8 h-8 flex items-center justify-center rounded-lg transition-all ${focusMode ? 'text-blue-600 dark:text-blue-500 bg-blue-50 dark:bg-blue-500/10' : 'text-slate-500 dark:text-secondary hover:text-slate-800 dark:hover:text-primary hover:bg-black/5 dark:hover:bg-void'}`}
@ -125,11 +186,14 @@ export function EditorHeader() {
<button <button
onClick={handleSave} onClick={handleSave}
disabled={nodes.length === 0 || isSaving} disabled={nodes.length === 0 || saveStatus === 'saving'}
className="flex items-center gap-2 px-4 py-1.5 rounded-lg bg-white dark:bg-surface border border-slate-200 dark:border-white/10 text-slate-600 dark:text-secondary hover:text-blue-600 dark:hover:text-blue-500 hover:border-blue-300 dark:hover:border-blue-500/30 text-[9px] font-black uppercase tracking-widest transition-all active:scale-95 disabled:opacity-20 disabled:cursor-not-allowed" className="flex items-center gap-2 px-3 sm:px-4 py-1.5 rounded-lg bg-white dark:bg-surface border border-slate-200 dark:border-white/10 text-slate-600 dark:text-secondary hover:text-blue-600 dark:hover:text-blue-500 hover:border-blue-300 dark:hover:border-blue-500/30 text-[9px] font-black uppercase tracking-widest transition-all active:scale-95 disabled:opacity-20 disabled:cursor-not-allowed"
title="Save Draft"
> >
<Save className={`w-3 h-3 ${isSaving ? 'animate-bounce' : ''}`} /> <Save className={`w-3.5 h-3.5 ${saveStatus === 'saving' ? 'animate-bounce' : ''} ${saveStatus === 'saved' ? 'text-green-500' : ''}`} />
<span>{isSaving ? 'Saving...' : 'Save Draft'}</span> <span className={`hidden sm:inline ${saveStatus === 'saved' ? 'text-green-500' : ''}`}>
{saveStatus === 'saving' ? 'Saving...' : saveStatus === 'saved' ? 'Saved!' : 'Save'}
</span>
</button> </button>
{/* Export Dropdown */} {/* Export Dropdown */}
@ -137,11 +201,12 @@ export function EditorHeader() {
<button <button
onClick={() => setShowExportMenu(!showExportMenu)} onClick={() => setShowExportMenu(!showExportMenu)}
disabled={nodes.length === 0} disabled={nodes.length === 0}
className={`flex items-center gap-2 px-4 py-1.5 rounded-lg text-[9px] font-black uppercase tracking-widest transition-all shadow-lg active:scale-95 disabled:opacity-20 disabled:cursor-not-allowed ${showExportMenu ? 'bg-blue-500 text-white' : 'bg-blue-600 hover:bg-blue-500 text-white'}`} className={`flex items-center gap-2 px-3 sm:px-4 py-1.5 rounded-lg text-[9px] font-black uppercase tracking-widest transition-all shadow-lg active:scale-95 disabled:opacity-20 disabled:cursor-not-allowed ${showExportMenu ? 'bg-blue-500 text-white' : 'bg-blue-600 hover:bg-blue-500 text-white'}`}
title="Export"
> >
<Download className="w-3 h-3" /> <Download className="w-3.5 h-3.5" />
<span>Export</span> <span className="hidden sm:inline">Export</span>
<ChevronDown className={`w-3 h-3 transition-transform ${showExportMenu ? 'rotate-180' : ''}`} /> <ChevronDown className={`w-3 h-3 hidden sm:block transition-transform ${showExportMenu ? 'rotate-180' : ''}`} />
</button> </button>
{showExportMenu && ( {showExportMenu && (
@ -150,7 +215,11 @@ export function EditorHeader() {
<div className="absolute top-full mt-2 right-0 w-48 floating-glass border titanium-border rounded-xl overflow-hidden z-[80] shadow-2xl animate-slide-up p-1"> <div className="absolute top-full mt-2 right-0 w-48 floating-glass border titanium-border rounded-xl overflow-hidden z-[80] shadow-2xl animate-slide-up p-1">
<button onClick={() => handleExport('code')} className="w-full flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-white/5 text-[10px] font-bold text-secondary transition-all group"> <button onClick={() => handleExport('code')} className="w-full flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-white/5 text-[10px] font-bold text-secondary transition-all group">
<FileCode className="w-3.5 h-3.5 text-blue-500" /> <FileCode className="w-3.5 h-3.5 text-blue-500" />
Mermaid Code Interactive Code
</button>
<button onClick={() => handleExport('json')} className="w-full flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-white/5 text-[10px] font-bold text-secondary transition-all group">
<FileCode className="w-3.5 h-3.5 text-purple-500" />
JSON Data
</button> </button>
<button onClick={() => handleExport('png')} className="w-full flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-white/5 text-[10px] font-bold text-secondary transition-all"> <button onClick={() => handleExport('png')} className="w-full flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-white/5 text-[10px] font-bold text-secondary transition-all">
<ImageIcon className="w-3.5 h-3.5 text-indigo-500" /> <ImageIcon className="w-3.5 h-3.5 text-indigo-500" />

View file

@ -0,0 +1,61 @@
import { Loader2, Zap } from 'lucide-react';
interface EditorToolbarProps {
handleGenerate: () => void;
isLoading: boolean;
hasCode: boolean;
hasCode: boolean;
}
import { usePluginStore } from '../../store/pluginStore';
export function EditorToolbar({
handleGenerate,
isLoading,
hasCode,
}: EditorToolbarProps) {
const { toolbarItems } = usePluginStore();
return (
<div className="flex items-center justify-between w-full pt-2 px-2">
{/* Center: Insert Tools */}
<div className="flex items-center gap-6 absolute left-1/2 -translate-x-1/2">
{/* Plugin Items */}
{toolbarItems.map((item) => (
<button
key={item.id}
onClick={item.onClick}
disabled={isLoading}
className="flex items-center gap-2 group px-2 py-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-white/5 transition-all disabled:opacity-50"
title={item.tooltip || item.label}
>
<item.icon className="w-4 h-4 text-slate-400 group-hover:text-blue-500 transition-colors" />
<span className="text-xs font-black uppercase tracking-widest text-slate-300 group-hover:text-slate-200 transition-colors">{item.label}</span>
</button>
))}
</div>
{/* Right: Actions */}
<div className="flex items-center">
<button
onClick={handleGenerate}
disabled={!hasCode || isLoading}
className="px-6 py-2.5 rounded-xl bg-gradient-to-r from-[#8b5cf6] to-[#a78bfa] hover:from-[#7c3aed] hover:to-[#8b5cf6] text-white shadow-lg shadow-purple-900/20 transition-all active:scale-[0.98] flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? (
<Loader2 className="w-4 h-4 animate-spin text-white/90" />
) : (
<>
<Zap className="w-4 h-4 fill-white" />
<span className="text-xs font-black uppercase tracking-widest">Visualize</span>
</>
)}
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,107 @@
import Editor, { type OnMount } from '@monaco-editor/react';
import { AlertCircle, Trash2 } from 'lucide-react';
interface MonacoWrapperProps {
code: string;
onChange: (value: string | undefined) => void;
onMount: OnMount;
theme: 'light' | 'dark';
syntaxErrors: { line: number; message: string }[];
setCode: (code: string) => void;
}
export function MonacoWrapper({
code,
onChange,
onMount,
theme,
syntaxErrors,
setCode
}: MonacoWrapperProps) {
// monacoRef removed as it was unused
// Define themes once on mount or when theme prop changes appropriately
// For simplicity, we can do it inside a useEffect here if we have access to monaco instance,
// but usually it's better to do it once.
// However, since we need monaco instance, we'll assume the parent component or this component handles it via onMount/useEffect.
// Actually, let's keep the theme definition inside the component for now or rely on the parent to pass the ref if needed.
// But better yet, let's expose specific theme logic or keep it self-contained if possible.
// The original code defined themes in useEffect when monacoRef was available.
return (
<div className={`flex-1 rounded-2xl overflow-hidden border relative group shadow-inner transition-colors ${theme === 'dark' ? 'bg-[#0B1221] border-white/5' : 'bg-slate-50 border-slate-200'
}`}>
{/* Internal Badges */}
<div className="absolute top-4 left-4 z-10 pointer-events-none">
<span className="text-[10px] font-black uppercase tracking-widest px-3 py-1.5 rounded-lg bg-blue-500/10 text-blue-400 border border-blue-500/20 backdrop-blur-md">
Mermaid
</span>
</div>
<div className="absolute top-4 right-4 z-10">
<span className="text-[10px] font-black uppercase tracking-widest px-3 py-1.5 rounded-lg bg-white/5 text-slate-400 border border-white/10 backdrop-blur-md">
Manual
</span>
</div>
<Editor
height="100%"
defaultLanguage="markdown"
// theme prop is controlled by monaco.editor.setTheme
options={{
minimap: { enabled: false },
fontSize: 13,
lineNumbers: 'on',
padding: { top: 50, bottom: 20 },
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
fontLigatures: true,
renderLineHighlight: 'all',
scrollbar: {
vertical: 'visible',
horizontal: 'visible',
useShadows: false,
verticalSliderSize: 6,
horizontalSliderSize: 6
},
lineHeight: 1.7,
cursorSmoothCaretAnimation: 'on',
smoothScrolling: true,
contextmenu: false,
fixedOverflowWidgets: true,
wordWrap: 'on',
glyphMargin: true,
}}
value={code}
onChange={onChange}
onMount={onMount}
/>
{/* Floating Action Buttons */}
<div className="absolute top-4 right-24 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity z-20">
<div className="flex items-center gap-2">
<button
onClick={() => setCode('')}
className="p-1.5 rounded-lg bg-white/5 border border-white/10 flex items-center justify-center hover:bg-red-500/20 text-slate-400 hover:text-red-400 transition-all backdrop-blur-md shadow-lg"
title="Clear Code"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* Syntax Errors */}
{syntaxErrors.length > 0 && (
<div className="absolute bottom-4 left-4 right-4 p-3 bg-red-500/10 border border-red-500/30 rounded-xl animate-fade-in">
<div className="flex items-start gap-2">
<AlertCircle className="w-4 h-4 text-red-400 shrink-0 mt-0.5" />
<div>
<p className="text-[10px] font-bold text-red-400 uppercase tracking-wider">Syntax Error</p>
<p className="text-[11px] text-red-300 mt-1">{syntaxErrors[0].message}</p>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -6,7 +6,7 @@ import { Database, Cpu, Users, Globe, Server, Zap, Play, Square, GitBranch } fro
* High-contrast, accessible node color palette * High-contrast, accessible node color palette
* Each color has a background, border, and text color for maximum readability * Each color has a background, border, and text color for maximum readability
*/ */
const NODE_STYLES = { export const NODE_STYLES = {
ai: { ai: {
bg: 'bg-violet-500/15 dark:bg-violet-500/20', bg: 'bg-violet-500/15 dark:bg-violet-500/20',
solid: 'bg-violet-200 dark:bg-violet-900', solid: 'bg-violet-200 dark:bg-violet-900',
@ -90,7 +90,7 @@ const NODE_STYLES = {
}, },
}; };
type NodeStyleKey = keyof typeof NODE_STYLES; export type NodeStyleKey = keyof typeof NODE_STYLES;
/** /**
* Determine node style based on label content * Determine node style based on label content
@ -415,6 +415,8 @@ export const EndNode = memo((props: any) => <TerminalNode {...props} />);
export const DecisionNode = memo((props: any) => <DecisionNodeComponent {...props} />); export const DecisionNode = memo((props: any) => <DecisionNodeComponent {...props} />);
export const DatabaseNode = memo((props: any) => <DatabaseNodeComponent {...props} />); export const DatabaseNode = memo((props: any) => <DatabaseNodeComponent {...props} />);
import { ShapeNode } from './ShapeNode';
export const nodeTypes = { export const nodeTypes = {
start: StartNode, start: StartNode,
startNode: StartNode, startNode: StartNode,
@ -427,8 +429,20 @@ export const nodeTypes = {
process: StandardNode, process: StandardNode,
processNode: StandardNode, processNode: StandardNode,
client: StandardNode, client: StandardNode,
// Rich Types (Implicitly styled by StandardNode logic)
ai: StandardNode,
team: StandardNode,
platform: StandardNode,
data: DatabaseNode, // Use Database component for Data type
tech: SystemNode, // Use System component for Tech type
server: SystemNode, server: SystemNode,
system: SystemNode, system: SystemNode,
default: StandardNode, default: StandardNode,
group: GroupNode, group: GroupNode,
// Custom Shape Node
'custom-shape': ShapeNode,
'shape': ShapeNode, // Alias
}; };

View file

@ -0,0 +1,69 @@
import { memo } from 'react';
import { Handle, Position } from '@xyflow/react';
import { NODE_STYLES } from './CustomNodes';
import { SHAPE_PATHS } from '../../lib/shapes';
// Map mermaid types to shape names if needed, or use raw type
// Default fallback
const DEFAULT_SHAPE = 'rect';
export const ShapeNode = memo(({ data, selected, type }: any) => {
const rawShape = (data.shape || 'rect').toLowerCase();
// Normalize shape names
let shapeKey = rawShape;
if (rawShape === 'database') shapeKey = 'cyl';
if (rawShape === 'decision') shapeKey = 'diamond';
if (rawShape === 'process') shapeKey = 'rect';
if (rawShape === 'start') shapeKey = 'stadium';
if (rawShape === 'end') shapeKey = 'stadium';
const pathData = SHAPE_PATHS[shapeKey] || SHAPE_PATHS[DEFAULT_SHAPE];
// Style logic similar to StandardNode
// We can infer style from category or just use default
const category = data.category || 'default';
// Mapping categories to styles in CustomNodes is logic we might want to reuse or replicate
// For now let's use a default blue or check if we can import logic.
// Since we are in a separate file, we might not have access to helper 'getNodeStyle'.
// Let's rely on data.category which mermaidParser sets.
// Map parser categories to NODE_STYLES keys
let styleKey = 'default';
if (category === 'filter-db') styleKey = 'data';
else if (category === 'filter-client') styleKey = 'team'; // or platform
else if (category === 'filter-server') styleKey = 'tech';
// Override based on shape
if (shapeKey === 'cyl') styleKey = 'data';
if (shapeKey === 'diamond') styleKey = 'decision';
if (shapeKey === 'stadium' && (type === 'start' || type === 'end')) styleKey = type;
// @ts-ignore
const themeStyle = NODE_STYLES[styleKey] || NODE_STYLES.default;
return (
<div className={`relative group w-32 h-20 flex items-center justify-center transition-all ${selected ? 'drop-shadow-lg scale-105' : ''}`}>
<svg viewBox="0 0 100 70" className={`w-full h-full overflow-visible drop-shadow-sm`}>
<path
d={pathData}
className={`${themeStyle.solid} ${themeStyle.border} fill-current stroke-2 stroke-current opacity-90`}
style={{ fill: 'var(--bg-color)', stroke: 'var(--border-color)' }}
/>
</svg>
{/* Label Overlay */}
<div className="absolute inset-0 flex items-center justify-center p-2 text-center pointer-events-none">
<span className={`text-[10px] font-bold leading-tight ${themeStyle.text}`}>
{data.label}
</span>
</div>
{/* Handles - Positions might need to be dynamic based on shape, but default Top/Bottom/Left/Right usually works */}
<Handle type="target" position={Position.Top} className="!w-2 !h-2 !bg-transparent" />
<Handle type="source" position={Position.Bottom} className="!w-2 !h-2 !bg-transparent" />
<Handle type="source" position={Position.Right} className="!w-2 !h-2 !bg-transparent" />
<Handle type="source" position={Position.Left} className="!w-2 !h-2 !bg-transparent" />
</div>
);
});

View file

@ -1,5 +1,5 @@
import React from 'react';
export function OrchestratorLoader() { export function OrchestratorLoader() {
return ( return (

View file

@ -4,3 +4,4 @@
export { useAIGeneration } from './useAIGeneration'; export { useAIGeneration } from './useAIGeneration';
export { useKeyboardShortcuts, getShortcutDisplay } from './useKeyboardShortcuts'; export { useKeyboardShortcuts, getShortcutDisplay } from './useKeyboardShortcuts';
export { useMobileDetect, checkIsMobile, MOBILE_BREAKPOINT } from './useMobileDetect';

View file

@ -28,17 +28,17 @@ export function useAIGeneration(): UseAIGenerationReturn {
const { isLoading, error, setLoading, setError } = useUIStore(); const { isLoading, error, setLoading, setError } = useUIStore();
const processAIResponse = useCallback( const processAIResponse = useCallback(
(result: AIResponse) => { async (result: AIResponse) => {
if (!result.success || !result.mermaidCode) { if (!result.success || !result.mermaidCode) {
throw new Error(result.error || 'Failed to generate diagram'); throw new Error(result.error || 'Failed to generate diagram');
} }
setSourceCode(result.mermaidCode); setSourceCode(result.mermaidCode);
const { nodes: parsedNodes, edges: parsedEdges } = parseMermaid(result.mermaidCode); const { nodes: parsedNodes, edges: parsedEdges } = await parseMermaid(result.mermaidCode);
// Attach metadata if available // Attach metadata if available
if (result.metadata) { if (result.metadata) {
parsedNodes.forEach((node) => { parsedNodes.forEach((node: any) => {
const label = (node.data.label as string) || ''; const label = (node.data.label as string) || '';
if (label && result.metadata && result.metadata[label]) { if (label && result.metadata && result.metadata[label]) {
node.data.metadata = result.metadata[label] as NodeMetadata; node.data.metadata = result.metadata[label] as NodeMetadata;

View file

@ -0,0 +1,70 @@
import { useEffect, useRef } from 'react';
import { useFlowStore } from '../store';
import { useSearchParams } from 'react-router-dom';
/**
* useDiagramAPI
*
* Enables programmatic control of the diagram editor via:
* 1. URL Parameters: ?code=<base64_mermaid>
* 2. Window Messages: PostMessage for embedding scenarios
*/
export function useDiagramAPI() {
const [searchParams] = useSearchParams();
const { setSourceCode, sourceCode } = useFlowStore();
// Create a ref for sourceCode to access current value in event listener without re-binding
const sourceCodeRef = useRef(sourceCode);
useEffect(() => {
sourceCodeRef.current = sourceCode;
}, [sourceCode]);
// 1. Handle URL Parameters
useEffect(() => {
const codeParam = searchParams.get('code');
if (codeParam) {
try {
// Decode Base64
const decodedCode = atob(codeParam);
setSourceCode(decodedCode);
// Clear param after consumption to avoid re-triggering or cluttering URL
// causing a reload? No, using router API.
// But better to keep it if user wants to share URL.
// Let's decided to keep it for "stateful URL" behavior for now.
// If we want to support "cleaning", we can uncomment below:
// setSearchParams(prev => {
// const next = new URLSearchParams(prev);
// next.delete('code');
// return next;
// });
} catch (e) {
console.error('Failed to decode diagram code from URL:', e);
}
}
}, [searchParams, setSourceCode]);
// 2. Handle Window Messages (for generic iframe control)
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
// Security check: You might want to check event.origin here in production
const { type, payload } = event.data;
if (type === 'KVGRAPH_LOAD' && payload?.code) {
setSourceCode(payload.code);
}
if (type === 'KVGRAPH_GET_CODE') {
// Reply with current code
event.source?.postMessage({
type: 'KVGRAPH_CODE_RESPONSE',
payload: { code: sourceCodeRef.current }
}, { targetOrigin: event.origin });
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [setSourceCode]);
}

View file

@ -0,0 +1,88 @@
import { useState, useEffect, useCallback } from 'react';
const MOBILE_BREAKPOINT = 768;
interface MobileDetectResult {
isMobile: boolean;
isTouchDevice: boolean;
prefersReducedMotion: boolean;
viewportWidth: number;
viewportHeight: number;
}
/**
* Hook for detecting mobile devices and touch capabilities.
* Provides reactive updates on viewport changes.
*/
export function useMobileDetect(): MobileDetectResult {
const [state, setState] = useState<MobileDetectResult>(() => ({
isMobile: typeof window !== 'undefined' ? window.innerWidth < MOBILE_BREAKPOINT : false,
isTouchDevice: typeof window !== 'undefined' ? 'ontouchstart' in window || navigator.maxTouchPoints > 0 : false,
prefersReducedMotion: typeof window !== 'undefined'
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
: false,
viewportWidth: typeof window !== 'undefined' ? window.innerWidth : 1024,
viewportHeight: typeof window !== 'undefined' ? window.innerHeight : 768,
}));
const handleResize = useCallback(() => {
setState(prev => {
const newIsMobile = window.innerWidth < MOBILE_BREAKPOINT;
// Only update if values changed to prevent unnecessary re-renders
if (
prev.isMobile === newIsMobile &&
prev.viewportWidth === window.innerWidth &&
prev.viewportHeight === window.innerHeight
) {
return prev;
}
return {
...prev,
isMobile: newIsMobile,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
};
});
}, []);
useEffect(() => {
// Use visualViewport for better mobile keyboard handling
const viewport = window.visualViewport;
const handleViewportResize = () => {
setState(prev => ({
...prev,
viewportHeight: viewport?.height || window.innerHeight,
}));
};
window.addEventListener('resize', handleResize);
viewport?.addEventListener('resize', handleViewportResize);
// Check reduced motion preference changes
const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const handleMotionChange = (e: MediaQueryListEvent) => {
setState(prev => ({ ...prev, prefersReducedMotion: e.matches }));
};
motionQuery.addEventListener('change', handleMotionChange);
return () => {
window.removeEventListener('resize', handleResize);
viewport?.removeEventListener('resize', handleViewportResize);
motionQuery.removeEventListener('change', handleMotionChange);
};
}, [handleResize]);
return state;
}
/**
* Utility function for one-time mobile check (non-reactive).
* Use in store initialization or outside React components.
*/
export function checkIsMobile(): boolean {
if (typeof window === 'undefined') return false;
return window.innerWidth < MOBILE_BREAKPOINT || 'ontouchstart' in window;
}
export { MOBILE_BREAKPOINT };

View file

@ -1,7 +1,7 @@
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { useDiagramStore } from '../store'; import { useDiagramStore } from '../store';
import { useSettingsStore } from '../store/settingsStore'; import { useSettingsStore } from '../store/settingsStore';
import { VisualOrganizer, createVisualOrganizer } from '../lib/visualOrganizer'; import { createVisualOrganizer } from '../lib/visualOrganizer';
import { analyzeVisualLayout } from '../lib/aiService'; import { analyzeVisualLayout } from '../lib/aiService';
import type { LayoutSuggestion, VisualIssue, LayoutMetrics } from '../types/visualOrganization'; import type { LayoutSuggestion, VisualIssue, LayoutMetrics } from '../types/visualOrganization';

View file

@ -14,40 +14,13 @@ export interface AIResponse {
import { webLlmService } from './webLlmService'; import { webLlmService } from './webLlmService';
import { visionService } from './visionService'; import { visionService } from './visionService';
import {
const SYSTEM_PROMPT = `You are an expert System Architect AI. Your task is to transform technical descriptions into precise Mermaid.js diagrams and high-fidelity metadata. SYSTEM_PROMPT,
SYSTEM_PROMPT_SIMPLE,
Return ONLY a strictly valid JSON object. No Markdown headers, no preamble. SYSTEM_PROMPT_COMPLEX,
SUGGEST_PROMPT,
EXPECTED JSON FORMAT: VISUAL_ANALYSIS_PROMPT
{ } from './prompts';
"mermaidCode": "flowchart TD\\n A[Client] --> B[API Server]\\n ...",
"metadata": {
"NodeLabel": {
"techStack": ["Technologies used"],
"role": "Specific role (e.g., Load Balancer, Cache, Database)",
"description": "Concise technical responsibility"
}
}
}
ARCHITECTURAL RULES:
1. Use 'flowchart TD' or 'flowchart LR'.
2. Use descriptive but concise ID/Labels (e.g., 'API', 'DB_PROD').
3. Labels must match the keys in the 'metadata' object EXACTLY.
4. If input is already Mermaid code, wrap it in the JSON 'mermaidCode' field and infer metadata.
5. Identify semantic roles: use keywords like 'Client', 'Server', 'Worker', 'Database', 'Queue' in labels.
6. Escape double quotes inside the mermaid string correctly.
DIAGRAM QUALITY RULES:
7. NEVER use HTML tags (like <br/>, <b>, etc.) in node labels. Use short, clean text only.
8. Use DIAMOND shapes {Decision} for review, approval, or decision steps (e.g., A{Approve?}).
9. Use CYLINDER shapes [(Database)] for data stores.
10. Use ROUNDED shapes (Process) for human/manual tasks.
11. For complex workflows, add step numbers as edge labels: A -->|1| B -->|2| C.
12. Include feedback loops where logical (e.g., connect "Collect Feedback" back to analysis nodes).
13. Use subgraphs/swimlanes to group related components by team, role, or domain.
14. Ensure consistent node shapes within each swimlane/subgraph.`;
/** /**
@ -92,20 +65,54 @@ async function callOnlineAI(
if (provider === 'openai') { if (provider === 'openai') {
url = 'https://api.openai.com/v1/chat/completions'; url = 'https://api.openai.com/v1/chat/completions';
headers['Authorization'] = `Bearer ${apiKey}`; headers['Authorization'] = `Bearer ${apiKey}`;
// Transform messages to OpenAI format with image support
const formattedMessages = messages.map(msg => {
if (msg.images && msg.images.length > 0) {
// Vision message with image
return {
role: msg.role,
content: [
{ type: 'text', text: msg.content },
...msg.images.map((img: string) => ({
type: 'image_url',
image_url: { url: `data:image/png;base64,${img}` }
}))
]
};
}
return { role: msg.role, content: msg.content };
});
body = { body = {
model: 'gpt-4o', model: 'gpt-4o',
messages: [{ role: 'system', content: activePrompt }, ...messages], messages: [{ role: 'system', content: activePrompt }, ...formattedMessages],
response_format: { type: 'json_object' } response_format: { type: 'json_object' }
}; };
} else if (provider === 'gemini') { } else if (provider === 'gemini') {
// Simple Gemini API call (v1beta) // Gemini API with vision support - using gemini-2.0-flash-exp for better compatibility
url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=${apiKey}`; url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-exp:generateContent?key=${apiKey}`;
// Build parts array - text first, then images
const parts: any[] = [
{ text: `${activePrompt}\n\nTask: ${messages[messages.length - 1].content}` }
];
// Add images if present
const lastMsg = messages[messages.length - 1];
if (lastMsg.images && lastMsg.images.length > 0) {
lastMsg.images.forEach((imageBase64: string) => {
parts.push({
inline_data: {
mime_type: 'image/png',
data: imageBase64
}
});
});
}
body = { body = {
contents: [{ contents: [{ parts }],
parts: [{
text: `${activePrompt}\n\nTask: ${messages[messages.length - 1].content}`
}]
}],
generationConfig: { responseMimeType: 'application/json' } generationConfig: { responseMimeType: 'application/json' }
}; };
} else if (provider === 'ollama-cloud') { } else if (provider === 'ollama-cloud') {
@ -127,7 +134,14 @@ async function callOnlineAI(
if (!response.ok) { if (!response.ok) {
let errorMsg = `${provider} error: ${response.status} ${response.statusText}`; let errorMsg = `${provider} error: ${response.status} ${response.statusText}`;
if (response.status === 401) errorMsg = 'Invalid API Key. Please check your settings.'; if (response.status === 401) errorMsg = 'Invalid API Key. Please check your settings.';
if (response.status === 404) errorMsg = `API endpoint not found. For ${provider}, please verify your API key is valid.`;
if (response.status === 429) errorMsg = 'Rate limit exceeded. Please try again later.'; if (response.status === 429) errorMsg = 'Rate limit exceeded. Please try again later.';
if (response.status === 400) {
try {
const errorData = await response.json();
errorMsg = errorData.error?.message || errorMsg;
} catch { /* ignore parse error */ }
}
throw new Error(errorMsg); throw new Error(errorMsg);
} }
@ -292,22 +306,6 @@ async function callBrowserAI(
} }
} }
const SYSTEM_PROMPT_SIMPLE = `You are a System Architect. Create a SIMPLE, high-level Mermaid diagram.
- Focus on the main data flow (max 5-7 nodes).
- Use simple labels (e.g., "User", "API", "DB"). NEVER use HTML tags like <br/>.
- Use diamond {Decision?} for approval/review steps.
- Minimal metadata (role only).`;
const SYSTEM_PROMPT_COMPLEX = `You are an Expert Solution Architect. Create a DETAILED, comprehensive Mermaid diagram.
- Include all subsystems, queues, workers, and external services.
- Use swimlanes (subgraphs) to group components by role/domain.
- NEVER use HTML tags like <br/> in labels. Keep labels clean and concise.
- Use diamond {Decision?} for approval/review steps.
- Use cylinder [(DB)] for databases, rounded (Task) for human tasks.
- Add step numbers as edge labels for complex flows: A -->|1| B -->|2| C.
- Include feedback loops connecting outputs back to inputs where logical.
- Detailed metadata (techStack, role, description).`;
function getSystemPrompt(complexity: 'simple' | 'complex') { function getSystemPrompt(complexity: 'simple' | 'complex') {
return complexity === 'simple' ? SYSTEM_PROMPT_SIMPLE : SYSTEM_PROMPT_COMPLEX; return complexity === 'simple' ? SYSTEM_PROMPT_SIMPLE : SYSTEM_PROMPT_COMPLEX;
} }
@ -386,74 +384,6 @@ export async function analyzeSVG(
return callLocalAI(ollamaUrl, model, messages); return callLocalAI(ollamaUrl, model, messages);
} }
const SUGGEST_PROMPT = `You are a Mermaid.js syntax and logic expert.
Your task is to analyze the provided Mermaid flowchart code and either:
1. Fix any syntax errors that prevent it from rendering.
2. Improve the logical flow or visual clarity if the syntax is already correct.
Return ONLY a strictly valid JSON object. No Markdown headers, no preamble.
EXPECTED JSON FORMAT:
{
"mermaidCode": "flowchart TD\\n A[Fixed/Improved] --> B[Nodes]",
"explanation": "Briefly explain what was fixed or improved."
}
RULES:
- Maintain the original intent of the diagram.
- Use best practices for Mermaid layout and labeling.
- If the code is already perfect, return it as is but provide a positive explanation.`;
const VISUAL_ANALYSIS_PROMPT = `You are a Visualization and UX Expert specialized in node-graph diagrams.
Your task is to analyze the provided graph structure and metrics to suggest specific improvements for layout, grouping, and visual clarity.
Return ONLY a strictly valid JSON object. Do not include markdown formatting like \`\`\`json.
EXPECTED JSON FORMAT:
{
"analysis": {
"suggestions": [
{
"id": "unique-id",
"title": "Short title",
"description": "Detailed explanation",
"type": "spacing" | "grouping" | "routing" | "hierarchy" | "style",
"impact": "high" | "medium" | "low",
"fix_strategy": "algorithmic_spacing" | "algorithmic_routing" | "group_nodes" | "unknown"
}
],
"summary": {
"critique": "Overall analysis",
"score": 0-100
}
}
}
EXAMPLE RESPONSE:
{
"analysis": {
"suggestions": [
{
"id": "sug-1",
"title": "Group Database Nodes",
"description": "Several database nodes are scattered. Group them for better logical separation.",
"type": "grouping",
"impact": "high",
"fix_strategy": "group_nodes"
}
],
"summary": {
"critique": "The flow is generally good but lacks logical grouping for backend services.",
"score": 75
}
}
}
RULES:
- Focus on readability, flow, and logical grouping.
- Identify if nodes are too cluttered or if the flow is confusing.
- Suggest grouping for nodes that appear related based on their labels.`;
export async function suggestFix( export async function suggestFix(
code: string, code: string,
ollamaUrl: string, ollamaUrl: string,
@ -541,3 +471,49 @@ export async function analyzeVisualLayout(
}; };
} }
} }
/**
* Fix and Enhance Mermaid Diagram
* Adds metadata, fixes syntax, and groups nodes
*/
export async function fixDiagram(code: string, apiKey: string, errorMessage?: string): Promise<string> {
const prompt = `
You are an expert Mermaid.js Diagram Engineer.
YOUR TASK:
Fix and Enhance the following Mermaid code.
RULES:
1. Fix any syntax errors.
2. Ensure all nodes are semantically grouped into subgraphs (e.g. "subgraph Client", "subgraph Server", "subgraph Database") if possible.
3. IMPORTANT: Generates JSON metadata for each node in comments.
Format: %% { "id": "NODE_ID", "metadata": { "role": "Specific Role", "techStack": ["Tech1", "Tech2"], "description": "Brief description" } }
4. Return ONLY the mermaid code. No markdown fences.
CODE TO FIX:
${code}
${errorMessage ? `ERROR TO FIX:\n${errorMessage}` : ''}
`;
try {
const response = await fetch('https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=' + apiKey, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: [{ text: prompt }] }]
})
});
const data = await response.json();
let result = data.candidates?.[0]?.content?.parts?.[0]?.text || code;
// Clean result
result = result.replace(/^```(?:mermaid)?/gm, '').replace(/```$/gm, '').trim();
return result;
} catch (error) {
console.error('AI Fix Failed:', error);
return code;
}
}

View file

@ -1,4 +1,4 @@
import { toPng } from 'html-to-image'; import { toPng, toJpeg, toSvg } from 'html-to-image';
import { type Node, type Edge } from '../store'; import { type Node, type Edge } from '../store';
export async function exportToPng(element: HTMLElement): Promise<void> { export async function exportToPng(element: HTMLElement): Promise<void> {
@ -24,7 +24,7 @@ export async function exportToPng(element: HTMLElement): Promise<void> {
export async function exportToJpg(element: HTMLElement): Promise<void> { export async function exportToJpg(element: HTMLElement): Promise<void> {
try { try {
const { toJpeg } = await import('html-to-image');
const dataUrl = await toJpeg(element, { const dataUrl = await toJpeg(element, {
backgroundColor: '#020617', backgroundColor: '#020617',
quality: 0.95, quality: 0.95,
@ -44,23 +44,23 @@ export async function exportToJpg(element: HTMLElement): Promise<void> {
} }
} }
export function exportToSvg(nodes: Node[], _edges: Edge[]): void { export async function exportToSvg(element: HTMLElement): Promise<void> {
// Basic SVG export logic (simplified for React Flow) try {
const svgContent = ` const dataUrl = await toSvg(element, {
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="800" viewBox="0 0 1200 800"> backgroundColor: '#020617',
<rect width="100%" height="100%" fill="#020617" /> filter: (node) => {
<text x="20" y="40" fill="white" font-family="sans-serif" font-size="16">Architecture Diagram Export (SVG)</text> const className = node.className?.toString() || '';
<!-- Simplified representation --> return !className.includes('react-flow__controls') &&
<g transform="translate(50,100)"> !className.includes('react-flow__minimap') &&
${nodes.map(n => `<rect x="${n.position.x}" y="${n.position.y}" width="150" height="60" rx="8" fill="#1e293b" stroke="#3b82f6" stroke-width="2" />`).join('')} !className.includes('react-flow__panel');
${nodes.map(n => `<text x="${n.position.x + 75}" y="${n.position.y + 35}" fill="white" font-size="12" text-anchor="middle" font-family="sans-serif">${(n.data as any).label || n.id}</text>`).join('')} }
</g> });
</svg>
`; downloadFile(dataUrl, `diagram-${getTimestamp()}.svg`);
const blob = new Blob([svgContent], { type: 'image/svg+xml' }); } catch (error) {
const url = URL.createObjectURL(blob); console.error('Failed to export SVG:', error);
downloadFile(url, `diagram-${getTimestamp()}.svg`); throw error;
URL.revokeObjectURL(url); }
} }
export function exportToTxt(nodes: Node[], edges: Edge[]): void { export function exportToTxt(nodes: Node[], edges: Edge[]): void {

View file

@ -1,22 +1,28 @@
import dagre from 'dagre'; import dagre from 'dagre';
import { type Node, type Edge } from '../store'; import { type Node, type Edge } from '../store';
const nodeWidth = 180; // Enhanced constants for better spacing
const nodeHeight = 60; const NODE_WIDTH = 180;
const groupPadding = 40; const NODE_HEIGHT = 60;
const groupTitleHeight = 50; const GROUP_PADDING = 60; // Increased padding
const groupGap = 60; // Gap between swimlane groups const GROUP_TITLE_HEIGHT = 50;
const GROUP_GAP = 120; // Increased gap between groups
const MIN_NODE_SPACING = 80; // Minimum space between nodes
export interface LayoutOptions { export interface LayoutOptions {
direction: 'TB' | 'LR' | 'BT' | 'RL'; direction: 'TB' | 'LR' | 'BT' | 'RL';
nodeSpacing: number; nodeSpacing: number;
rankSpacing: number; rankSpacing: number;
smartOverlapResolution?: boolean; // Enable collision detection
optimizeForReadability?: boolean; // Prioritize clear flow
} }
const defaultOptions: LayoutOptions = { const defaultOptions: LayoutOptions = {
direction: 'TB', direction: 'TB',
nodeSpacing: 40, nodeSpacing: 100, // Increased from 60 to prevent overlap
rankSpacing: 60, rankSpacing: 150, // Increased from 80 for edge labels
smartOverlapResolution: true,
optimizeForReadability: true,
}; };
export function getLayoutedElements( export function getLayoutedElements(
@ -85,7 +91,7 @@ export function getLayoutedElements(
childNodes.forEach(child => finalNodes.push(child)); childNodes.forEach(child => finalNodes.push(child));
// Move Y down for next group // Move Y down for next group
currentY += height + groupGap; currentY += height + GROUP_GAP;
}); });
// Layout orphan nodes (nodes without parent) to the right of groups // Layout orphan nodes (nodes without parent) to the right of groups
@ -130,8 +136,8 @@ function layoutGroupInternal(
// Add nodes // Add nodes
childNodes.forEach(node => { childNodes.forEach(node => {
const w = node.type === 'decision' ? 140 : nodeWidth; const w = node.type === 'decision' ? 140 : NODE_WIDTH;
const h = node.type === 'decision' ? 90 : nodeHeight; const h = node.type === 'decision' ? 90 : NODE_HEIGHT;
subGraph.setNode(node.id, { width: w, height: h }); subGraph.setNode(node.id, { width: w, height: h });
}); });
@ -154,8 +160,8 @@ function layoutGroupInternal(
const pos = subGraph.node(node.id); const pos = subGraph.node(node.id);
if (!pos) return; if (!pos) return;
const w = node.type === 'decision' ? 140 : nodeWidth; const w = node.type === 'decision' ? 140 : NODE_WIDTH;
const h = node.type === 'decision' ? 90 : nodeHeight; const h = node.type === 'decision' ? 90 : NODE_HEIGHT;
const x = pos.x - w / 2; const x = pos.x - w / 2;
const y = pos.y - h / 2; const y = pos.y - h / 2;
@ -175,14 +181,14 @@ function layoutGroupInternal(
// Normalize positions to start at padding // Normalize positions to start at padding
positionedChildren.forEach(child => { positionedChildren.forEach(child => {
child.position.x = child.position.x - minX + groupPadding; child.position.x = child.position.x - minX + GROUP_PADDING;
child.position.y = child.position.y - minY + groupPadding + groupTitleHeight; child.position.y = child.position.y - minY + GROUP_PADDING + GROUP_TITLE_HEIGHT;
}); });
const contentWidth = maxX - minX; const contentWidth = maxX - minX;
const contentHeight = maxY - minY; const contentHeight = maxY - minY;
const groupWidth = contentWidth + groupPadding * 2; const groupWidth = contentWidth + GROUP_PADDING * 2;
const groupHeight = contentHeight + groupPadding * 2 + groupTitleHeight; const groupHeight = contentHeight + GROUP_PADDING * 2 + GROUP_TITLE_HEIGHT;
return { return {
width: Math.max(groupWidth, 300), width: Math.max(groupWidth, 300),
@ -211,8 +217,8 @@ function layoutOrphanNodes(
}); });
nodes.forEach(node => { nodes.forEach(node => {
const w = node.type === 'decision' ? 140 : nodeWidth; const w = node.type === 'decision' ? 140 : NODE_WIDTH;
const h = node.type === 'decision' ? 90 : nodeHeight; const h = node.type === 'decision' ? 90 : NODE_HEIGHT;
orphanGraph.setNode(node.id, { width: w, height: h }); orphanGraph.setNode(node.id, { width: w, height: h });
}); });
@ -231,8 +237,8 @@ function layoutOrphanNodes(
const pos = orphanGraph.node(node.id); const pos = orphanGraph.node(node.id);
if (!pos) return node; if (!pos) return node;
const w = node.type === 'decision' ? 140 : nodeWidth; const w = node.type === 'decision' ? 140 : NODE_WIDTH;
const h = node.type === 'decision' ? 90 : nodeHeight; const h = node.type === 'decision' ? 90 : NODE_HEIGHT;
return { return {
...node, ...node,
@ -261,8 +267,8 @@ function layoutFlatNodes(
}); });
nodes.forEach(node => { nodes.forEach(node => {
const w = node.type === 'decision' ? 140 : nodeWidth; const w = node.type === 'decision' ? 140 : NODE_WIDTH;
const h = node.type === 'decision' ? 90 : nodeHeight; const h = node.type === 'decision' ? 90 : NODE_HEIGHT;
flatGraph.setNode(node.id, { width: w, height: h }); flatGraph.setNode(node.id, { width: w, height: h });
}); });
@ -278,8 +284,8 @@ function layoutFlatNodes(
const pos = flatGraph.node(node.id); const pos = flatGraph.node(node.id);
if (!pos) return node; if (!pos) return node;
const w = node.type === 'decision' ? 140 : nodeWidth; const w = node.type === 'decision' ? 140 : NODE_WIDTH;
const h = node.type === 'decision' ? 90 : nodeHeight; const h = node.type === 'decision' ? 90 : NODE_HEIGHT;
return { return {
...node, ...node,
@ -289,5 +295,130 @@ function layoutFlatNodes(
} as Node; } as Node;
}); });
return { nodes: layoutedNodes, edges }; // Apply smart overlap resolution if enabled
const resolvedNodes = opts.smartOverlapResolution
? resolveOverlaps(layoutedNodes)
: layoutedNodes;
return { nodes: resolvedNodes, edges };
} }
/**
* Smart collision resolution - iteratively pushes overlapping nodes apart
* Uses a force-directed approach with multiple passes for stability
*/
function resolveOverlaps(nodes: Node[], maxIterations: number = 50): Node[] {
const mutableNodes = nodes.map(n => ({
...n,
position: { ...n.position },
width: getNodeWidth(n),
height: getNodeHeight(n)
}));
for (let iteration = 0; iteration < maxIterations; iteration++) {
let hasOverlap = false;
for (let i = 0; i < mutableNodes.length; i++) {
for (let j = i + 1; j < mutableNodes.length; j++) {
const nodeA = mutableNodes[i];
const nodeB = mutableNodes[j];
// Skip group nodes
if (nodeA.type === 'group' || nodeB.type === 'group') continue;
// Check for overlap with padding
const overlapX = getOverlap(
nodeA.position.x, nodeA.width,
nodeB.position.x, nodeB.width,
MIN_NODE_SPACING
);
const overlapY = getOverlap(
nodeA.position.y, nodeA.height,
nodeB.position.y, nodeB.height,
MIN_NODE_SPACING
);
// If nodes overlap in both axes, push them apart
if (overlapX > 0 && overlapY > 0) {
hasOverlap = true;
// Determine which axis needs less push (more efficient separation)
if (overlapX < overlapY) {
// Push horizontally
const pushX = overlapX / 2 + 5;
if (nodeA.position.x < nodeB.position.x) {
nodeA.position.x -= pushX;
nodeB.position.x += pushX;
} else {
nodeA.position.x += pushX;
nodeB.position.x -= pushX;
}
} else {
// Push vertically
const pushY = overlapY / 2 + 5;
if (nodeA.position.y < nodeB.position.y) {
nodeA.position.y -= pushY;
nodeB.position.y += pushY;
} else {
nodeA.position.y += pushY;
nodeB.position.y -= pushY;
}
}
}
}
}
// If no overlaps detected, we're done
if (!hasOverlap) break;
}
// Ensure no negative positions (shift everything if needed)
let minX = Infinity, minY = Infinity;
mutableNodes.forEach(n => {
if (n.type !== 'group') {
minX = Math.min(minX, n.position.x);
minY = Math.min(minY, n.position.y);
}
});
const offsetX = minX < 60 ? 60 - minX : 0;
const offsetY = minY < 60 ? 60 - minY : 0;
return mutableNodes.map(n => ({
...n,
position: {
x: n.position.x + offsetX,
y: n.position.y + offsetY
}
}));
}
/**
* Calculate overlap between two rectangles with padding
*/
function getOverlap(pos1: number, size1: number, pos2: number, size2: number, padding: number): number {
const end1 = pos1 + size1 + padding;
const end2 = pos2 + size2 + padding;
return Math.min(end1 - pos2, end2 - pos1);
}
/**
* Get node width based on type
*/
function getNodeWidth(node: Node): number {
if (node.type === 'decision') return 140;
if (node.style?.width && typeof node.style.width === 'number') return node.style.width;
return NODE_WIDTH;
}
/**
* Get node height based on type
*/
function getNodeHeight(node: Node): number {
if (node.type === 'decision') return 90;
if (node.style?.height && typeof node.style.height === 'number') return node.style.height;
return NODE_HEIGHT;
}

View file

@ -11,7 +11,7 @@ mermaid.initialize({
interface ParsedNode { interface ParsedNode {
id: string; id: string;
label: string; label: string;
type: 'start' | 'end' | 'default' | 'decision' | 'process' | 'database' | 'group' | 'client' | 'server'; type: 'start' | 'end' | 'default' | 'decision' | 'process' | 'database' | 'group' | 'client' | 'server' | 'ai' | 'team' | 'platform' | 'data' | 'tech' | 'custom-shape';
parentId?: string; parentId?: string;
} }
@ -35,11 +35,45 @@ function isDecisionLabel(label: string): boolean {
return decisionKeywords.some(keyword => lowerLabel.includes(keyword)); return decisionKeywords.some(keyword => lowerLabel.includes(keyword));
} }
/**
* Parse metadata from Mermaid comments
* Format: %% { "id": "nodeId", "metadata": { ... } }
*/
function parseMetadataComments(code: string): Map<string, any> {
const metadataMap = new Map<string, any>();
// Regex matches lines starting with %% followed by JSON object
const lines = code.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('%%')) {
try {
// Extract potential JSON
const jsonStr = trimmed.substring(2).trim();
// Basic check if it looks like JSON object
if (jsonStr.startsWith('{') && jsonStr.endsWith('}')) {
const data = JSON.parse(jsonStr);
if (data.id && data.metadata) {
metadataMap.set(data.id, data.metadata);
}
}
} catch (e) {
// Ignore incomplete/invalid JSON in comments
}
}
}
return metadataMap;
}
/** /**
* Preprocess mermaid code to handle common issues * Preprocess mermaid code to handle common issues
*/ */
function preprocessMermaidCode(code: string): string { function preprocessMermaidCode(code: string): string {
return code let cleaned = code
// Strip markdown code fence wrappers (```mermaid ... ```)
.replace(/^```(?:mermaid)?\s*\n?/im, '')
.replace(/\n?```\s*$/im, '')
// Remove %%{init:...}%% directives that may cause issues // Remove %%{init:...}%% directives that may cause issues
.replace(/%%\{init:[^}]*\}%%/g, '') .replace(/%%\{init:[^}]*\}%%/g, '')
// Convert <br/>, <br>, <br /> to spaces in node labels // Convert <br/>, <br>, <br /> to spaces in node labels
@ -48,6 +82,8 @@ function preprocessMermaidCode(code: string): string {
.replace(/\r\n/g, '\n') .replace(/\r\n/g, '\n')
// Remove empty lines at start // Remove empty lines at start
.trim(); .trim();
return cleaned;
} }
export async function parseMermaid(mermaidCode: string): Promise<{ nodes: Node[]; edges: Edge[] }> { export async function parseMermaid(mermaidCode: string): Promise<{ nodes: Node[]; edges: Edge[] }> {
@ -80,18 +116,33 @@ export async function parseMermaid(mermaidCode: string): Promise<{ nodes: Node[]
// Create Group Nodes first // Create Group Nodes first
for (const sub of subgraphs) { for (const sub of subgraphs) {
const groupTitle = (sub.title || sub.id).toLowerCase();
let category = 'filter-other';
if (groupTitle.includes('client') || groupTitle.includes('frontend') || groupTitle.includes('ui') || groupTitle.includes('mobile') || groupTitle.includes('web')) {
category = 'filter-client';
} else if (groupTitle.includes('server') || groupTitle.includes('backend') || groupTitle.includes('api') || groupTitle.includes('service') || groupTitle.includes('auth') || groupTitle.includes('handler') || groupTitle.includes('worker')) {
category = 'filter-server';
} else if (groupTitle.includes('database') || groupTitle.includes('db') || groupTitle.includes('store') || groupTitle.includes('cache') || groupTitle.includes('redis')) {
category = 'filter-db';
}
nodes.push({ nodes.push({
id: sub.id, id: sub.id,
type: 'group', type: 'group',
position: { x: 0, y: 0 }, position: { x: 0, y: 0 },
data: { data: {
label: sub.title, label: sub.title,
color: groupColors[groupIndex++ % groupColors.length] color: groupColors[groupIndex++ % groupColors.length],
category
}, },
style: {}, style: {},
}); });
} }
// Match metadata to nodes
const metadataMap = parseMetadataComments(cleanedCode);
// Process Nodes and Groups logic combined // Process Nodes and Groups logic combined
for (const [id, vertex] of Object.entries(vertices) as any[]) { for (const [id, vertex] of Object.entries(vertices) as any[]) {
// vertex: { id, text, type, styles, classes, ... } // vertex: { id, text, type, styles, classes, ... }
@ -124,16 +175,77 @@ export async function parseMermaid(mermaidCode: string): Promise<{ nodes: Node[]
} }
} }
// Determine category for filtering // Determine category for filtering - Enhanced Logic
let category = 'filter-server'; let category = 'filter-other'; // Default to other/flow
if (type === 'database') category = 'filter-db';
else if (type === 'client') category = 'filter-client'; // 1. Try to infer from Parent Subgraph Title/ID first (High Priority)
if (parentId) {
const parentGroup = nodes.find(n => n.id === parentId);
if (parentGroup) {
// Check both label and ID for clues
const groupLabel = (parentGroup.data?.label as string || '').toLowerCase();
const groupTitle = groupLabel || parentGroup.id.toLowerCase();
if (groupTitle.includes('client') || groupTitle.includes('frontend') || groupTitle.includes('ui') || groupTitle.includes('mobile') || groupTitle.includes('web')) {
category = 'filter-client';
// Also upgrade node type if generic
if (type === 'default') type = 'client';
} else if (groupTitle.includes('server') || groupTitle.includes('backend') || groupTitle.includes('api') || groupTitle.includes('service') || groupTitle.includes('auth') || groupTitle.includes('handler') || groupTitle.includes('worker')) {
category = 'filter-server';
if (type === 'default') type = 'server';
} else if (groupTitle.includes('database') || groupTitle.includes('db') || groupTitle.includes('store') || groupTitle.includes('cache') || groupTitle.includes('redis')) {
category = 'filter-db';
if (type === 'default' || type === 'database') type = 'database';
}
}
}
// 2. If no parent group match (or no parent), use Node Type/Label
if (category === 'filter-other') {
if (type === 'database') category = 'filter-db';
else if (type === 'client') category = 'filter-client';
else if (type === 'server') category = 'filter-server';
// Heuristics from label if still default
else if (label.toLowerCase().includes('gateway')) {
category = 'filter-server';
if (type === 'default') type = 'server';
}
}
// 3. Final Fallback: If type is still default but category is specific, sync them
if (type === 'default') {
if (category === 'filter-server') type = 'server';
else if (category === 'filter-client') type = 'client';
else if (category === 'filter-db') type = 'database';
// Check for specialized types based on label keywords (matching CustomNodes.tsx logic)
const l = label.toLowerCase();
if (l.includes('ai') || l.includes('director') || l.includes('agent') || l.includes('generate')) type = 'ai';
else if (l.includes('team') || l.includes('human') || l.includes('review')) type = 'team';
else if (l.includes('platform') || l.includes('youtube') || l.includes('tiktok') || l.includes('shop')) type = 'platform';
else if (l.includes('data') || l.includes('analytics') || l.includes('metric')) type = 'data';
else if (l.includes('tech') || l.includes('infra')) type = 'tech';
// Check if it's a specific shape supported by ShapeNode
const supportedShapes = ['rect', 'rounded', 'stadium', 'subroutine', 'cyl', 'cylinder', 'diamond', 'rhombus', 'hexagon', 'parallelogram', 'trapezoid', 'doc', 'document', 'cloud', 'circle', 'doublecircle'];
if (supportedShapes.includes(vertex.type || '')) {
type = 'custom-shape';
}
}
// Merge metadata if available
const nodeMetadata = metadataMap.get(id);
nodes.push({ nodes.push({
id: id, id: id,
type: type, type: type, // We might want to use a generic 'custom-shape' type for explicit shapes later, but for now we keep the semantic type inference or fallback
position: { x: 0, y: 0 }, position: { x: 0, y: 0 },
data: { label, category }, // Use sanitized label data: {
label,
category,
shape: vertex.type, // Pass the raw mermaid shape type
metadata: nodeMetadata // Attach parsed metadata
},
parentId: parentId, parentId: parentId,
extent: parentId ? 'parent' : undefined extent: parentId ? 'parent' : undefined
}); });
@ -149,6 +261,7 @@ export async function parseMermaid(mermaidCode: string): Promise<{ nodes: Node[]
animated: e.stroke === 'dotted', // Heuristic animated: e.stroke === 'dotted', // Heuristic
style: { style: {
strokeWidth: 2, strokeWidth: 2,
strokeOpacity: 0.5,
strokeDasharray: e.stroke === 'dotted' ? '5,5' : undefined strokeDasharray: e.stroke === 'dotted' ? '5,5' : undefined
}, },
labelStyle: { fill: '#374151', fontWeight: 600, fontSize: 11 }, labelStyle: { fill: '#374151', fontWeight: 600, fontSize: 11 },
@ -326,7 +439,10 @@ function parseMermaidRegex(mermaidCode: string): { nodes: Node[]; edges: Edge[]
target: e.target, target: e.target,
label: e.label, label: e.label,
animated: e.dotted, animated: e.dotted,
style: e.dotted ? { strokeDasharray: '5,5' } : undefined style: {
strokeDasharray: e.dotted ? '5,5' : undefined,
strokeOpacity: 0.5
}
})); }));
console.log(`[MermaidParser] Regex fallback: ${nodes.length} nodes, ${edges.length} edges`); console.log(`[MermaidParser] Regex fallback: ${nodes.length} nodes, ${edges.length} edges`);

118
src/lib/prompts.ts Normal file
View file

@ -0,0 +1,118 @@
export const SYSTEM_PROMPT = `You are an expert System Architect AI. Your task is to transform technical descriptions into precise Mermaid.js diagrams and high-fidelity metadata.
Return ONLY a strictly valid JSON object. No Markdown headers, no preamble.
EXPECTED JSON FORMAT:
{
"mermaidCode": "flowchart TD\\n A[Client] --> B[API Server]\\n ...",
"metadata": {
"NodeLabel": {
"techStack": ["Technologies used"],
"role": "Specific role (e.g., Load Balancer, Cache, Database)",
"description": "Concise technical responsibility"
}
}
}
ARCHITECTURAL RULES:
1. Use 'flowchart TD' or 'flowchart LR'.
2. Use descriptive but concise ID/Labels (e.g., 'API', 'DB_PROD').
3. Labels must match the keys in the 'metadata' object EXACTLY.
4. If input is already Mermaid code, wrap it in the JSON 'mermaidCode' field and infer metadata.
5. Identify semantic roles: use keywords like 'Client', 'Server', 'Worker', 'Database', 'Queue' in labels.
6. Escape double quotes inside the mermaid string correctly.
DIAGRAM QUALITY RULES:
7. NEVER use HTML tags (like <br/>, <b>, etc.) in node labels. Use short, clean text only.
8. NEVER use pipe characters (|) inside node labels - they break parsing. Use commas or spaces instead.
9. Use DIAMOND shapes {Decision} for review, approval, or decision steps (e.g., A{Approve?}).
10. Use CYLINDER shapes [(Database)] for data stores.
11. Use ROUNDED shapes (Process) for human/manual tasks.
12. For complex workflows, add step numbers as edge labels: A -->|1| B -->|2| C.
13. Include feedback loops where logical (e.g., connect "Collect Feedback" back to analysis nodes).
14. Use subgraphs/swimlanes to group related components by team, role, or domain.
15. Ensure consistent node shapes within each swimlane/subgraph.`;
export const SYSTEM_PROMPT_SIMPLE = `You are a System Architect. Create a SIMPLE, high-level Mermaid diagram.
- Focus on the main data flow (max 5-7 nodes).
- Use simple labels (e.g., "User", "API", "DB"). NEVER use HTML tags like <br/>.
- Use diamond {Decision?} for approval/review steps.
- Minimal metadata (role only).`;
export const SYSTEM_PROMPT_COMPLEX = `You are an Expert Solution Architect. Create a DETAILED, comprehensive Mermaid diagram.
- Include all subsystems, queues, workers, and external services.
- Use swimlanes (subgraphs) to group components by role/domain.
- NEVER use HTML tags like <br/> in labels. Keep labels clean and concise.
- Use diamond {Decision?} for approval/review steps.
- Use cylinder [(DB)] for databases, rounded (Task) for human tasks.
- Add step numbers as edge labels for complex flows: A -->|1| B -->|2| C.
- Include feedback loops connecting outputs back to inputs where logical.
- Detailed metadata (techStack, role, description).`;
export const SUGGEST_PROMPT = `You are a Mermaid.js syntax and logic expert.
Your task is to analyze the provided Mermaid flowchart code and either:
1. Fix any syntax errors that prevent it from rendering.
2. Improve the logical flow or visual clarity if the syntax is already correct.
Return ONLY a strictly valid JSON object. No Markdown headers, no preamble.
EXPECTED JSON FORMAT:
{
"mermaidCode": "flowchart TD\\n A[Fixed/Improved] --> B[Nodes]",
"explanation": "Briefly explain what was fixed or improved."
}
RULES:
- Maintain the original intent of the diagram.
- Use best practices for Mermaid layout and labeling.
- If the code is already perfect, return it as is but provide a positive explanation.`;
export const VISUAL_ANALYSIS_PROMPT = `You are a Visualization and UX Expert specialized in node-graph diagrams.
Your task is to analyze the provided graph structure and metrics to suggest specific improvements for layout, grouping, and visual clarity.
Return ONLY a strictly valid JSON object. Do not include markdown formatting like \`\`\`json.
EXPECTED JSON FORMAT:
{
"analysis": {
"suggestions": [
{
"id": "unique-id",
"title": "Short title",
"description": "Detailed explanation",
"type": "spacing" | "grouping" | "routing" | "hierarchy" | "style",
"impact": "high" | "medium" | "low",
"fix_strategy": "algorithmic_spacing" | "algorithmic_routing" | "group_nodes" | "unknown"
}
],
"summary": {
"critique": "Overall analysis",
"score": 0-100
}
}
}
EXAMPLE RESPONSE:
{
"analysis": {
"suggestions": [
{
"id": "sug-1",
"title": "Group Database Nodes",
"description": "Several database nodes are scattered. Group them for better logical separation.",
"type": "grouping",
"impact": "high",
"fix_strategy": "group_nodes"
}
],
"summary": {
"critique": "The flow is generally good but lacks logical grouping for backend services.",
"score": 75
}
}
}
RULES:
- Focus on readability, flow, and logical grouping.
- Identify if nodes are too cluttered or if the flow is confusing.
- Suggest grouping for nodes that appear related based on their labels.`;

61
src/lib/shapes.ts Normal file
View file

@ -0,0 +1,61 @@
// SVG Paths for Mermaid Shapes
// Based on Mermaid v11.3.0+ syntax
// Syntax: node@{ shape: shapeName }
export interface ShapeDefinition {
id: string;
label: string;
path: string;
category: 'basic' | 'logic' | 'data' | 'other';
}
export const SHAPE_PATHS: Record<string, string> = {
// Basic
rect: 'M0 4 h100 v60 h-100 z',
rounded: 'M10 4 h80 a10 10 0 0 1 10 10 v40 a10 10 0 0 1 -10 10 h-80 a10 10 0 0 1 -10 -10 v-40 a10 10 0 0 1 10 -10 z',
stadium: 'M20 4 h60 a20 20 0 0 1 20 20 v20 a20 20 0 0 1 -20 20 h-60 a20 20 0 0 1 -20 -20 v-20 a20 20 0 0 1 20 -20 z',
subroutine: 'M10 4 h80 v60 h-80 z M20 4 v60 M80 4 v60',
cyl: 'M0 14 a50 10 0 0 1 100 0 v40 a50 10 0 0 1 -100 0 z M0 14 a50 10 0 0 1 100 0',
cylinder: 'M0 14 a50 10 0 0 1 100 0 v40 a50 10 0 0 1 -100 0 z M0 14 a50 10 0 0 1 100 0',
// Logic
diamond: 'M50 0 L100 50 L50 100 L0 50 Z',
rhombus: 'M50 0 L100 50 L50 100 L0 50 Z',
decision: 'M50 0 L100 50 L50 100 L0 50 Z',
hexagon: 'M15 4 L85 4 L100 34 L85 64 L15 64 L0 34 Z',
parallelogram: 'M15 4 h85 l-15 60 h-85 z',
trapezoid: 'M15 4 h70 l15 60 h-100 z',
// Documents/Data
doc: 'M0 0 h80 l20 20 v80 h-100 z M80 0 v20 h20',
document: 'M10 4 h80 v45 q0 15 -15 15 h-60 q-15 0 -15 -15 v-45 z M10 4 h80 v45 q-10 10 -40 10 q -30 0 -40 -10',
// Cloud (Approximation)
cloud: 'M20 50 a20 20 0 0 1 20 -30 a25 25 0 0 1 40 10 a20 20 0 0 1 15 35 h-70 z',
// Circles
circle: 'M50 4 a30 30 0 0 1 0 60 a30 30 0 0 1 0 -60',
doublecircle: 'M50 4 a30 30 0 0 1 0 60 a30 30 0 0 1 0 -60 M50 9 a25 25 0 0 1 0 50 a25 25 0 0 1 0 -50',
// New additions based on research
note: 'M10 4 h80 v60 h-80 z M70 4 v20 h20', // rough note
summary: 'M50 4 a30 30 0 0 1 0 60 a30 30 0 0 1 0 -60 M30 24 l40 40 M30 64 l40 -40', // Crossed circle
};
export const SHAPE_DEFINITIONS: ShapeDefinition[] = [
{ id: 'rect', label: 'Rectangle', path: SHAPE_PATHS.rect, category: 'basic' },
{ id: 'rounded', label: 'Rounded', path: SHAPE_PATHS.rounded, category: 'basic' },
{ id: 'stadium', label: 'Terminal', path: SHAPE_PATHS.stadium, category: 'basic' },
{ id: 'subroutine', label: 'Subroutine', path: SHAPE_PATHS.subroutine, category: 'basic' },
{ id: 'circle', label: 'Circle', path: SHAPE_PATHS.circle, category: 'basic' },
{ id: 'diamond', label: 'Decision', path: SHAPE_PATHS.diamond, category: 'logic' },
{ id: 'hexagon', label: 'Prepare', path: SHAPE_PATHS.hexagon, category: 'logic' },
{ id: 'parallelogram', label: 'Input/Output', path: SHAPE_PATHS.parallelogram, category: 'logic' },
{ id: 'cyl', label: 'Database', path: SHAPE_PATHS.cyl, category: 'data' },
{ id: 'doc', label: 'Document', path: SHAPE_PATHS.doc, category: 'data' },
{ id: 'cloud', label: 'Cloud', path: SHAPE_PATHS.cloud, category: 'data' },
{ id: 'note', label: 'Note', path: SHAPE_PATHS.note, category: 'other' },
];

View file

@ -16,11 +16,14 @@ export class WebLlmService {
private isReady = false; private isReady = false;
// Track GPU Availability // Track GPU Availability
public static async isSystemSupported(): Promise<boolean> { public static async isSystemSupported(): Promise<boolean | null> {
// @ts-ignore
if (!navigator.gpu) { if (!navigator.gpu) {
return false; console.warn('WebGPU not supported in this environment');
return null;
} }
try { try {
// @ts-ignore
const adapter = await navigator.gpu.requestAdapter(); const adapter = await navigator.gpu.requestAdapter();
return !!adapter; return !!adapter;
} catch (e) { } catch (e) {

View file

@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
Settings, Zap, ChevronRight, Activity, ArrowRight, Sun, Moon, Eye, Upload Settings, Zap, ChevronRight, Activity, ArrowRight, Sun, Moon, Eye, Upload, Trash2
} from 'lucide-react'; } from 'lucide-react';
import { useFlowStore } from '../store'; import { useFlowStore } from '../store';
import { SettingsModal } from '../components/Settings'; import { SettingsModal } from '../components/Settings';
@ -9,13 +9,15 @@ import { SettingsModal } from '../components/Settings';
export function Dashboard() { export function Dashboard() {
const navigate = useNavigate(); const navigate = useNavigate();
const { const {
savedDiagrams, theme, toggleTheme savedDiagrams, theme, toggleTheme, deleteDiagram, clearAllDiagrams
} = useFlowStore(); } = useFlowStore();
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0); const [uploadProgress, setUploadProgress] = useState(0);
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
// ... (keep handleFileSelect) ...
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (file) {
@ -37,11 +39,7 @@ export function Dashboard() {
return ( return (
<div className="h-screen bg-void text-primary overflow-hidden font-sans relative"> <div className="h-screen bg-void text-primary overflow-hidden font-sans relative">
{/* Ambient Background */} {/* ... (keep background and header) ... */}
<div className="absolute inset-0 z-0 pointer-events-none opacity-20">
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[80vw] h-[80vh] bg-blue-500/5 blur-[160px] rounded-full dark:bg-blue-500/10" />
</div>
{/* Top Navigation */} {/* Top Navigation */}
<header className="absolute top-0 left-0 right-0 h-20 px-12 flex items-center justify-between z-50"> <header className="absolute top-0 left-0 right-0 h-20 px-12 flex items-center justify-between z-50">
<div className="flex items-center gap-4 group cursor-pointer" onClick={() => window.location.reload()}> <div className="flex items-center gap-4 group cursor-pointer" onClick={() => window.location.reload()}>
@ -75,7 +73,7 @@ export function Dashboard() {
/> />
{/* Main Content Area */} {/* Main Content Area */}
<main className="h-full flex flex-col items-center justify-center relative z-10 px-12"> <main className="min-h-screen flex flex-col items-center justify-start relative z-10 px-6 sm:px-12 pt-28 pb-24 overflow-y-auto">
<div className="max-w-4xl w-full text-center mb-16 animate-slide-up"> <div className="max-w-4xl w-full text-center mb-16 animate-slide-up">
<h1 className="text-7xl font-display font-black tracking-tighter leading-[0.9] mb-6 text-primary"> <h1 className="text-7xl font-display font-black tracking-tighter leading-[0.9] mb-6 text-primary">
Design the <br /><span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-500 to-indigo-500">Unseen logic</span>. Design the <br /><span className="text-transparent bg-clip-text bg-gradient-to-r from-blue-500 to-indigo-500">Unseen logic</span>.
@ -86,6 +84,7 @@ export function Dashboard() {
<div className="flex flex-col items-center gap-8"> <div className="flex flex-col items-center gap-8">
<div className="flex flex-wrap items-center justify-center gap-4 w-full max-w-2xl"> <div className="flex flex-wrap items-center justify-center gap-4 w-full max-w-2xl">
{/* ... (keep Upload and Direct Access buttons) ... */}
<button <button
className="btn-primary flex-1 min-w-[240px] group h-14" className="btn-primary flex-1 min-w-[240px] group h-14"
onClick={() => !isUploading && document.getElementById('main-upload')?.click()} onClick={() => !isUploading && document.getElementById('main-upload')?.click()}
@ -113,9 +112,7 @@ export function Dashboard() {
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform opacity-40" /> <ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform opacity-40" />
</button> </button>
</div> </div>
<input id="main-upload" type="file" className="hidden" onChange={handleFileSelect} /> <input id="main-upload" type="file" className="hidden" onChange={handleFileSelect} />
<p className="text-[10px] font-black uppercase tracking-[0.4em] text-tertiary"> <p className="text-[10px] font-black uppercase tracking-[0.4em] text-tertiary">
Drop files anywhere or click to start Drop files anywhere or click to start
</p> </p>
@ -123,31 +120,60 @@ export function Dashboard() {
</div> </div>
{/* Compact Recent Intelligence */} {/* Compact Recent Intelligence */}
<div className="w-full max-w-5xl grid grid-cols-1 sm:grid-cols-3 gap-6 animate-slide-up" style={{ animationDelay: '0.2s' }}> <div className="w-full max-w-5xl animate-slide-up" style={{ animationDelay: '0.2s' }}>
{savedDiagrams.length > 0 ? ( {savedDiagrams.length > 0 && (
[...savedDiagrams].reverse().slice(0, 3).map((diagram) => ( <div className="flex justify-end mb-4">
<div <button
key={diagram.id} onClick={() => {
className="glass-panel rounded-2xl p-6 flex items-center justify-between group cursor-pointer hover:border-blue-500/30 shadow-sm hover:shadow-xl transition-all" if (confirm('Are you sure you want to delete ALL saved diagrams?')) {
onClick={() => navigate(`/diagram?id=${diagram.id}`)} clearAllDiagrams();
}
}}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-red-500/10 text-red-500 hover:bg-red-500/20 text-[10px] font-bold uppercase tracking-widest transition-all"
> >
<div className="flex items-center gap-4 overflow-hidden"> <Trash2 className="w-3.5 h-3.5" />
<div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center shrink-0"> Clear All
<Activity className="w-4 h-4 text-blue-500 group-hover:scale-110 transition-transform" /> </button>
</div> </div>
<div className="truncate text-left">
<h4 className="font-bold text-sm truncate text-primary">{diagram.name}</h4>
<p className="text-[9px] font-black text-tertiary uppercase tracking-widest">{diagram.nodes.filter(n => n.type !== 'group').length} Entities</p>
</div>
</div>
<ArrowRight className="w-4 h-4 text-tertiary group-hover:text-primary transition-all opacity-0 group-hover:opacity-100 scale-0 group-hover:scale-100" />
</div>
))
) : (
[1, 2, 3].map(i => (
<div key={i} className="glass-panel border-dashed rounded-2xl p-6 opacity-20" />
))
)} )}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
{savedDiagrams.length > 0 ? (
[...savedDiagrams].reverse().slice(0, 3).map((diagram) => (
<div
key={diagram.id}
className="glass-panel rounded-2xl p-6 flex items-center justify-between group cursor-pointer hover:border-blue-500/30 shadow-sm hover:shadow-xl transition-all relative overflow-hidden"
onClick={() => navigate(`/diagram?id=${diagram.id}`)}
>
<div className="flex items-center gap-4 overflow-hidden z-10 w-full pr-8">
<div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center shrink-0">
<Activity className="w-4 h-4 text-blue-500 group-hover:scale-110 transition-transform" />
</div>
<div className="truncate text-left">
<h4 className="font-bold text-sm truncate text-primary">{diagram.name}</h4>
<p className="text-[9px] font-black text-tertiary uppercase tracking-widest">{diagram.nodes.filter(n => n.type !== 'group').length} Entities</p>
</div>
</div>
{/* Delete Button (visible on hover) */}
<button
onClick={(e) => {
e.stopPropagation();
deleteDiagram(diagram.id);
}}
className="absolute top-2 right-2 p-2 rounded-lg bg-red-500/10 text-red-500 opacity-0 group-hover:opacity-100 dark:hover:bg-red-500/20 transition-all z-20 hover:scale-110"
title="Delete"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))
) : (
[1, 2, 3].map(i => (
<div key={i} className="glass-panel border-dashed rounded-2xl p-6 opacity-20" />
))
)}
</div>
</div> </div>
{savedDiagrams.length > 3 && ( {savedDiagrams.length > 3 && (

View file

@ -4,16 +4,34 @@ import { FlowCanvas } from '../components/FlowCanvas';
import { NodeDetailsPanel } from '../components/NodeDetailsPanel'; import { NodeDetailsPanel } from '../components/NodeDetailsPanel';
import InteractiveLegend from '../components/InteractiveLegend'; import InteractiveLegend from '../components/InteractiveLegend';
import OnboardingTour from '../components/OnboardingTour'; import OnboardingTour from '../components/OnboardingTour';
import { useFlowStore } from '../store'; import { useFlowStore, useSettingsStore } from '../store';
import { useMobileDetect } from '../hooks/useMobileDetect';
import { useState, useCallback, useEffect, useRef } from 'react'; import { useState, useCallback, useEffect, useRef } from 'react';
import { PanelLeft, PanelRight, Zap, Sparkles, Minimize2 } from 'lucide-react'; import { PanelLeft, PanelRight, Zap, Sparkles, Minimize2, X, Cpu, Cloud, Server } from 'lucide-react';
import { OrchestratorLoader } from '../components/ui/OrchestratorLoader'; import { OrchestratorLoader } from '../components/ui/OrchestratorLoader';
import { EditorHeader } from '../components/editor/EditorHeader'; import { EditorHeader } from '../components/editor/EditorHeader';
import { VisualOrganizerFAB } from '../components/VisualOrganizerFAB';
import { useDiagramAPI } from '../hooks/useDiagramAPI';
export function Editor() { export function Editor() {
const { nodes, isLoading, leftPanelOpen, setLeftPanelOpen, rightPanelOpen, setRightPanelOpen, focusMode, setFocusMode } = useFlowStore(); // Enable Programmatic API
useDiagramAPI();
const { nodes, isLoading, leftPanelOpen, setLeftPanelOpen, rightPanelOpen, setRightPanelOpen, focusMode, setFocusMode, mobileEditorOpen, setMobileEditorOpen } = useFlowStore();
const { aiMode, onlineProvider } = useSettingsStore();
const { isMobile } = useMobileDetect();
const [sidebarWidth, setSidebarWidth] = useState(384); // Default w-96 const [sidebarWidth, setSidebarWidth] = useState(384); // Default w-96
const isResizing = useRef(false); const isResizing = useRef(false);
const hasInitializedMobile = useRef(false);
// Mobile: Hide panels by default on mount
useEffect(() => {
if (isMobile && !hasInitializedMobile.current) {
setLeftPanelOpen(false);
setRightPanelOpen(false);
hasInitializedMobile.current = true;
}
}, [isMobile, setLeftPanelOpen, setRightPanelOpen]);
const startResizing = useCallback((e: React.MouseEvent) => { const startResizing = useCallback((e: React.MouseEvent) => {
isResizing.current = true; isResizing.current = true;
@ -101,7 +119,8 @@ export function Editor() {
)} )}
</div> </div>
{(!leftPanelOpen && !focusMode) && (
{(!leftPanelOpen && !focusMode && !isMobile) && (
<button <button
onClick={() => setLeftPanelOpen(true)} onClick={() => setLeftPanelOpen(true)}
className="absolute left-6 top-1/2 -translate-y-1/2 z-40 w-10 h-10 glass-panel rounded-xl flex items-center justify-center text-slate-500 dark:text-secondary hover:text-blue-500 hover:scale-110 transition-all shadow-xl bg-white/80 dark:bg-slate-900/50 border border-black/5 dark:border-white/10" className="absolute left-6 top-1/2 -translate-y-1/2 z-40 w-10 h-10 glass-panel rounded-xl flex items-center justify-center text-slate-500 dark:text-secondary hover:text-blue-500 hover:scale-110 transition-all shadow-xl bg-white/80 dark:bg-slate-900/50 border border-black/5 dark:border-white/10"
@ -124,8 +143,8 @@ export function Editor() {
</div> </div>
)} )}
{/* Empty Workspace */} {/* Empty Workspace - Hidden on mobile (FAB provides access) */}
{nodes.length === 0 && !isLoading && ( {nodes.length === 0 && !isLoading && !isMobile && (
<div className="absolute inset-0 flex items-center justify-center z-10 pointer-events-none"> <div className="absolute inset-0 flex items-center justify-center z-10 pointer-events-none">
<div className="text-center p-12 floating-glass rounded-[2.5rem] max-w-sm pointer-events-auto shadow-2xl border border-black/5 dark:border-white/10 bg-white/50 dark:bg-black/20 backdrop-blur-xl"> <div className="text-center p-12 floating-glass rounded-[2.5rem] max-w-sm pointer-events-auto shadow-2xl border border-black/5 dark:border-white/10 bg-white/50 dark:bg-black/20 backdrop-blur-xl">
<div className="w-14 h-14 rounded-2xl bg-blue-600 flex items-center justify-center mx-auto mb-6 shadow-xl shadow-blue-600/20"> <div className="w-14 h-14 rounded-2xl bg-blue-600 flex items-center justify-center mx-auto mb-6 shadow-xl shadow-blue-600/20">
@ -149,22 +168,50 @@ export function Editor() {
)} )}
</main> </main>
{/* Right Inspector Panel - Flex layout instead of absolute */} {/* Right Inspector Panel - Sidebar on desktop, Sheet on mobile */}
<div {!isMobile ? (
className={`transition-all duration-500 ease-out border-l border-black/5 dark:border-white/10 bg-white/80 dark:bg-slate-900/95 backdrop-blur-xl flex flex-col ${(rightPanelOpen && !focusMode) ? 'w-80 opacity-100' : 'w-0 opacity-0 overflow-hidden border-none'}`} <div
> className={`transition-all duration-500 ease-out border-l border-black/5 dark:border-white/10 bg-white/80 dark:bg-slate-900/95 backdrop-blur-xl flex flex-col ${(rightPanelOpen && !focusMode) ? 'w-80 opacity-100' : 'w-0 opacity-0 overflow-hidden border-none'}`}
<div className="h-12 px-6 flex items-center justify-between border-b border-black/5 dark:border-white/10 bg-slate-50/50 dark:bg-slate-950/20 shrink-0"> >
<span className="text-[9px] font-black uppercase tracking-[0.3em] text-slate-500 dark:text-tertiary">Inspector</span> <div className="h-12 px-6 flex items-center justify-between border-b border-black/5 dark:border-white/10 bg-slate-50/50 dark:bg-slate-950/20 shrink-0">
<button onClick={() => setRightPanelOpen(false)} className="text-slate-400 dark:text-tertiary hover:text-slate-600 dark:hover:text-primary transition-colors"> <span className="text-[9px] font-black uppercase tracking-[0.3em] text-slate-500 dark:text-tertiary">Inspector</span>
<PanelRight className="w-4 h-4" /> <button onClick={() => setRightPanelOpen(false)} className="text-slate-400 dark:text-tertiary hover:text-slate-600 dark:hover:text-primary transition-colors">
</button> <PanelRight className="w-4 h-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto hide-scrollbar">
<NodeDetailsPanel />
</div>
</div> </div>
<div className="flex-1 overflow-y-auto hide-scrollbar"> ) : (
<NodeDetailsPanel /> rightPanelOpen && !focusMode && (
</div> <div className="fixed inset-0 z-[100] flex flex-col animate-fade-in">
</div> <div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => setRightPanelOpen(false)}
/>
<div className="relative mt-auto h-[70vh] bg-white dark:bg-slate-900 rounded-t-3xl shadow-2xl flex flex-col animate-slide-up overflow-hidden">
<div className="flex justify-center py-3">
<div className="w-12 h-1.5 rounded-full bg-slate-200 dark:bg-slate-700" />
</div>
<div className="h-12 px-6 flex items-center justify-between border-b border-black/5 dark:border-white/10 shrink-0">
<span className="text-sm font-bold text-slate-700 dark:text-slate-200">Inspector</span>
<button
onClick={() => setRightPanelOpen(false)}
className="w-10 h-10 rounded-full flex items-center justify-center text-slate-400 hover:text-slate-600 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/10 transition-all"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto">
<NodeDetailsPanel />
</div>
</div>
</div>
)
)}
{(nodes.length > 0 && !rightPanelOpen && !focusMode) && ( {(nodes.length > 0 && !rightPanelOpen && !focusMode && !isMobile) && (
<button <button
onClick={() => setRightPanelOpen(true)} onClick={() => setRightPanelOpen(true)}
className="absolute right-6 top-1/2 -translate-y-1/2 z-40 w-10 h-10 glass-panel rounded-xl flex items-center justify-center text-secondary hover:text-indigo-500 hover:scale-110 transition-all shadow-xl" className="absolute right-6 top-1/2 -translate-y-1/2 z-40 w-10 h-10 glass-panel rounded-xl flex items-center justify-center text-secondary hover:text-indigo-500 hover:scale-110 transition-all shadow-xl"
@ -173,7 +220,69 @@ export function Editor() {
</button> </button>
)} )}
</div> </div>
{/* Mobile FAB - Opens Bottom Sheet */}
{isMobile && !mobileEditorOpen && !focusMode && (
<>
{/* Visual Organizer FAB (Above Main FAB) */}
<div className="fixed bottom-24 right-6 z-50 flex flex-col items-end pointer-events-none">
<div className="pointer-events-auto">
<VisualOrganizerFAB />
</div>
</div>
<button
onClick={() => setMobileEditorOpen(true)}
className="fixed bottom-6 right-6 z-50 w-14 h-14 rounded-full bg-gradient-to-br from-blue-600 to-indigo-600 shadow-xl shadow-blue-600/30 flex items-center justify-center hover:scale-110 active:scale-95 transition-all"
title="Open Editor"
>
<Sparkles className="w-6 h-6 text-white" />
</button>
</>
)}
{/* Mobile Bottom Sheet */}
{isMobile && mobileEditorOpen && (
<div className="fixed inset-0 z-[100] flex flex-col animate-fade-in">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => setMobileEditorOpen(false)}
/>
{/* Sheet Content */}
<div className="relative mt-auto h-[85vh] bg-white dark:bg-slate-900 rounded-t-3xl shadow-2xl flex flex-col animate-slide-up overflow-hidden">
{/* Handle */}
<div className="flex justify-center py-3">
<div className="w-12 h-1.5 rounded-full bg-slate-200 dark:bg-slate-700" />
</div>
{/* Header */}
<div className="h-14 px-6 flex items-center justify-between border-b border-black/5 dark:border-white/10 shrink-0">
<div className="flex items-center gap-3">
<div className="p-1.5 rounded-lg bg-blue-500/10">
<Sparkles className="w-4 h-4 text-blue-500" />
</div>
<span className="text-sm font-bold text-slate-700 dark:text-slate-200">System Architect</span>
</div>
<button
onClick={() => setMobileEditorOpen(false)}
className="w-10 h-10 rounded-full flex items-center justify-center text-slate-400 hover:text-slate-600 dark:hover:text-white hover:bg-slate-100 dark:hover:bg-white/10 transition-all"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto">
<InputPanel />
</div>
</div>
</div>
)}
</div> </div>
</ReactFlowProvider> </ReactFlowProvider>
); );
} }

26
src/plugins/types.ts Normal file
View file

@ -0,0 +1,26 @@
// Basic types needed for plugins
export interface ToolbarItem {
id: string;
label: string;
icon: any; // Lucide icon component
onClick: () => void;
position?: 'left' | 'center' | 'right'; // Default 'right' (actions)
tooltip?: string;
}
export interface PluginContext {
registerToolbarItem: (item: ToolbarItem) => void;
// Future extensions:
// registerSidebarTab: (tab: SidebarTab) => void;
// registerNodeAction: (action: NodeAction) => void;
}
export interface Plugin {
id: string;
name: string;
version: string;
init: (context: PluginContext) => void;
cleanup?: () => void;
}

View file

@ -4,6 +4,7 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { persist } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
import { temporal } from 'zundo';
import { addEdge, applyNodeChanges, applyEdgeChanges } from '@xyflow/react'; import { addEdge, applyNodeChanges, applyEdgeChanges } from '@xyflow/react';
import type { NodeChange, EdgeChange } from '@xyflow/react'; import type { NodeChange, EdgeChange } from '@xyflow/react';
import type { Node, Edge, Connection, SavedDiagram, EdgeStyle } from '../types'; import type { Node, Edge, Connection, SavedDiagram, EdgeStyle } from '../types';
@ -40,6 +41,7 @@ interface DiagramState {
saveDiagram: (name: string) => void; saveDiagram: (name: string) => void;
loadDiagram: (id: string) => void; loadDiagram: (id: string) => void;
deleteDiagram: (id: string) => void; deleteDiagram: (id: string) => void;
clearAllDiagrams: () => void;
getSavedDiagrams: () => SavedDiagram[]; getSavedDiagrams: () => SavedDiagram[];
// Reset // Reset
@ -58,130 +60,148 @@ const initialState = {
}; };
export const useDiagramStore = create<DiagramState>()( export const useDiagramStore = create<DiagramState>()(
persist( temporal(
(set, get) => ({ persist(
...initialState, (set, get) => ({
...initialState,
// Setters // Setters
setNodes: (nodes) => set({ nodes }), setNodes: (nodes) => set({ nodes }),
setEdges: (edges) => set({ edges }), setEdges: (edges) => set({ edges }),
setSourceCode: (sourceCode) => set({ sourceCode }), setSourceCode: (sourceCode) => set({ sourceCode }),
setEdgeStyle: (edgeStyle) => set({ edgeStyle }), setEdgeStyle: (edgeStyle) => set({ edgeStyle }),
setGenerationComplexity: (generationComplexity) => set({ generationComplexity }), setGenerationComplexity: (generationComplexity) => set({ generationComplexity }),
// React Flow handlers // React Flow handlers
onNodesChange: (changes) => { onNodesChange: (changes) => {
set({
nodes: applyNodeChanges(changes, get().nodes),
});
},
onEdgesChange: (changes) => {
set({
edges: applyEdgeChanges(changes, get().edges),
});
},
onConnect: (connection) => {
set({
edges: addEdge(connection, get().edges),
});
},
// Node operations
updateNodeData: (nodeId, data) => {
set({
nodes: get().nodes.map((node) =>
node.id === nodeId
? { ...node, data: { ...node.data, ...data } }
: node
),
});
},
updateNodeType: (nodeId, type) => {
set({
nodes: get().nodes.map((node) =>
node.id === nodeId ? { ...node, type } : node
),
});
},
deleteNode: (nodeId) => {
set({
nodes: get().nodes.filter((node) => node.id !== nodeId),
edges: get().edges.filter(
(edge) => edge.source !== nodeId && edge.target !== nodeId
),
});
},
// Diagram persistence
saveDiagram: (name) => {
const { nodes, edges, sourceCode } = get();
const diagrams = get().getSavedDiagrams();
const now = new Date().toISOString();
const newDiagram: SavedDiagram = {
id: `diagram_${Date.now()}`,
name,
nodes,
edges,
sourceCode,
createdAt: now,
updatedAt: now,
};
localStorage.setItem(
'flowgen_diagrams',
JSON.stringify([newDiagram, ...diagrams])
);
set({ savedDiagrams: [newDiagram, ...diagrams] });
},
loadDiagram: (id) => {
const diagrams = get().getSavedDiagrams();
const diagram = diagrams.find((d) => d.id === id);
if (diagram) {
set({ set({
nodes: diagram.nodes, nodes: applyNodeChanges(changes, get().nodes),
edges: diagram.edges,
sourceCode: diagram.sourceCode,
}); });
} },
},
deleteDiagram: (id) => { onEdgesChange: (changes) => {
const diagrams = get().getSavedDiagrams(); set({
localStorage.setItem( edges: applyEdgeChanges(changes, get().edges),
'flowgen_diagrams', });
JSON.stringify(diagrams.filter((d) => d.id !== id)) },
);
set({ savedDiagrams: diagrams.filter((d) => d.id !== id) });
},
getSavedDiagrams: () => { onConnect: (connection) => {
try { set({
const stored = localStorage.getItem('flowgen_diagrams'); edges: addEdge(connection, get().edges),
return stored ? JSON.parse(stored) : []; });
} catch { },
return [];
}
},
// Reset // Node operations
reset: () => set(initialState), updateNodeData: (nodeId, data) => {
}), set({
{ nodes: get().nodes.map((node) =>
name: 'flowgen-diagram-storage', node.id === nodeId
// Only persist saved diagrams and settings, NOT the current diagram ? { ...node, data: { ...node.data, ...data } }
// This ensures canvas clears on refresh unless user saves a draft : node
partialize: (state) => ({ ),
savedDiagrams: state.savedDiagrams, });
edgeStyle: state.edgeStyle, },
generationComplexity: state.generationComplexity,
updateNodeType: (nodeId, type) => {
set({
nodes: get().nodes.map((node) =>
node.id === nodeId ? { ...node, type } : node
),
});
},
deleteNode: (nodeId) => {
set({
nodes: get().nodes.filter((node) => node.id !== nodeId),
edges: get().edges.filter(
(edge) => edge.source !== nodeId && edge.target !== nodeId
),
});
},
// Diagram persistence
saveDiagram: (name) => {
const { nodes, edges, sourceCode } = get();
const diagrams = get().getSavedDiagrams();
const now = new Date().toISOString();
const newDiagram: SavedDiagram = {
id: `diagram_${Date.now()}`,
name,
nodes,
edges,
sourceCode,
createdAt: now,
updatedAt: now,
};
localStorage.setItem(
'flowgen_diagrams',
JSON.stringify([newDiagram, ...diagrams])
);
set({ savedDiagrams: [newDiagram, ...diagrams] });
},
loadDiagram: (id) => {
const diagrams = get().getSavedDiagrams();
const diagram = diagrams.find((d) => d.id === id);
if (diagram) {
set({
nodes: diagram.nodes,
edges: diagram.edges,
sourceCode: diagram.sourceCode,
});
}
},
deleteDiagram: (id) => {
const diagrams = get().getSavedDiagrams();
localStorage.setItem(
'flowgen_diagrams',
JSON.stringify(diagrams.filter((d) => d.id !== id))
);
set({ savedDiagrams: diagrams.filter((d) => d.id !== id) });
},
clearAllDiagrams: () => {
localStorage.removeItem('flowgen_diagrams');
set({ savedDiagrams: [] });
},
getSavedDiagrams: () => {
try {
const stored = localStorage.getItem('flowgen_diagrams');
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
},
// Reset
reset: () => set(initialState),
}), }),
{
name: 'flowgen-diagram-storage',
// Only persist saved diagrams and settings, NOT the current diagram
// This ensures canvas clears on refresh unless user saves a draft
partialize: (state) => ({
savedDiagrams: state.savedDiagrams,
edgeStyle: state.edgeStyle,
generationComplexity: state.generationComplexity,
}),
}
),
{
// Zundo configuration
limit: 100, // Limit history to 100 states
partialize: (state) => ({
nodes: state.nodes,
edges: state.edges,
sourceCode: state.sourceCode,
}),
// Don't track intermediate edits while dragging, only on release if possible
// But for now, tracking everything is safer.
} }
) )
); );

View file

@ -53,6 +53,7 @@ export function useFlowStore() {
saveDiagram: diagram.saveDiagram, saveDiagram: diagram.saveDiagram,
loadDiagram: diagram.loadDiagram, loadDiagram: diagram.loadDiagram,
deleteDiagram: diagram.deleteDiagram, deleteDiagram: diagram.deleteDiagram,
clearAllDiagrams: diagram.clearAllDiagrams,
// Settings state // Settings state
apiKey: settings.apiKey, apiKey: settings.apiKey,
@ -75,6 +76,11 @@ export function useFlowStore() {
leftPanelOpen: ui.leftPanelOpen, leftPanelOpen: ui.leftPanelOpen,
rightPanelOpen: ui.rightPanelOpen, rightPanelOpen: ui.rightPanelOpen,
focusMode: ui.focusMode, focusMode: ui.focusMode,
mobileEditorOpen: ui.mobileEditorOpen,
inputDescription: ui.inputDescription,
inputActiveTab: ui.inputActiveTab,
inputImageUrl: ui.inputImageUrl,
mermaidCode: ui.mermaidCode,
activeFilters: ui.activeFilters, activeFilters: ui.activeFilters,
isLoading: ui.isLoading, isLoading: ui.isLoading,
error: ui.error, error: ui.error,
@ -84,6 +90,12 @@ export function useFlowStore() {
setLeftPanelOpen: ui.setLeftPanelOpen, setLeftPanelOpen: ui.setLeftPanelOpen,
setRightPanelOpen: ui.setRightPanelOpen, setRightPanelOpen: ui.setRightPanelOpen,
setFocusMode: ui.setFocusMode, setFocusMode: ui.setFocusMode,
setMobileEditorOpen: ui.setMobileEditorOpen,
setInputDescription: ui.setInputDescription,
setInputActiveTab: ui.setInputActiveTab,
setInputImageUrl: ui.setInputImageUrl,
setMermaidCode: ui.setMermaidCode,
clearInputs: ui.clearInputs,
toggleFilter: ui.toggleFilter, toggleFilter: ui.toggleFilter,
setLoading: ui.setLoading, setLoading: ui.setLoading,
setError: ui.setError, setError: ui.setError,

63
src/store/pluginStore.ts Normal file
View file

@ -0,0 +1,63 @@
import { create } from 'zustand';
import type { Plugin, PluginContext, ToolbarItem } from '../plugins/types';
interface PluginState {
plugins: Record<string, Plugin>;
toolbarItems: ToolbarItem[];
// Actions
registerPlugin: (plugin: Plugin) => void;
unregisterPlugin: (pluginId: string) => void;
// Internal API for Plugins
_registerToolbarItem: (item: ToolbarItem) => void;
}
export const usePluginStore = create<PluginState>((set, get) => ({
plugins: {},
toolbarItems: [],
registerPlugin: (plugin) => {
if (get().plugins[plugin.id]) {
console.warn(`Plugin ${plugin.id} is already registered.`);
return;
}
// Create Context
const context: PluginContext = {
registerToolbarItem: (item) => get()._registerToolbarItem(item),
};
// Init Plugin
try {
plugin.init(context);
set((state) => ({
plugins: { ...state.plugins, [plugin.id]: plugin }
}));
console.log(`Plugin ${plugin.name} loaded.`);
} catch (e) {
console.error(`Failed to initialize plugin ${plugin.id}:`, e);
}
},
unregisterPlugin: (pluginId) => {
const plugin = get().plugins[pluginId];
if (!plugin) return;
if (plugin.cleanup) plugin.cleanup();
set((state) => {
const { [pluginId]: removed, ...rest } = state.plugins;
// Note: complex cleanup of UI items registered by this plugin would ideally happen here.
// For MVP, we might just clear all or need items to be tagged with pluginId.
// Improvement: ToolbarItem should have pluginId.
return { plugins: rest };
});
},
_registerToolbarItem: (item) => {
set((state) => ({
toolbarItems: [...state.toolbarItems, item]
}));
}
}));

View file

@ -5,6 +5,7 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { persist } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
import type { AIMode, OnlineProvider, Theme } from '../types'; import type { AIMode, OnlineProvider, Theme } from '../types';
import { checkIsMobile } from '../hooks/useMobileDetect';
interface SettingsState { interface SettingsState {
// AI Configuration // AI Configuration
@ -39,8 +40,19 @@ const getInitialTheme = (): Theme => {
return 'dark'; return 'dark';
}; };
// Check if we're on a mobile device for initial AI mode
const getInitialAiMode = (): AIMode => {
if (typeof window !== 'undefined') {
const stored = localStorage.getItem('flowgen_ai_mode') as AIMode | null;
if (stored) return stored;
// Default to online (cloud) on mobile for better performance
if (checkIsMobile()) return 'online';
}
return 'offline';
};
const initialSettings = { const initialSettings = {
aiMode: 'offline' as AIMode, aiMode: getInitialAiMode(),
onlineProvider: 'openai' as OnlineProvider, onlineProvider: 'openai' as OnlineProvider,
apiKey: '', apiKey: '',
ollamaUrl: 'http://localhost:11434', ollamaUrl: 'http://localhost:11434',

View file

@ -10,6 +10,13 @@ interface UIState {
leftPanelOpen: boolean; leftPanelOpen: boolean;
rightPanelOpen: boolean; rightPanelOpen: boolean;
focusMode: boolean; focusMode: boolean;
mobileEditorOpen: boolean; // Mobile bottom sheet state
// Input Panel persistence
inputDescription: string;
inputActiveTab: 'image' | 'code' | 'describe';
inputImageUrl: string | null;
mermaidCode: string;
// Selection // Selection
selectedNode: Node | null; selectedNode: Node | null;
@ -26,6 +33,14 @@ interface UIState {
setRightPanelOpen: (open: boolean) => void; setRightPanelOpen: (open: boolean) => void;
setFocusMode: (focusMode: boolean) => void; setFocusMode: (focusMode: boolean) => void;
toggleFocusMode: () => void; toggleFocusMode: () => void;
setMobileEditorOpen: (open: boolean) => void;
// Input Panel actions
setInputDescription: (description: string) => void;
setInputActiveTab: (tab: 'image' | 'code' | 'describe') => void;
setInputImageUrl: (url: string | null) => void;
setMermaidCode: (code: string) => void;
clearInputs: () => void;
setSelectedNode: (node: Node | null) => void; setSelectedNode: (node: Node | null) => void;
@ -38,13 +53,18 @@ interface UIState {
clearError: () => void; clearError: () => void;
} }
const DEFAULT_FILTERS = ['filter-client', 'filter-server', 'filter-db', 'filter-other']; const DEFAULT_FILTERS = ['filter-client', 'filter-server', 'filter-db', 'filter-other', 'filter-group'];
export const useUIStore = create<UIState>()((set, get) => ({ export const useUIStore = create<UIState>()((set, get) => ({
// Initial state // Initial state
leftPanelOpen: true, leftPanelOpen: true,
rightPanelOpen: false, rightPanelOpen: false,
focusMode: false, focusMode: false,
mobileEditorOpen: false,
inputDescription: '',
inputActiveTab: 'image',
inputImageUrl: null,
mermaidCode: '',
selectedNode: null, selectedNode: null,
activeFilters: DEFAULT_FILTERS, activeFilters: DEFAULT_FILTERS,
isLoading: false, isLoading: false,
@ -55,6 +75,14 @@ export const useUIStore = create<UIState>()((set, get) => ({
setRightPanelOpen: (rightPanelOpen) => set({ rightPanelOpen }), setRightPanelOpen: (rightPanelOpen) => set({ rightPanelOpen }),
setFocusMode: (focusMode) => set({ focusMode }), setFocusMode: (focusMode) => set({ focusMode }),
toggleFocusMode: () => set({ focusMode: !get().focusMode }), toggleFocusMode: () => set({ focusMode: !get().focusMode }),
setMobileEditorOpen: (mobileEditorOpen) => set({ mobileEditorOpen }),
// Input Panel actions
setInputDescription: (inputDescription) => set({ inputDescription }),
setInputActiveTab: (inputActiveTab) => set({ inputActiveTab }),
setInputImageUrl: (inputImageUrl) => set({ inputImageUrl }),
setMermaidCode: (mermaidCode) => set({ mermaidCode }),
clearInputs: () => set({ inputDescription: '', inputImageUrl: null, mermaidCode: '' }),
// Selection actions // Selection actions
setSelectedNode: (selectedNode) => { setSelectedNode: (selectedNode) => {

View file

@ -339,3 +339,81 @@
background: rgba(139, 92, 246, 0.15); background: rgba(139, 92, 246, 0.15);
color: #8b5cf6; color: #8b5cf6;
} }
/* ============================================
Mobile Touch Target Sizes
Ensure minimum 44x44px for accessibility
============================================ */
@media (max-width: 768px) {
/* Increase button touch targets on mobile */
.btn-icon {
min-width: 44px;
min-height: 44px;
width: 2.75rem;
height: 2.75rem;
}
.btn-primary,
.btn-secondary {
min-height: 48px;
padding: 1rem 1.5rem;
}
.btn-ghost {
min-height: 44px;
padding: 0.75rem 1rem;
}
/* Toolbar and panel buttons */
.glass-panel button {
min-height: 44px;
min-width: 44px;
}
/* Form inputs */
.input-base {
min-height: 48px;
font-size: 1rem; /* Prevent zoom on iOS */
}
/* Tabs and navigation items */
button[role="tab"],
nav button,
nav a {
min-height: 44px;
min-width: 44px;
}
}
/* Mobile-specific animation for bottom sheet */
@keyframes slide-up-sheet {
from {
opacity: 0;
transform: translateY(100%);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 768px) {
.animate-slide-up {
animation: slide-up-sheet 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
}
/* Fade in animation */
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.animate-fade-in {
animation: fade-in 0.2s ease-out forwards;
}