mirror of
https://github.com/vndangkhoa/Sys-Arc-Visl.git
synced 2026-04-04 17:08:17 +07:00
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:
parent
a4793bf996
commit
5c4b83203c
41 changed files with 4150 additions and 3720 deletions
46
Dockerfile
46
Dockerfile
|
|
@ -1,23 +1,23 @@
|
|||
# Stage 1: Build the application
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Serve with Nginx
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built assets from builder stage
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy custom nginx config
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
# Stage 1: Build the application
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Serve with Nginx
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built assets from builder stage
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy custom nginx config
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
|
|
|||
|
|
@ -1,35 +1,35 @@
|
|||
version: '3.8'
|
||||
|
||||
services:
|
||||
webapp:
|
||||
build: .
|
||||
container_name: kv-graph-web
|
||||
restart: always
|
||||
ports:
|
||||
- "8338:80"
|
||||
# Optional if you want to have Ollama running in a container
|
||||
depends_on:
|
||||
- ollama
|
||||
|
||||
# Optional if you want to have Ollama running in a container
|
||||
ollama:
|
||||
image: ollama/ollama:latest
|
||||
container_name: ollama-service
|
||||
restart: always
|
||||
ports:
|
||||
- "11434:11434"
|
||||
volumes:
|
||||
- ./ollama_data:/root/.ollama
|
||||
environment:
|
||||
- OLLAMA_KEEP_ALIVE=24h
|
||||
- OLLAMA_ORIGINS="*"
|
||||
# NVIDIA GPU Support Configuration
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [ gpu ]
|
||||
# Fallback for systems without 'deploy' support (older compose versions) or explicit runtime
|
||||
# runtime: nvidia
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
webapp:
|
||||
build: .
|
||||
container_name: kv-graph-web
|
||||
restart: always
|
||||
ports:
|
||||
- "8338:80"
|
||||
# Optional if you want to have Ollama running in a container
|
||||
depends_on:
|
||||
- ollama
|
||||
|
||||
# Optional if you want to have Ollama running in a container
|
||||
ollama:
|
||||
image: ollama/ollama:latest
|
||||
container_name: ollama-service
|
||||
restart: always
|
||||
ports:
|
||||
- "11434:11434"
|
||||
volumes:
|
||||
- ./ollama_data:/root/.ollama
|
||||
environment:
|
||||
- OLLAMA_KEEP_ALIVE=24h
|
||||
- OLLAMA_ORIGINS="*"
|
||||
# NVIDIA GPU Support Configuration
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
count: 1
|
||||
capabilities: [ gpu ]
|
||||
# Fallback for systems without 'deploy' support (older compose versions) or explicit runtime
|
||||
# runtime: nvidia
|
||||
|
|
|
|||
57
nginx.conf
57
nginx.conf
|
|
@ -1,21 +1,36 @@
|
|||
server {
|
||||
listen 80;
|
||||
|
||||
# Enable gzip compression
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
|
||||
# Root directory for the app
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# NOTE: Ollama proxy is NOT included by default to allow standalone operation.
|
||||
# The app works with Browser AI (WebLLM/Transformers.js) without any external services.
|
||||
#
|
||||
# If you need to proxy requests to Ollama, either:
|
||||
# 1. Set the Ollama URL directly in the app settings (e.g., http://your-nas-ip:11434)
|
||||
# 2. Or mount a custom nginx.conf with your proxy configuration
|
||||
}
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
# Enable gzip compression
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
|
||||
# Root directory for the app
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Proxy Ollama API requests
|
||||
# This solves Mixed Content (HTTPS -> HTTP) and CORS issues
|
||||
location /api/ {
|
||||
proxy_pass http://ollama:11434/api/;
|
||||
proxy_set_header Host $host;
|
||||
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
108
package-lock.json
generated
|
|
@ -13,10 +13,12 @@
|
|||
"@mlc-ai/web-llm": "^0.2.80",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@types/dagre": "^0.7.53",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/randomcolor": "^0.5.9",
|
||||
"@xyflow/react": "^12.10.0",
|
||||
"clsx": "^2.1.1",
|
||||
"dagre": "^0.8.5",
|
||||
"file-saver": "^2.0.5",
|
||||
"html-to-image": "^1.11.13",
|
||||
"lucide-react": "^0.562.0",
|
||||
"mermaid": "^11.12.2",
|
||||
|
|
@ -170,7 +172,6 @@
|
|||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
|
|
@ -577,7 +578,6 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
|
|
@ -621,7 +621,6 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
|
|
@ -1236,9 +1235,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@exodus/bytes": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.6.0.tgz",
|
||||
"integrity": "sha512-y32mI9627q5LR/L8fLc4YyDRJQOi+jK0D9okzLilAdiU3F9we3zC7Y7CFrR/8vAvUyv7FgBAYcNHtvbmhKCFcw==",
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.7.0.tgz",
|
||||
"integrity": "sha512-5i+BtvujK/vM07YCGDyz4C4AyDzLmhxHMtM5HpUyPRtJPBdFPsj290ffXW+UXY21/G7GtXeHD2nRmq0T1ShyQQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
|
@ -2736,7 +2735,8 @@
|
|||
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
|
||||
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
|
|
@ -3067,6 +3067,12 @@
|
|||
"dev": true,
|
||||
"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": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
|
|
@ -3174,7 +3180,6 @@
|
|||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
|
|
@ -3185,7 +3190,6 @@
|
|||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
|
|
@ -3266,7 +3270,6 @@
|
|||
"integrity": "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.50.1",
|
||||
"@typescript-eslint/types": "8.50.1",
|
||||
|
|
@ -3688,7 +3691,6 @@
|
|||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
|
|
@ -3739,6 +3741,7 @@
|
|||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
|
|
@ -3884,7 +3887,6 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
|
|
@ -4187,7 +4189,6 @@
|
|||
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz",
|
||||
"integrity": "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10"
|
||||
}
|
||||
|
|
@ -4588,7 +4589,6 @@
|
|||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
|
|
@ -4817,12 +4817,13 @@
|
|||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"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==",
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
|
||||
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
|
|
@ -4969,7 +4970,6 @@
|
|||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
|
|
@ -5238,6 +5238,12 @@
|
|||
"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": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
|
|
@ -5911,7 +5917,6 @@
|
|||
"integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@acemir/cssom": "^0.9.28",
|
||||
"@asamuzakjp/dom-selector": "^6.7.6",
|
||||
|
|
@ -6436,6 +6441,7 @@
|
|||
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"lz-string": "bin/bin.js"
|
||||
}
|
||||
|
|
@ -6451,15 +6457,15 @@
|
|||
}
|
||||
},
|
||||
"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==",
|
||||
"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": ">= 18"
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/matcher": {
|
||||
|
|
@ -6509,18 +6515,6 @@
|
|||
"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": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||
|
|
@ -6615,6 +6609,29 @@
|
|||
"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": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
|
|
@ -6929,7 +6946,6 @@
|
|||
"integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
|
|
@ -7033,6 +7049,7 @@
|
|||
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1",
|
||||
"ansi-styles": "^5.0.0",
|
||||
|
|
@ -7048,6 +7065,7 @@
|
|||
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
|
|
@ -7129,7 +7147,6 @@
|
|||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
|
@ -7139,7 +7156,6 @@
|
|||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
|
|
@ -7152,7 +7168,8 @@
|
|||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
|
||||
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.18.0",
|
||||
|
|
@ -7809,9 +7826,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/ts-api-utils": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz",
|
||||
"integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==",
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.3.0.tgz",
|
||||
"integrity": "sha512-6eg3Y9SF7SsAvGzRHQvvc1skDAhwI4YQ32ui1scxD1Ccr0G5qIIbUBT3pFTKX8kmWIQClHobtUdNuaBgwdfdWg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
|
@ -7868,7 +7885,6 @@
|
|||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
@ -7988,7 +8004,6 @@
|
|||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
|
|
@ -8393,7 +8408,6 @@
|
|||
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.28.tgz",
|
||||
"integrity": "sha512-EgnDOXs8+hBVm6mq3/S89Kiwzh5JRbn7w2wXwbrMRyKy/8dOFsLvuIfC+x19ZdtaDc0tA9rQmdZzbqqNHG44wA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"lib0": "^0.2.99"
|
||||
},
|
||||
|
|
@ -8425,7 +8439,6 @@
|
|||
"integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
|
@ -8466,7 +8479,6 @@
|
|||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz",
|
||||
"integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -31,10 +31,12 @@
|
|||
"@mlc-ai/web-llm": "^0.2.80",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@types/dagre": "^0.7.53",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/randomcolor": "^0.5.9",
|
||||
"@xyflow/react": "^12.10.0",
|
||||
"clsx": "^2.1.1",
|
||||
"dagre": "^0.8.5",
|
||||
"file-saver": "^2.0.5",
|
||||
"html-to-image": "^1.11.13",
|
||||
"lucide-react": "^0.562.0",
|
||||
"mermaid": "^11.12.2",
|
||||
|
|
|
|||
|
|
@ -1,25 +1,25 @@
|
|||
{
|
||||
"name": "SysVis.AI - System Design Visualizer",
|
||||
"short_name": "SysVis.AI",
|
||||
"description": "AI-Powered System Design Visualizer - Transform ideas into beautiful, interactive flowcharts",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0B1221",
|
||||
"theme_color": "#0B1221",
|
||||
"orientation": "any",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/vite.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
}
|
||||
],
|
||||
"categories": [
|
||||
"productivity",
|
||||
"developer tools",
|
||||
"design"
|
||||
],
|
||||
"scope": "/",
|
||||
"lang": "en"
|
||||
{
|
||||
"name": "SysVis.AI - System Design Visualizer",
|
||||
"short_name": "SysVis.AI",
|
||||
"description": "AI-Powered System Design Visualizer - Transform ideas into beautiful, interactive flowcharts",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0B1221",
|
||||
"theme_color": "#0B1221",
|
||||
"orientation": "any",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/vite.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
}
|
||||
],
|
||||
"categories": [
|
||||
"productivity",
|
||||
"developer tools",
|
||||
"design"
|
||||
],
|
||||
"scope": "/",
|
||||
"lang": "en"
|
||||
}
|
||||
|
|
@ -1,38 +1,38 @@
|
|||
# Check if Ollama is installed
|
||||
if (-not (Get-Command ollama -ErrorAction SilentlyContinue)) {
|
||||
Write-Host "Error: Ollama is not installed or not in your PATH." -ForegroundColor Red
|
||||
Write-Host "Please install Ollama from https://ollama.com/"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Checking local Ollama service status..." -ForegroundColor Cyan
|
||||
try {
|
||||
$status = Invoke-RestMethod -Uri "http://localhost:11434/api/tags" -ErrorAction Stop
|
||||
Write-Host "Ollama service is running!" -ForegroundColor Green
|
||||
} catch {
|
||||
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."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Define recommended models
|
||||
$models = @(
|
||||
@{ 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 = "llama3"; Desc = "Fast standard LLM for text logic (4.7GB)." }
|
||||
)
|
||||
|
||||
Write-Host "`nReady to pull recommended models:" -ForegroundColor Yellow
|
||||
foreach ($m in $models) {
|
||||
Write-Host " - $($m.Name): $($m.Desc)"
|
||||
}
|
||||
|
||||
$confirm = Read-Host "`nDo you want to pull these models now? (Y/n)"
|
||||
if ($confirm -eq 'n') { exit }
|
||||
|
||||
foreach ($m in $models) {
|
||||
Write-Host "`nPulling $($m.Name)..." -ForegroundColor Cyan
|
||||
ollama pull $m.Name
|
||||
}
|
||||
|
||||
Write-Host "`nAll models ready! You can now select them in the KV-Graph settings." -ForegroundColor Green
|
||||
# Check if Ollama is installed
|
||||
if (-not (Get-Command ollama -ErrorAction SilentlyContinue)) {
|
||||
Write-Host "Error: Ollama is not installed or not in your PATH." -ForegroundColor Red
|
||||
Write-Host "Please install Ollama from https://ollama.com/"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Checking local Ollama service status..." -ForegroundColor Cyan
|
||||
try {
|
||||
$status = Invoke-RestMethod -Uri "http://localhost:11434/api/tags" -ErrorAction Stop
|
||||
Write-Host "Ollama service is running!" -ForegroundColor Green
|
||||
} catch {
|
||||
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."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Define recommended models
|
||||
$models = @(
|
||||
@{ 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 = "llama3"; Desc = "Fast standard LLM for text logic (4.7GB)." }
|
||||
)
|
||||
|
||||
Write-Host "`nReady to pull recommended models:" -ForegroundColor Yellow
|
||||
foreach ($m in $models) {
|
||||
Write-Host " - $($m.Name): $($m.Desc)"
|
||||
}
|
||||
|
||||
$confirm = Read-Host "`nDo you want to pull these models now? (Y/n)"
|
||||
if ($confirm -eq 'n') { exit }
|
||||
|
||||
foreach ($m in $models) {
|
||||
Write-Host "`nPulling $($m.Name)..." -ForegroundColor Cyan
|
||||
ollama pull $m.Name
|
||||
}
|
||||
|
||||
Write-Host "`nAll models ready! You can now select them in the KV-Graph settings." -ForegroundColor Green
|
||||
|
|
|
|||
|
|
@ -231,99 +231,156 @@ export function FlowCanvas() {
|
|||
size={1}
|
||||
/>
|
||||
|
||||
{/* Control Panel - Top Right (Unified Toolkit) */}
|
||||
<Panel position="top-right" className={`!m-4 !mr-6 flex flex-col items-end gap-3 z-50 transition-all duration-300 ${focusMode ? '!mt-20' : ''}`}>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowToolkit(!showToolkit)}
|
||||
className={`
|
||||
{/* Control Panel - Top Right (Unified Toolkit) - Desktop Only */}
|
||||
{!isMobile && (
|
||||
<Panel position="top-right" className={`!m-4 !mr-6 flex flex-col items-end gap-3 z-50 transition-all duration-300 ${focusMode ? '!mt-20' : ''}`}>
|
||||
<div className="relative">
|
||||
<button
|
||||
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
|
||||
${showToolkit
|
||||
? '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-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'}
|
||||
`}
|
||||
>
|
||||
<Settings2 className="w-4 h-4" />
|
||||
<span className="text-xs font-bold uppercase tracking-wider">Toolkit</span>
|
||||
<ChevronDown className={`w-3 h-3 transition-transform duration-200 ${showToolkit ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
>
|
||||
<Settings2 className="w-4 h-4" />
|
||||
<span className="text-xs font-bold uppercase tracking-wider">Toolkit</span>
|
||||
<ChevronDown className={`w-3 h-3 transition-transform duration-200 ${showToolkit ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{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">
|
||||
{/* Dropdown Menu */}
|
||||
{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">
|
||||
|
||||
{/* Section: Interaction Mode */}
|
||||
<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>
|
||||
<div className="grid grid-cols-2 gap-1 bg-slate-100 dark:bg-white/5 p-1 rounded-lg">
|
||||
<button
|
||||
onClick={() => setIsSelectionMode(false)}
|
||||
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'
|
||||
: 'text-slate-500 dark:text-slate-400 hover:bg-black/5 dark:hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<Hand className="w-4 h-4 mb-1" />
|
||||
<span className="text-[9px] font-bold">Pan</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsSelectionMode(true)}
|
||||
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'
|
||||
: 'text-slate-500 dark:text-slate-400 hover:bg-black/5 dark:hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<MousePointer2 className="w-4 h-4 mb-1" />
|
||||
<span className="text-[9px] font-bold">Select</span>
|
||||
</button>
|
||||
{/* Section: Interaction Mode */}
|
||||
<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>
|
||||
<div className="grid grid-cols-2 gap-1 bg-slate-100 dark:bg-white/5 p-1 rounded-lg">
|
||||
<button
|
||||
onClick={() => setIsSelectionMode(false)}
|
||||
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'
|
||||
: 'text-slate-500 dark:text-slate-400 hover:bg-black/5 dark:hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<Hand className="w-4 h-4 mb-1" />
|
||||
<span className="text-[9px] font-bold">Pan</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsSelectionMode(true)}
|
||||
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'
|
||||
: 'text-slate-500 dark:text-slate-400 hover:bg-black/5 dark:hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<MousePointer2 className="w-4 h-4 mb-1" />
|
||||
<span className="text-[9px] font-bold">Select</span>
|
||||
</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 className="h-px bg-slate-200 dark:bg-white/10 mx-2" />
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
{/* Mobile Bottom Action Bar */}
|
||||
{isMobile && nodes.length > 0 && !focusMode && (
|
||||
<Panel position="bottom-center" className="!mb-24 z-50">
|
||||
<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">
|
||||
{/* Zoom Out */}
|
||||
<button
|
||||
onClick={() => zoomOut()}
|
||||
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 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 */}
|
||||
<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>
|
||||
{/* Fit View */}
|
||||
<button
|
||||
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
|
||||
icon={Wand2}
|
||||
label="Auto Layout"
|
||||
active={false}
|
||||
onClick={handleAutoLayout}
|
||||
/>
|
||||
{/* Divider */}
|
||||
<div className="w-px h-6 bg-slate-200 dark:bg-white/10 mx-1" />
|
||||
|
||||
<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')}
|
||||
/>
|
||||
{/* Auto Layout */}
|
||||
<button
|
||||
onClick={handleAutoLayout}
|
||||
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="Auto Layout"
|
||||
>
|
||||
<Wand2 className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<MenuButton
|
||||
icon={Map}
|
||||
label="MiniMap Overlay"
|
||||
active={showMiniMap}
|
||||
onClick={() => setShowMiniMap(!showMiniMap)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</Panel>
|
||||
{/* Toggle Edge Style */}
|
||||
<button
|
||||
onClick={() => setEdgeStyle(edgeStyle === 'curved' ? 'straight' : 'curved')}
|
||||
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={edgeStyle === 'curved' ? 'Switch to Straight Edges' : 'Switch to Curved Edges'}
|
||||
>
|
||||
{edgeStyle === 'curved' ? <Spline className="w-5 h-5" /> : <Minus className="w-5 h-5 rotate-45" />}
|
||||
</button>
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{/* MiniMap Container - Bottom Right (Hidden on Mobile) */}
|
||||
<Panel position="bottom-right" className="!m-4 z-40">
|
||||
|
|
@ -342,8 +399,8 @@ export function FlowCanvas() {
|
|||
)}
|
||||
</Panel>
|
||||
|
||||
{/* Status Indicator - Bottom Left */}
|
||||
{nodes.length > 0 && (
|
||||
{/* Status Indicator - Bottom Left (Hidden on Mobile - shown in header) */}
|
||||
{nodes.length > 0 && !isMobile && (
|
||||
<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-2">
|
||||
|
|
|
|||
|
|
@ -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 z-[9999] flex flex-col gap-4
|
||||
${isMobile
|
||||
? 'left-4 right-4 top-16 max-h-[80vh] rounded-[2rem]'
|
||||
: 'top-24 right-12 w-96 rounded-[2rem]'
|
||||
? 'inset-0 rounded-none pt-safe'
|
||||
: 'top-24 right-12 w-96 max-h-[80vh] rounded-[2rem]'
|
||||
} floating-glass p-6 titanium-border shadow-2xl overflow-hidden`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between flex-shrink-0">
|
||||
<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">
|
||||
<Cpu className="w-5 h-5 text-blue-500" />
|
||||
<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={`${isMobile ? 'w-6 h-6' : 'w-5 h-5'} text-blue-500`} />
|
||||
</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>
|
||||
</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>
|
||||
</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" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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 */}
|
||||
<div className="flex items-center gap-1 p-1 bg-black/20 rounded-xl border border-white/5">
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -1,89 +1,89 @@
|
|||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Lightbulb, MousePointer2, Keyboard, Layout } from 'lucide-react';
|
||||
|
||||
const TIPS = [
|
||||
{
|
||||
icon: MousePointer2,
|
||||
title: 'Select & Edit',
|
||||
text: 'Select any node to inspect its logic and edit metadata.'
|
||||
},
|
||||
{
|
||||
icon: Layout,
|
||||
title: 'Auto Layout',
|
||||
text: 'Use "Visual Organizer" to automatically arrange complex flows.'
|
||||
},
|
||||
{
|
||||
icon: Keyboard,
|
||||
title: 'Quick Actions',
|
||||
text: 'Press "K" to open the command palette for fast access.'
|
||||
},
|
||||
{
|
||||
icon: Lightbulb,
|
||||
title: 'Pro Tip',
|
||||
text: 'Shift + Drag to select multiple nodes at once.'
|
||||
}
|
||||
];
|
||||
|
||||
export function SmartGuide() {
|
||||
const [index, setIndex] = useState(0);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPaused) return;
|
||||
const timer = setInterval(() => {
|
||||
setIndex(prev => (prev + 1) % TIPS.length);
|
||||
}, 5000);
|
||||
return () => clearInterval(timer);
|
||||
}, [isPaused]);
|
||||
|
||||
const Tip = TIPS[index];
|
||||
const Icon = Tip.icon;
|
||||
|
||||
return (
|
||||
<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"
|
||||
onMouseEnter={() => setIsPaused(true)}
|
||||
onMouseLeave={() => setIsPaused(false)}
|
||||
onClick={() => setIndex(prev => (prev + 1) % TIPS.length)}
|
||||
>
|
||||
{/* Progress Bar */}
|
||||
<div className="absolute top-0 left-0 h-0.5 bg-blue-500/20 w-full">
|
||||
<div
|
||||
key={index}
|
||||
className="h-full bg-blue-500 transition-all duration-[5000ms] ease-linear w-full origin-left"
|
||||
style={{ animation: isPaused ? 'none' : 'progress 5s linear' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<Icon className="w-4 h-4 text-blue-500 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<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">
|
||||
{Tip.title}
|
||||
</span>
|
||||
<div className="flex gap-0.5">
|
||||
{TIPS.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`w-1 h-1 rounded-full transition-colors ${i === index ? 'bg-blue-500' : 'bg-blue-200 dark:bg-white/10'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] font-medium leading-relaxed text-slate-600 dark:text-slate-400">
|
||||
{Tip.text}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Add animation keyframes to global styles via style tag if strictly needed,
|
||||
// but often standard CSS transitions suffice. For the progress bar, specific keyframes
|
||||
// are better added to index.css or tailored here.
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Lightbulb, MousePointer2, Keyboard, Layout } from 'lucide-react';
|
||||
|
||||
const TIPS = [
|
||||
{
|
||||
icon: MousePointer2,
|
||||
title: 'Select & Edit',
|
||||
text: 'Select any node to inspect its logic and edit metadata.'
|
||||
},
|
||||
{
|
||||
icon: Layout,
|
||||
title: 'Auto Layout',
|
||||
text: 'Use "Visual Organizer" to automatically arrange complex flows.'
|
||||
},
|
||||
{
|
||||
icon: Keyboard,
|
||||
title: 'Quick Actions',
|
||||
text: 'Press "K" to open the command palette for fast access.'
|
||||
},
|
||||
{
|
||||
icon: Lightbulb,
|
||||
title: 'Pro Tip',
|
||||
text: 'Shift + Drag to select multiple nodes at once.'
|
||||
}
|
||||
];
|
||||
|
||||
export function SmartGuide() {
|
||||
const [index, setIndex] = useState(0);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPaused) return;
|
||||
const timer = setInterval(() => {
|
||||
setIndex(prev => (prev + 1) % TIPS.length);
|
||||
}, 5000);
|
||||
return () => clearInterval(timer);
|
||||
}, [isPaused]);
|
||||
|
||||
const Tip = TIPS[index];
|
||||
const Icon = Tip.icon;
|
||||
|
||||
return (
|
||||
<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"
|
||||
onMouseEnter={() => setIsPaused(true)}
|
||||
onMouseLeave={() => setIsPaused(false)}
|
||||
onClick={() => setIndex(prev => (prev + 1) % TIPS.length)}
|
||||
>
|
||||
{/* Progress Bar */}
|
||||
<div className="absolute top-0 left-0 h-0.5 bg-blue-500/20 w-full">
|
||||
<div
|
||||
key={index}
|
||||
className="h-full bg-blue-500 transition-all duration-[5000ms] ease-linear w-full origin-left"
|
||||
style={{ animation: isPaused ? 'none' : 'progress 5s linear' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<Icon className="w-4 h-4 text-blue-500 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<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">
|
||||
{Tip.title}
|
||||
</span>
|
||||
<div className="flex gap-0.5">
|
||||
{TIPS.map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`w-1 h-1 rounded-full transition-colors ${i === index ? 'bg-blue-500' : 'bg-blue-200 dark:bg-white/10'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] font-medium leading-relaxed text-slate-600 dark:text-slate-400">
|
||||
{Tip.text}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Add animation keyframes to global styles via style tag if strictly needed,
|
||||
// but often standard CSS transitions suffice. For the progress bar, specific keyframes
|
||||
// are better added to index.css or tailored here.
|
||||
|
|
|
|||
|
|
@ -1,142 +1,142 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Button } from './ui/Button';
|
||||
import { Card } from './ui/Card';
|
||||
import { useVisualOrganizer } from '../hooks/useVisualOrganizer';
|
||||
import { useDiagramStore } from '../store';
|
||||
import { Sparkles, Wand2, Scan, CheckCircle2, X } from 'lucide-react';
|
||||
import type { LayoutSuggestion } from '../types/visualOrganization';
|
||||
|
||||
export const VisualOrganizerFAB: React.FC = () => {
|
||||
const { analyzeLayout, generateSuggestions, applySuggestion } = useVisualOrganizer();
|
||||
const { nodes, edges, setNodes, setEdges } = useDiagramStore();
|
||||
|
||||
// UI States
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [status, setStatus] = useState<'idle' | 'analyzing' | 'ready' | 'applied'>('idle');
|
||||
const [bestSuggestion, setBestSuggestion] = useState<LayoutSuggestion | null>(null);
|
||||
const [snapshot, setSnapshot] = useState<{ nodes: any[], edges: any[] } | null>(null);
|
||||
|
||||
const handleAIOrganize = async () => {
|
||||
setStatus('analyzing');
|
||||
setIsOpen(true);
|
||||
|
||||
analyzeLayout();
|
||||
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
const results = await generateSuggestions();
|
||||
|
||||
if (results.length > 0) {
|
||||
setBestSuggestion(results[0]);
|
||||
setStatus('ready');
|
||||
} else {
|
||||
setStatus('idle');
|
||||
// Could show a generic "No suggestions" toast here
|
||||
setIsOpen(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setStatus('idle');
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
if (!bestSuggestion) return;
|
||||
setSnapshot({ nodes: [...nodes], edges: [...edges] });
|
||||
applySuggestion(bestSuggestion);
|
||||
setStatus('applied');
|
||||
};
|
||||
|
||||
const handleUndo = () => {
|
||||
if (snapshot) {
|
||||
setNodes(snapshot.nodes);
|
||||
setEdges(snapshot.edges);
|
||||
setSnapshot(null);
|
||||
setStatus('ready');
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setIsOpen(false);
|
||||
if (status === 'applied') {
|
||||
setStatus('idle');
|
||||
setSnapshot(null);
|
||||
setBestSuggestion(null);
|
||||
}
|
||||
};
|
||||
|
||||
// If closed, show just the FAB
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<button
|
||||
onClick={handleAIOrganize}
|
||||
className="w-12 h-12 rounded-full bg-white dark:bg-slate-800 border border-indigo-100 dark:border-indigo-900/30 shadow-lg hover:shadow-indigo-500/20 flex items-center justify-center group transition-all hover:scale-110 active:scale-95"
|
||||
title="AI Visual Organizer"
|
||||
>
|
||||
<Sparkles className="w-5 h-5 text-indigo-500 group-hover:rotate-12 transition-transform" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Expanded State (Floating Card)
|
||||
return (
|
||||
<div className="relative animate-in slide-in-from-bottom-4 fade-in duration-300">
|
||||
<Card className="w-80 p-0 overflow-hidden shadow-2xl border-indigo-100 dark:border-indigo-900/30">
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 bg-slate-50 dark:bg-slate-900/50 border-b border-slate-100 dark:border-white/5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Wand2 className="w-4 h-4 text-indigo-500" />
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-slate-600 dark:text-slate-300">Organizer</span>
|
||||
</div>
|
||||
<button onClick={handleClose} className="text-slate-400 hover:text-slate-600 dark:hover:text-white">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-5">
|
||||
{status === 'analyzing' && (
|
||||
<div className="flex flex-col items-center py-4 space-y-4">
|
||||
<div className="relative w-16 h-16">
|
||||
<div className="absolute inset-0 border-4 border-indigo-100 dark:border-indigo-900/30 rounded-full"></div>
|
||||
<div className="absolute inset-0 border-4 border-t-indigo-500 rounded-full animate-spin"></div>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Scan className="w-6 h-6 text-indigo-500 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs font-medium text-slate-500 animate-pulse">Analyzing structure...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'ready' && bestSuggestion && (
|
||||
<div className="space-y-4">
|
||||
<div className="text-center">
|
||||
<CheckCircle2 className="w-8 h-8 text-green-500 mx-auto mb-2" />
|
||||
<h4 className="font-bold text-sm text-slate-800 dark:text-white">Optimization Ready</h4>
|
||||
<p className="text-xs text-slate-500 mt-1">{bestSuggestion.description}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={handleClose}>Cancel</Button>
|
||||
<Button size="sm" onClick={handleApply} className="bg-indigo-600 hover:bg-indigo-500 text-white">Apply</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'applied' && (
|
||||
<div className="space-y-4">
|
||||
<div className="text-center">
|
||||
<Sparkles className="w-8 h-8 text-indigo-500 mx-auto mb-2" />
|
||||
<h4 className="font-bold text-sm text-slate-800 dark:text-white">Clean & Organized!</h4>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={handleUndo}>Undo</Button>
|
||||
<Button size="sm" onClick={handleClose} className="bg-slate-900 text-white dark:bg-white dark:text-slate-900">Done</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from './ui/Button';
|
||||
import { Card } from './ui/Card';
|
||||
import { useVisualOrganizer } from '../hooks/useVisualOrganizer';
|
||||
import { useDiagramStore } from '../store';
|
||||
import { Sparkles, Wand2, Scan, CheckCircle2, X } from 'lucide-react';
|
||||
import type { LayoutSuggestion } from '../types/visualOrganization';
|
||||
|
||||
export const VisualOrganizerFAB: React.FC = () => {
|
||||
const { analyzeLayout, generateSuggestions, applySuggestion } = useVisualOrganizer();
|
||||
const { nodes, edges, setNodes, setEdges } = useDiagramStore();
|
||||
|
||||
// UI States
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [status, setStatus] = useState<'idle' | 'analyzing' | 'ready' | 'applied'>('idle');
|
||||
const [bestSuggestion, setBestSuggestion] = useState<LayoutSuggestion | null>(null);
|
||||
const [snapshot, setSnapshot] = useState<{ nodes: any[], edges: any[] } | null>(null);
|
||||
|
||||
const handleAIOrganize = async () => {
|
||||
setStatus('analyzing');
|
||||
setIsOpen(true);
|
||||
|
||||
analyzeLayout();
|
||||
|
||||
try {
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
const results = await generateSuggestions();
|
||||
|
||||
if (results.length > 0) {
|
||||
setBestSuggestion(results[0]);
|
||||
setStatus('ready');
|
||||
} else {
|
||||
setStatus('idle');
|
||||
// Could show a generic "No suggestions" toast here
|
||||
setIsOpen(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setStatus('idle');
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
if (!bestSuggestion) return;
|
||||
setSnapshot({ nodes: [...nodes], edges: [...edges] });
|
||||
applySuggestion(bestSuggestion);
|
||||
setStatus('applied');
|
||||
};
|
||||
|
||||
const handleUndo = () => {
|
||||
if (snapshot) {
|
||||
setNodes(snapshot.nodes);
|
||||
setEdges(snapshot.edges);
|
||||
setSnapshot(null);
|
||||
setStatus('ready');
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setIsOpen(false);
|
||||
if (status === 'applied') {
|
||||
setStatus('idle');
|
||||
setSnapshot(null);
|
||||
setBestSuggestion(null);
|
||||
}
|
||||
};
|
||||
|
||||
// If closed, show just the FAB
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<button
|
||||
onClick={handleAIOrganize}
|
||||
className="w-12 h-12 rounded-full bg-white dark:bg-slate-800 border border-indigo-100 dark:border-indigo-900/30 shadow-lg hover:shadow-indigo-500/20 flex items-center justify-center group transition-all hover:scale-110 active:scale-95"
|
||||
title="AI Visual Organizer"
|
||||
>
|
||||
<Sparkles className="w-5 h-5 text-indigo-500 group-hover:rotate-12 transition-transform" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Expanded State (Floating Card)
|
||||
return (
|
||||
<div className="relative animate-in slide-in-from-bottom-4 fade-in duration-300">
|
||||
<Card className="w-80 p-0 overflow-hidden shadow-2xl border-indigo-100 dark:border-indigo-900/30">
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 bg-slate-50 dark:bg-slate-900/50 border-b border-slate-100 dark:border-white/5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Wand2 className="w-4 h-4 text-indigo-500" />
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-slate-600 dark:text-slate-300">Organizer</span>
|
||||
</div>
|
||||
<button onClick={handleClose} className="text-slate-400 hover:text-slate-600 dark:hover:text-white">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-5">
|
||||
{status === 'analyzing' && (
|
||||
<div className="flex flex-col items-center py-4 space-y-4">
|
||||
<div className="relative w-16 h-16">
|
||||
<div className="absolute inset-0 border-4 border-indigo-100 dark:border-indigo-900/30 rounded-full"></div>
|
||||
<div className="absolute inset-0 border-4 border-t-indigo-500 rounded-full animate-spin"></div>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Scan className="w-6 h-6 text-indigo-500 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs font-medium text-slate-500 animate-pulse">Analyzing structure...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'ready' && bestSuggestion && (
|
||||
<div className="space-y-4">
|
||||
<div className="text-center">
|
||||
<CheckCircle2 className="w-8 h-8 text-green-500 mx-auto mb-2" />
|
||||
<h4 className="font-bold text-sm text-slate-800 dark:text-white">Optimization Ready</h4>
|
||||
<p className="text-xs text-slate-500 mt-1">{bestSuggestion.description}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={handleClose}>Cancel</Button>
|
||||
<Button size="sm" onClick={handleApply} className="bg-indigo-600 hover:bg-indigo-500 text-white">Apply</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'applied' && (
|
||||
<div className="space-y-4">
|
||||
<div className="text-center">
|
||||
<Sparkles className="w-8 h-8 text-indigo-500 mx-auto mb-2" />
|
||||
<h4 className="font-bold text-sm text-slate-800 dark:text-white">Clean & Organized!</h4>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={handleUndo}>Undo</Button>
|
||||
<Button size="sm" onClick={handleClose} className="bg-slate-900 text-white dark:bg-white dark:text-slate-900">Done</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,194 +1,194 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Button } from './ui/Button';
|
||||
import { Card } from './ui/Card';
|
||||
import { useVisualOrganizer } from '../hooks/useVisualOrganizer';
|
||||
import { useDiagramStore } from '../store';
|
||||
import { Sparkles, Wand2, Layout, Scan, CheckCircle2, RotateCcw } from 'lucide-react';
|
||||
import type { LayoutSuggestion } from '../types/visualOrganization';
|
||||
|
||||
export const VisualOrganizerPanel: React.FC = () => {
|
||||
const { analyzeLayout, generateSuggestions, applySuggestion } = useVisualOrganizer();
|
||||
const { nodes, edges, setNodes, setEdges } = useDiagramStore();
|
||||
|
||||
// UI States
|
||||
const [status, setStatus] = useState<'idle' | 'analyzing' | 'ready' | 'applied'>('idle');
|
||||
const [bestSuggestion, setBestSuggestion] = useState<LayoutSuggestion | null>(null);
|
||||
const [snapshot, setSnapshot] = useState<{ nodes: any[], edges: any[] } | null>(null);
|
||||
|
||||
// AI Organize Handler
|
||||
const handleAIOrganize = async () => {
|
||||
setStatus('analyzing');
|
||||
|
||||
// 1. Analyze (Simulate brief delay for effect)
|
||||
analyzeLayout();
|
||||
|
||||
// 2. Generate Suggestions
|
||||
try {
|
||||
// Artificial delay for "Scanning" animation effect
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
|
||||
const results = await generateSuggestions();
|
||||
|
||||
// Pick best suggestion (or default to first non-current)
|
||||
if (results.length > 0) {
|
||||
setBestSuggestion(results[0]);
|
||||
setStatus('ready');
|
||||
} else {
|
||||
// Fallback if no suggestions
|
||||
setStatus('idle');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setStatus('idle');
|
||||
}
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
if (!bestSuggestion) return;
|
||||
|
||||
// Take snapshot before applying
|
||||
setSnapshot({ nodes: [...nodes], edges: [...edges] });
|
||||
|
||||
applySuggestion(bestSuggestion);
|
||||
setStatus('applied');
|
||||
};
|
||||
|
||||
const handleUndo = () => {
|
||||
if (snapshot) {
|
||||
setNodes(snapshot.nodes);
|
||||
setEdges(snapshot.edges);
|
||||
setSnapshot(null);
|
||||
setStatus('ready');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setStatus('idle');
|
||||
setSnapshot(null);
|
||||
setBestSuggestion(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="visual-organizer-panel w-full">
|
||||
<Card className="p-0 overflow-hidden border-none shadow-none bg-transparent">
|
||||
|
||||
{/* IDLE STATE: Main AI Button */}
|
||||
{status === 'idle' && (
|
||||
<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="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">
|
||||
<Sparkles className="w-10 h-10 text-white animate-pulse" />
|
||||
</div>
|
||||
<div className="absolute -bottom-2 -right-2 bg-white dark:bg-slate-800 p-2 rounded-full shadow-lg border border-slate-100 dark:border-slate-700">
|
||||
<Wand2 className="w-4 h-4 text-purple-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xl font-display font-bold text-slate-800 dark:text-white">AI Visual Organizer</h3>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 max-w-[200px] mx-auto leading-relaxed">
|
||||
Click to instantly analyze and reorganize your flow for maximum clarity.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleAIOrganize}
|
||||
className="bg-slate-900 dark:bg-white text-white dark:text-slate-900 hover:bg-slate-800 hover:dark:bg-slate-100 px-8 py-6 rounded-xl shadow-xl hover:shadow-2xl transition-all font-bold tracking-wide"
|
||||
>
|
||||
<Scan className="w-4 h-4 mr-2" />
|
||||
Start Organization
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ANALYZING STATE: Scanning Animation */}
|
||||
{status === 'analyzing' && (
|
||||
<div className="flex flex-col items-center justify-center p-12 text-center space-y-6 animate-in fade-in zoom-in duration-300">
|
||||
<div className="relative w-20 h-20">
|
||||
<div className="absolute inset-0 border-4 border-blue-500/20 rounded-full"></div>
|
||||
<div className="absolute inset-0 border-4 border-t-blue-500 rounded-full animate-spin"></div>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Scan className="w-8 h-8 text-blue-500 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-blue-600 dark:text-blue-400 animate-pulse">
|
||||
Analyzing Layout Logic...
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* READY STATE: Suggestion Found */}
|
||||
{status === 'ready' && bestSuggestion && (
|
||||
<div className="flex flex-col p-6 space-y-6 animate-in slide-in-from-bottom-4 duration-500">
|
||||
<div className="flex items-center gap-3 text-green-600 dark:text-green-400 mb-2">
|
||||
<CheckCircle2 className="w-6 h-6" />
|
||||
<span className="font-bold text-lg">Optimization Found!</span>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-slate-50 dark:bg-white/5 rounded-xl border border-slate-200 dark:border-white/10">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Layout className="w-5 h-5 text-indigo-500" />
|
||||
<h4 className="font-bold">{bestSuggestion.title}</h4>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400 leading-relaxed">
|
||||
{bestSuggestion.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button
|
||||
onClick={handleReset}
|
||||
variant="secondary"
|
||||
className="h-12"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleApply}
|
||||
className="h-12 bg-indigo-600 hover:bg-indigo-500 text-white shadow-lg hover:shadow-indigo-500/25"
|
||||
>
|
||||
<Wand2 className="w-4 h-4 mr-2" />
|
||||
Apply Magic
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* APPLIED STATE: Success & Undo */}
|
||||
{status === 'applied' && (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center space-y-6 animate-in zoom-in duration-300">
|
||||
<div className="w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mb-2">
|
||||
<CheckCircle2 className="w-8 h-8 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-slate-800 dark:text-white mb-2">Beautifully Organized!</h3>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
Your graph has been transformed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 w-full">
|
||||
<Button
|
||||
onClick={handleUndo}
|
||||
variant="secondary"
|
||||
className="flex-1 h-12 border-slate-200 dark:border-white/10 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/10 dark:hover:text-red-400 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
Undo
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleReset} // Goes back to idle
|
||||
className="flex-1 h-12"
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from './ui/Button';
|
||||
import { Card } from './ui/Card';
|
||||
import { useVisualOrganizer } from '../hooks/useVisualOrganizer';
|
||||
import { useDiagramStore } from '../store';
|
||||
import { Sparkles, Wand2, Layout, Scan, CheckCircle2, RotateCcw } from 'lucide-react';
|
||||
import type { LayoutSuggestion } from '../types/visualOrganization';
|
||||
|
||||
export const VisualOrganizerPanel: React.FC = () => {
|
||||
const { analyzeLayout, generateSuggestions, applySuggestion } = useVisualOrganizer();
|
||||
const { nodes, edges, setNodes, setEdges } = useDiagramStore();
|
||||
|
||||
// UI States
|
||||
const [status, setStatus] = useState<'idle' | 'analyzing' | 'ready' | 'applied'>('idle');
|
||||
const [bestSuggestion, setBestSuggestion] = useState<LayoutSuggestion | null>(null);
|
||||
const [snapshot, setSnapshot] = useState<{ nodes: any[], edges: any[] } | null>(null);
|
||||
|
||||
// AI Organize Handler
|
||||
const handleAIOrganize = async () => {
|
||||
setStatus('analyzing');
|
||||
|
||||
// 1. Analyze (Simulate brief delay for effect)
|
||||
analyzeLayout();
|
||||
|
||||
// 2. Generate Suggestions
|
||||
try {
|
||||
// Artificial delay for "Scanning" animation effect
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
|
||||
const results = await generateSuggestions();
|
||||
|
||||
// Pick best suggestion (or default to first non-current)
|
||||
if (results.length > 0) {
|
||||
setBestSuggestion(results[0]);
|
||||
setStatus('ready');
|
||||
} else {
|
||||
// Fallback if no suggestions
|
||||
setStatus('idle');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setStatus('idle');
|
||||
}
|
||||
};
|
||||
|
||||
const handleApply = () => {
|
||||
if (!bestSuggestion) return;
|
||||
|
||||
// Take snapshot before applying
|
||||
setSnapshot({ nodes: [...nodes], edges: [...edges] });
|
||||
|
||||
applySuggestion(bestSuggestion);
|
||||
setStatus('applied');
|
||||
};
|
||||
|
||||
const handleUndo = () => {
|
||||
if (snapshot) {
|
||||
setNodes(snapshot.nodes);
|
||||
setEdges(snapshot.edges);
|
||||
setSnapshot(null);
|
||||
setStatus('ready');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setStatus('idle');
|
||||
setSnapshot(null);
|
||||
setBestSuggestion(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="visual-organizer-panel w-full">
|
||||
<Card className="p-0 overflow-hidden border-none shadow-none bg-transparent">
|
||||
|
||||
{/* IDLE STATE: Main AI Button */}
|
||||
{status === 'idle' && (
|
||||
<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="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">
|
||||
<Sparkles className="w-10 h-10 text-white animate-pulse" />
|
||||
</div>
|
||||
<div className="absolute -bottom-2 -right-2 bg-white dark:bg-slate-800 p-2 rounded-full shadow-lg border border-slate-100 dark:border-slate-700">
|
||||
<Wand2 className="w-4 h-4 text-purple-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xl font-display font-bold text-slate-800 dark:text-white">AI Visual Organizer</h3>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400 max-w-[200px] mx-auto leading-relaxed">
|
||||
Click to instantly analyze and reorganize your flow for maximum clarity.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleAIOrganize}
|
||||
className="bg-slate-900 dark:bg-white text-white dark:text-slate-900 hover:bg-slate-800 hover:dark:bg-slate-100 px-8 py-6 rounded-xl shadow-xl hover:shadow-2xl transition-all font-bold tracking-wide"
|
||||
>
|
||||
<Scan className="w-4 h-4 mr-2" />
|
||||
Start Organization
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ANALYZING STATE: Scanning Animation */}
|
||||
{status === 'analyzing' && (
|
||||
<div className="flex flex-col items-center justify-center p-12 text-center space-y-6 animate-in fade-in zoom-in duration-300">
|
||||
<div className="relative w-20 h-20">
|
||||
<div className="absolute inset-0 border-4 border-blue-500/20 rounded-full"></div>
|
||||
<div className="absolute inset-0 border-4 border-t-blue-500 rounded-full animate-spin"></div>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Scan className="w-8 h-8 text-blue-500 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-blue-600 dark:text-blue-400 animate-pulse">
|
||||
Analyzing Layout Logic...
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* READY STATE: Suggestion Found */}
|
||||
{status === 'ready' && bestSuggestion && (
|
||||
<div className="flex flex-col p-6 space-y-6 animate-in slide-in-from-bottom-4 duration-500">
|
||||
<div className="flex items-center gap-3 text-green-600 dark:text-green-400 mb-2">
|
||||
<CheckCircle2 className="w-6 h-6" />
|
||||
<span className="font-bold text-lg">Optimization Found!</span>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-slate-50 dark:bg-white/5 rounded-xl border border-slate-200 dark:border-white/10">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Layout className="w-5 h-5 text-indigo-500" />
|
||||
<h4 className="font-bold">{bestSuggestion.title}</h4>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400 leading-relaxed">
|
||||
{bestSuggestion.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button
|
||||
onClick={handleReset}
|
||||
variant="secondary"
|
||||
className="h-12"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleApply}
|
||||
className="h-12 bg-indigo-600 hover:bg-indigo-500 text-white shadow-lg hover:shadow-indigo-500/25"
|
||||
>
|
||||
<Wand2 className="w-4 h-4 mr-2" />
|
||||
Apply Magic
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* APPLIED STATE: Success & Undo */}
|
||||
{status === 'applied' && (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center space-y-6 animate-in zoom-in duration-300">
|
||||
<div className="w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mb-2">
|
||||
<CheckCircle2 className="w-8 h-8 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-slate-800 dark:text-white mb-2">Beautifully Organized!</h3>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
Your graph has been transformed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 w-full">
|
||||
<Button
|
||||
onClick={handleUndo}
|
||||
variant="secondary"
|
||||
className="flex-1 h-12 border-slate-200 dark:border-white/10 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/10 dark:hover:text-red-400 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
Undo
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleReset} // Goes back to idle
|
||||
className="flex-1 h-12"
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
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';
|
||||
import { useStore } from 'zustand';
|
||||
import { useFlowStore, useDiagramStore } from '../../store';
|
||||
|
|
@ -13,6 +14,49 @@ import {
|
|||
import { useState } from 'react';
|
||||
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() {
|
||||
const {
|
||||
nodes, edges, leftPanelOpen, setLeftPanelOpen,
|
||||
|
|
@ -24,11 +68,17 @@ export function EditorHeader() {
|
|||
} = useFlowStore();
|
||||
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 [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
|
||||
const [showExportMenu, setShowExportMenu] = useState(false);
|
||||
const [showMobileMenu, setShowMobileMenu] = useState(false);
|
||||
|
||||
const handleSave = () => {
|
||||
setShowMobileMenu(false);
|
||||
const name = prompt('Enter diagram name:', `Diagram ${new Date().toLocaleDateString()}`);
|
||||
if (name) {
|
||||
setSaveStatus('saving');
|
||||
|
|
@ -44,6 +94,7 @@ export function EditorHeader() {
|
|||
|
||||
const handleExport = async (format: 'png' | 'jpg' | 'svg' | 'txt' | 'code' | 'json') => {
|
||||
setShowExportMenu(false);
|
||||
setShowMobileMenu(false);
|
||||
const viewport = document.querySelector('.react-flow__viewport') as HTMLElement;
|
||||
|
||||
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 (
|
||||
<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">
|
||||
|
|
@ -88,7 +393,7 @@ export function EditorHeader() {
|
|||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
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"
|
||||
title="Undo"
|
||||
>
|
||||
|
|
@ -96,7 +401,7 @@ export function EditorHeader() {
|
|||
</button>
|
||||
<button
|
||||
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"
|
||||
title="Redo"
|
||||
>
|
||||
|
|
@ -104,60 +409,54 @@ export function EditorHeader() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{!isMobile && (
|
||||
<>
|
||||
<div className="h-4 w-px bg-slate-200 dark:bg-white/10 mx-2"></div>
|
||||
<div className="flex items-center gap-1.5 bg-slate-100 dark:bg-slate-800/50 p-1 rounded-xl border border-black/5 dark:border-white/5">
|
||||
<button
|
||||
onClick={() => setLeftPanelOpen(!leftPanelOpen)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider transition-all ${leftPanelOpen
|
||||
? 'bg-white dark:bg-blue-600/15 text-blue-600 dark:text-blue-500 shadow-sm dark:shadow-none border border-black/5 dark:border-blue-500/20'
|
||||
: 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 hover:bg-black/5 dark:hover:bg-white/5 border border-transparent'}`}
|
||||
title="Toggle Input Panel"
|
||||
>
|
||||
<Edit3 className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">Input</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRightPanelOpen(!rightPanelOpen)}
|
||||
disabled={nodes.length === 0}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider transition-all ${nodes.length === 0
|
||||
? 'opacity-30 cursor-not-allowed text-slate-400 dark:text-slate-600'
|
||||
: (rightPanelOpen
|
||||
? 'bg-white dark:bg-blue-600/15 text-blue-600 dark:text-blue-500 shadow-sm dark:shadow-none border border-black/5 dark:border-blue-500/20'
|
||||
: 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 hover:bg-black/5 dark:hover:bg-white/5 border border-transparent')}`}
|
||||
title="Toggle Code Panel"
|
||||
>
|
||||
<Code className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">Code</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="h-4 w-px bg-slate-200 dark:bg-white/10 mx-2"></div>
|
||||
<div className="flex items-center gap-1.5 bg-slate-100 dark:bg-slate-800/50 p-1 rounded-xl border border-black/5 dark:border-white/5">
|
||||
<button
|
||||
onClick={() => setLeftPanelOpen(!leftPanelOpen)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider transition-all ${leftPanelOpen
|
||||
? 'bg-white dark:bg-blue-600/15 text-blue-600 dark:text-blue-500 shadow-sm dark:shadow-none border border-black/5 dark:border-blue-500/20'
|
||||
: 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 hover:bg-black/5 dark:hover:bg-white/5 border border-transparent'}`}
|
||||
title="Toggle Input Panel"
|
||||
>
|
||||
<Edit3 className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">Input</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRightPanelOpen(!rightPanelOpen)}
|
||||
disabled={nodes.length === 0}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[10px] font-bold uppercase tracking-wider transition-all ${nodes.length === 0
|
||||
? 'opacity-30 cursor-not-allowed text-slate-400 dark:text-slate-600'
|
||||
: (rightPanelOpen
|
||||
? 'bg-white dark:bg-blue-600/15 text-blue-600 dark:text-blue-500 shadow-sm dark:shadow-none border border-black/5 dark:border-blue-500/20'
|
||||
: 'text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 hover:bg-black/5 dark:hover:bg-white/5 border border-transparent')}`}
|
||||
title="Toggle Code Panel"
|
||||
>
|
||||
<Code className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">Code</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* AI Mode Status - Desktop Only */}
|
||||
{!isMobile && (
|
||||
<div className="hidden md:flex items-center gap-2 px-3 py-1.5 rounded-full bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-white/5 mr-2">
|
||||
{aiMode === 'offline' ? (
|
||||
<>
|
||||
<Server className="w-3 h-3 text-blue-500" />
|
||||
<span className="text-[10px] font-bold text-slate-500 dark:text-slate-400">Local</span>
|
||||
</>
|
||||
) : aiMode === 'browser' ? (
|
||||
<>
|
||||
<Cpu className="w-3 h-3 text-purple-500" />
|
||||
<span className="text-[10px] font-bold text-slate-500 dark:text-slate-400">Browser</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Cloud className="w-3 h-3 text-emerald-500" />
|
||||
<span className="text-[10px] font-bold text-slate-500 dark:text-slate-400">Cloud</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="hidden md:flex items-center gap-2 px-3 py-1.5 rounded-full bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-white/5 mr-2">
|
||||
{aiMode === 'offline' ? (
|
||||
<>
|
||||
<Server className="w-3 h-3 text-blue-500" />
|
||||
<span className="text-[10px] font-bold text-slate-500 dark:text-slate-400">Local</span>
|
||||
</>
|
||||
) : aiMode === 'browser' ? (
|
||||
<>
|
||||
<Cpu className="w-3 h-3 text-purple-500" />
|
||||
<span className="text-[10px] font-bold text-slate-500 dark:text-slate-400">Browser</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Cloud className="w-3 h-3 text-emerald-500" />
|
||||
<span className="text-[10px] font-bold text-slate-500 dark:text-slate-400">Cloud</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setFocusMode(!focusMode)}
|
||||
|
|
|
|||
|
|
@ -1,61 +1,61 @@
|
|||
import { Loader2, Zap } from 'lucide-react';
|
||||
|
||||
interface EditorToolbarProps {
|
||||
handleGenerate: () => void;
|
||||
isLoading: boolean;
|
||||
hasCode: boolean;
|
||||
|
||||
}
|
||||
|
||||
import { usePluginStore } from '../../store/pluginStore';
|
||||
|
||||
export function EditorToolbar({
|
||||
handleGenerate,
|
||||
isLoading,
|
||||
hasCode,
|
||||
}: EditorToolbarProps) {
|
||||
const { toolbarItems } = usePluginStore();
|
||||
return (
|
||||
<div className="flex items-center justify-between w-full pt-2 px-2">
|
||||
|
||||
|
||||
|
||||
{/* Center: Insert Tools */}
|
||||
<div className="flex items-center gap-6 absolute left-1/2 -translate-x-1/2">
|
||||
|
||||
|
||||
{/* Plugin Items */}
|
||||
{toolbarItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={item.onClick}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-2 group px-2 py-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-white/5 transition-all disabled:opacity-50"
|
||||
title={item.tooltip || item.label}
|
||||
>
|
||||
<item.icon className="w-4 h-4 text-slate-400 group-hover:text-blue-500 transition-colors" />
|
||||
<span className="text-xs font-black uppercase tracking-widest text-slate-300 group-hover:text-slate-200 transition-colors">{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right: Actions */}
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={!hasCode || isLoading}
|
||||
className="px-6 py-2.5 rounded-xl bg-gradient-to-r from-[#8b5cf6] to-[#a78bfa] hover:from-[#7c3aed] hover:to-[#8b5cf6] text-white shadow-lg shadow-purple-900/20 transition-all active:scale-[0.98] flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin text-white/90" />
|
||||
) : (
|
||||
<>
|
||||
<Zap className="w-4 h-4 fill-white" />
|
||||
<span className="text-xs font-black uppercase tracking-widest">Visualize</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { Loader2, Zap } from 'lucide-react';
|
||||
|
||||
interface EditorToolbarProps {
|
||||
handleGenerate: () => void;
|
||||
isLoading: boolean;
|
||||
hasCode: boolean;
|
||||
hasCode: boolean;
|
||||
}
|
||||
|
||||
import { usePluginStore } from '../../store/pluginStore';
|
||||
|
||||
export function EditorToolbar({
|
||||
handleGenerate,
|
||||
isLoading,
|
||||
hasCode,
|
||||
}: EditorToolbarProps) {
|
||||
const { toolbarItems } = usePluginStore();
|
||||
return (
|
||||
<div className="flex items-center justify-between w-full pt-2 px-2">
|
||||
|
||||
|
||||
|
||||
{/* Center: Insert Tools */}
|
||||
<div className="flex items-center gap-6 absolute left-1/2 -translate-x-1/2">
|
||||
|
||||
|
||||
{/* Plugin Items */}
|
||||
{toolbarItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={item.onClick}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-2 group px-2 py-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-white/5 transition-all disabled:opacity-50"
|
||||
title={item.tooltip || item.label}
|
||||
>
|
||||
<item.icon className="w-4 h-4 text-slate-400 group-hover:text-blue-500 transition-colors" />
|
||||
<span className="text-xs font-black uppercase tracking-widest text-slate-300 group-hover:text-slate-200 transition-colors">{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right: Actions */}
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={!hasCode || isLoading}
|
||||
className="px-6 py-2.5 rounded-xl bg-gradient-to-r from-[#8b5cf6] to-[#a78bfa] hover:from-[#7c3aed] hover:to-[#8b5cf6] text-white shadow-lg shadow-purple-900/20 transition-all active:scale-[0.98] flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin text-white/90" />
|
||||
) : (
|
||||
<>
|
||||
<Zap className="w-4 h-4 fill-white" />
|
||||
<span className="text-xs font-black uppercase tracking-widest">Visualize</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,107 +1,107 @@
|
|||
|
||||
import Editor, { type OnMount } from '@monaco-editor/react';
|
||||
import { AlertCircle, Trash2 } from 'lucide-react';
|
||||
|
||||
interface MonacoWrapperProps {
|
||||
code: string;
|
||||
onChange: (value: string | undefined) => void;
|
||||
onMount: OnMount;
|
||||
theme: 'light' | 'dark';
|
||||
syntaxErrors: { line: number; message: string }[];
|
||||
|
||||
setCode: (code: string) => void;
|
||||
}
|
||||
|
||||
export function MonacoWrapper({
|
||||
code,
|
||||
onChange,
|
||||
onMount,
|
||||
theme,
|
||||
syntaxErrors,
|
||||
setCode
|
||||
}: MonacoWrapperProps) {
|
||||
// monacoRef removed as it was unused
|
||||
|
||||
// Define themes once on mount or when theme prop changes appropriately
|
||||
// For simplicity, we can do it inside a useEffect here if we have access to monaco instance,
|
||||
// but usually it's better to do it once.
|
||||
// However, since we need monaco instance, we'll assume the parent component or this component handles it via onMount/useEffect.
|
||||
|
||||
// Actually, let's keep the theme definition inside the component for now or rely on the parent to pass the ref if needed.
|
||||
// But better yet, let's expose specific theme logic or keep it self-contained if possible.
|
||||
// The original code defined themes in useEffect when monacoRef was available.
|
||||
|
||||
return (
|
||||
<div className={`flex-1 rounded-2xl overflow-hidden border relative group shadow-inner transition-colors ${theme === 'dark' ? 'bg-[#0B1221] border-white/5' : 'bg-slate-50 border-slate-200'
|
||||
}`}>
|
||||
{/* Internal Badges */}
|
||||
<div className="absolute top-4 left-4 z-10 pointer-events-none">
|
||||
<span className="text-[10px] font-black uppercase tracking-widest px-3 py-1.5 rounded-lg bg-blue-500/10 text-blue-400 border border-blue-500/20 backdrop-blur-md">
|
||||
Mermaid
|
||||
</span>
|
||||
</div>
|
||||
<div className="absolute top-4 right-4 z-10">
|
||||
<span className="text-[10px] font-black uppercase tracking-widest px-3 py-1.5 rounded-lg bg-white/5 text-slate-400 border border-white/10 backdrop-blur-md">
|
||||
Manual
|
||||
</span>
|
||||
</div>
|
||||
<Editor
|
||||
height="100%"
|
||||
defaultLanguage="markdown"
|
||||
// theme prop is controlled by monaco.editor.setTheme
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
lineNumbers: 'on',
|
||||
padding: { top: 50, bottom: 20 },
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||
fontLigatures: true,
|
||||
renderLineHighlight: 'all',
|
||||
scrollbar: {
|
||||
vertical: 'visible',
|
||||
horizontal: 'visible',
|
||||
useShadows: false,
|
||||
verticalSliderSize: 6,
|
||||
horizontalSliderSize: 6
|
||||
},
|
||||
lineHeight: 1.7,
|
||||
cursorSmoothCaretAnimation: 'on',
|
||||
smoothScrolling: true,
|
||||
contextmenu: false,
|
||||
fixedOverflowWidgets: true,
|
||||
wordWrap: 'on',
|
||||
glyphMargin: true,
|
||||
}}
|
||||
value={code}
|
||||
onChange={onChange}
|
||||
onMount={onMount}
|
||||
/>
|
||||
|
||||
{/* Floating Action Buttons */}
|
||||
<div className="absolute top-4 right-24 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity z-20">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setCode('')}
|
||||
className="p-1.5 rounded-lg bg-white/5 border border-white/10 flex items-center justify-center hover:bg-red-500/20 text-slate-400 hover:text-red-400 transition-all backdrop-blur-md shadow-lg"
|
||||
title="Clear Code"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Syntax Errors */}
|
||||
{syntaxErrors.length > 0 && (
|
||||
<div className="absolute bottom-4 left-4 right-4 p-3 bg-red-500/10 border border-red-500/30 rounded-xl animate-fade-in">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-red-400 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-[10px] font-bold text-red-400 uppercase tracking-wider">Syntax Error</p>
|
||||
<p className="text-[11px] text-red-300 mt-1">{syntaxErrors[0].message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
import Editor, { type OnMount } from '@monaco-editor/react';
|
||||
import { AlertCircle, Trash2 } from 'lucide-react';
|
||||
|
||||
interface MonacoWrapperProps {
|
||||
code: string;
|
||||
onChange: (value: string | undefined) => void;
|
||||
onMount: OnMount;
|
||||
theme: 'light' | 'dark';
|
||||
syntaxErrors: { line: number; message: string }[];
|
||||
|
||||
setCode: (code: string) => void;
|
||||
}
|
||||
|
||||
export function MonacoWrapper({
|
||||
code,
|
||||
onChange,
|
||||
onMount,
|
||||
theme,
|
||||
syntaxErrors,
|
||||
setCode
|
||||
}: MonacoWrapperProps) {
|
||||
// monacoRef removed as it was unused
|
||||
|
||||
// Define themes once on mount or when theme prop changes appropriately
|
||||
// For simplicity, we can do it inside a useEffect here if we have access to monaco instance,
|
||||
// but usually it's better to do it once.
|
||||
// However, since we need monaco instance, we'll assume the parent component or this component handles it via onMount/useEffect.
|
||||
|
||||
// Actually, let's keep the theme definition inside the component for now or rely on the parent to pass the ref if needed.
|
||||
// But better yet, let's expose specific theme logic or keep it self-contained if possible.
|
||||
// The original code defined themes in useEffect when monacoRef was available.
|
||||
|
||||
return (
|
||||
<div className={`flex-1 rounded-2xl overflow-hidden border relative group shadow-inner transition-colors ${theme === 'dark' ? 'bg-[#0B1221] border-white/5' : 'bg-slate-50 border-slate-200'
|
||||
}`}>
|
||||
{/* Internal Badges */}
|
||||
<div className="absolute top-4 left-4 z-10 pointer-events-none">
|
||||
<span className="text-[10px] font-black uppercase tracking-widest px-3 py-1.5 rounded-lg bg-blue-500/10 text-blue-400 border border-blue-500/20 backdrop-blur-md">
|
||||
Mermaid
|
||||
</span>
|
||||
</div>
|
||||
<div className="absolute top-4 right-4 z-10">
|
||||
<span className="text-[10px] font-black uppercase tracking-widest px-3 py-1.5 rounded-lg bg-white/5 text-slate-400 border border-white/10 backdrop-blur-md">
|
||||
Manual
|
||||
</span>
|
||||
</div>
|
||||
<Editor
|
||||
height="100%"
|
||||
defaultLanguage="markdown"
|
||||
// theme prop is controlled by monaco.editor.setTheme
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
lineNumbers: 'on',
|
||||
padding: { top: 50, bottom: 20 },
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||
fontLigatures: true,
|
||||
renderLineHighlight: 'all',
|
||||
scrollbar: {
|
||||
vertical: 'visible',
|
||||
horizontal: 'visible',
|
||||
useShadows: false,
|
||||
verticalSliderSize: 6,
|
||||
horizontalSliderSize: 6
|
||||
},
|
||||
lineHeight: 1.7,
|
||||
cursorSmoothCaretAnimation: 'on',
|
||||
smoothScrolling: true,
|
||||
contextmenu: false,
|
||||
fixedOverflowWidgets: true,
|
||||
wordWrap: 'on',
|
||||
glyphMargin: true,
|
||||
}}
|
||||
value={code}
|
||||
onChange={onChange}
|
||||
onMount={onMount}
|
||||
/>
|
||||
|
||||
{/* Floating Action Buttons */}
|
||||
<div className="absolute top-4 right-24 flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity z-20">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setCode('')}
|
||||
className="p-1.5 rounded-lg bg-white/5 border border-white/10 flex items-center justify-center hover:bg-red-500/20 text-slate-400 hover:text-red-400 transition-all backdrop-blur-md shadow-lg"
|
||||
title="Clear Code"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Syntax Errors */}
|
||||
{syntaxErrors.length > 0 && (
|
||||
<div className="absolute bottom-4 left-4 right-4 p-3 bg-red-500/10 border border-red-500/30 rounded-xl animate-fade-in">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-red-400 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-[10px] font-bold text-red-400 uppercase tracking-wider">Syntax Error</p>
|
||||
<p className="text-[11px] text-red-300 mt-1">{syntaxErrors[0].message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,69 +1,69 @@
|
|||
import { memo } from 'react';
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
import { NODE_STYLES } from './CustomNodes';
|
||||
import { SHAPE_PATHS } from '../../lib/shapes';
|
||||
|
||||
// Map mermaid types to shape names if needed, or use raw type
|
||||
// Default fallback
|
||||
const DEFAULT_SHAPE = 'rect';
|
||||
|
||||
export const ShapeNode = memo(({ data, selected, type }: any) => {
|
||||
const rawShape = (data.shape || 'rect').toLowerCase();
|
||||
|
||||
// Normalize shape names
|
||||
let shapeKey = rawShape;
|
||||
if (rawShape === 'database') shapeKey = 'cyl';
|
||||
if (rawShape === 'decision') shapeKey = 'diamond';
|
||||
if (rawShape === 'process') shapeKey = 'rect';
|
||||
if (rawShape === 'start') shapeKey = 'stadium';
|
||||
if (rawShape === 'end') shapeKey = 'stadium';
|
||||
|
||||
const pathData = SHAPE_PATHS[shapeKey] || SHAPE_PATHS[DEFAULT_SHAPE];
|
||||
|
||||
// Style logic similar to StandardNode
|
||||
// We can infer style from category or just use default
|
||||
const category = data.category || 'default';
|
||||
// Mapping categories to styles in CustomNodes is logic we might want to reuse or replicate
|
||||
// For now let's use a default blue or check if we can import logic.
|
||||
// Since we are in a separate file, we might not have access to helper 'getNodeStyle'.
|
||||
// Let's rely on data.category which mermaidParser sets.
|
||||
|
||||
// Map parser categories to NODE_STYLES keys
|
||||
let styleKey = 'default';
|
||||
if (category === 'filter-db') styleKey = 'data';
|
||||
else if (category === 'filter-client') styleKey = 'team'; // or platform
|
||||
else if (category === 'filter-server') styleKey = 'tech';
|
||||
|
||||
// Override based on shape
|
||||
if (shapeKey === 'cyl') styleKey = 'data';
|
||||
if (shapeKey === 'diamond') styleKey = 'decision';
|
||||
if (shapeKey === 'stadium' && (type === 'start' || type === 'end')) styleKey = type;
|
||||
|
||||
// @ts-ignore
|
||||
const themeStyle = NODE_STYLES[styleKey] || NODE_STYLES.default;
|
||||
|
||||
return (
|
||||
<div className={`relative group w-32 h-20 flex items-center justify-center transition-all ${selected ? 'drop-shadow-lg scale-105' : ''}`}>
|
||||
<svg viewBox="0 0 100 70" className={`w-full h-full overflow-visible drop-shadow-sm`}>
|
||||
<path
|
||||
d={pathData}
|
||||
className={`${themeStyle.solid} ${themeStyle.border} fill-current stroke-2 stroke-current opacity-90`}
|
||||
style={{ fill: 'var(--bg-color)', stroke: 'var(--border-color)' }}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Label Overlay */}
|
||||
<div className="absolute inset-0 flex items-center justify-center p-2 text-center pointer-events-none">
|
||||
<span className={`text-[10px] font-bold leading-tight ${themeStyle.text}`}>
|
||||
{data.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Handles - Positions might need to be dynamic based on shape, but default Top/Bottom/Left/Right usually works */}
|
||||
<Handle type="target" position={Position.Top} className="!w-2 !h-2 !bg-transparent" />
|
||||
<Handle type="source" position={Position.Bottom} className="!w-2 !h-2 !bg-transparent" />
|
||||
<Handle type="source" position={Position.Right} className="!w-2 !h-2 !bg-transparent" />
|
||||
<Handle type="source" position={Position.Left} className="!w-2 !h-2 !bg-transparent" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
import { memo } from 'react';
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
import { NODE_STYLES } from './CustomNodes';
|
||||
import { SHAPE_PATHS } from '../../lib/shapes';
|
||||
|
||||
// Map mermaid types to shape names if needed, or use raw type
|
||||
// Default fallback
|
||||
const DEFAULT_SHAPE = 'rect';
|
||||
|
||||
export const ShapeNode = memo(({ data, selected, type }: any) => {
|
||||
const rawShape = (data.shape || 'rect').toLowerCase();
|
||||
|
||||
// Normalize shape names
|
||||
let shapeKey = rawShape;
|
||||
if (rawShape === 'database') shapeKey = 'cyl';
|
||||
if (rawShape === 'decision') shapeKey = 'diamond';
|
||||
if (rawShape === 'process') shapeKey = 'rect';
|
||||
if (rawShape === 'start') shapeKey = 'stadium';
|
||||
if (rawShape === 'end') shapeKey = 'stadium';
|
||||
|
||||
const pathData = SHAPE_PATHS[shapeKey] || SHAPE_PATHS[DEFAULT_SHAPE];
|
||||
|
||||
// Style logic similar to StandardNode
|
||||
// We can infer style from category or just use default
|
||||
const category = data.category || 'default';
|
||||
// Mapping categories to styles in CustomNodes is logic we might want to reuse or replicate
|
||||
// For now let's use a default blue or check if we can import logic.
|
||||
// Since we are in a separate file, we might not have access to helper 'getNodeStyle'.
|
||||
// Let's rely on data.category which mermaidParser sets.
|
||||
|
||||
// Map parser categories to NODE_STYLES keys
|
||||
let styleKey = 'default';
|
||||
if (category === 'filter-db') styleKey = 'data';
|
||||
else if (category === 'filter-client') styleKey = 'team'; // or platform
|
||||
else if (category === 'filter-server') styleKey = 'tech';
|
||||
|
||||
// Override based on shape
|
||||
if (shapeKey === 'cyl') styleKey = 'data';
|
||||
if (shapeKey === 'diamond') styleKey = 'decision';
|
||||
if (shapeKey === 'stadium' && (type === 'start' || type === 'end')) styleKey = type;
|
||||
|
||||
// @ts-ignore
|
||||
const themeStyle = NODE_STYLES[styleKey] || NODE_STYLES.default;
|
||||
|
||||
return (
|
||||
<div className={`relative group w-32 h-20 flex items-center justify-center transition-all ${selected ? 'drop-shadow-lg scale-105' : ''}`}>
|
||||
<svg viewBox="0 0 100 70" className={`w-full h-full overflow-visible drop-shadow-sm`}>
|
||||
<path
|
||||
d={pathData}
|
||||
className={`${themeStyle.solid} ${themeStyle.border} fill-current stroke-2 stroke-current opacity-90`}
|
||||
style={{ fill: 'var(--bg-color)', stroke: 'var(--border-color)' }}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Label Overlay */}
|
||||
<div className="absolute inset-0 flex items-center justify-center p-2 text-center pointer-events-none">
|
||||
<span className={`text-[10px] font-bold leading-tight ${themeStyle.text}`}>
|
||||
{data.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Handles - Positions might need to be dynamic based on shape, but default Top/Bottom/Left/Right usually works */}
|
||||
<Handle type="target" position={Position.Top} className="!w-2 !h-2 !bg-transparent" />
|
||||
<Handle type="source" position={Position.Bottom} className="!w-2 !h-2 !bg-transparent" />
|
||||
<Handle type="source" position={Position.Right} className="!w-2 !h-2 !bg-transparent" />
|
||||
<Handle type="source" position={Position.Left} className="!w-2 !h-2 !bg-transparent" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,38 +1,38 @@
|
|||
import React from 'react';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export const Button: React.FC<ButtonProps> = ({
|
||||
children,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
className = '',
|
||||
...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 variants = {
|
||||
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",
|
||||
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"
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: "px-3 py-1.5 text-xs",
|
||||
md: "px-4 py-2 text-sm",
|
||||
lg: "px-6 py-3 text-base"
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
import React from 'react';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export const Button: React.FC<ButtonProps> = ({
|
||||
children,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
className = '',
|
||||
...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 variants = {
|
||||
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",
|
||||
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"
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: "px-3 py-1.5 text-xs",
|
||||
md: "px-4 py-2 text-sm",
|
||||
lg: "px-6 py-3 text-base"
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,28 +1,28 @@
|
|||
import React from 'react';
|
||||
|
||||
interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export const Card: React.FC<CardProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
padding = 'md',
|
||||
...props
|
||||
}) => {
|
||||
const paddings = {
|
||||
none: '',
|
||||
sm: 'p-3',
|
||||
md: 'p-4',
|
||||
lg: 'p-6'
|
||||
};
|
||||
|
||||
return (
|
||||
<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}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
import React from 'react';
|
||||
|
||||
interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
export const Card: React.FC<CardProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
padding = 'md',
|
||||
...props
|
||||
}) => {
|
||||
const paddings = {
|
||||
none: '',
|
||||
sm: 'p-3',
|
||||
md: 'p-4',
|
||||
lg: 'p-6'
|
||||
};
|
||||
|
||||
return (
|
||||
<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}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,23 +1,23 @@
|
|||
|
||||
|
||||
|
||||
export function OrchestratorLoader() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8">
|
||||
<div className="relative w-16 h-16 mb-8">
|
||||
{/* Glow effect */}
|
||||
<div className="absolute inset-0 bg-blue-500/20 blur-xl rounded-full animate-pulse-slow" />
|
||||
|
||||
{/* 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" />
|
||||
|
||||
{/* 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>
|
||||
|
||||
<p className="text-[10px] font-black text-blue-500 uppercase tracking-[0.4em] animate-pulse">
|
||||
Orchestrating logic
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function OrchestratorLoader() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8">
|
||||
<div className="relative w-16 h-16 mb-8">
|
||||
{/* Glow effect */}
|
||||
<div className="absolute inset-0 bg-blue-500/20 blur-xl rounded-full animate-pulse-slow" />
|
||||
|
||||
{/* 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" />
|
||||
|
||||
{/* 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>
|
||||
|
||||
<p className="text-[10px] font-black text-blue-500 uppercase tracking-[0.4em] animate-pulse">
|
||||
Orchestrating logic
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,70 +1,70 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import { useFlowStore } from '../store';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
* useDiagramAPI
|
||||
*
|
||||
* Enables programmatic control of the diagram editor via:
|
||||
* 1. URL Parameters: ?code=<base64_mermaid>
|
||||
* 2. Window Messages: PostMessage for embedding scenarios
|
||||
*/
|
||||
export function useDiagramAPI() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { setSourceCode, sourceCode } = useFlowStore();
|
||||
|
||||
// Create a ref for sourceCode to access current value in event listener without re-binding
|
||||
const sourceCodeRef = useRef(sourceCode);
|
||||
useEffect(() => {
|
||||
sourceCodeRef.current = sourceCode;
|
||||
}, [sourceCode]);
|
||||
|
||||
// 1. Handle URL Parameters
|
||||
useEffect(() => {
|
||||
const codeParam = searchParams.get('code');
|
||||
if (codeParam) {
|
||||
try {
|
||||
// Decode Base64
|
||||
const decodedCode = atob(codeParam);
|
||||
setSourceCode(decodedCode);
|
||||
|
||||
// Clear param after consumption to avoid re-triggering or cluttering URL
|
||||
// causing a reload? No, using router API.
|
||||
// But better to keep it if user wants to share URL.
|
||||
// Let's decided to keep it for "stateful URL" behavior for now.
|
||||
// If we want to support "cleaning", we can uncomment below:
|
||||
// setSearchParams(prev => {
|
||||
// const next = new URLSearchParams(prev);
|
||||
// next.delete('code');
|
||||
// return next;
|
||||
// });
|
||||
} catch (e) {
|
||||
console.error('Failed to decode diagram code from URL:', e);
|
||||
}
|
||||
}
|
||||
}, [searchParams, setSourceCode]);
|
||||
|
||||
// 2. Handle Window Messages (for generic iframe control)
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
// Security check: You might want to check event.origin here in production
|
||||
|
||||
const { type, payload } = event.data;
|
||||
|
||||
if (type === 'KVGRAPH_LOAD' && payload?.code) {
|
||||
setSourceCode(payload.code);
|
||||
}
|
||||
|
||||
if (type === 'KVGRAPH_GET_CODE') {
|
||||
// Reply with current code
|
||||
event.source?.postMessage({
|
||||
type: 'KVGRAPH_CODE_RESPONSE',
|
||||
payload: { code: sourceCodeRef.current }
|
||||
}, { targetOrigin: event.origin });
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => window.removeEventListener('message', handleMessage);
|
||||
}, [setSourceCode]);
|
||||
}
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useFlowStore } from '../store';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
* useDiagramAPI
|
||||
*
|
||||
* Enables programmatic control of the diagram editor via:
|
||||
* 1. URL Parameters: ?code=<base64_mermaid>
|
||||
* 2. Window Messages: PostMessage for embedding scenarios
|
||||
*/
|
||||
export function useDiagramAPI() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { setSourceCode, sourceCode } = useFlowStore();
|
||||
|
||||
// Create a ref for sourceCode to access current value in event listener without re-binding
|
||||
const sourceCodeRef = useRef(sourceCode);
|
||||
useEffect(() => {
|
||||
sourceCodeRef.current = sourceCode;
|
||||
}, [sourceCode]);
|
||||
|
||||
// 1. Handle URL Parameters
|
||||
useEffect(() => {
|
||||
const codeParam = searchParams.get('code');
|
||||
if (codeParam) {
|
||||
try {
|
||||
// Decode Base64
|
||||
const decodedCode = atob(codeParam);
|
||||
setSourceCode(decodedCode);
|
||||
|
||||
// Clear param after consumption to avoid re-triggering or cluttering URL
|
||||
// causing a reload? No, using router API.
|
||||
// But better to keep it if user wants to share URL.
|
||||
// Let's decided to keep it for "stateful URL" behavior for now.
|
||||
// If we want to support "cleaning", we can uncomment below:
|
||||
// setSearchParams(prev => {
|
||||
// const next = new URLSearchParams(prev);
|
||||
// next.delete('code');
|
||||
// return next;
|
||||
// });
|
||||
} catch (e) {
|
||||
console.error('Failed to decode diagram code from URL:', e);
|
||||
}
|
||||
}
|
||||
}, [searchParams, setSourceCode]);
|
||||
|
||||
// 2. Handle Window Messages (for generic iframe control)
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
// Security check: You might want to check event.origin here in production
|
||||
|
||||
const { type, payload } = event.data;
|
||||
|
||||
if (type === 'KVGRAPH_LOAD' && payload?.code) {
|
||||
setSourceCode(payload.code);
|
||||
}
|
||||
|
||||
if (type === 'KVGRAPH_GET_CODE') {
|
||||
// Reply with current code
|
||||
event.source?.postMessage({
|
||||
type: 'KVGRAPH_CODE_RESPONSE',
|
||||
payload: { code: sourceCodeRef.current }
|
||||
}, { targetOrigin: event.origin });
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => window.removeEventListener('message', handleMessage);
|
||||
}, [setSourceCode]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,88 +1,88 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
interface MobileDetectResult {
|
||||
isMobile: boolean;
|
||||
isTouchDevice: boolean;
|
||||
prefersReducedMotion: boolean;
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for detecting mobile devices and touch capabilities.
|
||||
* Provides reactive updates on viewport changes.
|
||||
*/
|
||||
export function useMobileDetect(): MobileDetectResult {
|
||||
const [state, setState] = useState<MobileDetectResult>(() => ({
|
||||
isMobile: typeof window !== 'undefined' ? window.innerWidth < MOBILE_BREAKPOINT : false,
|
||||
isTouchDevice: typeof window !== 'undefined' ? 'ontouchstart' in window || navigator.maxTouchPoints > 0 : false,
|
||||
prefersReducedMotion: typeof window !== 'undefined'
|
||||
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
: false,
|
||||
viewportWidth: typeof window !== 'undefined' ? window.innerWidth : 1024,
|
||||
viewportHeight: typeof window !== 'undefined' ? window.innerHeight : 768,
|
||||
}));
|
||||
|
||||
const handleResize = useCallback(() => {
|
||||
setState(prev => {
|
||||
const newIsMobile = window.innerWidth < MOBILE_BREAKPOINT;
|
||||
// Only update if values changed to prevent unnecessary re-renders
|
||||
if (
|
||||
prev.isMobile === newIsMobile &&
|
||||
prev.viewportWidth === window.innerWidth &&
|
||||
prev.viewportHeight === window.innerHeight
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
isMobile: newIsMobile,
|
||||
viewportWidth: window.innerWidth,
|
||||
viewportHeight: window.innerHeight,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Use visualViewport for better mobile keyboard handling
|
||||
const viewport = window.visualViewport;
|
||||
|
||||
const handleViewportResize = () => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
viewportHeight: viewport?.height || window.innerHeight,
|
||||
}));
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
viewport?.addEventListener('resize', handleViewportResize);
|
||||
|
||||
// Check reduced motion preference changes
|
||||
const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const handleMotionChange = (e: MediaQueryListEvent) => {
|
||||
setState(prev => ({ ...prev, prefersReducedMotion: e.matches }));
|
||||
};
|
||||
motionQuery.addEventListener('change', handleMotionChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
viewport?.removeEventListener('resize', handleViewportResize);
|
||||
motionQuery.removeEventListener('change', handleMotionChange);
|
||||
};
|
||||
}, [handleResize]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function for one-time mobile check (non-reactive).
|
||||
* Use in store initialization or outside React components.
|
||||
*/
|
||||
export function checkIsMobile(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return window.innerWidth < MOBILE_BREAKPOINT || 'ontouchstart' in window;
|
||||
}
|
||||
|
||||
export { MOBILE_BREAKPOINT };
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
interface MobileDetectResult {
|
||||
isMobile: boolean;
|
||||
isTouchDevice: boolean;
|
||||
prefersReducedMotion: boolean;
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for detecting mobile devices and touch capabilities.
|
||||
* Provides reactive updates on viewport changes.
|
||||
*/
|
||||
export function useMobileDetect(): MobileDetectResult {
|
||||
const [state, setState] = useState<MobileDetectResult>(() => ({
|
||||
isMobile: typeof window !== 'undefined' ? window.innerWidth < MOBILE_BREAKPOINT : false,
|
||||
isTouchDevice: typeof window !== 'undefined' ? 'ontouchstart' in window || navigator.maxTouchPoints > 0 : false,
|
||||
prefersReducedMotion: typeof window !== 'undefined'
|
||||
? window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
: false,
|
||||
viewportWidth: typeof window !== 'undefined' ? window.innerWidth : 1024,
|
||||
viewportHeight: typeof window !== 'undefined' ? window.innerHeight : 768,
|
||||
}));
|
||||
|
||||
const handleResize = useCallback(() => {
|
||||
setState(prev => {
|
||||
const newIsMobile = window.innerWidth < MOBILE_BREAKPOINT;
|
||||
// Only update if values changed to prevent unnecessary re-renders
|
||||
if (
|
||||
prev.isMobile === newIsMobile &&
|
||||
prev.viewportWidth === window.innerWidth &&
|
||||
prev.viewportHeight === window.innerHeight
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
isMobile: newIsMobile,
|
||||
viewportWidth: window.innerWidth,
|
||||
viewportHeight: window.innerHeight,
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Use visualViewport for better mobile keyboard handling
|
||||
const viewport = window.visualViewport;
|
||||
|
||||
const handleViewportResize = () => {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
viewportHeight: viewport?.height || window.innerHeight,
|
||||
}));
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
viewport?.addEventListener('resize', handleViewportResize);
|
||||
|
||||
// Check reduced motion preference changes
|
||||
const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const handleMotionChange = (e: MediaQueryListEvent) => {
|
||||
setState(prev => ({ ...prev, prefersReducedMotion: e.matches }));
|
||||
};
|
||||
motionQuery.addEventListener('change', handleMotionChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
viewport?.removeEventListener('resize', handleViewportResize);
|
||||
motionQuery.removeEventListener('change', handleMotionChange);
|
||||
};
|
||||
}, [handleResize]);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function for one-time mobile check (non-reactive).
|
||||
* Use in store initialization or outside React components.
|
||||
*/
|
||||
export function checkIsMobile(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return window.innerWidth < MOBILE_BREAKPOINT || 'ontouchstart' in window;
|
||||
}
|
||||
|
||||
export { MOBILE_BREAKPOINT };
|
||||
|
|
|
|||
|
|
@ -1,87 +1,87 @@
|
|||
import { useCallback, useMemo } from 'react';
|
||||
import { useDiagramStore } from '../store';
|
||||
import { useSettingsStore } from '../store/settingsStore';
|
||||
import { createVisualOrganizer } from '../lib/visualOrganizer';
|
||||
import { analyzeVisualLayout } from '../lib/aiService';
|
||||
import type { LayoutSuggestion, VisualIssue, LayoutMetrics } from '../types/visualOrganization';
|
||||
|
||||
export const useVisualOrganizer = () => {
|
||||
const { nodes, edges, setNodes, setEdges } = useDiagramStore();
|
||||
const { aiMode, onlineProvider, apiKey, ollamaUrl, modelName } = useSettingsStore();
|
||||
|
||||
const visualOrganizer = useMemo(() => {
|
||||
return createVisualOrganizer(nodes, edges);
|
||||
}, [nodes, edges]);
|
||||
|
||||
const analyzeLayout = useCallback(() => {
|
||||
return visualOrganizer.analyzeLayout();
|
||||
}, [visualOrganizer]);
|
||||
|
||||
const generateSuggestions = useCallback(async () => {
|
||||
// 1. Get algorithmic suggestions
|
||||
const algorithmicSuggestions = visualOrganizer.generateSuggestions();
|
||||
|
||||
// 2. Get AI suggestions if enabled
|
||||
let aiSuggestions: LayoutSuggestion[] = [];
|
||||
try {
|
||||
const analysisResult = visualOrganizer.analyzeLayout();
|
||||
const aiResult = await analyzeVisualLayout(
|
||||
nodes,
|
||||
edges,
|
||||
analysisResult.metrics,
|
||||
ollamaUrl,
|
||||
modelName,
|
||||
aiMode,
|
||||
onlineProvider,
|
||||
apiKey
|
||||
);
|
||||
|
||||
if (aiResult.success && aiResult.analysis?.suggestions) {
|
||||
aiSuggestions = aiResult.analysis.suggestions.map((s: any) => ({
|
||||
id: s.id || `ai-${Math.random().toString(36).substr(2, 9)}`,
|
||||
title: s.title,
|
||||
description: s.description,
|
||||
type: s.type || 'style',
|
||||
impact: s.impact || 'low',
|
||||
estimatedImprovement: 0,
|
||||
beforeState: { metrics: analysisResult.metrics, issues: [] }, // AI doesn't calculate this yet
|
||||
afterState: { metrics: analysisResult.metrics, estimatedIssues: [] },
|
||||
implementation: {
|
||||
nodePositions: {}, // AI suggestions might not have positions yet
|
||||
description: s.fix_strategy // Store strategy for possible future implementation
|
||||
}
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('AI visual analysis failed:', error);
|
||||
}
|
||||
|
||||
return [...algorithmicSuggestions, ...aiSuggestions];
|
||||
}, [visualOrganizer, nodes, edges, aiMode, onlineProvider, apiKey, ollamaUrl, modelName]);
|
||||
|
||||
const applySuggestion = useCallback((suggestion: LayoutSuggestion) => {
|
||||
const { nodes: newNodes, edges: newEdges } = visualOrganizer.applySuggestion(suggestion);
|
||||
setNodes(newNodes);
|
||||
setEdges(newEdges);
|
||||
}, [visualOrganizer, setNodes, setEdges]);
|
||||
|
||||
const getPresets = useCallback(() => {
|
||||
return visualOrganizer.getPresets();
|
||||
}, [visualOrganizer]);
|
||||
|
||||
return {
|
||||
analyzeLayout,
|
||||
generateSuggestions,
|
||||
applySuggestion,
|
||||
getPresets,
|
||||
visualOrganizer
|
||||
};
|
||||
};
|
||||
|
||||
export type LayoutAnalysis = {
|
||||
metrics: LayoutMetrics;
|
||||
issues: VisualIssue[];
|
||||
strengths: string[];
|
||||
};
|
||||
|
||||
export default useVisualOrganizer;
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useDiagramStore } from '../store';
|
||||
import { useSettingsStore } from '../store/settingsStore';
|
||||
import { createVisualOrganizer } from '../lib/visualOrganizer';
|
||||
import { analyzeVisualLayout } from '../lib/aiService';
|
||||
import type { LayoutSuggestion, VisualIssue, LayoutMetrics } from '../types/visualOrganization';
|
||||
|
||||
export const useVisualOrganizer = () => {
|
||||
const { nodes, edges, setNodes, setEdges } = useDiagramStore();
|
||||
const { aiMode, onlineProvider, apiKey, ollamaUrl, modelName } = useSettingsStore();
|
||||
|
||||
const visualOrganizer = useMemo(() => {
|
||||
return createVisualOrganizer(nodes, edges);
|
||||
}, [nodes, edges]);
|
||||
|
||||
const analyzeLayout = useCallback(() => {
|
||||
return visualOrganizer.analyzeLayout();
|
||||
}, [visualOrganizer]);
|
||||
|
||||
const generateSuggestions = useCallback(async () => {
|
||||
// 1. Get algorithmic suggestions
|
||||
const algorithmicSuggestions = visualOrganizer.generateSuggestions();
|
||||
|
||||
// 2. Get AI suggestions if enabled
|
||||
let aiSuggestions: LayoutSuggestion[] = [];
|
||||
try {
|
||||
const analysisResult = visualOrganizer.analyzeLayout();
|
||||
const aiResult = await analyzeVisualLayout(
|
||||
nodes,
|
||||
edges,
|
||||
analysisResult.metrics,
|
||||
ollamaUrl,
|
||||
modelName,
|
||||
aiMode,
|
||||
onlineProvider,
|
||||
apiKey
|
||||
);
|
||||
|
||||
if (aiResult.success && aiResult.analysis?.suggestions) {
|
||||
aiSuggestions = aiResult.analysis.suggestions.map((s: any) => ({
|
||||
id: s.id || `ai-${Math.random().toString(36).substr(2, 9)}`,
|
||||
title: s.title,
|
||||
description: s.description,
|
||||
type: s.type || 'style',
|
||||
impact: s.impact || 'low',
|
||||
estimatedImprovement: 0,
|
||||
beforeState: { metrics: analysisResult.metrics, issues: [] }, // AI doesn't calculate this yet
|
||||
afterState: { metrics: analysisResult.metrics, estimatedIssues: [] },
|
||||
implementation: {
|
||||
nodePositions: {}, // AI suggestions might not have positions yet
|
||||
description: s.fix_strategy // Store strategy for possible future implementation
|
||||
}
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('AI visual analysis failed:', error);
|
||||
}
|
||||
|
||||
return [...algorithmicSuggestions, ...aiSuggestions];
|
||||
}, [visualOrganizer, nodes, edges, aiMode, onlineProvider, apiKey, ollamaUrl, modelName]);
|
||||
|
||||
const applySuggestion = useCallback((suggestion: LayoutSuggestion) => {
|
||||
const { nodes: newNodes, edges: newEdges } = visualOrganizer.applySuggestion(suggestion);
|
||||
setNodes(newNodes);
|
||||
setEdges(newEdges);
|
||||
}, [visualOrganizer, setNodes, setEdges]);
|
||||
|
||||
const getPresets = useCallback(() => {
|
||||
return visualOrganizer.getPresets();
|
||||
}, [visualOrganizer]);
|
||||
|
||||
return {
|
||||
analyzeLayout,
|
||||
generateSuggestions,
|
||||
applySuggestion,
|
||||
getPresets,
|
||||
visualOrganizer
|
||||
};
|
||||
};
|
||||
|
||||
export type LayoutAnalysis = {
|
||||
metrics: LayoutMetrics;
|
||||
issues: VisualIssue[];
|
||||
strengths: string[];
|
||||
};
|
||||
|
||||
export default useVisualOrganizer;
|
||||
|
|
|
|||
|
|
@ -1,154 +1,154 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { analyzeImage, interpretText } from '../aiService';
|
||||
|
||||
// Mock fetch global
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
describe('aiService', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('interpretText', () => {
|
||||
it('should call online AI when provider is not local', async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
choices: [{
|
||||
message: {
|
||||
content: JSON.stringify({
|
||||
mermaidCode: 'flowchart TD\nA-->B',
|
||||
metadata: {}
|
||||
})
|
||||
}
|
||||
}]
|
||||
}),
|
||||
text: async () => ''
|
||||
});
|
||||
|
||||
const result = await interpretText('test prompt', '', 'gpt-4', 'online', 'openai', 'test-key');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.mermaidCode).toContain('flowchart TD');
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('api.openai.com'),
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({
|
||||
'Authorization': 'Bearer test-key'
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should call local Ollama when provider is local', async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
message: {
|
||||
content: JSON.stringify({
|
||||
mermaidCode: 'flowchart TD\nA-->B',
|
||||
metadata: {}
|
||||
})
|
||||
}
|
||||
}),
|
||||
text: async () => ''
|
||||
});
|
||||
|
||||
// Using 'offline' mode correctly calls local AI
|
||||
const result = await interpretText('test prompt', 'http://localhost:11434', 'llama3', 'offline', undefined, '');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:11434/api/chat',
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle API errors gracefully', async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
text: async () => 'Unauthorized error text'
|
||||
});
|
||||
|
||||
const result = await interpretText('test', '', '', 'online', 'openai', 'bad-key');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Invalid API Key');
|
||||
});
|
||||
|
||||
it('should fetchWithRetry on transient errors', async () => {
|
||||
// first call fails with 429
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 429,
|
||||
statusText: 'Too Many Requests',
|
||||
text: async () => 'Rate limit exceeded'
|
||||
});
|
||||
// second call succeeds
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
choices: [{
|
||||
message: {
|
||||
content: JSON.stringify({ mermaidCode: 'flowchart TD', metadata: {} })
|
||||
}
|
||||
}]
|
||||
}),
|
||||
text: async () => ''
|
||||
});
|
||||
|
||||
// We expect it to retry, so use a short backoff or mock timers if possible.
|
||||
// Here we rely on the mocked response sequence.
|
||||
const result = await interpretText('retry test', '', 'gpt-4', 'online', 'openai', 'key');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should fallback to error message on non-retryable errors', async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
text: async () => 'Unauthorized error text'
|
||||
});
|
||||
|
||||
const result = await interpretText('test', '', '', 'online', 'openai', 'bad-key');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Invalid API Key');
|
||||
});
|
||||
});
|
||||
|
||||
describe('analyzeImage', () => {
|
||||
it('should successfully parse mermaid code from image analysis', async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
choices: [{
|
||||
message: {
|
||||
content: JSON.stringify({
|
||||
mermaidCode: 'flowchart LR\nX-->Y',
|
||||
metadata: {}
|
||||
})
|
||||
}
|
||||
}]
|
||||
}),
|
||||
text: async () => ''
|
||||
});
|
||||
|
||||
const result = await analyzeImage('base64data', '', 'gpt-4-vision', 'online', 'openai', 'key');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.mermaidCode).toContain('flowchart LR');
|
||||
});
|
||||
});
|
||||
});
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { analyzeImage, interpretText } from '../aiService';
|
||||
|
||||
// Mock fetch global
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
describe('aiService', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('interpretText', () => {
|
||||
it('should call online AI when provider is not local', async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
choices: [{
|
||||
message: {
|
||||
content: JSON.stringify({
|
||||
mermaidCode: 'flowchart TD\nA-->B',
|
||||
metadata: {}
|
||||
})
|
||||
}
|
||||
}]
|
||||
}),
|
||||
text: async () => ''
|
||||
});
|
||||
|
||||
const result = await interpretText('test prompt', '', 'gpt-4', 'online', 'openai', 'test-key');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.mermaidCode).toContain('flowchart TD');
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('api.openai.com'),
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: expect.objectContaining({
|
||||
'Authorization': 'Bearer test-key'
|
||||
})
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should call local Ollama when provider is local', async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
message: {
|
||||
content: JSON.stringify({
|
||||
mermaidCode: 'flowchart TD\nA-->B',
|
||||
metadata: {}
|
||||
})
|
||||
}
|
||||
}),
|
||||
text: async () => ''
|
||||
});
|
||||
|
||||
// Using 'offline' mode correctly calls local AI
|
||||
const result = await interpretText('test prompt', 'http://localhost:11434', 'llama3', 'offline', undefined, '');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:11434/api/chat',
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle API errors gracefully', async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
text: async () => 'Unauthorized error text'
|
||||
});
|
||||
|
||||
const result = await interpretText('test', '', '', 'online', 'openai', 'bad-key');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Invalid API Key');
|
||||
});
|
||||
|
||||
it('should fetchWithRetry on transient errors', async () => {
|
||||
// first call fails with 429
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 429,
|
||||
statusText: 'Too Many Requests',
|
||||
text: async () => 'Rate limit exceeded'
|
||||
});
|
||||
// second call succeeds
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
choices: [{
|
||||
message: {
|
||||
content: JSON.stringify({ mermaidCode: 'flowchart TD', metadata: {} })
|
||||
}
|
||||
}]
|
||||
}),
|
||||
text: async () => ''
|
||||
});
|
||||
|
||||
// We expect it to retry, so use a short backoff or mock timers if possible.
|
||||
// Here we rely on the mocked response sequence.
|
||||
const result = await interpretText('retry test', '', 'gpt-4', 'online', 'openai', 'key');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should fallback to error message on non-retryable errors', async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
text: async () => 'Unauthorized error text'
|
||||
});
|
||||
|
||||
const result = await interpretText('test', '', '', 'online', 'openai', 'bad-key');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Invalid API Key');
|
||||
});
|
||||
});
|
||||
|
||||
describe('analyzeImage', () => {
|
||||
it('should successfully parse mermaid code from image analysis', async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
choices: [{
|
||||
message: {
|
||||
content: JSON.stringify({
|
||||
mermaidCode: 'flowchart LR\nX-->Y',
|
||||
metadata: {}
|
||||
})
|
||||
}
|
||||
}]
|
||||
}),
|
||||
text: async () => ''
|
||||
});
|
||||
|
||||
const result = await analyzeImage('base64data', '', 'gpt-4-vision', 'online', 'openai', 'key');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.mermaidCode).toContain('flowchart LR');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,105 +1,105 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { parseMermaid } from '../mermaidParser';
|
||||
|
||||
// Mock mermaid library
|
||||
vi.mock('mermaid', () => {
|
||||
return {
|
||||
default: {
|
||||
initialize: vi.fn(),
|
||||
parse: vi.fn().mockResolvedValue(true),
|
||||
mermaidAPI: {
|
||||
getDiagramFromText: vi.fn().mockImplementation(async (text) => {
|
||||
// Mock DB response based on text content
|
||||
const vertices: any = {};
|
||||
const edges: any[] = [];
|
||||
const subgraphs: any[] = [];
|
||||
|
||||
if (text.includes('Start')) {
|
||||
vertices['A'] = { id: 'A', text: 'Start', type: 'round' };
|
||||
vertices['B'] = { id: 'B', text: 'End', type: 'round' };
|
||||
edges.push({ start: 'A', end: 'B', text: undefined, stroke: 'normal' });
|
||||
}
|
||||
if (text.includes('Group1')) {
|
||||
subgraphs.push({ id: 'Group1', title: 'Group1', nodes: ['A'] });
|
||||
vertices['A'] = { id: 'A', text: 'Node A', type: 'round' };
|
||||
vertices['B'] = { id: 'B', text: 'Node B', type: 'round' };
|
||||
// Edge from A outside to B outside
|
||||
edges.push({ start: 'A', end: 'B', text: undefined });
|
||||
}
|
||||
if (text.includes('Database')) {
|
||||
vertices['DB'] = { id: 'DB', text: 'Database', type: 'cylinder' };
|
||||
vertices['S'] = { id: 'S', text: 'Server', type: 'round' };
|
||||
vertices['C'] = { id: 'C', text: 'Client App', type: 'round' };
|
||||
}
|
||||
|
||||
return {
|
||||
db: {
|
||||
getVertices: () => vertices,
|
||||
getEdges: () => edges,
|
||||
getSubGraphs: () => subgraphs,
|
||||
}
|
||||
};
|
||||
})
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
describe('mermaidParser', () => {
|
||||
it('should parse a simple flowchart', async () => {
|
||||
const code = `
|
||||
flowchart TD
|
||||
A[Start] --> B[End]
|
||||
`;
|
||||
const { nodes, edges } = await parseMermaid(code);
|
||||
|
||||
expect(nodes).toHaveLength(2);
|
||||
expect(edges).toHaveLength(1);
|
||||
expect(nodes[0].data.label).toBe('Start');
|
||||
expect(nodes[1].data.label).toBe('End');
|
||||
});
|
||||
|
||||
it('should handle subgraphs correctly', async () => {
|
||||
const code = `
|
||||
flowchart TD
|
||||
subgraph Group1
|
||||
A[Node A]
|
||||
end
|
||||
A --> B[Node B]
|
||||
`;
|
||||
const { nodes } = await parseMermaid(code);
|
||||
|
||||
// Should have 3 nodes: Group1, A, B
|
||||
const groupNode = nodes.find(n => n.type === 'group');
|
||||
const childNode = nodes.find(n => n.id === 'A');
|
||||
|
||||
expect(groupNode).toBeDefined();
|
||||
// The mock implementation ensures correct parentId association logic is tested
|
||||
if (groupNode && childNode) {
|
||||
expect(childNode.parentId).toBe(groupNode.id);
|
||||
}
|
||||
});
|
||||
|
||||
it('should infer node types from labels', async () => {
|
||||
const code = `
|
||||
flowchart TD
|
||||
DB[(Database)] --> S[Server]
|
||||
S --> C([Client App])
|
||||
`;
|
||||
const { nodes } = await parseMermaid(code);
|
||||
|
||||
const dbNode = nodes.find(n => n.data.label === 'Database');
|
||||
const clientNode = nodes.find(n => n.data.label === 'Client App');
|
||||
const serverNode = nodes.find(n => n.data.label === 'Server');
|
||||
|
||||
expect(dbNode?.type).toBe('database');
|
||||
expect(clientNode?.type).toBe('client');
|
||||
expect(serverNode?.type).toBe('server');
|
||||
});
|
||||
|
||||
it('should handle empty or invalid input securely', async () => {
|
||||
const { nodes, edges } = await parseMermaid('');
|
||||
expect(nodes).toEqual([]);
|
||||
expect(edges).toEqual([]);
|
||||
});
|
||||
});
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { parseMermaid } from '../mermaidParser';
|
||||
|
||||
// Mock mermaid library
|
||||
vi.mock('mermaid', () => {
|
||||
return {
|
||||
default: {
|
||||
initialize: vi.fn(),
|
||||
parse: vi.fn().mockResolvedValue(true),
|
||||
mermaidAPI: {
|
||||
getDiagramFromText: vi.fn().mockImplementation(async (text) => {
|
||||
// Mock DB response based on text content
|
||||
const vertices: any = {};
|
||||
const edges: any[] = [];
|
||||
const subgraphs: any[] = [];
|
||||
|
||||
if (text.includes('Start')) {
|
||||
vertices['A'] = { id: 'A', text: 'Start', type: 'round' };
|
||||
vertices['B'] = { id: 'B', text: 'End', type: 'round' };
|
||||
edges.push({ start: 'A', end: 'B', text: undefined, stroke: 'normal' });
|
||||
}
|
||||
if (text.includes('Group1')) {
|
||||
subgraphs.push({ id: 'Group1', title: 'Group1', nodes: ['A'] });
|
||||
vertices['A'] = { id: 'A', text: 'Node A', type: 'round' };
|
||||
vertices['B'] = { id: 'B', text: 'Node B', type: 'round' };
|
||||
// Edge from A outside to B outside
|
||||
edges.push({ start: 'A', end: 'B', text: undefined });
|
||||
}
|
||||
if (text.includes('Database')) {
|
||||
vertices['DB'] = { id: 'DB', text: 'Database', type: 'cylinder' };
|
||||
vertices['S'] = { id: 'S', text: 'Server', type: 'round' };
|
||||
vertices['C'] = { id: 'C', text: 'Client App', type: 'round' };
|
||||
}
|
||||
|
||||
return {
|
||||
db: {
|
||||
getVertices: () => vertices,
|
||||
getEdges: () => edges,
|
||||
getSubGraphs: () => subgraphs,
|
||||
}
|
||||
};
|
||||
})
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
describe('mermaidParser', () => {
|
||||
it('should parse a simple flowchart', async () => {
|
||||
const code = `
|
||||
flowchart TD
|
||||
A[Start] --> B[End]
|
||||
`;
|
||||
const { nodes, edges } = await parseMermaid(code);
|
||||
|
||||
expect(nodes).toHaveLength(2);
|
||||
expect(edges).toHaveLength(1);
|
||||
expect(nodes[0].data.label).toBe('Start');
|
||||
expect(nodes[1].data.label).toBe('End');
|
||||
});
|
||||
|
||||
it('should handle subgraphs correctly', async () => {
|
||||
const code = `
|
||||
flowchart TD
|
||||
subgraph Group1
|
||||
A[Node A]
|
||||
end
|
||||
A --> B[Node B]
|
||||
`;
|
||||
const { nodes } = await parseMermaid(code);
|
||||
|
||||
// Should have 3 nodes: Group1, A, B
|
||||
const groupNode = nodes.find(n => n.type === 'group');
|
||||
const childNode = nodes.find(n => n.id === 'A');
|
||||
|
||||
expect(groupNode).toBeDefined();
|
||||
// The mock implementation ensures correct parentId association logic is tested
|
||||
if (groupNode && childNode) {
|
||||
expect(childNode.parentId).toBe(groupNode.id);
|
||||
}
|
||||
});
|
||||
|
||||
it('should infer node types from labels', async () => {
|
||||
const code = `
|
||||
flowchart TD
|
||||
DB[(Database)] --> S[Server]
|
||||
S --> C([Client App])
|
||||
`;
|
||||
const { nodes } = await parseMermaid(code);
|
||||
|
||||
const dbNode = nodes.find(n => n.data.label === 'Database');
|
||||
const clientNode = nodes.find(n => n.data.label === 'Client App');
|
||||
const serverNode = nodes.find(n => n.data.label === 'Server');
|
||||
|
||||
expect(dbNode?.type).toBe('database');
|
||||
expect(clientNode?.type).toBe('client');
|
||||
expect(serverNode?.type).toBe('server');
|
||||
});
|
||||
|
||||
it('should handle empty or invalid input securely', async () => {
|
||||
const { nodes, edges } = await parseMermaid('');
|
||||
expect(nodes).toEqual([]);
|
||||
expect(edges).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,54 @@
|
|||
import { toPng, toJpeg, toSvg } from 'html-to-image';
|
||||
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> {
|
||||
try {
|
||||
const backgroundColor = getThemeBackgroundColor();
|
||||
const dataUrl = await toPng(element, {
|
||||
backgroundColor: '#020617',
|
||||
backgroundColor,
|
||||
quality: 1,
|
||||
pixelRatio: 3,
|
||||
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) {
|
||||
console.error('Failed to export PNG:', error);
|
||||
throw error;
|
||||
|
|
@ -24,9 +69,9 @@ export async function exportToPng(element: HTMLElement): Promise<void> {
|
|||
|
||||
export async function exportToJpg(element: HTMLElement): Promise<void> {
|
||||
try {
|
||||
|
||||
const backgroundColor = getThemeBackgroundColor();
|
||||
const dataUrl = await toJpeg(element, {
|
||||
backgroundColor: '#020617',
|
||||
backgroundColor,
|
||||
quality: 0.95,
|
||||
pixelRatio: 2,
|
||||
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) {
|
||||
console.error('Failed to export JPG:', error);
|
||||
throw error;
|
||||
|
|
@ -46,8 +92,9 @@ export async function exportToJpg(element: HTMLElement): Promise<void> {
|
|||
|
||||
export async function exportToSvg(element: HTMLElement): Promise<void> {
|
||||
try {
|
||||
const backgroundColor = getThemeBackgroundColor();
|
||||
const dataUrl = await toSvg(element, {
|
||||
backgroundColor: '#020617',
|
||||
backgroundColor,
|
||||
filter: (node) => {
|
||||
const className = node.className?.toString() || '';
|
||||
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) {
|
||||
console.error('Failed to export SVG:', error);
|
||||
throw error;
|
||||
|
|
@ -80,10 +128,8 @@ export function exportToTxt(nodes: Node[], edges: Edge[]): void {
|
|||
txt += `- ${sourceLabel} -> ${targetLabel} ${e.label ? `(${e.label})` : ''}\n`;
|
||||
});
|
||||
|
||||
const blob = new Blob([txt], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
downloadFile(url, `summary-${getTimestamp()}.txt`);
|
||||
URL.revokeObjectURL(url);
|
||||
const blob = new Blob([txt], { type: 'text/plain;charset=utf-8' });
|
||||
saveFile(blob, `summary-${getTimestamp()}.txt`);
|
||||
}
|
||||
|
||||
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 blob = new Blob([jsonString], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
downloadFile(url, `diagram-${getTimestamp()}.json`);
|
||||
URL.revokeObjectURL(url);
|
||||
const blob = new Blob([jsonString], { type: 'application/json;charset=utf-8' });
|
||||
saveFile(blob, `diagram-${getTimestamp()}.json`);
|
||||
}
|
||||
|
||||
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 {
|
||||
const mermaid = exportToMermaid(nodes, edges);
|
||||
const blob = new Blob([mermaid], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
downloadFile(url, `diagram-${getTimestamp()}.mmd`);
|
||||
URL.revokeObjectURL(url);
|
||||
const blob = new Blob([mermaid], { type: 'text/plain;charset=utf-8' });
|
||||
saveFile(blob, `diagram-${getTimestamp()}.mmd`);
|
||||
}
|
||||
|
||||
function getTimestamp(): string {
|
||||
return new Date().toISOString().slice(0, 19).replace(/[:-]/g, '');
|
||||
}
|
||||
|
||||
function downloadFile(url: string, filename: string): void {
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
// Convert data URL to Blob for proper file download
|
||||
function dataURLtoBlob(dataUrl: string): Blob {
|
||||
const arr = dataUrl.split(',');
|
||||
const mimeMatch = arr[0].match(/:(.*?);/);
|
||||
const mime = mimeMatch ? mimeMatch[1] : 'application/octet-stream';
|
||||
const bstr = atob(arr[1]);
|
||||
let n = bstr.length;
|
||||
const u8arr = new Uint8Array(n);
|
||||
while (n--) {
|
||||
u8arr[n] = bstr.charCodeAt(n);
|
||||
}
|
||||
return new Blob([u8arr], { type: mime });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,424 +1,424 @@
|
|||
import dagre from 'dagre';
|
||||
import { type Node, type Edge } from '../store';
|
||||
|
||||
// Enhanced constants for better spacing
|
||||
const NODE_WIDTH = 180;
|
||||
const NODE_HEIGHT = 60;
|
||||
const GROUP_PADDING = 60; // Increased padding
|
||||
const GROUP_TITLE_HEIGHT = 50;
|
||||
const GROUP_GAP = 120; // Increased gap between groups
|
||||
const MIN_NODE_SPACING = 80; // Minimum space between nodes
|
||||
|
||||
export interface LayoutOptions {
|
||||
direction: 'TB' | 'LR' | 'BT' | 'RL';
|
||||
nodeSpacing: number;
|
||||
rankSpacing: number;
|
||||
smartOverlapResolution?: boolean; // Enable collision detection
|
||||
optimizeForReadability?: boolean; // Prioritize clear flow
|
||||
}
|
||||
|
||||
const defaultOptions: LayoutOptions = {
|
||||
direction: 'TB',
|
||||
nodeSpacing: 100, // Increased from 60 to prevent overlap
|
||||
rankSpacing: 150, // Increased from 80 for edge labels
|
||||
smartOverlapResolution: true,
|
||||
optimizeForReadability: true,
|
||||
};
|
||||
|
||||
export function getLayoutedElements(
|
||||
nodes: Node[],
|
||||
edges: Edge[],
|
||||
options: Partial<LayoutOptions> = {}
|
||||
): { nodes: Node[]; edges: Edge[] } {
|
||||
const opts = { ...defaultOptions, ...options };
|
||||
const isHorizontal = opts.direction === 'LR' || opts.direction === 'RL';
|
||||
|
||||
// Separate group nodes from regular nodes
|
||||
const groupNodes = nodes.filter(n => n.type === 'group');
|
||||
const regularNodes = nodes.filter(n => n.type !== 'group');
|
||||
|
||||
// If no groups, just layout all nodes flat
|
||||
if (groupNodes.length === 0) {
|
||||
return layoutFlatNodes(regularNodes, edges, opts, isHorizontal);
|
||||
}
|
||||
|
||||
// Separate nodes by their parent group
|
||||
const nodesWithoutParent = regularNodes.filter(n => !n.parentId);
|
||||
const nodesByGroup = new Map<string, Node[]>();
|
||||
|
||||
groupNodes.forEach(g => nodesByGroup.set(g.id, []));
|
||||
regularNodes.forEach(n => {
|
||||
if (n.parentId && nodesByGroup.has(n.parentId)) {
|
||||
nodesByGroup.get(n.parentId)!.push(n);
|
||||
}
|
||||
});
|
||||
|
||||
// Layout each group internally and calculate their sizes
|
||||
const groupLayouts = new Map<string, {
|
||||
width: number;
|
||||
height: number;
|
||||
nodes: Node[];
|
||||
group: Node;
|
||||
}>();
|
||||
|
||||
groupNodes.forEach(group => {
|
||||
const childNodes = nodesByGroup.get(group.id) || [];
|
||||
const layout = layoutGroupInternal(group, childNodes, edges, opts, isHorizontal);
|
||||
groupLayouts.set(group.id, layout);
|
||||
});
|
||||
|
||||
// Stack groups vertically (for TB direction)
|
||||
const finalNodes: Node[] = [];
|
||||
let currentY = 60; // Starting Y position
|
||||
const groupX = 60; // Left margin for groups
|
||||
|
||||
// Sort groups by their original order (first defined = first in list)
|
||||
const sortedGroups = Array.from(groupLayouts.values());
|
||||
|
||||
sortedGroups.forEach(({ group, width, height, nodes: childNodes }) => {
|
||||
// Position the group
|
||||
finalNodes.push({
|
||||
...group,
|
||||
position: { x: groupX, y: currentY },
|
||||
style: {
|
||||
...group.style,
|
||||
width,
|
||||
height,
|
||||
},
|
||||
} as Node);
|
||||
|
||||
// Add positioned child nodes
|
||||
childNodes.forEach(child => finalNodes.push(child));
|
||||
|
||||
// Move Y down for next group
|
||||
currentY += height + GROUP_GAP;
|
||||
});
|
||||
|
||||
// Layout orphan nodes (nodes without parent) to the right of groups
|
||||
if (nodesWithoutParent.length > 0) {
|
||||
const maxGroupWidth = Math.max(...sortedGroups.map(g => g.width), 300);
|
||||
const orphanStartX = groupX + maxGroupWidth + 100;
|
||||
|
||||
const orphanLayout = layoutOrphanNodes(nodesWithoutParent, edges, opts, isHorizontal, orphanStartX);
|
||||
orphanLayout.forEach(node => finalNodes.push(node));
|
||||
}
|
||||
|
||||
return { nodes: finalNodes, edges };
|
||||
}
|
||||
|
||||
// Layout nodes within a single group
|
||||
function layoutGroupInternal(
|
||||
group: Node,
|
||||
childNodes: Node[],
|
||||
edges: Edge[],
|
||||
opts: LayoutOptions,
|
||||
isHorizontal: boolean
|
||||
): { width: number; height: number; nodes: Node[]; group: Node } {
|
||||
if (childNodes.length === 0) {
|
||||
return {
|
||||
width: 300,
|
||||
height: 200,
|
||||
nodes: [],
|
||||
group
|
||||
};
|
||||
}
|
||||
|
||||
// Create dagre sub-graph for this group
|
||||
const subGraph = new dagre.graphlib.Graph();
|
||||
subGraph.setDefaultEdgeLabel(() => ({}));
|
||||
subGraph.setGraph({
|
||||
rankdir: opts.direction,
|
||||
nodesep: opts.nodeSpacing,
|
||||
ranksep: opts.rankSpacing,
|
||||
marginx: 30,
|
||||
marginy: 30,
|
||||
});
|
||||
|
||||
// Add nodes
|
||||
childNodes.forEach(node => {
|
||||
const w = node.type === 'decision' ? 140 : NODE_WIDTH;
|
||||
const h = node.type === 'decision' ? 90 : NODE_HEIGHT;
|
||||
subGraph.setNode(node.id, { width: w, height: h });
|
||||
});
|
||||
|
||||
// Add edges within this group
|
||||
edges.forEach(edge => {
|
||||
const sourceInGroup = childNodes.some(n => n.id === edge.source);
|
||||
const targetInGroup = childNodes.some(n => n.id === edge.target);
|
||||
if (sourceInGroup && targetInGroup) {
|
||||
subGraph.setEdge(edge.source, edge.target);
|
||||
}
|
||||
});
|
||||
|
||||
dagre.layout(subGraph);
|
||||
|
||||
// Calculate bounds
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
const positionedChildren: Node[] = [];
|
||||
|
||||
childNodes.forEach(node => {
|
||||
const pos = subGraph.node(node.id);
|
||||
if (!pos) return;
|
||||
|
||||
const w = node.type === 'decision' ? 140 : NODE_WIDTH;
|
||||
const h = node.type === 'decision' ? 90 : NODE_HEIGHT;
|
||||
const x = pos.x - w / 2;
|
||||
const y = pos.y - h / 2;
|
||||
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxX = Math.max(maxX, x + w);
|
||||
maxY = Math.max(maxY, y + h);
|
||||
|
||||
positionedChildren.push({
|
||||
...node,
|
||||
position: { x, y },
|
||||
targetPosition: isHorizontal ? 'left' : 'top',
|
||||
sourcePosition: isHorizontal ? 'right' : 'bottom',
|
||||
extent: 'parent',
|
||||
} as Node);
|
||||
});
|
||||
|
||||
// Normalize positions to start at padding
|
||||
positionedChildren.forEach(child => {
|
||||
child.position.x = child.position.x - minX + GROUP_PADDING;
|
||||
child.position.y = child.position.y - minY + GROUP_PADDING + GROUP_TITLE_HEIGHT;
|
||||
});
|
||||
|
||||
const contentWidth = maxX - minX;
|
||||
const contentHeight = maxY - minY;
|
||||
const groupWidth = contentWidth + GROUP_PADDING * 2;
|
||||
const groupHeight = contentHeight + GROUP_PADDING * 2 + GROUP_TITLE_HEIGHT;
|
||||
|
||||
return {
|
||||
width: Math.max(groupWidth, 300),
|
||||
height: Math.max(groupHeight, 200),
|
||||
nodes: positionedChildren,
|
||||
group
|
||||
};
|
||||
}
|
||||
|
||||
// Layout orphan nodes that don't belong to any group
|
||||
function layoutOrphanNodes(
|
||||
nodes: Node[],
|
||||
edges: Edge[],
|
||||
opts: LayoutOptions,
|
||||
isHorizontal: boolean,
|
||||
startX: number
|
||||
): Node[] {
|
||||
const orphanGraph = new dagre.graphlib.Graph();
|
||||
orphanGraph.setDefaultEdgeLabel(() => ({}));
|
||||
orphanGraph.setGraph({
|
||||
rankdir: opts.direction,
|
||||
nodesep: opts.nodeSpacing,
|
||||
ranksep: opts.rankSpacing,
|
||||
marginx: 0,
|
||||
marginy: 60,
|
||||
});
|
||||
|
||||
nodes.forEach(node => {
|
||||
const w = node.type === 'decision' ? 140 : NODE_WIDTH;
|
||||
const h = node.type === 'decision' ? 90 : NODE_HEIGHT;
|
||||
orphanGraph.setNode(node.id, { width: w, height: h });
|
||||
});
|
||||
|
||||
// Add edges between orphan nodes
|
||||
edges.forEach(edge => {
|
||||
const sourceOrphan = nodes.some(n => n.id === edge.source);
|
||||
const targetOrphan = nodes.some(n => n.id === edge.target);
|
||||
if (sourceOrphan && targetOrphan) {
|
||||
orphanGraph.setEdge(edge.source, edge.target);
|
||||
}
|
||||
});
|
||||
|
||||
dagre.layout(orphanGraph);
|
||||
|
||||
return nodes.map(node => {
|
||||
const pos = orphanGraph.node(node.id);
|
||||
if (!pos) return node;
|
||||
|
||||
const w = node.type === 'decision' ? 140 : NODE_WIDTH;
|
||||
const h = node.type === 'decision' ? 90 : NODE_HEIGHT;
|
||||
|
||||
return {
|
||||
...node,
|
||||
position: { x: startX + pos.x - w / 2, y: pos.y - h / 2 },
|
||||
targetPosition: isHorizontal ? 'left' : 'top',
|
||||
sourcePosition: isHorizontal ? 'right' : 'bottom',
|
||||
} as Node;
|
||||
});
|
||||
}
|
||||
|
||||
// Flat layout when there are no groups
|
||||
function layoutFlatNodes(
|
||||
nodes: Node[],
|
||||
edges: Edge[],
|
||||
opts: LayoutOptions,
|
||||
isHorizontal: boolean
|
||||
): { nodes: Node[]; edges: Edge[] } {
|
||||
const flatGraph = new dagre.graphlib.Graph();
|
||||
flatGraph.setDefaultEdgeLabel(() => ({}));
|
||||
flatGraph.setGraph({
|
||||
rankdir: opts.direction,
|
||||
nodesep: opts.nodeSpacing,
|
||||
ranksep: opts.rankSpacing,
|
||||
marginx: 60,
|
||||
marginy: 60,
|
||||
});
|
||||
|
||||
nodes.forEach(node => {
|
||||
const w = node.type === 'decision' ? 140 : NODE_WIDTH;
|
||||
const h = node.type === 'decision' ? 90 : NODE_HEIGHT;
|
||||
flatGraph.setNode(node.id, { width: w, height: h });
|
||||
});
|
||||
|
||||
edges.forEach(edge => {
|
||||
if (nodes.some(n => n.id === edge.source) && nodes.some(n => n.id === edge.target)) {
|
||||
flatGraph.setEdge(edge.source, edge.target);
|
||||
}
|
||||
});
|
||||
|
||||
dagre.layout(flatGraph);
|
||||
|
||||
const layoutedNodes = nodes.map(node => {
|
||||
const pos = flatGraph.node(node.id);
|
||||
if (!pos) return node;
|
||||
|
||||
const w = node.type === 'decision' ? 140 : NODE_WIDTH;
|
||||
const h = node.type === 'decision' ? 90 : NODE_HEIGHT;
|
||||
|
||||
return {
|
||||
...node,
|
||||
position: { x: pos.x - w / 2, y: pos.y - h / 2 },
|
||||
targetPosition: isHorizontal ? 'left' : 'top',
|
||||
sourcePosition: isHorizontal ? 'right' : 'bottom',
|
||||
} as Node;
|
||||
});
|
||||
|
||||
// Apply smart overlap resolution if enabled
|
||||
const resolvedNodes = opts.smartOverlapResolution
|
||||
? resolveOverlaps(layoutedNodes)
|
||||
: layoutedNodes;
|
||||
|
||||
return { nodes: resolvedNodes, edges };
|
||||
}
|
||||
|
||||
/**
|
||||
* Smart collision resolution - iteratively pushes overlapping nodes apart
|
||||
* Uses a force-directed approach with multiple passes for stability
|
||||
*/
|
||||
function resolveOverlaps(nodes: Node[], maxIterations: number = 50): Node[] {
|
||||
const mutableNodes = nodes.map(n => ({
|
||||
...n,
|
||||
position: { ...n.position },
|
||||
width: getNodeWidth(n),
|
||||
height: getNodeHeight(n)
|
||||
}));
|
||||
|
||||
for (let iteration = 0; iteration < maxIterations; iteration++) {
|
||||
let hasOverlap = false;
|
||||
|
||||
for (let i = 0; i < mutableNodes.length; i++) {
|
||||
for (let j = i + 1; j < mutableNodes.length; j++) {
|
||||
const nodeA = mutableNodes[i];
|
||||
const nodeB = mutableNodes[j];
|
||||
|
||||
// Skip group nodes
|
||||
if (nodeA.type === 'group' || nodeB.type === 'group') continue;
|
||||
|
||||
// Check for overlap with padding
|
||||
const overlapX = getOverlap(
|
||||
nodeA.position.x, nodeA.width,
|
||||
nodeB.position.x, nodeB.width,
|
||||
MIN_NODE_SPACING
|
||||
);
|
||||
|
||||
const overlapY = getOverlap(
|
||||
nodeA.position.y, nodeA.height,
|
||||
nodeB.position.y, nodeB.height,
|
||||
MIN_NODE_SPACING
|
||||
);
|
||||
|
||||
// If nodes overlap in both axes, push them apart
|
||||
if (overlapX > 0 && overlapY > 0) {
|
||||
hasOverlap = true;
|
||||
|
||||
// Determine which axis needs less push (more efficient separation)
|
||||
if (overlapX < overlapY) {
|
||||
// Push horizontally
|
||||
const pushX = overlapX / 2 + 5;
|
||||
if (nodeA.position.x < nodeB.position.x) {
|
||||
nodeA.position.x -= pushX;
|
||||
nodeB.position.x += pushX;
|
||||
} else {
|
||||
nodeA.position.x += pushX;
|
||||
nodeB.position.x -= pushX;
|
||||
}
|
||||
} else {
|
||||
// Push vertically
|
||||
const pushY = overlapY / 2 + 5;
|
||||
if (nodeA.position.y < nodeB.position.y) {
|
||||
nodeA.position.y -= pushY;
|
||||
nodeB.position.y += pushY;
|
||||
} else {
|
||||
nodeA.position.y += pushY;
|
||||
nodeB.position.y -= pushY;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no overlaps detected, we're done
|
||||
if (!hasOverlap) break;
|
||||
}
|
||||
|
||||
// Ensure no negative positions (shift everything if needed)
|
||||
let minX = Infinity, minY = Infinity;
|
||||
mutableNodes.forEach(n => {
|
||||
if (n.type !== 'group') {
|
||||
minX = Math.min(minX, n.position.x);
|
||||
minY = Math.min(minY, n.position.y);
|
||||
}
|
||||
});
|
||||
|
||||
const offsetX = minX < 60 ? 60 - minX : 0;
|
||||
const offsetY = minY < 60 ? 60 - minY : 0;
|
||||
|
||||
return mutableNodes.map(n => ({
|
||||
...n,
|
||||
position: {
|
||||
x: n.position.x + offsetX,
|
||||
y: n.position.y + offsetY
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate overlap between two rectangles with padding
|
||||
*/
|
||||
function getOverlap(pos1: number, size1: number, pos2: number, size2: number, padding: number): number {
|
||||
const end1 = pos1 + size1 + padding;
|
||||
const end2 = pos2 + size2 + padding;
|
||||
|
||||
return Math.min(end1 - pos2, end2 - pos1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get node width based on type
|
||||
*/
|
||||
function getNodeWidth(node: Node): number {
|
||||
if (node.type === 'decision') return 140;
|
||||
if (node.style?.width && typeof node.style.width === 'number') return node.style.width;
|
||||
return NODE_WIDTH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get node height based on type
|
||||
*/
|
||||
function getNodeHeight(node: Node): number {
|
||||
if (node.type === 'decision') return 90;
|
||||
if (node.style?.height && typeof node.style.height === 'number') return node.style.height;
|
||||
return NODE_HEIGHT;
|
||||
}
|
||||
|
||||
import dagre from 'dagre';
|
||||
import { type Node, type Edge } from '../store';
|
||||
|
||||
// Enhanced constants for better spacing
|
||||
const NODE_WIDTH = 180;
|
||||
const NODE_HEIGHT = 60;
|
||||
const GROUP_PADDING = 60; // Increased padding
|
||||
const GROUP_TITLE_HEIGHT = 50;
|
||||
const GROUP_GAP = 120; // Increased gap between groups
|
||||
const MIN_NODE_SPACING = 80; // Minimum space between nodes
|
||||
|
||||
export interface LayoutOptions {
|
||||
direction: 'TB' | 'LR' | 'BT' | 'RL';
|
||||
nodeSpacing: number;
|
||||
rankSpacing: number;
|
||||
smartOverlapResolution?: boolean; // Enable collision detection
|
||||
optimizeForReadability?: boolean; // Prioritize clear flow
|
||||
}
|
||||
|
||||
const defaultOptions: LayoutOptions = {
|
||||
direction: 'TB',
|
||||
nodeSpacing: 100, // Increased from 60 to prevent overlap
|
||||
rankSpacing: 150, // Increased from 80 for edge labels
|
||||
smartOverlapResolution: true,
|
||||
optimizeForReadability: true,
|
||||
};
|
||||
|
||||
export function getLayoutedElements(
|
||||
nodes: Node[],
|
||||
edges: Edge[],
|
||||
options: Partial<LayoutOptions> = {}
|
||||
): { nodes: Node[]; edges: Edge[] } {
|
||||
const opts = { ...defaultOptions, ...options };
|
||||
const isHorizontal = opts.direction === 'LR' || opts.direction === 'RL';
|
||||
|
||||
// Separate group nodes from regular nodes
|
||||
const groupNodes = nodes.filter(n => n.type === 'group');
|
||||
const regularNodes = nodes.filter(n => n.type !== 'group');
|
||||
|
||||
// If no groups, just layout all nodes flat
|
||||
if (groupNodes.length === 0) {
|
||||
return layoutFlatNodes(regularNodes, edges, opts, isHorizontal);
|
||||
}
|
||||
|
||||
// Separate nodes by their parent group
|
||||
const nodesWithoutParent = regularNodes.filter(n => !n.parentId);
|
||||
const nodesByGroup = new Map<string, Node[]>();
|
||||
|
||||
groupNodes.forEach(g => nodesByGroup.set(g.id, []));
|
||||
regularNodes.forEach(n => {
|
||||
if (n.parentId && nodesByGroup.has(n.parentId)) {
|
||||
nodesByGroup.get(n.parentId)!.push(n);
|
||||
}
|
||||
});
|
||||
|
||||
// Layout each group internally and calculate their sizes
|
||||
const groupLayouts = new Map<string, {
|
||||
width: number;
|
||||
height: number;
|
||||
nodes: Node[];
|
||||
group: Node;
|
||||
}>();
|
||||
|
||||
groupNodes.forEach(group => {
|
||||
const childNodes = nodesByGroup.get(group.id) || [];
|
||||
const layout = layoutGroupInternal(group, childNodes, edges, opts, isHorizontal);
|
||||
groupLayouts.set(group.id, layout);
|
||||
});
|
||||
|
||||
// Stack groups vertically (for TB direction)
|
||||
const finalNodes: Node[] = [];
|
||||
let currentY = 60; // Starting Y position
|
||||
const groupX = 60; // Left margin for groups
|
||||
|
||||
// Sort groups by their original order (first defined = first in list)
|
||||
const sortedGroups = Array.from(groupLayouts.values());
|
||||
|
||||
sortedGroups.forEach(({ group, width, height, nodes: childNodes }) => {
|
||||
// Position the group
|
||||
finalNodes.push({
|
||||
...group,
|
||||
position: { x: groupX, y: currentY },
|
||||
style: {
|
||||
...group.style,
|
||||
width,
|
||||
height,
|
||||
},
|
||||
} as Node);
|
||||
|
||||
// Add positioned child nodes
|
||||
childNodes.forEach(child => finalNodes.push(child));
|
||||
|
||||
// Move Y down for next group
|
||||
currentY += height + GROUP_GAP;
|
||||
});
|
||||
|
||||
// Layout orphan nodes (nodes without parent) to the right of groups
|
||||
if (nodesWithoutParent.length > 0) {
|
||||
const maxGroupWidth = Math.max(...sortedGroups.map(g => g.width), 300);
|
||||
const orphanStartX = groupX + maxGroupWidth + 100;
|
||||
|
||||
const orphanLayout = layoutOrphanNodes(nodesWithoutParent, edges, opts, isHorizontal, orphanStartX);
|
||||
orphanLayout.forEach(node => finalNodes.push(node));
|
||||
}
|
||||
|
||||
return { nodes: finalNodes, edges };
|
||||
}
|
||||
|
||||
// Layout nodes within a single group
|
||||
function layoutGroupInternal(
|
||||
group: Node,
|
||||
childNodes: Node[],
|
||||
edges: Edge[],
|
||||
opts: LayoutOptions,
|
||||
isHorizontal: boolean
|
||||
): { width: number; height: number; nodes: Node[]; group: Node } {
|
||||
if (childNodes.length === 0) {
|
||||
return {
|
||||
width: 300,
|
||||
height: 200,
|
||||
nodes: [],
|
||||
group
|
||||
};
|
||||
}
|
||||
|
||||
// Create dagre sub-graph for this group
|
||||
const subGraph = new dagre.graphlib.Graph();
|
||||
subGraph.setDefaultEdgeLabel(() => ({}));
|
||||
subGraph.setGraph({
|
||||
rankdir: opts.direction,
|
||||
nodesep: opts.nodeSpacing,
|
||||
ranksep: opts.rankSpacing,
|
||||
marginx: 30,
|
||||
marginy: 30,
|
||||
});
|
||||
|
||||
// Add nodes
|
||||
childNodes.forEach(node => {
|
||||
const w = node.type === 'decision' ? 140 : NODE_WIDTH;
|
||||
const h = node.type === 'decision' ? 90 : NODE_HEIGHT;
|
||||
subGraph.setNode(node.id, { width: w, height: h });
|
||||
});
|
||||
|
||||
// Add edges within this group
|
||||
edges.forEach(edge => {
|
||||
const sourceInGroup = childNodes.some(n => n.id === edge.source);
|
||||
const targetInGroup = childNodes.some(n => n.id === edge.target);
|
||||
if (sourceInGroup && targetInGroup) {
|
||||
subGraph.setEdge(edge.source, edge.target);
|
||||
}
|
||||
});
|
||||
|
||||
dagre.layout(subGraph);
|
||||
|
||||
// Calculate bounds
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
const positionedChildren: Node[] = [];
|
||||
|
||||
childNodes.forEach(node => {
|
||||
const pos = subGraph.node(node.id);
|
||||
if (!pos) return;
|
||||
|
||||
const w = node.type === 'decision' ? 140 : NODE_WIDTH;
|
||||
const h = node.type === 'decision' ? 90 : NODE_HEIGHT;
|
||||
const x = pos.x - w / 2;
|
||||
const y = pos.y - h / 2;
|
||||
|
||||
minX = Math.min(minX, x);
|
||||
minY = Math.min(minY, y);
|
||||
maxX = Math.max(maxX, x + w);
|
||||
maxY = Math.max(maxY, y + h);
|
||||
|
||||
positionedChildren.push({
|
||||
...node,
|
||||
position: { x, y },
|
||||
targetPosition: isHorizontal ? 'left' : 'top',
|
||||
sourcePosition: isHorizontal ? 'right' : 'bottom',
|
||||
extent: 'parent',
|
||||
} as Node);
|
||||
});
|
||||
|
||||
// Normalize positions to start at padding
|
||||
positionedChildren.forEach(child => {
|
||||
child.position.x = child.position.x - minX + GROUP_PADDING;
|
||||
child.position.y = child.position.y - minY + GROUP_PADDING + GROUP_TITLE_HEIGHT;
|
||||
});
|
||||
|
||||
const contentWidth = maxX - minX;
|
||||
const contentHeight = maxY - minY;
|
||||
const groupWidth = contentWidth + GROUP_PADDING * 2;
|
||||
const groupHeight = contentHeight + GROUP_PADDING * 2 + GROUP_TITLE_HEIGHT;
|
||||
|
||||
return {
|
||||
width: Math.max(groupWidth, 300),
|
||||
height: Math.max(groupHeight, 200),
|
||||
nodes: positionedChildren,
|
||||
group
|
||||
};
|
||||
}
|
||||
|
||||
// Layout orphan nodes that don't belong to any group
|
||||
function layoutOrphanNodes(
|
||||
nodes: Node[],
|
||||
edges: Edge[],
|
||||
opts: LayoutOptions,
|
||||
isHorizontal: boolean,
|
||||
startX: number
|
||||
): Node[] {
|
||||
const orphanGraph = new dagre.graphlib.Graph();
|
||||
orphanGraph.setDefaultEdgeLabel(() => ({}));
|
||||
orphanGraph.setGraph({
|
||||
rankdir: opts.direction,
|
||||
nodesep: opts.nodeSpacing,
|
||||
ranksep: opts.rankSpacing,
|
||||
marginx: 0,
|
||||
marginy: 60,
|
||||
});
|
||||
|
||||
nodes.forEach(node => {
|
||||
const w = node.type === 'decision' ? 140 : NODE_WIDTH;
|
||||
const h = node.type === 'decision' ? 90 : NODE_HEIGHT;
|
||||
orphanGraph.setNode(node.id, { width: w, height: h });
|
||||
});
|
||||
|
||||
// Add edges between orphan nodes
|
||||
edges.forEach(edge => {
|
||||
const sourceOrphan = nodes.some(n => n.id === edge.source);
|
||||
const targetOrphan = nodes.some(n => n.id === edge.target);
|
||||
if (sourceOrphan && targetOrphan) {
|
||||
orphanGraph.setEdge(edge.source, edge.target);
|
||||
}
|
||||
});
|
||||
|
||||
dagre.layout(orphanGraph);
|
||||
|
||||
return nodes.map(node => {
|
||||
const pos = orphanGraph.node(node.id);
|
||||
if (!pos) return node;
|
||||
|
||||
const w = node.type === 'decision' ? 140 : NODE_WIDTH;
|
||||
const h = node.type === 'decision' ? 90 : NODE_HEIGHT;
|
||||
|
||||
return {
|
||||
...node,
|
||||
position: { x: startX + pos.x - w / 2, y: pos.y - h / 2 },
|
||||
targetPosition: isHorizontal ? 'left' : 'top',
|
||||
sourcePosition: isHorizontal ? 'right' : 'bottom',
|
||||
} as Node;
|
||||
});
|
||||
}
|
||||
|
||||
// Flat layout when there are no groups
|
||||
function layoutFlatNodes(
|
||||
nodes: Node[],
|
||||
edges: Edge[],
|
||||
opts: LayoutOptions,
|
||||
isHorizontal: boolean
|
||||
): { nodes: Node[]; edges: Edge[] } {
|
||||
const flatGraph = new dagre.graphlib.Graph();
|
||||
flatGraph.setDefaultEdgeLabel(() => ({}));
|
||||
flatGraph.setGraph({
|
||||
rankdir: opts.direction,
|
||||
nodesep: opts.nodeSpacing,
|
||||
ranksep: opts.rankSpacing,
|
||||
marginx: 60,
|
||||
marginy: 60,
|
||||
});
|
||||
|
||||
nodes.forEach(node => {
|
||||
const w = node.type === 'decision' ? 140 : NODE_WIDTH;
|
||||
const h = node.type === 'decision' ? 90 : NODE_HEIGHT;
|
||||
flatGraph.setNode(node.id, { width: w, height: h });
|
||||
});
|
||||
|
||||
edges.forEach(edge => {
|
||||
if (nodes.some(n => n.id === edge.source) && nodes.some(n => n.id === edge.target)) {
|
||||
flatGraph.setEdge(edge.source, edge.target);
|
||||
}
|
||||
});
|
||||
|
||||
dagre.layout(flatGraph);
|
||||
|
||||
const layoutedNodes = nodes.map(node => {
|
||||
const pos = flatGraph.node(node.id);
|
||||
if (!pos) return node;
|
||||
|
||||
const w = node.type === 'decision' ? 140 : NODE_WIDTH;
|
||||
const h = node.type === 'decision' ? 90 : NODE_HEIGHT;
|
||||
|
||||
return {
|
||||
...node,
|
||||
position: { x: pos.x - w / 2, y: pos.y - h / 2 },
|
||||
targetPosition: isHorizontal ? 'left' : 'top',
|
||||
sourcePosition: isHorizontal ? 'right' : 'bottom',
|
||||
} as Node;
|
||||
});
|
||||
|
||||
// Apply smart overlap resolution if enabled
|
||||
const resolvedNodes = opts.smartOverlapResolution
|
||||
? resolveOverlaps(layoutedNodes)
|
||||
: layoutedNodes;
|
||||
|
||||
return { nodes: resolvedNodes, edges };
|
||||
}
|
||||
|
||||
/**
|
||||
* Smart collision resolution - iteratively pushes overlapping nodes apart
|
||||
* Uses a force-directed approach with multiple passes for stability
|
||||
*/
|
||||
function resolveOverlaps(nodes: Node[], maxIterations: number = 50): Node[] {
|
||||
const mutableNodes = nodes.map(n => ({
|
||||
...n,
|
||||
position: { ...n.position },
|
||||
width: getNodeWidth(n),
|
||||
height: getNodeHeight(n)
|
||||
}));
|
||||
|
||||
for (let iteration = 0; iteration < maxIterations; iteration++) {
|
||||
let hasOverlap = false;
|
||||
|
||||
for (let i = 0; i < mutableNodes.length; i++) {
|
||||
for (let j = i + 1; j < mutableNodes.length; j++) {
|
||||
const nodeA = mutableNodes[i];
|
||||
const nodeB = mutableNodes[j];
|
||||
|
||||
// Skip group nodes
|
||||
if (nodeA.type === 'group' || nodeB.type === 'group') continue;
|
||||
|
||||
// Check for overlap with padding
|
||||
const overlapX = getOverlap(
|
||||
nodeA.position.x, nodeA.width,
|
||||
nodeB.position.x, nodeB.width,
|
||||
MIN_NODE_SPACING
|
||||
);
|
||||
|
||||
const overlapY = getOverlap(
|
||||
nodeA.position.y, nodeA.height,
|
||||
nodeB.position.y, nodeB.height,
|
||||
MIN_NODE_SPACING
|
||||
);
|
||||
|
||||
// If nodes overlap in both axes, push them apart
|
||||
if (overlapX > 0 && overlapY > 0) {
|
||||
hasOverlap = true;
|
||||
|
||||
// Determine which axis needs less push (more efficient separation)
|
||||
if (overlapX < overlapY) {
|
||||
// Push horizontally
|
||||
const pushX = overlapX / 2 + 5;
|
||||
if (nodeA.position.x < nodeB.position.x) {
|
||||
nodeA.position.x -= pushX;
|
||||
nodeB.position.x += pushX;
|
||||
} else {
|
||||
nodeA.position.x += pushX;
|
||||
nodeB.position.x -= pushX;
|
||||
}
|
||||
} else {
|
||||
// Push vertically
|
||||
const pushY = overlapY / 2 + 5;
|
||||
if (nodeA.position.y < nodeB.position.y) {
|
||||
nodeA.position.y -= pushY;
|
||||
nodeB.position.y += pushY;
|
||||
} else {
|
||||
nodeA.position.y += pushY;
|
||||
nodeB.position.y -= pushY;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no overlaps detected, we're done
|
||||
if (!hasOverlap) break;
|
||||
}
|
||||
|
||||
// Ensure no negative positions (shift everything if needed)
|
||||
let minX = Infinity, minY = Infinity;
|
||||
mutableNodes.forEach(n => {
|
||||
if (n.type !== 'group') {
|
||||
minX = Math.min(minX, n.position.x);
|
||||
minY = Math.min(minY, n.position.y);
|
||||
}
|
||||
});
|
||||
|
||||
const offsetX = minX < 60 ? 60 - minX : 0;
|
||||
const offsetY = minY < 60 ? 60 - minY : 0;
|
||||
|
||||
return mutableNodes.map(n => ({
|
||||
...n,
|
||||
position: {
|
||||
x: n.position.x + offsetX,
|
||||
y: n.position.y + offsetY
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate overlap between two rectangles with padding
|
||||
*/
|
||||
function getOverlap(pos1: number, size1: number, pos2: number, size2: number, padding: number): number {
|
||||
const end1 = pos1 + size1 + padding;
|
||||
const end2 = pos2 + size2 + padding;
|
||||
|
||||
return Math.min(end1 - pos2, end2 - pos1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get node width based on type
|
||||
*/
|
||||
function getNodeWidth(node: Node): number {
|
||||
if (node.type === 'decision') return 140;
|
||||
if (node.style?.width && typeof node.style.width === 'number') return node.style.width;
|
||||
return NODE_WIDTH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get node height based on type
|
||||
*/
|
||||
function getNodeHeight(node: Node): number {
|
||||
if (node.type === 'decision') return 90;
|
||||
if (node.style?.height && typeof node.style.height === 'number') return node.style.height;
|
||||
return NODE_HEIGHT;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,30 +1,30 @@
|
|||
import mermaid from 'mermaid';
|
||||
|
||||
// Initialize mermaid
|
||||
mermaid.initialize({ startOnLoad: false });
|
||||
|
||||
const graphDefinition = `
|
||||
flowchart TD
|
||||
A[Start] --> B{Is it working?}
|
||||
B -- Yes --> C[Great!]
|
||||
B -- No --> D[Debug]
|
||||
`;
|
||||
|
||||
async function testParsing() {
|
||||
try {
|
||||
// Validate syntax
|
||||
const valid = await mermaid.parse(graphDefinition);
|
||||
console.log("Parse result:", valid);
|
||||
|
||||
// Attempt to get data
|
||||
// Note: mermaid.mermaidAPI.getDiagramFromText is deprecated/internal but often used.
|
||||
// In v10+, we might need to use other methods.
|
||||
const type = mermaid.detectType(graphDefinition);
|
||||
console.log("Detected type:", type);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Parsing failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
testParsing();
|
||||
import mermaid from 'mermaid';
|
||||
|
||||
// Initialize mermaid
|
||||
mermaid.initialize({ startOnLoad: false });
|
||||
|
||||
const graphDefinition = `
|
||||
flowchart TD
|
||||
A[Start] --> B{Is it working?}
|
||||
B -- Yes --> C[Great!]
|
||||
B -- No --> D[Debug]
|
||||
`;
|
||||
|
||||
async function testParsing() {
|
||||
try {
|
||||
// Validate syntax
|
||||
const valid = await mermaid.parse(graphDefinition);
|
||||
console.log("Parse result:", valid);
|
||||
|
||||
// Attempt to get data
|
||||
// Note: mermaid.mermaidAPI.getDiagramFromText is deprecated/internal but often used.
|
||||
// In v10+, we might need to use other methods.
|
||||
const type = mermaid.detectType(graphDefinition);
|
||||
console.log("Detected type:", type);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Parsing failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
testParsing();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
Return ONLY a strictly valid JSON object. No Markdown headers, no preamble.
|
||||
|
||||
EXPECTED JSON FORMAT:
|
||||
{
|
||||
"mermaidCode": "flowchart TD\\n A[Client] --> B[API Server]\\n ...",
|
||||
"metadata": {
|
||||
"NodeLabel": {
|
||||
"techStack": ["Technologies used"],
|
||||
"role": "Specific role (e.g., Load Balancer, Cache, Database)",
|
||||
"description": "Concise technical responsibility"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ARCHITECTURAL RULES:
|
||||
1. Use 'flowchart TD' or 'flowchart LR'.
|
||||
2. Use descriptive but concise ID/Labels (e.g., 'API', 'DB_PROD').
|
||||
3. Labels must match the keys in the 'metadata' object EXACTLY.
|
||||
4. If input is already Mermaid code, wrap it in the JSON 'mermaidCode' field and infer metadata.
|
||||
5. Identify semantic roles: use keywords like 'Client', 'Server', 'Worker', 'Database', 'Queue' in labels.
|
||||
6. Escape double quotes inside the mermaid string correctly.
|
||||
|
||||
DIAGRAM QUALITY RULES:
|
||||
7. NEVER use HTML tags (like <br/>, <b>, etc.) in node labels. Use short, clean text only.
|
||||
8. NEVER use pipe characters (|) inside node labels - they break parsing. Use commas or spaces instead.
|
||||
9. Use DIAMOND shapes {Decision} for review, approval, or decision steps (e.g., A{Approve?}).
|
||||
10. Use CYLINDER shapes [(Database)] for data stores.
|
||||
11. Use ROUNDED shapes (Process) for human/manual tasks.
|
||||
12. For complex workflows, add step numbers as edge labels: A -->|1| B -->|2| C.
|
||||
13. Include feedback loops where logical (e.g., connect "Collect Feedback" back to analysis nodes).
|
||||
14. Use subgraphs/swimlanes to group related components by team, role, or domain.
|
||||
15. Ensure consistent node shapes within each swimlane/subgraph.`;
|
||||
|
||||
export const SYSTEM_PROMPT_SIMPLE = `You are a System Architect. Create a SIMPLE, high-level Mermaid diagram.
|
||||
- Focus on the main data flow (max 5-7 nodes).
|
||||
- Use simple labels (e.g., "User", "API", "DB"). NEVER use HTML tags like <br/>.
|
||||
- Use diamond {Decision?} for approval/review steps.
|
||||
- Minimal metadata (role only).`;
|
||||
|
||||
export const SYSTEM_PROMPT_COMPLEX = `You are an Expert Solution Architect. Create a DETAILED, comprehensive Mermaid diagram.
|
||||
- Include all subsystems, queues, workers, and external services.
|
||||
- Use swimlanes (subgraphs) to group components by role/domain.
|
||||
- NEVER use HTML tags like <br/> in labels. Keep labels clean and concise.
|
||||
- Use diamond {Decision?} for approval/review steps.
|
||||
- Use cylinder [(DB)] for databases, rounded (Task) for human tasks.
|
||||
- Add step numbers as edge labels for complex flows: A -->|1| B -->|2| C.
|
||||
- Include feedback loops connecting outputs back to inputs where logical.
|
||||
- Detailed metadata (techStack, role, description).`;
|
||||
|
||||
export const SUGGEST_PROMPT = `You are a Mermaid.js syntax and logic expert.
|
||||
Your task is to analyze the provided Mermaid flowchart code and either:
|
||||
1. Fix any syntax errors that prevent it from rendering.
|
||||
2. Improve the logical flow or visual clarity if the syntax is already correct.
|
||||
|
||||
Return ONLY a strictly valid JSON object. No Markdown headers, no preamble.
|
||||
|
||||
EXPECTED JSON FORMAT:
|
||||
{
|
||||
"mermaidCode": "flowchart TD\\n A[Fixed/Improved] --> B[Nodes]",
|
||||
"explanation": "Briefly explain what was fixed or improved."
|
||||
}
|
||||
|
||||
RULES:
|
||||
- Maintain the original intent of the diagram.
|
||||
- Use best practices for Mermaid layout and labeling.
|
||||
- If the code is already perfect, return it as is but provide a positive explanation.`;
|
||||
|
||||
export const VISUAL_ANALYSIS_PROMPT = `You are a Visualization and UX Expert specialized in node-graph diagrams.
|
||||
Your task is to analyze the provided graph structure and metrics to suggest specific improvements for layout, grouping, and visual clarity.
|
||||
|
||||
Return ONLY a strictly valid JSON object. Do not include markdown formatting like \`\`\`json.
|
||||
|
||||
EXPECTED JSON FORMAT:
|
||||
{
|
||||
"analysis": {
|
||||
"suggestions": [
|
||||
{
|
||||
"id": "unique-id",
|
||||
"title": "Short title",
|
||||
"description": "Detailed explanation",
|
||||
"type": "spacing" | "grouping" | "routing" | "hierarchy" | "style",
|
||||
"impact": "high" | "medium" | "low",
|
||||
"fix_strategy": "algorithmic_spacing" | "algorithmic_routing" | "group_nodes" | "unknown"
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"critique": "Overall analysis",
|
||||
"score": 0-100
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
EXAMPLE RESPONSE:
|
||||
{
|
||||
"analysis": {
|
||||
"suggestions": [
|
||||
{
|
||||
"id": "sug-1",
|
||||
"title": "Group Database Nodes",
|
||||
"description": "Several database nodes are scattered. Group them for better logical separation.",
|
||||
"type": "grouping",
|
||||
"impact": "high",
|
||||
"fix_strategy": "group_nodes"
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"critique": "The flow is generally good but lacks logical grouping for backend services.",
|
||||
"score": 75
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RULES:
|
||||
- Focus on readability, flow, and logical grouping.
|
||||
- Identify if nodes are too cluttered or if the flow is confusing.
|
||||
- Suggest grouping for nodes that appear related based on their labels.`;
|
||||
export const SYSTEM_PROMPT = `You are an expert System Architect AI. Your task is to transform technical descriptions into precise Mermaid.js diagrams and high-fidelity metadata.
|
||||
|
||||
Return ONLY a strictly valid JSON object. No Markdown headers, no preamble.
|
||||
|
||||
EXPECTED JSON FORMAT:
|
||||
{
|
||||
"mermaidCode": "flowchart TD\\n A[Client] --> B[API Server]\\n ...",
|
||||
"metadata": {
|
||||
"NodeLabel": {
|
||||
"techStack": ["Technologies used"],
|
||||
"role": "Specific role (e.g., Load Balancer, Cache, Database)",
|
||||
"description": "Concise technical responsibility"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ARCHITECTURAL RULES:
|
||||
1. Use 'flowchart TD' or 'flowchart LR'.
|
||||
2. Use descriptive but concise ID/Labels (e.g., 'API', 'DB_PROD').
|
||||
3. Labels must match the keys in the 'metadata' object EXACTLY.
|
||||
4. If input is already Mermaid code, wrap it in the JSON 'mermaidCode' field and infer metadata.
|
||||
5. Identify semantic roles: use keywords like 'Client', 'Server', 'Worker', 'Database', 'Queue' in labels.
|
||||
6. Escape double quotes inside the mermaid string correctly.
|
||||
|
||||
DIAGRAM QUALITY RULES:
|
||||
7. NEVER use HTML tags (like <br/>, <b>, etc.) in node labels. Use short, clean text only.
|
||||
8. NEVER use pipe characters (|) inside node labels - they break parsing. Use commas or spaces instead.
|
||||
9. Use DIAMOND shapes {Decision} for review, approval, or decision steps (e.g., A{Approve?}).
|
||||
10. Use CYLINDER shapes [(Database)] for data stores.
|
||||
11. Use ROUNDED shapes (Process) for human/manual tasks.
|
||||
12. For complex workflows, add step numbers as edge labels: A -->|1| B -->|2| C.
|
||||
13. Include feedback loops where logical (e.g., connect "Collect Feedback" back to analysis nodes).
|
||||
14. Use subgraphs/swimlanes to group related components by team, role, or domain.
|
||||
15. Ensure consistent node shapes within each swimlane/subgraph.`;
|
||||
|
||||
export const SYSTEM_PROMPT_SIMPLE = `You are a System Architect. Create a SIMPLE, high-level Mermaid diagram.
|
||||
- Focus on the main data flow (max 5-7 nodes).
|
||||
- Use simple labels (e.g., "User", "API", "DB"). NEVER use HTML tags like <br/>.
|
||||
- Use diamond {Decision?} for approval/review steps.
|
||||
- Minimal metadata (role only).`;
|
||||
|
||||
export const SYSTEM_PROMPT_COMPLEX = `You are an Expert Solution Architect. Create a DETAILED, comprehensive Mermaid diagram.
|
||||
- Include all subsystems, queues, workers, and external services.
|
||||
- Use swimlanes (subgraphs) to group components by role/domain.
|
||||
- NEVER use HTML tags like <br/> in labels. Keep labels clean and concise.
|
||||
- Use diamond {Decision?} for approval/review steps.
|
||||
- Use cylinder [(DB)] for databases, rounded (Task) for human tasks.
|
||||
- Add step numbers as edge labels for complex flows: A -->|1| B -->|2| C.
|
||||
- Include feedback loops connecting outputs back to inputs where logical.
|
||||
- Detailed metadata (techStack, role, description).`;
|
||||
|
||||
export const SUGGEST_PROMPT = `You are a Mermaid.js syntax and logic expert.
|
||||
Your task is to analyze the provided Mermaid flowchart code and either:
|
||||
1. Fix any syntax errors that prevent it from rendering.
|
||||
2. Improve the logical flow or visual clarity if the syntax is already correct.
|
||||
|
||||
Return ONLY a strictly valid JSON object. No Markdown headers, no preamble.
|
||||
|
||||
EXPECTED JSON FORMAT:
|
||||
{
|
||||
"mermaidCode": "flowchart TD\\n A[Fixed/Improved] --> B[Nodes]",
|
||||
"explanation": "Briefly explain what was fixed or improved."
|
||||
}
|
||||
|
||||
RULES:
|
||||
- Maintain the original intent of the diagram.
|
||||
- Use best practices for Mermaid layout and labeling.
|
||||
- If the code is already perfect, return it as is but provide a positive explanation.`;
|
||||
|
||||
export const VISUAL_ANALYSIS_PROMPT = `You are a Visualization and UX Expert specialized in node-graph diagrams.
|
||||
Your task is to analyze the provided graph structure and metrics to suggest specific improvements for layout, grouping, and visual clarity.
|
||||
|
||||
Return ONLY a strictly valid JSON object. Do not include markdown formatting like \`\`\`json.
|
||||
|
||||
EXPECTED JSON FORMAT:
|
||||
{
|
||||
"analysis": {
|
||||
"suggestions": [
|
||||
{
|
||||
"id": "unique-id",
|
||||
"title": "Short title",
|
||||
"description": "Detailed explanation",
|
||||
"type": "spacing" | "grouping" | "routing" | "hierarchy" | "style",
|
||||
"impact": "high" | "medium" | "low",
|
||||
"fix_strategy": "algorithmic_spacing" | "algorithmic_routing" | "group_nodes" | "unknown"
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"critique": "Overall analysis",
|
||||
"score": 0-100
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
EXAMPLE RESPONSE:
|
||||
{
|
||||
"analysis": {
|
||||
"suggestions": [
|
||||
{
|
||||
"id": "sug-1",
|
||||
"title": "Group Database Nodes",
|
||||
"description": "Several database nodes are scattered. Group them for better logical separation.",
|
||||
"type": "grouping",
|
||||
"impact": "high",
|
||||
"fix_strategy": "group_nodes"
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"critique": "The flow is generally good but lacks logical grouping for backend services.",
|
||||
"score": 75
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RULES:
|
||||
- Focus on readability, flow, and logical grouping.
|
||||
- Identify if nodes are too cluttered or if the flow is confusing.
|
||||
- Suggest grouping for nodes that appear related based on their labels.`;
|
||||
|
|
|
|||
|
|
@ -1,61 +1,61 @@
|
|||
// SVG Paths for Mermaid Shapes
|
||||
// Based on Mermaid v11.3.0+ syntax
|
||||
// Syntax: node@{ shape: shapeName }
|
||||
|
||||
export interface ShapeDefinition {
|
||||
id: string;
|
||||
label: string;
|
||||
path: string;
|
||||
category: 'basic' | 'logic' | 'data' | 'other';
|
||||
}
|
||||
|
||||
export const SHAPE_PATHS: Record<string, string> = {
|
||||
// Basic
|
||||
rect: 'M0 4 h100 v60 h-100 z',
|
||||
rounded: 'M10 4 h80 a10 10 0 0 1 10 10 v40 a10 10 0 0 1 -10 10 h-80 a10 10 0 0 1 -10 -10 v-40 a10 10 0 0 1 10 -10 z',
|
||||
stadium: 'M20 4 h60 a20 20 0 0 1 20 20 v20 a20 20 0 0 1 -20 20 h-60 a20 20 0 0 1 -20 -20 v-20 a20 20 0 0 1 20 -20 z',
|
||||
subroutine: 'M10 4 h80 v60 h-80 z M20 4 v60 M80 4 v60',
|
||||
cyl: 'M0 14 a50 10 0 0 1 100 0 v40 a50 10 0 0 1 -100 0 z M0 14 a50 10 0 0 1 100 0',
|
||||
cylinder: 'M0 14 a50 10 0 0 1 100 0 v40 a50 10 0 0 1 -100 0 z M0 14 a50 10 0 0 1 100 0',
|
||||
|
||||
// Logic
|
||||
diamond: 'M50 0 L100 50 L50 100 L0 50 Z',
|
||||
rhombus: 'M50 0 L100 50 L50 100 L0 50 Z',
|
||||
decision: 'M50 0 L100 50 L50 100 L0 50 Z',
|
||||
hexagon: 'M15 4 L85 4 L100 34 L85 64 L15 64 L0 34 Z',
|
||||
parallelogram: 'M15 4 h85 l-15 60 h-85 z',
|
||||
trapezoid: 'M15 4 h70 l15 60 h-100 z',
|
||||
|
||||
// Documents/Data
|
||||
doc: 'M0 0 h80 l20 20 v80 h-100 z M80 0 v20 h20',
|
||||
document: 'M10 4 h80 v45 q0 15 -15 15 h-60 q-15 0 -15 -15 v-45 z M10 4 h80 v45 q-10 10 -40 10 q -30 0 -40 -10',
|
||||
|
||||
// Cloud (Approximation)
|
||||
cloud: 'M20 50 a20 20 0 0 1 20 -30 a25 25 0 0 1 40 10 a20 20 0 0 1 15 35 h-70 z',
|
||||
|
||||
// Circles
|
||||
circle: 'M50 4 a30 30 0 0 1 0 60 a30 30 0 0 1 0 -60',
|
||||
doublecircle: 'M50 4 a30 30 0 0 1 0 60 a30 30 0 0 1 0 -60 M50 9 a25 25 0 0 1 0 50 a25 25 0 0 1 0 -50',
|
||||
|
||||
// New additions based on research
|
||||
note: 'M10 4 h80 v60 h-80 z M70 4 v20 h20', // rough note
|
||||
summary: 'M50 4 a30 30 0 0 1 0 60 a30 30 0 0 1 0 -60 M30 24 l40 40 M30 64 l40 -40', // Crossed circle
|
||||
};
|
||||
|
||||
export const SHAPE_DEFINITIONS: ShapeDefinition[] = [
|
||||
{ id: 'rect', label: 'Rectangle', path: SHAPE_PATHS.rect, category: 'basic' },
|
||||
{ id: 'rounded', label: 'Rounded', path: SHAPE_PATHS.rounded, category: 'basic' },
|
||||
{ id: 'stadium', label: 'Terminal', path: SHAPE_PATHS.stadium, category: 'basic' },
|
||||
{ id: 'subroutine', label: 'Subroutine', path: SHAPE_PATHS.subroutine, category: 'basic' },
|
||||
{ id: 'circle', label: 'Circle', path: SHAPE_PATHS.circle, category: 'basic' },
|
||||
|
||||
{ id: 'diamond', label: 'Decision', path: SHAPE_PATHS.diamond, category: 'logic' },
|
||||
{ id: 'hexagon', label: 'Prepare', path: SHAPE_PATHS.hexagon, category: 'logic' },
|
||||
{ id: 'parallelogram', label: 'Input/Output', path: SHAPE_PATHS.parallelogram, category: 'logic' },
|
||||
|
||||
{ id: 'cyl', label: 'Database', path: SHAPE_PATHS.cyl, category: 'data' },
|
||||
{ id: 'doc', label: 'Document', path: SHAPE_PATHS.doc, category: 'data' },
|
||||
{ id: 'cloud', label: 'Cloud', path: SHAPE_PATHS.cloud, category: 'data' },
|
||||
|
||||
{ id: 'note', label: 'Note', path: SHAPE_PATHS.note, category: 'other' },
|
||||
];
|
||||
// SVG Paths for Mermaid Shapes
|
||||
// Based on Mermaid v11.3.0+ syntax
|
||||
// Syntax: node@{ shape: shapeName }
|
||||
|
||||
export interface ShapeDefinition {
|
||||
id: string;
|
||||
label: string;
|
||||
path: string;
|
||||
category: 'basic' | 'logic' | 'data' | 'other';
|
||||
}
|
||||
|
||||
export const SHAPE_PATHS: Record<string, string> = {
|
||||
// Basic
|
||||
rect: 'M0 4 h100 v60 h-100 z',
|
||||
rounded: 'M10 4 h80 a10 10 0 0 1 10 10 v40 a10 10 0 0 1 -10 10 h-80 a10 10 0 0 1 -10 -10 v-40 a10 10 0 0 1 10 -10 z',
|
||||
stadium: 'M20 4 h60 a20 20 0 0 1 20 20 v20 a20 20 0 0 1 -20 20 h-60 a20 20 0 0 1 -20 -20 v-20 a20 20 0 0 1 20 -20 z',
|
||||
subroutine: 'M10 4 h80 v60 h-80 z M20 4 v60 M80 4 v60',
|
||||
cyl: 'M0 14 a50 10 0 0 1 100 0 v40 a50 10 0 0 1 -100 0 z M0 14 a50 10 0 0 1 100 0',
|
||||
cylinder: 'M0 14 a50 10 0 0 1 100 0 v40 a50 10 0 0 1 -100 0 z M0 14 a50 10 0 0 1 100 0',
|
||||
|
||||
// Logic
|
||||
diamond: 'M50 0 L100 50 L50 100 L0 50 Z',
|
||||
rhombus: 'M50 0 L100 50 L50 100 L0 50 Z',
|
||||
decision: 'M50 0 L100 50 L50 100 L0 50 Z',
|
||||
hexagon: 'M15 4 L85 4 L100 34 L85 64 L15 64 L0 34 Z',
|
||||
parallelogram: 'M15 4 h85 l-15 60 h-85 z',
|
||||
trapezoid: 'M15 4 h70 l15 60 h-100 z',
|
||||
|
||||
// Documents/Data
|
||||
doc: 'M0 0 h80 l20 20 v80 h-100 z M80 0 v20 h20',
|
||||
document: 'M10 4 h80 v45 q0 15 -15 15 h-60 q-15 0 -15 -15 v-45 z M10 4 h80 v45 q-10 10 -40 10 q -30 0 -40 -10',
|
||||
|
||||
// Cloud (Approximation)
|
||||
cloud: 'M20 50 a20 20 0 0 1 20 -30 a25 25 0 0 1 40 10 a20 20 0 0 1 15 35 h-70 z',
|
||||
|
||||
// Circles
|
||||
circle: 'M50 4 a30 30 0 0 1 0 60 a30 30 0 0 1 0 -60',
|
||||
doublecircle: 'M50 4 a30 30 0 0 1 0 60 a30 30 0 0 1 0 -60 M50 9 a25 25 0 0 1 0 50 a25 25 0 0 1 0 -50',
|
||||
|
||||
// New additions based on research
|
||||
note: 'M10 4 h80 v60 h-80 z M70 4 v20 h20', // rough note
|
||||
summary: 'M50 4 a30 30 0 0 1 0 60 a30 30 0 0 1 0 -60 M30 24 l40 40 M30 64 l40 -40', // Crossed circle
|
||||
};
|
||||
|
||||
export const SHAPE_DEFINITIONS: ShapeDefinition[] = [
|
||||
{ id: 'rect', label: 'Rectangle', path: SHAPE_PATHS.rect, category: 'basic' },
|
||||
{ id: 'rounded', label: 'Rounded', path: SHAPE_PATHS.rounded, category: 'basic' },
|
||||
{ id: 'stadium', label: 'Terminal', path: SHAPE_PATHS.stadium, category: 'basic' },
|
||||
{ id: 'subroutine', label: 'Subroutine', path: SHAPE_PATHS.subroutine, category: 'basic' },
|
||||
{ id: 'circle', label: 'Circle', path: SHAPE_PATHS.circle, category: 'basic' },
|
||||
|
||||
{ id: 'diamond', label: 'Decision', path: SHAPE_PATHS.diamond, category: 'logic' },
|
||||
{ id: 'hexagon', label: 'Prepare', path: SHAPE_PATHS.hexagon, category: 'logic' },
|
||||
{ id: 'parallelogram', label: 'Input/Output', path: SHAPE_PATHS.parallelogram, category: 'logic' },
|
||||
|
||||
{ id: 'cyl', label: 'Database', path: SHAPE_PATHS.cyl, category: 'data' },
|
||||
{ id: 'doc', label: 'Document', path: SHAPE_PATHS.doc, category: 'data' },
|
||||
{ id: 'cloud', label: 'Cloud', path: SHAPE_PATHS.cloud, category: 'data' },
|
||||
|
||||
{ id: 'note', label: 'Note', path: SHAPE_PATHS.note, category: 'other' },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1,135 +1,124 @@
|
|||
|
||||
import { env, pipeline, RawImage } from '@huggingface/transformers';
|
||||
|
||||
// Configure transformers.js
|
||||
env.allowLocalModels = false;
|
||||
env.useBrowserCache = true;
|
||||
|
||||
export type VisionProgress = {
|
||||
status: string;
|
||||
progress?: number;
|
||||
file?: string;
|
||||
};
|
||||
|
||||
// ViT-GPT2 is the ONLY working model for browser-based image captioning
|
||||
// Other models (BLIP, Florence-2, LLaVA) are not supported by transformers.js
|
||||
const MODEL_ID = 'Xenova/vit-gpt2-image-captioning';
|
||||
|
||||
export class VisionService {
|
||||
private captioner: any = null;
|
||||
private isLoading = false;
|
||||
private isReady = false;
|
||||
|
||||
// Singleton instance
|
||||
private static instance: VisionService;
|
||||
|
||||
public static getInstance(): VisionService {
|
||||
if (!VisionService.instance) {
|
||||
VisionService.instance = new VisionService();
|
||||
}
|
||||
return VisionService.instance;
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
return {
|
||||
isReady: this.isReady,
|
||||
isLoading: this.isLoading,
|
||||
model: MODEL_ID
|
||||
};
|
||||
}
|
||||
|
||||
async initialize(onProgress?: (progress: VisionProgress) => void): Promise<void> {
|
||||
if (this.isReady || this.isLoading) return;
|
||||
|
||||
this.isLoading = true;
|
||||
|
||||
try {
|
||||
console.log('Loading Vision Model...');
|
||||
if (onProgress) onProgress({ status: 'Loading Vision Model...' });
|
||||
|
||||
// Use the pipeline API - much simpler and faster
|
||||
this.captioner = await pipeline('image-to-text', MODEL_ID, {
|
||||
progress_callback: (progress: any) => {
|
||||
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;
|
||||
} 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) {
|
||||
throw new Error('Vision model not loaded. Please initialize it first.');
|
||||
}
|
||||
|
||||
try {
|
||||
// Handle data URL prefix if present
|
||||
const cleanBase64 = imageBase64.includes(',') ? imageBase64 : `data:image/png;base64,${imageBase64}`;
|
||||
|
||||
let image = await RawImage.fromURL(cleanBase64);
|
||||
|
||||
// Keep higher resolution for better detail detection
|
||||
if (image.width > 512 || image.height > 512) {
|
||||
image = await image.resize(512, 512);
|
||||
}
|
||||
|
||||
console.log('Starting enhanced image analysis...');
|
||||
const startTime = performance.now();
|
||||
|
||||
// Run multiple passes for more comprehensive description
|
||||
const results = await Promise.all([
|
||||
// Pass 1: Detailed description
|
||||
this.captioner(image, {
|
||||
max_new_tokens: 150,
|
||||
num_beams: 4, // Beam search for better quality
|
||||
}),
|
||||
// Pass 2: Alternative perspective
|
||||
this.captioner(image, {
|
||||
max_new_tokens: 100,
|
||||
do_sample: true,
|
||||
temperature: 0.7,
|
||||
}),
|
||||
]);
|
||||
|
||||
const endTime = performance.now();
|
||||
console.log(`Vision analysis completed in ${((endTime - startTime) / 1000).toFixed(1)}s`);
|
||||
|
||||
// Combine descriptions for richer output
|
||||
const caption1 = results[0]?.[0]?.generated_text || '';
|
||||
const caption2 = results[1]?.[0]?.generated_text || '';
|
||||
|
||||
// If both are similar, use just one; otherwise combine
|
||||
if (caption1.toLowerCase().includes(caption2.toLowerCase().substring(0, 20)) ||
|
||||
caption2.toLowerCase().includes(caption1.toLowerCase().substring(0, 20))) {
|
||||
return caption1.length > caption2.length ? caption1 : caption2;
|
||||
}
|
||||
|
||||
const combined = `${caption1}. Additionally: ${caption2}`;
|
||||
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();
|
||||
|
||||
import { env, AutoProcessor, AutoModel, RawImage } from '@huggingface/transformers';
|
||||
|
||||
// Configure transformers.js
|
||||
env.allowLocalModels = false;
|
||||
env.useBrowserCache = true;
|
||||
|
||||
export type VisionProgress = {
|
||||
status: string;
|
||||
progress?: number;
|
||||
file?: string;
|
||||
};
|
||||
|
||||
// We use Florence-2-base for a good balance of speed and accuracy (~200MB - 400MB)
|
||||
// 'onnx-community/Florence-2-base-ft' is the modern standard for Transformers.js v3.
|
||||
const MODEL_ID = 'onnx-community/Florence-2-base-ft';
|
||||
|
||||
export class VisionService {
|
||||
private model: any = null;
|
||||
private processor: any = null;
|
||||
private isLoading = false;
|
||||
private isReady = false;
|
||||
|
||||
// Singleton instance
|
||||
private static instance: VisionService;
|
||||
|
||||
public static getInstance(): VisionService {
|
||||
if (!VisionService.instance) {
|
||||
VisionService.instance = new VisionService();
|
||||
}
|
||||
return VisionService.instance;
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
return {
|
||||
isReady: this.isReady,
|
||||
isLoading: this.isLoading,
|
||||
model: MODEL_ID
|
||||
};
|
||||
}
|
||||
|
||||
async initialize(onProgress?: (progress: VisionProgress) => void): Promise<void> {
|
||||
if (this.isReady || this.isLoading) return;
|
||||
|
||||
this.isLoading = true;
|
||||
|
||||
try {
|
||||
console.log('Loading Vision Model...');
|
||||
if (onProgress) onProgress({ status: 'Loading Processor...' });
|
||||
|
||||
this.processor = await AutoProcessor.from_pretrained(MODEL_ID);
|
||||
|
||||
if (onProgress) onProgress({ status: 'Loading Model (this may take a while)...' });
|
||||
|
||||
this.model = await AutoModel.from_pretrained(MODEL_ID, {
|
||||
progress_callback: (progress: any) => {
|
||||
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;
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyzes an image (Base64 or URL) and returns a detailed description.
|
||||
* We use the '<MORE_DETAILED_CAPTION>' task for Florence-2.
|
||||
*/
|
||||
async analyzeImage(imageBase64: string): Promise<string> {
|
||||
if (!this.isReady) {
|
||||
throw new Error('Vision model not loaded. Please initialize it first.');
|
||||
}
|
||||
|
||||
try {
|
||||
// Handle data URL prefix if present
|
||||
const cleanBase64 = imageBase64.includes(',') ? imageBase64 : `data:image/png;base64,${imageBase64}`;
|
||||
|
||||
const image = await RawImage.fromURL(cleanBase64);
|
||||
|
||||
// Task: Detailed Captioning is best for understanding diagrams
|
||||
const text = '<MORE_DETAILED_CAPTION>';
|
||||
const inputs = await this.processor(image, text);
|
||||
|
||||
const generatedIds = await this.model.generate({
|
||||
...inputs,
|
||||
max_new_tokens: 512, // Sufficient for a description
|
||||
});
|
||||
|
||||
const generatedText = this.processor.batch_decode(generatedIds, {
|
||||
skip_special_tokens: false,
|
||||
})[0];
|
||||
|
||||
// Post-process to extract the caption
|
||||
// Florence-2 output format usually includes the task token
|
||||
const parsedAnswer = this.processor.post_process_generation(
|
||||
generatedText,
|
||||
text,
|
||||
image.size
|
||||
);
|
||||
|
||||
// Access the dictionary result. For CAPTION tasks, it's usually under '<MORE_DETAILED_CAPTION>' or similar key
|
||||
// Ideally post_process_generation returns { '<MORE_DETAILED_CAPTION>': "Description..." }
|
||||
return parsedAnswer['<MORE_DETAILED_CAPTION>'] || typeof parsedAnswer === 'string' ? parsedAnswer : JSON.stringify(parsedAnswer);
|
||||
|
||||
} 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
|
|
@ -1,116 +1,106 @@
|
|||
import { CreateMLCEngine, MLCEngine } from "@mlc-ai/web-llm";
|
||||
import type { InitProgressCallback } from "@mlc-ai/web-llm";
|
||||
|
||||
export type WebLlmProgress = {
|
||||
progress: number;
|
||||
text: string;
|
||||
timeElapsed: number;
|
||||
};
|
||||
|
||||
// Qwen3-0.6B is fast and works well with simple Mermaid generation prompts
|
||||
const DEFAULT_MODEL = "Qwen3-0.6B-q4f32_1-MLC";
|
||||
|
||||
export class WebLlmService {
|
||||
private engine: MLCEngine | null = null;
|
||||
private isLoading = false;
|
||||
private isReady = false;
|
||||
|
||||
// Track GPU Availability
|
||||
public static async isSystemSupported(): Promise<boolean | null> {
|
||||
// @ts-ignore
|
||||
if (!navigator.gpu) {
|
||||
console.warn('WebGPU not supported in this environment');
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
// @ts-ignore
|
||||
const adapter = await navigator.gpu.requestAdapter();
|
||||
return !!adapter;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async initialize(onProgress?: (progress: WebLlmProgress) => void): Promise<void> {
|
||||
if (this.engine || this.isLoading) return;
|
||||
|
||||
this.isLoading = true;
|
||||
|
||||
const initProgressCallback: InitProgressCallback = (report) => {
|
||||
if (onProgress) {
|
||||
// Parse the native report which is basically just text and percentage
|
||||
// Example: "Loading model 10% [ cached ]" or "Fetching param shard 1/4"
|
||||
const progressMatch = report.text.match(/(\d+)%/);
|
||||
const progress = progressMatch ? parseInt(progressMatch[1], 10) : 0;
|
||||
|
||||
onProgress({
|
||||
progress,
|
||||
text: report.text,
|
||||
timeElapsed: report.timeElapsed
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
console.log('Initializing WebLLM Engine...');
|
||||
this.engine = await CreateMLCEngine(
|
||||
DEFAULT_MODEL,
|
||||
{ initProgressCallback }
|
||||
);
|
||||
this.isReady = true;
|
||||
console.log('WebLLM Engine Ready');
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize WebLLM:', error);
|
||||
this.engine = null;
|
||||
throw error;
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async chat(messages: { role: 'system' | 'user' | 'assistant', content: string }[]): Promise<AsyncGenerator<string>> {
|
||||
if (!this.engine || !this.isReady) {
|
||||
throw new Error("WebLLM Engine not initialized. Please load the model first.");
|
||||
}
|
||||
|
||||
console.log('WebLLM: Creating completion...');
|
||||
const startTime = performance.now();
|
||||
const completion = await this.engine.chat.completions.create({
|
||||
messages,
|
||||
stream: true,
|
||||
temperature: 0, // Deterministic output for code
|
||||
max_tokens: 512, // Mermaid code is compact
|
||||
top_p: 0.9, // Faster sampling
|
||||
repetition_penalty: 1.1, // Avoid repetitive output
|
||||
});
|
||||
console.log('WebLLM: Completion created, streaming...');
|
||||
|
||||
// Create a generator to stream chunks easily
|
||||
async function* streamGenerator() {
|
||||
let tokenCount = 0;
|
||||
for await (const chunk of completion) {
|
||||
const content = chunk.choices[0]?.delta?.content || "";
|
||||
if (content) {
|
||||
tokenCount++;
|
||||
if (tokenCount === 1) console.log('WebLLM: First token received');
|
||||
yield content;
|
||||
}
|
||||
}
|
||||
const endTime = performance.now();
|
||||
console.log(`WebLLM: Generation complete (${tokenCount} tokens, ${((endTime - startTime) / 1000).toFixed(1)}s)`);
|
||||
}
|
||||
|
||||
return streamGenerator();
|
||||
}
|
||||
|
||||
getStatus(): { isReady: boolean; isLoading: boolean; model: string } {
|
||||
return {
|
||||
isReady: this.isReady,
|
||||
isLoading: this.isLoading,
|
||||
model: DEFAULT_MODEL
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const webLlmService = new WebLlmService();
|
||||
import { CreateMLCEngine, MLCEngine } from "@mlc-ai/web-llm";
|
||||
import type { InitProgressCallback } from "@mlc-ai/web-llm";
|
||||
|
||||
export type WebLlmProgress = {
|
||||
progress: number;
|
||||
text: string;
|
||||
timeElapsed: number;
|
||||
};
|
||||
|
||||
// Latest "Tiny" model with high instruction adherence
|
||||
const DEFAULT_MODEL = "Llama-3.2-1B-Instruct-q4f32_1-MLC";
|
||||
|
||||
export class WebLlmService {
|
||||
private engine: MLCEngine | null = null;
|
||||
private isLoading = false;
|
||||
private isReady = false;
|
||||
|
||||
// Track GPU Availability
|
||||
public static async isSystemSupported(): Promise<boolean | null> {
|
||||
// @ts-ignore
|
||||
if (!navigator.gpu) {
|
||||
console.warn('WebGPU not supported in this environment');
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
// @ts-ignore
|
||||
const adapter = await navigator.gpu.requestAdapter();
|
||||
return !!adapter;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async initialize(onProgress?: (progress: WebLlmProgress) => void): Promise<void> {
|
||||
if (this.engine || this.isLoading) return;
|
||||
|
||||
this.isLoading = true;
|
||||
|
||||
const initProgressCallback: InitProgressCallback = (report) => {
|
||||
if (onProgress) {
|
||||
// Parse the native report which is basically just text and percentage
|
||||
// Example: "Loading model 10% [ cached ]" or "Fetching param shard 1/4"
|
||||
const progressMatch = report.text.match(/(\d+)%/);
|
||||
const progress = progressMatch ? parseInt(progressMatch[1], 10) : 0;
|
||||
|
||||
onProgress({
|
||||
progress,
|
||||
text: report.text,
|
||||
timeElapsed: report.timeElapsed
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
console.log('Initializing WebLLM Engine...');
|
||||
this.engine = await CreateMLCEngine(
|
||||
DEFAULT_MODEL,
|
||||
{ initProgressCallback }
|
||||
);
|
||||
this.isReady = true;
|
||||
console.log('WebLLM Engine Ready');
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize WebLLM:', error);
|
||||
this.engine = null;
|
||||
throw error;
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async chat(messages: { role: 'system' | 'user' | 'assistant', content: string }[]): Promise<AsyncGenerator<string>> {
|
||||
if (!this.engine || !this.isReady) {
|
||||
throw new Error("WebLLM Engine not initialized. Please load the model first.");
|
||||
}
|
||||
|
||||
const completion = await this.engine.chat.completions.create({
|
||||
messages,
|
||||
stream: true,
|
||||
temperature: 0.1, // Low temp for code/logic generation
|
||||
max_tokens: 4096, // Sufficient for diagrams
|
||||
});
|
||||
|
||||
// Create a generator to stream chunks easily
|
||||
async function* streamGenerator() {
|
||||
for await (const chunk of completion) {
|
||||
const content = chunk.choices[0]?.delta?.content || "";
|
||||
if (content) {
|
||||
yield content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return streamGenerator();
|
||||
}
|
||||
|
||||
getStatus(): { isReady: boolean; isLoading: boolean; model: string } {
|
||||
return {
|
||||
isReady: this.isReady,
|
||||
isLoading: this.isLoading,
|
||||
model: DEFAULT_MODEL
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const webLlmService = new WebLlmService();
|
||||
|
|
|
|||
|
|
@ -165,6 +165,42 @@ export function Editor() {
|
|||
</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>
|
||||
|
||||
{/* Right Inspector Panel - Sidebar on desktop, Sheet on mobile */}
|
||||
|
|
|
|||
|
|
@ -1,132 +1,132 @@
|
|||
import { useState, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeft, Search, Trash2, Activity, Clock,
|
||||
ArrowRight, Zap, Filter
|
||||
} from 'lucide-react';
|
||||
import { useFlowStore } from '../store';
|
||||
|
||||
export function History() {
|
||||
const navigate = useNavigate();
|
||||
const { savedDiagrams, deleteDiagram } = useFlowStore();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const filteredDiagrams = useMemo(() => {
|
||||
return [...savedDiagrams]
|
||||
.filter(d => d.name.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
.reverse();
|
||||
}, [savedDiagrams, searchQuery]);
|
||||
|
||||
const handleDelete = (e: React.MouseEvent, id: string) => {
|
||||
e.stopPropagation();
|
||||
if (confirm('Are you sure you want to delete this intelligence draft?')) {
|
||||
deleteDiagram(id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-void text-primary overflow-hidden font-sans relative flex flex-col">
|
||||
{/* Ambient Background */}
|
||||
<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 bottom-0 left-0 w-[40vw] h-[40vh] bg-indigo-500/5 blur-[120px] rounded-full" />
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<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">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<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">
|
||||
<Zap className="w-4 h-4 text-white fill-white/20" />
|
||||
</div>
|
||||
<h1 className="text-lg font-display font-black tracking-tight">Intelligence Archive</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 flex-1 max-w-xl mx-12">
|
||||
<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" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</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">
|
||||
<Filter className="w-3 h-3" />
|
||||
<span>{filteredDiagrams.length} Results</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content List */}
|
||||
<main className="flex-1 overflow-y-auto p-12 relative z-10 hide-scrollbar">
|
||||
{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">
|
||||
{filteredDiagrams.map((diagram) => (
|
||||
<div
|
||||
key={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"
|
||||
>
|
||||
<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">
|
||||
<Activity className="w-5 h-5 text-blue-500 group-hover:scale-110 transition-transform" />
|
||||
</div>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 mb-6">
|
||||
<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">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>{new Date(diagram.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{diagram.nodes.filter(n => n.type !== 'group').length} Entities
|
||||
</span>
|
||||
<div className="flex items-center gap-1 text-[9px] font-black uppercase text-tertiary group-hover:text-blue-500 transition-colors">
|
||||
<span>Restore</span>
|
||||
<ArrowRight className="w-3 h-3 group-hover:translate-x-1 transition-transform" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<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">
|
||||
<Search className="w-8 h-8 text-slate-500" />
|
||||
</div>
|
||||
<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'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* 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">
|
||||
<p className="text-[9px] font-black text-tertiary uppercase tracking-[0.5em]">
|
||||
Active Persistence Engine • v3.1.2 • Total Capacity: {savedDiagrams.length}/100
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeft, Search, Trash2, Activity, Clock,
|
||||
ArrowRight, Zap, Filter
|
||||
} from 'lucide-react';
|
||||
import { useFlowStore } from '../store';
|
||||
|
||||
export function History() {
|
||||
const navigate = useNavigate();
|
||||
const { savedDiagrams, deleteDiagram } = useFlowStore();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const filteredDiagrams = useMemo(() => {
|
||||
return [...savedDiagrams]
|
||||
.filter(d => d.name.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
.reverse();
|
||||
}, [savedDiagrams, searchQuery]);
|
||||
|
||||
const handleDelete = (e: React.MouseEvent, id: string) => {
|
||||
e.stopPropagation();
|
||||
if (confirm('Are you sure you want to delete this intelligence draft?')) {
|
||||
deleteDiagram(id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-void text-primary overflow-hidden font-sans relative flex flex-col">
|
||||
{/* Ambient Background */}
|
||||
<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 bottom-0 left-0 w-[40vw] h-[40vh] bg-indigo-500/5 blur-[120px] rounded-full" />
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<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">
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<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">
|
||||
<Zap className="w-4 h-4 text-white fill-white/20" />
|
||||
</div>
|
||||
<h1 className="text-lg font-display font-black tracking-tight">Intelligence Archive</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 flex-1 max-w-xl mx-12">
|
||||
<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" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</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">
|
||||
<Filter className="w-3 h-3" />
|
||||
<span>{filteredDiagrams.length} Results</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content List */}
|
||||
<main className="flex-1 overflow-y-auto p-12 relative z-10 hide-scrollbar">
|
||||
{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">
|
||||
{filteredDiagrams.map((diagram) => (
|
||||
<div
|
||||
key={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"
|
||||
>
|
||||
<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">
|
||||
<Activity className="w-5 h-5 text-blue-500 group-hover:scale-110 transition-transform" />
|
||||
</div>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 mb-6">
|
||||
<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">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>{new Date(diagram.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{diagram.nodes.filter(n => n.type !== 'group').length} Entities
|
||||
</span>
|
||||
<div className="flex items-center gap-1 text-[9px] font-black uppercase text-tertiary group-hover:text-blue-500 transition-colors">
|
||||
<span>Restore</span>
|
||||
<ArrowRight className="w-3 h-3 group-hover:translate-x-1 transition-transform" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<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">
|
||||
<Search className="w-8 h-8 text-slate-500" />
|
||||
</div>
|
||||
<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'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* 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">
|
||||
<p className="text-[9px] font-black text-tertiary uppercase tracking-[0.5em]">
|
||||
Active Persistence Engine • v3.1.2 • Total Capacity: {savedDiagrams.length}/100
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,26 @@
|
|||
// Basic types needed for plugins
|
||||
|
||||
|
||||
export interface ToolbarItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: any; // Lucide icon component
|
||||
onClick: () => void;
|
||||
position?: 'left' | 'center' | 'right'; // Default 'right' (actions)
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
export interface PluginContext {
|
||||
registerToolbarItem: (item: ToolbarItem) => void;
|
||||
// Future extensions:
|
||||
// registerSidebarTab: (tab: SidebarTab) => void;
|
||||
// registerNodeAction: (action: NodeAction) => void;
|
||||
}
|
||||
|
||||
export interface Plugin {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
init: (context: PluginContext) => void;
|
||||
cleanup?: () => void;
|
||||
}
|
||||
// Basic types needed for plugins
|
||||
|
||||
|
||||
export interface ToolbarItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: any; // Lucide icon component
|
||||
onClick: () => void;
|
||||
position?: 'left' | 'center' | 'right'; // Default 'right' (actions)
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
export interface PluginContext {
|
||||
registerToolbarItem: (item: ToolbarItem) => void;
|
||||
// Future extensions:
|
||||
// registerSidebarTab: (tab: SidebarTab) => void;
|
||||
// registerNodeAction: (action: NodeAction) => void;
|
||||
}
|
||||
|
||||
export interface Plugin {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
init: (context: PluginContext) => void;
|
||||
cleanup?: () => void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,63 +1,63 @@
|
|||
import { create } from 'zustand';
|
||||
import type { Plugin, PluginContext, ToolbarItem } from '../plugins/types';
|
||||
|
||||
interface PluginState {
|
||||
plugins: Record<string, Plugin>;
|
||||
toolbarItems: ToolbarItem[];
|
||||
|
||||
// Actions
|
||||
registerPlugin: (plugin: Plugin) => void;
|
||||
unregisterPlugin: (pluginId: string) => void;
|
||||
|
||||
// Internal API for Plugins
|
||||
_registerToolbarItem: (item: ToolbarItem) => void;
|
||||
}
|
||||
|
||||
export const usePluginStore = create<PluginState>((set, get) => ({
|
||||
plugins: {},
|
||||
toolbarItems: [],
|
||||
|
||||
registerPlugin: (plugin) => {
|
||||
if (get().plugins[plugin.id]) {
|
||||
console.warn(`Plugin ${plugin.id} is already registered.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create Context
|
||||
const context: PluginContext = {
|
||||
registerToolbarItem: (item) => get()._registerToolbarItem(item),
|
||||
};
|
||||
|
||||
// Init Plugin
|
||||
try {
|
||||
plugin.init(context);
|
||||
set((state) => ({
|
||||
plugins: { ...state.plugins, [plugin.id]: plugin }
|
||||
}));
|
||||
console.log(`Plugin ${plugin.name} loaded.`);
|
||||
} catch (e) {
|
||||
console.error(`Failed to initialize plugin ${plugin.id}:`, e);
|
||||
}
|
||||
},
|
||||
|
||||
unregisterPlugin: (pluginId) => {
|
||||
const plugin = get().plugins[pluginId];
|
||||
if (!plugin) return;
|
||||
|
||||
if (plugin.cleanup) plugin.cleanup();
|
||||
|
||||
set((state) => {
|
||||
const { [pluginId]: removed, ...rest } = state.plugins;
|
||||
// Note: complex cleanup of UI items registered by this plugin would ideally happen here.
|
||||
// For MVP, we might just clear all or need items to be tagged with pluginId.
|
||||
// Improvement: ToolbarItem should have pluginId.
|
||||
return { plugins: rest };
|
||||
});
|
||||
},
|
||||
|
||||
_registerToolbarItem: (item) => {
|
||||
set((state) => ({
|
||||
toolbarItems: [...state.toolbarItems, item]
|
||||
}));
|
||||
}
|
||||
}));
|
||||
import { create } from 'zustand';
|
||||
import type { Plugin, PluginContext, ToolbarItem } from '../plugins/types';
|
||||
|
||||
interface PluginState {
|
||||
plugins: Record<string, Plugin>;
|
||||
toolbarItems: ToolbarItem[];
|
||||
|
||||
// Actions
|
||||
registerPlugin: (plugin: Plugin) => void;
|
||||
unregisterPlugin: (pluginId: string) => void;
|
||||
|
||||
// Internal API for Plugins
|
||||
_registerToolbarItem: (item: ToolbarItem) => void;
|
||||
}
|
||||
|
||||
export const usePluginStore = create<PluginState>((set, get) => ({
|
||||
plugins: {},
|
||||
toolbarItems: [],
|
||||
|
||||
registerPlugin: (plugin) => {
|
||||
if (get().plugins[plugin.id]) {
|
||||
console.warn(`Plugin ${plugin.id} is already registered.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create Context
|
||||
const context: PluginContext = {
|
||||
registerToolbarItem: (item) => get()._registerToolbarItem(item),
|
||||
};
|
||||
|
||||
// Init Plugin
|
||||
try {
|
||||
plugin.init(context);
|
||||
set((state) => ({
|
||||
plugins: { ...state.plugins, [plugin.id]: plugin }
|
||||
}));
|
||||
console.log(`Plugin ${plugin.name} loaded.`);
|
||||
} catch (e) {
|
||||
console.error(`Failed to initialize plugin ${plugin.id}:`, e);
|
||||
}
|
||||
},
|
||||
|
||||
unregisterPlugin: (pluginId) => {
|
||||
const plugin = get().plugins[pluginId];
|
||||
if (!plugin) return;
|
||||
|
||||
if (plugin.cleanup) plugin.cleanup();
|
||||
|
||||
set((state) => {
|
||||
const { [pluginId]: removed, ...rest } = state.plugins;
|
||||
// Note: complex cleanup of UI items registered by this plugin would ideally happen here.
|
||||
// For MVP, we might just clear all or need items to be tagged with pluginId.
|
||||
// Improvement: ToolbarItem should have pluginId.
|
||||
return { plugins: rest };
|
||||
});
|
||||
},
|
||||
|
||||
_registerToolbarItem: (item) => {
|
||||
set((state) => ({
|
||||
toolbarItems: [...state.toolbarItems, item]
|
||||
}));
|
||||
}
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -416,4 +416,36 @@
|
|||
|
||||
.animate-fade-in {
|
||||
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;
|
||||
}
|
||||
|
|
@ -1,42 +1,42 @@
|
|||
import '@testing-library/jest-dom';
|
||||
import * as matchers from '@testing-library/jest-dom/matchers';
|
||||
import { expect } from 'vitest';
|
||||
|
||||
expect.extend(matchers);
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = (function () {
|
||||
let store: Record<string, string> = {};
|
||||
return {
|
||||
getItem: function (key: string) {
|
||||
return store[key] || null;
|
||||
},
|
||||
setItem: function (key: string, value: string) {
|
||||
store[key] = value.toString();
|
||||
},
|
||||
clear: function () {
|
||||
store = {};
|
||||
},
|
||||
removeItem: function (key: string) {
|
||||
delete store[key];
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: localStorageMock
|
||||
});
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => { },
|
||||
removeListener: () => { },
|
||||
addEventListener: () => { },
|
||||
removeEventListener: () => { },
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
});
|
||||
import '@testing-library/jest-dom';
|
||||
import * as matchers from '@testing-library/jest-dom/matchers';
|
||||
import { expect } from 'vitest';
|
||||
|
||||
expect.extend(matchers);
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = (function () {
|
||||
let store: Record<string, string> = {};
|
||||
return {
|
||||
getItem: function (key: string) {
|
||||
return store[key] || null;
|
||||
},
|
||||
setItem: function (key: string, value: string) {
|
||||
store[key] = value.toString();
|
||||
},
|
||||
clear: function () {
|
||||
store = {};
|
||||
},
|
||||
removeItem: function (key: string) {
|
||||
delete store[key];
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: localStorageMock
|
||||
});
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => { },
|
||||
removeListener: () => { },
|
||||
addEventListener: () => { },
|
||||
removeEventListener: () => { },
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,105 +1,105 @@
|
|||
export interface NodePosition {
|
||||
x: number;
|
||||
y: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export interface EdgeConnection {
|
||||
source: string;
|
||||
target: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface LayoutMetrics {
|
||||
nodeCount: number;
|
||||
edgeCount: number;
|
||||
edgeCrossings: number;
|
||||
nodeDensity: number;
|
||||
averageNodeSpacing: number;
|
||||
visualComplexity: number; // 0-100 scale
|
||||
aspectRatio: number;
|
||||
}
|
||||
|
||||
export interface VisualIssue {
|
||||
type: 'edge-crossing' | 'overlap' | 'poor-spacing' | 'unclear-flow' | 'inefficient-layout' | 'style-consistency';
|
||||
severity: 'low' | 'medium' | 'high';
|
||||
description: string;
|
||||
affectedNodes?: string[];
|
||||
affectedEdges?: string[];
|
||||
suggestedFix?: string;
|
||||
}
|
||||
|
||||
export interface LayoutSuggestion {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
type: 'spacing' | 'grouping' | 'routing' | 'hierarchy' | 'style' | 'node-shape' | 'node-color-semantic';
|
||||
impact: 'low' | 'medium' | 'high';
|
||||
estimatedImprovement: number; // percentage
|
||||
beforeState: {
|
||||
metrics: LayoutMetrics;
|
||||
issues: VisualIssue[];
|
||||
};
|
||||
afterState: {
|
||||
metrics: LayoutMetrics;
|
||||
estimatedIssues: VisualIssue[];
|
||||
};
|
||||
implementation: {
|
||||
nodePositions: Record<string, NodePosition>;
|
||||
edgeRouting?: Record<string, any>;
|
||||
styleChanges?: Record<string, any>;
|
||||
description?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface VisualOrganizationRequest {
|
||||
nodes: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
type: string;
|
||||
position: NodePosition;
|
||||
metadata?: any;
|
||||
}>;
|
||||
edges: EdgeConnection[];
|
||||
currentLayout?: {
|
||||
direction: 'TB' | 'LR' | 'BT' | 'RL';
|
||||
spacing: number;
|
||||
};
|
||||
preferences?: {
|
||||
priority: 'readability' | 'compactness' | 'flow-clarity';
|
||||
groupSimilarNodes: boolean;
|
||||
minimizeCrossings: boolean;
|
||||
preserveUserLayout: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface VisualOrganizationResponse {
|
||||
success: boolean;
|
||||
suggestions: LayoutSuggestion[];
|
||||
summary: {
|
||||
totalIssues: number;
|
||||
criticalIssues: number;
|
||||
potentialImprovement: number;
|
||||
recommendedAction: string;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface VisualAnalysisResult {
|
||||
metrics: LayoutMetrics;
|
||||
issues: VisualIssue[];
|
||||
strengths: string[];
|
||||
recommendations: string[];
|
||||
}
|
||||
|
||||
export interface AISuggestionPrompt {
|
||||
systemPrompt: string;
|
||||
userPrompt: string;
|
||||
context: {
|
||||
currentMetrics: LayoutMetrics;
|
||||
identifiedIssues: VisualIssue[];
|
||||
nodeTypes: string[];
|
||||
edgeTypes: string[];
|
||||
};
|
||||
}
|
||||
export interface NodePosition {
|
||||
x: number;
|
||||
y: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export interface EdgeConnection {
|
||||
source: string;
|
||||
target: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface LayoutMetrics {
|
||||
nodeCount: number;
|
||||
edgeCount: number;
|
||||
edgeCrossings: number;
|
||||
nodeDensity: number;
|
||||
averageNodeSpacing: number;
|
||||
visualComplexity: number; // 0-100 scale
|
||||
aspectRatio: number;
|
||||
}
|
||||
|
||||
export interface VisualIssue {
|
||||
type: 'edge-crossing' | 'overlap' | 'poor-spacing' | 'unclear-flow' | 'inefficient-layout' | 'style-consistency';
|
||||
severity: 'low' | 'medium' | 'high';
|
||||
description: string;
|
||||
affectedNodes?: string[];
|
||||
affectedEdges?: string[];
|
||||
suggestedFix?: string;
|
||||
}
|
||||
|
||||
export interface LayoutSuggestion {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
type: 'spacing' | 'grouping' | 'routing' | 'hierarchy' | 'style' | 'node-shape' | 'node-color-semantic';
|
||||
impact: 'low' | 'medium' | 'high';
|
||||
estimatedImprovement: number; // percentage
|
||||
beforeState: {
|
||||
metrics: LayoutMetrics;
|
||||
issues: VisualIssue[];
|
||||
};
|
||||
afterState: {
|
||||
metrics: LayoutMetrics;
|
||||
estimatedIssues: VisualIssue[];
|
||||
};
|
||||
implementation: {
|
||||
nodePositions: Record<string, NodePosition>;
|
||||
edgeRouting?: Record<string, any>;
|
||||
styleChanges?: Record<string, any>;
|
||||
description?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface VisualOrganizationRequest {
|
||||
nodes: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
type: string;
|
||||
position: NodePosition;
|
||||
metadata?: any;
|
||||
}>;
|
||||
edges: EdgeConnection[];
|
||||
currentLayout?: {
|
||||
direction: 'TB' | 'LR' | 'BT' | 'RL';
|
||||
spacing: number;
|
||||
};
|
||||
preferences?: {
|
||||
priority: 'readability' | 'compactness' | 'flow-clarity';
|
||||
groupSimilarNodes: boolean;
|
||||
minimizeCrossings: boolean;
|
||||
preserveUserLayout: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface VisualOrganizationResponse {
|
||||
success: boolean;
|
||||
suggestions: LayoutSuggestion[];
|
||||
summary: {
|
||||
totalIssues: number;
|
||||
criticalIssues: number;
|
||||
potentialImprovement: number;
|
||||
recommendedAction: string;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface VisualAnalysisResult {
|
||||
metrics: LayoutMetrics;
|
||||
issues: VisualIssue[];
|
||||
strengths: string[];
|
||||
recommendations: string[];
|
||||
}
|
||||
|
||||
export interface AISuggestionPrompt {
|
||||
systemPrompt: string;
|
||||
userPrompt: string;
|
||||
context: {
|
||||
currentMetrics: LayoutMetrics;
|
||||
identifiedIssues: VisualIssue[];
|
||||
nodeTypes: string[];
|
||||
edgeTypes: string[];
|
||||
};
|
||||
}
|
||||
|
|
|
|||
50
todo.md
50
todo.md
|
|
@ -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
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
/// <reference types="vitest" />
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: './src/test/setup.ts',
|
||||
css: true,
|
||||
},
|
||||
});
|
||||
/// <reference types="vitest" />
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: './src/test/setup.ts',
|
||||
css: true,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue