feat: add mobile-first UX improvements and theme-aware exports

- Mobile hamburger header with slide-in menu
- Mobile bottom action bar for canvas controls
- Full-screen settings modal on mobile
- Mobile empty state with Get Started prompt
- Theme-aware PNG/JPG/SVG exports (respects light/dark mode)
- Added file-saver package for reliable cross-browser downloads
- Updated README with new features and Docker Hub instructions
This commit is contained in:
SysVis AI 2025-12-29 09:58:43 +07:00
parent a4793bf996
commit 5c4b83203c
41 changed files with 4150 additions and 3720 deletions

View file

@ -1,23 +1,23 @@
# Stage 1: Build the application # Stage 1: Build the application
FROM node:20-alpine AS builder FROM node:20-alpine AS builder
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm ci RUN npm ci
COPY . . COPY . .
RUN npm run build RUN npm run build
# Stage 2: Serve with Nginx # Stage 2: Serve with Nginx
FROM nginx:alpine FROM nginx:alpine
# Copy built assets from builder stage # Copy built assets from builder stage
COPY --from=builder /app/dist /usr/share/nginx/html COPY --from=builder /app/dist /usr/share/nginx/html
# Copy custom nginx config # Copy custom nginx config
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80 EXPOSE 80
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]

View file

@ -1,35 +1,35 @@
version: '3.8' version: '3.8'
services: services:
webapp: webapp:
build: . build: .
container_name: kv-graph-web container_name: kv-graph-web
restart: always restart: always
ports: ports:
- "8338:80" - "8338:80"
# Optional if you want to have Ollama running in a container # Optional if you want to have Ollama running in a container
depends_on: depends_on:
- ollama - ollama
# Optional if you want to have Ollama running in a container # Optional if you want to have Ollama running in a container
ollama: ollama:
image: ollama/ollama:latest image: ollama/ollama:latest
container_name: ollama-service container_name: ollama-service
restart: always restart: always
ports: ports:
- "11434:11434" - "11434:11434"
volumes: volumes:
- ./ollama_data:/root/.ollama - ./ollama_data:/root/.ollama
environment: environment:
- OLLAMA_KEEP_ALIVE=24h - OLLAMA_KEEP_ALIVE=24h
- OLLAMA_ORIGINS="*" - OLLAMA_ORIGINS="*"
# NVIDIA GPU Support Configuration # NVIDIA GPU Support Configuration
deploy: deploy:
resources: resources:
reservations: reservations:
devices: devices:
- driver: nvidia - driver: nvidia
count: 1 count: 1
capabilities: [ gpu ] capabilities: [ gpu ]
# Fallback for systems without 'deploy' support (older compose versions) or explicit runtime # Fallback for systems without 'deploy' support (older compose versions) or explicit runtime
# runtime: nvidia # runtime: nvidia

View file

@ -1,21 +1,36 @@
server { server {
listen 80; listen 80;
# Enable gzip compression # Enable gzip compression
gzip on; gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
# Root directory for the app # Root directory for the app
location / { location / {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html index.htm; index index.html index.htm;
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
# NOTE: Ollama proxy is NOT included by default to allow standalone operation. # Proxy Ollama API requests
# The app works with Browser AI (WebLLM/Transformers.js) without any external services. # This solves Mixed Content (HTTPS -> HTTP) and CORS issues
# location /api/ {
# If you need to proxy requests to Ollama, either: proxy_pass http://ollama:11434/api/;
# 1. Set the Ollama URL directly in the app settings (e.g., http://your-nas-ip:11434) proxy_set_header Host $host;
# 2. Or mount a custom nginx.conf with your proxy configuration proxy_set_header X-Real-IP $remote_addr;
} proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# CORS headers (redundant if OLLAMA_ORIGINS is set, but good for safety)
add_header 'Access-Control-Allow-Origin' '*' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range' always;
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain; charset=utf-8';
add_header 'Content-Length' 0;
return 204;
}
}
}

108
package-lock.json generated
View file

@ -13,10 +13,12 @@
"@mlc-ai/web-llm": "^0.2.80", "@mlc-ai/web-llm": "^0.2.80",
"@monaco-editor/react": "^4.7.0", "@monaco-editor/react": "^4.7.0",
"@types/dagre": "^0.7.53", "@types/dagre": "^0.7.53",
"@types/file-saver": "^2.0.7",
"@types/randomcolor": "^0.5.9", "@types/randomcolor": "^0.5.9",
"@xyflow/react": "^12.10.0", "@xyflow/react": "^12.10.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dagre": "^0.8.5", "dagre": "^0.8.5",
"file-saver": "^2.0.5",
"html-to-image": "^1.11.13", "html-to-image": "^1.11.13",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"mermaid": "^11.12.2", "mermaid": "^11.12.2",
@ -170,7 +172,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5", "@babel/generator": "^7.28.5",
@ -577,7 +578,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
@ -621,7 +621,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@ -1236,9 +1235,9 @@
} }
}, },
"node_modules/@exodus/bytes": { "node_modules/@exodus/bytes": {
"version": "1.6.0", "version": "1.7.0",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.6.0.tgz", "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.7.0.tgz",
"integrity": "sha512-y32mI9627q5LR/L8fLc4YyDRJQOi+jK0D9okzLilAdiU3F9we3zC7Y7CFrR/8vAvUyv7FgBAYcNHtvbmhKCFcw==", "integrity": "sha512-5i+BtvujK/vM07YCGDyz4C4AyDzLmhxHMtM5HpUyPRtJPBdFPsj290ffXW+UXY21/G7GtXeHD2nRmq0T1ShyQQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -2736,7 +2735,8 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
@ -3067,6 +3067,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/file-saver": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.7.tgz",
"integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==",
"license": "MIT"
},
"node_modules/@types/geojson": { "node_modules/@types/geojson": {
"version": "7946.0.16", "version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
@ -3174,7 +3180,6 @@
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
@ -3185,7 +3190,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
@ -3266,7 +3270,6 @@
"integrity": "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==", "integrity": "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.50.1", "@typescript-eslint/scope-manager": "8.50.1",
"@typescript-eslint/types": "8.50.1", "@typescript-eslint/types": "8.50.1",
@ -3688,7 +3691,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@ -3739,6 +3741,7 @@
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@ -3884,7 +3887,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@ -4187,7 +4189,6 @@
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz", "resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz",
"integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==", "integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10" "node": ">=0.10"
} }
@ -4588,7 +4589,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC", "license": "ISC",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@ -4817,12 +4817,13 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/dompurify": { "node_modules/dompurify": {
"version": "3.2.7", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
"license": "(MPL-2.0 OR Apache-2.0)", "license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": { "optionalDependencies": {
"@types/trusted-types": "^2.0.7" "@types/trusted-types": "^2.0.7"
@ -4969,7 +4970,6 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@ -5238,6 +5238,12 @@
"node": ">=16.0.0" "node": ">=16.0.0"
} }
}, },
"node_modules/file-saver": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
"integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==",
"license": "MIT"
},
"node_modules/fill-range": { "node_modules/fill-range": {
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@ -5911,7 +5917,6 @@
"integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@acemir/cssom": "^0.9.28", "@acemir/cssom": "^0.9.28",
"@asamuzakjp/dom-selector": "^6.7.6", "@asamuzakjp/dom-selector": "^6.7.6",
@ -6436,6 +6441,7 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"lz-string": "bin/bin.js" "lz-string": "bin/bin.js"
} }
@ -6451,15 +6457,15 @@
} }
}, },
"node_modules/marked": { "node_modules/marked": {
"version": "14.0.0", "version": "16.4.2",
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz",
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==",
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"marked": "bin/marked.js" "marked": "bin/marked.js"
}, },
"engines": { "engines": {
"node": ">= 18" "node": ">= 20"
} }
}, },
"node_modules/matcher": { "node_modules/matcher": {
@ -6509,18 +6515,6 @@
"uuid": "^11.1.0" "uuid": "^11.1.0"
} }
}, },
"node_modules/mermaid/node_modules/marked": {
"version": "16.4.2",
"resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz",
"integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/micromatch": { "node_modules/micromatch": {
"version": "4.0.8", "version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
@ -6615,6 +6609,29 @@
"marked": "14.0.0" "marked": "14.0.0"
} }
}, },
"node_modules/monaco-editor/node_modules/dompurify": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
"license": "(MPL-2.0 OR Apache-2.0)",
"peer": true,
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/monaco-editor/node_modules/marked": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
"license": "MIT",
"peer": true,
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@ -6929,7 +6946,6 @@
"integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"prettier": "bin/prettier.cjs" "prettier": "bin/prettier.cjs"
}, },
@ -7033,6 +7049,7 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1", "ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0", "ansi-styles": "^5.0.0",
@ -7048,6 +7065,7 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=10" "node": ">=10"
}, },
@ -7129,7 +7147,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@ -7139,7 +7156,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@ -7152,7 +7168,8 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/react-refresh": { "node_modules/react-refresh": {
"version": "0.18.0", "version": "0.18.0",
@ -7809,9 +7826,9 @@
} }
}, },
"node_modules/ts-api-utils": { "node_modules/ts-api-utils": {
"version": "2.1.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.3.0.tgz",
"integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", "integrity": "sha512-6eg3Y9SF7SsAvGzRHQvvc1skDAhwI4YQ32ui1scxD1Ccr0G5qIIbUBT3pFTKX8kmWIQClHobtUdNuaBgwdfdWg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -7868,7 +7885,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@ -7988,7 +8004,6 @@
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.27.0", "esbuild": "^0.27.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@ -8393,7 +8408,6 @@
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.28.tgz", "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.28.tgz",
"integrity": "sha512-EgnDOXs8+hBVm6mq3/S89Kiwzh5JRbn7w2wXwbrMRyKy/8dOFsLvuIfC+x19ZdtaDc0tA9rQmdZzbqqNHG44wA==", "integrity": "sha512-EgnDOXs8+hBVm6mq3/S89Kiwzh5JRbn7w2wXwbrMRyKy/8dOFsLvuIfC+x19ZdtaDc0tA9rQmdZzbqqNHG44wA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"lib0": "^0.2.99" "lib0": "^0.2.99"
}, },
@ -8425,7 +8439,6 @@
"integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
@ -8466,7 +8479,6 @@
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz",
"integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==", "integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12.20.0" "node": ">=12.20.0"
}, },

View file

@ -31,10 +31,12 @@
"@mlc-ai/web-llm": "^0.2.80", "@mlc-ai/web-llm": "^0.2.80",
"@monaco-editor/react": "^4.7.0", "@monaco-editor/react": "^4.7.0",
"@types/dagre": "^0.7.53", "@types/dagre": "^0.7.53",
"@types/file-saver": "^2.0.7",
"@types/randomcolor": "^0.5.9", "@types/randomcolor": "^0.5.9",
"@xyflow/react": "^12.10.0", "@xyflow/react": "^12.10.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dagre": "^0.8.5", "dagre": "^0.8.5",
"file-saver": "^2.0.5",
"html-to-image": "^1.11.13", "html-to-image": "^1.11.13",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"mermaid": "^11.12.2", "mermaid": "^11.12.2",

View file

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

View file

@ -1,38 +1,38 @@
# Check if Ollama is installed # Check if Ollama is installed
if (-not (Get-Command ollama -ErrorAction SilentlyContinue)) { if (-not (Get-Command ollama -ErrorAction SilentlyContinue)) {
Write-Host "Error: Ollama is not installed or not in your PATH." -ForegroundColor Red Write-Host "Error: Ollama is not installed or not in your PATH." -ForegroundColor Red
Write-Host "Please install Ollama from https://ollama.com/" Write-Host "Please install Ollama from https://ollama.com/"
exit 1 exit 1
} }
Write-Host "Checking local Ollama service status..." -ForegroundColor Cyan Write-Host "Checking local Ollama service status..." -ForegroundColor Cyan
try { try {
$status = Invoke-RestMethod -Uri "http://localhost:11434/api/tags" -ErrorAction Stop $status = Invoke-RestMethod -Uri "http://localhost:11434/api/tags" -ErrorAction Stop
Write-Host "Ollama service is running!" -ForegroundColor Green Write-Host "Ollama service is running!" -ForegroundColor Green
} catch { } catch {
Write-Host "Error: Could not connect to Ollama at http://localhost:11434" -ForegroundColor Red Write-Host "Error: Could not connect to Ollama at http://localhost:11434" -ForegroundColor Red
Write-Host "Please ensure 'ollama serve' is running in another terminal." Write-Host "Please ensure 'ollama serve' is running in another terminal."
exit 1 exit 1
} }
# Define recommended models # Define recommended models
$models = @( $models = @(
@{ Name = "moondream"; Desc = "Tiny, fast vision model (1.7GB). Best for low-end hardware." }, @{ Name = "moondream"; Desc = "Tiny, fast vision model (1.7GB). Best for low-end hardware." },
@{ Name = "llava-phi3"; Desc = "High-performance small vision model (2.3GB). Good balance." }, @{ Name = "llava-phi3"; Desc = "High-performance small vision model (2.3GB). Good balance." },
@{ Name = "llama3"; Desc = "Fast standard LLM for text logic (4.7GB)." } @{ Name = "llama3"; Desc = "Fast standard LLM for text logic (4.7GB)." }
) )
Write-Host "`nReady to pull recommended models:" -ForegroundColor Yellow Write-Host "`nReady to pull recommended models:" -ForegroundColor Yellow
foreach ($m in $models) { foreach ($m in $models) {
Write-Host " - $($m.Name): $($m.Desc)" Write-Host " - $($m.Name): $($m.Desc)"
} }
$confirm = Read-Host "`nDo you want to pull these models now? (Y/n)" $confirm = Read-Host "`nDo you want to pull these models now? (Y/n)"
if ($confirm -eq 'n') { exit } if ($confirm -eq 'n') { exit }
foreach ($m in $models) { foreach ($m in $models) {
Write-Host "`nPulling $($m.Name)..." -ForegroundColor Cyan Write-Host "`nPulling $($m.Name)..." -ForegroundColor Cyan
ollama pull $m.Name ollama pull $m.Name
} }
Write-Host "`nAll models ready! You can now select them in the KV-Graph settings." -ForegroundColor Green Write-Host "`nAll models ready! You can now select them in the KV-Graph settings." -ForegroundColor Green

View file

@ -231,99 +231,156 @@ export function FlowCanvas() {
size={1} size={1}
/> />
{/* Control Panel - Top Right (Unified Toolkit) */} {/* Control Panel - Top Right (Unified Toolkit) - Desktop Only */}
<Panel position="top-right" className={`!m-4 !mr-6 flex flex-col items-end gap-3 z-50 transition-all duration-300 ${focusMode ? '!mt-20' : ''}`}> {!isMobile && (
<div className="relative"> <Panel position="top-right" className={`!m-4 !mr-6 flex flex-col items-end gap-3 z-50 transition-all duration-300 ${focusMode ? '!mt-20' : ''}`}>
<button <div className="relative">
onClick={() => setShowToolkit(!showToolkit)} <button
className={` onClick={() => setShowToolkit(!showToolkit)}
className={`
h-10 px-4 flex items-center gap-2 rounded-xl transition-all shadow-lg backdrop-blur-md border outline-none h-10 px-4 flex items-center gap-2 rounded-xl transition-all shadow-lg backdrop-blur-md border outline-none
${showToolkit ${showToolkit
? 'bg-blue-600 text-white border-blue-500 ring-2 ring-blue-500/20' ? 'bg-blue-600 text-white border-blue-500 ring-2 ring-blue-500/20'
: 'bg-white/90 dark:bg-surface/90 border-slate-200 dark:border-white/10 text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-white dark:hover:bg-surface'} : 'bg-white/90 dark:bg-surface/90 border-slate-200 dark:border-white/10 text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white hover:bg-white dark:hover:bg-surface'}
`} `}
> >
<Settings2 className="w-4 h-4" /> <Settings2 className="w-4 h-4" />
<span className="text-xs font-bold uppercase tracking-wider">Toolkit</span> <span className="text-xs font-bold uppercase tracking-wider">Toolkit</span>
<ChevronDown className={`w-3 h-3 transition-transform duration-200 ${showToolkit ? 'rotate-180' : ''}`} /> <ChevronDown className={`w-3 h-3 transition-transform duration-200 ${showToolkit ? 'rotate-180' : ''}`} />
</button> </button>
{/* Dropdown Menu */} {/* Dropdown Menu */}
{showToolkit && ( {showToolkit && (
<div className="absolute top-full right-0 mt-2 w-56 p-2 rounded-2xl bg-white/95 dark:bg-[#0B1221]/95 backdrop-blur-xl border border-slate-200 dark:border-white/10 shadow-2xl flex flex-col gap-2 animate-in fade-in slide-in-from-top-2 duration-200 origin-top-right"> <div className="absolute top-full right-0 mt-2 w-56 p-2 rounded-2xl bg-white/95 dark:bg-[#0B1221]/95 backdrop-blur-xl border border-slate-200 dark:border-white/10 shadow-2xl flex flex-col gap-2 animate-in fade-in slide-in-from-top-2 duration-200 origin-top-right">
{/* Section: Interaction Mode */} {/* Section: Interaction Mode */}
<div className="p-1"> <div className="p-1">
<span className="text-[9px] font-black uppercase tracking-widest text-slate-400 dark:text-slate-500 px-2 mb-1 block">Mode</span> <span className="text-[9px] font-black uppercase tracking-widest text-slate-400 dark:text-slate-500 px-2 mb-1 block">Mode</span>
<div className="grid grid-cols-2 gap-1 bg-slate-100 dark:bg-white/5 p-1 rounded-lg"> <div className="grid grid-cols-2 gap-1 bg-slate-100 dark:bg-white/5 p-1 rounded-lg">
<button <button
onClick={() => setIsSelectionMode(false)} onClick={() => setIsSelectionMode(false)}
className={`flex flex-col items-center justify-center py-2 rounded-md transition-all ${!isSelectionMode className={`flex flex-col items-center justify-center py-2 rounded-md transition-all ${!isSelectionMode
? 'bg-white dark:bg-white/10 text-blue-600 dark:text-blue-400 shadow-sm' ? 'bg-white dark:bg-white/10 text-blue-600 dark:text-blue-400 shadow-sm'
: 'text-slate-500 dark:text-slate-400 hover:bg-black/5 dark:hover:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:bg-black/5 dark:hover:bg-white/5'
}`} }`}
> >
<Hand className="w-4 h-4 mb-1" /> <Hand className="w-4 h-4 mb-1" />
<span className="text-[9px] font-bold">Pan</span> <span className="text-[9px] font-bold">Pan</span>
</button> </button>
<button <button
onClick={() => setIsSelectionMode(true)} onClick={() => setIsSelectionMode(true)}
className={`flex flex-col items-center justify-center py-2 rounded-md transition-all ${isSelectionMode className={`flex flex-col items-center justify-center py-2 rounded-md transition-all ${isSelectionMode
? 'bg-white dark:bg-white/10 text-blue-600 dark:text-blue-400 shadow-sm' ? 'bg-white dark:bg-white/10 text-blue-600 dark:text-blue-400 shadow-sm'
: 'text-slate-500 dark:text-slate-400 hover:bg-black/5 dark:hover:bg-white/5' : 'text-slate-500 dark:text-slate-400 hover:bg-black/5 dark:hover:bg-white/5'
}`} }`}
> >
<MousePointer2 className="w-4 h-4 mb-1" /> <MousePointer2 className="w-4 h-4 mb-1" />
<span className="text-[9px] font-bold">Select</span> <span className="text-[9px] font-bold">Select</span>
</button> </button>
</div>
</div>
<div className="h-px bg-slate-200 dark:bg-white/10 mx-2" />
{/* Section: View Controls */}
<div className="p-1">
<span className="text-[9px] font-black uppercase tracking-widest text-slate-400 dark:text-slate-500 px-2 mb-1 block">View</span>
<div className="flex bg-slate-100 dark:bg-white/5 rounded-lg p-0.5 divide-x divide-slate-200 dark:divide-white/5 border border-slate-200 dark:border-white/5">
<ToolkitButton icon={Minus} onClick={() => zoomOut()} label="Out" />
<ToolkitButton icon={Plus} onClick={() => zoomIn()} label="In" />
<ToolkitButton icon={Maximize} onClick={handleResetView} label="Fit" />
</div>
</div>
<div className="h-px bg-slate-200 dark:bg-white/10 mx-2" />
{/* Section: Layout & Overlays */}
<div className="p-1 space-y-1">
<span className="text-[9px] font-black uppercase tracking-widest text-slate-400 dark:text-slate-500 px-2 mb-1 block">Actions</span>
<MenuButton
icon={Wand2}
label="Auto Layout"
active={false}
onClick={handleAutoLayout}
/>
<MenuButton
icon={edgeStyle === 'curved' ? Spline : Minus}
label={edgeStyle === 'curved' ? 'Edge Style: Curved' : 'Edge Style: Straight'}
iconClass={edgeStyle === 'straight' ? 'rotate-45' : ''}
active={false}
onClick={() => setEdgeStyle(edgeStyle === 'curved' ? 'straight' : 'curved')}
/>
<MenuButton
icon={Map}
label="MiniMap Overlay"
active={showMiniMap}
onClick={() => setShowMiniMap(!showMiniMap)}
/>
</div> </div>
</div> </div>
)}
</div>
<div className="h-px bg-slate-200 dark:bg-white/10 mx-2" /> </Panel>
)}
{/* Section: View Controls */} {/* Mobile Bottom Action Bar */}
<div className="p-1"> {isMobile && nodes.length > 0 && !focusMode && (
<span className="text-[9px] font-black uppercase tracking-widest text-slate-400 dark:text-slate-500 px-2 mb-1 block">View</span> <Panel position="bottom-center" className="!mb-24 z-50">
<div className="flex bg-slate-100 dark:bg-white/5 rounded-lg p-0.5 divide-x divide-slate-200 dark:divide-white/5 border border-slate-200 dark:border-white/5"> <div className="flex items-center gap-1 px-2 py-2 rounded-2xl bg-white/95 dark:bg-slate-900/95 backdrop-blur-xl border border-slate-200 dark:border-white/10 shadow-2xl">
<ToolkitButton icon={Minus} onClick={() => zoomOut()} label="Out" /> {/* Zoom Out */}
<ToolkitButton icon={Plus} onClick={() => zoomIn()} label="In" /> <button
<ToolkitButton icon={Maximize} onClick={handleResetView} label="Fit" /> onClick={() => zoomOut()}
</div> className="w-11 h-11 flex items-center justify-center rounded-xl text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10 active:scale-95 transition-all"
</div> title="Zoom Out"
>
<Minus className="w-5 h-5" />
</button>
<div className="h-px bg-slate-200 dark:bg-white/10 mx-2" /> {/* Zoom In */}
<button
onClick={() => zoomIn()}
className="w-11 h-11 flex items-center justify-center rounded-xl text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10 active:scale-95 transition-all"
title="Zoom In"
>
<Plus className="w-5 h-5" />
</button>
{/* Section: Layout & Overlays */} {/* Fit View */}
<div className="p-1 space-y-1"> <button
<span className="text-[9px] font-black uppercase tracking-widest text-slate-400 dark:text-slate-500 px-2 mb-1 block">Actions</span> onClick={handleResetView}
className="w-11 h-11 flex items-center justify-center rounded-xl text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10 active:scale-95 transition-all"
title="Fit to View"
>
<Maximize className="w-5 h-5" />
</button>
<MenuButton {/* Divider */}
icon={Wand2} <div className="w-px h-6 bg-slate-200 dark:bg-white/10 mx-1" />
label="Auto Layout"
active={false}
onClick={handleAutoLayout}
/>
<MenuButton {/* Auto Layout */}
icon={edgeStyle === 'curved' ? Spline : Minus} <button
label={edgeStyle === 'curved' ? 'Edge Style: Curved' : 'Edge Style: Straight'} onClick={handleAutoLayout}
iconClass={edgeStyle === 'straight' ? 'rotate-45' : ''} className="w-11 h-11 flex items-center justify-center rounded-xl text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10 active:scale-95 transition-all"
active={false} title="Auto Layout"
onClick={() => setEdgeStyle(edgeStyle === 'curved' ? 'straight' : 'curved')} >
/> <Wand2 className="w-5 h-5" />
</button>
<MenuButton {/* Toggle Edge Style */}
icon={Map} <button
label="MiniMap Overlay" onClick={() => setEdgeStyle(edgeStyle === 'curved' ? 'straight' : 'curved')}
active={showMiniMap} className="w-11 h-11 flex items-center justify-center rounded-xl text-slate-500 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-white/10 active:scale-95 transition-all"
onClick={() => setShowMiniMap(!showMiniMap)} title={edgeStyle === 'curved' ? 'Switch to Straight Edges' : 'Switch to Curved Edges'}
/> >
</div> {edgeStyle === 'curved' ? <Spline className="w-5 h-5" /> : <Minus className="w-5 h-5 rotate-45" />}
</div> </button>
)} </div>
</div> </Panel>
)}
</Panel>
{/* MiniMap Container - Bottom Right (Hidden on Mobile) */} {/* MiniMap Container - Bottom Right (Hidden on Mobile) */}
<Panel position="bottom-right" className="!m-4 z-40"> <Panel position="bottom-right" className="!m-4 z-40">
@ -342,8 +399,8 @@ export function FlowCanvas() {
)} )}
</Panel> </Panel>
{/* Status Indicator - Bottom Left */} {/* Status Indicator - Bottom Left (Hidden on Mobile - shown in header) */}
{nodes.length > 0 && ( {nodes.length > 0 && !isMobile && (
<Panel position="bottom-left" className="!m-6"> <Panel position="bottom-left" className="!m-6">
<div className="flex items-center gap-4 px-4 py-2.5 bg-white/90 dark:bg-surface/90 backdrop-blur-md border border-black/5 dark:border-white/10 rounded-xl shadow-xl"> <div className="flex items-center gap-4 px-4 py-2.5 bg-white/90 dark:bg-surface/90 backdrop-blur-md border border-black/5 dark:border-white/10 rounded-xl shadow-xl">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View file

@ -189,17 +189,17 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
<div className="fixed inset-0 z-[9998] bg-black/40 backdrop-blur-sm animate-fade-in" onClick={onClose} /> <div className="fixed inset-0 z-[9998] bg-black/40 backdrop-blur-sm animate-fade-in" onClick={onClose} />
<div className={`fixed z-[9999] flex flex-col gap-4 <div className={`fixed z-[9999] flex flex-col gap-4
${isMobile ${isMobile
? 'left-4 right-4 top-16 max-h-[80vh] rounded-[2rem]' ? 'inset-0 rounded-none pt-safe'
: 'top-24 right-12 w-96 rounded-[2rem]' : 'top-24 right-12 w-96 max-h-[80vh] rounded-[2rem]'
} floating-glass p-6 titanium-border shadow-2xl overflow-hidden`} } floating-glass p-6 titanium-border shadow-2xl overflow-hidden`}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between flex-shrink-0">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-blue-500/10 flex items-center justify-center border border-blue-500/20"> <div className={`${isMobile ? 'w-12 h-12' : 'w-10 h-10'} rounded-xl bg-blue-500/10 flex items-center justify-center border border-blue-500/20`}>
<Cpu className="w-5 h-5 text-blue-500" /> <Cpu className={`${isMobile ? 'w-6 h-6' : 'w-5 h-5'} text-blue-500`} />
</div> </div>
<div> <div>
<h3 className="text-sm font-bold text-primary tracking-tight">System Settings</h3> <h3 className={`${isMobile ? 'text-base' : 'text-sm'} font-bold text-primary tracking-tight`}>System Settings</h3>
<p className="text-[10px] text-tertiary font-medium uppercase tracking-wider">Configuration</p> <p className="text-[10px] text-tertiary font-medium uppercase tracking-wider">Configuration</p>
</div> </div>
</div> </div>
@ -210,14 +210,17 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
<span className="text-[9px] font-bold text-amber-400 uppercase tracking-wider">Lite</span> <span className="text-[9px] font-bold text-amber-400 uppercase tracking-wider">Lite</span>
</div> </div>
)} )}
<button onClick={onClose} className="p-2 hover:bg-white/5 rounded-full transition-colors group"> <button
onClick={onClose}
className={`${isMobile ? 'w-11 h-11' : 'p-2'} hover:bg-white/5 rounded-full transition-colors group flex items-center justify-center`}
>
<X className="w-5 h-5 text-tertiary group-hover:text-primary transition-colors" /> <X className="w-5 h-5 text-tertiary group-hover:text-primary transition-colors" />
</button> </button>
</div> </div>
</div> </div>
{/* Scrollable Content Area */} {/* Scrollable Content Area */}
<div className={`${isMobile ? 'flex-1 overflow-y-auto' : ''} space-y-6`}> <div className="flex-1 overflow-y-auto space-y-6 pb-4">
{/* Mode Selection */} {/* Mode Selection */}
<div className="flex items-center gap-1 p-1 bg-black/20 rounded-xl border border-white/5"> <div className="flex items-center gap-1 p-1 bg-black/20 rounded-xl border border-white/5">
<button <button

View file

@ -1,89 +1,89 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Lightbulb, MousePointer2, Keyboard, Layout } from 'lucide-react'; import { Lightbulb, MousePointer2, Keyboard, Layout } from 'lucide-react';
const TIPS = [ const TIPS = [
{ {
icon: MousePointer2, icon: MousePointer2,
title: 'Select & Edit', title: 'Select & Edit',
text: 'Select any node to inspect its logic and edit metadata.' text: 'Select any node to inspect its logic and edit metadata.'
}, },
{ {
icon: Layout, icon: Layout,
title: 'Auto Layout', title: 'Auto Layout',
text: 'Use "Visual Organizer" to automatically arrange complex flows.' text: 'Use "Visual Organizer" to automatically arrange complex flows.'
}, },
{ {
icon: Keyboard, icon: Keyboard,
title: 'Quick Actions', title: 'Quick Actions',
text: 'Press "K" to open the command palette for fast access.' text: 'Press "K" to open the command palette for fast access.'
}, },
{ {
icon: Lightbulb, icon: Lightbulb,
title: 'Pro Tip', title: 'Pro Tip',
text: 'Shift + Drag to select multiple nodes at once.' text: 'Shift + Drag to select multiple nodes at once.'
} }
]; ];
export function SmartGuide() { export function SmartGuide() {
const [index, setIndex] = useState(0); const [index, setIndex] = useState(0);
const [isPaused, setIsPaused] = useState(false); const [isPaused, setIsPaused] = useState(false);
useEffect(() => { useEffect(() => {
if (isPaused) return; if (isPaused) return;
const timer = setInterval(() => { const timer = setInterval(() => {
setIndex(prev => (prev + 1) % TIPS.length); setIndex(prev => (prev + 1) % TIPS.length);
}, 5000); }, 5000);
return () => clearInterval(timer); return () => clearInterval(timer);
}, [isPaused]); }, [isPaused]);
const Tip = TIPS[index]; const Tip = TIPS[index];
const Icon = Tip.icon; const Icon = Tip.icon;
return ( return (
<div <div
className="p-6 rounded-2xl bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-900/10 dark:to-indigo-900/10 border border-blue-100 dark:border-blue-500/10 shadow-sm relative overflow-hidden group cursor-pointer" className="p-6 rounded-2xl bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-900/10 dark:to-indigo-900/10 border border-blue-100 dark:border-blue-500/10 shadow-sm relative overflow-hidden group cursor-pointer"
onMouseEnter={() => setIsPaused(true)} onMouseEnter={() => setIsPaused(true)}
onMouseLeave={() => setIsPaused(false)} onMouseLeave={() => setIsPaused(false)}
onClick={() => setIndex(prev => (prev + 1) % TIPS.length)} onClick={() => setIndex(prev => (prev + 1) % TIPS.length)}
> >
{/* Progress Bar */} {/* Progress Bar */}
<div className="absolute top-0 left-0 h-0.5 bg-blue-500/20 w-full"> <div className="absolute top-0 left-0 h-0.5 bg-blue-500/20 w-full">
<div <div
key={index} key={index}
className="h-full bg-blue-500 transition-all duration-[5000ms] ease-linear w-full origin-left" className="h-full bg-blue-500 transition-all duration-[5000ms] ease-linear w-full origin-left"
style={{ animation: isPaused ? 'none' : 'progress 5s linear' }} style={{ animation: isPaused ? 'none' : 'progress 5s linear' }}
/> />
</div> </div>
<div className="flex items-start gap-3 relative z-10"> <div className="flex items-start gap-3 relative z-10">
<div className="p-2 rounded-lg bg-white dark:bg-white/5 shadow-sm border border-blue-100 dark:border-white/5 shrink-0"> <div className="p-2 rounded-lg bg-white dark:bg-white/5 shadow-sm border border-blue-100 dark:border-white/5 shrink-0">
<Icon className="w-4 h-4 text-blue-500 dark:text-blue-400" /> <Icon className="w-4 h-4 text-blue-500 dark:text-blue-400" />
</div> </div>
<div> <div>
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<span className="text-[10px] font-black uppercase tracking-widest text-blue-600 dark:text-blue-400"> <span className="text-[10px] font-black uppercase tracking-widest text-blue-600 dark:text-blue-400">
{Tip.title} {Tip.title}
</span> </span>
<div className="flex gap-0.5"> <div className="flex gap-0.5">
{TIPS.map((_, i) => ( {TIPS.map((_, i) => (
<div <div
key={i} key={i}
className={`w-1 h-1 rounded-full transition-colors ${i === index ? 'bg-blue-500' : 'bg-blue-200 dark:bg-white/10' className={`w-1 h-1 rounded-full transition-colors ${i === index ? 'bg-blue-500' : 'bg-blue-200 dark:bg-white/10'
}`} }`}
/> />
))} ))}
</div> </div>
</div> </div>
<p className="text-[11px] font-medium leading-relaxed text-slate-600 dark:text-slate-400"> <p className="text-[11px] font-medium leading-relaxed text-slate-600 dark:text-slate-400">
{Tip.text} {Tip.text}
</p> </p>
</div> </div>
</div> </div>
</div> </div>
); );
} }
// Add animation keyframes to global styles via style tag if strictly needed, // Add animation keyframes to global styles via style tag if strictly needed,
// but often standard CSS transitions suffice. For the progress bar, specific keyframes // but often standard CSS transitions suffice. For the progress bar, specific keyframes
// are better added to index.css or tailored here. // are better added to index.css or tailored here.

View file

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

View file

@ -1,194 +1,194 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Button } from './ui/Button'; import { Button } from './ui/Button';
import { Card } from './ui/Card'; import { Card } from './ui/Card';
import { useVisualOrganizer } from '../hooks/useVisualOrganizer'; import { useVisualOrganizer } from '../hooks/useVisualOrganizer';
import { useDiagramStore } from '../store'; import { useDiagramStore } from '../store';
import { Sparkles, Wand2, Layout, Scan, CheckCircle2, RotateCcw } from 'lucide-react'; import { Sparkles, Wand2, Layout, Scan, CheckCircle2, RotateCcw } from 'lucide-react';
import type { LayoutSuggestion } from '../types/visualOrganization'; import type { LayoutSuggestion } from '../types/visualOrganization';
export const VisualOrganizerPanel: React.FC = () => { export const VisualOrganizerPanel: React.FC = () => {
const { analyzeLayout, generateSuggestions, applySuggestion } = useVisualOrganizer(); const { analyzeLayout, generateSuggestions, applySuggestion } = useVisualOrganizer();
const { nodes, edges, setNodes, setEdges } = useDiagramStore(); const { nodes, edges, setNodes, setEdges } = useDiagramStore();
// UI States // UI States
const [status, setStatus] = useState<'idle' | 'analyzing' | 'ready' | 'applied'>('idle'); const [status, setStatus] = useState<'idle' | 'analyzing' | 'ready' | 'applied'>('idle');
const [bestSuggestion, setBestSuggestion] = useState<LayoutSuggestion | null>(null); const [bestSuggestion, setBestSuggestion] = useState<LayoutSuggestion | null>(null);
const [snapshot, setSnapshot] = useState<{ nodes: any[], edges: any[] } | null>(null); const [snapshot, setSnapshot] = useState<{ nodes: any[], edges: any[] } | null>(null);
// AI Organize Handler // AI Organize Handler
const handleAIOrganize = async () => { const handleAIOrganize = async () => {
setStatus('analyzing'); setStatus('analyzing');
// 1. Analyze (Simulate brief delay for effect) // 1. Analyze (Simulate brief delay for effect)
analyzeLayout(); analyzeLayout();
// 2. Generate Suggestions // 2. Generate Suggestions
try { try {
// Artificial delay for "Scanning" animation effect // Artificial delay for "Scanning" animation effect
await new Promise(resolve => setTimeout(resolve, 1500)); await new Promise(resolve => setTimeout(resolve, 1500));
const results = await generateSuggestions(); const results = await generateSuggestions();
// Pick best suggestion (or default to first non-current) // Pick best suggestion (or default to first non-current)
if (results.length > 0) { if (results.length > 0) {
setBestSuggestion(results[0]); setBestSuggestion(results[0]);
setStatus('ready'); setStatus('ready');
} else { } else {
// Fallback if no suggestions // Fallback if no suggestions
setStatus('idle'); setStatus('idle');
} }
} catch (error) { } catch (error) {
console.error(error); console.error(error);
setStatus('idle'); setStatus('idle');
} }
}; };
const handleApply = () => { const handleApply = () => {
if (!bestSuggestion) return; if (!bestSuggestion) return;
// Take snapshot before applying // Take snapshot before applying
setSnapshot({ nodes: [...nodes], edges: [...edges] }); setSnapshot({ nodes: [...nodes], edges: [...edges] });
applySuggestion(bestSuggestion); applySuggestion(bestSuggestion);
setStatus('applied'); setStatus('applied');
}; };
const handleUndo = () => { const handleUndo = () => {
if (snapshot) { if (snapshot) {
setNodes(snapshot.nodes); setNodes(snapshot.nodes);
setEdges(snapshot.edges); setEdges(snapshot.edges);
setSnapshot(null); setSnapshot(null);
setStatus('ready'); setStatus('ready');
} }
}; };
const handleReset = () => { const handleReset = () => {
setStatus('idle'); setStatus('idle');
setSnapshot(null); setSnapshot(null);
setBestSuggestion(null); setBestSuggestion(null);
}; };
return ( return (
<div className="visual-organizer-panel w-full"> <div className="visual-organizer-panel w-full">
<Card className="p-0 overflow-hidden border-none shadow-none bg-transparent"> <Card className="p-0 overflow-hidden border-none shadow-none bg-transparent">
{/* IDLE STATE: Main AI Button */} {/* IDLE STATE: Main AI Button */}
{status === 'idle' && ( {status === 'idle' && (
<div className="flex flex-col items-center justify-center p-8 text-center space-y-6"> <div className="flex flex-col items-center justify-center p-8 text-center space-y-6">
<div className="relative group cursor-pointer" onClick={handleAIOrganize}> <div className="relative group cursor-pointer" onClick={handleAIOrganize}>
<div className="absolute inset-0 bg-blue-500 rounded-full blur-xl opacity-20 group-hover:opacity-40 transition-opacity duration-500 animate-pulse"></div> <div className="absolute inset-0 bg-blue-500 rounded-full blur-xl opacity-20 group-hover:opacity-40 transition-opacity duration-500 animate-pulse"></div>
<div className="relative w-24 h-24 bg-gradient-to-br from-blue-600 to-indigo-600 rounded-2xl flex items-center justify-center shadow-2xl transform group-hover:scale-105 transition-all duration-300 border border-white/20"> <div className="relative w-24 h-24 bg-gradient-to-br from-blue-600 to-indigo-600 rounded-2xl flex items-center justify-center shadow-2xl transform group-hover:scale-105 transition-all duration-300 border border-white/20">
<Sparkles className="w-10 h-10 text-white animate-pulse" /> <Sparkles className="w-10 h-10 text-white animate-pulse" />
</div> </div>
<div className="absolute -bottom-2 -right-2 bg-white dark:bg-slate-800 p-2 rounded-full shadow-lg border border-slate-100 dark:border-slate-700"> <div className="absolute -bottom-2 -right-2 bg-white dark:bg-slate-800 p-2 rounded-full shadow-lg border border-slate-100 dark:border-slate-700">
<Wand2 className="w-4 h-4 text-purple-500" /> <Wand2 className="w-4 h-4 text-purple-500" />
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<h3 className="text-xl font-display font-bold text-slate-800 dark:text-white">AI Visual Organizer</h3> <h3 className="text-xl font-display font-bold text-slate-800 dark:text-white">AI Visual Organizer</h3>
<p className="text-sm text-slate-500 dark:text-slate-400 max-w-[200px] mx-auto leading-relaxed"> <p className="text-sm text-slate-500 dark:text-slate-400 max-w-[200px] mx-auto leading-relaxed">
Click to instantly analyze and reorganize your flow for maximum clarity. Click to instantly analyze and reorganize your flow for maximum clarity.
</p> </p>
</div> </div>
<Button <Button
onClick={handleAIOrganize} onClick={handleAIOrganize}
className="bg-slate-900 dark:bg-white text-white dark:text-slate-900 hover:bg-slate-800 hover:dark:bg-slate-100 px-8 py-6 rounded-xl shadow-xl hover:shadow-2xl transition-all font-bold tracking-wide" className="bg-slate-900 dark:bg-white text-white dark:text-slate-900 hover:bg-slate-800 hover:dark:bg-slate-100 px-8 py-6 rounded-xl shadow-xl hover:shadow-2xl transition-all font-bold tracking-wide"
> >
<Scan className="w-4 h-4 mr-2" /> <Scan className="w-4 h-4 mr-2" />
Start Organization Start Organization
</Button> </Button>
</div> </div>
)} )}
{/* ANALYZING STATE: Scanning Animation */} {/* ANALYZING STATE: Scanning Animation */}
{status === 'analyzing' && ( {status === 'analyzing' && (
<div className="flex flex-col items-center justify-center p-12 text-center space-y-6 animate-in fade-in zoom-in duration-300"> <div className="flex flex-col items-center justify-center p-12 text-center space-y-6 animate-in fade-in zoom-in duration-300">
<div className="relative w-20 h-20"> <div className="relative w-20 h-20">
<div className="absolute inset-0 border-4 border-blue-500/20 rounded-full"></div> <div className="absolute inset-0 border-4 border-blue-500/20 rounded-full"></div>
<div className="absolute inset-0 border-4 border-t-blue-500 rounded-full animate-spin"></div> <div className="absolute inset-0 border-4 border-t-blue-500 rounded-full animate-spin"></div>
<div className="absolute inset-0 flex items-center justify-center"> <div className="absolute inset-0 flex items-center justify-center">
<Scan className="w-8 h-8 text-blue-500 animate-pulse" /> <Scan className="w-8 h-8 text-blue-500 animate-pulse" />
</div> </div>
</div> </div>
<h3 className="text-lg font-bold text-blue-600 dark:text-blue-400 animate-pulse"> <h3 className="text-lg font-bold text-blue-600 dark:text-blue-400 animate-pulse">
Analyzing Layout Logic... Analyzing Layout Logic...
</h3> </h3>
</div> </div>
)} )}
{/* READY STATE: Suggestion Found */} {/* READY STATE: Suggestion Found */}
{status === 'ready' && bestSuggestion && ( {status === 'ready' && bestSuggestion && (
<div className="flex flex-col p-6 space-y-6 animate-in slide-in-from-bottom-4 duration-500"> <div className="flex flex-col p-6 space-y-6 animate-in slide-in-from-bottom-4 duration-500">
<div className="flex items-center gap-3 text-green-600 dark:text-green-400 mb-2"> <div className="flex items-center gap-3 text-green-600 dark:text-green-400 mb-2">
<CheckCircle2 className="w-6 h-6" /> <CheckCircle2 className="w-6 h-6" />
<span className="font-bold text-lg">Optimization Found!</span> <span className="font-bold text-lg">Optimization Found!</span>
</div> </div>
<div className="p-4 bg-slate-50 dark:bg-white/5 rounded-xl border border-slate-200 dark:border-white/10"> <div className="p-4 bg-slate-50 dark:bg-white/5 rounded-xl border border-slate-200 dark:border-white/10">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<Layout className="w-5 h-5 text-indigo-500" /> <Layout className="w-5 h-5 text-indigo-500" />
<h4 className="font-bold">{bestSuggestion.title}</h4> <h4 className="font-bold">{bestSuggestion.title}</h4>
</div> </div>
<p className="text-sm text-slate-600 dark:text-slate-400 leading-relaxed"> <p className="text-sm text-slate-600 dark:text-slate-400 leading-relaxed">
{bestSuggestion.description} {bestSuggestion.description}
</p> </p>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Button <Button
onClick={handleReset} onClick={handleReset}
variant="secondary" variant="secondary"
className="h-12" className="h-12"
> >
Cancel Cancel
</Button> </Button>
<Button <Button
onClick={handleApply} onClick={handleApply}
className="h-12 bg-indigo-600 hover:bg-indigo-500 text-white shadow-lg hover:shadow-indigo-500/25" className="h-12 bg-indigo-600 hover:bg-indigo-500 text-white shadow-lg hover:shadow-indigo-500/25"
> >
<Wand2 className="w-4 h-4 mr-2" /> <Wand2 className="w-4 h-4 mr-2" />
Apply Magic Apply Magic
</Button> </Button>
</div> </div>
</div> </div>
)} )}
{/* APPLIED STATE: Success & Undo */} {/* APPLIED STATE: Success & Undo */}
{status === 'applied' && ( {status === 'applied' && (
<div className="flex flex-col items-center justify-center p-8 text-center space-y-6 animate-in zoom-in duration-300"> <div className="flex flex-col items-center justify-center p-8 text-center space-y-6 animate-in zoom-in duration-300">
<div className="w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mb-2"> <div className="w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mb-2">
<CheckCircle2 className="w-8 h-8 text-green-600 dark:text-green-400" /> <CheckCircle2 className="w-8 h-8 text-green-600 dark:text-green-400" />
</div> </div>
<div> <div>
<h3 className="text-xl font-bold text-slate-800 dark:text-white mb-2">Beautifully Organized!</h3> <h3 className="text-xl font-bold text-slate-800 dark:text-white mb-2">Beautifully Organized!</h3>
<p className="text-sm text-slate-500 dark:text-slate-400"> <p className="text-sm text-slate-500 dark:text-slate-400">
Your graph has been transformed. Your graph has been transformed.
</p> </p>
</div> </div>
<div className="flex gap-3 w-full"> <div className="flex gap-3 w-full">
<Button <Button
onClick={handleUndo} onClick={handleUndo}
variant="secondary" variant="secondary"
className="flex-1 h-12 border-slate-200 dark:border-white/10 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/10 dark:hover:text-red-400 transition-colors" className="flex-1 h-12 border-slate-200 dark:border-white/10 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/10 dark:hover:text-red-400 transition-colors"
> >
<RotateCcw className="w-4 h-4 mr-2" /> <RotateCcw className="w-4 h-4 mr-2" />
Undo Undo
</Button> </Button>
<Button <Button
onClick={handleReset} // Goes back to idle onClick={handleReset} // Goes back to idle
className="flex-1 h-12" className="flex-1 h-12"
> >
Done Done
</Button> </Button>
</div> </div>
</div> </div>
)} )}
</Card> </Card>
</div> </div>
); );
}; };

View file

@ -1,7 +1,8 @@
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { import {
Edit3, Code, Download, Zap, Sun, Moon, Maximize2, Minimize2, Settings, Save, Edit3, Code, Download, Zap, Sun, Moon, Maximize2, Minimize2, Settings, Save,
ChevronDown, FileCode, ImageIcon, FileText, Frame, Cloud, Server, Cpu, RotateCcw, RotateCw ChevronDown, FileCode, ImageIcon, FileText, Frame, Cloud, Server, Cpu, RotateCcw, RotateCw,
Menu, X, Home
} from 'lucide-react'; } from 'lucide-react';
import { useStore } from 'zustand'; import { useStore } from 'zustand';
import { useFlowStore, useDiagramStore } from '../../store'; import { useFlowStore, useDiagramStore } from '../../store';
@ -13,6 +14,49 @@ import {
import { useState } from 'react'; import { useState } from 'react';
import { SettingsModal } from '../Settings'; import { SettingsModal } from '../Settings';
// Mobile Menu Item Component - extracted outside to avoid hook issues
interface MobileMenuItemProps {
icon: any;
label: string;
onClick: () => void;
active?: boolean;
disabled?: boolean;
variant?: 'default' | 'primary' | 'success';
iconColor?: string;
}
function MobileMenuItem({
icon: Icon,
label,
onClick,
active = false,
disabled = false,
variant = 'default',
iconColor = ''
}: MobileMenuItemProps) {
return (
<button
onClick={onClick}
disabled={disabled}
className={`
w-full min-h-[44px] flex items-center gap-4 px-4 py-3 rounded-xl transition-all
${disabled ? 'opacity-30 cursor-not-allowed' : 'active:scale-[0.98]'}
${active
? 'bg-blue-50 dark:bg-blue-500/15 text-blue-600 dark:text-blue-400'
: variant === 'primary'
? 'bg-blue-600 text-white hover:bg-blue-500'
: variant === 'success'
? 'bg-emerald-50 dark:bg-emerald-500/15 text-emerald-600 dark:text-emerald-400'
: 'text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-white/5'}
`}
>
<Icon className={`w-5 h-5 flex-shrink-0 ${iconColor || (active ? 'text-blue-500' : variant === 'primary' ? 'text-white' : 'text-slate-400 dark:text-slate-500')}`} />
<span className="text-sm font-semibold flex-1 text-left">{label}</span>
{active && <div className="w-2 h-2 rounded-full bg-blue-500" />}
</button>
);
}
export function EditorHeader() { export function EditorHeader() {
const { const {
nodes, edges, leftPanelOpen, setLeftPanelOpen, nodes, edges, leftPanelOpen, setLeftPanelOpen,
@ -24,11 +68,17 @@ export function EditorHeader() {
} = useFlowStore(); } = useFlowStore();
const { isMobile } = useMobileDetect(); const { isMobile } = useMobileDetect();
// Temporal state hooks - MUST be called unconditionally at top level
const canUndo = useStore(useDiagramStore.temporal, (state: any) => state.pastStates.length > 0);
const canRedo = useStore(useDiagramStore.temporal, (state: any) => state.futureStates.length > 0);
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle'); const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
const [showExportMenu, setShowExportMenu] = useState(false); const [showExportMenu, setShowExportMenu] = useState(false);
const [showMobileMenu, setShowMobileMenu] = useState(false);
const handleSave = () => { const handleSave = () => {
setShowMobileMenu(false);
const name = prompt('Enter diagram name:', `Diagram ${new Date().toLocaleDateString()}`); const name = prompt('Enter diagram name:', `Diagram ${new Date().toLocaleDateString()}`);
if (name) { if (name) {
setSaveStatus('saving'); setSaveStatus('saving');
@ -44,6 +94,7 @@ export function EditorHeader() {
const handleExport = async (format: 'png' | 'jpg' | 'svg' | 'txt' | 'code' | 'json') => { const handleExport = async (format: 'png' | 'jpg' | 'svg' | 'txt' | 'code' | 'json') => {
setShowExportMenu(false); setShowExportMenu(false);
setShowMobileMenu(false);
const viewport = document.querySelector('.react-flow__viewport') as HTMLElement; const viewport = document.querySelector('.react-flow__viewport') as HTMLElement;
try { try {
@ -72,6 +123,260 @@ export function EditorHeader() {
} }
}; };
const handleOpenSettings = () => {
setShowMobileMenu(false);
setShowSettings(true);
};
// MOBILE HEADER
if (isMobile) {
return (
<>
<header className="h-14 px-4 flex items-center justify-between z-[60] border-b border-black/5 dark:border-white/10 bg-white/95 dark:bg-slate-900/95 backdrop-blur-xl">
{/* Left: Logo + Back */}
<div className="flex items-center gap-3">
<Link
to="/"
className="w-11 h-11 flex items-center justify-center rounded-xl bg-slate-100 dark:bg-slate-800 active:scale-95 transition-transform"
title="Home"
>
<Home className="w-5 h-5 text-slate-600 dark:text-slate-400" />
</Link>
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center shadow-lg shadow-blue-500/20">
<Zap className="w-3.5 h-3.5 text-white fill-white/20" />
</div>
<span className="text-sm font-bold text-slate-800 dark:text-white">SysVis</span>
</div>
</div>
{/* Center: Node count (if any) */}
{nodes.length > 0 && (
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-emerald-50 dark:bg-emerald-500/10 border border-emerald-200 dark:border-emerald-500/20">
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
<span className="text-xs font-bold text-emerald-600 dark:text-emerald-400">
{nodes.filter(n => n.type !== 'group').length} nodes
</span>
</div>
)}
{/* Right: Menu Button */}
<button
onClick={() => setShowMobileMenu(true)}
className="w-11 h-11 flex items-center justify-center rounded-xl bg-slate-100 dark:bg-slate-800 active:scale-95 transition-transform"
title="Menu"
>
<Menu className="w-5 h-5 text-slate-600 dark:text-slate-400" />
</button>
</header>
{/* Mobile Full-Screen Menu */}
{showMobileMenu && (
<div className="fixed inset-0 z-[200] animate-fade-in">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => setShowMobileMenu(false)}
/>
{/* Menu Panel - slides from right */}
<div className="absolute top-0 right-0 bottom-0 w-[85%] max-w-sm bg-white dark:bg-slate-900 shadow-2xl animate-slide-in-right flex flex-col">
{/* Menu Header */}
<div className="h-16 px-4 flex items-center justify-between border-b border-slate-200 dark:border-white/10 flex-shrink-0">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-blue-500 to-blue-600 flex items-center justify-center">
<Zap className="w-4 h-4 text-white" />
</div>
<span className="text-base font-bold text-slate-800 dark:text-white">Menu</span>
</div>
<button
onClick={() => setShowMobileMenu(false)}
className="w-11 h-11 flex items-center justify-center rounded-xl hover:bg-slate-100 dark:hover:bg-white/5 active:scale-95 transition-all"
>
<X className="w-5 h-5 text-slate-500" />
</button>
</div>
{/* Menu Content - Scrollable */}
<div className="flex-1 overflow-y-auto p-4 space-y-6">
{/* AI Mode Indicator */}
<div className="flex items-center gap-3 px-4 py-3 rounded-xl bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-white/5">
{aiMode === 'offline' ? (
<>
<Server className="w-5 h-5 text-blue-500" />
<div>
<span className="text-sm font-bold text-slate-700 dark:text-slate-200">Local Mode</span>
<p className="text-xs text-slate-500 dark:text-slate-400">Using Ollama</p>
</div>
</>
) : aiMode === 'browser' ? (
<>
<Cpu className="w-5 h-5 text-purple-500" />
<div>
<span className="text-sm font-bold text-slate-700 dark:text-slate-200">Browser Mode</span>
<p className="text-xs text-slate-500 dark:text-slate-400">WebLLM In-Device</p>
</div>
</>
) : (
<>
<Cloud className="w-5 h-5 text-emerald-500" />
<div>
<span className="text-sm font-bold text-slate-700 dark:text-slate-200">Cloud Mode</span>
<p className="text-xs text-slate-500 dark:text-slate-400">
{onlineProvider === 'openai' ? 'OpenAI' : onlineProvider === 'gemini' ? 'Gemini' : 'Cloud AI'}
</p>
</div>
</>
)}
</div>
{/* History Section */}
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-2 px-1">History</h3>
<div className="flex gap-2">
<button
onClick={() => {
useDiagramStore.temporal.getState().undo();
setShowMobileMenu(false);
}}
disabled={!canUndo}
className="flex-1 min-h-[44px] flex items-center justify-center gap-2 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 active:scale-95 transition-all disabled:opacity-30"
>
<RotateCcw className="w-5 h-5" />
<span className="text-sm font-semibold">Undo</span>
</button>
<button
onClick={() => {
useDiagramStore.temporal.getState().redo();
setShowMobileMenu(false);
}}
disabled={!canRedo}
className="flex-1 min-h-[44px] flex items-center justify-center gap-2 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 active:scale-95 transition-all disabled:opacity-30"
>
<RotateCw className="w-5 h-5" />
<span className="text-sm font-semibold">Redo</span>
</button>
</div>
</div>
{/* View Section */}
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-2 px-1">View</h3>
<div className="space-y-1">
<MobileMenuItem
icon={focusMode ? Minimize2 : Maximize2}
label={focusMode ? 'Exit Focus Mode' : 'Focus Mode'}
onClick={() => {
setFocusMode(!focusMode);
setShowMobileMenu(false);
}}
active={focusMode}
/>
<MobileMenuItem
icon={theme === 'light' ? Moon : Sun}
label={theme === 'light' ? 'Dark Theme' : 'Light Theme'}
onClick={() => {
toggleTheme();
setShowMobileMenu(false);
}}
/>
</div>
</div>
{/* Actions Section */}
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-2 px-1">Actions</h3>
<div className="space-y-1">
<MobileMenuItem
icon={Save}
label={saveStatus === 'saving' ? 'Saving...' : saveStatus === 'saved' ? 'Saved!' : 'Save Diagram'}
onClick={handleSave}
disabled={nodes.length === 0 || saveStatus === 'saving'}
variant={saveStatus === 'saved' ? 'success' : 'default'}
/>
<MobileMenuItem
icon={Settings}
label="Settings"
onClick={handleOpenSettings}
/>
</div>
</div>
{/* Export Section */}
<div>
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 mb-2 px-1">Export</h3>
<div className="space-y-1">
<MobileMenuItem
icon={FileCode}
label="Interactive Code"
onClick={() => handleExport('code')}
disabled={nodes.length === 0}
iconColor="text-blue-500"
/>
<MobileMenuItem
icon={FileCode}
label="JSON Data"
onClick={() => handleExport('json')}
disabled={nodes.length === 0}
iconColor="text-purple-500"
/>
<MobileMenuItem
icon={ImageIcon}
label="PNG Image"
onClick={() => handleExport('png')}
disabled={nodes.length === 0}
iconColor="text-indigo-500"
/>
<MobileMenuItem
icon={ImageIcon}
label="JPG Image"
onClick={() => handleExport('jpg')}
disabled={nodes.length === 0}
iconColor="text-amber-500"
/>
<MobileMenuItem
icon={Frame}
label="SVG Vector"
onClick={() => handleExport('svg')}
disabled={nodes.length === 0}
iconColor="text-emerald-500"
/>
<MobileMenuItem
icon={FileText}
label="Logic Summary"
onClick={() => handleExport('txt')}
disabled={nodes.length === 0}
iconColor="text-slate-400"
/>
</div>
</div>
</div>
{/* Menu Footer */}
<div className="p-4 border-t border-slate-200 dark:border-white/10 flex-shrink-0">
<Link
to="/"
className="w-full min-h-[44px] flex items-center justify-center gap-2 rounded-xl bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 active:scale-95 transition-all"
>
<Home className="w-5 h-5" />
<span className="text-sm font-semibold">Back to Dashboard</span>
</Link>
</div>
</div>
</div>
)}
<SettingsModal
isOpen={showSettings}
onClose={() => setShowSettings(false)}
/>
</>
);
}
// DESKTOP HEADER (unchanged)
return ( return (
<header className="h-14 px-6 flex items-center justify-between z-[60] border-b border-black/5 dark:border-white/10 bg-white/80 dark:bg-slate-900/90 backdrop-blur-md"> <header className="h-14 px-6 flex items-center justify-between z-[60] border-b border-black/5 dark:border-white/10 bg-white/80 dark:bg-slate-900/90 backdrop-blur-md">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@ -88,7 +393,7 @@ export function EditorHeader() {
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button <button
onClick={() => useDiagramStore.temporal.getState().undo()} onClick={() => useDiagramStore.temporal.getState().undo()}
disabled={!useStore(useDiagramStore.temporal, (state: any) => state.pastStates.length > 0)} disabled={!canUndo}
className="p-1.5 rounded-lg hover:bg-black/5 dark:hover:bg-white/10 text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-all disabled:opacity-30 disabled:cursor-not-allowed" className="p-1.5 rounded-lg hover:bg-black/5 dark:hover:bg-white/10 text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-all disabled:opacity-30 disabled:cursor-not-allowed"
title="Undo" title="Undo"
> >
@ -96,7 +401,7 @@ export function EditorHeader() {
</button> </button>
<button <button
onClick={() => useDiagramStore.temporal.getState().redo()} onClick={() => useDiagramStore.temporal.getState().redo()}
disabled={!useStore(useDiagramStore.temporal, (state: any) => state.futureStates.length > 0)} disabled={!canRedo}
className="p-1.5 rounded-lg hover:bg-black/5 dark:hover:bg-white/10 text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-all disabled:opacity-30 disabled:cursor-not-allowed" className="p-1.5 rounded-lg hover:bg-black/5 dark:hover:bg-white/10 text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-all disabled:opacity-30 disabled:cursor-not-allowed"
title="Redo" title="Redo"
> >
@ -104,60 +409,54 @@ export function EditorHeader() {
</button> </button>
</div> </div>
{!isMobile && ( <div className="h-4 w-px bg-slate-200 dark:bg-white/10 mx-2"></div>
<> <div className="flex items-center gap-1.5 bg-slate-100 dark:bg-slate-800/50 p-1 rounded-xl border border-black/5 dark:border-white/5">
<div className="h-4 w-px bg-slate-200 dark:bg-white/10 mx-2"></div> <button
<div className="flex items-center gap-1.5 bg-slate-100 dark:bg-slate-800/50 p-1 rounded-xl border border-black/5 dark:border-white/5"> onClick={() => setLeftPanelOpen(!leftPanelOpen)}
<button className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider transition-all ${leftPanelOpen
onClick={() => setLeftPanelOpen(!leftPanelOpen)} ? 'bg-white dark:bg-blue-600/15 text-blue-600 dark:text-blue-500 shadow-sm dark:shadow-none border border-black/5 dark:border-blue-500/20'
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider transition-all ${leftPanelOpen : 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 hover:bg-black/5 dark:hover:bg-white/5 border border-transparent'}`}
? 'bg-white dark:bg-blue-600/15 text-blue-600 dark:text-blue-500 shadow-sm dark:shadow-none border border-black/5 dark:border-blue-500/20' title="Toggle Input Panel"
: 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 hover:bg-black/5 dark:hover:bg-white/5 border border-transparent'}`} >
title="Toggle Input Panel" <Edit3 className="w-3.5 h-3.5" />
> <span className="hidden sm:inline">Input</span>
<Edit3 className="w-3.5 h-3.5" /> </button>
<span className="hidden sm:inline">Input</span> <button
</button> onClick={() => setRightPanelOpen(!rightPanelOpen)}
<button disabled={nodes.length === 0}
onClick={() => setRightPanelOpen(!rightPanelOpen)} className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider transition-all ${nodes.length === 0
disabled={nodes.length === 0} ? 'opacity-30 cursor-not-allowed text-slate-400 dark:text-slate-600'
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider transition-all ${nodes.length === 0 : (rightPanelOpen
? 'opacity-30 cursor-not-allowed text-slate-400 dark:text-slate-600' ? 'bg-white dark:bg-blue-600/15 text-blue-600 dark:text-blue-500 shadow-sm dark:shadow-none border border-black/5 dark:border-blue-500/20'
: (rightPanelOpen : 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 hover:bg-black/5 dark:hover:bg-white/5 border border-transparent')}`}
? 'bg-white dark:bg-blue-600/15 text-blue-600 dark:text-blue-500 shadow-sm dark:shadow-none border border-black/5 dark:border-blue-500/20' title="Toggle Code Panel"
: 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 hover:bg-black/5 dark:hover:bg-white/5 border border-transparent')}`} >
title="Toggle Code Panel" <Code className="w-3.5 h-3.5" />
> <span className="hidden sm:inline">Code</span>
<Code className="w-3.5 h-3.5" /> </button>
<span className="hidden sm:inline">Code</span> </div>
</button>
</div>
</>
)}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* AI Mode Status - Desktop Only */} {/* AI Mode Status - Desktop Only */}
{!isMobile && ( <div className="hidden md:flex items-center gap-2 px-3 py-1.5 rounded-full bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-white/5 mr-2">
<div className="hidden md:flex items-center gap-2 px-3 py-1.5 rounded-full bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-white/5 mr-2"> {aiMode === 'offline' ? (
{aiMode === 'offline' ? ( <>
<> <Server className="w-3 h-3 text-blue-500" />
<Server className="w-3 h-3 text-blue-500" /> <span className="text-[10px] font-bold text-slate-500 dark:text-slate-400">Local</span>
<span className="text-[10px] font-bold text-slate-500 dark:text-slate-400">Local</span> </>
</> ) : aiMode === 'browser' ? (
) : aiMode === 'browser' ? ( <>
<> <Cpu className="w-3 h-3 text-purple-500" />
<Cpu className="w-3 h-3 text-purple-500" /> <span className="text-[10px] font-bold text-slate-500 dark:text-slate-400">Browser</span>
<span className="text-[10px] font-bold text-slate-500 dark:text-slate-400">Browser</span> </>
</> ) : (
) : ( <>
<> <Cloud className="w-3 h-3 text-emerald-500" />
<Cloud className="w-3 h-3 text-emerald-500" /> <span className="text-[10px] font-bold text-slate-500 dark:text-slate-400">Cloud</span>
<span className="text-[10px] font-bold text-slate-500 dark:text-slate-400">Cloud</span> </>
</> )}
)} </div>
</div>
)}
<button <button
onClick={() => setFocusMode(!focusMode)} onClick={() => setFocusMode(!focusMode)}

View file

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

View file

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

View file

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

View file

@ -1,38 +1,38 @@
import React from 'react'; import React from 'react';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> { interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost'; variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
size?: 'sm' | 'md' | 'lg'; size?: 'sm' | 'md' | 'lg';
} }
export const Button: React.FC<ButtonProps> = ({ export const Button: React.FC<ButtonProps> = ({
children, children,
variant = 'primary', variant = 'primary',
size = 'md', size = 'md',
className = '', className = '',
...props ...props
}) => { }) => {
const baseStyles = "inline-flex items-center justify-center rounded-lg font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none"; const baseStyles = "inline-flex items-center justify-center rounded-lg font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none";
const variants = { const variants = {
primary: "bg-indigo-600 text-white hover:bg-indigo-700 focus:ring-indigo-500", primary: "bg-indigo-600 text-white hover:bg-indigo-700 focus:ring-indigo-500",
secondary: "bg-white dark:bg-white/5 text-gray-700 dark:text-gray-200 border border-gray-300 dark:border-white/10 hover:bg-gray-50 dark:hover:bg-white/10 focus:ring-indigo-500", secondary: "bg-white dark:bg-white/5 text-gray-700 dark:text-gray-200 border border-gray-300 dark:border-white/10 hover:bg-gray-50 dark:hover:bg-white/10 focus:ring-indigo-500",
danger: "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500", danger: "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500",
ghost: "bg-transparent text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/10 focus:ring-gray-500" ghost: "bg-transparent text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-white/10 focus:ring-gray-500"
}; };
const sizes = { const sizes = {
sm: "px-3 py-1.5 text-xs", sm: "px-3 py-1.5 text-xs",
md: "px-4 py-2 text-sm", md: "px-4 py-2 text-sm",
lg: "px-6 py-3 text-base" lg: "px-6 py-3 text-base"
}; };
return ( return (
<button <button
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`} className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
{...props} {...props}
> >
{children} {children}
</button> </button>
); );
}; };

View file

@ -1,28 +1,28 @@
import React from 'react'; import React from 'react';
interface CardProps extends React.HTMLAttributes<HTMLDivElement> { interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
padding?: 'none' | 'sm' | 'md' | 'lg'; padding?: 'none' | 'sm' | 'md' | 'lg';
} }
export const Card: React.FC<CardProps> = ({ export const Card: React.FC<CardProps> = ({
children, children,
className = '', className = '',
padding = 'md', padding = 'md',
...props ...props
}) => { }) => {
const paddings = { const paddings = {
none: '', none: '',
sm: 'p-3', sm: 'p-3',
md: 'p-4', md: 'p-4',
lg: 'p-6' lg: 'p-6'
}; };
return ( return (
<div <div
className={`bg-white dark:bg-black/20 backdrop-blur-sm rounded-xl border border-gray-200 dark:border-white/10 shadow-sm ${paddings[padding]} ${className}`} className={`bg-white dark:bg-black/20 backdrop-blur-sm rounded-xl border border-gray-200 dark:border-white/10 shadow-sm ${paddings[padding]} ${className}`}
{...props} {...props}
> >
{children} {children}
</div> </div>
); );
}; };

View file

@ -1,23 +1,23 @@
export function OrchestratorLoader() { export function OrchestratorLoader() {
return ( return (
<div className="flex flex-col items-center justify-center p-8"> <div className="flex flex-col items-center justify-center p-8">
<div className="relative w-16 h-16 mb-8"> <div className="relative w-16 h-16 mb-8">
{/* Glow effect */} {/* Glow effect */}
<div className="absolute inset-0 bg-blue-500/20 blur-xl rounded-full animate-pulse-slow" /> <div className="absolute inset-0 bg-blue-500/20 blur-xl rounded-full animate-pulse-slow" />
{/* Rotating Diamond */} {/* Rotating Diamond */}
<div className="absolute inset-0 m-auto w-8 h-8 border-[3px] border-blue-500 rounded-lg animate-spin-slow shadow-[0_0_15px_rgba(59,130,246,0.5)] transform rotate-45" /> <div className="absolute inset-0 m-auto w-8 h-8 border-[3px] border-blue-500 rounded-lg animate-spin-slow shadow-[0_0_15px_rgba(59,130,246,0.5)] transform rotate-45" />
{/* Inner accent (optional, based on image "feel") */} {/* Inner accent (optional, based on image "feel") */}
<div className="absolute inset-0 m-auto w-3 h-3 bg-blue-400 rounded-full animate-ping opacity-20" /> <div className="absolute inset-0 m-auto w-3 h-3 bg-blue-400 rounded-full animate-ping opacity-20" />
</div> </div>
<p className="text-[10px] font-black text-blue-500 uppercase tracking-[0.4em] animate-pulse"> <p className="text-[10px] font-black text-blue-500 uppercase tracking-[0.4em] animate-pulse">
Orchestrating logic Orchestrating logic
</p> </p>
</div> </div>
); );
} }

View file

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

View file

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

View file

@ -1,87 +1,87 @@
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { useDiagramStore } from '../store'; import { useDiagramStore } from '../store';
import { useSettingsStore } from '../store/settingsStore'; import { useSettingsStore } from '../store/settingsStore';
import { createVisualOrganizer } from '../lib/visualOrganizer'; import { createVisualOrganizer } from '../lib/visualOrganizer';
import { analyzeVisualLayout } from '../lib/aiService'; import { analyzeVisualLayout } from '../lib/aiService';
import type { LayoutSuggestion, VisualIssue, LayoutMetrics } from '../types/visualOrganization'; import type { LayoutSuggestion, VisualIssue, LayoutMetrics } from '../types/visualOrganization';
export const useVisualOrganizer = () => { export const useVisualOrganizer = () => {
const { nodes, edges, setNodes, setEdges } = useDiagramStore(); const { nodes, edges, setNodes, setEdges } = useDiagramStore();
const { aiMode, onlineProvider, apiKey, ollamaUrl, modelName } = useSettingsStore(); const { aiMode, onlineProvider, apiKey, ollamaUrl, modelName } = useSettingsStore();
const visualOrganizer = useMemo(() => { const visualOrganizer = useMemo(() => {
return createVisualOrganizer(nodes, edges); return createVisualOrganizer(nodes, edges);
}, [nodes, edges]); }, [nodes, edges]);
const analyzeLayout = useCallback(() => { const analyzeLayout = useCallback(() => {
return visualOrganizer.analyzeLayout(); return visualOrganizer.analyzeLayout();
}, [visualOrganizer]); }, [visualOrganizer]);
const generateSuggestions = useCallback(async () => { const generateSuggestions = useCallback(async () => {
// 1. Get algorithmic suggestions // 1. Get algorithmic suggestions
const algorithmicSuggestions = visualOrganizer.generateSuggestions(); const algorithmicSuggestions = visualOrganizer.generateSuggestions();
// 2. Get AI suggestions if enabled // 2. Get AI suggestions if enabled
let aiSuggestions: LayoutSuggestion[] = []; let aiSuggestions: LayoutSuggestion[] = [];
try { try {
const analysisResult = visualOrganizer.analyzeLayout(); const analysisResult = visualOrganizer.analyzeLayout();
const aiResult = await analyzeVisualLayout( const aiResult = await analyzeVisualLayout(
nodes, nodes,
edges, edges,
analysisResult.metrics, analysisResult.metrics,
ollamaUrl, ollamaUrl,
modelName, modelName,
aiMode, aiMode,
onlineProvider, onlineProvider,
apiKey apiKey
); );
if (aiResult.success && aiResult.analysis?.suggestions) { if (aiResult.success && aiResult.analysis?.suggestions) {
aiSuggestions = aiResult.analysis.suggestions.map((s: any) => ({ aiSuggestions = aiResult.analysis.suggestions.map((s: any) => ({
id: s.id || `ai-${Math.random().toString(36).substr(2, 9)}`, id: s.id || `ai-${Math.random().toString(36).substr(2, 9)}`,
title: s.title, title: s.title,
description: s.description, description: s.description,
type: s.type || 'style', type: s.type || 'style',
impact: s.impact || 'low', impact: s.impact || 'low',
estimatedImprovement: 0, estimatedImprovement: 0,
beforeState: { metrics: analysisResult.metrics, issues: [] }, // AI doesn't calculate this yet beforeState: { metrics: analysisResult.metrics, issues: [] }, // AI doesn't calculate this yet
afterState: { metrics: analysisResult.metrics, estimatedIssues: [] }, afterState: { metrics: analysisResult.metrics, estimatedIssues: [] },
implementation: { implementation: {
nodePositions: {}, // AI suggestions might not have positions yet nodePositions: {}, // AI suggestions might not have positions yet
description: s.fix_strategy // Store strategy for possible future implementation description: s.fix_strategy // Store strategy for possible future implementation
} }
})); }));
} }
} catch (error) { } catch (error) {
console.warn('AI visual analysis failed:', error); console.warn('AI visual analysis failed:', error);
} }
return [...algorithmicSuggestions, ...aiSuggestions]; return [...algorithmicSuggestions, ...aiSuggestions];
}, [visualOrganizer, nodes, edges, aiMode, onlineProvider, apiKey, ollamaUrl, modelName]); }, [visualOrganizer, nodes, edges, aiMode, onlineProvider, apiKey, ollamaUrl, modelName]);
const applySuggestion = useCallback((suggestion: LayoutSuggestion) => { const applySuggestion = useCallback((suggestion: LayoutSuggestion) => {
const { nodes: newNodes, edges: newEdges } = visualOrganizer.applySuggestion(suggestion); const { nodes: newNodes, edges: newEdges } = visualOrganizer.applySuggestion(suggestion);
setNodes(newNodes); setNodes(newNodes);
setEdges(newEdges); setEdges(newEdges);
}, [visualOrganizer, setNodes, setEdges]); }, [visualOrganizer, setNodes, setEdges]);
const getPresets = useCallback(() => { const getPresets = useCallback(() => {
return visualOrganizer.getPresets(); return visualOrganizer.getPresets();
}, [visualOrganizer]); }, [visualOrganizer]);
return { return {
analyzeLayout, analyzeLayout,
generateSuggestions, generateSuggestions,
applySuggestion, applySuggestion,
getPresets, getPresets,
visualOrganizer visualOrganizer
}; };
}; };
export type LayoutAnalysis = { export type LayoutAnalysis = {
metrics: LayoutMetrics; metrics: LayoutMetrics;
issues: VisualIssue[]; issues: VisualIssue[];
strengths: string[]; strengths: string[];
}; };
export default useVisualOrganizer; export default useVisualOrganizer;

View file

@ -1,154 +1,154 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { analyzeImage, interpretText } from '../aiService'; import { analyzeImage, interpretText } from '../aiService';
// Mock fetch global // Mock fetch global
const fetchMock = vi.fn(); const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('fetch', fetchMock);
describe('aiService', () => { describe('aiService', () => {
beforeEach(() => { beforeEach(() => {
fetchMock.mockReset(); fetchMock.mockReset();
}); });
afterEach(() => { afterEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
describe('interpretText', () => { describe('interpretText', () => {
it('should call online AI when provider is not local', async () => { it('should call online AI when provider is not local', async () => {
fetchMock.mockResolvedValueOnce({ fetchMock.mockResolvedValueOnce({
ok: true, ok: true,
json: async () => ({ json: async () => ({
choices: [{ choices: [{
message: { message: {
content: JSON.stringify({ content: JSON.stringify({
mermaidCode: 'flowchart TD\nA-->B', mermaidCode: 'flowchart TD\nA-->B',
metadata: {} metadata: {}
}) })
} }
}] }]
}), }),
text: async () => '' text: async () => ''
}); });
const result = await interpretText('test prompt', '', 'gpt-4', 'online', 'openai', 'test-key'); const result = await interpretText('test prompt', '', 'gpt-4', 'online', 'openai', 'test-key');
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.mermaidCode).toContain('flowchart TD'); expect(result.mermaidCode).toContain('flowchart TD');
expect(fetchMock).toHaveBeenCalledWith( expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining('api.openai.com'), expect.stringContaining('api.openai.com'),
expect.objectContaining({ expect.objectContaining({
method: 'POST', method: 'POST',
headers: expect.objectContaining({ headers: expect.objectContaining({
'Authorization': 'Bearer test-key' 'Authorization': 'Bearer test-key'
}) })
}) })
); );
}); });
it('should call local Ollama when provider is local', async () => { it('should call local Ollama when provider is local', async () => {
fetchMock.mockResolvedValueOnce({ fetchMock.mockResolvedValueOnce({
ok: true, ok: true,
json: async () => ({ json: async () => ({
message: { message: {
content: JSON.stringify({ content: JSON.stringify({
mermaidCode: 'flowchart TD\nA-->B', mermaidCode: 'flowchart TD\nA-->B',
metadata: {} metadata: {}
}) })
} }
}), }),
text: async () => '' text: async () => ''
}); });
// Using 'offline' mode correctly calls local AI // Using 'offline' mode correctly calls local AI
const result = await interpretText('test prompt', 'http://localhost:11434', 'llama3', 'offline', undefined, ''); const result = await interpretText('test prompt', 'http://localhost:11434', 'llama3', 'offline', undefined, '');
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(fetchMock).toHaveBeenCalledWith( expect(fetchMock).toHaveBeenCalledWith(
'http://localhost:11434/api/chat', 'http://localhost:11434/api/chat',
expect.any(Object) expect.any(Object)
); );
}); });
it('should handle API errors gracefully', async () => { it('should handle API errors gracefully', async () => {
fetchMock.mockResolvedValueOnce({ fetchMock.mockResolvedValueOnce({
ok: false, ok: false,
status: 401, status: 401,
statusText: 'Unauthorized', statusText: 'Unauthorized',
text: async () => 'Unauthorized error text' text: async () => 'Unauthorized error text'
}); });
const result = await interpretText('test', '', '', 'online', 'openai', 'bad-key'); const result = await interpretText('test', '', '', 'online', 'openai', 'bad-key');
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toContain('Invalid API Key'); expect(result.error).toContain('Invalid API Key');
}); });
it('should fetchWithRetry on transient errors', async () => { it('should fetchWithRetry on transient errors', async () => {
// first call fails with 429 // first call fails with 429
fetchMock.mockResolvedValueOnce({ fetchMock.mockResolvedValueOnce({
ok: false, ok: false,
status: 429, status: 429,
statusText: 'Too Many Requests', statusText: 'Too Many Requests',
text: async () => 'Rate limit exceeded' text: async () => 'Rate limit exceeded'
}); });
// second call succeeds // second call succeeds
fetchMock.mockResolvedValueOnce({ fetchMock.mockResolvedValueOnce({
ok: true, ok: true,
json: async () => ({ json: async () => ({
choices: [{ choices: [{
message: { message: {
content: JSON.stringify({ mermaidCode: 'flowchart TD', metadata: {} }) content: JSON.stringify({ mermaidCode: 'flowchart TD', metadata: {} })
} }
}] }]
}), }),
text: async () => '' text: async () => ''
}); });
// We expect it to retry, so use a short backoff or mock timers if possible. // We expect it to retry, so use a short backoff or mock timers if possible.
// Here we rely on the mocked response sequence. // Here we rely on the mocked response sequence.
const result = await interpretText('retry test', '', 'gpt-4', 'online', 'openai', 'key'); const result = await interpretText('retry test', '', 'gpt-4', 'online', 'openai', 'key');
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(2); expect(fetchMock).toHaveBeenCalledTimes(2);
}); });
it('should fallback to error message on non-retryable errors', async () => { it('should fallback to error message on non-retryable errors', async () => {
fetchMock.mockResolvedValueOnce({ fetchMock.mockResolvedValueOnce({
ok: false, ok: false,
status: 401, status: 401,
statusText: 'Unauthorized', statusText: 'Unauthorized',
text: async () => 'Unauthorized error text' text: async () => 'Unauthorized error text'
}); });
const result = await interpretText('test', '', '', 'online', 'openai', 'bad-key'); const result = await interpretText('test', '', '', 'online', 'openai', 'bad-key');
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.error).toContain('Invalid API Key'); expect(result.error).toContain('Invalid API Key');
}); });
}); });
describe('analyzeImage', () => { describe('analyzeImage', () => {
it('should successfully parse mermaid code from image analysis', async () => { it('should successfully parse mermaid code from image analysis', async () => {
fetchMock.mockResolvedValueOnce({ fetchMock.mockResolvedValueOnce({
ok: true, ok: true,
json: async () => ({ json: async () => ({
choices: [{ choices: [{
message: { message: {
content: JSON.stringify({ content: JSON.stringify({
mermaidCode: 'flowchart LR\nX-->Y', mermaidCode: 'flowchart LR\nX-->Y',
metadata: {} metadata: {}
}) })
} }
}] }]
}), }),
text: async () => '' text: async () => ''
}); });
const result = await analyzeImage('base64data', '', 'gpt-4-vision', 'online', 'openai', 'key'); const result = await analyzeImage('base64data', '', 'gpt-4-vision', 'online', 'openai', 'key');
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.mermaidCode).toContain('flowchart LR'); expect(result.mermaidCode).toContain('flowchart LR');
}); });
}); });
}); });

View file

@ -1,105 +1,105 @@
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { parseMermaid } from '../mermaidParser'; import { parseMermaid } from '../mermaidParser';
// Mock mermaid library // Mock mermaid library
vi.mock('mermaid', () => { vi.mock('mermaid', () => {
return { return {
default: { default: {
initialize: vi.fn(), initialize: vi.fn(),
parse: vi.fn().mockResolvedValue(true), parse: vi.fn().mockResolvedValue(true),
mermaidAPI: { mermaidAPI: {
getDiagramFromText: vi.fn().mockImplementation(async (text) => { getDiagramFromText: vi.fn().mockImplementation(async (text) => {
// Mock DB response based on text content // Mock DB response based on text content
const vertices: any = {}; const vertices: any = {};
const edges: any[] = []; const edges: any[] = [];
const subgraphs: any[] = []; const subgraphs: any[] = [];
if (text.includes('Start')) { if (text.includes('Start')) {
vertices['A'] = { id: 'A', text: 'Start', type: 'round' }; vertices['A'] = { id: 'A', text: 'Start', type: 'round' };
vertices['B'] = { id: 'B', text: 'End', type: 'round' }; vertices['B'] = { id: 'B', text: 'End', type: 'round' };
edges.push({ start: 'A', end: 'B', text: undefined, stroke: 'normal' }); edges.push({ start: 'A', end: 'B', text: undefined, stroke: 'normal' });
} }
if (text.includes('Group1')) { if (text.includes('Group1')) {
subgraphs.push({ id: 'Group1', title: 'Group1', nodes: ['A'] }); subgraphs.push({ id: 'Group1', title: 'Group1', nodes: ['A'] });
vertices['A'] = { id: 'A', text: 'Node A', type: 'round' }; vertices['A'] = { id: 'A', text: 'Node A', type: 'round' };
vertices['B'] = { id: 'B', text: 'Node B', type: 'round' }; vertices['B'] = { id: 'B', text: 'Node B', type: 'round' };
// Edge from A outside to B outside // Edge from A outside to B outside
edges.push({ start: 'A', end: 'B', text: undefined }); edges.push({ start: 'A', end: 'B', text: undefined });
} }
if (text.includes('Database')) { if (text.includes('Database')) {
vertices['DB'] = { id: 'DB', text: 'Database', type: 'cylinder' }; vertices['DB'] = { id: 'DB', text: 'Database', type: 'cylinder' };
vertices['S'] = { id: 'S', text: 'Server', type: 'round' }; vertices['S'] = { id: 'S', text: 'Server', type: 'round' };
vertices['C'] = { id: 'C', text: 'Client App', type: 'round' }; vertices['C'] = { id: 'C', text: 'Client App', type: 'round' };
} }
return { return {
db: { db: {
getVertices: () => vertices, getVertices: () => vertices,
getEdges: () => edges, getEdges: () => edges,
getSubGraphs: () => subgraphs, getSubGraphs: () => subgraphs,
} }
}; };
}) })
} }
} }
}; };
}); });
describe('mermaidParser', () => { describe('mermaidParser', () => {
it('should parse a simple flowchart', async () => { it('should parse a simple flowchart', async () => {
const code = ` const code = `
flowchart TD flowchart TD
A[Start] --> B[End] A[Start] --> B[End]
`; `;
const { nodes, edges } = await parseMermaid(code); const { nodes, edges } = await parseMermaid(code);
expect(nodes).toHaveLength(2); expect(nodes).toHaveLength(2);
expect(edges).toHaveLength(1); expect(edges).toHaveLength(1);
expect(nodes[0].data.label).toBe('Start'); expect(nodes[0].data.label).toBe('Start');
expect(nodes[1].data.label).toBe('End'); expect(nodes[1].data.label).toBe('End');
}); });
it('should handle subgraphs correctly', async () => { it('should handle subgraphs correctly', async () => {
const code = ` const code = `
flowchart TD flowchart TD
subgraph Group1 subgraph Group1
A[Node A] A[Node A]
end end
A --> B[Node B] A --> B[Node B]
`; `;
const { nodes } = await parseMermaid(code); const { nodes } = await parseMermaid(code);
// Should have 3 nodes: Group1, A, B // Should have 3 nodes: Group1, A, B
const groupNode = nodes.find(n => n.type === 'group'); const groupNode = nodes.find(n => n.type === 'group');
const childNode = nodes.find(n => n.id === 'A'); const childNode = nodes.find(n => n.id === 'A');
expect(groupNode).toBeDefined(); expect(groupNode).toBeDefined();
// The mock implementation ensures correct parentId association logic is tested // The mock implementation ensures correct parentId association logic is tested
if (groupNode && childNode) { if (groupNode && childNode) {
expect(childNode.parentId).toBe(groupNode.id); expect(childNode.parentId).toBe(groupNode.id);
} }
}); });
it('should infer node types from labels', async () => { it('should infer node types from labels', async () => {
const code = ` const code = `
flowchart TD flowchart TD
DB[(Database)] --> S[Server] DB[(Database)] --> S[Server]
S --> C([Client App]) S --> C([Client App])
`; `;
const { nodes } = await parseMermaid(code); const { nodes } = await parseMermaid(code);
const dbNode = nodes.find(n => n.data.label === 'Database'); const dbNode = nodes.find(n => n.data.label === 'Database');
const clientNode = nodes.find(n => n.data.label === 'Client App'); const clientNode = nodes.find(n => n.data.label === 'Client App');
const serverNode = nodes.find(n => n.data.label === 'Server'); const serverNode = nodes.find(n => n.data.label === 'Server');
expect(dbNode?.type).toBe('database'); expect(dbNode?.type).toBe('database');
expect(clientNode?.type).toBe('client'); expect(clientNode?.type).toBe('client');
expect(serverNode?.type).toBe('server'); expect(serverNode?.type).toBe('server');
}); });
it('should handle empty or invalid input securely', async () => { it('should handle empty or invalid input securely', async () => {
const { nodes, edges } = await parseMermaid(''); const { nodes, edges } = await parseMermaid('');
expect(nodes).toEqual([]); expect(nodes).toEqual([]);
expect(edges).toEqual([]); expect(edges).toEqual([]);
}); });
}); });

View file

@ -1,10 +1,54 @@
import { toPng, toJpeg, toSvg } from 'html-to-image'; import { toPng, toJpeg, toSvg } from 'html-to-image';
import { type Node, type Edge } from '../store'; import { type Node, type Edge } from '../store';
// Robust cross-browser file download function using data URLs for better Chrome compatibility
function saveFile(blob: Blob, filename: string): void {
console.log(`[Export] Starting download: ${filename}`);
// Convert blob to data URL for better download attribute support
const reader = new FileReader();
reader.onloadend = () => {
const dataUrl = reader.result as string;
// Create an anchor element
const a = document.createElement('a');
a.style.display = 'none';
a.href = dataUrl;
a.download = filename;
// Append to body (required for Firefox)
document.body.appendChild(a);
// Trigger download
a.click();
// Cleanup after a delay
setTimeout(() => {
document.body.removeChild(a);
}, 100);
console.log(`[Export] Download triggered: ${filename}`);
};
reader.onerror = () => {
console.error(`[Export] Failed to read blob for: ${filename}`);
};
reader.readAsDataURL(blob);
}
// Get the current theme's background color
function getThemeBackgroundColor(): string {
// Check if dark mode is active by looking at the document class
const isDark = document.documentElement.classList.contains('dark');
return isDark ? '#020617' : '#ffffff';
}
export async function exportToPng(element: HTMLElement): Promise<void> { export async function exportToPng(element: HTMLElement): Promise<void> {
try { try {
const backgroundColor = getThemeBackgroundColor();
const dataUrl = await toPng(element, { const dataUrl = await toPng(element, {
backgroundColor: '#020617', backgroundColor,
quality: 1, quality: 1,
pixelRatio: 3, pixelRatio: 3,
filter: (node) => { filter: (node) => {
@ -15,7 +59,8 @@ export async function exportToPng(element: HTMLElement): Promise<void> {
} }
}); });
downloadFile(dataUrl, `diagram-${getTimestamp()}.png`); const blob = dataURLtoBlob(dataUrl);
saveFile(blob, `diagram-${getTimestamp()}.png`);
} catch (error) { } catch (error) {
console.error('Failed to export PNG:', error); console.error('Failed to export PNG:', error);
throw error; throw error;
@ -24,9 +69,9 @@ export async function exportToPng(element: HTMLElement): Promise<void> {
export async function exportToJpg(element: HTMLElement): Promise<void> { export async function exportToJpg(element: HTMLElement): Promise<void> {
try { try {
const backgroundColor = getThemeBackgroundColor();
const dataUrl = await toJpeg(element, { const dataUrl = await toJpeg(element, {
backgroundColor: '#020617', backgroundColor,
quality: 0.95, quality: 0.95,
pixelRatio: 2, pixelRatio: 2,
filter: (node) => { filter: (node) => {
@ -37,7 +82,8 @@ export async function exportToJpg(element: HTMLElement): Promise<void> {
} }
}); });
downloadFile(dataUrl, `diagram-${getTimestamp()}.jpg`); const blob = dataURLtoBlob(dataUrl);
saveFile(blob, `diagram-${getTimestamp()}.jpg`);
} catch (error) { } catch (error) {
console.error('Failed to export JPG:', error); console.error('Failed to export JPG:', error);
throw error; throw error;
@ -46,8 +92,9 @@ export async function exportToJpg(element: HTMLElement): Promise<void> {
export async function exportToSvg(element: HTMLElement): Promise<void> { export async function exportToSvg(element: HTMLElement): Promise<void> {
try { try {
const backgroundColor = getThemeBackgroundColor();
const dataUrl = await toSvg(element, { const dataUrl = await toSvg(element, {
backgroundColor: '#020617', backgroundColor,
filter: (node) => { filter: (node) => {
const className = node.className?.toString() || ''; const className = node.className?.toString() || '';
return !className.includes('react-flow__controls') && return !className.includes('react-flow__controls') &&
@ -56,7 +103,8 @@ export async function exportToSvg(element: HTMLElement): Promise<void> {
} }
}); });
downloadFile(dataUrl, `diagram-${getTimestamp()}.svg`); const blob = dataURLtoBlob(dataUrl);
saveFile(blob, `diagram-${getTimestamp()}.svg`);
} catch (error) { } catch (error) {
console.error('Failed to export SVG:', error); console.error('Failed to export SVG:', error);
throw error; throw error;
@ -80,10 +128,8 @@ export function exportToTxt(nodes: Node[], edges: Edge[]): void {
txt += `- ${sourceLabel} -> ${targetLabel} ${e.label ? `(${e.label})` : ''}\n`; txt += `- ${sourceLabel} -> ${targetLabel} ${e.label ? `(${e.label})` : ''}\n`;
}); });
const blob = new Blob([txt], { type: 'text/plain' }); const blob = new Blob([txt], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob); saveFile(blob, `summary-${getTimestamp()}.txt`);
downloadFile(url, `summary-${getTimestamp()}.txt`);
URL.revokeObjectURL(url);
} }
export function exportToJson(nodes: Node[], edges: Edge[]): void { export function exportToJson(nodes: Node[], edges: Edge[]): void {
@ -106,11 +152,8 @@ export function exportToJson(nodes: Node[], edges: Edge[]): void {
}; };
const jsonString = JSON.stringify(data, null, 2); const jsonString = JSON.stringify(data, null, 2);
const blob = new Blob([jsonString], { type: 'application/json' }); const blob = new Blob([jsonString], { type: 'application/json;charset=utf-8' });
const url = URL.createObjectURL(blob); saveFile(blob, `diagram-${getTimestamp()}.json`);
downloadFile(url, `diagram-${getTimestamp()}.json`);
URL.revokeObjectURL(url);
} }
export function exportToMermaid(nodes: Node[], edges: Edge[]): string { export function exportToMermaid(nodes: Node[], edges: Edge[]): string {
@ -178,22 +221,24 @@ export function exportToMermaid(nodes: Node[], edges: Edge[]): string {
export function downloadMermaid(nodes: Node[], edges: Edge[]): void { export function downloadMermaid(nodes: Node[], edges: Edge[]): void {
const mermaid = exportToMermaid(nodes, edges); const mermaid = exportToMermaid(nodes, edges);
const blob = new Blob([mermaid], { type: 'text/plain' }); const blob = new Blob([mermaid], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob); saveFile(blob, `diagram-${getTimestamp()}.mmd`);
downloadFile(url, `diagram-${getTimestamp()}.mmd`);
URL.revokeObjectURL(url);
} }
function getTimestamp(): string { function getTimestamp(): string {
return new Date().toISOString().slice(0, 19).replace(/[:-]/g, ''); return new Date().toISOString().slice(0, 19).replace(/[:-]/g, '');
} }
function downloadFile(url: string, filename: string): void { // Convert data URL to Blob for proper file download
const link = document.createElement('a'); function dataURLtoBlob(dataUrl: string): Blob {
link.href = url; const arr = dataUrl.split(',');
link.download = filename; const mimeMatch = arr[0].match(/:(.*?);/);
document.body.appendChild(link); const mime = mimeMatch ? mimeMatch[1] : 'application/octet-stream';
link.click(); const bstr = atob(arr[1]);
document.body.removeChild(link); let n = bstr.length;
const u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new Blob([u8arr], { type: mime });
} }

View file

@ -1,424 +1,424 @@
import dagre from 'dagre'; import dagre from 'dagre';
import { type Node, type Edge } from '../store'; import { type Node, type Edge } from '../store';
// Enhanced constants for better spacing // Enhanced constants for better spacing
const NODE_WIDTH = 180; const NODE_WIDTH = 180;
const NODE_HEIGHT = 60; const NODE_HEIGHT = 60;
const GROUP_PADDING = 60; // Increased padding const GROUP_PADDING = 60; // Increased padding
const GROUP_TITLE_HEIGHT = 50; const GROUP_TITLE_HEIGHT = 50;
const GROUP_GAP = 120; // Increased gap between groups const GROUP_GAP = 120; // Increased gap between groups
const MIN_NODE_SPACING = 80; // Minimum space between nodes const MIN_NODE_SPACING = 80; // Minimum space between nodes
export interface LayoutOptions { export interface LayoutOptions {
direction: 'TB' | 'LR' | 'BT' | 'RL'; direction: 'TB' | 'LR' | 'BT' | 'RL';
nodeSpacing: number; nodeSpacing: number;
rankSpacing: number; rankSpacing: number;
smartOverlapResolution?: boolean; // Enable collision detection smartOverlapResolution?: boolean; // Enable collision detection
optimizeForReadability?: boolean; // Prioritize clear flow optimizeForReadability?: boolean; // Prioritize clear flow
} }
const defaultOptions: LayoutOptions = { const defaultOptions: LayoutOptions = {
direction: 'TB', direction: 'TB',
nodeSpacing: 100, // Increased from 60 to prevent overlap nodeSpacing: 100, // Increased from 60 to prevent overlap
rankSpacing: 150, // Increased from 80 for edge labels rankSpacing: 150, // Increased from 80 for edge labels
smartOverlapResolution: true, smartOverlapResolution: true,
optimizeForReadability: true, optimizeForReadability: true,
}; };
export function getLayoutedElements( export function getLayoutedElements(
nodes: Node[], nodes: Node[],
edges: Edge[], edges: Edge[],
options: Partial<LayoutOptions> = {} options: Partial<LayoutOptions> = {}
): { nodes: Node[]; edges: Edge[] } { ): { nodes: Node[]; edges: Edge[] } {
const opts = { ...defaultOptions, ...options }; const opts = { ...defaultOptions, ...options };
const isHorizontal = opts.direction === 'LR' || opts.direction === 'RL'; const isHorizontal = opts.direction === 'LR' || opts.direction === 'RL';
// Separate group nodes from regular nodes // Separate group nodes from regular nodes
const groupNodes = nodes.filter(n => n.type === 'group'); const groupNodes = nodes.filter(n => n.type === 'group');
const regularNodes = nodes.filter(n => n.type !== 'group'); const regularNodes = nodes.filter(n => n.type !== 'group');
// If no groups, just layout all nodes flat // If no groups, just layout all nodes flat
if (groupNodes.length === 0) { if (groupNodes.length === 0) {
return layoutFlatNodes(regularNodes, edges, opts, isHorizontal); return layoutFlatNodes(regularNodes, edges, opts, isHorizontal);
} }
// Separate nodes by their parent group // Separate nodes by their parent group
const nodesWithoutParent = regularNodes.filter(n => !n.parentId); const nodesWithoutParent = regularNodes.filter(n => !n.parentId);
const nodesByGroup = new Map<string, Node[]>(); const nodesByGroup = new Map<string, Node[]>();
groupNodes.forEach(g => nodesByGroup.set(g.id, [])); groupNodes.forEach(g => nodesByGroup.set(g.id, []));
regularNodes.forEach(n => { regularNodes.forEach(n => {
if (n.parentId && nodesByGroup.has(n.parentId)) { if (n.parentId && nodesByGroup.has(n.parentId)) {
nodesByGroup.get(n.parentId)!.push(n); nodesByGroup.get(n.parentId)!.push(n);
} }
}); });
// Layout each group internally and calculate their sizes // Layout each group internally and calculate their sizes
const groupLayouts = new Map<string, { const groupLayouts = new Map<string, {
width: number; width: number;
height: number; height: number;
nodes: Node[]; nodes: Node[];
group: Node; group: Node;
}>(); }>();
groupNodes.forEach(group => { groupNodes.forEach(group => {
const childNodes = nodesByGroup.get(group.id) || []; const childNodes = nodesByGroup.get(group.id) || [];
const layout = layoutGroupInternal(group, childNodes, edges, opts, isHorizontal); const layout = layoutGroupInternal(group, childNodes, edges, opts, isHorizontal);
groupLayouts.set(group.id, layout); groupLayouts.set(group.id, layout);
}); });
// Stack groups vertically (for TB direction) // Stack groups vertically (for TB direction)
const finalNodes: Node[] = []; const finalNodes: Node[] = [];
let currentY = 60; // Starting Y position let currentY = 60; // Starting Y position
const groupX = 60; // Left margin for groups const groupX = 60; // Left margin for groups
// Sort groups by their original order (first defined = first in list) // Sort groups by their original order (first defined = first in list)
const sortedGroups = Array.from(groupLayouts.values()); const sortedGroups = Array.from(groupLayouts.values());
sortedGroups.forEach(({ group, width, height, nodes: childNodes }) => { sortedGroups.forEach(({ group, width, height, nodes: childNodes }) => {
// Position the group // Position the group
finalNodes.push({ finalNodes.push({
...group, ...group,
position: { x: groupX, y: currentY }, position: { x: groupX, y: currentY },
style: { style: {
...group.style, ...group.style,
width, width,
height, height,
}, },
} as Node); } as Node);
// Add positioned child nodes // Add positioned child nodes
childNodes.forEach(child => finalNodes.push(child)); childNodes.forEach(child => finalNodes.push(child));
// Move Y down for next group // Move Y down for next group
currentY += height + GROUP_GAP; currentY += height + GROUP_GAP;
}); });
// Layout orphan nodes (nodes without parent) to the right of groups // Layout orphan nodes (nodes without parent) to the right of groups
if (nodesWithoutParent.length > 0) { if (nodesWithoutParent.length > 0) {
const maxGroupWidth = Math.max(...sortedGroups.map(g => g.width), 300); const maxGroupWidth = Math.max(...sortedGroups.map(g => g.width), 300);
const orphanStartX = groupX + maxGroupWidth + 100; const orphanStartX = groupX + maxGroupWidth + 100;
const orphanLayout = layoutOrphanNodes(nodesWithoutParent, edges, opts, isHorizontal, orphanStartX); const orphanLayout = layoutOrphanNodes(nodesWithoutParent, edges, opts, isHorizontal, orphanStartX);
orphanLayout.forEach(node => finalNodes.push(node)); orphanLayout.forEach(node => finalNodes.push(node));
} }
return { nodes: finalNodes, edges }; return { nodes: finalNodes, edges };
} }
// Layout nodes within a single group // Layout nodes within a single group
function layoutGroupInternal( function layoutGroupInternal(
group: Node, group: Node,
childNodes: Node[], childNodes: Node[],
edges: Edge[], edges: Edge[],
opts: LayoutOptions, opts: LayoutOptions,
isHorizontal: boolean isHorizontal: boolean
): { width: number; height: number; nodes: Node[]; group: Node } { ): { width: number; height: number; nodes: Node[]; group: Node } {
if (childNodes.length === 0) { if (childNodes.length === 0) {
return { return {
width: 300, width: 300,
height: 200, height: 200,
nodes: [], nodes: [],
group group
}; };
} }
// Create dagre sub-graph for this group // Create dagre sub-graph for this group
const subGraph = new dagre.graphlib.Graph(); const subGraph = new dagre.graphlib.Graph();
subGraph.setDefaultEdgeLabel(() => ({})); subGraph.setDefaultEdgeLabel(() => ({}));
subGraph.setGraph({ subGraph.setGraph({
rankdir: opts.direction, rankdir: opts.direction,
nodesep: opts.nodeSpacing, nodesep: opts.nodeSpacing,
ranksep: opts.rankSpacing, ranksep: opts.rankSpacing,
marginx: 30, marginx: 30,
marginy: 30, marginy: 30,
}); });
// Add nodes // Add nodes
childNodes.forEach(node => { childNodes.forEach(node => {
const w = node.type === 'decision' ? 140 : NODE_WIDTH; const w = node.type === 'decision' ? 140 : NODE_WIDTH;
const h = node.type === 'decision' ? 90 : NODE_HEIGHT; const h = node.type === 'decision' ? 90 : NODE_HEIGHT;
subGraph.setNode(node.id, { width: w, height: h }); subGraph.setNode(node.id, { width: w, height: h });
}); });
// Add edges within this group // Add edges within this group
edges.forEach(edge => { edges.forEach(edge => {
const sourceInGroup = childNodes.some(n => n.id === edge.source); const sourceInGroup = childNodes.some(n => n.id === edge.source);
const targetInGroup = childNodes.some(n => n.id === edge.target); const targetInGroup = childNodes.some(n => n.id === edge.target);
if (sourceInGroup && targetInGroup) { if (sourceInGroup && targetInGroup) {
subGraph.setEdge(edge.source, edge.target); subGraph.setEdge(edge.source, edge.target);
} }
}); });
dagre.layout(subGraph); dagre.layout(subGraph);
// Calculate bounds // Calculate bounds
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
const positionedChildren: Node[] = []; const positionedChildren: Node[] = [];
childNodes.forEach(node => { childNodes.forEach(node => {
const pos = subGraph.node(node.id); const pos = subGraph.node(node.id);
if (!pos) return; if (!pos) return;
const w = node.type === 'decision' ? 140 : NODE_WIDTH; const w = node.type === 'decision' ? 140 : NODE_WIDTH;
const h = node.type === 'decision' ? 90 : NODE_HEIGHT; const h = node.type === 'decision' ? 90 : NODE_HEIGHT;
const x = pos.x - w / 2; const x = pos.x - w / 2;
const y = pos.y - h / 2; const y = pos.y - h / 2;
minX = Math.min(minX, x); minX = Math.min(minX, x);
minY = Math.min(minY, y); minY = Math.min(minY, y);
maxX = Math.max(maxX, x + w); maxX = Math.max(maxX, x + w);
maxY = Math.max(maxY, y + h); maxY = Math.max(maxY, y + h);
positionedChildren.push({ positionedChildren.push({
...node, ...node,
position: { x, y }, position: { x, y },
targetPosition: isHorizontal ? 'left' : 'top', targetPosition: isHorizontal ? 'left' : 'top',
sourcePosition: isHorizontal ? 'right' : 'bottom', sourcePosition: isHorizontal ? 'right' : 'bottom',
extent: 'parent', extent: 'parent',
} as Node); } as Node);
}); });
// Normalize positions to start at padding // Normalize positions to start at padding
positionedChildren.forEach(child => { positionedChildren.forEach(child => {
child.position.x = child.position.x - minX + GROUP_PADDING; child.position.x = child.position.x - minX + GROUP_PADDING;
child.position.y = child.position.y - minY + GROUP_PADDING + GROUP_TITLE_HEIGHT; child.position.y = child.position.y - minY + GROUP_PADDING + GROUP_TITLE_HEIGHT;
}); });
const contentWidth = maxX - minX; const contentWidth = maxX - minX;
const contentHeight = maxY - minY; const contentHeight = maxY - minY;
const groupWidth = contentWidth + GROUP_PADDING * 2; const groupWidth = contentWidth + GROUP_PADDING * 2;
const groupHeight = contentHeight + GROUP_PADDING * 2 + GROUP_TITLE_HEIGHT; const groupHeight = contentHeight + GROUP_PADDING * 2 + GROUP_TITLE_HEIGHT;
return { return {
width: Math.max(groupWidth, 300), width: Math.max(groupWidth, 300),
height: Math.max(groupHeight, 200), height: Math.max(groupHeight, 200),
nodes: positionedChildren, nodes: positionedChildren,
group group
}; };
} }
// Layout orphan nodes that don't belong to any group // Layout orphan nodes that don't belong to any group
function layoutOrphanNodes( function layoutOrphanNodes(
nodes: Node[], nodes: Node[],
edges: Edge[], edges: Edge[],
opts: LayoutOptions, opts: LayoutOptions,
isHorizontal: boolean, isHorizontal: boolean,
startX: number startX: number
): Node[] { ): Node[] {
const orphanGraph = new dagre.graphlib.Graph(); const orphanGraph = new dagre.graphlib.Graph();
orphanGraph.setDefaultEdgeLabel(() => ({})); orphanGraph.setDefaultEdgeLabel(() => ({}));
orphanGraph.setGraph({ orphanGraph.setGraph({
rankdir: opts.direction, rankdir: opts.direction,
nodesep: opts.nodeSpacing, nodesep: opts.nodeSpacing,
ranksep: opts.rankSpacing, ranksep: opts.rankSpacing,
marginx: 0, marginx: 0,
marginy: 60, marginy: 60,
}); });
nodes.forEach(node => { nodes.forEach(node => {
const w = node.type === 'decision' ? 140 : NODE_WIDTH; const w = node.type === 'decision' ? 140 : NODE_WIDTH;
const h = node.type === 'decision' ? 90 : NODE_HEIGHT; const h = node.type === 'decision' ? 90 : NODE_HEIGHT;
orphanGraph.setNode(node.id, { width: w, height: h }); orphanGraph.setNode(node.id, { width: w, height: h });
}); });
// Add edges between orphan nodes // Add edges between orphan nodes
edges.forEach(edge => { edges.forEach(edge => {
const sourceOrphan = nodes.some(n => n.id === edge.source); const sourceOrphan = nodes.some(n => n.id === edge.source);
const targetOrphan = nodes.some(n => n.id === edge.target); const targetOrphan = nodes.some(n => n.id === edge.target);
if (sourceOrphan && targetOrphan) { if (sourceOrphan && targetOrphan) {
orphanGraph.setEdge(edge.source, edge.target); orphanGraph.setEdge(edge.source, edge.target);
} }
}); });
dagre.layout(orphanGraph); dagre.layout(orphanGraph);
return nodes.map(node => { return nodes.map(node => {
const pos = orphanGraph.node(node.id); const pos = orphanGraph.node(node.id);
if (!pos) return node; if (!pos) return node;
const w = node.type === 'decision' ? 140 : NODE_WIDTH; const w = node.type === 'decision' ? 140 : NODE_WIDTH;
const h = node.type === 'decision' ? 90 : NODE_HEIGHT; const h = node.type === 'decision' ? 90 : NODE_HEIGHT;
return { return {
...node, ...node,
position: { x: startX + pos.x - w / 2, y: pos.y - h / 2 }, position: { x: startX + pos.x - w / 2, y: pos.y - h / 2 },
targetPosition: isHorizontal ? 'left' : 'top', targetPosition: isHorizontal ? 'left' : 'top',
sourcePosition: isHorizontal ? 'right' : 'bottom', sourcePosition: isHorizontal ? 'right' : 'bottom',
} as Node; } as Node;
}); });
} }
// Flat layout when there are no groups // Flat layout when there are no groups
function layoutFlatNodes( function layoutFlatNodes(
nodes: Node[], nodes: Node[],
edges: Edge[], edges: Edge[],
opts: LayoutOptions, opts: LayoutOptions,
isHorizontal: boolean isHorizontal: boolean
): { nodes: Node[]; edges: Edge[] } { ): { nodes: Node[]; edges: Edge[] } {
const flatGraph = new dagre.graphlib.Graph(); const flatGraph = new dagre.graphlib.Graph();
flatGraph.setDefaultEdgeLabel(() => ({})); flatGraph.setDefaultEdgeLabel(() => ({}));
flatGraph.setGraph({ flatGraph.setGraph({
rankdir: opts.direction, rankdir: opts.direction,
nodesep: opts.nodeSpacing, nodesep: opts.nodeSpacing,
ranksep: opts.rankSpacing, ranksep: opts.rankSpacing,
marginx: 60, marginx: 60,
marginy: 60, marginy: 60,
}); });
nodes.forEach(node => { nodes.forEach(node => {
const w = node.type === 'decision' ? 140 : NODE_WIDTH; const w = node.type === 'decision' ? 140 : NODE_WIDTH;
const h = node.type === 'decision' ? 90 : NODE_HEIGHT; const h = node.type === 'decision' ? 90 : NODE_HEIGHT;
flatGraph.setNode(node.id, { width: w, height: h }); flatGraph.setNode(node.id, { width: w, height: h });
}); });
edges.forEach(edge => { edges.forEach(edge => {
if (nodes.some(n => n.id === edge.source) && nodes.some(n => n.id === edge.target)) { if (nodes.some(n => n.id === edge.source) && nodes.some(n => n.id === edge.target)) {
flatGraph.setEdge(edge.source, edge.target); flatGraph.setEdge(edge.source, edge.target);
} }
}); });
dagre.layout(flatGraph); dagre.layout(flatGraph);
const layoutedNodes = nodes.map(node => { const layoutedNodes = nodes.map(node => {
const pos = flatGraph.node(node.id); const pos = flatGraph.node(node.id);
if (!pos) return node; if (!pos) return node;
const w = node.type === 'decision' ? 140 : NODE_WIDTH; const w = node.type === 'decision' ? 140 : NODE_WIDTH;
const h = node.type === 'decision' ? 90 : NODE_HEIGHT; const h = node.type === 'decision' ? 90 : NODE_HEIGHT;
return { return {
...node, ...node,
position: { x: pos.x - w / 2, y: pos.y - h / 2 }, position: { x: pos.x - w / 2, y: pos.y - h / 2 },
targetPosition: isHorizontal ? 'left' : 'top', targetPosition: isHorizontal ? 'left' : 'top',
sourcePosition: isHorizontal ? 'right' : 'bottom', sourcePosition: isHorizontal ? 'right' : 'bottom',
} as Node; } as Node;
}); });
// Apply smart overlap resolution if enabled // Apply smart overlap resolution if enabled
const resolvedNodes = opts.smartOverlapResolution const resolvedNodes = opts.smartOverlapResolution
? resolveOverlaps(layoutedNodes) ? resolveOverlaps(layoutedNodes)
: layoutedNodes; : layoutedNodes;
return { nodes: resolvedNodes, edges }; return { nodes: resolvedNodes, edges };
} }
/** /**
* Smart collision resolution - iteratively pushes overlapping nodes apart * Smart collision resolution - iteratively pushes overlapping nodes apart
* Uses a force-directed approach with multiple passes for stability * Uses a force-directed approach with multiple passes for stability
*/ */
function resolveOverlaps(nodes: Node[], maxIterations: number = 50): Node[] { function resolveOverlaps(nodes: Node[], maxIterations: number = 50): Node[] {
const mutableNodes = nodes.map(n => ({ const mutableNodes = nodes.map(n => ({
...n, ...n,
position: { ...n.position }, position: { ...n.position },
width: getNodeWidth(n), width: getNodeWidth(n),
height: getNodeHeight(n) height: getNodeHeight(n)
})); }));
for (let iteration = 0; iteration < maxIterations; iteration++) { for (let iteration = 0; iteration < maxIterations; iteration++) {
let hasOverlap = false; let hasOverlap = false;
for (let i = 0; i < mutableNodes.length; i++) { for (let i = 0; i < mutableNodes.length; i++) {
for (let j = i + 1; j < mutableNodes.length; j++) { for (let j = i + 1; j < mutableNodes.length; j++) {
const nodeA = mutableNodes[i]; const nodeA = mutableNodes[i];
const nodeB = mutableNodes[j]; const nodeB = mutableNodes[j];
// Skip group nodes // Skip group nodes
if (nodeA.type === 'group' || nodeB.type === 'group') continue; if (nodeA.type === 'group' || nodeB.type === 'group') continue;
// Check for overlap with padding // Check for overlap with padding
const overlapX = getOverlap( const overlapX = getOverlap(
nodeA.position.x, nodeA.width, nodeA.position.x, nodeA.width,
nodeB.position.x, nodeB.width, nodeB.position.x, nodeB.width,
MIN_NODE_SPACING MIN_NODE_SPACING
); );
const overlapY = getOverlap( const overlapY = getOverlap(
nodeA.position.y, nodeA.height, nodeA.position.y, nodeA.height,
nodeB.position.y, nodeB.height, nodeB.position.y, nodeB.height,
MIN_NODE_SPACING MIN_NODE_SPACING
); );
// If nodes overlap in both axes, push them apart // If nodes overlap in both axes, push them apart
if (overlapX > 0 && overlapY > 0) { if (overlapX > 0 && overlapY > 0) {
hasOverlap = true; hasOverlap = true;
// Determine which axis needs less push (more efficient separation) // Determine which axis needs less push (more efficient separation)
if (overlapX < overlapY) { if (overlapX < overlapY) {
// Push horizontally // Push horizontally
const pushX = overlapX / 2 + 5; const pushX = overlapX / 2 + 5;
if (nodeA.position.x < nodeB.position.x) { if (nodeA.position.x < nodeB.position.x) {
nodeA.position.x -= pushX; nodeA.position.x -= pushX;
nodeB.position.x += pushX; nodeB.position.x += pushX;
} else { } else {
nodeA.position.x += pushX; nodeA.position.x += pushX;
nodeB.position.x -= pushX; nodeB.position.x -= pushX;
} }
} else { } else {
// Push vertically // Push vertically
const pushY = overlapY / 2 + 5; const pushY = overlapY / 2 + 5;
if (nodeA.position.y < nodeB.position.y) { if (nodeA.position.y < nodeB.position.y) {
nodeA.position.y -= pushY; nodeA.position.y -= pushY;
nodeB.position.y += pushY; nodeB.position.y += pushY;
} else { } else {
nodeA.position.y += pushY; nodeA.position.y += pushY;
nodeB.position.y -= pushY; nodeB.position.y -= pushY;
} }
} }
} }
} }
} }
// If no overlaps detected, we're done // If no overlaps detected, we're done
if (!hasOverlap) break; if (!hasOverlap) break;
} }
// Ensure no negative positions (shift everything if needed) // Ensure no negative positions (shift everything if needed)
let minX = Infinity, minY = Infinity; let minX = Infinity, minY = Infinity;
mutableNodes.forEach(n => { mutableNodes.forEach(n => {
if (n.type !== 'group') { if (n.type !== 'group') {
minX = Math.min(minX, n.position.x); minX = Math.min(minX, n.position.x);
minY = Math.min(minY, n.position.y); minY = Math.min(minY, n.position.y);
} }
}); });
const offsetX = minX < 60 ? 60 - minX : 0; const offsetX = minX < 60 ? 60 - minX : 0;
const offsetY = minY < 60 ? 60 - minY : 0; const offsetY = minY < 60 ? 60 - minY : 0;
return mutableNodes.map(n => ({ return mutableNodes.map(n => ({
...n, ...n,
position: { position: {
x: n.position.x + offsetX, x: n.position.x + offsetX,
y: n.position.y + offsetY y: n.position.y + offsetY
} }
})); }));
} }
/** /**
* Calculate overlap between two rectangles with padding * Calculate overlap between two rectangles with padding
*/ */
function getOverlap(pos1: number, size1: number, pos2: number, size2: number, padding: number): number { function getOverlap(pos1: number, size1: number, pos2: number, size2: number, padding: number): number {
const end1 = pos1 + size1 + padding; const end1 = pos1 + size1 + padding;
const end2 = pos2 + size2 + padding; const end2 = pos2 + size2 + padding;
return Math.min(end1 - pos2, end2 - pos1); return Math.min(end1 - pos2, end2 - pos1);
} }
/** /**
* Get node width based on type * Get node width based on type
*/ */
function getNodeWidth(node: Node): number { function getNodeWidth(node: Node): number {
if (node.type === 'decision') return 140; if (node.type === 'decision') return 140;
if (node.style?.width && typeof node.style.width === 'number') return node.style.width; if (node.style?.width && typeof node.style.width === 'number') return node.style.width;
return NODE_WIDTH; return NODE_WIDTH;
} }
/** /**
* Get node height based on type * Get node height based on type
*/ */
function getNodeHeight(node: Node): number { function getNodeHeight(node: Node): number {
if (node.type === 'decision') return 90; if (node.type === 'decision') return 90;
if (node.style?.height && typeof node.style.height === 'number') return node.style.height; if (node.style?.height && typeof node.style.height === 'number') return node.style.height;
return NODE_HEIGHT; return NODE_HEIGHT;
} }

View file

@ -1,30 +1,30 @@
import mermaid from 'mermaid'; import mermaid from 'mermaid';
// Initialize mermaid // Initialize mermaid
mermaid.initialize({ startOnLoad: false }); mermaid.initialize({ startOnLoad: false });
const graphDefinition = ` const graphDefinition = `
flowchart TD flowchart TD
A[Start] --> B{Is it working?} A[Start] --> B{Is it working?}
B -- Yes --> C[Great!] B -- Yes --> C[Great!]
B -- No --> D[Debug] B -- No --> D[Debug]
`; `;
async function testParsing() { async function testParsing() {
try { try {
// Validate syntax // Validate syntax
const valid = await mermaid.parse(graphDefinition); const valid = await mermaid.parse(graphDefinition);
console.log("Parse result:", valid); console.log("Parse result:", valid);
// Attempt to get data // Attempt to get data
// Note: mermaid.mermaidAPI.getDiagramFromText is deprecated/internal but often used. // Note: mermaid.mermaidAPI.getDiagramFromText is deprecated/internal but often used.
// In v10+, we might need to use other methods. // In v10+, we might need to use other methods.
const type = mermaid.detectType(graphDefinition); const type = mermaid.detectType(graphDefinition);
console.log("Detected type:", type); console.log("Detected type:", type);
} catch (error) { } catch (error) {
console.error("Parsing failed:", error); console.error("Parsing failed:", error);
} }
} }
testParsing(); testParsing();

View file

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

View file

@ -1,61 +1,61 @@
// SVG Paths for Mermaid Shapes // SVG Paths for Mermaid Shapes
// Based on Mermaid v11.3.0+ syntax // Based on Mermaid v11.3.0+ syntax
// Syntax: node@{ shape: shapeName } // Syntax: node@{ shape: shapeName }
export interface ShapeDefinition { export interface ShapeDefinition {
id: string; id: string;
label: string; label: string;
path: string; path: string;
category: 'basic' | 'logic' | 'data' | 'other'; category: 'basic' | 'logic' | 'data' | 'other';
} }
export const SHAPE_PATHS: Record<string, string> = { export const SHAPE_PATHS: Record<string, string> = {
// Basic // Basic
rect: 'M0 4 h100 v60 h-100 z', 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', 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', 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', 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', 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', 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 // Logic
diamond: 'M50 0 L100 50 L50 100 L0 50 Z', diamond: 'M50 0 L100 50 L50 100 L0 50 Z',
rhombus: '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', decision: 'M50 0 L100 50 L50 100 L0 50 Z',
hexagon: 'M15 4 L85 4 L100 34 L85 64 L15 64 L0 34 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', parallelogram: 'M15 4 h85 l-15 60 h-85 z',
trapezoid: 'M15 4 h70 l15 60 h-100 z', trapezoid: 'M15 4 h70 l15 60 h-100 z',
// Documents/Data // Documents/Data
doc: 'M0 0 h80 l20 20 v80 h-100 z M80 0 v20 h20', 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', 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 (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', 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 // Circles
circle: 'M50 4 a30 30 0 0 1 0 60 a30 30 0 0 1 0 -60', 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', 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 // New additions based on research
note: 'M10 4 h80 v60 h-80 z M70 4 v20 h20', // rough note 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 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[] = [ export const SHAPE_DEFINITIONS: ShapeDefinition[] = [
{ id: 'rect', label: 'Rectangle', path: SHAPE_PATHS.rect, category: 'basic' }, { id: 'rect', label: 'Rectangle', path: SHAPE_PATHS.rect, category: 'basic' },
{ id: 'rounded', label: 'Rounded', path: SHAPE_PATHS.rounded, category: 'basic' }, { id: 'rounded', label: 'Rounded', path: SHAPE_PATHS.rounded, category: 'basic' },
{ id: 'stadium', label: 'Terminal', path: SHAPE_PATHS.stadium, category: 'basic' }, { id: 'stadium', label: 'Terminal', path: SHAPE_PATHS.stadium, category: 'basic' },
{ id: 'subroutine', label: 'Subroutine', path: SHAPE_PATHS.subroutine, category: 'basic' }, { id: 'subroutine', label: 'Subroutine', path: SHAPE_PATHS.subroutine, category: 'basic' },
{ id: 'circle', label: 'Circle', path: SHAPE_PATHS.circle, category: 'basic' }, { id: 'circle', label: 'Circle', path: SHAPE_PATHS.circle, category: 'basic' },
{ id: 'diamond', label: 'Decision', path: SHAPE_PATHS.diamond, category: 'logic' }, { id: 'diamond', label: 'Decision', path: SHAPE_PATHS.diamond, category: 'logic' },
{ id: 'hexagon', label: 'Prepare', path: SHAPE_PATHS.hexagon, category: 'logic' }, { id: 'hexagon', label: 'Prepare', path: SHAPE_PATHS.hexagon, category: 'logic' },
{ id: 'parallelogram', label: 'Input/Output', path: SHAPE_PATHS.parallelogram, category: 'logic' }, { id: 'parallelogram', label: 'Input/Output', path: SHAPE_PATHS.parallelogram, category: 'logic' },
{ id: 'cyl', label: 'Database', path: SHAPE_PATHS.cyl, category: 'data' }, { id: 'cyl', label: 'Database', path: SHAPE_PATHS.cyl, category: 'data' },
{ id: 'doc', label: 'Document', path: SHAPE_PATHS.doc, category: 'data' }, { id: 'doc', label: 'Document', path: SHAPE_PATHS.doc, category: 'data' },
{ id: 'cloud', label: 'Cloud', path: SHAPE_PATHS.cloud, category: 'data' }, { id: 'cloud', label: 'Cloud', path: SHAPE_PATHS.cloud, category: 'data' },
{ id: 'note', label: 'Note', path: SHAPE_PATHS.note, category: 'other' }, { id: 'note', label: 'Note', path: SHAPE_PATHS.note, category: 'other' },
]; ];

View file

@ -1,135 +1,124 @@
import { env, pipeline, RawImage } from '@huggingface/transformers'; import { env, AutoProcessor, AutoModel, RawImage } from '@huggingface/transformers';
// Configure transformers.js // Configure transformers.js
env.allowLocalModels = false; env.allowLocalModels = false;
env.useBrowserCache = true; env.useBrowserCache = true;
export type VisionProgress = { export type VisionProgress = {
status: string; status: string;
progress?: number; progress?: number;
file?: string; file?: string;
}; };
// ViT-GPT2 is the ONLY working model for browser-based image captioning // We use Florence-2-base for a good balance of speed and accuracy (~200MB - 400MB)
// Other models (BLIP, Florence-2, LLaVA) are not supported by transformers.js // 'onnx-community/Florence-2-base-ft' is the modern standard for Transformers.js v3.
const MODEL_ID = 'Xenova/vit-gpt2-image-captioning'; const MODEL_ID = 'onnx-community/Florence-2-base-ft';
export class VisionService { export class VisionService {
private captioner: any = null; private model: any = null;
private isLoading = false; private processor: any = null;
private isReady = false; private isLoading = false;
private isReady = false;
// Singleton instance
private static instance: VisionService; // Singleton instance
private static instance: VisionService;
public static getInstance(): VisionService {
if (!VisionService.instance) { public static getInstance(): VisionService {
VisionService.instance = new VisionService(); if (!VisionService.instance) {
} VisionService.instance = new VisionService();
return VisionService.instance; }
} return VisionService.instance;
}
getStatus() {
return { getStatus() {
isReady: this.isReady, return {
isLoading: this.isLoading, isReady: this.isReady,
model: MODEL_ID isLoading: this.isLoading,
}; model: MODEL_ID
} };
}
async initialize(onProgress?: (progress: VisionProgress) => void): Promise<void> {
if (this.isReady || this.isLoading) return; async initialize(onProgress?: (progress: VisionProgress) => void): Promise<void> {
if (this.isReady || this.isLoading) return;
this.isLoading = true;
this.isLoading = true;
try {
console.log('Loading Vision Model...'); try {
if (onProgress) onProgress({ status: 'Loading Vision Model...' }); console.log('Loading Vision Model...');
if (onProgress) onProgress({ status: 'Loading Processor...' });
// Use the pipeline API - much simpler and faster
this.captioner = await pipeline('image-to-text', MODEL_ID, { this.processor = await AutoProcessor.from_pretrained(MODEL_ID);
progress_callback: (progress: any) => {
if (onProgress && progress.status === 'progress') { if (onProgress) onProgress({ status: 'Loading Model (this may take a while)...' });
onProgress({
status: `Downloading ${progress.file}`, this.model = await AutoModel.from_pretrained(MODEL_ID, {
progress: progress.progress, progress_callback: (progress: any) => {
file: progress.file if (onProgress && progress.status === 'progress') {
}); onProgress({
} status: `Downloading ${progress.file}`,
} progress: progress.progress,
}); file: progress.file
});
this.isReady = true; }
console.log('Vision Model Ready'); }
} catch (error) { });
console.error('Failed to load Vision Model:', error);
throw error; this.isReady = true;
} finally { console.log('Vision Model Ready');
this.isLoading = false; } catch (error) {
} console.error('Failed to load Vision Model:', error);
} throw error;
} finally {
/** this.isLoading = false;
* Analyzes an image (Base64 or URL) and returns a description. }
* Uses vit-gpt2 for fast captioning. }
*/
async analyzeImage(imageBase64: string): Promise<string> { /**
if (!this.isReady) { * Analyzes an image (Base64 or URL) and returns a detailed description.
throw new Error('Vision model not loaded. Please initialize it first.'); * We use the '<MORE_DETAILED_CAPTION>' task for Florence-2.
} */
async analyzeImage(imageBase64: string): Promise<string> {
try { if (!this.isReady) {
// Handle data URL prefix if present throw new Error('Vision model not loaded. Please initialize it first.');
const cleanBase64 = imageBase64.includes(',') ? imageBase64 : `data:image/png;base64,${imageBase64}`; }
let image = await RawImage.fromURL(cleanBase64); try {
// Handle data URL prefix if present
// Keep higher resolution for better detail detection const cleanBase64 = imageBase64.includes(',') ? imageBase64 : `data:image/png;base64,${imageBase64}`;
if (image.width > 512 || image.height > 512) {
image = await image.resize(512, 512); const image = await RawImage.fromURL(cleanBase64);
}
// Task: Detailed Captioning is best for understanding diagrams
console.log('Starting enhanced image analysis...'); const text = '<MORE_DETAILED_CAPTION>';
const startTime = performance.now(); const inputs = await this.processor(image, text);
// Run multiple passes for more comprehensive description const generatedIds = await this.model.generate({
const results = await Promise.all([ ...inputs,
// Pass 1: Detailed description max_new_tokens: 512, // Sufficient for a description
this.captioner(image, { });
max_new_tokens: 150,
num_beams: 4, // Beam search for better quality const generatedText = this.processor.batch_decode(generatedIds, {
}), skip_special_tokens: false,
// Pass 2: Alternative perspective })[0];
this.captioner(image, {
max_new_tokens: 100, // Post-process to extract the caption
do_sample: true, // Florence-2 output format usually includes the task token
temperature: 0.7, const parsedAnswer = this.processor.post_process_generation(
}), generatedText,
]); text,
image.size
const endTime = performance.now(); );
console.log(`Vision analysis completed in ${((endTime - startTime) / 1000).toFixed(1)}s`);
// Access the dictionary result. For CAPTION tasks, it's usually under '<MORE_DETAILED_CAPTION>' or similar key
// Combine descriptions for richer output // Ideally post_process_generation returns { '<MORE_DETAILED_CAPTION>': "Description..." }
const caption1 = results[0]?.[0]?.generated_text || ''; return parsedAnswer['<MORE_DETAILED_CAPTION>'] || typeof parsedAnswer === 'string' ? parsedAnswer : JSON.stringify(parsedAnswer);
const caption2 = results[1]?.[0]?.generated_text || '';
} catch (error) {
// If both are similar, use just one; otherwise combine console.error('Vision analysis failed:', error);
if (caption1.toLowerCase().includes(caption2.toLowerCase().substring(0, 20)) || throw new Error('Failed to analyze image with local vision model');
caption2.toLowerCase().includes(caption1.toLowerCase().substring(0, 20))) { }
return caption1.length > caption2.length ? caption1 : caption2; }
} }
const combined = `${caption1}. Additionally: ${caption2}`; export const visionService = VisionService.getInstance();
console.log('Enhanced description:', combined);
return combined;
} catch (error) {
console.error('Vision analysis failed:', error);
throw new Error('Failed to analyze image with local vision model');
}
}
}
export const visionService = VisionService.getInstance();

File diff suppressed because it is too large Load diff

View file

@ -1,116 +1,106 @@
import { CreateMLCEngine, MLCEngine } from "@mlc-ai/web-llm"; import { CreateMLCEngine, MLCEngine } from "@mlc-ai/web-llm";
import type { InitProgressCallback } from "@mlc-ai/web-llm"; import type { InitProgressCallback } from "@mlc-ai/web-llm";
export type WebLlmProgress = { export type WebLlmProgress = {
progress: number; progress: number;
text: string; text: string;
timeElapsed: number; timeElapsed: number;
}; };
// Qwen3-0.6B is fast and works well with simple Mermaid generation prompts // Latest "Tiny" model with high instruction adherence
const DEFAULT_MODEL = "Qwen3-0.6B-q4f32_1-MLC"; const DEFAULT_MODEL = "Llama-3.2-1B-Instruct-q4f32_1-MLC";
export class WebLlmService { export class WebLlmService {
private engine: MLCEngine | null = null; private engine: MLCEngine | null = null;
private isLoading = false; private isLoading = false;
private isReady = false; private isReady = false;
// Track GPU Availability // Track GPU Availability
public static async isSystemSupported(): Promise<boolean | null> { public static async isSystemSupported(): Promise<boolean | null> {
// @ts-ignore // @ts-ignore
if (!navigator.gpu) { if (!navigator.gpu) {
console.warn('WebGPU not supported in this environment'); console.warn('WebGPU not supported in this environment');
return null; return null;
} }
try { try {
// @ts-ignore // @ts-ignore
const adapter = await navigator.gpu.requestAdapter(); const adapter = await navigator.gpu.requestAdapter();
return !!adapter; return !!adapter;
} catch (e) { } catch (e) {
return false; return false;
} }
} }
async initialize(onProgress?: (progress: WebLlmProgress) => void): Promise<void> { async initialize(onProgress?: (progress: WebLlmProgress) => void): Promise<void> {
if (this.engine || this.isLoading) return; if (this.engine || this.isLoading) return;
this.isLoading = true; this.isLoading = true;
const initProgressCallback: InitProgressCallback = (report) => { const initProgressCallback: InitProgressCallback = (report) => {
if (onProgress) { if (onProgress) {
// Parse the native report which is basically just text and percentage // Parse the native report which is basically just text and percentage
// Example: "Loading model 10% [ cached ]" or "Fetching param shard 1/4" // Example: "Loading model 10% [ cached ]" or "Fetching param shard 1/4"
const progressMatch = report.text.match(/(\d+)%/); const progressMatch = report.text.match(/(\d+)%/);
const progress = progressMatch ? parseInt(progressMatch[1], 10) : 0; const progress = progressMatch ? parseInt(progressMatch[1], 10) : 0;
onProgress({ onProgress({
progress, progress,
text: report.text, text: report.text,
timeElapsed: report.timeElapsed timeElapsed: report.timeElapsed
}); });
} }
}; };
try { try {
console.log('Initializing WebLLM Engine...'); console.log('Initializing WebLLM Engine...');
this.engine = await CreateMLCEngine( this.engine = await CreateMLCEngine(
DEFAULT_MODEL, DEFAULT_MODEL,
{ initProgressCallback } { initProgressCallback }
); );
this.isReady = true; this.isReady = true;
console.log('WebLLM Engine Ready'); console.log('WebLLM Engine Ready');
} catch (error) { } catch (error) {
console.error('Failed to initialize WebLLM:', error); console.error('Failed to initialize WebLLM:', error);
this.engine = null; this.engine = null;
throw error; throw error;
} finally { } finally {
this.isLoading = false; this.isLoading = false;
} }
} }
async chat(messages: { role: 'system' | 'user' | 'assistant', content: string }[]): Promise<AsyncGenerator<string>> { async chat(messages: { role: 'system' | 'user' | 'assistant', content: string }[]): Promise<AsyncGenerator<string>> {
if (!this.engine || !this.isReady) { if (!this.engine || !this.isReady) {
throw new Error("WebLLM Engine not initialized. Please load the model first."); throw new Error("WebLLM Engine not initialized. Please load the model first.");
} }
console.log('WebLLM: Creating completion...'); const completion = await this.engine.chat.completions.create({
const startTime = performance.now(); messages,
const completion = await this.engine.chat.completions.create({ stream: true,
messages, temperature: 0.1, // Low temp for code/logic generation
stream: true, max_tokens: 4096, // Sufficient for diagrams
temperature: 0, // Deterministic output for code });
max_tokens: 512, // Mermaid code is compact
top_p: 0.9, // Faster sampling // Create a generator to stream chunks easily
repetition_penalty: 1.1, // Avoid repetitive output async function* streamGenerator() {
}); for await (const chunk of completion) {
console.log('WebLLM: Completion created, streaming...'); const content = chunk.choices[0]?.delta?.content || "";
if (content) {
// Create a generator to stream chunks easily yield content;
async function* streamGenerator() { }
let tokenCount = 0; }
for await (const chunk of completion) { }
const content = chunk.choices[0]?.delta?.content || "";
if (content) { return streamGenerator();
tokenCount++; }
if (tokenCount === 1) console.log('WebLLM: First token received');
yield content; getStatus(): { isReady: boolean; isLoading: boolean; model: string } {
} return {
} isReady: this.isReady,
const endTime = performance.now(); isLoading: this.isLoading,
console.log(`WebLLM: Generation complete (${tokenCount} tokens, ${((endTime - startTime) / 1000).toFixed(1)}s)`); model: DEFAULT_MODEL
} };
}
return streamGenerator(); }
}
// Singleton instance
getStatus(): { isReady: boolean; isLoading: boolean; model: string } { export const webLlmService = new WebLlmService();
return {
isReady: this.isReady,
isLoading: this.isLoading,
model: DEFAULT_MODEL
};
}
}
// Singleton instance
export const webLlmService = new WebLlmService();

View file

@ -165,6 +165,42 @@ export function Editor() {
</div> </div>
</div> </div>
)} )}
{/* Mobile Empty State - Big Get Started Prompt */}
{nodes.length === 0 && !isLoading && isMobile && !mobileEditorOpen && (
<div className="absolute inset-0 flex items-center justify-center z-10 p-8">
<div className="text-center w-full max-w-xs">
{/* Icon */}
<div className="w-20 h-20 rounded-3xl bg-gradient-to-br from-blue-500 to-indigo-600 flex items-center justify-center mx-auto mb-6 shadow-2xl shadow-blue-600/30">
<Sparkles className="w-10 h-10 text-white" />
</div>
{/* Title */}
<h2 className="text-2xl font-black tracking-tight text-slate-800 dark:text-white mb-3">
Create Your Diagram
</h2>
{/* Description */}
<p className="text-slate-500 dark:text-slate-400 text-sm leading-relaxed mb-8">
Describe your system in plain English, upload an image, or write Mermaid code.
</p>
{/* Get Started Button */}
<button
onClick={() => setMobileEditorOpen(true)}
className="w-full min-h-[56px] flex items-center justify-center gap-3 px-6 py-4 rounded-2xl bg-gradient-to-br from-blue-600 to-indigo-600 text-white font-bold text-base shadow-xl shadow-blue-600/30 active:scale-[0.98] transition-transform"
>
<Sparkles className="w-5 h-5" />
Get Started
</button>
{/* Hint */}
<p className="mt-6 text-xs text-slate-400 dark:text-slate-500">
Or tap the <span className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-blue-600 text-white align-middle mx-0.5"><Sparkles className="w-3 h-3" /></span> button anytime
</p>
</div>
</div>
)}
</main> </main>
{/* Right Inspector Panel - Sidebar on desktop, Sheet on mobile */} {/* Right Inspector Panel - Sidebar on desktop, Sheet on mobile */}

View file

@ -1,132 +1,132 @@
import { useState, useMemo } from 'react'; import { useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
ArrowLeft, Search, Trash2, Activity, Clock, ArrowLeft, Search, Trash2, Activity, Clock,
ArrowRight, Zap, Filter ArrowRight, Zap, Filter
} from 'lucide-react'; } from 'lucide-react';
import { useFlowStore } from '../store'; import { useFlowStore } from '../store';
export function History() { export function History() {
const navigate = useNavigate(); const navigate = useNavigate();
const { savedDiagrams, deleteDiagram } = useFlowStore(); const { savedDiagrams, deleteDiagram } = useFlowStore();
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const filteredDiagrams = useMemo(() => { const filteredDiagrams = useMemo(() => {
return [...savedDiagrams] return [...savedDiagrams]
.filter(d => d.name.toLowerCase().includes(searchQuery.toLowerCase())) .filter(d => d.name.toLowerCase().includes(searchQuery.toLowerCase()))
.reverse(); .reverse();
}, [savedDiagrams, searchQuery]); }, [savedDiagrams, searchQuery]);
const handleDelete = (e: React.MouseEvent, id: string) => { const handleDelete = (e: React.MouseEvent, id: string) => {
e.stopPropagation(); e.stopPropagation();
if (confirm('Are you sure you want to delete this intelligence draft?')) { if (confirm('Are you sure you want to delete this intelligence draft?')) {
deleteDiagram(id); deleteDiagram(id);
} }
}; };
return ( return (
<div className="h-screen bg-void text-primary overflow-hidden font-sans relative flex flex-col"> <div className="h-screen bg-void text-primary overflow-hidden font-sans relative flex flex-col">
{/* Ambient Background */} {/* Ambient Background */}
<div className="absolute inset-0 z-0 pointer-events-none opacity-20"> <div className="absolute inset-0 z-0 pointer-events-none opacity-20">
<div className="absolute top-0 right-0 w-[50vw] h-[50vh] bg-blue-500/5 blur-[120px] rounded-full" /> <div className="absolute top-0 right-0 w-[50vw] h-[50vh] bg-blue-500/5 blur-[120px] rounded-full" />
<div className="absolute bottom-0 left-0 w-[40vw] h-[40vh] bg-indigo-500/5 blur-[120px] rounded-full" /> <div className="absolute bottom-0 left-0 w-[40vw] h-[40vh] bg-indigo-500/5 blur-[120px] rounded-full" />
</div> </div>
{/* Header */} {/* Header */}
<header className="h-20 px-12 flex items-center justify-between z-10 border-b titanium-border bg-surface/50 backdrop-blur-md"> <header className="h-20 px-12 flex items-center justify-between z-10 border-b titanium-border bg-surface/50 backdrop-blur-md">
<div className="flex items-center gap-8"> <div className="flex items-center gap-8">
<button <button
onClick={() => navigate('/')} onClick={() => navigate('/')}
className="w-10 h-10 flex items-center justify-center rounded-xl bg-surface border titanium-border text-secondary hover:text-primary transition-all hover:scale-105 active:scale-95" className="w-10 h-10 flex items-center justify-center rounded-xl bg-surface border titanium-border text-secondary hover:text-primary transition-all hover:scale-105 active:scale-95"
> >
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-4 h-4" />
</button> </button>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center shadow-lg shadow-blue-600/20"> <div className="w-8 h-8 rounded-lg bg-blue-600 flex items-center justify-center shadow-lg shadow-blue-600/20">
<Zap className="w-4 h-4 text-white fill-white/20" /> <Zap className="w-4 h-4 text-white fill-white/20" />
</div> </div>
<h1 className="text-lg font-display font-black tracking-tight">Intelligence Archive</h1> <h1 className="text-lg font-display font-black tracking-tight">Intelligence Archive</h1>
</div> </div>
</div> </div>
<div className="flex items-center gap-4 flex-1 max-w-xl mx-12"> <div className="flex items-center gap-4 flex-1 max-w-xl mx-12">
<div className="relative flex-1 group"> <div className="relative flex-1 group">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-tertiary group-focus-within:text-blue-500 transition-colors" /> <Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-tertiary group-focus-within:text-blue-500 transition-colors" />
<input <input
type="text" type="text"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Find specific logic draft..." placeholder="Find specific logic draft..."
className="w-full bg-void/30 border titanium-border rounded-2xl py-2.5 pl-12 pr-4 text-sm outline-none focus:border-blue-500/50 transition-all text-primary" className="w-full bg-void/30 border titanium-border rounded-2xl py-2.5 pl-12 pr-4 text-sm outline-none focus:border-blue-500/50 transition-all text-primary"
/> />
</div> </div>
<div className="flex items-center gap-2 px-4 py-2 rounded-xl bg-surface border titanium-border text-[9px] font-black uppercase tracking-widest text-tertiary"> <div className="flex items-center gap-2 px-4 py-2 rounded-xl bg-surface border titanium-border text-[9px] font-black uppercase tracking-widest text-tertiary">
<Filter className="w-3 h-3" /> <Filter className="w-3 h-3" />
<span>{filteredDiagrams.length} Results</span> <span>{filteredDiagrams.length} Results</span>
</div> </div>
</div> </div>
</header> </header>
{/* Content List */} {/* Content List */}
<main className="flex-1 overflow-y-auto p-12 relative z-10 hide-scrollbar"> <main className="flex-1 overflow-y-auto p-12 relative z-10 hide-scrollbar">
{filteredDiagrams.length > 0 ? ( {filteredDiagrams.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 max-w-7xl mx-auto animate-slide-up"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 max-w-7xl mx-auto animate-slide-up">
{filteredDiagrams.map((diagram) => ( {filteredDiagrams.map((diagram) => (
<div <div
key={diagram.id} key={diagram.id}
onClick={() => navigate(`/diagram?id=${diagram.id}`)} onClick={() => navigate(`/diagram?id=${diagram.id}`)}
className="group relative glass-panel rounded-3xl p-6 border titanium-border hover:border-blue-500/30 transition-all duration-300 cursor-pointer shadow-sm hover:shadow-2xl hover:-translate-y-1" className="group relative glass-panel rounded-3xl p-6 border titanium-border hover:border-blue-500/30 transition-all duration-300 cursor-pointer shadow-sm hover:shadow-2xl hover:-translate-y-1"
> >
<div className="flex items-start justify-between mb-4"> <div className="flex items-start justify-between mb-4">
<div className="w-12 h-12 rounded-2xl bg-blue-500/10 flex items-center justify-center shrink-0"> <div className="w-12 h-12 rounded-2xl bg-blue-500/10 flex items-center justify-center shrink-0">
<Activity className="w-5 h-5 text-blue-500 group-hover:scale-110 transition-transform" /> <Activity className="w-5 h-5 text-blue-500 group-hover:scale-110 transition-transform" />
</div> </div>
<button <button
onClick={(e) => handleDelete(e, diagram.id)} onClick={(e) => handleDelete(e, diagram.id)}
className="w-8 h-8 flex items-center justify-center rounded-lg text-tertiary hover:text-red-500 hover:bg-red-500/10 transition-all opacity-0 group-hover:opacity-100" className="w-8 h-8 flex items-center justify-center rounded-lg text-tertiary hover:text-red-500 hover:bg-red-500/10 transition-all opacity-0 group-hover:opacity-100"
> >
<Trash2 className="w-3.5 h-3.5" /> <Trash2 className="w-3.5 h-3.5" />
</button> </button>
</div> </div>
<div className="space-y-1 mb-6"> <div className="space-y-1 mb-6">
<h4 className="font-bold text-base truncate text-primary pr-4">{diagram.name}</h4> <h4 className="font-bold text-base truncate text-primary pr-4">{diagram.name}</h4>
<div className="flex items-center gap-2 text-[10px] text-tertiary font-bold uppercase tracking-widest"> <div className="flex items-center gap-2 text-[10px] text-tertiary font-bold uppercase tracking-widest">
<Clock className="w-3 h-3" /> <Clock className="w-3 h-3" />
<span>{new Date(diagram.createdAt).toLocaleDateString()}</span> <span>{new Date(diagram.createdAt).toLocaleDateString()}</span>
</div> </div>
</div> </div>
<div className="flex items-center justify-between pt-4 border-t border-slate-500/5"> <div className="flex items-center justify-between pt-4 border-t border-slate-500/5">
<span className="text-[9px] font-black text-blue-500 uppercase tracking-widest"> <span className="text-[9px] font-black text-blue-500 uppercase tracking-widest">
{diagram.nodes.filter(n => n.type !== 'group').length} Entities {diagram.nodes.filter(n => n.type !== 'group').length} Entities
</span> </span>
<div className="flex items-center gap-1 text-[9px] font-black uppercase text-tertiary group-hover:text-blue-500 transition-colors"> <div className="flex items-center gap-1 text-[9px] font-black uppercase text-tertiary group-hover:text-blue-500 transition-colors">
<span>Restore</span> <span>Restore</span>
<ArrowRight className="w-3 h-3 group-hover:translate-x-1 transition-transform" /> <ArrowRight className="w-3 h-3 group-hover:translate-x-1 transition-transform" />
</div> </div>
</div> </div>
</div> </div>
))} ))}
</div> </div>
) : ( ) : (
<div className="h-full flex flex-col items-center justify-center opacity-40"> <div className="h-full flex flex-col items-center justify-center opacity-40">
<div className="w-20 h-20 rounded-full border-2 border-dashed border-slate-500 flex items-center justify-center mb-6"> <div className="w-20 h-20 rounded-full border-2 border-dashed border-slate-500 flex items-center justify-center mb-6">
<Search className="w-8 h-8 text-slate-500" /> <Search className="w-8 h-8 text-slate-500" />
</div> </div>
<p className="text-[10px] font-black uppercase tracking-[0.4em] text-center leading-relaxed"> <p className="text-[10px] font-black uppercase tracking-[0.4em] text-center leading-relaxed">
{searchQuery ? 'No intelligence matching your query' : 'Your intelligence archive is empty'} {searchQuery ? 'No intelligence matching your query' : 'Your intelligence archive is empty'}
</p> </p>
</div> </div>
)} )}
</main> </main>
{/* Footer Statistics */} {/* Footer Statistics */}
<footer className="h-12 border-t titanium-border bg-surface/50 backdrop-blur-md flex items-center justify-center px-12 z-10"> <footer className="h-12 border-t titanium-border bg-surface/50 backdrop-blur-md flex items-center justify-center px-12 z-10">
<p className="text-[9px] font-black text-tertiary uppercase tracking-[0.5em]"> <p className="text-[9px] font-black text-tertiary uppercase tracking-[0.5em]">
Active Persistence Engine v3.1.2 Total Capacity: {savedDiagrams.length}/100 Active Persistence Engine v3.1.2 Total Capacity: {savedDiagrams.length}/100
</p> </p>
</footer> </footer>
</div> </div>
); );
} }

View file

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

View file

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

View file

@ -416,4 +416,36 @@
.animate-fade-in { .animate-fade-in {
animation: fade-in 0.2s ease-out forwards; animation: fade-in 0.2s ease-out forwards;
}
/* Slide in from right animation - for mobile menu */
@keyframes slide-in-right {
from {
opacity: 0;
transform: translateX(100%);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.animate-slide-in-right {
animation: slide-in-right 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
/* Slide out to right animation */
@keyframes slide-out-right {
from {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(100%);
}
}
.animate-slide-out-right {
animation: slide-out-right 0.25s ease-out forwards;
} }

View file

@ -1,42 +1,42 @@
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import * as matchers from '@testing-library/jest-dom/matchers'; import * as matchers from '@testing-library/jest-dom/matchers';
import { expect } from 'vitest'; import { expect } from 'vitest';
expect.extend(matchers); expect.extend(matchers);
// Mock localStorage // Mock localStorage
const localStorageMock = (function () { const localStorageMock = (function () {
let store: Record<string, string> = {}; let store: Record<string, string> = {};
return { return {
getItem: function (key: string) { getItem: function (key: string) {
return store[key] || null; return store[key] || null;
}, },
setItem: function (key: string, value: string) { setItem: function (key: string, value: string) {
store[key] = value.toString(); store[key] = value.toString();
}, },
clear: function () { clear: function () {
store = {}; store = {};
}, },
removeItem: function (key: string) { removeItem: function (key: string) {
delete store[key]; delete store[key];
} }
}; };
})(); })();
Object.defineProperty(window, 'localStorage', { Object.defineProperty(window, 'localStorage', {
value: localStorageMock value: localStorageMock
}); });
Object.defineProperty(window, 'matchMedia', { Object.defineProperty(window, 'matchMedia', {
writable: true, writable: true,
value: (query: string) => ({ value: (query: string) => ({
matches: false, matches: false,
media: query, media: query,
onchange: null, onchange: null,
addListener: () => { }, addListener: () => { },
removeListener: () => { }, removeListener: () => { },
addEventListener: () => { }, addEventListener: () => { },
removeEventListener: () => { }, removeEventListener: () => { },
dispatchEvent: () => false, dispatchEvent: () => false,
}), }),
}); });

View file

@ -1,105 +1,105 @@
export interface NodePosition { export interface NodePosition {
x: number; x: number;
y: number; y: number;
width?: number; width?: number;
height?: number; height?: number;
} }
export interface EdgeConnection { export interface EdgeConnection {
source: string; source: string;
target: string; target: string;
type?: string; type?: string;
} }
export interface LayoutMetrics { export interface LayoutMetrics {
nodeCount: number; nodeCount: number;
edgeCount: number; edgeCount: number;
edgeCrossings: number; edgeCrossings: number;
nodeDensity: number; nodeDensity: number;
averageNodeSpacing: number; averageNodeSpacing: number;
visualComplexity: number; // 0-100 scale visualComplexity: number; // 0-100 scale
aspectRatio: number; aspectRatio: number;
} }
export interface VisualIssue { export interface VisualIssue {
type: 'edge-crossing' | 'overlap' | 'poor-spacing' | 'unclear-flow' | 'inefficient-layout' | 'style-consistency'; type: 'edge-crossing' | 'overlap' | 'poor-spacing' | 'unclear-flow' | 'inefficient-layout' | 'style-consistency';
severity: 'low' | 'medium' | 'high'; severity: 'low' | 'medium' | 'high';
description: string; description: string;
affectedNodes?: string[]; affectedNodes?: string[];
affectedEdges?: string[]; affectedEdges?: string[];
suggestedFix?: string; suggestedFix?: string;
} }
export interface LayoutSuggestion { export interface LayoutSuggestion {
id: string; id: string;
title: string; title: string;
description: string; description: string;
type: 'spacing' | 'grouping' | 'routing' | 'hierarchy' | 'style' | 'node-shape' | 'node-color-semantic'; type: 'spacing' | 'grouping' | 'routing' | 'hierarchy' | 'style' | 'node-shape' | 'node-color-semantic';
impact: 'low' | 'medium' | 'high'; impact: 'low' | 'medium' | 'high';
estimatedImprovement: number; // percentage estimatedImprovement: number; // percentage
beforeState: { beforeState: {
metrics: LayoutMetrics; metrics: LayoutMetrics;
issues: VisualIssue[]; issues: VisualIssue[];
}; };
afterState: { afterState: {
metrics: LayoutMetrics; metrics: LayoutMetrics;
estimatedIssues: VisualIssue[]; estimatedIssues: VisualIssue[];
}; };
implementation: { implementation: {
nodePositions: Record<string, NodePosition>; nodePositions: Record<string, NodePosition>;
edgeRouting?: Record<string, any>; edgeRouting?: Record<string, any>;
styleChanges?: Record<string, any>; styleChanges?: Record<string, any>;
description?: string; description?: string;
}; };
} }
export interface VisualOrganizationRequest { export interface VisualOrganizationRequest {
nodes: Array<{ nodes: Array<{
id: string; id: string;
label: string; label: string;
type: string; type: string;
position: NodePosition; position: NodePosition;
metadata?: any; metadata?: any;
}>; }>;
edges: EdgeConnection[]; edges: EdgeConnection[];
currentLayout?: { currentLayout?: {
direction: 'TB' | 'LR' | 'BT' | 'RL'; direction: 'TB' | 'LR' | 'BT' | 'RL';
spacing: number; spacing: number;
}; };
preferences?: { preferences?: {
priority: 'readability' | 'compactness' | 'flow-clarity'; priority: 'readability' | 'compactness' | 'flow-clarity';
groupSimilarNodes: boolean; groupSimilarNodes: boolean;
minimizeCrossings: boolean; minimizeCrossings: boolean;
preserveUserLayout: boolean; preserveUserLayout: boolean;
}; };
} }
export interface VisualOrganizationResponse { export interface VisualOrganizationResponse {
success: boolean; success: boolean;
suggestions: LayoutSuggestion[]; suggestions: LayoutSuggestion[];
summary: { summary: {
totalIssues: number; totalIssues: number;
criticalIssues: number; criticalIssues: number;
potentialImprovement: number; potentialImprovement: number;
recommendedAction: string; recommendedAction: string;
}; };
error?: string; error?: string;
} }
export interface VisualAnalysisResult { export interface VisualAnalysisResult {
metrics: LayoutMetrics; metrics: LayoutMetrics;
issues: VisualIssue[]; issues: VisualIssue[];
strengths: string[]; strengths: string[];
recommendations: string[]; recommendations: string[];
} }
export interface AISuggestionPrompt { export interface AISuggestionPrompt {
systemPrompt: string; systemPrompt: string;
userPrompt: string; userPrompt: string;
context: { context: {
currentMetrics: LayoutMetrics; currentMetrics: LayoutMetrics;
identifiedIssues: VisualIssue[]; identifiedIssues: VisualIssue[];
nodeTypes: string[]; nodeTypes: string[];
edgeTypes: string[]; edgeTypes: string[];
}; };
} }

50
todo.md
View file

@ -1,50 +0,0 @@
# AI Visual Organization Agent Implementation
## Phase 1: Enhanced AI Service Extension
- [x] Create visual organization types and interfaces
- [x] Add visual analysis AI methods to aiService.ts
- [x] Create specialized prompts for layout suggestions
- [x] Implement visual complexity scoring
## Phase 2: Visual Organization Engine
- [x] Create visualOrganizer.ts module
- [x] Implement layout analysis algorithms
- [x] Add edge crossing detection and optimization
- [x] Create node grouping and clustering logic
- [x] Integrate with existing layout engine
## Phase 3: UI Integration
- [x] Add Smart Layout panel to FlowCanvas (via NodeDetailsPanel)
- [x] Create suggestion cards component
- [x] Implement before/after preview functionality
- [x] Add AI organization control button
- [x] Create suggestion acceptance workflow
## Phase 4: Advanced Features
- [x] Multi-layout comparison system
- [x] Auto-organization presets by diagram type
- [x] Visual hierarchy optimization
- [x] Style consistency recommendations
## Phase 5: Smart Node Optimization (New)
- [x] AI Node Content Analysis (Shape/Icon/Label)
- [x] Intelligent Color Coding
- [x] Design System Refinement
## Phase 6: Critical Functionality Fixes
- [x] Enable 'Save Draft' button
- [x] Enable 'Export' button
## Phase 7: UI Redesign
- [x] Redesign Left Sidebar (Tabs, Editor, Bottom Actions)
- [x] Redesign Canvas Controls (Vertical Segmented Toolbar)
- [x] Implement Multi-Node Selection Mode (Pan vs Select toggle)
- [x] Redesign Sidebars (Mood & Tone Unification)
- [x] Refine Light Theme (Contrast and Visibility Fixes)
- [x] Theme Audit (Unify EditorHeader, Buttons, Cards)
## Testing & Polish
- [x] Test visual organization suggestions
- [x] Validate AI integration
- [x] Performance optimization
- [x] UI/UX refinements

View file

@ -1,11 +1,11 @@
/// <reference types="vitest" /> /// <reference types="vitest" />
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
export default defineConfig({ export default defineConfig({
test: { test: {
globals: true, globals: true,
environment: 'jsdom', environment: 'jsdom',
setupFiles: './src/test/setup.ts', setupFiles: './src/test/setup.ts',
css: true, css: true,
}, },
}); });