From b5b2261efb7be2619e68d6ab48def3dadf62b6fb Mon Sep 17 00:00:00 2001 From: SysVis AI Date: Sun, 28 Dec 2025 19:33:50 +0700 Subject: [PATCH] feat: enhance layout engine, edge styles, and editor header --- index.html | 7 +- package-lock.json | 346 ++++++++++++++++++++++- package.json | 6 + public/manifest.json | 25 ++ src/App.tsx | 11 + src/components/CodeEditor.tsx | 346 ++++++++--------------- src/components/FlowCanvas.tsx | 87 +++--- src/components/ImageUpload.tsx | 30 +- src/components/InputPanel.tsx | 44 ++- src/components/InteractiveLegend.tsx | 20 +- src/components/NodeDetailsPanel.tsx | 9 +- src/components/Settings.tsx | 168 +++++++++-- src/components/SmartGuide.tsx | 2 +- src/components/VisualOrganizerFAB.tsx | 142 ++++++++++ src/components/VisualOrganizerPanel.tsx | 330 +++++++++++---------- src/components/editor/EditorHeader.tsx | 143 +++++++--- src/components/editor/EditorToolbar.tsx | 61 ++++ src/components/editor/MonacoWrapper.tsx | 107 +++++++ src/components/nodes/CustomNodes.tsx | 18 +- src/components/nodes/ShapeNode.tsx | 69 +++++ src/components/ui/OrchestratorLoader.tsx | 2 +- src/hooks/index.ts | 1 + src/hooks/useAIGeneration.ts | 6 +- src/hooks/useDiagramAPI.ts | 70 +++++ src/hooks/useMobileDetect.ts | 88 ++++++ src/hooks/useVisualOrganizer.ts | 2 +- src/lib/aiService.ts | 228 +++++++-------- src/lib/exportUtils.ts | 38 +-- src/lib/layoutEngine.ts | 181 ++++++++++-- src/lib/mermaidParser.ts | 136 ++++++++- src/lib/prompts.ts | 118 ++++++++ src/lib/shapes.ts | 61 ++++ src/lib/webLlmService.ts | 7 +- src/pages/Dashboard.tsx | 92 +++--- src/pages/Editor.tsx | 149 ++++++++-- src/plugins/types.ts | 26 ++ src/store/diagramStore.ts | 252 +++++++++-------- src/store/index.ts | 12 + src/store/pluginStore.ts | 63 +++++ src/store/settingsStore.ts | 14 +- src/store/uiStore.ts | 30 +- src/styles/ui.css | 78 +++++ 42 files changed, 2754 insertions(+), 871 deletions(-) create mode 100644 public/manifest.json create mode 100644 src/components/VisualOrganizerFAB.tsx create mode 100644 src/components/editor/EditorToolbar.tsx create mode 100644 src/components/editor/MonacoWrapper.tsx create mode 100644 src/components/nodes/ShapeNode.tsx create mode 100644 src/hooks/useDiagramAPI.ts create mode 100644 src/hooks/useMobileDetect.ts create mode 100644 src/lib/prompts.ts create mode 100644 src/lib/shapes.ts create mode 100644 src/plugins/types.ts create mode 100644 src/store/pluginStore.ts diff --git a/index.html b/index.html index eabbdc6..11a4810 100644 --- a/index.html +++ b/index.html @@ -1,8 +1,13 @@ + - + + + + + SysVis.AI - System Design Visualizer =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": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -4661,7 +4717,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4807,6 +4862,12 @@ "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": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -5259,6 +5320,12 @@ "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": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -5468,6 +5535,26 @@ "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": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -5515,6 +5602,12 @@ "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": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", @@ -5571,6 +5664,16 @@ "dev": true, "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": { "version": "30.2.0", "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", @@ -5972,6 +6075,27 @@ "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": { "version": "1.30.2", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", @@ -6495,7 +6619,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -6966,6 +7089,41 @@ "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": { "version": "19.2.3", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", @@ -7044,6 +7202,20 @@ "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": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -7161,6 +7333,26 @@ "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", "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": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -7309,6 +7501,35 @@ "dev": true, "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": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -7378,6 +7599,15 @@ "dev": true, "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": { "version": "3.0.0", "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" } }, + "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": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", @@ -8043,7 +8279,7 @@ "version": "8.18.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -8078,6 +8314,73 @@ "dev": true, "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": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -8085,6 +8388,24 @@ "dev": true, "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": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -8122,11 +8443,30 @@ "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": { "version": "5.0.9", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz", "integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==", "license": "MIT", + "peer": true, "engines": { "node": ">=12.20.0" }, diff --git a/package.json b/package.json index d7b8c4e..ef30173 100644 --- a/package.json +++ b/package.json @@ -31,15 +31,21 @@ "@mlc-ai/web-llm": "^0.2.80", "@monaco-editor/react": "^4.7.0", "@types/dagre": "^0.7.53", + "@types/randomcolor": "^0.5.9", "@xyflow/react": "^12.10.0", "clsx": "^2.1.1", "dagre": "^0.8.5", "html-to-image": "^1.11.13", "lucide-react": "^0.562.0", "mermaid": "^11.12.2", + "randomcolor": "^0.6.2", "react": "^19.2.0", "react-dom": "^19.2.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" }, "devDependencies": { diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..a791bdb --- /dev/null +++ b/public/manifest.json @@ -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" +} \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 403329a..9c52991 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,7 +3,18 @@ import { Dashboard } from './pages/Dashboard'; import { Editor } from './pages/Editor'; import { History } from './pages/History'; +import { useEffect } from 'react'; +import { usePluginStore } from './store/pluginStore'; + + function App() { + const { registerPlugin } = usePluginStore(); + + useEffect(() => { + // Register Plugins + // StatsPlugin removed + }, [registerPlugin]); + return ( diff --git a/src/components/CodeEditor.tsx b/src/components/CodeEditor.tsx index b2b6249..f709ce8 100644 --- a/src/components/CodeEditor.tsx +++ b/src/components/CodeEditor.tsx @@ -1,47 +1,54 @@ import { useState, useCallback, useEffect, useRef } from 'react'; -import Editor from '@monaco-editor/react'; +import { useStore } from 'zustand'; import { useFlowStore } from '../store'; +import { useDiagramStore } from '../store/diagramStore'; import { parseMermaid, detectInputType } from '../lib/mermaidParser'; import { getLayoutedElements } from '../lib/layoutEngine'; -import { interpretText, suggestFix } from '../lib/aiService'; -import { - Loader2, Zap, Trash2, FileText, Lightbulb, - AlertCircle -} from 'lucide-react'; +import { interpretText } from '../lib/aiService'; +import { MonacoWrapper } from './editor/MonacoWrapper'; +import { EditorToolbar } from './editor/EditorToolbar'; -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() { - const [code, setCode] = useState(''); + // ... (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 [syntaxErrors, setSyntaxErrors] = useState<{ line: number; message: string }[]>([]); - const [highlightedLine, setHighlightedLine] = useState(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 + // We'll keep these refs here to handle the node selection highlighting logic const editorRef = useRef(null); const monacoRef = useRef(null); const decorationsRef = useRef([]); - 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) useEffect(() => { const handleNodeSelected = (event: CustomEvent<{ nodeId: string; label: string }>) => { @@ -59,8 +66,6 @@ export function CodeEditor() { ); if (lineIndex !== -1) { - setHighlightedLine(lineIndex + 1); - // Scroll to and highlight the line editorRef.current.revealLineInCenter(lineIndex + 1); @@ -82,7 +87,6 @@ export function CodeEditor() { setTimeout(() => { if (editorRef.current) { decorationsRef.current = editorRef.current.deltaDecorations(decorationsRef.current, []); - setHighlightedLine(null); } }, 3000); } @@ -97,7 +101,7 @@ export function CodeEditor() { const newCode = value || ''; setCode(newCode); if (newCode.trim()) setInputType(detectInputType(newCode)); - }, [inputType]); + }, [setCode]); const handleGenerate = useCallback(async () => { if (!code.trim()) return; @@ -114,21 +118,51 @@ export function CodeEditor() { const result = await interpretText(code, ollamaUrl, modelName, aiMode, onlineProvider, apiKey); if (!result.success || !result.mermaidCode) throw new Error(result.error || 'Interpretation failed'); mermaidCode = result.mermaidCode; + metadata = result.metadata; } setSourceCode(mermaidCode); - const { nodes: parsedNodes, edges: parsedEdges } = await parseMermaid(mermaidCode); - if (metadata) { - parsedNodes.forEach(node => { - const label = (node.data.label as string) || ''; - if (label && metadata && metadata[label]) node.data.metadata = metadata[label]; - }); + // First attempt to parse + try { + const { nodes: parsedNodes, edges: parsedEdges } = await parseMermaid(mermaidCode); + 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) { const errorMessage = error instanceof Error ? error.message : 'Error processing code'; setError(errorMessage); @@ -141,59 +175,19 @@ export function CodeEditor() { } finally { 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(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(); - if (!position) return; + // Handle Editor mount + const handleEditorMount = useCallback((editor: any, monaco: any) => { + editorRef.current = editor; + monacoRef.current = monaco; - const line = editorRef.current.getModel()?.getLineContent(position.lineNumber); - if (!line) return; - - // 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', { + // Define themes + monaco.editor.defineTheme('architect-dark', { base: 'vs-dark', inherit: true, rules: [ @@ -211,162 +205,66 @@ export function CodeEditor() { 'editor.lineHighlightBackground': '#1e293b', 'editor.selectionBackground': '#334155', 'editorCursor.foreground': '#60a5fa', + 'editor.lineHighlightBorder': '#00000000', // No border for line highlight } }); - // Define Light Theme - monacoRef.current.editor.defineTheme('architect-light', { + monaco.editor.defineTheme('architect-light', { base: 'vs', inherit: true, rules: [ - { token: 'keyword', foreground: '2563eb', fontStyle: 'bold' }, // Blue-600 - { token: 'comment', foreground: '94a3b8', fontStyle: 'italic' }, // Slate-400 - { token: 'string', foreground: '059669' }, // Emerald-600 - { token: 'number', foreground: 'd97706' }, // Amber-600 - { token: 'type', foreground: '9333ea' }, // Purple-600 + { token: 'keyword', foreground: '2563eb', fontStyle: 'bold' }, + { token: 'comment', foreground: '94a3b8', fontStyle: 'italic' }, + { token: 'string', foreground: '059669' }, + { token: 'number', foreground: 'd97706' }, + { token: 'type', foreground: '9333ea' }, ], colors: { - 'editor.background': '#f8fafc', // Slate-50 - 'editor.foreground': '#334155', // Slate-700 - 'editorLineNumber.foreground': '#cbd5e1', // Slate-300 - 'editorLineNumber.activeForeground': '#2563eb', // Blue-600 - 'editor.lineHighlightBackground': '#f1f5f9', // Slate-100 - 'editor.selectionBackground': '#e2e8f0', // Slate-200 - 'editorCursor.foreground': '#2563eb', // Blue-600 + 'editor.background': '#f8fafc', + 'editor.foreground': '#334155', + 'editorLineNumber.foreground': '#cbd5e1', + 'editorLineNumber.activeForeground': '#2563eb', + 'editor.lineHighlightBackground': '#f1f5f9', + 'editor.selectionBackground': '#e2e8f0', + '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]); - const handleEditorMount = useCallback((editor: any, monaco: any) => { - editorRef.current = editor; - monacoRef.current = monaco; - // Initial theme set handled by useEffect - }, []); + // Update theme when it changes + useEffect(() => { + if (monacoRef.current) { + monacoRef.current.editor.setTheme(theme === 'dark' ? 'architect-dark' : 'architect-light'); + } + }, [theme]); return ( -
- {/* Editor Container with Badges */} -
- {/* Internal Badges */} -
- - Mermaid - -
-
- - Manual - -
- +
+ - {/* Floating Action Buttons */} -
- - -
+ - {/* Syntax Errors */} - {syntaxErrors.length > 0 && ( -
-
- -
-

Syntax Error

-

{syntaxErrors[0].message}

-
-
-
- )} - {/* AI Suggestion Toast */} - {suggestion && ( -
-
- -

{suggestion}

-
-
- )} -
- {/* Action Buttons */} -
- - -
); } diff --git a/src/components/FlowCanvas.tsx b/src/components/FlowCanvas.tsx index a16a79e..2fc4230 100644 --- a/src/components/FlowCanvas.tsx +++ b/src/components/FlowCanvas.tsx @@ -9,22 +9,31 @@ import { SelectionMode, } from '@xyflow/react'; import { useFlowStore } from '../store'; +import { useMobileDetect } from '../hooks/useMobileDetect'; import { nodeTypes } from './nodes/CustomNodes'; import { edgeTypes, EdgeDefs } from './edges/AnimatedEdge'; import { Spline, Minus, Plus, Maximize, Map, Wand2, - RotateCcw, Download, Command, Hand, MousePointer2, Settings2, ChevronDown + Hand, MousePointer2, Settings2, ChevronDown } from 'lucide-react'; import { getLayoutedElements } from '../lib/layoutEngine'; + + + export function FlowCanvas() { const { nodes, edges, onNodesChange, onEdgesChange, onConnect, setSelectedNode, edgeStyle, setEdgeStyle, theme, activeFilters, - setNodes, setEdges + setNodes, setEdges, focusMode } = useFlowStore(); - const { zoomIn, zoomOut, fitView, getViewport } = useReactFlow(); + const { isMobile } = useMobileDetect(); + const { zoomIn, zoomOut, fitView } = useReactFlow(); 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 [isSelectionMode, setIsSelectionMode] = useState(false); // false = Pan, true = Select @@ -71,9 +80,35 @@ export function FlowCanvas() { // Filter nodes based on active filters const filteredNodes = useMemo(() => { 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 { ...node, @@ -157,6 +192,7 @@ export function FlowCanvas() { return (
+ {/* Control Panel - Top Right (Unified Toolkit) */} - +
- ); -} diff --git a/src/components/ImageUpload.tsx b/src/components/ImageUpload.tsx index 73436a1..da29f9a 100644 --- a/src/components/ImageUpload.tsx +++ b/src/components/ImageUpload.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useRef } from 'react'; import { useFlowStore } from '../store'; import { analyzeImage, analyzeSVG } from '../lib/aiService'; import { parseMermaid } from '../lib/mermaidParser'; @@ -10,6 +10,7 @@ export function ImageUpload() { const [isDragging, setIsDragging] = useState(false); const [fileType, setFileType] = useState<'image' | 'svg' | null>(null); const [svgContent, setSvgContent] = useState(''); + const fileInputRef = useRef(null); const { setNodes, setEdges, setLoading, setError, setSourceCode, isLoading, @@ -65,10 +66,26 @@ export function ImageUpload() { }, [handleFile]); const handleGenerate = useCallback(async () => { - if (aiMode === 'offline' && !ollamaUrl) { - setError('Please configure Ollama URL in settings'); + // Validate AI configuration before processing + 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; } + setLoading(true); setError(null); @@ -81,8 +98,7 @@ export function ImageUpload() { modelName, aiMode, onlineProvider, - apiKey, - generationComplexity + apiKey ); } else if (preview) { result = await analyzeImage( @@ -130,7 +146,7 @@ export function ImageUpload() { onDrop={handleDrop} onDragOver={handleDragOver} 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 ${isDragging ? 'bg-blue-500/10 border-blue-500/50 scale-[1.01]' @@ -138,7 +154,7 @@ export function ImageUpload() { }`} > ('image'); - const [description, setDescription] = useState(''); const { setNodes, setEdges, setLoading, setError, setSourceCode, isLoading, ollamaUrl, modelName, 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(); const handleTextGenerate = useCallback(async () => { if (!description.trim()) return; - if (aiMode === 'offline' && !ollamaUrl) { - setError('Please configure Ollama URL in settings'); - return; + + // Validate AI configuration before processing + 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); @@ -44,7 +63,10 @@ export function InputPanel() { 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); + const { nodes: parsedNodes, edges: parsedEdges } = await parseMermaid(result.mermaidCode); if (result.metadata) { @@ -59,12 +81,18 @@ export function InputPanel() { const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(parsedNodes, parsedEdges); setNodes(layoutedNodes); 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) { setError(error instanceof Error ? error.message : 'Failed to process description'); } finally { 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 = [ { id: 'image' as Tab, icon: Image, label: 'Upload' }, diff --git a/src/components/InteractiveLegend.tsx b/src/components/InteractiveLegend.tsx index b521281..6c07be2 100644 --- a/src/components/InteractiveLegend.tsx +++ b/src/components/InteractiveLegend.tsx @@ -1,19 +1,33 @@ 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'; const filters = [ { id: 'filter-client', label: 'Client', icon: Smartphone, color: '#a855f7' }, { id: 'filter-server', label: 'Server', icon: Server, color: '#3b82f6' }, { 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() { const [isOpen, setIsOpen] = useState(false); - const { focusMode, activeFilters, toggleFilter } = useFlowStore(); + const { focusMode, activeFilters, toggleFilter, nodes } = useFlowStore(); 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 (
{/* Toggle Button */} @@ -35,7 +49,7 @@ export default function InteractiveLegend() { Legend Filters
- {filters.map(f => { + {visibleFilters.map(f => { const isActive = activeFilters.includes(f.id); return (
- -
@@ -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" > + + + + + + diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 6b39eea..045699b 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -1,6 +1,7 @@ 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 { useMobileDetect } from '../hooks/useMobileDetect'; import { webLlmService } from '../lib/webLlmService'; import type { WebLlmProgress } from '../lib/webLlmService'; import { visionService } from '../lib/visionService'; @@ -25,6 +26,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { aiMode, setAiMode, onlineProvider, setOnlineProvider } = useFlowStore(); + const { isMobile } = useMobileDetect(); const [systemStatus, setSystemStatus] = useState<'online' | 'offline'>('offline'); const [isVerifying, setIsVerifying] = useState(false); @@ -41,6 +43,10 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { const [isVisionLoading, setIsVisionLoading] = useState(false); const [isVisionReady, setIsVisionReady] = useState(false); + // API Key Verification State + const [keyVerificationStatus, setKeyVerificationStatus] = useState<'idle' | 'checking' | 'valid' | 'invalid'>('idle'); + const [keyVerificationError, setKeyVerificationError] = useState(null); + const checkBrowserStatus = useCallback(() => { const llmStatus = webLlmService.getStatus(); setIsBrowserReady(llmStatus.isReady); @@ -114,12 +120,59 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { if (onlineProvider === 'ollama-cloud') { await fetchModels(); } else { - const isKeyValid = apiKey.length > 20 && (apiKey.startsWith('sk-') || apiKey.startsWith('AIza') || apiKey.length > 30); - setSystemStatus(isKeyValid ? 'online' : 'offline'); + // Just check format, actual verification is done with verify button + const isKeyFormatValid = apiKey.length > 20 && (apiKey.startsWith('sk-') || apiKey.startsWith('AIza') || apiKey.length > 30); + setSystemStatus(isKeyFormatValid && keyVerificationStatus === 'valid' ? 'online' : 'offline'); } } 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(() => { if (isOpen) { @@ -134,7 +187,12 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { return ( <>
-
+
@@ -145,12 +203,21 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {

Configuration

- +
+ {isMobile && ( +
+ + Lite +
+ )} + +
-
+ {/* Scrollable Content Area */} +
{/* Mode Selection */}
+ )} +
+ {keyVerificationStatus === 'invalid' && keyVerificationError && ( +

{keyVerificationError}

+ )} + {keyVerificationStatus === 'valid' && ( +

API key verified successfully!

+ )} + {/* Get API Key Link */} + {onlineProvider !== 'ollama-cloud' && ( + + + Get {onlineProvider === 'openai' ? 'OpenAI' : 'Google Gemini'} API Key → + + )}
)} diff --git a/src/components/SmartGuide.tsx b/src/components/SmartGuide.tsx index ba7708a..a3daee0 100644 --- a/src/components/SmartGuide.tsx +++ b/src/components/SmartGuide.tsx @@ -1,5 +1,5 @@ -import React, { useState, useEffect } from 'react'; +import { useState, useEffect } from 'react'; import { Lightbulb, MousePointer2, Keyboard, Layout } from 'lucide-react'; const TIPS = [ diff --git a/src/components/VisualOrganizerFAB.tsx b/src/components/VisualOrganizerFAB.tsx new file mode 100644 index 0000000..c5ff09a --- /dev/null +++ b/src/components/VisualOrganizerFAB.tsx @@ -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(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 ( + + ); + } + + // Expanded State (Floating Card) + return ( +
+ + {/* Header */} +
+
+ + Organizer +
+ +
+ + {/* Content */} +
+ {status === 'analyzing' && ( +
+
+
+
+
+ +
+
+

Analyzing structure...

+
+ )} + + {status === 'ready' && bestSuggestion && ( +
+
+ +

Optimization Ready

+

{bestSuggestion.description}

+
+
+ + +
+
+ )} + + {status === 'applied' && ( +
+
+ +

Clean & Organized!

+
+
+ + +
+
+ )} +
+
+
+ ); +}; diff --git a/src/components/VisualOrganizerPanel.tsx b/src/components/VisualOrganizerPanel.tsx index 51131bb..d12a4c1 100644 --- a/src/components/VisualOrganizerPanel.tsx +++ b/src/components/VisualOrganizerPanel.tsx @@ -3,194 +3,192 @@ import { Button } from './ui/Button'; import { Card } from './ui/Card'; import { useVisualOrganizer } from '../hooks/useVisualOrganizer'; import { useDiagramStore } from '../store'; +import { Sparkles, Wand2, Layout, Scan, CheckCircle2, RotateCcw } from 'lucide-react'; import type { LayoutSuggestion } from '../types/visualOrganization'; export const VisualOrganizerPanel: React.FC = () => { - const { analyzeLayout, generateSuggestions, applySuggestion, getPresets } = useVisualOrganizer(); - const { nodes, edges, setNodes, setEdges } = useDiagramStore(); // Needed for snapshotting - const [suggestions, setSuggestions] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [analysis, setAnalysis] = useState(null); - const [snapshotHistory, setSnapshotHistory] = useState>([]); - const [previewState, setPreviewState] = useState<{ - originalNodes: any[]; - originalEdges: any[]; - suggestionId: string; - } | null>(null); + const { analyzeLayout, generateSuggestions, applySuggestion } = useVisualOrganizer(); + const { nodes, edges, setNodes, setEdges } = useDiagramStore(); - const takeSnapshot = (name: string) => { - setSnapshotHistory(prev => [ - { id: Math.random().toString(36).substr(2, 9), timestamp: Date.now(), nodes: [...nodes], edges: [...edges], name }, - ...prev.slice(0, 4) // Keep last 5 - ]); - }; + // UI States + const [status, setStatus] = useState<'idle' | 'analyzing' | 'ready' | 'applied'>('idle'); + const [bestSuggestion, setBestSuggestion] = useState(null); + const [snapshot, setSnapshot] = useState<{ nodes: any[], edges: any[] } | null>(null); - const restoreSnapshot = (snapshot: any) => { - setNodes(snapshot.nodes); - setEdges(snapshot.edges); - }; + // AI Organize Handler + const handleAIOrganize = async () => { + setStatus('analyzing'); - const handleAnalyze = () => { - const result = analyzeLayout(); - setAnalysis(result); - }; + // 1. Analyze (Simulate brief delay for effect) + analyzeLayout(); - const handleGenerateSuggestions = async () => { - setIsLoading(true); + // 2. Generate Suggestions try { - const result = await generateSuggestions(); - setSuggestions(result); + // Artificial delay for "Scanning" animation effect + 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) { - console.error('Failed to generate suggestions:', error); - } finally { - setIsLoading(false); + console.error(error); + setStatus('idle'); } }; - const handlePreviewSuggestion = (suggestion: LayoutSuggestion) => { - // Save current state - if (!previewState) { - setPreviewState({ - originalNodes: [...nodes], - originalEdges: [...edges], - suggestionId: suggestion.id - }); - } - // Apply suggestion - applySuggestion(suggestion); + const handleApply = () => { + if (!bestSuggestion) return; + + // Take snapshot before applying + setSnapshot({ nodes: [...nodes], edges: [...edges] }); + + applySuggestion(bestSuggestion); + setStatus('applied'); }; - const handleConfirmPreview = (suggestion: LayoutSuggestion) => { - takeSnapshot(`Before ${suggestion.title}`); - setPreviewState(null); - setSuggestions(suggestions.filter(s => s.id !== suggestion.id)); + const handleUndo = () => { + if (snapshot) { + setNodes(snapshot.nodes); + setEdges(snapshot.edges); + setSnapshot(null); + setStatus('ready'); + } }; - const handleCancelPreview = () => { - if (previewState) { - setNodes(previewState.originalNodes); - setEdges(previewState.originalEdges); - setPreviewState(null); - } + const handleReset = () => { + setStatus('idle'); + setSnapshot(null); + setBestSuggestion(null); }; return ( -
- -

Visual Organizer

-
- - -
-
+
+ - {analysis && ( - -

Layout Analysis

-
-

Nodes: {analysis.metrics.nodeCount}

-

Edges: {analysis.metrics.edgeCount}

-

Issues: {analysis.issues.length}

-

Strengths: {analysis.strengths.length}

-
-
- )} - - -

Quick Layout Presets

-
- {getPresets().map(preset => ( - - ))} -
-
- - {suggestions.length > 0 && ( - -

AI Suggestions

-
- {suggestions.map((suggestion) => { - const isPreviewing = previewState?.suggestionId === suggestion.id; - const isOtherPreviewing = previewState !== null && !isPreviewing; - - if (isOtherPreviewing) return null; // Hide other suggestions while previewing - - return ( -
-
{suggestion.title}
-

{suggestion.description}

- -
- {!isPreviewing ? ( - <> - - - - ) : ( - <> - - - - )} -
-
- ); - })} -
-
- )} - - {snapshotHistory.length > 0 && ( - -

History & Comparison

-
- {snapshotHistory.map(snap => ( -
- {snap.name} - + {/* IDLE STATE: Main AI Button */} + {status === 'idle' && ( +
+
+
+
+
- ))} +
+ +
+
+ +
+

AI Visual Organizer

+

+ Click to instantly analyze and reorganize your flow for maximum clarity. +

+
+ +
- - )} + )} + + {/* ANALYZING STATE: Scanning Animation */} + {status === 'analyzing' && ( +
+
+
+
+
+ +
+
+

+ Analyzing Layout Logic... +

+
+ )} + + {/* READY STATE: Suggestion Found */} + {status === 'ready' && bestSuggestion && ( +
+
+ + Optimization Found! +
+ +
+
+ +

{bestSuggestion.title}

+
+

+ {bestSuggestion.description} +

+
+ +
+ + +
+
+ )} + + {/* APPLIED STATE: Success & Undo */} + {status === 'applied' && ( +
+
+ +
+ +
+

Beautifully Organized!

+

+ Your graph has been transformed. +

+
+ +
+ + +
+
+ )} + +
); }; diff --git a/src/components/editor/EditorHeader.tsx b/src/components/editor/EditorHeader.tsx index 0d54093..1e9cfaa 100644 --- a/src/components/editor/EditorHeader.tsx +++ b/src/components/editor/EditorHeader.tsx @@ -1,12 +1,14 @@ import { Link } from 'react-router-dom'; import { 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'; -import { useFlowStore } from '../../store'; +import { useStore } from 'zustand'; +import { useFlowStore, useDiagramStore } from '../../store'; +import { useMobileDetect } from '../../hooks/useMobileDetect'; import { exportToPng, exportToJpg, exportToSvg, - exportToTxt, downloadMermaid + exportToTxt, downloadMermaid, exportToJson } from '../../lib/exportUtils'; import { useState } from 'react'; import { SettingsModal } from '../Settings'; @@ -17,23 +19,30 @@ export function EditorHeader() { rightPanelOpen, setRightPanelOpen, theme, toggleTheme, focusMode, setFocusMode, - saveDiagram + saveDiagram, + aiMode, onlineProvider } = useFlowStore(); + const { isMobile } = useMobileDetect(); const [showSettings, setShowSettings] = useState(false); - const [isSaving, setIsSaving] = useState(false); + const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle'); const [showExportMenu, setShowExportMenu] = useState(false); const handleSave = () => { - setIsSaving(true); const name = prompt('Enter diagram name:', `Diagram ${new Date().toLocaleDateString()}`); if (name) { + setSaveStatus('saving'); 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); const viewport = document.querySelector('.react-flow__viewport') as HTMLElement; @@ -46,7 +55,7 @@ export function EditorHeader() { if (viewport) await exportToJpg(viewport); break; case 'svg': - exportToSvg(nodes, edges); + if (viewport) await exportToSvg(viewport); break; case 'txt': exportToTxt(nodes, edges); @@ -54,6 +63,9 @@ export function EditorHeader() { case 'code': downloadMermaid(nodes, edges); break; + case 'json': + exportToJson(nodes, edges); + break; } } catch (error) { console.error('Export failed:', error); @@ -67,37 +79,86 @@ export function EditorHeader() {
- SystemArchitect + SystemArchitect + {/* Panel toggles - hidden on mobile */}
-
+ + {/* History Controls */} +
+ + {!isMobile && ( + <> +
+
+ + +
+ + )}
+ {/* AI Mode Status - Desktop Only */} + {!isMobile && ( +
+ {aiMode === 'offline' ? ( + <> + + Local + + ) : aiMode === 'browser' ? ( + <> + + Browser + + ) : ( + <> + + Cloud + + )} +
+ )} + {/* Export Dropdown */} @@ -137,11 +201,12 @@ export function EditorHeader() { {showExportMenu && ( @@ -150,7 +215,11 @@ export function EditorHeader() {
+ + ))} +
+ + {/* Right: Actions */} +
+ +
+
+ ); +} diff --git a/src/components/editor/MonacoWrapper.tsx b/src/components/editor/MonacoWrapper.tsx new file mode 100644 index 0000000..2f1c86f --- /dev/null +++ b/src/components/editor/MonacoWrapper.tsx @@ -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 ( +
+ {/* Internal Badges */} +
+ + Mermaid + +
+
+ + Manual + +
+ + + {/* Floating Action Buttons */} +
+
+ +
+
+ + {/* Syntax Errors */} + {syntaxErrors.length > 0 && ( +
+
+ +
+

Syntax Error

+

{syntaxErrors[0].message}

+
+
+
+ )} +
+ ); +} diff --git a/src/components/nodes/CustomNodes.tsx b/src/components/nodes/CustomNodes.tsx index 1b1f802..8a09168 100644 --- a/src/components/nodes/CustomNodes.tsx +++ b/src/components/nodes/CustomNodes.tsx @@ -6,7 +6,7 @@ import { Database, Cpu, Users, Globe, Server, Zap, Play, Square, GitBranch } fro * High-contrast, accessible node color palette * Each color has a background, border, and text color for maximum readability */ -const NODE_STYLES = { +export const NODE_STYLES = { ai: { bg: 'bg-violet-500/15 dark:bg-violet-500/20', 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 @@ -415,6 +415,8 @@ export const EndNode = memo((props: any) => ); export const DecisionNode = memo((props: any) => ); export const DatabaseNode = memo((props: any) => ); +import { ShapeNode } from './ShapeNode'; + export const nodeTypes = { start: StartNode, startNode: StartNode, @@ -427,8 +429,20 @@ export const nodeTypes = { process: StandardNode, processNode: 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, system: SystemNode, default: StandardNode, group: GroupNode, + + // Custom Shape Node + 'custom-shape': ShapeNode, + 'shape': ShapeNode, // Alias }; diff --git a/src/components/nodes/ShapeNode.tsx b/src/components/nodes/ShapeNode.tsx new file mode 100644 index 0000000..b87c842 --- /dev/null +++ b/src/components/nodes/ShapeNode.tsx @@ -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 ( +
+ + + + + {/* Label Overlay */} +
+ + {data.label} + +
+ + {/* Handles - Positions might need to be dynamic based on shape, but default Top/Bottom/Left/Right usually works */} + + + + +
+ ); +}); diff --git a/src/components/ui/OrchestratorLoader.tsx b/src/components/ui/OrchestratorLoader.tsx index ac7df2a..f45e322 100644 --- a/src/components/ui/OrchestratorLoader.tsx +++ b/src/components/ui/OrchestratorLoader.tsx @@ -1,5 +1,5 @@ -import React from 'react'; + export function OrchestratorLoader() { return ( diff --git a/src/hooks/index.ts b/src/hooks/index.ts index fb83b52..e8cab00 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -4,3 +4,4 @@ export { useAIGeneration } from './useAIGeneration'; export { useKeyboardShortcuts, getShortcutDisplay } from './useKeyboardShortcuts'; +export { useMobileDetect, checkIsMobile, MOBILE_BREAKPOINT } from './useMobileDetect'; diff --git a/src/hooks/useAIGeneration.ts b/src/hooks/useAIGeneration.ts index c5507a5..fea1a99 100644 --- a/src/hooks/useAIGeneration.ts +++ b/src/hooks/useAIGeneration.ts @@ -28,17 +28,17 @@ export function useAIGeneration(): UseAIGenerationReturn { const { isLoading, error, setLoading, setError } = useUIStore(); const processAIResponse = useCallback( - (result: AIResponse) => { + async (result: AIResponse) => { if (!result.success || !result.mermaidCode) { throw new Error(result.error || 'Failed to generate diagram'); } setSourceCode(result.mermaidCode); - const { nodes: parsedNodes, edges: parsedEdges } = parseMermaid(result.mermaidCode); + const { nodes: parsedNodes, edges: parsedEdges } = await parseMermaid(result.mermaidCode); // Attach metadata if available if (result.metadata) { - parsedNodes.forEach((node) => { + parsedNodes.forEach((node: any) => { const label = (node.data.label as string) || ''; if (label && result.metadata && result.metadata[label]) { node.data.metadata = result.metadata[label] as NodeMetadata; diff --git a/src/hooks/useDiagramAPI.ts b/src/hooks/useDiagramAPI.ts new file mode 100644 index 0000000..825fa96 --- /dev/null +++ b/src/hooks/useDiagramAPI.ts @@ -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= + * 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]); +} diff --git a/src/hooks/useMobileDetect.ts b/src/hooks/useMobileDetect.ts new file mode 100644 index 0000000..da7531d --- /dev/null +++ b/src/hooks/useMobileDetect.ts @@ -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(() => ({ + 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 }; diff --git a/src/hooks/useVisualOrganizer.ts b/src/hooks/useVisualOrganizer.ts index f49c8c8..180cc68 100644 --- a/src/hooks/useVisualOrganizer.ts +++ b/src/hooks/useVisualOrganizer.ts @@ -1,7 +1,7 @@ import { useCallback, useMemo } from 'react'; import { useDiagramStore } from '../store'; import { useSettingsStore } from '../store/settingsStore'; -import { VisualOrganizer, createVisualOrganizer } from '../lib/visualOrganizer'; +import { createVisualOrganizer } from '../lib/visualOrganizer'; import { analyzeVisualLayout } from '../lib/aiService'; import type { LayoutSuggestion, VisualIssue, LayoutMetrics } from '../types/visualOrganization'; diff --git a/src/lib/aiService.ts b/src/lib/aiService.ts index 5f22c3b..cd95d63 100644 --- a/src/lib/aiService.ts +++ b/src/lib/aiService.ts @@ -14,40 +14,13 @@ export interface AIResponse { import { webLlmService } from './webLlmService'; import { visionService } from './visionService'; - -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
, , 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.`; +import { + SYSTEM_PROMPT, + SYSTEM_PROMPT_SIMPLE, + SYSTEM_PROMPT_COMPLEX, + SUGGEST_PROMPT, + VISUAL_ANALYSIS_PROMPT +} from './prompts'; /** @@ -92,20 +65,54 @@ async function callOnlineAI( if (provider === 'openai') { url = 'https://api.openai.com/v1/chat/completions'; 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 = { model: 'gpt-4o', - messages: [{ role: 'system', content: activePrompt }, ...messages], + messages: [{ role: 'system', content: activePrompt }, ...formattedMessages], response_format: { type: 'json_object' } }; } else if (provider === 'gemini') { - // Simple Gemini API call (v1beta) - url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=${apiKey}`; + // Gemini API with vision support - using gemini-2.0-flash-exp for better compatibility + 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 = { - contents: [{ - parts: [{ - text: `${activePrompt}\n\nTask: ${messages[messages.length - 1].content}` - }] - }], + contents: [{ parts }], generationConfig: { responseMimeType: 'application/json' } }; } else if (provider === 'ollama-cloud') { @@ -127,7 +134,14 @@ async function callOnlineAI( if (!response.ok) { let errorMsg = `${provider} error: ${response.status} ${response.statusText}`; 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 === 400) { + try { + const errorData = await response.json(); + errorMsg = errorData.error?.message || errorMsg; + } catch { /* ignore parse error */ } + } 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
. -- 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
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') { return complexity === 'simple' ? SYSTEM_PROMPT_SIMPLE : SYSTEM_PROMPT_COMPLEX; } @@ -386,74 +384,6 @@ export async function analyzeSVG( 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( code: 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 { + 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; + } +} diff --git a/src/lib/exportUtils.ts b/src/lib/exportUtils.ts index e7c887f..d295a63 100644 --- a/src/lib/exportUtils.ts +++ b/src/lib/exportUtils.ts @@ -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'; export async function exportToPng(element: HTMLElement): Promise { @@ -24,7 +24,7 @@ export async function exportToPng(element: HTMLElement): Promise { export async function exportToJpg(element: HTMLElement): Promise { try { - const { toJpeg } = await import('html-to-image'); + const dataUrl = await toJpeg(element, { backgroundColor: '#020617', quality: 0.95, @@ -44,23 +44,23 @@ export async function exportToJpg(element: HTMLElement): Promise { } } -export function exportToSvg(nodes: Node[], _edges: Edge[]): void { - // Basic SVG export logic (simplified for React Flow) - const svgContent = ` - - - Architecture Diagram Export (SVG) - - - ${nodes.map(n => ``).join('')} - ${nodes.map(n => `${(n.data as any).label || n.id}`).join('')} - - - `; - const blob = new Blob([svgContent], { type: 'image/svg+xml' }); - const url = URL.createObjectURL(blob); - downloadFile(url, `diagram-${getTimestamp()}.svg`); - URL.revokeObjectURL(url); +export async function exportToSvg(element: HTMLElement): Promise { + try { + const dataUrl = await toSvg(element, { + backgroundColor: '#020617', + filter: (node) => { + const className = node.className?.toString() || ''; + return !className.includes('react-flow__controls') && + !className.includes('react-flow__minimap') && + !className.includes('react-flow__panel'); + } + }); + + downloadFile(dataUrl, `diagram-${getTimestamp()}.svg`); + } catch (error) { + console.error('Failed to export SVG:', error); + throw error; + } } export function exportToTxt(nodes: Node[], edges: Edge[]): void { diff --git a/src/lib/layoutEngine.ts b/src/lib/layoutEngine.ts index f6f0094..bd6649b 100644 --- a/src/lib/layoutEngine.ts +++ b/src/lib/layoutEngine.ts @@ -1,22 +1,28 @@ import dagre from 'dagre'; import { type Node, type Edge } from '../store'; -const nodeWidth = 180; -const nodeHeight = 60; -const groupPadding = 40; -const groupTitleHeight = 50; -const groupGap = 60; // Gap between swimlane groups +// Enhanced constants for better spacing +const NODE_WIDTH = 180; +const NODE_HEIGHT = 60; +const GROUP_PADDING = 60; // Increased padding +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 { direction: 'TB' | 'LR' | 'BT' | 'RL'; nodeSpacing: number; rankSpacing: number; + smartOverlapResolution?: boolean; // Enable collision detection + optimizeForReadability?: boolean; // Prioritize clear flow } const defaultOptions: LayoutOptions = { direction: 'TB', - nodeSpacing: 40, - rankSpacing: 60, + nodeSpacing: 100, // Increased from 60 to prevent overlap + rankSpacing: 150, // Increased from 80 for edge labels + smartOverlapResolution: true, + optimizeForReadability: true, }; export function getLayoutedElements( @@ -85,7 +91,7 @@ export function getLayoutedElements( childNodes.forEach(child => finalNodes.push(child)); // Move Y down for next group - currentY += height + groupGap; + currentY += height + GROUP_GAP; }); // Layout orphan nodes (nodes without parent) to the right of groups @@ -130,8 +136,8 @@ function layoutGroupInternal( // Add nodes childNodes.forEach(node => { - const w = node.type === 'decision' ? 140 : nodeWidth; - const h = node.type === 'decision' ? 90 : nodeHeight; + const w = node.type === 'decision' ? 140 : NODE_WIDTH; + const h = node.type === 'decision' ? 90 : NODE_HEIGHT; subGraph.setNode(node.id, { width: w, height: h }); }); @@ -154,8 +160,8 @@ function layoutGroupInternal( const pos = subGraph.node(node.id); if (!pos) return; - const w = node.type === 'decision' ? 140 : nodeWidth; - const h = node.type === 'decision' ? 90 : nodeHeight; + const w = node.type === 'decision' ? 140 : NODE_WIDTH; + const h = node.type === 'decision' ? 90 : NODE_HEIGHT; const x = pos.x - w / 2; const y = pos.y - h / 2; @@ -175,14 +181,14 @@ function layoutGroupInternal( // Normalize positions to start at padding positionedChildren.forEach(child => { - child.position.x = child.position.x - minX + groupPadding; - child.position.y = child.position.y - minY + groupPadding + groupTitleHeight; + child.position.x = child.position.x - minX + GROUP_PADDING; + child.position.y = child.position.y - minY + GROUP_PADDING + GROUP_TITLE_HEIGHT; }); const contentWidth = maxX - minX; const contentHeight = maxY - minY; - const groupWidth = contentWidth + groupPadding * 2; - const groupHeight = contentHeight + groupPadding * 2 + groupTitleHeight; + const groupWidth = contentWidth + GROUP_PADDING * 2; + const groupHeight = contentHeight + GROUP_PADDING * 2 + GROUP_TITLE_HEIGHT; return { width: Math.max(groupWidth, 300), @@ -211,8 +217,8 @@ function layoutOrphanNodes( }); nodes.forEach(node => { - const w = node.type === 'decision' ? 140 : nodeWidth; - const h = node.type === 'decision' ? 90 : nodeHeight; + const w = node.type === 'decision' ? 140 : NODE_WIDTH; + const h = node.type === 'decision' ? 90 : NODE_HEIGHT; orphanGraph.setNode(node.id, { width: w, height: h }); }); @@ -231,8 +237,8 @@ function layoutOrphanNodes( const pos = orphanGraph.node(node.id); if (!pos) return node; - const w = node.type === 'decision' ? 140 : nodeWidth; - const h = node.type === 'decision' ? 90 : nodeHeight; + const w = node.type === 'decision' ? 140 : NODE_WIDTH; + const h = node.type === 'decision' ? 90 : NODE_HEIGHT; return { ...node, @@ -261,8 +267,8 @@ function layoutFlatNodes( }); nodes.forEach(node => { - const w = node.type === 'decision' ? 140 : nodeWidth; - const h = node.type === 'decision' ? 90 : nodeHeight; + const w = node.type === 'decision' ? 140 : NODE_WIDTH; + const h = node.type === 'decision' ? 90 : NODE_HEIGHT; flatGraph.setNode(node.id, { width: w, height: h }); }); @@ -278,8 +284,8 @@ function layoutFlatNodes( const pos = flatGraph.node(node.id); if (!pos) return node; - const w = node.type === 'decision' ? 140 : nodeWidth; - const h = node.type === 'decision' ? 90 : nodeHeight; + const w = node.type === 'decision' ? 140 : NODE_WIDTH; + const h = node.type === 'decision' ? 90 : NODE_HEIGHT; return { ...node, @@ -289,5 +295,130 @@ function layoutFlatNodes( } 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; +} + diff --git a/src/lib/mermaidParser.ts b/src/lib/mermaidParser.ts index bc4236b..180fda9 100644 --- a/src/lib/mermaidParser.ts +++ b/src/lib/mermaidParser.ts @@ -11,7 +11,7 @@ mermaid.initialize({ interface ParsedNode { id: 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; } @@ -35,11 +35,45 @@ function isDecisionLabel(label: string): boolean { return decisionKeywords.some(keyword => lowerLabel.includes(keyword)); } +/** + * Parse metadata from Mermaid comments + * Format: %% { "id": "nodeId", "metadata": { ... } } + */ +function parseMetadataComments(code: string): Map { + const metadataMap = new Map(); + + // 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 */ 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 .replace(/%%\{init:[^}]*\}%%/g, '') // Convert
,
,
to spaces in node labels @@ -48,6 +82,8 @@ function preprocessMermaidCode(code: string): string { .replace(/\r\n/g, '\n') // Remove empty lines at start .trim(); + + return cleaned; } 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 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({ id: sub.id, type: 'group', position: { x: 0, y: 0 }, data: { label: sub.title, - color: groupColors[groupIndex++ % groupColors.length] + color: groupColors[groupIndex++ % groupColors.length], + category }, style: {}, }); } + // Match metadata to nodes + const metadataMap = parseMetadataComments(cleanedCode); + // Process Nodes and Groups logic combined for (const [id, vertex] of Object.entries(vertices) as any[]) { // vertex: { id, text, type, styles, classes, ... } @@ -124,16 +175,77 @@ export async function parseMermaid(mermaidCode: string): Promise<{ nodes: Node[] } } - // Determine category for filtering - let category = 'filter-server'; - if (type === 'database') category = 'filter-db'; - else if (type === 'client') category = 'filter-client'; + // Determine category for filtering - Enhanced Logic + let category = 'filter-other'; // Default to other/flow + + // 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({ 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 }, - 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, extent: parentId ? 'parent' : undefined }); @@ -149,6 +261,7 @@ export async function parseMermaid(mermaidCode: string): Promise<{ nodes: Node[] animated: e.stroke === 'dotted', // Heuristic style: { strokeWidth: 2, + strokeOpacity: 0.5, strokeDasharray: e.stroke === 'dotted' ? '5,5' : undefined }, labelStyle: { fill: '#374151', fontWeight: 600, fontSize: 11 }, @@ -326,7 +439,10 @@ function parseMermaidRegex(mermaidCode: string): { nodes: Node[]; edges: Edge[] target: e.target, label: e.label, 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`); diff --git a/src/lib/prompts.ts b/src/lib/prompts.ts new file mode 100644 index 0000000..d9c606e --- /dev/null +++ b/src/lib/prompts.ts @@ -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
, , 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
. +- 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
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.`; diff --git a/src/lib/shapes.ts b/src/lib/shapes.ts new file mode 100644 index 0000000..cf8069a --- /dev/null +++ b/src/lib/shapes.ts @@ -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 = { + // 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' }, +]; diff --git a/src/lib/webLlmService.ts b/src/lib/webLlmService.ts index ffeb376..27b8050 100644 --- a/src/lib/webLlmService.ts +++ b/src/lib/webLlmService.ts @@ -16,11 +16,14 @@ export class WebLlmService { private isReady = false; // Track GPU Availability - public static async isSystemSupported(): Promise { + public static async isSystemSupported(): Promise { + // @ts-ignore if (!navigator.gpu) { - return false; + console.warn('WebGPU not supported in this environment'); + return null; } try { + // @ts-ignore const adapter = await navigator.gpu.requestAdapter(); return !!adapter; } catch (e) { diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index bb04a66..85cb197 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { - Settings, Zap, ChevronRight, Activity, ArrowRight, Sun, Moon, Eye, Upload + Settings, Zap, ChevronRight, Activity, ArrowRight, Sun, Moon, Eye, Upload, Trash2 } from 'lucide-react'; import { useFlowStore } from '../store'; import { SettingsModal } from '../components/Settings'; @@ -9,13 +9,15 @@ import { SettingsModal } from '../components/Settings'; export function Dashboard() { const navigate = useNavigate(); const { - savedDiagrams, theme, toggleTheme + savedDiagrams, theme, toggleTheme, deleteDiagram, clearAllDiagrams } = useFlowStore(); const [isUploading, setIsUploading] = useState(false); const [uploadProgress, setUploadProgress] = useState(0); const [showSettings, setShowSettings] = useState(false); + // ... (keep handleFileSelect) ... + const handleFileSelect = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) { @@ -37,11 +39,7 @@ export function Dashboard() { return (
- {/* Ambient Background */} -
-
-
- + {/* ... (keep background and header) ... */} {/* Top Navigation */}
window.location.reload()}> @@ -75,7 +73,7 @@ export function Dashboard() { /> {/* Main Content Area */} -
+

Design the
Unseen logic. @@ -86,6 +84,7 @@ export function Dashboard() {
+ {/* ... (keep Upload and Direct Access buttons) ... */}
- -

Drop files anywhere or click to start

@@ -123,31 +120,60 @@ export function Dashboard() {
{/* Compact Recent Intelligence */} -
- {savedDiagrams.length > 0 ? ( - [...savedDiagrams].reverse().slice(0, 3).map((diagram) => ( -
navigate(`/diagram?id=${diagram.id}`)} +
+ {savedDiagrams.length > 0 && ( +
+
- )) - ) : ( - [1, 2, 3].map(i => ( -
- )) + + Clear All + +
)} + +
+ {savedDiagrams.length > 0 ? ( + [...savedDiagrams].reverse().slice(0, 3).map((diagram) => ( +
navigate(`/diagram?id=${diagram.id}`)} + > +
+
+ +
+
+

{diagram.name}

+

{diagram.nodes.filter(n => n.type !== 'group').length} Entities

+
+
+ + {/* Delete Button (visible on hover) */} + +
+ )) + ) : ( + [1, 2, 3].map(i => ( +
+ )) + )} +
{savedDiagrams.length > 3 && ( diff --git a/src/pages/Editor.tsx b/src/pages/Editor.tsx index b641e35..30afc11 100644 --- a/src/pages/Editor.tsx +++ b/src/pages/Editor.tsx @@ -4,16 +4,34 @@ import { FlowCanvas } from '../components/FlowCanvas'; import { NodeDetailsPanel } from '../components/NodeDetailsPanel'; import InteractiveLegend from '../components/InteractiveLegend'; 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 { 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 { EditorHeader } from '../components/editor/EditorHeader'; +import { VisualOrganizerFAB } from '../components/VisualOrganizerFAB'; +import { useDiagramAPI } from '../hooks/useDiagramAPI'; 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 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) => { isResizing.current = true; @@ -101,7 +119,8 @@ export function Editor() { )}
- {(!leftPanelOpen && !focusMode) && ( + + {(!leftPanelOpen && !focusMode && !isMobile) && (
)} - {/* Empty Workspace */} - {nodes.length === 0 && !isLoading && ( + {/* Empty Workspace - Hidden on mobile (FAB provides access) */} + {nodes.length === 0 && !isLoading && !isMobile && (
@@ -149,22 +168,50 @@ export function Editor() { )}

- {/* Right Inspector Panel - Flex layout instead of absolute */} -
-
- Inspector - + {/* Right Inspector Panel - Sidebar on desktop, Sheet on mobile */} + {!isMobile ? ( +
+
+ Inspector + +
+
+ +
-
- -
-
+ ) : ( + rightPanelOpen && !focusMode && ( +
+
setRightPanelOpen(false)} + /> +
+
+
+
+
+ Inspector + +
+
+ +
+
+
+ ) + )} - {(nodes.length > 0 && !rightPanelOpen && !focusMode) && ( + {(nodes.length > 0 && !rightPanelOpen && !focusMode && !isMobile) && ( )}
+ + {/* Mobile FAB - Opens Bottom Sheet */} + {isMobile && !mobileEditorOpen && !focusMode && ( + <> + {/* Visual Organizer FAB (Above Main FAB) */} +
+
+ +
+
+ + + + )} + + {/* Mobile Bottom Sheet */} + {isMobile && mobileEditorOpen && ( +
+ {/* Backdrop */} +
setMobileEditorOpen(false)} + /> + + {/* Sheet Content */} +
+ {/* Handle */} +
+
+
+ + {/* Header */} +
+
+
+ +
+ System Architect +
+ +
+ + {/* Content */} +
+ +
+
+
+ )}
+ + ); } diff --git a/src/plugins/types.ts b/src/plugins/types.ts new file mode 100644 index 0000000..774bdc6 --- /dev/null +++ b/src/plugins/types.ts @@ -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; +} diff --git a/src/store/diagramStore.ts b/src/store/diagramStore.ts index 48fa6ca..5bbf829 100644 --- a/src/store/diagramStore.ts +++ b/src/store/diagramStore.ts @@ -4,6 +4,7 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; +import { temporal } from 'zundo'; import { addEdge, applyNodeChanges, applyEdgeChanges } from '@xyflow/react'; import type { NodeChange, EdgeChange } from '@xyflow/react'; import type { Node, Edge, Connection, SavedDiagram, EdgeStyle } from '../types'; @@ -40,6 +41,7 @@ interface DiagramState { saveDiagram: (name: string) => void; loadDiagram: (id: string) => void; deleteDiagram: (id: string) => void; + clearAllDiagrams: () => void; getSavedDiagrams: () => SavedDiagram[]; // Reset @@ -58,130 +60,148 @@ const initialState = { }; export const useDiagramStore = create()( - persist( - (set, get) => ({ - ...initialState, + temporal( + persist( + (set, get) => ({ + ...initialState, - // Setters - setNodes: (nodes) => set({ nodes }), - setEdges: (edges) => set({ edges }), - setSourceCode: (sourceCode) => set({ sourceCode }), - setEdgeStyle: (edgeStyle) => set({ edgeStyle }), - setGenerationComplexity: (generationComplexity) => set({ generationComplexity }), + // Setters + setNodes: (nodes) => set({ nodes }), + setEdges: (edges) => set({ edges }), + setSourceCode: (sourceCode) => set({ sourceCode }), + setEdgeStyle: (edgeStyle) => set({ edgeStyle }), + setGenerationComplexity: (generationComplexity) => set({ generationComplexity }), - // React Flow handlers - 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) { + // React Flow handlers + onNodesChange: (changes) => { set({ - nodes: diagram.nodes, - edges: diagram.edges, - sourceCode: diagram.sourceCode, + nodes: applyNodeChanges(changes, get().nodes), }); - } - }, + }, - 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) }); - }, + onEdgesChange: (changes) => { + set({ + edges: applyEdgeChanges(changes, get().edges), + }); + }, - getSavedDiagrams: () => { - try { - const stored = localStorage.getItem('flowgen_diagrams'); - return stored ? JSON.parse(stored) : []; - } catch { - return []; - } - }, + onConnect: (connection) => { + set({ + edges: addEdge(connection, get().edges), + }); + }, - // 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, + // 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({ + 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. } ) ); diff --git a/src/store/index.ts b/src/store/index.ts index 45a8744..10bbec7 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -53,6 +53,7 @@ export function useFlowStore() { saveDiagram: diagram.saveDiagram, loadDiagram: diagram.loadDiagram, deleteDiagram: diagram.deleteDiagram, + clearAllDiagrams: diagram.clearAllDiagrams, // Settings state apiKey: settings.apiKey, @@ -75,6 +76,11 @@ export function useFlowStore() { leftPanelOpen: ui.leftPanelOpen, rightPanelOpen: ui.rightPanelOpen, focusMode: ui.focusMode, + mobileEditorOpen: ui.mobileEditorOpen, + inputDescription: ui.inputDescription, + inputActiveTab: ui.inputActiveTab, + inputImageUrl: ui.inputImageUrl, + mermaidCode: ui.mermaidCode, activeFilters: ui.activeFilters, isLoading: ui.isLoading, error: ui.error, @@ -84,6 +90,12 @@ export function useFlowStore() { setLeftPanelOpen: ui.setLeftPanelOpen, setRightPanelOpen: ui.setRightPanelOpen, setFocusMode: ui.setFocusMode, + setMobileEditorOpen: ui.setMobileEditorOpen, + setInputDescription: ui.setInputDescription, + setInputActiveTab: ui.setInputActiveTab, + setInputImageUrl: ui.setInputImageUrl, + setMermaidCode: ui.setMermaidCode, + clearInputs: ui.clearInputs, toggleFilter: ui.toggleFilter, setLoading: ui.setLoading, setError: ui.setError, diff --git a/src/store/pluginStore.ts b/src/store/pluginStore.ts new file mode 100644 index 0000000..2a9cb50 --- /dev/null +++ b/src/store/pluginStore.ts @@ -0,0 +1,63 @@ +import { create } from 'zustand'; +import type { Plugin, PluginContext, ToolbarItem } from '../plugins/types'; + +interface PluginState { + plugins: Record; + toolbarItems: ToolbarItem[]; + + // Actions + registerPlugin: (plugin: Plugin) => void; + unregisterPlugin: (pluginId: string) => void; + + // Internal API for Plugins + _registerToolbarItem: (item: ToolbarItem) => void; +} + +export const usePluginStore = create((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] + })); + } +})); diff --git a/src/store/settingsStore.ts b/src/store/settingsStore.ts index dfc25bc..ecdfb89 100644 --- a/src/store/settingsStore.ts +++ b/src/store/settingsStore.ts @@ -5,6 +5,7 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; import type { AIMode, OnlineProvider, Theme } from '../types'; +import { checkIsMobile } from '../hooks/useMobileDetect'; interface SettingsState { // AI Configuration @@ -39,8 +40,19 @@ const getInitialTheme = (): Theme => { 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 = { - aiMode: 'offline' as AIMode, + aiMode: getInitialAiMode(), onlineProvider: 'openai' as OnlineProvider, apiKey: '', ollamaUrl: 'http://localhost:11434', diff --git a/src/store/uiStore.ts b/src/store/uiStore.ts index 309564e..bb23874 100644 --- a/src/store/uiStore.ts +++ b/src/store/uiStore.ts @@ -10,6 +10,13 @@ interface UIState { leftPanelOpen: boolean; rightPanelOpen: boolean; focusMode: boolean; + mobileEditorOpen: boolean; // Mobile bottom sheet state + + // Input Panel persistence + inputDescription: string; + inputActiveTab: 'image' | 'code' | 'describe'; + inputImageUrl: string | null; + mermaidCode: string; // Selection selectedNode: Node | null; @@ -26,6 +33,14 @@ interface UIState { setRightPanelOpen: (open: boolean) => void; setFocusMode: (focusMode: boolean) => 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; @@ -38,13 +53,18 @@ interface UIState { 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()((set, get) => ({ // Initial state leftPanelOpen: true, rightPanelOpen: false, focusMode: false, + mobileEditorOpen: false, + inputDescription: '', + inputActiveTab: 'image', + inputImageUrl: null, + mermaidCode: '', selectedNode: null, activeFilters: DEFAULT_FILTERS, isLoading: false, @@ -55,6 +75,14 @@ export const useUIStore = create()((set, get) => ({ setRightPanelOpen: (rightPanelOpen) => set({ rightPanelOpen }), setFocusMode: (focusMode) => set({ 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 setSelectedNode: (selectedNode) => { diff --git a/src/styles/ui.css b/src/styles/ui.css index f1d899d..46f0533 100644 --- a/src/styles/ui.css +++ b/src/styles/ui.css @@ -338,4 +338,82 @@ .badge-purple { background: rgba(139, 92, 246, 0.15); 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; } \ No newline at end of file